Skip to content

api: record why a sandbox stopped when its pod or VM died - #1520

Closed
ac-pplx wants to merge 4 commits into
ac/sandbox-stop-cause-columnsfrom
ac/sandbox-stop-cause-api
Closed

ac-pplx wants to merge 4 commits into
ac/sandbox-stop-cause-columnsfrom
ac/sandbox-stop-cause-api

Conversation

@ac-pplx

@ac-pplx ac-pplx commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A sandbox whose Kubernetes pod is force-deleted, or whose VM exits on a Direct
node, ends up stopped exactly like an operator stop. The admin sandbox
response and list now carry stop_cause: {reason, stopped_at}: reason is
pod_terminated or vm_exited, and stopped_at is the node's time of the
stop. It is null for a requested stop and for any rendered lifecycle other
than stopped.

The rule: the node attributes every stop it newly owns (a cause, or none for a
requested stop) with its own time of that stop, and publishes the attribution
with a stopped status. The publish ingest applies it in a statement of its
own, after the lifecycle convergence, to a row on that node that isn't
running, when it is newer than the stored one. Reaching started or paused
clears it.

  • Producers.
    • Kubernetes: the shim's legacy pod teardown, for a first-generation
      pod without the durable-suspend annotation, sends its Stop with
      POD_TERMINATED. A -g2 or later pod takes the teardown-suspend path,
      which records no cause.
    • Direct, and Kubernetes as a backstop: sbx-manager's runtime-exit
      recovery admits with VM_EXITED.
  • Ownership. A Stop that re-admits a row another stop owns keeps that
    stop's attribution.
  • A cause also clears when a requested stop newly owns the row.
  • Unchanged: failure_reason, the public /v1 sandbox, and the node
    assignment.

Limitation. After the shim's Stop, the pod's Delete deletes the node's
row and its copy of the sandbox. If it lands before node-ctl publishes
stopped, the stop is never seen, as can happen when the stop's cleanup fails.
It is the same teardown that leaves a force-deleted sandbox unable to start
again, which is tracked separately.

Validation

  • Store tests, each failing before its fix: an API-stamped stopped
    before the node publishes; a requested stop interrupting a start after a
    crash; ordering, republication and older-node reports (asserting that
    nothing is written); a running row; another node; an evacuation; the
    cached response.

  • Checks. fmt, clippy -D warnings, nextest on the touched packages,
    the shim checks, and cargo arch (no required findings).

  • Kind. A force-deleted pod gives pod_terminated, a VM kill gives
    vm_exited, and an operator stop gives null. Race 1 was staged by pausing
    node-ctl; race 2 couldn't be staged, and its store test covers it.

    Direct

Details and red runs: validation.md.

Severity and risk

Severity: moderate: a crash looked like a requested stop.

Risk: one more UPDATE per publication that carries attributions. It writes a
row only when the attribution is newer, so a republished one writes nothing.
The cause is written in the stop admission's transaction, and a failed write
is only logged. Unknown labels read as no cause. Attributions are ordered by
node wall clocks, assumed NTP-synced. A crash, an evacuation to a node whose
clock is behind, and a start interrupted there before started, all within
the skew, can keep the old crash shown, as can a backward clock step. A start
that reaches started clears it.

Rollout

Stacked on #1545, the columns, which must apply first.

All wire additions are optional: StopRequest.stop_cause,
SandboxInfo.stop_attribution, PublishedSandboxState.stop_attribution,
SandboxStopAttribution and SandboxStopCause. None of them has shipped, so
reusing the field numbers is safe. The SQLite migration applies when
sbx-manager starts.

What an old peer does:

  • API: shows no field.
  • internal-api: writes nothing.
  • Node: publishes nothing, which leaves the stored attribution alone.
  • Shim: its pod-teardown Stop newly owns the started row as a requested
    stop, so VM-exit recovery never runs: a force-deleted pod reads as a plain
    stop, as on main.

Rollback:

  1. API, node-ctl and shim roll back freely.
  2. sbx-manager rolls back only with a build that keeps
    db/sandbox-manager/20261105162353_sandbox_stop_cause.up.sql: a binary
    without it refuses a node database that recorded it.
  3. db: add the sandbox stop cause columns #1545's columns stay. Its down migration waits until neither this API nor
    this internal-api is deployed: internal-api writes them inside the
    node-publish transaction.

