Skip to content
About
Main content

LWW versus a G-counter under clock skew

Simulate a modelNeeds Python

Problem

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 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 , (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 , 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

Of 1,000 increments spread over three replicas, how many survive a last-writer-wins merge?

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.

    lww-versus-a-g-counter-under.py90 linesDownload
    View 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)
    
  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 lww-versus-a-g-counter-under.py
    uv run lww-versus-a-g-counter-under.py --help     # every knob
Knobs
Knob · defaultWhat 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

1,000 increments over three replicas
DesignFinal valueLostWith duplicated merges
one integer, last writer wins321679321
one integer, merged with max377623371
G-counter1,00001,000
Reference runApple M2 · 8 cores · 8 GB
Output · uv run lww-versus-a-g-counter-under.py
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 -60
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

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