Skip to content

[6.x] Invalidate cached error responses instead of warming them - #15062

Open
duncanmcclean wants to merge 2 commits into
6.xfrom
dont-track-404-static-cache-urls
Open

[6.x] Invalidate cached error responses instead of warming them#15062
duncanmcclean wants to merge 2 commits into
6.xfrom
dont-track-404-static-cache-urls

Conversation

@duncanmcclean

@duncanmcclean duncanmcclean commented Jul 23, 2026

Copy link
Copy Markdown
Member

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/.php probes, dead URLs, etc.) got cached and tracked alongside real pages, so when STATAMIC_BACKGROUND_RECACHE is enabled and a wildcard invalidation rule matches, a StaticWarmJob was dispatched for every one of them — flooding failed_jobs and delaying real content updates from appearing live.

This was happening because the refresh path warms every URL tracked in the static cache's .urls set, 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 .urls set, so they stay reachable by invalidateUrl() and flush() — publishing an entry at a previously-404'd URL invalidates the cached 404 as before. One caveat: when an expiry is 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 ResponsePrepared listener in ApplicationCacher::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

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 jasonvarga left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()):

  1. GET /foo returns 404 (e.g. a not-yet-published/scheduled entry). Cached under responses:hash(/foo), but no longer added to .urls.
  2. An entry is later published at /fooDefaultInvalidator::invalidate()ApplicationCacher::invalidateUrl('/foo').
  3. invalidateUrl filters getUrls($domain) for /foo — finds nothing, since /foo was never tracked. The stale responses:hash(/foo) cache entry is never forgotten.
  4. GET /foo again → hasCachedPage() still finds the old cached 404 and serves it. The real, newly-published content never appears — indefinitely, since there's no default TTL.
  5. Even php artisan statamic:static:clear doesn't reliably fix this: flush() also only iterates getUrls($domain)->keys(). Unless a dedicated cache store is configured for static caching, the orphaned responses:* 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>
@duncanmcclean duncanmcclean changed the title [6.x] Don't track error responses in the static cache's URL set [6.x] Invalidate cached error responses instead of warming them Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Background recache re-warming URLs that don't exist Non-existent urls are statically cached

2 participants