Overlap

The second to land adapts. No field numbers collide.

Stack

#1545 → this → #1521.

AI assistance: Claude Code drafted the code, tests and this description; a
human reviews them before this pull request opens.

@coderabbitai

coderabbitai Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Explain Change →

Caution

Review failed

The pull request is closed.

No actionable comments were generated in the recent review.

Walkthrough

The change adds pod_terminated and vm_exited stop causes and stores each attribution with a timestamp. Stop admission records attribution when it newly owns the stop. Nodes publish attribution for stopped sandboxes, and the control plane applies eligible reports with newer timestamps. Sandbox API responses include the cause and timestamp only when the rendered lifecycle is stopped and the stored values are usable. Attribution clears when a sandbox enters started or paused.

Merge Risk: Moderate · up to 51ff6

Stop reasons and timestamps now depend on new writes in both sandbox stores. Validate those persistence paths before merging.

Security architecture risk: Low · up to 51ff6

The change adds durable stop diagnostics without an established increase in access or privilege. Ownership checks limit the new writes, and diagnostic failures do not block stop admission. Time-based ordering and incomplete deployment coverage leave limited residual risk.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — A publisher able to act as a node can supply diagnostic attribution for eligible sandboxes actively assigned to that node, potentially spanning tenants sharing it. The new SQL does not extend that write to sandboxes assigned elsewhere or change their assignment.

Trust Boundaries and Controls

  • observed — The publication path resolves the supplied node identifier through registered node data and rejects a transport source IP that differs from the registered address. This pre-existing address-based identity control feeds the node ID used by the new active-node SQL predicate; the inspected path does not establish cryptographic workload identity binding.

Resilience and Maintainability Implications

  • observed — The node-local attribution write shares stop admission's transaction, but its failure is logged rather than returned as an admission failure. Repeated stops and failed-cleanup retries preserve existing attribution ownership, keeping diagnostic handling separate from termination guarantees.
Recent review info

Run configuration

Commits

Reviewing files that changed from the base of the PR and between d5c1e37 and 51ff605.

