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::topology::{Endpoint, ReplicaInfo};
42
43use crate::topology::ClusterMembership;
44
45/// A causal bookmark target the routing table reasons about.
46///
47/// Mirrors the server's `CausalBookmark` `(term, commit_lsn)` shape
48/// without depending on the engine crate — the driver only needs the
49/// two numbers to decide eligibility and routing.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub struct BookmarkTarget {
52    /// Replication term the bookmark was minted under.
53    pub term: u64,
54    /// Commit LSN a read must observe to be causally consistent.
55    pub commit_lsn: u64,
56}
57
58impl BookmarkTarget {
59    pub fn new(term: u64, commit_lsn: u64) -> Self {
60        Self { term, commit_lsn }
61    }
62}
63
64/// The write-path endpoint: the current primary, tagged with the
65/// replication term it is serving.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct WriteEndpoint {
68    pub addr: String,
69    pub region: String,
70    pub term: u64,
71}
72
73impl WriteEndpoint {
74    /// True when this write endpoint serves `term`. A caller holding a
75    /// bookmark from a newer term can use this to detect that the
76    /// routing table is stale and force a topology refresh.
77    pub fn serves_term(&self, term: u64) -> bool {
78        self.term == term
79    }
80
81    /// Project back to the wire `Endpoint` shape for dialling.
82    pub fn endpoint(&self) -> Endpoint {
83        Endpoint {
84            addr: self.addr.clone(),
85            region: self.region.clone(),
86        }
87    }
88}
89
90/// A read-path endpoint candidate carrying its applied frontier and
91/// whether it is eligible to serve a given bookmark.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct ReadEndpoint {
94    pub addr: String,
95    pub region: String,
96    pub healthy: bool,
97    /// Lag estimate carried straight from the advertisement.
98    pub lag_ms: u32,
99    /// Contiguous applied frontier (`ReplicaInfo::last_applied_lsn`).
100    pub frontier_lsn: u64,
101    /// `true` while this replica is re-bootstrapping (issue #837). Its
102    /// advertised frontier describes data it is about to discard, so it
103    /// is never eligible for a causal read regardless of how far ahead
104    /// that frontier sits.
105    pub rebootstrapping: bool,
106    /// `true` when this replica can serve the bookmark *now*: healthy,
107    /// **not** re-bootstrapping, and its frontier already covers the
108    /// bookmark commit LSN.
109    pub bookmark_eligible: bool,
110}
111
112impl ReadEndpoint {
113    fn from_replica(info: &ReplicaInfo, bookmark: BookmarkTarget) -> Self {
114        // A re-bootstrapping replica is excluded even when its frontier
115        // covers the bookmark: that frontier describes data it is about
116        // to throw away on the atomic swap (issue #837).
117        let eligible =
118            info.healthy && !info.rebootstrapping && info.last_applied_lsn >= bookmark.commit_lsn;
119        Self {
120            addr: info.addr.clone(),
121            region: info.region.clone(),
122            healthy: info.healthy,
123            lag_ms: info.lag_ms,
124            frontier_lsn: info.last_applied_lsn,
125            rebootstrapping: info.rebootstrapping,
126            bookmark_eligible: eligible,
127        }
128    }
129
130    fn endpoint(&self) -> Endpoint {
131        Endpoint {
132            addr: self.addr.clone(),
133            region: self.region.clone(),
134        }
135    }
136}
137
138/// Why a causal read landed on the endpoint it did.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum RouteKind {
141    /// The chosen target replica's snapshot frontier already covered
142    /// the bookmark — no wait was needed.
143    EligibleTarget,
144    /// The target replica was behind but caught up within the deadline
145    /// while we waited.
146    CaughtUpTarget,
147    /// The target stayed behind past the deadline; routed to another
148    /// replica already past the bookmark.
149    FallbackReplica,
150    /// No replica was past the bookmark; routed to the primary, which
151    /// is always past every committed bookmark.
152    FallbackPrimary,
153}
154
155impl RouteKind {
156    /// True when the read was served by a fallback hop rather than the
157    /// originally-chosen target replica.
158    pub fn is_fallback(self) -> bool {
159        matches!(self, Self::FallbackReplica | Self::FallbackPrimary)
160    }
161}
162
163/// The resolved route for a causal read. Always present — the routing
164/// table never turns a lagging replica into a hard error.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct RouteDecision {
167    /// The endpoint to dial.
168    pub endpoint: Endpoint,
169    /// How the decision was reached.
170    pub kind: RouteKind,
171    /// How long the bounded wait took before the decision settled.
172    pub waited: Duration,
173}
174
175/// Drives the bounded wait against a target replica.
176///
177/// Each [`Self::poll`] blocks for one poll interval (clamped by the
178/// remaining deadline), then reports the target replica's *current*
179/// contiguous applied frontier — in production by issuing a lightweight
180/// frontier RPC. [`Self::elapsed`] reports the time spent so far so the
181/// routing table can enforce the deadline without owning a clock.
182///
183/// Keeping the transport behind this trait lets the wait-then-fallback
184/// logic be exercised deterministically with a scripted fake.
185pub trait BookmarkWaiter {
186    /// Time elapsed since the wait began.
187    fn elapsed(&self) -> Duration;
188    /// Block for one poll interval, then return the target's current
189    /// applied frontier LSN.
190    fn poll(&mut self, target_addr: &str) -> u64;
191}
192
193/// Options for a causal read route.
194#[derive(Debug, Clone, Default)]
195pub struct CausalReadOptions {
196    /// Region the caller prefers to read from (locality). When set, a
197    /// healthy replica in this region is chosen as the wait target
198    /// ahead of out-of-region replicas.
199    pub preferred_region: Option<String>,
200    /// Upper bound on how long to wait for the target to catch up
201    /// before falling back.
202    pub deadline: Duration,
203}
204
205impl CausalReadOptions {
206    /// Bounded wait with the given deadline and no region preference.
207    pub fn with_deadline(deadline: Duration) -> Self {
208        Self {
209            preferred_region: None,
210            deadline,
211        }
212    }
213
214    /// Prefer reads from `region`.
215    pub fn prefer_region(mut self, region: impl Into<String>) -> Self {
216        self.preferred_region = Some(region.into());
217        self
218    }
219}
220
221/// Driver-consumable routing table derived from a topology
222/// advertisement plus the current replication term.
223#[derive(Debug, Clone, PartialEq, Eq)]
224pub struct RoutingTable {
225    write: WriteEndpoint,
226    replicas: Vec<ReplicaInfo>,
227    epoch: u64,
228}
229
230impl RoutingTable {
231    /// Build a routing table from a merged membership snapshot and the
232    /// current replication `term`. The primary becomes the write
233    /// endpoint; the advertised replicas become the read pool.
234    pub fn from_membership(membership: ClusterMembership, term: u64) -> Self {
235        let ClusterMembership {
236            primary,
237            replicas,
238            epoch,
239        } = membership;
240        Self {
241            write: WriteEndpoint {
242                addr: primary.addr,
243                region: primary.region,
244                term,
245            },
246            replicas,
247            epoch,
248        }
249    }
250
251    /// Epoch of the advertisement this table was built from.
252    pub fn epoch(&self) -> u64 {
253        self.epoch
254    }
255
256    /// The write endpoint — the current primary, keyed by term.
257    pub fn write_endpoint(&self) -> &WriteEndpoint {
258        &self.write
259    }
260
261    /// The read endpoints with per-node frontier and bookmark
262    /// eligibility computed against `bookmark`. Order matches the
263    /// advertisement.
264    pub fn read_endpoints(&self, bookmark: BookmarkTarget) -> Vec<ReadEndpoint> {
265        self.replicas
266            .iter()
267            .map(|r| ReadEndpoint::from_replica(r, bookmark))
268            .collect()
269    }
270
271    /// Pick the index of the wait target: a healthy, non-rebuilding
272    /// replica, preferring `preferred_region`, otherwise the first such
273    /// replica in advertised order. `None` when no replica can serve a
274    /// causal read.
275    ///
276    /// A re-bootstrapping replica is skipped entirely (issue #837):
277    /// there is no point waiting on or polling a node whose frontier
278    /// describes data it is about to discard — it can never become a
279    /// valid causal target until its swap completes and it re-advertises
280    /// without the flag.
281    fn pick_target_index(&self, preferred_region: Option<&str>) -> Option<usize> {
282        if let Some(region) = preferred_region {
283            if let Some(i) = self
284                .replicas
285                .iter()
286                .position(|r| r.healthy && !r.rebootstrapping && r.region == region)
287            {
288                return Some(i);
289            }
290        }
291        self.replicas
292            .iter()
293            .position(|r| r.healthy && !r.rebootstrapping)
294    }
295
296    /// Resolve a causal read with bounded-wait-then-fallback.
297    ///
298    /// Never errors: a lagging target degrades to a fallback hop
299    /// (another caught-up replica, else the primary).
300    pub fn route_causal_read(
301        &self,
302        bookmark: BookmarkTarget,
303        opts: &CausalReadOptions,
304        waiter: &mut dyn BookmarkWaiter,
305    ) -> RouteDecision {
306        let target_idx = self.pick_target_index(opts.preferred_region.as_deref());
307
308        if let Some(idx) = target_idx {
309            let target = &self.replicas[idx];
310
311            // Fast path: the snapshot already shows the target past the
312            // bookmark — route immediately, no wait.
313            if target.last_applied_lsn >= bookmark.commit_lsn {
314                return RouteDecision {
315                    endpoint: Endpoint {
316                        addr: target.addr.clone(),
317                        region: target.region.clone(),
318                    },
319                    kind: RouteKind::EligibleTarget,
320                    waited: Duration::ZERO,
321                };
322            }
323
324            // Bounded wait: poll the target's live frontier until it
325            // catches up or the deadline elapses.
326            let addr = target.addr.clone();
327            let region = target.region.clone();
328            while waiter.elapsed() < opts.deadline {
329                let frontier = waiter.poll(&addr);
330                if frontier >= bookmark.commit_lsn {
331                    return RouteDecision {
332                        endpoint: Endpoint { addr, region },
333                        kind: RouteKind::CaughtUpTarget,
334                        waited: waiter.elapsed(),
335                    };
336                }
337            }
338
339            // Deadline blown — fall back below, excluding the target.
340            return self.fall_back(bookmark, Some(idx), waiter.elapsed());
341        }
342
343        // No healthy replica at all — fall back straight away.
344        self.fall_back(bookmark, None, waiter.elapsed())
345    }
346
347    /// Fallback selection: a healthy replica (other than `exclude`)
348    /// already past the bookmark, else the primary.
349    fn fall_back(
350        &self,
351        bookmark: BookmarkTarget,
352        exclude: Option<usize>,
353        waited: Duration,
354    ) -> RouteDecision {
355        let caught_up = self.replicas.iter().enumerate().find(|(i, r)| {
356            Some(*i) != exclude
357                && r.healthy
358                && !r.rebootstrapping
359                && r.last_applied_lsn >= bookmark.commit_lsn
360        });
361        match caught_up {
362            Some((_, r)) => RouteDecision {
363                endpoint: ReadEndpoint::from_replica(r, bookmark).endpoint(),
364                kind: RouteKind::FallbackReplica,
365                waited,
366            },
367            None => RouteDecision {
368                endpoint: self.write.endpoint(),
369                kind: RouteKind::FallbackPrimary,
370                waited,
371            },
372        }
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use reddb_wire::topology::Endpoint as WireEndpoint;
380
381    fn primary() -> WireEndpoint {
382        WireEndpoint {
383            addr: "primary:5050".into(),
384            region: "us-east-1".into(),
385        }
386    }
387
388    fn replica(addr: &str, region: &str, healthy: bool, frontier: u64) -> ReplicaInfo {
389        ReplicaInfo {
390            addr: addr.into(),
391            region: region.into(),
392            healthy,
393            lag_ms: if healthy { 5 } else { u32::MAX },
394            last_applied_lsn: frontier,
395            rebootstrapping: false,
396        }
397    }
398
399    /// A re-bootstrapping replica: healthy and far ahead on its
400    /// advertised frontier, but rebuilding — so never causally
401    /// eligible (issue #837).
402    fn rebuilding_replica(addr: &str, region: &str, frontier: u64) -> ReplicaInfo {
403        ReplicaInfo {
404            addr: addr.into(),
405            region: region.into(),
406            healthy: true,
407            lag_ms: 5,
408            last_applied_lsn: frontier,
409            rebootstrapping: true,
410        }
411    }
412
413    fn membership(replicas: Vec<ReplicaInfo>) -> ClusterMembership {
414        ClusterMembership {
415            primary: primary(),
416            replicas,
417            epoch: 3,
418        }
419    }
420
421    /// Scripted waiter: returns the next frontier in `steps` on each
422    /// poll, advancing the elapsed clock by `tick` per poll. Once the
423    /// script is exhausted it keeps returning the last value so the
424    /// deadline (not the script) terminates the loop.
425    struct ScriptedWaiter {
426        steps: Vec<u64>,
427        idx: usize,
428        tick: Duration,
429        elapsed: Duration,
430        polled_addrs: Vec<String>,
431    }
432
433    impl ScriptedWaiter {
434        fn new(steps: Vec<u64>, tick: Duration) -> Self {
435            Self {
436                steps,
437                idx: 0,
438                tick,
439                elapsed: Duration::ZERO,
440                polled_addrs: Vec::new(),
441            }
442        }
443    }
444
445    impl BookmarkWaiter for ScriptedWaiter {
446        fn elapsed(&self) -> Duration {
447            self.elapsed
448        }
449        fn poll(&mut self, target_addr: &str) -> u64 {
450            self.polled_addrs.push(target_addr.to_string());
451            self.elapsed += self.tick;
452            let v = self
453                .steps
454                .get(self.idx)
455                .copied()
456                .or_else(|| self.steps.last().copied())
457                .unwrap_or(0);
458            self.idx += 1;
459            v
460        }
461    }
462
463    // ---- write endpoint: primary by term ----
464
465    #[test]
466    fn write_endpoint_is_primary_keyed_by_term() {
467        let table = RoutingTable::from_membership(membership(vec![]), 9);
468        let w = table.write_endpoint();
469        assert_eq!(w.addr, "primary:5050");
470        assert_eq!(w.region, "us-east-1");
471        assert_eq!(w.term, 9);
472        assert!(w.serves_term(9));
473        assert!(!w.serves_term(10));
474    }
475
476    // ---- read endpoints: per-node frontier + eligibility ----
477
478    #[test]
479    fn read_endpoints_carry_frontier_and_eligibility() {
480        let table = RoutingTable::from_membership(
481            membership(vec![
482                replica("r-ahead:5050", "us-east-1", true, 200),
483                replica("r-behind:5050", "us-east-1", true, 90),
484                replica("r-down:5050", "us-west-2", false, 500),
485            ]),
486            1,
487        );
488        let bookmark = BookmarkTarget::new(1, 100);
489        let reads = table.read_endpoints(bookmark);
490        assert_eq!(reads.len(), 3);
491
492        // Past the bookmark and healthy → eligible.
493        assert_eq!(reads[0].frontier_lsn, 200);
494        assert!(reads[0].bookmark_eligible);
495
496        // Healthy but behind the commit LSN → ineligible.
497        assert_eq!(reads[1].frontier_lsn, 90);
498        assert!(!reads[1].bookmark_eligible);
499
500        // Past the bookmark but unhealthy → ineligible.
501        assert!(reads[2].frontier_lsn >= 100);
502        assert!(!reads[2].healthy);
503        assert!(!reads[2].bookmark_eligible);
504    }
505
506    #[test]
507    fn eligibility_boundary_is_inclusive_at_commit_lsn() {
508        let table =
509            RoutingTable::from_membership(membership(vec![replica("r:5050", "r1", true, 100)]), 1);
510        // frontier == commit_lsn must count as eligible.
511        let reads = table.read_endpoints(BookmarkTarget::new(1, 100));
512        assert!(reads[0].bookmark_eligible);
513    }
514
515    // ---- rebootstrapping replicas are excluded from causal reads ----
516
517    #[test]
518    fn rebootstrapping_replica_is_never_bookmark_eligible_despite_frontier() {
519        // Frontier 999 is well past the bookmark, but the node is
520        // rebuilding — its frontier describes data it will discard.
521        let table = RoutingTable::from_membership(
522            membership(vec![rebuilding_replica("rebuild:5050", "us-east-1", 999)]),
523            1,
524        );
525        let reads = table.read_endpoints(BookmarkTarget::new(1, 100));
526        assert_eq!(reads[0].frontier_lsn, 999);
527        assert!(reads[0].rebootstrapping);
528        assert!(
529            !reads[0].bookmark_eligible,
530            "a rebuilding node must never be bookmark-eligible"
531        );
532    }
533
534    #[test]
535    fn route_skips_rebuilding_node_and_falls_back_to_caught_up_peer() {
536        // The rebuilding node is first in advertised order and far
537        // ahead, but causal reads must bounce to the caught-up peer —
538        // without ever polling the rebuilding node.
539        let table = RoutingTable::from_membership(
540            membership(vec![
541                rebuilding_replica("rebuild:5050", "us-east-1", 999),
542                replica("caught-up:5050", "us-east-1", true, 300),
543            ]),
544            1,
545        );
546        let mut waiter = ScriptedWaiter::new(vec![], Duration::from_millis(10));
547        let decision = table.route_causal_read(
548            BookmarkTarget::new(1, 100),
549            &CausalReadOptions::with_deadline(Duration::from_millis(500)),
550            &mut waiter,
551        );
552        // The caught-up peer is the target chosen straight away; the
553        // rebuilding node was never selected as the wait target.
554        assert_eq!(decision.endpoint.addr, "caught-up:5050");
555        assert!(!decision.kind.is_fallback());
556        assert!(
557            waiter.polled_addrs.iter().all(|a| a != "rebuild:5050"),
558            "must never poll a rebuilding node"
559        );
560    }
561
562    #[test]
563    fn route_falls_back_to_primary_when_every_replica_is_rebuilding() {
564        // All replicas are rebuilding (and ahead of the bookmark);
565        // a causal read must still resolve — to the primary.
566        let table = RoutingTable::from_membership(
567            membership(vec![
568                rebuilding_replica("rebuild-a:5050", "us-east-1", 999),
569                rebuilding_replica("rebuild-b:5050", "us-west-2", 999),
570            ]),
571            4,
572        );
573        let mut waiter = ScriptedWaiter::new(vec![], Duration::from_millis(10));
574        let decision = table.route_causal_read(
575            BookmarkTarget::new(4, 100),
576            &CausalReadOptions::with_deadline(Duration::from_millis(500)),
577            &mut waiter,
578        );
579        assert_eq!(decision.kind, RouteKind::FallbackPrimary);
580        assert_eq!(decision.endpoint.addr, "primary:5050");
581        assert!(
582            waiter.polled_addrs.is_empty(),
583            "no rebuilding node should be polled"
584        );
585    }
586
587    #[test]
588    fn rebuilding_node_excluded_as_fallback_target() {
589        // The wait target (region-preferred, lagging) never catches up;
590        // the only frontier-ahead peer is rebuilding, so the fallback
591        // skips it and lands on the primary rather than serving a
592        // bookmark from data about to be discarded.
593        let table = RoutingTable::from_membership(
594            membership(vec![
595                replica("east-lag:5050", "us-east-1", true, 10),
596                rebuilding_replica("west-rebuild:5050", "us-west-2", 999),
597            ]),
598            2,
599        );
600        let mut waiter = ScriptedWaiter::new(vec![10, 20, 30], Duration::from_millis(10));
601        let decision = table.route_causal_read(
602            BookmarkTarget::new(2, 100),
603            &CausalReadOptions::with_deadline(Duration::from_millis(25)).prefer_region("us-east-1"),
604            &mut waiter,
605        );
606        assert_eq!(decision.kind, RouteKind::FallbackPrimary);
607        assert_eq!(decision.endpoint.addr, "primary:5050");
608    }
609
610    // ---- route: immediate eligible target (no wait) ----
611
612    #[test]
613    fn route_picks_eligible_target_without_waiting() {
614        let table = RoutingTable::from_membership(
615            membership(vec![replica("r-ok:5050", "us-east-1", true, 150)]),
616            1,
617        );
618        let mut waiter = ScriptedWaiter::new(vec![], Duration::from_millis(10));
619        let decision = table.route_causal_read(
620            BookmarkTarget::new(1, 100),
621            &CausalReadOptions::with_deadline(Duration::from_millis(500)),
622            &mut waiter,
623        );
624        assert_eq!(decision.kind, RouteKind::EligibleTarget);
625        assert_eq!(decision.endpoint.addr, "r-ok:5050");
626        assert_eq!(decision.waited, Duration::ZERO);
627        assert!(waiter.polled_addrs.is_empty(), "must not poll on fast path");
628    }
629
630    #[test]
631    fn route_prefers_region_for_the_target() {
632        let table = RoutingTable::from_membership(
633            membership(vec![
634                replica("east:5050", "us-east-1", true, 150),
635                replica("west:5050", "us-west-2", true, 150),
636            ]),
637            1,
638        );
639        let mut waiter = ScriptedWaiter::new(vec![], Duration::from_millis(10));
640        let decision = table.route_causal_read(
641            BookmarkTarget::new(1, 100),
642            &CausalReadOptions::with_deadline(Duration::from_millis(500))
643                .prefer_region("us-west-2"),
644            &mut waiter,
645        );
646        assert_eq!(decision.endpoint.addr, "west:5050");
647    }
648
649    // ---- route: target catches up within the deadline ----
650
651    #[test]
652    fn route_waits_and_routes_to_target_once_it_catches_up() {
653        let table = RoutingTable::from_membership(
654            membership(vec![replica("r-lag:5050", "us-east-1", true, 50)]),
655            1,
656        );
657        // Target is behind in the snapshot (50 < 100); it advances to
658        // 100 on the third poll.
659        let mut waiter = ScriptedWaiter::new(vec![60, 80, 100], Duration::from_millis(10));
660        let decision = table.route_causal_read(
661            BookmarkTarget::new(1, 100),
662            &CausalReadOptions::with_deadline(Duration::from_millis(500)),
663            &mut waiter,
664        );
665        assert_eq!(decision.kind, RouteKind::CaughtUpTarget);
666        assert_eq!(decision.endpoint.addr, "r-lag:5050");
667        assert_eq!(decision.waited, Duration::from_millis(30));
668        assert_eq!(waiter.polled_addrs.len(), 3);
669    }
670
671    // ---- route: target stays behind → fall back to a caught-up node ----
672
673    #[test]
674    fn route_falls_back_to_caught_up_replica_when_target_stays_behind() {
675        let table = RoutingTable::from_membership(
676            membership(vec![
677                // Region-preferred target that never catches up.
678                replica("east-lag:5050", "us-east-1", true, 10),
679                // Out-of-region replica already past the bookmark.
680                replica("west-ok:5050", "us-west-2", true, 300),
681            ]),
682            1,
683        );
684        // Deadline 25ms, tick 10ms → 3 polls, target frontier never
685        // reaches 100.
686        let mut waiter = ScriptedWaiter::new(vec![10, 20, 30], Duration::from_millis(10));
687        let decision = table.route_causal_read(
688            BookmarkTarget::new(1, 100),
689            &CausalReadOptions::with_deadline(Duration::from_millis(25)).prefer_region("us-east-1"),
690            &mut waiter,
691        );
692        assert_eq!(decision.kind, RouteKind::FallbackReplica);
693        assert_eq!(decision.endpoint.addr, "west-ok:5050");
694        assert!(decision.waited >= Duration::from_millis(25));
695        // The fallback target was never the polled (lagging) node.
696        assert!(waiter.polled_addrs.iter().all(|a| a == "east-lag:5050"));
697    }
698
699    // ---- route: no caught-up replica → fall back to primary ----
700
701    #[test]
702    fn route_falls_back_to_primary_when_no_replica_is_caught_up() {
703        let table = RoutingTable::from_membership(
704            membership(vec![
705                replica("r1:5050", "us-east-1", true, 10),
706                replica("r2:5050", "us-east-1", true, 20),
707            ]),
708            7,
709        );
710        let mut waiter = ScriptedWaiter::new(vec![10, 20], Duration::from_millis(10));
711        let decision = table.route_causal_read(
712            BookmarkTarget::new(7, 100),
713            &CausalReadOptions::with_deadline(Duration::from_millis(15)),
714            &mut waiter,
715        );
716        assert_eq!(decision.kind, RouteKind::FallbackPrimary);
717        assert_eq!(decision.endpoint.addr, "primary:5050");
718        assert!(decision.kind.is_fallback());
719    }
720
721    #[test]
722    fn route_falls_back_to_primary_when_no_replica_is_healthy() {
723        let table = RoutingTable::from_membership(
724            membership(vec![replica("r-down:5050", "us-east-1", false, 500)]),
725            1,
726        );
727        let mut waiter = ScriptedWaiter::new(vec![], Duration::from_millis(10));
728        let decision = table.route_causal_read(
729            BookmarkTarget::new(1, 100),
730            &CausalReadOptions::with_deadline(Duration::from_millis(500)),
731            &mut waiter,
732        );
733        // No healthy replica → straight to primary, no polling.
734        assert_eq!(decision.kind, RouteKind::FallbackPrimary);
735        assert_eq!(decision.endpoint.addr, "primary:5050");
736        assert!(waiter.polled_addrs.is_empty());
737    }
738
739    #[test]
740    fn route_falls_back_to_primary_when_no_replicas_advertised() {
741        let table = RoutingTable::from_membership(membership(vec![]), 1);
742        let mut waiter = ScriptedWaiter::new(vec![], Duration::from_millis(10));
743        let decision = table.route_causal_read(
744            BookmarkTarget::new(1, 100),
745            &CausalReadOptions::with_deadline(Duration::from_millis(500)),
746            &mut waiter,
747        );
748        assert_eq!(decision.kind, RouteKind::FallbackPrimary);
749        assert_eq!(decision.endpoint.addr, "primary:5050");
750    }
751
752    // ---- a lagging replica never produces a hard error ----
753
754    #[test]
755    fn lagging_replica_never_errors_always_resolves_an_endpoint() {
756        // Whatever the topology shape, route_causal_read returns a
757        // dialable endpoint — never a panic, never an Err.
758        let shapes = vec![
759            vec![],
760            vec![replica("a:5050", "r1", true, 0)],
761            vec![replica("a:5050", "r1", false, 0)],
762            vec![
763                replica("a:5050", "r1", true, 1),
764                replica("b:5050", "r2", true, 999),
765            ],
766        ];
767        for shape in shapes {
768            let table = RoutingTable::from_membership(membership(shape), 1);
769            let mut waiter = ScriptedWaiter::new(vec![0], Duration::from_millis(10));
770            let decision = table.route_causal_read(
771                BookmarkTarget::new(1, 100),
772                &CausalReadOptions::with_deadline(Duration::from_millis(20)),
773                &mut waiter,
774            );
775            assert!(!decision.endpoint.addr.is_empty());
776        }
777    }
778}