Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
ktanakaj opened this issue Mar 8, 2024 · 4 comments
Closed

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

ktanakaj opened this issue Mar 8, 2024 · 4 comments

Comments

@ktanakaj
Copy link

ktanakaj commented Mar 8, 2024

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.
Copy link

github-actions bot commented Mar 8, 2024

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
Copy link
Member

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
Copy link
Author

ktanakaj commented Mar 8, 2024

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
Copy link

FeBe95 commented Apr 15, 2024

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants