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