Skip to content

fix: collapse a bulk invalidation's URL-map writes into one per domain - #4

Merged
Bahbv merged 1 commit into
v2from
fix/batch-invalidation-writes
Sep 15, 2026
Merged

Bahbv merged 1 commit into
v2from
fix/batch-invalidation-writes

Conversation

@Bahbv

@Bahbv Bahbv commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

The symptom

Editors save an entry and the page does not change. Another entry saved in the same minute updates fine. Nothing in laravel.log.

The trace is in failed_jobs. On the site this was found on: 47 failed jobs over 12 days, every one of them Statamic\StaticCaching\Invalidate failing with TimeoutExceededException, with the stack ending in ApplicationCacher::invalidateUrl().

A job that dies part-way through the URL list clears the URLs it reached and leaves the rest stale. That is why it looks arbitrary from the outside — it depends only on where in the list the worker ran out of time.

The cause

Statamic keeps every cached URL for a domain in one cache entry, and AbstractCacher::invalidateUrls() walks the list one URL at a time:

public function invalidateUrl($url, $domain = null)
{
    $this->getUrls($domain)          // read the whole map
        ->filter(...)
        ->each(function ($value, $key) use ($domain) {
            $this->cache->forget($this->normalizeKey('responses:'.$key));
            $this->forgetUrl($key, $domain);   // read it again, write it all back
        });
}

Clearing n URLs out of a map of m costs O(n*m). Measured on the affected site:

URL map 2,139 entries / 230 KB
URLs one article save resolves to 4,615
of those, actually still cached 1,382
cost of one map write 2.24 ms

So a single save rewrote 230 KB roughly 1,400 times, each write taking an exclusive lock on one file that live traffic is also rewriting on every uncached render. Idle, that is ~7s; under traffic it went past the worker's 60s timeout.

This is the graph's precision turned into work. Targeted invalidation hands the cacher a long list of individually correct URLs — that is the whole point — and the per-URL rewrite makes a longer list quadratically more expensive.

The fix

1. Buffer the URL map for the duration of a pass (BatchesInvalidation, used by both tracking cachers).

getUrls() is served from a per-domain buffer while a batch is in flight and forgetUrl() mutates that buffer; the map is written once per domain at the end, and only if something actually changed. The parent's matching, response deletion and event dispatch are untouched — nothing in the trait knows what a cached response looks like, which is why it works for the application and file cachers alike.

Wildcards, query-string variants, multiple domains and refreshUrls() all go through the parent as before.

2. Stop handing the cacher URLs that are no longer cached (GraphInvalidator).

The graph outlives the cache by design — rows are pruned as they are invalidated, not when a page falls out — so on the site above a tag resolved to 4,615 URLs of which 1,382 existed. The rest cost a lookup that can only miss.

One subtlety, and the reason this is not a plain array_intersect: CachedUrls::all() returns [] both for "nothing is cached" and for "this cacher cannot be enumerated" (it gives up on anything that is not an AbstractCacher). Narrowing against an empty list would clear nothing at all on a host app's own cacher. An empty list is therefore read as "unknown" and the full set is passed through. The existing suite caught this — two wiring tests went red on the first attempt.

Result

before after
URL-map writes per save one per URL cleared one per domain touched
URLs handed to the cacher every row the tag resolves to only those still cached

Tests

144 passing, 8 new. Both fixes mutation-checked — with the trait removed the batching tests report 40, 3 and 2 writes where they expect 1; with the narrowing reverted, it_does_not_hand_over_graph_urls_that_are_no_longer_cached fails.

Known limitation, unchanged here

On the full strategy, Statamic's FileCacher::invalidateUrl() also scans its cache directory once per URL. That is its own code and a separate cost; this PR does not touch it. The map fix applies to both strategies.

Changelog

2.0.0 has never been tagged as a stable release — only rc.1rc.7 — so the [Unreleased] fixes from the RC cycle are folded into ## [2.0.0] along with this one, and the date moved to today.

🤖 Generated with Claude Code

Statamic keeps every cached URL for a domain in a single cache entry, and
AbstractCacher::invalidateUrls() walks the list one URL at a time. Each call
reads that entry, and each match calls forgetUrl(), which reads it again and
writes the whole thing back. Clearing n URLs out of a map of m cost O(n*m).

That is the graph's own precision turned into work: targeted invalidation hands
the cacher a long list of individually correct URLs, which is the point, and the
per-URL rewrite makes a longer list quadratically more expensive. On a site with
a couple of thousand cached URLs the map is a few hundred kilobytes, every write
takes an exclusive lock on one file, and live traffic rewrites the same entry on
every uncached render. The queued Invalidate job ran past its timeout, was killed
part-way through the list, and every URL it had not reached went on serving the
old page. Nothing reported it: the only trace was a TimeoutExceededException in
failed_jobs, while the editor saw a saved entry that never appeared.

Buffering getUrls() for the duration of a pass leaves the parent's matching,
response deletion and event dispatch exactly as they are — nothing here knows
what a cached response looks like — while turning m reads and writes into one
read and one write per domain actually touched.

Also stop handing the cacher URLs that are not cached any more. The graph
outlives the cache by design, so a tag routinely resolves to two or three times
as many URLs as the cache holds, and each of those costs a lookup that can only
miss. A cacher whose contents cannot be enumerated still receives the full set:
an empty list there means "unknown", not "nothing is cached", and narrowing
against it would clear nothing at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Bahbv
Bahbv merged commit 788d1e7 into v2 Sep 15, 2026
3 checks passed
@Bahbv
Bahbv deleted the fix/batch-invalidation-writes branch September 15, 2026 12:18
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.

1 participant