From e626476d101b1cfe95fd8d556768aeb4e8322eeb Mon Sep 17 00:00:00 2001 From: Mike Upton Date: Sun, 19 Jul 2026 01:13:53 +0000 Subject: [PATCH 1/2] docs(site): add consensus/Raft documentation section Phase-1 consensus (log store, transport, RaftCore) is merged to main but wasn't reflected on the site. Add content/docs/consensus/ (index, log store, transport, election-and-replication, read-index, safety invariants & simulation) covering what's actually built: pre-vote election, log replication with the consistency check, current-term commit rule, and read-index linearizable reads, proven by a deterministic fault-injecting simulation against four safety invariants. Also: add a pre-vote-and-read-index ADR documenting the pre_vote:bool RPC-contract fix a whole-branch review caught before merge, update roadmap.mdx to mark Phase 2 core as shipped with an honest next-up list (snapshots/membership, node driver, chaos harness), and update architecture.mdx/index.mdx so the top-level story includes the consensus layer without overstating what's wired together yet. Co-Authored-By: Claude Opus 4.8 --- apps/site/content/docs/architecture.mdx | 37 +++++--- .../consensus/election-and-replication.mdx | 82 ++++++++++++++++ apps/site/content/docs/consensus/index.mdx | 73 ++++++++++++++ .../site/content/docs/consensus/log-store.mdx | 78 +++++++++++++++ apps/site/content/docs/consensus/meta.json | 11 +++ .../content/docs/consensus/read-index.mdx | 63 +++++++++++++ .../docs/consensus/safety-and-simulation.mdx | 94 +++++++++++++++++++ .../site/content/docs/consensus/transport.mdx | 52 ++++++++++ apps/site/content/docs/decisions/index.mdx | 3 + apps/site/content/docs/decisions/meta.json | 1 + .../decisions/pre-vote-and-read-index.mdx | 78 +++++++++++++++ apps/site/content/docs/index.mdx | 11 +++ apps/site/content/docs/meta.json | 1 + apps/site/content/docs/roadmap.mdx | 72 ++++++++++---- 14 files changed, 622 insertions(+), 34 deletions(-) create mode 100644 apps/site/content/docs/consensus/election-and-replication.mdx create mode 100644 apps/site/content/docs/consensus/index.mdx create mode 100644 apps/site/content/docs/consensus/log-store.mdx create mode 100644 apps/site/content/docs/consensus/meta.json create mode 100644 apps/site/content/docs/consensus/read-index.mdx create mode 100644 apps/site/content/docs/consensus/safety-and-simulation.mdx create mode 100644 apps/site/content/docs/consensus/transport.mdx create mode 100644 apps/site/content/docs/decisions/pre-vote-and-read-index.mdx diff --git a/apps/site/content/docs/architecture.mdx b/apps/site/content/docs/architecture.mdx index d6d031f..8a8680d 100644 --- a/apps/site/content/docs/architecture.mdx +++ b/apps/site/content/docs/architecture.mdx @@ -24,8 +24,8 @@ result is a complete, smaller system — never a broken half of a larger one. ┌──────────────────────────────────────────────────┐ │ MVCC transaction layer (Rust) │ Phase 3 ├──────────────────────────────────────────────────┤ -│ Raft consensus (Rust) │ Phase 2 -│ election · replication · read-index · snapshot · membership +│ Raft consensus (Rust) │ Phase 2 — core shipped +│ election · replication · read-index ✓ · snapshot · membership ← next ├──────────────────────────────────────────────────┤ │ Custom LSM storage engine (Rust) │ Phase 1 — shipped │ WAL · memtable · SSTables · leveled compaction · bloom @@ -49,27 +49,34 @@ through every write so the future MVCC layer can store multiple versions per key. Depends on the filesystem only. Full internals: [LSM Storage Engine](/docs/lsm-engine). -### 2. Raft consensus (Rust) +### 2. Raft consensus (Rust) — core shipped Replicates a log of commands across a group so every replica applies the same commands in the same order, tolerating a minority of node failures. -Leader election with pre-vote, log replication with a commit index, -read-index for linearizable reads without a log write, log compaction via -storage-engine snapshots, and joint-consensus membership changes. Depends on -a dedicated Raft log store (append-oriented, index-keyed — not the LSM KV -engine; see [Raft-over-Paxos](/docs/decisions/raft-over-paxos) and +**Built:** leader election with pre-vote, log replication with the +consistency check and conflict back-up, commit-index advancement under +Raft's current-term rule, and read-index for linearizable reads without a +log write — as `RaftCore`, a pure, synchronous, I/O-free step function, +proven against four safety invariants by a deterministic multi-node +simulation. **Next:** log compaction via storage-engine snapshots and +joint-consensus membership changes. Depends on a dedicated Raft log store +(append-oriented, index-keyed — not the LSM KV engine; see +[Raft-over-Paxos](/docs/decisions/raft-over-paxos) and [log-store vs. LSM reuse](/docs/decisions/lsm-over-btree)) and a pluggable transport trait so a deterministic in-memory implementation can drive -multi-node simulation in tests. +multi-node simulation in tests. Full internals: +[Raft Consensus](/docs/consensus). -### 3. Transport / RPC (Rust) +### 3. Transport / RPC (Rust) — shipped Node-to-node messaging for Raft (AppendEntries, RequestVote, snapshots) and -client request routing. Async send/receive of typed messages, pluggable so -the chaos harness can inject partitions, drops, delays, and reordering. -Transport is kept deliberately dumb — length-prefixed messages over TCP — -because the pluggable seam exists for the chaos harness, not for production -networking features. +client request routing, behind a `Transport` trait with two +implementations: a deterministic, seeded in-memory transport for +simulation (fault injection: partition, drop, delay) and a length-prefixed +framed TCP transport for a real cluster. Transport is kept deliberately +dumb by design — the pluggable seam exists for the chaos harness, not for +production networking features. Wiring it into an async node event loop is +still ahead. See [Transport](/docs/consensus/transport). ### 4. MVCC transaction layer (Rust) diff --git a/apps/site/content/docs/consensus/election-and-replication.mdx b/apps/site/content/docs/consensus/election-and-replication.mdx new file mode 100644 index 0000000..1346967 --- /dev/null +++ b/apps/site/content/docs/consensus/election-and-replication.mdx @@ -0,0 +1,82 @@ +--- +title: Election and Replication +description: Pre-vote leader election and log replication with the consistency check and conflict back-up, as a pure step function. +--- + +## Roles and time + +`RaftCore` tracks four roles — `Follower`, `PreCandidate`, `Candidate`, +`Leader` — and advances purely on logical time: `tick()` drives every +timeout, and there is no `Instant`/`SystemTime` anywhere in the core. +Election timeouts are randomized per node, drawn from a seeded PRNG held +inside the core, so the same construction seed reproduces the same election +timing run after run — a requirement for the +[deterministic simulation](/docs/consensus/safety-and-simulation) to be +meaningful at all. + +## Pre-vote: elections without disruption + +A naive Raft election lets any follower that stops hearing from a leader +increment its term and start soliciting votes. That's a problem for a node +that's merely partitioned, not actually needed: it keeps incrementing its +term on every timeout, and the moment it rejoins the cluster, its inflated +term forces a legitimate, working leader to step down for no real reason. + +`RaftCore` runs a **pre-vote** round first: on election timeout, a follower +sends `RequestVote { pre_vote: true }` *without* bumping its own term. Peers +answer honestly — would they vote for this candidate, hypothetically — using +the same up-to-date-log check a real vote uses, but the responder doesn't +persist anything or mutate `voted_for` for a pre-vote. Only if a *majority* +grants the pre-vote does the node become a real `Candidate`: increment its +term, vote for itself, persist that hard state, and broadcast a real +`RequestVote`. A partitioned node's pre-votes simply never reach a quorum, +so it never disrupts a healthy cluster. Any message carrying a higher term +than the node's own — pre-vote or real — steps it down to `Follower` and +persists the observed term. + +## Vote granting + +A peer grants a real vote iff the candidate's log is at least as +up-to-date as its own (compared by `last_log_term`, then `last_log_index` +as a tiebreak) **and** it hasn't already voted for a different candidate +this term. `voted_for` is persisted *before* the grant is sent — reversing +that order would let a crash between "decide to grant" and "persist the +vote" produce a double vote across a restart, which is exactly what the +core's restart test pins down. + +## Replication and the consistency check + +`AppendEntries` carries `prev_log_index`/`prev_log_term` so a follower can +check that its log agrees with the leader's immediately before the new +entries. On a mismatch, the follower rejects and returns a `conflict_index` +hint rather than making the leader retry one index at a time — that hint is +what lets a badly lagging follower catch up in a handful of round trips +instead of one per missing entry. On a match, the follower truncates any +conflicting suffix (`truncate_suffix` — see [Log Store](/docs/consensus/log-store)) +before appending the new entries. Heartbeats are just empty `AppendEntries` +calls, reusing the exact same consistency-check path as a real replication +round. + +The leader tracks `next_index`/`match_index` per peer and updates them from +each ack — advancing on success, backing up using `conflict_index` on +rejection. + +## Commit advancement: the current-term rule + +The leader advances its commit index to the highest index that's +replicated on a majority of `match_index` values — but only counting +entries **that belong to the current term**. This is Raft §5.4.2's +guardrail against a classic bug: committing an *older*-term entry purely by +replica count can be undone by a later leader that legitimately overwrites +it, which would mean a "committed" entry silently disappears. `RaftCore`'s +simulation pins this down with a dedicated scenario — a leader appends an +entry, gets partitioned before it commits, and a new leader is elected and +overwrites the uncommitted entry — asserting the old entry never gets +counted as committed by count alone. Followers adopt +`min(leader_commit, last_index)` as their own commit index once they've +matched the leader's log up to that point. + +See [Read-Index](/docs/consensus/read-index) for how commit advancement +feeds linearizable reads, and +[Safety Invariants & Simulation](/docs/consensus/safety-and-simulation) for +how all of this is checked under fault injection. diff --git a/apps/site/content/docs/consensus/index.mdx b/apps/site/content/docs/consensus/index.mdx new file mode 100644 index 0000000..c35881b --- /dev/null +++ b/apps/site/content/docs/consensus/index.mdx @@ -0,0 +1,73 @@ +--- +title: Overview +description: The Phase 1 consensus layer — log store, transport, and RaftCore — and what's actually proven about it so far. +--- + +## Status: core shipped + +Raft consensus is `crates/raft` (`cairn-raft`), built bottom-up in three +cycles, all merged: a dedicated **log store**, a pluggable **transport** +layer, and **RaftCore** — a pure, synchronous, I/O-free consensus step +function covering pre-vote leader election, log replication, commit +advancement, and read-index linearizable reads. **104 library tests plus 9 +deterministic simulation tests, green.** The simulation drives a multi-node +cluster through partition, drop, reorder, and crash-restart fault injection +and checks four safety invariants after every scenario — see +[Safety Invariants & Simulation](/docs/consensus/safety-and-simulation). + +What's left before this backs a live cluster: `InstallSnapshot` handling and +joint-consensus membership changes, the async node driver that wires +`RaftCore` to a real clock and the real transport, and the chaos/Jepsen +harness. See [Roadmap](/docs/roadmap) for what's next and in what order. + +## What Raft turns the LSM engine into + +The [LSM storage engine](/docs/lsm-engine) is a durable, crash-recoverable +*local* key-value store — it has no idea any other node exists. Raft +replicates a log of commands across a fixed set of nodes so every replica +applies the same commands in the same order, which is what turns that local +store into a linearizable, fault-tolerant one. See +[Raft over Paxos](/docs/decisions/raft-over-paxos) for why this project's +consensus protocol is Raft specifically, and +[Architecture](/docs/architecture#components) for where it sits in the +overall stack. + +## Module map + +| Module | Responsibility | +| --- | --- | +| [Log store](/docs/consensus/log-store) | Durable, index-addressed storage for log entries, hard state, and snapshot metadata | +| [Transport](/docs/consensus/transport) | The `Transport` trait, a deterministic in-memory implementation, and a framed TCP implementation | +| [Election and replication](/docs/consensus/election-and-replication) | Pre-vote leader election and log replication as a pure step function | +| [Read-index](/docs/consensus/read-index) | Linearizable reads without a log write | +| [Safety invariants & simulation](/docs/consensus/safety-and-simulation) | The four properties RaftCore is checked against, and how the deterministic simulation proves them | + +## RaftCore's interface + +`RaftCore` is a synchronous struct — no async, no wall +clock, no filesystem handle. It takes inputs and buffers outputs for a +driver to send elsewhere: + +```rust +fn tick(&mut self); // advance logical time +fn step(&mut self, from: NodeId, msg: Message) -> Result<()>; // process one inbound RPC +fn propose(&mut self, command: Vec) -> Result; // leader appends a command +fn read_index(&mut self, token: ReadToken); // register a linearizable read + +fn ready(&mut self) -> Ready; // drains { messages, apply, reads } +``` + +Keeping persistence behind a synchronous `RaftStorage` trait call (rather +than an async one) lets the core itself enforce Raft's persist-before-act +ordering — append then send, persist a vote then reply — and keeps +simulation fully deterministic, since the storage backing it is +synchronous too. Phase 1 ships `MemStorage`, an in-memory implementation +for the core's own tests and simulation; a `RaftLog`-backed adapter that +plugs the real [log store](/docs/consensus/log-store) into `RaftStorage` is +part of the node-driver work still ahead. + +The state machine this cycle applies committed entries to is a simple +in-memory KV — wiring the real LSM engine in as the applied state is also +node-driver work, not part of `RaftCore` itself. Keeping that boundary +narrow is what let the consensus core be built and proven correct without +dragging the whole storage engine into every test. diff --git a/apps/site/content/docs/consensus/log-store.mdx b/apps/site/content/docs/consensus/log-store.mdx new file mode 100644 index 0000000..e6af357 --- /dev/null +++ b/apps/site/content/docs/consensus/log-store.mdx @@ -0,0 +1,78 @@ +--- +title: Log Store +description: Durable, index-addressed storage for the Raft log, hard state, and snapshot metadata — the foundation the consensus core is built on. +--- + +## Purpose + +Raft needs a durable, ordered log of entries indexed by a dense integer, not +an arbitrary key — and it needs to be able to throw away a suffix of that +log when a follower's history conflicts with a new leader's. That's a +different access pattern from a KV store, which is why the log store is its +own component in `crates/raft` rather than another consumer of the +[LSM engine](/docs/lsm-engine). Full rationale: +[LSM over B-tree](/docs/decisions/lsm-over-btree#why-the-raft-log-store-is-not-another-lsm-consumer). + +It was also the first thing built in the consensus cycle — everything else +(transport, `RaftCore`) is testable against it without any networking or +election logic in the picture. + +## What it durably tracks + +- **The log itself** — `LogEntry { term, index, command }`, appended in + order, addressed by index. +- **Hard state** — `current_term` and `voted_for`, the two fields Raft + requires survive a crash before a node can safely take further action + (grant a vote, become a candidate). Persisted atomically and CRC'd. +- **Snapshot metadata** — the index and term the latest snapshot covers, so + the log store knows what a compacted prefix stood for even after the + entries themselves are gone. + +## Interface + +```rust +fn append(&mut self, entries: &[LogEntry]) -> Result<()>; +fn entries_from(&self, index: LogIndex) -> Vec; +fn entry(&self, index: LogIndex) -> Result>; +fn last_index(&self) -> LogIndex; +fn last_term(&self) -> Term; +fn truncate_suffix(&mut self, from_index: LogIndex) -> Result<()>; // conflict resolution +fn compact_prefix(&mut self, up_to_index: LogIndex, meta: SnapshotMeta) -> Result<()>; +fn save_hard_state(&mut self, hs: &HardState) -> Result<()>; +fn load_hard_state(&self) -> HardState; +``` + +`truncate_suffix` and `compact_prefix` are the two operations that don't map +cleanly onto an LSM engine's append-only, newest-wins model — a follower +that discovers its log diverges from the leader's needs to drop everything +after the conflict point outright, not shadow it with tombstones. + +## Durability: CRC records, torn-tail replay, truncate-on-open + +The log store reuses the ideas proven in the [WAL](/docs/lsm-engine/wal) — +CRC-checksummed records and crash-tolerant replay — without inheriting its +data structure. Each op-log record is length- and checksum-framed; on +replay, the reader stops cleanly at the first corrupt or short record and +returns everything valid read so far, the same torn-write discipline the +storage engine's WAL uses. + +One thing the Raft log adds on top of that pattern: **truncate-on-open**. A +crash can leave a torn tail behind even when nothing was actively being +truncated — replay has to reconcile the on-disk op-log with the last known +good state at open time, not just at the moment of a live write, since a +`truncate_suffix` call itself has to be crash-safe. + +Hard state is persisted separately from the op-log, atomically and CRC'd, +and fsync'd on every change — a node must never be able to lose or corrupt +its current term or vote across a restart, since replaying a stale vote +could let it grant two votes in the same term. + +## What's still a soft invariant + +`truncate_suffix`'s snapshot-boundary check — refusing to truncate at or +below the last compacted index — is currently a `debug_assert` rather than +a returned `Error::Corruption`, unlike `append` and `compact_prefix`, which +were promoted to real errors. It's harmless until snapshotting +([Roadmap](/docs/roadmap)) actually starts compacting log prefixes in +practice, but it's the kind of thing that needs to become a hard error +before this backs a live cluster. diff --git a/apps/site/content/docs/consensus/meta.json b/apps/site/content/docs/consensus/meta.json new file mode 100644 index 0000000..6da1de2 --- /dev/null +++ b/apps/site/content/docs/consensus/meta.json @@ -0,0 +1,11 @@ +{ + "title": "Raft Consensus", + "pages": [ + "index", + "log-store", + "transport", + "election-and-replication", + "read-index", + "safety-and-simulation" + ] +} diff --git a/apps/site/content/docs/consensus/read-index.mdx b/apps/site/content/docs/consensus/read-index.mdx new file mode 100644 index 0000000..9a5ca77 --- /dev/null +++ b/apps/site/content/docs/consensus/read-index.mdx @@ -0,0 +1,63 @@ +--- +title: Read-Index +description: Serving linearizable reads without a log write, and proving they're actually safe. +--- + +## The problem + +The simplest way to make a read linearizable in Raft is to route it through +the log like any other command: propose a no-op, wait for it to commit, +then read. That's correct, but it means every read pays for a log append +and a disk fsync — expensive for a read-heavy workload. **Read-index** gets +the same linearizability guarantee without writing anything to the log. + +## How it works + +`read_index(token)` registers a read request. The leader: + +1. **Snapshots its current `commit_index`** as the read's floor — nothing + committed after this point needs to be visible for the read to be valid. +2. **Confirms it's still the leader** by forcing a heartbeat round and + waiting for a majority to acknowledge it — a stale or partitioned former + leader must not serve a read that a newer leader has already diverged + from. +3. **Releases the read** — the token is emitted in `Ready.reads` — once + `last_applied` has caught up to the floor from step 1. + +The leadership-confirmation step doesn't use wall-clock "last heard from +this peer at time T" bookkeeping, which is fragile under message reordering +— an ack that arrives late could be mistaken for a fresh one. Instead each +peer has a send/ack counter pair: the read snapshots the current send count +per peer as a barrier, forces a broadcast, and only counts an ack toward +the quorum once a peer's ack count has advanced *past* that snapshotted +barrier — a pigeonhole argument that the ack corresponds to a message sent +after the read was registered, not a stale one. That holds under arbitrary +drop and reorder, which is exactly the condition the +[transport](/docs/consensus/transport) and +[simulation](/docs/consensus/safety-and-simulation) are built to exercise. + +## The freshly-elected-leader gap + +A newly elected leader's `commit_index` may still reflect entries from a +*previous* term — it hasn't necessarily committed anything in its own term +yet, since commit advancement only counts current-term entries (see +[Election and Replication](/docs/consensus/election-and-replication#commit-advancement-the-current-term-rule)). +Serving a read against that stale commit index would violate +linearizability. `RaftCore` closes this the standard way: a freshly elected +leader appends a no-op entry immediately, and won't release any read until +that no-op has committed — which forces its commit index to be current +before the first read can be trusted. + +## What the tests pin down + +Unit tests cover the release conditions directly: the floor is computed +correctly, a read isn't released before quorum confirmation, a read isn't +released before applied state catches up, and no read is served across the +no-op gap on a freshly elected leader. The +[deterministic simulation](/docs/consensus/safety-and-simulation) is what +currently exercises this under realistic node behavior (elections, +partitions, restarts); driving actual *reads* through the same +arbitrary-reorder fault injection the simulation applies to writes is +tracked as follow-on work for the chaos harness, not yet built — the +simulation today asserts safety invariants over applied writes, not read +outcomes. diff --git a/apps/site/content/docs/consensus/safety-and-simulation.mdx b/apps/site/content/docs/consensus/safety-and-simulation.mdx new file mode 100644 index 0000000..f84a8d3 --- /dev/null +++ b/apps/site/content/docs/consensus/safety-and-simulation.mdx @@ -0,0 +1,94 @@ +--- +title: Safety Invariants & Simulation +description: The four properties RaftCore is checked against, and how a deterministic, seeded, fault-injecting simulation proves them instead of asserting them. +--- + +## Why this page exists + +Anyone can *implement* Raft. The hard part — and the actual differentiator +for this project — is *proving* the implementation upholds Raft's safety +guarantees under the exact conditions that break naive implementations: +partitions, dropped and reordered messages, and crash-restarts mid-election +or mid-replication. This page is about that proof, not the algorithm +itself; see [Election and Replication](/docs/consensus/election-and-replication) +and [Read-Index](/docs/consensus/read-index) for the mechanics. + +## The four invariants + +Every scripted scenario is checked against all four after it runs: + +- **Election safety** — at most one leader per term. +- **Log matching** — if two logs contain an entry with the same index and + term, the logs are identical up through that index. +- **State-machine safety** — no two nodes ever apply different commands at + the same log index. +- **Leader completeness** *(containment)* — a committed entry is present in + the log of every future leader. + +These are exactly the properties [Raft over Paxos](/docs/decisions/raft-over-paxos) +points to as the reason this project chose Raft in the first place: a +protocol whose safety argument decomposes into a short, checkable invariant +list is a protocol whose test suite can actually state those invariants as +assertions and check them against a captured run, rather than trusting an +aggregate correctness argument. + +## Determinism, on purpose + +A simulation that isn't reproducible is barely better than a flaky +integration test. `RaftCore`'s simulation is built so that a fixed seed +reproduces the exact same run, every time: + +- **Logical time only.** `tick()` drives every timeout; there is no + `Instant`/`SystemTime` anywhere in the core or the harness. +- **Seeded randomness.** Election timeout jitter comes from a per-node PRNG + seeded at construction — same seed, same election timing. +- **A single controlling task.** The harness advances every node's clock + and routes every message from one task with ordered collections, not a + `HashMap` whose iteration order can silently vary between runs. + +That combination means a failing scenario is always replayable from its +seed — a real advantage over consensus bugs that only reproduce "sometimes, +under load." + +## Fault injection + +Each scenario scripts a specific failure shape against the cluster: + +- **Clean election** — baseline: a leader is elected, invariants hold. +- **Leader crash → re-election** — the current leader is killed mid-run; a + new one must be elected without violating election safety. +- **Partition and heal** — a minority of nodes is cut off, rejoins later, + and must reconcile its log without diverging. +- **Dropped `AppendEntries` → back-up and catch-up** — replication messages + are dropped; the `conflict_index` back-up mechanism must still converge + the follower's log. +- **Reordered delivery** — messages arrive out of send order; the + consistency check must still reject genuine conflicts and accept genuine + progress. + +## What's actually running today + +**104 library tests plus 9 simulation tests, green**, covering unit-level +behavior (vote-granting edge cases, term step-down, pre-vote +non-disruption, consistency-check rejection and back-up, commit-index +majority math including the current-term rule, read-index release +conditions) and the scripted multi-node scenarios above. A dedicated +**restart test** discards a node's volatile state, rebuilds `RaftCore` from +its persisted storage, and checks that term, vote, and log all recover with +no committed entry lost. + +Every change to `crates/raft` goes through a whole-branch adversarial +review pass in addition to per-task review before merge — see +[Pre-vote and Read-Index](/docs/decisions/pre-vote-and-read-index) for a +real cross-cutting safety bug that pass caught and a per-task review had +missed. + +## What this sets up + +The simulation today checks *write* safety across applied state. A +standalone per-key linearizability checker (`lincheck`) already exists +alongside `RaftCore`, built for the next stage: a chaos/Jepsen-style harness +that drives a real cluster (real TCP transport, real crashes) through the +same fault classes, records the resulting history of reads and writes, and +checks that history against `lincheck` instead of hand-asserted invariants. +See [Roadmap](/docs/roadmap) for where that sits in the sequencing. diff --git a/apps/site/content/docs/consensus/transport.mdx b/apps/site/content/docs/consensus/transport.mdx new file mode 100644 index 0000000..ce51998 --- /dev/null +++ b/apps/site/content/docs/consensus/transport.mdx @@ -0,0 +1,52 @@ +--- +title: Transport +description: A pluggable Transport trait — a deterministic in-memory implementation for simulation, and a framed TCP implementation for a real cluster. +--- + +## Purpose + +Raft nodes need to exchange typed RPC messages — `RequestVote`, +`AppendEntries`, `InstallSnapshot`, and their responses — over the network. +`crates/raft` puts that behind a single `Transport` trait so the exact same +message contract can be driven by two very different implementations: a +real socket for a live cluster, and a deterministic fake for tests. Neither +`RaftCore` itself nor the message types (`rpc.rs`'s `Message` enum) know or +care which one is underneath. + +## Two implementations, one trait + +**The in-memory transport** is a deterministic, seeded message bus built +for reproducible multi-node testing. It supports fault injection — +partitioning a node from the rest of the cluster, dropping messages, and +delaying them — which is exactly the seam the chaos/Jepsen harness +([Roadmap](/docs/roadmap)) will extend with richer fault scripts. A fixed +seed is meant to reproduce a run exactly, which is what makes a failing +test case replayable instead of a one-off flake. + +**The TCP transport** is the real one: length-prefixed framed messages over +a socket, with a small hand-rolled codec rather than a general-purpose RPC +framework — deliberately dumb, because the pluggable seam exists to serve +the test harness, not to grow into a feature-rich networking layer. + +## Where determinism actually lives + +`RaftCore`'s own test suite and the [safety-invariant simulation](/docs/consensus/safety-and-simulation) +don't drive nodes over the async in-memory transport described above — they +use a synchronous, single-task message router built specifically for the +core's tests. That sidesteps a real subtlety: the async in-memory +transport's seed governs delivery *timing* (partition/drop/delay +scheduling), but doesn't itself impose an order on messages sent +concurrently by independent tasks — true determinism there needs either a +single controlling task or explicit seeded interleaving. `RaftCore`'s +simulation gets full reproducibility by using a synchronous router driven +from one task; the async transport in this module is what the eventual node +driver and the chaos harness exercise, where that real-async, multi-task +shape is the point. + +## What's next + +Wiring the transport into an async node event loop — the ticks, +inbound-message, and client-proposal loop that drives `RaftCore` against a +real clock — is node-driver work, not yet built. See +[Overview](/docs/consensus#raftcores-interface) for the core/driver split +and [Roadmap](/docs/roadmap) for sequencing. diff --git a/apps/site/content/docs/decisions/index.mdx b/apps/site/content/docs/decisions/index.mdx index 23e30f2..fa25957 100644 --- a/apps/site/content/docs/decisions/index.mdx +++ b/apps/site/content/docs/decisions/index.mdx @@ -10,6 +10,9 @@ in the repo, not reconstructed after the fact. - **[Raft over Paxos](/docs/decisions/raft-over-paxos)** — why consensus is Raft, not Paxos or a Paxos variant. +- **[Pre-vote and read-index](/docs/decisions/pre-vote-and-read-index)** — + why elections use a non-disruptive pre-vote round and reads use + read-index, plus a real safety bug the design caught before merge. - **[LSM over B-tree](/docs/decisions/lsm-over-btree)** — why the storage engine is log-structured, and why the Raft log store is a separate component rather than another LSM consumer. diff --git a/apps/site/content/docs/decisions/meta.json b/apps/site/content/docs/decisions/meta.json index 93baf7a..9303801 100644 --- a/apps/site/content/docs/decisions/meta.json +++ b/apps/site/content/docs/decisions/meta.json @@ -3,6 +3,7 @@ "pages": [ "index", "raft-over-paxos", + "pre-vote-and-read-index", "lsm-over-btree", "atomic-flush", "seqno-recovery" diff --git a/apps/site/content/docs/decisions/pre-vote-and-read-index.mdx b/apps/site/content/docs/decisions/pre-vote-and-read-index.mdx new file mode 100644 index 0000000..c58d30c --- /dev/null +++ b/apps/site/content/docs/decisions/pre-vote-and-read-index.mdx @@ -0,0 +1,78 @@ +--- +title: Pre-vote and read-index +description: Why elections use a non-disruptive pre-vote round and reads use read-index instead of a log write — and a real safety bug the design caught before merge. +--- + +## Decision + +`RaftCore` runs a **pre-vote** round before any node becomes a real +candidate, and serves linearizable reads via **read-index** rather than +routing them through the log. Both are covered in depth in +[Election and Replication](/docs/consensus/election-and-replication) and +[Read-Index](/docs/consensus/read-index); this page is about *why*, and +about a correctness bug the pre-vote design surfaced in the RPC contract +itself. + +## Why pre-vote + +A partitioned node that can't reach the current leader keeps timing out and +incrementing its term, over and over, without ever actually winning an +election — it can't, because it can't reach a majority. The problem shows +up the moment it rejoins: its inflated term outranks the legitimate +leader's, and a plain Raft implementation forces that leader to step down +for no real reason, disrupting a cluster that was working fine. Pre-vote +asks peers "would you vote for me" without incrementing the asking node's +term or persisting anything on the responding side — only a *majority* +pre-vote grant lets the node proceed to a real, term-incrementing election. +A partitioned node's pre-votes simply never reach quorum, so it never +disrupts anything. + +## Why read-index over a log-routed read + +The alternative — append a no-op command, wait for it to commit, then read +— is correct but puts every read through a log append and an fsync. For a +project whose storage engine's whole point is a fast write path +([LSM over B-tree](/docs/decisions/lsm-over-btree)), taxing every read the +same way a write is taxed gives up a real advantage for no correctness +gain. Read-index confirms leadership via a heartbeat quorum round instead — +no log growth per read — while still proving the read reflects everything +committed before it began. + +## The bug pre-vote's own contract caught + +Adding pre-vote isn't free: a `RequestVote` response has to be +distinguishable as either "yes, I'd hypothetically vote for you" (a +pre-vote grant) or "yes, you have my vote this term" (a real grant) — +conflating the two is a latent election-safety violation. The original +"frozen" RPC contract was `RequestVoteResp { term, vote_granted }`, with no +field to tell those two cases apart. + +The failure mode: if a peer receiving a pre-vote request happens to already +be sitting at the candidate's prospective term (`T+1`) for unrelated +reasons, its grant is ambiguous by construction — a candidate counting that +response toward a *real* vote tally, when the peer only meant to grant a +*pre-vote*, could let two different nodes each believe they won an election +in the same term. That's a direct violation of election safety, the first +of the [four invariants](/docs/consensus/safety-and-simulation#the-four-invariants) +the whole test suite exists to prove — and term-overloading (trying to +infer pre-vote-vs-real from term arithmetic alone) turns out not to be +fixable; the ambiguity is structural, not a matter of picking cleverer +numbers. + +This was caught by a whole-branch adversarial review pass — not by any +single per-task unit test, since each task's own tests were internally +consistent with the contract as specified. The fix extends the contract +deliberately: `RequestVoteResp` gained a `pre_vote: bool` field, echoing the +request's own `pre_vote` flag back. A candidate now counts a response +toward its pre-vote tally only when `resp.pre_vote` is set, and toward its +real-vote tally only when it isn't — the ambiguity is gone by construction +instead of by convention. The hand-rolled TCP codec +([Transport](/docs/consensus/transport)) was updated for the new field +alongside the type change. + +The broader lesson kept from this: an RPC contract that looks "frozen" +because an earlier design phase settled it isn't actually immutable once a +later phase's behavior depends on distinguishing cases the original +contract couldn't express — extend it deliberately, with the same +adversarial scrutiny as any other consensus-critical change, rather than +working around the gap in caller logic. diff --git a/apps/site/content/docs/index.mdx b/apps/site/content/docs/index.mdx index 88fffda..d329d60 100644 --- a/apps/site/content/docs/index.mdx +++ b/apps/site/content/docs/index.mdx @@ -31,6 +31,14 @@ Cross-shard transactions are explicitly out of the guarantee set for the first cut — see [Roadmap](/docs/roadmap) and the [design decisions](/docs/decisions) for how that's fenced. +The storage engine and the Raft consensus core are both built and +independently proven — the engine against a `BTreeMap` reference model, the +consensus core against four safety invariants under a deterministic, +fault-injecting simulation (see [Raft Consensus](/docs/consensus)). The +guarantees above are what holds once they're wired into one running system +and chaos-tested end to end, which is the work still ahead — see +[Roadmap](/docs/roadmap) for exactly what's left. + ## Non-goals - Serving third-party or production traffic. This runs locally or on a @@ -46,6 +54,9 @@ first cut — see [Roadmap](/docs/roadmap) and the a write flows through it. - **[LSM Storage Engine](/docs/lsm-engine)** — internals of the Phase 1 engine: WAL, memtable, SSTables, bloom filters, compaction. +- **[Raft Consensus](/docs/consensus)** — the Phase 1 consensus core: log + store, transport, election and replication, read-index linearizable + reads, and the safety-invariant simulation that proves it. - **[Roadmap](/docs/roadmap)** — phased delivery plan with honest status. - **[Design Decisions](/docs/decisions)** — ADR-style writeups of the tradeoffs that shaped the system. diff --git a/apps/site/content/docs/meta.json b/apps/site/content/docs/meta.json index 3f772cb..b16606b 100644 --- a/apps/site/content/docs/meta.json +++ b/apps/site/content/docs/meta.json @@ -4,6 +4,7 @@ "index", "architecture", "lsm-engine", + "consensus", "roadmap", "decisions", "benchmarks" diff --git a/apps/site/content/docs/roadmap.mdx b/apps/site/content/docs/roadmap.mdx index ec20b71..be6b762 100644 --- a/apps/site/content/docs/roadmap.mdx +++ b/apps/site/content/docs/roadmap.mdx @@ -17,7 +17,7 @@ different points of completion right now. | # | Deliverable | Status | Finished-state demo | | --- | --- | --- | --- | | 1 | Custom LSM storage engine | **Shipped** | Durable, crash-recoverable local KV store — 32 tests passing | -| 2 | Raft consensus (single group) | **In design** | Design spec + log-store implementation plan drafted; log store is the next buildable unit | +| 2 | Raft consensus (single group) | **Core shipped** | Log store + transport + `RaftCore` merged; deterministic simulation proves 4 safety invariants under fault injection — 104 lib + 9 sim tests | | 3 | MVCC transactions | Planned | Snapshot-isolation transactions, checker-proven | | 4 | Multi-Raft (many groups) | Planned | Many key ranges, per-group consensus | | 5 | Shard router + control plane | Planned | Live sharded cluster, visualized, fault-tested | @@ -32,19 +32,46 @@ a `BTreeMap` reference model and explicit crash-recovery integration tests. See [LSM Storage Engine](/docs/lsm-engine) for internals and [Benchmarks](/docs/benchmarks) for real numbers. -## Phase 2 — Raft consensus (in design) - -Full single-group Raft in one cycle: leader election with pre-vote, log -replication, a commit index, linearizable reads via read-index, log -persistence, snapshotting/log compaction, and joint-consensus membership -changes. The design spec resolved the open forks — real TCP transport -behind a `Transport` trait (so a deterministic in-memory transport can drive -tests), and a **dedicated Raft log store** rather than reusing the LSM -engine (see [Raft-over-Paxos](/docs/decisions/raft-over-paxos) for why Raft -was chosen, and the log-store note in +## Phase 2 — Raft consensus (core shipped) + +`crates/raft` (`cairn-raft`) shipped the consensus core across three +cycles, built strictly bottom-up and each merged in turn: + +- **Log store** — durable, index-addressed log entries, CRC'd hard state, + and crash-tolerant replay with truncate-on-open. See + [Log Store](/docs/consensus/log-store). +- **Transport** — a `Transport` trait with a deterministic, seeded + in-memory implementation (fault injection: partition, drop, delay) and a + length-prefixed framed TCP implementation. See + [Transport](/docs/consensus/transport). +- **`RaftCore`** — a pure, synchronous, I/O-free step function: pre-vote + leader election, log replication with the consistency check and conflict + back-up, commit-index advancement under Raft's current-term rule, and + read-index linearizable reads. Proven by a deterministic multi-node + simulation against four safety invariants (election safety, log + matching, state-machine safety, leader completeness) under partition, + drop, reorder, and crash-restart fault injection — **104 library tests + plus 9 simulation tests, green.** See + [Safety Invariants & Simulation](/docs/consensus/safety-and-simulation). + +Building a dedicated log store rather than reusing the LSM engine, and real +TCP behind a `Transport` trait with a deterministic in-memory substrate for +tests, were both resolved early — see +[Raft-over-Paxos](/docs/decisions/raft-over-paxos) for why Raft was chosen, [LSM-over-B-tree](/docs/decisions/lsm-over-btree) for why the log isn't -just another LSM consumer). The first buildable unit — the log store itself -— has an implementation plan drafted and is the next thing to ship. +just another LSM consumer, and +[Pre-vote and read-index](/docs/decisions/pre-vote-and-read-index) for a +real cross-cutting safety bug the pre-vote design caught before merge. + +**Next, in order:** `InstallSnapshot` handling and log compaction, plus +joint-consensus membership changes (config is currently fixed at +construction); then a node driver — the async event loop wiring +`RaftCore` to the real transport, a real clock, and a disk-backed log-store +adapter, proven with a real-socket TCP integration test; then the +chaos/Jepsen harness, which drives a live cluster through the same fault +classes the simulation already covers and checks the resulting history with +a per-key linearizability checker (`lincheck`, already built alongside +`RaftCore`) instead of hand-asserted invariants. ## Phase 3 — MVCC transactions @@ -53,7 +80,9 @@ ordering for a timestamp/version source: `begin()`, `txn.get/put`, `txn.commit() → ok | conflict`. Garbage collection of obsolete versions folds into the existing compaction path. Cross-shard transactions are explicitly deferred past this phase — the initial guarantee set is -single-group. +single-group. Blocked on the rest of Phase 2 (node driver + real +integration), not on `RaftCore` itself — the commit-ordering primitive this +phase needs is already proven, just not yet wired to a running node. ## Phase 4 — Multi-Raft @@ -72,8 +101,13 @@ per-group leadership. Not a phase of its own — it's the thread that runs through Phases 2–4. Deterministic, seeded multi-node simulation catches most consensus bugs -before real sockets are involved; a Jepsen-style chaos harness then injects -partitions, drops, delays, reorders, crashes, and clock skew against a real -cluster and checks the resulting history for linearizability and snapshot -isolation violations. That checker output, committed as evidence, is the -actual deliverable — proving the guarantees, not asserting them. +before real sockets are involved, and that simulation is already running +against `RaftCore` — see +[Safety Invariants & Simulation](/docs/consensus/safety-and-simulation). A +Jepsen-style chaos harness will extend the same fault classes (partition, +drop, delay, reorder, crash) to a real cluster over real sockets and check +the resulting history for linearizability and snapshot isolation +violations. The per-key linearizability checker (`lincheck`) that harness +needs already exists; it isn't wired into a live-cluster harness yet. That +checker output, committed as evidence, is the actual deliverable — proving +the guarantees, not asserting them. From 1b8bfbbc64e645588a8c3b3a5eae9754aa7cec67 Mon Sep 17 00:00:00 2001 From: Mike Upton Date: Sun, 19 Jul 2026 01:13:58 +0000 Subject: [PATCH 2/2] feat(site): SEO metadata keyword refresh for consensus content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root metadata, per-page metadata (createPageMetadata), sitemap.ts, and robots.ts were already built out (title template, OG/Twitter cards via public/og.png, canonical URLs, JSON-LD SoftwareApplication schema, sitemap enumerating source.getPages() so new docs pages can't drift out of it). Audited that infrastructure and found it sound — no structural gaps to fix. Refresh siteConfig's description and keywords so they cover the consensus work now documented on the site: add "distributed key-value store", "linearizable reads"/"linearizability", and "LSM storage engine" alongside the existing Raft/Rust/MVCC/sharding terms, and mention linearizable reads explicitly in the root description. Co-Authored-By: Claude Opus 4.8 --- apps/site/src/lib/site-config.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/site/src/lib/site-config.ts b/apps/site/src/lib/site-config.ts index a044f5b..d9ca8b6 100644 --- a/apps/site/src/lib/site-config.ts +++ b/apps/site/src/lib/site-config.ts @@ -2,7 +2,7 @@ export const siteConfig = { name: "cairn", title: "cairn — Distributed KV Store in Rust", description: - "A distributed key-value store built from scratch in Rust with a custom LSM engine, Raft consensus, MVCC transactions, and a sharded control plane.", + "A distributed key-value store built from scratch in Rust: a custom LSM storage engine, Raft consensus with linearizable reads, MVCC transactions, and a sharded control plane.", shortDescription: "A from-scratch, sharded, Raft-replicated, LSM-backed distributed key-value store.", url: "https://cairn.uptonm.dev", @@ -13,12 +13,15 @@ export const siteConfig = { }, keywords: [ "distributed systems", - "key-value store", + "distributed key-value store", "database", "Rust", "Raft consensus", + "linearizable reads", + "linearizability", "LSM tree", "log-structured merge-tree", + "LSM storage engine", "MVCC", "sharding", "systems programming",