diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index f7fe9d3464ac..7313d16d25c7 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -71,9 +71,10 @@
Doctrine\Common\Cache
Symfony\Component\Cache
Symfony\Component\Cache\Tests\Fixtures
- Symfony\Component\Cache\Traits
- Symfony\Component\Console
- Symfony\Component\HttpFoundation
+ Symfony\Component\Cache\Tests\Traits
+ Symfony\Component\Cache\Traits
+ Symfony\Component\Console
+ Symfony\Component\HttpFoundation
diff --git a/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php b/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php
index 011a239bf6ac..e8fc564ddd1a 100644
--- a/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php
+++ b/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php
@@ -11,14 +11,13 @@
namespace Symfony\Component\Cache\Adapter;
-use Psr\Cache\CacheItemInterface;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\CacheItem;
use Symfony\Component\Cache\Exception\InvalidArgumentException;
use Symfony\Component\Cache\ResettableInterface;
-use Symfony\Component\Cache\Traits\AbstractTrait;
+use Symfony\Component\Cache\Traits\AbstractAdapterTrait;
use Symfony\Component\Cache\Traits\ContractsTrait;
use Symfony\Contracts\Cache\CacheInterface;
@@ -27,15 +26,12 @@
*/
abstract class AbstractAdapter implements AdapterInterface, CacheInterface, LoggerAwareInterface, ResettableInterface
{
- use AbstractTrait;
+ use AbstractAdapterTrait;
use ContractsTrait;
private static $apcuSupported;
private static $phpFilesSupported;
- private $createCacheItem;
- private $mergeByLifetime;
-
protected function __construct(string $namespace = '', int $defaultLifetime = 0)
{
$this->namespace = '' === $namespace ? '' : CacheItem::validateKey($namespace).':';
@@ -142,81 +138,6 @@ public static function createConnection($dsn, array $options = [])
throw new InvalidArgumentException(sprintf('Unsupported DSN: %s.', $dsn));
}
- /**
- * {@inheritdoc}
- */
- public function getItem($key)
- {
- if ($this->deferred) {
- $this->commit();
- }
- $id = $this->getId($key);
-
- $f = $this->createCacheItem;
- $isHit = false;
- $value = null;
-
- try {
- foreach ($this->doFetch([$id]) as $value) {
- $isHit = true;
- }
- } catch (\Exception $e) {
- CacheItem::log($this->logger, 'Failed to fetch key "{key}"', ['key' => $key, 'exception' => $e]);
- }
-
- return $f($key, $value, $isHit);
- }
-
- /**
- * {@inheritdoc}
- */
- public function getItems(array $keys = [])
- {
- if ($this->deferred) {
- $this->commit();
- }
- $ids = [];
-
- foreach ($keys as $key) {
- $ids[] = $this->getId($key);
- }
- try {
- $items = $this->doFetch($ids);
- } catch (\Exception $e) {
- CacheItem::log($this->logger, 'Failed to fetch requested items', ['keys' => $keys, 'exception' => $e]);
- $items = [];
- }
- $ids = array_combine($ids, $keys);
-
- return $this->generateItems($items, $ids);
- }
-
- /**
- * {@inheritdoc}
- */
- public function save(CacheItemInterface $item)
- {
- if (!$item instanceof CacheItem) {
- return false;
- }
- $this->deferred[$item->getKey()] = $item;
-
- return $this->commit();
- }
-
- /**
- * {@inheritdoc}
- */
- public function saveDeferred(CacheItemInterface $item)
- {
- if (!$item instanceof CacheItem) {
- return false;
- }
- $this->deferred[$item->getKey()] = $item;
-
- return true;
- }
-
/**
* {@inheritdoc}
*/
@@ -271,33 +192,4 @@ public function commit()
return $ok;
}
-
- public function __destruct()
- {
- if ($this->deferred) {
- $this->commit();
- }
- }
-
- private function generateItems($items, &$keys)
- {
- $f = $this->createCacheItem;
-
- try {
- foreach ($items as $id => $value) {
- if (!isset($keys[$id])) {
- $id = key($keys);
- }
- $key = $keys[$id];
- unset($keys[$id]);
- yield $key => $f($key, $value, true);
- }
- } catch (\Exception $e) {
- CacheItem::log($this->logger, 'Failed to fetch requested items', ['keys' => array_values($keys), 'exception' => $e]);
- }
-
- foreach ($keys as $key) {
- yield $key => $f($key, null, false);
- }
- }
}
diff --git a/src/Symfony/Component/Cache/Adapter/AbstractTagAwareAdapter.php b/src/Symfony/Component/Cache/Adapter/AbstractTagAwareAdapter.php
new file mode 100644
index 000000000000..ddebdf19bbb0
--- /dev/null
+++ b/src/Symfony/Component/Cache/Adapter/AbstractTagAwareAdapter.php
@@ -0,0 +1,302 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Cache\Adapter;
+
+use Psr\Log\LoggerAwareInterface;
+use Symfony\Component\Cache\CacheItem;
+use Symfony\Component\Cache\Exception\InvalidArgumentException;
+use Symfony\Component\Cache\ResettableInterface;
+use Symfony\Component\Cache\Traits\AbstractAdapterTrait;
+use Symfony\Component\Cache\Traits\ContractsTrait;
+use Symfony\Contracts\Cache\TagAwareCacheInterface;
+
+/**
+ * Abstract for native TagAware adapters.
+ *
+ * To keep info on tags, the tags are both serialized as part of cache value and provided as tag ids
+ * to Adapters on operations when needed for storage to doSave(), doDelete() & doInvalidate().
+ *
+ * @author Nicolas Grekas
+ * @author André Rømcke
+ *
+ * @internal
+ * @experimental in 4.3
+ */
+abstract class AbstractTagAwareAdapter implements TagAwareAdapterInterface, TagAwareCacheInterface, LoggerAwareInterface, ResettableInterface
+{
+ use AbstractAdapterTrait;
+ use ContractsTrait;
+
+ private const TAGS_PREFIX = "\0tags\0";
+
+ protected function __construct(string $namespace = '', int $defaultLifetime = 0)
+ {
+ $this->namespace = '' === $namespace ? '' : CacheItem::validateKey($namespace).':';
+ 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));
+ }
+ $this->createCacheItem = \Closure::bind(
+ static function ($key, $value, $isHit) use ($defaultLifetime) {
+ $item = new CacheItem();
+ $item->key = $key;
+ $item->defaultLifetime = $defaultLifetime;
+ $item->isTaggable = true;
+ // If structure does not match what we expect return item as is (no value and not a hit)
+ if (!\is_array($value) || !\array_key_exists('value', $value)) {
+ return $item;
+ }
+ $item->isHit = $isHit;
+ // Extract value, tags and meta data from the cache value
+ $item->value = $value['value'];
+ $item->metadata[CacheItem::METADATA_TAGS] = $value['tags'] ?? [];
+ if (isset($value['meta'])) {
+ // For compactness these values are packed, & expiry is offset to reduce size
+ $v = \unpack('Ve/Nc', $value['meta']);
+ $item->metadata[CacheItem::METADATA_EXPIRY] = $v['e'] + CacheItem::METADATA_EXPIRY_OFFSET;
+ $item->metadata[CacheItem::METADATA_CTIME] = $v['c'];
+ }
+
+ return $item;
+ },
+ null,
+ CacheItem::class
+ );
+ $getId = \Closure::fromCallable([$this, 'getId']);
+ $tagPrefix = self::TAGS_PREFIX;
+ $this->mergeByLifetime = \Closure::bind(
+ static function ($deferred, &$expiredIds) use ($getId, $tagPrefix) {
+ $byLifetime = [];
+ $now = microtime(true);
+ $expiredIds = [];
+
+ foreach ($deferred as $key => $item) {
+ $key = (string) $key;
+ if (null === $item->expiry) {
+ $ttl = 0 < $item->defaultLifetime ? $item->defaultLifetime : 0;
+ } elseif (0 >= $ttl = (int) ($item->expiry - $now)) {
+ $expiredIds[] = $getId($key);
+ continue;
+ }
+ // Store Value and Tags on the cache value
+ if (isset(($metadata = $item->newMetadata)[CacheItem::METADATA_TAGS])) {
+ $value = ['value' => $item->value, 'tags' => $metadata[CacheItem::METADATA_TAGS]];
+ unset($metadata[CacheItem::METADATA_TAGS]);
+ } else {
+ $value = ['value' => $item->value, 'tags' => []];
+ }
+
+ if ($metadata) {
+ // For compactness, expiry and creation duration are packed, using magic numbers as separators
+ $value['meta'] = \pack('VN', (int) $metadata[CacheItem::METADATA_EXPIRY] - CacheItem::METADATA_EXPIRY_OFFSET, $metadata[CacheItem::METADATA_CTIME]);
+ }
+
+ // Extract tag changes, these should be removed from values in doSave()
+ $value['tag-operations'] = ['add' => [], 'remove' => []];
+ $oldTags = $item->metadata[CacheItem::METADATA_TAGS] ?? [];
+ foreach (\array_diff($value['tags'], $oldTags) as $addedTag) {
+ $value['tag-operations']['add'][] = $getId($tagPrefix.$addedTag);
+ }
+ foreach (\array_diff($oldTags, $value['tags']) as $removedTag) {
+ $value['tag-operations']['remove'][] = $getId($tagPrefix.$removedTag);
+ }
+
+ $byLifetime[$ttl][$getId($key)] = $value;
+ }
+
+ return $byLifetime;
+ },
+ null,
+ CacheItem::class
+ );
+ }
+
+ /**
+ * Persists several cache items immediately.
+ *
+ * @param array $values The values to cache, indexed by their cache identifier
+ * @param int $lifetime The lifetime of the cached values, 0 for persisting until manual cleaning
+ * @param array[] $addTagData Hash where key is tag id, and array value is list of cache id's to add to tag
+ * @param array[] $removeTagData Hash where key is tag id, and array value is list of cache id's to remove to tag
+ *
+ * @return array The identifiers that failed to be cached or a boolean stating if caching succeeded or not
+ */
+ abstract protected function doSave(array $values, ?int $lifetime, array $addTagData = [], array $removeTagData = []): array;
+
+ /**
+ * Removes multiple items from the pool and their corresponding tags.
+ *
+ * @param array $ids An array of identifiers that should be removed from the pool
+ * @param array $tagData Optional array of tag identifiers => key identifiers that should be removed from the pool
+ *
+ * @return bool True if the items were successfully removed, false otherwise
+ */
+ abstract protected function doDelete(array $ids, array $tagData = []): bool;
+
+ /**
+ * Invalidates cached items using tags.
+ *
+ * @param string[] $tagIds An array of tags to invalidate, key is tag and value is tag id
+ *
+ * @return bool True on success
+ */
+ abstract protected function doInvalidate(array $tagIds): bool;
+
+ /**
+ * {@inheritdoc}
+ */
+ public function commit()
+ {
+ $ok = true;
+ $byLifetime = $this->mergeByLifetime;
+ $byLifetime = $byLifetime($this->deferred, $expiredIds);
+ $retry = $this->deferred = [];
+
+ if ($expiredIds) {
+ // Tags are not cleaned up in this case, however that is done on invalidateTags().
+ $this->doDelete($expiredIds);
+ }
+ foreach ($byLifetime as $lifetime => $values) {
+ try {
+ $values = $this->extractTagData($values, $addTagData, $removeTagData);
+ $e = $this->doSave($values, $lifetime, $addTagData, $removeTagData);
+ } catch (\Exception $e) {
+ }
+ if (true === $e || [] === $e) {
+ continue;
+ }
+ if (\is_array($e) || 1 === \count($values)) {
+ foreach (\is_array($e) ? $e : array_keys($values) as $id) {
+ $ok = false;
+ $v = $values[$id];
+ $type = \is_object($v) ? \get_class($v) : \gettype($v);
+ CacheItem::log($this->logger, 'Failed to save key "{key}" ({type})', ['key' => substr($id, \strlen($this->namespace)), 'type' => $type, 'exception' => $e instanceof \Exception ? $e : null]);
+ }
+ } else {
+ foreach ($values as $id => $v) {
+ $retry[$lifetime][] = $id;
+ }
+ }
+ }
+
+ // When bulk-save failed, retry each item individually
+ foreach ($retry as $lifetime => $ids) {
+ foreach ($ids as $id) {
+ try {
+ $v = $byLifetime[$lifetime][$id];
+ $values = $this->extractTagData([$id => $v], $addTagData, $removeTagData);
+ $e = $this->doSave($values, $lifetime, $addTagData, $removeTagData);
+ } catch (\Exception $e) {
+ }
+ if (true === $e || [] === $e) {
+ continue;
+ }
+ $ok = false;
+ $type = \is_object($v) ? \get_class($v) : \gettype($v);
+ CacheItem::log($this->logger, 'Failed to save key "{key}" ({type})', ['key' => substr($id, \strlen($this->namespace)), 'type' => $type, 'exception' => $e instanceof \Exception ? $e : null]);
+ }
+ }
+
+ return $ok;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * Overloaded in order to deal with tags for adjusted doDelete() signature.
+ */
+ public function deleteItems(array $keys)
+ {
+ if (!$keys) {
+ return true;
+ }
+
+ $ids = [];
+ $tagData = [];
+
+ foreach ($keys as $key) {
+ $ids[$key] = $this->getId($key);
+ unset($this->deferred[$key]);
+ }
+
+ foreach ($this->doFetch($ids) as $id => $value) {
+ foreach ($value['tags'] ?? [] as $tag) {
+ $tagData[$this->getId(self::TAGS_PREFIX.$tag)][] = $id;
+ }
+ }
+
+ try {
+ if ($this->doDelete(\array_values($ids), $tagData)) {
+ return true;
+ }
+ } catch (\Exception $e) {
+ }
+
+ $ok = true;
+
+ // When bulk-delete failed, retry each item individually
+ foreach ($ids as $key => $id) {
+ try {
+ $e = null;
+ if ($this->doDelete([$id])) {
+ continue;
+ }
+ } catch (\Exception $e) {
+ }
+ CacheItem::log($this->logger, 'Failed to delete key "{key}"', ['key' => $key, 'exception' => $e]);
+ $ok = false;
+ }
+
+ return $ok;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function invalidateTags(array $tags)
+ {
+ if (empty($tags)) {
+ return false;
+ }
+
+ $tagIds = [];
+ foreach (\array_unique($tags) as $tag) {
+ $tagIds[] = $this->getId(self::TAGS_PREFIX.$tag);
+ }
+
+ if ($this->doInvalidate($tagIds)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Extracts tags operation data from $values set in mergeByLifetime, and returns values without it.
+ */
+ private function extractTagData(array $values, ?array &$addTagData, ?array &$removeTagData): array
+ {
+ $addTagData = $removeTagData = [];
+ foreach ($values as $id => $value) {
+ foreach ($value['tag-operations']['add'] as $tag => $tagId) {
+ $addTagData[$tagId][] = $id;
+ }
+
+ foreach ($value['tag-operations']['remove'] as $tag => $tagId) {
+ $removeTagData[$tagId][] = $id;
+ }
+
+ unset($values[$id]['tag-operations']);
+ }
+
+ return $values;
+ }
+}
diff --git a/src/Symfony/Component/Cache/Adapter/FilesystemTagAwareAdapter.php b/src/Symfony/Component/Cache/Adapter/FilesystemTagAwareAdapter.php
new file mode 100644
index 000000000000..f96c670ae92e
--- /dev/null
+++ b/src/Symfony/Component/Cache/Adapter/FilesystemTagAwareAdapter.php
@@ -0,0 +1,149 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Cache\Adapter;
+
+use Symfony\Component\Cache\Exception\LogicException;
+use Symfony\Component\Cache\Marshaller\DefaultMarshaller;
+use Symfony\Component\Cache\Marshaller\MarshallerInterface;
+use Symfony\Component\Cache\PruneableInterface;
+use Symfony\Component\Cache\Traits\FilesystemTrait;
+use Symfony\Component\Filesystem\Filesystem;
+
+/**
+ * Stores tag id <> cache id relationship as a symlink, and lookup on invalidation calls.
+ *
+ * @author Nicolas Grekas
+ * @author André Rømcke
+ *
+ * @experimental in 4.3
+ */
+class FilesystemTagAwareAdapter extends AbstractTagAwareAdapter implements PruneableInterface
+{
+ use FilesystemTrait {
+ doSave as doSaveCache;
+ doDelete as doDeleteCache;
+ }
+
+ /**
+ * Folder used for tag symlinks.
+ */
+ private const TAG_FOLDER = 'tags';
+
+ /**
+ * @var Filesystem|null
+ */
+ private $fs;
+
+ public function __construct(string $namespace = '', int $defaultLifetime = 0, string $directory = null, MarshallerInterface $marshaller = null)
+ {
+ $this->marshaller = $marshaller ?? new DefaultMarshaller();
+ parent::__construct('', $defaultLifetime);
+ $this->init($namespace, $directory);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function doSave(array $values, ?int $lifetime, array $addTagData = [], array $removeTagData = []): array
+ {
+ $failed = $this->doSaveCache($values, $lifetime);
+
+ $fs = $this->getFilesystem();
+ // Add Tags as symlinks
+ foreach ($addTagData as $tagId => $ids) {
+ $tagFolder = $this->getTagFolder($tagId);
+ foreach ($ids as $id) {
+ if ($failed && \in_array($id, $failed, true)) {
+ continue;
+ }
+
+ $file = $this->getFile($id);
+ $fs->symlink($file, $this->getFile($id, true, $tagFolder));
+ }
+ }
+
+ // Unlink removed Tags
+ $files = [];
+ foreach ($removeTagData as $tagId => $ids) {
+ $tagFolder = $this->getTagFolder($tagId);
+ foreach ($ids as $id) {
+ if ($failed && \in_array($id, $failed, true)) {
+ continue;
+ }
+
+ $files[] = $this->getFile($id, false, $tagFolder);
+ }
+ }
+ $fs->remove($files);
+
+ return $failed;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function doDelete(array $ids, array $tagData = []): bool
+ {
+ $ok = $this->doDeleteCache($ids);
+
+ // Remove tags
+ $files = [];
+ $fs = $this->getFilesystem();
+ foreach ($tagData as $tagId => $idMap) {
+ $tagFolder = $this->getTagFolder($tagId);
+ foreach ($idMap as $id) {
+ $files[] = $this->getFile($id, false, $tagFolder);
+ }
+ }
+ $fs->remove($files);
+
+ return $ok;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function doInvalidate(array $tagIds): bool
+ {
+ foreach ($tagIds as $tagId) {
+ $tagsFolder = $this->getTagFolder($tagId);
+ if (!file_exists($tagsFolder)) {
+ continue;
+ }
+
+ foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($tagsFolder, \FilesystemIterator::SKIP_DOTS)) as $itemLink) {
+ if (!$itemLink->isLink()) {
+ throw new LogicException('Expected a (sym)link when iterating over tag folder, non link found: '.$itemLink);
+ }
+
+ $valueFile = $itemLink->getRealPath();
+ if ($valueFile && \file_exists($valueFile)) {
+ @unlink($valueFile);
+ }
+
+ @unlink((string) $itemLink);
+ }
+ }
+
+ return true;
+ }
+
+ private function getFilesystem(): Filesystem
+ {
+ return $this->fs ?? $this->fs = new Filesystem();
+ }
+
+ private function getTagFolder(string $tagId): string
+ {
+ return $this->getFile($tagId, false, $this->directory.self::TAG_FOLDER.\DIRECTORY_SEPARATOR).\DIRECTORY_SEPARATOR;
+ }
+}
diff --git a/src/Symfony/Component/Cache/Adapter/RedisTagAwareAdapter.php b/src/Symfony/Component/Cache/Adapter/RedisTagAwareAdapter.php
new file mode 100644
index 000000000000..d4ee186789a3
--- /dev/null
+++ b/src/Symfony/Component/Cache/Adapter/RedisTagAwareAdapter.php
@@ -0,0 +1,209 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Cache\Adapter;
+
+use Predis;
+use Predis\Connection\Aggregate\ClusterInterface;
+use Predis\Response\Status;
+use Symfony\Component\Cache\CacheItem;
+use Symfony\Component\Cache\Exception\LogicException;
+use Symfony\Component\Cache\Marshaller\MarshallerInterface;
+use Symfony\Component\Cache\Traits\RedisTrait;
+
+/**
+ * Stores tag id <> cache id relationship as a Redis Set, lookup on invalidation using sPOP.
+ *
+ * Set (tag relation info) is stored without expiry (non-volatile), while cache always gets an expiry (volatile) even
+ * if not set by caller. Thus if you configure redis with the right eviction policy you can be safe this tag <> cache
+ * relationship survives eviction (cache cleanup when Redis runs out of memory).
+ *
+ * Requirements:
+ * - Server: Redis 3.2+
+ * - Client: PHP Redis 3.1.3+ OR Predis
+ * - Redis Server(s) configured with any `volatile-*` eviction policy, OR `noeviction` if it will NEVER fill up memory
+ *
+ * Design limitations:
+ * - Max 2 billion cache keys per cache tag
+ * E.g. If you use a "all" items tag for expiry instead of clear(), that limits you to 2 billion cache items as well
+ *
+ * @see https://redis.io/topics/lru-cache#eviction-policies Documentation for Redis eviction policies.
+ * @see https://redis.io/topics/data-types#sets Documentation for Redis Set datatype.
+ * @see https://redis.io/commands/spop Documentation for sPOP operation, capable of retriving AND emptying a Set at once.
+ *
+ * @author Nicolas Grekas
+ * @author André Rømcke
+ *
+ * @experimental in 4.3
+ */
+class RedisTagAwareAdapter extends AbstractTagAwareAdapter
+{
+ use RedisTrait;
+
+ /**
+ * Redis "Set" can hold more than 4 billion members, here we limit ourselves to PHP's > 2 billion max int (32Bit).
+ */
+ private const POP_MAX_LIMIT = 2147483647 - 1;
+
+ /**
+ * Limits for how many keys are deleted in batch.
+ */
+ private const BULK_DELETE_LIMIT = 10000;
+
+ /**
+ * On cache items without a lifetime set, we set it to 100 days. This is to make sure cache items are
+ * preferred to be evicted over tag Sets, if eviction policy is configured according to requirements.
+ */
+ private const DEFAULT_CACHE_TTL = 8640000;
+
+ /**
+ * @var bool|null
+ */
+ private $redisServerSupportSPOP = null;
+
+ /**
+ * @param \Redis|\RedisArray|\RedisCluster|\Predis\Client $redisClient The redis client
+ * @param string $namespace The default namespace
+ * @param int $defaultLifetime The default lifetime
+ * @param MarshallerInterface|null $marshaller
+ *
+ * @throws \Symfony\Component\Cache\Exception\LogicException If phpredis with version lower than 3.1.3.
+ */
+ public function __construct($redisClient, string $namespace = '', int $defaultLifetime = 0, MarshallerInterface $marshaller = null)
+ {
+ $this->init($redisClient, $namespace, $defaultLifetime, $marshaller);
+
+ // Make sure php-redis is 3.1.3 or higher configured for Redis classes
+ if (!$this->redis instanceof Predis\Client && \version_compare(\phpversion('redis'), '3.1.3', '<')) {
+ throw new LogicException('RedisTagAwareAdapter requires php-redis 3.1.3 or higher, alternatively use predis/predis');
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function doSave(array $values, ?int $lifetime, array $addTagData = [], array $delTagData = []): array
+ {
+ // serialize values
+ if (!$serialized = $this->marshaller->marshall($values, $failed)) {
+ return $failed;
+ }
+
+ // While pipeline isn't supported on RedisCluster, other setups will at least benefit from doing this in one op
+ $results = $this->pipeline(static function () use ($serialized, $lifetime, $addTagData, $delTagData) {
+ // Store cache items, force a ttl if none is set, as there is no MSETEX we need to set each one
+ foreach ($serialized as $id => $value) {
+ yield 'setEx' => [
+ $id,
+ 0 >= $lifetime ? self::DEFAULT_CACHE_TTL : $lifetime,
+ $value,
+ ];
+ }
+
+ // Add and Remove Tags
+ foreach ($addTagData as $tagId => $ids) {
+ yield 'sAdd' => array_merge([$tagId], $ids);
+ }
+
+ foreach ($delTagData as $tagId => $ids) {
+ yield 'sRem' => array_merge([$tagId], $ids);
+ }
+ });
+
+ foreach ($results as $id => $result) {
+ // Skip results of SADD/SREM operations, they'll be 1 or 0 depending on if set value already existed or not
+ if (\is_numeric($result)) {
+ continue;
+ }
+ // setEx results
+ if (true !== $result && (!$result instanceof Status || $result !== Status::get('OK'))) {
+ $failed[] = $id;
+ }
+ }
+
+ return $failed;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function doDelete(array $ids, array $tagData = []): bool
+ {
+ if (!$ids) {
+ return true;
+ }
+
+ $predisCluster = $this->redis instanceof \Predis\Client && $this->redis->getConnection() instanceof ClusterInterface;
+ $this->pipeline(static function () use ($ids, $tagData, $predisCluster) {
+ if ($predisCluster) {
+ foreach ($ids as $id) {
+ yield 'del' => [$id];
+ }
+ } else {
+ yield 'del' => $ids;
+ }
+
+ foreach ($tagData as $tagId => $idList) {
+ yield 'sRem' => \array_merge([$tagId], $idList);
+ }
+ })->rewind();
+
+ return true;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function doInvalidate(array $tagIds): bool
+ {
+ if (!$this->redisServerSupportSPOP()) {
+ return false;
+ }
+
+ // Pop all tag info at once to avoid race conditions
+ $tagIdSets = $this->pipeline(static function () use ($tagIds) {
+ foreach ($tagIds as $tagId) {
+ // Client: Predis or PHP Redis 3.1.3+ (https://github.com/phpredis/phpredis/commit/d2e203a6)
+ // Server: Redis 3.2 or higher (https://redis.io/commands/spop)
+ yield 'sPop' => [$tagId, self::POP_MAX_LIMIT];
+ }
+ });
+
+ // Flatten generator result from pipeline, ignore keys (tag ids)
+ $ids = \array_unique(\array_merge(...\iterator_to_array($tagIdSets, false)));
+
+ // Delete cache in chunks to avoid overloading the connection
+ foreach (\array_chunk($ids, self::BULK_DELETE_LIMIT) as $chunkIds) {
+ $this->doDelete($chunkIds);
+ }
+
+ return true;
+ }
+
+ private function redisServerSupportSPOP(): bool
+ {
+ if (null !== $this->redisServerSupportSPOP) {
+ return $this->redisServerSupportSPOP;
+ }
+
+ foreach ($this->getHosts() as $host) {
+ $info = $host->info('Server');
+ $info = isset($info['Server']) ? $info['Server'] : $info;
+ if (version_compare($info['redis_version'], '3.2', '<')) {
+ CacheItem::log($this->logger, 'Redis server needs to be version 3.2 or higher, your Redis server was detected as {version}', ['version' => $info['redis_version']]);
+
+ return $this->redisServerSupportSPOP = false;
+ }
+ }
+
+ return $this->redisServerSupportSPOP = true;
+ }
+}
diff --git a/src/Symfony/Component/Cache/DependencyInjection/CachePoolPass.php b/src/Symfony/Component/Cache/DependencyInjection/CachePoolPass.php
index 1c69e10c942a..5d7a2369c22e 100644
--- a/src/Symfony/Component/Cache/DependencyInjection/CachePoolPass.php
+++ b/src/Symfony/Component/Cache/DependencyInjection/CachePoolPass.php
@@ -68,14 +68,20 @@ public function process(ContainerBuilder $container)
if ($pool->isAbstract()) {
continue;
}
+ $class = $adapter->getClass();
while ($adapter instanceof ChildDefinition) {
$adapter = $container->findDefinition($adapter->getParent());
+ $class = $class ?: $adapter->getClass();
if ($t = $adapter->getTag($this->cachePoolTag)) {
$tags[0] += $t[0];
}
}
$name = $tags[0]['name'] ?? $id;
if (!isset($tags[0]['namespace'])) {
+ if (null !== $class) {
+ $seed .= '.'.$class;
+ }
+
$tags[0]['namespace'] = $this->getNamespace($seed, $name);
}
if (isset($tags[0]['clearer'])) {
diff --git a/src/Symfony/Component/Cache/LockRegistry.php b/src/Symfony/Component/Cache/LockRegistry.php
index eee7948aab0b..676fba5dca3f 100644
--- a/src/Symfony/Component/Cache/LockRegistry.php
+++ b/src/Symfony/Component/Cache/LockRegistry.php
@@ -34,12 +34,14 @@ final class LockRegistry
*/
private static $files = [
__DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'AbstractAdapter.php',
+ __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'AbstractTagAwareAdapter.php',
__DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'AdapterInterface.php',
__DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'ApcuAdapter.php',
__DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'ArrayAdapter.php',
__DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'ChainAdapter.php',
__DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'DoctrineAdapter.php',
__DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'FilesystemAdapter.php',
+ __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'FilesystemTagAwareAdapter.php',
__DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'MemcachedAdapter.php',
__DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'NullAdapter.php',
__DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'PdoAdapter.php',
@@ -48,6 +50,7 @@ final class LockRegistry
__DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'ProxyAdapter.php',
__DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'Psr16Adapter.php',
__DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'RedisAdapter.php',
+ __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'RedisTagAwareAdapter.php',
__DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'SimpleCacheAdapter.php',
__DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'TagAwareAdapter.php',
__DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'TagAwareAdapterInterface.php',
diff --git a/src/Symfony/Component/Cache/Tests/Adapter/FilesystemTagAwareAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/FilesystemTagAwareAdapterTest.php
new file mode 100644
index 000000000000..83a7ea52ddad
--- /dev/null
+++ b/src/Symfony/Component/Cache/Tests/Adapter/FilesystemTagAwareAdapterTest.php
@@ -0,0 +1,28 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Cache\Tests\Adapter;
+
+use Symfony\Component\Cache\Adapter\FilesystemTagAwareAdapter;
+use Symfony\Component\Cache\Tests\Traits\TagAwareTestTrait;
+
+/**
+ * @group time-sensitive
+ */
+class FilesystemTagAwareAdapterTest extends FilesystemAdapterTest
+{
+ use TagAwareTestTrait;
+
+ public function createCachePool($defaultLifetime = 0)
+ {
+ return new FilesystemTagAwareAdapter('', $defaultLifetime);
+ }
+}
diff --git a/src/Symfony/Component/Cache/Tests/Adapter/PredisTagAwareAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/PredisTagAwareAdapterTest.php
new file mode 100644
index 000000000000..e321a1c9b8c2
--- /dev/null
+++ b/src/Symfony/Component/Cache/Tests/Adapter/PredisTagAwareAdapterTest.php
@@ -0,0 +1,34 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Cache\Tests\Adapter;
+
+use Symfony\Component\Cache\Adapter\RedisTagAwareAdapter;
+use Symfony\Component\Cache\Tests\Traits\TagAwareTestTrait;
+
+class PredisTagAwareAdapterTest extends PredisAdapterTest
+{
+ use TagAwareTestTrait;
+
+ protected function setUp()
+ {
+ parent::setUp();
+ $this->skippedTests['testTagItemExpiry'] = 'Testing expiration slows down the test suite';
+ }
+
+ public function createCachePool($defaultLifetime = 0)
+ {
+ $this->assertInstanceOf(\Predis\Client::class, self::$redis);
+ $adapter = new RedisTagAwareAdapter(self::$redis, str_replace('\\', '.', __CLASS__), $defaultLifetime);
+
+ return $adapter;
+ }
+}
diff --git a/src/Symfony/Component/Cache/Tests/Adapter/PredisTagAwareClusterAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/PredisTagAwareClusterAdapterTest.php
new file mode 100644
index 000000000000..a8a72e1de4ea
--- /dev/null
+++ b/src/Symfony/Component/Cache/Tests/Adapter/PredisTagAwareClusterAdapterTest.php
@@ -0,0 +1,34 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Cache\Tests\Adapter;
+
+use Symfony\Component\Cache\Adapter\RedisTagAwareAdapter;
+use Symfony\Component\Cache\Tests\Traits\TagAwareTestTrait;
+
+class PredisTagAwareClusterAdapterTest extends PredisClusterAdapterTest
+{
+ use TagAwareTestTrait;
+
+ protected function setUp()
+ {
+ parent::setUp();
+ $this->skippedTests['testTagItemExpiry'] = 'Testing expiration slows down the test suite';
+ }
+
+ public function createCachePool($defaultLifetime = 0)
+ {
+ $this->assertInstanceOf(\Predis\Client::class, self::$redis);
+ $adapter = new RedisTagAwareAdapter(self::$redis, str_replace('\\', '.', __CLASS__), $defaultLifetime);
+
+ return $adapter;
+ }
+}
diff --git a/src/Symfony/Component/Cache/Tests/Adapter/PredisTagAwareRedisClusterAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/PredisTagAwareRedisClusterAdapterTest.php
new file mode 100644
index 000000000000..5b82a80ecb32
--- /dev/null
+++ b/src/Symfony/Component/Cache/Tests/Adapter/PredisTagAwareRedisClusterAdapterTest.php
@@ -0,0 +1,34 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Cache\Tests\Adapter;
+
+use Symfony\Component\Cache\Adapter\RedisTagAwareAdapter;
+use Symfony\Component\Cache\Tests\Traits\TagAwareTestTrait;
+
+class PredisTagAwareRedisClusterAdapterTest extends PredisRedisClusterAdapterTest
+{
+ use TagAwareTestTrait;
+
+ protected function setUp()
+ {
+ parent::setUp();
+ $this->skippedTests['testTagItemExpiry'] = 'Testing expiration slows down the test suite';
+ }
+
+ public function createCachePool($defaultLifetime = 0)
+ {
+ $this->assertInstanceOf(\Predis\Client::class, self::$redis);
+ $adapter = new RedisTagAwareAdapter(self::$redis, str_replace('\\', '.', __CLASS__), $defaultLifetime);
+
+ return $adapter;
+ }
+}
diff --git a/src/Symfony/Component/Cache/Tests/Adapter/RedisTagAwareAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/RedisTagAwareAdapterTest.php
new file mode 100644
index 000000000000..95e5fe7e3a9e
--- /dev/null
+++ b/src/Symfony/Component/Cache/Tests/Adapter/RedisTagAwareAdapterTest.php
@@ -0,0 +1,35 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Cache\Tests\Adapter;
+
+use Symfony\Component\Cache\Adapter\RedisTagAwareAdapter;
+use Symfony\Component\Cache\Tests\Traits\TagAwareTestTrait;
+use Symfony\Component\Cache\Traits\RedisProxy;
+
+class RedisTagAwareAdapterTest extends RedisAdapterTest
+{
+ use TagAwareTestTrait;
+
+ protected function setUp()
+ {
+ parent::setUp();
+ $this->skippedTests['testTagItemExpiry'] = 'Testing expiration slows down the test suite';
+ }
+
+ public function createCachePool($defaultLifetime = 0)
+ {
+ $this->assertInstanceOf(RedisProxy::class, self::$redis);
+ $adapter = new RedisTagAwareAdapter(self::$redis, str_replace('\\', '.', __CLASS__), $defaultLifetime);
+
+ return $adapter;
+ }
+}
diff --git a/src/Symfony/Component/Cache/Tests/Adapter/RedisTagAwareArrayAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/RedisTagAwareArrayAdapterTest.php
new file mode 100644
index 000000000000..5855cc3adfc6
--- /dev/null
+++ b/src/Symfony/Component/Cache/Tests/Adapter/RedisTagAwareArrayAdapterTest.php
@@ -0,0 +1,34 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Cache\Tests\Adapter;
+
+use Symfony\Component\Cache\Adapter\RedisTagAwareAdapter;
+use Symfony\Component\Cache\Tests\Traits\TagAwareTestTrait;
+
+class RedisTagAwareArrayAdapterTest extends RedisArrayAdapterTest
+{
+ use TagAwareTestTrait;
+
+ protected function setUp()
+ {
+ parent::setUp();
+ $this->skippedTests['testTagItemExpiry'] = 'Testing expiration slows down the test suite';
+ }
+
+ public function createCachePool($defaultLifetime = 0)
+ {
+ $this->assertInstanceOf(\RedisArray::class, self::$redis);
+ $adapter = new RedisTagAwareAdapter(self::$redis, str_replace('\\', '.', __CLASS__), $defaultLifetime);
+
+ return $adapter;
+ }
+}
diff --git a/src/Symfony/Component/Cache/Tests/Adapter/RedisTagAwareClusterAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/RedisTagAwareClusterAdapterTest.php
new file mode 100644
index 000000000000..ef17c1d69e81
--- /dev/null
+++ b/src/Symfony/Component/Cache/Tests/Adapter/RedisTagAwareClusterAdapterTest.php
@@ -0,0 +1,35 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Cache\Tests\Adapter;
+
+use Symfony\Component\Cache\Adapter\RedisTagAwareAdapter;
+use Symfony\Component\Cache\Tests\Traits\TagAwareTestTrait;
+use Symfony\Component\Cache\Traits\RedisClusterProxy;
+
+class RedisTagAwareClusterAdapterTest extends RedisClusterAdapterTest
+{
+ use TagAwareTestTrait;
+
+ protected function setUp()
+ {
+ parent::setUp();
+ $this->skippedTests['testTagItemExpiry'] = 'Testing expiration slows down the test suite';
+ }
+
+ public function createCachePool($defaultLifetime = 0)
+ {
+ $this->assertInstanceOf(RedisClusterProxy::class, self::$redis);
+ $adapter = new RedisTagAwareAdapter(self::$redis, str_replace('\\', '.', __CLASS__), $defaultLifetime);
+
+ return $adapter;
+ }
+}
diff --git a/src/Symfony/Component/Cache/Tests/Adapter/TagAwareAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/TagAwareAdapterTest.php
index 7b8895b70019..a33979086243 100644
--- a/src/Symfony/Component/Cache/Tests/Adapter/TagAwareAdapterTest.php
+++ b/src/Symfony/Component/Cache/Tests/Adapter/TagAwareAdapterTest.php
@@ -14,13 +14,15 @@
use Symfony\Component\Cache\Adapter\AdapterInterface;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
-use Symfony\Component\Cache\CacheItem;
+use Symfony\Component\Cache\Tests\Traits\TagAwareTestTrait;
/**
* @group time-sensitive
*/
class TagAwareAdapterTest extends AdapterTestCase
{
+ use TagAwareTestTrait;
+
public function createCachePool($defaultLifetime = 0)
{
return new TagAwareAdapter(new FilesystemAdapter('', $defaultLifetime));
@@ -32,53 +34,9 @@ public static function tearDownAfterClass()
}
/**
- * @expectedException \Psr\Cache\InvalidArgumentException
+ * Test feature specific to TagAwareAdapter as it implicit needs to save deferred when also saving expiry info.
*/
- public function testInvalidTag()
- {
- $pool = $this->createCachePool();
- $item = $pool->getItem('foo');
- $item->tag(':');
- }
-
- public function testInvalidateTags()
- {
- $pool = $this->createCachePool();
-
- $i0 = $pool->getItem('i0');
- $i1 = $pool->getItem('i1');
- $i2 = $pool->getItem('i2');
- $i3 = $pool->getItem('i3');
- $foo = $pool->getItem('foo');
-
- $pool->save($i0->tag('bar'));
- $pool->save($i1->tag('foo'));
- $pool->save($i2->tag('foo')->tag('bar'));
- $pool->save($i3->tag('foo')->tag('baz'));
- $pool->save($foo);
-
- $pool->invalidateTags(['bar']);
-
- $this->assertFalse($pool->getItem('i0')->isHit());
- $this->assertTrue($pool->getItem('i1')->isHit());
- $this->assertFalse($pool->getItem('i2')->isHit());
- $this->assertTrue($pool->getItem('i3')->isHit());
- $this->assertTrue($pool->getItem('foo')->isHit());
-
- $pool->invalidateTags(['foo']);
-
- $this->assertFalse($pool->getItem('i1')->isHit());
- $this->assertFalse($pool->getItem('i3')->isHit());
- $this->assertTrue($pool->getItem('foo')->isHit());
-
- $anotherPoolInstance = $this->createCachePool();
-
- $this->assertFalse($anotherPoolInstance->getItem('i1')->isHit());
- $this->assertFalse($anotherPoolInstance->getItem('i3')->isHit());
- $this->assertTrue($anotherPoolInstance->getItem('foo')->isHit());
- }
-
- public function testInvalidateCommits()
+ public function testInvalidateCommitsSeperatePools()
{
$pool1 = $this->createCachePool();
@@ -94,76 +52,6 @@ public function testInvalidateCommits()
$this->assertTrue($foo->isHit());
}
- public function testTagsAreCleanedOnSave()
- {
- $pool = $this->createCachePool();
-
- $i = $pool->getItem('k');
- $pool->save($i->tag('foo'));
-
- $i = $pool->getItem('k');
- $pool->save($i->tag('bar'));
-
- $pool->invalidateTags(['foo']);
- $this->assertTrue($pool->getItem('k')->isHit());
- }
-
- public function testTagsAreCleanedOnDelete()
- {
- $pool = $this->createCachePool();
-
- $i = $pool->getItem('k');
- $pool->save($i->tag('foo'));
- $pool->deleteItem('k');
-
- $pool->save($pool->getItem('k'));
- $pool->invalidateTags(['foo']);
-
- $this->assertTrue($pool->getItem('k')->isHit());
- }
-
- public function testTagItemExpiry()
- {
- $pool = $this->createCachePool(10);
-
- $item = $pool->getItem('foo');
- $item->tag(['baz']);
- $item->expiresAfter(100);
-
- $pool->save($item);
- $pool->invalidateTags(['baz']);
- $this->assertFalse($pool->getItem('foo')->isHit());
-
- sleep(20);
-
- $this->assertFalse($pool->getItem('foo')->isHit());
- }
-
- /**
- * @group legacy
- */
- public function testGetPreviousTags()
- {
- $pool = $this->createCachePool();
-
- $i = $pool->getItem('k');
- $pool->save($i->tag('foo'));
-
- $i = $pool->getItem('k');
- $this->assertSame(['foo' => 'foo'], $i->getPreviousTags());
- }
-
- public function testGetMetadata()
- {
- $pool = $this->createCachePool();
-
- $i = $pool->getItem('k');
- $pool->save($i->tag('foo'));
-
- $i = $pool->getItem('k');
- $this->assertSame(['foo' => 'foo'], $i->getMetadata()[CacheItem::METADATA_TAGS]);
- }
-
public function testPrune()
{
$cache = new TagAwareAdapter($this->getPruneableMock());
diff --git a/src/Symfony/Component/Cache/Tests/DependencyInjection/CachePoolPassTest.php b/src/Symfony/Component/Cache/Tests/DependencyInjection/CachePoolPassTest.php
index f307aa5386b0..4681b3dc8197 100644
--- a/src/Symfony/Component/Cache/Tests/DependencyInjection/CachePoolPassTest.php
+++ b/src/Symfony/Component/Cache/Tests/DependencyInjection/CachePoolPassTest.php
@@ -13,6 +13,7 @@
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
+use Symfony\Component\Cache\Adapter\RedisAdapter;
use Symfony\Component\Cache\DependencyInjection\CachePoolPass;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
@@ -48,6 +49,27 @@ public function testNamespaceArgumentIsReplaced()
$this->assertSame('z3X945Jbf5', $cachePool->getArgument(0));
}
+ public function testNamespaceArgumentIsSeededWithAdapterClassName()
+ {
+ $container = new ContainerBuilder();
+ $container->setParameter('kernel.container_class', 'app');
+ $container->setParameter('kernel.project_dir', 'foo');
+ $adapter = new Definition();
+ $adapter->setAbstract(true);
+ $adapter->addTag('cache.pool');
+ $adapter->setClass(RedisAdapter::class);
+ $container->setDefinition('app.cache_adapter', $adapter);
+ $container->setAlias('app.cache_adapter_alias', 'app.cache_adapter');
+ $cachePool = new ChildDefinition('app.cache_adapter_alias');
+ $cachePool->addArgument(null);
+ $cachePool->addTag('cache.pool');
+ $container->setDefinition('app.cache_pool', $cachePool);
+
+ $this->cachePoolPass->process($container);
+
+ $this->assertSame('xmOJ8gqF-Y', $cachePool->getArgument(0));
+ }
+
public function testNamespaceArgumentIsNotReplacedIfArrayAdapterIsUsed()
{
$container = new ContainerBuilder();
diff --git a/src/Symfony/Component/Cache/Tests/Traits/TagAwareTestTrait.php b/src/Symfony/Component/Cache/Tests/Traits/TagAwareTestTrait.php
new file mode 100644
index 000000000000..38cc4dc9cc99
--- /dev/null
+++ b/src/Symfony/Component/Cache/Tests/Traits/TagAwareTestTrait.php
@@ -0,0 +1,160 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Cache\Tests\Traits;
+
+use Symfony\Component\Cache\CacheItem;
+
+/**
+ * Common assertions for TagAware adapters.
+ *
+ * @method \Symfony\Component\Cache\Adapter\TagAwareAdapterInterface createCachePool() Must be implemented by TestCase
+ */
+trait TagAwareTestTrait
+{
+ /**
+ * @expectedException \Psr\Cache\InvalidArgumentException
+ */
+ public function testInvalidTag()
+ {
+ $pool = $this->createCachePool();
+ $item = $pool->getItem('foo');
+ $item->tag(':');
+ }
+
+ public function testInvalidateTags()
+ {
+ $pool = $this->createCachePool();
+
+ $i0 = $pool->getItem('i0');
+ $i1 = $pool->getItem('i1');
+ $i2 = $pool->getItem('i2');
+ $i3 = $pool->getItem('i3');
+ $foo = $pool->getItem('foo');
+
+ $pool->save($i0->tag('bar'));
+ $pool->save($i1->tag('foo'));
+ $pool->save($i2->tag('foo')->tag('bar'));
+ $pool->save($i3->tag('foo')->tag('baz'));
+ $pool->save($foo);
+
+ $pool->invalidateTags(['bar']);
+
+ $this->assertFalse($pool->getItem('i0')->isHit());
+ $this->assertTrue($pool->getItem('i1')->isHit());
+ $this->assertFalse($pool->getItem('i2')->isHit());
+ $this->assertTrue($pool->getItem('i3')->isHit());
+ $this->assertTrue($pool->getItem('foo')->isHit());
+
+ $pool->invalidateTags(['foo']);
+
+ $this->assertFalse($pool->getItem('i1')->isHit());
+ $this->assertFalse($pool->getItem('i3')->isHit());
+ $this->assertTrue($pool->getItem('foo')->isHit());
+
+ $anotherPoolInstance = $this->createCachePool();
+
+ $this->assertFalse($anotherPoolInstance->getItem('i1')->isHit());
+ $this->assertFalse($anotherPoolInstance->getItem('i3')->isHit());
+ $this->assertTrue($anotherPoolInstance->getItem('foo')->isHit());
+ }
+
+ public function testInvalidateCommits()
+ {
+ $pool = $this->createCachePool();
+
+ $foo = $pool->getItem('foo');
+ $foo->tag('tag');
+
+ $pool->saveDeferred($foo->set('foo'));
+ $pool->invalidateTags(['tag']);
+
+ // ??: This seems to contradict a bit logic in deleteItems, where it does unset($this->deferred[$key]); on key matches
+
+ $foo = $pool->getItem('foo');
+
+ $this->assertTrue($foo->isHit());
+ }
+
+ public function testTagsAreCleanedOnSave()
+ {
+ $pool = $this->createCachePool();
+
+ $i = $pool->getItem('k');
+ $pool->save($i->tag('foo'));
+
+ $i = $pool->getItem('k');
+ $pool->save($i->tag('bar'));
+
+ $pool->invalidateTags(['foo']);
+ $this->assertTrue($pool->getItem('k')->isHit());
+ }
+
+ public function testTagsAreCleanedOnDelete()
+ {
+ $pool = $this->createCachePool();
+
+ $i = $pool->getItem('k');
+ $pool->save($i->tag('foo'));
+ $pool->deleteItem('k');
+
+ $pool->save($pool->getItem('k'));
+ $pool->invalidateTags(['foo']);
+
+ $this->assertTrue($pool->getItem('k')->isHit());
+ }
+
+ public function testTagItemExpiry()
+ {
+ if (isset($this->skippedTests[__FUNCTION__])) {
+ $this->markTestSkipped($this->skippedTests[__FUNCTION__]);
+ }
+
+ $pool = $this->createCachePool(10);
+
+ $item = $pool->getItem('foo');
+ $item->tag(['baz']);
+ $item->expiresAfter(100);
+
+ $pool->save($item);
+ $pool->invalidateTags(['baz']);
+ $this->assertFalse($pool->getItem('foo')->isHit());
+
+ sleep(20);
+
+ $this->assertFalse($pool->getItem('foo')->isHit());
+ }
+
+ /**
+ * @group legacy
+ */
+ public function testGetPreviousTags()
+ {
+ $pool = $this->createCachePool();
+
+ $i = $pool->getItem('k');
+ $pool->save($i->tag('foo'));
+
+ $i = $pool->getItem('k');
+ $this->assertSame(['foo' => 'foo'], $i->getPreviousTags());
+ }
+
+ public function testGetMetadata()
+ {
+ $pool = $this->createCachePool();
+
+ $i = $pool->getItem('k');
+ $pool->save($i->tag('foo'));
+
+ $i = $pool->getItem('k');
+ $this->assertSame(['foo' => 'foo'], $i->getMetadata()[CacheItem::METADATA_TAGS]);
+ }
+}
diff --git a/src/Symfony/Component/Cache/Traits/AbstractAdapterTrait.php b/src/Symfony/Component/Cache/Traits/AbstractAdapterTrait.php
new file mode 100644
index 000000000000..f1d97abf2d2f
--- /dev/null
+++ b/src/Symfony/Component/Cache/Traits/AbstractAdapterTrait.php
@@ -0,0 +1,139 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Cache\Traits;
+
+use Psr\Cache\CacheItemInterface;
+use Symfony\Component\Cache\CacheItem;
+
+/**
+ * @author Nicolas Grekas
+ *
+ * @internal
+ */
+trait AbstractAdapterTrait
+{
+ use AbstractTrait;
+
+ /**
+ * @var \Closure needs to be set by class, signature is function(string , mixed , bool )
+ */
+ private $createCacheItem;
+
+ /**
+ * @var \Closure needs to be set by class, signature is function(array , string , array <&expiredIds>)
+ */
+ private $mergeByLifetime;
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getItem($key)
+ {
+ if ($this->deferred) {
+ $this->commit();
+ }
+ $id = $this->getId($key);
+
+ $f = $this->createCacheItem;
+ $isHit = false;
+ $value = null;
+
+ try {
+ foreach ($this->doFetch([$id]) as $value) {
+ $isHit = true;
+ }
+ } catch (\Exception $e) {
+ CacheItem::log($this->logger, 'Failed to fetch key "{key}"', ['key' => $key, 'exception' => $e]);
+ }
+
+ return $f($key, $value, $isHit);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getItems(array $keys = [])
+ {
+ if ($this->deferred) {
+ $this->commit();
+ }
+ $ids = [];
+
+ foreach ($keys as $key) {
+ $ids[] = $this->getId($key);
+ }
+ try {
+ $items = $this->doFetch($ids);
+ } catch (\Exception $e) {
+ CacheItem::log($this->logger, 'Failed to fetch requested items', ['keys' => $keys, 'exception' => $e]);
+ $items = [];
+ }
+ $ids = array_combine($ids, $keys);
+
+ return $this->generateItems($items, $ids);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function save(CacheItemInterface $item)
+ {
+ if (!$item instanceof CacheItem) {
+ return false;
+ }
+ $this->deferred[$item->getKey()] = $item;
+
+ return $this->commit();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function saveDeferred(CacheItemInterface $item)
+ {
+ if (!$item instanceof CacheItem) {
+ return false;
+ }
+ $this->deferred[$item->getKey()] = $item;
+
+ return true;
+ }
+
+ public function __destruct()
+ {
+ if ($this->deferred) {
+ $this->commit();
+ }
+ }
+
+ private function generateItems($items, &$keys)
+ {
+ $f = $this->createCacheItem;
+
+ try {
+ foreach ($items as $id => $value) {
+ if (!isset($keys[$id])) {
+ $id = key($keys);
+ }
+ $key = $keys[$id];
+ unset($keys[$id]);
+ yield $key => $f($key, $value, true);
+ }
+ } catch (\Exception $e) {
+ CacheItem::log($this->logger, 'Failed to fetch requested items', ['keys' => array_values($keys), 'exception' => $e]);
+ }
+
+ foreach ($keys as $key) {
+ yield $key => $f($key, null, false);
+ }
+ }
+}
diff --git a/src/Symfony/Component/Cache/Traits/FilesystemCommonTrait.php b/src/Symfony/Component/Cache/Traits/FilesystemCommonTrait.php
index 3f684acd685d..37e1fd1f06b3 100644
--- a/src/Symfony/Component/Cache/Traits/FilesystemCommonTrait.php
+++ b/src/Symfony/Component/Cache/Traits/FilesystemCommonTrait.php
@@ -101,11 +101,11 @@ private function write($file, $data, $expiresAt = null)
}
}
- private function getFile($id, $mkdir = false)
+ private function getFile($id, $mkdir = false, string $directory = null)
{
// Use MD5 to favor speed over security, which is not an issue here
$hash = str_replace('/', '-', base64_encode(hash('md5', static::class.$id, true)));
- $dir = $this->directory.strtoupper($hash[0].\DIRECTORY_SEPARATOR.$hash[1].\DIRECTORY_SEPARATOR);
+ $dir = ($directory ?? $this->directory).strtoupper($hash[0].\DIRECTORY_SEPARATOR.$hash[1].\DIRECTORY_SEPARATOR);
if ($mkdir && !file_exists($dir)) {
@mkdir($dir, 0777, true);
diff --git a/src/Symfony/Component/Cache/Traits/RedisTrait.php b/src/Symfony/Component/Cache/Traits/RedisTrait.php
index 0b79a7d1adb3..b2faca651d0d 100644
--- a/src/Symfony/Component/Cache/Traits/RedisTrait.php
+++ b/src/Symfony/Component/Cache/Traits/RedisTrait.php
@@ -321,33 +321,13 @@ protected function doHave($id)
protected function doClear($namespace)
{
$cleared = true;
- $hosts = [$this->redis];
- $evalArgs = [[$namespace], 0];
-
if ($this->redis instanceof \Predis\Client) {
$evalArgs = [0, $namespace];
-
- $connection = $this->redis->getConnection();
- if ($connection instanceof ClusterInterface && $connection instanceof \Traversable) {
- $hosts = [];
- foreach ($connection as $c) {
- $hosts[] = new \Predis\Client($c);
- }
- }
- } elseif ($this->redis instanceof \RedisArray) {
- $hosts = [];
- foreach ($this->redis->_hosts() as $host) {
- $hosts[] = $this->redis->_instance($host);
- }
- } elseif ($this->redis instanceof RedisClusterProxy || $this->redis instanceof \RedisCluster) {
- $hosts = [];
- foreach ($this->redis->_masters() as $host) {
- $hosts[] = $h = new \Redis();
- $h->connect($host[0], $host[1]);
- }
+ } else {
+ $evalArgs = [[$namespace], 0];
}
- foreach ($hosts as $host) {
+ foreach ($this->getHosts() as $host) {
if (!isset($namespace[0])) {
$cleared = $host->flushDb() && $cleared;
continue;
@@ -479,4 +459,31 @@ private function pipeline(\Closure $generator)
yield $id => $results[$k];
}
}
+
+ private function getHosts(): array
+ {
+ $hosts = [$this->redis];
+ if ($this->redis instanceof \Predis\Client) {
+ $connection = $this->redis->getConnection();
+ if ($connection instanceof ClusterInterface && $connection instanceof \Traversable) {
+ $hosts = [];
+ foreach ($connection as $c) {
+ $hosts[] = new \Predis\Client($c);
+ }
+ }
+ } elseif ($this->redis instanceof \RedisArray) {
+ $hosts = [];
+ foreach ($this->redis->_hosts() as $host) {
+ $hosts[] = $this->redis->_instance($host);
+ }
+ } elseif ($this->redis instanceof RedisClusterProxy || $this->redis instanceof \RedisCluster) {
+ $hosts = [];
+ foreach ($this->redis->_masters() as $host) {
+ $hosts[] = $h = new \Redis();
+ $h->connect($host[0], $host[1]);
+ }
+ }
+
+ return $hosts;
+ }
}
diff --git a/src/Symfony/Component/Cache/phpunit.xml.dist b/src/Symfony/Component/Cache/phpunit.xml.dist
index c35458ca4471..591046cf1c41 100644
--- a/src/Symfony/Component/Cache/phpunit.xml.dist
+++ b/src/Symfony/Component/Cache/phpunit.xml.dist
@@ -40,7 +40,8 @@
Doctrine\Common\Cache
Symfony\Component\Cache
Symfony\Component\Cache\Tests\Fixtures
- Symfony\Component\Cache\Traits
+ Symfony\Component\Cache\Tests\Traits
+ Symfony\Component\Cache\Traits