Skip to content

Refuse a module failure the prerender pool cannot be shown to have earned - #6010

Open
backspace wants to merge 4 commits into
mainfrom
cs-12762-dont-persist-a-render-failure-that-came-from-a-stale-host
Open

Refuse a module failure the prerender pool cannot be shown to have earned#6010
backspace wants to merge 4 commits into
mainfrom
cs-12762-dont-persist-a-render-failure-that-came-from-a-stale-host

Conversation

@backspace

@backspace backspace commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

This is a step in mitigating production hosted site downtime from the base realm importing new things from boxel-ui.

Claude: The indexer stores a failed render as the card's content, so a render that resolves current realm source against a bundle that predates an export turns a few minutes of deploy overlap into an error document served from cache to every anonymous reader until something reindexes the row.

Deciding that on whether the reported token moved during the render leaves the shape that does the damage untouched. A pool can be behind the current shell for a whole render with nothing moving under it: the token moves, the recycle it triggers is still running — or has failed and is waiting for another heartbeat to retry — and every render in that window lands on the outgoing bundle and reads as steady.

So the question is no longer whether the shell moved but whether the pool can be shown to have been on the current shell throughout, which is what warmedHostShellHash already records: it advances only once a recycle has actually re-warmed the pool, where the reported token moves the moment the change is learned. Requiring both samples of it to equal the reported token rules out three ways of being stale in one comparison — a pool that never caught up, a recycle that landed mid-render, and a token that moved under the render — and it is strictly broader than the condition it replaces, which the last of those three was all it could see.

A failure that survives the one re-render on a pool that is still not current is no longer returned. It answers 500, which remote-prerenderer maps to a retryable error, because the alternative is persisting a failure this server has already decided says nothing about the card. That trade is deliberate: a pool that cannot reach the current shell now fails visibly by refusing to index, where before it failed quietly by poisoning rows. It is logged at error for that reason.

Tests state both directions, since the difference between them is the whole point: a module failure on a pool demonstrably current is still returned for the caller to persist, and a pool that never reaches the current shell answers retryably with the failure nowhere in the response. Reverting the predicate to the old token-move rule fails four of them, including the stale-pool case it could not express.

…rned

The indexer stores a failed render as the card's content, so a render that
resolves current realm source against a bundle that predates an export turns a
few minutes of deploy overlap into an error document served from cache to every
anonymous reader until something reindexes the row.

Deciding that on whether the reported token moved *during* the render leaves
the shape that does the damage untouched. A pool can be behind the current
shell for a whole render with nothing moving under it: the token moves, the
recycle it triggers is still running — or has failed and is waiting for another
heartbeat to retry — and every render in that window lands on the outgoing
bundle and reads as steady.

So the question is no longer whether the shell moved but whether the pool can
be shown to have been on the current shell throughout, which is what
`warmedHostShellHash` already records: it advances only once a recycle has
actually re-warmed the pool, where the reported token moves the moment the
change is learned. Requiring both samples of it to equal the reported token
rules out three ways of being stale in one comparison — a pool that never
caught up, a recycle that landed mid-render, and a token that moved under the
render — and it is strictly broader than the condition it replaces, which the
last of those three was all it could see.

A failure that survives the one re-render on a pool that is *still* not current
is no longer returned. It answers 500, which `remote-prerenderer` maps to a
retryable error, because the alternative is persisting a failure this server
has already decided says nothing about the card. That trade is deliberate: a
pool that cannot reach the current shell now fails visibly by refusing to
index, where before it failed quietly by poisoning rows. It is logged at
`error` for that reason.

Tests state both directions, since the difference between them is the whole
point: a module failure on a pool demonstrably current is still returned for
the caller to persist, and a pool that never reaches the current shell answers
retryably with the failure nowhere in the response. Reverting the predicate to
the old token-move rule fails four of them, including the stale-pool case it
could not express.
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Host Test Results

    1 files  ±  0      1 suites  ±0   1h 36m 33s ⏱️ - 27m 42s
