From b8621ebfe2b65826d25e80519209e5420df436a8 Mon Sep 17 00:00:00 2001
From: Claus Due <claus@namelesscoder.net>
Date: Mon, 7 Nov 2016 17:09:35 +0100
Subject: [PATCH] [BUGFIX] Flush all cache tags in bulk when deleting records

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>
---
 .../Classes/Cache/Backend/AbstractBackend.php | 16 ++++
 .../Backend/TaggableBackendInterface.php      |  9 +++
 .../Cache/Backend/Typo3DatabaseBackend.php    | 76 ++++++++++++++++++-
 .../core/Classes/Cache/CacheManager.php       | 72 ++++++++++++++----
 .../Cache/Frontend/AbstractFrontend.php       | 31 +++++++-
 .../Cache/Frontend/FrontendInterface.php      |  9 +++
 .../core/Classes/DataHandling/DataHandler.php |  8 +-
 .../Unit/Cache/Backend/ApcBackendTest.php     | 16 ++++
 .../Unit/Cache/Backend/ApcuBackendTest.php    | 16 ++++
 .../Cache/Backend/MemcachedBackendTest.php    | 16 ++++
 .../Unit/Cache/Backend/PdoBackendTest.php     | 16 ++++
 .../Unit/Cache/Backend/RedisBackendTest.php   | 22 ++++++
 .../Backend/TransientMemoryBackendTest.php    | 18 +++++
 .../Backend/Typo3DatabaseBackendTest.php      | 56 +++++++++++++-
 .../Cache/Backend/WincacheBackendTest.php     | 16 ++++
 .../Tests/Unit/Cache/CacheManagerTest.php     | 22 ++++++
 .../Unit/Cache/Fixtures/FrontendFixture.php   |  4 +
 .../Cache/Frontend/AbstractFrontendTest.php   | 21 ++++-
 .../Unit/DataHandling/DataHandlerTest.php     | 22 +++++-
 .../extbase/Classes/Service/CacheService.php  |  7 +-
 .../Tests/Unit/Service/CacheServiceTest.php   | 17 ++---
 21 files changed, 446 insertions(+), 44 deletions(-)

diff --git a/typo3/sysext/core/Classes/Cache/Backend/AbstractBackend.php b/typo3/sysext/core/Classes/Cache/Backend/AbstractBackend.php
index 6d80002a2c03..ee3f64181c4c 100644
--- a/typo3/sysext/core/Classes/Cache/Backend/AbstractBackend.php
+++ b/typo3/sysext/core/Classes/Cache/Backend/AbstractBackend.php
@@ -106,6 +106,22 @@ abstract class AbstractBackend implements \TYPO3\CMS\Core\Cache\Backend\BackendI
         $this->defaultLifetime = $defaultLifetime;
     }
 
+    /**
+     * 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.
diff --git a/typo3/sysext/core/Classes/Cache/Backend/TaggableBackendInterface.php b/typo3/sysext/core/Classes/Cache/Backend/TaggableBackendInterface.php
index d9f2949f6928..8dd7db8a0c20 100644
--- a/typo3/sysext/core/Classes/Cache/Backend/TaggableBackendInterface.php
+++ b/typo3/sysext/core/Classes/Cache/Backend/TaggableBackendInterface.php
@@ -26,6 +26,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
diff --git a/typo3/sysext/core/Classes/Cache/Backend/Typo3DatabaseBackend.php b/typo3/sysext/core/Classes/Cache/Backend/Typo3DatabaseBackend.php
index d7f59f30a150..817461ec14da 100644
--- a/typo3/sysext/core/Classes/Cache/Backend/Typo3DatabaseBackend.php
+++ b/typo3/sysext/core/Classes/Cache/Backend/Typo3DatabaseBackend.php
@@ -261,6 +261,69 @@ class Typo3DatabaseBackend extends AbstractBackend implements TaggableBackendInt
             ->truncate($this->tagsTable);
     }
 
+    /**
+     * 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.
      *
@@ -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();
diff --git a/typo3/sysext/core/Classes/Cache/CacheManager.php b/typo3/sysext/core/Classes/Cache/CacheManager.php
index c4b56e7a7ad7..bb6321e36dfc 100644
--- a/typo3/sysext/core/Classes/Cache/CacheManager.php
+++ b/typo3/sysext/core/Classes/Cache/CacheManager.php
@@ -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);
+            }
+        }
     }
 
     /**
@@ -214,6 +241,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.
      *
diff --git a/typo3/sysext/core/Classes/Cache/Frontend/AbstractFrontend.php b/typo3/sysext/core/Classes/Cache/Frontend/AbstractFrontend.php
index cdddad469f8e..b32fc92d1233 100644
--- a/typo3/sysext/core/Classes/Cache/Frontend/AbstractFrontend.php
+++ b/typo3/sysext/core/Classes/Cache/Frontend/AbstractFrontend.php
@@ -121,6 +121,25 @@ abstract class AbstractFrontend implements FrontendInterface
         $this->backend->flush();
     }
 
+    /**
+     * 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.
      *
@@ -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;
     }
 }
diff --git a/typo3/sysext/core/Classes/Cache/Frontend/FrontendInterface.php b/typo3/sysext/core/Classes/Cache/Frontend/FrontendInterface.php
index 43868c38f207..5863c619f22c 100644
--- a/typo3/sysext/core/Classes/Cache/Frontend/FrontendInterface.php
+++ b/typo3/sysext/core/Classes/Cache/Frontend/FrontendInterface.php
@@ -114,6 +114,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
      *
diff --git a/typo3/sysext/core/Classes/DataHandling/DataHandler.php b/typo3/sysext/core/Classes/DataHandling/DataHandler.php
index c5437d90fe22..24629cf2e0fa 100644
--- a/typo3/sysext/core/Classes/DataHandling/DataHandler.php
+++ b/typo3/sysext/core/Classes/DataHandling/DataHandler.php
@@ -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:
diff --git a/typo3/sysext/core/Tests/Unit/Cache/Backend/ApcBackendTest.php b/typo3/sysext/core/Tests/Unit/Cache/Backend/ApcBackendTest.php
index cc3639352158..24f63dbdaeee 100644
--- a/typo3/sysext/core/Tests/Unit/Cache/Backend/ApcBackendTest.php
+++ b/typo3/sysext/core/Tests/Unit/Cache/Backend/ApcBackendTest.php
@@ -207,6 +207,22 @@ class ApcBackendTest extends \TYPO3\CMS\Core\Tests\UnitTestCase
         $this->assertTrue($backend->has('BackendAPCTest3'), 'BackendAPCTest3');
     }
 
+    /**
+     * @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
      */
