Skip to content

api: adopt a Direct sandbox's live record even when its receipt lags - #1218

Merged
ac-pplx merged 4 commits into
mainfrom
ac/direct-wake-adoption-fences
Nov 2, 2026
Merged

ac-pplx merged 4 commits into
mainfrom
ac/direct-wake-adoption-fences

Conversation

@ac-pplx

@ac-pplx ac-pplx commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A follow-up to #1169 and #1170. When a wake races a Direct node's idle suspend, the sandbox can keep running on that node while its row has no active node. #1169 adopts that running record instead of restoring around it, but only when two generations both equal the record's: the route identity's execution generation and the last reserved one. Two reachable shapes failed that check even though the record was the newest execution:

  • Lagging receipt. A wake reserves the next generation before it calls Wake, and saves the route receipt only after the response. If the node's suspend clears active_node_id and that wake's API process stops before saving the receipt, the record is one generation ahead of the route identity. Every later wake then restored instead of adopting. The record's node refused the restore's Start, and the restore moved on to other candidates: it either started a second copy from the older durable snapshot, or answered 503 when there was none.
  • Abandoned reservation. A second wake or an explicit restore reserves a generation, then abandons its restore. For example, the first wake's completion writes started over restoring and clears that restore's lease. The row is left started with no active node, and the last reserved generation is above the record's. No lookup adopted that row, so every runtime call answered 503.

Separately, after a cross-node restore, the cleanup of the source node's copy deleted a record whose lifecycle the API could not decode, treating it as not live. sandbox-manager publishes Unspecified for a label it cannot parse, and an API older than its nodes cannot decode a newer value. Either record may still hold a VM.

The change. Adoption now requires all of the following:

  • a route identity on the record's node whose execution generation is at most the record's;
  • a record generation at most the last reserved one;
  • on a row that holds a restore-lease token, a route identity, record and last reservation that are all one generation, as on main.

The reasoning:

  • On a tokenless row, no restore owner can still save a receipt or complete. Both steps check the token, which a claim writes and leaving restoring clears. A reservation above the record was therefore abandoned, or is a wake through the same node's Wake, which the adoption joins. An abandoned restore may still have started a VM on another worker (see Not covered).
  • A newer execution whose receipt landed on another worker would have moved the route identity there, because a receipt moves it only to the row's active node. A restore whose receipt was refused after its lease was cleared can leave an untracked copy on another worker. No route reaches that copy, so adopting the record's own node is still right; the copy exists on main too.
  • A restoring row that still holds a token (its lease expired) may have an owner whose Start ran and whose receipt has not landed. Adopting would clear the token that receipt checks, and an owner whose receipt is refused after a remote-snapshot restore deletes that destination, which may be the copy just adopted. Such a row keeps main's rule.
  • A record older than the route identity is still not adopted.

No started row with a trailing route identity on the wake path. The adoption writes started only when the route identity already names the execution it adopts; otherwise it keeps the read lifecycle, as it does for a record that is still moving. When a wake finds a Started record ahead of the route identity on a tokenless row, it first sends that record's worker a Wake and persists the receipt, then adopts the receipt's execution. That Wake carries no route identity, so it cannot start a new execution. A record that now needs one is restored instead. A catch-up Wake that cannot connect or answer places nothing, so the row stays unplaced and the next wake reads it again. A receipt naming the wrong sandbox or node clears the route identity, as on main's Wake path. This lives in wake_execution, so every caller gets it, including access_runtime_client (spaced connect, ports) and the spaced fallback wake, which do not complete the lifecycle themselves. The catch-up is a read plus the existing receipt write; the sandbox_writes baseline is unchanged.

The cross-node cleanup now deletes the source copy only when it reads a record there whose lifecycle is outside the live set (lifecycle_holds_live_runtime). It keeps a live, undecodable or unreadable record and logs a warning. A lifecycle outside the live set, such as Quarantined after an unconfirmed pause, does not prove the VM stopped; that behavior predates this change.

New adoption rule, relative to #1170's description. #1170 says adoption needs "the same execution generation". That is no longer true on a tokenless row. Adoption now needs a route identity on the record's node whose generation is at most the record's, not equal to it. Adoption therefore still sets active_node_id only to the node the route identity names, so #1170's point stands: adoption cannot make the route identity and active_node_id disagree on the node. On the wake path, the route identity's generation catches up before the row reads started.

Also in this change:

  • Doc comments: the probe_runtime_client and ingress probe docs now say the Direct lookup may put a started row with no active node back on the node running it. The delete-miss comment describes Direct's local-state lookup as the first of active_node_id and target_node_id that is set.
  • Node-api simulator: when a record seeded as Direct needs no Wake transition, the simulator now reports the record's route identity, as sandbox-manager does. It can also fail the next Wake of a sandbox, for tests. The idle-suspend tests seed the record with the route identity's activation attempt, so the receipt matches what sandbox-manager would return.

Validation

At a9cb40c4 on top of 58fe107e. Main has since gained #1213, which changes the simulator and lifecycle.rs without conflicting with this change:

  • Fails without the fix. With only the test changes applied to the previous head's code, cargo nextest run -p pplx-space-api --all-features -E 'test(idle_suspended)' failed 2 of 10:
    • wake_adopts_a_started_record_only_at_its_route_identity: after a wake whose Wake failed, the row was started and placed while its route identity still named generation 1. This held for both wake_execution and access_runtime_client; each failed when run as the table's first case. Its third case, a successful access wake, passed there and guards the access path.
    • wake_adopts_a_live_record_on_its_artifact_node, at the case "restoring row whose expired owner reserved the record's generation": the token-holding row was adopted instead of restored around.
  • Fails on main (checked in the previous round at dc8e48b3, not re-run at this head). With only the test file applied to main's code, the same command failed at the cases "live record ahead of a receipt its wake never persisted" (the wake did not reach the record's worker) and "record at the receipt, below an abandoned reservation" ("is not assigned to an active worker node"). With the cleanup's undecodable-lifecycle arm removed, the case "record whose lifecycle the API cannot decode" failed because the source copy was deleted.
  • Passes here. With the full change, all 10 idle_suspended tests pass. Disabling only the catch-up makes wake_adopts_a_started_record_only_at_its_route_identity and the lagging-receipt case fail again.
  • Full suite. cargo nextest run -p pplx-space-api -p pplx-space-node-api-sim -p pplx-space-cluster-controller-server -p pplx-space-internal-api-server --all-features: 3784 passed, 1 skipped. The skip is the ignored benchmark_single_node_occupancy. Postgres and Redis were started by nextest's setup scripts, with no dependency skips.
  • Lint. cargo clippy on the same packages with --all-features --all-targets -- -D warnings is clean, and cargo fmt --all -- --check is clean.
  • Architecture. cargo arch --base-baseline <main's architecture-baseline.toml> --base-architecture <main's ARCHITECTURE.toml>: 0 required findings. The sandbox_writes baseline is unchanged (direct.rs stays at 6). direct.rs gets larger; it was already over its advisory size cap on main.
  • Direct-on-Kind. SPACE_KIND_SUBSTRATE=direct SPACE_KIND_STORAGE_CREDENTIALS=static infra/space/kind/scripts/up.sh, then the runs below. Environment: SPACE_SMOKE_SUBSTRATE=direct SPACE_KIND_SUBSTRATE=direct SPACE_KIND_STORAGE_CREDENTIALS=static SKIP_BOOTSTRAP=1 SPACE_SMOKE_BACKEND=kind, the admin token and SPACE_DATA_PLANE_URL set as scripts/tests/smoke-kind-typed.sh does, and the native surface for native_contract.*. Five runs each, all pass (35/35):
    • ecw.pause_timeout_requires_connect
    • ecw.pause_connect
    • ecw.access_idle_reconnect_preserves_workspace
    • native_contract.command_wakes_idle_suspended_sandbox
    • native_contract.process_session_wakes_idle_suspended_sandbox
    • native_contract.session_reattach_wakes_idle_suspended_sandbox
    • native_contract.session_reconnect_after_idle_suspend
  • Kubernetes on Kind. Default up.sh, then one run of ecw.pause_connect: pass.

