vendor/symfony/cache/Adapter/RedisTagAwareAdapter.php line 84

Open in your IDE?
  1. <?php
  2. /*
  3.  * This file is part of the Symfony package.
  4.  *
  5.  * (c) Fabien Potencier <fabien@symfony.com>
  6.  *
  7.  * For the full copyright and license information, please view the LICENSE
  8.  * file that was distributed with this source code.
  9.  */
  10. namespace Symfony\Component\Cache\Adapter;
  11. use Predis\Connection\Aggregate\ClusterInterface;
  12. use Predis\Connection\Aggregate\PredisCluster;
  13. use Predis\Connection\Aggregate\ReplicationInterface;
  14. use Predis\Response\ErrorInterface;
  15. use Predis\Response\Status;
  16. use Relay\Relay;
  17. use Symfony\Component\Cache\CacheItem;
  18. use Symfony\Component\Cache\Exception\InvalidArgumentException;
  19. use Symfony\Component\Cache\Exception\LogicException;
  20. use Symfony\Component\Cache\Marshaller\DeflateMarshaller;
  21. use Symfony\Component\Cache\Marshaller\MarshallerInterface;
  22. use Symfony\Component\Cache\Marshaller\TagAwareMarshaller;
  23. use Symfony\Component\Cache\Traits\RedisTrait;
  24. /**
  25.  * Stores tag id <> cache id relationship as a Redis Set.
  26.  *
  27.  * Set (tag relation info) is stored without expiry (non-volatile), while cache always gets an expiry (volatile) even
  28.  * if not set by caller. Thus if you configure redis with the right eviction policy you can be safe this tag <> cache
  29.  * relationship survives eviction (cache cleanup when Redis runs out of memory).
  30.  *
  31.  * Redis server 2.8+ with any `volatile-*` eviction policy, OR `noeviction` if you're sure memory will NEVER fill up
  32.  *
  33.  * Design limitations:
  34.  *  - Max 4 billion cache keys per cache tag as limited by Redis Set datatype.
  35.  *    E.g. If you use a "all" items tag for expiry instead of clear(), that limits you to 4 billion cache items also.
  36.  *
  37.  * @see https://redis.io/topics/lru-cache#eviction-policies Documentation for Redis eviction policies.
  38.  * @see https://redis.io/topics/data-types#sets Documentation for Redis Set datatype.
  39.  *
  40.  * @author Nicolas Grekas <p@tchwork.com>
  41.  * @author André Rømcke <andre.romcke+symfony@gmail.com>
  42.  */
  43. class RedisTagAwareAdapter extends AbstractTagAwareAdapter
  44. {
  45.     use RedisTrait;
  46.     /**
  47.      * On cache items without a lifetime set, we set it to 100 days. This is to make sure cache items are
  48.      * preferred to be evicted over tag Sets, if eviction policy is configured according to requirements.
  49.      */
  50.     private const DEFAULT_CACHE_TTL 8640000;
  51.     /**
  52.      * detected eviction policy used on Redis server.
  53.      */
  54.     private string $redisEvictionPolicy;
  55.     private string $namespace;
  56.     public function __construct(\Redis|Relay|\RedisArray|\RedisCluster|\Predis\ClientInterface $redisstring $namespace ''int $defaultLifetime 0MarshallerInterface $marshaller null)
  57.     {
  58.         if ($redis instanceof \Predis\ClientInterface && $redis->getConnection() instanceof ClusterInterface && !$redis->getConnection() instanceof PredisCluster) {
  59.             throw new InvalidArgumentException(sprintf('Unsupported Predis cluster connection: only "%s" is, "%s" given.'PredisCluster::class, get_debug_type($redis->getConnection())));
  60.         }
  61.         $isRelay $redis instanceof Relay;
  62.         if ($isRelay || \defined('Redis::OPT_COMPRESSION') && \in_array($redis::class, [\Redis::class, \RedisArray::class, \RedisCluster::class], true)) {
  63.             $compression $redis->getOption($isRelay Relay::OPT_COMPRESSION \Redis::OPT_COMPRESSION);
  64.             foreach (\is_array($compression) ? $compression : [$compression] as $c) {
  65.                 if ($isRelay Relay::COMPRESSION_NONE \Redis::COMPRESSION_NONE !== $c) {
  66.                     throw new InvalidArgumentException(sprintf('redis compression must be disabled when using "%s", use "%s" instead.', static::class, DeflateMarshaller::class));
  67.                 }
  68.             }
  69.         }
  70.         $this->init($redis$namespace$defaultLifetime, new TagAwareMarshaller($marshaller));
  71.         $this->namespace $namespace;
  72.     }
  73.     protected function doSave(array $valuesint $lifetime, array $addTagData = [], array $delTagData = []): array
  74.     {
  75.         $eviction $this->getRedisEvictionPolicy();
  76.         if ('noeviction' !== $eviction && !str_starts_with($eviction'volatile-')) {
  77.             throw new LogicException(sprintf('Redis maxmemory-policy setting "%s" is *not* supported by RedisTagAwareAdapter, use "noeviction" or "volatile-*" eviction policies.'$eviction));
  78.         }
  79.         // serialize values
  80.         if (!$serialized $this->marshaller->marshall($values$failed)) {
  81.             return $failed;
  82.         }
  83.         // While pipeline isn't supported on RedisCluster, other setups will at least benefit from doing this in one op
  84.         $results $this->pipeline(static function () use ($serialized$lifetime$addTagData$delTagData$failed) {
  85.             // Store cache items, force a ttl if none is set, as there is no MSETEX we need to set each one
  86.             foreach ($serialized as $id => $value) {
  87.                 yield 'setEx' => [
  88.                     $id,
  89.                     >= $lifetime self::DEFAULT_CACHE_TTL $lifetime,
  90.                     $value,
  91.                 ];
  92.             }
  93.             // Add and Remove Tags
  94.             foreach ($addTagData as $tagId => $ids) {
  95.                 if (!$failed || $ids array_diff($ids$failed)) {
  96.                     yield 'sAdd' => array_merge([$tagId], $ids);
  97.                 }
  98.             }
  99.             foreach ($delTagData as $tagId => $ids) {
  100.                 if (!$failed || $ids array_diff($ids$failed)) {
  101.                     yield 'sRem' => array_merge([$tagId], $ids);
  102.                 }
  103.             }
  104.         });
  105.         foreach ($results as $id => $result) {
  106.             // Skip results of SADD/SREM operations, they'll be 1 or 0 depending on if set value already existed or not
  107.             if (is_numeric($result)) {
  108.                 continue;
  109.             }
  110.             // setEx results
  111.             if (true !== $result && (!$result instanceof Status || Status::get('OK') !== $result)) {
  112.                 $failed[] = $id;
  113.             }
  114.         }
  115.         return $failed;
  116.     }
  117.     protected function doDeleteYieldTags(array $ids): iterable
  118.     {
  119.         $lua = <<<'EOLUA'
  120.             local v = redis.call('GET', KEYS[1])
  121.             local e = redis.pcall('UNLINK', KEYS[1])
  122.             if type(e) ~= 'number' then
  123.                 redis.call('DEL', KEYS[1])
  124.             end
  125.             if not v or v:len() <= 13 or v:byte(1) ~= 0x9D or v:byte(6) ~= 0 or v:byte(10) ~= 0x5F then
  126.                 return ''
  127.             end
  128.             return v:sub(14, 13 + v:byte(13) + v:byte(12) * 256 + v:byte(11) * 65536)
  129. EOLUA;
  130.         $results $this->pipeline(function () use ($ids$lua) {
  131.             foreach ($ids as $id) {
  132.                 yield 'eval' => $this->redis instanceof \Predis\ClientInterface ? [$lua1$id] : [$lua, [$id], 1];
  133.             }
  134.         });
  135.         foreach ($results as $id => $result) {
  136.             if ($result instanceof \RedisException || $result instanceof \Relay\Exception || $result instanceof ErrorInterface) {
  137.                 CacheItem::log($this->logger'Failed to delete key "{key}": '.$result->getMessage(), ['key' => substr($id\strlen($this->namespace)), 'exception' => $result]);
  138.                 continue;
  139.             }
  140.             try {
  141.                 yield $id => !\is_string($result) || '' === $result ? [] : $this->marshaller->unmarshall($result);
  142.             } catch (\Exception) {
  143.                 yield $id => [];
  144.             }
  145.         }
  146.     }
  147.     protected function doDeleteTagRelations(array $tagData): bool
  148.     {
  149.         $results $this->pipeline(static function () use ($tagData) {
  150.             foreach ($tagData as $tagId => $idList) {
  151.                 array_unshift($idList$tagId);
  152.                 yield 'sRem' => $idList;
  153.             }
  154.         });
  155.         foreach ($results as $result) {
  156.             // no-op
  157.         }
  158.         return true;
  159.     }
  160.     protected function doInvalidate(array $tagIds): bool
  161.     {
  162.         // This script scans the set of items linked to tag: it empties the set
  163.         // and removes the linked items. When the set is still not empty after
  164.         // the scan, it means we're in cluster mode and that the linked items
  165.         // are on other nodes: we move the links to a temporary set and we
  166.         // garbage collect that set from the client side.
  167.         $lua = <<<'EOLUA'
  168.             redis.replicate_commands()
  169.             local cursor = '0'
  170.             local id = KEYS[1]
  171.             repeat
  172.                 local result = redis.call('SSCAN', id, cursor, 'COUNT', 5000);
  173.                 cursor = result[1];
  174.                 local rems = {}
  175.                 for _, v in ipairs(result[2]) do
  176.                     local ok, _ = pcall(redis.call, 'DEL', ARGV[1]..v)
  177.                     if ok then
  178.                         table.insert(rems, v)
  179.                     end
  180.                 end
  181.                 if 0 < #rems then
  182.                     redis.call('SREM', id, unpack(rems))
  183.                 end
  184.             until '0' == cursor;
  185.             redis.call('SUNIONSTORE', '{'..id..'}'..id, id)
  186.             redis.call('DEL', id)
  187.             return redis.call('SSCAN', '{'..id..'}'..id, '0', 'COUNT', 5000)
  188. EOLUA;
  189.         $results $this->pipeline(function () use ($tagIds$lua) {
  190.             if ($this->redis instanceof \Predis\ClientInterface) {
  191.                 $prefix $this->redis->getOptions()->prefix $this->redis->getOptions()->prefix->getPrefix() : '';
  192.             } elseif (\is_array($prefix $this->redis->getOption($this->redis instanceof Relay Relay::OPT_PREFIX \Redis::OPT_PREFIX) ?? '')) {
  193.                 $prefix current($prefix);
  194.             }
  195.             foreach ($tagIds as $id) {
  196.                 yield 'eval' => $this->redis instanceof \Predis\ClientInterface ? [$lua1$id$prefix] : [$lua, [$id$prefix], 1];
  197.             }
  198.         });
  199.         $lua = <<<'EOLUA'
  200.             redis.replicate_commands()
  201.             local id = KEYS[1]
  202.             local cursor = table.remove(ARGV)
  203.             redis.call('SREM', '{'..id..'}'..id, unpack(ARGV))
  204.             return redis.call('SSCAN', '{'..id..'}'..id, cursor, 'COUNT', 5000)
  205. EOLUA;
  206.         $success true;
  207.         foreach ($results as $id => $values) {
  208.             if ($values instanceof \RedisException || $values instanceof \Relay\Exception || $values instanceof ErrorInterface) {
  209.                 CacheItem::log($this->logger'Failed to invalidate key "{key}": '.$values->getMessage(), ['key' => substr($id\strlen($this->namespace)), 'exception' => $values]);
  210.                 $success false;
  211.                 continue;
  212.             }
  213.             [$cursor$ids] = $values;
  214.             while ($ids || '0' !== $cursor) {
  215.                 $this->doDelete($ids);
  216.                 $evalArgs = [$id$cursor];
  217.                 array_splice($evalArgs10$ids);
  218.                 if ($this->redis instanceof \Predis\ClientInterface) {
  219.                     array_unshift($evalArgs$lua1);
  220.                 } else {
  221.                     $evalArgs = [$lua$evalArgs1];
  222.                 }
  223.                 $results $this->pipeline(function () use ($evalArgs) {
  224.                     yield 'eval' => $evalArgs;
  225.                 });
  226.                 foreach ($results as [$cursor$ids]) {
  227.                     // no-op
  228.                 }
  229.             }
  230.         }
  231.         return $success;
  232.     }
  233.     private function getRedisEvictionPolicy(): string
  234.     {
  235.         if (isset($this->redisEvictionPolicy)) {
  236.             return $this->redisEvictionPolicy;
  237.         }
  238.         $hosts $this->getHosts();
  239.         $host reset($hosts);
  240.         if ($host instanceof \Predis\Client && $host->getConnection() instanceof ReplicationInterface) {
  241.             // Predis supports info command only on the master in replication environments
  242.             $hosts = [$host->getClientFor('master')];
  243.         }
  244.         foreach ($hosts as $host) {
  245.             $info $host->info('Memory');
  246.             if (false === $info || null === $info || $info instanceof ErrorInterface) {
  247.                 continue;
  248.             }
  249.             $info $info['Memory'] ?? $info;
  250.             return $this->redisEvictionPolicy $info['maxmemory_policy'] ?? '';
  251.         }
  252.         return $this->redisEvictionPolicy '';
  253.     }
  254. }