feature #27009 [Cache] Add stampede protection via probabilistic early 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
-------

13523ad985 [Cache] Add stampede protection via probabilistic early expiration
This commit is contained in:
Fabien Potencier 2018-06-11 09:05:59 +02:00
commit 7e3b7b0b50
20 changed files with 254 additions and 43 deletions

View File

@ -1,6 +1,11 @@
UPGRADE FROM 4.1 to 4.2
=======================
Cache
-----
* Deprecated `CacheItem::getPreviousTags()`, use `CacheItem::getMetadata()` instead.
Security
--------

View File

@ -1,6 +1,11 @@
UPGRADE FROM 4.x to 5.0
=======================
Cache
-----
* Removed `CacheItem::getPreviousTags()`, use `CacheItem::getMetadata()` instead.
Config
------

View File

@ -46,9 +46,18 @@ abstract class AbstractAdapter implements AdapterInterface, CacheInterface, Logg
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;
},
@ -64,12 +73,18 @@ abstract class AbstractAdapter implements AdapterInterface, CacheInterface, Logg
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;

View File

@ -64,8 +64,10 @@ class ChainAdapter implements AdapterInterface, CacheInterface, PruneableInterfa
$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;
@ -84,19 +86,20 @@ class ChainAdapter implements AdapterInterface, CacheInterface, PruneableInterfa
/**
* {@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);

View File

@ -83,17 +83,17 @@ class PhpArrayAdapter implements AdapterInterface, CacheInterface, PruneableInte
/**
* {@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;

View File

@ -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)
@ -43,11 +44,22 @@ class ProxyAdapter implements AdapterInterface, CacheInterface, PruneableInterfa
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;
@ -55,20 +67,43 @@ class ProxyAdapter implements AdapterInterface, CacheInterface, PruneableInterfa
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);
}
/**
@ -164,13 +199,11 @@ class ProxyAdapter implements AdapterInterface, CacheInterface, PruneableInterfa
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);
}

View File

@ -67,7 +67,7 @@ class TagAwareAdapter implements CacheInterface, TagAwareAdapterInterface, Prune
}
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 {
@ -84,7 +84,7 @@ class TagAwareAdapter implements CacheInterface, TagAwareAdapterInterface, Prune
function ($deferred) {
$tagsByKey = array();
foreach ($deferred as $key => $item) {
$tagsByKey[$key] = $item->tags;
$tagsByKey[$key] = $item->newMetadata[CacheItem::METADATA_TAGS] ?? array();
}
return $tagsByKey;

View File

@ -37,7 +37,7 @@ class TraceableAdapter implements AdapterInterface, CacheInterface, PruneableInt
/**
* {@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));
@ -52,7 +52,7 @@ class TraceableAdapter implements AdapterInterface, CacheInterface, PruneableInt
$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);

View File

@ -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
-----
@ -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)

View File

@ -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);
}

View File

@ -21,13 +21,30 @@ use Symfony\Component\Cache\Exception\LogicException;
*/
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;
@ -121,7 +138,7 @@ final class CacheItem implements CacheItemInterface
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) {
@ -130,7 +147,7 @@ final class CacheItem implements CacheItemInterface
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;
@ -140,10 +157,24 @@ final class CacheItem implements CacheItemInterface
* 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;
}
/**

View File

@ -45,6 +45,42 @@ abstract class AdapterTestCase extends CachePoolTest
$item = $cache->getItem('foo');
$this->assertSame($value, $item->get());
$isHit = true;
$this->assertSame($value, $cache->get('foo', function (CacheItem $item) use (&$isHit) { $isHit = false; }, 0));
$this->assertTrue($isHit);
$this->assertNull($cache->get('foo', function (CacheItem $item) use (&$isHit, $value) {
$isHit = false;
$this->assertTrue($item->isHit());
$this->assertSame($value, $item->get());
}, INF));
$this->assertFalse($isHit);
}
public function testGetMetadata()
{
if (isset($this->skippedTests[__FUNCTION__])) {
$this->markTestSkipped($this->skippedTests[__FUNCTION__]);
}
$cache = $this->createCachePool(0, __FUNCTION__);
$cache->deleteItem('foo');
$cache->get('foo', function ($item) {
$item->expiresAfter(10);
sleep(1);
return 'bar';
});
$item = $cache->getItem('foo');
$expected = array(
CacheItem::METADATA_EXPIRY => 9 + time(),
CacheItem::METADATA_CTIME => 1000,
);
$this->assertSame($expected, $item->getMetadata());
}
public function testDefaultLifeTime()

View File

@ -19,6 +19,7 @@ use Symfony\Component\Cache\Adapter\ArrayAdapter;
class ArrayAdapterTest extends AdapterTestCase
{
protected $skippedTests = array(
'testGetMetadata' => 'ArrayAdapter does not keep metadata.',
'testDeferredSaveWithoutCommit' => 'Assumes a shared cache which ArrayAdapter is not.',
'testSaveWithoutExpire' => 'Assumes a shared cache which ArrayAdapter is not.',
);

View File

@ -24,8 +24,12 @@ use Symfony\Component\Cache\Tests\Fixtures\ExternalAdapter;
*/
class ChainAdapterTest extends AdapterTestCase
{
public function createCachePool($defaultLifetime = 0)
public function createCachePool($defaultLifetime = 0, $testMethod = null)
{
if ('testGetMetadata' === $testMethod) {
return new ChainAdapter(array(new FilesystemAdapter('', $defaultLifetime)), $defaultLifetime);
}
return new ChainAdapter(array(new ArrayAdapter($defaultLifetime), new ExternalAdapter(), new FilesystemAdapter('', $defaultLifetime)), $defaultLifetime);
}

View File

@ -12,6 +12,7 @@
namespace Symfony\Component\Cache\Tests\Adapter;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Adapter\ProxyAdapter;
/**
@ -19,8 +20,12 @@ use Symfony\Component\Cache\Adapter\ProxyAdapter;
*/
class NamespacedProxyAdapterTest extends ProxyAdapterTest
{
public function createCachePool($defaultLifetime = 0)
public function createCachePool($defaultLifetime = 0, $testMethod = null)
{
if ('testGetMetadata' === $testMethod) {
return new ProxyAdapter(new FilesystemAdapter(), 'foo', $defaultLifetime);
}
return new ProxyAdapter(new ArrayAdapter($defaultLifetime), 'foo', $defaultLifetime);
}
}

View File

@ -12,6 +12,7 @@
namespace Symfony\Component\Cache\Tests\Adapter;
use Psr\Cache\CacheItemInterface;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Adapter\NullAdapter;
use Symfony\Component\Cache\Adapter\PhpArrayAdapter;
@ -68,8 +69,12 @@ class PhpArrayAdapterTest extends AdapterTestCase
}
}
public function createCachePool()
public function createCachePool($defaultLifetime = 0, $testMethod = null)
{
if ('testGetMetadata' === $testMethod) {
return new PhpArrayAdapter(self::$file, new FilesystemAdapter());
}
return new PhpArrayAdapterWrapper(self::$file, new NullAdapter());
}

