1use std::collections::BTreeMap;
39
40use super::identity::NodeIdentity;
41use super::ownership::{
42 CatalogVersion, CollectionGroupId, CollectionId, OwnershipEpoch, PlacementAuthorityId,
43 RangeBounds, RangeId, RangeOwnership, ReplicaRole, ShardKeyMode, ShardOwnershipCatalog,
44};
45use super::routing::RoutingHint;
46use super::slot::hash_shard_key_to_range_key;
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum TopologyAuthority {
51 RoutingMetadataOnly,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct TopologyRange {
66 collection: CollectionId,
67 collection_group: CollectionGroupId,
68 placement_authority: PlacementAuthorityId,
69 range_id: RangeId,
70 shard_key_mode: ShardKeyMode,
71 bounds: RangeBounds,
72 owner: NodeIdentity,
73 replicas: Vec<NodeIdentity>,
74 compressed_archive_replicas: Vec<NodeIdentity>,
75 epoch: OwnershipEpoch,
76 version: CatalogVersion,
77}
78
79impl TopologyRange {
80 fn from_ownership(range: &RangeOwnership) -> Self {
81 Self {
82 collection: range.collection().clone(),
83 collection_group: range.collection_group().clone(),
84 placement_authority: range.placement_authority().clone(),
85 range_id: range.range_id(),
86 shard_key_mode: range.shard_key_mode(),
87 bounds: range.bounds().clone(),
88 owner: range.owner().clone(),
89 replicas: range.hot_mirror_replicas().to_vec(),
90 compressed_archive_replicas: range.compressed_archive_replicas().to_vec(),
91 epoch: range.epoch(),
92 version: range.version(),
93 }
94 }
95
96 pub fn collection(&self) -> &CollectionId {
97 &self.collection
98 }
99
100 pub fn collection_group(&self) -> &CollectionGroupId {
101 &self.collection_group
102 }
103
104 pub fn placement_authority(&self) -> &PlacementAuthorityId {
105 &self.placement_authority
106 }
107
108 pub fn range_id(&self) -> RangeId {
109 self.range_id
110 }
111
112 pub fn shard_key_mode(&self) -> ShardKeyMode {
113 self.shard_key_mode
114 }
115
116 pub fn bounds(&self) -> &RangeBounds {
117 &self.bounds
118 }
119
120 pub fn owner(&self) -> &NodeIdentity {
121 &self.owner
122 }
123
124 pub fn range_owner(&self) -> &NodeIdentity {
125 &self.owner
126 }
127
128 pub fn replicas(&self) -> &[NodeIdentity] {
129 &self.replicas
130 }
131
132 pub fn hot_mirror_replicas(&self) -> &[NodeIdentity] {
133 &self.replicas
134 }
135
136 pub fn compressed_archive_replicas(&self) -> &[NodeIdentity] {
137 &self.compressed_archive_replicas
138 }
139
140 pub fn replica_role_of(&self, node: &NodeIdentity) -> Option<ReplicaRole> {
141 if self.replicas.iter().any(|replica| replica == node) {
142 Some(ReplicaRole::HotMirror)
143 } else if self
144 .compressed_archive_replicas
145 .iter()
146 .any(|replica| replica == node)
147 {
148 Some(ReplicaRole::CompressedArchive)
149 } else {
150 None
151 }
152 }
153
154 pub fn promotion_candidates(&self) -> &[NodeIdentity] {
155 &self.replicas
156 }
157
158 pub fn epoch(&self) -> OwnershipEpoch {
162 self.epoch
163 }
164
165 pub fn version(&self) -> CatalogVersion {
166 self.version
167 }
168
169 fn key(&self) -> (CollectionId, RangeId) {
170 (self.collection.clone(), self.range_id)
171 }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct TopologySnapshot {
185 version: CatalogVersion,
186 ranges: Vec<TopologyRange>,
187}
188
189impl TopologySnapshot {
190 pub fn authority(&self) -> TopologyAuthority {
192 TopologyAuthority::RoutingMetadataOnly
193 }
194
195 pub fn version(&self) -> CatalogVersion {
197 self.version
198 }
199
200 pub fn ranges(&self) -> &[TopologyRange] {
202 &self.ranges
203 }
204
205 pub fn range(&self, collection: &CollectionId, range_id: RangeId) -> Option<&TopologyRange> {
207 self.ranges
208 .iter()
209 .find(|r| r.collection() == collection && r.range_id() == range_id)
210 }
211
212 pub fn route(&self, collection: &CollectionId, key: &[u8]) -> Option<&TopologyRange> {
215 self.ranges
216 .iter()
217 .find(|r| r.collection() == collection && r.bounds().contains(key))
218 }
219
220 pub fn route_shard_key(
222 &self,
223 collection: &CollectionId,
224 shard_key: &[u8],
225 ) -> Option<&TopologyRange> {
226 match self.shard_key_mode(collection)? {
227 ShardKeyMode::Ordered => self.route(collection, shard_key),
228 ShardKeyMode::Hash => {
229 let range_key = hash_shard_key_to_range_key(shard_key);
230 self.route(collection, &range_key)
231 }
232 }
233 }
234
235 fn shard_key_mode(&self, collection: &CollectionId) -> Option<ShardKeyMode> {
236 self.ranges
237 .iter()
238 .find(|range| range.collection() == collection)
239 .map(TopologyRange::shard_key_mode)
240 }
241}
242
243impl ShardOwnershipCatalog {
244 pub fn topology_snapshot(&self) -> TopologySnapshot {
252 let ranges: Vec<TopologyRange> =
253 self.entries().map(TopologyRange::from_ownership).collect();
254 let version = ranges
255 .iter()
256 .map(TopologyRange::version)
257 .max()
258 .unwrap_or_else(CatalogVersion::initial);
259 TopologySnapshot { version, ranges }
260 }
261}
262
263#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265pub enum RefreshOutcome {
266 Applied { ranges_changed: usize },
269 Ignored,
272}
273
274impl RefreshOutcome {
275 pub fn was_applied(self) -> bool {
276 matches!(self, RefreshOutcome::Applied { .. })
277 }
278}
279
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum HintOutcome {
284 Corrected,
287 AlreadyCurrent,
290 UnknownRange,
295}
296
297#[derive(Debug, Clone, PartialEq, Eq)]
303pub enum TopologyUpdate {
304 Full(TopologySnapshot),
306 Range(TopologyRange),
308}
309
310#[derive(Debug, Clone, PartialEq, Eq)]
324pub struct ClientTopology {
325 version: CatalogVersion,
326 ranges: BTreeMap<(CollectionId, RangeId), TopologyRange>,
327 needs_refresh: bool,
328}
329
330impl ClientTopology {
331 pub fn from_snapshot(snapshot: TopologySnapshot) -> Self {
333 let mut cache = Self {
334 version: snapshot.version(),
335 ranges: BTreeMap::new(),
336 needs_refresh: false,
337 };
338 for range in snapshot.ranges {
339 cache.ranges.insert(range.key(), range);
340 }
341 cache
342 }
343
344 pub fn version(&self) -> CatalogVersion {
348 self.version
349 }
350
351 pub fn needs_refresh(&self) -> bool {
356 self.needs_refresh
357 }
358
359 pub fn route(&self, collection: &CollectionId, key: &[u8]) -> Option<&TopologyRange> {
361 self.ranges
362 .values()
363 .find(|r| r.collection() == collection && r.bounds().contains(key))
364 }
365
366 pub fn resolve(&self, collection: &CollectionId, key: &[u8]) -> Option<&NodeIdentity> {
368 self.route_shard_key(collection, key)
369 .map(TopologyRange::owner)
370 }
371
372 pub fn route_shard_key(
374 &self,
375 collection: &CollectionId,
376 shard_key: &[u8],
377 ) -> Option<&TopologyRange> {
378 match self.shard_key_mode(collection)? {
379 ShardKeyMode::Ordered => self.route(collection, shard_key),
380 ShardKeyMode::Hash => {
381 let range_key = hash_shard_key_to_range_key(shard_key);
382 self.route(collection, &range_key)
383 }
384 }
385 }
386
387 fn shard_key_mode(&self, collection: &CollectionId) -> Option<ShardKeyMode> {
388 self.ranges
389 .values()
390 .find(|range| range.collection() == collection)
391 .map(TopologyRange::shard_key_mode)
392 }
393
394 pub fn range(&self, collection: &CollectionId, range_id: RangeId) -> Option<&TopologyRange> {
396 self.ranges.get(&(collection.clone(), range_id))
397 }
398
399 pub fn apply_refresh(&mut self, snapshot: TopologySnapshot) -> RefreshOutcome {
408 if !self.ranges.is_empty() && snapshot.version() < self.version {
409 return RefreshOutcome::Ignored;
410 }
411 if self.snapshot_rolls_back_any_range(&snapshot) {
412 return RefreshOutcome::Ignored;
413 }
414 let mut changed = 0usize;
415 let mut next: BTreeMap<(CollectionId, RangeId), TopologyRange> = BTreeMap::new();
416 for range in snapshot.ranges {
417 let key = range.key();
418 if self.ranges.get(&key) != Some(&range) {
419 changed += 1;
420 }
421 next.insert(key, range);
422 }
423 if !self.ranges.is_empty() && snapshot.version <= self.version && changed == 0 {
424 return RefreshOutcome::Ignored;
425 }
426 self.ranges = next;
427 self.version = snapshot.version;
428 self.needs_refresh = false;
429 RefreshOutcome::Applied {
430 ranges_changed: changed,
431 }
432 }
433
434 fn snapshot_rolls_back_any_range(&self, snapshot: &TopologySnapshot) -> bool {
435 snapshot.ranges().iter().any(|incoming| {
436 self.ranges
437 .get(&incoming.key())
438 .is_some_and(|current| incoming.version() < current.version())
439 })
440 }
441
442 pub fn apply_update(&mut self, update: TopologyUpdate) -> RefreshOutcome {
452 match update {
453 TopologyUpdate::Full(snapshot) => self.apply_refresh(snapshot),
454 TopologyUpdate::Range(range) => {
455 let key = range.key();
456 let newer = match self.ranges.get(&key) {
457 Some(current) => range.version() > current.version(),
458 None => true,
459 };
460 if !newer {
461 return RefreshOutcome::Ignored;
462 }
463 if range.version() > self.version {
464 self.version = range.version();
465 }
466 self.ranges.insert(key, range);
467 RefreshOutcome::Applied { ranges_changed: 1 }
468 }
469 }
470 }
471
472 pub fn apply_hint(&mut self, hint: &RoutingHint) -> HintOutcome {
483 let key = (hint.collection().clone(), hint.range_id());
484 match self.ranges.get_mut(&key) {
485 Some(range) => {
486 if hint.version() <= range.version {
487 return HintOutcome::AlreadyCurrent;
488 }
489 range.owner = hint.owner().clone();
490 range.epoch = hint.epoch();
491 range.version = hint.version();
492 self.needs_refresh = true;
493 HintOutcome::Corrected
494 }
495 None => {
496 self.needs_refresh = true;
497 HintOutcome::UnknownRange
498 }
499 }
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506 use crate::cluster::ownership::{PlacementMetadata, RangeBound, ShardKeyMode};
507 use crate::cluster::routing::{RequestOperation, RouteDecision, RoutedRequest, RoutingPolicy};
508
509 fn collection(name: &str) -> CollectionId {
510 CollectionId::new(name).unwrap()
511 }
512
513 fn ident(cn: &str) -> NodeIdentity {
514 NodeIdentity::from_certificate_subject(cn).unwrap()
515 }
516
517 fn full_range(coll: &CollectionId, id: u64, owner: &str, replicas: &[&str]) -> RangeOwnership {
518 RangeOwnership::establish(
519 coll.clone(),
520 RangeId::new(id),
521 ShardKeyMode::Hash,
522 RangeBounds::full(),
523 ident(owner),
524 replicas.iter().map(|r| ident(r)).collect::<Vec<_>>(),
525 PlacementMetadata::with_replication_factor(3),
526 )
527 }
528
529 fn split_range(
530 coll: &CollectionId,
531 id: u64,
532 lower: RangeBound,
533 upper: RangeBound,
534 owner: &str,
535 ) -> RangeOwnership {
536 RangeOwnership::establish(
537 coll.clone(),
538 RangeId::new(id),
539 ShardKeyMode::Ordered,
540 RangeBounds::new(lower, upper).unwrap(),
541 ident(owner),
542 Vec::<NodeIdentity>::new(),
543 PlacementMetadata::with_replication_factor(1),
544 )
545 }
546
547 fn single_hash_slot_bounds(key: &[u8]) -> RangeBounds {
548 let slot = super::super::slot::hash_shard_key_to_slot(key);
549 let lower = RangeBound::key(slot.range_key());
550 let upper = match slot.value().checked_add(1) {
551 Some(next) if next < super::super::slot::PRODUCTION_HASH_SLOT_COUNT => {
552 RangeBound::key(super::super::slot::HashSlot::new(next).unwrap().range_key())
553 }
554 _ => RangeBound::Max,
555 };
556 RangeBounds::new(lower, upper).unwrap()
557 }
558
559 fn hash_slot_range(
560 coll: &CollectionId,
561 id: u64,
562 shard_key: &[u8],
563 owner: &str,
564 ) -> RangeOwnership {
565 RangeOwnership::establish(
566 coll.clone(),
567 RangeId::new(id),
568 ShardKeyMode::Hash,
569 single_hash_slot_bounds(shard_key),
570 ident(owner),
571 Vec::<NodeIdentity>::new(),
572 PlacementMetadata::with_replication_factor(1),
573 )
574 }
575
576 fn catalog_with(ranges: impl IntoIterator<Item = RangeOwnership>) -> ShardOwnershipCatalog {
577 let mut catalog = ShardOwnershipCatalog::new();
578 for range in ranges {
579 catalog.apply_update(range).unwrap();
580 }
581 catalog
582 }
583
584 #[test]
587 fn snapshot_exposes_routing_metadata_for_direct_routing() {
588 let orders = collection("orders");
589 let catalog = catalog_with([full_range(&orders, 1, "CN=node-a", &["CN=node-b"])]);
590
591 let snapshot = catalog.topology_snapshot();
592 assert_eq!(snapshot.version(), CatalogVersion::initial());
593 assert_eq!(snapshot.ranges().len(), 1);
594
595 let range = snapshot
596 .route(&orders, b"any-key")
597 .expect("full range covers all keys");
598 assert_eq!(range.owner(), &ident("CN=node-a"));
599 assert_eq!(range.replicas(), &[ident("CN=node-b")]);
600 assert_eq!(range.epoch(), OwnershipEpoch::initial());
601 assert_eq!(range.range_id(), RangeId::new(1));
602 }
603
604 #[test]
605 fn snapshot_exposes_explicit_replica_roles() {
606 let orders = collection("orders");
607 let catalog = catalog_with([full_range(&orders, 1, "CN=node-a", &["CN=node-b"])
608 .with_collection_group(CollectionGroupId::new("commerce").unwrap())
609 .with_placement_authority(PlacementAuthorityId::new("pa-commerce-1").unwrap())
610 .with_compressed_archive_replicas([ident("CN=node-c")])]);
611
612 let snapshot = catalog.topology_snapshot();
613 assert_eq!(snapshot.authority(), TopologyAuthority::RoutingMetadataOnly);
614 let range = snapshot
615 .route(&orders, b"any-key")
616 .expect("full range covers all keys");
617
618 assert_eq!(range.owner(), &ident("CN=node-a"));
619 assert_eq!(range.range_owner(), &ident("CN=node-a"));
620 assert_eq!(
621 range.collection_group(),
622 &CollectionGroupId::new("commerce").unwrap()
623 );
624 assert_eq!(
625 range.placement_authority(),
626 &PlacementAuthorityId::new("pa-commerce-1").unwrap()
627 );
628 assert_eq!(range.hot_mirror_replicas(), &[ident("CN=node-b")]);
629 assert_eq!(range.compressed_archive_replicas(), &[ident("CN=node-c")]);
630 assert_eq!(range.promotion_candidates(), &[ident("CN=node-b")]);
631 assert_eq!(
632 range.replica_role_of(&ident("CN=node-b")),
633 Some(ReplicaRole::HotMirror)
634 );
635 assert_eq!(
636 range.replica_role_of(&ident("CN=node-c")),
637 Some(ReplicaRole::CompressedArchive)
638 );
639 }
640
641 #[test]
642 fn serving_graph_reflects_ownership_transitions_and_replica_roles() {
643 let orders = collection("orders");
644 let range = full_range(&orders, 1, "CN=node-a", &["CN=node-b"])
645 .with_collection_group(CollectionGroupId::new("commerce").unwrap())
646 .with_placement_authority(PlacementAuthorityId::new("pa-commerce-1").unwrap())
647 .with_compressed_archive_replicas([ident("CN=node-c")]);
648 let mut catalog = catalog_with([range]);
649
650 let current = catalog.range(&orders, RangeId::new(1)).unwrap().clone();
651 catalog
652 .apply_update(current.transfer_to(ident("CN=node-b"), [ident("CN=node-a")]))
653 .unwrap();
654 let current = catalog.range(&orders, RangeId::new(1)).unwrap().clone();
655 catalog
656 .apply_update(current.update_replica_roles([ident("CN=node-a")], [ident("CN=node-d")]))
657 .unwrap();
658
659 let projected = catalog
660 .topology_snapshot()
661 .range(&orders, RangeId::new(1))
662 .unwrap()
663 .clone();
664
665 assert_eq!(projected.range_owner(), &ident("CN=node-b"));
666 assert_eq!(projected.hot_mirror_replicas(), &[ident("CN=node-a")]);
667 assert_eq!(
668 projected.compressed_archive_replicas(),
669 &[ident("CN=node-d")]
670 );
671 assert_eq!(
672 projected.placement_authority(),
673 &PlacementAuthorityId::new("pa-commerce-1").unwrap()
674 );
675 }
676
677 #[test]
680 fn snapshot_routes_keys_to_distinct_owners() {
681 let parts = collection("parts");
682 let catalog = catalog_with([
683 split_range(
684 &parts,
685 1,
686 RangeBound::Min,
687 RangeBound::key(b"m"),
688 "CN=node-a",
689 ),
690 split_range(
691 &parts,
692 2,
693 RangeBound::key(b"m"),
694 RangeBound::Max,
695 "CN=node-b",
696 ),
697 ]);
698 let snapshot = catalog.topology_snapshot();
699
700 assert_eq!(
701 snapshot.route(&parts, b"apple").unwrap().owner(),
702 &ident("CN=node-a")
703 );
704 assert_eq!(
705 snapshot.route(&parts, b"zebra").unwrap().owner(),
706 &ident("CN=node-b")
707 );
708 }
709
710 #[test]
712 fn client_resolves_owner_from_polled_snapshot() {
713 let orders = collection("orders");
714 let catalog = catalog_with([full_range(&orders, 1, "CN=node-a", &[])]);
715 let client = ClientTopology::from_snapshot(catalog.topology_snapshot());
716
717 assert_eq!(client.resolve(&orders, b"k").unwrap(), &ident("CN=node-a"));
718 assert!(!client.needs_refresh());
719 }
720
721 #[test]
722 fn client_resolves_hash_collection_by_shard_key_slot() {
723 let orders = collection("orders");
724 let key = b"tenant:42";
725 let catalog = catalog_with([hash_slot_range(&orders, 1, key, "CN=node-a")]);
726 let client = ClientTopology::from_snapshot(catalog.topology_snapshot());
727
728 let routed = client
729 .route_shard_key(&orders, key)
730 .expect("hash slot range covers the logical shard key");
731 assert_eq!(routed.owner(), &ident("CN=node-a"));
732 assert_eq!(client.resolve(&orders, key).unwrap(), &ident("CN=node-a"));
733 }
734
735 #[test]
738 fn refresh_is_monotonic() {
739 let orders = collection("orders");
740 let mut catalog = catalog_with([full_range(&orders, 1, "CN=node-a", &["CN=node-b"])]);
741 let mut client = ClientTopology::from_snapshot(catalog.topology_snapshot());
742 let v1 = client.version();
743
744 let r = catalog.range(&orders, RangeId::new(1)).unwrap().clone();
746 catalog
747 .apply_update(r.transfer_to(ident("CN=node-b"), [ident("CN=node-a")]))
748 .unwrap();
749 let fresh = catalog.topology_snapshot();
750 assert!(fresh.version() > v1);
751
752 assert_eq!(
753 client.apply_refresh(fresh.clone()),
754 RefreshOutcome::Applied { ranges_changed: 1 }
755 );
756 assert_eq!(client.resolve(&orders, b"k").unwrap(), &ident("CN=node-b"));
757
758 assert_eq!(client.apply_refresh(fresh), RefreshOutcome::Ignored);
760 }
761
762 #[test]
763 fn refresh_applies_same_generation_snapshot_when_another_range_changed() {
764 let parts = collection("parts");
765 let mut catalog = catalog_with([
766 split_range(
767 &parts,
768 1,
769 RangeBound::Min,
770 RangeBound::key(b"m"),
771 "CN=node-a",
772 ),
773 split_range(
774 &parts,
775 2,
776 RangeBound::key(b"m"),
777 RangeBound::Max,
778 "CN=node-b",
779 ),
780 ]);
781 let mut client = ClientTopology::from_snapshot(catalog.topology_snapshot());
782
783 let r1 = catalog.range(&parts, RangeId::new(1)).unwrap().clone();
784 catalog
785 .apply_update(r1.transfer_to(ident("CN=node-c"), Vec::<NodeIdentity>::new()))
786 .unwrap();
787 assert_eq!(
788 client.apply_refresh(catalog.topology_snapshot()),
789 RefreshOutcome::Applied { ranges_changed: 1 }
790 );
791 assert_eq!(
792 client.resolve(&parts, b"apple").unwrap(),
793 &ident("CN=node-c")
794 );
795
796 let r2 = catalog.range(&parts, RangeId::new(2)).unwrap().clone();
797 catalog
798 .apply_update(r2.transfer_to(ident("CN=node-d"), Vec::<NodeIdentity>::new()))
799 .unwrap();
800 let same_generation = catalog.topology_snapshot();
801 assert_eq!(same_generation.version(), client.version());
802
803 assert_eq!(
804 client.apply_refresh(same_generation),
805 RefreshOutcome::Applied { ranges_changed: 1 }
806 );
807 assert_eq!(
808 client.resolve(&parts, b"zebra").unwrap(),
809 &ident("CN=node-d")
810 );
811 }
812
813 #[test]
814 fn equal_generation_refresh_does_not_roll_back_a_newer_range() {
815 let parts = collection("parts");
816 let base = catalog_with([
817 split_range(
818 &parts,
819 1,
820 RangeBound::Min,
821 RangeBound::key(b"m"),
822 "CN=node-a",
823 ),
824 split_range(
825 &parts,
826 2,
827 RangeBound::key(b"m"),
828 RangeBound::Max,
829 "CN=node-b",
830 ),
831 ]);
832 let mut current_catalog = base.clone();
833 let mut stale_fork = base;
834 let mut client = ClientTopology::from_snapshot(current_catalog.topology_snapshot());
835
836 let r1 = current_catalog
837 .range(&parts, RangeId::new(1))
838 .unwrap()
839 .clone();
840 current_catalog
841 .apply_update(r1.transfer_to(ident("CN=node-c"), Vec::<NodeIdentity>::new()))
842 .unwrap();
843 assert!(client
844 .apply_refresh(current_catalog.topology_snapshot())
845 .was_applied());
846
847 let r2 = stale_fork.range(&parts, RangeId::new(2)).unwrap().clone();
848 stale_fork
849 .apply_update(r2.transfer_to(ident("CN=node-d"), Vec::<NodeIdentity>::new()))
850 .unwrap();
851 let fork_snapshot = stale_fork.topology_snapshot();
852 assert_eq!(fork_snapshot.version(), client.version());
853
854 assert_eq!(client.apply_refresh(fork_snapshot), RefreshOutcome::Ignored);
855 assert_eq!(
856 client.resolve(&parts, b"apple").unwrap(),
857 &ident("CN=node-c")
858 );
859 assert_eq!(
860 client.resolve(&parts, b"zebra").unwrap(),
861 &ident("CN=node-b")
862 );
863 }
864
865 #[test]
868 fn redirect_hint_corrects_cache_but_is_not_authoritative() {
869 let orders = collection("orders");
870 let mut catalog = catalog_with([full_range(&orders, 1, "CN=node-a", &["CN=node-b"])]);
871 let mut client = ClientTopology::from_snapshot(catalog.topology_snapshot());
872
873 let r = catalog.range(&orders, RangeId::new(1)).unwrap().clone();
875 catalog
876 .apply_update(r.transfer_to(ident("CN=node-b"), [ident("CN=node-a")]))
877 .unwrap();
878
879 let stale_owner = client.resolve(&orders, b"k").unwrap().clone();
881 assert_eq!(stale_owner, ident("CN=node-a"));
882 let request =
883 RoutedRequest::new(orders.clone(), b"k".to_vec(), RequestOperation::Transaction);
884 let hint = match catalog.plan_route(&stale_owner, &request, &RoutingPolicy::forwarding()) {
885 RouteDecision::Redirect { hint, .. } => hint,
886 other => panic!("expected redirect, got {other:?}"),
887 };
888
889 assert_eq!(client.apply_hint(&hint), HintOutcome::Corrected);
891 assert_eq!(client.resolve(&orders, b"k").unwrap(), &ident("CN=node-b"));
892 assert!(
893 client.needs_refresh(),
894 "a hint is advisory, not authoritative"
895 );
896
897 assert!(client
899 .apply_refresh(catalog.topology_snapshot())
900 .was_applied());
901 assert!(!client.needs_refresh());
902 let range = client.range(&orders, RangeId::new(1)).unwrap();
904 assert_eq!(range.replicas(), &[ident("CN=node-a")]);
905 }
906
907 #[test]
910 fn hint_for_unknown_range_does_not_invent_topology() {
911 let orders = collection("orders");
912 let other = collection("other");
913 let catalog = catalog_with([full_range(&orders, 1, "CN=node-a", &[])]);
914 let mut client = ClientTopology::from_snapshot(catalog.topology_snapshot());
915
916 let foreign = catalog_with([full_range(&other, 9, "CN=node-z", &[])]);
918 let request =
919 RoutedRequest::new(other.clone(), b"k".to_vec(), RequestOperation::Transaction);
920 let hint = foreign
921 .plan_route(&ident("CN=node-b"), &request, &RoutingPolicy::forwarding())
922 .hint()
923 .cloned()
924 .unwrap();
925
926 assert_eq!(client.apply_hint(&hint), HintOutcome::UnknownRange);
927 assert!(
928 client.range(&other, RangeId::new(9)).is_none(),
929 "no phantom range"
930 );
931 assert!(client.needs_refresh());
932 }
933
934 #[test]
937 fn stale_hint_is_ignored_after_authoritative_catch_up() {
938 let orders = collection("orders");
939 let mut catalog = catalog_with([full_range(&orders, 1, "CN=node-a", &["CN=node-b"])]);
940 let request =
941 RoutedRequest::new(orders.clone(), b"k".to_vec(), RequestOperation::Transaction);
942 let early_hint = catalog
944 .plan_route(&ident("CN=node-b"), &request, &RoutingPolicy::forwarding())
945 .hint()
946 .cloned()
947 .unwrap();
948
949 let r = catalog.range(&orders, RangeId::new(1)).unwrap().clone();
951 catalog
952 .apply_update(r.transfer_to(ident("CN=node-b"), [ident("CN=node-a")]))
953 .unwrap();
954 let mut client = ClientTopology::from_snapshot(catalog.topology_snapshot());
955
956 assert_eq!(client.apply_hint(&early_hint), HintOutcome::AlreadyCurrent);
958 assert_eq!(client.resolve(&orders, b"k").unwrap(), &ident("CN=node-b"));
959 assert!(!client.needs_refresh());
960 }
961
962 #[test]
964 fn push_full_snapshot_applies_like_a_poll() {
965 let orders = collection("orders");
966 let mut catalog = catalog_with([full_range(&orders, 1, "CN=node-a", &[])]);
967 let mut client = ClientTopology::from_snapshot(catalog.topology_snapshot());
968
969 let r = catalog.range(&orders, RangeId::new(1)).unwrap().clone();
970 catalog
971 .apply_update(r.transfer_to(ident("CN=node-b"), [ident("CN=node-a")]))
972 .unwrap();
973 let update = TopologyUpdate::Full(catalog.topology_snapshot());
974
975 assert!(client.apply_update(update).was_applied());
976 assert_eq!(client.resolve(&orders, b"k").unwrap(), &ident("CN=node-b"));
977 }
978
979 #[test]
981 fn push_range_delta_advances_one_range() {
982 let parts = collection("parts");
983 let mut catalog = catalog_with([
984 split_range(
985 &parts,
986 1,
987 RangeBound::Min,
988 RangeBound::key(b"m"),
989 "CN=node-a",
990 ),
991 split_range(
992 &parts,
993 2,
994 RangeBound::key(b"m"),
995 RangeBound::Max,
996 "CN=node-b",
997 ),
998 ]);
999 let mut client = ClientTopology::from_snapshot(catalog.topology_snapshot());
1000
1001 let r2 = catalog.range(&parts, RangeId::new(2)).unwrap().clone();
1003 catalog
1004 .apply_update(r2.transfer_to(ident("CN=node-c"), Vec::<NodeIdentity>::new()))
1005 .unwrap();
1006 let moved = catalog
1007 .topology_snapshot()
1008 .range(&parts, RangeId::new(2))
1009 .unwrap()
1010 .clone();
1011
1012 assert_eq!(
1013 client.apply_update(TopologyUpdate::Range(moved)),
1014 RefreshOutcome::Applied { ranges_changed: 1 }
1015 );
1016 assert_eq!(
1018 client.resolve(&parts, b"apple").unwrap(),
1019 &ident("CN=node-a")
1020 );
1021 assert_eq!(
1022 client.resolve(&parts, b"zebra").unwrap(),
1023 &ident("CN=node-c")
1024 );
1025 }
1026
1027 #[test]
1031 fn missed_push_still_converges_via_hint_and_poll() {
1032 let orders = collection("orders");
1033 let mut catalog = catalog_with([full_range(&orders, 1, "CN=node-a", &["CN=node-b"])]);
1034 let mut client = ClientTopology::from_snapshot(catalog.topology_snapshot());
1035
1036 let r = catalog.range(&orders, RangeId::new(1)).unwrap().clone();
1039 catalog
1040 .apply_update(r.transfer_to(ident("CN=node-b"), [ident("CN=node-a")]))
1041 .unwrap();
1042 let _dropped_push = TopologyUpdate::Full(catalog.topology_snapshot());
1043
1044 assert_eq!(client.resolve(&orders, b"k").unwrap(), &ident("CN=node-a"));
1046
1047 let request =
1049 RoutedRequest::new(orders.clone(), b"k".to_vec(), RequestOperation::Transaction);
1050 let hint = catalog
1051 .plan_route(&ident("CN=node-a"), &request, &RoutingPolicy::forwarding())
1052 .hint()
1053 .cloned()
1054 .unwrap();
1055 assert_eq!(client.apply_hint(&hint), HintOutcome::Corrected);
1056 assert_eq!(client.resolve(&orders, b"k").unwrap(), &ident("CN=node-b"));
1057 assert!(client.needs_refresh());
1058
1059 assert!(client
1061 .apply_refresh(catalog.topology_snapshot())
1062 .was_applied());
1063 assert!(!client.needs_refresh());
1064 }
1065
1066 #[test]
1069 fn out_of_order_push_keeps_newest() {
1070 let orders = collection("orders");
1071 let mut catalog = catalog_with([full_range(&orders, 1, "CN=node-a", &["CN=node-b"])]);
1072 let mut client = ClientTopology::from_snapshot(catalog.topology_snapshot());
1073
1074 let r1 = catalog.range(&orders, RangeId::new(1)).unwrap().clone();
1076 catalog
1077 .apply_update(r1.transfer_to(ident("CN=node-b"), [ident("CN=node-a")]))
1078 .unwrap();
1079 let push_v2 = catalog
1080 .topology_snapshot()
1081 .range(&orders, RangeId::new(1))
1082 .unwrap()
1083 .clone();
1084
1085 let r2 = catalog.range(&orders, RangeId::new(1)).unwrap().clone();
1087 catalog
1088 .apply_update(r2.transfer_to(ident("CN=node-c"), [ident("CN=node-b")]))
1089 .unwrap();
1090 let push_v3 = catalog
1091 .topology_snapshot()
1092 .range(&orders, RangeId::new(1))
1093 .unwrap()
1094 .clone();
1095
1096 assert!(client
1098 .apply_update(TopologyUpdate::Range(push_v3))
1099 .was_applied());
1100 assert_eq!(client.resolve(&orders, b"k").unwrap(), &ident("CN=node-c"));
1101 assert_eq!(
1103 client.apply_update(TopologyUpdate::Range(push_v2)),
1104 RefreshOutcome::Ignored
1105 );
1106 assert_eq!(client.resolve(&orders, b"k").unwrap(), &ident("CN=node-c"));
1107 }
1108}