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}