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