// Copyright 2024 RustFS Team // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //! Metrics collector for I/O operation tracking and latency analysis. //! //! Provides latency percentile calculation (P50, P95, P99) and automatic //! reporting to the `metrics` crate for OTEL export. use super::performance::PerformanceMetrics; use std::collections::VecDeque; use std::sync::Arc; use std::sync::atomic::{AtomicU32, Ordering}; use std::time::{Duration, Instant}; use tokio::sync::RwLock; /// How many recorded operations elapse between P95/P99 recomputations. /// /// The sliding-window mean (`avg`) is refreshed on every operation — it is O(1) /// and the autotuner reads it — but the percentiles need an O(n log n) sort of /// the window, so they are recomputed at most once per this many operations. /// Both consumers (the autotuner tick and OTEL export) sample on their own /// periodic cadence, never per-IO, so a bounded lag here is invisible to them /// while the per-op sort cost is amortised away. const PERCENTILE_RECOMPUTE_INTERVAL: u32 = 128; /// Maximum wall-clock staleness of the P95/P99 percentiles before a recompute is /// forced regardless of the operation count. /// /// The count throttle alone starves low-IOPS deployments: at a few operations /// per minute it can take hours to accumulate `PERCENTILE_RECOMPUTE_INTERVAL` /// samples, during which the exported p95/p99 stay pinned at their initial `0` /// (or a long-stale value). This time bound guarantees the percentiles refresh /// within a bounded delay even under trickle traffic, while leaving the hot, /// high-IOPS path governed by the cheaper count throttle. const PERCENTILE_RECOMPUTE_MAX_INTERVAL: Duration = Duration::from_secs(10); /// Sliding window of I/O latency samples plus a running sum, so the window mean /// is maintained in O(1) as samples enter and leave. struct LatencyWindow { samples: VecDeque, /// Sum of `samples` in microseconds, kept in step with every push/pop. sum_micros: u128, /// When the P95/P99 percentiles were last recomputed, used to force a /// refresh under low-traffic conditions where the count throttle rarely /// fires. Seeded at construction so the first time-based refresh is measured /// from collector start, not from an absent prior recompute. last_percentile_at: Instant, } /// Metrics collector for tracking I/O operations and computing latency percentiles. /// /// Maintains a sliding window of I/O latency samples and updates P95/P99 metrics. /// Automatically reports to the `metrics` crate for OTEL export. pub struct MetricsCollector { /// The underlying metrics (shared reference) metrics: Arc, /// Sliding window of I/O latency samples (+ running sum) for mean/percentile calculation io_latency: RwLock, /// Maximum number of latency samples to keep max_latency_samples: usize, /// Operations recorded since the last P95/P99 recompute (throttle counter). ops_since_percentile: AtomicU32, } impl MetricsCollector { /// Create a new metrics collector. /// /// # Arguments /// /// * `metrics` - The underlying metrics structure to update /// * `max_latency_samples` - Maximum number of latency samples to keep for percentile calculation pub fn new(metrics: Arc, max_latency_samples: usize) -> Self { Self { metrics, io_latency: RwLock::new(LatencyWindow { samples: VecDeque::new(), sum_micros: 0, last_percentile_at: Instant::now(), }), max_latency_samples, ops_since_percentile: AtomicU32::new(0), } } /// Create a new metrics collector with default settings (1000 max samples). pub fn with_default_max_samples(metrics: Arc) -> Self { Self::new(metrics, 1000) } /// Record an I/O operation with its duration. /// /// This method: /// 1. Updates byte counters in PerformanceMetrics /// 2. Updates operation counters in PerformanceMetrics /// 3. Records latency for P95/P99 calculation /// 4. Reports to the `metrics` crate for OTEL export /// /// # Arguments /// /// * `bytes` - Number of bytes transferred /// * `duration` - Duration of the I/O operation /// * `is_read` - true for read operations, false for writes pub async fn record_io_operation(&self, bytes: u64, duration: Duration, is_read: bool) { // Update byte counters in PerformanceMetrics if is_read { self.metrics.record_bytes_read(bytes); } else { self.metrics.record_bytes_written(bytes); } // Update operation counters in PerformanceMetrics if is_read { self.metrics.record_disk_read(); } else { self.metrics.record_disk_write(); } // Report to metrics crate for OTEL export crate::record_data_transfer(bytes, duration.as_millis() as f64); // Update the sliding window and the O(1) running mean under a short write // lock, and decide — still under the lock, so the count is exact — whether // this op crosses the percentile-recompute throttle. let (mean_us, recompute_percentiles) = { let mut window = self.io_latency.write().await; window.samples.push_back(duration); window.sum_micros += duration.as_micros(); // Keep only the most recent samples (O(1) removal from front). if window.samples.len() > self.max_latency_samples && let Some(old) = window.samples.pop_front() { window.sum_micros -= old.as_micros(); } let len = window.samples.len() as u128; let mean_us = window.sum_micros.checked_div(len).unwrap_or(0) as u64; // Two independent recompute triggers, both evaluated under the lock so // the counter and the timestamp stay consistent: // * count: cheap amortisation for the hot, high-IOPS path; // * time: a staleness ceiling so low-IOPS deployments do not export // an initial/stale 0 for p95/p99 while samples trickle in. // We just pushed a sample, so the window is guaranteed non-empty and a // time-forced recompute always has data to sort — including the first // one, which can fire with far fewer than INTERVAL samples. let now = Instant::now(); let n = self.ops_since_percentile.fetch_add(1, Ordering::Relaxed) + 1; let count_due = n >= PERCENTILE_RECOMPUTE_INTERVAL; let time_due = now.duration_since(window.last_percentile_at) >= PERCENTILE_RECOMPUTE_MAX_INTERVAL; let recompute = count_due || time_due; if recompute { self.ops_since_percentile.store(0, Ordering::Relaxed); window.last_percentile_at = now; } (mean_us, recompute) }; // The mean is cheap and the autotuner reads it, so refresh it every op. self.metrics.avg_io_latency_us.store(mean_us, Ordering::Relaxed); crate::record_io_latency(mean_us as f64 / 1000.0); // Convert to ms // The P95/P99 sort is the expensive part and only feeds OTEL export, so it // is throttled to once per PERCENTILE_RECOMPUTE_INTERVAL operations. if recompute_percentiles { self.update_latency_percentiles().await; } } /// Recompute the P95/P99 latency percentiles from the current window. /// /// This sorts a snapshot of the window (O(n log n)), so it is driven on a /// throttle from the record path rather than per operation. The mean is not /// computed here — it is maintained in O(1) on every `record_io_operation`. async fn update_latency_percentiles(&self) { // Snapshot the window micros under a read lock, then sort outside the lock. let mut sorted: Vec = { let window = self.io_latency.read().await; if window.samples.is_empty() { return; } window.samples.iter().map(|d| d.as_micros()).collect() }; sorted.sort_unstable(); let len = sorted.len(); // Calculate P95 let p95_idx = ((len as f64) * 0.95) as usize; if let Some(&p95) = sorted.get(p95_idx.min(len - 1)) { self.metrics.p95_io_latency_us.store(p95 as u64, Ordering::Relaxed); crate::record_io_latency_p95(p95 as f64 / 1000.0); } // Calculate P99 let p99_idx = ((len as f64) * 0.99) as usize; if let Some(&p99) = sorted.get(p99_idx.min(len - 1)) { self.metrics.p99_io_latency_us.store(p99 as u64, Ordering::Relaxed); crate::record_io_latency_p99(p99 as f64 / 1000.0); } } /// Get the number of recorded latency samples. pub async fn sample_count(&self) -> usize { self.io_latency.read().await.samples.len() } /// Get the maximum number of samples this collector will retain. pub fn max_samples(&self) -> usize { self.max_latency_samples } } #[cfg(test)] mod tests { use super::*; #[test] fn test_collector_creation() { let metrics = Arc::new(PerformanceMetrics::new()); let collector = MetricsCollector::with_default_max_samples(metrics); assert_eq!(collector.max_samples(), 1000); } #[tokio::test] async fn test_record_io_basic() { let metrics = Arc::new(PerformanceMetrics::new()); let collector = MetricsCollector::new(metrics.clone(), 10); collector.record_io_operation(1024, Duration::from_millis(10), true).await; assert_eq!(metrics.total_bytes_read.load(Ordering::Relaxed), 1024); assert_eq!(metrics.disk_read_count.load(Ordering::Relaxed), 1); assert_eq!(collector.sample_count().await, 1); } #[tokio::test] async fn test_latency_percentiles() { let metrics = Arc::new(PerformanceMetrics::new()); let collector = MetricsCollector::new(metrics.clone(), 10); // Record some latencies collector.record_io_operation(0, Duration::from_micros(100), true).await; collector.record_io_operation(0, Duration::from_micros(200), true).await; collector.record_io_operation(0, Duration::from_micros(300), true).await; collector.record_io_operation(0, Duration::from_micros(400), true).await; collector.record_io_operation(0, Duration::from_micros(500), true).await; // The mean is refreshed on every op. let avg = metrics.avg_io_latency_us.load(Ordering::Relaxed); assert_eq!(avg, 300); // (100+200+300+400+500) / 5 // P95/P99 are throttled off the record path; force a recompute to exercise // the percentile math directly. collector.update_latency_percentiles().await; let p95 = metrics.p95_io_latency_us.load(Ordering::Relaxed); let p99 = metrics.p99_io_latency_us.load(Ordering::Relaxed); // P95 should be close to 500 (5th element) // P99 should be 500 (same as max) assert!(p95 >= 400); // Allow some tolerance assert_eq!(p99, 500); } #[tokio::test] async fn test_percentiles_throttled_but_mean_updates_per_op() { let metrics = Arc::new(PerformanceMetrics::new()); let collector = MetricsCollector::new(metrics.clone(), 1000); // Fewer ops than the recompute interval: the mean tracks every op, but the // percentiles must not have been recomputed yet (they stay at their init 0). for _ in 0..(PERCENTILE_RECOMPUTE_INTERVAL - 1) { collector.record_io_operation(0, Duration::from_micros(200), true).await; } assert_eq!(metrics.avg_io_latency_us.load(Ordering::Relaxed), 200); assert_eq!( metrics.p99_io_latency_us.load(Ordering::Relaxed), 0, "percentiles must not be recomputed before the throttle interval" ); // The op that crosses the interval triggers exactly one recompute. collector.record_io_operation(0, Duration::from_micros(200), true).await; assert_eq!(metrics.p99_io_latency_us.load(Ordering::Relaxed), 200); } #[tokio::test] async fn low_frequency_percentiles_recompute_on_time() { let metrics = Arc::new(PerformanceMetrics::new()); let collector = MetricsCollector::new(metrics.clone(), 1000); // Only a handful of ops — far below PERCENTILE_RECOMPUTE_INTERVAL — so the // count throttle alone would never fire and p99 would stay pinned at its // initial 0 (the low-IOPS staleness bug). for _ in 0..5 { collector.record_io_operation(0, Duration::from_micros(200), true).await; } assert_eq!( metrics.p99_io_latency_us.load(Ordering::Relaxed), 0, "count throttle alone must not have recomputed yet" ); // Simulate enough wall-clock time elapsing since the last recompute by // backdating the timestamp past the staleness ceiling. { let mut window = collector.io_latency.write().await; window.last_percentile_at = Instant::now() .checked_sub(PERCENTILE_RECOMPUTE_MAX_INTERVAL + Duration::from_secs(1)) .expect("monotonic clock has enough history to backdate"); } // The next op must force a time-based recompute even though the sample // count is still far below the interval — so p99 is no longer stuck at 0. collector.record_io_operation(0, Duration::from_micros(200), true).await; assert_eq!( metrics.p99_io_latency_us.load(Ordering::Relaxed), 200, "time-based trigger must refresh p99 on low-traffic deployments" ); } #[tokio::test] async fn test_sample_limit() { let metrics = Arc::new(PerformanceMetrics::new()); let collector = MetricsCollector::new(metrics.clone(), 5); // Max 5 samples // Record more than the limit for _ in 0..10 { collector.record_io_operation(0, Duration::from_millis(1), true).await; } // Should only keep 5 samples assert_eq!(collector.sample_count().await, 5); } #[tokio::test] async fn test_read_write_distinction() { let metrics = Arc::new(PerformanceMetrics::new()); let collector = MetricsCollector::new(metrics.clone(), 10); collector.record_io_operation(1024, Duration::from_millis(10), true).await; collector.record_io_operation(2048, Duration::from_millis(5), false).await; assert_eq!(metrics.total_bytes_read.load(Ordering::Relaxed), 1024); assert_eq!(metrics.total_bytes_written.load(Ordering::Relaxed), 2048); assert_eq!(metrics.disk_read_count.load(Ordering::Relaxed), 1); assert_eq!(metrics.disk_write_count.load(Ordering::Relaxed), 1); } }