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}