Skip to content

Commit d0fec0b

Browse files
susnuxprovokateurin
andcommitted
feat: add predis based Key-Value cache (redis / valkey etc)
Assisted-by: ClaudeCode:claude-opus-4-8 Co-authored-by: Kate <26026535+provokateurin@users.noreply.github.com> Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent b1edaaa commit d0fec0b

16 files changed

Lines changed: 1191 additions & 4 deletions
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
# SPDX-License-Identifier: MIT
3+
4+
name: PHPUnit key-value store
5+
6+
on:
7+
pull_request:
8+
9+
permissions:
10+
contents: read
11+
12+
concurrency:
13+
group: phpunit-kvstore-${{ github.head_ref || github.run_id }}
14+
cancel-in-progress: true
15+
16+
env:
17+
VALKEY_IMAGE: valkey/valkey:8
18+
19+
jobs:
20+
changes:
21+
runs-on: ubuntu-latest-low
22+
23+
outputs:
24+
src: ${{ steps.changes.outputs.src}}
25+
26+
steps:
27+
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
28+
id: changes
29+
continue-on-error: true
30+
with:
31+
filters: |
32+
src:
33+
- '.github/workflows/phpunit-kvstore.yml'
34+
- '3rdparty/**'
35+
- '**/appinfo/**'
36+
- '**.php'
37+
38+
phpunit-kvstore:
39+
runs-on: ubuntu-latest
40+
41+
needs: changes
42+
if: needs.changes.outputs.src != 'false'
43+
44+
strategy:
45+
fail-fast: false
46+
matrix:
47+
php-versions: ["8.3", "8.5"]
48+
# The three supported topologies for the key-value store cache
49+
topology: ["single", "cluster", "sentinel"]
50+
51+
name: KV store ${{ matrix.topology }} (PHP ${{ matrix.php-versions }})
52+
53+
steps:
54+
- name: Checkout server
55+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
56+
with:
57+
persist-credentials: false
58+
submodules: true
59+
60+
- name: Set up php ${{ matrix.php-versions }}
61+
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2
62+
timeout-minutes: 5
63+
with:
64+
php-version: ${{ matrix.php-versions }}
65+
# https://docs.nextcloud.com/server/stable/admin_manual/installation/source_installation.html#prerequisites-for-manual-installation
66+
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, pdo_sqlite, posix, session, simplexml, sqlite, xmlreader, xmlwriter, zip, zlib
67+
coverage: none
68+
ini-file: development
69+
ini-values: disable_functions=""
70+
env:
71+
fail-fast: true
72+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
73+
74+
- name: Start Valkey (single server)
75+
if: matrix.topology == 'single'
76+
run: |
77+
docker run -d --name kv-single -p 6379:6379 "${VALKEY_IMAGE}"
78+
timeout 60 sh -c 'until docker exec kv-single valkey-cli ping | grep -q PONG; do sleep 1; done'
79+
80+
- name: Start Valkey (cluster)
81+
if: matrix.topology == 'cluster'
82+
run: |
83+
for port in 7000 7001 7002 7003 7004 7005; do
84+
docker run -d --name "kv-cluster-$port" --network host "${VALKEY_IMAGE}" \
85+
valkey-server --port "$port" --cluster-enabled yes \
86+
--cluster-config-file "nodes-$port.conf" --cluster-node-timeout 5000 \
87+
--appendonly no --save ""
88+
done
89+
# Wait for every node to answer before forming the cluster
90+
for port in 7000 7001 7002 7003 7004 7005; do
91+
timeout 60 sh -c "until docker exec kv-cluster-$port valkey-cli -p $port ping | grep -q PONG; do sleep 1; done"
92+
done
93+
docker exec kv-cluster-7000 valkey-cli --cluster create \
94+
127.0.0.1:7000 127.0.0.1:7001 127.0.0.1:7002 \
95+
127.0.0.1:7003 127.0.0.1:7004 127.0.0.1:7005 \
96+
--cluster-replicas 1 --cluster-yes
97+
98+
- name: Start Valkey (sentinel)
99+
if: matrix.topology == 'sentinel'
100+
run: |
101+
docker run -d --name kv-master --network host "${VALKEY_IMAGE}" \
102+
valkey-server --port 6379
103+
docker run -d --name kv-replica --network host "${VALKEY_IMAGE}" \
104+
valkey-server --port 6380 --replicaof 127.0.0.1 6379
105+
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
106+
docker run -d --name kv-sentinel --network host -v "$PWD/sentinel.conf:/etc/valkey/sentinel.conf" \
107+
"${VALKEY_IMAGE}" valkey-sentinel /etc/valkey/sentinel.conf
108+
timeout 60 sh -c 'until docker exec kv-sentinel valkey-cli -p 26379 ping | grep -q PONG; do sleep 1; done'
109+
110+
- name: Set up dependencies
111+
run: composer i
112+
113+
- name: Set up Nextcloud
114+
run: |
115+
mkdir data
116+
cp tests/kvstore-${{ matrix.topology }}.config.php config/
117+
cp tests/preseed-config.php config/config.php
118+
./occ maintenance:install --verbose --database=sqlite --database-name=nextcloud --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass admin
119+
php -f tests/enable_all.php
120+
121+
- name: PHPUnit key-value store tests
122+
run: composer run test -- --group KeyValueCache --log-junit junit.xml
123+
124+
- name: Print logs
125+
if: always()
126+
run: |
127+
cat data/nextcloud.log
128+
129+
summary:
130+
permissions:
131+
contents: none
132+
runs-on: ubuntu-latest-low
133+
needs: [changes, phpunit-kvstore]
134+
135+
if: always()
136+
137+
name: phpunit-kvstore-summary
138+
139+
steps:
140+
- name: Summary status
141+
run: if ${{ needs.changes.outputs.src != 'false' && needs.phpunit-kvstore.result != 'success' }}; then exit 1; fi

