Skip to content

Render a bulk realm import once instead of once per write - #6103

Open
backspace wants to merge 6 commits into
mainfrom
cs-12960-a-bulk-realm-push-re-indexes-and-re-prerenders-itself
Open

backspace wants to merge 6 commits into
mainfrom
cs-12960-a-bulk-realm-push-re-indexes-and-re-prerenders-itself

Conversation

@backspace

@backspace backspace commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

This is a mitigation for an obstacle I encountered when trying to set up the school realm on staging. I deployed it to staging and pushed a fresh realm and it fixed the problem:

s 2026-09-15 at 14 57 43@2x

Claude: A realm import — a bulk push, a realm copy, a restore, a load fixture — renders each card once, rather than once per write that touches it. Two mechanisms, one per commit.

A batch write spawns one render pass, not one per index pass

_batchWriteUnlocked sorts modules ahead of instances and flushes them to the index before serializing the first instance, because fileSerialization's lookupDefinition needs them there. Every index pass fires its own prerender_html job, and the flush's invalidation set is every dependent of those modules — which includes the instances the same batch is about to write, whose on-disk content at flush time is still the pre-write version. So a mixed batch renders those cards twice: once from content the write is seconds away from replacing, and again from what it wrote.

The flush defers instead. It returns the set it would have rendered, and whichever pass closes the write folds that set into the single prerender_html job the write pays for. Nothing is dropped along the way: a deferred set rides through a coalesce merge, and a batch whose instances all turn out byte-identical has no closing pass, so the write enqueues the job itself.

Deferring also leaves those dependents serving their existing HTML for the span of the write, instead of tombstoning them at the flush and leaving them blank until it finishes.

A batch write holds its realm's render lane

Coalescing merges an incoming job into one no worker has claimed yet, so it helps most when the queue is backed up and not at all when workers are free — the opposite of what an import needs. On an idle cluster each write's render pass is claimed and finished before the next write ends, so every card touched by more than one write is rendered once per write.

A batch write takes a lease on prerender-html:<realm> and holds it until its own indexing settles, not until the write returns. That distinction is the load-bearing one: the render job a write is responsible for does not exist when the write ends, because the index pass the write queued is what enqueues it. Releasing at the end of the write frees the lane in exactly that gap, so the previous write's pass gets claimed just before the new job lands and the two render the same cards back to back. Holding across the gap puts both on a held lane, where they merge. Consecutive bulk writes chain their holds, which is what collapses a whole import into one pass.

job_claim_holds is a lease keyed by concurrency group and holder, and the queue's claim query anti-joins against it. Held jobs stay unfulfilled, so they remain coalesce candidates — being held is what makes them available as a merge target. The shape follows from the failure modes:

  • a lease, not a flag: a writer that dies mid-hold frees the lane when the lease lapses rather than stalling the group for good
  • keyed by holder: one holder can never revoke another's hold
  • refreshes stop at RENDER_HOLD_MAX_MS, so a realm taking bulk writes back to back cannot keep its HTML from ever being rendered
  • a hold never delays the user-initiated tier, so a publish still gets the render it blocks on
  • writes below RENDER_HOLD_MIN_BATCH_SIZE take no hold, leaving the card-save path untouched
  • a realm on the browser-side index has no worker queue behind it, so the hold is inert there rather than an error

Measurements

Real renders against a local stack, counting the URL sets of the prerender_html jobs each scenario spawns.

shape prerender jobs renders distinct cards
one push: changed module + 20 new instances, 20 already indexed 2 → 1 41 → 41 40
re-push: changed module + the same 20 instances, changed 2 → 1 41 → 21 20
two back-to-back 31-file pushes 2 → 1 92 → 61 60
four back-to-back instance-only pushes 2 40 40

The last row is already optimal and stays that way — no module in the batch means no flush, and nothing to merge.

Deliberately not included

The hourly prerender_html_reconcile sweep can enqueue repairs for URLs a hold is deferring, if a tick lands mid-import. It only re-adds renders that were going to happen anyway, so it is left alone.

Test plan

packages/realm-server/tests/atomic-batch-prerender-dedup-test.ts and packages/realm-server/tests/bulk-write-render-hold-test.ts are new, and cover: a mixed batch spawning one pass; no URL rendered twice across a write's passes; a batch write holding its lane past the write and releasing it once indexing settles; a single-file write taking no hold; and work landing on a held lane waiting there and merging into one pass.

