Online resharding — adding and removing nodes at runtime
skaidb places keys on a consistent-hash ring (vnodes). The ring is held behind a lock so membership can change while the cluster serves traffic: a node can join or leave online and the keyspace rebalances without a restart or a full reload.
Why a single join moves so little
Consistent hashing is the whole point: when one node joins, the only keys that
change owner are the ones whose hash now lands on the joiner's vnodes. Every
other key keeps its current placement. So a join moves roughly 1/(N+1) of the
keyspace onto the new node and disturbs nothing else — no global reshuffle.
(Contrast hash-modulo-N sharding, where bumping N remaps almost every key.)
Versioned, persisted membership
The ring carries a monotonically increasing epoch. Every SetMembership
broadcast carries the new epoch, and a node applies it only if it is newer
than the one it holds — so a stale or out-of-order broadcast can't move a node's
ring backward, and two concurrent topology changes can't both win (the higher
epoch supersedes; a losing orchestrator must retry at the next epoch). The
membership + epoch are persisted to a small topology file in the data
directory, so a node that restarts rejoins with the live ring (the one in
effect when it went down) instead of its original bootstrap config. See
Node::membership_epoch / member_ids.
How a join works
Node::add_member(id, addr) can be driven from any existing member. It runs
three broadcast steps over the internode RPC
(internode.rs):
- Re-ring everyone. The coordinator computes the new membership (current
members + the joiner) and broadcasts
SetMembership { members }. Each recipient — and the joiner — rebuilds the identical ring + peer table from the list. Placement is deterministic from the member set, so every node now agrees on who owns what. - Bootstrap the joiner's schema. The coordinator replays its catalog as
CREATEDDL (Database::schema_ddl()— tables, then secondary indexes, then vector indexes) to the joiner viaApplyDdl, so the joiner can accept rows and build the same local + vector indexes over its shard. - Migrate the keys. The coordinator broadcasts
Rebalance { joiner }. Each existing member scans every table and, for each row whose key the joiner now owns (recomputed against the new ring) and that it is the elected sender for, pushes it to the joiner with the row's original(value | tombstone, HLC)preserved. Tombstones migrate too, so a delete that hasn't compacted away still wins after the move. The push is throttled and resumable: rows are sent in batches (set_migration_throttle(batch, pause_ms)rate-limits so a large move doesn't saturate the cluster), and a per-joiner checkpoint records progress so an interrupted migration resumes where it left off instead of re-sending from the start (it's idempotent either way).
After step 3 the joiner holds (and indexes) every key the ring assigns it, and reads route to it normally. Because pushed rows keep their original HLC, any write that happened before the move stays correctly ordered under last-writer-wins.
// On a running cluster member:
node.add_member("c", "10.0.0.3:7100")?; // c joins; its share is migrated to it
How a graceful leave works
Node::remove_member(id) is the inverse of a join and can be driven from any
member — including the leaving node itself (self-decommission):
- Drain. The orchestrator sends the leaving node a
Draincarrying the post-removal membership. The leaving node walks every local row and, for each key, computes its owners under the smaller ring; for any owner that is not already a replica (i.e. a node that must pick up this key now), it pushes the row — HLC and tombstone preserved. So every key keeps its full replica set before the node disappears. - Shrink the ring. The orchestrator broadcasts
SetMembershipwith the node removed, so the survivors stop routing to it.
After this the leaving node still has its (now unowned) data on disk but serves
no key, so it is safe to shut down. Because consistent hashing only reassigns the
departing node's keys, a single leave moves about 1/N of the keyspace and
touches nothing else.
node.remove_member("c")?; // c drains its keys to their new owners, then leaves
Anti-entropy (keeping replicas converged)
Three mechanisms drive replicas toward agreement, so a write that reached only a minority — or a replica that was briefly down — doesn't leave a permanent divergence:
- Read-repair. A quorum point read compares the version each replica returned; the winning (highest-HLC) version is written back to any replica that answered with an older or missing one. Reads themselves heal the data.
- Hinted handoff. When a replicated write can't reach a replica (it's down),
the coordinator buffers the write as a hint (per replica, bounded) and
replays it once that replica is reachable again — opportunistically on the next
write, or via
flush_hints. Faster recovery than waiting for a full repair. - Active anti-entropy (
Node::repair/repair_cluster). A background pass reconciles each pair of co-replicas: a merge-join over two paged, key-ordered scans (2,000 rows/page per side) copies the newer version in both directions (tombstones included), so repair memory stays O(page) regardless of table size. This is the durable backstop that converges replicas even with no reads and even if hints were lost. It still compares every key; a Merkle tree would let it skip identical key ranges entirely.
Reclaiming space after a move
A join or leave leaves the former owner holding copies of keys it no longer
owns. Node::reclaim frees that space: it walks each local key, and for any key
this node is no longer a replica of, it physically purges the key — but only
after an actual current owner confirms it holds that key at a version at least as
new (the ack-gate, so a key whose migration never completed is never dropped
from its last copy). reclaim_cluster fans the cleanup out to every node.
The purge is a real physical drop (Engine::retain),
not a tombstone: it rewrites the table keeping only retained keys and discards
the rest, so dropped keys vanish from every scan and never resurrect via
compaction. That matters because a tombstone would carry a newer HLC than the
migrated copy and could (a) re-enter a later migration and delete the key on its
new owner, or (b) win an LWW merge and mask the live value elsewhere — a physical
purge does neither. Secondary-index entries for purged rows are left dangling;
reads already skip an index entry whose row is absent, and they compact away.
node.reclaim()?; // this node drops keys it no longer owns
node.reclaim_cluster()?; // …and tells every peer to do the same
Correctness model
- LWW is preserved. Migrated rows carry their original HLC, so they neither shadow nor are shadowed by concurrent writes incorrectly — the newest stamp wins as always.
- Stale copies are harmless, then reclaimed. Until reclamation runs the
former owner keeps its physical copy, but with rf=1 a point read routes only to
the new owner via the ring, and with rf>1 a cluster/index read merges by HLC,
so the migrated copy (equal or newer) wins or ties — no read returns a lost or
resurrected row.
Node::reclaim(below) then frees the space. - Idempotent.
Rebalancecan be re-sent; re-pushing a row at the same HLC is a no-op under LWW. A join that half-completes can be retried.
Assumptions & limitations
- Live migration (pending ranges). A join runs as a two-phase transition:
it first broadcasts the new ring with the old ring unioned in, so while
data is migrating every coordinator treats a migrating key's owners as both
its old and new node — writes dual-write to both and reads consult both — then
finalizes to drop the old ring. So concurrent writes during a join stay
consistent, not just on a quiescent cluster. (A node that restarts mid-
transition reloads only the finalized ring, since the pending state isn't
persisted; re-run
add_memberif a migration was interrupted.remove_memberdrains before changing the ring, so it needs no transition.) - Reclamation is a manual pass.
reclaim/reclaim_clusterare explicit calls run after a move, not automatic — until then the former owner keeps the (harmless) stale copies. It is a full-table scan with a point-read ack-gate per key; fine after a single reshard, heavier on a very large dataset. - Migration memory is bounded. Rebalance, drain, and reclaim page through each shard (2,000 rows at a time — the same paging as repair and distributed gathers) and stream out in throttled batches, so a topology change against a multi-GB table costs one page plus the in-flight batch, not the shard. The rebalance checkpoint doubles as the page cursor on resume.
- Membership has no gossip/consensus. Data converges via anti-entropy, but
membership changes (
SetMembership/Rebalance/Drain) are still best-effort broadcasts. The epoch stops a node from regressing to an older ring and persistence reloads the live ring on restart, but a member unreachable for the whole change lags until re-broadcast to — there is no membership gossip it pulls on reconnect. Concurrent topology changes converge to the highest epoch, but the losing change is dropped. Bring a lagging node up to date by re-runningadd_member/remove_member(idempotent) once it is reachable. - Single sender per key. Only the key's primary under the pre-join ring
pushes it during a join, so
rf > 1does not re-send each key from every replica. The trade-off: if that one sender is unreachable mid-join the key isn't migrated until the join is retried (an all-replicas push would be more redundant but more resilient).
Tested
online_resharding_migrates_keys_to_a_joining_node(rf=1, CL=ONE) fills a table on a two-node cluster, joins a third node online, and asserts every row is still readable from every coordinator (so the rows the new node now owns were migrated to it), the secondary index bootstrapped onto the joiner serves a distributed lookup, and a write after the join routes under the new ring.graceful_decommission_drains_keys_before_leaving(rf=1, CL=ONE) removes a node from a three-node cluster and asserts every key it owned was drained to its new owner (all rows stay readable from the survivors) and that writes after the leave route under the smaller ring.reclaim_drops_unowned_keys_after_join(rf=1) joins a node, then has the former ownersreclaim; it asserts space is actually freed (rows dropped > 0), no row is lost, and a second pass is a no-op (idempotent). The storage-levelretain_physically_drops_keys_without_resurrectionchecks the purge leaves no tombstone and survives reopen.membership_persists_across_restart_and_rejects_stale_epochjoins a node, then restarts a peer from its data dir with the stale bootstrap config and asserts it reloads the live ring at the right epoch, and that a stale (lower-epoch)SetMembershipis ignored.rf2_join_migrates_via_single_sender(rf=2) joins a third node and verifies every row is readable, exercising migration under replication.read_repair_and_anti_entropy_converge_replicas(rf=3) injects divergence on specific replicas and checks that a quorum read repairs the missing one, and thatrepairreconciles both push and pull directions.hinted_handoff_replays_to_a_recovered_replica(rf=3, CL=ALL) writes while a replica is down (buffering a hint), then recovers it and checksflush_hintshands the write off.pending_ranges_dual_write_to_old_and_new_owner(rf=1, CL=ALL) imposes a ring transition and checks a write to a migrating key lands on both its old and new owner.throttled_migration_completes_and_clears_checkpointjoins a node with a tiny batch size + pause and checks everything migrates and the resume checkpoint is removed;migrate_checkpoint_roundtripscovers the checkpoint codec.
Driving ring changes from an orchestrator
POST /admin/add-node / POST /admin/remove-node are synchronous — the
HTTP response returns after the migration completes and the membership
finalizes — but an orchestrator should not rely on holding one HTTP
connection through a long migration. Poll GET /admin/resharding
(ADMIN): {active, epoch, rows_moved}. active is the dual-ring
transition window; active:false is the reliable "resharding finished"
edge, and epoch is the membership version that committed. rows_moved
is the answering node's cumulative migrated-row counter (no byte totals
are tracked).
Idempotency contract: re-adding an existing member is a no-op that also
finalizes a transition a crashed join left open (the healing path);
removing an absent id is Ok. A new add or remove while a transition
window is open is refused with resharding in progress (epoch N) —
poll until active:false, then retry.