Distributed consensus becomes easier to reason about when the implementation is reduced to its essential state transitions.
This article builds a minimal three-node Raft cluster in a single process. There are no sockets, threads, or wall-clock dependencies. Nodes exchange messages through a simulated network, and a virtual clock controls delivery latency and election timeouts.
The implementation is intentionally small, but it captures the mechanisms at the center of Raft:
- leader election
- log replication
- commitment
- leader failover
Production implementations also need persistent storage, snapshots, membership changes, network transport, and careful recovery logic. Those concerns matter, but they are easier to understand once the core protocol is visible.
The Goal of Consensus
A replicated service needs its nodes to agree on one ordered sequence of operations. A key-value store, for example, might represent each write as a command in a replicated log. If every node applies the same committed commands in the same order, each state machine reaches the same result.
Raft organizes this problem around a strong leader:
- One node acts as leader for a given term.
- Clients send writes to the leader.
- The leader appends those writes to its log and replicates them to followers.
- An entry becomes committed only after the protocol establishes that it is safe.
Two mechanisms make that model work: leader election determines which node may coordinate writes, and log replication brings the other nodes into agreement with the leader's history.
Terms: Raft's Logical Clock
Every node tracks a monotonically increasing current_term. A term identifies a period in which an election occurs and, if the election succeeds, one leader coordinates the cluster.
Terms are logical rather than wall-clock time. A node that receives a message containing a term greater than its own updates current_term and becomes a follower. Messages from older terms cannot establish authority over a node in a newer term.
The minimal node state looks like this:
FOLLOWER, CANDIDATE, LEADER = "follower", "candidate", "leader"
class Node:
def __init__(self, node_id, peers):
self.id = node_id
self.peers = peers
self.state = FOLLOWER
self.current_term = 0
self.voted_for = None
self.log = [] # list of (term, command)
self.commit_index = -1
self.next_index = {}
self.match_index = {}
self.votes_received = set()
self.election_deadline = self.reset_election_deadline()
self.leader_id = None
def reset_election_deadline(self):
return random.uniform(150, 300) # ms
The randomized election timeout is essential to liveness. If every follower timed out at the same interval, multiple nodes could repeatedly become candidates together and split the vote. Randomization makes it likely that one candidate starts first and gathers a majority before competing elections begin.
In a real implementation, current_term, voted_for, and the log must be persisted before the node sends responses that depend on them. The in-memory simulation omits storage so the protocol remains easy to inspect.
Leader Election
A follower starts an election when it receives no valid leader communication before its election deadline. It increments its term, transitions to candidate, votes for itself, and requests votes from its peers:
def become_candidate(self, now):
self.state = CANDIDATE
self.current_term += 1
self.voted_for = self.id
self.votes_received = {self.id}
self.election_deadline = now + self.reset_election_deadline()
return [("RequestVote", self.id, peer, {
"term": self.current_term,
"candidate_id": self.id,
"last_log_index": len(self.log) - 1,
"last_log_term": self.log[-1][0] if self.log else -1,
}) for peer in self.peers]
A node grants at most one vote per term. It also checks whether the candidate's log is at least as up to date as its own, comparing the last entry's term first and its index second:
def handle_request_vote(self, sender, msg):
grant = False
if msg["term"] > self.current_term:
self.current_term = msg["term"]
self.state = FOLLOWER
self.voted_for = None
log_ok = (msg["last_log_term"], msg["last_log_index"]) >= (
(self.log[-1][0] if self.log else -1), len(self.log) - 1
)
if (
msg["term"] == self.current_term
and self.voted_for in (None, msg["candidate_id"])
and log_ok
):
grant = True
self.voted_for = msg["candidate_id"]
return ("RequestVoteResponse", self.id, sender, {
"term": self.current_term,
"vote_granted": grant,
})
The log comparison is a safety constraint, not an optimization. A candidate with a log older than a voter's log cannot receive that vote. Combined with majority voting, this prevents a candidate that is missing a committed entry from becoming leader.
The complete protocol also resets the election timer when a vote is granted and steps down whenever a higher term appears in any RPC or response. Those transitions should be centralized in production code; the excerpts here focus on the decision itself.
Becoming the Leader
A candidate becomes leader after receiving votes from a majority of the cluster. In a three-node cluster, that requires two votes, including the candidate's own.
The new leader initializes replication state for every follower and immediately sends AppendEntries RPCs. With no log entries attached, these messages act as heartbeats:
def become_leader(self, now):
self.state = LEADER
self.leader_id = self.id
for p in self.peers:
self.next_index[p] = len(self.log)
self.match_index[p] = -1
return self.make_heartbeats(now)
The leader maintains two indices per follower:
-
next_indexis the next log position the leader will send. -
match_indexis the highest position known to match the leader's log.
next_index begins optimistically at the end of the leader's log. If a follower rejects an append because its preceding entry does not match, the leader moves that index backward and retries.
Log Replication
When a client submits a command, the leader first appends it to its own log:
leader.log.append((leader.current_term, "SET x=1"))
The entry is not committed at this point. It exists only on the leader in this simulation and must still be replicated.
AppendEntries serves both as the heartbeat mechanism and the replication RPC. For each follower, the leader sends any entries beginning at next_index, together with the index and term immediately before them:
def make_heartbeats(self, now):
msgs = []
for p in self.peers:
prev_index = self.next_index[p] - 1
prev_term = self.log[prev_index][0] if prev_index >= 0 else -1
entries = self.log[self.next_index[p]:]
msgs.append(("AppendEntries", self.id, p, {
"term": self.current_term,
"leader_id": self.id,
"prev_log_index": prev_index,
"prev_log_term": prev_term,
"entries": entries,
"leader_commit": self.commit_index,
}))
return msgs
The follower accepts the entries only if its log contains a matching term at prev_log_index, or if the leader is appending from the beginning of the log. That check establishes a common prefix.
def handle_append_entries(self, sender, msg):
success = False
if msg["term"] >= self.current_term:
self.current_term = msg["term"]
self.state = FOLLOWER
self.leader_id = msg["leader_id"]
self.election_deadline = now + self.reset_election_deadline()
prev_ok = (msg["prev_log_index"] == -1) or (
msg["prev_log_index"] < len(self.log)
and self.log[msg["prev_log_index"]][0] == msg["prev_log_term"]
)
if prev_ok:
success = True
self.log = (
self.log[:msg["prev_log_index"] + 1]
+ msg["entries"]
)
if msg["leader_commit"] > self.commit_index:
self.commit_index = min(
msg["leader_commit"], len(self.log) - 1
)
return ("AppendEntriesResponse", self.id, sender, {
"term": self.current_term,
"success": success,
"match_index": len(self.log) - 1,
})
Here, now is supplied by the simulation's virtual clock. Resetting the deadline after a valid AppendEntries prevents followers from starting an election while they are receiving communication from the current leader.
If the prefix does not match, the follower rejects the request. The leader then decrements next_index for that follower and retries from an earlier position. Once it finds a matching prefix, the follower removes conflicting entries after that point and appends the leader's entries.
This is the operational form of Raft's Log Matching Property: if two logs contain an entry with the same index and term, the entries before it are identical.
When Is a Log Entry Committed?
An entry is not committed merely because it appears in a log. In production Raft, commitment implies that the entry has been durably replicated in a way that allows it to survive leader replacement. The simulation models the replication rule but uses volatile memory rather than durable storage.
The leader uses each follower's match_index to find the highest entry replicated on a majority. It may advance commit_index through this counting rule only for an entry from its current term:
def handle_append_entries_response(self, sender, msg):
if self.state != LEADER:
return
if msg["term"] > self.current_term:
self.current_term = msg["term"]
self.state = FOLLOWER
self.voted_for = None
return
if msg["term"] != self.current_term:
return
if msg["success"]:
self.match_index[sender] = msg["match_index"]
self.next_index[sender] = msg["match_index"] + 1
for n in range(len(self.log) - 1, self.commit_index, -1):
replicas = 1 + sum(
1 for p in self.peers if self.match_index[p] >= n
)
cluster_size = len(self.peers) + 1
if (
replicas > cluster_size // 2
and self.log[n][0] == self.current_term
):
self.commit_index = n
break
else:
self.next_index[sender] = max(0, self.next_index[sender] - 1)
The current-term condition is one of Raft's less obvious safety rules. A leader does not directly commit an entry from an earlier term merely by observing that it now exists on a majority. Once an entry from the current term is committed, all preceding entries become committed with it.
After advancing commit_index, the leader includes the new value in subsequent AppendEntries messages. Followers then advance their own commit indices to the smaller of leader_commit and the last entry they hold. Each node can apply newly committed entries to its state machine in order.
A client should receive success only after the corresponding entry is committed and applied according to the service's response policy. A leader crash before that point may leave an uncommitted entry in some logs; a later leader may preserve or overwrite it. A reply sent earlier would therefore be unsafe.
Running the Simulation
The simulated network can be represented by messages containing a delivery time, sender, recipient, type, and payload. The main loop advances a virtual clock, delivers due messages, triggers election timeouts, and schedules periodic heartbeats.
A successful run produces a trace similar to this:
t=161 A starts election for term 1
t=181 A became leader for term 1
t=202 client command appended to A's log at index 0
t=310 command committed on all nodes: {'A': 0, 'B': 0, 'C': 0}
final state:
A: state=leader term=1 log=[(1, 'SET x=1')] commit_index=0
B: state=follower term=1 log=[(1, 'SET x=1')] commit_index=0
C: state=follower term=1 log=[(1, 'SET x=1')] commit_index=0
The timestamps are properties of this simulation, not protocol guarantees. They make the message sequence observable: election, leadership, replication, majority acknowledgement, and propagation of the commit index.
Stopping the leader introduces the next important path. After the followers stop receiving heartbeats, one election deadline expires, a new term begins, and a candidate requests votes. Raft's voting restriction ensures that a node missing a committed entry cannot win the election. The new leader can then reconcile follower logs through the same AppendEntries consistency check.
What the Minimal Model Demonstrates
The simulation exposes several protocol properties that can be obscured by a production implementation:
- Randomized election timeouts support progress by reducing repeated split votes.
- Terms invalidate stale authority without relying on synchronized clocks.
- Majority voting and the up-to-date-log check protect committed history during elections.
-
prev_log_indexandprev_log_termlet the leader discover a common prefix and repair divergent logs. - Followers learn the commit boundary from the leader; they do not independently commit entries by replica counting.
- Entries from older terms become committed indirectly when a current-term entry is safely committed after them.
These mechanisms are closely connected. Election safety depends on log freshness, log repair depends on a legitimate leader, and client-visible durability depends on commitment surviving the next election.
Using Raft in Real-World Systems
The prototype models protocol decisions, not a complete distributed system. A production implementation must add several layers around the same state machine:
- Durable state: persist the current term, vote, and log before acknowledging dependent RPCs.
- State-machine application: apply committed entries exactly once and in log order.
- Snapshots and compaction: bound storage and transfer a compact state image to followers that fall far behind.
-
Efficient recovery: use conflict metadata to move
next_indexbackward by terms rather than one entry per retry. - Membership changes: use a safe reconfiguration protocol, such as joint consensus, so two disjoint majorities cannot form.
- Client semantics: handle retries, deduplication, leader redirection, and responses whose delivery is uncertain after a crash.
- Operational safeguards: add pre-vote, leadership transfer, observability, backpressure, and limits on replication batches where appropriate.
Failure injection is the most useful next step for the simulation. Delay selected messages, partition one node, crash a leader at each point in the replication sequence, and restart nodes with persisted state. Then assert the central safety property: no two nodes ever apply different commands at the same log index.
The Raft paper provides the complete safety argument and the edge cases deliberately omitted here. With the election and replication paths reduced to executable state transitions, its terminology maps directly onto observable behavior.
Conclusion
Raft is built from a small set of rules that reinforce one another. Terms establish authority. Elections restrict leadership to candidates with sufficiently up-to-date logs. AppendEntries repairs divergence around a verified prefix. Majority replication and the current-term rule establish a safe commit boundary.
The minimal cluster does not replace the formal specification or the engineering required for a production implementation. It provides a compact model for reasoning about both. Once the message flow is explicit, failures such as a leader crash after replication but before commitment are no longer special cases; they are ordinary state transitions governed by the same invariants.
Top comments (0)