Skip to content

fix(relay): let a host leave a dead reserve cell without an operator fence - #27458

Merged
Jinwoo-H merged 4 commits into
mainfrom
relay-dead-reserve-cell-host-replace
Oct 11, 2026
Merged

Jinwoo-H merged 4 commits into
mainfrom
relay-dead-reserve-cell-host-replace

Conversation

@Jinwoo-H

@Jinwoo-H Jinwoo-H commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 6 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​643 $\color{#cf222e}{\Huge{\mathbf{−}}}$​5 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​638
Prod 9 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​301 $\color{#cf222e}{\Huge{\mathbf{−}}}$​35 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​266

ELI5

A relay server ("cell") can die and not come back. When that happened to a cell in the new "reserve" mode, every desktop that had been on it was told "your cell is unavailable" over and over, and never got moved to a healthy cell until the dead one was restarted. Now, once the director has two independent signs that the cell is really gone, a desktop that asks is moved to a healthy cell.

What Changed

Before. On staging, reserve cell c2 was shut down. For 4.5 minutes, every desktop that had been on it got 503 relay_home_cell_unavailable, about 150 per 30 s per director. Not one moved to c1, which was live and had room. The outage ended only when c2 was started again.

Why it happened.

  • The database assignment path will not move a host off a capped cell (one with a connection hard cap) whose heartbeat has stopped, unless an operator has run the fence workflow. Every production cell is capped.
  • Reserve placement falls back to that same path when no other live reserve cell is in the region. So it inherits the refusal.
  • The earlier "dead reserve cell" guard in the dormant check could not help: it only frees hosts that have been idle for 24 h.

After. A desktop that asks for a new cell on /v1/assign is moved off its home cell, with the epoch bumped by one and its activity leases deleted, when all of these hold:

  • Postgres records the cell as reserve;
  • the cell's heartbeat is stale (> 45 s, cause unheard). A cell that is not ready, or is disabled for a drain, never qualifies, so drain-then-death keeps the fence;
  • this director's own seat-feed polls to the cell have gone unanswered for at least 45 s, continuously (≥ 3 failures, the last within 5 s). "Unanswered" means a timeout, a connection error, or a load-balancer 502/503/504. Any status the cell sends itself (2xx, 401, 404, 500) counts as alive;
  • the failure is not too wide and the sample is not too small. The verdict is withheld when any of these holds:
    • fewer than 6 feed cells are polled, or the dead cell is not among them. A feed cell has served this director a seat-feed page and has capacity; legacy rows that answer 404 count for nothing;
    • more than a third of all feed cells are failing (points at the director's own network or credential). For both of the last two guards, any current failure streak counts, so a staggered partition can't slip its first cell through;
    • half or more of the dead cell's own region is failing. Every director runs in us-central1, so a us-central1 ↔ asia-east2 partition would make all 6 Asia cells look dead to every director at once while they keep serving. One zone loss (2 of 6) still counts as dead;
  • the host has no post-drain migration pin;
  • the request came through the step-5 plan, whose epoch floor covers every seat, leaver and booking the director's map knows for the host. The move mints above all of them. Without the plan (placement off), the fence is kept.

Each move logs orca_relay_dead_reserve_cell_host_replaced, with the fleet and region counts. Every withheld verdict logs orca_relay_dead_cell_verdict_withheld (once a minute per cell). A new log metric and alert, relay_dead_cell_verdict_withheld, notifies on it. It is gated behind relay_dead_cell_verdict_alert: default false, so the production plan is unchanged; true in staging. The first apply creates the metric before the policy.

Unchanged:

  • background sweeps (evacuateDeadCells);
  • every caller other than the host's own assign;
  • migration pins;
  • capped db-mode cells, which still need the fence (see Why).

Why

Signals. Heartbeats (cell → director → Postgres) and seat-feed polls (director → load balancer → cell) take different paths. Requiring both rules out every case where a live cell must keep its hosts:

  • LB drain: the heartbeat is still fresh;
  • database outage: polls still answer;
  • frozen director: it has no recent failed poll.

Who moves. Only a host that asks is moved, and it asks because its own connection is gone. Connected hosts are never touched, and nothing double-seats. Reserve placement already moves an asking host off an unreachable reserve cell when another reserve cell exists. This brings the database path in line with that.

Alternatives considered:

  • Drop the capped-cell fence rule. Rejected: it lets sweeps evacuate connected hosts on heartbeat loss alone.
  • A cell self-fence. Rejected for now: it needs a cell image change, and a database outage would then disconnect the fleet.
  • Directors writing db mode for the dead cell. Rejected: step 5 rules out directors writing cell mode, and it still meets the fence.

Why db-mode cells are left out. A dead Asia db cell auto-moving ~3k hosts at once through the database path would recreate the source-row contention brownout seen in full-cell drains. Autoheal brings such a cell back in 3–5 min with its hosts intact. They are a deliberate follow-up.

Follow-ups, not in this PR:

  • capped db cells, which need paced re-placement (the herd onto any one target is bounded by its 45 pre-auth slots);
  • having the desktop name the cell and epoch it lost on /v1/assign.

Director only.

  • Cells never serve /v1/assign, and a cell's store has no poller, so cell behaviour is unchanged. No cell image roll is needed.
  • Production prerequisite: the serving production director has no ORCA_RELAY_SHADOW_SEAT_FEED_CELLS. Without the poller this change does nothing there. Set it to all, and turn on relay_dead_cell_verdict_alert, before the first production cell is flipped to reserve.

Linked Issue

N/A (internal relay step-5 staging finding).

Visual Proof

N/A: server-side relay placement, with no UI.

Testing

  • Automated tests added/updated
    • cell-poll-reachability.test.ts:
      • the 45 s continuous streak;
      • a frozen director (a gap restarts the streak);
      • any answer resets the streak;
      • the minimum of 6 polled cells;
      • the one-third fleet guard;
      • the region guard on production's 28 US + 6 Asia shape: all of Asia withheld, half of Asia withheld, a 2-of-6 zone loss decided;
      • a staggered six-cell Asia partition (0–2 s offsets): never judged dead;
      • the withheld line, logged once a minute, sample included;
      • through the real poller: legacy 404 rows neither fill nor dilute the sample, and a cell that never served a feed is withheld;
      • status classification through the real poller: throw/502/503/504 unanswered; 401/404/500 answered; identity-token failure not blamed on the cell.
    • assignment-dead-reserve-cell-postgres.test.ts (real PostgreSQL):
      • §8a, rows plus leases from dead-man re-registration: moved, epoch+1, leases gone, units released;
      • a hard crash with a ledger-only row: moved;
      • LB drain (fresh heartbeat, polls failing): pinned;
      • database outage or director freeze (no poll verdict): 503;
      • a sweep, or a non-host caller: 503, and evacuateDeadCells moves 0;
      • a capped db-mode cell: 503;
      • a migration pin: 503;
      • a withheld verdict: 503;
      • ready=0 or enabled=0 with polls failing ≥ 45 s: 503, no move (the mutation that drops the unheard check fails both);
      • no step-5 epoch floor: 503;
      • the old cell revives with a seat at 6 while the row was at 4: the move mints 7, and ledger writes at 6 and 7 for the old cell never take the row back.
    • Mutation checks:
      • forcing the new branch off fails the two move tests;
      • dropping the pin condition fails the pin test.
  • Full relay suite with ORCA_RELAY_TEST_POSTGRES_URL on a fresh, dedicated Postgres 16 container: 152 files, 1,326 passed, 1 skipped. Cloud node --test scripts, including the alert contract and the Terraform root partition: 1,008 passed. tsc --noEmit is clean, and terraform fmt -check is clean.
  • Manual staging rerun of the step-5 §8a drill. Planned after merge to staging. Pass:
    • ≥ 99% of c2's hosts are on c1 within about 105 s of the VM dying;
    • no moves during the LB drain;
    • Postgres lock waits stay at baseline during the move wave.

Review

Agent skill upstream boundary

  • Not applicable, or this change copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

Not affected: SSH, mobile, desktop clients, or the wire format. Directors and cells update independently, and no new field crosses between them.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

…fence

A host whose reserve home cell died got 503 relay_home_cell_unavailable until the
cell came back: the database path demands a committed fence for every capped cell.
On its own /v1/assign, a host now moves off a reserve cell (Postgres records it as
reserve) when the cell's heartbeat is stale and this director's seat-feed polls have
gone unanswered for 45 s, unless more than a third of the polled cells look the same.
Sweeps, migration pins and capped db cells keep the fence.
@coderabbitai

coderabbitai Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 48b893dd-a1a5-4cd8-9892-e7a339caa025

📥 Commits

Reviewing files that changed from the base of the PR and between fbd79e1 and e7ef38c.


📒 Files selected for processing (4)
  • cloud/apps/relay/src/cell-poll-reachability.test.ts
  • cloud/apps/relay/src/shadow-seat-directory.ts
  • cloud/infra/terraform/relay-observability.tf
  • cloud/infra/terraform/variables.tf

🚧 Files skipped from review as they are similar to previous changes (2)
  • cloud/infra/terraform/variables.tf
  • cloud/infra/terraform/relay-observability.tf

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 5 remain after this review.



📝 Walkthrough

Walkthrough

The relay poller tracks cell reachability from poll responses and exposes dead-cell verdicts to the assignment store. Host-requested assignments pass a consistent options object. The store can replace an unheard dead reserve cell when it is not migration-pinned and the verdict reports the cell as dead. Added PostgreSQL tests cover replacement and conditions that prevent movement. The changes also add a metric and alert for withheld verdicts.


Priority: ➖ Normal

Merge Risk: 🟡 Moderate · up to e7ef3

A host might be moved off a cell that has since been switched to db mode, if a director holds a stale reserve-mode cache. This earlier concern remains open and should be confirmed or resolved before merge.

Pre-merge checks | Passed 4 | Failed 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 6 functions across 11 files. (2 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check Passed The title clearly and concisely describes the primary change: allowing a host to leave a dead reserve cell without an operator fence.
Description check Passed The description is complete and well aligned with the template. It explains the user impact, mechanism, safeguards, alternatives, testing, operational prerequisites, and unchanged behavior. It documen…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.

Full details: Docstring Coverage

Explanation

Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 6 functions across 11 files. (2 skipped: 2 unsupported.)


  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR



  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 99a5df00-892b-4b67-bc6a-340553ff0b7c
📥 Commits

Reviewing files that changed from the base of the PR and between 4468a53 and d193099.

📒 Files selected for processing (10)
  • cloud/apps/relay/src/app.ts
  • cloud/apps/relay/src/assignment-dead-reserve-cell-postgres.test.ts
  • cloud/apps/relay/src/assignment-isolated-cell-replacement.test.ts
  • cloud/apps/relay/src/assignment-store.ts
  • cloud/apps/relay/src/cell-poll-reachability.test.ts
  • cloud/apps/relay/src/cell-poll-reachability.ts
  • cloud/apps/relay/src/public-assignment-reconnect-lane.test.ts
  • cloud/apps/relay/src/relay-region-app.test.ts
  • cloud/apps/relay/src/relay-server.ts
  • cloud/apps/relay/src/shadow-seat-directory.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.

cells: () => cells,
startedAt,
tick: () => round(true),
pollsDead: (cellId, at) => reachability.dead(cellId, cells.length, at),

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '1540,1605p' cloud/apps/relay/src/assignment-store.ts
sed -n '650,685p' cloud/apps/relay/src/shadow-seat-directory.ts
sed -n '744,792p' cloud/apps/relay/src/shadow-seat-directory.ts

Repository: stablyai/orca

Length of output: 6362


Use the same cell snapshot for polling and pollsDead.

Each later round starts refreshCells() and then polls the previous cells array. The refresh can replace cells before pollsDead runs, so cells.length can include cells that this round has not polled. For example, two unreachable cells from a three-cell snapshot pass unreachable * 3 <= 6 after a refresh adds three cells, even though two-thirds of the polled cells are unreachable.

The assignment path uses this result to allow a host-requested move when the current cell is reserve, unheard, unpinned, and cellPollsDead returns true. Capture the array used by polls and use that snapshot's length in pollsDead.

Suggested fix
   let cellListRetryAt = 0
   let lastSummaryAt = now()
   let stopped = false
+  let lastPolledCells = cells

   const identity = reusedIdentityToken(() => tokenProvider(audience), now, IDENTITY_TOKEN_REUSE_MS)
@@
-    const polls = cells.map((cell) => poll(cell))
+    const polledCells = cells
+    lastPolledCells = polledCells
+    const polls = polledCells.map((cell) => poll(cell))
@@
-    pollsDead: (cellId, at) => reachability.dead(cellId, cells.length, at),
+    pollsDead: (cellId, at) => reachability.dead(cellId, lastPolledCells.length, at),

…d floor the move on the map

- No verdict when half or more of the dead cell's own region fails with it (a cross-region
  partition), more than a third of the fleet does, or fewer than 6 cells are polled. A withheld
  region or fleet verdict logs orca_relay_dead_cell_verdict_withheld, which alerts.
- The move needs the step-5 plan's epoch floor, so it mints above every seat the map knows;
  without the plan the fence is kept. A test revives the old cell's seat above the row and checks
  that the ledger never writes the row back.
- The move's log line carries the fleet and region counts.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: d648182b-9514-4e1f-9a92-700bff641645
📥 Commits

Reviewing files that changed from the base of the PR and between d193099 and 632678f.

📒 Files selected for processing (9)
  • cloud/apps/relay/src/assignment-dead-reserve-cell-postgres.test.ts
  • cloud/apps/relay/src/assignment-store.ts
  • cloud/apps/relay/src/cell-poll-reachability.test.ts
  • cloud/apps/relay/src/cell-poll-reachability.ts
  • cloud/apps/relay/src/relay-server.ts
  • cloud/apps/relay/src/shadow-seat-directory.ts
  • cloud/dev/fixtures/terraform-root-partition/families.json
  • cloud/dev/scripts/relay-reserve-dead-man-alert.test.mjs
  • cloud/infra/terraform/relay-observability.tf

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 4 remain after this review.

…guards; gate the alert

- Denominators hold only cells that served this director a feed page and have capacity: legacy
  rows answering 404 no longer fill the 6-cell sample or dilute the fleet and region ratios.
- A peer counts as failing for the guards on any current streak (a failure in the last 5 s), so a
  staggered partition cannot let its first cell cross 45 s while its neighbours read healthy.
- Every withheld verdict is logged, sample included (a cell missing from the feed cells is one).
- Tests: ready=0 and enabled=0 with failing polls keep the fence (drain then death is fenced).
- The withheld metric and alert are gated by relay_dead_cell_verdict_alert (default false, on in
  staging); the alert docs now describe poll failures, not heartbeats.

@coderabbitai coderabbitai Bot 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.

Caution

Some comments are outside the diff and can’t be posted inline due to GitHub limitations.

⚠️ Outside diff range comments (1)

🟠 Major · Read and lock the current admit mode before granting the dead-reserve… · assignment-store.ts:1568

cloud/apps/relay/src/assignment-store.ts:1568
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Read and lock the current admit mode before granting the dead-reserve exception.

When assignOnce passes transaction, reserveModeCells(transaction) returns the cached read without querying the database. That cached read remains usable for up to RESERVE_MODE_STALE_MS (60 seconds), although the normal refresh interval is 5 seconds.

If another director changes the cell to db, the stale set can still contain the cell. deadCellFenceRequirement reports capped when a connection-limit row exists, but the exception is inside the fence.capped branch and only excludes fence.pinned. A dead verdict can therefore bypass the fence for a capped db-mode cell.

Read and lock the cell's relay_cell_admit_modes row in this transaction, and use that current result instead of the cached set for this exception.


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 3a31a193-66c7-4b88-8640-544061798a57
📥 Commits

Reviewing files that changed from the base of the PR and between 632678f and fbd79e1.

📒 Files selected for processing (9)
  • cloud/apps/relay/src/assignment-dead-reserve-cell-postgres.test.ts
  • cloud/apps/relay/src/assignment-store.ts
  • cloud/apps/relay/src/cell-poll-reachability.test.ts
  • cloud/apps/relay/src/cell-poll-reachability.ts
  • cloud/apps/relay/src/shadow-seat-directory.ts
  • cloud/dev/scripts/relay-reserve-dead-man-alert.test.mjs
  • cloud/infra/terraform/environments/staging.tfvars
  • cloud/infra/terraform/relay-observability.tf
  • cloud/infra/terraform/variables.tf

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 3 remain after this review.

…ector can still judge it

A director that restarted after a reserve cell died never received a feed page from it, so the
cell was never in the sample and its verdict stayed withheld as 'sample'. The candidate now always
counts when it has capacity (the store asks only about a cell Postgres records as reserve, and only
feed images run reserve); peers still count only once they have served a feed. Tests: a restart
during a lone outage decides, a partition begun before the restart withholds by region, and the
capacity filter. The Terraform comments now say to apply the metric first and the policy minutes later.
Jinwoo-H added a commit that referenced this pull request Oct 11, 2026
…cell guard e2e (#27476)

Six feed cells, five in us-central1, so one dead US reserve cell is 1/6 of the fleet and 1/5 of its
region and the dead-cell guards in #27458 can decide. Staging only; nothing is applied by merging.
@Jinwoo-H
Jinwoo-H merged commit 735e6d6 into main Oct 11, 2026
22 checks passed
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