David Maus

Unser kleines Geheimnis: Der CachingRecordLoaderDecorator

In meiner Abteilung BMS-Entwicklung & Discovery-Systeme an der SUB Hamburg sind wir für Betrieb und Entwicklung des zentralen Online-Katalogs für das Bibliothekssystem der Universität Hamburg zuständig. Wir verwenden dafür eine stark angepasste Installation der Discovery-Software VuFind und greifen auf den von der Verbundzentrale des gemeinsamen Bibliotheksverbunds (GBV) angebotenen Suchindex K10Plus Zentral zu.

Schon seit längerem haben wir das Problem, dass eine ungünstige Mischung aus hohen Nutzungszahlen, ungünstiger Programmierung und Ressourcenbeschränkungen auf Seiten des Suchindex zu Ausfällen des Recherchewerkzeugs führen.

Da wir auf die Nutzungszahlen kaum und die Ressourcenbeschränkungen nur bedingt Einfluss nehmen können, konzentrieren wir uns darauf, die ungünstige Programmierung zu identifizieren und nach Möglichkeit so zu ändern, dass wir insgesamt weniger Suchanfragen an den Index schicken.

Mit dem Rollout eines Updates im Januar diesen Jahres haben wir – inspiriert von Lukida – unsere jüngste Verbesserung, den CachingRecordLoaderDecorator in Betrieb genommen.

Die Idee ist einfach: Wenn wir eine Suche im Index auslösen, dann erhalten wir eine Trefferliste mit allen Datensätzen, die von der Suchanfrage gefunden wurden. Wenn wir nun von der Trefferliste in die Detailansicht wechseln, dann … schicken wir eine weitere Suchanfrage, um den jeweiligen Datensatz abzurufen.

Was nun, wenn wir die Datensätze aus der Trefferliste zwischenspeichern (cachen) und für die Detailansicht den im Cache gespeicherten Datensatz verwenden? Da wir in VuFind keine Änderungen an den Datensätzen vornehmen und die Datensätze keine Echtzeitdaten tragen, ist das problemlos möglich.

Und so war der CachingRecordLoaderDecorator geboren.

<?phpdeclare(strict_types=1);namespace SUBHH\VuFind\KatalogPlus;use VuFind\Record\Loader;use VuFind\RecordDriver;use VuFindSearch\ParamBag;use VuFindSearch\Service;use VuFindSearch\Response\RecordCollectionInterface;use Laminas\EventManager\EventInterface;use Laminas\EventManager\SharedEventManagerInterface;use Psr\SimpleCache\CacheInterface;use LogicException;use SUBHH\VuFind\Scoreboard\Metrics;final class CachingRecordLoaderDecorator extends Loader{    private Loader $loader;    private CacheInterface $cache;    private Metrics $metrics;    public function __construct (Loader $loader, CacheInterface $cache, Metrics $metrics)    {        $this->metrics = $metrics;        $this->loader = $loader;        $this->cache = $cache;    }    public function attach (SharedEventManagerInterface $events) : void    {        $events->attach('VuFindSearch', Service::EVENT_POST, [$this, 'onSearchPost']);    }    public function onSearchPost (EventInterface $event) : void    {        if ($command = $event->getParam('command')) {            if ($command->isExecuted()) {                if ($result = $command->getResult()) {                    if ($result instanceof RecordCollectionInterface) {                        foreach ($result->getRecords() as $record) {                            if ($record instanceof RecordDriver\AbstractBase) {                                $cacheKey = $this->createCacheKey($record->getSource() ?: DEFAULT_SEARCH_BACKEND, $record->getUniqueId());                                $this->store($cacheKey, $record->getRawData(), 3600);                            }                        }                    }                }            }        }    }    public function load ($id, $source = DEFAULT_SEARCH_BACKEND, $tolerateMissing = false, ParamBag $params = null) : RecordDriver\AbstractBase    {        $cacheKey = $this->createCacheKey($source, $id);        if ($data = $this->cache->get(md5($cacheKey), null)) {            $this->metrics->update('vufind.record.loader.hit', time(), 1);            $record = $this->loader->recordFactory->get('solrmarc');            $record->setRawData($data);            $record->setSourceIdentifier($source);            return $record;        }        $this->metrics->update('vufind.record.loader.miss', time(), 1);        $record = $this->loader->load($id, $source, $tolerateMissing, $params);        if ($record) {            $data = $record->getRawData();            // TTL is a hardcoded value!            //   -- dmaus, 2024-10-29            $this->store($cacheKey, $data, 3600);            return $record;        }        throw new LogicException();    }    public function loadBatchForSource ($ids, $source = DEFAULT_SEARCH_BACKEND, $tolerateBackendExceptions = false, ParamBag $params = null) : array    {        return $this->loader->loadBatchForSource($ids, $source, $tolerateBackendExceptions, $params);    }    public function loadBatch ($ids, $tolerateBackendExceptions = false, $params = []) : array    {        return $this->loader->loadBatch($ids, $tolerateBackendExceptions, $params);    }    public function setCacheContext ($context) : void    {        $this->loader->setCacheContext($context);    }    private function store (string $key, array $data, int $ttl) : void    {        $this->cache->set(md5($key), $data, $ttl);    }    private function createCacheKey (string $sourceId, string $recordId) : string    {        return __CLASS__ . ' ' . $recordId . '@' . $sourceId;    }}

Der CachingRecordLoaderDecorator wird anstelle des VuFind\Record\Loader durch Überschreiben des entsprechenden Schlüssels in der Konfiguration aktiviert. Da VuFind durchgängig die konkrete Implementierung oder eine Subklasse der Implementierung des VuFind\Record\Loader verwendet, ist der CachingRecordLoaderDecorator auch zugleich eine Subklasse des Loaders. Diese Kombination ist vielleicht ungewöhnlich, hilft aber, den Konstruktor der Klasse einfach zu halten.

Und wie gut ist der CachingRecordLoaderDecorator? Die Cache Hit Rate liegt bei ca. 90%. Der Wechsel von Trefferliste in Detailansicht erfolgt gefühlt sofort und wir haben die Anzahl der Anfragen pro Minute nach bisheriger Messung um die Hälfte reduziert.

Das nenn' ich doch mal einen Erfolg!