laravel / framework

The Laravel Framework.
https://laravel.com
MIT License
32.03k stars 10.85k forks source link

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

Closed ktanakaj closed 5 months ago

ktanakaj commented 5 months ago

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.
github-actions[bot] commented 5 months ago

Thank you for reporting this issue!

As Laravel is an open source project, we rely on the community to help us diagnose and fix issues as it is not possible to research and fix every issue reported to us via GitHub.

If possible, please make a pull request fixing the issue you have described, along with corresponding tests. All pull requests are promptly reviewed by the Laravel team.

Thank you!

driesvints commented 5 months ago

Please see https://laravel.com/docs/10.x/upgrade#redis-cache-tags

Usage of Cache::tags() is only recommended for applications using Memcached. If you are using Redis as your application's cache driver, you should consider moving to Memcached or using an alternative solution.

ktanakaj commented 5 months ago

OMG. I got it. But I have confirmed that I checked the page before selecting Laravel 10 and the Cache server. https://web.archive.org/web/20230428092634/https://laravel.com/docs/10.x/upgrade#redis-cache-tags

Did the Laravel maintainer change the supported driver in a minor update? Changes like this should only be made in a major update...

FeBe95 commented 4 months ago

Even the whole section about "Cache Tags" is gone in the Laravel 10 documentation...