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