From b0e33aadad9d7a0eaba4021ce8a70b6e86736690 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Wed, 22 Jul 2026 10:26:34 -0400 Subject: [PATCH 1/5] Floor user-initiated prerender-html one tier below indexing (priority 9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-initiated prerender-html had been raised to priority 10 — co-equal with user indexing — on the premise that worker capacity, not a dedicated lane, keeps indexing responsive. A dedicated user-index lane (--indexJobsOnly) was then reintroduced precisely because render sweeps were occupying the workers index jobs need, which leaves co-equal-at-10 resting solely on that lane's job-type filter: the priority floor no longer separates indexing from rendering. Restore userInitiatedPrerenderHtmlPriority to 9 so the floor separates them again, mirroring the system tier (system prerender-html 0 sits one below system indexing 1). The dedicated lane now excludes render work by both its floor and its job-type filter — defense-in-depth. This is behavior-preserving for the current pool configuration: the high-priority pool still floors at the prerender-html tier and serves all user work, and the dedicated index lane still excludes prerender-html. Harden the rationale across the queue tier table, prerenderHtmlPriority, the worker-manager pool setup, and the matrix worker-tier harness, and add a guard test asserting prerender-html floors strictly below its initiator in both tiers. Bring the indexing-diagnostics skill's priority section up to the four-tier scheme (index visits carry 10/1, the prerender-html renders they spawn carry 9/0). Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/indexing-diagnostics/SKILL.md | 32 +++++++------ .../matrix/support/isolated-realm-server.ts | 21 ++++----- .../tests/prerender-html-split-test.ts | 45 +++++++++++++++++++ packages/realm-server/worker-manager.ts | 28 ++++++------ .../runtime-common/jobs/prerender-html.ts | 10 +++-- packages/runtime-common/queue.ts | 37 ++++++++------- 6 files changed, 114 insertions(+), 59 deletions(-) diff --git a/.claude/skills/indexing-diagnostics/SKILL.md b/.claude/skills/indexing-diagnostics/SKILL.md index 5487d68db5f..70073ec9938 100644 --- a/.claude/skills/indexing-diagnostics/SKILL.md +++ b/.claude/skills/indexing-diagnostics/SKILL.md @@ -90,20 +90,24 @@ If you pick the wrong category you waste a day. The diagnostic fields in the [Cl ## Prerender priorities -Every prerender request — visit, module, run-command — carries a numeric `priority` that flows from the originating worker job all the way to the per-tab queue, the per-affinity file-admission semaphore, and the per-server render semaphore. Two priorities are in production today: +Every prerender request — visit, module, run-command — carries a numeric `priority` that flows from the originating worker job all the way to the per-tab queue, the per-affinity file-admission semaphore, and the per-server render semaphore. The value is the worker-job priority of the job that produced the render, so it splits by both initiator (user vs. system) and job family (indexing vs. prerender-html): -- **`0` — `systemInitiatedPriority`**. Background indexing work: scheduled full-reindex sweeps, `_full-reindex` runs, the worker's continuous reindex queue. The default for any code path that doesn't explicitly opt in. -- **`10` — `userInitiatedPriority`**. Anything a user kicked off: the `_reindex` endpoint, ad-hoc card publishes, manual UI-driven reindex actions. +- **`10` — `userInitiatedPriority`**. A user-initiated index visit: the `_reindex` endpoint, ad-hoc card publishes, manual UI-driven reindex actions. +- **`9` — `userInitiatedPrerenderHtmlPriority`**. The HTML render a user-initiated index pass spawns — one tier below the index visit so it stays off the indexing hot path. +- **`1` — `systemInitiatedPriority`**. A system-initiated index visit: scheduled full-reindex sweeps, `_full-reindex` runs, the worker's continuous reindex queue. The default worker-job priority for any code path that doesn't explicitly opt in. +- **`0` — `systemInitiatedPrerenderHtmlPriority`**. The HTML render a system-initiated index pass spawns — the background floor only the all-priority worker pool takes. -Higher priority dequeues first; FIFO is preserved within a priority bucket. There is **no preemption** — an in-flight low-priority render runs to completion. The next free slot goes to the highest-priority queued waiter. +The two job families land on different diagnostics tables: an index-visit row on `boxel_index.diagnostics` carries 10 or 1; the prerender-html render it spawns, on `prerendered_html.diagnostics`, carries 9 or 0 — one notch below its initiator in both cases. + +Higher priority dequeues first; FIFO is preserved within a priority bucket. There is **no preemption** — an in-flight lower-priority render runs to completion. The next free slot goes to the highest-priority queued waiter. Why this matters for triage: -1. **Reading a stuck render**: a `priority=10` row is a user request. If it's stuck on `waits.tabQueueMs` or `waits.semaphoreMs`, that's the UX-visible saturation event the priority routing was designed to mitigate. A `priority=0` row stuck on the same wait is background work — operationally less urgent and often expected during a deliberate reindex burst. +1. **Reading a stuck render**: a user-initiated render (10 on an index visit, 9 on a prerender-html visit) is a UX-facing request. If it's stuck on `waits.tabQueueMs` or `waits.semaphoreMs`, that's the saturation event priority routing was designed to mitigate. A system-initiated render (1 or 0) stuck on the same wait is background work — operationally less urgent and often expected during a deliberate reindex burst. -2. **Distinguishing capacity issues from priority misrouting**: a `priority=10` row that waited >1s in `tabQueueMs` while the affinity's `prerender-queue-snapshot` shows `priorities=tab:10:N` (queued behind other priority-10 work) is a **capacity** problem — the user-priority workload exceeded the fleet. A `priority=10` row queued behind `priorities=tab:0:N` (queued behind background work, with manager-side priority routing live in the build) is a **routing** failure — the manager picked the wrong server, or the file render the row was queued behind isn't releasing. These need different fixes. +2. **Distinguishing capacity issues from priority misrouting**: a user-priority row that waited >1s in `tabQueueMs` while the affinity's `prerender-queue-snapshot` shows it queued behind same-or-higher-priority work is a **capacity** problem — the user-priority workload exceeded the fleet. The same row queued behind strictly lower-priority (background) work, with manager-side priority routing live in the build, is a **routing** failure — the manager picked the wrong server, or the file render the row was queued behind isn't releasing. These need different fixes. -3. **Confirming priority routing actually fired**: if a known-user `_reindex` shows up in `diagnostics` with `priority=0`, the producer-side threading (job → IndexRunner → `prerenderVisit`) regressed somewhere. Most-likely place is a new task type that didn't pick up `jobInfo.priority`. +3. **Confirming priority routing actually fired**: if a known-user `_reindex` shows up in `diagnostics` with a system-tier priority (1 or 0), the producer-side threading (job → IndexRunner → `prerenderVisit`) regressed somewhere. Most-likely place is a new task type that didn't pick up `jobInfo.priority`. 4. **Sharpening the deadlock fingerprint**: `affinitySnapshot.sameAffinityActivity[*].priority` lets you tell a self-referential prerender deadlock apart from priority-driven queuing. Same-priority queued module sub-render on a stuck same-priority file render → deadlock. Higher-priority queued sibling → priority routing working as intended. @@ -1337,12 +1341,14 @@ WHERE realm_url = 'https://localhost:4201/user/your-realm/' "requestId": "b14e…", // single ID across client/manager/prerender-server "invalidationId": "a3e1…", // single ID across every row written by the same Batch.invalidate() "indexedAt": 1776964391615, // wall-clock ms when IndexWriter.updateEntry ran - "priority": 10, // worker-job priority that produced this render. - // 0 = system-initiated background (default); 10 = - // userInitiatedPriority. Read in post-mortems alongside - // `tabQueueMs` to tell whether priority routing put a - // high-priority render at the head of the queue. May be - // absent on older rows that predate the threading. + "priority": 10, // worker-job priority that produced this render. Index + // visits carry 10 (userInitiatedPriority) or 1 + // (systemInitiatedPriority); the prerender-html render + // they spawn carries 9 or 0 — one tier below its + // initiator. Read in post-mortems alongside `tabQueueMs` + // to tell whether priority routing put a high-priority + // render at the head of the queue. May be absent on + // older rows that predate the threading. "tabReused": false, // did this render land on a warm same-affinity tab (true) // or a freshly spawned / commandeered tab (false)? // Triage signal: a slow render with `tabReused: false` diff --git a/packages/matrix/support/isolated-realm-server.ts b/packages/matrix/support/isolated-realm-server.ts index 820a44f50db..fa640553dd0 100644 --- a/packages/matrix/support/isolated-realm-server.ts +++ b/packages/matrix/support/isolated-realm-server.ts @@ -464,18 +464,15 @@ export async function startServer({ // workers (fullyParallel) funnel their realm provisioning into one // worker manager, and each new workspace enqueues a from-scratch index // plus a prerender-html job whose realm-wide module pre-warm sweep runs - // for tens of seconds. User indexing and prerender-html are co-equal - // (both priority 10 — see runtime-common/queue.ts: a published realm's - // rendered HTML is as first-class as its search index), so priority - // alone cannot keep sweeps from occupying every high-tier worker while - // a user write's incremental index job sits queued. That queue wait is - // what test assertions feel: createRealm blocks on the from-scratch - // index, and read-your-writes endpoints (card GET, _publishability) - // drain in-flight incremental indexing before responding. The - // user-index worker claims only indexing job types, so an index job - // always has a lane no render sweep can hold; the two high-priority - // workers carry the prerender-html sweeps alongside any other - // user-initiated work. + // for tens of seconds. Prerender-html sits one tier below indexing (see + // runtime-common/queue.ts), so the user-index worker — flooring at the + // indexing tier and further restricted to indexing job types — can never + // be held by a render sweep. That matters here because test assertions + // feel the queue wait: createRealm blocks on the from-scratch index, and + // read-your-writes endpoints (card GET, _publishability) drain in-flight + // incremental indexing before responding. The two high-priority workers + // carry the prerender-html sweeps alongside any other user-initiated + // work. `--userIndexCount=1`, `--highPriorityCount=2`, diff --git a/packages/realm-server/tests/prerender-html-split-test.ts b/packages/realm-server/tests/prerender-html-split-test.ts index 13e4eea915f..cba926c8db9 100644 --- a/packages/realm-server/tests/prerender-html-split-test.ts +++ b/packages/realm-server/tests/prerender-html-split-test.ts @@ -6,6 +6,10 @@ import { IndexWriter, VirtualNetwork, getQueueJobCoalesceHandler, + systemInitiatedPrerenderHtmlPriority, + systemInitiatedPriority, + userInitiatedPrerenderHtmlPriority, + userInitiatedPriority, type DefinitionLookup, type Diagnostics, type IndexingProgressEvent, @@ -15,6 +19,7 @@ import { type QueueJobSpec, type Reader, } from '@cardstack/runtime-common'; +import { prerenderHtmlPriority } from '@cardstack/runtime-common/jobs/prerender-html'; import { runPrerenderHtmlPass } from '@cardstack/runtime-common/index-runner/prerender-html-visit'; // Registers the `prerender_html` coalesce handler at load time. import '@cardstack/runtime-common/tasks/prerender-html'; @@ -409,6 +414,46 @@ module(basename(import.meta.filename), function () { }); }); + // Prerender-html floors one tier below its initiator so a worker pool that + // floors at the indexing tier reserves itself to indexing and never carries + // a render sweep (see queue.ts and the user-index lane in worker-manager). + // The two initiator tiers mirror each other. Guard the relationship so it + // can't quietly collapse back to co-equal. + module('priority tiers', function () { + test('prerender-html floors strictly below its initiator in both tiers', function (assert) { + assert.ok( + userInitiatedPrerenderHtmlPriority < userInitiatedPriority, + 'user-initiated prerender-html floors below user-initiated indexing', + ); + assert.ok( + systemInitiatedPrerenderHtmlPriority < systemInitiatedPriority, + 'system-initiated prerender-html floors below system-initiated indexing', + ); + }); + + test('user-initiated prerender-html outranks every system-initiated job', function (assert) { + // Keeps user renders in the high-priority pool (which floors at the + // user prerender-html tier) and out of reach of system-tier workers. + assert.ok( + userInitiatedPrerenderHtmlPriority > systemInitiatedPriority, + 'user-initiated prerender-html floors above system-initiated indexing', + ); + }); + + test('prerenderHtmlPriority maps an index pass to the render tier one notch below it', function (assert) { + assert.strictEqual( + prerenderHtmlPriority(userInitiatedPriority), + userInitiatedPrerenderHtmlPriority, + 'a user-initiated index pass spawns user-initiated prerender-html', + ); + assert.strictEqual( + prerenderHtmlPriority(systemInitiatedPriority), + systemInitiatedPrerenderHtmlPriority, + 'a system-initiated index pass spawns system-initiated prerender-html', + ); + }); + }); + module('prerenderHtmlOnly batch', function (hooks) { let adapter: PgAdapter; let indexWriter: IndexWriter; diff --git a/packages/realm-server/worker-manager.ts b/packages/realm-server/worker-manager.ts index 86593959f6c..360280bd2d3 100644 --- a/packages/realm-server/worker-manager.ts +++ b/packages/realm-server/worker-manager.ts @@ -672,21 +672,19 @@ let adapter: PgAdapter; eventSink.setAdapter(adapter); // Each pool's minimum priority is a dequeue floor: its workers only - // claim jobs at or above it, oldest-first among those. User-initiated - // indexing and prerender-html are co-equal (both `userInitiatedPriority` - // — a published realm's rendered HTML is as first-class as its search - // index), so a priority floor alone cannot keep the two kinds of work - // from occupying the same workers. What separates them is the claim - // query's job-type filter: a worker only dequeues job types it has - // registered handlers for, and the user-index pool starts with - // `--indexJobsOnly` so it registers exactly the indexing job types. - // That makes it a lane a prerender-html sweep can never hold — indexing - // gates realm provisioning and every write's read-your-writes drain, so - // it must stay responsive even when long render sweeps saturate the - // high-priority pool. The high-priority pool serves all user-initiated - // work, indexing and prerender-html alike; the all-priority pool floors - // at the lowest tier and serves everything, including system-initiated - // prerender-html. + // claim jobs at or above it, oldest-first among those. The user-index + // pool floors at the user-initiated indexing tier, one notch above + // user-initiated prerender-html, so its floor alone excludes render work — + // an index job always has a lane a prerender-html sweep can't hold. + // Indexing gates realm provisioning and every write's read-your-writes + // drain, so it must stay responsive even when long render sweeps saturate + // the high-priority pool. The `--indexJobsOnly` flag on this pool is + // defense-in-depth on top of that floor: it registers only the indexing + // job types, so the lane stays index-only even if the tier gap were ever + // narrowed. The high-priority pool floors at the user-initiated + // prerender-html tier and serves all user-initiated work — indexing and + // prerender-html alike; the all-priority pool floors at the lowest tier and + // serves everything, including system-initiated prerender-html. for (let i = 0; i < userIndexCount; i++) { await startWorker(userInitiatedPriority, urlMappings, { indexJobsOnly: true, diff --git a/packages/runtime-common/jobs/prerender-html.ts b/packages/runtime-common/jobs/prerender-html.ts index cb154546d00..b71e4073ff5 100644 --- a/packages/runtime-common/jobs/prerender-html.ts +++ b/packages/runtime-common/jobs/prerender-html.ts @@ -11,10 +11,12 @@ import { Deferred } from '../deferred.ts'; import type { IncrementalChange } from '../tasks/indexer.ts'; import type { PrerenderHtmlArgs } from '../tasks/prerender-html.ts'; -// User-initiated HTML work shares its initiator's tier — for a published -// realm the rendered HTML is a first-class artifact, as important as the -// search index (see the tier table in queue.ts). System-initiated HTML work -// still drops to the background tier only the all-priority pool takes. +// A prerender-html job takes the tier one notch below the index pass that +// spawned it: a user-initiated index (userInitiatedPriority) yields +// userInitiatedPrerenderHtmlPriority, anything lower yields +// systemInitiatedPrerenderHtmlPriority. Keeping HTML rendering one tier below +// its initiator is what holds it off the indexing hot path — see the tier +// table in queue.ts for why the gap is load-bearing. export function prerenderHtmlPriority(spawningPriority: number): number { return spawningPriority >= userInitiatedPriority ? userInitiatedPrerenderHtmlPriority diff --git a/packages/runtime-common/queue.ts b/packages/runtime-common/queue.ts index 326e85fa422..6c4ee2d4196 100644 --- a/packages/runtime-common/queue.ts +++ b/packages/runtime-common/queue.ts @@ -8,23 +8,30 @@ import type { Deferred } from './deferred.ts'; // // The tiers: // -// | priority | job | -// | -------- | -------------------------------------------- | -// | 10 | any user-initiated job, incl. prerender-html | -// | 1 | system-initiated job (non-prerender-html) | -// | 0 | system-initiated prerender-html | +// | priority | job | +// | -------- | ----------------------------------------- | +// | 10 | user-initiated job (non-prerender-html) | +// | 9 | user-initiated prerender-html | +// | 1 | system-initiated job (non-prerender-html) | +// | 0 | system-initiated prerender-html | // -// User-initiated prerender-html is co-equal with user indexing (both 10): -// for a published realm the rendered HTML is the deliverable served to -// visitors — as important as the search index — so it is NOT deprioritized -// below its initiator tier. System-initiated prerender-html stays one notch -// below system work (background); boot rendering is gated separately and must -// not crowd out user-tier jobs. The high-priority pool floors at -// `userInitiatedPrerenderHtmlPriority` (serving all user-initiated work and -// never system-tier jobs); the all-priority pool floors at -// `systemInitiatedPrerenderHtmlPriority` and serves everything. +// Prerender-html sits one tier below its initiator so a pool's floor can +// admit an initiator's indexing work with or without its (orders-of-magnitude +// slower) HTML rendering. The two initiator tiers are mirror images: user +// prerender-html (9) is to user indexing (10) as system prerender-html (0) is +// to system indexing (1). That gap is load-bearing, not cosmetic — HTML +// rendering is a distinct job family kept off the indexing hot path, and only +// because it floors one notch below indexing can a pool reserve itself to +// indexing alone (see the user-index lane in worker-manager). Indexing gates +// realm provisioning and every write's read-your-writes drain, so a render +// sweep must never occupy the workers an index job needs; raising +// prerender-html to its initiator's tier would dissolve that separation. The +// high-priority pool floors at `userInitiatedPrerenderHtmlPriority`, serving +// all user-initiated work — prerender-html included — and never system-tier +// jobs; the all-priority pool floors at `systemInitiatedPrerenderHtmlPriority` +// and serves everything. export const userInitiatedPriority = 10; -export const userInitiatedPrerenderHtmlPriority = 10; +export const userInitiatedPrerenderHtmlPriority = 9; export const systemInitiatedPriority = 1; export const systemInitiatedPrerenderHtmlPriority = 0; From 4c804e5889eaac678cab714b75abeac25749ac51 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Wed, 22 Jul 2026 10:51:01 -0400 Subject: [PATCH 2/5] Keep publish-awaited prerender-html co-equal with indexing (priority 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flooring all user-initiated prerender-html at 9 put it one tier below indexing unconditionally. That is right for the common case — an edit's HTML render is a background follow-on, off the indexing hot path — but wrong for a publish: a publish does not report its realm ready until the published HTML exists, so that render is on the publish's critical path and should be admitted as promptly as indexing. Thread an `awaitedByPublish` signal from the publish handler's index enqueues (the durability reindex and the republish fullIndex) through the from-scratch args to the prerender-html enqueue, where it lifts the job from userInitiatedPrerenderHtmlPriority (9) to userInitiatedPriority (10). The flag is carried in the from-scratch args only when true, so every other index path's args keep their existing shape; the task reads it loosely, matching the clearLastModified pattern. Every non-publish user render stays at 9; system renders stay at 0. At 10 a publish render meets the user-index lane's floor, so the lane's --indexJobsOnly job-type filter — not the priority floor — is what keeps it out of indexing's reserved workers. The high-priority pool floors at 9 and serves all three: user indexing (10), publish renders (10), and ordinary user renders (9). Comments (queue tier table, prerenderHtmlPriority, worker-manager, guard test) and the indexing-diagnostics skill updated for the exception. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/indexing-diagnostics/SKILL.md | 15 +++--- .../handlers/handle-publish-realm.ts | 12 ++++- .../tests/prerender-html-split-test.ts | 33 ++++++++++-- packages/realm-server/worker-manager.ts | 26 +++++----- .../runtime-common/jobs/prerender-html.ts | 40 +++++++++++---- packages/runtime-common/jobs/reindex-realm.ts | 7 +++ packages/runtime-common/queue.ts | 50 +++++++++++-------- .../runtime-common/realm-index-updater.ts | 3 +- packages/runtime-common/realm.ts | 10 +++- packages/runtime-common/tasks/indexer.ts | 12 +++++ 10 files changed, 148 insertions(+), 60 deletions(-) diff --git a/.claude/skills/indexing-diagnostics/SKILL.md b/.claude/skills/indexing-diagnostics/SKILL.md index 70073ec9938..946f27d5ac4 100644 --- a/.claude/skills/indexing-diagnostics/SKILL.md +++ b/.claude/skills/indexing-diagnostics/SKILL.md @@ -93,17 +93,17 @@ If you pick the wrong category you waste a day. The diagnostic fields in the [Cl Every prerender request — visit, module, run-command — carries a numeric `priority` that flows from the originating worker job all the way to the per-tab queue, the per-affinity file-admission semaphore, and the per-server render semaphore. The value is the worker-job priority of the job that produced the render, so it splits by both initiator (user vs. system) and job family (indexing vs. prerender-html): - **`10` — `userInitiatedPriority`**. A user-initiated index visit: the `_reindex` endpoint, ad-hoc card publishes, manual UI-driven reindex actions. -- **`9` — `userInitiatedPrerenderHtmlPriority`**. The HTML render a user-initiated index pass spawns — one tier below the index visit so it stays off the indexing hot path. +- **`9` — `userInitiatedPrerenderHtmlPriority`**. The HTML render a user-initiated index pass spawns — one tier below the index visit so it stays off the indexing hot path. **Exception:** a render a publish is awaiting (published-realm readiness gates on it) is lifted to `10`, co-equal with indexing, so the prerender server admits it ahead of ordinary user renders. - **`1` — `systemInitiatedPriority`**. A system-initiated index visit: scheduled full-reindex sweeps, `_full-reindex` runs, the worker's continuous reindex queue. The default worker-job priority for any code path that doesn't explicitly opt in. - **`0` — `systemInitiatedPrerenderHtmlPriority`**. The HTML render a system-initiated index pass spawns — the background floor only the all-priority worker pool takes. -The two job families land on different diagnostics tables: an index-visit row on `boxel_index.diagnostics` carries 10 or 1; the prerender-html render it spawns, on `prerendered_html.diagnostics`, carries 9 or 0 — one notch below its initiator in both cases. +The two job families land on different diagnostics tables: an index-visit row on `boxel_index.diagnostics` carries 10 or 1; the prerender-html render it spawns, on `prerendered_html.diagnostics`, carries 9 or 0 — one notch below its initiator — except a publish-awaited render, which carries 10. So `priority=10` can show up on either table: an index visit, or a publish's HTML render. Higher priority dequeues first; FIFO is preserved within a priority bucket. There is **no preemption** — an in-flight lower-priority render runs to completion. The next free slot goes to the highest-priority queued waiter. Why this matters for triage: -1. **Reading a stuck render**: a user-initiated render (10 on an index visit, 9 on a prerender-html visit) is a UX-facing request. If it's stuck on `waits.tabQueueMs` or `waits.semaphoreMs`, that's the saturation event priority routing was designed to mitigate. A system-initiated render (1 or 0) stuck on the same wait is background work — operationally less urgent and often expected during a deliberate reindex burst. +1. **Reading a stuck render**: a user-initiated render (10 on an index visit or a publish-awaited render, 9 on an ordinary user render) is a UX-facing request. If it's stuck on `waits.tabQueueMs` or `waits.semaphoreMs`, that's the saturation event priority routing was designed to mitigate. A system-initiated render (1 or 0) stuck on the same wait is background work — operationally less urgent and often expected during a deliberate reindex burst. 2. **Distinguishing capacity issues from priority misrouting**: a user-priority row that waited >1s in `tabQueueMs` while the affinity's `prerender-queue-snapshot` shows it queued behind same-or-higher-priority work is a **capacity** problem — the user-priority workload exceeded the fleet. The same row queued behind strictly lower-priority (background) work, with manager-side priority routing live in the build, is a **routing** failure — the manager picked the wrong server, or the file render the row was queued behind isn't releasing. These need different fixes. @@ -1345,10 +1345,11 @@ WHERE realm_url = 'https://localhost:4201/user/your-realm/' // visits carry 10 (userInitiatedPriority) or 1 // (systemInitiatedPriority); the prerender-html render // they spawn carries 9 or 0 — one tier below its - // initiator. Read in post-mortems alongside `tabQueueMs` - // to tell whether priority routing put a high-priority - // render at the head of the queue. May be absent on - // older rows that predate the threading. + // initiator — except a publish-awaited render, lifted to + // 10. Read in post-mortems alongside `tabQueueMs` to tell + // whether priority routing put a high-priority render at + // the head of the queue. May be absent on older rows that + // predate the threading. "tabReused": false, // did this render land on a warm same-affinity tab (true) // or a freshly spawned / commandeered tab (false)? // Triage signal: a slow render with `tabReused: false` diff --git a/packages/realm-server/handlers/handle-publish-realm.ts b/packages/realm-server/handlers/handle-publish-realm.ts index 2cb42f5750a..a7a24394919 100644 --- a/packages/realm-server/handlers/handle-publish-realm.ts +++ b/packages/realm-server/handlers/handle-publish-realm.ts @@ -606,7 +606,10 @@ export default function handlePublishRealm({ queue, dbAdapter, userInitiatedPriority, - { clearLastModified: true }, + // This publish is blocked on the published realm's HTML (readiness + // gates on it), so the prerender-html job this pass spawns runs + // co-equal with indexing rather than one tier below. + { clearLastModified: true, awaitedByPublish: true }, ); return { lastPublishedAt, publishedRealmId, isNewRealm }; @@ -654,7 +657,12 @@ export default function handlePublishRealm({ (await reconciler.lookupOrMount(publishedRealmURL)); if (publishedRealm) { void publishedRealm - .fullIndex(userInitiatedPriority, { clearLastModified: true }) + .fullIndex(userInitiatedPriority, { + clearLastModified: true, + // Republish is awaiting this HTML for readiness, so its render + // runs co-equal with indexing (see prerenderHtmlPriority). + awaitedByPublish: true, + }) .catch((err: unknown) => { log.error( `background publish reindex failed for ${publishedRealmURL}: ${ diff --git a/packages/realm-server/tests/prerender-html-split-test.ts b/packages/realm-server/tests/prerender-html-split-test.ts index cba926c8db9..89eb28123c1 100644 --- a/packages/realm-server/tests/prerender-html-split-test.ts +++ b/packages/realm-server/tests/prerender-html-split-test.ts @@ -414,11 +414,12 @@ module(basename(import.meta.filename), function () { }); }); - // Prerender-html floors one tier below its initiator so a worker pool that - // floors at the indexing tier reserves itself to indexing and never carries - // a render sweep (see queue.ts and the user-index lane in worker-manager). - // The two initiator tiers mirror each other. Guard the relationship so it - // can't quietly collapse back to co-equal. + // Prerender-html normally floors one tier below its initiator so a worker + // pool that floors at the indexing tier reserves itself to indexing and + // never carries a render sweep (see queue.ts and the user-index lane in + // worker-manager). The two initiator tiers mirror each other. The sole + // co-equal case is a publish-awaited render (a publish blocks on its HTML). + // Guard both the gap and the exception so neither drifts. module('priority tiers', function () { test('prerender-html floors strictly below its initiator in both tiers', function (assert) { assert.ok( @@ -452,6 +453,28 @@ module(basename(import.meta.filename), function () { 'a system-initiated index pass spawns system-initiated prerender-html', ); }); + + test('a publish-awaited render runs co-equal with indexing, but only for user work', function (assert) { + assert.strictEqual( + prerenderHtmlPriority(userInitiatedPriority, { awaitedByPublish: true }), + userInitiatedPriority, + 'a publish-awaited user render is lifted to the indexing tier', + ); + assert.strictEqual( + prerenderHtmlPriority(userInitiatedPriority, { + awaitedByPublish: false, + }), + userInitiatedPrerenderHtmlPriority, + 'a non-publish user render stays one tier below indexing', + ); + assert.strictEqual( + prerenderHtmlPriority(systemInitiatedPriority, { + awaitedByPublish: true, + }), + systemInitiatedPrerenderHtmlPriority, + 'the publish exception never lifts system-initiated work', + ); + }); }); module('prerenderHtmlOnly batch', function (hooks) { diff --git a/packages/realm-server/worker-manager.ts b/packages/realm-server/worker-manager.ts index 360280bd2d3..68b659aa1dc 100644 --- a/packages/realm-server/worker-manager.ts +++ b/packages/realm-server/worker-manager.ts @@ -673,18 +673,20 @@ let adapter: PgAdapter; // Each pool's minimum priority is a dequeue floor: its workers only // claim jobs at or above it, oldest-first among those. The user-index - // pool floors at the user-initiated indexing tier, one notch above - // user-initiated prerender-html, so its floor alone excludes render work — - // an index job always has a lane a prerender-html sweep can't hold. - // Indexing gates realm provisioning and every write's read-your-writes - // drain, so it must stay responsive even when long render sweeps saturate - // the high-priority pool. The `--indexJobsOnly` flag on this pool is - // defense-in-depth on top of that floor: it registers only the indexing - // job types, so the lane stays index-only even if the tier gap were ever - // narrowed. The high-priority pool floors at the user-initiated - // prerender-html tier and serves all user-initiated work — indexing and - // prerender-html alike; the all-priority pool floors at the lowest tier and - // serves everything, including system-initiated prerender-html. + // pool floors at the user-initiated indexing tier and registers only the + // indexing job types (`--indexJobsOnly`), giving index jobs a lane no + // prerender-html sweep can hold. Both guards carry weight: the floor keeps + // out ordinary user prerender-html (one tier below), and the job-type + // filter keeps out a publish-awaited render (which runs co-equal with + // indexing, so the floor alone would admit it). Indexing gates realm + // provisioning and every write's read-your-writes drain, so it must stay + // responsive even when long render sweeps saturate the high-priority pool. + // + // The high-priority pool floors at the user-initiated prerender-html tier + // (one below indexing), so it serves all user-initiated work — user + // indexing, publish-awaited renders, and ordinary user renders alike — and + // never system-tier jobs. The all-priority pool floors at the lowest tier + // and serves everything, including system-initiated prerender-html. for (let i = 0; i < userIndexCount; i++) { await startWorker(userInitiatedPriority, urlMappings, { indexJobsOnly: true, diff --git a/packages/runtime-common/jobs/prerender-html.ts b/packages/runtime-common/jobs/prerender-html.ts index b71e4073ff5..ed710257fa2 100644 --- a/packages/runtime-common/jobs/prerender-html.ts +++ b/packages/runtime-common/jobs/prerender-html.ts @@ -11,16 +11,30 @@ import { Deferred } from '../deferred.ts'; import type { IncrementalChange } from '../tasks/indexer.ts'; import type { PrerenderHtmlArgs } from '../tasks/prerender-html.ts'; -// A prerender-html job takes the tier one notch below the index pass that -// spawned it: a user-initiated index (userInitiatedPriority) yields +// A prerender-html job normally floors one tier below the index pass that +// spawned it — a user-initiated index (userInitiatedPriority) yields // userInitiatedPrerenderHtmlPriority, anything lower yields -// systemInitiatedPrerenderHtmlPriority. Keeping HTML rendering one tier below -// its initiator is what holds it off the indexing hot path — see the tier -// table in queue.ts for why the gap is load-bearing. -export function prerenderHtmlPriority(spawningPriority: number): number { - return spawningPriority >= userInitiatedPriority - ? userInitiatedPrerenderHtmlPriority - : systemInitiatedPrerenderHtmlPriority; +// systemInitiatedPrerenderHtmlPriority — which holds HTML rendering off the +// indexing hot path (see the tier table in queue.ts for why the gap is +// load-bearing). +// +// The exception is a render a publish is waiting on: a publish does not +// report the realm ready until its HTML exists, so that render is on the +// publish's critical path rather than a background follow-on. It runs +// co-equal with indexing (userInitiatedPriority) so the prerender server +// admits it ahead of ordinary user renders. The dedicated user-index worker +// lane still excludes it by job type (see worker-manager), so co-equal here +// never lets a publish render hold the workers indexing reserves. +export function prerenderHtmlPriority( + spawningPriority: number, + opts?: { awaitedByPublish?: boolean }, +): number { + if (spawningPriority < userInitiatedPriority) { + return systemInitiatedPrerenderHtmlPriority; + } + return opts?.awaitedByPublish + ? userInitiatedPriority + : userInitiatedPrerenderHtmlPriority; } export interface PrerenderHtmlEnqueueArgs { @@ -32,6 +46,11 @@ export interface PrerenderHtmlEnqueueArgs { spawningJobId: number | null; spawningPriority: number; timeoutSec: number; + // True only when a publish is awaiting this HTML. Lifts the job to the + // indexing tier (co-equal) instead of one notch below it, so the publish's + // critical-path render is admitted ahead of ordinary user renders. See + // prerenderHtmlPriority. + awaitedByPublish?: boolean; // True when a from-scratch index pass spawned this job. The realm-wide // module pre-warm sweep — O(realm module count) — runs at the start of the // job only when set; incremental spawns leave it false. @@ -183,6 +202,7 @@ export async function enqueuePrerenderHtmlJob( spawningPriority, timeoutSec, preWarm, + awaitedByPublish, }: PrerenderHtmlEnqueueArgs, ): Promise> { let args: PrerenderHtmlArgs = { @@ -200,7 +220,7 @@ export async function enqueuePrerenderHtmlJob( // Separate from `indexing:${realmURL}` so HTML work never blocks // indexing. concurrencyGroup: prerenderHtmlConcurrencyGroup(realmURL), - priority: prerenderHtmlPriority(spawningPriority), + priority: prerenderHtmlPriority(spawningPriority, { awaitedByPublish }), timeout: timeoutSec, args, }); diff --git a/packages/runtime-common/jobs/reindex-realm.ts b/packages/runtime-common/jobs/reindex-realm.ts index 03141425b15..964688dfb21 100644 --- a/packages/runtime-common/jobs/reindex-realm.ts +++ b/packages/runtime-common/jobs/reindex-realm.ts @@ -7,6 +7,12 @@ import { interface EnqueueReindexRealmJobOptions { clearLastModified?: boolean; + // Set only by the publish flow. A publish blocks on the published realm's + // HTML before reporting it ready, so the prerender-html job this index pass + // spawns must run co-equal with indexing rather than one tier below it (see + // prerenderHtmlPriority). Surfaced in the from-scratch args, and carried + // only when true so ordinary index args keep their existing shape. + awaitedByPublish?: boolean; } export async function enqueueReindexRealmJob( @@ -25,6 +31,7 @@ export async function enqueueReindexRealmJob( realmURL: realmUrl, realmUsername, clearLastModified, + ...(opts?.awaitedByPublish ? { awaitedByPublish: true } : {}), }; if (clearLastModified) { await query(dbAdapter, [ diff --git a/packages/runtime-common/queue.ts b/packages/runtime-common/queue.ts index 6c4ee2d4196..2fb33b3337a 100644 --- a/packages/runtime-common/queue.ts +++ b/packages/runtime-common/queue.ts @@ -8,28 +8,36 @@ import type { Deferred } from './deferred.ts'; // // The tiers: // -// | priority | job | -// | -------- | ----------------------------------------- | -// | 10 | user-initiated job (non-prerender-html) | -// | 9 | user-initiated prerender-html | -// | 1 | system-initiated job (non-prerender-html) | -// | 0 | system-initiated prerender-html | +// | priority | job | +// | -------- | ------------------------------------------------------- | +// | 10 | user indexing (+ any user job); publish-awaited render | +// | 9 | user prerender-html (the common case) | +// | 1 | system indexing (+ any system job) | +// | 0 | system prerender-html | // -// Prerender-html sits one tier below its initiator so a pool's floor can -// admit an initiator's indexing work with or without its (orders-of-magnitude -// slower) HTML rendering. The two initiator tiers are mirror images: user -// prerender-html (9) is to user indexing (10) as system prerender-html (0) is -// to system indexing (1). That gap is load-bearing, not cosmetic — HTML -// rendering is a distinct job family kept off the indexing hot path, and only -// because it floors one notch below indexing can a pool reserve itself to -// indexing alone (see the user-index lane in worker-manager). Indexing gates -// realm provisioning and every write's read-your-writes drain, so a render -// sweep must never occupy the workers an index job needs; raising -// prerender-html to its initiator's tier would dissolve that separation. The -// high-priority pool floors at `userInitiatedPrerenderHtmlPriority`, serving -// all user-initiated work — prerender-html included — and never system-tier -// jobs; the all-priority pool floors at `systemInitiatedPrerenderHtmlPriority` -// and serves everything. +// Prerender-html normally floors one tier below its initiator so a pool's +// floor can admit an initiator's indexing work with or without its +// (orders-of-magnitude slower) HTML rendering. The two initiator tiers mirror +// each other: user prerender-html (9) is to user indexing (10) as system +// prerender-html (0) is to system indexing (1). That gap is load-bearing — +// HTML rendering is a distinct job family kept off the indexing hot path, and +// only because it floors below indexing can a pool reserve itself to indexing +// by flooring at the indexing tier (see the user-index lane in worker-manager). +// Indexing gates realm provisioning and every write's read-your-writes drain, +// so a render sweep must never occupy the workers an index job needs. +// +// One render escapes that gap: a publish does not report its realm ready until +// the published HTML exists, so a publish-awaited render is on the publish's +// critical path and runs co-equal with indexing (10) — the prerender server +// then admits it ahead of ordinary user renders. It reaches the indexing tier +// only in priority; the user-index lane still excludes it by job type +// (`--indexJobsOnly`), so a publish render can never hold the workers indexing +// reserves. +// +// The high-priority pool floors at `userInitiatedPrerenderHtmlPriority` (9), +// serving all user-initiated work — both render tiers and user indexing — and +// never system-tier jobs; the all-priority pool floors at +// `systemInitiatedPrerenderHtmlPriority` (0) and serves everything. export const userInitiatedPriority = 10; export const userInitiatedPrerenderHtmlPriority = 9; export const systemInitiatedPriority = 1; diff --git a/packages/runtime-common/realm-index-updater.ts b/packages/runtime-common/realm-index-updater.ts index 02362b25f2b..5996eb91509 100644 --- a/packages/runtime-common/realm-index-updater.ts +++ b/packages/runtime-common/realm-index-updater.ts @@ -144,7 +144,7 @@ export class RealmIndexUpdater { publishFullIndex( priority = systemInitiatedPriority, - opts?: { clearLastModified?: boolean }, + opts?: { clearLastModified?: boolean; awaitedByPublish?: boolean }, ): { published: Promise>; completed: Promise; @@ -163,6 +163,7 @@ export class RealmIndexUpdater { priority, { clearLastModified: opts?.clearLastModified, + awaitedByPublish: opts?.awaitedByPublish, }, ); return job; diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 44c1779ded0..f585154f728 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -1601,7 +1601,10 @@ export class Realm { await this.#startedUp.promise; } - async fullIndex(priority?: number, opts?: { clearLastModified?: boolean }) { + async fullIndex( + priority?: number, + opts?: { clearLastModified?: boolean; awaitedByPublish?: boolean }, + ) { // Clear the realmInfo cache before re-indexing so cards rendered // during this pass read /realm.json from the now-populated index // rather than a stale "Unnamed Workspace" cached during an earlier @@ -1612,7 +1615,10 @@ export class Realm { this.invalidateCachedRealmInfo(); let { completed } = this.#realmIndexUpdater.publishFullIndex( priority ?? systemInitiatedPriority, - { clearLastModified: opts?.clearLastModified }, + { + clearLastModified: opts?.clearLastModified, + awaitedByPublish: opts?.awaitedByPublish, + }, ); await completed; // The from-scratch swap has landed in boxel_index: drop searchCards diff --git a/packages/runtime-common/tasks/indexer.ts b/packages/runtime-common/tasks/indexer.ts index 480e26236ec..b3a83afc3f4 100644 --- a/packages/runtime-common/tasks/indexer.ts +++ b/packages/runtime-common/tasks/indexer.ts @@ -320,6 +320,14 @@ function incomingClearsLastModified(args: unknown): boolean { return isObjectLike(args) && args.clearLastModified === true; } +// A publish sets this on its from-scratch args so the prerender-html job the +// pass spawns runs co-equal with indexing (the publish blocks on that HTML) +// rather than one tier below. Absent on every other index path. Read loosely +// so a job enqueued before this field existed reads as false. +function argsAwaitedByPublish(args: unknown): boolean { + return isObjectLike(args) && args.awaitedByPublish === true; +} + registerQueueJobDefinition({ jobType: 'incremental-index', coalesce: chooseIncrementalCoalesceDecision, @@ -383,6 +391,10 @@ const fromScratchIndex: Task = ({ spawningJobId: jobInfo?.jobId ?? null, spawningPriority: jobInfo?.priority ?? systemInitiatedPriority, timeoutSec: FROM_SCRATCH_JOB_TIMEOUT_SEC, + // A publish awaits this HTML, so its render is on the publish's + // critical path and runs co-equal with indexing (see + // prerenderHtmlPriority). Only the publish flow sets this. + awaitedByPublish: argsAwaitedByPublish(args), // From-scratch: the prerender job runs the realm-wide module // pre-warm sweep before its format renders. preWarm: true, From 970e578d805f79a296e4697bdb6fb468a363d034 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Wed, 22 Jul 2026 11:04:08 -0400 Subject: [PATCH 3/5] Review fixes: Prettier wrap, correct two doc comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap the inline `{ awaitedByPublish: true }` in the priority-tiers guard test so it satisfies Prettier's print width (was failing lint:js). - Fix the guard-test comment that claimed priority 9 puts user renders "out of reach of system-tier workers": the all-priority pool (floor 0) still serves them; what the tier relationship guarantees is that a floor-9 pool excludes system work. - Soften the indexing-diagnostics claim that every prerender request carries a priority — the visit-arg builders include it only when the originating job has a priority. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/indexing-diagnostics/SKILL.md | 2 +- .../realm-server/tests/prerender-html-split-test.ts | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.claude/skills/indexing-diagnostics/SKILL.md b/.claude/skills/indexing-diagnostics/SKILL.md index 946f27d5ac4..7e351b96838 100644 --- a/.claude/skills/indexing-diagnostics/SKILL.md +++ b/.claude/skills/indexing-diagnostics/SKILL.md @@ -90,7 +90,7 @@ If you pick the wrong category you waste a day. The diagnostic fields in the [Cl ## Prerender priorities -Every prerender request — visit, module, run-command — carries a numeric `priority` that flows from the originating worker job all the way to the per-tab queue, the per-affinity file-admission semaphore, and the per-server render semaphore. The value is the worker-job priority of the job that produced the render, so it splits by both initiator (user vs. system) and job family (indexing vs. prerender-html): +A prerender request a worker job spawns carries a numeric `priority` — threaded from that job through the per-tab queue, the per-affinity file-admission semaphore, and the per-server render semaphore (a request with no originating job priority omits the field). The value is the worker-job priority of the job that produced the render, so it splits by both initiator (user vs. system) and job family (indexing vs. prerender-html): - **`10` — `userInitiatedPriority`**. A user-initiated index visit: the `_reindex` endpoint, ad-hoc card publishes, manual UI-driven reindex actions. - **`9` — `userInitiatedPrerenderHtmlPriority`**. The HTML render a user-initiated index pass spawns — one tier below the index visit so it stays off the indexing hot path. **Exception:** a render a publish is awaiting (published-realm readiness gates on it) is lifted to `10`, co-equal with indexing, so the prerender server admits it ahead of ordinary user renders. diff --git a/packages/realm-server/tests/prerender-html-split-test.ts b/packages/realm-server/tests/prerender-html-split-test.ts index 89eb28123c1..e10732b2b7e 100644 --- a/packages/realm-server/tests/prerender-html-split-test.ts +++ b/packages/realm-server/tests/prerender-html-split-test.ts @@ -433,8 +433,11 @@ module(basename(import.meta.filename), function () { }); test('user-initiated prerender-html outranks every system-initiated job', function (assert) { - // Keeps user renders in the high-priority pool (which floors at the - // user prerender-html tier) and out of reach of system-tier workers. + // The high-priority pool floors at the user prerender-html tier; + // keeping that tier above every system tier is what lets a floor-9 pool + // serve user renders while excluding system indexing (1) and system + // renders (0). (The all-priority pool still serves user renders too; + // this is about which pools the floor lets in, not exclusivity.) assert.ok( userInitiatedPrerenderHtmlPriority > systemInitiatedPriority, 'user-initiated prerender-html floors above system-initiated indexing', @@ -456,7 +459,9 @@ module(basename(import.meta.filename), function () { test('a publish-awaited render runs co-equal with indexing, but only for user work', function (assert) { assert.strictEqual( - prerenderHtmlPriority(userInitiatedPriority, { awaitedByPublish: true }), + prerenderHtmlPriority(userInitiatedPriority, { + awaitedByPublish: true, + }), userInitiatedPriority, 'a publish-awaited user render is lifted to the indexing tier', ); From 8a7135c761fce3b7ab70986012d32b43cb6cad8c Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Wed, 22 Jul 2026 11:12:07 -0400 Subject: [PATCH 4/5] Reframe the index-lane rationale as opt-in, not a prod-wide guarantee The user-index --indexJobsOnly lane is enabled only by --userIndexCount>0 (0 by default); the contended matrix test stack runs it for provisioning back-pressure, while the staging/production entrypoints leave it at 0. The comments in queue.ts, prerender-html.ts, and worker-manager overstated it as the mechanism that keeps renders off indexing everywhere. Reframe: the render-tier gap's portable effect is the prerender server's in-render admission ordering (an index visit's render outranks the slower prerender-html renders); the reserved worker lane is an opt-in add-on on top of that. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/realm-server/worker-manager.ts | 21 +++++++----- .../runtime-common/jobs/prerender-html.ts | 7 ++-- packages/runtime-common/queue.ts | 33 ++++++++++--------- 3 files changed, 33 insertions(+), 28 deletions(-) diff --git a/packages/realm-server/worker-manager.ts b/packages/realm-server/worker-manager.ts index 68b659aa1dc..b63b3166f43 100644 --- a/packages/realm-server/worker-manager.ts +++ b/packages/realm-server/worker-manager.ts @@ -671,16 +671,19 @@ let adapter: PgAdapter; // is set. eventSink.setAdapter(adapter); - // Each pool's minimum priority is a dequeue floor: its workers only - // claim jobs at or above it, oldest-first among those. The user-index - // pool floors at the user-initiated indexing tier and registers only the + // Each pool's minimum priority is a dequeue floor: its workers only claim + // jobs at or above it, oldest-first among those. The user-index pool is + // opt-in — it exists only when `--userIndexCount` > 0 (0 by default). When + // run, it floors at the user-initiated indexing tier and registers only the // indexing job types (`--indexJobsOnly`), giving index jobs a lane no - // prerender-html sweep can hold. Both guards carry weight: the floor keeps - // out ordinary user prerender-html (one tier below), and the job-type - // filter keeps out a publish-awaited render (which runs co-equal with - // indexing, so the floor alone would admit it). Indexing gates realm - // provisioning and every write's read-your-writes drain, so it must stay - // responsive even when long render sweeps saturate the high-priority pool. + // prerender-html sweep can hold; both guards carry weight there — the floor + // keeps out ordinary user prerender-html (one tier below), and the job-type + // filter keeps out a publish-awaited render (co-equal at the indexing tier, + // which the floor alone would admit). The contended matrix test stack runs + // this lane to keep realm provisioning responsive under indexing + // back-pressure; deployments that leave `--userIndexCount` at 0 rely on + // high-priority-pool capacity, plus the render-tier gap that keeps HTML + // rendering behind indexing in the prerender server's own admission queue. // // The high-priority pool floors at the user-initiated prerender-html tier // (one below indexing), so it serves all user-initiated work — user diff --git a/packages/runtime-common/jobs/prerender-html.ts b/packages/runtime-common/jobs/prerender-html.ts index ed710257fa2..fce6a224a4f 100644 --- a/packages/runtime-common/jobs/prerender-html.ts +++ b/packages/runtime-common/jobs/prerender-html.ts @@ -22,9 +22,10 @@ import type { PrerenderHtmlArgs } from '../tasks/prerender-html.ts'; // report the realm ready until its HTML exists, so that render is on the // publish's critical path rather than a background follow-on. It runs // co-equal with indexing (userInitiatedPriority) so the prerender server -// admits it ahead of ordinary user renders. The dedicated user-index worker -// lane still excludes it by job type (see worker-manager), so co-equal here -// never lets a publish render hold the workers indexing reserves. +// admits it ahead of ordinary user renders. Where a dedicated index lane is +// configured (worker-manager's opt-in `--indexJobsOnly` pool), that lane's +// job-type filter — not the priority floor — is what keeps a co-equal publish +// render out of it. export function prerenderHtmlPriority( spawningPriority: number, opts?: { awaitedByPublish?: boolean }, diff --git a/packages/runtime-common/queue.ts b/packages/runtime-common/queue.ts index 2fb33b3337a..4b0b7e0c70b 100644 --- a/packages/runtime-common/queue.ts +++ b/packages/runtime-common/queue.ts @@ -15,24 +15,25 @@ import type { Deferred } from './deferred.ts'; // | 1 | system indexing (+ any system job) | // | 0 | system prerender-html | // -// Prerender-html normally floors one tier below its initiator so a pool's -// floor can admit an initiator's indexing work with or without its -// (orders-of-magnitude slower) HTML rendering. The two initiator tiers mirror -// each other: user prerender-html (9) is to user indexing (10) as system -// prerender-html (0) is to system indexing (1). That gap is load-bearing — -// HTML rendering is a distinct job family kept off the indexing hot path, and -// only because it floors below indexing can a pool reserve itself to indexing -// by flooring at the indexing tier (see the user-index lane in worker-manager). -// Indexing gates realm provisioning and every write's read-your-writes drain, -// so a render sweep must never occupy the workers an index job needs. +// Prerender-html floors one tier below its initiator. The two initiator tiers +// mirror each other: user prerender-html (9) is to user indexing (10) as +// system prerender-html (0) is to system indexing (1). The gap does two +// things. Everywhere, the same value rides to the prerender server as the +// render's in-render admission priority (see prerender-headers), where higher +// is dequeued first — so an index visit's render (its initiator tier) outranks +// the slower prerender-html renders, keeping HTML rendering off the indexing +// hot path at the render layer. And it lets a worker pool that floors at the +// indexing tier reserve itself to indexing; that reserved lane is opt-in via +// `--userIndexCount` + `--indexJobsOnly` (the contended matrix test stack runs +// it to keep provisioning responsive — see worker-manager), and deployments +// without it lean on high-priority-pool capacity instead. // -// One render escapes that gap: a publish does not report its realm ready until +// One render escapes the gap: a publish does not report its realm ready until // the published HTML exists, so a publish-awaited render is on the publish's -// critical path and runs co-equal with indexing (10) — the prerender server -// then admits it ahead of ordinary user renders. It reaches the indexing tier -// only in priority; the user-index lane still excludes it by job type -// (`--indexJobsOnly`), so a publish render can never hold the workers indexing -// reserves. +// critical path and runs co-equal with indexing (10), admitted ahead of +// ordinary user renders. Where the index lane is configured, its +// `--indexJobsOnly` filter (not the priority floor) is what keeps a co-equal +// publish render out of it. // // The high-priority pool floors at `userInitiatedPrerenderHtmlPriority` (9), // serving all user-initiated work — both render tiers and user indexing — and From 736d1e12538687c11000f2f8cb6b9befe7129c69 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Wed, 22 Jul 2026 11:28:26 -0400 Subject: [PATCH 5/5] Test that enqueuePrerenderHtmlJob delivers the publish tier The guard test covered the pure prerenderHtmlPriority function, but nothing asserted the wiring that actually delivers the priority. Add a hermetic test (stub QueuePublisher, no DB) that enqueuePrerenderHtmlJob threads awaitedByPublish into the enqueued job's priority: co-equal with indexing (userInitiatedPriority) when a publish awaits it, one tier below otherwise. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/prerender-html-split-test.ts | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/realm-server/tests/prerender-html-split-test.ts b/packages/realm-server/tests/prerender-html-split-test.ts index e10732b2b7e..ea5edf2064a 100644 --- a/packages/realm-server/tests/prerender-html-split-test.ts +++ b/packages/realm-server/tests/prerender-html-split-test.ts @@ -17,9 +17,14 @@ import { type QueueCoalesceCandidate, type QueueCoalesceContext, type QueueJobSpec, + type QueuePublisher, type Reader, } from '@cardstack/runtime-common'; -import { prerenderHtmlPriority } from '@cardstack/runtime-common/jobs/prerender-html'; +import { + enqueuePrerenderHtmlJob, + prerenderHtmlPriority, + type PrerenderHtmlEnqueueArgs, +} from '@cardstack/runtime-common/jobs/prerender-html'; import { runPrerenderHtmlPass } from '@cardstack/runtime-common/index-runner/prerender-html-visit'; // Registers the `prerender_html` coalesce handler at load time. import '@cardstack/runtime-common/tasks/prerender-html'; @@ -480,6 +485,54 @@ module(basename(import.meta.filename), function () { 'the publish exception never lifts system-initiated work', ); }); + + // Guards the hop that actually delivers the priority: enqueuePrerenderHtmlJob + // must thread awaitedByPublish into prerenderHtmlPriority. A stub publisher + // captures the enqueued spec, so this needs no DB. + test('enqueuePrerenderHtmlJob prices the render from its publish flag', async function (assert) { + let enqueueArgs = ( + overrides: Partial, + ): PrerenderHtmlEnqueueArgs => ({ + realmURL: testRealm, + realmUsername: 'test_realm', + changes: [{ url: `${testRealm}1.json`, operation: 'update' }], + generation: 1, + loaderEpoch: 'epoch-a', + spawningJobId: 100, + spawningPriority: userInitiatedPriority, + timeoutSec: 60, + preWarm: false, + ...overrides, + }); + let captured: { priority?: number } | undefined; + let stubPublisher = { + publish: (spec: { priority?: number }) => { + captured = spec; + return Promise.resolve(undefined); + }, + destroy: () => Promise.resolve(), + } as unknown as QueuePublisher; + + await enqueuePrerenderHtmlJob( + stubPublisher, + enqueueArgs({ awaitedByPublish: true }), + ); + assert.strictEqual( + captured?.priority, + userInitiatedPriority, + 'a publish-awaited render is enqueued co-equal with indexing', + ); + + await enqueuePrerenderHtmlJob( + stubPublisher, + enqueueArgs({ awaitedByPublish: false }), + ); + assert.strictEqual( + captured?.priority, + userInitiatedPrerenderHtmlPriority, + 'an ordinary user render is enqueued one tier below indexing', + ); + }); }); module('prerenderHtmlOnly batch', function (hooks) {