Skip to content

Give host shells an ordering, assigned on first observation - #6023

Open
backspace wants to merge 5 commits into
mainfrom
cs-12763-stamp-a-monotonic-deploy-generation-on-index-rows-and-repair
Open

Give host shells an ordering, assigned on first observation#6023
backspace wants to merge 5 commits into
mainfrom
cs-12763-stamp-a-monotonic-deploy-generation-on-index-rows-and-repair

Conversation

@backspace

@backspace backspace commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

This is another step in the production hosted site downtime mitigation.

Claude: A realm server identifies the host bundle it serves by hashing the index HTML it fetches over HTTP, which is all it can observe about a separately deployed artifact. A hash answers "is this the same shell?" and never "is this shell older?" — and the second question is the one that repairing deploy-skewed rows depends on, so today there is no query for "the rows this deploy left behind".

host_shell_generation supplies the ordering: one row, whose generation advances each time a realm server observes a shell hash different from the one recorded. Servers observing the same shell read back the same number.

Why the number is assigned here

The host has nowhere to carry one. It is static files published to S3 by ember deploy, with no environment to inject and no task definition to stamp — which is exactly why the existing token is a hash of what the realm server fetches.

Advancing on a transition rather than per distinct hash is what makes a rollback behave. Redeploying a bundle that ran before takes a new, higher generation, because a row's generation records when it was rendered rather than which artifact is semantically newer — and a row from the bundle being rolled away from has to remain findable, which it would not be if the rollback reused that bundle's earlier number.

Why the claim is one statement

A rolling deploy overlaps a task booting against the outgoing bundle with its neighbour on the new one, so two different shells get claimed at once. A read-then-write collapses them: both readers see the same starting generation, both compute the same successor, and two distinct shells end up sharing one number — which destroys the ordering, because rows from either then carry the same generation and nothing can tell them apart.

One UPDATE cannot collapse that way. WHERE id = 1 is unconditional, so the statement always takes the row lock and always returns the row it observed; the second writer blocks, and once the first commits, READ COMMITTED re-evaluates against the committed row, where a CASE decides whether this claim is a transition to count or the shell already recorded.

What changed under review

Worth calling out, because the original version of this description overstated the property above. The first implementation was two statements: the UPDATE, plus a separate read for the idempotent case. Since each execute is its own autocommit statement, that released the row lock in between — so a different-shell claim landing in the gap made claimHostShellGeneration(db, A) return B's hash and B's generation. A caller stamping its own render with that number would have attributed it to a bundle it never ran on, defeating the ordering during exactly the concurrency this exists to survive.

Caught by Codex as a P1 and fixed in 8b2637da85. There is no second query left to race.

Commits

  • 8b2637da85 — the claim answers from the statement that holds the lock (the P1 fix above).
  • c659805772 — regenerate the host's SQLite schema. Adding a migration requires it: packages/host/config/environment.js refuses to build unless the newest migration's timestamp matches the schema filename. host_shell_generation joins the tables the dump excludes, alongside jobs, queues and session_rooms — realm-server operational state the browser never reads. That also avoids a trap: the dump is schema-only, so an included table would reach the browser without the row the migration seeds, and a claim against it would find nothing to update and silently never advance.
  • 8b2637da85's predecessor — the table, the claim, and the tests.

The other two commits are merges from main.

Tests

Eight cases cover the ordering itself; two more were added with the P1 fix.

The concurrency case is asserted against a real lock wait rather than a hopeful Promise.all — the commit waits until pg_stat_activity reports a backend blocked on the row, so the overlap exists rather than being hoped for. Substituting a read-then-write fails that test and only that test.

A first draft raced eight same-shell claims and passed against both implementations, since claimants of one shell compute the same successor either way; it was replaced because it discriminated nothing. The P1 fix's own test is mutation-checked the same way: restoring the two-statement shape fails it with actual: bbbbbbbb, expected: aaaaaaaa.

All 10 pass in CI (Realm Server Tests shard 4).

Scope

Nothing reads the generation yet. Threading it onto index rows, and the repair query it exists to serve, follow separately on CS-12763.

Note on CI: percy/-cardstack-host reports "1 visual change needs review". This PR touches a migration, a shell script, a schema-file rename, one runtime-common module and tests — no UI code — and #6047 carried the identical Percy status when it merged. It needs approving in Percy rather than fixing here.

A realm server identifies the host bundle it serves by hashing the index HTML
it fetches over HTTP, which is all it can observe about a separately deployed
artifact. A hash answers "is this the same shell?" and never "is this shell
older?" — and the second question is the one that repairing deploy-skewed rows
depends on, so today there is no query for "the rows this deploy left behind".

`host_shell_generation` supplies the ordering: one row, whose generation
advances each time a realm server observes a shell hash different from the one
recorded. Servers observing the same shell read back the same number.

