feat(plugin): decide the render order from a scored ready set, not from the index - #120
Conversation
…om the index; v0.50.0 `claim` takes the first rows it finds from the claim floor, so the queue serves whatever is oldest-due. Two production measurements say that is the wrong order under scarcity (#80): ~46% of a 521,929-row overdue queue was bot-discovered rather than sitemap-submitted, and absolute due time treats a 1h-TTL homepage 3h overdue exactly like a 48h-TTL product page 3h overdue — 300% stale against 6%. Simulated over the real corpus the 1h route sits at 4.78x its own TTL even at FULL capacity. WHY IT COULD NOT BE FIXED IN THE SCAN. The claim window is anchored at the OLDEST due time and is already an EDF prefix, so under a backlog every row in it is ancient and a homepage two of its own cadences late is never read at all — a wider window anchored in the same place is just more ancient rows. Ranking by relative lateness cannot be an index either: it has slope 1/interval, so two rows with different intervals cross exactly once and no stored key can express an order that changes with the clock. Two earlier attempts died on those two facts respectively. So the ordering leaves the index. A sweep on worker 0 scores the WHOLE due set and publishes the best few thousand into a shared buffer; `claim` pops from it in priority order and reads no index at all. This is affordable because of one measured fact (#119): a projected one-sided read is ~2.4 us/row, FLAT from 200 to 20,000 rows, and yielding every 200 rows is free — 200,000 rows in ~480 ms, a 500k-row overdue set in ~1.2 s, with ZERO writes. Writes are 76-89 us/row, 32x a read, so reading liberally and writing not at all is the cheap direction. The claim path gets strictly faster: it used to scan the index under the claim mutex, and now it does not touch it. Score is `max(0, now - dueAt) / renderInterval`, times `sitemapBoost` for a sitemap row. Lateness rather than age, because `dueAt - interval` is not when the page last rendered for every row — suppression rechecks schedule 7 days, backoffWait up to maxBackoff, the unpin hatch a defaultInterval — so an age ratio would put a 7-day recheck at the head reading as 3.5 cadences stale. Three properties carry the safety argument: IT IS A CACHE IN FRONT OF THE OLD PATH. Cold, exhausted, disabled, or a buffer that could not be sized all fall through to the floored scan, so every failure mode is the PREVIOUS behaviour rather than a stalled queue. That is why it ships on. NOTHING HERE IS A CORRECTNESS INVARIANT. A stale entry costs at most one redundant render — the lease CAS refuses a duplicate and processJobResult already drops a result whose target is gone — and the next sweep re-reads the table, so it cannot lose a page. The claim floor, by contrast, never reads a row filed below it again, silently and terminally. THE SWEEP OWNS THE FLOOR. Claims served from memory observe nothing, and a floor nothing observes freezes — measured, an unfloored seek degrades 0.073 -> 5.60 ms over 40,000 reschedules while a floored one stays flat. The sweep applies the same floor rule and is better informed doing it: it sees every due row, so "the first due row observed" is the true minimum rather than a window's. `fromSitemap` is carried through shared memory rather than re-read: the renderer serializes a non-indexable page only when the url is sitemap-listed, so a job reporting false for a listed page silently stops it being cached — a bug this package has shipped twice — and the alternative is a point read per granted job against a residency-pinned table where an unowned read has no timeout. New metrics: `claim_granted` split ready/index (the only series that shows whether this engages, since it moves no totals), `ready_sweep_ms`, `ready_published`. 750 pass / 0 fail. The ready set and the scoring run against a plain ArrayBuffer with no Harper; the sweep and the claim run against a fake table. Pinned in particular: the production symptom (a late homepage behind 400 older rows granted first), the floor still advancing, the fallback on cold/exhausted/disabled, a leased row never granted twice, a key dropped rather than truncated, and the cursor handing each index out exactly once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces the 'ready set' feature, which optimizes render queue prioritization by using a background sweep to score and publish the most urgent due rows into a shared memory buffer, with a seamless fallback to the standard index scan. The review feedback highlights several critical improvements: addressing a race condition in take() by using Atomics.compareExchange to safely clamp H_CURSOR, wrapping the synchronous invocation of sweepReadySet() in a try...catch block to handle synchronous exceptions, clamping the sweepInterval to Node's maximum 32-bit signed integer limit to avoid TimeoutOverflowWarning, and implementing a safe division helper in scoreOf to prevent potential Number(null) === 0 division issues.
| const index = Atomics.add(i32, H_CURSOR, 1); | ||
| if (index >= count) { | ||
| // Do not let the cursor run away past the count while a set is exhausted: it is an | ||
| // Int32 and a busy node claims several times a second, so an unbounded increment would | ||
| // wrap in about eight days of idling and start handing out valid indices again. | ||
| Atomics.store(i32, H_CURSOR, count); | ||
| break; | ||
| } |
There was a problem hiding this comment.
There is a critical race condition here. If multiple workers call take() concurrently when the queue is exhausted, H_CURSOR will keep increasing. While the code attempts to clamp H_CURSOR back to count using Atomics.store(i32, H_CURSOR, count), this is a plain store that can overwrite a newly reset H_CURSOR (set to 0 by a concurrent publish of a new generation). This would silently skip the entire new generation of ready entries. Use Atomics.compareExchange to only clamp H_CURSOR to count if it hasn't been reset to 0 by a publish.
| const index = Atomics.add(i32, H_CURSOR, 1); | |
| if (index >= count) { | |
| // Do not let the cursor run away past the count while a set is exhausted: it is an | |
| // Int32 and a busy node claims several times a second, so an unbounded increment would | |
| // wrap in about eight days of idling and start handing out valid indices again. | |
| Atomics.store(i32, H_CURSOR, count); | |
| break; | |
| } | |
| const index = Atomics.add(i32, H_CURSOR, 1); | |
| if (index >= count) { | |
| // Do not let the cursor run away past the count while a set is exhausted: it is an | |
| // Int32 and a busy node claims several times a second, so an unbounded increment would | |
| // wrap in about eight days of idling and start handing out valid indices again. | |
| Atomics.compareExchange(i32, H_CURSOR, index + 1, count); | |
| break; | |
| } |
| const sweep = () => { | ||
| if (sweeping || !config.queue.ready.enabled) return; | ||
| sweeping = true; | ||
| const started = performance.now(); | ||
| sweepReadySet() | ||
| .then((result) => { | ||
| if (result?.skipped) return; | ||
| metrics.readySweep(performance.now() - started, result.truncated ? 'capped' : 'complete'); | ||
| metrics.readyPublished(result.published); | ||
| if (result.truncated) { | ||
| // The rows past the cap are the YOUNGEST, so a truncated sweep leaves recently-due pages | ||
| // unranked — precisely the pages this feature exists to protect. That makes it a warning | ||
| // rather than a statistic. | ||
| logger.warn( | ||
| `[prerender] ready-set sweep read its ${config.queue.ready.sweepCap}-row cap without reaching a ` + | ||
| `not-yet-due row: ${result.due} due row(s) seen, ${result.published} published. The ordering ` + | ||
| `covers only the oldest part of the backlog, so recently-due pages are going unranked. Raise ` + | ||
| `queue.ready.sweepCap, or reduce the backlog.` | ||
| ); | ||
| } | ||
| }) | ||
| .catch(logger.error) | ||
| .finally(() => { | ||
| sweeping = false; | ||
| }); | ||
| }; |
There was a problem hiding this comment.
When invoking a function that returns a Promise on a critical boot or timer path, wrap the synchronous invocation itself in a try...catch block to capture any synchronous exceptions. If the function throws synchronously, asynchronous error handling (such as .catch()) will be bypassed, which can propagate unhandled exceptions and cause unexpected failures. Additionally, safely handle non-standard exceptions using e?.message ?? String(e) to ensure robust serialization of non-Error throws.
const sweep = () => {
if (sweeping || !config.queue.ready.enabled) return;
sweeping = true;
const started = performance.now();
try {
sweepReadySet()
.then((result) => {
if (result?.skipped) return;
metrics.readySweep(performance.now() - started, result.truncated ? 'capped' : 'complete');
metrics.readyPublished(result.published);
if (result.truncated) {
// The rows past the cap are the YOUNGEST, so a truncated sweep leaves recently-due pages
// unranked — precisely the pages this feature exists to protect. That makes it a warning
// rather than a statistic.
logger.warn(
`[prerender] ready-set sweep read its ${config.queue.ready.sweepCap}-row cap without reaching a ` +
`${result.due} due row(s) seen, ${result.published} published. The ordering ` +
`covers only the oldest part of the backlog, so recently-due pages are going unranked. Raise ` +
`queue.ready.sweepCap, or reduce the backlog.`
);
}
})
.catch((e) => {
logger.error(e?.message ?? String(e));
})
.finally(() => {
sweeping = false;
});
} catch (e) {
logger.error(e?.message ?? String(e));
sweeping = false;
}
};References
- Ensure that error handling in critical boot-path operations safely handles non-standard exceptions using e?.message ?? String(e) to prevent component failure.
- When invoking a function that returns a Promise, wrap the synchronous invocation itself in a 'try...catch' block to capture any synchronous exceptions.
- When handling caught exceptions, safely access the error message using optional chaining and fall back to String(e) to ensure that the error handling or logging block itself does not throw a TypeError.
| let armed = config.queue.ready.sweepInterval; | ||
| let timer = armed > 0 ? setInterval(sweep, armed) : null; | ||
| timer?.unref?.(); | ||
|
|
||
| onConfigApplied(() => { | ||
| if (config.queue.ready.sweepInterval === armed) return; | ||
| if (timer) clearInterval(timer); | ||
| armed = config.queue.ready.sweepInterval; | ||
| timer = armed > 0 ? setInterval(sweep, armed) : null; | ||
| timer?.unref?.(); | ||
| }); |
There was a problem hiding this comment.
In Node.js, configuration options representing timeouts or delays passed to setInterval or setTimeout must enforce a maximum limit of 2147483647 (the maximum 32-bit signed integer). Exceeding this limit causes Node.js to emit a TimeoutOverflowWarning and execute the callback immediately (after 1ms), which can lead to unexpected immediate timeouts and system failures.
| let armed = config.queue.ready.sweepInterval; | |
| let timer = armed > 0 ? setInterval(sweep, armed) : null; | |
| timer?.unref?.(); | |
| onConfigApplied(() => { | |
| if (config.queue.ready.sweepInterval === armed) return; | |
| if (timer) clearInterval(timer); | |
| armed = config.queue.ready.sweepInterval; | |
| timer = armed > 0 ? setInterval(sweep, armed) : null; | |
| timer?.unref?.(); | |
| }); | |
| let armed = config.queue.ready.sweepInterval; | |
| let timer = armed > 0 ? setInterval(sweep, Math.min(2147483647, armed)) : null; | |
| timer?.unref?.(); | |
| onConfigApplied(() => { | |
| if (config.queue.ready.sweepInterval === armed) return; | |
| if (timer) clearInterval(timer); | |
| armed = config.queue.ready.sweepInterval; | |
| timer = armed > 0 ? setInterval(sweep, Math.min(2147483647, armed)) : null; | |
| timer?.unref?.(); | |
| }); |
References
- In Node.js, clamp or validate configuration options representing timeouts or delays passed to setInterval or setTimeout to not exceed 2147483647 to prevent unexpected hot loops.
| export const scoreOf = ({ dueAt, fromSitemap }, { nowMs, intervalMs, sitemapBoost = 1 }) => { | ||
| const lateness = Math.max(0, nowMs - dueAt); | ||
| const ratio = intervalMs > 0 ? lateness / intervalMs : lateness; | ||
| return fromSitemap ? ratio * sitemapBoost : ratio; | ||
| }; |
There was a problem hiding this comment.
When calculating ratios or dividing metrics by a yardstick/cadence in JavaScript, avoid the Number(null) === 0 trap (where null / number evaluates to 0 instead of null or NaN). Use a helper function to explicitly validate that both the value and the yardstick are finite numbers and that the yardstick is greater than 0 before performing the division, returning null otherwise.
const safeDivide = (value, yardstick) => {
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
if (typeof yardstick !== 'number' || !Number.isFinite(yardstick) || yardstick <= 0) return null;
return value / yardstick;
};
export const scoreOf = ({ dueAt, fromSitemap }, { nowMs, intervalMs, sitemapBoost = 1 }) => {
const lateness = Math.max(0, nowMs - dueAt);
const ratio = safeDivide(lateness, intervalMs) ?? lateness;
return fromSitemap ? ratio * sitemapBoost : ratio;
};References
- When calculating ratios or dividing metrics by a yardstick/cadence in JavaScript, avoid the Number(null) === 0 trap by explicitly validating that both the value and the yardstick are finite numbers and that the yardstick is greater than 0 before performing the division.
…index Self-review of the ready set found three regressions with one shape: the sweep took over OBSERVING the index, and three separate signals were quietly derived from the fact that a claim did the observing. On a node serving every claim from the ready set — the intended steady state — nothing observed the index at all. All three passed the 750-test suite. 1. THE SWEEP WIPED THE NOT-YET-DUE MINUTE. `deriveQueueStatus` flips a node from `empty` to `queued` the moment a known future minute arrives, at zero database cost. The sweep runs every minute and overwrites the recorded outcome, and it was recording 0 — so a node with nothing due but a row due in thirty seconds would tell the whole fleet to go idle. The sweep observed that minute and threw it away; it now carries it. 2. THE PIN AGE FROZE. It only advances when something calls `notePinnedBy`, and both the wedged-row warning and `maybeUnpinFloor` key off it. Left to the claim path, a permanently failing URL would hold the floor with no warning and no automatic push — precisely the unbounded case `queue.claimFloor.unpinAfter` exists to bound. The sweep now notes the pin and runs the hatch, after its cursor is closed (the hatch writes, and a write with an open scan cursor is the shape Harper's long-transaction monitor aborts). 3. `lastFloorHeldBy` WENT STALE, so the warning — when something else made it fire — would name an innocent URL as the thing pinning the queue, which is the exact failure the comment on that variable warns about. Also: the shared-memory figure in `queue.ready.capacity` was per-slot and there are two slots, so ~2.8MB at the default rather than the 1.4MB documented. Three of the four new tests were verified to FAIL against the pre-fix code. The fourth — asserting the sweep notes the pin — passed against it, because the age is stored in whole seconds and a same-tick pin is legitimately 0; it is deleted rather than kept, with a note saying why, since the unpin test's `floorPinnedForMs >= 1000` across two sweeps is only reachable if the pin was noted with a stable key both times. A redundant test that cannot fail reads as coverage. 753 pass / 0 fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Self-review: three regressions found and fixed (b8d47ba)All three had the same shape, and all three passed the 750-test suite. The sweep took over observing the index, and three separate signals were quietly derived from the fact that a claim did the observing. On a node serving every claim from the ready set — the intended steady state — nothing observed the index at all. 1. The sweep wiped the not-yet-due minute. 2. The pin age froze — the serious one. It only advances when something calls 3. Also: the shared-memory figure in On the testsThree of the four new tests were verified to fail against the pre-fix code. The fourth — asserting the sweep notes the pin — passed against it too, because the age is stored in whole seconds and a same-tick pin is legitimately 753 pass / 0 fail. What I looked at and did not change
|
…e cluster Checked the design against the reference deployment before rollout (4 nodes, 16 workers, Harper Pro 5.2.3), using already-computed signals only — analytics, system_information, logs. No scans. WHAT CONFIRMED THE DESIGN. `claim_scan_ms` reports `method: capped` on essentially every pass, 280 samples over six hours. `capped` means the pass read its whole window and never reached a not-yet-due row — and with lease_occupancy at 75-155 that window is ~205 rows. So the queue is choosing ~4 jobs out of ~205 rows that are all ancient and never sees the rest of the due set. That is the anchoring problem, measured in production rather than argued from a simulation. `floor_pin_age_ms` also moves between 2s and 481s, so the pin mechanism is genuinely live — which makes the regression the self-review caught (a sweep that stopped noting the pin would silently disable the wedged-row warning and unpinAfter) a real one on this cluster, not a theoretical one. WHAT CHANGED A DEFAULT. The marginal per-row read cost is uncertain by an order of magnitude: the synthetic benchmark says ~2.4us/row, but live a ~205-row window takes a 5-6ms median (p95 9-12ms) and `empty` passes average 25ms with 47ms observed, which are seek-dominated. At the wide end a one-minute sweep would spend a noticeable fraction of a core continuously on a worker that also serves bot traffic — for no benefit, since the ordering does not go stale that fast and `capacity` covers ~16 minutes of claims. So `sweepInterval` defaults to 5 minutes and `ready_sweep_ms` is what tightens it, because nothing else can: `overdue` saturates at `management.scanCap` (observed pinned at 2,000), so the backlog snapshot cannot tell an operator how many rows a sweep will walk. The nodes also swap (4.6GB used, 5.2GB free of 33.6GB), which promotes the bounded top-K from tidy to load-bearing and is now stated where someone would otherwise raise `capacity`. Routes are deployed with the intervals the scoring needs (/ 1h, /catalog/ 24h, /catalog.jsp 24h, /product/prd- 48h), so relative lateness will discriminate. Deployed plugin is v0.49.0, so this is the next version. 753 pass / 0 fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…generation Four findings from @gemini-code-assist on #120; three applied, one declined. THE REAL ONE. `take()` clamped the exhausted cursor with a plain `Atomics.store(H_CURSOR, count)`, which races `publish`: publish resets the cursor to 0 and flips the slot, and a store landing just after that rewinds it to the PREVIOUS generation's count — so every entry of the fresh set is skipped, silently, until the next sweep replaces it. A `compareExchange` from `index + 1` only clamps if the cursor is still where this call's own increment left it, so a concurrent reset always wins. No test can reach it: two threads are needed, and single-threaded nothing can interleave between the add and the clamp. That is stated in the comment rather than left to be rediscovered. THE LATCH. `sweeping` is set before `sweepReadySet()` is called, so a synchronous throw from that call would leave it set forever — the sweep permanently dead for the life of the process, with claims quietly falling back to the index scan and nothing saying why. The invocation is now inside the try, and errors go through a `messageOf` helper for the reason `util/configOverride.js` already documents: anything can be thrown, and `null.message` is a TypeError raised from inside the handler that exists to keep the path alive. THE TIMER CEILING. `sweepInterval` now carries `max: 2147483647` in the schema and is clamped at the `setInterval` site, following the pattern and the reasoning in `util/configOverride.js`: past 2^31-1 ms node warns and then fires the callback after ONE MILLISECOND, so an over-large interval turns the sweep into a hot loop over the due set rather than slowing it down. The schema rejection is the loud path; the clamp is so the loud path working is not the only thing between a typo and that loop. DECLINED, WITH THE UNDERLYING CONCERN ACCEPTED. The suggested `safeDivide` helper for `scoreOf` guards a trap that is already closed: `intervalMs > 0` is false for null, undefined, NaN, 0 and every negative, so nothing is ever coerced and `Number(null) === 0` cannot arise. Adding a `typeof` check would only change behaviour for a numeric-string interval, which works correctly today and is now pinned by a test. But it made me look at the other half of the same expression, where the trap is real: `nowMs - null` is `nowMs`, so an absent DUE TIME produces a lateness of ~1.8e12 and sorts a broken row straight to the head of the set. The sweep filters non-finite due times before scoring, but `scoreOf` is exported, so the guard now lives with the arithmetic. Zero is the right answer — a row with no due time makes no claim to urgency. 755 pass / 0 fail.
|
Thanks @gemini-code-assist — three applied, one declined with reasoning. The cursor one was a real bug I'd have shipped. 1.
|
|
Thanks for the detailed breakdown, @harper-joseph. Your reasoning for the |
The sweep resolved every row's cadence from config, which cannot see the demand ladder. A route grants a CEILING — `/catalog/` at 24h — and `render.demand` promotes bot-visited targets beneath it to 12h or 6h, writing `now + rung` as the due time. So dividing lateness by the route understated a promoted page's overdue-ness by up to 4x, on precisely the pages the ladder singled out as worth rendering more often. The feature was discarding the ladder's work. Priority cannot be recovered from RenderTarget at scoring time: that is a cross-database point read per row over the whole due set, ~75% of them replication fetches on a residency-pinned table. So the cadence travels ON the row. `RenderSchedule.effectiveInterval` (non-indexed) is written by every schedule writer through the funnel and read in a projection the sweep already pays for — no extra read. `resolveEffectiveInterval` resolves rung > route > stored > default, clamping a stale rung to the ceiling exactly as `decideInterval` does, so a lowered route cannot make an old rung read as slower. The field is the page's CADENCE, not the gap that was written: a suppression recheck files 7 days and a backoff files its backoff, and neither is a cadence. That is the same distinction that makes the score lateness rather than age, so those writers file the cadence and not the gap they used. Absent is legal and self-healing. Pre-upgrade rows and the writers with no cadence in hand fall back to resolving from config, which is what the sweep did before — so the first sweep after a deploy behaves exactly as it does today and the corpus fills in as rows re-render. `queue_health` `ready_cadence` (carried/resolved) is the gauge for that crossover; a ratio that stays low means rows are being filed without a cadence, which no other series would show. Required explicitly, like `fromSitemap`, and for the same hazard: `put` replaces the record, so a writer that omits it does not leave the old value alone, it ERASES a correct cadence off a row that had one. Found while wiring it, and it would have been a silent deploy-day regression: `maybeUnpinFloor` handed the row's raw cadence back to the funnel. Every row written before this field exists carries `undefined`, the funnel now refuses that, and the refusal lands inside the hatch's own try/catch — logged, swallowed, nothing pushed, nothing reported. The one mechanism that bounds a wedged row would have been dead on every node for a full cadence after the upgrade. It now files the cadence it resolved, which also keeps `nextRenderTime - effectiveInterval === now` and leaves the row self-describing. Closes the sitemap half too: ingest goes through `Target.put` with the changefreq-derived interval, so those cadences now reach the row instead of being scored as `render.defaultInterval`. 763 tests pass. Four of the new tests were verified to fail against the code they describe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The scorer could not see the demand ladderPre-deploy review turned up a real correctness gap in this PR, now fixed in 4c8cacd.
const base = resolveRenderInterval(url, renderInterval);
const demand = decideInterval(url, base, renderTarget?.demandInterval);
const nextRenderTime = currentMinuteMs() + demand.interval; // ← the rungThe sweep scored with Recomputing at scoring time was not an option — The cadence travels on the row
It is the page's cadence, not the gap that was written — a suppression recheck files 7 days and a backoff files its backoff, and neither is a cadence. Same distinction that makes the score lateness rather than age, so those writers file the cadence and not the gap they used. Absent is legal and self-healing. Pre-upgrade rows, and the writers with genuinely no cadence in hand, fall back to resolving from config — which is what the sweep did before this commit. So the first sweep after deploy behaves exactly as the previous version did, and the corpus fills in as rows re-render. Required explicitly at the funnel, like This also closes the sitemap half, which I had flagged as the same shape: ingest goes through A silent deploy-day regression, found while wiring it
It now files the cadence it resolved, which also keeps Verification763 pass, 0 fail. Lint and prettier clean (the two Four of the new tests were checked to fail against the code they describe, not just to pass against the fix:
Audited for bypasses: the funnel is the only writer of that table (two Not bundled
|
Replaces #115 and #116, both of which I closed. Measured against the harness in #119.
The problem
claimtakes the first rows it finds from the claim floor, so the queue serves whatever is oldest-due. Two production measurements say that's the wrong order under scarcity (#80):The live cluster confirms the diagnosis directly:
claim_scan_msreports methodcappedon essentially every pass (280 samples over 6 h). Every pass reads its whole window and never reaches a not-yet-due row, so the queue picks ~4 jobs from ~205 uniformly ancient ones.Why it couldn't be fixed in the scan — two dead ends, both mine
#115 tried re-sorting the claim window. The window is anchored at the oldest due time and is already an EDF prefix, so under a backlog every row in it is ancient and a homepage two of its own cadences late is never read at all. Widening it (which that PR added a knob for) is just more ancient rows.
#116 tried encoding priority into
nextRenderTime. That works, but relative lateness still can't be an index —(t − dueAt)/intervalhas slope1/interval, so two rows with different intervals cross exactly once and no stored key expresses an order that changes with the clock. So it had to approximate with TTL bands, and it bought that with a 1.6M-row restamp migration, a decode-everywhere discipline, and a rollback that wasn't instant.The benchmark then removed its main justification: a second index costs ~13%, not the 39–48% that made a per-lane index look like a doubling of the hot write, and
patchis worse thanput, so "a lane change is a cheap in-place update" was never an advantage.What this does instead
The ordering leaves the index. A sweep on worker 0 scores the whole due set and publishes the best few thousand into a shared buffer;
claimpops from it in priority order and reads no index at all.Affordable because of one measured fact (#119): a projected one-sided read is ~2.4 µs/row, flat from 200 to 20,000 rows, and yielding every 200 rows is free — 200,000 rows in ~480 ms, a 500k-row overdue set in ~1.2 s, with zero writes. Writes are 76–89 µs/row, 32× a read, so reading liberally and writing not at all is the cheap direction.
The claim path gets strictly faster: it used to scan the index under the claim mutex and now doesn't touch it.
Score is
max(0, now − dueAt) / effectiveInterval, timessitemapBoostfor a sitemap row. Lateness, not age —dueAt − intervalisn't when the page last rendered for every row (suppression rechecks schedule 7 days,backoffWaitup tomaxBackoff, the unpin hatch adefaultInterval), so an age ratio would put a 7-day recheck at the head reading as 3.5 cadences stale.One additive non-indexed attribute, no migration, no queue pause, no backfill step.
The denominator has to come off the row, not from config
This is the part that changed late, on pre-deploy review, and it is worth reading before the rest.
render.demandis enabled and not in dry-run on the reference deployment. Its catalog routes grant 24 h as a ceiling and the ladder promotes bot-visited targets beneath it to 12 h/6 h. The write path honours that —nextRenderTime = now + decideInterval(...), the rung. Scoring resolved the cadence from config instead, which returns the ceiling, so a catalog page on the 6 h rung 6 h late scored0.25against a true1.0: a 4× understatement, on precisely the pages the ladder singled out as worth rendering more often. The feature was discarding the ladder's work, and the homepage tests couldn't see it (/is an exact 1 h route, single-rung, off-ladder).Recomputing at scoring time is not available:
decideIntervalneeds the stored rung to know where a target is, and it mutates ladder state (stats.promoted++,bump(level)), so calling it once per due row would corrupt everydemand_*metric.So the cadence travels on the row.
RenderSchedule.effectiveInterval(non-indexed) is written by every schedule writer through the funnel and read in a projection the sweep already pays for — no extra read. Recovering it fromRenderTargetinstead would be a cross-database point read per row over the whole due set, ~75% of them replication fetches on a residency-pinned table.resolveEffectiveIntervalresolves rung > route > stored > default, clamping a stale rung to the ceiling exactly asdecideIntervaldoes, so lowering a route can't leave an old rung reading as slower than the route.It is the page's cadence, not the gap that was written: a suppression recheck files 7 days and a backoff files its backoff, and neither is a cadence. Same distinction that makes the score lateness rather than age.
Absent is legal and self-healing. Pre-upgrade rows, and the writers with genuinely no cadence in hand, fall back to resolving from config — exactly what the sweep did before. So the first sweep after deploy behaves as the previous version did and the corpus fills in as rows re-render.
This also closes the sitemap half of the same gap: ingest goes through
Target.putwith the<changefreq>-derived interval, so those cadences now reach the row instead of being scored asrender.defaultInterval.Required explicitly at the funnel, like
fromSitemapand for the same hazard:putreplaces the record, so a writer that omits it doesn't leave the old value alone — it erases a correct cadence off a row that had one.The three properties that carry the safety argument
It's a cache in front of the old path. Cold (fresh worker generation), exhausted (claims outrunning the sweep), disabled, or a buffer that couldn't be sized — all fall through to the floored scan. Every failure mode is the previous behaviour, not a stalled queue. That's why it ships on.
queue.ready.enabled: falseis re-read per sweep tick and per claim, so it's a live kill switch needing no restart — and unlike the claim-floor kill switch it cannot make things worse.Nothing here is a correctness invariant. A stale entry costs at most one redundant render: the lease CAS refuses a duplicate, and
processJobResultalready drops a result whose target is gone. It cannot lose a page, because the next sweep re-reads the table. Compare the claim floor, where a row filed below it is never read again, silently and terminally.The sweep owns the floor — this is the bit most worth reviewing. Claims served from memory observe nothing, and a floor nothing observes freezes. Measured: an unfloored seek degrades 0.073 → 5.60 ms over 40,000 reschedules while a floored one stays flat at 0.07 ms. So the sweep applies the same floor rule, and is better informed doing it: it sees every due row, so "the first due row observed" is the true minimum rather than a window's. Disabling the sweep does not freeze the floor —
claimFromIndexkeeps its own advance/pin/unpin path.Two smaller decisions worth flagging:
fromSitemaprides in the buffer rather than being re-read. The renderer serializes a non-indexable page only when the URL is sitemap-listed, so a job reportingfalsefor a listed page silently stops it being cached — a bug this package has shipped twice — and the alternative is a point read per granted job against a residency-pinned table where an unowned read has no timeout.Observability
claim_grantedsplitready/indexis the one that matters: the ready set reorders a fixed amount of work and moves no total, so a node quietly serving every claim fromindexlooks identical to a healthy one in every other series.ready_cadencesplitcarried/resolvedis the backfill gauge — allresolvedon the first sweep after the upgrade, crossing over within one cadence. A ratio that stays low means rows are being filed without a cadence and promotions are being scored against their ceilings, which nothing else would show.Plus
ready_sweep_ms(methodcapped= ordering only the oldest part of the backlog, so recently-due pages go unranked) andready_published.What to watch on rollout
Deploy to one node first and watch
ready_sweep_ms. The sweep's cost on this corpus is genuinely unmeasured, and two numbers disagree by 10×: the harness says ~2.4 µs/row, the live cluster implies up to ~25 µs/row for a ~205-row window. The due-set size is also unknowable from the overview, because theoverduegauge saturates atmanagement.scanCap. Worst case is ~12.5 s per sweep on worker 0 — it yields every 200 rows so it won't block the loop, but these nodes are already swapping (4.6 GB used, 5.2 GB free of 33.6). That's whysweepIntervaldefaults to 5 minutes rather than 1. Tighten from the measurement, not from my estimate.Note that lowering
sweepCapis not the tuning lever: the scan is ascending, so truncating keeps the ancient long-cadence rows and drops the recently-due ones — precisely backwards. Acappedsweep warns for that reason.Testing
763 pass / 0 fail. The ready set and the scoring run against a plain
ArrayBufferwith no Harper; the sweep and the claim run against a fake table.Pinned specifically: the production symptom (a homepage 2 cadences late behind 400 rows that are 3 days older granted first); the claim serving it reading the index zero times; the floor still advancing; the fallback on cold / exhausted / disabled; a leased row never granted twice; an oversized key dropped rather than truncated (a truncated key names a different row); the cursor handing each index out exactly once across interleaved consumers, and not running away past the count (it's an Int32 incremented on every claim — unbounded it wraps in ~8 days of idling and starts handing out valid indices again); top-K bounded by K while streaming 100,000 candidates; and
fromSitemapsurviving the round trip.Four of the cadence tests were verified to fail against the code they describe, not just to pass against the fix:
THE LADDER GAPTHE UPGRADE PATHa BigInt carried cadence …Number()coercion, without which every promoted row silently falls backan unusable carried cadence …carriedCadence's> 0guard (a0cadence yieldsInfinityand takes every lease)A silent deploy-day regression, found while wiring the cadence
maybeUnpinFloorhanded the row's raw cadence straight back to the funnel. Every row written before the field exists carriesundefined, the funnel now refuses that, and the refusal lands inside the hatch's own try/catch — logged, swallowed, nothing pushed, nothing warned. The one mechanism that bounds a wedged row would have been dead on every node for a full cadence after the upgrade, looking perfectly healthy. It now files the cadence it resolved, which also keepsnextRenderTime - effectiveInterval === nowand leaves the row self-describing.Test-harness traps
Four tests initially passed or failed for the wrong reason, worth knowing if you edit these files: without routes configured every URL resolves to
defaultIntervalso relative lateness collapses to absolute lateness; the lease table runs on the real clock, so aT0-based lease expiry is already expired andisLeasedanswers false; the shared buffers outlive a test, so leases and generations leak between them (and between iterations of a loop inside one test — claims lease what they grant). The harness zeroes every buffer inbeforeEach, andresetShared()is exposed for loops.Deliberately not in this PR
invalidationReenqueue.jsinfers "when did this last render" asnextRenderTime - resolveRenderInterval(...)— the ceiling again, while rows are scheduled from the rung, so promoted pages read as having completed up to 18 h earlier than they did. Pre-existing, conservative in direction (it accelerates more rather than refusing wrongly), and now exactly computable fromeffectiveInterval. It changes invalidation eligibility semantics, so it needs its own tests.noindexbefore a Target is created, so a suppressed URL never enters the corpus and never accrues a 7-day recheck. That touches the hot serve path (the origin body is a stream relayed to the crawler, so it needs a bounded pass-through tee) and is worth its own review. The browser-side half shipped in feat(browser): skip the settle phase when the document already disowns itself #118.main(packages/console/test/trafficView.test.js:323, a destructure-to-omit trippingno-unused-vars, from fix(console): choose the statistic by the question — mean for capacity, median for typical, p95 for the tail #114). Not touched here so this diff stays about one thing.🤖 Generated with Claude Code