1<?php
2
3/*
4 * This file is part of the Symfony package.
5 *
6 * (c) Fabien Potencier <fabien@symfony.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace Symfony\Component\Cache\Adapter;
13
14use Predis\Connection\Aggregate\ClusterInterface;
15use Predis\Connection\Aggregate\PredisCluster;
16use Predis\Response\Status;
17use Symfony\Component\Cache\CacheItem;
18use Symfony\Component\Cache\Exception\InvalidArgumentException;
19use Symfony\Component\Cache\Marshaller\DeflateMarshaller;
20use Symfony\Component\Cache\Marshaller\MarshallerInterface;
21use Symfony\Component\Cache\Marshaller\TagAwareMarshaller;
22use Symfony\Component\Cache\Traits\RedisTrait;
23
24/**
25 * Stores tag id <> cache id relationship as a Redis Set, lookup on invalidation using RENAME+SMEMBERS.
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 * Requirements:
32 *  - Client: PHP Redis or Predis
33 *            Note: Due to lack of RENAME support it is NOT recommended to use Cluster on Predis, instead use phpredis.
34 *  - Server: Redis 2.8+
35 *            Configured with any `volatile-*` eviction policy, OR `noeviction` if it will NEVER fill up memory
36 *
37 * Design limitations:
38 *  - Max 4 billion cache keys per cache tag as limited by Redis Set datatype.
39 *    E.g. If you use a "all" items tag for expiry instead of clear(), that limits you to 4 billion cache items also.
40 *
41 * @see https://redis.io/topics/lru-cache#eviction-policies Documentation for Redis eviction policies.
42 * @see https://redis.io/topics/data-types#sets Documentation for Redis Set datatype.
43 *
44 * @author Nicolas Grekas <p@tchwork.com>
45 * @author André Rømcke <andre.romcke+symfony@gmail.com>
46 */
47class RedisTagAwareAdapter extends AbstractTagAwareAdapter
48{
49    use RedisTrait;
50
51    /**
52     * Limits for how many keys are deleted in batch.
53     */
54    private const BULK_DELETE_LIMIT = 10000;
55
56    /**
57     * On cache items without a lifetime set, we set it to 100 days. This is to make sure cache items are
58     * preferred to be evicted over tag Sets, if eviction policy is configured according to requirements.
59     */
60    private const DEFAULT_CACHE_TTL = 8640000;
61
62    /**
63     * @var string|null detected eviction policy used on Redis server
64     */
65    private $redisEvictionPolicy;
66
67    /**
68     * @param \Redis|\RedisArray|\RedisCluster|\Predis\ClientInterface $redisClient     The redis client
69     * @param string                                                   $namespace       The default namespace
70     * @param int                                                      $defaultLifetime The default lifetime
71     */
72    public function __construct($redisClient, string $namespace = '', int $defaultLifetime = 0, MarshallerInterface $marshaller = null)
73    {
74        if ($redisClient instanceof \Predis\ClientInterface && $redisClient->getConnection() instanceof ClusterInterface && !$redisClient->getConnection() instanceof PredisCluster) {
75            throw new InvalidArgumentException(sprintf('Unsupported Predis cluster connection: only "%s" is, "%s" given.', PredisCluster::class, \get_class($redisClient->getConnection())));
76        }
77
78        if (\defined('Redis::OPT_COMPRESSION') && ($redisClient instanceof \Redis || $redisClient instanceof \RedisArray || $redisClient instanceof \RedisCluster)) {
79            $compression = $redisClient->getOption(\Redis::OPT_COMPRESSION);
80
81            foreach (\is_array($compression) ? $compression : [$compression] as $c) {
82                if (\Redis::COMPRESSION_NONE !== $c) {
83                    throw new InvalidArgumentException(sprintf('phpredis compression must be disabled when using "%s", use "%s" instead.', static::class, DeflateMarshaller::class));
84                }
85            }
86        }
87
88        $this->init($redisClient, $namespace, $defaultLifetime, new TagAwareMarshaller($marshaller));
89    }
90
91    /**
92     * {@inheritdoc}
93     */
94    protected function doSave(array $values, int $lifetime, array $addTagData = [], array $delTagData = []): array
95    {
96        $eviction = $this->getRedisEvictionPolicy();
97        if ('noeviction' !== $eviction && 0 !== strpos($eviction, 'volatile-')) {
98            CacheItem::log($this->logger, sprintf('Redis maxmemory-policy setting "%s" is *not* supported by RedisTagAwareAdapter, use "noeviction" or  "volatile-*" eviction policies', $eviction));
99
100            return false;
101        }
102
103        // serialize values
104        if (!$serialized = $this->marshaller->marshall($values, $failed)) {
105            return $failed;
106        }
107
108        // While pipeline isn't supported on RedisCluster, other setups will at least benefit from doing this in one op
109        $results = $this->pipeline(static function () use ($serialized, $lifetime, $addTagData, $delTagData, $failed) {
110            // Store cache items, force a ttl if none is set, as there is no MSETEX we need to set each one
111            foreach ($serialized as $id => $value) {
112                yield 'setEx' => [
113                    $id,
114                    0 >= $lifetime ? self::DEFAULT_CACHE_TTL : $lifetime,
115                    $value,
116                ];
117            }
118
119            // Add and Remove Tags
120            foreach ($addTagData as $tagId => $ids) {
121                if (!$failed || $ids = array_diff($ids, $failed)) {
122                    yield 'sAdd' => array_merge([$tagId], $ids);
123                }
124            }
125
126            foreach ($delTagData as $tagId => $ids) {
127                if (!$failed || $ids = array_diff($ids, $failed)) {
128                    yield 'sRem' => array_merge([$tagId], $ids);
129                }
130            }
131        });
132
133        foreach ($results as $id => $result) {
134            // Skip results of SADD/SREM operations, they'll be 1 or 0 depending on if set value already existed or not
135            if (is_numeric($result)) {
136                continue;
137            }
138            // setEx results
139            if (true !== $result && (!$result instanceof Status || Status::get('OK') !== $result)) {
140                $failed[] = $id;
141            }
142        }
143
144        return $failed;
145    }
146
147    /**
148     * {@inheritdoc}
149     */
150    protected function doDeleteYieldTags(array $ids): iterable
151    {
152        $lua = <<<'EOLUA'
153            local v = redis.call('GET', KEYS[1])
154            redis.call('DEL', KEYS[1])
155
156            if not v or v:len() <= 13 or v:byte(1) ~= 0x9D or v:byte(6) ~= 0 or v:byte(10) ~= 0x5F then
157                return ''
158            end
159
160            return v:sub(14, 13 + v:byte(13) + v:byte(12) * 256 + v:byte(11) * 65536)
161EOLUA;
162
163        if ($this->redis instanceof \Predis\ClientInterface) {
164            $evalArgs = [$lua, 1, &$id];
165        } else {
166            $evalArgs = [$lua, [&$id], 1];
167        }
168
169        $results = $this->pipeline(function () use ($ids, &$id, $evalArgs) {
170            foreach ($ids as $id) {
171                yield 'eval' => $evalArgs;
172            }
173        });
174
175        foreach ($results as $id => $result) {
176            try {
177                yield $id => !\is_string($result) || '' === $result ? [] : $this->marshaller->unmarshall($result);
178            } catch (\Exception $e) {
179                yield $id => [];
180            }
181        }
182    }
183
184    /**
185     * {@inheritdoc}
186     */
187    protected function doDeleteTagRelations(array $tagData): bool
188    {
189        $this->pipeline(static function () use ($tagData) {
190            foreach ($tagData as $tagId => $idList) {
191                array_unshift($idList, $tagId);
192                yield 'sRem' => $idList;
193            }
194        })->rewind();
195
196        return true;
197    }
198
199    /**
200     * {@inheritdoc}
201     */
202    protected function doInvalidate(array $tagIds): bool
203    {
204        if (!$this->redis instanceof \Predis\ClientInterface || !$this->redis->getConnection() instanceof PredisCluster) {
205            $movedTagSetIds = $this->renameKeys($this->redis, $tagIds);
206        } else {
207            $clusterConnection = $this->redis->getConnection();
208            $tagIdsByConnection = new \SplObjectStorage();
209            $movedTagSetIds = [];
210
211            foreach ($tagIds as $id) {
212                $connection = $clusterConnection->getConnectionByKey($id);
213                $slot = $tagIdsByConnection[$connection] ?? $tagIdsByConnection[$connection] = new \ArrayObject();
214                $slot[] = $id;
215            }
216
217            foreach ($tagIdsByConnection as $connection) {
218                $slot = $tagIdsByConnection[$connection];
219                $movedTagSetIds = array_merge($movedTagSetIds, $this->renameKeys(new $this->redis($connection, $this->redis->getOptions()), $slot->getArrayCopy()));
220            }
221        }
222
223        // No Sets found
224        if (!$movedTagSetIds) {
225            return false;
226        }
227
228        // Now safely take the time to read the keys in each set and collect ids we need to delete
229        $tagIdSets = $this->pipeline(static function () use ($movedTagSetIds) {
230            foreach ($movedTagSetIds as $movedTagId) {
231                yield 'sMembers' => [$movedTagId];
232            }
233        });
234
235        // Return combination of the temporary Tag Set ids and their values (cache ids)
236        $ids = array_merge($movedTagSetIds, ...iterator_to_array($tagIdSets, false));
237
238        // Delete cache in chunks to avoid overloading the connection
239        foreach (array_chunk(array_unique($ids), self::BULK_DELETE_LIMIT) as $chunkIds) {
240            $this->doDelete($chunkIds);
241        }
242
243        return true;
244    }
245
246    /**
247     * Renames several keys in order to be able to operate on them without risk of race conditions.
248     *
249     * Filters out keys that do not exist before returning new keys.
250     *
251     * @see https://redis.io/commands/rename
252     * @see https://redis.io/topics/cluster-spec#keys-hash-tags
253     *
254     * @return array Filtered list of the valid moved keys (only those that existed)
255     */
256    private function renameKeys($redis, array $ids): array
257    {
258        $newIds = [];
259        $uniqueToken = bin2hex(random_bytes(10));
260
261        $results = $this->pipeline(static function () use ($ids, $uniqueToken) {
262            foreach ($ids as $id) {
263                yield 'rename' => [$id, '{'.$id.'}'.$uniqueToken];
264            }
265        }, $redis);
266
267        foreach ($results as $id => $result) {
268            if (true === $result || ($result instanceof Status && Status::get('OK') === $result)) {
269                // Only take into account if ok (key existed), will be false on phpredis if it did not exist
270                $newIds[] = '{'.$id.'}'.$uniqueToken;
271            }
272        }
273
274        return $newIds;
275    }
276
277    private function getRedisEvictionPolicy(): string
278    {
279        if (null !== $this->redisEvictionPolicy) {
280            return $this->redisEvictionPolicy;
281        }
282
283        foreach ($this->getHosts() as $host) {
284            $info = $host->info('Memory');
285            $info = isset($info['Memory']) ? $info['Memory'] : $info;
286
287            return $this->redisEvictionPolicy = $info['maxmemory_policy'];
288        }
289
290        return $this->redisEvictionPolicy = '';
291    }
292}
293