diff --git a/Adapter/AbstractTagAwareAdapter.php b/Adapter/AbstractTagAwareAdapter.php
index a384b16a..21be7f52 100644
--- a/Adapter/AbstractTagAwareAdapter.php
+++ b/Adapter/AbstractTagAwareAdapter.php
@@ -35,7 +35,7 @@ abstract class AbstractTagAwareAdapter implements TagAwareAdapterInterface, TagA
use AbstractAdapterTrait;
use ContractsTrait;
- private const TAGS_PREFIX = "\0tags\0";
+ protected const TAGS_PREFIX = "\0tags\0";
protected function __construct(string $namespace = '', int $defaultLifetime = 0)
{
diff --git a/Adapter/RedisTagAwareAdapter.php b/Adapter/RedisTagAwareAdapter.php
index 186b32e7..eb416abe 100644
--- a/Adapter/RedisTagAwareAdapter.php
+++ b/Adapter/RedisTagAwareAdapter.php
@@ -22,6 +22,7 @@
use Symfony\Component\Cache\Marshaller\DeflateMarshaller;
use Symfony\Component\Cache\Marshaller\MarshallerInterface;
use Symfony\Component\Cache\Marshaller\TagAwareMarshaller;
+use Symfony\Component\Cache\PruneableInterface;
use Symfony\Component\Cache\Traits\RedisClusterProxy;
use Symfony\Component\Cache\Traits\RedisProxy;
use Symfony\Component\Cache\Traits\RedisTrait;
@@ -45,7 +46,7 @@
* @author Nicolas Grekas
* @author André Rømcke
*/
-class RedisTagAwareAdapter extends AbstractTagAwareAdapter
+class RedisTagAwareAdapter extends AbstractTagAwareAdapter implements PruneableInterface
{
use RedisTrait;
@@ -322,4 +323,185 @@ private function getRedisEvictionPolicy(): string
return $this->redisEvictionPolicy = '';
}
+
+ private function getPrefix(): string
+ {
+ if ($this->redis instanceof \Predis\ClientInterface) {
+ $prefix = $this->redis->getOptions()->prefix ? $this->redis->getOptions()->prefix->getPrefix() : '';
+ } elseif (\is_array($prefix = $this->redis->getOption(\Redis::OPT_PREFIX) ?? '')) {
+ $prefix = current($prefix);
+ }
+
+ return $prefix;
+ }
+
+ /**
+ * Returns all existing tag keys from the cache.
+ *
+ * @TODO Verify the LUA scripts are redis-cluster safe.
+ */
+ protected function getAllTagKeys(): array
+ {
+ $tagKeys = [];
+ $prefix = $this->getPrefix();
+ // need to trim the \0 for lua script
+ $tagsPrefix = trim(self::TAGS_PREFIX);
+
+ // get all SET entries which are tagged
+ $getTagsLua = <<<'EOLUA'
+ redis.replicate_commands()
+ local cursor = ARGV[1]
+ local prefix = ARGV[2]
+ local tagPrefix = string.gsub(KEYS[1], prefix, "")
+ return redis.call('SCAN', cursor, 'COUNT', 5000, 'MATCH', '*' .. tagPrefix .. '*', 'TYPE', 'set')
+ EOLUA;
+ $cursor = 0;
+ do {
+ $results = $this->pipeline(function () use ($getTagsLua, $cursor, $prefix, $tagsPrefix) {
+ yield 'eval' => [$getTagsLua, [$tagsPrefix, $cursor, $prefix], 1];
+ });
+
+ $setKeys = $results->valid() ? iterator_to_array($results) : [];
+ [$cursor, $ids] = $setKeys[$tagsPrefix] ?? [null, null];
+ // merge the fetched ids together
+ $tagKeys = array_merge($tagKeys, $ids);
+ } while ($cursor = (int) $cursor);
+
+ return $tagKeys;
+ }
+
+ /**
+ * Checks all tags in the cache for orphaned items and creates a "report" array.
+ *
+ * By default, only completely orphaned tag keys are reported. If
+ * compressMode is enabled the report will include all tag keys
+ * that have any orphaned references to cache items
+ *
+ * @TODO Verify the LUA scripts are redis-cluster safe.
+ * @TODO Is there anything that can be done to reduce memory footprint?
+ *
+ * @return array{tagKeys: string[], orphanedTagKeys: string[], orphanedTagReferenceKeys?: array}
+ * tagKeys: List of all tags in the cache.
+ * orphanedTagKeys: List of tags that only reference orphaned cache items.
+ * orphanedTagReferenceKeys: List of all orphaned cache item references per tag.
+ * Keyed by tag, value is the list of orphaned cache item keys.
+ */
+ private function getOrphanedTagsStats(bool $compressMode = false): array
+ {
+ $prefix = $this->getPrefix();
+ $tagKeys = $this->getAllTagKeys();
+
+ // lua for fetching all entries/content from a SET
+ $getSetContentLua = <<<'EOLUA'
+ redis.replicate_commands()
+ local cursor = ARGV[1]
+ return redis.call('SSCAN', KEYS[1], cursor, 'COUNT', 5000)
+ EOLUA;
+
+ $orphanedTagReferenceKeys = [];
+ $orphanedTagKeys = [];
+ // Iterate over each tag and check if its entries reference orphaned
+ // cache items.
+ foreach ($tagKeys as $tagKey) {
+ $tagKey = substr($tagKey, \strlen($prefix));
+ $cursor = 0;
+ $hasExistingKeys = false;
+ do {
+ // Fetch all referenced cache keys from the tag entry.
+ $results = $this->pipeline(function () use ($getSetContentLua, $tagKey, $cursor) {
+ yield 'eval' => [$getSetContentLua, [$tagKey, $cursor], 1];
+ });
+ [$cursor, $referencedCacheKeys] = $results->valid() ? $results->current() : [null, null];
+
+ if (!empty($referencedCacheKeys)) {
+ // Counts how many of the referenced cache items exist.
+ $existingCacheKeysResult = $this->pipeline(function () use ($referencedCacheKeys) {
+ yield 'exists' => $referencedCacheKeys;
+ });
+ $existingCacheKeysCount = $existingCacheKeysResult->valid() ? $existingCacheKeysResult->current() : 0;
+ $hasExistingKeys = $hasExistingKeys || ($existingCacheKeysCount > 0 ?? false);
+
+ // If compression mode is enabled and the count between
+ // referenced and existing cache keys differs collect the
+ // missing references.
+ if ($compressMode && \count($referencedCacheKeys) > $existingCacheKeysCount) {
+ // In order to create the delta each single reference
+ // has to be checked.
+ foreach ($referencedCacheKeys as $cacheKey) {
+ $existingCacheKeyResult = $this->pipeline(function () use ($cacheKey) {
+ yield 'exists' => [$cacheKey];
+ });
+ if ($existingCacheKeyResult->valid() && !$existingCacheKeyResult->current()) {
+ $orphanedTagReferenceKeys[$tagKey][] = $cacheKey;
+ }
+ }
+ }
+ // Stop processing cursors in case compression mode is
+ // disabled and the tag references existing keys.
+ if (!$compressMode && $hasExistingKeys) {
+ break;
+ }
+ }
+ } while ($cursor = (int) $cursor);
+ if (!$hasExistingKeys) {
+ $orphanedTagKeys[] = $tagKey;
+ }
+ }
+
+ $stats = ['orphanedTagKeys' => $orphanedTagKeys, 'tagKeys' => $tagKeys];
+ if ($compressMode) {
+ $stats['orphanedTagReferenceKeys'] = $orphanedTagReferenceKeys;
+ }
+
+ return $stats;
+ }
+
+ /**
+ * @TODO Verify the LUA scripts are redis-cluster safe.
+ */
+ private function pruneOrphanedTags(bool $compressMode = false): bool
+ {
+ $success = true;
+ $orphanedTagsStats = $this->getOrphanedTagsStats($compressMode);
+
+ // Delete all tags that don't reference any existing cache item.
+ foreach ($orphanedTagsStats['orphanedTagKeys'] as $orphanedTagKey) {
+ $result = $this->pipeline(function () use ($orphanedTagKey) {
+ yield 'del' => [$orphanedTagKey];
+ });
+ if (!$result->valid() || 1 !== $result->current()) {
+ $success = false;
+ }
+ }
+ // If orphaned cache key references are provided prune them too.
+ if (!empty($orphanedTagsStats['orphanedTagReferenceKeys'])) {
+ // lua for deleting member from a SET
+ $removeSetMemberLua = <<<'EOLUA'
+ redis.replicate_commands()
+ return redis.call('SREM', KEYS[1], KEYS[2])
+ EOLUA;
+ // Loop through all tags with orphaned cache item references.
+ foreach ($orphanedTagsStats['orphanedTagReferenceKeys'] as $tagKey => $orphanedCacheKeys) {
+ // Remove each cache item reference from the tag set.
+ foreach ($orphanedCacheKeys as $orphanedCacheKey) {
+ $result = $this->pipeline(function () use ($tagKey, $orphanedCacheKey) {
+ yield 'srem' => [$tagKey, $orphanedCacheKey];
+ });
+ if (!$result->valid() || 1 !== $result->current()) {
+ $success = false;
+ }
+ }
+ }
+ }
+
+ return $success;
+ }
+
+ /**
+ * @TODO Make compression mode flag configurable.
+ */
+ public function prune(): bool
+ {
+ return $this->pruneOrphanedTags(true);
+ }
}