3 624 tests  - 990  3 616 ✅  - 984  8 💤  - 6  0 ❌ ±0 
3 636 runs   - 993  3 628 ✅  - 987  8 💤  - 6  0 ❌ ±0 

Results for commit b0bf179. ± Comparison against earlier commit a77a24a.

Realm Server Test Results

    1 files  ± 0    201 suites  +11   1h 10m 31s ⏱️ - 2m 16s
2 627 tests +97  2 627 ✅ +97  0 💤 ±0  0 ❌ ±0 
2 666 runs  +97  2 666 ✅ +97  0 💤 ±0  0 ❌ ±0 

Results for commit b0bf179. ± Comparison against earlier commit a77a24a.

@backspace

Copy link
Copy Markdown
Contributor Author

[Claude Code 🤖] Two things a reviewer should weigh that aren't visible from the diff, plus what CI verified.

1. The behaviour change is a refusal, and it wants explicit agreement.

A missing-export failure that survives the one re-render — on a pool that is still not demonstrably on the current shell — is no longer returned. It answers 500, which remote-prerenderer maps to a retryable error.

That is a deliberate trade rather than a side effect: a prerender pool that cannot reach the current shell now fails visibly, by refusing to index and logging at error, where before it failed quietly, by handing back a failure the indexer stores as the card's content and serves from cache to every anonymous reader until something reindexes the row. It is the right trade for the outage this targets, but it is the line where someone could reasonably want a different answer, so it should be agreed rather than pass unnoticed.

The risk to design against is the opposite one — swallowing a genuinely broken import — and that is why a module failure on a pool that is demonstrably current is still returned unchanged, with its own test.

2. The predicate is broader, not just renamed.

shouldRerenderForShellChangeshouldRerenderForStaleShell changes the question from "did the reported token move during this render?" to "can the pool be shown to have been on the current shell throughout?"

The old question misses the shape that does the damage: the token moves, the recycle it triggered is still running — or has failed and is waiting for another heartbeat to retry — and every render in that window lands on the outgoing bundle while reading as perfectly steady. The new one keys on warmedHostShellHash, which advances only once a recycle has actually re-warmed the pool, and rules out three ways of being stale in one comparison: a pool that never caught up, a recycle that landed mid-render, and a token that moved under the render. The old condition was only the third of those. It also takes one fewer parameter than what it replaces.

Mutation-checked rather than asserted: restoring the old token-move rule fails four cases, including a pool that never caught up is stale even though nothing moved — the one the old signature could not express.

What CI confirmed. The covering tests run in realm-server shards 3 and 4, and I checked they actually executed rather than trusting the green tick: all 6 stale-shell re-render route cases and all 12 predicate cases pass, zero not ok across both shards. That also resolves a caveat from my local run — 9 tests in prerender-server-test.ts fail on a machine without the dev stack serving the host app, and all 9 pass here.

Not in scope, deliberately. The pool's warmed token is not stamped into diagnostics, so this decision is not auditable after the fact. That needs a new Diagnostics field and belongs with CS-12763's ordering work, where the query it would serve is being designed.

@backspace
backspace marked this pull request as ready for review September 4, 2026 14:50
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 4, 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-04T14:52:34.747571Z 0a762f4 Draft marked ready
ℹ️ 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: 0a762f4c1b

ℹ️ 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/realm-server/prerender/prerender-app.ts Outdated
The re-check carried the pre-recycle sample forward as the retry's start token,
so a recycle that succeeded between the two renders left the retry looking
stale: warmed-before-recycle against the current reported token. A missing
export the retry then found on a pool that was current throughout — a card that
really is broken — was refused instead of returned.

That is worse than the failure this path prevents. The manager prunes a server
that answers 500, so a healthy prerender server would be dropped from the
registry over a broken card, and the visit retried until the registry ran out
of servers.

The retry now samples the warmed token inside its own closure, after the
recycle and immediately before the render it describes, so both samples belong
to the render being judged.

The tests had the same blind spot as the code: every case that reached the
re-check either failed twice on a pool that was never current, or succeeded on
the second attempt. None of them had a genuine failure survive a successful
recycle, which is exactly the shape that misjudged. That case is now covered,
and restoring the old assignment fails it.
@backspace
backspace requested a review from a team September 4, 2026 16:52