View File

@ -13,6 +13,7 @@ namespace Symfony\Component\Cache\Tests\Adapter;
use Psr\Cache\CacheItemInterface;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Adapter\ProxyAdapter;
use Symfony\Component\Cache\CacheItem;
@ -27,8 +28,12 @@ class ProxyAdapterTest extends AdapterTestCase
'testPrune' => 'ProxyAdapter just proxies',
);
public function createCachePool($defaultLifetime = 0)
public function createCachePool($defaultLifetime = 0, $testMethod = null)
{
if ('testGetMetadata' === $testMethod) {
return new ProxyAdapter(new FilesystemAdapter(), '', $defaultLifetime);
}
return new ProxyAdapter(new ArrayAdapter(), '', $defaultLifetime);
}

View File

@ -14,6 +14,7 @@ namespace Symfony\Component\Cache\Tests\Adapter;
use Symfony\Component\Cache\Adapter\AdapterInterface;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
use Symfony\Component\Cache\CacheItem;
/**
* @group time-sensitive
@ -138,6 +139,9 @@ class TagAwareAdapterTest extends AdapterTestCase
$this->assertFalse($pool->getItem('foo')->isHit());
}
/**
* @group legacy
*/
public function testGetPreviousTags()
{
$pool = $this->createCachePool();
@ -149,6 +153,17 @@ class TagAwareAdapterTest extends AdapterTestCase
$this->assertSame(array('foo' => 'foo'), $i->getPreviousTags());
}
public function testGetMetadata()
{
$pool = $this->createCachePool();
$i = $pool->getItem('k');
$pool->save($i->tag('foo'));
$i = $pool->getItem('k');
$this->assertSame(array('foo' => 'foo'), $i->getMetadata()[CacheItem::METADATA_TAGS]);
}
public function testPrune()
{
$cache = new TagAwareAdapter($this->getPruneableMock());

View File

@ -63,7 +63,7 @@ class CacheItemTest extends TestCase
$this->assertSame($item, $item->tag(array('bar', 'baz')));
call_user_func(\Closure::bind(function () use ($item) {
$this->assertSame(array('foo' => 'foo', 'bar' => 'bar', 'baz' => 'baz'), $item->tags);
$this->assertSame(array('foo' => 'foo', 'bar' => 'bar', 'baz' => 'baz'), $item->newMetadata[CacheItem::METADATA_TAGS]);
}, $this, CacheItem::class));
}

