Skip to content

Commit

Permalink
feature #27009 [Cache] Add stampede protection via probabilistic earl…
Browse files Browse the repository at this point in the history
…y 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
-------

13523ad [Cache] Add stampede protection via probabilistic early expiration
  • Loading branch information
fabpot committed Jun 11, 2018
2 parents 84ada0c + 13523ad commit 7e3b7b0
Show file tree
Hide file tree
Showing 20 changed files with 254 additions and 43 deletions.
5 changes: 5 additions & 0 deletions UPGRADE-4.2.md
@@ -1,6 +1,11 @@
UPGRADE FROM 4.1 to 4.2
=======================

Cache
-----

* Deprecated `CacheItem::getPreviousTags()`, use `CacheItem::getMetadata()` instead.

Security
--------

Expand Down
5 changes: 5 additions & 0 deletions UPGRADE-5.0.md
@@ -1,6 +1,11 @@
UPGRADE FROM 4.x to 5.0
=======================

Cache
-----

* Removed `CacheItem::getPreviousTags()`, use `CacheItem::getMetadata()` instead.

Config
------

Expand Down
21 changes: 18 additions & 3 deletions src/Symfony/Component/Cache/Adapter/AbstractAdapter.php
Expand Up @@ -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;
},
Expand All @@ -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;
Expand Down
11 changes: 7 additions & 4 deletions src/Symfony/Component/Cache/Adapter/ChainAdapter.php
Expand Up @@ -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;
Expand All @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions src/Symfony/Component/Cache/Adapter/PhpArrayAdapter.php
Expand Up @@ -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;
Expand Down
53 changes: 43 additions & 10 deletions src/Symfony/Component/Cache/Adapter/ProxyAdapter.php
Expand Up @@ -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)
Expand All @@ -43,32 +44,66 @@ 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;
},
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);
}

/**
Expand Down Expand Up @@ -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);
}
Expand Down
4 changes: 2 additions & 2 deletions src/Symfony/Component/Cache/Adapter/TagAwareAdapter.php
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/Symfony/Component/Cache/Adapter/TraceableAdapter.php
Expand Up @@ -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));
Expand All @@ -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);
Expand Down
5 changes: 3 additions & 2 deletions src/Symfony/Component/Cache/CHANGELOG.md
Expand Up @@ -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
-----
Expand All @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion src/Symfony/Component/Cache/CacheInterface.php
Expand Up @@ -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);
}
41 changes: 36 additions & 5 deletions src/Symfony/Component/Cache/CacheItem.php
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand All @@ -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;
}

/**
Expand Down

0 comments on commit 7e3b7b0

Please sign in to comment.