Skip to content

Commit dde685a

Browse files
authored
feat(cache): support stale while revalidate (#1269)
1 parent 20c3559 commit dde685a

File tree

9 files changed

+135
-8
lines changed

9 files changed

+135
-8
lines changed

packages/cache/src/Cache.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,10 @@ public function decrement(Stringable|string $key, int $by = 1): int;
7878

7979
/**
8080
* If the specified key already exists in the cache, the value is returned and the `$callback` is not executed. Otherwise, the result of the callback is stored, then returned.
81+
*
82+
* @var null|Duration $stale Allow the value to be stale for the specified amount of time in addition to the time-to-live specified by `$expiration`. When a value is stale, it will still be returned as-is, but it will be refreshed in the background.
8183
*/
82-
public function resolve(Stringable|string $key, Closure $callback, null|Duration|DateTimeInterface $expiration = null): mixed;
84+
public function resolve(Stringable|string $key, Closure $callback, null|Duration|DateTimeInterface $expiration = null, ?Duration $stale = null): mixed;
8385

8486
/**
8587
* Removes the specified key from the cache.

packages/cache/src/CacheInitializer.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Tempest\Container\Container;
99
use Tempest\Container\DynamicInitializer;
1010
use Tempest\Container\Singleton;
11+
use Tempest\Core\DeferredTasks;
1112
use Tempest\Reflection\ClassReflector;
1213
use UnitEnum;
1314

@@ -26,6 +27,7 @@ public function initialize(ClassReflector $class, null|string|UnitEnum $tag, Con
2627
{
2728
return new GenericCache(
2829
cacheConfig: $container->get(CacheConfig::class, $tag),
30+
deferredTasks: $container->get(DeferredTasks::class),
2931
enabled: $this->shouldCacheBeEnabled($tag),
3032
);
3133
}

packages/cache/src/GenericCache.php

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Psr\Cache\CacheItemPoolInterface;
88
use Stringable;
99
use Tempest\Cache\Config\CacheConfig;
10+
use Tempest\Core\DeferredTasks;
1011
use Tempest\DateTime\DateTime;
1112
use Tempest\DateTime\DateTimeInterface;
1213
use Tempest\DateTime\Duration;
@@ -19,6 +20,7 @@ public function __construct(
1920
private(set) CacheConfig $cacheConfig,
2021
public bool $enabled = true,
2122
private ?CacheItemPoolInterface $adapter = null,
23+
private ?DeferredTasks $deferredTasks = null,
2224
) {
2325
$this->adapter ??= $this->cacheConfig->createAdapter();
2426
}
@@ -136,12 +138,20 @@ public function getMany(iterable $key): array
136138
);
137139
}
138140

139-
public function resolve(Stringable|string $key, Closure $callback, null|Duration|DateTimeInterface $expiration = null): mixed
141+
public function resolve(Stringable|string $key, Closure $callback, null|Duration|DateTimeInterface $expiration = null, ?Duration $stale = null): mixed
140142
{
141143
if (! $this->enabled) {
142144
return $callback();
143145
}
144146

147+
if ($stale) {
148+
if ($expiration instanceof Duration) {
149+
$expiration = DateTime::now()->plus($expiration);
150+
}
151+
152+
return $this->resolveAllowingStale($key, $callback, $expiration, $stale);
153+
}
154+
145155
$item = $this->adapter->getItem((string) $key);
146156

147157
if (! $item->isHit()) {
@@ -151,6 +161,48 @@ public function resolve(Stringable|string $key, Closure $callback, null|Duration
151161
return $item->get();
152162
}
153163

164+
private function resolveAllowingStale(Stringable|string $key, Closure $callback, DateTimeInterface $expiration, Duration $stale): mixed
165+
{
166+
if (! $this->deferredTasks) {
167+
return $this->resolve($key, $callback, $expiration);
168+
}
169+
170+
$key = (string) $key;
171+
$staleAtCacheKey = "tempest.stale-cache.stale-at.{$key}";
172+
$cachedValue = $this->get($key);
173+
$cachedStaleAt = $this->get($staleAtCacheKey);
174+
175+
// Not in the cache, save it
176+
if (! $cachedValue || ! $cachedStaleAt) {
177+
$value = $callback();
178+
179+
$this->put($key, $value, $expiration->plus($stale));
180+
$this->put($staleAtCacheKey, $expiration->getTimestamp()->getSeconds(), $expiration->plus($stale));
181+
182+
return $value;
183+
}
184+
185+
// Not stale, return the value
186+
if ($cachedStaleAt > DateTime::now()->getTimestamp()->getSeconds()) {
187+
return $cachedValue;
188+
}
189+
190+
// Stale, trigger refresh and return the value
191+
$this->deferredTasks->add(
192+
task: fn () => $this->lock("tempest.stale-cache.lock.{$key}")->execute(function () use ($callback, $key, $cachedStaleAt, $staleAtCacheKey, $stale, $expiration) {
193+
if ($cachedStaleAt !== $this->get($staleAtCacheKey)) {
194+
return;
195+
}
196+
197+
$this->put($key, $callback(), $expiration->plus($stale));
198+
$this->put($staleAtCacheKey, $expiration->getTimestamp()->getSeconds(), $expiration->plus($stale));
199+
}),
200+
name: "tempest.stale-cache.task.{$key}",
201+
);
202+
203+
return $cachedValue;
204+
}
205+
154206
public function remove(Stringable|string $key): void
155207
{
156208
if (! $this->enabled) {

packages/cache/src/Testing/RestrictedCache.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ public function getMany(iterable $key): array
5858
throw new ForbiddenCacheUsageException($this->tag);
5959
}
6060

61-
public function resolve(Stringable|string $key, Closure $callback, null|Duration|DateTimeInterface $expiration = null): mixed
61+
public function resolve(Stringable|string $key, Closure $callback, null|Duration|DateTimeInterface $expiration = null, ?Duration $stale = null): mixed
6262
{
6363
throw new ForbiddenCacheUsageException($this->tag);
6464
}

packages/cache/src/Testing/TestingCache.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use Tempest\Cache\Config\CustomCacheConfig;
1313
use Tempest\Cache\GenericCache;
1414
use Tempest\Cache\GenericLock;
15+
use Tempest\Core\DeferredTasks;
1516
use Tempest\DateTime\DateTimeInterface;
1617
use Tempest\DateTime\Duration;
1718
use Tempest\Support\Random;
@@ -82,9 +83,9 @@ public function getMany(iterable $key): array
8283
return $this->cache->getMany($key);
8384
}
8485

85-
public function resolve(Stringable|string $key, Closure $callback, null|Duration|DateTimeInterface $expiration = null): mixed
86+
public function resolve(Stringable|string $key, Closure $callback, null|Duration|DateTimeInterface $expiration = null, ?Duration $stale = null): mixed
8687
{
87-
return $this->cache->resolve($key, $callback, $expiration);
88+
return $this->cache->resolve($key, $callback, $expiration, $stale);
8889
}
8990

9091
public function remove(Stringable|string $key): void

packages/core/src/DeferredTasks.php

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,33 @@
66

77
use Closure;
88
use Tempest\Container\Singleton;
9+
use Tempest\Support\Arr;
10+
use Tempest\Support\Random;
911

1012
#[Singleton]
1113
final class DeferredTasks
1214
{
15+
/** @var array<string,Closure> */
1316
private array $tasks = [];
1417

15-
public function add(Closure $task): void
18+
/**
19+
* Adds a deferred task to the list of tasks. Optionally, specify a name for uniqueness.
20+
*/
21+
public function add(Closure $task, ?string $name = null): void
1622
{
17-
$this->tasks[] = $task;
23+
$this->tasks[$name ?? Random\secure_string(10)] = $task;
1824
}
1925

2026
public function getTasks(): array
2127
{
2228
return $this->tasks;
2329
}
30+
31+
/**
32+
* Forgets the given deferred task.
33+
*/
34+
public function forget(string $name): void
35+
{
36+
Arr\forget_keys($this->tasks, $name);
37+
}
2438
}

