Skip to content

Commit b41526e

Browse files
andreromnicolas-grekas
authored andcommitted
[Cache] Improve RedisTagAwareAdapter invalidation logic & requirements
1 parent a0bbae7 commit b41526e

File tree

1 file changed

+72
-50
lines changed

1 file changed

+72
-50
lines changed

src/Symfony/Component/Cache/Adapter/RedisTagAwareAdapter.php

+72-50
Original file line numberDiff line numberDiff line change
@@ -11,33 +11,33 @@
1111

1212
namespace Symfony\Component\Cache\Adapter;
1313

14-
use Predis;
1514
use Predis\Connection\Aggregate\ClusterInterface;
15+
use Predis\Connection\Aggregate\PredisCluster;
1616
use Predis\Response\Status;
1717
use Symfony\Component\Cache\CacheItem;
18-
use Symfony\Component\Cache\Exception\LogicException;
18+
use Symfony\Component\Cache\Exception\InvalidArgumentException;
1919
use Symfony\Component\Cache\Marshaller\MarshallerInterface;
2020
use 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

Comments
 (0)