3rdparty

Submodule 3rdparty updated 631 files

build/psalm-baseline.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3060,6 +3060,11 @@
30603060
<code><![CDATA[$this->timeFactory->getTime()]]></code>
30613061
</InvalidScalarArgument>
30623062
</file>
3063+
<file src="core/Command/Memcache/RedisCommand.php">
3064+
<DeprecatedClass>
3065+
<code><![CDATA[protected]]></code>
3066+
</DeprecatedClass>
3067+
</file>
30633068
<file src="core/Command/Security/BruteforceAttempts.php">
30643069
<DeprecatedMethod>
30653070
<code><![CDATA[getAttempts]]></code>
@@ -4285,6 +4290,11 @@
42854290
<code><![CDATA[array{X-Request-Id: string, Cache-Control: string, Content-Security-Policy: string, Feature-Policy: string, X-Robots-Tag: string, Last-Modified?: string, ETag?: string, ...H}]]></code>
42864291
</MoreSpecificReturnType>
42874292
</file>
4293+
<file src="lib/public/ICache.php">
4294+
<AmbiguousConstantInheritance>
4295+
<code><![CDATA[DEFAULT_TTL]]></code>
4296+
</AmbiguousConstantInheritance>
4297+
</file>
42884298
<file src="public.php">
42894299
<DeprecatedMethod>
42904300
<code><![CDATA[getAppValue]]></code>

config/config.sample.php

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1791,6 +1791,87 @@
17911791
*/
17921792
'memcache_customprefix' => 'mycustomprefix',
17931793

