Repository navigation
Description
Problem
The render queue has no concept of priority. claim is strictly nextRenderTime-ascending, so under
any capacity deficit the queue serves whatever is oldest-due — regardless of whether a page was
submitted by the site owner or discovered by a third-party crawler, and regardless of how tight its
freshness budget is.
Two independent measurements say this is the wrong order under scarcity, and capacity is expected to
stay below demand indefinitely.
Provenance. On a production deployment during a multi-hour backlog, ~46% of the overdue queue
was bot-discovered rather than sitemap-submitted (239,090 of 521,929 rows). Roughly half the render
capacity was being spent on pages the site owner never submitted, while submitted pages aged past
their SWR window and fell through to origin.
TTL-blindness — the sharper of the two. Ordering by absolute due time treats a 1 h-TTL homepage
3 h overdue exactly like a 48 h-TTL product page 3 h overdue. The first is 300% stale, the second 6%.
So the shortest-TTL route is structurally the most damaged, and it is damaged even at full
capacity. Simulated over the real corpus (20-lanesim.mjs), measuring age ÷ TTL rather than
absolute lag:
| capacity | policy | home (1 h) | catalog (6–12 h) | product (48 h) | discovery |
|---|---|---|---|---|---|
| 100% | today | 4.78× | 1.32× | 1.08× | 1.08× |
| 100% | floors | 1.00× | 1.00× | 1.13× | 1.13× |
| 50% | today | 48.83× | 5.01× | 2.00× | 2.00× |
| 50% | floors | 4.95× (within SWR) | 1.33× | 2.49× | 2.49× |
| 50% | strict | 1.00× | 1.00× | 2.50× | STARVED |
Every policy renders the identical number of pages — this is pure redistribution, never extra
work. The model validates against production: it predicts ~3.3 h standing lag at ~100% utilisation,
where production measured 3.05 h at 98%.
The observable symptom today is a consistently stale homepage: a 1 h route sitting in a queue with a
~3 h standing lag, with a flat swrTtl shared across 1 h and 48 h routes, so worst-case served age is
7× its own interval while a product page is at 1.1× of its.
Scheduling intent is scattered across five mechanisms
There is no way to express "this matters more" today. Instead, five separate mechanisms overload the
same conflated field:
| mechanism | how it expresses intent |
|---|---|
documented nextRenderTime = 1 trick |
writes a sentinel due time to jump the queue |
Target.revalidate |
writes Date.now() into expiresAt |
| bulk invalidation epochs | deliberately does not touch the queue (cadence-heal only) |
render.failureRetry (incl. nonSitemapPenalty) |
pushes due time forward as a penalty |
| demand ladder | reallocates cadence within the route's budget |
Each is individually reasonable. Together they mean scheduling intent has five homes, none of them
named "priority", and every new requirement overloads nextRenderTime again.
Proposal
1. dueAt and priority are separate concepts
They are conflated today, which is the root of the problem above.
| concept | means | derived from |
|---|---|---|
dueAt |
when this content should be refreshed — a freshness deadline | interval / TTL / cadence |
priority |
how much it matters to hit that deadline when capacity is short | provenance, operator intent, health |
Truth about content vs. truth about importance. Neither is ever encoded into the other at the API
level.
2. Queue order is (lane, then dueAt ascending) — NOT relative lateness
An earlier revision of this issue proposed ordering within a class by relative lateness,
(now − dueAt) / interval descending, on the grounds that it makes "shorter TTL wins" fall out for
free. That reasoning is right about why short-TTL routes need protecting, but relative lateness
cannot be the comparator, because it is not a static order:
relativeLateness(t) = (t − dueAt) / intervalis linear intwith slope1/interval. Two rows
with different intervals therefore have different slopes and cross exactly once. No stored key
can express an order that changes with the clock.
Re-ranking inside the claim window does not rescue it either: the claim pass reads a bounded window
(~140 rows in production) that is already an EDF prefix, so a 1 h page sitting 600 minutes down the
queue never appears in it to be re-ranked.
The resolution: relative lateness becomes the rationale for lane assignment, not the comparator.
Lanes are derived from TTL and provenance, so within a lane the intervals are similar by construction
and EDF on dueAt is both index-backable and a good approximation of relative lateness. EDF is
provably optimal for maximum lateness, so any deviation inside a lane only ever costs.
3. priority is an ordered enum, and it is DERIVED, not stored
urgent operator intent — manual clear / invalidate / force-render
submitted present in a sitemap
discovered crawler-found, never submitted
cold repeatedly failing, or never successfully rendered
Named and ordered rather than a free-form integer: self-documenting in logs and the admin UI,
reviewable in a diff, and it cannot drift into arbitrary magic numbers.
Derived is load-bearing. Resolve the lane at write time from stable inputs (presence of
sitemapUrl, route match, failure count) instead of storing it — the same discipline
resolveRenderInterval already uses (route > stored > default). A config change is then retroactive
on each key's next render with no sweep of the corpus.
4. Fairness is scheduler policy, NOT part of the ordering key — and it must be FLOORS
queue:
lanes:
urgentMaxShare: 0.2 # drain-share cap on lane 0
minShare: { discovered: 0.10, cold: 0.02 }Two findings from the simulation, both load-bearing:
- Strict priority starves the tail at EVERY capacity level, including 100%. Its lag numbers look
excellent precisely because it drops work — starvation is invisible in a lag metric. Do not use it
for anything but a tiny rate-limited lane 0. - Floors beat fixed shares. Fixed shares summing to 1.0 leave nothing for a global EDF sweep, and
since EDF is optimal for maximum lateness the deviation only ever costs. Reserving a minimum for
the lanes that need protecting and letting the default lane compete for the remainder is far better
in the tail: discovery reaches 71 h vs 133 h at 50% capacity, 29 h vs 40 h at 75%.
For lane 0, cap its drain share, not its admission (urgentMaxShare): strict-first but never more
than that fraction of a claim batch. That is a hard structural bound — lanes 1–3 always get ≥80% —
that needs no token bucket and no admission bookkeeping. EDF within lane 0 so a flood is served
oldest-first.
Keeping the fairness bound out of the ordering key means it is tunable live with no rewriting of
stored rows. Any scheme that bakes fairness into the key requires re-encoding every stored value to
change the bound. This remains the strongest single argument for keeping ordering and fairness
separate.
5. One operation replaces the ad-hoc tricks
POST /render_queue/prioritize { url | scope, class: urgent }
This turns the cache-clear case into explicit policy rather than an accident of which mechanism the
operator reached for:
- clear-and-rush — drop the cached page and re-render at
urgent - clear-and-wait — drop the cached page, let normal cadence refill it, serve origin meanwhile
Both are legitimate; today the choice is implicit. It also retires nextRenderTime = 1, which is a
documented hack that only works if you happen to target the owner node.
6. Observability is part of the API, not an afterthought
- backlog depth per lane
- lane as a dimension on the render metrics
Without this there is no way to answer "is prioritisation working". Note the existing constraint that
recordAnalytics allows exactly three dimensions per metric — so this likely needs its own metric
name rather than an extra dimension on an existing one.
Encoding — DECIDED: lane in the high bits of nextRenderTime
The encoding was previously deferred. It is now settled, because one option turns out to need no
schema change and no migration at all.
Store nextRenderTime = lane × STRIDE + dueAtMs, with lane 0 = identity. The column stays
Long @indexed; the existing index is reused, not supplemented.
Why this one:
- Zero migration. Every existing row is already a valid lane-0 row. No backfill, no dual-write
window, and rollback is "stop encoding". - Lower value = claimed first, so the lane that matters most is the unencoded one. Urgent needs no
encoding, and the hottest path is untouched. - A lane change is an in-place numeric update, not a delete + put.
- Numerically safe with room to spare. Harper's
Longis 52-bit-safe (9.007e15) and current
timestamps are ~1.79e12. A stride of2^42(≈139 years, comfortably above any real timestamp)
yields 2,048 lanes; four are needed.
Measured support for one interleaved index over separate structures (21-duerank.mjs, 200k rows,
5.1.26 / rocksdb-js 2.4.1):
- Three lanes interleaved in ONE index, each with its own watermark: 0.29–0.32 ms per lane.
Interleaving costs nothing. - The same lane with its watermark reset to zero: 3.46 ms — the watermark is the entire win, not
the separation. - Buckets are unnecessary: bulk-enqueueing 100,000 keys at a single timestamp reads at 0.28 ms and
stays 0.29–0.43 ms while draining. Exact-ms ranking is fine. - A second index is not free: the existing secondary index is 39–48% of reschedule wall clock, so
a separate per-lane index or table roughly doubles the hot write.
Alternatives, and why they lose:
| option | verdict |
|---|---|
| additive offset on due time | too weak — a backlog deeper than the offset absorbs it; the lane goes invisible once everything is overdue, which is the steady state |
| low-order bits (due times are minute-floored, so ~59,999 ms per minute are free) | useless — only breaks ties within a single minute; a 600-minute backlog ignores it |
| time-bucketing (bucket-major, lane-minor) | works, bounded starvation, values stay valid timestamps — but spends due-time precision, so it breaks exactly the 1 h route this is meant to fix, and the bucket must be ≤ swrTtl |
| separate table or index per lane | clean semantics and a free per-lane floor, but a second index write on every reschedule, lane change becomes delete + put, and reconcile / backlog / admin each grow an N-way read — for no measurable gain over interleaving |
high-order lane prefix on nextRenderTime |
chosen — unbounded lane dominance, zero migration; cost is that the field stops being a timestamp for non-zero lanes (see below) |
The one real cost, and how it is guarded
For a non-zero lane the stored value is no longer a timestamp, while still looking exactly like
one. This is the same class of trap the maybeUnpinFloor comment already warns about — the row is the
only record, it outlives the pass that wrote it, and a later reader has no way to know the value was
synthetic. Guard it two ways:
dueAtOf(row)/laneOf(row)accessors inutil/renderSchedule.js, as the only way in or out.- A sanity horizon so a raw read of an encoded value throws loudly rather than returning a
plausible year-2109 date.
Implementation surface (audited against main @ v0.47.1)
Writes need no change at any call site. Every schedule write already funnels through
writeSchedule / writeSchedules — Target.put, Target.revalidate, suppression recheck, sitemap
ingest, reconcile, bot-request discovery, admin, the job-result path, and the unpin path. The lane is
resolved from the cacheKey's URL inside the funnel, so all ~14 writers keep passing plain
timestamps and never learn lanes exist.
Reads are the actual work — five sites:
| site | what breaks |
|---|---|
util/invalidationReenqueue.js:299 |
nextRenderTime − interval recovers the completion time; on an encoded row it silently answers ~139 years out. The one that lies quietly. |
util/backlogSnapshot.js:98-100 |
condition + sort would only ever see lane 0; needs per-lane ranges (disabled in production today, so this breaks on re-enable) |
util/renderSchedule.js:204 (lowerFloorFor), :351-363 (minuteOf algebra) |
the floor becomes lane-relative |
util/renderSchedule.js:528 |
new Date(nextRenderTime).toISOString() in the unpin warning would print year 2109 |
resources/PrerenderAdmin.js:178 |
the explainer displays the raw value |
Claim path: N seeks per pass instead of 1, one per lane at ~0.3 ms each — noise. The claim floor
becomes per-lane: one additional Uint32 per lane in the existing shared buffer.
Invariants any implementation must hold
expiresAtalways reflects truedueAt. Never a lane-adjusted value. Violating this serves
pages as fresh when they are not — silent and severe.- The claim floor is per-lane. A single watermark shared across lanes lets one low-priority row
pin the entire queue — the generalisation of the floor-pin failure mode fixed in v0.37.0. - Nothing outside the queue module reads the raw sort key. Callers see
dueAtandlane. - No corpus-wide sweep is required to change lane policy.
Cheaper levers available first
Neither is a substitute — under permanent scarcity the ordering is the fix — but both buy runway
without touching the schema:
- Per-route
swrTtl. A single global value cannot fit 1 h, 6 h and 48 h routes at once; the
short-TTL routes are the ones paying for it. Small plugin change, already flagged as the real fix in
the deployment's own config comments. - The existing manual force-render path, for a handful of URLs.
Open questions
Shouldurgentbypass the fairness reservation entirely, or compete within a reserved share?
Answered: neither — cap its drain share (urgentMaxShare), which bounds it structurally with
no admission bookkeeping.- Should
coldbe a lane at all, or is that the demand ladder's job? (The ladder treats the route's
base interval as its own top rung, so it can only make pages faster, never slower — a never-visited
page sits at base forever. Extending it downward overlaps withcold.) - Does bulk invalidation default to clear-and-rush or clear-and-wait?
Is relative lateness the right within-class comparator forAnswered by §2: relativeurgent?
lateness is not a viable comparator anywhere; every lane is EDF ondueAtinternally.- How many lanes actually ship? The simulation covers four;
submittedvsdiscoveredcarries the
provenance win and TTL carries the staleness win, and they may not need to be independent axes.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity
[-]Design: render priority as a first-class concept (dueAt + priority), replacing five overloaded scheduling mechanisms[/-][+]Design: render priority as lanes encoded in the high bits of nextRenderTime (no schema change)[/+]harper-joseph commentedon Sep 16, 2026
Closing: the problem this designed for is solved, and the encoding it chose was refuted by measurement.
The encoding argument did not survive the benchmark. #119 measured the two figures the high-bits design rested on: 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 37% worse thanput, so "a lane change is a cheap in-place numeric update" was never an advantage. #116 was closed on that basis.What actually decided it: a projected one-sided read is ~2.4 µs/row and flat, so priority never needed to live in the stored key. #120 (
prerender-v0.50.0) ships the ordering outside 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. That gives exact ordering instead of banded approximation, with no restamp migration, no decode-everywhere discipline, and a live kill switch.Both motivations in this issue are served by the score,
max(0, now − dueAt) / effectiveInterval × sitemapBoost:1/interval, two rows cross once). Doing it in memory sidesteps the impossibility rather than approximating around it.sitemapBoostis the submitted-over-discovered preference, tunable live.The denominator comes off the row (
RenderSchedule.effectiveInterval), not from config, so the demand ladder's promotions are scored at their true rung rather than their route ceiling. Observability shipped in #122;claim_grantedsplitsready/index.Several findings here carry into the shipped design and are worth keeping:
dueAtandprioritystay separate concepts; fairness is scheduler policy, not part of the ordering key; floors beat strict priority (which starves the tail at every capacity level, invisibly to a lag metric); and relative lateness is a rationale, never a comparator.What did not ship from the API section:
POST /render_queue/prioritizeand the explicit clear-and-rush / clear-and-wait split, sonextRenderTime = 1is still the documented way to jump the queue. That is a distinct, smaller piece of work — say the word and I'll open it on its own rather than leave it buried in a design issue whose encoding is dead.