8000 [Cache] Add stampede protection via probabilistic early expiration · symfony/symfony@8e556be · GitHub
[go: up one dir, main page]

Skip to content

Commit 8e556be

Browse files
[Cache] Add stampede protection via probabilistic early expiration
1 parent b672e2c commit 8e556be

21 files changed

+248
-54
lines changed

UPGRADE-4.2.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
UPGRADE FROM 4.1 to 4.2
2+
=======================
3+
4+
Cache
5+
-----
6+
7+
* Deprecated `CacheItem::getPreviousTags()`, use `CacheItem::getStats()` instead.

UPGRADE-5.0.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
UPGRADE FROM 4.x to 5.0
22
=======================
33

4+
Cache
5+
-----
6+
7+
* Removed `CacheItem::getPreviousTags()`, use `CacheItem::getStats()` instead.
8+
49
Config
510
------
611

src/Symfony/Component/Cache/Adapter/AbstractAdapter.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,12 @@ protected function __construct(string $namespace = '', int $defaultLifetime = 0)
4646
function ($key, $value, $isHit) use ($defaultLifetime) {
4747
$item = new CacheItem();
4848
$item->key = $key;
49-
$item->value = $value;
49+
$item->value = $v = $value;
5050
$item->isHit = $isHit;
5151
$item->defaultLifetime = $defaultLifetime;
52+
if (\is_array($v) && 1 === \count($v) && \is_array($v = $v[CacheItem::STATS_KEY] ?? null) && array(0, 1) === \array_keys($v)) {
53+
list($item->value, $item->stats) = $v;
54+
}
5255

5356
return $item;
5457
},
@@ -64,12 +67,14 @@ function ($deferred, $namespace, &$expiredIds) use ($getId) {
6467

6568
foreach ($deferred as $key => $item) {
6669
if (null === $item->expiry) {
67-
$byLifetime[0 < $item->defaultLifetime ? $item->defaultLifetime : 0][$getId($key)] = $item->value;
70+
$ttl = 0 < $item->defaultLifetime ? $item->defaultLifetime : 0;
6871
} elseif ($item->expiry > $now) {
69-
$byLifetime[$item->expiry - $now][$getId($key)] = $item->value;
72+
$ttl = $item->expiry - $now;
7073
} else {
7174
$expiredIds[] = $getId($key);
75+
continue;
7276
}
77+
$byLifetime[$ttl][$getId($key)] = $item->newStats ? array(CacheItem::STATS_KEY => array($item->value, $item->newStats)) : $item->value;
7378
}
7479

7580
return $byLifetime;

src/Symfony/Component/Cache/Adapter/ChainAdapter.php

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ function ($sourceItem, $item) use ($defaultLifetime) {
6464
$item->value = $sourceItem->value;
6565
$item->expiry = $sourceItem->expiry;
6666
$item->isHit = $sourceItem->isHit;
67+
$item->stats = $sourceItem->stats;
68+
69+
$sourceItem->isTaggable = false;
70+
unset($sourceItem->stats[CacheItem::STATS_TAGS]);
6771

6872
if (0 < $sourceItem->defaultLifetime && $sourceItem->defaultLifetime < $defaultLifetime) {
6973
$defaultLifetime = $sourceItem->defaultLifetime;
@@ -82,21 +86,23 @@ function ($sourceItem, $item) use ($defaultLifetime) {
8286
/**
8387
* {@inheritdoc}
8488
*/
85-
public function get(string $key, callable $callback)
89+
public function get(string $key, callable $callback, float $beta = null)
8690
{
87-
$computedItem = null;
88-
$i = -1;
89-
$wrap = function (CacheItem $item = null) use ($key, $callback, &$wrap, &$i, &$computedItem) {
90-
if (!$adapter = $this->adapters[++$i] ?? null) {
91-
$value = $callback($item);
92-
$computedItem = $item;
93-
} elseif ($adapter instanceof CacheInterface) {
94-
$value = $adapter->get($key, $wrap);
91+
$lastItem = null;
92+
$i = 0;
93+
$wrap = function (CacheItem $item = null) use ($key, $callback, $beta, &$wrap, &$i, &$lastItem) {
94+
$adapter = $this->adapters[$i];
95+
if (isset($this->adapters[++$i])) {
96+
$callback = $wrap;
97+
$beta = INF === $beta ? INF : 0;
98+
}
99+
if ($adapter instanceof CacheInterface) {
100+
$value = $adapter->get($key, $callback, $beta);
95101
} else {
96-
$value = $this->doGet($adapter, $key, $wrap);
102+
$value = $this->doGet($adapter, $key, $callback, $beta ?? 1.0);
97103
}
98104
if (null !== $item) {
99-
($this->syncItem)($computedItem ?? $item, $item);
105+
($this->syncItem)($lastItem = $lastItem ?? $item, $item);
100106
}
101107

102108
return $value;

src/Symfony/Component/Cache/Adapter/PhpArrayAdapter.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ public static function create($file, CacheItemPoolInterface $fallbackPool)
8383
/**
8484
* {@inheritdoc}
8585
*/
86-
public function get(string $key, callable $callback)
86+
public function get(string $key, callable $callback, float $beta = null)
8787
{
8888
if (!\is_string($key)) {
8989
throw new InvalidArgumentException(sprintf('Cache key must be string, "%s" given.', is_object($key) ? get_class($key) : gettype($key)));
@@ -93,10 +93,10 @@ public function get(string $key, callable $callback)
9393
}
9494
if (null === $value = $this->values[$key] ?? null) {
9595
if ($this->pool instanceof CacheInterface) {
96-
return $this->pool->get($key, $callback);
96+
return $this->pool->get($key, $callback, $beta);
9797
}
9898

99-
return $this->doGet($this->pool, $key, $callback);
99+
return $this->doGet($this->pool, $key, $callback, $beta ?? 1.0);
100100
}
101101
if ('N;' === $value) {
102102
return null;

src/Symfony/Component/Cache/Adapter/ProxyAdapter.php

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class ProxyAdapter implements AdapterInterface, CacheInterface, PruneableInterfa
3131
private $namespace;
3232
private $namespaceLen;
3333
private $createCacheItem;
34+
private $setInnerItem;
3435
private $poolHash;
3536

3637
public function __construct(CacheItemPoolInterface $pool, string $namespace = '', int $defaultLifetime = 0)
@@ -43,34 +44,52 @@ public function __construct(CacheItemPoolInterface $pool, string $namespace = ''
4344
function ($key, $innerItem) use ($defaultLifetime, $poolHash) {
4445
$item = new CacheItem();
4546
$item->key = $key;
46-
$item->value = $innerItem->get();
47+
$item->value = $v = $innerItem->get();
4748
$item->isHit = $innerItem->isHit();
4849
$item->defaultLifetime = $defaultLifetime;
4950
$item->innerItem = $innerItem;
5051
$item->poolHash = $poolHash;
52+
if (\is_array($v) && 1 === \count($v) && \is_array($v = $v[CacheItem::STATS_KEY] ?? null) && array(0, 1) === \array_keys($v)) {
53+
list($item->value, $item->stats) = $v;
54+
} elseif ($innerItem instanceof CacheItem) {
55+
$item->stats = $innerItem->stats;
56+
}
5157
$innerItem->set(null);
5258

5359
return $item;
5460
},
5561
null,
5662
CacheItem::class
5763
);
64+
$this->setInnerItem = \Closure::bind(
65+
function (CacheItemInterface $innerItem, array $item) {
66+
if ($stats = $item["\0*\0newStats"]) {
67+
$item["\0*\0value"] = array(CacheItem::STATS_KEY => array($item["\0*\0value"], $stats));
68+
}
69+
$innerItem->set($item["\0*\0value"]);
70+
$innerItem->expiresAt(null !== $item["\0*\0expiry"] ? \DateTime::createFromFormat('U', $item["\0*\0expiry"]) : null);
71+
},
72+
null,
73+
CacheItem::class
74+
);
5875
}
5976

6077
/**
6178
* {@inheritdoc}
6279
*/
63-
public function get(string $key, callable $callback)
80+
public function get(string $key, callable $callback, float $beta = null)
6481
{
65-
$callback = function ($item) use ($key, $callback) {
66-
return $callback(($this->createCacheItem)($key, $item));
67-
};
68-
69-
if ($this->pool instanceof CacheInterface) {
70-
return $this->pool->get($this->getId($key), $callback);
82+
if (!$this->pool instanceof CacheInterface) {
83+
return $this->doGet($this, $key, $callback, $beta ?? 1.0);
7184
}
7285

73-
return $this->doGet($this->pool, $key, $callback);
86+
return $this->pool->get($this->getId($key), function ($innerItem) use ($key, $callback) {
87+
$item = ($this->createCacheItem)($key, $innerItem);
88+
$item->set($value = $callback($item));
89+
($this->setInnerItem)($innerItem, (array) $item);
90+
91+
return $value;
92+
}, $beta);
7493
}
7594

7695
/**
@@ -166,13 +185,11 @@ private function doSave(CacheItemInterface $item, $method)
166185
return false;
167186
}
168187
$item = (array) $item;
169-
$expiry = $item["\0*\0expiry"];
170-
if (null === $expiry && 0 < $item["\0*\0defaultLifetime"]) {
171-
$expiry = time() + $item["\0*\0defaultLifetime"];
188+
if (null === $item["\0*\0expiry"] && 0 < $item["\0*\0defaultLifetime"]) {
189+
$item["\0*\0expiry"] = time() + $item["\0*\0defaultLifetime"];
172190
}
173191
$innerItem = $item["\0*\0poolHash"] === $this->poolHash ? $item["\0*\0innerItem"] : $this->pool->getItem($this->namespace.$item["\0*\0key"]);
174-
$innerItem->set($item["\0*\0value"]);
175-
$innerItem->expiresAt(null !== $expiry ? \DateTime::createFromFormat('U', $expiry) : null);
192+
($this->setInnerItem)($innerItem, $item);
176193

177194
return $this->pool->$method($innerItem);
178195
}

src/Symfony/Component/Cache/Adapter/TagAwareAdapter.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ function (CacheItem $item, $key, array &$itemTags) {
6464
}
6565
if (isset($itemTags[$key])) {
6666
foreach ($itemTags[$key] as $tag => $version) {
67-
$item->prevTags[$tag] = $tag;
67+
$item->stats[CacheItem::STATS_TAGS][$tag] = $tag;
6868
}
6969
unset($itemTags[$key]);
7070
} else {
@@ -81,7 +81,7 @@ function (CacheItem $item, $key, array &$itemTags) {
8181
function ($deferred) {
8282
$tagsByKey = array();
8383
foreach ($deferred as $key => $item) {
84-
$tagsByKey[$key] = $item->tags;
84+
$tagsByKey[$key] = $item->newStats[CacheItem::STATS_TAGS] ?? array();
8585
}
8686

8787
return $tagsByKey;

src/Symfony/Component/Cache/Adapter/TraceableAdapter.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public function __construct(AdapterInterface $pool)
3737
/**
3838
* {@inheritdoc}
3939
*/
40-
public function get(string $key, callable $callback)
40+
public function get(string $key, callable $callback, float $beta = null)
4141
{
4242
if (!$this->pool instanceof CacheInterface) {
4343
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)
5252

5353
$event = $this->start(__FUNCTION__);
5454
try {
55-
$value = $this->pool->get($key, $callback);
55+
$value = $this->pool->get($key, $callback, $beta);
5656
$event->result[$key] = \is_object($value) ? \get_class($value) : gettype($value);
5757
} finally {
5858
$event->end = microtime(true);

src/Symfony/Component/Cache/CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ CHANGELOG
44
4.2.0
55
-----
66

7-
* added `CacheInterface` and `TaggableCacheInterface`
7+
* added `CacheInterface` and `TaggableCacheInterface`, providing stampede protection via probabilistic early expiration
88
* throw `LogicException` when `CacheItem::tag()` is called on an item coming from a non tag-aware pool
9+
* deprecated `CacheItem::getPreviousTags()`, use `CacheItem::getStats()` instead
910

1011
3.4.0
1112
-----
@@ -19,7 +20,7 @@ CHANGELOG
1920
3.3.0
2021
-----
2122

22-
* [EXPERIMENTAL] added CacheItem::getPreviousTags() to get bound tags coming from the pool storage if any
23+
* added CacheItem::getPreviousTags() to get bound tags coming from the pool storage if any
2324
* added PSR-16 "Simple Cache" implementations for all existing PSR-6 adapters
2425
* added Psr6Cache and SimpleCacheAdapter for bidirectional interoperability between PSR-6 and PSR-16
2526
* added MemcachedAdapter (PSR-6) and MemcachedCache (PSR-16)

src/Symfony/Component/Cache/CacheInterface.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,11 @@ interface CacheInterface
3030
{
3131
/**
3232
* @param callable(CacheItemInterface $item):mixed $callback Should return the computed value for the given key/item
33+
* @param float $beta A float that controls the likelyness of triggering early expiration.
34+
* 0 disables it, INF forces immediate expiration. The default is implementation dependend
35+
* but should typically be 1.0, which should provide optimal stampede protection.
3336
*
3437
* @return mixed The value corresponding to the provided key
3538
*/
36-
public function get(string $key, callable $callback);
39+
public function get(string $key, callable $callback, float $beta = null);
3740
}

0 commit comments

Comments
 (0)
0