Closed
Description
Laravel Version
10.38.2
PHP Version
8.2.16 (Docker, php-fpm)
Database Driver & Version
PhpRedis and Redis 7.0.7 on AWS ElastiCache (Redis Cluster)
Description
The php artisan cache:prune-stale-tags
command does not function on a Redis Cluster. I have verified that this command works with Redis. However, when executed on a Redis cluster, no memory is freed, as illustrated in the chart below.
(The command was executed every hour. The areas that have decreased are those that I have manually addressed.)
I suspect there may be issues with both Laravel and possibly PhpRedis.
I addressed this by implementing the following measures in the code:
RedisStore::currentTags()
andPhpRedisConnection::scan()
- Modified to execute the
SCAN
command on each node in the Redis Cluster.
- Modified to execute the
RedisTagSet::flushStaleEntries()
- Modified to remove stale entries without using
pipeline()
.
- Modified to remove stale entries without using
I was unable to successfully integrate this into the framework code. I hope this issue can be resolved.
<?php
namespace App\Console\Commands;
use Illuminate\Cache\CacheManager;
use Illuminate\Cache\Console\PruneStaleTagsCommand;
use Illuminate\Cache\RedisStore;
use Illuminate\Cache\RedisTagSet;
use Illuminate\Console\Command;
use Illuminate\Redis\Connections\PhpRedisClusterConnection;
use Illuminate\Redis\Connections\PhpRedisConnection;
use Illuminate\Redis\Connections\PredisConnection;
use Illuminate\Support\Carbon;
use Illuminate\Support\LazyCollection;
use Illuminate\Support\Str;
use Redis;
use RedisCluster;
class PruneStaleTags extends PruneStaleTagsCommand
{
public function handle(CacheManager $cache)
{
$cache = $cache->store($this->argument('store'));
$store = $cache->getStore();
if (! $store instanceof RedisStore) {
$this->error('Pruning cache tags is only necessary when using Redis.');
return Command::FAILURE;
}
// If the Redis connection is a cluster, call the modified version of the method.
$this->info('Stale cache tags are pruning...');
if ($store->connection() instanceof PhpRedisClusterConnection) {
$this->flushStaleTagsForRedisCluster($store);
$this->info('Stale cache tags for cluster pruned successfully.');
} else {
$cache->flushStaleTags();
$this->info('Stale cache tags pruned successfully.');
}
return Command::SUCCESS;
}
// Modified from RedisStore::flushStaleTags()
private function flushStaleTagsForRedisCluster(RedisStore $store): void
{
$count = 0;
foreach ($this->currentTagsForRedisCluster($store)->chunk(1000) as $tags) {
$count += $this->flushStaleEntriesWithOutPipeline($store->tags($tags->all())->getTags(), $store);
}
$this->info("{$count} tags were pruned.");
}
// Modified from RedisStore::currentTags()
private function currentTagsForRedisCluster(RedisStore $store, int $chunkSize = 1000): LazyCollection
{
$connection = $store->connection();
$connectionPrefix = match (true) {
$connection instanceof PhpRedisConnection => $connection->_prefix(''),
$connection instanceof PredisConnection => $connection->getOptions()->prefix ?: '',
default => '',
};
$prefix = $connectionPrefix.$store->getPrefix();
// Set Redis options just in case. *This might not be necessary.
// https://github.com/phpredis/phpredis/issues/2074
$redis = $connection->client();
$redis->setOption(Redis::OPT_SCAN, Redis::SCAN_RETRY);
$redis->setOption(RedisCluster::OPT_SLAVE_FAILOVER, RedisCluster::FAILOVER_ERROR);
return LazyCollection::make(function () use ($redis, $chunkSize, $prefix) {
// Modified to loop through the master nodes of the Redis cluster.
foreach ($redis->_masters() as $master) {
$cursor = $defaultCursorValue = '0';
$count = 0;
do {
[$cursor, $tagsChunk] = $this->scanForCluster(
$redis,
$master,
$cursor,
['match' => $prefix.'tag:*:entries', 'count' => $chunkSize]);
if (! is_array($tagsChunk)) {
break;
}
$count += count($tagsChunk);
$tagsChunk = array_unique($tagsChunk);
if (empty($tagsChunk)) {
continue;
}
foreach ($tagsChunk as $tag) {
yield $tag;
}
} while (((string) $cursor) !== $defaultCursorValue);
$this->info("{$master[0]}:{$master[1]} : {$count} keys were scanned.");
}
})->map(fn (string $tagKey) => Str::match('/^'.preg_quote($prefix, '/').'tag:(.*):entries$/', $tagKey));
}
// Modified from PhpRedisConnection::scan()
private function scanForCluster(RedisCluster $redis, array $node, $cursor, array $options = []): mixed
{
// Execute scan on the node.
$result = $redis->scan(
$cursor,
$node,
$options['match'] ?? '*',
$options['count'] ?? 10
);
if ($result === false) {
$result = [];
}
return $cursor === 0 && empty($result) ? false : [$cursor, $result];
}
// Modified from RedisTagSet::flushStaleEntries()
public function flushStaleEntriesWithOutPipeline(RedisTagSet $tags, RedisStore $store): int
{
// Remove the stale entries without using pipeline(), as PhpRedisClusterConnection does not support pipeline().
$conn = $store->connection();
$count = 0;
foreach ($tags->getNames() as $name) {
$tagKey = $tags->tagId($name);
$count += $conn->zremrangebyscore($store->getPrefix().$tagKey, 0, Carbon::now()->getTimestamp());
}
return $count;
}
}
Steps To Reproduce
- Use a cache with an expiration time on a Redis Cluster.
- After the caches have expired, run
php artisan cache:prune-stale-tags
.