quorum-lab

Raft, written from scratch in Go, and a deterministic simulator that attacks it. The simulator partitions the network, crashes and restarts nodes, drops, duplicates and reorders messages, and checks Raft's safety properties after every event. Any failure replays exactly from its seed.

A distributed system rarely fails on the happy path. It fails when a leader dies halfway through replicating an entry, or a restarted node forgets a vote it already cast. A clean run only means something if the checker would have noticed a broken one, so the repository also carries six known ways to get Raft wrong, and the simulator has to catch every one.

Plays the six stages below in order, about 60 seconds. Scroll or press Esc to stop.

Language
Go 1.22+, standard library only
Tests
38 passing (32 tests plus 6 subtests)
Cluster
5 nodes, 4 clients, 240 commands a run
Checks
6 after every event, 2 at the end of a run
Bugs
6 deliberate, each caught
License
MIT
quorumlab run --seed 2 seed 2, 5 nodes, correct build 14595 ms simulated, 14676 events, 6683 messages (906 dropped, 0 duplicated)

All three are real runs of quorumlab 0.1.0 built from commit 4e365b0, output verbatim. Hunt timings are wall-clock on a 12-core Linux machine; everything else is simulated time and identical on any machine.

1A Raft node with no clock and no network

raft.Node doesn't send anything, sleep or read the time. Time arrives as Tick(), messages as Step(m) and client commands as Propose(data). Everything the node wants done comes back from Ready(): state to persist, messages to send, committed entries to apply.

  • The driver must persist before it sends. A vote or a log entry that leaves the node before it is on disk can be forgotten by a crash.
  • A new leader appends an empty no-op entry, and commits by counting replicas only of entries from its own term.
  • The same code runs under the simulator and under a hand-driven harness in the unit tests, which replay the paper's scenarios message by message.

Not implemented: membership changes, snapshots, PreVote and leader leases. The cluster is fixed and the log grows forever.

