Skip to content

fix(api): remove sandboxes from store when node crashes#3238

Draft
jakubno wants to merge 3 commits into
mainfrom
fix/bug-remove-sandboxes-from-redis-if-the-node-crashes-en-1424
Draft

fix(api): remove sandboxes from store when node crashes#3238
jakubno wants to merge 3 commits into
mainfrom
fix/bug-remove-sandboxes-from-redis-if-the-node-crashes-en-1424

Conversation

@jakubno

@jakubno jakubno commented Jul 9, 2026

Copy link
Copy Markdown
Member

No description provided.

@linear-code

linear-code Bot commented Jul 9, 2026

Copy link
Copy Markdown

EN-1424

@cla-bot cla-bot Bot added the cla-signed label Jul 9, 2026
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Introduces a destructive, flag-gated background loop that removes running sandbox records from Redis based on distributed liveness; incorrect rollout or flag misuse could purge live workloads, though grace periods, discovery freshness checks, and consensus-style Redis signals reduce that risk.

Overview
When an orchestrator node disappears and does not return, running sandboxes can linger in the API store even though nothing can reach them. This change adds a dead-node sweep (gated by dead-node-sweep-enabled, off by default) that periodically finds running sandboxes whose host has been absent long enough, then removes them with kill reason node_gone. Fleet-wide liveness in Redis lets every API replica refresh per-node “last seen” after a successful sync; purges only happen when no replica has seen the node for a grace period, with a shared “first missing” marker so one replica’s bad network view cannot trigger cleanup alone. The sweep also refuses to run if node discovery is stale, skips nodes without liveness evidence, and limits full store scans unless a local node has been unreachable long enough or a periodic backstop tick fires. Removal no longer requires a live node RPC for node_gone: store cleanup succeeds if the node is missing or the kill RPC fails. Supporting work tracks local sync reachability separately from orchestrator-reported unhealthy status, records successful discovery sync time, and adds a batched all running sandboxes scan reused by the expiration-index healer.

Reviewed by Cursor Bugbot for commit 9c94403. Bugbot is set up for automated code reviews on this repo. Configure here.

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request introduces a dead-node sweeper mechanism to automatically purge sandbox records from the store when their host node crashes or is removed from the fleet. It tracks node liveness across replicas using Redis, records local unreachability, and implements a background sweeper that kills sandboxes associated with dead nodes. The review feedback highlights two key improvement opportunities: first, preventing potential indefinite blocking during shutdown when acquiring the concurrency semaphore in the sweep loop by using a context-aware select block; second, increasing the discoveryFreshnessMax threshold from 30 to 60 seconds to tolerate minor network jitter and prevent premature skipping of sweep cycles.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +153 to +162
for _, sbx := range toKill {
sem <- struct{}{}
wg.Add(1)
go func(sbx sandbox.Sandbox) {
defer wg.Done()
defer func() { <-sem }()

s.kill(ctx, sbx)
}(sbx)
}

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.

medium

When acquiring the semaphore inside the loop, the sweeper can block indefinitely if the concurrency limit is reached and the active goroutines are slow. If the application is shutting down and the context is cancelled, this blocking behavior prevents a graceful and timely shutdown.

Additionally, a standard break statement inside a select block in Go only breaks out of the select, not the surrounding for loop. To safely abort the loop and proceed to wait for already running goroutines, we should use a labeled break.

Suggested change
for _, sbx := range toKill {
sem <- struct{}{}
wg.Add(1)
go func(sbx sandbox.Sandbox) {
defer wg.Done()
defer func() { <-sem }()
s.kill(ctx, sbx)
}(sbx)
}
loop:
for _, sbx := range toKill {
select {
case <-ctx.Done():
break loop
case sem <- struct{}{}:
}
wg.Add(1)
go func(sbx sandbox.Sandbox) {
defer wg.Done()
defer func() { <-sem }()
s.kill(ctx, sbx)
}(sbx)
}

Comment on lines +40 to +42
// discoveryFreshnessMax is how stale the last successful node discovery may
// be before the sweep refuses to run
discoveryFreshnessMax = 30 * time.Second

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.

medium

The discoveryFreshnessMax is set to 30 * time.Second, which is very close to the 20-second sync interval (cacheSyncTime). If a single sync cycle is slightly delayed (e.g., due to slow network or Nomad response) or fails once, the elapsed time since the last successful discovery will easily exceed 30 seconds, causing the dead-node sweeper to skip the cycle and log warning messages.

Consider increasing discoveryFreshnessMax to at least 60 * time.Second (or 2 * cacheSyncTime) to tolerate minor network jitter or a single missed sync cycle without immediately disabling the sweeper.

Suggested change
// discoveryFreshnessMax is how stale the last successful node discovery may
// be before the sweep refuses to run
discoveryFreshnessMax = 30 * time.Second
// discoveryFreshnessMax is how stale the last successful node discovery may
// be before the sweep refuses to run
discoveryFreshnessMax = 60 * time.Second

@jakubno
jakubno force-pushed the fix/bug-remove-sandboxes-from-redis-if-the-node-crashes-en-1424 branch from d206cdf to 9c94403 Compare July 9, 2026 12:03

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 9c94403. Configure here.

logger.WithNodeID(sbx.NodeID),
)

return nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Routing catalog not cleared

Medium Severity

When the dead-node sweep removes a sandbox with KillReasonNodeGone and getOrConnectNode returns nil, removeSandboxFromNode returns before routingCatalog.DeleteSandbox. The store entry is still removed, but the Redis routing catalog can keep pointing at the dead node until TTL expires.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9c94403. Configure here.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant