[6.x] Invalidate cached error responses instead of warming them - #15062
[6.x] Invalidate cached error responses instead of warming them#15062duncanmcclean wants to merge 2 commits into
Conversation
with the "half measure" `ApplicationCacher`, 404s get cached alongside successful pages. previously, every error response was also tracked in the forever-cached `.urls` set used for wildcard invalidation and background recache warming. on busy public sites, this meant every bot/scanner junk url ever hit (e.g. `.env`/`.php` probes, dead urls) got permanently tracked. when a wildcard invalidation rule matched, `refreshWildcardUrl()` would dispatch a `StaticWarmJob` for every junk url too, flooding `failed_jobs` and delaying real content updates. `ApplicationCacher::cachePage()` now skips adding a url to the tracked set for any non-2xx response, except the shared-error url used by `share_errors`. the 404 response itself is still cached and served correctly on repeat hits, since that lookup is keyed by url hash and doesn't depend on the `.urls` set.
jasonvarga
left a comment
There was a problem hiding this comment.
Critical: untracked error responses become unreachable by invalidation/flush, causing indefinitely stale errors
ApplicationCacher::invalidateUrl() and flush() locate cached response entries only by walking the tracked .urls set — they never compute the hash key directly. Meanwhile hasCachedPage()/getFromCache() look up cached responses independently of .urls, by hashing the request URL directly (that's how a cached 404 keeps being served on repeat hits). Skipping .urls tracking for non-2xx responses breaks that pairing.
Concrete regression (half-measure caching, default config — no expiry set, so entries are cached via Cache::forever()):
GET /fooreturns 404 (e.g. a not-yet-published/scheduled entry). Cached underresponses:hash(/foo), but no longer added to.urls.- An entry is later published at
/foo→DefaultInvalidator::invalidate()→ApplicationCacher::invalidateUrl('/foo'). invalidateUrlfiltersgetUrls($domain)for/foo— finds nothing, since/foowas never tracked. The staleresponses:hash(/foo)cache entry is never forgotten.GET /fooagain →hasCachedPage()still finds the old cached 404 and serves it. The real, newly-published content never appears — indefinitely, since there's no default TTL.- Even
php artisan statamic:static:cleardoesn't reliably fix this:flush()also only iteratesgetUrls($domain)->keys(). Unless a dedicated cache store is configured for static caching, the orphanedresponses:*entries for untracked error pages are never forgotten by the clear command either.
Before this PR, every cached URL (including 404s) was tracked in .urls, so invalidateUrl/flush could always find and clear it. This needs to be fixed before merge — none of the new tests cover invalidation of a previously-untracked entry, which is why CI is green despite this.
Suggested fix direction: don't solve this by omitting the URL from .urls entirely. Either (a) keep tracking the URL→key mapping for all responses as before, but filter untracked/non-2xx entries out of the wildcard warm/refresh path specifically (refreshWildcardUrl/refreshUrls) rather than out of the tracked set itself — this matches suggestion #3 in the linked issue #15054 ("have refreshWildcardUrl() skip URLs that no longer resolve"); or (b) make invalidateUrl()/flush() able to forget a response by directly hashing the URL, independent of .urls membership.
🤖 Generated with Claude Code
…of warming untracked error responses were unreachable by `invalidateUrl()` and `flush()`, which only walk the tracked url set, so a cached 404 could be served indefinitely even after content was published at that url. error responses are now tracked again, and the refresh path invalidates them instead of dispatching warm jobs that would fail with the same error. the `ResponsePrepared` listener is also guarded so it only handles the response for the request being cached, since stale listeners in long-running processes would re-store entries with later requests' statuses and headers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This pull request fixes an issue where sites using the "half measure" static caching strategy would dispatch cache warming jobs for every URL ever cached — including 404s and other error responses. On busy public sites, every bot/scanner junk URL ever hit (
.env/.phpprobes, dead URLs, etc.) got cached and tracked alongside real pages, so whenSTATAMIC_BACKGROUND_RECACHEis enabled and a wildcard invalidation rule matches, aStaticWarmJobwas dispatched for every one of them — floodingfailed_jobsand delaying real content updates from appearing live.This was happening because the refresh path warms every URL tracked in the static cache's
.urlsset, regardless of what's actually cached for it. Warming an error response just fails with the same error again.This PR fixes it by having
refreshUrl()check the status of the cached response: error responses are invalidated instead of being warmed, so the stale entry is evicted and the next visitor caches a fresh copy. As a bonus, junk URLs converge out of the tracked set over time as wildcard refreshes touch them.Error responses remain tracked in the
.urlsset, so they stay reachable byinvalidateUrl()andflush()— publishing an entry at a previously-404'd URL invalidates the cached 404 as before. One caveat: when anexpiryis configured and a tracked entry has expired, its status can't be known, so it's warmed once (and fails), re-cached by the next visitor, then invalidated on the next refresh — it converges after one cycle rather than failing forever.This PR also guards the
ResponsePreparedlistener inApplicationCacher::cachePage()so it only handles the response for the request being cached. Previously, in long-running processes, stale listeners would re-store earlier cache entries using later requests' statuses and headers.Fixes #10863
Fixes #15054