cld-toys › Toys › gossip-protocol

Commentary: gossip-protocol

"It reaches every node in O(log n) rounds" is true, which is exactly why it is not the result. Half the cluster costs 10.01 rounds; the last node costs 8.19 more, and 63.6% of every message ever sent goes to the final 5% of nodes. A study guide for gossip.py.

gossip-protocol/ on GitHub·the source with line numbers, for reading rather than downloading
How to read this This is the only documentation the toy has — read it with gossip.py open beside you. gossip.py is the toy itself (140 lines, five functions, no classes); demo.py runs the seven measurements this page is built on; test_gossip.py pins all 16 claims. Stdlib only, nothing written to disk. There is no wall clock anywhere: a round is an integer, every RNG is explicitly seeded, and the contact schedule is drawn once and replayed by all three modes, so the numbers below are reproducible byte-for-byte. Every transcript was captured from a real run on macOS 26.5.2 (Darwin 25.5.0, arm64 Apple Silicon), Python 3.15.0a8 (main, Apr 14 2026, 14:20:41) [Clang 22.1.3].
cd gossip-protocol
python3 demo.py         # the aha (§6) — about 25 seconds
python3 test_gossip.py  # pins every number this page claims — about 14 seconds
Contents
  1. Orientation
  2. The problem this mechanism exists to solve
  3. Background you need
  4. The mental model
  5. Reading the source
  6. The demo, and what it proves
  7. Design decisions and roads not taken
  8. What's simplified vs. the real thing
  9. Check yourself
  10. Further reading

1. Orientation

This toy is an epidemic dissemination protocol — the mechanism under Cassandra's gossip, Consul and Serf's membership layer, Redis Cluster's heartbeat bus, and Amazon Dynamo's original failure detector. One node learns something; every node has to end up knowing it; there is no coordinator and no broadcast primitive. Each node, once per round, calls fanout uniformly random peers, and the news travels along that call.

Three directions the news can travel, all implemented here:

The three modes replay one identical contact schedule, so they disagree about the direction of travel and never about who talked to whom. The shortest version of the result:

from gossip import schedule, simulate

sched = schedule(1024, 1, 40, seed=0)          # one plan, replayed three ways
for mode in ("push", "pull", "pushpull"):
    left = [1024 - c for c in simulate(1024, sched, mode=mode)["curve"]]
    print(f"{mode:9s} rounds={len(left)-1:2d}  tail={left[-6:]}")
# push      rounds=20  tail=[5, 2, 1, 1, 1, 0]
# pull      rounds=13  tail=[681, 437, 165, 22, 1, 0]
# pushpull  rounds= 9  tail=[943, 809, 525, 165, 18, 0]

Read the push tail again. It sits at one ignorant node for three consecutive rounds while 1023 nodes gossip at full volume. Pull, on the same arrows, walks 681 → 437 → 165 → 22 → 1 → 0 and is finished.

By the end you should be able to:


2. The problem this mechanism exists to solve

A cluster has to agree on something small and constantly changing: who is alive, which node owns which shard, what the current config version is. The obvious designs both fail at scale. A coordinator that mails everybody is an O(n) bottleneck at one machine and a single point of failure. A reliable broadcast primitive over an unreliable network is expensive and, in practice, is what you were trying to avoid building.

Epidemic dissemination trades certainty for cheapness: every node tells a random peer, forever, and the news spreads like a disease. Demers et al. reached for it because the deterministic design had melted their network — "For a domain stored at 300 sites, 90,000 mail messages might be introduced each night. This was far beyond the capacity of the network, and resulted in breakdowns in all the network services."

The competing goals that make more than one design defensible:


3. Background you need

