1use std::collections::BTreeMap;
45
46use super::identity::NodeIdentity;
47use super::ownership::{CollectionId, OwnershipEpoch, RangeId, ShardOwnershipCatalog};
48use super::ownership_transition::CommitWatermark;
49
50#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct KeyTarget {
56 collection: CollectionId,
57 key: Vec<u8>,
58}
59
60impl KeyTarget {
61 pub fn new(collection: CollectionId, key: impl Into<Vec<u8>>) -> Self {
62 Self {
63 collection,
64 key: key.into(),
65 }
66 }
67
68 pub fn collection(&self) -> &CollectionId {
69 &self.collection
70 }
71
72 pub fn key(&self) -> &[u8] {
73 &self.key
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct ResolvedTarget {
81 collection: CollectionId,
82 key: Vec<u8>,
83 range_id: RangeId,
84 owner: NodeIdentity,
85 epoch: OwnershipEpoch,
86}
87
88impl ResolvedTarget {
89 pub fn collection(&self) -> &CollectionId {
90 &self.collection
91 }
92
93 pub fn key(&self) -> &[u8] {
94 &self.key
95 }
96
97 pub fn range_id(&self) -> RangeId {
98 self.range_id
99 }
100
101 pub fn owner(&self) -> &NodeIdentity {
102 &self.owner
103 }
104
105 pub fn epoch(&self) -> OwnershipEpoch {
106 self.epoch
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct RangeParticipant {
115 collection: CollectionId,
116 range_id: RangeId,
117 epoch: OwnershipEpoch,
118}
119
120impl RangeParticipant {
121 pub fn collection(&self) -> &CollectionId {
122 &self.collection
123 }
124
125 pub fn range_id(&self) -> RangeId {
126 self.range_id
127 }
128
129 pub fn epoch(&self) -> OwnershipEpoch {
130 self.epoch
131 }
132}
133
134#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct WriterParticipation {
140 writer: NodeIdentity,
141 ranges: Vec<RangeParticipant>,
142}
143
144impl WriterParticipation {
145 pub fn writer(&self) -> &NodeIdentity {
146 &self.writer
147 }
148
149 pub fn ranges(&self) -> &[RangeParticipant] {
150 &self.ranges
151 }
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct WriteTransactionPlan {
160 participation: WriterParticipation,
161}
162
163impl WriteTransactionPlan {
164 pub fn writer(&self) -> &NodeIdentity {
166 self.participation.writer()
167 }
168
169 pub fn ranges(&self) -> &[RangeParticipant] {
171 self.participation.ranges()
172 }
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct ExactClaimPlan {
179 participation: WriterParticipation,
180}
181
182impl ExactClaimPlan {
183 pub fn writer(&self) -> &NodeIdentity {
185 self.participation.writer()
186 }
187
188 pub fn ranges(&self) -> &[RangeParticipant] {
190 self.participation.ranges()
191 }
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
196pub enum WriteTransactionReject {
197 Empty,
199 Unroutable {
202 collection: CollectionId,
203 key: Vec<u8>,
204 },
205 CrossRange { writers: Vec<WriterParticipation> },
210}
211
212impl std::fmt::Display for WriteTransactionReject {
213 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214 match self {
215 Self::Empty => write!(f, "write transaction names no targets"),
216 Self::Unroutable { collection, key } => write!(
217 f,
218 "no range of collection {collection} covers key {} — re-resolve routing",
219 DisplayKey(key)
220 ),
221 Self::CrossRange { writers } => {
222 write!(
223 f,
224 "cross-range write transaction spans {} writers and is unsupported: ",
225 writers.len()
226 )?;
227 for (i, w) in writers.iter().enumerate() {
228 if i > 0 {
229 write!(f, ", ")?;
230 }
231 write!(f, "{} owns ", w.writer())?;
232 for (j, r) in w.ranges().iter().enumerate() {
233 if j > 0 {
234 write!(f, "+")?;
235 }
236 write!(f, "{}/{}", r.collection(), r.range_id())?;
237 }
238 }
239 Ok(())
240 }
241 }
242 }
243}
244
245impl std::error::Error for WriteTransactionReject {}
246
247#[derive(Debug, Clone, PartialEq, Eq)]
249pub enum ExactClaimReject {
250 Empty,
252 Unroutable {
254 collection: CollectionId,
255 key: Vec<u8>,
256 },
257 CrossOwner { writers: Vec<WriterParticipation> },
261}
262
263impl std::fmt::Display for ExactClaimReject {
264 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265 match self {
266 Self::Empty => write!(f, "exact claim names no candidate targets"),
267 Self::Unroutable { collection, key } => write!(
268 f,
269 "no range of collection {collection} covers claim candidate key {} — re-resolve routing",
270 DisplayKey(key)
271 ),
272 Self::CrossOwner { writers } => {
273 write!(
274 f,
275 "cross-owner exact claim spans {} writers and is unsupported in the first cluster contract: ",
276 writers.len()
277 )?;
278 for (i, w) in writers.iter().enumerate() {
279 if i > 0 {
280 write!(f, ", ")?;
281 }
282 write!(f, "{} owns ", w.writer())?;
283 for (j, r) in w.ranges().iter().enumerate() {
284 if j > 0 {
285 write!(f, "+")?;
286 }
287 write!(f, "{}/{}", r.collection(), r.range_id())?;
288 }
289 }
290 Ok(())
291 }
292 }
293 }
294}
295
296impl std::error::Error for ExactClaimReject {}
297
298#[derive(Debug, Clone, PartialEq, Eq)]
300pub struct ReadLeg {
301 owner: NodeIdentity,
302 targets: Vec<ResolvedTarget>,
303}
304
305impl ReadLeg {
306 pub fn owner(&self) -> &NodeIdentity {
307 &self.owner
308 }
309
310 pub fn targets(&self) -> &[ResolvedTarget] {
311 &self.targets
312 }
313}
314
315#[derive(Debug, Clone, PartialEq, Eq)]
322pub struct ReadFanout {
323 legs: Vec<ReadLeg>,
324}
325
326impl ReadFanout {
327 pub fn legs(&self) -> &[ReadLeg] {
329 &self.legs
330 }
331
332 pub fn is_cross_range(&self) -> bool {
334 self.legs.len() > 1
335 }
336}
337
338#[derive(Debug, Clone, PartialEq, Eq)]
340pub enum ReadFanoutReject {
341 Empty,
343 Unroutable {
345 collection: CollectionId,
346 key: Vec<u8>,
347 },
348}
349
350impl std::fmt::Display for ReadFanoutReject {
351 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
352 match self {
353 Self::Empty => write!(f, "read fanout names no targets"),
354 Self::Unroutable { collection, key } => write!(
355 f,
356 "no range of collection {collection} covers key {} — re-resolve routing",
357 DisplayKey(key)
358 ),
359 }
360 }
361}
362
363impl std::error::Error for ReadFanoutReject {}
364
365#[derive(Debug, Clone, Default, PartialEq, Eq)]
373pub struct GlobalReadWatermark {
374 marks: BTreeMap<(CollectionId, RangeId), CommitWatermark>,
375}
376
377impl GlobalReadWatermark {
378 pub fn new() -> Self {
379 Self::default()
380 }
381
382 pub fn with(
384 mut self,
385 collection: CollectionId,
386 range_id: RangeId,
387 watermark: CommitWatermark,
388 ) -> Self {
389 self.marks.insert((collection, range_id), watermark);
390 self
391 }
392
393 pub fn insert(
395 &mut self,
396 collection: CollectionId,
397 range_id: RangeId,
398 watermark: CommitWatermark,
399 ) {
400 self.marks.insert((collection, range_id), watermark);
401 }
402
403 pub fn covers(&self, collection: &CollectionId, range_id: RangeId) -> Option<CommitWatermark> {
406 self.marks.get(&(collection.clone(), range_id)).copied()
407 }
408}
409
410#[derive(Debug, Clone, PartialEq, Eq)]
413pub struct ConsistentReadLeg {
414 owner: NodeIdentity,
415 targets: Vec<PinnedTarget>,
416}
417
418impl ConsistentReadLeg {
419 pub fn owner(&self) -> &NodeIdentity {
420 &self.owner
421 }
422
423 pub fn targets(&self) -> &[PinnedTarget] {
424 &self.targets
425 }
426}
427
428#[derive(Debug, Clone, PartialEq, Eq)]
430pub struct PinnedTarget {
431 target: ResolvedTarget,
432 watermark: CommitWatermark,
433}
434
435impl PinnedTarget {
436 pub fn target(&self) -> &ResolvedTarget {
437 &self.target
438 }
439
440 pub fn watermark(&self) -> CommitWatermark {
441 self.watermark
442 }
443}
444
445#[derive(Debug, Clone, PartialEq, Eq)]
448pub struct ConsistentReadPlan {
449 legs: Vec<ConsistentReadLeg>,
450}
451
452impl ConsistentReadPlan {
453 pub fn legs(&self) -> &[ConsistentReadLeg] {
456 &self.legs
457 }
458}
459
460#[derive(Debug, Clone, PartialEq, Eq)]
462pub enum ConsistentReadReject {
463 Empty,
465 Unroutable {
467 collection: CollectionId,
468 key: Vec<u8>,
469 },
470 NoSafeSnapshot,
474 WatermarkGap {
477 collection: CollectionId,
478 range_id: RangeId,
479 },
480}
481
482impl std::fmt::Display for ConsistentReadReject {
483 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
484 match self {
485 Self::Empty => write!(f, "consistent read names no targets"),
486 Self::Unroutable { collection, key } => write!(
487 f,
488 "no range of collection {collection} covers key {} — re-resolve routing",
489 DisplayKey(key)
490 ),
491 Self::NoSafeSnapshot => write!(
492 f,
493 "consistent cross-range read requires a global safe snapshot/watermark, none supplied"
494 ),
495 Self::WatermarkGap {
496 collection,
497 range_id,
498 } => write!(
499 f,
500 "safe snapshot does not cover {collection}/{range_id}; cannot serve a consistent read"
501 ),
502 }
503 }
504}
505
506impl std::error::Error for ConsistentReadReject {}
507
508struct DisplayKey<'a>(&'a [u8]);
510
511impl std::fmt::Display for DisplayKey<'_> {
512 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
513 write!(f, "0x")?;
514 for b in self.0 {
515 write!(f, "{b:02x}")?;
516 }
517 Ok(())
518 }
519}
520
521impl ShardOwnershipCatalog {
522 fn resolve_targets(
525 &self,
526 targets: &[KeyTarget],
527 ) -> Result<Vec<ResolvedTarget>, (CollectionId, Vec<u8>)> {
528 let mut resolved = Vec::with_capacity(targets.len());
529 for t in targets {
530 match self.route_shard_key(t.collection(), t.key()) {
531 Some(range) => resolved.push(ResolvedTarget {
532 collection: t.collection().clone(),
533 key: t.key().to_vec(),
534 range_id: range.range_id(),
535 owner: range.owner().clone(),
536 epoch: range.epoch(),
537 }),
538 None => return Err((t.collection().clone(), t.key().to_vec())),
539 }
540 }
541 Ok(resolved)
542 }
543
544 pub fn plan_write_transaction(
561 &self,
562 targets: &[KeyTarget],
563 ) -> Result<WriteTransactionPlan, WriteTransactionReject> {
564 if targets.is_empty() {
565 return Err(WriteTransactionReject::Empty);
566 }
567 let resolved = self
568 .resolve_targets(targets)
569 .map_err(|(collection, key)| WriteTransactionReject::Unroutable { collection, key })?;
570
571 let writers = group_by_owner(&resolved);
572 if writers.len() == 1 {
573 let (writer, ranges) = writers.into_iter().next().expect("exactly one writer");
574 Ok(WriteTransactionPlan {
575 participation: WriterParticipation { writer, ranges },
576 })
577 } else {
578 Err(WriteTransactionReject::CrossRange {
579 writers: writers
580 .into_iter()
581 .map(|(writer, ranges)| WriterParticipation { writer, ranges })
582 .collect(),
583 })
584 }
585 }
586
587 pub fn plan_exact_claim(
595 &self,
596 targets: &[KeyTarget],
597 ) -> Result<ExactClaimPlan, ExactClaimReject> {
598 if targets.is_empty() {
599 return Err(ExactClaimReject::Empty);
600 }
601 let resolved = self
602 .resolve_targets(targets)
603 .map_err(|(collection, key)| ExactClaimReject::Unroutable { collection, key })?;
604
605 let writers = group_by_owner(&resolved);
606 if writers.len() == 1 {
607 let (writer, ranges) = writers.into_iter().next().expect("exactly one writer");
608 Ok(ExactClaimPlan {
609 participation: WriterParticipation { writer, ranges },
610 })
611 } else {
612 Err(ExactClaimReject::CrossOwner {
613 writers: writers
614 .into_iter()
615 .map(|(writer, ranges)| WriterParticipation { writer, ranges })
616 .collect(),
617 })
618 }
619 }
620
621 pub fn plan_read_fanout(&self, targets: &[KeyTarget]) -> Result<ReadFanout, ReadFanoutReject> {
630 if targets.is_empty() {
631 return Err(ReadFanoutReject::Empty);
632 }
633 let resolved = self
634 .resolve_targets(targets)
635 .map_err(|(collection, key)| ReadFanoutReject::Unroutable { collection, key })?;
636
637 Ok(ReadFanout {
638 legs: group_targets_by_owner(resolved),
639 })
640 }
641
642 pub fn plan_consistent_read(
658 &self,
659 targets: &[KeyTarget],
660 snapshot: Option<&GlobalReadWatermark>,
661 ) -> Result<ConsistentReadPlan, ConsistentReadReject> {
662 if targets.is_empty() {
663 return Err(ConsistentReadReject::Empty);
664 }
665 let resolved = self
666 .resolve_targets(targets)
667 .map_err(|(collection, key)| ConsistentReadReject::Unroutable { collection, key })?;
668
669 let snapshot = snapshot.ok_or(ConsistentReadReject::NoSafeSnapshot)?;
670
671 let mut pinned = Vec::with_capacity(resolved.len());
674 for target in resolved {
675 let watermark = snapshot
676 .covers(target.collection(), target.range_id())
677 .ok_or_else(|| ConsistentReadReject::WatermarkGap {
678 collection: target.collection().clone(),
679 range_id: target.range_id(),
680 })?;
681 pinned.push(PinnedTarget { target, watermark });
682 }
683
684 Ok(ConsistentReadPlan {
685 legs: group_pinned_by_owner(pinned),
686 })
687 }
688}
689
690fn group_by_owner(resolved: &[ResolvedTarget]) -> Vec<(NodeIdentity, Vec<RangeParticipant>)> {
693 let mut by_owner: BTreeMap<NodeIdentity, BTreeMap<RangeId, RangeParticipant>> = BTreeMap::new();
694 for t in resolved {
695 by_owner
696 .entry(t.owner().clone())
697 .or_default()
698 .entry(t.range_id())
699 .or_insert_with(|| RangeParticipant {
700 collection: t.collection().clone(),
701 range_id: t.range_id(),
702 epoch: t.epoch(),
703 });
704 }
705 by_owner
706 .into_iter()
707 .map(|(owner, ranges)| (owner, ranges.into_values().collect()))
708 .collect()
709}
710
711fn group_targets_by_owner(resolved: Vec<ResolvedTarget>) -> Vec<ReadLeg> {
713 let mut by_owner: BTreeMap<NodeIdentity, Vec<ResolvedTarget>> = BTreeMap::new();
714 for t in resolved {
715 by_owner.entry(t.owner().clone()).or_default().push(t);
716 }
717 by_owner
718 .into_iter()
719 .map(|(owner, targets)| ReadLeg { owner, targets })
720 .collect()
721}
722
723fn group_pinned_by_owner(pinned: Vec<PinnedTarget>) -> Vec<ConsistentReadLeg> {
726 let mut by_owner: BTreeMap<NodeIdentity, Vec<PinnedTarget>> = BTreeMap::new();
727 for p in pinned {
728 by_owner
729 .entry(p.target().owner().clone())
730 .or_default()
731 .push(p);
732 }
733 by_owner
734 .into_iter()
735 .map(|(owner, targets)| ConsistentReadLeg { owner, targets })
736 .collect()
737}
738
739#[cfg(test)]
740mod tests {
741 use super::*;
742 use crate::cluster::ownership::{PlacementMetadata, RangeBound, RangeBounds, ShardKeyMode};
743
744 fn collection(name: &str) -> CollectionId {
745 CollectionId::new(name).unwrap()
746 }
747
748 fn ident(cn: &str) -> NodeIdentity {
749 NodeIdentity::from_certificate_subject(cn).unwrap()
750 }
751
752 fn bounds(lower: &[u8], upper: &[u8]) -> RangeBounds {
753 RangeBounds::new(RangeBound::key(lower), RangeBound::key(upper)).unwrap()
754 }
755
756 fn range(
758 coll: &CollectionId,
759 id: u64,
760 bnds: RangeBounds,
761 owner: &str,
762 ) -> super::super::ownership::RangeOwnership {
763 super::super::ownership::RangeOwnership::establish(
764 coll.clone(),
765 RangeId::new(id),
766 ShardKeyMode::Ordered,
767 bnds,
768 ident(owner),
769 [ident("CN=replica-1")],
770 PlacementMetadata::with_replication_factor(3),
771 )
772 }
773
774 fn two_range_catalog() -> (ShardOwnershipCatalog, CollectionId) {
777 let orders = collection("orders");
778 let mut catalog = ShardOwnershipCatalog::new();
779 catalog
780 .apply_update(range(&orders, 1, bounds(b"a", b"m"), "CN=node-a"))
781 .unwrap();
782 catalog
783 .apply_update(range(
784 &orders,
785 2,
786 RangeBounds::new(RangeBound::key(b"m"), RangeBound::Max).unwrap(),
787 "CN=node-b",
788 ))
789 .unwrap();
790 (catalog, orders)
791 }
792
793 fn target(coll: &CollectionId, key: &[u8]) -> KeyTarget {
794 KeyTarget::new(coll.clone(), key.to_vec())
795 }
796
797 #[test]
800 fn single_writer_transaction_succeeds() {
801 let orders = collection("orders");
802 let mut catalog = ShardOwnershipCatalog::new();
803 catalog
805 .apply_update(range(&orders, 1, bounds(b"a", b"m"), "CN=node-a"))
806 .unwrap();
807 catalog
808 .apply_update(range(
809 &orders,
810 2,
811 RangeBounds::new(RangeBound::key(b"m"), RangeBound::Max).unwrap(),
812 "CN=node-a",
813 ))
814 .unwrap();
815
816 let plan = catalog
817 .plan_write_transaction(&[target(&orders, b"alice"), target(&orders, b"zeb")])
818 .expect("single-writer transaction is admitted");
819 assert_eq!(plan.writer(), &ident("CN=node-a"));
820 let ids: Vec<u64> = plan.ranges().iter().map(|r| r.range_id().value()).collect();
822 assert_eq!(ids, vec![1, 2]);
823 assert_eq!(plan.ranges()[0].epoch(), OwnershipEpoch::initial());
824 }
825
826 #[test]
828 fn single_range_transaction_succeeds() {
829 let (catalog, orders) = two_range_catalog();
830 let plan = catalog
831 .plan_write_transaction(&[target(&orders, b"alice"), target(&orders, b"bob")])
832 .expect("single-range transaction is admitted");
833 assert_eq!(plan.writer(), &ident("CN=node-a"));
834 assert_eq!(plan.ranges().len(), 1);
835 assert_eq!(plan.ranges()[0].range_id(), RangeId::new(1));
836 }
837
838 #[test]
841 fn cross_range_write_transaction_is_rejected() {
842 let (catalog, orders) = two_range_catalog();
843 let err = catalog
844 .plan_write_transaction(&[target(&orders, b"alice"), target(&orders, b"zeb")])
845 .expect_err("cross-writer transaction is rejected");
846 match err {
847 WriteTransactionReject::CrossRange { writers } => {
848 assert_eq!(writers.len(), 2);
849 assert_eq!(writers[0].writer(), &ident("CN=node-a"));
850 assert_eq!(writers[1].writer(), &ident("CN=node-b"));
851 assert_eq!(writers[0].ranges()[0].range_id(), RangeId::new(1));
852 assert_eq!(writers[1].ranges()[0].range_id(), RangeId::new(2));
853 }
854 other => panic!("expected CrossRange, got {other:?}"),
855 }
856 }
857
858 #[test]
859 fn empty_write_transaction_is_rejected() {
860 let catalog = ShardOwnershipCatalog::new();
861 assert_eq!(
862 catalog.plan_write_transaction(&[]),
863 Err(WriteTransactionReject::Empty)
864 );
865 }
866
867 #[test]
868 fn unroutable_write_transaction_is_rejected() {
869 let catalog = ShardOwnershipCatalog::new();
870 let orders = collection("orders");
871 match catalog.plan_write_transaction(&[target(&orders, b"x")]) {
872 Err(WriteTransactionReject::Unroutable { collection, key }) => {
873 assert_eq!(collection, orders);
874 assert_eq!(key, b"x");
875 }
876 other => panic!("expected Unroutable, got {other:?}"),
877 }
878 }
879
880 #[test]
882 fn read_fanout_collects_per_owner_legs() {
883 let (catalog, orders) = two_range_catalog();
884 let fanout = catalog
885 .plan_read_fanout(&[
886 target(&orders, b"alice"),
887 target(&orders, b"zeb"),
888 target(&orders, b"bob"),
889 ])
890 .expect("fanout planned");
891 assert!(fanout.is_cross_range());
892 assert_eq!(fanout.legs().len(), 2);
893 let a = &fanout.legs()[0];
895 assert_eq!(a.owner(), &ident("CN=node-a"));
896 assert_eq!(a.targets().len(), 2);
897 let b = &fanout.legs()[1];
898 assert_eq!(b.owner(), &ident("CN=node-b"));
899 assert_eq!(b.targets().len(), 1);
900 assert_eq!(b.targets()[0].key(), b"zeb");
901 }
902
903 #[test]
904 fn single_owner_read_is_not_cross_range() {
905 let (catalog, orders) = two_range_catalog();
906 let fanout = catalog
907 .plan_read_fanout(&[target(&orders, b"alice"), target(&orders, b"bob")])
908 .expect("fanout planned");
909 assert!(!fanout.is_cross_range());
910 assert_eq!(fanout.legs().len(), 1);
911 }
912
913 #[test]
914 fn unroutable_read_fanout_is_rejected() {
915 let catalog = ShardOwnershipCatalog::new();
916 let orders = collection("orders");
917 match catalog.plan_read_fanout(&[target(&orders, b"x")]) {
918 Err(ReadFanoutReject::Unroutable { collection, .. }) => {
919 assert_eq!(collection, orders)
920 }
921 other => panic!("expected Unroutable, got {other:?}"),
922 }
923 }
924
925 #[test]
927 fn consistent_read_without_snapshot_is_rejected() {
928 let (catalog, orders) = two_range_catalog();
929 assert_eq!(
930 catalog
931 .plan_consistent_read(&[target(&orders, b"alice"), target(&orders, b"zeb")], None),
932 Err(ConsistentReadReject::NoSafeSnapshot)
933 );
934 }
935
936 #[test]
938 fn consistent_read_with_incomplete_snapshot_is_rejected() {
939 let (catalog, orders) = two_range_catalog();
940 let snapshot = GlobalReadWatermark::new().with(
942 orders.clone(),
943 RangeId::new(1),
944 CommitWatermark::new(1, 100),
945 );
946 match catalog.plan_consistent_read(
947 &[target(&orders, b"alice"), target(&orders, b"zeb")],
948 Some(&snapshot),
949 ) {
950 Err(ConsistentReadReject::WatermarkGap {
951 collection,
952 range_id,
953 }) => {
954 assert_eq!(collection, orders);
955 assert_eq!(range_id, RangeId::new(2));
956 }
957 other => panic!("expected WatermarkGap, got {other:?}"),
958 }
959 }
960
961 #[test]
964 fn consistent_read_with_full_snapshot_succeeds() {
965 let (catalog, orders) = two_range_catalog();
966 let snapshot = GlobalReadWatermark::new()
967 .with(
968 orders.clone(),
969 RangeId::new(1),
970 CommitWatermark::new(1, 100),
971 )
972 .with(
973 orders.clone(),
974 RangeId::new(2),
975 CommitWatermark::new(1, 250),
976 );
977 let plan = catalog
978 .plan_consistent_read(
979 &[target(&orders, b"alice"), target(&orders, b"zeb")],
980 Some(&snapshot),
981 )
982 .expect("consistent read planned");
983 assert_eq!(plan.legs().len(), 2);
984 let a = &plan.legs()[0];
985 assert_eq!(a.owner(), &ident("CN=node-a"));
986 assert_eq!(a.targets()[0].watermark(), CommitWatermark::new(1, 100));
987 let b = &plan.legs()[1];
988 assert_eq!(b.owner(), &ident("CN=node-b"));
989 assert_eq!(b.targets()[0].watermark(), CommitWatermark::new(1, 250));
990 }
991
992 #[test]
993 fn empty_consistent_read_is_rejected() {
994 let catalog = ShardOwnershipCatalog::new();
995 assert_eq!(
996 catalog.plan_consistent_read(&[], None),
997 Err(ConsistentReadReject::Empty)
998 );
999 }
1000
1001 #[test]
1003 fn cross_range_rejection_message_names_writers() {
1004 let (catalog, orders) = two_range_catalog();
1005 let err = catalog
1006 .plan_write_transaction(&[target(&orders, b"alice"), target(&orders, b"zeb")])
1007 .unwrap_err();
1008 let msg = err.to_string();
1009 assert!(msg.contains("cross-range write transaction"));
1010 assert!(msg.contains("CN=node-a"));
1011 assert!(msg.contains("CN=node-b"));
1012 }
1013
1014 #[test]
1015 fn exact_claim_confined_to_one_owner_is_admitted() {
1016 let (catalog, orders) = two_range_catalog();
1017
1018 let plan = catalog
1019 .plan_exact_claim(&[target(&orders, b"alice"), target(&orders, b"bob")])
1020 .expect("owner-local exact claim is admitted");
1021
1022 assert_eq!(plan.writer(), &ident("CN=node-a"));
1023 assert_eq!(plan.ranges().len(), 1);
1024 assert_eq!(plan.ranges()[0].range_id(), RangeId::new(1));
1025 }
1026
1027 #[test]
1028 fn cross_owner_exact_claim_is_rejected_explicitly() {
1029 let (catalog, orders) = two_range_catalog();
1030
1031 let err = catalog
1032 .plan_exact_claim(&[target(&orders, b"alice"), target(&orders, b"zeb")])
1033 .expect_err("cross-owner exact claim is outside the first cluster contract");
1034
1035 match &err {
1036 ExactClaimReject::CrossOwner { writers } => {
1037 assert_eq!(writers.len(), 2);
1038 assert_eq!(writers[0].writer(), &ident("CN=node-a"));
1039 assert_eq!(writers[1].writer(), &ident("CN=node-b"));
1040 }
1041 other => panic!("expected CrossOwner, got {other:?}"),
1042 }
1043 assert!(err.to_string().contains("cross-owner exact claim"));
1044 assert!(err.to_string().contains("first cluster contract"));
1045 }
1046}