TRENDING
Galvanized steel guardrail bolted to wooden posts along the edge of a bridge approach, with a grassy verge and a gravel road beside it
October 1, 2026
How to Enforce Guardrails on AI-Generated Terraform With Open Policy Agent and Rego
Microscope die shot of an AMD EPYC 7702 engineering sample I/O die, its circuit blocks glowing in teal, gold and violet
October 1, 2026
AMD Agrees to Buy Fei-Fei Li’s World Labs for $8.2 Billion to Steer Its Chip Roadmap
A silver signet ring engraved with a coat of arms between two sticks of red sealing wax on a grey surface
October 1, 2026
How to Build a Merkle Tree Certificate Issuer in Python to Keep Post-Quantum Certificates Small
Brass swing-bar door lock, a secondary latch, mounted on a hotel room door
October 1, 2026
Cloudflare’s Post-Quantum Visibility Turns Quantum Readiness Into a Per-Hop Audit
A seven-spot ladybird with black spots on its orange shell climbs a green plant stem
October 1, 2026
OpenAI Launches Dots, Always-On Agents, and Says It Is Still Fixing Known Vulnerabilities
01 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
Faint white watermark of a crown above an oval emblem showing through blue paper, a design that stays invisible until light passes through the sheet
How to Detect and Strip Invisible Unicode in Python to Stop ASCII Smuggling and Trojan Source
September 30, 2026
A small white wooden toll booth with a Pay Point sign and a fare board at Penmaenpool Toll Bridge, with orange traffic cones on the bridge deck
Two Cloudflare Agent Billing Betas Turn Web Monetization Into a Question of Who Holds the Meter
September 30, 2026
Eight silver hex keys of graduated sizes fanned out on a steel ring against a dark green surface
Attackers Exploit a Hex-Encoding Bypass in Cisco SD-WAN Manager, and CISA Sets an October 3 Deadline
September 30, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 216 Posts
News 218 Posts
Learning Hub 188 Posts
Home/Learning Hub/How to Build a CRDT Counter in Python So Distributed Nodes Never Lose an Increment
Learning Hub

How to Build a CRDT Counter in Python So Distributed Nodes Never Lose an Increment

Build a grow-only and positive-negative CRDT counter in Python, then prove its merge logic mathematically guarantees replicas never lose a concurrent increment, no matter what order they sync in.

September 27, 2026 14 Min Read
13

If you run the same service on two servers in two regions, and both servers can accept writes even when they can’t talk to each other, you eventually have to merge their state back together. Most naive approaches to that merge quietly throw data away. In this tutorial you will build a small class of data structure, called a CRDT (Conflict-Free Replicated Data Type), that merges two independently updated counters back into one correct value automatically, no matter which order the merges happen in and no matter how many times a sync message gets replayed.

Table Of Content

  • What a CRDT actually is, in plain terms
  • Prerequisites
  • Step 1: Reproduce why the obvious approach fails
  • Step 2: Build a G-Counter (Grow-Only Counter)
  • Why does that guarantee actually hold? Prove it, don’t assume it
  • Step 3: Handle decrements with a PN-Counter
  • Step 4: The gotcha, why you can’t just “reset” a slot
  • Step 5: A realistic 3-node simulation with out-of-order gossip
  • Step 6: Verify everything with pytest
  • Common mistakes to watch for
  • How to confirm it all works end to end
  • Next steps

By the end you will have a working GCounter (grow-only counter) and PNCounter (increment-and-decrement counter) in plain Python, a reproduced bug that shows exactly why the obvious “just copy the newer value” approach fails, a proof (not just an assertion) that the merge logic is mathematically safe under any ordering, and a 3-node simulation that survives a network partition and out-of-order gossip without losing a single count.

What a CRDT actually is, in plain terms

A CRDT is a data structure with one very specific promise: any two replicas of it, no matter how their local histories diverged, can always be merged back into a single consistent state, with no conflicts and no coordination between the replicas required at write time. This is different from asking a database to lock a row, or from routing every write through one leader node. With a CRDT, every replica accepts writes independently and locally, and a merge() function reconciles them later.

For that to work safely, the merge function has to satisfy three properties, all of which you will prove empirically later in this tutorial rather than just take on faith:

  • Commutative: merge(A, B) produces the same result as merge(B, A). The order two replicas sync in should never matter.
  • Associative: merge(merge(A, B), C) produces the same result as merge(A, merge(B, C)). It should not matter which pair merges first when three or more replicas are involved.
  • Idempotent: merge(A, A) produces exactly A. If a flaky network delivers the same sync message twice, applying it twice must not corrupt anything.

