* refactor(routing): replace hash-ring rebalancer with rendezvous (HRW) hashing Routing was a stored bucket table maintained by a central rebalancer daemon, which shared its liveness primitive (services.last_heartbeat) with the collector — when a heartbeat-fresh node went into a zombie HTTP-handler-broken state, neither the collector nor the rebalancer could self-correct, and the router kept directing traffic at it. Rendezvous hashing makes the route a pure function of (ws_id, live_services) so the heartbeat is the single source of truth and any liveness-eviction propagates to the next route call without a separate state-publication step. The rebalancer's central state has no analogue: the new router computes the per-key node winner on every call, the collector pushes membership updates into the router cache from its discovery thread, and per-route overrides survive on workstream_overrides. Eager workstream migration goes away; in-flight workstreams lazily rehydrate from storage on the new owner — already the dead-node behaviour. * fix(tools): describe rendezvous re-routing on spawn/inspect node_id The first pass overclaimed `node_id` "stays canonical for this workstream's lifetime" — under rendezvous routing the active owner re-derives per-call from live membership, so a node join/drop after spawn can shift it. Tool descriptions now say `node_id` is the spawn-time binding; subsequent ops re-route via rendezvous over the current live-node set; the new owner lazily rehydrates from shared storage; coordinators should re-read with inspect_workstream rather than caching the value.
5.1 KiB
Consistent Hash Ring — Reference Design
Status: Reference — alternative routing strategy
Live routing uses rendezvous (HRW) hashing in
turnstone/core/rendezvous.py and turnstone/console/router.py. This
document captures a vnode-ring approach as a reference for future
evaluation if the cluster outgrows rendezvous's O(N)-per-route
characteristic.
The FNV-1a-32 hash function specified below is bit-identical to the hash used by the live rendezvous implementation; cross-language clients can rely on these test vectors.
When the ring approach becomes interesting
The vnode ring becomes preferable to rendezvous hashing when:
- Cluster size grows large (50+ nodes) and the per-route O(N) hash computation becomes visible against downstream HTTP cost.
- Decentralised routing is needed (each node computes the ring locally, no central console required).
- A precomputed flat-array lookup is desired so the routing hot path avoids hashing entirely.
Algorithm
Hash function: FNV-1a (32-bit)
def fnv1a_32(data: bytes) -> int:
"""FNV-1a 32-bit hash.
Basis: 0x811C9DC5, Prime: 0x01000193.
XOR each byte, then multiply by prime (masked to 32 bits).
"""
h = 0x811C9DC5
for b in data:
h ^= b
h = (h * 0x01000193) & 0xFFFFFFFF
return h
Known test vectors:
fnv1a_32(b"")=0x811C9DC5(basis value)fnv1a_32(b"foobar")=0xBF9CF968
Cross-language implementations:
- Python: loop above (no dependencies)
- Go: same algorithm with
uint32arithmetic - TypeScript: same algorithm with
>>> 0for unsigned 32-bit
Virtual nodes
Each physical node with weight w gets w * 150 virtual positions on a
16-bit ring (65536 positions). Virtual node i of physical node N is
placed at:
position = fnv1a_32(f"{N.node_id}:{i}".encode()) % 65536
With 150 vnodes per unit weight:
- 2 equal-weight nodes: ~50/50 split (measured: 38-62% range due to hash variance, stddev ~3% with large vnode counts)
- 3 nodes at weights 2:1:1: ~50/25/25 (within 10% tolerance)
Lookup
def owner(bucket: int) -> str:
"""O(log n) bisect-right walk to find the next virtual node clockwise."""
idx = bisect_right(positions, bucket)
if idx >= len(positions):
idx = 0 # wrap around
return vnode_map[positions[idx]]
Stability properties
The consistent hash ring guarantees:
- Node addition: adding a node moves at most
1/Nof buckets (where N is the new node count). Other nodes' buckets are unaffected. - Node removal: only the removed node's buckets are reassigned. Buckets owned by surviving nodes don't move.
- Determinism: same membership list always produces the same ring. No coordination needed between processes.
Full assignment precomputation
def assignments() -> list[tuple[int, str]]:
"""Compute all 65536 bucket-to-node mappings."""
return [(b, owner(b)) for b in range(65536)]
This produces a complete assignment table that can be loaded into a flat array for O(1) request-time lookup. The ring itself is never consulted on the hot path.
Data structures
@dataclass(frozen=True, slots=True)
class RingNode:
node_id: str
url: str
weight: int = 1
class HashRing:
"""Immutable consistent hash ring. Thread-safe (no mutable state)."""
def __init__(self, nodes: Sequence[RingNode], vnodes_per_unit: int = 150):
# Validate no duplicate node_ids
# Build sorted array of (position, node_id) tuples
# positions[i] = fnv1a_32(f"{node_id}:{i}".encode()) % RING_SIZE
def owner(self, bucket: int) -> RingNode | None:
# bisect_right + wrap
@property
def version(self) -> int:
# Deterministic hash of membership: fnv1a_32 of sorted node_id:weight pairs
def assignments(self) -> list[tuple[int, str]]:
# Precompute all 65536 bucket assignments
Comparison with rendezvous (HRW) hashing
| Aspect | Rendezvous (live) | Consistent hash ring (this doc) |
|---|---|---|
| Per-route cost | O(N) hash computes | O(log V) bisect against precomputed array |
| Seeding | None — pure function | Build vnode array on every membership change |
| Node addition | Pure function moves ~1/N keys | Ring moves ~1/N buckets |
| Node removal | Surviving nodes' keys unchanged | Surviving nodes' buckets unchanged |
| Decentralised | Yes — pure function over services | Yes — each node computes locally |
| Persistent state | None | None on the hot path; precomputed array in memory |
| Complexity | ~20 LOC | Virtual-node construction + bisect |
Test vectors
For cross-language implementation validation:
{
"fnv1a_32": [
{"input": "", "output": 2166136261},
{"input": "foobar", "output": 3215766888}
],
"bucket_of": [
{"ws_id": "a3f100000000000000000000000000000", "bucket": 41969},
{"ws_id": "00000000000000000000000000000000", "bucket": 0},
{"ws_id": "ffff0000000000000000000000000000", "bucket": 65535}
],
"ring_single_node": {
"nodes": [{"node_id": "n1", "weight": 1}],
"vnodes_per_unit": 150,
"expected_n1_buckets": 65536
}
}