ConceptWhere it's used in the toyLink
Simple epidemic (SI model) inf is a bytearray of one bit per node: susceptible or infective, no third state Demers et al., §1
Push vs. pull vs. push-pull the two if statements in simulate's inner loop, lines 84–89 Demers et al., §1.3
Synchronous rounds for r in range(cap), and the snap that freezes state inside one round Karp et al., §1.2 "the random phone call model"
The residual and its recurrence §6.4: push divides s by e per round, pull squares it Demers et al., p. 6
(1 − 1/n)n → 1/e the arithmetic that explains why push stalls on the last node e (mathematical constant)
Fanout schedule(n, fanout, …); each node calls that many peers per round memberlist GossipNodes
Anti-entropy vs. rumor-mongering the toy ships only anti-entropy; §7 measures what the other costs Demers et al., §1.4
Common random numbers one schedule shared by all three modes, so a comparison isn't a coin flip Variance reduction

The three starred rows carry the result. Everything on this page follows from one asymmetry: under push, an ignorant node has to be found, and the probability of it being missed by all n−1 callers converges to 1/e. Under pull, the ignorant node does its own asking and cannot be overlooked.


4. The mental model

ROUND r. Every node places `fanout` calls to uniformly random peers. The arrows are FIXED by the schedule; only the direction of travel differs. node i node j +-----------+ call i->j +-----------+ | knows? | --------------------> | knows? | +-----------+ +-----------+ push i knows, j doesn't => j learns (news follows the call) pull j knows, i doesn't => i learns (news comes back) push-pull either of the above => both end up knowing The population, seen as "how many still DON'T know" (the residual s): s = 900 ................................ every call finds someone s = 100 .......... ignorant. Cheap. s = 10 . s = 1 . <-- 1023 nodes are still calling, every round, and at most ONE of those calls can teach anybody. PUSH shrinks s by a constant factor: s' = s/e ln(n) rounds PULL squares it: s' = s*s/n log log n rounds s: 1023 -> ... -> 437 -> 165 -> 22 -> 1 -> 0 (pull) s: 1023 -> ... -> 47 -> 17 -> 5 -> 2 -> 1 -> 1 -> 1 -> 0 (push) ^^^^^^^^^^ three rounds, 1023 callers, no progress

The whole page is that last picture. A push epidemic finishes when the last ignorant node is found by accident, and the cluster is paying full price for every round it spends looking.


5. Reading the source

The schedule comes first, because it is what makes the comparison honest:

gossip.py · lines 27–48
def schedule(n, fanout, rounds, seed):
    """plan[r][i] = the `fanout` peers node i calls in round r.

    Peers are drawn with replacement from the other n-1 nodes: `j >= i` is
    bumped so a node never calls itself. Both of those are measured to be
    inert at n=1024 (see commentary section 5) — the schedule is generated
    up front purely so that push, pull and push-pull can replay it.
    """
    rng = random.Random(seed)
    plan = []
    for _ in range(rounds):
        row = []
        for i in range(n):
            peers = []
            for _ in range(fanout):
                j = rng.randrange(n - 1)
                if j >= i:
                    j += 1
                peers.append(j)
            row.append(tuple(peers))
        plan.append(tuple(row))
    return plan

Generating the contact plan up front rather than drawing peers inside the simulation loop is the single decision that makes this toy a comparison instead of three unrelated runs. Push, pull and push-pull consume the same plan, so "pull is 1.31× faster at full coverage" is measured against the identical set of arrows rather than against a different roll of the dice. Statisticians call this common random numbers; here it also means the seed-0 traces in §6.2 can be read side by side.

The two lines that look like judgement calls in this function — j >= i: j += 1 (never call yourself) and drawing with replacement so a fanout of 3 can draw the same peer twice — are both measured inert at n=1024, 200 seeds:

B. self-exclusion `if j >= i: j += 1` vs peers drawn from all n mode exclude allow ratio push 18.20 17.89 0.984 pull 13.89 13.87 0.998 pushpull 9.18 9.24 1.007 C. fanout 3: with replacement (shipped) vs distinct peers mode with-repl distinct ratio push 8.19 8.19 1.001 pull 7.08 7.04 1.004 pushpull 5.05 5.06 0.999

18.20 rounds against 17.89 if self-calls are allowed, and 8.19 against 8.19 for distinct vs. repeated peers. The docstring says so, because a reader is entitled to know which lines were tested and which were merely written.

The contract of simulate is where the toy's two accounting choices live:

