Skip to content

Detect dead sandboxes on failed runners#762

Merged
phinze merged 3 commits into
mainfrom
phinze/mir-1009-coordinator-doesnt-detect-dead-sandboxes-on-failed-runners
Apr 14, 2026
Merged

Detect dead sandboxes on failed runners#762
phinze merged 3 commits into
mainfrom
phinze/mir-1009-coordinator-doesnt-detect-dead-sandboxes-on-failed-runners

Conversation

@phinze

@phinze phinze commented Apr 14, 2026

Copy link
Copy Markdown
Contributor

When a distributed runner dies (process crash, node failure), its session-scoped READY status on the Node entity clears after the ~60s session TTL. The scheduler already uses this to avoid placing new sandboxes on dead nodes, but nothing was watching for existing sandboxes that were already running there. They'd stay in RUNNING status forever, and the coordinator would keep routing traffic to their IPs, causing request timeouts for as long as the node was down.

The fix is a new nodehealth controller on the coordinator that watches Node entities. When a node loses READY, the controller starts a 5-minute grace period. If the node doesn't recover within that window, it lists all sandboxes scheduled to the node and marks them DEAD. The activator's existing watch pipeline picks up the status change and immediately invalidates cached leases, so traffic stops routing to the dead IPs. The pool controller then sees the reduced capacity and spins up replacements on healthy nodes.

The grace period is the interesting design choice here. Containers survive miren process restarts (containerd keeps running), and reconcileSandboxesOnBoot() re-adopts them when the runner comes back. We verified on Garden that all 13 sandboxes survived today's restart with zero downtime. Marking sandboxes DEAD immediately would break this recovery path and cause unnecessary churn on every upgrade or restart. Five minutes is generous for any normal restart (typically 15-30 seconds) while still bounding the impact of a real failure.

We also discovered during investigation that reconcileSandboxesOnBoot() reattaches logs and re-reserves IPs for surviving sandboxes, but doesn't re-register cgroup metrics monitoring. That's tracked separately in MIR-1013.

Validated on Toys with a coordinator + distributed runner topology. Killed the runner process and watched the coordinator detect the node going not-ready after session TTL, tick through the grace period for 5 minutes, then mark 3 sandboxes dead. The pool controller replaced them on surviving nodes within seconds. Also verified the restart path: bounced the runner, it recovered in under a second, grace period cleared, no sandbox disruption.

Closes MIR-1009

When a distributed runner dies, its session-scoped READY status
clears after ~60s, but nothing on the coordinator reacted to that
signal. Sandboxes stayed in RUNNING forever and the coordinator
kept routing traffic to dead IPs.

Adds a nodehealth controller that watches Node entities. When a
node loses READY, it starts a 5-minute grace period, then marks
the node's sandboxes DEAD. The grace period protects the normal
restart path where containers survive on containerd and the runner
re-adopts them via reconcileSandboxesOnBoot().

Closes MIR-1009
@phinze phinze requested a review from a team as a code owner April 14, 2026 20:58
@coderabbitai

coderabbitai Bot commented Apr 14, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c8c616f4-4a9e-43a0-a848-87c38d52328f

📥 Commits

Reviewing files that changed from the base of the PR and between 702f46c and 9402590.

📒 Files selected for processing (1)
  • controllers/nodehealth/controller.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • controllers/nodehealth/controller.go

📝 Walkthrough

Walkthrough

A new controllers/nodehealth package and Controller were added to track nodes that become non-READY by recording first-unhealthy timestamps and a handled flag, with DefaultGracePeriod = 5 * time.Minute. After the grace period the controller patches eligible Sandbox entities on the node to DEAD (skipping DEAD/STOPPED), aggregating patch errors. The Coordinator constructs and initializes the controller and registers it with the manager for compute_v1alpha.KindNode (30s resync, 1 worker). A comprehensive test suite exercising timing, state transitions, recovery, and multi-node scenarios was added.


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

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
controllers/nodehealth/controller.go (1)

62-79: Handled dead nodes will be rescanned forever.

Once a node crosses the grace period, nothing transitions it out of the “expired” path. With the 30s resync in components/coordinate/coordinate.go, a permanently dead node will keep emitting WARNs and re-listing its sandboxes every cycle even after everything is already DEAD/STOPPED. Consider tracking an “already handled” state and clearing it on recovery.

Also applies to: 105-145

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@controllers/nodehealth/controller.go` around lines 62 - 79, The current logic
in controller.go uses trackUnhealthy()/now()/gracePeriod to detect expired nodes
and calls markNodeSandboxesDead(node.ID) but never records that the node has
been handled, so recovered nodes still re-enter the expired path and dead nodes
are reprocessed every resync; add an “alreadyHandled” flag/state keyed by
node.ID inside the controller (e.g., handledNodes map or similar) and modify the
flow in the functions that call trackUnhealthy and markNodeSandboxesDead to: 1)
check handledNodes before calling markNodeSandboxesDead and skip/log once if
already handled, 2) set handledNodes[node.ID]=true immediately after
successfully calling markNodeSandboxesDead, and 3) clear handledNodes[node.ID]
when node.Status indicates recovery (use the same place that resets
trackUnhealthy on healthy status) so the node can be reprocessed on subsequent
failures; apply the same change to the other block that mirrors this logic (the
block around the markNodeSandboxesDead call in the 105–145 region).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@controllers/nodehealth/controller.go`:
- Around line 56-79: The Reconcile loop currently starts the grace timer for any
non-READY state including operator-initiated compute_v1alpha.DISABLED; change
Reconcile so DISABLED nodes do not enter the grace/tracking path: detect
node.Status == compute_v1alpha.DISABLED near the top of Reconcile (before
calling trackUnhealthy), call c.clearTracking(node.ID) and return nil so we
don't start the grace timer or later call c.markNodeSandboxesDead; this keeps
the existing trackUnhealthy/clearTracking/markNodeSandboxesDead logic intact but
skips grace handling for DISABLED nodes.

