Skip to main content

ruststream_rdkafka/
publisher.rs

1//! The publishers: fire-and-confirm production, plus Kafka transactions.
2
3use std::collections::HashMap;
4use std::fmt;
5use std::sync::{Arc, Mutex};
6use std::time::Duration;
7
8use rdkafka::TopicPartitionList;
9use rdkafka::consumer::ConsumerGroupMetadata;
10use rdkafka::producer::{FutureProducer, FutureRecord, Producer as _};
11use rdkafka::util::Timeout;
12use ruststream::{OutgoingMessage, Publisher, TransactionalPublisher};
13use tokio::sync::OnceCell;
14use tokio::task;
15
16use crate::broker::SharedConn;
17use crate::convert;
18use crate::error::KafkaError;
19
20const DEFAULT_TRANSACTION_TIMEOUT: Duration = Duration::from_secs(30);
21
22/// The lazily-created transactional producer shared by clones of one publisher.
23struct TxState {
24    id: String,
25    timeout: Duration,
26    producer: OnceCell<FutureProducer>,
27    /// Whether a transaction is currently open. Interleaving `publish` with
28    /// `begin_transaction`/`commit` from concurrent tasks is not supported: which side of the
29    /// transaction boundary a concurrent publish lands on would be a race either way.
30    open: Mutex<bool>,
31}
32
33impl fmt::Debug for TxState {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        f.debug_struct("TxState")
36            .field("id", &self.id)
37            .field("timeout", &self.timeout)
38            .finish_non_exhaustive()
39    }
40}
41
42/// A producer handle sharing the broker's connection.
43///
44/// [`OutgoingMessage::name`] is the destination topic. A
45/// [`PARTITION_KEY_HEADER`](crate::PARTITION_KEY_HEADER) header becomes the record's native key,
46/// so Kafka routes messages that share a key to the same partition; without it the configured
47/// partitioner picks one.
48///
49/// Each publish awaits the broker's delivery report, so an `Ok` means the cluster accepted the
50/// record (durability then depends on the producer's `acks` setting, configurable through
51/// [`KafkaBroker::producer_config`](crate::KafkaBroker::producer_config)).
52///
53/// [`transactional_id`](Self::transactional_id) upgrades the handle to a transactional one
54/// implementing [`TransactionalPublisher`]: publishes between `begin_transaction` and `commit`
55/// become visible atomically (readers on Kafka's default `read_committed` isolation see all of
56/// them or none), and `abort` discards them broker-side.
57///
58/// Obtained from [`KafkaBroker::publisher`](crate::KafkaBroker::publisher); usable before
59/// `Broker::connect` resolves the connection (publishing earlier returns
60/// [`KafkaError::NotConnected`]).
61#[derive(Debug, Clone)]
62pub struct KafkaPublisher {
63    conn: SharedConn,
64    queue_timeout: Option<Duration>,
65    tx: Option<Arc<TxState>>,
66}
67
68impl KafkaPublisher {
69    pub(crate) fn new(conn: SharedConn) -> Self {
70        Self {
71            conn,
72            queue_timeout: None,
73            tx: None,
74        }
75    }
76
77    /// How long a publish may wait for space when librdkafka's local queue is full, before
78    /// failing with a queue-full error. Without it a publish waits for space indefinitely,
79    /// which is the natural back-pressure behavior.
80    #[must_use]
81    pub fn queue_timeout(mut self, timeout: Duration) -> Self {
82        self.queue_timeout = Some(timeout);
83        self
84    }
85
86    /// Upgrades to a transactional publisher fenced by `id` (Kafka's `transactional.id`).
87    ///
88    /// The id must be stable and unique per concurrent producer: Kafka uses it to fence
89    /// zombies, so two live producers sharing an id abort each other. Create several
90    /// publishers with distinct ids for concurrent transactional flows. The transactional
91    /// producer itself is created (and its transactions initialized) on first use, from the
92    /// broker's resolved producer configuration.
93    ///
94    /// # Examples
95    ///
96    /// ```no_run
97    /// use ruststream_rdkafka::KafkaBroker;
98    ///
99    /// let broker = KafkaBroker::new(["localhost:9092"]);
100    /// let replies = broker.publisher().transactional_id("orders-svc-1");
101    /// # let _ = replies;
102    /// ```
103    #[must_use]
104    pub fn transactional_id(mut self, id: impl Into<String>) -> Self {
105        self.tx = Some(Arc::new(TxState {
106            id: id.into(),
107            timeout: DEFAULT_TRANSACTION_TIMEOUT,
108            producer: OnceCell::new(),
109            open: Mutex::new(false),
110        }));
111        self
112    }
113
114    /// How long transaction control calls (`init`, `commit`, `abort`) may block before
115    /// reporting failure. Defaults to 30 seconds; this is the call deadline handed to
116    /// librdkafka, not its `transaction.timeout.ms` (reachable through
117    /// [`KafkaBroker::producer_config`](crate::KafkaBroker::producer_config)).
118    ///
119    /// Only meaningful after [`transactional_id`](Self::transactional_id).
120    #[must_use]
121    pub fn transaction_timeout(mut self, timeout: Duration) -> Self {
122        if let Some(tx) = &self.tx {
123            self.tx = Some(Arc::new(TxState {
124                id: tx.id.clone(),
125                timeout,
126                producer: OnceCell::new(),
127                open: Mutex::new(false),
128            }));
129        }
130        self
131    }
132
133    fn tx_or_invalid(&self) -> Result<&Arc<TxState>, KafkaError> {
134        self.tx.as_ref().ok_or_else(|| {
135            KafkaError::InvalidOptions(
136                "transactional publishing needs `KafkaPublisher::transactional_id`; a plain \
137                 publisher cannot begin, commit, or abort transactions"
138                    .to_owned(),
139            )
140        })
141    }
142
143    /// Resolves (creating and initializing on first use) the transactional producer.
144    async fn tx_producer(&self, tx: &Arc<TxState>) -> Result<FutureProducer, KafkaError> {
145        let producer = tx
146            .producer
147            .get_or_try_init(|| async {
148                let state = self.conn.get().ok_or(KafkaError::NotConnected)?;
149                let mut config = state.producer_config().clone();
150                config.set("transactional.id", &tx.id);
151                let producer: FutureProducer = config.create().map_err(KafkaError::publish)?;
152                // init_transactions blocks (it fences earlier producers with this id), so it
153                // runs on the blocking pool.
154                let init = producer.clone();
155                let timeout = tx.timeout;
156                task::spawn_blocking(move || init.init_transactions(timeout))
157                    .await
158                    .map_err(|err| KafkaError::Publish(Box::new(err)))?
159                    .map_err(KafkaError::publish)?;
160                Ok(producer)
161            })
162            .await?;
163        Ok(producer.clone())
164    }
165
166    pub(crate) fn shared_conn(&self) -> SharedConn {
167        Arc::clone(&self.conn)
168    }
169
170    pub(crate) fn transactional_id_str(&self) -> Option<&str> {
171        self.tx.as_ref().map(|tx| tx.id.as_str())
172    }
173
174    pub(crate) fn transaction_deadline(&self) -> Duration {
175        self.tx
176            .as_ref()
177            .map_or(DEFAULT_TRANSACTION_TIMEOUT, |tx| tx.timeout)
178    }
179
180    /// Adds consumed source offsets (and their group's metadata) to the open transaction, so
181    /// they commit atomically with the records published into it. The EOS pipeline's commit
182    /// path; must run between `begin_transaction` and `commit`.
183    pub(crate) async fn send_offsets(
184        &self,
185        offsets: TopicPartitionList,
186        metadata: ConsumerGroupMetadata,
187    ) -> Result<(), KafkaError> {
188        let tx = self.tx_or_invalid()?.clone();
189        let producer = self.tx_producer(&tx).await?;
190        let timeout = tx.timeout;
191        task::spawn_blocking(move || {
192            producer.send_offsets_to_transaction(&offsets, &metadata, timeout)
193        })
194        .await
195        .map_err(|err| KafkaError::Publish(Box::new(err)))?
196        .map_err(KafkaError::publish)
197    }
198
199    fn is_open(tx: &TxState) -> bool {
200        *tx.open.lock().expect("transaction state mutex poisoned")
201    }
202
203    fn set_open(tx: &TxState, open: bool) {
204        *tx.open.lock().expect("transaction state mutex poisoned") = open;
205    }
206
207    async fn send_via(
208        &self,
209        producer: &FutureProducer,
210        msg: OutgoingMessage<'_>,
211    ) -> Result<(), KafkaError> {
212        let parts = convert::headers_for_publish(msg.headers())?;
213        let mut record = FutureRecord::<[u8], [u8]>::to(msg.name()).payload(msg.payload());
214        if let Some(key) = &parts.key {
215            record = record.key(key.as_ref());
216        }
217        if let Some(partition) = parts.partition {
218            // An explicit partition wins over the partitioner and the record key.
219            record = record.partition(partition);
220        }
221        if let Some(headers) = parts.headers {
222            record = record.headers(headers);
223        }
224        let queue_timeout = self.queue_timeout.map_or(Timeout::Never, Timeout::After);
225        producer
226            .send(record, queue_timeout)
227            .await
228            .map(|_delivery| ())
229            .map_err(|(err, _record)| KafkaError::publish(err))
230    }
231}
232
233impl Publisher for KafkaPublisher {
234    type Error = KafkaError;
235
236    /// Publishes `msg` to the topic named by [`OutgoingMessage::name`] and awaits the delivery
237    /// report. Inside an open transaction the record joins it; otherwise it goes out through
238    /// the broker's shared plain producer, transactional id or not.
239    ///
240    /// # Errors
241    ///
242    /// Returns [`KafkaError::NotConnected`] before `Broker::connect` resolves the connection and
243    /// [`KafkaError::Publish`] when the cluster rejects the record or the delivery times out
244    /// (librdkafka's `message.timeout.ms`).
245    ///
246    /// # Cancel safety
247    ///
248    /// Not cancel safe: dropping the future may leave the record in flight, delivered or not.
249    async fn publish(&self, msg: OutgoingMessage<'_>) -> Result<(), Self::Error> {
250        if let Some(tx) = &self.tx
251            && Self::is_open(tx)
252        {
253            let producer = self.tx_producer(tx).await?;
254            return self.send_via(&producer, msg).await;
255        }
256        let state = self.conn.get().ok_or(KafkaError::NotConnected)?;
257        self.send_via(state.producer(), msg).await
258    }
259}
260
261impl TransactionalPublisher for KafkaPublisher {
262    /// Begins a Kafka transaction (creating and initializing the transactional producer on
263    /// first use).
264    ///
265    /// One producer runs one transaction at a time, so beginning while one is open is an
266    /// error, not a queue: a second begin means two flows share one publisher, and silently
267    /// merging their messages into one transaction would commit one flow's records with the
268    /// other's. Concurrent transactional flows use distinct publishers (see
269    /// [`TransactionalPartitions`](crate::TransactionalPartitions)).
270    ///
271    /// # Errors
272    ///
273    /// Returns [`KafkaError::InvalidOptions`] without a
274    /// [`transactional_id`](Self::transactional_id), [`KafkaError::TransactionBusy`] when a
275    /// transaction is already open on this publisher (or a clone sharing its id),
276    /// [`KafkaError::NotConnected`] before `Broker::connect`, and [`KafkaError::Publish`] when
277    /// initialization or the begin call fails.
278    // The guard intentionally spans the begin call: check-and-begin must be atomic so two
279    // concurrent begins cannot both pass the check.
280    #[allow(clippy::significant_drop_tightening)]
281    async fn begin_transaction(&self) -> Result<(), Self::Error> {
282        let tx = self.tx_or_invalid()?.clone();
283        let producer = self.tx_producer(&tx).await?;
284        let mut open = tx.open.lock().expect("transaction state mutex poisoned");
285        if *open {
286            return Err(KafkaError::TransactionBusy);
287        }
288        producer.begin_transaction().map_err(KafkaError::publish)?;
289        *open = true;
290        Ok(())
291    }
292
293    /// Commits the open transaction, making its records visible atomically; a no-op when none
294    /// is open.
295    ///
296    /// # Errors
297    ///
298    /// Returns [`KafkaError::Publish`] when the commit fails. librdkafka distinguishes
299    /// retriable failures from ones requiring an abort; after an error the transaction's state
300    /// is unresolved, so treat the publisher as needing an
301    /// [`abort`](TransactionalPublisher::abort) or replacement.
302    async fn commit(&self) -> Result<(), Self::Error> {
303        let tx = self.tx_or_invalid()?.clone();
304        if !Self::is_open(&tx) {
305            return Ok(());
306        }
307        let producer = self.tx_producer(&tx).await?;
308        let timeout = tx.timeout;
309        task::spawn_blocking(move || producer.commit_transaction(timeout))
310            .await
311            .map_err(|err| KafkaError::Publish(Box::new(err)))?
312            .map_err(KafkaError::publish)?;
313        Self::set_open(&tx, false);
314        Ok(())
315    }
316
317    /// Aborts the open transaction, discarding its records broker-side; a no-op when none is
318    /// open.
319    ///
320    /// # Errors
321    ///
322    /// Returns [`KafkaError::Publish`] when the abort fails.
323    async fn abort(&self) -> Result<(), Self::Error> {
324        let tx = self.tx_or_invalid()?.clone();
325        if !Self::is_open(&tx) {
326            return Ok(());
327        }
328        let producer = self.tx_producer(&tx).await?;
329        let timeout = tx.timeout;
330        task::spawn_blocking(move || producer.abort_transaction(timeout))
331            .await
332            .map_err(|err| KafkaError::Publish(Box::new(err)))?
333            .map_err(KafkaError::publish)?;
334        Self::set_open(&tx, false);
335        Ok(())
336    }
337}
338
339/// Lazily materialized transactional publishers, one per source partition.
340///
341/// Kafka permits one open transaction per producer and one live producer per transactional id
342/// (initializing a second fences the first), so concurrent transactional handlers need one
343/// producer each. The source partition is the natural scope: under the default
344/// [`LaneKey::Partition`](crate::LaneKey::Partition) worker pool a partition's deliveries
345/// process serially on one lane, so a publisher per partition gives every lane an independent
346/// transaction with no coordination. The id set (`"{base}-p{partition}"`) follows the topic's
347/// partitions rather than the worker count: changing `workers(n)` neither changes the ids nor
348/// weakens zombie fencing - the scheme Kafka Streams uses for its per-task producers.
349///
350/// Not for [`LaneKey::RecordKey`](crate::LaneKey::RecordKey) pools: record-key lanes spread
351/// one partition across lanes, so two lanes would share a partition's publisher and collide
352/// on its single transaction ([`KafkaError::TransactionBusy`]).
353///
354/// Clones share the cache, so one instance in the application state serves every handler
355/// invocation.
356///
357/// # Examples
358///
359/// ```no_run
360/// use ruststream_rdkafka::{KafkaBroker, TransactionalPartitions};
361///
362/// let broker = KafkaBroker::new(["localhost:9092"]);
363/// let publishers = TransactionalPartitions::new(broker.publisher(), "billing-svc-1");
364/// // In a handler: the delivery's source partition picks the publisher.
365/// let publisher = publishers.for_partition(3); // transactional id "billing-svc-1-p3"
366/// # let _ = publisher;
367/// ```
368#[derive(Debug, Clone)]
369pub struct TransactionalPartitions {
370    inner: Arc<PartitionsInner>,
371}
372
373#[derive(Debug)]
374struct PartitionsInner {
375    template: KafkaPublisher,
376    id_base: String,
377    timeout: Option<Duration>,
378    publishers: Mutex<HashMap<i32, KafkaPublisher>>,
379}
380
381impl TransactionalPartitions {
382    /// Creates the per-partition publisher set over `template` (which carries the broker
383    /// connection and any [`queue_timeout`](KafkaPublisher::queue_timeout)); each partition's
384    /// publisher gets the transactional id `"{id_base}-p{partition}"`. A transactional id
385    /// already set on the template is ignored.
386    ///
387    /// `id_base` must be stable across restarts and unique per service instance - it is what
388    /// scopes zombie fencing.
389    #[must_use]
390    pub fn new(template: KafkaPublisher, id_base: impl Into<String>) -> Self {
391        Self {
392            inner: Arc::new(PartitionsInner {
393                template,
394                id_base: id_base.into(),
395                timeout: None,
396                publishers: Mutex::new(HashMap::new()),
397            }),
398        }
399    }
400
401    /// The control-call deadline ([`KafkaPublisher::transaction_timeout`]) applied to each
402    /// partition's publisher. Configure before handing the set out: publishers already
403    /// materialized keep their deadline.
404    #[must_use]
405    pub fn transaction_timeout(self, timeout: Duration) -> Self {
406        Self {
407            inner: Arc::new(PartitionsInner {
408                template: self.inner.template.clone(),
409                id_base: self.inner.id_base.clone(),
410                timeout: Some(timeout),
411                publishers: Mutex::new(HashMap::new()),
412            }),
413        }
414    }
415
416    /// The publisher owning `partition`'s transactional id, created on first use.
417    ///
418    /// `partition` is the delivery's source partition (`KafkaContext`'s `Partition` field in a
419    /// handler); passing anything else still works but forfeits the serialization argument
420    /// that makes the per-partition scope safe.
421    ///
422    /// # Panics
423    ///
424    /// Panics when the internal cache mutex is poisoned, which requires a prior panic while
425    /// materializing a publisher (an invariant violation, not an operational failure).
426    #[must_use]
427    pub fn for_partition(&self, partition: i32) -> KafkaPublisher {
428        let mut publishers = self
429            .inner
430            .publishers
431            .lock()
432            .expect("partition publisher cache mutex poisoned");
433        publishers
434            .entry(partition)
435            .or_insert_with(|| {
436                let id = format!("{}-p{partition}", self.inner.id_base);
437                let publisher = self.inner.template.clone().transactional_id(id);
438                match self.inner.timeout {
439                    Some(timeout) => publisher.transaction_timeout(timeout),
440                    None => publisher,
441                }
442            })
443            .clone()
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use ruststream::TransactionalPublisher as _;
450
451    use super::*;
452
453    fn tx_id(publisher: &KafkaPublisher) -> Option<String> {
454        publisher.tx.as_ref().map(|tx| tx.id.clone())
455    }
456
457    #[tokio::test]
458    async fn transactions_without_an_id_fail_clearly() {
459        let publisher = KafkaPublisher::new(Arc::default());
460        let err = publisher
461            .begin_transaction()
462            .await
463            .expect_err("begin without transactional_id must fail");
464        assert!(matches!(err, KafkaError::InvalidOptions(_)));
465        assert!(err.to_string().contains("transactional_id"));
466    }
467
468    #[test]
469    fn partitions_derive_ids_and_share_the_cache() {
470        let set = TransactionalPartitions::new(KafkaPublisher::new(Arc::default()), "svc-1");
471        let three = set.for_partition(3);
472        assert_eq!(tx_id(&three).as_deref(), Some("svc-1-p3"));
473        assert_eq!(tx_id(&set.for_partition(0)).as_deref(), Some("svc-1-p0"));
474
475        // The same partition resolves to the same producer state, through clones too: the
476        // clone shares the cache, so both handles are fenced (and serialized) together. The
477        // clone is the point of the assertion, not an artifact.
478        #[allow(clippy::redundant_clone)]
479        let cloned = set.clone();
480        let again = cloned.for_partition(3);
481        let (left, right) = (
482            three.tx.expect("transactional"),
483            again.tx.expect("transactional"),
484        );
485        assert!(Arc::ptr_eq(&left, &right));
486    }
487
488    #[test]
489    fn partitions_template_id_is_replaced() {
490        let template = KafkaPublisher::new(Arc::default()).transactional_id("ignored");
491        let set = TransactionalPartitions::new(template, "svc-1");
492        assert_eq!(tx_id(&set.for_partition(7)).as_deref(), Some("svc-1-p7"));
493    }
494}