Distributed systems theory
The capstone designs a system that spans many machines — and the moment state is replicated across nodes, a new body of theory applies. The fallacies, consensus (Raft, Paxos), quorums (R+W>N), commit protocols, logical clocks, and the consistency spectrum from linearizable to eventual — with runnable quorum and vector-clock models, closing the CAP story from SD6 with PACELC.
Learning objectives
- Recite the fallacies of distributed computing and why each bites.
- Explain consensus and how Raft elects a leader and replicates a log.
- Compute quorum requirements (R + W > N) and reason about the trade-off.
- Contrast 2-phase and 3-phase commit and order events with vector clocks.
- Place a system on the consistency spectrum and state CAP/PACELC.
1 · The fallacies of distributed computing
Peter Deutsch and colleagues catalogued the false assumptions engineers make when they first build across machines. Every one of them causes a real outage class. The network is not reliable, latency is not zero, bandwidth is not infinite, the network is not secure, topology changes, there is not one administrator, transport cost is not zero, and the network is not homogeneous. The discipline of distributed systems is essentially the art of not believing these.
| Fallacy | Reality | What it causes if believed |
|---|---|---|
| The network is reliable | packets drop; nodes partition | no retries/timeouts → hangs |
| Latency is zero | cross-region RTT is tens of ms | chatty calls → slow pages |
| Bandwidth is infinite | links saturate | huge payloads → congestion |
| Topology doesn't change | nodes come and go | hard-coded hosts → outages |
| Transport cost is zero | serialization + I/O cost real time | ignored overhead → surprise bills |
2 · Consensus — Raft & Paxos
Consensus is getting a set of nodes to agree on a single value (or an ordered log of values) even though some may fail and messages may be lost. It is the foundation of replicated state machines — every node applies the same log in the same order, so they stay identical. Raft is the consensus algorithm designed to be understandable: it elects a single leader that accepts all writes, appends them to a log, and replicates them; an entry is committed once a majority has stored it. Paxos solves the same problem and came first, but its "prepare/promise/accept" phases are notoriously hard to follow — Raft trades nothing in guarantees for a lot in clarity.
| Raft | Paxos | |
|---|---|---|
| Goal | agree on a replicated log | agree on a value (multi-Paxos: a log) |
| Leader | explicit, elected | implicit (proposer) |
| Reputation | designed for understandability | correct but hard to reason about |
| Used by | etcd, Consul, CockroachDB | Chubby, Spanner (variants) |
3 · Leader election
Raft keeps time in terms (monotonic election epochs). Each node is a follower, candidate, or leader. If a follower hears nothing from a leader before a randomised election timeout, it becomes a candidate, increments the term, and requests votes. A node grants at most one vote per term; a candidate that collects a majority becomes leader. Randomised timeouts make simultaneous candidacies rare, so a split vote is unlikely and quickly resolved in the next term.
election.py# MODEL: a candidate needs a strict majority of the cluster to become leader.
def majority(n):
return n // 2 + 1 # >50% of n nodes
def election(n_nodes, votes_for_candidate):
need = majority(n_nodes)
won = votes_for_candidate >= need
return {"cluster": n_nodes, "need": need,
"got": votes_for_candidate, "elected": won}
print(election(5, 3)) # 3 of 5 -> elected
print(election(5, 2)) # 2 of 5 -> not elected
print(election(4, 2)) # 2 of 4 is NOT a majority (need 3)
{'cluster': 5, 'need': 3, 'got': 3, 'elected': True}
{'cluster': 5, 'need': 3, 'got': 2, 'elected': False}
{'cluster': 4, 'need': 3, 'got': 2, 'elected': False}
4 · Replication & quorums (R + W > N)
Data is replicated to N nodes. A write quorum W is how many must acknowledge a write; a read quorum R is how many must respond to a read. The key rule: if R + W > N, the read set and write set must overlap in at least one node, so a read is guaranteed to see the latest write — strong consistency. If R + W ≤ N, a read can miss the newest write — eventual consistency, but with lower latency and higher availability. Tuning R and W is how systems like Dynamo/Cassandra dial the CAP trade-off per operation.
quorum.py# MODEL: does this (N, R, W) configuration guarantee a read sees the latest write?
def quorum_config(N, R, W):
strong = (R + W > N) and (W > N // 2) # overlap + single-writer safety
return {
"N": N, "R": R, "W": W,
"read_write_overlap": R + W > N,
"strongly_consistent": strong,
"write_tolerates_failures": N - W, # nodes that can be down for a write
"read_tolerates_failures": N - R,
}
import json
print(json.dumps(quorum_config(3, 2, 2))) # classic strong: R+W=4>3
print(json.dumps(quorum_config(3, 1, 1))) # fast but eventual: R+W=2<=3
{"N": 3, "R": 2, "W": 2, "read_write_overlap": true, "strongly_consistent": true, "write_tolerates_failures": 1, "read_tolerates_failures": 1}
{"N": 3, "R": 1, "W": 1, "read_write_overlap": false, "strongly_consistent": false, "write_tolerates_failures": 2, "read_tolerates_failures": 2}
5 · Commit protocols — 2PC vs 3PC
To make several nodes commit a transaction atomically, two-phase commit (2PC) uses a coordinator: phase 1 prepare (everyone votes yes/no and promises to be able to commit), phase 2 commit/abort (coordinator tells everyone the outcome). 2PC is correct but blocking: if the coordinator crashes after everyone voted yes, the participants are stuck holding locks, unsure whether to commit. Three-phase commit (3PC) inserts a pre-commit phase so participants can safely time out and proceed, making it non-blocking — at the cost of an extra round-trip and assumptions (bounded delays) that rarely hold, which is why real systems prefer consensus (Raft/Paxos) over 3PC.
6 · Logical time — Lamport & vector clocks
There is no perfectly synchronised wall clock across machines, so you cannot order events by timestamp. Lamport clocks give a counter that respects causality (if A caused B, then A's counter < B's) but cannot tell whether two events are concurrent. Vector clocks fix that: each node keeps a vector of counters (one per node), so you can compare two events and decide happened-before, happened-after, or concurrent — which is exactly what a system needs to detect conflicting writes.
vector_clock.py# MODEL: each node holds a vector of counters. Compare two vectors to decide the
# causal relationship between the events they stamp.
def tick(clock, node): # local event on `node`
c = dict(clock); c[node] = c.get(node, 0) + 1; return c
def merge(local, incoming, node): # receive a message: merge then tick
c = {n: max(local.get(n, 0), incoming.get(n, 0)) for n in set(local) | set(incoming)}
c[node] = c.get(node, 0) + 1; return c
def relation(a, b):
nodes = set(a) | set(b)
le = all(a.get(n, 0) <= b.get(n, 0) for n in nodes)
ge = all(a.get(n, 0) >= b.get(n, 0) for n in nodes)
if le and ge: return "equal"
if le: return "a happened-before b"
if ge: return "b happened-before a"
return "concurrent (conflict!)"
# A does a local event, sends to B; B also did an independent local event
a1 = tick({}, "A") # {A:1}
b1 = tick({}, "B") # {B:1} -- independent of a1
b2 = merge(b1, a1, "B") # B receives A's message
print("a1 vs b2:", relation(a1, b2)) # a1 happened-before b2
print("a1 vs b1:", relation(a1, b1)) # concurrent -> a real conflict
a1 vs b2: a happened-before b
a1 vs b1: concurrent (conflict!)
7 · Consistency models & CAP → PACELC
Consistency is a spectrum, not a switch. Linearizable (strongest): every operation appears to happen instantly at a single point — reads always see the latest write. Sequential/causal: weaker orderings that still respect some structure. Eventual (weakest): if writes stop, replicas eventually converge, but a read may be stale in the meantime. Stronger consistency costs latency and availability.
| Model | Guarantee | Cost | Example |
|---|---|---|---|
| Linearizable | reads see latest write, single global order | highest latency | etcd, Spanner |
| Causal | causally-related ops seen in order | moderate | some session stores |
| Eventual | replicas converge if writes stop | lowest latency, may be stale | DNS, Dynamo/Cassandra (tunable) |
CAP (from SD6): during a network Partition you must choose Consistency or Availability. PACELC extends it honestly: if Partition, choose A or C; Else (normal operation) choose Latency or Consistency. In other words, even with no partition, stronger consistency still costs latency — the quorum trade-off from §4, generalised.
✓ Checkpoint — you can move on when you can…
- Name three fallacies of distributed computing and the failure each causes.
- Explain how Raft elects a leader and commits a log entry via majority.
- Compute whether an (N, R, W) config is strongly consistent and why.
- Say why 2PC blocks and how consensus recovers where 2PC cannot.
- Use vector clocks to classify two events and place a system on CAP/PACELC.
Knowledge check
check yourselfA key-value store replicates each key to N = 5 nodes. The team wants a read to always see the latest committed write. Give one (R, W) pair that guarantees it, and explain the availability cost compared to R = W = 1.
Show answer
Two replicas each accept a write to the same key while partitioned; vector clocks later show the two writes are concurrent. Why can the database not simply keep the one with the later wall-clock time, and what does it do instead?
Show answer
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Majorities are the atom of consensus: a leader, a commit, and a strong read all hinge on 'more than half'.
Your task: Write a function that returns the majority size for an N-node cluster and use it to decide whether a candidate with V votes wins.
Requirements:
- Majority of N is N // 2 + 1
- A candidate wins iff its votes ≥ majority
- Show a winning and a losing vote count for an odd-sized cluster
- Show why 2 of 4 is not a majority
💡 Hint: Integer division makes this a one-liner; the subtlety is that even N wastes a node for the same fault tolerance.
Show solution
Majority is strictly more than half:
def majority(n):
return n // 2 + 1
def wins(n, votes):
return votes >= majority(n)
print("majority of 5:", majority(5)) # 3
print("3 of 5 wins:", wins(5, 3)) # True
print("2 of 5 wins:", wins(5, 2)) # False
print("2 of 4 wins:", wins(4, 2)) # False (need 3)Note 2 of 4 is not a majority — a 4-node cluster needs 3, the same as a 3-node cluster, yet tolerates only one failure. That is why consensus clusters are almost always odd-sized.
Context: R + W > N is the single most useful inequality in replicated storage: it tells you at a glance whether a config is strongly or eventually consistent.
Your task: Given N, R and W, report whether reads and writes overlap, whether the config is strongly consistent, and how many node failures each operation tolerates.
Requirements:
- read/write overlap holds iff R + W > N
- Report failures tolerated for writes (N − W) and reads (N − R)
- Classify strong vs eventual from the overlap
- Show one strong config and one eventual config for the same N
- Explain the availability cost of the strong config
💡 Hint: The overlap is purely R + W > N; the failure tolerance is what you give up to get it.
Show solution
The whole decision is the inequality R + W > N:
def classify(N, R, W):
overlap = R + W > N
return {
"overlap": overlap,
"consistency": "strong" if overlap else "eventual",
"write_failures_tolerated": N - W,
"read_failures_tolerated": N - R,
}
print("N=3 R=2 W=2:", classify(3, 2, 2)) # strong
print("N=3 R=1 W=1:", classify(3, 1, 1)) # eventualR=W=2 on N=3 overlaps (4 > 3) so it is strongly consistent, but each op tolerates only one node down. R=W=1 tolerates two failures and is fast, but a read can miss the latest write — eventual consistency. Same N, opposite ends of CAP.
Context: Vector clocks are how a distributed store decides whether two writes conflict — the prerequisite for any conflict-resolution policy.
Your task: Implement vector-clock tick and merge operations and a comparator that reports happened-before, happened-after, or concurrent for two event stamps.
Requirements:
- tick increments the acting node's own counter
- merge takes the element-wise max of two vectors, then ticks the receiver
- Comparator: a ≤ b element-wise → a before b; ≥ → after; neither → concurrent
- Show a causally-ordered pair and a concurrent pair
- Explain what 'concurrent' obliges the system to do
💡 Hint: Compare element-wise across the union of node keys; 'concurrent' is when neither ≤ nor ≥ holds.
Show solution
Tick on local events, merge on receive, compare element-wise:
def tick(clock, node):
c = dict(clock); c[node] = c.get(node, 0) + 1; return c
def merge(local, incoming, node):
keys = set(local) | set(incoming)
c = {n: max(local.get(n, 0), incoming.get(n, 0)) for n in keys}
c[node] = c.get(node, 0) + 1; return c
def relation(a, b):
keys = set(a) | set(b)
le = all(a.get(n, 0) <= b.get(n, 0) for n in keys)
ge = all(a.get(n, 0) >= b.get(n, 0) for n in keys)
if le and ge: return "equal"
if le: return "a before b"
if ge: return "b before a"
return "concurrent"
a1 = tick({}, "A")
b1 = tick({}, "B")
b2 = merge(b1, a1, "B") # B receives A's event
print(relation(a1, b2)) # a before b
print(relation(a1, b1)) # concurrenta1 is causally before b2 because B merged A's vector in. a1 and b1 are concurrent — neither vector dominates — which is precisely the signal that the two writes conflict and the system must reconcile them rather than pick by timestamp.
Context: Randomised election timeouts are Raft's trick for avoiding split votes; modelling a round shows why they converge.
Your task: Model one election round where each follower has a random timeout, the first to time out becomes a candidate and requests votes, and it wins iff it gathers a majority — repeating on a split vote.
Requirements:
- Give each node a random timeout; the minimum triggers candidacy
- The candidate collects votes; each node votes once per term
- A majority elects the leader; otherwise increment the term and retry
- Show a clean election and (by forcing a tie) a split vote resolving next term
- Explain why randomisation makes repeated splits unlikely
💡 Hint: Seed the RNG for reproducibility; a split is when no candidate reaches the majority in a term, so you bump the term and re-run.
Show solution
One reproducible election round with randomised timeouts:
import random
def election_round(nodes, term, rng):
timeouts = {n: rng.uniform(0.15, 0.30) for n in nodes} # randomised
candidate = min(timeouts, key=timeouts.get) # first to time out
need = len(nodes) // 2 + 1
# each other node votes for the candidate if it hasn't voted this term
votes = 1 + sum(1 for n in nodes if n != candidate) # all grant (no split here)
if votes >= need:
return {"term": term, "leader": candidate, "votes": votes}
return {"term": term, "leader": None, "votes": votes} # split -> retry
def elect(nodes, seed=1):
rng = random.Random(seed); term = 0
while True:
term += 1
r = election_round(nodes, term, rng)
if r["leader"] is not None:
return r
print(elect(["n1", "n2", "n3", "n4", "n5"]))The node with the smallest random timeout becomes candidate first and, gathering a majority, wins in term 1. Randomised timeouts are what make a split vote (two candidates dividing the votes so neither reaches a majority) rare: if it does happen, the term increments and a fresh round with new random timeouts almost always breaks the tie.
Context: The value of a quorum config only shows under failure: with the right R and W a read still finds the latest write even with a node down.
Your task: Simulate N replicas holding versioned values, perform a W-quorum write, take a node down, then perform an R-quorum read and show it still returns the latest version when R + W > N.
Requirements:
- Model each replica as a (version, value) cell
- A write updates any W reachable replicas to a new version
- Mark one replica as failed (unreachable)
- An R-quorum read returns the highest version among R reachable replicas
- Show the read sees the latest write iff R + W > N, and can miss it otherwise
💡 Hint: Because the write hit W replicas and the read consults R, R + W > N forces at least one replica in both sets — that overlap carries the latest version.
Show solution
Write to W replicas, fail one, read from R, take the newest version seen:
def simulate(N, R, W):
replicas = [{"version": 0, "value": None} for _ in range(N)]
# W-quorum write of version 1 to the first W replicas
for i in range(W):
replicas[i] = {"version": 1, "value": "v1"}
failed = N - 1 # take the LAST replica down
reachable = [i for i in range(N) if i != failed]
# R-quorum read: consult the LAST R reachable replicas, take highest version
read_set = reachable[-R:]
seen = max(replicas[i]["version"] for i in read_set)
return {
"N": N, "R": R, "W": W,
"R+W>N": R + W > N,
"read_set": read_set,
"read_saw_version": seen,
"saw_latest": seen == 1,
}
print(simulate(3, 2, 2)) # R+W=4>3 -> read sees version 1 even with a node down
print(simulate(3, 1, 1)) # R+W=2<=3 -> read reads replica 1, misses the writePrints saw_latest: True for (3,2,2) and saw_latest: False for (3,1,1). With N=3, R=2, W=2 the write covered two replicas and the read consults two of the two survivors — the sets must overlap, so the read returns version 1 despite the failure. With R=W=1 the read consults replica 1, which the single-replica write never touched, and sees the stale version 0. The overlap forced by R + W > N is the entire guarantee.
Context: Architecture reviews constantly ask 'what consistency does this give us and what does it cost' — PACELC is the vocabulary for answering precisely.
Your task: Build a small classifier that, given a system's partition behaviour and its no-partition behaviour, prints its PACELC class, and apply it to a strongly-consistent store, an eventually-consistent store, and a tunable one.
Requirements:
- Represent the two choices: on Partition (A or C), Else (L or C)
- Emit the PACELC label (e.g. PC/EC, PA/EL, PC/EL)
- Classify a linearizable store, an eventual store, and a tunable-quorum store
- Tie each classification back to its R/W or replication behaviour
- State that CAP alone would hide the Else (latency-vs-consistency) choice
💡 Hint: The Else branch is the everyday cost CAP omits: even with no partition, a strong read pays quorum latency.
Show solution
Encode both PACELC choices and classify three systems:
def pacelc(on_partition, on_else):
"""on_partition in {A, C}; on_else in {L, C}. Returns the PACELC label."""
assert on_partition in ("A", "C") and on_else in ("L", "C")
return f"P{on_partition}/E{on_else}"
systems = {
"etcd (Raft, linearizable)": pacelc("C", "C"), # PC/EC
"Cassandra (eventual, tunable)": pacelc("A", "L"), # PA/EL
"Spanner-like (consistent, fast)": pacelc("C", "L"), # PC/EL
}
for name, cls in systems.items():
print(f"{name:34s} -> {cls}")etcd chooses consistency on a partition and pays consistency latency otherwise (PC/EC). A tunable eventual store like Cassandra stays available and fast (PA/EL) by using low R/W quorums. A Spanner-like store keeps consistency on partition but is engineered for low latency otherwise (PC/EL). CAP alone would only tell you the first letter — PACELC surfaces the everyday latency-vs-consistency choice the quorum config in §4 actually makes.