Captures all uncommitted work from ha-config-bootstrap, ha-heartbeat-liveness, and associated spec/tooling updates: - src: ha.c, ha.h, cluster.c, config_repl.c, myapp.c, raft.c — heartbeat liveness, RDHUP integration, committing phase fixes - tests: test_ha_role.c, test_config_repl.c, test_ha_rdhup_integration.c, test_p1_integration.c, test_p3_hardening.c, scheduler test - specs: election-iomgr-refactor, ha-heartbeat-liveness, ha-peer-connection-reuse, raft-library (new); ha-config-bootstrap and election-iomgr-refactor task/research updates - tooling: .wolf/ state, ha/data cluster fixtures, raft/raft.tla, src/build symlink, xce-build skill update
30 KiB
Design Document: raft-library
Overview
This feature extracts the Raft-style election state machine from ha.c into a self-contained
C library (src/lib/raft/) that is decoupled from the iomgr/SSL/capnp transport layer. The
library exposes a pure state machine with a callback interface for all transport effects, enabling
deterministic unit testing without a live cluster.
Purpose: Provide a correct, independently verifiable Raft election implementation that eliminates
split-brain bugs traceable to entangled state transitions in ha.c.
Users: Controller developers will use this to wire the election protocol into the controller via a thin adapter, and to verify protocol correctness via a deterministic N-node simulator.
Impact: ha.c becomes an adapter over raft.h rather than the owner of election state.
ha_state_t.term, ha_state_t.voted_for_*, and quorum logic move to raft_state_t.
Goals
- Correct Raft invariants: one vote per term, quorum safety, no split-brain in 3-node deterministic tests
- Transport-free public API:
raft.hhas zero dependency onendpoint_t,SSL_CTX,iomgr_t,cluster_t - Exhaustive protocol-level test cases covering all invariants specified in requirements
- Drop-in wiring: the controller adapter preserves existing XCE_TESTING hook seams
Non-Goals
- Full Raft log replication (XCE is single-writer; only leader election is needed)
- WAL truncation on failover (remains in
ha.c/persist.c) - Config replication push (remains in
config_repl.c) - Disk persistence of term/voted_for (caller's responsibility via on_persist callback)
- Multi-Raft or multi-group election
Boundary Commitments
This Spec Owns
src/lib/raft/raft.h— public API:raft_state_t,raft_callbacks_t, all function signaturessrc/lib/raft/raft.c— pure state machine implementationsrc/lib/raft/raft_sim.c— deterministic N-node simulator (test harness only)src/lib/testsuite/test_raft.c— protocol-level test suite (≥ 20 test cases)src/lib/meson.buildadditions forraft.candtest_raft.c- The definition of
raft_callbacks_tand the semantic contract for each callback
Out of Boundary
ha.cadapter wiring (separate task, touches ha.c but does not own raft.h)- SSL/iomgr transport implementation of callbacks
- WAL truncation, config push, cluster_init_phase2
- Persistence to disk (ha-term.bin) — caller implements via on_persist callback
- Controller startup sequencing (
myapp_run)
Allowed Dependencies
raft.handraft.c:stdint.h,string.h,errno.honly — no pthread, no OS primitives- The library has zero threading dependencies; concurrency is entirely the caller's responsibility
- No libxce dependencies from
raft.h(headers that includeendpoint_tor similar are forbidden) - Test file may include
<unity/unity.h>(project test framework) and<pthread.h>(for test harness only) raft_sim.cmay include<string.h>and<stdio.h>for the simulator and dump log
Revalidation Triggers
- Changes to
raft_callbacks_tshape (adding/removing fields) require re-check ofha.cadapter - Changes to quorum formula require re-running protocol-level tests
- Changes to role enum values require scanning
ha.cfor role-comparison code
Architecture
Existing Architecture Analysis
ha.c currently mixes three concerns:
- Raft state — term, voted_for, role, quorum tracking (
ha_elect_ctx_t) - Transport —
endpoint_connect(),iomgr_monitor(), SSL setup, capnp encoding/decoding - Post-election actions —
cluster_init_phase2(),push_config_to_standbys(), WAL truncation
The split-brain bugs arise from concern #1 being modified across threads without a consistent lock discipline: the transport layer (concern #2) and post-election hooks (concern #3) call into Raft state functions without a clear ownership model.
Architecture Pattern
graph TB
subgraph libxce_raft
RaftState[raft_state_t]
RaftAPI[raft.c Pure SM]
RaftSim[raft_sim.c N-node sim]
end
subgraph ha_adapter
HaAdapter[ha.c adapter]
end
subgraph transport
Iomgr[iomgr endpoint_t SSL]
Persist[ha_persist_term]
DelayMgr[delaymgr timeout]
end
HaAdapter -->|calls| RaftAPI
RaftAPI -->|callbacks| HaAdapter
HaAdapter -->|drives| Iomgr
HaAdapter -->|drives| Persist
HaAdapter -->|drives| DelayMgr
RaftSim -->|calls| RaftAPI
Key decisions:
- No threads in the library:
raft.cis fully synchronous. The caller holds a lock before calling anyraft_*function. - Callbacks fire synchronously: The library calls back into the adapter within the same call frame. The adapter is responsible for not holding library locks during slow I/O.
- Role enum is self-contained:
raft_role_tinraft.his independent ofctrl_role_tinha.h; the adapter maps between them.
Technology Stack
| Layer | Choice | Role |
|---|---|---|
| Language | C gnu17 | Matches project standard |
| OS dependencies | None (stdint.h, string.h, errno.h only) |
Library is OS-free; concurrency is entirely caller-owned |
| Threading | Caller-owned (pthread or none) | raft_state_t is not thread-safe by itself; caller holds whatever lock fits their threading model |
| Atomics | None in library | Caller uses atomics for ha_state_t integration if needed |
| Test framework | Unity (existing) | Consistent with controller testsuite |
| Build | Meson (existing) | static_library('raft', 'raft.c') added to src/lib/meson.build |
File Structure Plan
src/lib/
├── raft/
│ ├── raft.h # Public API: raft_state_t, raft_callbacks_t, all function signatures
│ ├── raft.c # Pure state machine: term, voted_for, role, quorum evaluation
│ └── raft_sim.c # N-node deterministic simulator (compiled only under XCE_TESTING)
├── testsuite/
│ └── test_raft.c # Protocol-level test suite (≥ 20 cases)
└── meson.build # Add raft.c to xcelib; add test_raft.c to xcelib_test
Modified files:
src/lib/meson.build— addraft/raft.cto xcelib sources; addtestsuite/test_raft.csrc/controller/ha.h— add#include "raft/raft.h"andraft_state_t raftfield toha_state_t(adapter task)src/controller/ha.c— delegate election logic toraft_*API (adapter task, separate from library task)
System Flows
Election State Machine
stateDiagram-v2
[*] --> FOLLOWER : raft_init
FOLLOWER --> CANDIDATE : raft_start_election
CANDIDATE --> LEADER : quorum reached
CANDIDATE --> FOLLOWER : timeout or higher term
LEADER --> FOLLOWER : higher term seen
FOLLOWER --> FOLLOWER : raft_on_leader_lost clears voted_for
FOLLOWER --> CANDIDATE : raft_on_election_timeout
Vote Request Processing
sequenceDiagram
participant Caller
participant RaftSM as raft.c
participant CB as callbacks
Caller->>RaftSM: raft_on_vote_request(state, from, term)
alt term < current_term
RaftSM-->>Caller: RAFT_DENY stale term
else term > current_term
RaftSM->>RaftSM: update term, clear voted_for, role=FOLLOWER
end
alt already voted for different candidate same term
RaftSM-->>Caller: RAFT_DENY already voted
else on_vote_eligible returns 0
RaftSM->>CB: on_vote_eligible(ctx, from, term)
CB-->>RaftSM: 0 deny
RaftSM-->>Caller: RAFT_DENY policy
else all checks pass
RaftSM->>CB: on_vote_eligible(ctx, from, term)
CB-->>RaftSM: 1 grant
RaftSM->>CB: on_persist(term, from)
RaftSM->>RaftSM: voted_for = from
RaftSM-->>Caller: RAFT_GRANT
end
on_vote_eligible contract (implemented by ha.c adapter):
- Return 0 if local role is LEADER or CTRL_PRIMARY_COMMITTING
- Return 0 if STANDBY with fresh heartbeat (primary still alive)
- Return 0 if in-catch-up state
- Return 0 if candidate config_tag < local config_tag, or equal tag with lower LSN
- Return 1 otherwise (candidate is eligible)
RDHUP Failover (Priority Jitter)
sequenceDiagram
participant Caller
participant RaftSM as raft.c
participant CB as callbacks
Caller->>RaftSM: raft_on_leader_lost(state, my_priority)
RaftSM->>RaftSM: clear voted_for, role=FOLLOWER
RaftSM->>CB: on_start_election_after_delay(priority * election_timeout_ms)
note over Caller: delay elapses in caller's timer
Caller->>RaftSM: raft_check_and_start_election(state)
alt voted_for set to non-self (received vote during delay)
RaftSM-->>Caller: RAFT_SKIP_ELECTION
else
RaftSM->>RaftSM: raft_start_election internally
RaftSM->>CB: on_send_vote_request per peer
end
Components and Interfaces
Component Summary
| Component | Layer | Intent | Req Coverage | Contracts |
|---|---|---|---|---|
raft_state_t |
Data | Raft node state | 1.1, 1.2 | State |
raft_callbacks_t |
Interface | Transport/effect boundary | 7.1, 7.2, 7.3 | Service |
raft.c State Machine |
Logic | Pure election logic | 1–6 | Service |
raft_sim.c Simulator |
Test | Deterministic N-node harness | 8.1, 8.2 | Service |
ha.c Adapter |
Integration | Wire raft.c to iomgr/SSL | 9.1, 9.2, 9.3 | Service |
Core Data Structures
/* raft_role_t: self-contained role enum (does not depend on ctrl_role_t) */
typedef enum {
RAFT_FOLLOWER = 0,
RAFT_CANDIDATE = 1,
RAFT_LEADER = 2,
} raft_role_t;
/* raft_state_t: pure Raft consensus state for one node.
*
* Contains only the Raft invariant state. Application-specific fields
* (config_tag, log_lsn, heartbeat timestamps, ctrl_role) stay in ha_state_t;
* they are accessed by the caller via the on_vote_eligible callback. */
typedef struct {
uint64_t term; /* current term (monotonically increasing) */
raft_role_t role; /* current role */
int voted_for; /* peer index voted for this term; -1=none; self_idx=self-vote */
uint64_t voted_for_term; /* term in which voted_for was set */
int n_peers; /* total cluster size (including self) */
int self_idx; /* this node's index in [0, n_peers) */
int votes_granted; /* votes collected in current election */
int election_decided; /* 1 once on_become_leader/follower fired */
uint32_t election_timeout_ms;
int my_priority; /* priority index for jitter (0 = highest) */
} raft_state_t;
/* raft_callbacks_t: all side effects the library fires.
*
* The library owns Raft invariants (term ordering, one-vote-per-term, quorum).
* All application-specific policy (freshness gate, role gate, catch-up state,
* heartbeat liveness) is delegated to on_vote_eligible so the library is
* fully independent of XCE domain logic.
*/
typedef struct {
/* Send a RequestVote RPC to peer peer_idx for the given term.
* The caller encodes config_tag, log_lsn, and any other fields in the wire RPC. */
int (*on_send_vote_request)(void *ctx, int peer_idx, uint64_t term);
/* Application-specific vote eligibility gate.
* Called after Raft invariants pass (correct term, voted_for unset).
* Returns 1 to grant, 0 to deny. Receives the candidate's peer_idx and
* the incoming term so the caller can check freshness (config_tag, log_lsn),
* role gate (LEADER/COMMITTING deny), catch-up status, heartbeat liveness.
* The library grants the vote only if this callback returns 1. */
int (*on_vote_eligible)(void *ctx, int from_idx, uint64_t term);
/* Node has won the election */
void (*on_become_leader)(void *ctx, uint64_t term);
/* Node lost election or saw higher term; leader_idx = -1 if unknown */
void (*on_become_follower)(void *ctx, uint64_t term, int leader_idx);
/* Persist term + voted_for before granting vote (durability guarantee) */
int (*on_persist)(void *ctx, uint64_t term, int voted_for);
/* Schedule election timeout (fires raft_on_election_timeout after delay_ms) */
void (*on_schedule_timeout)(void *ctx, uint32_t delay_ms);
/* Cancel pending election timeout */
void (*on_cancel_timeout)(void *ctx);
/* Request delayed election start after RDHUP (priority-based jitter) */
void (*on_start_election_after_delay)(void *ctx, uint32_t delay_ms);
/* Trace callback for observability (optional, may be NULL) */
void (*on_trace)(void *ctx, const char *event, const char *detail);
} raft_callbacks_t;
Public API (raft.h)
/*
* raft_config_t: initialization context for a Raft node.
*
* Encapsulates all configuration so raft_init() has a single, extensible
* parameter rather than a flat list. Matches the existing XCE pattern
* (ha_config_t, persist_config_t). Zero-initialize and set only the
* fields you need — the library fills in safe defaults for unset fields.
*
* Persistence model:
* initial_term / initial_voted_for are restored by the caller from disk
* before raft_init(). The library never reads storage; it only writes
* via on_persist callbacks. Pass {0, -1} on first boot.
*/
typedef struct {
int n_peers; /* total cluster size (including self) */
int self_idx; /* this node's index in [0, n_peers) */
uint32_t election_timeout_ms; /* base election timeout; 0 → default 2000ms */
int my_priority; /* priority index for RDHUP jitter (0 = highest) */
uint64_t initial_term; /* term restored from disk; 0 on first boot */
int initial_voted_for; /* voted_for restored from disk; -1 if none */
const raft_callbacks_t *cbs; /* all callbacks; required callbacks must be non-NULL */
void *ctx; /* opaque caller context passed to every callback */
} raft_config_t;
/*
* raft_init: initialize raft_state_t from a raft_config_t.
* on_vote_eligible and on_trace may be NULL (optional policy / observability).
* All other callbacks in cbs must be non-NULL.
* Returns 0 on success; -EINVAL if a required callback is NULL or n_peers < 1.
*/
int raft_init(raft_state_t *s, const raft_config_t *cfg);
/*
* raft_start_election: begin a new election.
* Increments term, records self-vote (voted_for=self_idx), transitions to
* CANDIDATE, fires on_send_vote_request for each peer, fires on_schedule_timeout.
* Returns 0 on success; -EALREADY if already CANDIDATE/LEADER;
* RAFT_ELECTED_IMMEDIATELY for single-node (n_peers==1).
*/
int raft_start_election(raft_state_t *s);
/*
* raft_on_vote_request: process an incoming RequestVote RPC.
* Applies Raft invariants (term comparison, one-vote-per-term).
* If invariants pass, calls on_vote_eligible to allow application policy.
* Fires on_persist before granting.
* Returns RAFT_GRANT (1) or RAFT_DENY (0); sets *reply_term to current term.
*/
int raft_on_vote_request(raft_state_t *s, int from_idx, uint64_t term,
uint64_t *reply_term);
/*
* raft_on_vote_reply: process a reply to our RequestVote.
* Returns RAFT_BECAME_LEADER, RAFT_BECAME_FOLLOWER, or RAFT_PENDING.
*/
int raft_on_vote_reply(raft_state_t *s, int from_idx, int granted, uint64_t term);
/*
* raft_on_election_timeout: called by the timer when no quorum was reached.
* Transitions CANDIDATE → FOLLOWER; fires on_become_follower, on_cancel_timeout.
*/
void raft_on_election_timeout(raft_state_t *s);
/*
* raft_on_leader_lost: primary control channel dropped (RDHUP).
* Clears voted_for, transitions to FOLLOWER, fires on_start_election_after_delay.
*/
void raft_on_leader_lost(raft_state_t *s);
/*
* raft_check_and_start_election: call after the delay from raft_on_leader_lost.
* Starts election only if we have not already voted for a non-self candidate
* in the new term (i.e., another node won during the jitter window).
* Returns RAFT_SKIP_ELECTION or RAFT_ELECTION_STARTED.
*/
int raft_check_and_start_election(raft_state_t *s);
/* Return codes */
#define RAFT_OK 0
#define RAFT_GRANT 1
#define RAFT_DENY 0
#define RAFT_BECAME_LEADER 1
#define RAFT_BECAME_FOLLOWER 2
#define RAFT_PENDING 3
#define RAFT_ELECTED_IMMEDIATELY 4
#define RAFT_SKIP_ELECTION 5
#define RAFT_ELECTION_STARTED 6
Preconditions for all functions: Caller must hold a mutex protecting raft_state_t.
Postconditions: Callbacks may fire synchronously within the call; caller must not hold transport locks during callback execution.
Deterministic Simulator (raft_sim.c)
The simulator replaces real network I/O with synchronous in-process callback routing.
/* raft_sim_node_t: one simulated cluster node */
typedef struct {
raft_state_t state;
raft_callbacks_t cbs;
void *ctx; /* points back to sim_cluster_t */
int idx;
int partition; /* 1 = isolated from other nodes */
} raft_sim_node_t;
/* raft_sim_cluster_t: N-node simulation cluster */
typedef struct {
raft_sim_node_t nodes[RAFT_SIM_MAX_NODES];
int n;
int leader_count; /* incremented on each on_become_leader */
} raft_sim_cluster_t;
int raft_sim_init(raft_sim_cluster_t *c, int n_nodes, uint32_t election_timeout_ms);
void raft_sim_start_all_elections(raft_sim_cluster_t *c); /* triggers all nodes simultaneously */
void raft_sim_deliver_pending(raft_sim_cluster_t *c); /* routes buffered RPCs */
void raft_sim_fire_timeout(raft_sim_cluster_t *c, int node_idx);
void raft_sim_partition(raft_sim_cluster_t *c, int node_idx, int isolated);
Protocol-Level Use Cases (Testing Strategy)
The following test cases verify Raft invariants at the protocol level, independent of transport.
Each test uses raft_sim_cluster_t and runs to completion synchronously.
Group A: Basic Election Correctness (Req 1–4, 7, 8)
| TC | Scenario | Nodes | Expected |
|---|---|---|---|
| A1 | Single-node cluster starts election | 1 | Immediately LEADER, on_become_leader fires once |
| A2 | 3-node simultaneous election, node 0 has priority | 3 | Exactly 1 LEADER; nodes 1,2 grant to node 0 and become FOLLOWER |
| A3 | 3-node election, node 0 wins, node 1 starts late election same term | 3 | Node 1's election denied (voted_for set); still 1 LEADER |
| A4 | 5-node election, quorum = 3 | 5 | Leader wins after 3 grants; 2 remaining nodes become FOLLOWER |
| A5 | Node receives vote request in LEADER role | 3 | Denies regardless of term; role stays LEADER |
Group B: Voted_For Invariant (Req 3, one-vote-per-term)
| TC | Scenario | Expected |
|---|---|---|
| B1 | Node A and B both request vote from node C in same term | C grants only the first requester; second is denied |
| B2 | Node sees vote request with term > current_term | Updates term, clears voted_for, re-evaluates request |
| B3 | Node sees vote request with term < current_term | Denies immediately, returns current term |
| B4 | Node already voted for X in term T; receives request from Y in term T | Denies Y; voted_for stays X |
| B5 | Node granted vote for X in term T; receives request from X again in term T | Grants (idempotent re-grant to same candidate) |
Group C: on_vote_eligible Policy Callback (Req 3.4, 3.5)
The library calls on_vote_eligible after Raft invariants pass. These tests use a
mock on_vote_eligible that returns a configurable value:
| TC | Scenario | on_vote_eligible returns | Expected |
|---|---|---|---|
| C1 | All Raft invariants pass; on_vote_eligible returns 0 | 0 | Denied (policy) |
| C2 | All Raft invariants pass; on_vote_eligible returns 1 | 1 | Granted |
| C3 | on_vote_eligible is NULL at init | — | All eligible candidates granted (no policy gate) |
| C4 | Raft invariant fails (stale term); on_vote_eligible NOT called | N/A | Denied before callback fires |
| C5 | Voted-for already set; on_vote_eligible NOT called | N/A | Denied before callback fires |
Note: The freshness gate (config_tag, log_lsn), role gate (LEADER/COMMITTING deny),
and heartbeat-freshness gate are ha.c adapter responsibilities implemented inside
on_vote_eligible — not tested in the library test suite.
Group H: Persistence and Restart (Req 1.2, on_persist)
| TC | Scenario | Expected |
|---|---|---|
| H1 | raft_init with cfg.initial_term=5, cfg.initial_voted_for=1 | state.term=5, state.voted_for=1 immediately |
| H2 | raft_init with cfg.initial_term=0, cfg.initial_voted_for=-1 (first boot) | state.term=0, state.voted_for=-1 |
| H3 | Vote granted; on_persist fires before voted_for is recorded | on_persist called with (term, from_idx) before grant returns |
| H4 | on_persist returns non-zero (disk failure) | Vote denied; voted_for unchanged |
| H5 | raft_start_election: on_persist fires for self-vote | on_persist called with (new_term, self_idx) |
Group D: Quorum and Result (Req 4)
| TC | Scenario | Expected |
|---|---|---|
| D1 | 3-node: 1 grant received (self + 1 = 2 = quorum) | LEADER immediately |
| D2 | 3-node: both peers deny | election_decided=1, FOLLOWER, on_become_follower fires |
| D3 | on_become_leader fires: second vote reply arrives | No-op (election_decided guard) |
| D4 | Reply with term > current_term | Update term, FOLLOWER, on_become_follower fires |
Group E: Election Timeout (Req 6)
| TC | Scenario | Expected |
|---|---|---|
| E1 | Timeout fires before quorum | on_become_follower; on_cancel_timeout |
| E2 | Timeout fires after quorum | No-op (election already decided) |
| E3 | New election after timeout | term increments; on_schedule_timeout fires again |
Group F: RDHUP Failover (Req 5)
| TC | Scenario | Expected |
|---|---|---|
| F1 | 3-node: node 0 killed; nodes 1,2 call raft_on_leader_lost | Node 1 (priority=1) waits 1×timeout; node 2 (priority=2) waits 2×timeout |
| F2 | During node 1's delay, node 1 receives vote request from another election starter | voted_for set; raft_check_and_start_election returns RAFT_SKIP_ELECTION |
| F3 | During node 1's delay, no vote request arrives | raft_check_and_start_election starts election; 1 LEADER |
| F4 | Simultaneous RDHUP (all remaining nodes fire at same time, no delay difference) | Only one LEADER (self-vote prevents double grant) |
Group G: Split-Brain Prevention (Req 4, overall correctness)
| TC | Scenario | Expected |
|---|---|---|
| G1 | 3-node simultaneous elections all terms (100 random election rounds via simulator) | Never more than 1 LEADER per term across all rounds |
| G2 | Network partition: nodes 0+1 isolated from node 2 | Partition of 2 can elect; partition of 1 cannot |
Requirements Traceability
| Req | Summary | Component | Interface/Contract |
|---|---|---|---|
| 1.1 | State fields | raft_state_t |
Data struct |
| 1.2 | Init to FOLLOWER | raft_init |
Function |
| 1.3 | No transport types in header | raft.h boundary |
Boundary commit |
| 2.1 | Election: incr term, self-vote, send RPCs | raft_start_election |
Function |
| 2.2 | Single-node shortcircuit | raft_start_election |
RAFT_ELECTED_IMMEDIATELY |
| 2.3 | Error if already CANDIDATE/LEADER | raft_start_election |
-EALREADY |
| 3.1 | Higher term → update, re-evaluate | raft_on_vote_request |
Function |
| 3.2 | Lower term → deny | raft_on_vote_request |
Return value |
| 3.3 | Same term, different voted_for → deny | raft_on_vote_request |
voted_for check |
| 3.4 | Freshness gate → grant + persist | raft_on_vote_request |
on_persist callback |
| 3.5 (5) | LEADER/COMMITTING → deny | raft_on_vote_request |
Role check |
| 4.1 | Quorum → LEADER | raft_on_vote_reply |
RAFT_BECAME_LEADER |
| 4.2 | Higher term in reply → FOLLOWER | raft_on_vote_reply |
RAFT_BECAME_FOLLOWER |
| 4.3 | Post-decision no-op | raft_on_vote_reply |
election_decided guard |
| 4.4 | All replied, no quorum → FOLLOWER | raft_on_vote_reply |
vote counting |
| 5.1 | Leader lost → clear voted_for + delay callback | raft_on_leader_lost |
on_start_election_after_delay |
| 5.2 | Already voted during delay → skip | raft_check_and_start_election |
RAFT_SKIP_ELECTION |
| 5.3 | Not voted → start election | raft_check_and_start_election |
RAFT_ELECTION_STARTED |
| 6.1 | Start election → schedule timeout | raft_start_election |
on_schedule_timeout |
| 6.2 | Timeout fires, undecided → follower | raft_on_election_timeout |
on_become_follower |
| 6.3 | Timeout fires, decided → no-op | raft_on_election_timeout |
election_decided guard |
| 7.1 | raft_callbacks_t struct definition | raft.h |
Data struct |
| 7.2 | void* ctx threading | All callbacks | Function signatures |
| 7.3 | NULL callback → error | raft_init |
-EINVAL |
| 8.1 | Deterministic simulator | raft_sim.c |
Simulator API |
| 8.2 | 4 invariant test cases | test_raft.c |
Groups A–G |
| 8.3 | ASAN clean | Build config | Meson test suite |
| 9.1 | Adapter implements callbacks | ha.c adapter (separate task) |
raft_callbacks_t impl |
| 9.2 | ha.c delegates state to library | ha.c adapter (separate task) |
ha_state_t.raft field |
| 9.3 | XCE_TESTING hooks preserved | ha.c adapter (separate task) |
Existing seams |
Error Handling
| Error | Source | Response |
|---|---|---|
| NULL callback at init | raft_init |
Return -EINVAL before any state is set |
raft_start_election called while CANDIDATE |
State check | Return -EALREADY |
on_persist callback returns error |
raft_on_vote_request |
Do not grant vote; return RAFT_DENY |
on_send_vote_request fails for one peer |
Election start | Count as no-reply for that peer; election proceeds with remaining peers |
Observability and Debug Tracing
Protocol correctness requires that every state transition is observable without a live cluster. The library provides two tracing mechanisms:
1. Trace Callback (production + test)
Add an optional on_trace field to raft_callbacks_t:
/* on_trace: fired on every state transition or significant decision.
* event: short string literal, e.g. "vote_granted", "became_leader", "deny_stale_term"
* detail: formatted human-readable context string (caller-allocated)
* May be NULL to suppress tracing. */
void (*on_trace)(void *ctx, const char *event, const char *detail);
Every call to a public raft_* function fires on_trace at entry and at each decision point:
| Trace event | When fired |
|---|---|
election_start |
raft_start_election begins |
self_vote |
Self-vote recorded |
vote_request_in |
raft_on_vote_request called |
vote_granted |
Vote granted to candidate |
vote_denied_stale_term |
Denied: term < current |
vote_denied_already_voted |
Denied: voted_for already set |
vote_denied_role |
Denied: LEADER or COMMITTING |
vote_denied_freshness |
Denied: candidate not fresh |
vote_reply_in |
raft_on_vote_reply called |
quorum_reached |
Votes_granted >= quorum |
became_leader |
on_become_leader fired |
became_follower |
on_become_follower fired |
election_timeout |
raft_on_election_timeout called |
leader_lost |
raft_on_leader_lost called |
jitter_skip |
raft_check_and_start_election → SKIP |
term_update |
term incremented or bumped by peer |
In production (ha.c adapter), on_trace routes to INFO(...) logging.
In tests, on_trace appends to a per-node trace buffer; test assertions can inspect the
event sequence to verify protocol correctness beyond role/term checks.
2. State Snapshot (raft_snapshot)
/* raft_snapshot_t: point-in-time read of all Raft state for logging/debugging */
typedef struct {
uint64_t term;
raft_role_t role;
int voted_for;
uint64_t voted_for_term;
int votes_granted;
int election_decided;
} raft_snapshot_t;
/* Fills snap with current state. Thread-safe (caller must hold lock). */
void raft_snapshot(const raft_state_t *s, raft_snapshot_t *snap);
The raft_snapshot function is used by:
- The simulator to print cluster state after each test step
- The adapter to log state on RDHUP for post-mortem debugging
- The ASAN test runner to dump state on test failure
3. Simulator Trace Output
raft_sim_cluster_t records a timestamped event log for each RPC and state transition.
After each test, raft_sim_dump_log(cluster, FILE*) prints the protocol trace in a
human-readable format, e.g.:
[step 0] node0: election_start term=2
[step 1] node0→node1: vote_request term=2 tag=0 lsn=0
[step 1] node0→node2: vote_request term=2 tag=0 lsn=0
[step 2] node1: vote_granted to node0 term=2
[step 2] node2: vote_granted to node0 term=2
[step 3] node0: quorum_reached votes=3 → became_leader term=2
This trace is printed to stderr on test failure and can be enabled unconditionally
with RAFT_SIM_VERBOSE=1.
File structure additions
src/lib/raft/
├── raft.h # + on_trace, raft_snapshot_t, raft_snapshot()
├── raft.c # + trace emission at every decision point
└── raft_sim.c # + raft_sim_dump_log, per-node trace buffer
Key Decisions
-
Zero OS dependencies.
raft.handraft.cdepend only onstdint.h,string.h, anderrno.h. Nopthread.h, no atomics, no OS primitives. The library is a pure function of itsraft_state_tinput — all threading, locking, and scheduling are the caller's responsibility. In the controller adapter, the caller holdsha->lockaround everyraft_*call. In the deterministic simulator tests, no lock is needed (single-threaded). This makes the library embeddable in any threading model without modification. -
Self-vote recorded immediately in
raft_start_election. This prevents a concurrent node from granting its vote to two candidates (the root cause of observed split-brain). The self-vote isvoted_for = self_idxat term increment time, before any RPC is sent. -
Role enum is separate from
ctrl_role_t. The Raft roles (FOLLOWER/CANDIDATE/LEADER) are a subset of XCE's controller states (INIT/STANDBY/ELECTING/PRIMARY_COMMITTING/PRIMARY). The adapter maps between them; the library owns only Raft roles. -
Priority-based jitter is part of the library via
raft_on_leader_lost. The jitter formula (priority_idx * election_timeout_ms) is in the library because it is a Raft-level correctness concern. The caller implements the actual sleep/timer. -
Simulator routes callbacks synchronously. For test determinism,
raft_simdelivers vote RPCs synchronously (no goroutines, no nanosleep). Timing tests verify term-based invariants, not wall-clock timing.