Repository navigation
sbx-mgr: pause writes only over the state it expects - #417
Conversation
50186ed to
3ae9116
Compare
2b6fa52 to
5994075
Compare
Pause held only the artifact lock while it awaited guest preparation and
node-manager, and wrote sbx-mgr's node-local SQLite `sandboxes.state`
through the generic `update_state`, which has no expected-state
predicate. Stop takes the cleanup fence, not the artifact lock, and
admits a `pausing` row. A Stop that settled `stopped` in that window was
then overwritten: the rollbacks wrote `started` and the completion wrote
`paused`, leaving a row with cleared runtime fields and no VM (plan
finding 1). This is not the control-plane Postgres
`public.sandboxes.lifecycle`.
Pause's writes now go through `update_state_from` with the existing
edges, and its outcomes through the shared `require_state_write_from`,
`StateWriteRejected` and `update_state_from_best_effort`:
- begin: `PAUSE_BEGIN` ({started} -> pausing);
- rollback after a failed guest preparation, the best-effort rollback
after a node-manager connect failure, and both best-effort rollbacks
in `pause_with_reconciliation`: `PAUSE_ROLLBACK`
({pausing} -> started);
- completion: `PAUSE_COMPLETE` ({pausing} -> paused).
A write that finds the row outside the edge's from-set means another
operation moved it, and Pause answers `Aborted` with the shared
canonical text: "<context>: sandbox state write was superseded: sandbox
is <state>", or `update_state`'s guard message when a generic guard
answered first. This now covers the rollbacks, which used to answer
`Internal`, and the completion, which used to answer `Ok`.
`pause_with_reconciliation` returns the rejection instead of claiming
`started`. When its final quarantine compare-and-set is refused, it
re-reads the row and returns a from-state rejection if the row is no
longer `pausing`, including a row another writer quarantined; this is
the likely path for a Stop that kills the VM mid-pause. A failed read
or a missing row still reports `quarantined`. The suspend auto-pause
fails closed on any rejection, so only pause's own confirmed `paused`
lets it continue, and it still reports the found state as "auto-pause
failed; sandbox reconciled to <state>".
Side columns, the caller's mutex and the lifecycle-lock scopes are
unchanged. `update_state_from` adds its own IMMEDIATE transaction
around its reads and the UPDATE in autocommit, which is harmless on the
single connection.
The edge table drops the `PAUSE_ROLLBACK_AFTER_STOP` and
`PAUSE_COMPLETE_AFTER_STOP` known races, and the `PAUSE_*` doc comments
name callers rather than write functions. The allowlist drops
`stopped -> started` and `stopped -> paused`, which no edge writes now.
These were the last known-race edges, so the `KnownRace` edge kind,
`update_state_from`'s refusal of it and the refused-edge test's
known-race assertion are retired. Pause's rollbacks were the last
callers of the legacy `update_state_best_effort`, which is deleted, and
`activation_support.rs`'s archcheck cap is lowered from 1592 to 1574.
Tests: the Pause RPC runs with a test-only guest-preparation seam (a
task-local, so no test dials the host's spaced-host socket) and an
unbound or fake node-manager. One test runs plan finding 1's Stop at
each of six exits; one sweeps every `SandboxState` at each exit, with
`pausing` as the accepting control. Both fail on the base with only the
seam applied, and when the generic writes are restored.
5994075 to
51bee12
Compare
nikhilgupta-pplx
left a comment
There was a problem hiding this comment.
Approve. Begin/rollback/completion and both pause_with_reconciliation rollbacks use the right edges; {started} matches the only allowed Started → Pausing. In every refused case Stop/Delete owns the row and the VM, so a paused-VM/refused-row split resolves through that owner. KnownRace retirement is clean, and losing to Stop now gives Aborted/409 instead of Internal.
Non-blocking: the comment at pause_resume.rs:198-224 says mark_quarantined_best_effort returns false only when its own write did not apply, but it also returns false on poison/DB error (runtime_reconciliation.rs). The behavior is pre-existing; please correct the comment (or return a three-way outcome).
On the soak observation: the ECW 503 comes from the control plane's claim_suspend_execution CAS in crates/libs/api, which this stack does not touch, so I don't see it as introduced here.
Review — 3 findingsLow
What I checked
Verified against |
the-gigi-pplx
left a comment
There was a problem hiding this comment.
Approved. 3 non-blocking findings in the review comment above.
Stacked on #421 (on #416, #413, #418, #438 and main). Base:
ac/sbxmgr-sw-pr5a-snapshots@9f30aa4. This is plan §5 PR 2: one commit on top of #421. Stack, bottom to top: #438 → #418 → #413 → #416 → #421 → #417 → #414. #372 and #388 have merged to main.All state here is sbx-manager's node-local SQLite
sandboxes.state, not the control-plane Postgrespublic.sandboxes.lifecycle.What merging does
Converts pause's state writes to
update_state_fromAll of these are in
service/grpc_lifecycle/pause_resume.rs. Outcomes go through #438's shared helpers.pause: beginupdate_state(Pausing)→require_state_writePAUSE_BEGIN({started} → pausing) →require_state_write_frompause: rollback after a failed guest preparationupdate_state(Started), refusal only loggedPAUSE_ROLLBACK({pausing} → started) →state_write_result. A superseded rejection is returned throughStateWriteRejected::into_status; any other rejection is logged as beforepause: best-effort rollback after a node-manager connect failureupdate_state_best_effort(Started)PAUSE_ROLLBACK→update_state_from_best_effortpause_with_reconciliation: best-effort rollback when the VM is still running after the last attemptupdate_state_best_effort(Started)PAUSE_ROLLBACK→update_state_from_best_effortpause_with_reconciliation: best-effort rollback when the retry's guest preparation failsupdate_state_best_effort(Started)PAUSE_ROLLBACK→update_state_from_best_effortpause: completionupdate_state(Paused)→require_state_writePAUSE_COMPLETE({pausing} → paused) →require_state_write_fromTwo small local helpers remain:
superseded(rejected, edge)is the found-based test!edge.from().contains(found). A guard refusal of astoppingrow is superseded too, because the generic guards answer before the from-set.roll_back_pause_best_effortwrapsupdate_state_from_best_effort(PAUSE_ROLLBACK)and returns the rejection only when it was superseded.Unchanged: side columns, the caller's mutex scopes, and the artifact-lock scope.
update_state_fromadds its ownIMMEDIATEtransaction around its reads and theUPDATEin autocommit, whichupdate_statedid not. That is harmless on the single connection.#438's helpers: #418 and #421, below this PR, already removed
#[cfg_attr(not(test), expect(dead_code, …))]fromrequire_state_write_from,StateWriteRejected(and itsimpl),StateWriteGuard,state_write_resultandupdate_state_from_best_effort; pause now calls them in production too.Fixes plan finding 1: pause over a concurrent Stop
Pause holds only the artifact lock while it awaits guest preparation and node-manager. Stop takes the cleanup fence and admits a
pausingrow: #372'sstop_admission_answers_every_source_statepinspausingasAdmitted. A Stop that settledstoppedin that window used to be overwritten, bystarted(rollbacks) orpaused(completion). That left a row with its runtime fields cleared and no VM. Nowstopped,created,suspendedand every other state outside the edge's from-set refuse the write, andpausingmust be the found state.The quarantine-miss path. This is the likeliest Stop-during-pause path:
pause_sandboxfails and the VM-state probe errors.pause_with_reconciliationreachesmark_quarantined_best_effort(Pausing).stoppingorstopped.Now a refused quarantine re-reads the row.
mark_quarantined_best_effortanswers false only when its own write did not apply, so a re-read that is no longerpausingmeans another operation owns the row. That includesquarantined, which another writer must have made. Pause then returnsStateWriteRejected::FromState { found }and answers the canonical supersededAborted. A poisoned mutex, a failed read, a row stillpausing(a database error) or a missing row still reportsquarantined.Behavior changes, including client-visible ones
What Pause returns when it loses to another operation. It returns
Abortedwith #438's canonical text, which the API maps to 409:FromState:"<context>: sandbox state write was superseded: sandbox is <found>"stopping,deleting,cleanup_required,cleanup_failed→ cleanup-owned;quarantined):"<context>: sandbox state write was not applied: superseded_by_cleanup_episode | blocked_by_quarantine"roll back failed pause preparationstarted(or guard-refused);Internal "guest snapshot preparation before pause failed: …"Aborted, canonical textroll back pause after node-manager connect failurestarted;Internal "cannot connect to node-manager: …"Aborted, canonical textpause_with_reconciliationrollbacks (both)reconcile unconfirmed pauseInternal "pause failed; sandbox reconciled to started"Aborted, canonical textpause_with_reconciliationquarantine refusedreconcile unconfirmed pauseInternal "pause failed; sandbox reconciled to quarantined", row not quarantinedAborted "…: sandbox state write was superseded: sandbox is <found>"(from the re-read, so always theFromStatetext)complete pausepaused;OkAborted, canonical textbegin pauseAborted "…: sandbox state write was not applied: <outcome>"; other states appliedstartedrow isAborted "begin pause: sandbox state write was superseded: sandbox is <found>"Unchanged:
pause_refuses_rows_that_cannot_enter_pausing_before_writing_state).pausing, and a missing row: the guest-prep rollback logs a warning and returns the originalInternal, and the best-effort rollbacks returnstarted, as before.Message changes:
sandbox state write was superseded: sandbox is <found>, for from-state refusals at every exit.Abortedanswers replaceInternal(both rollbacks, both reconciliation rollbacks, the refused quarantine) andOk(completion) over a moved row.pause_with_reconciliationnow returnsResult<SandboxState, StateWriteRejected>.Erris a superseded rejection from a rollback or from the quarantine re-read; it is neverMissing.Ok(paused), so no rejection, including one that foundpaused, lets suspend proceed. It reports the found state, as before, asInternal "auto-pause failed; sandbox reconciled to <state>". This is a small change insuspend.rs.prepare_guest_for_vm_snapshot_via_spaced_hostdirectly, which dials the host's per-sandbox spaced-host socket, so reaching it would mean adding the seam tosuspend.rs, the file sbx-mgr: suspend writes only over the state it expects #414 converts. Rejections are unreachable there today under suspend's fences. The fail-closed property holds by construction: the only continue condition ispause_result == Ok(SandboxState::Paused).confirmed_node_pause_reports_paused_and_carries_the_execution_fencechanges fromassert_eq!(state, SandboxState::Paused)toassert_eq!(state, Ok(SandboxState::Paused)). That is a type-only change.The begin gap
lookup_sandboxand thePAUSE_BEGINwrite take separatedb_conn()acquisitions of the std mutex on a multithreaded runtime. Another worker can commit between them, for example cleanup admission's blocking task. An unlocked warm start no longer can: #418, below this PR, refuses astartedrow. The{started}from-set refuses whatever that worker left. A code comment says so.No test interleaves at that point, so mutation 5 below survives because no seam exists there. That is acceptable:
PAUSE_BEGIN's from-set.Edges and allowlist
KnownRaceedges:PAUSE_ROLLBACK_AFTER_STOP(stopped → started) andPAUSE_COMPLETE_AFTER_STOP(stopped → paused).PAUSE_BEGIN,PAUSE_COMPLETEandPAUSE_ROLLBACKnow name callers, not write functions: "pauseand the suspend auto-pause, aftertransition(Pausing)on the read", and so on.(Stopped, Started)is removed: no edge writes it now.(Stopped, Paused)is removed too: sbx-mgr: snapshot completion and rollback write only over the state they expect #421, below this PR, deleted the template-snapshot race, so no edge writes it now. I re-read the other mixed reasons: none names a pause race.KnownRacekind retired. These were the last twoKnownRaceedges: sbx-mgr: create completion and warm start write only over the state they expect #418, sbx-mgr: wake and resident resume write only over the state they expect #413 and sbx-mgr: snapshot completion and rollback write only over the state they expect #421, below this PR, deleted the other four. This PR deletesEdgeKind::KnownRace,update_state_from's refusal of it and the emptied// Known races.block. The refused-edge test drops itsany KnownRaceassertion and is renamedinsert_and_rejected_target_edges_are_refused.Test-only seam
prepare_guest_for_pausereplaces the two directprepare_guest_for_vm_snapshot_via_spaced_hostcalls in this file. Under#[cfg(test)]it checks atokio::task_local!hook,GUEST_PREPARATION; release builds compile the direct call. A task-local needs no field onSandboxManagerService, andservice/mod.rsis at itscargo archcap.Tests
The new file is
service/grpc_lifecycle/pause_resume/supersession_tests.rs. Each case runs the real Pause RPC onnew_with_options(…, for_test_in(tempdir)), with in-memory SQLite, the seam, and either an unbound node-manager or a test-servedFakeNodeManager.The interleaving runs at the guest-preparation call before the write under test. Its whole-database snapshot is then compared with #372's
assert_unchanged.Expected outcomes are written out per state:
PLAINgrid.stopping,deleting,cleanup_requiredandcleanup_failed→ cleanup-owned;quarantined→ quarantine.PreparationFailsNodeUnreachableNodeConfirmsRetryPreparationFailsVmStaysRunningVmUnobservable(new)PauseSandboxandGetFirecrackerVmStateboth failpause_leaves_a_row_a_concurrent_stop_settled(red/green, finding 1). This runs plan finding 1's Stop at each of the six exits, exactly sbx-mgr: characterize the state writers before the state-write helper (tests only) #372's reproduction: cleanup admission's transaction (update_state_superseding_activation_in_transaction(Stopping)+set_cleanup_intent(MarkStopped)), thencomplete_row_intent_cleanup_for_job(MarkStopped).Aborted "<context>: sandbox state write was superseded: sandbox is stopped", and the database exactly as Stop left it.pause_writes_only_over_its_own_pausing_row. All 18 states × 6 exits, usingforce_state(nowpub(crate)).pausingis the accepting control: rolled back tostartedwith today'sInternal,Okwithpaused, or quarantined withInternal "pause failed; sandbox reconciled to quarantined".Abortedtext in full, with the database unchanged.VmUnobservableover a row another writer quarantined answersAborted "reconcile unconfirmed pause: sandbox state write was superseded: sandbox is quarantined", like every other exit.Literal red run. I applied only the seam (behavior-neutral) and this test file onto #388's head
2fc6457, in a disposable worktree that was then removed. Both tests fail on the whole-database diff, for example:The row is overwritten to
started.Unchanged and green: #372's matrix and accepted-shape tests, #438's tests, and the plan's pins:
pause_refuses_rows_that_cannot_enter_pausing_before_writing_state,teardown_suspend_pause_failure_recovery_allows_same_generation_retry, the suspend tests, andconfirmed_node_pause_…(type-only change above).Mutation proof
Each mutation ran alone, from one backup, against
supersession_tests,pause_refuses_rowsandconfirmed_node_pause, and was then restored. The final restore was byte-compared with the backup.PreparationFails, Stop: "a refused write must leave every row unchanged"update_state(Started)at the guest-prep rollback onlyPreparationFailsrow diffupdate_state_best_effort(Started)in the best-effort rollback onlyNodeUnreachablerow and dirty-revision diffupdate_state(Paused)at completion onlyNodeConfirmsrow and dirty-revision diffupdate_state(Pausing)at beginlookup_sandboxand the begin write (see "The begin gap"). The from-set is proven at the DB level by #388's edge-binding testPAUSE_ROLLBACKfrom-set →StateSet::ALLPreparationFailsrow diffPAUSE_COMPLETEfrom-set →StateSet::ALLNodeConfirmsrow diffsupersededalways falsePreparationFails,InternalwhereAbortedexpectedstartedRetryPreparationFails, Stop,Internal "…reconciled to started"Internalwhen supersededPreparationFails, Stopmark_quarantined_best_effort's bool againVmUnobservable, Stopand the sweep atVmUnobservable, Force(Creating), eachInternal "…reconciled to quarantined"PreparationFails, Force(Stopping)quarantinedexclusion in the quarantine re-readVmUnobservable, Force(Quarantined),Internal "…reconciled to quarantined"Overlaps with the other PRs in the stack
PAUSE_BEGIN,PAUSE_COMPLETEandPAUSE_ROLLBACKwrites insuspend.rs. It relies on the caller-naming doc comments above, and builds on this PR's fail-closedpause_with_reconciliationhandling insuspend.rs.created,stoppedandsuspended.(Stopped, Paused)in the allowlist. sbx-mgr: snapshot completion and rollback write only over the state they expect #421 (PR 5a), below this PR, rewrote the same line ("known race: pause completion after Stop"), so this PR deletes the entry; the table test's stale-entry check enforces that.KnownRaceretirement. This PR removes the last race edges, so it retiresEdgeKind::KnownRaceand theany KnownRaceassertion (see above).db/state_write.rs. This PR touches only the threePAUSE_*doc comments and deletes the two pauseKnownRacelines, the emptied// Known races.block and theKnownRacekind; nothing is reordered.cleanup_support.rs. sbx-mgr: create completion and warm start write only over the state they expect #418 and sbx-mgr: snapshot completion and rollback write only over the state they expect #421, below this PR, removed theexpect(dead_code)lines; this PR only refreshesupdate_state_from_best_effort's doc comment, which named the deletedupdate_state_best_effort.state_write_fixtures.rs. sbx-mgr: create completion and warm start write only over the state they expect #418, below this PR, already madeforce_statepub(crate).update_state_best_effort. sbx-mgr: snapshot completion and rollback write only over the state they expect #421, below this PR, converted its template-snapshot caller, and pause's rollbacks were the last, so this PR deletes the legacy function, andactivation_support.rs's archcheck cap drops from 1592 to 1574.Findings not acted on
pausing, still reportsquarantined. This is unchanged.Decisions made without an operator
Result<_, StateWriteRejected>), so guard refusals there keep the legacy guard text. Only the quarantine re-read buildsFromState { found }, as requested.Errand reports the found state in its existingInternalmessage. ItsMissingarm (never produced) reportsstarted, which is what a missing row's rollback reported before.Validation of the restacked stack
The stack was restacked linearly on main (
b18051f) after #372 and #388 merged: #438 → #418 → #413 → #416 → #421 → #417 → #414. The per-PR validation further down ran on this PR's pre-restack head.This PR's head (
51bee12): pull CI is green. fmt, workspace clippy--all-targets --all-features -D warningsandcargo arch(0 required findings) all pass.SPACE_CI_REQUIRE_TEST_DEPS=1 cargo nextest run --locked -p pplx-space-sbx-mgr --retries 0passed 1649/1649, with 0 dependency skips. Tested at12cb312; the final head differs from it only inarchitecture-baseline.toml, which onlycargo archreads, andcargo archwas rerun on the final head. Kind Pull CI ran on #417, which contains this PR: all 4 Firecracker smoke shards passed.The top of the stack (#414 @
9a0a251, which contains this PR):cargo test -p pplx-space-sbx-mgr(shared process)smoke-kind-e2e.shpassed.smoke-kind-typed.sh all: native 23/23; ECW 76 passed, 2 infra errors.local.ecw_template_build_from_oci_bootspassed, and so did the leader-election brownout.node_group_boundary_schedulesandnode_selector_extras_render_and_enforce. Both scenarios restart the API Deployment, and the same failure family appears on main in an A/B on the same cluster. In the failed runs the kill never reached sbx-mgr.sandboxes.state, control-plane Postgreslifecycleand the VM agree. No change-induced violation: across 880 sandboxes, no quarantine, panic, restart or stuck state. Every violation reproduces on main with the same signature: an ECW pause that loses to Stop answers 503 instead of 409; after Suspend vs Delete, the node row can outlive the control-plane row until the next eviction pass (pass timing, not the build); and a refused quarantine write after Pause vs Delete is still logged. The soak's 0–200 ms API-level offsets did not reproduce the interleavings this stack closes, so the unit-level race tests remain the direct evidence for those.Validation on the pre-restack head
Head
5994075, one commit on #438's final headf1d1c9b.Round 2 (
2b6fa52→5994075) is a small error-mapping change: the quarantine re-read now treatsquarantinedas superseded, and suspend fails closed. For it I reran fmt, workspace clippy,cargo arch, the error-policy script and pre-commit (all pass), sbx-mgr nextest once (1632/1632, 0 dependency skips), and mutations 11 and 13 (both caught). Per the owner, Kind was not rerun, because Kind cannot reach this path. Kind ran at3ae9116: the same commit on #438's previous head7e2e41c. The rebase tof1d1c9bchanged only #438's own files (git diff 3ae9116 2b6fa52is exactly #438's delta:require_state_write_fromnow returnsResult<(), Status>), so per the owner only nextest was rerun after it.cargo fmt --all -- --checkcargo clippy --workspace --locked --all-targets --all-features -- -D warningsscripts/check-rust-error-policy.shpre-commit(Gitleaks)3ae9116); after the cherry-pick,pre-commit run --from-ref f1d1c9b --to-ref HEADpassescargo arch3ae9116(SPACE_CI_REQUIRE_TEST_DEPS=1,SPACE_TEST_DEP_SKIP_LOGset)#[ignore]privilegedtransfer_directorytest, unrelated2b6fa52after the final rebase5994075: fmt, workspace clippy-D warnings,cargo arch, error-policy script, pre-commit5994075: sbx-mgr nextest × 1 (SPACE_CI_REQUIRE_TEST_DEPS=1, skip log set)2fc6457smoke-kind-e2e.shwithBOOTSTRAP_TIMEOUT=1800 DATA_PLANE_URL=http://127.0.0.1:8090rust-src(pause_resume.rsbyte-identical to this worktree). All 4 sbx-mgr pods runspace-sbxmgr-svcsha25626d0ce1b…78616, identical to the artifact. The binary containsreconcile unconfirmed pause(new at this head; the previous head'sroll back unconfirmed pauseis absent) andsandbox state write was superseded: sandbox issmoke-kind-typed.sh all, unsharded, admin token file andSPACE_SMOKE_KIND_COLD_RECEIPTSset as in CIpause_requested→pause_completed, 0 supersededinfra/space/kind/scripts/teardown.sh, exit 0; no clusters left🤖 Generated with Claude Code