Skip to main content

reddb_server/cluster/
topology.rs

1//! Topology refresh and routing-hint client contract (issue #994, PRD #987, ADR 0037).
2//!
3//! Any-node routing ([`plan_route`](ShardOwnershipCatalog::plan_route), issue
4//! #993) lets a request land on any data member and still do something correct.
5//! This module is the *client-facing* half of that story: the contract a driver
6//! uses to learn the cluster's shape, route directly to range owners, and stay
7//! correct as ownership moves. Three mechanisms, in strict priority of authority:
8//!
9//! 1. **Polling** is the baseline and the source of authority. A driver fetches a
10//!    [`TopologySnapshot`] — every range's bounds, owner, replicas, ownership
11//!    epoch, and catalog version — and caches it in a [`ClientTopology`]. This is
12//!    the only path that establishes *authoritative* topology, and a driver that
13//!    only ever polls is always eventually correct.
14//!
15//! 2. **Routing hints** ([`RoutingHint`](super::routing::RoutingHint), carried on
16//!    a redirect response from issue #993) are an *advisory correction*. When a
17//!    write reaches a stale owner the response names the current owner+epoch; the
18//!    driver applies that hint to stop hammering the stale node, but the hint is
19//!    explicitly **not** authoritative — it cannot introduce ranges, it carries no
20//!    replica set, and applying one raises [`needs_refresh`](ClientTopology::needs_refresh)
21//!    so the driver knows to reconcile against an authoritative poll. This is
22//!    ADR 0037's "stale ownership responses remain the mandatory correctness path"
23//!    expressed on the client: correctness never *depends* on a hint, a hint only
24//!    *accelerates* convergence.
25//!
26//! 3. **Push / subscription updates** ([`TopologyUpdate`]) are an optional
27//!    accelerator where the transport supports them. A pushed snapshot or
28//!    single-range delta flows through the *same monotonic apply path* as a poll,
29//!    so a driver that misses a push is never wrong — the next poll (or the next
30//!    redirect hint) carries it forward. Push is never mandatory for correctness.
31//!
32//! Like the rest of the cluster module this is a pure data/decision layer with no
33//! I/O: [`topology_snapshot`](ShardOwnershipCatalog::topology_snapshot) projects a
34//! catalog into a driver-facing payload, and [`ClientTopology`] models exactly how
35//! a driver folds polls, hints, and pushes together. The transport that serialises
36//! a snapshot onto the wire or pushes a delta is a separate concern on top.
37
38use std::collections::BTreeMap;
39
40use super::identity::NodeIdentity;
41use super::ownership::{
42    CatalogVersion, CollectionGroupId, CollectionId, OwnershipEpoch, PlacementAuthorityId,
43    RangeBounds, RangeId, RangeOwnership, ReplicaRole, ShardKeyMode, ShardOwnershipCatalog,
44};
45use super::routing::RoutingHint;
46use super::slot::hash_shard_key_to_range_key;
47use crate::replication::{ReceivedSignal, SignalPlaneMessage};
48
49/// What authority a topology/serving-graph projection carries.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum TopologyAuthority {
52    /// Consumable by routers and drivers, but never sufficient to authorize a
53    /// durable write without the owner-side epoch fence.
54    RoutingMetadataOnly,
55}
56
57/// One range's routing metadata as a driver sees it.
58///
59/// Carries everything a driver needs to route a key directly to its owner and
60/// fence the write at the right epoch: the half-open [`bounds`](Self::bounds) for
61/// client-side range routing, the [`owner`](Self::owner) to send to, the
62/// [`replicas`](Self::replicas) for read fan-out, the [`epoch`](Self::epoch) to
63/// stamp a write at, and the [`version`](Self::version) used to decide whether an
64/// incoming update is newer than what is cached.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct TopologyRange {
67    collection: CollectionId,
68    collection_group: CollectionGroupId,
69    placement_authority: PlacementAuthorityId,
70    range_id: RangeId,
71    shard_key_mode: ShardKeyMode,
72    bounds: RangeBounds,
73    owner: NodeIdentity,
74    replicas: Vec<NodeIdentity>,
75    compressed_archive_replicas: Vec<NodeIdentity>,
76    epoch: OwnershipEpoch,
77    version: CatalogVersion,
78}
79
80impl TopologyRange {
81    fn from_ownership(range: &RangeOwnership) -> Self {
82        Self {
83            collection: range.collection().clone(),
84            collection_group: range.collection_group().clone(),
85            placement_authority: range.placement_authority().clone(),
86            range_id: range.range_id(),
87            shard_key_mode: range.shard_key_mode(),
88            bounds: range.bounds().clone(),
89            owner: range.owner().clone(),
90            replicas: range.hot_mirror_replicas().to_vec(),
91            compressed_archive_replicas: range.compressed_archive_replicas().to_vec(),
92            epoch: range.epoch(),
93            version: range.version(),
94        }
95    }
96
97    pub fn collection(&self) -> &CollectionId {
98        &self.collection
99    }
100
101    pub fn collection_group(&self) -> &CollectionGroupId {
102        &self.collection_group
103    }
104
105    pub fn placement_authority(&self) -> &PlacementAuthorityId {
106        &self.placement_authority
107    }
108
109    pub fn range_id(&self) -> RangeId {
110        self.range_id
111    }
112
113    pub fn shard_key_mode(&self) -> ShardKeyMode {
114        self.shard_key_mode
115    }
116
117    pub fn bounds(&self) -> &RangeBounds {
118        &self.bounds
119    }
120
121    pub fn owner(&self) -> &NodeIdentity {
122        &self.owner
123    }
124
125    pub fn range_owner(&self) -> &NodeIdentity {
126        &self.owner
127    }
128
129    pub fn replicas(&self) -> &[NodeIdentity] {
130        &self.replicas
131    }
132
133    pub fn hot_mirror_replicas(&self) -> &[NodeIdentity] {
134        &self.replicas
135    }
136
137    pub fn compressed_archive_replicas(&self) -> &[NodeIdentity] {
138        &self.compressed_archive_replicas
139    }
140
141    pub fn replica_role_of(&self, node: &NodeIdentity) -> Option<ReplicaRole> {
142        if self.replicas.iter().any(|replica| replica == node) {
143            Some(ReplicaRole::HotMirror)
144        } else if self
145            .compressed_archive_replicas
146            .iter()
147            .any(|replica| replica == node)
148        {
149            Some(ReplicaRole::CompressedArchive)
150        } else {
151            None
152        }
153    }
154
155    pub fn promotion_candidates(&self) -> &[NodeIdentity] {
156        &self.replicas
157    }
158
159    /// The epoch a driver should stamp a write to this range at — the same epoch
160    /// the owner's [`admit_public_write`](ShardOwnershipCatalog::admit_public_write)
161    /// gate will check (issue #990).
162    pub fn epoch(&self) -> OwnershipEpoch {
163        self.epoch
164    }
165
166    pub fn version(&self) -> CatalogVersion {
167        self.version
168    }
169
170    fn key(&self) -> (CollectionId, RangeId) {
171        (self.collection.clone(), self.range_id)
172    }
173}
174
175/// A point-in-time, driver-facing projection of the ownership catalog — the
176/// payload a topology poll returns.
177///
178/// The [`version`](Self::version) is the snapshot's high-water mark: the
179/// **maximum** catalog version across its ranges (or [`CatalogVersion::initial`]
180/// for an empty cluster). It is monotonic, but not a complete generation number:
181/// two different ranges can independently advance to the same version. Drivers
182/// therefore use it as a cheap stale-snapshot guard and still compare per-range
183/// content for same-version full refreshes.
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct TopologySnapshot {
186    version: CatalogVersion,
187    ranges: Vec<TopologyRange>,
188}
189
190impl TopologySnapshot {
191    /// The authority level carried by this serving graph projection.
192    pub fn authority(&self) -> TopologyAuthority {
193        TopologyAuthority::RoutingMetadataOnly
194    }
195
196    /// The snapshot generation — the high-water catalog version across its ranges.
197    pub fn version(&self) -> CatalogVersion {
198        self.version
199    }
200
201    /// Every range in the snapshot, in `(collection, range_id)` order.
202    pub fn ranges(&self) -> &[TopologyRange] {
203        &self.ranges
204    }
205
206    /// Look up one range by identity.
207    pub fn range(&self, collection: &CollectionId, range_id: RangeId) -> Option<&TopologyRange> {
208        self.ranges
209            .iter()
210            .find(|r| r.collection() == collection && r.range_id() == range_id)
211    }
212
213    /// Route a normalized range key to the range that owns it, by the same
214    /// half-open containment predicate the server uses.
215    pub fn route(&self, collection: &CollectionId, key: &[u8]) -> Option<&TopologyRange> {
216        self.ranges
217            .iter()
218            .find(|r| r.collection() == collection && r.bounds().contains(key))
219    }
220
221    /// Route a logical shard key through the collection's shard-key mode.
222    pub fn route_shard_key(
223        &self,
224        collection: &CollectionId,
225        shard_key: &[u8],
226    ) -> Option<&TopologyRange> {
227        match self.shard_key_mode(collection)? {
228            ShardKeyMode::Ordered => self.route(collection, shard_key),
229            ShardKeyMode::Hash => {
230                let range_key = hash_shard_key_to_range_key(shard_key);
231                self.route(collection, &range_key)
232            }
233        }
234    }
235
236    fn shard_key_mode(&self, collection: &CollectionId) -> Option<ShardKeyMode> {
237        self.ranges
238            .iter()
239            .find(|range| range.collection() == collection)
240            .map(TopologyRange::shard_key_mode)
241    }
242}
243
244impl ShardOwnershipCatalog {
245    /// Project the catalog into a driver-facing [`TopologySnapshot`] — the payload
246    /// a topology poll serves (issue #994).
247    ///
248    /// The snapshot carries every range's bounds, owner, replicas, ownership
249    /// epoch, and catalog version, and stamps a generation
250    /// ([`version`](TopologySnapshot::version)) drivers use to tell a newer
251    /// snapshot from a stale one.
252    pub fn topology_snapshot(&self) -> TopologySnapshot {
253        let ranges: Vec<TopologyRange> =
254            self.entries().map(TopologyRange::from_ownership).collect();
255        let version = ranges
256            .iter()
257            .map(TopologyRange::version)
258            .max()
259            .unwrap_or_else(CatalogVersion::initial);
260        TopologySnapshot { version, ranges }
261    }
262}
263
264/// The result of folding a polled or pushed snapshot/delta into a [`ClientTopology`].
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266pub enum RefreshOutcome {
267    /// The incoming data was strictly newer and was adopted; `ranges_changed`
268    /// ranges were added or advanced.
269    Applied { ranges_changed: usize },
270    /// The incoming data was not newer than what is cached and was ignored — the
271    /// monotonicity guard that makes out-of-order and duplicate delivery safe.
272    Ignored,
273}
274
275impl RefreshOutcome {
276    pub fn was_applied(self) -> bool {
277        matches!(self, RefreshOutcome::Applied { .. })
278    }
279}
280
281/// The result of applying an advisory [`RoutingHint`](super::routing::RoutingHint)
282/// correction to a [`ClientTopology`].
283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
284pub enum HintOutcome {
285    /// The hint named a newer owner/epoch for a known range; the cached owner was
286    /// corrected and [`needs_refresh`](ClientTopology::needs_refresh) was raised.
287    Corrected,
288    /// The cache already held this range at or beyond the hint's version; nothing
289    /// changed (an authoritative apply had already overtaken the hint).
290    AlreadyCurrent,
291    /// The hint named a range the cache does not know. A hint is never
292    /// authoritative enough to *introduce* a range, so no range was created;
293    /// [`needs_refresh`](ClientTopology::needs_refresh) was raised so the driver
294    /// polls for the authoritative topology.
295    UnknownRange,
296}
297
298/// A topology change delivered over a push / subscription transport.
299///
300/// Both variants flow through the same monotonic apply path as a poll
301/// ([`ClientTopology::apply_update`]), so a missed or out-of-order push is never a
302/// correctness problem — it is only a missed *acceleration*.
303#[derive(Debug, Clone, PartialEq, Eq)]
304pub enum TopologyUpdate {
305    /// A full snapshot push — identical in effect to a poll.
306    Full(TopologySnapshot),
307    /// A single range advanced; carries just that range's new metadata.
308    Range(TopologyRange),
309}
310
311/// A driver's cached view of cluster topology, and the contract for keeping it
312/// correct (issue #994).
313///
314/// Holds the ranges the driver believes in, the authoritative
315/// [`version`](Self::version) of the last *polled or pushed* snapshot, and a
316/// [`needs_refresh`](Self::needs_refresh) flag that is raised whenever the driver
317/// is running on an advisory hint correction rather than authoritative topology.
318///
319/// The three inputs compose by authority: [`apply_refresh`](Self::apply_refresh)
320/// and [`apply_update`](Self::apply_update) are authoritative and monotonic;
321/// [`apply_hint`](Self::apply_hint) is advisory and only ever corrects a *known*
322/// range's owner/epoch. Authority always wins — an authoritative apply that is
323/// newer overwrites a prior hint correction and clears `needs_refresh`.
324#[derive(Debug, Clone, PartialEq, Eq)]
325pub struct ClientTopology {
326    version: CatalogVersion,
327    ranges: BTreeMap<(CollectionId, RangeId), TopologyRange>,
328    needs_refresh: bool,
329}
330
331impl ClientTopology {
332    /// Seed a cache from an initial topology poll.
333    pub fn from_snapshot(snapshot: TopologySnapshot) -> Self {
334        let mut cache = Self {
335            version: snapshot.version(),
336            ranges: BTreeMap::new(),
337            needs_refresh: false,
338        };
339        for range in snapshot.ranges {
340            cache.ranges.insert(range.key(), range);
341        }
342        cache
343    }
344
345    /// The authoritative generation of this cache — the version of the most recent
346    /// snapshot or range delta adopted. Advisory hint corrections do **not** move
347    /// it, so it always reflects authoritative topology.
348    pub fn version(&self) -> CatalogVersion {
349        self.version
350    }
351
352    /// Whether the cache is running on an advisory hint correction and should poll
353    /// for authoritative topology. Raised by [`apply_hint`](Self::apply_hint);
354    /// cleared by an authoritative [`apply_refresh`](Self::apply_refresh) (or a
355    /// full-snapshot [`apply_update`](Self::apply_update)).
356    pub fn needs_refresh(&self) -> bool {
357        self.needs_refresh
358    }
359
360    /// The cached range owning a normalized range key.
361    pub fn route(&self, collection: &CollectionId, key: &[u8]) -> Option<&TopologyRange> {
362        self.ranges
363            .values()
364            .find(|r| r.collection() == collection && r.bounds().contains(key))
365    }
366
367    /// The owner a driver should send a request for `key` to — the routing answer.
368    pub fn resolve(&self, collection: &CollectionId, key: &[u8]) -> Option<&NodeIdentity> {
369        self.route_shard_key(collection, key)
370            .map(TopologyRange::owner)
371    }
372
373    /// The cached range owning a logical shard key.
374    pub fn route_shard_key(
375        &self,
376        collection: &CollectionId,
377        shard_key: &[u8],
378    ) -> Option<&TopologyRange> {
379        match self.shard_key_mode(collection)? {
380            ShardKeyMode::Ordered => self.route(collection, shard_key),
381            ShardKeyMode::Hash => {
382                let range_key = hash_shard_key_to_range_key(shard_key);
383                self.route(collection, &range_key)
384            }
385        }
386    }
387
388    fn shard_key_mode(&self, collection: &CollectionId) -> Option<ShardKeyMode> {
389        self.ranges
390            .values()
391            .find(|range| range.collection() == collection)
392            .map(TopologyRange::shard_key_mode)
393    }
394
395    /// One cached range by identity.
396    pub fn range(&self, collection: &CollectionId, range_id: RangeId) -> Option<&TopologyRange> {
397        self.ranges.get(&(collection.clone(), range_id))
398    }
399
400    /// Adopt a freshly polled snapshot — the authoritative refresh path.
401    ///
402    /// Monotonic: the snapshot is adopted if its generation advances the cache's
403    /// authoritative [`version`](Self::version), or if it carries same-generation
404    /// range content that does not roll any cached range backwards. An adopted
405    /// snapshot replaces the cached ranges wholesale and clears
406    /// [`needs_refresh`](Self::needs_refresh); an older or duplicate one is
407    /// [`Ignored`](RefreshOutcome::Ignored).
408    pub fn apply_refresh(&mut self, snapshot: TopologySnapshot) -> RefreshOutcome {
409        if !self.ranges.is_empty() && snapshot.version() < self.version {
410            return RefreshOutcome::Ignored;
411        }
412        if self.snapshot_rolls_back_any_range(&snapshot) {
413            return RefreshOutcome::Ignored;
414        }
415        let mut changed = 0usize;
416        let mut next: BTreeMap<(CollectionId, RangeId), TopologyRange> = BTreeMap::new();
417        for range in snapshot.ranges {
418            let key = range.key();
419            if self.ranges.get(&key) != Some(&range) {
420                changed += 1;
421            }
422            next.insert(key, range);
423        }
424        if !self.ranges.is_empty() && snapshot.version <= self.version && changed == 0 {
425            return RefreshOutcome::Ignored;
426        }
427        self.ranges = next;
428        self.version = snapshot.version;
429        self.needs_refresh = false;
430        RefreshOutcome::Applied {
431            ranges_changed: changed,
432        }
433    }
434
435    fn snapshot_rolls_back_any_range(&self, snapshot: &TopologySnapshot) -> bool {
436        snapshot.ranges().iter().any(|incoming| {
437            self.ranges
438                .get(&incoming.key())
439                .is_some_and(|current| incoming.version() < current.version())
440        })
441    }
442
443    /// Fold a pushed topology update in — the optional push/subscription path.
444    ///
445    /// A [`Full`](TopologyUpdate::Full) push is exactly an
446    /// [`apply_refresh`](Self::apply_refresh). A [`Range`](TopologyUpdate::Range)
447    /// delta advances a single range when its version is newer than the cached
448    /// one (and bumps the authoritative version to match), or is
449    /// [`Ignored`](RefreshOutcome::Ignored) when it is not newer. Because both go
450    /// through the same monotonic guard, a missed push is never a correctness
451    /// problem — a later poll or delta carries the change forward.
452    pub fn apply_update(&mut self, update: TopologyUpdate) -> RefreshOutcome {
453        match update {
454            TopologyUpdate::Full(snapshot) => self.apply_refresh(snapshot),
455            TopologyUpdate::Range(range) => {
456                let key = range.key();
457                let newer = match self.ranges.get(&key) {
458                    Some(current) => range.version() > current.version(),
459                    None => true,
460                };
461                if !newer {
462                    return RefreshOutcome::Ignored;
463                }
464                if range.version() > self.version {
465                    self.version = range.version();
466                }
467                self.ranges.insert(key, range);
468                RefreshOutcome::Applied { ranges_changed: 1 }
469            }
470        }
471    }
472
473    /// Apply an advisory routing-hint correction from a redirect response — the
474    /// stale-ownership correctness path (issue #993, ADR 0037).
475    ///
476    /// A hint is **not** authoritative: it can only correct the owner/epoch of a
477    /// range the cache already knows, and only when it is strictly newer than the
478    /// cached range. On a correction the cached owner/epoch/version advance (the
479    /// known bounds are kept; the replica set is left as-is because a hint carries
480    /// none) and [`needs_refresh`](Self::needs_refresh) is raised so the driver
481    /// reconciles against an authoritative poll. A hint for an unknown range
482    /// creates nothing — it only raises `needs_refresh`.
483    pub fn apply_hint(&mut self, hint: &RoutingHint) -> HintOutcome {
484        let key = (hint.collection().clone(), hint.range_id());
485        match self.ranges.get_mut(&key) {
486            Some(range) => {
487                if hint.version() <= range.version {
488                    return HintOutcome::AlreadyCurrent;
489                }
490                range.owner = hint.owner().clone();
491                range.epoch = hint.epoch();
492                range.version = hint.version();
493                self.needs_refresh = true;
494                HintOutcome::Corrected
495            }
496            None => {
497                self.needs_refresh = true;
498                HintOutcome::UnknownRange
499            }
500        }
501    }
502
503    /// Consume signal-plane messages and refresh through the normal authoritative
504    /// topology path when a peer reports a newer ownership-catalog version.
505    ///
506    /// The signal is only a trigger: it does not mutate cached ownership itself.
507    /// Stale, duplicate, missing, or non-catalog signals are ignored, and the
508    /// snapshot returned by `refresh` is still applied through the same monotonic
509    /// [`apply_refresh`](Self::apply_refresh) guard as an ordinary poll.
510    pub fn refresh_on_newer_catalog_signal(
511        &mut self,
512        signals: impl IntoIterator<Item = ReceivedSignal>,
513        mut refresh: impl FnMut() -> TopologySnapshot,
514    ) -> Option<RefreshOutcome> {
515        for signal in signals {
516            let SignalPlaneMessage::CatalogVersionHint(hint) = signal.message else {
517                continue;
518            };
519            if hint.ownership_catalog_version > self.version.value() {
520                return Some(self.apply_refresh(refresh()));
521            }
522        }
523        None
524    }
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530    use crate::cluster::ownership::{PlacementMetadata, RangeBound, ShardKeyMode};
531    use crate::cluster::routing::{RequestOperation, RouteDecision, RoutedRequest, RoutingPolicy};
532
533    fn collection(name: &str) -> CollectionId {
534        CollectionId::new(name).unwrap()
535    }
536
537    fn ident(cn: &str) -> NodeIdentity {
538        NodeIdentity::from_certificate_subject(cn).unwrap()
539    }
540
541    fn full_range(coll: &CollectionId, id: u64, owner: &str, replicas: &[&str]) -> RangeOwnership {
542        RangeOwnership::establish(
543            coll.clone(),
544            RangeId::new(id),
545            ShardKeyMode::Hash,
546            RangeBounds::full(),
547            ident(owner),
548            replicas.iter().map(|r| ident(r)).collect::<Vec<_>>(),
549            PlacementMetadata::with_replication_factor(3),
550        )
551    }
552
553    fn split_range(
554        coll: &CollectionId,
555        id: u64,
556        lower: RangeBound,
557        upper: RangeBound,
558        owner: &str,
559    ) -> RangeOwnership {
560        RangeOwnership::establish(
561            coll.clone(),
562            RangeId::new(id),
563            ShardKeyMode::Ordered,
564            RangeBounds::new(lower, upper).unwrap(),
565            ident(owner),
566            Vec::<NodeIdentity>::new(),
567            PlacementMetadata::with_replication_factor(1),
568        )
569    }
570
571    fn single_hash_slot_bounds(key: &[u8]) -> RangeBounds {
572        let slot = super::super::slot::hash_shard_key_to_slot(key);
573        let lower = RangeBound::key(slot.range_key());
574        let upper = match slot.value().checked_add(1) {
575            Some(next) if next < super::super::slot::PRODUCTION_HASH_SLOT_COUNT => {
576                RangeBound::key(super::super::slot::HashSlot::new(next).unwrap().range_key())
577            }
578            _ => RangeBound::Max,
579        };
580        RangeBounds::new(lower, upper).unwrap()
581    }
582
583    fn hash_slot_range(
584        coll: &CollectionId,
585        id: u64,
586        shard_key: &[u8],
587        owner: &str,
588    ) -> RangeOwnership {
589        RangeOwnership::establish(
590            coll.clone(),
591            RangeId::new(id),
592            ShardKeyMode::Hash,
593            single_hash_slot_bounds(shard_key),
594            ident(owner),
595            Vec::<NodeIdentity>::new(),
596            PlacementMetadata::with_replication_factor(1),
597        )
598    }
599
600    fn catalog_with(ranges: impl IntoIterator<Item = RangeOwnership>) -> ShardOwnershipCatalog {
601        let mut catalog = ShardOwnershipCatalog::new();
602        for range in ranges {
603            catalog.apply_update(range).unwrap();
604        }
605        catalog
606    }
607
608    // AC #1: a topology payload carries enough per-range routing metadata (owner,
609    // replicas, epoch, version, bounds) for a driver to route directly.
610    #[test]
611    fn snapshot_exposes_routing_metadata_for_direct_routing() {
612        let orders = collection("orders");
613        let catalog = catalog_with([full_range(&orders, 1, "CN=node-a", &["CN=node-b"])]);
614
615        let snapshot = catalog.topology_snapshot();
616        assert_eq!(snapshot.version(), CatalogVersion::initial());
617        assert_eq!(snapshot.ranges().len(), 1);
618
619        let range = snapshot
620            .route(&orders, b"any-key")
621            .expect("full range covers all keys");
622        assert_eq!(range.owner(), &ident("CN=node-a"));
623        assert_eq!(range.replicas(), &[ident("CN=node-b")]);
624        assert_eq!(range.epoch(), OwnershipEpoch::initial());
625        assert_eq!(range.range_id(), RangeId::new(1));
626    }
627
628    #[test]
629    fn snapshot_exposes_explicit_replica_roles() {
630        let orders = collection("orders");
631        let catalog = catalog_with([full_range(&orders, 1, "CN=node-a", &["CN=node-b"])
632            .with_collection_group(CollectionGroupId::new("commerce").unwrap())
633            .with_placement_authority(PlacementAuthorityId::new("pa-commerce-1").unwrap())
634            .with_compressed_archive_replicas([ident("CN=node-c")])]);
635
636        let snapshot = catalog.topology_snapshot();
637        assert_eq!(snapshot.authority(), TopologyAuthority::RoutingMetadataOnly);
638        let range = snapshot
639            .route(&orders, b"any-key")
640            .expect("full range covers all keys");
641
642        assert_eq!(range.owner(), &ident("CN=node-a"));
643        assert_eq!(range.range_owner(), &ident("CN=node-a"));
644        assert_eq!(
645            range.collection_group(),
646            &CollectionGroupId::new("commerce").unwrap()
647        );
648        assert_eq!(
649            range.placement_authority(),
650            &PlacementAuthorityId::new("pa-commerce-1").unwrap()
651        );
652        assert_eq!(range.hot_mirror_replicas(), &[ident("CN=node-b")]);
653        assert_eq!(range.compressed_archive_replicas(), &[ident("CN=node-c")]);
654        assert_eq!(range.promotion_candidates(), &[ident("CN=node-b")]);
655        assert_eq!(
656            range.replica_role_of(&ident("CN=node-b")),
657            Some(ReplicaRole::HotMirror)
658        );
659        assert_eq!(
660            range.replica_role_of(&ident("CN=node-c")),
661            Some(ReplicaRole::CompressedArchive)
662        );
663    }
664
665    #[test]
666    fn serving_graph_reflects_ownership_transitions_and_replica_roles() {
667        let orders = collection("orders");
668        let range = full_range(&orders, 1, "CN=node-a", &["CN=node-b"])
669            .with_collection_group(CollectionGroupId::new("commerce").unwrap())
670            .with_placement_authority(PlacementAuthorityId::new("pa-commerce-1").unwrap())
671            .with_compressed_archive_replicas([ident("CN=node-c")]);
672        let mut catalog = catalog_with([range]);
673
674        let current = catalog.range(&orders, RangeId::new(1)).unwrap().clone();
675        catalog
676            .apply_update(current.transfer_to(ident("CN=node-b"), [ident("CN=node-a")]))
677            .unwrap();
678        let current = catalog.range(&orders, RangeId::new(1)).unwrap().clone();
679        catalog
680            .apply_update(current.update_replica_roles([ident("CN=node-a")], [ident("CN=node-d")]))
681            .unwrap();
682
683        let projected = catalog
684            .topology_snapshot()
685            .range(&orders, RangeId::new(1))
686            .unwrap()
687            .clone();
688
689        assert_eq!(projected.range_owner(), &ident("CN=node-b"));
690        assert_eq!(projected.hot_mirror_replicas(), &[ident("CN=node-a")]);
691        assert_eq!(
692            projected.compressed_archive_replicas(),
693            &[ident("CN=node-d")]
694        );
695        assert_eq!(
696            projected.placement_authority(),
697            &PlacementAuthorityId::new("pa-commerce-1").unwrap()
698        );
699    }
700
701    // AC #1: an ordered, multi-range collection routes keys to distinct owners
702    // entirely client-side from the snapshot.
703    #[test]
704    fn snapshot_routes_keys_to_distinct_owners() {
705        let parts = collection("parts");
706        let catalog = catalog_with([
707            split_range(
708                &parts,
709                1,
710                RangeBound::Min,
711                RangeBound::key(b"m"),
712                "CN=node-a",
713            ),
714            split_range(
715                &parts,
716                2,
717                RangeBound::key(b"m"),
718                RangeBound::Max,
719                "CN=node-b",
720            ),
721        ]);
722        let snapshot = catalog.topology_snapshot();
723
724        assert_eq!(
725            snapshot.route(&parts, b"apple").unwrap().owner(),
726            &ident("CN=node-a")
727        );
728        assert_eq!(
729            snapshot.route(&parts, b"zebra").unwrap().owner(),
730            &ident("CN=node-b")
731        );
732    }
733
734    // AC #3: a driver polls a snapshot and resolves owners from its cache.
735    #[test]
736    fn client_resolves_owner_from_polled_snapshot() {
737        let orders = collection("orders");
738        let catalog = catalog_with([full_range(&orders, 1, "CN=node-a", &[])]);
739        let client = ClientTopology::from_snapshot(catalog.topology_snapshot());
740
741        assert_eq!(client.resolve(&orders, b"k").unwrap(), &ident("CN=node-a"));
742        assert!(!client.needs_refresh());
743    }
744
745    #[test]
746    fn client_resolves_hash_collection_by_shard_key_slot() {
747        let orders = collection("orders");
748        let key = b"tenant:42";
749        let catalog = catalog_with([hash_slot_range(&orders, 1, key, "CN=node-a")]);
750        let client = ClientTopology::from_snapshot(catalog.topology_snapshot());
751
752        let routed = client
753            .route_shard_key(&orders, key)
754            .expect("hash slot range covers the logical shard key");
755        assert_eq!(routed.owner(), &ident("CN=node-a"));
756        assert_eq!(client.resolve(&orders, key).unwrap(), &ident("CN=node-a"));
757    }
758
759    // AC #3 + polling baseline: refresh is monotonic — a newer poll is adopted, a
760    // stale or duplicate poll is ignored and cannot roll the cache backwards.
761    #[test]
762    fn refresh_is_monotonic() {
763        let orders = collection("orders");
764        let mut catalog = catalog_with([full_range(&orders, 1, "CN=node-a", &["CN=node-b"])]);
765        let mut client = ClientTopology::from_snapshot(catalog.topology_snapshot());
766        let v1 = client.version();
767
768        // Ownership transfers a -> b; poll the new snapshot.
769        let r = catalog.range(&orders, RangeId::new(1)).unwrap().clone();
770        catalog
771            .apply_update(r.transfer_to(ident("CN=node-b"), [ident("CN=node-a")]))
772            .unwrap();
773        let fresh = catalog.topology_snapshot();
774        assert!(fresh.version() > v1);
775
776        assert_eq!(
777            client.apply_refresh(fresh.clone()),
778            RefreshOutcome::Applied { ranges_changed: 1 }
779        );
780        assert_eq!(client.resolve(&orders, b"k").unwrap(), &ident("CN=node-b"));
781
782        // Re-applying the same snapshot is a no-op.
783        assert_eq!(client.apply_refresh(fresh), RefreshOutcome::Ignored);
784    }
785
786    #[test]
787    fn refresh_applies_same_generation_snapshot_when_another_range_changed() {
788        let parts = collection("parts");
789        let mut catalog = catalog_with([
790            split_range(
791                &parts,
792                1,
793                RangeBound::Min,
794                RangeBound::key(b"m"),
795                "CN=node-a",
796            ),
797            split_range(
798                &parts,
799                2,
800                RangeBound::key(b"m"),
801                RangeBound::Max,
802                "CN=node-b",
803            ),
804        ]);
805        let mut client = ClientTopology::from_snapshot(catalog.topology_snapshot());
806
807        let r1 = catalog.range(&parts, RangeId::new(1)).unwrap().clone();
808        catalog
809            .apply_update(r1.transfer_to(ident("CN=node-c"), Vec::<NodeIdentity>::new()))
810            .unwrap();
811        assert_eq!(
812            client.apply_refresh(catalog.topology_snapshot()),
813            RefreshOutcome::Applied { ranges_changed: 1 }
814        );
815        assert_eq!(
816            client.resolve(&parts, b"apple").unwrap(),
817            &ident("CN=node-c")
818        );
819
820        let r2 = catalog.range(&parts, RangeId::new(2)).unwrap().clone();
821        catalog
822            .apply_update(r2.transfer_to(ident("CN=node-d"), Vec::<NodeIdentity>::new()))
823            .unwrap();
824        let same_generation = catalog.topology_snapshot();
825        assert_eq!(same_generation.version(), client.version());
826
827        assert_eq!(
828            client.apply_refresh(same_generation),
829            RefreshOutcome::Applied { ranges_changed: 1 }
830        );
831        assert_eq!(
832            client.resolve(&parts, b"zebra").unwrap(),
833            &ident("CN=node-d")
834        );
835    }
836
837    #[test]
838    fn equal_generation_refresh_does_not_roll_back_a_newer_range() {
839        let parts = collection("parts");
840        let base = catalog_with([
841            split_range(
842                &parts,
843                1,
844                RangeBound::Min,
845                RangeBound::key(b"m"),
846                "CN=node-a",
847            ),
848            split_range(
849                &parts,
850                2,
851                RangeBound::key(b"m"),
852                RangeBound::Max,
853                "CN=node-b",
854            ),
855        ]);
856        let mut current_catalog = base.clone();
857        let mut stale_fork = base;
858        let mut client = ClientTopology::from_snapshot(current_catalog.topology_snapshot());
859
860        let r1 = current_catalog
861            .range(&parts, RangeId::new(1))
862            .unwrap()
863            .clone();
864        current_catalog
865            .apply_update(r1.transfer_to(ident("CN=node-c"), Vec::<NodeIdentity>::new()))
866            .unwrap();
867        assert!(client
868            .apply_refresh(current_catalog.topology_snapshot())
869            .was_applied());
870
871        let r2 = stale_fork.range(&parts, RangeId::new(2)).unwrap().clone();
872        stale_fork
873            .apply_update(r2.transfer_to(ident("CN=node-d"), Vec::<NodeIdentity>::new()))
874            .unwrap();
875        let fork_snapshot = stale_fork.topology_snapshot();
876        assert_eq!(fork_snapshot.version(), client.version());
877
878        assert_eq!(client.apply_refresh(fork_snapshot), RefreshOutcome::Ignored);
879        assert_eq!(
880            client.resolve(&parts, b"apple").unwrap(),
881            &ident("CN=node-c")
882        );
883        assert_eq!(
884            client.resolve(&parts, b"zebra").unwrap(),
885            &ident("CN=node-b")
886        );
887    }
888
889    // AC #2 + AC #3: a stale-ownership redirect hint corrects the cache without
890    // being authoritative — it advances the owner but raises needs_refresh.
891    #[test]
892    fn redirect_hint_corrects_cache_but_is_not_authoritative() {
893        let orders = collection("orders");
894        let mut catalog = catalog_with([full_range(&orders, 1, "CN=node-a", &["CN=node-b"])]);
895        let mut client = ClientTopology::from_snapshot(catalog.topology_snapshot());
896
897        // Ownership moves a -> b on the server; the driver has not polled yet.
898        let r = catalog.range(&orders, RangeId::new(1)).unwrap().clone();
899        catalog
900            .apply_update(r.transfer_to(ident("CN=node-b"), [ident("CN=node-a")]))
901            .unwrap();
902
903        // The driver routes to its stale owner (node-a); the server redirects.
904        let stale_owner = client.resolve(&orders, b"k").unwrap().clone();
905        assert_eq!(stale_owner, ident("CN=node-a"));
906        let request =
907            RoutedRequest::new(orders.clone(), b"k".to_vec(), RequestOperation::Transaction);
908        let hint = match catalog.plan_route(&stale_owner, &request, &RoutingPolicy::forwarding()) {
909            RouteDecision::Redirect { hint, .. } => hint,
910            other => panic!("expected redirect, got {other:?}"),
911        };
912
913        // Applying the hint corrects routing but flags the cache as advisory.
914        assert_eq!(client.apply_hint(&hint), HintOutcome::Corrected);
915        assert_eq!(client.resolve(&orders, b"k").unwrap(), &ident("CN=node-b"));
916        assert!(
917            client.needs_refresh(),
918            "a hint is advisory, not authoritative"
919        );
920
921        // An authoritative poll reconciles and clears the advisory flag.
922        assert!(client
923            .apply_refresh(catalog.topology_snapshot())
924            .was_applied());
925        assert!(!client.needs_refresh());
926        // Authority restored the replica set a hint never carried.
927        let range = client.range(&orders, RangeId::new(1)).unwrap();
928        assert_eq!(range.replicas(), &[ident("CN=node-a")]);
929    }
930
931    // AC #2: a hint cannot introduce a range — hints are not the source of
932    // ownership authority, so an unknown-range hint only forces a refresh.
933    #[test]
934    fn hint_for_unknown_range_does_not_invent_topology() {
935        let orders = collection("orders");
936        let other = collection("other");
937        let catalog = catalog_with([full_range(&orders, 1, "CN=node-a", &[])]);
938        let mut client = ClientTopology::from_snapshot(catalog.topology_snapshot());
939
940        // Forge a hint for a collection/range the cache has never seen.
941        let foreign = catalog_with([full_range(&other, 9, "CN=node-z", &[])]);
942        let request =
943            RoutedRequest::new(other.clone(), b"k".to_vec(), RequestOperation::Transaction);
944        let hint = foreign
945            .plan_route(&ident("CN=node-b"), &request, &RoutingPolicy::forwarding())
946            .hint()
947            .cloned()
948            .unwrap();
949
950        assert_eq!(client.apply_hint(&hint), HintOutcome::UnknownRange);
951        assert!(
952            client.range(&other, RangeId::new(9)).is_none(),
953            "no phantom range"
954        );
955        assert!(client.needs_refresh());
956    }
957
958    // AC #2: an authoritative apply that already overtook the hint makes the hint
959    // a no-op — authority wins.
960    #[test]
961    fn stale_hint_is_ignored_after_authoritative_catch_up() {
962        let orders = collection("orders");
963        let mut catalog = catalog_with([full_range(&orders, 1, "CN=node-a", &["CN=node-b"])]);
964        let request =
965            RoutedRequest::new(orders.clone(), b"k".to_vec(), RequestOperation::Transaction);
966        // Capture an early hint while a still owns the range.
967        let early_hint = catalog
968            .plan_route(&ident("CN=node-b"), &request, &RoutingPolicy::forwarding())
969            .hint()
970            .cloned()
971            .unwrap();
972
973        // Server advances; driver polls the fresh snapshot authoritatively.
974        let r = catalog.range(&orders, RangeId::new(1)).unwrap().clone();
975        catalog
976            .apply_update(r.transfer_to(ident("CN=node-b"), [ident("CN=node-a")]))
977            .unwrap();
978        let mut client = ClientTopology::from_snapshot(catalog.topology_snapshot());
979
980        // The stale early hint (owner a, epoch 1) must not roll routing back.
981        assert_eq!(client.apply_hint(&early_hint), HintOutcome::AlreadyCurrent);
982        assert_eq!(client.resolve(&orders, b"k").unwrap(), &ident("CN=node-b"));
983        assert!(!client.needs_refresh());
984    }
985
986    // AC #4: a pushed full snapshot is adopted exactly like a poll.
987    #[test]
988    fn push_full_snapshot_applies_like_a_poll() {
989        let orders = collection("orders");
990        let mut catalog = catalog_with([full_range(&orders, 1, "CN=node-a", &[])]);
991        let mut client = ClientTopology::from_snapshot(catalog.topology_snapshot());
992
993        let r = catalog.range(&orders, RangeId::new(1)).unwrap().clone();
994        catalog
995            .apply_update(r.transfer_to(ident("CN=node-b"), [ident("CN=node-a")]))
996            .unwrap();
997        let update = TopologyUpdate::Full(catalog.topology_snapshot());
998
999        assert!(client.apply_update(update).was_applied());
1000        assert_eq!(client.resolve(&orders, b"k").unwrap(), &ident("CN=node-b"));
1001    }
1002
1003    // AC #4: a pushed single-range delta advances just that range.
1004    #[test]
1005    fn push_range_delta_advances_one_range() {
1006        let parts = collection("parts");
1007        let mut catalog = catalog_with([
1008            split_range(
1009                &parts,
1010                1,
1011                RangeBound::Min,
1012                RangeBound::key(b"m"),
1013                "CN=node-a",
1014            ),
1015            split_range(
1016                &parts,
1017                2,
1018                RangeBound::key(b"m"),
1019                RangeBound::Max,
1020                "CN=node-b",
1021            ),
1022        ]);
1023        let mut client = ClientTopology::from_snapshot(catalog.topology_snapshot());
1024
1025        // Only range 2 moves b -> c.
1026        let r2 = catalog.range(&parts, RangeId::new(2)).unwrap().clone();
1027        catalog
1028            .apply_update(r2.transfer_to(ident("CN=node-c"), Vec::<NodeIdentity>::new()))
1029            .unwrap();
1030        let moved = catalog
1031            .topology_snapshot()
1032            .range(&parts, RangeId::new(2))
1033            .unwrap()
1034            .clone();
1035
1036        assert_eq!(
1037            client.apply_update(TopologyUpdate::Range(moved)),
1038            RefreshOutcome::Applied { ranges_changed: 1 }
1039        );
1040        // Range 1 untouched, range 2 advanced.
1041        assert_eq!(
1042            client.resolve(&parts, b"apple").unwrap(),
1043            &ident("CN=node-a")
1044        );
1045        assert_eq!(
1046            client.resolve(&parts, b"zebra").unwrap(),
1047            &ident("CN=node-c")
1048        );
1049    }
1050
1051    // AC #5: behavior when push updates are missed — push is not mandatory for
1052    // correctness. A dropped push leaves the cache stale, but a redirect hint and
1053    // a later poll converge it anyway.
1054    #[test]
1055    fn missed_push_still_converges_via_hint_and_poll() {
1056        let orders = collection("orders");
1057        let mut catalog = catalog_with([full_range(&orders, 1, "CN=node-a", &["CN=node-b"])]);
1058        let mut client = ClientTopology::from_snapshot(catalog.topology_snapshot());
1059
1060        // Server moves ownership a -> b. The push for this change is DROPPED:
1061        // we deliberately never call apply_update with it.
1062        let r = catalog.range(&orders, RangeId::new(1)).unwrap().clone();
1063        catalog
1064            .apply_update(r.transfer_to(ident("CN=node-b"), [ident("CN=node-a")]))
1065            .unwrap();
1066        let _dropped_push = TopologyUpdate::Full(catalog.topology_snapshot());
1067
1068        // The cache is stale and still points at node-a.
1069        assert_eq!(client.resolve(&orders, b"k").unwrap(), &ident("CN=node-a"));
1070
1071        // Correctness path: the stale request is redirected; the hint corrects.
1072        let request =
1073            RoutedRequest::new(orders.clone(), b"k".to_vec(), RequestOperation::Transaction);
1074        let hint = catalog
1075            .plan_route(&ident("CN=node-a"), &request, &RoutingPolicy::forwarding())
1076            .hint()
1077            .cloned()
1078            .unwrap();
1079        assert_eq!(client.apply_hint(&hint), HintOutcome::Corrected);
1080        assert_eq!(client.resolve(&orders, b"k").unwrap(), &ident("CN=node-b"));
1081        assert!(client.needs_refresh());
1082
1083        // Baseline poll reconciles the cache fully despite the missed push.
1084        assert!(client
1085            .apply_refresh(catalog.topology_snapshot())
1086            .was_applied());
1087        assert!(!client.needs_refresh());
1088    }
1089
1090    // AC #4: out-of-order pushes are safe — a newer delta applied before an older
1091    // one wins, and the older one is then ignored.
1092    #[test]
1093    fn out_of_order_push_keeps_newest() {
1094        let orders = collection("orders");
1095        let mut catalog = catalog_with([full_range(&orders, 1, "CN=node-a", &["CN=node-b"])]);
1096        let mut client = ClientTopology::from_snapshot(catalog.topology_snapshot());
1097
1098        // v2: a -> b.
1099        let r1 = catalog.range(&orders, RangeId::new(1)).unwrap().clone();
1100        catalog
1101            .apply_update(r1.transfer_to(ident("CN=node-b"), [ident("CN=node-a")]))
1102            .unwrap();
1103        let push_v2 = catalog
1104            .topology_snapshot()
1105            .range(&orders, RangeId::new(1))
1106            .unwrap()
1107            .clone();
1108
1109        // v3: b -> c.
1110        let r2 = catalog.range(&orders, RangeId::new(1)).unwrap().clone();
1111        catalog
1112            .apply_update(r2.transfer_to(ident("CN=node-c"), [ident("CN=node-b")]))
1113            .unwrap();
1114        let push_v3 = catalog
1115            .topology_snapshot()
1116            .range(&orders, RangeId::new(1))
1117            .unwrap()
1118            .clone();
1119
1120        // The newer push (v3) arrives first and is applied.
1121        assert!(client
1122            .apply_update(TopologyUpdate::Range(push_v3))
1123            .was_applied());
1124        assert_eq!(client.resolve(&orders, b"k").unwrap(), &ident("CN=node-c"));
1125        // The older push (v2) arrives late and is ignored — no rollback.
1126        assert_eq!(
1127            client.apply_update(TopologyUpdate::Range(push_v2)),
1128            RefreshOutcome::Ignored
1129        );
1130        assert_eq!(client.resolve(&orders, b"k").unwrap(), &ident("CN=node-c"));
1131    }
1132}