11
11
12
12
namespace Symfony \Component \Cache \Adapter ;
13
13
14
- use Predis ;
15
14
use Predis \Connection \Aggregate \ClusterInterface ;
15
+ use Predis \Connection \Aggregate \PredisCluster ;
16
16
use Predis \Response \Status ;
17
- use Symfony \Component \Cache \CacheItem ;
18
- use Symfony \Component \Cache \Exception \LogicException ;
17
+ use Symfony \Component \Cache \Exception \InvalidArgumentException ;
19
18
use Symfony \Component \Cache \Marshaller \MarshallerInterface ;
20
19
use Symfony \Component \Cache \Traits \RedisTrait ;
21
20
22
21
/**
23
- * Stores tag id <> cache id relationship as a Redis Set, lookup on invalidation using sPOP .
22
+ * Stores tag id <> cache id relationship as a Redis Set, lookup on invalidation using RENAME+SMEMBERS .
24
23
*
25
24
* Set (tag relation info) is stored without expiry (non-volatile), while cache always gets an expiry (volatile) even
26
25
* if not set by caller. Thus if you configure redis with the right eviction policy you can be safe this tag <> cache
27
26
* relationship survives eviction (cache cleanup when Redis runs out of memory).
28
27
*
29
28
* 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
33
33
*
34
34
* 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
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
37
*
38
38
* @see https://redis.io/topics/lru-cache#eviction-policies Documentation for Redis eviction policies.
39
39
* @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.
41
40
*
42
41
* @author Nicolas Grekas <p@tchwork.com>
43
42
* @author André Rømcke <andre.romcke+symfony@gmail.com>
@@ -46,11 +45,6 @@ class RedisTagAwareAdapter extends AbstractTagAwareAdapter
46
45
{
47
46
use RedisTrait;
48
47
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
-
54
48
/**
55
49
* Limits for how many keys are deleted in batch.
56
50
*/
@@ -62,26 +56,18 @@ class RedisTagAwareAdapter extends AbstractTagAwareAdapter
62
56
*/
63
57
private const DEFAULT_CACHE_TTL = 8640000 ;
64
58
65
- /**
66
- * @var bool|null
67
- */
68
- private $ redisServerSupportSPOP = null ;
69
-
70
59
/**
71
60
* @param \Redis|\RedisArray|\RedisCluster|\Predis\ClientInterface $redisClient The redis client
72
61
* @param string $namespace The default namespace
73
62
* @param int $defaultLifetime The default lifetime
74
- *
75
- * @throws \Symfony\Component\Cache\Exception\LogicException If phpredis with version lower than 3.1.3.
76
63
*/
77
64
public function __construct ($ redisClient , string $ namespace = '' , int $ defaultLifetime = 0 , MarshallerInterface $ marshaller = null )
78
65
{
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 ($ redisClient instanceof \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 ())));
84
68
}
69
+
70
+ $ this ->init ($ redisClient , $ namespace , $ defaultLifetime , $ marshaller );
85
71
}
86
72
87
73
/**
@@ -138,9 +124,10 @@ protected function doDelete(array $ids, array $tagData = []): bool
138
124
return true ;
139
125
}
140
126
141
- $ predisCluster = $ this ->redis instanceof \Predis \ClientInterface && $ this ->redis ->getConnection () instanceof ClusterInterface ;
127
+ $ predisCluster = $ this ->redis instanceof \Predis \ClientInterface && $ this ->redis ->getConnection () instanceof PredisCluster ;
142
128
$ this ->pipeline (static function () use ($ ids , $ tagData , $ predisCluster ) {
143
129
if ($ predisCluster ) {
130
+ // Unlike phpredis, Predis does not handle bulk calls for us against cluster
144
131
foreach ($ ids as $ id ) {
145
132
yield 'del ' => [$ id ];
146
133
}
@@ -161,46 +148,82 @@ protected function doDelete(array $ids, array $tagData = []): bool
161
148
*/
162
149
protected function doInvalidate (array $ tagIds ): bool
163
150
{
164
- if (!$ this ->redisServerSupportSPOP ()) {
151
+ if (($ redis = $ this ->redis ) instanceof \Predis \ClientInterface) {
152
+ $ options = clone $ redis ->getOptions ();
153
+ \Closure::bind (function () { $ this ->options ['exceptions ' ] = false ; }, $ options , $ options )();
154
+ $ redis = new $ redis ($ redis ->getConnection (), $ options );
155
+ }
156
+
157
+ if (!$ redis instanceof \Predis \ClientInterface || !$ redis ->getConnection () instanceof PredisCluster) {
158
+ $ movedTagSetIds = $ this ->renameKeys ($ redis , $ tagIds );
159
+ } else {
160
+ $ clusterConnection = $ redis ->getConnection ();
161
+ $ tagIdsByConnection = new \SplObjectStorage ();
162
+ $ movedTagSetIds = [];
163
+
164
+ foreach ($ tagIds as $ id ) {
165
+ $ connection = $ clusterConnection ->getConnectionByKey ($ id );
166
+ $ slot = $ tagIdsByConnection [$ connection ] ?? $ tagIdsByConnection [$ connection ] = new \ArrayObject ();
167
+ $ slot [] = $ id ;
168
+ }
169
+
170
+ foreach ($ tagIdsByConnection as $ connection ) {
171
+ $ slot = $ tagIdsByConnection [$ connection ];
172
+ $ movedTagSetIds = array_merge ($ movedTagSetIds , $ this ->renameKeys (new $ redis ($ connection , $ options ), $ slot ->getArrayCopy ()));
173
+ }
174
+ }
175
+
176
+ // No Sets found
177
+ if (!$ movedTagSetIds ) {
165
178
return false ;
166
179
}
167
180
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 ];
181
+ // Now safely take the time to read the keys in each set and collect ids we need to delete
182
+ $ tagIdSets = $ this ->pipeline (static function () use ($ movedTagSetIds ) {
183
+ foreach ($ movedTagSetIds as $ movedTagId ) {
184
+ yield 'sMembers ' => [$ movedTagId ];
174
185
}
175
186
});
176
187
177
- // Flatten generator result from pipeline, ignore keys (tag ids)
178
- $ ids = array_unique ( array_merge (...iterator_to_array ($ tagIdSets , false ) ));
188
+ // Return combination of the temporary Tag Set ids and their values (cache ids)
189
+ $ ids = array_merge ($ movedTagSetIds , ...iterator_to_array ($ tagIdSets , false ));
179
190
180
191
// Delete cache in chunks to avoid overloading the connection
181
- foreach (array_chunk ($ ids , self ::BULK_DELETE_LIMIT ) as $ chunkIds ) {
192
+ foreach (array_chunk (array_unique ( $ ids) , self ::BULK_DELETE_LIMIT ) as $ chunkIds ) {
182
193
$ this ->doDelete ($ chunkIds );
183
194
}
184
195
185
196
return true ;
186
197
}
187
198
188
- private function redisServerSupportSPOP (): bool
199
+ /**
200
+ * Renames several keys in order to be able to operate on them without risk of race conditions.
201
+ *
202
+ * Filters out keys that do not exist before returning new keys.
203
+ *
204
+ * @see https://redis.io/commands/rename
205
+ * @see https://redis.io/topics/cluster-spec#keys-hash-tags
206
+ *
207
+ * @return array Filtered list of the valid moved keys (only those that existed)
208
+ */
209
+ private function renameKeys ($ redis , array $ ids ): array
189
210
{
190
- if (null !== $ this ->redisServerSupportSPOP ) {
191
- return $ this ->redisServerSupportSPOP ;
192
- }
211
+ $ newIds = [];
212
+ $ uniqueToken = bin2hex (random_bytes (10 ));
193
213
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 ' ] );
214
+ $ results = $ this ->pipeline ( static function () use ( $ ids , $ uniqueToken ) {
215
+ foreach ( $ ids as $ id ) {
216
+ yield ' rename ' => [ $ id , ' { ' . $ id . ' } ' . $ uniqueToken ] ;
217
+ }
218
+ }, $ redis );
199
219
200
- return $ this ->redisServerSupportSPOP = false ;
220
+ foreach ($ results as $ id => $ ok ) {
221
+ if (true === $ ok || ($ ok instanceof Status && Status::get ('OK ' ) === $ ok )) {
222
+ // Only take into account if ok (key existed), will be false on phpredis if it did not exist
223
+ $ newIds [] = '{ ' .$ id .'} ' .$ uniqueToken ;
201
224
}
202
225
}
203
226
204
- return $ this -> redisServerSupportSPOP = true ;
227
+ return $ newIds ;
205
228
}
206
229
}
0 commit comments