gossip.py · lines 51–66
def simulate(n, sched, mode="pushpull", max_rounds=None, start=0,
             lazy=False, live=False):
    """Run one epidemic over a fixed contact schedule.

    lazy   charge only the nodes with a reason to call. A push from a node
           with no news, or a pull by a node that already knows, cannot teach
           anyone anything, so this changes the bill and not the outcome.
    live   read the infection set live instead of from a start-of-round
           snapshot, letting a node infected earlier in this round spread
           within it. This is the load-bearing line; False is the honest
           model of a synchronous round.

    Returns a dict whose `curve[r]` is how many nodes know at the START of
    round r. A run that exhausts `max_rounds` comes back converged=False
    rather than looping until it finishes.
    """

max_rounds is not defensive programming. A gossip simulation that loops until convergence is a program that can hang, and the rumor-mongering variant in §7 genuinely never converges in 99% of runs — so "ran out of rounds" has to be a result the caller can read (converged=False), not a stall.

Now the loop itself, sixteen lines that contain the entire mechanism:

gossip.py · lines 76–91
for r in range(cap):
    if known == n:
        break
    snap = inf if live else bytes(inf)   # <-- the load-bearing line
    calls += callers(mode, n, known, lazy) * fanout
    newly = set()
    for i, peers in enumerate(sched[r]):
        for j in peers:
            if mode != "pull" and snap[i] and not snap[j]:
                newly.add(j)
                inf[j] = 1
            if mode != "push" and snap[j] and not snap[i]:
                newly.add(i)
                inf[i] = 1
    known += len(newly)
    curve.append(known)

Three things are worth the space.

The two ifs are one mechanism read in two directions. mode != "pull" is the push arm: the caller has it, the callee doesn't, so the callee learns. mode != "push" is the pull arm, with i and j swapped. Push-pull is not a third algorithm — it is the same call with neither arm disabled, which is why it never loses to either.

snap = inf if live else bytes(inf) is the load-bearing line, and it is load-bearing in a specific, checkable way. Writes always go to inf; reads always go through snap. When snap is a copy, a node infected in round r cannot spread until round r+1 — a real synchronous round. When snap is inf, infection chains within a single round in node-index order. That one binding changes every constant on this page (200 seeds, checks/cf1_counterfactuals.py):

A. LOAD-BEARING? snap = bytes(inf) vs snap = inf mode snapshot live ratio push 18.20 14.56 1.250 pull 13.89 10.59 1.313 pushpull 9.18 6.38 1.438 push/pushpull: snapshot 1.98x, live 2.28x

And here is why it is worth a paragraph rather than a caveat: the aha gets stronger under the variant. Push against push-pull goes from 1.98× to 2.28×. The line sets the constants; it does not manufacture the result. A counterfactual that flips the conclusion means the toy is about the line. One that moves the numbers and widens the gap means the toy is about the mechanism.

newly is a set, and known += len(newly) is why. Under push, two different callers can reach the same ignorant node in one round; the set de-duplicates them, so the coverage curve counts nodes and never double-counts an arrival.

The charging model is separated out on purpose, because it is the part of the measurement people argue about:

gossip.py · lines 97–108
def callers(mode, n, known, lazy=False):
    """How many nodes actually place a call in a round that starts with
    `known` nodes informed.

    Naive accounting charges everybody every round. Lazy accounting is the
    steelman for push: a node with nothing to say stays silent, and under
    pull a node that already knows has nothing to ask for. Push-pull cannot
    be lazy — every node has one of the two reasons, always.
    """
    if not lazy or mode == "pushpull":
        return n
    return known if mode == "push" else n - known

Naive accounting ("everybody calls every round") makes push-pull look strictly dominant, and §6.6 shows that conclusion is false. A real push implementation does not wake up a node with no news to make an empty RPC, so charging it for one is not a model, it is a thumb on the scale. Note that lazy cannot change the coverage curve — a push from a node that knows nothing, and a pull by a node that already knows, are both no-ops in the loop above — which test_gossip.py::test_lazy_accounting_changes_the_bill_and_not_the_outcome asserts directly.

