Skip to content

Commit 1488cfd

Browse files
committed
feat: Use stale while revalidating
1 parent c3f7ddf commit 1488cfd

3 files changed

Lines changed: 256 additions & 0 deletions

File tree

app/AbstractUseStaleRequest.php

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
/**
3+
* Abstract request that uses the pattern "use stale while refetching"
4+
* Concrete classes *must* implement a PHP 8.1 compatible serialization contract (__serialize and __unserialize) for the dispatched jobs to work.
5+
*
6+
* Your refreshAfter time should be much shorter than your cacheExpiresTime
7+
* You could even choose to have cacheExpiresTime return null,
8+
* so any good value is cached indefinitely and only replaced when the re-request succeeds.
9+
*/
10+
declare(strict_types=1);
11+
12+
namespace Carsdotcom\ApiRequest;
13+
14+
use Carbon\Carbon;
15+
use GuzzleHttp\Psr7\Response;
16+
use Illuminate\Support\Facades\Cache;
17+
18+
abstract class AbstractUseStaleRequest extends AbstractRequest
19+
{
20+
protected function responseFromCache(): ?Response
21+
{
22+
$cachedResponse = parent::responseFromCache();
23+
if ($cachedResponse && $this->needsRefresh()) {
24+
Cache::tags($this->cacheTags)->put(
25+
$this->refreshCacheKey(),
26+
'Wait between refreshes',
27+
$this->waitBetweenRefreshes(),
28+
);
29+
$reRequest = clone $this;
30+
dispatch(function () use ($reRequest) {
31+
try {
32+
$reRequest
33+
->setReadCache(false)
34+
->setWriteCache(true)
35+
->sync();
36+
} catch (\Throwable) {
37+
// Nobody cares, this is literally what use-stale is good at
38+
}
39+
});
40+
}
41+
return $cachedResponse;
42+
}
43+
44+
protected function writeResponseToCache(): void
45+
{
46+
if ($this->shouldWriteResponseToCache()) {
47+
Cache::tags($this->cacheTags)->put($this->refreshCacheKey(), 'refresh after', $this->refreshAfter());
48+
}
49+
parent::writeResponseToCache();
50+
}
51+
52+
abstract public function refreshAfter(): Carbon;
53+
54+
public function waitBetweenRefreshes(): Carbon
55+
{
56+
return Carbon::now()->addMinutes(5);
57+
}
58+
59+
public function refreshCacheKey(): string
60+
{
61+
return $this->cacheKey() . ':REFRESH';
62+
}
63+
64+
public function needsRefresh(): bool
65+
{
66+
return !Cache::tags($this->cacheTags)->has($this->refreshCacheKey());
67+
}
68+
69+
public function refreshOnNextRequest(): self
70+
{
71+
Cache::tags($this->cacheTags)->forget($this->refreshCacheKey());
72+
return $this;
73+
}
74+
75+
public function purgeCache(): self
76+
{
77+
$this->refreshOnNextRequest();
78+
return parent::purgeCache();
79+
}
80+
81+
// Children of this class are *required* to thoughtfully implement their own PHP 8.1+ style serialization,
82+
// to work with `dispatch` in `responseFromCache`
83+
// https://php.watch/versions/8.1/serializable-deprecated
84+
abstract public function __serialize(): array;
85+
86+
abstract public function __unserialize(array $data): void;
87+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<?php
2+
/**
3+
* Unit test the AbstractUseStaleRequest request class.
4+
*/
5+
declare(strict_types=1);
6+
7+
namespace Tests\Feature;
8+
9+
use Carsdotcom\ApiRequest\Testing\MocksGuzzleInstance;
10+
use Carsdotcom\ApiRequest\Testing\RequestClassAssertions;
11+
use GuzzleHttp\Psr7\Response;
12+
use Illuminate\Queue\CallQueuedClosure;
13+
use Illuminate\Support\Facades\Cache;
14+
use Illuminate\Support\Facades\Queue;
15+
use Tests\MockClasses\ConcreteUseStaleRequest;
16+
use Tests\BaseTestCase;
17+
18+
/**
19+
* Class AbstractUseStaleRequestTest
20+
* @package Tests\Feature\Requests
21+
*/
22+
class AbstractUseStaleRequestTest extends BaseTestCase
23+
{
24+
use MocksGuzzleInstance;
25+
use RequestClassAssertions;
26+
27+
public function testUsesCachedResults(): void
28+
{
29+
$this->mockGuzzleWithTapper()->addMatchBody('GET', '/test/', 'kablooey', 500);
30+
$request = new ConcreteUseStaleRequest('thing');
31+
32+
self::mockRequestCachedResponse($request, 'All good');
33+
Cache::put($request->cacheKey(), new Response(body: 'All good'));
34+
35+
self::assertSame('All good', $request->sync());
36+
}
37+
38+
public function testNextRequestHasFreshData(): void
39+
{
40+
$this->mockGuzzleWithTapper()->addMatchBody('GET', '/test/', 'new hotness');
41+
$request = new ConcreteUseStaleRequest('thing');
42+
43+
self::mockRequestCachedResponse($request, 'Antique');
44+
self::assertTrue($request->needsRefresh());
45+
46+
self::assertSame('Antique', $request->sync());
47+
self::assertSame('new hotness', $request->sync());
48+
self::assertFalse($request->needsRefresh());
49+
self::assertSame('new hotness', $request->sync());
50+
51+
$this->assertTapperRequestLike('GET', '#test/thing#', 1);
52+
$this->expectTotalRequestCount(1);
53+
}
54+
55+
public function testDeferredClosure(): void
56+
{
57+
Queue::fake();
58+
$this->mockGuzzleWithTapper()->addMatchBody('GET', '/test/', 'new hotness');
59+
$request = new ConcreteUseStaleRequest('thing');
60+
61+
self::mockRequestCachedResponse($request, 'Antique');
62+
self::assertTrue($request->needsRefresh());
63+
64+
self::assertSame('Antique', $request->sync());
65+
self::assertFalse(
66+
$request->needsRefresh(),
67+
'Refresh job is queued, we "snooze" needsRefresh to give it a chance to run.',
68+
);
69+
self::assertSame('Antique', $request->sync(), 'Refresh job hasn\'t run yet, returning stale data.');
70+
71+
Queue::assertPushed(function (CallQueuedClosure $job) use ($request) {
72+
$job->closure->getClosure()();
73+
self::assertSame('new hotness', $request->sync(), 'Refresh job complete, requests return fresh data.');
74+
75+
return true;
76+
});
77+
}
78+
79+
public function testAbsentFromCacheGetsImmediately(): void
80+
{
81+
Queue::fake();
82+
$this->mockGuzzleWithTapper()->addMatchBody('GET', '/test/', 'new hotness');
83+
$request = new ConcreteUseStaleRequest('thing');
84+
85+
self::assertSame('new hotness', $request->sync());
86+
$this->assertTapperRequestLike('GET', '#test/thing#', 1);
87+
$this->expectTotalRequestCount(1);
88+
Queue::assertNothingPushed();
89+
}
90+
91+
public function testPurgesAllCacheKeys(): void
92+
{
93+
$this->mockGuzzleWithTapper()->addMatchBody('GET', '/test/', 'new hotness');
94+
$request = new ConcreteUseStaleRequest('thing');
95+
self::assertFalse($request->canBeFulfilledByCache());
96+
self::assertTrue($request->needsRefresh());
97+
98+
$request->sync();
99+
self::assertTrue($request->canBeFulfilledByCache());
100+
self::assertFalse($request->needsRefresh());
101+
102+
$request->purgeCache();
103+
self::assertFalse($request->canBeFulfilledByCache());
104+
self::assertTrue($request->needsRefresh());
105+
}
106+
107+
public function testRefreshOnNextRequest(): void
108+
{
109+
$this->mockGuzzleWithTapper()->addMatchBody('GET', '/test/', 'new hotness');
110+
$request = new ConcreteUseStaleRequest('thing');
111+
self::assertFalse($request->canBeFulfilledByCache());
112+
self::assertTrue($request->needsRefresh());
113+
114+
$request->sync();
115+
self::assertTrue($request->canBeFulfilledByCache());
116+
self::assertFalse($request->needsRefresh());
117+
118+
$request->refreshOnNextRequest();
119+
self::assertTrue($request->canBeFulfilledByCache(), 'Cache is still available!');
120+
self::assertTrue($request->needsRefresh(), 'But we want to refresh opportunistically');
121+
}
122+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
/**
3+
* Concrete class for use unit testing AbstractUseStaleRequest
4+
* (our usual process of creating anonymous descendants won't work because we need to be able to serialize them for the deferred job
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Tests\MockClasses;
9+
10+
use Carbon\Carbon;
11+
use Carsdotcom\ApiRequest\AbstractUseStaleRequest;
12+
13+
class ConcreteUseStaleRequest extends AbstractUseStaleRequest
14+
{
15+
protected string $method = 'GET';
16+
17+
public function __construct(protected string $param)
18+
{
19+
}
20+
21+
public function getURL(): string
22+
{
23+
return "https://example.com/test/{$this->param}";
24+
}
25+
26+
public function refreshAfter(): Carbon
27+
{
28+
return Carbon::now()->addMinutes(5);
29+
}
30+
31+
public function getLogFolder(): string
32+
{
33+
return 'concrete/use_stale';
34+
}
35+
36+
public function __serialize(): array
37+
{
38+
return [
39+
'param' => $this->param,
40+
];
41+
}
42+
43+
public function __unserialize(array $data): void
44+
{
45+
$this->param = $data['param'];
46+
}
47+
}

0 commit comments

Comments
 (0)