From 221103b1d96fc661cd78059a0fba60ecfa950f09 Mon Sep 17 00:00:00 2001 From: Jeremy Wadhams Date: Tue, 7 Jul 2026 11:21:56 -0500 Subject: [PATCH 1/3] fix: skip background refresh job when cache already refreshed by a prior job Queued refresh lambdas now check whether the cache was already successfully refreshed by another job before making a network request. This prevents a storm of stale-while-revalidating jobs from all hitting upstream after a backlogged queue drains. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- app/AbstractUseStaleRequest.php | 8 ++++ tests/Feature/AbstractUseStaleRequestTest.php | 37 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/app/AbstractUseStaleRequest.php b/app/AbstractUseStaleRequest.php index 3d6b9d1..ef55889 100644 --- a/app/AbstractUseStaleRequest.php +++ b/app/AbstractUseStaleRequest.php @@ -29,6 +29,9 @@ protected function responseFromCache(): ?Response $reRequest = clone $this; dispatch(function () use ($reRequest) { try { + if ($reRequest->cacheIsCurrentlyFresh()) { + return; + } $reRequest ->setReadCache(false) ->setWriteCache(true) @@ -67,6 +70,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()) === 'refresh after'; + } + 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..cc3084c 100644 --- a/tests/Feature/AbstractUseStaleRequestTest.php +++ b/tests/Feature/AbstractUseStaleRequestTest.php @@ -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(); From 4cdabae71cac138bb836f94c038e8b0c07998f7c Mon Sep 17 00:00:00 2001 From: Jeremy Wadhams Date: Tue, 7 Jul 2026 11:32:41 -0500 Subject: [PATCH 2/3] refactor: extract refresh marker strings to named constants Replaces magic strings 'refresh after' and 'Wait between refreshes' with CACHE_IS_NOT_STALE and CACHE_STALE_REFRESH_IS_QUEUED constants, eliminating the silent-mismatch risk introduced when cacheIsCurrentlyFresh() was added. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- app/AbstractUseStaleRequest.php | 9 ++++++--- tests/Feature/AbstractUseStaleRequestTest.php | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/AbstractUseStaleRequest.php b/app/AbstractUseStaleRequest.php index ef55889..90a487a 100644 --- a/app/AbstractUseStaleRequest.php +++ b/app/AbstractUseStaleRequest.php @@ -23,7 +23,7 @@ protected function responseFromCache(): ?Response 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; @@ -49,7 +49,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(); } @@ -65,6 +65,9 @@ public function refreshCacheKey(): string return $this->cacheKey() . ':REFRESH'; } + public const CACHE_IS_NOT_STALE = 'refresh after'; + public const CACHE_STALE_REFRESH_IS_QUEUED = 'Wait between refreshes'; + public function needsRefresh(): bool { return !Cache::tags($this->getCacheTags())->has($this->refreshCacheKey()); @@ -72,7 +75,7 @@ public function needsRefresh(): bool public function cacheIsCurrentlyFresh(): bool { - return Cache::tags($this->getCacheTags())->get($this->refreshCacheKey()) === 'refresh after'; + return Cache::tags($this->getCacheTags())->get($this->refreshCacheKey()) === self::CACHE_IS_NOT_STALE; } public function refreshOnNextRequest(): self diff --git a/tests/Feature/AbstractUseStaleRequestTest.php b/tests/Feature/AbstractUseStaleRequestTest.php index cc3084c..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.', ); From 0370c513a39ab5ac96ca1c0d40ec096e4c45dcd8 Mon Sep 17 00:00:00 2001 From: Jeremy Wadhams Date: Tue, 7 Jul 2026 11:47:55 -0500 Subject: [PATCH 3/3] refactor: move constants to top of class Co-Authored-By: Claude Sonnet 4.6 (1M context) --- app/AbstractUseStaleRequest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/AbstractUseStaleRequest.php b/app/AbstractUseStaleRequest.php index 90a487a..191430d 100644 --- a/app/AbstractUseStaleRequest.php +++ b/app/AbstractUseStaleRequest.php @@ -17,6 +17,9 @@ 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(); @@ -65,9 +68,6 @@ public function refreshCacheKey(): string return $this->cacheKey() . ':REFRESH'; } - public const CACHE_IS_NOT_STALE = 'refresh after'; - public const CACHE_STALE_REFRESH_IS_QUEUED = 'Wait between refreshes'; - public function needsRefresh(): bool { return !Cache::tags($this->getCacheTags())->has($this->refreshCacheKey());