LWW versus a G-counter under clock skew
Simulate a modelNeeds Python
Problem
Replicas that accept writes independently must merge their states later. Merging one integer is lossy: last-writer-wins keeps the value with the newest timestamp and throws the others away, and it trusts clocks that disagree; taking the max keeps the largest count and loses every increment the others made. A conflict-free replicated data type (CRDT) is designed so that merges never lose information. A G-counter keeps one slot per replica, merges slot by slot with max, and reports the sum, so every replica's increments survive any merge order, any number of times.
Write a tiny simulation with three replicas, each with a settable clock offset (for example replica C is 3 s ahead). Run 1,000 increments of a points counter spread over the three replicas, merge periodically in random order, and compare three designs: (a) a single integer with LWW, (b) a single integer merged with max, (c) a G-counter (per-replica slots merged with max, value = sum).
The script also delivers every merge twice in a second run, adds 300 decrements for a PN-counter, and tries a "never below zero" rule on two replicas.
The question. How many of 1,000 increments survive each merge design, does duplicated delivery change anything, and can two replicas each approve a redemption that together breaks "never below zero"?
Predict
Expected: (a) and (b) lose many increments, and in (a) the loser depends on the clock offset; (c) always ends at exactly 1,000 after all merges, even if you deliver merges twice or out of order.
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
lww-versus-a-g-counter-under.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """LWW versus a G-counter under clock skew. Three replicas, one with a clock several seconds ahead, take 1,000 increments of a points counter and merge in random order. Compared: one integer merged by last-writer-wins, one integer merged with max, and a G-counter. Then decrements (PN-counter) and a "never below zero" rule. """ import argparse, json, random p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--increments", type=int, default=1000, help="Increments spread over the replicas.") p.add_argument("--skew", type=float, default=3.0, help="Seconds replica C's clock is ahead.") p.add_argument("--merge-every", type=int, default=20, help="Operations between random pairwise merges.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() random.seed(args.seed) R = ["A", "B", "C"] offset = {"A": 0.0, "B": 0.0, "C": args.skew} def run(design, ops, dup=False): """ops: list of (replica, delta). Returns the converged value after full merges.""" state = {r: ({"v": 0, "ts": 0.0} if design in ("lww", "max") else {"P": {}, "N": {}}) for r in R} now = 0.0 def merge(a, b): # b absorbs a sa, sb = state[a], state[b] if design == "lww": if sa["ts"] > sb["ts"]: sb.update(sa) elif design == "max": sb["v"] = max(sa["v"], sb["v"]) else: for part in ("P", "N"): for k in set(sa[part]) | set(sb[part]): sb[part][k] = max(sa[part].get(k, 0), sb[part].get(k, 0)) for i, (r, d) in enumerate(ops): now += 0.01 s = state[r] if design in ("lww", "max"): s["v"] += d; s["ts"] = now + offset[r] else: part = "P" if d > 0 else "N" s[part][r] = s[part].get(r, 0) + abs(d) if i % args.merge_every == 0: a, b = random.sample(R, 2); merge(a, b) if dup: merge(a, b) # a duplicated delivery for _ in range(3): # final anti-entropy: everyone merges with everyone for a in R: for b in R: if a != b: merge(a, b) s = state["A"] return s["v"] if design in ("lww", "max") else sum(s["P"].values()) - sum(s["N"].values()) inc = [(random.choice(R), +1) for _ in range(args.increments)] rows = [] for design, label in (("lww", "one integer, last writer wins"), ("max", "one integer, merged with max"), ("g", "G-counter")): v = run(design, inc); vd = run(design, inc, dup=True) rows.append({"design": label, "value": v, "lost": args.increments - v, "value_with_duplicates": vd}) print(f"{label:<32} final {v:>5} of {args.increments} (lost {args.increments - v:>4}; with duplicated merges: {vd})") # who wins under LWW depends on the clock: C (ahead) overwrites the others skewless = args.skew; offset["C"] = 0.0 v0 = run("lww", inc); offset["C"] = skewless print(f"LWW with no skew: {v0} (with C {args.skew:g} s ahead: {rows[0]['value']})") # decrements: a G-counter cannot take them; a PN-counter (two G-counters) can mixed = inc + [(random.choice(R), -1) for _ in range(300)] random.shuffle(mixed) pn = run("g", mixed) print(f"PN-counter with {args.increments} increments and 300 decrements: {pn} (expected {args.increments - 300})") # "never below zero": two replicas each allow a redemption against the same balance of 100 balance, redeem = 100, 80 local = {"A": balance, "B": balance} ok = [r for r in ("A", "B") if local[r] - redeem >= 0] # each checks only its own copy merged = balance - redeem * len(ok) print(f"two replicas each approve a {redeem}-point redemption from {balance}: merged balance {merged}") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"designs": rows, "summary": [{ "lww": rows[0]["value"], "max": rows[1]["value"], "g": rows[2]["value"], "lww_noskew": v0, "pn": pn, "pn_expected": args.increments - 300, "negative_balance": merged}]}}, 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 lww-versus-a-g-counter-under.py uv run lww-versus-a-g-counter-under.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --increments1000 | Increments spread over the replicas. |
| --skew3 | Seconds replica C's clock is ahead. |
| --merge-every20 | Operations between random pairwise merges. |
| --seed0 | Random seed. |
Our run
1,000 increments over three replicas
| Design | Final value | Lost | With duplicated merges |
|---|---|---|---|
| one integer, last writer wins | 321 | 679 | 321 |
| one integer, merged with max | 377 | 623 | 371 |
| G-counter | 1,000 | 0 | 1,000 |
one integer, last writer wins final 321 of 1000 (lost 679; with duplicated merges: 321)
one integer, merged with max final 377 of 1000 (lost 623; with duplicated merges: 371)
G-counter final 1000 of 1000 (lost 0; with duplicated merges: 1000)
LWW with no skew: 359 (with C 3 s ahead: 321)
PN-counter with 1000 increments and 300 decrements: 700 (expected 700)
two replicas each approve a 80-point redemption from 100: merged balance -60What we found
Last-writer-wins ended at 321 and max at 377: both lost most of the 1,000 increments, as expected. Under LWW the clock decided which replica's count survived: with replica C 3 s ahead the result was 321, with no skew 359. The G-counter ended at exactly 1,000, with merges delivered once or twice. With 300 decrements added, the PN-counter (two G-counters, one for increments and one for decrements) ended at 700, the expected 700. And two replicas that each approved an 80-point redemption against a balance of 100, checking only their own copy, merged to -60.
Read it
Then add a decrement operation to (c) and show why a PN-counter (two G-counters) is needed. Last, add a "never below zero" rule and show that two replicas can each allow a redemption that together go negative.
A G-counter works because its merge is commutative, associative and idempotent: order and duplication cannot change the result. That is why it needs no coordination. The "never below zero" rule is different in kind: it is a bound on a quantity that operations push toward it, which is not invariant-confluent, so no merge function can enforce it after the fact. It needs coordination at the moment of the redemption, or escrow: give each replica its own share of the balance to spend.