Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/cache/src/Cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,10 @@ public function decrement(Stringable|string $key, int $by = 1): int;

/**
* 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.
*
* @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.
*/
public function resolve(Stringable|string $key, Closure $callback, null|Duration|DateTimeInterface $expiration = null): mixed;
public function resolve(Stringable|string $key, Closure $callback, null|Duration|DateTimeInterface $expiration = null, ?Duration $stale = null): mixed;

/**
* Removes the specified key from the cache.
Expand Down
2 changes: 2 additions & 0 deletions packages/cache/src/CacheInitializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Tempest\Container\Container;
use Tempest\Container\DynamicInitializer;
use Tempest\Container\Singleton;
use Tempest\Core\DeferredTasks;
use Tempest\Reflection\ClassReflector;

use function Tempest\env;
Expand All @@ -25,6 +26,7 @@ public function initialize(ClassReflector $class, ?string $tag, Container $conta
{
return new GenericCache(
cacheConfig: $container->get(CacheConfig::class, $tag),
deferredTasks: $container->get(DeferredTasks::class),
enabled: $this->shouldCacheBeEnabled($tag),
);
}
Expand Down
54 changes: 53 additions & 1 deletion packages/cache/src/GenericCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Psr\Cache\CacheItemPoolInterface;
use Stringable;
use Tempest\Cache\Config\CacheConfig;
use Tempest\Core\DeferredTasks;
use Tempest\DateTime\DateTime;
use Tempest\DateTime\DateTimeInterface;
use Tempest\DateTime\Duration;
Expand All @@ -19,6 +20,7 @@ public function __construct(
private(set) CacheConfig $cacheConfig,
public bool $enabled = true,
private ?CacheItemPoolInterface $adapter = null,
private ?DeferredTasks $deferredTasks = null,
) {
$this->adapter ??= $this->cacheConfig->createAdapter();
}
Expand Down Expand Up @@ -136,12 +138,20 @@ public function getMany(iterable $key): array
);
}

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

if ($stale) {
if ($expiration instanceof Duration) {
$expiration = DateTime::now()->plus($expiration);
}

return $this->resolveAllowingStale($key, $callback, $expiration, $stale);
}

$item = $this->adapter->getItem((string) $key);

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

private function resolveAllowingStale(Stringable|string $key, Closure $callback, DateTimeInterface $expiration, Duration $stale): mixed
{
if (! $this->deferredTasks) {
return $this->resolve($key, $callback, $expiration);
}

$key = (string) $key;
$staleAtCacheKey = "tempest.stale-cache.stale-at.{$key}";
$cachedValue = $this->get($key);
$cachedStaleAt = $this->get($staleAtCacheKey);

// Not in the cache, save it
if (! $cachedValue || ! $cachedStaleAt) {
$value = $callback();

$this->put($key, $value, $expiration->plus($stale));
$this->put($staleAtCacheKey, $expiration->getTimestamp()->getSeconds(), $expiration->plus($stale));

return $value;
}

// Not stale, return the value
if ($cachedStaleAt > DateTime::now()->getTimestamp()->getSeconds()) {
return $cachedValue;
}

// Stale, trigger refresh and return the value
$this->deferredTasks->add(
task: fn () => $this->lock("tempest.stale-cache.lock.{$key}")->execute(function () use ($callback, $key, $cachedStaleAt, $staleAtCacheKey, $stale, $expiration) {
if ($cachedStaleAt !== $this->get($staleAtCacheKey)) {
return;
}

$this->put($key, $callback(), $expiration->plus($stale));
$this->put($staleAtCacheKey, $expiration->getTimestamp()->getSeconds(), $expiration->plus($stale));
}),
name: "tempest.stale-cache.task.{$key}",
);

return $cachedValue;
}

public function remove(Stringable|string $key): void
{
if (! $this->enabled) {
Expand Down
2 changes: 1 addition & 1 deletion packages/cache/src/Testing/RestrictedCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public function getMany(iterable $key): array
throw new ForbiddenCacheUsageException($this->tag);
}

public function resolve(Stringable|string $key, Closure $callback, null|Duration|DateTimeInterface $expiration = null): mixed
public function resolve(Stringable|string $key, Closure $callback, null|Duration|DateTimeInterface $expiration = null, ?Duration $stale = null): mixed
{
throw new ForbiddenCacheUsageException($this->tag);
}
Expand Down
5 changes: 3 additions & 2 deletions packages/cache/src/Testing/TestingCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Tempest\Cache\Config\CustomCacheConfig;
use Tempest\Cache\GenericCache;
use Tempest\Cache\GenericLock;
use Tempest\Core\DeferredTasks;
use Tempest\DateTime\DateTimeInterface;
use Tempest\DateTime\Duration;
use Tempest\Support\Random;
Expand Down Expand Up @@ -82,9 +83,9 @@ public function getMany(iterable $key): array
return $this->cache->getMany($key);
}

public function resolve(Stringable|string $key, Closure $callback, null|Duration|DateTimeInterface $expiration = null): mixed
public function resolve(Stringable|string $key, Closure $callback, null|Duration|DateTimeInterface $expiration = null, ?Duration $stale = null): mixed
{
return $this->cache->resolve($key, $callback, $expiration);
return $this->cache->resolve($key, $callback, $expiration, $stale);
}

public function remove(Stringable|string $key): void
Expand Down
18 changes: 16 additions & 2 deletions packages/core/src/DeferredTasks.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,33 @@

use Closure;
use Tempest\Container\Singleton;
use Tempest\Support\Arr;
use Tempest\Support\Random;

#[Singleton]
final class DeferredTasks
{
/** @var array<string,Closure> */
private array $tasks = [];

