feat(plugin): claim floor + out-of-row job leases — the nextRenderTime index no longer degrades (v0.34.0) - #72
feat(plugin): claim floor + out-of-row job leases — the nextRenderTime index no longer degrades (v0.34.0)#72harper-joseph wants to merge 6 commits into
Conversation
`RenderQueue.claim` scanned `nextRenderTime <= now` sorted, which seeks the ABSOLUTE
MINIMUM of that secondary index. Every completed render moves a key off the head and
leaves a dead index entry at the seek point, so the scan degraded 0.36ms -> 6.25ms over
40,000 reschedules (17.4x) — linearly, position-dependent (churn away from the seek point
was free), and it did not self-heal. Backlog depth was free; the `sort` was free.
Stage 1 of the queue redesign:
- One-sided lower bound on the claim scan (`nextRenderTime >= floor`, single condition,
sorted, limited; the `<= now` half moves to application code). Measured: the identical
20 keys at 0.43ms instead of 6.20ms, no schema change.
- The job lease leaves `nextRenderTime` for a node-local shared buffer (new
`util/renderLease.js`, over `coordination.SharedBuffer.getUserSharedBuffer`). That is
what makes a floor mean anything — a lease written into the due-time column IS a row
moved forward — and it halves queue writes (2/render -> 1) and audit bytes
(~87 -> ~44 MB/day/node). Losing leases on restart is correct: the schedule row never
moved, so the job is simply re-granted.
- `syncQueueState`'s second head-seeking scan (~700ms/min on an aged node, on worker 0,
which also serves bot traffic) is deleted. Status is derived from the floor plus the
last claim outcome, at zero DB cost, and is tri-state: a pass that saw due rows but
granted none reports `queued`, never `empty`.
THE FLOOR RULE: floor = the due minute of the FIRST DUE ROW A PASS OBSERVED (granted,
skipped-as-leased, or refused). The naive "floor = last granted" stranded 167 of 1,189
rows (14%, matching the simulated 15% renderer-failure rate exactly); this rule stranded
zero. Both are driven through the same trace in the tests. The comparator is INCLUSIVE and
on the value: ~1,100 keys share every minute at the recorded corpus, so an exclusive
advance would strand a whole minute per pass.
Accepted cost, documented in `queue.jobLeaseTime`: the floor advances only as fast as the
oldest in-flight job completes, so one wedged render pins it for a full lease.
`jobLeaseTime` is now a LATENCY knob, and its minimum is raised to 2m because the fleet
discards any granted job with under 30s of lease left.
A floor silently breaks any writer that files a row below it — the terminal render gap
(see `util/reconcile.js`). Three layers, all shipping here:
1. ONE FUNNEL. New `util/renderSchedule.js` owns the due-time write and the floor
lowering together; all 16 schedule call sites across 5 files route through it, and
`test/queueFunnel.test.js` fails the build if any other file in `src/` writes the
table. `fromSitemap` is now a required argument, which fixes a pre-existing bug:
`Target.revalidate` omitted it and `put` replaces the record, so every revalidated key
reported `isFromSitemap: false` and the renderer skipped serializing sitemap-listed
non-indexable pages.
2. GUARD BAND. The floor is clamped to `nowMinute - queue.claimFloor.guard` on every read,
so every "due now" write from ANY node is claimable with zero cross-node coordination
(schedule rows are residency-pinned, so ~75% of such writes are issued by a non-owner).
3. PERIODIC RESET + DETECTION. The ops API and the exported `RenderSchedule` REST surface
write the table with no plugin code in the path, so worker 0 zeroes the floor every
`queue.claimFloor.resetInterval` (under the claim mutex, inside `syncQueueState`) and
the next pass re-derives it — bounding stranding from permanent to one interval, at one
6.25ms seek per interval. `POST /prerender_admin/queue {"action":"reset-claim-floor"}`
does it now. The backlog snapshot keeps its full-minimum seek deliberately (it is the
only reader that can see a below-floor row) and reports `belowFloor`; the overview
alarms on it and the URL explainer names it per URL.
`retryAfterFailure`'s FAST lane worked purely by leaving the lease value in the row. With
the lease out of the row that means "immediately re-claimable", i.e. a hot loop against a
failing page — so the lane now returns `'fast'` and the caller HOLDS the lease. Same fix
in the generic-error target-backed branch, which is where every renderer crash, timeout
and settle failure lands. Two-lane semantics and timing are otherwise unchanged (the lease
is never re-armed on failure).
Also: `expiresAt` is no longer minute-floored (it only was because it doubled as a
`nextRenderTime`, and the flooring silently cost up to 60s of lease); a job_result with an
unusable `x-metadata-size` is a legible 400 rather than a mystery 500; and claim counts and
logs granted keys this node does not own by residency (the claim-time lease write used to
purge stale local records on a former owner — detection ships, the repair is deferred).
New config in `configSchema.js`: `queue.claimFloor.{enabled,guard,resetInterval}`,
`queue.maxLeases` (restart-scoped), `queue.claimScanCap`. Rewritten:
`queue.jobLeaseTime`, `queue.statusSyncInterval`, `queue.maxClaimLimit`,
`render.failureRetry`, `render.reconcile`, `management.{scanCap,backlogSnapshotInterval}`.
No schema change, and no `audit: false` anywhere: the audit store IS the redo log (table
data runs WAL-off and `replayLogs()` replays from it — measured, 0/500 acknowledged writes
survived an unclean shutdown with audit off).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t floor docs; v0.35.0 Review follow-up to 0f38c97 (three independent adversarial passes). Nothing here changes the claim-floor design; it fixes the ways the first cut of it could lose or double-grant work, and corrects four comments that told a future reader something untrue. - THE BLOCKER: a throw out of `processDecodedJobResult` RELEASED the lease. `holdLease` is only set by branches that completed, so any throw — a headerless post stamping `x-harper-rendered`, a `PrerenderedPage.put`/`createBlob` failure, a `Target.get` rejection — freed the lease while the row still carried its original overdue due time, and the throw also aborts the ambient transaction so the page write is rolled back too. The floor is at or below that row's minute by construction, so the next pass re-granted it seconds later: an unpaced re-render loop at claim frequency (pre-0.35.0 the same throw retried once per lease). Now `catch { holdLease = true; throw e }`. - `recordPassOutcome` CAS-MIN'd the earliest-not-due mark, so a pass reporting a LATER horizon had it discarded and `deriveQueueStatus` answered `queued` for the life of the buffer while `claim` answered `empty` — the node rewrote the replicated QueueStatus row ~2×/min and the fleet never reached its idle interval. The pass is authoritative for the whole window it drained, so it STORES its horizon; the CAS-min stays in `lowerFloorTo`, where a writer is authoritative only about its own row. - The occupancy gauge could read 0 with leases in flight: `grant` counted only never-used slots, `release` decremented unconditionally, and `scanLive` stored the live count, so every lease that expired and was released late left an unmatched −1 behind the reconciliation. `occupancy()` low is not cosmetic — it collapses the scan window to 2 × grantLimit and a pass with more live leases than that grants nothing while a backlog exists. `grant`/`release`/`scanLive` now agree on one predicate (live and not released), so the gauge can only ever read HIGH, which is the safe direction. (The reviewers suggested counting non-empty slots instead; that reconciles exactly too, but the non-empty count creeps toward `maxLeases` on a long-lived node and pins the claim scan at `claimScanCap` — 1,000 rows a pass instead of ~40.) - `release` published the slot free before the result's reschedule had COMMITTED (a static `put` joins the ambient transaction), so another worker's `claim` — which does not share the result path's mutex — could grant the same row again, on every result, and double-strike a failing key. It now shortens the lease to a 5s commit-visibility grace instead of clearing the slot: unclaimable while the transaction lands, not in flight for the console or the gauge, and reusable after. That also removes the publish-order hazard in the old release (CAS hashLo → 0, then zero the expiry, killing a lease granted in between): release writes no hashLo at all now. - `queue.maxLeases` was dead. It was read at MODULE SCOPE, which `extension.js` reaches before `applyOptions`, so the size came from the default (measured: option 16384, still 4,096 slots) and the mismatch warning could never fire either — both sides of its comparison came from the same stale number. The table is allocated on first use now, and `leaseTable` is an accessor. - `scanTruncated: rows.length >= scanLimit` counted rows from a deliberately ONE-SIDED query, so it was true on any real corpus — the warning fired on a healthy idle node recommending a hunt for wedged renders, and went quiet when the node was actually busy. It now also requires that the drain never reached a not-yet-due row. - `Target.revalidate` captured `currentMinuteMs()` once for a sweep that writes up to collectCap × devices rows with a point read each. Every row written more than `queue.claimFloor.guard` later was filed BELOW the owning node's floor and never claimed again — silently, from a fully funnel-routed in-plugin write. Computed per URL now, as `Sitemap.js` already does. Its `pick` also stopped skipping a row with no `url` once it started returning an object. - The accepted cost was documented WRONG in three places (`configSchema`, the funnel's module comment, the README): the floor pin is not bounded by one lease. `claim` writes nothing, so a row whose result never reschedules it stays due at the same minute forever and the periodic reset re-derives the same value — the generic-failure branch is exactly that shape. The pass now returns `floorHeldBy`, `claim` names it, and the console shows it beside the floor lag. Routing that branch through the strike-counted lanes would make the pin bounded and is deliberately NOT here: those strikes are the shared counter suppression and redirect verdicts retire targets on. - Smaller: the "row first, floor second" comment claimed an ordering guarantee that the ambient transaction does not provide (the guard band is what covers the hazard); the claim query's comment offered a two-sided range as an equally fine alternative, when a window that cannot fill costs 1,128–2,977 ms against 0.74 ms (~480×) because the second condition is a post-filter; `leaseTableFull` was reported as "all slots in use" when a full 8-slot probe window or a lost CAS says it too; the URL explainer printed "was due 9h ago" for a row due in nine hours; the overview's "In flight" tile said `live` while preferring the snapshot's copy of the gauge. 474 tests (was 460). Every behavioural fix has a test that fails without it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three defects found by independent verifiers on the previous round, plus the root cause the round before it deferred. All four were in the same place — what happens to a lease or a row that nobody ever comes back for. R1 — the occupancy gauge drifted UP without bound. grant/release are exact about every lease that ends in a RESULT, but a lease that merely EXPIRES has nobody to decrement it: the grant counted +1, and the late release (or the release that never arrives) sees a dead slot and correctly declines. Every expiry leaked one, permanently, and scanLive() was documented as a console read so nothing walked the slots on a timer. The gauge SIZES the claim scan (grantLimit + occupancy + grantLimit, capped at claimScanCap): measured 820 against 20 truly in flight by pass 40, crossing a 1,000-row cap by pass 49 and staying — after which every claim drains the full cap under the claim mutex, on a worker that also serves bot traffic. Under a broad origin outage it saturates in minutes. reconcileLeaseGauge() now rides syncQueueState beside maybeResetFloor: same worker, same mutex, same cadence, and one walk fixes the number every worker reads because the buffer is shared. R2 — release could truncate a RECYCLED slot's fresh lease. It read the expiry AFTER the ownership check, so a slot recycled in between handed it the recycler's far-future expiry, `graceSec < expiresSec` was true, and the CAS cut a brand-new lease to the 5s grace: that key was re-granted seconds later with its first render still running, and on a failing key that double-counted a strike. The result path does not share the claim mutex, so this is genuinely concurrent. It now reads the expiry FIRST and CASes against exactly that value, which is sufficient because grant publishes a recycled slot's payload before it claims hashLo — a recycle that began earlier fails the ownership re-check, one that landed later fails the CAS. Not unit-testable: reaching the interleaving needs a grant between two adjacent instructions of release. Argued from the publish order in the module comment, and deliberately not pinned by a test that would pass either way. R3 — the wedged-row warning could not fire in its own motivating scenario. It hung off `scanTruncated && jobs.length < limit`, which needs the whole scan window consumed by due rows; one wedged row on a healthy node reaches a not-yet-due row every pass, so the single case floorHeldBy was added for was the one case it could never print. It is now an independent check on the pin AGE, rate-limited, gated at what the retry lanes can actually account for (fastRetries × jobLeaseTime). R4 — the deferred root cause: a permanently-failing URL pinned the floor forever. The generic-failure branch (target exists -> hold the lease, write no row, no strike) is where every renderer crash, navigation timeout and settle failure lands, and it moves nothing, so the floor never advanced past it while dead index entries piled up above it — ~43ms/pass after a day, worse than the 6.25ms unfloored scan the floor replaces. queue.claimFloor.unpinAfter (1h) now has the claim path write that ONE row forward by a render interval and name it. It touches `strikes` NOWHERE — that is the shared counter suppression and redirect verdicts delete targets on, and routing the highest-volume failure path through it would walk the corpus toward deletion during a broad outage; the previous round's refusal to do that stands. Self-limiting at one write per interval per node, since unpinning one row promotes the next, which must hold for a full interval of its own. The pin is tracked in the shared header (a per-worker counter would be divided by the worker count and never reach a threshold) and measured in TIME, not passes — fifty passes is seconds behind a nine-pod fleet and an hour behind one idle consumer, while only duration makes a pin pathological. 484 tests pass (+10). The six new ones were verified to FAIL with each fix backed out, so none of them passes for the wrong reason. 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 node-local render-lease table and a claim floor mechanism to optimize the claim scan performance of the prerender queue, moving lease state out of the database rows and into a node-local shared buffer to halve write volume. The feedback identifies critical issues regarding numeric coercion and validation: specifically, coercing nextRenderTime values directly to a Number without a truthiness check can cause null to evaluate to 0, which would trigger false alarms or incorrectly lower the claim floor. Additionally, using Number.isFinite directly on potential BigInt/Long database values will return false, so they must be coerced to a Number first.
| const at = Number(row.nextRenderTime); | ||
| if (!Number.isFinite(at)) continue; |
There was a problem hiding this comment.
Coercing row.nextRenderTime directly to a Number without checking for null or undefined first will cause null to evaluate to 0. Since 0 is finite and less than floorMs, this will incorrectly increment belowFloor and set oldestBelowFloorMs to 0 (epoch 1970), triggering false "Below claim floor" alarms on the dashboard. Performing a truthiness check first ensures that missing or null due times are safely skipped. Since this coerce-before-finite check is used across multiple sites, we should consolidate it into a shared helper.
| const at = Number(row.nextRenderTime); | |
| if (!Number.isFinite(at)) continue; | |
| const at = parseFiniteNumber(row.nextRenderTime); | |
| if (Number.isNaN(at)) continue; |
References
- Always perform a truthiness check (ensuring the value is not null or undefined) before coercing date/time values to prevent null from evaluating to 0, which can corrupt metrics or calculations.
- Consolidate this coerce-before-finite check into a shared helper if used across multiple sites.
| const describeScheduleRow = (row, now) => { | ||
| const at = Number(row.nextRenderTime); |
There was a problem hiding this comment.
Coercing row.nextRenderTime directly to a Number without checking for null or undefined first will cause null to evaluate to 0. Since 0 is finite and less than floor.floorMinute, this will incorrectly mark the row as belowClaimFloor in the admin view, triggering false alarms for individual keys. Performing a truthiness check first ensures that missing or null due times are safely handled. Since this coerce-before-finite check is used across multiple sites, we should consolidate it into a shared helper.
| const describeScheduleRow = (row, now) => { | |
| const at = Number(row.nextRenderTime); | |
| const describeScheduleRow = (row, now) => { | |
| const at = parseFiniteNumber(row.nextRenderTime); |
References
- Always perform a truthiness check (ensuring the value is not null or undefined) before coercing date/time values to prevent null from evaluating to 0, which can corrupt metrics or calculations.
- Consolidate this coerce-before-finite check into a shared helper if used across multiple sites.
| const lowerFloorFor = (nextRenderTime) => { | ||
| const at = Number(nextRenderTime); | ||
| if (!Number.isFinite(at)) { |
There was a problem hiding this comment.
Coercing nextRenderTime directly to a Number without checking for null or undefined first will cause null to evaluate to 0. Since 0 is finite, this will silently lower the floor to 0 (unbounding the scan) instead of logging a warning and returning early. Performing a truthiness check first ensures that missing or null due times are safely handled. Since this coerce-before-finite check is used across multiple sites, we should consolidate it into a shared helper.
| const lowerFloorFor = (nextRenderTime) => { | |
| const at = Number(nextRenderTime); | |
| if (!Number.isFinite(at)) { | |
| const lowerFloorFor = (nextRenderTime) => { | |
| const at = parseFiniteNumber(nextRenderTime); | |
| if (Number.isNaN(at)) { |
References
- Always perform a truthiness check (ensuring the value is not null or undefined) before coercing date/time values to prevent null from evaluating to 0, which can corrupt metrics or calculations.
- Consolidate this coerce-before-finite check into a shared helper if used across multiple sites.
| await writeSchedules( | ||
| cacheKeysOf(url).map((cacheKey) => ({ | ||
| cacheKey, | ||
| nextRenderTime: | ||
| Number.isFinite(nextRenderTime) && nextRenderTime > 0 | ||
| ? nextRenderTime | ||
| : getInitialRenderTime(cacheKey, interval), | ||
| fromSitemap, | ||
| }); | ||
| } | ||
| })) | ||
| ); |
There was a problem hiding this comment.
Using Number.isFinite(nextRenderTime) directly on a value that may be a BigInt/Long from the database will return false for BigInts, causing them to be ignored and overwritten with the initial render time. Coercing the value to a Number first, after performing a truthiness check, ensures that BigInt values are correctly validated and handled. Since this coerce-before-finite check is used across multiple sites, we should consolidate it into a shared helper.
const at = parseFiniteNumber(nextRenderTime);
const resolvedNextRenderTime = !Number.isNaN(at) && at > 0 ? at : null;
await writeSchedules(
cacheKeysOf(url).map((cacheKey) => ({
cacheKey,
nextRenderTime: resolvedNextRenderTime ?? getInitialRenderTime(cacheKey, interval),
fromSitemap,
}))
);References
- Always coerce database column values (which may be BigInt/Long) to a Number before validating with Number.isFinite, as Number.isFinite returns false for BigInts. Consolidate this coerce-before-finite check into a shared helper if used across multiple sites.
… guard Addresses the Gemini review on #72. Three of its four findings are real and share one root cause: `Number(null)` is `0`, `0` is finite, so a bare `Number.isFinite(Number(x))` accepts a MISSING column as the epoch — the most plausible-looking wrong answer available. - util/renderSchedule.js `lowerFloorFor` — the serious one. A null due time became 0, and `lowerFloorTo(0)` means NO FLOOR, so one missing value silently put the claim scan back to seeking the absolute index minimum: the degraded 6.25ms seek this release exists to remove, with no warning, because 0 passed the finite check. `writeSchedules` is worse still — one null row won the batch minimum and unbounded the floor for a whole device fan-out. - util/backlogSnapshot.js — a null counted as `belowFloor` with an `oldest` of 1970, a permanent false alarm on the ONE metric that reports rows filed where no claim will ever look again. - resources/PrerenderAdmin.js `describeScheduleRow` — a row with no due time showed as overdue since 1970 AND below the claim floor: a false accusation against a named URL in the view an operator uses to decide whether to repair or delete it. Derived fields now report `null` rather than an answer they do not have. Consolidated into `numberOf` in util/time.js, as the review asked, beside the `epochMsOf` it deliberately is not: that one is for Date-typed columns and round-trips through `new Date` to accept an ISO string, while this never allocates because it runs once per scanned row on the claim path and once per row of a 20,000-row backlog sweep. `runClaimPass`'s hand-rolled version folds into it too. A REAL 0 is still accepted and still unbounds the floor, deliberately: a due time at or before the epoch minute is a documented value here (the `nextRenderTime = 1` priority trick, a junk PUT). Only ABSENCE is the bug, so only absence is rejected. The new test pins both halves and was verified to fail with the coercion restored. The fourth finding (Target.js) is DISMISSED, and the reason is worth recording: it argues `Number.isFinite` must not be used on a possibly-BigInt `Long`. A `Long` in a Harper schema is a 52-bit integer, so it always arrives as a JS number and `Number.isFinite` on one is safe by itself. `Number.isFinite` also does not coerce, so the existing `Number.isFinite(x) && x > 0` there already rejects null correctly. Comments in this package that justified a coercion by citing BigInt are corrected — the justification is `null`, and always was. 485 tests pass; lint and Prettier clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — three of the four are real and are fixed in 7d360b3. They share one root cause, so they are now one shared guard ( Fixed —
One behaviour deliberately preserved: a real Dismissed — Acting on it would have been actively harmful in one respect: it would have enshrined a wrong justification in the code. Comments elsewhere in this package that cited BigInt as the reason for a coercion have been corrected in the same commit — the reason is |
#70 held 0.34.0 and was closed unmerged, so the number is free and was never released: `main` is 0.33.0 and the newest release tag is prerender-v0.33.0. This release is therefore 0.34.0, and 0.35.0 would have left a gap whose only effect would be to make a later prerender-v0.35.0 tag ambiguous. Renumbered everywhere, not just in package.json — ten prose references name the version as a behavioural boundary rather than as trivia, and a wrong number in them is worse than no number. `queue.claimFloor.enabled: false` restores "the absolute index minimum, exactly as before v0.34.0"; the backlog snapshot's in-flight count "is no longer comparable with numbers recorded before v0.34.0"; the floor's non-persistence turns on `floor = 0` being "exactly the pre-v0.34.0 behaviour". Each of those is what an operator or a future reader checks a rollback against. The three earlier commit subjects on this branch still read v0.35.0. Left alone deliberately: rewriting them means a force-push, which would strip the anchors off the review comments already on #72 and invalidate the SHAs cited in the PR body and in the review reply. The number that ships is the one in package.json. 485 tests pass; lint and Prettier clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
….defaultInterval `maybeUnpinFloor` filed `now + render.defaultInterval`. Every other schedule writer in the plugin files `completionMinute + resolveRenderInterval(...)`, so this was the one writer that ignored the row's actual cadence, and it is wrong in both directions: a 1h-cadence homepage was parked for a full day, and a 48h-cadence page came back due long before its own cadence. The subtler half is that `nextRenderTime - interval` is HOW A COMPLETION IS READ BACK OUT OF A ROW, precisely because every other writer files it that way. A flat `defaultInterval` manufactured a completion that never happened — on the deployed 48h route the row read as "rendered 24h ago", on a 1h route as "rendered in the future". Nothing on this branch performs that arithmetic yet, which is the reason to fix it now rather than later: the row is the only record, it outlives the pass that wrote it, and a later reader has no way to tell that this particular value was synthetic. (It is not hypothetical — this was found by a reviewer on the bulk-invalidation branch, where a guard that infers a completion from the row was waved through by exactly this, and re-armed the never-completing row the hatch had just moved, re-pinning the floor the hatch exists to release.) Route > default here, where `processJobResult` resolves route > the target's STORED interval > default. Reading the Target from the funnel would mean a point read on the claim path and an import cycle (`resources/Target.js` imports this module), so the residual is stated in the code instead: a target with a stored interval and no route interval still desynchronises the two by that difference. 486 tests pass; lint and Prettier clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
One more fix,
The subtler half is that It isn't hypothetical. A reviewer on the bulk-invalidation branch (which stacks on this one) found it because a guard there infers a completion from the row: the synthetic value waved through the never-completing row this hatch had just moved, re-arming it on every crawl and re-pinning the floor the hatch exists to release — which is the only bound on that pin. Scoped deliberately: route > default, where 486 tests pass, lint and Prettier clean. The new test asserts both the filed value and that |
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>
The claim scan degrades, measurably and permanently, and this is the fix for it.
Every completed render moves a key from the head of the
nextRenderTimeindex into the future andleaves a dead index entry at the seek point. Measured on harper 5.1.26 / rocksdb-js 2.4.1, the
claim scan went from 0.36 ms to 6.25 ms over 40,000 reschedules — linearly, at ~0.15 µs per
completed render — and it did not self-heal (6.17 ms after the churn stopped). The cost is
position-dependent, not volume- or depth-dependent: identical churn away from the seek point was
free (0.26 → 0.27 ms), and backlog depth was free too (2,083 → 133,333 overdue rows was flat at
0.18–0.35 ms). The
sortclause was free.The fix is a one-sided lower bound on the scan —
nextRenderTime >= floor— so it no longerseeks the absolute minimum of that index. The same 20 keys come back in 0.43 ms instead of
6.20 ms, with no schema change.
What had to move for that to mean anything
The lease used to be written into
nextRenderTime—claimstorednow + jobLeaseTimethere tomark a job taken. A lease written into the due-time column is a row moved forward, so a floor over
that column is meaningless while the lease lives there. Leases now live in a node-local shared buffer
(
util/renderLease.js), which also halves queue writes: 2 per render down to 1, ~87 → ~44MB/day/node of audit. And
syncQueueState's second head-seeking scan (nextRenderTime <= now,limit 1 — ~700 ms/min of synchronous native iteration on an aged node, on the worker that also serves
bot traffic) is deleted: empty/queued is now derived from the floor plus the last claim outcome at
zero database cost.
test/queueStatusDerived.test.jspins its absence by installing asearchthatthrows.
The floor rule
The obvious alternative — "floor = the last row I granted" — is wrong in the worst possible way.
Simulated over 1,189 rows at a 15% renderer-failure rate it stranded 167 rows (14.0%, matching the
failure rate exactly): every lease that expired without a posted result sat behind the advanced
floor forever. The shipped rule stranded zero.
test/renderQueueFloor.test.jsdrives the sametrace through both rules, because the rejected half is what makes the test explain itself.
A floor silently breaks any writer that files a row below it — that row is never read again, which
is the terminal render gap: a URL that stops rendering forever and reports nothing. Three things
guard it:
util/renderSchedule.jsis the only module insrc/that writesRenderSchedule,so the due-time write and the floor lowering happen together. There were eight call sites across
five files before; "remember to lower the floor" is not an invariant sixteen call sites can hold.
test/queueFunnel.test.jsis a source scan that fails the build if any other file writes thetable.
queue.claimFloor.guardbehind the current minute on everyread.
RenderScheduleis residency-pinned, so ~75% of "render this URL now" writes on a four-nodecluster come from a node that cannot lower the owner's floor — the guard makes those rows land above
every node's floor by construction, with no signalling and no cross-node coordination.
@exported and the operations API reaches it regardless, soPUT /RenderSchedule/<key>writes a due time with no plugin code in the path. Worker 0 zeroes thefloor every
queue.claimFloor.resetInterval, bounding that from permanent to one interval.queue.claimFloor.enabled: falserolls the whole thing back to the old full seek and changes nothingelse.
The accepted cost, and how it is bounded
The floor cannot advance past the oldest due row, and the only thing that moves a row is its own
result — a lease expiring does not, because claiming writes nothing to the table. The
generic-failure branch (target exists → hold the lease, write no row, count no strike) is where every
renderer crash, navigation timeout and settle failure lands, so one permanently-failing URL would pin
the floor at its own minute forever while dead entries piled up above it: ~43 ms/pass after a day,
i.e. worse than the 6.25 ms this change exists to remove.
queue.claimFloor.unpinAfter(1h) bounds it — the claim path writes that one row forward by arender interval and names it in a warning. It touches
strikesnowhere: that is the target's oneshared counter that suppression and redirect verdicts delete targets on, and routing the
highest-volume failure path through it would walk the corpus toward deletion during a broad origin
outage. Self-limiting at one write per interval per node, because unpinning one row promotes the next,
which must then hold for a full interval of its own.
Review history — three rounds, and what each caught
Each round found real defects in the previous one, and every regression originated in the lease
accounting. Recorded here because the pattern is the useful part:
07d0e12(15 findings). The blocker: a throw released the lease while the row stayedoverdue, and the throw aborted the ambient transaction so the cached-page write rolled back too —
an unpaced re-render loop at claim frequency. A throw now holds the lease.
2fbeb28(this round's 4):decrement it, and nothing walked the slots on a timer. Since the gauge sizes the claim scan, it
reached 820 against 20 truly in flight by pass 40, crossed a 1,000-row cap by pass 49 and
stayed — after which every claim drains the full cap under the claim mutex. Minutes, not hours,
during a broad outage.
releasecould truncate a recycled slot's fresh lease to the 5s grace, because it read theexpiry after the ownership check. The key was then re-granted with its first render still running.
scanTruncated, which one wedged row on a healthy node never sets.7d360b3. Four findings, one root cause:Number(null)is0and
0is finite, so a bare finite check accepted a MISSING column as the epoch. Worst atlowerFloorFor, where a null due time drove the floor to0— which means no floor — silentlyrestoring the degraded seek this PR exists to remove. Three fixed and consolidated into one shared
numberOfguard; the fourth dismissed, because it arguedNumber.isFiniteis unsafe on apossibly-BigInt
Longand aLongin a Harper schema is a 52-bit integer that always arrives as aJS number. A real
0still unbounds the floor deliberately — that is the documentednextRenderTime = 1priority trick — so only absence is rejected.Testing
485 tests pass; lint and Prettier clean. The new tests in this branch's last two rounds were each
verified to fail with their fix backed out, so none of them passes for the wrong reason.
One gap, stated deliberately: the
releasereordering has no unit test. Reaching theinterleaving requires a
grantto run between two adjacent instructions ofrelease, which nosequential test can arrange. It is argued from
grant's publish order (payload beforehashLo) inthe module comment instead of pinned by a test that would pass either way.
Measurement scripts for every number above were run against a throwaway harper 5.1.26 + rocksdb-js
2.4.1 instance, matching the deployed stack.
Sequencing
Version 0.34.0, on top of
mainat 0.33.0. #70 (which held 0.34.0) and #71 are closed unmerged,so the number is free and was never released — the newest release tag is
prerender-v0.33.0. Thisrelease took it in
b8645c2, renumbered from 0.35.0 across package.json and the ten prose referencesthat name the version as a behavioural boundary (what
enabled: falserolls back to, what the backlogsnapshot is no longer comparable with). The three earlier commit subjects still read v0.35.0; that is
left alone deliberately, because rewriting them means a force-push that would strip the anchors off
the review comments already here.
Nothing blocks this and nothing conflicts with it — it is the only open plugin PR and it is based
on the current
maintip.Nothing here depends on a browser release: the wire format between the queue and the render fleet is
unchanged, so the current fleet (browser 1.16.0) claims and posts against this exactly as it does
today.
🤖 Generated with Claude Code