//! Time-series history of node-level and per-peer statistics. //! //! Maintains a fast ring (1s × 3600 = 1h) and a slow ring (1m × 1440 = 24h) //! per metric, in daemon memory. Used by the control socket //! `show_stats_history` family and rendered as sparklines / braille plots //! by `fipsctl` and `fipstop`. Lost on restart. //! //! Storage is split between node-level metrics (one ring per metric) and //! per-peer metrics (one map `NodeAddr -> PeerStatsRings`, each holding //! one ring per per-peer metric). Per-peer rings are back-filled with //! NaN on first sight so every peer shares the same time axis with the //! node-level rings. When a peer is absent from a tick, NaN is appended //! to keep alignment. Peers are evicted once they have been absent from //! every tick in the full 24h slow-ring window. //! //! Gap representation: `f64::NAN` for any sample where data is not //! available (new peer back-fill, disconnected peer, MMP not yet //! established, counter reset on link reconnect). NaN is serialized as //! JSON `null` via a custom serializer. use crate::identity::NodeAddr; use serde::{Serialize, Serializer}; use std::collections::{HashMap, HashSet, VecDeque}; use std::str::FromStr; use std::time::{Duration, Instant}; /// Fast-ring capacity: 3600 seconds = 1 hour at 1s resolution. pub const FAST_RING_CAPACITY: usize = 3600; /// Slow-ring capacity: 1440 minutes = 24 hours at 1m resolution. pub const SLOW_RING_CAPACITY: usize = 1440; /// Downsample window: how many fast samples fold into one slow sample. pub const DOWNSAMPLE_FACTOR: usize = 60; /// Evict peers that have been silent for at least this long. pub const PEER_EVICTION_SECS: u64 = 24 * 3600; /// Node-level metrics tracked in the history. Keep this list in sync /// with `ALL_METRICS` and with the snapshot construction in /// [`StatsHistory::tick`]. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)] #[serde(rename_all = "snake_case")] pub enum Metric { MeshSize, TreeDepth, PeerCount, ParentSwitches, BytesIn, BytesOut, PacketsIn, PacketsOut, LossRate, ActiveSessions, } /// Every node-level metric tracked, in a stable order (for enumeration /// via `stats list` and for Graphs-tab cycling). pub const ALL_METRICS: &[Metric] = &[ Metric::MeshSize, Metric::TreeDepth, Metric::PeerCount, Metric::ParentSwitches, Metric::BytesIn, Metric::BytesOut, Metric::PacketsIn, Metric::PacketsOut, Metric::LossRate, Metric::ActiveSessions, ]; /// Per-peer metrics tracked in the history (one ring per metric, per peer). /// Names collide with some `Metric` variants because the two live in /// separate namespaces on the wire — a query is per-peer iff `peer` is /// specified in the request. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)] #[serde(rename_all = "snake_case")] pub enum PeerMetric { SrttMs, LossRate, BytesIn, BytesOut, PacketsIn, PacketsOut, EcnCe, } pub const ALL_PEER_METRICS: &[PeerMetric] = &[ PeerMetric::SrttMs, PeerMetric::LossRate, PeerMetric::BytesIn, PeerMetric::BytesOut, PeerMetric::PacketsIn, PeerMetric::PacketsOut, PeerMetric::EcnCe, ]; /// How a metric reduces a window of fast samples into one slow sample. /// NaN samples are excluded from all reductions; a window of entirely /// NaN samples produces NaN. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Aggregation { /// Keep the last non-NaN value. Last, /// Sum non-NaN values. Sum, /// Mean of non-NaN values. Mean, } impl Metric { pub fn name(self) -> &'static str { match self { Metric::MeshSize => "mesh_size", Metric::TreeDepth => "tree_depth", Metric::PeerCount => "peer_count", Metric::ParentSwitches => "parent_switches", Metric::BytesIn => "bytes_in", Metric::BytesOut => "bytes_out", Metric::PacketsIn => "packets_in", Metric::PacketsOut => "packets_out", Metric::LossRate => "loss_rate", Metric::ActiveSessions => "active_sessions", } } pub fn unit(self) -> &'static str { match self { Metric::MeshSize => "nodes", Metric::TreeDepth => "hops", Metric::PeerCount => "peers", Metric::ParentSwitches => "events/s", Metric::BytesIn | Metric::BytesOut => "bytes/s", Metric::PacketsIn | Metric::PacketsOut => "packets/s", Metric::LossRate => "fraction", Metric::ActiveSessions => "sessions", } } pub fn aggregation(self) -> Aggregation { match self { Metric::MeshSize | Metric::TreeDepth | Metric::PeerCount | Metric::ActiveSessions => { Aggregation::Last } Metric::ParentSwitches => Aggregation::Sum, Metric::BytesIn | Metric::BytesOut | Metric::PacketsIn | Metric::PacketsOut | Metric::LossRate => Aggregation::Mean, } } } impl FromStr for Metric { type Err = String; fn from_str(s: &str) -> Result { for m in ALL_METRICS { if m.name() == s { return Ok(*m); } } Err(format!("unknown metric: {s}")) } } impl PeerMetric { pub fn name(self) -> &'static str { match self { PeerMetric::SrttMs => "srtt_ms", PeerMetric::LossRate => "loss_rate", PeerMetric::BytesIn => "bytes_in", PeerMetric::BytesOut => "bytes_out", PeerMetric::PacketsIn => "packets_in", PeerMetric::PacketsOut => "packets_out", PeerMetric::EcnCe => "ecn_ce", } } pub fn unit(self) -> &'static str { match self { PeerMetric::SrttMs => "ms", PeerMetric::LossRate => "fraction", PeerMetric::BytesIn | PeerMetric::BytesOut => "bytes/s", PeerMetric::PacketsIn | PeerMetric::PacketsOut => "packets/s", PeerMetric::EcnCe => "events/s", } } pub fn aggregation(self) -> Aggregation { match self { PeerMetric::SrttMs => Aggregation::Mean, PeerMetric::LossRate => Aggregation::Mean, PeerMetric::BytesIn | PeerMetric::BytesOut | PeerMetric::PacketsIn | PeerMetric::PacketsOut => Aggregation::Mean, PeerMetric::EcnCe => Aggregation::Sum, } } /// Whether this metric is derived from a monotonic counter (sample = /// delta per tick, reset to NaN if the counter decreases). pub fn is_counter(self) -> bool { matches!( self, PeerMetric::BytesIn | PeerMetric::BytesOut | PeerMetric::PacketsIn | PeerMetric::PacketsOut | PeerMetric::EcnCe ) } } impl FromStr for PeerMetric { type Err = String; fn from_str(s: &str) -> Result { for m in ALL_PEER_METRICS { if m.name() == s { return Ok(*m); } } Err(format!("unknown peer metric: {s}")) } } /// Snapshot of raw node-level counter state used to derive per-tick /// samples. Produced by `Node` and passed into [`StatsHistory::tick`]. #[derive(Clone, Copy, Debug)] pub struct Snapshot { pub mesh_size: Option, pub tree_depth: u32, pub peer_count: u64, pub parent_switches_total: u64, pub bytes_in_total: u64, pub bytes_out_total: u64, pub packets_in_total: u64, pub packets_out_total: u64, pub loss_rate: f64, pub active_sessions: u64, } /// Snapshot of one peer's state at the current tick. An entry missing /// from the `peers` slice of [`StatsHistory::tick`] is treated as "peer /// absent this tick" and backs NaN into each of its rings. #[derive(Clone, Debug)] pub struct PeerSnapshot { pub node_addr: NodeAddr, pub last_seen: Instant, /// MMP SRTT; `None` when no MMP measurement exists yet. pub srtt_ms: Option, /// MMP loss rate; `None` when the peer has no MMP session yet. pub loss_rate: Option, /// Monotonic counters. May decrease when the peer reconnects on a /// new link (fresh LinkStats); that's detected per-ring and emits /// NaN for the affected tick. pub bytes_in_total: u64, pub bytes_out_total: u64, pub packets_in_total: u64, pub packets_out_total: u64, pub ecn_ce_total: u64, } /// Which ring a query reads from. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Granularity { /// 1-second samples from the fast ring. Fast, /// 1-minute samples from the slow ring. Slow, } impl Granularity { pub fn seconds(self) -> u64 { match self { Granularity::Fast => 1, Granularity::Slow => 60, } } } impl FromStr for Granularity { type Err = String; fn from_str(s: &str) -> Result { match s { "1s" => Ok(Granularity::Fast), "1m" => Ok(Granularity::Slow), other => Err(format!("unknown granularity: {other} (expected 1s or 1m)")), } } } /// One metric's dual-tier ring. #[derive(Clone)] struct Ring { fast: VecDeque, slow: VecDeque, /// Accumulator used for downsampling fast → slow on minute boundaries. accum: DownsampleAccum, aggregation: Aggregation, /// Value from the previous tick, used to derive deltas for counter /// metrics. `None` means "first sample upcoming" and produces NaN. prev_total: Option, } /// Running accumulator over up to `DOWNSAMPLE_FACTOR` fast samples. /// NaN samples are skipped from all statistics; `total` still tracks /// them so we know whether ANY sample arrived this window. #[derive(Clone)] struct DownsampleAccum { sum: f64, /// Count of non-NaN samples. count: u32, /// Most recent non-NaN sample, or NaN if none. last: f64, /// Total samples observed (including NaN). total: u32, } impl DownsampleAccum { fn new() -> Self { Self { sum: 0.0, count: 0, last: f64::NAN, total: 0, } } fn push(&mut self, v: f64) { self.total += 1; if !v.is_nan() { self.sum += v; self.count += 1; self.last = v; } } fn reduce(&self, agg: Aggregation) -> Option { if self.total == 0 { return None; } if self.count == 0 { return Some(f64::NAN); } Some(match agg { Aggregation::Last => self.last, Aggregation::Sum => self.sum, Aggregation::Mean => self.sum / self.count as f64, }) } fn reset(&mut self) { *self = Self::new(); } } impl Ring { fn new(aggregation: Aggregation) -> Self { Self { fast: VecDeque::with_capacity(FAST_RING_CAPACITY), slow: VecDeque::with_capacity(SLOW_RING_CAPACITY), accum: DownsampleAccum::new(), aggregation, prev_total: None, } } fn push_fast(&mut self, value: f64) { if self.fast.len() == FAST_RING_CAPACITY { self.fast.pop_front(); } self.fast.push_back(value); self.accum.push(value); } fn flush_slow(&mut self) { if let Some(v) = self.accum.reduce(self.aggregation) { if self.slow.len() == SLOW_RING_CAPACITY { self.slow.pop_front(); } self.slow.push_back(v); } self.accum.reset(); } } /// Helper: convert a monotonic counter into a per-tick delta. Returns /// NaN when no previous sample exists (first observation) or when the /// counter decreased (new link). Updates `prev_total` on every call so /// the next tick's baseline is the current value. fn delta_or_nan(ring: &mut Ring, total: u64) -> f64 { let prev = ring.prev_total; ring.prev_total = Some(total); match prev { None => f64::NAN, Some(p) if total < p => f64::NAN, Some(p) => (total - p) as f64, } } /// Custom serializer: NaN / infinity → JSON `null`; finite values pass /// through as numbers. fn serialize_nan_as_null(values: &[f64], s: S) -> Result { use serde::ser::SerializeSeq; let mut seq = s.serialize_seq(Some(values.len()))?; for &v in values { if v.is_finite() { seq.serialize_element(&v)?; } else { seq.serialize_element(&Option::::None)?; } } seq.end() } /// Query result — a contiguous series of samples newest-last. /// Gap samples are NaN in memory and `null` in JSON. #[derive(Clone, Debug, Serialize)] pub struct Series { pub metric: &'static str, pub unit: &'static str, pub granularity_seconds: u64, #[serde(serialize_with = "serialize_nan_as_null")] pub values: Vec, } /// One peer's per-metric rings plus lifecycle metadata. #[derive(Clone)] pub struct PeerStatsRings { rings: Vec, first_seen: Instant, last_contact: Instant, } impl PeerStatsRings { fn new(now: Instant, fast_pushes_so_far: u64) -> Self { let mut rings: Vec = ALL_PEER_METRICS .iter() .map(|m| Ring::new(m.aggregation())) .collect(); // Back-fill NaN so this peer's rings share a time axis with the // node-level rings that have been collecting since start. We // fill up to (but not including) the slot this tick will take. let n_fast = (fast_pushes_so_far as usize).min(FAST_RING_CAPACITY); let n_slow = ((fast_pushes_so_far as usize) / DOWNSAMPLE_FACTOR).min(SLOW_RING_CAPACITY); for ring in &mut rings { for _ in 0..n_fast { ring.fast.push_back(f64::NAN); } for _ in 0..n_slow { ring.slow.push_back(f64::NAN); } } Self { rings, first_seen: now, last_contact: now, } } fn ring(&self, metric: PeerMetric) -> &Ring { let idx = ALL_PEER_METRICS.iter().position(|m| *m == metric).unwrap(); &self.rings[idx] } fn ring_mut(&mut self, metric: PeerMetric) -> &mut Ring { let idx = ALL_PEER_METRICS.iter().position(|m| *m == metric).unwrap(); &mut self.rings[idx] } fn push_sample(&mut self, snap: &PeerSnapshot, now: Instant) { self.last_contact = now; for &metric in ALL_PEER_METRICS { let value = match metric { PeerMetric::SrttMs => snap.srtt_ms.unwrap_or(f64::NAN), PeerMetric::LossRate => snap.loss_rate.unwrap_or(f64::NAN), PeerMetric::BytesIn => delta_or_nan(self.ring_mut(metric), snap.bytes_in_total), PeerMetric::BytesOut => delta_or_nan(self.ring_mut(metric), snap.bytes_out_total), PeerMetric::PacketsIn => delta_or_nan(self.ring_mut(metric), snap.packets_in_total), PeerMetric::PacketsOut => { delta_or_nan(self.ring_mut(metric), snap.packets_out_total) } PeerMetric::EcnCe => delta_or_nan(self.ring_mut(metric), snap.ecn_ce_total), }; self.ring_mut(metric).push_fast(value); } } /// Push NaN for every ring (peer was absent this tick). Also clears /// the counter baseline so the next real sample produces NaN rather /// than an inflated delta accumulated over the silence. fn push_nan(&mut self) { for (i, ring) in self.rings.iter_mut().enumerate() { ring.push_fast(f64::NAN); if ALL_PEER_METRICS[i].is_counter() { ring.prev_total = None; } } } fn flush_slow(&mut self) { for ring in &mut self.rings { ring.flush_slow(); } } pub fn first_seen(&self) -> Instant { self.first_seen } pub fn last_contact(&self) -> Instant { self.last_contact } } /// Per-metric ring storage for node-level metrics plus a map of /// per-peer rings keyed by `NodeAddr`. #[derive(Clone)] pub struct StatsHistory { rings: Vec, peers: HashMap, /// Wall-clock anchor for 1-minute downsample boundaries. Set on the /// first tick; downsample fires when elapsed since the anchor crosses /// a multiple of 60s (coarsely — we just count fast pushes). fast_pushes: u64, /// Monotonic timestamp of the most recent tick, used by readers that /// want to label the series in wall-clock terms. last_tick: Option, } impl StatsHistory { pub fn new() -> Self { let rings = ALL_METRICS .iter() .map(|m| Ring::new(m.aggregation())) .collect(); Self { rings, peers: HashMap::new(), fast_pushes: 0, last_tick: None, } } fn ring_mut(&mut self, metric: Metric) -> &mut Ring { let idx = ALL_METRICS.iter().position(|m| *m == metric).unwrap(); &mut self.rings[idx] } fn ring(&self, metric: Metric) -> &Ring { let idx = ALL_METRICS.iter().position(|m| *m == metric).unwrap(); &self.rings[idx] } /// Record one tick. Should be invoked once per second from the node /// event loop, passing the latest snapshot and the set of peers /// observed this tick. /// /// Derives per-second rates from delta on counter totals; gauges /// are sampled directly. Every 60 pushes, the accumulator is /// flushed to the slow ring. Peers that have been absent for the /// full eviction window are dropped from the map. pub fn tick(&mut self, now: Instant, snapshot: &Snapshot, peers: &[PeerSnapshot]) { // Node-level metrics. for &metric in ALL_METRICS { let value = match metric { Metric::MeshSize => snapshot.mesh_size.unwrap_or(0) as f64, Metric::TreeDepth => snapshot.tree_depth as f64, Metric::PeerCount => snapshot.peer_count as f64, Metric::ParentSwitches => { Self::node_delta(self.ring_mut(metric), snapshot.parent_switches_total) } Metric::BytesIn => Self::node_delta(self.ring_mut(metric), snapshot.bytes_in_total), Metric::BytesOut => { Self::node_delta(self.ring_mut(metric), snapshot.bytes_out_total) } Metric::PacketsIn => { Self::node_delta(self.ring_mut(metric), snapshot.packets_in_total) } Metric::PacketsOut => { Self::node_delta(self.ring_mut(metric), snapshot.packets_out_total) } Metric::LossRate => snapshot.loss_rate, Metric::ActiveSessions => snapshot.active_sessions as f64, }; self.ring_mut(metric).push_fast(value); } // Per-peer metrics. let mut seen: HashSet = HashSet::with_capacity(peers.len()); for ps in peers { seen.insert(ps.node_addr); let entry = self .peers .entry(ps.node_addr) .or_insert_with(|| PeerStatsRings::new(now, self.fast_pushes)); entry.push_sample(ps, now); } for (addr, rings) in self.peers.iter_mut() { if !seen.contains(addr) { rings.push_nan(); } } self.fast_pushes += 1; if self.fast_pushes.is_multiple_of(DOWNSAMPLE_FACTOR as u64) { for ring in &mut self.rings { ring.flush_slow(); } for rings in self.peers.values_mut() { rings.flush_slow(); } } // Evict peers silent for at least PEER_EVICTION_SECS. let threshold = Duration::from_secs(PEER_EVICTION_SECS); self.peers .retain(|_, rings| now.duration_since(rings.last_contact) < threshold); self.last_tick = Some(now); } /// Helper: node-level monotonic counter → per-tick delta. Uses /// `saturating_sub` because node totals never reset; the defensive /// saturation matches the pre-per-peer behavior. fn node_delta(ring: &mut Ring, total: u64) -> f64 { let prev = ring.prev_total; ring.prev_total = Some(total); match prev { None => 0.0, Some(p) => total.saturating_sub(p) as f64, } } /// Answer a query for a single node-level metric across a given /// window and granularity. The returned series always has the full /// window width (clipped only to ring capacity); any samples older /// than the ring has seen are front-padded with NaN so each window /// renders at its chosen density. pub fn query(&self, metric: Metric, window: Duration, granularity: Granularity) -> Series { let ring = self.ring(metric); Self::build_series(ring, metric.name(), metric.unit(), window, granularity) } /// Answer a query for one peer's metric. Returns `None` if the peer /// is not tracked. pub fn peer_query( &self, addr: &NodeAddr, metric: PeerMetric, window: Duration, granularity: Granularity, ) -> Option { let rings = self.peers.get(addr)?; Some(Self::build_series( rings.ring(metric), metric.name(), metric.unit(), window, granularity, )) } fn build_series( ring: &Ring, name: &'static str, unit: &'static str, window: Duration, granularity: Granularity, ) -> Series { let (source, capacity): (&VecDeque, usize) = match granularity { Granularity::Fast => (&ring.fast, FAST_RING_CAPACITY), Granularity::Slow => (&ring.slow, SLOW_RING_CAPACITY), }; let want = (window.as_secs() / granularity.seconds()) as usize; let want = want.min(capacity); let take = source.len().min(want); let tail: Vec = source.iter().rev().take(take).rev().copied().collect(); let values = if tail.len() < want { let pad = want - tail.len(); let mut out = Vec::with_capacity(want); out.resize(pad, f64::NAN); out.extend(tail); out } else { tail }; Series { metric: name, unit, granularity_seconds: granularity.seconds(), values, } } /// Most recent node-level value for a metric, reading from the fast /// ring. pub fn latest(&self, metric: Metric) -> Option { self.ring(metric).fast.back().copied() } /// Return the last `n` node-level samples from the fast ring, /// oldest-first. pub fn recent(&self, metric: Metric, n: usize) -> Vec { let ring = self.ring(metric); let n = n.min(ring.fast.len()); ring.fast.iter().rev().take(n).rev().copied().collect() } /// Iterate tracked peer addresses. pub fn peer_addrs(&self) -> impl Iterator { self.peers.keys() } /// Iterate tracked peers with their ring metadata. pub fn peers(&self) -> impl Iterator { self.peers.iter() } /// Number of tracked peers (includes recently-disconnected within /// the 24h retention window). pub fn tracked_peer_count(&self) -> usize { self.peers.len() } /// Whether this peer is currently in the tracking map (has been /// seen at some point and not yet evicted). pub fn has_peer(&self, addr: &NodeAddr) -> bool { self.peers.contains_key(addr) } /// Whether tick() has ever been called. pub fn has_data(&self) -> bool { self.last_tick.is_some() } } impl Default for StatsHistory { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; fn make_snap(t: u64) -> Snapshot { Snapshot { mesh_size: Some(10 + t), tree_depth: 2, peer_count: 3, parent_switches_total: t, bytes_in_total: 100 * t, bytes_out_total: 200 * t, packets_in_total: t, packets_out_total: 2 * t, loss_rate: 0.01 * t as f64, active_sessions: t, } } fn make_addr(tag: u8) -> NodeAddr { NodeAddr::from_bytes([tag; 16]) } fn make_peer_snap(tag: u8, now: Instant, t: u64) -> PeerSnapshot { PeerSnapshot { node_addr: make_addr(tag), last_seen: now, srtt_ms: Some(10.0 + t as f64), loss_rate: Some(0.01 * t as f64), bytes_in_total: 50 * t, bytes_out_total: 75 * t, packets_in_total: t, packets_out_total: 2 * t, ecn_ce_total: 0, } } #[test] fn push_and_query_fast_ring() { let mut h = StatsHistory::new(); let t0 = Instant::now(); for i in 0..10 { h.tick(t0 + Duration::from_secs(i), &make_snap(i), &[]); } let s = h.query(Metric::MeshSize, Duration::from_secs(5), Granularity::Fast); assert_eq!(s.values.len(), 5); assert_eq!(s.values, vec![15.0, 16.0, 17.0, 18.0, 19.0]); assert_eq!(s.granularity_seconds, 1); } #[test] fn fast_ring_wraps_at_capacity() { let mut h = StatsHistory::new(); let t0 = Instant::now(); for i in 0..3610u64 { h.tick(t0 + Duration::from_secs(i), &make_snap(i), &[]); } let s = h.query( Metric::MeshSize, Duration::from_secs(FAST_RING_CAPACITY as u64 * 2), Granularity::Fast, ); assert_eq!(s.values.len(), FAST_RING_CAPACITY); assert_eq!(s.values[0], 20.0); assert_eq!(*s.values.last().unwrap(), 3619.0); } #[test] fn delta_for_counter_metric() { let mut h = StatsHistory::new(); let t0 = Instant::now(); let totals = [0, 0, 2, 5]; for (i, &v) in totals.iter().enumerate() { let mut s = make_snap(i as u64); s.parent_switches_total = v; h.tick(t0 + Duration::from_secs(i as u64), &s, &[]); } let s = h.query( Metric::ParentSwitches, Duration::from_secs(10), Granularity::Fast, ); assert_eq!(s.values.len(), 10); assert!(s.values[..6].iter().all(|v| v.is_nan())); assert_eq!(s.values[6..], [0.0, 0.0, 2.0, 3.0]); } #[test] fn downsample_last_aggregation() { let mut h = StatsHistory::new(); let t0 = Instant::now(); for i in 0..60u64 { h.tick(t0 + Duration::from_secs(i), &make_snap(i), &[]); } let s = h.query( Metric::MeshSize, Duration::from_secs(60 * 5), Granularity::Slow, ); assert_eq!(s.values.len(), 5); assert!(s.values[..4].iter().all(|v| v.is_nan())); assert_eq!(s.values[4], 69.0); assert_eq!(s.granularity_seconds, 60); } #[test] fn downsample_mean_aggregation() { let mut h = StatsHistory::new(); let t0 = Instant::now(); for i in 0..60u64 { h.tick(t0 + Duration::from_secs(i), &make_snap(i), &[]); } let s = h.query(Metric::LossRate, Duration::from_secs(60), Granularity::Slow); assert_eq!(s.values.len(), 1); assert!((s.values[0] - 0.295).abs() < 1e-9); } #[test] fn downsample_sum_aggregation() { let mut h = StatsHistory::new(); let t0 = Instant::now(); for i in 0..60u64 { let mut s = make_snap(0); s.parent_switches_total = i; h.tick(t0 + Duration::from_secs(i), &s, &[]); } let s = h.query( Metric::ParentSwitches, Duration::from_secs(60), Granularity::Slow, ); assert_eq!(s.values.len(), 1); assert_eq!(s.values[0], 59.0); } #[test] fn query_pads_front_with_nan_when_ring_is_short() { let mut h = StatsHistory::new(); let t0 = Instant::now(); for i in 0..3u64 { h.tick(t0 + Duration::from_secs(i), &make_snap(i), &[]); } let s = h.query(Metric::MeshSize, Duration::from_secs(10), Granularity::Fast); assert_eq!(s.values.len(), 10); assert!(s.values[..7].iter().all(|v| v.is_nan())); assert_eq!(s.values[7..], [10.0, 11.0, 12.0]); } #[test] fn fast_query_young_ring_returns_full_hour_with_leading_nan() { let mut h = StatsHistory::new(); let t0 = Instant::now(); // 5 minutes of data. for i in 0..300u64 { h.tick(t0 + Duration::from_secs(i), &make_snap(i), &[]); } let s = h.query( Metric::MeshSize, Duration::from_secs(3600), Granularity::Fast, ); assert_eq!(s.values.len(), 3600); assert!(s.values[..3300].iter().all(|v| v.is_nan())); assert_eq!(s.values[3300], 10.0); assert_eq!(*s.values.last().unwrap(), 309.0); } #[test] fn slow_query_young_ring_returns_full_day_with_leading_nan() { let mut h = StatsHistory::new(); let t0 = Instant::now(); // 30 minutes of data → 30 slow samples flushed. for i in 0u64..1800 { h.tick(t0 + Duration::from_secs(i), &make_snap(i), &[]); } let s = h.query( Metric::MeshSize, Duration::from_secs(24 * 3600), Granularity::Slow, ); assert_eq!(s.values.len(), 1440); assert!(s.values[..1410].iter().all(|v| v.is_nan())); assert!(s.values[1410..].iter().all(|v| !v.is_nan())); } #[test] fn metric_parse_roundtrip() { for m in ALL_METRICS { assert_eq!(Metric::from_str(m.name()).unwrap(), *m); } assert!(Metric::from_str("bogus").is_err()); } #[test] fn peer_metric_parse_roundtrip() { for m in ALL_PEER_METRICS { assert_eq!(PeerMetric::from_str(m.name()).unwrap(), *m); } assert!(PeerMetric::from_str("bogus").is_err()); } #[test] fn granularity_parse() { assert_eq!(Granularity::from_str("1s").unwrap(), Granularity::Fast); assert_eq!(Granularity::from_str("1m").unwrap(), Granularity::Slow); assert!(Granularity::from_str("1h").is_err()); } #[test] fn latest_and_recent() { let mut h = StatsHistory::new(); let t0 = Instant::now(); for i in 0..5u64 { h.tick(t0 + Duration::from_secs(i), &make_snap(i), &[]); } assert_eq!(h.latest(Metric::MeshSize), Some(14.0)); let r = h.recent(Metric::MeshSize, 3); assert_eq!(r, vec![12.0, 13.0, 14.0]); let r2 = h.recent(Metric::MeshSize, 100); assert_eq!(r2.len(), 5); } #[test] fn active_sessions_is_sampled_as_gauge() { let mut h = StatsHistory::new(); let t0 = Instant::now(); for i in 0..3u64 { let mut s = make_snap(i); s.active_sessions = 10 + i; h.tick(t0 + Duration::from_secs(i), &s, &[]); } let s = h.query( Metric::ActiveSessions, Duration::from_secs(5), Granularity::Fast, ); assert_eq!(s.values.len(), 5); assert!(s.values[..2].iter().all(|v| v.is_nan())); assert_eq!(s.values[2..], [10.0, 11.0, 12.0]); } #[test] fn new_peer_backfills_nan_to_align_with_node_rings() { let mut h = StatsHistory::new(); let t0 = Instant::now(); // Tick 5 times with no peers (node rings fill up). for i in 0..5u64 { h.tick(t0 + Duration::from_secs(i), &make_snap(i), &[]); } // Peer A joins on tick 6. let a = make_addr(1); h.tick( t0 + Duration::from_secs(5), &make_snap(5), &[make_peer_snap(1, t0 + Duration::from_secs(5), 5)], ); // A's srtt ring has 5 NaN backfill + 1 real = 6 samples. A 60s // window front-pads with 54 more NaN so the real value lands at // the tail. let s = h .peer_query( &a, PeerMetric::SrttMs, Duration::from_secs(60), Granularity::Fast, ) .unwrap(); assert_eq!(s.values.len(), 60); assert!(s.values[..59].iter().all(|v| v.is_nan())); assert_eq!(s.values[59], 15.0); } #[test] fn absent_peer_gets_nan_sample() { let mut h = StatsHistory::new(); let t0 = Instant::now(); let a = make_addr(1); // Tick 3 times with A present. for i in 0..3u64 { h.tick( t0 + Duration::from_secs(i), &make_snap(i), &[make_peer_snap(1, t0 + Duration::from_secs(i), i)], ); } // A disappears for 2 ticks. for i in 3..5u64 { h.tick(t0 + Duration::from_secs(i), &make_snap(i), &[]); } let s = h .peer_query( &a, PeerMetric::SrttMs, Duration::from_secs(60), Granularity::Fast, ) .unwrap(); assert_eq!(s.values.len(), 60); // 55 NaN front-pad, then 3 real, then 2 NaN (A gone). assert!(s.values[..55].iter().all(|v| v.is_nan())); assert_eq!(s.values[55], 10.0); assert_eq!(s.values[57], 12.0); assert!(s.values[58].is_nan()); assert!(s.values[59].is_nan()); } #[test] fn counter_decrease_emits_nan_and_rebaselines() { let mut h = StatsHistory::new(); let t0 = Instant::now(); let a = make_addr(1); // Three ticks with bytes_in increasing. for (i, total) in [(0u64, 100u64), (1, 200), (2, 300)].iter().copied() { let mut ps = make_peer_snap(1, t0 + Duration::from_secs(i), i); ps.bytes_in_total = total; h.tick(t0 + Duration::from_secs(i), &make_snap(i), &[ps]); } // Fourth tick: bytes_in drops to 50 (link reconnected). let mut ps = make_peer_snap(1, t0 + Duration::from_secs(3), 3); ps.bytes_in_total = 50; h.tick(t0 + Duration::from_secs(3), &make_snap(3), &[ps]); // Fifth tick: bytes_in grows to 80. let mut ps = make_peer_snap(1, t0 + Duration::from_secs(4), 4); ps.bytes_in_total = 80; h.tick(t0 + Duration::from_secs(4), &make_snap(4), &[ps]); let s = h .peer_query( &a, PeerMetric::BytesIn, Duration::from_secs(60), Granularity::Fast, ) .unwrap(); assert_eq!(s.values.len(), 60); // 55 NaN front-pad, then the 5 per-tick samples at the tail. assert!(s.values[..55].iter().all(|v| v.is_nan())); // First real tick has no prev → NaN. assert!(s.values[55].is_nan()); assert_eq!(s.values[56], 100.0); assert_eq!(s.values[57], 100.0); // Decrease → NaN, rebaseline to 50. assert!(s.values[58].is_nan()); // Next delta from new baseline. assert_eq!(s.values[59], 30.0); } #[test] fn peer_eviction_fires_after_24h_of_silence() { let mut h = StatsHistory::new(); let t0 = Instant::now(); let a = make_addr(1); // One real sample for A at t=0. h.tick(t0, &make_snap(0), &[make_peer_snap(1, t0, 0)]); assert!(h.has_peer(&a)); // Keep ticking every minute without A for 24 hours + 1 minute. // (we tick at 60s intervals to avoid building a 24h fast ring) let eviction = Duration::from_secs(PEER_EVICTION_SECS); let mut i = 1u64; loop { let t = t0 + Duration::from_secs(i * 60); h.tick(t, &make_snap(i), &[]); if t.duration_since(t0) >= eviction { break; } i += 1; } assert!(!h.has_peer(&a)); } #[test] fn nan_mean_downsample_skips_nan_samples() { let mut h = StatsHistory::new(); let t0 = Instant::now(); let a = make_addr(1); // 60 ticks alternating present / absent — 30 real SRTT samples // at values 10, 12, 14, ..., 68, mean = 39. for i in 0..60u64 { if i.is_multiple_of(2) { h.tick( t0 + Duration::from_secs(i), &make_snap(i), &[make_peer_snap(1, t0 + Duration::from_secs(i), i)], ); } else { h.tick(t0 + Duration::from_secs(i), &make_snap(i), &[]); } } let s = h .peer_query( &a, PeerMetric::SrttMs, Duration::from_secs(60), Granularity::Slow, ) .unwrap(); assert_eq!(s.values.len(), 1); let expected: f64 = (0..60u64) .filter(|i| i.is_multiple_of(2)) .map(|i| 10.0 + i as f64) .sum::() / 30.0; assert!((s.values[0] - expected).abs() < 1e-9); } #[test] fn all_nan_window_downsamples_to_nan() { let mut h = StatsHistory::new(); let t0 = Instant::now(); let a = make_addr(1); // Introduce A, then silence it for 60+ ticks so one full slow // sample accumulates entirely of NaN. h.tick(t0, &make_snap(0), &[make_peer_snap(1, t0, 0)]); for i in 1..=60u64 { h.tick(t0 + Duration::from_secs(i), &make_snap(i), &[]); } let s = h .peer_query( &a, PeerMetric::SrttMs, Duration::from_secs(60 * 5), Granularity::Slow, ) .unwrap(); // We got one slow sample after 60 fast ticks. First 60 samples // in the fast ring were 1 real + 59 NaN → Last = 10.0. But the // boundary lands at fast_pushes == 60, AFTER pushing tick 59 // (index 59). So the slow window covers fast indices 0..59, i.e. // tick 0 (real) + ticks 1..59 (NaN) → Last = 10.0. Not all-NaN. // // Window is 300s / 60s = 5 slots; ring has 1 slow sample, so 4 // leading NaN from the front-pad and the real value at the tail. // // Let's instead assert that the NEXT slow flush (after another // 60 all-NaN ticks) is NaN. assert_eq!(s.values.len(), 5); assert!(s.values[..4].iter().all(|v| v.is_nan())); assert_eq!(s.values[4], 10.0); for i in 61..=120u64 { h.tick(t0 + Duration::from_secs(i), &make_snap(i), &[]); } let s = h .peer_query( &a, PeerMetric::SrttMs, Duration::from_secs(60 * 5), Granularity::Slow, ) .unwrap(); // 3 leading NaN from front-pad, then 2 real slow samples: the // first Last=10.0, the second a fully-NaN slow window → NaN. assert_eq!(s.values.len(), 5); assert!(s.values[..3].iter().all(|v| v.is_nan())); assert_eq!(s.values[3], 10.0); assert!(s.values[4].is_nan()); } #[test] fn nan_serializes_to_json_null() { let series = Series { metric: "srtt_ms", unit: "ms", granularity_seconds: 1, values: vec![1.0, f64::NAN, 3.0], }; let json = serde_json::to_value(&series).unwrap(); let values = json.get("values").unwrap().as_array().unwrap(); assert!(values[0].is_f64()); assert!(values[1].is_null()); assert!(values[2].is_f64()); } }