The concept was formally defined in 2011 by Marc Shapiro, Nuno Preguiça, Carlos Baquero, and Marek Zawirski (see the Wikipedia summary of the formal definition and known CRDT types), and today it underpins real production systems: Redis Software’s Active-Active (geo-distributed) databases are built on CRDT technology specifically so that applications in different regions can read and write the same data set with sub-millisecond local latency and automatic conflict resolution, and Riak and Cosmos DB expose CRDT data types as well. The two simplest and most teachable CRDTs, and the ones you’ll build here, are the G-Counter and the PN-Counter.

Prerequisites

  • Python 3.10 or later (this tutorial was built and tested on 3.13.14). No third-party packages beyond pytest are required; everything else is the standard library.
  • pytest installed (pip install pytest) to run the verification suite in the last step.
  • Comfort with basic Python: classes, dictionaries, and simple functions. No prior distributed-systems background is assumed; every term is defined before it’s used.

Step 1: Reproduce why the obvious approach fails

Before building anything clever, it’s worth seeing the naive approach actually break, on purpose, so the rest of this tutorial has a concrete problem to solve. Imagine a “like counter” on a post, served by two application servers, node_a and node_b, that both start in sync.

class NaiveCounter:
    def __init__(self):
        self.value = 0

    def increment(self, amount=1):
        self.value += amount


def sync_last_write_wins(local, remote):
    """A naive 'sync': whichever replica's value we copy last wins."""
    local.value = remote.value
    return local


if __name__ == "__main__":
    node_a = NaiveCounter()
    node_b = NaiveCounter()

    node_a.value = 10
    node_b.value = 10
    print(f"Starting state: node_a={node_a.value}, node_b={node_b.value}")

    # A network partition happens. Both nodes keep accepting likes locally
    # while they can't reach each other.
    for _ in range(3):
        node_a.increment()  # 3 new likes land on node_a
    for _ in range(5):
        node_b.increment()  # 5 new likes land on node_b

    print(f"After partition: node_a={node_a.value}, node_b={node_b.value}")
    print(f"Real total likes that happened: {3 + 5} (plus the original 10) = 18")

    # The partition heals. We "sync" by copying whichever value we see last.
    sync_last_write_wins(node_a, node_b)
    print(f"After naive sync (copy node_b into node_a): node_a={node_a.value}")
    print("The 3 likes that landed on node_a were silently discarded.")

Run it (python step1_naive_bug.py) and this is the real captured output:

Starting state: node_a=10, node_b=10
After partition: node_a=13, node_b=15
Real total likes that happened: 8 (plus the original 10) = 18
After naive sync (copy node_b into node_a): node_a=15
The 3 likes that landed on node_a were silently discarded.

Eighteen real likes happened. The naive sync reports fifteen. This isn’t a contrived edge case, it’s what happens by default any time two writers can both touch the same value and you resolve conflicts by “whoever synced last wins.” The fix isn’t a smarter tiebreaker; it’s a data structure whose merge function can never lose information in the first place.

Step 2: Build a G-Counter (Grow-Only Counter)

The trick behind a G-Counter is simple once you see it: instead of one shared integer, every node gets its own private slot in a dictionary, and a node is only ever allowed to increment its own slot. The counter’s total value is the sum of every slot. Merging two G-Counters means taking the element-wise maximum of every slot, never a wholesale copy.

class GCounter:
    """Grow-only counter: each node tracks its own slot, merge takes the max."""

    def __init__(self, node_id):
        self.node_id = node_id
        self.counts = {node_id: 0}

    def increment(self, amount=1):
        if amount < 0:
            raise ValueError("GCounter cannot decrement; use PNCounter instead")
        self.counts[self.node_id] = self.counts.get(self.node_id, 0) + amount

    def value(self):
        return sum(self.counts.values())

    def merge(self, other):
        """Merge another GCounter's state into this one, in place."""
        for node, count in other.counts.items():
            self.counts[node] = max(self.counts.get(node, 0), count)

    def copy(self):
        clone = GCounter(self.node_id)
        clone.counts = dict(self.counts)
        return clone

    def __repr__(self):
        return f"GCounter({self.node_id}, counts={self.counts}, value={self.value()})"

