ruststream_rdkafka/eos.rs
1//! Exactly-once pipelines: group-committed producer transactions that carry the consumed
2//! offsets (`send_offsets_to_transaction`, the KIP-447 consume-transform-produce shape).
3//!
4//! An [`EosPipeline`] shares one transactional producer across every handler of one or more
5//! `Commit::Transactional` subscriptions. Handlers publish into the pipeline's open window;
6//! on the commit interval the pipeline waits for the window's participants to settle, adds
7//! their source positions to the transaction, and commits - records and offsets become
8//! visible atomically, so a crash or an abort rewinds both and the output topic never sees a
9//! duplicate. Concurrent publishes may join an open transaction (only the control calls are
10//! exclusive), which is what lets a `workers(n, by_key)` pool share one transactional id.
11
12use std::collections::HashMap;
13use std::sync::{Arc, Mutex, Weak};
14use std::time::Duration;
15
16use futures::future::select_all;
17use rdkafka::consumer::{Consumer as _, ConsumerGroupMetadata, StreamConsumer};
18use rdkafka::{Offset, TopicPartitionList};
19use ruststream::codec::Codec;
20#[cfg(any(feature = "json", feature = "cbor", feature = "msgpack"))]
21use ruststream::codec::DefaultCodec;
22use ruststream::runtime::{
23 Outgoing, PublishContext, PublishTransform, PublishTransformIdentity, PublishTransformStack,
24 TypedPublisher,
25};
26use ruststream::{
27 OutgoingMessage, PairError, PublishPolicy, Publisher, TransactionalPublisher as _,
28};
29use tracing::{debug, error};
30
31use crate::broker::ConnectedKafkaBroker;
32use crate::error::KafkaError;
33use crate::publisher::{KafkaPublish, KafkaTransactionalPublish, KafkaTransactionalPublisher};
34use crate::tracker::{CommitTracker, TrackingContext};
35
36/// The Kafka Streams default for exactly-once commit intervals.
37const DEFAULT_COMMIT_INTERVAL: Duration = Duration::from_millis(100);
38
39/// One `Commit::Transactional` subscription registered under a pipeline id: the watermark
40/// tracker deciding which positions are settled, and the consumer whose group metadata
41/// fences the offset commit (and which seeks back on an abort).
42///
43/// Held weakly: the registry must not keep a dropped subscriber's consumer alive (it would
44/// silently stay in its group and stall rebalances). A dead entry is pruned on lookup.
45#[derive(Clone)]
46pub(crate) struct EosSource {
47 tracker: Weak<CommitTracker>,
48 consumer: Weak<StreamConsumer<TrackingContext>>,
49}
50
51impl EosSource {
52 pub(crate) fn new(
53 tracker: &Arc<CommitTracker>,
54 consumer: &Arc<StreamConsumer<TrackingContext>>,
55 ) -> Self {
56 Self {
57 tracker: Arc::downgrade(tracker),
58 consumer: Arc::downgrade(consumer),
59 }
60 }
61
62 pub(crate) fn alive(&self) -> bool {
63 self.tracker.strong_count() > 0 && self.consumer.strong_count() > 0
64 }
65
66 fn upgrade(&self) -> Option<LiveSource> {
67 Some(LiveSource {
68 tracker: self.tracker.upgrade()?,
69 consumer: self.consumer.upgrade()?,
70 })
71 }
72}
73
74/// An upgraded [`EosSource`] pinned for the duration of one window commit.
75struct LiveSource {
76 tracker: Arc<CommitTracker>,
77 consumer: Arc<StreamConsumer<TrackingContext>>,
78}
79
80/// The source coordinates of one delivery, as [`EosPipeline::publish`] needs them.
81///
82/// In a handler, take them as a `Ctx(source): Ctx<Source>` extractor parameter, or read the
83/// [`keys::Source`](crate::context::keys::Source) field off a declared ctx parameter.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct SourceOffset {
86 topic: String,
87 partition: i32,
88 offset: i64,
89}
90
91impl SourceOffset {
92 /// Builds the coordinates by hand; in a handler prefer the
93 /// [`keys::Source`](crate::context::keys::Source) key.
94 #[must_use]
95 pub fn new(topic: impl Into<String>, partition: i32, offset: i64) -> Self {
96 Self {
97 topic: topic.into(),
98 partition,
99 offset,
100 }
101 }
102
103 fn key(&self) -> (String, i32) {
104 (self.topic.clone(), self.partition)
105 }
106}
107
108/// Where the pipeline's current window stands.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110enum Phase {
111 /// No transaction open; the next publish opens one.
112 Idle,
113 /// A publish is opening the transaction; others wait for the outcome.
114 Opening,
115 /// The transaction is open and admitting publishes.
116 Open,
117 /// The commit interval elapsed: only participants already enrolled may still publish,
118 /// new deliveries wait for the next window.
119 Committing,
120}
121
122/// The open window's state.
123#[derive(Debug)]
124struct Window {
125 phase: Phase,
126 /// Highest enrolled source offset per (topic, partition).
127 enrolled: HashMap<(String, i32), i64>,
128 /// A publish into this window failed: the transaction is poisoned and must abort.
129 failed: bool,
130 /// Distinguishes windows across commits, so a stale window task cannot touch its
131 /// successor.
132 epoch: u64,
133}
134
135struct PipelineInner {
136 publisher: KafkaTransactionalPublisher,
137 /// The pipeline id: the publisher's transactional id, which `Commit::Transactional`
138 /// subscriptions name to register their offsets here.
139 id: String,
140 interval: Duration,
141 window: Mutex<Window>,
142 /// Woken on every phase transition; publishers waiting for admission re-check then.
143 phase_changed: tokio::sync::Notify,
144 /// The sources registered under this pipeline id, refreshed whenever a window opens. The
145 /// publish path reads them to notice a reposition without touching the broker-wide registry
146 /// per message.
147 sources: Mutex<Vec<EosSource>>,
148 /// Offsets committed by this pipeline per (topic, partition) ("next to consume"), the
149 /// seek target when a window aborts.
150 committed: Mutex<HashMap<(String, i32), i64>>,
151 /// The first offset ever enrolled per (topic, partition): the abort seek target before
152 /// anything committed.
153 session_low: Mutex<HashMap<(String, i32), i64>>,
154}
155
156/// The publish policy of [`EosPipeline`]: the pipeline id (the producer's transactional id)
157/// plus the commit interval, declared anywhere and paired with the connected broker at startup.
158///
159/// Wiring, all three naming the same id:
160///
161/// 1. The policy: `KafkaEosPublish::new("pipeline-1")`, attached at the include site
162/// (`b.include(handler).publisher(policy)`) or bound for an `after_startup` hook.
163/// 2. Each source subscription: `Commit::Transactional("pipeline-1".into())` - its consumer
164/// stops committing offsets on its own and registers with the pipeline instead.
165/// 3. The handler: it receives the paired [`EosPipeline`] and calls
166/// [`publish`](EosPipeline::publish) with the delivery's [`SourceOffset`].
167///
168/// # Examples
169///
170/// ```
171/// use std::time::Duration;
172///
173/// use ruststream_rdkafka::KafkaEosPublish;
174///
175/// let policy = KafkaEosPublish::new("enrich-1").commit_interval(Duration::from_millis(50));
176/// # let _ = policy;
177/// ```
178#[derive(Debug, Clone, PartialEq, Eq)]
179#[must_use]
180pub struct KafkaEosPublish {
181 transactional: KafkaTransactionalPublish,
182 interval: Duration,
183}
184
185impl KafkaEosPublish {
186 /// Declares a pipeline fenced by `id`, which doubles as the pipeline id that
187 /// `Commit::Transactional` subscriptions register under.
188 pub fn new(id: impl Into<String>) -> Self {
189 Self {
190 transactional: KafkaPublish::default().transactional_id(id),
191 interval: DEFAULT_COMMIT_INTERVAL,
192 }
193 }
194
195 /// How long a window stays open before committing; defaults to 100ms (the Kafka Streams
196 /// exactly-once default). Longer intervals amortize the commit over more records at the
197 /// cost of end-to-end latency (records become visible only at the commit).
198 pub const fn commit_interval(mut self, interval: Duration) -> Self {
199 self.interval = interval;
200 self
201 }
202
203 /// See [`KafkaTransactionalPublish::transaction_timeout`]; it doubles as the deadline a
204 /// window waits for its participants to settle.
205 pub fn transaction_timeout(mut self, timeout: Duration) -> Self {
206 self.transactional = self.transactional.transaction_timeout(timeout);
207 self
208 }
209
210 /// See [`KafkaPublish::queue_timeout`].
211 pub fn queue_timeout(mut self, timeout: Duration) -> Self {
212 self.transactional = self.transactional.queue_timeout(timeout);
213 self
214 }
215
216 /// The pipeline id.
217 #[must_use]
218 pub fn id(&self) -> &str {
219 self.transactional.id()
220 }
221}
222
223impl PublishPolicy<ConnectedKafkaBroker> for KafkaEosPublish {
224 type Live = EosPipeline;
225
226 async fn pair(self, connected: &ConnectedKafkaBroker) -> Result<Self::Live, PairError> {
227 let interval = self.interval;
228 let publisher = self.transactional.pair(connected).await?;
229 Ok(EosPipeline::new(publisher, interval))
230 }
231}
232
233/// An exactly-once pipeline over one transactional producer, the live form of
234/// [`KafkaEosPublish`].
235///
236/// Every [`commit_interval`](KafkaEosPublish::commit_interval) the pipeline closes the window:
237/// it waits until every delivery that published into it has settled (the shared watermark
238/// reached the enrolled offsets), adds the settled source positions and their group metadata to
239/// the transaction, and commits. On any failure - a failed publish, a settle stall (a handler
240/// hanging or `retry()`-ing past the publisher's transaction timeout), a rebalance revoking
241/// an enrolled partition, a commit error - the window aborts and the consumers seek back, so
242/// the whole window redelivers and republishes into a fresh transaction; committed output
243/// still never duplicates.
244///
245/// Works best over the default `LaneKey::Partition` worker lanes: a partition processes in
246/// order on one lane, so the settle condition follows the lane head and windows close
247/// promptly. Clones share the pipeline.
248#[derive(Clone)]
249pub struct EosPipeline {
250 inner: Arc<PipelineInner>,
251}
252
253impl std::fmt::Debug for EosPipeline {
254 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255 f.debug_struct("EosPipeline")
256 .field("id", &self.inner.id)
257 .field("interval", &self.inner.interval)
258 .finish_non_exhaustive()
259 }
260}
261
262impl EosPipeline {
263 fn new(publisher: KafkaTransactionalPublisher, interval: Duration) -> Self {
264 let id = publisher.id().to_owned();
265 Self {
266 inner: Arc::new(PipelineInner {
267 publisher,
268 id,
269 interval,
270 window: Mutex::new(Window {
271 phase: Phase::Idle,
272 enrolled: HashMap::new(),
273 failed: false,
274 epoch: 0,
275 }),
276 phase_changed: tokio::sync::Notify::new(),
277 sources: Mutex::new(Vec::new()),
278 committed: Mutex::new(HashMap::new()),
279 session_low: Mutex::new(HashMap::new()),
280 }),
281 }
282 }
283
284 /// The pipeline id: the transactional id its producer is fenced by, and the id
285 /// `Commit::Transactional` subscriptions register under.
286 #[must_use]
287 pub fn id(&self) -> &str {
288 &self.inner.id
289 }
290
291 /// Publishes `msg` into the pipeline's open window on behalf of the delivery at `source`.
292 ///
293 /// The record joins the window's transaction and becomes visible at its commit, atomically
294 /// with the source position. Publish, then return `Ack`: the settled watermark is what
295 /// releases the window's commit.
296 ///
297 /// # Errors
298 ///
299 /// Returns [`KafkaError::Closed`] once the broker has shut down and
300 /// [`KafkaError::Publish`] when opening the transaction or producing the record fails - the
301 /// window aborts and redelivers, so failing the handler (`retry()`) is the right response.
302 ///
303 /// # Cancel safety
304 ///
305 /// Not cancel safe: dropping the future may leave the record in the window's transaction.
306 ///
307 /// # Panics
308 ///
309 /// Panics when the internal window mutex is poisoned, which requires a prior panic
310 /// inside the pipeline (an invariant violation, not an operational failure).
311 pub async fn publish(
312 &self,
313 source: &SourceOffset,
314 msg: OutgoingMessage<'_>,
315 ) -> Result<(), KafkaError> {
316 let epoch = self.admit(source).await?;
317 let sent = self.inner.publisher.publish(msg).await;
318 if sent.is_err() {
319 let mut window = self.inner.window.lock().expect("window mutex poisoned");
320 // A failed produce poisons the transaction it was admitted into. The epoch guard
321 // keeps a slow failure from an already-torn-down window off its successor, which
322 // every other window-touching path already checks.
323 if window.epoch == epoch {
324 window.failed = true;
325 }
326 }
327 sent
328 }
329
330 /// Joins the open window (opening one when idle), waiting out a commit in progress
331 /// unless the delivery is already part of it. Returns the epoch of the window the source
332 /// was admitted into, so the caller can attribute a later failure to that window only.
333 async fn admit(&self, source: &SourceOffset) -> Result<u64, KafkaError> {
334 loop {
335 // The waiter is created before the phase check so a transition landing in
336 // between is not missed.
337 let phase_changed = self.inner.phase_changed.notified();
338 // A window whose sources were repositioned is void: its transaction is about to
339 // abort, and anything published into it would be purged with it. The replayed
340 // delivery waits for the fresh window instead.
341 let voided = self.repositioned();
342 let action = {
343 let mut window = self.inner.window.lock().expect("window mutex poisoned");
344 match window.phase {
345 _ if voided && window.phase != Phase::Idle => Admission::Wait,
346 Phase::Open => {
347 Self::enroll(&mut window, &self.inner.session_low, source);
348 Admission::Admitted(window.epoch)
349 }
350 Phase::Idle => {
351 window.phase = Phase::Opening;
352 Admission::Opener
353 }
354 Phase::Opening => Admission::Wait,
355 Phase::Committing => {
356 // A delivery at or below the window's enrolled offsets is part of
357 // the committing window: its records must land in it (the commit is
358 // waiting for its settle). Anything else waits for the next window.
359 let participant = window
360 .enrolled
361 .get(&source.key())
362 .is_some_and(|max| source.offset <= *max);
363 if participant {
364 Admission::Admitted(window.epoch)
365 } else {
366 Admission::Wait
367 }
368 }
369 }
370 };
371 match action {
372 Admission::Admitted(epoch) => return Ok(epoch),
373 Admission::Wait => {
374 phase_changed.await;
375 }
376 Admission::Opener => return self.open_window(source).await,
377 }
378 }
379 }
380
381 /// Opens the transaction as the winning publisher and spawns the window's commit task.
382 /// Returns the opened window's epoch.
383 async fn open_window(&self, source: &SourceOffset) -> Result<u64, KafkaError> {
384 // The registry lookup happens once per window, here, and the publish path reads the
385 // cached list. A reposition from before this window is already reflected in the
386 // sources' bookkeeping, so its flag is cleared: only a seek landing while the window is
387 // open has offsets of this window to invalidate.
388 let registered = self.inner.publisher.state().eos_sources(&self.inner.id);
389 for source in registered.iter().filter_map(EosSource::upgrade) {
390 source.tracker.take_repositioned();
391 }
392 *self.inner.sources.lock().expect("sources mutex poisoned") = registered;
393 let begun = self.inner.publisher.begin_transaction().await;
394 let mut window = self.inner.window.lock().expect("window mutex poisoned");
395 match begun {
396 Ok(()) => {
397 window.phase = Phase::Open;
398 window.failed = false;
399 Self::enroll(&mut window, &self.inner.session_low, source);
400 let epoch = window.epoch;
401 drop(window);
402 tokio::spawn(run_window(Arc::clone(&self.inner), epoch));
403 self.inner.phase_changed.notify_waiters();
404 Ok(epoch)
405 }
406 Err(err) => {
407 window.phase = Phase::Idle;
408 drop(window);
409 self.inner.phase_changed.notify_waiters();
410 Err(err)
411 }
412 }
413 }
414
415 /// Whether any source of the open window has a pending reposition. Reads the cached source
416 /// list (refreshed once per window) and one atomic per source, so it stays cheap enough for
417 /// the publish path.
418 fn repositioned(&self) -> bool {
419 self.inner
420 .sources
421 .lock()
422 .expect("sources mutex poisoned")
423 .iter()
424 .filter_map(EosSource::upgrade)
425 .any(|source| source.tracker.is_repositioned())
426 }
427
428 fn enroll(
429 window: &mut Window,
430 session_low: &Mutex<HashMap<(String, i32), i64>>,
431 source: &SourceOffset,
432 ) {
433 let key = source.key();
434 session_low
435 .lock()
436 .expect("session low mutex poisoned")
437 .entry(key.clone())
438 .or_insert(source.offset);
439 let max = window.enrolled.entry(key).or_insert(source.offset);
440 if source.offset > *max {
441 *max = source.offset;
442 }
443 }
444}
445
446enum Admission {
447 Admitted(u64),
448 Wait,
449 Opener,
450}
451
452/// The per-window task: sleeps out the commit interval, closes admission, waits for the
453/// participants to settle, and commits (or aborts and seeks back).
454async fn run_window(inner: Arc<PipelineInner>, epoch: u64) {
455 stay_open(&inner).await;
456 let enrolled = {
457 let mut window = inner.window.lock().expect("window mutex poisoned");
458 if window.epoch != epoch || window.phase != Phase::Open {
459 return;
460 }
461 window.phase = Phase::Committing;
462 window.enrolled.clone()
463 };
464 let outcome = commit_window(&inner, &enrolled).await;
465 {
466 let mut window = inner.window.lock().expect("window mutex poisoned");
467 window.phase = Phase::Idle;
468 window.enrolled.clear();
469 window.failed = false;
470 window.epoch += 1;
471 }
472 inner.phase_changed.notify_waiters();
473 if let Err(err) = outcome {
474 error!(
475 target: "ruststream_rdkafka",
476 pipeline = %inner.id,
477 error = %err,
478 "EOS window aborted; its sources seek back and the window redelivers",
479 );
480 }
481}
482
483/// Holds the window open for the commit interval, or until a source is repositioned.
484///
485/// A seek voids everything the window would commit, so waiting out the rest of the interval
486/// would only strand the replayed deliveries behind a transaction that is going to abort.
487async fn stay_open(inner: &Arc<PipelineInner>) {
488 let sources: Vec<LiveSource> = inner
489 .sources
490 .lock()
491 .expect("sources mutex poisoned")
492 .iter()
493 .filter_map(EosSource::upgrade)
494 .collect();
495 // Waiters first, flag second: a reposition landing between the two is caught by the
496 // already-registered waiters.
497 let waiters: Vec<_> = sources
498 .iter()
499 .map(|source| Box::pin(source.tracker.reposition_waiter()))
500 .collect();
501 if waiters.is_empty()
502 || sources
503 .iter()
504 .any(|source| source.tracker.is_repositioned())
505 {
506 if waiters.is_empty() {
507 tokio::time::sleep(inner.interval).await;
508 }
509 return;
510 }
511 tokio::select! {
512 () = tokio::time::sleep(inner.interval) => {}
513 (..) = select_all(waiters) => {}
514 }
515}
516
517/// Commits the window: settle-wait, offsets into the transaction, commit. Any failure runs
518/// the abort path (abort the transaction, seek the sources back) and reports the cause.
519async fn commit_window(
520 inner: &Arc<PipelineInner>,
521 enrolled: &HashMap<(String, i32), i64>,
522) -> Result<(), KafkaError> {
523 let sources = live_sources(&inner.id, &inner.publisher);
524
525 // A seek while the window was open moved the read position out from under it: the offsets
526 // this window would attach to its transaction describe records the subscription no longer
527 // reads from, so committing them would carry the group past everything the seek replayed.
528 // The window aborts instead, and the seek-back is skipped - the consumer already sits where
529 // the seek put it.
530 // Counting rather than `any`: the flag has to be taken from every source, so a second
531 // source's reposition cannot linger into the next window.
532 let repositioned = sources
533 .iter()
534 .filter(|source| source.tracker.take_repositioned())
535 .count()
536 > 0;
537 if repositioned {
538 abort_window(inner, &sources, enrolled, Repositioned::Yes).await;
539 return Err(KafkaError::InvalidOptions(
540 "the subscription was repositioned while this window was open; its records are \
541 discarded and the replayed deliveries are processed into a fresh window"
542 .to_owned(),
543 ));
544 }
545
546 let failed = {
547 let window = inner.window.lock().expect("window mutex poisoned");
548 window.failed
549 };
550 let ready = if failed {
551 Err(KafkaError::Publish(
552 "a publish into this window failed; the transaction is poisoned"
553 .to_owned()
554 .into(),
555 ))
556 } else {
557 wait_settled(inner, &sources, enrolled).await
558 };
559 let result = match ready {
560 Ok(()) => try_commit(inner, &sources).await,
561 Err(err) => Err(err),
562 };
563 if let Err(err) = result {
564 abort_window(inner, &sources, enrolled, Repositioned::No).await;
565 return Err(err);
566 }
567 Ok(())
568}
569
570/// The pipeline's registered sources that are still alive.
571fn live_sources(id: &str, publisher: &KafkaTransactionalPublisher) -> Vec<LiveSource> {
572 publisher
573 .state()
574 .eos_sources(id)
575 .iter()
576 .filter_map(EosSource::upgrade)
577 .collect()
578}
579
580/// Whether the window is aborting because its sources were repositioned, which decides the fate
581/// of the seek-back.
582#[derive(Debug, Clone, Copy, PartialEq, Eq)]
583enum Repositioned {
584 Yes,
585 No,
586}
587
588/// Waits until every enrolled (topic, partition) has settled up to its enrolled offset, with
589/// the publisher's transaction-timeout as the stall deadline.
590async fn wait_settled(
591 inner: &Arc<PipelineInner>,
592 sources: &[LiveSource],
593 enrolled: &HashMap<(String, i32), i64>,
594) -> Result<(), KafkaError> {
595 let deadline = tokio::time::Instant::now() + inner.publisher.deadline();
596 loop {
597 // Waiters first, condition second: an advance between the two is caught by the
598 // already-registered waiters.
599 let waiters: Vec<_> = sources
600 .iter()
601 .map(|source| Box::pin(source.tracker.advance_waiter()))
602 .collect();
603 let pending = enrolled.iter().find(|((topic, partition), max)| {
604 !sources.iter().any(|source| {
605 source
606 .tracker
607 .stored_position(topic, *partition)
608 .is_some_and(|stored| stored >= **max)
609 })
610 });
611 let Some(((topic, partition), max)) = pending else {
612 return Ok(());
613 };
614 if waiters.is_empty() {
615 return Err(KafkaError::InvalidOptions(format!(
616 "EOS pipeline has no registered sources for its id; is the subscription in \
617 `Commit::Transactional` mode with the matching pipeline id? (waiting on \
618 {topic}[{partition}] up to offset {max})",
619 )));
620 }
621 debug!(
622 target: "ruststream_rdkafka",
623 topic = %topic,
624 partition = partition,
625 up_to = max,
626 "EOS window waiting for participants to settle",
627 );
628 if tokio::time::timeout_at(deadline, select_all(waiters))
629 .await
630 .is_err()
631 {
632 return Err(KafkaError::Publish(
633 format!(
634 "EOS window stalled: {topic}[{partition}] did not settle up to offset \
635 {max} within the transaction deadline (a hung or retrying handler, or a \
636 revoked partition)",
637 )
638 .into(),
639 ));
640 }
641 }
642}
643
644/// Adds every source's settled positions (with its group metadata) to the transaction and
645/// commits it.
646async fn try_commit(inner: &Arc<PipelineInner>, sources: &[LiveSource]) -> Result<(), KafkaError> {
647 let mut sent: Vec<((String, i32), i64)> = Vec::new();
648 for source in sources {
649 let positions = source.tracker.stored_positions();
650 if positions.is_empty() {
651 continue;
652 }
653 let mut offsets = TopicPartitionList::new();
654 for ((topic, partition), stored) in &positions {
655 offsets
656 .add_partition_offset(topic, *partition, Offset::Offset(stored + 1))
657 .map_err(KafkaError::publish)?;
658 }
659 let metadata = group_metadata(source)?;
660 inner.publisher.send_offsets(offsets, metadata).await?;
661 sent.extend(positions.into_iter().map(|(key, stored)| (key, stored + 1)));
662 }
663 inner.publisher.commit().await?;
664 {
665 let mut committed = inner.committed.lock().expect("committed mutex poisoned");
666 for (key, next) in sent {
667 committed.insert(key, next);
668 }
669 }
670 Ok(())
671}
672
673fn group_metadata(source: &LiveSource) -> Result<ConsumerGroupMetadata, KafkaError> {
674 source.consumer.group_metadata().ok_or_else(|| {
675 KafkaError::Publish(
676 "the source consumer has no group metadata (not a group member yet or already \
677 closed); cannot commit its offsets transactionally"
678 .to_owned()
679 .into(),
680 )
681 })
682}
683
684/// The abort path: abort the transaction and seek every enrolled partition back to the last
685/// offset this pipeline committed (or the first offset it ever saw), so the whole window
686/// redelivers promptly instead of waiting for a rebalance.
687///
688/// A window aborting because it was repositioned skips the seek-back entirely: the consumer is
689/// already where the seek put it, and rewinding to this pipeline's own bookkeeping would undo
690/// the caller's reposition. That bookkeeping is dropped with it, since it describes offsets of a
691/// read position this subscription no longer has.
692async fn abort_window(
693 inner: &Arc<PipelineInner>,
694 sources: &[LiveSource],
695 enrolled: &HashMap<(String, i32), i64>,
696 repositioned: Repositioned,
697) {
698 if let Err(err) = inner.publisher.abort().await {
699 error!(
700 target: "ruststream_rdkafka",
701 error = %err,
702 "EOS window abort failed; the transaction resolves by its broker-side timeout",
703 );
704 }
705 if repositioned == Repositioned::Yes {
706 {
707 let mut committed = inner.committed.lock().expect("committed mutex poisoned");
708 for key in enrolled.keys() {
709 committed.remove(key);
710 }
711 }
712 {
713 let mut session_low = inner
714 .session_low
715 .lock()
716 .expect("session low mutex poisoned");
717 for key in enrolled.keys() {
718 session_low.remove(key);
719 }
720 }
721 return;
722 }
723 let committed = inner
724 .committed
725 .lock()
726 .expect("committed mutex poisoned")
727 .clone();
728 let session_low = inner
729 .session_low
730 .lock()
731 .expect("session low mutex poisoned")
732 .clone();
733 for key @ (topic, partition) in enrolled.keys() {
734 let Some(target) = committed
735 .get(key)
736 .copied()
737 .or_else(|| session_low.get(key).copied())
738 else {
739 continue;
740 };
741 let Some(source) = sources
742 .iter()
743 .find(|source| source.tracker.covers(topic, *partition))
744 else {
745 continue;
746 };
747 if let Err(err) = source.consumer.seek(
748 topic,
749 *partition,
750 Offset::Offset(target),
751 Duration::from_secs(5),
752 ) {
753 // A revoked partition cannot seek; its new owner resumes from the committed
754 // offset on its own.
755 debug!(
756 target: "ruststream_rdkafka",
757 topic = %topic,
758 partition = partition,
759 error = %err,
760 "seek-back after an aborted EOS window failed",
761 );
762 }
763 }
764}
765
766/// Header carrying a transactional delivery's source coordinates through the reply path.
767///
768/// Stamped onto every incoming delivery of a `Commit::Transactional` subscription (the value is
769/// `"{partition}:{offset}:{topic}"`), relayed onto a publishing handler's reply by
770/// [`EosReplies`], and consumed by the pipeline's [`Publisher`] impl to pair the reply with the
771/// consumed offset. It is stripped from every outgoing publish, so it never reaches the wire.
772pub const EOS_SOURCE_HEADER: &str = "kafka-eos-source";
773
774pub(crate) fn encode_source(topic: &str, partition: i32, offset: i64) -> String {
775 format!("{partition}:{offset}:{topic}")
776}
777
778fn decode_source(value: &str) -> Option<SourceOffset> {
779 let mut parts = value.splitn(3, ':');
780 let partition = parts.next()?.parse().ok()?;
781 let offset = parts.next()?.parse().ok()?;
782 let topic = parts.next()?;
783 Some(SourceOffset::new(topic, partition, offset))
784}
785
786/// The [`PublishTransform`] relaying [`EOS_SOURCE_HEADER`] from the originating delivery onto
787/// the reply, so the pipeline's [`Publisher`] impl can pair the reply with its consumed offset.
788///
789/// [`KafkaEosPublish::replies`] wires it for you; name it directly to keep the explicit
790/// `TypedPublisher` form: `TypedPublisher::new(policy).transform(EosReplies)`.
791/// Generic over the handler's context type, so bare handlers (no ctx parameter, no `Ctx`
792/// extractors) work.
793#[derive(Debug, Clone, Copy, Default)]
794pub struct EosReplies;
795
796impl<C> PublishTransform<C> for EosReplies {
797 fn apply(&self, out: &mut Outgoing<'_>, cx: &PublishContext<'_, C>) {
798 if let Some(source) = cx.headers().get(EOS_SOURCE_HEADER) {
799 let source = source.to_vec();
800 out.headers_mut().insert(EOS_SOURCE_HEADER, source);
801 }
802 }
803}
804
805impl KafkaEosPublish {
806 /// A reply publisher for `#[subscriber(.., publish("replies"))]` handlers: every reply
807 /// joins the pipeline's open window paired with its delivery's consumed offset, making the
808 /// publishing-handler form exactly-once end to end - the handler just returns the value.
809 ///
810 /// Pairs only with subscriptions in `Commit::Transactional` mode naming this pipeline's id
811 /// (they stamp the source coordinates the reply path relays); a reply from any other
812 /// subscription fails with a clear error. The `retry_after` deferred-republish fallback
813 /// does not apply to these replies: a delayed copy would break the offset-record pairing.
814 ///
815 /// Equivalent explicit form: `TypedPublisher::new(policy).transform(EosReplies)`.
816 ///
817 /// # Examples
818 ///
819 /// ```
820 /// use ruststream_rdkafka::KafkaEosPublish;
821 ///
822 /// let replies = KafkaEosPublish::new("enrich-1").replies();
823 /// // b.include(enrich).publisher(replies);
824 /// # let _ = replies;
825 /// ```
826 #[cfg(any(feature = "json", feature = "cbor", feature = "msgpack"))]
827 #[must_use]
828 pub fn replies(
829 self,
830 ) -> TypedPublisher<
831 Self,
832 DefaultCodec,
833 PublishTransformStack<PublishTransformIdentity, EosReplies>,
834 > {
835 TypedPublisher::new(self).transform(EosReplies)
836 }
837
838 /// Like [`replies`](Self::replies), with an explicit codec instead of the default one.
839 #[must_use]
840 pub fn replies_with<C: Codec>(
841 self,
842 codec: C,
843 ) -> TypedPublisher<Self, C, PublishTransformStack<PublishTransformIdentity, EosReplies>> {
844 TypedPublisher::with_codec(self, codec).transform(EosReplies)
845 }
846}
847
848impl Publisher for EosPipeline {
849 type Error = KafkaError;
850
851 /// Publishes a reply into the pipeline's open window, paired with the source coordinates
852 /// the [`EOS_SOURCE_HEADER`] carries (stripped before the record is produced).
853 ///
854 /// # Errors
855 ///
856 /// Returns [`KafkaError::InvalidOptions`] when the header is missing or malformed - the
857 /// originating subscription is not in `Commit::Transactional` mode for this pipeline, or
858 /// the reply publisher was wired without [`EosReplies`] (use
859 /// [`replies`](KafkaEosPublish::replies)); otherwise as
860 /// [`EosPipeline::publish`](EosPipeline::publish).
861 ///
862 /// # Cancel safety
863 ///
864 /// Not cancel safe: dropping the future may leave the record in the window's transaction.
865 async fn publish(&self, msg: OutgoingMessage<'_>) -> Result<(), Self::Error> {
866 let Some(source) = msg
867 .headers()
868 .get_str(EOS_SOURCE_HEADER)
869 .and_then(decode_source)
870 else {
871 return Err(KafkaError::InvalidOptions(
872 "an EOS reply carries no source coordinates: the subscription must be in \
873 `Commit::Transactional` mode for this pipeline, and the reply publisher must \
874 relay them (wire it with `KafkaEosPublish::replies()` or add the `EosReplies` \
875 transform)"
876 .to_owned(),
877 ));
878 };
879 let mut headers = msg.headers().clone();
880 headers.remove(EOS_SOURCE_HEADER);
881 let stripped = OutgoingMessage::new(msg.name(), msg.payload()).with_headers(headers);
882 self.publish(&source, stripped).await
883 }
884}
885
886#[cfg(test)]
887mod tests {
888 use super::*;
889
890 #[test]
891 fn source_header_roundtrips_topics_with_colons() {
892 let encoded = encode_source("orders:eu:v1", 3, 42);
893 let decoded = decode_source(&encoded).expect("decodes");
894 assert_eq!(decoded, SourceOffset::new("orders:eu:v1", 3, 42));
895 }
896
897 #[test]
898 fn malformed_source_headers_are_rejected() {
899 for bad in ["", "3", "3:x:orders", "x:42:orders"] {
900 assert!(decode_source(bad).is_none(), "{bad:?} must not decode");
901 }
902 }
903
904 #[test]
905 fn the_policy_carries_the_pipeline_id_and_interval() {
906 let policy = KafkaEosPublish::new("enrich-1").commit_interval(Duration::from_millis(250));
907 assert_eq!(policy.id(), "enrich-1");
908 assert_eq!(policy.interval, Duration::from_millis(250));
909 }
910}