M2 Raft Hardening — Status & Handoff¶
Branch
feat/m2-raft-hardening(offmain, which carries the full M1 stack). Scope: the multi-Raft follow-ups documented onCONCEPT:EG-KG.sharding.raft-resharding— pooled per-peer connections, group-per-tenant-range routing, per-group snapshot scoping, leader balancing across groups, heartbeat coalescing.This branch is NOT pushed, NOT deployed. Single-group behavior is byte-for-byte unchanged; every addition activates only under a multi-group / multi-node config.
All file:line references are against this branch's tree.
DONE — implemented + lib-tested¶
1. Pooled per-peer Raft connections — CONCEPT:AU-KG.ontology.manage-arbitrary¶
- What: the scaffold opened a fresh
TcpStreamper append/vote/snapshot RPC and dropped it after one round-trip (a connect+handshake per heartbeat, per peer, per group). A sharedPeerPoolnow keeps a bounded set of WARM connections per peer address, reused across RPCs and across ALL groups on the node (one pool perMultiRaft, mirroring the already-shared inbound listener). - Where:
src/raft/network.rs:94struct PeerPool(idle map +opens/reusescounters).src/raft/network.rs:148PeerPool::round_trip— take-warm-or-connect, with the stale-retry contract: a reused connection that fails on the first frame is discarded and the call retries ONCE on a fresh connection, so openraft never sees a spurious failure. Wire is strict request→response on one stream (no correlation id), so a connection is loaned out exclusively for one round-trip and only returned to the idle set if it SUCCEEDED.src/raft/network.rs:220GroupNetworkFactorycarriespool: Arc<PeerPool>;src/raft/network.rs:264GroupNetworkClient::round_tripnow callsself.pool.round_trip(...)instead ofTcpStream::connectper call.src/raft/multi.rs:179MultiRaft.poolfield, created atmulti.rs:223(network::PeerPool::new()), threaded into every group's factory atmulti.rs:328; exposed viaMultiRaft::pool()(multi.rs:263).- Proven by:
src/raft/tests.rs:600peer_pool_reuses_warm_connection— three sequential RPCs to one echo peer pay exactly ONE TCP connect (opens()==1,reuses()>=2).src/raft/tests.rs:642peer_pool_retries_on_stale_connection— an echo server that closes after one frame forces a reconnect; the second round-trip still succeeds (opens()==2, no hard error).
2. Group-per-tenant-range routing ring — CONCEPT:AU-KG.ingest.mirror-inbound¶
- What:
GroupRouter::group_ofnow resolvesoverride → tenant-range ring → DEFAULT_GROUP. The ring is a sorted, de-duplicated set of group ids that un-pinned graphs hash-distribute across via a stable FNV-1a hash (identical on every node, never persisted — NOTRandomState). With no ring configured (the default) every un-pinned graph maps toDEFAULT_GROUP— byte-for-byte the single-group scaffold. - Where:
src/raft/multi.rs:62GroupRouter.ringfield +fnv1ahelper.src/raft/multi.rs:86group_of,multi.rs:101set_group_ring,multi.rs:110group_ring.src/raft/multi.rs:273MultiRaft::configure_group_ring(n)— brings up groups0..non this node and installs the ring;n<=1is a no-op (single-group).- Proven by:
src/raft/tests.rs:678group_router_distributes_tenants_across_ring— empty ring ⇒ all toDEFAULT_GROUP; a 4-group ring spreads 200 tenants across ≥2 groups; deterministic on repeat;assignoverride beats the ring;is_cross_shardworks across real ranges; empty ring collapses back to default.
3. Per-group snapshot scoping — CONCEPT:AU-KG.ingest.staged¶
- What: a group's snapshot dump is SCOPED to the graphs whose tenant range resolves to THIS group, so a large tenant in one group never bloats another group's snapshot. A store opened WITHOUT a router (the direct/single-store path) dumps the whole registry, preserving the scaffold behavior.
- Where:
src/raft/mod.rsAppCtx.router: Option<Arc<multi::GroupRouter>>— threaded through everyAppCtxconstruction (node/harnesses/tests =None;MultiRaft::create_groupbuilds astore_ctxcarryingSome(self.router)atsrc/raft/multi.rs:311).src/raft/store.rs:224dump_graphsfiltersall_entries()byrouter.group_of(name) == self.group_idwhen a router is present.src/raft/store.rs:247scoped_snapshot_graph_names(#[cfg(test)]) — the test seam.- Proven by:
src/raft/tests.rs:725group_snapshot_is_scoped_to_its_tenant_range— two graphs pinned to non-default groups 3 and 5; group 3's snapshot carries ONLYgraphA, group 5's ONLYgraphB, and the DEFAULT group (0) carries ONLY the un-pinned bootstrap__commons__(no bleed across any of them); a router-less store dumps the WHOLE registry (__commons__+ both graphs), proving the unscoped path is intact.
Build: cargo build --release --features "full,cluster" -j8 → clean (exit 0).
Tests: cargo test --release --features "full,cluster" --lib raft → see REPORT
(all single-group raft tests stay green; the 4 new M2 tests pass).
DONE (R1/R2/R3) — branch feat/m2-r1r3-completion (off the base above)¶
All three remaining M2 items are now implemented + lib-tested on top of the base. Single-group default behavior is unchanged — the full pre-existing
--lib raftsuite (incl. the 3-node failover test + the nemesis gauntlets) stays green. Concept-ID note: the original plan reserved EG-KG.storage.kg-kg-2 (R1) and KG-2.269 (R2). By the time this branch landed, KG-2.269 had been claimed by another session (shard-commons, "Ingestion graph routing"), so the heartbeat work uses a fresh id. Final mapping: R3 = EG-KG.storage.kg-kg-2, R1 = EG-KG.sharding.multi-raft, R2 = EG-KG.storage.concept-2 (all reserved in the ledger).
R3. Per-group multi-NODE membership join — CONCEPT:EG-KG.storage.kg-kg-2¶
- What: a group can now be grown from a single-node bootstrap to span multiple NODES via the openraft add-learner → change-membership lifecycle, per group, over the shared listener.
- Where (
src/raft/multi.rs): join_group(gid, peers)— stand a group up on a node as an EMPTY, non-bootstrapping member (neverinitializes) ready to receive replication.add_group_member(gid, new_node, addr)— leader-side:add_learner(.., blocking)(blocks until the joiner is up-to-date) thenchange_membership(voters ∪ {new}).remove_group_member(gid, node)— leader-sidechange_membershipto a reduced voter set; idempotent; refuses to remove the LAST voter.group_membership(gid)— the current sorted VOTER set from the replicated metrics.- Proven by:
src/raft/tests.rsmulti_node_group_join_then_leader_rebalance— node 1 single-member-bootstraps group 7, nodes 2/3join_groupit empty, the leaderadd_group_members both → membership becomes[1,2,3], and a write through the leader replicates to the two freshly-joined voters.
R1. Leader balancing across groups — CONCEPT:EG-KG.sharding.multi-raft¶
- What:
MultiRaft::rebalance_leaders()spreads leadership so one node isn't leader for every group. EVERY node runs the pass (like a real cluster); per group it computes the deterministic round-robin target (desired_leader(gid, sorted_voters)— identical on every node, no coordination) and either claims a group it should lead (triggers an election, rate-limited per group byELECT_COOLDOWN= 10s so it never flaps) or yields a group it leads but shouldn't (disables its heartbeat so the lease expires and the target can take over). No-op for single-voter groups + already-balanced groups. Returns aRebalanceReport { targets, elected, yielded, errors }. - openraft-0.9 reality (important): openraft 0.9.24 has NO
transfer_leader(it arrives in 0.10 astrigger_transfer_leader), and its anti-disruption / leader- stickiness makes followers REJECT a challenger's vote while they still hear from a healthy leader. So a triggered election ALONE cannot move leadership off a healthy incumbent — the incumbent must cooperatively yield (stop heartbeating). That is the yield half above; with both halves the cluster converges to the round-robin spread within a couple of election timeouts. - Where (
src/raft/multi.rs):desired_leader(pure fn),RebalanceReport,MultiRaft::rebalance_leaders+may_trigger_elect(cooldown),last_electfield. - Proven by:
src/raft/tests.rsdesired_leader_round_robin_spreads_across_voters(unit: the selection spreads across all voters, deterministic) and the integration half ofmulti_node_group_join_then_leader_rebalance— over the 3-voter group 7 the target is node 2 (7 % 3), the first pass shows node 1yielded+ node 2elected, periodic passes converge leadership to node 2, and a further pass is a no-op (idempotent).
R2. Heartbeat coalescing — CONCEPT:EG-KG.storage.concept-2¶
- What: same-destination Raft HEARTBEATS (empty-entries
AppendEntries) across N groups coalesce into ONE batched frame to that peer instead of N frames per heartbeat interval; the batch rides the AU-KG.ontology.manage-arbitraryPeerPool(one connect-amortized round-trip). Only heartbeats coalesce — log-bearing appends / votes / snapshots stay individual (latency/ordering-sensitive). - Where (
src/raft/network.rs): RaftFrame { One(GroupRpc) | Batch(Vec<GroupRpc>) }+RaftFrameReply— the top-level wire envelope; a single-RPC frame isOne, so the existing per-group path is behavior-identical (and the 3-node + 2-group tests exercise it).HeartbeatCoalescer— buffers heartbeats per peer (offergates onis_heartbeat),drain_batches()builds one ordered batch per peer,send_batch()ships it over the pool and returns the ordered per-RPC replies;coalesced/flushescounters.src/raft/multi.rsserve_conndemuxes aBatchby dispatching each tagged sub-RPC to its group and replying in the SAME order.- Proven by:
src/raft/tests.rsheartbeat_coalescer_batches_per_peer(unit: bucket-by-peer, non-heartbeats refused, counters) andcoalesced_batch_round_trips_on_one_connection(a 3-heartbeat batch demuxes to 3 ordered replies over EXACTLY ONE TCP connect on a live listener).
Build: cargo build --features "full,cluster" --lib → clean. Tests:
cargo test --features "full,cluster" --lib raft → 23 passed, 0 failed. My three
changed files are cargo fmt + cargo clippy clean (the remaining workspace clippy
warnings pre-date this branch in M1 redb_backend.rs / query handlers / eg-query).
DONE — openraft 0.9 → 0.10 migration + native handoff (CONCEPT:AU-KG.backend.authority-has-already-acked)¶
Branch
feat/openraft-010-cluster. Bumped openraft to 0.10 (pinned=0.10.0-alpha.26, the line that carriestransfer_leader) and migrated the wholesrc/raft/API surface to the v2 split-storage model. The cooperative yield-then-claim leader balancing (EG-KG.sharding.multi-raft) is replaced by the native graceful handoff. Seedocs/architecture/cluster_deployment.mdfor the multi-node deploy.
- Storage = v2 split traits. openraft 0.10 removed the combined
RaftStorage+ theAdaptor.EgStorenow implementsRaftLogStorage(+ super-traitRaftLogReader) ANDRaftStateMachine(+RaftSnapshotBuilder) onArc<EgStore>;create_grouppasses the SAMEArcas both (no adaptor). Every storage method returnsstd::io::Error(the 0.9StorageError/StorageIOErrorconstructors are gone); types use the…Of<C>aliases.appendnow signals durability via anIOFlushedcallback (fired right after our group-commit fsync);applyconsumes aStreamof(entry, responder)andsends each response;delete_conflict_logs_since/purge_logs_uptobecametruncate_after/purge; the chunkedinstall_snapshotbecame full-snapshot transfer. - Network =
RaftNetworkV2. The deprecated v1RaftNetworkwas deleted; the client implementsRaftNetworkV2(blanket-deriving theNet*sub-traits the factory needs). The snapshot RPC is now one taggedfull_snapshotframe (vote + meta + body) → the follower'sinstall_full_snapshot.RPCError<C>is single-generic. A newtransfer_leaderRPC forwards the handoff notification to the target. - R1 native instant handoff (DONE).
MultiRaft::rebalance_leadersno longer yields heartbeats — for each group THIS node leads whose round-robin target is elsewhere, it issues the nativeraft.trigger().transfer_leader(target)(graceful, near-instant; openraft hands a fresh term + the leader vote to the target and notifies it to campaign at once).RebalanceReport.{elected,yielded}collapse totransferred. Rate-limited per group byTRANSFER_COOLDOWNso it never spams transfers. - Tests green.
cargo test --features "full,cluster" --lib raft— the full pre-existing suite (incl. the 3-node failover + join-then-rebalance, now asserting the native transfer) passes against the 0.10 API. Validate withscripts/validate-raft-cluster.sh.
STILL REMAINING — needs real multi-node hardware (not in-process testable)¶
- R2 under-openraft wiring. The coalescer + batch wire are complete and tested, but
hooking them under openraft's per-group heartbeat cadence (an enqueue + short flush
timer that fans each batched reply back to its awaiting
append_entriescaller, so the engine's ACTUAL heartbeat ticks batch) — and measuring the frames-saved/latency benefit — needs a real multi-node cluster under steady replication. - Both: true cross-host soak (leadership actually rebalancing across machines, heartbeat-frame reduction under load) needs multi-node hardware; the in-process harness proves correctness, not the distributed performance win.
See also: Capabilities matrix · Engine Scaling Program · Catalog-driven Resharding · Cluster Deployment · Distribution / Robotics / GPU.