Investigation & fixes (failover committed-job loss): - root-cause bug-851 on an owned 3-node cluster: chaotic failover causes silent committed-prefix divergence (WAL hdr.term always 0; two leaders per term; no (term,lsn) reconcile). Evidence in repro-851/ + INVESTIGATION. - live-validated fixes 848/849/850 (recovery-on-promotion, advertise real applied_lsn, persist-layer contiguity guard); add waldump WAL inspector. Specs (strict raft.tla conformance — library owns the log): - TLA-gap-analysis.md: impl vs raft/raft.tla, gaps G1-G6. - raft-log-replication, raft-commit-safety, raft-sim-conformance: extend src/lib/raft to own log/commit/verification via callbacks (specs generated, cross-spec review passed). - ha-term-safety: controller-side adapter + config marker (R1-R7); design done. - config-upgrade-snapshot: R8 descoped here (deps entity-bearing-snapshots, bug-857). - roadmap + buglog (851/854/857) + .wolf knowledge updated.
18 KiB
Requirements Document
Introduction
src/lib/raft/raft.{c,h} is today an election-only Raft library: it implements terms, votes, leader election, and PreVote with a pure callback interface (raft_callbacks_t, raft_state_t) and a deterministic simulator (raft_sim.c). It has no log, no AppendEntries, and no commit. The replicated log is instead hand-rolled across the controller (persist.c WAL, repl.c catch-up streaming, ha.c, ha_convergence.c) and violates the log invariants of raft/raft.tla (Ongaro's canonical Raft TLA+ spec). That split root-caused bug-851: silent committed-prefix divergence under chaotic failover. The authoritative gap analysis is .kiro/specs/ha-term-safety/TLA-gap-analysis.md (gaps G1–G6).
This feature extends the library so it owns the replicated log, strictly mirroring the raft.tla log actions, while keeping the core pure / synchronous / deterministic (no I/O, no threads) so it remains independently verifiable in the simulator against the Raft safety invariants. All side effects flow through new callbacks that a controller adapter implements over the existing WAL/repl transport. The feature closes exactly the four log-half gaps:
- G1 — per-entry term is dead today (WAL
hdr.termalways 0;p->termnever set fromha->term). The library log model gives every entry a realuint64_tterm, set to the leader's current term at append time (raft.tlaClientRequest, line 246). - G2 — replication matches LSN contiguity only; there is no
prevLogTermcheck. The library enforces thelogOkpredicate (raft.tlaAppendEntriesline 204 /HandleAppendEntriesRequestline 327):prevLogIndex == 0 ∨ log[prevLogIndex].term == prevLogTerm. - G3 — no
(term, index)conflict truncation; reconcile truncates by a committed-boundary heuristic and cannot detect same-index/different-term divergence. The library implements the TLA+ conflict branch: delete the divergent entry and everything after it. - G5 — a node adopts a higher term only on vote RPCs; the AppendEntries/heartbeat path never advances
currentTerm. The library appliesraft.tlaUpdateTerm(line 406) on every incoming RPC including AppendEntries/heartbeat: a strictly higher term ⇒ adopt term, clear vote, step down to follower.
Commit-index advance (AdvanceCommitIndex, G4) and (lastLogTerm, lastLogIndex) vote freshness (G6) are out of scope and are closed by the downstream raft-commit-safety spec. The full conformance harness is raft-sim-conformance. The controller-side wiring of WAL / commit-barrier / snapshot onto these callbacks is owned by ha-term-safety. This spec's contract (the new callbacks and entry points below) is consumed verbatim by all three downstream specs.
Boundary Context
-
In scope:
- A per-node log model inside the library where every entry carries a
uint64_tterm (G1), with the log index 1-based (TLA+) and mapping 1:1 to the controller WAL LSN. ClientRequestappend:raft_log_append(...)appends[term ↦ currentTerm, value]on the leader (raft.tlaline 246).AppendEntriessend: per-peernextIndex/matchIndexleader state; emission ofprevLogIndex,prevLogTerm, ≤1 entry, andleaderCommitvia theon_send_append_entriescallback (raft.tlaline 204).HandleAppendEntriesRequest: thelogOkprevLogTermmatch (G2), reject (stale term /¬logOk), candidate→follower on equal term, and the accept branch's three sub-cases — already-have, conflict-truncate, append-by-(term, index)(G3) (raft.tlaline 327).HandleAppendEntriesResponse: success advancesnextIndex/matchIndex; failure decrementsnextIndex(floored at 1) (raft.tlaline 393).UpdateTermapplied on every incoming RPC including AppendEntries/heartbeat (G5) (raft.tlaline 406).- The shared callback contract:
on_persist_entry,on_get_entry_term,on_send_append_entries,on_truncate_from(pure protocol decisions in the library; I/O in the controller adapter). - The controller adapter only at the seam: it stamps the WAL
hdr.termfromha->term, carries the term in the repl envelope, reads it back on recovery, and routes(term, index)match/truncate decisions back into the library. - Simulator coverage in
raft_sim.cthat drives AppendEntries across partitions, message drop/dup/reorder, and restarts, asserting the Log Matching invariant and divergent-suffix truncation.
- A per-node log model inside the library where every entry carries a
-
Out of scope (owned elsewhere):
AdvanceCommitIndex/ commit-index advance and the Figure-8 current-term commit rule (G4) →raft-commit-safety.RequestVote/ PreVote freshness becoming(lastLogTerm, lastLogIndex)instead of LSN-only (G6) →raft-commit-safety.- The full conformance / TLC trace-check harness →
raft-sim-conformance. - Configuration-change markers in the unified log + the upgrade snapshot →
ha-term-safety. - Async vtask I/O, fan-out replication scheduling, the synchronous commit barrier, persist-before-ACK durability, and snapshot transport → controller callback adapters (
ha-term-safety), not the pure core. - The heavy controller wiring of WAL / repl / recovery onto the callbacks →
ha-term-safety(this spec defines only the seam contract and a thin adapter description).
-
Adjacent expectations:
- Builds on the implemented election core (
raft-library); all existing election callbacks and behaviour are unchanged. - Preserves as policy (must NOT be canonicalized away): PreVote, priority leadership, persist-before-ACK durability + the synchronous commit barrier (consumed downstream), and "controller never self-terminates" (the library returns statuses; the controller owns liveness/fencing — the library never aborts a process).
- Feeds
raft-commit-safety(commit + freshness),raft-sim-conformance(verification harness), andha-term-safety(XCE integration + regression). Its protocol invariants R1–R5 there are realized by this library spec rather than by patching the controller directly.
- Builds on the implemented election core (
Requirements
Requirement 1: Library-owned log model with per-entry term (closes G1)
Objective: As a consumer of the raft library, I want the library to own a log where every entry carries the term under which it was created, so that entries from different terms are distinguishable and Raft log-matching becomes possible.
Acceptance Criteria
- The Raft Library shall maintain, per node, a 1-based log index whose value at index
iincludes theuint64_tterm recorded for the entry at that index, mirroringraft.tlalog[i][...].term. - The Raft Library shall expose the log index as identical to the controller WAL LSN (index 1 ↔ LSN 1), so callbacks and the adapter address entries by a single shared
(term, index)identity. - When the leader appends a client value, the Raft Library shall record the leader's current
currentTermas that entry's term, mirroringraft.tlaClientRequest(entry == [term ↦ currentTerm[i], value ↦ v]). - The Raft Library shall not record a fixed or zero placeholder term for any entry created while a valid term is in effect (the G1 defect).
- While persisting or overwriting an entry, the Raft Library shall delegate the durable write to the
on_persist_entry(ctx, index, term, entry, len)callback and shall not itself perform any I/O. - When the library needs the term of a local entry at a given index, it shall obtain it via the
on_get_entry_term(ctx, index)callback, treating a return of0as "no entry at that index" consistent withraft.tlaprevLogTerm == 0whenprevLogIndex == 0.
Requirement 2: ClientRequest append on the leader (raft.tla ClientRequest, line 246)
Objective: As a controller acting as primary, I want a single library entry point to append a new log entry stamped with the current term, so that the library — not hand-rolled controller code — owns log growth.
Acceptance Criteria
- When
raft_log_append(...)is called while the node's role isRAFT_LEADER, the Raft Library shall append one entry at indexLen(log) + 1with term equal tocurrentTerm, mirroringraft.tlaClientRequest. - If
raft_log_append(...)is called while the node's role is notRAFT_LEADER, then the Raft Library shall reject the append and report a non-success status without mutating the log. - When appending, the Raft Library shall invoke
on_persist_entryfor the new(index, term, entry)before reporting success, preserving the persist-before-act ordering used by the existing election path'son_persist. - The Raft Library shall return the assigned 1-based index to the caller on a successful append, so the controller adapter can map it to the WAL LSN.
- The Raft Library shall not, in this spec, advance any commit index as a side effect of append (commit advance is owned by
raft-commit-safety).
Requirement 3: AppendEntries emission with per-peer next/match index (raft.tla AppendEntries, line 204)
Objective: As a primary replicating to standbys, I want the library to own per-peer replication progress and emit correctly-formed AppendEntries, so that replication carries the (prevLogIndex, prevLogTerm) needed for log-matching.
Acceptance Criteria
- The Raft Library shall maintain, per peer, a
nextIndexand amatchIndex(theraft.tlaleaderVars), initialized on becoming leader tonextIndex = Len(log) + 1andmatchIndex = 0for every peer, mirroringraft.tlaBecomeLeader. - When the library emits an AppendEntries to peer
j, it shall computeprevLogIndex = nextIndex[j] - 1andprevLogTerm = (prevLogIndex > 0) ? term-at-prevLogIndex : 0, mirroringraft.tlaAppendEntries. - When the library emits an AppendEntries to peer
j, it shall send at most one entry, constrained by the end of the log (SubSeq(log, nextIndex[j], Min(Len(log), nextIndex[j]))), via theon_send_append_entries(ctx, peer_idx, prevLogIndex, prevLogTerm, leaderCommit, ...)callback. - The Raft Library shall pass the leader commit value through
on_send_append_entriesas an opaqueleaderCommitargument and shall not itself interpret or advance commit (commit handling is owned downstream). - While the node's role is not
RAFT_LEADER, the Raft Library shall not emit AppendEntries for that node.
Requirement 4: HandleAppendEntriesRequest — logOk, reject, step-down, accept (raft.tla line 327; closes G2, G3)
Objective: As a standby receiving replication, I want the library to accept, reject, or conflict-truncate strictly by (term, index) matching, so that Log Matching holds and divergent suffixes are truncated.
Acceptance Criteria
- When
raft_on_append_entries_request(...)is processed, the Raft Library shall computelogOk = (prevLogIndex == 0) ∨ (prevLogIndex ≤ Len(log) ∧ on_get_entry_term(prevLogIndex) == prevLogTerm), mirroring theraft.tlalogOkpredicate (closes G2). - If the request term is less than
currentTerm, or the term equalscurrentTermand the node is a follower and¬logOk, then the Raft Library shall reject the request and report failure withmatchIndex = 0, mirroring theraft.tlareject branch. - When the request term equals
currentTermand the node is a candidate, the Raft Library shall transition the node to follower (return-to-follower branch) without otherwise mutating the log. - When the request term equals
currentTerm, the node is a follower, andlogOkholds, and the incoming entry already matches the local entry atindex = prevLogIndex + 1(same term, or no entries sent), the Raft Library shall treat the request as already-satisfied and report success withmatchIndex = prevLogIndex + len(entries)without rewriting the log. - When
logOkholds and the local entry atindex = prevLogIndex + 1exists with a different term than the incoming entry, the Raft Library shall delete the divergent entry and all entries after it viaon_truncate_from(ctx, index), mirroring theraft.tlaconflict branch (closes G3). - When
logOkholds and the local log ends exactly atprevLogIndex(no entry atprevLogIndex + 1), the Raft Library shall append the incoming entry viaon_persist_entryand report success, mirroring theraft.tlano-conflict append branch. - The Raft Library shall return a reply carrying success/failure and the resulting
matchIndexso the leader can update its per-peer progress, and shall not perform transport I/O itself (the adapter sends the reply).
Requirement 5: HandleAppendEntriesResponse — advance/decrement progress (raft.tla line 393)
Objective: As a primary processing replication replies, I want the library to advance or back off per-peer progress, so that a lagging or divergent follower is walked back to the matching point.
Acceptance Criteria
- When
raft_on_append_entries_reply(...)reports success, the Raft Library shall setnextIndex[peer] = matchIndex + 1andmatchIndex[peer] = reply.matchIndex, mirroringraft.tlaHandleAppendEntriesResponsesuccess. - If
raft_on_append_entries_reply(...)reports failure, then the Raft Library shall setnextIndex[peer] = Max(nextIndex[peer] - 1, 1)and leavematchIndex[peer]unchanged, mirroring theraft.tlafailure branch. - The Raft Library shall apply a reply only when the reply term equals
currentTerm, ignoring stale-term responses, consistent withraft.tlaHandleAppendEntriesResponse/DropStaleResponse. - The Raft Library shall not, in this spec, recompute or advance any commit index from updated
matchIndexvalues (owned byraft-commit-safety).
Requirement 6: UpdateTerm on every RPC including AppendEntries/heartbeat (raft.tla line 406; closes G5)
Objective: As any cluster member, I want a strictly-higher term on any incoming RPC — vote, AppendEntries, or heartbeat — to advance my term and step me down, so that a rejoined node can never act or re-elect at a stale term and two leaders can never share a term.
Acceptance Criteria
- When an incoming AppendEntries or heartbeat RPC carries a term strictly greater than
currentTerm, the Raft Library shall setcurrentTermto that term, clearvoted_for, and transition to follower, mirroringraft.tlaUpdateTerm(closes G5). - While adopting a higher term from any RPC, the Raft Library shall persist the new
(term, voted_for)via the existingon_persistcallback before processing the rest of the RPC, preserving the durability ordering already used on the vote path. - When a node that was leader adopts a higher term from an AppendEntries/heartbeat RPC, the Raft Library shall fire
on_become_followerso the controller HA FSM steps down (mirroring the existing higher-term step-down already wired for vote RPCs). - The Raft Library shall apply the term-adoption check on the AppendEntries path before the
logOk/ accept-reject logic, so a higher-term request is never rejected merely because the local log does not match. - The Raft Library shall not, on equal or lower term, alter
currentTermorvoted_forfrom the AppendEntries path (term adoption is strictly-greater-than only), consistent withraft.tla.
Requirement 7: Shared callback contract and pure-core / adapter split
Objective: As a downstream spec author (raft-commit-safety, raft-sim-conformance, ha-term-safety), I want the new callbacks and entry points fixed verbatim and the library kept pure, so that I can depend on a stable seam without re-deriving the protocol.
Acceptance Criteria
- The Raft Library shall define the required callback
int on_persist_entry(void *ctx, uint64_t index, uint64_t term, const void *entry, size_t len)to durably append or overwrite the entry atindex. - The Raft Library shall define the required callback
uint64_t on_get_entry_term(void *ctx, uint64_t index)returning the term of the local entry atindex, or0when absent. - The Raft Library shall define the required callback
int on_send_append_entries(void *ctx, int peer_idx, uint64_t prev_log_index, uint64_t prev_log_term, uint64_t leader_commit, ...)carrying an entries handle, to transmit one AppendEntries. - The Raft Library shall define the required callback
int on_truncate_from(void *ctx, uint64_t index)to delete local entries inindex..end. - The Raft Library shall keep all four callbacks free of protocol decisions: the library decides what/whether to persist, send, or truncate; the controller adapter only performs the I/O (WAL write, transport, recovery read-back).
- The Raft Library shall leave all existing election callbacks (
on_send_vote_request,on_become_leader,on_become_follower,on_persist,on_schedule_timeout,on_cancel_timeout,on_start_election_after_delay, optionalon_vote_eligible/on_send_pre_vote_request/on_trace) and their behaviour unchanged. - The controller adapter shall implement the four callbacks over the existing WAL/repl transport — stamping
hdr.termfromha->term, carrying the term in the repl envelope, reading it back on recovery, and routing(term, index)match/truncate decisions to the library — while the heavy controller wiring lives inha-term-safetyas a downstream consumer.
Requirement 8: Deterministic Log Matching verification in the simulator
Objective: As a maintainer, I want the deterministic simulator to drive replication under adversarial conditions and assert Raft Log Matching, so that the log-half implementation is verifiable without a live cluster.
Acceptance Criteria
- The Raft Simulator shall route AppendEntries requests and responses synchronously between nodes (mirroring its existing vote-RPC routing), honoring per-node partition flags.
- The Raft Simulator shall provide an in-memory per-node log backing the four new callbacks, so a node can append, read entry terms, send, and truncate deterministically.
- The Raft Simulator shall exercise message drop, duplication, and reordering of AppendEntries, plus node restart, while replication proceeds.
- The Raft Simulator shall assert the Log Matching invariant after replication: for any two nodes and any index
i, if both hold an entry atiwith the same term, then their entire prefixes throughiare byte-identical. - When a follower holds a divergent suffix (same index, different term) and then receives the leader's AppendEntries, the Raft Simulator shall assert that the follower truncates the divergent suffix and converges to the leader's prefix.
- While driving chaotic leadership churn (a stale-term node rejoining under a higher-term leader), the Raft Simulator shall assert that the rejoining node adopts the higher term from the AppendEntries/heartbeat path (G5) and does not re-elect at a reused term.