Skip to content

Commit 7ec3d50

Browse files
[Cache] Add stampede protection via probabilistic early expiration
1 parent f557f94 commit 7ec3d50

21 files changed

+247
-44
lines changed

UPGRADE-4.2.md

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

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

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: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,15 @@ 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) && 10 === \strlen($k = \key($v)) && "\x9D" === $k[0] && "\0" === $k[5] && "\x5F" === $k[9]) {
53+
$item->value = $v[$k];
54+
$v = \unpack('Ve/Nc', \substr($k, 1, -1));
55+
$item->stats[CacheItem::STATS_EXPIRY] = $v['e'] + CacheItem::STATS_EXPIRY_OFFSET;
56+
$item->stats[CacheItem::STATS_CTIME] = $v['c'];
57+
}
5258

5359
return $item;
5460
},
@@ -64,12 +70,17 @@ function ($deferred, $namespace, &$expiredIds) use ($getId) {
6470

6571
foreach ($deferred as $key => $item) {
6672
if (null === $item->expiry) {
67-
$byLifetime[0 < $item->defaultLifetime ? $item->defaultLifetime : 0][$getId($key)] = $item->value;
73+
$ttl = 0 < $item->defaultLifetime ? $item->defaultLifetime : 0;
6874
} elseif ($item->expiry > $now) {
69-
$byLifetime[$item->expiry - $now][$getId($key)] = $item->value;
75+
$ttl = $item->expiry - $now;
7076
} else {
7177
$expiredIds[] = $getId($key);
78+
continue;
79+
}
80+
if (isset(($stats = $item->newStats)[CacheItem::STATS_TAGS])) {
81+
unset($stats[CacheItem::STATS_TAGS]);
7282
}
83+
$byLifetime[$ttl][$getId($key)] = $stats ? array("\x9D".pack('VN', $stats[CacheItem::STATS_EXPIRY] - CacheItem::STATS_EXPIRY_OFFSET, $stats[CacheItem::STATS_CTIME])."\x5F" => $item->value) : $item->value;
7384
}
7485

7586
return $byLifetime;

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +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;
6768

6869
$sourceItem->isTaggable = false;
70+
unset($sourceItem->stats[CacheItem::STATS_TAGS]);
6971

7072
if (0 < $sourceItem->defaultLifetime && $sourceItem->defaultLifetime < $defaultLifetime) {
7173
$defaultLifetime = $sourceItem->defaultLifetime;
@@ -84,19 +86,20 @@ function ($sourceItem, $item) use ($defaultLifetime) {
8486
/**
8587
* {@inheritdoc}
8688
*/
87-
public function get(string $key, callable $callback)
89+
public function get(string $key, callable $callback, float $beta = null)
8890
{
8991
$lastItem = null;
9092
$i = 0;
91-
$wrap = function (CacheItem $item = null) use ($key, $callback, &$wrap, &$i, &$lastItem) {
93+
$wrap = function (CacheItem $item = null) use ($key, $callback, $beta, &$wrap, &$i, &$lastItem) {
9294
$adapter = $this->adapters[$i];
9395
if (isset($this->adapters[++$i])) {
9496
$callback = $wrap;
97+
$beta = INF === $beta ? INF : 0;
9598
}
9699
if ($adapter instanceof CacheInterface) {
97-
$value = $adapter->get($key, $callback);
100+
$value = $adapter->get($key, $callback, $beta);
98101
} else {
99-
$value = $this->doGet($adapter, $key, $callback);
102+
$value = $this->doGet($adapter, $key, $callback, $beta ?? 1.0);
100103
}
101104
if (null !== $item) {
102105
($this->syncItem)($lastItem = $lastItem ?? $item, $item);

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,17 +83,17 @@ 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 (null === $this->values) {
8989
$this->initialize();
9090
}
9191
if (null === $value = $this->values[$key] ?? null) {
9292
if ($this->pool instanceof CacheInterface) {
93-
return $this->pool->get($key, $callback);
93+
return $this->pool->get($key, $callback, $beta);
9494
}
9595

96-
return $this->doGet($this->pool, $key, $callback);
96+
return $this->doGet($this->pool, $key, $callback, $beta ?? 1.0);
9797
}
9898
if ('N;' === $value) {
9999
return null;

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

Lines changed: 35 additions & 10 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,32 +44,58 @@ 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) && 10 === \strlen($k = \key($v)) && "\x9D" === $k[0] && "\0" === $k[5] && "\x5F" === $k[9]) {
53+
$item->value = $v[$k];
54+
$v = \unpack('Ve/Nc', \substr($k, 1, -1));
55+
$item->stats[CacheItem::STATS_EXPIRY] = $v['e'] + CacheItem::STATS_EXPIRY_OFFSET;
56+
$item->stats[CacheItem::STATS_CTIME] = $v['c'];
57+
} elseif ($innerItem instanceof CacheItem) {
58+
$item->stats = $innerItem->stats;
59+
}
5160
$innerItem->set(null);
5261

5362
return $item;
5463
},
5564
null,
5665
CacheItem::class
5766
);
67+
$this->setInnerItem = \Closure::bind(
68+
function (CacheItemInterface $innerItem, array $item) {
69+
if (isset(($stats = $item["\0*\0newStats"])[CacheItem::STATS_TAGS])) {
70+
unset($stats[CacheItem::STATS_TAGS]);
71+
}
72+
if ($stats) {
73+
$item["\0*\0value"] = array("\x9D".pack('VN', $stats[CacheItem::STATS_EXPIRY] - CacheItem::STATS_EXPIRY_OFFSET, $stats[CacheItem::STATS_CTIME])."\x5F" => $item["\0*\0value"]);
74+
}
75+
$innerItem->set($item["\0*\0value"]);
76+
$innerItem->expiresAt(null !== $item["\0*\0expiry"] ? \DateTime::createFromFormat('U', $item["\0*\0expiry"]) : null);
77+
},
78+
null,
79+
CacheItem::class
80+
);
5881
}
5982

6083
/**
6184
* {@inheritdoc}
6285
*/
63-
public function get(string $key, callable $callback)
86+
public function get(string $key, callable $callback, float $beta = null)
6487
{
6588
if (!$this->pool instanceof CacheInterface) {
66-
return $this->doGet($this->pool, $key, $callback);
89+
return $this->doGet($this, $key, $callback, $beta ?? 1.0);
6790
}
6891

6992
return $this->pool->get($this->getId($key), function ($innerItem) use ($key, $callback) {
70-
return $callback(($this->createCacheItem)($key, $innerItem));
71-
});
93+
$item = ($this->createCacheItem)($key, $innerItem);
94+
$item->set($value = $callback($item));
95+
($this->setInnerItem)($innerItem, (array) $item);
96+
97+
return $value;
98+
}, $beta);
7299
}
73100

74101
/**
@@ -164,13 +191,11 @@ private function doSave(CacheItemInterface $item, $method)
164191
return false;
165192
}
166193
$item = (array) $item;
167-
$expiry = $item["\0*\0expiry"];
168-
if (null === $expiry && 0 < $item["\0*\0defaultLifetime"]) {
169-
$expiry = time() + $item["\0*\0defaultLifetime"];
194+
if (null === $item["\0*\0expiry"] && 0 < $item["\0*\0defaultLifetime"]) {
195+
$item["\0*\0expiry"] = time() + $item["\0*\0defaultLifetime"];
170196
}
171197
$innerItem = $item["\0*\0poolHash"] === $this->poolHash ? $item["\0*\0innerItem"] : $this->pool->getItem($this->namespace.$item["\0*\0key"]);
172-
$innerItem->set($item["\0*\0value"]);
173-
$innerItem->expiresAt(null !== $expiry ? \DateTime::createFromFormat('U', $expiry) : null);
198+
($this->setInnerItem)($innerItem, $item);
174199

175200
return $this->pool->$method($innerItem);
176201
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ function (CacheItem $item, $key, array &$itemTags) {
6767
}
6868
if (isset($itemTags[$key])) {
6969
foreach ($itemTags[$key] as $tag => $version) {
70-
$item->prevTags[$tag] = $tag;
70+
$item->stats[CacheItem::STATS_TAGS][$tag] = $tag;
7171
}
7272
unset($itemTags[$key]);
7373
} else {
@@ -84,7 +84,7 @@ function (CacheItem $item, $key, array &$itemTags) {
8484
function ($deferred) {
8585
$tagsByKey = array();
8686
foreach ($deferred as $key => $item) {
87-
$tagsByKey[$key] = $item->tags;
87+
$tagsByKey[$key] = $item->newStats[CacheItem::STATS_TAGS] ?? array();
8888
}
8989

9090
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: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,12 @@ interface CacheInterface
3030
{
3131
/**
3232
* @param callable(CacheItemInterface):mixed $callback Should return the computed value for the given key/item
33+
* @param float|null $beta A float that controls the likeliness of triggering early expiration.
34+
* 0 disables it, INF forces immediate expiration.
35+
* The default (or providing null) is implementation dependent but should
36+
* typically be 1.0, which should provide optimal stampede protection.
3337
*
3438
* @return mixed The value corresponding to the provided key
3539
*/
36-
public function get(string $key, callable $callback);
40+
public function get(string $key, callable $callback, float $beta = null);
3741
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy