1use super::identity::NodeIdentity;
60use super::ownership::{
61 CatalogError, CatalogVersion, CollectionId, OwnershipEpoch, RangeId, RangeOwnership, RangeRole,
62 ShardOwnershipCatalog,
63};
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct CommitWatermark {
71 pub term: u64,
74 pub lsn: u64,
76}
77
78impl CommitWatermark {
79 pub fn new(term: u64, lsn: u64) -> Self {
80 Self { term, lsn }
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct CatchUpEvidence {
91 pub candidate: NodeIdentity,
94 pub applied_term: u64,
96 pub applied_lsn: u64,
98}
99
100impl CatchUpEvidence {
101 pub fn new(candidate: NodeIdentity, applied_term: u64, applied_lsn: u64) -> Self {
102 Self {
103 candidate,
104 applied_term,
105 applied_lsn,
106 }
107 }
108
109 pub fn covers(&self, watermark: CommitWatermark) -> bool {
113 self.applied_term > watermark.term
114 || (self.applied_term == watermark.term && self.applied_lsn >= watermark.lsn)
115 }
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub struct ArchiveRecoveryEvidence {
124 pub restore_validated: bool,
125 pub checksum_validated: bool,
126 pub watermark_validated: bool,
127}
128
129impl ArchiveRecoveryEvidence {
130 pub fn new(
131 restore_validated: bool,
132 checksum_validated: bool,
133 watermark_validated: bool,
134 ) -> Self {
135 Self {
136 restore_validated,
137 checksum_validated,
138 watermark_validated,
139 }
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum ArchiveRecoveryRejection {
147 NotArchiveReplica {
148 collection: CollectionId,
149 range_id: RangeId,
150 candidate: NodeIdentity,
151 role: RangeRole,
152 },
153 MissingRestoreValidation {
154 collection: CollectionId,
155 range_id: RangeId,
156 candidate: NodeIdentity,
157 },
158 MissingChecksumValidation {
159 collection: CollectionId,
160 range_id: RangeId,
161 candidate: NodeIdentity,
162 },
163 MissingWatermarkValidation {
164 collection: CollectionId,
165 range_id: RangeId,
166 candidate: NodeIdentity,
167 },
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub enum TransitionKind {
174 Promote,
177 Handoff,
180}
181
182impl TransitionKind {
183 fn label(self) -> &'static str {
184 match self {
185 TransitionKind::Promote => "promote",
186 TransitionKind::Handoff => "handoff",
187 }
188 }
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct TransitionRequest {
199 kind: TransitionKind,
200 collection: CollectionId,
201 range_id: RangeId,
202 expected_owner: NodeIdentity,
203 expected_epoch: OwnershipEpoch,
204 expected_version: CatalogVersion,
205 target: NodeIdentity,
206 watermark: CommitWatermark,
207 evidence: Option<CatchUpEvidence>,
208 new_replicas: Vec<NodeIdentity>,
209}
210
211impl TransitionRequest {
212 #[allow(clippy::too_many_arguments)]
218 pub fn new(
219 kind: TransitionKind,
220 collection: CollectionId,
221 range_id: RangeId,
222 expected_owner: NodeIdentity,
223 expected_epoch: OwnershipEpoch,
224 expected_version: CatalogVersion,
225 target: NodeIdentity,
226 watermark: CommitWatermark,
227 ) -> Self {
228 Self {
229 kind,
230 collection,
231 range_id,
232 expected_owner,
233 expected_epoch,
234 expected_version,
235 target,
236 watermark,
237 evidence: None,
238 new_replicas: Vec::new(),
239 }
240 }
241
242 pub fn with_evidence(mut self, evidence: CatchUpEvidence) -> Self {
244 self.evidence = Some(evidence);
245 self
246 }
247
248 pub fn with_replicas(mut self, replicas: impl IntoIterator<Item = NodeIdentity>) -> Self {
251 self.new_replicas = replicas.into_iter().collect();
252 self
253 }
254
255 pub fn kind(&self) -> TransitionKind {
256 self.kind
257 }
258
259 pub fn collection(&self) -> &CollectionId {
260 &self.collection
261 }
262
263 pub fn range_id(&self) -> RangeId {
264 self.range_id
265 }
266
267 pub fn target(&self) -> &NodeIdentity {
268 &self.target
269 }
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274pub enum InvalidCandidateReason {
275 NotAReplica,
279 ArchiveReplicaRequiresRestoreValidation,
283 AlreadyOwner,
286}
287
288impl InvalidCandidateReason {
289 fn label(self) -> &'static str {
290 match self {
291 InvalidCandidateReason::NotAReplica => "candidate is not a replica of the range",
292 InvalidCandidateReason::ArchiveReplicaRequiresRestoreValidation => {
293 "archive replica requires restore, checksum, and watermark validation"
294 }
295 InvalidCandidateReason::AlreadyOwner => "candidate is already the current owner",
296 }
297 }
298}
299
300#[derive(Debug, Clone, PartialEq, Eq)]
303pub enum TransitionRejection {
304 UnknownRange {
306 collection: CollectionId,
307 range_id: RangeId,
308 },
309 OwnerMismatch {
312 collection: CollectionId,
313 range_id: RangeId,
314 expected: NodeIdentity,
315 current: NodeIdentity,
316 },
317 StaleEpoch {
320 collection: CollectionId,
321 range_id: RangeId,
322 expected: OwnershipEpoch,
323 current: OwnershipEpoch,
324 },
325 StaleCatalogVersion {
328 collection: CollectionId,
329 range_id: RangeId,
330 expected: CatalogVersion,
331 current: CatalogVersion,
332 },
333 InvalidCandidate {
335 collection: CollectionId,
336 range_id: RangeId,
337 candidate: NodeIdentity,
338 reason: InvalidCandidateReason,
339 },
340 MissingSafetyEvidence {
343 collection: CollectionId,
344 range_id: RangeId,
345 candidate: NodeIdentity,
346 },
347 EvidenceForWrongCandidate {
350 collection: CollectionId,
351 range_id: RangeId,
352 target: NodeIdentity,
353 evidence_for: NodeIdentity,
354 },
355 SafetyCheckFailed {
358 collection: CollectionId,
359 range_id: RangeId,
360 candidate: NodeIdentity,
361 watermark: CommitWatermark,
362 applied_term: u64,
363 applied_lsn: u64,
364 },
365}
366
367impl std::fmt::Display for TransitionRejection {
368 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
369 match self {
370 Self::UnknownRange {
371 collection,
372 range_id,
373 } => write!(f, "no range {collection}/{range_id} in the catalog"),
374 Self::OwnerMismatch {
375 collection,
376 range_id,
377 expected,
378 current,
379 } => write!(
380 f,
381 "ownership transition for {collection}/{range_id} expected current owner {expected}, but catalog owner is {current}"
382 ),
383 Self::StaleEpoch {
384 collection,
385 range_id,
386 expected,
387 current,
388 } => write!(
389 f,
390 "ownership transition for {collection}/{range_id} expected epoch {expected}, but catalog epoch is {current}"
391 ),
392 Self::StaleCatalogVersion {
393 collection,
394 range_id,
395 expected,
396 current,
397 } => write!(
398 f,
399 "ownership transition for {collection}/{range_id} expected catalog version {expected}, but catalog version is {current}"
400 ),
401 Self::InvalidCandidate {
402 collection,
403 range_id,
404 candidate,
405 reason,
406 } => write!(
407 f,
408 "invalid ownership transition candidate {candidate} for {collection}/{range_id}: {}",
409 reason.label()
410 ),
411 Self::MissingSafetyEvidence {
412 collection,
413 range_id,
414 candidate,
415 } => write!(
416 f,
417 "ownership transition for {collection}/{range_id} carries no safety evidence for candidate {candidate}"
418 ),
419 Self::EvidenceForWrongCandidate {
420 collection,
421 range_id,
422 target,
423 evidence_for,
424 } => write!(
425 f,
426 "ownership transition for {collection}/{range_id} targets {target} but its safety evidence describes {evidence_for}"
427 ),
428 Self::SafetyCheckFailed {
429 collection,
430 range_id,
431 candidate,
432 watermark,
433 applied_term,
434 applied_lsn,
435 } => write!(
436 f,
437 "candidate {candidate} for {collection}/{range_id} is behind the commit watermark (term {}, lsn {}): applied term {applied_term}, lsn {applied_lsn}",
438 watermark.term, watermark.lsn
439 ),
440 }
441 }
442}
443
444impl std::error::Error for TransitionRejection {}
445
446#[derive(Debug, Clone, PartialEq, Eq)]
451pub struct PreparedTransition {
452 kind: TransitionKind,
453 collection: CollectionId,
454 range_id: RangeId,
455 previous_owner: NodeIdentity,
456 new_owner: NodeIdentity,
457 previous_epoch: OwnershipEpoch,
458 previous_version: CatalogVersion,
459 watermark: CommitWatermark,
460 next: RangeOwnership,
461}
462
463impl PreparedTransition {
464 pub fn next_entry(&self) -> &RangeOwnership {
467 &self.next
468 }
469
470 pub fn new_epoch(&self) -> OwnershipEpoch {
474 self.next.epoch()
475 }
476
477 pub fn activate(
483 self,
484 catalog: &mut ShardOwnershipCatalog,
485 ) -> Result<TransitionOutcome, CatalogError> {
486 let new_epoch = self.next.epoch();
487 let new_version = self.next.version();
488 catalog.apply_update(self.next)?;
489 Ok(TransitionOutcome {
490 kind: self.kind,
491 collection: self.collection,
492 range_id: self.range_id,
493 previous_owner: self.previous_owner,
494 new_owner: self.new_owner,
495 previous_epoch: self.previous_epoch,
496 new_epoch,
497 previous_version: self.previous_version,
498 new_version,
499 watermark: self.watermark,
500 })
501 }
502}
503
504#[derive(Debug, Clone, PartialEq, Eq)]
509pub struct TransitionOutcome {
510 pub kind: TransitionKind,
511 pub collection: CollectionId,
512 pub range_id: RangeId,
513 pub previous_owner: NodeIdentity,
514 pub new_owner: NodeIdentity,
515 pub previous_epoch: OwnershipEpoch,
516 pub new_epoch: OwnershipEpoch,
517 pub previous_version: CatalogVersion,
518 pub new_version: CatalogVersion,
519 pub watermark: CommitWatermark,
520}
521
522impl TransitionOutcome {
523 pub fn fenced_old_owner(&self) -> bool {
527 self.new_epoch > self.previous_epoch
528 }
529}
530
531impl std::fmt::Display for TransitionOutcome {
532 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
533 write!(
534 f,
535 "{} {}/{}: {} (epoch {}, version {}) -> {} (epoch {}, version {}) over watermark term {} lsn {}",
536 self.kind.label(),
537 self.collection,
538 self.range_id,
539 self.previous_owner,
540 self.previous_epoch,
541 self.previous_version,
542 self.new_owner,
543 self.new_epoch,
544 self.new_version,
545 self.watermark.term,
546 self.watermark.lsn,
547 )
548 }
549}
550
551pub fn prepare(
563 catalog: &ShardOwnershipCatalog,
564 request: &TransitionRequest,
565) -> Result<PreparedTransition, TransitionRejection> {
566 let current = catalog.range(&request.collection, request.range_id).ok_or(
567 TransitionRejection::UnknownRange {
568 collection: request.collection.clone(),
569 range_id: request.range_id,
570 },
571 )?;
572
573 if *current.owner() != request.expected_owner {
576 return Err(TransitionRejection::OwnerMismatch {
577 collection: request.collection.clone(),
578 range_id: request.range_id,
579 expected: request.expected_owner.clone(),
580 current: current.owner().clone(),
581 });
582 }
583 if current.epoch() != request.expected_epoch {
584 return Err(TransitionRejection::StaleEpoch {
585 collection: request.collection.clone(),
586 range_id: request.range_id,
587 expected: request.expected_epoch,
588 current: current.epoch(),
589 });
590 }
591 if current.version() != request.expected_version {
592 return Err(TransitionRejection::StaleCatalogVersion {
593 collection: request.collection.clone(),
594 range_id: request.range_id,
595 expected: request.expected_version,
596 current: current.version(),
597 });
598 }
599
600 match current.role_of(&request.target) {
604 RangeRole::Owner => {
605 return Err(TransitionRejection::InvalidCandidate {
606 collection: request.collection.clone(),
607 range_id: request.range_id,
608 candidate: request.target.clone(),
609 reason: InvalidCandidateReason::AlreadyOwner,
610 });
611 }
612 RangeRole::Replica => {}
613 RangeRole::ArchiveReplica => {
614 return Err(TransitionRejection::InvalidCandidate {
615 collection: request.collection.clone(),
616 range_id: request.range_id,
617 candidate: request.target.clone(),
618 reason: InvalidCandidateReason::ArchiveReplicaRequiresRestoreValidation,
619 });
620 }
621 RangeRole::NoCopy => {
622 return Err(TransitionRejection::InvalidCandidate {
623 collection: request.collection.clone(),
624 range_id: request.range_id,
625 candidate: request.target.clone(),
626 reason: InvalidCandidateReason::NotAReplica,
627 });
628 }
629 }
630
631 let evidence =
634 request
635 .evidence
636 .as_ref()
637 .ok_or_else(|| TransitionRejection::MissingSafetyEvidence {
638 collection: request.collection.clone(),
639 range_id: request.range_id,
640 candidate: request.target.clone(),
641 })?;
642 if evidence.candidate != request.target {
643 return Err(TransitionRejection::EvidenceForWrongCandidate {
644 collection: request.collection.clone(),
645 range_id: request.range_id,
646 target: request.target.clone(),
647 evidence_for: evidence.candidate.clone(),
648 });
649 }
650 if !evidence.covers(request.watermark) {
651 return Err(TransitionRejection::SafetyCheckFailed {
652 collection: request.collection.clone(),
653 range_id: request.range_id,
654 candidate: request.target.clone(),
655 watermark: request.watermark,
656 applied_term: evidence.applied_term,
657 applied_lsn: evidence.applied_lsn,
658 });
659 }
660
661 let next = current.transfer_to(request.target.clone(), request.new_replicas.clone());
663 Ok(PreparedTransition {
664 kind: request.kind,
665 collection: request.collection.clone(),
666 range_id: request.range_id,
667 previous_owner: current.owner().clone(),
668 new_owner: request.target.clone(),
669 previous_epoch: current.epoch(),
670 previous_version: current.version(),
671 watermark: request.watermark,
672 next,
673 })
674}
675
676pub fn run_transition(
681 catalog: &mut ShardOwnershipCatalog,
682 request: &TransitionRequest,
683) -> Result<TransitionOutcome, TransitionError> {
684 let prepared = prepare(catalog, request)?;
685 prepared.activate(catalog).map_err(TransitionError::Catalog)
686}
687
688pub fn validate_archive_recovery_source(
692 catalog: &ShardOwnershipCatalog,
693 collection: &CollectionId,
694 range_id: RangeId,
695 candidate: &NodeIdentity,
696 evidence: ArchiveRecoveryEvidence,
697) -> Result<(), ArchiveRecoveryRejection> {
698 let role = catalog
699 .role_at(candidate, collection, range_id)
700 .unwrap_or(RangeRole::NoCopy);
701 if role != RangeRole::ArchiveReplica {
702 return Err(ArchiveRecoveryRejection::NotArchiveReplica {
703 collection: collection.clone(),
704 range_id,
705 candidate: candidate.clone(),
706 role,
707 });
708 }
709 if !evidence.restore_validated {
710 return Err(ArchiveRecoveryRejection::MissingRestoreValidation {
711 collection: collection.clone(),
712 range_id,
713 candidate: candidate.clone(),
714 });
715 }
716 if !evidence.checksum_validated {
717 return Err(ArchiveRecoveryRejection::MissingChecksumValidation {
718 collection: collection.clone(),
719 range_id,
720 candidate: candidate.clone(),
721 });
722 }
723 if !evidence.watermark_validated {
724 return Err(ArchiveRecoveryRejection::MissingWatermarkValidation {
725 collection: collection.clone(),
726 range_id,
727 candidate: candidate.clone(),
728 });
729 }
730 Ok(())
731}
732
733#[derive(Debug, Clone, PartialEq, Eq)]
736pub enum TransitionError {
737 Rejected(TransitionRejection),
739 Catalog(CatalogError),
741}
742
743impl From<TransitionRejection> for TransitionError {
744 fn from(value: TransitionRejection) -> Self {
745 TransitionError::Rejected(value)
746 }
747}
748
749impl std::fmt::Display for TransitionError {
750 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
751 match self {
752 Self::Rejected(err) => write!(f, "{err}"),
753 Self::Catalog(err) => write!(f, "{err}"),
754 }
755 }
756}
757
758impl std::error::Error for TransitionError {}
759
760#[cfg(test)]
761mod tests {
762 use super::*;
763 use crate::cluster::ownership::{
764 OwnershipEpoch, PlacementMetadata, RangeBounds, RangeRole, RangeWriteReject, ShardKeyMode,
765 };
766
767 fn collection(name: &str) -> CollectionId {
768 CollectionId::new(name).unwrap()
769 }
770
771 fn ident(cn: &str) -> NodeIdentity {
772 NodeIdentity::from_certificate_subject(cn).unwrap()
773 }
774
775 fn catalog_with(owner: &str, replicas: &[&str]) -> (ShardOwnershipCatalog, CollectionId) {
777 let orders = collection("orders");
778 let mut catalog = ShardOwnershipCatalog::new();
779 catalog
780 .apply_update(RangeOwnership::establish(
781 orders.clone(),
782 RangeId::new(1),
783 ShardKeyMode::Hash,
784 RangeBounds::full(),
785 ident(owner),
786 replicas.iter().map(|r| ident(r)).collect::<Vec<_>>(),
787 PlacementMetadata::with_replication_factor(3),
788 ))
789 .unwrap();
790 (catalog, orders)
791 }
792
793 fn catalog_with_archive(
794 owner: &str,
795 replicas: &[&str],
796 archive_replicas: &[&str],
797 ) -> (ShardOwnershipCatalog, CollectionId) {
798 let (mut catalog, orders) = catalog_with(owner, replicas);
799 let range = catalog.range(&orders, RangeId::new(1)).unwrap();
800 let archived = range.update_replica_roles(
801 range.replicas().iter().cloned(),
802 archive_replicas.iter().map(|r| ident(r)),
803 );
804 catalog.apply_update(archived).unwrap();
805 (catalog, orders)
806 }
807
808 fn request(
811 kind: TransitionKind,
812 orders: &CollectionId,
813 expected_owner: &str,
814 target: &str,
815 ) -> TransitionRequest {
816 TransitionRequest::new(
817 kind,
818 orders.clone(),
819 RangeId::new(1),
820 ident(expected_owner),
821 OwnershipEpoch::initial(),
822 CatalogVersion::initial(),
823 ident(target),
824 CommitWatermark::new(1, 10),
825 )
826 .with_evidence(CatchUpEvidence::new(ident(target), 1, 10))
827 }
828
829 #[test]
830 fn successful_promote_moves_authority_and_bumps_epoch() {
831 let (mut catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
832 let req = request(TransitionKind::Promote, &orders, "CN=node-a", "CN=node-b");
833
834 let outcome = run_transition(&mut catalog, &req).expect("promote should succeed");
835
836 assert_eq!(outcome.kind, TransitionKind::Promote);
837 assert_eq!(outcome.previous_owner, ident("CN=node-a"));
838 assert_eq!(outcome.new_owner, ident("CN=node-b"));
839 assert_eq!(outcome.previous_epoch, OwnershipEpoch::initial());
840 assert_eq!(outcome.new_epoch.value(), 2);
841 assert_eq!(outcome.new_version.value(), 2);
842 assert!(outcome.fenced_old_owner());
843
844 let range = catalog.range(&orders, RangeId::new(1)).unwrap();
846 assert_eq!(range.owner(), &ident("CN=node-b"));
847 assert_eq!(range.epoch().value(), 2);
848 let audit = outcome.to_string();
850 assert!(audit.contains("promote"));
851 assert!(audit.contains("CN=node-a"));
852 assert!(audit.contains("CN=node-b"));
853 }
854
855 #[test]
856 fn successful_handoff_demotes_old_owner_to_replica() {
857 let (mut catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
858 let req = request(TransitionKind::Handoff, &orders, "CN=node-a", "CN=node-b")
860 .with_replicas([ident("CN=node-a")]);
861
862 let outcome = run_transition(&mut catalog, &req).expect("handoff should succeed");
863 assert_eq!(outcome.kind, TransitionKind::Handoff);
864
865 let range = catalog.range(&orders, RangeId::new(1)).unwrap();
866 assert_eq!(range.owner(), &ident("CN=node-b"));
867 assert_eq!(range.role_of(&ident("CN=node-a")), RangeRole::Replica);
868 assert_eq!(range.role_of(&ident("CN=node-b")), RangeRole::Owner);
869 }
870
871 #[test]
872 fn old_owner_is_fenced_from_durable_writes_after_transition() {
873 let (mut catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
874 assert!(catalog
876 .admit_public_write(
877 &ident("CN=node-a"),
878 &orders,
879 b"k",
880 OwnershipEpoch::initial()
881 )
882 .is_ok());
883
884 let req = request(TransitionKind::Promote, &orders, "CN=node-a", "CN=node-b")
885 .with_replicas([ident("CN=node-a")]);
886 run_transition(&mut catalog, &req).unwrap();
887
888 let err = catalog
891 .admit_public_write(
892 &ident("CN=node-a"),
893 &orders,
894 b"k",
895 OwnershipEpoch::initial(),
896 )
897 .unwrap_err();
898 assert!(matches!(err, RangeWriteReject::NotOwner { .. }));
899
900 assert!(catalog
903 .admit_public_write(
904 &ident("CN=node-b"),
905 &orders,
906 b"k",
907 catalog.range(&orders, RangeId::new(1)).unwrap().epoch()
908 )
909 .is_ok());
910 }
911
912 #[test]
913 fn prepare_does_not_mutate_catalog() {
914 let (catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
915 let req = request(TransitionKind::Promote, &orders, "CN=node-a", "CN=node-b");
916 let _prepared = prepare(&catalog, &req).expect("prepare ok");
917 let range = catalog.range(&orders, RangeId::new(1)).unwrap();
920 assert_eq!(range.owner(), &ident("CN=node-a"));
921 assert_eq!(range.epoch(), OwnershipEpoch::initial());
922 }
923
924 fn bumped(catalog: &ShardOwnershipCatalog, orders: &CollectionId) -> RangeOwnership {
928 catalog
929 .range(orders, RangeId::new(1))
930 .unwrap()
931 .transfer_to(ident("CN=tmp"), [])
932 }
933
934 #[test]
935 fn stale_catalog_version_is_rejected() {
936 let (mut catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
937 let v2_entry = catalog
940 .range(&orders, RangeId::new(1))
941 .unwrap()
942 .update_replicas([ident("CN=node-b")]);
943 catalog.apply_update(v2_entry).unwrap();
944
945 let req = request(TransitionKind::Promote, &orders, "CN=node-a", "CN=node-b");
946 let err = prepare(&catalog, &req).unwrap_err();
948 match err {
949 TransitionRejection::StaleCatalogVersion {
950 expected, current, ..
951 } => {
952 assert_eq!(expected, CatalogVersion::initial());
953 assert_eq!(current.value(), 2);
954 }
955 other => panic!("expected StaleCatalogVersion, got {other:?}"),
956 }
957 }
958
959 #[test]
960 fn stale_expected_owner_is_rejected() {
961 let (catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
962 let req = request(TransitionKind::Promote, &orders, "CN=node-x", "CN=node-b");
964 let err = prepare(&catalog, &req).unwrap_err();
965 match err {
966 TransitionRejection::OwnerMismatch {
967 expected, current, ..
968 } => {
969 assert_eq!(expected, ident("CN=node-x"));
970 assert_eq!(current, ident("CN=node-a"));
971 }
972 other => panic!("expected OwnerMismatch, got {other:?}"),
973 }
974 }
975
976 #[test]
977 fn stale_expected_epoch_is_rejected() {
978 let (catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
979 let wrong_epoch = bumped(&catalog, &orders).epoch();
981 assert_eq!(wrong_epoch.value(), 2);
982 let req = TransitionRequest::new(
983 TransitionKind::Promote,
984 orders.clone(),
985 RangeId::new(1),
986 ident("CN=node-a"),
987 wrong_epoch,
988 CatalogVersion::initial(),
989 ident("CN=node-b"),
990 CommitWatermark::new(1, 10),
991 )
992 .with_evidence(CatchUpEvidence::new(ident("CN=node-b"), 1, 10));
993 let err = prepare(&catalog, &req).unwrap_err();
994 assert!(matches!(err, TransitionRejection::StaleEpoch { .. }));
995 }
996
997 #[test]
998 fn invalid_candidate_not_a_replica_is_rejected() {
999 let (catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
1000 let req = request(TransitionKind::Promote, &orders, "CN=node-a", "CN=node-z");
1002 let err = prepare(&catalog, &req).unwrap_err();
1003 match err {
1004 TransitionRejection::InvalidCandidate { reason, .. } => {
1005 assert_eq!(reason, InvalidCandidateReason::NotAReplica);
1006 }
1007 other => panic!("expected InvalidCandidate(NotAReplica), got {other:?}"),
1008 }
1009 }
1010
1011 #[test]
1012 fn archive_replica_is_not_a_direct_promotion_candidate() {
1013 let (catalog, orders) =
1014 catalog_with_archive("CN=node-a", &["CN=node-b"], &["CN=archive-a"]);
1015 let version = catalog.range(&orders, RangeId::new(1)).unwrap().version();
1016 let req = TransitionRequest::new(
1017 TransitionKind::Promote,
1018 orders.clone(),
1019 RangeId::new(1),
1020 ident("CN=node-a"),
1021 OwnershipEpoch::initial(),
1022 version,
1023 ident("CN=archive-a"),
1024 CommitWatermark::new(1, 10),
1025 )
1026 .with_evidence(CatchUpEvidence::new(ident("CN=archive-a"), 1, 10));
1027
1028 let err = prepare(&catalog, &req).unwrap_err();
1029 match err {
1030 TransitionRejection::InvalidCandidate { reason, .. } => {
1031 assert_eq!(
1032 reason,
1033 InvalidCandidateReason::ArchiveReplicaRequiresRestoreValidation
1034 );
1035 }
1036 other => panic!("expected archive replica rejection, got {other:?}"),
1037 }
1038 }
1039
1040 #[test]
1041 fn archive_recovery_rejects_missing_restore_validation() {
1042 let (catalog, orders) = catalog_with_archive("CN=node-a", &[], &["CN=archive-a"]);
1043 let err = validate_archive_recovery_source(
1044 &catalog,
1045 &orders,
1046 RangeId::new(1),
1047 &ident("CN=archive-a"),
1048 ArchiveRecoveryEvidence::new(false, true, true),
1049 )
1050 .unwrap_err();
1051 assert!(matches!(
1052 err,
1053 ArchiveRecoveryRejection::MissingRestoreValidation { .. }
1054 ));
1055 }
1056
1057 #[test]
1058 fn archive_recovery_rejects_missing_checksum_validation() {
1059 let (catalog, orders) = catalog_with_archive("CN=node-a", &[], &["CN=archive-a"]);
1060 let err = validate_archive_recovery_source(
1061 &catalog,
1062 &orders,
1063 RangeId::new(1),
1064 &ident("CN=archive-a"),
1065 ArchiveRecoveryEvidence::new(true, false, true),
1066 )
1067 .unwrap_err();
1068 assert!(matches!(
1069 err,
1070 ArchiveRecoveryRejection::MissingChecksumValidation { .. }
1071 ));
1072 }
1073
1074 #[test]
1075 fn archive_recovery_rejects_missing_watermark_validation() {
1076 let (catalog, orders) = catalog_with_archive("CN=node-a", &[], &["CN=archive-a"]);
1077 let err = validate_archive_recovery_source(
1078 &catalog,
1079 &orders,
1080 RangeId::new(1),
1081 &ident("CN=archive-a"),
1082 ArchiveRecoveryEvidence::new(true, true, false),
1083 )
1084 .unwrap_err();
1085 assert!(matches!(
1086 err,
1087 ArchiveRecoveryRejection::MissingWatermarkValidation { .. }
1088 ));
1089 }
1090
1091 #[test]
1092 fn archive_recovery_and_hot_mirror_promotion_use_different_paths() {
1093 let (mut catalog, orders) =
1094 catalog_with_archive("CN=node-a", &["CN=node-b"], &["CN=archive-a"]);
1095 let version = catalog.range(&orders, RangeId::new(1)).unwrap().version();
1096
1097 let hot_mirror = TransitionRequest::new(
1098 TransitionKind::Promote,
1099 orders.clone(),
1100 RangeId::new(1),
1101 ident("CN=node-a"),
1102 OwnershipEpoch::initial(),
1103 version,
1104 ident("CN=node-b"),
1105 CommitWatermark::new(1, 10),
1106 )
1107 .with_evidence(CatchUpEvidence::new(ident("CN=node-b"), 1, 10));
1108 run_transition(&mut catalog, &hot_mirror).expect("hot mirror promotion succeeds");
1109
1110 let (catalog, orders) =
1111 catalog_with_archive("CN=node-a", &["CN=node-b"], &["CN=archive-a"]);
1112 validate_archive_recovery_source(
1113 &catalog,
1114 &orders,
1115 RangeId::new(1),
1116 &ident("CN=archive-a"),
1117 ArchiveRecoveryEvidence::new(true, true, true),
1118 )
1119 .expect("archive recovery accepts only after restore integrity validation");
1120 }
1121
1122 #[test]
1123 fn invalid_candidate_already_owner_is_rejected() {
1124 let (catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
1125 let req = request(TransitionKind::Promote, &orders, "CN=node-a", "CN=node-a")
1127 .with_evidence(CatchUpEvidence::new(ident("CN=node-a"), 1, 10));
1128 let err = prepare(&catalog, &req).unwrap_err();
1129 match err {
1130 TransitionRejection::InvalidCandidate { reason, .. } => {
1131 assert_eq!(reason, InvalidCandidateReason::AlreadyOwner);
1132 }
1133 other => panic!("expected InvalidCandidate(AlreadyOwner), got {other:?}"),
1134 }
1135 }
1136
1137 #[test]
1138 fn missing_safety_evidence_fails_closed() {
1139 let (catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
1140 let req = TransitionRequest::new(
1141 TransitionKind::Promote,
1142 orders.clone(),
1143 RangeId::new(1),
1144 ident("CN=node-a"),
1145 OwnershipEpoch::initial(),
1146 CatalogVersion::initial(),
1147 ident("CN=node-b"),
1148 CommitWatermark::new(1, 10),
1149 ); let err = prepare(&catalog, &req).unwrap_err();
1151 assert!(matches!(
1152 err,
1153 TransitionRejection::MissingSafetyEvidence { .. }
1154 ));
1155 }
1156
1157 #[test]
1158 fn evidence_for_a_different_candidate_is_rejected() {
1159 let (catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
1160 let req = request(TransitionKind::Promote, &orders, "CN=node-a", "CN=node-b")
1162 .with_evidence(CatchUpEvidence::new(ident("CN=node-c"), 9, 99));
1163 let err = prepare(&catalog, &req).unwrap_err();
1164 assert!(matches!(
1165 err,
1166 TransitionRejection::EvidenceForWrongCandidate { .. }
1167 ));
1168 }
1169
1170 #[test]
1171 fn candidate_behind_commit_watermark_fails_safety_check() {
1172 let (catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
1173 let req = TransitionRequest::new(
1175 TransitionKind::Promote,
1176 orders.clone(),
1177 RangeId::new(1),
1178 ident("CN=node-a"),
1179 OwnershipEpoch::initial(),
1180 CatalogVersion::initial(),
1181 ident("CN=node-b"),
1182 CommitWatermark::new(2, 50),
1183 )
1184 .with_evidence(CatchUpEvidence::new(ident("CN=node-b"), 2, 49));
1185 let err = prepare(&catalog, &req).unwrap_err();
1186 match err {
1187 TransitionRejection::SafetyCheckFailed {
1188 applied_lsn,
1189 watermark,
1190 ..
1191 } => {
1192 assert_eq!(applied_lsn, 49);
1193 assert_eq!(watermark, CommitWatermark::new(2, 50));
1194 }
1195 other => panic!("expected SafetyCheckFailed, got {other:?}"),
1196 }
1197 }
1198
1199 #[test]
1200 fn candidate_on_older_term_fails_even_with_higher_lsn() {
1201 let (catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
1202 let req = TransitionRequest::new(
1204 TransitionKind::Promote,
1205 orders.clone(),
1206 RangeId::new(1),
1207 ident("CN=node-a"),
1208 OwnershipEpoch::initial(),
1209 CatalogVersion::initial(),
1210 ident("CN=node-b"),
1211 CommitWatermark::new(3, 10),
1212 )
1213 .with_evidence(CatchUpEvidence::new(ident("CN=node-b"), 2, 9999));
1214 let err = prepare(&catalog, &req).unwrap_err();
1215 assert!(matches!(err, TransitionRejection::SafetyCheckFailed { .. }));
1216 }
1217
1218 #[test]
1219 fn evidence_on_newer_term_covers_watermark() {
1220 let evidence = CatchUpEvidence::new(ident("CN=node-b"), 5, 0);
1222 assert!(evidence.covers(CommitWatermark::new(4, 9999)));
1223 }
1224
1225 #[test]
1226 fn rejected_transition_leaves_catalog_unchanged() {
1227 let (mut catalog, orders) = catalog_with("CN=node-a", &["CN=node-b"]);
1228 let req = request(TransitionKind::Promote, &orders, "CN=node-a", "CN=node-z");
1229 assert!(run_transition(&mut catalog, &req).is_err());
1230 let range = catalog.range(&orders, RangeId::new(1)).unwrap();
1232 assert_eq!(range.owner(), &ident("CN=node-a"));
1233 assert_eq!(range.epoch(), OwnershipEpoch::initial());
1234 assert_eq!(range.version(), CatalogVersion::initial());
1235 }
1236
1237 #[test]
1238 fn second_transition_with_stale_cas_loses() {
1239 let (mut catalog, orders) = catalog_with("CN=node-a", &["CN=node-b", "CN=node-c"]);
1242 let first = request(TransitionKind::Promote, &orders, "CN=node-a", "CN=node-b");
1243 run_transition(&mut catalog, &first).unwrap();
1244
1245 let second = request(TransitionKind::Promote, &orders, "CN=node-a", "CN=node-c");
1247 let err = run_transition(&mut catalog, &second).unwrap_err();
1248 assert!(matches!(
1249 err,
1250 TransitionError::Rejected(TransitionRejection::OwnerMismatch { .. })
1251 ));
1252 }
1253}