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