[Cache] Fix ChainAdapter ignoring item expiry when propagating to earlier adapters

This commit is contained in:
Guillaume VDP
2026-03-13 14:34:54 +01:00
committed by Nicolas Grekas
parent 77f5eca135
commit b643496715
4 changed files with 89 additions and 8 deletions

View File

@@ -34,6 +34,7 @@ class ArrayAdapter implements AdapterInterface, CacheInterface, LoggerAwareInter
private array $values = [];
private array $tags = [];
private array $expiries = [];
private array $explicitExpiries = [];
private int $defaultLifetime;
private float $maxLifetime;
private int $maxItems;
@@ -58,7 +59,7 @@ class ArrayAdapter implements AdapterInterface, CacheInterface, LoggerAwareInter
$this->maxLifetime = $maxLifetime;
$this->maxItems = $maxItems;
self::$createCacheItem ??= \Closure::bind(
static function ($key, $value, $isHit, $tags) {
static function ($key, $value, $isHit, $tags, $expiry = null) {
$item = new CacheItem();
$item->key = $key;
$item->value = $value;
@@ -66,6 +67,9 @@ class ArrayAdapter implements AdapterInterface, CacheInterface, LoggerAwareInter
if (null !== $tags) {
$item->metadata[CacheItem::METADATA_TAGS] = $tags;
}
if (null !== $expiry) {
$item->metadata[CacheItem::METADATA_EXPIRY] = $expiry;
}
return $item;
},
@@ -126,7 +130,7 @@ class ArrayAdapter implements AdapterInterface, CacheInterface, LoggerAwareInter
$value = $this->storeSerialized ? $this->unfreeze($key, $isHit) : $this->values[$key];
}
return (self::$createCacheItem)($key, $value, $isHit, $this->tags[$key] ?? null);
return (self::$createCacheItem)($key, $value, $isHit, $this->tags[$key] ?? null, $this->explicitExpiries[$key] ?? null);
}
public function getItems(array $keys = []): iterable
@@ -139,7 +143,7 @@ class ArrayAdapter implements AdapterInterface, CacheInterface, LoggerAwareInter
public function deleteItem(mixed $key): bool
{
\assert('' !== CacheItem::validateKey($key));
unset($this->values[$key], $this->tags[$key], $this->expiries[$key]);
unset($this->values[$key], $this->tags[$key], $this->expiries[$key], $this->explicitExpiries[$key]);
return true;
}
@@ -193,13 +197,19 @@ class ArrayAdapter implements AdapterInterface, CacheInterface, LoggerAwareInter
break;
}
unset($this->values[$k], $this->tags[$k], $this->expiries[$k]);
unset($this->values[$k], $this->tags[$k], $this->expiries[$k], $this->explicitExpiries[$k]);
}
}
$this->values[$key] = $value;
$this->expiries[$key] = $expiry ?? \PHP_INT_MAX;
if (null !== $item["\0*\0expiry"] && \PHP_INT_MAX !== $this->expiries[$key]) {
$this->explicitExpiries[$key] = $this->expiries[$key];
} else {
unset($this->explicitExpiries[$key]);
}
if (null === $this->tags[$key] = $item["\0*\0newMetadata"][CacheItem::METADATA_TAGS] ?? null) {
unset($this->tags[$key]);
}
@@ -224,7 +234,7 @@ class ArrayAdapter implements AdapterInterface, CacheInterface, LoggerAwareInter
foreach ($this->values as $key => $value) {
if (!isset($this->expiries[$key]) || $this->expiries[$key] <= $now || str_starts_with($key, $prefix)) {
unset($this->values[$key], $this->tags[$key], $this->expiries[$key]);
unset($this->values[$key], $this->tags[$key], $this->expiries[$key], $this->explicitExpiries[$key]);
}
}
@@ -233,7 +243,7 @@ class ArrayAdapter implements AdapterInterface, CacheInterface, LoggerAwareInter
}
}
$this->values = $this->tags = $this->expiries = [];
$this->values = $this->tags = $this->expiries = $this->explicitExpiries = [];
return true;
}
@@ -290,7 +300,7 @@ class ArrayAdapter implements AdapterInterface, CacheInterface, LoggerAwareInter
}
unset($keys[$i]);
yield $key => $f($key, $value, $isHit, $this->tags[$key] ?? null);
yield $key => $f($key, $value, $isHit, $this->tags[$key] ?? null, $this->explicitExpiries[$key] ?? null);
}
foreach ($keys as $key) {

View File

@@ -79,6 +79,7 @@ class ChainAdapter implements AdapterInterface, CacheInterface, PruneableInterfa
$item->expiresAt(\DateTimeImmutable::createFromFormat('U.u', \sprintf('%.6F', $item->metadata[CacheItem::METADATA_EXPIRY])));
} elseif (0 < $defaultLifetime) {
$item->expiresAfter($defaultLifetime);
$item->newMetadata[CacheItem::METADATA_EXPIRY] = $item->expiry;
}
return $item;

