feature #27009 [Cache] Add stampede protection via probabilistic early expiration (nicolas-grekas)

This PR was merged into the 4.2-dev branch.

Discussion
----------

[Cache] Add stampede protection via probabilistic early expiration

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | yes
| Tests pass?   | yes
| Fixed tickets | -
| License       | MIT
| Doc PR        |

This PR implements [probabilistic early expiration](https://en.wikipedia.org/wiki/Cache_stampede#Probabilistic_early_expiration) on top of `$cache->get($key, $callback);`

It adds a 3rd arg to `CacheInterface::get`:
> float $beta A float that controls the likelyness of triggering early expiration. 0 disables it, INF forces immediate expiration. The default is implementation dependend but should typically be 1.0, which should provide optimal stampede protection.

Commits
-------

13523ad985 [Cache] Add stampede protection via probabilistic early expiration
This commit is contained in:
Fabien Potencier 2018-06-11 09:05:59 +02:00
commit 7e3b7b0b50
20 changed files with 254 additions and 43 deletions

View File

@ -1,6 +1,11 @@
UPGRADE FROM 4.1 to 4.2 UPGRADE FROM 4.1 to 4.2
======================= =======================
Cache
-----
* Deprecated `CacheItem::getPreviousTags()`, use `CacheItem::getMetadata()` instead.
Security Security
-------- --------

View File

@ -1,6 +1,11 @@
UPGRADE FROM 4.x to 5.0 UPGRADE FROM 4.x to 5.0
======================= =======================
Cache
-----
* Removed `CacheItem::getPreviousTags()`, use `CacheItem::getMetadata()` instead.
Config Config
------ ------

View File

@ -46,9 +46,18 @@ abstract class AbstractAdapter implements AdapterInterface, CacheInterface, Logg
function ($key, $value, $isHit) use ($defaultLifetime) { function ($key, $value, $isHit) use ($defaultLifetime) {
$item = new CacheItem(); $item = new CacheItem();
$item->key = $key; $item->key = $key;
$item->value = $value; $item->value = $v = $value;
$item->isHit = $isHit; $item->isHit = $isHit;
$item->defaultLifetime = $defaultLifetime; $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; return $item;
}, },
@ -64,12 +73,18 @@ abstract class AbstractAdapter implements AdapterInterface, CacheInterface, Logg
foreach ($deferred as $key => $item) { foreach ($deferred as $key => $item) {
if (null === $item->expiry) { 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) { } elseif ($item->expiry > $now) {
$byLifetime[$item->expiry - $now][$getId($key)] = $item->value; $ttl = $item->expiry - $now;
} else { } else {
$expiredIds[] = $getId($key); $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; return $byLifetime;

View File

@ -64,8 +64,10 @@ class ChainAdapter implements AdapterInterface, CacheInterface, PruneableInterfa
$item->value = $sourceItem->value; $item->value = $sourceItem->value;
$item->expiry = $sourceItem->expiry; $item->expiry = $sourceItem->expiry;
$item->isHit = $sourceItem->isHit; $item->isHit = $sourceItem->isHit;
$item->metadata = $sourceItem->metadata;
$sourceItem->isTaggable = false; $sourceItem->isTaggable = false;
unset($sourceItem->metadata[CacheItem::METADATA_TAGS]);
if (0 < $sourceItem->defaultLifetime && $sourceItem->defaultLifetime < $defaultLifetime) { if (0 < $sourceItem->defaultLifetime && $sourceItem->defaultLifetime < $defaultLifetime) {
$defaultLifetime = $sourceItem->defaultLifetime; $defaultLifetime = $sourceItem->defaultLifetime;
@ -84,19 +86,20 @@ class ChainAdapter implements AdapterInterface, CacheInterface, PruneableInterfa
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
public function get(string $key, callable $callback) public function get(string $key, callable $callback, float $beta = null)
{ {
$lastItem = null; $lastItem = null;
$i = 0; $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]; $adapter = $this->adapters[$i];
if (isset($this->adapters[++$i])) { if (isset($this->adapters[++$i])) {
$callback = $wrap; $callback = $wrap;
$beta = INF === $beta ? INF : 0;
} }
if ($adapter instanceof CacheInterface) { if ($adapter instanceof CacheInterface) {
$value = $adapter->get($key, $callback); $value = $adapter->get($key, $callback, $beta);
} else { } else {
$value = $this->doGet($adapter, $key, $callback); $value = $this->doGet($adapter, $key, $callback, $beta ?? 1.0);
} }
if (null !== $item) { if (null !== $item) {
($this->syncItem)($lastItem = $lastItem ?? $item, $item); ($this->syncItem)($lastItem = $lastItem ?? $item, $item);

View File

@ -83,17 +83,17 @@ class PhpArrayAdapter implements AdapterInterface, CacheInterface, PruneableInte
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
public function get(string $key, callable $callback) public function get(string $key, callable $callback, float $beta = null)
{ {
if (null === $this->values) { if (null === $this->values) {
$this->initialize(); $this->initialize();
} }
if (null === $value = $this->values[$key] ?? null) { if (null === $value = $this->values[$key] ?? null) {
if ($this->pool instanceof CacheInterface) { 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) { if ('N;' === $value) {
return null; return null;

View File

@ -31,6 +31,7 @@ class ProxyAdapter implements AdapterInterface, CacheInterface, PruneableInterfa
private $namespace; private $namespace;
private $namespaceLen; private $namespaceLen;
private $createCacheItem; private $createCacheItem;
private $setInnerItem;
private $poolHash; private $poolHash;
public function __construct(CacheItemPoolInterface $pool, string $namespace = '', int $defaultLifetime = 0) public function __construct(CacheItemPoolInterface $pool, string $namespace = '', int $defaultLifetime = 0)
@ -43,11 +44,22 @@ class ProxyAdapter implements AdapterInterface, CacheInterface, PruneableInterfa
function ($key, $innerItem) use ($defaultLifetime, $poolHash) { function ($key, $innerItem) use ($defaultLifetime, $poolHash) {
$item = new CacheItem(); $item = new CacheItem();
$item->key = $key; $item->key = $key;
$item->value = $innerItem->get(); $item->value = $v = $innerItem->get();
$item->isHit = $innerItem->isHit(); $item->isHit = $innerItem->isHit();
$item->defaultLifetime = $defaultLifetime; $item->defaultLifetime = $defaultLifetime;
$item->innerItem = $innerItem; $item->innerItem = $innerItem;
$item->poolHash = $poolHash; $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); $innerItem->set(null);
return $item; return $item;
@ -55,20 +67,43 @@ class ProxyAdapter implements AdapterInterface, CacheInterface, PruneableInterfa
null, null,
CacheItem::class 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} * {@inheritdoc}
*/ */
public function get(string $key, callable $callback) public function get(string $key, callable $callback, float $beta = null)
{ {
if (!$this->pool instanceof CacheInterface) { 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 $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 @@ class ProxyAdapter implements AdapterInterface, CacheInterface, PruneableInterfa
return false; return false;
} }
$item = (array) $item; $item = (array) $item;
$expiry = $item["\0*\0expiry"]; if (null === $item["\0*\0expiry"] && 0 < $item["\0*\0defaultLifetime"]) {
if (null === $expiry && 0 < $item["\0*\0defaultLifetime"]) { $item["\0*\0expiry"] = time() + $item["\0*\0defaultLifetime"];
$expiry = time() + $item["\0*\0defaultLifetime"];
} }
$innerItem = $item["\0*\0poolHash"] === $this->poolHash ? $item["\0*\0innerItem"] : $this->pool->getItem($this->namespace.$item["\0*\0key"]); $innerItem = $item["\0*\0poolHash"] === $this->poolHash ? $item["\0*\0innerItem"] : $this->pool->getItem($this->namespace.$item["\0*\0key"]);
$innerItem->set($item["\0*\0value"]); ($this->setInnerItem)($innerItem, $item);
$innerItem->expiresAt(null !== $expiry ? \DateTime::createFromFormat('U', $expiry) : null);
return $this->pool->$method($innerItem); return $this->pool->$method($innerItem);
} }

View File

@ -67,7 +67,7 @@ class TagAwareAdapter implements CacheInterface, TagAwareAdapterInterface, Prune
} }
if (isset($itemTags[$key])) { if (isset($itemTags[$key])) {
foreach ($itemTags[$key] as $tag => $version) { foreach ($itemTags[$key] as $tag => $version) {
$item->prevTags[$tag] = $tag; $item->metadata[CacheItem::METADATA_TAGS][$tag] = $tag;
} }
unset($itemTags[$key]); unset($itemTags[$key]);
} else { } else {
@ -84,7 +84,7 @@ class TagAwareAdapter implements CacheInterface, TagAwareAdapterInterface, Prune
function ($deferred) { function ($deferred) {
$tagsByKey = array(); $tagsByKey = array();
foreach ($deferred as $key => $item) { foreach ($deferred as $key => $item) {
$tagsByKey[$key] = $item->tags; $tagsByKey[$key] = $item->newMetadata[CacheItem::METADATA_TAGS] ?? array();
} }
return $tagsByKey; return $tagsByKey;

View File

@ -37,7 +37,7 @@ class TraceableAdapter implements AdapterInterface, CacheInterface, PruneableInt
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
public function get(string $key, callable $callback) public function get(string $key, callable $callback, float $beta = null)
{ {
if (!$this->pool instanceof CacheInterface) { 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)); throw new \BadMethodCallException(sprintf('Cannot call "%s::get()": this class doesn\'t implement "%s".', get_class($this->pool), CacheInterface::class));
@ -52,7 +52,7 @@ class TraceableAdapter implements AdapterInterface, CacheInterface, PruneableInt
$event = $this->start(__FUNCTION__); $event = $this->start(__FUNCTION__);
try { 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); $event->result[$key] = \is_object($value) ? \get_class($value) : gettype($value);
} finally { } finally {
$event->end = microtime(true); $event->end = microtime(true);

View File

@ -4,8 +4,9 @@ CHANGELOG
4.2.0 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 * 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 3.4.0
----- -----
@ -19,7 +20,7 @@ CHANGELOG
3.3.0 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 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 Psr6Cache and SimpleCacheAdapter for bidirectional interoperability between PSR-6 and PSR-16
* added MemcachedAdapter (PSR-6) and MemcachedCache (PSR-16) * added MemcachedAdapter (PSR-6) and MemcachedCache (PSR-16)

View File

@ -26,8 +26,12 @@ interface CacheInterface
{ {
/** /**
* @param callable(CacheItem):mixed $callback Should return the computed value for the given key/item * @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 * @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);
} }

View File

@ -21,13 +21,30 @@ use Symfony\Component\Cache\Exception\LogicException;
*/ */
final class CacheItem implements CacheItemInterface 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 $key;
protected $value; protected $value;
protected $isHit = false; protected $isHit = false;
protected $expiry; protected $expiry;
protected $defaultLifetime; protected $defaultLifetime;
protected $tags = array(); protected $metadata = array();
protected $prevTags = array(); protected $newMetadata = array();
protected $innerItem; protected $innerItem;
protected $poolHash; protected $poolHash;
protected $isTaggable = false; protected $isTaggable = false;
@ -121,7 +138,7 @@ final class CacheItem implements CacheItemInterface
if (!\is_string($tag)) { if (!\is_string($tag)) {
throw new InvalidArgumentException(sprintf('Cache tag must be string, "%s" given', is_object($tag) ? get_class($tag) : gettype($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; continue;
} }
if ('' === $tag) { if ('' === $tag) {
@ -130,7 +147,7 @@ final class CacheItem implements CacheItemInterface
if (false !== strpbrk($tag, '{}()/\@:')) { if (false !== strpbrk($tag, '{}()/\@:')) {
throw new InvalidArgumentException(sprintf('Cache tag "%s" contains reserved characters {}()/\@:', $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; return $this;
@ -140,10 +157,24 @@ final class CacheItem implements CacheItemInterface
* Returns the list of tags bound to the value coming from the pool storage if any. * Returns the list of tags bound to the value coming from the pool storage if any.
* *
* @return array * @return array
*
* @deprecated since Symfony 4.2, use the "getMetadata()" method instead.
*/ */
public function getPreviousTags() 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;
} }
/** /**

View File

@ -45,6 +45,42 @@ abstract class AdapterTestCase extends CachePoolTest
$item = $cache->getItem('foo'); $item = $cache->getItem('foo');
$this->assertSame($value, $item->get()); $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() public function testDefaultLifeTime()

View File

@ -19,6 +19,7 @@ use Symfony\Component\Cache\Adapter\ArrayAdapter;
class ArrayAdapterTest extends AdapterTestCase class ArrayAdapterTest extends AdapterTestCase
{ {
protected $skippedTests = array( protected $skippedTests = array(
'testGetMetadata' => 'ArrayAdapter does not keep metadata.',
'testDeferredSaveWithoutCommit' => 'Assumes a shared cache which ArrayAdapter is not.', 'testDeferredSaveWithoutCommit' => 'Assumes a shared cache which ArrayAdapter is not.',
'testSaveWithoutExpire' => 'Assumes a shared cache which ArrayAdapter is not.', 'testSaveWithoutExpire' => 'Assumes a shared cache which ArrayAdapter is not.',
); );

View File

@ -24,8 +24,12 @@ use Symfony\Component\Cache\Tests\Fixtures\ExternalAdapter;
*/ */
class ChainAdapterTest extends AdapterTestCase 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); return new ChainAdapter(array(new ArrayAdapter($defaultLifetime), new ExternalAdapter(), new FilesystemAdapter('', $defaultLifetime)), $defaultLifetime);
} }

View File

@ -12,6 +12,7 @@
namespace Symfony\Component\Cache\Tests\Adapter; namespace Symfony\Component\Cache\Tests\Adapter;
use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Adapter\ProxyAdapter; use Symfony\Component\Cache\Adapter\ProxyAdapter;
/** /**
@ -19,8 +20,12 @@ use Symfony\Component\Cache\Adapter\ProxyAdapter;
*/ */
class NamespacedProxyAdapterTest extends ProxyAdapterTest 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); return new ProxyAdapter(new ArrayAdapter($defaultLifetime), 'foo', $defaultLifetime);
} }
} }

View File

@ -12,6 +12,7 @@
namespace Symfony\Component\Cache\Tests\Adapter; namespace Symfony\Component\Cache\Tests\Adapter;
use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemInterface;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Adapter\NullAdapter; use Symfony\Component\Cache\Adapter\NullAdapter;
use Symfony\Component\Cache\Adapter\PhpArrayAdapter; use Symfony\Component\Cache\Adapter\PhpArrayAdapter;
@ -68,8 +69,12 @@ class PhpArrayAdapterTest extends AdapterTestCase
} }
} }
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()); return new PhpArrayAdapterWrapper(self::$file, new NullAdapter());
} }

View File

@ -13,6 +13,7 @@ namespace Symfony\Component\Cache\Tests\Adapter;
use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemInterface;
use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Adapter\ProxyAdapter; use Symfony\Component\Cache\Adapter\ProxyAdapter;
use Symfony\Component\Cache\CacheItem; use Symfony\Component\Cache\CacheItem;
@ -27,8 +28,12 @@ class ProxyAdapterTest extends AdapterTestCase
'testPrune' => 'ProxyAdapter just proxies', '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); return new ProxyAdapter(new ArrayAdapter(), '', $defaultLifetime);
} }

View File

@ -14,6 +14,7 @@ namespace Symfony\Component\Cache\Tests\Adapter;
use Symfony\Component\Cache\Adapter\AdapterInterface; use Symfony\Component\Cache\Adapter\AdapterInterface;
use Symfony\Component\Cache\Adapter\FilesystemAdapter; use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Adapter\TagAwareAdapter; use Symfony\Component\Cache\Adapter\TagAwareAdapter;
use Symfony\Component\Cache\CacheItem;
/** /**
* @group time-sensitive * @group time-sensitive
@ -138,6 +139,9 @@ class TagAwareAdapterTest extends AdapterTestCase
$this->assertFalse($pool->getItem('foo')->isHit()); $this->assertFalse($pool->getItem('foo')->isHit());
} }
/**
* @group legacy
*/
public function testGetPreviousTags() public function testGetPreviousTags()
{ {
$pool = $this->createCachePool(); $pool = $this->createCachePool();
@ -149,6 +153,17 @@ class TagAwareAdapterTest extends AdapterTestCase
$this->assertSame(array('foo' => 'foo'), $i->getPreviousTags()); $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() public function testPrune()
{ {
$cache = new TagAwareAdapter($this->getPruneableMock()); $cache = new TagAwareAdapter($this->getPruneableMock());

View File

@ -63,7 +63,7 @@ class CacheItemTest extends TestCase
$this->assertSame($item, $item->tag(array('bar', 'baz'))); $this->assertSame($item, $item->tag(array('bar', 'baz')));
call_user_func(\Closure::bind(function () use ($item) { 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)); }, $this, CacheItem::class));
} }

View File

@ -11,9 +11,15 @@
namespace Symfony\Component\Cache\Traits; namespace Symfony\Component\Cache\Traits;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface; 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 <p@tchwork.com> * @author Nicolas Grekas <p@tchwork.com>
* *
* @internal * @internal
@ -23,21 +29,58 @@ trait GetTrait
/** /**
* {@inheritdoc} * {@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); $item = $pool->getItem($key);
$recompute = !$item->isHit() || INF === $beta;
if ($item->isHit()) { 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 (!$recompute) {
return $item->get(); return $item->get();
} }
$pool->save($item->set($value = $callback($item))); static $save = null;
return $value; 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 $save($pool, $item, $callback($item), $t);
} }
} }