@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 🤖] This review went after the refusal's downstream consequences — prerender-appmanager-appremote-prerendererindex-runner — the predicate's inputs at each call site, and whether the new tests discriminate. It does not cover the render path inside prerenderer / render-runner, and no realm-server tests were run here (the test-postgres step needs a Docker daemon this environment lacks), so nothing below rests on a local run.

The predicate change is sound and is the broader question it claims to be. The refusal's blast radius is not: a 500 prunes every prerender server the visit reaches, not one, and after the retry budget the indexer writes the error doc anyway — so both ends of the trade need explicit agreement, and the recycle the retry awaits may belong to a different token than the one it is judged against.

Recommendations:

  1. Decide the refusal's wire disposition — the 500 empties the manager's registry rather than sidelining one server, and the resulting fan-out can hit a non-retryable abort. Thread on the "The 500 is not free" comment in prerender-app.ts. Blocking.
  2. Strengthen the refusal's precondition — awaitHostShellRecycle() can resolve on a recycle for an earlier token, in which case the refusal fires with no wait. Thread on retryWarmedAtStart. Blocking.
  3. State the terminal outcome — #handleVisitError#bufferErrorEntriesFor persists a file-error / instance-error row carrying this refusal's message once remote-prerenderer exhausts its ~6.5 minutes of retries. Thread on the throw.
  4. Consider narrowing the refusal to a failure that is a missing export, leaving additionalErrors to drive only the re-render. Thread on hasMissingExportError's new early return.
  5. Drop the duplicated predicate case in prerender-host-shell-recycle-test.ts. Thread there.
  6. Three route tests still reach the stale path through undefined rather than through a moved token. a module failure under a moved shell is re-rendered once, after the recycle, a rejecting re-render answers 500 so the visit is retried elsewhere, and a drain during the re-render answers draining all pass getHostShellHash: shellThatMovesOnce() and no getWarmedHostShellHash, so both warmed samples are undefined and the gate fires whatever the reported token does — each would behave identically with a steady token, which makes the shellThatMovesOnce framing inert. Stating both tokens, as the two rewritten cases now do, would make them pin the trigger they name.

Adjacent, out of scope:

  • package-shim-handler.ts's comment above MISSING_EXPORT_MESSAGE still describes the superseded rule — "A caller that can see the host shell moved under the render needs to recognize the message to treat it as retryable". That is the condition this PR argues is insufficient, and it is the far end of the contract this predicate implements.
  • stampHostShellTokens stamps only the reported tokens, so the decision made here isn't reconstructible from diagnostics. Already called out as deliberate; noting it only because the refusal makes the warmed token the first field an operator would reach for.

Generated by Claude Code

Comment thread packages/realm-server/prerender/prerender-app.ts Outdated
Comment thread packages/realm-server/prerender/prerender-app.ts Outdated
Comment thread packages/realm-server/prerender/prerender-app.ts
Comment thread packages/realm-server/prerender/prerender-app.ts
Comment thread packages/realm-server/tests/prerender-host-shell-recycle-test.ts Outdated
Answering a stale-shell failure with a 5xx cost far more than it bought, in two
ways that together remove the case for it.

It does not prevent the row. `remote-prerenderer` retries a 5xx twelve times
over roughly six and a half minutes and then rethrows, and `#handleVisitError`
buffers a `file-error` row — plus an `instance-error` row for a card instance —
carrying whatever message arrived. So the refusal substituted one error document
for another, later, rather than avoiding one.

And its blast radius is the whole fleet, not one server. The manager's proxy
loop prunes a 5xx target and then removes it from its attempt set as well, so
`attempts.size < registry.servers.size` stays true, the loop advances to the
next server, and prunes that one too. Since every prerender server learns the
same token from the same manager, "the pool has not reached the current shell"
tends to be true of all of them at once — so one visit can empty the registry
and leave the manager answering `503 No servers`, with affinity assignments
gone and later arrivals parked waiting for heartbeats to refill it. Each hop is
also two renders inside a per-attempt abort that is classified as *not*
retryable, so the fan-out can convert the intended retry into a hard failure.

