Multi-node Raft cluster — deployment & data migration (CONCEPT:AU-KG.backend.authority-has-already-acked)¶
How to run the epistemic-graph engine as a highly-available Raft cluster across four runtime-selected nodes, and how to convert the current authoritative node (which already holds the authoritative redb data) into the SEED of that cluster without data loss. Built on openraft 0.10 (the v2 split-storage API + native graceful
trigger().transfer_leader()— seem2_raft_status.md).This document is a runbook. It does NOT perform the live cutover. The operator runs the steps below by hand, with a verified backup in place.
Claim status:
DESIGNED/deployable procedure only. A completed run becomesLAB-PROVENorLIVEonly when the exact binary/image digest, environment, backup/restore evidence, and observations are recorded. This page makes no1M-CERTIFIEDclaim.
0. Topology¶
| Raft node | Runtime host variable | Runtime address variable | Role |
|---|---|---|---|
| 1 | NODE_1_HOST |
NODE_1_ADDR |
SEED — already holds authoritative data; lowest id ⇒ bootstrap |
| 2 | NODE_2_HOST |
NODE_2_ADDR |
learner → voter |
| 3 | NODE_3_HOST |
NODE_3_ADDR |
learner → voter |
| 4 | NODE_4_HOST |
NODE_4_ADDR |
learner → voter (deployment manager when applicable) |
- Raft RPC binds
:9100on every node (theEPISTEMIC_GRAPH_RAFT_PEERSport). Peers dial the runtime-injectedNODE_<N>_ADDRvalues on:9100. - The engine's client TCP must therefore move OFF
:9100(collision) — thecluster.envflavor setsENGINE_TCP_ADDR=0.0.0.0:9101; a co-located graph-os should prefer the local UDS. Remote clients dial a member's:9101and follow theForwardToLeaderredirect. EPISTEMIC_GRAPH_RAFT_BIND_ADDR=0.0.0.0:9100lets a containerized member bind all interfaces while still advertising its runtime-injected routable address (fromPEERS) to peers.- Every member receives the same runtime-mounted key through
EPISTEMIC_GRAPH_RAFT_AUTH_SECRET_FILE. The Raft transport authenticates both peer ids with a fresh nonce exchange, derives a per-connection key, encrypts frames with XChaCha20-Poly1305, and rejects replayed/out-of-order sequence numbers. A routable or multi-member cluster refuses to start without key material; plaintext is limited to one-member loopback development. EPISTEMIC_GRAPH_RAFT_FAILURE_DOMAINSis an optional complete map such as1=az-a,2=az-b,3=az-b,4=az-c. It defines the failure boundary used by the automatic leader balancer. If omitted, the host portion of each advertised Raft endpoint is used. Equal or unknown domains fail closed: the balancer will not move a leader merely because node ids differ.
Writer model — K=N under an active Raft node¶
With Raft active, all durable writes for a graph route through that graph's group
LEADER before they are acked (consensus is the replication barrier). The configured
multi-Raft groups own matching redb shards (K=N), so the cluster retains HA while
independent graph ranges can write in parallel. EPISTEMIC_GRAPH_RAFT_GROUPS controls
N and defaults from effective cgroup-aware CPU capacity for a fresh store. On restart,
the existing on-disk shard count is authoritative: node startup adopts that durable K
before creating the default group or tenant ring, and changing K requires the offline
shard migration path. A 4-node cluster still buys survivability of a node loss;
write throughput scales with the configured groups, not with the number of nodes alone.
Bounded automatic leader balance¶
Each live MultiRaft manager observes committed leader assignments every 15 seconds.
The deterministic round-robin target remains the desired placement, but an automatic
handoff is issued only when all safety gates pass: the local node is the current leader,
the target is a voter in a different declared failure domain, the target reduces the
leader-slot load by at least one, and the per-group 10-second cooldown has elapsed.
At most one automatic transfer is issued per pass, allowing terms and replication to
settle before another decision. Single-voter groups and equal/unknown domains are
no-ops. This is a bounded source-level control loop; cross-host availability and
performance still require the operator's multi-node soak.
1. Pre-flight (do these BEFORE touching anything)¶
- Back up node 1's authoritative data. Stop writers if you can; snapshot the redb
persist dir (
ENGINE_PERSIST, configured outside the repository) — a filesystem copy of the closed*.redbfiles, or a borg/zfs snapshot. Verify the backup restores before proceeding. This is the rollback anchor. - Build the cluster-capable engine. The cluster image/binary must be built with
--features "full,cluster"(it links openraft; a default build does NOT). Stage it on every node (ENGINE_BINfor the dev/source-mounted flavor, or bake it into the registry image for prod). - Open
:9100between the four hosts (Raft RPC) on the trusted LAN. - Confirm clocks are sane (Raft leases tolerate drift but don't abuse it; run NTP).
- Decide the client path. If graph-os is co-located on each node, it uses the local
UDS and needs no change. If clients dial the engine over TCP, repoint them at a
member's
:9101(seeGRAPH_SERVICE_TCP_ADDRincluster.env).
2. Convert the authoritative node into the SEED — no data loss¶
The key idea: the authoritative host keeps its redb data and becomes node 1. The other nodes start empty and replicate the authoritative state from node 1 (via Raft log + snapshot). You never wipe node 1, and you never let an empty node bootstrap the cluster.
2a. Restart node 1 as a single-member Raft cluster (still authoritative, now HA-ready)¶
Node 1 boots with EPISTEMIC_GRAPH_RAFT_NODE_ID=1 and a PEERS list. Because node 1 is
the lowest id, it is the bootstrap candidate; it initializes the cluster as a
single voter {1} over its existing redb (the Raft log lives in the SAME
the authoritative shard, keyed by group id — the graph data is untouched). At this
point the cluster is {1} and every committed write still lands on node 1's disk exactly
as before.
# On the runtime-selected deployment manager:
set -a; source services/epistemic-graph/flavors/cluster.env; set +a
export EPISTEMIC_GRAPH_RAFT_NODE_ID=1
export EPISTEMIC_GRAPH_RAFT_AUTH_SECRET_FILE="${RAFT_AUTH_SECRET_FILE:?set a runtime secret-file reference}"
# CONCEPT:EG-KG.sharding.cluster-topology (ADR-1 / W1.1): required once Raft peers are
# configured -- the address this node self-reports so ClusterMembers/
# PlacementRoute.endpoints can hand it to a discovering client, replacing
# the static GRAPH_RAFT_GROUP_ENDPOINTS map. Fails closed (refuses to
# start) without it once EPISTEMIC_GRAPH_RAFT_NODE_ID/_PEERS are set.
export EPISTEMIC_GRAPH_ADVERTISED_CLIENT_ADDR="${NODE_1_CLIENT_ADDR:?e.g. tcp://192.0.2.10:8765 (RFC 5737 documentation address -- substitute the real one at runtime)}"
export SERVER="${NODE_1_HOST:?set the node 1 host alias at runtime}"
export ENGINE_PERSIST="${ENGINE_PERSIST:?set to node 1's existing data directory}"
docker stack deploy -c services/epistemic-graph/compose.dev.yml epistemic-graph-1
Verify before going further: the engine is up, it is the leader of the single-member cluster, and its data is intact (node/edge counts match the pre-cutover baseline). Only then proceed.
Why this is safe:
RaftClusterConfigonly lets the lowest-id node bootstrap, and the store recovers its applied pointers + graph data from the existing redb. Node 1 is the single source of truth throughout.
2b. Bring up nodes 2, 3, 4 EMPTY (they will replicate from node 1)¶
Each joins on a fresh, empty persist dir. They do NOT bootstrap (only node 1 can): they stand up, then wait to be added by the leader.
# Node 2:
set -a; source services/epistemic-graph/flavors/cluster.env; set +a
export EPISTEMIC_GRAPH_RAFT_NODE_ID=2
export EPISTEMIC_GRAPH_RAFT_AUTH_SECRET_FILE="${RAFT_AUTH_SECRET_FILE:?set the shared runtime secret-file reference}"
export SERVER="${NODE_2_HOST:?set the node 2 host alias at runtime}"
export ENGINE_PERSIST="${ENGINE_PERSIST:?set to node 2's empty data directory}"
docker stack deploy -c services/epistemic-graph/compose.dev.yml epistemic-graph-2
# Repeat for node 3 and node 4, using `NODE_3_HOST` and `NODE_4_HOST`; each gets its own
# empty directory.
2c. Add nodes 2→4 as learners, then promote to voters (from the node 1 LEADER)¶
Membership changes are leader-only. The engine exposes this through the MultiRaft
membership lifecycle — add_group_learner (add_learner, blocking-until-caught-up)
then change_group_voters (change_membership), reachable at runtime as
Method::RaftAddLearner / Method::RaftChangeMembership (see §5 item 2 below). The
leader replicates the authoritative state to each learner (log tail, or a full
snapshot if the log was purged) BEFORE promoting it, so a node is only made a voter
once it holds the data. Add them one at a time, verifying catch-up between each,
so quorum is never at risk:
- Add node 2 as a learner (
client.raft_admin.add_learner(node_id=2, addr=...)); wait until its applied index matches the leader; promote to voter (client.raft_admin.change_membership(voters=[1, 2])). Membership becomes{1,2}. - Add node 3 the same way →
{1,2,3}(now a fault-tolerant majority of 3). - Add node 4 →
{1,2,3,4}.
Operationally this is driven by the engine's admin surface for membership — the
MultiRaft::add_group_learner/change_group_voterspath the lib testsmulti_node_group_join_then_leader_rebalance(voter promotion) andraft_add_learner_wire_method_attaches_non_voting_learner(wire method, non-voting) exercise — overepistemic_graph.client'sraft_adminnamespace (§5 item 2). Each step is idempotent and refuses to produce an empty voter set.
2d. Verify replication, then (optionally) rebalance leadership¶
- Read a known key back on a follower (e.g. node 3) — it must match node 1.
- Write a new key through the leader — it must appear on every follower within a heartbeat.
- Leadership starts entirely on node 1 (it bootstrapped). The manager's bounded leader
scheduler automatically issues the native
trigger().transfer_leader(target)per eligible group (graceful, near-instant) toward the deterministic round-robin target. With one group this is normally a no-op; it matters once multi-group sharding is enabled. Set explicit failure domains before relying on this across hosts.
The cluster is now 4-node HA with node 1's data fully replicated. No data was lost and no node was wiped.
3. Rollback¶
At any point before you trust the cluster, you can return to the single-node engine:
- Tear down nodes 2–4 (
docker stack rm epistemic-graph-2/3/4). With them gone the cluster loses quorum — that is fine for rollback because node 1 still holds the authoritative data on disk. - Restart node 1 WITHOUT the Raft env (unset
EPISTEMIC_GRAPH_RAFT_NODE_ID/PEERS): redeploy the single-node defaultcompose.dev.yml. The engine runs single-node over the SAME redb exactly as before the migration (the Raft log rows are inert when Raft is off). - If node 1's data is ever in doubt, restore the §1 backup into
ENGINE_PERSISTand restart single-node.
Because node 1 is never wiped and the Raft log shares its redb, rollback is "stop the new nodes, drop the Raft env" — no data reconstruction needed.
4. Operational notes¶
- Quorum: 4 voters tolerate 1 failure (majority = 3). For 2-failure tolerance you'd want 5 voters; with 4, prefer keeping all 4 healthy and treat a node loss as a page, not a shrug. (A 3-voter set tolerating 1 failure is the classic sweet spot; node 4 here adds a replica + a manager-co-located voter, not extra fault tolerance.)
- A restarted node self-heals from its own redb (durable log tail) and catches the rest from the leader — it does not need a wipe.
- Snapshots are per-group and tenant-scoped, so a large tenant never bloats another group's snapshot transfer.
- Security: Raft and served RPC never run unauthenticated. Every node requires the same runtime-provisioned request secret, audience/tenant/policy revision, trusted signer registry, and Raft transport key. Routable native client traffic uses TLS/mTLS; auxiliary listeners remain loopback-only.
5. Integration follow-ups (NOT done here — flagged for the dispatch owner)¶
The write path that routes a durable mutation through RaftHandle::client_write on the
leader (and the follower ForwardToLeader redirect) lives in src/server/dispatch.rs,
which is owned by a sibling change — out of scope for this branch. Two items to
confirm there before declaring the cluster production-live:
- Leader-routing on the dispatch hot path: when
ServerState.raftisSome, a durable write must go throughclient_write(consensus barrier) and a follower must surface the leader hint so the client retries against the leader (or the co-located graph-os transparently forwards). Verify this is wired for the 0.10client_writereturn/ForwardToLeadershape. - Membership admin entrypoint — DONE.
MultiRaft::add_group_memberwas split intoadd_group_learner(src/raft/multi.rs, add-learner only, no promotion) +change_group_voters(the promotion/rebalance half), both now reachable at runtime asMethod::RaftAddLearner/Method::RaftChangeMembership(src/server/handlers/raft_admin.rs), gatedadmin:clusterand leader-only — a follower answersOPERATION_REDIRECTEDnaming the current leader, exactly likePlacementRoute's stale-route redirect.remove_group_memberstill has no wire Method (out of scope here;RaftChangeMembershipcan shrink the voter set as a workaround by omitting a node from the requestedvoters, but that is NOT the same safety envelope as a dedicated remove entrypoint — flag for a follow-up).
§2c is now drivable end-to-end without a bespoke binary: bring node 2 up on
the SAME EPISTEMIC_GRAPH_RAFT_NODE_ID/EPISTEMIC_GRAPH_RAFT_PEERS/
EPISTEMIC_GRAPH_RAFT_AUTH_SECRET_FILE boot env this doc already documents in
§2b (its is_bootstrap resolves to false because its id isn't the lowest in
EPISTEMIC_GRAPH_RAFT_PEERS, so it opens the group WITHOUT initialize — i.e.
it boots straight into "empty, non-bootstrapping, learner-ready" — no new env
var needed), then from the LEADER:
await client.raft_admin.add_learner(node_id=2, addr=NODE_2_PEER_ADDR)
# ... verify replication (§2d), then promote:
await client.raft_admin.change_membership(voters=[1, 2])
Repeat per node, one at a time, exactly as §2c already prescribed.
See also: Capabilities matrix · Engine Scaling Program · Multi-Raft Cluster Status · Deployment (database) · Operations Runbook.