Files ignored due to path filters: 4

  • crates/l40_api/node/ctl/server/src/facets/snapshots/pplx_space_node_ctl_server__facets__tests__quarantined_published_sandbox_state_wire_shape.snap is excluded by !**/*.snap
  • crates/l40_api/node/ctl/server/src/facets/snapshots/pplx_space_node_ctl_server__facets__tests__recovered_suspended_published_sandbox_state_wire_shape.snap is excluded by !**/*.snap
  • crates/l40_api/sbx/mgr/server/src/service/grpc_state_snapshot/snapshots/pplx_space_sbx_mgr__service__grpc_state_snapshot__tests__retained_route_identity_provenance_wire_shape.snap is excluded by !**/*.snap
  • shim/proto/sandbox/manager/v1/manager.pb.go is excluded by !**/*.pb.go, !**/*.pb.go
Files selected for processing: 48

  • crates/l30_storage/sandbox/store/src/ingest.rs
  • crates/l30_storage/sandbox/store/src/lib.rs
  • crates/l30_storage/sandbox/store/src/tests.rs
  • crates/l30_storage/sandbox/store/src/tests/stop_cause_tests.rs
  • crates/l30_storage/sbx/mgr/store/src/db.rs
  • crates/l30_storage/sbx/mgr/store/src/db/pool.rs
  • crates/l30_storage/sbx/mgr/store/src/db/sandbox_row.rs
  • crates/l30_storage/sbx/mgr/store/src/db/sandbox_row/tests.rs
  • crates/l30_storage/sbx/mgr/store/src/db/sandboxes.rs
  • crates/l30_storage/sbx/mgr/store/src/db/stop_cause.rs
  • crates/l30_storage/sbx/mgr/store/src/db/tests.rs
  • crates/l30_storage/sbx/mgr/store/src/db/tests/stop_cause.rs
  • crates/l40_api/api/src/controllers/sandbox/lifecycle.rs
  • crates/l40_api/api/src/controllers/sandbox/responses.rs
  • crates/l40_api/api/src/controllers/sandbox/tests.rs
  • crates/l40_api/api/src/controllers/substrate/direct.rs
  • crates/l40_api/api/src/controllers/substrate/kubernetes/adapter/teardown.rs
  • crates/l40_api/api/src/controllers/substrate/kubernetes/tests.rs
  • crates/l40_api/api/src/models.rs
  • crates/l40_api/api/src/routes/e2b/tests.rs
  • crates/l40_api/build/mgr/engine/src/lib.rs
  • crates/l40_api/cluster/controller/server/src/node_drain/store.rs
  • crates/l40_api/internal/api/server/src/published_state.rs
  • crates/l40_api/internal/api/server/src/published_state/input.rs
  • crates/l40_api/internal/api/server/src/published_state_tests/lifecycle_writers.rs
  • crates/l40_api/node/ctl/server/src/facets/local_state.rs
  • crates/l40_api/sbx/mgr/server/src/service/cleanup.rs
  • crates/l40_api/sbx/mgr/server/src/service/cleanup/stop_admission_tests.rs
  • crates/l40_api/sbx/mgr/server/src/service/cleanup_targets/tests.rs
  • crates/l40_api/sbx/mgr/server/src/service/grpc_core.rs
  • crates/l40_api/sbx/mgr/server/src/service/grpc_core/tests.rs
  • crates/l40_api/sbx/mgr/server/src/service/grpc_core/unpersisted_cleanup_tests.rs
  • crates/l40_api/sbx/mgr/server/src/service/grpc_state_snapshot.rs
  • crates/l40_api/sbx/mgr/server/src/service/reconcile.rs
  • crates/l40_api/sbx/mgr/server/src/service/tests.rs
  • crates/l40_api/sbx/mgr/server/src/service/tests/cancelled_cleanup_rpc_tests.rs
  • crates/l40_api/template/mgr/server/src/pool_manager.rs
  • crates/l40_api/template/mgr/server/src/service/pools.rs
  • crates/l40_api/template/mgr/server/src/service/snapshots.rs
  • crates/l50_app/sbx/mgr/cli/src/dispatch/core/lifecycle_selection.rs
  • crates/test_support/node/api/sim/src/worker.rs
  • crates/test_support/node/api/sim/src/worker/tests.rs
  • db/sandbox-manager/20261105162353_sandbox_stop_cause.down.sql
  • db/sandbox-manager/20261105162353_sandbox_stop_cause.up.sql
  • proto/node/api/v1/cluster.proto
  • proto/sandbox/manager/v1/manager.proto
  • shim/space.go
  • shim/space_test.go

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

@github-actions github-actions Bot added the triage:pending Waiting for a maintainer to triage label Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

Thanks for opening this pull request! A SPACE maintainer will take a look.

CONTRIBUTING.md explains how we review outside contributions.

@ac-pplx
ac-pplx added this pull request to stack #1527 November 5, 2026 18:44
@ac-pplx
ac-pplx removed this pull request from stack #1527 November 5, 2026 20:09
@ac-pplx
ac-pplx changed the base branch from main to ac/sandbox-stop-cause-columns November 5, 2026 20:09
@ac-pplx
ac-pplx force-pushed the ac/sandbox-stop-cause-api branch from e645258 to 5a5a8cd Compare November 5, 2026 20:10
@ac-pplx
ac-pplx added this pull request to stack #1548 November 5, 2026 20:10
A sandbox whose pod was force-deleted, or whose VM exited on its own,
ended up stopped with nothing telling it apart from a requested stop.
Only the node knows the difference, so it now says: the shim's legacy
pod teardown sends its Stop with a POD_TERMINATED cause, and
sbx-manager's runtime-exit recovery admits its stop with VM_EXITED. A
stop that newly owns the row records its attribution in the admission's
transaction; one that re-admits a row another stop owns keeps that
stop's. The node publishes the cause with the stopped status, the
stopped convergence stores it in the columns the previous change added,
and on both databases it lasts until the sandbox reaches started or
paused again. The admin sandbox response and list expose it as
stop_cause {reason, stopped_at} while they render the sandbox stopped;
a requested stop leaves it null.
Writing the cause during the stopped convergence lost it when an API
stamp moved the row to stopped first, and could not clear an old cause
when a requested stop followed a start that never reached started. The
node now attributes every stop it newly owns, a requested one included,
with its own time of that stop, and publishes the pair with a stopped
status. The ingest applies it, after the convergence, to a row on that
node that isn't running, when it is newer than the stored one; a node
that publishes none leaves the stored attribution alone. The
convergence no longer writes the cause, and the cached admin response
decides on the cause after it picks the lifecycle it renders.
@ac-pplx
ac-pplx force-pushed the ac/sandbox-stop-cause-api branch from 5a5a8cd to 12e342c Compare November 5, 2026 21:49
The ingest orders attributions by the publishing nodes' wall clocks.
Say that this assumes NTP-synced nodes, and which sequence a skewed or
stepped-back clock can get wrong: a crash, an evacuation and a start
interrupted before started, all within the skew. Say too, on the node
side, that the control plane applies an attribution only to a row on the
publishing node that isn't started or paused.
Assert the ids the attribution statement writes, so a republished,
older or attribution-less report is shown to write nothing, including on
a row with no attribution stored. Send an older node's report as a real
stopped publication without attribution fields. After the evacuation,
publish from the destination node, and check that its newer attribution
applies there while the old node's report, even a newer one, no longer
does.
@ac-pplx
ac-pplx marked this pull request as ready for review November 5, 2026 22:20
@ac-pplx

ac-pplx commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

Closing: we decided not to add schema columns to record a stop's cause. The value doesn't justify a new column, a trigger on sandboxes, and the ingest rule. The branch stays in place in case we revisit.

@ac-pplx ac-pplx closed this Nov 5, 2026

@shehan-pplx shehan-pplx left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed by Shehan's Review Bot

Comment on lines +15 to +53
/// Why a stop no caller requested happened.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StopCause {
/// The shim tore the sandbox's Kubernetes pod down while it ran.
PodTerminated,
/// The sandbox's VM process exited on its own.
VmExited,
}

impl StopCause {
pub const fn as_str(self) -> &'static str {
match self {
Self::PodTerminated => "pod_terminated",
Self::VmExited => "vm_exited",
}
}
}

impl std::fmt::Display for StopCause {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}

impl FromStr for StopCause {
type Err = StopCauseParseError;

fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
match value {
"pod_terminated" => Ok(Self::PodTerminated),
"vm_exited" => Ok(Self::VmExited),
_ => Err(StopCauseParseError),
}
}
}

#[derive(Debug, Clone, Copy, Eq, PartialEq, thiserror::Error)]
#[error("stop cause must be pod_terminated or vm_exited")]
pub struct StopCauseParseError;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[Should fix] StopCause here and SandboxStopCause in sandbox-store/src/lib.rs are the same enum: same variants, same labels, same as_str/Display/FromStr, same parse error text. Adding a cause means editing both, plus the proto enum and three hand-written match maps (stop_cause_from_proto in grpc_core.rs, stop_attribution_to_proto in grpc_state_snapshot.rs, and published_stop_attribution in internal-api). Both stores already depend on pplx-space-common-core, where SandboxLifecycle lives (types/sandbox.rs).

Fix: define SandboxStopCause (with as_str, FromStr and snake_case serde) once in pplx_space_common_core::types::sandbox, and re-export or use it from both stores and the API response. That leaves one label table, and only the proto conversions at the edges.

Comment on lines +449 to 462
/// Whether a stop admitted over `superseded` starts a new stop rather than
/// re-admitting one: `stopping` and `stopped` rows, and the cleanup states a
/// stop's failed or interrupted cleanup leaves, already belong to a stop.
fn newly_owns_stop(superseded: Option<SandboxState>) -> bool {
!matches!(
superseded,
Some(
SandboxState::Stopping
| SandboxState::Stopped
| SandboxState::CleanupRequired
| SandboxState::CleanupFailed
)
)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[Consider] This is a second rule for "a stop already owns this row", and it disagrees with the shim's. releaseLegacyTaskTeardown treats CLEANUP_REQUIRED/CLEANUP_FAILED as stop-owned only when the recorded completion is MARK_STOPPED. Here every cleanup row counts as stop-owned, so a Stop over a failed suspend (mark_suspended), create rollback (mark_created) or quarantine (mark_failed) records no attribution. The node then publishes none, and the control plane keeps whatever it last stored. If it never saw the intervening started, that is the previous crash's cause.

Fix: read the superseded row's cleanup_completion next to its state, and count CleanupRequired | CleanupFailed as stop-owned only with Some(CleanupCompletion::MarkStopped). Then add the non-MarkStopped cleanup cases to a_readmitted_stop_keeps_the_first_stops_attribution.

AND input.published_lifecycle = 'stopped'
AND input.stop_cause_at IS NOT NULL
AND LOWER(TRIM(s.lifecycle)) NOT IN ('started', 'paused')
AND (s.stop_cause_at IS NULL OR input.stop_cause_at > s.stop_cause_at)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[Consider] The doc comment above lists the clock-skew limitation, but the only cross-clock comparison is between attributions from different nodes. One node's republications are already handled by >. Because the active_node_id = $1 gate means only the current node writes, an attribution from a node other than the one that wrote the stored value is always the later stop.

Fix: while #1545 is unshipped, add a stop_cause_node_id column there, and accept the write when s.stop_cause_node_id IS DISTINCT FROM $1 OR input.stop_cause_at > s.stop_cause_at. That removes the evacuation and skew case, and its paragraph in the doc.

Comment on lines +418 to +427
stop_attribution: row
.get::<_, Option<i64>>("stop_cause_at")?
.map(|stopped_at_ms| super::StopAttribution {
cause: row
.get::<_, Option<String>>("stop_cause")
.ok()
.flatten()
.and_then(|value| value.parse().ok()),
stopped_at_ms,
}),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[Consider] .ok().flatten() swallows any column error on stop_cause, such as a projection that forgot the column or a type mismatch, and reports it as a requested stop. stop_cause_at beside it uses ?. The rollback case only needs the parse().ok().

Suggested change
stop_attribution: row
.get::<_, Option<i64>>("stop_cause_at")?
.map(|stopped_at_ms| super::StopAttribution {
cause: row
.get::<_, Option<String>>("stop_cause")
.ok()
.flatten()
.and_then(|value| value.parse().ok()),
stopped_at_ms,
}),
stop_attribution: {
let stop_cause: Option<String> = row.get("stop_cause")?;
row.get::<_, Option<i64>>("stop_cause_at")?
.map(|stopped_at_ms| super::StopAttribution {
cause: stop_cause.as_deref().and_then(|value| value.parse().ok()),
stopped_at_ms,
})
},

Comment on lines +1035 to +1038
#[expect(
clippy::too_many_arguments,
reason = "each argument is an independent field of the Stop or Delete request being admitted"
)]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[Consider] A seventh positional argument, which adds a too_many_arguments suppression. Call sites now read (id, MarkStopped, false, true, None, false, None), and two adjacent Options with three bools are easy to swap without any error.

Fix: pass a small SelectedCleanup { completion, immediate_kill, persist_intent, template_filter, require_template_owner, stop_cause } struct, as CleanupAdmissionSpec already does one level down, and drop the #[expect].

Comment on lines +520 to +526
// Set only by a caller stopping the sandbox because its infrastructure went
// away rather than because anyone asked for a stop: the shim's legacy pod
// teardown (a first-generation pod without the durable-suspend teardown
// annotation) sends POD_TERMINATED; a later-generation pod takes the
// teardown-suspend path, which records no cause. sbx-manager records it only when this Stop newly admits the stop:
// a Stop that re-admits a row another stop owns keeps that stop's
// attribution. Absent for every requested stop, and from older callers.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[Consider] This wire contract describes one caller's internals: shim pod generations, the durable-suspend annotation and the teardown-suspend path. Those details will go stale while the field's meaning stays the same. Line 524 also runs past the wrap.

Suggested change
// Set only by a caller stopping the sandbox because its infrastructure went
// away rather than because anyone asked for a stop: the shim's legacy pod
// teardown (a first-generation pod without the durable-suspend teardown
// annotation) sends POD_TERMINATED; a later-generation pod takes the
// teardown-suspend path, which records no cause. sbx-manager records it only when this Stop newly admits the stop:
// a Stop that re-admits a row another stop owns keeps that stop's
// attribution. Absent for every requested stop, and from older callers.
// Why the sandbox is stopping when nobody asked for a stop (for example,
// its pod was torn down). Absent for a requested stop and from older
// callers. sbx-manager records it only when this Stop newly owns the row;
// a Stop that re-admits a row another stop owns keeps that stop's
// attribution.

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

Labels

triage:pending Waiting for a maintainer to triage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants