From 1488cfdebaf2b8fb02c2ce833c27ff11b4fe6545 Mon Sep 17 00:00:00 2001 From: Jeremy Wadhams Date: Mon, 19 May 2025 14:09:53 -0500 Subject: [PATCH] feat: Use stale while revalidating --- app/AbstractUseStaleRequest.php | 87 +++++++++++++ tests/Feature/AbstractUseStaleRequestTest.php | 122 ++++++++++++++++++ tests/MockClasses/ConcreteUseStaleRequest.php | 47 +++++++ 3 files changed, 256 insertions(+) create mode 100644 app/AbstractUseStaleRequest.php create mode 100644 tests/Feature/AbstractUseStaleRequestTest.php create mode 100644 tests/MockClasses/ConcreteUseStaleRequest.php diff --git a/app/AbstractUseStaleRequest.php b/app/AbstractUseStaleRequest.php new file mode 100644 index 0000000..3a37172 --- /dev/null +++ b/app/AbstractUseStaleRequest.php @@ -0,0 +1,87 @@ +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; +} diff --git a/tests/Feature/AbstractUseStaleRequestTest.php b/tests/Feature/AbstractUseStaleRequestTest.php new file mode 100644 index 0000000..08f0e79 --- /dev/null +++ b/tests/Feature/AbstractUseStaleRequestTest.php @@ -0,0 +1,122 @@ +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'); + } +} diff --git a/tests/MockClasses/ConcreteUseStaleRequest.php b/tests/MockClasses/ConcreteUseStaleRequest.php new file mode 100644 index 0000000..04ac633 --- /dev/null +++ b/tests/MockClasses/ConcreteUseStaleRequest.php @@ -0,0 +1,47 @@ +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']; + } +}