View File

@ -11,9 +11,15 @@
namespace Symfony\Component\Cache\Traits;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Cache\CacheItem;
/**
* An implementation for CacheInterface that provides stampede protection via probabilistic early expiration.
*
* @see https://en.wikipedia.org/wiki/Cache_stampede
*
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
@ -23,21 +29,58 @@ trait GetTrait
/**
* {@inheritdoc}
*/
public function get(string $key, callable $callback)
public function get(string $key, callable $callback, float $beta = null)
{
return $this->doGet($this, $key, $callback);
return $this->doGet($this, $key, $callback, $beta ?? 1.0);
}
private function doGet(CacheItemPoolInterface $pool, string $key, callable $callback)
private function doGet(CacheItemPoolInterface $pool, string $key, callable $callback, float $beta)
{
$t = 0;
$item = $pool->getItem($key);
$recompute = !$item->isHit() || INF === $beta;
if ($item->isHit()) {
if ($item instanceof CacheItem && 0 < $beta) {
if ($recompute) {
$t = microtime(true);
} else {
$metadata = $item->getMetadata();
$expiry = $metadata[CacheItem::METADATA_EXPIRY] ?? false;
$ctime = $metadata[CacheItem::METADATA_CTIME] ?? false;
if ($ctime && $expiry) {
$t = microtime(true);
$recompute = $expiry <= $t - $ctime / 1000 * $beta * log(random_int(1, PHP_INT_MAX) / PHP_INT_MAX);
}
}
if ($recompute) {
// force applying defaultLifetime to expiry
$item->expiresAt(null);
}
}
if (!$recompute) {
return $item->get();
}
$pool->save($item->set($value = $callback($item)));
static $save = null;
return $value;
if (null === $save) {
$save = \Closure::bind(
function (CacheItemPoolInterface $pool, CacheItemInterface $item, $value, float $startTime) {
if ($item instanceof CacheItem && $startTime && $item->expiry > $endTime = microtime(true)) {
$item->newMetadata[CacheItem::METADATA_EXPIRY] = $item->expiry;
$item->newMetadata[CacheItem::METADATA_CTIME] = 1000 * (int) ($endTime - $startTime);
}
$pool->save($item->set($value));
return $value;
},
null,
CacheItem::class
);
}
return $save($pool, $item, $callback($item), $t);
}
}