The Kind runs show the idle-suspend wake paths still work. They do not reproduce the race shapes, which need a wake whose API process stops mid-wake, a Wake that fails after the record read, or a concurrent wake during the suspend. The Postgres-backed tests above cover those shapes.

Severity and risk

Severity: low to moderate, and Direct only. Hitting it takes the wake-vs-idle-suspend race plus an interrupted or concurrent wake. When it hits, the sandbox either runs a second copy from an older snapshot or answers 503 on every runtime call until someone intervenes.

Risk: narrow. The fence is a single guarded UPDATE, and it is relaxed only where no restore owner can still record a newer execution. Rows that still hold a restore-lease token keep main's rule. The cost is main's behavior for a restore owner that stopped after its Start and before its receipt: a later wake restores around that record, and the cross-node cleanup keeps the copy. A wake that finds a Started record ahead of the route identity now sends one extra Wake to that record's worker before it adopts; a failure there fails the wake without placing the row. The cleanup change only keeps more source copies than before: an undecodable record now stays on its node instead of being deleted. Kubernetes is unaffected. Rollback: revert these commits.

Rollout

No migration and no flag. The change is in the API, plus a test-only simulator change; sandbox-manager is unchanged. While old and new API replicas run together, they run different WHERE clauses. An old replica applies the strict fence and may restore around a record a new replica would adopt; that is today's behavior. Both adoptions are a single UPDATE guarded on active_node_id IS NULL, so the first writer wins and the other joins through active_sandbox_node. Old replicas keep deleting an undecodable source copy until they are replaced.

Not covered

  • An adoption on the lookup path (a started row with no active node) issues no Wake, so it does not catch the route identity up. A started row whose route identity trails its record can still reach the lookup, for example when a restore started the record on its own node and an earlier wake's completion cleared that restore's lease before its receipt. After the adoption, the node refuses route tokens minted from the older identity until a later Wake persists a newer receipt, such as a wake after the node next suspends it.
  • A restore whose receipt is refused after an earlier wake's completion cleared its lease can leave an untracked copy on another worker; its destination delete is best effort, and nothing runs if the API process stops first. The concurrent-wake behavior that causes it, and the wake-vs-node-suspend race itself, are tracked separately.
  • The cross-node cleanup still deletes a Quarantined, Failed, CleanupRequired, CleanupFailed or Deleting source copy, as on main. Whether to keep some of those is a follow-up.
  • A restore that claims the lease and releases it between the catch-up's check and the adoption lets the adoption keep suspended. On the access paths, which do not complete the lifecycle, the row then reads suspended while it runs, as it already does on main for a record that was still moving when adopted. This belongs with the concurrent-wake behavior above.
  • The catch-up Wake, like the Wake that follows an adoption, is bounded by the node RPC timeout rather than the wake's deadline.
  • No deployment-level reproduction of the races themselves; see Validation.

AI assistance: Claude Code wrote the fix and its tests, applied it to main, ran the validation and drafted this description.

