From 13523ad9854cd733c8db55640b7059e7789e2e2b Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 23 Apr 2018 18:02:04 +0200 Subject: [PATCH] [Cache] Add stampede protection via probabilistic early expiration --- UPGRADE-4.2.md | 5 ++ UPGRADE-5.0.md | 5 ++ .../Cache/Adapter/AbstractAdapter.php | 21 ++++++- .../Component/Cache/Adapter/ChainAdapter.php | 11 ++-- .../Cache/Adapter/PhpArrayAdapter.php | 6 +- .../Component/Cache/Adapter/ProxyAdapter.php | 53 ++++++++++++++---- .../Cache/Adapter/TagAwareAdapter.php | 4 +- .../Cache/Adapter/TraceableAdapter.php | 4 +- src/Symfony/Component/Cache/CHANGELOG.md | 5 +- .../Component/Cache/CacheInterface.php | 6 +- src/Symfony/Component/Cache/CacheItem.php | 41 ++++++++++++-- .../Cache/Tests/Adapter/AdapterTestCase.php | 36 ++++++++++++ .../Cache/Tests/Adapter/ArrayAdapterTest.php | 1 + .../Cache/Tests/Adapter/ChainAdapterTest.php | 6 +- .../Adapter/NamespacedProxyAdapterTest.php | 7 ++- .../Tests/Adapter/PhpArrayAdapterTest.php | 7 ++- .../Cache/Tests/Adapter/ProxyAdapterTest.php | 7 ++- .../Tests/Adapter/TagAwareAdapterTest.php | 15 +++++ .../Component/Cache/Tests/CacheItemTest.php | 2 +- .../Component/Cache/Traits/GetTrait.php | 55 +++++++++++++++++-- 20 files changed, 254 insertions(+), 43 deletions(-) diff --git a/UPGRADE-4.2.md b/UPGRADE-4.2.md index 7386221a5df9..743543a1c871 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 7c4ddf1d8eeb..4d8447368e9c 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 1e246b8790cb..c6caee6ced4e 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 ea0af87d9f23..57b6cafd0970 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 bcd322fede1b..daba071bb7eb 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 b9981f5e64c0..796dd6c6063f 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 e810f5d00fb0..f49e97119ad5 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 a0df682d92b6..76db2f66d838 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 f6fb43ebe787..b0f7793a2538 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 49194d135e9b..7a149d71a926 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 82ad9df68262..91fdc5316404 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 3c96b731cc56..385c70720901 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 725d79015082..9503501899b3 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 293a90cc8678..0c9969e9275c 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 c2714033385f..f1ffcbb823fb 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 8630b52cf30c..19c1285af40d 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 ff4b9d34bcba..fbbdac22a887 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 7074299e7ac3..ad37fbef7d25 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 3a0ea098ad7c..2572651290cf 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 d2a5f92da2e5..c2aef90c389d 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); } }