1111
1212namespace Symfony \Component \Cache \Adapter ;
1313
14- use Predis ;
1514use Predis \Connection \Aggregate \ClusterInterface ;
15+ use Predis \Connection \Aggregate \PredisCluster ;
1616use Predis \Response \Status ;
1717use Symfony \Component \Cache \CacheItem ;
18- use Symfony \Component \Cache \Exception \LogicException ;
18+ use Symfony \Component \Cache \Exception \InvalidArgumentException ;
1919use Symfony \Component \Cache \Marshaller \MarshallerInterface ;
2020use Symfony \Component \Cache \Traits \RedisTrait ;
2121
2222/**
23- * Stores tag id <> cache id relationship as a Redis Set, lookup on invalidation using sPOP .
23+ * Stores tag id <> cache id relationship as a Redis Set, lookup on invalidation using RENAME+SMEMBERS .
2424 *
2525 * Set (tag relation info) is stored without expiry (non-volatile), while cache always gets an expiry (volatile) even
2626 * if not set by caller. Thus if you configure redis with the right eviction policy you can be safe this tag <> cache
2727 * relationship survives eviction (cache cleanup when Redis runs out of memory).
2828 *
2929 * Requirements:
30- * - Server: Redis 3.2+
31- * - Client: PHP Redis 3.1.3+ OR Predis
32- * - Redis Server(s) configured with any `volatile-*` eviction policy, OR `noeviction` if it will NEVER fill up memory
30+ * - Client: PHP Redis or Predis
31+ * Note: Due to lack of RENAME support it is NOT recommended to use Cluster on Predis, instead use phpredis.
32+ * - Server: Redis 2.8+
33+ * Configured with any `volatile-*` eviction policy, OR `noeviction` if it will NEVER fill up memory
3334 *
3435 * Design limitations:
35- * - Max 2 billion cache keys per cache tag
36- * E.g. If you use a "all" items tag for expiry instead of clear(), that limits you to 2 billion cache items as well
36+ * - Max 4 billion cache keys per cache tag as limited by Redis Set datatype.
37+ * E.g. If you use a "all" items tag for expiry instead of clear(), that limits you to 4 billion cache items also.
3738 *
3839 * @see https://redis.io/topics/lru-cache#eviction-policies Documentation for Redis eviction policies.
3940 * @see https://redis.io/topics/data-types#sets Documentation for Redis Set datatype.
40- * @see https://redis.io/commands/spop Documentation for sPOP operation, capable of retriving AND emptying a Set at once.
4141 *
4242 * @author Nicolas Grekas <p@tchwork.com>
4343 * @author André Rømcke <andre.romcke+symfony@gmail.com>
@@ -46,11 +46,6 @@ class RedisTagAwareAdapter extends AbstractTagAwareAdapter
4646{
4747 use RedisTrait;
4848
49- /**
50- * Redis "Set" can hold more than 4 billion members, here we limit ourselves to PHP's > 2 billion max int (32Bit).
51- */
52- private const POP_MAX_LIMIT = 2147483647 - 1 ;
53-
5449 /**
5550 * Limits for how many keys are deleted in batch.
5651 */
@@ -62,26 +57,18 @@ class RedisTagAwareAdapter extends AbstractTagAwareAdapter
6257 */
6358 private const DEFAULT_CACHE_TTL = 8640000 ;
6459
65- /**
66- * @var bool|null
67- */
68- private $ redisServerSupportSPOP = null ;
69-
7060 /**
7161 * @param \Redis|\RedisArray|\RedisCluster|\Predis\ClientInterface $redisClient The redis client
7262 * @param string $namespace The default namespace
7363 * @param int $defaultLifetime The default lifetime
74- *
75- * @throws \Symfony\Component\Cache\Exception\LogicException If phpredis with version lower than 3.1.3.
7664 */
7765 public function __construct ($ redisClient , string $ namespace = '' , int $ defaultLifetime = 0 , MarshallerInterface $ marshaller = null )
7866 {
79- $ this ->init ($ redisClient , $ namespace , $ defaultLifetime , $ marshaller );
80-
81- // Make sure php-redis is 3.1.3 or higher configured for Redis classes
82- if (!$ this ->redis instanceof \Predis \ClientInterface && version_compare (phpversion ('redis ' ), '3.1.3 ' , '< ' )) {
83- throw new LogicException ('RedisTagAwareAdapter requires php-redis 3.1.3 or higher, alternatively use predis/predis ' );
67+ if ($ redisClient instanceof \Predis \ClientInterface && $ redisClient ->getConnection () instanceof ClusterInterface && !$ redisClient ->getConnection () instanceof PredisCluster) {
68+ throw new InvalidArgumentException (sprintf ('Unsupported Predis cluster connection: only "%s" is, "%s" given. ' , PredisCluster::class, \get_class ($ this ->redis ->getConnection ())));
8469 }
70+
71+ $ this ->init ($ redisClient , $ namespace , $ defaultLifetime , $ marshaller );
8572 }
8673
8774 /**
@@ -138,9 +125,10 @@ protected function doDelete(array $ids, array $tagData = []): bool
138125 return true ;
139126 }
140127
141- $ predisCluster = $ this ->redis instanceof \Predis \ClientInterface && $ this ->redis ->getConnection () instanceof ClusterInterface ;
128+ $ predisCluster = $ this ->redis instanceof \Predis \ClientInterface && $ this ->redis ->getConnection () instanceof PredisCluster ;
142129 $ this ->pipeline (static function () use ($ ids , $ tagData , $ predisCluster ) {
143130 if ($ predisCluster ) {
131+ // Unlike phpredis, Predis does not handle bulk calls for us against cluster
144132 foreach ($ ids as $ id ) {
145133 yield 'del ' => [$ id ];
146134 }
@@ -161,46 +149,80 @@ protected function doDelete(array $ids, array $tagData = []): bool
161149 */
162150 protected function doInvalidate (array $ tagIds ): bool
163151 {
164- if (!$ this ->redisServerSupportSPOP ()) {
152+ if (!$ this ->redis instanceof \Predis \ClientInterface || !$ this ->redis ->getConnection () instanceof PredisCluster) {
153+ $ movedTagSetIds = $ this ->renameKeys ($ this ->redis , $ tagIds );
154+ } else {
155+ $ clusterConnection = $ this ->redis ->getConnection ();
156+ $ tagIdsByConnection = new \SplObjectStorage ();
157+ $ movedTagSetIds = [];
158+
159+ foreach ($ tagIds as $ id ) {
160+ $ connection = $ clusterConnection ->getConnectionByKey ($ id );
161+ $ slot = $ tagIdsByConnection [$ connection ] ?? $ tagIdsByConnection [$ connection ] = new \ArrayObject ();
162+ $ slot [] = $ id ;
163+ }
164+
165+ foreach ($ tagIdsByConnection as $ connection ) {
166+ $ slot = $ tagIdsByConnection [$ connection ];
167+ $ connection = new \Predis \Client ($ connection , $ this ->redis ->getOptions ());
168+ $ movedTagSetIds = array_merge ($ movedTagSetIds , $ this ->renameKeys ($ connection , $ slot ->getArrayCopy ()));
169+ }
170+ }
171+
172+ // No Sets found
173+ if (!$ movedTagSetIds ) {
165174 return false ;
166175 }
167176
168- // Pop all tag info at once to avoid race conditions
169- $ tagIdSets = $ this ->pipeline (static function () use ($ tagIds ) {
170- foreach ($ tagIds as $ tagId ) {
171- // Client: Predis or PHP Redis 3.1.3+ (https://github.com/phpredis/phpredis/commit/d2e203a6)
172- // Server: Redis 3.2 or higher (https://redis.io/commands/spop)
173- yield 'sPop ' => [$ tagId , self ::POP_MAX_LIMIT ];
177+ // Now safely take the time to read the keys in each set and collect ids we need to delete
178+ $ tagIdSets = $ this ->pipeline (static function () use ($ movedTagSetIds ) {
179+ foreach ($ movedTagSetIds as $ movedTagId ) {
180+ yield 'sMembers ' => [$ movedTagId ];
174181 }
175182 });
176183
177- // Flatten generator result from pipeline, ignore keys (tag ids)
178- $ ids = array_unique ( array_merge (...iterator_to_array ($ tagIdSets , false ) ));
184+ // Return combination of the temporary Tag Set ids and their values (cache ids)
185+ $ ids = array_merge ($ movedTagSetIds , ...iterator_to_array ($ tagIdSets , false ));
179186
180187 // Delete cache in chunks to avoid overloading the connection
181- foreach (array_chunk ($ ids , self ::BULK_DELETE_LIMIT ) as $ chunkIds ) {
188+ foreach (array_chunk (array_unique ( $ ids) , self ::BULK_DELETE_LIMIT ) as $ chunkIds ) {
182189 $ this ->doDelete ($ chunkIds );
183190 }
184191
185192 return true ;
186193 }
187194
188- private function redisServerSupportSPOP (): bool
195+ /**
196+ * Renames several keys in order to be able to operate on them without risk of race conditions.
197+ *
198+ * Filters out keys that do not exist before returning new keys.
199+ *
200+ * @see https://redis.io/commands/rename
201+ *
202+ * @return array Filtered list of the valid moved keys (only those that existed)
203+ */
204+ private function renameKeys ($ connection , array $ ids ): array
189205 {
190- if (null !== $ this ->redisServerSupportSPOP ) {
191- return $ this ->redisServerSupportSPOP ;
192- }
193-
194- foreach ($ this ->getHosts () as $ host ) {
195- $ info = $ host ->info ('Server ' );
196- $ info = isset ($ info ['Server ' ]) ? $ info ['Server ' ] : $ info ;
197- if (version_compare ($ info ['redis_version ' ], '3.2 ' , '< ' )) {
198- CacheItem::log ($ this ->logger , 'Redis server needs to be version 3.2 or higher, your Redis server was detected as ' .$ info ['redis_version ' ]);
199-
200- return $ this ->redisServerSupportSPOP = false ;
206+ // 1. Due to Predis exception we don't do this in pipeline
207+ // 2. https://redis.io/topics/cluster-spec#keys-hash-tags is used to place in same hash slot on cluster
208+ $ newIds = [];
209+ $ uniqueToken = bin2hex (random_bytes (10 ));
210+ foreach ($ ids as $ id ) {
211+ $ newId = '{ ' .$ id .'} ' .$ uniqueToken ;
212+ try {
213+ $ ok = $ connection ->rename ($ id , $ newId );
214+ if (true === $ ok || ($ ok instanceof Status && $ ok === Status::get ('OK ' ))) {
215+ // Only take into account if ok (key existed), will be false on phpredis if it did not exist
216+ $ newIds [] = $ newId ;
217+ }
218+ } catch (\Predis \Response \ServerException $ e ) {
219+ // Silence errors when key does not exists on Predis. Otherwise re-throw exception
220+ if ('ERR no such key ' !== $ e ->getMessage ()) {
221+ throw $ e ;
222+ }
201223 }
202224 }
203225
204- return $ this -> redisServerSupportSPOP = true ;
226+ return $ newIds ;
205227 }
206228}
0 commit comments