Skip to content
Reliable Data Engineering
Practice problem hard hashingdistributed-systemsbisect
Solve it in the browser (Python editor)

Consistent Hashing Ring for Sharding

Difficulty: Hard · Topics: hashing, distributed-systems, bisect · Asked at: Amazon, Uber, Discord, Cloudflare

Problem

Implement HashRing(nodes, vnodes=100) with add(node), remove(node) and get(key) -> node. Each node is placed on the ring at vnodes positions (hash of f"{node}#{i}"); a key belongs to the first position clockwise from hash(key). Use a stable hash (e.g. MD5 → int), not Python’s randomised hash().

Starter code

class HashRing:
    def __init__(self, nodes: list[str], vnodes: int = 100):
        pass

    def add(self, node: str) -> None:
        pass

    def remove(self, node: str) -> None:
        pass

    def get(self, key: str) -> str:
        pass

Hints

Hint 1

Keep a sorted list of positions and a dict position → node; bisect finds the successor; wrap around at the end.

Solution

import bisect, hashlib

def _h(s: str) -> int:
    return int(hashlib.md5(s.encode()).hexdigest(), 16)

class HashRing:
    def __init__(self, nodes: list[str], vnodes: int = 100):
        self.vnodes = vnodes
        self.ring: list[int] = []
        self.owner: dict[int, str] = {}
        for n in nodes:
            self.add(n)

    def add(self, node: str) -> None:
        for i in range(self.vnodes):
            p = _h(f"{node}#{i}")
            bisect.insort(self.ring, p)
            self.owner[p] = node

    def remove(self, node: str) -> None:
        for i in range(self.vnodes):
            p = _h(f"{node}#{i}")
            self.ring.pop(bisect.bisect_left(self.ring, p))
            del self.owner[p]

    def get(self, key: str) -> str:
        if not self.ring:
            raise KeyError("empty ring")
        i = bisect.bisect_right(self.ring, _h(key)) % len(self.ring)
        return self.owner[self.ring[i]]

Tests

Your solution should pass these:

keys = [f"user-{i}" for i in range(5000)]
ring = HashRing(["a", "b", "c", "d"])
before = {k: ring.get(k) for k in keys}
share = {n: sum(1 for v in before.values() if v == n) / len(keys) for n in "abcd"}
assert all(0.15 < s < 0.35 for s in share.values()), share       # roughly balanced
ring.add("e")
after = {k: ring.get(k) for k in keys}
moved = sum(before[k] != after[k] for k in keys) / len(keys)
assert 0.1 < moved < 0.3, moved                                  # ~1/5 move, not ~4/5
assert all(after[k] == "e" for k in keys if before[k] != after[k])  # keys only move TO the new node
ring.remove("e")
assert {k: ring.get(k) for k in keys} == before

Explanation

With naive hash(key) % N, changing N remaps ~all keys (a reshuffle storm). With consistent hashing only keys between the new node’s positions and their predecessors move, about 1/N. Virtual nodes smooth the distribution and let you weight bigger nodes with more positions. Used by DynamoDB/Cassandra partitioning, CDN routing, cache clusters. Contrast with Kafka, where adding partitions does remap keys (hash % partitions), which is why you over-provision partitions.