Each new test was checked against a build with its mechanism reverted, and fails there — the flush left un-deferred, the write's threshold raised out of reach, and the claim query's hold check neutralized, respectively.

Also run green locally alongside them: atomic-batch-indexing-test.ts and queue-test.ts (49 assertions total). runtime-common, realm-server, postgres, and host all type-check.

🤖 Generated with Claude Code

backspace and others added 2 commits September 14, 2026 12:38
A batch that mixes modules and instances is written in two index passes:
_batchWriteUnlocked sorts modules first and flushes them to the index before
serializing the first instance, because fileSerialization's lookupDefinition
needs them there. Each pass spawned its own prerender_html job, and the
flush's invalidation set is every dependent of those modules — which includes
the instances the same batch is about to write, still holding their pre-write
content on disk. So the write rendered them twice: once from content it was
seconds away from replacing, and again from what it wrote. Re-pushing a realm
whose module and instances both changed paid 41 renders for 20 cards.

The flush now defers instead: it returns the set it would have rendered, and
whichever pass closes the write folds that set into the one prerender_html job
the write pays for. Nothing is dropped along the way — a deferred set rides
through a coalesce merge, and a batch whose instances all turn out to be
byte-identical has no closing pass, so the write enqueues the job itself.

Deferring also means those dependents keep their existing HTML for the
duration of the write rather than being tombstoned at the flush and left
blank until the write finishes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Coalescing merges an incoming job into one no worker has claimed yet, which
means it helps most when the queue is backed up and not at all when workers
are free. A bulk import on an idle cluster is the second case: each write's
render pass is claimed and finished before the next write ends, so every card
touched by more than one write is rendered once per write. A realm imported
over two pushes paid 92 renders for 60 cards.

A batch write now takes a lease on its realm's render lane, and holds it until
its own indexing settles rather than until the write returns. That last part
is the whole trick: the render job a write is responsible for does not exist
when the write ends — the index pass the write queued is what enqueues it — so
releasing at the end of the write frees the lane in exactly the gap where the
previous pass gets claimed just before the new job lands. Holding across the
gap puts both on a held lane, where they merge. The same 60 cards now cost 61
renders in one pass.

Leases rather than a flag, keyed by holder: a writer that dies mid-hold frees
the lane when its lease lapses instead of stalling the group, one holder can
never revoke another's, and a refresh that stops at RENDER_HOLD_MAX_MS keeps a
realm under continuous bulk writes from never rendering at all. A hold never
delays the user-initiated tier, so a publish still gets the render it blocks
on. Writes below RENDER_HOLD_MIN_BATCH_SIZE take no hold, leaving the card-save
path untouched, and a realm on the browser-side index has no queue to hold.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-14T11:14:12.523421Z a27b110 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a27b1103fb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/postgres/migrations/1789382669943_add-job-claim-holds.js
Comment thread packages/runtime-common/realm.ts Outdated
Comment thread packages/runtime-common/jobs/claim-hold.ts
@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

    1 files  ±0      1 suites  ±0   2h 33m 5s ⏱️ - 1m 19s
4 803 tests ±0  4 789 ✅ ±0  14 💤 ±0  0 ❌ ±0 
4 818 runs  ±0  4 804 ✅ ±0  14 💤 ±0  0 ❌ ±0 

Results for commit e0b5c89. ± Comparison against earlier commit 32809ce.

Realm Server Test Results

    1 files  ± 0    226 suites  +1   1h 21m 57s ⏱️ + 3m 22s
3 056 tests +48  3 056 ✅ +48  0 💤 ±0  0 ❌ ±0 
3 095 runs  +48  3 095 ✅ +48  0 💤 ±0  0 ❌ ±0 

Results for commit e0b5c89. ± Comparison against earlier commit 32809ce.

backspace and others added 3 commits September 14, 2026 14:53
The host build compares the newest migration's timestamp against the checked-in
schema file's, and refuses to build when they diverge. Adding a migration
therefore requires advancing the file even when its contents do not move —
which they do not here: `job_claim_holds` is queue infrastructure, and the
queue tables are excluded from the dump because the browser-side index has no
worker queue behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two ways the hold outlived what it promised.

The five-minute cap was anchored per call. Holds compose, so back-to-back bulk
writes each started a fresh clock and no single hold ever looked old enough to
stop being renewed — a realm under continuous writes could hold its render lane
indefinitely. The anchor is now the first hold in an unbroken run, and it
clears when the last one releases.

