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
87 changes: 87 additions & 0 deletions app/AbstractUseStaleRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php
/**
* Abstract request that uses the pattern "use stale while refetching"
* Concrete classes *must* implement a PHP 8.1 compatible serialization contract (__serialize and __unserialize) for the dispatched jobs to work.
*
* Your refreshAfter time should be much shorter than your cacheExpiresTime
* You could even choose to have cacheExpiresTime return null,
* so any good value is cached indefinitely and only replaced when the re-request succeeds.
*/
declare(strict_types=1);

namespace Carsdotcom\ApiRequest;

use Carbon\Carbon;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Facades\Cache;

abstract class AbstractUseStaleRequest extends AbstractRequest
{
protected function responseFromCache(): ?Response
{
$cachedResponse = parent::responseFromCache();
if ($cachedResponse && $this->needsRefresh()) {
Cache::tags($this->cacheTags)->put(
$this->refreshCacheKey(),
'Wait between refreshes',
$this->waitBetweenRefreshes(),
);
$reRequest = clone $this;
dispatch(function () use ($reRequest) {
try {
$reRequest
->setReadCache(false)
->setWriteCache(true)
->sync();
} catch (\Throwable) {
// Nobody cares, this is literally what use-stale is good at
}
});
}
return $cachedResponse;
}

protected function writeResponseToCache(): void
{
if ($this->shouldWriteResponseToCache()) {
Cache::tags($this->cacheTags)->put($this->refreshCacheKey(), 'refresh after', $this->refreshAfter());
}
parent::writeResponseToCache();
}

abstract public function refreshAfter(): Carbon;

public function waitBetweenRefreshes(): Carbon
{
return Carbon::now()->addMinutes(5);
}

public function refreshCacheKey(): string
{
return $this->cacheKey() . ':REFRESH';
}

public function needsRefresh(): bool
{
return !Cache::tags($this->cacheTags)->has($this->refreshCacheKey());
}

public function refreshOnNextRequest(): self
{
Cache::tags($this->cacheTags)->forget($this->refreshCacheKey());
return $this;
}

public function purgeCache(): self
{
$this->refreshOnNextRequest();
return parent::purgeCache();
}

// Children of this class are *required* to thoughtfully implement their own PHP 8.1+ style serialization,
// to work with `dispatch` in `responseFromCache`
// https://php.watch/versions/8.1/serializable-deprecated
abstract public function __serialize(): array;

abstract public function __unserialize(array $data): void;
}
122 changes: 122 additions & 0 deletions tests/Feature/AbstractUseStaleRequestTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php
/**
* Unit test the AbstractUseStaleRequest request class.
*/
declare(strict_types=1);

namespace Tests\Feature;

use Carsdotcom\ApiRequest\Testing\MocksGuzzleInstance;
use Carsdotcom\ApiRequest\Testing\RequestClassAssertions;
use GuzzleHttp\Psr7\Response;
use Illuminate\Queue\CallQueuedClosure;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Queue;
use Tests\MockClasses\ConcreteUseStaleRequest;
use Tests\BaseTestCase;

