Skip to content

Commit dd52562

Browse files
Merge pull request #62176 from nextcloud/backport/61559/stable33
[stable33] fix(cache): make clear() redis cluster compatible
2 parents 2d508af + 5ace4f7 commit dd52562

3 files changed

Lines changed: 76 additions & 7 deletions

File tree

lib/private/Memcache/LoggerWrapperCache.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ public function ncad(string $key, mixed $old): bool {
157157
FILE_APPEND
158158
);
159159

160-
return $this->wrappedCache->cad($key, $old);
160+
return $this->wrappedCache->ncad($key, $old);
161161
}
162162

163163
/** @inheritDoc */

lib/private/Memcache/Redis.php

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,11 @@ class Redis extends Cache implements IMemcacheTTL {
3636

3737
private const MAX_TTL = 30 * 24 * 60 * 60; // 1 month
3838

39+
/** Number of keys to request per SCAN iteration in {@see self::clear()} (only a hint to Redis) */
40+
private const SCAN_COUNT = 1000;
41+
3942
/**
40-
* @var \Redis|\RedisCluster $cache
43+
* @var \Redis|\RedisCluster|null $cache
4144
*/
4245
private static $cache = null;
4346

@@ -88,12 +91,45 @@ public function remove($key) {
8891
}
8992

9093
public function clear($prefix = '') {
91-
// TODO: this is slow and would fail with Redis cluster
92-
$prefix = $this->getPrefix() . $prefix . '*';
93-
$keys = $this->getCache()->keys($prefix);
94-
$deleted = $this->getCache()->del($keys);
94+
$pattern = $this->getPrefix() . $prefix . '*';
95+
$cache = $this->getCache();
96+
97+
// Iterate with SCAN and remove with UNLINK rather than KEYS + DEL:
98+
// KEYS walks the whole keyspace and blocks the server, while a
99+
// multi-key DEL/UNLINK is not cluster-safe (keys spanning hash slots
100+
// raise a CROSSSLOT error). SCAN is non-blocking and UNLINK reclaims
101+
// memory in the background.
102+
if ($cache instanceof \RedisCluster) {
103+
// On a cluster SCAN must be run against each master node, and keys
104+
// are unlinked one at a time so each command stays within a slot.
105+
foreach ($cache->_masters() as $master) {
106+
$iterator = null;
107+
do {
108+
/** @psalm-suppress NullArgument, PossiblyNullArgument the SCAN cursor must start as null (the phpredis stub types it as int) */
109+
$keys = $cache->scan($iterator, $master, $pattern, self::SCAN_COUNT);
110+
if ($keys === false) {
111+
break;
112+
}
113+
foreach ($keys as $key) {
114+
$cache->unlink($key);
115+
}
116+
} while ($iterator > 0);
117+
}
118+
} else {
119+
$iterator = null;
120+
do {
121+
/** @psalm-suppress NullArgument, PossiblyNullArgument the SCAN cursor must start as null (the phpredis stub types it as int) */
122+
$keys = $cache->scan($iterator, $pattern, self::SCAN_COUNT);
123+
if ($keys === false) {
124+
break;
125+
}
126+
if ($keys !== []) {
127+
$cache->unlink($keys);
128+
}
129+
} while ($iterator > 0);
130+
}
95131

96-
return (is_array($keys) && (count($keys) === $deleted));
132+
return true;
97133
}
98134

99135
/**

tests/lib/Memcache/RedisTest.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,37 @@ public function testCasTtlChanged(): void {
8282
// allow for 1s of inaccuracy due to time moving forward
8383
$this->assertLessThan(1, 50 - $this->instance->getTTL('foo'));
8484
}
85+
86+
public function testClearWithPrefixOnlyRemovesMatchingKeys(): void {
87+
$this->instance->set('foo1', 'a');
88+
$this->instance->set('foo2', 'b');
89+
$this->instance->set('bar1', 'c');
90+
91+
$this->assertTrue($this->instance->clear('foo'));
92+
93+
$this->assertFalse($this->instance->hasKey('foo1'));
94+
$this->assertFalse($this->instance->hasKey('foo2'));
95+
$this->assertTrue($this->instance->hasKey('bar1'));
96+
}
97+
98+
public function testClearWithoutMatchesReturnsTrue(): void {
99+
// Nothing is stored under this prefix; clearing must not error out
100+
// (regression guard for calling UNLINK/DEL with an empty key list).
101+
$this->assertTrue($this->instance->clear('no-such-prefix'));
102+
}
103+
104+
public function testClearRemovesEntriesAcrossMultipleScanBatches(): void {
105+
// More keys than a single SCAN batch (self::SCAN_COUNT) to exercise the
106+
// cursor loop and make sure nothing is left behind.
107+
$count = 1500;
108+
for ($i = 0; $i < $count; $i++) {
109+
$this->instance->set('bulk-' . $i, $i);
110+
}
111+
112+
$this->assertTrue($this->instance->clear('bulk-'));
113+
114+
$this->assertFalse($this->instance->hasKey('bulk-0'));
115+
$this->assertFalse($this->instance->hasKey('bulk-' . ($count - 1)));
116+
$this->assertFalse($this->instance->hasKey('bulk-' . intdiv($count, 2)));
117+
}
85118
}

0 commit comments

Comments
 (0)