cld-toys › Toys › raft-toy

Commentary: raft-toy

An entry sits on three of five servers — a real majority — and the leader is forbidden to call it committed, because it was created two terms ago. Two steps later it exists on no server at all. Figure 8 of the Raft paper, staged deliberately. A study guide for raft.py.

raft-toy/ on GitHub·the source with line numbers, for reading rather than downloading
How to read this This is the only documentation the toy has — read it with raft.py open beside you. raft.py is the toy itself (216 lines, of which 152 are code — two classes and a driver); demo.py runs the schedule twice, once per commit rule, then the boundary case; test_raft.py locks it down with 13 tests. Every transcript below was captured from a real run on macOS 26.5.2 (Darwin 25.5.0, arm64 Apple Silicon), Python 3.15.0a8 (main, Apr 14 2026, 14:20:41) [Clang 22.1.3], stdlib only.
cd raft-toy
python3 demo.py      # the aha (§6)
python3 test_raft.py # pins every number this page claims
Contents
  1. Orientation
  2. The problem this mechanism exists to solve
  3. Background you need
  4. The mental model
  5. Reading the source
  6. The demo, and what it proves
  7. Design decisions and roads not taken
  8. What's simplified vs. the real thing
  9. Check yourself
  10. Further reading

1. Orientation

This toy is five simulated servers running a stripped-down Raft: leader election, log replication, and the commit rule. There are no threads, no sockets, no clock and no random number generator. Messages accumulate in a Python list, and a driver decides which ones get delivered and which ones are thrown away.

That is the whole trick. A run is a pure function of the schedule, so the schedule can be written down — and this toy is about one specific schedule, the one drawn as Figure 8 of the Raft paper.

You will see the same five-phase schedule executed twice. The first time, the cluster uses the commit rule almost everybody invents when they first think about replication: an entry is committed once a majority of servers store it. The second time it uses the rule Raft actually specifies. Nothing else differs — figure8() takes the rule as an argument and changes not one other line.

The first run reports an entry committed. Two steps later, that entry does not exist on any of the five servers.

By the end you should be able to:


2. The problem this mechanism exists to solve

A replicated log has one job: every server applies the same commands in the same order, and once a client is told "done," it stays done. The second half is the hard part, and it is the only part this toy is about.

"Done" has to mean something specific, because the leader is a single machine that can die at any instant. The obvious definition is a quorum: once a majority of servers have the entry on disk, any future majority must overlap this one, so at least one server in any future election has the entry. That overlap argument is correct, and it is why quorums are everywhere.

The trap is that overlap guarantees the entry survives somewhere; it does not guarantee the next leader keeps it. Those are different claims, and the gap between them is where Figure 8 lives. A future leader is chosen by an election, and the election rule compares logs by their last entry's term, not by whether they contain some particular older entry. A server can hold your majority-replicated entry and still vote for a candidate that does not have it, because that candidate's log looks newer.

So there are two competing goals:

Raft resolves this by making the cheap test legal only for entries the current leader created itself. Everything older gets committed indirectly, carried along behind a current-term entry. That is more conservative than strictly necessary — the paper says so outright, noting there are situations where a leader could safely conclude an older entry is committed (for example if it is stored on every server) — and Raft declines the extra cases for simplicity.

Other designs make other choices. Multi-Paxos attaches a ballot number to each slot independently, so it does not inherit this problem in the same shape; Viewstamped Replication runs an explicit view-change protocol that reconciles logs before the new primary accepts anything. Raft's answer is cheaper to implement and, as this toy shows, has exactly one subtle consequence you have to know about.


3. Background you need

ConceptWhere it is used in the toyOne link
Term — a monotonically increasing integer, at most one leader per term. Raft's only clock.Node.term, step_down (raft.py line 58). Every message carries one.Raft paper §5.1
Log entry carries its creating leader's termEntry (raft.py line 16). Without this field the commit rule cannot be written at all.Raft paper §5.3
Quorum / majority overlapadvance_commit (raft.py line 103): stored * 2 <= len(self.peers) + 1.Quorum (distributed computing)
Election restriction (§5.4.1) — vote only for a log at least as up to date as yours, compared by (last term, last index)on_vote (raft.py line 122). This is what lets S5 win in step (d).Raft paper §5.4.1
Commit rule (§5.4.2) ⭐ — never commit an entry from a previous term by counting replicasadvance_commit (raft.py line 105). The whole toy.Raft paper §5.4.2
Log Matching Property — same index + same term ⇒ identical logs up to that pointsend_append (raft.py line 90) sends prev_index/prev_term; on_append checks it. It is why "prior entries are committed indirectly" is sound.Raft paper §5.3
Persistent vs. volatile stateNode.__init__ (raft.py lines 37–48) and Cluster.crash (raft.py line 200). commit_index is volatile, which has consequences (§6.4).Raft paper Figure 2

The two starred rows carry the result. Everything else is scaffolding you need in order to stage the situation where they matter.


4. The mental model

Five servers, one log each, growing left to right. Each cell is command@term. The schedule walks the cluster through five states:

index: 1 2 what a leader may conclude ------------------------------------------------------------ (0) all agree S1 [L] term 1 a@1 S2..S5 a@1 a@1 is from term 1, and S1 is in term 1 -> commit index 1 (a) S1 in term 2 appends b, only S2 hears it S1 [L] term 2 a@1 b@2 S2 a@1 b@2 2 of 5. not a majority. S3 S4 S5 a@1 nothing new commits (b) S1 crashes. S5 wins term 3 on S3+S4's votes and appends c S1 [x] a@1 b@2 (dead, log intact) S2 S3 S4 a@1 S5 [L] term 3 a@1 c@3 <- a DIFFERENT entry at index 2 (c) S5 crashes. S1 returns, wins term 4, pushes b@2 to S3 S1 [L] term 4 a@1 b@2 \ S2 a@1 b@2 >- 3 of 5 = MAJORITY S3 a@1 b@2 / S4 a@1 naive: "index 2 is committed" S5 [x] a@1 c@3 raft: refuses. b@2 is term 2, my term is 4 (d) S1 crashes. S5 returns and wins term 5 -- its last entry is term 3, the others' is term 2, so S5's log is "more up to date" S2 S3 S4 a@1 c@3 <- b@2 truncated away S5 [L] term 5 a@1 c@3 (e) S1 rejoins and is truncated too S1..S5 a@1 c@3 b@2 exists nowhere

The shape to remember: index 2 is contested. Two different leaders, in two different terms, each put a different command in that slot. A majority holding one of them is not the same as that one having won.

The fix, in one picture:

leader in term 4, log = [ a@1 , b@2 ] naive: is b@2 on a majority? yes -> commit index 2 raft: is b@2 on a majority? yes is b@2 from term 4? NO -> skip ...nothing left to check -> commit nothing leader in term 4, log = [ a@1 , b@2 , d@4 ] <- one more write raft: is d@4 on a majority? yes is d@4 from term 4? yes -> commit index 3 and 1 and 2 ride along

5. Reading the source

In reading order. Every excerpt is captioned with its provenance so it cannot drift from the file.

5.1 Entry — why the term is stored per record

raft.py · lines 16–20
class Entry:
    """A log record. Storing the creating leader's `term` is what makes the
    commit rule expressible at all."""

    __slots__ = ("term", "cmd")

This looks like bookkeeping and is not. An entry's term is set once, by the leader that created it, and it is never rewritten — not when a later leader replicates it, not when it is finally committed. That immutability is the whole reason advance_commit can ask "is this entry mine?" three terms later.

If entries were stamped with the term of whoever last replicated them, b@2 would become b@4 the moment S1 pushed it out in step (c), the commit rule would see a match, and Raft would have exactly the bug it is designed to avoid. The paper flags this explicitly: log entries retain their original term numbers, and the extra complexity in the commitment rules is the price.

5.2 on_vote — the election restriction

raft.py · lines 117–125
def on_vote(self, m):
    """The election restriction: never vote for a candidate whose log is
    behind yours, by (term, index) -- a longer log with an older final term
    loses to a shorter one with a newer term."""
    idx, lt = self.last()
    up_to_date = (m["last_term"], m["last_index"]) >= (lt, idx)
    granted = (m["term"] == self.term
               and self.voted_for in (None, m["src"])
               and up_to_date)

Three separate conditions, and the tuple comparison is doing more than it looks. Python compares (last_term, last_index) lexicographically, so term dominates index: a candidate with a shorter log wins the comparison if its final entry is from a newer term.

That is not a quirk of the encoding, it is the rule from §5.4.1, and it is precisely what lets S5 — whose log is [a@1, c@3], two entries — beat S2 and S3, whose logs are [a@1, b@2], also two entries. Term 3 beats term 2. S5 wins and truncates.

The >= rather than > is load-bearing in the most literal way. Change it and the cluster cannot hold a single election:

=== VARIANT: on_vote uses `>` instead of `>=` (rule=raft) === elect(1) returns: None role: candidate term burned to: 4 votes S1 collected: [1] (itself only; all logs are empty and equal) with `>=`: elect(1) returns: 1 role: leader

