"""Consistent hashing ring with virtual nodes. A minimal, dependency-free implementation suitable for distributing keys across a dynamic set of nodes with minimal rebalancing. """ import bisect import hashlib from typing import Any, Iterable, List, Optional class ConsistentHashRing: def __init__(self, num_vnodes: int = 100): if not isinstance(num_vnodes, int) or num_vnodes < 1: raise ValueError("num_vnodes must be a positive int") self.num_vnodes = num_vnodes self._ring: dict[int, Any] = {} # position -> node self._sorted: List[int] = [] # sorted positions self._nodes: set = set() @staticmethod def _hash(key: Any) -> int: # 128-bit digest collapsed to int; deterministic across processes. return int(hashlib.md5(str(key).encode("utf-8")).hexdigest(), 16) def add_node(self, node: Any) -> None: if node in self._nodes: return self._nodes.add(node) for i in range(self.num_vnodes): pos = self._hash(f"{node}#{i}") # handle (extremely unlikely) collision by nudging while pos in self._ring: pos = (pos + 1) & ((1 << 128) - 1) self._ring[pos] = node bisect.insort(self._sorted, pos) def remove_node(self, node: Any) -> None: if node not in self._nodes: return self._nodes.discard(node) # rebuild positions belonging to node to_delete = [p for p, n in self._ring.items() if n == node] for p in to_delete: del self._ring[p] idx = bisect.bisect_left(self._sorted, p) if idx < len(self._sorted) and self._sorted[idx] == p: del self._sorted[idx] @property def nodes(self) -> List[Any]: return sorted(self._nodes, key=lambda x: str(x)) def get_node(self, key: Any) -> Optional[Any]: if not self._sorted: return None pos = self._hash(key) idx = bisect.bisect_left(self._sorted, pos) if idx == len(self._sorted): idx = 0 return self._ring[self._sorted[idx]] def get_nodes(self, key: Any, n: int) -> List[Any]: if n < 1: raise ValueError("n must be >= 1") if not self._sorted: return [] result: List[Any] = [] seen: set = set() pos = self._hash(key) idx = bisect.bisect_left(self._sorted, pos) if idx == len(self._sorted): idx = 0 start = idx # walk ring collecting distinct nodes i = 0 total = len(self._sorted) while len(result) < n and i < total: node = self._ring[self._sorted[(start + i) % total]] if node not in seen: seen.add(node) result.append(node) i += 1 return result def __len__(self) -> int: return len(self._nodes) def __contains__(self, node: Any) -> bool: return node in self._nodes