feat(plugin): bulk cache invalidation — one row, compared on the serve path, instead of rewriting 1.6M rows (v0.35.0) - #74
Conversation
Records ONE row naming a scope and an instant. From then on any cached page in that scope rendered before that instant stops being served and bots get the origin, until the page re-renders on its normal cadence. Nothing is rewritten. WHY NOT REWRITE THE CORPUS, which `Target.revalidate`'s collection form already does. Measured on 400,000 rows (one node's slice of 1.61M keys): 15.7s and 61.8 MB of audit per node per invalidation, at 162 B/row — and pacing does not reduce it (same 162 B/write, 8.9x longer, claim max latency WORSE: 47.4 -> 62.4 ms). The naive form is uniquely bad on top: collapsing due times to currentMinuteMs() piles rows exactly where the claim scan seeks, 0.36 -> 11.59 ms (32x), clearing only on the next compaction of that store. Recording an epoch instead: 0.18 ms, 102 bytes — ~606,000x less audit — and undo is instant because `lastCached` is never touched. WHY NOT A QUEUE-SIDE SWEEP, ever. 1,530,046 PDP keys / 71,289 renders/hr = a 21.5h floor at 100% utilisation, against the 48h such a page waits anyway, while measured utilisation is 98% with a 3.05h backlog. Cadence-heal is correct by CONSTRUCTION: nothing in claim, syncQueueState or reconcile reads PrerenderedPage, so the epoch cannot perturb the queue at all. WHY NOT `expiresAt`. It is lastCached + interval, so testing it under-invalidates by up to a full interval — 48h on PDP, precisely the direction that keeps serving the pre-change page. SCOPE AXIS: `all` or one compiled prerender route (`route:<match>:<path>`), COMPARED never parsed (a path may contain a colon). The route list is the axis because it is the only closed, already-compiled partition of the corpus: `classifyPath` already returns the matched entry, so a page's scope derives synchronously with no extra read. A closed set is load-bearing twice over — an unvalidatable prefix scope would record a row that reports applied and matches nothing, which is the worst failure available because the operator's mitigation appears to have worked; and it is what keeps resolution to two point reads by known key instead of a walk. The gate is the other half of the cost argument: the epoch is read ONLY when the request would otherwise have been a cache serve. A miss, a cache skip, a non-GET, a page past its SWR window and `enabled: false` all pay zero, and it is what makes the `invalidated` counter mean "cache serves this is costing us" rather than a tally of every stale key in the scope. `cacheServeStatus` -> `resolveServeStatus` with a REQUIRED `epoch`, in one change on purpose: a call site left on the old name is an import error, not a silently epoch-blind freshness check reporting a page as fresh while bots get the origin. It throws on a missing key AND on `epoch: undefined` — the latter is the more dangerous case, since it looks deliberate and would report `epochConsulted: true`. Also here, because they are the same request's correctness: - `renderNow.fallback: 'stale'` served a cached page with NO check at all. It is opt-in staleness, so no freshness check belongs there — but an invalidation says "this is WRONG", not "this is old", and that path is the one cache read that never passes the gate. - An invalidated-but-fresh page is not a miss for the on-demand levers. With `defaultMissMode: 'prerender'` every authorized request to one became an unjittered currentMinuteMs() schedule write plus up to 30s polling for a render with no reason to arrive. Forced to 'origin' unless `missHeader: prerender` was ASKED for — hence `missModeExplicit`, since `missMode` alone cannot tell a gesture from a default. - `page_age_negative`: negative ages were silently discarded on every served request, and they are the only evidence of cross-node clock skew — which is half of what `invalidation.pad` covers, and the half its default rests on no evidence for. - Admin views get the epoch too, resolved ONCE per request and derived per row synchronously, so the console cannot report `cached` for a page bots are being proxied for. `entryState`'s select gains `lastCached`, without which it could not. Ships INERT: with the table empty every verdict equals today's, and the only behavioural difference on the serve path is one conditional point read. Deliberately NOT in this commit: the demand-driven accelerator and `util/schedule.js#lowerDueTime`. Their contract is written against the claim watermark in #72, which is not on main — there is nothing for them to bind to yet. 396 tests pass; lint and Prettier clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
When an invalidation is what made a request non-servable, lower that URL's due time so
the pages bots actually crawl heal first. Request-triggered: no timer, no scan, no
cursor. Roughly 4,000 requests/day against 1.6M keys, of which crawlers ask for ~0.25%.
Off by default, like render.reconcile.enabled: enable it after one rehearsal, not on
the deploy that introduces it. With it off there is no series, no read and no write.
WHY NOT A SWEEP, restated because this is the knob people will reach for instead:
1,530,046 PDP keys / 71,289 renders/hr is a 21.5h floor at 100% utilisation, against
the 48h such a page waits anyway, and utilisation is already 98% with a 3.05h backlog.
A bulk rewrite also costs ~61.8 MB of audit per node, which pacing provably does not
reduce. This is a heal-what-is-asked-for mechanism, not a corpus mechanism.
GUARDS, every one from a read it already makes. Outcome dimension names each refusal,
so §12.4 (real accelerator coverage) becomes measurable instead of asserted:
not-owner the claim floor is a node-local SAB, so a cross-node write lowers the
wrong node's floor (measured: 0 rows, 23ms — invisible to claim)
paused a paused node must not stockpile pulled-forward due times
leased a live lease means a render is in flight for that key right now
no-schedule never creates a schedule row
no-target never creates a Target
unhealable past fastRetries, or a render COMPLETED after the epoch and still did
not heal the key. strikes > 0 cannot express the second: the
discardContent path reschedules at cadence AND zeroes strikes
not-sooner never raises a due time
throttled per-node minute budget, reserved LATE so one hot unhealable URL cannot
starve healable keys
Jitter is getInitialRenderTime(cacheKey, spreadWindow), NEVER currentMinuteMs(): a
pile of due times at `now` lands exactly where the claim scan seeks and takes it from
0.36ms to 11.59ms. The write fans out over cacheKeysOf(url) so a URL's device variants
stay on one minute — lowering only the crawled device would leave the other on the
pre-change page for a full interval, and because processJobResult reschedules from
each completion the split would be PERMANENT, cycle over cycle, with no metric able to
show it. It goes through #72's funnel, so the floor is lowered with the write.
Three adversarial reviewers found 12 issues; 8 were real and are fixed here, 4
rejected. The two worth naming, both defects in the first implementation:
- `not-sooner` and the unhealable verdict were derived per-URL from one device's row.
On a split pair — a normal state, since revalidate/renderNow write one device key
— a mobile crawl re-armed a desktop row a desktop crawl had just been refused, and
a desktop crawl refused the mobile key that could still heal. Now per-row.
- The rate window rolled in BOTH directions and sampled the minute before an awaited
read, so a boundary-straddling request CAS'd the window backwards and zeroed a
counter the new minute had already spent. The documented ceiling bounded nothing.
Also fixed here, both flagged by the design as pre-existing:
- The fromSitemap clobber in renderNow's schedule write. `put` REPLACES the record,
so one on-demand render of a sitemap-listed URL cleared the flag, and the renderer
then skips serializing a non-indexable sitemap-listed page — that page silently
stops being cached at all. A source-scan test now fails the build on any literal
`fromSitemap: false`, which is the shape this bug takes twice.
- Target.revalidate trusted the CALLER'S projection for sitemapUrl: `?select(url)`
made it absent, indistinguishable from null, so every revalidated key re-filed
fromSitemap: false; a select without `url` made the sweep report success having
written nothing. Both silent. An unusable projection is now refused BY NAME rather
than rebuilt, because spreading a Harper request target into a fresh object is how
the single-id form silently becomes a whole-registry sweep.
514 tests pass; lint and Prettier clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…x, core tests
Everything needed to actually USE the mechanism, plus the three things that made the
previous commits unfinishable as a feature.
THE API. `POST /prerender_admin/invalidate` (record / `dryRun` / `mode: null` to
clear) and `GET /prerender_admin/invalidations`. Home is PrerenderAdmin because it is
the only resource in this plugin that authenticates — one request here takes the whole
corpus off the serve path, and an unauthenticated version of that is strictly worse
than an unauthenticated fleet pause, which RenderQueue already keeps node-scoped for
exactly that reason.
Validation, each rule aimed at a SILENT outcome rather than an invalid one:
- scope must be in the closed set; the 400 lists the valid literals, because
"unknown scope" without them is the message that sends an operator to guess, and a
guessed scope is a row that reports applied and matches nothing
- reason required, <=200 chars — the only record of intent that outlives the incident
- invalidatedAt is server-stamped and a supplied one is REJECTED, not ignored: an
operator who thinks they backdated an invalidation and did not has a corpus they
believe is invalidated and isn't
- 409 when enabled:false (a row the serve path never reads is the definition of
silent), and 409 past maxScopes
The response is the primary correctness surface and doubles as the dryRun preflight —
same body, minus the write, so "what would this do" and "what did this do" cannot
drift. It names what the scope covers, every other applicable scope (so the max(at)
precedence is visible rather than inferred), and the two things the plugin cannot do.
The clear path is computed from what it just did, never by re-reading: a row deleted
earlier in a request is still visible to a read in that request, so re-reading would
make the one operation whose entire value is confirmation report the opposite of what
happened. Its warning states the asymmetry with numbers — undo is instant for pages
still inside their own window and IMPOSSIBLE for pages whose window elapsed while the
invalidation was active.
THE 304 HAZARD, which is the one hole that could silently defeat a hard invalidation.
The validators a crawler holds came from us, off the snapshot just invalidated. They
are forwarded to the origin, whose ETag may be a publish date rather than a content
hash, so it answers 304; and applyConditional compares them against the response
headers, which on an origin proxy are the origin's, so it can produce the 304 locally.
Either way the request records cacheStatus 'invalidated' while the crawler keeps the
pre-change bytes — every signal reads as success. Both halves are closed, scoped to
that one verdict. Independently: applyDebugHeaders now runs AFTER applyConditional on
every path, because downgradeTo304 replaces the header set, so "one curl from a render
pod is a complete diagnosis" was false whenever the curl carried a validator.
TWO DEAD ENTRY POINTS, now wired via startInvalidationWatch in extension.js, on every
worker and re-run on every config apply. Priming covers the one uncovered read (the
HTTP handler is installed at module load, before handleApplication). The resolvability
check is the only detector for the failure the closed set exists to prevent, and it
must run on config APPLY: a route renamed by a live edit un-invalidates whatever that
scope covered, leaving a row that still looks applied and now matches nothing.
collectConfigWarnings cannot see it — it is synchronous and cannot read a table.
enabled:false while rows exist is now reported on boot and on every apply, and flagged
on GET /invalidations. Silently serving content somebody deliberately invalidated is
the one outcome this feature must never produce.
TESTS FOR util/invalidation.js, which had none — the module everything else rests on,
where every failure is silent rather than loud. 16 tests: max(at) precedence in BOTH
insertion orders (a stale route row must not hide a fresh `all`), scope isolation, pad
applied at resolution, the read count itself (two point reads by known key; zero when
disabled), an unreadable invalidatedAt applying to nothing with no updatedTime
fallback, unknown mode treated as hard, and the four LKG properties — absence stored,
fresh fallback used, expiry failing open, no-LKG failing open.
That last group found a real bug: `lkgMaxAge: 0` is documented as "fail open on the
first read error", but `age <= maxAge` is true when both are 0 — which is the common
case, two reads in one millisecond — so the documented way to switch the LKG off did
not switch it off. Now strictly less-than.
Also fixed a flaky test of my own from the previous commit: the detached-attempt case
slept 5ms for a `setImmediate` plus several awaits, which passed locally and failed
under load. It polls with a deadline now. Suite run 5× clean.
530 tests pass; lint and Prettier clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a bulk cache invalidation feature that demotes cached pages rendered before a specified epoch without rewriting the corpus. It also implements a demand-driven 'invalidation accelerator' to prioritize re-rendering pages that bots actually crawl. Additionally, the changes fix several pre-existing bugs related to sitemap flags being cleared during on-demand renders and sweeps. The review feedback suggests improving robustness by adding defensive input validation guards for exported functions to prevent potential TypeErrors, and performing case-insensitive hostname comparisons to avoid mismatches.
| const owner = getResidencyByUrl(url); | ||
| if (owner !== server.hostname) return refuse('not-owner', { owner }); |
There was a problem hiding this comment.
According to the general rules, hostname comparisons should be performed case-insensitively to comply with the specification and prevent mismatches caused by different DNS or server configuration casings.
| const owner = getResidencyByUrl(url); | |
| if (owner !== server.hostname) return refuse('not-owner', { owner }); | |
| const owner = getResidencyByUrl(url); | |
| if (owner.toLowerCase() !== server.hostname.toLowerCase()) return refuse('not-owner', { owner }); |
References
- Hostname comparisons should be performed case-insensitively to comply with the specification and prevent mismatches caused by different DNS or server configuration casings.
There was a problem hiding this comment.
Dismissing — and the suggested fix would be actively wrong here. getResidencyByUrl returns an element of [server.hostname, ...server.nodes].sort(), so when this node owns the key the returned winner is the server.hostname string itself — the self-comparison cannot miscompare on casing. A casing mismatch could only arise if this node also appeared in server.nodes under different casing, which double-enters it in the HRW ring and breaks residency ownership cluster-wide — a cluster-config bug no local .toLowerCase() repairs. Making only this one site case-insensitive would diverge from the identical exact comparison in RenderQueue and from the residency-list computation itself; consistency of the exact comparison is the correctness property.
| export const accelerateHeal = async ({ url, cacheKey, invalidatedBy }) => { | ||
| const scope = invalidatedBy?.scope ?? null; | ||
| const refuse = (outcome, extra) => { | ||
| record(outcome, scope); | ||
| return { outcome, ...extra }; | ||
| }; | ||
|
|
There was a problem hiding this comment.
Defensive programming: invalidatedBy is a nullable reference and could potentially be null or undefined if accelerateHeal is called directly from other contexts. Adding a guard at the entry point of accelerateHeal prevents potential TypeErrors downstream (such as when accessing invalidatedBy.at on line 295).
| export const accelerateHeal = async ({ url, cacheKey, invalidatedBy }) => { | |
| const scope = invalidatedBy?.scope ?? null; | |
| const refuse = (outcome, extra) => { | |
| record(outcome, scope); | |
| return { outcome, ...extra }; | |
| }; | |
| export const accelerateHeal = async ({ url, cacheKey, invalidatedBy }) => { | |
| const scope = invalidatedBy?.scope ?? null; | |
| const refuse = (outcome, extra) => { | |
| record(outcome, scope); | |
| return { outcome, ...extra }; | |
| }; | |
| if (!invalidatedBy) return refuse('error'); |
References
- Guard exported functions against invalid inputs (e.g., non-string types) to ensure the robustness of the module's public API.
There was a problem hiding this comment.
Resolved the underlying ambiguity the opposite way in 7c5c4f1. Null is not an input this function supports: the one production caller (maybeAccelerateHeal) returns before dispatch without a verdict, and guards 1–4 don't depend on it — a hypothetical null from a future caller surfaces as a caught, counted, logged error outcome in the detached path, never a 500 and never a write. What WAS misleading is the invalidatedBy?.scope at entry, which implied null is tolerated when the guards below don't extend that tolerance — so the ?. is now removed and a bad caller fails loudly on the first property read, rather than adding a guard that quietly accepts an input the contract rejects.
| export const epochFromActiveSet = (rows, routeScope) => { | ||
| if (!config.invalidation.enabled) return null; | ||
| let winner = null; | ||
| for (const row of rows) { |
There was a problem hiding this comment.
Defensive programming: rows is expected to be an iterable array, but if a database read fails or a caller passes null/undefined (or a non-array), iterating over it with for...of will throw a TypeError. Adding a check to ensure rows is an array prevents potential crashes in the admin views.
| export const epochFromActiveSet = (rows, routeScope) => { | |
| if (!config.invalidation.enabled) return null; | |
| let winner = null; | |
| for (const row of rows) { | |
| export const epochFromActiveSet = (rows, routeScope) => { | |
| if (!config.invalidation.enabled || !Array.isArray(rows)) return null; | |
| let winner = null; | |
| for (const row of rows) { |
References
- Guard exported functions against invalid inputs (e.g., non-string types) to ensure the robustness of the module's public API.
There was a problem hiding this comment.
Dismissing. All four call sites guarantee an array (invalidations ?? [], (await readWithTimeout(...))?.rows ?? [] twice, and a = [] default parameter), so the guard would be dead code today — and if a future caller does pass garbage, silently returning null is the wrong failure: null here means "nothing invalidated", i.e. the guard would convert a type bug into a page reported fresh and servable in the console — the silent fail-open this module's own header names as its worst failure class. A thrown TypeError in an admin view is loud and bounded, which is the better direction for this specific function.
Both this PR and #74 stamped 0.35.0, and the two branches text-merge cleanly — including the identical version line — so whichever merged second would have shipped two feature sets under one number. #74 keeps 0.35.0 (merges first); this renumbers to 0.36.0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…imeout fallback; honest cross-node wording; pad covers the lease Deep-review findings: - The clearing branch now runs BEFORE the closed-set scope check. The row that most needs clearing is one whose scope stopped resolving (a route renamed by a live config edit — the state checkScopeResolvability warns about), and validating first made the documented remediation answer '400 Unknown scope'. Recording still validates; clearing a never-recorded scope still 404s. First API-level tests for invalidate() pin all three. - The renderNow timeout->origin fallback forwarded the crawler's validators — the exact 304 defeat this PR closes on the direct origin path and the stale fallback. It now strips them whenever an epoch is active for the scope (over-stripping costs a full response instead of a 304, only while a row exists). - The 'effective on every node, nothing to propagate' operator responses ignored that each node reads its OWN replica: cross-node effectiveness rides on async replication, which this cluster has seen silently stall for days. Reworded (apply and clear), and the module comment now names the peer-verification rehearsal step. - invalidation.pad default 2min did not cover the in-flight window its own text calls 'the certain one' — a job may post back up to queue.jobLeaseTime (10min) after claiming, stamping pre-change bytes as healed for a full render interval with every counter green. Default raised to 10min and a cross-option config warning fires when pad < jobLeaseTime. - accelerateHeal no longer half-tolerates a null verdict (the ?. implied support the guards below don't have); a bad caller now fails loudly on the first property read. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Deep-review pass complete. Four findings fixed in 7c5c4f1; all three Gemini inline threads answered (each dismissed with reasoning on-thread).
Also: Untested-but-argued remainder, flagged as follow-ups rather than blockers: unit tests for |
Stacked on #72 — review that first, or read this diff against
feat/queue-watermark. GitHub is showing #72's commits here because it is not merged yet; this PR's own work is the last three commits."Everything of this kind is wrong as of now; stop serving it." One row records a scope and an instant; from then on any cached page in that scope rendered before that instant stops being served, and bots get the origin until the page re-renders on its normal cadence.
The mechanism is a comparison, not a rewrite
That is the whole design, and it is a measured choice. The tree already has an invalidation mechanism —
Target.revalidate's collection form — and it rewrites the corpus. Measured on 400,000 rows, one node's slice of a 1.61M-key corpus:Pacing does not fix the rewrite — batch-100-with-yields kept the same 162 B/write, took 8.9× longer, and made claim's max latency worse (47.4 → 62.4 ms). And the naive form is uniquely bad: collapsing every due time to
currentMinuteMs()piles the rows exactly where the claim scan seeks, taking it 0.36 ms → 11.59 ms (32×), and that only clears on the next compaction of the store, which needs write pressure.Consulting the epoch costs ~13 µs of storage time. The read is 0.107 ms p50 over a socket whose transport floor is 0.094 ms, against an existing hot page read of 0.291 ms p50 that also streams a ~50 KB blob. At ~4,000 bot requests/day, of which ~2,900 are cache-servable, the whole feature's steady-state cost is ≤5,800 point reads/day.
Why there is no sweep, and never will be
1,530,046 PDP keys ÷ a measured fleet ceiling of 71,289 renders/hr = a 21.5 h floor at 100% utilisation — against the 48 h such a page waits anyway — while measured utilisation is already 98% with a 3.05 h standing backlog. A sweep would also displace the 1 h and 12 h routes behind 1.53M PDP keys, because claim is strictly due-time-ascending.
Cadence-heal is correct by construction, not by preference: nothing in
claim,syncQueueStateorreconcilereadsPrerenderedPage, so the epoch cannot perturb the queue at all.invalidation.reenqueue(default off) optionally pulls forward the pages bots actually crawl — request-triggered, no timer, no scan.The scope axis is a closed set, and that is load-bearing
all, or one prerender route writtenroute:<match>:<path>exactly asingress.routesdeclares it.classifyPathalready returns the matched entry, so a page's scope derives synchronously with no extra read.Two reasons this had to be closed rather than free text, both about silence:
allplus at most one route key — two point reads by known key, never a walk. A prefix axis turns resolution into a scan and forces a refresh timer back into the design.Sub-route granularity costs zero code: declare a narrower route, which also buys that path its own metrics series and its own
renderInterval.Precedence between overlapping scopes is
max(invalidatedAt)— not most-specific-wins, which reads as the natural rule and is the one rule that can silently serve invalidated content: a leftover rehearsal row would hide a freshall, and no coverage check can catch that because coverage enumerates routes, not competing rows.How the epoch reaches every worker: it doesn't
There is no propagation mechanism, deliberately. Every worker resolves per request, so apply and undo are effective on the next request everywhere, and "the epoch never reached worker 5" is not a state this design can be in. A refresh timer was costed and rejected: 8 workers × 4 nodes × 1/min is 46,080 reads/day against ≤5,800 for per-request resolution — ~7.9× more storage work, while introducing that very failure mode and delaying both apply and undo by an interval.
The affordability comes from the gate: the epoch is read only when the request would otherwise have been a cache serve. A miss, a cache skip, a non-GET, a page past its SWR window, and
enabled: falseall pay zero. The gate is also what makes theinvalidatedcounter mean "cache serves this invalidation is costing us" rather than a tally of every stale key in the scope.Things that would have silently defeated it
304; andapplyConditionalcompares them against the response headers, which on an origin proxy are the origin's →304locally. Either way the request recordscacheStatus: 'invalidated'while the crawler keeps the pre-change bytes. Every signal reads as success. Both halves are closed, scoped to that one verdict. This is not the "the edge keeps its own TTL" caveat — a TTL expires, a 304 loop does not.renderNow.fallback: 'stale're-read and served a cached page with no freshness check at all. It is opt-in staleness, so no freshness check belongs there — but an invalidation says "this is WRONG", not "this is old", and that path is the one cache read that never passes the gate.defaultMissMode: 'prerender', every authorized request to one became an unjitteredcurrentMinuteMs()schedule write plus up to 30 s of polling for a render with no reason to arrive. Forced to'origin'unlessprerenderwas explicitly asked for — hencemissModeExplicit, sincemissModealone cannot tell a gesture from a default.collectConfigWarningscannot detect it — it is synchronous and cannot read a table — so the resolvability check runs at boot and on every config apply.enabled: falsewhile rows exist. Reported on boot, on every apply, and flagged onGET /invalidations.Undo is asymmetric, by construction
Clearing restores service on the next request for every page still inside its own expiry/SWR window, and cannot restore a page whose window elapsed while the invalidation was active — its lifetime ended on its own terms and nothing here rewrote
lastCached. So a long-running invalidation cannot be fully undone. The clear response says so with the numbers attached, rather than reporting an unqualified success.API
POST /prerender_admin/invalidate {"scope":"all","reason":"…","dryRun":true} # preview POST /prerender_admin/invalidate {"scope":"route:prefix:/catalog/","reason":"…"} GET /prerender_admin/invalidations POST /prerender_admin/invalidate {"scope":"all","mode":null} # cleardryRunreturns exactly the body the real call returns, minus the write, so "what would this do" and "what did this do" cannot drift apart.invalidatedAtis server-stamped and a caller-supplied one is rejected, not ignored — an operator who believes they backdated an invalidation and did not has a corpus they think is invalidated and isn't.Bugs in existing code, fixed here
fromSitemapclobbers.putREPLACES the record, so an on-demand render of a sitemap-listed URL cleared the flag, and the renderer then skips serializing a non-indexable sitemap-listed page — that page silently stops being cached at all. TheTarget.revalidateone was worse than expected: it trusted the caller's projection, so?select(url)madesitemapUrlabsent and indistinguishable from null, and every revalidated key re-filedfromSitemap: false; a select withouturlmade the sweep report success having written nothing. A source-scan test now fails the build on any literalfromSitemap: false, which is the shape this bug has taken twice.page_age_negative. Negative ages were silently discarded on every served request, and they are the only evidence anywhere of cross-node clock skew — which is half of whatinvalidation.padcovers, and the half whose default rests on no evidence.Testing
530 tests pass, lint and Prettier clean, suite run 5× to confirm no flakes.
The accelerator was implemented and then reviewed by three adversarial reviewers on distinct lenses (queue safety, device-pair/jitter alignment, guard completeness). 12 findings: 8 real and fixed, 4 rejected. The two worth naming were both defects in the first implementation — a per-URL verdict derived from one device's row (on a split pair, a mobile crawl re-armed a desktop row a desktop crawl had just been refused), and a rate window that rolled in both directions and sampled the minute before an awaited read, so the documented ceiling bounded nothing.
Writing the core-module tests found a real bug too:
lkgMaxAge: 0is documented as "fail open on the first read error", butage <= maxAgeis true when both are 0 — the common case, two reads in one millisecond — so the documented way to switch the last-known-good off did not switch it off.Ships INERT. With the table empty every verdict equals today's, and the only behavioural difference on the serve path is one conditional point read.
reenqueueis off, so it adds zero schedule writes.Not in this PR
gracemode.hardonly.graceis definitionally "hard, delayed byswrTtl", both end at origin-proxy-until-re-render, and its only distinct use case is a template change — where an origin proxy already serves the new template. The column exists so adding it later is not a migration.Target.revalidate's collection form is not retired. An unknown consumer script may call it; gating it belongs to the footgun-retirement work, not here.allinvalidation taking offload to zero (~120 extra origin requests/hour at today's volume, on bot traffic, and the phased bot-category rollout is explicitly intended to multiply it); whethergraceshould exist after all; whether a fleet-wide render-failure spike should auto-suspend active invalidations (I lean no — it un-invalidates without an operator asking, during an incident, in the direction of serving wrong prices); and who may invalidate, givensuper_usercurrently makes "may deploy config" and "may take 1.6M keys off the serve path" the same privilege.Before enabling it on anything real
Rehearse on the narrowest route first — that step is the whole value of the feature being usable by someone who has done it once before, under pressure. Confirm the debug headers on a direct-to-node request,
invalidatedin the per-route serve counter, healing with nobody clearing the row, and that clearing restores service on the next request. Then a wider route. Thenreenqueue, separately. Only thenall.🤖 Generated with Claude Code