With >, a candidate must be strictly newer than every voter — but at the start of time all logs are empty and equal, so nobody ever grants a vote, S1 burns all four attempts, and the demo dies with a KeyError on the first client write. With >=, the same call returns term 1 and the role leader.

5.3 send_append — the consistency check

raft.py · lines 90–96
def send_append(self, p):
    """Carries the (index, term) of the entry *before* the ones it sends;
    a follower matching there matches everywhere earlier."""
    ni = self.next_index[p]
    self.send(p, type="append", term=self.term, prev_index=ni - 1,
              prev_term=self.log[ni - 2].term if ni > 1 else 0,
              entries=self.log[ni - 1:], commit=self.commit_index)

Every AppendEntries carries the index and term of the entry before the ones it is sending. The follower accepts only if it has an entry at that index with that term. That single check is the Log Matching Property: if two logs agree on (index, term) at one position, they are identical at every earlier position.

This is what makes "prior entries are committed indirectly" a theorem rather than a hope. When step (c′) commits index 3, it is not separately verifying that indexes 1 and 2 are replicated — it is relying on the fact that a follower which accepted index 3 must match everywhere below it.

5.4 advance_commit — the whole toy

raft.py · lines 98–108
def advance_commit(self):
    """THE commit rule. Walk down for the highest index a majority stores;
    under RAFT, skip any index whose entry came from an earlier term."""
    for n in range(len(self.log), self.commit_index, -1):
        stored = 1 + sum(1 for p in self.peers if self.match_index[p] >= n)
        if stored * 2 <= len(self.peers) + 1:
            continue
        if self.rule == RAFT and self.log[n - 1].term != self.term:
            continue                      # <-- Figure 8 lives on this line
        self.commit_index = n             # everything below n rides along
        return

Four judgement calls in nine lines. Three of them turn out to matter, and one does not — all four were checked by writing the variant and running it against the same schedule (§7.4).

The term clause is the mechanism. Delete it — which is exactly what rule == NAIVE does — and the demo's headline flips from "refused" to "committed, then erased."

The return is not an optimisation, despite looking like one. The loop counts down from the newest entry, so the first index that qualifies is the highest one, and returning there is correct. Remove the return and the loop keeps going to lower indexes, each assignment overwriting the last, so commit_index ends up at the lowest qualifying index instead of the highest. Run under the naive rule, that variant reports claimed b@2 committed: False against a baseline of True — it silently un-does the very thing the demo is built to show.

stored counts the leader itself via the leading 1 +. The leader has the entry by construction (it appended it), and match_index only tracks peers. Forget the 1 + and a 5-node cluster needs 3 followers, i.e. 4 of 5, which is a quorum of a 7-node cluster.

The <= is the one that does not matter here, and it is worth knowing why. stored * 2 <= len(self.peers) + 1 rejects; with 5 nodes len(peers) + 1 is 5, so it commits at stored in {3, 4, 5}. Changing it to < gives {3, 4, 5} as well — identical. The two differ only at even cluster sizes: with 4 nodes, <= commits at {3, 4} and < commits at {2, 3, 4}, and 2 of 4 is not a majority. The line is a real off-by-one hazard that this schedule cannot expose, which is a good argument for odd cluster sizes and a better argument for not trusting a line because one test passed.

5.5 on_append — the line that erases a commit

raft.py · lines 138–147
def on_append(self, m):
    ok = False
    if m["term"] >= self.term:
        self.role = "follower"
        pi, pt = m["prev_index"], m["prev_term"]
        ok = pi <= len(self.log) and (pi == 0 or self.log[pi - 1].term == pt)
    if ok:
        # The leader's log wins, unconditionally. This truncation is the
        # line that erases a "committed" entry in the demo.
        self.log = self.log[:pi] + list(m["entries"])

self.log[:pi] + list(m["entries"]) is an assignment, not an append. A follower does not merge, negotiate or object; whatever the leader sends replaces everything from pi onward. In step (d) that one expression deletes b@2 from S2, S3 and S4, and in step (e) from S1.

Why the rule has to be conservative Nothing here consults commit_index. The follower has no idea whether the entry it is about to discard was ever declared committed, and it could not act on that knowledge if it had it. There is no defence downstream — which is why all the care has to live on the leader's side, in advance_commit.

5.6 Cluster.deliver — determinism, and the partition model

raft.py · lines 177–187
def deliver(self, only=None, rounds=12):
    """Run until quiet. Anything addressed outside `only` is LOST, not
    queued -- the partition model, and what lets a schedule say "this
    entry reaches exactly S2"."""
    for _ in range(rounds):
        batch, self.net[:] = list(self.net), []
        if not batch:
            break
        for m in batch:
            if self.nodes[m["dst"]].alive and (only is None or m["dst"] in only):
                self.nodes[m["dst"]].recv(m)