public function add(Closure $task): void
/**
* Adds a deferred task to the list of tasks. Optionally, specify a name for uniqueness.
*/
public function add(Closure $task, ?string $name = null): void
{
$this->tasks[] = $task;
$this->tasks[$name ?? Random\secure_string(10)] = $task;
}

public function getTasks(): array
{
return $this->tasks;
}

/**
* Forgets the given deferred task.
*/
public function forget(string $name): void
{
Arr\forget_keys($this->tasks, $name);
}
}
3 changes: 2 additions & 1 deletion packages/core/src/Kernel/FinishDeferredTasks.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ public function __construct(

public function __invoke(): void
{
foreach ($this->deferredTasks->getTasks() as $task) {
foreach ($this->deferredTasks->getTasks() as $name => $task) {
$this->container->invoke($task);
$this->deferredTasks->forget($name);
}
}
}
52 changes: 52 additions & 0 deletions tests/Integration/Cache/CacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
use Tempest\Cache\Config\InMemoryCacheConfig;
use Tempest\Cache\GenericCache;
use Tempest\Cache\NotNumberException;
use Tempest\Core\DeferredTasks;
use Tempest\Core\Kernel\FinishDeferredTasks;
use Tempest\DateTime\Duration;
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;

Expand Down Expand Up @@ -226,4 +228,54 @@ public function test_clear(): void
$this->assertNull($cache->get('a'));
$this->assertNull($cache->get('b'));
}

public function test_stale_while_revalidate(): void
{
$clock = $this->clock();
$cache = new GenericCache(
cacheConfig: new InMemoryCacheConfig(),
adapter: $pool = new ArrayAdapter(clock: $clock->toPsrClock()),
deferredTasks: $tasks = $this->container->get(DeferredTasks::class),
);

// Cache value can be stale for 1min, but will be refreshed in the background
$retrieve = fn (string $value) => $cache->resolve('test', fn () => $value, expiration: Duration::minute(), stale: Duration::minute());

// We fetch the value within the allowed duration, there is no deferring
$this->assertSame('update1', $retrieve('update1'));
$this->assertSame('update1', $pool->getItem('test')->get());
$this->assertSame($clock->now()->plus(Duration::minute())->getTimestamp()->getSeconds(), $pool->getItem('tempest.stale-cache.stale-at.test')->get());
$this->assertEmpty($tasks->getTasks());

// After 30 seconds, we should still get the same value, with no plan for refreshing it
$this->container->invoke(FinishDeferredTasks::class);
$clock->plus(Duration::seconds(30));
$this->assertSame('update1', $retrieve('update2'));
$this->assertSame($clock->now()->plus(Duration::seconds(30))->getTimestamp()->getSeconds(), $pool->getItem('tempest.stale-cache.stale-at.test')->get());
$this->assertEmpty($tasks->getTasks());

// We fetch it again after one minute, within stale window, so under the hood it gets deferred for refresh
$this->container->invoke(FinishDeferredTasks::class);
$clock->plus(Duration::seconds(30));
$this->assertSame('update1', $retrieve('update3'));
$this->assertSame($clock->now()->getTimestamp()->getSeconds(), $pool->getItem('tempest.stale-cache.stale-at.test')->get());
$this->assertCount(1, $tasks->getTasks());

// After 1min30 total, within stale window again, we should get the previous value,
// since it has been refreshed by the deferred task. However, we start the countdown
// again, so we are within the fresh window. So, no update task will be deferred.
$this->container->invoke(FinishDeferredTasks::class);
$clock->plus(Duration::seconds(30));
$this->assertSame('update3', $retrieve('update4'));
$this->assertSame($clock->now()->plus(Duration::seconds(30))->getTimestamp()->getSeconds(), $pool->getItem('tempest.stale-cache.stale-at.test')->get());
$this->assertEmpty($tasks->getTasks());

// We now try it again 2 minutes after last refresh, the value is
// totally invalidated, with no plan on refreshing it yet since we just did.
$this->container->invoke(FinishDeferredTasks::class);
$clock->plus(Duration::minutes(2));
$this->assertSame('update5', $retrieve('update5'));
$this->assertSame($clock->now()->plus(Duration::seconds(60))->getTimestamp()->getSeconds(), $pool->getItem('tempest.stale-cache.stale-at.test')->get());
$this->assertEmpty($tasks->getTasks());
}
}
3 changes: 3 additions & 0 deletions tests/Integration/Core/DeferredTasksTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Tests\Tempest\Integration\Core;

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

$this->assertTrue(DeferController::$executed);
$this->assertEmpty($this->container->get(DeferredTasks::class)->getTasks());
}

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

$this->assertTrue($executed);
$this->assertEmpty($this->container->get(DeferredTasks::class)->getTasks());
}
}