1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub struct SplitPolicy {
86 pub max_whole_move_bytes: u64,
89 pub hot_traffic_threshold: u64,
93}
94
95impl Default for SplitPolicy {
96 fn default() -> Self {
97 Self {
100 max_whole_move_bytes: 256 * 1024 * 1024,
101 hot_traffic_threshold: 10_000,
102 }
103 }
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum MoveKind {
110 Whole,
112 Split,
114}
115
116pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum SplitSide {
132 Lower,
134 Upper,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct RangeSplit {
147 retained: RangeOwnership,
148 moved: RangeOwnership,
149}
150
151impl RangeSplit {
152 pub fn retained(&self) -> &RangeOwnership {
155 &self.retained
156 }
157
158 pub fn moved(&self) -> &RangeOwnership {
162 &self.moved
163 }
164
165 pub fn apply(&self, catalog: &mut ShardOwnershipCatalog) -> Result<(), CatalogError> {
169 catalog.apply_update(self.retained.clone())?;
172 catalog.apply_update(self.moved.clone())?;
173 Ok(())
174 }
175}
176
177pub 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 SplitSide::Lower => (upper_bounds, lower_bounds),
204 SplitSide::Upper => (lower_bounds, upper_bounds),
206 };
207
208 let retained = range.with_bounds(retained_bounds);
210
211 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#[derive(Debug, Clone, PartialEq, Eq)]
233pub enum SplitError {
234 Bounds(RangeBoundsError),
237 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub enum MovePhase {
258 CopyingSnapshot,
261 CatchingUp,
265 Completed,
268 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#[derive(Debug, Clone, PartialEq, Eq)]
293pub struct MoveRange {
294 collection: CollectionId,
295 range_id: RangeId,
296 source: NodeIdentity,
298 target: NodeIdentity,
300 expected_epoch: OwnershipEpoch,
302 expected_version: CatalogVersion,
304 phase: MovePhase,
305 snapshot_watermark: Option<CommitWatermark>,
307 position: Option<RangeStreamPosition>,
310 placement_authority: Option<CollectionGroupPlacementAuthority>,
313}
314
315impl MoveRange {
316 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 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 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 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 pub fn snapshot_watermark(&self) -> Option<CommitWatermark> {
406 self.snapshot_watermark
407 }
408
409 pub fn position(&self) -> Option<RangeStreamPosition> {
412 self.position
413 }
414
415 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 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 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 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 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 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 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
579pub 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 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#[derive(Debug, Clone, PartialEq, Eq)]
639pub enum MoveRecovery {
640 Promoted(TransitionOutcome),
643 AbortedSourceRetained {
647 applied_term: u64,
648 applied_lsn: u64,
649 watermark: CommitWatermark,
650 },
651}
652
653impl MoveRecovery {
654 pub fn promoted(&self) -> bool {
656 matches!(self, MoveRecovery::Promoted(_))
657 }
658}
659
660#[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 .with_replicas([source.clone()]);
689 run_transition(catalog, &request).map_err(MoveError::Transition)
690}
691
692#[derive(Debug, Clone, PartialEq, Eq)]
695pub enum MoveError {
696 UnknownRange {
698 collection: CollectionId,
699 range_id: RangeId,
700 },
701 TargetIsOwner {
703 collection: CollectionId,
704 range_id: RangeId,
705 owner: NodeIdentity,
706 },
707 WrongPhase {
710 expected: &'static str,
711 actual: MovePhase,
712 },
713 TargetBehindWatermark {
716 collection: CollectionId,
717 range_id: RangeId,
718 target: NodeIdentity,
719 watermark: CommitWatermark,
720 applied_term: u64,
721 applied_lsn: u64,
722 },
723 MissingPlacementAuthority {
725 collection: CollectionId,
726 range_id: RangeId,
727 },
728 CollectionOutsidePlacementAuthority {
730 collection: CollectionId,
731 collection_group: CollectionGroupId,
732 authority: NodeIdentity,
733 },
734 WrongPlacementAuthority {
736 collection_group: CollectionGroupId,
737 expected: NodeIdentity,
738 actual: NodeIdentity,
739 },
740 Catalog(CatalogError),
742 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 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 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 #[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 assert_eq!(
874 classify_move(RangeLoad::idle(900), &policy),
875 MoveKind::Whole
876 );
877 assert_eq!(
879 classify_move(RangeLoad::idle(1_001), &policy),
880 MoveKind::Split
881 );
882 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 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 #[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 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 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 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 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 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 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 #[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 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 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 mv.complete_snapshot(CommitWatermark::new(1, 100)).unwrap();
1049 assert_eq!(mv.phase(), MovePhase::CatchingUp);
1050 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 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 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 #[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 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 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 #[test]
1163 fn split_and_move_relocates_only_the_subrange() {
1164 let (mut catalog, orders) = catalog_with("CN=node-a", &[]);
1165 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 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 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 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 #[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 let plan = mv
1228 .record_catch_up(&[
1229 record(99, 1, 105, 1), record(1, 1, 110, 0), record(1, 1, 120, 1), record(1, 1, 130, 1), ])
1234 .unwrap();
1235 assert_eq!(plan.apply_count(), 2);
1236 assert_eq!(plan.rejected.len(), 1);
1237 assert_eq!(mv.position().unwrap().applied_lsn, 130);
1239 assert!(mv.has_caught_up(CommitWatermark::new(1, 130)));
1240 }
1241
1242 #[test]
1245 fn interrupted_move_promotes_a_caught_up_target() {
1246 let (mut catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
1247 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 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 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 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}