Skip to content

feat(plugin): claim floor + out-of-row job leases — the nextRenderTime index no longer degrades (v0.34.0) - #72

Open
harper-joseph wants to merge 6 commits into
mainfrom
feat/queue-watermark
Open

feat(plugin): claim floor + out-of-row job leases — the nextRenderTime index no longer degrades (v0.34.0)#72
harper-joseph wants to merge 6 commits into
mainfrom
feat/queue-watermark

Conversation

@harper-joseph

@harper-joseph harper-joseph commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

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 nextRenderTime index into the future and
leaves 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 sort clause was free.

The fix is a one-sided lower bound on the scan — nextRenderTime >= floor — so it no longer
seeks 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 nextRenderTimeclaim stored now + jobLeaseTime there to
mark 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 → ~44
MB/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.js pins its absence by installing a search that
throws.

The floor rule

floor_new = the due minute of the first due row the pass observed — granted, skipped as
already-leased, or refused for any other reason. If the pass observed no due row: nowMinute - guard.

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.js drives the same
trace
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:

  • One funnel. util/renderSchedule.js is the only module in src/ that writes RenderSchedule,
    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.js is a source scan that fails the build if any other file writes the
    table.
  • A guard band. The floor is clamped queue.claimFloor.guard behind the current minute on every
    read. RenderSchedule is residency-pinned, so ~75% of "render this URL now" writes on a four-node
    cluster 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.
  • A periodic reset. The table is @exported and the operations API reaches it regardless, so
    PUT /RenderSchedule/<key> writes a due time with no plugin code in the path. Worker 0 zeroes the
    floor every queue.claimFloor.resetInterval, bounding that from permanent to one interval.

