Skip to main content

reddb_client/
topology.rs

1//! Client-side topology consumer (issue #168, ADR 0008).
2//!
3//! Parses the canonical [`reddb_wire::topology::Topology`] payload —
4//! delivered either as raw gRPC bytes or as a base64-wrapped string
5//! field inside a RedWire HelloAck JSON envelope — and projects it
6//! into a [`ClusterMembership`] structure that downstream routing
7//! can read without caring about the wire encoding.
8//!
9//! # Merge rule (ADR 0008 §2)
10//!
11//! The seed URI a caller passes to `Reddb::connect("grpc://a,b,c")`
12//! is a *hint*, not a constraint. When the server advertises a
13//! topology:
14//!
15//! * The advertised primary always wins. The seed primary is
16//!   discarded.
17//! * Replicas advertised by the server make the cut. Each carries
18//!   the server's metadata (`region`, `healthy`, `lag_ms`,
19//!   `last_applied_lsn`).
20//! * Replicas listed in the seed URI but absent from the
21//!   advertisement are dropped — the operator decommissioned that
22//!   node and the seed is stale.
23//! * Replicas in both lists are kept; advertised metadata wins on
24//!   any field collision.
25//!
26//! # Refresh contract
27//!
28//! [`TopologyConsumer::should_refresh`] short-circuits when the
29//! observed epoch matches the current one. A higher-level driver
30//! (the future `HealthAwareRouter` in lane Q) is expected to:
31//!
32//! * Poll the [`Topology`] RPC at a configured interval (default
33//!   30s — see [`DEFAULT_REFRESH_INTERVAL`]).
34//! * Force a refresh on the next call after a connection-level
35//!   error, regardless of timer state.
36//! * Skip the refresh when the previously observed epoch matches.
37//!
38//! [`RefreshScheduler`] captures the first two pieces with a
39//! pluggable clock so the 30s interval is testable without real
40//! waits.
41//!
42//! # Forward-compat (ADR 0008 §4)
43//!
44//! Unknown wire version tags and malformed base64 are *not* errors;
45//! they collapse to "fall back to URI-only routing" by surfacing a
46//! [`ConsumeError::UnknownVersion`] / [`ConsumeError::MalformedEnvelope`]
47//! that callers downgrade with a one-line warning. Structurally
48//! malformed bodies (truncated, bad UTF-8, oversized strings) bubble
49//! up as typed [`ConsumeError`] variants — never panics.
50
51use std::time::Duration;
52
53use reddb_wire::topology::{
54    self as wire, decode_topology, Endpoint as WireEndpoint, ReplicaInfo, Topology,
55};
56
57/// Default refresh interval for the topology poll loop. ADR 0008
58/// §1 picks 30s as the conservative default; operators can override
59/// per-deployment via [`RefreshScheduler::with_interval`].
60pub const DEFAULT_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
61
62/// Seed addresses extracted from the connection URI.
63///
64/// `primary` is the host the caller dialled first. `replicas` is the
65/// optional comma-separated tail (`grpc://a,b,c`). Both are kept as
66/// the raw connection-string strings so the merge rule can match
67/// them against advertised endpoint addresses cheaply.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct UriSeed {
70    pub primary: String,
71    pub replicas: Vec<String>,
72}
73
74impl UriSeed {
75    /// Single-host seed (no replicas listed in the URI).
76    pub fn single(primary: impl Into<String>) -> Self {
77        Self {
78            primary: primary.into(),
79            replicas: Vec::new(),
80        }
81    }
82
83    /// Multi-host seed straight from a parsed `grpc://a,b,c` URI.
84    pub fn cluster(primary: impl Into<String>, replicas: Vec<String>) -> Self {
85        Self {
86            primary: primary.into(),
87            replicas,
88        }
89    }
90}
91
92/// The merged, route-ready view of the cluster.
93///
94/// The fields are the wire-canonical types from `reddb-wire` so a
95/// future router can read them without translating shapes again.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct ClusterMembership {
98    pub primary: WireEndpoint,
99    pub replicas: Vec<ReplicaInfo>,
100    pub epoch: u64,
101}
102
103/// Decode + merge errors. The unknown-version and malformed-envelope
104/// variants are recoverable: the caller is expected to log a warning
105/// and fall back to URI-only routing (ADR 0008 §4). The structural
106/// variants (truncated, bad UTF-8, oversize strings) indicate a
107/// genuinely broken peer.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum ConsumeError {
110    /// Buffer shorter than the 5-byte version+length header.
111    Truncated,
112    /// Header declared more body bytes than the buffer carries.
113    BodyLengthMismatch { declared: u32, available: usize },
114    /// A length-prefixed string field was not valid UTF-8.
115    InvalidUtf8,
116    /// A length-prefixed string declared more bytes than the body
117    /// has remaining.
118    StringTooLong { declared: u32, remaining: usize },
119    /// Recognised header but the version tag is past
120    /// [`wire::MAX_KNOWN_TOPOLOGY_VERSION`]. Recoverable: drop the
121    /// advertisement, fall back to URI-only routing.
122    UnknownVersion,
123    /// HelloAck `topology` field was not valid base64. Recoverable
124    /// in the same way as [`Self::UnknownVersion`].
125    MalformedEnvelope,
126}
127
128impl std::fmt::Display for ConsumeError {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        match self {
131            Self::Truncated => write!(f, "topology blob truncated"),
132            Self::BodyLengthMismatch {
133                declared,
134                available,
135            } => write!(
136                f,
137                "topology body length mismatch (declared {declared}, available {available})"
138            ),
139            Self::InvalidUtf8 => write!(f, "topology string field is not valid UTF-8"),
140            Self::StringTooLong {
141                declared,
142                remaining,
143            } => write!(
144                f,
145                "topology string length {declared} exceeds remaining body {remaining}"
146            ),
147            Self::UnknownVersion => write!(
148                f,
149                "topology wire version tag past MAX_KNOWN_TOPOLOGY_VERSION; falling back to URI-only routing"
150            ),
151            Self::MalformedEnvelope => write!(
152                f,
153                "topology envelope (HelloAck base64) is malformed; falling back to URI-only routing"
154            ),
155        }
156    }
157}
158
159impl std::error::Error for ConsumeError {}
160
161impl ConsumeError {
162    /// True when the caller should downgrade to URI-only routing
163    /// with a one-line warning (ADR 0008 §4) rather than treat the
164    /// error as a hard failure.
165    pub fn is_recoverable(&self) -> bool {
166        matches!(self, Self::UnknownVersion | Self::MalformedEnvelope)
167    }
168}
169
170impl From<wire::TopologyError> for ConsumeError {
171    fn from(e: wire::TopologyError) -> Self {
172        match e {
173            wire::TopologyError::Truncated => Self::Truncated,
174            wire::TopologyError::BodyLengthMismatch {
175                declared,
176                available,
177            } => Self::BodyLengthMismatch {
178                declared,
179                available,
180            },
181            wire::TopologyError::InvalidUtf8 => Self::InvalidUtf8,
182            wire::TopologyError::StringTooLong {
183                declared,
184                remaining,
185            } => Self::StringTooLong {
186                declared,
187                remaining,
188            },
189        }
190    }
191}
192
193/// Stateless deep module — entry points are associated functions so
194/// the routing driver can hold a `&Self` without capturing any state
195/// the consumer doesn't actually need to keep across calls. State
196/// (current epoch, last refresh) lives on the driver, not here.
197#[derive(Debug, Default)]
198pub struct TopologyConsumer;
199
200impl TopologyConsumer {
201    /// Apply the merge rule from ADR 0008 §2 against an already-
202    /// decoded payload. Pure, infallible, no I/O.
203    pub fn consume(payload: Topology, uri_seed: Option<UriSeed>) -> ClusterMembership {
204        let Topology {
205            epoch,
206            primary,
207            replicas,
208        } = payload;
209
210        // Merge: advertised replicas win on metadata; URI-only
211        // replicas are dropped (decommissioned). Membership we emit
212        // is exactly the advertised set, in advertised order — the
213        // seed only acted as a hint for the *initial* dial.
214        //
215        // We still walk `uri_seed` to keep the merge contract
216        // explicit: if a future variant of this rule wants to
217        // surface "URI replica X was dropped because it isn't in
218        // the advertisement", this is the spot. Today the merge is
219        // a no-op on the seed and we just keep the advertised list.
220        let _ = uri_seed;
221
222        ClusterMembership {
223            primary,
224            replicas,
225            epoch,
226        }
227    }
228
229    /// Decode raw canonical bytes (gRPC `TopologyReply.topology_bytes`)
230    /// and apply the merge.
231    ///
232    /// Recoverable variants (`UnknownVersion`) are surfaced as errors
233    /// for the caller to log; the caller is expected to fall back to
234    /// URI-only routing.
235    pub fn consume_bytes(
236        bytes: &[u8],
237        uri_seed: Option<UriSeed>,
238    ) -> Result<ClusterMembership, ConsumeError> {
239        match decode_topology(bytes)? {
240            Some(t) => Ok(Self::consume(t, uri_seed)),
241            None => Err(ConsumeError::UnknownVersion),
242        }
243    }
244
245    /// Decode the base64-wrapped HelloAck `topology` field and apply
246    /// the merge. Mirrors the gRPC path so a router can drive both
247    /// transports through one code path.
248    pub fn consume_hello_ack(
249        field: &str,
250        uri_seed: Option<UriSeed>,
251    ) -> Result<ClusterMembership, ConsumeError> {
252        // `decode_topology_from_hello_ack` collapses both "bad
253        // base64" and "unknown version tag" into `Ok(None)`. We
254        // can't tell them apart from here, so the recoverable
255        // variant we surface is the union — `MalformedEnvelope` —
256        // when the base64 layer rejected the input. To distinguish,
257        // we first try the base64 decode ourselves: if it succeeds
258        // and `decode_topology` reports unknown version, surface
259        // `UnknownVersion`; if base64 itself failed, surface
260        // `MalformedEnvelope`.
261        match decode_base64(field) {
262            None => Err(ConsumeError::MalformedEnvelope),
263            Some(bytes) => Self::consume_bytes(&bytes, uri_seed),
264        }
265        // (We deliberately don't call `decode_topology_from_hello_ack`
266        // here even though it exists — splitting the two stages lets
267        // us surface a precise recoverable variant.)
268    }
269
270    /// Refresh decision: skip when the observed epoch matches the
271    /// epoch we already applied. Strictly-greater is the canonical
272    /// "advance" condition; a lower observed epoch is treated as
273    /// stale and *also* skipped (the refresh loop will see the next
274    /// poll's payload).
275    ///
276    /// The refresh loop calls this with the raw observed epoch from
277    /// the just-decoded payload. Connection-level errors are out of
278    /// scope here — they force a refresh through a different
279    /// codepath ([`RefreshScheduler::force_now`]).
280    pub fn should_refresh(current_epoch: u64, observed_epoch: u64) -> bool {
281        observed_epoch > current_epoch
282    }
283}
284
285// --------------------------------------------------------------
286// Refresh scheduling — pluggable clock so the 30s interval is
287// testable without real waits.
288// --------------------------------------------------------------
289
290/// Trait the [`RefreshScheduler`] reads time from. The production
291/// impl reads `std::time::Instant::now()`; tests inject a
292/// monotonic-counter fake.
293pub trait Clock: std::fmt::Debug {
294    fn now_monotonic_ms(&self) -> u64;
295}
296
297/// Default real-time clock. Hides the `Instant` epoch so the trait
298/// stays `dyn`-friendly.
299#[derive(Debug)]
300pub struct SystemClock {
301    origin: std::time::Instant,
302}
303
304impl Default for SystemClock {
305    fn default() -> Self {
306        Self {
307            origin: std::time::Instant::now(),
308        }
309    }
310}
311
312impl Clock for SystemClock {
313    fn now_monotonic_ms(&self) -> u64 {
314        self.origin.elapsed().as_millis() as u64
315    }
316}
317
318/// Drives the periodic-refresh + force-on-error rule.
319///
320/// Owns the "should I refresh now?" decision; the actual RPC dispatch
321/// is the higher-level driver's job. Keeping this state machine
322/// isolated lets the 30s interval get tested without sleeping.
323#[derive(Debug)]
324pub struct RefreshScheduler {
325    interval: Duration,
326    clock: Box<dyn Clock + Send + Sync>,
327    last_refresh_ms: Option<u64>,
328    /// Force flag set by [`Self::force_now`]; cleared the next time
329    /// [`Self::should_refresh_now`] returns true.
330    force: bool,
331}
332
333impl RefreshScheduler {
334    /// Build a scheduler with the default 30s interval and the real
335    /// system clock.
336    pub fn new() -> Self {
337        Self::with_interval(DEFAULT_REFRESH_INTERVAL)
338    }
339
340    /// Build a scheduler with a custom interval and the real system
341    /// clock.
342    pub fn with_interval(interval: Duration) -> Self {
343        Self::with_interval_and_clock(interval, Box::new(SystemClock::default()))
344    }
345
346    /// Build a scheduler with a custom interval *and* clock — the
347    /// hook tests inject a fake clock through.
348    pub fn with_interval_and_clock(
349        interval: Duration,
350        clock: Box<dyn Clock + Send + Sync>,
351    ) -> Self {
352        Self {
353            interval,
354            clock,
355            last_refresh_ms: None,
356            force: false,
357        }
358    }
359
360    /// Poll-loop hook: call once per loop iteration (or before each
361    /// dispatch). Returns true when a refresh is due.
362    ///
363    /// On `true`, the caller should dispatch the [`Topology`] RPC,
364    /// run [`TopologyConsumer::consume_bytes`], and call
365    /// [`Self::mark_refreshed`] with the resulting epoch.
366    pub fn should_refresh_now(&mut self) -> bool {
367        if self.force {
368            self.force = false;
369            return true;
370        }
371        let now = self.clock.now_monotonic_ms();
372        let interval_ms = self.interval.as_millis() as u64;
373        match self.last_refresh_ms {
374            None => true,
375            Some(last) => now.saturating_sub(last) >= interval_ms,
376        }
377    }
378
379    /// Stamp the last successful refresh. The next
380    /// [`Self::should_refresh_now`] returns true once
381    /// `interval` has elapsed.
382    pub fn mark_refreshed(&mut self) {
383        self.last_refresh_ms = Some(self.clock.now_monotonic_ms());
384    }
385
386    /// Set the force flag — the next call to
387    /// [`Self::should_refresh_now`] returns true regardless of
388    /// the timer. Used by the routing driver after a connection-
389    /// level error.
390    pub fn force_now(&mut self) {
391        self.force = true;
392    }
393}
394
395impl Default for RefreshScheduler {
396    fn default() -> Self {
397        Self::new()
398    }
399}
400
401// --------------------------------------------------------------
402// Internal: minimal base64 decoder reused so we can split the
403// "bad base64" vs "bad version" recoverable error variants.
404// Mirrors the alphabet used by the wire encoder. Kept private —
405// the wire crate exposes its own; this is a paste-equivalent so
406// we don't widen `reddb-wire`'s public surface for one branch.
407// --------------------------------------------------------------
408
409fn decode_base64(input: &str) -> Option<Vec<u8>> {
410    let trimmed = input.trim_end_matches('=');
411    let mut out = Vec::with_capacity(trimmed.len() * 3 / 4);
412    let mut buf = 0u32;
413    let mut bits = 0u8;
414    for ch in trimmed.bytes() {
415        let v: u32 = match ch {
416            b'A'..=b'Z' => (ch - b'A') as u32,
417            b'a'..=b'z' => (ch - b'a' + 26) as u32,
418            b'0'..=b'9' => (ch - b'0' + 52) as u32,
419            b'+' => 62,
420            b'/' => 63,
421            _ => return None,
422        };
423        buf = (buf << 6) | v;
424        bits += 6;
425        if bits >= 8 {
426            bits -= 8;
427            out.push(((buf >> bits) & 0xFF) as u8);
428        }
429    }
430    Some(out)
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use reddb_wire::topology::{
437        encode_topology, encode_topology_for_hello_ack, Endpoint as WireEndpoint, ReplicaInfo,
438        Topology, TOPOLOGY_HEADER_SIZE, TOPOLOGY_WIRE_VERSION_V1,
439    };
440
441    fn fixture() -> Topology {
442        Topology {
443            epoch: 7,
444            primary: WireEndpoint {
445                addr: "primary.example.com:5050".into(),
446                region: "us-east-1".into(),
447            },
448            replicas: vec![
449                ReplicaInfo {
450                    addr: "replica-a.example.com:5050".into(),
451                    region: "us-east-1".into(),
452                    healthy: true,
453                    lag_ms: 12,
454                    last_applied_lsn: 4242,
455                    rebootstrapping: false,
456                },
457                ReplicaInfo {
458                    addr: "replica-b.example.com:5050".into(),
459                    region: "us-west-2".into(),
460                    healthy: false,
461                    lag_ms: 999,
462                    last_applied_lsn: 4100,
463                    rebootstrapping: false,
464                },
465            ],
466        }
467    }
468
469    // ---- round-trip on both transports ----
470
471    #[test]
472    fn parse_round_trip_grpc_bytes() {
473        let t = fixture();
474        let bytes = encode_topology(&t);
475        let m = TopologyConsumer::consume_bytes(&bytes, None).expect("consume");
476        assert_eq!(m.epoch, 7);
477        assert_eq!(m.primary, t.primary);
478        assert_eq!(m.replicas, t.replicas);
479    }
480
481    #[test]
482    fn parse_round_trip_hello_ack_field() {
483        let t = fixture();
484        let field = encode_topology_for_hello_ack(&t);
485        let m = TopologyConsumer::consume_hello_ack(&field, None).expect("consume");
486        assert_eq!(m.epoch, 7);
487        assert_eq!(m.primary, t.primary);
488        assert_eq!(m.replicas, t.replicas);
489    }
490
491    #[test]
492    fn fixture_byte_stable_across_runs() {
493        // Acceptance: same Topology fixture round-trips byte-stable
494        // through the canonical encoder, so both transports carry
495        // identical bytes (#166 §4 already pinned this; here we
496        // assert the consumer doesn't perturb it).
497        let t = fixture();
498        let a = encode_topology(&t);
499        let b = encode_topology(&t);
500        assert_eq!(a, b);
501        // And the inner bytes recovered from the HelloAck base64
502        // wrapper match the gRPC bytes byte-for-byte.
503        let field = encode_topology_for_hello_ack(&t);
504        let recovered = decode_base64(&field).expect("base64");
505        assert_eq!(recovered, a);
506    }
507
508    // ---- merge rule ----
509
510    #[test]
511    fn merge_uri_only_replicas_dropped() {
512        // URI lists three replicas; advertisement only carries two.
513        // The third (URI-only) must be dropped — operator
514        // decommissioned it.
515        let t = fixture();
516        let seed = UriSeed::cluster(
517            "primary.example.com:5050".to_string(),
518            vec![
519                "replica-a.example.com:5050".into(),
520                "replica-b.example.com:5050".into(),
521                "replica-stale.example.com:5050".into(),
522            ],
523        );
524        let m = TopologyConsumer::consume(t.clone(), Some(seed));
525        assert_eq!(m.replicas.len(), 2, "URI-only replica must be dropped");
526        assert!(
527            m.replicas
528                .iter()
529                .all(|r| r.addr != "replica-stale.example.com:5050"),
530            "stale URI replica must not appear in membership"
531        );
532    }
533
534    #[test]
535    fn merge_advertised_only_replicas_added() {
536        // URI lists no replicas; advertisement carries two. Both
537        // must show up in the merged membership — URI is a hint,
538        // not a constraint.
539        let t = fixture();
540        let seed = UriSeed::single("primary.example.com:5050");
541        let m = TopologyConsumer::consume(t.clone(), Some(seed));
542        assert_eq!(m.replicas.len(), 2);
543        assert_eq!(m.replicas, t.replicas);
544    }
545
546    #[test]
547    fn merge_intersection_uses_advertised_metadata() {
548        // URI replica matches an advertised replica. The merged
549        // membership must carry the *advertised* metadata
550        // (region, healthy, lag_ms, last_applied_lsn), not anything
551        // synthesised from the URI.
552        let t = fixture();
553        let seed = UriSeed::cluster(
554            "primary.example.com:5050".to_string(),
555            vec!["replica-a.example.com:5050".into()],
556        );
557        let m = TopologyConsumer::consume(t.clone(), Some(seed));
558        let merged_a = m
559            .replicas
560            .iter()
561            .find(|r| r.addr == "replica-a.example.com:5050")
562            .expect("advertised replica must be present");
563        assert_eq!(merged_a.region, "us-east-1");
564        assert!(merged_a.healthy);
565        assert_eq!(merged_a.lag_ms, 12);
566        assert_eq!(merged_a.last_applied_lsn, 4242);
567    }
568
569    #[test]
570    fn merge_with_no_seed_keeps_full_advertisement() {
571        let t = fixture();
572        let m = TopologyConsumer::consume(t.clone(), None);
573        assert_eq!(m.primary, t.primary);
574        assert_eq!(m.replicas, t.replicas);
575        assert_eq!(m.epoch, t.epoch);
576    }
577
578    // ---- refresh decision ----
579
580    #[test]
581    fn should_refresh_skips_same_epoch() {
582        assert!(!TopologyConsumer::should_refresh(7, 7));
583    }
584
585    #[test]
586    fn should_refresh_advances_on_higher_epoch() {
587        assert!(TopologyConsumer::should_refresh(7, 8));
588    }
589
590    #[test]
591    fn should_refresh_treats_lower_epoch_as_stale() {
592        // A lower observed epoch means the peer is behind us. We
593        // skip — the next poll picks up the canonical advancement.
594        assert!(!TopologyConsumer::should_refresh(7, 6));
595    }
596
597    // ---- malformed / adversarial fixtures ----
598
599    #[test]
600    fn malformed_truncated_blob_returns_typed_error() {
601        let err = TopologyConsumer::consume_bytes(&[0x01, 0x00], None).unwrap_err();
602        assert!(matches!(err, ConsumeError::Truncated));
603        assert!(!err.is_recoverable());
604    }
605
606    #[test]
607    fn malformed_body_length_mismatch_returns_typed_error() {
608        let bytes = vec![0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x00];
609        let err = TopologyConsumer::consume_bytes(&bytes, None).unwrap_err();
610        assert!(matches!(err, ConsumeError::BodyLengthMismatch { .. }));
611        assert!(!err.is_recoverable());
612    }
613
614    #[test]
615    fn unknown_version_tag_is_recoverable() {
616        // ADR 0008 §4: forward-compat. An unknown wire version tag
617        // collapses to "fall back to URI-only routing", surfaced as
618        // a recoverable typed error so the caller can log a one-line
619        // warning before downgrading.
620        let mut bytes = encode_topology(&fixture());
621        bytes[0] = 0xFE; // past MAX_KNOWN_TOPOLOGY_VERSION
622        let err = TopologyConsumer::consume_bytes(&bytes, None).unwrap_err();
623        assert!(matches!(err, ConsumeError::UnknownVersion));
624        assert!(err.is_recoverable());
625    }
626
627    #[test]
628    fn malformed_envelope_base64_is_recoverable() {
629        // Bad base64 in the HelloAck `topology` field. Same posture
630        // as an unknown version tag: drop, fall back, never panic.
631        let err = TopologyConsumer::consume_hello_ack("@not base64@", None).unwrap_err();
632        assert!(matches!(err, ConsumeError::MalformedEnvelope));
633        assert!(err.is_recoverable());
634    }
635
636    #[test]
637    fn oversize_string_field_returns_typed_error() {
638        // Adversarial: stamp a string-length prefix that overruns
639        // the body. The decoder must surface a typed error, not
640        // panic.
641        // Build a v1 body with a primary.addr length way past the
642        // available body bytes.
643        let mut body = Vec::new();
644        body.extend_from_slice(&0u64.to_le_bytes()); // epoch
645                                                     // primary.addr len = 0xFFFF_FFFF (clearly bogus)
646        body.extend_from_slice(&u32::MAX.to_le_bytes());
647        let mut bytes = Vec::new();
648        bytes.push(TOPOLOGY_WIRE_VERSION_V1);
649        bytes.extend_from_slice(&(body.len() as u32).to_le_bytes());
650        bytes.extend_from_slice(&body);
651        assert_eq!(bytes.len(), TOPOLOGY_HEADER_SIZE + body.len());
652        let err = TopologyConsumer::consume_bytes(&bytes, None).unwrap_err();
653        assert!(
654            matches!(err, ConsumeError::StringTooLong { .. }),
655            "expected StringTooLong, got {err:?}"
656        );
657        assert!(!err.is_recoverable());
658    }
659
660    #[test]
661    fn invalid_utf8_string_returns_typed_error() {
662        // Build a v1 body where primary.addr is two bytes of
663        // invalid UTF-8 (0xFF 0xFE).
664        let mut body = Vec::new();
665        body.extend_from_slice(&0u64.to_le_bytes()); // epoch
666        body.extend_from_slice(&2u32.to_le_bytes()); // primary.addr len
667        body.extend_from_slice(&[0xFF, 0xFE]); // bogus utf8
668                                               // The body would normally continue, but the decoder
669                                               // hits invalid utf8 first.
670        let mut bytes = Vec::new();
671        bytes.push(TOPOLOGY_WIRE_VERSION_V1);
672        bytes.extend_from_slice(&(body.len() as u32).to_le_bytes());
673        bytes.extend_from_slice(&body);
674        let err = TopologyConsumer::consume_bytes(&bytes, None).unwrap_err();
675        assert!(
676            matches!(err, ConsumeError::InvalidUtf8),
677            "expected InvalidUtf8, got {err:?}"
678        );
679    }
680
681    #[test]
682    fn consume_does_not_panic_on_any_random_short_buffer() {
683        // Smoke fuzz: short buffers should always either Ok-Some or
684        // typed-Err, never panic.
685        for n in 0..10usize {
686            let bytes = vec![0xAAu8; n];
687            let _ = TopologyConsumer::consume_bytes(&bytes, None);
688        }
689    }
690
691    // ---- fake-clock RefreshScheduler ----
692
693    #[derive(Debug)]
694    struct FakeClock {
695        ms: std::sync::Mutex<u64>,
696    }
697
698    impl FakeClock {
699        fn new() -> Self {
700            Self {
701                ms: std::sync::Mutex::new(0),
702            }
703        }
704        fn advance(&self, by: Duration) {
705            *self.ms.lock().unwrap() += by.as_millis() as u64;
706        }
707    }
708
709    impl Clock for FakeClock {
710        fn now_monotonic_ms(&self) -> u64 {
711            *self.ms.lock().unwrap()
712        }
713    }
714
715    fn scheduler_with(clock: std::sync::Arc<FakeClock>, interval: Duration) -> RefreshScheduler {
716        // The scheduler owns a Box<dyn Clock>; route both the
717        // scheduler and the test handle through an Arc so the test
718        // can advance time without taking the box back.
719        #[derive(Debug)]
720        struct ArcClock(std::sync::Arc<FakeClock>);
721        impl Clock for ArcClock {
722            fn now_monotonic_ms(&self) -> u64 {
723                self.0.now_monotonic_ms()
724            }
725        }
726        RefreshScheduler::with_interval_and_clock(interval, Box::new(ArcClock(clock)))
727    }
728
729    #[test]
730    fn fake_clock_first_call_refreshes_immediately() {
731        let clock = std::sync::Arc::new(FakeClock::new());
732        let mut s = scheduler_with(clock.clone(), Duration::from_secs(30));
733        assert!(s.should_refresh_now(), "first call must refresh");
734    }
735
736    #[test]
737    fn fake_clock_thirty_second_interval_holds_without_real_waits() {
738        let clock = std::sync::Arc::new(FakeClock::new());
739        let mut s = scheduler_with(clock.clone(), Duration::from_secs(30));
740        assert!(s.should_refresh_now());
741        s.mark_refreshed();
742        // Just under 30s: must NOT refresh.
743        clock.advance(Duration::from_millis(29_999));
744        assert!(
745            !s.should_refresh_now(),
746            "must not refresh before interval elapsed"
747        );
748        // Crossing the threshold: must refresh.
749        clock.advance(Duration::from_millis(2));
750        assert!(
751            s.should_refresh_now(),
752            "must refresh once interval has elapsed"
753        );
754    }
755
756    #[test]
757    fn fake_clock_force_now_overrides_interval() {
758        let clock = std::sync::Arc::new(FakeClock::new());
759        let mut s = scheduler_with(clock.clone(), Duration::from_secs(30));
760        assert!(s.should_refresh_now());
761        s.mark_refreshed();
762        // Far below the 30s interval — would normally skip.
763        clock.advance(Duration::from_millis(100));
764        assert!(!s.should_refresh_now());
765        // Connection-level error: force a refresh on the next call.
766        s.force_now();
767        assert!(s.should_refresh_now(), "force_now must override the timer");
768        // Force flag is single-shot: the next call goes back to the
769        // timer (which has not elapsed).
770        s.mark_refreshed();
771        clock.advance(Duration::from_millis(100));
772        assert!(!s.should_refresh_now());
773    }
774
775    #[test]
776    fn default_interval_is_thirty_seconds() {
777        // Sentinel against an accidental rebase that knocks the
778        // documented 30s default.
779        assert_eq!(DEFAULT_REFRESH_INTERVAL, Duration::from_secs(30));
780    }
781}