Skip to main content

batch_aint_one/
metrics.rs

1//! Metrics recording for batcher observability.
2
3use std::{fmt::Debug, time::Duration};
4
5/// Records metrics about batcher activity.
6///
7/// Implement this trait to bridge batcher metrics to your metrics system (e.g. Prometheus,
8/// OpenTelemetry). All methods have default no-op implementations, so you only need to override
9/// the ones you care about.
10///
11/// All methods are called synchronously on the background worker's event loop, so implementations
12/// should be cheap and non-blocking.
13pub trait MetricsRecorder: Debug + Send + Sync + 'static {
14    /// An item was received by the worker.
15    ///
16    /// `channel_duration` is the time the item spent waiting in the channel between submission
17    /// and the worker picking it up.
18    fn item_received(&self, _channel_duration: Duration) {}
19
20    /// An item was rejected because the batch queue for its key is full.
21    fn item_rejected(&self) {}
22
23    /// A batch finished processing and results were sent back to callers.
24    fn batch_completed(&self, _metrics: &BatchStats) {}
25
26    /// Resource acquisition completed.
27    fn resource_acquisition_completed(&self, _duration: Duration, _success: bool) {}
28
29    /// The number of active keys (keys with batch queues) changed.
30    fn active_keys_changed(&self, _count: usize) {}
31
32    /// The total number of batches currently processing changed.
33    ///
34    /// `max_per_key` is the highest concurrency across any single key, useful for detecting
35    /// hotspots without per-key labels.
36    fn processing_concurrency_changed(&self, _total: usize, _max_per_key: usize) {}
37
38    /// The total number of batches queued for processing changed.
39    ///
40    /// `max_per_key` is the deepest queue across any single key, useful for detecting
41    /// saturation without per-key labels.
42    fn queue_depth_changed(&self, _total: usize, _max_per_key: usize) {}
43}
44
45/// Stats for a completed batch.
46#[derive(Debug, Clone)]
47#[non_exhaustive]
48pub struct BatchStats {
49    /// The number of items in the batch.
50    pub size: usize,
51    /// How long the batch took to process (including resource acquisition if not pre-acquired).
52    pub processing_duration: Duration,
53    /// Whether the batch processed successfully.
54    pub success: bool,
55    /// Time from submission to result delivery, for each item in the batch.
56    pub item_latencies: Vec<Duration>,
57    /// Time each item spent in the batch queue before processing started.
58    ///
59    /// Measured from when the worker received the item to when the batch began processing.
60    /// Includes time waiting for the batch to fill, for concurrency capacity, and for
61    /// resource acquisition.
62    pub queue_durations: Vec<Duration>,
63}
64
65impl BatchStats {
66    /// Create a new `BatchStats`.
67    pub fn new(
68        size: usize,
69        processing_duration: Duration,
70        success: bool,
71        item_latencies: Vec<Duration>,
72        queue_durations: Vec<Duration>,
73    ) -> Self {
74        Self {
75            size,
76            processing_duration,
77            success,
78            item_latencies,
79            queue_durations,
80        }
81    }
82}
83
84/// Creates a [`MetricsRecorder`] for a named batcher.
85pub trait MetricsRecorderFactory: Debug + Send + Sync + 'static {
86    /// Create a [`MetricsRecorder`] for the given batcher name.
87    fn create_recorder(&self, batcher_name: &str) -> Box<dyn MetricsRecorder>;
88}
89
90/// A no-op metrics recorder that discards all metrics.
91#[derive(Debug, Clone, Copy)]
92pub(crate) struct NoopMetricsRecorder;
93
94impl MetricsRecorder for NoopMetricsRecorder {}
95
96#[cfg(test)]
97mod tests {
98    use std::sync::{Arc, Mutex};
99    use std::time::Duration;
100
101    use crate::{Batcher, BatchingPolicy, Limits, Processor};
102
103    use super::*;
104
105    #[derive(Debug, Default)]
106    struct TestRecorderInner {
107        items_received: usize,
108        items_rejected: usize,
109        batches_completed: usize,
110        batch_sizes: Vec<usize>,
111        item_latencies: Vec<Duration>,
112        channel_durations: Vec<Duration>,
113        resource_acquisitions: usize,
114        active_keys: Vec<usize>,
115        processing_concurrency: (usize, usize),
116        queue_depth: (usize, usize),
117    }
118
119    #[derive(Debug)]
120    struct TestRecorder(Arc<Mutex<TestRecorderInner>>);
121
122    #[derive(Debug)]
123    struct TestRecorderFactory(Arc<Mutex<TestRecorderInner>>);
124
125    impl MetricsRecorderFactory for TestRecorderFactory {
126        fn create_recorder(&self, _batcher_name: &str) -> Box<dyn MetricsRecorder> {
127            Box::new(TestRecorder(self.0.clone()))
128        }
129    }
130
131    fn test_metrics() -> (Arc<Mutex<TestRecorderInner>>, Box<dyn MetricsRecorderFactory>) {
132        let state = Arc::new(Mutex::new(TestRecorderInner::default()));
133        let factory = Box::new(TestRecorderFactory(state.clone()));
134        (state, factory)
135    }
136
137    impl MetricsRecorder for TestRecorder {
138        fn item_received(&self, channel_duration: Duration) {
139            let mut inner = self.0.lock().unwrap();
140            inner.items_received += 1;
141            inner.channel_durations.push(channel_duration);
142        }
143
144        fn item_rejected(&self) {
145            self.0.lock().unwrap().items_rejected += 1;
146        }
147
148        fn batch_completed(&self, metrics: &BatchStats) {
149            let mut inner = self.0.lock().unwrap();
150            inner.batches_completed += 1;
151            inner.batch_sizes.push(metrics.size);
152            inner
153                .item_latencies
154                .extend_from_slice(&metrics.item_latencies);
155        }
156
157        fn resource_acquisition_completed(&self, _duration: Duration, _success: bool) {
158            self.0.lock().unwrap().resource_acquisitions += 1;
159        }
160
161        fn active_keys_changed(&self, count: usize) {
162            self.0.lock().unwrap().active_keys.push(count);
163        }
164
165        fn processing_concurrency_changed(&self, total: usize, max_per_key: usize) {
166            self.0.lock().unwrap().processing_concurrency = (total, max_per_key);
167        }
168
169        fn queue_depth_changed(&self, total: usize, max_per_key: usize) {
170            self.0.lock().unwrap().queue_depth = (total, max_per_key);
171        }
172    }
173
174    #[derive(Debug, Clone)]
175    struct SimpleProcessor {
176        process_delay: Duration,
177    }
178
179    impl SimpleProcessor {
180        fn instant() -> Self {
181            Self {
182                process_delay: Duration::ZERO,
183            }
184        }
185    }
186
187    impl Processor for SimpleProcessor {
188        type Key = String;
189        type Input = String;
190        type Output = String;
191        type Error = String;
192        type Resources = ();
193
194        async fn acquire_resources(&self, _key: String) -> Result<(), String> {
195            Ok(())
196        }
197
198        async fn process(
199            &self,
200            _key: String,
201            inputs: impl Iterator<Item = String> + Send,
202            _resources: (),
203        ) -> Result<Vec<String>, String> {
204            if self.process_delay > Duration::ZERO {
205                tokio::time::sleep(self.process_delay).await;
206            }
207            Ok(inputs.map(|s| s + " done").collect())
208        }
209    }
210
211    async fn shut_down(batcher: &Batcher<SimpleProcessor>) {
212        let worker = batcher.worker_handle();
213        worker.shut_down().await;
214        tokio::time::timeout(Duration::from_secs(1), worker.wait_for_shutdown())
215            .await
216            .expect("Worker should shut down");
217    }
218
219    #[tokio::test]
220    async fn records_metrics_for_successful_batch() {
221        let (state, factory) = test_metrics();
222
223        let batcher = Batcher::builder()
224            .name("test")
225            .processor(SimpleProcessor::instant())
226            .limits(Limits::builder().max_batch_size(2).build())
227            .batching_policy(BatchingPolicy::Size)
228            .metrics(factory)
229            .build();
230
231        let (r1, r2) = tokio::join!(
232            batcher.add("A".to_string(), "1".to_string()),
233            batcher.add("A".to_string(), "2".to_string()),
234        );
235        assert!(r1.is_ok());
236        assert!(r2.is_ok());
237
238        shut_down(&batcher).await;
239
240        let inner = state.lock().unwrap();
241        assert_eq!(inner.items_received, 2);
242        assert_eq!(inner.batches_completed, 1);
243        assert_eq!(inner.items_rejected, 0);
244        assert_eq!(inner.batch_sizes, vec![2]);
245        assert_eq!(inner.item_latencies.len(), 2);
246        assert!(inner.item_latencies.iter().all(|d| *d > Duration::ZERO));
247        assert_eq!(inner.channel_durations.len(), 2);
248    }
249
250    #[tokio::test(start_paused = true)]
251    async fn records_rejection_metrics() {
252        let (state, factory) = test_metrics();
253
254        let batcher = Batcher::builder()
255            .name("test")
256            .processor(SimpleProcessor {
257                process_delay: Duration::from_millis(100),
258            })
259            .limits(
260                Limits::builder()
261                    .max_batch_size(1)
262                    .max_key_concurrency(1)
263                    .max_batch_queue_size(1)
264                    .build(),
265            )
266            .batching_policy(BatchingPolicy::Size)
267            .metrics(factory)
268            .build();
269
270        // With max_batch_size=1 and max_batch_queue_size=1:
271        // - Item 1 fills a batch and starts processing (slow processor keeps it in flight)
272        // - Item 2 fills the replacement batch (queue is now full)
273        // - Item 3 is rejected because the queue is full
274        let (r1, r2, r3) = tokio::join!(
275            batcher.add("A".to_string(), "1".to_string()),
276            batcher.add("A".to_string(), "2".to_string()),
277            batcher.add("A".to_string(), "3".to_string()),
278        );
279
280        let results = [&r1, &r2, &r3];
281        let successes = results.iter().filter(|r| r.is_ok()).count();
282        let rejections = results.iter().filter(|r| r.is_err()).count();
283        assert_eq!(successes, 2);
284        assert_eq!(rejections, 1);
285
286        shut_down(&batcher).await;
287
288        assert_eq!(state.lock().unwrap().items_rejected, 1);
289    }
290
291    #[tokio::test]
292    async fn records_gauge_metrics() {
293        let (state, factory) = test_metrics();
294
295        let batcher = Batcher::builder()
296            .name("test")
297            .processor(SimpleProcessor::instant())
298            .limits(Limits::builder().max_batch_size(1).build())
299            .batching_policy(BatchingPolicy::Size)
300            .metrics(factory)
301            .build();
302
303        let r = batcher.add("A".to_string(), "1".to_string()).await;
304        assert!(r.is_ok());
305
306        shut_down(&batcher).await;
307
308        let inner = state.lock().unwrap();
309        assert!(!inner.active_keys.is_empty());
310        assert!(inner.active_keys.iter().any(|&c| c > 0));
311        // Key goes idle and is removed after the batch finishes.
312        assert_eq!(*inner.active_keys.last().unwrap(), 0);
313    }
314}