packages/core/src/Kernel/FinishDeferredTasks.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ public function __construct(
1616

1717
public function __invoke(): void
1818
{
19-
foreach ($this->deferredTasks->getTasks() as $task) {
19+
foreach ($this->deferredTasks->getTasks() as $name => $task) {
2020
$this->container->invoke($task);
21+
$this->deferredTasks->forget($name);
2122
}
2223
}
2324
}

tests/Integration/Cache/CacheTest.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
use Tempest\Cache\Config\InMemoryCacheConfig;
99
use Tempest\Cache\GenericCache;
1010
use Tempest\Cache\NotNumberException;
11+
use Tempest\Core\DeferredTasks;
12+
use Tempest\Core\Kernel\FinishDeferredTasks;
1113
use Tempest\DateTime\Duration;
1214
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
1315

@@ -226,4 +228,54 @@ public function test_clear(): void
226228
$this->assertNull($cache->get('a'));
227229
$this->assertNull($cache->get('b'));
228230
}
231+
232+
public function test_stale_while_revalidate(): void
233+
{
234+
$clock = $this->clock();
235+
$cache = new GenericCache(
236+
cacheConfig: new InMemoryCacheConfig(),
237+
adapter: $pool = new ArrayAdapter(clock: $clock->toPsrClock()),
238+
deferredTasks: $tasks = $this->container->get(DeferredTasks::class),
239+
);
240+
241+
// Cache value can be stale for 1min, but will be refreshed in the background
242+
$retrieve = fn (string $value) => $cache->resolve('test', fn () => $value, expiration: Duration::minute(), stale: Duration::minute());
243+
244+
// We fetch the value within the allowed duration, there is no deferring
245+
$this->assertSame('update1', $retrieve('update1'));
246+
$this->assertSame('update1', $pool->getItem('test')->get());
247+
$this->assertSame($clock->now()->plus(Duration::minute())->getTimestamp()->getSeconds(), $pool->getItem('tempest.stale-cache.stale-at.test')->get());
248+
$this->assertEmpty($tasks->getTasks());
249+
250+
// After 30 seconds, we should still get the same value, with no plan for refreshing it
251+
$this->container->invoke(FinishDeferredTasks::class);
252+
$clock->plus(Duration::seconds(30));
253+
$this->assertSame('update1', $retrieve('update2'));
254+
$this->assertSame($clock->now()->plus(Duration::seconds(30))->getTimestamp()->getSeconds(), $pool->getItem('tempest.stale-cache.stale-at.test')->get());
255+
$this->assertEmpty($tasks->getTasks());
256+
257+
// We fetch it again after one minute, within stale window, so under the hood it gets deferred for refresh
258+
$this->container->invoke(FinishDeferredTasks::class);
259+
$clock->plus(Duration::seconds(30));
260+
$this->assertSame('update1', $retrieve('update3'));
261+
$this->assertSame($clock->now()->getTimestamp()->getSeconds(), $pool->getItem('tempest.stale-cache.stale-at.test')->get());
262+
$this->assertCount(1, $tasks->getTasks());
263+
264+
// After 1min30 total, within stale window again, we should get the previous value,
265+
// since it has been refreshed by the deferred task. However, we start the countdown
266+
// again, so we are within the fresh window. So, no update task will be deferred.
267+
$this->container->invoke(FinishDeferredTasks::class);
268+
$clock->plus(Duration::seconds(30));
269+
$this->assertSame('update3', $retrieve('update4'));
270+
$this->assertSame($clock->now()->plus(Duration::seconds(30))->getTimestamp()->getSeconds(), $pool->getItem('tempest.stale-cache.stale-at.test')->get());
271+
$this->assertEmpty($tasks->getTasks());
272+
273+
// We now try it again 2 minutes after last refresh, the value is
274+
// totally invalidated, with no plan on refreshing it yet since we just did.
275+
$this->container->invoke(FinishDeferredTasks::class);
276+
$clock->plus(Duration::minutes(2));
277+
$this->assertSame('update5', $retrieve('update5'));
278+
$this->assertSame($clock->now()->plus(Duration::seconds(60))->getTimestamp()->getSeconds(), $pool->getItem('tempest.stale-cache.stale-at.test')->get());
279+
$this->assertEmpty($tasks->getTasks());
280+
}
229281
}

tests/Integration/Core/DeferredTasksTest.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Tests\Tempest\Integration\Core;
66

77
use Tempest\Container\Container;
8+
use Tempest\Core\DeferredTasks;
89
use Tempest\Core\Kernel\FinishDeferredTasks;
910
use Tests\Tempest\Fixtures\Controllers\DeferController;
1011
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
@@ -28,6 +29,7 @@ public function test_deferred_tasks_are_executed(): void
2829
$this->container->invoke(FinishDeferredTasks::class);
2930

3031
$this->assertTrue(DeferController::$executed);
32+
$this->assertEmpty($this->container->get(DeferredTasks::class)->getTasks());
3133
}
3234

3335
public function test_deferred_tasks_are_executed_with_container_parameters(): void
@@ -43,5 +45,6 @@ public function test_deferred_tasks_are_executed_with_container_parameters(): vo
4345
$this->container->invoke(FinishDeferredTasks::class);
4446

4547
$this->assertTrue($executed);
48+
$this->assertEmpty($this->container->get(DeferredTasks::class)->getTasks());
4649
}
4750
}

0 commit comments

Comments
 (0)