Skip to main content

nntp_proxy/metrics/
snapshot.rs

1//! Metrics snapshot type and methods
2//!
3//! Contains the immutable `MetricsSnapshot` struct with functional methods
4//! for querying and aggregating metrics across backends.
5
6#![allow(clippy::cast_precision_loss, clippy::float_cmp)] // Snapshot rates are presentation values; tests use exact deterministic fixtures.
7
8// Snapshot rates are presentation/monitoring values, and the tests exercise
9// exact deterministic fixtures rather than fuzzy comparisons.
10
11use super::types::{ActiveConnections, BackendHealthStatus, ErrorRatePercent};
12use crate::types::{BackendId, BackendToClientBytes, ClientToBackendBytes};
13use std::sync::Arc;
14use std::time::Duration;
15
16use super::BackendStats;
17use super::UserStats;
18
19/// Snapshot of current metrics (for display/reporting)
20///
21/// This is an immutable snapshot designed for functional composition.
22/// Created by `MetricsCollector::snapshot()` and enriched with fluent methods.
23///
24/// # Rates
25/// This snapshot contains cumulative counters only. The TUI calculates rates
26/// by taking deltas between snapshots over time.
27///
28/// # Arc Sharing
29/// `backend_stats` is Arc-wrapped to avoid cloning the entire slice when
30/// calculating user rates every TUI frame (4 Hz). This reduces allocations
31/// from O(backends) to O(1) per update.
32#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
33pub struct MetricsSnapshot {
34    pub total_connections: u64,
35    #[serde(skip, default)]
36    pub active_connections: usize,
37    #[serde(skip, default)]
38    pub stateful_sessions: usize,
39    pub client_to_backend_bytes: ClientToBackendBytes,
40    pub backend_to_client_bytes: BackendToClientBytes,
41    #[serde(skip, default)]
42    pub uptime: Duration,
43    pub backend_stats: Arc<[BackendStats]>,
44    pub user_stats: Vec<UserStats>,
45    #[serde(skip, default)]
46    pub cache_entries: u64,
47    #[serde(skip, default)]
48    pub cache_size_bytes: u64,
49    #[serde(skip, default)]
50    pub cache_hit_rate: f64,
51    /// Disk cache statistics (only present when using hybrid cache)
52    #[serde(skip, default)]
53    pub disk_cache: Option<DiskCacheStats>,
54    /// Number of pipelined batches (batches with >1 command)
55    pub pipeline_batches: u64,
56    /// Total commands processed in pipelined batches
57    pub pipeline_commands: u64,
58    /// Total requests enqueued to backend pipeline queues
59    pub pipeline_requests_queued: u64,
60    /// Total requests completed via backend pipeline
61    pub pipeline_requests_completed: u64,
62}
63
64/// Disk cache statistics for hybrid cache mode
65#[derive(Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize)]
66pub struct DiskCacheStats {
67    /// Number of cache hits from disk tier
68    pub disk_hits: u64,
69    /// Disk hit rate (percentage of hits served from disk vs total hits)
70    pub disk_hit_rate: f64,
71    /// Configured disk cache capacity in bytes
72    pub disk_capacity: u64,
73    /// Bytes actually written to disk (from foyer statistics)
74    pub bytes_written: u64,
75    /// Bytes read from disk (from foyer statistics)
76    pub bytes_read: u64,
77    /// Number of write I/O operations
78    pub write_ios: u64,
79    /// Number of read I/O operations
80    pub read_ios: u64,
81}
82
83impl MetricsSnapshot {
84    /// Update backend active connections from pool status
85    ///
86    /// Populates `active_connections` for each backend by querying the connection pool.
87    /// Active connections = created connections - available connections.
88    ///
89    /// This is a fluent method for functional composition: `snapshot.with_pool_status(router)`
90    #[must_use]
91    pub fn with_pool_status(mut self, router: &crate::router::BackendSelector) -> Self {
92        use crate::pool::ConnectionProvider;
93
94        // Get mutable access - clones only if Arc has other refs
95        let backend_stats = Arc::make_mut(&mut self.backend_stats);
96
97        for stats in backend_stats {
98            if let Some(provider) = router.backend_provider(stats.backend_id) {
99                let pool_status = provider.status();
100                // Active = checked out connections. Deadpool grows lazily, so
101                // unopened capacity must not be counted as active work.
102                let active = pool_status
103                    .created
104                    .get()
105                    .saturating_sub(pool_status.available.get());
106                stats.active_connections = ActiveConnections::new(active);
107            }
108        }
109        self
110    }
111
112    /// Format uptime as a human-readable string
113    #[must_use]
114    pub fn format_uptime(&self) -> String {
115        let secs = self.uptime.as_secs();
116        let hours = secs / 3600;
117        let minutes = (secs % 3600) / 60;
118        let seconds = secs % 60;
119
120        if hours > 0 {
121            format!("{hours}h {minutes}m {seconds}s")
122        } else if minutes > 0 {
123            format!("{minutes}m {seconds}s")
124        } else {
125            format!("{seconds}s")
126        }
127    }
128
129    /// Get total bytes transferred (sent + received) across backends
130    ///
131    /// This is a pure calculation method - no side effects.
132    #[must_use]
133    #[inline]
134    pub const fn total_bytes(&self) -> u64 {
135        self.client_to_backend_bytes.as_u64() + self.backend_to_client_bytes.as_u64()
136    }
137
138    /// Calculate throughput in bytes per second
139    ///
140    /// Returns 0.0 if uptime is zero (avoid division by zero).
141    /// This is a pure calculation method - no side effects.
142    #[must_use]
143    pub fn throughput_bps(&self) -> f64 {
144        let secs = self.uptime.as_secs_f64();
145        if secs > 0.0 {
146            self.total_bytes() as f64 / secs
147        } else {
148            0.0
149        }
150    }
151
152    /// Get total number of commands processed across all backends
153    ///
154    /// This is a pure calculation using iterator composition.
155    #[must_use]
156    #[inline]
157    pub fn total_commands(&self) -> u64 {
158        self.backend_stats
159            .iter()
160            .map(|stats| stats.total_commands.get())
161            .sum()
162    }
163
164    /// Get total number of errors across all backends
165    ///
166    /// This is a pure calculation using iterator composition.
167    #[must_use]
168    #[inline]
169    pub fn total_errors(&self) -> u64 {
170        self.backend_stats
171            .iter()
172            .map(|stats| stats.errors.get())
173            .sum()
174    }
175
176    /// Get overall error rate percentage
177    ///
178    /// Returns error rate across all backends combined.
179    /// This is a pure calculation method - no side effects.
180    #[must_use]
181    pub fn error_rate_percent(&self) -> f64 {
182        let total_cmds = self.total_commands();
183        let total_errs = self.total_errors();
184        ErrorRatePercent::from_raw_counts(total_errs, total_cmds).get()
185    }
186
187    /// Get all backends with high error rates
188    ///
189    /// Returns an iterator of backend IDs with error rates > 5%.
190    /// This is a pure calculation using iterator composition.
191    pub fn high_error_backends(&self) -> impl Iterator<Item = BackendId> + '_ {
192        self.backend_stats
193            .iter()
194            .filter(|stats| stats.has_high_error_rate())
195            .map(|stats| stats.backend_id)
196    }
197
198    /// Get all healthy backends
199    ///
200    /// Returns an iterator of backend IDs with Healthy status.
201    /// This is a pure calculation using iterator composition.
202    pub fn healthy_backends(&self) -> impl Iterator<Item = BackendId> + '_ {
203        self.backend_stats
204            .iter()
205            .filter(|stats| stats.health_status == BackendHealthStatus::Healthy)
206            .map(|stats| stats.backend_id)
207    }
208
209    /// Get backend statistics by ID
210    ///
211    /// Returns None if `backend_id` is out of range.
212    #[must_use]
213    pub fn backend(&self, backend_id: BackendId) -> Option<&BackendStats> {
214        self.backend_stats.get(backend_id.as_index())
215    }
216
217    /// Count backends by health status
218    ///
219    /// Returns (healthy, degraded, down) counts.
220    /// This is a pure calculation using iterator composition.
221    #[must_use]
222    pub fn backend_health_counts(&self) -> (usize, usize, usize) {
223        self.backend_stats
224            .iter()
225            .fold((0, 0, 0), |(h, d, dn), stats| match stats.health_status {
226                BackendHealthStatus::Healthy => (h + 1, d, dn),
227                BackendHealthStatus::Degraded => (h, d + 1, dn),
228                BackendHealthStatus::Down => (h, d, dn + 1),
229            })
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::metrics::{
237        ArticleCount, CommandCount, ErrorCount, FailureCount, RecvMicros, SendMicros, TtfbMicros,
238    };
239    use crate::types::BackendId;
240
241    fn create_test_snapshot() -> MetricsSnapshot {
242        use crate::types::{ArticleBytesTotal, BytesReceived, BytesSent, TimingMeasurementCount};
243
244        let backend1 = BackendStats {
245            backend_id: BackendId::from_index(0),
246            total_commands: CommandCount::new(100),
247            errors: ErrorCount::new(5),
248            bytes_sent: BytesSent::new(1000),
249            bytes_received: BytesReceived::new(2000),
250            health_status: BackendHealthStatus::Healthy,
251            active_connections: ActiveConnections::new(3),
252            errors_4xx: ErrorCount::new(2),
253            errors_5xx: ErrorCount::new(3),
254            article_bytes_total: ArticleBytesTotal::new(5000),
255            article_count: ArticleCount::new(10),
256            ttfb_micros_total: TtfbMicros::new(1000),
257            ttfb_count: TimingMeasurementCount::new(10),
258            send_micros_total: SendMicros::new(500),
259            recv_micros_total: RecvMicros::new(1500),
260            connection_failures: FailureCount::new(0),
261        };
262
263        let backend2 = BackendStats {
264            backend_id: BackendId::from_index(1),
265            total_commands: CommandCount::new(50),
266            errors: ErrorCount::new(10),
267            bytes_sent: BytesSent::new(500),
268            bytes_received: BytesReceived::new(1500),
269            health_status: BackendHealthStatus::Degraded,
270            active_connections: ActiveConnections::new(2),
271            errors_4xx: ErrorCount::new(5),
272            errors_5xx: ErrorCount::new(5),
273            article_bytes_total: ArticleBytesTotal::new(2500),
274            article_count: ArticleCount::new(5),
275            ttfb_micros_total: TtfbMicros::new(500),
276            ttfb_count: TimingMeasurementCount::new(5),
277            send_micros_total: SendMicros::new(250),
278            recv_micros_total: RecvMicros::new(750),
279            connection_failures: FailureCount::new(1),
280        };
281
282        MetricsSnapshot {
283            total_connections: 5,
284            active_connections: 5,
285            stateful_sessions: 2,
286            client_to_backend_bytes: ClientToBackendBytes::new(1500),
287            backend_to_client_bytes: BackendToClientBytes::new(3500),
288            uptime: crate::constants::duration_polyfill::from_hours(1),
289            backend_stats: vec![backend1, backend2].into(),
290            user_stats: vec![],
291            cache_entries: 0,
292            cache_size_bytes: 0,
293            cache_hit_rate: 0.0,
294            disk_cache: None,
295            pipeline_batches: 0,
296            pipeline_commands: 0,
297            pipeline_requests_queued: 0,
298            pipeline_requests_completed: 0,
299        }
300    }
301
302    #[test]
303    fn test_format_uptime_hours() {
304        let snapshot = MetricsSnapshot {
305            uptime: Duration::from_secs(3661), // 1h 1m 1s
306            ..Default::default()
307        };
308        assert_eq!(snapshot.format_uptime(), "1h 1m 1s");
309    }
310
311    #[test]
312    fn test_format_uptime_minutes() {
313        let snapshot = MetricsSnapshot {
314            uptime: Duration::from_secs(125), // 2m 5s
315            ..Default::default()
316        };
317        assert_eq!(snapshot.format_uptime(), "2m 5s");
318    }
319
320    #[test]
321    fn test_format_uptime_seconds() {
322        let snapshot = MetricsSnapshot {
323            uptime: Duration::from_secs(42),
324            ..Default::default()
325        };
326        assert_eq!(snapshot.format_uptime(), "42s");
327    }
328
329    #[test]
330    fn test_format_uptime_zero() {
331        let snapshot = MetricsSnapshot {
332            uptime: Duration::from_secs(0),
333            ..Default::default()
334        };
335        assert_eq!(snapshot.format_uptime(), "0s");
336    }
337
338    #[test]
339    fn test_total_bytes() {
340        let snapshot = create_test_snapshot();
341        assert_eq!(snapshot.total_bytes(), 5000); // 1500 + 3500
342    }
343
344    #[test]
345    fn test_total_bytes_zero() {
346        let snapshot = MetricsSnapshot::default();
347        assert_eq!(snapshot.total_bytes(), 0);
348    }
349
350    #[test]
351    fn test_throughput_bps() {
352        let snapshot = create_test_snapshot();
353        let expected = 5000.0 / 3600.0; // total_bytes / uptime_secs
354        assert!((snapshot.throughput_bps() - expected).abs() < 0.01);
355    }
356
357    #[test]
358    fn test_throughput_bps_zero_uptime() {
359        let snapshot = MetricsSnapshot {
360            client_to_backend_bytes: ClientToBackendBytes::new(1000),
361            backend_to_client_bytes: BackendToClientBytes::new(2000),
362            uptime: Duration::from_secs(0),
363            ..Default::default()
364        };
365        assert_eq!(snapshot.throughput_bps(), 0.0);
366    }
367
368    #[test]
369    fn test_total_commands() {
370        let snapshot = create_test_snapshot();
371        assert_eq!(snapshot.total_commands(), 150); // 100 + 50
372    }
373
374    #[test]
375    fn test_total_commands_empty() {
376        let snapshot = MetricsSnapshot::default();
377        assert_eq!(snapshot.total_commands(), 0);
378    }
379
380    #[test]
381    fn test_total_errors() {
382        let snapshot = create_test_snapshot();
383        assert_eq!(snapshot.total_errors(), 15); // 5 + 10
384    }
385
386    #[test]
387    fn test_total_errors_empty() {
388        let snapshot = MetricsSnapshot::default();
389        assert_eq!(snapshot.total_errors(), 0);
390    }
391
392    #[test]
393    fn test_error_rate_percent() {
394        let snapshot = create_test_snapshot();
395        let expected = 15.0 / 150.0 * 100.0; // (total_errors / total_commands) * 100
396        assert!((snapshot.error_rate_percent() - expected).abs() < 0.01);
397    }
398
399    #[test]
400    fn test_error_rate_percent_zero_commands() {
401        let snapshot = MetricsSnapshot::default();
402        assert_eq!(snapshot.error_rate_percent(), 0.0);
403    }
404
405    #[test]
406    fn test_high_error_backends() {
407        let snapshot = create_test_snapshot();
408        let high_error: Vec<_> = snapshot.high_error_backends().collect();
409        // Backend2 has 10/50 = 20% error rate (> 5%)
410        // Backend1 has 5/100 = 5% error rate (not > 5%)
411        assert_eq!(high_error.len(), 1);
412        assert_eq!(high_error[0], BackendId::from_index(1));
413    }
414
415    #[test]
416    fn test_high_error_backends_empty() {
417        let snapshot = MetricsSnapshot::default();
418        assert_eq!(snapshot.high_error_backends().count(), 0);
419    }
420
421    #[test]
422    fn test_healthy_backends() {
423        let snapshot = create_test_snapshot();
424        let healthy: Vec<_> = snapshot.healthy_backends().collect();
425        assert_eq!(healthy.len(), 1);
426        assert_eq!(healthy[0], BackendId::from_index(0));
427    }
428
429    #[test]
430    fn test_healthy_backends_all_down() {
431        let backend = BackendStats {
432            health_status: BackendHealthStatus::Down,
433            ..Default::default()
434        };
435
436        let snapshot = MetricsSnapshot {
437            backend_stats: vec![backend].into(),
438            ..Default::default()
439        };
440
441        assert_eq!(snapshot.healthy_backends().count(), 0);
442    }
443
444    #[test]
445    fn test_backend_by_id() {
446        let snapshot = create_test_snapshot();
447
448        let backend0 = snapshot.backend(BackendId::from_index(0));
449        assert!(backend0.is_some());
450        assert_eq!(backend0.unwrap().backend_id, BackendId::from_index(0));
451        assert_eq!(backend0.unwrap().total_commands.get(), 100);
452
453        let backend1 = snapshot.backend(BackendId::from_index(1));
454        assert!(backend1.is_some());
455        assert_eq!(backend1.unwrap().backend_id, BackendId::from_index(1));
456        assert_eq!(backend1.unwrap().total_commands.get(), 50);
457    }
458
459    #[test]
460    fn test_backend_by_id_out_of_range() {
461        let snapshot = create_test_snapshot();
462        let backend = snapshot.backend(BackendId::from_index(2));
463        assert!(backend.is_none());
464    }
465
466    #[test]
467    fn test_backend_health_counts() {
468        let snapshot = create_test_snapshot();
469        let (healthy, degraded, down) = snapshot.backend_health_counts();
470        assert_eq!(healthy, 1);
471        assert_eq!(degraded, 1);
472        assert_eq!(down, 0);
473    }
474
475    #[test]
476    fn test_backend_health_counts_mixed() {
477        let backends = vec![
478            BackendStats {
479                backend_id: BackendId::from_index(0),
480                health_status: BackendHealthStatus::Healthy,
481                ..Default::default()
482            },
483            BackendStats {
484                backend_id: BackendId::from_index(1),
485                health_status: BackendHealthStatus::Healthy,
486                ..Default::default()
487            },
488            BackendStats {
489                backend_id: BackendId::from_index(2),
490                health_status: BackendHealthStatus::Down,
491                ..Default::default()
492            },
493        ];
494
495        let snapshot = MetricsSnapshot {
496            backend_stats: backends.into(),
497            ..Default::default()
498        };
499
500        let (healthy, degraded, down) = snapshot.backend_health_counts();
501        assert_eq!(healthy, 2);
502        assert_eq!(degraded, 0);
503        assert_eq!(down, 1);
504    }
505
506    #[test]
507    fn test_backend_health_counts_empty() {
508        let snapshot = MetricsSnapshot::default();
509        let (healthy, degraded, down) = snapshot.backend_health_counts();
510        assert_eq!(healthy, 0);
511        assert_eq!(degraded, 0);
512        assert_eq!(down, 0);
513    }
514
515    #[test]
516    fn with_pool_status_does_not_count_unopened_capacity_as_active() {
517        use crate::pool::DeadpoolConnectionProvider;
518        use crate::router::BackendSelector;
519        use crate::types::{BackendId, ServerName};
520
521        let provider = DeadpoolConnectionProvider::builder("127.0.0.1", 9)
522            .name("unused")
523            .max_connections(50)
524            .build()
525            .expect("provider should build without connecting");
526
527        let mut router = BackendSelector::new();
528        router.add_backend(
529            ServerName::try_new("unused".to_string()).unwrap(),
530            provider,
531            1,
532        );
533
534        let snapshot = MetricsSnapshot {
535            backend_stats: vec![BackendStats {
536                backend_id: BackendId::from_index(0),
537                ..Default::default()
538            }]
539            .into(),
540            ..Default::default()
541        }
542        .with_pool_status(&router);
543
544        assert_eq!(snapshot.backend_stats[0].active_connections.get(), 0);
545    }
546
547    #[test]
548    fn test_snapshot_default() {
549        let snapshot = MetricsSnapshot::default();
550        assert_eq!(snapshot.total_connections, 0);
551        assert_eq!(snapshot.active_connections, 0);
552        assert_eq!(snapshot.stateful_sessions, 0);
553        assert_eq!(snapshot.total_bytes(), 0);
554        assert_eq!(snapshot.uptime, Duration::from_secs(0));
555        assert_eq!(snapshot.backend_stats.len(), 0);
556        assert_eq!(snapshot.user_stats.len(), 0);
557    }
558
559    #[test]
560    fn test_snapshot_clone() {
561        let snapshot = create_test_snapshot();
562        let cloned = snapshot.clone();
563
564        assert_eq!(snapshot.total_connections, cloned.total_connections);
565        assert_eq!(snapshot.total_bytes(), cloned.total_bytes());
566        assert_eq!(snapshot.uptime, cloned.uptime);
567
568        // Arc should share the same allocation
569        assert!(Arc::ptr_eq(&snapshot.backend_stats, &cloned.backend_stats));
570    }
571}