[BUGFIX] Flush all cache tags in bulk when deleting records 37/50537/24
authorClaus Due <claus@namelesscoder.net>
Mon, 7 Nov 2016 16:09:35 +0000 (17:09 +0100)
committerGeorg Ringer <georg.ringer@gmail.com>
Tue, 6 Dec 2016 08:26:09 +0000 (09:26 +0100)
This patch makes the TYPO3 DB cache backend capable of
flushing tags using an array of tag names that is then turned
into a CSV list and used in an `IN` query. It limits the number
of cache flushes from $numRecords to ceil($numRecords/100).

NB: the desired behavior is introduced by reviving the
flushByTags() method as API for cache frontends and backends.
The method was previously present but was dropped in order to
keep sync with Flow - which then later added the method exactly
because of performance concerns.

TYPO3 however did not revive this method and obviously the sync
with Flow is no longer a concern. So this patch restores the
full API required to flush tags in bulk, adds an implementation
for the TYPO3 DB cache backend and adds delegation to the old
flushByTag method for backends not covered by this patch.

It will be possible to improve other backends as well but this
patch focuses exclusively on the DB cache backend for *NIX,
which is where the bad performance was observed in the wild.

Change-Id: I99d4dd8d0881c3bf9f6240e84b083b72b1831779
Resolves: #78596
Releases: master
Reviewed-on: https://review.typo3.org/50537
Reviewed-by: Anja Leichsenring <aleichsenring@ab-softlab.de>
Tested-by: Anja Leichsenring <aleichsenring@ab-softlab.de>
Tested-by: TYPO3com <no-reply@typo3.com>
Reviewed-by: Joerg Boesche <typo3@joergboesche.de>
Reviewed-by: Georg Ringer <georg.ringer@gmail.com>
Tested-by: Georg Ringer <georg.ringer@gmail.com>
21 files changed:
typo3/sysext/core/Classes/Cache/Backend/AbstractBackend.php
typo3/sysext/core/Classes/Cache/Backend/TaggableBackendInterface.php
typo3/sysext/core/Classes/Cache/Backend/Typo3DatabaseBackend.php
typo3/sysext/core/Classes/Cache/CacheManager.php
typo3/sysext/core/Classes/Cache/Frontend/AbstractFrontend.php
typo3/sysext/core/Classes/Cache/Frontend/FrontendInterface.php
typo3/sysext/core/Classes/DataHandling/DataHandler.php
typo3/sysext/core/Tests/Unit/Cache/Backend/ApcBackendTest.php
typo3/sysext/core/Tests/Unit/Cache/Backend/ApcuBackendTest.php
typo3/sysext/core/Tests/Unit/Cache/Backend/MemcachedBackendTest.php
typo3/sysext/core/Tests/Unit/Cache/Backend/PdoBackendTest.php
typo3/sysext/core/Tests/Unit/Cache/Backend/RedisBackendTest.php
typo3/sysext/core/Tests/Unit/Cache/Backend/TransientMemoryBackendTest.php
typo3/sysext/core/Tests/Unit/Cache/Backend/Typo3DatabaseBackendTest.php
typo3/sysext/core/Tests/Unit/Cache/Backend/WincacheBackendTest.php
typo3/sysext/core/Tests/Unit/Cache/CacheManagerTest.php
typo3/sysext/core/Tests/Unit/Cache/Fixtures/FrontendFixture.php
typo3/sysext/core/Tests/Unit/Cache/Frontend/AbstractFrontendTest.php
typo3/sysext/core/Tests/Unit/DataHandling/DataHandlerTest.php
typo3/sysext/extbase/Classes/Service/CacheService.php
typo3/sysext/extbase/Tests/Unit/Service/CacheServiceTest.php

