[6.x] Fix static cache urls map race conditions under concurrent requests - #15084
Open
panda4man wants to merge 5 commits into
Open
[6.x] Fix static cache urls map race conditions under concurrent requests#15084panda4man wants to merge 5 commits into
panda4man wants to merge 5 commits into
Conversation
Concurrent requests could each read the URL-to-file-path map, add their own entry, then clobber each other's write with cache->forever(), since the read-modify-write cycle in cacheDomain()/cacheUrl()/forgetUrl() had no locking. This silently dropped tracked URLs even though their static .html files were written to disk correctly, leaving them permanently un-invalidatable since Statamic no longer knew they existed. Wrap the three critical sections in a new withLock() helper that uses Cache::lock()->block() when the store supports it, falling back to the previous unlocked behavior for stores that don't implement LockProvider. Also catch the resulting LockTimeoutException in the CP's "invalidate URLs" utility action, the one synchronous (non-queued) call site that would otherwise surface it as an uncaught 500.
Sites with hundreds of cached URLs could turn the CP's synchronous "invalidate URLs" action into a request-timeout hazard: invalidateUrls() looped per URL, and each iteration's forgetUrl() acquired/released the domain's urls lock independently, so up to LOCK_WAIT (5s) could be spent per URL, all serialized against the same lock key. invalidateUrls() now groups URLs by domain and holds a single lock for each domain's whole batch instead of one per URL. withLock() gained a reentrancy guard so forgetUrl() calls made from within an already-locked batch (via invalidateUrl()) don't try to re-acquire the same lock and deadlock against themselves. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A contended urls lock during cachePage() previously threw out of the middleware, replacing the rendered page with a 503 refresh response. FileCacher also left the written HTML file behind with no urls map entry - served forever but invisible to invalidation. Catch the timeout in both cachers: ApplicationCacher skips storing the response, FileCacher additionally deletes the written file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Invalidation previously held a domain's urls lock for its entire run - file deletes, per-url directory scans, and UrlInvalidated listeners included. A large batch could hold the lock long enough for visitor requests to stall or give up caching, or outlive the lock's expiry entirely, silently reintroducing the lost-update race. Split invalidation into two phases. Under the lock: resolve matching map entries, remove them, and persist the map in a single write. After releasing it: delete files / forget responses and dispatch events. The worst concurrent interleaving is now a map entry whose cached copy was deleted, which self-heals on the next request. Flushing wipes the urls map before deleting stored pages for the same reason. Custom cachers extending AbstractCacher must implement the new cleanupInvalidatedUrls() hook; invalidateUrls() no longer routes through invalidateUrl(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
panda4man
force-pushed
the
fix/static-cache-url-map-race-condition
branch
from
July 28, 2026 07:44
cfc1d38 to
d8b4e60
Compare
Member
|
Thanks for the pull request! Does this fix an open issue or is just something you've run into? |
The lock-contention error added to the CP's "invalidate URLs" utility used the full sentence as its own translation key. Sentence-length strings belong in a lang file, so addons and translators can override them without matching the English copy verbatim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
|
@duncanmcclean This is something I've run in to. If I missed something in the PR guidelines for how to submit this, let me know and I'll revisit to see what changes I need to make. |
Member
|
No problem - was just curious if it closed something. 🙂 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
The static cache keeps a
slug => pathurls map under one cache key per domain (static-cache:{md5(domain)}.urls). Every write to it was an unguarded read–modify–write:The middleware's existing lock is per-URL, so two requests caching different URLs never contend — but they share the same map.
/about/blogOn high-traffic and load-balanced sites this happens routinely.
The fix
1. Serialize map writes with an atomic lock
Works on the file, redis, database, and array stores.
2. Never leave an orphan on lock timeout
FileCacherdeletes the file it just wrote;ApplicationCacherskips storing the response. Either way the rendered response is still served.3. Hold the lock for map bookkeeping only
Lock holds are milliseconds regardless of batch size, so bulk invalidation (e.g. the CP "Invalidate URLs" utility) can no longer stall visitor requests, time out, or outlive the lock's expiry. Flushing wipes the urls map before deleting stored pages, so a page cached mid-flush can't become untracked. As a bonus,
FileCacherruns its untracked-file scan once per batch instead of once per URL, and bulk invalidation writes the map once per domain instead of once per entry.The worst remaining interleaving is a map entry whose cached copy was just deleted — self-healing, since the next request re-caches under the same key. The dangerous direction, a cached page invisible to the map, can no longer occur.
Behavior notes
UrlInvalidatedstill fires for every requested URL (including ones not in the map), with wildcards expanding to the entries they matched — CDN-purger listeners are unaffected.cache_lockstable, which upgraded apps without the default Laravel cache migration may need to add.One breaking change for custom cachers
Custom cachers extending
AbstractCachermust now implement thecleanupInvalidatedUrls()hook, andinvalidateUrls()no longer routes throughinvalidateUrl(). Deliberately abstract, so existing subclasses fail loudly instead of silently skipping their storage cleanup. Cachers implementing theCacherinterface directly are unaffected. (Happy to add hints for an upgrade-guide note.)Tests
Written test-first. Concurrency tests assert: the lock is shared per domain, batches acquire it once and write the map once, cleanup and events run after the lock is released, flush wipes the map before deleting pages, and a contended lock during page caching leaves no orphan while still serving the rendered response. Existing invalidation suites (query-string variants, multisite paths, wildcard expansion, event payloads) pass unchanged except
CacherTest::it_invalidates_urls, which asserted the old internal routing and now asserts the same outcomes and event counts through the new path.🤖 Generated with Claude Code