#![cfg_attr(
not(feature = "testing"),
allow(clippy::infallible_destructuring_match)
)]
use std::collections::HashMap;
use std::fmt;
use std::future::{Future, ready};
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;
#[cfg(feature = "asyncapi")]
use ruststream::asyncapi::Bindings;
use ruststream::runtime::{OutPipeline, PublishBuilder, PublishSink, Slot};
use ruststream::{
DefaultPublish, Lend, OutgoingMessage, PairError, PublishPolicy, Publisher,
TransactionalPublisher,
};
use tokio::sync::OnceCell;
use tokio::task;
#[cfg(feature = "testing")]
use rdkafka::types::RDKafkaType;
#[cfg(feature = "testing")]
use ruststream::Str;
use crate::broker::{ConnState, ConnectedKafkaBroker};
use crate::convert;
use crate::error::KafkaError;
#[cfg(feature = "testing")]
use crate::in_process::{Cluster, ProducerId, create_offline};
const DEFAULT_TRANSACTION_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[must_use]
pub struct KafkaOptions {
partition: Option<i32>,
}
impl KafkaOptions {
pub const fn partition(mut self, partition: i32) -> Self {
self.partition = Some(partition);
self
}
#[must_use]
pub const fn partition_setting(self) -> Option<i32> {
self.partition
}
}
pub trait KafkaPublishSteps {
#[must_use]
fn partition(self, partition: i32) -> Self;
}
impl<Sink, Body, Enc, Hdrs, Dest> KafkaPublishSteps for PublishBuilder<Sink, Body, Enc, Hdrs, Dest>
where
Sink: PublishSink<Options = KafkaOptions>,
{
fn partition(mut self, partition: i32) -> Self {
self.options_mut()
.get_or_insert_with(KafkaOptions::default)
.partition = Some(partition);
self
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[must_use]
pub struct KafkaPublish {
queue_timeout: Option<Duration>,
}
impl KafkaPublish {
pub const fn queue_timeout(mut self, timeout: Duration) -> Self {
self.queue_timeout = Some(timeout);
self
}
pub fn transactional_id(self, id: impl Into<String>) -> KafkaTransactionalPublish {
KafkaTransactionalPublish {
queue_timeout: self.queue_timeout,
id: id.into(),
transaction_timeout: DEFAULT_TRANSACTION_TIMEOUT,
}
}
#[cfg(feature = "protobuf")]
pub fn framed(registry: &crate::SchemaRegistry) -> crate::protobuf::KafkaFramedPublish {
crate::protobuf::KafkaFramedPublish::over(Self::default(), registry)
}
pub(crate) const fn queue_timeout_setting(self) -> Option<Duration> {
self.queue_timeout
}
}
impl PublishPolicy<ConnectedKafkaBroker> for KafkaPublish {
type Live = KafkaPublisher;
fn pair(
self,
connected: &ConnectedKafkaBroker,
) -> impl Future<Output = Result<Self::Live, PairError>> {
ready(Ok(connected.publisher(self)))
}
#[cfg(feature = "asyncapi")]
fn channel_bindings(&self, channel: &str) -> Bindings {
crate::bindings::channel(channel)
}
}
impl DefaultPublish for ConnectedKafkaBroker {
type Policy = KafkaPublish;
}
#[derive(Clone)]
pub struct KafkaPublisher {
state: Arc<ConnState>,
queue_timeout: Option<Duration>,
}
impl fmt::Debug for KafkaPublisher {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("KafkaPublisher")
.field("queue_timeout", &self.queue_timeout)
.finish_non_exhaustive()
}
}
impl KafkaPublisher {
pub(crate) const fn new(state: Arc<ConnState>, queue_timeout: Option<Duration>) -> Self {
Self {
state,
queue_timeout,
}
}
}
async fn send_via(
producer: &FutureProducer,
queue_timeout: Option<Duration>,
msg: OutgoingMessage<'_>,
options: Option<&KafkaOptions>,
) -> 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) = options.and_then(|options| options.partition_setting()) {
record = record.partition(partition);
}
if let Some(headers) = parts.headers {
record = record.headers(headers);
}
let queue_timeout = 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 Payload = Lend;
type Error = KafkaError;
type Options = KafkaOptions;
async fn publish(
&self,
msg: OutgoingMessage<'_>,
options: Option<&Self::Options>,
) -> Result<(), Self::Error> {
self.state.ensure_open(msg.name())?;
#[cfg(feature = "testing")]
if let Some(cluster) = self.state.in_process() {
return produce_in_process(cluster, &msg, options, None);
}
send_via(self.state.producer()?, self.queue_timeout, msg, options).await
}
}
#[cfg(feature = "testing")]
fn produce_in_process(
cluster: &Cluster,
msg: &OutgoingMessage<'_>,
options: Option<&KafkaOptions>,
transaction: Option<&ProducerId>,
) -> Result<(), KafkaError> {
cluster.produce(
msg.name(),
options.and_then(|options| options.partition_setting()),
msg.payload(),
msg.headers(),
transaction,
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[must_use]
pub struct KafkaTransactionalPublish {
queue_timeout: Option<Duration>,
id: String,
transaction_timeout: Duration,
}
impl KafkaTransactionalPublish {
pub const fn queue_timeout(mut self, timeout: Duration) -> Self {
self.queue_timeout = Some(timeout);
self
}
pub const fn transaction_timeout(mut self, timeout: Duration) -> Self {
self.transaction_timeout = timeout;
self
}
pub fn per_partition(self) -> KafkaPartitionedPublish {
KafkaPartitionedPublish { template: self }
}
#[must_use]
pub fn id(&self) -> &str {
&self.id
}
pub(crate) fn with_id(&self, id: String) -> Self {
Self {
queue_timeout: self.queue_timeout,
id,
transaction_timeout: self.transaction_timeout,
}
}
}
impl PublishPolicy<ConnectedKafkaBroker> for KafkaTransactionalPublish {
type Live = KafkaTransactionalPublisher;
async fn pair(self, connected: &ConnectedKafkaBroker) -> Result<Self::Live, PairError> {
connected
.transactional_publisher(self)
.await
.map_err(PairError::new)
}
#[cfg(feature = "asyncapi")]
fn channel_bindings(&self, channel: &str) -> Bindings {
crate::bindings::channel(channel)
}
}
impl ConnectedKafkaBroker {
pub async fn transactional_publisher(
&self,
policy: KafkaTransactionalPublish,
) -> Result<KafkaTransactionalPublisher, KafkaError> {
open_transactional(self.state(), &policy).await
}
}
pub(crate) async fn open_transactional(
state: &Arc<ConnState>,
policy: &KafkaTransactionalPublish,
) -> Result<KafkaTransactionalPublisher, KafkaError> {
state.ensure_open(policy.id())?;
let mut config = state.producer_config().clone();
config.set("transactional.id", policy.id());
#[cfg(feature = "testing")]
if let Some(cluster) = state.in_process() {
create_offline(&config, RDKafkaType::RD_KAFKA_PRODUCER).map_err(KafkaError::publish)?;
let producer = TxProducer::InProcess {
cluster: Arc::clone(cluster),
id: cluster.init_transactions(policy.id()),
};
return Ok(KafkaTransactionalPublisher::with(state, producer, policy));
}
let producer: FutureProducer = config.create().map_err(KafkaError::publish)?;
let init = producer.clone();
let timeout = policy.transaction_timeout;
task::spawn_blocking(move || init.init_transactions(timeout))
.await
.map_err(|err| KafkaError::Publish(Box::new(err)))?
.map_err(KafkaError::publish)?;
Ok(KafkaTransactionalPublisher::with(
state,
TxProducer::Kafka(producer),
policy,
))
}
enum TxProducer {
Kafka(FutureProducer),
#[cfg(feature = "testing")]
InProcess {
cluster: Arc<Cluster>,
id: ProducerId,
},
}
#[cfg(not(feature = "testing"))]
const _: () = assert!(size_of::<TxProducer>() == size_of::<FutureProducer>());
struct TxInner {
state: Arc<ConnState>,
producer: TxProducer,
queue_timeout: Option<Duration>,
timeout: Duration,
id: String,
open: Mutex<bool>,
}
#[derive(Clone)]
pub struct KafkaTransactionalPublisher {
inner: Arc<TxInner>,
}
impl fmt::Debug for KafkaTransactionalPublisher {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("KafkaTransactionalPublisher")
.field("id", &self.inner.id)
.field("timeout", &self.inner.timeout)
.finish_non_exhaustive()
}
}
impl KafkaTransactionalPublisher {
fn with(
state: &Arc<ConnState>,
producer: TxProducer,
policy: &KafkaTransactionalPublish,
) -> Self {
Self {
inner: Arc::new(TxInner {
state: Arc::clone(state),
producer,
queue_timeout: policy.queue_timeout,
timeout: policy.transaction_timeout,
id: policy.id.clone(),
open: Mutex::new(false),
}),
}
}
#[must_use]
pub fn id(&self) -> &str {
&self.inner.id
}
pub(crate) fn deadline(&self) -> Duration {
self.inner.timeout
}
pub(crate) fn state(&self) -> &Arc<ConnState> {
&self.inner.state
}
fn is_open(&self) -> bool {
*self
.inner
.open
.lock()
.expect("transaction state mutex poisoned")
}
fn set_open(&self, open: bool) {
*self
.inner
.open
.lock()
.expect("transaction state mutex poisoned") = open;
}
fn no_transaction(&self) -> KafkaError {
KafkaError::NoTransaction {
id: self.inner.id.clone(),
}
}
pub(crate) async fn send_offsets(
&self,
offsets: TopicPartitionList,
metadata: ConsumerGroupMetadata,
) -> Result<(), KafkaError> {
self.inner.state.ensure_open(&self.inner.id)?;
if !self.is_open() {
return Err(self.no_transaction());
}
let producer = match &self.inner.producer {
TxProducer::Kafka(producer) => producer.clone(),
#[cfg(feature = "testing")]
TxProducer::InProcess { .. } => {
return Err(KafkaError::InvalidOptions(
"librdkafka offsets reached an in-process transaction".to_owned(),
));
}
};
let timeout = self.inner.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)
}
}
#[cfg(feature = "testing")]
impl KafkaTransactionalPublisher {
pub(crate) fn send_offsets_in_process(
&self,
group: &str,
positions: &[((Str, i32), i64)],
) -> Result<(), KafkaError> {
self.inner.state.ensure_open(&self.inner.id)?;
if !self.is_open() {
return Err(self.no_transaction());
}
match &self.inner.producer {
TxProducer::InProcess { cluster, id } => cluster.send_offsets(id, group, positions),
TxProducer::Kafka(_) => Err(KafkaError::InvalidOptions(
"in-process offsets reached a librdkafka transaction".to_owned(),
)),
}
}
}
impl Publisher for KafkaTransactionalPublisher {
type Payload = Lend;
type Error = KafkaError;
type Options = KafkaOptions;
async fn publish(
&self,
msg: OutgoingMessage<'_>,
options: Option<&Self::Options>,
) -> Result<(), Self::Error> {
self.inner.state.ensure_open(msg.name())?;
let producer = match &self.inner.producer {
TxProducer::Kafka(producer) => producer,
#[cfg(feature = "testing")]
TxProducer::InProcess { cluster, id } => {
let transaction = self.is_open().then_some(id);
return produce_in_process(cluster, &msg, options, transaction);
}
};
if self.is_open() {
return send_via(producer, self.inner.queue_timeout, msg, options).await;
}
send_via(
self.inner.state.producer()?,
self.inner.queue_timeout,
msg,
options,
)
.await
}
}
impl TransactionalPublisher for KafkaTransactionalPublisher {
fn begin_transaction(&self) -> impl Future<Output = Result<(), Self::Error>> {
if let Err(err) = self.inner.state.ensure_open(&self.inner.id) {
return ready(Err(err));
}
{
let mut open = self
.inner
.open
.lock()
.expect("transaction state mutex poisoned");
if *open {
return ready(Err(KafkaError::TransactionBusy {
id: self.inner.id.clone(),
}));
}
let begun = match &self.inner.producer {
TxProducer::Kafka(producer) => {
producer.begin_transaction().map_err(KafkaError::publish)
}
#[cfg(feature = "testing")]
TxProducer::InProcess { cluster, id } => {
cluster.begin(id, self.inner.state.runtime())
}
};
if let Err(err) = begun {
return ready(Err(err));
}
*open = true;
}
ready(Ok(()))
}
async fn commit(&self) -> Result<(), Self::Error> {
self.inner.state.ensure_open(&self.inner.id)?;
if !self.is_open() {
return Err(self.no_transaction());
}
let producer = match &self.inner.producer {
TxProducer::Kafka(producer) => producer.clone(),
#[cfg(feature = "testing")]
TxProducer::InProcess { cluster, id } => {
cluster.commit(id)?;
self.set_open(false);
return Ok(());
}
};
let timeout = self.inner.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(false);
Ok(())
}
async fn abort(&self) -> Result<(), Self::Error> {
self.inner.state.ensure_open(&self.inner.id)?;
if !self.is_open() {
return Err(self.no_transaction());
}
let producer = match &self.inner.producer {
TxProducer::Kafka(producer) => producer.clone(),
#[cfg(feature = "testing")]
TxProducer::InProcess { cluster, id } => {
let aborted = cluster.abort(id);
self.set_open(false);
return aborted;
}
};
let timeout = self.inner.timeout;
let aborted = task::spawn_blocking(move || producer.abort_transaction(timeout))
.await
.map_err(|err| KafkaError::Publish(Box::new(err)))?;
self.set_open(false);
aborted.map_err(KafkaError::publish)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[must_use]
pub struct KafkaPartitionedPublish {
template: KafkaTransactionalPublish,
}
impl PublishPolicy<ConnectedKafkaBroker> for KafkaPartitionedPublish {
type Live = TransactionalPartitions;
fn pair(
self,
connected: &ConnectedKafkaBroker,
) -> impl Future<Output = Result<Self::Live, PairError>> {
ready(Ok(TransactionalPartitions {
inner: Arc::new(PartitionsInner {
state: Arc::clone(connected.state()),
template: self.template,
publishers: Mutex::new(HashMap::new()),
}),
}))
}
#[cfg(feature = "asyncapi")]
fn channel_bindings(&self, channel: &str) -> Bindings {
crate::bindings::channel(channel)
}
}
#[derive(Clone)]
pub struct TransactionalPartitions {
inner: Arc<PartitionsInner>,
}
struct PartitionsInner {
state: Arc<ConnState>,
template: KafkaTransactionalPublish,
publishers: Mutex<HashMap<i32, Arc<OnceCell<KafkaTransactionalPublisher>>>>,
}
impl fmt::Debug for TransactionalPartitions {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TransactionalPartitions")
.field("id_base", &self.inner.template.id())
.finish_non_exhaustive()
}
}
impl TransactionalPartitions {
pub async fn for_partition(
&self,
partition: i32,
) -> Result<KafkaTransactionalPublisher, KafkaError> {
let cell = {
let mut publishers = self
.inner
.publishers
.lock()
.expect("partition publisher cache mutex poisoned");
Arc::clone(publishers.entry(partition).or_default())
};
let policy = self
.inner
.template
.with_id(format!("{}-p{partition}", self.inner.template.id()));
cell.get_or_try_init(|| open_transactional(&self.inner.state, &policy))
.await
.cloned()
}
}
#[diagnostic::on_unimplemented(
message = "`{Self}` does not hand out per-partition transactional publishers",
note = "for an `Out<impl PartitionLanes, _>` slot, attach a \
`KafkaPublish::default().transactional_id(..).per_partition()` policy"
)]
pub trait PartitionLanes: Send + Sync {
type Publisher: TransactionalPublisher<Error = KafkaError>;
fn for_partition(
&self,
partition: i32,
) -> impl Future<Output = Result<Self::Publisher, KafkaError>> + Send;
}
impl PartitionLanes for TransactionalPartitions {
type Publisher = KafkaTransactionalPublisher;
fn for_partition(
&self,
partition: i32,
) -> impl Future<Output = Result<KafkaTransactionalPublisher, KafkaError>> + Send {
Self::for_partition(self, partition)
}
}
impl<M, L, EncodeCodec, Pipe, Body> PartitionLanes for Slot<M, L, EncodeCodec, Pipe, Body>
where
L: PartitionLanes,
EncodeCodec: Send + Sync,
Pipe: OutPipeline<L>,
{
type Publisher = L::Publisher;
fn for_partition(
&self,
partition: i32,
) -> impl Future<Output = Result<Self::Publisher, KafkaError>> + Send {
(**self).for_partition(partition)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transactional_id_is_a_type_transition() {
let plain = KafkaPublish::default().queue_timeout(Duration::from_secs(1));
let transactional = plain.transactional_id("svc-1");
assert_eq!(transactional.id(), "svc-1");
assert_eq!(transactional.queue_timeout, plain.queue_timeout_setting());
assert_eq!(
transactional.transaction_timeout,
DEFAULT_TRANSACTION_TIMEOUT
);
}
#[test]
fn per_partition_derives_ids_from_the_base() {
let policy = KafkaPublish::default()
.transactional_id("svc-1")
.transaction_timeout(Duration::from_secs(5));
let derived = policy.with_id(format!("{}-p3", policy.id()));
assert_eq!(derived.id(), "svc-1-p3");
assert_eq!(derived.transaction_timeout, Duration::from_secs(5));
}
}