32 KiB
Design Document
Overview
Purpose: This feature turns src/lib/raft/raft_sim.c (today an election-only RPC
router) into an in-process TLA+ conformance harness for the XCE Raft core. It drives
N library instances synchronously through transitions equivalent to the raft.tla
Next actions (raft.tla:454) and asserts the four Raft safety invariants after every
step. It delivers an independent regression oracle for the consensus core — the "use the
verified spec to help" payoff — that would have caught bug-851 (the silent
committed-prefix divergence) before it shipped.
Users: Raft library maintainers (verifying raft.c), the raft-log-replication and
raft-commit-safety authors (whose entry points this harness exercises), and CI (which
runs the harness as a first-class library test target).
Impact: Replaces ad-hoc election-only simulation with a complete in-process model of
the raft.tla state space: a shared message bag mirroring messages, per-node state
mirroring serverVars/logVars/leaderVars, an action scheduler, four invariant
checkers, and a failure-trace recorder. The protocol implementation is unchanged — the
harness drives it only through the published entry points and callbacks from the two
upstream specs; the harness itself is the in-memory adapter (it implements the WAL/
transport callbacks against in-memory structures).
Goals
- A deterministic, seedable driver that mirrors the
raft.tlamessagesbag and the per-node variables this harness tracks, driving the library entry points only. - Per-step assertion of all four safety invariants (Election Safety, Log Matching, Leader Completeness, State Machine Safety) computed purely from observable node state.
- Targeted adversarial scenarios (partition/heal, restart, drop/dup/reorder, Figure-8, bug-851 G5 term-reuse) plus randomized seed sweeps.
- A replayable failure trace (seed + ordered action list) and a CI-runnable target.
- A deliberately-broken-build negative test proving the oracle catches a bug-851-class divergence and passes on the correct build.
- Optionally, a TLC-consumable trace export.
Non-Goals
- No protocol semantics. logOk, conflict truncation, the current-term commit rule,
(lastLogTerm, lastLogIndex)freshness, andUpdateTermadoption live inraft.c(owned byraft-log-replication/raft-commit-safety). The harness drives and checks them; it never re-implements them. - No live cluster / controller integration. The
/xce-deploy-hachaotic-failover regression, controller WAL/repl/recovery/commit-barrier internals, config markers, and the upgrade snapshot areha-term-safety. - No changes to
raft/raft.tlaand no TLC model-checker harness for the spec (only an optional export the maintainer can feed to TLC manually). - No async I/O, threads, or sockets. The harness is synchronous and deterministic by construction.
Boundary Commitments
This Spec Owns
src/lib/raft/raft_sim.c— the harness body: the shared message bag, per-node in-memory log + commit + applied state, the in-memory adapter callbacks, the seeded action scheduler, the four invariant checkers, the trace recorder, the scenario generators, and the deliberately-broken-build fault switch wiring (test-only).- The harness public surface in
raft.h'sXCE_TESTINGsection: new sim types (message-bag entry, per-node log array, action record) and the newraft_sim_*driver functions. These extend — never alter — the protocol declarations. - The conformance test entry point + scenarios in
src/lib/testsuite/test_raft.c(XCE_TESTING-gated), including the negative (broken-build) test.
Out of Boundary
- The protocol entry points and callback semantics (
raft_log_append,raft_send_append_entries,raft_on_append_entries_request/reply,raft_advance_commit, the vote path,on_persist_entry,on_get_entry_term,on_send_append_entries,on_truncate_from,on_commit_advanced) — owned byraft-log-replication/raft-commit-safety. This spec consumes them verbatim. - Any
raft.cchange to protocol logic. The harness must not need one; if it does, that is an upstream-spec gap, not a harness change. - The controller-side adapters, WAL byte layout, repl transport, recovery, commit
barrier, config markers, snapshot —
ha-term-safety. raft/raft.tlaitself and any TLC tooling.
Allowed Dependencies
- The
raft-log-replicationshared callback contract verbatim:on_persist_entry,on_get_entry_term,on_send_append_entries,on_truncate_from; the 1-based index = WAL LSN identity; theterm 0= absent convention; per-peernext_index[]/match_index[]; entry pointsraft_log_append,raft_send_append_entries,raft_on_append_entries_request,raft_on_append_entries_reply,raft_append_result_t. - The
raft-commit-safetyadditions:raft_advance_commit(current-term-gatedAdvanceCommitIndex),on_commit_advanced, and the(lastLogTerm, lastLogIndex)vote freshness already enforced inside the vote handlers. - The implemented election core (
raft-library):raft_init,raft_start_election,raft_on_vote_request/reply,raft_on_election_timeout, PreVote,raft_snapshot. - Only
errno.h,stdint.h,string.h,stdio.h,unity/unity.h(test) — the simulator stays free-standing, like the rest of the library, and isXCE_TESTING-gated.
Revalidation Triggers
Re-check this design if any upstream changes:
- The signature/semantics of any of the four log-replication callbacks or
on_commit_advanced. - The signature/return semantics of
raft_log_append,raft_send_append_entries,raft_on_append_entries_request,raft_on_append_entries_reply,raft_advance_commit, orraft_append_result_t. - The 1-based-index ↔ WAL-LSN identity or the
term 0= absent convention. - The placement of
UpdateTerm/DropStaleResponseinside the handlers (the harness relies on the handlers performing these, not the harness).
Architecture
Existing Architecture Analysis
raft_sim.c already establishes the harness idiom this design extends:
- A
raft_sim_cluster_towningnodes[RAFT_SIM_MAX_NODES], apending[]RPC queue, and cluster-level counters. - Each
raft_sim_node_tcarriesraft_state_t state, araft_callbacks_t cbswired to sim routing functions, a back-pointerctxto the cluster, anidx, apartitionflag, and atrace_buf. - Callbacks cast
ctxback toraft_sim_node_t*, reaching the cluster vianode->ctx. - Routing is synchronous:
raft_sim_deliver_pendingdrains the queue, callingraft_on_vote_requeston the destination and routing the reply back.
This design keeps that idiom and adds: a richer message bag (vote and AppendEntries,
requests and responses), an in-memory log + commit + applied state per node, the four
new adapter callbacks, a seeded action scheduler, invariant checkers, and a trace
recorder. No new file is introduced; the existing module is extended (consistent with how
raft-log-replication extends raft_sim.c).
TLA+ variable → harness state mapping
The harness mirrors exactly the raft.tla variables it needs to drive actions and check
invariants. The protocol-internal portions of serverVars/leaderVars (currentTerm,
votedFor, state, nextIndex, matchIndex) live inside each raft_state_t and are
read through raft_snapshot and the library's own fields — the harness does not
duplicate them.
raft.tla variable |
Harness home | Read/owned |
|---|---|---|
messages (bag of in-flight RPCs) |
cluster.bag[] (requests + responses) |
harness-owned |
currentTerm[i], state[i], votedFor[i] |
node.state (raft_state_t) |
library-owned, read via snapshot/fields |
log[i] (sequence of (index, term, value)) |
node.log[] in-memory array |
harness-owned (the adapter) |
commitIndex[i] |
node.commit_index |
harness-owned, advanced via raft_advance_commit + on_commit_advanced |
nextIndex[i][j], matchIndex[i][j] |
node.state.next_index[]/match_index[] |
library-owned |
| applied state machine (for SM Safety) | node.applied[] (value per committed index) |
harness-owned |
allLogs (history of every log) |
cluster.all_logs snapshot used by checkers |
harness-owned |
Component / data-flow map
flowchart TB
subgraph SCHED["Action Scheduler (raft_sim.c) — seeded PRNG"]
PICK["pick action ~ raft.tla Next (454)"]
REC["record action -> trace buffer (R1.4)"]
end
subgraph NODES["N library instances (raft.c — UNCHANGED)"]
ST["raft_state_t (term, role, votedFor, next/match)"]
EP["entry points: start_election, on_vote_*,<br/>log_append, send_append_entries,<br/>on_append_entries_*, advance_commit"]
end
subgraph ADAPT["In-memory adapter callbacks (raft_sim.c = the adapter)"]
A1["on_persist_entry -> node.log[]"]
A2["on_get_entry_term -> node.log[idx].term | 0"]
A3["on_send_append_entries -> bag.push(AE req)"]
A4["on_truncate_from -> node.log truncate"]
A5["on_commit_advanced -> node.commit_index + apply()"]
A6["on_send_vote_request -> bag.push(vote req)"]
end
subgraph BAG["Message bag (cluster.bag[]) — mirrors raft.tla messages"]
B["vote/AE requests + responses,<br/>Drop/Duplicate/Reorder live here"]
end
subgraph CHK["Invariant checkers (after every step, R3)"]
I1["Election Safety"]
I2["Log Matching"]
I3["Leader Completeness"]
I4["State Machine Safety"]
end
PICK --> REC --> EP
EP --> A1 & A2 & A3 & A4 & A5 & A6
A3 & A6 --> BAG
BAG -->|Receive/Deliver| EP
EP --> CHK
CHK -->|violation| FAIL["halt + dump seed & trace (R5)"]
Architecture integration
- Pattern: model-based test harness over a pure state machine. The library is the
system-under-test (Ports-and-Adapters: the harness implements all callback ports
against in-memory structures). The scheduler is a seeded interleaving engine over the
raft.tlaaction set; the checkers are pure observers. - Domain boundary: protocol decisions stay in
raft.c; driving (which action, which message) and checking (the four invariants) stay in the harness. - Preserved patterns:
ctx-back-pointer callbacks, synchronous routing,XCE_TESTINGgating, per-node trace buffers, theraft_sim_*naming. - Steering compliance:
src/lib/CLAUDE.md(free-standing gnu17, tests intestsuite/),ha-async-io.md(no blocking — and here, no async at all),testing.md(TDD: checkers/scenarios written before they pass), "controller never self-terminates" (the harnessabort()s a test on violation, never the library).
Technology Stack
| Layer | Choice | Role | Notes |
|---|---|---|---|
| Harness core | C (gnu17), string.h/stdint.h/stdio.h |
message bag, scheduler, checkers, trace | XCE_TESTING-gated; free-standing |
| PRNG | small inlined deterministic generator (e.g. SplitMix64/xorshift) seeded by a uint64_t |
all nondeterministic choices | no libc rand() (not reproducible across platforms) |
| Adapter | in-memory node.log[], commit_index, applied[] |
implements the 6 callbacks | the harness IS the WAL/transport adapter |
| Test runner | Unity (unity/unity.h) in test_raft.c |
scenario + seed-sweep + negative test | built by src/lib/meson.build test target |
| Fault switch | compile-time macro (e.g. RAFT_SIM_BREAK_*) in the test build only |
negative test | never set in default CI run |
File Structure Plan
Modified Files
src/lib/raft/raft.h(XCE_TESTINGsection only) — extend the sim types: a unified message-bag entry (raft_sim_msg_t: kind = vote-req/vote-rsp/ae-req/ae-rsp, src, dst, and a union of payload fields incl. term, prev_log_index/term, leader_commit, entry bytes + entry_term, match_index, granted/success); a per-node in-memory log entry (raft_sim_log_entry_t { uint64_t term; uint64_t value; }); an action record (raft_sim_action_t: kind + actor + target/message identity); extendraft_sim_node_twithlog[],log_len,commit_index,applied[]; extendraft_sim_cluster_twith the bag, the PRNG state, the trace buffer, and the seed; declare the newraft_sim_*driver/scenario/check functions. No protocol declaration is changed.src/lib/raft/raft_sim.c— the harness implementation (sections below): adapter callbacks, bag operations (push/deliver/drop/duplicate/reorder), the seeded action scheduler (raft_sim_step/raft_sim_run), the four invariant checkers (raft_sim_check_*+raft_sim_check_all), the trace recorder + dumper, the targeted scenario drivers, and the fault-switch hooks (compile-time-gated).src/lib/testsuite/test_raft.c— the conformance test cases: targeted scenarios, the randomized M-seed sweep, the replay-reproduction test, and the negative (broken-build) test.
Directory Structure (unchanged)
src/lib/raft/
├── raft.h # + sim message bag, in-memory log, action record, driver decls (XCE_TESTING)
├── raft.c # UNCHANGED by this spec (protocol owned upstream)
└── raft_sim.c # + bag, scheduler, 4 invariant checkers, trace, scenarios, fault hooks
src/lib/testsuite/
└── test_raft.c # + conformance scenarios, M-seed sweep, replay test, negative test
Single-responsibility kept:
raft.c= pure protocol (untouched),raft_sim.c= deterministic harness + in-memory adapter + checkers,test_raft.c= assertions and the CI batch.
System Flows
One scheduler step (the Next interleaving)
sequenceDiagram
participant S as Scheduler (seeded PRNG)
participant L as Library node (raft.c)
participant A as Adapter cb (raft_sim.c)
participant B as Message bag
participant C as Checkers
S->>S: action = pick(Next set) from PRNG
S->>S: record(action) into trace (R1.4)
alt node-local (Timeout / RequestVote / BecomeLeader / ClientRequest)
S->>L: call entry point (start_election / log_append / ...)
L->>A: on_persist_entry / on_send_* (drives bag/log)
A->>B: push request(s)
else AppendEntries (leader replicate)
S->>L: raft_send_append_entries(peer, leader_commit)
L->>A: on_send_append_entries -> bag.push(AE req)
else AdvanceCommitIndex
S->>L: raft_advance_commit()
L->>A: on_commit_advanced -> commit_index advanced, apply()
else Receive (deliver one bag message)
S->>B: pop selected message
S->>L: route to handler (on_vote_request / on_append_entries_request / *_reply)
L->>A: UpdateTerm/persist/truncate/append + reply push
else DropMessage / DuplicateMessage
S->>B: discard / re-enqueue (no node mutation) (R2.5)
else Restart
S->>L: raft_init preserving persisted term/votedFor + restore log_len (R2.1)
end
S->>C: raft_sim_check_all() (R3)
alt any invariant violated
C-->>S: fail -> dump seed + trace + invariant (R5), then halt
else ok
C-->>S: continue
end
Flow-level decisions:
- The library performs the protocol; the harness performs the interleaving.
UpdateTermandDropStaleResponsehappen inside the handlers (R2.4); the harness never pre-filters by term — that would mask the very bugs it must catch. - Commit is library-gated. The harness calls
raft_advance_commit; the current-term Figure-8 rule is enforced byraft.c. The harness only applies whatever the library reports committed viaon_commit_advanced, then checks State Machine Safety. - Faults are bag-only. Drop/Duplicate/Reorder mutate
cluster.bag[]exclusively (R2.5), mirroringraft.tla:443/448(UNCHANGEDof all non-message vars). - Restart preserves persistence. The harness keeps
node.log[], the recoveredlog_len, and the last-persisted(term, votedFor)across araft_init, matchingraft.tlaRestart(keepscurrentTerm,votedFor,log; resets volatile state).
Invariant checker construction (observable-state-only)
flowchart LR
subgraph IN["inputs: per-node observable state"]
R["roles+terms (raft_snapshot)"]
LG["node.log[] (index, term, value)"]
CM["node.commit_index"]
AP["node.applied[]"]
end
R --> ES["Election Safety: |{i: role==LEADER, term==t}| <= 1, all t (R3.1)"]
LG --> LM["Log Matching: pairwise, shared (idx,term) => identical prefix (R3.2)"]
LG --> LC["Leader Completeness: committed entry in every higher-term leader log (R3.3)"]
CM --> LC
AP --> SM["State Machine Safety: applied[i][k] == applied[j][k] for all k (R3.4)"]
Requirements Traceability
| Requirement | Summary | Design elements | raft.tla anchor |
|---|---|---|---|
| 1.1–1.6 | Deterministic seedable driver | seeded PRNG; cluster.bag[]; per-node log[]/commit_index/applied[]; trace buffer; no threads/sockets/clock; deterministic replay |
messages, serverVars, logVars |
| 2.1–2.6 | Drive Next-equivalent actions via library entry points | scheduler step dispatch; raft_send_append_entries; raft_advance_commit; handler routing (UpdateTerm/DropStale inside lib); bag-only Drop/Dup; entry-points-only rule |
Next (454), Restart/Timeout/RequestVote/BecomeLeader/ClientRequest/AppendEntries/AdvanceCommitIndex/Receive/Duplicate/Drop |
| 3.1–3.6 | Per-step four-invariant assertion | raft_sim_check_election_safety/log_matching/leader_completeness/state_machine_safety; raft_sim_check_all; halt-on-fail; observable-state-only |
Election Safety / Log Matching / Leader Completeness / State Machine Safety |
| 4.1–4.5 | Targeted scenarios | raft_sim_scenario_partition_heal/restart_churn/drop_dup_reorder/figure8/stale_rejoin |
partition / Restart / message faults / Figure-8 / G5 |
| 5.1–5.4 | Failure-trace capture + replay | trace recorder; raft_sim_dump_trace (seed + ordered actions); replay from seed; quiet-on-success |
(verification) |
| 6.1–6.4 | CI-runnable target | test_raft.c conformance entry; M-seed batch + scenarios; ASAN/UBSAN clean; bounded steps/seeds |
(verification) |
| 7.1–7.4 | Deliberately-broken-build negative test | compile-time RAFT_SIM_BREAK_* fault switch (test build only); both-direction assertion |
bug-851 G1/G3 mechanism |
| 8.1–8.3 | Optional TLC-consumable trace export | stable machine-readable record named per raft.tla; off by default |
raft.tla actions/vars |
Components and Interfaces
| Component | Layer | Intent | Req coverage | Key dependencies | Contract |
|---|---|---|---|---|---|
| Message bag | Harness | Mirror messages; carry vote/AE req+rsp |
1, 2 | raft_sim_msg_t |
State |
| In-memory adapter callbacks | Harness | Implement the 6 ports against node.log[]/commit |
1, 2 | upstream callback contract (P0) | Service |
| Action scheduler | Harness | Seeded interleaving over the Next set | 1, 2 | PRNG, entry points (P0) | Service |
| Invariant checkers | Harness | Assert the four safety properties | 3 | observable node state | Service |
| Trace recorder | Harness | Record + dump replayable action trace | 1, 5 | scheduler | Service |
| Scenario drivers | Harness | Targeted adversarial cases | 4 | scheduler, bag | Service |
| Fault switch | Test build | Deliberately-broken build | 7 | compile-time macro | Service |
| Conformance test | Test suite | CI batch + negative test | 6, 7 | all of the above | Test |
In-memory adapter callbacks (the harness as adapter)
The harness implements the upstream callbacks against per-node in-memory state, exactly
as the raft-log-replication design anticipates ("bytes live in the adapter (WAL) /
simulator (in-memory array)"). Signatures are consumed verbatim from upstream; behavior:
| Callback | Harness behavior |
|---|---|
on_persist_entry(ctx, index, term, entry, len) |
Write/overwrite node.log[index-1] = {term, value}; grow log_len. Persist-before-act preserved (called before the library reports success). |
on_get_entry_term(ctx, index) |
Return node.log[index-1].term, or 0 if index==0 or index > log_len (the term 0 = absent convention). |
on_send_append_entries(ctx, peer, prev_log_index, prev_log_term, leader_commit, ...) |
Push an AE-request raft_sim_msg_t onto cluster.bag[] honoring partitions. |
on_truncate_from(ctx, index) |
Set node.log_len = index - 1 (delete index..end). |
on_commit_advanced(ctx, commit_index) |
Set node.commit_index = commit_index; apply node.log[k].value into node.applied[k] for each newly committed k. |
on_send_vote_request(ctx, peer, term) (existing) |
Push a vote-request message onto the bag. |
Message bag (mirrors raft.tla messages)
typedef enum {
RAFT_SIM_MSG_VOTE_REQ, RAFT_SIM_MSG_VOTE_RSP,
RAFT_SIM_MSG_AE_REQ, RAFT_SIM_MSG_AE_RSP,
} raft_sim_msg_kind_t;
typedef struct {
raft_sim_msg_kind_t kind;
int src, dst;
uint64_t term; /* RPC term (all kinds) */
/* AE request */
uint64_t prev_log_index, prev_log_term, leader_commit;
uint64_t entry_term, entry_value; int entries_len; /* 0=heartbeat, 1=one entry */
/* responses */
int granted; /* vote rsp */
int success; /* AE rsp */
uint64_t match_index; /* AE rsp */
} raft_sim_msg_t;
raft_sim_bag_push/raft_sim_bag_deliver(i)(route messageito the matching handler and push any reply) /raft_sim_bag_drop(i)/raft_sim_bag_duplicate(i)/ reorder (deliver in PRNG-chosen order). Delivery honorsnode.partition(a partitioned endpoint's messages are not delivered, mirroring a dropped network edge).- A handler reply is itself pushed onto the bag as a
*_RSPmessage, so responses are first-class bag members (drop/dup/reorder apply to them too).
Action scheduler
/* One step of the Next interleaving. Picks an enabled action from the PRNG,
* records it, applies it through the library entry points / bag, then runs
* raft_sim_check_all. Returns 0 on success, <0 (and dumps the trace) on the
* first invariant violation. */
int raft_sim_step(raft_sim_cluster_t *c);
/* Run up to max_steps (or until quiescent). Returns 0 if no invariant was
* violated through the whole run; <0 on the first violation. */
int raft_sim_run(raft_sim_cluster_t *c, int max_steps);
/* Seeded init: builds N nodes wired to the in-memory adapter + bag. */
int raft_sim_conformance_init(raft_sim_cluster_t *c, int n_nodes,
uint32_t election_timeout_ms, uint64_t seed);
The action set the scheduler chooses from (each maps to a raft.tla Next disjunct):
Restart, Timeout, RequestVote(deliver vote-req), BecomeLeader(deliver enough
vote-rsp), ClientRequest, AppendEntries, AdvanceCommitIndex, Receive(deliver an
AE req/rsp incl. UpdateTerm/DropStaleResponse), DuplicateMessage, DropMessage.
Only enabled actions are eligible (e.g. ClientRequest/AppendEntries require a
leader; Receive requires a non-empty bag). Note that BecomeLeader is not a
standalone library entry point: it is realized by delivering the quorum-completing
vote-response to raft_on_vote_reply, which returns RAFT_BECAME_LEADER when that
response carries the candidate over quorum.
Invariant checkers
int raft_sim_check_election_safety(const raft_sim_cluster_t *c); /* R3.1 */
int raft_sim_check_log_matching(const raft_sim_cluster_t *c); /* R3.2 */
int raft_sim_check_leader_completeness(const raft_sim_cluster_t *c); /* R3.3 */
int raft_sim_check_state_machine_safety(const raft_sim_cluster_t *c); /* R3.4 */
int raft_sim_check_all(const raft_sim_cluster_t *c); /* R3.5: first failure */
Checker semantics (observable-state-only, R3.6):
- Election Safety — for each term
t, count nodes withrole==LEADER && term==t; fail if any count > 1. - Log Matching — for every node pair
(i, j)and every indexkpresent in both: iflog_i[k].term == log_j[k].term, then every entry at indices1..kmust be equal(term, value)in both logs; fail otherwise. Note: the library collapsesraft.tla's truncate-only conflict step and the immediately-following no-conflict append into a single AppendEntries handler invocation (truncate then append), so the Log-Matching / divergent-suffix checker authors should expect the divergent suffix to be truncated and replaced with the leader's entry in one step rather than over two. - Leader Completeness — track the highest committed entry observed cluster-wide; for
every node currently
LEADER(and across forced leader changes, the highest-term leader to date), every committed(index, term)must be present in its log; fail otherwise. (Committedness is taken fromnode.commit_indexproduced by the library-gatedraft_advance_commit.) - State Machine Safety — for every index
kapplied by two nodes,applied_i[k] == applied_j[k]; fail otherwise.
Trace recorder
raft_sim_action_t { kind, actor, msg_index, term, index } recorded per step into
cluster.trace[]; raft_sim_dump_trace(c, fp) prints seed, n, the failing invariant,
and the ordered actions in a human-readable form (R5.1, R5.2). Replay = re-run
raft_sim_conformance_init with the same (n, seed) and the same max_steps (R5.3);
quiet on success unless RAFT_SIM_VERBOSE (R5.4).
Deliberately-broken-build fault switch (test-only)
A runtime fault-injection flag (cluster.fault_inject, set via
raft_sim_set_fault_inject) gates harness-side adapter corruptions that emulate the
bug-851 defects without touching raft.c. The flag is compiled only under XCE_TESTING
(never in the shipped build) and defaults to OFF (never enabled in the default CI run), so
it satisfies R7.3 by the same substance a compile-time macro would — but, unlike a
#ifdef, a runtime flag can be toggled within a SINGLE binary, which R7.4 requires (the
negative test must assert the broken-config FAILURE and the correct-config PASS in one
test). The RAFT_SIM_BREAK_* names are the flag's bit values:
RAFT_SIM_BREAK_TERM_STAMP—on_persist_entrywritesterm = 0instead of the supplied term (emulates G1: entry term always 0), soon_get_entry_termcan no longer distinguish(term=A, idx=k)from(term=B, idx=k).RAFT_SIM_BREAK_TRUNCATE—on_truncate_fromis a no-op (emulates G3: the divergent suffix is never deleted).
With a fault bit set the run must trip Log Matching / Leader Completeness / State Machine
Safety; with the flag off (the default), the same run is clean. This keeps the negative
test entirely inside the harness build configuration (R7.3) — the shipped protocol and the
default CI run are unaffected. (Implementation note: RAFT_SIM_BREAK_TERM_STAMP is the
flag used by the task-7.2 negative test because the library collapses conflict-truncate
and the immediately-following append into one AppendEntries handler invocation, so a no-op
truncate alone is overwritten by the re-persist and does not diverge through normal
replication — the term-stamp corruption reliably trips Log Matching instead.)
Why adapter-side and not a
raft.cedit: the harness owns onlyraft_sim.c/tests. Emulating G1 by having the adapter drop the term reproduces the exact observable divergence (same-index/different-term entries become indistinguishable) that the real bug caused, while honoring the spec boundary (no protocol modification). It still proves the checkers have teeth: they catch the divergence regardless of where it originates.
Optional TLC-consumable trace export
raft_sim_export_trace(c, fp) (off unless RAFT_SIM_EXPORT) emits, per step, the action
and the resulting tracked variables (messages summary, per-node currentTerm/state/
log/commitIndex) named per raft.tla (R8.1, R8.2), in a stable line-oriented format
the maintainer can post-process for TLC cross-checking. Off by default (R8.3).
Data Models
Domain Model
- Bag message
raft_sim_msg_t— a single in-flight RPC (vote/AE, request/response), the unit Drop/Duplicate/Reorder operate on. - Log entry (sim)
raft_sim_log_entry_t { uint64_t term; uint64_t value; }— index is the 1-based array position (= WAL LSN);valueis a synthetic monotone client value so State Machine Safety divergence is observable. - Action record
raft_sim_action_t—(kind, actor, msg_index, term, index), the replayable trace unit.
Logical Data Model
raft_sim_node_tgainslog[RAFT_SIM_MAX_LOG],log_len,commit_index,applied[RAFT_SIM_MAX_LOG], and a persisted-(term, votedFor)shadow for Restart.raft_sim_cluster_tgainsbag[RAFT_SIM_MAX_MSGS]+bag_count, the PRNG state, theseed, andtrace[RAFT_SIM_MAX_STEPS]+trace_len.- Fixed-size arrays only (no allocation), bounded by
RAFT_SIM_MAX_LOG/MSGS/STEPS, keeping the harness allocation-free and ASAN-clean.
Data Contracts & Integration
- Entries handle: one entry per AE (
entries_len ∈ {0,1}), matching the upstreamraft.tla"≤1 entry" simplification and the log-replication wire shape — the harness reuses that shape exactly. term 0sentinel: honored byon_get_entry_termfor absent indices.- 1-based index = LSN:
node.log[index-1]is the canonical mapping.
Error Handling
Error Strategy
The harness is test code: on an invariant violation it dumps the seed + trace and fails
the Unity assertion (the test aborts), never the library (raft.c returns status
codes only — "controller never self-terminates" is preserved; the harness's halt is a
test failure, not a library abort).
Error Categories and Responses
- Invariant violation →
raft_sim_check_allreturns the failing invariant; the scheduler dumps(seed, n, invariant, trace)and returns<0; the test fails (R3.5, R5.1). - Bag overflow / log overflow (a scenario exceeded a fixed bound) → the harness fails loud (a distinct, non-invariant test error) rather than silently truncating, so bounds bugs are visible.
- Misuse (e.g.
ClientRequestdriven on a non-leader) → the scheduler only selects enabled actions, so the library's-EPERMpath is exercised intentionally (a targeted unit test), never as an accidental scheduler state.
Monitoring
Reuse the existing per-node trace_buf and the library on_trace events
("ae_recv", "ae_conflict_truncate", "term_adopt_ae", "ae_reply_advance", etc.)
plus the new harness-level action trace, so a failing run shows both the library's view
and the scheduler's interleaving.
Testing Strategy
TDD-mandatory (testing.md): every checker and scenario is written as a RED test in
test_raft.c (with the raft_sim.c backing) that fails before the harness logic exists
and passes after. All tests are XCE_TESTING-gated and run via /xce-build lib.
Unit tests (checkers in isolation)
- Each checker flags a hand-constructed violating cluster and passes a valid one:
two leaders same term (Election Safety); same
(idx, term)with different prefix (Log Matching); a committed entry missing from a higher-term leader (Leader Completeness); divergentapplied[k](State Machine Safety). (R3.1–3.4) raft_sim_check_allreturns the first failing invariant and halts (R3.5).
Scenario tests (targeted, R4)
- Partition/heal: isolate a minority during appends, heal, drain; assert all four invariants hold throughout and after convergence (R4.1).
- Restart churn: restart nodes mid-replication preserving persisted state; assert re-convergence with no violation (R4.2).
- Drop/dup/reorder: perturb bag delivery; assert idempotent convergence + invariants (R4.3).
- Figure-8: a previous-term entry on a quorum, leader change; assert it is not reported committed until a current-term entry commits (R4.4).
- Stale-rejoin (bug-851 G5): a term-lagging node rejoins under a higher-term leader; assert it adopts the higher term from the AE/heartbeat path and does not re-elect at a reused term (R4.5).
Randomized + CI (R6)
- M-seed sweep: run
raft_sim_runfor a fixedmax_stepsacross M seeds; assert zero violations. Bounded so the CI target fits the library test-suite budget (R6.4). - Runs clean under ASAN/UBSAN (R6.3).
Negative test (R7)
- With
RAFT_SIM_BREAK_TERM_STAMP(orRAFT_SIM_BREAK_TRUNCATE) set, the Figure-8 / divergent-suffix scenario fails an invariant and the trace is dumped; with the macro off, the same scenario passes — both directions asserted in one test, proving the oracle's sensitivity (R7.1, R7.2, R7.4). The macro is confined to the test build (R7.3).
Replay test (R5)
- A run that fails for seed
Sreproduces the identical failing action and invariant when re-run with seedSand the samemax_steps(R5.3).