This is a direct implementation of the state-based G-Counter algorithm as formally specified: each node maintains a payload array indexed by node ID, increment() only ever writes to its own index, value() sums the whole array, and merge(X, Y) sets every index of the result to max(X[i], Y[i]). That last rule is the entire trick. A node’s own count can only ever go up, so max() can never accidentally erase a real increment, because the true value at any slot is always the largest number anyone has ever seen written there.

Now replay the exact same like-counter scenario from Step 1, but with a GCounter instead of a plain integer:

if __name__ == "__main__":
    node_a = GCounter("node_a")
    node_b = GCounter("node_b")

    node_a.counts = {"node_a": 10, "node_b": 10}
    node_b.counts = {"node_a": 10, "node_b": 10}
    print(f"Starting: {node_a}")
    print(f"Starting: {node_b}")

    for _ in range(3):
        node_a.increment()
    for _ in range(5):
        node_b.increment()

    print(f"\nAfter partition: {node_a}")
    print(f"After partition: {node_b}")

    node_a.merge(node_b)
    node_b.merge(node_a)

    print(f"\nAfter merge: {node_a}")
    print(f"After merge: {node_b}")
    print(f"\nBoth nodes agree: {node_a.value() == node_b.value()}")
    print(f"Expected total (20 original + 3 + 5): {20 + 3 + 5}")

Real captured output:

Starting: GCounter(node_a, counts={'node_a': 10, 'node_b': 10}, value=20)
Starting: GCounter(node_b, counts={'node_a': 10, 'node_b': 10}, value=20)

After partition: GCounter(node_a, counts={'node_a': 13, 'node_b': 10}, value=23)
After partition: GCounter(node_b, counts={'node_a': 10, 'node_b': 15}, value=25)

After merge: GCounter(node_a, counts={'node_a': 13, 'node_b': 15}, value=28)
After merge: GCounter(node_b, counts={'node_a': 13, 'node_b': 15}, value=28)

Both nodes agree: True
Expected total (20 original + 3 + 5): 28

All 28 likes survive, and both nodes converge to the identical answer, without either node ever being told which one “won.” Merging {'node_a': 13, 'node_b': 10} with {'node_a': 10, 'node_b': 15} just takes max(13,10)=13 for node_a’s slot and max(10,15)=15 for node_b’s slot: nothing about that step depends on which merge call happened first.

Why does that guarantee actually hold? Prove it, don’t assume it

Rather than trust that description, verify the three merge properties from earlier directly against the code:

from gcounter import GCounter


def make_three_nodes():
    a = GCounter("node_a")
    b = GCounter("node_b")
    c = GCounter("node_c")
    a.counts = {"node_a": 5, "node_b": 2, "node_c": 0}
    b.counts = {"node_a": 3, "node_b": 8, "node_c": 1}
    c.counts = {"node_a": 5, "node_b": 2, "node_c": 4}
    return a, b, c


def merged_counts(x, y):
    result = x.copy()
    result.merge(y)
    return result.counts


if __name__ == "__main__":
    a, b, _ = make_three_nodes()

    # Commutative: merge(a, b) == merge(b, a)
    ab = merged_counts(a, b)
    ba = merged_counts(b, a)
    print(f"merge(a, b) = {ab}")
    print(f"merge(b, a) = {ba}")
    print(f"Commutative: {ab == ba}\n")

    # Associative: merge(merge(a, b), c) == merge(a, merge(b, c))
    a, b, c = make_three_nodes()
    left = a.copy()
    left.merge(b)
    left.merge(c)

    a, b, c = make_three_nodes()
    right = b.copy()
    right.merge(c)
    right.merge(a)

    print(f"merge(merge(a, b), c) = {left.counts}")
    print(f"merge(a, merge(b, c)) = {right.counts}")
    print(f"Associative: {left.counts == right.counts}\n")

    # Idempotent: merge(a, a) == a
    a, _, _ = make_three_nodes()
    before = dict(a.counts)
    a.merge(a.copy())
    print(f"a before self-merge:  {before}")
    print(f"a after self-merge:   {a.counts}")
    print(f"Idempotent: {before == a.counts}")

Real captured output:

merge(a, b) = {'node_a': 5, 'node_b': 8, 'node_c': 1}
merge(b, a) = {'node_a': 5, 'node_b': 8, 'node_c': 1}
Commutative: True

