Skip to main content

rskit_messaging/
batch.rs

1//! Batch producer that buffers messages and flushes by size or interval.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use rskit_errors::{AppError, AppResult, ErrorCode};
7use rskit_stream::SpawnedTask;
8
9use crate::message::Message;
10use crate::traits::MessageProducer;
11
12/// Configuration for [`BatchProducer`].
13#[derive(Debug, Clone)]
14pub struct BatchConfig {
15    /// Maximum number of messages before a flush is triggered.
16    pub max_size: usize,
17    /// Maximum time between automatic flushes.
18    pub max_wait: Duration,
19    /// Maximum byte budget per batch (reserved for specialized
20    /// implementations; not enforced in the generic `BatchProducer`).
21    pub max_bytes: Option<u64>,
22}
23
24impl Default for BatchConfig {
25    fn default() -> Self {
26        Self {
27            max_size: 100,
28            max_wait: Duration::from_secs(5),
29            max_bytes: None,
30        }
31    }
32}
33
34impl BatchConfig {
35    /// Validate batch bounds before spawning background flush tasks.
36    pub fn validate(&self) -> AppResult<()> {
37        if self.max_size == 0 {
38            return Err(AppError::new(
39                ErrorCode::InvalidInput,
40                "batch max_size must be greater than zero",
41            ));
42        }
43        if self.max_wait.is_zero() {
44            return Err(AppError::new(
45                ErrorCode::InvalidInput,
46                "batch max_wait must be greater than zero",
47            ));
48        }
49        Ok(())
50    }
51}
52
53/// A producer that buffers messages and flushes them in batches.
54///
55/// Flushing occurs when:
56/// - The buffer reaches [`BatchConfig::max_size`] messages.
57/// - The [`BatchConfig::max_wait`] interval elapses.
58/// - [`BatchProducer::flush`] or [`BatchProducer::close`] is called.
59///
60/// A background tokio task handles periodic flushing.
61pub struct BatchProducer<T: Send + Sync + Clone + 'static> {
62    producer: Arc<dyn MessageProducer<T>>,
63    config: BatchConfig,
64    buffer: Arc<tokio::sync::Mutex<Vec<Message<T>>>>,
65    flush_task: Arc<tokio::sync::Mutex<Option<SpawnedTask>>>,
66}
67
68impl<T: Send + Sync + Clone + 'static> BatchProducer<T> {
69    /// Create a new batch producer.
70    ///
71    /// Spawns a background task that periodically flushes buffered
72    /// messages according to `config.max_wait`.
73    ///
74    /// # Errors
75    ///
76    /// Returns [`ErrorCode::InvalidInput`] when [`BatchConfig::max_size`] is zero
77    /// or [`BatchConfig::max_wait`] is zero.
78    pub fn new(
79        producer: Arc<dyn MessageProducer<T>>,
80        _topic: String,
81        config: BatchConfig,
82    ) -> AppResult<Self> {
83        config.validate()?;
84        let buffer: Arc<tokio::sync::Mutex<Vec<Message<T>>>> =
85            Arc::new(tokio::sync::Mutex::new(Vec::new()));
86
87        let flush_buffer = buffer.clone();
88        let flush_producer = producer.clone();
89        let max_wait = config.max_wait;
90
91        let task = SpawnedTask::spawn(move |cancel| async move {
92            let mut interval = tokio::time::interval(max_wait);
93            // Consume the immediate first tick.
94            interval.tick().await;
95            loop {
96                tokio::select! {
97                    () = cancel.cancelled() => break,
98                    _ = interval.tick() => {
99                        let msgs = {
100                            let mut buf = flush_buffer.lock().await;
101                            std::mem::take(&mut *buf)
102                        };
103                        if !msgs.is_empty()
104                            && let Err(e) = flush_producer.send_batch(msgs).await {
105                                ::tracing::error!(error = %e, "batch periodic flush failed");
106                            }
107                    }
108                }
109            }
110        });
111
112        Ok(Self {
113            producer,
114            config,
115            buffer,
116            flush_task: Arc::new(tokio::sync::Mutex::new(Some(task))),
117        })
118    }
119
120    /// Buffer a message, flushing immediately if `max_size` is reached.
121    pub async fn send(&self, msg: Message<T>) -> AppResult<()> {
122        let should_flush = {
123            let mut buf = self.buffer.lock().await;
124            buf.push(msg);
125            buf.len() >= self.config.max_size
126        };
127        if should_flush {
128            self.flush().await?;
129        }
130        Ok(())
131    }
132
133    /// Flush all buffered messages to the underlying producer.
134    pub async fn flush(&self) -> AppResult<()> {
135        let msgs = {
136            let mut buf = self.buffer.lock().await;
137            std::mem::take(&mut *buf)
138        };
139        if !msgs.is_empty() {
140            self.producer.send_batch(msgs).await?;
141        }
142        Ok(())
143    }
144
145    /// Flush remaining messages and shut down the background flush task.
146    pub async fn close(&self) -> AppResult<()> {
147        self.flush().await?;
148        let task = {
149            let mut t = self.flush_task.lock().await;
150            t.take()
151        };
152        if let Some(task) = task {
153            task.shutdown(Duration::from_secs(10)).await;
154        }
155        Ok(())
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use async_trait::async_trait;
163
164    /// Producer that records every message sent through it.
165    struct RecordingProducer<T: Clone + Send + Sync + 'static> {
166        sent: Arc<tokio::sync::Mutex<Vec<Message<T>>>>,
167    }
168
169    impl<T: Clone + Send + Sync + 'static> RecordingProducer<T> {
170        fn new() -> (Self, Arc<tokio::sync::Mutex<Vec<Message<T>>>>) {
171            let sent = Arc::new(tokio::sync::Mutex::new(Vec::new()));
172            (Self { sent: sent.clone() }, sent)
173        }
174    }
175
176    #[async_trait]
177    impl<T: Clone + Send + Sync + 'static> MessageProducer<T> for RecordingProducer<T> {
178        async fn send(&self, msg: Message<T>) -> AppResult<()> {
179            self.sent.lock().await.push(msg);
180            Ok(())
181        }
182
183        async fn send_batch(&self, msgs: Vec<Message<T>>) -> AppResult<()> {
184            self.sent.lock().await.extend(msgs);
185            Ok(())
186        }
187
188        async fn flush(&self, _timeout: Duration) -> AppResult<()> {
189            Ok(())
190        }
191    }
192
193    #[tokio::test]
194    async fn batch_flush_on_size() {
195        let (producer, sent) = RecordingProducer::<String>::new();
196        let batch = BatchProducer::new(
197            Arc::new(producer),
198            "topic".to_string(),
199            BatchConfig {
200                max_size: 3,
201                max_wait: Duration::from_mins(1),
202                max_bytes: None,
203            },
204        )
205        .unwrap();
206
207        batch
208            .send(Message::new("topic", "a".to_string()))
209            .await
210            .unwrap();
211        batch
212            .send(Message::new("topic", "b".to_string()))
213            .await
214            .unwrap();
215        assert_eq!(sent.lock().await.len(), 0);
216
217        // Third message should trigger a size-based flush.
218        batch
219            .send(Message::new("topic", "c".to_string()))
220            .await
221            .unwrap();
222        assert_eq!(sent.lock().await.len(), 3);
223
224        batch.close().await.unwrap();
225    }
226
227    #[tokio::test(start_paused = true)]
228    async fn batch_flush_on_time() {
229        let (producer, sent) = RecordingProducer::<String>::new();
230        let config = BatchConfig {
231            max_size: 100,
232            max_wait: Duration::from_millis(100),
233            max_bytes: None,
234        };
235        let batch = BatchProducer::new(Arc::new(producer), "topic".to_string(), config).unwrap();
236        tokio::task::yield_now().await;
237
238        batch
239            .send(Message::new("topic", "a".to_string()))
240            .await
241            .unwrap();
242        batch
243            .send(Message::new("topic", "b".to_string()))
244            .await
245            .unwrap();
246        assert_eq!(sent.lock().await.len(), 0);
247
248        tokio::time::advance(Duration::from_millis(101)).await;
249        tokio::task::yield_now().await;
250        tokio::task::yield_now().await;
251
252        assert_eq!(sent.lock().await.len(), 2);
253        batch.close().await.unwrap();
254    }
255
256    #[tokio::test]
257    async fn batch_flush_on_close() {
258        let (producer, sent) = RecordingProducer::<String>::new();
259        let batch = BatchProducer::new(
260            Arc::new(producer),
261            "topic".to_string(),
262            BatchConfig {
263                max_size: 100,
264                max_wait: Duration::from_mins(1),
265                max_bytes: None,
266            },
267        )
268        .unwrap();
269
270        batch
271            .send(Message::new("topic", "a".to_string()))
272            .await
273            .unwrap();
274        batch
275            .send(Message::new("topic", "b".to_string()))
276            .await
277            .unwrap();
278        assert_eq!(sent.lock().await.len(), 0);
279
280        batch.close().await.unwrap();
281        assert_eq!(sent.lock().await.len(), 2);
282    }
283
284    #[test]
285    fn batch_config_rejects_unbounded_zero_values() {
286        assert!(
287            BatchConfig {
288                max_size: 0,
289                ..BatchConfig::default()
290            }
291            .validate()
292            .is_err()
293        );
294        assert!(
295            BatchConfig {
296                max_wait: Duration::ZERO,
297                ..BatchConfig::default()
298            }
299            .validate()
300            .is_err()
301        );
302    }
303}