diff --git a/composer.json b/composer.json index e3bef379af0335483c87e0e4d62e988ca199d412..1bf994954bf70fc5620dec1fb2b6ce69c4c50354 100644 --- a/composer.json +++ b/composer.json @@ -123,6 +123,7 @@ "webmozart/assert": "^1.11.0" }, "suggest": { + "ext-apcu": "Needed when non-default APCU based cache backends are used", "ext-gd": "GDlib/Freetype is required for building images with text (GIFBUILDER) and can also be used to scale images", "ext-fileinfo": "Used for proper file type detection in the file abstraction layer", "ext-zlib": "TYPO3 uses zlib for amongst others output compression and un/packing t3x extension files", diff --git a/typo3/sysext/core/Classes/Cache/Backend/ApcuBackend.php b/typo3/sysext/core/Classes/Cache/Backend/ApcuBackend.php index a9a8b36ad37bb68925da166234d45c60b0cc7423..04a91243c8d59b7fc09cd225ff048796ea62a05a 100644 --- a/typo3/sysext/core/Classes/Cache/Backend/ApcuBackend.php +++ b/typo3/sysext/core/Classes/Cache/Backend/ApcuBackend.php @@ -16,58 +16,37 @@ namespace TYPO3\CMS\Core\Cache\Backend; use TYPO3\CMS\Core\Cache\Exception; -use TYPO3\CMS\Core\Cache\Exception\InvalidDataException; use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; use TYPO3\CMS\Core\Core\Environment; /** * A caching backend which stores cache entries by using APCu. * + * The APCu backend is not very good with tagging and scales O(2n) with the + * number of tags. Do not use this backend if the data to be cached has many tags! + * * This backend uses the following types of keys: * - tag_xxx - * xxx is tag name, value is array of associated identifiers identifier. This - * is "forward" tag index. It is mainly used for obtaining content by tag - * (get identifier by tag -> get content by identifier) + * xxx is tag name, value is array of associated identifiers identifier. This + * is "forward" tag index. It is mainly used for obtaining content by tag + * (get identifier by tag -> get content by identifier) * - ident_xxx - * xxx is identifier, value is array of associated tags. This is "reverse" tag - * index. It provides quick access for all tags associated with this identifier - * and used when removing the identifier + * xxx is identifier, value is array of associated tags. This is "reverse" tag + * index. It provides quick access for all tags associated with this identifier + * and used when removing the identifier * - * Each key is prepended with a prefix. By default prefix consists from two parts + * Each key is prepended with a prefix. The prefix makes sure keys from the different + * installations do not conflict. By default, prefix consists from two parts * separated by underscore character and ends in yet another underscore character: * - "TYPO3" - * - MD5 of path to TYPO3 and user running TYPO3 - * This prefix makes sure that keys from the different installations do not - * conflict. + * - Hash of path to TYPO3 and user running TYPO3 */ -class ApcuBackend extends AbstractBackend implements TaggableBackendInterface +final class ApcuBackend extends AbstractBackend implements TaggableBackendInterface, TransientBackendInterface { /** - * A prefix to separate stored data from other data possible stored in the APC - * - * @var string + * A prefix to separate stored data from other data possible stored in the APC. */ - protected $identifierPrefix; - - /** - * Set the cache identifier prefix. - * - * @param string $identifierPrefix - */ - protected function setIdentifierPrefix($identifierPrefix) - { - $this->identifierPrefix = $identifierPrefix; - } - - /** - * Retrieves the cache identifier prefix. - * - * @return string - */ - protected function getIdentifierPrefix() - { - return $this->identifierPrefix; - } + private string $identifierPrefix = ''; /** * Constructs this backend @@ -90,34 +69,29 @@ class ApcuBackend extends AbstractBackend implements TaggableBackendInterface /** * Initializes the identifier prefix when setting the cache. */ - public function setCache(FrontendInterface $cache) + public function setCache(FrontendInterface $cache): void { parent::setCache($cache); - $pathHash = md5(Environment::getProjectPath() . $this->context . $cache->getIdentifier()); - $this->setIdentifierPrefix('TYPO3_' . $pathHash); + $this->identifierPrefix = 'TYPO3_' . hash('xxh3', Environment::getProjectPath() . $this->context . $cache->getIdentifier()) . '_'; } /** * Saves data in the cache. * * @param string $entryIdentifier An identifier for this specific cache entry - * @param string $data The data to be stored + * @param mixed $data The data to be stored * @param array $tags Tags to associate with this cache entry * @param int $lifetime Lifetime of this cache entry in seconds. If NULL is specified, the default lifetime is used. "0" means unlimited lifetime. * @throws Exception if no cache frontend has been set. - * @throws InvalidDataException if $data is not a string */ - public function set($entryIdentifier, $data, array $tags = [], $lifetime = null) + public function set($entryIdentifier, $data, array $tags = [], $lifetime = null): void { if (!$this->cache instanceof FrontendInterface) { throw new Exception('No cache frontend has been set yet via setCache().', 1232986118); } - if (!is_string($data)) { - throw new InvalidDataException('The specified data is of type "' . gettype($data) . '" but a string is expected.', 1232986125); - } $tags[] = '%APCBE%' . $this->cacheIdentifier; $expiration = $lifetime ?? $this->defaultLifetime; - $success = apcu_store($this->getIdentifierPrefix() . $entryIdentifier, $data, $expiration); + $success = apcu_store($this->identifierPrefix . $entryIdentifier, $data, $expiration); if ($success === true) { $this->removeIdentifierFromAllTags($entryIdentifier); $this->addIdentifierToTags($entryIdentifier, $tags); @@ -132,10 +106,10 @@ class ApcuBackend extends AbstractBackend implements TaggableBackendInterface * @param string $entryIdentifier An identifier which describes the cache entry to load * @return mixed The cache entry's content as a string or FALSE if the cache entry could not be loaded */ - public function get($entryIdentifier) + public function get($entryIdentifier): mixed { $success = false; - $value = apcu_fetch($this->getIdentifierPrefix() . $entryIdentifier, $success); + $value = apcu_fetch($this->identifierPrefix . $entryIdentifier, $success); return $success ? $value : $success; } @@ -145,10 +119,10 @@ class ApcuBackend extends AbstractBackend implements TaggableBackendInterface * @param string $entryIdentifier An identifier specifying the cache entry * @return bool TRUE if such an entry exists, FALSE if not */ - public function has($entryIdentifier) + public function has($entryIdentifier): bool { $success = false; - apcu_fetch($this->getIdentifierPrefix() . $entryIdentifier, $success); + apcu_fetch($this->identifierPrefix . $entryIdentifier, $success); return $success; } @@ -160,10 +134,10 @@ class ApcuBackend extends AbstractBackend implements TaggableBackendInterface * @param string $entryIdentifier Specifies the cache entry to remove * @return bool TRUE if (at least) an entry could be removed or FALSE if no entry was found */ - public function remove($entryIdentifier) + public function remove($entryIdentifier): bool { $this->removeIdentifierFromAllTags($entryIdentifier); - return apcu_delete($this->getIdentifierPrefix() . $entryIdentifier); + return apcu_delete($this->identifierPrefix . $entryIdentifier); } /** @@ -173,36 +147,22 @@ class ApcuBackend extends AbstractBackend implements TaggableBackendInterface * @param string $tag The tag to search for * @return array An array with identifiers of all matching entries. An empty array if no entries matched */ - public function findIdentifiersByTag($tag) + public function findIdentifiersByTag($tag): array { $success = false; - $identifiers = apcu_fetch($this->getIdentifierPrefix() . 'tag_' . $tag, $success); + $identifiers = apcu_fetch($this->identifierPrefix . 'tag_' . $tag, $success); if ($success === false) { return []; } return (array)$identifiers; } - /** - * Finds all tags for the given identifier. This function uses reverse tag - * index to search for tags. - * - * @param string $identifier Identifier to find tags by - * @return array Array with tags - */ - protected function findTagsByIdentifier($identifier) - { - $success = false; - $tags = apcu_fetch($this->getIdentifierPrefix() . 'ident_' . $identifier, $success); - return $success ? (array)$tags : []; - } - /** * Removes all cache entries of this cache. * * @throws Exception */ - public function flush() + public function flush(): void { if (!$this->cache instanceof FrontendInterface) { throw new Exception('Yet no cache frontend has been set via setCache().', 1232986571); @@ -215,7 +175,7 @@ class ApcuBackend extends AbstractBackend implements TaggableBackendInterface * * @param string $tag The tag the entries must have */ - public function flushByTag($tag) + public function flushByTag($tag): void { $identifiers = $this->findIdentifiersByTag($tag); foreach ($identifiers as $identifier) { @@ -225,10 +185,8 @@ class ApcuBackend extends AbstractBackend implements TaggableBackendInterface /** * Associates the identifier with the given tags - * - * @param string $entryIdentifier */ - protected function addIdentifierToTags($entryIdentifier, array $tags) + private function addIdentifierToTags(string $entryIdentifier, array $tags): void { // Get identifier-to-tag index to look for updates $existingTags = $this->findTagsByIdentifier($entryIdentifier); @@ -239,7 +197,7 @@ class ApcuBackend extends AbstractBackend implements TaggableBackendInterface $identifiers = $this->findIdentifiersByTag($tag); if (!in_array($entryIdentifier, $identifiers, true)) { $identifiers[] = $entryIdentifier; - apcu_store($this->getIdentifierPrefix() . 'tag_' . $tag, $identifiers); + apcu_store($this->identifierPrefix . 'tag_' . $tag, $identifiers); } // Test if identifier-to-tag index needs update if (!in_array($tag, $existingTags, true)) { @@ -250,16 +208,14 @@ class ApcuBackend extends AbstractBackend implements TaggableBackendInterface // Update identifier-to-tag index if needed if ($existingTagsUpdated) { - apcu_store($this->getIdentifierPrefix() . 'ident_' . $entryIdentifier, $existingTags); + apcu_store($this->identifierPrefix . 'ident_' . $entryIdentifier, $existingTags); } } /** * Removes association of the identifier with the given tags - * - * @param string $entryIdentifier */ - protected function removeIdentifierFromAllTags($entryIdentifier) + private function removeIdentifierFromAllTags(string $entryIdentifier): void { // Get tags for this identifier $tags = $this->findTagsByIdentifier($entryIdentifier); @@ -274,20 +230,29 @@ class ApcuBackend extends AbstractBackend implements TaggableBackendInterface if (($key = array_search($entryIdentifier, $identifiers)) !== false) { unset($identifiers[$key]); if (!empty($identifiers)) { - apcu_store($this->getIdentifierPrefix() . 'tag_' . $tag, $identifiers); + apcu_store($this->identifierPrefix . 'tag_' . $tag, $identifiers); } else { - apcu_delete($this->getIdentifierPrefix() . 'tag_' . $tag); + apcu_delete($this->identifierPrefix . 'tag_' . $tag); } } } // Clear reverse tag index for this identifier - apcu_delete($this->getIdentifierPrefix() . 'ident_' . $entryIdentifier); + apcu_delete($this->identifierPrefix . 'ident_' . $entryIdentifier); } /** - * Does nothing, as APCu does GC itself + * Finds all tags for the given identifier. This function uses reverse tag + * index to search for tags. */ - public function collectGarbage() + private function findTagsByIdentifier(string $identifier): array + { + $success = false; + $tags = apcu_fetch($this->identifierPrefix . 'ident_' . $identifier, $success); + return $success ? (array)$tags : []; + } + + public function collectGarbage(): void { + // Noop, APCu has internal GC } } diff --git a/typo3/sysext/core/Tests/Unit/Cache/Backend/ApcuBackendTest.php b/typo3/sysext/core/Tests/Functional/Cache/Backend/ApcuBackendTest.php similarity index 71% rename from typo3/sysext/core/Tests/Unit/Cache/Backend/ApcuBackendTest.php rename to typo3/sysext/core/Tests/Functional/Cache/Backend/ApcuBackendTest.php index 7dfe77af01e69a70bbc18d1501dff128a73f6b60..543d830eb4d51afc1185606578cce38547abd553 100644 --- a/typo3/sysext/core/Tests/Unit/Cache/Backend/ApcuBackendTest.php +++ b/typo3/sysext/core/Tests/Functional/Cache/Backend/ApcuBackendTest.php @@ -15,33 +15,38 @@ declare(strict_types=1); * The TYPO3 project - inspiring people to share! */ -namespace TYPO3\CMS\Core\Tests\Unit\Cache\Backend; +namespace TYPO3\CMS\Core\Tests\Functional\Cache\Backend; use TYPO3\CMS\Core\Cache\Backend\ApcuBackend; use TYPO3\CMS\Core\Cache\Exception; use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; use TYPO3\CMS\Core\Utility\StringUtility; -use TYPO3\TestingFramework\Core\AccessibleObjectInterface; -use TYPO3\TestingFramework\Core\Unit\UnitTestCase; +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; /** * NOTE: If you want to execute these tests you need to enable apc in * cli context (apc.enable_cli = 1) and disable slam defense (apc.slam_defense = 0) */ -class ApcuBackendTest extends UnitTestCase +class ApcuBackendTest extends FunctionalTestCase { - protected bool $resetSingletonInstances = true; + protected bool $initializeDatabase = false; protected function setUp(): void { - parent::setUp(); // APCu module is called apcu, but options are prefixed with apc if (!extension_loaded('apcu') || !(bool)ini_get('apc.enabled') || !(bool)ini_get('apc.enable_cli')) { self::markTestSkipped('APCu extension was not available, or it was disabled for CLI.'); } - if ((bool)ini_get('apc.slam_defense')) { + if (ini_get('apc.slam_defense')) { self::markTestSkipped('This testcase can only be executed with apc.slam_defense = 0'); } + parent::setUp(); + } + + protected function tearDown(): void + { + apcu_clear_cache(); + parent::tearDown(); } /** @@ -62,24 +67,51 @@ class ApcuBackendTest extends UnitTestCase */ public function itIsPossibleToSetAndCheckExistenceInCache(): void { - $backend = $this->setUpBackend(); + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); $data = 'Some data'; $identifier = StringUtility::getUniqueId('MyIdentifier'); $backend->set($identifier, $data); self::assertTrue($backend->has($identifier)); } + public function itIsPossibleToSetAndGetEntryDataProvider(): iterable + { + yield [ 5 ]; + yield [ 5.23 ]; + yield [ 'foo' ]; + yield [ false ]; + yield [ true ]; + yield [ null ]; + yield [ ['foo', 'bar'] ]; + } + /** * @test + * @dataProvider itIsPossibleToSetAndGetEntryDataProvider */ - public function itIsPossibleToSetAndGetEntry(): void + public function itIsPossibleToSetAndGetEntry(mixed $data): void { - $backend = $this->setUpBackend(); - $data = 'Some data'; + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); $identifier = StringUtility::getUniqueId('MyIdentifier'); $backend->set($identifier, $data); + self::assertSame($data, $backend->get($identifier)); + } + + /** + * @test + */ + public function itIsPossibleToSetAndGetObject(): void + { + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); + $identifier = StringUtility::getUniqueId('MyIdentifier'); + $object = new \stdClass(); + $object->foo = 'foo'; + $backend->set($identifier, $object); $fetchedData = $backend->get($identifier); - self::assertEquals($data, $fetchedData); + self::assertEquals($object, $fetchedData); } /** @@ -87,7 +119,8 @@ class ApcuBackendTest extends UnitTestCase */ public function itIsPossibleToRemoveEntryFromCache(): void { - $backend = $this->setUpBackend(); + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); $data = 'Some data'; $identifier = StringUtility::getUniqueId('MyIdentifier'); $backend->set($identifier, $data); @@ -100,7 +133,8 @@ class ApcuBackendTest extends UnitTestCase */ public function itIsPossibleToOverwriteAnEntryInTheCache(): void { - $backend = $this->setUpBackend(); + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); $data = 'Some data'; $identifier = StringUtility::getUniqueId('MyIdentifier'); $backend->set($identifier, $data); @@ -115,7 +149,8 @@ class ApcuBackendTest extends UnitTestCase */ public function findIdentifiersByTagFindsSetEntries(): void { - $backend = $this->setUpBackend(); + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); $data = 'Some data'; $identifier = StringUtility::getUniqueId('MyIdentifier'); $backend->set($identifier, $data, ['UnitTestTag%tag1', 'UnitTestTag%tag2']); @@ -130,7 +165,8 @@ class ApcuBackendTest extends UnitTestCase */ public function setRemovesTagsFromPreviousSet(): void { - $backend = $this->setUpBackend(); + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); $data = 'Some data'; $identifier = StringUtility::getUniqueId('MyIdentifier'); $backend->set($identifier, $data, ['UnitTestTag%tag1', 'UnitTestTag%tagX']); @@ -144,7 +180,8 @@ class ApcuBackendTest extends UnitTestCase */ public function hasReturnsFalseIfTheEntryDoesNotExist(): void { - $backend = $this->setUpBackend(); + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); $identifier = StringUtility::getUniqueId('NonExistingIdentifier'); self::assertFalse($backend->has($identifier)); } @@ -154,7 +191,8 @@ class ApcuBackendTest extends UnitTestCase */ public function removeReturnsFalseIfTheEntryDoesntExist(): void { - $backend = $this->setUpBackend(); + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); $identifier = StringUtility::getUniqueId('NonExistingIdentifier'); self::assertFalse($backend->remove($identifier)); } @@ -164,7 +202,8 @@ class ApcuBackendTest extends UnitTestCase */ public function flushByTagRemovesCacheEntriesWithSpecifiedTag(): void { - $backend = $this->setUpBackend(); + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); $data = 'some data' . microtime(); $backend->set('BackendAPCUTest1', $data, ['UnitTestTag%test', 'UnitTestTag%boring']); $backend->set('BackendAPCUTest2', $data, ['UnitTestTag%test', 'UnitTestTag%special']); @@ -180,7 +219,8 @@ class ApcuBackendTest extends UnitTestCase */ public function flushByTagsRemovesCacheEntriesWithSpecifiedTags(): void { - $backend = $this->setUpBackend(); + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); $data = 'some data' . microtime(); $backend->set('BackendAPCUTest1', $data, ['UnitTestTag%test', 'UnitTestTag%boring']); $backend->set('BackendAPCUTest2', $data, ['UnitTestTag%test', 'UnitTestTag%special']); @@ -196,7 +236,8 @@ class ApcuBackendTest extends UnitTestCase */ public function flushRemovesAllCacheEntries(): void { - $backend = $this->setUpBackend(); + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); $data = 'some data' . microtime(); $backend->set('BackendAPCUTest1', $data); $backend->set('BackendAPCUTest2', $data); @@ -235,7 +276,8 @@ class ApcuBackendTest extends UnitTestCase */ public function largeDataIsStored(): void { - $backend = $this->setUpBackend(); + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); $data = str_repeat('abcde', 1024 * 1024); $identifier = StringUtility::getUniqueId('tooLargeData'); $backend->set($identifier, $data); @@ -251,35 +293,13 @@ class ApcuBackendTest extends UnitTestCase $identifier = StringUtility::getUniqueId('MyIdentifier'); $tags = ['UnitTestTag%test', 'UnitTestTag%boring']; - $backend = $this->setUpBackend(true); - $backend->_call('addIdentifierToTags', $identifier, $tags); - self::assertSame( - $tags, - $backend->_call('findTagsByIdentifier', $identifier) - ); - - $backend->_call('addIdentifierToTags', $identifier, $tags); - self::assertSame( - $tags, - $backend->_call('findTagsByIdentifier', $identifier) - ); - } + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); + $backend->set($identifier, 'testData', $tags); + $backend->set($identifier, 'testData', $tags); - /** - * Sets up the APCu backend used for testing - * - * @param bool $accessible TRUE if backend should be encapsulated in accessible proxy otherwise FALSE. - * @return AccessibleObjectInterface|ApcuBackend - */ - protected function setUpBackend(bool $accessible = false) - { - $cache = $this->createMock(FrontendInterface::class); - if ($accessible) { - $backend = $this->getAccessibleMock(ApcuBackend::class, null, ['Testing']); - } else { - $backend = new ApcuBackend('Testing'); - } - $backend->setCache($cache); - return $backend; + // Expect exactly 5 entries: + // 1 for data, 3 for tag->identifier, 1 for identifier->tags + self::assertSame(5, count(apcu_cache_info()['cache_list'])); } } diff --git a/typo3/sysext/core/Tests/Functional/Cache/Backend/RedisBackendTest.php b/typo3/sysext/core/Tests/Functional/Cache/Backend/RedisBackendTest.php index 16bbd7fd65aaf769f1746e5acd0323e8e182c839..f0dc7af6731623d29f635ce97576bb2d80278a01 100644 --- a/typo3/sysext/core/Tests/Functional/Cache/Backend/RedisBackendTest.php +++ b/typo3/sysext/core/Tests/Functional/Cache/Backend/RedisBackendTest.php @@ -42,10 +42,9 @@ class RedisBackendTest extends FunctionalTestCase if (!getenv('typo3TestingRedisHost')) { self::markTestSkipped('environment variable "typo3TestingRedisHost" must be set to run this test'); } - // Note we assume that if that typo3TestingRedisHost env is set, we can use that for testing, + // Note we assume that if typo3TestingRedisHost env is set, we can use that for testing, // there is no test to see if the daemon is actually up and running. Tests will fail if env // is set but daemon is down. - parent::setUp(); } diff --git a/typo3/sysext/core/composer.json b/typo3/sysext/core/composer.json index 46110237765dfc46e404e36b8f28b6a6aba13db4..6127790262e751c5dcfea1e156c1c7729b42f72b 100644 --- a/typo3/sysext/core/composer.json +++ b/typo3/sysext/core/composer.json @@ -76,6 +76,7 @@ "typo3fluid/fluid": "^2.7.2" }, "suggest": { + "ext-apcu": "Needed when non-default APCU based cache backends are used", "ext-fileinfo": "Used for proper file type detection in the file abstraction layer", "ext-gd": "GDlib/Freetype is required for building images with text (GIFBUILDER) and can also be used to scale images", "ext-mysqli": "",