net/adapter/net/failure.rs
1//! Failure detection and recovery for Net.
2//!
3//! This module provides:
4//! - `FailureDetector` - Heartbeat-based failure detection
5//! - `LossSimulator` - Packet loss simulation for testing
6//! - `RecoveryManager` - Route recovery and failover
7//! - `CircuitBreaker` - Prevent cascading failures
8
9use dashmap::DashMap;
10use parking_lot::{Mutex, RwLock};
11use std::collections::VecDeque;
12use std::net::SocketAddr;
13use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
14use std::sync::Arc;
15use std::time::{Duration, Instant};
16
17/// Failure detector configuration
18#[derive(Debug, Clone)]
19pub struct FailureDetectorConfig {
20 /// Heartbeat timeout before considering node failed
21 pub timeout: Duration,
22 /// Number of missed heartbeats before declaring failure
23 pub miss_threshold: u32,
24 /// Suspicion threshold (soft failure)
25 pub suspicion_threshold: u32,
26 /// Cleanup interval for stale entries
27 pub cleanup_interval: Duration,
28}
29
30impl Default for FailureDetectorConfig {
31 fn default() -> Self {
32 Self {
33 timeout: Duration::from_secs(5),
34 miss_threshold: 3,
35 suspicion_threshold: 2,
36 cleanup_interval: Duration::from_secs(30),
37 }
38 }
39}
40
41/// Node health status
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum NodeStatus {
44 /// Node is healthy (receiving heartbeats)
45 Healthy,
46 /// Node is suspected (missed some heartbeats)
47 Suspected,
48 /// Node is considered failed
49 Failed,
50 /// Node status is unknown (never seen)
51 Unknown,
52}
53
54/// Per-node failure tracking state
55#[derive(Debug)]
56struct NodeState {
57 /// Last heartbeat timestamp
58 last_heartbeat: Instant,
59 /// Number of consecutive missed heartbeats
60 missed_count: u32,
61 /// Current status
62 status: NodeStatus,
63 /// Node address
64 #[allow(dead_code)]
65 addr: SocketAddr,
66 /// Total heartbeats received
67 total_heartbeats: u64,
68 /// Time node was first seen
69 #[allow(dead_code)]
70 first_seen: Instant,
71 /// The peer INCARNATION the most recent heartbeat came from — the
72 /// session id, when the caller supplies one (0 = unknown).
73 ///
74 /// Carried so a failure verdict can name the exact incarnation it
75 /// is about. A consumer cannot reconstruct that after the fact: by
76 /// the time a callback runs, the peer may already have been
77 /// replaced, and "read the current session" would then attribute
78 /// the failure to the replacement.
79 epoch: u64,
80 /// Sequence number of the most recent verdict issued for this node.
81 ///
82 /// The epoch alone cannot order verdicts. A failure and a recovery
83 /// for the SAME incarnation both legitimately name epoch E, so a
84 /// delayed failure callback and a concurrent recovery callback are
85 /// indistinguishable by incarnation — and the failure, running
86 /// second, would undo a recovery that had already restored the
87 /// peer's routes. This orders them: every verdict draws a
88 /// monotonic sequence under the node's shard guard, and a consumer
89 /// revalidates at its mutation point that no later verdict has
90 /// been issued since.
91 verdict_seq: u64,
92}
93
94impl NodeState {
95 fn new(addr: SocketAddr, epoch: u64) -> Self {
96 let now = Instant::now();
97 Self {
98 last_heartbeat: now,
99 missed_count: 0,
100 status: NodeStatus::Healthy,
101 addr,
102 total_heartbeats: 1,
103 first_seen: now,
104 epoch,
105 verdict_seq: 0,
106 }
107 }
108
109 fn on_heartbeat(&mut self, addr: SocketAddr, epoch: u64) {
110 self.last_heartbeat = Instant::now();
111 self.missed_count = 0;
112 self.status = NodeStatus::Healthy;
113 self.total_heartbeats += 1;
114 self.addr = addr;
115 // A heartbeat under a NEW incarnation re-stamps the epoch, so a
116 // later failure names the incarnation that actually died.
117 if epoch != 0 {
118 self.epoch = epoch;
119 }
120 }
121
122 fn check(
123 &mut self,
124 now: Instant,
125 timeout: Duration,
126 suspicion_threshold: u32,
127 miss_threshold: u32,
128 ) {
129 // `now` is read once by the caller and shared across the whole
130 // check_all sweep instead of a per-node clock read.
131 let elapsed = now.saturating_duration_since(self.last_heartbeat);
132
133 if elapsed > timeout {
134 // Compute how many heartbeat intervals have been missed based on
135 // actual elapsed time, not just how many times check() was called.
136 // This prevents both under- and over-counting when check_all()
137 // runs at a different frequency than the heartbeat interval.
138 let timeout_nanos = timeout.as_nanos().max(1);
139 self.missed_count = (elapsed.as_nanos() / timeout_nanos) as u32;
140
141 if self.missed_count >= miss_threshold {
142 self.status = NodeStatus::Failed;
143 } else if self.missed_count >= suspicion_threshold {
144 self.status = NodeStatus::Suspected;
145 }
146 }
147 }
148}
149
150/// Failure detection statistics
151#[derive(Debug, Clone, Default)]
152pub struct FailureStats {
153 /// Total nodes tracked
154 pub nodes_tracked: usize,
155 /// Healthy nodes
156 pub nodes_healthy: usize,
157 /// Suspected nodes
158 pub nodes_suspected: usize,
159 /// Failed nodes
160 pub nodes_failed: usize,
161 /// Total failures detected
162 pub total_failures: u64,
163 /// Total recoveries
164 pub total_recoveries: u64,
165}
166
167/// The exact peer state a failure or recovery verdict is ABOUT.
168///
169/// Supplied by the detector, which is the only component that knows
170/// which incarnation's heartbeats it was tracking when it reached the
171/// verdict. A callback cannot reconstruct this from current state:
172/// production runs substantial work between the verdict and the
173/// downstream consumer, and by then a replacement session for the
174/// same `node_id` may already be installed — reading "the current
175/// session" would silently attribute the failure to the replacement
176/// and let a dead peer's callback rewrite a live peer's routes.
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub struct PeerFailureEvent {
179 /// The node the verdict concerns.
180 pub node_id: u64,
181 /// Its address as the detector last observed it.
182 pub addr: SocketAddr,
183 /// The incarnation (session id) the detector was tracking, or 0
184 /// when the heartbeat source supplied none.
185 pub epoch: u64,
186 /// Monotonic order of this verdict among all verdicts for
187 /// `node_id`. Revalidate it with
188 /// [`FailureDetector::verdict_is_current`] before acting on the
189 /// verdict — see [`VerdictStatus`] for why the epoch is not enough.
190 pub verdict_seq: u64,
191 /// Which verdict this is.
192 pub status: VerdictStatus,
193}
194
195/// Which way a verdict went.
196///
197/// Failure and recovery are not distinguishable by incarnation: both
198/// legitimately name the epoch whose heartbeats the detector was
199/// tracking. A consumer handed only `{node_id, addr, epoch}` therefore
200/// cannot tell a stale failure from the recovery that superseded it,
201/// which is how a delayed failure callback ends up tearing down routes
202/// a recovery has already restored.
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum VerdictStatus {
205 /// The detector declared this incarnation failed.
206 Failed,
207 /// Heartbeats resumed for this incarnation.
208 Recovered,
209}
210
211/// Heartbeat-based failure detector.
212///
213/// Tracks node health via heartbeat messages and detects failures.
214pub struct FailureDetector {
215 /// Configuration
216 config: FailureDetectorConfig,
217 /// Per-node state
218 nodes: DashMap<u64, NodeState>,
219 /// Failure callback, carrying the exact failed incarnation.
220 on_failure: Option<Arc<dyn Fn(PeerFailureEvent) + Send + Sync>>,
221 /// Recovery callback, carrying the recovered incarnation.
222 on_recovery: Option<Arc<dyn Fn(PeerFailureEvent) + Send + Sync>>,
223 /// Total failures detected
224 total_failures: AtomicU64,
225 /// Total recoveries
226 total_recoveries: AtomicU64,
227 /// O(1) tracked-node count. `DashMap::len()` walks every shard (~1us);
228 /// node_count()/stats().nodes_tracked read this instead. Maintained on the
229 /// insert (heartbeat) / remove / cleanup paths — the only ones that change
230 /// map size. See docs/internal/misc/PERF_AUDIT_2026_06_08_BENCHMARK_WINS.md §4.
231 num_nodes: AtomicUsize,
232 /// Last cleanup time
233 last_cleanup: Mutex<Instant>,
234 /// Monotonic source of verdict sequence numbers, table-wide so a
235 /// node removed and re-tracked cannot reissue one an earlier
236 /// consumer already holds. Starts at 1: 0 means "no verdict", so a
237 /// freshly tracked node can never be mistaken for one that has
238 /// already been judged.
239 next_verdict_seq: AtomicU64,
240}
241
242impl FailureDetector {
243 /// Create a new failure detector with default config
244 pub fn new() -> Self {
245 Self::with_config(FailureDetectorConfig::default())
246 }
247
248 /// Create with custom config
249 pub fn with_config(config: FailureDetectorConfig) -> Self {
250 Self {
251 config,
252 nodes: DashMap::new(),
253 on_failure: None,
254 on_recovery: None,
255 total_failures: AtomicU64::new(0),
256 total_recoveries: AtomicU64::new(0),
257 num_nodes: AtomicUsize::new(0),
258 last_cleanup: Mutex::new(Instant::now()),
259 next_verdict_seq: AtomicU64::new(1),
260 }
261 }
262
263 /// Draw the next verdict sequence number.
264 fn issue_verdict_seq(&self) -> u64 {
265 self.next_verdict_seq.fetch_add(1, Ordering::Relaxed)
266 }
267
268 /// Whether `verdict_seq` is still the most recent verdict issued
269 /// for `node_id`.
270 ///
271 /// A consumer calls this at the point where it MUTATES state, not
272 /// where it received the callback. Between the two, the detector
273 /// may have reached the opposite verdict for the same incarnation
274 /// — a failure and the recovery that superseded it both name epoch
275 /// E, so nothing in the event itself distinguishes them.
276 ///
277 /// An unknown node answers `false`: a verdict about a node the
278 /// detector no longer tracks has nothing to revalidate against, and
279 /// refusing is the safe direction.
280 pub fn verdict_is_current(&self, node_id: u64, verdict_seq: u64) -> bool {
281 self.nodes
282 .get(&node_id)
283 .is_some_and(|s| s.verdict_seq == verdict_seq)
284 }
285
286 /// Set failure callback. Receives the exact failed incarnation —
287 /// see [`PeerFailureEvent`].
288 pub fn on_failure<F>(mut self, f: F) -> Self
289 where
290 F: Fn(PeerFailureEvent) + Send + Sync + 'static,
291 {
292 self.on_failure = Some(Arc::new(f));
293 self
294 }
295
296 /// Set recovery callback.
297 pub fn on_recovery<F>(mut self, f: F) -> Self
298 where
299 F: Fn(PeerFailureEvent) + Send + Sync + 'static,
300 {
301 self.on_recovery = Some(Arc::new(f));
302 self
303 }
304
305 /// Record a heartbeat from a node
306 ///
307 /// Previously the recovery callback was invoked inside
308 /// `entry().and_modify(...)`, which holds the DashMap shard's
309 /// write lock. A user-supplied callback that re-entered the same
310 /// shard (or any structure ordered against it) deadlocked; even
311 /// without deadlock, every concurrent `heartbeat` hashing to the
312 /// same shard stalled while the callback ran. The fix collects a
313 /// "should I notify?" flag inside the closure and fires the
314 /// callback *after* the `and_modify` returns, releasing the
315 /// shard lock.
316 pub fn heartbeat(&self, node_id: u64, addr: SocketAddr) {
317 self.heartbeat_for_incarnation(node_id, addr, 0);
318 }
319
320 /// [`Self::heartbeat`], naming the peer INCARNATION the heartbeat
321 /// came from (the session id).
322 ///
323 /// Recording it here is what lets a later failure verdict say
324 /// which incarnation died, instead of leaving every consumer to
325 /// guess from state that may already have moved on.
326 pub fn heartbeat_for_incarnation(&self, node_id: u64, addr: SocketAddr, epoch: u64) {
327 let mut should_notify_recovery = false;
328 let mut node_inserted = false;
329 let mut recovered_epoch = epoch;
330 let mut recovered_seq = 0u64;
331 self.nodes
332 .entry(node_id)
333 .and_modify(|state| {
334 let was_failed = state.status == NodeStatus::Failed;
335 state.on_heartbeat(addr, epoch);
336 recovered_epoch = state.epoch;
337
338 if was_failed {
339 self.total_recoveries.fetch_add(1, Ordering::Relaxed);
340 // Stamp the verdict order UNDER the shard guard,
341 // even though the callback fires after it is
342 // released. Sequencing is what makes this recovery
343 // and any concurrent failure verdict comparable;
344 // taking the number outside the guard would let two
345 // verdicts for the same node be stamped in the
346 // opposite order from the one they were decided in.
347 state.verdict_seq = self.issue_verdict_seq();
348 recovered_seq = state.verdict_seq;
349 should_notify_recovery = true;
350 }
351 })
352 .or_insert_with(|| {
353 node_inserted = true;
354 NodeState::new(addr, epoch)
355 });
356 if node_inserted {
357 self.num_nodes.fetch_add(1, Ordering::Relaxed);
358 }
359
360 if should_notify_recovery {
361 if let Some(ref cb) = self.on_recovery {
362 cb(PeerFailureEvent {
363 node_id,
364 addr,
365 epoch: recovered_epoch,
366 verdict_seq: recovered_seq,
367 status: VerdictStatus::Recovered,
368 });
369 }
370 }
371 }
372
373 /// Check all nodes for failures
374 ///
375 /// Callbacks are now invoked *after* the `iter_mut` loop has
376 /// dropped its shard locks. Previously `cb(*entry.key())` ran
377 /// inside the iteration, with the per-shard write lock still
378 /// held — a user-supplied callback that touched another DashMap
379 /// entry on the same shard (or re-entered the failure detector
380 /// itself via `heartbeat` / `status`) would deadlock. We collect
381 /// the failed ids first, release the iteration locks, then fire
382 /// the callbacks.
383 pub fn check_all(&self) -> Vec<u64> {
384 let mut newly_failed = Vec::new();
385
386 // Read the clock once for the whole sweep instead of per node.
387 let now = Instant::now();
388 for mut entry in self.nodes.iter_mut() {
389 let prev_status = entry.status;
390 entry.check(
391 now,
392 self.config.timeout,
393 self.config.suspicion_threshold,
394 self.config.miss_threshold,
395 );
396
397 if entry.status == NodeStatus::Failed && prev_status != NodeStatus::Failed {
398 // Capture the exact incarnation this verdict is about,
399 // while the state that produced it is still in hand,
400 // and order it against every other verdict for this
401 // node. Both stamps happen under the shard guard the
402 // iteration holds; only the callbacks below run after
403 // it is released.
404 entry.verdict_seq = self.issue_verdict_seq();
405 newly_failed.push(PeerFailureEvent {
406 node_id: *entry.key(),
407 addr: entry.addr,
408 epoch: entry.epoch,
409 verdict_seq: entry.verdict_seq,
410 status: VerdictStatus::Failed,
411 });
412 self.total_failures.fetch_add(1, Ordering::Relaxed);
413 }
414 }
415
416 if let Some(ref cb) = self.on_failure {
417 for event in &newly_failed {
418 cb(*event);
419 }
420 }
421
422 newly_failed.into_iter().map(|e| e.node_id).collect()
423 }
424
425 /// Get node status
426 pub fn status(&self, node_id: u64) -> NodeStatus {
427 self.nodes
428 .get(&node_id)
429 .map(|s| s.status)
430 .unwrap_or(NodeStatus::Unknown)
431 }
432
433 /// Get all failed nodes
434 pub fn failed_nodes(&self) -> Vec<u64> {
435 self.nodes
436 .iter()
437 .filter(|r| r.status == NodeStatus::Failed)
438 .map(|r| *r.key())
439 .collect()
440 }
441
442 /// Get all suspected nodes
443 pub fn suspected_nodes(&self) -> Vec<u64> {
444 self.nodes
445 .iter()
446 .filter(|r| r.status == NodeStatus::Suspected)
447 .map(|r| *r.key())
448 .collect()
449 }
450
451 /// Get all healthy nodes
452 pub fn healthy_nodes(&self) -> Vec<u64> {
453 self.nodes
454 .iter()
455 .filter(|r| r.status == NodeStatus::Healthy)
456 .map(|r| *r.key())
457 .collect()
458 }
459
460 /// Remove a node from tracking
461 pub fn remove(&self, node_id: u64) {
462 if self.nodes.remove(&node_id).is_some() {
463 self.num_nodes.fetch_sub(1, Ordering::Relaxed);
464 }
465 }
466
467 /// Remove a node from tracking only while it is still the
468 /// incarnation `epoch` names.
469 ///
470 /// The eviction path decides to drop a peer, then removes its
471 /// detector entry. Between the two a fresh session for the same
472 /// `node_id` can be installed and start heartbeating; an
473 /// unconditional remove then discards the LIVE peer's tracking, and
474 /// nothing re-creates it until that peer's next heartbeat — during
475 /// which window no failure verdict can be reached for it at all.
476 ///
477 /// `epoch == 0` means the caller has no incarnation to name and the
478 /// removal is unconditional, matching [`Self::remove`].
479 pub fn remove_for_incarnation(&self, node_id: u64, epoch: u64) {
480 if epoch == 0 {
481 self.remove(node_id);
482 return;
483 }
484 if self
485 .nodes
486 .remove_if(&node_id, |_, s| s.epoch == epoch)
487 .is_some()
488 {
489 self.num_nodes.fetch_sub(1, Ordering::Relaxed);
490 }
491 }
492
493 /// Clean up stale entries (nodes that have been failed for too long)
494 pub fn cleanup(&self) -> usize {
495 // Recover from poisoning rather than panic. A panic
496 // anywhere holding this mutex would otherwise turn every
497 // subsequent `cleanup()` call into a runtime panic that
498 // takes the failure-detection loop down with it. Matches
499 // the recovery pattern used elsewhere in the crate
500 // (e.g. `crypto.rs::sliding_window`).
501 let mut last = self.last_cleanup.lock();
502 if last.elapsed() < self.config.cleanup_interval {
503 return 0;
504 }
505 *last = Instant::now();
506 drop(last);
507
508 let stale_threshold = self.config.timeout * 10; // 10x timeout = stale
509 let mut removed = 0;
510
511 self.nodes.retain(|_, state| {
512 if state.status == NodeStatus::Failed
513 && state.last_heartbeat.elapsed() > stale_threshold
514 {
515 removed += 1;
516 false
517 } else {
518 true
519 }
520 });
521
522 self.num_nodes.fetch_sub(removed, Ordering::Relaxed);
523 removed
524 }
525
526 /// Get statistics
527 ///
528 /// `nodes_tracked` reads the O(1) counter; the per-status tally is still
529 /// a single pass over the entries. That scan is observability-only (not on
530 /// any hot path) and is deliberately NOT replaced by per-status counters:
531 /// node status is mutated in-place via `get_mut().status = ...` in tests
532 /// and could be elsewhere, which would silently drift maintained counters.
533 /// The scan is always exact.
534 pub fn stats(&self) -> FailureStats {
535 let mut healthy = 0;
536 let mut suspected = 0;
537 let mut failed = 0;
538
539 for entry in self.nodes.iter() {
540 match entry.status {
541 NodeStatus::Healthy => healthy += 1,
542 NodeStatus::Suspected => suspected += 1,
543 NodeStatus::Failed => failed += 1,
544 NodeStatus::Unknown => {}
545 }
546 }
547
548 FailureStats {
549 nodes_tracked: self.num_nodes.load(Ordering::Relaxed),
550 nodes_healthy: healthy,
551 nodes_suspected: suspected,
552 nodes_failed: failed,
553 total_failures: self.total_failures.load(Ordering::Relaxed),
554 total_recoveries: self.total_recoveries.load(Ordering::Relaxed),
555 }
556 }
557
558 /// Get node count
559 pub fn node_count(&self) -> usize {
560 self.num_nodes.load(Ordering::Relaxed)
561 }
562}
563
564impl Default for FailureDetector {
565 fn default() -> Self {
566 Self::new()
567 }
568}
569
570/// Packet loss simulator for testing.
571///
572/// Simulates various network failure conditions.
573pub struct LossSimulator {
574 /// Base loss rate (0.0 - 1.0)
575 loss_rate: f32,
576 /// Burst loss state
577 in_burst: AtomicBool,
578 /// Burst probability
579 burst_prob: f32,
580 /// Burst length (packets)
581 burst_length: u32,
582 /// Current burst remaining
583 burst_remaining: AtomicU64,
584 /// Random state (simple LCG)
585 rng_state: AtomicU64,
586 /// Total packets seen
587 total_packets: AtomicU64,
588 /// Total packets dropped
589 total_dropped: AtomicU64,
590}
591
592impl LossSimulator {
593 /// Create a new loss simulator with given loss rate
594 pub fn new(loss_rate: f32) -> Self {
595 Self {
596 loss_rate: loss_rate.clamp(0.0, 1.0),
597 in_burst: AtomicBool::new(false),
598 burst_prob: 0.0,
599 burst_length: 0,
600 burst_remaining: AtomicU64::new(0),
601 rng_state: AtomicU64::new(
602 std::time::SystemTime::now()
603 .duration_since(std::time::UNIX_EPOCH)
604 .unwrap_or_default()
605 .as_nanos() as u64,
606 ),
607 total_packets: AtomicU64::new(0),
608 total_dropped: AtomicU64::new(0),
609 }
610 }
611
612 /// Create with burst loss behavior
613 pub fn with_bursts(mut self, burst_prob: f32, burst_length: u32) -> Self {
614 self.burst_prob = burst_prob.clamp(0.0, 1.0);
615 self.burst_length = burst_length;
616 self
617 }
618
619 /// Check if a packet should be dropped
620 pub fn should_drop(&self) -> bool {
621 self.total_packets.fetch_add(1, Ordering::Relaxed);
622
623 // Check burst state — use compare-and-swap to avoid underflow wrapping
624 // to u64::MAX when multiple threads race on the last remaining count.
625 loop {
626 let remaining = self.burst_remaining.load(Ordering::Relaxed);
627 if remaining == 0 {
628 break;
629 }
630 match self.burst_remaining.compare_exchange_weak(
631 remaining,
632 remaining - 1,
633 Ordering::Relaxed,
634 Ordering::Relaxed,
635 ) {
636 Ok(_) => {
637 self.total_dropped.fetch_add(1, Ordering::Relaxed);
638 return true;
639 }
640 Err(_) => continue, // Retry CAS
641 }
642 }
643
644 // Generate random value
645 let r = self.next_random();
646
647 // Check for burst start
648 if self.burst_prob > 0.0 && r < self.burst_prob {
649 // The triggering packet counts as the first drop in the burst,
650 // so only burst_length - 1 additional packets remain.
651 self.burst_remaining.store(
652 self.burst_length.saturating_sub(1) as u64,
653 Ordering::Relaxed,
654 );
655 self.in_burst.store(true, Ordering::Relaxed);
656 self.total_dropped.fetch_add(1, Ordering::Relaxed);
657 return true;
658 }
659
660 // Normal loss
661 if r < self.loss_rate {
662 self.total_dropped.fetch_add(1, Ordering::Relaxed);
663 return true;
664 }
665
666 false
667 }
668
669 /// Get current effective loss rate
670 pub fn effective_loss_rate(&self) -> f32 {
671 let total = self.total_packets.load(Ordering::Relaxed);
672 let dropped = self.total_dropped.load(Ordering::Relaxed);
673 if total == 0 {
674 return 0.0;
675 }
676 dropped as f32 / total as f32
677 }
678
679 /// Reset statistics
680 pub fn reset(&self) {
681 self.total_packets.store(0, Ordering::Relaxed);
682 self.total_dropped.store(0, Ordering::Relaxed);
683 self.burst_remaining.store(0, Ordering::Relaxed);
684 self.in_burst.store(false, Ordering::Relaxed);
685 }
686
687 /// Get statistics
688 pub fn stats(&self) -> (u64, u64) {
689 (
690 self.total_packets.load(Ordering::Relaxed),
691 self.total_dropped.load(Ordering::Relaxed),
692 )
693 }
694
695 // Simple LCG random number generator (0.0 - 1.0).
696 // Uses CAS loop so concurrent threads don't get identical random values.
697 // fetch_update returns Ok(previous_value); derive the output from the
698 // new state (prev * M + 1) which the closure already stored atomically.
699 #[expect(
700 clippy::unwrap_used,
701 reason = "closure always returns Some, so fetch_update never returns Err"
702 )]
703 fn next_random(&self) -> f32 {
704 let prev = self
705 .rng_state
706 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |s| {
707 Some(s.wrapping_mul(6364136223846793005).wrapping_add(1))
708 })
709 .unwrap();
710 let new_state = prev.wrapping_mul(6364136223846793005).wrapping_add(1);
711 (new_state >> 33) as f32 / (1u64 << 31) as f32
712 }
713}
714
715/// Circuit breaker state
716#[derive(Debug, Clone, Copy, PartialEq, Eq)]
717pub enum CircuitState {
718 /// Circuit is closed (normal operation)
719 Closed,
720 /// Circuit is open (blocking requests)
721 Open,
722 /// Circuit is half-open (testing recovery)
723 HalfOpen,
724}
725
726/// Circuit breaker for preventing cascading failures.
727pub struct CircuitBreaker {
728 /// Current state
729 state: RwLock<CircuitState>,
730 /// Failure count in current window
731 failure_count: AtomicU64,
732 /// Success count in current window
733 success_count: AtomicU64,
734 /// Failure threshold to trip
735 failure_threshold: u64,
736 /// Success threshold to close
737 success_threshold: u64,
738 /// Time to wait before half-open
739 reset_timeout: Duration,
740 /// Last state change time
741 last_state_change: Mutex<Instant>,
742 /// Total trips
743 total_trips: AtomicU64,
744}
745
746impl CircuitBreaker {
747 /// Create a new circuit breaker
748 pub fn new(failure_threshold: u64, success_threshold: u64, reset_timeout: Duration) -> Self {
749 Self {
750 state: RwLock::new(CircuitState::Closed),
751 failure_count: AtomicU64::new(0),
752 success_count: AtomicU64::new(0),
753 failure_threshold,
754 success_threshold,
755 reset_timeout,
756 last_state_change: Mutex::new(Instant::now()),
757 total_trips: AtomicU64::new(0),
758 }
759 }
760
761 /// Check if request should be allowed
762 pub fn allow(&self) -> bool {
763 // Fast path: read lock for the common Closed/HalfOpen case so
764 // typical allow() calls don't contend on the writer lock.
765 {
766 let state = *self.state.read();
767 match state {
768 CircuitState::Closed | CircuitState::HalfOpen => return true,
769 CircuitState::Open => {} // fall through to slow path
770 }
771 }
772 // Slow path: when the fast path observed Open, hold the write
773 // lock across the entire read-decide-transition. Dropping it
774 // between the read and the transition (the previous
775 // implementation) lets a concurrent reset() — which transitions
776 // Open → Closed — be silently undone by this method's
777 // transition Open → HalfOpen layered on top of the Closed
778 // state. record_success/record_failure deliberately hold the
779 // write lock throughout for the same reason; allow() was the
780 // outlier.
781 let mut state = self.state.write();
782 match *state {
783 CircuitState::Closed | CircuitState::HalfOpen => true,
784 CircuitState::Open => {
785 let elapsed = self.last_state_change.lock().elapsed();
786 if elapsed >= self.reset_timeout {
787 Self::transition_locked(
788 &mut state,
789 CircuitState::HalfOpen,
790 &self.failure_count,
791 &self.success_count,
792 &self.last_state_change,
793 &self.total_trips,
794 );
795 true
796 } else {
797 false
798 }
799 }
800 }
801 }
802
803 /// Record a success
804 pub fn record_success(&self) {
805 // Hold write lock through the entire read-decide-transition path
806 // to prevent TOCTOU races where concurrent threads undo each other's
807 // state transitions.
808 let mut state = self.state.write();
809 match *state {
810 CircuitState::Closed => {
811 // Reset failure count on success
812 self.failure_count.store(0, Ordering::Relaxed);
813 }
814 CircuitState::HalfOpen => {
815 let count = self.success_count.fetch_add(1, Ordering::Relaxed) + 1;
816 if count >= self.success_threshold {
817 Self::transition_locked(
818 &mut state,
819 CircuitState::Closed,
820 &self.failure_count,
821 &self.success_count,
822 &self.last_state_change,
823 &self.total_trips,
824 );
825 }
826 }
827 CircuitState::Open => {}
828 }
829 }
830
831 /// Record a failure
832 pub fn record_failure(&self) {
833 // Hold write lock through the entire read-decide-transition path
834 // to prevent TOCTOU races where concurrent threads undo each other's
835 // state transitions.
836 let mut state = self.state.write();
837 match *state {
838 CircuitState::Closed => {
839 let count = self.failure_count.fetch_add(1, Ordering::Relaxed) + 1;
840 if count >= self.failure_threshold {
841 Self::transition_locked(
842 &mut state,
843 CircuitState::Open,
844 &self.failure_count,
845 &self.success_count,
846 &self.last_state_change,
847 &self.total_trips,
848 );
849 }
850 }
851 CircuitState::HalfOpen => {
852 // Single failure in half-open trips back to open
853 Self::transition_locked(
854 &mut state,
855 CircuitState::Open,
856 &self.failure_count,
857 &self.success_count,
858 &self.last_state_change,
859 &self.total_trips,
860 );
861 }
862 CircuitState::Open => {}
863 }
864 }
865
866 /// Get current state
867 pub fn state(&self) -> CircuitState {
868 *self.state.read()
869 }
870
871 /// Get total trip count
872 pub fn total_trips(&self) -> u64 {
873 self.total_trips.load(Ordering::Relaxed)
874 }
875
876 /// Reset the circuit breaker
877 pub fn reset(&self) {
878 self.transition_to(CircuitState::Closed);
879 self.failure_count.store(0, Ordering::Relaxed);
880 self.success_count.store(0, Ordering::Relaxed);
881 }
882
883 fn transition_to(&self, new_state: CircuitState) {
884 let mut state = self.state.write();
885 Self::transition_locked(
886 &mut state,
887 new_state,
888 &self.failure_count,
889 &self.success_count,
890 &self.last_state_change,
891 &self.total_trips,
892 );
893 }
894
895 /// Transition while already holding the write lock (avoids deadlock
896 /// when called from record_success/record_failure which hold the lock).
897 fn transition_locked(
898 state: &mut CircuitState,
899 new_state: CircuitState,
900 failure_count: &AtomicU64,
901 success_count: &AtomicU64,
902 last_state_change: &Mutex<Instant>,
903 total_trips: &AtomicU64,
904 ) {
905 let old_state = *state;
906 if old_state != new_state {
907 *state = new_state;
908 *last_state_change.lock() = Instant::now();
909
910 // Reset counters on transition
911 failure_count.store(0, Ordering::Relaxed);
912 success_count.store(0, Ordering::Relaxed);
913
914 // Track trips
915 if new_state == CircuitState::Open {
916 total_trips.fetch_add(1, Ordering::Relaxed);
917 }
918 }
919 }
920}
921
922/// Recovery action for a failed node
923#[derive(Debug, Clone)]
924pub enum RecoveryAction {
925 /// Reroute traffic through alternate path
926 Reroute {
927 /// Node IDs forming the alternate path
928 via: Vec<u64>,
929 },
930 /// Retry with backoff
931 Retry {
932 /// Delay before retry in milliseconds
933 delay_ms: u64,
934 },
935 /// Drop and notify
936 Drop {
937 /// Reason for dropping the message
938 reason: String,
939 },
940 /// Queue for later delivery
941 Queue,
942}
943
944/// Recovery statistics
945#[derive(Debug, Clone, Default)]
946pub struct RecoveryStats {
947 /// Reroutes performed
948 pub reroutes: u64,
949 /// Retries performed
950 pub retries: u64,
951 /// Packets dropped
952 pub dropped: u64,
953 /// Packets queued
954 pub queued: u64,
955 /// Average recovery time (ms)
956 pub avg_recovery_ms: u64,
957}
958
959/// Recovery manager for handling node failures.
960pub struct RecoveryManager {
961 /// Failed nodes and their recovery state
962 failed_nodes: DashMap<u64, FailedNodeState>,
963 /// Pending recovery queue
964 recovery_queue: Mutex<VecDeque<(u64, Instant)>>,
965 /// Stats
966 reroutes: AtomicU64,
967 retries: AtomicU64,
968 dropped: AtomicU64,
969 queued: AtomicU64,
970 total_recovery_time_ms: AtomicU64,
971 recovery_count: AtomicU64,
972}
973
974#[derive(Debug)]
975struct FailedNodeState {
976 /// When failure was detected
977 failed_at: Instant,
978 /// Retry count
979 retry_count: u32,
980 /// Alternate routes
981 alternates: Vec<u64>,
982}
983
984impl RecoveryManager {
985 /// Create a new recovery manager
986 pub fn new() -> Self {
987 Self {
988 failed_nodes: DashMap::new(),
989 recovery_queue: Mutex::new(VecDeque::new()),
990 reroutes: AtomicU64::new(0),
991 retries: AtomicU64::new(0),
992 dropped: AtomicU64::new(0),
993 queued: AtomicU64::new(0),
994 total_recovery_time_ms: AtomicU64::new(0),
995 recovery_count: AtomicU64::new(0),
996 }
997 }
998
999 /// Handle a node failure
1000 pub fn on_failure(&self, node_id: u64, alternates: Vec<u64>) -> RecoveryAction {
1001 // Repeat failures must NOT reset `failed_at` or
1002 // `retry_count`. A flapping peer that fails, gets one or
1003 // more retries, then fails again would otherwise have its
1004 // retry budget restored from zero each time and never
1005 // reach `max_retries` in `get_action`. Preserve the
1006 // existing state on a repeat; refresh `alternates` so a
1007 // newly-discovered reroute path takes effect.
1008 self.failed_nodes
1009 .entry(node_id)
1010 .and_modify(|s| {
1011 if !alternates.is_empty() {
1012 s.alternates = alternates.clone();
1013 }
1014 })
1015 .or_insert_with(|| FailedNodeState {
1016 failed_at: Instant::now(),
1017 retry_count: 0,
1018 alternates: alternates.clone(),
1019 });
1020
1021 if !alternates.is_empty() {
1022 self.reroutes.fetch_add(1, Ordering::Relaxed);
1023 RecoveryAction::Reroute { via: alternates }
1024 } else {
1025 self.queued.fetch_add(1, Ordering::Relaxed);
1026 self.recovery_queue
1027 .lock()
1028 .push_back((node_id, Instant::now()));
1029 RecoveryAction::Queue
1030 }
1031 }
1032
1033 /// Handle node recovery
1034 pub fn on_recovery(&self, node_id: u64) {
1035 if let Some((_, state)) = self.failed_nodes.remove(&node_id) {
1036 let recovery_time = state.failed_at.elapsed().as_millis() as u64;
1037 self.total_recovery_time_ms
1038 .fetch_add(recovery_time, Ordering::Relaxed);
1039 self.recovery_count.fetch_add(1, Ordering::Relaxed);
1040 }
1041 }
1042
1043 /// Get recovery action for a node
1044 pub fn get_action(&self, node_id: u64, max_retries: u32) -> RecoveryAction {
1045 if let Some(mut state) = self.failed_nodes.get_mut(&node_id) {
1046 if !state.alternates.is_empty() {
1047 return RecoveryAction::Reroute {
1048 via: state.alternates.clone(),
1049 };
1050 }
1051
1052 if state.retry_count < max_retries {
1053 state.retry_count += 1;
1054 self.retries.fetch_add(1, Ordering::Relaxed);
1055 let delay = 100 * (1 << state.retry_count.min(6)); // Exponential backoff
1056 return RecoveryAction::Retry { delay_ms: delay };
1057 }
1058
1059 self.dropped.fetch_add(1, Ordering::Relaxed);
1060 RecoveryAction::Drop {
1061 reason: "max retries exceeded".into(),
1062 }
1063 } else {
1064 // Node not in failed list — caller asked for an action
1065 // on a node we don't track as failed. Pre-fix this
1066 // returned `Retry { delay_ms: 0 }`, which a caller
1067 // dutifully respecting the delay would busy-loop on.
1068 // The semantically-cleanest answer is "no action
1069 // needed, treat as healthy," but the variant doesn't
1070 // exist. Return the same 100ms first-backoff step the
1071 // failed-node path uses on its first retry, so the
1072 // caller paces itself even when get_action was called
1073 // by mistake on a healthy node.
1074 RecoveryAction::Retry { delay_ms: 100 }
1075 }
1076 }
1077
1078 /// Check if a node is failed
1079 pub fn is_failed(&self, node_id: u64) -> bool {
1080 self.failed_nodes.contains_key(&node_id)
1081 }
1082
1083 /// Get statistics
1084 pub fn stats(&self) -> RecoveryStats {
1085 let count = self.recovery_count.load(Ordering::Relaxed);
1086 let total_time = self.total_recovery_time_ms.load(Ordering::Relaxed);
1087 let avg = total_time.checked_div(count).unwrap_or(0);
1088
1089 RecoveryStats {
1090 reroutes: self.reroutes.load(Ordering::Relaxed),
1091 retries: self.retries.load(Ordering::Relaxed),
1092 dropped: self.dropped.load(Ordering::Relaxed),
1093 queued: self.queued.load(Ordering::Relaxed),
1094 avg_recovery_ms: avg,
1095 }
1096 }
1097
1098 /// Get failed node count
1099 pub fn failed_count(&self) -> usize {
1100 self.failed_nodes.len()
1101 }
1102}
1103
1104impl Default for RecoveryManager {
1105 fn default() -> Self {
1106 Self::new()
1107 }
1108}
1109
1110#[cfg(test)]
1111mod tests {
1112 use super::*;
1113
1114 #[test]
1115 fn test_failure_detector_basic() {
1116 let detector = FailureDetector::with_config(FailureDetectorConfig {
1117 timeout: Duration::from_millis(100),
1118 miss_threshold: 2,
1119 suspicion_threshold: 1,
1120 cleanup_interval: Duration::from_secs(60),
1121 });
1122
1123 let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
1124 detector.heartbeat(0x1234, addr);
1125
1126 assert_eq!(detector.status(0x1234), NodeStatus::Healthy);
1127 assert_eq!(detector.node_count(), 1);
1128 }
1129
1130 /// node_count() / stats().nodes_tracked read an O(1) counter that must
1131 /// track the map across new heartbeats, duplicate heartbeats (no growth),
1132 /// and removal.
1133 #[test]
1134 fn node_count_tracks_heartbeats_and_removal() {
1135 let detector = FailureDetector::new();
1136 let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
1137
1138 detector.heartbeat(1, addr);
1139 detector.heartbeat(2, addr);
1140 detector.heartbeat(3, addr);
1141 assert_eq!(detector.node_count(), 3);
1142 assert_eq!(detector.stats().nodes_tracked, 3);
1143
1144 // Duplicate heartbeat for an existing node must not grow the count.
1145 detector.heartbeat(1, addr);
1146 assert_eq!(detector.node_count(), 3, "re-heartbeat must not grow count");
1147
1148 detector.remove(2);
1149 assert_eq!(detector.node_count(), 2);
1150 assert_eq!(detector.stats().nodes_tracked, 2);
1151
1152 // Removing an absent node is a no-op for the counter.
1153 detector.remove(999);
1154 assert_eq!(detector.node_count(), 2);
1155 }
1156
1157 #[test]
1158 fn test_failure_detector_failure() {
1159 // Timings: timeout=100ms, sleeps=150ms. `missed_count`
1160 // computes `elapsed / timeout`, so after 150ms we
1161 // expect 1 miss → Suspected. After 300ms we expect 3
1162 // misses → Failed. Wider ratio than the original
1163 // (10ms / 15ms) because OS scheduler slippage + deps
1164 // that pull in larger runtimes (hyper / igd-next for
1165 // the `port-mapping` feature) can add several-ms jitter
1166 // on top of a 15ms sleep, which was enough to push
1167 // `missed_count` from 1 into 2 (i.e. Failed) after the
1168 // first sleep — false positive on the Suspected assert.
1169 let detector = FailureDetector::with_config(FailureDetectorConfig {
1170 timeout: Duration::from_millis(100),
1171 miss_threshold: 2,
1172 suspicion_threshold: 1,
1173 cleanup_interval: Duration::from_secs(60),
1174 });
1175
1176 let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
1177 detector.heartbeat(0x1234, addr);
1178
1179 // Wait for timeout (~1.5× the timeout → 1 miss).
1180 std::thread::sleep(Duration::from_millis(150));
1181
1182 // First check - should be suspected
1183 detector.check_all();
1184 assert_eq!(detector.status(0x1234), NodeStatus::Suspected);
1185
1186 // Wait more (total ~300ms → 3 misses → Failed).
1187 std::thread::sleep(Duration::from_millis(150));
1188
1189 // Second check - should be failed
1190 let failed = detector.check_all();
1191 assert_eq!(failed.len(), 1);
1192 assert_eq!(failed[0], 0x1234);
1193 assert_eq!(detector.status(0x1234), NodeStatus::Failed);
1194 }
1195
1196 #[test]
1197 fn test_failure_detector_recovery() {
1198 let detector = FailureDetector::with_config(FailureDetectorConfig {
1199 timeout: Duration::from_millis(10),
1200 miss_threshold: 1,
1201 suspicion_threshold: 1,
1202 cleanup_interval: Duration::from_secs(60),
1203 });
1204
1205 let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
1206 detector.heartbeat(0x1234, addr);
1207
1208 std::thread::sleep(Duration::from_millis(15));
1209 detector.check_all();
1210 assert_eq!(detector.status(0x1234), NodeStatus::Failed);
1211
1212 // Recovery
1213 detector.heartbeat(0x1234, addr);
1214 assert_eq!(detector.status(0x1234), NodeStatus::Healthy);
1215
1216 let stats = detector.stats();
1217 assert_eq!(stats.total_failures, 1);
1218 assert_eq!(stats.total_recoveries, 1);
1219 }
1220
1221 #[test]
1222 fn test_failure_detector_elapsed_based_missed_count() {
1223 // Regression: check() incremented missed_count by 1 per call regardless
1224 // of elapsed time. If check_all() ran infrequently, a node could stay
1225 // healthy much longer than the configured timeout. Now missed_count is
1226 // computed from elapsed / timeout.
1227 let detector = FailureDetector::with_config(FailureDetectorConfig {
1228 timeout: Duration::from_millis(10),
1229 miss_threshold: 3,
1230 suspicion_threshold: 2,
1231 cleanup_interval: Duration::from_secs(60),
1232 });
1233
1234 let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
1235 detector.heartbeat(0x1234, addr);
1236
1237 // Wait long enough that multiple timeouts have elapsed
1238 std::thread::sleep(Duration::from_millis(35));
1239
1240 // A single check_all() call should detect the node as failed
1241 // because ~35ms / 10ms = 3 missed heartbeats >= miss_threshold(3).
1242 // With the old code (increment by 1), this would only be missed_count=1.
1243 let failed = detector.check_all();
1244 assert_eq!(
1245 detector.status(0x1234),
1246 NodeStatus::Failed,
1247 "node should be Failed after 3+ timeout intervals, even with one check call"
1248 );
1249 assert_eq!(failed.len(), 1);
1250 }
1251
1252 #[test]
1253 fn test_loss_simulator() {
1254 let sim = LossSimulator::new(0.5);
1255
1256 let mut dropped = 0;
1257 for _ in 0..1000 {
1258 if sim.should_drop() {
1259 dropped += 1;
1260 }
1261 }
1262
1263 // Should be roughly 50% (allow wide margin for randomness)
1264 assert!(dropped > 300 && dropped < 700);
1265 }
1266
1267 #[test]
1268 fn test_loss_simulator_burst() {
1269 let sim = LossSimulator::new(0.0).with_bursts(0.1, 5);
1270
1271 let mut total_bursts = 0;
1272 let mut in_burst = false;
1273 for _ in 0..1000 {
1274 if sim.should_drop() {
1275 if !in_burst {
1276 in_burst = true;
1277 total_bursts += 1;
1278 }
1279 } else {
1280 in_burst = false;
1281 }
1282 }
1283
1284 // Should have had some bursts
1285 assert!(total_bursts > 0);
1286 }
1287
1288 #[test]
1289 fn test_burst_drops_exactly_burst_length_packets() {
1290 // Regression: a burst starting dropped the triggering packet AND then
1291 // burst_length more, for burst_length + 1 total drops per burst.
1292 //
1293 // We verify by directly inspecting burst_remaining after triggering.
1294 // With burst_prob = 1.0, the first call always starts a burst.
1295 let burst_len = 5u32;
1296 let sim = LossSimulator::new(0.0).with_bursts(1.0, burst_len);
1297
1298 // First call: triggers burst, drops the triggering packet.
1299 assert!(sim.should_drop());
1300 // burst_remaining should be burst_length - 1 (since the trigger was the 1st drop)
1301 let remaining = sim.burst_remaining.load(Ordering::Relaxed);
1302 assert_eq!(
1303 remaining,
1304 (burst_len - 1) as u64,
1305 "after trigger, burst_remaining should be burst_length - 1, \
1306 not burst_length (which would cause burst_length + 1 total drops)"
1307 );
1308
1309 // Drain the remaining burst
1310 for _ in 0..remaining {
1311 assert!(sim.should_drop());
1312 }
1313
1314 // After exactly burst_length total drops, burst_remaining should be 0
1315 assert_eq!(sim.burst_remaining.load(Ordering::Relaxed), 0);
1316 assert_eq!(sim.total_dropped.load(Ordering::Relaxed), burst_len as u64);
1317 }
1318
1319 #[test]
1320 fn test_circuit_breaker() {
1321 let cb = CircuitBreaker::new(3, 2, Duration::from_millis(50));
1322
1323 assert_eq!(cb.state(), CircuitState::Closed);
1324 assert!(cb.allow());
1325
1326 // Trip the breaker
1327 cb.record_failure();
1328 cb.record_failure();
1329 cb.record_failure();
1330
1331 assert_eq!(cb.state(), CircuitState::Open);
1332 assert!(!cb.allow());
1333
1334 // Wait for reset timeout
1335 std::thread::sleep(Duration::from_millis(60));
1336
1337 // Should transition to half-open
1338 assert!(cb.allow());
1339 assert_eq!(cb.state(), CircuitState::HalfOpen);
1340
1341 // Successes should close it
1342 cb.record_success();
1343 cb.record_success();
1344 assert_eq!(cb.state(), CircuitState::Closed);
1345 }
1346
1347 #[test]
1348 fn test_regression_loss_simulator_burst_no_underflow() {
1349 // Regression: concurrent should_drop() calls could race on
1350 // burst_remaining decrement, wrapping u64 to MAX. The fix uses
1351 // compare_exchange_weak (CAS loop) instead of fetch_sub.
1352 use std::sync::Arc;
1353
1354 let sim = Arc::new(LossSimulator::new(0.0).with_bursts(0.3, 10));
1355 let threads: Vec<_> = (0..8)
1356 .map(|_| {
1357 let sim = Arc::clone(&sim);
1358 std::thread::spawn(move || {
1359 for _ in 0..5_000 {
1360 sim.should_drop();
1361 }
1362 })
1363 })
1364 .collect();
1365
1366 for t in threads {
1367 t.join().unwrap();
1368 }
1369
1370 let (total, dropped) = sim.stats();
1371 // burst_remaining should never have wrapped to u64::MAX, so
1372 // dropped can never exceed total.
1373 assert!(
1374 dropped <= total,
1375 "dropped ({dropped}) must not exceed total ({total}) — \
1376 would indicate burst_remaining underflow"
1377 );
1378 // Sanity: we actually ran packets
1379 assert_eq!(total, 8 * 5_000);
1380 }
1381
1382 #[test]
1383 fn test_regression_circuit_breaker_concurrent_transitions() {
1384 // Regression: record_failure/record_success read state then
1385 // transitioned without holding the lock, allowing TOCTOU races
1386 // that could corrupt state. The fix holds the write lock across
1387 // the entire read-decide-transition path.
1388 use std::sync::Arc;
1389
1390 let cb = Arc::new(CircuitBreaker::new(3, 2, Duration::from_millis(10)));
1391
1392 let threads: Vec<_> = (0..8)
1393 .map(|i| {
1394 let cb = Arc::clone(&cb);
1395 std::thread::spawn(move || {
1396 for _ in 0..2_000 {
1397 if i % 2 == 0 {
1398 cb.record_failure();
1399 } else {
1400 cb.record_success();
1401 }
1402 }
1403 })
1404 })
1405 .collect();
1406
1407 for t in threads {
1408 t.join().unwrap();
1409 }
1410
1411 // State must be one of the valid variants (not corrupted)
1412 let state = cb.state();
1413 assert!(
1414 state == CircuitState::Closed
1415 || state == CircuitState::Open
1416 || state == CircuitState::HalfOpen,
1417 "circuit breaker state is invalid after concurrent access"
1418 );
1419 // total_trips should be reasonable (not wildly inflated)
1420 let trips = cb.total_trips();
1421 // With 4 failure threads * 2000 calls, at most 8000 trips possible
1422 assert!(
1423 trips <= 8_000,
1424 "total_trips ({trips}) is unreasonably high, suggests corruption"
1425 );
1426 }
1427
1428 #[test]
1429 fn test_regression_allow_does_not_undo_reset() {
1430 // Regression: allow() previously read state under the read lock,
1431 // dropped it, then called transition_to(HalfOpen) without
1432 // re-checking. A reset() that ran in that gap (transition_to
1433 // Closed) was silently overwritten when allow()'s transition_to
1434 // re-acquired the write lock and stamped HalfOpen on top.
1435 //
1436 // Fix: allow() holds the write lock across the read-decide-
1437 // transition path, so a state change between the fast-path read
1438 // and the slow-path write lock is observed before any
1439 // transition runs.
1440 //
1441 // The test repeatedly trips the breaker to Open, then races
1442 // allow() (in an observer thread) against reset() (on the main
1443 // thread). The reset_timeout is 1ns so allow() always sees the
1444 // timeout as elapsed and would transition to HalfOpen if it
1445 // could. Final state should always be Closed: either reset()
1446 // ran "after" allow()'s transition (write-lock serialization
1447 // guarantees Closed wins), or it ran "before" and allow()
1448 // observed Closed under the write lock and skipped the
1449 // transition. With the bug, some trials end in HalfOpen.
1450 use std::sync::atomic::{AtomicU8, Ordering};
1451 use std::sync::Arc;
1452 use std::thread;
1453
1454 const TRIALS: u32 = 5_000;
1455
1456 let cb = Arc::new(CircuitBreaker::new(1, 1, Duration::from_nanos(1)));
1457 let signal = Arc::new(AtomicU8::new(0)); // 0=idle, 1=run, 2=stop
1458
1459 let cb_observer = cb.clone();
1460 let signal_observer = signal.clone();
1461 let observer = thread::spawn(move || loop {
1462 match signal_observer.load(Ordering::Acquire) {
1463 0 => std::hint::spin_loop(),
1464 1 => {
1465 cb_observer.allow();
1466 signal_observer.store(0, Ordering::Release);
1467 }
1468 _ => return,
1469 }
1470 });
1471
1472 let mut bug_count = 0u32;
1473 for _ in 0..TRIALS {
1474 // Trip Closed → Open (failure_threshold = 1).
1475 cb.record_failure();
1476 assert_eq!(cb.state(), CircuitState::Open);
1477
1478 // Hand off to observer; race reset() against its allow().
1479 signal.store(1, Ordering::Release);
1480 cb.reset();
1481 while signal.load(Ordering::Acquire) != 0 {
1482 std::hint::spin_loop();
1483 }
1484
1485 if cb.state() != CircuitState::Closed {
1486 bug_count += 1;
1487 // Recover for the next trial so the assertion below
1488 // surfaces the race count, not a stuck state.
1489 cb.reset();
1490 }
1491 }
1492
1493 signal.store(2, Ordering::Release);
1494 observer.join().unwrap();
1495
1496 assert_eq!(
1497 bug_count, 0,
1498 "{bug_count} of {TRIALS} trials ended in non-Closed state — \
1499 allow() transitioned to HalfOpen on top of a fresh reset()"
1500 );
1501 }
1502
1503 #[test]
1504 fn test_recovery_manager() {
1505 let mgr = RecoveryManager::new();
1506
1507 // Failure with alternates
1508 let action = mgr.on_failure(0x1234, vec![0x5678, 0x9ABC]);
1509 match action {
1510 RecoveryAction::Reroute { via } => {
1511 assert_eq!(via, vec![0x5678, 0x9ABC]);
1512 }
1513 _ => panic!("expected reroute"),
1514 }
1515
1516 // Failure without alternates
1517 let action = mgr.on_failure(0x2222, vec![]);
1518 match action {
1519 RecoveryAction::Queue => {}
1520 _ => panic!("expected queue"),
1521 }
1522
1523 assert!(mgr.is_failed(0x1234));
1524 assert!(mgr.is_failed(0x2222));
1525
1526 // Recovery
1527 mgr.on_recovery(0x1234);
1528 assert!(!mgr.is_failed(0x1234));
1529
1530 let stats = mgr.stats();
1531 assert_eq!(stats.reroutes, 1);
1532 assert_eq!(stats.queued, 1);
1533 }
1534
1535 /// Pin: a flapping peer (fail, retry, fail, retry, ...) must
1536 /// reach `max_retries` and be dropped. Pre-fix `on_failure`
1537 /// unconditionally re-`insert`-ed the node, resetting
1538 /// `retry_count` to 0 every time, so `get_action` never saw
1539 /// the count climb past 1 and the node was retried forever.
1540 #[test]
1541 fn on_failure_preserves_retry_count_on_repeat() {
1542 let mgr = RecoveryManager::new();
1543 let node = 0x42u64;
1544 let max_retries = 3u32;
1545
1546 // Failure 1 → enters the failed list with retry_count=0,
1547 // no alternates so action is Queue.
1548 let action = mgr.on_failure(node, vec![]);
1549 assert!(matches!(action, RecoveryAction::Queue));
1550
1551 // Drive `get_action` to bump retry_count up to the cap.
1552 for expected_count in 1..=max_retries {
1553 match mgr.get_action(node, max_retries) {
1554 RecoveryAction::Retry { .. } => {}
1555 other => panic!(
1556 "expected Retry on attempt {} (count would become {}), got {:?}",
1557 expected_count, expected_count, other
1558 ),
1559 }
1560 }
1561
1562 // Now simulate a re-failure WITHOUT recovery in between
1563 // (the flapping case). Pre-fix this re-`insert`-ed and
1564 // wiped `retry_count` back to 0, restoring an unbounded
1565 // retry budget.
1566 let _ = mgr.on_failure(node, vec![]);
1567
1568 // The very next `get_action` must return Drop — the
1569 // budget set by the prior Retries should still apply.
1570 match mgr.get_action(node, max_retries) {
1571 RecoveryAction::Drop { .. } => {}
1572 other => panic!(
1573 "expected Drop after exhausting retries across a flap; got {:?} \
1574 (pre-fix on_failure reset retry_count to 0 on repeat)",
1575 other
1576 ),
1577 }
1578 }
1579
1580 /// Pin: a repeat `on_failure` carrying newly-discovered
1581 /// alternates must update the alternates list (so a node
1582 /// that was unreachable can become reroutable when topology
1583 /// changes), but must NOT reset `retry_count`.
1584 #[test]
1585 fn on_failure_repeat_updates_alternates_without_resetting_count() {
1586 let mgr = RecoveryManager::new();
1587 let node = 0x99u64;
1588 let max_retries = 2u32;
1589
1590 // First failure with no alternates → Queue.
1591 let _ = mgr.on_failure(node, vec![]);
1592 // Bump the retry count once via get_action.
1593 let _ = mgr.get_action(node, max_retries);
1594
1595 // Second failure now learns of an alternate — semantics
1596 // should switch to Reroute, but the prior retry_count
1597 // must be preserved.
1598 let action = mgr.on_failure(node, vec![0xDEAD]);
1599 match action {
1600 RecoveryAction::Reroute { via } => assert_eq!(via, vec![0xDEAD]),
1601 other => panic!("expected Reroute, got {:?}", other),
1602 }
1603
1604 // One more get_action without alternates path: clear
1605 // alternates and confirm retry budget is exhausted at
1606 // max_retries (count was 1 after first get_action; one
1607 // more retry brings it to 2; the next call must Drop).
1608 if let Some(mut s) = mgr.failed_nodes.get_mut(&node) {
1609 s.alternates.clear();
1610 }
1611 let _ = mgr.get_action(node, max_retries); // count → 2 (== max)
1612 match mgr.get_action(node, max_retries) {
1613 RecoveryAction::Drop { .. } => {}
1614 other => panic!("expected Drop after exhausting retries; got {:?}", other),
1615 }
1616 }
1617
1618 /// Regression: BUG_REPORT.md #14 — `heartbeat` and `check_all`
1619 /// previously invoked the user-supplied recovery / failure
1620 /// callbacks while still holding the DashMap shard's write
1621 /// lock (`and_modify` / `iter_mut` respectively). A callback
1622 /// that re-entered the failure detector — calling
1623 /// `heartbeat` / `status` / `is_failed` for *any* node — could
1624 /// deadlock if it hashed to the same shard, and at minimum
1625 /// serialized concurrent heartbeats hashing to that shard
1626 /// behind the user code.
1627 ///
1628 /// The fix: collect the "should I notify?" signal inside the
1629 /// closure / loop, drop the shard locks, then fire the
1630 /// callbacks. We pin this by setting a callback that calls
1631 /// back into the detector's `status()` (which acquires a
1632 /// read lock on the same shard). With the bug present, this
1633 /// deadlocks; with the fix, it returns successfully.
1634 #[test]
1635 fn callbacks_run_after_shard_lock_release() {
1636 use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
1637 use std::sync::Arc;
1638
1639 let detector = Arc::new(FailureDetector::with_config(FailureDetectorConfig {
1640 timeout: Duration::from_millis(10),
1641 miss_threshold: 1,
1642 suspicion_threshold: 1,
1643 cleanup_interval: Duration::from_secs(60),
1644 }));
1645
1646 let detector_for_cb = Arc::clone(&detector);
1647 let observed = Arc::new(AtomicBool::new(false));
1648 let observed_clone = Arc::clone(&observed);
1649
1650 // The recovery callback re-enters `status()`, which must
1651 // be able to acquire a read lock on the same DashMap
1652 // shard the recovery path is mutating. With the pre-fix
1653 // code (callback under `and_modify`'s write lock), this
1654 // would deadlock on a single-shard DashMap.
1655 let detector_arc = Arc::new(
1656 // Re-create using the constructor that accepts a
1657 // callback. We'll thread it via the public setter.
1658 FailureDetector::with_config(FailureDetectorConfig {
1659 timeout: Duration::from_millis(10),
1660 miss_threshold: 1,
1661 suspicion_threshold: 1,
1662 cleanup_interval: Duration::from_secs(60),
1663 })
1664 .on_recovery(move |event| {
1665 // Re-enter the same detector; observable proof
1666 // we got here without a deadlock.
1667 let _ = detector_for_cb.status(event.node_id);
1668 observed_clone.store(true, AtomicOrdering::SeqCst);
1669 }),
1670 );
1671
1672 let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
1673 // Drive node into Failed state, then heartbeat to recover.
1674 detector_arc.heartbeat(0x4242, addr);
1675 std::thread::sleep(Duration::from_millis(25));
1676 let _ = detector_arc.check_all();
1677 assert_eq!(detector_arc.status(0x4242), NodeStatus::Failed);
1678
1679 // This call would deadlock under the pre-fix code.
1680 detector_arc.heartbeat(0x4242, addr);
1681
1682 assert!(
1683 observed.load(AtomicOrdering::SeqCst),
1684 "recovery callback must have run (and re-entered status()) — \
1685 a deadlock here would manifest as the test hanging (#14)"
1686 );
1687 let _ = detector;
1688 }
1689
1690 /// Pin: `get_action` on a node not in the failed list must
1691 /// return a non-zero retry delay. Pre-fix the unfailed-node
1692 /// branch returned `Retry { delay_ms: 0 }`, which a caller
1693 /// dutifully respecting the delay would busy-loop on,
1694 /// pegging a CPU. The fix returns the same first-step
1695 /// backoff (100ms) the failed-node path uses on retry 1, so
1696 /// the caller paces itself even when `get_action` was
1697 /// called by mistake on a healthy node.
1698 #[test]
1699 fn get_action_on_unfailed_node_does_not_busy_loop() {
1700 let mgr = RecoveryManager::new();
1701 let untracked = 0xDEAD_BEEFu64;
1702
1703 // Sanity: node is not in the failed list.
1704 assert!(
1705 !mgr.is_failed(untracked),
1706 "precondition: node must not be tracked as failed"
1707 );
1708
1709 let action = mgr.get_action(untracked, 3);
1710 match action {
1711 RecoveryAction::Retry { delay_ms } => {
1712 assert!(
1713 delay_ms > 0,
1714 "regression: get_action on an unfailed node returned \
1715 Retry {{ delay_ms: 0 }} — a delay-respecting caller \
1716 would busy-loop on this and saturate a CPU"
1717 );
1718 assert_eq!(
1719 delay_ms, 100,
1720 "first-step backoff should match the failed-node \
1721 path's retry-1 delay (100ms) so callers pace \
1722 consistently across both branches"
1723 );
1724 }
1725 other => panic!("unfailed-node branch must return Retry, got {:?}", other),
1726 }
1727 }
1728
1729 /// Circuit-breaker HalfOpen → Open on a single failure.
1730 ///
1731 /// The existing `test_circuit_breaker` covers Closed → Open
1732 /// → HalfOpen → Closed, but never the HalfOpen → Open arm
1733 /// at L646-L656. That arm is the "probe failed, snap back to
1734 /// open" path. A regression here means a half-open probe
1735 /// that fails would NOT trip back to open — a known-broken
1736 /// backend would continue receiving probe traffic indefinitely
1737 /// instead of waiting another reset_timeout cycle.
1738 #[test]
1739 fn circuit_breaker_half_open_failure_trips_back_to_open() {
1740 // `from_nanos(1)` lets `allow()` see the reset_timeout as
1741 // already elapsed without a real-time sleep — same trick
1742 // as `test_regression_allow_does_not_undo_reset`. The
1743 // trade-off is that after the HalfOpen → Open snap-back,
1744 // any further `allow()` call would also see the timeout
1745 // elapsed and immediately transition back to HalfOpen.
1746 // The decisive observable for this regression is the
1747 // state right after `record_failure()`, not what `allow()`
1748 // returns on the next call.
1749 let cb = CircuitBreaker::new(2, 2, Duration::from_nanos(1));
1750
1751 // Open the breaker.
1752 cb.record_failure();
1753 cb.record_failure();
1754 assert_eq!(cb.state(), CircuitState::Open);
1755
1756 // Probe — moves to HalfOpen.
1757 assert!(
1758 cb.allow(),
1759 "expected allow() to admit a probe after reset_timeout"
1760 );
1761 assert_eq!(cb.state(), CircuitState::HalfOpen);
1762
1763 // Single failure in HalfOpen must trip back to Open.
1764 cb.record_failure();
1765 assert_eq!(
1766 cb.state(),
1767 CircuitState::Open,
1768 "HalfOpen + failure must snap back to Open; \
1769 a regression here keeps probing a broken backend",
1770 );
1771 }
1772
1773 /// Pin: the `Default` for `FailureDetectorConfig` is what
1774 /// `FailureDetector::new()` installs, and downstream timing
1775 /// (heartbeat windows, suspicion → failure escalation, the
1776 /// 30s cleanup cadence that frees stale-node memory) depends
1777 /// on the specific values. A refactor that bumps `timeout`
1778 /// to 50s or drops `cleanup_interval` to 3s would silently
1779 /// change failure-detection latency in every default-config
1780 /// caller — pin the load-bearing values.
1781 #[test]
1782 fn failure_detector_config_default_values() {
1783 let cfg = FailureDetectorConfig::default();
1784 assert_eq!(cfg.timeout, Duration::from_secs(5));
1785 assert_eq!(cfg.miss_threshold, 3);
1786 assert_eq!(cfg.suspicion_threshold, 2);
1787 assert_eq!(cfg.cleanup_interval, Duration::from_secs(30));
1788 }
1789
1790 /// `suspected_nodes` / `healthy_nodes` filter the tracked
1791 /// nodes by `NodeStatus`. A regression that swaps the two
1792 /// (or aliases either to `failed_nodes`) would mis-route
1793 /// every operator query — dashboards would render Suspected
1794 /// nodes as Healthy or vice versa.
1795 #[test]
1796 fn suspected_and_healthy_nodes_filter_by_status() {
1797 let detector = FailureDetector::new();
1798 // Seed three nodes in distinct states by manipulating
1799 // the internal `nodes` map directly (the same trick
1800 // existing tests use to stage failure-detector state
1801 // without sleeping out real timeouts).
1802 let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
1803 detector.heartbeat(1, addr);
1804 detector.heartbeat(2, addr);
1805 detector.heartbeat(3, addr);
1806 // Drop their statuses into known classes.
1807 detector.nodes.get_mut(&2).unwrap().status = NodeStatus::Suspected;
1808 detector.nodes.get_mut(&3).unwrap().status = NodeStatus::Failed;
1809
1810 let mut healthy = detector.healthy_nodes();
1811 healthy.sort_unstable();
1812 assert_eq!(healthy, vec![1]);
1813
1814 let suspected = detector.suspected_nodes();
1815 assert_eq!(suspected, vec![2]);
1816 }
1817
1818 /// `cleanup` is rate-limited by `cleanup_interval`. Two
1819 /// calls within the interval must return early (no nodes
1820 /// scanned, no removals) so a hot loop can't pin the mutex
1821 /// or thrash DashMap iteration.
1822 #[test]
1823 fn cleanup_returns_zero_within_cleanup_interval() {
1824 let detector = FailureDetector::with_config(FailureDetectorConfig {
1825 cleanup_interval: Duration::from_secs(60),
1826 ..Default::default()
1827 });
1828 // Force `last_cleanup` to "just now" so the rate-limit
1829 // gate fires.
1830 *detector.last_cleanup.lock() = Instant::now();
1831 assert_eq!(
1832 detector.cleanup(),
1833 0,
1834 "cleanup called inside the rate-limit window must return 0 without scanning",
1835 );
1836 }
1837
1838 /// `LossSimulator::effective_loss_rate` returns `0.0` on the
1839 /// divide-by-zero guard (no packets observed) and a real
1840 /// ratio once packets have flowed. Operator dashboards
1841 /// reading this on a freshly-started simulator must see 0,
1842 /// not `NaN` from `0/0`.
1843 #[test]
1844 fn loss_simulator_effective_loss_rate_handles_div_by_zero_and_ratio() {
1845 let sim = LossSimulator::new(1.0); // drop everything
1846 assert_eq!(sim.effective_loss_rate(), 0.0, "no packets → 0, not NaN");
1847
1848 // Drive 4 should_drop() calls — with prob 1.0 every
1849 // packet is dropped, so the ratio reads 4/4 = 1.0.
1850 for _ in 0..4 {
1851 let _ = sim.should_drop();
1852 }
1853 let rate = sim.effective_loss_rate();
1854 assert!(
1855 (rate - 1.0).abs() < 1e-6,
1856 "expected loss_rate ≈ 1.0 after 4 drops; got {rate}",
1857 );
1858
1859 // reset() zeroes the counters AND the burst state. The
1860 // post-reset rate is 0/0 → the div-by-zero guard fires
1861 // again.
1862 sim.reset();
1863 assert_eq!(sim.total_packets.load(Ordering::Relaxed), 0);
1864 assert_eq!(sim.total_dropped.load(Ordering::Relaxed), 0);
1865 assert_eq!(sim.burst_remaining.load(Ordering::Relaxed), 0);
1866 assert!(!sim.in_burst.load(Ordering::Relaxed));
1867 assert_eq!(sim.effective_loss_rate(), 0.0);
1868 }
1869}