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 @@
*/ -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 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 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;
+}