8000 [Cache] Add stampede protection via probabilistic early expiration by nicolas-grekas · Pull Request #27009 · symfony/symfony · GitHub
[go: up one dir, main page]

Skip to content

[Cache] Add stampede protection via probabilistic early expiration #27009

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 11, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions UPGRADE-4.2.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
6D4E
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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 b 813A ound 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
Loading
0