ClusterNode hosts a BTreeMap<ShardId, Arc<ShardReplica>>: writes hash-route to the owning shard leader, reads scatter over shard groups. In-group shard==region preserved so the engine and tidal-net are untouched; S=1 stays byte-for-byte (today's cluster is a 1-shard × RF=N group). Topology grows shard-group awareness; membership, election, forward, reseed, and join_boot thread ShardId through. Proven by an in-process 2×2 RF=2 gRPC test plus S=1 parity, incl. tier-3 real-OS-process failover. clippy/fmt clean.
15 KiB
m11p6 — Sharding × Replication + Rebalancing (IN PROGRESS)
Phase spec and exit gate: docs/roadmap-to-cluster.md §4/m11p6. Closes the ROADMAP write-scaling gap ("EITHER replicated (1 leader for everything) OR sharded (no replication)"). Predecessors: p1 (ship queue/perf), p2 (one log), p3 (quorum/commit-index), p4 (election/fencing), p5 (membership/discovery/elasticity).
Goal: writes scale horizontally without giving up replication. The
"replicated XOR sharded" split ends: S shard groups, each a replication group
at RF (default 3) with its own WAL/relay/commit-index and its own elected
leader, leaders balanced across nodes; regions demote to placement labels;
any gateway hash-routes to the shard leader (write) or nearest replica (read);
rebalancing = snapshot + stream + fenced cutover.
The load-bearing realization (why this is tractable, not a rewrite)
A single TidalDb is already a complete, self-contained shard (scale-arch
spec §1; single-process mode already runs N real TidalDb in one process). And
today's cluster is exactly "1 shard × RF=N" — every region replicates one
log under one leader. So m11p6 = run today's whole single-shard cluster
machinery N times, once per data-shard group, where each group spans only
its replica nodes, in its own data subdir + gRPC port. The data-shard number
S is purely a gateway-routing + directory + port concern; inside a group,
"shard id == region id" is preserved unchanged.
Consequences:
- Engine replication (relay/commit/election/ship/receiver/membership): NO semantic change — each runs per-group exactly as today's whole cluster.
tidal-net: NO change — each group owns its ownGrpcTransportbound to a distinct port, peering only with that group's replica nodes.S=1is byte-for-byte today's behavior — every existing tier-3 suite (quorum, membership, election, region, runbook, chaos) runs inS=1mode and stays green by construction.- The work concentrates in
tidal-server: decompose the per-shard machinery out ofRegionClusterStateintoShardReplica; the node hostsBTreeMap<ShardId, Arc<ShardReplica>>; add hash-routing to the right group's leader. Plus topology schema, route unification, rebalancing, stress/harness, the exit gate, docs.
Design (as adopted)
1. Two types: process node + shard replica
As-built note (decided during L1): the §1-era naming below was superseded. The process node is a NEW type
ClusterNode(not "keep the nameRegionClusterState"); the formerRegionClusterStatewas renamed toShardReplica(it already WAS one group's machinery). The responsibilities are exactly as described — only the node's type name changed from the plannedRegionClusterStatetoClusterNode.
ClusterNode(NEW process node) = the process node and the axumState. Owns the node identity (region/region_name), the gatewayShardRouter+node_httpmap, the shared forward client, andgroups: BTreeMap<ShardId, Arc<ShardReplica>>(the groups this node hosts) plus aplacementmap of every group (to forward writes for groups it does NOT host). The HTTP handlers stay free functions overState<Arc<ClusterNode>>; each hash-routes the target group and either calls a localShardReplicamethod or forwards to that group's leader.ShardReplica(the renamedRegionClusterState) = one shard group's full replication machinery, essentially the per-shard fields + methods of the oldRegionClusterState:db: Arc<TidalDb>,ship_feed,ship_queue,commit+commit_watchbridge,election_runtime/election_store/election_boot,membership: MembershipView+membership_store,stream_baseline, snapshot source +reseed_marker_store,leader: RwLock<Option<RegionId>>,activation_prev,partitioned,deferred_retires,ack_default,quorum_timeout, and its ownGrpcTransportbound to this group's port. Construction = today'sRegionClusterState::newbody, parameterized by(shard, replica nodes, bootstrap leader, group data subdir, group grpc port).
2. Shard identity, ports, and data dirs
- In-group identity is region-id based (preserved from today).
ShardReplicafor data-shardSon nodeNopensTidalDbwithNodeConfig{ shard_id: ShardId(N.region_id), peer_shards: <other group-S nodes' region ids>, .. }. Two groups on one node use the sameshard_idvalue but live in separate data dirs + separate transports, so nothing collides. The data-shardSnever enters the replication wire. - gRPC ports: each replica entry may set an explicit
grpc_addr/grpc_bindper(node, shard); otherwise derivenode.base_port + S. LegacyS=0→ offset 0 → the region's declared address verbatim. - Data dirs: explicit
shards:→<data_dir>/shard-{S:05}/per hosted group. Legacy (synthesized 1-shard) →<data_dir>verbatim (existing on-disk clusters restart unchanged). /metrics: the node owns ONE listener onmetrics_addr; the engine's per-TidalDbmetrics HTTP server is suppressed in cluster mode. The node renders each group'scluster_metricswith ashard="S"label and the per-shard engine metrics likewise.
3. Topology schema (backward compatible)
TopologySpec gains optional shards: Option<Vec<ShardSpec>> and RegionSpec
gains optional zone: Option<String> (placement/read-affinity label), both
#[serde(default)].
nodes: # `regions:` still accepted as the node list
- { name: us-east, grpc_addr: ..., http_addr: ..., zone: az-a }
shards: # NEW — optional
- id: 0
leader: us-east # term-0 / preferred leader (balances placement)
replicas: # nodes hosting this group's RF replicas
- { node: us-east } # grpc_addr/grpc_bind optional → derive base+S
- { node: eu-west }
- { node: ap-south }
- { id: 1, leader: eu-west, replicas: [...] }
- { id: 2, leader: ap-south, replicas: [...] }
Absent shards: ⇒ legacy synthesis: one ShardSpec{ id: 0, leader: topology.leader, replicas: <every region, grpc verbatim> }. This is
literally today's "1 shard × RF=all-regions," so the model unifies cleanly.
Validation: shard ids dense & unique; each leader/replica.node names a
declared region; RF ≥ 1; a node's shards do not collide on derived ports.
4. Routing (the new gateway layer)
Any node, any write to entity E:
S = ShardRouter.route(E)(the engine's FNV-1a hash; the same router the/sharded/*path already uses).- If this node hosts group
Sand leads it → apply locally onShardReplica[S](the existing stage→complete→ship→await-quorum path). - If this node hosts group
Sbut follows → forward to groupS's leader (resolved fromShardReplica[S].leader), to the leader node'shttp_addr. - If this node does not host group
S→ forward to any replica node ofS(from topology); that node routes to the leader. Term fencing + theNotLeaderretry bound the forward chain.
Reads route to a local replica of S if present, else nearest (zone) / any
replica. Scatter-gather merges over shard GROUPS: one replica per group,
totals summed (entity-sharded groups are disjoint — no dedup-by-max), the
existing degraded/unavailable_shards/deadline semantics retained.
Route unification: /items, /embeddings, /signals become the shard-
routed surface (they hash-route instead of forwarding to a single global
leader); /sharded/* is retained as an alias of the same path. x-tidal-ack,
x-tidal-seq, quorum await, and NotLeader/QuorumTimeout all become
per-shard; NotLeader names the shard and its leader.
5. Leadership balance
ShardSpec.leader is each group's term-0 / preferred leader; the operator (and
the test harness) set shard i's leader to node i for balanced placement.
Each group boot-classifies its own leader exactly like today's topology.leader,
but per group. On failover, the group's election picks the surviving max-applied
voter (m11p4 protocol, unchanged); only the dead node's groups elect. An
optional leadership-transfer-back-to-preferred is the rebalance path, not the
availability mechanism.
6. Rebalancing (operator-triggered)
Shard move / replica change reuses m11p5 verbatim, per group: a conf-change
on the group's own log adds a Learner replica (a new node, or an existing node
gaining a replica of group S), which catches up via FetchSnapshot +
StreamSegments (per-group, already per-instance once §1 lands), auto-promotes
Learner→Voter, then the operator may transfer leadership; removing a replica is
the m11p5 fenced removal on that group's log. Endpoints:
POST /cluster/shards/{id}/replicas (add/remove) and
POST /cluster/shards/{id}/transfer. Per the roadmap, automatic rate-limited
rebalancing is explicitly "later" — m11p6 ships the operator verbs.
Exit gate (from the roadmap)
- 3 shards × RF=3: ≥5,000 quorum signals/s (≥2.5× single-shard p3).
- Kill any node → only its shard-leaderships move (<10 s), reads never stop.
tidal-stresssharded-vs-replicated comparison collapses into one path.
Layer plan (each keeps the workspace green)
- L0 — this doc + topology schema (
shards:/zone:+ validation + legacy synthesis).S=1unchanged. - L1 — extract
ShardReplica; node hosts a one-entry map; suppress engine metrics server in cluster mode + node aggregates.S=1byte-for-byte. - L2 — multi-shard construction + gateway hash-routing + per-shard forward + route unification + scatter-gather over groups.
- L3 — operator rebalancing verbs.
- L4 — tidal-stress path collapse + nodes×shards tier-3 harness +
cluster_sharding.rsexit gate (run for real). - L5 — docs (runbook/monitoring/roadmap/CHANGELOG/k8s/spec) + memory.
Status
-
L0 topology schema (
shards:/zone:+ resolver + validation; legacy synthesis) + this doc — 20 topology tests green incl. 5 new shard tests. -
L1
RegionClusterState→ShardReplicarename + group-parameterizedShardReplica::new(.., group, enable_metrics)(leader/peers/voters/data- dir/metrics from the resolved group; in-group identity stays region-based). S=1 byte-for-byte green (116 lib + cluster_region 11 + cluster_routes 6 + cluster_grpc 2). NB: theClusterNodeprocess wrapper + multi-shard hosting moved to L2 (inseparable from the routing/handler rewiring). -
L2 DONE + green (the sharding × replication data plane).
ClusterNode(the axum State + process handle) hostsBTreeMap<ShardId, Arc<ShardReplica>>, built byClusterNode::new(topology, region, schema, profiles, data_dir, hlc)(resolves groups; opens oneShardReplicaper hosted group — own data subdirshard-<id>/+ derived/explicit gRPC port forS>1; node data dir verbatim + metrics owner forS=1).ServeState for ClusterNodestarts every group's election driver and shuts each down viaArc::try_unwrap. Entity writes (/items,/embeddings,/signals,/hardnegs) hash-route viaroute_entity→ local replica (existing leader/forward path) orforward_to_group_node(remote group). Reads (/feed,/search) scatter in-process over hosted groups and merge (sum totals, score-sort — S=1 unchanged)./cluster/status/localgains a per-shardshards[]array;region_healthaggregates all groups. Verified: in-process 2 nodes × 2 shards × RF=2 test (region_sharded_writes_route_per_shard_and_reads_scatter) — writes route per-shard (A forwards shard-1 writes to B), each shard replicates to its follower, reads scatter over both groups and return all items from BOTH shards, over real gRPC. S=1 byte-for-byte: 116 lib + cluster_region 12 + cluster_routes 6 + cluster_grpc 2 + tier-3cluster_multiproc5 (real OS processes: replication <2s, leader-crash failover <10s). clippy-D+ fmt clean. Deferred to L2-followups (tracked): per-shard admin verbs (?shard=on promote/heal/partition/etc.) and/sharded/*→unified-path aliasing — currently the admin verbs +/sharded/*target the first hosted group (correct forS=1; multi-shard admin needs the selector).Known L2 limitations —
S>1only, FULL-placement assumed (tracked for L4, NOT silently dropped): these are correct forS=1and for the headline exit-gate shape (3×3 RF=3, where every node hosts every group), and the writes proven by the 2×2 test are unaffected — but a genuinely PARTIAL placement (a node hosting a strict subset of groups) hits them:- Unified reads are local-only.
/feed///searchscatter over the groups THIS node hosts (hosted_dbs); a node that does not host every group returns a strict-subset corpus with a 200 and nodegradedflag. Complete only under full placement; cross-node read fan-out (reuse the/sharded/*HttpShardContext) is the L4 item. Use/sharded/*for partial placements. - No cross-shard diversity re-rank. The scatter merge keeps each group's
own diversity pass then score-merges; it does not re-run diversity ACROSS
groups, so an
S>1/feedcan be less diverse thanS=1. Summing totals over disjoint groups is exact for cardinality; a cross-shard MMR re-rank (engine entry point) is the L4 follow-up. S>1observability is one-group-deep. Only the metrics-owner group binds the engine/metricslistener; the other hosted groups' per-shardcluster_metrics(ship/relay/commit/quorum) are not rendered. The kill-node gate uses/cluster/status/local(and now/cluster/status)shards[], which IS per-group; node-level per-shard/metricsrendering (design §2) is the L4/p8 item.
- Unified reads are local-only.
-
L3 rebalancing verbs (operator shard move; reuse m11p5 per-group)
-
L4 tidal-stress path collapse + nodes×shards tier-3 harness +
cluster_sharding.rsexit gate (3×3 kill-node + per-shard ledger; run for real). NB: the ≥5,000/s throughput sub-gate is a Ref-A line item — k3s access has blocked the Ref-A runs since p1 (same standing caveat). -
L5 docs (runbook/monitoring/roadmap/CHANGELOG/k8s/spec) + memory
Exit-gate evidence
Data-plane correctness (local, in-process, real gRPC) — DONE. The 2×2 test
proves the replicated-XOR-sharded split is gone: a single gateway routes writes
to the owning shard's leader (forwarding cross-node), each shard group replicates
independently, and corpus reads merge across groups. The per-shard election +
quorum + commit machinery is the unchanged m11p4/m11p3 code instantiated per
group, and S=1 failover is re-proven over real OS processes (cluster_multiproc).
Remaining for the headline exit gate: the 3 shards × RF=3 tier-3 run over real
OS processes (kill any node → only its shard-leaderships move <10 s, reads never
stop; per-shard zero-acked-loss ledger) and the ≥5,000/s tidal-stress figure
(Ref-A-pending). These need the L4 harness extension (per-(node,shard) ports +
shards: emission) + the rebalance verbs (L3).