DEV Community

Tural Muradov
Tural Muradov

Posted on

Understanding Raft by Building a Minimal Consensus Cluster

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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,
    })
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

The leader maintains two indices per follower:

  • next_index is the next log position the leader will send.
  • match_index is 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"))
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
    })
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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_index and prev_log_term let 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_index backward 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)