Pre-existing uncommitted work: ha-repl-hardening payload data plane (7.x), heartbeat liveness fix (Req 1.3), connection-reuse ASAN fixes, doc cleanup. New: ha-consistency-invariants umbrella (requirements/design/research) and its four implementation sub-specs (specs generated via batch, cross-spec review applied), steering roadmap + product.md Raft+CP decision.
17 KiB
Implementation Plan
-
1. Foundation: build system and file scaffold
- Create
src/lib/raft/directory with placeholder files:raft.h,raft.c,raft_sim.c - Add
static_library('raft', 'raft/raft.c')tosrc/lib/meson.buildand link it intoxcelib - Add
testsuite/test_raft.cto the xcelib test executable block; guardraft_sim.ccompilation underXCE_TESTINGdefine /xce-build libcompiles to completion without link errors- Requirements: 8.3
- Create
-
2. Public API definition (raft.h)
-
2.1 Define core data structures: raft_role_t, raft_state_t, raft_snapshot_t
raft_role_tenum:FOLLOWER=0,CANDIDATE=1,LEADER=2raft_state_tstruct:term,role,voted_for,voted_for_term,n_peers,self_idx,votes_granted,replies_received,election_decided,election_timeout_ms,my_priorityraft_snapshot_t: point-in-time read ofterm,role,voted_for,voted_for_term,votes_granted,election_decided;raft_snapshot()signature declared- Header includes only
stdint.h,string.h,errno.h— noiomgr_t,endpoint_t,SSL_CTX,cluster_t, or capnp types visible anywhere - Requirements: 1.1, 1.3
-
2.2 Define callback interface (raft_callbacks_t, raft_config_t) and all public function signatures
raft_callbacks_twith 9 function pointers:on_send_vote_request,on_vote_eligible(optional/NULL-safe),on_become_leader,on_become_follower,on_persist,on_schedule_timeout,on_cancel_timeout,on_start_election_after_delay,on_trace(optional/NULL-safe)raft_config_tstruct:n_peers,self_idx,election_timeout_ms(0 → 2000ms default),my_priority,initial_term,initial_voted_for,cbs,ctx- All public function signatures:
raft_init,raft_start_election,raft_on_vote_request,raft_on_vote_reply,raft_on_election_timeout,raft_on_leader_lost,raft_check_and_start_election,raft_snapshot - Return code defines:
RAFT_OK,RAFT_GRANT,RAFT_DENY,RAFT_BECAME_LEADER,RAFT_BECAME_FOLLOWER,RAFT_PENDING,RAFT_ELECTED_IMMEDIATELY,RAFT_SKIP_ELECTION,RAFT_ELECTION_STARTED - Header compiles cleanly as a standalone include in a file with no other XCE headers
- Requirements: 1.2, 7.1, 7.2, 7.3
-
3. Core state machine implementation (raft.c)
-
3.1 Implement raft_init and raft_snapshot
raft_initvalidates all required callback pointers are non-NULL (all excepton_vote_eligibleandon_trace); returns-EINVALwithout modifying state if any required callback is NULL orn_peers < 1- Initializes from
raft_config_t: copiesn_peers,self_idx,election_timeout_ms(apply 2000ms default if 0),my_priority,initial_term,initial_voted_for; setsrole=FOLLOWER,votes_granted=0,replies_received=0,election_decided=0 raft_snapshot()fills snap with current field values (called while caller holds lock)raft_initwith a NULL required callback returns-EINVAL; subsequentraft_start_electionis not callable- Requirements: 1.2, 7.2, 7.3
-
3.2 Implement raft_start_election
- Guard: returns
-EALREADYifroleisRAFT_CANDIDATEorRAFT_LEADERwithout modifying state - Increments
term, setsrole=RAFT_CANDIDATE, recordsvoted_for=self_idx,voted_for_term=term, resetsvotes_granted=1,replies_received=0,election_decided=0 - Fires
on_persist(ctx, new_term, self_idx)for self-vote durability before sending any RPC - Single-node shortcircuit: if
n_peers==1, setsrole=RAFT_LEADER,election_decided=1, fireson_become_leader(ctx, term), returnsRAFT_ELECTED_IMMEDIATELY(no RPCs, no timeout) - For
n_peers>1: fireson_send_vote_request(ctx, peer_idx, term)for each peer index ≠self_idx, then fireson_schedule_timeout(ctx, election_timeout_ms) - Fires
on_traceevents:election_start,self_vote,term_updateif NULL-safe checks pass - Requirements: 2.1, 2.2, 2.3, 6.1
- Guard: returns
-
3.3 Implement raft_on_vote_request (incoming RequestVote processing)
- Stale term:
term < current_term→ set*reply_term, fireon_trace(vote_denied_stale_term), returnRAFT_DENY - Term upgrade:
term > current_term→ updatecurrent_term=term, clearvoted_for=-1, setrole=RAFT_FOLLOWER, fireon_trace(term_update) - Role gate: if
role == RAFT_LEADER→ returnRAFT_DENY(fireon_trace(vote_denied_role)); note: COMMITTING state is delegated toon_vote_eligiblein the adapter - Duplicate deny: if
voted_for != -1ANDvoted_for != from_idxANDvoted_for_term == term→ returnRAFT_DENY(fireon_trace(vote_denied_already_voted)) - Policy gate: if
on_vote_eligibleis non-NULL, call it; if it returns 0 → returnRAFT_DENY(fireon_trace(vote_denied_freshness)); if NULL, treat as eligible - Grant: fire
on_persist(ctx, term, from_idx); if non-zero, returnRAFT_DENYwithout updatingvoted_for; on success, setvoted_for=from_idx,voted_for_term=term, fireon_trace(vote_granted), returnRAFT_GRANT - Sets
*reply_term = current_termbefore every return path - Requirements: 3.1, 3.2, 3.3, 3.4, 3.5
- Stale term:
-
3.4 Implement raft_on_vote_reply (quorum tracking and election decision)
- Term upgrade: if
reply_term > current_term, updatecurrent_term, setrole=RAFT_FOLLOWER,election_decided=1, fireon_become_follower(ctx, current_term, -1),on_cancel_timeout,on_trace(became_follower), returnRAFT_BECAME_FOLLOWER - No-op guard: if
election_decided, fireon_trace(vote_reply_noop), returnRAFT_PENDING - Increment
replies_receivedon every call past the guard (tracks total peer replies) - Grant accumulation: if
granted, incrementvotes_granted; quorum =(n_peers / 2) + 1 - Quorum reached: set
role=RAFT_LEADER,election_decided=1, fireon_become_leader(ctx, term),on_cancel_timeout,on_trace(quorum_reached),on_trace(became_leader), returnRAFT_BECAME_LEADER - All-replied-no-quorum: if
replies_received == n_peers - 1andvotes_granted < quorum, setrole=RAFT_FOLLOWER,election_decided=1, fireon_become_follower(ctx, term, -1),on_trace(became_follower), returnRAFT_BECAME_FOLLOWER - Requirements: 4.1, 4.2, 4.3, 4.4
- Term upgrade: if
-
3.5 Implement raft_on_election_timeout, raft_on_leader_lost, raft_check_and_start_election
raft_on_election_timeout: ifelection_decided, fireon_cancel_timeout,on_trace(election_timeout_noop), return; otherwise setrole=RAFT_FOLLOWER,election_decided=1, fireon_become_follower(ctx, term, -1),on_cancel_timeout,on_trace(election_timeout)raft_on_leader_lost: clearvoted_for=-1, setrole=RAFT_FOLLOWER, fireon_start_election_after_delay(ctx, my_priority * election_timeout_ms), fireon_trace(leader_lost)raft_check_and_start_election: ifvoted_for != -1ANDvoted_for != self_idx(another node won during the delay), fireon_trace(jitter_skip), returnRAFT_SKIP_ELECTION; otherwise callraft_start_election(s)internally, returnRAFT_ELECTION_STARTED- After
raft_on_leader_lost, callingraft_check_and_start_electionwith no intervening vote grant starts a new election and returnsRAFT_ELECTION_STARTED - Requirements: 5.1, 5.2, 5.3, 6.2, 6.3
-
4. (P) Deterministic N-node simulator (raft_sim.c) Depends: 3
-
4.1 Implement simulator data structures and initialization
raft_sim_node_t: containsraft_state_t state,raft_callbacks_t cbs,void *ctx(points back to cluster),int idx,int partition, per-node event log buffer foron_tracecaptureraft_sim_cluster_t: array of nodes up toRAFT_SIM_MAX_NODES, node countn,leader_count(incremented by eachon_become_leaderfiring)raft_sim_init(cluster, n_nodes, election_timeout_ms): initializesnnodes with indices 0..n-1, priority=idx, wireson_send_vote_requestto buffer the RPC for routing, wireson_become_leaderto incrementleader_countraft_sim_dump_log(cluster, FILE*): prints[step N] nodeX: eventlines;RAFT_SIM_VERBOSE=1enables unconditional output; prints to stderr on Unity test failureraft_sim_init(c, 3, 2000)completes without error; all 3 nodes haverole=FOLLOWER,term=0- Requirements: 8.1
- Boundary: raft_sim.c
-
4.2 Implement simulator network operations and test driving
raft_sim_deliver_pending(cluster): routes all buffered RPCs synchronously — for each buffered(src, dst, term)entry, callsraft_on_vote_requestondstthen routes the reply back tosrcviaraft_on_vote_reply; skips delivery ifdsthaspartition=1raft_sim_start_all_elections(cluster): callsraft_start_electionon each node in order 0..n-1raft_sim_fire_timeout(cluster, node_idx): callsraft_on_election_timeouton the specified noderaft_sim_partition(cluster, node_idx, isolated): sets/clearspartitionflag; subsequentraft_sim_deliver_pendingdrops RPCs to/from that node- Running
raft_sim_start_all_elections+raft_sim_deliver_pendingon a 3-node cluster yieldsleader_count == 1 - Requirements: 8.1, 8.2
- Boundary: raft_sim.c
-
5. Protocol-level test suite (test_raft.c) Depends: 4
-
5.1 Test Groups A and B: basic election correctness and voted_for invariant (10 cases)
- A1: single-node cluster → LEADER immediately;
on_become_leaderfires once; noon_send_vote_requestcalls - A2: 3-node simultaneous election →
leader_count == 1afterraft_sim_deliver_pending - A3: node 0 wins, node 1 starts late election same term → node 1 denied; total
leader_countstays 1 - A4: 5-node election → leader wins after 3 grants (quorum = 3); remaining 2 become FOLLOWER
- A5: vote request arrives at a LEADER node with any term → denied; role stays
RAFT_LEADER - B1: two candidates request vote from node C same term → only first requester granted
- B2: vote request
term > current_term→ node updates term, clearsvoted_for, re-evaluates; test confirmsstate.termupdated - B3: vote request
term < current_term→ denied immediately;*reply_termequals node's current term - B4: node voted for X; receives request from Y same term → denied;
voted_forstays X - B5: node voted for X; receives request from X again same term → re-granted (idempotent)
- All 10 Unity test cases pass;
raft_sim_dump_logconfirms correct event sequence for A2 - Requirements: 2.1, 2.2, 2.3, 3.1, 3.2, 3.3, 3.5, 8.2, 8.3
- A1: single-node cluster → LEADER immediately;
-
5.2 Test Groups C and H: on_vote_eligible policy and persistence/restart (11 cases)
- C1: all Raft invariants pass,
on_vote_eligiblereturns 0 → denied - C2: all Raft invariants pass,
on_vote_eligiblereturns 1 → granted - C3:
on_vote_eligibleis NULL at init → eligible candidates granted when Raft invariants pass - C4: stale term →
on_vote_eligiblenever called (verified via mock call counter) - C5:
voted_foralready set →on_vote_eligiblenever called - H1:
raft_initwithinitial_term=5,initial_voted_for=1→state.term==5,state.voted_for==1immediately after init - H2:
raft_initwithinitial_term=0,initial_voted_for=-1→ first-boot defaults (role=FOLLOWER,term=0) - H3: vote granted →
on_persistfires (and records its call) beforevoted_foris set in state; verified by checking call order in mock - H4:
on_persistreturns non-zero → vote denied;voted_forunchanged in state - H5:
raft_start_election→on_persistfires with(new_term, self_idx)for self-vote - Null-callback:
raft_initwith NULL required callback returns-EINVAL; state unmodified - All 11 Unity test cases pass
- Requirements: 1.2, 3.4, 3.5, 7.2, 7.3, 8.3
- C1: all Raft invariants pass,
-
5.3 Test Groups D and E: quorum evaluation and election timeout (7 cases)
- D1: 3-node cluster, 1 peer grant received (self + 1 = quorum 2) →
role==RAFT_LEADER;on_become_leaderfired once - D2: 3-node, both peers deny →
election_decided==1,role==RAFT_FOLLOWER;on_become_followerfired - D3:
on_become_leaderfired; second vote reply arrives → returnsRAFT_PENDING;leader_countstays 1 - D4: reply with
term > current_term→role==RAFT_FOLLOWER;on_become_followerfires;state.termupdated - E1: timeout fires before quorum →
on_become_followerfires;on_cancel_timeoutfires;election_decided==1 - E2: timeout fires after quorum reached →
on_cancel_timeoutfires;rolestaysRAFT_LEADER;on_become_followernot fired - E3: start new election after timeout →
state.termincremented again;on_schedule_timeoutfires again - All 7 Unity test cases pass; trace buffer confirms
on_cancel_timeoutfires in E1 and E2 - Requirements: 4.1, 4.2, 4.3, 4.4, 6.1, 6.2, 6.3, 8.2, 8.3
- D1: 3-node cluster, 1 peer grant received (self + 1 = quorum 2) →
-
5.4 Test Groups F and G: RDHUP failover and split-brain prevention (6 cases)
- F1: nodes 1 and 2 call
raft_on_leader_lost; mockon_start_election_after_delayreceives1×timeoutfor node 1 and2×timeoutfor node 2 (priority-based jitter) - F2: during node 1's delay, node 1 receives and grants a vote request from another node →
voted_for != self_idx;raft_check_and_start_electionreturnsRAFT_SKIP_ELECTION - F3:
raft_on_leader_lostfollowed byraft_check_and_start_electionwith no intervening vote grant → returnsRAFT_ELECTION_STARTED;state.role==RAFT_CANDIDATE - F4: simultaneous RDHUP on all 3 remaining nodes with identical priority → exactly 1 LEADER after delivery (self-vote prevents any node from granting two votes)
- G1: 100-round randomized 3-node election loop via simulator →
leader_countnever exceeds 1 per round; loop completes without assertion failure - G2: partition node 2 from nodes 0+1 → 2-node partition can elect (
leader_count==1); isolated node 2 cannot win (votes_granted < quorum) - All 6 Unity test cases pass; G1 confirms split-brain freedom across 100 rounds
- Requirements: 5.1, 5.2, 5.3, 8.1, 8.2, 8.3
- F1: nodes 1 and 2 call
-
6. (P) Controller adapter: wire raft.h into ha.c Depends: 3
-
6.1 Add raft_state_t to ha_state_t and initialize at startup
- Add
#include "raft/raft.h"toha.h; addraft_state_t raftfield toha_state_t - In the ha initialization path, call
raft_init(&ha->raft, &cfg)with term/voted_for loaded fromha_persist_load(), all ha-implemented callbacks, andha_state_t*as context /xce-build controllercompiles without errors after the change- Requirements: 9.1, 9.2
- Boundary: ha.h, ha.c adapter init
- Add
-
6.2 Implement all raft_callbacks_t callbacks in ha.c
on_send_vote_request: encode capnpVoteRequestand send via existingendpoint_t/iomgr pathon_become_leader: invoke existing ha leadership promotion path (phase2 trigger)on_become_follower: invoke existing ha standby transition pathon_persist: callha_persist_term(ha, term, voted_for)for durable write before granting voteon_schedule_timeout: schedule election watchdog timer via existing delaymgr mechanismon_cancel_timeout: cancel pending election timeron_start_election_after_delay: schedule RDHUP retry timer with the received priority-based delayon_vote_eligible: implement freshness gate (candidate config_tag ≥ local config_tag AND candidate log_lsn ≥ applied_lsn), role gate (CTRL_PRIMARY_COMMITTING → return 0), heartbeat liveness check (active heartbeat → return 0 on standby); return 1 if all pass- All 8 callbacks compile and link; adapter initializes in the existing ha startup flow
- Requirements: 9.1
- Boundary: ha.c callbacks
-
6.3 Delegate election state management to raft_* API
- Remove direct
ha->term++andha->voted_for = ...writes fromha_start_election_async; replace withraft_start_election(&ha->raft) - Replace vote request handling in the capnp vote-request handler with
raft_on_vote_request(&ha->raft, from_idx, term, &reply_term) - Replace vote reply accumulation with
raft_on_vote_reply(&ha->raft, from_idx, granted, term) - Replace RDHUP election trigger with
raft_on_leader_lost(&ha->raft) - No direct writes to
ha->termorha->voted_forremain outsideon_persistandon_vote_eligiblecallbacks after the refactor - Requirements: 9.2
- Boundary: ha.c election dispatch
- Remove direct
-
6.4 Verify XCE_TESTING hook seams remain functional
- Confirm
ha_phase2_hookandha_rdhup_election_hookstill compile and are invoked at the same protocol points as before the refactor - Run
/xce-build controller; all pre-existing controller tests pass without regression - Test pass count for
test_config_repl.cand other ha-related tests matches or exceeds pre-refactor baseline - Requirements: 9.3
- Boundary: ha.c hook seams
- Confirm
-
7. Validation: ASAN build and full protocol verification Depends: 5, 6
/xce-build controllerwithASAN_OPTIONS=detect_leaks=0produces zero sanitizer errors or warnings- All
test_raft.cgroups (A–H, ≥ 20 cases + H null-callback case) pass under ASAN - All pre-existing controller tests (
test_config_repl.cand others) remain green under ASAN - lib + controller combined test pass count meets or exceeds pre-refactor numbers
- Requirements: 8.3