checkpoint: bug-851 root cause + raft TLA+ conformance spec family
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.
This commit is contained in:
parent
7d70820aac
commit
20e59d0b42
85 changed files with 10525 additions and 1993 deletions
41
.kiro/specs/raft-log-replication/brief.md
Normal file
41
.kiro/specs/raft-log-replication/brief.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Brief: raft-log-replication
|
||||
|
||||
## Problem
|
||||
`src/lib/raft/raft.{c,h}` is an **election-only** Raft library (terms, votes, leader election, PreVote) with a clean callback interface and a deterministic simulator (`raft_sim.c`). It has **no log, no AppendEntries, no commit**. The replicated log is hand-rolled in the controller (`persist.c` WAL, `repl.c` catch-up streaming, `ha.c`, `ha_convergence.c`) and **violates the Raft log invariants of `raft/raft.tla`**, which root-caused **bug-851** (silent committed-prefix divergence under chaotic failover). Authoritative gap analysis: `.kiro/specs/ha-term-safety/TLA-gap-analysis.md`.
|
||||
|
||||
This spec closes the four **log-half** gaps by making the library own the log, strictly following `raft.tla`:
|
||||
- **G1** — per-entry term is dead (WAL `hdr.term` always 0; `p->term` never set from `ha->term`).
|
||||
- **G2** — replication matches LSN contiguity only; no `prevLogTerm` check (TLA+ `AppendEntries`/`HandleAppendEntriesRequest` `logOk`).
|
||||
- **G3** — no `(term,lsn)` conflict truncation (TLA+ conflict branch: delete the divergent entry + all after).
|
||||
- **G5** — a node adopts a higher term only on **vote** RPCs; the AppendEntries/heartbeat path never advances `currentTerm` (TLA+ `UpdateTerm` fires on **any** higher-term RPC). Confirmed live: a rejoined standby reported `term:2` under a term-3 primary → term reuse → two leaders per term.
|
||||
|
||||
## Approach
|
||||
Extend the library to own the replicated log, **strictly mirroring the `raft.tla` actions**, keeping the core **pure / synchronous / deterministic** (no I/O, no threads) so it stays simulator-verifiable. All side effects go through new callbacks the controller adapter implements over the existing WAL/repl transport.
|
||||
|
||||
### TLA+ actions implemented (raft.tla line refs)
|
||||
- `ClientRequest(i,v)` (246) → `raft_log_append(...)`: append `[term ↦ currentTerm, value]`.
|
||||
- `AppendEntries(i,j)` (204) → emit via `on_send_append_entries` with `prevLogIndex`, `prevLogTerm`, ≤1+ entries, `leaderCommit`. Per-peer `nextIndex`/`matchIndex` (TLA+ leaderVars).
|
||||
- `HandleAppendEntriesRequest(i,j,m)` (327) → `raft_on_append_entries_request(...)`: `logOk` = `prevLogIndex==0 ∨ log[prevLogIndex].term==prevLogTerm`; reject (stale term / `¬logOk`), candidate→follower on equal term, accept (already-have / conflict-truncate-one / append).
|
||||
- `HandleAppendEntriesResponse(i,j,m)` (393) → `raft_on_append_entries_reply(...)`: success → advance `nextIndex`/`matchIndex`; fail → decrement `nextIndex`.
|
||||
- `UpdateTerm(i,j,m)` (406) → applied on **every** incoming RPC including AppendEntries/heartbeat (closes G5): higher term ⇒ adopt term, clear vote, step down to follower.
|
||||
|
||||
### Authoritative shared contract (downstream specs MUST reference this verbatim)
|
||||
Log index model: the library log index is 1-based (TLA+) and maps 1:1 to the controller WAL **LSN**. Per-entry `term` is `uint64_t`. New REQUIRED callbacks (pure; controller implements I/O):
|
||||
- `int on_persist_entry(void *ctx, uint64_t index, uint64_t term, const void *entry, size_t len)` — durably append/overwrite at index.
|
||||
- `uint64_t on_get_entry_term(void *ctx, uint64_t index)` — term of local entry at index (0 if absent).
|
||||
- `int on_send_append_entries(void *ctx, int peer_idx, uint64_t prev_log_index, uint64_t prev_log_term, uint64_t leader_commit, /* entries handle */ ...)` — transmit.
|
||||
- `int on_truncate_from(void *ctx, uint64_t index)` — delete local entries at `index..end`.
|
||||
New library entry points: `raft_log_append`, `raft_on_append_entries_request`, `raft_on_append_entries_reply`. Existing election callbacks/behaviour unchanged.
|
||||
|
||||
## Scope
|
||||
- **In**: library log model with per-entry term; `ClientRequest` append; AppendEntries send (per-peer next/match index); `HandleAppendEntriesRequest` accept/reject/conflict-truncate/append by `(term,index)`; `UpdateTerm` on every RPC; controller adapter that maps callbacks onto the existing WAL/repl transport (WAL `hdr.term` stamped from `ha->term`, carried in the repl envelope, read back on recovery); simulator coverage asserting **Log Matching**.
|
||||
- **Out**: `AdvanceCommitIndex` / commit-index safety + `(term,lsn)` vote freshness → `raft-commit-safety`. The full conformance harness → `raft-sim-conformance`. Config-change markers + upgrade snapshot → `ha-term-safety`. Async I/O, fan-out scheduling, the sync commit barrier, snapshot transport → controller callback adapters (not the pure core).
|
||||
|
||||
## Preserve as policy (do NOT canonicalize away)
|
||||
PreVote, priority leadership, persist-before-ACK durability + synchronous commit barrier (consumed in `raft-commit-safety`), "controller never self-terminates" (library returns statuses; controller owns liveness/fencing).
|
||||
|
||||
## Verification
|
||||
Extend `raft_sim.c`: drive AppendEntries across partitions, message drop/dup/reorder, and restarts; assert the **Log Matching** invariant (same `(index,term)` ⇒ identical prefix) and that a follower truncates a divergent suffix. Replace the controller's LSN-only reconcile with `(term,index)` matching delegated to the library.
|
||||
|
||||
## Dependencies
|
||||
`raft-library` (election core, implemented `[x]`). Feeds `raft-commit-safety`, `raft-sim-conformance`, and `ha-term-safety` integration. Closes bug-851 G1/G2/G3/G5.
|
||||
411
.kiro/specs/raft-log-replication/design.md
Normal file
411
.kiro/specs/raft-log-replication/design.md
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
# Design Document
|
||||
|
||||
## Overview
|
||||
|
||||
**Purpose**: This feature extends the election-only Raft library (`src/lib/raft/raft.{c,h}`) into a strict implementation of the **log half** of `raft/raft.tla` (Ongaro canonical), giving the library ownership of the replicated log. It closes the four log-half gaps of bug-851 (G1/G2/G3/G5): per-entry term, `prevLogTerm` log-matching, `(term, index)` conflict truncation, and term adoption on the AppendEntries/heartbeat path.
|
||||
|
||||
**Users**: The XCE controller (as a Raft node), the deterministic simulator (`raft_sim.c`), and three downstream specs that consume this seam verbatim — `raft-commit-safety`, `raft-sim-conformance`, and `ha-term-safety`.
|
||||
|
||||
**Impact**: Today the replicated log is hand-rolled across the controller (`persist.c`/`repl.c`/`ha.c`/`ha_convergence.c`) and matches by LSN contiguity only. After this feature, the library owns log growth, AppendEntries emission/handling, per-peer progress, and `(term, index)` truncation as pure, synchronous, deterministic state transitions; the controller becomes a thin adapter that performs I/O behind four new callbacks. The on-disk WAL format is unchanged (the term is a value fix, not a layout change).
|
||||
|
||||
### Goals
|
||||
- Add a library-owned log model where every entry carries a `uint64_t` term, index 1-based mapping 1:1 to the WAL LSN (G1).
|
||||
- Implement `ClientRequest`, `AppendEntries` emission, `HandleAppendEntriesRequest`, and `HandleAppendEntriesResponse` exactly as `raft.tla` defines them (G2/G3).
|
||||
- Apply `UpdateTerm` on **every** RPC including AppendEntries/heartbeat (G5).
|
||||
- Define the four new callbacks **verbatim** per the brief's Authoritative shared contract.
|
||||
- Extend `raft_sim.c` to assert the **Log Matching** invariant and divergent-suffix truncation under partitions, drop/dup/reorder, and restart.
|
||||
|
||||
### Non-Goals
|
||||
- **No commit-index advance** (`AdvanceCommitIndex`, G4) and **no Figure-8 current-term commit rule** — owned by `raft-commit-safety`.
|
||||
- **No `(lastLogTerm, lastLogIndex)` vote freshness** (G6) — owned by `raft-commit-safety`.
|
||||
- **No config-change markers / upgrade snapshot** — owned by `ha-term-safety`.
|
||||
- **No async I/O, fan-out scheduling, commit barrier, or snapshot transport** — these live in controller-side callback adapters, not the pure core.
|
||||
- **No heavy controller WAL/repl/recovery wiring** — this spec defines only the seam contract and a thin adapter description; the integration is `ha-term-safety`.
|
||||
|
||||
## Boundary Commitments
|
||||
|
||||
### This Spec Owns
|
||||
- The library log model: per-entry `uint64_t` term, 1-based index, the in-library leader progress vectors (`next_index[]`, `match_index[]`).
|
||||
- New entry points: `raft_log_append`, `raft_on_append_entries_request`, `raft_on_append_entries_reply`.
|
||||
- The four new callbacks in `raft_callbacks_t`: `on_persist_entry`, `on_get_entry_term`, `on_send_append_entries`, `on_truncate_from`.
|
||||
- `UpdateTerm`-on-any-RPC applied on the AppendEntries/heartbeat path.
|
||||
- The `(term, index)` reconcile seam: the library decides match / truncate / append; the controller routes the decision.
|
||||
- Simulator extensions in `raft_sim.c` + tests in `src/lib/testsuite/test_raft.c` asserting Log Matching.
|
||||
|
||||
### Out of Boundary
|
||||
- Commit-index advance and current-term commit safety (`raft-commit-safety`, G4).
|
||||
- Vote-freshness `(term, index)` comparison in `RequestVote`/PreVote (`raft-commit-safety`, G6).
|
||||
- Configuration markers in the unified log + the change-boundary snapshot (`ha-term-safety`).
|
||||
- The controller's WAL byte layout, repl envelope serialization, recovery loop, commit barrier, fsync/durability, and snapshot transport internals (`ha-term-safety` adapter). This spec specifies only **what the adapter must route**, not its internal mechanics.
|
||||
|
||||
### Allowed Dependencies
|
||||
- The implemented election core (`raft-library`): `raft_state_t`, `raft_callbacks_t`, `raft_init`, the vote/PreVote path. All unchanged.
|
||||
- `errno.h`, `stdint.h`, `string.h` only (the library has no other dependencies and must stay free-standing for the simulator).
|
||||
- The simulator harness (`raft_sim.c`) for verification.
|
||||
- Must NOT depend on any controller header, WAL type, capnp type, or transport — the core stays pure.
|
||||
|
||||
### Revalidation Triggers
|
||||
Dependent specs (`raft-commit-safety`, `raft-sim-conformance`, `ha-term-safety`) must re-check integration if any of the following change:
|
||||
- The signature or semantics of any of the four new callbacks (`on_persist_entry`, `on_get_entry_term`, `on_send_append_entries`, `on_truncate_from`).
|
||||
- The signature or return semantics of `raft_log_append`, `raft_on_append_entries_request`, `raft_on_append_entries_reply`.
|
||||
- The 1-based-index ↔ WAL-LSN identity, or the "term `0` means absent" convention.
|
||||
- The per-peer `next_index[]` / `match_index[]` initialization or update rules.
|
||||
- The placement of `UpdateTerm` before `logOk` on the AppendEntries path.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Existing Architecture Analysis
|
||||
|
||||
The library is a pure, callback-driven state machine (`raft.c`, ~430 lines). `raft_state_t` holds only Raft invariant state; application policy is delegated to `on_vote_eligible`. Persistence is write-only via `on_persist` — **the library never reads storage**; the caller restores `(term, voted_for)` into `raft_config_t` before `raft_init`. This feature follows the same discipline for the log: the library never reads the WAL directly; it reads entry terms only through `on_get_entry_term`, and writes/truncates only through callbacks. The pattern preserved:
|
||||
|
||||
- **Pure / synchronous / deterministic core** — no I/O, no threads, no allocation in the hot path. The log model uses fixed bookkeeping (a `Len(log)` counter `log_len` plus per-peer vectors); the entry **bytes** live in the adapter (WAL) / simulator (in-memory array), reached via callbacks. The library never owns entry storage — consistent with how it already does not own `(term, voted_for)` storage.
|
||||
- **Persist-before-act ordering** — `on_persist` is called before granting a vote / sending RPCs; `on_persist_entry` is called before reporting an append success, identically.
|
||||
- **Status returns, never self-terminate** — the library returns codes; the controller owns liveness and fencing. No new code path aborts.
|
||||
|
||||
### Architecture Pattern & Boundary Map
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph PureCore["src/lib/raft/raft.c — PURE CORE (this spec)"]
|
||||
APP["raft_log_append()<br/>ClientRequest (tla:246)"]
|
||||
SEND["AppendEntries emit<br/>(tla:204) next/match index"]
|
||||
REQ["raft_on_append_entries_request()<br/>HandleAppendEntriesRequest (tla:327)<br/>UpdateTerm (tla:406) first"]
|
||||
REPLY["raft_on_append_entries_reply()<br/>HandleAppendEntriesResponse (tla:393)"]
|
||||
end
|
||||
subgraph CBs["raft_callbacks_t — NEW seam (verbatim contract)"]
|
||||
C1["on_persist_entry(index,term,entry,len)"]
|
||||
C2["on_get_entry_term(index) -> term|0"]
|
||||
C3["on_send_append_entries(peer,prevIdx,prevTerm,leaderCommit,...)"]
|
||||
C4["on_truncate_from(index)"]
|
||||
end
|
||||
subgraph Consumers["ADAPTERS — implement callbacks (NOT this spec's heavy wiring)"]
|
||||
CTRL["controller adapter (ha-term-safety):<br/>WAL hdr.term=ha->term, repl envelope,<br/>recovery read-back, (term,idx) route"]
|
||||
SIM["raft_sim.c: in-memory log,<br/>synchronous routing (this spec)"]
|
||||
end
|
||||
APP --> C1
|
||||
SEND --> C3
|
||||
REQ --> C2 & C1 & C4
|
||||
REPLY -.updates.-> SEND
|
||||
C1 & C2 & C3 & C4 --> CTRL
|
||||
C1 & C2 & C3 & C4 --> SIM
|
||||
```
|
||||
|
||||
**Architecture Integration**:
|
||||
- **Selected pattern**: Pure state machine + callback ports (Hexagonal/Ports-and-Adapters), already established by the election core. New log actions are new ports; adapters (controller, simulator) implement them.
|
||||
- **Domain boundaries**: protocol decisions (what to persist/send/truncate, accept/reject/conflict) stay in the library; I/O (WAL write, transport, recovery, in-memory backing) stays in adapters.
|
||||
- **Existing patterns preserved**: `raft_config_t` extensibility, persist-before-act, NULL-checked optional callbacks, status-code returns, `XCE_TESTING`-gated simulator.
|
||||
- **Steering compliance**: matches `src/lib/CLAUDE.md` (free-standing C, gnu17), `ha-async-io.md` (no blocking in the core; async lives in adapters), `testing.md` (TDD: simulator/test first), "controller never self-terminates" (library returns statuses only).
|
||||
|
||||
### Technology Stack
|
||||
|
||||
| Layer | Choice / Version | Role in Feature | Notes |
|
||||
|-------|------------------|-----------------|-------|
|
||||
| Library core | C (gnu17), `errno.h`/`stdint.h`/`string.h` only | Pure log state machine | Free-standing; no controller/capnp deps |
|
||||
| Callbacks | `raft_callbacks_t` function pointers | Seam for I/O | 4 new required callbacks |
|
||||
| Verification | `raft_sim.c` (XCE_TESTING) + `test_raft.c` | Deterministic Log Matching | In-memory log backing |
|
||||
| Adapter (downstream) | controller `persist.c`/`repl.c` (`ha-term-safety`) | WAL/transport I/O | Not built here |
|
||||
|
||||
## File Structure Plan
|
||||
|
||||
### Modified Files
|
||||
- `src/lib/raft/raft.h` — Add log-model fields to `raft_state_t` (`log_len`, `next_index[RAFT_MAX_PEERS]`, `match_index[RAFT_MAX_PEERS]`); add the four callbacks to `raft_callbacks_t`; declare `raft_log_append`, `raft_on_append_entries_request`, `raft_on_append_entries_reply`; add an `raft_append_result_t` reply struct; extend the simulator section (an in-memory log per `raft_sim_node_t`, an AppendEntries RPC buffer/route).
|
||||
- `src/lib/raft/raft.c` — Implement the three new entry points + a private AppendEntries emit helper; add `on_persist_entry` ordering on `raft_init` validation; apply `UpdateTerm` on the AppendEntries path before `logOk`; initialize `next_index`/`match_index` in `raft_start_election`/`on_become_leader` path.
|
||||
- `src/lib/raft/raft_sim.c` — In-memory per-node log; wire the four callbacks; add `raft_sim_deliver_append_entries` (synchronous request→reply routing honoring partitions); drop/dup/reorder + restart drivers; the Log Matching assertion helper.
|
||||
- `src/lib/testsuite/test_raft.c` — New tests for each entry point and the simulator invariants (TDD-first).
|
||||
|
||||
### Directory Structure (unchanged)
|
||||
```
|
||||
src/lib/raft/
|
||||
├── raft.h # + log fields, 4 callbacks, 3 entry points, sim log
|
||||
├── raft.c # + ClientRequest / AppendEntries / Handle* / UpdateTerm-on-AE
|
||||
└── raft_sim.c # + in-memory log, AE routing, Log Matching assert
|
||||
src/lib/testsuite/
|
||||
└── test_raft.c # + log-replication + Log Matching tests
|
||||
```
|
||||
|
||||
> Each file keeps one responsibility: `raft.c` = pure protocol, `raft_sim.c` = deterministic harness + in-memory adapter, `test_raft.c` = assertions. No new file is introduced; the existing module pattern is extended.
|
||||
|
||||
## System Flows
|
||||
|
||||
### AppendEntries: leader emit → follower handle → leader reply (TLA+ actions in nodes)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant L as Leader (raft.c)
|
||||
participant LC as Leader adapter (cb)
|
||||
participant FC as Follower adapter (cb)
|
||||
participant F as Follower (raft.c)
|
||||
Note over L: raft_log_append(v) — ClientRequest tla:246
|
||||
L->>LC: on_persist_entry(idx, term, v)
|
||||
Note over L: AppendEntries tla:204<br/>prevLogIndex=nextIndex[j]-1
|
||||
L->>LC: on_send_append_entries(j, prevIdx, prevTerm, leaderCommit, entry)
|
||||
LC-->>FC: (transport / sim route)
|
||||
Note over F: raft_on_append_entries_request()<br/>UpdateTerm tla:406 FIRST (G5)
|
||||
F->>FC: on_get_entry_term(prevIdx) %% logOk (G2)
|
||||
alt term < currentTerm OR (follower AND not logOk)
|
||||
F-->>L: reply success=false, matchIndex=0
|
||||
else conflict: local[idx].term != entry.term (G3)
|
||||
F->>FC: on_truncate_from(idx)
|
||||
F->>FC: on_persist_entry(idx, term, entry)
|
||||
F-->>L: reply success=true, matchIndex=idx
|
||||
else no-conflict append OR already-have
|
||||
F->>FC: on_persist_entry(idx, term, entry) %% append case
|
||||
F-->>L: reply success=true, matchIndex=prevIdx+len
|
||||
end
|
||||
Note over L: raft_on_append_entries_reply() tla:393
|
||||
alt success
|
||||
Note over L: nextIndex[j]=matchIndex+1; matchIndex[j]=reply.matchIndex
|
||||
else failure
|
||||
Note over L: nextIndex[j]=Max(nextIndex[j]-1, 1) %% walk back
|
||||
end
|
||||
```
|
||||
|
||||
Flow-level decisions:
|
||||
- **UpdateTerm fires first** on the request path (before `logOk`), so a strictly-higher-term request is never spuriously rejected for a log mismatch (R6.4, closes G5).
|
||||
- **`logOk`** is computed via `on_get_entry_term(prevLogIndex)`; a return of `0` is the "absent" sentinel consistent with `prevLogTerm == 0` when `prevLogIndex == 0` (R1.6, R4.1).
|
||||
- **Conflict truncation** deletes `index..end` via `on_truncate_from` then appends in the same handler invocation (R4.5/R4.6, closes G3) — an intentional, safe collapse of `raft.tla`'s truncate-only conflict step plus the following no-conflict append; see **Note on conflict branch** below.
|
||||
- **Commit is opaque** — `leaderCommit` is passed through untouched; this spec never advances commit (R3.4, R5.4).
|
||||
|
||||
## Requirements Traceability
|
||||
|
||||
| Requirement | Summary | Design Elements | raft.tla action |
|
||||
|-------------|---------|-----------------|-----------------|
|
||||
| 1.1–1.6 | Library log model, per-entry term | `raft_state_t.log_len`; `on_get_entry_term`; `on_persist_entry` | `ClientRequest` (246), `log[i].term` |
|
||||
| 2.1–2.5 | ClientRequest append | `raft_log_append()` | `ClientRequest` (246) |
|
||||
| 3.1–3.5 | AppendEntries emit, next/match | `next_index[]`/`match_index[]`; `on_send_append_entries` | `AppendEntries` (204), `BecomeLeader` (229) |
|
||||
| 4.1–4.7 | logOk / reject / step-down / accept / conflict | `raft_on_append_entries_request()`; `on_get_entry_term`; `on_truncate_from`; `on_persist_entry` | `HandleAppendEntriesRequest` (327) |
|
||||
| 5.1–5.4 | reply advance/decrement | `raft_on_append_entries_reply()` | `HandleAppendEntriesResponse` (393) |
|
||||
| 6.1–6.5 | UpdateTerm on any RPC | term-adoption block in request handler (before logOk); `on_persist`; `on_become_follower` | `UpdateTerm` (406) |
|
||||
| 7.1–7.7 | Callback contract + pure split | `raft_callbacks_t` additions; `raft_init` validation; adapter description | (seam, not a TLA+ action) |
|
||||
| 8.1–8.6 | Simulator Log Matching | `raft_sim.c` AE routing, in-memory log, assert helpers | Log Matching invariant |
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
| Component | Layer | Intent | Req Coverage | Key Dependencies | Contracts |
|
||||
|-----------|-------|--------|--------------|------------------|-----------|
|
||||
| Log model (`raft_state_t`) | Library core | Per-entry term + per-peer progress | 1, 3 | election core (P0) | State |
|
||||
| `raft_log_append` | Library core | ClientRequest append | 2 | on_persist_entry (P0) | Service |
|
||||
| `raft_on_append_entries_request` | Library core | Handle+logOk+conflict+UpdateTerm | 4, 6 | on_get_entry_term, on_truncate_from, on_persist_entry, on_persist, on_become_follower (P0) | Service |
|
||||
| `raft_on_append_entries_reply` | Library core | Advance/decrement progress | 5 | (internal state) | Service |
|
||||
| New callbacks | Seam | I/O ports | 7 | adapters (P0) | Service |
|
||||
| Simulator | Verification | Deterministic Log Matching | 8 | all of the above (P0) | State |
|
||||
|
||||
### Library Core
|
||||
|
||||
#### Log model additions to `raft_state_t`
|
||||
|
||||
| Field | Detail |
|
||||
|-------|--------|
|
||||
| Intent | Hold the library's view of log length and per-peer replication progress (TLA+ `logVars` length + `leaderVars`) |
|
||||
| Requirements | 1.1, 1.2, 3.1 |
|
||||
|
||||
**Responsibilities & Constraints**
|
||||
- `uint64_t log_len;` — `Len(log)`; the highest 1-based index the library believes is persisted locally. Incremented on append, set by truncate.
|
||||
- `uint64_t next_index[RAFT_MAX_PEERS];` — per-peer `nextIndex` (1-based).
|
||||
- `uint64_t match_index[RAFT_MAX_PEERS];` — per-peer `matchIndex` (0 = nothing matched).
|
||||
- The library stores **no entry bytes and no per-entry term array** — entry terms are obtained via `on_get_entry_term`; bytes live in the adapter/simulator. This keeps `raft_state_t` fixed-size and the core pure (consistent with not storing `(term, voted_for)` bytes).
|
||||
- Invariant: `match_index[j] ≤ next_index[j] - 1` for every peer; `log_len` is the 1:1 WAL LSN.
|
||||
|
||||
**State Management**
|
||||
- `log_len` restored at init from the adapter's recovered WAL length (added to `raft_config_t` as `initial_log_len`, default 0; the library never reads storage).
|
||||
- `next_index[j] = log_len + 1`, `match_index[j] = 0` set when the node becomes leader (mirrors `BecomeLeader`, tla:229).
|
||||
|
||||
#### `raft_log_append` — ClientRequest (raft.tla:246)
|
||||
|
||||
| Field | Detail |
|
||||
|-------|--------|
|
||||
| Intent | Append one entry stamped with `currentTerm` on the leader |
|
||||
| Requirements | 2.1, 2.2, 2.3, 2.4, 2.5 |
|
||||
|
||||
##### Service Interface
|
||||
```c
|
||||
/*
|
||||
* raft_log_append: leader appends one client entry (raft.tla ClientRequest, 246).
|
||||
* Stamps the entry with s->term, assigns index Len(log)+1, persists it via
|
||||
* on_persist_entry BEFORE returning success (persist-before-act).
|
||||
* Does NOT advance any commit index (owned by raft-commit-safety).
|
||||
*
|
||||
* out_index: receives the assigned 1-based index (== WAL LSN) on success.
|
||||
* Returns RAFT_OK on success; -EPERM if role != RAFT_LEADER; the on_persist_entry
|
||||
* return is propagated on persist failure (no log mutation on failure).
|
||||
*/
|
||||
int raft_log_append(raft_state_t *s, const void *entry, size_t len,
|
||||
uint64_t *out_index);
|
||||
```
|
||||
- Preconditions: `s->role == RAFT_LEADER`.
|
||||
- Postconditions: `log_len` incremented by 1; `*out_index == log_len`; entry persisted at `(log_len, s->term)`.
|
||||
- Invariants: appended entry term == `s->term`; no commit-index change.
|
||||
|
||||
#### `raft_on_append_entries_request` — HandleAppendEntriesRequest (raft.tla:327) + UpdateTerm (406)
|
||||
|
||||
| Field | Detail |
|
||||
|-------|--------|
|
||||
| Intent | Apply UpdateTerm first, then logOk + reject/step-down/accept (already-have / conflict-truncate / append) |
|
||||
| Requirements | 4.1–4.7, 6.1–6.5 |
|
||||
|
||||
##### Service Interface
|
||||
```c
|
||||
/* Result of handling an AppendEntries request — the adapter sends this back. */
|
||||
typedef struct {
|
||||
uint64_t reply_term; /* current term to return to the leader */
|
||||
int success; /* 1 = accepted, 0 = rejected */
|
||||
uint64_t match_index; /* on success: prevLogIndex + len(entries); else 0 */
|
||||
} raft_append_result_t;
|
||||
|
||||
/*
|
||||
* raft_on_append_entries_request: handle an incoming AppendEntries / heartbeat.
|
||||
* Step order (must not be reordered):
|
||||
* 1. UpdateTerm (raft.tla:406, G5): if term > s->term, adopt term, clear vote,
|
||||
* step down to FOLLOWER, on_persist(term,-1), fire on_become_follower if was
|
||||
* leader. Applied on EVERY RPC including a 0-entry heartbeat, BEFORE logOk.
|
||||
* 2. logOk (raft.tla:328, G2): prevLogIndex==0 || (prevLogIndex<=log_len &&
|
||||
* on_get_entry_term(prevLogIndex)==prevLogTerm).
|
||||
* 3. Reject (raft.tla:333): term<s->term || (term==s->term && FOLLOWER && !logOk)
|
||||
* -> success=0, match_index=0.
|
||||
* 4. Candidate step-down (raft.tla:346): term==s->term && CANDIDATE -> FOLLOWER.
|
||||
* 5. Accept (raft.tla:351), index = prevLogIndex+1:
|
||||
* already-have: entries empty, or local[index].term == entry.term -> success.
|
||||
* conflict (raft.tla:375, G3): local[index] exists, term differs ->
|
||||
* on_truncate_from(index), then on_persist_entry(index, entry).
|
||||
* append (raft.tla:383): log_len == prevLogIndex -> on_persist_entry(index).
|
||||
* entries_len is 0 (heartbeat) or 1 (this spec, like raft.tla).
|
||||
* Returns RAFT_OK; fills *out.
|
||||
*/
|
||||
int raft_on_append_entries_request(raft_state_t *s, int leader_idx,
|
||||
uint64_t term, uint64_t prev_log_index,
|
||||
uint64_t prev_log_term, uint64_t leader_commit,
|
||||
const void *entry, size_t entry_len,
|
||||
uint64_t entry_term, int entries_len,
|
||||
raft_append_result_t *out);
|
||||
```
|
||||
- Preconditions: none beyond a valid `s`.
|
||||
- Postconditions: local log matches the leader through `match_index` on success; divergent suffix truncated on conflict; term adopted if higher.
|
||||
- Invariants: `on_truncate_from` is always followed by re-`on_persist_entry` of the leader's entry; commit is never advanced here (`leader_commit` is opaque, passed for the downstream commit spec to consume — ignored now).
|
||||
|
||||
> **Note on `leader_commit`**: accepted into the signature now (so the wire/sim shape is stable for `raft-commit-safety`) but **not acted on** in this spec — R3.4/R5.4. This avoids a later signature change (a Revalidation Trigger).
|
||||
|
||||
> **Note on conflict branch (intentional, safe divergence from `raft.tla`)**: `raft.tla` (lines 375–388) models the conflict as a **truncate-only** step — `HandleAppendEntriesRequest` deletes the conflicting suffix and returns, and the leader's entry is appended on a *later* AppendEntries via the no-conflict branch (raft.tla:383). XCE deliberately **collapses** the truncate and the immediately-following no-conflict append into a single handler invocation: `on_truncate_from(index)` **then** `on_persist_entry(index, term, entry)`. This is a safe optimization — `raft.tla` itself notes implementations may batch these steps — and Log Matching is preserved because any entry kept after truncation still matches the leader's `(term, index)`, and the freshly-persisted entry is exactly the leader's. On this conflict-and-append path the returned `match_index` is `prevLogIndex + len(entries)` (= `index`), identical to the value the success/no-conflict branch returns, so the leader's `nextIndex`/`matchIndex` accounting is unchanged.
|
||||
|
||||
#### `raft_on_append_entries_reply` — HandleAppendEntriesResponse (raft.tla:393)
|
||||
|
||||
| Field | Detail |
|
||||
|-------|--------|
|
||||
| Intent | Advance per-peer progress on success; walk back `nextIndex` on failure |
|
||||
| Requirements | 5.1, 5.2, 5.3, 5.4 |
|
||||
|
||||
##### Service Interface
|
||||
```c
|
||||
/*
|
||||
* raft_on_append_entries_reply: process a follower's AppendEntries response
|
||||
* (raft.tla:393). Applied only when reply_term == s->term (stale-term drop, 415).
|
||||
* success: next_index[peer]=match_index+1; match_index[peer]=match_index.
|
||||
* failure: next_index[peer]=Max(next_index[peer]-1, 1); match_index unchanged.
|
||||
* Does NOT advance commit (owned by raft-commit-safety).
|
||||
* Returns RAFT_OK.
|
||||
*/
|
||||
int raft_on_append_entries_reply(raft_state_t *s, int peer_idx, int success,
|
||||
uint64_t match_index, uint64_t reply_term);
|
||||
```
|
||||
- Preconditions: `s->role == RAFT_LEADER` for the update to matter (stale replies are ignored).
|
||||
- Postconditions: `next_index`/`match_index` for `peer_idx` updated per branch.
|
||||
- Invariants: `next_index[peer] ≥ 1` always (floor); commit untouched.
|
||||
|
||||
#### AppendEntries emit helper (raft.tla:204)
|
||||
|
||||
| Field | Detail |
|
||||
|-------|--------|
|
||||
| Intent | Compute `prevLogIndex`/`prevLogTerm` and emit ≤1 entry via `on_send_append_entries` |
|
||||
| Requirements | 3.1, 3.2, 3.3, 3.4, 3.5 |
|
||||
|
||||
**Responsibilities & Constraints**
|
||||
- For peer `j`: `prevLogIndex = next_index[j] - 1`; `prevLogTerm = (prevLogIndex > 0) ? on_get_entry_term(prevLogIndex) : 0`.
|
||||
- `lastEntry = Min(log_len, next_index[j])`; send `SubSeq(log, next_index[j], lastEntry)` — at most one entry; a heartbeat when `next_index[j] > log_len`.
|
||||
- Calls `on_send_append_entries(ctx, j, prevLogIndex, prevLogTerm, leaderCommit, /* entries handle */ ...)`. `leaderCommit` is supplied opaquely by the caller (the controller/sim), since commit lives outside this spec.
|
||||
- Emitted only while `s->role == RAFT_LEADER` (R3.5).
|
||||
|
||||
> Exposed as `raft_send_append_entries(raft_state_t *s, int peer_idx, uint64_t leader_commit)` so the controller/simulator can drive replication per-peer (fan-out scheduling itself is an adapter concern).
|
||||
|
||||
### Seam — `raft_callbacks_t` additions (verbatim Authoritative shared contract)
|
||||
|
||||
These are appended to `raft_callbacks_t` and validated **required** in `raft_init`. Reproduced **verbatim** from the brief so downstream specs depend on the exact shapes:
|
||||
|
||||
```c
|
||||
/* REQUIRED: durably append/overwrite the log entry at `index` with `term`. */
|
||||
int on_persist_entry(void *ctx, uint64_t index, uint64_t term,
|
||||
const void *entry, size_t len);
|
||||
|
||||
/* REQUIRED: term of the local entry at `index` (0 if absent). */
|
||||
uint64_t on_get_entry_term(void *ctx, uint64_t index);
|
||||
|
||||
/* REQUIRED: transmit one AppendEntries to peer_idx with prevLog{Index,Term},
|
||||
* leaderCommit, and an entries handle. */
|
||||
int on_send_append_entries(void *ctx, int peer_idx,
|
||||
uint64_t prev_log_index, uint64_t prev_log_term,
|
||||
uint64_t leader_commit /*, entries handle */ ...);
|
||||
|
||||
/* REQUIRED: delete local entries at index..end. */
|
||||
int on_truncate_from(void *ctx, uint64_t index);
|
||||
```
|
||||
|
||||
**Contract rules (R7.5)**: the library decides *what / whether* to persist, send, or truncate; the callback only performs I/O. `on_get_entry_term` returning `0` is the canonical "absent" signal (used by `logOk` and by `prevLogTerm` computation). All four are NULL-checked in `raft_init` and required (return `-EINVAL` if NULL), matching how the election-required callbacks are validated today.
|
||||
|
||||
### Controller adapter (seam only — heavy wiring is `ha-term-safety`)
|
||||
|
||||
This spec describes the adapter **only at the seam**; the full controller integration is the downstream `ha-term-safety` consumer.
|
||||
|
||||
- `on_persist_entry` → controller WAL write that stamps `hdr.term` from `ha->term` (closing G1 at the byte level), at the LSN equal to `index`.
|
||||
- `on_get_entry_term` → read the recorded `hdr.term` for a given LSN (from the in-memory WAL index / recovery read-back).
|
||||
- `on_send_append_entries` → enqueue a repl envelope carrying `prevLogIndex`, `prevLogTerm`, the entry, and its term, onto the existing repl transport.
|
||||
- `on_truncate_from` → truncate the WAL at/after the LSN, replacing the LSN-only reconcile in `ha_convergence.c` with the library's `(term, index)` decision.
|
||||
- On recovery, the controller restores `log_len` (= recovered WAL length) and each entry's term, feeding `on_get_entry_term`; `initial_log_len` is passed into `raft_config_t`.
|
||||
|
||||
## Data Models
|
||||
|
||||
### Domain Model
|
||||
- **Log entry (logical)**: `(index: uint64, term: uint64, value: bytes)`. The library holds only `index` accounting (`log_len`) and obtains `term` via callback; `value` bytes are owned by the adapter (WAL) / simulator (in-memory array). Index is 1-based, identical to the controller WAL LSN.
|
||||
- **Per-peer progress**: `(next_index: uint64, match_index: uint64)` per peer, the TLA+ `leaderVars`.
|
||||
|
||||
### Logical Data Model
|
||||
- `raft_state_t` gains `log_len`, `next_index[RAFT_MAX_PEERS]`, `match_index[RAFT_MAX_PEERS]`.
|
||||
- `raft_config_t` gains `initial_log_len` (restored WAL length at boot; 0 on first boot — the library never reads storage, consistent with `initial_term`/`initial_voted_for`).
|
||||
- `raft_append_result_t` is the request-handler reply payload.
|
||||
- **No on-disk format change**: the WAL header already carries a `hdr.term` field (today always 0); this feature only requires the adapter to write the real term. The library imposes no new persisted layout (R7 in requirements / unchanged on-disk format per `ha-term-safety` R1.7).
|
||||
|
||||
### Data Contracts & Integration
|
||||
- **Entries handle**: `on_send_append_entries` and `raft_on_append_entries_request` carry one entry as `(const void *entry, size_t len, uint64_t entry_term)` plus an `entries_len ∈ {0,1}`. This mirrors `raft.tla`'s "up to 1 entry" simplification and keeps the simulator and controller envelope shapes identical.
|
||||
- **Term `0` sentinel**: `on_get_entry_term` returns `0` for an absent index; legacy pre-feature entries with a zero term are handled by the controller adapter (mapped to the lowest-known term) per `ha-term-safety` R1.8 — out of this library's scope, but the `0`-means-absent convention is honored consistently here.
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Error Strategy
|
||||
The library returns status codes only and **never aborts** (preserves "controller never self-terminates"). Callback failures propagate as negative returns; the caller decides liveness.
|
||||
|
||||
### Error Categories and Responses
|
||||
- **Caller misuse**: `raft_log_append` while not leader → `-EPERM`, no log change. NULL required callback at `raft_init` → `-EINVAL`.
|
||||
- **Persist failure**: `on_persist_entry` non-zero return → propagated; the library does **not** increment `log_len` on a failed append (no phantom entry), preserving the persist-before-act guarantee.
|
||||
- **Truncate failure**: `on_truncate_from` non-zero return on the conflict branch → reported as a non-success request result so the leader retries (the follower has not corrupted its log; it simply did not converge this round).
|
||||
- **Stale RPCs**: a request with `term < currentTerm` is rejected (success=0, matchIndex=0); a reply with `reply_term < currentTerm` is ignored (DropStaleResponse, tla:415).
|
||||
- **Higher-term RPC**: always adopted first (UpdateTerm) — never rejected for a log mismatch (R6.4).
|
||||
|
||||
### Monitoring
|
||||
Reuse the optional `on_trace` callback for new events: `"ae_recv"`, `"ae_reject_stale"`, `"ae_reject_logmismatch"`, `"ae_conflict_truncate"`, `"ae_append"`, `"term_adopt_ae"`, `"ae_reply_advance"`, `"ae_reply_backoff"`. NULL-checked exactly like existing trace calls.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
TDD-mandatory (`testing.md`): tests and simulator drivers are written **before** the `raft.c` implementation (RED → GREEN). All tests are `XCE_TESTING`-gated and live in `src/lib/testsuite/test_raft.c` driving `raft_sim.c`.
|
||||
|
||||
### Unit Tests (entry points, against in-memory sim log)
|
||||
1. `raft_log_append` on a leader stamps `currentTerm`, assigns `Len(log)+1`, calls `on_persist_entry` once, returns the index (R2.1–2.4); on a follower returns `-EPERM` with no log change (R2.2).
|
||||
2. `raft_on_append_entries_request` `logOk`: accepts when `prevLogIndex==0`; accepts when `on_get_entry_term(prevLogIndex)==prevLogTerm`; rejects (success=0, matchIndex=0) when terms differ (R4.1, R4.2 — G2).
|
||||
3. Conflict branch: a follower with `(index=k, term=A)` receiving `(index=k, term=B)` calls `on_truncate_from(k)` then `on_persist_entry(k, B)` and reports success with `matchIndex=k` (R4.5, R4.6 — G3).
|
||||
4. `raft_on_append_entries_reply`: success sets `next_index=matchIndex+1`, `match_index=reply`; failure decrements `next_index` flooring at 1; stale `reply_term` ignored (R5.1–5.3).
|
||||
5. `UpdateTerm` on AppendEntries: a follower/leader at term T receiving an AE/heartbeat at term T+1 adopts T+1, clears vote, persists, and (if was leader) fires `on_become_follower` — applied before `logOk`, including a 0-entry heartbeat (R6.1–6.4 — G5).
|
||||
|
||||
### Integration / Simulator Tests (Log Matching invariant)
|
||||
1. Two-phase replication: leader appends 3 entries, replicates to a quorum; assert every node's prefix is byte-identical (Log Matching, R8.4).
|
||||
2. Partition + heal: isolate a follower during several appends, then heal; assert AppendEntries walks `nextIndex` back, the follower catches up, and Log Matching holds (R8.1, R8.3).
|
||||
3. Divergent-suffix truncation: hand a follower a manufactured divergent tail (same indices, different term), replicate from a higher-term leader; assert the follower truncates and converges to the leader's prefix (R8.5 — G3).
|
||||
4. Chaotic churn / G5 regression: a stale-term node (term lagging) rejoins under a higher-term leader; assert it adopts the higher term from the AppendEntries/heartbeat path and does **not** re-elect at a reused term (R8.6 — closes the bug-851 G5 mechanism in the deterministic harness).
|
||||
|
||||
### Message-fault Tests
|
||||
1. Drop, duplicate, and reorder AppendEntries requests/replies in the sim queue; assert idempotent convergence and Log Matching after the faults clear (R8.3) — the already-have accept branch (R4.4) makes a duplicated AE a no-op.
|
||||
143
.kiro/specs/raft-log-replication/requirements.md
Normal file
143
.kiro/specs/raft-log-replication/requirements.md
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
# 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.term` always 0; `p->term` never set from `ha->term`). The library log model gives every entry a real `uint64_t` term, set to the leader's current term at append time (`raft.tla` `ClientRequest`, line 246).
|
||||
- **G2** — replication matches LSN contiguity only; there is no `prevLogTerm` check. The library enforces the `logOk` predicate (`raft.tla` `AppendEntries` line 204 / `HandleAppendEntriesRequest` line 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 applies `raft.tla` `UpdateTerm` (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_t` term (G1), with the log index 1-based (TLA+) and mapping 1:1 to the controller WAL LSN.
|
||||
- `ClientRequest` append: `raft_log_append(...)` appends `[term ↦ currentTerm, value]` on the leader (`raft.tla` line 246).
|
||||
- `AppendEntries` send: per-peer `nextIndex` / `matchIndex` leader state; emission of `prevLogIndex`, `prevLogTerm`, ≤1 entry, and `leaderCommit` via the `on_send_append_entries` callback (`raft.tla` line 204).
|
||||
- `HandleAppendEntriesRequest`: the `logOk` `prevLogTerm` match (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.tla` line 327).
|
||||
- `HandleAppendEntriesResponse`: success advances `nextIndex` / `matchIndex`; failure decrements `nextIndex` (floored at 1) (`raft.tla` line 393).
|
||||
- `UpdateTerm` applied on **every** incoming RPC including AppendEntries/heartbeat (G5) (`raft.tla` line 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.term` from `ha->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.c` that drives AppendEntries across partitions, message drop/dup/reorder, and restarts, asserting the **Log Matching** invariant and divergent-suffix truncation.
|
||||
|
||||
- **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), and `ha-term-safety` (XCE integration + regression). Its protocol invariants R1–R5 there are realized by this library spec rather than by patching the controller directly.
|
||||
|
||||
## 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
|
||||
|
||||
1. The Raft Library shall maintain, per node, a 1-based log index whose value at index `i` includes the `uint64_t` term recorded for the entry at that index, mirroring `raft.tla` `log[i][...].term`.
|
||||
2. 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.
|
||||
3. When the leader appends a client value, the Raft Library shall record the leader's current `currentTerm` as that entry's term, mirroring `raft.tla` `ClientRequest` (`entry == [term ↦ currentTerm[i], value ↦ v]`).
|
||||
4. 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).
|
||||
5. 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.
|
||||
6. 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 of `0` as "no entry at that index" consistent with `raft.tla` `prevLogTerm == 0` when `prevLogIndex == 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
|
||||
|
||||
1. When `raft_log_append(...)` is called while the node's role is `RAFT_LEADER`, the Raft Library shall append one entry at index `Len(log) + 1` with term equal to `currentTerm`, mirroring `raft.tla` `ClientRequest`.
|
||||
2. If `raft_log_append(...)` is called while the node's role is not `RAFT_LEADER`, then the Raft Library shall reject the append and report a non-success status without mutating the log.
|
||||
3. When appending, the Raft Library shall invoke `on_persist_entry` for the new `(index, term, entry)` before reporting success, preserving the persist-before-act ordering used by the existing election path's `on_persist`.
|
||||
4. 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.
|
||||
5. 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
|
||||
|
||||
1. The Raft Library shall maintain, per peer, a `nextIndex` and a `matchIndex` (the `raft.tla` leaderVars), initialized on becoming leader to `nextIndex = Len(log) + 1` and `matchIndex = 0` for every peer, mirroring `raft.tla` `BecomeLeader`.
|
||||
2. When the library emits an AppendEntries to peer `j`, it shall compute `prevLogIndex = nextIndex[j] - 1` and `prevLogTerm = (prevLogIndex > 0) ? term-at-prevLogIndex : 0`, mirroring `raft.tla` `AppendEntries`.
|
||||
3. 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 the `on_send_append_entries(ctx, peer_idx, prevLogIndex, prevLogTerm, leaderCommit, ...)` callback.
|
||||
4. The Raft Library shall pass the leader commit value through `on_send_append_entries` as an opaque `leaderCommit` argument and shall not itself interpret or advance commit (commit handling is owned downstream).
|
||||
5. 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
|
||||
|
||||
1. When `raft_on_append_entries_request(...)` is processed, the Raft Library shall compute `logOk = (prevLogIndex == 0) ∨ (prevLogIndex ≤ Len(log) ∧ on_get_entry_term(prevLogIndex) == prevLogTerm)`, mirroring the `raft.tla` `logOk` predicate (closes G2).
|
||||
2. If the request term is less than `currentTerm`, or the term equals `currentTerm` and the node is a follower and `¬logOk`, then the Raft Library shall reject the request and report failure with `matchIndex = 0`, mirroring the `raft.tla` reject branch.
|
||||
3. When the request term equals `currentTerm` and the node is a candidate, the Raft Library shall transition the node to follower (return-to-follower branch) without otherwise mutating the log.
|
||||
4. When the request term equals `currentTerm`, the node is a follower, and `logOk` holds, and the incoming entry already matches the local entry at `index = prevLogIndex + 1` (same term, or no entries sent), the Raft Library shall treat the request as already-satisfied and report success with `matchIndex = prevLogIndex + len(entries)` without rewriting the log.
|
||||
5. When `logOk` holds and the local entry at `index = prevLogIndex + 1` exists with a **different** term than the incoming entry, the Raft Library shall delete the divergent entry and all entries after it via `on_truncate_from(ctx, index)`, mirroring the `raft.tla` conflict branch (closes G3).
|
||||
6. When `logOk` holds and the local log ends exactly at `prevLogIndex` (no entry at `prevLogIndex + 1`), the Raft Library shall append the incoming entry via `on_persist_entry` and report success, mirroring the `raft.tla` no-conflict append branch.
|
||||
7. The Raft Library shall return a reply carrying success/failure and the resulting `matchIndex` so 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
|
||||
|
||||
1. When `raft_on_append_entries_reply(...)` reports success, the Raft Library shall set `nextIndex[peer] = matchIndex + 1` and `matchIndex[peer] = reply.matchIndex`, mirroring `raft.tla` `HandleAppendEntriesResponse` success.
|
||||
2. If `raft_on_append_entries_reply(...)` reports failure, then the Raft Library shall set `nextIndex[peer] = Max(nextIndex[peer] - 1, 1)` and leave `matchIndex[peer]` unchanged, mirroring the `raft.tla` failure branch.
|
||||
3. The Raft Library shall apply a reply only when the reply term equals `currentTerm`, ignoring stale-term responses, consistent with `raft.tla` `HandleAppendEntriesResponse` / `DropStaleResponse`.
|
||||
4. The Raft Library shall not, in this spec, recompute or advance any commit index from updated `matchIndex` values (owned by `raft-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
|
||||
|
||||
1. When an incoming AppendEntries or heartbeat RPC carries a term strictly greater than `currentTerm`, the Raft Library shall set `currentTerm` to that term, clear `voted_for`, and transition to follower, mirroring `raft.tla` `UpdateTerm` (closes G5).
|
||||
2. While adopting a higher term from any RPC, the Raft Library shall persist the new `(term, voted_for)` via the existing `on_persist` callback before processing the rest of the RPC, preserving the durability ordering already used on the vote path.
|
||||
3. When a node that was leader adopts a higher term from an AppendEntries/heartbeat RPC, the Raft Library shall fire `on_become_follower` so the controller HA FSM steps down (mirroring the existing higher-term step-down already wired for vote RPCs).
|
||||
4. 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.
|
||||
5. The Raft Library shall not, on equal or lower term, alter `currentTerm` or `voted_for` from the AppendEntries path (term adoption is strictly-greater-than only), consistent with `raft.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
|
||||
|
||||
1. 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 at `index`.
|
||||
2. 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 at `index`, or `0` when absent.
|
||||
3. 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.
|
||||
4. The Raft Library shall define the required callback `int on_truncate_from(void *ctx, uint64_t index)` to delete local entries in `index..end`.
|
||||
5. 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).
|
||||
6. 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`, optional `on_vote_eligible` / `on_send_pre_vote_request` / `on_trace`) and their behaviour unchanged.
|
||||
7. The controller adapter shall implement the four callbacks over the existing WAL/repl transport — stamping `hdr.term` from `ha->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 in `ha-term-safety` as 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
|
||||
|
||||
1. The Raft Simulator shall route AppendEntries requests and responses synchronously between nodes (mirroring its existing vote-RPC routing), honoring per-node partition flags.
|
||||
2. 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.
|
||||
3. The Raft Simulator shall exercise message drop, duplication, and reordering of AppendEntries, plus node restart, while replication proceeds.
|
||||
4. The Raft Simulator shall assert the **Log Matching** invariant after replication: for any two nodes and any index `i`, if both hold an entry at `i` with the same term, then their entire prefixes through `i` are byte-identical.
|
||||
5. 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.
|
||||
6. 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.
|
||||
34
.kiro/specs/raft-log-replication/research.md
Normal file
34
.kiro/specs/raft-log-replication/research.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Research Log: raft-log-replication
|
||||
|
||||
## Discovery Scope
|
||||
|
||||
Extension of an existing, implemented pure-C library (`src/lib/raft/raft.{c,h}` + `raft_sim.c`). Discovery type: **Extension / integration-focused** — the architecture pattern (pure state machine + callback ports + deterministic simulator) already exists and is authoritative; this feature adds the log half of `raft/raft.tla` to it. No external dependency research needed (the library is free-standing C with only `errno.h`/`stdint.h`/`string.h`).
|
||||
|
||||
## Authoritative Sources
|
||||
|
||||
| Source | Use |
|
||||
|--------|-----|
|
||||
| `.kiro/specs/raft-log-replication/brief.md` | Problem, approach, **Authoritative shared callback contract** (binding, reproduced verbatim in design), TLA+ action→entry-point map |
|
||||
| `raft/raft.tla` | Canonical log actions: `ClientRequest` (246), `AppendEntries` (204), `HandleAppendEntriesRequest` (327), `HandleAppendEntriesResponse` (393), `UpdateTerm` (406), `BecomeLeader` (229) |
|
||||
| `.kiro/specs/ha-term-safety/TLA-gap-analysis.md` | Gaps G1–G6; this spec closes G1/G2/G3/G5 |
|
||||
| `src/lib/raft/raft.{c,h}` | Existing election core style: `raft_config_t` extensibility, persist-before-act, NULL-checked required callbacks, status-code returns, `XCE_TESTING` sim |
|
||||
| `.kiro/steering/roadmap.md` (Raft TLA+ Conformance series) | Family policy: preserve PreVote, priority leadership, sync commit-barrier + persist-before-ACK, controller-never-self-terminates |
|
||||
|
||||
## Key Findings
|
||||
|
||||
1. **The library must NOT store entry bytes.** The election core already declines to store `(term, voted_for)` bytes — the caller restores them via `raft_config_t` and the library writes via `on_persist`. The log follows the same discipline: the library tracks only `log_len` + per-peer `next_index`/`match_index`; entry terms come from `on_get_entry_term`, bytes live in the adapter/simulator. This keeps `raft_state_t` fixed-size and the core pure/deterministic (simulator-verifiable). **Decision**: no per-entry array in `raft_state_t`.
|
||||
|
||||
2. **Index ↔ LSN is 1:1 and 1-based.** `raft.tla` logs are 1-based; the controller WAL LSN is the natural mapping. Settling this in the contract (R1.2) means the controller adapter and simulator address entries identically. `on_get_entry_term(0)` / `prevLogIndex==0` → term `0` is the absent sentinel, matching `raft.tla`'s `prevLogTerm == 0` when `prevLogIndex == 0`.
|
||||
|
||||
3. **UpdateTerm must precede logOk on the AE path (G5).** The bug-851 mechanism: a rejoined standby never adopted the leader's term from heartbeats, re-elected at a stale+1 term, and reused a term → two leaders/term. `raft.tla` `Receive` applies `UpdateTerm` before any handler. **Decision**: the request handler applies term adoption first, unconditionally, including for 0-entry heartbeats — otherwise a higher-term request could be rejected for a log mismatch and never advance the term (R6.4).
|
||||
|
||||
4. **`leader_commit` is carried but not acted on.** Commit advance (G4) is `raft-commit-safety`. To avoid a later signature change (a Revalidation Trigger for all downstream consumers), the AppendEntries entry points accept `leader_commit` opaquely **now** and ignore it. This stabilizes the wire/sim shape across the two specs. **Decision (build-vs-defer)**: include the parameter, defer the behavior.
|
||||
|
||||
5. **`(term, index)` truncation replaces the LSN-only reconcile.** The controller's `ha_convergence.c` truncates by a committed-boundary heuristic (G3) and cannot see same-LSN/different-term divergence. The library's conflict branch (`on_truncate_from(index)` + re-append) is the exact `raft.tla` semantics. The heavy controller swap is `ha-term-safety`; this spec owns the library decision + the simulator proof.
|
||||
|
||||
## Synthesis Outcomes
|
||||
|
||||
- **Generalization found**: the existing "pure core, I/O via callbacks, restore-state-via-config, never-read-storage" pattern generalizes cleanly from election state to log state. No new architectural concept is introduced — only new ports and new pure transitions.
|
||||
- **Build-vs-adopt**: adopt the canonical `raft.tla` algorithm verbatim (it is the verified reference). Build only the C translation + the in-memory simulator backing. No third-party Raft library is imported (the project's constraint is a free-standing, simulator-verifiable core).
|
||||
- **Simplification**: keep the "≤1 entry per AppendEntries" `raft.tla` simplification rather than batching — it minimizes atomic regions, matches the spec exactly, and keeps the simulator's Log Matching assertion tractable. Batching is an adapter-side optimization that can be layered later without changing the core contract.
|
||||
- **Risk**: a future signature change to the four callbacks or the three entry points cascades to three downstream specs (logged as Revalidation Triggers). Mitigated by reproducing the brief's contract verbatim and by accepting `leader_commit` now.
|
||||
23
.kiro/specs/raft-log-replication/spec.json
Normal file
23
.kiro/specs/raft-log-replication/spec.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"feature_name": "raft-log-replication",
|
||||
"created_at": "2026-06-21T11:00:00Z",
|
||||
"updated_at": "2026-06-21T11:00:00Z",
|
||||
"language": "en",
|
||||
"phase": "tasks-generated",
|
||||
"approvals": {
|
||||
"requirements": {
|
||||
"generated": true,
|
||||
"approved": true
|
||||
},
|
||||
"design": {
|
||||
"generated": true,
|
||||
"approved": true
|
||||
},
|
||||
"tasks": {
|
||||
"generated": true,
|
||||
"approved": true
|
||||
}
|
||||
},
|
||||
"ready_for_implementation": true,
|
||||
"note": "Auto-approved (-y mode). Extends election-only src/lib/raft into a strict raft.tla log-half implementation: ClientRequest/AppendEntries/HandleAppendEntriesRequest/HandleAppendEntriesResponse/UpdateTerm-on-any-RPC. Closes bug-851 G1/G2/G3/G5. Authoritative shared callback contract (on_persist_entry, on_get_entry_term, on_send_append_entries, on_truncate_from) consumed verbatim by raft-commit-safety, raft-sim-conformance, and ha-term-safety. Boundary: owns src/lib/raft log additions + (term,index) reconcile seam only; NOT commit-index advance, vote freshness, config, or controller WAL/transport internals."
|
||||
}
|
||||
196
.kiro/specs/raft-log-replication/tasks.md
Normal file
196
.kiro/specs/raft-log-replication/tasks.md
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
# Implementation Plan
|
||||
|
||||
> **TDD-mandatory** (`testing.md`): every implementation sub-task is preceded by a RED test in `src/lib/testsuite/test_raft.c` (and the simulator backing in `raft_sim.c`) that fails before the `raft.c` change and passes after. Build/run library tests via `/xce-build lib`.
|
||||
>
|
||||
> **Spec boundary** (applies to ALL tasks): this spec owns the `src/lib/raft` log additions (per-entry term in the library model, `ClientRequest`/`AppendEntries`/`Handle*`/`UpdateTerm`-on-AE) and the `(term, index)` reconcile seam (the four callbacks). It does **NOT** own commit-index advance, `(lastLogTerm,lastLogIndex)` vote freshness, config markers/upgrade snapshot, or the controller's WAL/repl/recovery internals — those are `raft-commit-safety` and `ha-term-safety`. No task here edits any controller file; the controller adapter is described, not built.
|
||||
|
||||
## 1. Header contract: log model, callbacks, entry points
|
||||
|
||||
- [ ] 1.1 Extend `raft.h` with the log model state and config restore field
|
||||
- Add `uint64_t log_len;`, `uint64_t next_index[RAFT_MAX_PEERS];`, `uint64_t match_index[RAFT_MAX_PEERS];` to `raft_state_t`
|
||||
- Add `uint64_t initial_log_len;` to `raft_config_t` (restored WAL length at boot, default 0; library never reads storage)
|
||||
- Observable completion: `raft.h` compiles; `sizeof(raft_state_t)` grows by the new fixed fields; no entry-byte storage added
|
||||
- _Requirements: 1.1, 1.2, 3.1_
|
||||
- _Boundary: raft.h_
|
||||
|
||||
- [ ] 1.2 Add the four new REQUIRED callbacks to `raft_callbacks_t` (verbatim contract)
|
||||
- Declare `on_persist_entry`, `on_get_entry_term`, `on_send_append_entries`, `on_truncate_from` with the exact signatures from design.md / brief
|
||||
- Document each as REQUIRED and the `on_get_entry_term` → `0`-means-absent convention
|
||||
- Observable completion: the four function-pointer members exist in `raft_callbacks_t` with the verbatim signatures; header doc comments match the Authoritative shared contract
|
||||
- _Requirements: 7.1, 7.2, 7.3, 7.4_
|
||||
- _Boundary: raft.h_
|
||||
- _Depends: 1.1_
|
||||
|
||||
- [ ] 1.3 Declare the three new entry points and `raft_append_result_t`
|
||||
- Declare `raft_append_result_t`; declare `raft_log_append`, `raft_send_append_entries`, `raft_on_append_entries_request`, `raft_on_append_entries_reply` with the design.md signatures
|
||||
- Observable completion: header declares all new entry points; a stub TU including `raft.h` compiles
|
||||
- _Requirements: 2.1, 3.3, 4.7, 5.1_
|
||||
- _Boundary: raft.h_
|
||||
- _Depends: 1.2_
|
||||
|
||||
## 2. Init validation and leader-progress initialization
|
||||
|
||||
- [ ] 2.1 RED+GREEN: `raft_init` requires the four new callbacks
|
||||
- Test: `raft_init` returns `-EINVAL` when any of the four new callbacks is NULL; returns 0 when all present
|
||||
- Implement the NULL checks in `raft_init` (mirroring the existing required-callback checks); restore `s->log_len = cfg->initial_log_len`
|
||||
- Observable completion: new `-EINVAL` test cases pass; existing election init tests still green
|
||||
- _Requirements: 7.1, 7.2, 7.3, 7.4, 1.1_
|
||||
- _Boundary: raft.c, test_raft.c_
|
||||
- _Depends: 1.3_
|
||||
|
||||
- [ ] 2.2 RED+GREEN: leader progress initialized on becoming leader
|
||||
- Test: after a node becomes leader, `next_index[j] == log_len + 1` and `match_index[j] == 0` for every peer (raft.tla BecomeLeader)
|
||||
- Implement the `next_index`/`match_index` initialization on the win path (`raft_start_election` single-node + the multi-node become-leader transition)
|
||||
- Observable completion: assertion on `next_index`/`match_index` after election passes; no regression in election tests
|
||||
- _Requirements: 3.1_
|
||||
- _Boundary: raft.c, test_raft.c_
|
||||
- _Depends: 2.1_
|
||||
|
||||
## 3. ClientRequest — raft_log_append (raft.tla:246)
|
||||
|
||||
- [ ] 3.1 RED: append tests (leader stamps term, follower rejected)
|
||||
- Test: leader `raft_log_append` assigns index `log_len+1`, stamps `currentTerm`, calls `on_persist_entry` exactly once with `(index, term, entry)`, returns the index; follower call returns `-EPERM` with no log change and no callback
|
||||
- Observable completion: tests compile and FAIL (no implementation yet)
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4_
|
||||
- _Boundary: test_raft.c_
|
||||
- _Depends: 2.2_
|
||||
|
||||
- [ ] 3.2 GREEN: implement `raft_log_append`
|
||||
- Role guard (`-EPERM` if not leader); persist-before-act (`on_persist_entry` before incrementing `log_len`); set `*out_index`; do NOT advance any commit index
|
||||
- Observable completion: 3.1 tests pass; `log_len` only advances on a successful persist (no phantom entry on persist failure)
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5_
|
||||
- _Boundary: raft.c_
|
||||
- _Depends: 3.1_
|
||||
|
||||
## 4. AppendEntries emission — raft_send_append_entries (raft.tla:204)
|
||||
|
||||
- [ ] 4.1 RED: emit tests (prevLogIndex/prevLogTerm, ≤1 entry, leader-only)
|
||||
- Test: for peer `j`, emit computes `prevLogIndex = next_index[j]-1` and `prevLogTerm` via `on_get_entry_term` (0 when `prevLogIndex==0`), sends ≤1 entry, passes `leader_commit` through opaquely; a non-leader emits nothing
|
||||
- Observable completion: tests compile and FAIL
|
||||
- _Requirements: 3.2, 3.3, 3.4, 3.5_
|
||||
- _Boundary: test_raft.c_
|
||||
- _Depends: 2.2_
|
||||
|
||||
- [ ] 4.2 GREEN: implement `raft_send_append_entries`
|
||||
- Compute prev index/term; `lastEntry = Min(log_len, next_index[j])`; one entry or heartbeat; call `on_send_append_entries` with the opaque `leader_commit`; leader-only guard
|
||||
- Observable completion: 4.1 tests pass; heartbeat (0 entries) emitted when `next_index[j] > log_len`
|
||||
- _Requirements: 3.2, 3.3, 3.4, 3.5_
|
||||
- _Boundary: raft.c_
|
||||
- _Depends: 4.1_
|
||||
|
||||
## 5. UpdateTerm-on-AppendEntries (raft.tla:406, closes G5)
|
||||
|
||||
- [ ] 5.1 RED: higher-term AE/heartbeat adopts term before logOk
|
||||
- Test: a follower and a leader at term T receiving an AE (and separately a 0-entry heartbeat) at term T+1 adopt T+1, clear `voted_for`, call `on_persist(T+1,-1)`, and (was-leader case) fire `on_become_follower`; assert this happens even when the local log would fail `logOk`
|
||||
- Observable completion: tests compile and FAIL
|
||||
- _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5_
|
||||
- _Boundary: test_raft.c_
|
||||
- _Depends: 1.3_
|
||||
|
||||
- [ ] 5.2 GREEN: term-adoption block at the top of the request handler
|
||||
- Implement the `UpdateTerm` block as the FIRST step of `raft_on_append_entries_request`, before `logOk`; persist-before-process; fire `on_become_follower` if was leader; no change on equal/lower term
|
||||
- Observable completion: 5.1 tests pass; the block is unmistakably before `logOk` in `raft.c`
|
||||
- _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5_
|
||||
- _Boundary: raft.c_
|
||||
- _Depends: 5.1_
|
||||
|
||||
## 6. HandleAppendEntriesRequest — logOk / reject / step-down / accept (raft.tla:327, closes G2/G3)
|
||||
|
||||
- [ ] 6.1 RED: logOk + reject + candidate step-down tests
|
||||
- Test: `logOk` true when `prevLogIndex==0` or `on_get_entry_term(prevLogIndex)==prevLogTerm`; reject (success=0, matchIndex=0) on stale term and on `follower && !logOk`; candidate at equal term transitions to follower
|
||||
- Observable completion: tests compile and FAIL
|
||||
- _Requirements: 4.1, 4.2, 4.3_
|
||||
- _Boundary: test_raft.c_
|
||||
- _Depends: 5.2_
|
||||
|
||||
- [ ] 6.2 RED: accept branch tests (already-have / conflict-truncate / append)
|
||||
- Test: already-have (empty entries or matching term) → success, no log rewrite, `matchIndex = prevLogIndex + entries_len`; conflict (`local[index].term != entry.term`) → `on_truncate_from(index)` then `on_persist_entry(index, entry)`, success `matchIndex=index`; append (`log_len == prevLogIndex`) → `on_persist_entry(index)`, success
|
||||
- Observable completion: tests compile and FAIL
|
||||
- _Requirements: 4.4, 4.5, 4.6, 4.7_
|
||||
- _Boundary: test_raft.c_
|
||||
- _Depends: 6.1_
|
||||
|
||||
- [ ] 6.3 GREEN: implement `raft_on_append_entries_request` (logOk + all branches)
|
||||
- Implement (after the 5.2 UpdateTerm block): `logOk` via `on_get_entry_term`; reject/candidate-step-down/accept; the three accept sub-cases exactly per raft.tla:351-388; fill `raft_append_result_t`; never advance commit (`leader_commit` ignored); never perform transport I/O
|
||||
- Observable completion: 6.1 and 6.2 tests pass; `on_truncate_from` is always followed by re-persist of the leader entry on the conflict branch
|
||||
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7_
|
||||
- _Boundary: raft.c_
|
||||
- _Depends: 6.2_
|
||||
|
||||
## 7. HandleAppendEntriesResponse — raft_on_append_entries_reply (raft.tla:393)
|
||||
|
||||
- [ ] 7.1 RED: reply advance/decrement/stale-drop tests
|
||||
- Test: success sets `next_index[peer]=matchIndex+1`, `match_index[peer]=reply.matchIndex`; failure sets `next_index[peer]=Max(next_index[peer]-1,1)` (floor at 1), `match_index` unchanged; a reply with `reply_term != currentTerm` is ignored
|
||||
- Observable completion: tests compile and FAIL
|
||||
- _Requirements: 5.1, 5.2, 5.3_
|
||||
- _Boundary: test_raft.c_
|
||||
- _Depends: 2.2_
|
||||
|
||||
- [ ] 7.2 GREEN: implement `raft_on_append_entries_reply`
|
||||
- Stale-term drop; success/failure branches; `next_index` floored at 1; do NOT recompute or advance any commit index from `match_index`
|
||||
- Observable completion: 7.1 tests pass
|
||||
- _Requirements: 5.1, 5.2, 5.3, 5.4_
|
||||
- _Boundary: raft.c_
|
||||
- _Depends: 7.1_
|
||||
|
||||
## 8. Simulator backing: in-memory log + AppendEntries routing
|
||||
|
||||
- [ ] 8.1 Add an in-memory per-node log and wire the four callbacks in `raft_sim.c`
|
||||
- Add a per-`raft_sim_node_t` in-memory entry array (`{term, bytes}` per index); implement `on_persist_entry` (append/overwrite at index), `on_get_entry_term` (term or 0), `on_truncate_from` (drop index..end), and buffer `on_send_append_entries` into a new AE RPC queue
|
||||
- Observable completion: a sim node can append, read a term back, and truncate deterministically; `raft_sim_init` wires the four callbacks for every node
|
||||
- _Requirements: 8.2, 7.5_
|
||||
- _Boundary: raft_sim.c_
|
||||
- _Depends: 6.3, 7.2_
|
||||
|
||||
- [ ] 8.2 Add synchronous AppendEntries delivery honoring partitions
|
||||
- Add `raft_sim_deliver_append_entries`: for each buffered AE, call `raft_on_append_entries_request` on the (non-partitioned) destination, then route the reply back via `raft_on_append_entries_reply`; skip partitioned nodes (mirrors the existing vote-RPC routing)
|
||||
- Observable completion: driving one append + delivery replicates the entry to a connected follower; partitioned follower receives nothing
|
||||
- _Requirements: 8.1_
|
||||
- _Boundary: raft_sim.c_
|
||||
- _Depends: 8.1_
|
||||
|
||||
- [ ] 8.3 Add a Log Matching assertion helper
|
||||
- Add a helper asserting: for any two nodes and any index `i`, if both hold an entry at `i` with equal term, their prefixes through `i` are byte-identical
|
||||
- Observable completion: the helper returns pass on a converged cluster and fails on a hand-divergent one (self-test)
|
||||
- _Requirements: 8.4_
|
||||
- _Boundary: raft_sim.c_
|
||||
- _Depends: 8.1_
|
||||
|
||||
## 9. Simulator invariant tests (Log Matching, truncation, faults, G5 regression)
|
||||
|
||||
- [ ] 9.1 Replication + partition catch-up Log Matching test
|
||||
- Test: leader appends 3 entries, replicates to a quorum, assert Log Matching; then isolate a follower across more appends, heal, assert `nextIndex` walks back, the follower catches up, and Log Matching holds
|
||||
- Observable completion: test passes end-to-end via the sim
|
||||
- _Requirements: 8.1, 8.3, 8.4_
|
||||
- _Boundary: test_raft.c, raft_sim.c_
|
||||
- _Depends: 8.2, 8.3_
|
||||
|
||||
- [ ] 9.2 Divergent-suffix truncation test (G3)
|
||||
- Test: give a follower a manufactured divergent tail (same indices, different term), replicate from a higher-term leader; assert the follower truncates the divergent suffix (`on_truncate_from`) and converges to the leader's prefix; Log Matching holds
|
||||
- Observable completion: test passes; follower's post-converge prefix equals the leader's
|
||||
- _Requirements: 8.5_
|
||||
- _Boundary: test_raft.c, raft_sim.c_
|
||||
- _Depends: 9.1_
|
||||
|
||||
- [ ] 9.3 Message drop/dup/reorder convergence test
|
||||
- Test: drop, duplicate, and reorder AE requests/replies in the sim queue; assert idempotent convergence (a duplicated AE is a no-op via the already-have branch) and Log Matching after faults clear
|
||||
- Observable completion: test passes despite injected faults
|
||||
- _Requirements: 8.3_
|
||||
- _Boundary: test_raft.c, raft_sim.c_
|
||||
- _Depends: 9.1_
|
||||
|
||||
- [ ] 9.4 Chaotic-churn G5 regression test
|
||||
- Test: a stale-term node (term lagging) 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; Log Matching holds across the churn
|
||||
- Observable completion: test passes; the rejoining node's term equals the leader's after the first heartbeat, and no second leader appears at the reused term
|
||||
- _Requirements: 8.6_
|
||||
- _Boundary: test_raft.c, raft_sim.c_
|
||||
- _Depends: 9.1_
|
||||
|
||||
## 10. Verification
|
||||
|
||||
- [ ] 10.1 Full library suite green under ASAN
|
||||
- Run `/xce-build lib`; all existing election tests plus the new log-replication and simulator tests pass under ASAN with zero leaks
|
||||
- Observable completion: test binary exits 0; ASAN clean; no regression in the election-core tests
|
||||
- _Requirements: 1.1, 2.1, 3.2, 4.3, 5.1, 6.1, 7.1, 8.4_
|
||||
- _Boundary: src/lib/raft, src/lib/testsuite_
|
||||
- _Depends: 9.2, 9.3, 9.4_
|
||||
Loading…
Add table
Add a link
Reference in a new issue