Files
turnstone/docs/design/consistent-hash-ring.md
T
Patrick Buckley 471d1a3311 docs: audit documentation for 1.4 / 1.5 state
Systematic pass over every doc under docs/, the root-level README /
QUICKSTART / CONTRIBUTING, and the PlantUML diagrams.  Memory and docs
had drifted against the code since 1.2 — this catches them up to the
1.4.0 release and the 1.5.0a1 experimental line.

User-facing fixes
- README: fix broken docs/mcp.md link (→ mcp-registry.md); channel
  gateway entry reflects shipped Discord + Slack adapters instead of
  "Slack/Teams planned"; diagrams table mentions both.
- QUICKSTART: docs/*.md relative links were wrong from the repo root;
  wizard version bumped from 0.5.4.
- CONTRIBUTING: add dev extra plus the ruff / mypy / pytest commands
  we actually expect before push.

Reference docs
- architecture.md: 19 tool schemas (was 15), 18 admin tabs (was 14),
  turnstone-bootstrap added to entry-points table, OpenAI provider
  file split (chat/responses/common) documented, 38 SDK event
  dataclasses (was 27 and referenced deleted mq/protocol.py), Slack
  adapter + multi-adapter gateway, plan_agent/task_agent naming,
  governance admin-panel rewrite.
- api-reference.md: full attachment endpoints (POST/GET/content/
  DELETE on /v1/api/workstreams/{ws_id}/attachments) plus the
  multipart mode on POST /v1/api/workstreams/new.
- channels.md: Slack Setup section (Socket Mode app creation, OAuth
  scopes, tokens), Slack CLI/env reference in config table, combined-
  adapter architecture diagram.
- console.md: 18-tab listing (was 13) with Channels/Models/Nodes/TLS
  descriptions and ConfigStore live-edit note.
- docker.md: Slack env vars block; image entry-point list now
  includes turnstone / turnstone-bootstrap.
- sdk.md: attachments methods on the server client, attachments
  example (upload-then-send and at-creation), event count fixed.
- releasing.md: four-track table (stable/1.0, 1.3, 1.4 + main 1.5);
  promotion workflow uses 1.5 / 1.6 numbering.
- settings.md: plan_model / task_model / plan_effort / task_effort
  overrides section.
- governance.md: skill naming (/skill, `skill` field — not /template),
  Prompts/Judge tabs called out.
- security.md: two-token-types wording; src claim values match the
  AuthResult source strings actually emitted.
- mcp-registry.md: SDK package name is @turnstone/sdk.
- tools.md: plan / task renamed to plan_agent / task_agent in the
  section headings and summary table; primary-key table matched.
- design/consistent-hash-ring.md: dead direct-http-transport.md
  pointer redirected to architecture.md.

Diagrams
- 02-package-structure: drop phantom chat.py entry point, add admin
  and bootstrap, add slack/bot.py, rename channels/gateway.py →
  channels/cli.py.
- 16-channel-architecture: Slack is no longer "(future)", add a
  SlackBot class and the slack-bolt Socket Mode edges; wire the new
  bot into ChannelService.  PNGs regenerated from both puml sources.
2026-04-16 16:01:24 -07:00

5.4 KiB

Consistent Hash Ring — Reference Design

Status: Reference (not currently in the hot path) Date: 2026-03-30

Overview

This document describes a consistent hash ring algorithm evaluated during the design of the direct HTTP transport routing system. The current implementation uses weight-proportional bucket assignment with a donor/recipient rebalancing algorithm (see the routing section of ../architecture.md). The consistent hash ring is documented here as a reference for future scalability work — if the cluster grows beyond the point where the weight-proportional approach is sufficient, the ring provides a proven alternative with stronger stability guarantees.

When to consider the ring approach

The current weight-proportional seeding + donor/recipient rebalancer works well when:

  • Cluster size is moderate (< 50 nodes)
  • Nodes join/leave infrequently
  • The rebalancer runs centrally (in the console)

The consistent hash ring becomes advantageous when:

  • Cluster size grows large (50+ nodes) and frequent membership changes cause the donor/recipient algorithm to churn
  • Decentralized routing is needed (each node computes the ring locally, no central console required)
  • Cross-language determinism is important (multiple implementations must agree on the same assignment without sharing state)

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 uint32 arithmetic
  • TypeScript: same algorithm with >>> 0 for 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/N of 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 current approach

Aspect Weight-proportional (current) Consistent hash ring
Seeding Exact weight split, deterministic Hash-based, ~3% variance
Node addition Donor/recipient moves only excess Ring moves ~1/N buckets
Node removal Dead buckets → most underloaded Ring redistributes to clockwise neighbors
Cross-node churn Zero (only donor→recipient) Zero (ring stability guarantee)
Decentralized No (needs central rebalancer) Yes (each node computes locally)
Complexity Simple weight arithmetic 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
  }
}