Finally, the accounting that turns a coverage curve into the aha:

gossip.py · lines 121–140
def band_costs(curves, n, fanout, bands, mode="push", lazy=True):
    """Attribute every call placed to the coverage band its round STARTED in.

    A round that begins with c nodes informed places callers(...) * fanout
    calls and ends with curve[r+1] informed, so the spending and the nodes it
    bought are charged to the same band. That pairing is the entire cost
    argument: the bands cover equal fractions of the cluster at wildly
    unequal prices.
    """
    calls = {b: 0 for b in bands}
    gained = {b: 0 for b in bands}
    for curve in curves:
        for r in range(len(curve) - 1):
            frac = curve[r] / n
            for b in bands:
                if b[0] <= frac < b[1]:
                    calls[b] += callers(mode, n, curve[r], lazy) * fanout
                    gained[b] += curve[r + 1] - curve[r]
                    break
    return calls, gained

The break matters: bands are half-open and disjoint, so every round's spending lands in exactly one bucket and nothing is counted twice or dropped. test_gossip.py::test_band_costs_conserves_both_columns checks both columns against independent totals — sum(gained) == seeds × (n−1) and sum(calls) equal to the calls figure simulate returned — because a headline of "63.6% of messages" is worthless if the denominator quietly leaks rows.


6. The demo, and what it proves

python3 demo.py runs seven measurements at n=1024. Every table below is copied from its stdout. Seed counts are stated per section because they differ: 200 seeds for the main figures, 300 for the coverage-target sweep, 100 for the fanout sweep.

6.1 The claim everyone predicts correctly

=== 0. the claim everybody predicts correctly (200 seeds, push, fanout 1) === n r100(mean) r100/log2(n) log2 n + ln n residue 8 5.46 1.820 5.08 0.38 32 9.29 1.859 8.47 0.83 128 12.90 1.843 11.85 1.05 512 16.52 1.836 15.24 1.28 1024 18.20 1.820 16.93 1.26

200 seeds, push, fanout 1. r100/log2(n) sits between 1.820 and 1.859 across a 128× range of n. O(log n) is confirmed, and there is no toy in it, because that is what the reader already believed.

The last two columns are worth more than the confirmation. Demers gives the exact push constant: "For push, the exact formula is log2(n) + ln(n) + O(1) for large n." That predicts 16.93 rounds at n=1024 and the toy measures 18.20, so this round model's O(1) is 1.26 — and it grows slowly with n (0.38, 0.83, 1.05, 1.28, 1.26) rather than being constant over this range. The asymptotic ratio is 1 + ln 2 = 1.693; the measured 1.82 is that plus O(1)/log₂ n. The column being flat is partly luck: the residue grows at almost exactly the rate that divides it out.

6.2 Where the time actually goes

Rounds to reach a given fraction of the cluster, 200 seeds:

mode 25% 50% 75% 90% 99% 100% push 9.00 10.01 11.62 12.96 15.15 18.20 pull 9.19 10.45 11.44 12.18 13.15 13.89 pushpull 5.78 6.62 7.32 7.80 8.63 9.18 push/pull 0.980 0.958 1.016 1.064 1.152 1.309

Push reaches half of 1024 nodes in 10.01 rounds and needs 8.19 more for the last one. That is 8.19/18.20 = 45.0% of the wall clock spent after half the cluster already knows. Getting from 99% to 100% costs 3.05 rounds — more than getting from 0% to 25% costs (9.00 rounds gets you 256 nodes; 3.05 more rounds at the end gets you 10).

The seed-0 traces show it without any statistics. Same schedule, three directions, "nodes that still don't know":

push 1023 1022 1020 1016 1008 992 963 906 804 647 440 246 123 47 17 5 2 1 1 1 0 pull 1023 1021 1018 1011 1000 982 935 847 681 437 165 22 1 0 pushpull 1023 1020 1013 994 943 809 525 165 18 0

Push spends rounds 17, 18 and 19 with exactly one ignorant node left, while 1023 nodes place 1023 calls per round. Pull's last three values are 22 → 1 → 0. Push-pull's are 165 → 18 → 0.

