use crate::error::{LaserError, PublishFailure};
use crate::laser::Laser;
use iggy::prelude::{HeaderKey, HeaderValue, IggyMessage};
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::sync::{Mutex, Notify};
pub const DEFAULT_MAX_RECORDS: usize = 512;
pub const DEFAULT_MAX_BYTES: usize = 1024 * 1024;
pub const DEFAULT_LINGER: Duration = Duration::from_millis(5);
pub const MIN_LINGER: Duration = Duration::from_millis(1);
pub struct BatchingProducerBuilder {
laser: Laser,
stream: String,
topic: String,
partition_key: Option<String>,
max_records: usize,
max_bytes: usize,
linger: Duration,
}
impl BatchingProducerBuilder {
pub(crate) fn new(laser: Laser, stream: String, topic: String) -> Self {
Self {
laser,
stream,
topic,
partition_key: None,
max_records: DEFAULT_MAX_RECORDS,
max_bytes: DEFAULT_MAX_BYTES,
linger: DEFAULT_LINGER,
}
}
#[must_use]
pub fn max_records(mut self, n: usize) -> Self {
self.max_records = n.max(1);
self
}
#[must_use]
pub fn max_bytes(mut self, payload: usize) -> Self {
self.max_bytes = payload.max(1);
self
}
#[must_use]
pub fn linger(mut self, linger: Duration) -> Self {
self.linger = linger;
self
}
#[must_use]
pub fn partition_key(mut self, key: impl Into<String>) -> Self {
self.partition_key = Some(key.into());
self
}
pub fn build(self) -> BatchingProducer {
let inner = Arc::new(Inner {
laser: self.laser,
stream: self.stream,
topic: self.topic,
partition_key: self.partition_key,
max_records: self.max_records,
max_bytes: self.max_bytes,
queue: Mutex::new(Queue::default()),
kept: Mutex::new(None),
failed: AtomicBool::new(false),
});
let shutdown = Arc::new(Notify::new());
let timer = {
let inner = Arc::clone(&inner);
let shutdown = Arc::clone(&shutdown);
let linger = self.linger.max(MIN_LINGER);
tokio::spawn(async move {
let mut ticker = tokio::time::interval(linger);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
_ = ticker.tick() => inner.flush_on_timer().await,
_ = shutdown.notified() => {
inner.flush_on_timer().await;
break;
}
}
}
})
};
BatchingProducer {
inner,
timer: Some(timer),
shutdown,
}
}
}
pub struct BatchingProducer {
inner: Arc<Inner>,
timer: Option<tokio::task::JoinHandle<()>>,
shutdown: Arc<Notify>,
}
#[derive(Default)]
struct Queue {
messages: Vec<IggyMessage>,
payload: usize,
}
#[derive(Clone, Copy)]
enum Trigger {
Send,
Timer,
Caller,
}
struct Inner {
laser: Laser,
stream: String,
topic: String,
partition_key: Option<String>,
max_records: usize,
max_bytes: usize,
queue: Mutex<Queue>,
kept: Mutex<Option<LaserError>>,
failed: AtomicBool,
}
impl BatchingProducer {
pub async fn send(
&self,
payload: impl Into<Vec<u8>>,
headers: BTreeMap<HeaderKey, HeaderValue>,
) -> Result<(), LaserError> {
let payload = payload.into();
let message = IggyMessage::builder()
.payload(payload.into())
.user_headers(headers)
.build()?;
if let Some(kept) = self.inner.take_kept().await {
return Err(refuse(kept, message, &self.inner.stream, &self.inner.topic));
}
let flush_now = {
let mut queue = self.inner.queue.lock().await;
queue.payload += message.payload.len();
queue.messages.push(message);
queue.messages.len() >= self.inner.max_records || queue.payload >= self.inner.max_bytes
};
if flush_now {
self.inner.flush(Trigger::Send).await?;
}
Ok(())
}
pub async fn flush(&self) -> Result<(), LaserError> {
self.inner.flush(Trigger::Caller).await
}
pub async fn close(mut self) -> Result<(), LaserError> {
self.shutdown.notify_one();
if let Some(timer) = self.timer.take() {
let _ = timer.await;
}
self.inner.flush(Trigger::Caller).await
}
}
impl Inner {
async fn flush(&self, trigger: Trigger) -> Result<(), LaserError> {
let mut kept = self.kept.lock().await;
let batch = {
let mut queue = self.queue.lock().await;
queue.payload = 0;
std::mem::take(&mut queue.messages)
};
let sent = if batch.is_empty() {
Ok(())
} else {
self.laser
.send_batch_on(
&self.stream,
&self.topic,
batch,
self.partition_key.as_deref(),
)
.await
.map(|_| ())
};
let settled = settle(trigger, &mut kept, sent);
self.failed.store(kept.is_some(), Ordering::Release);
settled
}
async fn flush_on_timer(&self) {
let _ = self.flush(Trigger::Timer).await;
}
async fn take_kept(&self) -> Option<LaserError> {
if !self.failed.load(Ordering::Acquire) {
return None;
}
let mut kept = self.kept.lock().await;
self.failed.store(false, Ordering::Release);
kept.take()
}
}
fn settle(
trigger: Trigger,
kept: &mut Option<LaserError>,
sent: Result<(), LaserError>,
) -> Result<(), LaserError> {
match trigger {
Trigger::Send => sent,
Trigger::Timer => {
if let Err(error) = sent {
tracing::warn!(%error, "linger flush failed");
*kept = Some(match kept.take() {
Some(earlier) => merge(earlier, error),
None => error,
});
}
Ok(())
}
Trigger::Caller => match (kept.take(), sent) {
(None, sent) => sent,
(Some(earlier), Ok(())) => Err(earlier),
(Some(earlier), Err(error)) => Err(merge(earlier, error)),
},
}
}
fn refuse(kept: LaserError, record: IggyMessage, stream: &str, topic: &str) -> LaserError {
match kept {
LaserError::PublishFailed(mut failure) => {
failure.unconfirmed.push(record);
LaserError::PublishFailed(failure)
}
source => LaserError::PublishFailed(Box::new(PublishFailure {
source,
stream: stream.to_owned(),
topic: topic.to_owned(),
committed: Vec::new(),
unconfirmed: vec![record],
})),
}
}
fn merge(earlier: LaserError, later: LaserError) -> LaserError {
match (earlier, later) {
(LaserError::PublishFailed(mut first), LaserError::PublishFailed(next)) => {
let next = *next;
first.committed.extend(next.committed);
first.unconfirmed.extend(next.unconfirmed);
LaserError::PublishFailed(first)
}
(earlier, later) => {
tracing::warn!(error = %later, "a later batch flush failed while an earlier failure was kept");
earlier
}
}
}
impl Drop for BatchingProducer {
fn drop(&mut self) {
if self.timer.take().is_some() {
self.shutdown.notify_one();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn producer() -> BatchingProducer {
let laser = Laser::from_client(iggy::prelude::IggyClient::default());
BatchingProducerBuilder::new(laser, "fleet".into(), "readings".into()).build()
}
fn publish_failure(payloads: &[&'static str]) -> LaserError {
LaserError::PublishFailed(Box::new(PublishFailure {
source: LaserError::Invalid("append refused".into()),
stream: "fleet".into(),
topic: "readings".into(),
committed: Vec::new(),
unconfirmed: payloads
.iter()
.map(|payload| {
IggyMessage::builder()
.payload((*payload).into())
.build()
.expect("a test message should build")
})
.collect(),
}))
}
fn unconfirmed(error: &LaserError) -> Vec<&[u8]> {
match error {
LaserError::PublishFailed(failure) => failure
.unconfirmed
.iter()
.map(|message| message.payload.as_ref())
.collect(),
other => panic!("expected a publish failure, got {other:?}"),
}
}
#[tokio::test]
async fn given_a_kept_timer_failure_when_flushing_then_should_report_it_once() {
let producer = producer();
*producer.inner.kept.lock().await = Some(LaserError::Invalid("failed batch".into()));
producer.inner.flush_on_timer().await;
assert!(matches!(
producer.flush().await,
Err(LaserError::Invalid(_))
));
producer
.close()
.await
.expect("the reported failure is not repeated");
}
#[tokio::test]
async fn given_a_kept_timer_failure_when_sending_then_should_refuse_the_record_and_report_both()
{
let producer = producer();
*producer.inner.kept.lock().await = Some(publish_failure(&["a"]));
producer.inner.flush_on_timer().await;
let error = producer
.send("b", BTreeMap::new())
.await
.expect_err("the sender hears the kept failure");
assert_eq!(unconfirmed(&error), [b"a".as_slice(), b"b"]);
assert!(
producer.inner.queue.lock().await.messages.is_empty(),
"the refused record is not queued"
);
producer
.close()
.await
.expect("the reported failure is not repeated");
}
#[tokio::test]
async fn given_a_kept_failure_without_records_when_sending_then_should_list_the_refused_record()
{
let producer = producer();
*producer.inner.kept.lock().await = Some(LaserError::Invalid("failed batch".into()));
producer.inner.flush_on_timer().await;
let error = producer
.send("b", BTreeMap::new())
.await
.expect_err("the sender hears the kept failure");
assert_eq!(unconfirmed(&error), [b"b".as_slice()]);
let LaserError::PublishFailed(failure) = error else {
panic!("a publish failure")
};
assert!(matches!(failure.source, LaserError::Invalid(_)));
assert_eq!(
(failure.stream.as_str(), failure.topic.as_str()),
("fleet", "readings")
);
}
#[tokio::test]
async fn given_a_kept_timer_failure_when_closing_then_should_not_report_success() {
let producer = producer();
*producer.inner.kept.lock().await = Some(LaserError::Invalid("failed batch".into()));
assert!(matches!(
producer.close().await,
Err(LaserError::Invalid(_))
));
}
#[test]
fn given_a_failed_timer_flush_when_settled_then_should_keep_it_and_report_success() {
let mut kept = None;
settle(Trigger::Timer, &mut kept, Err(publish_failure(&["a"])))
.expect("the timer keeps the failure");
assert_eq!(
unconfirmed(kept.as_ref().expect("the failure is kept")),
[b"a"]
);
}
#[test]
fn given_two_failed_timer_flushes_when_settled_then_should_keep_the_records_of_both() {
let mut kept = None;
settle(Trigger::Timer, &mut kept, Err(publish_failure(&["a"])))
.expect("the timer keeps the first failure");
settle(Trigger::Timer, &mut kept, Err(publish_failure(&["b", "c"])))
.expect("the timer keeps the second failure");
assert_eq!(
unconfirmed(kept.as_ref().expect("the failures are kept")),
[b"a".as_slice(), b"b", b"c"]
);
}
#[test]
fn given_a_kept_failure_when_a_sender_flush_succeeds_then_should_not_report_it_to_the_sender() {
let mut kept = Some(publish_failure(&["a"]));
settle(Trigger::Send, &mut kept, Ok(())).expect("the sender hears only its own flush");
assert!(kept.is_some());
}
#[test]
fn given_a_kept_failure_when_the_caller_flush_also_fails_then_should_report_both_once() {
let mut kept = Some(publish_failure(&["a"]));
let error = settle(Trigger::Caller, &mut kept, Err(publish_failure(&["b"])))
.expect_err("the caller hears both failures");
assert_eq!(unconfirmed(&error), [b"a".as_slice(), b"b"]);
assert!(kept.is_none());
}
#[test]
fn given_a_kept_failure_when_the_caller_flush_succeeds_then_should_report_the_kept_one() {
let mut kept = Some(publish_failure(&["a"]));
let error = settle(Trigger::Caller, &mut kept, Ok(()))
.expect_err("the caller hears the kept failure");
assert_eq!(unconfirmed(&error), [b"a".as_slice()]);
assert!(kept.is_none());
}
}