I Built a Full Raft Consensus Engine in Rust at 17 — Here's Every Gritty Detail
I'm 17. This is my first distributed systems project.
Raft is the consensus algorithm that powers etcd, Consul, TiKV, and a dozen other production systems. The paper makes it look straightforward — leader election, log replication, snapshots. But implementing it from scratch? Every paragraph in the paper expands into dozens of edge cases. Figure 8 alone (the "previous term entries can't be committed directly" rule) caused me three rewrites.
This implementation is 4,400+ lines of Rust across 7 crates, 52 tests, real gRPC networking, disk persistence with sled, log compaction via InstallSnapshot, and a chaos harness that kills nodes and partitions networks. No etcd code. No TiKV dependencies. Pure paper-to-production.
What This Actually Is
A complete, tested Raft implementation that you can run right now:
# In-process 3-node demo
cargo run --example kv-demo
# Or run a real cluster across 3 terminals
cargo build --release
# Terminal 1: cargo run --release -p raft-node -- --node-id 1 --peers 2@127.0.0.1:8081,3@127.0.0.1:8082 --port 8080
# Terminal 2: cargo run --release -p raft-node -- --node-id 2 --peers 1@127.0.0.1:8080,3@127.0.0.1:8082 --port 8081
# Terminal 3: cargo run --release -p raft-node -- --node-id 3 --peers 1@127.0.0.1:8080,2@127.0.0.1:8081 --port 8082
What works:
- Leader election with randomized timeouts (300-500ms) and proper log-up-to-date checks
- Log replication with Figure 8 compliance — previous-term entries commit indirectly only
- Fast backup optimization — followers return
conflict_term/conflict_index, leader skips entire terms in one RTT - Persistence before RPC replies (Figure 2 requirement) —
current_term,voted_for, log entries flushed to sled - Snapshotting with InstallSnapshot RPC — log compaction, boundary handling, 15 tests
- Chaos testing — network partitions, node kills, leader isolation, process-level chaos with 5 real OS processes
What's not done (honest gaps):
- Dynamic membership changes (joint consensus)
- ReadIndex/LeaseRead for linearizable reads
- WAN optimization / batching
- WAL-based persistence (sled works but isn't crash-consistent like a WAL)
Architecture — 7 Crates, Clean Boundaries
raft/
├── Cargo.toml
├── crates/
│ ├── raft-core/ # Pure logic — 40 unit tests
│ │ ├── types.rs
│ │ ├── state.rs
│ │ ├── election.rs
│ │ ├── replication.rs
│ │ └── snapshot.rs
│ ├── raft-rpc/ # gRPC transport (tonic)
│ ├── raft-storage/ # Persistence abstraction
│ │ ├── RaftStorage trait
│ │ ├── SledStorage
│ │ └── MemStorage
│ ├── raft-node/ # Standalone binary
│ └── raft-chaos/ # Chaos harness
└── examples/
├── kv-demo/
└── process-chaos/
Why this separation matters: The core logic has zero I/O. No network, no disk, no time. Pure functions. Unit tests are deterministic and fast. Storage backends are swappable without touching consensus logic. The chaos harness injects failures at the network layer — not the logic layer.
Leader Election — The Part Everyone Gets Wrong
The paper says: "If election timeout elapses without receiving AppendEntries, become candidate." Simple, right?
Wrong. Here's what actually happens in election.rs:
pub fn on_election_timeout(&mut self) -> Vec<NodeAction> {
self.become_candidate(); // term++, vote for self
let args = RequestVoteArgs {
term: self.current_term,
candidate_id: self.id,
last_log_index: self.last_log_index(),
last_log_term: self.last_log_term(),
};
self.peers.iter()
.map(|&peer| NodeAction::SendRequestVote(peer, args.clone()))
.collect()
}
But the vote granting logic is where bugs live (§5.4.1):
fn is_log_up_to_date(&self, candidate_last_term: u64, candidate_last_index: u64) -> bool {
let my_last_term = self.last_log_term();
let my_last_index = self.last_log_index();
if candidate_last_term != my_last_term {
candidate_last_term > my_last_term // Higher term wins
} else {
candidate_last_index >= my_last_index // Same term? Longer log wins
}
}
The subtlety that catches everyone: A candidate with term 3 and log length 1 beats a follower with term 2 and log length 100. Term always wins. This prevents the "stale leader with massive log" problem.
My test suite hammers this:
- 12 election tests covering grants, rejections, stale terms, split votes, term conversion
-
test_no_two_leaders_same_termspins up 5 nodes simultaneously — all become candidates, all vote for themselves, none win (majority = 3, each has 1 vote). Beautiful.
Log Replication — Where Figure 8 Bites You
Leader receives command → appends to local log → sends AppendEntries → majority replicates → commit → apply. Standard stuff.
Except Figure 8. The paper shows a scenario where a leader commits an entry from a previous term, then crashes. New leader doesn't have that entry. Data loss.
The rule: A leader can only directly commit entries from its current term. Previous-term entries commit indirectly when a current-term entry after them commits.
My implementation in replication.rs:
pub fn advance_commit_index(&mut self) {
if self.state != NodeState::Leader { return; }
let mut match_indices: Vec<u64> = self.match_index.values().copied().collect();
match_indices.push(self.last_log_index());
match_indices.sort_unstable_by(|a, b| b.cmp(a));
for &n in &match_indices {
if n > self.commit_index {
if let Some(entry) = self.get_entry(n) {
// FIGURE 8 RULE: only commit current-term entries directly
if entry.term == self.current_term {
let replicated = match_indices.iter().filter(|&&idx| idx >= n).count();
if self.is_majority(replicated) {
self.commit_index = n;
break;
}
}
}
}
}
}
The test that proves it works (test_advance_commit_index_figure8):
// Leader has: index 1 (term 1), index 2-3 (term 2)
// Followers replicated up to index 1 (term 1 entry)
leader.match_index.insert(2, 1);
leader.match_index.insert(3, 1);
leader.advance_commit_index();
// commit_index stays 0 — term 1 entry NOT committed directly
// Now followers replicate index 2 (term 2)
leader.match_index.insert(2, 2);
leader.match_index.insert(3, 2);
leader.advance_commit_index();
// commit_index = 2 — term 2 entry commits, drags term 1 along indirectly
This test alone caught 3 bugs in my early implementation. Write the Figure 8 test first. Always.
Fast Backup — The Optimization That Makes Raft Practical
Without fast backup, a follower 10,000 entries behind takes 10,000 round-trips to catch up. The paper's appendix describes the fix: followers return conflict_term and conflict_index on rejection. Leader jumps next_index past the entire conflicting term.
My implementation:
pub fn handle_append_entries_reply(
&mut self,
peer: NodeId,
reply: AppendEntriesReply,
) -> Option<NodeAction> {
if reply.success {
// Update match_index, next_index normally
...
} else {
// FAST BACKUP
if let Some(conflict_term) = reply.conflict_term {
let mut new_next = reply.conflict_index.unwrap_or(1);
for entry in self.log.iter().rev() {
if entry.term == conflict_term {
new_next = entry.index + 1; // Jump PAST the term
break;
}
}
self.next_index.insert(peer, new_next.max(1));
} else if let Some(conflict_index) = reply.conflict_index {
self.next_index.insert(peer, conflict_index.max(1));
} else {
// Fallback: decrement by 1 (slow path)
let current = self.next_index.get(&peer).copied().unwrap_or(1);
self.next_index.insert(peer, (current - 1).max(1));
}
...
}
}
Test proof (test_fast_backup_convergence):
- Leader has terms: [1, 1, 3, 5] at indices [1, 2, 3, 4]
- Follower rejects at index 4, says "I have term 1 at index 4, first index of term 1 is 1"
- Leader jumps
next_indexfrom 5 → 3 (skips all of term 1 in one round-trip)
Real-world impact: A node 1M entries behind catches up in ~log(terms) round-trips, not O(entries). This is the difference between "toy" and "production."
Persistence — The "Before Reply" Rule
Figure 2 is explicit: persist before responding. My grpc_server.rs enforces this:
async fn request_vote(&self, request: Request<ProtoRequestVoteArgs>) -> ... {
let core_args = proto_to_core_request_vote_args(args);
let mut node = self.state.node.write().await;
let reply = node.handle_request_vote(core_args);
// PERSIST BEFORE REPLY — Figure 2 requirement
let _ = self.state.storage.save_current_term(node.current_term);
let _ = self.state.storage.save_voted_for(node.voted_for);
Ok(Response::new(core_request_vote_reply_to_proto(reply)))
}
async fn append_entries(&self, request: Request<ProtoAppendEntriesArgs>) -> ... {
let core_args = proto_to_core_append_entries_args(args);
let mut node = self.state.node.write().await;
let reply = node.handle_append_entries(core_args);
// Persist log entries BEFORE responding
if reply.success {
for entry in &node.log {
if entry.index >= core_args.prev_log_index {
let _ = self.state.storage.save_log_entry(entry);
}
}
}
let _ = self.state.storage.save_current_term(node.current_term);
Ok(Response::new(core_append_entries_reply_to_proto(reply)))
}
SledStorage uses bincode serialization with a 64MB cache. Each write flushes to disk. On restart, the node loads current_term, voted_for, and the full log — resumes exactly where it left off.
Crash recovery test: Kill a node mid-replication, restart it, verify it has the same committed state. Works.
Snapshotting — Log Compaction Done Right
Logs grow forever. Snapshots fix this. Figure 12 + Figure 7.
Leader side (state.rs):
pub fn snapshot(&mut self, include_index: u64) -> Option<(Vec<u8>, u64, u64)> {
// Validations: must be committed, applied, past last snapshot
if include_index <= self.last_included_index
|| include_index > self.commit_index
|| include_index > self.last_applied {
return None;
}
// Collect all applied commands up to include_index
let mut data = Vec::new();
for entry in &self.log {
if entry.index <= include_index {
data.extend_from_slice(&entry.command);
data.push(b'\n');
}
}
let last_included_term = self.get_entry(include_index)
.map_or(self.last_included_term, |e| e.term);
// Discard compacted entries
self.log.retain(|e| e.index > include_index);
self.last_included_index = include_index;
self.last_included_term = last_included_term;
if self.last_applied < include_index {
self.last_applied = include_index;
}
Some((data, include_index, last_included_term))
}
Follower side (handle_install_snapshot):
pub fn handle_install_snapshot(&mut self, args: InstallSnapshotArgs) -> InstallSnapshotReply {
// Rule 1: Stale term → reject
if args.term < self.current_term { ... }
// Rule 2: Higher term → become follower
if args.term > self.current_term { self.become_follower(args.term); }
// Rule 3: Snapshot behind our snapshot → reject (stale)
if args.last_included_index <= self.last_included_index { ... }
// Rule 4: Install it
let existing_term = self.get_entry(args.last_included_index).map(|e| e.term);
// If boundary matches, keep entries after; else discard ALL log
if existing_term == Some(args.last_included_term) {
self.log.retain(|e| e.index > args.last_included_index);
} else {
self.log.clear();
}
self.last_included_index = args.last_included_index;
self.last_included_term = args.last_included_term;
// Advance commit/applied past snapshot
if self.last_applied < args.last_included_index {
self.last_applied = args.last_included_index;
}
if self.commit_index < args.last_included_index {
self.commit_index = args.last_included_index;
}
InstallSnapshotReply {
term: self.current_term,
reset_election_timer: true,
snapshot_data: Some(args.data),
last_included_index: Some(args.last_included_index),
}
}
15 snapshot tests cover: basic compaction, stale rejection, uncommitted rejection, unapplied rejection, boundary preservation, empty log after compact, InstallSnapshot basic, stale term/index rejection, log discard, matching boundary kept, mismatched boundary discarded, term_at_index with snapshot, apply after snapshot, multiple snapshots.
Chaos Testing — Breaking It On Purpose
Unit tests verify correctness. Chaos tests verify resilience.
raft-chaos spins up real nodes (in-process or real processes), then:
-
Partitions networks bidirectionally (
HashSet<(NodeId, NodeId)>) - Kills nodes (clears peers, sets to follower)
- Heals partitions
- Verifies safety (no split-brain) and liveness (new leader elected)
The 5 chaos tests:
| Test | What It Verifies |
|---|---|
test_chaos_leader_election |
3-node cluster elects leader within 5s |
test_chaos_command_submission |
Commands replicate to all nodes' state machines |
test_chaos_node_survives_majority |
Kill leader → new election in majority partition |
test_chaos_partition_minority_cannot_commit |
2-node minority partitioned → no commits, majority continues |
test_chaos_partition_leader_isolated |
Isolate leader → majority elects new leader → heal → old leader steps down |
Process chaos (examples/process-chaos): Spawns 5 real OS processes via cargo run --release -p raft-node, kills the leader process, verifies new election. This catches bugs the in-process harness misses (file descriptor limits, port binding races, sled locking).
The KV Demo — See It Run
cargo run --example kv-demo
Output:
=== 3-node Raft cluster started ===
Nodes on ports [9001, 9002, 9003]
Waiting for leader election...
Leader elected: node 2
Submitted: "name Alice" -> index=1, term=1
Submitted: "name Bob" -> index=2, term=1
Submitted: "lang Rust" -> index=3, term=1
Submitted: "lang Python" -> index=4, term=1
=== Cluster state ===
Node 1: state=Follower, term=1, log_len=4, commit=4, applied=4, store={"name": "Bob", "lang": "Python"}
Node 2: state=Leader, term=1, log_len=4, commit=4, applied=4, store={"name": "Bob", "lang": Python"}
Node 3: state=Follower, term=1, log_len=4, commit=4, applied=4, store={"name": "Bob", "lang": "Python"}
All nodes consistent: true
Real consensus. Real replication. Real state machines.
Spec Reference
Built from Ongaro & Ousterhout's "In Search of an Understandable Consensus Algorithm" (2014) — the Raft paper. Every field name, every rule, follows Figure 2 directly.
Test plan follows MIT 6.5840 (Distributed Systems) Raft lab structure — each stage isolates a bug class:
- Stage A: Leader election
- Stage B: Log replication
- Stage C: Persistence + crash recovery
- Stage D: Log compaction / snapshotting
- Stage E: Chaos testing
Links
GitHub: https://github.com/subhanshh/raft-rs
Email: me@subhansh.dev
Top comments (0)