6.3 Where the messages actually go

Charging push only for calls a node had a reason to place (§5's lazy), and attributing each round's spending to the coverage band it started in — 200 seeds, per-seed averages:

band calls % of all nodes calls/node 0-25% 457.7 5.5% 368.77 1.2 25-50% 372.3 4.5% 198.87 1.9 50-75% 1030.8 12.4% 281.44 3.7 75-90% 1158.3 14.0% 120.52 9.6 90-99% 2165.9 26.1% 47.09 46.0 99-100% 3108.0 37.5% 6.32 492.2 TOTAL 8293.1 100.0% 1023.00

Read the last column. The first quarter of the cluster costs 1.2 calls per node informed. The last 1% costs 492.2 — a factor of 396. Rounds that began at ≥90% coverage burn 63.6% of the entire message budget to inform 53.4 of 1023 nodes, or 5.2% of the cluster.

The arithmetic is not subtle once you see the shape. A round starting with c nodes informed costs c calls under lazy push and gains c'−c nodes, so the unit price is c/(c'−c). Early on, c' ≈ 2c, so the price is about 1 call per node. At the end, c = 1023 and c'−c is at most 1, so the price is at least 1023 calls per node — and averaged over the runs that spend more than one round there, 492.2 for the last band as a whole. The cluster does not slow down at the end. It keeps working at exactly the same rate and stops buying anything.

6.4 Why: two decay laws, one of them quadratic

Take the residual s — nodes that still don't know — and ask what one round does to it. Start with the extreme case, one node left:

One node of 1024 is left. The other 1023 each push to a uniform peer: P(a given call misses it) = 1 - 1/1023 = 0.999022 P(all 1023 miss) = 0.999022^1023 = 0.3677 (1/e = 0.3679) expected rounds stuck at 1 = 1/0.6323 = 1.582 (given it reaches residual 1) measured push 128/200 runs pass through residual 1, mean 1.688 rounds there measured pull 39/200 runs pass through residual 1, mean 1.000 rounds there measured pushpull 26/200 runs pass through residual 1, mean 1.000 rounds there

That is the whole asymmetry in four lines. Under push, 1023 nodes fire 1023 calls at 1023 possible targets and the one that matters is missed by all of them with probability 0.3677 — which is 1/e to three decimals, and is 1/e for the usual reason, (1 − 1/m)^m → e⁻¹. So the epidemic sits at residual 1 for 1/(1−0.3677) = 1.582 rounds in expectation. Under pull, the last ignorant node places its own call, and every peer it could possibly reach already knows: probability 1023/1023 = 1.000, one round, always. The measurement is exact — no pull run in 200 spends more than a single round at residual 1, and at 1000 seeds it is still 204/204 runs at exactly 1.000 while push's mean converges toward the predicted 1.582:

derived: P(all 1023 pushes miss) = 0.3677, 1/e = 0.3679, E[rounds] = 1.582 200 seeds push 128 runs reach residual 1, mean 1.688 rounds there, max 5 200 seeds pull 39 runs reach residual 1, mean 1.000 rounds there, max 1 1000 seeds push 629 runs reach residual 1, mean 1.614 rounds there, max 7 1000 seeds pull 204 runs reach residual 1, mean 1.000 rounds there, max 1

Generalize from residual 1 to every residual, by pooling every s → s' transition across 200 seeds and bucketing by s:

s push s' s'/s pull s' pred s*s/n [ 4, 8) 2.03 0.372 0.02 0.03 [ 8, 16) 3.96 0.365 0.05 0.12 [ 16, 32) 7.95 0.380 0.64 0.50 [ 32, 64) 19.21 0.388 2.15 2.13 [ 64, 128) 45.64 0.408 8.26 8.12 [ 128, 256) 84.72 0.447 37.93 35.79 [ 256, 512) 210.37 0.548 151.80 147.49

Push divides the residual by a constant. Pull squares it. push s'/s sits at 0.365–0.408 for every small bucket, against 1/e = 0.368. The pull s' column tracks the predicted s²/n — 2.15 against 2.13, 8.26 against 8.12, 37.93 against 35.79 — at every bucket where there is data.

