Skip to content
About
Main content

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 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 () 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 : a number that grows on every grant, checked by the resource itself.

See 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

Of all pairs of 3-node subsets out of 6 nodes, how many share no node?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    why-two-majorities-always-intersect-and.py63 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat 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

Part (a): pairs of quorums with no shared node
NQuorum sizePairsDisjoint pairs
53450
6319010
641050

Part (b): a client paused past its lock

Part (b): a client paused past its lock
StorageWhat happenedFinal value
accepts any writeB wrote (token 2); A wrote (token 1)A
checks the fencing tokenB wrote (token 2); A rejected (token 1 < 2)B
Reference runApple M2 · 8 cores · 8 GB
Output · uv run why-two-majorities-always-intersect-and.py
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: B
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

What 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 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.