Three nodes, driven by hand Illustrative input

      Input: Tick, Step or ProposeReady: persistReady: sendReady: apply
      The scenario and the command put x 1 were made up for this page: a short driver outside the repository builds three nodes with the repo's own raft package, delivers every message in order and prints each input and each Ready. Every line below the input is what the nodes returned, verbatim.

      2Cut the network four ways

      One event queue and one virtual clock that jumps from event to event. Every message gets a random delay, and 5% take a slow path that lets later traffic overtake them. Messages are dropped and duplicated, each node ticks at its own rate, and nodes crash, losing everything they hadn't persisted.

      • Partitions come in four shapes. A bridge and a ring aren't transitive: a bridge node hears candidates on both sides that can't hear each other, so it can vote in both elections.
      • Half of all crashes and isolations aim at the current leader, and half of the crashes bounce, restarting within 30 ms as under a supervisor.
      • Each seed draws its own fault profile: how lossy the network is, how often faults strike, how skewed the clocks are, how short election timeouts are against message delay.

      The README records that two of the six bugs went uncaught in 5,000 seeds until bridges and rings were added.

      The four shapes, as seed 2 drew them

      Can exchange messagesCutCannot reach a majorityHears both sides
      The labels are the simulator's own trace lines from quorumlab run --seed 2 --trace, with their times. Links are drawn from the rules in sim/sim.go, and the shading follows the same majority count the simulator uses for its timeline.

      3Ten seconds of chaos, then heal

      Four clients each run 60 commands (get, put and append) on three keys. They retry with the same sequence number when a reply doesn't come, and follow leader hints. After 10 simulated seconds the chaos phase ends: the network heals and becomes fast, every node comes back, and the clients get 10 more seconds to finish.

      • Every random choice comes from one generator seeded with the run's seed: delays, drops, fault times and victims, election timeouts, clock skew and client commands.
      • Nothing reads the wall clock, and the simulator never iterates over a map where order would matter. TestSameSeedSameRun runs seeds twice and compares the entire result.

      Seed 2, the correct build, start to finish

      LeaderCandidateCrashedPartitioned
      Lanes come from the run's own timeline record, the data behind --svg, dumped as JSON by a driver outside the repository. The log lines are --trace output and the counts are the run's summary, verbatim. Played back about twice as fast as simulated time.

      4Check safety after every event

      On the live cluster, after every event: at most one leader per term, logs that match wherever they share an entry, every new leader holding the entries committed before its term, no two nodes applying different entries at one index, terms and commit indexes that never go back, and no panics.

      • At the end of each run the clients' history must be linearizable. The checker is the Wing and Gong search with memoisation, the algorithm behind Porcupine, run on each key separately.
      • A command that never got a reply may or may not have taken effect, and the checker considers both.
      • Liveness is checked only once the network is healed and fast. Raft promises progress only when message delays are well under the election timeout.

      The end-of-run checks see what the per-event ones can't. Seed 2 with one bug switched on, applying a retried command twice, runs every one of the same events and passes every per-event check.

      Seed 2, twice: the same events, one different ending

      Correct build

      --bug no-dedup

        Both columns are verbatim output of quorumlab run --seed 2, without and with --bug no-dedup; the bug's run then printed its last 12 trace lines, left out here. The check list restates the README's two tables, which match the checks in sim/sim.go.

        ClearedAbove this line the correct build is attacked and holds. Below it, the same checker meets six deliberate bugs, and each one has to be caught and replayed from its seed.

        5Six ways to get Raft wrong, each caught

        Each bug is a real mistake people make implementing Raft, kept behind a switch in raft.Bugs, or NoDedup for the state machine. quorumlab mutants hunts the correct build and every bug, and fails if the correct build breaks or any bug gets away.

        • forget-vote is the hardest. A node must vote, crash, restart and vote again for a rival in the same term, and the rival must still win. That usually takes a bridge partition.
        • TestEveryBugIsCaught fails if any bug stops being found within its bound: 600 and 1,500 seeds for the two rare bugs, first caught at seeds 258 and 674, and 50 for the other four, which are caught within the first six.
        • The bugs are also pinned down by directed unit tests. TestFigure8 replays Figure 8 of the paper message by message, with and without the bug.

        A clean hunt is evidence, not proof. The mutants show the search has teeth, not that it reaches every bug.

        quorumlab mutants, then each bug over 3,000 seeds

          The first seed and the violation kind are from quorumlab mutants, which runs 5,000 seeds of the correct build. The bars are from quorumlab hunt --bug NAME --seeds 3000, one run per bug; all six counts match the README's table. Descriptions are from quorumlab bugs.

          6Replay Figure 8 from seed 258

          With commit-previous-term switched on, the hunt first fails at seed 258. The leader of term 50 counts replicas of the term-19 leader's entry at index 52 and calls it committed. Node 2, holding the term-20 leader's entry at that index, then wins term 51 and would overwrite it.

          • This is Figure 8 of the Raft paper, found by random search. Correct Raft never counts replicas of an earlier term's entry (§5.4.2).
          • A ring partition leaves nodes 2 and 4 trading leadership through dozens of short terms. Leader Completeness fails the moment node 2 takes office.
          • A failure found by hunt on a 12-core machine replays identically on a laptop with quorumlab run --seed N --bug NAME --trace.

          Seed 258 with the bug, 3.0 to 4.8 s

          SHA-256 of the full --trace output, run 1
          12c7df704b401337…
          Run 2
          12c7df704b401337…
          Lanes are the run's timeline record, dumped as JSON by a driver outside the repository; the log lines and the violation are the run's own output, verbatim. Both hashes were taken on this machine; regenerating docs/figure8.svg from the same seed also left the committed file byte for byte unchanged.

          Measured, not assumed

          Every number here was re-run from a clean clone of commit 4e365b0 with Go 1.27 on one 12-core Linux machine. The seed counts are deterministic and match the README; the times are this machine's.

          The correct build

          CI runs the 20,000-seed hunt and the mutant table on every push to main.

          20,000 seeds0 violations87.1 s

          The correct build also passed 2,000 seeds in 12.9 s and the 5,000 seeds inside quorumlab mutants, which took 16.2 s with all six bugs.

          How rare the bugs are

          The first seed that fails, out of seeds tried in order from 1.

          forget-vote674
          Figure 8258
          stale term6
          no-dedup2

          The other two bugs fail on seed 1. forget-vote broke only 3 of 3,000 seeds, the Figure 8 bug 29.

          What it found in itself

          The README records two mistakes in the harness, both fixed. The first Leader Completeness check was too strict: a ring partition produced a legal run where a node won an old term after a later term had committed entries.

          The second was a message storm. Under duplication, one run of keep-conflicts grew to seven million messages in 20 simulated seconds, until the leader probed again only when a rejection moved its estimate backwards.

          38 tests in about 13 seconds

          Counted with go test -count=1 -json ./...: 32 tests plus 6 subtests, all passing. The README counts the 32 top-level tests; the subtests are one per deliberate bug.

            Hand-built scenarios, then random ones

            • Raft: election, replication and commit, stale-leader rejection, the vote's log check, votes surviving a restart, conflict truncation, reordered appends that must not truncate, a bogus reply that must not crash a leader, and Figure 8.
            • Linearizability: hand-built histories with stale reads, values going back in time, duplicated appends and pending operations, then 300 random histories that are linearizable by construction, each also corrupted so it must fail.
            • Simulator: determinism, the correct build on 400 seeds, floors on partitions, crashes, elections, drops and duplicates so the runs are really hostile, every bug caught within its bound, a quiet network, a single-node cluster.
            • Store and CLI: retries take effect once, exit codes, replays that match byte for byte, traces, SVG output and usage errors.

            CI runs vet, gofmt, the suite on Go 1.22 and stable, the short suite under -race, the 20,000-seed hunt with the mutant table, and a check that the pictures in docs/ are what the current code draws.

            Deliberately missing

            Membership changes and snapshots
            The cluster is fixed and the log grows forever. Each is a substantial addition with its own known bugs.
            PreVote and CheckQuorum
            A partitioned node keeps raising its term and disrupts the cluster when it rejoins. That costs liveness, not safety, and the timelines show it.
            Fast reads
            Every get is a log entry: simple and linearizable. Lease or read-index reads would be faster and worth simulating, precisely because they're easy to get wrong.
            Torn writes
            A message arrives whole or not at all, and a persisted state survives a crash whole. Torn writes and fsync lies are a different simulator.
            A proof
            20,000 hostile seeds are strong evidence, not verification the way TLA+ or a proof assistant would give.

            Run it

            git clone https://github.com/ahmed-hashim-pro/quorum-lab
            cd quorum-lab
            go build -o quorumlab ./cmd/quorumlab
            
            ./quorumlab run --seed 2 --trace   # every election, fault and restart
            ./quorumlab hunt --seeds 2000      # many seeds, on every core
            ./quorumlab mutants                # the six bugs, each caught
            ./quorumlab run --seed 258 --bug commit-previous-term --trace
            
            go test ./...

            Go 1.22 or later and nothing else: no dependencies outside the standard library. make check adds the full 20,000-seed hunt and the mutant table, as CI does. Exit codes: 0 no violation, 1 a violation, 2 a usage error.