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

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