ipfrs_network/bandwidth_monitor.rs
1//! Per-peer and aggregate bandwidth usage tracking with sliding window rate calculation.
2//!
3//! This module provides fine-grained bandwidth monitoring for each connected peer,
4//! including:
5//! - Recording inbound and outbound bytes per peer with timestamps
6//! - Sliding window rate calculation (bytes/sec) over a configurable window
7//! - Peak rate detection based on inter-sample intervals
8//! - Aggregate statistics across all peers
9//! - Atomic global counters for lock-free stats snapshots
10//! - Idle peer eviction to bound memory usage
11//!
12//! # Example
13//!
14//! ```rust
15//! use ipfrs_network::bandwidth_monitor::{BandwidthMonitor, Direction};
16//! use std::time::Duration;
17//!
18//! let monitor = BandwidthMonitor::with_window(Duration::from_secs(10));
19//!
20//! monitor.record("peer-1", 1024, Direction::Inbound);
21//! monitor.record("peer-1", 512, Direction::Outbound);
22//!
23//! let inbound_rate = monitor.rate_for_peer("peer-1", Direction::Inbound);
24//! println!("peer-1 inbound rate: {:.1} B/s", inbound_rate);
25//!
26//! let top = monitor.top_receivers(5);
27//! println!("top receiver: {:?}", top.first());
28//! ```
29
30use parking_lot::RwLock;
31use serde::{Deserialize, Serialize};
32use std::collections::{HashMap, VecDeque};
33use std::sync::atomic::{AtomicU64, Ordering};
34use std::sync::Arc;
35use std::time::{Duration, Instant};
36
37// ---------------------------------------------------------------------------
38// Direction
39// ---------------------------------------------------------------------------
40
41/// Traffic direction for a bandwidth sample.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
43pub enum Direction {
44 /// Bytes received from a remote peer.
45 Inbound,
46 /// Bytes sent to a remote peer.
47 Outbound,
48}
49
50// ---------------------------------------------------------------------------
51// BandwidthSample
52// ---------------------------------------------------------------------------
53
54/// A single bandwidth observation recorded at a specific point in time.
55#[derive(Debug, Clone)]
56pub struct BandwidthSample {
57 /// Number of bytes transferred in this sample.
58 pub bytes: u64,
59 /// Wall-clock instant when the transfer was observed.
60 pub timestamp: Instant,
61 /// Whether the bytes were received or sent.
62 pub direction: Direction,
63}
64
65impl BandwidthSample {
66 /// Create a new sample stamped at `now`.
67 pub fn new(bytes: u64, direction: Direction, now: Instant) -> Self {
68 Self {
69 bytes,
70 timestamp: now,
71 direction,
72 }
73 }
74}
75
76// ---------------------------------------------------------------------------
77// PeerBandwidth
78// ---------------------------------------------------------------------------
79
80/// Per-peer bandwidth state keeping a sliding window of recent samples.
81#[derive(Debug)]
82pub struct PeerBandwidth {
83 /// Stable identifier for this peer (e.g. libp2p PeerId string).
84 pub peer_id: String,
85 /// Inbound samples within the current retention window.
86 pub inbound_samples: VecDeque<BandwidthSample>,
87 /// Outbound samples within the current retention window.
88 pub outbound_samples: VecDeque<BandwidthSample>,
89 /// Running total of all inbound bytes ever recorded (never decremented).
90 pub total_inbound_bytes: u64,
91 /// Running total of all outbound bytes ever recorded (never decremented).
92 pub total_outbound_bytes: u64,
93}
94
95impl PeerBandwidth {
96 /// Create a new, empty `PeerBandwidth` for the given peer.
97 pub fn new(peer_id: impl Into<String>) -> Self {
98 Self {
99 peer_id: peer_id.into(),
100 inbound_samples: VecDeque::new(),
101 outbound_samples: VecDeque::new(),
102 total_inbound_bytes: 0,
103 total_outbound_bytes: 0,
104 }
105 }
106
107 /// Record a transfer of `bytes` in `direction` at time `now`.
108 ///
109 /// Updates both the sliding-window sample queue and the all-time totals.
110 pub fn record(&mut self, bytes: u64, direction: Direction, now: Instant) {
111 let sample = BandwidthSample::new(bytes, direction, now);
112 match direction {
113 Direction::Inbound => {
114 self.inbound_samples.push_back(sample);
115 self.total_inbound_bytes = self.total_inbound_bytes.saturating_add(bytes);
116 }
117 Direction::Outbound => {
118 self.outbound_samples.push_back(sample);
119 self.total_outbound_bytes = self.total_outbound_bytes.saturating_add(bytes);
120 }
121 }
122 }
123
124 /// Remove samples older than `window` before `now` from both queues.
125 pub fn evict_old(&mut self, now: Instant, window: Duration) {
126 let cutoff = now.checked_sub(window).unwrap_or(now);
127 while let Some(front) = self.inbound_samples.front() {
128 if front.timestamp <= cutoff {
129 self.inbound_samples.pop_front();
130 } else {
131 break;
132 }
133 }
134 while let Some(front) = self.outbound_samples.front() {
135 if front.timestamp <= cutoff {
136 self.outbound_samples.pop_front();
137 } else {
138 break;
139 }
140 }
141 }
142
143 /// Compute the average rate (bytes/sec) for `direction` within the last
144 /// `window` seconds ending at `now`.
145 ///
146 /// Returns `0.0` when there are no samples in the window or the window
147 /// duration is zero.
148 pub fn rate_bps(&self, direction: Direction, now: Instant, window: Duration) -> f64 {
149 let window_secs = window.as_secs_f64();
150 if window_secs <= 0.0 {
151 return 0.0;
152 }
153 let cutoff = now.checked_sub(window).unwrap_or(now);
154 let queue = match direction {
155 Direction::Inbound => &self.inbound_samples,
156 Direction::Outbound => &self.outbound_samples,
157 };
158 let total_bytes: u64 = queue
159 .iter()
160 .filter(|s| s.timestamp > cutoff)
161 .map(|s| s.bytes)
162 .sum();
163 total_bytes as f64 / window_secs
164 }
165
166 /// Compute the peak instantaneous rate (bytes/sec) for `direction`.
167 ///
168 /// The peak is defined as the maximum single-sample byte count divided by
169 /// the elapsed time since the previous sample in the queue. The very first
170 /// sample is excluded because there is no prior reference point.
171 ///
172 /// Returns `0.0` when fewer than two samples exist.
173 pub fn peak_rate_bps(&self, direction: Direction) -> f64 {
174 let queue = match direction {
175 Direction::Inbound => &self.inbound_samples,
176 Direction::Outbound => &self.outbound_samples,
177 };
178 if queue.len() < 2 {
179 return 0.0;
180 }
181 let mut peak: f64 = 0.0;
182 let samples: Vec<&BandwidthSample> = queue.iter().collect();
183 for i in 1..samples.len() {
184 let elapsed = samples[i]
185 .timestamp
186 .duration_since(samples[i - 1].timestamp)
187 .as_secs_f64();
188 if elapsed > 0.0 {
189 let rate = samples[i].bytes as f64 / elapsed;
190 if rate > peak {
191 peak = rate;
192 }
193 }
194 }
195 peak
196 }
197
198 /// Return the timestamp of the most recent sample in either direction,
199 /// or `None` if no samples have been recorded yet.
200 pub fn last_activity(&self) -> Option<Instant> {
201 let inbound_ts = self.inbound_samples.back().map(|s| s.timestamp);
202 let outbound_ts = self.outbound_samples.back().map(|s| s.timestamp);
203 match (inbound_ts, outbound_ts) {
204 (Some(a), Some(b)) => Some(a.max(b)),
205 (Some(a), None) => Some(a),
206 (None, Some(b)) => Some(b),
207 (None, None) => None,
208 }
209 }
210}
211
212// ---------------------------------------------------------------------------
213// BandwidthStats
214// ---------------------------------------------------------------------------
215
216/// Atomic global counters for lock-free stats collection.
217#[derive(Debug, Default)]
218pub struct BandwidthStats {
219 /// Total inbound bytes recorded across all peers since monitor creation.
220 pub total_inbound_bytes: AtomicU64,
221 /// Total outbound bytes recorded across all peers since monitor creation.
222 pub total_outbound_bytes: AtomicU64,
223 /// Total number of individual samples recorded (both directions).
224 pub total_samples: AtomicU64,
225}
226
227/// A point-in-time snapshot of [`BandwidthStats`].
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct BandwidthStatsSnapshot {
230 /// Total inbound bytes at snapshot time.
231 pub total_inbound_bytes: u64,
232 /// Total outbound bytes at snapshot time.
233 pub total_outbound_bytes: u64,
234 /// Total sample count at snapshot time.
235 pub total_samples: u64,
236}
237
238impl BandwidthStats {
239 /// Create zeroed stats.
240 pub fn new() -> Self {
241 Self::default()
242 }
243
244 /// Take a consistent snapshot of all counters.
245 ///
246 /// Note: each field is read atomically but the three reads are not
247 /// collectively atomic; the snapshot may reflect slightly different
248 /// instants for each counter under heavy concurrent load.
249 pub fn snapshot(&self) -> BandwidthStatsSnapshot {
250 BandwidthStatsSnapshot {
251 total_inbound_bytes: self.total_inbound_bytes.load(Ordering::Relaxed),
252 total_outbound_bytes: self.total_outbound_bytes.load(Ordering::Relaxed),
253 total_samples: self.total_samples.load(Ordering::Relaxed),
254 }
255 }
256}
257
258// ---------------------------------------------------------------------------
259// BandwidthMonitor
260// ---------------------------------------------------------------------------
261
262/// Monitor that tracks per-peer and aggregate bandwidth usage.
263///
264/// `BandwidthMonitor` maintains a map of [`PeerBandwidth`] entries protected
265/// by a [`RwLock`]. All mutating operations take a write lock; read-only
266/// queries take a read lock. Global byte totals are accumulated in
267/// [`BandwidthStats`] using lock-free atomics, so callers can take cheap
268/// snapshots without acquiring the peer map lock.
269///
270/// The sliding window used for rate calculations is configurable at
271/// construction time (defaults to 10 seconds). Samples older than the
272/// window are lazily evicted on each call to [`record`](BandwidthMonitor::record).
273pub struct BandwidthMonitor {
274 /// Per-peer bandwidth state.
275 peers: RwLock<HashMap<String, PeerBandwidth>>,
276 /// Duration of the sliding window for rate calculations.
277 window: Duration,
278 /// Global atomic counters.
279 stats: Arc<BandwidthStats>,
280}
281
282impl BandwidthMonitor {
283 /// Create a new monitor with the default 10-second sliding window.
284 pub fn new() -> Self {
285 Self::with_window(Duration::from_secs(10))
286 }
287
288 /// Create a new monitor with a custom sliding `window` duration.
289 pub fn with_window(window: Duration) -> Self {
290 Self {
291 peers: RwLock::new(HashMap::new()),
292 window,
293 stats: Arc::new(BandwidthStats::new()),
294 }
295 }
296
297 /// Return the configured sliding window duration.
298 pub fn window(&self) -> Duration {
299 self.window
300 }
301
302 /// Return a shared reference to the global atomic stats.
303 pub fn stats(&self) -> &Arc<BandwidthStats> {
304 &self.stats
305 }
306
307 /// Record a transfer of `bytes` in `direction` for `peer_id`.
308 ///
309 /// This method:
310 /// 1. Obtains a write lock on the peer map.
311 /// 2. Creates a [`PeerBandwidth`] entry if one does not exist.
312 /// 3. Appends a new sample stamped with the current instant.
313 /// 4. Evicts samples older than the sliding window from that peer.
314 /// 5. Increments the global atomic counters.
315 pub fn record(&self, peer_id: &str, bytes: u64, direction: Direction) {
316 let now = Instant::now();
317 {
318 let mut peers = self.peers.write();
319 let entry = peers
320 .entry(peer_id.to_owned())
321 .or_insert_with(|| PeerBandwidth::new(peer_id));
322 entry.record(bytes, direction, now);
323 entry.evict_old(now, self.window);
324 }
325 // Update global atomics outside the write lock.
326 match direction {
327 Direction::Inbound => {
328 self.stats
329 .total_inbound_bytes
330 .fetch_add(bytes, Ordering::Relaxed);
331 }
332 Direction::Outbound => {
333 self.stats
334 .total_outbound_bytes
335 .fetch_add(bytes, Ordering::Relaxed);
336 }
337 }
338 self.stats.total_samples.fetch_add(1, Ordering::Relaxed);
339 }
340
341 /// Return the current rate (bytes/sec) for `peer_id` in `direction`.
342 ///
343 /// Returns `0.0` if the peer is unknown.
344 pub fn rate_for_peer(&self, peer_id: &str, direction: Direction) -> f64 {
345 let now = Instant::now();
346 let peers = self.peers.read();
347 peers
348 .get(peer_id)
349 .map(|p| p.rate_bps(direction, now, self.window))
350 .unwrap_or(0.0)
351 }
352
353 /// Return the top `n` peers by **outbound** rate, sorted descending.
354 ///
355 /// Each element is `(peer_id, rate_bps)`. If there are fewer than `n`
356 /// peers, all known peers are returned.
357 pub fn top_senders(&self, n: usize) -> Vec<(String, f64)> {
358 let now = Instant::now();
359 let peers = self.peers.read();
360 let mut rates: Vec<(String, f64)> = peers
361 .values()
362 .map(|p| {
363 (
364 p.peer_id.clone(),
365 p.rate_bps(Direction::Outbound, now, self.window),
366 )
367 })
368 .collect();
369 rates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
370 rates.truncate(n);
371 rates
372 }
373
374 /// Return the top `n` peers by **inbound** rate, sorted descending.
375 ///
376 /// Each element is `(peer_id, rate_bps)`.
377 pub fn top_receivers(&self, n: usize) -> Vec<(String, f64)> {
378 let now = Instant::now();
379 let peers = self.peers.read();
380 let mut rates: Vec<(String, f64)> = peers
381 .values()
382 .map(|p| {
383 (
384 p.peer_id.clone(),
385 p.rate_bps(Direction::Inbound, now, self.window),
386 )
387 })
388 .collect();
389 rates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
390 rates.truncate(n);
391 rates
392 }
393
394 /// Compute the aggregate rate (bytes/sec) across all peers for `direction`.
395 pub fn total_rate_bps(&self, direction: Direction) -> f64 {
396 let now = Instant::now();
397 let peers = self.peers.read();
398 peers
399 .values()
400 .map(|p| p.rate_bps(direction, now, self.window))
401 .sum()
402 }
403
404 /// Remove peers whose most recent sample is older than `max_idle`.
405 ///
406 /// A peer is considered idle when:
407 /// - It has no samples at all, **or**
408 /// - Its most recent sample (in either direction) was recorded more than
409 /// `max_idle` ago.
410 pub fn evict_idle_peers(&self, max_idle: Duration) {
411 let now = Instant::now();
412 let mut peers = self.peers.write();
413 peers.retain(|_, peer| {
414 match peer.last_activity() {
415 None => false, // No samples at all — remove immediately.
416 Some(ts) => now.duration_since(ts) <= max_idle,
417 }
418 });
419 }
420
421 /// Return the current number of tracked peers.
422 pub fn peer_count(&self) -> usize {
423 self.peers.read().len()
424 }
425
426 /// Take a snapshot of the global atomic stats without acquiring the peer
427 /// map lock.
428 pub fn stats_snapshot(&self) -> BandwidthStatsSnapshot {
429 self.stats.snapshot()
430 }
431}
432
433impl Default for BandwidthMonitor {
434 fn default() -> Self {
435 Self::new()
436 }
437}
438
439// ---------------------------------------------------------------------------
440// Tests
441// ---------------------------------------------------------------------------
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446 use std::thread;
447 use std::time::Duration;
448
449 // -----------------------------------------------------------------------
450 // Helper: fabricate a PeerBandwidth with injected instants
451 // -----------------------------------------------------------------------
452
453 fn make_peer_with_samples(
454 peer_id: &str,
455 direction: Direction,
456 samples: &[(u64, Instant)],
457 ) -> PeerBandwidth {
458 let mut peer = PeerBandwidth::new(peer_id);
459 for (bytes, ts) in samples {
460 let sample = BandwidthSample {
461 bytes: *bytes,
462 timestamp: *ts,
463 direction,
464 };
465 match direction {
466 Direction::Inbound => {
467 peer.inbound_samples.push_back(sample);
468 peer.total_inbound_bytes = peer.total_inbound_bytes.saturating_add(*bytes);
469 }
470 Direction::Outbound => {
471 peer.outbound_samples.push_back(sample);
472 peer.total_outbound_bytes = peer.total_outbound_bytes.saturating_add(*bytes);
473 }
474 }
475 }
476 peer
477 }
478
479 // -----------------------------------------------------------------------
480 // 1. record() increments per-peer totals
481 // -----------------------------------------------------------------------
482 #[test]
483 fn test_record_increments_totals() {
484 let monitor = BandwidthMonitor::new();
485 monitor.record("peer-a", 100, Direction::Inbound);
486 monitor.record("peer-a", 200, Direction::Inbound);
487 monitor.record("peer-a", 50, Direction::Outbound);
488
489 let peers = monitor.peers.read();
490 let peer = peers.get("peer-a").expect("peer-a should exist");
491 assert_eq!(peer.total_inbound_bytes, 300);
492 assert_eq!(peer.total_outbound_bytes, 50);
493 }
494
495 // -----------------------------------------------------------------------
496 // 2. Global stats counters accumulate across peers
497 // -----------------------------------------------------------------------
498 #[test]
499 fn test_stats_accumulation() {
500 let monitor = BandwidthMonitor::new();
501 monitor.record("p1", 1000, Direction::Inbound);
502 monitor.record("p2", 2000, Direction::Inbound);
503 monitor.record("p1", 500, Direction::Outbound);
504
505 let snap = monitor.stats_snapshot();
506 assert_eq!(snap.total_inbound_bytes, 3000);
507 assert_eq!(snap.total_outbound_bytes, 500);
508 assert_eq!(snap.total_samples, 3);
509 }
510
511 // -----------------------------------------------------------------------
512 // 3. rate_bps() — known window with time-shifted samples
513 // -----------------------------------------------------------------------
514 #[test]
515 fn test_rate_bps_correct_over_known_window() {
516 // 10-second window; inject two samples 5 s apart; together they are
517 // both within the 10-second window. Rate = (100 + 200) / 10 = 30 B/s.
518 let window = Duration::from_secs(10);
519 let now = Instant::now();
520 let t0 = now - Duration::from_secs(8);
521 let t1 = now - Duration::from_secs(3);
522
523 let peer = make_peer_with_samples("x", Direction::Inbound, &[(100, t0), (200, t1)]);
524
525 let rate = peer.rate_bps(Direction::Inbound, now, window);
526 assert!((rate - 30.0).abs() < 1e-9, "expected 30 B/s, got {rate}");
527 }
528
529 // -----------------------------------------------------------------------
530 // 4. Old samples are evicted by evict_old()
531 // -----------------------------------------------------------------------
532 #[test]
533 fn test_evict_old_removes_stale_samples() {
534 let window = Duration::from_secs(10);
535 let now = Instant::now();
536 let old = now - Duration::from_secs(15);
537 let recent = now - Duration::from_secs(2);
538
539 let mut peer =
540 make_peer_with_samples("y", Direction::Inbound, &[(999, old), (100, recent)]);
541
542 assert_eq!(peer.inbound_samples.len(), 2);
543 peer.evict_old(now, window);
544 assert_eq!(
545 peer.inbound_samples.len(),
546 1,
547 "stale sample should be evicted"
548 );
549 assert_eq!(peer.inbound_samples.front().map(|s| s.bytes), Some(100));
550 }
551
552 // -----------------------------------------------------------------------
553 // 5. rate_bps() returns 0 when no samples are in the window
554 // -----------------------------------------------------------------------
555 #[test]
556 fn test_rate_bps_zero_when_no_samples_in_window() {
557 let window = Duration::from_secs(5);
558 let now = Instant::now();
559 let old = now - Duration::from_secs(20);
560
561 let peer = make_peer_with_samples("z", Direction::Outbound, &[(500, old)]);
562 let rate = peer.rate_bps(Direction::Outbound, now, window);
563 assert_eq!(rate, 0.0);
564 }
565
566 // -----------------------------------------------------------------------
567 // 6. peak_rate_bps() returns max inter-sample rate
568 // -----------------------------------------------------------------------
569 #[test]
570 fn test_peak_rate_bps() {
571 let now = Instant::now();
572 // Two samples 1 second apart: 1000 bytes → 1000 B/s
573 // Two samples 0.5 s apart: 2000 bytes → 4000 B/s ← peak
574 let t0 = now - Duration::from_millis(1500);
575 let t1 = now - Duration::from_millis(500);
576 let t2 = now;
577
578 let peer = make_peer_with_samples(
579 "peak",
580 Direction::Outbound,
581 &[(1000, t0), (1000, t1), (2000, t2)],
582 );
583
584 let peak = peer.peak_rate_bps(Direction::Outbound);
585 // t1 → t2 gap is 500 ms; 2000 / 0.5 = 4000
586 assert!(peak >= 3999.0, "expected peak >= 4000 B/s, got {peak}");
587 }
588
589 // -----------------------------------------------------------------------
590 // 7. top_senders() is sorted descending by outbound rate
591 // -----------------------------------------------------------------------
592 #[test]
593 fn test_top_senders_sorted_descending() {
594 let monitor = BandwidthMonitor::with_window(Duration::from_secs(30));
595 // Inject samples in a single slice so Instant::now() is used:
596 // use small sleeps to ensure distinct instants don't matter — we just
597 // need relative ordering within the window, so record directly.
598 monitor.record("peer-low", 100, Direction::Outbound);
599 monitor.record("peer-mid", 1000, Direction::Outbound);
600 monitor.record("peer-high", 5000, Direction::Outbound);
601
602 let top = monitor.top_senders(3);
603 assert_eq!(top.len(), 3);
604 // Rates are equal over the same window duration so ordering is by
605 // absolute bytes — highest first.
606 assert!(
607 top[0].1 >= top[1].1,
608 "first element should have rate >= second"
609 );
610 assert!(
611 top[1].1 >= top[2].1,
612 "second element should have rate >= third"
613 );
614 assert_eq!(top[0].0, "peer-high");
615 }
616
617 // -----------------------------------------------------------------------
618 // 8. top_receivers() is sorted descending by inbound rate
619 // -----------------------------------------------------------------------
620 #[test]
621 fn test_top_receivers_sorted_descending() {
622 let monitor = BandwidthMonitor::with_window(Duration::from_secs(30));
623 monitor.record("recv-a", 200, Direction::Inbound);
624 monitor.record("recv-b", 800, Direction::Inbound);
625 monitor.record("recv-c", 50, Direction::Inbound);
626
627 let top = monitor.top_receivers(3);
628 assert_eq!(top.len(), 3);
629 assert!(top[0].1 >= top[1].1);
630 assert!(top[1].1 >= top[2].1);
631 assert_eq!(top[0].0, "recv-b");
632 }
633
634 // -----------------------------------------------------------------------
635 // 9. top_senders() returns at most n entries
636 // -----------------------------------------------------------------------
637 #[test]
638 fn test_top_senders_truncates_to_n() {
639 let monitor = BandwidthMonitor::new();
640 for i in 0..10_u64 {
641 monitor.record(&format!("p{i}"), i * 100, Direction::Outbound);
642 }
643 let top = monitor.top_senders(3);
644 assert_eq!(top.len(), 3);
645 }
646
647 // -----------------------------------------------------------------------
648 // 10. total_rate_bps() sums all peer rates
649 // -----------------------------------------------------------------------
650 #[test]
651 fn test_total_rate_bps_sums_all_peers() {
652 let window = Duration::from_secs(10);
653 let monitor = BandwidthMonitor::with_window(window);
654
655 // All samples recorded at approximately "now", so rate per peer ≈ bytes / 10
656 monitor.record("p1", 1000, Direction::Inbound);
657 monitor.record("p2", 2000, Direction::Inbound);
658 monitor.record("p3", 500, Direction::Inbound);
659
660 let total = monitor.total_rate_bps(Direction::Inbound);
661 // Expected: (1000 + 2000 + 500) / 10 = 350 B/s
662 assert!(
663 (total - 350.0).abs() < 1.0,
664 "expected ~350 B/s, got {total}"
665 );
666 }
667
668 // -----------------------------------------------------------------------
669 // 11. evict_idle_peers() removes peers with no recent activity
670 // -----------------------------------------------------------------------
671 #[test]
672 fn test_evict_idle_peers_removes_inactive() {
673 let monitor = BandwidthMonitor::new();
674 // Record for two peers; then sleep briefly so we can set a very short
675 // max_idle below the sleep duration.
676 monitor.record("active-peer", 100, Direction::Inbound);
677 monitor.record("idle-peer", 100, Direction::Inbound);
678
679 // Wait long enough so "idle-peer" becomes stale for a 1-ms window.
680 thread::sleep(Duration::from_millis(5));
681
682 // Record fresh activity for "active-peer" only.
683 monitor.record("active-peer", 100, Direction::Inbound);
684
685 // max_idle of 3 ms — "idle-peer" last recorded > 5 ms ago.
686 monitor.evict_idle_peers(Duration::from_millis(3));
687
688 let count = monitor.peer_count();
689 assert_eq!(count, 1, "only active-peer should remain, got {count}");
690
691 let peers = monitor.peers.read();
692 assert!(peers.contains_key("active-peer"));
693 assert!(!peers.contains_key("idle-peer"));
694 }
695
696 // -----------------------------------------------------------------------
697 // 12. peer_count() returns correct count
698 // -----------------------------------------------------------------------
699 #[test]
700 fn test_peer_count_correct() {
701 let monitor = BandwidthMonitor::new();
702 assert_eq!(monitor.peer_count(), 0);
703
704 monitor.record("a", 1, Direction::Inbound);
705 assert_eq!(monitor.peer_count(), 1);
706
707 monitor.record("b", 1, Direction::Outbound);
708 assert_eq!(monitor.peer_count(), 2);
709
710 monitor.record("a", 1, Direction::Outbound); // existing peer
711 assert_eq!(monitor.peer_count(), 2);
712 }
713
714 // -----------------------------------------------------------------------
715 // 13. evict_idle_peers() keeps peers with recent activity
716 // -----------------------------------------------------------------------
717 #[test]
718 fn test_evict_idle_peers_retains_active() {
719 let monitor = BandwidthMonitor::new();
720 monitor.record("fresh", 500, Direction::Inbound);
721 // Use a generous max_idle so no peer is evicted.
722 monitor.evict_idle_peers(Duration::from_secs(60));
723 assert_eq!(monitor.peer_count(), 1);
724 }
725
726 // -----------------------------------------------------------------------
727 // 14. Direction enum: Inbound and Outbound are distinct
728 // -----------------------------------------------------------------------
729 #[test]
730 fn test_direction_inbound_outbound_independent() {
731 let monitor = BandwidthMonitor::new();
732 monitor.record("peer-x", 1000, Direction::Inbound);
733 monitor.record("peer-x", 500, Direction::Outbound);
734
735 let in_rate = monitor.rate_for_peer("peer-x", Direction::Inbound);
736 let out_rate = monitor.rate_for_peer("peer-x", Direction::Outbound);
737
738 // inbound rate should be higher than outbound rate
739 assert!(
740 in_rate > out_rate,
741 "inbound ({in_rate}) should > outbound ({out_rate})"
742 );
743
744 // Verify totals are tracked separately
745 let peers = monitor.peers.read();
746 let p = peers.get("peer-x").expect("peer-x should exist");
747 assert_eq!(p.total_inbound_bytes, 1000);
748 assert_eq!(p.total_outbound_bytes, 500);
749 }
750
751 // -----------------------------------------------------------------------
752 // 15. BandwidthStats::snapshot() returns consistent values
753 // -----------------------------------------------------------------------
754 #[test]
755 fn test_stats_snapshot_values() {
756 let monitor = BandwidthMonitor::new();
757 monitor.record("s1", 4096, Direction::Inbound);
758 monitor.record("s1", 1024, Direction::Outbound);
759 monitor.record("s2", 8192, Direction::Inbound);
760
761 let snap = monitor.stats_snapshot();
762 assert_eq!(snap.total_inbound_bytes, 4096 + 8192);
763 assert_eq!(snap.total_outbound_bytes, 1024);
764 assert_eq!(snap.total_samples, 3);
765 }
766
767 // -----------------------------------------------------------------------
768 // 16. Unknown peer rate_for_peer() returns 0.0
769 // -----------------------------------------------------------------------
770 #[test]
771 fn test_rate_for_unknown_peer() {
772 let monitor = BandwidthMonitor::new();
773 assert_eq!(monitor.rate_for_peer("nobody", Direction::Inbound), 0.0);
774 assert_eq!(monitor.rate_for_peer("nobody", Direction::Outbound), 0.0);
775 }
776}
777
778// ---------------------------------------------------------------------------
779// PeerBandwidthMonitor — tick-based sliding window bandwidth tracking
780// ---------------------------------------------------------------------------
781
782/// A single tick-based bandwidth measurement for a peer.
783#[derive(Debug, Clone)]
784pub struct TickBandwidthSample {
785 /// The logical tick at which this sample was recorded.
786 pub tick: u64,
787 /// Number of bytes sent during this tick.
788 pub bytes_sent: u64,
789 /// Number of bytes received during this tick.
790 pub bytes_received: u64,
791}
792
793/// Sliding-window bandwidth state for a single peer, indexed by logical tick.
794#[derive(Debug, Clone)]
795pub struct PeerBandwidthWindow {
796 /// Stable identifier for this peer.
797 pub peer_id: String,
798 /// Ordered samples (oldest first); bounded by `window_size`.
799 pub samples: Vec<TickBandwidthSample>,
800 /// Maximum number of samples to retain.
801 pub window_size: usize,
802 /// Cumulative bytes sent across all samples ever recorded (not windowed).
803 pub total_sent: u64,
804 /// Cumulative bytes received across all samples ever recorded (not windowed).
805 pub total_received: u64,
806}
807
808impl PeerBandwidthWindow {
809 /// Create a new, empty window for the given peer with the given capacity.
810 pub fn new(peer_id: impl Into<String>, window_size: usize) -> Self {
811 Self {
812 peer_id: peer_id.into(),
813 samples: Vec::new(),
814 window_size,
815 total_sent: 0,
816 total_received: 0,
817 }
818 }
819
820 /// Append a new sample, evicting the oldest when over capacity.
821 ///
822 /// Updates cumulative totals before any eviction.
823 pub fn add_sample(&mut self, tick: u64, bytes_sent: u64, bytes_received: u64) {
824 self.total_sent = self.total_sent.saturating_add(bytes_sent);
825 self.total_received = self.total_received.saturating_add(bytes_received);
826 self.samples.push(TickBandwidthSample {
827 tick,
828 bytes_sent,
829 bytes_received,
830 });
831 if self.samples.len() > self.window_size {
832 self.samples.remove(0);
833 }
834 }
835
836 /// Mean `bytes_sent` across all samples currently in the window.
837 ///
838 /// Returns `0.0` when the window is empty.
839 pub fn avg_send_rate(&self) -> f64 {
840 if self.samples.is_empty() {
841 return 0.0;
842 }
843 let total: u64 = self.samples.iter().map(|s| s.bytes_sent).sum();
844 total as f64 / self.samples.len() as f64
845 }
846
847 /// Mean `bytes_received` across all samples currently in the window.
848 ///
849 /// Returns `0.0` when the window is empty.
850 pub fn avg_recv_rate(&self) -> f64 {
851 if self.samples.is_empty() {
852 return 0.0;
853 }
854 let total: u64 = self.samples.iter().map(|s| s.bytes_received).sum();
855 total as f64 / self.samples.len() as f64
856 }
857
858 /// Maximum `bytes_sent` of any sample in the current window.
859 ///
860 /// Returns `0` when the window is empty.
861 pub fn peak_send(&self) -> u64 {
862 self.samples.iter().map(|s| s.bytes_sent).max().unwrap_or(0)
863 }
864
865 /// Maximum `bytes_received` of any sample in the current window.
866 ///
867 /// Returns `0` when the window is empty.
868 pub fn peak_recv(&self) -> u64 {
869 self.samples
870 .iter()
871 .map(|s| s.bytes_received)
872 .max()
873 .unwrap_or(0)
874 }
875}
876
877// ---------------------------------------------------------------------------
878// BandwidthAnomaly
879// ---------------------------------------------------------------------------
880
881/// An anomaly detected by [`PeerBandwidthMonitor`].
882#[derive(Debug, Clone, PartialEq)]
883pub enum BandwidthAnomaly {
884 /// A single send sample is more than `spike_multiplier` times the prior
885 /// rolling average.
886 SendSpike {
887 /// The peer that triggered the spike.
888 peer_id: String,
889 /// The raw bytes_sent value that caused the spike.
890 sample_bytes: u64,
891 /// The rolling average that was exceeded.
892 avg_bytes: f64,
893 },
894 /// A single receive sample is more than `spike_multiplier` times the prior
895 /// rolling average.
896 RecvSpike {
897 /// The peer that triggered the spike.
898 peer_id: String,
899 /// The raw bytes_received value that caused the spike.
900 sample_bytes: u64,
901 /// The rolling average that was exceeded.
902 avg_bytes: f64,
903 },
904 /// A peer has produced no samples for at least `idle_threshold_ticks` ticks.
905 Idle {
906 /// The idle peer.
907 peer_id: String,
908 /// How many ticks have elapsed since the last recorded sample.
909 ticks_since_last: u64,
910 },
911}
912
913// ---------------------------------------------------------------------------
914// MonitorConfig
915// ---------------------------------------------------------------------------
916
917/// Configuration for [`PeerBandwidthMonitor`].
918#[derive(Debug, Clone)]
919pub struct MonitorConfig {
920 /// Number of ticks to keep in each peer's sliding window.
921 pub window_size: usize,
922 /// Threshold multiplier: a sample is a spike when `sample > multiplier * avg`.
923 pub spike_multiplier: f64,
924 /// Ticks without a sample before a peer is considered idle.
925 pub idle_threshold_ticks: u64,
926}
927
928impl Default for MonitorConfig {
929 fn default() -> Self {
930 Self {
931 window_size: 60,
932 spike_multiplier: 3.0,
933 idle_threshold_ticks: 120,
934 }
935 }
936}
937
938// ---------------------------------------------------------------------------
939// BandwidthMonitorStats
940// ---------------------------------------------------------------------------
941
942/// Aggregate statistics maintained by [`PeerBandwidthMonitor`].
943#[derive(Debug, Clone, Default)]
944pub struct BandwidthMonitorStats {
945 /// Number of distinct peers currently tracked.
946 pub total_peers: usize,
947 /// Total number of samples recorded across all peers.
948 pub total_samples_recorded: u64,
949 /// Total number of anomalies detected (spikes + idles).
950 pub total_anomalies_detected: u64,
951 /// Aggregate bytes sent across all peers (cumulative).
952 pub aggregate_sent_bytes: u64,
953 /// Aggregate bytes received across all peers (cumulative).
954 pub aggregate_received_bytes: u64,
955}
956
957// ---------------------------------------------------------------------------
958// PeerBandwidthMonitor
959// ---------------------------------------------------------------------------
960
961/// Tracks per-peer and aggregate bandwidth over a sliding tick window.
962///
963/// Call [`record`](PeerBandwidthMonitor::record) on each tick per peer.
964/// Spike anomalies are appended to `pending_anomalies` and can be retrieved
965/// with [`drain_anomalies`](PeerBandwidthMonitor::drain_anomalies).
966/// Idle anomalies are computed on demand via
967/// [`check_idle`](PeerBandwidthMonitor::check_idle).
968pub struct PeerBandwidthMonitor {
969 /// Per-peer sliding windows, keyed by peer_id.
970 pub windows: HashMap<String, PeerBandwidthWindow>,
971 /// Monitor configuration.
972 pub config: MonitorConfig,
973 /// Aggregate statistics.
974 pub stats: BandwidthMonitorStats,
975 /// Anomalies detected during `record` calls; cleared by `drain_anomalies`.
976 pub pending_anomalies: Vec<BandwidthAnomaly>,
977}
978
979impl PeerBandwidthMonitor {
980 /// Create a new monitor with the given configuration.
981 pub fn new(config: MonitorConfig) -> Self {
982 Self {
983 windows: HashMap::new(),
984 config,
985 stats: BandwidthMonitorStats::default(),
986 pending_anomalies: Vec::new(),
987 }
988 }
989
990 /// Record a bandwidth sample for `peer_id` at logical `tick`.
991 ///
992 /// Automatically creates a window entry for new peers. After recording,
993 /// checks for send/receive spikes and appends any detected
994 /// [`BandwidthAnomaly`] to `pending_anomalies`.
995 pub fn record(&mut self, peer_id: &str, tick: u64, bytes_sent: u64, bytes_recv: u64) {
996 let window_size = self.config.window_size;
997 let window = self
998 .windows
999 .entry(peer_id.to_owned())
1000 .or_insert_with(|| PeerBandwidthWindow::new(peer_id, window_size));
1001
1002 // Compute prior averages before the new sample is added (needs >= 1
1003 // existing sample so we have a meaningful baseline).
1004 let (prior_avg_send, prior_avg_recv, has_prior) = if !window.samples.is_empty() {
1005 (window.avg_send_rate(), window.avg_recv_rate(), true)
1006 } else {
1007 (0.0, 0.0, false)
1008 };
1009
1010 window.add_sample(tick, bytes_sent, bytes_recv);
1011
1012 // Update aggregate stats.
1013 self.stats.total_samples_recorded = self.stats.total_samples_recorded.saturating_add(1);
1014 self.stats.aggregate_sent_bytes =
1015 self.stats.aggregate_sent_bytes.saturating_add(bytes_sent);
1016 self.stats.aggregate_received_bytes = self
1017 .stats
1018 .aggregate_received_bytes
1019 .saturating_add(bytes_recv);
1020 self.stats.total_peers = self.windows.len();
1021
1022 // Spike detection requires at least one prior sample.
1023 if !has_prior {
1024 return;
1025 }
1026
1027 let multiplier = self.config.spike_multiplier;
1028
1029 if prior_avg_send > 0.0 && bytes_sent as f64 > multiplier * prior_avg_send {
1030 self.pending_anomalies.push(BandwidthAnomaly::SendSpike {
1031 peer_id: peer_id.to_owned(),
1032 sample_bytes: bytes_sent,
1033 avg_bytes: prior_avg_send,
1034 });
1035 self.stats.total_anomalies_detected =
1036 self.stats.total_anomalies_detected.saturating_add(1);
1037 }
1038
1039 if prior_avg_recv > 0.0 && bytes_recv as f64 > multiplier * prior_avg_recv {
1040 self.pending_anomalies.push(BandwidthAnomaly::RecvSpike {
1041 peer_id: peer_id.to_owned(),
1042 sample_bytes: bytes_recv,
1043 avg_bytes: prior_avg_recv,
1044 });
1045 self.stats.total_anomalies_detected =
1046 self.stats.total_anomalies_detected.saturating_add(1);
1047 }
1048 }
1049
1050 /// Take all pending anomalies, leaving an empty list.
1051 pub fn drain_anomalies(&mut self) -> Vec<BandwidthAnomaly> {
1052 std::mem::take(&mut self.pending_anomalies)
1053 }
1054
1055 /// Scan all tracked peers for idle conditions at `current_tick`.
1056 ///
1057 /// A peer is idle when it has no samples in its window **or** its most
1058 /// recent sample tick is more than `idle_threshold_ticks` behind
1059 /// `current_tick`.
1060 ///
1061 /// Unlike spike detection, idle anomalies are **returned directly** and
1062 /// are NOT added to `pending_anomalies`.
1063 pub fn check_idle(&mut self, current_tick: u64) -> Vec<BandwidthAnomaly> {
1064 let threshold = self.config.idle_threshold_ticks;
1065 let mut anomalies = Vec::new();
1066
1067 for (peer_id, window) in &self.windows {
1068 let is_idle = match window.samples.last() {
1069 None => true,
1070 Some(last) => {
1071 // saturating_sub avoids underflow if current_tick < last.tick
1072 let elapsed = current_tick.saturating_sub(last.tick);
1073 elapsed >= threshold
1074 }
1075 };
1076
1077 if is_idle {
1078 let ticks_since_last = match window.samples.last() {
1079 None => current_tick,
1080 Some(last) => current_tick.saturating_sub(last.tick),
1081 };
1082 anomalies.push(BandwidthAnomaly::Idle {
1083 peer_id: peer_id.clone(),
1084 ticks_since_last,
1085 });
1086 self.stats.total_anomalies_detected =
1087 self.stats.total_anomalies_detected.saturating_add(1);
1088 }
1089 }
1090
1091 anomalies
1092 }
1093
1094 /// Return a shared reference to the sliding window for `peer_id`, if any.
1095 pub fn peer_window(&self, peer_id: &str) -> Option<&PeerBandwidthWindow> {
1096 self.windows.get(peer_id)
1097 }
1098
1099 /// Return a shared reference to the aggregate statistics.
1100 pub fn stats(&self) -> &BandwidthMonitorStats {
1101 &self.stats
1102 }
1103}
1104
1105// ---------------------------------------------------------------------------
1106// PeerBandwidthMonitor tests
1107// ---------------------------------------------------------------------------
1108
1109#[cfg(test)]
1110mod peer_monitor_tests {
1111 use super::{BandwidthAnomaly, MonitorConfig, PeerBandwidthMonitor, PeerBandwidthWindow};
1112
1113 // -----------------------------------------------------------------------
1114 // Helper: build a fresh monitor with default config
1115 // -----------------------------------------------------------------------
1116 fn default_monitor() -> PeerBandwidthMonitor {
1117 PeerBandwidthMonitor::new(MonitorConfig::default())
1118 }
1119
1120 // -----------------------------------------------------------------------
1121 // 1. add_sample — basic insertion
1122 // -----------------------------------------------------------------------
1123 #[test]
1124 fn test_add_sample_basic() {
1125 let mut w = PeerBandwidthWindow::new("p1", 5);
1126 w.add_sample(1, 100, 200);
1127 assert_eq!(w.samples.len(), 1);
1128 assert_eq!(w.samples[0].tick, 1);
1129 assert_eq!(w.samples[0].bytes_sent, 100);
1130 assert_eq!(w.samples[0].bytes_received, 200);
1131 }
1132
1133 // -----------------------------------------------------------------------
1134 // 2. add_sample — sliding window eviction (oldest first)
1135 // -----------------------------------------------------------------------
1136 #[test]
1137 fn test_add_sample_evicts_oldest() {
1138 let mut w = PeerBandwidthWindow::new("p1", 3);
1139 w.add_sample(1, 10, 10);
1140 w.add_sample(2, 20, 20);
1141 w.add_sample(3, 30, 30);
1142 // Window is full; adding one more must evict tick=1
1143 w.add_sample(4, 40, 40);
1144 assert_eq!(w.samples.len(), 3);
1145 assert_eq!(
1146 w.samples[0].tick, 2,
1147 "oldest (tick=1) should have been evicted"
1148 );
1149 assert_eq!(w.samples[2].tick, 4);
1150 }
1151
1152 // -----------------------------------------------------------------------
1153 // 3. add_sample — cumulative totals are not affected by eviction
1154 // -----------------------------------------------------------------------
1155 #[test]
1156 fn test_add_sample_totals_accumulate_beyond_window() {
1157 let mut w = PeerBandwidthWindow::new("p1", 2);
1158 w.add_sample(1, 100, 50);
1159 w.add_sample(2, 200, 100);
1160 w.add_sample(3, 300, 150); // evicts tick=1
1161 assert_eq!(w.total_sent, 600);
1162 assert_eq!(w.total_received, 300);
1163 // Only the last 2 samples are in the window
1164 assert_eq!(w.samples.len(), 2);
1165 }
1166
1167 // -----------------------------------------------------------------------
1168 // 4. avg_send_rate — empty window
1169 // -----------------------------------------------------------------------
1170 #[test]
1171 fn test_avg_send_rate_empty() {
1172 let w = PeerBandwidthWindow::new("p1", 10);
1173 assert_eq!(w.avg_send_rate(), 0.0);
1174 }
1175
1176 // -----------------------------------------------------------------------
1177 // 5. avg_send_rate — correct mean
1178 // -----------------------------------------------------------------------
1179 #[test]
1180 fn test_avg_send_rate_correct() {
1181 let mut w = PeerBandwidthWindow::new("p1", 10);
1182 w.add_sample(1, 100, 0);
1183 w.add_sample(2, 200, 0);
1184 w.add_sample(3, 300, 0);
1185 // mean = (100+200+300)/3 = 200
1186 assert!((w.avg_send_rate() - 200.0).abs() < 1e-9);
1187 }
1188
1189 // -----------------------------------------------------------------------
1190 // 6. avg_recv_rate — empty window
1191 // -----------------------------------------------------------------------
1192 #[test]
1193 fn test_avg_recv_rate_empty() {
1194 let w = PeerBandwidthWindow::new("p1", 10);
1195 assert_eq!(w.avg_recv_rate(), 0.0);
1196 }
1197
1198 // -----------------------------------------------------------------------
1199 // 7. avg_recv_rate — correct mean
1200 // -----------------------------------------------------------------------
1201 #[test]
1202 fn test_avg_recv_rate_correct() {
1203 let mut w = PeerBandwidthWindow::new("p1", 10);
1204 w.add_sample(1, 0, 400);
1205 w.add_sample(2, 0, 600);
1206 // mean = 500
1207 assert!((w.avg_recv_rate() - 500.0).abs() < 1e-9);
1208 }
1209
1210 // -----------------------------------------------------------------------
1211 // 8. peak_send — empty window
1212 // -----------------------------------------------------------------------
1213 #[test]
1214 fn test_peak_send_empty() {
1215 let w = PeerBandwidthWindow::new("p1", 10);
1216 assert_eq!(w.peak_send(), 0);
1217 }
1218
1219 // -----------------------------------------------------------------------
1220 // 9. peak_send — correct maximum
1221 // -----------------------------------------------------------------------
1222 #[test]
1223 fn test_peak_send_correct() {
1224 let mut w = PeerBandwidthWindow::new("p1", 10);
1225 w.add_sample(1, 50, 0);
1226 w.add_sample(2, 999, 0);
1227 w.add_sample(3, 100, 0);
1228 assert_eq!(w.peak_send(), 999);
1229 }
1230
1231 // -----------------------------------------------------------------------
1232 // 10. peak_recv — empty window
1233 // -----------------------------------------------------------------------
1234 #[test]
1235 fn test_peak_recv_empty() {
1236 let w = PeerBandwidthWindow::new("p1", 10);
1237 assert_eq!(w.peak_recv(), 0);
1238 }
1239
1240 // -----------------------------------------------------------------------
1241 // 11. peak_recv — correct maximum
1242 // -----------------------------------------------------------------------
1243 #[test]
1244 fn test_peak_recv_correct() {
1245 let mut w = PeerBandwidthWindow::new("p1", 10);
1246 w.add_sample(1, 0, 1000);
1247 w.add_sample(2, 0, 500);
1248 w.add_sample(3, 0, 2000);
1249 assert_eq!(w.peak_recv(), 2000);
1250 }
1251
1252 // -----------------------------------------------------------------------
1253 // 12. record() auto-creates window for new peer
1254 // -----------------------------------------------------------------------
1255 #[test]
1256 fn test_record_creates_window() {
1257 let mut monitor = default_monitor();
1258 assert!(monitor.peer_window("peer-a").is_none());
1259 monitor.record("peer-a", 1, 100, 200);
1260 assert!(monitor.peer_window("peer-a").is_some());
1261 }
1262
1263 // -----------------------------------------------------------------------
1264 // 13. record() updates aggregate stats
1265 // -----------------------------------------------------------------------
1266 #[test]
1267 fn test_record_updates_aggregate_stats() {
1268 let mut monitor = default_monitor();
1269 monitor.record("p1", 1, 100, 50);
1270 monitor.record("p2", 1, 200, 75);
1271
1272 let s = monitor.stats();
1273 assert_eq!(s.total_peers, 2);
1274 assert_eq!(s.total_samples_recorded, 2);
1275 assert_eq!(s.aggregate_sent_bytes, 300);
1276 assert_eq!(s.aggregate_received_bytes, 125);
1277 }
1278
1279 // -----------------------------------------------------------------------
1280 // 14. No spike on first sample (only 1 sample — no prior avg)
1281 // -----------------------------------------------------------------------
1282 #[test]
1283 fn test_no_spike_on_first_sample() {
1284 let mut monitor = default_monitor();
1285 // Enormous values that would definitely trigger a spike if checked
1286 monitor.record("p1", 1, u64::MAX / 2, u64::MAX / 2);
1287 assert!(
1288 monitor.pending_anomalies.is_empty(),
1289 "no spike should be detected on the very first sample"
1290 );
1291 }
1292
1293 // -----------------------------------------------------------------------
1294 // 15. SendSpike detected when sample > 3x prior avg
1295 // -----------------------------------------------------------------------
1296 #[test]
1297 fn test_send_spike_detected() {
1298 let config = MonitorConfig {
1299 window_size: 60,
1300 spike_multiplier: 3.0,
1301 idle_threshold_ticks: 120,
1302 };
1303 let mut monitor = PeerBandwidthMonitor::new(config);
1304 // Establish baseline of 100 bytes/tick
1305 monitor.record("peer", 1, 100, 0);
1306 monitor.record("peer", 2, 100, 0);
1307 monitor.record("peer", 3, 100, 0);
1308 // Clear any incidental anomalies from setup
1309 let _ = monitor.drain_anomalies();
1310
1311 // Now send 400 bytes — prior avg is 100, 400 > 3 * 100 → spike
1312 monitor.record("peer", 4, 400, 0);
1313 let anomalies = monitor.drain_anomalies();
1314 assert_eq!(anomalies.len(), 1);
1315 match &anomalies[0] {
1316 BandwidthAnomaly::SendSpike {
1317 peer_id,
1318 sample_bytes,
1319 ..
1320 } => {
1321 assert_eq!(peer_id, "peer");
1322 assert_eq!(*sample_bytes, 400);
1323 }
1324 other => panic!("expected SendSpike, got {:?}", other),
1325 }
1326 }
1327
1328 // -----------------------------------------------------------------------
1329 // 16. RecvSpike detected when sample > 3x prior avg
1330 // -----------------------------------------------------------------------
1331 #[test]
1332 fn test_recv_spike_detected() {
1333 let mut monitor = default_monitor();
1334 monitor.record("peer", 1, 0, 100);
1335 monitor.record("peer", 2, 0, 100);
1336 monitor.record("peer", 3, 0, 100);
1337 let _ = monitor.drain_anomalies();
1338
1339 // 400 bytes recv > 3 * 100
1340 monitor.record("peer", 4, 0, 400);
1341 let anomalies = monitor.drain_anomalies();
1342 assert_eq!(anomalies.len(), 1);
1343 match &anomalies[0] {
1344 BandwidthAnomaly::RecvSpike {
1345 peer_id,
1346 sample_bytes,
1347 ..
1348 } => {
1349 assert_eq!(peer_id, "peer");
1350 assert_eq!(*sample_bytes, 400);
1351 }
1352 other => panic!("expected RecvSpike, got {:?}", other),
1353 }
1354 }
1355
1356 // -----------------------------------------------------------------------
1357 // 17. Exactly 3x multiplier does NOT trigger a spike (> not >=)
1358 // -----------------------------------------------------------------------
1359 #[test]
1360 fn test_spike_boundary_exactly_3x_no_spike() {
1361 let config = MonitorConfig {
1362 window_size: 60,
1363 spike_multiplier: 3.0,
1364 idle_threshold_ticks: 120,
1365 };
1366 let mut monitor = PeerBandwidthMonitor::new(config);
1367 // Baseline: avg_send = 100
1368 monitor.record("peer", 1, 100, 0);
1369 monitor.record("peer", 2, 100, 0);
1370 let _ = monitor.drain_anomalies();
1371
1372 // Exactly 3 * avg = 300 → NOT a spike (must be strictly greater)
1373 monitor.record("peer", 3, 300, 0);
1374 let anomalies = monitor.drain_anomalies();
1375 assert!(
1376 anomalies.is_empty(),
1377 "exactly 3x average should NOT trigger a spike, got {:?}",
1378 anomalies
1379 );
1380 }
1381
1382 // -----------------------------------------------------------------------
1383 // 18. One tick above 3x multiplier triggers a spike
1384 // -----------------------------------------------------------------------
1385 #[test]
1386 fn test_spike_boundary_above_3x_triggers_spike() {
1387 let config = MonitorConfig {
1388 window_size: 60,
1389 spike_multiplier: 3.0,
1390 idle_threshold_ticks: 120,
1391 };
1392 let mut monitor = PeerBandwidthMonitor::new(config);
1393 monitor.record("peer", 1, 100, 0);
1394 monitor.record("peer", 2, 100, 0);
1395 let _ = monitor.drain_anomalies();
1396
1397 // 301 > 3 * 100 → spike
1398 monitor.record("peer", 3, 301, 0);
1399 let anomalies = monitor.drain_anomalies();
1400 assert_eq!(anomalies.len(), 1, "301 > 300 should trigger a spike");
1401 }
1402
1403 // -----------------------------------------------------------------------
1404 // 19. drain_anomalies clears the pending list
1405 // -----------------------------------------------------------------------
1406 #[test]
1407 fn test_drain_anomalies_clears_list() {
1408 let mut monitor = default_monitor();
1409 monitor.record("peer", 1, 100, 0);
1410 monitor.record("peer", 2, 100, 0);
1411 monitor.record("peer", 3, 100, 0);
1412 // Trigger a spike
1413 monitor.record("peer", 4, 999, 0);
1414
1415 let first = monitor.drain_anomalies();
1416 assert!(!first.is_empty(), "should have anomalies after spike");
1417
1418 let second = monitor.drain_anomalies();
1419 assert!(second.is_empty(), "drain_anomalies should clear the list");
1420 }
1421
1422 // -----------------------------------------------------------------------
1423 // 20. check_idle — detects idle peer with no samples
1424 // -----------------------------------------------------------------------
1425 #[test]
1426 fn test_check_idle_no_samples() {
1427 let config = MonitorConfig {
1428 window_size: 5,
1429 spike_multiplier: 3.0,
1430 idle_threshold_ticks: 10,
1431 };
1432 let mut monitor = PeerBandwidthMonitor::new(config);
1433 // Create a window with no samples by recording then draining via a
1434 // fresh monitor with a tiny window that evicts everything — simpler:
1435 // just insert an entry manually.
1436 monitor.windows.insert(
1437 "idle-peer".to_owned(),
1438 PeerBandwidthWindow::new("idle-peer", 5),
1439 );
1440
1441 let anomalies = monitor.check_idle(50);
1442 assert_eq!(anomalies.len(), 1);
1443 match &anomalies[0] {
1444 BandwidthAnomaly::Idle {
1445 peer_id,
1446 ticks_since_last,
1447 } => {
1448 assert_eq!(peer_id, "idle-peer");
1449 assert_eq!(*ticks_since_last, 50); // current_tick when no samples
1450 }
1451 other => panic!("expected Idle, got {:?}", other),
1452 }
1453 }
1454
1455 // -----------------------------------------------------------------------
1456 // 21. check_idle — detects peer whose last sample is old enough
1457 // -----------------------------------------------------------------------
1458 #[test]
1459 fn test_check_idle_stale_last_sample() {
1460 let config = MonitorConfig {
1461 window_size: 60,
1462 spike_multiplier: 3.0,
1463 idle_threshold_ticks: 10,
1464 };
1465 let mut monitor = PeerBandwidthMonitor::new(config);
1466 monitor.record("peer", 5, 100, 100); // last tick = 5
1467
1468 // current_tick = 16; elapsed = 11 >= threshold 10 → idle
1469 let anomalies = monitor.check_idle(16);
1470 assert_eq!(anomalies.len(), 1);
1471 match &anomalies[0] {
1472 BandwidthAnomaly::Idle {
1473 peer_id,
1474 ticks_since_last,
1475 } => {
1476 assert_eq!(peer_id, "peer");
1477 assert_eq!(*ticks_since_last, 11);
1478 }
1479 other => panic!("expected Idle, got {:?}", other),
1480 }
1481 }
1482
1483 // -----------------------------------------------------------------------
1484 // 22. check_idle — does NOT flag a recently-active peer
1485 // -----------------------------------------------------------------------
1486 #[test]
1487 fn test_check_idle_active_peer_not_flagged() {
1488 let config = MonitorConfig {
1489 window_size: 60,
1490 spike_multiplier: 3.0,
1491 idle_threshold_ticks: 10,
1492 };
1493 let mut monitor = PeerBandwidthMonitor::new(config);
1494 monitor.record("peer", 95, 100, 100); // last tick = 95
1495
1496 // current_tick = 100; elapsed = 5 < threshold 10 → not idle
1497 let anomalies = monitor.check_idle(100);
1498 assert!(
1499 anomalies.is_empty(),
1500 "active peer should not be flagged as idle"
1501 );
1502 }
1503
1504 // -----------------------------------------------------------------------
1505 // 23. check_idle — exactly at threshold IS idle (>=)
1506 // -----------------------------------------------------------------------
1507 #[test]
1508 fn test_check_idle_exactly_at_threshold() {
1509 let config = MonitorConfig {
1510 window_size: 60,
1511 spike_multiplier: 3.0,
1512 idle_threshold_ticks: 10,
1513 };
1514 let mut monitor = PeerBandwidthMonitor::new(config);
1515 monitor.record("peer", 90, 100, 100); // last tick = 90
1516
1517 // elapsed = 10 = threshold → idle
1518 let anomalies = monitor.check_idle(100);
1519 assert_eq!(anomalies.len(), 1, "elapsed == threshold should be idle");
1520 }
1521
1522 // -----------------------------------------------------------------------
1523 // 24. check_idle increments stats.total_anomalies_detected
1524 // -----------------------------------------------------------------------
1525 #[test]
1526 fn test_check_idle_increments_anomaly_count() {
1527 let config = MonitorConfig {
1528 window_size: 60,
1529 spike_multiplier: 3.0,
1530 idle_threshold_ticks: 5,
1531 };
1532 let mut monitor = PeerBandwidthMonitor::new(config);
1533 monitor
1534 .windows
1535 .insert("a".to_owned(), PeerBandwidthWindow::new("a", 60));
1536 monitor
1537 .windows
1538 .insert("b".to_owned(), PeerBandwidthWindow::new("b", 60));
1539
1540 let before = monitor.stats().total_anomalies_detected;
1541 let anomalies = monitor.check_idle(100);
1542 let after = monitor.stats().total_anomalies_detected;
1543
1544 assert_eq!(anomalies.len(), 2);
1545 assert_eq!(after - before, 2);
1546 }
1547
1548 // -----------------------------------------------------------------------
1549 // 25. check_idle does NOT add to pending_anomalies
1550 // -----------------------------------------------------------------------
1551 #[test]
1552 fn test_check_idle_does_not_populate_pending() {
1553 let config = MonitorConfig {
1554 window_size: 60,
1555 spike_multiplier: 3.0,
1556 idle_threshold_ticks: 5,
1557 };
1558 let mut monitor = PeerBandwidthMonitor::new(config);
1559 monitor
1560 .windows
1561 .insert("idle".to_owned(), PeerBandwidthWindow::new("idle", 60));
1562
1563 let _ = monitor.check_idle(100);
1564 assert!(
1565 monitor.pending_anomalies.is_empty(),
1566 "check_idle must not populate pending_anomalies"
1567 );
1568 }
1569
1570 // -----------------------------------------------------------------------
1571 // 26. Both send and recv spikes can fire in the same record() call
1572 // -----------------------------------------------------------------------
1573 #[test]
1574 fn test_both_send_and_recv_spike_same_record() {
1575 let mut monitor = default_monitor();
1576 monitor.record("peer", 1, 100, 100);
1577 monitor.record("peer", 2, 100, 100);
1578 monitor.record("peer", 3, 100, 100);
1579 let _ = monitor.drain_anomalies();
1580
1581 // 500 bytes send AND recv — both > 3 * 100
1582 monitor.record("peer", 4, 500, 500);
1583 let anomalies = monitor.drain_anomalies();
1584 assert_eq!(
1585 anomalies.len(),
1586 2,
1587 "should detect both a SendSpike and RecvSpike"
1588 );
1589 let has_send = anomalies
1590 .iter()
1591 .any(|a| matches!(a, BandwidthAnomaly::SendSpike { .. }));
1592 let has_recv = anomalies
1593 .iter()
1594 .any(|a| matches!(a, BandwidthAnomaly::RecvSpike { .. }));
1595 assert!(has_send, "expected SendSpike in anomalies");
1596 assert!(has_recv, "expected RecvSpike in anomalies");
1597 }
1598
1599 // -----------------------------------------------------------------------
1600 // 27. peer_window() returns None for unknown peer
1601 // -----------------------------------------------------------------------
1602 #[test]
1603 fn test_peer_window_unknown_peer() {
1604 let monitor = default_monitor();
1605 assert!(monitor.peer_window("nobody").is_none());
1606 }
1607
1608 // -----------------------------------------------------------------------
1609 // 28. stats() reflects the latest snapshot
1610 // -----------------------------------------------------------------------
1611 #[test]
1612 fn test_stats_reflects_current_state() {
1613 let mut monitor = default_monitor();
1614 monitor.record("a", 1, 1000, 2000);
1615 monitor.record("b", 1, 3000, 4000);
1616
1617 let s = monitor.stats();
1618 assert_eq!(s.total_peers, 2);
1619 assert_eq!(s.aggregate_sent_bytes, 4000);
1620 assert_eq!(s.aggregate_received_bytes, 6000);
1621 assert_eq!(s.total_samples_recorded, 2);
1622 }
1623
1624 // -----------------------------------------------------------------------
1625 // 29. Spike anomaly carries the correct avg_bytes field
1626 // -----------------------------------------------------------------------
1627 #[test]
1628 fn test_spike_carries_correct_avg_bytes() {
1629 let mut monitor = default_monitor();
1630 // avg_send after 2 samples of 100 = 100.0
1631 monitor.record("peer", 1, 100, 0);
1632 monitor.record("peer", 2, 100, 0);
1633 let _ = monitor.drain_anomalies();
1634
1635 monitor.record("peer", 3, 400, 0);
1636 let anomalies = monitor.drain_anomalies();
1637 match &anomalies[0] {
1638 BandwidthAnomaly::SendSpike { avg_bytes, .. } => {
1639 assert!(
1640 (avg_bytes - 100.0).abs() < 1e-6,
1641 "avg_bytes should be 100.0, got {}",
1642 avg_bytes
1643 );
1644 }
1645 other => panic!("expected SendSpike, got {:?}", other),
1646 }
1647 }
1648
1649 // -----------------------------------------------------------------------
1650 // 30. MonitorConfig::default() has expected field values
1651 // -----------------------------------------------------------------------
1652 #[test]
1653 fn test_monitor_config_defaults() {
1654 let cfg = MonitorConfig::default();
1655 assert_eq!(cfg.window_size, 60);
1656 assert!((cfg.spike_multiplier - 3.0).abs() < 1e-9);
1657 assert_eq!(cfg.idle_threshold_ticks, 120);
1658 }
1659}