1use std::collections::BTreeMap;
47
48use super::identity::NodeIdentity;
49use super::ownership::{CollectionId, OwnershipEpoch, RangeId, ShardOwnershipCatalog};
50use super::ownership_transition::CommitWatermark;
51
52#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct KeyTarget {
58 collection: CollectionId,
59 key: Vec<u8>,
60}
61
62impl KeyTarget {
63 pub fn new(collection: CollectionId, key: impl Into<Vec<u8>>) -> Self {
64 Self {
65 collection,
66 key: key.into(),
67 }
68 }
69
70 pub fn collection(&self) -> &CollectionId {
71 &self.collection
72 }
73
74 pub fn key(&self) -> &[u8] {
75 &self.key
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct ResolvedTarget {
83 collection: CollectionId,
84 key: Vec<u8>,
85 range_id: RangeId,
86 owner: NodeIdentity,
87 epoch: OwnershipEpoch,
88}
89
90impl ResolvedTarget {
91 pub fn collection(&self) -> &CollectionId {
92 &self.collection
93 }
94
95 pub fn key(&self) -> &[u8] {
96 &self.key
97 }
98
99 pub fn range_id(&self) -> RangeId {
100 self.range_id
101 }
102
103 pub fn owner(&self) -> &NodeIdentity {
104 &self.owner
105 }
106
107 pub fn epoch(&self) -> OwnershipEpoch {
108 self.epoch
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct RangeParticipant {
117 collection: CollectionId,
118 range_id: RangeId,
119 epoch: OwnershipEpoch,
120}
121
122impl RangeParticipant {
123 pub fn collection(&self) -> &CollectionId {
124 &self.collection
125 }
126
127 pub fn range_id(&self) -> RangeId {
128 self.range_id
129 }
130
131 pub fn epoch(&self) -> OwnershipEpoch {
132 self.epoch
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct WriterParticipation {
142 writer: NodeIdentity,
143 ranges: Vec<RangeParticipant>,
144}
145
146impl WriterParticipation {
147 pub fn writer(&self) -> &NodeIdentity {
148 &self.writer
149 }
150
151 pub fn ranges(&self) -> &[RangeParticipant] {
152 &self.ranges
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct WriteTransactionPlan {
162 participation: WriterParticipation,
163}
164
165impl WriteTransactionPlan {
166 pub fn writer(&self) -> &NodeIdentity {
168 self.participation.writer()
169 }
170
171 pub fn ranges(&self) -> &[RangeParticipant] {
173 self.participation.ranges()
174 }
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct ExactClaimPlan {
181 participation: WriterParticipation,
182}
183
184impl ExactClaimPlan {
185 pub fn writer(&self) -> &NodeIdentity {
187 self.participation.writer()
188 }
189
190 pub fn ranges(&self) -> &[RangeParticipant] {
192 self.participation.ranges()
193 }
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
198pub enum WriteTransactionReject {
199 Empty,
201 Unroutable {
204 collection: CollectionId,
205 key: Vec<u8>,
206 },
207 CrossRange { writers: Vec<WriterParticipation> },
212}
213
214impl std::fmt::Display for WriteTransactionReject {
215 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216 match self {
217 Self::Empty => write!(f, "write transaction names no targets"),
218 Self::Unroutable { collection, key } => write!(
219 f,
220 "no range of collection {collection} covers key {} — re-resolve routing",
221 DisplayKey(key)
222 ),
223 Self::CrossRange { writers } => {
224 write!(
225 f,
226 "cross-range write transaction spans {} writers and is unsupported: ",
227 writers.len()
228 )?;
229 for (i, w) in writers.iter().enumerate() {
230 if i > 0 {
231 write!(f, ", ")?;
232 }
233 write!(f, "{} owns ", w.writer())?;
234 for (j, r) in w.ranges().iter().enumerate() {
235 if j > 0 {
236 write!(f, "+")?;
237 }
238 write!(f, "{}/{}", r.collection(), r.range_id())?;
239 }
240 }
241 Ok(())
242 }
243 }
244 }
245}
246
247impl std::error::Error for WriteTransactionReject {}
248
249#[derive(Debug, Clone, PartialEq, Eq)]
251pub enum ExactClaimReject {
252 Empty,
254 Unroutable {
256 collection: CollectionId,
257 key: Vec<u8>,
258 },
259 CrossOwner { writers: Vec<WriterParticipation> },
263}
264
265impl std::fmt::Display for ExactClaimReject {
266 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
267 match self {
268 Self::Empty => write!(f, "exact claim names no candidate targets"),
269 Self::Unroutable { collection, key } => write!(
270 f,
271 "no range of collection {collection} covers claim candidate key {} — re-resolve routing",
272 DisplayKey(key)
273 ),
274 Self::CrossOwner { writers } => {
275 write!(
276 f,
277 "cross-owner exact claim spans {} writers and is unsupported in the first cluster contract: ",
278 writers.len()
279 )?;
280 for (i, w) in writers.iter().enumerate() {
281 if i > 0 {
282 write!(f, ", ")?;
283 }
284 write!(f, "{} owns ", w.writer())?;
285 for (j, r) in w.ranges().iter().enumerate() {
286 if j > 0 {
287 write!(f, "+")?;
288 }
289 write!(f, "{}/{}", r.collection(), r.range_id())?;
290 }
291 }
292 Ok(())
293 }
294 }
295 }
296}
297
298impl std::error::Error for ExactClaimReject {}
299
300#[derive(Debug, Clone, PartialEq, Eq)]
302pub struct ReadLeg {
303 owner: NodeIdentity,
304 targets: Vec<ResolvedTarget>,
305}
306
307impl ReadLeg {
308 pub fn owner(&self) -> &NodeIdentity {
309 &self.owner
310 }
311
312 pub fn targets(&self) -> &[ResolvedTarget] {
313 &self.targets
314 }
315}
316
317#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
325pub enum ReadFanoutPolicy {
326 #[default]
328 SingleOwnerOnly,
329 ExplicitFanout {
332 budget: ReadFanoutBudget,
333 allow_partial: bool,
334 },
335}
336
337impl ReadFanoutPolicy {
338 pub fn single_owner_only() -> Self {
339 Self::SingleOwnerOnly
340 }
341
342 pub fn explicit(budget: ReadFanoutBudget) -> Self {
343 Self::ExplicitFanout {
344 budget,
345 allow_partial: false,
346 }
347 }
348
349 pub fn allowing_partial(mut self) -> Self {
350 if let Self::ExplicitFanout {
351 ref mut allow_partial,
352 ..
353 } = self
354 {
355 *allow_partial = true;
356 }
357 self
358 }
359}
360
361#[derive(Debug, Clone, Copy, PartialEq, Eq)]
363pub struct ReadFanoutBudget {
364 max_owners: usize,
365 max_ranges: usize,
366 max_targets: usize,
367}
368
369impl ReadFanoutBudget {
370 pub fn new(max_owners: usize, max_ranges: usize, max_targets: usize) -> Self {
371 Self {
372 max_owners,
373 max_ranges,
374 max_targets,
375 }
376 }
377
378 pub fn max_owners(&self) -> usize {
379 self.max_owners
380 }
381
382 pub fn max_ranges(&self) -> usize {
383 self.max_ranges
384 }
385
386 pub fn max_targets(&self) -> usize {
387 self.max_targets
388 }
389}
390
391impl Default for ReadFanoutBudget {
392 fn default() -> Self {
393 Self {
394 max_owners: 8,
395 max_ranges: 64,
396 max_targets: 512,
397 }
398 }
399}
400
401#[derive(Debug, Clone, Copy, PartialEq, Eq)]
403pub struct ReadFanoutTrace {
404 target_count: usize,
405 owner_count: usize,
406 range_count: usize,
407 partial_allowed: bool,
408}
409
410impl ReadFanoutTrace {
411 pub fn target_count(&self) -> usize {
412 self.target_count
413 }
414
415 pub fn owner_count(&self) -> usize {
416 self.owner_count
417 }
418
419 pub fn range_count(&self) -> usize {
420 self.range_count
421 }
422
423 pub fn partial_allowed(&self) -> bool {
424 self.partial_allowed
425 }
426}
427
428#[derive(Debug, Clone, PartialEq, Eq)]
435pub struct ReadFanout {
436 legs: Vec<ReadLeg>,
437 trace: ReadFanoutTrace,
438}
439
440impl ReadFanout {
441 pub fn legs(&self) -> &[ReadLeg] {
443 &self.legs
444 }
445
446 pub fn trace(&self) -> ReadFanoutTrace {
448 self.trace
449 }
450
451 pub fn is_cross_range(&self) -> bool {
453 self.trace.range_count > 1
454 }
455}
456
457#[derive(Debug, Clone, PartialEq, Eq)]
459pub enum ReadFanoutReject {
460 Empty,
462 Unroutable {
464 collection: CollectionId,
465 key: Vec<u8>,
466 },
467 FanoutNotExplicit { owners: Vec<NodeIdentity> },
470 TargetBudgetExceeded { requested: usize, max: usize },
472 OwnerBudgetExceeded { requested: usize, max: usize },
474 RangeBudgetExceeded { requested: usize, max: usize },
476}
477
478impl std::fmt::Display for ReadFanoutReject {
479 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
480 match self {
481 Self::Empty => write!(f, "read fanout names no targets"),
482 Self::Unroutable { collection, key } => write!(
483 f,
484 "no range of collection {collection} covers key {} — re-resolve routing",
485 DisplayKey(key)
486 ),
487 Self::FanoutNotExplicit { owners } => {
488 write!(
489 f,
490 "read would fan out to {} owners but fanout was not explicit: ",
491 owners.len()
492 )?;
493 for (i, owner) in owners.iter().enumerate() {
494 if i > 0 {
495 write!(f, ", ")?;
496 }
497 write!(f, "{owner}")?;
498 }
499 Ok(())
500 }
501 Self::TargetBudgetExceeded { requested, max } => write!(
502 f,
503 "read fanout targets {requested} keys, exceeding budget {max}"
504 ),
505 Self::OwnerBudgetExceeded { requested, max } => write!(
506 f,
507 "read fanout would contact {requested} owners, exceeding budget {max}"
508 ),
509 Self::RangeBudgetExceeded { requested, max } => write!(
510 f,
511 "read fanout would touch {requested} ranges, exceeding budget {max}"
512 ),
513 }
514 }
515}
516
517impl std::error::Error for ReadFanoutReject {}
518
519#[derive(Debug, Clone, Default, PartialEq, Eq)]
527pub struct GlobalReadWatermark {
528 marks: BTreeMap<(CollectionId, RangeId), CommitWatermark>,
529}
530
531impl GlobalReadWatermark {
532 pub fn new() -> Self {
533 Self::default()
534 }
535
536 pub fn with(
538 mut self,
539 collection: CollectionId,
540 range_id: RangeId,
541 watermark: CommitWatermark,
542 ) -> Self {
543 self.marks.insert((collection, range_id), watermark);
544 self
545 }
546
547 pub fn insert(
549 &mut self,
550 collection: CollectionId,
551 range_id: RangeId,
552 watermark: CommitWatermark,
553 ) {
554 self.marks.insert((collection, range_id), watermark);
555 }
556
557 pub fn covers(&self, collection: &CollectionId, range_id: RangeId) -> Option<CommitWatermark> {
560 self.marks.get(&(collection.clone(), range_id)).copied()
561 }
562}
563
564#[derive(Debug, Clone, PartialEq, Eq)]
567pub struct ConsistentReadLeg {
568 owner: NodeIdentity,
569 targets: Vec<PinnedTarget>,
570}
571
572impl ConsistentReadLeg {
573 pub fn owner(&self) -> &NodeIdentity {
574 &self.owner
575 }
576
577 pub fn targets(&self) -> &[PinnedTarget] {
578 &self.targets
579 }
580}
581
582#[derive(Debug, Clone, PartialEq, Eq)]
584pub struct PinnedTarget {
585 target: ResolvedTarget,
586 watermark: CommitWatermark,
587}
588
589impl PinnedTarget {
590 pub fn target(&self) -> &ResolvedTarget {
591 &self.target
592 }
593
594 pub fn watermark(&self) -> CommitWatermark {
595 self.watermark
596 }
597}
598
599#[derive(Debug, Clone, PartialEq, Eq)]
602pub struct ConsistentReadPlan {
603 legs: Vec<ConsistentReadLeg>,
604}
605
606impl ConsistentReadPlan {
607 pub fn legs(&self) -> &[ConsistentReadLeg] {
610 &self.legs
611 }
612}
613
614#[derive(Debug, Clone, PartialEq, Eq)]
616pub enum ConsistentReadReject {
617 Empty,
619 Unroutable {
621 collection: CollectionId,
622 key: Vec<u8>,
623 },
624 NoSafeSnapshot,
628 WatermarkGap {
631 collection: CollectionId,
632 range_id: RangeId,
633 },
634}
635
636impl std::fmt::Display for ConsistentReadReject {
637 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
638 match self {
639 Self::Empty => write!(f, "consistent read names no targets"),
640 Self::Unroutable { collection, key } => write!(
641 f,
642 "no range of collection {collection} covers key {} — re-resolve routing",
643 DisplayKey(key)
644 ),
645 Self::NoSafeSnapshot => write!(
646 f,
647 "consistent cross-range read requires a global safe snapshot/watermark, none supplied"
648 ),
649 Self::WatermarkGap {
650 collection,
651 range_id,
652 } => write!(
653 f,
654 "safe snapshot does not cover {collection}/{range_id}; cannot serve a consistent read"
655 ),
656 }
657 }
658}
659
660impl std::error::Error for ConsistentReadReject {}
661
662struct DisplayKey<'a>(&'a [u8]);
664
665impl std::fmt::Display for DisplayKey<'_> {
666 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
667 write!(f, "0x")?;
668 for b in self.0 {
669 write!(f, "{b:02x}")?;
670 }
671 Ok(())
672 }
673}
674
675impl ShardOwnershipCatalog {
676 fn resolve_targets(
679 &self,
680 targets: &[KeyTarget],
681 ) -> Result<Vec<ResolvedTarget>, (CollectionId, Vec<u8>)> {
682 let mut resolved = Vec::with_capacity(targets.len());
683 for t in targets {
684 match self.route_shard_key(t.collection(), t.key()) {
685 Some(range) => resolved.push(ResolvedTarget {
686 collection: t.collection().clone(),
687 key: t.key().to_vec(),
688 range_id: range.range_id(),
689 owner: range.owner().clone(),
690 epoch: range.epoch(),
691 }),
692 None => return Err((t.collection().clone(), t.key().to_vec())),
693 }
694 }
695 Ok(resolved)
696 }
697
698 pub fn plan_write_transaction(
715 &self,
716 targets: &[KeyTarget],
717 ) -> Result<WriteTransactionPlan, WriteTransactionReject> {
718 if targets.is_empty() {
719 return Err(WriteTransactionReject::Empty);
720 }
721 let resolved = self
722 .resolve_targets(targets)
723 .map_err(|(collection, key)| WriteTransactionReject::Unroutable { collection, key })?;
724
725 let writers = group_by_owner(&resolved);
726 if writers.len() == 1 {
727 let (writer, ranges) = writers.into_iter().next().expect("exactly one writer");
728 Ok(WriteTransactionPlan {
729 participation: WriterParticipation { writer, ranges },
730 })
731 } else {
732 Err(WriteTransactionReject::CrossRange {
733 writers: writers
734 .into_iter()
735 .map(|(writer, ranges)| WriterParticipation { writer, ranges })
736 .collect(),
737 })
738 }
739 }
740
741 pub fn plan_exact_claim(
749 &self,
750 targets: &[KeyTarget],
751 ) -> Result<ExactClaimPlan, ExactClaimReject> {
752 if targets.is_empty() {
753 return Err(ExactClaimReject::Empty);
754 }
755 let resolved = self
756 .resolve_targets(targets)
757 .map_err(|(collection, key)| ExactClaimReject::Unroutable { collection, key })?;
758
759 let writers = group_by_owner(&resolved);
760 if writers.len() == 1 {
761 let (writer, ranges) = writers.into_iter().next().expect("exactly one writer");
762 Ok(ExactClaimPlan {
763 participation: WriterParticipation { writer, ranges },
764 })
765 } else {
766 Err(ExactClaimReject::CrossOwner {
767 writers: writers
768 .into_iter()
769 .map(|(writer, ranges)| WriterParticipation { writer, ranges })
770 .collect(),
771 })
772 }
773 }
774
775 pub fn plan_read_fanout(
784 &self,
785 targets: &[KeyTarget],
786 policy: ReadFanoutPolicy,
787 ) -> Result<ReadFanout, ReadFanoutReject> {
788 if targets.is_empty() {
789 return Err(ReadFanoutReject::Empty);
790 }
791 if let ReadFanoutPolicy::ExplicitFanout { budget, .. } = policy {
792 if targets.len() > budget.max_targets() {
793 return Err(ReadFanoutReject::TargetBudgetExceeded {
794 requested: targets.len(),
795 max: budget.max_targets(),
796 });
797 }
798 }
799 let resolved = self
800 .resolve_targets(targets)
801 .map_err(|(collection, key)| ReadFanoutReject::Unroutable { collection, key })?;
802
803 let owner_count = distinct_owner_count(&resolved);
804 let range_count = distinct_range_count(&resolved);
805 let partial_allowed = match policy {
806 ReadFanoutPolicy::SingleOwnerOnly => {
807 if owner_count > 1 {
808 return Err(ReadFanoutReject::FanoutNotExplicit {
809 owners: distinct_owners(&resolved),
810 });
811 }
812 false
813 }
814 ReadFanoutPolicy::ExplicitFanout {
815 budget,
816 allow_partial,
817 } => {
818 if owner_count > budget.max_owners() {
819 return Err(ReadFanoutReject::OwnerBudgetExceeded {
820 requested: owner_count,
821 max: budget.max_owners(),
822 });
823 }
824 if range_count > budget.max_ranges() {
825 return Err(ReadFanoutReject::RangeBudgetExceeded {
826 requested: range_count,
827 max: budget.max_ranges(),
828 });
829 }
830 allow_partial
831 }
832 };
833
834 Ok(ReadFanout {
835 legs: group_targets_by_owner(resolved),
836 trace: ReadFanoutTrace {
837 target_count: targets.len(),
838 owner_count,
839 range_count,
840 partial_allowed,
841 },
842 })
843 }
844
845 pub fn plan_consistent_read(
861 &self,
862 targets: &[KeyTarget],
863 snapshot: Option<&GlobalReadWatermark>,
864 ) -> Result<ConsistentReadPlan, ConsistentReadReject> {
865 if targets.is_empty() {
866 return Err(ConsistentReadReject::Empty);
867 }
868 let resolved = self
869 .resolve_targets(targets)
870 .map_err(|(collection, key)| ConsistentReadReject::Unroutable { collection, key })?;
871
872 let snapshot = snapshot.ok_or(ConsistentReadReject::NoSafeSnapshot)?;
873
874 let mut pinned = Vec::with_capacity(resolved.len());
877 for target in resolved {
878 let watermark = snapshot
879 .covers(target.collection(), target.range_id())
880 .ok_or_else(|| ConsistentReadReject::WatermarkGap {
881 collection: target.collection().clone(),
882 range_id: target.range_id(),
883 })?;
884 pinned.push(PinnedTarget { target, watermark });
885 }
886
887 Ok(ConsistentReadPlan {
888 legs: group_pinned_by_owner(pinned),
889 })
890 }
891}
892
893fn group_by_owner(resolved: &[ResolvedTarget]) -> Vec<(NodeIdentity, Vec<RangeParticipant>)> {
896 let mut by_owner: BTreeMap<NodeIdentity, BTreeMap<RangeId, RangeParticipant>> = BTreeMap::new();
897 for t in resolved {
898 by_owner
899 .entry(t.owner().clone())
900 .or_default()
901 .entry(t.range_id())
902 .or_insert_with(|| RangeParticipant {
903 collection: t.collection().clone(),
904 range_id: t.range_id(),
905 epoch: t.epoch(),
906 });
907 }
908 by_owner
909 .into_iter()
910 .map(|(owner, ranges)| (owner, ranges.into_values().collect()))
911 .collect()
912}
913
914fn group_targets_by_owner(resolved: Vec<ResolvedTarget>) -> Vec<ReadLeg> {
916 let mut by_owner: BTreeMap<NodeIdentity, Vec<ResolvedTarget>> = BTreeMap::new();
917 for t in resolved {
918 by_owner.entry(t.owner().clone()).or_default().push(t);
919 }
920 by_owner
921 .into_iter()
922 .map(|(owner, targets)| ReadLeg { owner, targets })
923 .collect()
924}
925
926fn distinct_owners(resolved: &[ResolvedTarget]) -> Vec<NodeIdentity> {
927 resolved
928 .iter()
929 .map(|target| target.owner().clone())
930 .collect::<std::collections::BTreeSet<_>>()
931 .into_iter()
932 .collect()
933}
934
935fn distinct_owner_count(resolved: &[ResolvedTarget]) -> usize {
936 distinct_owners(resolved).len()
937}
938
939fn distinct_range_count(resolved: &[ResolvedTarget]) -> usize {
940 resolved
941 .iter()
942 .map(|target| (target.collection().clone(), target.range_id()))
943 .collect::<std::collections::BTreeSet<_>>()
944 .len()
945}
946
947fn group_pinned_by_owner(pinned: Vec<PinnedTarget>) -> Vec<ConsistentReadLeg> {
950 let mut by_owner: BTreeMap<NodeIdentity, Vec<PinnedTarget>> = BTreeMap::new();
951 for p in pinned {
952 by_owner
953 .entry(p.target().owner().clone())
954 .or_default()
955 .push(p);
956 }
957 by_owner
958 .into_iter()
959 .map(|(owner, targets)| ConsistentReadLeg { owner, targets })
960 .collect()
961}
962
963#[cfg(test)]
964mod tests {
965 use super::*;
966 use crate::cluster::ownership::{PlacementMetadata, RangeBound, RangeBounds, ShardKeyMode};
967
968 fn collection(name: &str) -> CollectionId {
969 CollectionId::new(name).unwrap()
970 }
971
972 fn ident(cn: &str) -> NodeIdentity {
973 NodeIdentity::from_certificate_subject(cn).unwrap()
974 }
975
976 fn bounds(lower: &[u8], upper: &[u8]) -> RangeBounds {
977 RangeBounds::new(RangeBound::key(lower), RangeBound::key(upper)).unwrap()
978 }
979
980 fn range(
982 coll: &CollectionId,
983 id: u64,
984 bnds: RangeBounds,
985 owner: &str,
986 ) -> super::super::ownership::RangeOwnership {
987 super::super::ownership::RangeOwnership::establish(
988 coll.clone(),
989 RangeId::new(id),
990 ShardKeyMode::Ordered,
991 bnds,
992 ident(owner),
993 [ident("CN=replica-1")],
994 PlacementMetadata::with_replication_factor(3),
995 )
996 }
997
998 fn two_range_catalog() -> (ShardOwnershipCatalog, CollectionId) {
1001 let orders = collection("orders");
1002 let mut catalog = ShardOwnershipCatalog::new();
1003 catalog
1004 .apply_update(range(&orders, 1, bounds(b"a", b"m"), "CN=node-a"))
1005 .unwrap();
1006 catalog
1007 .apply_update(range(
1008 &orders,
1009 2,
1010 RangeBounds::new(RangeBound::key(b"m"), RangeBound::Max).unwrap(),
1011 "CN=node-b",
1012 ))
1013 .unwrap();
1014 (catalog, orders)
1015 }
1016
1017 fn target(coll: &CollectionId, key: &[u8]) -> KeyTarget {
1018 KeyTarget::new(coll.clone(), key.to_vec())
1019 }
1020
1021 #[test]
1024 fn single_writer_transaction_succeeds() {
1025 let orders = collection("orders");
1026 let mut catalog = ShardOwnershipCatalog::new();
1027 catalog
1029 .apply_update(range(&orders, 1, bounds(b"a", b"m"), "CN=node-a"))
1030 .unwrap();
1031 catalog
1032 .apply_update(range(
1033 &orders,
1034 2,
1035 RangeBounds::new(RangeBound::key(b"m"), RangeBound::Max).unwrap(),
1036 "CN=node-a",
1037 ))
1038 .unwrap();
1039
1040 let plan = catalog
1041 .plan_write_transaction(&[target(&orders, b"alice"), target(&orders, b"zeb")])
1042 .expect("single-writer transaction is admitted");
1043 assert_eq!(plan.writer(), &ident("CN=node-a"));
1044 let ids: Vec<u64> = plan.ranges().iter().map(|r| r.range_id().value()).collect();
1046 assert_eq!(ids, vec![1, 2]);
1047 assert_eq!(plan.ranges()[0].epoch(), OwnershipEpoch::initial());
1048 }
1049
1050 #[test]
1052 fn single_range_transaction_succeeds() {
1053 let (catalog, orders) = two_range_catalog();
1054 let plan = catalog
1055 .plan_write_transaction(&[target(&orders, b"alice"), target(&orders, b"bob")])
1056 .expect("single-range transaction is admitted");
1057 assert_eq!(plan.writer(), &ident("CN=node-a"));
1058 assert_eq!(plan.ranges().len(), 1);
1059 assert_eq!(plan.ranges()[0].range_id(), RangeId::new(1));
1060 }
1061
1062 #[test]
1065 fn cross_range_write_transaction_is_rejected() {
1066 let (catalog, orders) = two_range_catalog();
1067 let err = catalog
1068 .plan_write_transaction(&[target(&orders, b"alice"), target(&orders, b"zeb")])
1069 .expect_err("cross-writer transaction is rejected");
1070 match err {
1071 WriteTransactionReject::CrossRange { writers } => {
1072 assert_eq!(writers.len(), 2);
1073 assert_eq!(writers[0].writer(), &ident("CN=node-a"));
1074 assert_eq!(writers[1].writer(), &ident("CN=node-b"));
1075 assert_eq!(writers[0].ranges()[0].range_id(), RangeId::new(1));
1076 assert_eq!(writers[1].ranges()[0].range_id(), RangeId::new(2));
1077 }
1078 other => panic!("expected CrossRange, got {other:?}"),
1079 }
1080 }
1081
1082 #[test]
1083 fn empty_write_transaction_is_rejected() {
1084 let catalog = ShardOwnershipCatalog::new();
1085 assert_eq!(
1086 catalog.plan_write_transaction(&[]),
1087 Err(WriteTransactionReject::Empty)
1088 );
1089 }
1090
1091 #[test]
1092 fn unroutable_write_transaction_is_rejected() {
1093 let catalog = ShardOwnershipCatalog::new();
1094 let orders = collection("orders");
1095 match catalog.plan_write_transaction(&[target(&orders, b"x")]) {
1096 Err(WriteTransactionReject::Unroutable { collection, key }) => {
1097 assert_eq!(collection, orders);
1098 assert_eq!(key, b"x");
1099 }
1100 other => panic!("expected Unroutable, got {other:?}"),
1101 }
1102 }
1103
1104 #[test]
1106 fn explicit_read_fanout_collects_per_owner_legs() {
1107 let (catalog, orders) = two_range_catalog();
1108 let fanout = catalog
1109 .plan_read_fanout(
1110 &[
1111 target(&orders, b"alice"),
1112 target(&orders, b"zeb"),
1113 target(&orders, b"bob"),
1114 ],
1115 ReadFanoutPolicy::explicit(ReadFanoutBudget::default()).allowing_partial(),
1116 )
1117 .expect("fanout planned");
1118 assert!(fanout.is_cross_range());
1119 assert_eq!(fanout.legs().len(), 2);
1120 assert_eq!(fanout.trace().target_count(), 3);
1121 assert_eq!(fanout.trace().owner_count(), 2);
1122 assert_eq!(fanout.trace().range_count(), 2);
1123 assert!(fanout.trace().partial_allowed());
1124 let a = &fanout.legs()[0];
1126 assert_eq!(a.owner(), &ident("CN=node-a"));
1127 assert_eq!(a.targets().len(), 2);
1128 let b = &fanout.legs()[1];
1129 assert_eq!(b.owner(), &ident("CN=node-b"));
1130 assert_eq!(b.targets().len(), 1);
1131 assert_eq!(b.targets()[0].key(), b"zeb");
1132 }
1133
1134 #[test]
1135 fn cross_owner_read_requires_explicit_fanout() {
1136 let (catalog, orders) = two_range_catalog();
1137 match catalog.plan_read_fanout(
1138 &[target(&orders, b"alice"), target(&orders, b"zeb")],
1139 ReadFanoutPolicy::single_owner_only(),
1140 ) {
1141 Err(ReadFanoutReject::FanoutNotExplicit { owners }) => {
1142 assert_eq!(owners, vec![ident("CN=node-a"), ident("CN=node-b")]);
1143 }
1144 other => panic!("expected FanoutNotExplicit, got {other:?}"),
1145 }
1146 }
1147
1148 #[test]
1149 fn explicit_read_fanout_enforces_owner_budget() {
1150 let (catalog, orders) = two_range_catalog();
1151 match catalog.plan_read_fanout(
1152 &[target(&orders, b"alice"), target(&orders, b"zeb")],
1153 ReadFanoutPolicy::explicit(ReadFanoutBudget::new(1, 2, 8)),
1154 ) {
1155 Err(ReadFanoutReject::OwnerBudgetExceeded { requested, max }) => {
1156 assert_eq!(requested, 2);
1157 assert_eq!(max, 1);
1158 }
1159 other => panic!("expected OwnerBudgetExceeded, got {other:?}"),
1160 }
1161 }
1162
1163 #[test]
1164 fn explicit_read_fanout_enforces_range_budget() {
1165 let (catalog, orders) = two_range_catalog();
1166 match catalog.plan_read_fanout(
1167 &[target(&orders, b"alice"), target(&orders, b"zeb")],
1168 ReadFanoutPolicy::explicit(ReadFanoutBudget::new(2, 1, 8)),
1169 ) {
1170 Err(ReadFanoutReject::RangeBudgetExceeded { requested, max }) => {
1171 assert_eq!(requested, 2);
1172 assert_eq!(max, 1);
1173 }
1174 other => panic!("expected RangeBudgetExceeded, got {other:?}"),
1175 }
1176 }
1177
1178 #[test]
1179 fn explicit_read_fanout_enforces_target_budget_before_routing() {
1180 let (catalog, orders) = two_range_catalog();
1181 match catalog.plan_read_fanout(
1182 &[target(&orders, b"alice"), target(&orders, b"bob")],
1183 ReadFanoutPolicy::explicit(ReadFanoutBudget::new(2, 2, 1)),
1184 ) {
1185 Err(ReadFanoutReject::TargetBudgetExceeded { requested, max }) => {
1186 assert_eq!(requested, 2);
1187 assert_eq!(max, 1);
1188 }
1189 other => panic!("expected TargetBudgetExceeded, got {other:?}"),
1190 }
1191 }
1192
1193 #[test]
1194 fn single_owner_read_is_not_cross_range() {
1195 let (catalog, orders) = two_range_catalog();
1196 let fanout = catalog
1197 .plan_read_fanout(
1198 &[target(&orders, b"alice"), target(&orders, b"bob")],
1199 ReadFanoutPolicy::single_owner_only(),
1200 )
1201 .expect("fanout planned");
1202 assert!(!fanout.is_cross_range());
1203 assert_eq!(fanout.legs().len(), 1);
1204 assert_eq!(fanout.trace().owner_count(), 1);
1205 assert_eq!(fanout.trace().range_count(), 1);
1206 assert!(!fanout.trace().partial_allowed());
1207 }
1208
1209 #[test]
1210 fn single_owner_multi_range_read_is_cross_range_but_not_cross_owner() {
1211 let orders = collection("orders");
1212 let mut catalog = ShardOwnershipCatalog::new();
1213 catalog
1214 .apply_update(range(&orders, 1, bounds(b"a", b"m"), "CN=node-a"))
1215 .unwrap();
1216 catalog
1217 .apply_update(range(
1218 &orders,
1219 2,
1220 RangeBounds::new(RangeBound::key(b"m"), RangeBound::Max).unwrap(),
1221 "CN=node-a",
1222 ))
1223 .unwrap();
1224
1225 let fanout = catalog
1226 .plan_read_fanout(
1227 &[target(&orders, b"alice"), target(&orders, b"zeb")],
1228 ReadFanoutPolicy::single_owner_only(),
1229 )
1230 .expect("same-owner multi-range read is admitted");
1231 assert!(fanout.is_cross_range());
1232 assert_eq!(fanout.trace().owner_count(), 1);
1233 assert_eq!(fanout.trace().range_count(), 2);
1234 }
1235
1236 #[test]
1237 fn unroutable_read_fanout_is_rejected() {
1238 let catalog = ShardOwnershipCatalog::new();
1239 let orders = collection("orders");
1240 match catalog.plan_read_fanout(
1241 &[target(&orders, b"x")],
1242 ReadFanoutPolicy::single_owner_only(),
1243 ) {
1244 Err(ReadFanoutReject::Unroutable { collection, .. }) => {
1245 assert_eq!(collection, orders)
1246 }
1247 other => panic!("expected Unroutable, got {other:?}"),
1248 }
1249 }
1250
1251 #[test]
1253 fn consistent_read_without_snapshot_is_rejected() {
1254 let (catalog, orders) = two_range_catalog();
1255 assert_eq!(
1256 catalog
1257 .plan_consistent_read(&[target(&orders, b"alice"), target(&orders, b"zeb")], None),
1258 Err(ConsistentReadReject::NoSafeSnapshot)
1259 );
1260 }
1261
1262 #[test]
1264 fn consistent_read_with_incomplete_snapshot_is_rejected() {
1265 let (catalog, orders) = two_range_catalog();
1266 let snapshot = GlobalReadWatermark::new().with(
1268 orders.clone(),
1269 RangeId::new(1),
1270 CommitWatermark::new(1, 100),
1271 );
1272 match catalog.plan_consistent_read(
1273 &[target(&orders, b"alice"), target(&orders, b"zeb")],
1274 Some(&snapshot),
1275 ) {
1276 Err(ConsistentReadReject::WatermarkGap {
1277 collection,
1278 range_id,
1279 }) => {
1280 assert_eq!(collection, orders);
1281 assert_eq!(range_id, RangeId::new(2));
1282 }
1283 other => panic!("expected WatermarkGap, got {other:?}"),
1284 }
1285 }
1286
1287 #[test]
1290 fn consistent_read_with_full_snapshot_succeeds() {
1291 let (catalog, orders) = two_range_catalog();
1292 let snapshot = GlobalReadWatermark::new()
1293 .with(
1294 orders.clone(),
1295 RangeId::new(1),
1296 CommitWatermark::new(1, 100),
1297 )
1298 .with(
1299 orders.clone(),
1300 RangeId::new(2),
1301 CommitWatermark::new(1, 250),
1302 );
1303 let plan = catalog
1304 .plan_consistent_read(
1305 &[target(&orders, b"alice"), target(&orders, b"zeb")],
1306 Some(&snapshot),
1307 )
1308 .expect("consistent read planned");
1309 assert_eq!(plan.legs().len(), 2);
1310 let a = &plan.legs()[0];
1311 assert_eq!(a.owner(), &ident("CN=node-a"));
1312 assert_eq!(a.targets()[0].watermark(), CommitWatermark::new(1, 100));
1313 let b = &plan.legs()[1];
1314 assert_eq!(b.owner(), &ident("CN=node-b"));
1315 assert_eq!(b.targets()[0].watermark(), CommitWatermark::new(1, 250));
1316 }
1317
1318 #[test]
1319 fn empty_consistent_read_is_rejected() {
1320 let catalog = ShardOwnershipCatalog::new();
1321 assert_eq!(
1322 catalog.plan_consistent_read(&[], None),
1323 Err(ConsistentReadReject::Empty)
1324 );
1325 }
1326
1327 #[test]
1329 fn cross_range_rejection_message_names_writers() {
1330 let (catalog, orders) = two_range_catalog();
1331 let err = catalog
1332 .plan_write_transaction(&[target(&orders, b"alice"), target(&orders, b"zeb")])
1333 .unwrap_err();
1334 let msg = err.to_string();
1335 assert!(msg.contains("cross-range write transaction"));
1336 assert!(msg.contains("CN=node-a"));
1337 assert!(msg.contains("CN=node-b"));
1338 }
1339
1340 #[test]
1341 fn exact_claim_confined_to_one_owner_is_admitted() {
1342 let (catalog, orders) = two_range_catalog();
1343
1344 let plan = catalog
1345 .plan_exact_claim(&[target(&orders, b"alice"), target(&orders, b"bob")])
1346 .expect("owner-local exact claim is admitted");
1347
1348 assert_eq!(plan.writer(), &ident("CN=node-a"));
1349 assert_eq!(plan.ranges().len(), 1);
1350 assert_eq!(plan.ranges()[0].range_id(), RangeId::new(1));
1351 }
1352
1353 #[test]
1354 fn cross_owner_exact_claim_is_rejected_explicitly() {
1355 let (catalog, orders) = two_range_catalog();
1356
1357 let err = catalog
1358 .plan_exact_claim(&[target(&orders, b"alice"), target(&orders, b"zeb")])
1359 .expect_err("cross-owner exact claim is outside the first cluster contract");
1360
1361 match &err {
1362 ExactClaimReject::CrossOwner { writers } => {
1363 assert_eq!(writers.len(), 2);
1364 assert_eq!(writers[0].writer(), &ident("CN=node-a"));
1365 assert_eq!(writers[1].writer(), &ident("CN=node-b"));
1366 }
1367 other => panic!("expected CrossOwner, got {other:?}"),
1368 }
1369 assert!(err.to_string().contains("cross-owner exact claim"));
1370 assert!(err.to_string().contains("first cluster contract"));
1371 }
1372}