From d8177f2600d915a2228d6db6f19bf7be7c98ca98 Mon Sep 17 00:00:00 2001
From: Markus Klein <markus.klein@typo3.org>
Date: Tue, 3 Sep 2024 13:52:05 +0200
Subject: [PATCH] [FEATURE] Allow key prefix for Redis keys
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Adds the possibility to configure Redis key prefixes
for cache and session backends, which allows to use
the same Redis database for multiple caches and/or
TYPO3 instances.

Resolves: #104451
Releases: main
Change-Id: Id6aa2f7ba9a4a0dbfb2335150602aeb836e09b90
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/85324
Tested-by: Benni Mack <benni@typo3.org>
Tested-by: core-ci <typo3@b13.com>
Tested-by: Stefan Bürk <stefan@buerk.tech>
Reviewed-by: Benni Mack <benni@typo3.org>
Reviewed-by: Stefan Bürk <stefan@buerk.tech>
---
 .../Classes/Cache/Backend/RedisBackend.php    | 29 ++++--
 .../Session/Backend/RedisSessionBackend.php   |  2 +-
 ...51-RedisBackendsSupportForKeyPrefixing.rst | 74 +++++++++++++++
 .../Cache/Backend/RedisBackendTest.php        | 90 +++++++++++++++++++
 4 files changed, 188 insertions(+), 7 deletions(-)
 create mode 100644 typo3/sysext/core/Documentation/Changelog/13.3/Feature-104451-RedisBackendsSupportForKeyPrefixing.rst

diff --git a/typo3/sysext/core/Classes/Cache/Backend/RedisBackend.php b/typo3/sysext/core/Classes/Cache/Backend/RedisBackend.php
index 038101957393..865e0c16a2c7 100644
--- a/typo3/sysext/core/Classes/Cache/Backend/RedisBackend.php
+++ b/typo3/sysext/core/Classes/Cache/Backend/RedisBackend.php
@@ -132,6 +132,11 @@ class RedisBackend extends AbstractBackend implements TaggableBackendInterface
      */
     protected $connectionTimeout = 0;
 
