Skip to main content

reddb_server/cluster/
move_range.rs

1//! Split-and-move planning and the move-range cutover state machine
2//! (issue #1004, PRD #987, ADR 0037).
3//!
4//! The [`WeightedPlacementPlanner`](super::placement::WeightedPlacementPlanner)
5//! decides *that* a range should move; this module decides *how* it moves and
6//! drives the move safely to completion. It is the glossary's **split-and-move**
7//! — *"rebalancing transition that first divides a large or hot shard/range, then
8//! moves only the selected subrange to a different writer. Small ranges may move
9//! whole without splitting"* — riding the glossary's **move range cutover** —
10//! *"the old owner continues serving writes while the target first copies a
11//! physical checkpoint/snapshot of the range directory, then catches up through
12//! the logical range-indexed stream; only after catch-up does the catalog epoch
13//! move write authority to the target."*
14//!
15//! ## Whole-range vs split-and-move
16//!
17//! [`classify_move`] is the small/large-or-hot decision: a range whose bytes and
18//! traffic both sit under the [`SplitPolicy`] thresholds moves whole
19//! ([`MoveKind::Whole`]); a range over either threshold is split first so the
20//! move sheds only part of the load ([`MoveKind::Split`]). [`split_range`] then
21//! carves the range at a chosen key into a retained child (the keys the owner
22//! keeps) and a moved child (a fresh range id the move hands off), tiling the
23//! original keyspace with no gap or overlap.
24//!
25//! ## The cutover, fenced and gated
26//!
27//! [`MoveRange`] is the state machine for one move. It encodes the move-range
28//! invariant directly:
29//!
30//! 1. **[`CopyingSnapshot`](MovePhase::CopyingSnapshot)** — the target copies a
31//!    consistent physical snapshot of the range. Throughout, the catalog still
32//!    names the old owner, so the old owner *keeps serving writes*.
33//! 2. **[`CatchingUp`](MovePhase::CatchingUp)** — the snapshot is installed at a
34//!    consistent [`CommitWatermark`]; the target replays **range-indexed WAL
35//!    records** (issue #992) from that point to close the gap to the live commit
36//!    watermark, which keeps advancing because the old owner is still writing.
37//! 3. **[`cut_over`](MoveRange::cut_over)** — only when the target's applied log
38//!    covers the live commit watermark does the fenced
39//!    [`Handoff`](super::ownership_transition::TransitionKind::Handoff) transition
40//!    move the catalog epoch. The epoch bump fences the old owner (its writes now
41//!    carry a stale epoch and [`admit_public_write`] rejects them) and makes the
42//!    target authoritative. *The target accepts no public write until this
43//!    instant* — before it, the target is a replica and the ownership gate
44//!    rejects it.
45//!
46//! ## Interrupted moves fail safe
47//!
48//! A move can be interrupted at any point — a supervisor restart, a crashed
49//! target. [`recover_interrupted_move`] resumes from the target's persisted
50//! catch-up position and **promotes the target only if it covers the range commit
51//! watermark**; otherwise it leaves the catalog untouched and the old owner keeps
52//! authority. A half-copied target is never promoted, so an interrupted move can
53//! lose no committed write.
54//!
55//! Everything here is a pure data model over the catalog plus the range-indexed
56//! WAL contract — no disk, no clock, no network — so the split arithmetic, the
57//! catch-up gate, the fencing, and the interrupted-move safety are all exercised
58//! deterministically.
59//!
60//! [`admit_public_write`]: super::ownership::ShardOwnershipCatalog::admit_public_write
61
62use crate::replication::cdc::{
63    plan_range_catchup, ChangeRecord, RangeCatchupPlan, RangeStreamPosition,
64};
65
66use super::identity::NodeIdentity;
67use super::ownership::{
68    CatalogError, CatalogVersion, CollectionId, OwnershipEpoch, RangeBoundsError, RangeId,
69    RangeOwnership, ShardOwnershipCatalog,
70};
71use super::ownership_transition::{
72    run_transition, CatchUpEvidence, CommitWatermark, TransitionError, TransitionKind,
73    TransitionOutcome, TransitionRequest,
74};
75use super::placement::{CollectionGroupId, CollectionGroupPlacementAuthority, RangeLoad};
76
77/// The thresholds that decide whether a range is small enough to move whole or
78/// must be split first.
79///
80/// The two are independent and either trips a split: a range can be small on disk
81/// yet a traffic hotspot, or quiet yet too large to copy and cut over as one
82/// unit. Splitting in either case lets the move shed only a subrange instead of
83/// relocating the whole load at once.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub struct SplitPolicy {
86    /// A range strictly **above** this many bytes is "large" — too big to copy
87    /// and cut over whole, so it is split and only a subrange moves.
88    pub max_whole_move_bytes: u64,
89    /// A range serving **at or above** this much read+write traffic in the
90    /// observation window is "hot" — split so the move relocates only part of the
91    /// traffic.
92    pub hot_traffic_threshold: u64,
93}
94
95impl Default for SplitPolicy {
96    fn default() -> Self {
97        // Deliberately coarse defaults: only a genuinely large or genuinely hot
98        // range is worth the extra split step; everything else moves whole.
99        Self {
100            max_whole_move_bytes: 256 * 1024 * 1024,
101            hot_traffic_threshold: 10_000,
102        }
103    }
104}
105
106/// How a planned move should be carried out: relocate the whole range, or split
107/// it first and move only a subrange.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum MoveKind {
110    /// Small and cool: copy and cut over the whole range in one move.
111    Whole,
112    /// Large or hot: divide the range and move only the selected subrange.
113    Split,
114}
115
116/// Decide whether a range moves whole or is split first, from its live load and
117/// the [`SplitPolicy`]. A range over the byte ceiling **or** at/over the hot
118/// traffic threshold is split; otherwise it moves whole.
119pub fn classify_move(load: RangeLoad, policy: &SplitPolicy) -> MoveKind {
120    let large = load.bytes_used > policy.max_whole_move_bytes;
121    let hot = load.traffic() >= policy.hot_traffic_threshold && policy.hot_traffic_threshold > 0;
122    if large || hot {
123        MoveKind::Split
124    } else {
125        MoveKind::Whole
126    }
127}
128
129/// Which child of a split moves to the target.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum SplitSide {
132    /// The lower child `[lower, split_key)` moves; the owner retains the upper.
133    Lower,
134    /// The upper child `[split_key, upper)` moves; the owner retains the lower.
135    Upper,
136}
137
138/// The two entries a [`split_range`] produces: the child the owner keeps and the
139/// child the move will hand off.
140///
141/// Applying a split is order-sensitive — the retained child must be **narrowed
142/// first**, then the moved child created — or the create would transiently
143/// overlap the still-full original and the catalog would reject it.
144/// [`apply`](Self::apply) does this in the right order.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct RangeSplit {
147    retained: RangeOwnership,
148    moved: RangeOwnership,
149}
150
151impl RangeSplit {
152    /// The child the owner keeps writing — the original range id, narrowed to the
153    /// non-moved keys, version advanced but epoch unchanged (no authority moved).
154    pub fn retained(&self) -> &RangeOwnership {
155        &self.retained
156    }
157
158    /// The carved-off child the move hands off — a fresh range id, still owned by
159    /// the original owner (which keeps serving its keys until cutover) with the
160    /// move target enlisted as a replica.
161    pub fn moved(&self) -> &RangeOwnership {
162        &self.moved
163    }
164
165    /// Install the split into the catalog: narrow the retained child first, then
166    /// create the moved child. After this the two children tile the original
167    /// keyspace and the move can proceed on [`moved`](Self::moved)'s range id.
168    pub fn apply(&self, catalog: &mut ShardOwnershipCatalog) -> Result<(), CatalogError> {
169        // Narrow the retained child first so the moved child no longer overlaps a
170        // still-full original on create.
171        catalog.apply_update(self.retained.clone())?;
172        catalog.apply_update(self.moved.clone())?;
173        Ok(())
174    }
175}
176
177/// Divide `range` at `split_key` into a retained child and a moved child, with
178/// `target` enlisted as a replica of the moved child so a later
179/// [`MoveRange`] can hand authority to it.
180///
181/// `moved_id` is the fresh range id the carved-off subrange takes; it must differ
182/// from `range`'s own id. `moved_side` selects which child moves: the retained
183/// child keeps `range`'s id (narrowed in place), and the moved child is a brand
184/// new entry at epoch/version 1 — its data still lives under the owner until the
185/// move cuts over. Fails with [`SplitError`] if the split key does not fall
186/// strictly inside the range or the moved id collides with the original.
187pub fn split_range(
188    range: &RangeOwnership,
189    split_key: &[u8],
190    moved_side: SplitSide,
191    moved_id: RangeId,
192    target: NodeIdentity,
193) -> Result<RangeSplit, SplitError> {
194    if moved_id == range.range_id() {
195        return Err(SplitError::MovedIdCollision { id: moved_id });
196    }
197    let (lower_bounds, upper_bounds) = range
198        .bounds()
199        .split_at(split_key)
200        .map_err(SplitError::Bounds)?;
201    let (retained_bounds, moved_bounds) = match moved_side {
202        // The moved child is the lower part; the owner retains the upper.
203        SplitSide::Lower => (upper_bounds, lower_bounds),
204        // The moved child is the upper part; the owner retains the lower.
205        SplitSide::Upper => (lower_bounds, upper_bounds),
206    };
207
208    // The retained child keeps the original id and owner, narrowed in place.
209    let retained = range.with_bounds(retained_bounds);
210
211    // The moved child is a fresh range, still owned by the current owner (it keeps
212    // serving these keys until cutover) with the target enlisted as a replica so
213    // the handoff has a valid promotion candidate.
214    let mut replicas: Vec<NodeIdentity> = range.replicas().to_vec();
215    if !replicas.contains(&target) {
216        replicas.push(target);
217    }
218    let moved = RangeOwnership::establish(
219        range.collection().clone(),
220        moved_id,
221        range.shard_key_mode(),
222        moved_bounds,
223        range.owner().clone(),
224        replicas,
225        range.placement().clone(),
226    );
227
228    Ok(RangeSplit { retained, moved })
229}
230
231/// Why a range split could not be planned.
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub enum SplitError {
234    /// The split key does not fall strictly inside the range's bounds, so one
235    /// child would be empty.
236    Bounds(RangeBoundsError),
237    /// The moved subrange was given the same range id as the range being split.
238    MovedIdCollision { id: RangeId },
239}
240
241impl std::fmt::Display for SplitError {
242    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243        match self {
244            Self::Bounds(err) => write!(f, "cannot split range: {err}"),
245            Self::MovedIdCollision { id } => write!(
246                f,
247                "split moved subrange id {id} collides with the range being split"
248            ),
249        }
250    }
251}
252
253impl std::error::Error for SplitError {}
254
255/// Where a move-range is in its copy → catch-up → cutover lifecycle.
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub enum MovePhase {
258    /// The target is copying a consistent physical snapshot of the range. The
259    /// catalog still names the old owner, which keeps serving writes.
260    CopyingSnapshot,
261    /// The snapshot is installed at a consistent watermark; the target is
262    /// replaying range-indexed WAL records to catch up to the live commit
263    /// watermark.
264    CatchingUp,
265    /// The catalog epoch has moved: the target is authoritative and the old owner
266    /// is fenced.
267    Completed,
268    /// The move was abandoned; the old owner retains authority.
269    Aborted,
270}
271
272impl MovePhase {
273    fn label(self) -> &'static str {
274        match self {
275            MovePhase::CopyingSnapshot => "copying-snapshot",
276            MovePhase::CatchingUp => "catching-up",
277            MovePhase::Completed => "completed",
278            MovePhase::Aborted => "aborted",
279        }
280    }
281}
282
283/// One in-flight move-range: the bookkeeping that carries authority for one range
284/// from its current owner to a target without losing a write or letting the
285/// target serve early.
286///
287/// Built with [`begin`](Self::begin), which enlists the target as a replica and
288/// captures the catalog CAS (owner / epoch / version) the cutover will use. The
289/// snapshot point and the target's catch-up progress are filled in as the move
290/// runs. Until [`cut_over`](Self::cut_over) succeeds the catalog is unchanged, so
291/// the old owner keeps serving and the target — a mere replica — cannot.
292#[derive(Debug, Clone, PartialEq, Eq)]
293pub struct MoveRange {
294    collection: CollectionId,
295    range_id: RangeId,
296    /// The range's current owner — the move's source, fenced at cutover.
297    source: NodeIdentity,
298    /// The move target — promoted at cutover, a replica until then.
299    target: NodeIdentity,
300    /// The catalog epoch captured at [`begin`](Self::begin) — the cutover CAS.
301    expected_epoch: OwnershipEpoch,
302    /// The catalog version captured at [`begin`](Self::begin) — the cutover CAS.
303    expected_version: CatalogVersion,
304    phase: MovePhase,
305    /// The consistent point the snapshot was taken at, once installed.
306    snapshot_watermark: Option<CommitWatermark>,
307    /// The target's range-indexed catch-up position over the shared WAL, once the
308    /// snapshot is installed and catch-up begins.
309    position: Option<RangeStreamPosition>,
310    /// The Collection group Placement Authority that scoped this movement, when
311    /// the caller uses the authority-checked workflow.
312    placement_authority: Option<CollectionGroupPlacementAuthority>,
313}
314
315impl MoveRange {
316    /// Start moving `(collection, range_id)` to `target`. Enlists `target` as a
317    /// replica of the range if it is not one already (so the cutover has a valid
318    /// promotion candidate), then captures the catalog CAS for the eventual
319    /// fenced handoff. The move begins in [`CopyingSnapshot`](MovePhase::CopyingSnapshot);
320    /// the catalog's *owner* is unchanged, so the old owner keeps serving writes.
321    ///
322    /// Fails if the range is unknown or `target` is already its owner (a move to
323    /// the incumbent is a no-op).
324    pub fn begin(
325        catalog: &mut ShardOwnershipCatalog,
326        collection: CollectionId,
327        range_id: RangeId,
328        target: NodeIdentity,
329    ) -> Result<Self, MoveError> {
330        let current =
331            catalog
332                .range(&collection, range_id)
333                .ok_or_else(|| MoveError::UnknownRange {
334                    collection: collection.clone(),
335                    range_id,
336                })?;
337        let source = current.owner().clone();
338        if target == source {
339            return Err(MoveError::TargetIsOwner {
340                collection,
341                range_id,
342                owner: source,
343            });
344        }
345
346        // Enlist the target as a replica if it is not one yet — a replica is the
347        // only valid handoff candidate. This advances the version but not the
348        // epoch (no authority moved), so the old owner is not fenced.
349        if !current.replicas().contains(&target) {
350            let mut replicas: Vec<NodeIdentity> = current.replicas().to_vec();
351            replicas.push(target.clone());
352            let enlisted = current.update_replicas(replicas);
353            catalog.apply_update(enlisted).map_err(MoveError::Catalog)?;
354        }
355
356        // Capture the CAS *after* any replica enlistment so the cutover names the
357        // current catalog version.
358        let current = catalog
359            .range(&collection, range_id)
360            .expect("range present immediately after enlist");
361        Ok(Self {
362            collection,
363            range_id,
364            source,
365            target,
366            expected_epoch: current.epoch(),
367            expected_version: current.version(),
368            phase: MovePhase::CopyingSnapshot,
369            snapshot_watermark: None,
370            position: None,
371            placement_authority: None,
372        })
373    }
374
375    /// Start a move under the Collection group Placement Authority responsible
376    /// for this range's collection. The authority scopes the operational move;
377    /// the later cutover still transitions only this range's owner and epoch.
378    pub fn begin_authorized(
379        catalog: &mut ShardOwnershipCatalog,
380        collection: CollectionId,
381        range_id: RangeId,
382        target: NodeIdentity,
383        placement_authority: CollectionGroupPlacementAuthority,
384        caller: &NodeIdentity,
385    ) -> Result<Self, MoveError> {
386        validate_placement_authority(&collection, &placement_authority, caller)?;
387        let mut movement = Self::begin(catalog, collection, range_id, target)?;
388        movement.placement_authority = Some(placement_authority);
389        Ok(movement)
390    }
391
392    pub fn phase(&self) -> MovePhase {
393        self.phase
394    }
395
396    pub fn source(&self) -> &NodeIdentity {
397        &self.source
398    }
399
400    pub fn target(&self) -> &NodeIdentity {
401        &self.target
402    }
403
404    /// The consistent point the physical snapshot was taken at, once installed.
405    pub fn snapshot_watermark(&self) -> Option<CommitWatermark> {
406        self.snapshot_watermark
407    }
408
409    /// The target's catch-up position over the range-indexed WAL, once catch-up
410    /// has begun.
411    pub fn position(&self) -> Option<RangeStreamPosition> {
412        self.position
413    }
414
415    /// Record that the target has installed a consistent physical snapshot taken
416    /// at `at`. Moves the move into [`CatchingUp`](MovePhase::CatchingUp) and
417    /// seeds the catch-up position from the snapshot point: the target has applied
418    /// everything up to `at` and will accept range records ahead of it, fencing
419    /// any stamped below the range's current ownership epoch.
420    ///
421    /// Only valid while copying the snapshot.
422    pub fn complete_snapshot(&mut self, at: CommitWatermark) -> Result<(), MoveError> {
423        self.expect_phase(MovePhase::CopyingSnapshot)?;
424        self.snapshot_watermark = Some(at);
425        self.position = Some(RangeStreamPosition::new(
426            self.range_id.value(),
427            at.lsn,
428            at.term,
429            self.expected_epoch.value(),
430        ));
431        self.phase = MovePhase::CatchingUp;
432        Ok(())
433    }
434
435    /// Replay a slice of the shared logical stream into the target's range-indexed
436    /// catch-up, advancing its applied position past every record stamped for this
437    /// range (issue #992). Returns the [`RangeCatchupPlan`] so the caller can see
438    /// which records applied and which were fenced. Only valid while catching up.
439    pub fn record_catch_up(
440        &mut self,
441        records: &[ChangeRecord],
442    ) -> Result<RangeCatchupPlan, MoveError> {
443        self.expect_phase(MovePhase::CatchingUp)?;
444        let position = self
445            .position
446            .as_mut()
447            .expect("catch-up position present while catching up");
448        let plan = plan_range_catchup(position, records);
449        *position = plan.resume;
450        Ok(plan)
451    }
452
453    /// The catch-up evidence the cutover will present for the target: the highest
454    /// `(term, lsn)` it has applied for the range. `None` before a snapshot is
455    /// installed.
456    pub fn catch_up_evidence(&self) -> Option<CatchUpEvidence> {
457        self.position.map(|position| {
458            CatchUpEvidence::new(
459                self.target.clone(),
460                position.accepted_term,
461                position.applied_lsn,
462            )
463        })
464    }
465
466    /// Whether the target's applied log covers `live` — the live range commit
467    /// watermark, which has advanced past the snapshot point as the old owner kept
468    /// writing. The cutover may only proceed once this holds.
469    pub fn has_caught_up(&self, live: CommitWatermark) -> bool {
470        self.catch_up_evidence()
471            .map(|evidence| evidence.covers(live))
472            .unwrap_or(false)
473    }
474
475    /// Cut over: move the catalog epoch to the target through the fenced
476    /// [`Handoff`](TransitionKind::Handoff) transition, demoting the old owner to a
477    /// replica. The move must be [`CatchingUp`](MovePhase::CatchingUp) and the
478    /// target must cover `live` — otherwise this returns
479    /// [`TargetBehindWatermark`](MoveError::TargetBehindWatermark) **without
480    /// touching the catalog**, so a target that has not caught up is never
481    /// promoted and the old owner keeps serving.
482    ///
483    /// On success the catalog names the target at a new epoch (fencing the old
484    /// owner's stale-epoch writes) and the move is [`Completed`](MovePhase::Completed).
485    pub fn cut_over(
486        &mut self,
487        catalog: &mut ShardOwnershipCatalog,
488        live: CommitWatermark,
489    ) -> Result<TransitionOutcome, MoveError> {
490        self.expect_phase(MovePhase::CatchingUp)?;
491        let evidence = self
492            .catch_up_evidence()
493            .expect("catch-up evidence present while catching up");
494        if !evidence.covers(live) {
495            return Err(MoveError::TargetBehindWatermark {
496                collection: self.collection.clone(),
497                range_id: self.range_id,
498                target: self.target.clone(),
499                watermark: live,
500                applied_term: evidence.applied_term,
501                applied_lsn: evidence.applied_lsn,
502            });
503        }
504
505        let outcome = attempt_handoff(
506            catalog,
507            &self.collection,
508            self.range_id,
509            &self.source,
510            self.expected_epoch,
511            self.expected_version,
512            &self.target,
513            evidence,
514            live,
515        )?;
516        self.phase = MovePhase::Completed;
517        Ok(outcome)
518    }
519
520    /// Cut over under the same Collection group Placement Authority that planned
521    /// the move. The authority check gates the workflow, then the catalog update
522    /// remains the same per-range fenced handoff as [`cut_over`](Self::cut_over).
523    pub fn cut_over_authorized(
524        &mut self,
525        catalog: &mut ShardOwnershipCatalog,
526        live: CommitWatermark,
527        caller: &NodeIdentity,
528    ) -> Result<TransitionOutcome, MoveError> {
529        let placement_authority = self.placement_authority.as_ref().ok_or_else(|| {
530            MoveError::MissingPlacementAuthority {
531                collection: self.collection.clone(),
532                range_id: self.range_id,
533            }
534        })?;
535        validate_placement_authority(&self.collection, placement_authority, caller)?;
536        self.cut_over(catalog, live)
537    }
538
539    /// Abandon the move. The catalog is untouched (the old owner remains owner);
540    /// the target keeps whatever copy it has but is never promoted.
541    pub fn abort(&mut self) {
542        self.phase = MovePhase::Aborted;
543    }
544
545    fn expect_phase(&self, expected: MovePhase) -> Result<(), MoveError> {
546        if self.phase == expected {
547            Ok(())
548        } else {
549            Err(MoveError::WrongPhase {
550                expected: expected.label(),
551                actual: self.phase,
552            })
553        }
554    }
555}
556
557fn validate_placement_authority(
558    collection: &CollectionId,
559    placement_authority: &CollectionGroupPlacementAuthority,
560    caller: &NodeIdentity,
561) -> Result<(), MoveError> {
562    if !placement_authority.covers(collection) {
563        return Err(MoveError::CollectionOutsidePlacementAuthority {
564            collection: collection.clone(),
565            collection_group: placement_authority.collection_group().clone(),
566            authority: placement_authority.authority().clone(),
567        });
568    }
569    if placement_authority.authority() != caller {
570        return Err(MoveError::WrongPlacementAuthority {
571            collection_group: placement_authority.collection_group().clone(),
572            expected: placement_authority.authority().clone(),
573            actual: caller.clone(),
574        });
575    }
576    Ok(())
577}
578
579/// Resume an interrupted move and decide its fate from the target's persisted
580/// catch-up position alone — the recovery path after a supervisor restart or a
581/// crash mid-move.
582///
583/// Promotes `target` through the fenced handoff **only if** its applied position
584/// covers `live` (the range commit watermark); otherwise it leaves the catalog
585/// untouched so the old owner keeps authority. This is the interrupted-move
586/// safety rule: a half-copied target is never promoted, so no committed write is
587/// lost when a move is cut short.
588pub fn recover_interrupted_move(
589    catalog: &mut ShardOwnershipCatalog,
590    collection: &CollectionId,
591    range_id: RangeId,
592    target: &NodeIdentity,
593    target_position: RangeStreamPosition,
594    live: CommitWatermark,
595) -> Result<MoveRecovery, MoveError> {
596    let current = catalog
597        .range(collection, range_id)
598        .ok_or_else(|| MoveError::UnknownRange {
599            collection: collection.clone(),
600            range_id,
601        })?;
602    let source = current.owner().clone();
603    let expected_epoch = current.epoch();
604    let expected_version = current.version();
605
606    let evidence = CatchUpEvidence::new(
607        target.clone(),
608        target_position.accepted_term,
609        target_position.applied_lsn,
610    );
611
612    // The interrupted-move safety gate: promote only a target that covers the
613    // range commit watermark. A target behind it is abandoned and the source
614    // retains authority — the catalog is not touched.
615    if !evidence.covers(live) {
616        return Ok(MoveRecovery::AbortedSourceRetained {
617            applied_term: evidence.applied_term,
618            applied_lsn: evidence.applied_lsn,
619            watermark: live,
620        });
621    }
622
623    let outcome = attempt_handoff(
624        catalog,
625        collection,
626        range_id,
627        &source,
628        expected_epoch,
629        expected_version,
630        target,
631        evidence,
632        live,
633    )?;
634    Ok(MoveRecovery::Promoted(outcome))
635}
636
637/// The outcome of recovering an interrupted move.
638#[derive(Debug, Clone, PartialEq, Eq)]
639pub enum MoveRecovery {
640    /// The target covered the watermark and was promoted through a fenced
641    /// handoff; the old owner is now fenced.
642    Promoted(TransitionOutcome),
643    /// The target did not cover the watermark; the move was abandoned and the
644    /// source retains authority. Carries the target's applied position and the
645    /// watermark it fell short of.
646    AbortedSourceRetained {
647        applied_term: u64,
648        applied_lsn: u64,
649        watermark: CommitWatermark,
650    },
651}
652
653impl MoveRecovery {
654    /// Whether recovery promoted the target. False when the move was abandoned.
655    pub fn promoted(&self) -> bool {
656        matches!(self, MoveRecovery::Promoted(_))
657    }
658}
659
660/// Build and run the fenced [`Handoff`](TransitionKind::Handoff) that completes a
661/// move: the target takes ownership and the old owner is demoted to a replica and
662/// fenced by the epoch bump. Shared by the normal cutover and interrupted-move
663/// recovery so both run the identical safety gate.
664#[allow(clippy::too_many_arguments)]
665fn attempt_handoff(
666    catalog: &mut ShardOwnershipCatalog,
667    collection: &CollectionId,
668    range_id: RangeId,
669    source: &NodeIdentity,
670    expected_epoch: OwnershipEpoch,
671    expected_version: CatalogVersion,
672    target: &NodeIdentity,
673    evidence: CatchUpEvidence,
674    watermark: CommitWatermark,
675) -> Result<TransitionOutcome, MoveError> {
676    let request = TransitionRequest::new(
677        TransitionKind::Handoff,
678        collection.clone(),
679        range_id,
680        source.clone(),
681        expected_epoch,
682        expected_version,
683        target.clone(),
684        watermark,
685    )
686    .with_evidence(evidence)
687    // Demote the old owner to a replica of the range after cutover.
688    .with_replicas([source.clone()]);
689    run_transition(catalog, &request).map_err(MoveError::Transition)
690}
691
692/// Why a move-range step failed. Every variant that can be returned before the
693/// fenced handoff leaves the catalog untouched.
694#[derive(Debug, Clone, PartialEq, Eq)]
695pub enum MoveError {
696    /// No range with this `(collection, range_id)` exists in the catalog.
697    UnknownRange {
698        collection: CollectionId,
699        range_id: RangeId,
700    },
701    /// The move target is already the range's owner — a no-op move.
702    TargetIsOwner {
703        collection: CollectionId,
704        range_id: RangeId,
705        owner: NodeIdentity,
706    },
707    /// A move step was attempted from the wrong phase (e.g. cutting over before a
708    /// snapshot was installed).
709    WrongPhase {
710        expected: &'static str,
711        actual: MovePhase,
712    },
713    /// Cutover was attempted but the target's applied log does not yet cover the
714    /// live commit watermark — refused, the catalog untouched.
715    TargetBehindWatermark {
716        collection: CollectionId,
717        range_id: RangeId,
718        target: NodeIdentity,
719        watermark: CommitWatermark,
720        applied_term: u64,
721        applied_lsn: u64,
722    },
723    /// The scoped workflow was used without an authority token.
724    MissingPlacementAuthority {
725        collection: CollectionId,
726        range_id: RangeId,
727    },
728    /// The authority token does not cover this range's collection.
729    CollectionOutsidePlacementAuthority {
730        collection: CollectionId,
731        collection_group: CollectionGroupId,
732        authority: NodeIdentity,
733    },
734    /// A different Placement Authority attempted the scoped transition.
735    WrongPlacementAuthority {
736        collection_group: CollectionGroupId,
737        expected: NodeIdentity,
738        actual: NodeIdentity,
739    },
740    /// A catalog write (replica enlistment) was rejected.
741    Catalog(CatalogError),
742    /// The fenced handoff transition was rejected (a CAS or safety failure) or the
743    /// activation write failed.
744    Transition(TransitionError),
745}
746
747impl std::fmt::Display for MoveError {
748    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
749        match self {
750            Self::UnknownRange {
751                collection,
752                range_id,
753            } => write!(f, "no range {collection}/{range_id} to move"),
754            Self::TargetIsOwner {
755                collection,
756                range_id,
757                owner,
758            } => write!(
759                f,
760                "move target {owner} is already the owner of {collection}/{range_id}"
761            ),
762            Self::WrongPhase { expected, actual } => write!(
763                f,
764                "move-range step expected phase {expected} but the move is {}",
765                actual.label()
766            ),
767            Self::TargetBehindWatermark {
768                collection,
769                range_id,
770                target,
771                watermark,
772                applied_term,
773                applied_lsn,
774            } => write!(
775                f,
776                "cannot cut over {collection}/{range_id} to {target}: applied term {applied_term} lsn {applied_lsn} is behind the commit watermark term {} lsn {}",
777                watermark.term, watermark.lsn
778            ),
779            Self::MissingPlacementAuthority {
780                collection,
781                range_id,
782            } => write!(
783                f,
784                "move-range {collection}/{range_id} has no collection group placement authority"
785            ),
786            Self::CollectionOutsidePlacementAuthority {
787                collection,
788                collection_group,
789                authority,
790            } => write!(
791                f,
792                "placement authority {authority} for collection group {collection_group} does not cover collection {collection}"
793            ),
794            Self::WrongPlacementAuthority {
795                collection_group,
796                expected,
797                actual,
798            } => write!(
799                f,
800                "placement authority {actual} cannot transition collection group {collection_group}; expected {expected}"
801            ),
802            Self::Catalog(err) => write!(f, "{err}"),
803            Self::Transition(err) => write!(f, "{err}"),
804        }
805    }
806}
807
808impl std::error::Error for MoveError {}
809
810#[cfg(test)]
811mod tests {
812    use super::*;
813    use crate::cluster::ownership::{
814        PlacementMetadata, RangeBound, RangeBounds, RangeRole, RangeWriteReject, ShardKeyMode,
815    };
816    use crate::replication::cdc::ChangeOperation;
817
818    fn collection(name: &str) -> CollectionId {
819        CollectionId::new(name).unwrap()
820    }
821
822    fn ident(cn: &str) -> NodeIdentity {
823        NodeIdentity::from_certificate_subject(cn).unwrap()
824    }
825
826    /// A single full-keyspace ordered range owned by `owner` with `replicas`, so
827    /// concrete split keys land inside the range.
828    fn catalog_with(owner: &str, replicas: &[&str]) -> (ShardOwnershipCatalog, CollectionId) {
829        let orders = collection("orders");
830        let mut catalog = ShardOwnershipCatalog::new();
831        catalog
832            .apply_update(RangeOwnership::establish(
833                orders.clone(),
834                RangeId::new(1),
835                ShardKeyMode::Ordered,
836                RangeBounds::full(),
837                ident(owner),
838                replicas.iter().map(|r| ident(r)).collect::<Vec<_>>(),
839                PlacementMetadata::with_replication_factor(3),
840            ))
841            .unwrap();
842        (catalog, orders)
843    }
844
845    /// A range-indexed WAL record for `range_id` at `(term, lsn)` carrying
846    /// ownership `epoch` — the catch-up feed a move-range target replays.
847    fn record(range_id: u64, term: u64, lsn: u64, epoch: u64) -> ChangeRecord {
848        ChangeRecord {
849            term,
850            lsn,
851            timestamp: 1,
852            operation: ChangeOperation::Insert,
853            collection: "orders".to_string(),
854            entity_id: lsn,
855            entity_kind: "row".to_string(),
856            entity_bytes: Some(vec![1]),
857            metadata: None,
858            refresh_records: None,
859            range_id: Some(range_id),
860            ownership_epoch: Some(epoch),
861        }
862    }
863
864    // --- criterion 1: whole vs split classification ----------------------
865
866    #[test]
867    fn small_cool_range_moves_whole_large_or_hot_range_splits() {
868        let policy = SplitPolicy {
869            max_whole_move_bytes: 1_000,
870            hot_traffic_threshold: 500,
871        };
872        // Small and cool -> whole.
873        assert_eq!(
874            classify_move(RangeLoad::idle(900), &policy),
875            MoveKind::Whole
876        );
877        // Large on disk -> split.
878        assert_eq!(
879            classify_move(RangeLoad::idle(1_001), &policy),
880            MoveKind::Split
881        );
882        // Small but hot (traffic at threshold) -> split.
883        assert_eq!(
884            classify_move(
885                RangeLoad {
886                    bytes_used: 10,
887                    read_ops: 300,
888                    write_ops: 200,
889                },
890                &policy
891            ),
892            MoveKind::Split
893        );
894        // Small and just under the hot threshold -> whole.
895        assert_eq!(
896            classify_move(
897                RangeLoad {
898                    bytes_used: 10,
899                    read_ops: 250,
900                    write_ops: 249,
901                },
902                &policy
903            ),
904            MoveKind::Whole
905        );
906    }
907
908    // --- range split arithmetic ------------------------------------------
909
910    #[test]
911    fn split_tiles_the_keyspace_with_no_gap_or_overlap() {
912        let (catalog, orders) = catalog_with("CN=node-a", &[]);
913        let range = catalog.range(&orders, RangeId::new(1)).unwrap();
914        let split = split_range(
915            range,
916            b"m",
917            SplitSide::Upper,
918            RangeId::new(2),
919            ident("CN=node-b"),
920        )
921        .expect("split ok");
922
923        // Retained keeps id 1, narrowed to [Min, "m"); moved is id 2 over ["m", Max).
924        assert_eq!(split.retained().range_id(), RangeId::new(1));
925        assert_eq!(split.retained().bounds().lower(), &RangeBound::Min);
926        assert_eq!(
927            split.retained().bounds().upper(),
928            &RangeBound::key(b"m".to_vec())
929        );
930        assert_eq!(split.moved().range_id(), RangeId::new(2));
931        assert_eq!(
932            split.moved().bounds().lower(),
933            &RangeBound::key(b"m".to_vec())
934        );
935        assert_eq!(split.moved().bounds().upper(), &RangeBound::Max);
936
937        // Both children stay with the original owner; the target is a replica of
938        // the moved child only.
939        assert_eq!(split.retained().owner(), &ident("CN=node-a"));
940        assert_eq!(split.moved().owner(), &ident("CN=node-a"));
941        assert_eq!(
942            split.moved().role_of(&ident("CN=node-b")),
943            RangeRole::Replica
944        );
945
946        // The retained child's epoch is unchanged (no authority moved); only the
947        // version advanced.
948        assert_eq!(split.retained().epoch(), range.epoch());
949        assert!(split.retained().version() > range.version());
950    }
951
952    #[test]
953    fn split_rejects_an_out_of_range_key_and_an_id_collision() {
954        let (catalog, orders) = catalog_with("CN=node-a", &[]);
955        let range = catalog.range(&orders, RangeId::new(1)).unwrap();
956        // Reusing the original id is a collision.
957        assert!(matches!(
958            split_range(
959                range,
960                b"m",
961                SplitSide::Upper,
962                RangeId::new(1),
963                ident("CN=node-b")
964            ),
965            Err(SplitError::MovedIdCollision { .. })
966        ));
967
968        // A bounded range cannot be split at or outside its bounds.
969        let bounded = RangeOwnership::establish(
970            orders.clone(),
971            RangeId::new(5),
972            ShardKeyMode::Ordered,
973            RangeBounds::new(
974                RangeBound::key(b"d".to_vec()),
975                RangeBound::key(b"h".to_vec()),
976            )
977            .unwrap(),
978            ident("CN=node-a"),
979            Vec::<NodeIdentity>::new(),
980            PlacementMetadata::with_replication_factor(1),
981        );
982        assert!(matches!(
983            split_range(
984                &bounded,
985                b"z",
986                SplitSide::Upper,
987                RangeId::new(6),
988                ident("CN=node-b")
989            ),
990            Err(SplitError::Bounds(_))
991        ));
992    }
993
994    #[test]
995    fn applying_a_split_installs_two_non_overlapping_ranges() {
996        let (mut catalog, orders) = catalog_with("CN=node-a", &[]);
997        let range = catalog.range(&orders, RangeId::new(1)).unwrap().clone();
998        let split = split_range(
999            &range,
1000            b"m",
1001            SplitSide::Upper,
1002            RangeId::new(2),
1003            ident("CN=node-b"),
1004        )
1005        .unwrap();
1006        split.apply(&mut catalog).expect("split applies cleanly");
1007
1008        assert_eq!(catalog.range_count(), 2);
1009        // Routing now resolves either side to exactly one range.
1010        assert_eq!(
1011            catalog.route(&orders, b"a").unwrap().range_id(),
1012            RangeId::new(1)
1013        );
1014        assert_eq!(
1015            catalog.route(&orders, b"z").unwrap().range_id(),
1016            RangeId::new(2)
1017        );
1018    }
1019
1020    // --- criterion 2 + 3 + 4: snapshot, catch-up, fenced cutover ---------
1021
1022    #[test]
1023    fn whole_range_move_copies_snapshot_catches_up_then_cuts_over() {
1024        let (mut catalog, orders) = catalog_with("CN=node-a", &[]);
1025        let mut mv = MoveRange::begin(
1026            &mut catalog,
1027            orders.clone(),
1028            RangeId::new(1),
1029            ident("CN=node-b"),
1030        )
1031        .expect("begin ok");
1032        assert_eq!(mv.phase(), MovePhase::CopyingSnapshot);
1033
1034        // Criterion 3: while copying, the catalog still names node-a, which keeps
1035        // serving public writes.
1036        let serving_epoch = catalog.range(&orders, RangeId::new(1)).unwrap().epoch();
1037        assert!(catalog
1038            .admit_public_write(&ident("CN=node-a"), &orders, b"k", serving_epoch)
1039            .is_ok());
1040        // Criterion 4: the target is only a replica, so its writes are rejected.
1041        let err = catalog
1042            .admit_public_write(&ident("CN=node-b"), &orders, b"k", serving_epoch)
1043            .unwrap_err();
1044        assert!(matches!(err, RangeWriteReject::NotOwner { .. }));
1045
1046        // Criterion 2: snapshot taken at a consistent point, then range-indexed
1047        // WAL catch-up closes the gap to the live watermark.
1048        mv.complete_snapshot(CommitWatermark::new(1, 100)).unwrap();
1049        assert_eq!(mv.phase(), MovePhase::CatchingUp);
1050        // The old owner kept writing: live watermark is now term 1 lsn 130.
1051        let plan = mv
1052            .record_catch_up(&[
1053                record(1, 1, 110, 1),
1054                record(1, 1, 120, 1),
1055                record(1, 1, 130, 1),
1056            ])
1057            .unwrap();
1058        assert_eq!(plan.apply_count(), 3);
1059        assert!(mv.has_caught_up(CommitWatermark::new(1, 130)));
1060
1061        let outcome = mv
1062            .cut_over(&mut catalog, CommitWatermark::new(1, 130))
1063            .unwrap();
1064        assert_eq!(mv.phase(), MovePhase::Completed);
1065        assert_eq!(outcome.kind, TransitionKind::Handoff);
1066        assert!(outcome.fenced_old_owner());
1067
1068        // Criterion 3 (after cutover): node-a is fenced — demoted to a replica at
1069        // the stale epoch, its public write is rejected.
1070        let err = catalog
1071            .admit_public_write(&ident("CN=node-a"), &orders, b"k", serving_epoch)
1072            .unwrap_err();
1073        assert!(matches!(
1074            err,
1075            RangeWriteReject::NotOwner { .. } | RangeWriteReject::StaleEpoch { .. }
1076        ));
1077        // Criterion 4 (after cutover): the target is now the owner and is admitted
1078        // at the new epoch.
1079        let new_epoch = catalog.range(&orders, RangeId::new(1)).unwrap().epoch();
1080        assert!(catalog
1081            .admit_public_write(&ident("CN=node-b"), &orders, b"k", new_epoch)
1082            .is_ok());
1083    }
1084
1085    // --- criterion 4: cutover refused before catch-up --------------------
1086
1087    #[test]
1088    fn cutover_before_catch_up_is_refused_and_leaves_catalog_untouched() {
1089        let (mut catalog, orders) = catalog_with("CN=node-a", &[]);
1090        let mut mv = MoveRange::begin(
1091            &mut catalog,
1092            orders.clone(),
1093            RangeId::new(1),
1094            ident("CN=node-b"),
1095        )
1096        .unwrap();
1097        mv.complete_snapshot(CommitWatermark::new(1, 100)).unwrap();
1098        // Only caught up to lsn 110, but the live watermark is lsn 200.
1099        mv.record_catch_up(&[record(1, 1, 110, 1)]).unwrap();
1100        assert!(!mv.has_caught_up(CommitWatermark::new(1, 200)));
1101
1102        let err = mv
1103            .cut_over(&mut catalog, CommitWatermark::new(1, 200))
1104            .unwrap_err();
1105        assert!(matches!(err, MoveError::TargetBehindWatermark { .. }));
1106        // The move did not advance and node-a is still the owner.
1107        assert_eq!(mv.phase(), MovePhase::CatchingUp);
1108        assert_eq!(
1109            catalog.range(&orders, RangeId::new(1)).unwrap().owner(),
1110            &ident("CN=node-a")
1111        );
1112    }
1113
1114    #[test]
1115    fn authorized_cutover_rejects_the_wrong_collection_group_authority() {
1116        let (mut catalog, orders) = catalog_with("CN=node-a", &[]);
1117        let authority = CollectionGroupPlacementAuthority::new(
1118            CollectionGroupId::new("commerce").unwrap(),
1119            ident("CN=pa-commerce"),
1120            [orders.clone()],
1121        )
1122        .unwrap();
1123        let mut mv = MoveRange::begin_authorized(
1124            &mut catalog,
1125            orders.clone(),
1126            RangeId::new(1),
1127            ident("CN=node-b"),
1128            authority,
1129            &ident("CN=pa-commerce"),
1130        )
1131        .unwrap();
1132        mv.complete_snapshot(CommitWatermark::new(1, 10)).unwrap();
1133        mv.record_catch_up(&[record(1, 1, 20, 1)]).unwrap();
1134
1135        let err = mv
1136            .cut_over_authorized(
1137                &mut catalog,
1138                CommitWatermark::new(1, 20),
1139                &ident("CN=pa-analytics"),
1140            )
1141            .unwrap_err();
1142
1143        assert!(matches!(err, MoveError::WrongPlacementAuthority { .. }));
1144        assert_eq!(
1145            catalog.range(&orders, RangeId::new(1)).unwrap().owner(),
1146            &ident("CN=node-a")
1147        );
1148
1149        mv.cut_over_authorized(
1150            &mut catalog,
1151            CommitWatermark::new(1, 20),
1152            &ident("CN=pa-commerce"),
1153        )
1154        .unwrap();
1155        let moved = catalog.range(&orders, RangeId::new(1)).unwrap();
1156        assert_eq!(moved.owner(), &ident("CN=node-b"));
1157        assert!(moved.epoch().value() > OwnershipEpoch::initial().value());
1158    }
1159
1160    // --- split-and-move end to end ---------------------------------------
1161
1162    #[test]
1163    fn split_and_move_relocates_only_the_subrange() {
1164        let (mut catalog, orders) = catalog_with("CN=node-a", &[]);
1165        // Split the hot/large range and move only the upper subrange to node-b.
1166        let range = catalog.range(&orders, RangeId::new(1)).unwrap().clone();
1167        let split = split_range(
1168            &range,
1169            b"m",
1170            SplitSide::Upper,
1171            RangeId::new(2),
1172            ident("CN=node-b"),
1173        )
1174        .unwrap();
1175        split.apply(&mut catalog).unwrap();
1176
1177        // Move the carved-off subrange (id 2) to node-b.
1178        let mut mv = MoveRange::begin(
1179            &mut catalog,
1180            orders.clone(),
1181            RangeId::new(2),
1182            ident("CN=node-b"),
1183        )
1184        .unwrap();
1185        mv.complete_snapshot(CommitWatermark::new(1, 10)).unwrap();
1186        mv.record_catch_up(&[record(2, 1, 20, 1)]).unwrap();
1187        mv.cut_over(&mut catalog, CommitWatermark::new(1, 20))
1188            .unwrap();
1189
1190        // The moved subrange is now owned by node-b; the retained subrange stays
1191        // with node-a, untouched by the move.
1192        assert_eq!(
1193            catalog.range(&orders, RangeId::new(2)).unwrap().owner(),
1194            &ident("CN=node-b")
1195        );
1196        assert_eq!(
1197            catalog.range(&orders, RangeId::new(1)).unwrap().owner(),
1198            &ident("CN=node-a")
1199        );
1200        // node-a still owns the lower keys; node-b owns the upper keys.
1201        assert_eq!(
1202            catalog.route(&orders, b"a").unwrap().owner(),
1203            &ident("CN=node-a")
1204        );
1205        assert_eq!(
1206            catalog.route(&orders, b"z").unwrap().owner(),
1207            &ident("CN=node-b")
1208        );
1209    }
1210
1211    // --- criterion 2: catch-up only consumes this range's records --------
1212
1213    #[test]
1214    fn catch_up_ignores_other_ranges_and_fences_stale_epoch_records() {
1215        let (mut catalog, orders) = catalog_with("CN=node-a", &[]);
1216        let mut mv = MoveRange::begin(
1217            &mut catalog,
1218            orders.clone(),
1219            RangeId::new(1),
1220            ident("CN=node-b"),
1221        )
1222        .unwrap();
1223        mv.complete_snapshot(CommitWatermark::new(1, 100)).unwrap();
1224
1225        // A shared WAL slice: a record for another range, a stale-epoch record
1226        // from a deposed owner, and two genuine records for this range.
1227        let plan = mv
1228            .record_catch_up(&[
1229                record(99, 1, 105, 1), // other range — skipped
1230                record(1, 1, 110, 0),  // stale ownership epoch (0 < 1) — fenced
1231                record(1, 1, 120, 1),  // applied
1232                record(1, 1, 130, 1),  // applied
1233            ])
1234            .unwrap();
1235        assert_eq!(plan.apply_count(), 2);
1236        assert_eq!(plan.rejected.len(), 1);
1237        // Only this range's genuine records advanced the position.
1238        assert_eq!(mv.position().unwrap().applied_lsn, 130);
1239        assert!(mv.has_caught_up(CommitWatermark::new(1, 130)));
1240    }
1241
1242    // --- criterion 5: interrupted-move recovery safety -------------------
1243
1244    #[test]
1245    fn interrupted_move_promotes_a_caught_up_target() {
1246        let (mut catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
1247        // The supervisor died mid-move; node-b's persisted position covers the
1248        // live watermark (term 1 lsn 50).
1249        let position = RangeStreamPosition::new(RangeId::new(1).value(), 50, 1, 1);
1250        let recovery = recover_interrupted_move(
1251            &mut catalog,
1252            &orders,
1253            RangeId::new(1),
1254            &ident("CN=node-b"),
1255            position,
1256            CommitWatermark::new(1, 50),
1257        )
1258        .unwrap();
1259        assert!(recovery.promoted());
1260        assert_eq!(
1261            catalog.range(&orders, RangeId::new(1)).unwrap().owner(),
1262            &ident("CN=node-b")
1263        );
1264    }
1265
1266    #[test]
1267    fn interrupted_move_abandons_a_target_behind_the_watermark() {
1268        let (mut catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
1269        // node-b only applied through lsn 40 but the watermark is lsn 50 — it must
1270        // not be promoted.
1271        let position = RangeStreamPosition::new(RangeId::new(1).value(), 40, 1, 1);
1272        let recovery = recover_interrupted_move(
1273            &mut catalog,
1274            &orders,
1275            RangeId::new(1),
1276            &ident("CN=node-b"),
1277            position,
1278            CommitWatermark::new(1, 50),
1279        )
1280        .unwrap();
1281        assert!(!recovery.promoted());
1282        assert!(matches!(
1283            recovery,
1284            MoveRecovery::AbortedSourceRetained {
1285                applied_lsn: 40,
1286                ..
1287            }
1288        ));
1289        // The source kept authority; the catalog is unchanged.
1290        assert_eq!(
1291            catalog.range(&orders, RangeId::new(1)).unwrap().owner(),
1292            &ident("CN=node-a")
1293        );
1294        assert_eq!(
1295            catalog.range(&orders, RangeId::new(1)).unwrap().epoch(),
1296            OwnershipEpoch::initial()
1297        );
1298    }
1299
1300    #[test]
1301    fn move_to_the_incumbent_owner_is_rejected() {
1302        let (mut catalog, orders) = catalog_with("CN=node-a", &[]);
1303        let err = MoveRange::begin(&mut catalog, orders, RangeId::new(1), ident("CN=node-a"))
1304            .unwrap_err();
1305        assert!(matches!(err, MoveError::TargetIsOwner { .. }));
1306    }
1307
1308    #[test]
1309    fn begin_enlists_the_target_as_a_replica() {
1310        let (mut catalog, orders) = catalog_with("CN=node-a", &[]);
1311        let mv = MoveRange::begin(
1312            &mut catalog,
1313            orders.clone(),
1314            RangeId::new(1),
1315            ident("CN=node-b"),
1316        )
1317        .unwrap();
1318        assert_eq!(mv.source(), &ident("CN=node-a"));
1319        // node-b is now a replica of the range — a valid handoff candidate.
1320        assert_eq!(
1321            catalog
1322                .range(&orders, RangeId::new(1))
1323                .unwrap()
1324                .role_of(&ident("CN=node-b")),
1325            RangeRole::Replica
1326        );
1327    }
1328}