Declining to persist a skew-shaped failure belongs where the row is written.
Recovery here is one extra render, and the widened predicate is what makes that
render fire in the case that matters: a pool behind the current shell for a
whole render, with nothing moving under it, which the previous token-move rule
could not see.

Removing the refusal also settles two questions it had raised. Whether
`awaitHostShellRecycle` awaits the right token no longer decides anything
heavier than one wasted render — worth tightening, but not here. Nor does it
matter that `hasMissingExportError` matches inside `additionalErrors`: that
breadth costs an extra render again, as it did before, rather than a 5xx.

Tests follow the same line. The refusal's own case goes; its companion keeps its
value and says what it now guards. Two predicate cases had become byte-identical,
so one is dropped. And three route tests named for a moved shell left both warmed
samples `undefined`, which fires the gate whatever the reported token does — they
would have passed identically with a steady token. They now state the pool's
token too, and fail when the reported one is held still.
@backspace

Copy link
Copy Markdown
Contributor Author

[Claude Code 🤖] Both blocking items were right, and together with the terminal-outcome finding they removed the case for the refusal rather than changing its shape. b0bf17987a keeps the widened predicate and drops the 5xx it was driving — net −81 lines.

Why removal rather than a better disposition. Two verified facts, either of which alone might have been survivable:

  • It never prevented the row. remote-prerenderer retries a 5xx for ~6.5 minutes, rethrows, and #handleVisitError#bufferErrorEntriesFor writes a file-error (plus instance-error) row carrying the message. The refusal substituted one error document for another, later.
  • Its blast radius is the fleet. pruneServer shrinks the registry while attempts.delete shrinks the attempt set, so the proxy loop advances and prunes the next server too — and since every server learns the same token from the same manager, the condition is true of all of them at once. One visit can empty the registry and answer 503 No servers.

So the trade was a fleet-wide prune in exchange for delaying one row. No choice of status code fixes that, which is why I didn't take the draining-status suggestion: it avoids the prune but misreports a healthy server as draining, still walks the fleet, and leaves the row written either way.

What survives. The predicate change, which was the half that stood on its own: recovery is one extra render, as before, but it now fires for a pool that is behind for a whole render with nothing moving under it — the case the old token-move rule could not see.

What this means for CS-12762. Its ask — don't persist a failure that came from a stale bundle — needs the indexer, not the prerender server. This layer can only delay a write, never decline one. I've noted on the threads that two things you found become load-bearing again there: the recycle/token mismatch (a decision that heavy needs a recycle keyed to the reported token, or a re-sample after a heartbeat), and the additionalErrors breadth (declining to persist wants a stricter test than declining to trust one render).

Tests. The refusal's case is gone; its companion kept and retitled to what it now guards. Duplicate predicate case dropped. And your item 6 was the useful one — those three route tests left both warmed samples undefined, so the gate fired whatever the reported token did. They now state the pool's token, and I mutation-checked it: holding the reported token steady fails all three, where before they passed identically. Predicate suite 28/28, stale-shell re-render 5/5, same 9 pre-existing environmental failures in that file (they need a dev stack serving the host app).

Adjacent, taken: the package-shim-handler.ts comment above MISSING_EXPORT_MESSAGE does still describe the superseded rule. Left for the indexer PR, where the contract it describes is the thing being changed — flagging so it isn't lost.

Every failure on the previous run was GitHub's artifact service answering
403 to both uploads and downloads — `Failed to FinalizeArtifact` on the jobs
that had finished testing, `Failed to ListArtifacts` on the ones that could
not fetch the test web assets to start. Main was failing the same way at the
same time, and the realm-server suite this branch touches reported `# fail 0`
before its upload was refused.

An empty commit rather than a re-run of the failed jobs: the uploads that
failed never finalized, so the jobs that consume those artifacts would have
nothing to download on a partial re-run.
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