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
662 lines
30 KiB
Markdown
662 lines
30 KiB
Markdown
# 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.h` has zero dependency on `endpoint_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 signatures
|
||
- `src/lib/raft/raft.c` — pure state machine implementation
|
||
- `src/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.build` additions for `raft.c` and `test_raft.c`
|
||
- The definition of `raft_callbacks_t` and the semantic contract for each callback
|
||
|
||
### Out of Boundary
|
||
- `ha.c` adapter 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.h` and `raft.c`: `stdint.h`, `string.h`, `errno.h` only** — 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 include `endpoint_t` or similar are forbidden)
|
||
- Test file may include `<unity/unity.h>` (project test framework) and `<pthread.h>` (for test harness only)
|
||
- `raft_sim.c` may include `<string.h>` and `<stdio.h>` for the simulator and dump log
|
||
|
||
### Revalidation Triggers
|
||
- Changes to `raft_callbacks_t` shape (adding/removing fields) require re-check of `ha.c` adapter
|
||
- Changes to quorum formula require re-running protocol-level tests
|
||
- Changes to role enum values require scanning `ha.c` for role-comparison code
|
||
|
||
---
|
||
|
||
## Architecture
|
||
|
||
### Existing Architecture Analysis
|
||
|
||
`ha.c` currently mixes three concerns:
|
||
1. **Raft state** — term, voted_for, role, quorum tracking (`ha_elect_ctx_t`)
|
||
2. **Transport** — `endpoint_connect()`, `iomgr_monitor()`, SSL setup, capnp encoding/decoding
|
||
3. **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
|
||
|
||
```mermaid
|
||
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.c` is fully synchronous. The caller holds a lock before calling any `raft_*` 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_t` in `raft.h` is independent of `ctrl_role_t` in `ha.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` — add `raft/raft.c` to xcelib sources; add `testsuite/test_raft.c`
|
||
- `src/controller/ha.h` — add `#include "raft/raft.h"` and `raft_state_t raft` field to `ha_state_t` (adapter task)
|
||
- `src/controller/ha.c` — delegate election logic to `raft_*` API (adapter task, separate from library task)
|
||
|
||
---
|
||
|
||
## System Flows
|
||
|
||
### Election State Machine
|
||
|
||
```mermaid
|
||
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
|
||
|
||
```mermaid
|
||
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)
|
||
|
||
```mermaid
|
||
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
|
||
|
||
```c
|
||
/* 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`)
|
||
|
||
```c
|
||
/*
|
||
* 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.
|
||
|
||
```c
|
||
/* 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`:
|
||
|
||
```c
|
||
/* 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`)
|
||
|
||
```c
|
||
/* 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
|
||
|
||
1. **Zero OS dependencies.** `raft.h` and `raft.c` depend only on `stdint.h`, `string.h`, and `errno.h`. No `pthread.h`, no atomics, no OS primitives. The library is a pure function of its `raft_state_t` input — all threading, locking, and scheduling are the caller's responsibility. In the controller adapter, the caller holds `ha->lock` around every `raft_*` call. In the deterministic simulator tests, no lock is needed (single-threaded). This makes the library embeddable in any threading model without modification.
|
||
|
||
2. **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 is `voted_for = self_idx` at term increment time, before any RPC is sent.
|
||
|
||
3. **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.
|
||
|
||
4. **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.
|
||
|
||
5. **Simulator routes callbacks synchronously.** For test determinism, `raft_sim` delivers vote RPCs synchronously (no goroutines, no nanosleep). Timing tests verify term-based invariants, not wall-clock timing.
|