Merkle Trees: Efficient Data Integrity in Distributed Systems

Learn how Merkle trees enable efficient data synchronization, consistency verification, and conflict detection in distributed databases and blockchain systems.

published: reading time: 18 min read

Merkle Trees: Efficient Data Integrity in Distributed Systems

When you synchronize data between two databases, how do you know they have the same data? You could compare every record. But that gets painful fast when you’re dealing with millions of rows. Merkle trees let you compare vast amounts of data with minimal computation.

A Merkle tree is a hash tree where each leaf node stores the hash of a data block, and each non-leaf node stores the hash of its two children combined. The root hash summarizes everything below it. Match the root hashes and your data matches. Mismatch? You can trace down exactly where the disagreement lives.

How Merkle Trees Work

Tree Structure

graph TD
    R[Root Hash<br/>H(ABCD)] --> L[H(AB)]
    R --> Ri[H(CD)]
    L --> A[H(A)]
    L --> B[H(B)]
    Ri --> C[H(C)]
    Ri --> D[H(D)]
    A --> A1[Data Block A]
    B --> B1[Data Block B]
    C --> C1[Data Block C]
    D --> D1[Data Block D]

Each leaf node holds the hash of one data block. Each parent node hashes its two children concatenated together. The root hash at the top summarizes the entire dataset.

Building a Merkle Tree

import hashlib

def hash_data(data: bytes) -> bytes:
    return hashlib.sha256(data).digest()

def hash_pair(left: bytes, right: bytes) -> bytes:
    return hashlib.sha256(left + right).digest()

class MerkleTree:
    def __init__(self, data_blocks: list[bytes]):
        self.leaves = [hash_data(block) for block in data_blocks]
        self.tree = self._build_tree(self.leaves)

    def _build_tree(self, nodes: list[bytes]) -> list[list[bytes]]:
        if not nodes:
            return []

        tree = [nodes]

        while len(nodes) > 1:
            if len(nodes) % 2 == 1:
                nodes.append(nodes[-1])

            next_level = []
            for i in range(0, len(nodes), 2):
                next_level.append(hash_pair(nodes[i], nodes[i + 1]))
            tree.append(next_level)
            nodes = next_level

        return tree

    def root_hash(self) -> bytes:
        if not self.tree:
            return b""
        return self.tree[-1][0]

Verifying Data

To verify a data block is in the tree, you need the block itself and all the hashes along the path from that leaf up to the root:

class MerkleProof:
    def __init__(self, leaf_hash: bytes, path: list[tuple[bytes, str]]):
        self.leaf_hash = leaf_hash
        self.path = path

    def verify(self, root_hash: bytes) -> bool:
        current = self.leaf_hash
        for sibling_hash, direction in self.path:
            if direction == "left":
                current = hash_pair(sibling_hash, current)
            else:
                current = hash_pair(current, sibling_hash)
        return current == root_hash

def create_proof(tree: MerkleTree, leaf_index: int) -> MerkleProof:
    if leaf_index >= len(tree.leaves):
        raise ValueError("Leaf index out of range")

    path = []
    nodes = tree.leaves

    for level in range(len(tree.tree) - 1):
        is_left = leaf_index % 2 == 0
        sibling_index = leaf_index + 1 if is_left else leaf_index - 1

        if sibling_index < len(nodes):
            sibling_hash = nodes[sibling_index]
        else:
            sibling_hash = nodes[leaf_index]

        direction = "left" if is_left else "right"
        path.append((sibling_hash, direction))

        leaf_index = leaf_index // 2
        nodes = tree.tree[level + 1]

    return MerkleProof(tree.leaves[leaf_index], path)

Why Merkle Trees Are Efficient

Comparison Complexity

Without Merkle trees, comparing two datasets of N blocks requires O(N) comparisons. With Merkle trees, you first compare root hashes. If they match, you are done. If they differ, you traverse down the tree, comparing only the nodes on the paths that differ.

graph TD
    A[Compare Root Hashes] --> B{Root Hashes Match?}
    B -->|Yes| C[Data Identical<br/>O(1) comparisons]
    B -->|No| D[Compare Left and Right Children]
    D --> E{Left Hash Matches?}
    E -->|Yes| F[Recurse on Right Subtree]
    E -->|No| G[Recurse on Left Subtree]
    F --> H{Leaf Level?}
    G --> H
    H -->|Yes| I[Identify Specific Blocks]
    I --> J[O(log N) to find differences]

In the worst case, you traverse O(log N) nodes before finding where things diverge. That’s the appeal: instead of reading your entire dataset, you do a handful of hash comparisons and know exactly what needs syncing.

Storage Overhead

Merkle trees add some storage overhead, but it’s negligible compared to the data itself. For N data blocks, you need N leaf hashes and roughly N additional internal node hashes — under 2N total.

def calculate_overhead(num_blocks: int) -> dict:
    leaf_hashes = num_blocks

    internal_hashes = max(0, num_blocks - 1)

    total_hashes = leaf_hashes + internal_hashes

    overhead_bytes = total_hashes * 32

    data_bytes = num_blocks * 1024

    return {
        "merkle_hashes_bytes": overhead_bytes,
        "full_data_bytes": data_bytes,
        "overhead_ratio": overhead_bytes / data_bytes
    }

For large datasets, this overhead settles around 3% or less.

Use Cases in Distributed Systems

Cassandra Anti-Entropy

Cassandra uses Merkle trees for anti-entropy repair. When replicas exchange data, they compute trees and compare root hashes. If the roots match, nothing more to do. If they differ, Cassandra streams just the mismatched ranges across.

class AntiEntropyRepair:
    def __init__(self, replica: Replica):
        self.replica = replica

    def compute_merkle_tree(self, range: KeyRange) -> MerkleTree:
        data_blocks = []
        for key in self.replica.get_keys_in_range(range):
            data_blocks.append(self.replica.get_value(key))

        return MerkleTree(data_blocks)

    def exchange_trees(
        self,
        peer: Replica,
        range: KeyRange
    ) -> list[tuple[bytes, bytes]]:
        local_tree = self.compute_merkle_tree(range)

        remote_root = peer.get_merkle_root(range)

        if local_tree.root_hash() == remote_root:
            return []

        return self.find_differences(local_tree, peer.get_merkle_tree(range))

Amazon DynamoDB and Riak

Dynamo-style databases use Merkle trees for consistent hashing and data versioning. Each node maintains a Merkle tree for the key-range it owns. When nodes join or leave, the trees get compared to figure out exactly what data needs to move.

See Consistency Models for more on how Dynamo-style systems handle consistency.

Bitcoin and Blockchain

Bitcoin uses Merkle trees to summarize all transactions in a block. The Merkle root lives in the block header. This is what lets lightweight clients verify a transaction exists in a block without downloading the whole blockchain.

graph TD
    BlockHeader --> MR[Merkle Root]
    BlockHeader --> Prev[Previous Block Hash]
    BlockHeader --> T[Timestamp]
    BlockHeader --> NB[Nonce]
    BlockHeader --> D[Difficulty Target]

    MR --> TX1[Transaction A]
    MR --> TX2[Transaction B]
    MR --> TX3[Transaction C]
    MR --> TX4[Transaction D]

    TX1 --> H1[Hash A]
    TX2 --> H2[Hash B]
    TX3 --> H3[Hash C]
    TX4 --> H4[Hash D]

    H1 --> H12[Hash AB]
    H2 --> H12
    H3 --> H34[Hash CD]
    H4 --> H34
    H12 --> MR
    H34 --> MR

Git

Git uses Merkle trees internally, though it calls them object stores. Each commit points to a tree that represents the filesystem state. Trees contain blobs (file contents) and subtrees (directories). The commit hash is effectively a Merkle root.

Synchronization Protocols

Simple Sync Protocol

When two nodes need to synchronize, here’s the basic protocol:

async def sync_nodes(local: Node, remote: Node, range: KeyRange):
    local_root = local.get_merkle_root(range)
    remote_root = remote.get_merkle_root(range)

    if local_root == remote_root:
        return

    local_children = local.get_merkle_tree(range)[0]
    remote_children = remote.get_merkle_tree(range)[0]

    for i, (lc, rc) in enumerate(zip(local_children, remote_children)):
        if lc != rc:
            await sync_subtree(local, remote, range, level=1, index=i)

Efficient Sync with Partial Trees

For very large datasets, you can send the tree level by level. Start with the root. Only request children of nodes that actually differ:

async def efficient_sync(local: Node, remote: Node, range: KeyRange):
    local_tree = local.get_merkle_tree(range)

    remote_tree = await remote.get_merkle_tree_at_level(range, len(local_tree) - 1)

    differences = find_differences_at_level(local_tree[-1], remote_tree)

    for level in reversed(range(len(local_tree) - 1)):
        remote_subtrees = await remote.get_subtrees(
            range, level, differences[level]
        )

        for idx in differences[level]:
            child_idx = idx // 2
            if local_tree[level][child_idx] != remote_subtrees[child_idx]:
                if level > 0:
                    differences[level - 1].extend([child_idx * 2, child_idx * 2 + 1])
                else:
                    await sync_data(local, remote, range, idx)

Conflict Detection

Merkle trees can spot conflicts in eventually consistent systems:

class ConflictDetector:
    def __init__(self, trees: dict[str, MerkleTree]):
        self.trees = trees

    def detect_conflicts(self, key: str) -> list[str]:
        replica_roots = {
            replica: tree.root_hash()
            for replica, tree in self.trees.items()
        }

        unique_roots = set(replica_roots.values())
        if len(unique_roots) == 1:
            return []

        conflicting_replicas = []
        for replica, root in replica_roots.items():
            if root != list(unique_roots)[0]:
                conflicting_replicas.append(replica)

        return conflicting_replicas

For more on conflict resolution in eventually consistent systems, see Event-Driven Architecture.

Limitations

Non-Atomic Updates

Merkle trees are computed from data snapshots. If data changes while you’re building or comparing trees, you need to handle the inconsistency yourself.

Memory Usage

Computing large Merkle trees means holding all hashes in memory. For very large datasets, look into streaming approaches or segmented trees.

Insertions and Deletions

When you insert or delete data and the leaf count changes, you may need to recompute the whole tree. Some systems use intermediate hashes or tree cursors to handle dynamic datasets more gracefully.

Merkle trees get confused with Patricia trees (Practical Algorithm to Retrieve Information Coded in Alphanumeric) and Radix trees (compressed tries) a lot. All three involve hashing, but that’s about where the similarity ends.

Comparison Table

AspectMerkle TreePatricia/Radix Tree
Primary PurposeData integrity verification and synchronizationEfficient key-value lookup and routing
Hash LocationHash computed on data contentHash computed on key prefixes
StructureBinary tree of content hashesCompressed trie with shared prefixes
Lookup EfficiencyO(log N) for existence checkO(K) where K is key length
Use CaseCompare large datasets, detect differencesIP routing (Radix), associative arrays
Sibling VerificationYes - can prove element inclusionNo - no membership proofs
Byzantine Fault ToleranceYes - collision-resistant hashingNo - relies on exact key matching
Typical ApplicationsBitcoin, Cassandra anti-entropy, GitNetwork routing tables, dictionaries

When to Use Each

Pick Merkle Trees when:

  • Comparing two large datasets for differences
  • Verifying that a specific element exists in a set
  • Building synchronization protocols between replicas
  • Detecting tampering or corruption

Pick Patricia/Radix Trees when:

  • You need fast key-value lookups (O(K) where K is key length)
  • Building a routing table or prefix map
  • Finding all keys with a common prefix
  • You do not need cryptographic integrity guarantees

Practical Example: Database Index vs Anti-Entropy

Consider a database that needs to synchronize replicas:

# Merkle tree approach: Used for anti-entropy
# Efficiently find which data ranges differ between replicas
class AntiEntropyMerkle:
    def __init__(self, replica):
        self.replica = replica

    def sync_with(self, peer):
        local_root = self.compute_merkle_root()
        peer_root = peer.get_merkle_root()

        if local_root != peer_root:
            # Find and sync only differing ranges
            diffs = self.find_differences(local_root, peer_root)
            for range_start, range_end in diffs:
                self.replica.sync_range(peer, range_start, range_end)

# Patricia tree approach: Used for index structure
# Fast lookup of keys by prefix
class PrefixRegistry:
    def __init__(self):
        self.trie = {}

    def insert(self, key, value):
        node = self.trie
        for char in key:
            if char not in node:
                node[char] = {}
            node = node[char]
        node['value'] = value

    def find_prefix(self, prefix):
        node = self.trie
        for char in prefix:
            if char not in node:
                return None
            node = node[char]
        return node

So here’s the thing: Merkle trees do content verification and sync across distributed systems. Patricia/Radix trees do prefix-based routing and fast lookups. Different tools for different jobs — they actually complement each other.

Synchronization Performance in Practice

O(log N) sounds good on paper. Here’s what it actually means for real datasets.

import math

def sync_comparison_stats(num_blocks: int, block_size_bytes: int = 1024) -> dict:
    """Compare Merkle tree sync vs full scan for a dataset."""
    full_scan = num_blocks * block_size_bytes  # bytes to transfer on full scan

    # Merkle tree comparison: compare log2(N) hashes at each level
    # Starting from root, send child hashes, recurse only on mismatched branches
    levels = math.ceil(math.log2(num_blocks))

    # At each level, send at most 2^level sibling hashes per divergent path
    # Worst case: all paths diverge at root = send full tree
    # Best case: root matches = just 1 hash comparison

    # Average case: ~log N hash comparisons to find first difference
    avg_hash_comparisons = levels * 32  # 32 bytes per SHA-256 hash
    worst_case_hashes = (2 * num_blocks - 1) * 32  # entire tree
    best_case = 32  # root only

    return {
        "dataset_gb": round(num_blocks * block_size_bytes / 1e9, 2),
        "num_blocks": num_blocks,
        "tree_depth": levels,
        "full_scan_mb": round(full_scan / 1e6, 1),
        "best_case_sync_bytes": best_case,
        "avg_case_sync_mb": round(avg_hash_comparisons / 1e6, 4),
        "worst_case_sync_mb": round(worst_case_hashes / 1e6, 1),
        "best_vs_full_ratio": round(best_case / full_scan * 100, 8),
    }

# Example outputs
for n in [1_000, 10_000, 100_000, 1_000_000, 10_000_000]:
    stats = sync_comparison_stats(n)
    print(f"N={n:>10}: depth={stats['tree_depth']:>2}, "
          f"avg={stats['avg_case_sync_mb']:>7}MB, "
          f"full={stats['full_scan_mb']:>6}MB")

Example output:

Dataset SizeTree DepthAvg SyncFull ScanSavings
1GB (10K blocks)140.0004MB1024 MB99.99996%
10GB (100K blocks)170.0005MB10240 MB99.99999%
1TB (1M blocks)200.0006MB1,048,576 MB99.9999999%
10TB (10M blocks)240.0008MB10,485,760 MB99.99999999%

These numbers assume one divergent path. In practice, multiple partitions might differ at the same time, but even then Merkle trees rarely end up transferring more than 1% of the dataset.

The catch: these savings only materialize when the trees are relatively similar. If two nodes have completely diverged (say, 90% dataset mismatch), you might end up transferring nearly everything anyway. Merkle trees shine when datasets are mostly identical.

Tree Reconstruction After Node Failure

When a node fails and recovers, it must rebuild its Merkle tree before participating in sync again. This matters for Cassandra operators and anyone running Dynamo-style systems.

import hashlib

class MerkleTreeReconstructor:
    def __init__(self, data_store, checkpoint_interval: int = 1000):
        self.data_store = data_store
        self.checkpoint_interval = checkpoint_interval
        self.checkpoints = {}  # level -> (leaf_offset, hash_list)

    def reconstruct(self, range: KeyRange) -> MerkleTree:
        """Reconstruct Merkle tree for a key range."""
        keys = sorted(self.data_store.get_keys_in_range(range))
        data_blocks = [self.data_store.get_value(k) for k in keys]
        return MerkleTree(data_blocks)

    def incremental_rebuild(self, range: KeyRange, checkpoint: dict) -> MerkleTree:
        """
        Rebuild from checkpoint instead of full recompute.
        Checkpoint contains (last_leaf_index, root_hash) pairs per level.
        """
        keys = sorted(self.data_store.get_keys_in_range(range))
        last_index = checkpoint['last_leaf_index']

        # Verify checkpoint is still valid by hashing from last known leaf
        # If hash matches checkpoint, we only need to rebuild from that point
        if last_index > 0:
            new_keys = keys[last_index:]
            new_data = [self.data_store.get_value(k) for k in new_keys]
            incremental_tree = MerkleTree(new_data)

            # Verify the incremental tree produces the expected root
            if incremental_tree.root_hash() != checkpoint['expected_root']:
                # Checkpoint invalid, full rebuild required
                return self.reconstruct(range)

            return incremental_tree

        return self.reconstruct(range)

    def create_checkpoint(self, tree: MerkleTree, leaf_index: int) -> dict:
        """Save checkpoint for faster future rebuilds."""
        return {
            'last_leaf_index': leaf_index,
            'root_hash': tree.root_hash(),
            'timestamp': time.time(),
        }

Cassandra’s approach: Cassandra computes Merkle trees asynchronously during nodetool repair. For large tables, this gets expensive. Cassandra 4.0 introduced “repair history” to track which ranges were last repaired and skip unnecessary full repairs.

Practical concerns: Reconstruction means reading every key-value pair from disk. For a 1TB database with 100M keys, expect 30-60 minutes of full disk scan during repair. Schedule maintenance windows accordingly.

Partial Verification for Lightweight Clients

Merkle trees let lightweight clients verify data from untrusted servers without downloading everything. This is how Bitcoin SPV clients work.

class PartialMerkleVerifier:
    def __init__(self, trusted_root_hash: bytes):
        self.trusted_root = trusted_root_hash

    def verify_transaction_in_block(
        self,
        tx: bytes,
        merkle_branch: list[tuple[bytes, str]],
        block_root: bytes
    ) -> bool:
        """
        Verify tx is in a block given only the Merkle branch.

        merkle_branch: list of (hash, direction) pairs from tx leaf to root
        direction: 'left' if the sibling is the left child, 'right' otherwise
        """
        current = hashlib.sha256(tx).digest()

        for sibling_hash, direction in merkle_branch:
            if direction == 'left':
                current = hashlib.sha256(sibling_hash + current).digest()
            else:
                current = hashlib.sha256(current + sibling_hash).digest()

        return current == block_root

    def parse_merkle_branch_from_block(
        self,
        block_header: bytes,
        tx_count: int
    ) -> list[tuple[bytes, str]]:
        """
        Parse Merkle branch from a Bitcoin-style block.
        Block header contains: version, prev_block_hash, merkle_root,
        timestamp, bits, nonce.
        """
        # merkle_root in header is already verified to match our trusted root
        pass

Bitcoin SPV (Simplified Payment Verification): A lightweight wallet only downloads block headers (~80 bytes each) instead of full blocks. When verifying a payment, it asks the network for a Merkle proof showing its transaction is in a block. The block header contains the Merkle root, which commits to all transactions in that block.

The tradeoff: SPV clients trust that the majority of mining power validated the transactions. They cannot detect double-spends within a block, only prove that a transaction was mined.

For distributed databases: A replica joining a cluster can use the same pattern. Request the current Merkle root from a peer, then request Merkle proofs for specific key ranges you care about, verifying each proof against the trusted root.

Security Beyond Hash Function Choice

The hash function gets all the attention in Merkle tree security discussions, but it’s not the only thing that matters.

Collision resistance is the floor. If someone can find two data blocks with the same hash, they can swap one for the other without you noticing. SHA-256 holds up here. MD5 and SHA-1 do not — both have known collision attacks.

Domain separation matters more than people think. Hash(data) and Hash(data || salt) produce completely different outputs. Some systems use domain-separated hashes for leaves versus internal nodes versus checkpoints. Without that separation, a collision in one context could bleed into another.

What hash function to use:

Hash FunctionCollision ResistanceSpeed (SHA-256 = 1x)Notes
SHA-256Excellent1xStandard choice, widely supported
SHA-3-256Excellent0.5xSlower but different design from SHA-2
BLAKE3Excellent10x+Very fast, but verify your threat model
xxHash64None50x+Not cryptographic — only for checksums

For Merkle trees where adversaries might manipulate data, stick with a cryptographic hash (SHA-256 minimum). For internal consistency checks where attackers are not a concern, xxHash works fine.

Merkle tree updates are not atomic. If an attacker can watch tree recomputation happen, they might time their modification to slip in during the update window. Use versioned checkpoints: only accept sync data rooted at a checkpoint hash you observed at a specific log index.

Timelines matter more than hash strength for most threats. If your Merkle root commits to an audit log (like Certificate Transparency), the hash only needs to last as long as that log. SHA-256 is fine for this.

Existing Libraries and Tools

Don’t implement Merkle trees from scratch for production. Use well-audited libraries.

Python

# pymerkle — full Merkle tree implementation with audit proofs
# pip install pymerkle
from pymerkle import MerkleTree

tree = MerkleTree()
for i in range(10):
    tree.append(f'data_block_{i}'.encode())

# Get inclusion proof for a leaf
proof = tree.gen_proof(f'data_block_5'.encode())
print(tree.verify_consistency(tree.root(), proof))

Go

// github.com/cornelk/merkletree — Go Merkle tree library
import "github.com/cornelk/merkletree"

tree, _ := merkletree.New(ctx,
    merkletree.UsingHash(crypto.SHA256),
)

for _, data := range dataBlocks {
    tree.Add(data)
}

root, _ := tree.MerkleRoot()

// Get proof for inclusion
proof, _ := tree.GetProof(dataBlocks[5])
valid, _ := proof.Verify(root, dataBlocks[5], crypto.SHA256)

Java / JVM

// guava's AbstractMerkleTree (used internally by Cassandra)
// For production Cassandra operations, use nodetool repair directly

// For custom implementations:
import com.google.common.hash.Hashing;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;

// Apache Commons Codec has MerkleTree utilities

Cassandra Operations

# Compute Merkle tree for a Cassandra table
nodetool repair --validate --in-local-dc mykeyspace mytable

# View Merkle tree differences (Cassandra 4.0+)
nodetool repair -pr -inc

# Check compaction and Merkle tree generation progress
nodetool compactionstats
nodetool tpstats | grep Validation

Rust

// rust-merkletree crate
use merkletree::{MerkleTree, Sha256};

let tree: MerkleTree<Sha256, _> =
    MerkleTree::new(data_blocks.iter().map(|b| b.as_slice()));

let root = tree.root();
let proof = tree.generate_proof(data_blocks[5]).unwrap();

JavaScript / TypeScript

// @noble/hashes or js-sha3 for browser-compatible Merkle trees
import { sha256 } from "@noble/hashes/sha256";

class MerkleTree {
  private leaves: Uint8Array[];

  constructor(dataBlocks: Uint8Array[]) {
    this.leaves = dataBlocks.map((d) => sha256(d));
  }

  getRoot(): Uint8Array {
    return this.buildTree(this.leaves)[0];
  }
}

For most production scenarios, the database or messaging system you are already using handles Merkle trees internally. Cassandra, DynamoDB, Bitcoin, Git, and Certificate Transparency all implement Merkle trees — you interact with them through the system’s APIs, not by building trees yourself.

Common Mistakes

Merkle trees work best with relatively static data. If your data changes constantly, you’ll recompute trees constantly, and that’s where the efficiency gain disappears.

Not Handling Tree Reconstruction

When nodes fail and recover, they need to reconstruct their Merkle trees. Plan for this and make reconstruction efficient.

Ignoring Security Implications

Merkle roots are used for security-sensitive comparisons. Ensure your hash function is collision-resistant. SHA-256 is standard, but older systems using MD5 or SHA-1 have known vulnerabilities.

Quick Recap

Merkle trees are a powerful tool for distributed data integrity:

  • O(log N) comparison instead of O(N) when finding differences between datasets
  • Storage overhead stays under 2N hashes for N data blocks
  • Used in Cassandra anti-entropy, Bitcoin, Git, and Dynamo-style databases
  • Enable efficient sync and conflict detection protocols

When you need to compare or synchronize large datasets across distributed nodes, Merkle trees belong in your toolkit.

For more on distributed data structures and consistency, see Consistency Models, Database Scaling, and Horizontal Sharding.

Category

Related Posts

Bloom Filters: Space-Efficient Probabilistic Data Structures

How Bloom filters provide memory-efficient membership testing with configurable false positive rates for caches, databases, and distributed systems.

#distributed-systems #data-structures #storage

Google Spanner: Globally Distributed SQL at Scale

Google Spanner architecture combining relational model with horizontal scalability, TrueTime API for global consistency, and F1 database implementation.

#distributed-systems #databases #google

Gossip Protocol: Scalable State Propagation

Learn how gossip protocols enable scalable state sharing in distributed systems. Covers epidemic broadcast, anti-entropy, SWIM failure detection, and real-world applications like Cassandra and Consul.

#distributed-systems #gossip-protocol #consistency