module 09 · from MESI to consensus

Distributed memory

The 30-second version. Take what modules 01–09 assumed — one memory, one bus, one fate — and put it on a network. Nodes fail partially: the other node exists and doesn't, and you can't tell which; a message costs ~1 ms, not ~1 ns. Causality survives — Lamport clocks export happens-before as a per-node counter, vector clocks make "concurrent" decidable at O(N) cost. Durability survives as quorums (R + W > N) and as consensus — in Raft the log is the distributed memory, the commit index the write barrier, the majority its durability. Availability survives as CRDTs: commuting operations that converge under any reorder.

1. The break: three assumptions die at once

A mutex on one machine (chapter 02) rests on three assumptions you never state: one fate (lock, data, and waiter share a kernel), one bus (MESI's agreed truth per cache line, ~100 ns answers), and one clock ("which write came first" was answerable). Distribute the state, and all three die.

Partial failure is the cruelest: a silent peer could be crashed, slow, or fine with your packet lost in transit — a timeout cannot distinguish the three. The other node exists and doesn't, and you can't even tell which. Latency kills the bus by three orders of magnitude per step of module 01 chapter 02's tower:

 register ~0.4 ns | L1 ~1 ns | L2 ~4 ns | L3 ~15 ns | DRAM ~100 ns
   -> futex syscall 1-10 us (ch02)  -> one RPC hop, same LAN, ~0.5-1 ms
   -> cross-region RTT 30-80 ms (public cloud inter-region, 2024)
 every arrow = ~1000x -- and best-effort IP can also just LOSE things.

And the clock dies worst: NTP holds clocks within milliseconds while correctness cares about "who saw what" — you need an ordering that never consults the clock.

2. Exporting happens-before: Lamport clocks, then vector clocks

Chapter 01's happens-before imports almost unchanged: program order within a node, send → receive across nodes. How do we label events so the relation is computable? Lamport's 1978 answer: a counter per node. Increment on every local event; stamp every message; on receive, c = max(c, stamp) + 1. The theorem: a → b implies L(a) < L(b) — one-way only. The scalar cannot say whether two stamps are causally ordered or unrelated: Lamport clocks capture ordering but are blind to concurrency.

Vector clocks (Mattern, Fidge, 1988–89) pay O(N) to close the gap: each node carries an N-vector, bumps its own slot on every event, ships the whole vector per message, and the receiver takes the componentwise max. Compare vectors A and B: A ≤ B componentwise ⇒ A happened-before B; B ≤ A ⇒ the reverse; neither dominates ⇒ concurrent — the same "did these two race?" predicate TSan computes per access pair (module 03 ch05). The price: N counters per event and per message — 8 bytes per slot, so 100 nodes pay 800 bytes of metadata per message, and comparison is O(N). Production systems prune — and the pruning itself was a bug farm.

3. Replication: the read/write set grows up, and R + W > N

Data on exactly one node dies with it, so replicate: N replicas turn every logical write into a physical write-set, every read into a read-set — chapter 01's owner-to-read/write-sets move, networked. How many of each must succeed before you answer the client? Call them W and R, the write and read quorums.

Pigeonhole: any read quorum of R and write quorum of W share at least R + W − N nodes. The useful middle is R + W > N: the shared node must hold the last committed write, so the reader can find it — picking the newest version by vector clock, section 2. N = 3, R = W = 2 (the Dynamo paper default): overlap 1; writes survive one dead replica, reads stay fresh with one slow one. Cassandra's default ONE/ONE at RF = 3: R + W = 2 ≤ 3 — no overlap, stale reads legal — traded for latency: the consistency-latency exchange rate, one equation.

Leader-follower logs are the structured special case: one replica is the entry point (chapter 04's single-owner principle, networked), appending to a log followers apply in order.

4. Consensus: the linearizable write

Quorums replicate; they don't decide — two clients writing different values to overlapping quorums, or a write a majority saw but nobody was told "done", need one agreed sequence: consensus, the linearizable write. Its cleanest expression is Raft.

Raft in one page. Time divides into terms (monotonic); each has at most one leader, elected by majority vote with randomized timeouts (paper: 10–500 ms; etcd 3.4+: 100 ms heartbeat, 1000 ms election). The leader owns the log: entries, each stamped with the term that wrote it. A client write appends an entry; the leader replicates it; once a majority stores it, the leader advances the commit index — the barrier below which the log is the truth, durable against any minority crash — and only then acks. Failures compare logs: voters refuse less up-to-date candidates; followers refuse entries mismatching their prefix (log matching). The log is the distributed memory; the commit index the write barrier; the majority its durability. Watch:

 5 nodes (S1..S5).  [t:i] = entry at index i, written in term t.  CI = commit index.

 t0  term 1, S1 leader, CI = 3:
     S1 [1:1][1:2][1:3][1:4][1:5]   <- entries 4,5 reached ONLY S2 (2/5)
     S2 [1:1][1:2][1:3][1:4][1:5]      -> not a majority -> NOT committed
     S3 [1:1][1:2][1:3]                -> no ack was ever sent for 4,5
     S4 [1:1][1:2][1:3]
     S5 [1:1][1:2][1:3]             <- all five: CI = 3

 t1  S1 CRASHES. Timers fire (etcd default: 1 s no heartbeat); S3
     stands for election, term 2.

 t2  Up-to-date check, S3's last entry (term 1, index 3) vs each voter's:
     S4, S5 same -> grant. S2's log is longer -> S2 refuses -- but 3/5
     is already a majority. S3 leads term 2. (Leaders don't need the
     longest log, just one a majority accepts as most up-to-date.)

 t3  Client writes x=7. S3 appends [2:4], replicates to 3/5, advances
     CI 3 -> 4, acks. [1:1..1:3] commit too, via log matching.

 t4  S2 (and a restarted S1) resync. AppendEntries at prevLogIndex=3:
     S2's [1:4] mismatches -> nextIndex backs up -> S2 TRUNCATES
     [1:4][1:5], takes the term-2 log. Uncommitted entries vanish --
     legal: never committed, never acked. THE COMMIT INDEX IS THE
     DURABILITY BARRIER.

Why not two-phase commit? A coordinator broadcasts PREPARE, everyone locks and votes yes, it broadcasts COMMIT. The death window: the coordinator dies after collecting the yes votes, and participants hold locks in "prepared" — unable to commit or abort — until its successor digs through its logs. Consensus's superiority is structural: no single node's memory decides the outcome — the majority is the decision, so any surviving quorum can finish it.

5. Commutative memory: CRDTs, and the LWW pathology

Consensus buys linearizability by blocking: without a quorum, writes wait (CP). The other CAP fork accepts writes everywhere and reconciles later — workable only if reconciliation is order-independent. That is a CRDT: a replicated type whose operations commute, so reordering any two converges to the same state — the strongest availability you can buy.

The pathology is the shortcut people reach for instead of CRDT design: last-write-wins on timestamps. With millisecond NTP drift and out-of-order delivery, the earlier-arriving newer write is silently discarded — LWW doesn't delay data, it eats it, no error, forever; the Jepsen Cassandra and Elasticsearch findings were both LWW underneath.

6. The menu, PACELC, Jepsen, and the circle closing

Everything above is one menu, ordered by the anomaly each level forbids:

 strong +------------------------------------------------------+
  | LINEARIZABLE  forbids: stale read after ack.  Write   |
  | acked at real time t => every read after t sees it.   |
  | Real time consulted (Raft ReadIndex, etcd linearizable |
  | reads) = sequential consistency for a SINGLE location. |
  | ----------------------------------------------------- |
  | SEQUENTIAL    forbids: clients observing the same     |
  | writes in opposite orders. One total order explains   |
  | all; real time ignored. = SC, the interleaving        |
  | fiction a mutex gave you (module 01 ch20).            |
  | ----------------------------------------------------- |
  | CAUSAL        forbids: seeing the reply before the    |
  | post. Happens-before respected; concurrent writes may |
  | still order differently per client.                   |
  | ----------------------------------------------------- |
  | READ-YOUR-WRITES  forbids: your own write bouncing    |
  | back at you (sticky sessions fix it; others'          |
  | staleness is fine).                                   |
  v ----------------------------------------------------- |
 weak | EVENTUAL    forbids: disagreeing forever.          |
      | "Converge soon" -- by CRDT or by sync.             |
      +------------------------------------------------------+

Each step down is an anomaly you accept; each step up, real-time ordering you pay for. CAP: under partition, pick availability or consistency. PACELC (Abadi, 2012) adds the honest clause: Else — no partition — you still pay latency or consistency: a linearizable read must contact a majority, a ONE read doesn't. The partition didn't create the price; it itemized it.

Jepsen (Kyle Kingsbury, 2013–) is the distributed TSan: run a database under client load while a nemesis partitions nodes (iptables), kills processes, skews clocks; then check the history against a formal model — a litmus test, automated, at cluster scale. Famous verdicts: 2013 Redis-with-Sentinel split-brained and lost 56% of acknowledged writes in a majority partition; 2013 MongoDB returned writes a deposed primary had acknowledged, then rolled back; 2014 Elasticsearch's LWW mappings silently dropped updates. Vendor consistency claims are hypotheses; Jepsen is the experiment — Knossos the formal-method checker.

And the circle closes. Chapter 01's store-buffering litmus is the one-machine shape of "stale read after ack". A mutex gave you sequential consistency for a critical section, Raft gives it for one log; a linearizable register is sequentially-consistent behavior for a single location, with real time respected. The store buffer became the network, the fence became the quorum, MESI's one agreed truth per line became consensus — the memory model, one scale up, where module 01 chapter 20 said it started.

What insiders know

See it yourself

Lab A — a two-node vector-clock playground. Two nodes, local events, one message, then parallel events — the case where Lamport scalars and vector clocks disagree:

# vclock.py -- two-node vector-clock playground (CPython 3.11+)
def le(A, B): return all(a <= b for a, b in zip(A, B))   # componentwise <=

def relate(A, B):
    if le(A, B): return "A -> B (happens-before)"
    if le(B, A): return "B -> A (happens-before)"
    return "CONCURRENT (no causal order either way)"

class Node:
    def __init__(s, me): s.me, s.v, s.l = me, [0, 0], 0  # v: vector, l: Lamport
    def local(s):
        s.v[s.me] += 1; s.l += 1; return tuple(s.v), s.l
    def send(s):
        s.v[s.me] += 1; s.l += 1; return tuple(s.v), s.l
    def recv(s, mv, ml):                       # max-on-receive, both clocks
        s.v = [max(a, b) for a, b in zip(s.v, mv)]; s.v[s.me] += 1
        s.l = max(s.l, ml) + 1; return tuple(s.v), s.l

A, B = Node(0), Node(1)
a1 = A.local(); b1 = B.local()     # independent local events
mv, ml = A.send()                  # A -> B (one message)
b2 = B.recv(mv, ml)                # the causal edge, imported
a2 = A.local(); b3 = B.local()     # more parallel local events

for x, y in [(a1, b2), (a2, b3), (a1, b1)]:
    print(f"V={list(x[0])} L={x[1]}  vs  V={list(y[0])} L={y[1]}: {relate(x[0], y[0])}")
$ python3 vclock.py
V=[1, 0] L=1  vs  V=[2, 2] L=3: A -> B (happens-before)
V=[3, 0] L=3  vs  V=[2, 3] L=4: CONCURRENT (no causal order either way)
V=[1, 0] L=1  vs  V=[0, 1] L=1: CONCURRENT (no causal order either way)

Notice line 2: the Lamport scalars say L=3 < L=4 — an ordering that does not exist; the split vectors [3,0] vs [2,3] are what a race looks like — the predicate TSan computes per access pair.

Lab B — a Raft failover, traced by hand. Work the section-4 diagram, answering before the annotations: (1) Why is entry 5 not lost when S2 truncates it? Never committed: 2/5 replicas, no ack. (2) Why do S4/S5 elect S3 when S2's log is longer? Up-to-date is (term, length); 3/5 is a majority without S2. (3) What commits as CI moves 3 → 4? The prefix [1:1..1:3] too, via log matching. (4) Where's the linearizability? The ack for x=7 exists only once a majority stores the entry, and any future leader's majority must intersect it. For a live one: a 3-member etcd (v3.5, docker) shows term and commit index in etcdctl endpoint status -w table — RAFT TERM / RAFT INDEX; kill the leader and watch the term tick up.

How to use this knowledge

Connections

Quiz

  1. Concept. A request times out. Name the three peer states, why a longer timeout cannot distinguish them, and what this does to dead-or-alive as fact.
  1. Concept. Lamport clocks guarantee a → b ⟹ L(a) < L(b). Why is the converse's failure the reason vector clocks exist, and what becomes decidable at O(N) cost?
  1. Predict the guarantee. A 5-node store runs R = 2, W = 2. Compute the read-write overlap, name the anomaly now legal, and give the minimal fix.
  1. Predict the output. Before running the lab: A is at V = [3, 0], B at V = [2, 3]. What does relate print, and what would the Lamport scalars print?
  1. Bug diagnosis. An LWW-resolved column loses ~0.1% of writes during a network blip; no errors logged, no failure detector fires. Give the exact mechanism — two writes, one clock.
  1. Bug diagnosis. 2PC: the coordinator dies after all participants vote PREPARE-yes. What do participants hold, for how long, and what structural property of Raft closes the window?
  1. Bug diagnosis. From the section-4 trace: the term-2 leader truncates S2's [1:4] and [1:5]; a teammate files "data loss in our Raft cluster." Refute it with the commit index and the ack rule.
  1. Cross-module (module 01 ch20 / 09 ch01). Map store-buffering onto this chapter — the two private buffers, the missing fence, the (0,0) outcome — and name the rung that forbids it, and the weaker rung that permits it.
Answers
  1. Crashed, slow, or fine-with-lost-traffic. A timeout measures my absence of an answer, conflating peer, network, and load state; lengthening it only trades detection latency against accuracy. One machine has a kernel that knows a thread is runnable or not; distributed, "alive" is a per-observer opinion — so systems agree on fates (terms, leases, quorums) rather than discover them.
  1. L(a) < L(b) does not imply a → b — the scalar collapses "caused" and "merely later-numbered" into one order, hiding concurrency. Vector clocks make concurrency itself decidable: neither A ≤ B nor B ≤ A proves the third state, "concurrent" — the race predicate, at the cost of N counters per event.
  1. Overlap = 2 + 2 − 5 = −1, i.e. 0 shared nodes. A read's R = 2 nodes can both sit in the 3 nodes the write's W = 2 never touched — stale reads after completed writes are legal. Minimal fix: R = 3 (or W = 4), overlap ≥ 1, at one extra node per read — PACELC's "else" priced.
  1. CONCURRENT (no causal order either way) — A's slot 0 is 3 > 2 while its slot 1 is 0 < 3, so neither vector dominates. The Lamport scalars print L = 3 < L = 4, which looks ordered — the trap: scalars manufacture order where none exists. (Lab line 3 is the other half: equal scalars, equally silent.)
  1. Two clients write the same key nearly simultaneously; the write with the genuinely newer value carries an older timestamp because its node's clock is behind. LWW resolves to the "newer" timestamp and silently discards the actual later write — as a normal update, so no error or detector fires. Fix ladder: client timestamps → version vectors → CRDT value types.
  1. Participants hold locks and in-doubt prepared state until a new coordinator recovers the decision from the old one's log — blocked for failover time, potentially minutes. Raft's structural difference: the decision lives in a majority of replicas — any surviving majority is the outcome, and the window closes by election.
  1. Nothing was lost, because nothing was promised. Entries 4 and 5 existed on 2 of 5 nodes — below the majority barrier — so CI never advanced past 3; no client was acked. Raft's contract: acked ⇒ committed ⇒ on a majority ⇒ survives any failover. The real bug is client code treating "accepted by the old leader" as "durable".
  1. Each core's store buffer ↔ each node's private, unsynchronized state; the missing fence ↔ the missing quorum/consensus round; the (0,0) outcome ↔ a read that misses an acknowledged write. The rung forbidding it is linearizable — real time respected: ack at t forces reads after t to see it. Sequential permits it: SC ignores real time, as the interleaving fiction let the stale interleaving stand.