Skip to content

Commit b290ca2

Browse files
committed
Implement PrunableInterface on RedisTagAwareAdapter symfony#47835
1 parent d4a7010 commit b290ca2

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 = "\0tags\0";
38+
protected const TAGS_PREFIX = "\0tags\0";
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
@@ -24,6 +24,7 @@
2424
use Symfony\Component\Cache\Marshaller\TagAwareMarshaller;
2525
use Symfony\Component\Cache\Traits\RedisClusterProxy;
2626
use Symfony\Component\Cache\Traits\RedisProxy;
27+
use Symfony\Component\Cache\PruneableInterface;
2728
use Symfony\Component\Cache\Traits\RedisTrait;
2829

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

@@ -318,4 +319,193 @@ private function getRedisEvictionPolicy(): string
318319

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

0 commit comments

Comments
 (0)