1use std::collections::{BTreeMap, BTreeSet};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
30pub struct ShardId(pub u32);
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
34pub struct RaftGroupId(pub u32);
35
36pub const META_RAFT_GROUP_ID: u32 = u32::MAX;
41
42#[must_use]
44pub const fn is_meta_raft_group(group: u32) -> bool {
45 group == META_RAFT_GROUP_ID
46}
47
48pub const DEFAULT_GROUP_REPLICATION_FACTOR: u32 = 3;
50
51pub const DEFAULT_GROUP_LEARNER_FACTOR: u32 = 0;
53
54pub const MAX_VIRTUAL_SHARDS: u32 = 4096;
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum ShardRoutingKind {
60 Modulus,
62 StableVirtual,
64}
65
66impl ShardRoutingKind {
67 #[must_use]
69 pub const fn as_str(self) -> &'static str {
70 match self {
71 Self::Modulus => "modulus",
72 Self::StableVirtual => "stable_virtual",
73 }
74 }
75}
76
77fn fnv1a(bytes: &[u8]) -> u64 {
81 const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
82 const PRIME: u64 = 0x0000_0100_0000_01b3;
83 let mut hash = OFFSET;
84 for &b in bytes {
85 hash ^= u64::from(b);
86 hash = hash.wrapping_mul(PRIME);
87 }
88 hash
89}
90
91fn mix64(mut x: u64) -> u64 {
95 x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
96 x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
97 x ^ (x >> 31)
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub struct ShardRouter {
103 shard_count: u32,
104}
105
106impl ShardRouter {
107 #[must_use]
109 pub fn new(shard_count: u32) -> Self {
110 Self {
111 shard_count: shard_count.clamp(1, MAX_VIRTUAL_SHARDS),
112 }
113 }
114
115 #[must_use]
117 pub fn shard_count(&self) -> u32 {
118 self.shard_count
119 }
120
121 #[must_use]
123 pub fn shard_for(&self, key: &[u8]) -> ShardId {
124 #[allow(clippy::cast_possible_truncation)] ShardId((fnv1a(key) % u64::from(self.shard_count)) as u32)
126 }
127
128 pub fn expand_shard_count(
135 &mut self,
136 new_count: u32,
137 ) -> Result<ShardCountExpansionPlan, ShardExpansionError> {
138 let plan = plan_shard_count_expansion(self.shard_count, new_count)?;
139 self.shard_count = plan.to;
140 Ok(plan)
141 }
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum ShardExpansionError {
147 CannotShrink {
149 current: u32,
151 requested: u32,
153 },
154 ExceedsMax {
156 requested: u32,
158 },
159 NotMultiRaft,
161 StableRoutingActive,
163}
164
165impl std::fmt::Display for ShardExpansionError {
166 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167 match self {
168 Self::CannotShrink { current, requested } => write!(
169 f,
170 "shard count can only increase (have {current}, requested {requested})"
171 ),
172 Self::ExceedsMax { requested } => write!(
173 f,
174 "requested {requested} shards exceeds MAX_VIRTUAL_SHARDS ({MAX_VIRTUAL_SHARDS})"
175 ),
176 Self::NotMultiRaft => {
177 f.write_str("shard expansion requires multi-Raft (raft_groups > 1)")
178 }
179 Self::StableRoutingActive => f.write_str(
180 "stable virtual shard routing is active; use activate_shards instead of expand_shard_count",
181 ),
182 }
183 }
184}
185
186impl std::error::Error for ShardExpansionError {}
187
188#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct ShardCountExpansionPlan {
191 pub from: u32,
193 pub to: u32,
195 pub new_shard_ids: Vec<ShardId>,
197}
198
199pub fn plan_shard_count_expansion(
206 from: u32,
207 to: u32,
208) -> Result<ShardCountExpansionPlan, ShardExpansionError> {
209 let from = from.max(1);
210 if to <= from {
211 return Err(ShardExpansionError::CannotShrink {
212 current: from,
213 requested: to,
214 });
215 }
216 if to > MAX_VIRTUAL_SHARDS {
217 return Err(ShardExpansionError::ExceedsMax { requested: to });
218 }
219 Ok(ShardCountExpansionPlan {
220 from,
221 to,
222 new_shard_ids: (from..to).map(ShardId).collect(),
223 })
224}
225
226#[must_use]
232pub fn place_shard(shard: ShardId, groups: &[RaftGroupId]) -> Option<RaftGroupId> {
233 groups.iter().copied().max_by_key(|g| weight(shard, *g))
234}
235
236fn weight(shard: ShardId, group: RaftGroupId) -> u64 {
238 mix64(u64::from(shard.0) << 32 | u64::from(group.0))
239}
240
241fn group_node_weight(group: RaftGroupId, node: crafty_proto::NodeId) -> u64 {
243 mix64(u64::from(group.0) << 32 | node.0)
244}
245
246#[must_use]
251pub(crate) fn place_group(
252 group: RaftGroupId,
253 nodes: &[crafty_proto::NodeId],
254) -> Option<crafty_proto::NodeId> {
255 nodes
256 .iter()
257 .copied()
258 .max_by_key(|n| group_node_weight(group, *n))
259}
260
261#[must_use]
263pub fn group_host_assignment(
264 groups: &[RaftGroupId],
265 nodes: &[crafty_proto::NodeId],
266) -> BTreeMap<RaftGroupId, crafty_proto::NodeId> {
267 let mut map = BTreeMap::new();
268 if nodes.is_empty() {
269 return map;
270 }
271 for &group in groups {
272 if let Some(node) = place_group(group, nodes) {
273 map.insert(group, node);
274 }
275 }
276 map
277}
278
279#[must_use]
282pub fn effective_replication_factor(replication_factor: u32, live_count: usize) -> u32 {
283 if live_count == 0 {
284 return 0;
285 }
286 replication_factor
287 .max(1)
288 .min(u32::try_from(live_count).unwrap_or(u32::MAX))
289}
290
291#[must_use]
294pub fn group_voters(
295 group: RaftGroupId,
296 live_nodes: &[crafty_proto::NodeId],
297 replication_factor: u32,
298) -> Vec<crafty_proto::NodeId> {
299 let rf = effective_replication_factor(replication_factor, live_nodes.len());
300 if rf == 0 {
301 return Vec::new();
302 }
303 let mut ranked: Vec<_> = live_nodes.to_vec();
304 ranked.sort_by(|a, b| {
305 group_node_weight(group, *b)
306 .cmp(&group_node_weight(group, *a))
307 .then_with(|| a.cmp(b))
308 });
309 ranked.truncate(rf as usize);
310 ranked.sort();
311 ranked
312}
313
314#[must_use]
317pub fn group_learners(
318 group: RaftGroupId,
319 live_nodes: &[crafty_proto::NodeId],
320 replication_factor: u32,
321 learner_factor: u32,
322) -> Vec<crafty_proto::NodeId> {
323 if learner_factor == 0 || live_nodes.is_empty() {
324 return Vec::new();
325 }
326
327 let voters: BTreeSet<_> = group_voters(group, live_nodes, replication_factor)
328 .into_iter()
329 .collect();
330 let mut ranked: Vec<_> = live_nodes.to_vec();
331 ranked.sort_by(|a, b| {
332 group_node_weight(group, *b)
333 .cmp(&group_node_weight(group, *a))
334 .then_with(|| a.cmp(b))
335 });
336 ranked.retain(|n| !voters.contains(n));
337 ranked.truncate(learner_factor as usize);
338 ranked.sort();
339 ranked
340}
341
342#[derive(Debug, Clone, PartialEq, Eq)]
344pub struct GroupReplicationTarget {
345 pub voters: Vec<crafty_proto::NodeId>,
347 pub learners: Vec<crafty_proto::NodeId>,
349}
350
351#[must_use]
353pub fn group_membership_assignment(
354 groups: &[RaftGroupId],
355 live_nodes: &[crafty_proto::NodeId],
356 replication_factor: u32,
357) -> BTreeMap<RaftGroupId, Vec<crafty_proto::NodeId>> {
358 groups
359 .iter()
360 .map(|&group| (group, group_voters(group, live_nodes, replication_factor)))
361 .collect()
362}
363
364#[derive(Debug, Clone, PartialEq, Eq, Default)]
366pub struct GroupMembershipChange {
367 pub add: Vec<crafty_proto::NodeId>,
369 pub remove: Vec<crafty_proto::NodeId>,
371}
372
373#[must_use]
375pub fn plan_group_membership_change(
376 current_voters: &[crafty_proto::NodeId],
377 desired_voters: &[crafty_proto::NodeId],
378) -> GroupMembershipChange {
379 use std::collections::BTreeSet;
380
381 let current: BTreeSet<_> = current_voters.iter().copied().collect();
382 let desired: BTreeSet<_> = desired_voters.iter().copied().collect();
383 GroupMembershipChange {
384 add: desired.difference(¤t).copied().collect(),
385 remove: current.difference(&desired).copied().collect(),
386 }
387}
388
389#[must_use]
392pub fn groups_joining_node_affects(
393 node: crafty_proto::NodeId,
394 all_groups: &[RaftGroupId],
395 live_nodes_before: &[crafty_proto::NodeId],
396 live_nodes_after: &[crafty_proto::NodeId],
397 replication_factor: u32,
398) -> Vec<RaftGroupId> {
399 debug_assert!(
400 live_nodes_after.contains(&node),
401 "joining node must appear in live_nodes_after"
402 );
403 debug_assert!(
404 !live_nodes_before.contains(&node),
405 "joining node must be absent from live_nodes_before"
406 );
407 all_groups
408 .iter()
409 .copied()
410 .filter(|&group| {
411 let before = group_voters(group, live_nodes_before, replication_factor);
412 let after = group_voters(group, live_nodes_after, replication_factor);
413 !before.contains(&node) && after.contains(&node)
414 })
415 .collect()
416}
417
418#[must_use]
421pub fn groups_leaving_node_affects(
422 node: crafty_proto::NodeId,
423 all_groups: &[RaftGroupId],
424 live_nodes_before: &[crafty_proto::NodeId],
425 live_nodes_after: &[crafty_proto::NodeId],
426 replication_factor: u32,
427) -> Vec<RaftGroupId> {
428 debug_assert!(
429 live_nodes_before.contains(&node),
430 "departing node must appear in live_nodes_before"
431 );
432 debug_assert!(
433 !live_nodes_after.contains(&node),
434 "departing node must be absent from live_nodes_after"
435 );
436 all_groups
437 .iter()
438 .copied()
439 .filter(|&group| {
440 let before = group_voters(group, live_nodes_before, replication_factor);
441 let after = group_voters(group, live_nodes_after, replication_factor);
442 before.contains(&node) && !after.contains(&node)
443 })
444 .collect()
445}
446
447#[must_use]
451pub fn plan_group_membership_sync(
452 catalog: &[RaftGroupId],
453 live_nodes: &[crafty_proto::NodeId],
454 current_voters: &BTreeMap<RaftGroupId, Vec<crafty_proto::NodeId>>,
455 current_learners: &BTreeMap<RaftGroupId, Vec<crafty_proto::NodeId>>,
456 replication_factor: u32,
457 learner_factor: u32,
458) -> BTreeMap<RaftGroupId, GroupReplicationTarget> {
459 let mut out = BTreeMap::new();
460 for &group in catalog {
461 if is_meta_raft_group(group.0) {
462 continue;
463 }
464 let desired_voters = group_voters(group, live_nodes, replication_factor);
465 let desired_learners =
466 group_learners(group, live_nodes, replication_factor, learner_factor);
467 let cur_v = current_voters.get(&group).map_or(&[][..], Vec::as_slice);
468 let cur_l = current_learners.get(&group).map_or(&[][..], Vec::as_slice);
469 let voter_change = plan_group_membership_change(cur_v, &desired_voters);
470 let learner_change = plan_group_membership_change(cur_l, &desired_learners);
471 if !voter_change.add.is_empty()
472 || !voter_change.remove.is_empty()
473 || !learner_change.add.is_empty()
474 || !learner_change.remove.is_empty()
475 {
476 out.insert(
477 group,
478 GroupReplicationTarget {
479 voters: desired_voters,
480 learners: desired_learners,
481 },
482 );
483 }
484 }
485 out
486}
487
488#[derive(Debug, Clone, PartialEq, Eq, Default)]
490pub struct GroupRebalancePlan {
491 pub adopt: Vec<RaftGroupId>,
493 pub retire: Vec<RaftGroupId>,
495}
496
497#[must_use]
499pub fn node_should_host_group(
500 group: RaftGroupId,
501 node_id: crafty_proto::NodeId,
502 live_nodes: &[crafty_proto::NodeId],
503 replication_factor: u32,
504 learner_factor: u32,
505) -> bool {
506 group_voters(group, live_nodes, replication_factor).contains(&node_id)
507 || group_learners(group, live_nodes, replication_factor, learner_factor).contains(&node_id)
508}
509
510#[must_use]
513pub fn plan_node_group_rebalance(
514 node_id: crafty_proto::NodeId,
515 all_groups: &[RaftGroupId],
516 live_nodes: &[crafty_proto::NodeId],
517 currently_hosted: &[RaftGroupId],
518 replication_factor: u32,
519 learner_factor: u32,
520) -> GroupRebalancePlan {
521 use std::collections::BTreeSet;
522
523 let should: BTreeSet<RaftGroupId> = all_groups
524 .iter()
525 .copied()
526 .filter(|g| {
527 node_should_host_group(*g, node_id, live_nodes, replication_factor, learner_factor)
528 })
529 .collect();
530 let current: BTreeSet<RaftGroupId> = currently_hosted.iter().copied().collect();
531
532 let adopt = should.difference(¤t).copied().collect();
533 let retire = current.difference(&should).copied().collect();
534 GroupRebalancePlan { adopt, retire }
535}
536
537#[must_use]
547pub fn virtual_shard_for(key: &[u8]) -> ShardId {
548 #[allow(clippy::cast_possible_truncation)] ShardId((fnv1a(key) % u64::from(MAX_VIRTUAL_SHARDS)) as u32)
550}
551
552#[must_use]
554pub fn shard_is_active(shard: ShardId, active_count: u32) -> bool {
555 shard.0 < active_count.clamp(1, MAX_VIRTUAL_SHARDS)
556}
557
558#[derive(Debug, Clone, Copy, PartialEq, Eq)]
560pub enum StableShardActivationError {
561 CannotShrink {
563 current: u32,
565 requested: u32,
567 },
568 ExceedsMax {
570 requested: u32,
572 },
573 ModulusRoutingActive,
575 NotMultiRaft,
577}
578
579impl std::fmt::Display for StableShardActivationError {
580 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
581 match self {
582 Self::CannotShrink { current, requested } => write!(
583 f,
584 "active shard count can only increase (have {current}, requested {requested})"
585 ),
586 Self::ExceedsMax { requested } => write!(
587 f,
588 "requested {requested} active shards exceeds MAX_VIRTUAL_SHARDS ({MAX_VIRTUAL_SHARDS})"
589 ),
590 Self::ModulusRoutingActive => f.write_str(
591 "modulus shard routing is active; use expand_shard_count instead of activate_shards",
592 ),
593 Self::NotMultiRaft => {
594 f.write_str("shard activation requires multi-Raft (raft_groups > 1)")
595 }
596 }
597 }
598}
599
600impl std::error::Error for StableShardActivationError {}
601
602#[derive(Debug, Clone, PartialEq, Eq)]
604pub struct StableShardActivationPlan {
605 pub from: u32,
607 pub to: u32,
609 pub newly_active: Vec<ShardId>,
611}
612
613pub fn plan_stable_shard_activation(
619 from: u32,
620 to: u32,
621) -> Result<StableShardActivationPlan, StableShardActivationError> {
622 let from = from.clamp(1, MAX_VIRTUAL_SHARDS);
623 if to <= from {
624 return Err(StableShardActivationError::CannotShrink {
625 current: from,
626 requested: to,
627 });
628 }
629 if to > MAX_VIRTUAL_SHARDS {
630 return Err(StableShardActivationError::ExceedsMax { requested: to });
631 }
632 Ok(StableShardActivationPlan {
633 from,
634 to,
635 newly_active: (from..to).map(ShardId).collect(),
636 })
637}
638
639#[derive(Debug, Clone, Copy, PartialEq, Eq)]
641pub enum ShardRoutingSwitchError {
642 AlreadyStable,
644 InvalidActiveCount,
646 NotMultiRaft,
648}
649
650impl std::fmt::Display for ShardRoutingSwitchError {
651 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
652 match self {
653 Self::AlreadyStable => {
654 f.write_str("stable virtual shard routing is already active; switch is a no-op")
655 }
656 Self::InvalidActiveCount => {
657 f.write_str("active shard count must be at least 1 to switch routing")
658 }
659 Self::NotMultiRaft => {
660 f.write_str("routing switch requires multi-Raft (raft_groups > 1)")
661 }
662 }
663 }
664}
665
666impl std::error::Error for ShardRoutingSwitchError {}
667
668#[derive(Debug, Clone, Copy, PartialEq, Eq)]
670pub struct ShardRoutingSwitchPlan {
671 pub from: ShardRoutingKind,
673 pub to: ShardRoutingKind,
675 pub active_count: u32,
677}
678
679pub fn plan_switch_to_stable_routing(
687 current: ShardRoutingKind,
688 active_count: u32,
689) -> Result<ShardRoutingSwitchPlan, ShardRoutingSwitchError> {
690 if current == ShardRoutingKind::StableVirtual {
691 return Err(ShardRoutingSwitchError::AlreadyStable);
692 }
693 if active_count == 0 {
694 return Err(ShardRoutingSwitchError::InvalidActiveCount);
695 }
696 Ok(ShardRoutingSwitchPlan {
697 from: ShardRoutingKind::Modulus,
698 to: ShardRoutingKind::StableVirtual,
699 active_count: active_count.clamp(1, MAX_VIRTUAL_SHARDS),
700 })
701}
702
703#[derive(Debug, Clone, Copy, PartialEq, Eq)]
705pub struct StableShardRouter {
706 active_count: u32,
707}
708
709impl StableShardRouter {
710 #[must_use]
712 pub fn new(active_count: u32) -> Self {
713 Self {
714 active_count: active_count.clamp(1, MAX_VIRTUAL_SHARDS),
715 }
716 }
717
718 #[must_use]
720 pub fn active_count(&self) -> u32 {
721 self.active_count
722 }
723
724 #[must_use]
726 pub fn shard_for(&self, key: &[u8]) -> Option<ShardId> {
727 let shard = virtual_shard_for(key);
728 shard_is_active(shard, self.active_count).then_some(shard)
729 }
730
731 pub fn activate_shards(
736 &mut self,
737 new_active: u32,
738 ) -> Result<StableShardActivationPlan, StableShardActivationError> {
739 let plan = plan_stable_shard_activation(self.active_count, new_active)?;
740 self.active_count = plan.to;
741 Ok(plan)
742 }
743}
744
745#[derive(Debug, Clone, Copy, PartialEq, Eq)]
747pub enum CatalogError {
748 Empty,
750 NonContiguous {
752 expected_next: u32,
754 found: u32,
756 },
757 Duplicate {
759 group: u32,
761 },
762 InvalidExpansionCount {
764 add_groups: u32,
766 },
767}
768
769impl std::fmt::Display for CatalogError {
770 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
771 match self {
772 Self::Empty => f.write_str("catalog must not be empty"),
773 Self::NonContiguous {
774 expected_next,
775 found,
776 } => write!(
777 f,
778 "catalog ids must be contiguous (expected {expected_next}, found {found})"
779 ),
780 Self::Duplicate { group } => write!(f, "duplicate group id {group}"),
781 Self::InvalidExpansionCount { add_groups } => {
782 write!(f, "add_groups must be >= 1 (got {add_groups})")
783 }
784 }
785 }
786}
787
788impl std::error::Error for CatalogError {}
789
790#[derive(Debug, Clone, PartialEq, Eq)]
792pub struct CatalogExpansionPlan {
793 pub from_len: u32,
795 pub to_len: u32,
797 pub new_groups: Vec<RaftGroupId>,
799}
800
801pub fn validate_catalog(catalog: &[RaftGroupId]) -> Result<(), CatalogError> {
808 if catalog.is_empty() {
809 return Err(CatalogError::Empty);
810 }
811 let mut seen = std::collections::BTreeSet::new();
812 for (i, &group) in catalog.iter().enumerate() {
813 if !seen.insert(group.0) {
814 return Err(CatalogError::Duplicate { group: group.0 });
815 }
816 #[allow(clippy::cast_possible_truncation)] let expected = i as u32;
818 if group.0 != expected {
819 return Err(CatalogError::NonContiguous {
820 expected_next: expected,
821 found: group.0,
822 });
823 }
824 }
825 Ok(())
826}
827
828pub fn plan_catalog_expansion(
837 catalog: &[RaftGroupId],
838 add_groups: u32,
839) -> Result<CatalogExpansionPlan, CatalogError> {
840 validate_catalog(catalog)?;
841 if add_groups == 0 {
842 return Err(CatalogError::InvalidExpansionCount { add_groups });
843 }
844 let from_len = u32::try_from(catalog.len()).expect("catalog length fits u32");
845 let next_id = catalog.last().expect("non-empty").0 + 1;
846 let new_groups: Vec<_> = (0..add_groups)
847 .map(|offset| RaftGroupId(next_id + offset))
848 .collect();
849 Ok(CatalogExpansionPlan {
850 from_len,
851 to_len: from_len + add_groups,
852 new_groups,
853 })
854}
855
856#[must_use]
859pub fn stable_router_preserves_routable_keys(
860 from_active: u32,
861 to_active: u32,
862 samples: &[&[u8]],
863) -> bool {
864 let before = StableShardRouter::new(from_active);
865 let mut after = StableShardRouter::new(from_active);
866 if after.activate_shards(to_active).is_err() {
867 return false;
868 }
869 for key in samples {
870 let before_shard = before.shard_for(key);
871 let after_shard = after.shard_for(key);
872 if let Some(a) = before_shard
873 && after_shard != Some(a)
874 {
875 return false;
876 }
877 }
878 true
879}
880
881#[cfg(test)]
884#[must_use]
885pub(crate) fn shard_assignment(
886 shard_count: u32,
887 groups: &[RaftGroupId],
888) -> BTreeMap<ShardId, RaftGroupId> {
889 let mut map = BTreeMap::new();
890 if groups.is_empty() {
891 return map;
892 }
893 for s in 0..shard_count.max(1) {
894 let shard = ShardId(s);
895 if let Some(group) = place_shard(shard, groups) {
896 map.insert(shard, group);
897 }
898 }
899 map
900}
901
902#[cfg(test)]
903mod tests {
904 use super::*;
905
906 #[test]
907 fn shard_mapping_is_stable_and_in_range() {
908 let router = ShardRouter::new(16);
909 let a = router.shard_for(b"account:42");
911 let b = router.shard_for(b"account:42");
912 assert_eq!(a, b);
913 for key in ["a", "bb", "account:1", "account:2", "x/y/z", ""] {
915 assert!(router.shard_for(key.as_bytes()).0 < 16);
916 }
917 }
918
919 #[test]
920 fn keys_spread_across_shards() {
921 let router = ShardRouter::new(8);
922 let mut seen = std::collections::HashSet::new();
923 for i in 0..1000 {
924 seen.insert(router.shard_for(format!("key-{i}").as_bytes()).0);
925 }
926 assert_eq!(seen.len(), 8, "all shards should receive keys");
928 }
929
930 #[test]
931 fn single_shard_router_maps_everything_to_zero() {
932 let router = ShardRouter::new(1);
933 assert_eq!(router.shard_for(b"anything"), ShardId(0));
934 assert_eq!(router.shard_for(b"else"), ShardId(0));
935 }
936
937 #[test]
938 fn placement_is_deterministic_and_covers_only_given_groups() {
939 let groups = [RaftGroupId(1), RaftGroupId(2), RaftGroupId(3)];
940 let map = shard_assignment(64, &groups);
941 assert_eq!(map.len(), 64);
942 for group in map.values() {
943 assert!(groups.contains(group));
944 }
945 assert_eq!(map, shard_assignment(64, &groups));
947 }
948
949 #[test]
950 fn empty_group_set_places_nothing() {
951 assert!(place_shard(ShardId(0), &[]).is_none());
952 assert!(shard_assignment(32, &[]).is_empty());
953 }
954
955 #[test]
956 fn placement_is_roughly_balanced() {
957 let groups: Vec<_> = (1..=4).map(RaftGroupId).collect();
958 let map = shard_assignment(400, &groups);
959 let mut counts = std::collections::HashMap::new();
960 for g in map.values() {
961 *counts.entry(*g).or_insert(0u32) += 1;
962 }
963 for (_g, n) in counts {
965 assert!(
966 (50..=150).contains(&n),
967 "group owned {n} shards (want ~100)"
968 );
969 }
970 }
971
972 #[test]
973 fn adding_a_group_moves_a_minimal_fraction_of_shards() {
974 let before = shard_assignment(400, &[RaftGroupId(1), RaftGroupId(2), RaftGroupId(3)]);
978 let after = shard_assignment(
979 400,
980 &[
981 RaftGroupId(1),
982 RaftGroupId(2),
983 RaftGroupId(3),
984 RaftGroupId(4),
985 ],
986 );
987
988 let mut moved = 0;
989 for (shard, old) in &before {
990 let new = after[shard];
991 if new != *old {
992 assert_eq!(
995 new,
996 RaftGroupId(4),
997 "shard {shard:?} churned between old groups"
998 );
999 moved += 1;
1000 }
1001 }
1002 assert!(moved > 0, "adding a group should claim some shards");
1004 assert!(
1005 moved < 200,
1006 "moved {moved}/400 shards — rendezvous hashing should move ~1/4"
1007 );
1008 }
1009
1010 #[test]
1011 fn group_hosts_spread_across_physical_nodes() {
1012 use crafty_proto::NodeId;
1013
1014 let nodes = [NodeId(1), NodeId(2), NodeId(3)];
1015 let groups: Vec<_> = (0..6).map(RaftGroupId).collect();
1016 let map = group_host_assignment(&groups, &nodes);
1017 assert_eq!(map.len(), 6);
1018 for host in map.values() {
1019 assert!(nodes.contains(host));
1020 }
1021 }
1022
1023 #[test]
1024 fn node_rebalance_plan_adopts_for_a_joining_node() {
1025 use crafty_proto::NodeId;
1026
1027 let groups: Vec<_> = (0..12).map(RaftGroupId).collect();
1028 let live = [NodeId(1), NodeId(2), NodeId(3)];
1029 let assignment = group_host_assignment(&groups, &live);
1030 let node_id = live
1031 .iter()
1032 .copied()
1033 .find(|n| assignment.values().any(|host| *host == *n))
1034 .expect("at least one node should host a group");
1035 let plan = plan_node_group_rebalance(node_id, &groups, &live, &[], 1, 0);
1036 assert!(!plan.adopt.is_empty());
1037 assert!(plan.retire.is_empty());
1038 }
1039
1040 #[test]
1041 fn effective_replication_factor_clamps_to_live_count() {
1042 assert_eq!(effective_replication_factor(0, 5), 1);
1043 assert_eq!(effective_replication_factor(3, 2), 2);
1044 assert_eq!(effective_replication_factor(3, 5), 3);
1045 assert_eq!(effective_replication_factor(3, 0), 0);
1046 }
1047
1048 #[test]
1049 fn group_voters_rf_one_matches_rendezvous_host() {
1050 use crafty_proto::NodeId;
1051
1052 let nodes = [NodeId(1), NodeId(2), NodeId(3)];
1053 for g in 0..12 {
1054 let group = RaftGroupId(g);
1055 let voters = group_voters(group, &nodes, 1);
1056 assert_eq!(voters.len(), 1);
1057 assert_eq!(voters[0], place_group(group, &nodes).unwrap());
1058 }
1059 }
1060
1061 #[test]
1062 fn full_replication_assigns_all_live_nodes_to_every_group() {
1063 use crafty_proto::NodeId;
1064
1065 let nodes = [NodeId(1), NodeId(2), NodeId(3)];
1066 let groups: Vec<_> = (0..4).map(RaftGroupId).collect();
1067 let assignment = group_membership_assignment(&groups, &nodes, 3);
1068 for voters in assignment.values() {
1069 assert_eq!(*voters, vec![NodeId(1), NodeId(2), NodeId(3)]);
1070 }
1071 }
1072
1073 #[test]
1074 fn join_affects_only_groups_where_node_enters_voter_set() {
1075 use crafty_proto::NodeId;
1076
1077 let groups: Vec<_> = (0..12).map(RaftGroupId).collect();
1078 let before = [NodeId(1), NodeId(2), NodeId(3)];
1079 let after = [NodeId(1), NodeId(2), NodeId(3), NodeId(4)];
1080 let affected = groups_joining_node_affects(NodeId(4), &groups, &before, &after, 1);
1081 assert!(!affected.is_empty());
1082 assert!(affected.len() < groups.len());
1083 for g in &affected {
1084 assert_eq!(group_voters(*g, &after, 1), vec![NodeId(4)]);
1085 }
1086 for g in groups.iter().filter(|g| !affected.contains(g)) {
1087 assert_eq!(
1088 group_voters(*g, &before, 1),
1089 group_voters(*g, &after, 1),
1090 "group {g:?} should be unchanged by join"
1091 );
1092 }
1093 }
1094
1095 #[test]
1096 fn leave_affects_groups_that_drop_the_departed_node() {
1097 use crafty_proto::NodeId;
1098
1099 let groups: Vec<_> = (0..12).map(RaftGroupId).collect();
1100 let before = [NodeId(1), NodeId(2), NodeId(3), NodeId(4)];
1101 let after = [NodeId(1), NodeId(2), NodeId(3)];
1102 let affected = groups_leaving_node_affects(NodeId(4), &groups, &before, &after, 1);
1103 for g in &affected {
1104 let voters = group_voters(*g, &before, 1);
1105 assert_eq!(voters, vec![NodeId(4)]);
1106 }
1107 }
1108
1109 #[test]
1110 fn plan_group_membership_change_diffs_add_and_remove() {
1111 use crafty_proto::NodeId;
1112
1113 let change = plan_group_membership_change(
1114 &[NodeId(1), NodeId(2), NodeId(3)],
1115 &[NodeId(1), NodeId(2), NodeId(4)],
1116 );
1117 assert_eq!(change.add, vec![NodeId(4)]);
1118 assert_eq!(change.remove, vec![NodeId(3)]);
1119 }
1120
1121 #[test]
1122 fn plan_group_membership_sync_skips_meta_and_diffs_shards() {
1123 use crafty_proto::NodeId;
1124
1125 let catalog: Vec<_> = (0..4).map(RaftGroupId).collect();
1126 let live = [NodeId(1), NodeId(2), NodeId(3), NodeId(4)];
1127 let mut current_voters = BTreeMap::new();
1128 current_voters.insert(RaftGroupId(0), vec![NodeId(1), NodeId(2), NodeId(3)]);
1129 current_voters.insert(RaftGroupId(1), vec![NodeId(1), NodeId(2), NodeId(3)]);
1130 current_voters.insert(RaftGroupId(2), vec![NodeId(1), NodeId(2), NodeId(3)]);
1131 current_voters.insert(
1132 RaftGroupId(META_RAFT_GROUP_ID),
1133 vec![NodeId(1), NodeId(2), NodeId(3)],
1134 );
1135
1136 let sync =
1137 plan_group_membership_sync(&catalog, &live, ¤t_voters, &BTreeMap::new(), 3, 0);
1138 assert!(!sync.contains_key(&RaftGroupId(META_RAFT_GROUP_ID)));
1139 assert!(sync.values().all(|t| t.voters.contains(&NodeId(4))));
1140 }
1141
1142 #[test]
1143 fn group_learners_picks_nodes_after_voters() {
1144 use crafty_proto::NodeId;
1145
1146 let nodes = [NodeId(1), NodeId(2), NodeId(3), NodeId(4), NodeId(5)];
1147 let voters = group_voters(RaftGroupId(1), &nodes, 3);
1148 let learners = group_learners(RaftGroupId(1), &nodes, 3, 2);
1149 assert_eq!(learners.len(), 2);
1150 for l in &learners {
1151 assert!(!voters.contains(l));
1152 }
1153 }
1154
1155 #[test]
1156 fn plan_rebalance_keeps_learner_hosted_groups() {
1157 use crafty_proto::NodeId;
1158
1159 let groups: Vec<_> = (0..4).map(RaftGroupId).collect();
1160 let live = [NodeId(1), NodeId(2), NodeId(3), NodeId(4)];
1161 let learner = NodeId(4);
1162 let mut found_learner_only = false;
1163 for group in &groups {
1164 let voters = group_voters(*group, &live, 3);
1165 let learners = group_learners(*group, &live, 3, 1);
1166 if learners.contains(&learner) && !voters.contains(&learner) {
1167 found_learner_only = true;
1168 let without = plan_node_group_rebalance(learner, &groups, &live, &[], 3, 0);
1169 assert!(
1170 !without.adopt.contains(group),
1171 "voter-only planner must not adopt learner-only group {group:?}"
1172 );
1173 let with = plan_node_group_rebalance(learner, &groups, &live, &[], 3, 1);
1174 assert!(
1175 with.adopt.contains(group),
1176 "learner-aware planner should adopt group {group:?}"
1177 );
1178 let retire = plan_node_group_rebalance(learner, &groups, &live, &[*group], 3, 0);
1179 assert!(
1180 retire.retire.contains(group),
1181 "voter-only planner incorrectly retires hosted learner group"
1182 );
1183 }
1184 }
1185 assert!(
1186 found_learner_only,
1187 "fixture must include at least one learner-only assignment"
1188 );
1189 }
1190
1191 #[test]
1192 fn node_should_host_group_covers_voters_and_learners() {
1193 use crafty_proto::NodeId;
1194
1195 let live = [NodeId(1), NodeId(2), NodeId(3), NodeId(4)];
1196 let group = RaftGroupId(7);
1197 let voter = group_voters(group, &live, 3)[0];
1198 let learner = group_learners(group, &live, 3, 1)
1199 .into_iter()
1200 .find(|n| !group_voters(group, &live, 3).contains(n))
1201 .expect("learner exists");
1202 assert!(node_should_host_group(group, voter, &live, 3, 1));
1203 assert!(node_should_host_group(group, learner, &live, 3, 1));
1204 assert!(!node_should_host_group(group, NodeId(99), &live, 3, 1));
1205 }
1206
1207 #[test]
1208 fn shard_count_expansion_plans_new_shard_range() {
1209 let plan = plan_shard_count_expansion(256, 512).expect("expand");
1210 assert_eq!(plan.from, 256);
1211 assert_eq!(plan.to, 512);
1212 assert_eq!(plan.new_shard_ids.len(), 256);
1213 assert_eq!(plan.new_shard_ids[0], ShardId(256));
1214 }
1215
1216 #[test]
1217 fn shard_router_expand_updates_count() {
1218 let mut router = ShardRouter::new(64);
1219 let plan = router.expand_shard_count(128).expect("expand");
1220 assert_eq!(plan.to, 128);
1221 assert_eq!(router.shard_count(), 128);
1222 }
1223
1224 #[test]
1225 fn stable_activation_does_not_remap_routable_keys() {
1226 let samples: Vec<Vec<u8>> = (0..200u16).map(|n| n.to_le_bytes().to_vec()).collect();
1227 let sample_refs: Vec<&[u8]> = samples.iter().map(std::vec::Vec::as_slice).collect();
1228
1229 let mut tier1 = ShardRouter::new(256);
1231 let before: Vec<_> = sample_refs.iter().map(|k| tier1.shard_for(k)).collect();
1232 tier1.expand_shard_count(512).expect("expand");
1233 let remapped = before
1234 .iter()
1235 .zip(sample_refs.iter())
1236 .filter(|(old, key)| **old != tier1.shard_for(key))
1237 .count();
1238 assert!(
1239 remapped > sample_refs.len() / 4,
1240 "modulus expansion should remap a large fraction"
1241 );
1242
1243 assert!(stable_router_preserves_routable_keys(
1245 256,
1246 512,
1247 &sample_refs
1248 ));
1249 }
1250
1251 #[test]
1252 fn catalog_validation_requires_contiguous_ids() {
1253 assert!(validate_catalog(&[]).is_err());
1254 assert!(validate_catalog(&[RaftGroupId(1)]).is_err());
1255 assert!(validate_catalog(&[RaftGroupId(0), RaftGroupId(2)]).is_err());
1256 assert!(validate_catalog(&[RaftGroupId(0), RaftGroupId(0)]).is_err());
1257 assert!(validate_catalog(&[RaftGroupId(0), RaftGroupId(1)]).is_ok());
1258 }
1259
1260 #[test]
1261 fn catalog_expansion_appends_contiguous_groups() {
1262 let catalog: Vec<_> = (0..3).map(RaftGroupId).collect();
1263 let plan = plan_catalog_expansion(&catalog, 2).expect("expand");
1264 assert_eq!(plan.from_len, 3);
1265 assert_eq!(plan.to_len, 5);
1266 assert_eq!(plan.new_groups, vec![RaftGroupId(3), RaftGroupId(4)]);
1267 let mut expanded = catalog;
1268 expanded.extend(plan.new_groups);
1269 assert!(validate_catalog(&expanded).is_ok());
1270 }
1271
1272 #[test]
1273 fn catalog_expansion_moves_minimal_shard_fraction() {
1274 let before: Vec<_> = (0..4).map(RaftGroupId).collect();
1275 let plan = plan_catalog_expansion(&before, 1).expect("expand");
1276 let mut after = before.clone();
1277 after.extend(plan.new_groups);
1278 let before_map = shard_assignment(400, &before);
1279 let after_map = shard_assignment(400, &after);
1280 let mut moved = 0;
1281 for (shard, old) in &before_map {
1282 let new = after_map[shard];
1283 if new != *old {
1284 assert_eq!(new, RaftGroupId(4));
1285 moved += 1;
1286 }
1287 }
1288 assert!(moved > 0);
1289 assert!(moved < 200);
1290 }
1291}