Skip to main content

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}