1use std::collections::{BTreeMap, BTreeSet};
47
48use super::identity::NodeIdentity;
49use super::slot::hash_shard_key_to_range_key;
50
51#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
59pub struct CollectionId(String);
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct CollectionIdError;
63
64impl std::fmt::Display for CollectionIdError {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 write!(f, "collection id is empty")
67 }
68}
69
70impl std::error::Error for CollectionIdError {}
71
72impl CollectionId {
73 pub fn new(value: impl AsRef<str>) -> Result<Self, CollectionIdError> {
75 let value = value.as_ref().trim();
76 if value.is_empty() {
77 return Err(CollectionIdError);
78 }
79 Ok(Self(value.to_string()))
80 }
81
82 pub fn as_str(&self) -> &str {
83 &self.0
84 }
85}
86
87impl std::fmt::Display for CollectionId {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 f.write_str(&self.0)
90 }
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
100pub struct CollectionGroupId(String);
101
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct CollectionGroupIdError;
104
105impl std::fmt::Display for CollectionGroupIdError {
106 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107 write!(f, "collection group id is empty")
108 }
109}
110
111impl std::error::Error for CollectionGroupIdError {}
112
113impl CollectionGroupId {
114 pub fn new(value: impl AsRef<str>) -> Result<Self, CollectionGroupIdError> {
116 let value = value.as_ref().trim();
117 if value.is_empty() {
118 return Err(CollectionGroupIdError);
119 }
120 Ok(Self(value.to_string()))
121 }
122
123 pub fn for_collection(collection: &CollectionId) -> Self {
124 Self(collection.as_str().to_string())
125 }
126
127 pub fn as_str(&self) -> &str {
128 &self.0
129 }
130}
131
132impl std::fmt::Display for CollectionGroupId {
133 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134 f.write_str(&self.0)
135 }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct CollectionGroupAuthority {
142 group: CollectionGroupId,
143 authority: NodeIdentity,
144 slices: Vec<AuthorityCatalogSlice>,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
150struct AuthorityCatalogSlice {
151 collection: CollectionId,
152 bounds: RangeBounds,
153}
154
155impl CollectionGroupAuthority {
156 pub fn new(
158 group: CollectionGroupId,
159 authority: NodeIdentity,
160 collections: impl IntoIterator<Item = CollectionId>,
161 ) -> Result<Self, PlacementAuthorityError> {
162 let collections = collections.into_iter().collect::<BTreeSet<_>>();
163 if collections.is_empty() {
164 return Err(PlacementAuthorityError::EmptyCatalogSlice { group });
165 }
166 let slices = collections
167 .into_iter()
168 .map(|collection| AuthorityCatalogSlice {
169 collection,
170 bounds: RangeBounds::full(),
171 })
172 .collect();
173 Ok(Self {
174 group,
175 authority,
176 slices,
177 })
178 }
179
180 pub fn with_slices(
182 group: CollectionGroupId,
183 authority: NodeIdentity,
184 slices: impl IntoIterator<Item = (CollectionId, RangeBounds)>,
185 ) -> Result<Self, PlacementAuthorityError> {
186 let slices = slices
187 .into_iter()
188 .map(|(collection, bounds)| AuthorityCatalogSlice { collection, bounds })
189 .collect::<Vec<_>>();
190 if slices.is_empty() {
191 return Err(PlacementAuthorityError::EmptyCatalogSlice { group });
192 }
193 Ok(Self {
194 group,
195 authority,
196 slices,
197 })
198 }
199
200 pub fn group(&self) -> &CollectionGroupId {
201 &self.group
202 }
203
204 pub fn authority(&self) -> &NodeIdentity {
205 &self.authority
206 }
207
208 pub fn collections(&self) -> impl Iterator<Item = &CollectionId> {
209 self.slices.iter().map(|slice| &slice.collection)
210 }
211
212 pub fn slices(&self) -> impl Iterator<Item = (&CollectionId, &RangeBounds)> {
213 self.slices
214 .iter()
215 .map(|slice| (&slice.collection, &slice.bounds))
216 }
217
218 fn overlaps(&self, other: &CollectionGroupAuthority) -> Option<(CollectionId, RangeBounds)> {
219 for existing in &self.slices {
220 for attempted in &other.slices {
221 if existing.collection == attempted.collection {
222 if let Some(overlap) = existing.bounds.intersection(&attempted.bounds) {
223 return Some((existing.collection.clone(), overlap));
224 }
225 }
226 }
227 }
228 None
229 }
230}
231
232#[derive(Debug, Clone, PartialEq, Eq)]
234pub enum PlacementAuthorityError {
235 EmptyCatalogSlice { group: CollectionGroupId },
238 DuplicateCollectionGroup { group: CollectionGroupId },
240 OverlappingCatalogSlice {
243 collection: CollectionId,
244 existing_group: CollectionGroupId,
245 existing_authority: NodeIdentity,
246 attempted_group: CollectionGroupId,
247 attempted_authority: NodeIdentity,
248 overlap: RangeBounds,
249 },
250}
251
252impl std::fmt::Display for PlacementAuthorityError {
253 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254 match self {
255 Self::EmptyCatalogSlice { group } => {
256 write!(f, "collection group {group} has an empty catalog slice")
257 }
258 Self::DuplicateCollectionGroup { group } => {
259 write!(
260 f,
261 "collection group {group} already has a placement authority"
262 )
263 }
264 Self::OverlappingCatalogSlice {
265 collection,
266 existing_group,
267 existing_authority,
268 attempted_group,
269 attempted_authority,
270 overlap,
271 } => write!(
272 f,
273 "placement authority {attempted_authority} for group {attempted_group} overlaps placement authority {existing_authority} for group {existing_group} on {collection}{overlap}"
274 ),
275 }
276 }
277}
278
279impl std::error::Error for PlacementAuthorityError {}
280
281#[derive(Debug, Clone, Default)]
283pub struct PlacementAuthorityCatalog {
284 assignments: BTreeMap<CollectionGroupId, CollectionGroupAuthority>,
285}
286
287impl PlacementAuthorityCatalog {
288 pub fn new() -> Self {
290 Self::default()
291 }
292
293 pub fn assign(
295 &mut self,
296 assignment: CollectionGroupAuthority,
297 ) -> Result<(), PlacementAuthorityError> {
298 self.validate_assignment(&assignment)?;
299 self.assignments
300 .insert(assignment.group().clone(), assignment);
301 Ok(())
302 }
303
304 pub fn authority_for_group(
305 &self,
306 group: &CollectionGroupId,
307 ) -> Option<&CollectionGroupAuthority> {
308 self.assignments.get(group)
309 }
310
311 pub fn authority_for_collection(
312 &self,
313 collection: &CollectionId,
314 ) -> Option<&CollectionGroupAuthority> {
315 self.assignments.values().find(|assignment| {
316 assignment
317 .slices
318 .iter()
319 .any(|slice| &slice.collection == collection)
320 })
321 }
322
323 pub fn split_collection_group_slice(
327 &mut self,
328 retired_group: &CollectionGroupId,
329 retained: CollectionGroupAuthority,
330 carved: CollectionGroupAuthority,
331 ) -> Result<(), PlacementAuthorityError> {
332 self.replace_assignments([retired_group], [retained, carved])
333 }
334
335 pub fn merge_collection_group_slices<'a>(
337 &mut self,
338 retired_groups: impl IntoIterator<Item = &'a CollectionGroupId>,
339 merged: CollectionGroupAuthority,
340 ) -> Result<(), PlacementAuthorityError> {
341 self.replace_assignments(retired_groups, [merged])
342 }
343
344 pub fn reassign_collection_group_slice(
346 &mut self,
347 retired_group: &CollectionGroupId,
348 retained: CollectionGroupAuthority,
349 reassigned: CollectionGroupAuthority,
350 ) -> Result<(), PlacementAuthorityError> {
351 self.replace_assignments([retired_group], [retained, reassigned])
352 }
353
354 pub fn force_replace_assignments<'a>(
358 &mut self,
359 retired_groups: impl IntoIterator<Item = &'a CollectionGroupId>,
360 replacements: impl IntoIterator<Item = CollectionGroupAuthority>,
361 ) -> Result<(), PlacementAuthorityError> {
362 self.replace_assignments(retired_groups, replacements)
363 }
364
365 fn validate_assignment(
366 &self,
367 assignment: &CollectionGroupAuthority,
368 ) -> Result<(), PlacementAuthorityError> {
369 if self.assignments.contains_key(assignment.group()) {
370 return Err(PlacementAuthorityError::DuplicateCollectionGroup {
371 group: assignment.group().clone(),
372 });
373 }
374 for existing in self.assignments.values() {
375 if let Some((collection, overlap)) = existing.overlaps(assignment) {
376 return Err(PlacementAuthorityError::OverlappingCatalogSlice {
377 collection,
378 existing_group: existing.group().clone(),
379 existing_authority: existing.authority().clone(),
380 attempted_group: assignment.group().clone(),
381 attempted_authority: assignment.authority().clone(),
382 overlap,
383 });
384 }
385 }
386 Ok(())
387 }
388
389 fn replace_assignments<'a>(
390 &mut self,
391 retired_groups: impl IntoIterator<Item = &'a CollectionGroupId>,
392 replacements: impl IntoIterator<Item = CollectionGroupAuthority>,
393 ) -> Result<(), PlacementAuthorityError> {
394 let retired_groups = retired_groups.into_iter().cloned().collect::<BTreeSet<_>>();
395 let replacements = replacements.into_iter().collect::<Vec<_>>();
396 let mut next = self
397 .assignments
398 .iter()
399 .filter(|(group, _)| !retired_groups.contains(group))
400 .map(|(group, assignment)| (group.clone(), assignment.clone()))
401 .collect::<BTreeMap<_, _>>();
402
403 for replacement in replacements {
404 if let Some(existing) = next.get(replacement.group()) {
405 if let Some((collection, overlap)) = existing.overlaps(&replacement) {
406 return Err(PlacementAuthorityError::OverlappingCatalogSlice {
407 collection,
408 existing_group: existing.group().clone(),
409 existing_authority: existing.authority().clone(),
410 attempted_group: replacement.group().clone(),
411 attempted_authority: replacement.authority().clone(),
412 overlap,
413 });
414 }
415 if existing.authority() != replacement.authority() {
416 return Err(PlacementAuthorityError::DuplicateCollectionGroup {
417 group: replacement.group().clone(),
418 });
419 }
420 let mut merged = existing.clone();
421 merged.slices.extend(replacement.slices);
422 next.insert(merged.group().clone(), merged);
423 continue;
424 }
425 for existing in next.values() {
426 if let Some((collection, overlap)) = existing.overlaps(&replacement) {
427 return Err(PlacementAuthorityError::OverlappingCatalogSlice {
428 collection,
429 existing_group: existing.group().clone(),
430 existing_authority: existing.authority().clone(),
431 attempted_group: replacement.group().clone(),
432 attempted_authority: replacement.authority().clone(),
433 overlap,
434 });
435 }
436 }
437 next.insert(replacement.group().clone(), replacement);
438 }
439
440 self.assignments = next;
441 Ok(())
442 }
443}
444
445#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
447pub struct PlacementAuthorityId(String);
448
449#[derive(Debug, Clone, PartialEq, Eq)]
450pub struct PlacementAuthorityIdError;
451
452impl std::fmt::Display for PlacementAuthorityIdError {
453 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
454 write!(f, "placement authority id is empty")
455 }
456}
457
458impl std::error::Error for PlacementAuthorityIdError {}
459
460impl PlacementAuthorityId {
461 pub fn new(value: impl AsRef<str>) -> Result<Self, PlacementAuthorityIdError> {
462 let value = value.as_ref().trim();
463 if value.is_empty() {
464 return Err(PlacementAuthorityIdError);
465 }
466 Ok(Self(value.to_string()))
467 }
468
469 pub fn default_global() -> Self {
470 Self("global-placement-authority".to_string())
471 }
472
473 pub fn as_str(&self) -> &str {
474 &self.0
475 }
476}
477
478impl std::fmt::Display for PlacementAuthorityId {
479 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
480 f.write_str(&self.0)
481 }
482}
483
484#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
492pub struct RangeId(u64);
493
494impl RangeId {
495 pub fn new(value: u64) -> Self {
496 Self(value)
497 }
498
499 pub fn value(self) -> u64 {
500 self.0
501 }
502}
503
504impl std::fmt::Display for RangeId {
505 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
506 write!(f, "{}", self.0)
507 }
508}
509
510#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
519pub enum ShardKeyMode {
520 #[default]
522 Hash,
523 Ordered,
526}
527
528#[derive(Debug, Clone, PartialEq, Eq)]
536pub enum RangeBound {
537 Min,
539 Key(Vec<u8>),
541 Max,
543}
544
545impl RangeBound {
546 pub fn key(bytes: impl Into<Vec<u8>>) -> Self {
548 RangeBound::Key(bytes.into())
549 }
550
551 fn position(&self) -> Position<'_> {
555 match self {
556 RangeBound::Min => Position::Min,
557 RangeBound::Key(k) => Position::Key(k),
558 RangeBound::Max => Position::Max,
559 }
560 }
561}
562
563impl std::fmt::Display for RangeBound {
564 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
565 match self {
566 Self::Min => f.write_str("Min"),
567 Self::Max => f.write_str("Max"),
568 Self::Key(bytes) => {
569 f.write_str("0x")?;
570 for byte in bytes {
571 write!(f, "{byte:02x}")?;
572 }
573 Ok(())
574 }
575 }
576 }
577}
578
579#[derive(PartialEq, Eq, PartialOrd, Ord)]
580enum Position<'a> {
581 Min,
582 Key(&'a [u8]),
583 Max,
584}
585
586#[derive(Debug, Clone, PartialEq, Eq)]
593pub struct RangeBounds {
594 lower: RangeBound,
595 upper: RangeBound,
596}
597
598#[derive(Debug, Clone, PartialEq, Eq)]
599pub struct RangeBoundsError;
600
601impl std::fmt::Display for RangeBoundsError {
602 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
603 write!(
604 f,
605 "range lower bound must be strictly below the upper bound"
606 )
607 }
608}
609
610impl std::error::Error for RangeBoundsError {}
611
612impl RangeBounds {
613 pub fn new(lower: RangeBound, upper: RangeBound) -> Result<Self, RangeBoundsError> {
616 if lower.position() >= upper.position() {
617 return Err(RangeBoundsError);
618 }
619 Ok(Self { lower, upper })
620 }
621
622 pub fn full() -> Self {
624 Self {
625 lower: RangeBound::Min,
626 upper: RangeBound::Max,
627 }
628 }
629
630 pub fn lower(&self) -> &RangeBound {
631 &self.lower
632 }
633
634 pub fn upper(&self) -> &RangeBound {
635 &self.upper
636 }
637
638 pub fn contains(&self, key: &[u8]) -> bool {
641 let key = Position::Key(key);
642 self.lower.position() <= key && key < self.upper.position()
643 }
644
645 pub fn overlaps(&self, other: &RangeBounds) -> bool {
648 self.lower.position() < other.upper.position()
649 && other.lower.position() < self.upper.position()
650 }
651
652 pub fn intersection(&self, other: &RangeBounds) -> Option<RangeBounds> {
654 if !self.overlaps(other) {
655 return None;
656 }
657 let lower = if self.lower.position() >= other.lower.position() {
658 self.lower.clone()
659 } else {
660 other.lower.clone()
661 };
662 let upper = if self.upper.position() <= other.upper.position() {
663 self.upper.clone()
664 } else {
665 other.upper.clone()
666 };
667 Some(RangeBounds { lower, upper })
668 }
669
670 pub fn split_at(&self, at: &[u8]) -> Result<(RangeBounds, RangeBounds), RangeBoundsError> {
679 let at_pos = Position::Key(at);
680 if at_pos <= self.lower.position() || at_pos >= self.upper.position() {
681 return Err(RangeBoundsError);
682 }
683 let lower = RangeBounds {
684 lower: self.lower.clone(),
685 upper: RangeBound::key(at.to_vec()),
686 };
687 let upper = RangeBounds {
688 lower: RangeBound::key(at.to_vec()),
689 upper: self.upper.clone(),
690 };
691 Ok((lower, upper))
692 }
693}
694
695impl std::fmt::Display for RangeBounds {
696 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
697 write!(f, "[{}, {})", self.lower, self.upper)
698 }
699}
700
701#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
708pub struct CatalogVersion(u64);
709
710impl CatalogVersion {
711 pub fn initial() -> Self {
713 Self(1)
714 }
715
716 pub fn value(self) -> u64 {
717 self.0
718 }
719
720 fn next(self) -> Self {
721 Self(self.0 + 1)
722 }
723}
724
725impl std::fmt::Display for CatalogVersion {
726 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
727 write!(f, "{}", self.0)
728 }
729}
730
731#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
739pub struct OwnershipEpoch(u64);
740
741impl OwnershipEpoch {
742 pub fn initial() -> Self {
744 Self(1)
745 }
746
747 pub fn value(self) -> u64 {
748 self.0
749 }
750
751 fn next(self) -> Self {
752 Self(self.0 + 1)
753 }
754}
755
756impl std::fmt::Display for OwnershipEpoch {
757 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
758 write!(f, "{}", self.0)
759 }
760}
761
762#[derive(Debug, Clone, Default, PartialEq, Eq)]
768pub struct PlacementMetadata {
769 replication_factor: usize,
770 attributes: BTreeMap<String, String>,
771}
772
773impl PlacementMetadata {
774 pub fn with_replication_factor(replication_factor: usize) -> Self {
776 Self {
777 replication_factor,
778 attributes: BTreeMap::new(),
779 }
780 }
781
782 pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
784 self.attributes.insert(key.into(), value.into());
785 self
786 }
787
788 pub fn replication_factor(&self) -> usize {
789 self.replication_factor
790 }
791
792 pub fn attribute(&self, key: &str) -> Option<&str> {
793 self.attributes.get(key).map(String::as_str)
794 }
795}
796
797#[derive(Debug, Clone, PartialEq, Eq)]
808pub struct RangeOwnership {
809 collection: CollectionId,
810 collection_group: CollectionGroupId,
811 placement_authority: PlacementAuthorityId,
812 range_id: RangeId,
813 shard_key_mode: ShardKeyMode,
814 bounds: RangeBounds,
815 owner: NodeIdentity,
816 replicas: Vec<NodeIdentity>,
817 compressed_archive_replicas: Vec<NodeIdentity>,
818 epoch: OwnershipEpoch,
819 version: CatalogVersion,
820 placement: PlacementMetadata,
821}
822
823#[derive(Debug, Clone, PartialEq, Eq)]
824pub struct HotMirrorCandidate {
825 node: NodeIdentity,
826 durable_lsn: u64,
827}
828
829impl HotMirrorCandidate {
830 pub fn new(node: NodeIdentity, durable_lsn: u64) -> Self {
831 Self { node, durable_lsn }
832 }
833
834 pub fn node(&self) -> &NodeIdentity {
835 &self.node
836 }
837
838 pub fn durable_lsn(&self) -> u64 {
839 self.durable_lsn
840 }
841}
842
843#[derive(Debug, Clone, PartialEq, Eq)]
844pub enum HotMirrorPromotionRefusal {
845 NotReplica {
846 candidate: NodeIdentity,
847 role: RangeRole,
848 },
849 WatermarkNotCovered {
850 candidate_lsn: u64,
851 watermark: u64,
852 },
853}
854
855#[derive(Debug, Clone, PartialEq, Eq)]
856pub struct HotMirrorPromotion {
857 previous: RangeOwnership,
858 promoted: RangeOwnership,
859}
860
861impl HotMirrorPromotion {
862 pub fn previous(&self) -> &RangeOwnership {
863 &self.previous
864 }
865
866 pub fn promoted(&self) -> &RangeOwnership {
867 &self.promoted
868 }
869}
870
871impl RangeOwnership {
872 #[allow(clippy::too_many_arguments)]
875 pub fn establish(
876 collection: CollectionId,
877 range_id: RangeId,
878 shard_key_mode: ShardKeyMode,
879 bounds: RangeBounds,
880 owner: NodeIdentity,
881 replicas: impl IntoIterator<Item = NodeIdentity>,
882 placement: PlacementMetadata,
883 ) -> Self {
884 let collection_group = CollectionGroupId::for_collection(&collection);
885 Self {
886 collection,
887 collection_group,
888 placement_authority: PlacementAuthorityId::default_global(),
889 range_id,
890 shard_key_mode,
891 bounds,
892 owner,
893 replicas: replicas.into_iter().collect(),
894 compressed_archive_replicas: Vec::new(),
895 epoch: OwnershipEpoch::initial(),
896 version: CatalogVersion::initial(),
897 placement,
898 }
899 }
900
901 pub fn collection(&self) -> &CollectionId {
902 &self.collection
903 }
904
905 pub fn collection_group(&self) -> &CollectionGroupId {
906 &self.collection_group
907 }
908
909 pub fn placement_authority(&self) -> &PlacementAuthorityId {
910 &self.placement_authority
911 }
912
913 pub fn range_id(&self) -> RangeId {
914 self.range_id
915 }
916
917 pub fn shard_key_mode(&self) -> ShardKeyMode {
918 self.shard_key_mode
919 }
920
921 pub fn bounds(&self) -> &RangeBounds {
922 &self.bounds
923 }
924
925 pub fn owner(&self) -> &NodeIdentity {
926 &self.owner
927 }
928
929 pub fn replicas(&self) -> &[NodeIdentity] {
930 &self.replicas
931 }
932
933 pub fn hot_mirror_replicas(&self) -> &[NodeIdentity] {
937 &self.replicas
938 }
939
940 pub fn compressed_archive_replicas(&self) -> &[NodeIdentity] {
944 &self.compressed_archive_replicas
945 }
946
947 pub fn epoch(&self) -> OwnershipEpoch {
948 self.epoch
949 }
950
951 pub fn version(&self) -> CatalogVersion {
952 self.version
953 }
954
955 pub fn placement(&self) -> &PlacementMetadata {
956 &self.placement
957 }
958
959 fn key(&self) -> (CollectionId, RangeId) {
961 (self.collection.clone(), self.range_id)
962 }
963
964 pub fn with_collection_group(mut self, collection_group: CollectionGroupId) -> Self {
965 self.collection_group = collection_group;
966 self
967 }
968
969 pub fn with_placement_authority(mut self, placement_authority: PlacementAuthorityId) -> Self {
970 self.placement_authority = placement_authority;
971 self
972 }
973
974 pub fn transfer_to(
978 &self,
979 new_owner: NodeIdentity,
980 new_replicas: impl IntoIterator<Item = NodeIdentity>,
981 ) -> Self {
982 Self {
983 owner: new_owner,
984 replicas: new_replicas.into_iter().collect(),
985 epoch: self.epoch.next(),
986 version: self.version.next(),
987 ..self.clone()
988 }
989 }
990
991 pub fn promote_hot_mirror(
996 &self,
997 candidate: &HotMirrorCandidate,
998 commit_watermark: u64,
999 ) -> Result<HotMirrorPromotion, HotMirrorPromotionRefusal> {
1000 let role = self.role_of(candidate.node());
1001 if role != RangeRole::Replica {
1002 return Err(HotMirrorPromotionRefusal::NotReplica {
1003 candidate: candidate.node().clone(),
1004 role,
1005 });
1006 }
1007 if candidate.durable_lsn() < commit_watermark {
1008 return Err(HotMirrorPromotionRefusal::WatermarkNotCovered {
1009 candidate_lsn: candidate.durable_lsn(),
1010 watermark: commit_watermark,
1011 });
1012 }
1013
1014 let new_replicas = std::iter::once(self.owner().clone())
1015 .chain(
1016 self.replicas()
1017 .iter()
1018 .filter(|replica| *replica != candidate.node())
1019 .cloned(),
1020 )
1021 .collect::<Vec<_>>();
1022 Ok(HotMirrorPromotion {
1023 previous: self.clone(),
1024 promoted: self.transfer_to(candidate.node().clone(), new_replicas),
1025 })
1026 }
1027
1028 pub fn update_replicas(&self, new_replicas: impl IntoIterator<Item = NodeIdentity>) -> Self {
1031 Self {
1032 replicas: new_replicas.into_iter().collect(),
1033 version: self.version.next(),
1034 ..self.clone()
1035 }
1036 }
1037
1038 pub fn with_compressed_archive_replicas(
1041 &self,
1042 archive_replicas: impl IntoIterator<Item = NodeIdentity>,
1043 ) -> Self {
1044 Self {
1045 compressed_archive_replicas: archive_replicas.into_iter().collect(),
1046 version: self.version.next(),
1047 ..self.clone()
1048 }
1049 }
1050
1051 pub fn update_replica_roles(
1053 &self,
1054 hot_mirror_replicas: impl IntoIterator<Item = NodeIdentity>,
1055 compressed_archive_replicas: impl IntoIterator<Item = NodeIdentity>,
1056 ) -> Self {
1057 Self {
1058 replicas: hot_mirror_replicas.into_iter().collect(),
1059 compressed_archive_replicas: compressed_archive_replicas.into_iter().collect(),
1060 version: self.version.next(),
1061 ..self.clone()
1062 }
1063 }
1064
1065 pub fn update_placement(&self, placement: PlacementMetadata) -> Self {
1068 Self {
1069 placement,
1070 version: self.version.next(),
1071 ..self.clone()
1072 }
1073 }
1074
1075 pub fn with_bounds(&self, bounds: RangeBounds) -> Self {
1081 Self {
1082 bounds,
1083 version: self.version.next(),
1084 ..self.clone()
1085 }
1086 }
1087
1088 pub fn role_of(&self, node: &NodeIdentity) -> RangeRole {
1098 if self.owner == *node {
1099 RangeRole::Owner
1100 } else if self.replicas.iter().any(|replica| replica == node) {
1101 RangeRole::Replica
1102 } else if self
1103 .compressed_archive_replicas
1104 .iter()
1105 .any(|archive_replica| archive_replica == node)
1106 {
1107 RangeRole::ArchiveReplica
1108 } else {
1109 RangeRole::NoCopy
1110 }
1111 }
1112
1113 pub fn replica_role_of(&self, node: &NodeIdentity) -> Option<ReplicaRole> {
1115 if self.replicas.iter().any(|replica| replica == node) {
1116 Some(ReplicaRole::HotMirror)
1117 } else if self
1118 .compressed_archive_replicas
1119 .iter()
1120 .any(|replica| replica == node)
1121 {
1122 Some(ReplicaRole::CompressedArchive)
1123 } else {
1124 None
1125 }
1126 }
1127
1128 fn validate_replica_roles(&self) -> Result<(), CatalogError> {
1129 let mut seen = BTreeSet::new();
1130 for replica in &self.replicas {
1131 if replica == &self.owner || !seen.insert(replica) {
1132 return Err(CatalogError::InvalidReplicaRoles {
1133 collection: self.collection.clone(),
1134 range_id: self.range_id,
1135 });
1136 }
1137 }
1138 for replica in &self.compressed_archive_replicas {
1139 if replica == &self.owner || !seen.insert(replica) {
1140 return Err(CatalogError::InvalidReplicaRoles {
1141 collection: self.collection.clone(),
1142 range_id: self.range_id,
1143 });
1144 }
1145 }
1146 Ok(())
1147 }
1148}
1149
1150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1152pub enum ReplicaRole {
1153 HotMirror,
1156 CompressedArchive,
1159}
1160
1161impl ReplicaRole {
1162 pub fn is_promotion_candidate(self) -> bool {
1163 matches!(self, ReplicaRole::HotMirror)
1164 }
1165
1166 pub fn is_restore_only(self) -> bool {
1167 matches!(self, ReplicaRole::CompressedArchive)
1168 }
1169}
1170
1171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1183pub enum RangeRole {
1184 Owner,
1187 Replica,
1191 ArchiveReplica,
1194 NoCopy,
1196}
1197
1198impl RangeRole {
1199 pub fn may_write_public(self) -> bool {
1202 matches!(self, RangeRole::Owner)
1203 }
1204
1205 pub fn is_direct_promotion_candidate(self) -> bool {
1206 matches!(self, RangeRole::Replica)
1207 }
1208
1209 fn label(self) -> &'static str {
1210 match self {
1211 RangeRole::Owner => "owner",
1212 RangeRole::Replica => "replica",
1213 RangeRole::ArchiveReplica => "archive-replica",
1214 RangeRole::NoCopy => "no-copy",
1215 }
1216 }
1217}
1218
1219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1221pub enum UpdateOutcome {
1222 Created,
1224 Updated,
1226}
1227
1228#[derive(Debug, Clone, PartialEq, Eq)]
1230pub enum CatalogError {
1231 StaleVersion {
1235 collection: CollectionId,
1236 range_id: RangeId,
1237 current: CatalogVersion,
1238 attempted: CatalogVersion,
1239 },
1240 ShardKeyModeMismatch {
1243 collection: CollectionId,
1244 declared: ShardKeyMode,
1245 attempted: ShardKeyMode,
1246 },
1247 OverlappingRange {
1250 collection: CollectionId,
1251 existing: RangeId,
1252 attempted: RangeId,
1253 },
1254 InvalidReplicaRoles {
1257 collection: CollectionId,
1258 range_id: RangeId,
1259 },
1260}
1261
1262impl std::fmt::Display for CatalogError {
1263 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1264 match self {
1265 Self::StaleVersion {
1266 collection,
1267 range_id,
1268 current,
1269 attempted,
1270 } => write!(
1271 f,
1272 "stale catalog update for {collection}/{range_id}: current version {current}, attempted {attempted}"
1273 ),
1274 Self::ShardKeyModeMismatch {
1275 collection,
1276 declared,
1277 attempted,
1278 } => write!(
1279 f,
1280 "collection {collection} is declared {declared:?} but range uses {attempted:?}"
1281 ),
1282 Self::OverlappingRange {
1283 collection,
1284 existing,
1285 attempted,
1286 } => write!(
1287 f,
1288 "range {attempted} overlaps existing range {existing} of collection {collection}"
1289 ),
1290 Self::InvalidReplicaRoles {
1291 collection,
1292 range_id,
1293 } => write!(
1294 f,
1295 "range {range_id} of collection {collection} has invalid replica role metadata"
1296 ),
1297 }
1298 }
1299}
1300
1301impl std::error::Error for CatalogError {}
1302
1303#[derive(Debug, Clone, PartialEq, Eq)]
1313pub enum RangeWriteReject {
1314 NoRange { collection: CollectionId },
1317 NotOwner {
1324 collection: CollectionId,
1325 range_id: RangeId,
1326 role: RangeRole,
1327 owner: NodeIdentity,
1328 },
1329 StaleEpoch {
1334 collection: CollectionId,
1335 range_id: RangeId,
1336 expected: OwnershipEpoch,
1337 current: OwnershipEpoch,
1338 },
1339}
1340
1341impl std::fmt::Display for RangeWriteReject {
1342 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1343 match self {
1344 Self::NoRange { collection } => write!(
1345 f,
1346 "no range of collection {collection} covers the routed key — re-resolve routing"
1347 ),
1348 Self::NotOwner {
1349 collection,
1350 range_id,
1351 role,
1352 owner,
1353 } => write!(
1354 f,
1355 "this node is {} of {collection}/{range_id}, not its owner — route the write to {owner}",
1356 role.label()
1357 ),
1358 Self::StaleEpoch {
1359 collection,
1360 range_id,
1361 expected,
1362 current,
1363 } => write!(
1364 f,
1365 "stale ownership epoch for {collection}/{range_id}: write authorised under epoch {expected}, current is {current}"
1366 ),
1367 }
1368 }
1369}
1370
1371impl std::error::Error for RangeWriteReject {}
1372
1373#[derive(Debug, Clone, Default)]
1384pub struct ShardOwnershipCatalog {
1385 collections: BTreeMap<CollectionId, ShardKeyMode>,
1390 ranges: BTreeMap<(CollectionId, RangeId), RangeOwnership>,
1391}
1392
1393impl ShardOwnershipCatalog {
1394 pub fn new() -> Self {
1396 Self::default()
1397 }
1398
1399 pub fn declare_collection(
1407 &mut self,
1408 collection: CollectionId,
1409 mode: ShardKeyMode,
1410 ) -> Result<(), CatalogError> {
1411 match self.collections.get(&collection) {
1412 Some(&declared) if declared != mode => Err(CatalogError::ShardKeyModeMismatch {
1413 collection,
1414 declared,
1415 attempted: mode,
1416 }),
1417 _ => {
1418 self.collections.insert(collection, mode);
1419 Ok(())
1420 }
1421 }
1422 }
1423
1424 pub fn shard_key_mode(&self, collection: &CollectionId) -> Option<ShardKeyMode> {
1427 self.collections.get(collection).copied()
1428 }
1429
1430 pub fn apply_update(&mut self, entry: RangeOwnership) -> Result<UpdateOutcome, CatalogError> {
1441 entry.validate_replica_roles()?;
1442
1443 match self.collections.get(entry.collection()) {
1445 Some(&declared) if declared != entry.shard_key_mode() => {
1446 return Err(CatalogError::ShardKeyModeMismatch {
1447 collection: entry.collection().clone(),
1448 declared,
1449 attempted: entry.shard_key_mode(),
1450 });
1451 }
1452 _ => {}
1453 }
1454
1455 let key = entry.key();
1456 match self.ranges.get(&key) {
1457 Some(current) => {
1458 if entry.version() <= current.version() {
1459 return Err(CatalogError::StaleVersion {
1460 collection: entry.collection().clone(),
1461 range_id: entry.range_id(),
1462 current: current.version(),
1463 attempted: entry.version(),
1464 });
1465 }
1466 if let Some(existing) = self.overlapping_sibling(&entry) {
1467 return Err(CatalogError::OverlappingRange {
1468 collection: entry.collection().clone(),
1469 existing,
1470 attempted: entry.range_id(),
1471 });
1472 }
1473 self.collections
1474 .insert(entry.collection().clone(), entry.shard_key_mode());
1475 self.ranges.insert(key, entry);
1476 Ok(UpdateOutcome::Updated)
1477 }
1478 None => {
1479 if let Some(existing) = self.overlapping_sibling(&entry) {
1482 return Err(CatalogError::OverlappingRange {
1483 collection: entry.collection().clone(),
1484 existing,
1485 attempted: entry.range_id(),
1486 });
1487 }
1488 self.collections
1489 .insert(entry.collection().clone(), entry.shard_key_mode());
1490 self.ranges.insert(key, entry);
1491 Ok(UpdateOutcome::Created)
1492 }
1493 }
1494 }
1495
1496 fn overlapping_sibling(&self, entry: &RangeOwnership) -> Option<RangeId> {
1497 self.ranges_for(entry.collection())
1498 .find(|range| {
1499 range.range_id() != entry.range_id() && range.bounds().overlaps(entry.bounds())
1500 })
1501 .map(RangeOwnership::range_id)
1502 }
1503
1504 pub fn range(&self, collection: &CollectionId, range_id: RangeId) -> Option<&RangeOwnership> {
1507 self.ranges.get(&(collection.clone(), range_id))
1508 }
1509
1510 pub fn ranges_for<'a>(
1512 &'a self,
1513 collection: &CollectionId,
1514 ) -> impl Iterator<Item = &'a RangeOwnership> {
1515 let collection = collection.clone();
1516 self.ranges
1517 .iter()
1518 .filter(move |((c, _), _)| *c == collection)
1519 .map(|(_, r)| r)
1520 }
1521
1522 pub fn route(&self, collection: &CollectionId, key: &[u8]) -> Option<&RangeOwnership> {
1527 self.ranges_for(collection)
1528 .find(|r| r.bounds().contains(key))
1529 }
1530
1531 pub fn route_shard_key(
1537 &self,
1538 collection: &CollectionId,
1539 shard_key: &[u8],
1540 ) -> Option<&RangeOwnership> {
1541 match self.shard_key_mode(collection)? {
1542 ShardKeyMode::Ordered => self.route(collection, shard_key),
1543 ShardKeyMode::Hash => {
1544 let range_key = hash_shard_key_to_range_key(shard_key);
1545 self.route(collection, &range_key)
1546 }
1547 }
1548 }
1549
1550 pub fn role_at(
1555 &self,
1556 node: &NodeIdentity,
1557 collection: &CollectionId,
1558 range_id: RangeId,
1559 ) -> Option<RangeRole> {
1560 self.range(collection, range_id)
1561 .map(|range| range.role_of(node))
1562 }
1563
1564 pub fn admit_public_write(
1580 &self,
1581 node: &NodeIdentity,
1582 collection: &CollectionId,
1583 key: &[u8],
1584 expected_epoch: OwnershipEpoch,
1585 ) -> Result<&RangeOwnership, RangeWriteReject> {
1586 let range =
1587 self.route_shard_key(collection, key)
1588 .ok_or_else(|| RangeWriteReject::NoRange {
1589 collection: collection.clone(),
1590 })?;
1591 let role = range.role_of(node);
1592 if !role.may_write_public() {
1593 return Err(RangeWriteReject::NotOwner {
1594 collection: collection.clone(),
1595 range_id: range.range_id(),
1596 role,
1597 owner: range.owner().clone(),
1598 });
1599 }
1600 if expected_epoch != range.epoch() {
1601 return Err(RangeWriteReject::StaleEpoch {
1602 collection: collection.clone(),
1603 range_id: range.range_id(),
1604 expected: expected_epoch,
1605 current: range.epoch(),
1606 });
1607 }
1608 Ok(range)
1609 }
1610
1611 pub fn range_count(&self) -> usize {
1613 self.ranges.len()
1614 }
1615
1616 pub fn entries(&self) -> impl Iterator<Item = &RangeOwnership> {
1620 self.ranges.values()
1621 }
1622}
1623
1624#[cfg(test)]
1625mod tests {
1626 use super::*;
1627
1628 fn collection(name: &str) -> CollectionId {
1629 CollectionId::new(name).unwrap()
1630 }
1631
1632 fn ident(cn: &str) -> NodeIdentity {
1633 NodeIdentity::from_certificate_subject(cn).unwrap()
1634 }
1635
1636 fn bounds(lower: &[u8], upper: &[u8]) -> RangeBounds {
1637 RangeBounds::new(RangeBound::key(lower), RangeBound::key(upper)).unwrap()
1638 }
1639
1640 fn hash_range(coll: &CollectionId, id: u64, bnds: RangeBounds, owner: &str) -> RangeOwnership {
1642 RangeOwnership::establish(
1643 coll.clone(),
1644 RangeId::new(id),
1645 ShardKeyMode::Hash,
1646 bnds,
1647 ident(owner),
1648 [ident("CN=replica-1")],
1649 PlacementMetadata::with_replication_factor(3),
1650 )
1651 }
1652
1653 fn single_hash_slot_bounds(key: &[u8]) -> RangeBounds {
1654 let slot = super::super::slot::hash_shard_key_to_slot(key);
1655 let lower = RangeBound::key(slot.range_key());
1656 let upper = match slot.value().checked_add(1) {
1657 Some(next) if next < super::super::slot::PRODUCTION_HASH_SLOT_COUNT => {
1658 RangeBound::key(super::super::slot::HashSlot::new(next).unwrap().range_key())
1659 }
1660 _ => RangeBound::Max,
1661 };
1662 RangeBounds::new(lower, upper).unwrap()
1663 }
1664
1665 #[test]
1666 fn hot_mirror_behind_commit_watermark_cannot_be_promoted() {
1667 let orders = collection("orders");
1668 let current = hash_range(&orders, 1, RangeBounds::full(), "CN=owner-a");
1669 let candidate = HotMirrorCandidate::new(ident("CN=replica-1"), 99);
1670
1671 assert_eq!(
1672 current.promote_hot_mirror(&candidate, 100),
1673 Err(HotMirrorPromotionRefusal::WatermarkNotCovered {
1674 candidate_lsn: 99,
1675 watermark: 100,
1676 }),
1677 );
1678 }
1679
1680 #[test]
1681 fn hot_mirror_covering_watermark_can_be_selected_for_promotion() {
1682 let orders = collection("orders");
1683 let current = hash_range(&orders, 1, RangeBounds::full(), "CN=owner-a");
1684 let candidate = HotMirrorCandidate::new(ident("CN=replica-1"), 100);
1685
1686 let promotion = current.promote_hot_mirror(&candidate, 100).unwrap();
1687
1688 assert_eq!(promotion.previous(), ¤t);
1689 assert_eq!(promotion.promoted().owner(), &ident("CN=replica-1"));
1690 assert_eq!(promotion.promoted().epoch(), current.epoch().next());
1691 assert_eq!(
1692 promotion.promoted().role_of(&ident("CN=owner-a")),
1693 RangeRole::Replica,
1694 );
1695 }
1696
1697 #[test]
1698 fn hot_mirror_promotion_publishes_epoch_before_durable_writes() {
1699 let orders = collection("orders");
1700 let key = b"order-7";
1701 let current = hash_range(&orders, 1, single_hash_slot_bounds(key), "CN=owner-a");
1702 let old_epoch = current.epoch();
1703 let promotion = current
1704 .promote_hot_mirror(&HotMirrorCandidate::new(ident("CN=replica-1"), 120), 100)
1705 .unwrap();
1706 let new_epoch = promotion.promoted().epoch();
1707 let mut catalog = ShardOwnershipCatalog::new();
1708 catalog.apply_update(current).unwrap();
1709 catalog.apply_update(promotion.promoted().clone()).unwrap();
1710
1711 assert_eq!(
1712 catalog
1713 .admit_public_write(&ident("CN=replica-1"), &orders, key, old_epoch)
1714 .unwrap_err(),
1715 RangeWriteReject::StaleEpoch {
1716 collection: orders.clone(),
1717 range_id: RangeId::new(1),
1718 expected: old_epoch,
1719 current: new_epoch,
1720 },
1721 );
1722 assert!(catalog
1723 .admit_public_write(&ident("CN=replica-1"), &orders, key, new_epoch)
1724 .is_ok());
1725 }
1726
1727 #[test]
1728 fn hot_mirror_promotion_fences_old_owner_by_epoch() {
1729 let orders = collection("orders");
1730 let key = b"order-7";
1731 let current = hash_range(&orders, 1, single_hash_slot_bounds(key), "CN=owner-a");
1732 let old_epoch = current.epoch();
1733 let promotion = current
1734 .promote_hot_mirror(&HotMirrorCandidate::new(ident("CN=replica-1"), 120), 100)
1735 .unwrap();
1736 let mut catalog = ShardOwnershipCatalog::new();
1737 catalog.apply_update(current).unwrap();
1738 catalog.apply_update(promotion.promoted().clone()).unwrap();
1739
1740 assert_eq!(
1741 catalog
1742 .admit_public_write(&ident("CN=owner-a"), &orders, key, old_epoch)
1743 .unwrap_err(),
1744 RangeWriteReject::NotOwner {
1745 collection: orders,
1746 range_id: RangeId::new(1),
1747 role: RangeRole::Replica,
1748 owner: ident("CN=replica-1"),
1749 },
1750 );
1751 }
1752
1753 #[test]
1754 fn empty_catalog_creation() {
1755 let catalog = ShardOwnershipCatalog::new();
1756 assert_eq!(catalog.range_count(), 0);
1757 assert!(catalog.shard_key_mode(&collection("orders")).is_none());
1758 }
1759
1760 #[test]
1761 fn hash_is_the_default_shard_key_mode() {
1762 assert_eq!(ShardKeyMode::default(), ShardKeyMode::Hash);
1765
1766 let mut catalog = ShardOwnershipCatalog::new();
1767 let orders = collection("orders");
1768 catalog
1769 .apply_update(hash_range(&orders, 1, RangeBounds::full(), "CN=node-a"))
1770 .unwrap();
1771 assert_eq!(catalog.shard_key_mode(&orders), Some(ShardKeyMode::Hash));
1772 }
1773
1774 #[test]
1775 fn hash_range_entry_routes_to_owner() {
1776 let mut catalog = ShardOwnershipCatalog::new();
1777 let orders = collection("orders");
1778
1779 catalog
1781 .apply_update(hash_range(
1782 &orders,
1783 1,
1784 RangeBounds::new(RangeBound::Min, RangeBound::key([0x80])).unwrap(),
1785 "CN=node-a",
1786 ))
1787 .unwrap();
1788 catalog
1789 .apply_update(hash_range(
1790 &orders,
1791 2,
1792 RangeBounds::new(RangeBound::key([0x80]), RangeBound::Max).unwrap(),
1793 "CN=node-b",
1794 ))
1795 .unwrap();
1796
1797 assert_eq!(
1799 catalog.route(&orders, &[0x10]).unwrap().owner(),
1800 &ident("CN=node-a")
1801 );
1802 assert_eq!(
1803 catalog.route(&orders, &[0x80]).unwrap().owner(),
1804 &ident("CN=node-b")
1805 );
1806 assert_eq!(
1807 catalog.route(&orders, &[0xff]).unwrap().owner(),
1808 &ident("CN=node-b")
1809 );
1810 let r = catalog.route(&orders, &[0x10]).unwrap();
1812 assert_eq!(r.replicas(), &[ident("CN=replica-1")]);
1813 assert_eq!(r.epoch(), OwnershipEpoch::initial());
1814 }
1815
1816 #[test]
1817 fn hash_mode_routes_logical_shard_key_through_hash_slot() {
1818 let mut catalog = ShardOwnershipCatalog::new();
1819 let orders = collection("orders");
1820 let key = b"tenant:42";
1821 catalog
1822 .apply_update(hash_range(
1823 &orders,
1824 1,
1825 single_hash_slot_bounds(key),
1826 "CN=node-a",
1827 ))
1828 .unwrap();
1829
1830 let routed = catalog
1831 .route_shard_key(&orders, key)
1832 .expect("hash slot range covers the logical shard key");
1833 assert_eq!(routed.owner(), &ident("CN=node-a"));
1834 }
1835
1836 #[test]
1837 fn ordered_mode_can_be_declared_and_routed() {
1838 let mut catalog = ShardOwnershipCatalog::new();
1839 let events = collection("events");
1840 catalog
1841 .declare_collection(events.clone(), ShardKeyMode::Ordered)
1842 .unwrap();
1843 assert_eq!(catalog.shard_key_mode(&events), Some(ShardKeyMode::Ordered));
1844
1845 catalog
1847 .apply_update(RangeOwnership::establish(
1848 events.clone(),
1849 RangeId::new(1),
1850 ShardKeyMode::Ordered,
1851 bounds(b"a", b"m"),
1852 ident("CN=node-a"),
1853 [],
1854 PlacementMetadata::with_replication_factor(3),
1855 ))
1856 .unwrap();
1857 catalog
1858 .apply_update(RangeOwnership::establish(
1859 events.clone(),
1860 RangeId::new(2),
1861 ShardKeyMode::Ordered,
1862 bounds(b"m", b"z"),
1863 ident("CN=node-b"),
1864 [],
1865 PlacementMetadata::with_replication_factor(3),
1866 ))
1867 .unwrap();
1868
1869 assert_eq!(
1870 catalog.route(&events, b"alpha").unwrap().owner(),
1871 &ident("CN=node-a")
1872 );
1873 assert_eq!(
1874 catalog.route(&events, b"mike").unwrap().owner(),
1875 &ident("CN=node-b")
1876 );
1877 assert!(catalog.route(&events, b"zzz").is_none());
1879 }
1880
1881 #[test]
1882 fn declaring_a_conflicting_mode_is_rejected() {
1883 let mut catalog = ShardOwnershipCatalog::new();
1884 let events = collection("events");
1885 catalog
1886 .declare_collection(events.clone(), ShardKeyMode::Ordered)
1887 .unwrap();
1888 catalog
1890 .declare_collection(events.clone(), ShardKeyMode::Ordered)
1891 .unwrap();
1892 let err = catalog
1894 .declare_collection(events.clone(), ShardKeyMode::Hash)
1895 .unwrap_err();
1896 assert_eq!(
1897 err,
1898 CatalogError::ShardKeyModeMismatch {
1899 collection: events.clone(),
1900 declared: ShardKeyMode::Ordered,
1901 attempted: ShardKeyMode::Hash,
1902 }
1903 );
1904 let err = catalog
1906 .apply_update(hash_range(&events, 1, RangeBounds::full(), "CN=node-a"))
1907 .unwrap_err();
1908 assert!(matches!(err, CatalogError::ShardKeyModeMismatch { .. }));
1909 }
1910
1911 #[test]
1912 fn version_bumps_on_owner_transfer_and_epoch_fences() {
1913 let mut catalog = ShardOwnershipCatalog::new();
1914 let orders = collection("orders");
1915 catalog
1916 .apply_update(hash_range(&orders, 1, RangeBounds::full(), "CN=node-a"))
1917 .unwrap();
1918
1919 let current = catalog.range(&orders, RangeId::new(1)).unwrap();
1920 assert_eq!(current.version(), CatalogVersion::initial());
1921 assert_eq!(current.epoch(), OwnershipEpoch::initial());
1922
1923 let moved = current.transfer_to(ident("CN=node-b"), [ident("CN=node-a")]);
1925 let outcome = catalog.apply_update(moved).unwrap();
1926 assert_eq!(outcome, UpdateOutcome::Updated);
1927
1928 let after = catalog.range(&orders, RangeId::new(1)).unwrap();
1929 assert_eq!(after.owner(), &ident("CN=node-b"));
1930 assert_eq!(after.version().value(), 2);
1931 assert_eq!(after.epoch().value(), 2); let replicas_changed = after.update_replicas([ident("CN=node-c")]);
1935 catalog.apply_update(replicas_changed).unwrap();
1936 let after2 = catalog.range(&orders, RangeId::new(1)).unwrap();
1937 assert_eq!(after2.version().value(), 3);
1938 assert_eq!(after2.epoch().value(), 2); assert_eq!(after2.replicas(), &[ident("CN=node-c")]);
1940 }
1941
1942 #[test]
1943 fn stale_update_is_rejected_and_leaves_catalog_unchanged() {
1944 let mut catalog = ShardOwnershipCatalog::new();
1945 let orders = collection("orders");
1946 catalog
1947 .apply_update(hash_range(&orders, 1, RangeBounds::full(), "CN=node-a"))
1948 .unwrap();
1949
1950 let v1 = catalog.range(&orders, RangeId::new(1)).unwrap().clone();
1951 catalog
1953 .apply_update(v1.transfer_to(ident("CN=node-b"), []))
1954 .unwrap();
1955 assert_eq!(
1956 catalog.range(&orders, RangeId::new(1)).unwrap().owner(),
1957 &ident("CN=node-b")
1958 );
1959
1960 let err = catalog.apply_update(v1.clone()).unwrap_err();
1963 assert_eq!(
1964 err,
1965 CatalogError::StaleVersion {
1966 collection: orders.clone(),
1967 range_id: RangeId::new(1),
1968 current: CatalogVersion::initial().next(),
1969 attempted: CatalogVersion::initial(),
1970 }
1971 );
1972 assert_eq!(
1974 catalog.range(&orders, RangeId::new(1)).unwrap().owner(),
1975 &ident("CN=node-b")
1976 );
1977 assert_eq!(
1978 catalog
1979 .range(&orders, RangeId::new(1))
1980 .unwrap()
1981 .version()
1982 .value(),
1983 2
1984 );
1985 }
1986
1987 #[test]
1988 fn overlapping_range_creation_is_rejected() {
1989 let mut catalog = ShardOwnershipCatalog::new();
1990 let orders = collection("orders");
1991 catalog
1992 .apply_update(hash_range(
1993 &orders,
1994 1,
1995 bounds(&[0x00], &[0x80]),
1996 "CN=node-a",
1997 ))
1998 .unwrap();
1999 let err = catalog
2001 .apply_update(hash_range(
2002 &orders,
2003 2,
2004 bounds(&[0x40], &[0xc0]),
2005 "CN=node-b",
2006 ))
2007 .unwrap_err();
2008 assert_eq!(
2009 err,
2010 CatalogError::OverlappingRange {
2011 collection: orders.clone(),
2012 existing: RangeId::new(1),
2013 attempted: RangeId::new(2),
2014 }
2015 );
2016 assert_eq!(catalog.range_count(), 1);
2017 }
2018
2019 #[test]
2020 fn overlapping_range_update_is_rejected() {
2021 let mut catalog = ShardOwnershipCatalog::new();
2022 let orders = collection("orders");
2023 catalog
2024 .apply_update(hash_range(
2025 &orders,
2026 1,
2027 bounds(&[0x00], &[0x80]),
2028 "CN=node-a",
2029 ))
2030 .unwrap();
2031 catalog
2032 .apply_update(hash_range(
2033 &orders,
2034 2,
2035 RangeBounds::new(RangeBound::key([0x80]), RangeBound::Max).unwrap(),
2036 "CN=node-b",
2037 ))
2038 .unwrap();
2039
2040 let widened = catalog
2041 .range(&orders, RangeId::new(1))
2042 .unwrap()
2043 .with_bounds(bounds(&[0x00], &[0xc0]));
2044 let err = catalog.apply_update(widened).unwrap_err();
2045 assert_eq!(
2046 err,
2047 CatalogError::OverlappingRange {
2048 collection: orders.clone(),
2049 existing: RangeId::new(2),
2050 attempted: RangeId::new(1),
2051 }
2052 );
2053 assert_eq!(
2054 catalog.range(&orders, RangeId::new(1)).unwrap().bounds(),
2055 &bounds(&[0x00], &[0x80])
2056 );
2057 }
2058
2059 #[test]
2060 fn catalog_replicates_to_data_members_with_read_visibility() {
2061 let orders = collection("orders");
2064 let mut leader = ShardOwnershipCatalog::new();
2065 let mut data_member = ShardOwnershipCatalog::new();
2066
2067 let create = hash_range(&orders, 1, RangeBounds::full(), "CN=node-a");
2069 leader.apply_update(create.clone()).unwrap();
2070 assert_eq!(
2071 data_member.apply_update(create).unwrap(),
2072 UpdateOutcome::Created
2073 );
2074
2075 assert_eq!(
2077 data_member.route(&orders, b"any-key").unwrap().owner(),
2078 &ident("CN=node-a")
2079 );
2080
2081 let v2 = leader
2083 .range(&orders, RangeId::new(1))
2084 .unwrap()
2085 .transfer_to(ident("CN=node-b"), []);
2086 leader.apply_update(v2.clone()).unwrap();
2087 assert_eq!(
2088 data_member.apply_update(v2.clone()).unwrap(),
2089 UpdateOutcome::Updated
2090 );
2091 assert_eq!(
2092 data_member.route(&orders, b"any-key").unwrap().owner(),
2093 &ident("CN=node-b")
2094 );
2095
2096 let err = data_member.apply_update(v2).unwrap_err();
2099 assert!(matches!(err, CatalogError::StaleVersion { .. }));
2100 assert_eq!(
2101 data_member
2102 .range(&orders, RangeId::new(1))
2103 .unwrap()
2104 .version()
2105 .value(),
2106 2
2107 );
2108 }
2109
2110 #[test]
2111 fn collection_group_scope_is_owned_by_one_placement_authority() {
2112 let group = CollectionGroupId::new("commerce").unwrap();
2113 let assignment = CollectionGroupAuthority::new(
2114 group.clone(),
2115 ident("CN=placement-authority-a"),
2116 [collection("orders"), collection("order_items")],
2117 )
2118 .unwrap();
2119 let mut catalog = PlacementAuthorityCatalog::new();
2120
2121 catalog.assign(assignment.clone()).unwrap();
2122
2123 assert_eq!(
2124 catalog.authority_for_group(&group).unwrap().authority(),
2125 &ident("CN=placement-authority-a")
2126 );
2127 assert_eq!(
2128 catalog
2129 .authority_for_collection(&collection("orders"))
2130 .unwrap(),
2131 &assignment
2132 );
2133
2134 let err = catalog
2135 .assign(
2136 CollectionGroupAuthority::new(
2137 group.clone(),
2138 ident("CN=placement-authority-b"),
2139 [collection("invoices")],
2140 )
2141 .unwrap(),
2142 )
2143 .unwrap_err();
2144 assert_eq!(
2145 err,
2146 PlacementAuthorityError::DuplicateCollectionGroup {
2147 group: group.clone(),
2148 }
2149 );
2150 }
2151
2152 #[test]
2153 fn collection_group_catalog_slices_do_not_overlap() {
2154 let commerce = CollectionGroupId::new("commerce").unwrap();
2155 let analytics = CollectionGroupId::new("analytics-events").unwrap();
2156 let support = CollectionGroupId::new("support").unwrap();
2157 let orders = collection("orders");
2158 let order_items = collection("order_items");
2159 let events = collection("events");
2160 let tickets = collection("tickets");
2161 let mut catalog = PlacementAuthorityCatalog::new();
2162
2163 catalog
2165 .assign(
2166 CollectionGroupAuthority::new(
2167 commerce.clone(),
2168 ident("CN=placement-authority-a"),
2169 [orders.clone(), order_items],
2170 )
2171 .unwrap(),
2172 )
2173 .unwrap();
2174 assert_eq!(
2175 catalog.authority_for_collection(&orders).unwrap().group(),
2176 &commerce
2177 );
2178
2179 catalog
2181 .assign(
2182 CollectionGroupAuthority::new(
2183 analytics.clone(),
2184 ident("CN=placement-authority-b"),
2185 [events.clone()],
2186 )
2187 .unwrap(),
2188 )
2189 .unwrap();
2190 assert_eq!(
2191 catalog.authority_for_collection(&events).unwrap().group(),
2192 &analytics
2193 );
2194
2195 let err = catalog
2196 .assign(
2197 CollectionGroupAuthority::new(
2198 support.clone(),
2199 ident("CN=placement-authority-c"),
2200 [tickets, events.clone()],
2201 )
2202 .unwrap(),
2203 )
2204 .unwrap_err();
2205 assert_eq!(
2206 err,
2207 PlacementAuthorityError::OverlappingCatalogSlice {
2208 collection: events,
2209 existing_group: analytics,
2210 existing_authority: ident("CN=placement-authority-b"),
2211 attempted_group: support,
2212 attempted_authority: ident("CN=placement-authority-c"),
2213 overlap: RangeBounds::full(),
2214 }
2215 );
2216 }
2217
2218 #[test]
2219 fn placement_authority_creation_rejects_overlapping_range_slice() {
2220 let orders = collection("orders");
2221 let commerce = CollectionGroupId::new("commerce").unwrap();
2222 let support = CollectionGroupId::new("support").unwrap();
2223 let mut catalog = PlacementAuthorityCatalog::new();
2224 catalog
2225 .assign(
2226 CollectionGroupAuthority::with_slices(
2227 commerce.clone(),
2228 ident("CN=placement-authority-a"),
2229 [(orders.clone(), bounds(b"a", b"m"))],
2230 )
2231 .unwrap(),
2232 )
2233 .unwrap();
2234
2235 let err = catalog
2236 .assign(
2237 CollectionGroupAuthority::with_slices(
2238 support.clone(),
2239 ident("CN=placement-authority-b"),
2240 [(orders.clone(), bounds(b"h", b"z"))],
2241 )
2242 .unwrap(),
2243 )
2244 .unwrap_err();
2245
2246 assert_eq!(
2247 err,
2248 PlacementAuthorityError::OverlappingCatalogSlice {
2249 collection: orders,
2250 existing_group: commerce,
2251 existing_authority: ident("CN=placement-authority-a"),
2252 attempted_group: support,
2253 attempted_authority: ident("CN=placement-authority-b"),
2254 overlap: bounds(b"h", b"m"),
2255 }
2256 );
2257 }
2258
2259 #[test]
2260 fn placement_authority_split_rejects_overlapping_replacement() {
2261 let orders = collection("orders");
2262 let commerce = CollectionGroupId::new("commerce").unwrap();
2263 let archive = CollectionGroupId::new("archive").unwrap();
2264 let mut catalog = PlacementAuthorityCatalog::new();
2265 catalog
2266 .assign(
2267 CollectionGroupAuthority::with_slices(
2268 commerce.clone(),
2269 ident("CN=placement-authority-a"),
2270 [(orders.clone(), bounds(b"a", b"z"))],
2271 )
2272 .unwrap(),
2273 )
2274 .unwrap();
2275
2276 let err = catalog
2277 .split_collection_group_slice(
2278 &commerce,
2279 CollectionGroupAuthority::with_slices(
2280 commerce.clone(),
2281 ident("CN=placement-authority-a"),
2282 [(orders.clone(), bounds(b"a", b"n"))],
2283 )
2284 .unwrap(),
2285 CollectionGroupAuthority::with_slices(
2286 archive.clone(),
2287 ident("CN=placement-authority-b"),
2288 [(orders.clone(), bounds(b"m", b"z"))],
2289 )
2290 .unwrap(),
2291 )
2292 .unwrap_err();
2293
2294 assert_eq!(
2295 err,
2296 PlacementAuthorityError::OverlappingCatalogSlice {
2297 collection: orders,
2298 existing_group: commerce,
2299 existing_authority: ident("CN=placement-authority-a"),
2300 attempted_group: archive,
2301 attempted_authority: ident("CN=placement-authority-b"),
2302 overlap: bounds(b"m", b"n"),
2303 }
2304 );
2305 }
2306
2307 #[test]
2308 fn placement_authority_merge_rejects_overlap_with_surviving_authority() {
2309 let orders = collection("orders");
2310 let commerce = CollectionGroupId::new("commerce").unwrap();
2311 let archive = CollectionGroupId::new("archive").unwrap();
2312 let support = CollectionGroupId::new("support").unwrap();
2313 let mut catalog = PlacementAuthorityCatalog::new();
2314 catalog
2315 .assign(
2316 CollectionGroupAuthority::with_slices(
2317 commerce.clone(),
2318 ident("CN=placement-authority-a"),
2319 [(orders.clone(), bounds(b"a", b"m"))],
2320 )
2321 .unwrap(),
2322 )
2323 .unwrap();
2324 catalog
2325 .assign(
2326 CollectionGroupAuthority::with_slices(
2327 archive.clone(),
2328 ident("CN=placement-authority-b"),
2329 [(orders.clone(), bounds(b"m", b"t"))],
2330 )
2331 .unwrap(),
2332 )
2333 .unwrap();
2334 catalog
2335 .assign(
2336 CollectionGroupAuthority::with_slices(
2337 support.clone(),
2338 ident("CN=placement-authority-c"),
2339 [(orders.clone(), bounds(b"t", b"z"))],
2340 )
2341 .unwrap(),
2342 )
2343 .unwrap();
2344
2345 let err = catalog
2346 .merge_collection_group_slices(
2347 [&commerce, &archive],
2348 CollectionGroupAuthority::with_slices(
2349 commerce.clone(),
2350 ident("CN=placement-authority-a"),
2351 [(orders.clone(), bounds(b"a", b"u"))],
2352 )
2353 .unwrap(),
2354 )
2355 .unwrap_err();
2356
2357 assert_eq!(
2358 err,
2359 PlacementAuthorityError::OverlappingCatalogSlice {
2360 collection: orders,
2361 existing_group: support,
2362 existing_authority: ident("CN=placement-authority-c"),
2363 attempted_group: commerce,
2364 attempted_authority: ident("CN=placement-authority-a"),
2365 overlap: bounds(b"t", b"u"),
2366 }
2367 );
2368 }
2369
2370 #[test]
2371 fn placement_authority_reassign_rejects_overlapping_target_slice() {
2372 let orders = collection("orders");
2373 let commerce = CollectionGroupId::new("commerce").unwrap();
2374 let archive = CollectionGroupId::new("archive").unwrap();
2375 let mut catalog = PlacementAuthorityCatalog::new();
2376 catalog
2377 .assign(
2378 CollectionGroupAuthority::with_slices(
2379 commerce.clone(),
2380 ident("CN=placement-authority-a"),
2381 [(orders.clone(), bounds(b"a", b"m"))],
2382 )
2383 .unwrap(),
2384 )
2385 .unwrap();
2386 catalog
2387 .assign(
2388 CollectionGroupAuthority::with_slices(
2389 archive.clone(),
2390 ident("CN=placement-authority-b"),
2391 [(orders.clone(), bounds(b"m", b"z"))],
2392 )
2393 .unwrap(),
2394 )
2395 .unwrap();
2396
2397 let err = catalog
2398 .reassign_collection_group_slice(
2399 &commerce,
2400 CollectionGroupAuthority::with_slices(
2401 commerce.clone(),
2402 ident("CN=placement-authority-a"),
2403 [(orders.clone(), bounds(b"a", b"h"))],
2404 )
2405 .unwrap(),
2406 CollectionGroupAuthority::with_slices(
2407 archive.clone(),
2408 ident("CN=placement-authority-b"),
2409 [(orders.clone(), bounds(b"h", b"n"))],
2410 )
2411 .unwrap(),
2412 )
2413 .unwrap_err();
2414
2415 assert_eq!(
2416 err,
2417 PlacementAuthorityError::OverlappingCatalogSlice {
2418 collection: orders,
2419 existing_group: archive.clone(),
2420 existing_authority: ident("CN=placement-authority-b"),
2421 attempted_group: archive,
2422 attempted_authority: ident("CN=placement-authority-b"),
2423 overlap: bounds(b"m", b"n"),
2424 }
2425 );
2426 }
2427
2428 #[test]
2429 fn forced_placement_authority_replacement_still_rejects_overlap() {
2430 let orders = collection("orders");
2431 let commerce = CollectionGroupId::new("commerce").unwrap();
2432 let archive = CollectionGroupId::new("archive").unwrap();
2433 let support = CollectionGroupId::new("support").unwrap();
2434 let mut catalog = PlacementAuthorityCatalog::new();
2435 catalog
2436 .assign(
2437 CollectionGroupAuthority::with_slices(
2438 commerce.clone(),
2439 ident("CN=placement-authority-a"),
2440 [(orders.clone(), bounds(b"a", b"m"))],
2441 )
2442 .unwrap(),
2443 )
2444 .unwrap();
2445 catalog
2446 .assign(
2447 CollectionGroupAuthority::with_slices(
2448 archive.clone(),
2449 ident("CN=placement-authority-b"),
2450 [(orders.clone(), bounds(b"m", b"z"))],
2451 )
2452 .unwrap(),
2453 )
2454 .unwrap();
2455
2456 let err = catalog
2457 .force_replace_assignments(
2458 [&commerce],
2459 [CollectionGroupAuthority::with_slices(
2460 support.clone(),
2461 ident("CN=placement-authority-root"),
2462 [(orders.clone(), bounds(b"h", b"t"))],
2463 )
2464 .unwrap()],
2465 )
2466 .unwrap_err();
2467
2468 assert_eq!(
2469 err,
2470 PlacementAuthorityError::OverlappingCatalogSlice {
2471 collection: orders,
2472 existing_group: archive,
2473 existing_authority: ident("CN=placement-authority-b"),
2474 attempted_group: support,
2475 attempted_authority: ident("CN=placement-authority-root"),
2476 overlap: bounds(b"m", b"t"),
2477 }
2478 );
2479 }
2480
2481 #[test]
2482 fn range_bounds_reject_empty_or_inverted() {
2483 assert!(RangeBounds::new(RangeBound::key([0x10]), RangeBound::key([0x10])).is_err());
2484 assert!(RangeBounds::new(RangeBound::key([0x20]), RangeBound::key([0x10])).is_err());
2485 assert!(RangeBounds::new(RangeBound::Max, RangeBound::Min).is_err());
2486 assert!(RangeBounds::full().contains(b"anything"));
2487 }
2488
2489 fn range_with(
2495 coll: &CollectionId,
2496 id: u64,
2497 bnds: RangeBounds,
2498 owner: &str,
2499 replicas: &[&str],
2500 ) -> RangeOwnership {
2501 RangeOwnership::establish(
2502 coll.clone(),
2503 RangeId::new(id),
2504 ShardKeyMode::Hash,
2505 bnds,
2506 ident(owner),
2507 replicas.iter().map(|r| ident(r)).collect::<Vec<_>>(),
2508 PlacementMetadata::with_replication_factor(3),
2509 )
2510 }
2511
2512 #[test]
2513 fn role_of_distinguishes_owner_replica_and_no_copy() {
2514 let orders = collection("orders");
2515 let range = range_with(&orders, 1, RangeBounds::full(), "CN=node-a", &["CN=node-b"]);
2516
2517 assert_eq!(range.role_of(&ident("CN=node-a")), RangeRole::Owner);
2518 assert_eq!(range.role_of(&ident("CN=node-b")), RangeRole::Replica);
2519 assert_eq!(range.role_of(&ident("CN=node-c")), RangeRole::NoCopy);
2520 assert!(RangeRole::Owner.may_write_public());
2521 assert!(!RangeRole::Replica.may_write_public());
2522 assert!(!RangeRole::NoCopy.may_write_public());
2523 }
2524
2525 #[test]
2526 fn replica_role_metadata_distinguishes_hot_mirror_and_archive() {
2527 let orders = collection("orders");
2528 let range = range_with(&orders, 1, RangeBounds::full(), "CN=node-a", &["CN=node-b"])
2529 .with_compressed_archive_replicas([ident("CN=node-c")]);
2530
2531 assert_eq!(range.hot_mirror_replicas(), &[ident("CN=node-b")]);
2532 assert_eq!(range.compressed_archive_replicas(), &[ident("CN=node-c")]);
2533 assert_eq!(
2534 range.replica_role_of(&ident("CN=node-b")),
2535 Some(ReplicaRole::HotMirror)
2536 );
2537 assert_eq!(
2538 range.replica_role_of(&ident("CN=node-c")),
2539 Some(ReplicaRole::CompressedArchive)
2540 );
2541 assert!(ReplicaRole::HotMirror.is_promotion_candidate());
2542 assert!(!ReplicaRole::CompressedArchive.is_promotion_candidate());
2543 assert!(ReplicaRole::CompressedArchive.is_restore_only());
2544 }
2545
2546 #[test]
2547 fn catalog_rejects_owner_or_duplicate_replica_roles() {
2548 let orders = collection("orders");
2549 let mut catalog = ShardOwnershipCatalog::new();
2550
2551 let owner_also_hot =
2552 range_with(&orders, 1, RangeBounds::full(), "CN=node-a", &["CN=node-a"]);
2553 assert!(matches!(
2554 catalog.apply_update(owner_also_hot).unwrap_err(),
2555 CatalogError::InvalidReplicaRoles { .. }
2556 ));
2557
2558 let owner_also_archive = range_with(&orders, 1, RangeBounds::full(), "CN=node-a", &[])
2559 .with_compressed_archive_replicas([ident("CN=node-a")]);
2560 assert!(matches!(
2561 catalog.apply_update(owner_also_archive).unwrap_err(),
2562 CatalogError::InvalidReplicaRoles { .. }
2563 ));
2564
2565 let duplicate_hot_and_archive =
2566 range_with(&orders, 1, RangeBounds::full(), "CN=node-a", &["CN=node-b"])
2567 .with_compressed_archive_replicas([ident("CN=node-b")]);
2568 assert!(matches!(
2569 catalog.apply_update(duplicate_hot_and_archive).unwrap_err(),
2570 CatalogError::InvalidReplicaRoles { .. }
2571 ));
2572 }
2573
2574 #[test]
2575 fn role_is_per_range_not_a_global_node_role() {
2576 let mut catalog = ShardOwnershipCatalog::new();
2579 let orders = collection("orders");
2580 catalog
2581 .apply_update(range_with(
2582 &orders,
2583 1,
2584 RangeBounds::new(RangeBound::Min, RangeBound::key([0x80])).unwrap(),
2585 "CN=node-a",
2586 &["CN=node-b"],
2587 ))
2588 .unwrap();
2589 catalog
2590 .apply_update(range_with(
2591 &orders,
2592 2,
2593 RangeBounds::new(RangeBound::key([0x80]), RangeBound::Max).unwrap(),
2594 "CN=node-b",
2595 &["CN=node-a"],
2596 ))
2597 .unwrap();
2598
2599 let node_a = ident("CN=node-a");
2600 assert_eq!(
2601 catalog.role_at(&node_a, &orders, RangeId::new(1)),
2602 Some(RangeRole::Owner)
2603 );
2604 assert_eq!(
2605 catalog.role_at(&node_a, &orders, RangeId::new(2)),
2606 Some(RangeRole::Replica)
2607 );
2608 assert_eq!(catalog.role_at(&node_a, &orders, RangeId::new(99)), None);
2610 assert_eq!(
2612 catalog.role_at(&node_a, &collection("ghost"), RangeId::new(1)),
2613 None
2614 );
2615 }
2616
2617 #[test]
2618 fn public_write_admitted_on_owner_at_matching_epoch() {
2619 let mut catalog = ShardOwnershipCatalog::new();
2620 let orders = collection("orders");
2621 catalog
2622 .apply_update(range_with(
2623 &orders,
2624 1,
2625 RangeBounds::full(),
2626 "CN=node-a",
2627 &["CN=node-b"],
2628 ))
2629 .unwrap();
2630
2631 let admitted = catalog
2632 .admit_public_write(
2633 &ident("CN=node-a"),
2634 &orders,
2635 b"k",
2636 OwnershipEpoch::initial(),
2637 )
2638 .expect("owner at current epoch may write");
2639 assert_eq!(admitted.owner(), &ident("CN=node-a"));
2640 assert_eq!(admitted.range_id(), RangeId::new(1));
2641 }
2642
2643 #[test]
2644 fn public_write_uses_hash_slot_routing_for_hash_collections() {
2645 let mut catalog = ShardOwnershipCatalog::new();
2646 let orders = collection("orders");
2647 let key = b"tenant:42";
2648 catalog
2649 .apply_update(hash_range(
2650 &orders,
2651 1,
2652 single_hash_slot_bounds(key),
2653 "CN=node-a",
2654 ))
2655 .unwrap();
2656
2657 let admitted = catalog
2658 .admit_public_write(&ident("CN=node-a"), &orders, key, OwnershipEpoch::initial())
2659 .expect("hash owner admits write routed by shard-key slot");
2660 assert_eq!(admitted.range_id(), RangeId::new(1));
2661 }
2662
2663 #[test]
2664 fn public_write_rejected_on_replica_with_routing_error() {
2665 let mut catalog = ShardOwnershipCatalog::new();
2666 let orders = collection("orders");
2667 catalog
2668 .apply_update(range_with(
2669 &orders,
2670 1,
2671 RangeBounds::full(),
2672 "CN=node-a",
2673 &["CN=node-b"],
2674 ))
2675 .unwrap();
2676
2677 let err = catalog
2680 .admit_public_write(
2681 &ident("CN=node-b"),
2682 &orders,
2683 b"k",
2684 OwnershipEpoch::initial(),
2685 )
2686 .unwrap_err();
2687 match err {
2688 RangeWriteReject::NotOwner {
2689 role, ref owner, ..
2690 } => {
2691 assert_eq!(role, RangeRole::Replica);
2692 assert_eq!(owner, &ident("CN=node-a"));
2693 }
2694 other => panic!("expected NotOwner(Replica), got {other:?}"),
2695 }
2696 assert!(err.to_string().contains("route the write to"));
2698 }
2699
2700 #[test]
2701 fn public_write_rejected_on_no_copy_holder() {
2702 let mut catalog = ShardOwnershipCatalog::new();
2703 let orders = collection("orders");
2704 catalog
2705 .apply_update(range_with(
2706 &orders,
2707 1,
2708 RangeBounds::full(),
2709 "CN=node-a",
2710 &["CN=node-b"],
2711 ))
2712 .unwrap();
2713
2714 let err = catalog
2716 .admit_public_write(
2717 &ident("CN=node-c"),
2718 &orders,
2719 b"k",
2720 OwnershipEpoch::initial(),
2721 )
2722 .unwrap_err();
2723 match err {
2724 RangeWriteReject::NotOwner { role, .. } => assert_eq!(role, RangeRole::NoCopy),
2725 other => panic!("expected NotOwner(NoCopy), got {other:?}"),
2726 }
2727 }
2728
2729 #[test]
2730 fn public_write_rejected_on_stale_ownership_epoch() {
2731 let mut catalog = ShardOwnershipCatalog::new();
2735 let orders = collection("orders");
2736 let v1 = range_with(&orders, 1, RangeBounds::full(), "CN=node-a", &["CN=node-b"]);
2737 let original_epoch = v1.epoch();
2738 catalog.apply_update(v1.clone()).unwrap();
2739
2740 let v2 = v1.transfer_to(ident("CN=node-b"), [ident("CN=node-a")]);
2741 catalog.apply_update(v2.clone()).unwrap();
2742 let v3 = v2.transfer_to(ident("CN=node-a"), [ident("CN=node-b")]);
2743 catalog.apply_update(v3.clone()).unwrap();
2744
2745 assert_ne!(original_epoch, v3.epoch());
2747 let err = catalog
2748 .admit_public_write(&ident("CN=node-a"), &orders, b"k", original_epoch)
2749 .unwrap_err();
2750 match err {
2751 RangeWriteReject::StaleEpoch {
2752 expected, current, ..
2753 } => {
2754 assert_eq!(expected, original_epoch);
2755 assert_eq!(current, v3.epoch());
2756 }
2757 other => panic!("expected StaleEpoch, got {other:?}"),
2758 }
2759 assert!(catalog
2761 .admit_public_write(&ident("CN=node-a"), &orders, b"k", v3.epoch())
2762 .is_ok());
2763 }
2764
2765 #[test]
2766 fn public_write_rejected_when_no_range_covers_the_key() {
2767 let catalog = ShardOwnershipCatalog::new();
2768 let orders = collection("orders");
2769 let err = catalog
2770 .admit_public_write(
2771 &ident("CN=node-a"),
2772 &orders,
2773 b"k",
2774 OwnershipEpoch::initial(),
2775 )
2776 .unwrap_err();
2777 assert!(matches!(err, RangeWriteReject::NoRange { .. }));
2778 }
2779
2780 #[test]
2781 fn internal_apply_path_stays_privileged_for_a_public_write_replica() {
2782 use crate::replication::cdc::{ChangeOperation, ChangeRecord, RangeAuthority};
2787
2788 let mut catalog = ShardOwnershipCatalog::new();
2789 let orders = collection("orders");
2790 catalog
2791 .apply_update(range_with(
2792 &orders,
2793 7,
2794 RangeBounds::full(),
2795 "CN=node-a",
2796 &["CN=node-b"],
2797 ))
2798 .unwrap();
2799
2800 assert!(matches!(
2802 catalog
2803 .admit_public_write(
2804 &ident("CN=node-b"),
2805 &orders,
2806 b"k",
2807 OwnershipEpoch::initial()
2808 )
2809 .unwrap_err(),
2810 RangeWriteReject::NotOwner {
2811 role: RangeRole::Replica,
2812 ..
2813 }
2814 ));
2815
2816 let record = ChangeRecord {
2819 term: 1,
2820 lsn: 1,
2821 timestamp: 0,
2822 operation: ChangeOperation::Insert,
2823 collection: orders.as_str().to_string(),
2824 entity_id: 1,
2825 entity_kind: "row".to_string(),
2826 entity_bytes: Some(vec![1]),
2827 metadata: None,
2828 refresh_records: None,
2829 range_id: None,
2830 ownership_epoch: None,
2831 }
2832 .with_range_authority(7, OwnershipEpoch::initial().value());
2833 let fence = RangeAuthority {
2834 range_id: 7,
2835 min_term: 1,
2836 min_ownership_epoch: OwnershipEpoch::initial().value(),
2837 };
2838 assert!(
2839 fence.admit(&record).is_ok(),
2840 "replica internal apply must remain privileged for the owner's changes"
2841 );
2842 }
2843}