Files

90 lines
3.0 KiB
Python

"""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