A wake that races a Direct node's idle suspend can leave the sandbox
running on that node while the row is unplaced. The API adopts such a
record instead of restoring around it (#1169), but only when the route
identity's execution generation and the last reserved one both equal
the record's. Two reachable shapes failed that fence while the record
was the newest execution:

- A placed wake reserves the next generation before its Wake and
  persists the route receipt only after the response. If the node's
  suspend projection clears active_node_id and that wake's API process
  stops before the receipt, the record runs one generation ahead of the
  route identity. Every later wake restored instead: the record's node
  refused the restore's Start for a different execution, and the
  candidates moved on, running a second copy from the older durable
  snapshot or answering 503 without one.
- A second wake or an explicit restore reserves a generation that its
  restore then abandons, for example when the first wake's completion
  writes started over restoring and clears its lease. The row is left
  started with no active node and the last reserved generation above
  the record, which no lookup adopted, so every runtime call answered
  503.

The adoption now fences on a route identity on the record's node whose
execution generation is at most the record's, and a record generation at
most the last one reserved, and equal to it unless the row holds no
restore-lease token. On a tokenless row no restore owner can still
persist a receipt or complete, since both fence on the token, which a
claim writes and leaving restoring clears; a reservation above the
record is abandoned, or a wake through the same node's Wake, which
joins. A newer execution on another worker would have moved the route
identity there, because a receipt moves it only to the row's active
node. A restoring row that still holds a token, its lease expired, keeps
the strict rule, and a record older than the route identity is still not
adopted. After a wake-path adoption the Wake's receipt catches the route
identity up to the record.

A cross-node restore's source cleanup treated a record whose lifecycle it
could not decode as not live and deleted it. sandbox-manager publishes
Unspecified for a label it cannot parse, and an API older than its nodes
cannot decode a newer value. The cleanup now deletes only a record whose
lifecycle decodes to one that holds no VM and keeps an undecodable one
with its own warning.

The probe_runtime_client and ingress probe docs now say that on Direct
the lookup may place a started row with no active node back on the node
running it, and the delete-miss comment no longer says a suspended
Direct row is never contacted.

The node-api simulator's Wake of a Direct record that needs no
transition now reports the record's route identity, as sandbox-manager
does, so the tests see the receipt catch up.
@github-actions github-actions Bot added the triage:pending Waiting for a maintainer to triage label Oct 31, 2026
@github-actions

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.

The adoption fence relaxed the route identity's execution generation
for every row, including a restoring row that still holds a
restore-lease token after its lease expired. Such a row may have an
owner whose Start ran and whose receipt has not landed. Adopting it
writes started and clears the token that receipt fences on; an owner
whose receipt is then refused after a remote-snapshot restore deletes
that destination, which may be the copy the adoption just placed the
row on.

A row that holds a restore-lease token is now adopted only when the
route identity, the record and the last reservation are one execution
generation, as before the relaxation. Only a tokenless row is adopted
with a route identity that trails its record. The cost is the older
behavior for an owner that stopped after its Start and before its
receipt: the wake restores around the record, and the cross-node
cleanup keeps that copy.
A wake that adopted a Started record ahead of the route identity wrote
started, active_node_id and the placement in one statement, and only
the Wake after it persisted the receipt that moves the route identity
to the record's execution generation. A wake that failed between the
two left the row started on the node while the route identity still
named the older execution. Route tokens minted from that identity, by
the API's spaced bridge and by data-plane session routes, are refused
by the node as a different execution generation. An implicit access
does not wake a started row, so nothing caught the route identity up
until a later Wake persisted a newer receipt.

On the wake path, when the route identity on the record's node trails
a Started record on a tokenless row that still has the lifecycle the
wake read, the wake now sends that record's worker a Wake first and
persists its receipt, then adopts the receipt's execution. That Wake
carries no route identity, so it cannot start a new execution: a
record that now needs one is restored instead, and a Wake that fails
commits nothing, leaving the row unplaced for the next wake. The
adoption statement itself writes started only when the route identity
names the execution it adopts; otherwise it keeps the read lifecycle,
as it does for a record still moving. Every caller of wake_execution
gets this, including access_runtime_client and the spaced fallback
wake, which do not complete the lifecycle themselves.

An adoption on the lookup path issues no Wake and still places a
started row whose route identity trails its record; the node's next
suspend projects it and a later wake repairs the route identity.

The node-api simulator can now fail the next Wake of a sandbox, and
the idle-suspend tests seed the resident with the route identity's
activation attempt, so a Wake that needs no transition returns the
receipt sandbox-manager would.
Doc and comment wording only.

The cross-node restore's source cleanup deletes any record whose
lifecycle decodes outside the live set (lifecycle_holds_live_runtime),
which includes Quarantined, Failed, CleanupRequired, CleanupFailed and
Deleting. A quarantine after an unconfirmed pause does not establish
that the VM stopped, so the docs no longer say the cleanup deletes only
a copy that holds no VM. They say it deletes a record outside the live
set and keeps a live, undecodable or unreadable one.

The adoption's reasoning now says that only a newer execution whose
receipt landed on another worker would have moved the route identity
there. A restore whose receipt was refused after its lease was cleared
can leave an untracked copy on another worker, and an abandoned
reservation may still have started one. No route reaches such a copy,
so adopting the record's own node is still right.

The delete-miss comment describes Direct's local-state lookup as the
first of active_node_id and target_node_id that is set, and the probe
contract names the Direct adoption as the one routing-evidence change
a probe may make.
@ac-pplx
ac-pplx marked this pull request as ready for review October 31, 2026 05:34
zibi-pplx
zibi-pplx previously approved these changes Nov 1, 2026

@zibi-pplx zibi-pplx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The adoption fence changes look right for the wake path. I found one restore path that still bypasses adoption, plus a doc question.

  • [medium] An explicit Restore still restores around a live record (pre-existing on main)
    crates/libs/api/src/controllers/substrate/direct.rs:4624-4636: restore_execution goes straight to restore_execution_with_candidates and never reads the source record or calls adopt_live_source_record. The adoption and catch-up added here only run from wake_execution and sandbox_node. A native restore and ECW resume both reach restore_execution, because ECW resume maps to SandboxAction::Restore (routes/e2b/lifecycle.rs:91).
    It can still start a second copy when all of these hold:

    • the row is suspended;
    • the source node still runs a live record from the wake-vs-idle-suspend race;
    • the source refuses the restore's Start with FailedPrecondition, which should_try_next_restore_candidate (direct.rs:307) treats as "try the next candidate";
    • another candidate has a usable remote snapshot.

    The sandbox then restores elsewhere from the older snapshot, and the source cleanup keeps the live copy. That is the outcome this PR prevents for wakes.
    Suggestion: before claiming the lease, have Direct's restore_execution read the source record the same way wake_execution does, and adopt it under the same fence. Keep the fix in the substrate; the ECW resume handler should keep mapping to Restore, not to a wake. If that is out of scope here, a tracking issue linked from the "Not covered" list would do.

  • [low] Is AccessBackend::reachable still a read-only probe?
    crates/libs/api/src/controllers/sandbox/ingress.rs:38-41: the doc still opens with "Read-only probe" and now adds that on Direct the lookup may re-place a row. That re-placement is an UPDATE of active_node_id, target_node_id and placement, behind a node StateRequest that can take up to DIRECT_SOURCE_RECORD_READ_TIMEOUT (3 s). Should the probe skip the adoption, for example through a lookup that doesn't adopt? Or should the trait doc drop "read-only" so callers don't rely on it?

@zibi-pplx
zibi-pplx dismissed their stale review November 1, 2026 22:29

Withdrawing this approval for now. Any feedback above still stands.

@mabdelraouf-pplx mabdelraouf-pplx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving. Please follow up on these two fixes:

  1. Keep the started lookup fence strict unless the route identity has been caught up. sandbox_node() currently uses the relaxed route <= record <= last adoption without running catch_up_route_identity(), so a started row can be placed while its route identity still trails the execution. Normal access then skips Wake and presents stale route tokens. Either catch up on lookup or require route == record there, and add a PostgreSQL-backed case for route < record.

  2. Bound catch_up_route_identity() end to end by the caller's remaining deadline. The new fresh connection and Wake use the default node RPC timeout; only the capability check uses the passed budget. Carry one deadline across connect, capability check, and Wake so a stalled catch-up cannot outlive the access or wake request.

@ac-pplx
ac-pplx added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit 6ab2999 Nov 2, 2026
23 checks passed
@ac-pplx
ac-pplx deleted the ac/direct-wake-adoption-fences branch November 2, 2026 19:02
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.

3 participants