diff --git a/app/AbstractUseStaleRequest.php b/app/AbstractUseStaleRequest.php index 3d6b9d1..191430d 100644 --- a/app/AbstractUseStaleRequest.php +++ b/app/AbstractUseStaleRequest.php @@ -17,18 +17,24 @@ abstract class AbstractUseStaleRequest extends AbstractRequest { + public const CACHE_IS_NOT_STALE = 'refresh after'; + public const CACHE_STALE_REFRESH_IS_QUEUED = 'Wait between refreshes'; + protected function responseFromCache(): ?Response { $cachedResponse = parent::responseFromCache(); if ($cachedResponse && $this->needsRefresh()) { Cache::tags($this->getCacheTags())->put( $this->refreshCacheKey(), - 'Wait between refreshes', + self::CACHE_STALE_REFRESH_IS_QUEUED, $this->waitBetweenRefreshes(), ); $reRequest = clone $this; dispatch(function () use ($reRequest) { try { + if ($reRequest->cacheIsCurrentlyFresh()) { + return; + } $reRequest ->setReadCache(false) ->setWriteCache(true) @@ -46,7 +52,7 @@ protected function writeResponseToCache(): void if (!$this->shouldWriteResponseToCache()) { return; } - Cache::tags($this->getCacheTags())->put($this->refreshCacheKey(), 'refresh after', $this->refreshAfter()); + Cache::tags($this->getCacheTags())->put($this->refreshCacheKey(), self::CACHE_IS_NOT_STALE, $this->refreshAfter()); parent::writeResponseToCache(); } @@ -67,6 +73,11 @@ public function needsRefresh(): bool return !Cache::tags($this->getCacheTags())->has($this->refreshCacheKey()); } + public function cacheIsCurrentlyFresh(): bool + { + return Cache::tags($this->getCacheTags())->get($this->refreshCacheKey()) === self::CACHE_IS_NOT_STALE; + } + public function refreshOnNextRequest(): self { Cache::tags($this->getCacheTags())->forget($this->refreshCacheKey()); diff --git a/tests/Feature/AbstractUseStaleRequestTest.php b/tests/Feature/AbstractUseStaleRequestTest.php index 5ab15d3..15cf94d 100644 --- a/tests/Feature/AbstractUseStaleRequestTest.php +++ b/tests/Feature/AbstractUseStaleRequestTest.php @@ -154,7 +154,7 @@ public function testFreshFetchWritesRefreshMarker(): void self::assertSame('fresh', $request->sync()); self::assertSame( - 'refresh after', + ConcreteUseStaleRequest::CACHE_IS_NOT_STALE, Cache::tags($request->getCacheTags())->get($request->refreshCacheKey()), 'A successful live fetch must write the refresh marker.', ); @@ -178,6 +178,43 @@ public function testWriteCacheDisabledSkipsRefreshMarker(): void ); } + public function testDeferredJobSkipsWhenCacheAlreadyRefreshed(): void + { + Queue::fake(); + $this->mockGuzzleWithTapper()->addMatchBody('GET', '/test/', 'constant'); + $request = new ConcreteUseStaleRequest('thing'); + + self::mockRequestCachedResponse($request, 'Antique'); + + // First stale request queues J1 + Carbon::setTestNow('2026-01-01 00:00:00'); + self::assertSame('Antique', $request->sync()); + $j1 = null; + Queue::assertPushed(function (CallQueuedClosure $job) use (&$j1) { + $j1 = $job->closure->getClosure(); + return true; + }); + + // Advance past waitBetweenRefreshes so a second stale request can queue J2 + Queue::fake(); + Carbon::setTestNow('2026-01-01 00:06:00'); + self::assertSame('Antique', $request->sync()); + $j2 = null; + Queue::assertPushed(function (CallQueuedClosure $job) use (&$j2) { + $j2 = $job->closure->getClosure(); + return true; + }); + + // J1 runs first — makes a network call and marks the cache fresh + $j1(); + $this->assertTapperRequestLike('GET', '#test/thing#', 1); + self::assertTrue($request->cacheIsCurrentlyFresh()); + + // J2 runs after J1 has already refreshed — should terminate without a network call + $j2(); + $this->expectTotalRequestCount(1); + } + public function testCacheBehaviorUnderHeavyLoad(): void { Queue::fake();