20 KiB
Implementation Plan: ha-single-leader-role
TDD ordering per project steering (
testing.md): every implementation sub-task is preceded by a testsuite sub-task that lands failing (RED) tests first; deletion tasks are driven by retargeted test expectations plus compiler enforcement (the enum value is removed LAST so the compiler surfaces every missed reference). New tests live intestsuite/test_ha_role_lifecycle.c; two existing test files are deleted (their subject is the deleted machinery) and eight are updated — this shared-ha.c reality is why no(P)markers are applied: the plan is sequential. Hard upstream prerequisite: ha-commit-engine AND ha-commit-barrier-rollout must be implemented and green first — the promotion path callscommit_set_writable_lsnon the engine incluster_t.commit, and the universal barrier + dispatch-gate fact allowlist must be in force before the transitional state is removed (umbrella hard ordering). Verify exact line numbers with grep before editing — research.md §3 has the 2026-06-11 verified anchor table, but barrier-rollout landing first WILL shift ha.c line numbers.
-
1. WAL_NOOP entry type (additive foundation)
-
1.1 Write RED tests for the no-op entry
- Create
testsuite/test_ha_role_lifecycle.c(XCE_TESTING pattern, AAA structure) and register it incontroller_unit_test_src(src/controller/meson.build:74-131); first cases cover the entry type only - Tests:
persist_wal_async(p, WAL_NOOP, NULL, 0)appends a header-only entry (payload_len 0) and broadcasts it (stub replicator observes the entry);apply_wal_entryon a WAL_NOOP advancescurrent_lsnpast the entry's LSN and touches no entity state (jobmgr/queue/worker untouched); bootreplay_walover a segment containing WAL_NOOP replays cleanly with the same no-effect guarantee;wal_classify(WAL_NOOP, 0) == WAL_MODE_RETAIN(keeps barrier-rollout's totality test green) - Observable completion: build succeeds with the new test file; all new cases FAIL (red) —
WAL_NOOPdoes not exist yet - Requirements: 2.1
- Create
-
1.2 Implement WAL_NOOP to green
- Add
WAL_NOOP = 40towal_entry_type(src/include/wal_types.h:44-58) with comment: internal, empty payload, replay no-op, appended only at promotion; value stays belowWAL_ENTRY_TYPE_MAX(100, src/include/config.h:80) so standbys LSN-advance and ACK it via the existing common path - Add explicit
case WAL_NOOP: break;toapply_wal_entry's type switch (persist.c:728-765 anchor); add theWAL_NOOP → WAL_MODE_RETAINrow towal_classify(the row barrier-rollout pre-declared) - Observable completion: all task 1.1 tests PASS; barrier-rollout's classification totality test still PASSES
- Requirements: 2.1
- Add
-
2. Promotion rewrite: direct PRIMARY + own-term gate
-
2.1 Write RED lifecycle tests for direct promotion and the own-term gate
- Tests in
test_ha_role_lifecycle.c: driving the become-leader path yieldsha->role == CTRL_PRIMARYdirectly (no intermediate value observable;ha_is_primary()true immediately);ha_should_redirect()returns 0 the instant promotion returns, before any standby ACK; persistence hooks are registered by promotion (hooksys observes the three persist hook fns;hooks_registered == 1); promotion arms NO delaymgr watchdog (delaymgr task-count seam unchanged across promotion); with a real engine (n_peers=3, stub registry): promotion appends WAL_NOOP at lsn=N, registers it RETAIN, andcommit_set_writable_lsn(N)is in effect — acommit_submit(WAIT)for entry N+1 does NOT complete while ACKs cover N+1 but not N, and completes oncecommitted_lsn >= N; with the gate unmet a WAIT submission resolves only as barrier success,-ETIMEDOUT, or-XCE_ERR_NOT_READY— never via any promotion-side path; n_peers=1: gate opens immediately at promotion (noop self-committed), WAIT returns 0 as before - Observable completion: new cases FAIL (red) against the current COMMITTING-entering
raft_cb_become_leader - Requirements: 1.1, 1.3, 1.4, 1.5, 2.1, 2.2, 2.3
- Tests in
-
2.2 Rewrite raft_cb_become_leader and share the promotion sequence
- In
raft_cb_become_leader(ha.c:445-617 anchor): keep term sync, safe-LSN truncation,last_known_committed_lsn/committed_config_tagstores, phase2, convergence-array memsets, vote-endpoint promotion, config push, ctrlhello,ha_start_primary_heartbeat; replace theCTRL_PRIMARY_COMMITTINGstore (ha.c:505-508) with a directCTRL_PRIMARYstore +INFO("HA FSM (raft): → CTRL_PRIMARY (term=...)"); delete therepl_ack_countreset (ha.c:511) and the watchdog-arm block (ha.c:593-616) - Extract hook registration from
ha_promote_to_primary(ha.c:1691-1698) into staticha_register_persist_hooks(cluster_t *c)(keeps thehooks_registeredidempotency guard); call it in the rewrite BEFORE the no-op append;ha_promote_to_primarybody becomesha_do_promote+ha_register_persist_hooks+ha_start_primary_heartbeat+ the same noop+gate tail — every road to PRIMARY arms the gate; the rewrite does NOT callha_do_promote(raft owns the term; no double increment) - Noop+gate tail (production, guarded on
c != NULLand engine availability):persist_wal_async(WAL_NOOP)after endpoint promotion →noop_lsn = current_lsn - 1underp->lock→commit_submit(&c->commit, noop_lsn, COMMIT_MODE_RETAIN, 0)→commit_set_writable_lsn(&c->commit, noop_lsn); append failure → ERROR + WARN, gate left unset (degraded-but-safe per design Error Strategy), promotion still completes - Observable completion: all task 2.1 tests PASS; promotion never blocks (returns without waiting for the noop to commit)
- Requirements: 1.1, 1.3, 1.4, 1.5, 2.1, 2.2, 2.3
- In
-
3. Legacy XCE_TESTING election path symmetry
-
3.1 Update ha_elect_evaluate_and_signal and its direct tests (RED → GREEN in one pass)
- Retarget existing legacy-path expectations first (test_ha_start_election_async.c, the evaluation cases in test_ha_role.c): quorum win → role
CTRL_PRIMARYdirectly, no watchdog armed — run to confirm RED against current code - In the CTRL_PRIMARY branch of
ha_elect_evaluate_and_signal(ha.c:2247-2300 anchor): replace the COMMITTING store (2248-2252) with the directCTRL_PRIMARYstore, delete therepl_ack_countreset (2255) and the watchdog-arm block (2275-2299); keep the config-push/ctrlhello XCE_TESTING hooks; route promotion side effects through the same shared sequence (noop+gate tail guarded on persist/engine availability, as in production) - Observable completion: retargeted legacy-path tests PASS; lifecycle test for symmetry PASSES (both entry points produce identical role/hook/heartbeat/no-watchdog state)
- Requirements: 1.1, 1.5
- Retarget existing legacy-path expectations first (test_ha_start_election_async.c, the evaluation cases in test_ha_role.c): quorum win → role
-
4. Machinery deletions (watchdog, convergence, ack counters, gate branch, predicates)
-
4.1 Delete the force-promotion watchdog and ack/convergence machinery
- Delete from ha.c:
committing_timeout_fn(3080-3121),committing_timeout_fn_for_test(2950-2990),committing_timeout_ctx_ttype + fwd decl (64-69, 83), the cancel block inraft_cb_become_follower(640-648);ha_check_majority_convergence(1295-1353) and its heartbeat-reply call site (2710-2715);ha_repl_ack_received(3123-3141) — itsOPCode_replCatchUpDonebranch inrepl_ack_decode_and_dispatch(2659-2661) becomes an INFO-logged no-op (opcode stays gate-allowed;repl_complete_catchupuntouched);repl_ack_countinit at ha.c:835 - Delete declarations from ha.h:
ha_check_majority_convergence(396-412),ha_repl_ack_received(626-630) - Delete test files whose subject is this machinery: remove
testsuite/test_ha_repl_ack.candtestsuite/test_ha_evaluate_committing.cand their entries incontroller_unit_test_src(meson.build:116, 118 anchors); concerns still valid (promotion only on real quorum, no promotion without majority) are already pinned by task 2.1's gate tests - Observable completion: controller builds;
grep -rn "committing_timeout_fn\|ha_check_majority_convergence\|ha_repl_ack_received" src/controller/*.c src/controller/*.hreturns zero matches; test suite green - Requirements: 1.5, 2.3
- Delete from ha.c:
-
4.2 Delete the dispatch-gate COMMITTING branch and collapse role predicates
- Delete ONLY the
case CTRL_PRIMARY_COMMITTING:block fromha_dispatch_gate(ha.c:1594-1612 pre-rollout anchor — re-grep; the block follows the ELECTING case); the CTRL_PRIMARY branch — including barrier-rollout's no-quorum fact allowlist (OPCode_notify/update/setpreason/clrpreason) — is preserved byte-for-byte - Collapse compound role checks to PRIMARY-only:
raft_cb_vote_eligible(ha.c:210-211),handle_request_votegates (ha.c:1984, 2004),vote_ep_err_handler(ha.c:3159),peer_ctrl_msg_handlercase labels (ha.c:2789),primary_heartbeat_fnre-arm/run condition (ha.c:3956 — heartbeat re-arm condition drops COMMITTING); update the stale comments at each site plus ha.c:1940, 1971, 2057-2060, 2625-2627, 3044-3045 and theha_is_primary/ha_should_redirectdoc comments (no code change in those two functions) - Update gate tests: COMMITTING-specific cases in
test_ha_vote_gate.cand gate fixtures retargeted (elected node denies same-term votes as PRIMARY — denial parity pinned) - Observable completion: barrier-rollout's gate tests (PRIMARY fact allowlist under no-quorum) still PASS; vote-gate tests PASS with PRIMARY-only denial; suite green
- Requirements: 1.2, 1.3, 1.4
- Delete ONLY the
-
5. Test-fixture sweep and final symbol removal
-
5.1 Retarget the remaining COMMITTING-referencing fixtures
- Update expectations COMMITTING→PRIMARY (census: research.md §3):
test_ha_role.c(37 refs),test_ha_rdhup_integration.c(20),test_ha_repl_push.c(14),test_ha_startup.c(12),test_ha_connection_reuse.c(11),test_ha_start_election_async.c(7),test_ha_vote_gate.c(6, residual from 4.2); delete the 11cfg.committing_timeout_ms = 10000;initializers intest_config_repl.c - Each updated test asserts the new contract (direct PRIMARY, no transitional observations), not stubbed compatibility
- Observable completion: full controller suite green;
grep -rln "COMMITTING" src/controller/testsuite/returns zero files - Requirements: 1.1, 1.2
- Update expectations COMMITTING→PRIMARY (census: research.md §3):
-
5.2 Remove the enum value and remaining declarations (compiler-enforced completeness)
- Delete
CTRL_PRIMARY_COMMITTING = 4fromctrl_role_t(ha.h:64); deleteHA_DEFAULT_COMMITTING_TIMEOUT_MS(ha.h:131),committing_timeout_ms(ha.h:141),repl_ack_count(ha.h:180),committing_timeout_ctx(ha.h:187); delete the.committing_timeout_msinitializer (cluster.c:899); sweep remaining ha.h comments (214-230, 244-262, 373, 503-519, 607-614, 673-700, 791), the cluster.c ctrlhello comment (1402-1410), and the raft.c comment (src/lib/raft/raft.c:123) - Fix everything the compiler then surfaces — this is the completeness gate for code references
- Observable completion: clean build with zero warnings about the removed symbols; full controller testsuite + ASAN green via
/xce-build controller - Requirements: 1.2
- Delete
-
6. Documentation sync and grep verification
-
6.1 Synchronize steering and contributor docs to the three-state model
.kiro/steering/ha-async-io.md: heartbeat re-arm sentence (:51 — "PRIMARY only"); Replication Consistency section (:57 — replace the COMMITTING→PRIMARY convergence-promotion sentence with the own-term no-op gate: promotion appends WAL_NOOP, confirmation waits behindcommitted_lsn >= writable_lsn); dispatch-gate table (:68 — remove the CTRL_PRIMARY_COMMITTING row; note the PRIMARY no-quorum fact allowlist); Raft-to-code mapping (:79 — role set without PRIMARY_COMMITTING)src/controller/CLAUDE.md:40role-mapping table;.claude/skills/xce-deploy-ha/SKILL.md:98expected log line (now a single→ CTRL_PRIMARY);src/docs/cluster-config-split-analysis.md:34config listing (drop the deleted knob)- Observable completion:
grep -rn "COMMITTING" .kiro/steering/ src/controller/CLAUDE.md .claude/skills/xce-deploy-ha/ src/docs/returns zero matches - Requirements: 1.2
-
6.2 Final grep verification and full regression
- Run the completion gate:
grep -rEn "CTRL_PRIMARY_COMMITTING|PRIMARY_COMMITTING|committing_timeout|repl_ack_count|ha_repl_ack_received|ha_check_majority_convergence" --exclude-dir=.git --exclude-dir=specs --exclude-dir=.wolf --exclude-dir=.codegraph --exclude-dir=builddir .— zero matches; the only permitted residue is git history and.kiro/specs/documents (this spec's own census and the umbrella audit). Note.kiro/steering/is deliberately INSIDE the gate — a missed steering reference fails it - Run
/xce-build controller(debug + ASAN + full testsuite) — green; record the grep output and build result as task evidence - Observable completion: zero-match grep output committed as evidence; controller suite + ASAN green
- Requirements: 1.2, 1.5, 2.3
- Run the completion gate:
Implementation Notes
- (2.1/2.2) Two promotion seams in the lifecycle tests: cases 1-2 (role/redirect) drive
raft_cb_become_leader_for_testwhich forcesc==NULL(the noop+gate tail isc!=NULL-guarded and skipped); cases 3-7 (hooks/gate) drive the real-cluster shared seamha_promote_to_primary(&g_lc). Hook registration cannot be pinned on thec==NULLpath (the helper needs&c->hooksys) — case 3 is a green contract pin viaha_promote_to_primary. - (2.2) Expected category-(a) failures open from task 2.2 until retargeted: after the raft path enters
CTRL_PRIMARYdirectly, three single-node tests still assertCTRL_PRIMARY_COMMITTING (4)and FAIL (Expected 4 Was 0):test_single_node_shortcircuit_evaluates_immediately+test_single_node_not_primary_directly(test_ha_start_election_async.c) → retargeted by task 3.1;test_nonblocking_single_node_election_enters_committing(test_ha_startup.c) → retargeted by task 5.1. Single-node election reachesraft_cb_become_leadervia theraft_start_electionshortcircuit firingon_become_leader. - (2.2) ha.c shared sequence: raft path stores
CTRL_PRIMARYdirectly with NOha_do_promote(term synced at entry — avoids double-increment);ha_promote_to_primarykeepsha_do_promote(owns its term). Both call staticha_register_persist_hooks(c)(BEFORE noop append) and staticha_arm_own_term_gate(c)(persist_wal_async(WAL_NOOP)→noop_lsn = current_lsn-1under p->lock →commit_submit(RETAIN)→commit_set_writable_lsn); append-failure degrades safe (gate unset, promotion completes); non-blocking.committing_timeout_fn+ enum retained until tasks 4.x/5.2. - Unity runner gotcha: the aggregated controller runner prints the PREVIOUS passing test's
file:lineas a stale prefix on a FAIL line — attribute failures by test NAME, not the printed file:line prefix. - (3.1) Legacy path routes through BARE helpers, not
ha_promote_to_primary:ha_elect_evaluate_and_signal's PRIMARY branch callsha_register_persist_hooks+ha_start_primary_heartbeat+ha_arm_own_term_gatedirectly (matching the raft path), NOTha_promote_to_primary— because the latter callsha_do_promote(term increment) and the legacy branch historically has NO term store/increment (research §4 finding 2). This is a deliberate deviation from design §283's loose wording ("route through ha_promote_to_primary / ha_register_persist_hooks"); the bare-helper route preserves term semantics and is the correct reading. - (4.1) No shims — DELETE, don't stub (design §Transitional-State Deletions / §Design Synthesis: "rewritten or DELETED, not stubbed"). Machinery-subject test cases (convergence checker, committing watchdog, ack-driven promotion, ack counting) were DELETED, not shimmed: test_ha_role.c (5 majority_convergence + 2 committing_timeout_promotes_degraded), test_ha_rdhup_integration.c (rdhup_committing_to_primary_on_ack, rdhup_3node_one_ack_promotes), test_ha_connection_reuse.c (dispatcher_committing_handles_repl_ack, promoted_ep_repl_ack_promotes_to_primary). Kept state-entry cases had their deleted-fn CALLS excised (not shimmed).
test_ha_connection_reuse.cwas overlooked by the spec's §3 census disposition for machinery cases — confirmed in census (line 97) but its ack cases needed DELETE, not UPDATE. - (4.2) Residual stale COMMITTING comments in ha.c (~lines 443, 826, 1240): documentary prose on the legacy raft_cb_become_leader/convergence path — NOT live branches. Scrub in 5.2/6.x (they fail the task-6.2 repo-wide grep). After 4.2, ZERO live code branches on
CTRL_PRIMARY_COMMITTINGin ha.c; the enum value persists in ha.h:64 until 5.2. - (5.2) Code is COMMITTING-free; only DOCS remain for task 6.1: after 5.2 the grep
CTRL_PRIMARY_COMMITTING|HA_DEFAULT_COMMITTING_TIMEOUT_MS|committing_timeout_ms|repl_ack_count|committing_timeout_ctxis ZERO acrosssrc/controller/+src/lib/. Remaining tree references (task 6.1 docs-sync targets, then 6.2 verifies):src/docs/cluster-config-split-analysis.md:34(committing_timeout_ms) ANDsrc/controller/CLAUDE.md(TLA+/role-mapping table still names PRIMARY_COMMITTING — 6.1 must scrub ALL its COMMITTING refs, not just the :40 line) plus the steering/skill docs already listed in 6.1. ctrl_role_t now has 4 values: CTRL_PRIMARY=0, CTRL_STANDBY=1, CTRL_INIT=2, CTRL_ELECTING=3. - (5.1) Meson 300s timeout is now routinely hit — barrier tests' commit-deadline waits push controller-suite wall-time over the 300s meson cap under load (
Fail: 0, Timeout: 1). The AUTHORITATIVE green check is the DIRECT binary run (src/xce.build/controller/controller_unit_test, EXIT=0, 167 Tests/0 Failures aggregated). For task 6.2's "suite + ASAN green" gate: run the direct binary OR re-run meson until under-cap; aTimeout: 1is a wrapper artifact, NOT a test failure.test_committing_timeout_default_is_60swas DELETED in 5.1 (not 5.2) because its body used the uppercase production macroHA_DEFAULT_COMMITTING_TIMEOUT_MS(caught by the grep gate); the knob it tested dies in 5.2 regardless. - (4.2) Test-leak / suite-timeout facts: the controller test target sets
ASAN_OPTIONS=detect_leaks=0(controller/meson.build:163), so a pre-existing suite-wide LSan leak (~153KB, present at baseline678b7f8, this spec leaks slightly LESS) is suppressed under the meson gate — LSan exit=1 only on a RAW direct-binary run, not a regression. The controller suite runs ~268s against a 300s meson timeout — borderline; the final task-6.2 full regression may TIMEOUT under machine load — re-run if so (it is not a failure). - (4.1) Residual for task 6.2 final grep: 3 DOCUMENTARY PROSE comments in
test_ha_role_lifecycle.c(~lines 760/815/825) still mentionha_repl_ack_received/committing_timeout_fnby name (no live code). The 4.1 gate (src/controller/*.c|*.h) excludes testsuite so this passed; the task-6.2 repo-wide grep WILL catch them — scrub these comments in 5.x/6.x. Also a non-tracked editor backupsrc/controller/.ha.c.~undo-tree~holds stale refs (irrelevant; not a build input — do not commit). - (3.1) Expected-failure set after task 3.1 (8 category-(a), all owned downstream): task 4.1 DELETES
test_ha_evaluate_committing.c(3 fails: test_winning_quorum_enters_committing, test_committing_resets_repl_ack_count, test_election_timeout_fn_fires_evaluate); task 5.1 retargetstest_nonblocking_single_node_election_enters_committing(test_ha_startup.c),test_3node_win_calls_both_pushes(test_ha_repl_push.c), and 3 RDHUP cases (test_ha_rdhup_integration.c: rdhup_full_path_reaches_committing, rdhup_committing_to_primary_on_ack, rdhup_3node_one_ack_promotes).committing_timeout_fnnow has a benign-Wunused-functionwarning (no arming site left) until task 4.1 deletes it. - Lifecycle fixture (test_ha_role_lifecycle.c):
lifecycle_cluster_setupinstallsrepl_broadcast_send_hook = lc_drop_send_hook(drop-only, reset to NULL in teardown) — required once promotion broadcasts the WAL_NOOP, else fakeintendpoints cause an ASAN overflow. Gate cases primecurrent_lsn = noop_lsnso the post-increment append lands the noop atnoop_lsn(production derivesnoop_lsn = current_lsn-1).