Skip to content

cache:prune-stale-tags Not Functioning on Redis Cluster #50415

Closed
@ktanakaj

Description

@ktanakaj

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.
RedisClusterMemoryUsage
(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:

  1. RedisStore::currentTags() and PhpRedisConnection::scan()
    • Modified to execute the SCAN command on each node in the Redis Cluster.
  2. RedisTagSet::flushStaleEntries()
    • Modified to remove stale entries without using pipeline().

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

  1. Use a cache with an expiration time on a Redis Cluster.
  2. After the caches have expired, run php artisan cache:prune-stale-tags.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions