Skip to main content

tale_ndjson/metrics/
collector.rs

1//! Collecting system metrics and maintaining a sliding window.
2//! This will be fun.
3
4use std::time::Duration;
5
6#[derive(Debug, Clone)]
7pub struct MetricsCollector {
8    metrics: ChunkMetrics,
9}
10
11impl Default for MetricsCollector {
12    fn default() -> Self {
13        Self::new()
14    }
15}
16
17impl MetricsCollector {
18    pub fn new() -> Self {
19        Self {
20            metrics: ChunkMetrics::new(),
21        }
22    }
23
24    pub fn chunks_seen(&self) -> usize {
25        self.metrics.chunks_seen
26    }
27
28    pub fn chunk_sizes_avg(&self) -> f64 {
29        self.metrics.chunk_sizes.average()
30    }
31
32    pub fn processing_speed_mbps(&self) -> f64 {
33        self.metrics.processing_speed_mbps()
34    }
35
36    pub fn record_chunk(&mut self, chunk_bytes: usize, elapsed_ms: Duration, line_count: usize) {
37        self.metrics
38            .record_chunk_processing(chunk_bytes, elapsed_ms, line_count)
39    }
40
41    pub fn ready_to_adapt(&self, interval: usize) -> bool {
42        self.metrics.chunks_seen > 0 && self.metrics.chunks_seen % interval == 0 && self.metrics.lines_moving.count >= 3
43    }
44
45    pub fn snapshot(&self) -> &ChunkMetrics {
46        &self.metrics
47    }
48}
49
50/// We want to track how we're doing so we know when to shift down into second
51/// gear and ride the clutch or when we're winding up into high rpms and need to
52/// upshift.
53#[derive(Debug, Clone)]
54pub struct ChunkMetrics {
55    /// Number of chunks processed
56    pub chunks_seen: usize,
57    /// JSON parsing time: megabytes per millisecond
58    pub parsed_per_ms: f64,
59    /// how we're tracking megabytes parsed per ms
60    pub parsed_moving: MovingAverage<20>,
61    /// memory usage in bytes
62    pub memory_bytes: usize,
63    pub memory_moving: MovingAverage<20>,
64    /// Tracking chunk sizes over time
65    pub chunk_sizes: MovingAverage<20>,
66    // The number of lines per chunk we're seeing
67    pub lines_per_chunk: usize,
68    /// Tracking the lines per chunk
69    pub lines_moving: MovingAverage<20>,
70    /// Total bytes processed
71    total_bytes: usize,
72    /// Total time spent processing
73    total_duration: Duration,
74}
75
76impl Default for ChunkMetrics {
77    fn default() -> Self {
78        Self::new()
79    }
80}
81
82impl ChunkMetrics {
83    pub fn new() -> Self {
84        Self {
85            chunks_seen: 0,
86            chunk_sizes: MovingAverage::new(),
87            parsed_per_ms: 0.0,
88            parsed_moving: MovingAverage::new(),
89            memory_bytes: 0,
90            memory_moving: MovingAverage::new(),
91            lines_per_chunk: 0,
92            lines_moving: MovingAverage::new(),
93            total_bytes: 0,
94            total_duration: Duration::new(0, 0),
95        }
96    }
97
98    pub fn record_chunk_processing(&mut self, chunk_size: usize, duration: Duration, lines: usize) {
99        self.chunks_seen += 1;
100        self.lines_per_chunk = lines;
101        self.lines_moving.push(lines as f64);
102
103        // parsed per ms; MB / s
104        let mb_processed = chunk_size as f64 / (1024.0 * 1024.0);
105        let seconds = duration.as_secs_f64();
106        let mbps = if seconds > 0.0 { mb_processed / seconds } else { 0.0 };
107        self.parsed_per_ms = mbps;
108        self.parsed_moving.push(mbps);
109
110        if let Some(stats) = memory_stats::memory_stats() {
111            self.memory_bytes = stats.physical_mem;
112            self.memory_moving.push(stats.physical_mem as f64);
113        }
114
115        // This is not IOwait, but a proxy: If processing is slow relative to
116        // chunk size, we're likely I/O bound. Calculate efficiency metric instead:
117        // We want a 50MB/s baseline.
118        // let expected_parse_time_ms = chunk_size as f64 / 50_000.0;
119        // let actual_time_ms = duration.as_millis() as f64;
120        // let efficiency = (expected_parse_time_ms / actual_time_ms.max(1.0)).min(1.0);
121
122        self.chunk_sizes.push(chunk_size as f64);
123        self.total_bytes += chunk_size;
124        self.total_duration += duration;
125    }
126
127    pub fn overall_throughput_mbps(&self) -> f64 {
128        let total_mb = self.total_bytes as f64 / (1024.0 * 1024.0);
129        let total_secs = self.total_duration.as_secs_f64();
130        if total_secs > 0.0 { total_mb / total_secs } else { 0.0 }
131    }
132
133    pub fn processing_speed_mbps(&self) -> f64 {
134        self.parsed_moving.average()
135    }
136
137    pub fn speed_moving(&self) -> &MovingAverage<20> {
138        &self.parsed_moving
139    }
140
141    pub fn memory_moving(&self) -> &MovingAverage<20> {
142        &self.memory_moving
143    }
144
145    pub fn chunk_sizes(&self) -> &MovingAverage<20> {
146        &self.chunk_sizes
147    }
148
149    /// We check if we should adapt every often. This means "check if we should
150    /// adapt", not "we should definitely adapt".
151    pub fn should_adapt(&self, interval: usize) -> bool {
152        // Adapt every N chunks, but only after we have enough data
153        self.chunks_seen > 0 && self.chunks_seen % interval == 0 && self.lines_moving.count >= 3
154    }
155}
156
157#[derive(Debug, Clone)]
158pub struct MovingAverage<const N: usize> {
159    values: [f64; N],
160    index: usize,
161    count: usize,
162}
163
164impl<const N: usize> Default for MovingAverage<N> {
165    fn default() -> Self {
166        Self::new()
167    }
168}
169
170impl<const N: usize> MovingAverage<N> {
171    pub fn new() -> Self {
172        Self {
173            values: [0.0; N],
174            index: 0,
175            count: 0,
176        }
177    }
178
179    /// Here's some more frequency, Kenneth.
180    pub fn push(&mut self, value: f64) {
181        self.values[self.index] = value;
182        self.index = (self.index + 1) % N; // wrappity bappity
183        if self.count < N {
184            self.count += 1;
185        }
186    }
187
188    /// What's the average of whatever this is, Kenneth?
189    pub fn average(&self) -> f64 {
190        if self.count == 0 {
191            return 0.0;
192        }
193        let sum: f64 = if self.count < N {
194            self.values[0..self.count].iter().sum()
195        } else {
196            self.values.iter().sum()
197        };
198        sum / self.count as f64
199    }
200
201    /// What's the trend, Kenneth?
202    pub fn trend(&self, stable: f64) -> Trend {
203        if self.count < 2 {
204            // We don't have enough data to know.
205            return Trend::Unknown;
206        }
207
208        let mid = self.count / 2;
209
210        // Well, I'm doing this the old-fashioned C way. My apologies..
211        let oldest_idx = if self.count < N {
212            0 // Buffer not full, start at beginning
213        } else {
214            self.index // Buffer full, oldest is at current index
215        };
216
217        let mut ptr = oldest_idx;
218        let mut sum = 0.0;
219        for _i in 0..mid {
220            sum += self.values[ptr];
221            ptr = (ptr + 1) % N
222        }
223        let older = sum / mid as f64;
224
225        ptr = (oldest_idx + mid) % N;
226        let mut sum = 0.0;
227        for _i in 0..mid {
228            sum += self.values[ptr];
229            ptr = (ptr + 1) % N;
230        }
231        let newer = sum / mid as f64;
232
233        // Compare and determine the difference with a threshold.
234        // If newer & older are within 5% of each other, it's stable
235        // If the gap is wider than last time, we're degrading.
236
237        let percent_diff = if older != 0.0 {
238            ((newer - older) / older.abs()) * 100.0
239        } else {
240            0.0
241        };
242
243        if percent_diff.abs() < stable {
244            Trend::Stable
245        } else if percent_diff > 0.0 {
246            Trend::Improving
247        } else {
248            Trend::Degrading
249        }
250    }
251}
252
253#[derive(Debug, Clone, Default)]
254pub enum Trend {
255    Improving,
256    Stable,
257    Degrading,
258    #[default]
259    Unknown,
260}
261
262#[cfg(test)]
263mod test {
264    use super::*;
265
266    #[test]
267    fn metrics_collectors_work() {
268        let mut collector = MetricsCollector::new();
269
270        // Record some chunks
271        collector.record_chunk(8192, Duration::from_millis(10), 100);
272        collector.record_chunk(8192, Duration::from_millis(12), 95);
273        collector.record_chunk(8192, Duration::from_millis(8), 105);
274
275        // Should not be ready yet (interval is 5)
276        assert!(!collector.ready_to_adapt(5));
277
278        collector.record_chunk(8192, Duration::from_millis(11), 98);
279        collector.record_chunk(8192, Duration::from_millis(9), 102);
280
281        // Now should be ready (5 chunks processed)
282        assert!(collector.ready_to_adapt(5));
283
284        // Check metrics are reasonable
285        let snapshot = collector.snapshot();
286        assert!(snapshot.processing_speed_mbps() > 0.0);
287    }
288
289    #[test]
290    fn moving_average_can_average() {
291        let mut avg = MovingAverage::<5>::new();
292        avg.push(1.0);
293        avg.push(2.0);
294        avg.push(3.0);
295        assert_eq!(avg.average(), 2.0);
296
297        avg.push(4.0);
298        avg.push(5.0);
299        assert_eq!(avg.average(), 3.0);
300
301        avg.push(6.0); // Should evict 1.0
302        assert_eq!(avg.average(), 4.0); // (2+3+4+5+6)/5
303    }
304
305    #[test]
306    fn can_keep_up_with_depeche_mode() {
307        let mut avg = MovingAverage::<4>::new();
308
309        // Increasing values
310        avg.push(1.0);
311        avg.push(2.0);
312        avg.push(3.0);
313        avg.push(4.0);
314        assert!(matches!(avg.trend(0.5), Trend::Improving));
315
316        // Stable values
317        let mut avg = MovingAverage::<4>::new();
318        avg.push(5.0);
319        avg.push(5.1);
320        avg.push(4.9);
321        avg.push(5.0);
322        assert!(matches!(avg.trend(4.0), Trend::Stable));
323    }
324
325    #[test]
326    fn can_pass_thresholds() {
327        // Stable
328        let mut avg = MovingAverage::<4>::new();
329        avg.push(50.0);
330        avg.push(51.0);
331        avg.push(49.0);
332        avg.push(50.0);
333        assert!(matches!(avg.trend(5.0), Trend::Stable));
334
335        // This is degrading now
336        let mut avg = MovingAverage::<4>::new();
337        avg.push(50.0);
338        avg.push(51.0);
339        avg.push(49.0);
340        avg.push(50.0);
341        assert!(matches!(avg.trend(0.5), Trend::Degrading));
342    }
343}