Skip to main content

featherbit/batch/
mod.rs

1//! Batching sink for logger plugins.
2//!
3//! The featherbit analogue of APISIX's batch processor
4//! (`apisix/utils/batch-processor.lua`): log entries produced on the request
5//! path are handed to a [`BatchSink`] with a fire-and-forget [`BatchSink::push`]
6//! and delivered downstream in batches by a background tokio task. A batch is
7//! flushed when it reaches `batch_max_size`, when no new entry has arrived for
8//! `inactive_timeout`, or when the oldest buffered entry is `buffer_duration`
9//! old — whichever comes first. Failed flushes are retried up to
10//! `max_retry_count` times, honouring the flusher's `first_fail` hint so
11//! already-delivered entries are not re-sent (mirroring APISIX's
12//! `slice_batch`).
13//!
14//! `push()` never blocks and never awaits: entries go through a bounded mpsc
15//! channel and are **dropped with a `tracing::warn!`** when the channel is
16//! full (`max_pending_entries`). A rising drop rate is the operator's signal
17//! to raise the capacity or fix the downstream.
18//!
19//! One sink is created per logger node in a compiled policy graph. When the
20//! graph is recompiled the old sink is dropped and a new one spawned; dropping
21//! the sink closes the channel, the background task drains whatever is still
22//! buffered or queued, flushes it, and exits.
23
24use 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/// Tuning knobs for a [`BatchSink`], matching the field names (and defaults)
33/// of APISIX's batch-processor schema plus featherbit's channel capacity.
34#[derive(Debug, Clone)]
35pub struct BatchConfig {
36    /// Maximum entries per batch; reaching it triggers an immediate flush.
37    /// A value of `1` flushes every entry as it arrives, with no timers.
38    /// Default `1000`.
39    pub batch_max_size: usize,
40    /// Flush the buffer when no new entry has arrived for this long.
41    /// Default 5 seconds.
42    pub inactive_timeout: Duration,
43    /// Flush the buffer when the *oldest* buffered entry is this old, even if
44    /// entries keep trickling in fast enough to reset `inactive_timeout`.
45    /// Default 60 seconds.
46    pub buffer_duration: Duration,
47    /// How many times a batch is retried after its first failed flush before
48    /// being dropped. Default `0` (no retries).
49    pub max_retry_count: u32,
50    /// Delay between retry attempts. Default 1 second.
51    pub retry_delay: Duration,
52    /// Capacity of the channel between [`BatchSink::push`] and the background
53    /// task. When full, pushed entries are dropped with a warning.
54    /// Default `10_000`.
55    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    /// Parses the APISIX-shaped keys (`batch_max_size`, `inactive_timeout`,
73    /// `buffer_duration`, `max_retry_count`, `retry_delay` — seconds as
74    /// integers — and `max_pending_entries`) from a plugin config map, using
75    /// the defaults for absent keys.
76    ///
77    /// Returns an error naming the offending key when a present value is not
78    /// a non-negative integer.
79    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
103/// Reads `key` from the config map as a non-negative integer. `None` (and
104/// JSON `null`) mean "absent, use the default"; any other non-integer value
105/// is an error.
106fn 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/// Error returned by [`BatchFlusher::flush`] when a batch (or part of one)
116/// could not be delivered.
117#[derive(Debug)]
118pub struct FlushError {
119    /// Human-readable description of the failure, included in retry/drop logs.
120    pub message: String,
121    /// 0-based index of the first entry that failed; entries before it are
122    /// treated as delivered and only the tail is retried. `None` means the
123    /// whole batch failed and is retried in full.
124    pub first_fail: Option<usize>,
125}
126
127/// Delivers a batch of log entries downstream (HTTP endpoint, file, syslog,
128/// ...). Implemented by each logger plugin; the batching, timing, and retry
129/// logic all live in [`BatchSink`].
130#[async_trait::async_trait]
131pub trait BatchFlusher: Send + Sync + 'static {
132    /// Attempts to deliver `entries`. On partial success, return a
133    /// [`FlushError`] with `first_fail` set so the sink retries only the
134    /// undelivered tail.
135    async fn flush(&self, entries: &[Value]) -> Result<(), FlushError>;
136}
137
138/// Handle to a spawned batching task. Cheap to clone; the background task
139/// flushes any remaining entries and exits when the last handle is dropped.
140#[derive(Clone)]
141pub struct BatchSink {
142    tx: mpsc::Sender<Value>,
143    name: String,
144}
145
146impl BatchSink {
147    /// Spawns the background flush task and returns the sink handle.
148    ///
149    /// `name` identifies the sink (typically the logger node id) in warning
150    /// and error logs.
151    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    /// Non-blocking enqueue of one log entry.
159    ///
160    /// Never blocks or awaits: when the channel is full (the background task
161    /// is not keeping up, e.g. a slow or retrying downstream) the entry is
162    /// **dropped** and a `tracing::warn!` is emitted. The rate of such drops
163    /// is the operator's signal to raise `max_pending_entries`.
164    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
177/// The background flush loop: buffers entries from `rx` and flushes on size,
178/// inactivity, age, or channel close (drain-on-drop).
179async 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    // Timestamps are only meaningful while `buffer` is non-empty.
188    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            // Nothing buffered: no deadline to watch, wait solely on recv().
194            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, // sink dropped, nothing buffered: done
201            }
202        } else {
203            // Flush when the oldest entry turns buffer_duration old or the
204            // stream has been quiet for inactive_timeout, whichever is first.
205            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                        // Sink dropped: drain what we have, then exit.
217                        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        // Size trigger; with batch_max_size == 1 this flushes every entry as
228        // it arrives and the timer arm above is never reached.
229        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
237/// Flushes `buffer`, retrying per `cfg` on failure. Honours `first_fail` by
238/// dropping the delivered head and retrying only the tail. Entries arriving
239/// during a retry sleep simply queue in the channel and form the next batch.
240/// When retries are exhausted, the remaining entries are dropped with a
241/// `tracing::error!`.
242async 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 before first_fail were delivered; retry only the tail.
265            entries.drain(..first_fail.min(entries.len()));
266        }
267        if entries.is_empty() {
268            // The flusher errored but claimed every entry was delivered;
269            // nothing left to retry.
270            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    /// Records every flush call; pops a scripted [`FlushError`] per call
295    /// until the script is exhausted, then succeeds.
296    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    /// Polls `cond` for up to ~1s of (possibly auto-advanced) time.
330    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    /// Long timers so only the trigger under test can fire.
341    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        // Let the background task receive the entry and arm its deadline
384        // (yield_now keeps the clock still, unlike sleep under paused time).
385        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        // "a" (index 0) was delivered; only the tail is retried.
442        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        // All keys absent: defaults.
462        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        // Present keys override defaults.
471        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        // Non-integer value is an error naming the key.
489        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}