Repository navigation
sbx-mgr: background and startup recovery write only over the state they expect - #416
Conversation
4e1e12d to
dce4736
Compare
dce4736 to
6098e51
Compare
…ey expect
Convert a periodic background generic writer and the startup
recovery writers of sbx-manager's node-local SQLite `sandboxes.state` (not
the control-plane Postgres `public.sandboxes.lifecycle`) from `update_state`
to `update_state_from` with explicit from-sets.
Auto-lifecycle Stop of a `suspended` row (`run_due_auto_lifecycle`, now
through `stop_suspended_for_auto_lifecycle`) writes `stopped` with
AUTO_LIFECYCLE_STOP_SUSPENDED ({suspended}). It reads the row under one
database guard and writes under a later one while holding only the wake
lock, which Stop and Delete admission and eviction do not take. Before,
the write overwrote every state the generic guards admit: a Stop admitted
in between lost its `stopping` row, so its completion no longer owned the
row and left the stopped runtime's fields behind; a `deleting` or
`quarantined` row instead failed the transition with Aborted. Now a row
that left `suspended` refuses the write; the transition is skipped and
logged, not recorded, and the loop does not error. Anything else maps
through PR 1b's state_write_result / StateWriteRejected::into_status, so a
guard refusal of a row still `suspended`, and a missing row, keep their
Aborted messages.
The startup writers run before sbx-manager binds its sockets and starts its
background loops. The snapshot scheduler and watchdog workers spawned at
service construction act only on registered sandboxes, and
runtime-registration recovery registers them only after these writes, so
their behavior is unchanged: recover_interrupted_create
(INTERRUPTED_CREATE_RECOVERY), recover_unlaunched_start
(UNLAUNCHED_START_RECOVERY_STOPPED/_CREATED), and the interrupted-restore
branch of recover_interrupted_startup_states (INTERRUPTED_RESTORE_RECOVERY).
The two that require their write go through require_startup_state_write,
whose refusal is the new RecoverStartupStatesError::StateWriteNotApplied;
the now-unused StartupRecoveryError::StateWrite variant is removed.
Caller mutex and lifecycle-lock scopes and side columns are unchanged;
in autocommit, update_state_from adds its own IMMEDIATE transaction around
the guard reads and the write, which is harmless on the single connection.
No edges are added or deleted and the parity allowlist is unchanged; the
five edges' doc comments name the converted writers.
Tests: a service-level two-phase barrier between the re-read and the
write (test builds only) drives auto_lifecycle_stop_writes_only_over_a_
still_suspended_row, a wildcard-free sweep over every SandboxState with
`suspended` as the accepting control, and stop_admitted_during_auto_
lifecycle_stop_keeps_its_completion. Both fail with the generic write
restored and pass with the from-set.
6098e51 to
cf519ce
Compare
nikhilgupta-pplx
left a comment
There was a problem hiding this comment.
Approve. The auto-lifecycle skip returns before mark_transition, releases the wake lock/lease, and the next scan defers transient states and settles on stopped without a hot loop. Startup recovery edges ({suspended}, {creating}, {starting}, {restoring}) match their callers, and the new refusal is unreachable for episode-less rows, so no new crash-loop path.
Non-blocking:
StateWriteNotAppliedrenders{outcome:?}in an operator-facing startup error; formatfound/refusalexplicitly.reconcile/tests.rsonly exercises the guard-refusal skip viaquarantined;force_state(Deleting)has nodelete_rowintent, so it goes throughFromState. Adeletingrow with a currentdelete_rowintent would pin theCleanupOwnedskip.
Review — 4 findingsLow
What I checked and found sound:
Verified against |
the-gigi-pplx
left a comment
There was a problem hiding this comment.
Approved. 4 non-blocking findings in the review comment above.
Stacked on #413 (on #418, #438 and main). Base:
ac/sbxmgr-sw-pr3-wake-resume@38e424c. This PR is one commit on top of it. Stack, bottom to top: #438 → #418 → #413 → #416 → #421 → #417 → #414. #372 and #388 have merged to main.This PR changes only sbx-manager's node-local SQLite
sandboxes.state. It does not touch the control-plane Postgrespublic.sandboxes.lifecycle.What merging does
It converts a periodic background generic writer, and the startup-recovery writers, from
update_statetoupdate_state_fromwith explicit from-sets. This is the plan's §5 PR 5, background/startup part (finding 7).Call sites converted
service/reconcile.rsrun_due_auto_lifecycle, Stop of asuspendedrow (nowstop_suspended_for_auto_lifecycle)update_state(Stopped)throughrequire_state_writeupdate_state_from(&edges::AUTO_LIFECYCLE_STOP_SUSPENDED), {suspended} → stopped. Refusals map through #438'sstate_write_result/StateWriteRejected::into_statusservice/recovery.rsrecover_interrupted_createupdate_state(Created)?.require_applied()?require_startup_state_write(&edges::INTERRUPTED_CREATE_RECOVERY), {creating} → createdservice/recovery.rsrecover_unlaunched_startupdate_state(Stopped | Created)?.require_applied()?require_startup_state_write(&edges::UNLAUNCHED_START_RECOVERY_STOPPED | _CREATED), {starting} → stopped / created. The recorded outcome isedge.to()db/startup_recovery.rsrecover_interrupted_startup_states, the restoring branchupdate_state(Suspended), skip unless appliedupdate_state_from(&edges::INTERRUPTED_RESTORE_RECOVERY), {restoring} → suspended, skip unless appliedrequire_startup_state_write(new, indb/startup_recovery.rs) wrapsupdate_state_from. Any outcome butAppliedbecomes the newRecoverStartupStatesError::StateWriteNotApplied { edge, outcome }.Startup writers: behavior unchanged
All three startup writers run inside
recover_interrupted_startup_states(run.rs~884):new_with_config) already spawns the snapshot scheduler and watchdog workers. They cannot race these writes, because they act only on sandboxes registered with them.recover_runtime_registrations(run.rs~964) registers sandboxes with them only after these writes. Therequire_startup_state_writedoc comment records this invariant.So no RPC or worker can move a row between recovery's read and its write. Every row each writer reaches is already in its edge's from-set, and it applies as before. The existing pins listed below are green.
Auto-lifecycle Stop: the race fixed (plan finding 7)
Before. The Stop of a
suspendedrow re-read the row under one DB mutex guard and wrote under a later one. It held only the wake lock. Stop admission, Delete admission and eviction do not take that lock (they use the cleanup fence and staging locks). With no from-set, the write overwrote whatever the generic guards admitted. A wildcard-free sweep against #388's code (moved to X, the row moved after the re-read) gave:creating,created,starting,started,stopping,stopped,pausing,paused,resuming,checkpointing,suspending,restoring,cleanup_required,cleanup_failedandfailedwere overwritten withstopped, and the Stop stage was recorded as fired.deleting(whose state ownsdelete_row) andquarantinedreturnedAborted, withsuperseded_by_cleanup_episodeorblocked_by_quarantine. That errored the transition, and the loop persisted it aslast_auto_error.The concrete production interleaving is a Stop admitted between the re-read and the write. Stop admits a
suspendedrow only while its runtime is live: in the window between Stop'sfc.pidgate and its cleanup fence, or with a leaked runtime. The overwrite turned Stop'sstoppingintostopped. Stop's own completion (complete_row_intent_cleanup_for_job(MarkStopped)) then no longer owned the row, so it changed nothing and left the stopped runtime's fields (pid, uid, route) on astoppedrow.After.
foundis outside the edge's from-set skips the transition.FromStateorGuard, matched by!edge.from().contains(found). This predicate deliberately differs from the other PRs': here any refusal of a row that leftsuspendedmeans "skip", including a guard refusal of adeletingorquarantinedrow.infolog and returnsOk(())beforemark_transition. Nothing is recorded, and no auto-lifecycle error is persisted. The next scan re-evaluates the row in its new state.StateWriteRejected::into_status: a guard refusal of a row stillsuspended(onlyCleanupOwnedcan occur there), and a missing row. These arms are defensive and not pinned by a test. TheirAbortedtexts are unchanged.Internal{context}: {error}, as before.AUTO_LIFECYCLE_STOP_SUSPENDEDstaysRequest, as #388 classified it. As #388 noted, it bypasses every RPC. Its allowlist reason already says that.Red/green tests (
service/reconcile/tests.rs). They use a service-level two-phase barrier between the re-read and the write: a#[cfg(test)]AutoLifecycleCoordinator::suspended_stop_barrier(tokio::sync::Barrier), following theadmission_barrierprecedent. The services usefor_test_in(tempdir)with in-memory SQLite and bounded 30 s waits.auto_lifecycle_stop_writes_only_over_a_still_suspended_row: a wildcard-free sweep over everySandboxState. The row is moved withforce_stateat the barrier.suspendedis the accepting control: the row endsstoppedand the stage is recorded (next_auto_event_at= None).Okand leave the whole database unchanged.moved to creating: the row was overwritten withstopped). It passes with this change.stop_admitted_during_auto_lifecycle_stop_keeps_its_completion: a runtime-seededsuspendedrow. At the barrier, Stop admission's state write runs (update_state_superseding_activation_in_transaction(Stopping); a blocking Stop persists no intent). The test asserts:Okand leaves the whole database unchanged;stoppedwith its runtime cleared.The completion's return value is not asserted: without a job id it reports
Appliedeven for a row it does not own. On sbx-mgr: add the persisted state-transition table and update_state_from (no callers) #388's code the test fails, becausestoppingwas overwritten withstopped. It passes with this change.Neither test asserts a message.
Every behavior change
Auto-lifecycle Stop over a row that left
suspended(the race only).stoppedand no longer records the Stop stage.deletingandquarantinedrows it no longer returnsAbortedor persistslast_auto_error.infolog.This is client-visible through the sandbox's
auto_lifecycle_status. For a raced row:last_auto_error,last_auto_transitionandnext_auto_event_at, and the ECWend_atderived from them, now read as unchanged by the skipped attempt;deleting/quarantinedrow no longer getslast_auto_error;suspendedrecords no Stop transition until the next scan re-evaluates it: about 5 s later, or after the 60 s gated defer if its new state is mid-transition.Rows that do not race are unaffected.
Client-visible status messages: none changed. This caller never produces the canonical
FromStatetext: a from-state refusal of a row that leftsuspendedskips. A from-state refusal of a row stillsuspendedis impossible, because the writer returnsStatementChangedRows(0)instead. TheGuardandMissingtexts equal the old ones.Startup recovery errors (not client-visible; startup fails and process supervision retries either way):
StartupRecoveryError::StateWrite, displayed assandbox state write was not applied: <outcome>. It now surfaces asStateRecovery(StateWriteNotApplied), displayed asstartup recovery write <EDGE> was not applied: <outcome>. This outcome is unreachable at startup. The now-unusedStartupRecoveryError::StateWritevariant is removed.startup recovery database operation failed: …(StartupRecoveryError::Database). It now displays asstartup state recovery database operation failed: …(StartupRecoveryError::StateRecovery(RecoverStartupStatesError::Database)).Startup interrupted-restore write: it still skips a non-applied write. It now logs a
warnon that skip.update_stateused to log only its guard refusals.Locks and transactions: caller mutex and lifecycle-lock scopes are unchanged. The helper adds its own
IMMEDIATEtransaction in autocommit, around its guard reads and theUPDATE;update_stateran a bareUPDATEfor these non-deletingtargets. That is harmless on the single connection. Side columns are unchanged.Double count (not fixed, noted). The
auto_lifecycle_transition_latenessmetric and the "running auto lifecycle transition" log fire before the Stop's write. A stage that is skipped by this refusal and later retried is counted, and logged, twice. Emitting them only after a successful write would touch the Pause, Suspend and Delete arms too, so this PR leaves it.Edges, allowlist and #438 items
No edges added or deleted. None of my call sites had a
KnownRaceedge, so this branch does not retire theKnownRacekind.The doc comments of the five converted edges now name the new writers. No reordering or reformatting.
Allowlist unchanged. I re-read the reasons for my pairs:
suspended → stopped("bypasses every RPC");starting → created/stopped("startup recovery restore the entry state");restoring → suspended("restore rollback, cross-node publication, restore recovery").sbx-mgr: create completion and warm start write only over the state they expect #418 (PR 4), below this PR, dropped its warm-start clause.
sbx-mgr: one refusal-to-status mapping for update_state_from #438
dead_codeexpectations. sbx-mgr: create completion and warm start write only over the state they expect #418, below this PR, already dropped them from the items this PR's production code uses:StateWriteRejected(and itsimpl),StateWriteGuardandstate_write_result.update_state_from_best_effortkeeps its expectation until sbx-mgr: snapshot completion and rollback write only over the state they expect #421, above this PR, calls it.require_state_write_from. At sbx-mgr: one refusal-to-status mapping for update_state_from #438's final head it returnsResult<(), Status>. This PR does not call it; it matchesstate_write_result(..)withOk(_).Test-fixture visibility.
db/tests/state_write_fixtures.rschangesseed_runtimefrompub(super)topub(crate), for reuse by the service tests; sbx-mgr: create completion and warm start write only over the state they expect #418, below this PR, already widenedforce_state.Overlaps with the other PRs in the stack
db/state_write.rs. This PR changes doc-comment lines only, onAUTO_LIFECYCLE_STOP_SUSPENDED,INTERRUPTED_CREATE_RECOVERY,UNLAUNCHED_START_RECOVERY_STOPPED/_CREATEDandINTERRUPTED_RESTORE_RECOVERY. The other PRs in the stack deleteKnownRaceedges elsewhere in the table, so the hunks are disjoint.service/cleanup_support.rs. sbx-mgr: create completion and warm start write only over the state they expect #418, below this PR, drops thedead_codeexpectations this PR relies on, so this PR no longer touches the file.state_write_fixtures.rs. sbx-mgr: create completion and warm start write only over the state they expect #418, below this PR, widensforce_state; this PR widens onlyseed_runtime.KnownRaceedge. sbx-mgr: pause writes only over the state it expects #417, above this PR, deletes the last ones and retires the kind. This PR removes none.service/recovery.rs. It is listed inarchitecture-baseline.tomlwith a cap of 2017 code lines and was already at 2059 on sbx-mgr: add the persisted state-transition table and update_state_from (no callers) #388's head, a pre-existing advisory overrun. This PR takes it to 2058 and raises no cap. Keep sandbox-manager out of retained btrfs send sources #246 also touches this file. Separately,service/mod.rsis 1782 against its 1757 cap, also pre-existing; this PR does not touch it.Mutation proof
The mutations ran one at a time on this head, each from a saved copy of the file, which was restored and touched before the next. The selection was the state-write table tests plus the auto-lifecycle, startup-recovery, interrupted and unlaunched tests.
update_state(Stopped)callauto_lifecycle_stop_writes_only_over_a_still_suspended_row,stop_admitted_during_auto_lifecycle_stop_keeps_its_completionAUTO_LIFECYCLE_STOP_SUSPENDEDfrom-set →StateSet::ALLno_edge_lists_its_own_target_as_a_sourceand the parity testinto_statusOk(true)){created}edgestartup_recovery_finishes_interrupted_create_by_creating_cache_root_CREATEDedgestartup_recovery_rewinds_unlaunched_start_with_rootfs_to_stopped{suspending}edgestartup_recovery_reconciles_interrupted_states_without_runtime_fieldsPins (green)
auto_lifecycle_scheduler_*,auto_lifecycle_error_*, and the auto-lifecycle tests inservice/tests.rsandauto_lifecycle/tests.rs.startup_recovery_finishes_interrupted_create_by_creating_cache_root;startup_recovery_rewinds_unlaunched_start_without_rootfs_to_created/_with_rootfs_to_stopped;startup_recovery_reconciles_interrupted_states_without_runtime_fieldsand the otherstartup_*tests.update_state_from, table andcleanup_supporttests are unchanged and green.Decisions made without an operator
AutoLifecycleCoordinator. I put it there, not oncleanup_executor, because it is the auto-lifecycle path's own state.defer_gated_auto_lifecycle_row. The next scan should evaluate the changed row promptly, as the wake-superseded skip already does.deletingrow: it only errored the loop. So the named regression test uses Stop admission, the interleaving the old code actually overwrote.require_startup_state_writelives indb/startup_recovery.rs, soservice/recovery.rsdoes not grow.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 (
cf519ce): 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 1641/1641, with 0 dependency skips. Tested ataeafa72; 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
cargo fmt --all -- --check,scripts/check-rust-error-policy.sh6098e51)cargo clippy --workspace --locked --all-targets --all-features -- -D warnings6098e51)cargo archservice/recovery.rsis 2058 against its 2017 cap, a pre-existing overrun (2059 on #388's head)SPACE_CI_REQUIRE_TEST_DEPS=1 cargo nextest run --locked -p pplx-space-sbx-mgr --retries 0dce4736(onto #438 @7e2e41c), then × 1 on6098e51(onto #438 @f1d1c9b): 1632/1632 each run; 0 dependency skips (skip logs empty). The 1 skipped test is the#[ignore]privilegedtransfer_directory::tests::distinct_service_uids_can_exchange_downloads, which is unrelateddce4736, all restoredsmoke-kind-e2e.shbootstrappedspace-localdce4736:space-rust-build-kind:localand the runningspace-sandbox-managerpod'sspace-sbxmgr-svcboth have sha2569706f804…. The binary contains this change's new strings, and pods report"version":"dce4736d5b73"uiresourceswere allok/not_applicablesmoke-kind-e2e.shsmoke-kind-typed.sh all, with the cold-receipts dir and admin token file as in CIinfra/space/kind/scripts/teardown.sh: Tilt stopped, cluster deletedf1d1c9brebase changed only #438'srequire_state_write_fromreturn type, which this PR does not callsuspendedrow. The converted branch is covered by the unit tests above🤖 Generated with Claude Code