Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion config/cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => [
Expand Down Expand Up @@ -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".
|
*/

Expand Down
4 changes: 3 additions & 1 deletion config/database.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
|
*/

Expand Down
45 changes: 44 additions & 1 deletion src/Illuminate/Cache/Console/ClearCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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.');

Expand Down Expand Up @@ -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.
*
Expand All @@ -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'],
];
}
Expand Down
156 changes: 155 additions & 1 deletion src/Illuminate/Cache/RedisStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down
35 changes: 35 additions & 0 deletions src/Illuminate/Cache/Repository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down
13 changes: 13 additions & 0 deletions src/Illuminate/Contracts/Cache/CanFlushPrefix.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Illuminate\Contracts\Cache;

interface CanFlushPrefix
{
/**
* Flush all cache entries managed by the store's configured prefix.
*
* @return bool
*/
public function flushPrefix(): bool;
}
2 changes: 2 additions & 0 deletions src/Illuminate/Support/Facades/Cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,12 @@
* @method static bool deleteMultiple(iterable $keys)
* @method static bool clear()
* @method static bool flushLocks()
* @method static bool flushPrefix()
* @method static \Illuminate\Cache\TaggedCache tags(mixed $names)
* @method static string|null getName()
* @method static bool supportsTags()
* @method static bool supportsFlushingLocks()
* @method static bool supportsFlushingPrefix()
* @method static int|null getDefaultCacheTime()
* @method static \Illuminate\Cache\Repository setDefaultCacheTime(int|null $seconds)
* @method static \Illuminate\Contracts\Cache\Store getStore()
Expand Down
Loading
Loading