Skip to content

cluster-controller: characterize the lifecycle writers (tests only) - #323

Merged
ac-pplx merged 1 commit into
mainfrom
ac/char-tests-cluster-lifecycle-writers
Oct 13, 2026
Merged

ac-pplx merged 1 commit into
mainfrom
ac/char-tests-cluster-lifecycle-writers

Conversation

@ac-pplx

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

Copy link
Copy Markdown
Contributor

Adds characterization tests for the cluster-controller's writers of the Postgres control-plane sandboxes.lifecycle column and the columns that move with it. The tests pin, for each writer below, which stored lifecycles it moves, what it moves them to and what else changes, so a later change to lifecycle ownership has an executable spec. Nothing here touches the node-local sbx-mgr SQLite sandboxes table.

Tests only. No production code changes. The only edits to production files are four #[cfg(test)] mod lines:

  • lib.rs: the shared fixture module lifecycle_writer_test_support;
  • transient_lifecycle_sweeper/mod.rs, node_drain/store.rs and orphaned_sandboxes.rs: one lifecycle_writer_tests module each.

One existing test is corrected (see below).

What the tests cover

Each characterized writer gets one sweep over all 16 SandboxLifecycle variants plus the stored non-variants deleted and cleanup_required, with the inputs production can pass. Refusals not already pinned elsewhere sit beside an accepting control. Refusals already pinned by an existing test are not repeated; the inventory names that test, and the mutation table shows it failing. Assertions read the persisted Postgres rows, not SQL text, logs, metrics or the error taxonomy.

  • Transient-lifecycle sweeper, through the production SubstrateSweepSink over the direct substrate (managed_by = 'space'):
    • the fail CAS and its undo;
    • the restoring converge;
    • revert-to-suspended and its undo;
    • the unrestorable-restoring fail, including the active-node-only and target-node-only refusals;
    • the command-ready-pending reap (including an unmarked started row), its undo (including a later write on failed) and heal.
  • Node drain, through NodeDrainSink on SubstrateSink with its existing in-process suspend (SubstrateSink::with_suspend_execution):
    • the evacuation claim for the listed started/paused, with residency and drain-phase refusals;
    • the revert after a suspend that fails before the node commits;
    • the Kubernetes suspend handoff, followed by node drain's own commit update;
    • the stranded-suspend redrive;
    • the cohort restore's deferrals beside the restore-pending listing that accepts the eligible row;
    • mark-lost;
    • the gone-node bulk give-up.
  • Orphaned-sandboxes reconciler, through SubstrateActionSink::classify_orphan for each ClassifyGuard:
    • recover to suspended;
    • quarantine, with the Postgres sandbox_quarantine_state row and the entered_quarantine history row;
    • deletion of an abandoned create or a legacy deleting row;
    • the forgotten guard refusing when a newer generation exists (running, or retired but not forgotten);
    • the pod guard refusing an active execution with another pod name;
    • a young pending create.
  • Interleavings.
    • Setup: the sweeper's fail CAS and the evacuation claim run on a dedicated one-connection pool, so the test knows the waiting backend's pid. The test polls pg_blocking_pids for that backend, and bounds the wait and the join with a timeout.
    • What is pinned: each waiter decides on the row a concurrent writer commits, and claims when that writer rolls back.
    • Mutation proof: splitting either CAS into an unlocked guard SELECT plus an UPDATE by id (I1, I3), or taking its lock with SKIP LOCKED (I2, I4), fails only the interleaving test. The package's other 954 tests pass.

Shared fixtures (stored_lifecycles, the sandbox, node, execution and snapshot inserts, the one-backend pool and the bounded lock wait) live in lifecycle_writer_test_support.

Changes to existing tests

node_drain::store::tests::evacuation_reverts_claim_when_suspend_fails_before_commit inserted its node with no drain phase or mode. The claim requires a forced drain, so it was refused, and the assertions held without the revert running: with the revert disabled (R1) the test still passed. Its node is now draining/forced, and it asserts that the suspend ran once, so a refused claim fails it and R1 fails it.

Inventory: cluster-controller writers of Postgres sandboxes.lifecycle

All writes go to the Postgres control-plane store. The BEFORE UPDATE triggers on Postgres sandboxes fire on every one:

  • sandboxes_lifecycle_changed_at stamps lifecycle_changed_at.
  • sandboxes_clear_evacuation_restore_target clears the marker on a move outside suspending/suspended/restoring, and resets the restore backoff whenever the marker is NULL.
  • sandboxes_clear_restore_lease clears the restore lease and preserve_local_artifact_on_restore_failure outside restoring.
  • sandboxes_clear_command_ready_pending clears that flag on a move away from started.

