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}