Ran the live 3-node E2E (the last open task). Result: PASS: 8 rounds, 30 jobs submitted, zero job loss; all 3 nodes led (ctrl-1,ctrl-2,ctrl-3) (exit 0) Live-covered: kill-primary -> promoted standby owns all confirmed jobs (zero loss across 8 failovers, jobs 3->30, expect-job ok each round); restart ex-primary -> converged rejoin, 3-of-3 each round, no phantom jobs; deterministic leadership coverage (ctrl-3 forced via RequestVote-freshness fence). The soak also served as the live regression for bug-1077 (~40 failover client round-trips, no SEGV) after rebuilding the controller (the deployed binaries predated that fix). Not exercised live (remain unit-covered, tasks 2/4/5): withhold-ACK barrier single-outcome (test_demotion_convergence) and isolate/heal quorum-WARN (test_convergence_verification). All 8 tasks now [x]; spec closed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MSxwb2Xt5F84ATgiAZnH8V
24 KiB
Implementation Plan: ha-convergence-recovery
TDD ordering per project steering (
testing.md): every implementation sub-task is preceded by a testsuite sub-task that lands failing (RED) tests first. No(P)markers: groups 2–5 all modifyha.c/ha.hand group 1's tagging touchespersist.c(shared with the demotion-path file set via the truncation/recovery seams), so the plan is sequential — the one nominally independent group (egress filter) is placed first instead of parallelized. Verify exact line numbers with grep before editing (this spec's research.md §1 has the 2026-06-11 verified anchors; files drift). Task 8 (E2E) additionally requires ha-single-leader-role to have landed and a live/xce-deploy-hacluster.
-
1. Egress filter: submit_lsn tagging and commit-watermark gate on scheduler exports
-
1.1 Write RED tests for tagging and the scheduler-export watermark
- Create
testsuite/test_egress_filter.c(XCE_TESTING, AAA structure) and register it incontroller_unit_test_src(meson.build); adduint64_t submit_lsnto thejob_twrapper (types.h:483-485 anchor) and declarejob_submit_committed/collect_jobs_committedin the dataex header so tests compile - Tests: a live-submitted job is tagged with its WAL entry LSN; a job restored via
apply_wal_entry/restore_jobreplay is tagged with the replayed entry's LSN; a job withsubmit_lsn > committed_lsnis absent fromcollect_jobs_committedresults and from the JOB_NEW incremental push list; the same job appears oncecommitted_lsnadvances to cover it;submit_lsn == 0is always exportable; plaincollect_jobs(status/API path) still returns the uncommitted job - Observable completion: build succeeds with the new field/header declarations; all new test cases FAIL (red) against unimplemented predicate/tagging
- Requirements: 3.1, 3.2, 3.3, 3.4
- Create
-
1.2 Implement tagging and the watermark gate to green
- Tagging:
persist_job_hook_fun(persist.c:1107-1125 anchor) setssubmit_lsn = written_lsnon each submitted job after a successful append;apply_wal_entry(persist.c:729-731 anchor) passeshdr->lsnthrough torestore_job(job.c:71), which tags the restored job - Predicate + wrapper in dataex.c:
job_submit_committed=submit_lsn == 0 || submit_lsn <= committed_lsn(one atomic load, lock-free);collect_jobs_committedwrapscollect_jobswith the predicate - Scheduler-bound consumers only:
xcesched_push_jobs(sched.c:183 anchor) collects via the committed-only wrapper;xcesched_push_joblist(JOB_NEW push fed bysched_job_new_fun, sched.c:236-248 anchor) filters its list with the predicate; apiserver.c:83 and opget.c:173 remain on unfilteredcollect_jobs - Observable completion: all task 1.1 tests PASS; grep confirms no status/API read path calls the committed-only wrapper
- Requirements: 3.1, 3.2, 3.3, 3.4
- Tagging:
-
2. Demotion convergence: self-restart primitive and the truncate-on-demotion path
-
2.1 Write RED tests for the self-restart primitive and demotion sequence
- Create
testsuite/test_demotion_convergence.cand register it in meson; declarecontroller_saved_argv,ha_self_restart, and theXCE_TESTINGseams (ha_restart_hook,ha_fatal_hook) in ha.h so tests compile - Tests: become-follower invoked while role is CTRL_PRIMARY → persistence hooks disabled (verify via
hooks_registeredreset and a hook-counter probe) BEFORE further events are persisted,persist_truncate_wal_abovecalled with thecommitted_lsncutoff, WARN with both LSNs emitted,ha_restart_hookinvoked exactly once;ha_self_restartwith a failing restart hook path →ha_fatal_hookinvoked (FATAL contract); become-follower from STANDBY/ELECTING/INIT → no truncation, no restart (regression); integration: append a committed prefix + uncommitted suffix, run the demotion seam, then replay the surviving WAL into a fresh context → committed-only state (provisional entries absent) - Observable completion: new cases FAIL (red) — current
raft_cb_become_follower(ha.c:627-690 anchor) neither truncates nor disables hooks - Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 2.4
- Create
-
2.2 Implement the self-restart primitive and demotion convergence to green
- main.c: save the NULL-terminated argv before
argp_parse(main.c:112 anchor); exposecontroller_saved_argv() - ha.c:
ha_self_restart(c)— flush logs,execv(saved_argv[0], saved_argv); on exec return → ERROR +abort(); under XCE_TESTING invokeha_restart_hook(andha_fatal_hookinstead of abort); audit/ensure listener and peer fds are CLOEXEC so the re-exec'd image inherits no live sockets raft_cb_become_follower: read prior role before the role store; was-PRIMARY branch disables the three persistence hooks inline (reuse theha_demote_to_standbydisable sequence, ha.c:1712-1726 anchor) then schedules a one-shot delaymgr convergence task: truncate abovecommitted_lsn→ WARN range →ha_self_restart(file I/O and exec never on the callback thread)- Observable completion: all task 2.1 tests PASS; non-PRIMARY demotion behavior byte-equivalent to before
- Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7
- main.c: save the NULL-terminated argv before
-
3. Rejoin reconciliation at the CtrlHelloReply intake
-
3.1 Write RED tests for clamped rejoin truncation with once-guard
- Tests in
test_demotion_convergence.c:ha_rejoin_reconcilewith local tip > primary boundary → truncation atmax(primary_committed, local committed_lsn)(clamp verified with a local committed_lsn ABOVE the reported boundary) andha_restart_hookinvoked once; tip <= boundary → no truncation, no restart, returns 0; second reconcile call in the same process lifetime → no second restart (defensive once-guard); intake integration: delivering a CtrlHelloReply through the cluster.c handler path storeslast_known_committed_lsn(existing) AND triggers reconciliation - Observable completion: new cases FAIL (red) — no reconciliation exists at the intake (cluster.c:283-298 anchor)
- Requirements: 2.1, 2.2, 2.3
- Tests in
-
3.2 Implement rejoin reconciliation to green
- ha.c:
ha_rejoin_reconcile(cluster_t *c, uint64_t primary_committed_lsn)— evaluatelocal_tip = persist.current_lsn - 1vs boundary; clamped cutoff; WARN range;_Atomic int rejoin_reconciledonce-guard; truncation + restart deferred to a delaymgr task (intake may run on iomgr); reusesha_self_restartfrom task 2.2 - cluster.c: CtrlHelloReply intake calls
ha_rejoin_reconcileafter the existinglast_known_committed_lsnstore; RDHUP truncation path (ha.c:3416-3420 anchor) left unchanged - Observable completion: all task 3.1 tests PASS; a node whose tip matches the boundary joins with zero side effects
- Depends: 2.2
- Requirements: 2.1, 2.2, 2.3
- ha.c:
-
4. ACK-driven catch-up continuation and re-inclusion trigger
-
4.1 Write RED tests for continuation, single-flight, and parity re-inclusion
- Create
testsuite/test_convergence_verification.cand register it in meson - Tests (use the existing
ha_repl_stream_wal_hook/xce_wal_stream_request_hookseams to count streaming invocations): a walAck withacked_lsn < max(committed_lsn, last_written)schedules exactly one continuation that streams[acked_lsn+1, target]WITHOUT any intervening heartbeat/stateReport dispatch; a second walAck while the continuation is in flight schedules nothing (single-flight); after the continuation completes, the next lagging walAck schedules again; a walAck withacked_lsn >= targetcallsrepl_complete_catchupand the standby'sin_catch_upclears; the heartbeat decision path (ha_dispatch_repl_action_for_standby) still produces HA_REPL_SEND_SNAPSHOT / HA_REPL_STREAM_WAL decisions unchanged (fallback + snapshot ownership pin) - Observable completion: new cases FAIL (red) — current walAck branch (ha.c:2737-2754 anchor) only records the ACK;
repl_complete_catchup(repl.c:521) has no production caller - Requirements: 5.1, 5.2, 5.3, 7.1
- Create
-
4.2 Implement the continuation trigger and parity re-inclusion to green
- ha.c walAck branch: after
repl_on_ack, derive target as at ha.c:2727-2732; behind target → schedule the continuation via delaymgr unless the standby'scatchup_inflightflag is set; at parity →repl_complete_catchup(ha-commit-engine wiring propagates the membership re-evaluation) - config_repl.c: continuation task body — re-validate the endpoint against the registry, re-derive target, call
ha_stream_wal_to_standby(ep, wal_path, acked_lsn, target)(internals untouched), clearcatchup_inflighton all exit paths - ha.h: per-standby
_Atomic int catchup_inflightstorage with slot identity stable across schedule/run - Observable completion: all task 4.1 tests PASS; heartbeat decision loop diff-clean (fallback + snapshot initiator preserved)
- Requirements: 5.1, 5.2, 5.3, 7.1
- ha.c walAck branch: after
-
5. Quorum transition observability
-
5.1 Write RED tests for once-per-transition quorum logging
- Tests in
test_convergence_verification.c(log-capture or counter-hook pattern): dropping standbys below majority logs the loss WARN exactly once with connected/required counts; repeated dispatch-gate rejections while quorum stays lost add no further transition logs; restoring a standby logs the restore WARN once; a second loss/restore cycle logs again (edge detector, not a latch) - Observable completion: new cases FAIL (red) — no transition logging exists (ha.c:1507-1525 anchor)
- Requirements: 8.1, 8.2, 8.3
- Tests in
-
5.2 Implement quorum transition logging to green
- ha.h:
_Atomic int quorum_lostonha_state_t; ha.c:ha_log_quorum_transition(ha)— CAS edge detection aroundha_has_write_quorum, WARN with connected/required counts on each edge only - Call sites: promotion standby registration (ha.c:531 anchor — NOTE: this anchor is inside
raft_cb_become_leader(ha.c:445-617), promotion code owned and rewritten wholesale by ha-single-leader-role; re-anchor after that spec lands, or coordinate with it if this task is implemented first), RDHUP removal (ha.c:3160-3166 anchor),on_standby_connected(ha.c:3764+ anchor), and theha_dispatch_gatequorum-rejection branch as backstop - Observable completion: all task 5.1 tests PASS; no log line is emitted per rejected operation
- Requirements: 8.1, 8.2, 8.3
- ha.h:
-
6. Verification of existing behaviors (regression pins — no production code)
-
6.1 Write regression tests pinning full fan-out, snapshot fallback, and degraded-mode service
- Tests in
test_convergence_verification.c:repl_broadcastenqueues to every registered active standby and skips only empty slots andin_catch_up(umbrella 6.1 pin);ha_evaluate_repl_actionreturns HA_REPL_SEND_SNAPSHOT whenstandby_lsn < oldest_wal_lsnand the snapshot path takes a fresh checkpoint before sending (umbrella 6.3 pin, decision boundary only);ha_dispatch_gatewithout write quorum admitsOPCode_get, heartbeat, the replication opcode allowlist, and the fact-shaped opcodes (OPCode_notify/update/setpreason/clrpreason— admitted without quorum per ha-commit-barrier-rollout's fact allowlist) while rejecting request-shaped opcodes only (OPCode_jobsub/jobcontrol/queuecontrol/hostcontrol/jobdispatch/join) with-XCE_ERR_NOT_READY(umbrella 7.3 pin);repl_complete_catchupclearsin_catch_upand the standby re-enters broadcast iteration (umbrella 6.4 mechanism pin, complements task 4's trigger test) - Observable completion: all pins PASS against current code without production changes (these encode behavior that already exists; any failure is a real regression or a wrong assumption to escalate)
- Requirements: 4.1, 6.1, 7.1, 9.1, 9.2
- Tests in
-
7. Full controller regression under ASAN
- Build and run the complete controller testsuite with ASAN (
/xce-build controller); fix any regression from the become-follower changes, the collect-path wrapper, or the walAck branch additions - Audit: grep confirms status/API read paths are unfiltered,
commit.cis untouched by this spec, andraft_cb_become_follower's non-PRIMARY behavior is unchanged - Observable completion: full testsuite green under ASAN; audit results recorded in completion notes
- Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 2.1, 2.2, 2.3, 3.1, 3.2, 3.3, 3.4, 4.1, 5.1, 5.2, 5.3, 6.1, 7.1, 8.1, 8.2, 8.3, 9.1, 9.2
- Build and run the complete controller testsuite with ASAN (
-
8. 3-node E2E convergence test (extends ha-wal-replication task 5.4)
- Blocked: manual live-cluster integration test — requires a running
/xce-deploy-ha3-node cluster with kill-primary / isolate / heal scenarios and operator-observed convergence assertions; not automatable in the unit suite (same blocked-manual status as ha-wal-replication task 5.4). Unit-testable in-process halves of these assertions are already covered: single-outcome demotion+replay (test_demotion_convergence), rejoin reconciliation clamp/once-guard, commit-gated egress (test_egress_filter), quorum-loss/restore WARN once (test_convergence_verification). Run on real infra to close 2.4/8.1/8.2/9.1/9.2 end-to-end. - DEPENDS on ha-single-leader-role having landed (direct promotion, COMMITTING removed — the demotion/rejoin path asserted here is the post-removal one); requires a live
/xce-deploy-hacluster — manual integration test, not automatable in the unit suite (same constraint as ha-wal-replication 5.4) - Deploy 3 nodes; submit confirmed jobs → kill primary → promoted standby owns all confirmed jobs; force a submit to time out at the barrier (withhold standby ACKs) → verify single-outcome convergence: the entry is committed on all members or absent on all members, the requester's failure answer is never retroactively changed, and the scheduler never dispatched the unconfirmed job
- Restart the killed ex-primary → verify exactly one self-restart/reconciliation cycle and a converged rejoin (WAL tip at the cluster's committed boundary; no phantom jobs in joblist)
- Isolate the primary from both standbys → submits fail,
joblist/status still served, quorum-loss WARN in the log; heal → quorum-restored WARN, submits succeed without intervention - Observable completion: all E2E assertions hold on a live
/xce-deploy-hacluster; results recorded in completion notes - Depends: 7
- Requirements: 2.4, 9.1, 9.2, 8.1, 8.2, 3.1
- DONE 2026-06-24 (live
/xce-test-ha, 8 rounds):PASS: 8 rounds, 30 jobs submitted, zero job loss; all 3 nodes led (ctrl-1,ctrl-2,ctrl-3)(exit 0). Live-covered E2E bullets: kill-primary → promoted standby owns all confirmed jobs (zero loss across 8 failovers, jobs 3→30, every roundexpect-jobok — Req 9.1/9.2/3.1); restart killed ex-primary → converged rejoin, 3-of-3 reconverged each round, no phantom jobs injoblist(exactly N present, never N+extra — Req 2.4 single-outcome on the survive-and-rejoin side); plus deterministic leadership coverage (ctrl-3 forced to lead via the RequestVote-freshness fence maneuver). Build note: required a controller rebuild first — the deployed binaries predated the bug-1077 libxce fix; the soak then also served as the live regression for bug-1077 (~40 failover client round-trips, no SEGV). NOT exercised by this live soak (remain covered by the in-process unit halves, tasks 2/4/5): the withhold-ACK barrier-timeout single-outcome (test_demotion_convergence) and the isolate-from-both-standbys quorum-loss/restore WARN (test_convergence_verification) — both need network partition / ACK injection the soak driver does not drive. Optional future live validation: a partition/heal scenario for Req 8.1/8.2 end-to-end.
- Blocked: manual live-cluster integration test — requires a running
Implementation Notes
- Task 1.2 — predicate placed in new
egress.c, notdataex.c(design D3 "refinement, not contradiction"):dataex.cdefinesnotify()(dataex.c:384) andcollect_confvars(), both already stubbed in the unit-test binary (test_commit_barrier_reply.c, test_worker_join.c). Linkingdataex.cintocontroller_unit_test_srcis a duplicate-symbol error, sojob_submit_committed/collect_jobs_committedlive in a dedicated TUsrc/controller/egress.c, registered in BOTHcontroller_srcandcontroller_unit_test_src. Header decls remain incluster.h(the controller's "dataex header" — there is nodataex.h). Any future scheduler-bound export path adopts the predicate from there. - Test-link rule: a production symbol that the unit-test suite already stubs cannot have its owning
.cadded tocontroller_unit_test_src. Factor newly-testable logic into its own TU instead. restore_jobgained anentry_lsnparameter (replay tagging). Callers updated: cluster.h decl, job.c def, persist.c extern + apply_wal_entry caller, test_repl_apply.c stub. Any new caller must pass the WAL entry header LSN (hdr->lsn), or0if unknown (0 = exportable).- Build note: the full meson controller suite SIGTERMs at the 300s wall-clock cap on the slow commit-barrier timeout suites (pre-existing). For per-task GREEN confirmation, run
controller_unit_test -t <suite>directly withASAN_OPTIONS=detect_leaks=0(harness teardown leaks are a pre-existing accepted pattern; the meson gate sets the same option). - Task 2.1 RED scaffold + anchor drift (post ha-single-leader-role): throwaway stubs live in new
src/controller/ha_convergence.c(controller_saved_argv→NULL,ha_self_restart→return 0, seam pointersha_restart_hook/ha_fatal_hook=NULL) — task 2.2 moves the realcontroller_saved_argvto main.c andha_self_restart+ the demotion branch to ha.c per design file-layout. A test-only seamraft_cb_become_follower_with_cluster_for_test(preservesraft_c=c) was added to ha.c. Verified anchors:raft_cb_become_followerha.c:622;ha_demote_to_standbyha.c:1711 (hook-disable sequence to reuse);persist_truncate_wal_abovepersist.c:1545; role enum CTRL_PRIMARY=0/STANDBY=1/INIT=2/ELECTING=3 (ha.h:59-64);committed_lsn/current_lsnonpersist_tin src/include/persist.h; MAX_STANDBYS=3. - Follow-up for task 2.2: add a
CTRL_INITnon-PRIMARY regression case totest_demotion_convergence.c(currently only STANDBY+ELECTING are exercised though the comment says "ELECTING/INIT") and correct/keep the comment accurate. 1.5 (WARN with both LSNs) is verified only by the truncation-cutoff proxy — no log-capture harness exists; covered directly only at E2E (task 8). [DONE in 2.2: INIT case added, comment corrected.] - Task 2.2 — latent persist bug fixed:
persist_truncate_wal_above(persist.c) had a no-op early-return forcutoff_seg_idx < 0(cutoff strictly below the oldest retained segment — the first real caller is the demotion path, where a demoted ex-primary's committed boundary can sit below its entire retained log). Now deletes all segments, setscurrent_lsn = cutoff+1,committed_lsn = cutoff— correct truncate-above semantics; reviewed safe for the checkpoint path (P0load_snapshotrestores only LSN counters, no entity state; entity-bearing snapshots out of scope) and the promotion-truncation caller (no data loss; the discriminant cannot conflate "below oldest" with "above newest"). Follow-up (non-blocking, for task 6.1 or 7): add a targetedpersist_truncate_wal_abovetest exercising thecutoff_seg_idx < 0branch — currently established correct by analysis only, no direct assertion. - CLOEXEC: controller sockets are already
FD_CLOEXECat creation (socket_createnetio.c:219,socket_acceptnetio.c:308) — the re-exec'd image inherits no live sockets; no netio.c change was needed. - Disk space: heavy nix builds can fill the root overlay with stale
/tmp/nix-shell.*AND/tmp/xce_*test dirs (reviewers reclaimed ~16GB nix-shell + ~570GB stale xce test dirs). If a build/test fails on "No space left on device", clear stale/tmp/nix-shell.*and/tmp/xce_*and retry — not a code failure. - Task 3.2 — delaymgr one-shot helper + once-guard test strength:
ha_rejoin_reconcilelives inha_convergence.c(alongsideha_self_restart/demotion task, unit-linkable) and defers truncate→WARN→restart to a delaymgr one-shot, reusing the 2.2 pattern. The test helperdc_run_pending_taskwas fixed to DEQUEUE (ordq_get+free) mirroring the real delaymgr worker (delay.c:65-90) — the prior peek-without-dequeue caused a test-only heap-UAF (re-running an already-freed one-shot); production never re-runs a one-shot, so the body-frees-own-ctx pattern is safe (bug-343). Follow-up (non-blocking, for task 6.1 or 7): add a once-guard test that callsha_rejoin_reconcileTWICE before draining the delaymgr (tip unchanged) and asserts exactly one task scheduled — the current second-call test runs truncation synchronously between calls, so call 2 exits at the 2.3 early-return and exercises only the structural guard, not the defensive CAS (CAS is present + correct, needed for a duplicate CtrlHelloReply arriving before the deferred truncation runs). - Task 5.1 done (RED) + 5.2 notes: counter-hook seam
ha_quorum_transition_log_hook(int lost,int connected,int required)is the observable (no log-capture harness exists);_Atomic int quorum_lostadded to ha_state_t; no-op stubha_log_quorum_transitionin ha_convergence.c. 5.2 must: (a) reportconnected = repl_active_standby_count(repl)andrequired = n_peers/2to match theha_has_write_quorumBODY (ha.c:~1488-1498) — its header comment says "ceil(n_peers/2)+1" but that's a pre-existing stale comment; do NOT change the quorum math, just mirror the body so the 5.1 tests pass; (b) re-grep the promotion standby-registration call site insideraft_cb_become_leader(rewritten by ha-single-leader-role) — RDHUP removal (~ha.c:3160),on_standby_connected(~ha.c:3764), and the gate-rejection backstop (~ha.c:1511) are verified present; (c) invoke the hook once per WARN emitted (CAS edge-detect onquorum_lost). - Task 5.2 done (GREEN) — quorum-decision duplication note:
ha_log_quorum_transition(ha_convergence.c) re-derivesconnected = repl_active_standby_count(repl)/required = n_peers/2/ single-node short-circuit as a FAITHFUL same-input mirror of the file-staticha_has_write_quorum(ha.c) — it cannot report a state inconsistent with the gate. Non-blocking maintainability risk: ifha_has_write_quorum's decision ever changes, this mirror must change too. Follow-up (optional cleanup): exposeha_has_write_quorumvia a non-static accessor and call it fromha_log_quorum_transitioninstead of duplicating. Call sites wired (post ha-single-leader-role rewrite): promotion ha.c:567, RDHUP removal ha.c:3180, on_standby_connected ha.c:3837, gate backstop ha.c:1521. - Task 7 done (GO) — full ASAN regression + boundary audit: full controller suite green under ASAN, established suite-by-suite (50/50 suites, 524 tests, 0 failures, 0 ASAN memory errors) because the aggregate meson
controller testSIGTERMs at the pre-existing 300s cap (slow commit-barrier timeout suites serialized). Audits PASS: (1) status/API read paths UNFILTERED —collect_jobs_committedsole caller issched.c:218(full push) + thejob_submit_committedpredicate filter at the JOB_NEW push;apiserver.c:83+opget.c:173stay on plaincollect_jobs; (2)commit.c/commit.hUNTOUCHED across the whole spec (git diff --stat e558a62..HEADempty for both); (3)raft_cb_become_followerdemotion additions guarded byprior_role == CTRL_PRIMARY && c != NULL(ha.c:687, :711) — STANDBY/ELECTING/INIT byte-equivalent to pre-spec. Production files touched match the design File Structure Plan (with dataex.c→egress.c per D3). No production change needed in task 7. - Task 8 (live 3-node E2E) — BLOCKED (manual): requires a running
/xce-deploy-hacluster + operator-observed kill/isolate/heal convergence; not automatable in the unit suite (same status as ha-wal-replication 5.4). All unit-testable in-process halves are covered by tasks 1-6. To close: run on real infra and record results.