merge(merge(a, b), c) = {'node_a': 5, 'node_b': 8, 'node_c': 4}
merge(a, merge(b, c)) = {'node_a': 5, 'node_b': 8, 'node_c': 4}
Associative: True

a before self-merge:  {'node_a': 5, 'node_b': 2, 'node_c': 0}
a after self-merge:   {'node_a': 5, 'node_b': 2, 'node_c': 0}
Idempotent: True

The idempotence result is the one that’s easy to underrate. In a real system, sync messages get retried after timeouts, load balancers occasionally deliver a duplicate, and gossip protocols intentionally re-send state to nodes that may have already seen it. A merge function that isn’t idempotent would double-count every one of those retries. Because max() is idempotent by definition, merging a G-Counter with itself, or with any state it has already absorbed, changes nothing.

Step 3: Handle decrements with a PN-Counter

A G-Counter has an obvious limitation: it can only grow. That’s fine for a “total requests served” counter, but not for something like “current active viewers,” which needs to go down as well as up. The fix is not to make a single slot decrementable (that would break the max-based merge). Instead, a PN-Counter (Positive-Negative Counter) keeps two separate G-Counters under the hood, one that only ever receives increments (P) and one that only ever receives decrements (N), and reports value() = P.total() - N.total().

from gcounter import GCounter


class PNCounter:
    """Two G-Counters underneath: one for increments, one for decrements."""

    def __init__(self, node_id):
        self.node_id = node_id
        self.positive = GCounter(node_id)
        self.negative = GCounter(node_id)

    def increment(self, amount=1):
        self.positive.increment(amount)

    def decrement(self, amount=1):
        self.negative.increment(amount)  # decrementing = incrementing the "negative" side

    def value(self):
        return self.positive.value() - self.negative.value()

    def merge(self, other):
        self.positive.merge(other.positive)
        self.negative.merge(other.negative)

    def copy(self):
        clone = PNCounter(self.node_id)
        clone.positive = self.positive.copy()
        clone.negative = self.negative.copy()
        return clone

    def __repr__(self):
        return f"PNCounter({self.node_id}, value={self.value()})"

Notice that decrement() doesn’t subtract from anything. It calls self.negative.increment(amount), which means the negative side is itself a perfectly ordinary G-Counter, grow-only, max-based merge, all the same guarantees already proven above. A decrement is just an increment recorded on the other side of the ledger.

Test it with a viewer-count scenario where viewers both join and leave on two nodes during a partition:

if __name__ == "__main__":
    node_a = PNCounter("node_a")
    node_b = PNCounter("node_b")

    for _ in range(10):
        node_a.increment()  # 10 viewers joined via node_a
    for _ in range(15):
        node_b.increment()  # 15 viewers joined via node_b
    for _ in range(4):
        node_a.decrement()  # 4 viewers left via node_a
    for _ in range(3):
        node_b.decrement()  # 3 viewers left via node_b

    print(f"Before merge: node_a value={node_a.value()}, node_b value={node_b.value()}")

    node_a.merge(node_b)
    node_b.merge(node_a)

    print(f"After merge:  node_a value={node_a.value()}, node_b value={node_b.value()}")
    print(f"Expected: (10 + 15) joined - (4 + 3) left = {10 + 15 - 4 - 3}")

Real captured output:

Before merge: node_a value=6, node_b value=12
After merge:  node_a value=18, node_b value=18
Expected: (10 + 15) joined - (4 + 3) left = 18

Both nodes converge on 18 active viewers, the mathematically correct answer, even though each node only ever saw half of the joins and half of the leaves directly.

Step 4: The gotcha, why you can’t just “reset” a slot

Here’s a mistake that looks completely reasonable and will quietly undo itself. Say an operator wants to reset a counter back to zero on one node, maybe to clear out test data. The tempting shortcut is to reach in and zero out that node’s own slot directly:

def reset_node_slot_naively(counter, node_id):
    """A tempting but WRONG way to 'reset' a counter: zero out one slot."""
    counter.counts[node_id] = 0


if __name__ == "__main__":
    node_a = GCounter("node_a")
    node_b = GCounter("node_b")

    node_a.increment(50)
    node_b.merge(node_a)  # node_b now knows node_a is at 50
    print(f"node_a: {node_a}")
    print(f"node_b: {node_b}")

    reset_node_slot_naively(node_a, "node_a")
    print(f"\nAfter 'resetting' node_a's slot to 0: {node_a}")

    node_a.increment(3)
    print(f"node_a after 3 more increments: {node_a}")

    node_b.merge(node_a)
    print(f"\nnode_b after merging the 'reset' node_a: {node_b}")