The number is assigned here rather than by the deploy pipeline because the host
has nowhere to carry one. It is static files published to S3 by `ember deploy`,
with no environment to inject and no task definition to stamp, which is exactly
why the existing token is a hash of what the realm server fetches.

Advancing on a transition rather than per distinct hash is what makes a
rollback behave. Redeploying a bundle that ran before takes a new, higher
generation, because a row's generation records when it was rendered rather than
which artifact is semantically newer — and a row from the bundle being rolled
away from has to remain findable, which it would not be if the rollback reused
that bundle's earlier number.

The claim is a single UPDATE for a reason a read-then-write cannot satisfy. A
rolling deploy overlaps a task booting against the outgoing bundle with its
neighbour on the new one, so two *different* shells are claimed at once. Two
readers see the same starting generation, compute the same successor, and two
distinct shells end up sharing one number — which destroys the ordering,
because rows from either then carry the same generation and nothing can tell
them apart. One UPDATE cannot collapse that way: the second writer blocks on
the row, and READ COMMITTED re-evaluates its predicate against the committed
transition, so it counts its own on top.

That is asserted against a real lock wait rather than a hopeful `Promise.all` —
the commit waits until `pg_stat_activity` reports a backend blocked on the row,
so the overlap exists rather than being hoped for. Substituting a
read-then-write fails that test and only that test; a first draft that raced
eight same-shell claims passed against both implementations, since claimants of
one shell compute the same successor either way.

Nothing reads the generation yet. Threading it onto index rows, and the repair
query it exists to serve, follow separately.
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

    1 files  ±  0      1 suites  ±0   2h 30m 25s ⏱️ + 33m 16s
4 673 tests +325  4 659 ✅ +325  14 💤 ±0  0 ❌ ±0 
4 688 runs  +326  4 674 ✅ +326  14 💤 ±0  0 ❌ ±0 

Results for commit 8b2637d. ± Comparison against earlier commit c0cebe9.

Realm Server Test Results

    1 files  ±0    204 suites  ±0   1h 12m 26s ⏱️ +23s
2 663 tests +2  2 663 ✅ +2  0 💤 ±0  0 ❌ ±0 
2 702 runs  +2  2 702 ✅ +2  0 💤 ±0  0 ❌ ±0 

Results for commit 8b2637d. ± Comparison against earlier commit c0cebe9.

The host derives its in-browser SQLite schema from a dump of the migrated
Postgres database, and refuses to build when the newest migration's timestamp
does not match the schema file's. Adding a migration therefore requires
regenerating that file, which is what three jobs were failing on — the two
Matrix report merges among them, cascading from an asset build that never
produced reports.

`host_shell_generation` joins the tables the dump excludes. The exclusions are
realm-server operational state the browser never reads — jobs, queues, users,
session rooms — and which host bundle a server is serving belongs with them,
not with the index tables the host queries.

Excluding it also avoids a trap. The dump is schema-only, so an included table
would arrive in the browser without the row the migration seeds, and a claim
against it would find nothing to update and silently never advance. Absent, the
table cannot be reached by mistake.

The regenerated file is byte-identical to the one it replaces; only the
timestamp moved, which is all the check reads.
One conflict, in `tests/index.ts`. Main replaced the hand-maintained
`ALL_TEST_FILES` array with a walk over every `*-test.ts` under the directory —
the same walk that assigns files to CI shards, so a file the sharder assigns is
by construction a file the runner parses.

Taking main's side wholesale is the whole resolution: the list entry this branch
added for `host-shell-generation-test` was the only thing it contributed to that
file, and discovery now supplies it. Confirmed rather than assumed —
`discoverTestFiles()` returns 198 files and both of this branch's new suites are
among them.
…tonic-deploy-generation-on-index-rows-and-repair
@backspace
backspace marked this pull request as ready for review September 9, 2026 15:57
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 9, 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-09T16:01:34.671943Z c0cebe9 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: c0cebe9f35

ℹ️ 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/runtime-common/host-shell-generation.ts Outdated
The idempotent path answered from a second query, and that reopened the gap the
first statement exists to close. A same-shell claim's `UPDATE` matched no rows,
committed, released the row lock, and only then read the row back — so a
different-shell claim landing in between made this return *that* shell's hash
and generation. A caller stamping its own render with the number would have
attributed it to a bundle it never ran on, defeating the ordering during
exactly the rolling-deploy concurrency this helper is for.

One statement now, with `WHERE id = 1` unconditional so it always takes the lock
and always returns the row it observed, and a `CASE` deciding from the committed
value whether the claim is a transition to count or the shell already recorded.
A same-shell claim writes its own values back — a no-op — and reads the
generation out of the same locked statement.

Two tests. One states the contract directly: the answer describes the shell that
was asked about. The other drives the gap rather than hoping to meet it — a
querier that lets a competing shell claim the row the moment this claim's
statement finishes, which is the interleaving a read-back would have been
exposed to. Restoring the two-statement shape fails it with `bbbbbbbb` where
`aaaaaaaa` was claimed.
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.

1 participant