Repository navigation
sbx-mgr: skip teardown snapshot after a confirmed activation rollback - #583
nickdujay-pplx wants to merge 4 commits into
Conversation
Review — 3 findingsMedium
Low
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.
6515279 to
b7ab411
Compare
A completed activation rollback records a receipt for the exact execution generation and activation attempt it retired, but a late request for that same attempt could still claim the row. Activation cleared the receipt, the retired execution reached a started state, and its teardown then took and uploaded a new snapshot instead of leaving the original artifact as the restore source for the retry. Activation admission now refuses a request that repeats the rolled-back generation/attempt, matching either the receipt's attempt or the retained route evidence, and leaves the row, receipt and artifact pin untouched. Start and restore map the refusal to FAILED_PRECONDITION so the caller mints a new attempt. A new attempt for the same generation is still admitted. Teardown release evidence now also honors a rollback back to suspended for the exact rolled-back generation, so that execution's teardown is answered as superseded without binding a suspend operation or queueing a backup. Older generations keep their durable answer for the retained artifact.
Local eviction deletes the sandbox row and with it the activation-rollback receipt. A same-attempt restore retry could then rebuild the row from the remote snapshot and activate the rolled-back generation again. Record the delete receipt before the row delete, at the larger of the route generation and the rolled-back generation, and refuse activation of a generation that a delete receipt has retired on this node.
Start now checks the rolled-back attempt and retired-generation guards before any restore or start work, including before a missing row is rebuilt from the remote snapshot. The claim repeats the check atomically. One shared function maps both refusals to FAILED_PRECONDITION. Add RPC-level tests for the restore, cold-start and evicted-row paths, and a test for the inventory stale-row delete after a rollback.
…ipts one way Address review of the rollback guard: - Teardown: a rollback to `suspended` keeps the predecessor's route generation and pod UID as one pair (`apply_cleanup_state`), but the suspended release arm still required no route pod UID, so it never matched a sandbox that ran before its suspend. It now accepts the retained pair when the generation is older than the rolled-back one. The rollback test fixture can seed that prior route identity, the teardown test runs with and without it, and the fixture asserts the rollback keeps it. - One reader for the activation-rollback receipt. Admission, the execution delete fence and the teardown arm all use `activation_rollback_receipt`, which decodes the receipt against the state it names and ignores a missing, malformed or other-kind one. Admission and teardown also require the receipt to be current for the row's state; the delete fence does not, because eviction moves the row to `deleting` first. A malformed receipt no longer widens the delete fence. - Tests: move the fixture's doc comment off `job_snapshot`, and drop the teardown test's unreachable pre-fix branch. - Build on the extracted store: `retired_activation` is `pub` for the server's start pre-check, and the tests use the store's connection fixtures.
b7ab411 to
e697abd
Compare
|
Thanks, @the-gigi-pplx. All three are fixed in e697abd. I also rebased the PR on 1. Medium: the
|
the-gigi-pplx
left a comment
There was a problem hiding this comment.
Re-reviewed at e697abd. All three findings from my previous review are fixed:
- The
suspendedteardown arm now accepts the retained (older generation, pod UID) pair. The fixture seeds the production shape viaprior_route, and M13 covers it. - The misplaced doc comment is moved and the unreachable test code is removed.
- All three readers now go through
activation_rollback_receipt. The delete fence intentionally skipsis_current_forbecause the row isdeletingat that point. That is correct, andeviction_after_rollback_keeps_the_rolled_back_generation_fencedcovers it.
Also checked:
record_execution_delete_receiptruns before the row delete on all three delete paths (inventory stale-row, cleanup-episode completion, row-intent cleanup). OnWitnessChanged, the inventory path drops the tx without committing, so no stray receipt is left.- Caller impact of the new
FAILED_PRECONDITION: the Direct substrate treats it the same asINTERNAL(should_try_next_restore_candidate) and reserves a new route identity per candidate, so the post-evictionRetiredGenerationfence cannot block a fresh attempt. The shim passes the code through unchanged.
Non-blocking nit: the doc comment on retired_activation (store/src/db/state_transitions.rs) has two opening summaries. "Returns the refusal for an activation that repeats..." is left over above "Whether activation of this generation/attempt is retired on this node." One should go.
CI is green (20/20).
Summary
A failed restore can be retried after its rollback. The node then snapshots the sandbox again. The next restore uses that new snapshot instead of the original one.
This PR makes the sandbox manager refuse that late retry. The successor restore then uses the original snapshot. No new snapshot or upload job is created.
Self-assessed severity: High. The bug silently replaces a sandbox's restore source with a snapshot taken from an attempt that the system already rolled back. Users keep their files in the cases we tested, but the node and the control plane disagree on which artifact is current.
Terms
Problem
This sequence reproduces the bug:
There is a second path to the same bug. About two minutes after the rollback, eviction deletes the node's sandbox row. That also deletes the rollback receipt. The retry then rebuilds the row from the remote snapshot and activates generation 2 again.
What changed
All changes are in the sandbox manager: the server (
crates/libs/sbx/mgr/server) and its SQLite store (crates/libs/sbx/mgr/store). Paths below are relative to those crates.begin_activation_with_route_evidencenow returnsRolledBackAttemptwhen the attempt matches the rollback receipt. The match is exact: same attempt, or same generation and attempt in the route evidence.RetiredGeneration.Startnow checks both guards before it prepares a restore or a cold start. It also checks them before it rebuilds an evicted row from the remote snapshot. The claim repeats the check inside its transaction, so a race is still caught.begin_activation_status, maps both errors to gRPCFAILED_PRECONDITION.CreateandStartboth use it. Other errors stayINTERNAL.activation_rollback_receipt. A missing, malformed or other-kind receipt gives no authority to any of them. Admission and teardown also require the receipt to match the row's current state. The delete fence does not, because eviction moves the row todeletingbefore the delete.Trade-off: after eviction, the node also refuses a new attempt for the retired generation. This matches existing behaviour before eviction. The route-activation fence already aborts a different attempt for a retained generation. The API starts a newer generation for each new wake, so this does not block recovery.
How it was validated
Unit tests
On e697abd (rebased on
mainat be16af3):cargo test -p pplx-space-sbx-mgr --lib: 1374 passed, 1 ignored.cargo test -p pplx-space-sbx-mgr-store: 366 passed. (The store's tests moved to this crate onmain.)cargo clippy --tests -- -D warnings(with and withouttest-hooks) andcargo fmt --checkpass for both crates.New tests in
service/tests.rs:teardown_after_late_activation_of_rolled_back_attempt_reuses_original_artifact(with and without a prior route generation and pod UID)a_malformed_rollback_receipt_carries_no_authority_anywhere(no refusal at admission, and the delete fence does not widen)a_rollback_receipt_refuses_only_activations_from_its_own_statenew_activation_attempt_for_rolled_back_generation_is_admitted(the database guard only refuses the exact rolled-back attempt)eviction_after_rollback_keeps_the_rolled_back_generation_fencedinventory_stale_row_delete_after_rollback_keeps_the_rolled_back_generation_fenced(the inventory delete path also keeps the fence; a later late activation returnsRetiredGeneration)start_rpc_refuses_late_restore_of_rolled_back_attempt(throughstart_sandbox, with and without a remote backup:FAILED_PRECONDITION; the row, rollback receipt and artifact pin are unchanged; no new artifact or job; the later teardown publishes no artifact and binds no driver)start_rpc_refuses_restore_of_generation_retired_by_eviction(no row is rebuilt, and no artifact or job is created)start_rpc_refuses_late_cold_start_of_rolled_back_attemptMutation check
We broke each guard on purpose, one at a time, and ran the tests again. A mutant is "killed" when at least one test fails.
INTERNALcreate.rsmaps toINTERNALstart.rsmaps toINTERNALM10 and M11 survive because the pre-check now refuses first. The claim refuses only if a receipt lands between the pre-check and the claim. Unit tests cannot reach the claim: it needs a node-manager, snapshot files under the fixed cache directory, or a template-manager. Both sites call the shared mapping, so M6 covers the mapping itself.
Live Kind runs
These runs used the PR before the review fixes and the rebase. The guard in #594 reruns this flow on Kind.
We ran SPACE on a local Kind cluster with real Firecracker microVMs. We used the public ECW/E2B SDK: create, write a file, pause (suspend), then connect (restore). A temporary local-only failpoint made one restore prepare fail with a disk-admission error. The failpoint is not part of this PR.
IDs below are synthetic labels. "New" means a snapshot that did not exist after the original suspend.
Before the fix (
main)In the same-attempt case, the control plane still listed the original artifact. The node had moved to the new one.
After the fix (this PR)
FAILED_PRECONDITION"attempt … was rolled back"; after eviction, 1 × "generation 2 was retired on this node"ABORTEDfrom the existing route fence; after eviction, 1 × "retired on this node""File and exec after" means the file written before the original suspend was still there, and a new command ran.
The first version of this PR fixed only the receipt check. The live run showed the eviction path: generation 2 was activated again, and a new snapshot was written. The second commit fixes that. The "after" table is from the second commit.
What the API does after FAILED_PRECONDITION
We did not see a
Supersededteardown outcome live. With this fix, generation 2 never starts, so its pod deletion does not call teardown. The unit test covers that outcome.Open issues (not in this PR)
FAILED_PRECONDITIONfrom the node as final and start a newer generation sooner.test-hooks-only sandbox-manager hook that fails one prepare for a named sandbox, a matching smoke fault operation, and a scenario that checks the successor restores the original snapshot. This would be a separate PR.Review guide
store/src/db/state_transitions.rs: the two new guards inbegin_activation_with_route_evidence.store/src/db.rsandstore/src/db/sandboxes.rs: the delete receipt now records the rolled-back generation and is written before the row delete.store/src/db/teardown_suspend.rs: the suspended-row branch, including a retained prior route identity.store/src/db/state_transitions.rs:activation_rollback_receipt, the one reader of the rollback receipt.server/src/service/start.rs: the pre-check (refuse_retired_activation) and the shared error mapping (begin_activation_status).server/src/service/create.rs: uses the shared mapping.AI disclosure
An AI agent (Perplexity Computer) wrote this description. It also wrote the code changes, the unit tests, the Kind simulations (including the local failpoint and test scripts), and the mutation checks. @nickdujay-pplx directed the work and reviews it.