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};
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}