diff --git a/typo3/sysext/core/Tests/Unit/Cache/Backend/ApcuBackendTest.php b/typo3/sysext/core/Tests/Unit/Cache/Backend/ApcuBackendTest.php
index 865ba7e43632..1620d3c769f5 100644
--- a/typo3/sysext/core/Tests/Unit/Cache/Backend/ApcuBackendTest.php
+++ b/typo3/sysext/core/Tests/Unit/Cache/Backend/ApcuBackendTest.php
@@ -206,6 +206,22 @@ class ApcuBackendTest extends UnitTestCase
         $this->assertTrue($backend->has('BackendAPCUTest3'));
     }
 
+    /**
+     * @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
      */
diff --git a/typo3/sysext/core/Tests/Unit/Cache/Backend/MemcachedBackendTest.php b/typo3/sysext/core/Tests/Unit/Cache/Backend/MemcachedBackendTest.php
index 61d1f2e87ac7..2509c93ee4ce 100644
--- a/typo3/sysext/core/Tests/Unit/Cache/Backend/MemcachedBackendTest.php
+++ b/typo3/sysext/core/Tests/Unit/Cache/Backend/MemcachedBackendTest.php
@@ -192,6 +192,22 @@ class MemcachedBackendTest extends \TYPO3\CMS\Core\Tests\UnitTestCase
         $this->assertTrue($backend->has('BackendMemcacheTest3'), 'BackendMemcacheTest3');
     }
 
+    /**
+     * @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
      */
diff --git a/typo3/sysext/core/Tests/Unit/Cache/Backend/PdoBackendTest.php b/typo3/sysext/core/Tests/Unit/Cache/Backend/PdoBackendTest.php
index 95151caf6439..57c0c6e71c16 100644
--- a/typo3/sysext/core/Tests/Unit/Cache/Backend/PdoBackendTest.php
+++ b/typo3/sysext/core/Tests/Unit/Cache/Backend/PdoBackendTest.php
@@ -176,6 +176,22 @@ class PdoBackendTest extends \TYPO3\CMS\Core\Tests\UnitTestCase
         $this->assertTrue($backend->has('PdoBackendTest3'), 'PdoBackendTest3');
     }
 
+    /**
+     * @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
      */
diff --git a/typo3/sysext/core/Tests/Unit/Cache/Backend/RedisBackendTest.php b/typo3/sysext/core/Tests/Unit/Cache/Backend/RedisBackendTest.php
index c44f0b01d456..40039f96c4d7 100644
--- a/typo3/sysext/core/Tests/Unit/Cache/Backend/RedisBackendTest.php
+++ b/typo3/sysext/core/Tests/Unit/Cache/Backend/RedisBackendTest.php
@@ -762,6 +762,28 @@ class RedisBackendTest extends \TYPO3\CMS\Core\Tests\UnitTestCase
         $this->assertSame($expectedResult, $actualResult);
     }
 
+    /**
+     * @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
      */
diff --git a/typo3/sysext/core/Tests/Unit/Cache/Backend/TransientMemoryBackendTest.php b/typo3/sysext/core/Tests/Unit/Cache/Backend/TransientMemoryBackendTest.php
index 26b61b61bec2..0e2ee1e4ef71 100644
--- a/typo3/sysext/core/Tests/Unit/Cache/Backend/TransientMemoryBackendTest.php
+++ b/typo3/sysext/core/Tests/Unit/Cache/Backend/TransientMemoryBackendTest.php
@@ -159,6 +159,24 @@ class TransientMemoryBackendTest extends \TYPO3\CMS\Core\Tests\UnitTestCase
         $this->assertTrue($backend->has('TransientMemoryBackendTest3'), 'TransientMemoryBackendTest3');
     }
 