index 6d80002..ee3f641 100644 (file)
@@ -107,6 +107,22 @@ abstract class AbstractBackend implements \TYPO3\CMS\Core\Cache\Backend\BackendI
     }
 
     /**
+     * Backwards compatibility safeguard since re-introducing flushByTags as API.
+     * See https://review.typo3.org/#/c/50537/ comments for patch set 14.
+     *
+     * The method is here even though it is only required for TaggableBackendInterface.
+     * We add it here to ensure third party cache backends do not fail but instead
+     * delegate to a less efficient linear flushing behavior.
+     *
+     * @param string[] $tags
+     * @api
+     */
+    public function flushByTags(array $tags)
+    {
+        array_walk($tags, [$this, 'flushByTag']);
+    }
+
+    /**
      * Calculates the expiry time by the given lifetime. If no lifetime is
      * specified, the default lifetime is used.
      *
index d9f2949..8dd7db8 100644 (file)
@@ -27,6 +27,15 @@ interface TaggableBackendInterface extends \TYPO3\CMS\Core\Cache\Backend\Backend
     public function flushByTag($tag);
 
     /**
+     * Removes all cache entries of this cache which are tagged by any of the specified tags.
+     *
+     * @param string[] $tag List of tags
+     * @return void
+     * @api
+     */
+    public function flushByTags(array $tags);
+
+    /**
      * Finds and returns all cache entry identifiers which are tagged by the
      * specified tag
      *
index d7f59f3..817461e 100644 (file)
@@ -262,6 +262,69 @@ class Typo3DatabaseBackend extends AbstractBackend implements TaggableBackendInt
     }
 
     /**
+     * Removes all entries tagged by any of the specified tags. Performs the SQL
+     * operation as a bulk query for better performance.
+     *
+     * @param string[] $tags
+     */
+    public function flushByTags(array $tags)
+    {
+        $this->throwExceptionIfFrontendDoesNotExist();
+
+        if (empty($tags)) {
+            return;
+        }
+
+        /** @var Connection $connection */
+        $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($this->cacheTable);
+
+        // A large set of tags was detected. Process it in chunks to guard against exceeding
+        // maximum SQL query limits.
+        if (count($tags) > 100) {
+            array_walk(array_chunk($tags, 100), [$this, 'flushByTags']);
+            return;
+        }
+        // VERY simple quoting of tags is sufficient here for performance. Tags are already
+        // validated to not contain any bad characters, e.g. they are automatically generated
+        // inside this class and suffixed with a pure integer enforced by DB.
+        $quotedTagList = array_map(function ($value) {
+            return '\'' . $value . '\'';
+        }, $tags);
+
+        if ($this->isConnectionMysql($connection)) {
+            // Use a optimized query on mysql ... don't use on your own
+            // * ansi sql does not know about multi table delete
+            // * doctrine query builder does not support join on delete()
+            $connection->executeQuery(
+                'DELETE tags2, cache1'
+                . ' FROM ' . $this->tagsTable . ' AS tags1'
+                . ' JOIN ' . $this->tagsTable . ' AS tags2 ON tags1.identifier = tags2.identifier'
+                . ' JOIN ' . $this->cacheTable . ' AS cache1 ON tags1.identifier = cache1.identifier'
+                . ' WHERE tags1.tag IN (' . implode(',', $quotedTagList) . ')'
+            );
+        } else {
+            $queryBuilder = $connection->createQueryBuilder();
+            $result = $queryBuilder->select('identifier')
+                ->from($this->tagsTable)
+                ->where('tag IN (' . implode(',', $quotedTagList) . ')')
+                // group by is like DISTINCT and used here to suppress possible duplicate identifiers
+                ->groupBy('identifier')
+                ->execute();
+            $cacheEntryIdentifiers = [];
+            while ($row = $result->fetch()) {
+                $cacheEntryIdentifiers[] = $row['identifier'];
+            }
+            $quotedIdentifiers = $queryBuilder->createNamedParameter($cacheEntryIdentifiers, Connection::PARAM_STR_ARRAY);
+            $queryBuilder->delete($this->cacheTable)
+                ->where($queryBuilder->expr()->in('identifier', $quotedIdentifiers))
+                ->execute();
+            $queryBuilder->delete($this->tagsTable)
+                ->where($queryBuilder->expr()->in('identifier', $quotedIdentifiers))
+                ->execute();
+        }
+    }
+
+    /**
      * Removes all cache entries of this cache which are tagged by the specified tag.
      *
      * @param string $tag The tag the entries must have
@@ -271,7 +334,15 @@ class Typo3DatabaseBackend extends AbstractBackend implements TaggableBackendInt
     {
         $this->throwExceptionIfFrontendDoesNotExist();
 
+        if (empty($tag)) {
+            return;
+        }
+
+        /** @var Connection $connection */
         $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($this->cacheTable);