Real captured output:

node_a: GCounter(node_a, counts={'node_a': 50}, value=50)
node_b: GCounter(node_b, counts={'node_b': 0, 'node_a': 50}, value=50)

After 'resetting' node_a's slot to 0: GCounter(node_a, counts={'node_a': 0}, value=0)
node_a after 3 more increments: GCounter(node_a, counts={'node_a': 3}, value=3)

node_b after merging the 'reset' node_a: GCounter(node_b, counts={'node_b': 0, 'node_a': 50}, value=50)

The reset gets silently undone the moment node_b merges again. max(50, 3) is 50, so node_b’s own record of node_a’s slot simply overrides node_a’s freshly-zeroed value the next time they sync. This isn’t a bug in the merge logic; it’s the merge logic doing exactly what it’s supposed to do. A G-Counter’s whole safety guarantee rests on the invariant that a node’s slot can only ever increase, so any code path that decreases a slot directly (rather than through the negative side of a PN-Counter) breaks the one promise the data structure exists to make. If you actually need a resettable counter, you generally have to mint a brand-new counter identity and retire the old one, rather than mutate a slot in place.

Step 5: A realistic 3-node simulation with out-of-order gossip

Two nodes syncing directly is the easy case. Real systems usually propagate state through gossip: node A syncs with B, B syncs with C, C syncs back with A, and the order those pairwise syncs happen in is not something any single node controls. This step simulates three edge servers recording page views for a video during a partition, then reconciling through gossip in a randomized order, to check that the final answer really doesn’t depend on that order.

import random

from gcounter import GCounter


def simulate(seed):
    rng = random.Random(seed)

    edge_us = GCounter("edge_us")
    edge_eu = GCounter("edge_eu")
    edge_asia = GCounter("edge_asia")
    nodes = {"edge_us": edge_us, "edge_eu": edge_eu, "edge_asia": edge_asia}

    total_real_views = 0

    for name, node in nodes.items():
        views = rng.randint(20, 80)
        node.increment(views)
        total_real_views += views
        print(f"  {name} recorded {views} views while partitioned")

    print(f"  Real total views across all 3 servers: {total_real_views}")

    sync_pairs = [("edge_us", "edge_eu"), ("edge_eu", "edge_asia"), ("edge_asia", "edge_us")]
    rng.shuffle(sync_pairs)
    print(f"\n  Gossip sync order this run: {sync_pairs}")
    for a, b in sync_pairs:
        nodes[a].merge(nodes[b])
        nodes[b].merge(nodes[a])

    # A second gossip round in case the first left anyone behind.
    for a, b in sync_pairs:
        nodes[a].merge(nodes[b])
        nodes[b].merge(nodes[a])

    print(f"\n  What each server reports after gossip converges:")
    all_agree = True
    for name, node in nodes.items():
        print(f"    {name}.value() = {node.value()}")
        if node.value() != total_real_views:
            all_agree = False

    print(f"\n  All 3 servers agree with the real total: {all_agree}")
    return all_agree, total_real_views


if __name__ == "__main__":
    for seed in (1, 2, 3, 4, 5):
        print(f"\n=== Run with random seed {seed} ===")
        ok, total = simulate(seed)
        assert ok, f"Servers disagreed after gossip converged (seed={seed})"
    print("\nAll 5 runs converged to the correct total regardless of sync order.")

Real captured output for the first run (seeds 2 through 5 all converge the same way, just with different random view counts and a different shuffled sync order):

=== Run with random seed 1 ===
  edge_us recorded 28 views while partitioned
  edge_eu recorded 56 views while partitioned
  edge_asia recorded 74 views while partitioned
  Real total views across all 3 servers: 158

  Gossip sync order this run: [('edge_asia', 'edge_us'), ('edge_eu', 'edge_asia'), ('edge_us', 'edge_eu')]

  What each server reports after gossip converges:
    edge_us.value() = 158
    edge_eu.value() = 158
    edge_asia.value() = 158

  All 3 servers agree with the real total: True

All five randomized runs converged on the correct total, each with a different random view count and a different shuffled gossip order. That’s the point of proving commutativity and associativity earlier: it’s what guarantees this simulation didn’t need to get the sync order right to get the right answer. Any pairing, in any order, arrives at the same place, which is exactly what makes CRDTs practical for gossip-based systems where you generally can’t control or predict propagation order.

Step 6: Verify everything with pytest

Manually reading printed output is good for building intuition, but the properties this tutorial claims (commutative, associative, idempotent, correct under decrements, correct under randomized gossip) should be checked automatically. Save this as test_crdt_counter.py alongside gcounter.py, pncounter.py, and three_node_sim.py:

import pytest

from gcounter import GCounter
from pncounter import PNCounter


def test_gcounter_increment_and_value():
    counter = GCounter("node_a")
    counter.increment(5)
    counter.increment(2)
    assert counter.value() == 7


def test_gcounter_rejects_negative_increment():
    counter = GCounter("node_a")
    with pytest.raises(ValueError):
        counter.increment(-1)


def test_gcounter_merge_is_commutative():
    a = GCounter("node_a")
    b = GCounter("node_b")
    a.counts = {"node_a": 5, "node_b": 2}
    b.counts = {"node_a": 3, "node_b": 8}

    ab = a.copy()
    ab.merge(b)
    ba = b.copy()
    ba.merge(a)

    assert ab.counts == ba.counts


def test_gcounter_merge_is_associative():
    a = GCounter("node_a")
    b = GCounter("node_b")
    c = GCounter("node_c")
    a.counts = {"node_a": 5, "node_b": 2, "node_c": 0}
    b.counts = {"node_a": 3, "node_b": 8, "node_c": 1}
    c.counts = {"node_a": 5, "node_b": 2, "node_c": 4}

    left = a.copy()
    left.merge(b)
    left.merge(c)

    right = b.copy()
    right.merge(c)
    right.merge(a)

    assert left.counts == right.counts


def test_gcounter_merge_is_idempotent():
    a = GCounter("node_a")
    a.counts = {"node_a": 5, "node_b": 2}
    before = dict(a.counts)
    a.merge(a.copy())
    assert a.counts == before


def test_gcounter_merge_never_loses_data_regardless_of_order():
    node_a = GCounter("node_a")
    node_b = GCounter("node_b")
    node_a.increment(3)
    node_b.increment(5)

    node_a.merge(node_b)
    node_b.merge(node_a)

    assert node_a.value() == node_b.value() == 8


def test_pncounter_increment_and_decrement():
    counter = PNCounter("node_a")
    counter.increment(10)
    counter.decrement(4)
    assert counter.value() == 6


def test_pncounter_merge_reconciles_concurrent_join_and_leave():
    node_a = PNCounter("node_a")
    node_b = PNCounter("node_b")

    node_a.increment(10)
    node_b.increment(15)
    node_a.decrement(4)
    node_b.decrement(3)

    node_a.merge(node_b)
    node_b.merge(node_a)

    assert node_a.value() == node_b.value() == 18


def test_three_node_gossip_converges_regardless_of_sync_order():
    from three_node_sim import simulate

    for seed in range(10):
        converged, _ = simulate(seed)
        assert converged

Run it with pytest test_crdt_counter.py -v. Real captured output:

============================= test session starts =============================
platform win32 -- Python 3.13.14, pytest-9.1.1, pluggy-1.6.0
collected 9 items

test_crdt_counter.py::test_gcounter_increment_and_value PASSED           [ 11%]
test_crdt_counter.py::test_gcounter_rejects_negative_increment PASSED    [ 22%]
test_crdt_counter.py::test_gcounter_merge_is_commutative PASSED          [ 33%]
test_crdt_counter.py::test_gcounter_merge_is_associative PASSED          [ 44%]
test_crdt_counter.py::test_gcounter_merge_is_idempotent PASSED           [ 55%]
test_crdt_counter.py::test_gcounter_merge_never_loses_data_regardless_of_order PASSED [ 66%]
test_crdt_counter.py::test_pncounter_increment_and_decrement PASSED      [ 77%]
test_crdt_counter.py::test_pncounter_merge_reconciles_concurrent_join_and_leave PASSED [ 88%]
test_crdt_counter.py::test_three_node_gossip_converges_regardless_of_sync_order PASSED [100%]

============================== 9 passed in 0.05s ==============================

The last test is worth calling out specifically: test_three_node_gossip_converges_regardless_of_sync_order reruns the randomized 3-node simulation across 10 different seeds, meaning 10 different random view counts and 10 different shuffled gossip orders, and asserts every single one converges. That single test is doing the real work of confirming the ordering-independence guarantee this whole tutorial is built around, not just the two library-level demos shown earlier.

Common mistakes to watch for

  • Mutating a slot directly instead of going through increment/decrement. Step 4 showed exactly why: it breaks the monotonic-growth invariant the whole merge algorithm depends on, and the mistake will silently un-fix itself on the next merge rather than raising an error.
  • Reusing a node’s identity across two different physical machines. If two servers both increment using the node ID "node_a", their writes collide in the same dictionary slot and the counter undercounts, exactly like the naive last-write-wins bug from Step 1, just one level down. Every real replica needs its own unique, stable node ID.
  • Forgetting that a G-Counter cannot decrement at all. The increment(-1) call in this tutorial’s code deliberately raises ValueError rather than silently corrupting the slot, specifically to catch this mistake at the point it’s made instead of during some later merge.
  • Assuming “eventually consistent” means “instantly consistent.” Right after a partition heals but before every pair of nodes has actually gossiped with each other, nodes can briefly report different values. That’s expected. The simulation in Step 5 runs two full gossip rounds specifically to make sure every pair has actually exchanged state before checking for agreement; a single round isn’t guaranteed to reach every node.

How to confirm it all works end to end

  1. Place gcounter.py, pncounter.py, and three_node_sim.py in one directory.
  2. Run python gcounter.py and confirm both nodes report the identical merged value.
  3. Run python pncounter.py and confirm the merged value matches the hand-computed expected total.
  4. Run python three_node_sim.py and confirm all five randomized runs print “All 5 runs converged to the correct total regardless of sync order.”
  5. Run pytest test_crdt_counter.py -v and confirm all 9 tests pass.

If every one of those checks passes, you have a counter whose correctness under concurrent, out-of-order updates isn’t a hope, it’s a property you’ve proven with code.

Next steps

A CRDT counter resolves conflicts automatically, but it can only do that because the domain (a running total) is simple enough that “the largest value anyone has recorded” is always the right merge rule. Two natural directions to go from here:

  • If you need to detect that two writes happened concurrently, rather than automatically resolve them, read How to Build Vector Clocks in Python to Detect Concurrent Writes in a Distributed System. Vector clocks and CRDTs solve related but different problems: vector clocks tell you a conflict happened and let you decide what to do about it; a CRDT’s merge function decides for you, automatically, as part of the data structure’s design.
  • If your operation genuinely can’t be resolved by a commutative merge rule (for example, transferring money between two accounts, where you can’t just take the max of two balances), you need actual coordination between replicas instead. How to Implement Two-Phase Commit in Python So Two Databases Commit or Abort Together covers the coordinated alternative, and How to Build Leader Election in Python to Stop Split-Brain Writes covers how a cluster agrees on which single node is allowed to coordinate in the first place.

Tags:

AlgorithmsConcurrencyDistributed SystemsPython

Share

An LG Android TV remote lying on a fabric surface, showing dedicated Netflix and Prime Video buttons alongside colored quick-access buttons
Previous Post

A Developer’s Claude-Debloated TV Turns Near-Misses Into an Agent Permissions Playbook

A green Intel PCIe network interface card on a white background, representing the physical network hardware layer where NVIDIA's Sentry watchdog enforces agent security
Next Post

NVIDIA Launches an Open Agent Safety Platform to Put Security Outside the AI Agent’s Reach

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
30 Sep
How to Detect and Strip Invisible Unicode in Python to Stop ASCII Smuggling and Trojan Source
30 Sep
Two Cloudflare Agent Billing Betas Turn Web Monetization Into a Question of Who Holds the Meter
Trending
September 30, 2026
How to Detect and Strip Invisible Unicode in Python to Stop ASCII Smuggling and Trojan Source
September 30, 2026
Two Cloudflare Agent Billing Betas Turn Web Monetization Into a Question of Who Holds the Meter
September 30, 2026
Attackers Exploit a Hex-Encoding Bypass in Cisco SD-WAN Manager, and CISA Sets an October 3 Deadline
September 30, 2026
How to Enforce Guardrails on AI-Generated Terraform With Open Policy Agent and Rego
September 30, 2026
AMD Agrees to Buy Fei-Fei Li’s World Labs for $8.2 Billion to Steer Its Chip Roadmap
September 29, 2026
How to Build a Merkle Tree Certificate Issuer in Python to Keep Post-Quantum Certificates Small

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026