Date: 2026-05-29
Time: 08:53
consistent-hashing/consistent_hashing.pyThis file implements a consistent hash ring — the core data partitioning primitive described in DDIA Chapter 6 (Partitioning). It owns the mapping from arbitrary string keys to physical nodes, handling the three main concerns of consistent hashing: virtual nodes for load balancing, weighted nodes for heterogeneous hardware, and replication via preference lists.
This is the kind of component that sits inside a distributed storage coordinator (like Dynamo, Cassandra, or Riak) to decide which node owns which key range.
RING_SIZE = 232** — The hash ring is a 32-bit address space (0 to 4,294,967,295). All positions wrap modulo this value._hash(key: str) -> intA free function that hashes strings to ring positions using MD5, masked to 32 bits. MD5 is not cryptographically relevant here — what matters is uniform distribution across the ring. The & 0xFFFFFFFF mask ensures the output fits within RING_SIZE.
ConsistentHashRingThe main class. Its internal state is three parallel structures:
| Field | Type | Role |
|-------|------|------|
| ringpositions | List[int] | Sorted list of virtual node hash positions |
| ringnodes | List[str] | Node ID at each position (parallel to ringpositions) |
| _nodes | Dict[str, float] | Physical node registry: node ID → weight |
Constructor parameters:
num_vnodes (default 150) — base virtual node count per physical node. Multiplied by weight.replication_factor (default 1) — how many distinct physical nodes a key maps to.#### Core Operations
addnode(nodeid, weight=1.0) -> Dict — Inserts a physical node into the ring by generating int(numvnodes * weight) virtual nodes. Each virtual node's position is hash(f"{nodeid}:{i}"). For each inserted position, it computes the transfer: which arc of the ring is being taken from which existing node. Returns a transfer map of {(arcstart, arcend): (oldowner, new_owner)}.
removenode(nodeid) -> Dict — Removes all virtual nodes for the given physical node. Iterates indices in reverse to avoid index invalidation during deletion. For each removed position, finds the successor node that inherits the arc. Returns the same transfer map format.
get_node(key) -> str — The primary lookup. Hashes the key, then uses bisect.bisect to find the first ring position ≥ the hash. If the hash exceeds all positions, it wraps to index 0. This is the classic clockwise-walk on the ring.
getnodes(key) -> List[str] — Builds a preference list of replicationfactor distinct physical nodes by walking clockwise from the key's position, skipping duplicate physical nodes (since consecutive virtual nodes might belong to the same physical node). This is exactly how Dynamo constructs its coordinator list.
#### Analytical Methods
getloaddistribution() — Computes the fraction of the ring each node owns by summing arc lengths between consecutive virtual nodes. Handles the wrap-around case where curpos < prevpos.getkeydistribution(keys) — Empirical distribution: counts how many of the given keys route to each node.loadimbalance() — maxload / average_load. Perfect balance is 1.0; higher values indicate skew.ring_info() — Human-readable summary showing node count, weights, vnodes, and load percentages.1. Sorted array + bisect — Rather than a balanced BST or skip list, the ring uses bisect on a sorted Python list for O(log n) lookups and O(n) inserts. This is the right tradeoff for a ring that changes rarely (node add/remove) but is queried constantly (key lookups).
2. Parallel arrays — ringpositions and ringnodes are kept in lockstep rather than using a list of tuples. This allows bisect to work directly on positions without a custom comparator.
3. Virtual nodes with deterministic naming — Virtual node positions are hash(f"{nodeid}:{i}"), making them reproducible. The same node added on different machines produces the same ring positions — important for consistency across replicas of the routing table.
4. Transfer maps — Both addnode and removenode return structured descriptions of what data must move, rather than performing the movement themselves. This separates the partitioning decision from the data migration concern.
Imports:
hashlib — MD5 for position hashingbisect — Binary search on the sorted ringtyping — Type annotations only (Tuple is imported but unused)Imported by:
testconsistenthashing.py — Unit teststestertestconsistent_hashing.py — Likely an automated test validatorNo runtime dependencies beyond the standard library.
add_node("A", weight=1.0)
→ generates 150 positions via _hash("A:0"), _hash("A:1"), ...
→ for each position, bisect_left finds insertion point
→ records which existing node loses that arc
→ inserts into _ring_positions and _ring_nodes
get_node("user:123")
→ _hash("user:123") → position P
→ bisect(positions, P) → index of first position ≥ P
→ wrap to 0 if past end
→ return _ring_nodes[index]
get_nodes)
get_nodes("user:123")
→ find starting index via bisect
→ walk clockwise through _ring_nodes
→ collect nodes into result, skipping duplicates
→ stop when len(result) == replication_factor
The walk can traverse up to len(ringpositions) entries in the worst case (when most virtual nodes belong to a small number of physical nodes), but terminates early once enough distinct nodes are found.
1. ringpositions is always sorted — enforced by bisect.insort-style insertion (bisect_left + insert).
2. ringpositions and ringnodes are always the same length — every insert/remove touches both.
3. Each physical node gets int(num_vnodes * weight) virtual nodes — weight < 1.0 reduces presence on the ring.
4. getnodes always returns exactly replicationfactor distinct physical nodes — guaranteed by the precondition check that len(self.nodes) >= self.replicationfactor.
5. Adding the same node twice is a no-op — the early return if nodeid in self.nodes: return {} prevents duplicate virtual nodes.
6. Ring positions wrap at RINGSIZE (2^32) — arc computations and the getnode wrap-around handle this explicitly.
Errors are raised eagerly at the API boundary:
getnode / getnodes on an empty ring → ValueError("Empty ring")get_nodes with insufficient nodes for RF → ValueError with a message showing actual vs. required countremove_node for a non-existent node → ValueErroradd_node for an existing node → silent no-op (returns {})No try/except blocks exist in this module — it raises on invalid state and trusts callers to provide valid inputs.
consistent-hashing/testconsistenthashing.py — How the ring is tested for load balance, replication correctness, and node add/remove transfer accuracyconsistenthashing.py:getnodes — Compare this preference list construction to Dynamo's approach described in DDIA §6; note how virtual nodes complicate the "walk clockwise and collect N nodes" strategyleaderless-replication/dynamo.py — Likely consumes a consistent hash ring for its partitioning layer; see how the two modules composevirtual-node-count-tuning — The default of 150 vnodes is a significant design choice — explore how it affects load balance variance vs. memory/computation costtransfer-map-accuracy — The transfer computation in add_node may over-count when multiple virtual nodes for the new node land in the same existing arc; worth verifying against the testsch-ring-sorted-invariant — ringpositions is maintained in sorted order at all times; all lookups depend on this via bisectch-vnode-count-is-weighted — A node with weight w gets int(num_vnodes * w) virtual nodes, making weight=0.5 produce half the ring presencech-preference-list-skips-duplicates — get_nodes walks clockwise past virtual nodes belonging to already-seen physical nodes, ensuring RF distinct physical nodes in the resultch-transfer-maps-are-descriptive — addnode and removenode return transfer descriptions but do not move data themselves; the caller is responsible for acting on the mapch-md5-masked-to-32-bits — The hash function truncates MD5's 128-bit output to 32 bits via & 0xFFFFFFFF, matching the RING_SIZE of 2^32