Dividing by a constant costs ln(n) rounds to clear. Squaring costs log log n. That is the entire push/pull story, and it is not this toy's discovery — it is Demers' 1987 recurrence, measured. The paper derives pi+1 = (pi for pull and pi+1 = pi(1 − 1/n)n(1−pi) for push, "which also converges to 0, but much less rapidly, since for very small pi (and large n) it is approximately pi+1 = pie⁻¹". Karp et al. put the same thing in mean-field form: u ≈ exp(−t/n) for push against u ≈ exp(−2t) for pull — an exponential against a double exponential.

6.5 The first boundary: your SLO decides whether any of this matters

Push/pull ratio by coverage target, 300 seeds per size:

n 25% 50% 75% 90% 99% 100% 8 0.671 0.852 0.994 1.188 1.188 1.188 32 0.900 0.977 1.014 1.078 1.223 1.223 128 0.974 0.947 0.991 1.062 1.181 1.255 1024 0.986 0.963 1.021 1.069 1.157 1.310

At 50% coverage push is faster than pull at every size (0.852–0.977). At 75% they are within 2.2% either way (0.991–1.021), and which side of 1.0 the crossing lands on flips with n — 1.014 and 1.021 at n=32 and 1024, but 0.994 and 0.991 at n=8 and 128. Do not read that column as monotone; it isn't. At 90% pull is ahead by 6–19%. Only at 100% does pull win clearly (1.19–1.31).

So: if your SLO is "90% of nodes have the new config within X", the direction of the arrow is worth a few percent and you should spend your attention elsewhere. Pull's entire advantage lives in the last 10% of nodes, and if you never wait for the last node, you never collect it. This is where the effect vanishes — and it vanishes at a coverage target most systems actually care about.

6.6 The second boundary: fanout, and a claim that did not survive

Under naive accounting push-pull looks strictly dominant, and the tempting sentence is "push-pull is free." Steelman push instead — a node with nothing to say places no call — and run the fanout sweep, 100 seeds:

mode lazy fanout rounds calls push True 1 18.20 8302 push True 2 10.72 8715 push True 3 8.21 9285 push True 5 6.07 10100 push False 1 18.20 18637 pushpull False 1 9.18 9400 pushpull False 2 6.22 12739 pushpull False 3 5.04 15483

Lazy push at fanout 3 beats push-pull at fanout 1 on both axes: 8.21 rounds against 9.18, and 9,285 calls against 9,400. "Push-pull is free" is false, and it is the sentence this page would have shipped without the run.

What replaces it is better. Fanout is nearly free. Going from fanout 1 to fanout 5 makes push 3.0× faster (18.20 → 6.07 rounds) for 21.6% more messages (8,302 → 10,100). That looks impossible until you remember §6.3: almost the whole message budget is spent in the endgame, so shortening the endgame pays for the extra calls in the rounds that remain. Fanout buys time at nearly no cost, and switching arrow direction buys a little time at a structural cost — which is the practical ranking most production systems have independently arrived at.


7. Design decisions and roads not taken

One schedule, three modes. The alternative — draw peers inside the simulation — would make each mode's run an independent sample, and a 1.31× difference measured that way is a claim about two random variables rather than about the mechanism. Sharing the schedule is what lets §6.2's three traces sit under each other and be the same experiment.

The snapshot, not live state. Reading inf live is not "wrong" — it is a different model, closer to a system where a node forwards on receipt within the same tick. The toy takes the snapshot because a synchronous round with simultaneous exchange is the model the analysis in every paper cited here assumes, and because a live read makes the outcome depend on node index order, which is an artifact of the loop rather than of the protocol. The counterfactual is in §5 and it makes the aha stronger, not weaker.

Lazy accounting ships, and is the default in band_costs. It is the honest bill and it kills a wrong claim (§6.6). It also makes the concentration worse, not better: under naive charging the ≥90% bands hold 28.8% of the budget and the last-1%-vs-first-25% ratio is 20×, against 63.6% and 396× under lazy (checks/cf4_selfcheck.py). Charging idle nodes for silence hides the skew by inflating the cheap rounds.

Rumor-mongering is not in the toy. Demers' fix for the expensive endgame is to have a node stop spreading after k contacts that taught nobody anything, converting an "infective" node to "removed". It is the natural next question after §6.3, and it is a second mental model — the toy would grow a third state, a staleness counter, and an entirely different failure mode. It is measured, though, in checks/cf2_mongering.py (n=256, push, fanout 1, 200 seeds, hard cap 60 rounds):

=== n=256 fanout=1 seeds=200 mode=push cap=60 === k converged %never mean final worst final mean calls stranded(mean) None 200 0.0% 256.00 256 1707 0.0 1 0 100.0% 211.94 194 447 44.1 2 0 100.0% 243.49 232 774 12.5 3 2 99.0% 251.99 246 1061 4.1 4 54 73.0% 254.62 251 1308 1.9 6 158 21.0% 255.79 255 1604 1.0 8 193 3.5% 255.97 255 1693 1.0 12 200 0.0% 256.00 256 1707 0.0

k=3 saves 38% of the messages (1061 against 1707) and 99.0% of runs never converge at all, stranding a mean of 4.1 nodes permanently — the epidemic dies while nodes are still ignorant, and no amount of waiting fixes it, because nobody is still spreading. You buy message savings with correctness, continuously, and there is no k that is free. Push-pull is far more robust to the same cutoff: at k=3 it converges in 200/200 runs where push needs k=12, because an ignorant node initiates its own pull and cannot be overlooked.

=== n=256 fanout=1 seeds=200 mode=pushpull cap=60 === k converged %never mean final worst final mean calls stranded(mean) None 200 0.0% 256.00 256 629 0.0 1 1 99.5% 249.04 239 403 7.0 2 168 16.0% 255.84 254 607 1.0 3 200 0.0% 256.00 256 625 0.0

One rumor bit, not a database. Real anti-entropy reconciles whole key-value states, which is why Demers spends pages on checksums, recent-update lists and "peel back". A single bit keeps the toy about dissemination dynamics; adding reconciliation would double the code and teach a different mechanism (that one is merkle-tree).

A hard max_rounds, and non-convergence as a return value. Every sweep in demo.py caps rounds and asserts convergence, so a pathological seed is a loud assertion rather than a hang. The mongering script above is the reason this is not optional.

Deliberately absent: node failures and partitions, join/leave churn, message loss, asynchronous per-node timers, network delay, topology (Demers' "spatial distributions" that favor nearby peers). Each is a real production concern and each would be a second mechanism in one file.


8. What's simplified vs. the real thing


9. Check yourself

Q1. Push at n=1024 takes 18.20 rounds to reach every node. You double the cluster to 2048. How many extra rounds, and why is the answer not "double"?

Answer

About 1.6 rounds — measured 19.78 against 18.20, a delta of 1.59 over 200 seeds (checks/cf4_selfcheck.py).

Q: doubling the cluster costs how many extra push rounds? predicted: log2(2n)+ln(2n) - (log2 n + ln n) = 1 + ln 2 = 1.693 n= 1024 r100 = 18.20 n= 2048 r100 = 19.78 delta = 1.59

Derive it from §6.1: the cost is log2(n) + ln(n) + O(1). Doubling n adds exactly 1 to log2(n) and ln 2 = 0.693 to ln(n), so the prediction is 1.693 rounds regardless of how big the cluster already is. Every doubling costs the same fixed 1.7 rounds, which is what "O(log n)" means when you spend it rather than quote it.

Q2. Your config-distribution SLO is "90% of nodes within 15 seconds", one gossip round per second. Someone proposes switching from push to pull to hit it. What do you tell them?

Answer

That it buys 6.4% and they should look elsewhere. From §6.2 at n=1024: push reaches 90% in 12.96 rounds, pull in 12.18 — ratio 1.064. Both already meet a 15-round SLO; neither has much margin.

Two better answers are in the same tables. Fanout 3 takes push to 8.21 rounds for 12% more messages (§6.6). Push-pull reaches 90% in 7.80 rounds. And if the SLO were "100% of nodes within 15", push at 18.20 misses it, pull at 13.89 makes it, and that single word is the entire decision — which is the point of §6.5.

Q3. Fanout 1 → 5 makes push 3.0× faster. Naively that should cost 5× the messages; measured it costs 1.216×. Where did the other 4× go?

Answer

Into rounds that no longer happen. Under lazy push a round costs (nodes informed) × fanout calls, so the bill is fanout × Σ curve[r] — and raising fanout collapses the number of terms in that sum, mostly by deleting the expensive ones.

From §6.3, 63.6% of the fanout-1 budget is spent in rounds that started at ≥90% coverage, where nearly every node is informed and thus charged. Fanout 5 cuts the run from 18.20 rounds to 6.07, and the rounds it deletes are precisely those end rounds at ~1023 callers each. 8302 → 10100 is what is left after that cancellation: +21.6% for 3.0× the speed.

Q4. Change snap = bytes(inf) to snap = inf. Which of these breaks: (a) push is slower than pull at full coverage, (b) the residual decay laws, (c) the 45%-of-the-clock endgame?

Answer

None of them. Every mode gets faster and the gap widens: push 18.20 → 14.56, pull 13.89 → 10.59, push-pull 9.18 → 6.38, and push/push-pull goes from 1.98× to 2.28× (200 seeds, checks/cf1_counterfactuals.py).

That is what "load-bearing for the constants, not the conclusion" means, and it is the reason the line is worth a paragraph in §5 rather than a footnote. The reason nothing structural moves: reading inf live lets a node infected earlier in the same round spread within it, which is worth roughly a fraction of a round of extra progress per round — a constant factor on the growth phase. It does nothing at all for the endgame, where the residual is 1 and there is no chain to extend.

Q5. The cost table charges only nodes that had a reason to call. Charge every node every round instead — the "everybody gossips" accounting. Does the concentration get better or worse?

Answer

It looks better, and that is an artifact.

Q: the same cost table charged naively (everybody calls, always) lazy=True total/seed 8293.1 >=90% band 63.6% first25 1.2 last1% 492.2 ratio 396x lazy=False total/seed 18631.7 >=90% band 28.8% first25 25.0 last1% 493.8 ratio 20x

Under naive charging the ≥90% bands hold 28.8% of the budget instead of 63.6%, and the last-1%-to-first-25% price ratio falls from 396× to 20×.

Nothing about the epidemic changed — the coverage curves are identical, since lazy cannot affect the loop. What changed is that naive charging bills 1024 idle nodes for the early rounds, when only a handful of nodes have anything to say. That inflates the cheap end of the table from 1.2 calls/node to 25.0 and flattens the ratio. The honest bill is the one that makes the skew look worse: total spend drops from 18,632 to 8,293 calls, and the share spent on stragglers doubles.

Q6. A 1024-node cluster gossips push-only, one round per second, and you need every node to have a config change. Your dashboard shows 99% coverage at 15 seconds. When do you page?

Answer

Not at 16 seconds. From §6.2, mean 99% coverage is 15.15 rounds and mean 100% is 18.20, so the last 1% costs about three more seconds on average — and the tail is long, because the epidemic sits at residual 1 for 1.582 rounds in expectation once it gets there, and up to 7 rounds in the worst of 1000 runs (checks/cf3_last_node.py). A 99%-to-100% gap of several seconds is the protocol working correctly.

The production lesson is the one memberlist encodes: don't fix this by switching arrows, fix it by raising fanout (GossipNodes) or by letting the ignorant node ask (pull / push-pull), which turns "be found" into "go look" — 1.582 expected rounds into exactly 1.


10. Further reading

Every link below was fetched and confirmed live when this was written.

Elsewhere in this repo, failure-detector is the other half of a real membership layer — this toy assumes every node is alive and reachable — and consistent-hashing is the other page where a true average ("about 1/N of the keys move") hides everything that matters about the distribution.