Skip to main content

batch_aint_one/
worker.rs

1use std::{collections::HashMap, fmt::Debug, time::Duration};
2
3use tokio::{
4    sync::{mpsc, oneshot},
5    task::JoinHandle,
6};
7use tracing::{Span, debug, info};
8
9use crate::{
10    BatchError,
11    batch::BatchItem,
12    batch_inner::Generation,
13    batch_queue::BatchQueue,
14    limits::Limits,
15    metrics::{BatchStats, MetricsRecorder},
16    policies::{BatchingPolicy, OnAdd, OnFinish, OnGenerationEvent},
17    processor::Processor,
18};
19
20pub(crate) struct Worker<P: Processor> {
21    batcher_name: String,
22
23    /// Used to receive new batch items.
24    item_rx: mpsc::Receiver<BatchItem<P>>,
25    /// The callback to process a batch of inputs.
26    processor: P,
27
28    /// Used to signal that a batch for key `K` should be processed.
29    msg_tx: mpsc::Sender<Message<P>>,
30    /// Receives signals to process a batch for key `K`.
31    msg_rx: mpsc::Receiver<Message<P>>,
32
33    /// Used to send messages to the worker related to shutdown.
34    shutdown_notifier_rx: mpsc::Receiver<ShutdownMessage>,
35
36    /// Used to signal to listeners that the worker has shut down.
37    shutdown_notifiers: Vec<oneshot::Sender<()>>,
38
39    shutting_down: bool,
40
41    limits: Limits,
42    /// Controls when to start processing a batch.
43    batching_policy: BatchingPolicy,
44
45    /// Unprocessed batches, grouped by key `K`.
46    batch_queues: HashMap<P::Key, BatchQueue<P>>,
47
48    metrics_recorder: Box<dyn MetricsRecorder>,
49}
50
51/// Events which drive the worker.
52///
53/// Spawned tasks (resource acquisition, timeouts) report their outcomes to the worker by
54/// sending a message, and the worker performs the resulting batch state transitions when
55/// handling them, so it observes all events in message order.
56pub(crate) enum Message<P: Processor> {
57    TimedOut(P::Key, Generation),
58    ResourcesAcquired {
59        key: P::Key,
60        generation: Generation,
61        resources: P::Resources,
62        span: Span,
63        acquisition_duration: Duration,
64    },
65    ResourceAcquisitionFailed {
66        key: P::Key,
67        generation: Generation,
68        err: BatchError<P::Error>,
69        acquisition_duration: Duration,
70    },
71    Finished {
72        key: P::Key,
73        metrics: BatchStats,
74    },
75}
76
77impl<P: Processor> Debug for Message<P> {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        match self {
80            Message::TimedOut(key, generation) => f
81                .debug_tuple("TimedOut")
82                .field(key)
83                .field(generation)
84                .finish(),
85            Message::ResourcesAcquired {
86                key,
87                generation,
88                resources: _,
89                span: _,
90                acquisition_duration: _,
91            } => f
92                .debug_tuple("ResourcesAcquired")
93                .field(key)
94                .field(generation)
95                .field(&"<Resources>")
96                .finish(),
97            Message::ResourceAcquisitionFailed {
98                key,
99                generation,
100                err,
101                acquisition_duration: _,
102            } => f
103                .debug_tuple("ResourceAcquisitionFailed")
104                .field(key)
105                .field(generation)
106                .field(err)
107                .finish(),
108            Message::Finished { key, metrics: _ } => f.debug_tuple("Finished").field(key).finish(),
109        }
110    }
111}
112
113pub(crate) enum ShutdownMessage {
114    Register(ShutdownNotifier),
115    ShutDown,
116}
117
118pub(crate) struct ShutdownNotifier(oneshot::Sender<()>);
119
120/// A handle to the worker task.
121///
122/// Used for shutting down the worker and waiting for it to finish.
123#[derive(Debug, Clone)]
124pub struct WorkerHandle {
125    shutdown_tx: mpsc::Sender<ShutdownMessage>,
126}
127
128/// Aborts the worker task when dropped.
129#[derive(Debug)]
130pub(crate) struct WorkerDropGuard {
131    handle: JoinHandle<()>,
132}
133
134impl<P: Processor> Worker<P> {
135    pub fn spawn(
136        batcher_name: String,
137        processor: P,
138        limits: Limits,
139        batching_policy: BatchingPolicy,
140        metrics_recorder: Box<dyn MetricsRecorder>,
141    ) -> (WorkerHandle, WorkerDropGuard, mpsc::Sender<BatchItem<P>>) {
142        // These channel sizes are somewhat arbitrary - they just need to be big enough to avoid
143        // backpressure in normal operation.
144        let (item_tx, item_rx) = mpsc::channel(limits.max_items_in_system_per_key());
145        let (msg_tx, msg_rx) = mpsc::channel(limits.max_items_in_system_per_key());
146
147        let (shutdown_tx, shutdown_rx) = mpsc::channel(1);
148
149        let mut worker = Worker {
150            batcher_name,
151
152            item_rx,
153            processor,
154
155            msg_tx,
156            msg_rx,
157
158            shutdown_notifier_rx: shutdown_rx,
159            shutdown_notifiers: Vec::new(),
160
161            shutting_down: false,
162
163            limits,
164            batching_policy,
165
166            batch_queues: HashMap::new(),
167
168            metrics_recorder,
169        };
170
171        let handle = tokio::spawn(async move {
172            worker.run().await;
173        });
174
175        (
176            WorkerHandle { shutdown_tx },
177            WorkerDropGuard { handle },
178            item_tx,
179        )
180    }
181
182    /// Add an item to the batch.
183    fn add(&mut self, mut item: BatchItem<P>) {
184        self.metrics_recorder
185            .item_received(item.submitted_at.elapsed());
186        item.received_at = Some(tokio::time::Instant::now());
187
188        let key = item.key.clone();
189
190        let batch_queue = self.batch_queues.entry(key.clone()).or_insert_with(|| {
191            BatchQueue::new(self.batcher_name.clone(), key.clone(), self.limits)
192        });
193
194        match self.batching_policy.on_add(batch_queue) {
195            OnAdd::AddAndProcess => {
196                batch_queue.push(item);
197
198                self.process_next_batch(&key);
199            }
200            OnAdd::AddAndAcquireResources => {
201                batch_queue.push(item);
202
203                batch_queue.pre_acquire_resources(self.processor.clone(), self.msg_tx.clone());
204            }
205            OnAdd::AddAndProcessAfter(duration) => {
206                batch_queue.push(item);
207
208                batch_queue.process_after(duration, self.msg_tx.clone());
209            }
210            OnAdd::Add => {
211                batch_queue.push(item);
212            }
213            OnAdd::Reject(reason) => {
214                self.metrics_recorder.item_rejected();
215
216                if item
217                    .tx
218                    .send((Err(BatchError::Rejected(reason)), None))
219                    .is_err()
220                {
221                    // Whatever was waiting for the output must have shut down. Presumably it
222                    // doesn't care anymore, but we log here anyway. There's not much else we can do.
223                    debug!(
224                        "Unable to send output over oneshot channel. Receiver deallocated. Batcher: {}",
225                        self.batcher_name
226                    );
227                }
228            }
229        }
230
231        self.report_gauges();
232    }
233
234    /// Get the batch queue for the given key, which should always exist when handling an event
235    /// for that key.
236    fn queue_mut<'q>(
237        batch_queues: &'q mut HashMap<P::Key, BatchQueue<P>>,
238        key: &P::Key,
239    ) -> &'q mut BatchQueue<P> {
240        batch_queues.get_mut(key).expect("batch queue should exist")
241    }
242
243    fn process_generation(&mut self, key: P::Key, generation: Generation) {
244        let batch_queue = Self::queue_mut(&mut self.batch_queues, &key);
245
246        batch_queue.process_generation(generation, self.processor.clone(), self.msg_tx.clone());
247    }
248
249    fn process_next_ready_batch(&mut self, key: &P::Key) {
250        let batch_queue = Self::queue_mut(&mut self.batch_queues, key);
251
252        batch_queue.process_next_ready_batch(self.processor.clone(), self.msg_tx.clone());
253    }
254
255    fn process_next_batch(&mut self, key: &P::Key) {
256        let batch_queue = Self::queue_mut(&mut self.batch_queues, key);
257
258        batch_queue.process_next_batch(self.processor.clone(), self.msg_tx.clone());
259    }
260
261    fn on_timeout(&mut self, key: P::Key, generation: Generation) {
262        // Unlike the other message handlers, the queue may have been removed: timers are not
263        // tracked by the in-flight counters, so a TimedOut message can outlive its queue.
264        let Some(batch_queue) = self.batch_queues.get_mut(&key) else {
265            debug!("Timeout for a batch queue which no longer exists. Ignoring.");
266            return;
267        };
268
269        match self.batching_policy.on_timeout(generation, batch_queue) {
270            OnGenerationEvent::Process => {
271                self.process_generation(key, generation);
272            }
273            OnGenerationEvent::DoNothing => {}
274        }
275    }
276
277    fn on_resource_acquired(
278        &mut self,
279        key: P::Key,
280        generation: Generation,
281        resources: P::Resources,
282        span: Span,
283        acquisition_duration: Duration,
284    ) {
285        self.metrics_recorder
286            .resource_acquisition_completed(acquisition_duration, true);
287
288        let batch_queue = Self::queue_mut(&mut self.batch_queues, &key);
289
290        batch_queue.resources_acquired(generation, resources, span);
291
292        match self
293            .batching_policy
294            .on_resources_acquired(generation, batch_queue)
295        {
296            OnGenerationEvent::Process => {
297                self.process_generation(key, generation);
298            }
299            OnGenerationEvent::DoNothing => {}
300        }
301    }
302
303    fn on_resource_acquisition_failed(
304        &mut self,
305        key: P::Key,
306        generation: Generation,
307        err: BatchError<P::Error>,
308        acquisition_duration: Duration,
309    ) {
310        self.metrics_recorder
311            .resource_acquisition_completed(acquisition_duration, false);
312
313        let batch_queue = Self::queue_mut(&mut self.batch_queues, &key);
314
315        batch_queue.fail_generation(generation, err);
316
317        self.process_next_and_clean_up(&key);
318        self.report_gauges();
319    }
320
321    fn on_batch_finished(&mut self, key: &P::Key, metrics: BatchStats) {
322        self.metrics_recorder.batch_completed(&metrics);
323
324        let batch_queue = Self::queue_mut(&mut self.batch_queues, key);
325
326        batch_queue.mark_processed();
327
328        self.process_next_and_clean_up(key);
329        self.report_gauges();
330    }
331
332    fn report_gauges(&self) {
333        self.metrics_recorder
334            .active_keys_changed(self.batch_queues.len());
335
336        let (total_processing, max_processing, total_queued, max_queued, total_items, max_items) =
337            self.batch_queues
338                .values()
339                .fold((0, 0, 0, 0, 0, 0), |(tp, mp, tq, mq, ti, mi), bq| {
340                    let p = bq.processing();
341                    let q = bq.queued();
342                    let i = bq.queued_items();
343                    (tp + p, mp.max(p), tq + q, mq.max(q), ti + i, mi.max(i))
344                });
345
346        self.metrics_recorder
347            .processing_concurrency_changed(total_processing, max_processing);
348        self.metrics_recorder
349            .queue_depth_changed(total_queued, max_queued);
350        self.metrics_recorder
351            .queue_items_changed(total_items, max_items);
352    }
353
354    /// After a batch has left the queue (either processed or having failed to acquire resources),
355    /// apply the batching policy's finish action and drop the queue if the key is now idle.
356    fn process_next_and_clean_up(&mut self, key: &P::Key) {
357        let batch_queue = Self::queue_mut(&mut self.batch_queues, key);
358
359        match self.batching_policy.on_finish(batch_queue) {
360            OnFinish::ProcessNextReady => {
361                self.process_next_ready_batch(key);
362            }
363            OnFinish::ProcessNext => {
364                self.process_next_batch(key);
365            }
366            OnFinish::DoNothing => {}
367        }
368
369        // Remove the queue for idle keys, otherwise the map grows unboundedly as new keys are
370        // seen. A key can only become idle once a batch leaves the queue, so these handlers are
371        // the only place we need to do this.
372        if Self::queue_mut(&mut self.batch_queues, key).is_idle() {
373            self.batch_queues.remove(key);
374        }
375    }
376
377    fn ready_to_shut_down(&self) -> bool {
378        self.shutting_down
379            && self.batch_queues.values().all(|q| q.is_empty())
380            && !self.batch_queues.values().any(|q| q.is_processing())
381    }
382
383    /// Start running the worker event loop.
384    async fn run(&mut self) {
385        loop {
386            tokio::select! {
387                Some(msg) = self.shutdown_notifier_rx.recv() => {
388                    match msg {
389                        ShutdownMessage::Register(notifier) => {
390                           self.shutdown_notifiers.push(notifier.0);
391                        }
392                        ShutdownMessage::ShutDown => {
393                            self.shutting_down = true;
394                        }
395                    }
396                }
397
398                Some(item) = self.item_rx.recv() => {
399                    self.add(item);
400                }
401
402                Some(msg) = self.msg_rx.recv() => {
403                    match msg {
404                        Message::ResourcesAcquired { key, generation, resources, span, acquisition_duration } => {
405                            self.on_resource_acquired(key, generation, resources, span, acquisition_duration);
406                        }
407                        Message::ResourceAcquisitionFailed { key, generation, err, acquisition_duration } => {
408                            self.on_resource_acquisition_failed(key, generation, err, acquisition_duration);
409                        }
410                        Message::TimedOut(key, generation) => {
411                            self.on_timeout(key, generation);
412                        }
413                        Message::Finished { key, metrics } => {
414                            self.on_batch_finished(&key, metrics);
415                        }
416                    }
417                }
418            }
419
420            if self.ready_to_shut_down() {
421                info!("Batch worker '{}' is shutting down", &self.batcher_name);
422                return;
423            }
424        }
425    }
426}
427
428impl WorkerHandle {
429    /// Signal the worker to shut down after processing any in-flight batches.
430    ///
431    /// New items are still accepted while shutting down, and the worker only shuts down once all
432    /// keys are idle. This means shutdown may never complete if:
433    ///
434    /// - new items keep being added, or
435    /// - a batch never meets its policy's processing condition, e.g. when using the
436    ///   [`Size`](crate::BatchingPolicy::Size) policy, a final partial batch may wait
437    ///   indefinitely for more items.
438    ///
439    /// Stopping the flow of new items is expected to be handled by the caller, e.g. by shutting
440    /// down the message handlers which add items before shutting down the batcher.
441    pub async fn shut_down(&self) {
442        info!("Sending shut down signal to batch worker");
443        // We ignore errors here - if the receiver has gone away, the worker is already shut down.
444        let _ = self.shutdown_tx.send(ShutdownMessage::ShutDown).await;
445    }
446
447    /// Wait for the worker to finish.
448    pub async fn wait_for_shutdown(&self) {
449        // We ignore errors here - if the receiver has gone away, the worker is already shut down.
450        let (notifier_tx, notifier_rx) = oneshot::channel();
451        let _ = self
452            .shutdown_tx
453            .send(ShutdownMessage::Register(ShutdownNotifier(notifier_tx)))
454            .await;
455        // Wait for the notifier to be dropped.
456        let _ = notifier_rx.await;
457    }
458}
459
460impl Drop for WorkerDropGuard {
461    fn drop(&mut self) {
462        info!("Aborting batch worker");
463        self.handle.abort();
464    }
465}
466
467#[cfg(test)]
468mod test {
469    use std::sync::{Arc, Mutex};
470
471    use tokio::sync::oneshot;
472    use tracing::Span;
473
474    use super::*;
475
476    #[derive(Debug, Clone)]
477    struct SimpleBatchProcessor;
478
479    impl Processor for SimpleBatchProcessor {
480        type Key = String;
481        type Input = String;
482        type Output = String;
483        type Error = String;
484        type Resources = ();
485
486        async fn acquire_resources(&self, _key: String) -> Result<(), String> {
487            Ok(())
488        }
489
490        async fn process(
491            &self,
492            _key: String,
493            inputs: impl Iterator<Item = String> + Send,
494            _resources: (),
495        ) -> Result<Vec<String>, String> {
496            Ok(inputs.map(|s| s + " processed").collect())
497        }
498    }
499
500    /// Construct a worker directly, without spawning the run loop, so tests can drive it
501    /// manually and inspect its state.
502    fn new_worker() -> Worker<SimpleBatchProcessor> {
503        let (_item_tx, item_rx) = mpsc::channel(1);
504        let (msg_tx, msg_rx) = mpsc::channel(1);
505        let (_shutdown_tx, shutdown_rx) = mpsc::channel(1);
506
507        Worker {
508            batcher_name: "test".to_string(),
509            item_rx,
510            processor: SimpleBatchProcessor,
511            msg_tx,
512            msg_rx,
513            shutdown_notifier_rx: shutdown_rx,
514            shutdown_notifiers: Vec::new(),
515            shutting_down: false,
516            limits: Limits::builder().max_batch_size(1).build(),
517            batching_policy: BatchingPolicy::Size,
518            batch_queues: HashMap::new(),
519            metrics_recorder: Box::new(crate::metrics::NoopMetricsRecorder),
520        }
521    }
522
523    #[tokio::test]
524    async fn removes_batch_queue_when_key_becomes_idle() {
525        let mut worker = new_worker();
526
527        let (tx, rx) = oneshot::channel();
528        worker.add(BatchItem {
529            key: "K1".to_string(),
530            input: "I1".to_string(),
531            submitted_at: tokio::time::Instant::now(),
532            received_at: None,
533            tx,
534            requesting_span: Span::none(),
535        });
536
537        // max_batch_size is 1, so the batch processes immediately.
538        let output = rx.await.unwrap().0.unwrap();
539        assert_eq!(output, "I1 processed");
540
541        // Handle the Finished message, as the run loop would.
542        let msg = worker.msg_rx.recv().await.unwrap();
543        let Message::Finished { key, metrics } = msg else {
544            panic!("expected Finished message, got {:?}", msg);
545        };
546        worker.on_batch_finished(&key, metrics);
547
548        assert!(
549            worker.batch_queues.is_empty(),
550            "the batch queue for an idle key should be removed"
551        );
552    }
553
554    #[tokio::test]
555    async fn ignores_timeout_for_removed_batch_queue() {
556        // A timer can fire and enqueue a TimedOut message, after which the batch is processed
557        // anyway (e.g. it filled up) and the queue is removed once the key is idle. The stale
558        // TimedOut message must be ignored, not panic the worker.
559        let mut worker = new_worker();
560
561        worker.on_timeout("K1".to_string(), Generation::default());
562    }
563
564    #[tokio::test]
565    async fn simple_test_over_channel() {
566        let (_worker_handle, _worker_guard, item_tx) = Worker::<SimpleBatchProcessor>::spawn(
567            "test".to_string(),
568            SimpleBatchProcessor,
569            Limits::builder().max_batch_size(2).build(),
570            BatchingPolicy::Size,
571            Box::new(crate::metrics::NoopMetricsRecorder),
572        );
573
574        let rx1 = {
575            let (tx, rx) = oneshot::channel();
576            item_tx
577                .send(BatchItem {
578                    key: "K1".to_string(),
579                    input: "I1".to_string(),
580                    submitted_at: tokio::time::Instant::now(),
581                    received_at: None,
582                    tx,
583                    requesting_span: Span::none(),
584                })
585                .await
586                .unwrap();
587
588            rx
589        };
590
591        let rx2 = {
592            let (tx, rx) = oneshot::channel();
593            item_tx
594                .send(BatchItem {
595                    key: "K1".to_string(),
596                    input: "I2".to_string(),
597                    submitted_at: tokio::time::Instant::now(),
598                    received_at: None,
599                    tx,
600                    requesting_span: Span::none(),
601                })
602                .await
603                .unwrap();
604
605            rx
606        };
607
608        let o1 = rx1.await.unwrap().0.unwrap();
609        let o2 = rx2.await.unwrap().0.unwrap();
610
611        assert_eq!(o1, "I1 processed".to_string());
612        assert_eq!(o2, "I2 processed".to_string());
613    }
614
615    #[derive(Debug, Clone)]
616    struct RecordingMetrics(Arc<Mutex<(usize, usize)>>);
617
618    impl MetricsRecorder for RecordingMetrics {
619        fn queue_items_changed(&self, total: usize, max_per_key: usize) {
620            *self.0.lock().unwrap() = (total, max_per_key);
621        }
622    }
623
624    fn dummy_item(key: &str) -> BatchItem<SimpleBatchProcessor> {
625        let (tx, _rx) = oneshot::channel();
626        BatchItem {
627            key: key.to_string(),
628            input: "I".to_string(),
629            submitted_at: tokio::time::Instant::now(),
630            received_at: None,
631            tx,
632            requesting_span: Span::none(),
633        }
634    }
635
636    #[tokio::test]
637    async fn queue_items_changed_sums_and_maxes_across_keys() {
638        let mut worker = new_worker();
639        worker.limits = Limits::builder().max_batch_size(2).build();
640
641        let gauges = Arc::new(Mutex::new((0, 0)));
642        worker.metrics_recorder = Box::new(RecordingMetrics(gauges.clone()));
643
644        let limits = worker.limits;
645
646        // Key "A": 3 items split across 2 batches (one full, one with a single item).
647        let queue_a = worker
648            .batch_queues
649            .entry("A".to_string())
650            .or_insert_with(|| BatchQueue::new("test".to_string(), "A".to_string(), limits));
651        for _ in 0..3 {
652            queue_a.push(dummy_item("A"));
653        }
654
655        // Key "B": 1 item in a single batch.
656        let queue_b = worker
657            .batch_queues
658            .entry("B".to_string())
659            .or_insert_with(|| BatchQueue::new("test".to_string(), "B".to_string(), limits));
660        queue_b.push(dummy_item("B"));
661
662        worker.report_gauges();
663
664        let (total, max_per_key) = *gauges.lock().unwrap();
665        assert_eq!(total, 4, "should sum items across both keys");
666        assert_eq!(
667            max_per_key, 3,
668            "should report the highest per-key item count, not batch count"
669        );
670    }
671}