Two decisions here.

Messages outside only are dropped, not queued. They are gone. That is what lets the schedule say "this entry reaches exactly S2 and nobody else" in one argument, and it is a faithful model of a network partition that heals after the interesting moment has passed. A queue would be a delay model, which is a different thing and cannot produce step (a).

The loop runs until the network goes quiet. One deliver() call is therefore "let the cluster settle," not "advance one tick" — which is what makes next_index back-off (a reject, a decrement, a resend, an accept) complete inside a single call. rounds=12 is a safety valve against an infinite exchange, not a tuning knob; the demo never approaches it.

Nowhere in this file is there a time, a random, or a thread. The determinism is structural, not injected: there is nothing to seed.


6. The demo, and what it proves

python3 demo.py. The real output follows, in three parts.

6.1 The naive rule

==================================================================== COMMIT RULE = naive ==================================================================== (0) S1 wins term 1; a@1 replicated to all five after (0) S1 [L] term=1 commit=1 log: a@1 S2 [F] term=1 commit=0 log: a@1 S3 [F] term=1 commit=0 log: a@1 S4 [F] term=1 commit=0 log: a@1 S5 [F] term=1 commit=0 log: a@1 (a) S1 wins term 2; b@2 reaches S2 only -- 2 of 5 after (a) S1 [L] term=2 commit=1 log: a@1 b@2 S2 [F] term=2 commit=1 log: a@1 b@2 S3 [F] term=2 commit=1 log: a@1 S4 [F] term=2 commit=1 log: a@1 S5 [F] term=2 commit=1 log: a@1 (b) S1 down; S5 wins term 3; c@3 sits at index 2 on S5 alone after (b) S1 [x] term=2 commit=0 log: a@1 b@2 S2 [F] term=3 commit=1 log: a@1 S3 [F] term=3 commit=1 log: a@1 S4 [F] term=3 commit=1 log: a@1 S5 [L] term=3 commit=1 log: a@1 c@3 (c) S5 down; S1 wins term 4; b@2 now on [1, 2, 3] after (c) S1 [L] term=4 commit=2 log: a@1 b@2 S2 [F] term=4 commit=1 log: a@1 b@2 S3 [F] term=4 commit=1 log: a@1 b@2 S4 [F] term=3 commit=1 log: a@1 S5 [x] term=3 commit=0 log: a@1 c@3 S1 counts b@2 on 3 of 5 -- a majority. b@2 was created in term 2; S1's term is 4. commit_index = 2 [rule = naive] (d) S1 down; S5 wins term 5; index 2 becomes c@3 after (d) S1 [x] term=4 commit=0 log: a@1 b@2 S2 [F] term=5 commit=1 log: a@1 c@3 S3 [F] term=5 commit=1 log: a@1 c@3 S4 [F] term=5 commit=1 log: a@1 c@3 S5 [L] term=5 commit=2 log: a@1 c@3 (e) S1 rejoins and is overwritten too after (e) S1 [F] term=5 commit=2 log: a@1 c@3 S2 [F] term=5 commit=2 log: a@1 c@3 S3 [F] term=5 commit=2 log: a@1 c@3 S4 [F] term=5 commit=2 log: a@1 c@3 S5 [L] term=5 commit=2 log: a@1 c@3 index 2 reported COMMITTED at step (c): True nodes still holding b@2 at the end: none
Disclosure, before the arithmetic

This schedule is hand-written. It is an adversary, constructed line by line to break the naive rule — not a run that happened to go this way, and not a seed searched for until something broke. The crashes, the restarts and the three partitions are literal arguments in demo.py, and you should read them as the attack they are. §7.1 explains why a written schedule was chosen over a searched one, and reports what a search actually finds.

Two more things the schedule does on purpose, so they don't read as sleight of hand. Nodes campaign on command — there are no election timeouts, so Cluster.elect is called explicitly at each phase. And elect retries after a stale-term loss: when S1 comes back in step (c) its term is 2 while S3 and S4 are at 3, so its first campaign cannot win, it learns term 3 from the rejection, and stands again to reach term 4. That is real protocol behaviour — a stale candidate losing and re-standing — not a fudge to reach a needed number.

6.2 Deriving the majority

The headline number is 3. Five servers, so a majority is ⌊5/2⌋ + 1 = 3. After step (c), holders(2, 'b') returns [1, 2, 3]:

serverlog after (c)holds b@2?
S1a@1 b@2yes (it is the leader; it created it)
S2a@1 b@2yes (from step (a)'s partial delivery)
S3a@1 b@2yes (from step (c)'s replication)
S4a@1no — still partitioned out
S5a@1 c@3no — has a different entry there

The leader computes the same count arithmetically: stored = 1 + |{p : match_index[p] >= 2}| = 1 + |{2, 3}| = 3, and 3 * 2 = 6, which is greater than len(peers) + 1 = 5. A genuine majority, by the same test that would commit any current-term entry.

The naive rule stops there and sets commit_index = 2. The transcript line commit_index = 2 [rule = naive] is that assignment.

6.3 Why the majority did not save it

Step (d) is the punchline, and it turns on one comparison. Candidate S5 sends last_index=2, last_term=3. Each voter evaluates (m["last_term"], m["last_index"]) >= (lt, idx):

voterits last entryits (lt, idx)(3, 2) >= (lt, idx)?
S2b@2(2, 2)(3,2) >= (2,2)yes
S3b@2(2, 2)(3,2) >= (2,2)yes
S4a@1(1, 1)(3,2) >= (1,1)yes

Three grants plus S5's own vote is 4 of 5 — S5 becomes leader of term 5, and its first AppendEntries truncates index 2 on all three. Note what S2 and S3 just did: they voted away an entry they were personally storing. They had b@2 in their logs. Nothing in the vote path looks at that. It compares last terms, and 3 > 2.

The last line of the run is the verdict:

index 2 reported COMMITTED at step (c): True nodes still holding b@2 at the end: none

A client that wrote b was told the write was durable. Five servers later hold no trace of it.

6.4 The same schedule, real rule

(c) S5 down; S1 wins term 4; b@2 now on [1, 2, 3] after (c) S1 [L] term=4 commit=0 log: a@1 b@2 S2 [F] term=4 commit=1 log: a@1 b@2 S3 [F] term=4 commit=1 log: a@1 b@2 S4 [F] term=3 commit=1 log: a@1 S5 [x] term=3 commit=0 log: a@1 c@3 S1 counts b@2 on 3 of 5 -- a majority. b@2 was created in term 2; S1's term is 4. commit_index = 0 [rule = raft]
==================================================================== VERDICT ==================================================================== Same schedule, both times. Only the commit rule differed. naive: claimed b@2 committed = True, survivors = none raft : claimed b@2 committed = False, survivors = none The naive rule told a client 'committed' about an entry that no node in the cluster still holds.

Step (d) still happens. S5 still wins, still truncates, and b@2 still ends up on nobody. Raft does not prevent the overwrite — it prevents the lie. That distinction is the single most useful thing on this page. The safety property is not "committed entries are never lost"; it is "an entry that was declared committed is never lost," and the rule earns that by declaring fewer things.

The commit_index = 0 is worth a second look, because it is stronger than "it refused index 2." S1 in term 4 cannot commit index 1 either: a@1 is from term 1, which is also not term 4. Its log is two entries long, a majority stores both, and it may commit neither.

That is not a bug in the toy, it is Raft, and it is why commit_index returning to 0 on restart (Cluster.crash, raft.py line 200) matters — it is volatile state in Figure 2 of the paper. A freshly elected leader genuinely does not know which entries are committed, and cannot find out by counting. The real fix is in §8 of the paper: "Raft handles this by having each leader commit a blank no-op entry into the log at the start of its term." One write of the leader's own term, and everything below it commits at once. test_raft.py pins exactly that: a term-4 leader with [a@1, b@2] and a majority commits 0, and the same leader with [a@1, b@2, no-op@4] commits 3.

6.5 The boundary — where the effect vanishes

The paper's Figure 8(e). Same schedule as before, with one extra line: while S1 is leader in term 4, it accepts one more client command, d, and gets it to the same majority that already has b@2.

==================================================================== BOUNDARY -- the same schedule, plus one term-4 entry ==================================================================== (c') S1 wins term 4 and appends d@4 to [1, 2, 3] after (c') S1 [L] term=4 commit=3 log: a@1 b@2 d@4 S2 [F] term=4 commit=1 log: a@1 b@2 d@4 S3 [F] term=4 commit=1 log: a@1 b@2 d@4 S4 [F] term=3 commit=1 log: a@1 S5 [x] term=3 commit=0 log: a@1 c@3 index 3 holds d@4, and S1's term is 4 -- they match, so it commits. commit_index = 3: index 2 rides along behind index 3. (d') S5 tries again. Leader? False. It reached term 7 trying. S2: term=7 voted_for=None S3: term=7 voted_for=None S4: term=7 voted_for=5 after (d') S1 [x] term=4 commit=0 log: a@1 b@2 d@4 S2 [F] term=7 commit=1 log: a@1 b@2 d@4 S3 [F] term=7 commit=1 log: a@1 b@2 d@4 S4 [F] term=7 commit=1 log: a@1 S5 [C] term=7 commit=0 log: a@1 c@3 nodes still holding b@2: [1, 2, 3] One entry of the leader's own term, and the old entry is safe.

commit_index jumps from 0 to 3 in one step. Index 3 qualifies on its own merits — d@4, leader's term 4, stored on 3 of 5 — and indexes 1 and 2 are committed indirectly, by the Log Matching Property: any server that accepted index 3 necessarily matches at 1 and 2.

And now S5 cannot come back. Its last entry is c@3; S2's and S3's is d@4, and (3, 2) >= (4, 3) is false, so they refuse. S5 campaigns four times, burning terms 4, 5, 6 and 7, and collects only S4's vote each time — S4: voted_for=5, while S2 and S3 show voted_for=None at term 7, having stepped down to the higher term without granting anything. Two votes of five is not a majority. b@2 survives on [1, 2, 3].

The effect vanishes the moment the leader writes anything of its own. In a real cluster under load that is microseconds, which is why this hazard is invisible in practice and why it took a figure in a paper to make people see it. The dangerous window is precisely an idle cluster that has just changed leaders — which is also the window right after a failure, when a monitoring system is most likely to be issuing reads.

6.6 What the trace itself proves

Two events in the schedule were removed one at a time and the run repeated. Both results were surprising enough to be worth stating.

Remove the partition in step (a) — let b@2 reach all five servers instead of only S2:

=== VARIANT: step (a) delivers b@2 to all five (rule=naive) === after (a): b@2 on [1, 2, 3, 4, 5] (b) S5 elected? True term=3 S5 log now [a@1, b@2, c@3] (c) S1 term 4, b@2 on [1, 2, 3, 4, 5], commit_index=2 -> claimed=True (d) S5 won? True (reached term 5) b@2 survivors: [1, 2, 3, 4, 5]

S5 wins step (d) exactly as before — but it does no damage, because its log is now [a@1, b@2, c@3]: c landed at index 3, not index 2, so there is no conflict to truncate. The hazard was never about S5 winning. It was about the dropped message in step (a), which is what created two different entries competing for one index.

Remove S5's own write in step (b) — S5 becomes leader but never accepts c:

=== VARIANT: step (b) S5 never accepts `c` (rule=naive) === after (a): b@2 on [1, 2] (b) S5 elected? True term=3 S5 log now [a@1] (c) S1 term 4, b@2 on [1, 2, 3], commit_index=2 -> claimed=True (d) S5 won? False (reached term 7) b@2 survivors: [1, 2, 3]

S5 cannot win step (d) at all. Its last entry is a@1, term 1, against S2 and S3's term 2 — it burns four terms and fails. So the entry that destroys a majority-replicated value is c@3: an entry that was never replicated to a single other server, and that no client was ever told anything about. It never needed to be durable. It only needed to exist, once, on one machine, long enough to raise that machine's last log term.

6.7 The tests

python3 test_raft.py — 13 tests, all passing, no pytest:

All 13 tests PASSED

They pin the headline (both rules on the identical schedule, opposite verdicts), the majority arithmetic at step (c), the boundary, the negative result from §7.2, and unit checks on the vote comparison, the next_index back-off, truncation, crash persistence, message loss, and byte-identical output across repeated runs.


7. Design decisions and roads not taken

7.1 A written schedule, not a searched seed

The alternative was to give the driver randomised election timeouts and random crash injection, search seeds until one produced a violation, and ship the seed. That is a legitimate technique, and it was measured rather than dismissed.

A search was actually run against this exact raft.py: 3000 seeds, each a 40-round random schedule (random crashes, restarts, campaigns, client writes, and a random delivery subset each round), all under the naive rule, watching for an entry that a leader had reported committed and that later differed at that index. It works — 170 of 3000 seeds, 5.7%, produce a violation. Those figures come from that run and nothing else; they are not quoted from anywhere.

The search was still rejected, on the evidence it produced about itself:

scripted schedulesearched seed (median of 170 hits)
events before the violation14 (5 phases)52.5
rounds526 (min 6, max 40)
highest term reached58 (max 18)
typical violationb@2 at index 2, twice named in the logindex 1 rewritten from x2 to x1

A reader can hold the scripted version in their head. The searched version requires trusting a magic number and reading a 50-event trace to find the needle — and worse, it hides the adversary. The whole lesson is what an attacker has to arrange: a dropped message, two crashes, and one write that goes nowhere. A seed says "this happens sometimes." The script says why.

The 5.7% earns its place here anyway, because it answers the obvious objection: if the schedule is hand-picked, is it a contrived special case? No. Random schedules hit it roughly one time in eighteen.

7.2 The negative result: the election restriction is not what saves you

The obvious guess, having read §5.2, is that the (term, index) comparison in on_vote is doing the safety work — that it stops the wrong candidate winning. It was tested by weakening it to compare log length only, ignoring terms entirely, and re-running the identical schedule under the real commit rule.

Nothing changed. claimed=False, b@2 survivors: none, S5's final log [a@1, c@3]. S5 still wins step (d), because with length-only comparison its two-entry log ties S2's and S3's two-entry logs and >= grants the vote anyway.

So the election restriction is not what saves Figure 8 in this schedule; the commit rule is, on its own. This is worth knowing because the two rules are usually taught together as a pair, which makes it easy to assume either one would do. The paper's own safety argument (Figure 9) needs both — the restriction is what makes the general proof go through, and there are other schedules where it is the binding constraint. But it is not load-bearing here, and a commentary that claimed it was would be wrong.

7.3 Why ship both rules in one file

Node.rule is a string compared inside the hot path, which is not how anyone would write production code. It buys the only thing this page really needs: the guarantee that both runs execute the same schedule. Two separate implementations, or a git branch, would leave the reader wondering whether some other difference crept in. One function, one argument, one clause.

7.4 The counterfactuals that were run

Every judgement call in advance_commit and on_vote was replaced with its plausible alternative and re-run against the same schedule:

changeresult
drop the term clause (rule = NAIVE)headline flips: commits index 2, then loses it
>=> in up_to_dateno election ever completes; first client write raises KeyError
remove the return in advance_commitnaive's claim flips TrueFalse; commit lands on the lowest qualifying index
<=< in the majority testno change at 5 nodes (both commit at 3+); differs only at even sizes
length-only vote comparisonno change (§7.2)
deliver step (a) to all fiveS5 still wins (d), but c lands at index 3 — no conflict, b@2 survives
S5 never accepts c in (b)S5 cannot win (d) at all; b@2 survives on [1,2,3]

Two of the seven are negative results, and they stayed in. A line that looks decisive and is not is worth as much page space as one that is.

7.5 What is deliberately absent


8. What's simplified vs. the real thing

No election timeouts, and this is the important one Real Raft nodes each pick a random timeout in a range (150–300 ms is the paper's suggestion) and campaign when it expires; the randomisation is what breaks split votes. This toy has Cluster.elect(i) instead, called explicitly. That is not laziness, it is the subject: timeouts are the availability mechanism; they are not the safety mechanism. Every claim on this page is a safety claim — an entry declared committed and then lost — and safety in Raft does not depend on timing at all. The paper is explicit that Raft's correctness never relies on bounded clock skew (it mentions leases as an alternative for read-only queries precisely because they would introduce such a dependency). Adding randomised timeouts would make the schedule unreproducible and would not change a single outcome above. The one thing it would buy — watching a leader election happen on its own — is the backlog's original, weaker idea for this toy, and it teaches nothing you could not learn from an animation.

Messages are Python dicts in a list. No serialisation, no network, no retransmission, no reordering beyond what the driver chooses, and no duplicate delivery. Real implementations must handle all four; the RPCs are specified as idempotent for exactly this reason.

Delivery is synchronous and batched. deliver() runs the cluster to quiescence, so a leader's next_index back-off — reject, decrement, resend, accept — completes atomically from the schedule's point of view. Real leaders do this over many round trips, and real implementations optimise it by having the follower return a conflict hint so the leader can skip a whole term's worth of entries instead of decrementing one at a time.

Everything the follower is missing, in one message, and no heartbeats. The toy sends self.log[ni - 1:] and only ever sends when something changes. Real leaders heartbeat on an interval to suppress elections and to propagate commitIndex, which is why followers here lag on commit in the transcript (S2 shows commit=1 while the leader shows 3).

No state machine. Entries are strings that are never applied to anything. commit_index is the promise; a real system also has lastApplied and a state machine behind it, and the whole point of committing is to feed that machine.

No persistence, no fsync. Marked, not implemented — see §7.5.

Scale. The paper reports its own RAMCloud implementation at roughly 2000 lines of C++ excluding tests, comments and blanks, for a mechanism this toy covers in 152 lines of Python. The missing 1850 lines are almost entirely the five items above.

Budget note. raft.py is 216 lines total against this repo's 100–200 line target. 152 of those are code and the rest are comments and docstrings; the overrun is real and it is because this toy needs three sub-protocols — election, replication, and the commit rule — where every other toy in the repo needs one.


9. Check yourself

Every answer is derivable from raft.py and the transcripts above, and every one was verified by running it.

1

At step (c), S1 is leader in term 4 with log [a@1, b@2], and b@2 is stored on 3 of 5 servers. Under the real rule, what is commit_index, and why is the intuitive answer wrong?

Answer

0, not 1. The intuitive answer is 1 — surely the term-1 entry that everybody has is committed? But advance_commit tests every candidate index against the leader's current term. a@1 is from term 1 and b@2 is from term 2; S1's term is 4. Neither matches, both are skipped, and commit_index stays at the value it was reset to when S1 crashed, which is 0 (it is volatile state, per Figure 2 — see Cluster.crash, raft.py line 200).

This is exactly why real leaders append a no-op entry of their own term immediately on election: without it, a new leader cannot commit anything, and therefore cannot safely answer a read, until a client happens to write.

2

In step (d), S2 and S3 both have b@2 in their logs and both vote for S5, which does not. Which line lets them, and what does it compare?

Answer

raft.py line 122 — up_to_date = (m["last_term"], m["last_index"]) >= (lt, idx). It compares only the last entry of each log, term first. S5's last entry is c@3 giving (3, 2); S2's and S3's is b@2 giving (2, 2). (3,2) >= (2,2) is true, so the vote is granted.

Nothing in the vote path inspects whether the voter holds some particular older entry, or whether that entry was ever called committed. A voter can and does vote away an entry it is personally storing.

3

Step (b) has S5 accept a client command c that it replicates to nobody — zero other servers ever see it. Delete that one line from the schedule. Does b@2 still get erased?

Answer

No. Without c, S5's log stays [a@1], last entry term 1. In step (d) it needs votes from S2 or S3, whose last entry is b@2, term 2 — and (1, 1) >= (2, 2) is false, so they refuse. S5 collects only S4 and fails, burning terms 4 through 7. b@2 survives on [1, 2, 3].

The lesson: the entry that destroys a majority-replicated value was never replicated to anyone and was never acknowledged to any client. Its entire contribution was raising S5's last log term from 1 to 3.

4

advance_commit counts down from the newest index and returns at the first qualifying one. Delete the return. What breaks, and why is it not caught by the real rule's run?

Answer

The loop continues to lower indexes, and each qualifying one overwrites commit_index, so it ends up at the lowest qualifying index rather than the highest — commit_index can go backwards.

It is invisible in the real rule's run because under RAFT nothing qualifies at all in this schedule, so there is no second assignment to do the damage. Under NAIVE it flips the demo's headline: claimed b@2 committed goes from True to False. A test that only exercised the safe rule would have shipped the bug.

5

The majority test is if stored * 2 <= len(self.peers) + 1: continue. Change <= to <. Which cluster sizes notice?

Answer

Even ones only. With 5 nodes, len(peers) + 1 = 5: <= commits at stored ∈ {3, 4, 5} and < commits at {3, 4, 5} — identical, so this schedule cannot expose the difference. With 4 nodes, <= commits at {3, 4} while < commits at {2, 3, 4}, and 2 of 4 is not a majority: two disjoint "majorities" of 2 could each commit a different entry.

This is the practical argument for odd cluster sizes, and a reminder that a passing test suite on 5 nodes says nothing about the line.

6

Reaching past the toy. You run a 5-node etcd cluster. The leader dies, a new one is elected, and your monitoring immediately issues a read. The new leader's log contains every committed entry — the Leader Completeness Property guarantees it. Can it answer the read correctly?

Answer

Not yet, and this toy shows why. It has every committed entry but does not know which ones they are — its commit_index is volatile and starts at 0, and it cannot recover the answer by counting replicas, because every entry in its log is from an earlier term (question 1).

Two precautions are needed, both from §8 of the paper: commit a blank no-op entry of the new term, which commits everything below it indirectly and establishes commit_index; and exchange heartbeats with a majority before replying, to confirm it has not already been deposed. A read served between election and no-op is the real-world face of the gap this toy stages.

7

The demo's figure8() is called twice with different rules and produces byte-identical output except where the rule matters. What in raft.py makes that guarantee, and what would break it?

Answer

There is no clock, no RNG and no concurrency anywhere in the file — the schedule is the only input, so determinism is structural rather than seeded. deliver() drains a list in insertion order, and elect() is called explicitly rather than fired by a timeout.

Adding randomised election timeouts would break it, which is the trade-off §8 describes. So would iterating a set where order matters — note that deliver iterates batch, a list, and only is only ever membership-tested. test_raft.py's last test runs the whole demo twice and compares the strings.


10. Further reading