+    /**
+     * @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
      */
diff --git a/typo3/sysext/core/Tests/Unit/Cache/Backend/Typo3DatabaseBackendTest.php b/typo3/sysext/core/Tests/Unit/Cache/Backend/Typo3DatabaseBackendTest.php
index a15798d91141..474c9ef97827 100644
--- a/typo3/sysext/core/Tests/Unit/Cache/Backend/Typo3DatabaseBackendTest.php
+++ b/typo3/sysext/core/Tests/Unit/Cache/Backend/Typo3DatabaseBackendTest.php
@@ -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([]);
     }
 }
diff --git a/typo3/sysext/core/Tests/Unit/Cache/Backend/WincacheBackendTest.php b/typo3/sysext/core/Tests/Unit/Cache/Backend/WincacheBackendTest.php
index f31fbe198bd6..c6e0868bd814 100644
--- a/typo3/sysext/core/Tests/Unit/Cache/Backend/WincacheBackendTest.php
+++ b/typo3/sysext/core/Tests/Unit/Cache/Backend/WincacheBackendTest.php
@@ -169,6 +169,22 @@ class WincacheBackendTest extends \TYPO3\CMS\Core\Tests\UnitTestCase
         $this->assertTrue($backend->has('BackendWincacheTest3'), 'BackendWincacheTest3');
     }
 
+    /**
+     * @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
      */
diff --git a/typo3/sysext/core/Tests/Unit/Cache/CacheManagerTest.php b/typo3/sysext/core/Tests/Unit/Cache/CacheManagerTest.php
index c8410122c9f2..a03fe9b4908d 100644
--- a/typo3/sysext/core/Tests/Unit/Cache/CacheManagerTest.php
+++ b/typo3/sysext/core/Tests/Unit/Cache/CacheManagerTest.php
@@ -138,6 +138,28 @@ class CacheManagerTest extends UnitTestCase
         $manager->flushCachesByTag('theTag');
     }
 
+    /**
+     * @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
      */
diff --git a/typo3/sysext/core/Tests/Unit/Cache/Fixtures/FrontendFixture.php b/typo3/sysext/core/Tests/Unit/Cache/Fixtures/FrontendFixture.php
index 6a861c9f5b8b..4f6cf54b0a98 100644
--- a/typo3/sysext/core/Tests/Unit/Cache/Fixtures/FrontendFixture.php
+++ b/typo3/sysext/core/Tests/Unit/Cache/Fixtures/FrontendFixture.php
@@ -65,6 +65,10 @@ class FrontendFixture implements FrontendInterface
     {
     }
 
+    public function flushByTags(array $tags)
+    {
+    }
+
     public function collectGarbage()
     {
     }
diff --git a/typo3/sysext/core/Tests/Unit/Cache/Frontend/AbstractFrontendTest.php b/typo3/sysext/core/Tests/Unit/Cache/Frontend/AbstractFrontendTest.php
index 227abfe6a7b1..a4fd5bea2682 100644
--- a/typo3/sysext/core/Tests/Unit/Cache/Frontend/AbstractFrontendTest.php
+++ b/typo3/sysext/core/Tests/Unit/Cache/Frontend/AbstractFrontendTest.php
@@ -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);
@@ -114,6 +114,25 @@ class AbstractFrontendTest extends \TYPO3\CMS\Core\Tests\UnitTestCase
         $cache->flushByTag($tag);
     }
 
+    /**
+     * @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
      */
diff --git a/typo3/sysext/core/Tests/Unit/DataHandling/DataHandlerTest.php b/typo3/sysext/core/Tests/Unit/DataHandling/DataHandlerTest.php
index 209b72c6beb7..e264eb38aadb 100644
--- a/typo3/sysext/core/Tests/Unit/DataHandling/DataHandlerTest.php
+++ b/typo3/sysext/core/Tests/Unit/DataHandling/DataHandlerTest.php
@@ -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);
diff --git a/typo3/sysext/extbase/Classes/Service/CacheService.php b/typo3/sysext/extbase/Classes/Service/CacheService.php
index 04cb59077979..b1c2911ba05f 100644
--- a/typo3/sysext/extbase/Classes/Service/CacheService.php
+++ b/typo3/sysext/extbase/Classes/Service/CacheService.php
@@ -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);
         }
     }
 
diff --git a/typo3/sysext/extbase/Tests/Unit/Service/CacheServiceTest.php b/typo3/sysext/extbase/Tests/Unit/Service/CacheServiceTest.php
index 305788a73c4c..c9cc1e69c425 100644
--- a/typo3/sysext/extbase/Tests/Unit/Service/CacheServiceTest.php
+++ b/typo3/sysext/extbase/Tests/Unit/Service/CacheServiceTest.php
@@ -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);
-- 
GitLab