A refresh already in flight when a release ran would re-create the row after
the delete, so the lane read as held moments after the notification told every
worker it was free. A release now marks the holder released and drains any
refresh before deleting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both sides thread a new field through the same index-pass plumbing: main
carries the invalidated card types on the incremental event, this branch
carries the prerender set an intermediate pass declined to render. They are
independent, so every conflict keeps both.

Main also reshaped the surfaces underneath. An index pass now takes an
`IndexChange[]` rather than URLs plus a `delete` flag, so the deferring
module→instance flush passes `asUpdates(urls)`. The write primitive became
`_commitBatchUnlocked`, a batch covering removals as well as writes; the
render-lane hold moves onto it, and its size threshold counts staged removals
alongside staged writes — a bulk delete costs the same render passes a bulk
write does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@backspace
backspace requested a review from a team September 15, 2026 12:58
@backspace

Copy link
Copy Markdown
Contributor Author

[Claude Code 🤖] Validated against a full realm import on staging. It works, and the merge is visible in the file counts.

Method: push the same ~2,300-file realm to a fresh realm, before and after, and count prerender_html jobs and their total_files.

before (baseline) with this PR
prerender for 2 files claimed claimed
prerender for 1 file claimed claimed
prerender for 1 file claimed claimed
prerender for 149 files claimed, rendered — 1m43s not claimed
final prerender total_files=2146 total_files=2295

The three small commits are below RENDER_HOLD_MIN_BATCH_SIZE, take no hold, and render immediately — by design. The 149-file commit crosses the threshold: its index pass completed (files_completed=149 at 12:51:16) and no prerender job was claimed for it. The lane stayed held, that pass stayed pending, and the bulk commit merged into it — 2295 = 2146 + 149, the union rendered once.

That is the duplicated work the issue describes, removed. Baseline wall-clock for the wasted pass was 1m43s on a realm this size; it scales with how much of the import lands in intermediate commits.

A caution for anyone repeating this. My first attempt reported the opposite — five prerender jobs, identical to baseline, hold apparently doing nothing. It was wrong: the push started at 12:39:26 UTC while the realm-server rollout did not reach COMPLETED until 12:42:30, so the deciding commit was served by an old task with no hold code in it. Comparing image tags is not enough, because the new task definition is registered before the rollout finishes — check deployments[0].rolloutState and updatedAt against the test's start time. Re-run cleanly and the behaviour is unambiguous.

Also confirmed incidentally: the hold does not block indefinitely, and RENDER_HOLD_MIN_BATCH_SIZE is doing real work — the sub-threshold commits were never delayed.

Two collisions, both narrow. The queue runner's import list gains a
job-heartbeat pair alongside the priority constant the claim-hold check reads.
And the commit primitive's staged-write map is typed `WriteContent` now, which
the render-lane hold's inner half adopts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@habdelra habdelra left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Went after the two mechanisms' failure modes rather than the happy path: the hold's lifecycle and its escape hatches, the deferred-set handoff through every path that can drop or duplicate it, and the claim query's exemption tier. I did not re-derive the render-count measurements or run the suites.

