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