Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 103 additions & 5 deletions Classes/Common/DocumentCacheManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,39 @@ class DocumentCacheManager implements SingletonInterface
*/
public const LOAD_FAILED = '__DLF_DOC_LOAD_FAILED__';

/**
* Lifetime of entries in the in-memory pool in seconds. An entry is
* refreshed (sliding TTL) as long as it is requested regularly.
*/
private const POOL_TTL = 300;

/**
* Maximum number of documents kept in the in-memory pool per
* PHP-FPM worker. The oldest (least recently used) entry is
* evicted first.
*/
private const POOL_MAX_ENTRIES = 5;

/**
* In-memory, per-PHP-FPM-worker pool of recently used documents.
*
* Reading a document from the file-based TYPO3 cache requires
* deserializing the entry and re-parsing the complete METS XML in
* AbstractDocument::__wakeup(). Documents that are requested
* repeatedly (e.g. by crawlers) are therefore additionally kept
* in this short-lived pool: a pool hit skips the file cache, the
* deserialization and the XML re-parsing entirely, and the already
* computed lazy data (physical structure, metadata array, file
* groups) is reused as well.
*
* Note: PHP static state lives per FPM worker process and is only
* accessed by one request at a time, so sharing the instances
* between sequential requests of the same worker is safe.
*
* @var array<string, array{0: AbstractDocument, 1: int}>
*/
protected static array $pool = [];

/**
* @var FrontendInterface
*/
Expand All @@ -51,6 +84,11 @@ public function __construct()
/**
* Get document instance from cache.
*
* The per-worker in-memory pool is checked first; on a hit the
* fully loaded document is returned without touching the file
* cache. On a miss the file cache is read and the result is added
* to the pool (subject to its TTL and entry cap).
*
* @access public
*
* @param string $location
Expand All @@ -61,13 +99,30 @@ public function __construct()
*/
public function get(string $location)
{
return $this->cache->get($this->getIdentifier($location));
$identifier = $this->getIdentifier($location);
$now = time();
if (isset(self::$pool[$identifier])) {
[$document, $cachedAt] = self::$pool[$identifier];
// Re-insert the entry to keep least-recently-used ordering.
unset(self::$pool[$identifier]);
if ($now - $cachedAt < self::POOL_TTL) {
self::$pool[$identifier] = [$document, $now];
return $document;
}
}
$instance = $this->cache->get($identifier);
if ($instance instanceof AbstractDocument) {
self::$pool[$identifier] = [$instance, $now];
self::evictOldestPoolEntries();
}
return $instance;
}

/**
* Set cache for document instance.
*
* The entry uses the cache's default lifetime (one day).
* The entry uses the cache's default lifetime (one day) in the
* file cache and POOL_TTL in the per-worker in-memory pool.
*
* @access public
*
Expand All @@ -78,7 +133,11 @@ public function get(string $location)
*/
public function set(string $location, AbstractDocument $currentDocument): void
{
$this->cache->set($this->getIdentifier($location), $currentDocument);
$identifier = $this->getIdentifier($location);
$this->cache->set($identifier, $currentDocument);
unset(self::$pool[$identifier]);
self::$pool[$identifier] = [$currentDocument, time()];
self::evictOldestPoolEntries();
}

/**
Expand All @@ -97,6 +156,7 @@ public function set(string $location, AbstractDocument $currentDocument): void
*/
public function setFail(string $location): void
{
unset(self::$pool[$this->getIdentifier($location)]);
$this->cache->set($this->getIdentifier($location), self::LOAD_FAILED, [], $this->getFailLifetime());
}

Expand All @@ -115,19 +175,40 @@ private function getFailLifetime(): int
}

/**
* Remove all documents from cache.
* Remove all documents from cache and from the in-memory pool.
*
* @access public
*
* @return void
*/
public function flush(): void
{
self::$pool = [];
$this->cache->flush();
}

/**
* Remove single document from cache.
* Reset the in-memory pool without touching the file cache.
*
* Unlike flush(), which is part of the public cache API, this is
* intended for testing only: the pool is per-worker state that
* survives individual functional tests, and a pooled document
* loaded by a previous test (possibly with different settings)
* would leak into the next test.
*
* @access public
*
* @static
*
* @return void
*/
public static function resetPool(): void
{
self::$pool = [];
}

/**
* Remove single document from cache and from the in-memory pool.
*
* @access public
*
Expand All @@ -137,6 +218,7 @@ public function flush(): void
*/
public function remove(string $location): void
{
unset(self::$pool[$this->getIdentifier($location)]);
$this->cache->remove($this->getIdentifier($location));
}

Expand All @@ -153,4 +235,20 @@ private function getIdentifier(string $location): string
{
return hash('md5', $location);
}

/**
* Evict least-recently-used entries from the in-memory pool until
* it contains at most POOL_MAX_ENTRIES documents.
*
* @access private
*
* @return void
*/
private static function evictOldestPoolEntries(): void
{
$excess = count(self::$pool) - self::POOL_MAX_ENTRIES;
for ($i = 0; $i < $excess; ++$i) {
unset(self::$pool[array_key_first(self::$pool)]);
}
}
}
6 changes: 6 additions & 0 deletions Tests/Functional/FunctionalTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

use Dotenv\Dotenv;
use GuzzleHttp\Client as HttpClient;
use Kitodo\Dlf\Common\DocumentCacheManager;
use Kitodo\Dlf\Common\Helper;
use Kitodo\Dlf\Common\Indexer;
use Kitodo\Dlf\Common\Solr\Solr;
Expand Down Expand Up @@ -117,6 +118,11 @@ public function setUp(): void

parent::setUp();

// The in-memory document pool is per-worker state that survives
// individual tests; reset it so that no document loaded by a
// previous test (possibly with different settings) leaks in.
DocumentCacheManager::resetPool();

$this->persistenceManager = GeneralUtility::makeInstance(PersistenceManager::class);

$this->baseUrl = 'http://web:8000/public/typo3temp/var/tests/functional-' . $this->identifier . '/';
Expand Down
Loading
Loading