Repository navigation
internal-api: recheck the failed projection's guards under the row lock - #337
Merged
Merged
Conversation
The `failed` node-report projection selected its candidates from the control-plane Postgres `public.sandboxes` table in an unlocked CTE and then updated by id. sbx-mgr's node-local SQLite `sandboxes` table is not involved. When a concurrent writer held a row, READ COMMITTED re-checked only `s.id = claimed.id` after the wait. A `failed` report therefore overwrote a committed `deleting`, a committed `quarantined`, or a row that had been unplaced as `suspended`, and reported a stale from-state. For a delete whose teardown then failed, the compensating update (`WHERE lifecycle = 'deleting'`) missed, the API returned 500 and the row stayed `failed`. The claim now takes `FOR NO KEY UPDATE OF s`, the mode the UPDATE already takes on the same rows. The node, `managed_by` and lifecycle guards are evaluated against the committed row once the lock is granted, and `claimed.lifecycle` is the lifecycle actually replaced. The projection moves into `project_node_published_failed_sandboxes` so its result is testable; its events and warning are unchanged apart from the accurate from-state. The internal-api AGENTS.md no longer lists the failed projection as an exception to the recheck-under-lock rule. It names the activation- completion receipt as one instead, fenced by the execution attempt it releases. Tests: a Postgres-backed interleaving holds the conflicting writer's row lock, waits on `pg_blocking_pids` until the projection is blocked, and commits. A delete, a quarantine and an unplacing suspend stand, and a still-qualifying pause becomes `failed`, reported as leaving `paused`.
ac-pplx
force-pushed
the
ac/fix-node-report-lifecycle-races
branch
from
October 13, 2026 03:10
bd25eca to
7dbc89e
Compare
ac-pplx
marked this pull request as ready for review
October 13, 2026 03:17
nikhilbirmiwal-pplx
approved these changes
Oct 13, 2026
This was referenced Oct 13, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Node publication (
PublishNodeState) projects each node's report onto the control-plane Postgrespublic.sandboxesrow. Other writers in api, internal-api and cluster-controller share that row. This PR makes thefailedprojection decide on the row it actually writes. sbx-mgr's node-local SQLitesandboxestable (state) is not involved. There is no schema change and no new metric.The failed projection overwrote a lifecycle committed while it waited
Mechanism. The projection read its candidates in an unlocked CTE (
claimed: the reporting node'sk8srows outsidefailed, quarantined, deleted, cleanup_required, deleting). It then ranUPDATE sandboxes s SET lifecycle = 'failed' FROM claimed WHERE s.id = claimed.id. If another transaction held the row, for example a user DELETE that takesFOR UPDATEand commitsdeleting, the UPDATE waited for it. Under READ COMMITTED, Postgres then re-checks only the UPDATE's ownWHERE, heres.id = claimed.id, so it wrotefailedover the committed row. The same race overwrote a committedquarantined, and a row that had meanwhile been unplaced assuspended(active_node_idNULL). TheSandboxFailedevent also reported a stale from-state.Impact.
WHERE lifecycle = 'deleting',crates/libs/api/src/controllers/sandbox/lifecycle.rs) matched nothing. The API returned 500 and the row stayedfailed.failed.failed.Fix. The claim now locks before it checks, as the suspended projection does:
FOR NO KEY UPDATE OF sin the CTE. A row another writer holds is re-read at its committed version once the lock is granted. The node,managed_byand lifecycle guards are evaluated against that version, andclaimed.lifecycleis the lifecycle the UPDATE replaces.Why this rather than repeating the guards in the UPDATE's
WHERE:RETURNING claimed.lifecyclewould still be the pre-wait value whenever the row changed but still qualified (for examplestarted→paused). Postgres before 18 cannot returnOLD.FOR NO KEY UPDATEis the mode the UPDATE already takes on these rows (lifecycleis in no unique index). The statement locks the same rows in the same mode as before; the lock is only taken earlier, during the claim's scan. UnlikeFOR UPDATE, it adds no conflict with theFOR KEY SHARElocks that foreign-key checks take.failed-status row.FOR UPDATE SKIP LOCKED.project_node_published_failed_sandboxesso its result can be tested. The caller emits the sameSandboxFailedevents and the same warning; only the from-state is now accurate.crates/libs/internal/api/server/AGENTS.mdno longer lists the failed projection as an exception to its recheck-under-lock rule. It now namesFOR NO KEY UPDATEas an accepted lock, lists the activation-completion receipt as the existing exception (it rechecks only the lifecycle and is fenced by the execution attempt it releases: generation, attempt,active), and calls the convergence "restoring-to-target".Test
published_state_lifecycle_race_tests::failed_projection_decides_on_the_row_committed_while_it_waitsis a new module, declared by one#[cfg(test)] modline inpublished_state.rs, and runs against the real Postgres fixture.startedk8srow and afailedreport.pg_backend_pid()and polls$holder = ANY(pg_blocking_pids($waiter))until the projection is blocked; both that wait and the join are bounded bytokio::time::timeout. Then the writer commits.lifecycleandactive_node_id, plus the transitions the projection returns.FOR UPDATE, thendeletingdeleting, nothing reportedfailed, reported fromstartedquarantinedquarantinedfailedsuspended,active_node_idNULLfailedpaused(control: still qualifies)failed, reported as leavingpausedstartedMutation. M1 drops
FOR NO KEY UPDATE OF s, which leaves exactly main's statement. The test fails in all four cases.The test's row and interleaving helpers duplicate helpers in #324's
lifecycle_writers.rs. Once both land, these cases fold into #324's interleaving table as afailedreport.Not fixed here: node drain's evacuation marker
Design input, not part of this change:
restoringrow that still carriesevacuation_restore_targettostarted/pausedbefore node drain's finalize runs: the activation-completion receipt,complete_wake_lifecycle, andfenced_completion_cas.sandboxes_clear_evacuation_restore_targettrigger then clears the marker. Drain's finalize (WHERE lifecycle = 'restoring') misses, reports a restore failure and skips re-pointing port exposures. The row leaves the restore-pending scan, so nothing retries.Unavailable, and it leaves the two wake writers.Interaction with #324
#324 (
ac/char-tests-internal-api-lifecycle-writers, checked at6e526a3) is tests only, and nothing in it breaks with this change. Its revised test files were overlaid on this head in a scratch worktree, without touching this branch. All 26published_state_tests, including its 9lifecycle_writerstests, pass.failed_report_moves_every_live_k8s_lifecycle_to_failedpublishes without a concurrent writer, so locking before checking changes no outcome. The from-set is unchanged.publication_waiting_on_a_row_lock_decides_on_the_committed_row) has nofailedreport, so it does not cover this race; the cases above are meant to join it.This PR does not touch #324's files.
Overlaps
published_state_tests.rsandpublished_state_tests/lifecycle_writers.rs; this PR edits neither.git diff origin/main -- crates/libs/clusteris empty).AGENTS.mdthat this PR amends.Validation
Head
7dbc89e, on currentmain.ci/scripts/run-rust-checks.sh fmtcargo clippy --locked --workspace --all-targets --all-features -- -D warningscargo nextest run --locked -p pplx-space-internal-api-server -p pplx-space-cluster-controller-server --retries 0,SPACE_CI_REQUIRE_TEST_DEPS=1, three consecutive runstests::concurrent_begin_artifact_backup_is_idempotentdid not fire.6e526a3, overlaidbd25eca, whose fresh-cluster run covered it:smoke-kind-e2e.shpassed;smoke-kind-typed.sh allpassed ECW 77/77 and native 23/23 with cold receipts set; drain and delete scenarios passed individually; and the deployed internal-api binary was shown to carry this statement.🤖 Generated with Claude Code