1use std::collections::HashMap;
25use std::sync::Arc;
26use std::time::Duration;
27
28use serde_json::Value;
29use tokio::sync::mpsc;
30use tokio::time::{self, Instant};
31
32#[derive(Debug, Clone)]
35pub struct BatchConfig {
36 pub batch_max_size: usize,
40 pub inactive_timeout: Duration,
43 pub buffer_duration: Duration,
47 pub max_retry_count: u32,
50 pub retry_delay: Duration,
52 pub max_pending_entries: usize,
56}
57
58impl Default for BatchConfig {
59 fn default() -> Self {
60 Self {
61 batch_max_size: 1000,
62 inactive_timeout: Duration::from_secs(5),
63 buffer_duration: Duration::from_secs(60),
64 max_retry_count: 0,
65 retry_delay: Duration::from_secs(1),
66 max_pending_entries: 10_000,
67 }
68 }
69}
70
71impl BatchConfig {
72 pub fn from_config(config: &HashMap<String, Value>) -> Result<Self, String> {
80 let mut cfg = Self::default();
81 if let Some(v) = int_key(config, "batch_max_size")? {
82 cfg.batch_max_size = v as usize;
83 }
84 if let Some(v) = int_key(config, "inactive_timeout")? {
85 cfg.inactive_timeout = Duration::from_secs(v);
86 }
87 if let Some(v) = int_key(config, "buffer_duration")? {
88 cfg.buffer_duration = Duration::from_secs(v);
89 }
90 if let Some(v) = int_key(config, "max_retry_count")? {
91 cfg.max_retry_count = v as u32;
92 }
93 if let Some(v) = int_key(config, "retry_delay")? {
94 cfg.retry_delay = Duration::from_secs(v);
95 }
96 if let Some(v) = int_key(config, "max_pending_entries")? {
97 cfg.max_pending_entries = v as usize;
98 }
99 Ok(cfg)
100 }
101}
102
103fn int_key(config: &HashMap<String, Value>, key: &str) -> Result<Option<u64>, String> {
107 match config.get(key) {
108 None | Some(Value::Null) => Ok(None),
109 Some(v) => v.as_u64().map(Some).ok_or_else(|| {
110 format!("batch config key `{key}` must be a non-negative integer, got {v}")
111 }),
112 }
113}
114
115#[derive(Debug)]
118pub struct FlushError {
119 pub message: String,
121 pub first_fail: Option<usize>,
125}
126
127#[async_trait::async_trait]
131pub trait BatchFlusher: Send + Sync + 'static {
132 async fn flush(&self, entries: &[Value]) -> Result<(), FlushError>;
136}
137
138#[derive(Clone)]
141pub struct BatchSink {
142 tx: mpsc::Sender<Value>,
143 name: String,
144}
145
146impl BatchSink {
147 pub fn spawn(name: &str, cfg: BatchConfig, flusher: Arc<dyn BatchFlusher>) -> Self {
152 let name = name.to_string();
153 let (tx, rx) = mpsc::channel(cfg.max_pending_entries.max(1));
154 tokio::spawn(run_loop(name.clone(), cfg, flusher, rx));
155 Self { tx, name }
156 }
157
158 pub fn push(&self, entry: Value) {
165 match self.tx.try_send(entry) {
166 Ok(()) => {}
167 Err(mpsc::error::TrySendError::Full(_)) => {
168 tracing::warn!(sink = %self.name, "batch sink queue full, dropping log entry");
169 }
170 Err(mpsc::error::TrySendError::Closed(_)) => {
171 tracing::warn!(sink = %self.name, "batch sink task gone, dropping log entry");
172 }
173 }
174 }
175}
176
177async fn run_loop(
180 name: String,
181 cfg: BatchConfig,
182 flusher: Arc<dyn BatchFlusher>,
183 mut rx: mpsc::Receiver<Value>,
184) {
185 let batch_max = cfg.batch_max_size.max(1);
186 let mut buffer: Vec<Value> = Vec::new();
187 let mut first_entry_at = Instant::now();
189 let mut last_entry_at = first_entry_at;
190
191 loop {
192 if buffer.is_empty() {
193 match rx.recv().await {
195 Some(entry) => {
196 first_entry_at = Instant::now();
197 last_entry_at = first_entry_at;
198 buffer.push(entry);
199 }
200 None => break, }
202 } else {
203 let deadline = std::cmp::min(
206 first_entry_at + cfg.buffer_duration,
207 last_entry_at + cfg.inactive_timeout,
208 );
209 tokio::select! {
210 maybe_entry = rx.recv() => match maybe_entry {
211 Some(entry) => {
212 last_entry_at = Instant::now();
213 buffer.push(entry);
214 }
215 None => {
216 flush_with_retry(&name, &cfg, &flusher, &mut buffer).await;
218 break;
219 }
220 },
221 _ = time::sleep_until(deadline) => {
222 flush_with_retry(&name, &cfg, &flusher, &mut buffer).await;
223 }
224 }
225 }
226
227 if buffer.len() >= batch_max {
230 flush_with_retry(&name, &cfg, &flusher, &mut buffer).await;
231 }
232 }
233
234 tracing::debug!(sink = %name, "batch sink channel closed, flush task exiting");
235}
236
237async fn flush_with_retry(
243 name: &str,
244 cfg: &BatchConfig,
245 flusher: &Arc<dyn BatchFlusher>,
246 buffer: &mut Vec<Value>,
247) {
248 if buffer.is_empty() {
249 return;
250 }
251 let mut entries = std::mem::take(buffer);
252 let mut failures: u32 = 0;
253
254 loop {
255 let err = match flusher.flush(&entries).await {
256 Ok(()) => {
257 tracing::debug!(sink = %name, count = entries.len(), "batch flushed");
258 return;
259 }
260 Err(err) => err,
261 };
262
263 if let Some(first_fail) = err.first_fail {
264 entries.drain(..first_fail.min(entries.len()));
266 }
267 if entries.is_empty() {
268 tracing::warn!(sink = %name, error = %err.message,
271 "flush reported an error but no entries remain to retry");
272 return;
273 }
274
275 failures += 1;
276 if failures > cfg.max_retry_count {
277 tracing::error!(sink = %name, dropped = entries.len(), error = %err.message,
278 "batch sink exceeded max_retry_count, dropping entries");
279 return;
280 }
281 tracing::warn!(sink = %name, remaining = entries.len(), attempt = failures,
282 error = %err.message, "batch flush failed, retrying");
283 time::sleep(cfg.retry_delay).await;
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use serde_json::json;
291 use std::collections::VecDeque;
292 use std::sync::Mutex;
293
294 struct RecordingFlusher {
297 calls: Mutex<Vec<Vec<Value>>>,
298 failures: Mutex<VecDeque<FlushError>>,
299 }
300
301 impl RecordingFlusher {
302 fn new() -> Arc<Self> {
303 Self::failing_with(Vec::new())
304 }
305
306 fn failing_with(failures: Vec<FlushError>) -> Arc<Self> {
307 Arc::new(Self {
308 calls: Mutex::new(Vec::new()),
309 failures: Mutex::new(failures.into()),
310 })
311 }
312
313 fn calls(&self) -> Vec<Vec<Value>> {
314 self.calls.lock().unwrap().clone()
315 }
316 }
317
318 #[async_trait::async_trait]
319 impl BatchFlusher for RecordingFlusher {
320 async fn flush(&self, entries: &[Value]) -> Result<(), FlushError> {
321 self.calls.lock().unwrap().push(entries.to_vec());
322 match self.failures.lock().unwrap().pop_front() {
323 Some(err) => Err(err),
324 None => Ok(()),
325 }
326 }
327 }
328
329 async fn wait_for(cond: impl Fn() -> bool) {
331 for _ in 0..100 {
332 if cond() {
333 return;
334 }
335 time::sleep(Duration::from_millis(10)).await;
336 }
337 panic!("condition not met within timeout");
338 }
339
340 fn quiet_cfg() -> BatchConfig {
342 BatchConfig {
343 inactive_timeout: Duration::from_secs(3600),
344 buffer_duration: Duration::from_secs(3600),
345 ..BatchConfig::default()
346 }
347 }
348
349 #[tokio::test]
350 async fn size_triggered_flush() {
351 let flusher = RecordingFlusher::new();
352 let cfg = BatchConfig {
353 batch_max_size: 3,
354 ..quiet_cfg()
355 };
356 let sink = BatchSink::spawn("size", cfg, flusher.clone());
357
358 sink.push(json!({"n": 1}));
359 sink.push(json!({"n": 2}));
360 sink.push(json!({"n": 3}));
361
362 wait_for(|| flusher.calls().len() == 1).await;
363 let calls = flusher.calls();
364 assert_eq!(calls.len(), 1);
365 assert_eq!(calls[0].len(), 3);
366 assert_eq!(calls[0][2], json!({"n": 3}));
367 }
368
369 #[tokio::test]
370 async fn inactive_timeout_flush() {
371 tokio::time::pause();
372
373 let flusher = RecordingFlusher::new();
374 let cfg = BatchConfig {
375 batch_max_size: 100,
376 inactive_timeout: Duration::from_secs(5),
377 buffer_duration: Duration::from_secs(60),
378 ..BatchConfig::default()
379 };
380 let sink = BatchSink::spawn("inactive", cfg, flusher.clone());
381
382 sink.push(json!("entry"));
383 for _ in 0..10 {
386 tokio::task::yield_now().await;
387 }
388 assert!(
389 flusher.calls().is_empty(),
390 "must not flush before the timeout"
391 );
392
393 tokio::time::advance(Duration::from_secs(6)).await;
394 for _ in 0..10 {
395 tokio::task::yield_now().await;
396 }
397
398 let calls = flusher.calls();
399 assert_eq!(calls.len(), 1);
400 assert_eq!(calls[0], vec![json!("entry")]);
401 }
402
403 #[tokio::test]
404 async fn batch_max_size_one_flushes_immediately() {
405 let flusher = RecordingFlusher::new();
406 let cfg = BatchConfig {
407 batch_max_size: 1,
408 ..quiet_cfg()
409 };
410 let sink = BatchSink::spawn("one", cfg, flusher.clone());
411
412 sink.push(json!(1));
413 sink.push(json!(2));
414
415 wait_for(|| flusher.calls().len() == 2).await;
416 let calls = flusher.calls();
417 assert_eq!(calls, vec![vec![json!(1)], vec![json!(2)]]);
418 }
419
420 #[tokio::test]
421 async fn first_fail_retries_only_the_tail() {
422 let flusher = RecordingFlusher::failing_with(vec![FlushError {
423 message: "partial delivery".into(),
424 first_fail: Some(1),
425 }]);
426 let cfg = BatchConfig {
427 batch_max_size: 3,
428 max_retry_count: 2,
429 retry_delay: Duration::from_millis(10),
430 ..quiet_cfg()
431 };
432 let sink = BatchSink::spawn("retry", cfg, flusher.clone());
433
434 sink.push(json!("a"));
435 sink.push(json!("b"));
436 sink.push(json!("c"));
437
438 wait_for(|| flusher.calls().len() == 2).await;
439 let calls = flusher.calls();
440 assert_eq!(calls[0], vec![json!("a"), json!("b"), json!("c")]);
441 assert_eq!(calls[1], vec![json!("b"), json!("c")]);
443 }
444
445 #[tokio::test]
446 async fn drain_on_drop() {
447 let flusher = RecordingFlusher::new();
448 let sink = BatchSink::spawn("drain", quiet_cfg(), flusher.clone());
449
450 sink.push(json!(1));
451 sink.push(json!(2));
452 drop(sink);
453
454 wait_for(|| flusher.calls().len() == 1).await;
455 let calls = flusher.calls();
456 assert_eq!(calls[0], vec![json!(1), json!(2)]);
457 }
458
459 #[tokio::test]
460 async fn from_config_defaults_and_errors() {
461 let cfg = BatchConfig::from_config(&HashMap::new()).unwrap();
463 assert_eq!(cfg.batch_max_size, 1000);
464 assert_eq!(cfg.inactive_timeout, Duration::from_secs(5));
465 assert_eq!(cfg.buffer_duration, Duration::from_secs(60));
466 assert_eq!(cfg.max_retry_count, 0);
467 assert_eq!(cfg.retry_delay, Duration::from_secs(1));
468 assert_eq!(cfg.max_pending_entries, 10_000);
469
470 let map: HashMap<String, Value> = [
472 ("batch_max_size".to_string(), json!(10)),
473 ("inactive_timeout".to_string(), json!(2)),
474 ("buffer_duration".to_string(), json!(30)),
475 ("max_retry_count".to_string(), json!(3)),
476 ("retry_delay".to_string(), json!(7)),
477 ("max_pending_entries".to_string(), json!(500)),
478 ]
479 .into();
480 let cfg = BatchConfig::from_config(&map).unwrap();
481 assert_eq!(cfg.batch_max_size, 10);
482 assert_eq!(cfg.inactive_timeout, Duration::from_secs(2));
483 assert_eq!(cfg.buffer_duration, Duration::from_secs(30));
484 assert_eq!(cfg.max_retry_count, 3);
485 assert_eq!(cfg.retry_delay, Duration::from_secs(7));
486 assert_eq!(cfg.max_pending_entries, 500);
487
488 let map: HashMap<String, Value> = [("batch_max_size".to_string(), json!("many"))].into();
490 let err = BatchConfig::from_config(&map).unwrap_err();
491 assert!(
492 err.contains("batch_max_size"),
493 "error should name the key: {err}"
494 );
495 }
496}