Skip to content

[6.x] Fix static cache urls map race conditions under concurrent requests - #15084

Open
panda4man wants to merge 5 commits into
statamic:6.xfrom
panda4man:fix/static-cache-url-map-race-condition
Open

[6.x] Fix static cache urls map race conditions under concurrent requests#15084
panda4man wants to merge 5 commits into
statamic:6.xfrom
panda4man:fix/static-cache-url-map-race-condition

Conversation

@panda4man

@panda4man panda4man commented Jul 28, 2026

Copy link
Copy Markdown

The problem

The static cache keeps a slug => path urls map under one cache key per domain (static-cache:{md5(domain)}.urls). Every write to it was an unguarded read–modify–write:

 time │ Request A — caches /blog │  urls map (single key)  │ Request B — caches /about
──────┼──────────────────────────┼─────────────────────────┼──────────────────────────
  t1  │ read  ──────────────────►│ { }                     │
  t2  │                          │ { } ◄───────────────────│ read
  t3  │ write ──────────────────►│ { /blog }               │
  t4  │                          │ { /about } ◄────────────│ write
      │                          │      ▲                  │
      │                          │      └── /blog entry silently lost

The middleware's existing lock is per-URL, so two requests caching different URLs never contend — but they share the same map.

after t4 page on disk / in cache in urls map invalidation can find it?
/about yes
/blog never — stale until a full cache clear

On high-traffic and load-balanced sites this happens routinely.

The fix

1. Serialize map writes with an atomic lock

 cacheUrl · forgetUrl · cacheDomain · invalidate · flush
                      │
                      ▼
        ┌────────────────────────────────┐
        │ Cache::lock('<urls key>:lock') │  per domain, same store as the map
        └────────────────────────────────┘
                      │
             read → modify → write         serialized, no lost updates

 serving a cached page (reads) ──► no lock, unaffected
 store without LockProvider    ──► runs unlocked, exactly as before

Works on the file, redis, database, and array stores.

2. Never leave an orphan on lock timeout

 before │ render ─► write file ─► can't get lock ─► 503 refresh page, file left orphaned
 after  │ render ─► write file ─► can't get lock ─► undo the write ─► serve response uncached

FileCacher deletes the file it just wrote; ApplicationCacher skips storing the response. Either way the rendered response is still served.

3. Hold the lock for map bookkeeping only

 before — invalidating 500 urls
   lock ├─────────────────────────────────────────────────────────────────┤
        │ 500 × (read map · delete file · write map · dispatch event)     │
        └─ seconds under lock: visitors block, lock can expire mid-run ───┘

 after
   lock ├────┤
        │ resolve matches · one map write │
        └────┘──► unlocked: delete files · dispatch UrlInvalidated events

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, FileCacher runs 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

  • The CP "Invalidate URLs" utility shows a friendly "cache was locked, try again" message instead of a 500 if the lock is genuinely contended.
  • UrlInvalidated still fires for every requested URL (including ones not in the map), with wildcards expanding to the entries they matched — CDN-purger listeners are unaffected.
  • On the database cache driver, locks use the cache_locks table, which upgraded apps without the default Laravel cache migration may need to add.

One breaking change for custom cachers

Custom cachers extending AbstractCacher must now implement the cleanupInvalidatedUrls() hook, and invalidateUrls() no longer routes through invalidateUrl(). Deliberately abstract, so existing subclasses fail loudly instead of silently skipping their storage cleanup. Cachers implementing the Cacher interface 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

Andrew Clinton and others added 4 commits July 23, 2026 23:36
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
panda4man force-pushed the fix/static-cache-url-map-race-condition branch from cfc1d38 to d8b4e60 Compare July 28, 2026 07:44
@duncanmcclean

Copy link
Copy Markdown
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>
@panda4man

Copy link
Copy Markdown
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.

@duncanmcclean

Copy link
Copy Markdown
Member

No problem - was just curious if it closed something. 🙂

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.

2 participants