From c878e52971e78019ff736f056b0fc11f0c027f48 Mon Sep 17 00:00:00 2001 From: Sergey Belyshkin Date: Sun, 26 Dec 2021 23:19:18 +0700 Subject: [PATCH 1/2] [Cache] Upgrade TagAwareAdapter --- .../Component/Cache/Adapter/ProxyAdapter.php | 6 +- .../Cache/Adapter/TagAwareAdapter.php | 431 +++++++++++------- src/Symfony/Component/Cache/CHANGELOG.md | 1 + .../Tests/Adapter/TagAwareAdapterTest.php | 129 +++--- 4 files changed, 320 insertions(+), 247 deletions(-) diff --git a/src/Symfony/Component/Cache/Adapter/ProxyAdapter.php b/src/Symfony/Component/Cache/Adapter/ProxyAdapter.php index 23434cd4714c9..4a2617d2d6b69 100644 --- a/src/Symfony/Component/Cache/Adapter/ProxyAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/ProxyAdapter.php @@ -83,11 +83,7 @@ static function ($key, $innerItem, $poolHash) { * @param array $item A CacheItem cast to (array); accessing protected properties requires adding the "\0*\0" PHP prefix */ static function (CacheItemInterface $innerItem, array $item) { - // Tags are stored separately, no need to account for them when considering this item's newly set metadata - if (isset(($metadata = $item["\0*\0newMetadata"])[CacheItem::METADATA_TAGS])) { - unset($metadata[CacheItem::METADATA_TAGS]); - } - if ($metadata) { + if ($metadata = $item["\0*\0newMetadata"]) { // For compactness, expiry and creation duration are packed in the key of an array, using magic numbers as separators $item["\0*\0value"] = ["\x9D".pack('VN', (int) (0.1 + $metadata[self::METADATA_EXPIRY] - self::METADATA_EXPIRY_OFFSET), $metadata[self::METADATA_CTIME])."\x5F" => $item["\0*\0value"]]; } diff --git a/src/Symfony/Component/Cache/Adapter/TagAwareAdapter.php b/src/Symfony/Component/Cache/Adapter/TagAwareAdapter.php index 0176383ef063c..5e8f4871087a1 100644 --- a/src/Symfony/Component/Cache/Adapter/TagAwareAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/TagAwareAdapter.php @@ -19,28 +19,40 @@ use Symfony\Component\Cache\PruneableInterface; use Symfony\Component\Cache\ResettableInterface; use Symfony\Component\Cache\Traits\ContractsTrait; -use Symfony\Component\Cache\Traits\ProxyTrait; use Symfony\Contracts\Cache\TagAwareCacheInterface; /** + * Implements simple and robust tag-based invalidation algorithm suitable for use with volatile caches. + * + * Tags point to a separate keys a values of which are current tag versions. Values of tagged items contain + * tag versions as an integral part and remain valid until any of their tag versions are changed. + * Invalidation is achieved by deleting tags, thereby ensuring change of their versions even when the storage is out of + * space. When versions of non-existing tags are requested for item commits or for validation of retrieved items, + * adapter creates tags and assigns a new random version to them. + * * @author Nicolas Grekas + * @author Sergey Belyshkin */ class TagAwareAdapter implements TagAwareAdapterInterface, TagAwareCacheInterface, PruneableInterface, ResettableInterface, LoggerAwareInterface { use ContractsTrait; use LoggerAwareTrait; - use ProxyTrait; public const TAGS_PREFIX = "\0tags\0"; + private const ITEM_PREFIX = '$'; + private const TAG_PREFIX = '#'; + private const MAX_NUMBER_OF_KNOWN_TAG_VERSIONS = 1000; private array $deferred = []; + private AdapterInterface $pool; private AdapterInterface $tags; private array $knownTagVersions = []; private float $knownTagVersionsTtl; - private static \Closure $createCacheItem; - private static \Closure $setCacheItemTags; - private static \Closure $getTagsByKey; + private static \Closure $unpackCacheItem; + private static \Closure $unsetCacheItem; + private static \Closure $computeAndPackItems; + private static \Closure $extractTagsFromItems; private static \Closure $saveTags; public function __construct(AdapterInterface $itemsPool, AdapterInterface $tagsPool = null, float $knownTagVersionsTtl = 0.15) @@ -48,57 +60,104 @@ public function __construct(AdapterInterface $itemsPool, AdapterInterface $tagsP $this->pool = $itemsPool; $this->tags = $tagsPool ?? $itemsPool; $this->knownTagVersionsTtl = $knownTagVersionsTtl; - self::$createCacheItem ??= \Closure::bind( - static function ($key, $value, CacheItem $protoItem) { - $item = new CacheItem(); + self::$unpackCacheItem ??= \Closure::bind( + static function (CacheItem $item, string $key): array { $item->key = $key; - $item->value = $value; - $item->expiry = $protoItem->expiry; - $item->poolHash = $protoItem->poolHash; + $item->isTaggable = true; + if (!$item->isHit) { + return []; + } + $value = $item->value; + if (!\is_array($value) || !((['$', '#', '^'] === ($arrayKeys = array_keys($value)) && \is_string($value['^']) || ['$', '#'] === $arrayKeys) && \is_array($value['#']))) { + $item->isHit = false; + $item->value = null; + + return []; + } + $item->value = $value['$']; + if ($value['#']) { + $tags = []; + foreach ($value['#'] as $tag => $tagVersion) { + $tags[$tag] = $tag; + } + $item->metadata[CacheItem::METADATA_TAGS] = $tags; + } + if (isset($value['^'])) { + $m = unpack('Ne/Vc', str_pad($value['^'], 8, "\x00")); + $item->metadata[CacheItem::METADATA_EXPIRY] = $m['e']; + $item->metadata[CacheItem::METADATA_CTIME] = $m['c']; + } - return $item; + return $value['#']; }, null, CacheItem::class ); - self::$setCacheItemTags ??= \Closure::bind( - static function (CacheItem $item, $key, array &$itemTags) { - $item->isTaggable = true; - if (!$item->isHit) { - return $item; - } - if (isset($itemTags[$key])) { - foreach ($itemTags[$key] as $tag => $version) { - $item->metadata[CacheItem::METADATA_TAGS][$tag] = $tag; + self::$unsetCacheItem ??= \Closure::bind( + static function (CacheItem $item) { + $item->isHit = false; + $item->value = null; + $item->metadata = []; + }, + null, + CacheItem::class + ); + $getPrefixedKeyMethod = \Closure::fromCallable([$this, 'getPrefixedKey']); + self::$computeAndPackItems ??= \Closure::bind( + static function ($deferred, $tagVersions) use ($getPrefixedKeyMethod) { + $packedItems = []; + foreach ($deferred as $key => $item) { + $itemTagVersions = []; + $metadata = $item->newMetadata; + if (isset($metadata[CacheItem::METADATA_TAGS])) { + foreach ($metadata[CacheItem::METADATA_TAGS] as $tag) { + if (!isset($tagVersions[$tag])) { + // Don't save items without full set of valid tags + continue 2; + } + $itemTagVersions[$tag] = $tagVersions[$tag]; + } } - unset($itemTags[$key]); - } else { - $item->value = null; - $item->isHit = false; + // Pack the value, tags and meta data. + $value = ['$' => $item->value, '#' => $itemTagVersions]; + if (isset($metadata[CacheItem::METADATA_CTIME])) { + $ctime = $metadata[CacheItem::METADATA_CTIME]; + // 1. 03:14:08 UTC on Tuesday, 19 January 2038 timestamp will reach 0x7FFFFFFF and 32-bit systems + // will go back to Unix Epoch, but on 64-bit systems it's OK to use first 32 bits of timestamp + // till 06:28:15 UTC on Sunday, 7 February 2106, when it'll reach 0xFFFFFFFF. + // 2. CTIME is packed as an 8/16/24/32-bits integer. For reference, 24 bits are able to reflect + // intervals up to 4 hours 39 minutes 37 seconds and 215 ms, but in most cases 8 bits are enough. + $length = 4 + ($ctime <= 255 ? 1 : ($ctime <= 65535 ? 2 : ($ctime <= 16777215 ? 3 : 4))); + $value['^'] = substr(pack('NV', (int) ceil($metadata[CacheItem::METADATA_EXPIRY]), $ctime), 0, $length); + } + $packedItem = clone $item; + $packedItem->metadata = $packedItem->newMetadata = []; + $packedItem->key = $getPrefixedKeyMethod($key); + $packedItem->value = $value; + $packedItems[] = $packedItem; + + $item->metadata = $metadata; } - return $item; + return $packedItems; }, null, CacheItem::class ); - self::$getTagsByKey ??= \Closure::bind( + self::$extractTagsFromItems ??= \Closure::bind( static function ($deferred) { - $tagsByKey = []; - foreach ($deferred as $key => $item) { - $tagsByKey[$key] = $item->newMetadata[CacheItem::METADATA_TAGS] ?? []; - $item->metadata = $item->newMetadata; + $uniqueTags = []; + foreach ($deferred as $item) { + $uniqueTags += $item->newMetadata[CacheItem::METADATA_TAGS] ?? []; } - return $tagsByKey; + return $uniqueTags; }, null, CacheItem::class ); self::$saveTags ??= \Closure::bind( static function (AdapterInterface $tagsAdapter, array $tags) { - ksort($tags); - foreach ($tags as $v) { $v->expiry = 0; $tagsAdapter->saveDeferred($v); @@ -120,7 +179,7 @@ public function invalidateTags(array $tags): bool foreach ($tags as $tag) { \assert('' !== CacheItem::validateKey($tag)); unset($this->knownTagVersions[$tag]); - $ids[] = $tag.static::TAGS_PREFIX; + $ids[] = static::TAG_PREFIX.$tag; } return !$tags || $this->tags->deleteItems($ids); @@ -135,27 +194,13 @@ public function hasItem(mixed $key): bool $this->commit(); } - if (!$this->pool->hasItem($key)) { + if (!$this->pool->hasItem($this->getPrefixedKey($key))) { return false; } - $itemTags = $this->pool->getItem(static::TAGS_PREFIX.$key); + $item = $this->getItem($key); - if (!$itemTags->isHit()) { - return false; - } - - if (!$itemTags = $itemTags->get()) { - return true; - } - - foreach ($this->getTagVersions([$itemTags]) as $tag => $version) { - if ($itemTags[$tag] !== $version) { - return false; - } - } - - return true; + return $item->isHit(); } /** @@ -163,9 +208,37 @@ public function hasItem(mixed $key): bool */ public function getItem(mixed $key): CacheItem { - foreach ($this->getItems([$key]) as $item) { + $prefixedKey = $this->getPrefixedKey($key); + + if (isset($this->deferred[$key])) { + $this->commit(); + } + + $item = $this->pool->getItem($prefixedKey); + $itemTagVersions = (self::$unpackCacheItem)($item, $key); + + while (true) { + $knownTagVersions = $this->knownTagVersions; + $now = microtime(true); + foreach ($itemTagVersions as $itemTag => $itemTagVersion) { + if (($knownTagVersions[$itemTag][0] ?? 0.0) < $now || $knownTagVersions[$itemTag][1] !== $itemTagVersion) { + break 2; + } + } + return $item; } + $knownTagVersions = null; + + $tagVersions = $this->getTagVersions(array_keys($itemTagVersions)); + foreach ($itemTagVersions as $itemTag => $itemTagVersion) { + if (!isset($tagVersions[$itemTag]) || $tagVersions[$itemTag] !== $itemTagVersion) { + (self::$unsetCacheItem)($item); + break; + } + } + + return $item; } /** @@ -173,30 +246,40 @@ public function getItem(mixed $key): CacheItem */ public function getItems(array $keys = []): iterable { - $tagKeys = []; + $items = $itemIdsMap = $itemTagVersions = $tagVersions = []; $commit = false; foreach ($keys as $key) { - if ('' !== $key && \is_string($key)) { - $commit = $commit || isset($this->deferred[$key]); - $key = static::TAGS_PREFIX.$key; - $tagKeys[$key] = $key; - } + $itemIdsMap[$this->getPrefixedKey($key)] = $key; + $commit = $commit || isset($this->deferred[$key]); } if ($commit) { $this->commit(); } - try { - $items = $this->pool->getItems($tagKeys + $keys); - } catch (InvalidArgumentException $e) { - $this->pool->getItems($keys); // Should throw an exception - - throw $e; + $validateAgainstKnownTagVersions = !empty($this->knownTagVersions); + $f = self::$unpackCacheItem; + foreach ($this->pool->getItems(array_keys($itemIdsMap)) as $itemId => $item) { + $key = $itemIdsMap[$itemId]; + $itemTagVersions[$key] = $t = ($f)($item, $key); + $items[$key] = $item; + if (!$t) { + continue; + } + $tagVersions += $t; + if ($validateAgainstKnownTagVersions) { + foreach ($t as $tag => $tagVersion) { + if ($tagVersions[$tag] !== $tagVersion) { + $validateAgainstKnownTagVersions = false; + break; + } + } + } } + $itemIdsMap = null; - return $this->generateItems($items, $tagKeys); + return $this->generateItems($items, $itemTagVersions, $tagVersions, $validateAgainstKnownTagVersions); } /** @@ -204,6 +287,18 @@ public function getItems(array $keys = []): iterable */ public function clear(string $prefix = ''): bool { + if ($this->pool instanceof AdapterInterface) { + $isPoolCleared = $this->pool->clear(self::ITEM_PREFIX.$prefix); + } else { + $isPoolCleared = $this->pool->clear(); + } + + if ($this->tags instanceof AdapterInterface) { + $isTagPoolCleared = $this->tags->clear(static::TAG_PREFIX.$prefix); + } else { + $isTagPoolCleared = $this->tags->clear(); + } + if ('' !== $prefix) { foreach ($this->deferred as $key => $item) { if (str_starts_with($key, $prefix)) { @@ -214,11 +309,7 @@ public function clear(string $prefix = ''): bool $this->deferred = []; } - if ($this->pool instanceof AdapterInterface) { - return $this->pool->clear($prefix); - } - - return $this->pool->clear(); + return $isPoolCleared && $isTagPoolCleared; } /** @@ -226,7 +317,7 @@ public function clear(string $prefix = ''): bool */ public function deleteItem(mixed $key): bool { - return $this->deleteItems([$key]); + return $this->pool->deleteItem($this->getPrefixedKey($key)); } /** @@ -234,13 +325,9 @@ public function deleteItem(mixed $key): bool */ public function deleteItems(array $keys): bool { - foreach ($keys as $key) { - if ('' !== $key && \is_string($key)) { - $keys[] = static::TAGS_PREFIX.$key; - } - } + $prefixedKeys = array_map([$this, 'getPrefixedKey'], $keys); - return $this->pool->deleteItems($keys); + return $this->pool->deleteItems($prefixedKeys); } /** @@ -248,12 +335,7 @@ public function deleteItems(array $keys): bool */ public function save(CacheItemInterface $item): bool { - if (!$item instanceof CacheItem) { - return false; - } - $this->deferred[$item->getKey()] = $item; - - return $this->commit(); + return $this->saveDeferred($item) && $this->commit(); } /** @@ -278,26 +360,35 @@ public function commit(): bool return true; } - $ok = true; - foreach ($this->deferred as $key => $item) { - if (!$this->pool->saveDeferred($item)) { - unset($this->deferred[$key]); - $ok = false; - } + $uniqueTags = (self::$extractTagsFromItems)($this->deferred); + $tagVersions = $this->getTagVersions($uniqueTags); + $packedItems = (self::$computeAndPackItems)($this->deferred, $tagVersions); + $allItemsArePacked = \count($this->deferred) === \count($packedItems); + $this->deferred = []; + + foreach ($packedItems as $item) { + $this->pool->saveDeferred($item); } - $items = $this->deferred; - $tagsByKey = (self::$getTagsByKey)($items); - $this->deferred = []; + return $this->pool->commit() && $allItemsArePacked; + } - $tagVersions = $this->getTagVersions($tagsByKey); - $f = self::$createCacheItem; + /** + * {@inheritdoc} + */ + public function prune(): bool + { + $isPruned = $this->pool instanceof PruneableInterface && $this->pool->prune(); - foreach ($tagsByKey as $key => $tags) { - $this->pool->saveDeferred($f(static::TAGS_PREFIX.$key, array_intersect_key($tagVersions, $tags), $items[$key])); - } + return $this->tags instanceof PruneableInterface && $this->tags->prune() && $isPruned; + } - return $this->pool->commit() && $ok; + public function reset() + { + $this->commit(); + $this->knownTagVersions = []; + $this->pool instanceof ResettableInterface && $this->pool->reset(); + $this->tags instanceof ResettableInterface && $this->tags->reset(); } public function __sleep(): array @@ -315,95 +406,97 @@ public function __destruct() $this->commit(); } - private function generateItems(iterable $items, array $tagKeys): \Generator + private function generateItems(array $items, array $itemTagVersions, array $tagVersions, bool $validateAgainstKnownTagVersions = false): \Generator { - $bufferedItems = $itemTags = []; - $f = self::$setCacheItemTags; - - foreach ($items as $key => $item) { - if (!$tagKeys) { - yield $key => $f($item, static::TAGS_PREFIX.$key, $itemTags); - continue; - } - if (!isset($tagKeys[$key])) { - $bufferedItems[$key] = $item; - continue; - } - - unset($tagKeys[$key]); - - if ($item->isHit()) { - $itemTags[$key] = $item->get() ?: []; + if ($validateAgainstKnownTagVersions) { + $knownTagVersions = $this->knownTagVersions; + $now = microtime(true); + foreach ($itemTagVersions as $itemTag => $itemTagVersion) { + if (($knownTagVersions[$itemTag][0] ?? 0.0) < $now || $knownTagVersions[$itemTag][1] !== $itemTagVersion) { + $validateAgainstKnownTagVersions = false; + break; + } } - - if (!$tagKeys) { - $tagVersions = $this->getTagVersions($itemTags); - - foreach ($itemTags as $key => $tags) { - foreach ($tags as $tag => $version) { - if ($tagVersions[$tag] !== $version) { - unset($itemTags[$key]); - continue 2; - } + } + if (!$validateAgainstKnownTagVersions) { + $tagVersions = $this->getTagVersions(array_keys($tagVersions)); + foreach ($items as $key => $item) { + foreach ($itemTagVersions[$key] as $itemTag => $itemTagVersion) { + if (!isset($tagVersions[$itemTag]) || $tagVersions[$itemTag] !== $itemTagVersion) { + (self::$unsetCacheItem)($item); + break; } } - $tagVersions = $tagKeys = null; - - foreach ($bufferedItems as $key => $item) { - yield $key => $f($item, static::TAGS_PREFIX.$key, $itemTags); - } - $bufferedItems = null; + yield $key => $item; + } + } else { + foreach ($items as $key => $item) { + yield $key => $item; } } } - private function getTagVersions(array $tagsByKey): array + /** + * Loads tag versions from or creates them in the tag pool, and updates the cache of known tag versions. + * + * May return only a part of requested tags or even none of them if for some reason they cannot be read or created. + * + * @throws InvalidArgumentException + * + * @return string[] + */ + private function getTagVersions(array $tags): array { - $tagVersions = []; - $fetchTagVersions = false; - - foreach ($tagsByKey as $tags) { - $tagVersions += $tags; - - foreach ($tags as $tag => $version) { - if ($tagVersions[$tag] !== $version) { - unset($this->knownTagVersions[$tag]); - } - } + if (!$tags) { + return []; } - if (!$tagVersions) { - return []; + $tagIdsMap = $tagVersions = $createdTagVersions = $createdTags = []; + foreach ($tags as $tag) { + $tagIdsMap[static::TAG_PREFIX.$tag] = $tag; } + ksort($tagIdsMap); - $now = microtime(true); - $tags = []; - foreach ($tagVersions as $tag => $version) { - $tags[$tag.static::TAGS_PREFIX] = $tag; - if ($fetchTagVersions || ($this->knownTagVersions[$tag][1] ?? null) !== $version || $now - $this->knownTagVersions[$tag][0] >= $this->knownTagVersionsTtl) { - // reuse previously fetched tag versions up to the ttl - $fetchTagVersions = true; + if (0.0 < $this->knownTagVersionsTtl) { + $now = microtime(true); + $knownTagVersionsExpiration = $now + $this->knownTagVersionsTtl; + if (self::MAX_NUMBER_OF_KNOWN_TAG_VERSIONS < \count($this->knownTagVersions)) { + $this->knownTagVersions = array_filter($this->knownTagVersions, static function ($v) use ($now) { return $now < $v[0]; }); + } + foreach ($this->tags->getItems(array_keys($tagIdsMap)) as $tagId => $version) { + $tag = $tagIdsMap[$tagId]; + if ($version->isHit()) { + $tagVersions[$tag] = $version->get(); + $this->knownTagVersions[$tag] = [$knownTagVersionsExpiration, $tagVersions[$tag]]; + continue; + } + $createdTags[] = $version->set($newTagVersion ??= random_bytes(8)); + $createdTagVersions[$tag] = $newTagVersion; + unset($this->knownTagVersions[$tag]); + } + } else { + foreach ($this->tags->getItems(array_keys($tagIdsMap)) as $tagId => $version) { + $tag = $tagIdsMap[$tagId]; + if ($version->isHit()) { + $tagVersions[$tag] = $version->get(); + continue; + } + $createdTags[] = $version->set($newTagVersion ??= random_bytes(8)); + $createdTagVersions[$tag] = $newTagVersion; } } - if (!$fetchTagVersions) { - return $tagVersions; + if ($createdTags && !(self::$saveTags)($this->tags, $createdTags)) { + $createdTagVersions = []; } - $newTags = []; - $newVersion = null; - foreach ($this->tags->getItems(array_keys($tags)) as $tag => $version) { - if (!$version->isHit()) { - $newTags[$tag] = $version->set($newVersion ??= random_int(\PHP_INT_MIN, \PHP_INT_MAX)); - } - $tagVersions[$tag = $tags[$tag]] = $version->get(); - $this->knownTagVersions[$tag] = [$now, $tagVersions[$tag]]; - } + return $tagVersions += $createdTagVersions; + } - if ($newTags) { - (self::$saveTags)($this->tags, $newTags); - } + private function getPrefixedKey($key): string + { + \assert('' !== CacheItem::validateKey($key)); - return $tagVersions; + return static::ITEM_PREFIX.$key; } } diff --git a/src/Symfony/Component/Cache/CHANGELOG.md b/src/Symfony/Component/Cache/CHANGELOG.md index 6b7fd87d7c27e..902d0ef29cb24 100644 --- a/src/Symfony/Component/Cache/CHANGELOG.md +++ b/src/Symfony/Component/Cache/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG --- * Add support for ACL auth in RedisAdapter + * Improve reliability and performance of `TagAwareAdapter` by making tag versions an integral part of item value 6.0 --- diff --git a/src/Symfony/Component/Cache/Tests/Adapter/TagAwareAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/TagAwareAdapterTest.php index eba7f764a130d..db85c6b479a42 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/TagAwareAdapterTest.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/TagAwareAdapterTest.php @@ -62,81 +62,20 @@ public function testKnownTagVersionsTtl() $item->tag(['baz']); $item->expiresAfter(100); - $tag = $tagsPool->getItem('baz'.TagAwareAdapter::TAGS_PREFIX); - $tagsPool->save($tag->set(10)); - $pool->save($item); $this->assertTrue($pool->getItem('foo')->isHit()); - $this->assertTrue($pool->getItem('foo')->isHit()); - sleep(20); + $tagsPool->deleteItem('#baz'); $this->assertTrue($pool->getItem('foo')->isHit()); sleep(5); $this->assertTrue($pool->getItem('foo')->isHit()); - } - - public function testTagEntryIsCreatedForItemWithoutTags() - { - $pool = $this->createCachePool(); - - $itemKey = 'foo'; - $item = $pool->getItem($itemKey); - $pool->save($item); - - $adapter = new FilesystemAdapter(); - $this->assertTrue($adapter->hasItem(TagAwareAdapter::TAGS_PREFIX.$itemKey)); - } - - public function testHasItemReturnsFalseWhenPoolDoesNotHaveItemTags() - { - $pool = $this->createCachePool(); - - $itemKey = 'foo'; - $item = $pool->getItem($itemKey); - $pool->save($item); - $anotherPool = $this->createCachePool(); - - $adapter = new FilesystemAdapter(); - $adapter->deleteItem(TagAwareAdapter::TAGS_PREFIX.$itemKey); //simulate item losing tags pair - - $this->assertFalse($anotherPool->hasItem($itemKey)); - } - - public function testGetItemReturnsCacheMissWhenPoolDoesNotHaveItemTags() - { - $pool = $this->createCachePool(); - - $itemKey = 'foo'; - $item = $pool->getItem($itemKey); - $pool->save($item); - - $anotherPool = $this->createCachePool(); - - $adapter = new FilesystemAdapter(); - $adapter->deleteItem(TagAwareAdapter::TAGS_PREFIX.$itemKey); //simulate item losing tags pair - - $item = $anotherPool->getItem($itemKey); - $this->assertFalse($item->isHit()); - } - - public function testHasItemReturnsFalseWhenPoolDoesNotHaveItemAndOnlyHasTags() - { - $pool = $this->createCachePool(); - - $itemKey = 'foo'; - $item = $pool->getItem($itemKey); - $pool->save($item); - - $anotherPool = $this->createCachePool(); - - $adapter = new FilesystemAdapter(); - $adapter->deleteItem($itemKey); //simulate losing item but keeping tags + sleep(20); - $this->assertFalse($anotherPool->hasItem($itemKey)); + $this->assertFalse($pool->getItem('foo')->isHit()); } public function testInvalidateTagsWithArrayAdapter() @@ -158,21 +97,65 @@ public function testInvalidateTagsWithArrayAdapter() $this->assertFalse($adapter->getItem('foo')->isHit()); } - public function testGetItemReturnsCacheMissWhenPoolDoesNotHaveItemAndOnlyHasTags() + /** + * @dataProvider providePackedItemValue + */ + public function testUnpackCacheItem($packedItemValue, $isValid, $value) { $pool = $this->createCachePool(); - $itemKey = 'foo'; - $item = $pool->getItem($itemKey); - $pool->save($item); - - $anotherPool = $this->createCachePool(); $adapter = new FilesystemAdapter(); - $adapter->deleteItem($itemKey); //simulate losing item but keeping tags + $item = $adapter->getItem('$'.$itemKey); + $adapter->save($item->set($packedItemValue)); - $item = $anotherPool->getItem($itemKey); - $this->assertFalse($item->isHit()); + $item = $pool->getItem($itemKey); + $this->assertSame($isValid, $item->isHit()); + $this->assertEquals($value, $item->get()); + + foreach ($pool->getItems([$itemKey]) as $item) { + $this->assertSame($isValid, $item->isHit()); + $this->assertEquals($value, $item->get()); + } + } + + public function providePackedItemValue() + { + return [ + // missed fields + [[], false, null], + [['$' => ''], false, null], + [['#' => []], false, null], + [['$' => '', '^' => ''], false, null], + [['#' => [], '^' => ''], false, null], + // extra fields + [[null, '$' => '', '#' => []], false, null], + [['$' => '', '#' => [], '' => ''], false, null], + // wrong order of fields + [['#' => [], '$' => ''], false, null], + [['$' => '$', '^' => '', '#' => []], false, null], + [['^' => '', '$' => '', '#' => []], false, null], + // bad types + [null, false, null], + [serialize(['$' => '$', '#' => []]), false, null], + [(object) ['$' => '$', '#' => []], false, null], + [['$' => '', '#' => ''], false, null], + [['$' => '', '#' => null], false, null], + [['$' => '', '#' => new \stdClass()], false, null], + [['$' => '', '#' => [], '^' => []], false, null], + [['$' => '', '#' => [], '^' => null], false, null], + [['$' => '', '#' => [], '^' => new \stdClass()], false, null], + // good items + [['$' => 0, '#' => []], true, 0], + [['$' => '0', '#' => []], true, '0'], + [['$' => [''], '#' => []], true, ['']], + [['$' => (object) ['$' => '$'], '#' => []], true, (object) ['$' => '$']], + [['$' => null, '#' => []], true, null], + [['$' => null, '#' => [], '^' => ''], true, null], + [['$' => '1', '#' => [], '^' => ''], true, '1'], + [['$' => [[0]], '#' => [], '^' => ''], true, [[0]]], + [['$' => serialize((object) ['$' => '$']), '#' => [], '^' => ''], true, serialize((object) ['$' => '$'])], + ]; } private function getPruneableMock(): PruneableInterface&MockObject From 6a90ca0fef83a0bb27839b461a5f2396a93d90f6 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 21 Mar 2022 19:09:48 +0100 Subject: [PATCH 2/2] [Cache] Improve packing tags into items --- composer.json | 3 + .../Cache/Adapter/AbstractAdapter.php | 16 +- .../Cache/Adapter/AbstractTagAwareAdapter.php | 9 +- .../Component/Cache/Adapter/ArrayAdapter.php | 26 +- .../Component/Cache/Adapter/ChainAdapter.php | 1 - .../Component/Cache/Adapter/ProxyAdapter.php | 48 +-- .../Cache/Adapter/TagAwareAdapter.php | 407 +++++++----------- src/Symfony/Component/Cache/CacheItem.php | 35 ++ src/Symfony/Component/Cache/Psr16Cache.php | 27 +- .../Tests/Adapter/TagAwareAdapterTest.php | 71 +-- .../Component/Cache/Traits/ContractsTrait.php | 2 +- .../Component/Cache/Traits/ValueWrapper.php | 83 ++++ src/Symfony/Component/Cache/composer.json | 3 + 13 files changed, 335 insertions(+), 396 deletions(-) create mode 100644 src/Symfony/Component/Cache/Traits/ValueWrapper.php diff --git a/composer.json b/composer.json index 77f1cd83e63e4..87dca514ffa16 100644 --- a/composer.json +++ b/composer.json @@ -175,6 +175,9 @@ "files": [ "src/Symfony/Component/String/Resources/functions.php" ], + "classmap": [ + "src/Symfony/Component/Cache/Traits/ValueWrapper.php" + ], "exclude-from-classmap": [ "**/Tests/" ] diff --git a/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php b/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php index 666e6049a858e..74188645e8a1b 100644 --- a/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php @@ -49,15 +49,7 @@ static function ($key, $value, $isHit) { $item->key = $key; $item->value = $v = $value; $item->isHit = $isHit; - // Detect wrapped values that encode for their expiry and creation duration - // For compactness, these values are packed in the key of an array using - // magic numbers in the form 9D-..-..-..-..-00-..-..-..-5F - if (\is_array($v) && 1 === \count($v) && 10 === \strlen($k = (string) array_key_first($v)) && "\x9D" === $k[0] && "\0" === $k[5] && "\x5F" === $k[9]) { - $item->value = $v[$k]; - $v = unpack('Ve/Nc', substr($k, 1, -1)); - $item->metadata[CacheItem::METADATA_EXPIRY] = $v['e'] + CacheItem::METADATA_EXPIRY_OFFSET; - $item->metadata[CacheItem::METADATA_CTIME] = $v['c']; - } + $item->unpack(); return $item; }, @@ -80,11 +72,7 @@ static function ($deferred, $namespace, &$expiredIds, $getId, $defaultLifetime) $expiredIds[] = $getId($key); continue; } - if (isset(($metadata = $item->newMetadata)[CacheItem::METADATA_TAGS])) { - unset($metadata[CacheItem::METADATA_TAGS]); - } - // For compactness, expiry and creation duration are packed in the key of an array, using magic numbers as separators - $byLifetime[$ttl][$getId($key)] = $metadata ? ["\x9D".pack('VN', (int) (0.1 + $metadata[self::METADATA_EXPIRY] - self::METADATA_EXPIRY_OFFSET), $metadata[self::METADATA_CTIME])."\x5F" => $item->value] : $item->value; + $byLifetime[$ttl][$getId($key)] = $item->pack(); } return $byLifetime; diff --git a/src/Symfony/Component/Cache/Adapter/AbstractTagAwareAdapter.php b/src/Symfony/Component/Cache/Adapter/AbstractTagAwareAdapter.php index 65a33a9b0ee7e..ca934f8e1b932 100644 --- a/src/Symfony/Component/Cache/Adapter/AbstractTagAwareAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/AbstractTagAwareAdapter.php @@ -56,7 +56,7 @@ static function ($key, $value, $isHit) { $item->isHit = $isHit; // Extract value, tags and meta data from the cache value $item->value = $value['value']; - $item->metadata[CacheItem::METADATA_TAGS] = $value['tags'] ?? []; + $item->metadata[CacheItem::METADATA_TAGS] = isset($value['tags']) ? array_combine($value['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']); @@ -95,18 +95,19 @@ static function ($deferred, &$expiredIds, $getId, $tagPrefix, $defaultLifetime) if ($metadata) { // For compactness, expiry and creation duration are packed, using magic numbers as separators - $value['meta'] = pack('VN', (int) (0.1 + $metadata[self::METADATA_EXPIRY] - self::METADATA_EXPIRY_OFFSET), $metadata[self::METADATA_CTIME]); + $value['meta'] = pack('VN', (int) (0.1 + $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) { + foreach (array_diff_key($value['tags'], $oldTags) as $addedTag) { $value['tag-operations']['add'][] = $getId($tagPrefix.$addedTag); } - foreach (array_diff($oldTags, $value['tags']) as $removedTag) { + foreach (array_diff_key($oldTags, $value['tags']) as $removedTag) { $value['tag-operations']['remove'][] = $getId($tagPrefix.$removedTag); } + $value['tags'] = array_keys($value['tags']); $byLifetime[$ttl][$getId($key)] = $value; $item->metadata = $item->newMetadata; diff --git a/src/Symfony/Component/Cache/Adapter/ArrayAdapter.php b/src/Symfony/Component/Cache/Adapter/ArrayAdapter.php index d6eb152f08fcb..f687074011dec 100644 --- a/src/Symfony/Component/Cache/Adapter/ArrayAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/ArrayAdapter.php @@ -32,6 +32,7 @@ class ArrayAdapter implements AdapterInterface, CacheInterface, LoggerAwareInter private bool $storeSerialized; private array $values = []; + private array $tags = []; private array $expiries = []; private int $defaultLifetime; private float $maxLifetime; @@ -57,11 +58,14 @@ public function __construct(int $defaultLifetime = 0, bool $storeSerialized = tr $this->maxLifetime = $maxLifetime; $this->maxItems = $maxItems; self::$createCacheItem ?? self::$createCacheItem = \Closure::bind( - static function ($key, $value, $isHit) { + static function ($key, $value, $isHit, $tags) { $item = new CacheItem(); $item->key = $key; $item->value = $value; $item->isHit = $isHit; + if (null !== $tags) { + $item->metadata[CacheItem::METADATA_TAGS] = $tags; + } return $item; }, @@ -131,7 +135,7 @@ public function getItem(mixed $key): CacheItem $value = $this->storeSerialized ? $this->unfreeze($key, $isHit) : $this->values[$key]; } - return (self::$createCacheItem)($key, $value, $isHit); + return (self::$createCacheItem)($key, $value, $isHit, $this->tags[$key] ?? null); } /** @@ -150,7 +154,7 @@ public function getItems(array $keys = []): iterable public function deleteItem(mixed $key): bool { \assert('' !== CacheItem::validateKey($key)); - unset($this->values[$key], $this->expiries[$key]); + unset($this->values[$key], $this->tags[$key], $this->expiries[$key]); return true; } @@ -202,7 +206,7 @@ public function save(CacheItemInterface $item): bool } if ($this->maxItems) { - unset($this->values[$key]); + unset($this->values[$key], $this->tags[$key]); // Iterate items and vacuum expired ones while we are at it foreach ($this->values as $k => $v) { @@ -210,13 +214,17 @@ public function save(CacheItemInterface $item): bool break; } - unset($this->values[$k], $this->expiries[$k]); + unset($this->values[$k], $this->tags[$k], $this->expiries[$k]); } } $this->values[$key] = $value; $this->expiries[$key] = $expiry ?? \PHP_INT_MAX; + if (null === $this->tags[$key] = $item["\0*\0newMetadata"][CacheItem::METADATA_TAGS] ?? null) { + unset($this->tags[$key]); + } + return true; } @@ -246,7 +254,7 @@ public function clear(string $prefix = ''): bool foreach ($this->values as $key => $value) { if (!isset($this->expiries[$key]) || $this->expiries[$key] <= $now || str_starts_with($key, $prefix)) { - unset($this->values[$key], $this->expiries[$key]); + unset($this->values[$key], $this->tags[$key], $this->expiries[$key]); } } @@ -255,7 +263,7 @@ public function clear(string $prefix = ''): bool } } - $this->values = $this->expiries = []; + $this->values = $this->tags = $this->expiries = []; return true; } @@ -312,7 +320,7 @@ private function generateItems(array $keys, float $now, \Closure $f): \Generator } unset($keys[$i]); - yield $key => $f($key, $value, $isHit); + yield $key => $f($key, $value, $isHit, $this->tags[$key] ?? null); } foreach ($keys as $key) { @@ -334,7 +342,7 @@ private function freeze($value, string $key) try { $serialized = serialize($value); } catch (\Exception $e) { - unset($this->values[$key]); + unset($this->values[$key], $this->tags[$key]); $type = get_debug_type($value); $message = sprintf('Failed to save key "{key}" of type %s: %s', $type, $e->getMessage()); CacheItem::log($this->logger, $message, ['key' => $key, 'exception' => $e, 'cache-adapter' => get_debug_type($this)]); diff --git a/src/Symfony/Component/Cache/Adapter/ChainAdapter.php b/src/Symfony/Component/Cache/Adapter/ChainAdapter.php index 64ae546440aad..bd840367c37bf 100644 --- a/src/Symfony/Component/Cache/Adapter/ChainAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/ChainAdapter.php @@ -70,7 +70,6 @@ public function __construct(array $adapters, int $defaultLifetime = 0) static function ($sourceItem, $item, $defaultLifetime, $sourceMetadata = null) { $sourceItem->isTaggable = false; $sourceMetadata ??= $sourceItem->metadata; - unset($sourceMetadata[CacheItem::METADATA_TAGS]); $item->value = $sourceItem->value; $item->isHit = $sourceItem->isHit; diff --git a/src/Symfony/Component/Cache/Adapter/ProxyAdapter.php b/src/Symfony/Component/Cache/Adapter/ProxyAdapter.php index 4a2617d2d6b69..d2cd0b9428339 100644 --- a/src/Symfony/Component/Cache/Adapter/ProxyAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/ProxyAdapter.php @@ -46,7 +46,7 @@ public function __construct(CacheItemPoolInterface $pool, string $namespace = '' } $this->namespaceLen = \strlen($namespace); $this->defaultLifetime = $defaultLifetime; - self::$createCacheItem ?? self::$createCacheItem = \Closure::bind( + self::$createCacheItem ??= \Closure::bind( static function ($key, $innerItem, $poolHash) { $item = new CacheItem(); $item->key = $key; @@ -55,20 +55,12 @@ static function ($key, $innerItem, $poolHash) { return $item; } - $item->value = $v = $innerItem->get(); + $item->value = $innerItem->get(); $item->isHit = $innerItem->isHit(); $item->innerItem = $innerItem; $item->poolHash = $poolHash; - // Detect wrapped values that encode for their expiry and creation duration - // For compactness, these values are packed in the key of an array using - // magic numbers in the form 9D-..-..-..-..-00-..-..-..-5F - if (\is_array($v) && 1 === \count($v) && 10 === \strlen($k = (string) array_key_first($v)) && "\x9D" === $k[0] && "\0" === $k[5] && "\x5F" === $k[9]) { - $item->value = $v[$k]; - $v = unpack('Ve/Nc', substr($k, 1, -1)); - $item->metadata[CacheItem::METADATA_EXPIRY] = $v['e'] + CacheItem::METADATA_EXPIRY_OFFSET; - $item->metadata[CacheItem::METADATA_CTIME] = $v['c']; - } elseif ($innerItem instanceof CacheItem) { + if (!$item->unpack() && $innerItem instanceof CacheItem) { $item->metadata = $innerItem->metadata; } $innerItem->set(null); @@ -78,17 +70,10 @@ static function ($key, $innerItem, $poolHash) { null, CacheItem::class ); - self::$setInnerItem ?? self::$setInnerItem = \Closure::bind( - /** - * @param array $item A CacheItem cast to (array); accessing protected properties requires adding the "\0*\0" PHP prefix - */ - static function (CacheItemInterface $innerItem, array $item) { - if ($metadata = $item["\0*\0newMetadata"]) { - // For compactness, expiry and creation duration are packed in the key of an array, using magic numbers as separators - $item["\0*\0value"] = ["\x9D".pack('VN', (int) (0.1 + $metadata[self::METADATA_EXPIRY] - self::METADATA_EXPIRY_OFFSET), $metadata[self::METADATA_CTIME])."\x5F" => $item["\0*\0value"]]; - } - $innerItem->set($item["\0*\0value"]); - $innerItem->expiresAt(null !== $item["\0*\0expiry"] ? \DateTime::createFromFormat('U.u', sprintf('%.6F', $item["\0*\0expiry"])) : null); + self::$setInnerItem ??= \Closure::bind( + static function (CacheItemInterface $innerItem, CacheItem $item, $expiry = null) { + $innerItem->set($item->pack()); + $innerItem->expiresAt(($expiry ?? $item->expiry) ? \DateTime::createFromFormat('U.u', sprintf('%.6F', $expiry ?? $item->expiry)) : null); }, null, CacheItem::class @@ -107,7 +92,7 @@ public function get(string $key, callable $callback, float $beta = null, array & return $this->pool->get($this->getId($key), function ($innerItem, bool &$save) use ($key, $callback) { $item = (self::$createCacheItem)($key, $innerItem, $this->poolHash); $item->set($value = $callback($item, $save)); - (self::$setInnerItem)($innerItem, (array) $item); + (self::$setInnerItem)($innerItem, $item); return $value; }, $beta, $metadata); @@ -208,22 +193,23 @@ private function doSave(CacheItemInterface $item, string $method): bool if (!$item instanceof CacheItem) { return false; } - $item = (array) $item; - if (null === $item["\0*\0expiry"] && 0 < $this->defaultLifetime) { - $item["\0*\0expiry"] = microtime(true) + $this->defaultLifetime; + $castItem = (array) $item; + + if (null === $castItem["\0*\0expiry"] && 0 < $this->defaultLifetime) { + $castItem["\0*\0expiry"] = microtime(true) + $this->defaultLifetime; } - if ($item["\0*\0poolHash"] === $this->poolHash && $item["\0*\0innerItem"]) { - $innerItem = $item["\0*\0innerItem"]; + if ($castItem["\0*\0poolHash"] === $this->poolHash && $castItem["\0*\0innerItem"]) { + $innerItem = $castItem["\0*\0innerItem"]; } elseif ($this->pool instanceof AdapterInterface) { // this is an optimization specific for AdapterInterface implementations // so we can save a round-trip to the backend by just creating a new item - $innerItem = (self::$createCacheItem)($this->namespace.$item["\0*\0key"], null, $this->poolHash); + $innerItem = (self::$createCacheItem)($this->namespace.$castItem["\0*\0key"], null, $this->poolHash); } else { - $innerItem = $this->pool->getItem($this->namespace.$item["\0*\0key"]); + $innerItem = $this->pool->getItem($this->namespace.$castItem["\0*\0key"]); } - (self::$setInnerItem)($innerItem, $item); + (self::$setInnerItem)($innerItem, $item, $castItem["\0*\0expiry"]); return $this->pool->$method($innerItem); } diff --git a/src/Symfony/Component/Cache/Adapter/TagAwareAdapter.php b/src/Symfony/Component/Cache/Adapter/TagAwareAdapter.php index 5e8f4871087a1..7e7395b71281a 100644 --- a/src/Symfony/Component/Cache/Adapter/TagAwareAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/TagAwareAdapter.php @@ -22,13 +22,13 @@ use Symfony\Contracts\Cache\TagAwareCacheInterface; /** - * Implements simple and robust tag-based invalidation algorithm suitable for use with volatile caches. + * Implements simple and robust tag-based invalidation suitable for use with volatile caches. * - * Tags point to a separate keys a values of which are current tag versions. Values of tagged items contain - * tag versions as an integral part and remain valid until any of their tag versions are changed. - * Invalidation is achieved by deleting tags, thereby ensuring change of their versions even when the storage is out of - * space. When versions of non-existing tags are requested for item commits or for validation of retrieved items, - * adapter creates tags and assigns a new random version to them. + * This adapter works by storing a version for each tags. When saving an item, it is stored together with its tags and + * their corresponding versions. When retrieveing an item, those tag versions are compared to the current version of + * each tags. Invalidation is achieved by deleting tags, thereby ensuring that their versions change even when the + * storage is out of space. When versions of non-existing tags are requested for item commits, this adapter assigns a + * new random version to them. * * @author Nicolas Grekas * @author Sergey Belyshkin @@ -39,8 +39,6 @@ class TagAwareAdapter implements TagAwareAdapterInterface, TagAwareCacheInterfac use LoggerAwareTrait; public const TAGS_PREFIX = "\0tags\0"; - private const ITEM_PREFIX = '$'; - private const TAG_PREFIX = '#'; private const MAX_NUMBER_OF_KNOWN_TAG_VERSIONS = 1000; private array $deferred = []; @@ -49,10 +47,9 @@ class TagAwareAdapter implements TagAwareAdapterInterface, TagAwareCacheInterfac private array $knownTagVersions = []; private float $knownTagVersionsTtl; - private static \Closure $unpackCacheItem; - private static \Closure $unsetCacheItem; - private static \Closure $computeAndPackItems; - private static \Closure $extractTagsFromItems; + private static \Closure $setCacheItemTags; + private static \Closure $setTagVersions; + private static \Closure $getTagsByKey; private static \Closure $saveTags; public function __construct(AdapterInterface $itemsPool, AdapterInterface $tagsPool = null, float $knownTagVersionsTtl = 0.15) @@ -60,104 +57,53 @@ public function __construct(AdapterInterface $itemsPool, AdapterInterface $tagsP $this->pool = $itemsPool; $this->tags = $tagsPool ?? $itemsPool; $this->knownTagVersionsTtl = $knownTagVersionsTtl; - self::$unpackCacheItem ??= \Closure::bind( - static function (CacheItem $item, string $key): array { - $item->key = $key; - $item->isTaggable = true; - if (!$item->isHit) { - return []; - } - $value = $item->value; - if (!\is_array($value) || !((['$', '#', '^'] === ($arrayKeys = array_keys($value)) && \is_string($value['^']) || ['$', '#'] === $arrayKeys) && \is_array($value['#']))) { - $item->isHit = false; - $item->value = null; - - return []; - } - $item->value = $value['$']; - if ($value['#']) { - $tags = []; - foreach ($value['#'] as $tag => $tagVersion) { - $tags[$tag] = $tag; + self::$setCacheItemTags ??= \Closure::bind( + static function (array $items, array $itemTags) { + foreach ($items as $key => $item) { + $item->isTaggable = true; + + if (isset($itemTags[$key])) { + $tags = array_keys($itemTags[$key]); + $item->metadata[CacheItem::METADATA_TAGS] = array_combine($tags, $tags); + } else { + $item->value = null; + $item->isHit = false; + $item->metadata = []; } - $item->metadata[CacheItem::METADATA_TAGS] = $tags; - } - if (isset($value['^'])) { - $m = unpack('Ne/Vc', str_pad($value['^'], 8, "\x00")); - $item->metadata[CacheItem::METADATA_EXPIRY] = $m['e']; - $item->metadata[CacheItem::METADATA_CTIME] = $m['c']; } - return $value['#']; - }, - null, - CacheItem::class - ); - self::$unsetCacheItem ??= \Closure::bind( - static function (CacheItem $item) { - $item->isHit = false; - $item->value = null; - $item->metadata = []; + return $items; }, null, CacheItem::class ); - $getPrefixedKeyMethod = \Closure::fromCallable([$this, 'getPrefixedKey']); - self::$computeAndPackItems ??= \Closure::bind( - static function ($deferred, $tagVersions) use ($getPrefixedKeyMethod) { - $packedItems = []; - foreach ($deferred as $key => $item) { - $itemTagVersions = []; - $metadata = $item->newMetadata; - if (isset($metadata[CacheItem::METADATA_TAGS])) { - foreach ($metadata[CacheItem::METADATA_TAGS] as $tag) { - if (!isset($tagVersions[$tag])) { - // Don't save items without full set of valid tags - continue 2; - } - $itemTagVersions[$tag] = $tagVersions[$tag]; - } - } - // Pack the value, tags and meta data. - $value = ['$' => $item->value, '#' => $itemTagVersions]; - if (isset($metadata[CacheItem::METADATA_CTIME])) { - $ctime = $metadata[CacheItem::METADATA_CTIME]; - // 1. 03:14:08 UTC on Tuesday, 19 January 2038 timestamp will reach 0x7FFFFFFF and 32-bit systems - // will go back to Unix Epoch, but on 64-bit systems it's OK to use first 32 bits of timestamp - // till 06:28:15 UTC on Sunday, 7 February 2106, when it'll reach 0xFFFFFFFF. - // 2. CTIME is packed as an 8/16/24/32-bits integer. For reference, 24 bits are able to reflect - // intervals up to 4 hours 39 minutes 37 seconds and 215 ms, but in most cases 8 bits are enough. - $length = 4 + ($ctime <= 255 ? 1 : ($ctime <= 65535 ? 2 : ($ctime <= 16777215 ? 3 : 4))); - $value['^'] = substr(pack('NV', (int) ceil($metadata[CacheItem::METADATA_EXPIRY]), $ctime), 0, $length); - } - $packedItem = clone $item; - $packedItem->metadata = $packedItem->newMetadata = []; - $packedItem->key = $getPrefixedKeyMethod($key); - $packedItem->value = $value; - $packedItems[] = $packedItem; - - $item->metadata = $metadata; + self::$setTagVersions ??= \Closure::bind( + static function (array $items, array $tagVersions) { + $now = null; + foreach ($items as $key => $item) { + $item->newMetadata[CacheItem::METADATA_TAGS] = array_intersect_key($tagVersions, $item->newMetadata[CacheItem::METADATA_TAGS] ?? []); } - - return $packedItems; }, null, CacheItem::class ); - self::$extractTagsFromItems ??= \Closure::bind( + self::$getTagsByKey ??= \Closure::bind( static function ($deferred) { - $uniqueTags = []; - foreach ($deferred as $item) { - $uniqueTags += $item->newMetadata[CacheItem::METADATA_TAGS] ?? []; + $tagsByKey = []; + foreach ($deferred as $key => $item) { + $tagsByKey[$key] = $item->newMetadata[CacheItem::METADATA_TAGS] ?? []; + $item->metadata = $item->newMetadata; } - return $uniqueTags; + return $tagsByKey; }, null, CacheItem::class ); self::$saveTags ??= \Closure::bind( static function (AdapterInterface $tagsAdapter, array $tags) { + ksort($tags); + foreach ($tags as $v) { $v->expiry = 0; $tagsAdapter->saveDeferred($v); @@ -179,7 +125,7 @@ public function invalidateTags(array $tags): bool foreach ($tags as $tag) { \assert('' !== CacheItem::validateKey($tag)); unset($this->knownTagVersions[$tag]); - $ids[] = static::TAG_PREFIX.$tag; + $ids[] = $tag.static::TAGS_PREFIX; } return !$tags || $this->tags->deleteItems($ids); @@ -190,17 +136,7 @@ public function invalidateTags(array $tags): bool */ public function hasItem(mixed $key): bool { - if (\is_string($key) && isset($this->deferred[$key])) { - $this->commit(); - } - - if (!$this->pool->hasItem($this->getPrefixedKey($key))) { - return false; - } - - $item = $this->getItem($key); - - return $item->isHit(); + return $this->getItem($key)->isHit(); } /** @@ -208,37 +144,9 @@ public function hasItem(mixed $key): bool */ public function getItem(mixed $key): CacheItem { - $prefixedKey = $this->getPrefixedKey($key); - - if (isset($this->deferred[$key])) { - $this->commit(); - } - - $item = $this->pool->getItem($prefixedKey); - $itemTagVersions = (self::$unpackCacheItem)($item, $key); - - while (true) { - $knownTagVersions = $this->knownTagVersions; - $now = microtime(true); - foreach ($itemTagVersions as $itemTag => $itemTagVersion) { - if (($knownTagVersions[$itemTag][0] ?? 0.0) < $now || $knownTagVersions[$itemTag][1] !== $itemTagVersion) { - break 2; - } - } - + foreach ($this->getItems([$key]) as $item) { return $item; } - $knownTagVersions = null; - - $tagVersions = $this->getTagVersions(array_keys($itemTagVersions)); - foreach ($itemTagVersions as $itemTag => $itemTagVersion) { - if (!isset($tagVersions[$itemTag]) || $tagVersions[$itemTag] !== $itemTagVersion) { - (self::$unsetCacheItem)($item); - break; - } - } - - return $item; } /** @@ -246,40 +154,58 @@ public function getItem(mixed $key): CacheItem */ public function getItems(array $keys = []): iterable { - $items = $itemIdsMap = $itemTagVersions = $tagVersions = []; + $tagKeys = []; $commit = false; foreach ($keys as $key) { - $itemIdsMap[$this->getPrefixedKey($key)] = $key; - $commit = $commit || isset($this->deferred[$key]); + if ('' !== $key && \is_string($key)) { + $commit = $commit || isset($this->deferred[$key]); + $key = static::TAGS_PREFIX.$key; + $tagKeys[$key] = $key; // BC with pools populated before v6.1 + } } if ($commit) { $this->commit(); } - $validateAgainstKnownTagVersions = !empty($this->knownTagVersions); - $f = self::$unpackCacheItem; - foreach ($this->pool->getItems(array_keys($itemIdsMap)) as $itemId => $item) { - $key = $itemIdsMap[$itemId]; - $itemTagVersions[$key] = $t = ($f)($item, $key); - $items[$key] = $item; - if (!$t) { + try { + $items = $this->pool->getItems($tagKeys + $keys); + } catch (InvalidArgumentException $e) { + $this->pool->getItems($keys); // Should throw an exception + + throw $e; + } + + $bufferedItems = $itemTags = []; + + foreach ($items as $key => $item) { + if (isset($tagKeys[$key])) { // BC with pools populated before v6.1 + if ($item->isHit()) { + $itemTags[substr($key, \strlen(static::TAGS_PREFIX))] = $item->get() ?: []; + } continue; } - $tagVersions += $t; - if ($validateAgainstKnownTagVersions) { - foreach ($t as $tag => $tagVersion) { - if ($tagVersions[$tag] !== $tagVersion) { - $validateAgainstKnownTagVersions = false; - break; - } + + if (null !== $tags = $item->getMetadata()[CacheItem::METADATA_TAGS] ?? null) { + $itemTags[$key] = $tags; + } + + $bufferedItems[$key] = $item; + } + + $tagVersions = $this->getTagVersions($itemTags, false); + foreach ($itemTags as $key => $tags) { + foreach ($tags as $tag => $version) { + if ($tagVersions[$tag] !== $version) { + unset($itemTags[$key]); + continue 2; } } } - $itemIdsMap = null; + $tagVersions = null; - return $this->generateItems($items, $itemTagVersions, $tagVersions, $validateAgainstKnownTagVersions); + return (self::$setCacheItemTags)($bufferedItems, $itemTags); } /** @@ -287,18 +213,6 @@ public function getItems(array $keys = []): iterable */ public function clear(string $prefix = ''): bool { - if ($this->pool instanceof AdapterInterface) { - $isPoolCleared = $this->pool->clear(self::ITEM_PREFIX.$prefix); - } else { - $isPoolCleared = $this->pool->clear(); - } - - if ($this->tags instanceof AdapterInterface) { - $isTagPoolCleared = $this->tags->clear(static::TAG_PREFIX.$prefix); - } else { - $isTagPoolCleared = $this->tags->clear(); - } - if ('' !== $prefix) { foreach ($this->deferred as $key => $item) { if (str_starts_with($key, $prefix)) { @@ -309,7 +223,11 @@ public function clear(string $prefix = ''): bool $this->deferred = []; } - return $isPoolCleared && $isTagPoolCleared; + if ($this->pool instanceof AdapterInterface) { + return $this->pool->clear($prefix); + } + + return $this->pool->clear(); } /** @@ -317,7 +235,7 @@ public function clear(string $prefix = ''): bool */ public function deleteItem(mixed $key): bool { - return $this->pool->deleteItem($this->getPrefixedKey($key)); + return $this->deleteItems([$key]); } /** @@ -325,9 +243,13 @@ public function deleteItem(mixed $key): bool */ public function deleteItems(array $keys): bool { - $prefixedKeys = array_map([$this, 'getPrefixedKey'], $keys); + foreach ($keys as $key) { + if ('' !== $key && \is_string($key)) { + $keys[] = static::TAGS_PREFIX.$key; // BC with pools populated before v6.1 + } + } - return $this->pool->deleteItems($prefixedKeys); + return $this->pool->deleteItems($keys); } /** @@ -335,7 +257,12 @@ public function deleteItems(array $keys): bool */ public function save(CacheItemInterface $item): bool { - return $this->saveDeferred($item) && $this->commit(); + if (!$item instanceof CacheItem) { + return false; + } + $this->deferred[$item->getKey()] = $item; + + return $this->commit(); } /** @@ -356,21 +283,27 @@ public function saveDeferred(CacheItemInterface $item): bool */ public function commit(): bool { - if (!$this->deferred) { + if (!$items = $this->deferred) { return true; } - $uniqueTags = (self::$extractTagsFromItems)($this->deferred); - $tagVersions = $this->getTagVersions($uniqueTags); - $packedItems = (self::$computeAndPackItems)($this->deferred, $tagVersions); - $allItemsArePacked = \count($this->deferred) === \count($packedItems); - $this->deferred = []; + $tagVersions = $this->getTagVersions((self::$getTagsByKey)($items), true); + (self::$setTagVersions)($items, $tagVersions); - foreach ($packedItems as $item) { - $this->pool->saveDeferred($item); + $ok = true; + foreach ($items as $key => $item) { + if ($this->pool->saveDeferred($item)) { + unset($this->deferred[$key]); + } else { + $ok = false; + } } + $ok = $this->pool->commit() && $ok; + + $tagVersions = array_keys($tagVersions); + (self::$setTagVersions)($items, array_combine($tagVersions, $tagVersions)); - return $this->pool->commit() && $allItemsArePacked; + return $ok; } /** @@ -378,11 +311,12 @@ public function commit(): bool */ public function prune(): bool { - $isPruned = $this->pool instanceof PruneableInterface && $this->pool->prune(); - - return $this->tags instanceof PruneableInterface && $this->tags->prune() && $isPruned; + return $this->pool instanceof PruneableInterface && $this->pool->prune(); } + /** + * {@inheritdoc} + */ public function reset() { $this->commit(); @@ -406,97 +340,62 @@ public function __destruct() $this->commit(); } - private function generateItems(array $items, array $itemTagVersions, array $tagVersions, bool $validateAgainstKnownTagVersions = false): \Generator + private function getTagVersions(array $tagsByKey, bool $persistTags): array { - if ($validateAgainstKnownTagVersions) { - $knownTagVersions = $this->knownTagVersions; - $now = microtime(true); - foreach ($itemTagVersions as $itemTag => $itemTagVersion) { - if (($knownTagVersions[$itemTag][0] ?? 0.0) < $now || $knownTagVersions[$itemTag][1] !== $itemTagVersion) { - $validateAgainstKnownTagVersions = false; - break; - } - } - } - if (!$validateAgainstKnownTagVersions) { - $tagVersions = $this->getTagVersions(array_keys($tagVersions)); - foreach ($items as $key => $item) { - foreach ($itemTagVersions[$key] as $itemTag => $itemTagVersion) { - if (!isset($tagVersions[$itemTag]) || $tagVersions[$itemTag] !== $itemTagVersion) { - (self::$unsetCacheItem)($item); - break; - } + $tagVersions = []; + $fetchTagVersions = false; + + foreach ($tagsByKey as $tags) { + $tagVersions += $tags; + + foreach ($tags as $tag => $version) { + if ($tagVersions[$tag] !== $version) { + unset($this->knownTagVersions[$tag]); } - yield $key => $item; - } - } else { - foreach ($items as $key => $item) { - yield $key => $item; } } - } - /** - * Loads tag versions from or creates them in the tag pool, and updates the cache of known tag versions. - * - * May return only a part of requested tags or even none of them if for some reason they cannot be read or created. - * - * @throws InvalidArgumentException - * - * @return string[] - */ - private function getTagVersions(array $tags): array - { - if (!$tags) { + if (!$tagVersions) { return []; } - $tagIdsMap = $tagVersions = $createdTagVersions = $createdTags = []; - foreach ($tags as $tag) { - $tagIdsMap[static::TAG_PREFIX.$tag] = $tag; - } - ksort($tagIdsMap); - - if (0.0 < $this->knownTagVersionsTtl) { - $now = microtime(true); - $knownTagVersionsExpiration = $now + $this->knownTagVersionsTtl; - if (self::MAX_NUMBER_OF_KNOWN_TAG_VERSIONS < \count($this->knownTagVersions)) { - $this->knownTagVersions = array_filter($this->knownTagVersions, static function ($v) use ($now) { return $now < $v[0]; }); - } - foreach ($this->tags->getItems(array_keys($tagIdsMap)) as $tagId => $version) { - $tag = $tagIdsMap[$tagId]; - if ($version->isHit()) { - $tagVersions[$tag] = $version->get(); - $this->knownTagVersions[$tag] = [$knownTagVersionsExpiration, $tagVersions[$tag]]; - continue; - } - $createdTags[] = $version->set($newTagVersion ??= random_bytes(8)); - $createdTagVersions[$tag] = $newTagVersion; - unset($this->knownTagVersions[$tag]); + $now = microtime(true); + $tags = []; + foreach ($tagVersions as $tag => $version) { + $tags[$tag.static::TAGS_PREFIX] = $tag; + $knownTagVersion = $this->knownTagVersions[$tag] ?? [0, null]; + if ($fetchTagVersions || $knownTagVersion[1] !== $version || $now - $knownTagVersion[0] >= $this->knownTagVersionsTtl) { + // reuse previously fetched tag versions up to the ttl + $fetchTagVersions = true; } - } else { - foreach ($this->tags->getItems(array_keys($tagIdsMap)) as $tagId => $version) { - $tag = $tagIdsMap[$tagId]; - if ($version->isHit()) { - $tagVersions[$tag] = $version->get(); - continue; - } - $createdTags[] = $version->set($newTagVersion ??= random_bytes(8)); - $createdTagVersions[$tag] = $newTagVersion; + unset($this->knownTagVersions[$tag]); // For LRU tracking + if ([0, null] !== $knownTagVersion) { + $this->knownTagVersions[$tag] = $knownTagVersion; } } - if ($createdTags && !(self::$saveTags)($this->tags, $createdTags)) { - $createdTagVersions = []; + if (!$fetchTagVersions) { + return $tagVersions; } - return $tagVersions += $createdTagVersions; - } + $newTags = []; + $newVersion = null; + foreach ($this->tags->getItems(array_keys($tags)) as $tag => $version) { + if (!$version->isHit()) { + $newTags[$tag] = $version->set($newVersion ??= random_bytes(6)); + } + $tagVersions[$tag = $tags[$tag]] = $version->get(); + $this->knownTagVersions[$tag] = [$now, $tagVersions[$tag]]; + } - private function getPrefixedKey($key): string - { - \assert('' !== CacheItem::validateKey($key)); + if ($newTags && $persistTags) { + (self::$saveTags)($this->tags, $newTags); + } + + if (\count($this->knownTagVersions) > $maxTags = max(self::MAX_NUMBER_OF_KNOWN_TAG_VERSIONS, \count($newTags) << 1)) { + array_splice($this->knownTagVersions, 0, $maxTags >> 1); + } - return static::ITEM_PREFIX.$key; + return $tagVersions; } } diff --git a/src/Symfony/Component/Cache/CacheItem.php b/src/Symfony/Component/Cache/CacheItem.php index f6453ea7c004c..844237df30bc6 100644 --- a/src/Symfony/Component/Cache/CacheItem.php +++ b/src/Symfony/Component/Cache/CacheItem.php @@ -22,6 +22,7 @@ final class CacheItem implements ItemInterface { private const METADATA_EXPIRY_OFFSET = 1527506807; + private const VALUE_WRAPPER = "\xA9"; protected string $key; protected mixed $value = null; @@ -181,4 +182,38 @@ public static function log(?LoggerInterface $logger, string $message, array $con @trigger_error(strtr($message, $replace), \E_USER_WARNING); } } + + private function pack(): mixed + { + if (!$m = $this->newMetadata) { + return $this->value; + } + $valueWrapper = self::VALUE_WRAPPER; + + return new $valueWrapper($this->value, $m + ['expiry' => $this->expiry]); + } + + private function unpack(): bool + { + $v = $this->value; + $valueWrapper = self::VALUE_WRAPPER; + + if ($v instanceof $valueWrapper) { + $this->value = $v->value; + $this->metadata = $v->metadata; + + return true; + } + + if (!\is_array($v) || 1 !== \count($v) || 10 !== \strlen($k = (string) array_key_first($v)) || "\x9D" !== $k[0] || "\0" !== $k[5] || "\x5F" !== $k[9]) { + return false; + } + + // BC with pools populated before v6.1 + $this->value = $v[$k]; + $this->metadata = unpack('Vexpiry/Nctime', substr($k, 1, -1)); + $this->metadata['expiry'] += self::METADATA_EXPIRY_OFFSET; + + return true; + } } diff --git a/src/Symfony/Component/Cache/Psr16Cache.php b/src/Symfony/Component/Cache/Psr16Cache.php index 4d2936ca4c8c8..e36646ef0827b 100644 --- a/src/Symfony/Component/Cache/Psr16Cache.php +++ b/src/Symfony/Component/Cache/Psr16Cache.php @@ -28,10 +28,9 @@ class Psr16Cache implements CacheInterface, PruneableInterface, ResettableInterf { use ProxyTrait; - private const METADATA_EXPIRY_OFFSET = 1527506807; - private ?\Closure $createCacheItem = null; private ?CacheItem $cacheItemPrototype = null; + private static \Closure $packCacheItem; public function __construct(CacheItemPoolInterface $pool) { @@ -67,6 +66,15 @@ static function ($key, $value, $allowInt = false) use (&$cacheItemPrototype) { return $createCacheItem($key, null, $allowInt)->set($value); }; + self::$packCacheItem ??= \Closure::bind( + static function (CacheItem $item) { + $item->newMetadata = $item->metadata; + + return $item->pack(); + }, + null, + CacheItem::class + ); } /** @@ -163,20 +171,7 @@ public function getMultiple($keys, $default = null): iterable } foreach ($items as $key => $item) { - if (!$item->isHit()) { - $values[$key] = $default; - continue; - } - $values[$key] = $item->get(); - - if (!$metadata = $item->getMetadata()) { - continue; - } - unset($metadata[CacheItem::METADATA_TAGS]); - - if ($metadata) { - $values[$key] = ["\x9D".pack('VN', (int) (0.1 + $metadata[CacheItem::METADATA_EXPIRY] - self::METADATA_EXPIRY_OFFSET), $metadata[CacheItem::METADATA_CTIME])."\x5F" => $values[$key]]; - } + $values[$key] = $item->isHit() ? (self::$packCacheItem)($item) : $default; } return $values; diff --git a/src/Symfony/Component/Cache/Tests/Adapter/TagAwareAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/TagAwareAdapterTest.php index db85c6b479a42..5d6c52892b45f 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/TagAwareAdapterTest.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/TagAwareAdapterTest.php @@ -62,20 +62,20 @@ public function testKnownTagVersionsTtl() $item->tag(['baz']); $item->expiresAfter(100); + $tag = $tagsPool->getItem('baz'.TagAwareAdapter::TAGS_PREFIX); + $tagsPool->save($tag->set(10)); + $pool->save($item); $this->assertTrue($pool->getItem('foo')->isHit()); + $this->assertTrue($pool->getItem('foo')->isHit()); - $tagsPool->deleteItem('#baz'); + sleep(20); $this->assertTrue($pool->getItem('foo')->isHit()); sleep(5); $this->assertTrue($pool->getItem('foo')->isHit()); - - sleep(20); - - $this->assertFalse($pool->getItem('foo')->isHit()); } public function testInvalidateTagsWithArrayAdapter() @@ -97,67 +97,6 @@ public function testInvalidateTagsWithArrayAdapter() $this->assertFalse($adapter->getItem('foo')->isHit()); } - /** - * @dataProvider providePackedItemValue - */ - public function testUnpackCacheItem($packedItemValue, $isValid, $value) - { - $pool = $this->createCachePool(); - $itemKey = 'foo'; - - $adapter = new FilesystemAdapter(); - $item = $adapter->getItem('$'.$itemKey); - $adapter->save($item->set($packedItemValue)); - - $item = $pool->getItem($itemKey); - $this->assertSame($isValid, $item->isHit()); - $this->assertEquals($value, $item->get()); - - foreach ($pool->getItems([$itemKey]) as $item) { - $this->assertSame($isValid, $item->isHit()); - $this->assertEquals($value, $item->get()); - } - } - - public function providePackedItemValue() - { - return [ - // missed fields - [[], false, null], - [['$' => ''], false, null], - [['#' => []], false, null], - [['$' => '', '^' => ''], false, null], - [['#' => [], '^' => ''], false, null], - // extra fields - [[null, '$' => '', '#' => []], false, null], - [['$' => '', '#' => [], '' => ''], false, null], - // wrong order of fields - [['#' => [], '$' => ''], false, null], - [['$' => '$', '^' => '', '#' => []], false, null], - [['^' => '', '$' => '', '#' => []], false, null], - // bad types - [null, false, null], - [serialize(['$' => '$', '#' => []]), false, null], - [(object) ['$' => '$', '#' => []], false, null], - [['$' => '', '#' => ''], false, null], - [['$' => '', '#' => null], false, null], - [['$' => '', '#' => new \stdClass()], false, null], - [['$' => '', '#' => [], '^' => []], false, null], - [['$' => '', '#' => [], '^' => null], false, null], - [['$' => '', '#' => [], '^' => new \stdClass()], false, null], - // good items - [['$' => 0, '#' => []], true, 0], - [['$' => '0', '#' => []], true, '0'], - [['$' => [''], '#' => []], true, ['']], - [['$' => (object) ['$' => '$'], '#' => []], true, (object) ['$' => '$']], - [['$' => null, '#' => []], true, null], - [['$' => null, '#' => [], '^' => ''], true, null], - [['$' => '1', '#' => [], '^' => ''], true, '1'], - [['$' => [[0]], '#' => [], '^' => ''], true, [[0]]], - [['$' => serialize((object) ['$' => '$']), '#' => [], '^' => ''], true, serialize((object) ['$' => '$'])], - ]; - } - private function getPruneableMock(): PruneableInterface&MockObject { $pruneable = $this->createMock(PrunableAdapter::class); diff --git a/src/Symfony/Component/Cache/Traits/ContractsTrait.php b/src/Symfony/Component/Cache/Traits/ContractsTrait.php index 76934ff11bb9f..a8e347a32623e 100644 --- a/src/Symfony/Component/Cache/Traits/ContractsTrait.php +++ b/src/Symfony/Component/Cache/Traits/ContractsTrait.php @@ -75,7 +75,7 @@ static function (CacheItem $item, float $startTime, ?array &$metadata) { $item->newMetadata[CacheItem::METADATA_EXPIRY] = $metadata[CacheItem::METADATA_EXPIRY] = $item->expiry; $item->newMetadata[CacheItem::METADATA_CTIME] = $metadata[CacheItem::METADATA_CTIME] = (int) ceil(1000 * ($endTime - $startTime)); } else { - unset($metadata[CacheItem::METADATA_EXPIRY], $metadata[CacheItem::METADATA_CTIME]); + unset($metadata[CacheItem::METADATA_EXPIRY], $metadata[CacheItem::METADATA_CTIME], $metadata[CacheItem::METADATA_TAGS]); } }, null, diff --git a/src/Symfony/Component/Cache/Traits/ValueWrapper.php b/src/Symfony/Component/Cache/Traits/ValueWrapper.php new file mode 100644 index 0000000000000..0718ac1353d38 --- /dev/null +++ b/src/Symfony/Component/Cache/Traits/ValueWrapper.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Contracts\Cache\ItemInterface; + +/** + * A short namespace-less class to serialize items with metadata. + * + * @author Nicolas Grekas + * + * @internal + */ +class © +{ + private const EXPIRY_OFFSET = 1648206727; + private const INT32_MAX = 2147483647; + + public readonly mixed $value; + public readonly array $metadata; + + public function __construct(mixed $value, array $metadata) + { + $this->value = $value; + $this->metadata = $metadata; + } + + public function __serialize(): array + { + // pack 31-bits ctime into 14bits + $c = $this->metadata['ctime'] ?? 0; + $c = match (true) { + $c > self::INT32_MAX - 2 => self::INT32_MAX, + $c > 0 => 1 + $c, + default => 1, + }; + $e = 0; + while (!(0x40000000 & $c)) { + $c <<= 1; + ++$e; + } + $c = (0x7FE0 & ($c >> 16)) | $e; + + $pack = pack('Vn', (int) (0.1 + ($this->metadata['expiry'] ?: self::INT32_MAX + self::EXPIRY_OFFSET) - self::EXPIRY_OFFSET), $c); + + if (isset($this->metadata['tags'])) { + $pack[4] = $pack[4] | "\x80"; + } + + return [$pack => $this->value] + ($this->metadata['tags'] ?? []); + } + + public function __unserialize(array $data) + { + $pack = array_key_first($data); + $this->value = $data[$pack]; + + if ($hasTags = "\x80" === ($pack[4] & "\x80")) { + unset($data[$pack]); + $pack[4] = $pack[4] & "\x7F"; + } + + $metadata = unpack('Vexpiry/nctime', $pack); + $metadata['expiry'] += self::EXPIRY_OFFSET; + + if (!$metadata['ctime'] = ((0x4000 | $metadata['ctime']) << 16 >> (0x1F & $metadata['ctime'])) - 1) { + unset($metadata['ctime']); + } + + if ($hasTags) { + $metadata['tags'] = $data; + } + + $this->metadata = $metadata; + } +} diff --git a/src/Symfony/Component/Cache/composer.json b/src/Symfony/Component/Cache/composer.json index 39a620c774c44..fb077b9272595 100644 --- a/src/Symfony/Component/Cache/composer.json +++ b/src/Symfony/Component/Cache/composer.json @@ -48,6 +48,9 @@ }, "autoload": { "psr-4": { "Symfony\\Component\\Cache\\": "" }, + "classmap": [ + "Traits/ValueWrapper.php" + ], "exclude-from-classmap": [ "/Tests/" ]