/**
* Class AbstractUseStaleRequestTest
* @package Tests\Feature\Requests
*/
class AbstractUseStaleRequestTest extends BaseTestCase
{
use MocksGuzzleInstance;
use RequestClassAssertions;

public function testUsesCachedResults(): void
{
$this->mockGuzzleWithTapper()->addMatchBody('GET', '/test/', 'kablooey', 500);
$request = new ConcreteUseStaleRequest('thing');

self::mockRequestCachedResponse($request, 'All good');
Cache::put($request->cacheKey(), new Response(body: 'All good'));

self::assertSame('All good', $request->sync());
}

public function testNextRequestHasFreshData(): void
{
$this->mockGuzzleWithTapper()->addMatchBody('GET', '/test/', 'new hotness');
$request = new ConcreteUseStaleRequest('thing');

self::mockRequestCachedResponse($request, 'Antique');
self::assertTrue($request->needsRefresh());

self::assertSame('Antique', $request->sync());
self::assertSame('new hotness', $request->sync());
self::assertFalse($request->needsRefresh());
self::assertSame('new hotness', $request->sync());

$this->assertTapperRequestLike('GET', '#test/thing#', 1);
$this->expectTotalRequestCount(1);
}

public function testDeferredClosure(): void
{
Queue::fake();
$this->mockGuzzleWithTapper()->addMatchBody('GET', '/test/', 'new hotness');
$request = new ConcreteUseStaleRequest('thing');

self::mockRequestCachedResponse($request, 'Antique');
self::assertTrue($request->needsRefresh());

self::assertSame('Antique', $request->sync());
self::assertFalse(
$request->needsRefresh(),
'Refresh job is queued, we "snooze" needsRefresh to give it a chance to run.',
);
self::assertSame('Antique', $request->sync(), 'Refresh job hasn\'t run yet, returning stale data.');

Queue::assertPushed(function (CallQueuedClosure $job) use ($request) {
$job->closure->getClosure()();
self::assertSame('new hotness', $request->sync(), 'Refresh job complete, requests return fresh data.');

return true;
});
}

public function testAbsentFromCacheGetsImmediately(): void
{
Queue::fake();
$this->mockGuzzleWithTapper()->addMatchBody('GET', '/test/', 'new hotness');
$request = new ConcreteUseStaleRequest('thing');

self::assertSame('new hotness', $request->sync());
$this->assertTapperRequestLike('GET', '#test/thing#', 1);
$this->expectTotalRequestCount(1);
Queue::assertNothingPushed();
}

public function testPurgesAllCacheKeys(): void
{
$this->mockGuzzleWithTapper()->addMatchBody('GET', '/test/', 'new hotness');
$request = new ConcreteUseStaleRequest('thing');
self::assertFalse($request->canBeFulfilledByCache());
self::assertTrue($request->needsRefresh());

$request->sync();
self::assertTrue($request->canBeFulfilledByCache());
self::assertFalse($request->needsRefresh());

$request->purgeCache();
self::assertFalse($request->canBeFulfilledByCache());
self::assertTrue($request->needsRefresh());
}

public function testRefreshOnNextRequest(): void
{
$this->mockGuzzleWithTapper()->addMatchBody('GET', '/test/', 'new hotness');
$request = new ConcreteUseStaleRequest('thing');
self::assertFalse($request->canBeFulfilledByCache());
self::assertTrue($request->needsRefresh());

$request->sync();
self::assertTrue($request->canBeFulfilledByCache());
self::assertFalse($request->needsRefresh());

$request->refreshOnNextRequest();
self::assertTrue($request->canBeFulfilledByCache(), 'Cache is still available!');
self::assertTrue($request->needsRefresh(), 'But we want to refresh opportunistically');
}
}
47 changes: 47 additions & 0 deletions tests/MockClasses/ConcreteUseStaleRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php
/**
* Concrete class for use unit testing AbstractUseStaleRequest
* (our usual process of creating anonymous descendants won't work because we need to be able to serialize them for the deferred job
*/
declare(strict_types=1);

namespace Tests\MockClasses;

use Carbon\Carbon;
use Carsdotcom\ApiRequest\AbstractUseStaleRequest;

class ConcreteUseStaleRequest extends AbstractUseStaleRequest
{
protected string $method = 'GET';

public function __construct(protected string $param)
{
}

public function getURL(): string
{
return "https://example.com/test/{$this->param}";
}

public function refreshAfter(): Carbon
{
return Carbon::now()->addMinutes(5);
}

public function getLogFolder(): string
{
return 'concrete/use_stale';
}

public function __serialize(): array
{
return [
'param' => $this->param,
];
}

public function __unserialize(array $data): void
{
$this->param = $data['param'];
}
}