use std::collections::HashMap;
use std::fmt;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use rdkafka::TopicPartitionList;
use rdkafka::consumer::ConsumerGroupMetadata;
use rdkafka::producer::{FutureProducer, FutureRecord, Producer as _};
use rdkafka::util::Timeout;
use ruststream::{OutgoingMessage, Publisher, TransactionalPublisher};
use tokio::sync::OnceCell;
use tokio::task;
use crate::broker::SharedConn;
use crate::convert;
use crate::error::KafkaError;
const DEFAULT_TRANSACTION_TIMEOUT: Duration = Duration::from_secs(30);
struct TxState {
id: String,
timeout: Duration,
producer: OnceCell<FutureProducer>,
open: Mutex<bool>,
}
impl fmt::Debug for TxState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TxState")
.field("id", &self.id)
.field("timeout", &self.timeout)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone)]
pub struct KafkaPublisher {
conn: SharedConn,
queue_timeout: Option<Duration>,
tx: Option<Arc<TxState>>,
}
impl KafkaPublisher {
pub(crate) fn new(conn: SharedConn) -> Self {
Self {
conn,
queue_timeout: None,
tx: None,
}
}
#[must_use]
pub fn queue_timeout(mut self, timeout: Duration) -> Self {
self.queue_timeout = Some(timeout);
self
}
#[must_use]
pub fn transactional_id(mut self, id: impl Into<String>) -> Self {
self.tx = Some(Arc::new(TxState {
id: id.into(),
timeout: DEFAULT_TRANSACTION_TIMEOUT,
producer: OnceCell::new(),
open: Mutex::new(false),
}));
self
}
#[must_use]
pub fn transaction_timeout(mut self, timeout: Duration) -> Self {
if let Some(tx) = &self.tx {
self.tx = Some(Arc::new(TxState {
id: tx.id.clone(),
timeout,
producer: OnceCell::new(),
open: Mutex::new(false),
}));
}
self
}
fn tx_or_invalid(&self) -> Result<&Arc<TxState>, KafkaError> {
self.tx.as_ref().ok_or_else(|| {
KafkaError::InvalidOptions(
"transactional publishing needs `KafkaPublisher::transactional_id`; a plain \
publisher cannot begin, commit, or abort transactions"
.to_owned(),
)
})
}
async fn tx_producer(&self, tx: &Arc<TxState>) -> Result<FutureProducer, KafkaError> {
let producer = tx
.producer
.get_or_try_init(|| async {
let state = self.conn.get().ok_or(KafkaError::NotConnected)?;
let mut config = state.producer_config().clone();
config.set("transactional.id", &tx.id);
let producer: FutureProducer = config.create().map_err(KafkaError::publish)?;
let init = producer.clone();
let timeout = tx.timeout;
task::spawn_blocking(move || init.init_transactions(timeout))
.await
.map_err(|err| KafkaError::Publish(Box::new(err)))?
.map_err(KafkaError::publish)?;
Ok(producer)
})
.await?;
Ok(producer.clone())
}
pub(crate) fn shared_conn(&self) -> SharedConn {
Arc::clone(&self.conn)
}
pub(crate) fn transactional_id_str(&self) -> Option<&str> {
self.tx.as_ref().map(|tx| tx.id.as_str())
}
pub(crate) fn transaction_deadline(&self) -> Duration {
self.tx
.as_ref()
.map_or(DEFAULT_TRANSACTION_TIMEOUT, |tx| tx.timeout)
}
pub(crate) async fn send_offsets(
&self,
offsets: TopicPartitionList,
metadata: ConsumerGroupMetadata,
) -> Result<(), KafkaError> {
let tx = self.tx_or_invalid()?.clone();
let producer = self.tx_producer(&tx).await?;
let timeout = tx.timeout;
task::spawn_blocking(move || {
producer.send_offsets_to_transaction(&offsets, &metadata, timeout)
})
.await
.map_err(|err| KafkaError::Publish(Box::new(err)))?
.map_err(KafkaError::publish)
}
fn is_open(tx: &TxState) -> bool {
*tx.open.lock().expect("transaction state mutex poisoned")
}
fn set_open(tx: &TxState, open: bool) {
*tx.open.lock().expect("transaction state mutex poisoned") = open;
}
async fn send_via(
&self,
producer: &FutureProducer,
msg: OutgoingMessage<'_>,
) -> Result<(), KafkaError> {
let parts = convert::headers_for_publish(msg.headers())?;
let mut record = FutureRecord::<[u8], [u8]>::to(msg.name()).payload(msg.payload());
if let Some(key) = &parts.key {
record = record.key(key.as_ref());
}
if let Some(partition) = parts.partition {
record = record.partition(partition);
}
if let Some(headers) = parts.headers {
record = record.headers(headers);
}
let queue_timeout = self.queue_timeout.map_or(Timeout::Never, Timeout::After);
producer
.send(record, queue_timeout)
.await
.map(|_delivery| ())
.map_err(|(err, _record)| KafkaError::publish(err))
}
}
impl Publisher for KafkaPublisher {
type Error = KafkaError;
async fn publish(&self, msg: OutgoingMessage<'_>) -> Result<(), Self::Error> {
if let Some(tx) = &self.tx
&& Self::is_open(tx)
{
let producer = self.tx_producer(tx).await?;
return self.send_via(&producer, msg).await;
}
let state = self.conn.get().ok_or(KafkaError::NotConnected)?;
self.send_via(state.producer(), msg).await
}
}
impl TransactionalPublisher for KafkaPublisher {
#[allow(clippy::significant_drop_tightening)]
async fn begin_transaction(&self) -> Result<(), Self::Error> {
let tx = self.tx_or_invalid()?.clone();
let producer = self.tx_producer(&tx).await?;
let mut open = tx.open.lock().expect("transaction state mutex poisoned");
if *open {
return Err(KafkaError::TransactionBusy);
}
producer.begin_transaction().map_err(KafkaError::publish)?;
*open = true;
Ok(())
}
async fn commit(&self) -> Result<(), Self::Error> {
let tx = self.tx_or_invalid()?.clone();
if !Self::is_open(&tx) {
return Ok(());
}
let producer = self.tx_producer(&tx).await?;
let timeout = tx.timeout;
task::spawn_blocking(move || producer.commit_transaction(timeout))
.await
.map_err(|err| KafkaError::Publish(Box::new(err)))?
.map_err(KafkaError::publish)?;
Self::set_open(&tx, false);
Ok(())
}
async fn abort(&self) -> Result<(), Self::Error> {
let tx = self.tx_or_invalid()?.clone();
if !Self::is_open(&tx) {
return Ok(());
}
let producer = self.tx_producer(&tx).await?;
let timeout = tx.timeout;
task::spawn_blocking(move || producer.abort_transaction(timeout))
.await
.map_err(|err| KafkaError::Publish(Box::new(err)))?
.map_err(KafkaError::publish)?;
Self::set_open(&tx, false);
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct TransactionalPartitions {
inner: Arc<PartitionsInner>,
}
#[derive(Debug)]
struct PartitionsInner {
template: KafkaPublisher,
id_base: String,
timeout: Option<Duration>,
publishers: Mutex<HashMap<i32, KafkaPublisher>>,
}
impl TransactionalPartitions {
#[must_use]
pub fn new(template: KafkaPublisher, id_base: impl Into<String>) -> Self {
Self {
inner: Arc::new(PartitionsInner {
template,
id_base: id_base.into(),
timeout: None,
publishers: Mutex::new(HashMap::new()),
}),
}
}
#[must_use]
pub fn transaction_timeout(self, timeout: Duration) -> Self {
Self {
inner: Arc::new(PartitionsInner {
template: self.inner.template.clone(),
id_base: self.inner.id_base.clone(),
timeout: Some(timeout),
publishers: Mutex::new(HashMap::new()),
}),
}
}
#[must_use]
pub fn for_partition(&self, partition: i32) -> KafkaPublisher {
let mut publishers = self
.inner
.publishers
.lock()
.expect("partition publisher cache mutex poisoned");
publishers
.entry(partition)
.or_insert_with(|| {
let id = format!("{}-p{partition}", self.inner.id_base);
let publisher = self.inner.template.clone().transactional_id(id);
match self.inner.timeout {
Some(timeout) => publisher.transaction_timeout(timeout),
None => publisher,
}
})
.clone()
}
}
#[cfg(test)]
mod tests {
use ruststream::TransactionalPublisher as _;
use super::*;
fn tx_id(publisher: &KafkaPublisher) -> Option<String> {
publisher.tx.as_ref().map(|tx| tx.id.clone())
}
#[tokio::test]
async fn transactions_without_an_id_fail_clearly() {
let publisher = KafkaPublisher::new(Arc::default());
let err = publisher
.begin_transaction()
.await
.expect_err("begin without transactional_id must fail");
assert!(matches!(err, KafkaError::InvalidOptions(_)));
assert!(err.to_string().contains("transactional_id"));
}
#[test]
fn partitions_derive_ids_and_share_the_cache() {
let set = TransactionalPartitions::new(KafkaPublisher::new(Arc::default()), "svc-1");
let three = set.for_partition(3);
assert_eq!(tx_id(&three).as_deref(), Some("svc-1-p3"));
assert_eq!(tx_id(&set.for_partition(0)).as_deref(), Some("svc-1-p0"));
#[allow(clippy::redundant_clone)]
let cloned = set.clone();
let again = cloned.for_partition(3);
let (left, right) = (
three.tx.expect("transactional"),
again.tx.expect("transactional"),
);
assert!(Arc::ptr_eq(&left, &right));
}
#[test]
fn partitions_template_id_is_replaced() {
let template = KafkaPublisher::new(Arc::default()).transactional_id("ignored");
let set = TransactionalPartitions::new(template, "svc-1");
assert_eq!(tx_id(&set.for_partition(7)).as_deref(), Some("svc-1-p7"));
}
}