From 81a8cebf0ce4f78f07e1bf20dd58e47f50740f2d Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Wed, 19 Feb 2025 15:04:36 +0100 Subject: [PATCH] [Cache] Enable namespace-based invalidation by prefixing keys with backend-native namespace separators --- composer.json | 4 +- .../FrameworkExtension.php | 5 ++ .../Resources/views/Collector/cache.html.twig | 2 +- .../Cache/Adapter/AbstractAdapter.php | 23 +++++-- .../Cache/Adapter/AbstractTagAwareAdapter.php | 38 ++++++++--- .../Component/Cache/Adapter/ArrayAdapter.php | 41 ++++++++++-- .../Component/Cache/Adapter/ChainAdapter.php | 21 +++++- .../Cache/Adapter/DoctrineDbalAdapter.php | 6 +- .../Component/Cache/Adapter/NullAdapter.php | 8 ++- .../Component/Cache/Adapter/PdoAdapter.php | 6 +- .../Component/Cache/Adapter/ProxyAdapter.php | 30 +++++++-- .../Cache/Adapter/RedisTagAwareAdapter.php | 2 +- .../Cache/Adapter/TagAwareAdapter.php | 21 +++++- .../Cache/Adapter/TraceableAdapter.php | 29 ++++++++- src/Symfony/Component/Cache/CHANGELOG.md | 1 + .../Exception/BadMethodCallException.php | 25 ++++++++ .../Cache/Tests/Adapter/AdapterTestCase.php | 28 ++++++++ .../Tests/Adapter/PhpArrayAdapterTest.php | 2 + .../PhpArrayAdapterWithFallbackTest.php | 2 + .../Cache/Tests/Adapter/TagAwareTestTrait.php | 23 +++++++ .../Cache/Tests/Psr16CacheProxyTest.php | 4 +- .../Cache/Traits/AbstractAdapterTrait.php | 64 +++++++++++++------ src/Symfony/Component/Cache/composer.json | 2 +- src/Symfony/Contracts/CHANGELOG.md | 1 + .../Cache/NamespacedPoolInterface.php | 31 +++++++++ 25 files changed, 357 insertions(+), 62 deletions(-) create mode 100644 src/Symfony/Component/Cache/Exception/BadMethodCallException.php create mode 100644 src/Symfony/Contracts/Cache/NamespacedPoolInterface.php diff --git a/composer.json b/composer.json index ed9bda8e66125..ee17718abd750 100644 --- a/composer.json +++ b/composer.json @@ -47,7 +47,7 @@ "psr/http-message": "^1.0|^2.0", "psr/link": "^1.1|^2.0", "psr/log": "^1|^2|^3", - "symfony/contracts": "^3.5", + "symfony/contracts": "^3.6", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-intl-grapheme": "~1.0", "symfony/polyfill-intl-icu": "~1.0", @@ -218,7 +218,7 @@ "url": "src/Symfony/Contracts", "options": { "versions": { - "symfony/contracts": "3.5.x-dev" + "symfony/contracts": "3.6.x-dev" } } }, diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index e2d0888dbd7e6..353fd19428363 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -208,6 +208,7 @@ use Symfony\Component\Yaml\Yaml; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CallbackInterface; +use Symfony\Contracts\Cache\NamespacedPoolInterface; use Symfony\Contracts\Cache\TagAwareCacheInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -2576,6 +2577,10 @@ private function registerCacheConfiguration(array $config, ContainerBuilder $con $container->registerAliasForArgument($tagAwareId, TagAwareCacheInterface::class, $pool['name'] ?? $name); $container->registerAliasForArgument($name, CacheInterface::class, $pool['name'] ?? $name); $container->registerAliasForArgument($name, CacheItemPoolInterface::class, $pool['name'] ?? $name); + + if (interface_exists(NamespacedPoolInterface::class)) { + $container->registerAliasForArgument($name, NamespacedPoolInterface::class, $pool['name'] ?? $name); + } } $definition->setPublic($pool['public']); diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/cache.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/cache.html.twig index d0bc96868e8e6..217ad78f2b412 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/cache.html.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/cache.html.twig @@ -93,7 +93,7 @@ {{ loop.index }} {{ '%0.2f'|format((call.end - call.start) * 1000) }} ms - {{ call.name }}() + {{ call.name }}({{ call.namespace|default('') }}) {{ profiler_dump(call.value.result, maxDepth=2) }} {% endfor %} diff --git a/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php b/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php index 8cd2218dc06c1..2b4bc8b22440f 100644 --- a/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php @@ -19,11 +19,12 @@ use Symfony\Component\Cache\Traits\AbstractAdapterTrait; use Symfony\Component\Cache\Traits\ContractsTrait; use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\NamespacedPoolInterface; /** * @author Nicolas Grekas */ -abstract class AbstractAdapter implements AdapterInterface, CacheInterface, LoggerAwareInterface, ResettableInterface +abstract class AbstractAdapter implements AdapterInterface, CacheInterface, NamespacedPoolInterface, LoggerAwareInterface, ResettableInterface { use AbstractAdapterTrait; use ContractsTrait; @@ -37,7 +38,19 @@ abstract class AbstractAdapter implements AdapterInterface, CacheInterface, Logg protected function __construct(string $namespace = '', int $defaultLifetime = 0) { - $this->namespace = '' === $namespace ? '' : CacheItem::validateKey($namespace).static::NS_SEPARATOR; + if ('' !== $namespace) { + if (str_contains($namespace, static::NS_SEPARATOR)) { + if (str_contains($namespace, static::NS_SEPARATOR.static::NS_SEPARATOR)) { + throw new InvalidArgumentException(\sprintf('Cache namespace "%s" contains empty sub-namespace.', $namespace)); + } + CacheItem::validateKey(str_replace(static::NS_SEPARATOR, '', $namespace)); + } else { + CacheItem::validateKey($namespace); + } + $this->namespace = $namespace.static::NS_SEPARATOR; + } + $this->rootNamespace = $this->namespace; + $this->defaultLifetime = $defaultLifetime; if (null !== $this->maxIdLength && \strlen($namespace) > $this->maxIdLength - 24) { throw new InvalidArgumentException(\sprintf('Namespace must be %d chars max, %d given ("%s").', $this->maxIdLength - 24, \strlen($namespace), $namespace)); @@ -118,7 +131,7 @@ public static function createConnection(#[\SensitiveParameter] string $dsn, arra return MemcachedAdapter::createConnection($dsn, $options); } if (str_starts_with($dsn, 'couchbase:')) { - if (class_exists('CouchbaseBucket') && CouchbaseBucketAdapter::isSupported()) { + if (class_exists(\CouchbaseBucket::class) && CouchbaseBucketAdapter::isSupported()) { return CouchbaseBucketAdapter::createConnection($dsn, $options); } @@ -159,7 +172,7 @@ public function commit(): bool $v = $values[$id]; $type = get_debug_type($v); $message = \sprintf('Failed to save key "{key}" of type %s%s', $type, $e instanceof \Exception ? ': '.$e->getMessage() : '.'); - CacheItem::log($this->logger, $message, ['key' => substr($id, \strlen($this->namespace)), 'exception' => $e instanceof \Exception ? $e : null, 'cache-adapter' => get_debug_type($this)]); + CacheItem::log($this->logger, $message, ['key' => substr($id, \strlen($this->rootNamespace)), 'exception' => $e instanceof \Exception ? $e : null, 'cache-adapter' => get_debug_type($this)]); } } else { foreach ($values as $id => $v) { @@ -182,7 +195,7 @@ public function commit(): bool $ok = false; $type = get_debug_type($v); $message = \sprintf('Failed to save key "{key}" of type %s%s', $type, $e instanceof \Exception ? ': '.$e->getMessage() : '.'); - CacheItem::log($this->logger, $message, ['key' => substr($id, \strlen($this->namespace)), 'exception' => $e instanceof \Exception ? $e : null, 'cache-adapter' => get_debug_type($this)]); + CacheItem::log($this->logger, $message, ['key' => substr($id, \strlen($this->rootNamespace)), 'exception' => $e instanceof \Exception ? $e : null, 'cache-adapter' => get_debug_type($this)]); } } diff --git a/src/Symfony/Component/Cache/Adapter/AbstractTagAwareAdapter.php b/src/Symfony/Component/Cache/Adapter/AbstractTagAwareAdapter.php index 822c30f09bdbd..23db2b6eb8c8a 100644 --- a/src/Symfony/Component/Cache/Adapter/AbstractTagAwareAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/AbstractTagAwareAdapter.php @@ -17,6 +17,7 @@ use Symfony\Component\Cache\ResettableInterface; use Symfony\Component\Cache\Traits\AbstractAdapterTrait; use Symfony\Component\Cache\Traits\ContractsTrait; +use Symfony\Contracts\Cache\NamespacedPoolInterface; use Symfony\Contracts\Cache\TagAwareCacheInterface; /** @@ -30,16 +31,33 @@ * * @internal */ -abstract class AbstractTagAwareAdapter implements TagAwareAdapterInterface, TagAwareCacheInterface, LoggerAwareInterface, ResettableInterface +abstract class AbstractTagAwareAdapter implements TagAwareAdapterInterface, TagAwareCacheInterface, AdapterInterface, NamespacedPoolInterface, LoggerAwareInterface, ResettableInterface { use AbstractAdapterTrait; use ContractsTrait; + /** + * @internal + */ + protected const NS_SEPARATOR = ':'; + private const TAGS_PREFIX = "\1tags\1"; protected function __construct(string $namespace = '', int $defaultLifetime = 0) { - $this->namespace = '' === $namespace ? '' : CacheItem::validateKey($namespace).':'; + if ('' !== $namespace) { + if (str_contains($namespace, static::NS_SEPARATOR)) { + if (str_contains($namespace, static::NS_SEPARATOR.static::NS_SEPARATOR)) { + throw new InvalidArgumentException(\sprintf('Cache namespace "%s" contains empty sub-namespace.', $namespace)); + } + CacheItem::validateKey(str_replace(static::NS_SEPARATOR, '', $namespace)); + } else { + CacheItem::validateKey($namespace); + } + $this->namespace = $namespace.static::NS_SEPARATOR; + } + $this->rootNamespace = $this->namespace; + $this->defaultLifetime = $defaultLifetime; if (null !== $this->maxIdLength && \strlen($namespace) > $this->maxIdLength - 24) { throw new InvalidArgumentException(\sprintf('Namespace must be %d chars max, %d given ("%s").', $this->maxIdLength - 24, \strlen($namespace), $namespace)); @@ -70,7 +88,7 @@ static function ($key, $value, $isHit) { CacheItem::class ); self::$mergeByLifetime ??= \Closure::bind( - static function ($deferred, &$expiredIds, $getId, $tagPrefix, $defaultLifetime) { + static function ($deferred, &$expiredIds, $getId, $tagPrefix, $defaultLifetime, $rootNamespace) { $byLifetime = []; $now = microtime(true); $expiredIds = []; @@ -102,10 +120,10 @@ static function ($deferred, &$expiredIds, $getId, $tagPrefix, $defaultLifetime) $value['tag-operations'] = ['add' => [], 'remove' => []]; $oldTags = $item->metadata[CacheItem::METADATA_TAGS] ?? []; foreach (array_diff_key($value['tags'], $oldTags) as $addedTag) { - $value['tag-operations']['add'][] = $getId($tagPrefix.$addedTag); + $value['tag-operations']['add'][] = $getId($tagPrefix.$addedTag, $rootNamespace); } foreach (array_diff_key($oldTags, $value['tags']) as $removedTag) { - $value['tag-operations']['remove'][] = $getId($tagPrefix.$removedTag); + $value['tag-operations']['remove'][] = $getId($tagPrefix.$removedTag, $rootNamespace); } $value['tags'] = array_keys($value['tags']); @@ -168,7 +186,7 @@ protected function doDeleteYieldTags(array $ids): iterable public function commit(): bool { $ok = true; - $byLifetime = (self::$mergeByLifetime)($this->deferred, $expiredIds, $this->getId(...), self::TAGS_PREFIX, $this->defaultLifetime); + $byLifetime = (self::$mergeByLifetime)($this->deferred, $expiredIds, $this->getId(...), self::TAGS_PREFIX, $this->defaultLifetime, $this->rootNamespace); $retry = $this->deferred = []; if ($expiredIds) { @@ -195,7 +213,7 @@ public function commit(): bool $v = $values[$id]; $type = get_debug_type($v); $message = \sprintf('Failed to save key "{key}" of type %s%s', $type, $e instanceof \Exception ? ': '.$e->getMessage() : '.'); - CacheItem::log($this->logger, $message, ['key' => substr($id, \strlen($this->namespace)), 'exception' => $e instanceof \Exception ? $e : null, 'cache-adapter' => get_debug_type($this)]); + CacheItem::log($this->logger, $message, ['key' => substr($id, \strlen($this->rootNamespace)), 'exception' => $e instanceof \Exception ? $e : null, 'cache-adapter' => get_debug_type($this)]); } } else { foreach ($values as $id => $v) { @@ -219,7 +237,7 @@ public function commit(): bool $ok = false; $type = get_debug_type($v); $message = \sprintf('Failed to save key "{key}" of type %s%s', $type, $e instanceof \Exception ? ': '.$e->getMessage() : '.'); - CacheItem::log($this->logger, $message, ['key' => substr($id, \strlen($this->namespace)), 'exception' => $e instanceof \Exception ? $e : null, 'cache-adapter' => get_debug_type($this)]); + CacheItem::log($this->logger, $message, ['key' => substr($id, \strlen($this->rootNamespace)), 'exception' => $e instanceof \Exception ? $e : null, 'cache-adapter' => get_debug_type($this)]); } } @@ -244,7 +262,7 @@ public function deleteItems(array $keys): bool try { foreach ($this->doDeleteYieldTags(array_values($ids)) as $id => $tags) { foreach ($tags as $tag) { - $tagData[$this->getId(self::TAGS_PREFIX.$tag)][] = $id; + $tagData[$this->getId(self::TAGS_PREFIX.$tag, $this->rootNamespace)][] = $id; } } } catch (\Exception) { @@ -283,7 +301,7 @@ public function invalidateTags(array $tags): bool $tagIds = []; foreach (array_unique($tags) as $tag) { - $tagIds[] = $this->getId(self::TAGS_PREFIX.$tag); + $tagIds[] = $this->getId(self::TAGS_PREFIX.$tag, $this->rootNamespace); } try { diff --git a/src/Symfony/Component/Cache/Adapter/ArrayAdapter.php b/src/Symfony/Component/Cache/Adapter/ArrayAdapter.php index 7b92387742894..7deb9dc6e2187 100644 --- a/src/Symfony/Component/Cache/Adapter/ArrayAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/ArrayAdapter.php @@ -19,6 +19,7 @@ use Symfony\Component\Cache\Exception\InvalidArgumentException; use Symfony\Component\Cache\ResettableInterface; use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\NamespacedPoolInterface; /** * An in-memory cache storage. @@ -27,13 +28,14 @@ * * @author Nicolas Grekas */ -class ArrayAdapter implements AdapterInterface, CacheInterface, LoggerAwareInterface, ResettableInterface +class ArrayAdapter implements AdapterInterface, CacheInterface, NamespacedPoolInterface, LoggerAwareInterface, ResettableInterface { use LoggerAwareTrait; private array $values = []; private array $tags = []; private array $expiries = []; + private array $subPools = []; private static \Closure $createCacheItem; @@ -226,16 +228,38 @@ public function clear(string $prefix = ''): bool } } - if ($this->values) { - return true; - } + return true; + } + + foreach ($this->subPools as $pool) { + $pool->clear(); } - $this->values = $this->tags = $this->expiries = []; + $this->subPools = $this->values = $this->tags = $this->expiries = []; return true; } + public function withSubNamespace(string $namespace): static + { + CacheItem::validateKey($namespace); + + $subPools = $this->subPools; + + if (isset($subPools[$namespace])) { + return $subPools[$namespace]; + } + + $this->subPools = []; + $clone = clone $this; + $clone->clear(); + + $subPools[$namespace] = $clone; + $this->subPools = $subPools; + + return $clone; + } + /** * Returns all cached values, with cache miss as null. */ @@ -263,6 +287,13 @@ public function reset(): void $this->clear(); } + public function __clone() + { + foreach ($this->subPools as $i => $pool) { + $this->subPools[$i] = clone $pool; + } + } + private function generateItems(array $keys, float $now, \Closure $f): \Generator { foreach ($keys as $i => $key) { diff --git a/src/Symfony/Component/Cache/Adapter/ChainAdapter.php b/src/Symfony/Component/Cache/Adapter/ChainAdapter.php index 09fcfdcc07b88..c27faeb111617 100644 --- a/src/Symfony/Component/Cache/Adapter/ChainAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/ChainAdapter.php @@ -14,11 +14,13 @@ use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemPoolInterface; use Symfony\Component\Cache\CacheItem; +use Symfony\Component\Cache\Exception\BadMethodCallException; use Symfony\Component\Cache\Exception\InvalidArgumentException; use Symfony\Component\Cache\PruneableInterface; use Symfony\Component\Cache\ResettableInterface; use Symfony\Component\Cache\Traits\ContractsTrait; use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\NamespacedPoolInterface; use Symfony\Contracts\Service\ResetInterface; /** @@ -29,7 +31,7 @@ * * @author Kévin Dunglas */ -class ChainAdapter implements AdapterInterface, CacheInterface, PruneableInterface, ResettableInterface +class ChainAdapter implements AdapterInterface, CacheInterface, NamespacedPoolInterface, PruneableInterface, ResettableInterface { use ContractsTrait; @@ -280,6 +282,23 @@ public function prune(): bool return $pruned; } + public function withSubNamespace(string $namespace): static + { + $clone = clone $this; + $adapters = []; + + foreach ($this->adapters as $adapter) { + if (!$adapter instanceof NamespacedPoolInterface) { + throw new BadMethodCallException('All adapters must implement NamespacedPoolInterface to support namespaces.'); + } + + $adapters[] = $adapter->withSubNamespace($namespace); + } + $clone->adapters = $adapters; + + return $clone; + } + public function reset(): void { foreach ($this->adapters as $adapter) { diff --git a/src/Symfony/Component/Cache/Adapter/DoctrineDbalAdapter.php b/src/Symfony/Component/Cache/Adapter/DoctrineDbalAdapter.php index c69c777c993e7..7c5379e417355 100644 --- a/src/Symfony/Component/Cache/Adapter/DoctrineDbalAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/DoctrineDbalAdapter.php @@ -335,17 +335,17 @@ protected function doSave(array $values, int $lifetime): array|bool /** * @internal */ - protected function getId(mixed $key): string + protected function getId(mixed $key, ?string $namespace = null): string { if ('pgsql' !== $this->platformName ??= $this->getPlatformName()) { - return parent::getId($key); + return parent::getId($key, $namespace); } if (str_contains($key, "\0") || str_contains($key, '%') || !preg_match('//u', $key)) { $key = rawurlencode($key); } - return parent::getId($key); + return parent::getId($key, $namespace); } private function getPlatformName(): string diff --git a/src/Symfony/Component/Cache/Adapter/NullAdapter.php b/src/Symfony/Component/Cache/Adapter/NullAdapter.php index d5d2ef6b40d03..35553ea15f89a 100644 --- a/src/Symfony/Component/Cache/Adapter/NullAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/NullAdapter.php @@ -14,11 +14,12 @@ use Psr\Cache\CacheItemInterface; use Symfony\Component\Cache\CacheItem; use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\NamespacedPoolInterface; /** * @author Titouan Galopin */ -class NullAdapter implements AdapterInterface, CacheInterface +class NullAdapter implements AdapterInterface, CacheInterface, NamespacedPoolInterface { private static \Closure $createCacheItem; @@ -94,6 +95,11 @@ public function delete(string $key): bool return $this->deleteItem($key); } + public function withSubNamespace(string $namespace): static + { + return clone $this; + } + private function generateItems(array $keys): \Generator { $f = self::$createCacheItem; diff --git a/src/Symfony/Component/Cache/Adapter/PdoAdapter.php b/src/Symfony/Component/Cache/Adapter/PdoAdapter.php index 525e2c6db6020..7d6cb2dfcb6d1 100644 --- a/src/Symfony/Component/Cache/Adapter/PdoAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/PdoAdapter.php @@ -348,17 +348,17 @@ protected function doSave(array $values, int $lifetime): array|bool /** * @internal */ - protected function getId(mixed $key): string + protected function getId(mixed $key, ?string $namespace = null): string { if ('pgsql' !== $this->getDriver()) { - return parent::getId($key); + return parent::getId($key, $namespace); } if (str_contains($key, "\0") || str_contains($key, '%') || !preg_match('//u', $key)) { $key = rawurlencode($key); } - return parent::getId($key); + return parent::getId($key, $namespace); } private function getConnection(): \PDO diff --git a/src/Symfony/Component/Cache/Adapter/ProxyAdapter.php b/src/Symfony/Component/Cache/Adapter/ProxyAdapter.php index 5621226069bb1..d692dbf3d5c15 100644 --- a/src/Symfony/Component/Cache/Adapter/ProxyAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/ProxyAdapter.php @@ -19,11 +19,12 @@ use Symfony\Component\Cache\Traits\ContractsTrait; use Symfony\Component\Cache\Traits\ProxyTrait; use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\NamespacedPoolInterface; /** * @author Nicolas Grekas */ -class ProxyAdapter implements AdapterInterface, CacheInterface, PruneableInterface, ResettableInterface +class ProxyAdapter implements AdapterInterface, NamespacedPoolInterface, CacheInterface, PruneableInterface, ResettableInterface { use ContractsTrait; use ProxyTrait; @@ -38,12 +39,17 @@ class ProxyAdapter implements AdapterInterface, CacheInterface, PruneableInterfa public function __construct(CacheItemPoolInterface $pool, string $namespace = '', int $defaultLifetime = 0) { - $this->pool = $pool; - $this->poolHash = spl_object_hash($pool); if ('' !== $namespace) { - \assert('' !== CacheItem::validateKey($namespace)); - $this->namespace = $namespace; + if ($pool instanceof NamespacedPoolInterface) { + $pool = $pool->withSubNamespace($namespace); + $this->namespace = $namespace = ''; + } else { + \assert('' !== CacheItem::validateKey($namespace)); + $this->namespace = $namespace; + } } + $this->pool = $pool; + $this->poolHash = spl_object_hash($pool); $this->namespaceLen = \strlen($namespace); $this->defaultLifetime = $defaultLifetime; self::$createCacheItem ??= \Closure::bind( @@ -158,6 +164,20 @@ public function commit(): bool return $this->pool->commit(); } + public function withSubNamespace(string $namespace): static + { + $clone = clone $this; + + if ($clone->pool instanceof NamespacedPoolInterface) { + $clone->pool = $clone->pool->withSubNamespace($namespace); + } else { + $clone->namespace .= CacheItem::validateKey($namespace); + $clone->namespaceLen = \strlen($clone->namespace); + } + + return $clone; + } + private function doSave(CacheItemInterface $item, string $method): bool { if (!$item instanceof CacheItem) { diff --git a/src/Symfony/Component/Cache/Adapter/RedisTagAwareAdapter.php b/src/Symfony/Component/Cache/Adapter/RedisTagAwareAdapter.php index a887f29abfb0a..779c4d91f855e 100644 --- a/src/Symfony/Component/Cache/Adapter/RedisTagAwareAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/RedisTagAwareAdapter.php @@ -159,7 +159,7 @@ protected function doDeleteYieldTags(array $ids): iterable foreach ($results as $id => $result) { if ($result instanceof \RedisException || $result instanceof \Relay\Exception || $result instanceof ErrorInterface) { - CacheItem::log($this->logger, 'Failed to delete key "{key}": '.$result->getMessage(), ['key' => substr($id, \strlen($this->namespace)), 'exception' => $result]); + CacheItem::log($this->logger, 'Failed to delete key "{key}": '.$result->getMessage(), ['key' => substr($id, \strlen($this->rootNamespace)), 'exception' => $result]); continue; } diff --git a/src/Symfony/Component/Cache/Adapter/TagAwareAdapter.php b/src/Symfony/Component/Cache/Adapter/TagAwareAdapter.php index 53c989047ff63..70927cf4e5b8a 100644 --- a/src/Symfony/Component/Cache/Adapter/TagAwareAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/TagAwareAdapter.php @@ -16,9 +16,11 @@ use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerAwareTrait; use Symfony\Component\Cache\CacheItem; +use Symfony\Component\Cache\Exception\BadMethodCallException; use Symfony\Component\Cache\PruneableInterface; use Symfony\Component\Cache\ResettableInterface; use Symfony\Component\Cache\Traits\ContractsTrait; +use Symfony\Contracts\Cache\NamespacedPoolInterface; use Symfony\Contracts\Cache\TagAwareCacheInterface; /** @@ -33,7 +35,7 @@ * @author Nicolas Grekas * @author Sergey Belyshkin */ -class TagAwareAdapter implements TagAwareAdapterInterface, TagAwareCacheInterface, PruneableInterface, ResettableInterface, LoggerAwareInterface +class TagAwareAdapter implements TagAwareAdapterInterface, TagAwareCacheInterface, NamespacedPoolInterface, PruneableInterface, ResettableInterface, LoggerAwareInterface { use ContractsTrait; use LoggerAwareTrait; @@ -277,6 +279,23 @@ public function commit(): bool return $ok; } + /** + * @throws BadMethodCallException When the item pool is not a NamespacedPoolInterface + */ + public function withSubNamespace(string $namespace): static + { + if (!$this->pool instanceof NamespacedPoolInterface) { + throw new BadMethodCallException(\sprintf('Cannot call "%s::withSubNamespace()": this class doesn\'t implement "%s".', get_debug_type($this->pool), NamespacedPoolInterface::class)); + } + + $knownTagVersions = &$this->knownTagVersions; // ensures clones share the same array + $clone = clone $this; + $clone->deferred = []; + $clone->pool = $this->pool->withSubNamespace($namespace); + + return $clone; + } + public function prune(): bool { return $this->pool instanceof PruneableInterface && $this->pool->prune(); diff --git a/src/Symfony/Component/Cache/Adapter/TraceableAdapter.php b/src/Symfony/Component/Cache/Adapter/TraceableAdapter.php index 8fe6cf3764806..43628e4cedbf0 100644 --- a/src/Symfony/Component/Cache/Adapter/TraceableAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/TraceableAdapter.php @@ -13,9 +13,11 @@ use Psr\Cache\CacheItemInterface; use Symfony\Component\Cache\CacheItem; +use Symfony\Component\Cache\Exception\BadMethodCallException; use Symfony\Component\Cache\PruneableInterface; use Symfony\Component\Cache\ResettableInterface; use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\NamespacedPoolInterface; use Symfony\Contracts\Service\ResetInterface; /** @@ -25,8 +27,9 @@ * @author Tobias Nyholm * @author Nicolas Grekas */ -class TraceableAdapter implements AdapterInterface, CacheInterface, PruneableInterface, ResettableInterface +class TraceableAdapter implements AdapterInterface, CacheInterface, NamespacedPoolInterface, PruneableInterface, ResettableInterface { + private string $namespace = ''; private array $calls = []; public function __construct( @@ -34,10 +37,13 @@ public function __construct( ) { } + /** + * @throws BadMethodCallException When the item pool is not a CacheInterface + */ public function get(string $key, callable $callback, ?float $beta = null, ?array &$metadata = null): mixed { if (!$this->pool instanceof CacheInterface) { - throw new \BadMethodCallException(\sprintf('Cannot call "%s::get()": this class doesn\'t implement "%s".', get_debug_type($this->pool), CacheInterface::class)); + throw new BadMethodCallException(\sprintf('Cannot call "%s::get()": this class doesn\'t implement "%s".', get_debug_type($this->pool), CacheInterface::class)); } $isHit = true; @@ -225,11 +231,29 @@ public function getPool(): AdapterInterface return $this->pool; } + /** + * @throws BadMethodCallException When the item pool is not a NamespacedPoolInterface + */ + public function withSubNamespace(string $namespace): static + { + if (!$this->pool instanceof NamespacedPoolInterface) { + throw new BadMethodCallException(\sprintf('Cannot call "%s::withSubNamespace()": this class doesn\'t implement "%s".', get_debug_type($this->pool), NamespacedPoolInterface::class)); + } + + $calls = &$this->calls; // ensures clones share the same array + $clone = clone $this; + $clone->namespace .= CacheItem::validateKey($namespace).':'; + $clone->pool = $this->pool->withSubNamespace($namespace); + + return $clone; + } + protected function start(string $name): TraceableAdapterEvent { $this->calls[] = $event = new TraceableAdapterEvent(); $event->name = $name; $event->start = microtime(true); + $event->namespace = $this->namespace; return $event; } @@ -246,4 +270,5 @@ class TraceableAdapterEvent public array|bool $result; public int $hits = 0; public int $misses = 0; + public string $namespace; } diff --git a/src/Symfony/Component/Cache/CHANGELOG.md b/src/Symfony/Component/Cache/CHANGELOG.md index 59195f03edbfa..d7b18246802dd 100644 --- a/src/Symfony/Component/Cache/CHANGELOG.md +++ b/src/Symfony/Component/Cache/CHANGELOG.md @@ -6,6 +6,7 @@ CHANGELOG * Add support for `\Relay\Cluster` in `RedisAdapter` * Add support for `valkey:` / `valkeys:` schemes + * Add support for namespace-based invalidation * Rename options "redis_cluster" and "redis_sentinel" to "cluster" and "sentinel" respectively 7.2 diff --git a/src/Symfony/Component/Cache/Exception/BadMethodCallException.php b/src/Symfony/Component/Cache/Exception/BadMethodCallException.php new file mode 100644 index 0000000000000..d81f9d26464a9 --- /dev/null +++ b/src/Symfony/Component/Cache/Exception/BadMethodCallException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Exception; + +use Psr\Cache\CacheException as Psr6CacheInterface; +use Psr\SimpleCache\CacheException as SimpleCacheInterface; + +if (interface_exists(SimpleCacheInterface::class)) { + class BadMethodCallException extends \BadMethodCallException implements Psr6CacheInterface, SimpleCacheInterface + { + } +} else { + class BadMethodCallException extends \BadMethodCallException implements Psr6CacheInterface + { + } +} diff --git a/src/Symfony/Component/Cache/Tests/Adapter/AdapterTestCase.php b/src/Symfony/Component/Cache/Tests/Adapter/AdapterTestCase.php index da430296bcdcf..35eefb501b5a6 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/AdapterTestCase.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/AdapterTestCase.php @@ -18,6 +18,7 @@ use Symfony\Component\Cache\CacheItem; use Symfony\Component\Cache\PruneableInterface; use Symfony\Contracts\Cache\CallbackInterface; +use Symfony\Contracts\Cache\NamespacedPoolInterface; abstract class AdapterTestCase extends CachePoolTest { @@ -350,6 +351,33 @@ public function testNumericKeysWorkAfterMemoryLeakPrevention() $this->assertEquals('value-50', $cache->getItem((string) 50)->get()); } + + public function testNamespaces() + { + if (isset($this->skippedTests[__FUNCTION__])) { + $this->markTestSkipped($this->skippedTests[__FUNCTION__]); + } + + $cache = $this->createCachePool(0, __FUNCTION__); + + $this->assertInstanceOf(NamespacedPoolInterface::class, $cache); + + $derived = $cache->withSubNamespace('derived'); + + $item = $derived->getItem('foo'); + $derived->save($item->set('Foo')); + + $this->assertFalse($cache->getItem('foo')->isHit()); + + $item = $cache->getItem('bar'); + $cache->save($item->set('Bar')); + + $this->assertFalse($derived->getItem('bar')->isHit()); + $this->assertTrue($cache->getItem('bar')->isHit()); + + $derived = $cache->withSubNamespace('derived'); + $this->assertTrue($derived->getItem('foo')->isHit()); + } } class NotUnserializable diff --git a/src/Symfony/Component/Cache/Tests/Adapter/PhpArrayAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/PhpArrayAdapterTest.php index 5bbe4d1d7be13..5bc2bfeb1fedd 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/PhpArrayAdapterTest.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/PhpArrayAdapterTest.php @@ -57,6 +57,8 @@ class PhpArrayAdapterTest extends AdapterTestCase 'testDefaultLifeTime' => 'PhpArrayAdapter does not allow configuring a default lifetime.', 'testPrune' => 'PhpArrayAdapter just proxies', + + 'testNamespaces' => 'PhpArrayAdapter does not support namespaces.', ]; protected static string $file; diff --git a/src/Symfony/Component/Cache/Tests/Adapter/PhpArrayAdapterWithFallbackTest.php b/src/Symfony/Component/Cache/Tests/Adapter/PhpArrayAdapterWithFallbackTest.php index d20ffd554f90a..0f92aee451506 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/PhpArrayAdapterWithFallbackTest.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/PhpArrayAdapterWithFallbackTest.php @@ -28,6 +28,8 @@ class PhpArrayAdapterWithFallbackTest extends AdapterTestCase 'testDeleteItemInvalidKeys' => 'PhpArrayAdapter does not throw exceptions on invalid key.', 'testDeleteItemsInvalidKeys' => 'PhpArrayAdapter does not throw exceptions on invalid key.', 'testPrune' => 'PhpArrayAdapter just proxies', + + 'testNamespaces' => 'PhpArrayAdapter does not support namespaces.', ]; protected static string $file; diff --git a/src/Symfony/Component/Cache/Tests/Adapter/TagAwareTestTrait.php b/src/Symfony/Component/Cache/Tests/Adapter/TagAwareTestTrait.php index 8ec1297ea24e4..9894ba00982db 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/TagAwareTestTrait.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/TagAwareTestTrait.php @@ -183,4 +183,27 @@ public function testRefreshAfterExpires() $cacheItem = $pool->getItem('test'); $this->assertTrue($cacheItem->isHit()); } + + public function testNamespacesAndTags() + { + $pool = $this->createCachePool(); + $pool->clear(); + + $item = $pool->getItem('foo'); + $item->tag('baz'); + $pool->save($item); + + $derived = $pool->withSubNamespace('derived'); + $item = $derived->getItem('bar'); + $item->tag('baz'); + $derived->save($item); + + $this->assertTrue($pool->getItem('foo')->isHit()); + $this->assertTrue($derived->getItem('bar')->isHit()); + + $pool->invalidateTags(['baz']); + + $this->assertFalse($pool->getItem('foo')->isHit()); + $this->assertFalse($derived->getItem('bar')->isHit()); + } } diff --git a/src/Symfony/Component/Cache/Tests/Psr16CacheProxyTest.php b/src/Symfony/Component/Cache/Tests/Psr16CacheProxyTest.php index c3d2d8d59f444..fa771cf92207f 100644 --- a/src/Symfony/Component/Cache/Tests/Psr16CacheProxyTest.php +++ b/src/Symfony/Component/Cache/Tests/Psr16CacheProxyTest.php @@ -45,12 +45,12 @@ public function createSimpleCache(int $defaultLifetime = 0): CacheInterface public function testProxy() { $pool = new ArrayAdapter(); - $cache = new Psr16Cache(new ProxyAdapter($pool, 'my-namespace.')); + $cache = new Psr16Cache(new ProxyAdapter($pool, 'my-namespace')); $this->assertNull($cache->get('some-key')); $this->assertTrue($cache->set('some-other-key', 'value')); - $item = $pool->getItem('my-namespace.some-other-key', 'value'); + $item = $pool->withSubNamespace('my-namespace')->getItem('some-other-key', 'value'); $this->assertTrue($item->isHit()); $this->assertSame('value', $item->get()); } diff --git a/src/Symfony/Component/Cache/Traits/AbstractAdapterTrait.php b/src/Symfony/Component/Cache/Traits/AbstractAdapterTrait.php index 6a716743ffc94..ac8dc97a23c34 100644 --- a/src/Symfony/Component/Cache/Traits/AbstractAdapterTrait.php +++ b/src/Symfony/Component/Cache/Traits/AbstractAdapterTrait.php @@ -35,6 +35,7 @@ trait AbstractAdapterTrait */ private static \Closure $mergeByLifetime; + private readonly string $rootNamespace; private string $namespace = ''; private int $defaultLifetime; private string $namespaceVersion = ''; @@ -106,15 +107,16 @@ public function clear(string $prefix = ''): bool { $this->deferred = []; if ($cleared = $this->versioningIsEnabled) { + $rootNamespace = $this->rootNamespace ??= $this->namespace; if ('' === $namespaceVersionToClear = $this->namespaceVersion) { - foreach ($this->doFetch([static::NS_SEPARATOR.$this->namespace]) as $v) { + foreach ($this->doFetch([static::NS_SEPARATOR.$rootNamespace]) as $v) { $namespaceVersionToClear = $v; } } - $namespaceToClear = $this->namespace.$namespaceVersionToClear; + $namespaceToClear = $rootNamespace.$namespaceVersionToClear; $namespaceVersion = self::formatNamespaceVersion(mt_rand()); try { - $e = $this->doSave([static::NS_SEPARATOR.$this->namespace => $namespaceVersion], 0); + $e = $this->doSave([static::NS_SEPARATOR.$rootNamespace => $namespaceVersion], 0); } catch (\Exception $e) { } if (true !== $e && [] !== $e) { @@ -247,6 +249,16 @@ public function saveDeferred(CacheItemInterface $item): bool return true; } + public function withSubNamespace(string $namespace): static + { + $this->rootNamespace ??= $this->namespace; + + $clone = clone $this; + $clone->namespace .= CacheItem::validateKey($namespace).static::NS_SEPARATOR; + + return $clone; + } + /** * Enables/disables versioning of items. * @@ -318,19 +330,24 @@ private function generateItems(iterable $items, array &$keys): \Generator /** * @internal */ - protected function getId(mixed $key): string + protected function getId(mixed $key, ?string $namespace = null): string { - if ($this->versioningIsEnabled && '' === $this->namespaceVersion) { + $namespace ??= $this->namespace; + + if ('' !== $this->namespaceVersion) { + $namespace .= $this->namespaceVersion; + } elseif ($this->versioningIsEnabled) { + $rootNamespace = $this->rootNamespace ??= $this->namespace; $this->ids = []; $this->namespaceVersion = '1'.static::NS_SEPARATOR; try { - foreach ($this->doFetch([static::NS_SEPARATOR.$this->namespace]) as $v) { + foreach ($this->doFetch([static::NS_SEPARATOR.$rootNamespace]) as $v) { $this->namespaceVersion = $v; } $e = true; if ('1'.static::NS_SEPARATOR === $this->namespaceVersion) { $this->namespaceVersion = self::formatNamespaceVersion(time()); - $e = $this->doSave([static::NS_SEPARATOR.$this->namespace => $this->namespaceVersion], 0); + $e = $this->doSave([static::NS_SEPARATOR.$rootNamespace => $this->namespaceVersion], 0); } } catch (\Exception $e) { } @@ -338,25 +355,34 @@ protected function getId(mixed $key): string $message = 'Failed to save the new namespace'.($e instanceof \Exception ? ': '.$e->getMessage() : '.'); CacheItem::log($this->logger, $message, ['exception' => $e instanceof \Exception ? $e : null, 'cache-adapter' => get_debug_type($this)]); } + + $namespace .= $this->namespaceVersion; } if (\is_string($key) && isset($this->ids[$key])) { - return $this->namespace.$this->namespaceVersion.$this->ids[$key]; - } - \assert('' !== CacheItem::validateKey($key)); - $this->ids[$key] = $key; + $id = $this->ids[$key]; + } else { + \assert('' !== CacheItem::validateKey($key)); + $this->ids[$key] = $key; - if (\count($this->ids) > 1000) { - $this->ids = \array_slice($this->ids, 500, null, true); // stop memory leak if there are many keys - } + if (\count($this->ids) > 1000) { + $this->ids = \array_slice($this->ids, 500, null, true); // stop memory leak if there are many keys + } + + if (null === $this->maxIdLength) { + return $namespace.$key; + } + if (\strlen($id = $namespace.$key) <= $this->maxIdLength) { + return $id; + } - if (null === $this->maxIdLength) { - return $this->namespace.$this->namespaceVersion.$key; - } - if (\strlen($id = $this->namespace.$this->namespaceVersion.$key) > $this->maxIdLength) { // Use xxh128 to favor speed over security, which is not an issue here $this->ids[$key] = $id = substr_replace(base64_encode(hash('xxh128', $key, true)), static::NS_SEPARATOR, -(\strlen($this->namespaceVersion) + 2)); - $id = $this->namespace.$this->namespaceVersion.$id; + } + $id = $namespace.$id; + + if (null !== $this->maxIdLength && \strlen($id) > $this->maxIdLength) { + return base64_encode(hash('xxh128', $id, true)); } return $id; diff --git a/src/Symfony/Component/Cache/composer.json b/src/Symfony/Component/Cache/composer.json index bdb461be8c9e2..c89d667288286 100644 --- a/src/Symfony/Component/Cache/composer.json +++ b/src/Symfony/Component/Cache/composer.json @@ -24,7 +24,7 @@ "php": ">=8.2", "psr/cache": "^2.0|^3.0", "psr/log": "^1.1|^2|^3", - "symfony/cache-contracts": "^2.5|^3", + "symfony/cache-contracts": "^3.6", "symfony/deprecation-contracts": "^2.5|^3.0", "symfony/service-contracts": "^2.5|^3", "symfony/var-exporter": "^6.4|^7.0" diff --git a/src/Symfony/Contracts/CHANGELOG.md b/src/Symfony/Contracts/CHANGELOG.md index ffbd4d2ef81d5..dc9ba968168bc 100644 --- a/src/Symfony/Contracts/CHANGELOG.md +++ b/src/Symfony/Contracts/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG --- * Make `HttpClientTestCase` and `TranslatorTest` compatible with PHPUnit 10+ + * Add `NamespacedPoolInterface` to support namespace-based invalidation 3.5 --- diff --git a/src/Symfony/Contracts/Cache/NamespacedPoolInterface.php b/src/Symfony/Contracts/Cache/NamespacedPoolInterface.php new file mode 100644 index 0000000000000..cd67bc09bc882 --- /dev/null +++ b/src/Symfony/Contracts/Cache/NamespacedPoolInterface.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Contracts\Cache; + +use Psr\Cache\InvalidArgumentException; + +/** + * Enables namespace-based invalidation by prefixing keys with backend-native namespace separators. + * + * Note that calling `withSubNamespace()` MUST NOT mutate the pool, but return a new instance instead. + * + * When tags are used, they MUST ignore sub-namespaces. + * + * @author Nicolas Grekas + */ +interface NamespacedPoolInterface +{ + /** + * @throws InvalidArgumentException If the namespace contains characters found in ItemInterface's RESERVED_CHARACTERS + */ + public function withSubNamespace(string $namespace): static; +}