1use std::{fmt::Debug, time::Duration};
4
5pub trait MetricsRecorder: Debug + Send + Sync + 'static {
14 fn item_received(&self, _channel_duration: Duration) {}
19
20 fn item_rejected(&self) {}
22
23 fn batch_completed(&self, _metrics: &BatchStats) {}
25
26 fn resource_acquisition_completed(&self, _duration: Duration, _success: bool) {}
28
29 fn active_keys_changed(&self, _count: usize) {}
31
32 fn processing_concurrency_changed(&self, _total: usize, _max_per_key: usize) {}
37
38 fn queue_depth_changed(&self, _total: usize, _max_per_key: usize) {}
43
44 fn queue_items_changed(&self, _total: usize, _max_per_key: usize) {}
52}
53
54#[derive(Debug, Clone)]
56#[non_exhaustive]
57pub struct BatchStats {
58 pub size: usize,
60 pub processing_duration: Duration,
62 pub success: bool,
64 pub item_latencies: Vec<Duration>,
66 pub queue_durations: Vec<Duration>,
72}
73
74impl BatchStats {
75 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
93pub trait MetricsRecorderFactory: Debug + Send + Sync + 'static {
95 fn create_recorder(&self, batcher_name: &str) -> Box<dyn MetricsRecorder>;
97}
98
99#[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 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 assert_eq!(*inner.active_keys.last().unwrap(), 0);
330 }
331}