Skip to main content

laser_sdk/
batching.rs

1use crate::error::{LaserError, PublishFailure};
2use crate::laser::Laser;
3use iggy::prelude::{HeaderKey, HeaderValue, IggyMessage};
4use std::collections::BTreeMap;
5use std::sync::Arc;
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::time::Duration;
8use tokio::sync::{Mutex, Notify};
9
10/// Flush at this many queued records unless overridden.
11pub const DEFAULT_MAX_RECORDS: usize = 512;
12/// Flush at this many queued payload bytes unless overridden (1 MiB).
13pub const DEFAULT_MAX_BYTES: usize = 1024 * 1024;
14/// Flush a non-empty queue after at most this long unless overridden.
15pub const DEFAULT_LINGER: Duration = Duration::from_millis(5);
16/// The smallest linger the timer runs at. `tokio::time::interval` panics on a
17/// zero period, and a sub-millisecond linger spins the timer hot for no gain
18/// on a batching producer, so a zero or tiny value is floored here.
19pub const MIN_LINGER: Duration = Duration::from_millis(1);
20
21/// Builder for a [`BatchingProducer`], opened with
22/// [`Topic::batching`](crate::stream::Topic::batching). Every bound is
23/// explicit: the batch flushes on whichever of `max_records`, `max_bytes`, or
24/// `linger` trips first.
25pub struct BatchingProducerBuilder {
26    laser: Laser,
27    stream: String,
28    topic: String,
29    partition_key: Option<String>,
30    max_records: usize,
31    max_bytes: usize,
32    linger: Duration,
33}
34
35impl BatchingProducerBuilder {
36    pub(crate) fn new(laser: Laser, stream: String, topic: String) -> Self {
37        Self {
38            laser,
39            stream,
40            topic,
41            partition_key: None,
42            max_records: DEFAULT_MAX_RECORDS,
43            max_bytes: DEFAULT_MAX_BYTES,
44            linger: DEFAULT_LINGER,
45        }
46    }
47
48    /// Flush once this many records are queued.
49    #[must_use]
50    pub fn max_records(mut self, n: usize) -> Self {
51        self.max_records = n.max(1);
52        self
53    }
54
55    /// Flush once the queued payload bytes reach this bound.
56    #[must_use]
57    pub fn max_bytes(mut self, payload: usize) -> Self {
58        self.max_bytes = payload.max(1);
59        self
60    }
61
62    /// Flush a non-empty queue after at most this long, so a trickle of
63    /// records never waits for a full batch.
64    #[must_use]
65    pub fn linger(mut self, linger: Duration) -> Self {
66        self.linger = linger;
67        self
68    }
69
70    /// Pin every record in this handle to one partition key. One key per
71    /// handle by construction: batches are flushed whole under a single
72    /// partitioning, so ordering within the key is never silently
73    /// interleaved. Without a key, Iggy's balanced partitioner spreads each
74    /// flushed batch.
75    #[must_use]
76    pub fn partition_key(mut self, key: impl Into<String>) -> Self {
77        self.partition_key = Some(key.into());
78        self
79    }
80
81    /// Build the handle and start its linger timer.
82    pub fn build(self) -> BatchingProducer {
83        let inner = Arc::new(Inner {
84            laser: self.laser,
85            stream: self.stream,
86            topic: self.topic,
87            partition_key: self.partition_key,
88            max_records: self.max_records,
89            max_bytes: self.max_bytes,
90            queue: Mutex::new(Queue::default()),
91            kept: Mutex::new(None),
92            failed: AtomicBool::new(false),
93        });
94        let shutdown = Arc::new(Notify::new());
95        let timer = {
96            let inner = Arc::clone(&inner);
97            let shutdown = Arc::clone(&shutdown);
98            let linger = self.linger.max(MIN_LINGER);
99            tokio::spawn(async move {
100                let mut ticker = tokio::time::interval(linger);
101                ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
102                loop {
103                    // A graceful shutdown drains the queue and exits. Aborting
104                    // instead would drop a flush future mid-send (the batch is
105                    // already taken out of the queue), losing it silently, so
106                    // `close` signals here rather than calling `abort`.
107                    tokio::select! {
108                        _ = ticker.tick() => inner.flush_on_timer().await,
109                        _ = shutdown.notified() => {
110                            inner.flush_on_timer().await;
111                            break;
112                        }
113                    }
114                }
115            })
116        };
117        BatchingProducer {
118            inner,
119            timer: Some(timer),
120            shutdown,
121        }
122    }
123}
124
125/// A size-and-time batching publisher over one topic: `send` enqueues, the
126/// queue flushes as ONE Iggy `send_messages` call when `max_records`,
127/// `max_bytes`, or `linger` trips, whichever first. Opt-in construction: the
128/// unbatched publish path is untouched.
129///
130/// `flush().await` is the guaranteed path. Dropping the handle flushes
131/// best-effort on a background task and logs a failure. A caller that needs
132/// the last batch on the log awaits `flush` (or [`close`](Self::close))
133/// before dropping.
134///
135/// A failed linger flush never stops the timer. Its failure is kept and
136/// returned by the next `send`, `flush`, or `close`, whichever comes first.
137/// A `send` that finds it refuses its own record, so nothing queues behind a
138/// failed batch. `flush` and `close` drain the queue first. When several
139/// batches fail before the caller asks, the report is one
140/// [`LaserError::PublishFailed`] that lists the records of all of them.
141pub struct BatchingProducer {
142    inner: Arc<Inner>,
143    timer: Option<tokio::task::JoinHandle<()>>,
144    shutdown: Arc<Notify>,
145}
146
147#[derive(Default)]
148struct Queue {
149    messages: Vec<IggyMessage>,
150    payload: usize,
151}
152
153#[derive(Clone, Copy)]
154enum Trigger {
155    Send,
156    Timer,
157    Caller,
158}
159
160struct Inner {
161    laser: Laser,
162    stream: String,
163    topic: String,
164    partition_key: Option<String>,
165    max_records: usize,
166    max_bytes: usize,
167    queue: Mutex<Queue>,
168    // Held across the append, so batches reach the log in queue order. It
169    // guards the failure a timer flush left for the caller.
170    kept: Mutex<Option<LaserError>>,
171    // Mirrors whether a failure is kept, so a sender checks without waiting
172    // on a flush in flight.
173    failed: AtomicBool,
174}
175
176impl BatchingProducer {
177    /// Enqueue one payload with optional headers. Flushes inline when a size
178    /// bound trips, so backpressure lands on the sender, not the timer. An
179    /// error is the failure of that inline flush and lists this record as
180    /// unconfirmed. When a linger flush failed before this call, the record is
181    /// not queued: the error is that kept failure with this record added to
182    /// its unconfirmed records, reported once.
183    pub async fn send(
184        &self,
185        payload: impl Into<Vec<u8>>,
186        headers: BTreeMap<HeaderKey, HeaderValue>,
187    ) -> Result<(), LaserError> {
188        let payload = payload.into();
189        let message = IggyMessage::builder()
190            .payload(payload.into())
191            .user_headers(headers)
192            .build()?;
193        if let Some(kept) = self.inner.take_kept().await {
194            return Err(refuse(kept, message, &self.inner.stream, &self.inner.topic));
195        }
196        let flush_now = {
197            let mut queue = self.inner.queue.lock().await;
198            queue.payload += message.payload.len();
199            queue.messages.push(message);
200            queue.messages.len() >= self.inner.max_records || queue.payload >= self.inner.max_bytes
201        };
202        if flush_now {
203            self.inner.flush(Trigger::Send).await?;
204        }
205        Ok(())
206    }
207
208    /// Flush everything queued as one batch append, then report the failure
209    /// an earlier linger flush left, if any. A no-op on an empty queue.
210    pub async fn flush(&self) -> Result<(), LaserError> {
211        self.inner.flush(Trigger::Caller).await
212    }
213
214    /// Flush and stop the linger timer. The graceful shutdown spelling: the
215    /// timer is signalled (never aborted mid-flush, which would drop a batch
216    /// already taken from the queue) and awaited so its final drain completes,
217    /// then a last flush covers anything enqueued in the meantime and
218    /// reports a kept linger failure.
219    pub async fn close(mut self) -> Result<(), LaserError> {
220        self.shutdown.notify_one();
221        if let Some(timer) = self.timer.take() {
222            let _ = timer.await;
223        }
224        self.inner.flush(Trigger::Caller).await
225    }
226}
227
228impl Inner {
229    async fn flush(&self, trigger: Trigger) -> Result<(), LaserError> {
230        let mut kept = self.kept.lock().await;
231        let batch = {
232            let mut queue = self.queue.lock().await;
233            queue.payload = 0;
234            std::mem::take(&mut queue.messages)
235        };
236        let sent = if batch.is_empty() {
237            Ok(())
238        } else {
239            self.laser
240                .send_batch_on(
241                    &self.stream,
242                    &self.topic,
243                    batch,
244                    self.partition_key.as_deref(),
245                )
246                .await
247                .map(|_| ())
248        };
249        let settled = settle(trigger, &mut kept, sent);
250        self.failed.store(kept.is_some(), Ordering::Release);
251        settled
252    }
253
254    async fn flush_on_timer(&self) {
255        // A timer flush keeps its failure for the caller, so it has nothing to return.
256        let _ = self.flush(Trigger::Timer).await;
257    }
258
259    // The failure a linger flush kept, taken so it is reported once.
260    async fn take_kept(&self) -> Option<LaserError> {
261        if !self.failed.load(Ordering::Acquire) {
262            return None;
263        }
264        let mut kept = self.kept.lock().await;
265        self.failed.store(false, Ordering::Release);
266        kept.take()
267    }
268}
269
270// Decides who hears about a flush result. A sender hears its own inline
271// flush. The timer keeps a failure. The caller hears everything kept so far
272// together with its own flush.
273fn settle(
274    trigger: Trigger,
275    kept: &mut Option<LaserError>,
276    sent: Result<(), LaserError>,
277) -> Result<(), LaserError> {
278    match trigger {
279        Trigger::Send => sent,
280        Trigger::Timer => {
281            if let Err(error) = sent {
282                tracing::warn!(%error, "linger flush failed");
283                *kept = Some(match kept.take() {
284                    Some(earlier) => merge(earlier, error),
285                    None => error,
286                });
287            }
288            Ok(())
289        }
290        Trigger::Caller => match (kept.take(), sent) {
291            (None, sent) => sent,
292            (Some(earlier), Ok(())) => Err(earlier),
293            (Some(earlier), Err(error)) => Err(merge(earlier, error)),
294        },
295    }
296}
297
298// A kept failure reported to a sender, with the sender's record added to the
299// unconfirmed records because it was never queued. A kept failure that lists
300// no records becomes the cause of a publish failure for this one record.
301fn refuse(kept: LaserError, record: IggyMessage, stream: &str, topic: &str) -> LaserError {
302    match kept {
303        LaserError::PublishFailed(mut failure) => {
304            failure.unconfirmed.push(record);
305            LaserError::PublishFailed(failure)
306        }
307        source => LaserError::PublishFailed(Box::new(PublishFailure {
308            source,
309            stream: stream.to_owned(),
310            topic: topic.to_owned(),
311            committed: Vec::new(),
312            unconfirmed: vec![record],
313        })),
314    }
315}
316
317// One report for every batch that failed: the first cause, with the records
318// of the later batch added. A later failure that carries no records is logged.
319fn merge(earlier: LaserError, later: LaserError) -> LaserError {
320    match (earlier, later) {
321        (LaserError::PublishFailed(mut first), LaserError::PublishFailed(next)) => {
322            let next = *next;
323            first.committed.extend(next.committed);
324            first.unconfirmed.extend(next.unconfirmed);
325            LaserError::PublishFailed(first)
326        }
327        (earlier, later) => {
328            tracing::warn!(error = %later, "a later batch flush failed while an earlier failure was kept");
329            earlier
330        }
331    }
332}
333
334impl Drop for BatchingProducer {
335    fn drop(&mut self) {
336        // Signal the timer to drain and exit rather than aborting it (abort
337        // could drop a flush future mid-send). Best-effort: the guaranteed
338        // path is an awaited `flush`/`close`. `close` already took the timer
339        // handle, so this only fires on a bare drop.
340        if self.timer.take().is_some() {
341            self.shutdown.notify_one();
342        }
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    fn producer() -> BatchingProducer {
351        let laser = Laser::from_client(iggy::prelude::IggyClient::default());
352        BatchingProducerBuilder::new(laser, "fleet".into(), "readings".into()).build()
353    }
354
355    fn publish_failure(payloads: &[&'static str]) -> LaserError {
356        LaserError::PublishFailed(Box::new(PublishFailure {
357            source: LaserError::Invalid("append refused".into()),
358            stream: "fleet".into(),
359            topic: "readings".into(),
360            committed: Vec::new(),
361            unconfirmed: payloads
362                .iter()
363                .map(|payload| {
364                    IggyMessage::builder()
365                        .payload((*payload).into())
366                        .build()
367                        .expect("a test message should build")
368                })
369                .collect(),
370        }))
371    }
372
373    fn unconfirmed(error: &LaserError) -> Vec<&[u8]> {
374        match error {
375            LaserError::PublishFailed(failure) => failure
376                .unconfirmed
377                .iter()
378                .map(|message| message.payload.as_ref())
379                .collect(),
380            other => panic!("expected a publish failure, got {other:?}"),
381        }
382    }
383
384    #[tokio::test]
385    async fn given_a_kept_timer_failure_when_flushing_then_should_report_it_once() {
386        let producer = producer();
387        *producer.inner.kept.lock().await = Some(LaserError::Invalid("failed batch".into()));
388        producer.inner.flush_on_timer().await;
389        assert!(matches!(
390            producer.flush().await,
391            Err(LaserError::Invalid(_))
392        ));
393        producer
394            .close()
395            .await
396            .expect("the reported failure is not repeated");
397    }
398
399    #[tokio::test]
400    async fn given_a_kept_timer_failure_when_sending_then_should_refuse_the_record_and_report_both()
401    {
402        let producer = producer();
403        *producer.inner.kept.lock().await = Some(publish_failure(&["a"]));
404        producer.inner.flush_on_timer().await;
405        let error = producer
406            .send("b", BTreeMap::new())
407            .await
408            .expect_err("the sender hears the kept failure");
409        assert_eq!(unconfirmed(&error), [b"a".as_slice(), b"b"]);
410        assert!(
411            producer.inner.queue.lock().await.messages.is_empty(),
412            "the refused record is not queued"
413        );
414        producer
415            .close()
416            .await
417            .expect("the reported failure is not repeated");
418    }
419
420    #[tokio::test]
421    async fn given_a_kept_failure_without_records_when_sending_then_should_list_the_refused_record()
422    {
423        let producer = producer();
424        *producer.inner.kept.lock().await = Some(LaserError::Invalid("failed batch".into()));
425        producer.inner.flush_on_timer().await;
426        let error = producer
427            .send("b", BTreeMap::new())
428            .await
429            .expect_err("the sender hears the kept failure");
430        assert_eq!(unconfirmed(&error), [b"b".as_slice()]);
431        let LaserError::PublishFailed(failure) = error else {
432            panic!("a publish failure")
433        };
434        assert!(matches!(failure.source, LaserError::Invalid(_)));
435        assert_eq!(
436            (failure.stream.as_str(), failure.topic.as_str()),
437            ("fleet", "readings")
438        );
439    }
440
441    #[tokio::test]
442    async fn given_a_kept_timer_failure_when_closing_then_should_not_report_success() {
443        let producer = producer();
444        *producer.inner.kept.lock().await = Some(LaserError::Invalid("failed batch".into()));
445        assert!(matches!(
446            producer.close().await,
447            Err(LaserError::Invalid(_))
448        ));
449    }
450
451    #[test]
452    fn given_a_failed_timer_flush_when_settled_then_should_keep_it_and_report_success() {
453        let mut kept = None;
454        settle(Trigger::Timer, &mut kept, Err(publish_failure(&["a"])))
455            .expect("the timer keeps the failure");
456        assert_eq!(
457            unconfirmed(kept.as_ref().expect("the failure is kept")),
458            [b"a"]
459        );
460    }
461
462    #[test]
463    fn given_two_failed_timer_flushes_when_settled_then_should_keep_the_records_of_both() {
464        let mut kept = None;
465        settle(Trigger::Timer, &mut kept, Err(publish_failure(&["a"])))
466            .expect("the timer keeps the first failure");
467        settle(Trigger::Timer, &mut kept, Err(publish_failure(&["b", "c"])))
468            .expect("the timer keeps the second failure");
469        assert_eq!(
470            unconfirmed(kept.as_ref().expect("the failures are kept")),
471            [b"a".as_slice(), b"b", b"c"]
472        );
473    }
474
475    #[test]
476    fn given_a_kept_failure_when_a_sender_flush_succeeds_then_should_not_report_it_to_the_sender() {
477        let mut kept = Some(publish_failure(&["a"]));
478        settle(Trigger::Send, &mut kept, Ok(())).expect("the sender hears only its own flush");
479        assert!(kept.is_some());
480    }
481
482    #[test]
483    fn given_a_kept_failure_when_the_caller_flush_also_fails_then_should_report_both_once() {
484        let mut kept = Some(publish_failure(&["a"]));
485        let error = settle(Trigger::Caller, &mut kept, Err(publish_failure(&["b"])))
486            .expect_err("the caller hears both failures");
487        assert_eq!(unconfirmed(&error), [b"a".as_slice(), b"b"]);
488        assert!(kept.is_none());
489    }
490
491    #[test]
492    fn given_a_kept_failure_when_the_caller_flush_succeeds_then_should_report_the_kept_one() {
493        let mut kept = Some(publish_failure(&["a"]));
494        let error = settle(Trigger::Caller, &mut kept, Ok(()))
495            .expect_err("the caller hears the kept failure");
496        assert_eq!(unconfirmed(&error), [b"a".as_slice()]);
497        assert!(kept.is_none());
498    }
499}