Skip to content

Commit fd3cfe5

Browse files
zoidbergxdas-peter
authored andcommitted
feat: symfony#47835 added redis garbage collector
1 parent 877a8e8 commit fd3cfe5

File tree

2 files changed

+192
-2
lines changed

2 files changed

+192
-2
lines changed

src/Symfony/Component/Cache/Adapter/AbstractTagAwareAdapter.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ abstract class AbstractTagAwareAdapter implements TagAwareAdapterInterface, TagA
3535
use AbstractAdapterTrait;
3636
use ContractsTrait;
3737

38-
private const TAGS_PREFIX = "\1tags\1";
38+
protected const TAGS_PREFIX = "\1tags\1";
3939

4040
protected function __construct(string $namespace = '', int $defaultLifetime = 0)
4141
{

src/Symfony/Component/Cache/Adapter/RedisTagAwareAdapter.php

+191-1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use Symfony\Component\Cache\Marshaller\DeflateMarshaller;
2424
use Symfony\Component\Cache\Marshaller\MarshallerInterface;
2525
use Symfony\Component\Cache\Marshaller\TagAwareMarshaller;
26+
use Symfony\Component\Cache\PruneableInterface;
2627
use Symfony\Component\Cache\Traits\RedisTrait;
2728

2829
/**
@@ -44,7 +45,7 @@
4445
* @author Nicolas Grekas <p@tchwork.com>
4546
* @author André Rømcke <andre.romcke+symfony@gmail.com>
4647
*/
47-
class RedisTagAwareAdapter extends AbstractTagAwareAdapter
48+
class RedisTagAwareAdapter extends AbstractTagAwareAdapter implements PruneableInterface
4849
{
4950
use RedisTrait;
5051

@@ -305,4 +306,193 @@ private function getRedisEvictionPolicy(): string
305306

306307
return $this->redisEvictionPolicy = '';
307308
}
309+
310+
311+
private function getPrefix(): string
312+
{
313+
if ($this->redis instanceof \Predis\ClientInterface) {
314+
$prefix = $this->redis->getOptions()->prefix ? $this->redis->getOptions()->prefix->getPrefix() : '';
315+
} elseif (\is_array($prefix = $this->redis->getOption(\Redis::OPT_PREFIX) ?? '')) {
316+
$prefix = current($prefix);
317+
}
318+
return $prefix;
319+
}
320+
321+
/**
322+
* Returns all existing tag keys from the cache.
323+
*
324+
* @TODO Verify the LUA scripts are redis-cluster safe.
325+
*
326+
* @return array
327+
*/
328+
protected function getAllTagKeys(): array
329+
{
330+
$tagKeys = [];
331+
$prefix = $this->getPrefix();
332+
// need to trim the \0 for lua script
333+
$tagsPrefix = trim(self::TAGS_PREFIX);
334+
335+
// get all SET entries which are tagged
336+
$getTagsLua = <<<'EOLUA'
337+
redis.replicate_commands()
338+
local cursor = ARGV[1]
339+
local prefix = ARGV[2]
340+
local tagPrefix = string.gsub(KEYS[1], prefix, "")
341+
return redis.call('SCAN', cursor, 'COUNT', 5000, 'MATCH', '*' .. tagPrefix .. '*', 'TYPE', 'set')
342+
EOLUA;
343+
$cursor = null;
344+
do {
345+
$results = $this->pipeline(function () use ($getTagsLua, $cursor, $prefix, $tagsPrefix) {
346+
yield 'eval' => [$getTagsLua, [$tagsPrefix, $cursor, $prefix], 1];
347+
});
348+
349+
$setKeys = $results->valid() ? iterator_to_array($results) : [];
350+
[$cursor, $ids] = $setKeys[$tagsPrefix] ?? [null, null];
351+
// merge the fetched ids together
352+
$tagKeys = array_merge($tagKeys, $ids);
353+
} while ($cursor = (int) $cursor);
354+
355+
return $tagKeys;
356+
}
357+
358+
359+
/**
360+
* Checks all tags in the cache for orphaned items and creates a "report" array.
361+
*
362+
* By default, only completely orphaned tag keys are reported. If
363+
* compressMode is enabled the report will include all tag keys
364+
* that have any orphaned references to cache items
365+
*
366+
* @TODO Verify the LUA scripts are redis-cluster safe.
367+
* @TODO Is there anything that can be done to reduce memory footprint?
368+
*
369+
* @param bool $compressMode
370+
* @return array{tagKeys: string[], orphanedTagKeys: string[], orphanedTagReferenceKeys?: array<string, string[]>}
371+
* tagKeys: List of all tags in the cache.
372+
* orphanedTagKeys: List of tags that only reference orphaned cache items.
373+
* orphanedTagReferenceKeys: List of all orphaned cache item references per tag.
374+
* Keyed by tag, value is the list of orphaned cache item keys.
375+
*/
376+
private function getOrphanedTagsStats(bool $compressMode = false): array
377+
{
378+
$prefix = $this->getPrefix();
379+
$tagKeys = $this->getAllTagKeys();
380+
381+
// lua for fetching all entries/content from a SET
382+
$getSetContentLua = <<<'EOLUA'
383+
redis.replicate_commands()
384+
local cursor = ARGV[1]
385+
return redis.call('SSCAN', KEYS[1], cursor, 'COUNT', 5000)
386+
EOLUA;
387+
388+
$orphanedTagReferenceKeys = [];
389+
$orphanedTagKeys = [];
390+
// Iterate over each tag and check if its entries reference orphaned
391+
// cache items.
392+
foreach ($tagKeys as $tagKey) {
393+
$tagKey = substr($tagKey, strlen($prefix));
394+
$cursor = null;
395+
$hasExistingKeys = false;
396+
do {
397+
// Fetch all referenced cache keys from the tag entry.
398+
$results = $this->pipeline(function () use ($getSetContentLua, $tagKey, $cursor) {
399+
yield 'eval' => [$getSetContentLua, [$tagKey, $cursor], 1];
400+
});
401+
[$cursor, $referencedCacheKeys] = $results->valid() ? $results->current() : [null, null];
402+
403+
if (!empty($referencedCacheKeys)) {
404+
// Counts how many of the referenced cache items exist.
405+
$existingCacheKeysResult = $this->pipeline(function () use ($referencedCacheKeys) {
406+
yield 'exists' => $referencedCacheKeys;
407+
});
408+
$existingCacheKeysCount = $existingCacheKeysResult->valid() ? $existingCacheKeysResult->current() : 0;
409+
$hasExistingKeys = $hasExistingKeys || ($existingCacheKeysCount > 0 ?? false);
410+
411+
// If compression mode is enabled and the count between
412+
// referenced and existing cache keys differs collect the
413+
// missing references.
414+
if ($compressMode && count($referencedCacheKeys) > $existingCacheKeysCount) {
415+
// In order to create the delta each single reference
416+
// has to be checked.
417+
foreach ($referencedCacheKeys as $cacheKey) {
418+
$existingCacheKeyResult = $this->pipeline(function () use ($cacheKey) {
419+
yield 'exists' => [$cacheKey];
420+
});
421+
if ($existingCacheKeyResult->valid() && !$existingCacheKeyResult->current()) {
422+
$orphanedTagReferenceKeys[$tagKey][] = $cacheKey;
423+
}
424+
}
425+
}
426+
// Stop processing cursors in case compression mode is
427+
// disabled and the tag references existing keys.
428+
if (!$compressMode && $hasExistingKeys) {
429+
break;
430+
}
431+
}
432+
} while ($cursor = (int) $cursor);
433+
if (!$hasExistingKeys) {
434+
$orphanedTagKeys[] = $tagKey;
435+
}
436+
}
437+
438+
$stats = ['orphanedTagKeys' => $orphanedTagKeys, 'tagKeys' => $tagKeys];
439+
if ($compressMode) {
440+
$stats['orphanedTagReferenceKeys'] = $orphanedTagReferenceKeys;
441+
}
442+
return $stats;
443+
}
444+
445+
/**
446+
*
447+
* @TODO Verify the LUA scripts are redis-cluster safe.
448+
*
449+
* @param bool $compressMode
450+
* @return bool
451+
*/
452+
private function pruneOrphanedTags(bool $compressMode = false): bool
453+
{
454+
$success = true;
455+
$orphanedTagsStats = $this->getOrphanedTagsStats($compressMode);
456+
457+
// Delete all tags that don't reference any existing cache item.
458+
foreach ($orphanedTagsStats['orphanedTagKeys'] as $orphanedTagKey) {
459+
$result = $this->pipeline(function () use ($orphanedTagKey) {
460+
yield 'del' => [$orphanedTagKey];
461+
});
462+
if (!$result->valid() || $result->current() !== 1) {
463+
$success = false;
464+
}
465+
}
466+
// If orphaned cache key references are provided prune them too.
467+
if (!empty($orphanedTagsStats['orphanedTagReferenceKeys'])) {
468+
// lua for deleting member from a SET
469+
$removeSetMemberLua = <<<'EOLUA'
470+
redis.replicate_commands()
471+
return redis.call('SREM', KEYS[1], KEYS[2])
472+
EOLUA;
473+
// Loop through all tags with orphaned cache item references.
474+
foreach ($orphanedTagsStats['orphanedTagReferenceKeys'] as $tagKey => $orphanedCacheKeys) {
475+
// Remove each cache item reference from the tag set.
476+
foreach ($orphanedCacheKeys as $orphanedCacheKey) {
477+
$result = $this->pipeline(function () use ($removeSetMemberLua, $tagKey, $orphanedCacheKey) {
478+
yield 'srem' => [$tagKey, $orphanedCacheKey];
479+
});
480+
if (!$result->valid() || $result->current() !== 1) {
481+
$success = false;
482+
}
483+
}
484+
}
485+
}
486+
return $success;
487+
}
488+
489+
/**
490+
* @TODO Make compression mode flag configurable.
491+
*
492+
* @return bool
493+
*/
494+
public function prune(): bool
495+
{
496+
return $this->pruneOrphanedTags(true);
497+
}
308498
}

0 commit comments

Comments
 (0)