---

Nitpick comments:
In `@controllers/nodehealth/controller.go`:
- Around line 62-79: The current logic in controller.go uses
trackUnhealthy()/now()/gracePeriod to detect expired nodes and calls
markNodeSandboxesDead(node.ID) but never records that the node has been handled,
so recovered nodes still re-enter the expired path and dead nodes are
reprocessed every resync; add an “alreadyHandled” flag/state keyed by node.ID
inside the controller (e.g., handledNodes map or similar) and modify the flow in
the functions that call trackUnhealthy and markNodeSandboxesDead to: 1) check
handledNodes before calling markNodeSandboxesDead and skip/log once if already
handled, 2) set handledNodes[node.ID]=true immediately after successfully
calling markNodeSandboxesDead, and 3) clear handledNodes[node.ID] when
node.Status indicates recovery (use the same place that resets trackUnhealthy on
healthy status) so the node can be reprocessed on subsequent failures; apply the
same change to the other block that mirrors this logic (the block around the
markNodeSandboxesDead call in the 105–145 region).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4768fce7-411e-4c13-b841-2ddcfb117508

📥 Commits

Reviewing files that changed from the base of the PR and between 568dc60 and 053f61d.

📒 Files selected for processing (3)
  • components/coordinate/coordinate.go
  • controllers/nodehealth/controller.go
  • controllers/nodehealth/controller_test.go

Comment thread controllers/nodehealth/controller.go Outdated
DISABLED is set by Drain() during graceful shutdown, which handles
its own sandbox cleanup. Starting a grace timer on DISABLED nodes
would race with the drain. Skip them.

Also track when a node has already been handled so we don't
re-scan its sandboxes on every 30s resync. The handled flag
clears when the node recovers, is removed, or enters DISABLED.

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@controllers/nodehealth/controller.go`:
- Around line 97-100: markNodeSandboxesDead currently logs per-Patch failures
but returns nil, allowing Reconcile to call setHandled even when some sandboxes
failed to be patched; modify markNodeSandboxesDead to collect any per-patch
errors (e.g., aggregate into a multi-error or wrap the first error) and return a
non-nil error when any patch failed, and ensure Reconcile only calls
setHandled(node.ID) when markNodeSandboxesDead returns nil; apply the same
change to the other similar call site (the second invocation around lines
176-195) so partial failures prevent marking the node as handled.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cfd86600-9ce8-4856-a3f0-ee6e23d4c3d2

📥 Commits

Reviewing files that changed from the base of the PR and between 053f61d and 702f46c.

📒 Files selected for processing (2)
  • controllers/nodehealth/controller.go
  • controllers/nodehealth/controller_test.go

Comment thread controllers/nodehealth/controller.go
If some sandbox patches fail in markNodeSandboxesDead, return the
error so Reconcile doesn't call setHandled. This ensures the
controller retries on the next resync rather than permanently
skipping a node with sandboxes stuck in RUNNING.

@evanphx evanphx 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.

Approving — ship it. 🚢

Evan + Claude took a look at this together. Clean, well-scoped, good test matrix, and the grace-period / handled-latch design choices all make sense given the reconcile-on-boot recovery path you're protecting.

One thing we spotted that's worth a follow-up (not a blocker):

In controllers/nodehealth/controller.go:179-185:

_, err := c.eac.Patch(ctx, entity.New(
    entity.DBId, sb.ID,
    (&compute_v1alpha.Sandbox{
        Status: compute_v1alpha.DEAD,
    }).Encode,
).Attrs(), 0)

This reads like "patch just Status to DEAD", but Sandbox.Encode() in api/compute/compute_v1alpha/schema.gen.go:1103 doesn't quite play along. Most fields are gated behind !Empty / len() > 0 checks, but HostNetwork is emitted unconditionally at line 1107:

attrs = append(attrs, entity.Bool(SandboxHostNetworkId, o.HostNetwork))

So the patch actually sends two attrs: Status=DEAD and HostNetwork=false. Any sandbox on a dead node that was using host networking will silently get HostNetwork flipped to false on its way to DEAD. Probably harmless in practice since the sandbox is terminal, but it's a latent trap for the next person who tries a targeted Encode-based patch.

Two ways to deal with it, whenever you feel like it:

  1. Build the attr list by hand here — just entity.Ref(SandboxStatusId, sandboxstatusToId[DEAD]) — and sidestep Encode entirely.
  2. Or fix the generator to gate Bool fields the same way as the other scalars. That's the real fix and probably affects other kinds too, so it's its own PR.

Also a tiny aside: Patch(..., 0) skips optimistic concurrency, where most other callers thread the version through. Low risk here since the runner's already gone, but figured we'd mention it.

Really nice work on this one — the grace-period reasoning in the PR description is great context to have in the commit trail.

— Evan & Claude

@phinze phinze merged commit 31cee83 into main Apr 14, 2026
12 checks passed
@phinze phinze deleted the phinze/mir-1009-coordinator-doesnt-detect-dead-sandboxes-on-failed-runners branch April 14, 2026 22:30
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