Skip to main content

nodedb_cluster/
routing.rs

1// SPDX-License-Identifier: BUSL-1.1
2
3use std::collections::HashMap;
4
5use nodedb_types::id::{DatabaseId, VShardId};
6
7use crate::error::{ClusterError, Result};
8
9/// Number of virtual shards.
10///
11/// Re-exports [`VShardId::COUNT`] to keep the cluster routing layer and the
12/// types-layer hash function locked to the same constant. Changing the count
13/// in only one of the two crates would silently misroute every collection.
14pub const VSHARD_COUNT: u32 = VShardId::COUNT;
15
16/// Maps vShards to Raft groups and Raft groups to nodes.
17///
18/// The 1024 vShards are divided into distinct Raft Groups
19/// (e.g., vShards 0-63 managed by Raft Group 1 across Nodes A, B, and C).
20///
21/// This table is the authoritative routing source. It is updated atomically
22/// via Raft state machine when:
23/// - A shard migration completes (Phase 3 atomic cut-over)
24/// - A Raft group membership changes
25/// - A node joins or decommissions
26#[derive(
27    Debug,
28    Clone,
29    serde::Serialize,
30    serde::Deserialize,
31    zerompk::ToMessagePack,
32    zerompk::FromMessagePack,
33)]
34pub struct RoutingTable {
35    /// vshard_id → raft_group_id.
36    vshard_to_group: Vec<u64>,
37    /// raft_group_id → (leader_node, [replica_nodes]).
38    group_members: HashMap<u64, GroupInfo>,
39}
40
41#[derive(
42    Debug,
43    Clone,
44    Default,
45    serde::Serialize,
46    serde::Deserialize,
47    zerompk::ToMessagePack,
48    zerompk::FromMessagePack,
49)]
50pub struct GroupInfo {
51    /// Current leader node ID (0 = no leader known).
52    pub leader: u64,
53    /// All voting members (including leader).
54    pub members: Vec<u64>,
55    /// Non-voting learner peers catching up to this group.
56    ///
57    /// Learners receive log replication but do not vote in elections and
58    /// are not counted toward the commit quorum. A learner transitions
59    /// into `members` via a second `PromoteLearner` conf-change once the
60    /// leader observes it has caught up.
61    #[serde(default)]
62    pub learners: Vec<u64>,
63    /// Intended voter nodes for this group (the placement set), authored
64    /// centrally and replicated via the metadata Raft group. `None` = no
65    /// explicit placement; consumers fall back to the current `members`.
66    /// `Some(_)` = the explicit target voter set used to cap promotion.
67    #[serde(default)]
68    pub placement: Option<Vec<u64>>,
69}
70
71impl RoutingTable {
72    /// Create a routing table with uniform distribution of vShards across data groups.
73    ///
74    /// `num_groups` is the number of **data** Raft groups. vShards are distributed
75    /// round-robin across groups `1..=num_groups`. Group 0 is the metadata group and
76    /// is always included in `group_members` but is never assigned any vShards —
77    /// it is accessed only via `propose_to_metadata_group`, never via
78    /// `propose(vshard_id, data)`.
79    ///
80    /// Each data group initially contains `replication_factor` nodes from `nodes`.
81    /// The metadata group (0) receives the same membership as the first data group.
82    pub fn uniform(num_groups: u64, nodes: &[u64], replication_factor: usize) -> Self {
83        assert!(!nodes.is_empty(), "need at least one node");
84        assert!(num_groups > 0, "need at least 1 data group");
85        assert!(replication_factor > 0, "need at least RF=1");
86
87        // vShards map to data groups 1..=num_groups, skipping group 0 (metadata).
88        let mut vshard_to_group = Vec::with_capacity(VSHARD_COUNT as usize);
89        for i in 0..VSHARD_COUNT {
90            vshard_to_group.push(1 + (i as u64) % num_groups);
91        }
92
93        let mut group_members = HashMap::new();
94        // Data groups: 1..=num_groups.
95        for idx in 0..num_groups {
96            let group_id = idx + 1;
97            let rf = replication_factor.min(nodes.len());
98            let start = (idx as usize * rf) % nodes.len();
99            let members: Vec<u64> = (0..rf).map(|i| nodes[(start + i) % nodes.len()]).collect();
100            let leader = members[0];
101            group_members.insert(
102                group_id,
103                GroupInfo {
104                    leader,
105                    members,
106                    learners: Vec::new(),
107                    placement: None,
108                },
109            );
110        }
111        // Metadata group 0: same membership as the first data group.
112        let rf = replication_factor.min(nodes.len());
113        let meta_members: Vec<u64> = (0..rf).map(|i| nodes[i % nodes.len()]).collect();
114        let meta_leader = meta_members[0];
115        group_members.insert(
116            0,
117            GroupInfo {
118                leader: meta_leader,
119                members: meta_members,
120                learners: Vec::new(),
121                placement: None,
122            },
123        );
124
125        Self {
126            vshard_to_group,
127            group_members,
128        }
129    }
130
131    /// Look up which Raft group owns a vShard.
132    pub fn group_for_vshard(&self, vshard_id: u32) -> Result<u64> {
133        self.vshard_to_group
134            .get(vshard_id as usize)
135            .copied()
136            .ok_or(ClusterError::VShardNotMapped { vshard_id })
137    }
138
139    /// Look up the leader node for a vShard.
140    pub fn leader_for_vshard(&self, vshard_id: u32) -> Result<u64> {
141        let group_id = self.group_for_vshard(vshard_id)?;
142        let info = self
143            .group_members
144            .get(&group_id)
145            .ok_or(ClusterError::GroupNotFound { group_id })?;
146        Ok(info.leader)
147    }
148
149    /// Get group info.
150    pub fn group_info(&self, group_id: u64) -> Option<&GroupInfo> {
151        self.group_members.get(&group_id)
152    }
153
154    /// Update the leader for a Raft group.
155    pub fn set_leader(&mut self, group_id: u64, leader: u64) {
156        if let Some(info) = self.group_members.get_mut(&group_id) {
157            info.leader = leader;
158        }
159    }
160
161    /// Atomically reassign a vShard to a different Raft group.
162    /// Used during Phase 3 (atomic cut-over) of shard migration.
163    pub fn reassign_vshard(&mut self, vshard_id: u32, new_group_id: u64) {
164        if (vshard_id as usize) < self.vshard_to_group.len() {
165            self.vshard_to_group[vshard_id as usize] = new_group_id;
166        }
167    }
168
169    /// All vShards assigned to a given group.
170    pub fn vshards_for_group(&self, group_id: u64) -> Vec<u32> {
171        self.vshard_to_group
172            .iter()
173            .enumerate()
174            .filter(|(_, gid)| **gid == group_id)
175            .map(|(i, _)| i as u32)
176            .collect()
177    }
178
179    /// Number of Raft groups.
180    pub fn num_groups(&self) -> usize {
181        self.group_members.len()
182    }
183
184    /// All group IDs.
185    pub fn group_ids(&self) -> Vec<u64> {
186        self.group_members.keys().copied().collect()
187    }
188
189    /// Update the voting members of a Raft group (for membership changes).
190    pub fn set_group_members(&mut self, group_id: u64, members: Vec<u64>) {
191        if let Some(info) = self.group_members.get_mut(&group_id) {
192            info.members = members;
193        }
194    }
195
196    /// Set the explicit placement (intended voter set) for a group.
197    ///
198    /// No-op if the group is not present; consistent with `set_group_members`.
199    pub fn set_placement(&mut self, group_id: u64, placement: Vec<u64>) {
200        if let Some(info) = self.group_members.get_mut(&group_id) {
201            info.placement = Some(placement);
202        }
203    }
204
205    /// The effective target voter set for a group: the explicit placement if
206    /// set, else the current members. Never panics; empty for unknown groups.
207    pub fn effective_placement(&self, group_id: u64) -> Vec<u64> {
208        match self.group_members.get(&group_id) {
209            Some(g) => g.placement.clone().unwrap_or_else(|| g.members.clone()),
210            None => Vec::new(),
211        }
212    }
213
214    /// Remove a node from a group's voter and learner lists. If the
215    /// removed node was the current leader hint, the hint is cleared
216    /// so the next query drives a fresh discovery. Returns `true` if
217    /// the group existed and anything was actually removed.
218    ///
219    /// The caller is responsible for safety: dropping below the
220    /// configured replication factor must be gated by
221    /// `decommission::safety::check_can_decommission`.
222    pub fn remove_group_member(&mut self, group_id: u64, node_id: u64) -> bool {
223        let Some(info) = self.group_members.get_mut(&group_id) else {
224            return false;
225        };
226        let before_members = info.members.len();
227        let before_learners = info.learners.len();
228        info.members.retain(|&id| id != node_id);
229        info.learners.retain(|&id| id != node_id);
230        if info.leader == node_id {
231            info.leader = 0;
232        }
233        info.members.len() != before_members || info.learners.len() != before_learners
234    }
235
236    /// Update the learner list for a Raft group.
237    pub fn set_group_learners(&mut self, group_id: u64, learners: Vec<u64>) {
238        if let Some(info) = self.group_members.get_mut(&group_id) {
239            info.learners = learners;
240        }
241    }
242
243    /// Add a learner to a group if not already present. No-op if the peer
244    /// is already a voter or a learner.
245    pub fn add_group_learner(&mut self, group_id: u64, peer: u64) {
246        if let Some(info) = self.group_members.get_mut(&group_id)
247            && !info.members.contains(&peer)
248            && !info.learners.contains(&peer)
249        {
250            info.learners.push(peer);
251        }
252    }
253
254    /// Remove a learner from a group's learner list only. Voters (members) are
255    /// not touched. Returns `true` if the group existed and the learner was
256    /// actually present and removed; `false` for unknown groups or absent peers.
257    pub fn remove_group_learner(&mut self, group_id: u64, peer: u64) -> bool {
258        if let Some(info) = self.group_members.get_mut(&group_id) {
259            let before = info.learners.len();
260            info.learners.retain(|&id| id != peer);
261            info.learners.len() != before
262        } else {
263            false
264        }
265    }
266
267    /// Promote a learner to a voter within a group. Returns `true` if the
268    /// learner was found and promoted.
269    pub fn promote_group_learner(&mut self, group_id: u64, peer: u64) -> bool {
270        if let Some(info) = self.group_members.get_mut(&group_id)
271            && let Some(pos) = info.learners.iter().position(|&id| id == peer)
272        {
273            info.learners.remove(pos);
274            if !info.members.contains(&peer) {
275                info.members.push(peer);
276            }
277            return true;
278        }
279        false
280    }
281
282    /// Access the vshard-to-group mapping (for persistence / wire transfer).
283    pub fn vshard_to_group(&self) -> &[u64] {
284        &self.vshard_to_group
285    }
286
287    /// Access all group members (for persistence / wire transfer).
288    pub fn group_members(&self) -> &HashMap<u64, GroupInfo> {
289        &self.group_members
290    }
291
292    /// Reconstruct a RoutingTable from persisted data.
293    pub fn from_parts(vshard_to_group: Vec<u64>, group_members: HashMap<u64, GroupInfo>) -> Self {
294        Self {
295            vshard_to_group,
296            group_members,
297        }
298    }
299}
300
301/// Compute the primary vShard for a `(database, collection)` pair.
302///
303/// Delegates to [`VShardId::from_collection_in_database`] so the cluster
304/// routing layer and the types-layer hash function cannot drift. The database
305/// id is folded into the hash so the same collection name in two different
306/// databases routes to independent vShards — required for multi-database
307/// isolation. Passing only the collection name (without `db`) would route
308/// every database through the same vShard space and silently corrupt
309/// cross-database deployments; the parameter is mandatory by design.
310pub fn vshard_for_collection(database_id: DatabaseId, collection: &str) -> u32 {
311    VShardId::from_collection_in_database(database_id, collection).as_u32()
312}
313
314/// FNV-1a 64-bit hash for deterministic key partitioning.
315///
316/// Used by distributed join shuffle and shard split to assign keys
317/// to partitions. NOT for vShard routing — use `vshard_for_collection`
318/// for that.
319pub fn fnv1a_hash(key: &str) -> u64 {
320    let mut hash: u64 = 0xcbf29ce484222325;
321    for byte in key.as_bytes() {
322        hash ^= *byte as u64;
323        hash = hash.wrapping_mul(0x100000001b3);
324    }
325    hash
326}
327
328/// Hash `key` using the algorithm recorded in the cluster's [`PlacementHashId`].
329///
330/// Callers load the `PlacementHashId` from `ClusterSettings` once at
331/// startup and pass it through every shard-split / shuffle operation.
332/// The underlying implementations live in
333/// [`crate::catalog::placement_hash`]; this function is the routing-layer
334/// entry point so callers do not need to import the catalog module directly.
335pub fn partition_hash(placement_hash_id: crate::catalog::PlacementHashId, key: &str) -> u64 {
336    crate::catalog::placement_hash(placement_hash_id, key.as_bytes())
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    #[test]
344    fn uniform_distribution() {
345        // 16 data groups → groups 1..=16 for vShards, plus metadata group 0.
346        // Total group_members entries = 17, but vShard groups = 16.
347        let rt = RoutingTable::uniform(16, &[1, 2, 3], 3);
348        // num_groups() returns group_members.len() = 17 (16 data + 1 metadata).
349        assert_eq!(rt.num_groups(), 17);
350
351        // Each data group (1..=16) should have ~64 vShards (1024/16).
352        for gid in 1..=16u64 {
353            let shards = rt.vshards_for_group(gid);
354            assert_eq!(shards.len(), 64);
355        }
356
357        // Metadata group 0 has no vShards.
358        assert_eq!(rt.vshards_for_group(0).len(), 0);
359    }
360
361    #[test]
362    fn leader_lookup() {
363        let rt = RoutingTable::uniform(4, &[10, 20, 30], 3);
364        let leader = rt.leader_for_vshard(0).unwrap();
365        // vshard 0 maps to data group 1, which has a valid leader.
366        assert!(leader > 0);
367    }
368
369    #[test]
370    fn reassign_vshard() {
371        let mut rt = RoutingTable::uniform(4, &[1, 2, 3], 3);
372        let old_group = rt.group_for_vshard(0).unwrap();
373        // old_group is 1 (first data group); reassign to data group 2.
374        let new_group = if old_group < 4 { old_group + 1 } else { 1 };
375        rt.reassign_vshard(0, new_group);
376        assert_eq!(rt.group_for_vshard(0).unwrap(), new_group);
377    }
378
379    #[test]
380    fn set_leader() {
381        let mut rt = RoutingTable::uniform(2, &[1, 2, 3], 3);
382        // Data group 1 owns vshard 0.
383        rt.set_leader(1, 99);
384        assert_eq!(rt.leader_for_vshard(0).unwrap(), 99);
385    }
386
387    #[test]
388    fn remove_group_member_strips_voter_and_clears_leader() {
389        let mut rt = RoutingTable::uniform(2, &[1, 2, 3], 3);
390        // Use data group 1 (vshard 0 owner).
391        rt.set_leader(1, 2);
392        assert!(rt.remove_group_member(1, 2));
393        let info = rt.group_info(1).unwrap();
394        assert!(!info.members.contains(&2));
395        assert_eq!(info.leader, 0, "leader hint should be cleared");
396    }
397
398    #[test]
399    fn remove_group_member_strips_learner_only() {
400        let mut rt = RoutingTable::uniform(2, &[1, 2, 3], 3);
401        rt.add_group_learner(1, 9);
402        assert!(rt.remove_group_member(1, 9));
403        let info = rt.group_info(1).unwrap();
404        assert!(!info.learners.contains(&9));
405    }
406
407    #[test]
408    fn remove_group_member_unknown_group_returns_false() {
409        let mut rt = RoutingTable::uniform(1, &[1, 2], 2);
410        assert!(!rt.remove_group_member(99, 1));
411    }
412
413    #[test]
414    fn remove_group_learner_removes_from_learners_only() {
415        let mut rt = RoutingTable::uniform(2, &[1, 2, 3], 3);
416        rt.add_group_learner(1, 9);
417        let members_before = rt.group_info(1).unwrap().members.clone();
418
419        assert!(rt.remove_group_learner(1, 9));
420
421        let info = rt.group_info(1).unwrap();
422        assert!(!info.learners.contains(&9), "learner must be removed");
423        assert_eq!(info.members, members_before, "voters must not be affected");
424    }
425
426    #[test]
427    fn remove_group_learner_noop_for_voter_and_absent() {
428        let mut rt = RoutingTable::uniform(2, &[1, 2, 3], 3);
429        let members_before = rt.group_info(1).unwrap().members.clone();
430
431        // Voter node — remove_group_learner must not touch it.
432        assert!(!rt.remove_group_learner(1, members_before[0]));
433        assert_eq!(rt.group_info(1).unwrap().members, members_before);
434
435        // Absent learner — returns false without panic.
436        assert!(!rt.remove_group_learner(1, 999));
437    }
438
439    #[test]
440    fn remove_group_learner_unknown_group_returns_false() {
441        let mut rt = RoutingTable::uniform(1, &[1, 2], 2);
442        assert!(!rt.remove_group_learner(99, 1));
443    }
444
445    #[test]
446    fn vshard_not_mapped() {
447        let rt = RoutingTable::uniform(2, &[1, 2], 2);
448        // All 1024 are mapped, so this shouldn't fail.
449        assert!(rt.group_for_vshard(1023).is_ok());
450    }
451
452    #[test]
453    fn partition_hash_fnv1a_vs_xxhash3_differ() {
454        use crate::catalog::PlacementHashId;
455        let key = "some-partition-key";
456        let fnv = partition_hash(PlacementHashId::Fnv1a, key);
457        let xx3 = partition_hash(PlacementHashId::XxHash3, key);
458        assert_ne!(fnv, xx3, "FNV-1a and XxHash3 must produce distinct values");
459    }
460
461    #[test]
462    fn vshard_for_collection_matches_types_layer() {
463        // The cluster routing layer MUST agree with the types-layer hash
464        // for every (database, collection) pair. Drift here silently
465        // misroutes data across nodes — once this regresses, collections
466        // in any non-DEFAULT database resolve to the wrong vShard on the
467        // gateway while the data plane still keys them by the correct
468        // hash. This test pins the contract.
469        for db_raw in [0u64, 1, 2, 1024, 999_999] {
470            let db = DatabaseId::new(db_raw);
471            for name in ["users", "orders", "events", "a", "this_is_a_long_name"] {
472                assert_eq!(
473                    vshard_for_collection(db, name),
474                    VShardId::from_collection_in_database(db, name).as_u32(),
475                    "drift detected: db={db_raw} collection={name}"
476                );
477            }
478        }
479    }
480
481    #[test]
482    fn vshard_for_collection_diverges_across_databases() {
483        // Same collection name in different databases must route to
484        // different vShards (probabilistic; "users" is a canonical
485        // example whose hashes are known to differ across DEFAULT and
486        // DatabaseId(1024)).
487        let v_default = vshard_for_collection(DatabaseId::DEFAULT, "users");
488        let v_other = vshard_for_collection(DatabaseId::new(1024), "users");
489        assert_ne!(
490            v_default, v_other,
491            "same collection name across databases must route independently"
492        );
493    }
494
495    #[test]
496    fn set_placement_and_effective_placement() {
497        let mut rt = RoutingTable::uniform(2, &[1, 2, 3], 3);
498
499        // Before any explicit placement: effective_placement returns members.
500        let members = rt.group_info(1).unwrap().members.clone();
501        assert_eq!(rt.effective_placement(1), members);
502        assert!(rt.group_info(1).unwrap().placement.is_none());
503
504        // After set_placement: effective_placement returns the explicit set.
505        rt.set_placement(1, vec![10, 20]);
506        assert_eq!(rt.effective_placement(1), vec![10, 20]);
507        assert_eq!(rt.group_info(1).unwrap().placement, Some(vec![10, 20]));
508
509        // Unknown group: effective_placement returns empty, no panic.
510        assert_eq!(rt.effective_placement(999), Vec::<u64>::new());
511
512        // set_placement on unknown group is a no-op (consistent with set_group_members).
513        rt.set_placement(999, vec![1]);
514        assert_eq!(rt.effective_placement(999), Vec::<u64>::new());
515    }
516
517    #[test]
518    fn partition_hash_deterministic() {
519        use crate::catalog::PlacementHashId;
520        let key = "some-partition-key";
521        assert_eq!(
522            partition_hash(PlacementHashId::Fnv1a, key),
523            partition_hash(PlacementHashId::Fnv1a, key)
524        );
525        assert_eq!(
526            partition_hash(PlacementHashId::XxHash3, key),
527            partition_hash(PlacementHashId::XxHash3, key)
528        );
529    }
530}