From 7a1c3c64fa355d24ef731470a8583ae974090490 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 16 Jul 2026 04:36:42 -0400 Subject: [PATCH 1/8] Make user-initiated prerender-html co-equal with indexing (priority 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For a published realm the rendered HTML is the deliverable served to visitors — a realm that is indexed but not prerendered serves a blank shell — so user-initiated prerendered HTML is as first-class as the search index, not an off-critical-path follow-on. Raise userInitiatedPrerenderHtml from 9 to 10, co-equal with user indexing; system-initiated prerender-html stays background (0). With prerender-html at 10 the high-priority worker pool (which floors at that tier) and the user-index pool both floor at 10 and serve all user-initiated work — indexing and rendering alike. What keeps each moving is worker capacity, not a dedicated lane; the split across the two count knobs is now immaterial. Comments on both the worker-manager tiers and the matrix harness updated to the co-equal model. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../matrix/support/isolated-realm-server.ts | 28 +++++++---------- packages/realm-server/worker-manager.ts | 20 ++++++------- packages/runtime-common/queue.ts | 30 +++++++++---------- 3 files changed, 35 insertions(+), 43 deletions(-) diff --git a/packages/matrix/support/isolated-realm-server.ts b/packages/matrix/support/isolated-realm-server.ts index b7e8410d263..fdaaf1e58a6 100644 --- a/packages/matrix/support/isolated-realm-server.ts +++ b/packages/matrix/support/isolated-realm-server.ts @@ -463,25 +463,17 @@ export async function startServer({ // Worker tiers for this shared, contended stack. Both Playwright // workers (fullyParallel) funnel their realm provisioning into one // worker manager, and each new workspace enqueues a from-scratch index - // (priority 10) plus a ~110-file, ~10s prerender-html sweep (priority 9). - // Priority is a pool-reservation floor, not an ordering — within a pool - // the queue dequeues oldest-first (see runtime-common/queue.ts) — so a - // newer index job is NOT pulled ahead of an already-queued prerender-html - // sweep in the same pool. That is what made createRealm (and new-user - // provisioning, which blocks on a personal-realm index before the - // workspace chooser renders) time out: the index sat behind ~10s render - // sweeps until it blew its settle budget. - // - // A dedicated user-index worker (floor 10) is the fix: it claims only - // indexing jobs and never the slower prerender-html tier below it, so an - // index job always has a lane that a render sweep can't hold. Index jobs - // are short (a fresh realm indexes in a few seconds once dequeued), so - // one is enough for the two-Playwright-worker producer rate. + // plus a ~110-file, ~10s prerender-html sweep. 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 these three high-tier workers all + // floor at 10 and serve both kinds of user work. What keeps indexing + // (which gates createRealm / new-user provisioning) and rendering (which + // gates published-realm serving) both moving is capacity, not a dedicated + // lane: three user-work workers absorb the two-Playwright-worker producer + // rate. (The split across the two count knobs is immaterial now that both + // floor at 10; kept separate only for symmetry with production.) `--userIndexCount=1`, - // Two high-priority workers still carry the prerender-html sweeps (and - // spill over onto indexing when free). Keep two, not one: republishing - // waits on the prerender-html job to regenerate the published HTML, so - // this tier must not become the new bottleneck. `--highPriorityCount=2`, `--fromUrl='https://localhost:4205/test/'`, diff --git a/packages/realm-server/worker-manager.ts b/packages/realm-server/worker-manager.ts index 94ef8ac3fa7..d6674e0ec13 100644 --- a/packages/realm-server/worker-manager.ts +++ b/packages/realm-server/worker-manager.ts @@ -672,16 +672,16 @@ 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. The user-index - // pool floors at the user-initiated indexing tier, so it claims only - // user indexing jobs and never the (orders-of-magnitude slower) - // prerender-html work one tier below — a dedicated lane that keeps - // index jobs (which gate createRealm and new-user provisioning) from - // waiting behind a prerender-html sweep already queued in the shared - // high-priority pool. The high-priority pool floors at the - // user-initiated prerender-html tier so it serves all user-initiated - // work — prerender-html included — and never system-tier jobs; the - // all-priority pool floors at the lowest tier and serves everything. + // 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 the user-index and high-priority pools both floor at that + // tier and serve all user-initiated work — indexing and prerender-html + // alike — and never system-tier jobs. The two counts stay separate knobs + // for deployment flexibility but land equivalent floor-10 workers; size + // the total to the user-work rate so neither indexing nor rendering + // starves the other. 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); } diff --git a/packages/runtime-common/queue.ts b/packages/runtime-common/queue.ts index ee9d57cffca..326e85fa422 100644 --- a/packages/runtime-common/queue.ts +++ b/packages/runtime-common/queue.ts @@ -8,23 +8,23 @@ import type { Deferred } from './deferred.ts'; // // The tiers: // -// | priority | job | -// | -------- | ---------------------------------- | -// | 10 | any user-initiated job | -// | 9 | user-initiated prerender-html | -// | 1 | any system-initiated job | -// | 0 | system-initiated prerender-html | +// | priority | job | +// | -------- | -------------------------------------------- | +// | 10 | any user-initiated job, incl. prerender-html | +// | 1 | system-initiated job (non-prerender-html) | +// | 0 | system-initiated prerender-html | // -// The prerender-html tiers sit one notch below their initiator tier so -// a pool's floor can take in an initiator's plain jobs with or without -// its (orders-of-magnitude slower) HTML rendering work. Two pools -// exist: 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. +// 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. export const userInitiatedPriority = 10; -export const userInitiatedPrerenderHtmlPriority = 9; +export const userInitiatedPrerenderHtmlPriority = 10; export const systemInitiatedPriority = 1; export const systemInitiatedPrerenderHtmlPriority = 0; From addba793f95adf9c9762b9bfa96cc383cc83e470 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 16 Jul 2026 04:45:12 -0400 Subject: [PATCH 2/8] Await published HTML as part of publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A published realm's rendered HTML is its deliverable, but prerender-html runs on a separate fire-and-forget channel after the index pass, so a realm can report ready (indexed) while still serving a shell without card markup — the source of the host-mode / head-tags publish flakes. Make publishing await the HTML. `_readiness-check` gains an opt-in `awaitPrerenderHtml` param that, after the index await, also waits for the realm's published HTML to be live for its current generation (`awaitPublishedHtmlReady`: polls `prerendered_html` for an instance row with isolated_html at >= the realm's current generation — the artifact, gated on generation, so it can't settle on a prior publish's stale rows or race the fire-and-forget enqueue). The publish handler points its 202 `Location` at the flagged readiness URL, and the matrix `publishRealm` helper waits on it, so a publish is not "done" until the realm is both indexed and rendered. Default readiness stays index-only, so createRealm / boot readiness are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/matrix/tests/host-mode.spec.ts | 17 ++++++ .../handlers/handle-publish-realm.ts | 7 ++- .../runtime-common/jobs/prerender-html.ts | 61 +++++++++++++++++-- packages/runtime-common/realm.ts | 18 +++++- 4 files changed, 94 insertions(+), 9 deletions(-) diff --git a/packages/matrix/tests/host-mode.spec.ts b/packages/matrix/tests/host-mode.spec.ts index 13ba6cd9e96..4351607b1eb 100644 --- a/packages/matrix/tests/host-mode.spec.ts +++ b/packages/matrix/tests/host-mode.spec.ts @@ -60,6 +60,23 @@ async function publishRealm( }, { realmURL, publishedRealmURL }, ); + + // Publishing is 202/async. A published realm's rendered HTML is its + // deliverable, so wait until its readiness check reports both indexed AND + // rendered — `awaitPrerenderHtml` holds the readiness response until the + // prerendered HTML is live, not just the index. This blocks server-side, so + // one GET with a generous budget resolves when the realm is fully viewable + // (rather than polling the served URL and racing the HTML job). + let readinessURL = `${publishedRealmURL}_readiness-check?awaitPrerenderHtml=true`; + let readiness = await page.request.get(readinessURL, { + headers: { Accept: 'text/html' }, + timeout: 120_000, + }); + if (!readiness.ok()) { + throw new Error( + `published realm did not become ready: HTTP ${readiness.status()} for ${readinessURL}`, + ); + } } // Create a fresh source realm, seed it with the host-mode fixture cards, and diff --git a/packages/realm-server/handlers/handle-publish-realm.ts b/packages/realm-server/handlers/handle-publish-realm.ts index 4eaed13755f..2cb42f5750a 100644 --- a/packages/realm-server/handlers/handle-publish-realm.ts +++ b/packages/realm-server/handlers/handle-publish-realm.ts @@ -689,8 +689,11 @@ export default function handlePublishRealm({ // realm's readiness check, which resolves once it is indexed and // viewable, and `Retry-After` hints the poll interval. This lets a // consumer discover where to wait for completion from the response - // itself rather than hard-coding the readiness URL. - let readinessCheckURL = `${publishedRealmURL}_readiness-check`; + // itself rather than hard-coding the readiness URL. `awaitPrerenderHtml` + // holds that readiness until the rendered HTML is live, not just the + // index — a published realm's HTML is its deliverable, so a publish is + // not complete until it exists. + let readinessCheckURL = `${publishedRealmURL}_readiness-check?awaitPrerenderHtml=true`; let response = new Response( JSON.stringify( { diff --git a/packages/runtime-common/jobs/prerender-html.ts b/packages/runtime-common/jobs/prerender-html.ts index ba6862981e3..81eeca482ee 100644 --- a/packages/runtime-common/jobs/prerender-html.ts +++ b/packages/runtime-common/jobs/prerender-html.ts @@ -5,14 +5,15 @@ import { type Job, type QueuePublisher, } from '../queue.ts'; -import type { PgPrimitive } from '../expression.ts'; +import { param, query, type PgPrimitive } from '../expression.ts'; +import type { DBAdapter } from '../db.ts'; import type { IncrementalChange } from '../tasks/indexer.ts'; import type { PrerenderHtmlArgs } from '../tasks/prerender-html.ts'; -// The prerender-html tier sits one notch below its initiator's tier (see the -// tier table in queue.ts): the high-priority worker pool still serves -// user-initiated HTML work, while system-initiated HTML work drops to the -// tier only the all-priority pool takes. +// 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. export function prerenderHtmlPriority(spawningPriority: number): number { return spawningPriority >= userInitiatedPriority ? userInitiatedPrerenderHtmlPriority @@ -42,6 +43,56 @@ export function prerenderHtmlConcurrencyGroup(realmURL: string): string { return `prerender-html:${realmURL}`; } +// Await a realm's published HTML being live for its current index +// generation. The index pass spawns the prerender-html job fire-and-forget +// and completes without it, so "indexed" does not imply "viewable" — a +// freshly published realm can be reachable and searchable while still +// serving a shell without card markup. Publishing awaits this so a published +// realm reports ready only once its HTML exists. +// +// Polls the artifact (`prerendered_html`) rather than the job: `batch.done()` +// swaps a generation's rendered rows in atomically, so once any instance row +// carries `isolated_html` at >= the realm's current generation, that +// generation's HTML is live. Gating on `generation` (not job status) sidesteps +// the fire-and-forget enqueue race and never settles on a prior publish's +// stale rows. Resolves true when live, false on timeout — best-effort so a +// stuck or failed render surfaces at the caller (e.g. a missing marker) rather +// than hanging readiness forever. +export async function awaitPublishedHtmlReady( + dbAdapter: DBAdapter, + realmURL: string, + opts?: { timeoutMs?: number; intervalMs?: number }, +): Promise { + let timeoutMs = opts?.timeoutMs ?? 60_000; + let intervalMs = opts?.intervalMs ?? 250; + let [genRow] = (await query(dbAdapter, [ + 'SELECT current_generation FROM realm_generations WHERE realm_url =', + param(realmURL), + ])) as { current_generation: number }[]; + let currentGeneration = genRow?.current_generation; + if (currentGeneration == null) { + // The realm has never been indexed — there is no generation to await. + return true; + } + let deadline = Date.now() + timeoutMs; + for (;;) { + let rows = await query(dbAdapter, [ + 'SELECT 1 FROM prerendered_html WHERE realm_url =', + param(realmURL), + 'AND generation >=', + param(currentGeneration), + "AND type = 'instance' AND isolated_html IS NOT NULL LIMIT 1", + ]); + if (rows.length > 0) { + return true; + } + if (Date.now() >= deadline) { + return false; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } +} + // Publish a `prerender_html` job through the normal queue-publish path. The // registered coalesce handler (tasks/prerender-html.ts) merges same-realm // publishes: per-URL update-wins merge, max generation/priority/timeout. diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index a594b20e147..28f87afa693 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -1,4 +1,5 @@ import { Deferred } from './deferred.ts'; +import { awaitPublishedHtmlReady } from './jobs/prerender-html.ts'; import type { RealmVisibility } from './realm-visibility.ts'; import type { SearchOpts } from './search-utils.ts'; import { buildSearchErrorBody, SearchRequestError } from './search-utils.ts'; @@ -1171,7 +1172,7 @@ export class Realm { } private async readinessCheck( - _request: Request, + request: Request, requestContext: RequestContext, ) { await this.#startedUp.promise; @@ -1180,12 +1181,25 @@ export class Realm { // resolved #startedUp, so awaiting it alone would report ready before the // reindex of the swapped files completes. Also await any in-flight full or // incremental index so a publish poll only succeeds once the just-published - // content is indexed and viewable. + // content is indexed. let inflight = this.indexing(); if (inflight) { await inflight; } + // Opt-in: also await the published HTML being live for the current + // generation. Indexing makes a realm searchable; prerendering makes it + // viewable — and for a published realm the HTML is the deliverable. That + // work lands on a separate (fire-and-forget) channel, so the publish flow + // sets `awaitPrerenderHtml` to hold readiness until the rendered HTML + // exists, not just the index. Left off by default so createRealm / boot + // readiness stay index-only and fast. + if ( + new URL(request.url).searchParams.get('awaitPrerenderHtml') === 'true' + ) { + await awaitPublishedHtmlReady(this.#dbAdapter, this.url); + } + return createResponse({ body: null, init: { From 482bf07594af8fb9e4b2d62ebd26ea9690788663 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 16 Jul 2026 04:55:50 -0400 Subject: [PATCH 3/8] Wire the publish HTML gate through all publish paths; fail readiness when HTML never lands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps in the prior commit: 1. Only the 202 Location carried awaitPrerenderHtml, so the shared waitForReady operation — used by the host Publish UI and boxel-cli — still polled the index-only readiness URL and reported a publish complete once the index settled, while the render was still queued. waitForReady gains an opt-in awaitPrerenderHtml flag; the two publish callers (host waitForRealmReady, boxel-cli realm publish) set it, so "publish awaits HTML" reaches production, not just the matrix test. Generic readiness (createRealm) leaves it off. 2. readinessCheck ignored awaitPublishedHtmlReady's result, so a render failure or a wait past its budget still returned 200. Return 503 (Retry-After) when the HTML isn't live for the current generation, so pollers keep waiting and a single-shot caller sees the failure. The matrix publishRealm helper now polls the readiness URL until 200 instead of a single blocking GET. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../boxel-cli/src/commands/realm/publish.ts | 3 +++ packages/host/app/services/realm-server.ts | 4 +++ packages/matrix/tests/host-mode.spec.ts | 26 ++++++++++++------- packages/runtime-common/realm-operations.ts | 11 +++++++- packages/runtime-common/realm.ts | 17 +++++++++++- 5 files changed, 50 insertions(+), 11 deletions(-) diff --git a/packages/boxel-cli/src/commands/realm/publish.ts b/packages/boxel-cli/src/commands/realm/publish.ts index 00ce530de33..b78856be7af 100644 --- a/packages/boxel-cli/src/commands/realm/publish.ts +++ b/packages/boxel-cli/src/commands/realm/publish.ts @@ -159,6 +159,9 @@ export async function publishRealm( await waitForReady(client, { publishedRealmURL: result.publishedRealmURL, timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + // A published realm's rendered HTML is its deliverable, so wait until it + // is live (not just indexed) before reporting the publish complete. + awaitPrerenderHtml: true, }); } diff --git a/packages/host/app/services/realm-server.ts b/packages/host/app/services/realm-server.ts index 95c423d2ce0..a1bfbbff971 100644 --- a/packages/host/app/services/realm-server.ts +++ b/packages/host/app/services/realm-server.ts @@ -1222,6 +1222,10 @@ export default class RealmServerService extends Service { publishedRealmURL, timeoutMs: opts?.timeoutMs, pollIntervalMs: opts?.pollIntervalMs, + // A published realm's rendered HTML is its deliverable, so hold the + // Publish UI's "Publishing…" state until the HTML is live, not just the + // index. + awaitPrerenderHtml: true, }); } diff --git a/packages/matrix/tests/host-mode.spec.ts b/packages/matrix/tests/host-mode.spec.ts index 4351607b1eb..1489c3cd231 100644 --- a/packages/matrix/tests/host-mode.spec.ts +++ b/packages/matrix/tests/host-mode.spec.ts @@ -64,17 +64,25 @@ async function publishRealm( // Publishing is 202/async. A published realm's rendered HTML is its // deliverable, so wait until its readiness check reports both indexed AND // rendered — `awaitPrerenderHtml` holds the readiness response until the - // prerendered HTML is live, not just the index. This blocks server-side, so - // one GET with a generous budget resolves when the realm is fully viewable - // (rather than polling the served URL and racing the HTML job). + // prerendered HTML is live, not just the index (readiness returns 503 while + // it isn't). Poll until 200 rather than polling the served URL and racing + // the HTML job. let readinessURL = `${publishedRealmURL}_readiness-check?awaitPrerenderHtml=true`; - let readiness = await page.request.get(readinessURL, { - headers: { Accept: 'text/html' }, - timeout: 120_000, - }); - if (!readiness.ok()) { + let lastStatus: number | undefined; + try { + await waitUntil(async () => { + let readiness = await page.request.get(readinessURL, { + headers: { Accept: 'text/html' }, + timeout: 120_000, + }); + lastStatus = readiness.status(); + return readiness.ok(); + }, 120_000); + } catch { throw new Error( - `published realm did not become ready: HTTP ${readiness.status()} for ${readinessURL}`, + `published realm did not become ready (indexed + rendered): last HTTP ${ + lastStatus ?? 'none' + } for ${readinessURL}`, ); } } diff --git a/packages/runtime-common/realm-operations.ts b/packages/runtime-common/realm-operations.ts index 8be949d1bf0..01752b855c4 100644 --- a/packages/runtime-common/realm-operations.ts +++ b/packages/runtime-common/realm-operations.ts @@ -286,6 +286,11 @@ export interface WaitForReadyInput { timeoutMs?: number; // Defaults to 1000ms. pollIntervalMs?: number; + // When true, hold readiness until the realm's published HTML is live for its + // current generation, not just the index. A published realm's rendered HTML + // is its deliverable (served to visitors), so publish callers set this; + // index-only readiness (e.g. createRealm) leaves it off to stay fast. + awaitPrerenderHtml?: boolean; } // Polls `_readiness-check` until it returns ok (the realm is @@ -302,7 +307,11 @@ export const waitForReady: RealmOperation = async ( let timeoutMs = input.timeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS; let pollIntervalMs = input.pollIntervalMs ?? DEFAULT_READINESS_POLL_INTERVAL_MS; - let readinessUrl = new URL('_readiness-check', publishedRealmURL).href; + let readinessUrlObj = new URL('_readiness-check', publishedRealmURL); + if (input.awaitPrerenderHtml) { + readinessUrlObj.searchParams.set('awaitPrerenderHtml', 'true'); + } + let readinessUrl = readinessUrlObj.href; let startedAt = Date.now(); let lastError: string | undefined; diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 28f87afa693..f9c7ac903f2 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -1197,7 +1197,22 @@ export class Realm { if ( new URL(request.url).searchParams.get('awaitPrerenderHtml') === 'true' ) { - await awaitPublishedHtmlReady(this.#dbAdapter, this.url); + let htmlReady = await awaitPublishedHtmlReady(this.#dbAdapter, this.url); + if (!htmlReady) { + // The current generation's HTML never became live within budget (a + // stuck/failed render, or a queue backlog longer than the wait). Report + // not-ready rather than a false 200 so a poller keeps waiting and a + // single-shot caller sees the failure instead of treating the publish + // as complete on an unrendered realm. + return createResponse({ + body: null, + init: { + headers: { 'content-type': 'text/html', 'Retry-After': '1' }, + status: 503, + }, + requestContext, + }); + } } return createResponse({ From 6518c9490bf2144a9777634c0dbc7a3b5542d850 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 16 Jul 2026 05:14:21 -0400 Subject: [PATCH 4/8] Gate published-HTML readiness on the render channel catching up, not a successful isolated_html row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit awaitPublishedHtmlReady required an instance row with non-null isolated_html at the current generation, which never appears for a realm whose renders error (e.g. the boxel-cli integration harness's noop prerenderer, where every instance becomes an error document) — so readiness hung and publish timed out. Gate instead on any prerendered_html row at >= the current generation: batch.done() swaps a generation's rows in atomically, so a row at the current generation means that generation's render batch has landed, whether the renders succeeded (isolated HTML present) or errored (error rows). Either way the publish's async render work is done; a genuine render failure surfaces downstream as missing markup rather than hanging readiness. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../runtime-common/jobs/prerender-html.ts | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/runtime-common/jobs/prerender-html.ts b/packages/runtime-common/jobs/prerender-html.ts index 81eeca482ee..70b8f8be2b9 100644 --- a/packages/runtime-common/jobs/prerender-html.ts +++ b/packages/runtime-common/jobs/prerender-html.ts @@ -43,21 +43,24 @@ export function prerenderHtmlConcurrencyGroup(realmURL: string): string { return `prerender-html:${realmURL}`; } -// Await a realm's published HTML being live for its current index -// generation. The index pass spawns the prerender-html job fire-and-forget -// and completes without it, so "indexed" does not imply "viewable" — a -// freshly published realm can be reachable and searchable while still -// serving a shell without card markup. Publishing awaits this so a published -// realm reports ready only once its HTML exists. +// Await the prerender-html channel having caught up to a realm's current +// index generation. The index pass spawns the prerender-html job +// fire-and-forget and completes without it, so "indexed" does not imply +// "viewable" — a freshly published realm can be reachable and searchable +// while still serving a shell without card markup. Publishing awaits this so a +// published realm reports ready only once its current generation has been +// rendered. // -// Polls the artifact (`prerendered_html`) rather than the job: `batch.done()` -// swaps a generation's rendered rows in atomically, so once any instance row -// carries `isolated_html` at >= the realm's current generation, that -// generation's HTML is live. Gating on `generation` (not job status) sidesteps -// the fire-and-forget enqueue race and never settles on a prior publish's -// stale rows. Resolves true when live, false on timeout — best-effort so a -// stuck or failed render surfaces at the caller (e.g. a missing marker) rather -// than hanging readiness forever. +// Signal: `prerendered_html` carrying any row at >= the realm's current +// generation. `batch.done()` swaps a generation's rendered rows into the +// production table atomically, so a row at the current generation means that +// generation's render batch has landed (a successful render leaves the +// isolated HTML on those rows; a failed one leaves error rows — either way the +// async render work for this publish is done, and a genuine render failure +// surfaces downstream as missing markup rather than hanging readiness). Gating +// on `generation` (not job status) sidesteps the fire-and-forget enqueue race +// and never settles on a prior publish's stale (lower-generation) rows. +// Resolves true when caught up, false on timeout. export async function awaitPublishedHtmlReady( dbAdapter: DBAdapter, realmURL: string, @@ -81,7 +84,7 @@ export async function awaitPublishedHtmlReady( param(realmURL), 'AND generation >=', param(currentGeneration), - "AND type = 'instance' AND isolated_html IS NOT NULL LIMIT 1", + 'LIMIT 1', ]); if (rows.length > 0) { return true; From e68ba5a6ddf2fa3ff46e95dc786cb0b10475a5db Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 16 Jul 2026 05:39:39 -0400 Subject: [PATCH 5/8] Update publish Location assertion for the awaitPrerenderHtml gate The 202 Location now points at the readiness check gated on the published HTML being rendered (?awaitPrerenderHtml=true), so a publish poll only reports ready once the realm is both indexed and viewable. Update the expected Location. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/realm-server/tests/publish-unpublish-realm-test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/realm-server/tests/publish-unpublish-realm-test.ts b/packages/realm-server/tests/publish-unpublish-realm-test.ts index 94b61045dec..f62909942eb 100644 --- a/packages/realm-server/tests/publish-unpublish-realm-test.ts +++ b/packages/realm-server/tests/publish-unpublish-realm-test.ts @@ -257,8 +257,8 @@ module(basename(import.meta.filename), function () { ); assert.strictEqual( response.headers['location'], - `${response.body.data.attributes.publishedRealmURL}_readiness-check`, - 'Location points at the readiness-check status monitor for the 202', + `${response.body.data.attributes.publishedRealmURL}_readiness-check?awaitPrerenderHtml=true`, + 'Location points at the readiness-check status monitor for the 202, gated on the published HTML being rendered', ); assert.ok( response.headers['retry-after'], From e1a95261585cd9e17334c1fa2020246a8aea63bd Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 16 Jul 2026 07:43:42 -0400 Subject: [PATCH 6/8] Poll published-HTML readiness at 1s, not 250ms Readiness callers already re-poll at ~1s (Retry-After: 1) and HTML rendering takes seconds, so the 250ms internal interval only multiplied DB queries (~240 per blocking request) under concurrent publish polls without improving latency. Default to 1000ms. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/runtime-common/jobs/prerender-html.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/runtime-common/jobs/prerender-html.ts b/packages/runtime-common/jobs/prerender-html.ts index 70b8f8be2b9..d53d353b7ca 100644 --- a/packages/runtime-common/jobs/prerender-html.ts +++ b/packages/runtime-common/jobs/prerender-html.ts @@ -67,7 +67,11 @@ export async function awaitPublishedHtmlReady( opts?: { timeoutMs?: number; intervalMs?: number }, ): Promise { let timeoutMs = opts?.timeoutMs ?? 60_000; - let intervalMs = opts?.intervalMs ?? 250; + // 1s cadence: readiness callers already re-poll at ~1s (Retry-After: 1) and + // HTML rendering takes seconds, so a tighter interval only multiplies DB + // queries under concurrent publish polls without meaningfully improving + // latency. + let intervalMs = opts?.intervalMs ?? 1000; let [genRow] = (await query(dbAdapter, [ 'SELECT current_generation FROM realm_generations WHERE realm_url =', param(realmURL), From 305ec9a09f9a260256d62cbc996012ef521cb05b Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 16 Jul 2026 07:51:52 -0400 Subject: [PATCH 7/8] Keep published-HTML readiness poll at 250ms for prompt readiness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responsiveness wins over the modest query volume here: the probe is a single indexed `SELECT 1 … LIMIT 1`, and readiness should return promptly once the render batch lands rather than tracking the caller's coarser ~1s re-poll. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/runtime-common/jobs/prerender-html.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/runtime-common/jobs/prerender-html.ts b/packages/runtime-common/jobs/prerender-html.ts index d53d353b7ca..3a1e50f26a2 100644 --- a/packages/runtime-common/jobs/prerender-html.ts +++ b/packages/runtime-common/jobs/prerender-html.ts @@ -67,11 +67,11 @@ export async function awaitPublishedHtmlReady( opts?: { timeoutMs?: number; intervalMs?: number }, ): Promise { let timeoutMs = opts?.timeoutMs ?? 60_000; - // 1s cadence: readiness callers already re-poll at ~1s (Retry-After: 1) and - // HTML rendering takes seconds, so a tighter interval only multiplies DB - // queries under concurrent publish polls without meaningfully improving - // latency. - let intervalMs = opts?.intervalMs ?? 1000; + // Poll at 250ms so readiness returns promptly once the render batch lands. + // The probe is a single indexed `SELECT 1 … LIMIT 1`, so the extra query + // volume within one blocking request is cheap; the caller's ~1s re-poll is a + // coarse fallback, not the cadence readiness responsiveness should track. + let intervalMs = opts?.intervalMs ?? 250; let [genRow] = (await query(dbAdapter, [ 'SELECT current_generation FROM realm_generations WHERE realm_url =', param(realmURL), From 684125e7acc97b2594bb0aaf6acd8a58aa41d899 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 16 Jul 2026 07:57:50 -0400 Subject: [PATCH 8/8] Wake published-HTML readiness on NOTIFY instead of tight-polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the fixed-interval poll with an event-driven wait: pg-queue emits NOTIFY jobs_finished when a job's finalize transaction commits, and the prerender-html batch's generation swap is durable by then, so re-checking on that signal catches the current generation landing near-instantly — with far fewer DB queries than a 250ms poll under concurrent publish polls. The LISTEN is feature-detected (PgAdapter exposes subscribe; SQLite has no pub/sub) and fire-and-forget: a coarse periodic poll remains as the guarantee, covering a missed notification, a failed/slow LISTEN, and adapters without pub/sub, so readiness never hangs on the subscription. Resolves as soon as the render channel catches up, false on timeout. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../runtime-common/jobs/prerender-html.ts | 90 ++++++++++++++++--- 1 file changed, 77 insertions(+), 13 deletions(-) diff --git a/packages/runtime-common/jobs/prerender-html.ts b/packages/runtime-common/jobs/prerender-html.ts index 3a1e50f26a2..cb154546d00 100644 --- a/packages/runtime-common/jobs/prerender-html.ts +++ b/packages/runtime-common/jobs/prerender-html.ts @@ -7,6 +7,7 @@ import { } from '../queue.ts'; import { param, query, type PgPrimitive } from '../expression.ts'; import type { DBAdapter } from '../db.ts'; +import { Deferred } from '../deferred.ts'; import type { IncrementalChange } from '../tasks/indexer.ts'; import type { PrerenderHtmlArgs } from '../tasks/prerender-html.ts'; @@ -61,17 +62,20 @@ export function prerenderHtmlConcurrencyGroup(realmURL: string): string { // on `generation` (not job status) sidesteps the fire-and-forget enqueue race // and never settles on a prior publish's stale (lower-generation) rows. // Resolves true when caught up, false on timeout. +// +// Woken by NOTIFY rather than tight-polling: pg-queue emits `NOTIFY +// jobs_finished` when a job's finalize transaction commits, and the +// prerender-html batch's swap is already durable by then, so re-checking on +// that signal catches the current generation landing near-instantly. The +// periodic poll is a safety net for a missed notification and for adapters +// without pub/sub (SQLite has no LISTEN), so it stays coarse. export async function awaitPublishedHtmlReady( dbAdapter: DBAdapter, realmURL: string, - opts?: { timeoutMs?: number; intervalMs?: number }, + opts?: { timeoutMs?: number; pollIntervalMs?: number }, ): Promise { let timeoutMs = opts?.timeoutMs ?? 60_000; - // Poll at 250ms so readiness returns promptly once the render batch lands. - // The probe is a single indexed `SELECT 1 … LIMIT 1`, so the extra query - // volume within one blocking request is cheap; the caller's ~1s re-poll is a - // coarse fallback, not the cadence readiness responsiveness should track. - let intervalMs = opts?.intervalMs ?? 250; + let pollIntervalMs = opts?.pollIntervalMs ?? 1000; let [genRow] = (await query(dbAdapter, [ 'SELECT current_generation FROM realm_generations WHERE realm_url =', param(realmURL), @@ -81,8 +85,8 @@ export async function awaitPublishedHtmlReady( // The realm has never been indexed — there is no generation to await. return true; } - let deadline = Date.now() + timeoutMs; - for (;;) { + + let hasCaughtUp = async () => { let rows = await query(dbAdapter, [ 'SELECT 1 FROM prerendered_html WHERE realm_url =', param(realmURL), @@ -90,13 +94,73 @@ export async function awaitPublishedHtmlReady( param(currentGeneration), 'LIMIT 1', ]); - if (rows.length > 0) { - return true; + return rows.length > 0; + }; + + if (await hasCaughtUp()) { + return true; + } + + // Feature-detected: PgAdapter exposes `subscribe`; SQLite does not and falls + // back to the poll below. + let subscribe = ( + dbAdapter as unknown as { + subscribe?: ( + channel: string, + handler: () => void, + ) => Promise<{ unsubscribe: () => Promise }>; } - if (Date.now() >= deadline) { - return false; + ).subscribe; + + let ready = new Deferred(); + let settled = false; + let settle = (value: boolean) => { + if (!settled) { + settled = true; + ready.fulfill(value); } - await new Promise((resolve) => setTimeout(resolve, intervalMs)); + }; + let recheck = () => { + hasCaughtUp().then( + (caughtUp) => { + if (caughtUp) { + settle(true); + } + }, + () => { + // A transient query error just waits for the next signal / poll tick. + }, + ); + }; + + let subscription: { unsubscribe: () => Promise } | undefined; + let poll = setInterval(recheck, pollIntervalMs); + let timer = setTimeout(() => settle(false), timeoutMs); + // Subscribe fire-and-forget: the poll is the guarantee, so a slow or failed + // LISTEN must never block the result or the timeout. If it comes up after + // we've already settled, just tear it down; otherwise re-check once, since a + // row may have landed between the check above and the LISTEN establishing. + if (subscribe) { + subscribe.call(dbAdapter, 'jobs_finished', recheck).then( + (sub) => { + if (settled) { + void sub.unsubscribe(); + } else { + subscription = sub; + recheck(); + } + }, + () => { + // LISTEN setup failed — rely on the poll. + }, + ); + } + try { + return await ready.promise; + } finally { + clearInterval(poll); + clearTimeout(timer); + await subscription?.unsubscribe(); } }