One blocking finding: RENDER_HOLD_MAX_MS does not actually cap a chain of holds, so the starvation guard is inert under exactly the workload it was added for. The deferral mechanism itself holds up — the handoff survives the coalesce merge, the byte-identical fallback, and the union rules, and the publish-awaited render is genuinely exempt from holds (prerenderHtmlPriority lifts it to userInitiatedPriority, which is what the claim query's >= userInitiatedPriority admits; an ordinary render at 9 is not, which is the intended line).

Recommendations:

  1. Check the chain age before JobClaimHold.acquire, not only in the heartbeat — see the comment on the acquire call in realm.ts. Blocking.
  2. Say which bulk paths reach the deferred commit branch; on waitForIndex (the default) the hold releases before the write returns and merges nothing — comment on incrementalIndexing() in _commitBatchUnlocked.
  3. Add holder_id to JobClaimHoldsTable, or drop the interface — comment on job-tables.ts.
  4. Retarget the #renderHoldChainStartedAt comment at _commitBatchUnlocked.

Adjacent, out of scope: IndexRunner#notifyInvalidationsReady returns early on an empty URL set, so a pass handed carriedPrerenderHtmlChanges that invalidates nothing would drop them, and performIndex's unconditional deferredPrerenderHtml = deferred would clear the parked set at the same time. I could not construct a reachable case — a flush only fires with at least one changed module in urls, and updateIndexAndCollectInvalidations short-circuits on an empty change set before the carried set is handed over — so this is a note for whoever adds a third pass to a batch write, not an ask of this PR.


Generated by Claude Code

Comment on lines +2818 to +2822
let hold = await JobClaimHold.acquire(
this.#dbAdapter,
prerenderHtmlConcurrencyGroup(this.url),
RENDER_HOLD_LEASE_MS,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] RENDER_HOLD_MAX_MS still doesn't bound the chain, so a realm under sustained bulk commits can keep its render lane held indefinitely — the starvation the cap exists to prevent.

The cap is consulted in one place, the heartbeat callback below. JobClaimHold.acquire writes a lease unconditionally: it goes straight to await hold.refresh(leaseMs), which guards only on #enabled and #released. So past the cap, the heartbeat stops renewing — but every new commit's acquire writes a fresh RENDER_HOLD_LEASE_MS (30s) lease, and its own heartbeat then declines to renew it. With commits arriving more often than one lease apart, the lane is continuously held by a succession of never-renewed-but-freshly-minted leases, and chainStartedAt never resets because #renderHoldDepth never returns to 0 (commit N+1 acquires while commit N is still indexing, which is the chain's premise).

The fix wants to be at acquire time — skip the hold, or acquire and release immediately, when #renderHoldChainStartedAt is already older than RENDER_HOLD_MAX_MS:

if (
  this.#renderHoldChainStartedAt !== undefined &&
  Date.now() - this.#renderHoldChainStartedAt > RENDER_HOLD_MAX_MS
) {
  return await this.#commitBatchUnlockedInner(batch, options);
}

Note that also leaves the depth counter untouched for that commit, so the anchor still clears when the in-flight holds drain.

bulk-write-render-hold-test.ts covers acquire, release, the refresh/release race and the merge, but nothing exercises the cap — a test that anchors the chain in the past and asserts the next commit takes no live lease would pin this.

Regression, introduced by this PR. Blocking: it is the guarantee the PR description advertises ("refreshes stop at RENDER_HOLD_MAX_MS, so a realm taking bulk writes back to back cannot keep its HTML from ever being rendered").


Generated by Claude Code

Comment on lines +2887 to +2891
let settled = this.incrementalIndexing();
if (settled) {
settled.then(releaseHold, releaseHold);
} else {
await releaseHold();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Which bulk paths actually reach the deferred branch here?

#commitBatchUnlockedInner treats waitForIndex as true unless explicitly false, and on that path it await performIndex(changes) before returning — which awaits updateChangessettled, and the indexing deferred is removed inside that. So by the time control reaches this line the realm has no pending incremental indexing, incrementalIndexing() returns undefined, and releaseHold() runs inline with its NOTIFY jobs. The chain never forms, and the commit pays an acquire + delete + notify for nothing.

/_atomic is on the deferred path — bulk-write-render-hold-test.ts pins that the hold outlives the write there — so the import case the measurements cover is fine. But the description names realm copy, restore and fixture loads as beneficiaries too, and I couldn't establish that those go through waitForIndex: false. If any of them commit synchronously, they get the cost of the hold and none of the merging, and it'd be worth either routing them through the deferred path or skipping the hold when waitForIndex isn't false.

Non-blocking — an answer, or a one-line guard, either way.


Generated by Claude Code

Comment on lines +23 to +26
export interface JobClaimHoldsTable {
concurrency_group: string;
expires_at: Date;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] This interface is missing holder_id and nothing imports it.

The migration creates job_claim_holds with a composite primary key of (concurrency_group, holder_id), and per-holder keying is the whole compose-safety argument — "a holder only ever deletes its own row, so one holder finishing cannot free the group out from under another". A reader who types their first query against this table off this interface writes the group-wide delete that argument exists to rule out.

grep finds no use of JobClaimHoldsTable anywhere; claim-hold.ts writes raw SQL and doesn't reference it. Either add holder_id: string or drop the interface until something needs it.

Regression, non-blocking.


Generated by Claude Code

Comment on lines +1336 to +1337
// Anchors the render-hold cap across back-to-back bulk writes; see
// `_batchWriteUnlocked`. Undefined whenever no write holds the lane.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Points at the wrong method — the hold, the anchor and the depth counter all live in _commitBatchUnlocked. _batchWriteUnlocked just forwards to it. Nit, non-blocking.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants