Skip to main content

crafty_core/
shard.rs

1//! Shard routing for write sharding / multi-Raft (write-sharding-multi-raft).
2//!
3//! v1 runs a **single** Raft group, so every write funnels through one leader
4//! and one log — the write-throughput ceiling recorded as risk R1 in future-work-and-risks.
5//! The scaling path is to partition the keyspace across **multiple independent
6//! Raft groups**, each replicating its own shard of state. That is a large
7//! runtime change (N drivers, per-shard storage, cross-shard routing); this
8//! module lands the **pure, deterministic routing foundation** it builds on,
9//! independently testable and free of any I/O:
10//!
11//! * [`ShardRouter`] maps an application key to a [`ShardId`] with a stable hash
12//!   (so every node in the cluster agrees on the mapping).
13//! * [`place_shard`] / [`shard_assignment`] map shards onto Raft groups with
14//!   **rendezvous (highest-random-weight) hashing**, so adding or removing a
15//!   group relocates a minimal, roughly `1/N` fraction of shards rather than
16//!   reshuffling everything.
17//!
18//! The number of shards is fixed for the life of a cluster (repartitioning is
19//! out of scope); groups may come and go, and rendezvous hashing keeps the
20//! churn small when they do.
21//!
22//! Per-group Raft membership planning (desired voter sets, join/leave diffs)
23//! lives here too — per-group-raft-membership.
24
25use std::collections::{BTreeMap, BTreeSet};
26
27/// A partition of the keyspace. Fixed count per cluster; each shard is owned by
28/// exactly one Raft group at a time.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
30pub struct ShardId(pub u32);
31
32/// Identifies one of the cluster's independent Raft groups (multi-Raft).
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
34pub struct RaftGroupId(pub u32);
35
36/// Reserved Raft group id for the cluster coordinator (Meta-Raft).
37///
38/// Hosts cluster registry (join/leave), dynamic catalog, and saga journal metadata.
39/// Not part of the user catalog or keyed shard routing.
40pub const META_RAFT_GROUP_ID: u32 = u32::MAX;
41
42/// Whether `group` is the Meta-Raft coordinator group.
43#[must_use]
44pub const fn is_meta_raft_group(group: u32) -> bool {
45    group == META_RAFT_GROUP_ID
46}
47
48/// Default replication factor for per-group voter sets (per-group-raft-membership).
49pub const DEFAULT_GROUP_REPLICATION_FACTOR: u32 = 3;
50
51/// Default non-voting learner replicas per group beyond voters (Tier 1). `0` disables.
52pub const DEFAULT_GROUP_LEARNER_FACTOR: u32 = 0;
53
54/// Upper bound for [`ShardRouter`] active shard counts (Tier 1 expansion).
55pub const MAX_VIRTUAL_SHARDS: u32 = 4096;
56
57/// How keyed traffic maps into the virtual shard space (Tier 1 vs Tier 2).
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum ShardRoutingKind {
60    /// Tier 1: `hash(key) % active_count` — keys remap when the count grows.
61    Modulus,
62    /// Tier 2: fixed virtual id `hash(key) % MAX_VIRTUAL_SHARDS` with an active prefix.
63    StableVirtual,
64}
65
66impl ShardRoutingKind {
67    /// Stable string for introspect / operator tooling.
68    #[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
77/// FNV-1a (64-bit): a small, dependency-free, **stable** hash. Stability matters
78/// because the mapping must be identical on every node and across process
79/// restarts — unlike `DefaultHasher`, whose output is not guaranteed stable.
80fn 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
91/// Mix two integers into a well-distributed hash (a `SplitMix64`-style
92/// finalizer). Used for rendezvous weights, which need good bit dispersion so
93/// shards spread evenly across groups.
94fn 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/// Maps application keys onto a fixed number of shards with a stable hash.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub struct ShardRouter {
103    shard_count: u32,
104}
105
106impl ShardRouter {
107    /// A router over `shard_count` shards (clamped to `[1, MAX_VIRTUAL_SHARDS]`).
108    #[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    /// The number of shards this router partitions keys into.
116    #[must_use]
117    pub fn shard_count(&self) -> u32 {
118        self.shard_count
119    }
120
121    /// The shard owning `key`, by stable hash modulo the shard count.
122    #[must_use]
123    pub fn shard_for(&self, key: &[u8]) -> ShardId {
124        #[allow(clippy::cast_possible_truncation)] // hash modulo shard_count always fits u32
125        ShardId((fnv1a(key) % u64::from(self.shard_count)) as u32)
126    }
127
128    /// Increase the active shard count (operator-driven expansion). Keys
129    /// **remap** when the modulus changes — drain clients before applying.
130    ///
131    /// # Errors
132    /// Returns an error when `new_count` shrinks the space or exceeds
133    /// [`MAX_VIRTUAL_SHARDS`].
134    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/// Why a shard-count expansion request was rejected.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum ShardExpansionError {
147    /// `new_count` must be strictly greater than the current count.
148    CannotShrink {
149        /// Current active shard count.
150        current: u32,
151        /// Requested count.
152        requested: u32,
153    },
154    /// `new_count` exceeds [`MAX_VIRTUAL_SHARDS`].
155    ExceedsMax {
156        /// Requested count.
157        requested: u32,
158    },
159    /// Keyed routing / expansion requires multi-Raft (`raft_groups > 1`).
160    NotMultiRaft,
161    /// Stable virtual routing is active — use [`StableShardRouter::activate_shards`].
162    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/// Plan for expanding the active shard keyspace (Tier 1).
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct ShardCountExpansionPlan {
191    /// Previous active shard count.
192    pub from: u32,
193    /// New active shard count.
194    pub to: u32,
195    /// Shard ids entering the active range `[from, to)`.
196    pub new_shard_ids: Vec<ShardId>,
197}
198
199/// Plan a shard-count increase. Shrinking is rejected — pick a larger
200/// [`MAX_VIRTUAL_SHARDS`] up front or migrate data explicitly.
201///
202/// # Errors
203/// Returns [`ShardExpansionError`] when `to` is not a strict increase within
204/// [`MAX_VIRTUAL_SHARDS`].
205pub 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/// The Raft group that owns `shard`, chosen by rendezvous (highest-random-weight)
227/// hashing: the group maximizing `mix64(shard, group)`. Returns `None` only when
228/// `groups` is empty. Deterministic given the same group set, and stable under
229/// group churn — removing the winning group promotes the next-highest weight,
230/// leaving all other shards' owners unchanged.
231#[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
236/// The rendezvous weight of pairing `shard` with `group`.
237fn weight(shard: ShardId, group: RaftGroupId) -> u64 {
238    mix64(u64::from(shard.0) << 32 | u64::from(group.0))
239}
240
241/// Rendezvous weight for placing `group` on physical node `node`.
242fn group_node_weight(group: RaftGroupId, node: crafty_proto::NodeId) -> u64 {
243    mix64(u64::from(group.0) << 32 | node.0)
244}
245
246/// The physical node that should host the sole replica of `group` among
247/// `nodes`, by rendezvous hashing. Returns `None` when `nodes` is empty.
248/// Deterministic and stable under node churn — the same property as
249/// [`place_shard`].
250#[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/// Full assignment of each Raft group to a host node over `nodes`.
262#[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/// Clamp `replication_factor` to `[1, live_count]`. Returns `0` when
280/// `live_count == 0`.
281#[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/// Desired voter set for one Raft group: the top [`effective_replication_factor`]
292/// live nodes by rendezvous weight for `group`, sorted by `NodeId` (per-group-raft-membership).
293#[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/// Desired learner set for one Raft group: live nodes ranked after the voter
315/// set, up to `learner_factor` nodes (Tier 1 per-group-raft-membership).
316#[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/// Desired voters + learners for one group.
343#[derive(Debug, Clone, PartialEq, Eq)]
344pub struct GroupReplicationTarget {
345    /// Joint-consensus voters.
346    pub voters: Vec<crafty_proto::NodeId>,
347    /// Non-voting learners (catch-up replicas).
348    pub learners: Vec<crafty_proto::NodeId>,
349}
350
351/// Full desired voter assignment for every group in `groups` (per-group-raft-membership).
352#[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/// Per-group membership delta between a committed and desired voter set.
365#[derive(Debug, Clone, PartialEq, Eq, Default)]
366pub struct GroupMembershipChange {
367    /// Voters to add via joint consensus.
368    pub add: Vec<crafty_proto::NodeId>,
369    /// Voters to remove via joint consensus.
370    pub remove: Vec<crafty_proto::NodeId>,
371}
372
373/// Diff `current_voters` against `desired_voters` (sorted inputs not required).
374#[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(&current).copied().collect(),
385        remove: current.difference(&desired).copied().collect(),
386    }
387}
388
389/// Groups whose desired voter set gains `node` when the live set grows from
390/// `live_nodes_before` to `live_nodes_after` (cluster join).
391#[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/// Groups whose desired voter set loses `node` when it departs the live set
419/// (cluster leave).
420#[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/// Groups whose desired voter/learner sets differ from `current`
448/// (per-group-raft-membership). Skips the Meta-Raft coordinator — its
449/// membership is managed by `/cluster/join` and `/cluster/leave`.
450#[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/// Local rebalance actions for one physical node (multi-Raft control plane).
489#[derive(Debug, Clone, PartialEq, Eq, Default)]
490pub struct GroupRebalancePlan {
491    /// Groups this node should begin hosting.
492    pub adopt: Vec<RaftGroupId>,
493    /// Groups this node should stop hosting.
494    pub retire: Vec<RaftGroupId>,
495}
496
497/// Whether `node_id` should run a local replica for `group` (voter or learner).
498#[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/// Diff the groups `node_id` currently hosts against groups where it belongs
511/// in the desired voter or learner set (per-group-raft-membership).
512#[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(&current).copied().collect();
533    let retire = current.difference(&should).copied().collect();
534    GroupRebalancePlan { adopt, retire }
535}
536
537// ---------------------------------------------------------------------------
538// Tier 2 — stable virtual shards + dynamic catalog (pure planners)
539// ---------------------------------------------------------------------------
540
541/// Map `key` to a **fixed** virtual shard in `[0, [``MAX_VIRTUAL_SHARDS``])`.
542/// Unlike [`ShardRouter::shard_for`], this id never changes when the active
543/// prefix grows ([tier2-multi-raft-architecture]).
544///
545/// [multi-raft]: ../../../docs/decisions/multi-raft.md
546#[must_use]
547pub fn virtual_shard_for(key: &[u8]) -> ShardId {
548    #[allow(clippy::cast_possible_truncation)] // hash modulo MAX_VIRTUAL_SHARDS always fits u32
549    ShardId((fnv1a(key) % u64::from(MAX_VIRTUAL_SHARDS)) as u32)
550}
551
552/// Whether `shard` is routable given `active_count` active virtual shards.
553#[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/// Why stable shard activation was rejected.
559#[derive(Debug, Clone, Copy, PartialEq, Eq)]
560pub enum StableShardActivationError {
561    /// `new_active` must be strictly greater than the current active count.
562    CannotShrink {
563        /// Current active virtual shards.
564        current: u32,
565        /// Requested active count.
566        requested: u32,
567    },
568    /// `new_active` exceeds [`MAX_VIRTUAL_SHARDS`].
569    ExceedsMax {
570        /// Requested active count.
571        requested: u32,
572    },
573    /// Tier 1 modulus routing is active — use [`ShardRouter::expand_shard_count`].
574    ModulusRoutingActive,
575    /// Activation requires multi-Raft (`raft_groups > 1`).
576    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/// Plan for activating more virtual shards without remapping existing keys.
603#[derive(Debug, Clone, PartialEq, Eq)]
604pub struct StableShardActivationPlan {
605    /// Previous active virtual shard count.
606    pub from: u32,
607    /// New active virtual shard count.
608    pub to: u32,
609    /// Virtual shard ids entering the active range `[from, to)`.
610    pub newly_active: Vec<ShardId>,
611}
612
613/// Plan increasing the active virtual shard prefix (Tier 2 stable expansion).
614///
615/// # Errors
616/// Returns [`StableShardActivationError`] when `to` is not a strict increase
617/// within [`MAX_VIRTUAL_SHARDS`].
618pub 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/// Why a Tier 1 → Tier 2 routing switch was rejected.
640#[derive(Debug, Clone, Copy, PartialEq, Eq)]
641pub enum ShardRoutingSwitchError {
642    /// Stable virtual routing is already active.
643    AlreadyStable,
644    /// Active shard count must be at least 1.
645    InvalidActiveCount,
646    /// Routing switch requires multi-Raft (`raft_groups > 1`).
647    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/// Operator plan for switching keyed routing from modulus to stable virtual.
669#[derive(Debug, Clone, Copy, PartialEq, Eq)]
670pub struct ShardRoutingSwitchPlan {
671    /// Previous routing mode (always [`ShardRoutingKind::Modulus`]).
672    pub from: ShardRoutingKind,
673    /// Target routing mode (always [`ShardRoutingKind::StableVirtual`]).
674    pub to: ShardRoutingKind,
675    /// Active shard count preserved across the switch.
676    pub active_count: u32,
677}
678
679/// Validate switching from Tier 1 modulus to Tier 2 stable virtual routing.
680///
681/// Keys **remap** to the stable formula — drain keyed clients before applying
682/// ([multi-raft](../../docs/decisions/multi-raft.md)).
683///
684/// # Errors
685/// Returns [`ShardRoutingSwitchError::AlreadyStable`] when already on stable routing.
686pub 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/// Router over a fixed virtual space with a tunable active prefix (Tier 2).
704#[derive(Debug, Clone, Copy, PartialEq, Eq)]
705pub struct StableShardRouter {
706    active_count: u32,
707}
708
709impl StableShardRouter {
710    /// Active virtual shard count in `[1, MAX_VIRTUAL_SHARDS]`.
711    #[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    /// Number of virtual shards currently accepting keyed traffic.
719    #[must_use]
720    pub fn active_count(&self) -> u32 {
721        self.active_count
722    }
723
724    /// Virtual shard for `key`, or `None` when the key lands outside the active prefix.
725    #[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    /// Grow the active prefix without remapping keys already in `[0, from)`.
732    ///
733    /// # Errors
734    /// Same rules as [`plan_stable_shard_activation`].
735    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/// Invalid multi-Raft group catalog.
746#[derive(Debug, Clone, Copy, PartialEq, Eq)]
747pub enum CatalogError {
748    /// Catalog must not be empty.
749    Empty,
750    /// Group ids must be contiguous `0..=max` without gaps.
751    NonContiguous {
752        /// Last valid id before the gap.
753        expected_next: u32,
754        /// Id that broke contiguity.
755        found: u32,
756    },
757    /// Duplicate group id.
758    Duplicate {
759        /// Repeated id.
760        group: u32,
761    },
762    /// `add_groups` must be at least 1.
763    InvalidExpansionCount {
764        /// Requested append count.
765        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/// Plan for appending contiguous Raft groups to the catalog (Tier 2).
791#[derive(Debug, Clone, PartialEq, Eq)]
792pub struct CatalogExpansionPlan {
793    /// Previous catalog length.
794    pub from_len: u32,
795    /// Catalog length after expansion.
796    pub to_len: u32,
797    /// New group ids appended in order.
798    pub new_groups: Vec<RaftGroupId>,
799}
800
801/// Validate a multi-Raft user group catalog (contiguous ids `0..=max`).
802///
803/// The Meta-Raft coordinator group is not part of the catalog.
804///
805/// # Errors
806/// Returns [`CatalogError`] when `catalog` violates catalog invariants.
807pub 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)] // catalog indices are contiguous from zero
817        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
828/// Plan appending `add_groups` contiguous ids after the current catalog tail.
829///
830/// # Errors
831/// Returns [`CatalogError`] when the current catalog is invalid or `add_groups`
832/// is zero (use `add_groups >= 1`).
833///
834/// # Panics
835/// Panics if the validated catalog is empty (invariant after [`validate_catalog`]).
836pub 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/// After growing the active prefix, keys that were already routable keep the
857/// same virtual shard id.
858#[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/// The full shard → owning-group assignment for `shard_count` shards over
882/// `groups`, using [`place_shard`]. Empty when `groups` is empty.
883#[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        // Deterministic: same key → same shard, every time.
910        let a = router.shard_for(b"account:42");
911        let b = router.shard_for(b"account:42");
912        assert_eq!(a, b);
913        // Always in `[0, shard_count)`.
914        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        // A good hash should touch every shard over 1000 keys.
927        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        // Re-running yields the identical assignment.
946        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        // Each of 4 groups should own ~100 of 400 shards; allow generous slack.
964        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        // Rendezvous hashing's key property: growing from 3→4 groups should move
975        // only shards that the new group now wins — about 1/4 — and never
976        // shuffle shards between the pre-existing groups.
977        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                // Any moved shard must have moved *to the new group*, never
993                // between two old groups.
994                assert_eq!(
995                    new,
996                    RaftGroupId(4),
997                    "shard {shard:?} churned between old groups"
998                );
999                moved += 1;
1000            }
1001        }
1002        // Expect roughly a quarter to move; assert it stays well under half.
1003        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, &current_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        // Tier 1 modulus router remaps most keys when count doubles.
1230        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        // Stable router keeps virtual shard ids for already-routable keys.
1244        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}