diff --git a/config/cache.php b/config/cache.php index 923671e14e53..9f8d8415b814 100644 --- a/config/cache.php +++ b/config/cache.php @@ -87,6 +87,7 @@ 'driver' => 'redis', 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), + 'flush_scope' => env('REDIS_CACHE_FLUSH_SCOPE', 'database'), ], 'dynamodb' => [ @@ -119,7 +120,9 @@ | | When utilizing the APC, database, memcached, Redis, and DynamoDB cache | stores, there might be other applications using the same cache. For - | that reason, you may prefix every cache key to avoid collisions. + | that reason, you may prefix every cache key to avoid collisions. This + | prefix also allows Redis cache stores to be cleared without flushing the + | entire database when REDIS_CACHE_FLUSH_SCOPE is set to "prefix". | */ diff --git a/config/database.php b/config/database.php index dcfe3f033179..26c4e3bfd85f 100644 --- a/config/database.php +++ b/config/database.php @@ -149,7 +149,9 @@ | | Redis is an open source, fast, and advanced key-value store that also | provides a richer body of commands than a typical key-value system - | such as Memcached. You may define your connection settings here. + | such as Memcached. You may define your connection settings here. Modern + | cloud Redis providers often only expose database 0, so application + | isolation should rely on prefixes where separate databases are unavailable. | */ diff --git a/src/Illuminate/Cache/Console/ClearCommand.php b/src/Illuminate/Cache/Console/ClearCommand.php index 496c43878281..cbdec9a65892 100755 --- a/src/Illuminate/Cache/Console/ClearCommand.php +++ b/src/Illuminate/Cache/Console/ClearCommand.php @@ -73,11 +73,27 @@ public function handle() return $this->clearLocks(); } + if ($this->option('prefix') && ! empty($this->tags())) { + $this->components->error('Cache tags cannot be used when clearing by prefix.'); + + return self::FAILURE; + } + $this->laravel['events']->dispatch( 'cache:clearing', [$this->argument('store'), $this->tags()] ); - $successful = $this->cache()->flush(); + if ($this->flushesByPrefix()) { + try { + $successful = $this->cache()->flushPrefix(); + } catch (BadMethodCallException) { + $this->components->error('This cache store does not support clearing by prefix.'); + + return self::FAILURE; + } + } else { + $successful = $this->cache()->flush(); + } $this->flushFacades(); @@ -103,6 +119,12 @@ public function handle() */ protected function clearLocks() { + if ($this->option('prefix')) { + $this->components->error('Cache locks cannot be used when clearing by prefix.'); + + return self::FAILURE; + } + if (! empty($this->tags())) { $this->components->error('Cache tags cannot be used when clearing locks.'); @@ -168,6 +190,26 @@ protected function tags() return array_filter(explode(',', $this->option('tags') ?? '')); } + /** + * Determine if the cache store should be cleared by prefix. + * + * @return bool + */ + protected function flushesByPrefix() + { + if ($this->option('prefix')) { + return true; + } + + if (! empty($this->tags()) || ! $this->laravel->bound('config')) { + return false; + } + + $store = $this->argument('store') ?: $this->laravel['config']->get('cache.default'); + + return $this->laravel['config']->get("cache.stores.{$store}.flush_scope") === 'prefix'; + } + /** * Get the console command arguments. * @@ -189,6 +231,7 @@ protected function getOptions() { return [ ['tags', null, InputOption::VALUE_OPTIONAL, 'The cache tags you would like to clear', null], + ['prefix', null, InputOption::VALUE_NONE, 'Only clear cache entries matching the configured prefix'], ['locks', null, InputOption::VALUE_NONE, 'Only clear cache locks'], ]; } diff --git a/src/Illuminate/Cache/RedisStore.php b/src/Illuminate/Cache/RedisStore.php index bd0eec1570d4..c54fdf0579f6 100755 --- a/src/Illuminate/Cache/RedisStore.php +++ b/src/Illuminate/Cache/RedisStore.php @@ -3,6 +3,7 @@ namespace Illuminate\Cache; use Illuminate\Contracts\Cache\CanFlushLocks; +use Illuminate\Contracts\Cache\CanFlushPrefix; use Illuminate\Contracts\Cache\LockProvider; use Illuminate\Contracts\Redis\Factory as Redis; use Illuminate\Redis\Connections\PhpRedisClusterConnection; @@ -13,7 +14,7 @@ use Illuminate\Support\Str; use RuntimeException; -class RedisStore extends TaggableStore implements CanFlushLocks, LockProvider +class RedisStore extends TaggableStore implements CanFlushLocks, CanFlushPrefix, LockProvider { use RetrievesMultipleKeys { many as private manyAlias; @@ -300,6 +301,30 @@ public function flush() return true; } + /** + * Remove all cache entries managed by the store's configured prefix. + * + * @return bool + * + * @throws \RuntimeException + */ + public function flushPrefix(): bool + { + if ((string) $this->getPrefix() === '') { + throw new RuntimeException('Flushing Redis cache by prefix is only supported when a cache prefix is configured.'); + } + + $connection = $this->connection(); + $connectionPrefix = $this->connectionPrefix($connection); + $prefix = $connectionPrefix.$this->getPrefix(); + + foreach ($this->scan($connection, $prefix.'*') as $keys) { + $this->deleteKeys($connection, $keys, $connectionPrefix); + } + + return true; + } + /** * Remove all locks from the store. * @@ -399,6 +424,135 @@ protected function currentTags($chunkSize = 1000) }))->map(fn (string $tagKey) => Str::match('/^'.preg_quote($prefix, '/').'tag:(.*):entries$/', $tagKey)); } + /** + * Scan all Redis keys matching the given pattern. + * + * @param \Illuminate\Redis\Connections\Connection $connection + * @param string $pattern + * @param int $chunkSize + * @return \Illuminate\Support\LazyCollection + */ + protected function scan($connection, string $pattern, int $chunkSize = 1000) + { + if ($connection instanceof PhpRedisClusterConnection) { + return (new LazyCollection(function () use ($connection, $pattern, $chunkSize) { + foreach ($connection->client()->_masters() as $master) { + yield from $this->scanNode($connection, $pattern, $chunkSize, $master); + } + }))->filter->isNotEmpty(); + } + + if ($connection instanceof PredisClusterConnection) { + return (new LazyCollection(function () use ($connection, $pattern, $chunkSize) { + foreach ($connection->client() as $node) { + yield from $this->scanNode($node, $pattern, $chunkSize); + } + }))->filter->isNotEmpty(); + } + + return (new LazyCollection(function () use ($connection, $pattern, $chunkSize) { + yield from $this->scanNode($connection, $pattern, $chunkSize); + }))->filter->isNotEmpty(); + } + + /** + * Scan matching Redis keys for a connection or cluster node. + * + * @param mixed $connection + * @param string $pattern + * @param int $chunkSize + * @param mixed|null $node + * @return \Generator + */ + protected function scanNode($connection, string $pattern, int $chunkSize, $node = null) + { + $cursor = $this->initialScanCursor($connection); + + do { + $options = ['match' => $pattern, 'count' => $chunkSize]; + + if (! is_null($node)) { + $options['node'] = $node; + } + + $results = $connection->scan($cursor, $options); + + if (! is_array($results)) { + break; + } + + [$cursor, $keys] = $results; + + if (is_array($keys) && $keys !== []) { + yield collect($keys); + } + } while (! in_array($cursor, [0, '0', null], true)); + } + + /** + * Get the initial scan cursor for the Redis connection. + * + * @param mixed $connection + * @return mixed + */ + protected function initialScanCursor($connection) + { + return $connection instanceof PhpRedisConnection && version_compare(phpversion('redis'), '6.1.0', '>=') + ? null + : '0'; + } + + /** + * Delete the given keys from Redis. + * + * @param \Illuminate\Redis\Connections\Connection $connection + * @param \Illuminate\Support\Collection $keys + * @param string $connectionPrefix + * @return void + */ + protected function deleteKeys($connection, $keys, string $connectionPrefix) + { + $keys = $keys->map(fn (string $key) => $this->normalizeKey($key, $connectionPrefix))->values(); + + if ($connection instanceof PhpRedisClusterConnection || + $connection instanceof PredisClusterConnection) { + $keys->each(fn (string $key) => $connection->del($key)); + + return; + } + + $connection->del(...$keys->all()); + } + + /** + * Remove the Redis client's configured prefix from a scanned key. + * + * @param string $key + * @param string $connectionPrefix + * @return string + */ + protected function normalizeKey(string $key, string $connectionPrefix): string + { + return $connectionPrefix !== '' && Str::startsWith($key, $connectionPrefix) + ? Str::after($key, $connectionPrefix) + : $key; + } + + /** + * Get the configured Redis client prefix for the connection. + * + * @param \Illuminate\Redis\Connections\Connection $connection + * @return string + */ + protected function connectionPrefix($connection): string + { + return (string) match (true) { + $connection instanceof PhpRedisConnection => $connection->client()->getOption(\Redis::OPT_PREFIX) ?: '', + $connection instanceof PredisConnection => $connection->client()->getOptions()->prefix ?: '', + default => '', + }; + } + /** * Get the Redis connection instance. * diff --git a/src/Illuminate/Cache/Repository.php b/src/Illuminate/Cache/Repository.php index 7e7fdd23bd6a..871b4bd1e9c4 100755 --- a/src/Illuminate/Cache/Repository.php +++ b/src/Illuminate/Cache/Repository.php @@ -25,6 +25,7 @@ use Illuminate\Cache\Events\WritingManyKeys; use Illuminate\Cache\Limiters\ConcurrencyLimiterBuilder; use Illuminate\Contracts\Cache\CanFlushLocks; +use Illuminate\Contracts\Cache\CanFlushPrefix; use Illuminate\Contracts\Cache\LockProvider; use Illuminate\Contracts\Cache\Repository as CacheContract; use Illuminate\Contracts\Cache\Store; @@ -820,6 +821,32 @@ public function flushLocks(): bool return $result; } + /** + * Flush all cache entries managed by the store's configured prefix. + * + * @throws \BadMethodCallException + */ + public function flushPrefix(): bool + { + $store = $this->getStore(); + + if (! $this->supportsFlushingPrefix()) { + throw new BadMethodCallException('This cache store does not support flushing by prefix.'); + } + + $this->event(new CacheFlushing($this->getName())); + + $result = $store->flushPrefix(); + + if ($result) { + $this->event(new CacheFlushed($this->getName())); + } else { + $this->event(new CacheFlushFailed($this->getName())); + } + + return $result; + } + /** * Begin executing a new tags operation if the store supports it. * @@ -925,6 +952,14 @@ public function supportsFlushingLocks(): bool return $this->store instanceof CanFlushLocks; } + /** + * Determine if the current store supports flushing by prefix. + */ + public function supportsFlushingPrefix(): bool + { + return $this->store instanceof CanFlushPrefix; + } + /** * Get the default cache time. * diff --git a/src/Illuminate/Contracts/Cache/CanFlushPrefix.php b/src/Illuminate/Contracts/Cache/CanFlushPrefix.php new file mode 100644 index 000000000000..1bbe428d13ae --- /dev/null +++ b/src/Illuminate/Contracts/Cache/CanFlushPrefix.php @@ -0,0 +1,13 @@ +assertTrue($repository->clear()); } + public function testFlushPrefixTriggersEvents() + { + $dispatcher = $this->getDispatcher(); + $store = m::mock(Store::class, CanFlushPrefix::class); + $repository = new Repository($store, ['store' => 'redis']); + $repository->setEventDispatcher($dispatcher); + + $store->shouldReceive('flushPrefix')->once()->andReturn(true); + + $dispatcher->shouldReceive('dispatch')->once()->with( + $this->assertEventMatches(CacheFlushing::class, [ + 'storeName' => 'redis', + ]) + ); + + $dispatcher->shouldReceive('dispatch')->once()->with( + $this->assertEventMatches(CacheFlushed::class, [ + 'storeName' => 'redis', + ]) + ); + + $this->assertTrue($repository->flushPrefix()); + } + public function testFlushLocksTriggersEvents() { $dispatcher = $this->getDispatcher(); diff --git a/tests/Cache/CacheRedisStoreTest.php b/tests/Cache/CacheRedisStoreTest.php index 25c3c03e09d1..b51612a4e59a 100755 --- a/tests/Cache/CacheRedisStoreTest.php +++ b/tests/Cache/CacheRedisStoreTest.php @@ -4,8 +4,12 @@ use Illuminate\Cache\RedisStore; use Illuminate\Contracts\Redis\Factory; +use Illuminate\Redis\Connections\PhpRedisClusterConnection; +use Illuminate\Redis\Connections\PredisConnection; use Mockery as m; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; use PHPUnit\Framework\TestCase; +use RuntimeException; class CacheRedisStoreTest extends TestCase { @@ -146,6 +150,70 @@ public function testFlushesCached() $this->assertTrue($result); } + public function testFlushesCachedByPrefix() + { + $redis = $this->getRedis(); + $redis->getRedis()->shouldReceive('connection')->once()->with('default')->andReturn($redis->getRedis()); + $redis->getRedis()->shouldReceive('scan')->once()->with('0', ['match' => 'prefix:*', 'count' => 1000])->andReturn(['17', ['prefix:foo', 'prefix:bar']]); + $redis->getRedis()->shouldReceive('del')->once()->with('prefix:foo', 'prefix:bar'); + $redis->getRedis()->shouldReceive('scan')->once()->with('17', ['match' => 'prefix:*', 'count' => 1000])->andReturn(['0', ['prefix:baz']]); + $redis->getRedis()->shouldReceive('del')->once()->with('prefix:baz'); + + $result = $redis->flushPrefix(); + + $this->assertTrue($result); + } + + public function testFlushesCachedByPrefixWithRedisConnectionPrefix() + { + $factory = m::mock(Factory::class); + $connection = new CacheRedisStorePredisConnectionStub(new CacheRedisStorePredisClientStub('redis:')); + + $factory->shouldReceive('connection')->once()->with('default')->andReturn($connection); + + $result = (new RedisStore($factory, 'prefix:'))->flushPrefix(); + + $this->assertTrue($result); + $this->assertSame([ + ['0', ['match' => 'redis:prefix:*', 'count' => 1000]], + ], $connection->scans); + $this->assertSame([ + ['prefix:foo', 'prefix:bar'], + ], $connection->deletions); + } + + public function testFlushPrefixRequiresCachePrefix() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Flushing Redis cache by prefix is only supported when a cache prefix is configured.'); + + (new RedisStore(m::mock(Factory::class), ''))->flushPrefix(); + } + + #[RequiresPhpExtension('redis')] + public function testFlushesCachedByPrefixAcrossPhpRedisClusterMasters() + { + $client = m::mock(\RedisCluster::class); + $factory = m::mock(Factory::class); + $connection = new PhpRedisClusterConnection($client); + + $client->shouldReceive('getOption')->once()->with(\Redis::OPT_PREFIX)->andReturn(''); + $client->shouldReceive('_masters')->once()->andReturn([ + ['127.0.0.1', '6379'], + ['127.0.0.2', '6379'], + ]); + $client->shouldReceive('scan')->once()->with(m::any(), ['127.0.0.1', '6379'], 'prefix:*', 1000)->andReturn(['prefix:foo']); + $client->shouldReceive('scan')->once()->with(m::any(), ['127.0.0.2', '6379'], 'prefix:*', 1000)->andReturn(['prefix:bar']); + $client->shouldReceive('del')->once()->with('prefix:foo')->andReturn(1); + $client->shouldReceive('del')->once()->with('prefix:bar')->andReturn(1); + + $factory->shouldReceive('connection')->once()->with('default')->andReturn($connection); + + $result = (new RedisStore($factory, 'prefix:'))->flushPrefix(); + + $this->assertTrue($result); + } + public function testFlushesCachedLocks() { $redis = $this->getRedis(); @@ -171,3 +239,36 @@ protected function getRedis() return new RedisStore(m::mock(Factory::class), 'prefix:'); } } + +class CacheRedisStorePredisConnectionStub extends PredisConnection +{ + public array $deletions = []; + + public array $scans = []; + + public function scan($cursor, $options = []) + { + $this->scans[] = [$cursor, $options]; + + return ['0', ['redis:prefix:foo', 'redis:prefix:bar']]; + } + + public function del(...$keys) + { + $this->deletions[] = $keys; + + return count($keys); + } +} + +class CacheRedisStorePredisClientStub +{ + public function __construct(protected string $prefix) + { + } + + public function getOptions() + { + return (object) ['prefix' => $this->prefix]; + } +} diff --git a/tests/Cache/ClearCommandTest.php b/tests/Cache/ClearCommandTest.php index 540162a3e549..a958aed0d5ea 100644 --- a/tests/Cache/ClearCommandTest.php +++ b/tests/Cache/ClearCommandTest.php @@ -5,6 +5,7 @@ use BadMethodCallException; use Illuminate\Cache\CacheManager; use Illuminate\Cache\Console\ClearCommand; +use Illuminate\Config\Repository as ConfigRepository; use Illuminate\Contracts\Cache\Repository; use Illuminate\Filesystem\Filesystem; use Illuminate\Foundation\Application; @@ -111,6 +112,73 @@ public function testClearWithStoreArgumentAndTagsOption() $this->runCommand($this->command, ['store' => 'redis', '--tags' => 'foo']); } + public function testClearWithPrefixOption() + { + $this->files->shouldReceive('exists')->andReturn(true); + $this->files->shouldReceive('files')->andReturn([]); + + $this->cacheManager->shouldReceive('store')->once()->with(null)->andReturn($this->cacheRepository); + $this->cacheRepository->shouldReceive('flushPrefix')->once()->andReturn(true); + $this->cacheRepository->shouldReceive('flush')->never(); + + $this->assertSame(0, $this->runCommand($this->command, ['--prefix' => true])); + } + + public function testClearWithPrefixOptionAndTagsFails() + { + $this->cacheManager->shouldNotReceive('store'); + $this->cacheRepository->shouldNotReceive('flush'); + $this->cacheRepository->shouldNotReceive('flushPrefix'); + + $this->assertSame(1, $this->runCommand($this->command, ['--prefix' => true, '--tags' => 'foo'])); + } + + public function testClearWithPrefixOptionAndLocksFails() + { + $this->cacheManager->shouldNotReceive('store'); + $this->cacheRepository->shouldNotReceive('flush'); + $this->cacheRepository->shouldNotReceive('flushPrefix'); + $this->cacheRepository->shouldNotReceive('flushLocks'); + + $this->assertSame(1, $this->runCommand($this->command, ['--prefix' => true, '--locks' => true])); + } + + public function testClearWithPrefixOptionWillFailWhenNotSupportedByStore() + { + $this->cacheManager->shouldReceive('store')->once()->with(null)->andReturn($this->cacheRepository); + $this->cacheRepository->shouldReceive('flushPrefix')->once()->andThrow(new BadMethodCallException); + $this->cacheRepository->shouldReceive('flush')->never(); + + $this->files->shouldNotReceive('exists'); + $this->files->shouldNotReceive('files'); + $this->files->shouldNotReceive('delete'); + + $this->assertSame(1, $this->runCommand($this->command, ['--prefix' => true])); + } + + public function testClearUsesConfiguredPrefixFlushScope() + { + $this->command->getLaravel()->instance('config', new ConfigRepository([ + 'cache' => [ + 'default' => 'redis', + 'stores' => [ + 'redis' => [ + 'flush_scope' => 'prefix', + ], + ], + ], + ])); + + $this->files->shouldReceive('exists')->andReturn(true); + $this->files->shouldReceive('files')->andReturn([]); + + $this->cacheManager->shouldReceive('store')->once()->with(null)->andReturn($this->cacheRepository); + $this->cacheRepository->shouldReceive('flushPrefix')->once()->andReturn(true); + $this->cacheRepository->shouldReceive('flush')->never(); + + $this->assertSame(0, $this->runCommand($this->command)); + } + public function testClearWillClearRealTimeFacades() { $this->cacheManager->shouldReceive('store')->once()->with(null)->andReturn($this->cacheRepository);