Repository navigation
internal-api: characterize node publication's lifecycle writers (tests only) - #324
Merged
Merged
Conversation
ac-pplx
force-pushed
the
ac/char-tests-internal-api-lifecycle-writers
branch
from
October 12, 2026 21:59
16433b4 to
7d544a3
Compare
…s only) Pin, against the real Postgres fixture and through `publish_sandbox_states` run in one transaction as `publish_node_state` runs it, which stored values of Postgres `sandboxes.lifecycle` each node-publication projection moves, what it moves them to, and what moves with them: placement, `active_node_id` and `target_node_id`, the `sandbox_k8s_executions` row, the Postgres quarantine tables and open port exposures. No production code changes; the only edit outside test code is the `mod` line that wires the new module into `published_state_tests`. Each projection gets one sweep over every `SandboxLifecycle` variant plus the stored non-variants `deleted` and `cleanup_required`; the `failed`, `quarantined`, activation, `suspended`-report and ready-backup projections also get a report from a node the row is not active on, which moves nothing: - `failed`: only `k8s` rows, out of every lifecycle but `failed`, `quarantined`, `deleting` and the non-variants. - `quarantined`: either substrate, out of every lifecycle but `quarantined` and `deleted`, `deleting` included as the projection documents, with the Postgres quarantine state and history rows. - Activation completion: the matching attempt is released whatever the lifecycle; a `started` or `paused` target is projected `on_node` outside the suspend window, `quarantined`, `deleting` and the non-variants; receipts for another attempt or generation are inert. - Activation rollback: `created`, `stopped` or `suspended` targets converge out of every lifecycle but `quarantined`, `deleting` and the non-variants, unplacing the row, backfilling an empty `target_node_id`, retiring the execution and resetting only open port exposures; a receipt for another generation is inert. - `suspended` projection: with a current durable snapshot, out of `started`, `paused` and `restoring` only, for either substrate. The ready-backup projection that `finalize_artifact_backup` runs moves the same set, plus a `suspending` row whose execution is `suspended` and whose marker names the backed-up artifact, and neither fact alone. - Quarantine-recovery receipt: suspends a row only while it is still `quarantined`. - Pause-failed quarantine recovery: only `quarantined` rows recover to the reported `started` or `paused`. The existing `started`/`stopped` convergence sweep now covers `k8s` rows with a routable execution as well as `space` rows, and publishes in a transaction. A row-lock interleaving test runs each publication on a connection whose backend it polls through `pg_blocking_pids` under a timeout. It pins that a `stopped` report and an activation-completion receipt waiting behind a node-drain evacuation claim leave the claim's `suspending` and marker standing (the receipt's attempt is still released), that a `quarantined` report records nothing behind a committed `stopped` or a committed move of `active_node_id` to another node, and that each moves the row as usual when the other writer rolls back. Every guard was mutation-checked against the whole package, one mutation at a time, and each mutation was reverted.
ac-pplx
force-pushed
the
ac/char-tests-internal-api-lifecycle-writers
branch
from
October 12, 2026 22:32
7d544a3 to
6e526a3
Compare
ac-pplx
marked this pull request as ready for review
October 12, 2026 22:59
Collaborator
Review — 4 findingsLow
Verified against |
the-gigi-pplx
approved these changes
Oct 13, 2026
the-gigi-pplx
left a comment
Collaborator
There was a problem hiding this comment.
Approved. 4 non-blocking findings in the review comment above.
johnlouie-pplx
approved these changes
Oct 13, 2026
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.
Adds characterization tests for node publication's writes to the Postgres control-plane
sandboxes.lifecyclecolumn and what moves with it. Node publication is the bridge between the two stores: sbx-mgr publishes its node-local SQLite state, and internal-api moves Postgreslifecyclefrom those reports. Throughpublish_sandbox_statesagainst the real Postgres fixture, the tests pin which stored lifecycles each projection moves, what it moves them to, and what else changes:active_node_idandtarget_node_id;sandbox_k8s_executionsrow;sandbox_quarantine_stateandsandbox_quarantine_historyrows;Every publication in these tests, including the extended convergence sweep, runs in one transaction, as
publish_node_stateruns it, and rows are read after the commit. The tests stand in for the node by building thePublishedSandboxStateit would send; the sbx-mgr SQLite store is not touched.Tests only. No production code changes. The new module is wired by one
modline in the test filepublished_state_tests.rs, and the existing convergence sweep in that file is extended.What the tests cover
Each projection gets:
SandboxLifecyclevariants plus the stored non-variantsdeletedandcleanup_required;suspendedreport), W7, W8 and W14, a report from a second ready node that the row is not active on, which must leave the row unchanged. W9's node refusal is pinned by the existingnode_inventory_only_recovers_sbx_manager_pause_failed_quarantines(tests/published_state.rs), and W2's bypublishing_started_sandbox_from_non_active_node_does_not_converge. W6's node guard has no refusing test, here or elsewhere;Assertions read the persisted Postgres rows, not SQL text, logs or metrics.
node_publication_converges_only_from_its_source_lifecycles(existing) now sweepsk8srows with a routable execution as well asspacerows, for therestoring→startedand→ stoppedconvergences.failed_report_moves_every_live_k8s_lifecycle_to_failedquarantined_report_moves_every_unquarantined_lifecycle_and_records_itpinsdeleting→quarantined, which the projection documents as intended.cleanup_requiredis not swept, since no Postgres writer stores it.activation_completion_receipt_projects_its_target_outside_the_suspend_window: receipts for another attempt, another generation or another node release nothing and move nothing.activation_rollback_receipt_converges_its_target_out_of_every_live_lifecycle:target_node_idis seeded NULL so its backfill is observable;suspended_report_projects_only_out_of_running_and_restoring_lifecycles:target_node_idis seeded NULL;spacerows carry no execution.ready_backup_projects_suspended_only_out_of_running_and_restoring_lifecyclesdrivesproject_ready_suspend_snapshotin a transaction, asfinalize_artifact_backupdoes:suspendingrow is projected only when its execution issuspendedand its marker names the backed-up artifact;recovery_receipt_suspends_only_a_still_quarantined_rowhealthy_report_recovers_only_a_pause_failed_quarantinepublication_waiting_on_a_row_lock_decides_on_the_committed_row:stoppedreport and an activation-completion receipt behind a node-drain evacuation claim; aquarantinedreport behind a committedstopped; and aquarantinedreport behind a committed move ofactive_node_idto another node with the lifecycle unchanged, aspersist_pod_node_placementmakes;writer = ANY(pg_blocking_pids(publication))and bounds both the wait and the join with a timeout;Inventory: internal-api writers of Postgres
sandboxes.lifecyclepublish_node_state→publish_sandbox_statesruns W1–W9 in this order inside one transaction, then W12 and W13. Each statement sees the earlier ones' writes; W4 and W5 nullactive_node_id, so later node-guarded projections miss those rows.The
BEFORE UPDATEtriggers on Postgressandboxesfire on every write:lifecycle_changed_at;evacuation_restore_targetoutsidesuspending/suspended/restoring;preserve_local_artifact_on_restore_failureoutsiderestoring;command_ready_pendingon a move away fromstarted.published_state.rsabsent-row deletestoppedactive_node_id= node,managed_by = 'space', publishedpresent = falsepublishing_absent_stopped_space_sandbox_deletes_central_rowrestoringstartedk8s)RestoreCompletedeventnode_publication_converges_only_from_its_source_lifecycles; existing routability and marker testssandbox_quarantine_store.rsconsume_activation_completion_receiptssuspending, suspended, quarantined, deleting, deleted, cleanup_required; acreatedtarget only oncreatedstarted/paused(placement = 'on_node') orcreatedstarted/pausedrunning with a pod IP; no marker guardactivation_completion_receipt_projects_its_target_outside_the_suspend_window(with attempt, generation and node refusals), interleaving test (see finding 3)select_activation_rollback_candidates+apply_activation_rollback_candidatesquarantined, deleted, deleting, cleanup_requiredcreated/stopped/suspendedFOR UPDATE); published status = targetplacement = 'unplaced',active_node_idNULL,target_node_idbackfilled,command_ready_pendingcleared; execution retiredsuspendedorreplaced; open port exposures reset; a lost row after selection aborts the publicationactivation_rollback_receipt_converges_its_target_out_of_every_live_lifecycle(with port exposures, a NULLtarget_node_id, generation and node refusals)select_suspended_projection_candidates+apply_suspended_k8s_finalizationstarted,paused,restoringwith a current durable suspend snapshot;suspendingwith committed or exact node evidence;quarantinedthrough a recovery receiptsuspendedFOR UPDATE); nomanaged_byfilter; arestoringrow with an unconsumed recovery receipt waits for itactive_node_idNULL, an emptytarget_node_idbackfilled from it, artifact marker set or cleared; execution retiredsuspended; open port exposures reset; warm-pool slots retired; recovery receipts write arecovered_to_suspendedhistory row and delete the quarantine statesuspended_report_projects_only_out_of_running_and_restoring_lifecycles(with node refusal),recovery_receipt_suspends_only_a_still_quarantined_row; existing evidence testspublished_state.rsstopped promotionstarted, paused, suspended, stopping, restoringstoppedfailed, quarantined, deleting, deleted, cleanup_requiredfailedmanaged_by = 'k8s'; the from-set is read by an unlocked CTEfailed_report_moves_every_live_k8s_lifecycle_to_failed(see finding 1)quarantined,deletedquarantinedentered_quarantinehistoryquarantined_report_moves_every_unquarantined_lifecycle_and_records_it(includingdeleting, which the projection documents as intended), interleaving testrecover_pause_failed_from_inventoryquarantinedwith an sbx-mgrpause_failedquarantinestarted/pausedFOR UPDATE)recovered_to_*; quarantine state deleted; observed failure reason cleared; a lost CAS aborts the publicationhealthy_report_recovers_only_a_pause_failed_quarantine; existing source/reason/node refusalsstatus.rsprune_absent_node_sandboxesdeleted,deletingdeletedtombstonemanaged_by = 'space', absent from a complete censuscomplete_inventory_marks_missing_space_sandbox_deletedauto_lifecycle_delete_completions.rscreatingsnapshotfinalize_sandbox_db_delete_txauto_lifecycle_delete_completions/tests.rsproject_ready_suspend_snapshot(viafinalize_artifact_backup)started,paused,restoring;suspendingwhose own suspend committedsuspendedready_backup_projects_suspended_only_out_of_running_and_restoring_lifecycles(each commit fact alone refused; another source node refused)Publication also enqueues node reconcile triggers (
enqueue_triggers_conn) for new inventory. Those writes go to the Postgres trigger queue and do not touchsandboxes.lifecycle.Tests → guard → mutation
Each mutation was applied to production code, the whole internal-api package was run against Postgres (316 tests), and the file was restored. The harness ran one mutation at a time with its own backup file. The table lists every test that failed.
Four mutations survive by construction, because production repeats the guard in a statement that runs first or re-checks the row:
quarantinedfrom the rollback apply's exclusion. The rollback select already excludes it, so the apply never sees such a row. Q5, the select-side mutation, is killed.quarantinedprojection's node guard from its claim, a plainSELECTwithout a row lock. The CAS repeats the guard on the row version it writes. P10, which drops the CAS's copy, is killed by the interleaving case in whichactive_node_idmoves while the report waits. P13 drops both copies and is also killed.published_state.rs: failed: managed_by = 'k8s' droppedfailed_report_moves_every_live_k8s_lifecycle_to_failed,publishing_direct_failed_sandbox_does_not_mark_central_lifecycle_failedpublished_state.rs: failed: quarantined no longer excludedfailed_report_moves_every_live_k8s_lifecycle_to_failedpublished_state.rs: failed: reporting-node guard droppedfailed_report_moves_every_live_k8s_lifecycle_to_failed,publishing_failed_sandbox_from_non_active_node_does_not_mark_central_lifecycle_failedpublished_state.rs: quarantined: claim no longer excludes quarantinedquarantined_report_moves_every_unquarantined_lifecycle_and_records_itpublished_state.rs: quarantined: CAS no longer rechecks the claimed lifecyclepublication_waiting_on_a_row_lock_decides_on_the_committed_rowpublished_state.rs: restoring -> started: k8s rows excluded (only the k8s extension of the sweep sees it)healthy_report_recovers_only_a_pause_failed_quarantine,node_publication_converges_only_from_its_source_lifecycles,publishing_owned_started_sandbox_converges_restoring_lifecyclesandbox_quarantine_store.rs: completion: suspending no longer excludedactivation_completion_receipt_cannot_overwrite_a_newer_suspend_claim,activation_completion_receipt_projects_its_target_outside_the_suspend_window,publication_waiting_on_a_row_lock_decides_on_the_committed_rowsandbox_quarantine_store.rs: completion: placement no longer set on_nodeactivation_completion_receipt_projects_its_target_outside_the_suspend_windowsandbox_quarantine_store.rs: completion: execution generation fence droppedactivation_completion_receipt_projects_its_target_outside_the_suspend_windowsandbox_quarantine_store.rs: rollback select: quarantined no longer excludedactivation_rollback_receipt_converges_its_target_out_of_every_live_lifecyclesandbox_quarantine_store.rs: rollback: target_node_id backfill droppedactivation_rollback_receipt_converges_its_target_out_of_every_live_lifecyclesandbox_quarantine_store.rs: rollback: execution always retired as replacedactivation_rollback_receipts_converge_stable_targets_with_attempt_fence,activation_rollback_receipt_converges_its_target_out_of_every_live_lifecyclesandbox_quarantine_store.rs: pause-failed recovery select: lifecycle guard droppedhealthy_report_recovers_only_a_pause_failed_quarantinesandbox-portability lib.rs: suspended projection: from-set widened (sandbox-portability, shared by the publication and ready-backup projections)ready_backup_projects_suspended_only_out_of_running_and_restoring_lifecycles,suspended_report_projects_only_out_of_running_and_restoring_lifecyclespublished_state.rs: ready-backup projection: committed-suspend evidence never matchesready_backup_projects_suspended_only_out_of_running_and_restoring_lifecycles,finalize_suspend_artifact_projects_committed_suspendsandbox_quarantine_store.rs: recovery receipt: lifecycle = 'quarantined' droppedrecovery_receipt_suspends_only_a_still_quarantined_rowsandbox_quarantine_store.rs: rollback apply: quarantined no longer excluded (shadowed by the select, which excludes it first)published_state.rs: quarantined: deleting excludedquarantined_report_moves_every_unquarantined_lifecycle_and_records_itpublished_state.rs: quarantined: claim node guard dropped (the CAS re-checks it)published_state.rs: quarantined: CAS node guard droppedpublication_waiting_on_a_row_lock_decides_on_the_committed_rowpublished_state.rs: ready-backup projection: source-node = active-node guard droppedready_backup_projects_suspended_only_out_of_running_and_restoring_lifecyclespublished_state.rs: ready-backup projection: committed-artifact evidence always matchesready_backup_projects_suspended_only_out_of_running_and_restoring_lifecycles,finalize_suspend_artifact_without_committed_marker_preserves_suspendsandbox_quarantine_store.rs: completion: reporting-node guard droppedactivation_completion_receipt_projects_its_target_outside_the_suspend_windowsandbox_quarantine_store.rs: rollback select: reporting-node guard droppedactivation_rollback_receipt_converges_its_target_out_of_every_live_lifecyclesandbox_quarantine_store.rs: rollback select: generation fence dropped (the retire repeats it)sandbox_quarantine_store.rs: rollback retire: generation fence dropped (the select repeats it)sandbox_quarantine_store.rs: rollback: port-exposure reset no longer limited to open exposuresactivation_rollback_receipt_converges_its_target_out_of_every_live_lifecyclesandbox_quarantine_store.rs: suspended projection: reporting-node guard droppedsuspended_report_projects_only_out_of_running_and_restoring_lifecyclessandbox-portability lib.rs: suspended finalization: target_node_id backfill dropped (sandbox-portability)ready_backup_projects_suspended_only_out_of_running_and_restoring_lifecycles,suspended_report_projects_only_out_of_running_and_restoring_lifecyclessandbox-portability lib.rs: suspended projection: suspending row no longer needs its execution suspended (sandbox-portability)ready_backup_projects_suspended_only_out_of_running_and_restoring_lifecyclespublished_state.rs: quarantined: node guard dropped from both the claim and the CASquarantined_report_moves_every_unquarantined_lifecycle_and_records_itsandbox_quarantine_store.rs: rollback: generation fence dropped from both the select and the retireactivation_rollback_receipt_converges_its_target_out_of_every_live_lifecycleFindings
Reproduced against the Postgres fixture with throwaway tests and not pinned. Neither is fixed here.
failedreport can overwrite a lifecycle committed while it waits for the row lock.managed_by = 'k8s'in a CTE without a row lock, then updatesWHERE s.id = claimed.id. After a lock wait, Postgres re-checks only the UPDATE target's own conditions, so none of the CTE's filters is re-checked.deleting, a concurrently committedquarantined, and a row a concurrent suspend finalization leftsuspendedand unplaced (active_node_idNULL). All becomefailed.quarantinedCAS re-checks its claimed lifecycle and holds in the same race, which the interleaving test pins.WHERE lifecycle = 'deleting'misses, the API returns 500, and the row staysfailed.UPDATE sandboxes SET lifecycle = 'deleting'(or'quarantined', or'suspended'withactive_node_id = NULL, placement = 'unplaced') uncommitted on astartedk8srow. Publish afailedreport from its node. Wait untilpg_blocking_pidsshows the publication blocked, then commit. The row endsfailed.restoringrow, because node drain owns it. W3 has no marker guard: the drain's restore activation completes with a receipt whose target equals the drain's target, so W3 moves the row to that target and the trigger clearsevacuation_restore_target. The final lifecycle is right.WHERE lifecycle = 'restoring') then misses. It emitsLifecycleTransitionCasLostand reports a restore failure, and it skips the post-restore port-exposure reconcile. The row also drops out of the restore-pending scan.evacuation_restore_target = 'paused'on arestoringrow with a matching activation attempt targetingpaused, and publish the completion receipt. The row becomespausedwith no marker. The same row with a plainstartedreport staysrestoring.Not a defect: a
quarantinedreport moves adeletingrow toquarantined. The projection documents it ("Quarantine is a stronger hold than cleanup/delete intent … Deleted still wins"), and a later delete re-claimsdeletingfrom any lifecycle, so the delete intent is not lost. It is pinned.cleanup_requiredis unreachable in Postgres (only sbx-mgr's SQLite stores it), so the quarantined sweep leaves it out.Pre-existing:
cargo nextest run -p pplx-space-internal-api-serverdoes not compile on its own.data_plane_sessions/tests.rsusestokio::runtime::Builder::start_paused, whosetest-utilfeature the crate's dev-dependencies do not enable; it compiles in workspace runs through feature unification. Package runs here therefore also selectpplx-space-cluster-controller-serverfor the feature and filter topackage(pplx-space-internal-api-server).Not characterized here
These keep their existing tests and are not swept by lifecycle here:
suspendingbranch;Host safety
The tests use the Postgres fixture only. Publication is called in process with built reports. No sockets, node-api clients, host paths, token files or processes are involved, on the normal or the mutation runs.
Overlap with open pull requests
published_state_tests.rs, in a one-line hunk near the top. This PR edits the convergence sweep in the middle of the file and appends the module line at its end.published_state.rs, and fix(sandbox): wait before retrying failed activations #3 editssandbox_quarantine_store.rsandtests/published_state.rs. This PR touches none of those files.Validation
ci/scripts/run-rust-checks.sh fmt(env contract, error policy,cargo fmt --check)cargo 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 -E 'package(pplx-space-internal-api-server)',SPACE_CI_REQUIRE_TEST_DEPS=1, three consecutive runsscripts/source_guards/{env_test_hook_boundary,node_selector_extras_smoke,observability_vendor_boundary}.pyscripts/check_no_inline_rust_test_modules.py,scripts/source_guards/thin_service_mains.pyobservability/src/{otlp_endpoint,selection}.rsandapps/vol/mgr/svc/src/main.rs, which this PR does not touchpublished_state_tests.rsand the newpublished_state_tests/lifecycle_writers.rsAI-assisted: written with Claude Code; every result above was produced by the commands listed.
🤖 Generated with Claude Code