View File

@@ -170,6 +170,10 @@ final class CacheItem implements ItemInterface
}
$valueWrapper = self::VALUE_WRAPPER;
if ($this->value instanceof $valueWrapper) {
return new $valueWrapper($this->value->value, $m + ['expiry' => $this->expiry] + $this->value->metadata);
}
return new $valueWrapper($this->value, $m + ['expiry' => $this->expiry]);
}

View File

@@ -197,6 +197,72 @@ class ChainAdapterTest extends AdapterTestCase
$this->assertFalse($item->isHit());
}
public function testItemExpiryIsPreservedWhenPropagatedToPreviousAdapters()
{
if (isset($this->skippedTests[__FUNCTION__])) {
$this->markTestSkipped($this->skippedTests[__FUNCTION__]);
}
$adapter1 = new ArrayAdapter(100);
$adapter2 = new ArrayAdapter(100);
$cache = new ChainAdapter([$adapter1, $adapter2], 100);
// Save with an explicit 2-second TTL
$cache->save($cache->getItem('key')->expiresAfter(2)->set('value'));
// Simulate adapter1 miss
$adapter1->clear();
$this->assertFalse($adapter1->hasItem('key'));
$this->assertTrue($adapter2->hasItem('key'));
// This should propagate the item from adapter2 to adapter1,
// preserving the original 2-second TTL (not the 100-second defaultLifetime)
$cache->getItem('key');
$this->assertTrue($adapter1->hasItem('key'));
sleep(3);
$this->assertFalse($adapter2->getItem('key')->isHit(), 'Item should have expired in adapter2');
$this->assertFalse($adapter1->getItem('key')->isHit(), 'Item should have expired in adapter1 with original TTL, not defaultLifetime');
}
public function testItemExpiryIsPreservedWhenPropagatedToPreviousAdaptersUsingGetMethod()
{
if (isset($this->skippedTests[__FUNCTION__])) {
$this->markTestSkipped($this->skippedTests[__FUNCTION__]);
}
$adapter1 = new ArrayAdapter(100);
$adapter2 = new ArrayAdapter(100);
$cache = new ChainAdapter([$adapter1, $adapter2], 100);
// Save with an explicit 2-second TTL via get() callback
$cache->get('key', static function (ItemInterface $item) {
$item->expiresAfter(2);
return 'value';
});
// Simulate adapter1 miss
$adapter1->clear();
$this->assertFalse($adapter1->hasItem('key'));
$this->assertTrue($adapter2->hasItem('key'));
// This should propagate the item from adapter2 to adapter1,
// preserving the original 2-second TTL (not the 100-second defaultLifetime)
$cache->get('key', function () {
$this->fail('Callback should not be called when item exists in adapter2');
});
$this->assertTrue($adapter1->hasItem('key'));
sleep(3);
$this->assertFalse($adapter2->getItem('key')->isHit(), 'Item should have expired in adapter2');
$this->assertFalse($adapter1->getItem('key')->isHit(), 'Item should have expired in adapter1 with original TTL, not defaultLifetime');
}
public function testExpirationOnAllAdapters()
{
if (isset($this->skippedTests[__FUNCTION__])) {
@@ -231,7 +297,7 @@ class ChainAdapterTest extends AdapterTestCase
->willReturn(true);
$cache = new ChainAdapter([$adapter1, $adapter2], 6);
$cache->get('test_key', function (ItemInterface $item) {
$cache->get('test_key', static function (ItemInterface $item) {
$item->expiresAfter(15);
return 'chain';