+    /**
+     * Used as prefix for all Redis keys/identifiers
+     */
+    protected string $keyPrefix = '';
+
     /**
      * Construct this backend
      *
@@ -292,6 +297,11 @@ class RedisBackend extends AbstractBackend implements TaggableBackendInterface
         $this->connectionTimeout = $connectionTimeout;
     }
 
+    protected function setKeyPrefix(string $keyPrefix): void
+    {
+        $this->keyPrefix = $keyPrefix;
+    }
+
     /**
      * Save data in the cache
      *
@@ -445,14 +455,21 @@ class RedisBackend extends AbstractBackend implements TaggableBackendInterface
 
     /**
      * Removes all cache entries of this cache.
-     *
-     * Scales O(1) with number of cache entries
      */
     public function flush(): void
     {
-        if ($this->connected) {
+        if (!$this->connected) {
+            return;
+        }
+        // unless we have a key prefix all data can be flushed
+        if ($this->keyPrefix === '') {
             $this->redis->flushDB();
+            return;
         }
+        $keys = $this->redis->keys($this->keyPrefix . '*');
+        $queue = $this->redis->multi();
+        $queue->del($keys);
+        $queue->exec();
     }
 
     /**
@@ -556,16 +573,16 @@ class RedisBackend extends AbstractBackend implements TaggableBackendInterface
 
     protected function getDataIdentifier(string $identifier): string
     {
-        return self::IDENTIFIER_DATA_PREFIX . $identifier;
+        return $this->keyPrefix . self::IDENTIFIER_DATA_PREFIX . $identifier;
     }
 
     protected function getTagsIdentifier(string $identifier): string
     {
-        return self::IDENTIFIER_TAGS_PREFIX . $identifier;
+        return $this->keyPrefix . self::IDENTIFIER_TAGS_PREFIX . $identifier;
     }
 
     protected function getTagIdentifier(string $tag): string
     {
-        return self::TAG_IDENTIFIERS_PREFIX . $tag;
+        return $this->keyPrefix . self::TAG_IDENTIFIERS_PREFIX . $tag;
     }
 }
diff --git a/typo3/sysext/core/Classes/Session/Backend/RedisSessionBackend.php b/typo3/sysext/core/Classes/Session/Backend/RedisSessionBackend.php
index 009fb02910c0..df34d024315d 100644
--- a/typo3/sysext/core/Classes/Session/Backend/RedisSessionBackend.php
+++ b/typo3/sysext/core/Classes/Session/Backend/RedisSessionBackend.php
@@ -60,7 +60,7 @@ class RedisSessionBackend implements SessionBackendInterface, HashableSessionBac
 
         $this->configuration = $configuration;
         $this->identifier = $identifier;
-        $this->applicationIdentifier = 'typo3_ses_'
+        $this->applicationIdentifier = ($configuration['keyPrefix'] ?? '') . 'typo3_ses_'
             . $identifier . '_'
             . sha1($GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey']) . '_';
     }
diff --git a/typo3/sysext/core/Documentation/Changelog/13.3/Feature-104451-RedisBackendsSupportForKeyPrefixing.rst b/typo3/sysext/core/Documentation/Changelog/13.3/Feature-104451-RedisBackendsSupportForKeyPrefixing.rst
new file mode 100644
index 000000000000..7de513430fa1
--- /dev/null
+++ b/typo3/sysext/core/Documentation/Changelog/13.3/Feature-104451-RedisBackendsSupportForKeyPrefixing.rst
@@ -0,0 +1,74 @@
+.. include:: /Includes.rst.txt
+
+.. _feature-104451-1721646565:
+
+===========================================================
+Feature: #104451 - Redis backends support for key prefixing
+===========================================================
+
+See :issue:`104451`
+
+Description
+===========
+
+It is now possible to add a dedicated key prefix for all invocations of a Redis
+cache or session backend. This allows to use the same Redis database for multiple
+caches or even for multiple TYPO3 instances if the provided prefix is unique.
+
+Possible use cases are:
+
+* Using Redis caching for multiple caches, if only one Redis database is available
+* Pre-fill caches upon deployments using a new prefix (zero downtime deployments)
+
+..  code-block:: php
+    :caption: additional.php example for using Redis as session backend
+
+    $GLOBALS['TYPO3_CONF_VARS']['SYS']['session']['BE'] = [
+        'backend' => \TYPO3\CMS\Core\Session\Backend\RedisSessionBackend::class,
+        'options' => [
+            'hostname' => 'redis',
+            'database' => '11',
+            'compression' => true,
+            'keyPrefix' => 'be_sessions_',
+        ],
+    ];
+    $GLOBALS['TYPO3_CONF_VARS']['SYS']['session']['FE'] = [
+        'backend' => \TYPO3\CMS\Core\Session\Backend\RedisSessionBackend::class,
+        'options' => [
+            'hostname' => 'redis',
+            'database' => '11',
+            'compression' => true,
+            'keyPrefix' => 'fe_sessions_',
+            'has_anonymous' => true,
+        ],
+    ];
+
+..  code-block:: php
+    :caption: additional.php example for pages cache
+
+    $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['pages']['backend'] = \TYPO3\CMS\Core\Cache\Backend\RedisBackend::class;
+    $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['pages']['options']['hostname'] = 'redis';
+    $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['pages']['options']['database'] = 11;
+    $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['pages']['options']['compression'] = true;
+    $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['pages']['options']['keyPrefix'] = 'pages_';
+
+    $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['rootline']['backend'] = \TYPO3\CMS\Core\Cache\Backend\RedisBackend::class;
+    $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['rootline']['options']['hostname'] = 'redis';
+    $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['rootline']['options']['database'] = 11;
+    $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['rootline']['options']['compression'] = true;
+    $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['rootline']['options']['keyPrefix'] = 'rootline_';
+
+Impact
+======
+
+The new feature allows to use the same Redis database for multiple caches or even
+for multiple TYPO3 instances while having no impact on existing configuration.
+
+..  attention::
+    If you start using the same Redis database for multiple caches or
+    using the same database also for session storage, make sure any involved
+    cache configuration uses **a unique key prefix**.
+    If only one of the caches does not use a key prefix, any cache flush
+    operation will always flush the whole database, hence also all other caches/sessions.
+
+.. index:: Frontend, LocalConfiguration, ext:core
diff --git a/typo3/sysext/core/Tests/Functional/Cache/Backend/RedisBackendTest.php b/typo3/sysext/core/Tests/Functional/Cache/Backend/RedisBackendTest.php
index 244473fb9aad..72cf6aee2edf 100644
--- a/typo3/sysext/core/Tests/Functional/Cache/Backend/RedisBackendTest.php
+++ b/typo3/sysext/core/Tests/Functional/Cache/Backend/RedisBackendTest.php
@@ -217,6 +217,96 @@ final class RedisBackendTest extends FunctionalTestCase
         self::assertSame($lifetime, $lifetimeRegisteredInBackend);
     }
 
+    #[Test]
+    public function setSavesEntryWithSpecifiedKeyPrefix(): void
+    {
+        $firstBackend = $this->setUpSubject(['keyPrefix' => 'kp1_']);
+        $secondBackend = $this->setUpSubject(['keyPrefix' => 'kp2_']);
+        $redis = $this->setUpRedis();
+
+        $identifier = StringUtility::getUniqueId('identifier');
+        $data = 'data';
+
+        $firstBackend->set($identifier, $data);
+        self::assertSame($data, $firstBackend->get($identifier));
+        self::assertSame($data, $redis->get(sprintf('kp1_identData:%s', $identifier)));
+        self::assertFalse($redis->get(sprintf('kp2_identData:%s', $identifier)));
+        self::assertFalse($secondBackend->get($identifier));
+    }
+
+    #[Test]
+    public function flushOnPrefixedBackendDoesNotDeleteKeysOfSecondPrefixedBackend(): void
+    {
+        $firstBackend = $this->setUpSubject(['keyPrefix' => 'kp1_']);
+        $secondBackend = $this->setUpSubject(['keyPrefix' => 'kp2_']);
+        $redis = $this->setUpRedis();
+        $redis->flushAll();
+
+        $identifier = StringUtility::getUniqueId('identifier');
+        $data = 'data';
+
+        $firstBackend->set($identifier, $data);
+        $secondBackend->set($identifier, $data);
+        self::assertSame($data, $firstBackend->get($identifier));
+        self::assertSame($data, $secondBackend->get($identifier));
+        self::assertSame($data, $redis->get(sprintf('kp1_identData:%s', $identifier)));
+        self::assertSame($data, $redis->get(sprintf('kp2_identData:%s', $identifier)));
+
+        $firstBackend->flush();
+        self::assertFalse($firstBackend->get($identifier));
+        self::assertFalse($redis->get(sprintf('kp1_identData:%s', $identifier)));
+        self::assertSame($data, $secondBackend->get($identifier));
+        self::assertSame($data, $redis->get(sprintf('kp2_identData:%s', $identifier)));
+    }
+
+    #[Test]
+    public function flushByTagOnPrefixedBackendDoesNotDeleteKeysOfSecondPrefixedBackend(): void
+    {
+        $firstBackend = $this->setUpSubject(['keyPrefix' => 'kp1_']);
+        $secondBackend = $this->setUpSubject(['keyPrefix' => 'kp2_']);
+        $redis = $this->setUpRedis();
+        $redis->flushAll();
+
+        $identifier = StringUtility::getUniqueId('identifier');
+        $tagName = 'some-tag';
+        $data = 'data';
+
+        $firstBackend->set($identifier, $data, [$tagName]);
+        $secondBackend->set($identifier, $data, [$tagName]);
+        self::assertSame($data, $firstBackend->get($identifier));
+        self::assertSame($data, $secondBackend->get($identifier));
+        self::assertSame($data, $redis->get(sprintf('kp1_identData:%s', $identifier)));
+        self::assertSame($data, $redis->get(sprintf('kp2_identData:%s', $identifier)));
+
+        $firstBackend->flushByTag($tagName);
+        self::assertFalse($firstBackend->get($identifier));
+        self::assertFalse($redis->get(sprintf('kp1_identData:%s', $identifier)));
+        self::assertSame($data, $secondBackend->get($identifier));
+        self::assertSame($data, $redis->get(sprintf('kp2_identData:%s', $identifier)));
+    }
+
+    #[Test]
+    public function flushByTagsOnPrefixedBackendDoesNotDeleteKeysOfSecondPrefixedBackend(): void
+    {
+        $firstBackend = $this->setUpSubject(['keyPrefix' => 'kp1_']);
+        $secondBackend = $this->setUpSubject(['keyPrefix' => 'kp2_']);
+        $redis = $this->setUpRedis();
+        $redis->flushAll();
+
+        $identifier = StringUtility::getUniqueId('identifier');
+        $tagName = 'some-tag';
+        $data = 'data';
+
+        $firstBackend->set($identifier, $data, [$tagName]);
+        $secondBackend->set($identifier, $data, [$tagName]);
+        self::assertSame($data, $firstBackend->get($identifier));
+        self::assertSame($data, $secondBackend->get($identifier));
+
+        $firstBackend->flushByTags([$tagName]);
+        self::assertFalse($firstBackend->get($identifier));
+        self::assertSame($data, $secondBackend->get($identifier));
+    }
+
     #[Test]
     public function setSavesEntryWithUnlimitedLifeTime(): void
     {
-- 
GitLab