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) / interval is linear in t with slope 1/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 0minShare: { 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
Long is 52-bit-safe (9.007e15) and current
timestamps are ~1.79e12. A stride of 2^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 in util/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
expiresAt always reflects true dueAt. 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
dueAt and lane. - 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
Should urgent bypass 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
cold be 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 with cold.) - Does bulk invalidation default to clear-and-rush or clear-and-wait?
Is relative lateness the right within-class comparator for urgent?Answered by §2: relative
lateness is not a viable comparator anywhere; every lane is EDF on dueAt internally.- How many lanes actually ship? The simulation covers four;
submitted vs discovered carries the
provenance win and TTL carries the staleness win, and they may not need to be independent axes.
Problem
The render queue has no concept of priority.
claimis strictlynextRenderTime-ascending, so underany 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 thanabsolute lag:
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
swrTtlshared across 1 h and 48 h routes, so worst-case served age is7× 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:
nextRenderTime = 1trickTarget.revalidateDate.now()intoexpiresAtrender.failureRetry(incl.nonSitemapPenalty)Each is individually reasonable. Together they mean scheduling intent has five homes, none of them
named "priority", and every new requirement overloads
nextRenderTimeagain.Proposal
1.
dueAtandpriorityare separate conceptsThey are conflated today, which is the root of the problem above.
dueAtpriorityTruth 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 latenessAn earlier revision of this issue proposed ordering within a class by relative lateness,
(now − dueAt) / intervaldescending, on the grounds that it makes "shorter TTL wins" fall out forfree. 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:
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
dueAtis both index-backable and a good approximation of relative lateness. EDF isprovably optimal for maximum lateness, so any deviation inside a lane only ever costs.
3.
priorityis an ordered enum, and it is DERIVED, not storedNamed 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 disciplineresolveRenderIntervalalready uses (route > stored > default). A config change is then retroactiveon 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
Two findings from the simulation, both load-bearing:
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.
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 morethan 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
This turns the cache-clear case into explicit policy rather than an accident of which mechanism the
operator reached for:
urgentBoth are legitimate; today the choice is implicit. It also retires
nextRenderTime = 1, which is adocumented hack that only works if you happen to target the owner node.
6. Observability is part of the API, not an afterthought
Without this there is no way to answer "is prioritisation working". Note the existing constraint that
recordAnalyticsallows exactly three dimensions per metric — so this likely needs its own metricname rather than an extra dimension on an existing one.
Encoding — DECIDED: lane in the high bits of
nextRenderTimeThe 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 staysLong @indexed; the existing index is reused, not supplemented.Why this one:
window, and rollback is "stop encoding".
encoding, and the hottest path is untouched.
Longis 52-bit-safe (9.007e15) and currenttimestamps are ~1.79e12. A stride of
2^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):
Interleaving costs nothing.
the separation.
stays 0.29–0.43 ms while draining. Exact-ms ranking is fine.
a separate per-lane index or table roughly doubles the hot write.
Alternatives, and why they lose:
swrTtlnextRenderTimeThe 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
maybeUnpinFloorcomment already warns about — the row is theonly 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.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, sitemapingest, 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:
util/invalidationReenqueue.js:299nextRenderTime − intervalrecovers the completion time; on an encoded row it silently answers ~139 years out. The one that lies quietly.util/backlogSnapshot.js:98-100util/renderSchedule.js:204(lowerFloorFor),:351-363(minuteOfalgebra)util/renderSchedule.js:528new Date(nextRenderTime).toISOString()in the unpin warning would print year 2109resources/PrerenderAdmin.js:178Claim 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
Uint32per lane in the existing shared buffer.Invariants any implementation must hold
expiresAtalways reflects truedueAt. Never a lane-adjusted value. Violating this servespages as fresh when they are not — silent and severe.
pin the entire queue — the generalisation of the floor-pin failure mode fixed in v0.37.0.
dueAtandlane.Cheaper levers available first
Neither is a substitute — under permanent scarcity the ordering is the fix — but both buy runway
without touching the schema:
swrTtl. A single global value cannot fit 1 h, 6 h and 48 h routes at once; theshort-TTL routes are the ones paying for it. Small plugin change, already flagged as the real fix in
the deployment's own config comments.
Open questions
Shouldurgentbypass the fairness reservation entirely, or compete within a reserved share?Answered: neither — cap its drain share (
urgentMaxShare), which bounds it structurally withno admission bookkeeping.
coldbe a lane at all, or is that the demand ladder's job? (The ladder treats the route'sbase 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 with
cold.)Is relative lateness the right within-class comparator forAnswered by §2: relativeurgent?lateness is not a viable comparator anywhere; every lane is EDF on
dueAtinternally.submittedvsdiscoveredcarries theprovenance win and TTL carries the staleness win, and they may not need to be independent axes.