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 ;
17- use Symfony \Component \Cache \CacheItem ;
18- use Symfony \Component \Cache \Exception \LogicException ;
17+ use Symfony \Component \Cache \Exception \InvalidArgumentException ;
1918use Symfony \Component \Cache \Marshaller \MarshallerInterface ;
2019use Symfony \Component \Cache \Traits \RedisTrait ;
2120
2221/**
23- * Stores tag id <> cache id relationship as a Redis Set, lookup on invalidation usingsPOP .
22+ * Stores tag id <> cache id relationship as a Redis Set, lookup on invalidation usingRENAME+SMEMBERS .
2423 *
2524 * Set (tag relation info) is stored without expiry (non-volatile), while cache always gets an expiry (volatile) even
2625 * if not set by caller. Thus if you configure redis with the right eviction policy you can be safe this tag <> cache
2726 * relationship survives eviction (cache cleanup when Redis runs out of memory).
2827 *
2928 * 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
29+ * - Client: PHP Redis or Predis
30+ * Note: Due to lack of RENAME support it is NOT recommended to use Cluster on Predis, instead use phpredis.
31+ * - Server: Redis 2.8+
32+ * Configured with any `volatile-*` eviction policy, OR `noeviction` if it will NEVER fill up memory
3333 *
3434 * Design limitations:
35- * - Max2 billion cache keys per cache tag
36- * E.g. If you use a "all" items tag for expiry instead of clear(), that limits you to2 billion cache itemsas well
35+ * - Max4 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 to4 billion cache itemsalso.
3737 *
3838 * @see https://redis.io/topics/lru-cache#eviction-policies Documentation for Redis eviction policies.
3939 * @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.
4140 *
4241 * @author Nicolas Grekas <p@tchwork.com>
4342 * @author André Rømcke <andre.romcke+symfony@gmail.com>
@@ -46,11 +45,6 @@ class RedisTagAwareAdapter extends AbstractTagAwareAdapter
4645{
4746use RedisTrait;
4847
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-
5448/**
5549 * Limits for how many keys are deleted in batch.
5650 */
@@ -62,26 +56,18 @@ class RedisTagAwareAdapter extends AbstractTagAwareAdapter
6256 */
6357private const DEFAULT_CACHE_TTL =8640000 ;
6458
65- /**
66- * @var bool|null
67- */
68- private $ redisServerSupportSPOP =null ;
69-
7059/**
7160 * @param \Redis|\RedisArray|\RedisCluster|\Predis\ClientInterface $redisClient The redis client
7261 * @param string $namespace The default namespace
7362 * @param int $defaultLifetime The default lifetime
74- *
75- * @throws \Symfony\Component\Cache\Exception\LogicException If phpredis with version lower than 3.1.3.
7663 */
7764public function __construct ($ redisClient ,string $ namespace ='' ,int $ defaultLifetime =0 ,MarshallerInterface $ marshaller =null )
7865 {
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 ' );
66+ if ($ redisClientinstanceof \Predis \ClientInterface &&$ redisClient ->getConnection ()instanceof ClusterInterface && !$ redisClient ->getConnection ()instanceof PredisCluster) {
67+ throw new InvalidArgumentException (sprintf ('Unsupported Predis cluster connection: only "%s" is, "%s" given. ' , PredisCluster::class,\get_class ($ redisClient ->getConnection ())));
8468 }
69+
70+ $ this ->init ($ redisClient ,$ namespace ,$ defaultLifetime ,$ marshaller );
8571 }
8672
8773/**
@@ -121,7 +107,7 @@ protected function doSave(array $values, ?int $lifetime, array $addTagData = [],
121107continue ;
122108 }
123109// setEx results
124- if (true !==$ result && (!$ resultinstanceof Status ||$ result !== Status::get ('OK ' ))) {
110+ if (true !==$ result && (!$ resultinstanceof Status || Status::get ('OK ' ) !== $ result )) {
125111$ failed [] =$ id ;
126112 }
127113 }
@@ -138,9 +124,10 @@ protected function doDelete(array $ids, array $tagData = []): bool
138124return true ;
139125 }
140126
141- $ predisCluster =$ this ->redis instanceof \Predis \ClientInterface &&$ this ->redis ->getConnection ()instanceof ClusterInterface ;
127+ $ predisCluster =$ this ->redis instanceof \Predis \ClientInterface &&$ this ->redis ->getConnection ()instanceof PredisCluster ;
142128$ this ->pipeline (static function ()use ($ ids ,$ tagData ,$ predisCluster ) {
143129if ($ predisCluster ) {
130+ // Unlike phpredis, Predis does not handle bulk calls for us against cluster
144131foreach ($ idsas $ id ) {
145132yield 'del ' => [$ id ];
146133 }
@@ -161,46 +148,76 @@ protected function doDelete(array $ids, array $tagData = []): bool
161148 */
162149protected function doInvalidate (array $ tagIds ):bool
163150 {
164- if (!$ this ->redisServerSupportSPOP ()) {
151+ if (!$ this ->redis instanceof \Predis \ClientInterface || !$ this ->redis ->getConnection ()instanceof PredisCluster) {
152+ $ movedTagSetIds =$ this ->renameKeys ($ this ->redis ,$ tagIds );
153+ }else {
154+ $ clusterConnection =$ this ->redis ->getConnection ();
155+ $ tagIdsByConnection =new \SplObjectStorage ();
156+ $ movedTagSetIds = [];
157+
158+ foreach ($ tagIdsas $ id ) {
159+ $ connection =$ clusterConnection ->getConnectionByKey ($ id );
160+ $ slot =$ tagIdsByConnection [$ connection ] ??$ tagIdsByConnection [$ connection ] =new \ArrayObject ();
161+ $ slot [] =$ id ;
162+ }
163+
164+ foreach ($ tagIdsByConnectionas $ connection ) {
165+ $ slot =$ tagIdsByConnection [$ connection ];
166+ $ movedTagSetIds =array_merge ($ movedTagSetIds ,$ this ->renameKeys (new $ this ->redis ($ connection ,$ this ->redis ->getOptions ()),$ slot ->getArrayCopy ()));
167+ }
168+ }
169+
170+ // No Sets found
171+ if (!$ movedTagSetIds ) {
165172return false ;
166173 }
167174
168- // Pop all tag info at once to avoid race conditions
169- $ tagIdSets =$ this ->pipeline (static function ()use ($ tagIds ) {
170- foreach ($ tagIdsas $ 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 ];
175+ // Now safely take the time to read the keys in each set and collect ids we need to delete
176+ $ tagIdSets =$ this ->pipeline (static function ()use ($ movedTagSetIds ) {
177+ foreach ($ movedTagSetIdsas $ movedTagId ) {
178+ yield 'sMembers ' => [$ movedTagId ];
174179 }
175180 });
176181
177- //Flatten generator result from pipeline, ignore keys (tag ids)
178- $ ids =array_unique ( array_merge (...iterator_to_array ($ tagIdSets ,false ) ));
182+ //Return combination of the temporary Tag Set ids and their values (cache ids)
183+ $ ids =array_merge ($ movedTagSetIds , ...iterator_to_array ($ tagIdSets ,false ));
179184
180185// Delete cache in chunks to avoid overloading the connection
181- foreach (array_chunk ($ ids ,self ::BULK_DELETE_LIMIT )as $ chunkIds ) {
186+ foreach (array_chunk (array_unique ( $ ids) ,self ::BULK_DELETE_LIMIT )as $ chunkIds ) {
182187$ this ->doDelete ($ chunkIds );
183188 }
184189
185190return true ;
186191 }
187192
188- private function redisServerSupportSPOP ():bool
193+ /**
194+ * Renames several keys in order to be able to operate on them without risk of race conditions.
195+ *
196+ * Filters out keys that do not exist before returning new keys.
197+ *
198+ * @see https://redis.io/commands/rename
199+ * @see https://redis.io/topics/cluster-spec#keys-hash-tags
200+ *
201+ * @return array Filtered list of the valid moved keys (only those that existed)
202+ */
203+ private function renameKeys ($ redis ,array $ ids ):array
189204 {
190- if (null !==$ this ->redisServerSupportSPOP ) {
191- return $ this ->redisServerSupportSPOP ;
192- }
205+ $ newIds = [];
206+ $ uniqueToken =bin2hex (random_bytes (10 ));
193207
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 ' ] );
208+ $ results = $ this ->pipeline ( static function () use ( $ ids , $ uniqueToken ) {
209+ foreach ( $ ids as $ id ) {
210+ yield ' rename ' => [ $ id , ' { ' . $ id . ' } ' . $ uniqueToken ] ;
211+ }
212+ }, $ redis );
199213
200- return $ this ->redisServerSupportSPOP =false ;
214+ foreach ($ resultsas $ id =>$ result ) {
215+ if (true ===$ result || ($ resultinstanceof Status && Status::get ('OK ' ) ===$ result )) {
216+ // Only take into account if ok (key existed), will be false on phpredis if it did not exist
217+ $ newIds [] ='{ ' .$ id .'} ' .$ uniqueToken ;
201218 }
202219 }
203220
204- return $ this -> redisServerSupportSPOP = true ;
221+ return $ newIds ;
205222 }
206223}