Skip to main content

reddb_server/cluster/
cross_range.rs

1//! First-cut cross-range transaction and read behaviour (issue #1002, PRD #987).
2//!
3//! Once a collection is split into independently-owned ranges (issue #989's
4//! catalog, routed by [`plan_route`](ShardOwnershipCatalog::plan_route)), a
5//! single client operation can touch keys that land in *different* ranges — and,
6//! in a multi-writer cluster, those ranges can be owned by *different writers*.
7//! This module is the request-layer gate that decides what the first multi-writer
8//! cut is allowed to do with such an operation. It deliberately refuses to
9//! pretend a cross-writer operation is globally atomic or globally
10//! snapshot-consistent when nothing underneath guarantees it.
11//!
12//! Three decisions live here, all pure reads of the [`ShardOwnershipCatalog`]:
13//!
14//! * **Write transactions** ([`plan_write_transaction`]). Resolve every targeted
15//!   key to its owning range and group by writer. A transaction confined to a
16//!   *single* writer (even one that spans several of that writer's own ranges)
17//!   commits on that owner and is admitted. A transaction whose keys span ranges
18//!   owned by *different* writers has no atomic-commit path in this cut, so it is
19//!   rejected with [`WriteTransactionReject::CrossRange`] naming every writer
20//!   involved — a clear "unsupported" contract rather than a silent partial
21//!   commit.
22//!
23//! * **Simple read fanout** ([`plan_read_fanout`]). A best-effort read may span
24//!   any number of range owners; the plan collects one [`ReadLeg`] per owner so
25//!   the caller can scatter the read and gather the results. This is explicitly
26//!   *not* a globally consistent snapshot — each leg observes its owner at
27//!   whatever point it happens to be at — and the type name says so.
28//!
29//! * **Consistent / transactional reads** ([`plan_consistent_read`]). A read that
30//!   must look globally consistent needs a safe snapshot point that covers every
31//!   range it touches. The caller supplies a [`GlobalReadWatermark`]; the plan
32//!   pins each leg to that range's watermark. With no snapshot the request fails
33//!   with [`ConsistentReadReject::NoSafeSnapshot`]; with a snapshot that is
34//!   missing a targeted range it fails with [`ConsistentReadReject::WatermarkGap`].
35//!   Either way the caller learns it cannot get a consistent answer rather than
36//!   getting an inconsistent one dressed up as consistent.
37//!
38//! Like the rest of the cluster module this is a pure decision layer with no I/O:
39//! it maps a catalog plus a set of [`KeyTarget`]s to an intent, so the
40//! cross-range contract is exercised deterministically. The transport that
41//! actually fans the legs out and the storage that admits each write are layered
42//! on top.
43
44use std::collections::BTreeMap;
45
46use super::identity::NodeIdentity;
47use super::ownership::{CollectionId, OwnershipEpoch, RangeId, ShardOwnershipCatalog};
48use super::ownership_transition::CommitWatermark;
49
50/// One `(collection, key)` a cross-range operation touches.
51///
52/// A transaction or multi-key read is just a set of these; the catalog resolves
53/// each to its owning range to decide whether the operation crosses writers.
54#[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/// A targeted key resolved to the range that owns it — the catalog read every
78/// cross-range decision is built from.
79#[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/// A range a writer owns that an operation touches, with the epoch the caller
111/// must fence each write under (the same epoch
112/// [`admit_public_write`](ShardOwnershipCatalog::admit_public_write) checks).
113#[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/// One writer's participation in a cross-range write transaction: the writer and
135/// the distinct ranges of theirs the transaction touches. Used both as the
136/// admitted single-writer plan and, in the rejection, to name each writer the
137/// transaction would have had to coordinate.
138#[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/// An admitted single-writer write transaction: every targeted key resolves to a
155/// range owned by the *same* writer, so the transaction commits atomically on
156/// that owner. Carries each participating range so the caller fences every write
157/// at the right epoch.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct WriteTransactionPlan {
160    participation: WriterParticipation,
161}
162
163impl WriteTransactionPlan {
164    /// The single writer that owns every range the transaction touches.
165    pub fn writer(&self) -> &NodeIdentity {
166        self.participation.writer()
167    }
168
169    /// The distinct ranges (with fencing epochs) the transaction writes.
170    pub fn ranges(&self) -> &[RangeParticipant] {
171        self.participation.ranges()
172    }
173}
174
175/// An admitted exact claim: every candidate key resolves to one writer, so that
176/// owner is the only member allowed to choose the winners.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct ExactClaimPlan {
179    participation: WriterParticipation,
180}
181
182impl ExactClaimPlan {
183    /// The single writer that owns every candidate range.
184    pub fn writer(&self) -> &NodeIdentity {
185        self.participation.writer()
186    }
187
188    /// The distinct ranges (with fencing epochs) the claim may inspect.
189    pub fn ranges(&self) -> &[RangeParticipant] {
190        self.participation.ranges()
191    }
192}
193
194/// Why a write transaction could not be planned in the first multi-writer cut.
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub enum WriteTransactionReject {
197    /// The transaction named no targets — there is nothing to commit.
198    Empty,
199    /// A targeted key resolves to no range of its collection; routing is stale or
200    /// the collection is not yet placed. The caller must refresh its catalog.
201    Unroutable {
202        collection: CollectionId,
203        key: Vec<u8>,
204    },
205    /// The transaction's keys span ranges owned by **different writers**. There is
206    /// no atomic cross-writer commit path in this cut, so the transaction is
207    /// rejected rather than committed partially. Carries every writer involved (in
208    /// identity order) so the caller sees exactly which owners it straddled.
209    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/// Why an exact claim could not be planned in the first cluster contract.
248#[derive(Debug, Clone, PartialEq, Eq)]
249pub enum ExactClaimReject {
250    /// The claim named no candidate keys.
251    Empty,
252    /// A candidate key resolves to no range of its collection.
253    Unroutable {
254        collection: CollectionId,
255        key: Vec<u8>,
256    },
257    /// Candidate keys span ranges owned by different writers. Exact claim needs
258    /// one authority to decide all winners, so this is explicitly unsupported in
259    /// the first cluster contract.
260    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/// One owner's leg of a read: the owner and the resolved targets to read there.
299#[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/// A simple, best-effort cross-range read split into one [`ReadLeg`] per owner.
316///
317/// **Not** a globally consistent snapshot: each leg observes its owner at
318/// whatever point that owner is at when it answers, so two legs may reflect
319/// different moments in time. For a globally consistent answer use
320/// [`plan_consistent_read`](ShardOwnershipCatalog::plan_consistent_read).
321#[derive(Debug, Clone, PartialEq, Eq)]
322pub struct ReadFanout {
323    legs: Vec<ReadLeg>,
324}
325
326impl ReadFanout {
327    /// One leg per distinct owner, in identity order.
328    pub fn legs(&self) -> &[ReadLeg] {
329        &self.legs
330    }
331
332    /// Whether the read fans out to more than one owner.
333    pub fn is_cross_range(&self) -> bool {
334        self.legs.len() > 1
335    }
336}
337
338/// Why a simple read fanout could not be planned.
339#[derive(Debug, Clone, PartialEq, Eq)]
340pub enum ReadFanoutReject {
341    /// The read named no targets.
342    Empty,
343    /// A targeted key resolves to no range of its collection.
344    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/// A safe snapshot point for a globally consistent cross-range read: a commit
366/// watermark per `(collection, range)` that the read pins itself to.
367///
368/// This is the "explicit safe snapshot/watermark path" the issue requires before
369/// a cross-range read may claim to be consistent. A consistent read must find a
370/// watermark here for **every** range it touches; a missing entry means the
371/// snapshot does not cover that range and the read cannot be served consistently.
372#[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    /// Pin `range`'s safe read point to `watermark` (builder form).
383    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    /// Record `range`'s safe read point.
394    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    /// The pinned watermark for a range, or `None` if the snapshot does not cover
404    /// it.
405    pub fn covers(&self, collection: &CollectionId, range_id: RangeId) -> Option<CommitWatermark> {
406        self.marks.get(&(collection.clone(), range_id)).copied()
407    }
408}
409
410/// One owner's leg of a consistent read: each resolved target paired with the
411/// safe watermark its range is pinned to for this snapshot.
412#[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/// A resolved target pinned to the safe read watermark of its range.
429#[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/// A globally consistent cross-range read, pinned to a safe snapshot covering
446/// every range it touches.
447#[derive(Debug, Clone, PartialEq, Eq)]
448pub struct ConsistentReadPlan {
449    legs: Vec<ConsistentReadLeg>,
450}
451
452impl ConsistentReadPlan {
453    /// One leg per distinct owner, in identity order, each pinned to its range's
454    /// safe watermark.
455    pub fn legs(&self) -> &[ConsistentReadLeg] {
456        &self.legs
457    }
458}
459
460/// Why a consistent cross-range read could not be planned.
461#[derive(Debug, Clone, PartialEq, Eq)]
462pub enum ConsistentReadReject {
463    /// The read named no targets.
464    Empty,
465    /// A targeted key resolves to no range of its collection.
466    Unroutable {
467        collection: CollectionId,
468        key: Vec<u8>,
469    },
470    /// No safe snapshot was supplied. A cross-range read cannot be served
471    /// consistently without a global watermark, so the request fails clearly
472    /// rather than silently degrading to a best-effort fanout.
473    NoSafeSnapshot,
474    /// The supplied snapshot does not cover a targeted range, so the read cannot
475    /// be pinned to a single safe point across all of its ranges.
476    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
508/// Hex-ish key rendering for error messages — keys are arbitrary bytes.
509struct 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    /// Resolve every target to its owning range, preserving caller order.
523    /// `Err` on the first key no range covers.
524    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    /// Plan a write transaction over `targets` in the first multi-writer cut
545    /// (issue #1002).
546    ///
547    /// Resolves every targeted key to its owning range and groups by writer:
548    ///
549    /// * all targets owned by one writer → [`WriteTransactionPlan`] (the
550    ///   transaction commits atomically on that owner, even across several of its
551    ///   own ranges);
552    /// * targets span ranges of different writers →
553    ///   [`WriteTransactionReject::CrossRange`] naming every writer — this cut has
554    ///   no atomic cross-writer commit;
555    /// * a target routes nowhere → [`WriteTransactionReject::Unroutable`].
556    ///
557    /// Pure: it reads the catalog and returns intent. Each admitted write still
558    /// passes [`admit_public_write`](Self::admit_public_write) at the owner's
559    /// current epoch, so a stale plan cannot smuggle a write past fencing.
560    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    /// Plan an exact claim over `targets` in the first cluster contract.
588    ///
589    /// A claim winner is a non-deterministic write decision, so only one write
590    /// authority may inspect the candidate set and choose winners. Claims whose
591    /// candidates all route to one owner are admitted for that owner. Claims
592    /// whose candidates require multiple owners are rejected explicitly; later
593    /// cluster contracts can add coordinated claims without weakening this one.
594    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    /// Plan a simple, best-effort cross-range read fanout over `targets`
622    /// (issue #1002).
623    ///
624    /// Collects the resolved targets into one [`ReadLeg`] per owner so the caller
625    /// can scatter the read across every range owner and gather the results. This
626    /// is *not* a consistent snapshot — see [`ReadFanout`]. `Err` only when the
627    /// read is empty or a target routes nowhere; spanning many owners is the
628    /// expected, successful case.
629    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    /// Plan a globally consistent cross-range read over `targets`, pinned to
643    /// `snapshot` (issue #1002).
644    ///
645    /// A consistent read must pin every range it touches to a single safe point:
646    ///
647    /// * `snapshot` is `None` → [`ConsistentReadReject::NoSafeSnapshot`]; the
648    ///   caller must obtain a global watermark first;
649    /// * a targeted range is absent from `snapshot` →
650    ///   [`ConsistentReadReject::WatermarkGap`];
651    /// * a target routes nowhere → [`ConsistentReadReject::Unroutable`];
652    /// * otherwise → a [`ConsistentReadPlan`] with each leg pinned to its range's
653    ///   watermark.
654    ///
655    /// This is the explicit safe-snapshot path: without it a cross-range read may
656    /// only be served as a best-effort [`ReadFanout`], never as a consistent one.
657    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        // Every targeted range must be covered by the snapshot before any leg is
672        // built — a partial pin is not a consistent read.
673        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
690/// Group resolved targets by owner into `(writer, distinct ranges)` pairs, in
691/// identity order. Ranges within a writer are deduplicated and ordered by id.
692fn 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
711/// Group resolved targets into one [`ReadLeg`] per owner, in identity order.
712fn 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
723/// Group pinned targets into one [`ConsistentReadLeg`] per owner, in identity
724/// order.
725fn 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    /// A range over `[lower, upper)` of `coll` owned by `owner`.
757    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    /// Catalog with `orders` split into two ranges: [a,m) owned by node-a,
775    /// [m,Max) owned by node-b.
776    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    // AC #5: a write transaction whose keys all land in one writer's ranges is
798    // admitted — even when it spans several of that writer's ranges.
799    #[test]
800    fn single_writer_transaction_succeeds() {
801        let orders = collection("orders");
802        let mut catalog = ShardOwnershipCatalog::new();
803        // Two ranges both owned by node-a.
804        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        // Both of node-a's ranges participate, deduplicated and id-ordered.
821        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    // AC #5: keys that all land in a single range are trivially single-writer.
827    #[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    // AC #1 + #2: a transaction straddling ranges owned by different writers is
839    // detected and rejected, naming both writers.
840    #[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    // AC #3: a simple read fanout collects one leg per owner across ranges.
881    #[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        // node-a leg gets alice + bob (range 1); node-b leg gets zeb (range 2).
894        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    // AC #4: a consistent cross-range read with no snapshot fails clearly.
926    #[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    // AC #4: a snapshot missing a targeted range fails with a watermark gap.
937    #[test]
938    fn consistent_read_with_incomplete_snapshot_is_rejected() {
939        let (catalog, orders) = two_range_catalog();
940        // Snapshot covers range 1 but not range 2.
941        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    // AC #4: with a snapshot covering every targeted range, the consistent read
962    // is planned and each leg is pinned to its range's watermark.
963    #[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    // The rejection contract renders a readable, writer-naming message.
1002    #[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}