Why two majorities always intersect, and why a lock with a TTL is not enough
Simulate a modelBreak it on purposeNeeds Python
Problem
Coordination protocols get their safety from counting, not from clocks. Any two majorities of the same N nodes must share at least one node (the pigeonhole argument), so two decisions made by two majorities always have a witness in common. Locks are where clocks sneak back in. A lock with a time-to-live (TTL) expires if its holder goes quiet, so a client that pauses past the TTL can wake up still believing it holds the lock while someone else does. The defence is a fencing token: a number that grows on every grant, checked by the resource itself.
See quorum intersection and a stale lock holder (Consensus: what it delivers and what it costs and Locks, leases and fences: why mutual exclusion is unavailable).
Part (a): for N = 5, enumerate every pair of 3-node subsets of {0,...,4} with itertools.combinations. Check that every pair shares at least one node. Repeat for N = 6 with subsets of size 3: find pairs with no shared node, and explain why a majority of 6 must be 4.
Part (b): write a tiny simulation with a lock service (TTL 5 s), a client, and a storage object. Client A takes the lock, then "pauses" for 8 s (time.sleep) before writing. The lock expires, client B takes it and writes. A wakes up and writes. Now make the lock service return a fencing token (a counter that grows on every grant), send the token with each write, and make the storage reject any token lower than the highest it has seen.
The question. Do quorums of 3 out of 5 always overlap, and out of 6? And when a paused client writes after its lock expired, does the storage keep its stale write?
Predict
Every pair of 3-node quorums of 5 shares a node; quorums of 3 out of 6 do not always. Without a fence, the storage accepts both writes and A's stale write overwrites B's. With the fencing token, A's write is rejected.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
why-two-majorities-always-intersect-and.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Why two majorities always intersect, and a stale lock holder. (a) Every pair of quorums of size q out of N, checked for a shared node. (b) A lock with a TTL, a client that pauses past it, and a storage that accepts any write, then one that checks a fencing token. """ import argparse, itertools, json, threading, time p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--ttl", type=float, default=5.0, help="Lock TTL, seconds.") p.add_argument("--pause", type=float, default=8.0, help="Seconds client A pauses after taking the lock.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() # (a) quorum intersection quorums = [] for n, q in ((5, 3), (6, 3), (6, 4)): pairs = list(itertools.combinations(itertools.combinations(range(n), q), 2)) disjoint = sum(1 for a, b in pairs if not set(a) & set(b)) quorums.append({"N": n, "quorum": q, "pairs": len(pairs), "disjoint_pairs": disjoint}) print(f"N = {n}, quorums of {q}: {len(pairs):,} pairs, {disjoint} with no shared node") # (b) a lock service with a TTL and a fencing token class LockService: def __init__(self): self.holder, self.expires, self.token = None, 0.0, 0 def acquire(self, who): now = time.monotonic() if self.holder is None or now >= self.expires: self.token += 1; self.holder, self.expires = who, now + args.ttl return self.token return None def run(fenced): lock, store, log = LockService(), {"value": None, "highest": 0}, [] def write(who, token): if fenced and token < store["highest"]: log.append(f"{who} rejected (token {token} < {store['highest']})"); return store["highest"] = max(store["highest"], token); store["value"] = who log.append(f"{who} wrote (token {token})") def client_a(): tok = lock.acquire("A"); time.sleep(args.pause); write("A", tok) # paused past the TTL def client_b(): while (tok := lock.acquire("B")) is None: time.sleep(0.1) write("B", tok) ta = threading.Thread(target=client_a); ta.start(); time.sleep(0.05) tb = threading.Thread(target=client_b); tb.start(); ta.join(); tb.join() return store["value"], log rows = [] for fenced in (False, True): final, log = run(fenced) rows.append({"storage": "checks the fencing token" if fenced else "accepts any write", "events": "; ".join(log), "final": final}) print(f"{rows[-1]['storage']:<26} {'; '.join(log):<60} final value: {final}") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"quorums": quorums, "locks": rows}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run why-two-majorities-always-intersect-and.py uv run why-two-majorities-always-intersect-and.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --ttl5 | Lock TTL, seconds. |
| --pause8 | Seconds client A pauses after taking the lock. |
Our run
Part (a): pairs of quorums with no shared node
| N | Quorum size | Pairs | Disjoint pairs |
|---|---|---|---|
| 5 | 3 | 45 | 0 |
| 6 | 3 | 190 | 10 |
| 6 | 4 | 105 | 0 |
Part (b): a client paused past its lock
| Storage | What happened | Final value |
|---|---|---|
| accepts any write | B wrote (token 2); A wrote (token 1) | A |
| checks the fencing token | B wrote (token 2); A rejected (token 1 < 2) | B |
N = 5, quorums of 3: 45 pairs, 0 with no shared node
N = 6, quorums of 3: 190 pairs, 10 with no shared node
N = 6, quorums of 4: 105 pairs, 0 with no shared node
accepts any write B wrote (token 2); A wrote (token 1) final value: A
checks the fencing token B wrote (token 2); A rejected (token 1 < 2) final value: BWhat we found
All 45 pairs of 3-node quorums out of 5 shared a node. Out of 6, 10 of the 190 pairs of 3-node subsets had nothing in common: two halves of six can each decide without seeing the other. With quorums of 4 out of 6 there were 0 disjoint pairs, which is why a majority of 6 is 4.
The paused client did exactly what was expected. With a storage that accepts any write, B wrote, then A woke up and wrote, and the final value was A's stale one (A). With the fencing token, A's write was rejected and B's value stayed (B).
Read it
Safety is preserved by intersection arguments, which contain no time. Two majorities of the same set must share a member: no clock, no assumption about delay. The rule R + W > N is the same argument. An even N buys nothing: a majority of 6 is 4, and the sixth node adds cost without tolerating an extra failure.
A lock with a TTL is a liveness mechanism, so it cannot also deliver safety. The holder cannot know its lock expired while it was paused; only the resource can tell, by comparing tokens. Fencing moves the check to the one place that sees every write, and the token is the same structure that appears everywhere in coordination: a monotonically increasing integer agreed once, then stamped on everything.