+
+        $quotedTag = '\'' . $tag . '\'';
+
         if ($this->isConnectionMysql($connection)) {
             // Use a optimized query on mysql ... don't use on your own
             // * ansi sql does not know about multi table delete
@@ -281,14 +352,13 @@ class Typo3DatabaseBackend extends AbstractBackend implements TaggableBackendInt
                 . ' FROM ' . $this->tagsTable . ' AS tags1'
                 . ' JOIN ' . $this->tagsTable . ' AS tags2 ON tags1.identifier = tags2.identifier'
                 . ' JOIN ' . $this->cacheTable . ' AS cache1 ON tags1.identifier = cache1.identifier'
-                . ' WHERE tags1.tag = ?',
-                [$tag]
+                . ' WHERE tags1.tag = ' . $quotedTag
             );
         } else {
             $queryBuilder = $connection->createQueryBuilder();
             $result = $queryBuilder->select('identifier')
                 ->from($this->tagsTable)
-                ->where($queryBuilder->expr()->eq('tag', $queryBuilder->createNamedParameter($tag, \PDO::PARAM_STR)))
+                ->where('tag = ' . $quotedTag)
                 // group by is like DISTINCT and used here to suppress possible duplicate identifiers
                 ->groupBy('identifier')
                 ->execute();
index c4b56e7..bb6321e 100644 (file)
@@ -163,15 +163,14 @@ class CacheManager implements SingletonInterface
     public function flushCachesInGroup($groupIdentifier)
     {
         $this->createAllCaches();
-        if (isset($this->cacheGroups[$groupIdentifier])) {
-            foreach ($this->cacheGroups[$groupIdentifier] as $cacheIdentifier) {
-                if (isset($this->caches[$cacheIdentifier])) {
-                    $this->caches[$cacheIdentifier]->flush();
-                }
-            }
-        } else {
+        if (!isset($this->cacheGroups[$groupIdentifier])) {
             throw new NoSuchCacheGroupException('No cache in the specified group \'' . $groupIdentifier . '\'', 1390334120);
         }
+        foreach ($this->cacheGroups[$groupIdentifier] as $cacheIdentifier) {
+            if (isset($this->caches[$cacheIdentifier])) {
+                $this->caches[$cacheIdentifier]->flush();
+            }
+        }
     }
 
     /**
@@ -179,23 +178,51 @@ class CacheManager implements SingletonInterface
      * caches of a specific group.
      *
      * @param string $groupIdentifier
-     * @param string $tag Tag to search for
+     * @param string|array $tag Tag to search for
      * @return void
      * @throws NoSuchCacheGroupException
      * @api
      */
     public function flushCachesInGroupByTag($groupIdentifier, $tag)
     {
+        if (empty($tag)) {
+            return;
+        }
         $this->createAllCaches();
-        if (isset($this->cacheGroups[$groupIdentifier])) {
-            foreach ($this->cacheGroups[$groupIdentifier] as $cacheIdentifier) {
-                if (isset($this->caches[$cacheIdentifier])) {
-                    $this->caches[$cacheIdentifier]->flushByTag($tag);
-                }
-            }
-        } else {
+        if (!isset($this->cacheGroups[$groupIdentifier])) {
             throw new NoSuchCacheGroupException('No cache in the specified group \'' . $groupIdentifier . '\'', 1390337129);
         }
+        foreach ($this->cacheGroups[$groupIdentifier] as $cacheIdentifier) {
+            if (isset($this->caches[$cacheIdentifier])) {
+                $this->caches[$cacheIdentifier]->flushByTag($tag);
+            }
+        }
+    }
+
+    /**
+     * Flushes entries tagged by any of the specified tags in all registered
+     * caches of a specific group.
+     *
+     * @param string $groupIdentifier
+     * @param string[] $tag Tags to search for
+     * @return void
+     * @throws NoSuchCacheGroupException
+     * @api
+     */
+    public function flushCachesInGroupByTags($groupIdentifier, array $tags)
+    {
+        if (empty($tag)) {
+            return;
+        }
+        $this->createAllCaches();
+        if (!isset($this->cacheGroups[$groupIdentifier])) {
+            throw new NoSuchCacheGroupException('No cache in the specified group \'' . $groupIdentifier . '\'', 1390337130);
+        }
+        foreach ($this->cacheGroups[$groupIdentifier] as $cacheIdentifier) {
+            if (isset($this->caches[$cacheIdentifier])) {
+                $this->caches[$cacheIdentifier]->flushByTags($tags);
+            }
+        }
     }
 
     /**
@@ -215,6 +242,21 @@ class CacheManager implements SingletonInterface
     }
 
     /**
+     * Flushes entries tagged by any of the specified tags in all registered caches.
+     *
+     * @param string[] $tag Tags to search for
+     * @return void
+     * @api
+     */
+    public function flushCachesByTags(array $tags)
+    {
+        $this->createAllCaches();
+        foreach ($this->caches as $cache) {
+            $cache->flushByTags($tags);
+        }
+    }
+
+    /**
      * Instantiates all registered caches.
      *
      * @return void
index cdddad4..b32fc92 100644 (file)
@@ -122,6 +122,25 @@ abstract class AbstractFrontend implements FrontendInterface
     }
 
     /**
+     * Removes all cache entries of this cache which are tagged by any of the specified tags.
+     *
+     * @param string[] $tags
+     * @return void
+     * @throws \InvalidArgumentException
+     */
+    public function flushByTags(array $tags)
+    {
+        foreach ($tags as $tag) {
+            if (!$this->isValidTag($tag)) {
+                throw new \InvalidArgumentException('"' . $tag . '" is not a valid tag for a cache entry.', 1233057360);
+            }
+        }
+        if ($this->backend instanceof TaggableBackendInterface) {
+            $this->backend->flushByTags($tags);
+        }
+    }
+
+    /**
      * Removes all cache entries of this cache which are tagged by the specified tag.
      *
      * @param string $tag The tag the entries must have
@@ -171,12 +190,20 @@ abstract class AbstractFrontend implements FrontendInterface
     /**
      * Checks the validity of a tag. Returns TRUE if it's valid.
      *
-     * @param string $tag An identifier to be checked for validity
+     * @param string|array $tag An identifier to be checked for validity
      * @return bool
      * @api
      */
     public function isValidTag($tag)
     {
-        return preg_match(self::PATTERN_TAG, $tag) === 1;
+        if (!is_array($tag)) {
+            return preg_match(self::PATTERN_TAG, $tag) === 1;
+        }
+        foreach ($tag as $tagValue) {
+            if (!$this->isValidTag($tagValue)) {
+                return false;
+            }
+        }
+        return true;
     }
 }
index 43868c3..5863c61 100644 (file)
@@ -115,6 +115,15 @@ interface FrontendInterface
     public function flushByTag($tag);
 
     /**
+     * Removes all cache entries of this cache which are tagged by any of the specified tags.
+     *
+     * @param string[] $tag List of tags
+     * @return void
+     * @api
+     */
+    public function flushByTags(array $tags);
+
+    /**
      * Does garbage collection
      *
      * @return void
index c5437d9..24629cf 100644 (file)
@@ -8113,9 +8113,7 @@ class DataHandler
 
         /** @var CacheManager $cacheManager */
         $cacheManager = $this->getCacheManager();
-        foreach ($tagsToClear as $tag => $_) {
-            $cacheManager->flushCachesInGroupByTag('pages', $tag);
-        }
+        $cacheManager->flushCachesInGroupByTags('pages', array_keys($tagsToClear));
 
         // Execute collected clear cache commands from page TSConfig
         foreach ($clearCacheCommands as $command) {
@@ -8360,9 +8358,7 @@ class DataHandler
         }
         // process caching framwork operations
         if (!empty($tagsToFlush)) {
-            foreach (array_unique($tagsToFlush) as $tag) {
-                $this->getCacheManager()->flushCachesInGroupByTag('pages', $tag);
-            }
+            $this->getCacheManager()->flushCachesInGroupByTags('pages', $tagsToFlush);
         }
 
         // Call post processing function for clear-cache:
index cc36393..24f63db 100644 (file)
@@ -210,6 +210,22 @@ class ApcBackendTest extends \TYPO3\CMS\Core\Tests\UnitTestCase
     /**
      * @test
      */
+    public function flushByTagsRemovesCacheEntriesWithSpecifiedTags()
+    {
+        $backend = $this->setUpBackend();
+        $data = 'some data' . microtime();
+        $backend->set('BackendAPCTest1', $data, ['UnitTestTag%test', 'UnitTestTag%boring']);
+        $backend->set('BackendAPCTest2', $data, ['UnitTestTag%test', 'UnitTestTag%special']);
+        $backend->set('BackendAPCTest3', $data, ['UnitTestTag%test']);
+        $backend->flushByTags(['UnitTestTag%special', 'UnitTestTag%boring']);
+        $this->assertFalse($backend->has('BackendAPCTest1'), 'BackendAPCTest1');
+        $this->assertFalse($backend->has('BackendAPCTest2'), 'BackendAPCTest2');
+        $this->assertTrue($backend->has('BackendAPCTest3'), 'BackendAPCTest3');
+    }
+
+    /**
+     * @test
+     */
     public function flushRemovesAllCacheEntries()
     {
         $backend = $this->setUpBackend();
index 865ba7e..1620d3c 100644 (file)
@@ -209,6 +209,22 @@ class ApcuBackendTest extends UnitTestCase
     /**
      * @test
      */
+    public function flushByTagsRemovesCacheEntriesWithSpecifiedTags()
+    {
+        $backend = $this->setUpBackend();
+        $data = 'some data' . microtime();
+        $backend->set('BackendAPCUTest1', $data, ['UnitTestTag%test', 'UnitTestTag%boring']);
+        $backend->set('BackendAPCUTest2', $data, ['UnitTestTag%test', 'UnitTestTag%special']);
+        $backend->set('BackendAPCUTest3', $data, ['UnitTestTag%test']);
+        $backend->flushByTags(['UnitTestTag%special', 'UnitTestTag%boring']);
+        $this->assertFalse($backend->has('BackendAPCUTest1'), 'BackendAPCTest1');
+        $this->assertFalse($backend->has('BackendAPCUTest2'), 'BackendAPCTest2');
+        $this->assertTrue($backend->has('BackendAPCUTest3'), 'BackendAPCTest3');
+    }
+
+    /**
+     * @test
+     */
     public function flushRemovesAllCacheEntries()
     {
         $backend = $this->setUpBackend();
index 61d1f2e..2509c93 100644 (file)
@@ -195,6 +195,22 @@ class MemcachedBackendTest extends \TYPO3\CMS\Core\Tests\UnitTestCase
     /**
      * @test
      */
+    public function flushByTagsRemovesCacheEntriesWithSpecifiedTags()
+    {
+        $backend = $this->setUpBackend();
+        $data = 'some data' . microtime();
+        $backend->set('BackendMemcacheTest1', $data, ['UnitTestTag%test', 'UnitTestTag%boring']);
+        $backend->set('BackendMemcacheTest2', $data, ['UnitTestTag%test', 'UnitTestTag%special']);
+        $backend->set('BackendMemcacheTest3', $data, ['UnitTestTag%test']);
+        $backend->flushByTags(['UnitTestTag%special', 'UnitTestTag%boring']);
+        $this->assertFalse($backend->has('BackendMemcacheTest1'), 'BackendMemcacheTest1');
+        $this->assertFalse($backend->has('BackendMemcacheTest2'), 'BackendMemcacheTest2');
+        $this->assertTrue($backend->has('BackendMemcacheTest3'), 'BackendMemcacheTest3');
+    }
+
+    /**
+     * @test
+     */
     public function flushRemovesAllCacheEntries()
     {
         $backend = $this->setUpBackend();
index 95151ca..57c0c6e 100644 (file)
@@ -179,6 +179,22 @@ class PdoBackendTest extends \TYPO3\CMS\Core\Tests\UnitTestCase
     /**
      * @test
      */
+    public function flushByTagsRemovesCacheEntriesWithSpecifiedTags()
+    {
+        $backend = $this->setUpBackend();
+        $data = 'some data' . microtime();
+        $backend->set('PdoBackendTest1', $data, ['UnitTestTag%test', 'UnitTestTags%boring']);
+        $backend->set('PdoBackendTest2', $data, ['UnitTestTag%test', 'UnitTestTag%special']);
+        $backend->set('PdoBackendTest3', $data, ['UnitTestTag%test']);
+        $backend->flushByTags(['UnitTestTag%special', 'UnitTestTags%boring']);
+        $this->assertFalse($backend->has('PdoBackendTest1'), 'PdoBackendTest1');
+        $this->assertFalse($backend->has('PdoBackendTest2'), 'PdoBackendTest2');
+        $this->assertTrue($backend->has('PdoBackendTest3'), 'PdoBackendTest3');
+    }
+
+    /**
+     * @test
+     */
     public function flushRemovesAllCacheEntries()
     {
         $backend = $this->setUpBackend();
index c44f0b0..40039f9 100644 (file)
@@ -763,6 +763,28 @@ class RedisBackendTest extends \TYPO3\CMS\Core\Tests\UnitTestCase
     }
 
     /**
+     * @test Functional
+     */
+    public function flushByTagsRemovesEntriesTaggedWithSpecifiedTags()
+    {
+        $this->setUpBackend();
+        $identifier = $this->getUniqueId('identifier');
+        $this->backend->set($identifier . 'A', 'data', ['tag1']);
+        $this->backend->set($identifier . 'B', 'data', ['tag2']);
+        $this->backend->set($identifier . 'C', 'data', ['tag1', 'tag2']);
+        $this->backend->set($identifier . 'D', 'data', ['tag3']);
+        $this->backend->flushByTags(['tag1', 'tag2']);
+        $expectedResult = [false, false, false, true];
+        $actualResult = [
+            $this->backend->has($identifier . 'A'),
+            $this->backend->has($identifier . 'B'),
+            $this->backend->has($identifier . 'C'),
+            $this->backend->has($identifier . 'D')
+        ];
+        $this->assertSame($expectedResult, $actualResult);
+    }
+
+    /**
      * @test Implementation
      */
     public function flushByTagRemovesTemporarySet()
index 26b61b6..0e2ee1e 100644 (file)
@@ -162,6 +162,24 @@ class TransientMemoryBackendTest extends \TYPO3\CMS\Core\Tests\UnitTestCase
     /**
      * @test
      */
+    public function flushByTagsRemovesCacheEntriesWithSpecifiedTags()
+    {
+        $cache = $this->createMock(\TYPO3\CMS\Core\Cache\Frontend\FrontendInterface::class);
+        $backend = new \TYPO3\CMS\Core\Cache\Backend\TransientMemoryBackend('Testing');
+        $backend->setCache($cache);
+        $data = 'some data' . microtime();
+        $backend->set('TransientMemoryBackendTest1', $data, ['UnitTestTag%test', 'UnitTestTag%boring']);
+        $backend->set('TransientMemoryBackendTest2', $data, ['UnitTestTag%test', 'UnitTestTag%special']);
+        $backend->set('TransientMemoryBackendTest3', $data, ['UnitTestTag%test']);
+        $backend->flushByTags(['UnitTestTag%special', 'UnitTestTag%boring']);
+        $this->assertFalse($backend->has('TransientMemoryBackendTest1'), 'TransientMemoryBackendTest1');
+        $this->assertFalse($backend->has('TransientMemoryBackendTest2'), 'TransientMemoryBackendTest2');
+        $this->assertTrue($backend->has('TransientMemoryBackendTest3'), 'TransientMemoryBackendTest3');
+    }
+
+    /**
+     * @test
+     */
     public function flushRemovesAllCacheEntries()
     {
         $cache = $this->createMock(\TYPO3\CMS\Core\Cache\Frontend\FrontendInterface::class);
index a15798d..474c9ef 100644 (file)
@@ -176,6 +176,50 @@ class Typo3DatabaseBackendTest extends UnitTestCase
         $subject->flush();
     }
 
+    public function flushByTagCallsDeleteOnConnection()
+    {
+        $frontendProphecy = $this->prophesize(FrontendInterface::class);
+        $frontendProphecy->getIdentifier()->willReturn('cache_test');
+
+        $subject = new Typo3DatabaseBackend('Testing');
+        $subject->setCache($frontendProphecy->reveal());
+
+        $connectionProphet = $this->prophesize(Connection::class);
+        $connectionProphet->delete('cf_cache_test')->shouldBeCalled()->willReturn(0);
+        $connectionProphet->delete('cf_cache_test_tags')->shouldBeCalled()->willReturn(0);
+
+        $connectionPoolProphet = $this->prophesize(ConnectionPool::class);
+        $connectionPoolProphet->getConnectionForTable(Argument::cetera())->willReturn($connectionProphet->reveal());
+
+        // Two instances are required as there are different tables being cleared
+        GeneralUtility::addInstance(ConnectionPool::class, $connectionPoolProphet->reveal());
+        GeneralUtility::addInstance(ConnectionPool::class, $connectionPoolProphet->reveal());
+
+        $subject->flushByTag('Tag');
+    }
+
+    public function flushByTagsCallsDeleteOnConnection()
+    {
+        $frontendProphecy = $this->prophesize(FrontendInterface::class);
+        $frontendProphecy->getIdentifier()->willReturn('cache_test');
+
+        $subject = new Typo3DatabaseBackend('Testing');
+        $subject->setCache($frontendProphecy->reveal());
+
+        $connectionProphet = $this->prophesize(Connection::class);
+        $connectionProphet->delete('cf_cache_test')->shouldBeCalled()->willReturn(0);
+        $connectionProphet->delete('cf_cache_test_tags')->shouldBeCalled()->willReturn(0);
+
+        $connectionPoolProphet = $this->prophesize(ConnectionPool::class);
+        $connectionPoolProphet->getConnectionForTable(Argument::cetera())->willReturn($connectionProphet->reveal());
+
+        // Two instances are required as there are different tables being cleared
+        GeneralUtility::addInstance(ConnectionPool::class, $connectionPoolProphet->reveal());
+        GeneralUtility::addInstance(ConnectionPool::class, $connectionPoolProphet->reveal());
+
+        $subject->flushByTag(['Tag1', 'Tag2']);
+    }
+
     /**
      * @test
      */
@@ -184,6 +228,16 @@ class Typo3DatabaseBackendTest extends UnitTestCase
         $subject = new Typo3DatabaseBackend('Testing');
         $this->expectException(Exception::class);
         $this->expectExceptionCode(1236518288);
-        $subject->flushByTag([]);
+        $subject->flushByTag('Tag');
+    }
+    /**
+     * @test
+     */
+    public function flushByTagsThrowsExceptionIfFrontendWasNotSet()
+    {
+        $subject = new Typo3DatabaseBackend('Testing');
+        $this->expectException(Exception::class);
+        $this->expectExceptionCode(1236518288);
+        $subject->flushByTags([]);
     }
 }
index f31fbe1..c6e0868 100644 (file)
@@ -172,6 +172,22 @@ class WincacheBackendTest extends \TYPO3\CMS\Core\Tests\UnitTestCase
     /**
      * @test
      */
+    public function flushByTagsRemovesCacheEntriesWithSpecifiedTags()
+    {
+        $backend = $this->setUpBackend();
+        $data = 'some data' . microtime();
+        $backend->set('BackendWincacheTest1', $data, ['UnitTestTag%test', 'UnitTestTag%boring']);
+        $backend->set('BackendWincacheTest2', $data, ['UnitTestTag%test', 'UnitTestTag%special']);
+        $backend->set('BackendWincacheTest3', $data, ['UnitTestTag%test']);
+        $backend->flushByTag('UnitTestTag%special', 'UnitTestTag%boring');
+        $this->assertTrue($backend->has('BackendWincacheTest1'), 'BackendWincacheTest1');
+        $this->assertFalse($backend->has('BackendWincacheTest2'), 'BackendWincacheTest2');
+        $this->assertTrue($backend->has('BackendWincacheTest3'), 'BackendWincacheTest3');
+    }
+
+    /**
+     * @test
+     */
     public function flushRemovesAllCacheEntries()
     {
         $backend = $this->setUpBackend();
index c841012..a03fe9b 100644 (file)
@@ -141,6 +141,28 @@ class CacheManagerTest extends UnitTestCase
     /**
      * @test
      */
+    public function flushCachesByTagsCallsTheFlushByTagsMethodOfAllRegisteredCaches()
+    {
+        $manager = new CacheManager();
+        $cache1 = $this->getMockBuilder(AbstractFrontend::class)
+            ->setMethods(['getIdentifier', 'set', 'get', 'getByTag', 'has', 'remove', 'flush', 'flushByTags'])
+            ->disableOriginalConstructor()
+            ->getMock();
+        $cache1->expects($this->atLeastOnce())->method('getIdentifier')->will($this->returnValue('cache1'));
+        $cache1->expects($this->once())->method('flushByTags')->with($this->equalTo(['theTag']));
+        $manager->registerCache($cache1);
+        $cache2 = $this->getMockBuilder(AbstractFrontend::class)
+            ->setMethods(['getIdentifier', 'set', 'get', 'getByTag', 'has', 'remove', 'flush', 'flushByTags'])
+            ->disableOriginalConstructor()
+            ->getMock();
+        $cache2->expects($this->once())->method('flushByTags')->with($this->equalTo(['theTag']));
+        $manager->registerCache($cache2);
+        $manager->flushCachesByTags(['theTag']);
+    }
+
+    /**
+     * @test
+     */
     public function flushCachesCallsTheFlushMethodOfAllRegisteredCaches()
     {
         $manager = new CacheManager();
index 6a861c9..4f6cf54 100644 (file)
@@ -65,6 +65,10 @@ class FrontendFixture implements FrontendInterface
     {
     }
 
+    public function flushByTags(array $tags)
+    {
+    }
+
     public function collectGarbage()
     {
     }
index 227abfe..a4fd5be 100644 (file)
@@ -103,7 +103,7 @@ class AbstractFrontendTest extends \TYPO3\CMS\Core\Tests\UnitTestCase
         $tag = 'sometag';
         $identifier = 'someCacheIdentifier';
         $backend = $this->getMockBuilder(\TYPO3\CMS\Core\Cache\Backend\TaggableBackendInterface::class)
-            ->setMethods(['setCache', 'get', 'set', 'has', 'remove', 'findIdentifiersByTag', 'flush', 'flushByTag', 'collectGarbage'])
+            ->setMethods(['setCache', 'get', 'set', 'has', 'remove', 'findIdentifiersByTag', 'flush', 'flushByTag', 'flushByTags', 'collectGarbage'])
             ->disableOriginalConstructor()
             ->getMock();
         $backend->expects($this->once())->method('flushByTag')->with($tag);
@@ -117,6 +117,25 @@ class AbstractFrontendTest extends \TYPO3\CMS\Core\Tests\UnitTestCase
     /**
      * @test
      */
+    public function flushByTagsCallsBackendIfItIsATaggableBackend()
+    {
+        $tag = 'sometag';
+        $identifier = 'someCacheIdentifier';
+        $backend = $this->getMockBuilder(\TYPO3\CMS\Core\Cache\Backend\TaggableBackendInterface::class)
+            ->setMethods(['setCache', 'get', 'set', 'has', 'remove', 'findIdentifiersByTag', 'flush', 'flushByTag', 'flushByTags', 'collectGarbage'])
+            ->disableOriginalConstructor()
+            ->getMock();
+        $backend->expects($this->once())->method('flushByTags')->with([$tag]);
+        $cache = $this->getMockBuilder(\TYPO3\CMS\Core\Cache\Frontend\StringFrontend::class)
+            ->setMethods(['__construct', 'get', 'set', 'has', 'remove', 'getByTag'])
+            ->setConstructorArgs([$identifier, $backend])
+            ->getMock();
+        $cache->flushByTags([$tag]);
+    }
+
+    /**
+     * @test
+     */
     public function collectGarbageCallsBackend()
     {
         $identifier = 'someCacheIdentifier';
index 209b72c..e264eb3 100644 (file)
@@ -16,6 +16,7 @@ namespace TYPO3\CMS\Core\Tests\Unit\DataHandler;
 
 use Prophecy\Argument;
 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
+use TYPO3\CMS\Core\Cache\CacheManager;
 use TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools;
 use TYPO3\CMS\Core\DataHandling\DataHandler;
 use TYPO3\CMS\Core\Tests\AccessibleObjectInterface;
@@ -349,7 +350,18 @@ class DataHandlerTest extends \TYPO3\CMS\Core\Tests\UnitTestCase
 
         /** @var $subject DataHandler|\PHPUnit_Framework_MockObject_MockObject */
         $subject = $this->getMockBuilder(DataHandler::class)
-            ->setMethods(['newlog', 'checkModifyAccessList', 'tableReadOnly', 'checkRecordUpdateAccess', 'recordInfo'])
+            ->setMethods([
+                'newlog',
+                'checkModifyAccessList',
+                'tableReadOnly',
+                'checkRecordUpdateAccess',
+                'recordInfo',
+                'getCacheManager',
+                'registerElementsToBeDeleted',
+                'unsetElementsToBeDeleted',
+                'resetElementsToBeDeleted'
+            ])
+            ->disableOriginalConstructor()
             ->getMock();
 
         $subject->bypassWorkspaceRestrictions = false;
@@ -360,10 +372,18 @@ class DataHandlerTest extends \TYPO3\CMS\Core\Tests\UnitTestCase
                 ]
             ]
         ];
+
+        $cacheManagerMock = $this->getMockBuilder(CacheManager::class)
+            ->setMethods(['flushCachesInGroupByTags'])
+            ->getMock();
+        $cacheManagerMock->expects($this->once())->method('flushCachesInGroupByTags')->with('pages', []);
+
+        $subject->expects($this->once())->method('getCacheManager')->willReturn($cacheManagerMock);
         $subject->expects($this->once())->method('recordInfo')->will($this->returnValue(null));
         $subject->expects($this->once())->method('checkModifyAccessList')->with('pages')->will($this->returnValue(true));
         $subject->expects($this->once())->method('tableReadOnly')->with('pages')->will($this->returnValue(false));
         $subject->expects($this->once())->method('checkRecordUpdateAccess')->will($this->returnValue(true));
+        $subject->expects($this->once())->method('unsetElementsToBeDeleted')->willReturnArgument(0);
 
         /** @var BackendUserAuthentication|\PHPUnit_Framework_MockObject_MockObject $backEndUser */
         $backEndUser = $this->createMock(BackendUserAuthentication::class);
index 04cb590..b1c2911 100644 (file)
@@ -67,9 +67,10 @@ class CacheService implements \TYPO3\CMS\Core\SingletonInterface
             if (!is_array($pageIdsToClear)) {
                 $pageIdsToClear = [(int)$pageIdsToClear];
             }
-            foreach ($pageIdsToClear as $pageId) {
-                $this->cacheManager->flushCachesInGroupByTag('pages', 'pageId_' . $pageId);
-            }
+            $tags = array_map(function ($item) {
+                return 'pageId_' . $item;
+            }, $pageIdsToClear);
+            $this->cacheManager->flushCachesInGroupByTags('pages', $tags);
         }
     }
 
index 305788a..c9cc1e6 100644 (file)
@@ -41,7 +41,7 @@ class CacheServiceTest extends \TYPO3\CMS\Core\Tests\UnitTestCase
      */
     public function clearPageCacheConvertsPageIdsToArray()
     {
-        $this->cacheManagerMock->expects($this->once())->method('flushCachesInGroupByTag')->with('pages', 'pageId_123');
+        $this->cacheManagerMock->expects($this->once())->method('flushCachesInGroupByTags')->with('pages', ['pageId_123']);
         $this->cacheService->clearPageCache(123);
     }
 
@@ -50,7 +50,7 @@ class CacheServiceTest extends \TYPO3\CMS\Core\Tests\UnitTestCase
      */
     public function clearPageCacheConvertsPageIdsToNumericArray()
     {
-        $this->cacheManagerMock->expects($this->once())->method('flushCachesInGroupByTag')->with('pages', 'pageId_0');
+        $this->cacheManagerMock->expects($this->once())->method('flushCachesInGroupByTags')->with('pages', ['pageId_0']);
         $this->cacheService->clearPageCache('Foo');
     }
 
@@ -68,9 +68,7 @@ class CacheServiceTest extends \TYPO3\CMS\Core\Tests\UnitTestCase
      */
     public function clearPageCacheUsesCacheManagerToFlushCacheOfSpecifiedPages()
     {
-        $this->cacheManagerMock->expects($this->at(0))->method('flushCachesInGroupByTag')->with('pages', 'pageId_1');
-        $this->cacheManagerMock->expects($this->at(1))->method('flushCachesInGroupByTag')->with('pages', 'pageId_2');
-        $this->cacheManagerMock->expects($this->at(2))->method('flushCachesInGroupByTag')->with('pages', 'pageId_3');
+        $this->cacheManagerMock->expects($this->at(0))->method('flushCachesInGroupByTags')->with('pages', ['pageId_1', 'pageId_2', 'pageId_3']);
         $this->cacheService->clearPageCache([1, 2, 3]);
     }
 
@@ -79,9 +77,7 @@ class CacheServiceTest extends \TYPO3\CMS\Core\Tests\UnitTestCase
      */
     public function clearsCachesOfRegisteredPageIds()
     {
-        $this->cacheManagerMock->expects($this->at(0))->method('flushCachesInGroupByTag')->with('pages', 'pageId_2');
-        $this->cacheManagerMock->expects($this->at(1))->method('flushCachesInGroupByTag')->with('pages', 'pageId_15');
-        $this->cacheManagerMock->expects($this->at(2))->method('flushCachesInGroupByTag')->with('pages', 'pageId_8');
+        $this->cacheManagerMock->expects($this->at(0))->method('flushCachesInGroupByTags')->with('pages', ['pageId_2', 'pageId_15', 'pageId_8']);
 
         $this->cacheService->getPageIdStack()->push(8);
         $this->cacheService->getPageIdStack()->push(15);
@@ -95,10 +91,7 @@ class CacheServiceTest extends \TYPO3\CMS\Core\Tests\UnitTestCase
      */
     public function clearsCachesOfDuplicateRegisteredPageIdsOnlyOnce()
     {
-        $this->cacheManagerMock->expects($this->at(0))->method('flushCachesInGroupByTag')->with('pages', 'pageId_2');
-        $this->cacheManagerMock->expects($this->at(1))->method('flushCachesInGroupByTag')->with('pages', 'pageId_15');
-        $this->cacheManagerMock->expects($this->at(2))->method('flushCachesInGroupByTag')->with('pages', 'pageId_8');
-        $this->cacheManagerMock->expects($this->exactly(3))->method('flushCachesInGroupByTag');
+        $this->cacheManagerMock->expects($this->at(0))->method('flushCachesInGroupByTags')->with('pages', ['pageId_2', 'pageId_15', 'pageId_8']);
 
         $this->cacheService->getPageIdStack()->push(8);
         $this->cacheService->getPageIdStack()->push(15);