diff --git a/UPGRADE-4.2.md b/UPGRADE-4.2.md index 7386221a5df9d..743543a1c871a 100644 --- a/UPGRADE-4.2.md +++ b/UPGRADE-4.2.md @@ -1,6 +1,11 @@ UPGRADE FROM 4.1 to 4.2 ======================= +Cache +----- + + * Deprecated `CacheItem::getPreviousTags()`, use `CacheItem::getMetadata()` instead. + Security -------- diff --git a/UPGRADE-5.0.md b/UPGRADE-5.0.md index 7c4ddf1d8eeb3..4d8447368e9cd 100644 --- a/UPGRADE-5.0.md +++ b/UPGRADE-5.0.md @@ -1,6 +1,11 @@ UPGRADE FROM 4.x to 5.0 ======================= +Cache +----- + + * Removed `CacheItem::getPreviousTags()`, use `CacheItem::getMetadata()` instead. + Config ------ diff --git a/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php b/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php index 1e246b8790cb4..c6caee6ced4e3 100644 --- a/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/AbstractAdapter.php @@ -46,9 +46,18 @@ protected function __construct(string $namespace = '', int $defaultLifetime = 0) function ($key, $value, $isHit) use ($defaultLifetime) { $item = new CacheItem(); $item->key = $key; - $item->value = $value; + $item->value = $v = $value; $item->isHit = $isHit; $item->defaultLifetime = $defaultLifetime; + // 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 = \key($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']; + } return $item; }, @@ -64,12 +73,18 @@ function ($deferred, $namespace, &$expiredIds) use ($getId) { foreach ($deferred as $key => $item) { if (null === $item->expiry) { - $byLifetime[0 < $item->defaultLifetime ? $item->defaultLifetime : 0][$getId($key)] = $item->value; + $ttl = 0 < $item->defaultLifetime ? $item->defaultLifetime : 0; } elseif ($item->expiry > $now) { - $byLifetime[$item->expiry - $now][$getId($key)] = $item->value; + $ttl = $item->expiry - $now; } else { $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 a array, using magic numbers as separators + $byLifetime[$ttl][$getId($key)] = $metadata ? array("\x9D".pack('VN', (int) $metadata[CacheItem::METADATA_EXPIRY] - CacheItem::METADATA_EXPIRY_OFFSET, $metadata[CacheItem::METADATA_CTIME])."\x5F" => $item->value) : $item->value; } return $byLifetime; diff --git a/src/Symfony/Component/Cache/Adapter/ChainAdapter.php b/src/Symfony/Component/Cache/Adapter/ChainAdapter.php index ea0af87d9f238..57b6cafd0970c 100644 --- a/src/Symfony/Component/Cache/Adapter/ChainAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/ChainAdapter.php @@ -64,8 +64,10 @@ function ($sourceItem, $item) use ($defaultLifetime) { $item->value = $sourceItem->value; $item->expiry = $sourceItem->expiry; $item->isHit = $sourceItem->isHit; + $item->metadata = $sourceItem->metadata; $sourceItem->isTaggable = false; + unset($sourceItem->metadata[CacheItem::METADATA_TAGS]); if (0 < $sourceItem->defaultLifetime && $sourceItem->defaultLifetime < $defaultLifetime) { $defaultLifetime = $sourceItem->defaultLifetime; @@ -84,19 +86,20 @@ function ($sourceItem, $item) use ($defaultLifetime) { /** * {@inheritdoc} */ - public function get(string $key, callable $callback) + public function get(string $key, callable $callback, float $beta = null) { $lastItem = null; $i = 0; - $wrap = function (CacheItem $item = null) use ($key, $callback, &$wrap, &$i, &$lastItem) { + $wrap = function (CacheItem $item = null) use ($key, $callback, $beta, &$wrap, &$i, &$lastItem) { $adapter = $this->adapters[$i]; if (isset($this->adapters[++$i])) { $callback = $wrap; + $beta = INF === $beta ? INF : 0; } if ($adapter instanceof CacheInterface) { - $value = $adapter->get($key, $callback); + $value = $adapter->get($key, $callback, $beta); } else { - $value = $this->doGet($adapter, $key, $callback); + $value = $this->doGet($adapter, $key, $callback, $beta ?? 1.0); } if (null !== $item) { ($this->syncItem)($lastItem = $lastItem ?? $item, $item); diff --git a/src/Symfony/Component/Cache/Adapter/PhpArrayAdapter.php b/src/Symfony/Component/Cache/Adapter/PhpArrayAdapter.php index bcd322fede1ba..daba071bb7eb6 100644 --- a/src/Symfony/Component/Cache/Adapter/PhpArrayAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/PhpArrayAdapter.php @@ -83,17 +83,17 @@ public static function create($file, CacheItemPoolInterface $fallbackPool) /** * {@inheritdoc} */ - public function get(string $key, callable $callback) + public function get(string $key, callable $callback, float $beta = null) { if (null === $this->values) { $this->initialize(); } if (null === $value = $this->values[$key] ?? null) { if ($this->pool instanceof CacheInterface) { - return $this->pool->get($key, $callback); + return $this->pool->get($key, $callback, $beta); } - return $this->doGet($this->pool, $key, $callback); + return $this->doGet($this->pool, $key, $callback, $beta ?? 1.0); } if ('N;' === $value) { return null; diff --git a/src/Symfony/Component/Cache/Adapter/ProxyAdapter.php b/src/Symfony/Component/Cache/Adapter/ProxyAdapter.php index b9981f5e64c0c..796dd6c6063fc 100644 --- a/src/Symfony/Component/Cache/Adapter/ProxyAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/ProxyAdapter.php @@ -31,6 +31,7 @@ class ProxyAdapter implements AdapterInterface, CacheInterface, PruneableInterfa private $namespace; private $namespaceLen; private $createCacheItem; + private $setInnerItem; private $poolHash; public function __construct(CacheItemPoolInterface $pool, string $namespace = '', int $defaultLifetime = 0) @@ -43,11 +44,22 @@ public function __construct(CacheItemPoolInterface $pool, string $namespace = '' function ($key, $innerItem) use ($defaultLifetime, $poolHash) { $item = new CacheItem(); $item->key = $key; - $item->value = $innerItem->get(); + $item->value = $v = $innerItem->get(); $item->isHit = $innerItem->isHit(); $item->defaultLifetime = $defaultLifetime; $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 = \key($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) { + $item->metadata = $innerItem->metadata; + } $innerItem->set(null); return $item; @@ -55,20 +67,43 @@ function ($key, $innerItem) use ($defaultLifetime, $poolHash) { null, CacheItem::class ); + $this->setInnerItem = \Closure::bind( + /** + * @param array $item A CacheItem cast to (array); accessing protected properties requires adding the \0*\0" PHP prefix + */ + 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) { + // For compactness, expiry and creation duration are packed in the key of a array, using magic numbers as separators + $item["\0*\0value"] = array("\x9D".pack('VN', (int) $metadata[CacheItem::METADATA_EXPIRY] - CacheItem::METADATA_EXPIRY_OFFSET, $metadata[CacheItem::METADATA_CTIME])."\x5F" => $item["\0*\0value"]); + } + $innerItem->set($item["\0*\0value"]); + $innerItem->expiresAt(null !== $item["\0*\0expiry"] ? \DateTime::createFromFormat('U', $item["\0*\0expiry"]) : null); + }, + null, + CacheItem::class + ); } /** * {@inheritdoc} */ - public function get(string $key, callable $callback) + public function get(string $key, callable $callback, float $beta = null) { if (!$this->pool instanceof CacheInterface) { - return $this->doGet($this->pool, $key, $callback); + return $this->doGet($this, $key, $callback, $beta ?? 1.0); } return $this->pool->get($this->getId($key), function ($innerItem) use ($key, $callback) { - return $callback(($this->createCacheItem)($key, $innerItem)); - }); + $item = ($this->createCacheItem)($key, $innerItem); + $item->set($value = $callback($item)); + ($this->setInnerItem)($innerItem, (array) $item); + + return $value; + }, $beta); } /** @@ -164,13 +199,11 @@ private function doSave(CacheItemInterface $item, $method) return false; } $item = (array) $item; - $expiry = $item["\0*\0expiry"]; - if (null === $expiry && 0 < $item["\0*\0defaultLifetime"]) { - $expiry = time() + $item["\0*\0defaultLifetime"]; + if (null === $item["\0*\0expiry"] && 0 < $item["\0*\0defaultLifetime"]) { + $item["\0*\0expiry"] = time() + $item["\0*\0defaultLifetime"]; } $innerItem = $item["\0*\0poolHash"] === $this->poolHash ? $item["\0*\0innerItem"] : $this->pool->getItem($this->namespace.$item["\0*\0key"]); - $innerItem->set($item["\0*\0value"]); - $innerItem->expiresAt(null !== $expiry ? \DateTime::createFromFormat('U', $expiry) : null); + ($this->setInnerItem)($innerItem, $item); return $this->pool->$method($innerItem); } diff --git a/src/Symfony/Component/Cache/Adapter/TagAwareAdapter.php b/src/Symfony/Component/Cache/Adapter/TagAwareAdapter.php index e810f5d00fb0d..f49e97119ad53 100644 --- a/src/Symfony/Component/Cache/Adapter/TagAwareAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/TagAwareAdapter.php @@ -67,7 +67,7 @@ function (CacheItem $item, $key, array &$itemTags) { } if (isset($itemTags[$key])) { foreach ($itemTags[$key] as $tag => $version) { - $item->prevTags[$tag] = $tag; + $item->metadata[CacheItem::METADATA_TAGS][$tag] = $tag; } unset($itemTags[$key]); } else { @@ -84,7 +84,7 @@ function (CacheItem $item, $key, array &$itemTags) { function ($deferred) { $tagsByKey = array(); foreach ($deferred as $key => $item) { - $tagsByKey[$key] = $item->tags; + $tagsByKey[$key] = $item->newMetadata[CacheItem::METADATA_TAGS] ?? array(); } return $tagsByKey; diff --git a/src/Symfony/Component/Cache/Adapter/TraceableAdapter.php b/src/Symfony/Component/Cache/Adapter/TraceableAdapter.php index a0df682d92b69..76db2f66d8386 100644 --- a/src/Symfony/Component/Cache/Adapter/TraceableAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/TraceableAdapter.php @@ -37,7 +37,7 @@ public function __construct(AdapterInterface $pool) /** * {@inheritdoc} */ - public function get(string $key, callable $callback) + public function get(string $key, callable $callback, float $beta = null) { if (!$this->pool instanceof CacheInterface) { throw new \BadMethodCallException(sprintf('Cannot call "%s::get()": this class doesn\'t implement "%s".', get_class($this->pool), CacheInterface::class)); @@ -52,7 +52,7 @@ public function get(string $key, callable $callback) $event = $this->start(__FUNCTION__); try { - $value = $this->pool->get($key, $callback); + $value = $this->pool->get($key, $callback, $beta); $event->result[$key] = \is_object($value) ? \get_class($value) : gettype($value); } finally { $event->end = microtime(true); diff --git a/src/Symfony/Component/Cache/CHANGELOG.md b/src/Symfony/Component/Cache/CHANGELOG.md index f6fb43ebe7874..b0f7793a25386 100644 --- a/src/Symfony/Component/Cache/CHANGELOG.md +++ b/src/Symfony/Component/Cache/CHANGELOG.md @@ -4,8 +4,9 @@ CHANGELOG 4.2.0 ----- - * added `CacheInterface`, which should become the preferred way to use a cache + * added `CacheInterface`, which provides stampede protection via probabilistic early expiration and should become the preferred way to use a cache * throw `LogicException` when `CacheItem::tag()` is called on an item coming from a non tag-aware pool + * deprecated `CacheItem::getPreviousTags()`, use `CacheItem::getMetadata()` instead 3.4.0 ----- @@ -19,7 +20,7 @@ CHANGELOG 3.3.0 ----- - * [EXPERIMENTAL] added CacheItem::getPreviousTags() to get bound tags coming from the pool storage if any + * added CacheItem::getPreviousTags() to get bound tags coming from the pool storage if any * added PSR-16 "Simple Cache" implementations for all existing PSR-6 adapters * added Psr6Cache and SimpleCacheAdapter for bidirectional interoperability between PSR-6 and PSR-16 * added MemcachedAdapter (PSR-6) and MemcachedCache (PSR-16) diff --git a/src/Symfony/Component/Cache/CacheInterface.php b/src/Symfony/Component/Cache/CacheInterface.php index 49194d135e9bd..7a149d71a9268 100644 --- a/src/Symfony/Component/Cache/CacheInterface.php +++ b/src/Symfony/Component/Cache/CacheInterface.php @@ -26,8 +26,12 @@ interface CacheInterface { /** * @param callable(CacheItem):mixed $callback Should return the computed value for the given key/item + * @param float|null $beta A float that controls the likeliness of triggering early expiration. + * 0 disables it, INF forces immediate expiration. + * The default (or providing null) is implementation dependent but should + * typically be 1.0, which should provide optimal stampede protection. * * @return mixed The value corresponding to the provided key */ - public function get(string $key, callable $callback); + public function get(string $key, callable $callback, float $beta = null); } diff --git a/src/Symfony/Component/Cache/CacheItem.php b/src/Symfony/Component/Cache/CacheItem.php index 82ad9df68262c..91fdc53164040 100644 --- a/src/Symfony/Component/Cache/CacheItem.php +++ b/src/Symfony/Component/Cache/CacheItem.php @@ -21,13 +21,30 @@ */ final class CacheItem implements CacheItemInterface { + /** + * References the Unix timestamp stating when the item will expire. + */ + const METADATA_EXPIRY = 'expiry'; + + /** + * References the time the item took to be created, in milliseconds. + */ + const METADATA_CTIME = 'ctime'; + + /** + * References the list of tags that were assigned to the item, as string[]. + */ + const METADATA_TAGS = 'tags'; + + private const METADATA_EXPIRY_OFFSET = 1527506807; + protected $key; protected $value; protected $isHit = false; protected $expiry; protected $defaultLifetime; - protected $tags = array(); - protected $prevTags = array(); + protected $metadata = array(); + protected $newMetadata = array(); protected $innerItem; protected $poolHash; protected $isTaggable = false; @@ -121,7 +138,7 @@ public function tag($tags) if (!\is_string($tag)) { throw new InvalidArgumentException(sprintf('Cache tag must be string, "%s" given', is_object($tag) ? get_class($tag) : gettype($tag))); } - if (isset($this->tags[$tag])) { + if (isset($this->newMetadata[self::METADATA_TAGS][$tag])) { continue; } if ('' === $tag) { @@ -130,7 +147,7 @@ public function tag($tags) if (false !== strpbrk($tag, '{}()/\@:')) { throw new InvalidArgumentException(sprintf('Cache tag "%s" contains reserved characters {}()/\@:', $tag)); } - $this->tags[$tag] = $tag; + $this->newMetadata[self::METADATA_TAGS][$tag] = $tag; } return $this; @@ -140,10 +157,24 @@ public function tag($tags) * Returns the list of tags bound to the value coming from the pool storage if any. * * @return array + * + * @deprecated since Symfony 4.2, use the "getMetadata()" method instead. */ public function getPreviousTags() { - return $this->prevTags; + @trigger_error(sprintf('The "%s" method is deprecated since Symfony 4.2, use the "getMetadata()" method instead.', __METHOD__), E_USER_DEPRECATED); + + return $this->metadata[self::METADATA_TAGS] ?? array(); + } + + /** + * Returns a list of metadata info that were saved alongside with the cached value. + * + * See public CacheItem::METADATA_* consts for keys potentially found in the returned array. + */ + public function getMetadata(): array + { + return $this->metadata; } /** diff --git a/src/Symfony/Component/Cache/Tests/Adapter/AdapterTestCase.php b/src/Symfony/Component/Cache/Tests/Adapter/AdapterTestCase.php index 3c96b731cc565..385c70720901a 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/AdapterTestCase.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/AdapterTestCase.php @@ -45,6 +45,42 @@ public function testGet() $item = $cache->getItem('foo'); $this->assertSame($value, $item->get()); + + $isHit = true; + $this->assertSame($value, $cache->get('foo', function (CacheItem $item) use (&$isHit) { $isHit = false; }, 0)); + $this->assertTrue($isHit); + + $this->assertNull($cache->get('foo', function (CacheItem $item) use (&$isHit, $value) { + $isHit = false; + $this->assertTrue($item->isHit()); + $this->assertSame($value, $item->get()); + }, INF)); + $this->assertFalse($isHit); + } + + public function testGetMetadata() + { + if (isset($this->skippedTests[__FUNCTION__])) { + $this->markTestSkipped($this->skippedTests[__FUNCTION__]); + } + + $cache = $this->createCachePool(0, __FUNCTION__); + + $cache->deleteItem('foo'); + $cache->get('foo', function ($item) { + $item->expiresAfter(10); + sleep(1); + + return 'bar'; + }); + + $item = $cache->getItem('foo'); + + $expected = array( + CacheItem::METADATA_EXPIRY => 9 + time(), + CacheItem::METADATA_CTIME => 1000, + ); + $this->assertSame($expected, $item->getMetadata()); } public function testDefaultLifeTime() diff --git a/src/Symfony/Component/Cache/Tests/Adapter/ArrayAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/ArrayAdapterTest.php index 725d79015082e..9503501899b33 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/ArrayAdapterTest.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/ArrayAdapterTest.php @@ -19,6 +19,7 @@ class ArrayAdapterTest extends AdapterTestCase { protected $skippedTests = array( + 'testGetMetadata' => 'ArrayAdapter does not keep metadata.', 'testDeferredSaveWithoutCommit' => 'Assumes a shared cache which ArrayAdapter is not.', 'testSaveWithoutExpire' => 'Assumes a shared cache which ArrayAdapter is not.', ); diff --git a/src/Symfony/Component/Cache/Tests/Adapter/ChainAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/ChainAdapterTest.php index 293a90cc86783..0c9969e9275c8 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/ChainAdapterTest.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/ChainAdapterTest.php @@ -24,8 +24,12 @@ */ class ChainAdapterTest extends AdapterTestCase { - public function createCachePool($defaultLifetime = 0) + public function createCachePool($defaultLifetime = 0, $testMethod = null) { + if ('testGetMetadata' === $testMethod) { + return new ChainAdapter(array(new FilesystemAdapter('', $defaultLifetime)), $defaultLifetime); + } + return new ChainAdapter(array(new ArrayAdapter($defaultLifetime), new ExternalAdapter(), new FilesystemAdapter('', $defaultLifetime)), $defaultLifetime); } diff --git a/src/Symfony/Component/Cache/Tests/Adapter/NamespacedProxyAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/NamespacedProxyAdapterTest.php index c2714033385f4..f1ffcbb823fb7 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/NamespacedProxyAdapterTest.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/NamespacedProxyAdapterTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Cache\Tests\Adapter; use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; use Symfony\Component\Cache\Adapter\ProxyAdapter; /** @@ -19,8 +20,12 @@ */ class NamespacedProxyAdapterTest extends ProxyAdapterTest { - public function createCachePool($defaultLifetime = 0) + public function createCachePool($defaultLifetime = 0, $testMethod = null) { + if ('testGetMetadata' === $testMethod) { + return new ProxyAdapter(new FilesystemAdapter(), 'foo', $defaultLifetime); + } + return new ProxyAdapter(new ArrayAdapter($defaultLifetime), 'foo', $defaultLifetime); } } diff --git a/src/Symfony/Component/Cache/Tests/Adapter/PhpArrayAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/PhpArrayAdapterTest.php index 8630b52cf30c9..19c1285af40d5 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/PhpArrayAdapterTest.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/PhpArrayAdapterTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Cache\Tests\Adapter; use Psr\Cache\CacheItemInterface; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; use Symfony\Component\Cache\Adapter\NullAdapter; use Symfony\Component\Cache\Adapter\PhpArrayAdapter; @@ -68,8 +69,12 @@ protected function tearDown() } } - public function createCachePool() + public function createCachePool($defaultLifetime = 0, $testMethod = null) { + if ('testGetMetadata' === $testMethod) { + return new PhpArrayAdapter(self::$file, new FilesystemAdapter()); + } + return new PhpArrayAdapterWrapper(self::$file, new NullAdapter()); } diff --git a/src/Symfony/Component/Cache/Tests/Adapter/ProxyAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/ProxyAdapterTest.php index ff4b9d34bcbaf..fbbdac22a8874 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/ProxyAdapterTest.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/ProxyAdapterTest.php @@ -13,6 +13,7 @@ use Psr\Cache\CacheItemInterface; use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; use Symfony\Component\Cache\Adapter\ProxyAdapter; use Symfony\Component\Cache\CacheItem; @@ -27,8 +28,12 @@ class ProxyAdapterTest extends AdapterTestCase 'testPrune' => 'ProxyAdapter just proxies', ); - public function createCachePool($defaultLifetime = 0) + public function createCachePool($defaultLifetime = 0, $testMethod = null) { + if ('testGetMetadata' === $testMethod) { + return new ProxyAdapter(new FilesystemAdapter(), '', $defaultLifetime); + } + return new ProxyAdapter(new ArrayAdapter(), '', $defaultLifetime); } diff --git a/src/Symfony/Component/Cache/Tests/Adapter/TagAwareAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/TagAwareAdapterTest.php index 7074299e7ac34..ad37fbef7d25d 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/TagAwareAdapterTest.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/TagAwareAdapterTest.php @@ -14,6 +14,7 @@ use Symfony\Component\Cache\Adapter\AdapterInterface; use Symfony\Component\Cache\Adapter\FilesystemAdapter; use Symfony\Component\Cache\Adapter\TagAwareAdapter; +use Symfony\Component\Cache\CacheItem; /** * @group time-sensitive @@ -138,6 +139,9 @@ public function testTagItemExpiry() $this->assertFalse($pool->getItem('foo')->isHit()); } + /** + * @group legacy + */ public function testGetPreviousTags() { $pool = $this->createCachePool(); @@ -149,6 +153,17 @@ public function testGetPreviousTags() $this->assertSame(array('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(array('foo' => 'foo'), $i->getMetadata()[CacheItem::METADATA_TAGS]); + } + public function testPrune() { $cache = new TagAwareAdapter($this->getPruneableMock()); diff --git a/src/Symfony/Component/Cache/Tests/CacheItemTest.php b/src/Symfony/Component/Cache/Tests/CacheItemTest.php index 3a0ea098ad7c1..2572651290cfc 100644 --- a/src/Symfony/Component/Cache/Tests/CacheItemTest.php +++ b/src/Symfony/Component/Cache/Tests/CacheItemTest.php @@ -63,7 +63,7 @@ public function testTag() $this->assertSame($item, $item->tag(array('bar', 'baz'))); call_user_func(\Closure::bind(function () use ($item) { - $this->assertSame(array('foo' => 'foo', 'bar' => 'bar', 'baz' => 'baz'), $item->tags); + $this->assertSame(array('foo' => 'foo', 'bar' => 'bar', 'baz' => 'baz'), $item->newMetadata[CacheItem::METADATA_TAGS]); }, $this, CacheItem::class)); } diff --git a/src/Symfony/Component/Cache/Traits/GetTrait.php b/src/Symfony/Component/Cache/Traits/GetTrait.php index d2a5f92da2e53..c2aef90c389dc 100644 --- a/src/Symfony/Component/Cache/Traits/GetTrait.php +++ b/src/Symfony/Component/Cache/Traits/GetTrait.php @@ -11,9 +11,15 @@ namespace Symfony\Component\Cache\Traits; +use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\Cache\CacheItem; /** + * An implementation for CacheInterface that provides stampede protection via probabilistic early expiration. + * + * @see https://en.wikipedia.org/wiki/Cache_stampede + * * @author Nicolas Grekas
* * @internal @@ -23,21 +29,58 @@ trait GetTrait /** * {@inheritdoc} */ - public function get(string $key, callable $callback) + public function get(string $key, callable $callback, float $beta = null) { - return $this->doGet($this, $key, $callback); + return $this->doGet($this, $key, $callback, $beta ?? 1.0); } - private function doGet(CacheItemPoolInterface $pool, string $key, callable $callback) + private function doGet(CacheItemPoolInterface $pool, string $key, callable $callback, float $beta) { + $t = 0; $item = $pool->getItem($key); + $recompute = !$item->isHit() || INF === $beta; + + if ($item instanceof CacheItem && 0 < $beta) { + if ($recompute) { + $t = microtime(true); + } else { + $metadata = $item->getMetadata(); + $expiry = $metadata[CacheItem::METADATA_EXPIRY] ?? false; + $ctime = $metadata[CacheItem::METADATA_CTIME] ?? false; + + if ($ctime && $expiry) { + $t = microtime(true); + $recompute = $expiry <= $t - $ctime / 1000 * $beta * log(random_int(1, PHP_INT_MAX) / PHP_INT_MAX); + } + } + if ($recompute) { + // force applying defaultLifetime to expiry + $item->expiresAt(null); + } + } - if ($item->isHit()) { + if (!$recompute) { return $item->get(); } - $pool->save($item->set($value = $callback($item))); + static $save = null; + + if (null === $save) { + $save = \Closure::bind( + function (CacheItemPoolInterface $pool, CacheItemInterface $item, $value, float $startTime) { + if ($item instanceof CacheItem && $startTime && $item->expiry > $endTime = microtime(true)) { + $item->newMetadata[CacheItem::METADATA_EXPIRY] = $item->expiry; + $item->newMetadata[CacheItem::METADATA_CTIME] = 1000 * (int) ($endTime - $startTime); + } + $pool->save($item->set($value)); + + return $value; + }, + null, + CacheItem::class + ); + } - return $value; + return $save($pool, $item, $callback($item), $t); } }
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: