use crate::sink::metrics::KafkaSinkStatsMetrics;
use rdkafka::client::ClientContext;
use rdkafka::error::RDKafkaErrorCode;
use rdkafka::message::DeliveryResult;
use rdkafka::producer::ProducerContext;
use rdkafka::statistics::Statistics;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Instant;
use tokio::sync::Notify;
#[derive(Debug)]
pub(crate) struct BatchInflight {
remaining: AtomicUsize,
failed: AtomicUsize,
first_error: OnceLock<(Option<RDKafkaErrorCode>, String)>,
done: Notify,
}
impl BatchInflight {
pub(crate) fn new(messages: usize) -> Self {
BatchInflight {
remaining: AtomicUsize::new(messages),
failed: AtomicUsize::new(0),
first_error: OnceLock::new(),
done: Notify::new(),
}
}
pub(crate) fn record_failure(&self, code: Option<RDKafkaErrorCode>, reason: String) {
let _ = self.first_error.set((code, reason));
self.failed.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn complete_one(&self) {
if self.remaining.fetch_sub(1, Ordering::AcqRel) == 1 {
self.done.notify_one();
}
}
pub(crate) async fn wait(&self, deadline: Instant) -> bool {
loop {
let notified = self.done.notified();
if self.remaining.load(Ordering::Acquire) == 0 {
return true;
}
let Some(left) = deadline.checked_duration_since(Instant::now()) else {
return self.remaining.load(Ordering::Acquire) == 0;
};
if tokio::time::timeout(left, notified).await.is_err() {
return self.remaining.load(Ordering::Acquire) == 0;
}
}
}
pub(crate) fn first_error(&self) -> Option<&(Option<RDKafkaErrorCode>, String)> {
self.first_error.get()
}
pub(crate) fn failed(&self) -> usize {
self.failed.load(Ordering::Relaxed)
}
}
#[derive(Debug)]
pub(crate) struct SinkContext {
stats: Arc<Mutex<Option<KafkaSinkStatsMetrics>>>,
}
impl SinkContext {
pub(crate) fn new(stats: Arc<Mutex<Option<KafkaSinkStatsMetrics>>>) -> Self {
SinkContext { stats }
}
pub(crate) fn detached() -> Self {
SinkContext::new(Arc::new(Mutex::new(None)))
}
}
impl ClientContext for SinkContext {
fn stats(&self, statistics: Statistics) {
if let Some(metrics) = self.stats.lock().expect("stats slot lock").as_mut() {
metrics.update(&statistics);
}
}
fn log(&self, level: rdkafka::config::RDKafkaLogLevel, fac: &str, log_message: &str) {
use rdkafka::config::RDKafkaLogLevel as L;
match level {
L::Emerg | L::Alert | L::Critical | L::Error => {
tracing::error!(target: "librdkafka", fac, "{log_message}");
}
L::Warning => tracing::warn!(target: "librdkafka", fac, "{log_message}"),
L::Notice | L::Info => tracing::info!(target: "librdkafka", fac, "{log_message}"),
L::Debug => tracing::debug!(target: "librdkafka", fac, "{log_message}"),
}
}
fn error(&self, error: rdkafka::error::KafkaError, reason: &str) {
tracing::warn!(target: "librdkafka", %error, "{reason}");
}
}
impl ProducerContext for SinkContext {
type DeliveryOpaque = Arc<BatchInflight>;
fn delivery(&self, delivery_result: &DeliveryResult<'_>, inflight: Arc<BatchInflight>) {
if let Err((error, _message)) = delivery_result {
inflight.record_failure(error.rdkafka_error_code(), error.to_string());
}
inflight.complete_one();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[tokio::test]
async fn countdown_completes_on_last_delivery() {
let inflight = Arc::new(BatchInflight::new(3));
let waiter = {
let inflight = Arc::clone(&inflight);
tokio::spawn(
async move { inflight.wait(Instant::now() + Duration::from_secs(5)).await },
)
};
let reporter = {
let inflight = Arc::clone(&inflight);
std::thread::spawn(move || {
for _ in 0..3 {
inflight.complete_one();
}
})
};
assert!(waiter.await.unwrap(), "all reports in → wait resolves true");
reporter.join().unwrap();
}
#[tokio::test]
async fn wait_times_out_when_reports_are_missing() {
let inflight = BatchInflight::new(2);
inflight.complete_one();
let deadline = Instant::now() + Duration::from_millis(50);
assert!(!inflight.wait(deadline).await, "one report missing → false");
}
#[tokio::test]
async fn first_error_wins_and_is_visible_after_countdown() {
let inflight = Arc::new(BatchInflight::new(2));
let reporter = {
let inflight = Arc::clone(&inflight);
std::thread::spawn(move || {
inflight
.record_failure(Some(RDKafkaErrorCode::MessageTimedOut), "first".to_string());
inflight.complete_one();
inflight.record_failure(
Some(RDKafkaErrorCode::MessageSizeTooLarge),
"second".to_string(),
);
inflight.complete_one();
})
};
assert!(inflight.wait(Instant::now() + Duration::from_secs(5)).await);
reporter.join().unwrap();
let (code, reason) = inflight.first_error().expect("an error was recorded");
assert_eq!(*code, Some(RDKafkaErrorCode::MessageTimedOut));
assert_eq!(reason, "first");
assert_eq!(inflight.failed(), 2, "later failures still count");
}
#[tokio::test]
async fn abandoned_countdown_reclaims_arcs() {
let inflight = Arc::new(BatchInflight::new(2));
let opaques = [Arc::clone(&inflight), Arc::clone(&inflight)];
{
let _abandoned = inflight.wait(Instant::now());
}
for opaque in opaques {
opaque.record_failure(None, "purged at teardown".to_string());
opaque.complete_one();
drop(opaque);
}
assert_eq!(
Arc::strong_count(&inflight),
1,
"all opaque references reclaimed"
);
assert!(inflight.wait(Instant::now()).await);
}
}