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