No trigger enqueues reconcile work or sends NOTIFY; the crate only LISTENs, on space_evacuation_restore_ready. "New" means this PR's tests; "existing" names the test that already pins a guard, and the mutation table below shows it failing.

Writer (file → entry point) From-set Target Other guards Moves with it / side effects Pinned by
transient_lifecycle_sweeper/store.rs fail_candidate_query → SweepSink::fail_aged_candidate the lifecycle it is listed under (the sweeper lists creating, starting, pausing, resuming, suspending, restoring, stopping, checkpointing) failed managed_by = substrate; age; marker NULL unless suspending; a marked suspending row is spared while recoverable (a published local artifact with active_node_id NULL, or a current-cycle creating/ready snapshot); restoring needs the lease quiet for a full age window triggers clear marker, lease, preserve_local_artifact_on_restore_failure; returns a write token New: from-set sweep, trigger-cleared columns, the published-artifact exemption, interleaving. Existing: age r1, substrate r4, marked restoring r6, current-cycle snapshot r7, lease r13/pu9
… undo_fail_aged_candidate_query → undo_fail_aged_candidate failed the lifecycle it is handed write token, managed_by restores nothing the triggers cleared; the reconciler declines lossy undos (an_age_out_is_not_undone_where_the_undo_would_be_lossy) New: from-set and write-token rows
… converge_restoring_query → converge_restoring restoring the wake target it is handed age, lease quiet window, routable execution (k8s), managed_by; the CAS carries no evacuation exemption (the candidate select applies it) triggers clear lease and preserve_local_artifact_on_restore_failure New: from-set, cleared columns, substrate. Existing: age and lease r10, routability r18
… revert_to_suspended_query → revert_to_suspended restoring suspended age, lease, a ready pre-restore snapshot safe for a suspended restore, managed_by marker kept; lease and preserve_local_artifact_on_restore_failure cleared New: from-set, moved columns, age, substrate. Existing: snapshot r11/r15, lease r13
… undo_revert_to_suspended_query → undo_revert_to_suspended suspended handed lifecycle (restoring) write token, managed_by restores preserve_local_artifact_on_restore_failure, not the lease New: from-set. Existing: token the_revert_undo_only_matches_its_own_write
… fail_unrestorable_restoring_query → fail_unrestorable_restoring restoring failed unmarked; active_node_id and target_node_id NULL; no active execution; no execution with a node; no retry-source snapshot; no in-flight upload; placement_changed_at aged; managed_by; deliberately no lease guard triggers New: from-set, node columns, substrate. Existing: pu1–pu9
… REAP_COMMAND_READY_PENDING_SQL → reap_command_ready_pending started with command_ready_pending failed managed_by trigger clears command_ready_pending New: from-set, the marker guard, substrate
… undo_reap_command_ready_pending_query → undo_reap_command_ready_pending failed started write token, managed_by sets command_ready_pending back New: from-set, write token
… HEAL_COMMAND_READY_PENDING_SQL → heal_command_ready_pending any (no lifecycle write) — managed_by, flag set clears command_ready_pending New
… REPAIR_FAILED_ACTIVE_EXECUTION_SQL → repair_failed_active_execution failed started managed_by = 'k8s', placement = 'on_node', active execution — Existing: r16 (query level; it never matches through the direct substrate)
node_drain/store.rs claim_evacuation_suspend → NodeDrainSink::evacuate_sandbox the listed started/paused, or suspending already marked for that lifecycle suspending, marker = listed lifecycle active_node_id = node; node drain_phase = 'draining' and drain_mode = 'forced' — New: from-set, residency, drain phase, interleaving. Existing: drain mode evacuation_claim_refuses_a_node_that_turned_gentle
… revert_evacuation_suspend_claim (suspend failed before the node commit) suspending the listed lifecycle no active execution in state suspended; no node or marker guard clears the marker New: from-set and commit guard; existing evacuation_reverts_claim_when_suspend_fails_before_commit (fixed here)
finish_suspended_execution in the api crate (the Kubernetes substrate's suspend) suspending/suspended with the published artifact suspended the execution's generation and pod UID placement = 'unplaced', active_node_id NULL, command_ready_pending cleared, artifact marker written, execution retired suspended, open port exposures reset; target_node_id kept Outside this crate; the in-process suspend reproduces its persisted row so node drain's follow-up is pinned
… suspend commit, inline in evacuate_sandbox suspending suspended none after the handoff it matches nothing (see finding 1); then the best-effort backup enqueue New: evacuation_after_the_suspend_handoff_leaves_the_handed_off_row
… redrive_stranded_suspending_query → redrive_stranded_suspending_evacuations marked suspending suspended active_node_id NULL; a local artifact, or for a row without one a current-cycle ready snapshot marker kept New: from-set. Existing: marker, retirement and snapshot gates redrive_legacy_rows_require_ready_snapshot, redrive_skips_when_only_prior_cycle_ready_snapshot, redrive_waits_for_current_upload_over_stale_ready
… restore_evacuated_and_finalize → restore_evacuated_sandbox restoring (after restore_evacuated_execution moves suspended → restoring in the api crate) started/paused, placement = 'on_node', marker NULL eligibility read: marker set and newest full snapshot ready then enqueue_sandbox_reconcile (Postgres node_reconcile_queue, sandbox desired state) New: deferrals beside the restore-pending listing of the eligible row. The finalize needs Kubernetes
… schedule_restore_retry_query → schedule_restore_retry — (backoff columns only) — marker set restore_attempt_count, next_restore_attempt_at Existing durable_upload_gate_tests
… mark_sandbox_lost_query → mark_sandbox_lost suspended failed target_node_id = node; no copy durable for source release; no in-flight upload of the current suspend — New: from-set, target node, durable copy. Existing: in-flight upload mark_sandbox_lost_waits_for_inflight_upload
… mark_sandbox_lost_on_gone_node_query, mark_sandbox_failed_on_node_query, abandon_stale_resident_sandboxes_query, the two remote-snapshot adoptions suspended; started/paused; the non-evacuable resident set failed (adoptions: no lifecycle change) see each query's doc comment the latter two then call the Kubernetes execution delete Existing query-level tests in durable_upload_gate_tests
… fail_resident_sandboxes_on_gone_node_query → fail_resident_sandboxes_on_gone_node creating, starting, started, pausing, paused, resuming, stopping, suspending, restoring, checkpointing failed active_node_id = node; marked restoring spared; marked suspending spared while recoverable; node draining with the observed k8s_node_uid marker NULL New: from-set, residency. Existing: gone_node_bulk_fail_flips_dead_residents_only
orphaned_sandboxes.rs recover_orphan_to_suspended → classify_orphan not failed, quarantined, cleanup_required, deleted, plus each guard's set. Pod guard: the active execution with the observed pod name and UID, and either started, paused, resuming, pausing, stopping with e.state NOT IN ('creating','recovering') or an execution updated 300 s ago, or creating, starting with an execution updated 300 s ago. Forgotten guard: creating, starting, started, paused, resuming, pausing, stopping whose latest execution is forgotten. suspended a current durable suspend snapshot; no creating snapshot asserted: placement = 'unplaced', both node columns NULL, active execution retired suspended. Not asserted here: placement_changed_at and marker clearing, port-exposure reset, reconcile-ref deletion New: from-set per guard, latest-generation and pod-name fences, young create. Existing: in-flight upload creating_snapshot_defers_disposition
… quarantine_orphan → classify_orphan same sets quarantined no current snapshot asserted: same unplacement, execution retired quarantined, Postgres sandbox_quarantine_state.entered_from_lifecycle and one entered_quarantine history row. Not asserted here: port-exposure reset, reconcile-ref deletion same
… finalize_sandbox_db_delete_tx → classify_orphan abandoned creating without a current snapshot; legacy deleting row deleted pod deletion confirmed, then re-planned asserted: the row is gone. Not asserted here: what is removed with it same
orphaned_sandboxes/quarantine_recovery/store.rs recover quarantined (publication-failed quarantine) suspended locked recheck of every candidate fact history row; quarantine, observed-state and reconcile rows deleted Existing quarantine_recovery/tests.rs

Not characterized here, with their existing per-module Postgres tests:

  • started_sandbox_repair (started → suspended);
  • failed_restorable_repair (failed → suspended);
  • drained_node_terminator (suspended → failed);
  • suspended_execution_reaper (placement only);
  • tombstone_retention (deletes deleted rows);
  • the warm-pool delete.

The Direct substrate does not reach node drain: the reconciler builds SubstrateSink only from a KubernetesSubstrate, and its node scans are managed_by = 'k8s'. Direct's own suspend_execution writes no Postgres sandboxes.lifecycle: it records the artifact marker and clears the direct route identity (clear_direct_route_identity).

Inputs not pinned (defensive, unreachable)

  • evacuate_sandbox and restore_evacuated_sandbox with a non-evacuable listed lifecycle or target: the only callers filter on EVACUABLE_LIFECYCLES.
  • The sink-level undo of a suspending or restoring age-out: the reconciler never requests it.
  • The revert CAS with a listed lifecycle other than restoring: the reconciler only reverts restoring candidates.

Tests → guard → mutation

Each mutation was applied to production code, the whole cluster-controller package was run against Postgres (955 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, so a guard pinned by an existing test shows that test.

How to read it:

  • S1–S20, E1–E8: transient-lifecycle sweeper guards (E4–E7 are node drain's mark-lost and gone-node guards). The old S21, which dropped every rechecked guard at once, is replaced by I1/I2.
  • N1–N18: node drain.
  • C1–C8: guards first pinned in this revision.
  • O1–O6: orphaned-sandbox reconciler.
  • I1–I4: interleaving-only mutations.
  • R1: the repaired existing test.

Run predicate by predicate, the fail CAS's interleaving test fails only for the lease predicate (S6). A committed lifecycle move is refused both by lifecycle = $2 and by the age window that move resets, so neither alone breaks it. What only the interleaving tests catch is the recheck itself: I1–I4 each fail exactly one test, the matching interleaving test, while the other 954 pass.

S12 survives by design. It drops the revert's lifecycle = 'restoring' hard pin, which only matters for a listed lifecycle other than restoring, and no caller passes one (see the defensive inputs above).

# Mutation (reverted after the run) Tests that failed (whole package run) Result
S1 transient_lifecycle_sweeper/store.rs: fail CAS: lifecycle = $2 widened to every sweepable lifecycle fail_cas_claims_only_the_lifecycle_it_was_listed_under killed
S2 transient_lifecycle_sweeper/store.rs: fail CAS: managed_by predicate dropped r4_candidate_select_scoped_to_substrate_managed_by killed
S3 transient_lifecycle_sweeper/store.rs: fail CAS: marked-restoring exemption dropped r6_evacuation_exemption_spares_restoring_but_not_suspending killed
S4 transient_lifecycle_sweeper/store.rs: fail CAS: age window zeroed r13_revert_and_fail_cas_respect_restore_lease, r1_candidate_select_and_fail_cas_leave_drifted_rows_alone, r20_stranded_stopping_row_without_a_node_is_selected_fenced_and_failed killed
S5 transient_lifecycle_sweeper/store.rs: fail CAS: marked suspending with recoverable state no longer spared fail_cas_clears_the_moved_columns_and_spares_a_published_evacuation, r7_suspending_candidates_guard_inflight_evacuation_upload killed
S6 transient_lifecycle_sweeper/store.rs: fail CAS: restore-lease quiet window dropped fail_cas_waiting_on_a_row_lock_decides_on_the_committed_row, pu9_aged_fail_cas_defers_on_lease_where_unrestorable_cas_claims, r13_revert_and_fail_cas_respect_restore_lease killed
S7 transient_lifecycle_sweeper/store.rs: fail undo: from-set widened fail_undo_reverts_only_its_own_failed_write killed
S8 transient_lifecycle_sweeper/store.rs: fail undo: write-token predicate dropped fail_undo_reverts_only_its_own_failed_write killed
S9 transient_lifecycle_sweeper/store.rs: converge CAS: from-set widened converge_cas_moves_only_restoring_rows, r10_converge_cas_respects_restore_lease killed
S10 transient_lifecycle_sweeper/store.rs: converge CAS: restore-lease guard dropped r10_converge_cas_respects_restore_lease killed
S11 transient_lifecycle_sweeper/store.rs: converge CAS: age predicate dropped r10_converge_cas_respects_restore_lease killed
S12 transient_lifecycle_sweeper/store.rs: revert CAS: lifecycle = 'restoring' pin dropped — survived
S13 transient_lifecycle_sweeper/store.rs: revert CAS: snapshot predicate dropped r11_revert_cas_requires_ready_snapshot_with_artifact_identity, r15_revert_cas_refuses_late_branch_restore_artifact killed
S14 transient_lifecycle_sweeper/store.rs: revert undo: from-set widened revert_undo_reverts_only_its_own_suspended_write killed
S15 transient_lifecycle_sweeper/store.rs: unrestorable fail CAS: from-set widened unrestorable_fail_cas_moves_only_unplaced_restoring_rows_of_its_substrate killed
S16 transient_lifecycle_sweeper/store.rs: unrestorable fail CAS: managed_by dropped unrestorable_fail_cas_moves_only_unplaced_restoring_rows_of_its_substrate killed
S17 transient_lifecycle_sweeper/store.rs: reap CAS: from-set widened reap_moves_only_marked_started_rows_and_heal_never_moves_a_lifecycle killed
S18 transient_lifecycle_sweeper/store.rs: heal: also writes lifecycle reap_moves_only_marked_started_rows_and_heal_never_moves_a_lifecycle killed
S19 transient_lifecycle_sweeper/store.rs: heal: managed_by dropped reap_moves_only_marked_started_rows_and_heal_never_moves_a_lifecycle killed
S20 transient_lifecycle_sweeper/store.rs: reap undo: from-set widened reap_undo_reverts_only_its_own_failed_write killed
E1 transient_lifecycle_sweeper/store.rs: revert CAS: age window zeroed revert_cas_moves_only_restoring_rows killed
E2 transient_lifecycle_sweeper/store.rs: revert CAS: managed_by dropped revert_cas_moves_only_restoring_rows killed
E3 transient_lifecycle_sweeper/store.rs: converge CAS: managed_by dropped converge_cas_moves_only_restoring_rows killed
E4 node_drain/store.rs: mark lost: target node dropped mark_lost_fails_only_suspended_rows_without_a_durable_copy killed
E5 node_drain/store.rs: mark lost: durable-copy guard dropped mark_lost_fails_only_suspended_rows_without_a_durable_copy killed
E6 node_drain/store.rs: gone-node give-up: recoverable marked suspend no longer spared gone_node_bulk_fail_flips_dead_residents_only killed
E7 node_drain/store.rs: gone-node give-up: active_node_id residency dropped gone_node_give_up_fails_only_resident_rows_of_the_node killed
E8 sandbox-portability lib.rs: recoverable-suspend predicate: published-artifact branch dropped (sandbox-portability) abandon_sweep_spares_redrivable_marked_suspend, fail_cas_clears_the_moved_columns_and_spares_a_published_evacuation killed
N1 node_drain/store.rs: claim: lifecycle = $2 widened to both evacuable lifecycles evacuation_claims_only_the_listed_lifecycle_or_its_own_marked_suspend killed
N2 node_drain/store.rs: claim: resume branch no longer requires its own marker evacuation_claims_only_the_listed_lifecycle_or_its_own_marked_suspend killed
N3 node_drain/store.rs: claim: forced drain mode dropped evacuation_claim_refuses_a_node_that_turned_gentle killed
N4 node_drain/store.rs: claim: active_node_id residency dropped evacuation_claim_requires_residency_on_a_draining_node, evacuation_claim_waiting_on_a_row_lock_decides_on_the_committed_row killed
N5 node_drain/store.rs: claim: draining phase dropped evacuation_claim_requires_residency_on_a_draining_node killed
N6 node_drain/store.rs: revert: from-set widened evacuation_revert_moves_only_a_claim_still_suspending_and_uncommitted killed
N7 node_drain/store.rs: revert: committed-execution guard dropped evacuation_revert_moves_only_a_claim_still_suspending_and_uncommitted, evacuation_redrives_pod_retirement_after_suspend_commit killed
N8 node_drain/store.rs: commit: lifecycle = 'suspending' dropped evacuation_after_the_suspend_handoff_leaves_the_handed_off_row killed
N9 node_drain/store.rs: redrive: from-set widened redrive_legacy_rows_require_ready_snapshot, redrive_moves_only_marked_suspending_rows killed
N10 node_drain/store.rs: redrive: marker dropped redrive_legacy_rows_require_ready_snapshot killed
N11 node_drain/store.rs: redrive: active_node_id IS NULL dropped redrive_legacy_rows_require_ready_snapshot killed
N12 node_drain/store.rs: redrive: legacy ready-snapshot gate dropped redrive_legacy_rows_require_ready_snapshot, redrive_skips_when_only_prior_cycle_ready_snapshot, redrive_waits_for_current_upload_over_stale_ready killed
N13 node_drain/store.rs: restore: eligibility marker check dropped cohort_restore_defers_rows_the_restore_sweep_would_not_list killed
N14 node_drain/store.rs: mark lost: from-set dropped mark_lost_fails_only_suspended_rows_without_a_durable_copy killed
N15 node_drain/store.rs: mark lost: in-flight upload guard dropped give_up_path_gated_on_state_and_freshness, mark_sandbox_lost_waits_for_inflight_upload killed
N16 node_drain/store.rs: gone-node give-up: from-set widened gone_node_bulk_fail_flips_dead_residents_only, gone_node_give_up_fails_only_resident_rows_of_the_node killed
N17 node_drain/store.rs: gone-node give-up: node uid identity dropped gone_node_bulk_fail_flips_dead_residents_only killed
N18 node_drain/store.rs: gone-node give-up: marked-restoring exemption dropped gone_node_bulk_fail_flips_dead_residents_only killed
C1 transient_lifecycle_sweeper/store.rs: reap CAS: command_ready_pending guard dropped reap_moves_only_marked_started_rows_and_heal_never_moves_a_lifecycle killed
C2 transient_lifecycle_sweeper/store.rs: unrestorable fail CAS: active_node_id IS NULL dropped unrestorable_fail_cas_moves_only_unplaced_restoring_rows_of_its_substrate killed
C3 transient_lifecycle_sweeper/store.rs: unrestorable fail CAS: target_node_id IS NULL dropped unrestorable_fail_cas_moves_only_unplaced_restoring_rows_of_its_substrate killed
C4 transient_lifecycle_sweeper/store.rs: reap undo: write-token predicate dropped reap_undo_reverts_only_its_own_failed_write killed
C5 orphaned_sandboxes.rs: forgotten guard: latest-generation predicate dropped classification_acts_only_on_the_execution_its_guard_names killed
C6 orphaned_sandboxes.rs: forgotten guard: latest execution need not be forgotten classification_acts_only_on_the_execution_its_guard_names killed
C7 orphaned_sandboxes.rs: pod guard: active execution pod-name fence dropped classification_acts_only_on_the_execution_its_guard_names killed
C8 node_drain/store.rs: restore-pending scan: suspended rows dropped (the accepting control) redrive_legacy_rows_require_ready_snapshot, restore_pending_gates_on_next_restore_attempt_at, restore_pending_ignores_stale_windows_when_backoff_disabled, restore_pending_orders_attempt_tiers_then_recency, restore_pending_selects_only_evacuation_marked_ready_sandboxes, restore_pending_waits_for_current_upload_over_stale_snapshot, cohort_restore_defers_rows_the_restore_sweep_would_not_list killed
O1 orphaned_sandboxes.rs: pod guard: live-pod lifecycle set widened classification_moves_only_the_lifecycles_its_guard_owns killed
O2 orphaned_sandboxes.rs: forgotten guard: lifecycle set widened classification_moves_only_the_lifecycles_its_guard_owns killed
O3 orphaned_sandboxes.rs: pod guard: pending-create age dropped classification_acts_only_on_the_execution_its_guard_names killed
O4 orphaned_sandboxes.rs: plan: in-flight snapshot deferral dropped creating_snapshot_defers_disposition killed
O5 orphaned_sandboxes.rs: plan: abandoned-create delete dropped classification_moves_only_the_lifecycles_its_guard_owns, abandoned_initial_create_is_finalized_instead_of_quarantined, lifecycle_change_during_cleanup_prevents_database_delete, unconfirmed_cleanup_keeps_abandoned_create_for_retry killed
O6 orphaned_sandboxes.rs: legacy-deleting guard widened classification_moves_only_the_lifecycles_its_guard_owns killed
I1 transient_lifecycle_sweeper/store.rs: fail CAS split into an unlocked guard SELECT plus an UPDATE by id fail_cas_waiting_on_a_row_lock_decides_on_the_committed_row killed
I2 transient_lifecycle_sweeper/store.rs: fail CAS takes its row lock with SKIP LOCKED fail_cas_waiting_on_a_row_lock_decides_on_the_committed_row killed
I3 node_drain/store.rs: evacuation claim split into an unlocked guard SELECT plus an UPDATE by id evacuation_claim_waiting_on_a_row_lock_decides_on_the_committed_row killed
I4 node_drain/store.rs: evacuation claim takes its row lock with SKIP LOCKED evacuation_claim_waiting_on_a_row_lock_decides_on_the_committed_row killed
R1 node_drain/store.rs: the claim revert disabled (WHERE FALSE) evacuation_claim_requires_residency_on_a_draining_node, evacuation_claim_waiting_on_a_row_lock_decides_on_the_committed_row, evacuation_claims_only_the_listed_lifecycle_or_its_own_marked_suspend, evacuation_revert_moves_only_a_claim_still_suspending_and_uncommitted, evacuation_reverts_claim_when_suspend_fails_before_commit killed

Findings

Behavior that looks wrong, or is redundant, is listed here and not pinned.

  1. Node drain's own suspend commit is redundant, and its comment is stale.
    • What happens: on the Kubernetes substrate, a successful suspend_execution finalizes through finish_suspended_execution → finalize_suspended_sandbox_in. That already persists Postgres sandboxes.lifecycle = 'suspended', clears active_node_id, unplaces the row, writes the artifact marker and retires the execution. So evacuate_sandbox's own UPDATE sandboxes SET lifecycle = 'suspended' WHERE id = $1 AND lifecycle = 'suspending' matches no row on a normal drain.
    • The stale comment says the substrate touches sandbox_k8s_executions and placement "but NOT sandboxes.lifecycle".
    • Why rows_affected can't be checked: doing so would fail every normal drain. The update is a simplification candidate.
    • Remaining window: evacuate_sandbox emits SuspendCompleted and enqueues the artifact backup whatever the row holds after the handoff.
      • A writer that moves the suspended, unplaced row between the handoff and the backup enqueue leaves a success event and a backup request for a row that is no longer suspended. The reachable one is the API's delete claim to deleting. The backup is wasted work for a sandbox being deleted, and the event stream shows a completed suspend after the delete began.
      • abandon_stale_resident_sandboxes cannot land in that window, because its lifecycle set excludes suspended. If it fails the row while the suspend is still in flight, the handoff's own lifecycle check refuses, the suspend errors, and evacuation reports the failure.
      • The node-guarded publication projections no longer match once active_node_id is NULL.
    • Pinned: evacuation_after_the_suspend_handoff_leaves_the_handed_off_row pins the persisted rows, including that a write landing after the handoff stands.

Observations, pinned or noted as today's contract rather than defects:

  • The claim revert has no node or marker guard.
  • The converge CAS carries no evacuation exemption; the candidate select excludes marked restoring rows.
  • The sink-level undos accept any lifecycle they are handed. Declining a lossy undo is the reconciler's policy.

Not characterized here

These need a KubernetesSubstrate, and this crate has no injectable seam for it. Their SQL keeps its existing query-level tests.

  • the cohort-restore finalize and its reconcile enqueue;
  • the claims of mark_sandbox_failed_on_node and abandon_stale_resident_sandboxes, which call the Kubernetes execution delete as soon as they claim;
  • the converge CAS's routability predicate;
  • repair_failed_active_execution.

Host safety

The tests use the Postgres fixture only.

  • The direct substrate makes no node calls for these writes.
  • The node-drain suspend runs in process. On its success path it removes the test's own nodes row, so the post-suspend backup enqueue resolves no endpoint. That means no node-api connection, no Kubernetes call and no token-file read.
  • The orphan sink runs without a pod cleaner.
  • The interleaving tests open extra pools only on the test's own database.
  • No test opens a socket outside Postgres, reads a fixed host path or spawns a process.
  • The mutation runs exercised the same tests, one mutation at a time.

Overlap with open pull requests

Validation

Check Result
ci/scripts/run-rust-checks.sh fmt (env contract, error policy, cargo fmt --check) pass
cargo clippy --locked --workspace --all-targets --all-features -- -D warnings pass
cargo nextest run --locked -p pplx-space-cluster-controller-server --retries 0, SPACE_CI_REQUIRE_TEST_DEPS=1, three consecutive runs 955 passed, 0 skipped, each run
pre-commit (gitleaks) over the branch pass
scripts/source_guards/{env_test_hook_boundary,node_selector_extras_smoke,observability_vendor_boundary}.py pass
scripts/check_no_inline_rust_test_modules.py, scripts/source_guards/thin_service_mains.py fail, PRE-EXISTING: they flag observability/src/{otlp_endpoint,selection}.rs and apps/vol/mgr/svc/src/main.rs, which this PR does not touch
Production files vs the merge base only the four #[cfg(test)] mod lines
Mutation table above 65 mutations: 64 killed, 1 expected survivor (S12)
Kind not run: tests-only change

AI-assisted: written with Claude Code; every result above was produced by the commands listed.

🤖 Generated with Claude Code

@ac-pplx
ac-pplx force-pushed the ac/char-tests-cluster-lifecycle-writers branch from 92d7d97 to 6550b0a Compare October 12, 2026 21:46
Pin, against the real Postgres fixture, which stored values of Postgres
`sandboxes.lifecycle` the transient-lifecycle sweeper, node drain and the
orphaned-sandbox reconciler move, what they move them to, and the columns
that move with them, so a later change to lifecycle ownership has an
executable spec. No production code changes; the only edits to production
files are the `#[cfg(test)] mod` lines that wire the new modules and their
shared fixtures (`lifecycle_writer_test_support`).

Each writer gets one sweep over every `SandboxLifecycle` variant plus the
stored non-variants `deleted` and `cleanup_required`, with the inputs
production can pass. Refusals no existing test pins sit beside an accepting
control; refusals an existing query-level test already pins are not
repeated.

- Transient-lifecycle sweeper, through `SubstrateSweepSink` over the direct
  substrate: the aged fail CAS and its undo, the restoring converge,
  revert-to-suspended and its undo, the unrestorable-restoring fail (with
  its node-column refusals), and the command-ready-pending reap (with its
  marker guard), its undo (with its write token) and heal.
- Node drain, through `NodeDrainSink` on `SubstrateSink` with its
  in-process suspend: the evacuation claim and its residency and
  drain-phase guards, the revert, node drain's own commit after the
  Kubernetes substrate's persisted suspend handoff, the stranded-suspend
  redrive, the cohort restore's deferrals beside the restore-pending
  listing, mark-lost, and the gone-node give-up.
- Orphaned-sandbox reconciler, through `classify_orphan`, for each
  classification guard, including the forgotten guard's latest-generation
  fence, the pod guard's pod-name fence and the young-create window.
- Row-lock interleavings: the sweeper's fail CAS and the evacuation claim,
  each on a one-connection pool whose backend is polled through
  `pg_blocking_pids` under a timeout, decide on the row a concurrent writer
  commits and claim when it rolls back.

`evacuation_reverts_claim_when_suspend_fails_before_commit` inserted its
node without a forced drain, so the claim was refused before the suspend
and the revert never ran. Its node is now forced draining, and the test
asserts the suspend ran once.

Every guard was mutation-checked against the whole package: dropping the
predicate or widening the from-set failed the pinned test, splitting a CAS
into an unlocked guard plus an update by id or adding SKIP LOCKED failed
only the interleaving test, and each mutation was reverted.
@ac-pplx
ac-pplx force-pushed the ac/char-tests-cluster-lifecycle-writers branch from 6550b0a to 28f6226 Compare October 12, 2026 22:34
@ac-pplx
ac-pplx marked this pull request as ready for review October 12, 2026 22:59
@ac-pplx
ac-pplx added this pull request to the merge queue Oct 13, 2026
Merged via the queue into main with commit c565c8e Oct 13, 2026
13 checks passed
@ac-pplx
ac-pplx deleted the ac/char-tests-cluster-lifecycle-writers branch October 13, 2026 03:14
@the-gigi-pplx

Copy link
Copy Markdown
Collaborator

Review — 4 findings

Medium

  1. crates/libs/cluster/controller/server/src/transient_lifecycle_sweeper/lifecycle_writer_tests.rs:578 — revert_undo_reverts_only_its_own_suspended_write says the undo restores "the fork-preservation flag it is handed", but it only ever hands true.
    undo_revert_to_suspended_query binds preserve_local_artifact_on_restore_failure = $5, and the reconciler passes candidate.fork_restore there. I changed that assignment to TRUE OR $5::bool and the whole package still passed (965/965), so an undo that ignores the flag goes unnoticed. Could you add a case that hands false and expects Row::bare("restoring")?

  2. crates/libs/cluster/controller/server/src/node_drain/store/lifecycle_writer_tests.rs:600 — nothing shows restore_evacuated_and_finalize's eligibility gate letting a row through.
    Every row this test hands to restore_evacuated_sandbox expects Deferred. The accepting control goes through list_restore_pending_evacuations, which is a different query from the gate's. I changed if !eligible to if !eligible || true in node_drain/store.rs and the whole package still passed, so a gate that defers every cohort restore wouldn't be caught. A cheap accepting control: send the eligible row through restore_evacuated_sandbox inside tokio::spawn and assert the JoinError is_panic(). Past the gate, the suspend-only backend panics at kubernetes(). I tried that locally: it passes at this head and fails under the mutation.

Low

  1. crates/libs/cluster/controller/server/src/transient_lifecycle_sweeper/lifecycle_writer_tests.rs:119 — "Same lifecycle, so no lifecycle trigger fires" isn't right.
    sandboxes_clear_restore_lease runs on every UPDATE where the new lifecycle isn't restoring, whether or not the lifecycle changed. sandboxes_clear_evacuation_restore_target also resets the backoff whenever the marker is NULL. Today's callers only lease or preserve restoring rows, so nothing breaks yet. But Row::bare("started").leased() would silently be stored without its lease. I'd reword the comment and assert that the inserted row equals row.

  2. crates/libs/cluster/controller/server/src/node_drain/store.rs:2908 — your Finding 1 shows this comment is false (sandbox_assignments writes the lifecycle), but it stays in the source. Someone who trusts it could add a rows_affected check that fails every normal drain. That's fine to leave out of a tests-only PR, but it's worth a follow-up issue or a one-line comment fix.

Verified against 28f62260d14dbb3f6127e75b69e80a2f1e8ebab8. Reproduced locally: the 22 new and corrected tests pass. I ran the whole pplx-space-cluster-controller-server package once under each of the two mutations above (965/965 both times), and ran the probe from finding 2 at this head and under its mutation. I also confirmed that the production diff is only the four #[cfg(test)] mod lines. I didn't re-run the PR's own 65-mutation table or repeat-run the interleaving tests for flakiness.

@the-gigi-pplx the-gigi-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.

Approved. 4 non-blocking findings in the review comment above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants