Function: ConsistentHashRing in consistent-hashing/consistent_hashing.py

Date: 2026-05-29

Time: 13:12

ConsistentHashRing

Purpose

This is a reference implementation of the consistent hashing technique described in DDIA Chapter 6 (Partitioning). It solves the problem of distributing keys across a dynamic set of nodes such that adding or removing a node only reassigns a small fraction of keys — unlike naive modulo hashing, which reshuffles nearly everything.

The ring maps both keys and nodes into a shared 32-bit hash space (0 to 2^32 - 1). Each physical node gets multiple virtual nodes (vnodes) placed around the ring. A key is assigned to the first vnode encountered when walking clockwise from the key's hash position. Virtual nodes exist to smooth out load distribution — without them, a few physical nodes can end up owning disproportionately large arcs.

Contract

Preconditions:

Postconditions:

Invariants:

Parameters

| Parameter | Type | Meaning |

|-----------|------|---------|

| num_vnodes | int | Base number of virtual nodes per physical node (before weight scaling). Default 150 is a common choice — enough for reasonable balance with 10s of nodes. |

| replicationfactor | int | How many distinct physical nodes getnodes() returns for a key. get_node() ignores this and always returns 1. |

Return Value

The class itself is a stateful container. Key return semantics per method:

Algorithm

Ring placement (add_node)

For each of num_vnodes * weight virtual nodes:

1. Hash "{node_id}:{i}" to get a 32-bit position.

2. Use bisect_left to find where this position falls in the sorted position list.

3. Before inserting, look at who currently owns that position (the successor vnode at index idx % len). Record the arc [prev_vnode+1, pos] as a transfer from that old owner to the new node.

4. Insert the position and node ID at idx to maintain sort order.

The key insight: because we insert one vnode at a time and compute transfers *before* inserting, each transfer is computed against the ring state that includes all previously inserted vnodes of the same node. This means the transfer map may contain overlapping or superseded arcs — it's an approximation useful for orchestration, not a precise partition map.

Key lookup (get_node)

1. Hash the key to a 32-bit position.

2. bisect (right) finds the first ring position strictly greater than the key's hash.

3. If that index is past the end, wrap to index 0 (the ring wraps around).

4. Return the node at that index.

This is the classic "walk clockwise to the first vnode" operation.

Preference list (get_nodes)

Same starting point as getnode, but walks clockwise collecting distinct physical nodes until it has replicationfactor of them. The seen set skips over vnodes belonging to already-collected physical nodes — this ensures replicas land on different machines.

Load distribution (getloaddistribution)

Walks every adjacent pair of vnodes on the ring, computes the arc length between them (handling wraparound), and credits it to the node that owns the clockwise vnode. This gives the theoretical fraction of keys each node would own under a uniform key distribution.

Side Effects

All mutations are internal state changes to ringpositions, ringnodes, and nodes. No I/O, no external calls. The class is not thread-safe — concurrent addnode/remove_node calls will corrupt the sorted lists.

Error Handling

Usage Patterns


ring = ConsistentHashRing(num_vnodes=150, replication_factor=3)

# Add nodes — use the transfer map to migrate data
transfers = ring.add_node("node-1", weight=1.0)
transfers = ring.add_node("node-2", weight=2.0)  # gets 2x vnodes

# Route a key
primary = ring.get_node("user:42")
replicas = ring.get_nodes("user:42")  # [primary, replica1, replica2]

# Monitor balance
print(ring.load_imbalance())  # 1.0 = perfect, higher = skewed

Caller obligations:

Dependencies

Assumptions Not Enforced by Types

1. Weight is positive. A weight of 0 produces 0 vnodes (node exists in nodes but has no ring presence). Negative weights would also produce 0 vnodes due to int() truncation but the node would still appear in nodes.

2. No hash collisions. If two vnodes hash to the same position, bisect_left will stack them adjacent, but the transfer calculation assumes positions are unique. Collisions are unlikely in a 2^32 space with hundreds of vnodes but not impossible.

3. ringpositions uses list.insert, which is O(n) per insertion. With 150 vnodes per node and, say, 100 nodes, that's 15,000 positions — fine. At thousands of nodes this would become a bottleneck; a production system would use a balanced tree.

4. Transfer map accuracy. Because vnodes are inserted one at a time, later insertions of the *same* node can split arcs that were recorded in the transfer map earlier in the same add_node call. The map is therefore a reasonable but not perfectly minimal description of data movement.

Topics to Explore

Beliefs