Skip to main content

reddb_client/
bookmark_routing.rs

1//! Bookmark-aware routing table (issue #831, PRD #819).
2//!
3//! Projects the merged [`crate::topology::ClusterMembership`] into a
4//! *driver-consumable* routing table:
5//!
6//! * **Write endpoint** — the current primary, keyed by replication
7//!   `term`. Writes always route here; the term lets a caller detect
8//!   that it is talking to a primary from a stale election.
9//! * **Read endpoints** — every advertised replica carries its
10//!   applied frontier (`last_applied_lsn`) and a *bookmark-eligibility*
11//!   flag computed against a target bookmark. A replica is eligible
12//!   when it is healthy and its contiguous applied frontier already
13//!   covers the bookmark's commit LSN.
14//!
15//! ## Bounded-wait-then-fallback for causal reads
16//!
17//! [`RoutingTable::route_causal_read`] is the deep entry point. Given a
18//! causal bookmark it:
19//!
20//! 1. Picks a *target* read replica (region-preferred, else the first
21//!    healthy replica in advertised order).
22//! 2. If that target's snapshot frontier already covers the bookmark,
23//!    routes there immediately ([`RouteKind::EligibleTarget`]).
24//! 3. Otherwise it waits, polling the target's live frontier through an
25//!    injected [`BookmarkWaiter`], until either the target catches up
26//!    ([`RouteKind::CaughtUpTarget`]) or the bounded deadline elapses.
27//! 4. On deadline, it transparently falls back to *another* node whose
28//!    snapshot frontier is already past the bookmark
29//!    ([`RouteKind::FallbackReplica`]); if none qualifies, it falls back
30//!    to the primary ([`RouteKind::FallbackPrimary`]), which is by
31//!    definition past every committed bookmark.
32//!
33//! The method **never returns an error**: a lagging replica degrades a
34//! causal read into a fallback hop, never a hard failure (issue #831
35//! acceptance criterion 3). The wait/probe transport (an RPC against
36//! the replica) is abstracted behind [`BookmarkWaiter`] so the routing
37//! logic stays pure and unit-testable without a clock or a network.
38
39use std::time::Duration;
40
41use reddb_wire::replication::CausalBookmark;
42use reddb_wire::topology::{Endpoint, ReplicaInfo};
43
44use crate::topology::ClusterMembership;
45
46/// Backwards-compatible client name for the wire causal bookmark token.
47pub type BookmarkTarget = CausalBookmark;
48
49/// The write-path endpoint: the current primary, tagged with the
50/// replication term it is serving.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct WriteEndpoint {
53    pub addr: String,
54    pub region: String,
55    pub term: u64,
56}
57
58impl WriteEndpoint {
59    /// True when this write endpoint serves `term`. A caller holding a
60    /// bookmark from a newer term can use this to detect that the
61    /// routing table is stale and force a topology refresh.
62    pub fn serves_term(&self, term: u64) -> bool {
63        self.term == term
64    }
65
66    /// Project back to the wire `Endpoint` shape for dialling.
67    pub fn endpoint(&self) -> Endpoint {
68        Endpoint {
69            addr: self.addr.clone(),
70            region: self.region.clone(),
71        }
72    }
73}
74
75/// A read-path endpoint candidate carrying its applied frontier and
76/// whether it is eligible to serve a given bookmark.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct ReadEndpoint {
79    pub addr: String,
80    pub region: String,
81    pub healthy: bool,
82    /// Lag estimate carried straight from the advertisement.
83    pub lag_ms: u32,
84    /// Contiguous applied frontier (`ReplicaInfo::last_applied_lsn`).
85    pub frontier_lsn: u64,
86    /// `true` while this replica is re-bootstrapping (issue #837). Its
87    /// advertised frontier describes data it is about to discard, so it
88    /// is never eligible for a causal read regardless of how far ahead
89    /// that frontier sits.
90    pub rebootstrapping: bool,
91    /// `true` when this replica can serve the bookmark *now*: healthy,
92    /// **not** re-bootstrapping, and its frontier already covers the
93    /// bookmark commit LSN.
94    pub bookmark_eligible: bool,
95}
96
97impl ReadEndpoint {
98    fn from_replica(info: &ReplicaInfo, bookmark: BookmarkTarget) -> Self {
99        // A re-bootstrapping replica is excluded even when its frontier
100        // covers the bookmark: that frontier describes data it is about
101        // to throw away on the atomic swap (issue #837).
102        let eligible =
103            info.healthy && !info.rebootstrapping && info.last_applied_lsn >= bookmark.commit_lsn();
104        Self {
105            addr: info.addr.clone(),
106            region: info.region.clone(),
107            healthy: info.healthy,
108            lag_ms: info.lag_ms,
109            frontier_lsn: info.last_applied_lsn,
110            rebootstrapping: info.rebootstrapping,
111            bookmark_eligible: eligible,
112        }
113    }
114
115    fn endpoint(&self) -> Endpoint {
116        Endpoint {
117            addr: self.addr.clone(),
118            region: self.region.clone(),
119        }
120    }
121}
122
123/// Why a causal read landed on the endpoint it did.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum RouteKind {
126    /// The request required a strong read, so it routed to the
127    /// primary/range-owner path.
128    StrongPrimary,
129    /// The chosen target replica's snapshot frontier already covered
130    /// the bookmark — no wait was needed.
131    EligibleTarget,
132    /// The target replica was behind but caught up within the deadline
133    /// while we waited.
134    CaughtUpTarget,
135    /// The target stayed behind past the deadline; routed to another
136    /// replica already past the bookmark.
137    FallbackReplica,
138    /// No replica was past the bookmark; routed to the primary, which
139    /// is always past every committed bookmark.
140    FallbackPrimary,
141    /// Bounded-staleness routing found a healthy replica within the
142    /// caller's declared lag bound.
143    BoundedStalenessReplica,
144    /// Local routing selected a healthy replica without making any
145    /// freshness claim.
146    LocalReplica,
147}
148
149impl RouteKind {
150    /// True when the read was served by a fallback hop rather than the
151    /// originally-chosen target replica.
152    pub fn is_fallback(self) -> bool {
153        matches!(self, Self::FallbackReplica | Self::FallbackPrimary)
154    }
155}
156
157/// The resolved route for a causal read. Always present — the routing
158/// table never turns a lagging replica into a hard error.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct RouteDecision {
161    /// The endpoint to dial.
162    pub endpoint: Endpoint,
163    /// How the decision was reached.
164    pub kind: RouteKind,
165    /// How long the bounded wait took before the decision settled.
166    pub waited: Duration,
167}
168
169/// Drives the bounded wait against a target replica.
170///
171/// Each [`Self::poll`] blocks for one poll interval (clamped by the
172/// remaining deadline), then reports the target replica's *current*
173/// contiguous applied frontier — in production by issuing a lightweight
174/// frontier RPC. [`Self::elapsed`] reports the time spent so far so the
175/// routing table can enforce the deadline without owning a clock.
176///
177/// Keeping the transport behind this trait lets the wait-then-fallback
178/// logic be exercised deterministically with a scripted fake.
179pub trait BookmarkWaiter {
180    /// Time elapsed since the wait began.
181    fn elapsed(&self) -> Duration;
182    /// Block for one poll interval, then return the target's current
183    /// applied frontier LSN.
184    fn poll(&mut self, target_addr: &str) -> u64;
185}
186
187/// Options for a causal read route.
188#[derive(Debug, Clone, Default, PartialEq, Eq)]
189pub struct CausalReadOptions {
190    /// Region the caller prefers to read from (locality). When set, a
191    /// healthy replica in this region is chosen as the wait target
192    /// ahead of out-of-region replicas.
193    pub preferred_region: Option<String>,
194    /// Upper bound on how long to wait for the target to catch up
195    /// before falling back.
196    pub deadline: Duration,
197}
198
199/// Per-request read consistency level.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub enum ReadConsistency {
202    /// Route to the primary/range owner so the read never observes
203    /// state before the commit watermark.
204    Strong,
205    /// Route using the session bookmark path.
206    Causal {
207        bookmark: BookmarkTarget,
208        opts: CausalReadOptions,
209    },
210    /// Allow any healthy replica whose advertised lag is within
211    /// `max_lag`.
212    BoundedStaleness { max_lag: Duration },
213    /// Allow any healthy member; does not block or claim freshness.
214    Local,
215}
216
217/// Per-request query routing options.
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct QueryOptions {
220    pub consistency: ReadConsistency,
221}
222
223impl QueryOptions {
224    pub fn strong() -> Self {
225        Self {
226            consistency: ReadConsistency::Strong,
227        }
228    }
229
230    pub fn causal(bookmark: BookmarkTarget, opts: CausalReadOptions) -> Self {
231        Self {
232            consistency: ReadConsistency::Causal { bookmark, opts },
233        }
234    }
235
236    pub fn bounded_staleness(max_lag: Duration) -> Self {
237        Self {
238            consistency: ReadConsistency::BoundedStaleness { max_lag },
239        }
240    }
241
242    pub fn local() -> Self {
243        Self {
244            consistency: ReadConsistency::Local,
245        }
246    }
247}
248
249impl Default for QueryOptions {
250    fn default() -> Self {
251        Self::local()
252    }
253}
254
255impl CausalReadOptions {
256    /// Bounded wait with the given deadline and no region preference.
257    pub fn with_deadline(deadline: Duration) -> Self {
258        Self {
259            preferred_region: None,
260            deadline,
261        }
262    }
263
264    /// Prefer reads from `region`.
265    pub fn prefer_region(mut self, region: impl Into<String>) -> Self {
266        self.preferred_region = Some(region.into());
267        self
268    }
269}
270
271/// Driver-consumable routing table derived from a topology
272/// advertisement plus the current replication term.
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub struct RoutingTable {
275    write: WriteEndpoint,
276    replicas: Vec<ReplicaInfo>,
277    epoch: u64,
278}
279
280impl RoutingTable {
281    /// Build a routing table from a merged membership snapshot and the
282    /// current replication `term`. The primary becomes the write
283    /// endpoint; the advertised replicas become the read pool.
284    pub fn from_membership(membership: ClusterMembership, term: u64) -> Self {
285        let ClusterMembership {
286            primary,
287            replicas,
288            epoch,
289        } = membership;
290        Self {
291            write: WriteEndpoint {
292                addr: primary.addr,
293                region: primary.region,
294                term,
295            },
296            replicas,
297            epoch,
298        }
299    }
300
301    /// Epoch of the advertisement this table was built from.
302    pub fn epoch(&self) -> u64 {
303        self.epoch
304    }
305
306    /// The write endpoint — the current primary, keyed by term.
307    pub fn write_endpoint(&self) -> &WriteEndpoint {
308        &self.write
309    }
310
311    /// The read endpoints with per-node frontier and bookmark
312    /// eligibility computed against `bookmark`. Order matches the
313    /// advertisement.
314    pub fn read_endpoints(&self, bookmark: BookmarkTarget) -> Vec<ReadEndpoint> {
315        self.replicas
316            .iter()
317            .map(|r| ReadEndpoint::from_replica(r, bookmark))
318            .collect()
319    }
320
321    /// Pick the index of the wait target: a healthy, non-rebuilding
322    /// replica, preferring `preferred_region`, otherwise the first such
323    /// replica in advertised order. `None` when no replica can serve a
324    /// causal read.
325    ///
326    /// A re-bootstrapping replica is skipped entirely (issue #837):
327    /// there is no point waiting on or polling a node whose frontier
328    /// describes data it is about to discard — it can never become a
329    /// valid causal target until its swap completes and it re-advertises
330    /// without the flag.
331    fn pick_target_index(&self, preferred_region: Option<&str>) -> Option<usize> {
332        if let Some(region) = preferred_region {
333            if let Some(i) = self
334                .replicas
335                .iter()
336                .position(|r| r.healthy && !r.rebootstrapping && r.region == region)
337            {
338                return Some(i);
339            }
340        }
341        self.replicas
342            .iter()
343            .position(|r| r.healthy && !r.rebootstrapping)
344    }
345
346    /// Resolve a causal read with bounded-wait-then-fallback.
347    ///
348    /// Never errors: a lagging target degrades to a fallback hop
349    /// (another caught-up replica, else the primary).
350    pub fn route_causal_read(
351        &self,
352        bookmark: BookmarkTarget,
353        opts: &CausalReadOptions,
354        waiter: &mut dyn BookmarkWaiter,
355    ) -> RouteDecision {
356        let target_idx = self.pick_target_index(opts.preferred_region.as_deref());
357
358        if let Some(idx) = target_idx {
359            let target = &self.replicas[idx];
360
361            // Fast path: the snapshot already shows the target past the
362            // bookmark — route immediately, no wait.
363            if target.last_applied_lsn >= bookmark.commit_lsn() {
364                return RouteDecision {
365                    endpoint: Endpoint {
366                        addr: target.addr.clone(),
367                        region: target.region.clone(),
368                    },
369                    kind: RouteKind::EligibleTarget,
370                    waited: Duration::ZERO,
371                };
372            }
373
374            // Bounded wait: poll the target's live frontier until it
375            // catches up or the deadline elapses.
376            let addr = target.addr.clone();
377            let region = target.region.clone();
378            while waiter.elapsed() < opts.deadline {
379                let frontier = waiter.poll(&addr);
380                if frontier >= bookmark.commit_lsn() {
381                    return RouteDecision {
382                        endpoint: Endpoint { addr, region },
383                        kind: RouteKind::CaughtUpTarget,
384                        waited: waiter.elapsed(),
385                    };
386                }
387            }
388
389            // Deadline blown — fall back below, excluding the target.
390            return self.fall_back(bookmark, Some(idx), waiter.elapsed());
391        }
392
393        // No healthy replica at all — fall back straight away.
394        self.fall_back(bookmark, None, waiter.elapsed())
395    }
396
397    /// Resolve a read for the requested per-request consistency
398    /// level. Causal delegates to the bookmark route. Strong and
399    /// fallback routes use the primary, which is the range-owner path
400    /// at the current client routing layer.
401    pub fn route_read(
402        &self,
403        options: &QueryOptions,
404        waiter: &mut dyn BookmarkWaiter,
405    ) -> RouteDecision {
406        match &options.consistency {
407            ReadConsistency::Strong => RouteDecision {
408                endpoint: self.write.endpoint(),
409                kind: RouteKind::StrongPrimary,
410                waited: Duration::ZERO,
411            },
412            ReadConsistency::Causal { bookmark, opts } => {
413                self.route_causal_read(*bookmark, opts, waiter)
414            }
415            ReadConsistency::BoundedStaleness { max_lag } => self.route_bounded_staleness(*max_lag),
416            ReadConsistency::Local => self.route_local(),
417        }
418    }
419
420    fn route_bounded_staleness(&self, max_lag: Duration) -> RouteDecision {
421        let max_lag_ms = max_lag.as_millis().min(u32::MAX as u128) as u32;
422        match self
423            .replicas
424            .iter()
425            .find(|r| r.healthy && !r.rebootstrapping && r.lag_ms <= max_lag_ms)
426        {
427            Some(r) => RouteDecision {
428                endpoint: Endpoint {
429                    addr: r.addr.clone(),
430                    region: r.region.clone(),
431                },
432                kind: RouteKind::BoundedStalenessReplica,
433                waited: Duration::ZERO,
434            },
435            None => RouteDecision {
436                endpoint: self.write.endpoint(),
437                kind: RouteKind::FallbackPrimary,
438                waited: Duration::ZERO,
439            },
440        }
441    }
442
443    fn route_local(&self) -> RouteDecision {
444        match self.replicas.iter().find(|r| r.healthy) {
445            Some(r) => RouteDecision {
446                endpoint: Endpoint {
447                    addr: r.addr.clone(),
448                    region: r.region.clone(),
449                },
450                kind: RouteKind::LocalReplica,
451                waited: Duration::ZERO,
452            },
453            None => RouteDecision {
454                endpoint: self.write.endpoint(),
455                kind: RouteKind::FallbackPrimary,
456                waited: Duration::ZERO,
457            },
458        }
459    }
460
461    /// Fallback selection: a healthy replica (other than `exclude`)
462    /// already past the bookmark, else the primary.
463    fn fall_back(
464        &self,
465        bookmark: BookmarkTarget,
466        exclude: Option<usize>,
467        waited: Duration,
468    ) -> RouteDecision {
469        let caught_up = self.replicas.iter().enumerate().find(|(i, r)| {
470            Some(*i) != exclude
471                && r.healthy
472                && !r.rebootstrapping
473                && r.last_applied_lsn >= bookmark.commit_lsn()
474        });
475        match caught_up {
476            Some((_, r)) => RouteDecision {
477                endpoint: ReadEndpoint::from_replica(r, bookmark).endpoint(),
478                kind: RouteKind::FallbackReplica,
479                waited,
480            },
481            None => RouteDecision {
482                endpoint: self.write.endpoint(),
483                kind: RouteKind::FallbackPrimary,
484                waited,
485            },
486        }
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493    use reddb_wire::topology::Endpoint as WireEndpoint;
494
495    fn primary() -> WireEndpoint {
496        WireEndpoint {
497            addr: "primary:5050".into(),
498            region: "us-east-1".into(),
499        }
500    }
501
502    fn replica(addr: &str, region: &str, healthy: bool, frontier: u64) -> ReplicaInfo {
503        ReplicaInfo {
504            addr: addr.into(),
505            region: region.into(),
506            healthy,
507            lag_ms: if healthy { 5 } else { u32::MAX },
508            last_applied_lsn: frontier,
509            rebootstrapping: false,
510        }
511    }
512
513    fn lagging_replica(
514        addr: &str,
515        region: &str,
516        healthy: bool,
517        frontier: u64,
518        lag_ms: u32,
519    ) -> ReplicaInfo {
520        ReplicaInfo {
521            addr: addr.into(),
522            region: region.into(),
523            healthy,
524            lag_ms,
525            last_applied_lsn: frontier,
526            rebootstrapping: false,
527        }
528    }
529
530    /// A re-bootstrapping replica: healthy and far ahead on its
531    /// advertised frontier, but rebuilding — so never causally
532    /// eligible (issue #837).
533    fn rebuilding_replica(addr: &str, region: &str, frontier: u64) -> ReplicaInfo {
534        ReplicaInfo {
535            addr: addr.into(),
536            region: region.into(),
537            healthy: true,
538            lag_ms: 5,
539            last_applied_lsn: frontier,
540            rebootstrapping: true,
541        }
542    }
543
544    fn membership(replicas: Vec<ReplicaInfo>) -> ClusterMembership {
545        ClusterMembership {
546            primary: primary(),
547            replicas,
548            epoch: 3,
549        }
550    }
551
552    /// Scripted waiter: returns the next frontier in `steps` on each
553    /// poll, advancing the elapsed clock by `tick` per poll. Once the
554    /// script is exhausted it keeps returning the last value so the
555    /// deadline (not the script) terminates the loop.
556    struct ScriptedWaiter {
557        steps: Vec<u64>,
558        idx: usize,
559        tick: Duration,
560        elapsed: Duration,
561        polled_addrs: Vec<String>,
562    }
563
564    impl ScriptedWaiter {
565        fn new(steps: Vec<u64>, tick: Duration) -> Self {
566            Self {
567                steps,
568                idx: 0,
569                tick,
570                elapsed: Duration::ZERO,
571                polled_addrs: Vec::new(),
572            }
573        }
574    }
575
576    impl BookmarkWaiter for ScriptedWaiter {
577        fn elapsed(&self) -> Duration {
578            self.elapsed
579        }
580        fn poll(&mut self, target_addr: &str) -> u64 {
581            self.polled_addrs.push(target_addr.to_string());
582            self.elapsed += self.tick;
583            let v = self
584                .steps
585                .get(self.idx)
586                .copied()
587                .or_else(|| self.steps.last().copied())
588                .unwrap_or(0);
589            self.idx += 1;
590            v
591        }
592    }
593
594    // ---- write endpoint: primary by term ----
595
596    #[test]
597    fn write_endpoint_is_primary_keyed_by_term() {
598        let table = RoutingTable::from_membership(membership(vec![]), 9);
599        let w = table.write_endpoint();
600        assert_eq!(w.addr, "primary:5050");
601        assert_eq!(w.region, "us-east-1");
602        assert_eq!(w.term, 9);
603        assert!(w.serves_term(9));
604        assert!(!w.serves_term(10));
605    }
606
607    // ---- read endpoints: per-node frontier + eligibility ----
608
609    #[test]
610    fn read_endpoints_carry_frontier_and_eligibility() {
611        let table = RoutingTable::from_membership(
612            membership(vec![
613                replica("r-ahead:5050", "us-east-1", true, 200),
614                replica("r-behind:5050", "us-east-1", true, 90),
615                replica("r-down:5050", "us-west-2", false, 500),
616            ]),
617            1,
618        );
619        let bookmark = BookmarkTarget::new(1, 100);
620        let reads = table.read_endpoints(bookmark);
621        assert_eq!(reads.len(), 3);
622
623        // Past the bookmark and healthy → eligible.
624        assert_eq!(reads[0].frontier_lsn, 200);
625        assert!(reads[0].bookmark_eligible);
626
627        // Healthy but behind the commit LSN → ineligible.
628        assert_eq!(reads[1].frontier_lsn, 90);
629        assert!(!reads[1].bookmark_eligible);
630
631        // Past the bookmark but unhealthy → ineligible.
632        assert!(reads[2].frontier_lsn >= 100);
633        assert!(!reads[2].healthy);
634        assert!(!reads[2].bookmark_eligible);
635    }
636
637    #[test]
638    fn eligibility_boundary_is_inclusive_at_commit_lsn() {
639        let table =
640            RoutingTable::from_membership(membership(vec![replica("r:5050", "r1", true, 100)]), 1);
641        // frontier == commit_lsn must count as eligible.
642        let reads = table.read_endpoints(BookmarkTarget::new(1, 100));
643        assert!(reads[0].bookmark_eligible);
644    }
645
646    // ---- rebootstrapping replicas are excluded from causal reads ----
647
648    #[test]
649    fn rebootstrapping_replica_is_never_bookmark_eligible_despite_frontier() {
650        // Frontier 999 is well past the bookmark, but the node is
651        // rebuilding — its frontier describes data it will discard.
652        let table = RoutingTable::from_membership(
653            membership(vec![rebuilding_replica("rebuild:5050", "us-east-1", 999)]),
654            1,
655        );
656        let reads = table.read_endpoints(BookmarkTarget::new(1, 100));
657        assert_eq!(reads[0].frontier_lsn, 999);
658        assert!(reads[0].rebootstrapping);
659        assert!(
660            !reads[0].bookmark_eligible,
661            "a rebuilding node must never be bookmark-eligible"
662        );
663    }
664
665    #[test]
666    fn route_skips_rebuilding_node_and_falls_back_to_caught_up_peer() {
667        // The rebuilding node is first in advertised order and far
668        // ahead, but causal reads must bounce to the caught-up peer —
669        // without ever polling the rebuilding node.
670        let table = RoutingTable::from_membership(
671            membership(vec![
672                rebuilding_replica("rebuild:5050", "us-east-1", 999),
673                replica("caught-up:5050", "us-east-1", true, 300),
674            ]),
675            1,
676        );
677        let mut waiter = ScriptedWaiter::new(vec![], Duration::from_millis(10));
678        let decision = table.route_causal_read(
679            BookmarkTarget::new(1, 100),
680            &CausalReadOptions::with_deadline(Duration::from_millis(500)),
681            &mut waiter,
682        );
683        // The caught-up peer is the target chosen straight away; the
684        // rebuilding node was never selected as the wait target.
685        assert_eq!(decision.endpoint.addr, "caught-up:5050");
686        assert!(!decision.kind.is_fallback());
687        assert!(
688            waiter.polled_addrs.iter().all(|a| a != "rebuild:5050"),
689            "must never poll a rebuilding node"
690        );
691    }
692
693    #[test]
694    fn route_falls_back_to_primary_when_every_replica_is_rebuilding() {
695        // All replicas are rebuilding (and ahead of the bookmark);
696        // a causal read must still resolve — to the primary.
697        let table = RoutingTable::from_membership(
698            membership(vec![
699                rebuilding_replica("rebuild-a:5050", "us-east-1", 999),
700                rebuilding_replica("rebuild-b:5050", "us-west-2", 999),
701            ]),
702            4,
703        );
704        let mut waiter = ScriptedWaiter::new(vec![], Duration::from_millis(10));
705        let decision = table.route_causal_read(
706            BookmarkTarget::new(4, 100),
707            &CausalReadOptions::with_deadline(Duration::from_millis(500)),
708            &mut waiter,
709        );
710        assert_eq!(decision.kind, RouteKind::FallbackPrimary);
711        assert_eq!(decision.endpoint.addr, "primary:5050");
712        assert!(
713            waiter.polled_addrs.is_empty(),
714            "no rebuilding node should be polled"
715        );
716    }
717
718    #[test]
719    fn rebuilding_node_excluded_as_fallback_target() {
720        // The wait target (region-preferred, lagging) never catches up;
721        // the only frontier-ahead peer is rebuilding, so the fallback
722        // skips it and lands on the primary rather than serving a
723        // bookmark from data about to be discarded.
724        let table = RoutingTable::from_membership(
725            membership(vec![
726                replica("east-lag:5050", "us-east-1", true, 10),
727                rebuilding_replica("west-rebuild:5050", "us-west-2", 999),
728            ]),
729            2,
730        );
731        let mut waiter = ScriptedWaiter::new(vec![10, 20, 30], Duration::from_millis(10));
732        let decision = table.route_causal_read(
733            BookmarkTarget::new(2, 100),
734            &CausalReadOptions::with_deadline(Duration::from_millis(25)).prefer_region("us-east-1"),
735            &mut waiter,
736        );
737        assert_eq!(decision.kind, RouteKind::FallbackPrimary);
738        assert_eq!(decision.endpoint.addr, "primary:5050");
739    }
740
741    // ---- route: immediate eligible target (no wait) ----
742
743    #[test]
744    fn route_picks_eligible_target_without_waiting() {
745        let table = RoutingTable::from_membership(
746            membership(vec![replica("r-ok:5050", "us-east-1", true, 150)]),
747            1,
748        );
749        let mut waiter = ScriptedWaiter::new(vec![], Duration::from_millis(10));
750        let decision = table.route_causal_read(
751            BookmarkTarget::new(1, 100),
752            &CausalReadOptions::with_deadline(Duration::from_millis(500)),
753            &mut waiter,
754        );
755        assert_eq!(decision.kind, RouteKind::EligibleTarget);
756        assert_eq!(decision.endpoint.addr, "r-ok:5050");
757        assert_eq!(decision.waited, Duration::ZERO);
758        assert!(waiter.polled_addrs.is_empty(), "must not poll on fast path");
759    }
760
761    #[test]
762    fn route_prefers_region_for_the_target() {
763        let table = RoutingTable::from_membership(
764            membership(vec![
765                replica("east:5050", "us-east-1", true, 150),
766                replica("west:5050", "us-west-2", true, 150),
767            ]),
768            1,
769        );
770        let mut waiter = ScriptedWaiter::new(vec![], Duration::from_millis(10));
771        let decision = table.route_causal_read(
772            BookmarkTarget::new(1, 100),
773            &CausalReadOptions::with_deadline(Duration::from_millis(500))
774                .prefer_region("us-west-2"),
775            &mut waiter,
776        );
777        assert_eq!(decision.endpoint.addr, "west:5050");
778    }
779
780    // ---- route: target catches up within the deadline ----
781
782    #[test]
783    fn route_waits_and_routes_to_target_once_it_catches_up() {
784        let table = RoutingTable::from_membership(
785            membership(vec![replica("r-lag:5050", "us-east-1", true, 50)]),
786            1,
787        );
788        // Target is behind in the snapshot (50 < 100); it advances to
789        // 100 on the third poll.
790        let mut waiter = ScriptedWaiter::new(vec![60, 80, 100], Duration::from_millis(10));
791        let decision = table.route_causal_read(
792            BookmarkTarget::new(1, 100),
793            &CausalReadOptions::with_deadline(Duration::from_millis(500)),
794            &mut waiter,
795        );
796        assert_eq!(decision.kind, RouteKind::CaughtUpTarget);
797        assert_eq!(decision.endpoint.addr, "r-lag:5050");
798        assert_eq!(decision.waited, Duration::from_millis(30));
799        assert_eq!(waiter.polled_addrs.len(), 3);
800    }
801
802    // ---- route: target stays behind → fall back to a caught-up node ----
803
804    #[test]
805    fn route_falls_back_to_caught_up_replica_when_target_stays_behind() {
806        let table = RoutingTable::from_membership(
807            membership(vec![
808                // Region-preferred target that never catches up.
809                replica("east-lag:5050", "us-east-1", true, 10),
810                // Out-of-region replica already past the bookmark.
811                replica("west-ok:5050", "us-west-2", true, 300),
812            ]),
813            1,
814        );
815        // Deadline 25ms, tick 10ms → 3 polls, target frontier never
816        // reaches 100.
817        let mut waiter = ScriptedWaiter::new(vec![10, 20, 30], Duration::from_millis(10));
818        let decision = table.route_causal_read(
819            BookmarkTarget::new(1, 100),
820            &CausalReadOptions::with_deadline(Duration::from_millis(25)).prefer_region("us-east-1"),
821            &mut waiter,
822        );
823        assert_eq!(decision.kind, RouteKind::FallbackReplica);
824        assert_eq!(decision.endpoint.addr, "west-ok:5050");
825        assert!(decision.waited >= Duration::from_millis(25));
826        // The fallback target was never the polled (lagging) node.
827        assert!(waiter.polled_addrs.iter().all(|a| a == "east-lag:5050"));
828    }
829
830    // ---- route: no caught-up replica → fall back to primary ----
831
832    #[test]
833    fn route_falls_back_to_primary_when_no_replica_is_caught_up() {
834        let table = RoutingTable::from_membership(
835            membership(vec![
836                replica("r1:5050", "us-east-1", true, 10),
837                replica("r2:5050", "us-east-1", true, 20),
838            ]),
839            7,
840        );
841        let mut waiter = ScriptedWaiter::new(vec![10, 20], Duration::from_millis(10));
842        let decision = table.route_causal_read(
843            BookmarkTarget::new(7, 100),
844            &CausalReadOptions::with_deadline(Duration::from_millis(15)),
845            &mut waiter,
846        );
847        assert_eq!(decision.kind, RouteKind::FallbackPrimary);
848        assert_eq!(decision.endpoint.addr, "primary:5050");
849        assert!(decision.kind.is_fallback());
850    }
851
852    #[test]
853    fn route_falls_back_to_primary_when_no_replica_is_healthy() {
854        let table = RoutingTable::from_membership(
855            membership(vec![replica("r-down:5050", "us-east-1", false, 500)]),
856            1,
857        );
858        let mut waiter = ScriptedWaiter::new(vec![], Duration::from_millis(10));
859        let decision = table.route_causal_read(
860            BookmarkTarget::new(1, 100),
861            &CausalReadOptions::with_deadline(Duration::from_millis(500)),
862            &mut waiter,
863        );
864        // No healthy replica → straight to primary, no polling.
865        assert_eq!(decision.kind, RouteKind::FallbackPrimary);
866        assert_eq!(decision.endpoint.addr, "primary:5050");
867        assert!(waiter.polled_addrs.is_empty());
868    }
869
870    #[test]
871    fn route_falls_back_to_primary_when_no_replicas_advertised() {
872        let table = RoutingTable::from_membership(membership(vec![]), 1);
873        let mut waiter = ScriptedWaiter::new(vec![], Duration::from_millis(10));
874        let decision = table.route_causal_read(
875            BookmarkTarget::new(1, 100),
876            &CausalReadOptions::with_deadline(Duration::from_millis(500)),
877            &mut waiter,
878        );
879        assert_eq!(decision.kind, RouteKind::FallbackPrimary);
880        assert_eq!(decision.endpoint.addr, "primary:5050");
881    }
882
883    // ---- a lagging replica never produces a hard error ----
884
885    #[test]
886    fn lagging_replica_never_errors_always_resolves_an_endpoint() {
887        // Whatever the topology shape, route_causal_read returns a
888        // dialable endpoint — never a panic, never an Err.
889        let shapes = vec![
890            vec![],
891            vec![replica("a:5050", "r1", true, 0)],
892            vec![replica("a:5050", "r1", false, 0)],
893            vec![
894                replica("a:5050", "r1", true, 1),
895                replica("b:5050", "r2", true, 999),
896            ],
897        ];
898        for shape in shapes {
899            let table = RoutingTable::from_membership(membership(shape), 1);
900            let mut waiter = ScriptedWaiter::new(vec![0], Duration::from_millis(10));
901            let decision = table.route_causal_read(
902                BookmarkTarget::new(1, 100),
903                &CausalReadOptions::with_deadline(Duration::from_millis(20)),
904                &mut waiter,
905            );
906            assert!(!decision.endpoint.addr.is_empty());
907        }
908    }
909
910    #[test]
911    fn route_read_strong_uses_primary_without_polling() {
912        let table = RoutingTable::from_membership(
913            membership(vec![replica("r-ok:5050", "us-east-1", true, 150)]),
914            1,
915        );
916        let mut waiter = ScriptedWaiter::new(vec![150], Duration::from_millis(10));
917        let decision = table.route_read(&QueryOptions::strong(), &mut waiter);
918        assert_eq!(decision.kind, RouteKind::StrongPrimary);
919        assert_eq!(decision.endpoint.addr, "primary:5050");
920        assert!(waiter.polled_addrs.is_empty());
921    }
922
923    #[test]
924    fn route_read_bounded_staleness_respects_declared_lag_bound() {
925        let table = RoutingTable::from_membership(
926            membership(vec![
927                lagging_replica("too-stale:5050", "us-east-1", true, 500, 250),
928                lagging_replica("fresh-enough:5050", "us-east-1", true, 90, 40),
929            ]),
930            1,
931        );
932        let mut waiter = ScriptedWaiter::new(vec![], Duration::from_millis(10));
933        let decision = table.route_read(
934            &QueryOptions::bounded_staleness(Duration::from_millis(50)),
935            &mut waiter,
936        );
937        assert_eq!(decision.kind, RouteKind::BoundedStalenessReplica);
938        assert_eq!(decision.endpoint.addr, "fresh-enough:5050");
939        assert!(waiter.polled_addrs.is_empty());
940
941        let fallback = table.route_read(
942            &QueryOptions::bounded_staleness(Duration::from_millis(10)),
943            &mut waiter,
944        );
945        assert_eq!(fallback.kind, RouteKind::FallbackPrimary);
946        assert_eq!(fallback.endpoint.addr, "primary:5050");
947    }
948
949    #[test]
950    fn route_read_local_never_blocks_on_freshness() {
951        let table = RoutingTable::from_membership(
952            membership(vec![lagging_replica(
953                "very-stale:5050",
954                "us-east-1",
955                true,
956                0,
957                u32::MAX,
958            )]),
959            1,
960        );
961        let mut waiter = ScriptedWaiter::new(vec![0, 0, 0], Duration::from_millis(10));
962        let decision = table.route_read(&QueryOptions::local(), &mut waiter);
963        assert_eq!(decision.kind, RouteKind::LocalReplica);
964        assert_eq!(decision.endpoint.addr, "very-stale:5050");
965        assert_eq!(decision.waited, Duration::ZERO);
966        assert!(waiter.polled_addrs.is_empty());
967    }
968}