Thanks to visit codestin.com
Credit goes to github.com

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

+5
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

+5
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

+14-3
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

+7-4
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

+3-3
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

+35-10
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

+2-2
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

+2-2
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

+3-2
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

+5-1
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
}

src/Symfony/Component/Cache/CacheItem.php

+36-5
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,30 @@
2121
*/
2222
final class CacheItem implements CacheItemInterface
2323
{
24+
/**
25+
* References the Unix timestamp stating when the item will expire.
26+
*/
27+
const STATS_EXPIRY = 1;
28+
29+
/**
30+
* References the time the item took to be created, in milliseconds.
31+
*/
32+
const STATS_CTIME = 2;
33+
34+
/**
35+
* References the list of tags that were assigned to the item, as string[].
36+
*/
37+
const STATS_TAGS = 3;
38+
39+
private const STATS_EXPIRY_OFFSET = 1527506807;
40+
2441
protected $key;
2542
protected $value;
2643
protected $isHit = false;
2744
protected $expiry;
2845
protected $defaultLifetime;
29-
protected $tags = array();
30-
protected $prevTags = array();
46+
protected $stats = array();
47+
protected $newStats = array();
3148
protected $innerItem;
3249
protected $poolHash;
3350
protected $isTaggable = false;
@@ -121,7 +138,7 @@ public function tag($tags)
121138
if (!\is_string($tag)) {
122139
throw new InvalidArgumentException(sprintf('Cache tag must be string, "%s" given', is_object($tag) ? get_class($tag) : gettype($tag)));
123140
}
124-
if (isset($this->tags[$tag])) {
141+
if (isset($this->newStats[self::STATS_TAGS][$tag])) {
125142
continue;
126143
}
127144
if ('' === $tag) {
@@ -130,7 +147,7 @@ public function tag($tags)
130147
if (false !== strpbrk($tag, '{}()/\@:')) {
131148
throw new InvalidArgumentException(sprintf('Cache tag "%s" contains reserved characters {}()/\@:', $tag));
132149
}
133-
$this->tags[$tag] = $tag;
150+
$this->newStats[self::STATS_TAGS][$tag] = $tag;
134151
}
135152

136153
return $this;
@@ -140,10 +157,24 @@ public function tag($tags)
140157
* Returns the list of tags bound to the value coming from the pool storage if any.
141158
*
142159
* @return array
160+
*
161+
* @deprecated since Symfony 4.2. Use the "getStats()" method instead.
143162
*/
144163
public function getPreviousTags()
145164
{
146-
return $this->prevTags;
165+
@trigger_error(sprintf('The "%s" method is deprecated since Symfony 4.2. Use the "getStats()" method instead.', __METHOD__), E_USER_DEPRECATED);
166+
167+
return $this->stats[self::STATS_TAGS] ?? array();
168+
}
169+
170+
/**
171+
* Returns a list of stats info that were saved alongside with the cached value.
172+
*
173+
* See public CacheItem::STATS_* consts for keys potentially found in the returned array.
174+
*/
175+
public function getStats(): array
176+
{
177+
return $this->stats;
147178
}
148179

149180
/**

src/Symfony/Component/Cache/TaggableCacheInterface.php

+5-1
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,12 @@ interface TaggableCacheInterface extends CacheInterface
2828
{
2929
/**
3030
* @param callable(CacheItem):mixed $callback Should return the computed value for the given key/item
31+
* @param float|null $beta A float that controls the likeliness of triggering early expiration.
32+
* 0 disables it, INF forces immediate expiration.
33+
* The default (or providing null) is implementation dependent but should
34+
* typically be 1.0, which should provide optimal stampede protection.
3135
*
3236
* @return mixed The value corresponding to the provided key
3337
*/
34-
public function get(string $key, callable $callback);
38+
public function get(string $key, callable $callback, float $beta = null);
3539
}

0 commit comments

Comments
 (0)