1794+
/**
1795+
* Connection details for the Key-Value store used for in-memory caching,
1796+
* for example when using Valkey or Redis.
1797+
*
1798+
* This is the brand-independent successor of the ``redis`` and
1799+
* ``redis.cluster`` options below and supports the latest Valkey and Redis
1800+
* releases. Three topologies are supported:
1801+
* a single server, a Sentinel managed replication set, and a
1802+
* server cluster. Configure exactly one of ``server``, ``sentinel`` or
1803+
* ``seeds``.
1804+
*
1805+
* For enhanced security, it is recommended to configure ACLs in
1806+
* the cache server and configure the ``user`` and ``password`` (or a TLS
1807+
* client certificate). Alternatively, you can also configure the cache
1808+
* server to just use a ``password``.
1809+
* See https://valkey.io/topics/security/ for more information when using Valkey.
1810+
*/
1811+
'memcache.kvstore' => [
1812+
/**
1813+
* Single server setup.
1814+
*
1815+
* Also used as the connection template for the ``sentinel`` managed
1816+
* primary / replica connections.
1817+
*/
1818+
'server' => [
1819+
'host' => 'localhost', // can also be a Unix domain socket: '/tmp/cache.sock'
1820+
'port' => 6379, // ignored for Unix domain sockets
1821+
// Protocol used to connect. One of 'tcp', 'tls' or 'unix'.
1822+
// When omitted it is derived from the host (a leading '/' means 'unix').
1823+
'protocol' => 'tcp',
1824+
],
1825+
1826+
/**
1827+
* Sentinel managed replication setup.
1828+
*
1829+
* Provide the name of the monitored service and one entry per Sentinel
1830+
* node. Each seed uses the same format as the ``server`` entry above.
1831+
* Uncomment to enable and remove the ``server`` / ``seeds`` entries.
1832+
*/
1833+
//'sentinel' => [
1834+
// 'service' => 'mymaster',
1835+
// 'seeds' => [
1836+
// ['host' => 'localhost', 'port' => 26379],
1837+
// ['host' => 'localhost', 'port' => 26380],
1838+
// ],
1839+
//],
1840+
1841+
/**
1842+
* Cluster setup.
1843+
*
1844+
* Provide some or all of the cluster nodes to bootstrap discovery.
1845+
* Each seed uses the same format as the ``server`` entry above.
1846+
* Uncomment to enable and remove the ``server`` / ``sentinel`` entries.
1847+
*/
1848+
//'seeds' => [
1849+
// ['host' => 'localhost', 'port' => 7000],
1850+
// ['host' => 'localhost', 'port' => 7001],
1851+
//],
1852+
1853+
// Optional: username, only sent when the cache server uses ACLs.
1854+
'user' => '',
1855+
// Optional: if not defined, no password will be used.
1856+
'password' => '',
1857+
// Optional: select a numbered database. Only supported for single
1858+
// servers and Sentinel setups, clusters always use database 0.
1859+
'dbindex' => 0,
1860+
// Optional: connection timeout in seconds (float). 0 means no timeout.
1861+
'timeout' => 0.0,
1862+
// Optional: read/write timeout in seconds (float). 0 means no timeout.
1863+
'read_timeout' => 0.0,
1864+
// Optional: keep the connection open across requests. Defaults to false.
1865+
'persistent' => false,
1866+
// Optional: when the 'tls' protocol is used, provide the SSL context.
1867+
// SSL context options, see https://www.php.net/manual/en/context.ssl.php
1868+
//'ssl_context' => [
1869+
// 'local_cert' => '/certs/cache.crt',
1870+
// 'local_pk' => '/certs/cache.key',
1871+
// 'cafile' => '/certs/ca.crt',
1872+
//],
1873+
],
1874+
17941875
/**
17951876
* Connection details for Redis to use for memory caching in a single server configuration.
17961877
*
@@ -1800,6 +1881,8 @@
18001881
*
18011882
* We also support Redis SSL/TLS encryption as of version 6.
18021883
* See https://redis.io/topics/encryption for more information.
1884+
*
1885+
* @deprecated 34.0.0 use `memcache.kvstore` instead which supports also Valkey.
18031886
*/
18041887
'redis' => [
18051888
'host' => 'localhost', // can also be a Unix domain socket: '/tmp/redis.sock'
@@ -1839,6 +1922,8 @@
18391922
*
18401923
* Authentication works with phpredis version 4.2.1+. See
18411924
* https://github.com/phpredis/phpredis/commit/c5994f2a42b8a348af92d3acb4edff1328ad8ce1
1925+
*
1926+
* @deprecated 34.0.0 use `memcache.kvstore` instead which supports also Valkey.
18421927
*/
18431928
'redis.cluster' => [
18441929
'seeds' => [ // provide some or all of the cluster servers to bootstrap discovery, port required

lib/composer/composer/autoload_classmap.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1945,6 +1945,8 @@
19451945
'OC\\Memcache\\CASTrait' => $baseDir . '/lib/private/Memcache/CASTrait.php',
19461946
'OC\\Memcache\\Cache' => $baseDir . '/lib/private/Memcache/Cache.php',
19471947
'OC\\Memcache\\Factory' => $baseDir . '/lib/private/Memcache/Factory.php',
1948+
'OC\\Memcache\\KeyValueCache' => $baseDir . '/lib/private/Memcache/KeyValueCache.php',
1949+
'OC\\Memcache\\KeyValueCacheFactory' => $baseDir . '/lib/private/Memcache/KeyValueCacheFactory.php',
19481950
'OC\\Memcache\\LoggerWrapperCache' => $baseDir . '/lib/private/Memcache/LoggerWrapperCache.php',
19491951
'OC\\Memcache\\Memcached' => $baseDir . '/lib/private/Memcache/Memcached.php',
19501952
'OC\\Memcache\\MemcachedFactory' => $baseDir . '/lib/private/Memcache/MemcachedFactory.php',

lib/composer/composer/autoload_static.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1986,6 +1986,8 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
19861986
'OC\\Memcache\\CASTrait' => __DIR__ . '/../../..' . '/lib/private/Memcache/CASTrait.php',
19871987
'OC\\Memcache\\Cache' => __DIR__ . '/../../..' . '/lib/private/Memcache/Cache.php',
19881988
'OC\\Memcache\\Factory' => __DIR__ . '/../../..' . '/lib/private/Memcache/Factory.php',
1989+
'OC\\Memcache\\KeyValueCache' => __DIR__ . '/../../..' . '/lib/private/Memcache/KeyValueCache.php',
1990+
'OC\\Memcache\\KeyValueCacheFactory' => __DIR__ . '/../../..' . '/lib/private/Memcache/KeyValueCacheFactory.php',
19891991
'OC\\Memcache\\LoggerWrapperCache' => __DIR__ . '/../../..' . '/lib/private/Memcache/LoggerWrapperCache.php',
19901992
'OC\\Memcache\\Memcached' => __DIR__ . '/../../..' . '/lib/private/Memcache/Memcached.php',
19911993
'OC\\Memcache\\MemcachedFactory' => __DIR__ . '/../../..' . '/lib/private/Memcache/MemcachedFactory.php',

lib/private/Memcache/Factory.php

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ public function withServerVersionPrefix(\Closure $closure): void {
165165
#[\Override]
166166
public function createLocking(string $prefix = ''): IMemcache {
167167
$cache = new $this->lockingCacheClass($this->getGlobalPrefix() . '/' . $prefix);
168-
if ($this->lockingCacheClass === Redis::class) {
168+
if ($this->supportsInstrumentation($this->lockingCacheClass)) {
169169
if ($this->profiler->isEnabled()) {
170170
// We only support the profiler with Redis
171171
$cache = new ProfilerWrapperCache($cache, 'Locking');
@@ -188,7 +188,7 @@ public function createLocking(string $prefix = ''): IMemcache {
188188
#[\Override]
189189
public function createDistributed(string $prefix = ''): ICache {
190190
$cache = new $this->distributedCacheClass($this->getGlobalPrefix() . '/' . $prefix);
191-
if ($this->distributedCacheClass === Redis::class) {
191+
if ($this->supportsInstrumentation($this->distributedCacheClass)) {
192192
if ($this->profiler->isEnabled()) {
193193
// We only support the profiler with Redis
194194
$cache = new ProfilerWrapperCache($cache, 'Distributed');
@@ -211,7 +211,7 @@ public function createDistributed(string $prefix = ''): ICache {
211211
#[\Override]
212212
public function createLocal(string $prefix = ''): ICache {
213213
$cache = new $this->localCacheClass($this->getGlobalPrefix() . '/' . $prefix);
214-
if ($this->localCacheClass === Redis::class) {
214+
if ($this->supportsInstrumentation($this->localCacheClass)) {
215215
if ($this->profiler->isEnabled()) {
216216
// We only support the profiler with Redis
217217
$cache = new ProfilerWrapperCache($cache, 'Local');
@@ -250,6 +250,15 @@ public function isLocalCacheAvailable(): bool {
250250
return $this->localCacheClass !== self::NULL_CACHE;
251251
}
252252

253+
/**
254+
* Whether the given cache backend supports the profiler and logger wrappers.
255+
*
256+
* @param class-string<ICache> $cacheClass
257+
*/
258+
private function supportsInstrumentation(string $cacheClass): bool {
259+
return $cacheClass === Redis::class || $cacheClass === KeyValueCache::class;
260+
}
261+
253262
public function clearAll(): void {
254263
$this->createLocal()->clear();
255264
$this->createDistributed()->clear();

0 commit comments

Comments
 (0)