Skip to content

Commit

Permalink
Ability to defer value callback as a queued job
Browse files Browse the repository at this point in the history
  • Loading branch information
iksaku committed Sep 12, 2023
1 parent 75adf4f commit 54664d1
Show file tree
Hide file tree
Showing 3 changed files with 179 additions and 13 deletions.
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,51 @@ $stats = \Illuminate\Support\Facades\Cache::swr(
// ...
```

If the value is available in cache, it will be returned immediately,
otherwise, the callback will be executed and the result will be cached.

If the value is stale, the value from cache will be returned immediately,
and the callback will be executed after the response is sent to the user.
You can learn more about this strategy on the
[Running a task after the response is sent](https://divinglaravel.com/running-a-task-after-the-response-is-sent)
post from [Mohamed Said](https://twitter.com/themsaid).

### Queueing the callback execution

If you prefer to queue the callback execution instead of running it after the
response is sent, you can use the `queue` parameter:

```php
$stats = cache()->swr(
key: 'stats',
ttl: now()->addHour(),
tts: now()->addMinutes(15),
callback: function () {
// This may take more than a couple of seconds...
},
queue: true
);
```

And, if you want to further customize the queued job, you can pass on a closure
that accepts a parameter of type [`Illuminate/Foundation/Bus/PendingClosureDispatch`](https://laravel.com/api/9.x/Illuminate/Foundation/Bus/PendingClosureDispatch.html):

```php
use Illuminate/Foundation/Bus/PendingClosureDispatch;

$stats = cache()->swr(
key: 'stats',
ttl: now()->addHour(),
tts: now()->addMinutes(15),
callback: function () {
// This may take more than a couple of seconds...
},
queue: function (PendingClosureDispatch $job) {
$job->onQueue('high-priority')
}
);
```

## Testing

```bash
Expand Down
40 changes: 28 additions & 12 deletions src/LaravelSwrCacheServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@

class LaravelSwrCacheServiceProvider extends ServiceProvider
{
public function boot()
public function boot(): void
{
Repository::macro('swr', function (string $key, mixed $ttl, mixed $tts, Closure $callback) {
Repository::macro('swr', function (string $key, mixed $ttl, mixed $tts, Closure $callback, bool|Closure $queue = false) {
/** @var Repository $this */
if ($this->getSeconds($tts) >= $this->getSeconds($ttl)) {
throw new UnexpectedValueException('The time-to-stale value must be less than the time-to-live value.');
Expand All @@ -23,32 +23,48 @@ public function boot()
// Re-implement the logic of the `remember()` method to avoid the overhead of
// calling `has()` twice on the same key, as well as the need to `forget()`
// the key before the value is ready to be set in cache.
$remember = fn () => tap($callback(), function (mixed $value) use ($key, $ttl, $ttsKey, $tts) {
$evaluateAndStore = function () use ($callback, $key, $ttl, $ttsKey, $tts, $revalidatingKey) {
/** @var Repository $this */
$this->put($key, $value, value($ttl, $value));
$this->put($ttsKey, true, value($tts, true));
});
try {
$value = $callback();

$this->put($key, $value, value($ttl, $value));
$this->put($ttsKey, true, value($tts, true));

return $value;
} finally {
$this->forget($revalidatingKey);
}
};

// Set the value in cache if key doesn't exist.
if ($this->missing($key)) {
return $remember();
return $evaluateAndStore();
}

app()->terminating(function () use ($ttsKey, $revalidatingKey, $remember) {
// After the application has finished handling the request, verify that the
// value in cache is still fresh. If not, re-evaluate the callback and
// store the new value in cache for the next request.
app()->terminating(function () use ($queue, $ttsKey, $revalidatingKey, $evaluateAndStore) {
/** @var Repository $this */
if ($this->has($ttsKey) || $this->has($revalidatingKey)) {
return;
}

$this->put($revalidatingKey, true);

try {
$remember();
} finally {
$this->forget($revalidatingKey);
if (!$queue) {
$evaluateAndStore();
} else {
$queued = dispatch($evaluateAndStore);

if ($queue instanceof Closure) {
$queue($queued);
}
}
});

// Return the (possibly stale) value from cache.
return $this->get($key);
});
}
Expand Down
107 changes: 106 additions & 1 deletion tests/SwrCacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
use Illuminate\Cache\Events\CacheMissed;
use Illuminate\Cache\Events\KeyForgotten;
use Illuminate\Cache\Events\KeyWritten;
use Illuminate\Foundation\Bus\PendingClosureDispatch;
use Illuminate\Queue\CallQueuedClosure;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue;
use function Pest\Laravel\travelTo;

test('swr macro is registered', function () {
Expand Down Expand Up @@ -93,7 +96,7 @@
expect($valueFromCache)->toBe($value);
});

it('returns stale value from cache and queues update', function () {
it('returns stale value from cache and updates after request', function () {
$originalValue = 'original value';

cache()->swr($key = 'key', $ttl = 20, $tts = 10, fn () => $originalValue);
Expand Down Expand Up @@ -136,3 +139,105 @@
expect(cache()->get("{$key}:tts"))->toBeTrue();
expect(cache()->get($key))->toBe($newValue);
});

it('returns stale value from cache and queues update', function () {
$originalValue = 'original value';

cache()->swr($key = 'key', $ttl = 20, $tts = 10, fn () => $originalValue);

travelTo(now()->addSeconds($tts)->addSecond());

Event::fake();
Queue::fake();

$newValue = 'new value';

$staleValue = cache()->swr($key, $ttl, $tts, fn () => $newValue, queue: true);

Event::assertDispatched(CacheHit::class, fn (CacheHit $event) => $event->key === $key);

expect($staleValue)->toBe($originalValue);

app()->terminate();

Event::assertDispatched(CacheMissed::class, fn (CacheMissed $event) => $event->key === "{$key}:tts");
Event::assertDispatched(CacheMissed::class, fn (CacheMissed $event) => $event->key === "{$key}:revalidating");
Event::assertDispatched(KeyWritten::class, fn (KeyWritten $event) => $event->key === "{$key}:revalidating");

Queue::assertPushed(CallQueuedClosure::class, function ($job) {
app()->call([$job, 'handle']);
return true;
});

Event::assertDispatched(
KeyWritten::class,
fn (KeyWritten $event) => $event->key === "{$key}:tts"
&& $event->value === true
&& $event->seconds === $tts
);
Event::assertDispatched(
KeyWritten::class,
fn (KeyWritten $event) => $event->key === $key
&& $event->value === $newValue
&& $event->seconds === $ttl
);
Event::assertDispatched(
KeyForgotten::class,
fn (KeyForgotten $event) => $event->key === "{$key}:revalidating"
);

expect(cache()->get("{$key}:tts"))->toBeTrue();
expect(cache()->get($key))->toBe($newValue);
});

it('returns stale value from cache and (custom) queues update', function () {
$originalValue = 'original value';

cache()->swr($key = 'key', $ttl = 20, $tts = 10, fn () => $originalValue);

travelTo(now()->addSeconds($tts)->addSecond());

Event::fake();
Queue::fake();

$newValue = 'new value';

$staleValue = cache()->swr($key, $ttl, $tts, fn () => $newValue, queue: function (PendingClosureDispatch $job) {
$job->onQueue('custom-queue');
});

Event::assertDispatched(CacheHit::class, fn (CacheHit $event) => $event->key === $key);

expect($staleValue)->toBe($originalValue);

app()->terminate();

Event::assertDispatched(CacheMissed::class, fn (CacheMissed $event) => $event->key === "{$key}:tts");
Event::assertDispatched(CacheMissed::class, fn (CacheMissed $event) => $event->key === "{$key}:revalidating");
Event::assertDispatched(KeyWritten::class, fn (KeyWritten $event) => $event->key === "{$key}:revalidating");

Queue::assertPushedOn('custom-queue', CallQueuedClosure::class, function ($job) {
app()->call([$job, 'handle']);
return true;
});

Event::assertDispatched(
KeyWritten::class,
fn (KeyWritten $event) => $event->key === "{$key}:tts"
&& $event->value === true
&& $event->seconds === $tts
);
Event::assertDispatched(
KeyWritten::class,
fn (KeyWritten $event) => $event->key === $key
&& $event->value === $newValue
&& $event->seconds === $ttl
);
Event::assertDispatched(
KeyForgotten::class,
fn (KeyForgotten $event) => $event->key === "{$key}:revalidating"
);

expect(cache()->get("{$key}:tts"))->toBeTrue();
expect(cache()->get($key))->toBe($newValue);
});

0 comments on commit 54664d1

Please sign in to comment.