diff --git a/.github/workflows/phpunit-kvstore.yml b/.github/workflows/phpunit-kvstore.yml
new file mode 100644
index 0000000000000..3e8c4eba19bd3
--- /dev/null
+++ b/.github/workflows/phpunit-kvstore.yml
@@ -0,0 +1,141 @@
+# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+# SPDX-License-Identifier: MIT
+
+name: PHPUnit key-value store
+
+on:
+ pull_request:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: phpunit-kvstore-${{ github.head_ref || github.run_id }}
+ cancel-in-progress: true
+
+env:
+ VALKEY_IMAGE: valkey/valkey:8
+
+jobs:
+ changes:
+ runs-on: ubuntu-latest-low
+
+ outputs:
+ src: ${{ steps.changes.outputs.src}}
+
+ steps:
+ - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
+ id: changes
+ continue-on-error: true
+ with:
+ filters: |
+ src:
+ - '.github/workflows/phpunit-kvstore.yml'
+ - '3rdparty/**'
+ - '**/appinfo/**'
+ - '**.php'
+
+ phpunit-kvstore:
+ runs-on: ubuntu-latest
+
+ needs: changes
+ if: needs.changes.outputs.src != 'false'
+
+ strategy:
+ fail-fast: false
+ matrix:
+ php-versions: ["8.3", "8.5"]
+ # The three supported topologies for the key-value store cache
+ topology: ["single", "cluster", "sentinel"]
+
+ name: KV store ${{ matrix.topology }} (PHP ${{ matrix.php-versions }})
+
+ steps:
+ - name: Checkout server
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+ submodules: true
+
+ - name: Set up php ${{ matrix.php-versions }}
+ uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2
+ timeout-minutes: 5
+ with:
+ php-version: ${{ matrix.php-versions }}
+ # https://docs.nextcloud.com/server/stable/admin_manual/installation/source_installation.html#prerequisites-for-manual-installation
+ extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, pdo_sqlite, posix, session, simplexml, sqlite, xmlreader, xmlwriter, zip, zlib
+ coverage: none
+ ini-file: development
+ ini-values: disable_functions=""
+ env:
+ fail-fast: true
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Start Valkey (single server)
+ if: matrix.topology == 'single'
+ run: |
+ docker run -d --name kv-single -p 6379:6379 "${VALKEY_IMAGE}"
+ timeout 60 sh -c 'until docker exec kv-single valkey-cli ping | grep -q PONG; do sleep 1; done'
+
+ - name: Start Valkey (cluster)
+ if: matrix.topology == 'cluster'
+ run: |
+ for port in 7000 7001 7002 7003 7004 7005; do
+ docker run -d --name "kv-cluster-$port" --network host "${VALKEY_IMAGE}" \
+ valkey-server --port "$port" --cluster-enabled yes \
+ --cluster-config-file "nodes-$port.conf" --cluster-node-timeout 5000 \
+ --appendonly no --save ""
+ done
+ # Wait for every node to answer before forming the cluster
+ for port in 7000 7001 7002 7003 7004 7005; do
+ timeout 60 sh -c "until docker exec kv-cluster-$port valkey-cli -p $port ping | grep -q PONG; do sleep 1; done"
+ done
+ docker exec kv-cluster-7000 valkey-cli --cluster create \
+ 127.0.0.1:7000 127.0.0.1:7001 127.0.0.1:7002 \
+ 127.0.0.1:7003 127.0.0.1:7004 127.0.0.1:7005 \
+ --cluster-replicas 1 --cluster-yes
+
+ - name: Start Valkey (sentinel)
+ if: matrix.topology == 'sentinel'
+ run: |
+ docker run -d --name kv-master --network host "${VALKEY_IMAGE}" \
+ valkey-server --port 6379
+ docker run -d --name kv-replica --network host "${VALKEY_IMAGE}" \
+ valkey-server --port 6380 --replicaof 127.0.0.1 6379
+ printf 'port 26379\nsentinel monitor mymaster 127.0.0.1 6379 1\nsentinel down-after-milliseconds mymaster 5000\nsentinel failover-timeout mymaster 10000\n' > sentinel.conf
+ docker run -d --name kv-sentinel --network host -v "$PWD/sentinel.conf:/etc/valkey/sentinel.conf" \
+ "${VALKEY_IMAGE}" valkey-sentinel /etc/valkey/sentinel.conf
+ timeout 60 sh -c 'until docker exec kv-sentinel valkey-cli -p 26379 ping | grep -q PONG; do sleep 1; done'
+
+ - name: Set up dependencies
+ run: composer i
+
+ - name: Set up Nextcloud
+ run: |
+ mkdir data
+ cp tests/kvstore-${{ matrix.topology }}.config.php config/
+ cp tests/preseed-config.php config/config.php
+ ./occ maintenance:install --verbose --database=sqlite --database-name=nextcloud --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass admin
+ php -f tests/enable_all.php
+
+ - name: PHPUnit key-value store tests
+ run: composer run test -- --group KeyValueCache --log-junit junit.xml
+
+ - name: Print logs
+ if: always()
+ run: |
+ cat data/nextcloud.log
+
+ summary:
+ permissions:
+ contents: none
+ runs-on: ubuntu-latest-low
+ needs: [changes, phpunit-kvstore]
+
+ if: always()
+
+ name: phpunit-kvstore-summary
+
+ steps:
+ - name: Summary status
+ run: if ${{ needs.changes.outputs.src != 'false' && needs.phpunit-kvstore.result != 'success' }}; then exit 1; fi
diff --git a/3rdparty b/3rdparty
index 69b1534909f2a..68dd46844ec29 160000
--- a/3rdparty
+++ b/3rdparty
@@ -1 +1 @@
-Subproject commit 69b1534909f2abd81621d03e3660398743c38ae5
+Subproject commit 68dd46844ec29feba3b1a16c15c7c272ddd8c2c8
diff --git a/apps/settings/composer/composer/autoload_classmap.php b/apps/settings/composer/composer/autoload_classmap.php
index cfa1b1ec0c11d..1538c376eca2a 100644
--- a/apps/settings/composer/composer/autoload_classmap.php
+++ b/apps/settings/composer/composer/autoload_classmap.php
@@ -111,6 +111,7 @@
'OCA\\Settings\\SetupChecks\\LoggingLevel' => $baseDir . '/../lib/SetupChecks/LoggingLevel.php',
'OCA\\Settings\\SetupChecks\\MaintenanceWindowStart' => $baseDir . '/../lib/SetupChecks/MaintenanceWindowStart.php',
'OCA\\Settings\\SetupChecks\\MemcacheConfigured' => $baseDir . '/../lib/SetupChecks/MemcacheConfigured.php',
+ 'OCA\\Settings\\SetupChecks\\MemcacheLegacy' => $baseDir . '/../lib/SetupChecks/MemcacheLegacy.php',
'OCA\\Settings\\SetupChecks\\MimeTypeMigrationAvailable' => $baseDir . '/../lib/SetupChecks/MimeTypeMigrationAvailable.php',
'OCA\\Settings\\SetupChecks\\MysqlRowFormat' => $baseDir . '/../lib/SetupChecks/MysqlRowFormat.php',
'OCA\\Settings\\SetupChecks\\MysqlUnicodeSupport' => $baseDir . '/../lib/SetupChecks/MysqlUnicodeSupport.php',
diff --git a/apps/settings/composer/composer/autoload_static.php b/apps/settings/composer/composer/autoload_static.php
index 2900792c91478..cc73eb0b78510 100644
--- a/apps/settings/composer/composer/autoload_static.php
+++ b/apps/settings/composer/composer/autoload_static.php
@@ -126,6 +126,7 @@ class ComposerStaticInitSettings
'OCA\\Settings\\SetupChecks\\LoggingLevel' => __DIR__ . '/..' . '/../lib/SetupChecks/LoggingLevel.php',
'OCA\\Settings\\SetupChecks\\MaintenanceWindowStart' => __DIR__ . '/..' . '/../lib/SetupChecks/MaintenanceWindowStart.php',
'OCA\\Settings\\SetupChecks\\MemcacheConfigured' => __DIR__ . '/..' . '/../lib/SetupChecks/MemcacheConfigured.php',
+ 'OCA\\Settings\\SetupChecks\\MemcacheLegacy' => __DIR__ . '/..' . '/../lib/SetupChecks/MemcacheLegacy.php',
'OCA\\Settings\\SetupChecks\\MimeTypeMigrationAvailable' => __DIR__ . '/..' . '/../lib/SetupChecks/MimeTypeMigrationAvailable.php',
'OCA\\Settings\\SetupChecks\\MysqlRowFormat' => __DIR__ . '/..' . '/../lib/SetupChecks/MysqlRowFormat.php',
'OCA\\Settings\\SetupChecks\\MysqlUnicodeSupport' => __DIR__ . '/..' . '/../lib/SetupChecks/MysqlUnicodeSupport.php',
diff --git a/apps/settings/lib/AppInfo/Application.php b/apps/settings/lib/AppInfo/Application.php
index 09ef7c54bd966..569a5708b4aa7 100644
--- a/apps/settings/lib/AppInfo/Application.php
+++ b/apps/settings/lib/AppInfo/Application.php
@@ -49,6 +49,7 @@
use OCA\Settings\SetupChecks\LegacySSEKeyFormat;
use OCA\Settings\SetupChecks\MaintenanceWindowStart;
use OCA\Settings\SetupChecks\MemcacheConfigured;
+use OCA\Settings\SetupChecks\MemcacheLegacy;
use OCA\Settings\SetupChecks\MimeTypeMigrationAvailable;
use OCA\Settings\SetupChecks\MysqlRowFormat;
use OCA\Settings\SetupChecks\MysqlUnicodeSupport;
@@ -193,6 +194,7 @@ public function register(IRegistrationContext $context): void {
$context->registerSetupCheck(LegacySSEKeyFormat::class);
$context->registerSetupCheck(MaintenanceWindowStart::class);
$context->registerSetupCheck(MemcacheConfigured::class);
+ $context->registerSetupCheck(MemcacheLegacy::class);
$context->registerSetupCheck(MimeTypeMigrationAvailable::class);
$context->registerSetupCheck(MysqlRowFormat::class);
$context->registerSetupCheck(MysqlUnicodeSupport::class);
diff --git a/apps/settings/lib/SetupChecks/MemcacheLegacy.php b/apps/settings/lib/SetupChecks/MemcacheLegacy.php
new file mode 100644
index 0000000000000..753bc2ad5c739
--- /dev/null
+++ b/apps/settings/lib/SetupChecks/MemcacheLegacy.php
@@ -0,0 +1,59 @@
+l10n->t('Redis cache');
+ }
+
+ #[\Override]
+ public function getCategory(): string {
+ return 'system';
+ }
+
+ #[\Override]
+ public function run(): SetupResult {
+ if ($this->isLegacyRedisUsed()) {
+ return SetupResult::info(
+ $this->l10n->t('You are still using the old Redis cache backend. For full support of latest Valkey and Redis features, like clustering and sentinel, please switch to the new KeyValueCache backend.'),
+ $this->urlGenerator->linkToDocs('admin-cache')
+ );
+ } else {
+ return SetupResult::success($this->l10n->t('No legacy Redis cache detected'));
+ }
+ }
+
+ protected function isLegacyRedisUsed(): bool {
+ $memcacheDistributedClass = $this->config->getSystemValue('memcache.distributed', null);
+ $memcacheLockingClass = $this->config->getSystemValue('memcache.locking', null);
+
+ /** @psalm-suppress DeprecatedClass */
+ if ($memcacheDistributedClass === Redis::class || $memcacheLockingClass === Redis::class) {
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/build/psalm-baseline.xml b/build/psalm-baseline.xml
index 2a63a40c58416..fb44813ca6d86 100644
--- a/build/psalm-baseline.xml
+++ b/build/psalm-baseline.xml
@@ -3060,6 +3060,11 @@
timeFactory->getTime()]]>
+
+
+
+
+
@@ -4285,6 +4290,11 @@
+
+
+
+
+
diff --git a/config/config.sample.php b/config/config.sample.php
index 58ac3bf423235..faec7d7a5bb15 100644
--- a/config/config.sample.php
+++ b/config/config.sample.php
@@ -1791,6 +1791,87 @@
*/
'memcache_customprefix' => 'mycustomprefix',
+ /**
+ * Connection details for the Key-Value store used for in-memory caching,
+ * for example when using Valkey or Redis.
+ *
+ * This is the brand-independent successor of the ``redis`` and
+ * ``redis.cluster`` options below and supports the latest Valkey and Redis
+ * releases. Three topologies are supported:
+ * a single server, a Sentinel managed replication set, and a
+ * server cluster. Configure exactly one of ``server``, ``sentinel`` or
+ * ``seeds``.
+ *
+ * For enhanced security, it is recommended to configure ACLs in
+ * the cache server and configure the ``user`` and ``password`` (or a TLS
+ * client certificate). Alternatively, you can also configure the cache
+ * server to just use a ``password``.
+ * See https://valkey.io/topics/security/ for more information when using Valkey.
+ */
+ 'memcache.kvstore' => [
+ /**
+ * Single server setup.
+ *
+ * Also used as the connection template for the ``sentinel`` managed
+ * primary / replica connections.
+ */
+ 'server' => [
+ 'host' => 'localhost', // can also be a Unix domain socket: '/tmp/cache.sock'
+ 'port' => 6379, // ignored for Unix domain sockets
+ // Protocol used to connect. One of 'tcp', 'tls' or 'unix'.
+ // When omitted it is derived from the host (a leading '/' means 'unix').
+ 'protocol' => 'tcp',
+ ],
+
+ /**
+ * Sentinel managed replication setup.
+ *
+ * Provide the name of the monitored service and one entry per Sentinel
+ * node. Each seed uses the same format as the ``server`` entry above.
+ * Uncomment to enable and remove the ``server`` / ``seeds`` entries.
+ */
+ //'sentinel' => [
+ // 'service' => 'mymaster',
+ // 'seeds' => [
+ // ['host' => 'localhost', 'port' => 26379],
+ // ['host' => 'localhost', 'port' => 26380],
+ // ],
+ //],
+
+ /**
+ * Cluster setup.
+ *
+ * Provide some or all of the cluster nodes to bootstrap discovery.
+ * Each seed uses the same format as the ``server`` entry above.
+ * Uncomment to enable and remove the ``server`` / ``sentinel`` entries.
+ */
+ //'seeds' => [
+ // ['host' => 'localhost', 'port' => 7000],
+ // ['host' => 'localhost', 'port' => 7001],
+ //],
+
+ // Optional: username, only sent when the cache server uses ACLs.
+ 'user' => '',
+ // Optional: if not defined, no password will be used.
+ 'password' => '',
+ // Optional: select a numbered database. Only supported for single
+ // servers and Sentinel setups, clusters always use database 0.
+ 'dbindex' => 0,
+ // Optional: connection timeout in seconds (float). 0 means no timeout.
+ 'timeout' => 0.0,
+ // Optional: read/write timeout in seconds (float). 0 means no timeout.
+ 'read_timeout' => 0.0,
+ // Optional: keep the connection open across requests. Defaults to false.
+ 'persistent' => false,
+ // Optional: when the 'tls' protocol is used, provide the SSL context.
+ // SSL context options, see https://www.php.net/manual/en/context.ssl.php
+ //'ssl_context' => [
+ // 'local_cert' => '/certs/cache.crt',
+ // 'local_pk' => '/certs/cache.key',
+ // 'cafile' => '/certs/ca.crt',
+ //],
+ ],
+
/**
* Connection details for Redis to use for memory caching in a single server configuration.
*
@@ -1800,6 +1881,8 @@
*
* We also support Redis SSL/TLS encryption as of version 6.
* See https://redis.io/topics/encryption for more information.
+ *
+ * @deprecated 34.0.0 use `memcache.kvstore` instead which supports also Valkey.
*/
'redis' => [
'host' => 'localhost', // can also be a Unix domain socket: '/tmp/redis.sock'
@@ -1839,6 +1922,8 @@
*
* Authentication works with phpredis version 4.2.1+. See
* https://github.com/phpredis/phpredis/commit/c5994f2a42b8a348af92d3acb4edff1328ad8ce1
+ *
+ * @deprecated 34.0.0 use `memcache.kvstore` instead which supports also Valkey.
*/
'redis.cluster' => [
'seeds' => [ // provide some or all of the cluster servers to bootstrap discovery, port required
diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php
index 83b5dbeb66a9e..dd712cf793d8a 100644
--- a/lib/composer/composer/autoload_classmap.php
+++ b/lib/composer/composer/autoload_classmap.php
@@ -1945,6 +1945,8 @@
'OC\\Memcache\\CASTrait' => $baseDir . '/lib/private/Memcache/CASTrait.php',
'OC\\Memcache\\Cache' => $baseDir . '/lib/private/Memcache/Cache.php',
'OC\\Memcache\\Factory' => $baseDir . '/lib/private/Memcache/Factory.php',
+ 'OC\\Memcache\\KeyValueCache' => $baseDir . '/lib/private/Memcache/KeyValueCache.php',
+ 'OC\\Memcache\\KeyValueCacheFactory' => $baseDir . '/lib/private/Memcache/KeyValueCacheFactory.php',
'OC\\Memcache\\LoggerWrapperCache' => $baseDir . '/lib/private/Memcache/LoggerWrapperCache.php',
'OC\\Memcache\\Memcached' => $baseDir . '/lib/private/Memcache/Memcached.php',
'OC\\Memcache\\MemcachedFactory' => $baseDir . '/lib/private/Memcache/MemcachedFactory.php',
diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php
index 1b0383a23142e..880b663bad51b 100644
--- a/lib/composer/composer/autoload_static.php
+++ b/lib/composer/composer/autoload_static.php
@@ -1986,6 +1986,8 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Memcache\\CASTrait' => __DIR__ . '/../../..' . '/lib/private/Memcache/CASTrait.php',
'OC\\Memcache\\Cache' => __DIR__ . '/../../..' . '/lib/private/Memcache/Cache.php',
'OC\\Memcache\\Factory' => __DIR__ . '/../../..' . '/lib/private/Memcache/Factory.php',
+ 'OC\\Memcache\\KeyValueCache' => __DIR__ . '/../../..' . '/lib/private/Memcache/KeyValueCache.php',
+ 'OC\\Memcache\\KeyValueCacheFactory' => __DIR__ . '/../../..' . '/lib/private/Memcache/KeyValueCacheFactory.php',
'OC\\Memcache\\LoggerWrapperCache' => __DIR__ . '/../../..' . '/lib/private/Memcache/LoggerWrapperCache.php',
'OC\\Memcache\\Memcached' => __DIR__ . '/../../..' . '/lib/private/Memcache/Memcached.php',
'OC\\Memcache\\MemcachedFactory' => __DIR__ . '/../../..' . '/lib/private/Memcache/MemcachedFactory.php',
diff --git a/lib/private/Memcache/Factory.php b/lib/private/Memcache/Factory.php
index cb89b77b592fc..071d6edec69b7 100644
--- a/lib/private/Memcache/Factory.php
+++ b/lib/private/Memcache/Factory.php
@@ -165,7 +165,7 @@ public function withServerVersionPrefix(\Closure $closure): void {
#[\Override]
public function createLocking(string $prefix = ''): IMemcache {
$cache = new $this->lockingCacheClass($this->getGlobalPrefix() . '/' . $prefix);
- if ($this->lockingCacheClass === Redis::class) {
+ if ($this->supportsInstrumentation($this->lockingCacheClass)) {
if ($this->profiler->isEnabled()) {
// We only support the profiler with Redis
$cache = new ProfilerWrapperCache($cache, 'Locking');
@@ -188,7 +188,7 @@ public function createLocking(string $prefix = ''): IMemcache {
#[\Override]
public function createDistributed(string $prefix = ''): ICache {
$cache = new $this->distributedCacheClass($this->getGlobalPrefix() . '/' . $prefix);
- if ($this->distributedCacheClass === Redis::class) {
+ if ($this->supportsInstrumentation($this->distributedCacheClass)) {
if ($this->profiler->isEnabled()) {
// We only support the profiler with Redis
$cache = new ProfilerWrapperCache($cache, 'Distributed');
@@ -211,7 +211,7 @@ public function createDistributed(string $prefix = ''): ICache {
#[\Override]
public function createLocal(string $prefix = ''): ICache {
$cache = new $this->localCacheClass($this->getGlobalPrefix() . '/' . $prefix);
- if ($this->localCacheClass === Redis::class) {
+ if ($this->supportsInstrumentation($this->localCacheClass)) {
if ($this->profiler->isEnabled()) {
// We only support the profiler with Redis
$cache = new ProfilerWrapperCache($cache, 'Local');
@@ -250,6 +250,15 @@ public function isLocalCacheAvailable(): bool {
return $this->localCacheClass !== self::NULL_CACHE;
}
+ /**
+ * Whether the given cache backend supports the profiler and logger wrappers.
+ *
+ * @param class-string $cacheClass
+ */
+ private function supportsInstrumentation(string $cacheClass): bool {
+ return $cacheClass === Redis::class || $cacheClass === KeyValueCache::class;
+ }
+
public function clearAll(): void {
$this->createLocal()->clear();
$this->createDistributed()->clear();
diff --git a/lib/private/Memcache/KeyValueCache.php b/lib/private/Memcache/KeyValueCache.php
new file mode 100644
index 0000000000000..e68a66bce75cd
--- /dev/null
+++ b/lib/private/Memcache/KeyValueCache.php
@@ -0,0 +1,254 @@
+ [script, sha1] */
+ public const LUA_SCRIPTS = [
+ 'dec' => [
+ 'if redis.call("exists", KEYS[1]) == 1 then return redis.call("decrby", KEYS[1], ARGV[1]) else return "NEX" end',
+ '720b40cb66cef1579f2ef16ec69b3da8c85510e9',
+ ],
+ 'cas' => [
+ 'if redis.call("get", KEYS[1]) == ARGV[1] then redis.call("set", KEYS[1], ARGV[2]) return 1 else return 0 end',
+ '94eac401502554c02b811e3199baddde62d976d4',
+ ],
+ 'cad' => [
+ 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end',
+ 'cf0e94b2e9ffc7e04395cf88f7583fc309985910',
+ ],
+ 'ncad' => [
+ 'if redis.call("get", KEYS[1]) ~= ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end',
+ '75526f8048b13ce94a41b58eee59c664b4990ab2',
+ ],
+ 'caSetTtl' => [
+ 'if redis.call("get", KEYS[1]) == ARGV[1] then redis.call("expire", KEYS[1], ARGV[2]) return 1 else return 0 end',
+ 'fa4acbc946d23ef41d7d3910880b60e6e4972d72',
+ ],
+ ];
+
+ private const MAX_TTL = 30 * 24 * 60 * 60; // 1 month
+
+ private ?Client $cache = null;
+
+ public function getCache(): Client {
+ return $this->cache ??= Server::get(KeyValueCacheFactory::class)->getInstance();
+ }
+
+ #[\Override]
+ public function get($key) {
+ $result = $this->getCache()->get($this->getPrefix() . $key);
+ if ($result === null) {
+ return null;
+ }
+
+ return self::decodeValue($result);
+ }
+
+ #[\Override]
+ public function set($key, $value, $ttl = 0) {
+ $value = self::encodeValue($value);
+ $ttl = $this->normalizeTtl($ttl);
+ return (bool)$this->getCache()->setex($this->getPrefix() . $key, $ttl, $value);
+ }
+
+ #[\Override]
+ public function hasKey($key) {
+ return (bool)$this->getCache()->exists($this->getPrefix() . $key);
+ }
+
+ #[\Override]
+ public function remove($key) {
+ return (bool)$this->getCache()->del($this->getPrefix() . $key);
+ }
+
+ #[\Override]
+ public function clear($prefix = '') {
+ $pattern = $this->getPrefix() . $prefix . '*';
+ $client = $this->getCache();
+
+ // On a cluster the key space is spread across the nodes, so we have to
+ // scan every node individually. Other topologies route writes to the
+ // primary on their own.
+ if ($client->getConnection() instanceof ClusterInterface) {
+ $success = true;
+ /** @var Client $node */
+ foreach ($client as $node) {
+ // Keys of a single node can still span multiple hash slots, so
+ // delete them individually to avoid CROSSSLOT errors.
+ $success = $this->clearNode($node, $pattern, true) && $success;
+ }
+ return $success;
+ }
+
+ return $this->clearNode($client, $pattern, false);
+ }
+
+ private function clearNode(Client $node, string $pattern, bool $perKey): bool {
+ $keys = $node->keys($pattern);
+ if ($keys === []) {
+ return true;
+ }
+
+ if ($perKey) {
+ $deleted = 0;
+ foreach ($keys as $key) {
+ $deleted += $node->del($key);
+ }
+ } else {
+ $deleted = $node->del($keys);
+ }
+
+ return count($keys) === $deleted;
+ }
+
+ #[\Override]
+ public function add($key, $value, $ttl = 0) {
+ $value = self::encodeValue($value);
+ $ttl = $this->normalizeTtl($ttl);
+
+ return $this->getCache()->set($this->getPrefix() . $key, $value, 'EX', $ttl, 'NX') !== null;
+ }
+
+ #[\Override]
+ public function inc($key, $step = 1) {
+ try {
+ return $this->getCache()->incrby($this->getPrefix() . $key, $step);
+ } catch (ServerException) {
+ // The stored value is not an integer
+ return false;
+ }
+ }
+
+ #[\Override]
+ public function dec($key, $step = 1) {
+ try {
+ $res = $this->evalLua('dec', [$key], [$step]);
+ } catch (ServerException) {
+ // The stored value is not an integer
+ return false;
+ }
+ return ($res === 'NEX') ? false : $res;
+ }
+
+ #[\Override]
+ public function cas($key, $old, $new) {
+ $old = self::encodeValue($old);
+ $new = self::encodeValue($new);
+
+ return $this->evalLua('cas', [$key], [$old, $new]) > 0;
+ }
+
+ #[\Override]
+ public function cad($key, $old) {
+ $old = self::encodeValue($old);
+
+ return $this->evalLua('cad', [$key], [$old]) > 0;
+ }
+
+ #[\Override]
+ public function ncad(string $key, mixed $old): bool {
+ $old = self::encodeValue($old);
+
+ return $this->evalLua('ncad', [$key], [$old]) > 0;
+ }
+
+ #[\Override]
+ public function setTTL($key, $ttl) {
+ $ttl = $this->normalizeTtl($ttl);
+ $this->getCache()->expire($this->getPrefix() . $key, $ttl);
+ }
+
+ #[\Override]
+ public function getTTL(string $key): int|false {
+ $ttl = $this->getCache()->ttl($this->getPrefix() . $key);
+ return $ttl > 0 ? (int)$ttl : false;
+ }
+
+ #[\Override]
+ public function compareSetTTL(string $key, mixed $value, int $ttl): bool {
+ $value = self::encodeValue($value);
+
+ return $this->evalLua('caSetTtl', [$key], [$value, $ttl]) > 0;
+ }
+
+ #[\Override]
+ public static function isAvailable(): bool {
+ return Server::get(KeyValueCacheFactory::class)->isAvailable();
+ }
+
+ /**
+ * Run one of the predefined LUA scripts on the cache server.
+ *
+ * The given keys are prefixed and passed to the script as `KEYS`, followed
+ * by the raw `$args` as `ARGV`. The script is invoked via `EVALSHA` using its
+ * precomputed SHA1; if the server has not cached the script yet (`NOSCRIPT`)
+ * the full body is sent once via `EVAL`. Any other server error is rethrown.
+ *
+ * @param key-of $scriptName The script to run
+ * @param list $keys Unprefixed keys, passed as `KEYS` to the script
+ * @param list $args Extra arguments passed as `ARGV` to the script
+ * @return mixed The raw value returned by the script
+ * @throws ServerException on a server-side error other than `NOSCRIPT`
+ */
+ protected function evalLua(string $scriptName, array $keys, array $args) {
+ $keys = array_map(fn ($key) => $this->getPrefix() . $key, $keys);
+ $numKeys = count($keys);
+ $arguments = array_merge($keys, $args);
+ $script = self::LUA_SCRIPTS[$scriptName];
+
+ try {
+ return $this->getCache()->evalsha($script[1], $numKeys, ...$arguments);
+ } catch (ServerException $e) {
+ // The script is not cached on the server yet, send the full body
+ if ($e->getErrorType() === 'NOSCRIPT') {
+ return $this->getCache()->eval($script[0], $numKeys, ...$arguments);
+ }
+ throw $e;
+ }
+ }
+
+ /**
+ * An infinite TTL can leak keys as the prefix changes with version upgrades,
+ * so fall back to the default and cap it to the maximum.
+ */
+ private function normalizeTtl(int $ttl): int {
+ if ($ttl <= 0) {
+ $ttl = self::DEFAULT_TTL;
+ }
+ return min($ttl, self::MAX_TTL);
+ }
+
+ /**
+ * Serialize a value for storage in the key-value store.
+ */
+ protected static function encodeValue(mixed $value): string {
+ return is_int($value) ? (string)$value : json_encode($value, JSON_THROW_ON_ERROR);
+ }
+
+ /**
+ * Unserialize a value from the key-value store.
+ */
+ protected static function decodeValue(string $value): mixed {
+ return is_numeric($value) ? (int)$value : json_decode($value, true);
+ }
+}
diff --git a/lib/private/Memcache/KeyValueCacheFactory.php b/lib/private/Memcache/KeyValueCacheFactory.php
new file mode 100644
index 0000000000000..80897a60befe5
--- /dev/null
+++ b/lib/private/Memcache/KeyValueCacheFactory.php
@@ -0,0 +1,229 @@
+config->getValue('memcache.kvstore', []) !== [];
+ }
+
+ /**
+ * Get the (lazily connecting) predis client for the configured topology.
+ *
+ * @throws \RuntimeException if the cache is not configured
+ */
+ public function getInstance(): Client {
+ if ($this->client === null) {
+ if (!$this->isAvailable()) {
+ throw new \RuntimeException('Key-value store cache is not available');
+ }
+ $this->client = $this->create();
+ }
+
+ return $this->client;
+ }
+
+ private function create(): Client {
+ /** @var array $config */
+ $config = $this->config->getValue('memcache.kvstore', []);
+
+ $this->eventLogger->start('connect:kvstore', 'Connect to the key-value store cache server');
+ [$parameters, $options] = $this->buildConnectionConfig($config);
+ $client = new Client($parameters, $options);
+ $this->eventLogger->end('connect:kvstore');
+
+ return $client;
+ }
+
+ /**
+ * Translate the `memcache.kvstore` configuration into predis connection
+ * parameters and client options.
+ *
+ * This method is pure (it does not connect) so that the mapping can be
+ * verified without a running cache server.
+ *
+ * @param array $config The `memcache.kvstore` configuration
+ * @return array{0: array, 1: array} tuple of `[$parameters, $options]`
+ * @throws \RuntimeException on invalid configuration
+ */
+ public function buildConnectionConfig(array $config): array {
+ if (isset($config['sentinel'])) {
+ return $this->buildSentinelConfig($config);
+ }
+
+ if (isset($config['seeds'])) {
+ return $this->buildClusterConfig($config);
+ }
+
+ return $this->buildSingleServerConfig($config);
+ }
+
+ /**
+ * @return array{0: array, 1: array}
+ */
+ private function buildSingleServerConfig(array $config): array {
+ if (!isset($config['server']) || !is_array($config['server'])) {
+ throw new \RuntimeException('memcache.kvstore is missing the "server" configuration');
+ }
+
+ $parameters = $this->createServerParameters($config['server'], $config);
+
+ // Numbered databases are only supported on single servers for now, as
+ // predis does not support selecting a database on a cluster yet.
+ if (isset($config['dbindex'])) {
+ $parameters['database'] = (int)$config['dbindex'];
+ }
+
+ return [$parameters, []];
+ }
+
+ /**
+ * @return array{0: array, 1: array}
+ */
+ private function buildClusterConfig(array $config): array {
+ $seeds = $config['seeds'];
+ if (!is_array($seeds) || $seeds === []) {
+ throw new \RuntimeException('memcache.kvstore cluster configuration is missing the "seeds" attribute');
+ }
+
+ $parameters = array_map(
+ fn (array $seed): array => $this->createServerParameters($seed, $config),
+ array_values($seeds),
+ );
+
+ $options = ['cluster' => 'redis'];
+ $nodeParameters = $this->createSharedParameters($config);
+ if ($nodeParameters !== []) {
+ // applied to the nodes discovered while talking to the cluster
+ $options['parameters'] = $nodeParameters;
+ }
+
+ return [$parameters, $options];
+ }
+
+ /**
+ * @return array{0: array, 1: array}
+ */
+ private function buildSentinelConfig(array $config): array {
+ $sentinel = $config['sentinel'];
+ if (!is_array($sentinel) || empty($sentinel['service'])) {
+ throw new \RuntimeException('memcache.kvstore sentinel configuration is missing the "service" attribute');
+ }
+
+ $seeds = $sentinel['seeds'] ?? [];
+ if (!is_array($seeds) || $seeds === []) {
+ throw new \RuntimeException('memcache.kvstore sentinel configuration is missing the "seeds" attribute');
+ }
+
+ $parameters = array_map(
+ fn (array $seed): array => $this->createServerParameters($seed, $config),
+ array_values($seeds),
+ );
+
+ $options = [
+ 'replication' => 'sentinel',
+ 'service' => (string)$sentinel['service'],
+ ];
+ $nodeParameters = $this->createSharedParameters($config);
+ if (isset($config['dbindex'])) {
+ $nodeParameters['database'] = (int)$config['dbindex'];
+ }
+ if ($nodeParameters !== []) {
+ // applied to the master / replica connections resolved via Sentinel
+ $options['parameters'] = $nodeParameters;
+ }
+
+ return [$parameters, $options];
+ }
+
+ /**
+ * Build the predis parameters for a single server entry, merging in the
+ * shared authentication, TLS and timeout options.
+ */
+ public function createServerParameters(array $server, array $shared = []): array {
+ $host = (string)($server['host'] ?? '127.0.0.1');
+ $protocol = $server['protocol'] ?? null;
+ if ($protocol === null) {
+ // A leading slash indicates a Unix domain socket
+ $protocol = ($host !== '' && $host[0] === '/') ? 'unix' : 'tcp';
+ }
+
+ $parameters = ['scheme' => (string)$protocol];
+ if ($protocol === 'unix') {
+ $parameters['path'] = $host;
+ } else {
+ $parameters['host'] = $host;
+ $parameters['port'] = (int)($server['port'] ?? 6379);
+ }
+
+ if ($protocol === 'tls' && isset($shared['ssl_context'])) {
+ // SSL context options, see https://www.php.net/manual/en/context.ssl.php
+ $parameters['ssl'] = $shared['ssl_context'];
+ }
+
+ return $parameters + $this->createSharedParameters($shared);
+ }
+
+ /**
+ * Authentication, timeout and persistence parameters shared by every node.
+ */
+ private function createSharedParameters(array $config): array {
+ $parameters = [];
+
+ if (isset($config['password']) && (string)$config['password'] !== '') {
+ $parameters['password'] = (string)$config['password'];
+ // A username is only sent when ACLs are in use
+ if (isset($config['user']) && (string)$config['user'] !== '') {
+ $parameters['username'] = (string)$config['user'];
+ }
+ }
+
+ if (isset($config['timeout'])) {
+ $parameters['timeout'] = (float)$config['timeout'];
+ }
+ if (isset($config['read_timeout'])) {
+ $parameters['read_write_timeout'] = (float)$config['read_timeout'];
+ }
+ if (isset($config['persistent'])) {
+ $parameters['persistent'] = (bool)$config['persistent'];
+ }
+
+ return $parameters;
+ }
+}
diff --git a/lib/private/Memcache/Redis.php b/lib/private/Memcache/Redis.php
index edf9a80aad497..afd813921e7be 100644
--- a/lib/private/Memcache/Redis.php
+++ b/lib/private/Memcache/Redis.php
@@ -12,6 +12,12 @@
use OCP\IMemcacheTTL;
use OCP\Server;
+/**
+ * @deprecated 34.0.2 Legacy phpredis based backend. Kept so existing `redis`
+ * and `redis.cluster` configurations keep working. New setups should
+ * use {@see KeyValueCache} with the `memcache.kvstore` configuration,
+ * which also supports Valkey.
+ */
class Redis extends Cache implements IMemcacheTTL {
/** name => [script, sha1] */
public const LUA_SCRIPTS = [
diff --git a/lib/private/RedisFactory.php b/lib/private/RedisFactory.php
index 722c356d9d879..b867e4e6aa52f 100644
--- a/lib/private/RedisFactory.php
+++ b/lib/private/RedisFactory.php
@@ -10,6 +10,9 @@
use OCP\Diagnostics\IEventLogger;
+/**
+ * @deprecated 34.0.2 - use {@see \OC\Memcache\KeyValueCacheFactory} instead
+ */
class RedisFactory {
public const REDIS_MINIMAL_VERSION = '4.0.0';
public const REDIS_EXTRA_PARAMETERS_MINIMAL_VERSION = '5.3.0';
diff --git a/tests/kvstore-cluster.config.php b/tests/kvstore-cluster.config.php
new file mode 100644
index 0000000000000..fc4007fc9bc78
--- /dev/null
+++ b/tests/kvstore-cluster.config.php
@@ -0,0 +1,23 @@
+ '\\OC\\Memcache\\KeyValueCache',
+ 'memcache.distributed' => '\\OC\\Memcache\\KeyValueCache',
+ 'memcache.locking' => '\\OC\\Memcache\\KeyValueCache',
+ 'memcache.kvstore' => [
+ 'seeds' => [
+ ['host' => '127.0.0.1', 'port' => 7000],
+ ['host' => '127.0.0.1', 'port' => 7001],
+ ['host' => '127.0.0.1', 'port' => 7002],
+ ['host' => '127.0.0.1', 'port' => 7003],
+ ['host' => '127.0.0.1', 'port' => 7004],
+ ['host' => '127.0.0.1', 'port' => 7005],
+ ],
+ ],
+];
diff --git a/tests/kvstore-sentinel.config.php b/tests/kvstore-sentinel.config.php
new file mode 100644
index 0000000000000..70f2e43a37474
--- /dev/null
+++ b/tests/kvstore-sentinel.config.php
@@ -0,0 +1,21 @@
+ '\\OC\\Memcache\\KeyValueCache',
+ 'memcache.distributed' => '\\OC\\Memcache\\KeyValueCache',
+ 'memcache.locking' => '\\OC\\Memcache\\KeyValueCache',
+ 'memcache.kvstore' => [
+ 'sentinel' => [
+ 'service' => 'mymaster',
+ 'seeds' => [
+ ['host' => '127.0.0.1', 'port' => 26379],
+ ],
+ ],
+ ],
+];
diff --git a/tests/kvstore-single.config.php b/tests/kvstore-single.config.php
new file mode 100644
index 0000000000000..1e385849689dc
--- /dev/null
+++ b/tests/kvstore-single.config.php
@@ -0,0 +1,20 @@
+ '\\OC\\Memcache\\KeyValueCache',
+ 'memcache.distributed' => '\\OC\\Memcache\\KeyValueCache',
+ 'memcache.locking' => '\\OC\\Memcache\\KeyValueCache',
+ 'memcache.kvstore' => [
+ 'server' => [
+ 'host' => 'localhost',
+ 'port' => 6379,
+ 'protocol' => 'tcp',
+ ],
+ ],
+];
diff --git a/tests/lib/Memcache/KeyValueCacheFactoryTest.php b/tests/lib/Memcache/KeyValueCacheFactoryTest.php
new file mode 100644
index 0000000000000..4c65863cc162f
--- /dev/null
+++ b/tests/lib/Memcache/KeyValueCacheFactoryTest.php
@@ -0,0 +1,275 @@
+config = $this->createMock(SystemConfig::class);
+ $this->eventLogger = $this->createMock(IEventLogger::class);
+ $this->factory = new KeyValueCacheFactory($this->config, $this->eventLogger);
+ }
+
+ public function testSingleServerTcp(): void {
+ [$parameters, $options] = $this->factory->buildConnectionConfig([
+ 'server' => ['host' => 'cache.example', 'port' => 6380],
+ ]);
+
+ $this->assertSame([
+ 'scheme' => 'tcp',
+ 'host' => 'cache.example',
+ 'port' => 6380,
+ ], $parameters);
+ $this->assertSame([], $options);
+ }
+
+ public function testSingleServerDefaultPort(): void {
+ [$parameters] = $this->factory->buildConnectionConfig([
+ 'server' => ['host' => 'cache.example'],
+ ]);
+
+ $this->assertSame(6379, $parameters['port']);
+ }
+
+ public function testSingleServerUnixSocketFromProtocol(): void {
+ [$parameters] = $this->factory->buildConnectionConfig([
+ 'server' => ['host' => '/tmp/cache.sock', 'protocol' => 'unix'],
+ ]);
+
+ $this->assertSame('unix', $parameters['scheme']);
+ $this->assertSame('/tmp/cache.sock', $parameters['path']);
+ $this->assertArrayNotHasKey('port', $parameters);
+ $this->assertArrayNotHasKey('host', $parameters);
+ }
+
+ public function testSingleServerUnixSocketInferredFromHost(): void {
+ [$parameters] = $this->factory->buildConnectionConfig([
+ 'server' => ['host' => '/tmp/cache.sock'],
+ ]);
+
+ $this->assertSame('unix', $parameters['scheme']);
+ $this->assertSame('/tmp/cache.sock', $parameters['path']);
+ }
+
+ public function testSingleServerTls(): void {
+ $sslContext = ['cafile' => '/certs/ca.crt'];
+ [$parameters] = $this->factory->buildConnectionConfig([
+ 'server' => ['host' => 'cache.example', 'protocol' => 'tls'],
+ 'ssl_context' => $sslContext,
+ ]);
+
+ $this->assertSame('tls', $parameters['scheme']);
+ $this->assertSame($sslContext, $parameters['ssl']);
+ }
+
+ public function testSslContextIgnoredWithoutTls(): void {
+ [$parameters] = $this->factory->buildConnectionConfig([
+ 'server' => ['host' => 'cache.example'],
+ 'ssl_context' => ['cafile' => '/certs/ca.crt'],
+ ]);
+
+ $this->assertArrayNotHasKey('ssl', $parameters);
+ }
+
+ public function testSingleServerWithDbIndex(): void {
+ [$parameters] = $this->factory->buildConnectionConfig([
+ 'server' => ['host' => 'cache.example'],
+ 'dbindex' => 5,
+ ]);
+
+ $this->assertSame(5, $parameters['database']);
+ }
+
+ public function testAuthWithUserAndPassword(): void {
+ [$parameters] = $this->factory->buildConnectionConfig([
+ 'server' => ['host' => 'cache.example'],
+ 'user' => 'nextcloud',
+ 'password' => 's3cret',
+ ]);
+
+ $this->assertSame('nextcloud', $parameters['username']);
+ $this->assertSame('s3cret', $parameters['password']);
+ }
+
+ public function testAuthWithPasswordOnly(): void {
+ [$parameters] = $this->factory->buildConnectionConfig([
+ 'server' => ['host' => 'cache.example'],
+ 'password' => 's3cret',
+ ]);
+
+ $this->assertSame('s3cret', $parameters['password']);
+ $this->assertArrayNotHasKey('username', $parameters);
+ }
+
+ public function testEmptyPasswordIsIgnored(): void {
+ [$parameters] = $this->factory->buildConnectionConfig([
+ 'server' => ['host' => 'cache.example'],
+ 'user' => 'nextcloud',
+ 'password' => '',
+ ]);
+
+ $this->assertArrayNotHasKey('password', $parameters);
+ $this->assertArrayNotHasKey('username', $parameters);
+ }
+
+ public function testTimeoutAndPersistence(): void {
+ [$parameters] = $this->factory->buildConnectionConfig([
+ 'server' => ['host' => 'cache.example'],
+ 'timeout' => 1.5,
+ 'read_timeout' => 2.5,
+ 'persistent' => true,
+ ]);
+
+ $this->assertSame(1.5, $parameters['timeout']);
+ $this->assertSame(2.5, $parameters['read_write_timeout']);
+ $this->assertTrue($parameters['persistent']);
+ }
+
+ public function testMissingServerThrows(): void {
+ $this->expectException(\RuntimeException::class);
+ $this->factory->buildConnectionConfig([]);
+ }
+
+ public function testCluster(): void {
+ [$parameters, $options] = $this->factory->buildConnectionConfig([
+ 'seeds' => [
+ ['host' => 'node1', 'port' => 7000],
+ ['host' => 'node2', 'port' => 7001],
+ ],
+ 'password' => 's3cret',
+ ]);
+
+ $this->assertCount(2, $parameters);
+ $this->assertSame('node1', $parameters[0]['host']);
+ $this->assertSame(7000, $parameters[0]['port']);
+ $this->assertSame('node2', $parameters[1]['host']);
+ $this->assertSame('redis', $options['cluster']);
+ // Authentication is propagated to the nodes discovered in the cluster
+ $this->assertSame('s3cret', $options['parameters']['password']);
+ }
+
+ public function testClusterEmptySeedsThrows(): void {
+ $this->expectException(\RuntimeException::class);
+ $this->factory->buildConnectionConfig(['seeds' => []]);
+ }
+
+ public function testSentinel(): void {
+ [$parameters, $options] = $this->factory->buildConnectionConfig([
+ 'sentinel' => [
+ 'service' => 'mymaster',
+ 'seeds' => [
+ ['host' => 'sentinel1', 'port' => 26379],
+ ['host' => 'sentinel2', 'port' => 26380],
+ ],
+ ],
+ 'password' => 's3cret',
+ 'dbindex' => 3,
+ ]);
+
+ $this->assertCount(2, $parameters);
+ $this->assertSame('sentinel1', $parameters[0]['host']);
+ $this->assertSame(26379, $parameters[0]['port']);
+ $this->assertSame('sentinel', $options['replication']);
+ $this->assertSame('mymaster', $options['service']);
+ // Auth and database are applied to the resolved primary / replica
+ $this->assertSame('s3cret', $options['parameters']['password']);
+ $this->assertSame(3, $options['parameters']['database']);
+ }
+
+ public function testSentinelMissingServiceThrows(): void {
+ $this->expectException(\RuntimeException::class);
+ $this->factory->buildConnectionConfig([
+ 'sentinel' => ['seeds' => [['host' => 'sentinel1', 'port' => 26379]]],
+ ]);
+ }
+
+ public function testSentinelMissingSeedsThrows(): void {
+ $this->expectException(\RuntimeException::class);
+ $this->factory->buildConnectionConfig([
+ 'sentinel' => ['service' => 'mymaster'],
+ ]);
+ }
+
+ public function testIsAvailableWithoutConfig(): void {
+ $this->config->method('getValue')->with('memcache.kvstore', [])->willReturn([]);
+ $this->assertFalse($this->factory->isAvailable());
+ }
+
+ public function testIsAvailableWithConfig(): void {
+ $this->config->method('getValue')->with('memcache.kvstore', [])
+ ->willReturn(['server' => ['host' => 'localhost']]);
+ $this->assertTrue($this->factory->isAvailable());
+ }
+
+ public function testGetInstanceThrowsWhenUnavailable(): void {
+ $this->config->method('getValue')->with('memcache.kvstore', [])->willReturn([]);
+ $this->expectException(\RuntimeException::class);
+ $this->factory->getInstance();
+ }
+
+ public function testGetInstanceSingleServer(): void {
+ $this->config->method('getValue')->with('memcache.kvstore', [])
+ ->willReturn(['server' => ['host' => 'localhost', 'port' => 6379]]);
+
+ $client = $this->factory->getInstance();
+ $this->assertInstanceOf(Client::class, $client);
+ $this->assertInstanceOf(NodeConnectionInterface::class, $client->getConnection());
+ }
+
+ public function testGetInstanceCluster(): void {
+ $this->config->method('getValue')->with('memcache.kvstore', [])
+ ->willReturn(['seeds' => [['host' => 'localhost', 'port' => 7000]]]);
+
+ $client = $this->factory->getInstance();
+ $this->assertInstanceOf(ClusterInterface::class, $client->getConnection());
+ }
+
+ public function testGetInstanceSentinel(): void {
+ $this->config->method('getValue')->with('memcache.kvstore', [])
+ ->willReturn([
+ 'sentinel' => [
+ 'service' => 'mymaster',
+ 'seeds' => [['host' => 'localhost', 'port' => 26379]],
+ ],
+ ]);
+
+ $client = $this->factory->getInstance();
+ $this->assertInstanceOf(SentinelReplication::class, $client->getConnection());
+ }
+
+ public function testGetInstanceIsMemoized(): void {
+ $this->config->method('getValue')->with('memcache.kvstore', [])
+ ->willReturn(['server' => ['host' => 'localhost']]);
+
+ $this->assertSame($this->factory->getInstance(), $this->factory->getInstance());
+ }
+}
diff --git a/tests/lib/Memcache/KeyValueCacheTest.php b/tests/lib/Memcache/KeyValueCacheTest.php
new file mode 100644
index 0000000000000..72d3d02093536
--- /dev/null
+++ b/tests/lib/Memcache/KeyValueCacheTest.php
@@ -0,0 +1,107 @@
+getSystemValue('memcache.kvstore', []) === []) {
+ self::markTestSkipped('Key-value store not configured in config.php');
+ }
+
+ if (!KeyValueCache::isAvailable()) {
+ self::markTestSkipped('The predis library is not available.');
+ }
+
+ try {
+ $instance = new KeyValueCache(self::getUniqueID());
+ if ($instance->set(self::getUniqueID(), self::getUniqueID()) === false) {
+ self::markTestSkipped('Key-value store server seems to be down.');
+ }
+ } catch (CommunicationException $e) {
+ self::markTestSkipped('Key-value store server is not reachable: ' . $e->getMessage());
+ }
+ }
+
+ #[\Override]
+ protected function setUp(): void {
+ parent::setUp();
+ $this->instance = new KeyValueCache($this->getUniqueID());
+ }
+
+ /**
+ * @return array
+ */
+ public static function roundtripValuesProvider(): array {
+ return [
+ 'string' => ['some string value'],
+ 'empty string' => [''],
+ 'numeric string' => ['0123'],
+ 'integer' => [1234],
+ 'zero' => [0],
+ 'negative integer' => [-42],
+ 'boolean true' => [true],
+ 'boolean false' => [false],
+ 'null' => [null],
+ 'list' => [['a', 'b', 'c']],
+ 'associative array' => [['foo' => 'bar', 'baz' => 42]],
+ 'nested array' => [['a' => ['b' => ['c' => 'd']]]],
+ ];
+ }
+
+ /**
+ * A value that is written to the cache must be readable again unchanged.
+ *
+ * Runs against every configured topology (single server, Sentinel and
+ * cluster) through the CI matrix, see .github/workflows/phpunit-kvstore.yml.
+ */
+ #[\PHPUnit\Framework\Attributes\DataProvider('roundtripValuesProvider')]
+ public function testStoreAndReadBack(mixed $value): void {
+ $this->assertNull($this->instance->get('roundtrip'), 'key should be empty before storing');
+
+ $this->assertNotFalse($this->instance->set('roundtrip', $value), 'value should be stored');
+ $this->assertTrue($this->instance->hasKey('roundtrip'), 'stored key should exist');
+ $this->assertEquals($value, $this->instance->get('roundtrip'), 'stored value should be read back unchanged');
+ }
+
+ public function testScriptHashes(): void {
+ foreach (KeyValueCache::LUA_SCRIPTS as $script) {
+ $this->assertEquals(sha1($script[0]), $script[1]);
+ }
+ }
+
+ public function testCasTtlNotChanged(): void {
+ $this->instance->set('foo', 'bar', 50);
+ $this->assertTrue($this->instance->compareSetTTL('foo', 'bar', 100));
+ // allow for 1s of inaccuracy due to time moving forward
+ $this->assertLessThan(1, 100 - $this->instance->getTTL('foo'));
+ }
+
+ public function testCasTtlChanged(): void {
+ $this->instance->set('foo', 'bar1', 50);
+ $this->assertFalse($this->instance->compareSetTTL('foo', 'bar', 100));
+ // allow for 1s of inaccuracy due to time moving forward
+ $this->assertLessThan(1, 50 - $this->instance->getTTL('foo'));
+ }
+}