queue.claimFloor.enabled: false rolls the whole thing back to the old full seek and changes nothing
else.

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 a
render interval and names it in a warning. It touches strikes nowhere: that is the target's one
shared 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:

  • Round 1 → 07d0e12 (15 findings). The blocker: a throw released the lease while the row stayed
    overdue, 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.
  • Round 2/3 → 2fbeb28 (this round's 4):
    • The occupancy gauge drifted up without bound. A lease that merely expires has nobody to
      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.
    • release could truncate a recycled slot's fresh lease to the 5s grace, because it read the
      expiry after the ownership check. The key was then re-granted with its first render still running.
    • The wedged-row warning could not fire in its own motivating scenario — it hung off
      scanTruncated, which one wedged row on a healthy node never sets.
    • The unbounded pin above (deferred by round 2, on purpose, and correctly).
  • Round 4 (Gemini, on this PR) → 7d360b3. Four findings, one root cause: Number(null) is 0
    and 0 is finite, so a bare finite check accepted a MISSING column as the epoch. Worst at
    lowerFloorFor, where a null due time drove the floor to 0 — which means no floor — silently
    restoring the degraded seek this PR exists to remove. Three fixed and consolidated into one shared
    numberOf guard; the fourth dismissed, because it argued Number.isFinite is unsafe on a
    possibly-BigInt Long and a Long in a Harper schema is a 52-bit integer that always arrives as a
    JS number. A real 0 still unbounds the floor deliberately — that is the documented
    nextRenderTime = 1 priority 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 release reordering has no unit test. Reaching the
interleaving requires a grant to run between two adjacent instructions of release, which no
sequential test can arrange. It is argued from grant's publish order (payload before hashLo) in
the 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 main at 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. This
release took it in b8645c2, renumbered from 0.35.0 across package.json and the ten prose references
that name the version as a behavioural boundary (what enabled: false rolls back to, what the backlog
snapshot 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 main tip.

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

harper-joseph and others added 3 commits August 7, 2026 15:21
`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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 107 to 108
const at = Number(row.nextRenderTime);
if (!Number.isFinite(at)) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
const at = Number(row.nextRenderTime);
if (!Number.isFinite(at)) continue;
const at = parseFiniteNumber(row.nextRenderTime);
if (Number.isNaN(at)) continue;
References
  1. 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.
  2. Consolidate this coerce-before-finite check into a shared helper if used across multiple sites.

Comment on lines +152 to +153
const describeScheduleRow = (row, now) => {
const at = Number(row.nextRenderTime);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
const describeScheduleRow = (row, now) => {
const at = Number(row.nextRenderTime);
const describeScheduleRow = (row, now) => {
const at = parseFiniteNumber(row.nextRenderTime);
References
  1. 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.
  2. Consolidate this coerce-before-finite check into a shared helper if used across multiple sites.

Comment on lines +202 to +204
const lowerFloorFor = (nextRenderTime) => {
const at = Number(nextRenderTime);
if (!Number.isFinite(at)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
const lowerFloorFor = (nextRenderTime) => {
const at = Number(nextRenderTime);
if (!Number.isFinite(at)) {
const lowerFloorFor = (nextRenderTime) => {
const at = parseFiniteNumber(nextRenderTime);
if (Number.isNaN(at)) {
References
  1. 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.
  2. Consolidate this coerce-before-finite check into a shared helper if used across multiple sites.

Comment on lines +103 to +112
await writeSchedules(
cacheKeysOf(url).map((cacheKey) => ({
cacheKey,
nextRenderTime:
Number.isFinite(nextRenderTime) && nextRenderTime > 0
? nextRenderTime
: getInitialRenderTime(cacheKey, interval),
fromSitemap,
});
}
}))
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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
  1. 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>
@harper-joseph

Copy link
Copy Markdown
Contributor Author

Thanks — three of the four are real and are fixed in 7d360b3. They share one root cause, so they are now one shared guard (numberOf in util/time.js) rather than four call-site checks, as suggested.

Fixed — Number(null) === 0 is the live trap:

  • renderSchedule.js lowerFloorFor — the serious one, and worse than described. 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.25 ms 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 an entire device fan-out.
  • backlogSnapshot.js — agreed, and the consequence is specifically corrosive: it is a false alarm on the one metric that reports rows filed where no claim will ever look again.
  • PrerenderAdmin.js describeScheduleRow — agreed. Derived fields now report null instead of an answer they do not have, rather than a named URL appearing overdue-since-1970 and below-floor in the view an operator uses to decide whether to repair or delete it.

One behaviour deliberately preserved: a real 0 still unbounds the floor. A due time at or before the epoch minute is a documented value in this system — the nextRenderTime = 1 priority trick, or a junk PUT — so only absence is rejected, not zero. The new test pins both halves and was verified to fail with the old coercion restored.

Dismissed — Target.js: the BigInt premise does not hold here. A Long in a Harper schema is a 52-bit integer, so it always arrives as a JS number; Number.isFinite on one is safe by itself. Number.isFinite also does not coerce, so the existing Number.isFinite(x) && x > 0 already rejects null correctly — the suggested change is behaviour-neutral at that site.

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 null, and always was.

#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>
@harper-joseph harper-joseph changed the title feat(plugin): claim floor + out-of-row job leases — the nextRenderTime index no longer degrades (v0.35.0) feat(plugin): claim floor + out-of-row job leases — the nextRenderTime index no longer degrades (v0.34.0) Aug 8, 2026
harper-joseph added a commit that referenced this pull request Aug 8, 2026
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>
@harper-joseph

Copy link
Copy Markdown
Contributor Author

One more fix, 88de22b — found from outside this branch, which is why it is worth calling out.

maybeUnpinFloor filed now + render.defaultInterval. Every other schedule writer files completionMinute + resolveRenderInterval(...), so this was the one writer that ignored the row's own cadence — wrong in both directions: a 1h-cadence homepage parked for a full day, a 48h-cadence page back due long before its 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 exactly the argument for fixing it here 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 one value was synthetic.

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 processJobResult resolves route > the target's stored interval > default. Closing that last gap needs a Target read on the claim path plus an import cycle (resources/Target.js imports the funnel), so the residual is written down at the site instead of half-fixed.

486 tests pass, lint and Prettier clean. The new test asserts both the filed value and that filed - resolveRenderInterval(url, stored) === now, i.e. that the row reads as a completion NOW rather than a fake past, and it was verified to fail with the fix backed out.

harper-joseph added a commit that referenced this pull request Aug 8, 2026
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>
harper-joseph added a commit that referenced this pull request Aug 8, 2026
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>
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