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}
58
59impl BatchStats {
60    /// Create a new `BatchStats`.
61    pub fn new(
62        size: usize,
63        processing_duration: Duration,
64        success: bool,
65        item_latencies: Vec<Duration>,
66    ) -> Self {
67        Self {
68            size,
69            processing_duration,
70            success,
71            item_latencies,
72        }
73    }
74}
75
76/// A no-op metrics recorder that discards all metrics.
77#[derive(Debug, Clone, Copy)]
78pub(crate) struct NoopMetricsRecorder;
79
80impl MetricsRecorder for NoopMetricsRecorder {}
81
82#[cfg(test)]
83mod tests {
84    use std::sync::{Arc, Mutex};
85    use std::time::Duration;
86
87    use crate::{Batcher, BatchingPolicy, Limits, Processor};
88
89    use super::*;
90
91    #[derive(Debug, Default)]
92    struct TestRecorderInner {
93        items_received: usize,
94        items_rejected: usize,
95        batches_completed: usize,
96        batch_sizes: Vec<usize>,
97        item_latencies: Vec<Duration>,
98        channel_durations: Vec<Duration>,
99        resource_acquisitions: usize,
100        active_keys: Vec<usize>,
101        processing_concurrency: (usize, usize),
102        queue_depth: (usize, usize),
103    }
104
105    #[derive(Debug, Default)]
106    struct TestRecorder(Mutex<TestRecorderInner>);
107
108    impl MetricsRecorder for TestRecorder {
109        fn item_received(&self, channel_duration: Duration) {
110            let mut inner = self.0.lock().unwrap();
111            inner.items_received += 1;
112            inner.channel_durations.push(channel_duration);
113        }
114
115        fn item_rejected(&self) {
116            self.0.lock().unwrap().items_rejected += 1;
117        }
118
119        fn batch_completed(&self, metrics: &BatchStats) {
120            let mut inner = self.0.lock().unwrap();
121            inner.batches_completed += 1;
122            inner.batch_sizes.push(metrics.size);
123            inner
124                .item_latencies
125                .extend_from_slice(&metrics.item_latencies);
126        }
127
128        fn resource_acquisition_completed(&self, _duration: Duration, _success: bool) {
129            self.0.lock().unwrap().resource_acquisitions += 1;
130        }
131
132        fn active_keys_changed(&self, count: usize) {
133            self.0.lock().unwrap().active_keys.push(count);
134        }
135
136        fn processing_concurrency_changed(&self, total: usize, max_per_key: usize) {
137            self.0.lock().unwrap().processing_concurrency = (total, max_per_key);
138        }
139
140        fn queue_depth_changed(&self, total: usize, max_per_key: usize) {
141            self.0.lock().unwrap().queue_depth = (total, max_per_key);
142        }
143    }
144
145    #[derive(Debug, Clone)]
146    struct SimpleProcessor {
147        process_delay: Duration,
148    }
149
150    impl SimpleProcessor {
151        fn instant() -> Self {
152            Self {
153                process_delay: Duration::ZERO,
154            }
155        }
156    }
157
158    impl Processor for SimpleProcessor {
159        type Key = String;
160        type Input = String;
161        type Output = String;
162        type Error = String;
163        type Resources = ();
164
165        async fn acquire_resources(&self, _key: String) -> Result<(), String> {
166            Ok(())
167        }
168
169        async fn process(
170            &self,
171            _key: String,
172            inputs: impl Iterator<Item = String> + Send,
173            _resources: (),
174        ) -> Result<Vec<String>, String> {
175            if self.process_delay > Duration::ZERO {
176                tokio::time::sleep(self.process_delay).await;
177            }
178            Ok(inputs.map(|s| s + " done").collect())
179        }
180    }
181
182    async fn shut_down(batcher: &Batcher<SimpleProcessor>) {
183        let worker = batcher.worker_handle();
184        worker.shut_down().await;
185        tokio::time::timeout(Duration::from_secs(1), worker.wait_for_shutdown())
186            .await
187            .expect("Worker should shut down");
188    }
189
190    #[tokio::test]
191    async fn records_metrics_for_successful_batch() {
192        let recorder = Arc::new(TestRecorder::default());
193
194        let batcher = Batcher::builder()
195            .name("test")
196            .processor(SimpleProcessor::instant())
197            .limits(Limits::builder().max_batch_size(2).build())
198            .batching_policy(BatchingPolicy::Size)
199            .metrics_recorder(Arc::clone(&recorder) as Arc<dyn MetricsRecorder>)
200            .build();
201
202        let (r1, r2) = tokio::join!(
203            batcher.add("A".to_string(), "1".to_string()),
204            batcher.add("A".to_string(), "2".to_string()),
205        );
206        assert!(r1.is_ok());
207        assert!(r2.is_ok());
208
209        shut_down(&batcher).await;
210
211        let inner = recorder.0.lock().unwrap();
212        assert_eq!(inner.items_received, 2);
213        assert_eq!(inner.batches_completed, 1);
214        assert_eq!(inner.items_rejected, 0);
215        assert_eq!(inner.batch_sizes, vec![2]);
216        assert_eq!(inner.item_latencies.len(), 2);
217        assert!(inner.item_latencies.iter().all(|d| *d > Duration::ZERO));
218        assert_eq!(inner.channel_durations.len(), 2);
219    }
220
221    #[tokio::test(start_paused = true)]
222    async fn records_rejection_metrics() {
223        let recorder = Arc::new(TestRecorder::default());
224
225        let batcher = Batcher::builder()
226            .name("test")
227            .processor(SimpleProcessor {
228                process_delay: Duration::from_millis(100),
229            })
230            .limits(
231                Limits::builder()
232                    .max_batch_size(1)
233                    .max_key_concurrency(1)
234                    .max_batch_queue_size(1)
235                    .build(),
236            )
237            .batching_policy(BatchingPolicy::Size)
238            .metrics_recorder(Arc::clone(&recorder) as Arc<dyn MetricsRecorder>)
239            .build();
240
241        // With max_batch_size=1 and max_batch_queue_size=1:
242        // - Item 1 fills a batch and starts processing (slow processor keeps it in flight)
243        // - Item 2 fills the replacement batch (queue is now full)
244        // - Item 3 is rejected because the queue is full
245        let (r1, r2, r3) = tokio::join!(
246            batcher.add("A".to_string(), "1".to_string()),
247            batcher.add("A".to_string(), "2".to_string()),
248            batcher.add("A".to_string(), "3".to_string()),
249        );
250
251        let results = [&r1, &r2, &r3];
252        let successes = results.iter().filter(|r| r.is_ok()).count();
253        let rejections = results.iter().filter(|r| r.is_err()).count();
254        assert_eq!(successes, 2);
255        assert_eq!(rejections, 1);
256
257        shut_down(&batcher).await;
258
259        assert_eq!(recorder.0.lock().unwrap().items_rejected, 1);
260    }
261
262    #[tokio::test]
263    async fn records_gauge_metrics() {
264        let recorder = Arc::new(TestRecorder::default());
265
266        let batcher = Batcher::builder()
267            .name("test")
268            .processor(SimpleProcessor::instant())
269            .limits(Limits::builder().max_batch_size(1).build())
270            .batching_policy(BatchingPolicy::Size)
271            .metrics_recorder(Arc::clone(&recorder) as Arc<dyn MetricsRecorder>)
272            .build();
273
274        let r = batcher.add("A".to_string(), "1".to_string()).await;
275        assert!(r.is_ok());
276
277        shut_down(&batcher).await;
278
279        let inner = recorder.0.lock().unwrap();
280        assert!(!inner.active_keys.is_empty());
281        assert!(inner.active_keys.iter().any(|&c| c > 0));
282        // Key goes idle and is removed after the batch finishes.
283        assert_eq!(*inner.active_keys.last().unwrap(), 0);
284    }
285}