Skip to main content

krabka_client_producer/
transactional.rs

1//! Client-side transactional state machine. It drives the
2//! `init_transactions` / `begin` / `commit` / `abort` / `send_offsets_to_transaction`
3//! flow.
4
5use std::{
6    fmt,
7    str::FromStr,
8    sync::{
9        Arc,
10        atomic::{AtomicI32, Ordering},
11    },
12};
13
14use crate::{error::ProducerError, producer::Producer};
15
16/// The slot holds no error. An abortable error code fits in an `i16`, and
17/// `-1` is `UNKNOWN_SERVER_ERROR`, so the empty value is outside that range.
18const NO_TXN_ERROR: i32 = i32::MIN;
19
20/// The sentinel that marks the slot as holding an abortable timeout rather
21/// than a broker error code. It is one above `NO_TXN_ERROR` (`i32::MIN`), so
22/// it stays outside the `i16` code range and cannot collide with a real,
23/// sign-extended code.
24const ABORTABLE_TIMEOUT: i32 = i32::MIN + 1;
25
26/// The sentinel that marks the slot as holding a fence.
27const FATAL_FENCED: i32 = i32::MIN + 2;
28
29/// The offset that moves a fatal error code out of the abortable code range.
30/// A fatal code `c` is stored as `c + FATAL_CODE_OFFSET`, which is in
31/// `[0x1_0000, 0x1_ffff]`.
32const FATAL_CODE_OFFSET: i32 = 0x1_8000;
33
34/// The lowest stored value of a fatal error code: `i16::MIN +
35/// FATAL_CODE_OFFSET`.
36const FATAL_CODE_MIN: i32 = 0x1_0000;
37
38/// The error that makes the current transaction abort-only.
39///
40/// Apache Kafka's `TransactionManager` moves to the `ABORTABLE_ERROR` state
41/// when a batch of the transaction fails, or when a coordinator answers with a
42/// code that only an abort can clear (`abortableError`). Every later
43/// `commitTransaction` and `send` then fails with the stored error
44/// (`maybeFailWithError`), and only `abortTransaction` clears the state. Kafka
45/// stores the raised exception itself, which is either a broker error code
46/// wrapped in a `KafkaException`, or a client-side `TimeoutException` that
47/// carries no code (a batch that ran out of retries or its routing budget
48/// with no broker answer).
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum AbortableError {
51    /// A coordinator, or a Produce response, answered with this code.
52    Server(i16),
53    /// A batch of the transaction was never acknowledged, and carries no
54    /// broker code. Kafka raises `TimeoutException` for the same case.
55    Timeout,
56}
57
58/// The error that stops every later transactional operation.
59///
60/// Apache Kafka's `TransactionManager` moves to the `FATAL_ERROR` state when a
61/// coordinator fences the producer, or answers with a code that its handler
62/// passes to `fatalError`. `FATAL_ERROR` is never a valid start state of a
63/// transition, and `maybeFailWithError` throws in `initTransactions`,
64/// `beginTransaction`, `send`, `sendOffsetsToTransaction`,
65/// `commitTransaction` and `abortTransaction`. The application can only close
66/// the producer.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum FatalError {
69    /// A newer epoch owns the transactional id (`PRODUCER_FENCED` or
70    /// `INVALID_PRODUCER_EPOCH`).
71    Fenced,
72    /// A coordinator answered with this code.
73    Server(i16),
74}
75
76impl FatalError {
77    /// The transaction state after this error.
78    pub(crate) const fn state(self) -> TxnState {
79        match self {
80            Self::Fenced => TxnState::Fenced,
81            Self::Server(_) => TxnState::FatalError,
82        }
83    }
84
85    /// The error that an operation reports in this state.
86    pub(crate) const fn error(self) -> ProducerError {
87        match self {
88            Self::Fenced => ProducerError::FencedProducer,
89            Self::Server(code) => ProducerError::FatalTransactionError(code),
90        }
91    }
92}
93
94/// The error state of the transaction: none, abortable, or fatal.
95///
96/// The sender task writes this slot from a synchronous context, so it holds an
97/// atomic rather than a mutex. A fatal error is final: a later abortable error
98/// does not replace it, and a clear does not remove it, as Kafka's
99/// `TransactionManager` allows no transition out of `FATAL_ERROR`.
100#[derive(Debug)]
101pub struct TxnErrorSlot(AtomicI32);
102
103impl Default for TxnErrorSlot {
104    fn default() -> Self {
105        Self(AtomicI32::new(NO_TXN_ERROR))
106    }
107}
108
109impl TxnErrorSlot {
110    /// Store `value` unless the slot holds a fatal error.
111    fn store_abortable(&self, value: i32) {
112        let _ = self
113            .0
114            .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
115                (!is_fatal(current)).then_some(value)
116            });
117    }
118
119    /// Store `code` as the error that the application must abort. A later
120    /// error replaces an earlier one, as Kafka's `transitionTo` replaces
121    /// `lastError`. It has no effect after a fatal error.
122    pub(crate) fn set_abortable(&self, code: i16) {
123        self.store_abortable(i32::from(code));
124    }
125
126    /// Store that the transaction can no longer commit because a batch of it
127    /// timed out with no broker code. A later error replaces an earlier one,
128    /// same as [`Self::set_abortable`].
129    pub(crate) fn set_abortable_timeout(&self) {
130        self.store_abortable(ABORTABLE_TIMEOUT);
131    }
132
133    /// Store the fatal error `error`. It replaces any earlier error, as
134    /// Kafka's `transitionToFatalError` replaces `lastError`.
135    pub(crate) fn set_fatal(&self, error: FatalError) {
136        let value = match error {
137            FatalError::Fenced => FATAL_FENCED,
138            FatalError::Server(code) => i32::from(code) + FATAL_CODE_OFFSET,
139        };
140        self.0.store(value, Ordering::Release);
141    }
142
143    /// The stored abortable error, or `None` when the transaction can still
144    /// commit or the error is fatal.
145    pub(crate) fn abortable(&self) -> Option<AbortableError> {
146        match self.0.load(Ordering::Acquire) {
147            ABORTABLE_TIMEOUT => Some(AbortableError::Timeout),
148            value => i16::try_from(value).ok().map(AbortableError::Server),
149        }
150    }
151
152    /// The stored fatal error, or `None` when there is none.
153    pub(crate) fn fatal(&self) -> Option<FatalError> {
154        match self.0.load(Ordering::Acquire) {
155            FATAL_FENCED => Some(FatalError::Fenced),
156            value if value >= FATAL_CODE_MIN => i16::try_from(value - FATAL_CODE_OFFSET)
157                .ok()
158                .map(FatalError::Server),
159            _ => None,
160        }
161    }
162
163    /// Forget the stored abortable error. An abort and a new producer identity
164    /// both clear it, as Kafka's `resetTransactionState` and
165    /// `InitProducerIdHandler` clear `lastError`. A fatal error stays.
166    pub(crate) fn clear_abortable(&self) {
167        self.store_abortable(NO_TXN_ERROR);
168    }
169}
170
171/// Tell if a stored slot value is a fatal error.
172const fn is_fatal(value: i32) -> bool {
173    value == FATAL_FENCED || value >= FATAL_CODE_MIN
174}
175
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177#[allow(dead_code)]
178pub enum TxnState {
179    /// The caller has not yet called `init_transactions`.
180    Uninitialized,
181    /// `init_transactions` succeeded, and no txn is in flight.
182    Ready,
183    /// Inside `begin_transaction` ... `commit/abort`.
184    InTransaction,
185    /// `prepare_transaction` has stopped new writes and is flushing records.
186    Preparing,
187    /// A 2PC transaction has been flushed and awaits its external decision.
188    Prepared,
189    /// `init_transactions_with_keep_prepared` is in flight.
190    Initializing,
191    /// A `commit` or an `abort` is in progress.
192    CommittingOrAborting,
193    /// A guard was dropped or `EndTxn` had an uncertain transport outcome.
194    /// `init_transactions` must establish a new epoch before reuse.
195    RecoveryRequired,
196    /// A newer epoch owns the transactional id. Every later transactional
197    /// operation fails with `FencedProducer`, as Kafka's `FATAL_ERROR` state
198    /// does for `ProducerFencedException`.
199    Fenced,
200    /// A coordinator answered with a fatal code. Every later transactional
201    /// operation fails with `FatalTransactionError`, as Kafka's `FATAL_ERROR`
202    /// state does.
203    FatalError,
204}
205
206/// Stable identity of a transaction prepared for external two-phase commit.
207///
208/// Its string form is Kafka-compatible: `"producer_id:producer_epoch"`.
209/// The empty string represents no transaction and round-trips through
210/// [`Default`].
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
212pub struct PreparedTransactionState {
213    producer_id: i64,
214    producer_epoch: i16,
215}
216
217impl PreparedTransactionState {
218    /// Creates a prepared transaction identity.
219    ///
220    /// # Errors
221    ///
222    /// Returns [`PreparedTransactionStateParseError`] when either identity
223    /// component is negative.
224    pub fn new(
225        producer_id: i64,
226        producer_epoch: i16,
227    ) -> Result<Self, PreparedTransactionStateParseError> {
228        if producer_id < 0 || producer_epoch < 0 {
229            return Err(PreparedTransactionStateParseError);
230        }
231        Ok(Self {
232            producer_id,
233            producer_epoch,
234        })
235    }
236
237    /// Producer ID of the prepared transaction.
238    #[must_use]
239    pub const fn producer_id(self) -> i64 {
240        self.producer_id
241    }
242
243    /// Producer epoch of the prepared transaction.
244    #[must_use]
245    pub const fn producer_epoch(self) -> i16 {
246        self.producer_epoch
247    }
248
249    /// Whether this token identifies a real transaction.
250    #[must_use]
251    pub const fn has_transaction(self) -> bool {
252        self.producer_id >= 0
253    }
254}
255
256impl Default for PreparedTransactionState {
257    fn default() -> Self {
258        Self {
259            producer_id: -1,
260            producer_epoch: -1,
261        }
262    }
263}
264
265impl fmt::Display for PreparedTransactionState {
266    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
267        if self.has_transaction() {
268            write!(formatter, "{}:{}", self.producer_id, self.producer_epoch)
269        } else {
270            Ok(())
271        }
272    }
273}
274
275impl FromStr for PreparedTransactionState {
276    type Err = PreparedTransactionStateParseError;
277
278    fn from_str(serialized: &str) -> Result<Self, Self::Err> {
279        if serialized.is_empty() {
280            return Ok(Self::default());
281        }
282        let (producer_id, producer_epoch) = serialized
283            .split_once(':')
284            .ok_or(PreparedTransactionStateParseError)?;
285        if producer_epoch.contains(':') {
286            return Err(PreparedTransactionStateParseError);
287        }
288        Self::new(
289            producer_id
290                .parse()
291                .map_err(|_| PreparedTransactionStateParseError)?,
292            producer_epoch
293                .parse()
294                .map_err(|_| PreparedTransactionStateParseError)?,
295        )
296    }
297}
298
299/// Error returned when a prepared transaction token is malformed.
300#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
301#[error("invalid prepared transaction state; expected producer_id:producer_epoch")]
302pub struct PreparedTransactionStateParseError;
303
304/// An open transaction, borrowing the [`Producer`] that opened it.
305///
306/// [`Producer::begin_transaction`] returns it. [`commit`](Self::commit) and
307/// [`abort`](Self::abort) each consume `self` on success, so a transaction
308/// cannot be silently reused or finished twice.
309///
310/// On failure the call hands the guard back through
311/// [`EndTransactionError::transaction`] instead of dropping it. Kafka's
312/// `EndTxn` contract makes some failures, such as `CONCURRENT_TRANSACTIONS`,
313/// retryable against the very same broker-side transaction, so the caller can
314/// retry `commit()` on the returned guard, or switch to `abort()`. For
315/// non-retryable failures the producer's transaction state has already moved
316/// on, and the returned guard's next `commit` or `abort` attempt fails
317/// immediately.
318///
319/// A dropped unresolved guard marks the producer as recovery-required. The
320/// producer never guesses whether Kafka committed or aborted the transaction.
321/// The caller must call `init_transactions` before the producer can send or
322/// begin again.
323#[derive(Debug)]
324#[must_use = "a transaction must be finished with `commit()` or `abort()`"]
325pub struct Transaction<'p> {
326    pub(crate) producer: &'p Producer,
327    pub(crate) finished: bool,
328    pub(crate) guard_generation: u64,
329}
330
331impl Transaction<'_> {
332    /// Flushes and prepares this transaction for external two-phase commit.
333    ///
334    /// # Errors
335    ///
336    /// See [`Producer::prepare_transaction`].
337    pub async fn prepare(&self) -> Result<PreparedTransactionState, ProducerError> {
338        self.producer.prepare_transaction().await
339    }
340
341    /// Commit this transaction.
342    ///
343    /// # Errors
344    ///
345    /// See [`Producer::begin_transaction`] for the shared error conditions.
346    /// On failure, `self` is returned via [`EndTransactionError::transaction`]
347    /// so a retryable failure can be retried or aborted.
348    pub async fn commit(mut self) -> Result<(), EndTransactionError<Self>> {
349        match self.producer.end_transaction(true).await {
350            Ok(()) => {
351                self.finished = true;
352                Ok(())
353            }
354            Err(source) => Err(EndTransactionError {
355                transaction: self,
356                source,
357            }),
358        }
359    }
360
361    /// Abort this transaction.
362    ///
363    /// # Errors
364    ///
365    /// See [`Producer::begin_transaction`] for the shared error conditions.
366    /// On failure, `self` is returned via [`EndTransactionError::transaction`]
367    /// so a retryable failure can be retried or aborted.
368    pub async fn abort(mut self) -> Result<(), EndTransactionError<Self>> {
369        match self.producer.end_transaction(false).await {
370            Ok(()) => {
371                self.finished = true;
372                Ok(())
373            }
374            Err(source) => Err(EndTransactionError {
375                transaction: self,
376                source,
377            }),
378        }
379    }
380}
381
382impl Drop for Transaction<'_> {
383    fn drop(&mut self) {
384        if !self.finished {
385            self.producer
386                .abandon_transaction_guard(self.guard_generation);
387        }
388    }
389}
390
391/// Same contract as [`Transaction`], but owns an `Arc<Producer>` instead of
392/// borrowing it.
393///
394/// Use it when the caller must hold the guard across an owned or `'static`
395/// boundary that a borrow cannot survive, for example behind a `dyn Trait`
396/// object stored in a struct field across many separate async calls.
397/// [`Producer::begin_transaction_owned`] returns it. It mirrors
398/// `tokio::sync::Mutex::{lock, lock_owned}` and `MutexGuard`/`OwnedMutexGuard`.
399#[derive(Debug)]
400#[must_use = "a transaction must be finished with `commit()` or `abort()`"]
401pub struct OwnedTransaction {
402    pub(crate) producer: Arc<Producer>,
403    pub(crate) finished: bool,
404    pub(crate) guard_generation: u64,
405}
406
407impl OwnedTransaction {
408    /// Flushes and prepares this transaction for external two-phase commit.
409    ///
410    /// # Errors
411    ///
412    /// See [`Producer::prepare_transaction`].
413    pub async fn prepare(&self) -> Result<PreparedTransactionState, ProducerError> {
414        self.producer.prepare_transaction().await
415    }
416
417    /// Commit this transaction.
418    ///
419    /// # Errors
420    ///
421    /// See [`Producer::begin_transaction`] for the shared error conditions.
422    /// On failure, `self` is returned via [`EndTransactionError::transaction`]
423    /// so a retryable failure can be retried or aborted.
424    pub async fn commit(mut self) -> Result<(), EndTransactionError<Self>> {
425        match self.producer.end_transaction(true).await {
426            Ok(()) => {
427                self.finished = true;
428                Ok(())
429            }
430            Err(source) => Err(EndTransactionError {
431                transaction: self,
432                source,
433            }),
434        }
435    }
436
437    /// Abort this transaction.
438    ///
439    /// # Errors
440    ///
441    /// See [`Producer::begin_transaction`] for the shared error conditions.
442    /// On failure, `self` is returned via [`EndTransactionError::transaction`]
443    /// so a retryable failure can be retried or aborted.
444    pub async fn abort(mut self) -> Result<(), EndTransactionError<Self>> {
445        match self.producer.end_transaction(false).await {
446            Ok(()) => {
447                self.finished = true;
448                Ok(())
449            }
450            Err(source) => Err(EndTransactionError {
451                transaction: self,
452                source,
453            }),
454        }
455    }
456}
457
458impl Drop for OwnedTransaction {
459    fn drop(&mut self) {
460        if !self.finished {
461            self.producer
462                .abandon_transaction_guard(self.guard_generation);
463        }
464    }
465}
466
467/// Error returned by [`Transaction::commit`], [`abort`](Transaction::abort),
468/// and the [`OwnedTransaction`] equivalents.
469///
470/// It carries the guard back, so the caller can retry or abort a retryable
471/// failure, such as `CONCURRENT_TRANSACTIONS`, on the same underlying
472/// transaction instead of stranding it.
473#[derive(Debug, thiserror::Error)]
474#[error("{source}")]
475pub struct EndTransactionError<T> {
476    /// The guard the `commit` or `abort` call was made on, handed back so the
477    /// caller can retry `commit()` or call `abort()` on the same
478    /// transaction.
479    pub transaction: T,
480    /// The underlying failure.
481    #[source]
482    pub source: ProducerError,
483}
484
485#[cfg(test)]
486mod tests {
487    use std::{
488        collections::VecDeque,
489        iter,
490        sync::{
491            Arc, Mutex,
492            atomic::{AtomicBool, AtomicI16, AtomicU16, AtomicUsize, Ordering},
493        },
494        time::Duration,
495    };
496
497    use bytes::BytesMut;
498    use futures::future::join_all;
499    use krabka_client_core::{ClientError, MockBroker};
500    use krabka_protocol::{
501        Decode, Encode,
502        owned::{
503            add_offsets_to_txn_request,
504            add_offsets_to_txn_response::{self, AddOffsetsToTxnResponse},
505            add_partitions_to_txn_request,
506            add_partitions_to_txn_response::{self, AddPartitionsToTxnResponse},
507            api_versions_request,
508            api_versions_response::{ApiVersion, ApiVersionsResponse},
509            common::add_partitions_to_txn_response::{
510                add_partitions_to_txn_partition_result::AddPartitionsToTxnPartitionResult,
511                add_partitions_to_txn_topic_result::AddPartitionsToTxnTopicResult,
512            },
513            end_txn_request,
514            end_txn_response::{self, EndTxnResponse},
515            find_coordinator_request::{self, FindCoordinatorRequest},
516            find_coordinator_response::FindCoordinatorResponse,
517            init_producer_id_request::{self, InitProducerIdRequest},
518            init_producer_id_response::{self, InitProducerIdResponse},
519            metadata_request,
520            metadata_response::{
521                self, MetadataResponse, MetadataResponseBroker, MetadataResponsePartition,
522                MetadataResponseTopic,
523            },
524            produce_request,
525            produce_response::{
526                self, PartitionProduceResponse, ProduceResponse, TopicProduceResponse,
527            },
528            sasl_handshake_request,
529            txn_offset_commit_request::{
530                self, TxnOffsetCommitRequest, TxnOffsetCommitRequestPartition,
531                TxnOffsetCommitRequestTopic,
532            },
533            txn_offset_commit_response::{
534                self, TxnOffsetCommitResponse, TxnOffsetCommitResponsePartition,
535                TxnOffsetCommitResponseTopic,
536            },
537        },
538        primitives::uuid::Uuid,
539    };
540    use tokio::time;
541
542    use super::{AbortableError, EndTransactionError, PreparedTransactionState, TxnState};
543    use crate::{ProducerRecord, error::ProducerError, producer::Producer};
544
545    #[test]
546    fn prepared_transaction_state_has_stable_string_round_trip() {
547        let state = PreparedTransactionState::new(42, 7).expect("valid transaction identity");
548
549        assert2::assert!(state.producer_id() == 42);
550        assert2::assert!(state.producer_epoch() == 7);
551        assert2::assert!(state.has_transaction());
552        assert2::assert!(state.to_string() == "42:7");
553        assert2::assert!("42:7".parse::<PreparedTransactionState>() == Ok(state));
554        assert2::assert!(PreparedTransactionState::default().to_string().is_empty());
555        assert2::assert!(
556            "".parse::<PreparedTransactionState>() == Ok(PreparedTransactionState::default())
557        );
558    }
559
560    #[test]
561    fn prepared_transaction_state_rejects_malformed_or_negative_identity() {
562        for serialized in ["42", "-1:0", "1:-1", "1:2:3", "a:2", "1:b"] {
563            assert2::assert!(serialized.parse::<PreparedTransactionState>().is_err());
564        }
565    }
566
567    /// Tell if an `InitProducerId` request body carries a `transactional.id`.
568    ///
569    /// The builder of an idempotent producer sends one request without it, and
570    /// `init_transactions` sends the requests with it. The body starts with the
571    /// request header client id, and a flexible version adds a tagged-field
572    /// byte after it.
573    fn is_transactional_init(body: &[u8], version: i16) -> bool {
574        decode_request::<InitProducerIdRequest>(
575            body,
576            version,
577            init_producer_id_request::FLEXIBLE_MIN,
578        )
579        .transactional_id
580        .is_some()
581    }
582
583    /// Decode the request of a request body at `version`. The body starts
584    /// with the request header client id, and a flexible version adds a
585    /// tagged-field byte after it.
586    fn decode_request<R: for<'a> Decode<'a>>(body: &[u8], version: i16, flexible_min: i16) -> R {
587        let client_id_len = usize::try_from(i16::from_be_bytes([body[0], body[1]]).max(0))
588            .expect("non-negative client id length");
589        let flexible = usize::from(version >= flexible_min);
590        let mut request = &body[2 + client_id_len + flexible..];
591        R::decode(&mut request, version).expect("decode request")
592    }
593
594    fn encode_v0(resp: &impl Encode) -> Vec<u8> {
595        let mut buf = BytesMut::new();
596        resp.encode(&mut buf, 0).unwrap();
597        buf.to_vec()
598    }
599
600    /// Boots a mock broker that also answers as its own transaction
601    /// coordinator, so `FindCoordinator` resolves back to the mock's own
602    /// address. It returns a transactional `Producer` with `init_transactions`
603    /// already completed against that broker.
604    ///
605    /// `end_txn_error` lets each test steer the `error_code` of the `EndTxn`
606    /// response independently per call, where 0 means success. A test can
607    /// therefore fail a `commit` or `abort`, and then flip the mock so that a
608    /// retry on the same guard succeeds.
609    async fn transactional_producer(end_txn_error: Arc<AtomicI16>) -> (MockBroker, Producer) {
610        transactional_producer_with_end_txn_timeout(end_txn_error, Arc::new(AtomicBool::new(false)))
611            .await
612    }
613
614    #[tokio::test]
615    async fn init_transactions_retries_with_configured_policy() {
616        let port_cell = Arc::new(AtomicU16::new(0));
617        let handler_port = Arc::clone(&port_cell);
618        let attempts = Arc::new(AtomicU16::new(0));
619        let observed = Arc::clone(&attempts);
620        let mock = MockBroker::start(move |api_key, version, _corr_id, body| {
621            if api_key == api_versions_request::API_KEY {
622                return Some(encode_v0(&ApiVersionsResponse::default()));
623            }
624            if api_key == find_coordinator_request::API_KEY {
625                return Some(encode_v0(&FindCoordinatorResponse {
626                    error_code: 0,
627                    node_id: 1,
628                    host: "127.0.0.1".into(),
629                    port: i32::from(handler_port.load(Ordering::SeqCst)),
630                    ..Default::default()
631                }));
632            }
633            if api_key == init_producer_id_request::API_KEY {
634                if !is_transactional_init(body, version) {
635                    return Some(encode_v0(&InitProducerIdResponse {
636                        producer_id: 1,
637                        ..Default::default()
638                    }));
639                }
640                let attempt = observed.fetch_add(1, Ordering::SeqCst);
641                return Some(encode_v0(&InitProducerIdResponse {
642                    error_code: if attempt == 0 { 14 } else { 0 },
643                    producer_id: 7,
644                    producer_epoch: 3,
645                    ..Default::default()
646                }));
647            }
648            None
649        })
650        .await
651        .unwrap();
652        port_cell.store(mock.addr.port(), Ordering::SeqCst);
653
654        let producer = Producer::builder()
655            .bootstrap(mock.addr.to_string())
656            .transactional_id("test-txn")
657            .request_timeout(Duration::from_millis(100))
658            .retry_backoff(Duration::from_millis(1))
659            .init_retry_timeout(Duration::from_millis(100))
660            .retry_backoff_max(Duration::from_millis(1))
661            .build()
662            .await
663            .expect("producer connects");
664
665        producer
666            .init_transactions()
667            .await
668            .expect("cold coordinator retry succeeds");
669        drop(producer);
670        assert2::assert!(attempts.load(Ordering::SeqCst) == 2);
671        mock.stop();
672    }
673
674    async fn transactional_producer_with_end_txn_timeout(
675        end_txn_error: Arc<AtomicI16>,
676        end_txn_silent: Arc<AtomicBool>,
677    ) -> (MockBroker, Producer) {
678        transactional_producer_configured(end_txn_error, end_txn_silent, false).await
679    }
680
681    async fn transactional_producer_configured(
682        end_txn_error: Arc<AtomicI16>,
683        end_txn_silent: Arc<AtomicBool>,
684        two_phase_commit_enabled: bool,
685    ) -> (MockBroker, Producer) {
686        let port_cell = Arc::new(AtomicU16::new(0));
687        let handler_port = Arc::clone(&port_cell);
688        let next_epoch = Arc::new(AtomicI16::new(3));
689        let handler_epoch = Arc::clone(&next_epoch);
690        let mock = MockBroker::start(move |api_key, version, _corr_id, body| {
691            if api_key == api_versions_request::API_KEY {
692                return Some(encode_v0(&ApiVersionsResponse {
693                    api_keys: vec![ApiVersion {
694                        api_key: init_producer_id_request::API_KEY,
695                        min_version: 0,
696                        max_version: if two_phase_commit_enabled { 6 } else { 0 },
697                        ..Default::default()
698                    }],
699                    ..Default::default()
700                }));
701            }
702            if api_key == find_coordinator_request::API_KEY {
703                return Some(encode_v0(&FindCoordinatorResponse {
704                    error_code: 0,
705                    node_id: 1,
706                    host: "127.0.0.1".into(),
707                    port: i32::from(handler_port.load(Ordering::SeqCst)),
708                    ..Default::default()
709                }));
710            }
711            if api_key == init_producer_id_request::API_KEY {
712                let response = if is_transactional_init(body, version) {
713                    InitProducerIdResponse {
714                        error_code: 0,
715                        producer_id: 7,
716                        producer_epoch: handler_epoch.fetch_add(1, Ordering::SeqCst),
717                        ..Default::default()
718                    }
719                } else {
720                    InitProducerIdResponse {
721                        producer_id: 1,
722                        ..Default::default()
723                    }
724                };
725                let mut buf = BytesMut::new();
726                if version >= init_producer_id_response::FLEXIBLE_MIN {
727                    buf.extend_from_slice(&[0]);
728                }
729                response.encode(&mut buf, version).unwrap();
730                return Some(buf.to_vec());
731            }
732            if api_key == end_txn_request::API_KEY {
733                if end_txn_silent.load(Ordering::SeqCst) {
734                    return None;
735                }
736                return Some(encode_v0(&EndTxnResponse {
737                    error_code: end_txn_error.load(Ordering::SeqCst),
738                    ..Default::default()
739                }));
740            }
741            None
742        })
743        .await
744        .unwrap();
745        port_cell.store(mock.addr.port(), Ordering::SeqCst);
746
747        let producer = Producer::builder()
748            .bootstrap(mock.addr.to_string())
749            .transactional_id("test-txn")
750            .transaction_two_phase_commit_enable(two_phase_commit_enabled)
751            .request_timeout(Duration::from_millis(100))
752            .retry_backoff(Duration::from_millis(1))
753            .init_retry_timeout(Duration::from_millis(400))
754            .retry_backoff_max(Duration::from_millis(20))
755            .build()
756            .await
757            .expect("producer connects to the mock");
758        producer
759            .init_transactions()
760            .await
761            .expect("init_transactions against the mock coordinator");
762        (mock, producer)
763    }
764
765    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
766    async fn prepared_guard_can_drop_without_poisoning_the_next_transaction() {
767        let end_txn_error = Arc::new(AtomicI16::new(0));
768        let (mock, producer) = transactional_producer_configured(
769            end_txn_error,
770            Arc::new(AtomicBool::new(false)),
771            true,
772        )
773        .await;
774        let transaction = producer
775            .begin_transaction()
776            .await
777            .expect("begin transaction");
778        let prepared = transaction.prepare().await.expect("prepare transaction");
779
780        assert2::assert!(*producer.txn_state.lock().await == TxnState::Prepared);
781        drop(transaction);
782        assert2::assert!(!producer.txn_recovery_required.load(Ordering::Acquire));
783        producer
784            .complete_transaction(prepared)
785            .await
786            .expect("complete prepared transaction");
787        producer
788            .begin_transaction()
789            .await
790            .expect("begin next transaction")
791            .abort()
792            .await
793            .expect("abort next transaction");
794        mock.stop();
795    }
796
797    macro_rules! end_txn_retry_test {
798        ($name:ident, borrowed, $finish:ident) => {
799            #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
800            async fn $name() {
801                let end_txn_error = Arc::new(AtomicI16::new(51));
802                let (mock, producer) = transactional_producer(end_txn_error.clone()).await;
803                let txn = producer
804                    .begin_transaction()
805                    .await
806                    .expect("begin_transaction");
807
808                let EndTransactionError {
809                    transaction,
810                    source,
811                } = txn
812                    .$finish()
813                    .await
814                    .expect_err("broker reported CONCURRENT_TRANSACTIONS");
815                assert2::assert!(matches!(source, ProducerError::ConcurrentTransactions));
816
817                end_txn_error.store(0, Ordering::SeqCst);
818                transaction.$finish().await.expect("retry succeeds");
819                mock.stop();
820            }
821        };
822        ($name:ident, owned, $finish:ident) => {
823            #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
824            async fn $name() {
825                let end_txn_error = Arc::new(AtomicI16::new(51));
826                let (mock, producer) = transactional_producer(end_txn_error.clone()).await;
827                let producer = Arc::new(producer);
828                let txn = producer
829                    .clone()
830                    .begin_transaction_owned()
831                    .await
832                    .expect("begin_transaction_owned");
833
834                let EndTransactionError {
835                    transaction,
836                    source,
837                } = txn
838                    .$finish()
839                    .await
840                    .expect_err("broker reported CONCURRENT_TRANSACTIONS");
841                assert2::assert!(matches!(source, ProducerError::ConcurrentTransactions));
842
843                end_txn_error.store(0, Ordering::SeqCst);
844                transaction.$finish().await.expect("retry succeeds");
845                mock.stop();
846            }
847        };
848    }
849
850    // CONCURRENT_TRANSACTIONS (51) proves `commit`/`abort` drive the broker
851    // round trip, and the returned guard remains usable once the broker clears
852    // the condition.
853    end_txn_retry_test!(
854        transaction_commit_reports_broker_error_and_retries_on_the_same_guard,
855        borrowed,
856        commit
857    );
858    end_txn_retry_test!(
859        transaction_abort_reports_broker_error_and_retries_on_the_same_guard,
860        borrowed,
861        abort
862    );
863    end_txn_retry_test!(
864        owned_transaction_commit_reports_broker_error_and_retries_on_the_same_guard,
865        owned,
866        commit
867    );
868    end_txn_retry_test!(
869        owned_transaction_abort_reports_broker_error_and_retries_on_the_same_guard,
870        owned,
871        abort
872    );
873
874    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
875    async fn uncertain_end_txn_requires_reinitialization_before_reuse() {
876        let end_txn_error = Arc::new(AtomicI16::new(0));
877        let end_txn_silent = Arc::new(AtomicBool::new(true));
878        let (mock, producer) =
879            transactional_producer_with_end_txn_timeout(end_txn_error, Arc::clone(&end_txn_silent))
880                .await;
881
882        let EndTransactionError {
883            transaction,
884            source,
885        } = producer
886            .begin_transaction()
887            .await
888            .expect("begin transaction")
889            .commit()
890            .await
891            .expect_err(
892                "EndTxn that stays silent past the retry deadline has an uncertain outcome",
893            );
894        assert2::assert!(let ProducerError::RecoveryRequired = source);
895        drop(transaction);
896
897        let begin = producer.begin_transaction().await.err();
898        assert2::assert!(let Some(ProducerError::RecoveryRequired) = begin);
899        let acknowledgement = producer.send(ProducerRecord::default()).await;
900        assert2::assert!(
901            let Err(ProducerError::RecoveryRequired) =
902                acknowledgement
903        );
904
905        end_txn_silent.store(false, Ordering::SeqCst);
906        producer.next_seq.insert(("topic".to_owned(), 0), 5);
907        producer
908            .init_transactions()
909            .await
910            .expect("reinitialization obtains a new epoch");
911        assert2::assert!(*producer.txn_pid_epoch.lock().await == (7, 4));
912        // Kafka's `TransactionManager` starts the sequences again at a new epoch.
913        assert2::assert!(producer.next_seq.is_empty());
914        assert2::assert!(producer.transactional_identity().await == Some((7, 4)));
915        producer
916            .begin_transaction()
917            .await
918            .expect("new epoch permits a transaction")
919            .abort()
920            .await
921            .expect("abort after recovery");
922        mock.stop();
923    }
924
925    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
926    async fn dropping_an_open_transaction_requires_explicit_recovery() {
927        let end_txn_error = Arc::new(AtomicI16::new(0));
928        let (mock, producer) = transactional_producer(end_txn_error).await;
929        drop(
930            producer
931                .begin_transaction()
932                .await
933                .expect("begin transaction before drop"),
934        );
935
936        assert!(matches!(
937            producer.begin_transaction().await,
938            Err(ProducerError::RecoveryRequired)
939        ));
940        producer
941            .init_transactions()
942            .await
943            .expect("explicit initialization recovers dropped transaction");
944        producer
945            .begin_transaction()
946            .await
947            .expect("begin after recovery")
948            .abort()
949            .await
950            .expect("abort after recovery");
951        mock.stop();
952    }
953
954    /// One scripted answer from the mock coordinator.
955    #[derive(Debug, Clone, Copy)]
956    enum Reply {
957        /// Send no response, so the request times out in transport.
958        Silent,
959        /// Answer with this error code.
960        Code(i16),
961    }
962
963    /// The scripted answers for one API, and the number of requests it got.
964    #[derive(Debug)]
965    struct Script {
966        replies: VecDeque<Reply>,
967        exhausted: Reply,
968        requests: usize,
969    }
970
971    impl Default for Script {
972        fn default() -> Self {
973            Self::new(&[], Reply::Code(0))
974        }
975    }
976
977    impl Script {
978        fn new(replies: &[Reply], exhausted: Reply) -> Self {
979            Self {
980                replies: replies.iter().copied().collect(),
981                exhausted,
982                requests: 0,
983            }
984        }
985
986        fn next(&mut self) -> Reply {
987            self.requests += 1;
988            self.replies.pop_front().unwrap_or(self.exhausted)
989        }
990    }
991
992    /// The scripts of a mock transaction coordinator. The same mock is the
993    /// group coordinator and the partition leader.
994    #[derive(Debug, Default)]
995    struct Coordinator {
996        end_txn: Script,
997        add_partitions: Script,
998        add_offsets: Script,
999        txn_offset_commit: Script,
1000        produce: Script,
1001        /// The answers to `FindCoordinator` for the transactional id.
1002        txn_lookup: Script,
1003        /// The answers to `FindCoordinator` for a group.
1004        group_lookup: Script,
1005        find_coordinator_requests: usize,
1006        /// The number of transactional `InitProducerId` requests.
1007        init_producer_id_requests: usize,
1008        /// The number of `Metadata` requests.
1009        metadata_requests: usize,
1010        /// The negotiated version of each `AddPartitionsToTxn` request.
1011        add_partitions_versions: Vec<i16>,
1012        /// The `AddPartitionsToTxn` version range that the mock advertises.
1013        /// A Kafka broker advertises 0 to 5.
1014        add_partitions_range: Option<(i16, i16)>,
1015        /// Build the producer with `transaction_two_phase_commit_enable`. The
1016        /// mock then advertises `InitProducerId` v6.
1017        two_phase_commit: bool,
1018        /// The linger of the producer. Zero when `None`.
1019        linger: Option<Duration>,
1020        /// The finalized `transaction.version` that the mock reports. With a
1021        /// value, the mock also advertises `AddOffsetsToTxn` v0 to v4, `EndTxn`
1022        /// v0 to v5 and `TxnOffsetCommit` up to `txn_offset_commit_max`, as a
1023        /// Kafka 4 broker does.
1024        transaction_version: Option<i16>,
1025        /// The API key and version of each request other than `ApiVersions`,
1026        /// `Metadata`, `FindCoordinator` and `InitProducerId`.
1027        transaction_requests: Vec<(i16, i16)>,
1028        /// The highest `TxnOffsetCommit` version that the mock advertises
1029        /// with a transaction version. v5 when `None`, as Kafka 4.3 does.
1030        txn_offset_commit_max: Option<i16>,
1031        /// The topic id that the mock metadata gives the topic `topic`.
1032        topic_id: Uuid,
1033        /// The negotiated version and the decoded topics of each
1034        /// `TxnOffsetCommit` request.
1035        txn_offset_commit_topics: Vec<(i16, Vec<TxnOffsetCommitRequestTopic>)>,
1036    }
1037
1038    type SharedCoordinator = Arc<Mutex<Coordinator>>;
1039
1040    /// How a coordinator request ended, in a form that tests can compare.
1041    #[derive(Debug, PartialEq, Eq)]
1042    enum TxnResult {
1043        Ok,
1044        Fenced,
1045        ConcurrentTransactions,
1046        Server(i16),
1047        /// The producer is in the fatal error state after this code.
1048        Fatal(i16),
1049        OutcomeUnknown,
1050        /// The broker supports no version that the client sends. The fields
1051        /// are the broker range and the client range.
1052        IncompatibleVersion((i16, i16), (i16, i16)),
1053        Other(String),
1054    }
1055
1056    impl From<Result<(), ProducerError>> for TxnResult {
1057        fn from(result: Result<(), ProducerError>) -> Self {
1058            match result {
1059                Ok(()) => Self::Ok,
1060                Err(ProducerError::FencedProducer) => Self::Fenced,
1061                Err(ProducerError::ConcurrentTransactions) => Self::ConcurrentTransactions,
1062                Err(ProducerError::Server(code)) => Self::Server(code),
1063                Err(ProducerError::FatalTransactionError(code)) => Self::Fatal(code),
1064                Err(ProducerError::RecoveryRequired) => Self::OutcomeUnknown,
1065                Err(ProducerError::Client(ClientError::IncompatibleVersion {
1066                    broker_min,
1067                    broker_max,
1068                    client_min,
1069                    client_max,
1070                    ..
1071                })) => {
1072                    Self::IncompatibleVersion((broker_min, broker_max), (client_min, client_max))
1073                }
1074                Err(other) => Self::Other(other.to_string()),
1075            }
1076        }
1077    }
1078
1079    /// Encode `response` at `version`, after the tagged-fields byte of
1080    /// response header v1 for a flexible version.
1081    fn encode_at(response: &impl Encode, version: i16, flexible_min: i16) -> Vec<u8> {
1082        let mut buf = BytesMut::new();
1083        if version >= flexible_min {
1084            buf.extend_from_slice(&[0]);
1085        }
1086        response.encode(&mut buf, version).expect("encode response");
1087        buf.to_vec()
1088    }
1089
1090    /// The `ApiVersions` answer of the scripted mock coordinator, encoded at
1091    /// the request `version` (response header v0 for every version).
1092    fn scripted_api_versions(
1093        add_partitions_range: (i16, i16),
1094        two_phase_commit: bool,
1095        transaction_version: Option<i16>,
1096        txn_offset_commit_max: i16,
1097        version: i16,
1098    ) -> Vec<u8> {
1099        use krabka_protocol::owned::api_versions_response::FinalizedFeatureKey;
1100        let range = |api_key, max_version| ApiVersion {
1101            api_key,
1102            min_version: 0,
1103            max_version,
1104            ..Default::default()
1105        };
1106        let mut api_keys = vec![
1107            ApiVersion {
1108                api_key: add_partitions_to_txn_request::API_KEY,
1109                min_version: add_partitions_range.0,
1110                max_version: add_partitions_range.1,
1111                ..Default::default()
1112            },
1113            range(metadata_request::API_KEY, 12),
1114            ApiVersion {
1115                api_key: produce_request::API_KEY,
1116                min_version: 3,
1117                max_version: 13,
1118                ..Default::default()
1119            },
1120            range(
1121                init_producer_id_request::API_KEY,
1122                if two_phase_commit { 6 } else { 0 },
1123            ),
1124        ];
1125        let mut finalized_features = Vec::new();
1126        if let Some(level) = transaction_version {
1127            api_keys.extend([
1128                range(add_offsets_to_txn_request::API_KEY, 4),
1129                range(end_txn_request::API_KEY, 5),
1130                range(txn_offset_commit_request::API_KEY, txn_offset_commit_max),
1131            ]);
1132            finalized_features.push(FinalizedFeatureKey {
1133                name: "transaction.version".into(),
1134                max_version_level: level,
1135                min_version_level: level,
1136                ..Default::default()
1137            });
1138        }
1139        let mut buf = BytesMut::new();
1140        ApiVersionsResponse {
1141            api_keys,
1142            finalized_features_epoch: if transaction_version.is_some() { 1 } else { -1 },
1143            finalized_features,
1144            ..Default::default()
1145        }
1146        .encode(&mut buf, version.min(5))
1147        .expect("encode ApiVersions");
1148        buf.to_vec()
1149    }
1150
1151    /// The `Metadata` answer of the scripted mock coordinator: the topic
1152    /// `topic` with one partition that the mock leads.
1153    ///
1154    /// The producer waits for metadata that holds the topic, so the answer
1155    /// must decode at the negotiated version.
1156    fn scripted_metadata(port: u16, topic_id: Uuid, version: i16) -> Vec<u8> {
1157        let response = MetadataResponse {
1158            brokers: vec![MetadataResponseBroker {
1159                node_id: 1,
1160                host: "127.0.0.1".into(),
1161                port: i32::from(port),
1162                ..Default::default()
1163            }],
1164            topics: vec![MetadataResponseTopic {
1165                name: Some("topic".into()),
1166                topic_id,
1167                partitions: vec![MetadataResponsePartition {
1168                    partition_index: 0,
1169                    leader_id: 1,
1170                    ..Default::default()
1171                }],
1172                ..Default::default()
1173            }],
1174            ..Default::default()
1175        };
1176        let mut buf = BytesMut::new();
1177        if version >= metadata_response::FLEXIBLE_MIN {
1178            buf.extend_from_slice(&[0]);
1179        }
1180        response.encode(&mut buf, version).expect("encode Metadata");
1181        buf.to_vec()
1182    }
1183
1184    /// Record one `TxnOffsetCommit` request, and answer it from the script.
1185    /// The answer names each topic as the request did, and gives each
1186    /// partition the scripted code.
1187    fn scripted_txn_offset_commit(
1188        coordinator: &mut Coordinator,
1189        body: &[u8],
1190        version: i16,
1191    ) -> Option<Vec<u8>> {
1192        let request = decode_request::<TxnOffsetCommitRequest>(
1193            body,
1194            version,
1195            txn_offset_commit_request::FLEXIBLE_MIN,
1196        );
1197        coordinator
1198            .txn_offset_commit_topics
1199            .push((version, request.topics.clone()));
1200        let Reply::Code(error_code) = coordinator.txn_offset_commit.next() else {
1201            return None;
1202        };
1203        let topics = request
1204            .topics
1205            .iter()
1206            .map(|topic| TxnOffsetCommitResponseTopic {
1207                name: topic.name.clone(),
1208                topic_id: topic.topic_id,
1209                partitions: topic
1210                    .partitions
1211                    .iter()
1212                    .map(|partition| TxnOffsetCommitResponsePartition {
1213                        partition_index: partition.partition_index,
1214                        error_code,
1215                        ..Default::default()
1216                    })
1217                    .collect(),
1218                ..Default::default()
1219            })
1220            .collect();
1221        Some(encode_at(
1222            &TxnOffsetCommitResponse {
1223                topics,
1224                ..Default::default()
1225            },
1226            version,
1227            txn_offset_commit_response::FLEXIBLE_MIN,
1228        ))
1229    }
1230
1231    /// Boot a mock coordinator that answers `EndTxn` and `AddPartitionsToTxn`
1232    /// from `coordinator`, and connect a transactional producer to it without
1233    /// calling `init_transactions`. The producer is left `Uninitialized`.
1234    async fn scripted_producer_raw(
1235        coordinator: Coordinator,
1236    ) -> (MockBroker, Producer, SharedCoordinator) {
1237        let port_cell = Arc::new(AtomicU16::new(0));
1238        let handler_port = Arc::clone(&port_cell);
1239        let shared = Arc::new(Mutex::new(coordinator));
1240        let handler_shared = Arc::clone(&shared);
1241        let (
1242            add_partitions_range,
1243            two_phase_commit,
1244            transaction_version,
1245            linger,
1246            txn_offset_commit_max,
1247            topic_id,
1248        ) = {
1249            let coordinator = shared.lock().expect("scripted coordinator");
1250            (
1251                coordinator.add_partitions_range.unwrap_or((0, 5)),
1252                coordinator.two_phase_commit,
1253                coordinator.transaction_version,
1254                coordinator.linger.unwrap_or(Duration::ZERO),
1255                coordinator.txn_offset_commit_max.unwrap_or(5),
1256                coordinator.topic_id,
1257            )
1258        };
1259        let mock = MockBroker::start(move |api_key, version, _corr_id, body| {
1260            if api_key == api_versions_request::API_KEY {
1261                return Some(scripted_api_versions(
1262                    add_partitions_range,
1263                    two_phase_commit,
1264                    transaction_version,
1265                    txn_offset_commit_max,
1266                    version,
1267                ));
1268            }
1269            let mut coordinator = handler_shared.lock().expect("scripted coordinator");
1270            if ![
1271                metadata_request::API_KEY,
1272                find_coordinator_request::API_KEY,
1273                init_producer_id_request::API_KEY,
1274            ]
1275            .contains(&api_key)
1276            {
1277                coordinator.transaction_requests.push((api_key, version));
1278            }
1279            if api_key == metadata_request::API_KEY {
1280                coordinator.metadata_requests += 1;
1281                return Some(scripted_metadata(
1282                    handler_port.load(Ordering::SeqCst),
1283                    topic_id,
1284                    version,
1285                ));
1286            }
1287            if api_key == produce_request::API_KEY {
1288                let Reply::Code(error_code) = coordinator.produce.next() else {
1289                    return None;
1290                };
1291                let response = ProduceResponse {
1292                    responses: vec![TopicProduceResponse {
1293                        name: "topic".into(),
1294                        partition_responses: vec![PartitionProduceResponse {
1295                            index: 0,
1296                            error_code,
1297                            base_offset: 0,
1298                            log_start_offset: -1,
1299                            ..Default::default()
1300                        }],
1301                        ..Default::default()
1302                    }],
1303                    ..Default::default()
1304                };
1305                let mut buf = BytesMut::new();
1306                if version >= produce_response::FLEXIBLE_MIN {
1307                    buf.extend_from_slice(&[0]);
1308                }
1309                response.encode(&mut buf, version).expect("encode Produce");
1310                return Some(buf.to_vec());
1311            }
1312            if api_key == add_offsets_to_txn_request::API_KEY {
1313                return match coordinator.add_offsets.next() {
1314                    Reply::Silent => None,
1315                    Reply::Code(error_code) => Some(encode_at(
1316                        &AddOffsetsToTxnResponse {
1317                            error_code,
1318                            ..Default::default()
1319                        },
1320                        version,
1321                        add_offsets_to_txn_response::FLEXIBLE_MIN,
1322                    )),
1323                };
1324            }
1325            if api_key == txn_offset_commit_request::API_KEY {
1326                return scripted_txn_offset_commit(&mut coordinator, body, version);
1327            }
1328            if api_key == find_coordinator_request::API_KEY {
1329                coordinator.find_coordinator_requests += 1;
1330                let request: FindCoordinatorRequest =
1331                    decode_request(body, version, find_coordinator_request::FLEXIBLE_MIN);
1332                let script = if request.key == "test-txn" {
1333                    &mut coordinator.txn_lookup
1334                } else {
1335                    &mut coordinator.group_lookup
1336                };
1337                let Reply::Code(error_code) = script.next() else {
1338                    return None;
1339                };
1340                return Some(encode_v0(&FindCoordinatorResponse {
1341                    error_code,
1342                    node_id: 1,
1343                    host: "127.0.0.1".into(),
1344                    port: i32::from(handler_port.load(Ordering::SeqCst)),
1345                    ..Default::default()
1346                }));
1347            }
1348            if api_key == init_producer_id_request::API_KEY {
1349                if is_transactional_init(body, version) {
1350                    coordinator.init_producer_id_requests += 1;
1351                }
1352                let mut buf = BytesMut::new();
1353                if version >= init_producer_id_response::FLEXIBLE_MIN {
1354                    buf.extend_from_slice(&[0]);
1355                }
1356                InitProducerIdResponse {
1357                    error_code: 0,
1358                    producer_id: 7,
1359                    producer_epoch: 3,
1360                    ..Default::default()
1361                }
1362                .encode(&mut buf, version)
1363                .expect("encode InitProducerId");
1364                return Some(buf.to_vec());
1365            }
1366            if api_key == end_txn_request::API_KEY {
1367                return match coordinator.end_txn.next() {
1368                    Reply::Silent => None,
1369                    Reply::Code(error_code) => Some(encode_at(
1370                        &EndTxnResponse {
1371                            error_code,
1372                            ..Default::default()
1373                        },
1374                        version,
1375                        end_txn_response::FLEXIBLE_MIN,
1376                    )),
1377                };
1378            }
1379            if api_key != add_partitions_to_txn_request::API_KEY {
1380                return None;
1381            }
1382            coordinator.add_partitions_versions.push(version);
1383            match coordinator.add_partitions.next() {
1384                Reply::Silent => None,
1385                Reply::Code(partition_error_code) => {
1386                    let response = AddPartitionsToTxnResponse {
1387                        results_by_topic_v3_and_below: vec![AddPartitionsToTxnTopicResult {
1388                            name: "topic".into(),
1389                            results_by_partition: vec![AddPartitionsToTxnPartitionResult {
1390                                partition_index: 0,
1391                                partition_error_code,
1392                                ..Default::default()
1393                            }],
1394                            ..Default::default()
1395                        }],
1396                        ..Default::default()
1397                    };
1398                    let mut buf = BytesMut::new();
1399                    if version >= add_partitions_to_txn_response::FLEXIBLE_MIN {
1400                        buf.extend_from_slice(&[0]);
1401                    }
1402                    response
1403                        .encode(&mut buf, version)
1404                        .expect("encode AddPartitionsToTxn");
1405                    Some(buf.to_vec())
1406                }
1407            }
1408        })
1409        .await
1410        .unwrap();
1411        port_cell.store(mock.addr.port(), Ordering::SeqCst);
1412        let producer = Producer::builder()
1413            .bootstrap(mock.addr.to_string())
1414            .transactional_id("test-txn")
1415            .transaction_two_phase_commit_enable(two_phase_commit)
1416            .linger(linger)
1417            .request_timeout(Duration::from_millis(100))
1418            .retry_backoff(Duration::from_millis(1))
1419            .init_retry_timeout(Duration::from_millis(1500))
1420            .retry_backoff_max(Duration::from_millis(20))
1421            .build()
1422            .await
1423            .expect("producer connects to the mock");
1424        (mock, producer, shared)
1425    }
1426
1427    /// [`scripted_producer_raw`], then `init_transactions` against the mock
1428    /// coordinator, leaving the producer `Ready`.
1429    async fn scripted_producer(
1430        coordinator: Coordinator,
1431    ) -> (MockBroker, Producer, SharedCoordinator) {
1432        let (mock, producer, shared) = scripted_producer_raw(coordinator).await;
1433        producer
1434            .init_transactions()
1435            .await
1436            .expect("init_transactions against the mock coordinator");
1437        {
1438            let mut coordinator = shared.lock().expect("scripted coordinator");
1439            coordinator.find_coordinator_requests = 0;
1440            coordinator.init_producer_id_requests = 0;
1441        }
1442        (mock, producer, shared)
1443    }
1444
1445    /// Boot a mock coordinator that answers `EndTxn` from `script`, and then
1446    /// with `exhausted` when the script is empty.
1447    async fn scripted_end_txn_producer(
1448        script: &[Reply],
1449        exhausted: Reply,
1450    ) -> (MockBroker, Producer, SharedCoordinator) {
1451        scripted_producer(Coordinator {
1452            end_txn: Script::new(script, exhausted),
1453            ..Coordinator::default()
1454        })
1455        .await
1456    }
1457
1458    /// The observable result of one scripted commit.
1459    #[derive(Debug, PartialEq, Eq)]
1460    struct ScriptedCommit {
1461        result: TxnResult,
1462        end_txn_requests: usize,
1463        state: TxnState,
1464    }
1465
1466    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1467    async fn commit_learns_the_outcome_of_a_lost_end_txn() {
1468        use Reply::{Code, Silent};
1469        use TxnResult::{Fenced, OutcomeUnknown};
1470        let cases = [
1471            ("answered", vec![Code(0)], TxnResult::Ok, 1, TxnState::Ready),
1472            (
1473                "lost then committed",
1474                vec![Silent, Code(0)],
1475                TxnResult::Ok,
1476                2,
1477                TxnState::Ready,
1478            ),
1479            (
1480                "lost, prepare in progress, then committed",
1481                vec![Silent, Code(51), Code(51), Code(0)],
1482                TxnResult::Ok,
1483                4,
1484                TxnState::Ready,
1485            ),
1486            (
1487                "lost, coordinator loading, then committed",
1488                vec![Silent, Code(14), Code(15), Code(16), Code(0)],
1489                TxnResult::Ok,
1490                5,
1491                TxnState::Ready,
1492            ),
1493            (
1494                "loading then committed",
1495                vec![Code(14), Code(0)],
1496                TxnResult::Ok,
1497                2,
1498                TxnState::Ready,
1499            ),
1500            (
1501                "concurrent without a loss, then committed",
1502                vec![Code(51), Code(0)],
1503                TxnResult::Ok,
1504                2,
1505                TxnState::Ready,
1506            ),
1507            (
1508                "request timed out, then committed",
1509                vec![Code(7), Code(0)],
1510                TxnResult::Ok,
1511                2,
1512                TxnState::Ready,
1513            ),
1514            ("fenced", vec![Code(47)], Fenced, 1, TxnState::Fenced),
1515            (
1516                "producer fenced",
1517                vec![Code(90)],
1518                Fenced,
1519                1,
1520                TxnState::Fenced,
1521            ),
1522            (
1523                "invalid state is fatal",
1524                vec![Code(48)],
1525                TxnResult::Fatal(48),
1526                1,
1527                TxnState::FatalError,
1528            ),
1529            (
1530                "lost then fenced",
1531                vec![Silent, Code(47)],
1532                OutcomeUnknown,
1533                2,
1534                TxnState::RecoveryRequired,
1535            ),
1536            (
1537                "lost then producer fenced",
1538                vec![Silent, Code(90)],
1539                OutcomeUnknown,
1540                2,
1541                TxnState::RecoveryRequired,
1542            ),
1543            (
1544                "lost then invalid state",
1545                vec![Silent, Code(48)],
1546                OutcomeUnknown,
1547                2,
1548                TxnState::RecoveryRequired,
1549            ),
1550        ];
1551        for (name, script, result, end_txn_requests, state) in cases {
1552            let (mock, producer, coordinator) = scripted_end_txn_producer(&script, Code(0)).await;
1553            let outcome = producer
1554                .begin_transaction()
1555                .await
1556                .expect("begin transaction")
1557                .commit()
1558                .await
1559                .map_err(|error| error.source);
1560            let state_after = *producer.txn_state.lock().await;
1561            let actual = ScriptedCommit {
1562                result: outcome.into(),
1563                end_txn_requests: coordinator
1564                    .lock()
1565                    .expect("scripted coordinator")
1566                    .end_txn
1567                    .requests,
1568                state: state_after,
1569            };
1570            let expected = ScriptedCommit {
1571                result,
1572                end_txn_requests,
1573                state,
1574            };
1575            assert2::assert!(actual == expected, "{name}");
1576            mock.stop();
1577        }
1578    }
1579
1580    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1581    async fn committed_after_a_lost_end_txn_permits_the_next_transaction() {
1582        let (mock, producer, coordinator) =
1583            scripted_end_txn_producer(&[Reply::Silent], Reply::Code(0)).await;
1584        producer
1585            .begin_transaction()
1586            .await
1587            .expect("begin first transaction")
1588            .commit()
1589            .await
1590            .expect("retry learns that the first transaction committed");
1591        producer
1592            .begin_transaction()
1593            .await
1594            .expect("no reinitialization is needed after a learned outcome")
1595            .commit()
1596            .await
1597            .expect("second transaction commits");
1598        assert2::assert!(
1599            coordinator
1600                .lock()
1601                .expect("scripted coordinator")
1602                .end_txn
1603                .requests
1604                == 3
1605        );
1606        mock.stop();
1607    }
1608
1609    /// The observable result of a coordinator request that retried until the
1610    /// deadline.
1611    #[derive(Debug, PartialEq, Eq)]
1612    struct DeadlineResult {
1613        result: TxnResult,
1614        retried: bool,
1615        state: TxnState,
1616    }
1617
1618    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1619    async fn end_txn_that_stays_unanswered_reports_the_last_answer_at_the_deadline() {
1620        use Reply::{Code, Silent};
1621        let cases = [
1622            (
1623                "stays lost",
1624                Silent,
1625                TxnResult::OutcomeUnknown,
1626                TxnState::RecoveryRequired,
1627            ),
1628            (
1629                "stays concurrent",
1630                Code(51),
1631                TxnResult::ConcurrentTransactions,
1632                TxnState::InTransaction,
1633            ),
1634            (
1635                "stays loading",
1636                Code(14),
1637                TxnResult::Server(14),
1638                TxnState::InTransaction,
1639            ),
1640        ];
1641        for (name, exhausted, result, state) in cases {
1642            let (mock, producer, coordinator) = scripted_end_txn_producer(&[], exhausted).await;
1643            let outcome = producer
1644                .begin_transaction()
1645                .await
1646                .expect("begin transaction")
1647                .commit()
1648                .await
1649                .map_err(|error| error.source);
1650            let state_after = *producer.txn_state.lock().await;
1651            let end_txn_requests = coordinator
1652                .lock()
1653                .expect("scripted coordinator")
1654                .end_txn
1655                .requests;
1656            let actual = DeadlineResult {
1657                result: outcome.into(),
1658                retried: end_txn_requests > 1,
1659                state: state_after,
1660            };
1661            let expected = DeadlineResult {
1662                result,
1663                retried: true,
1664                state,
1665            };
1666            assert2::assert!(actual == expected, "{name}");
1667            mock.stop();
1668        }
1669    }
1670
1671    /// The observable result of one scripted `AddPartitionsToTxn`.
1672    #[derive(Debug, PartialEq, Eq)]
1673    struct ScriptedAddPartitions {
1674        result: TxnResult,
1675        add_partitions_requests: usize,
1676        coordinator_lookups: usize,
1677    }
1678
1679    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1680    async fn add_partitions_retries_the_codes_that_kafka_retries() {
1681        use Reply::{Code, Silent};
1682        use TxnResult::{Fenced, Server};
1683        let cases = [
1684            ("added", vec![Code(0)], TxnResult::Ok, 1, 0),
1685            (
1686                "concurrent, then added",
1687                vec![Code(51), Code(0)],
1688                TxnResult::Ok,
1689                2,
1690                0,
1691            ),
1692            (
1693                "loading, then added",
1694                vec![Code(14), Code(0)],
1695                TxnResult::Ok,
1696                2,
1697                0,
1698            ),
1699            (
1700                "moved, then added",
1701                vec![Code(16), Code(0)],
1702                TxnResult::Ok,
1703                2,
1704                1,
1705            ),
1706            (
1707                "lost, then added",
1708                vec![Silent, Code(0)],
1709                TxnResult::Ok,
1710                2,
1711                1,
1712            ),
1713            ("fenced", vec![Code(47)], Fenced, 1, 0),
1714            ("producer fenced", vec![Code(90)], Fenced, 1, 0),
1715            ("topic authorization", vec![Code(29)], Server(29), 1, 0),
1716            ("operation not attempted", vec![Code(55)], Server(55), 1, 0),
1717            ("invalid state", vec![Code(48)], TxnResult::Fatal(48), 1, 0),
1718            (
1719                "transactional id authorization",
1720                vec![Code(53)],
1721                TxnResult::Fatal(53),
1722                1,
1723                0,
1724            ),
1725        ];
1726        for (name, script, result, add_partitions_requests, coordinator_lookups) in cases {
1727            let (mock, producer, coordinator) = scripted_producer(Coordinator {
1728                add_partitions: Script::new(&script, Code(0)),
1729                ..Coordinator::default()
1730            })
1731            .await;
1732            let outcome = producer.register_transaction_partition("topic", 0).await;
1733            let actual = {
1734                let coordinator = coordinator.lock().expect("scripted coordinator");
1735                ScriptedAddPartitions {
1736                    result: outcome.into(),
1737                    add_partitions_requests: coordinator.add_partitions.requests,
1738                    coordinator_lookups: coordinator.find_coordinator_requests,
1739                }
1740            };
1741            let expected = ScriptedAddPartitions {
1742                result,
1743                add_partitions_requests,
1744                coordinator_lookups,
1745            };
1746            assert2::assert!(actual == expected, "{name}");
1747            mock.stop();
1748        }
1749    }
1750
1751    /// The observable result of one scripted `AddPartitionsToTxn` version
1752    /// negotiation.
1753    #[derive(Debug, PartialEq, Eq)]
1754    struct ScriptedAddPartitionsVersion {
1755        result: TxnResult,
1756        versions: Vec<i16>,
1757    }
1758
1759    /// Apache Kafka's client builds `AddPartitionsToTxn` with
1760    /// `AddPartitionsToTxnRequest.Builder.forClient`, which allows v3 at most.
1761    /// v4 and later carry the broker-to-broker form, which a broker answers
1762    /// only for a principal with `CLUSTER_ACTION`.
1763    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1764    async fn add_partitions_to_txn_stops_at_the_last_client_version() {
1765        let cases = [
1766            ("broker stops at v3", (0, 3), TxnResult::Ok, vec![3]),
1767            ("broker supports v5", (0, 5), TxnResult::Ok, vec![3]),
1768            (
1769                "broker supports only the broker versions",
1770                (4, 5),
1771                TxnResult::IncompatibleVersion((4, 5), (0, 3)),
1772                Vec::new(),
1773            ),
1774        ];
1775        for (name, range, result, versions) in cases {
1776            let (mock, producer, coordinator) = scripted_producer(Coordinator {
1777                add_partitions_range: Some(range),
1778                ..Coordinator::default()
1779            })
1780            .await;
1781            let outcome = producer.register_transaction_partition("topic", 0).await;
1782            let actual = {
1783                let coordinator = coordinator.lock().expect("scripted coordinator");
1784                ScriptedAddPartitionsVersion {
1785                    result: outcome.into(),
1786                    versions: coordinator.add_partitions_versions.clone(),
1787                }
1788            };
1789            assert2::assert!(
1790                actual == ScriptedAddPartitionsVersion { result, versions },
1791                "{name}"
1792            );
1793            mock.stop();
1794        }
1795    }
1796
1797    /// The observable result of one scripted `send_offsets_to_transaction`.
1798    #[derive(Debug, PartialEq, Eq)]
1799    struct ScriptedSendOffsets {
1800        result: TxnResult,
1801        add_offsets_requests: usize,
1802        txn_offset_commit_requests: usize,
1803        coordinator_lookups: usize,
1804        abortable_error: Option<i16>,
1805        state: TxnState,
1806    }
1807
1808    /// Kafka's `AddOffsetsToTxnHandler` and `TxnOffsetCommitHandler` send the
1809    /// request again after a transport loss and after every retriable code,
1810    /// and they find the coordinator again for 15, 16 and, for
1811    /// `TxnOffsetCommit`, `REQUEST_TIMED_OUT`. An abortable code stops the
1812    /// transaction from committing, and 47 and 90 fence the producer.
1813    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1814    async fn send_offsets_to_transaction_retries_the_codes_that_kafka_retries() {
1815        use Reply::{Code, Silent};
1816        use TxnResult::{Fenced, Server};
1817        let group = krabka_client_consumer::ConsumerGroupMetadata {
1818            group_id: "group-a".into(),
1819            generation_id: 3,
1820            member_id: "member-a".into(),
1821            group_instance_id: None,
1822        };
1823        // (name, add offsets script, offset commit script, result, add offsets
1824        // requests, offset commit requests, coordinator lookups, abortable,
1825        // transaction state)
1826        let cases = [
1827            (
1828                "committed",
1829                vec![Code(0)],
1830                vec![Code(0)],
1831                TxnResult::Ok,
1832                1,
1833                1,
1834                1,
1835                None,
1836                TxnState::InTransaction,
1837            ),
1838            (
1839                "add offsets loading, then committed",
1840                vec![Code(14), Code(0)],
1841                vec![Code(0)],
1842                TxnResult::Ok,
1843                2,
1844                1,
1845                1,
1846                None,
1847                TxnState::InTransaction,
1848            ),
1849            (
1850                "add offsets moved, then committed",
1851                vec![Code(16), Code(0)],
1852                vec![Code(0)],
1853                TxnResult::Ok,
1854                2,
1855                1,
1856                2,
1857                None,
1858                TxnState::InTransaction,
1859            ),
1860            (
1861                "add offsets lost, then committed",
1862                vec![Silent, Code(0)],
1863                vec![Code(0)],
1864                TxnResult::Ok,
1865                2,
1866                1,
1867                2,
1868                None,
1869                TxnState::InTransaction,
1870            ),
1871            (
1872                "add offsets fenced",
1873                vec![Code(90)],
1874                vec![Code(0)],
1875                Fenced,
1876                1,
1877                0,
1878                0,
1879                None,
1880                TxnState::Fenced,
1881            ),
1882            (
1883                "add offsets abortable",
1884                vec![Code(120)],
1885                vec![Code(0)],
1886                Server(120),
1887                1,
1888                0,
1889                0,
1890                Some(120),
1891                TxnState::InTransaction,
1892            ),
1893            (
1894                "add offsets invalid state is fatal",
1895                vec![Code(48)],
1896                vec![Code(0)],
1897                TxnResult::Fatal(48),
1898                1,
1899                0,
1900                0,
1901                None,
1902                TxnState::FatalError,
1903            ),
1904            (
1905                "offset commit unknown topic, then committed",
1906                vec![Code(0)],
1907                vec![Code(3), Code(0)],
1908                TxnResult::Ok,
1909                1,
1910                2,
1911                1,
1912                None,
1913                TxnState::InTransaction,
1914            ),
1915            (
1916                "offset commit timed out, then committed",
1917                vec![Code(0)],
1918                vec![Code(7), Code(0)],
1919                TxnResult::Ok,
1920                1,
1921                2,
1922                2,
1923                None,
1924                TxnState::InTransaction,
1925            ),
1926            (
1927                "offset commit moved, then committed",
1928                vec![Code(0)],
1929                vec![Code(16), Code(0)],
1930                TxnResult::Ok,
1931                1,
1932                2,
1933                2,
1934                None,
1935                TxnState::InTransaction,
1936            ),
1937            (
1938                "offset commit group metadata mismatch",
1939                vec![Code(0)],
1940                vec![Code(22)],
1941                Server(22),
1942                1,
1943                1,
1944                1,
1945                Some(22),
1946                TxnState::InTransaction,
1947            ),
1948            (
1949                "offset commit fenced",
1950                vec![Code(0)],
1951                vec![Code(47)],
1952                Fenced,
1953                1,
1954                1,
1955                1,
1956                None,
1957                TxnState::Fenced,
1958            ),
1959        ];
1960        for (
1961            name,
1962            add_offsets,
1963            txn_offset_commit,
1964            result,
1965            add_offsets_requests,
1966            txn_offset_commit_requests,
1967            coordinator_lookups,
1968            abortable_error,
1969            expected_state,
1970        ) in cases
1971        {
1972            let (mock, producer, coordinator) = scripted_producer(Coordinator {
1973                add_offsets: Script::new(&add_offsets, Code(0)),
1974                txn_offset_commit: Script::new(&txn_offset_commit, Code(0)),
1975                ..Coordinator::default()
1976            })
1977            .await;
1978            let transaction = producer
1979                .begin_transaction()
1980                .await
1981                .expect("begin transaction");
1982            let outcome = producer
1983                .send_offsets_to_transaction([(("topic".to_owned(), 0), 42)], &group)
1984                .await;
1985            let state = *producer.txn_state.lock().await;
1986            drop(transaction);
1987            let actual = {
1988                let coordinator = coordinator.lock().expect("scripted coordinator");
1989                ScriptedSendOffsets {
1990                    result: outcome.into(),
1991                    add_offsets_requests: coordinator.add_offsets.requests,
1992                    txn_offset_commit_requests: coordinator.txn_offset_commit.requests,
1993                    coordinator_lookups: coordinator.find_coordinator_requests,
1994                    abortable_error: producer.txn_error.abortable().map(|error| match error {
1995                        AbortableError::Server(code) => code,
1996                        AbortableError::Timeout => i16::MIN,
1997                    }),
1998                    state,
1999                }
2000            };
2001            let expected = ScriptedSendOffsets {
2002                result,
2003                add_offsets_requests,
2004                txn_offset_commit_requests,
2005                coordinator_lookups,
2006                abortable_error,
2007                state: expected_state,
2008            };
2009            assert2::assert!(actual == expected, "{name}");
2010            mock.stop();
2011        }
2012    }
2013
2014    /// The number of `FindCoordinator` requests, where `MANY_LOOKUPS` stands
2015    /// for that number or more.
2016    fn lookups(coordinator: &Coordinator) -> usize {
2017        coordinator.find_coordinator_requests.min(MANY_LOOKUPS)
2018    }
2019
2020    /// More lookups than a script names: the producer retried until the
2021    /// deadline.
2022    const MANY_LOOKUPS: usize = 5;
2023
2024    /// The observable result of one scripted `init_transactions`.
2025    #[derive(Debug, PartialEq, Eq)]
2026    struct ScriptedInit {
2027        result: TxnResult,
2028        coordinator_lookups: usize,
2029        init_producer_id_requests: usize,
2030        state: TxnState,
2031    }
2032
2033    /// Kafka's `FindCoordinatorHandler` sends the lookup again after a
2034    /// disconnect and after every retriable code, so `initTransactions`
2035    /// blocks while a new broker creates `__transaction_state`. The handler
2036    /// makes `TRANSACTIONAL_ID_AUTHORIZATION_FAILED` and an unexpected code
2037    /// fatal, and `TRANSACTION_ABORTABLE` abortable. A lookup that answers a
2038    /// retriable code until the deadline reports that code.
2039    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2040    async fn init_transactions_retries_the_lookups_that_kafka_retries() {
2041        use Reply::{Code, Silent};
2042        use TxnResult::{Fatal, Server};
2043        let cases = [
2044            ("found", vec![Code(0)], TxnResult::Ok, 1, 1, TxnState::Ready),
2045            (
2046                "unavailable twice, then found",
2047                vec![Code(15), Code(15), Code(0)],
2048                TxnResult::Ok,
2049                3,
2050                1,
2051                TxnState::Ready,
2052            ),
2053            (
2054                "loading, moved, then found",
2055                vec![Code(14), Code(16), Code(0)],
2056                TxnResult::Ok,
2057                3,
2058                1,
2059                TxnState::Ready,
2060            ),
2061            (
2062                "lost, then found",
2063                vec![Silent, Code(0)],
2064                TxnResult::Ok,
2065                2,
2066                1,
2067                TxnState::Ready,
2068            ),
2069            (
2070                "unavailable until the deadline",
2071                vec![Code(15); MANY_LOOKUPS],
2072                Server(15),
2073                MANY_LOOKUPS,
2074                0,
2075                TxnState::Uninitialized,
2076            ),
2077            (
2078                "transactional id authorization is fatal",
2079                vec![Code(53)],
2080                Fatal(53),
2081                1,
2082                0,
2083                TxnState::FatalError,
2084            ),
2085            (
2086                "an unexpected code is fatal",
2087                vec![Code(42)],
2088                Fatal(42),
2089                1,
2090                0,
2091                TxnState::FatalError,
2092            ),
2093            (
2094                "transaction abortable leaves the producer as it was",
2095                vec![Code(120)],
2096                Server(120),
2097                1,
2098                0,
2099                TxnState::Uninitialized,
2100            ),
2101        ];
2102        for (name, txn_lookup, result, coordinator_lookups, init_producer_id_requests, state) in
2103            cases
2104        {
2105            let exhausted = txn_lookup.last().copied().unwrap_or(Code(0));
2106            let (mock, producer, coordinator) = scripted_producer_raw(Coordinator {
2107                txn_lookup: Script::new(&txn_lookup, exhausted),
2108                ..Coordinator::default()
2109            })
2110            .await;
2111            let outcome = producer.init_transactions().await;
2112            let state_after = *producer.txn_state.lock().await;
2113            let actual = {
2114                let coordinator = coordinator.lock().expect("scripted coordinator");
2115                ScriptedInit {
2116                    result: outcome.into(),
2117                    coordinator_lookups: lookups(&coordinator),
2118                    init_producer_id_requests: coordinator.init_producer_id_requests,
2119                    state: state_after,
2120                }
2121            };
2122            let expected = ScriptedInit {
2123                result,
2124                coordinator_lookups,
2125                init_producer_id_requests,
2126                state,
2127            };
2128            assert2::assert!(actual == expected, "{name}");
2129            mock.stop();
2130        }
2131    }
2132
2133    /// The lookups of `send_offsets_to_transaction` follow Kafka's
2134    /// `FindCoordinatorHandler` too: the group lookup of `TxnOffsetCommit`, and
2135    /// the lookup of the transaction coordinator after `AddOffsetsToTxn`
2136    /// answers `NOT_COORDINATOR`. `GROUP_AUTHORIZATION_FAILED` stops the
2137    /// transaction from committing, and
2138    /// `TRANSACTIONAL_ID_AUTHORIZATION_FAILED` is fatal.
2139    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2140    async fn send_offsets_to_transaction_retries_the_lookups_that_kafka_retries() {
2141        use Reply::Code;
2142        use TxnResult::{Fatal, Server};
2143        let group = krabka_client_consumer::ConsumerGroupMetadata {
2144            group_id: "group-a".into(),
2145            generation_id: 3,
2146            member_id: "member-a".into(),
2147            group_instance_id: None,
2148        };
2149        // (name, transactional id lookups after the one of init_transactions,
2150        // group lookups, add offsets script, result, offset commit requests,
2151        // coordinator lookups, abortable, transaction state)
2152        let cases = [
2153            (
2154                "group loading, then found",
2155                vec![],
2156                vec![Code(14), Code(0)],
2157                vec![Code(0)],
2158                TxnResult::Ok,
2159                1,
2160                2,
2161                None,
2162                TxnState::InTransaction,
2163            ),
2164            (
2165                "group unavailable twice, then found",
2166                vec![],
2167                vec![Code(15), Code(15), Code(0)],
2168                vec![Code(0)],
2169                TxnResult::Ok,
2170                1,
2171                3,
2172                None,
2173                TxnState::InTransaction,
2174            ),
2175            (
2176                "group authorization is abortable",
2177                vec![],
2178                vec![Code(30)],
2179                vec![Code(0)],
2180                Server(30),
2181                0,
2182                1,
2183                Some(30),
2184                TxnState::InTransaction,
2185            ),
2186            (
2187                "group lookup unexpected code is fatal",
2188                vec![],
2189                vec![Code(42)],
2190                vec![Code(0)],
2191                Fatal(42),
2192                0,
2193                1,
2194                None,
2195                TxnState::FatalError,
2196            ),
2197            (
2198                "transaction coordinator moved, loading, then found",
2199                vec![Code(14), Code(0)],
2200                vec![Code(0)],
2201                vec![Code(16), Code(0)],
2202                TxnResult::Ok,
2203                1,
2204                3,
2205                None,
2206                TxnState::InTransaction,
2207            ),
2208            (
2209                "transaction coordinator moved, then transactional id authorization",
2210                vec![Code(53)],
2211                vec![Code(0)],
2212                vec![Code(16)],
2213                Fatal(53),
2214                0,
2215                1,
2216                None,
2217                TxnState::FatalError,
2218            ),
2219            (
2220                "transaction coordinator moved, then transaction abortable",
2221                vec![Code(120)],
2222                vec![Code(0)],
2223                vec![Code(16)],
2224                Server(120),
2225                0,
2226                1,
2227                Some(120),
2228                TxnState::InTransaction,
2229            ),
2230        ];
2231        for (
2232            name,
2233            txn_lookup,
2234            group_lookup,
2235            add_offsets,
2236            result,
2237            txn_offset_commit_requests,
2238            coordinator_lookups,
2239            abortable_error,
2240            state,
2241        ) in cases
2242        {
2243            let mut txn_lookups = vec![Code(0)];
2244            txn_lookups.extend(txn_lookup);
2245            let (mock, producer, coordinator) = scripted_producer(Coordinator {
2246                txn_lookup: Script::new(&txn_lookups, Code(0)),
2247                group_lookup: Script::new(&group_lookup, Code(0)),
2248                add_offsets: Script::new(&add_offsets, Code(0)),
2249                ..Coordinator::default()
2250            })
2251            .await;
2252            let transaction = producer
2253                .begin_transaction()
2254                .await
2255                .expect("begin transaction");
2256            let outcome = producer
2257                .send_offsets_to_transaction([(("topic".to_owned(), 0), 42)], &group)
2258                .await;
2259            let state_after = *producer.txn_state.lock().await;
2260            drop(transaction);
2261            let actual = {
2262                let coordinator = coordinator.lock().expect("scripted coordinator");
2263                ScriptedSendOffsets {
2264                    result: outcome.into(),
2265                    add_offsets_requests: coordinator.add_offsets.requests,
2266                    txn_offset_commit_requests: coordinator.txn_offset_commit.requests,
2267                    coordinator_lookups: lookups(&coordinator),
2268                    abortable_error: producer.txn_error.abortable().map(|error| match error {
2269                        AbortableError::Server(code) => code,
2270                        AbortableError::Timeout => i16::MIN,
2271                    }),
2272                    state: state_after,
2273                }
2274            };
2275            let expected = ScriptedSendOffsets {
2276                result,
2277                add_offsets_requests: add_offsets.len(),
2278                txn_offset_commit_requests,
2279                coordinator_lookups,
2280                abortable_error,
2281                state,
2282            };
2283            assert2::assert!(actual == expected, "{name}");
2284            mock.stop();
2285        }
2286    }
2287
2288    /// A commit whose `EndTxn` answers `NOT_COORDINATOR` finds the
2289    /// coordinator again, with the retries of Kafka's
2290    /// `FindCoordinatorHandler`. A fatal lookup answer makes the producer
2291    /// fatal, and an abortable one fails the commit with that code.
2292    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2293    async fn commit_retries_the_lookups_that_kafka_retries() {
2294        use Reply::Code;
2295        use TxnResult::{Fatal, Server};
2296        let cases = [
2297            (
2298                "moved, unavailable, then committed",
2299                vec![Code(15), Code(0)],
2300                vec![Code(16), Code(0)],
2301                TxnResult::Ok,
2302                TxnState::Ready,
2303            ),
2304            (
2305                "moved, then transactional id authorization",
2306                vec![Code(53)],
2307                vec![Code(16)],
2308                Fatal(53),
2309                TxnState::FatalError,
2310            ),
2311            (
2312                "moved, then transaction abortable",
2313                vec![Code(120)],
2314                vec![Code(16)],
2315                Server(120),
2316                TxnState::InTransaction,
2317            ),
2318        ];
2319        for (name, txn_lookup, end_txn, result, state) in cases {
2320            let mut txn_lookups = vec![Code(0)];
2321            txn_lookups.extend(txn_lookup);
2322            let (mock, producer, coordinator) = scripted_producer(Coordinator {
2323                txn_lookup: Script::new(&txn_lookups, Code(0)),
2324                end_txn: Script::new(&end_txn, Code(0)),
2325                ..Coordinator::default()
2326            })
2327            .await;
2328            let outcome = producer
2329                .begin_transaction()
2330                .await
2331                .expect("begin transaction")
2332                .commit()
2333                .await
2334                .map_err(|error| error.source);
2335            let state_after = *producer.txn_state.lock().await;
2336            let actual = ScriptedCommit {
2337                result: outcome.into(),
2338                end_txn_requests: coordinator
2339                    .lock()
2340                    .expect("scripted coordinator")
2341                    .end_txn
2342                    .requests,
2343                state: state_after,
2344            };
2345            let expected = ScriptedCommit {
2346                result,
2347                end_txn_requests: end_txn.len(),
2348                state,
2349            };
2350            assert2::assert!(actual == expected, "{name}");
2351            mock.stop();
2352        }
2353    }
2354
2355    /// Kafka's producer follows the finalized feature `transaction.version`
2356    /// (`TransactionManager.maybeUpdateTransactionV2Enabled`). With level 2 it
2357    /// sends no `AddPartitionsToTxn` (`maybeAddPartition`) and no
2358    /// `AddOffsetsToTxn` (`sendOffsetsToTransaction`), and it sends Produce,
2359    /// `TxnOffsetCommit` and `EndTxn` at their latest versions. With level 1
2360    /// it caps them at `LAST_STABLE_VERSION_BEFORE_TRANSACTION_V2`: 11, 4 and
2361    /// 4.
2362    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2363    async fn the_transaction_version_decides_the_transaction_requests() {
2364        let group = krabka_client_consumer::ConsumerGroupMetadata {
2365            group_id: "group-a".into(),
2366            generation_id: 3,
2367            member_id: "member-a".into(),
2368            group_instance_id: None,
2369        };
2370        let cases = [
2371            (
2372                "transaction version 1",
2373                1,
2374                vec![
2375                    (produce_request::API_KEY, 11),
2376                    (txn_offset_commit_request::API_KEY, 4),
2377                    (end_txn_request::API_KEY, 4),
2378                    (add_partitions_to_txn_request::API_KEY, 3),
2379                    (add_offsets_to_txn_request::API_KEY, 4),
2380                ],
2381            ),
2382            (
2383                "transaction version 2",
2384                2,
2385                // The mock metadata has no topic id, so Produce stops at v12,
2386                // the last version with topic names.
2387                vec![
2388                    (produce_request::API_KEY, 12),
2389                    (txn_offset_commit_request::API_KEY, 5),
2390                    (end_txn_request::API_KEY, 5),
2391                ],
2392            ),
2393        ];
2394        for (name, transaction_version, expected) in cases {
2395            let (mock, producer, coordinator) = scripted_producer(Coordinator {
2396                transaction_version: Some(transaction_version),
2397                ..Coordinator::default()
2398            })
2399            .await;
2400            coordinator
2401                .lock()
2402                .expect("scripted coordinator")
2403                .transaction_requests
2404                .clear();
2405            let transaction = producer
2406                .begin_transaction()
2407                .await
2408                .expect("begin transaction");
2409            let record = producer
2410                .enqueue(ProducerRecord {
2411                    topic: "topic".to_owned(),
2412                    partition: Some(0),
2413                    value: Some(bytes::Bytes::from_static(b"v")),
2414                    ..Default::default()
2415                })
2416                .await
2417                .expect("record is queued");
2418            producer
2419                .send_offsets_to_transaction([(("topic".to_owned(), 0), 42)], &group)
2420                .await
2421                .expect("send offsets");
2422            transaction.commit().await.expect("commit");
2423            record.await.expect("the record is delivered");
2424            let mut actual = coordinator
2425                .lock()
2426                .expect("scripted coordinator")
2427                .transaction_requests
2428                .clone();
2429            mock.stop();
2430            let mut expected = expected;
2431            actual.sort_unstable();
2432            expected.sort_unstable();
2433            assert2::assert!(actual == expected, "{name}");
2434        }
2435    }
2436
2437    /// What one scripted `send_offsets_to_transaction` sent and gave.
2438    #[derive(Debug, PartialEq, Eq)]
2439    struct SentOffsetCommits {
2440        result: TxnResult,
2441        /// The negotiated version and the decoded topics of each
2442        /// `TxnOffsetCommit` request.
2443        requests: Vec<(i16, Vec<TxnOffsetCommitRequestTopic>)>,
2444    }
2445
2446    /// Kafka's `TransactionManager.txnOffsetCommitHandler` reads the topic
2447    /// ids from the metadata that `KafkaProducer.sendOffsetsToTransaction`
2448    /// waited for. With an id for every topic it negotiates up to v6
2449    /// (KIP-1319), which names each topic by id. Without an id, or with
2450    /// transaction version 1, it names each topic at v5 or v4. The handler
2451    /// treats `UNKNOWN_TOPIC_ID` as retriable and sends the same request
2452    /// again.
2453    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2454    async fn send_offsets_to_transaction_names_topics_by_id_at_v6() {
2455        use Reply::Code;
2456        let group = krabka_client_consumer::ConsumerGroupMetadata {
2457            group_id: "group-a".into(),
2458            generation_id: 3,
2459            member_id: "member-a".into(),
2460            group_instance_id: None,
2461        };
2462        let id = Uuid([5; 16]);
2463        let by_id = TxnOffsetCommitRequestTopic {
2464            topic_id: id,
2465            partitions: vec![TxnOffsetCommitRequestPartition {
2466                partition_index: 0,
2467                committed_offset: 42,
2468                ..Default::default()
2469            }],
2470            ..Default::default()
2471        };
2472        let by_name = TxnOffsetCommitRequestTopic {
2473            name: "topic".into(),
2474            topic_id: Uuid::ZERO,
2475            ..by_id.clone()
2476        };
2477        // (name, transaction version, advertised max, metadata topic id,
2478        // offset commit script, expected)
2479        let cases = [
2480            (
2481                "every topic has an id",
2482                2,
2483                6,
2484                id,
2485                vec![Code(0)],
2486                SentOffsetCommits {
2487                    result: TxnResult::Ok,
2488                    requests: vec![(6, vec![by_id.clone()])],
2489                },
2490            ),
2491            (
2492                "the metadata gives no id",
2493                2,
2494                6,
2495                Uuid::ZERO,
2496                vec![Code(0)],
2497                SentOffsetCommits {
2498                    result: TxnResult::Ok,
2499                    requests: vec![(5, vec![by_name.clone()])],
2500                },
2501            ),
2502            (
2503                "the broker stops at v5",
2504                2,
2505                5,
2506                id,
2507                vec![Code(0)],
2508                SentOffsetCommits {
2509                    result: TxnResult::Ok,
2510                    requests: vec![(5, vec![by_name.clone()])],
2511                },
2512            ),
2513            (
2514                "transaction version 1",
2515                1,
2516                6,
2517                id,
2518                vec![Code(0)],
2519                SentOffsetCommits {
2520                    result: TxnResult::Ok,
2521                    requests: vec![(4, vec![by_name.clone()])],
2522                },
2523            ),
2524            (
2525                "unknown topic id, then committed",
2526                2,
2527                6,
2528                id,
2529                vec![Code(100), Code(0)],
2530                SentOffsetCommits {
2531                    result: TxnResult::Ok,
2532                    requests: vec![(6, vec![by_id.clone()]), (6, vec![by_id.clone()])],
2533                },
2534            ),
2535            (
2536                "unknown topic id until the retry deadline",
2537                2,
2538                6,
2539                id,
2540                vec![Code(100)],
2541                SentOffsetCommits {
2542                    result: TxnResult::Server(100),
2543                    requests: Vec::new(),
2544                },
2545            ),
2546        ];
2547        for (name, transaction_version, txn_offset_commit_max, topic_id, script, expected) in cases
2548        {
2549            let exhausted = *script.last().expect("script");
2550            let (mock, producer, coordinator) = scripted_producer(Coordinator {
2551                transaction_version: Some(transaction_version),
2552                txn_offset_commit_max: Some(txn_offset_commit_max),
2553                topic_id,
2554                txn_offset_commit: Script::new(&script, exhausted),
2555                ..Coordinator::default()
2556            })
2557            .await;
2558            let transaction = producer
2559                .begin_transaction()
2560                .await
2561                .expect("begin transaction");
2562            let result = producer
2563                .send_offsets_to_transaction([(("topic".to_owned(), 0), 42)], &group)
2564                .await
2565                .into();
2566            drop(transaction);
2567            let mut requests = coordinator
2568                .lock()
2569                .expect("scripted coordinator")
2570                .txn_offset_commit_topics
2571                .clone();
2572            mock.stop();
2573            // The request goes out until the retry deadline ends, so the row
2574            // checks the shape of each request and not their number.
2575            if expected.requests.is_empty() {
2576                requests.dedup();
2577                assert2::assert!(requests == vec![(6, vec![by_id.clone()])], "{name}");
2578                requests.clear();
2579            }
2580            let actual = SentOffsetCommits { result, requests };
2581            assert2::assert!(actual == expected, "{name}");
2582        }
2583    }
2584
2585    /// The observable result of a commit after a failed transactional batch.
2586    #[derive(Debug, PartialEq, Eq)]
2587    struct CommitAfterFailedBatch {
2588        record: TxnResult,
2589        commit: TxnResult,
2590        end_txn_requests: usize,
2591        abort: TxnResult,
2592        abortable_error_after_abort: Option<i16>,
2593        next_transaction: TxnResult,
2594    }
2595
2596    /// A send after `prepare_transaction` fails at once with the state error.
2597    /// It does not wait for buffer memory first.
2598    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2599    async fn send_in_preparing_state_fails_before_waiting_for_memory() {
2600        let (mock, producer, _coordinator) = scripted_producer(Coordinator::default()).await;
2601        let _transaction = producer
2602            .begin_transaction()
2603            .await
2604            .expect("begin transaction");
2605        *producer.txn_state.lock().await = TxnState::Preparing;
2606        let _all_memory = producer
2607            .buffer_pool
2608            .allocate(producer.buffer_pool.total(), Duration::ZERO)
2609            .await
2610            .expect("the pool is free");
2611
2612        let send = producer.send(ProducerRecord {
2613            topic: "topic".to_owned(),
2614            partition: Some(0),
2615            value: Some(bytes::Bytes::from_static(b"v")),
2616            ..Default::default()
2617        });
2618        let outcome = time::timeout(Duration::from_secs(2), send)
2619            .await
2620            .map_or_else(
2621                |_| Err("waited".to_owned()),
2622                |result| result.map(drop).map_err(|error| error.to_string()),
2623            );
2624        mock.stop();
2625        assert2::assert!(
2626            outcome
2627                == Err(
2628                    "invalid transaction state: send is not allowed after prepare_transaction"
2629                        .to_owned()
2630                )
2631        );
2632    }
2633
2634    /// What a send in a prepared transaction gave, and the `Metadata` requests
2635    /// that it sent.
2636    #[derive(Debug, PartialEq, Eq)]
2637    struct PreparedSend {
2638        outcome: Result<(), String>,
2639        metadata_requests: usize,
2640    }
2641
2642    /// A send after `prepare_transaction` fails at once with the state error.
2643    /// It does not wait for the metadata of a topic that the producer does not
2644    /// know, as Kafka's `KafkaProducer.doSend` calls `throwIfInPreparedState`
2645    /// before `waitOnMetadata`.
2646    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2647    async fn send_in_a_prepared_transaction_fails_before_the_metadata_wait() {
2648        for state in [TxnState::Preparing, TxnState::Prepared] {
2649            let (mock, producer, coordinator) = scripted_producer(Coordinator::default()).await;
2650            let _transaction = producer
2651                .begin_transaction()
2652                .await
2653                .expect("begin transaction");
2654            *producer.txn_state.lock().await = state;
2655            let before = coordinator
2656                .lock()
2657                .expect("scripted coordinator")
2658                .metadata_requests;
2659
2660            // The mock broker never lists this topic.
2661            let send = producer.send(ProducerRecord {
2662                topic: "unknown".to_owned(),
2663                value: Some(bytes::Bytes::from_static(b"v")),
2664                ..Default::default()
2665            });
2666            let outcome = time::timeout(Duration::from_secs(2), send)
2667                .await
2668                .map_or_else(
2669                    |_| Err("waited".to_owned()),
2670                    |result| result.map(drop).map_err(|error| error.to_string()),
2671                );
2672            let actual = PreparedSend {
2673                outcome,
2674                metadata_requests: coordinator
2675                    .lock()
2676                    .expect("scripted coordinator")
2677                    .metadata_requests
2678                    - before,
2679            };
2680            mock.stop();
2681            assert2::assert!(
2682                actual
2683                    == PreparedSend {
2684                        outcome: Err("invalid transaction state: send is not allowed after \
2685                                      prepare_transaction"
2686                            .to_owned()),
2687                        metadata_requests: 0,
2688                    },
2689                "{state:?}"
2690            );
2691        }
2692    }
2693
2694    /// Concurrent transactional sends to one partition all complete. A send
2695    /// takes the transaction state lock before the accumulator lock, also
2696    /// when it checks for buffer memory, so two sends cannot wait for each
2697    /// other.
2698    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2699    async fn concurrent_transactional_sends_to_one_partition_complete() {
2700        let (mock, producer, _coordinator) = scripted_producer(Coordinator::default()).await;
2701        let _transaction = producer
2702            .begin_transaction()
2703            .await
2704            .expect("begin transaction");
2705        let sends = iter::repeat_with(|| {
2706            producer.enqueue(ProducerRecord {
2707                topic: "topic".to_owned(),
2708                partition: Some(0),
2709                value: Some(bytes::Bytes::from_static(b"v")),
2710                ..Default::default()
2711            })
2712        })
2713        .take(200);
2714        let enqueued = time::timeout(Duration::from_secs(10), join_all(sends))
2715            .await
2716            .map(|receivers| receivers.len());
2717        mock.stop();
2718        assert2::assert!(enqueued == Ok(200));
2719    }
2720
2721    /// Kafka's `Sender.failBatch` calls
2722    /// `TransactionManager.handleFailedBatch`, which moves a transactional
2723    /// producer to `ABORTABLE_ERROR`. `commitTransaction` then fails in
2724    /// `maybeFailWithError`, and it sends no `EndTxn`. Only
2725    /// `abortTransaction` clears the state.
2726    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2727    async fn a_failed_transactional_batch_stops_a_later_commit() {
2728        let (mock, producer, coordinator) = scripted_producer(Coordinator {
2729            // 42 is INVALID_REQUEST, which Kafka does not retry.
2730            produce: Script::new(&[Reply::Code(42)], Reply::Code(0)),
2731            ..Coordinator::default()
2732        })
2733        .await;
2734        let (record, commit) = {
2735            let transaction = producer
2736                .begin_transaction()
2737                .await
2738                .expect("begin transaction");
2739            let record = producer
2740                .send(ProducerRecord {
2741                    topic: "topic".to_owned(),
2742                    partition: Some(0),
2743                    value: Some(bytes::Bytes::from_static(b"v")),
2744                    ..Default::default()
2745                })
2746                .await;
2747
2748            (record, transaction.commit().await)
2749        };
2750        let (commit_result, transaction) = match commit {
2751            Ok(()) => (TxnResult::Ok, None),
2752            Err(error) => (TxnResult::from(Err(error.source)), Some(error.transaction)),
2753        };
2754        let end_txn_requests = coordinator
2755            .lock()
2756            .expect("scripted coordinator")
2757            .end_txn
2758            .requests;
2759        let abort = match transaction {
2760            Some(transaction) => {
2761                TxnResult::from(transaction.abort().await.map_err(|error| error.source))
2762            }
2763            None => TxnResult::Other("the commit succeeded".to_owned()),
2764        };
2765        let abortable_error_after_abort = producer.txn_error.abortable().map(|error| match error {
2766            AbortableError::Server(code) => code,
2767            AbortableError::Timeout => i16::MIN,
2768        });
2769        let next_transaction = match producer.begin_transaction().await {
2770            Ok(transaction) => {
2771                TxnResult::from(transaction.abort().await.map_err(|error| error.source))
2772            }
2773            Err(error) => TxnResult::from(Err(error)),
2774        };
2775
2776        let actual = CommitAfterFailedBatch {
2777            record: TxnResult::from(record.map(drop)),
2778            commit: commit_result,
2779            end_txn_requests,
2780            abort,
2781            abortable_error_after_abort,
2782            next_transaction,
2783        };
2784        assert2::assert!(
2785            actual
2786                == CommitAfterFailedBatch {
2787                    record: TxnResult::Server(42),
2788                    commit: TxnResult::Server(42),
2789                    end_txn_requests: 0,
2790                    abort: TxnResult::Ok,
2791                    abortable_error_after_abort: None,
2792                    next_transaction: TxnResult::Ok,
2793                }
2794        );
2795        mock.stop();
2796    }
2797
2798    /// The producer state after a commit whose `EndTxn` v5 answer carried an
2799    /// identity.
2800    #[derive(Debug, PartialEq, Eq)]
2801    struct IdentityAfterCommit {
2802        identity: (i64, i16),
2803        sequences: Vec<((String, i32), i32)>,
2804    }
2805
2806    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2807    async fn a_new_producer_identity_resets_sequence_numbers() {
2808        let kept = vec![(("topic".to_owned(), 0), 5)];
2809        let cases = [
2810            ("epoch bump", (7, 4), (7, 4), vec![]),
2811            ("new producer id", (8, 0), (8, 0), vec![]),
2812            ("same identity", (7, 3), (7, 3), kept.clone()),
2813            ("no identity in the answer", (-1, -1), (7, 3), kept),
2814        ];
2815        for (name, answered, identity, sequences) in cases {
2816            let port_cell = Arc::new(AtomicU16::new(0));
2817            let handler_port = Arc::clone(&port_cell);
2818            let mock = MockBroker::start(move |api_key, version, _corr_id, _body| {
2819                if api_key == api_versions_request::API_KEY {
2820                    // Only transaction version 2 sends `EndTxn` v5, whose
2821                    // answer carries the new identity.
2822                    return Some(scripted_api_versions((0, 5), false, Some(2), 5, version));
2823                }
2824                if api_key == find_coordinator_request::API_KEY {
2825                    return Some(encode_v0(&FindCoordinatorResponse {
2826                        error_code: 0,
2827                        node_id: 1,
2828                        host: "127.0.0.1".into(),
2829                        port: i32::from(handler_port.load(Ordering::SeqCst)),
2830                        ..Default::default()
2831                    }));
2832                }
2833                if api_key == init_producer_id_request::API_KEY {
2834                    return Some(encode_v0(&InitProducerIdResponse {
2835                        error_code: 0,
2836                        producer_id: 7,
2837                        producer_epoch: 3,
2838                        ..Default::default()
2839                    }));
2840                }
2841                if api_key == end_txn_request::API_KEY {
2842                    let mut buf = BytesMut::new();
2843                    if version >= end_txn_response::FLEXIBLE_MIN {
2844                        buf.extend_from_slice(&[0]);
2845                    }
2846                    EndTxnResponse {
2847                        error_code: 0,
2848                        producer_id: answered.0,
2849                        producer_epoch: answered.1,
2850                        ..Default::default()
2851                    }
2852                    .encode(&mut buf, version)
2853                    .expect("encode EndTxn response");
2854                    return Some(buf.to_vec());
2855                }
2856                None
2857            })
2858            .await
2859            .unwrap();
2860            port_cell.store(mock.addr.port(), Ordering::SeqCst);
2861            let producer = Producer::builder()
2862                .bootstrap(mock.addr.to_string())
2863                .transactional_id("test-txn")
2864                .request_timeout(Duration::from_millis(100))
2865                .build()
2866                .await
2867                .expect("producer connects to the mock");
2868            producer
2869                .init_transactions()
2870                .await
2871                .expect("init_transactions against the mock coordinator");
2872            let transaction = producer
2873                .begin_transaction()
2874                .await
2875                .expect("begin transaction");
2876            producer.next_seq.insert(("topic".to_owned(), 0), 5);
2877            transaction.commit().await.expect("commit");
2878            let actual = IdentityAfterCommit {
2879                identity: *producer.txn_pid_epoch.lock().await,
2880                sequences: producer
2881                    .next_seq
2882                    .iter()
2883                    .map(|entry| (entry.key().clone(), *entry.value()))
2884                    .collect(),
2885            };
2886            drop(producer);
2887            let expected = IdentityAfterCommit {
2888                identity,
2889                sequences,
2890            };
2891            assert2::assert!(actual == expected, "{name}");
2892            mock.stop();
2893        }
2894    }
2895
2896    /// What `prepare_transaction` gave after a batch failed during its flush.
2897    #[derive(Debug, PartialEq, Eq)]
2898    struct PrepareAfterFailedFlush {
2899        record: TxnResult,
2900        prepare: TxnResult,
2901        state: TxnState,
2902    }
2903
2904    /// Kafka's `KafkaProducer.prepareTransaction` flushes, and then
2905    /// `TransactionManager.prepareTransaction` calls `maybeFailWithError`. A
2906    /// batch that fails during the flush therefore stops the prepare.
2907    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2908    async fn prepare_fails_when_a_batch_fails_during_its_flush() {
2909        let (mock, producer, _coordinator) = scripted_producer(Coordinator {
2910            // 42 is INVALID_REQUEST, which Kafka does not retry.
2911            produce: Script::new(&[Reply::Code(42)], Reply::Code(0)),
2912            two_phase_commit: true,
2913            // The record stays in the accumulator until the prepare flushes.
2914            linger: Some(Duration::from_mins(1)),
2915            ..Coordinator::default()
2916        })
2917        .await;
2918        let transaction = producer
2919            .begin_transaction()
2920            .await
2921            .expect("begin transaction");
2922        let receiver = producer
2923            .enqueue(ProducerRecord {
2924                topic: "topic".to_owned(),
2925                partition: Some(0),
2926                value: Some(bytes::Bytes::from_static(b"v")),
2927                ..Default::default()
2928            })
2929            .await
2930            .expect("record is queued");
2931        let prepare = TxnResult::from(transaction.prepare().await.map(drop));
2932        let record = TxnResult::from(receiver.await.map(drop));
2933        let actual = PrepareAfterFailedFlush {
2934            record,
2935            prepare,
2936            state: *producer.txn_state.lock().await,
2937        };
2938        drop(transaction);
2939        mock.stop();
2940        assert2::assert!(
2941            actual
2942                == PrepareAfterFailedFlush {
2943                    record: TxnResult::Server(42),
2944                    prepare: TxnResult::Server(42),
2945                    state: TxnState::InTransaction,
2946                }
2947        );
2948    }
2949
2950    /// The requests that a scripted coordinator got.
2951    #[derive(Debug, Default, PartialEq, Eq)]
2952    struct CoordinatorRequests {
2953        add_partitions: usize,
2954        produce: usize,
2955        add_offsets: usize,
2956        txn_offset_commit: usize,
2957        end_txn: usize,
2958        init_producer_id: usize,
2959    }
2960
2961    impl CoordinatorRequests {
2962        fn of(coordinator: &SharedCoordinator) -> Self {
2963            let coordinator = coordinator.lock().expect("scripted coordinator");
2964            Self {
2965                add_partitions: coordinator.add_partitions.requests,
2966                produce: coordinator.produce.requests,
2967                add_offsets: coordinator.add_offsets.requests,
2968                txn_offset_commit: coordinator.txn_offset_commit.requests,
2969                end_txn: coordinator.end_txn.requests,
2970                init_producer_id: coordinator.init_producer_id_requests,
2971            }
2972        }
2973    }
2974
2975    /// The operation that puts the transaction in an error state.
2976    #[derive(Debug, Clone, Copy)]
2977    enum Trigger {
2978        Send,
2979        SendOffsets,
2980        Commit,
2981    }
2982
2983    /// What each transactional operation gave after a transaction error, and
2984    /// the requests that all of them sent.
2985    #[derive(Debug, PartialEq, Eq)]
2986    struct AfterTransactionError {
2987        trigger: TxnResult,
2988        state: TxnState,
2989        send: TxnResult,
2990        send_offsets: TxnResult,
2991        commit: TxnResult,
2992        abort: TxnResult,
2993        begin_and_abort: TxnResult,
2994        init_transactions: TxnResult,
2995        requests: CoordinatorRequests,
2996    }
2997
2998    /// Kafka's `TransactionManager.maybeFailWithError` throws the stored error
2999    /// in `maybeAddPartition` (so `KafkaProducer.doSend` fails the send),
3000    /// `sendOffsetsToTransaction`, `beginCommit`, `beginTransaction` and
3001    /// `initializeTransactions`. An abortable error lets `beginAbort` go on,
3002    /// and the abort clears it. A fatal error (`fatalError` in the handlers:
3003    /// a fence, `TRANSACTIONAL_ID_AUTHORIZATION_FAILED`, `INVALID_TXN_STATE`,
3004    /// `INVALID_PRODUCER_ID_MAPPING` and an unexpected code) stops every one
3005    /// of them, the abort and a new `initTransactions` included.
3006    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3007    async fn every_operation_fails_after_a_transaction_error() {
3008        use Reply::Code;
3009        use TxnResult::{Fatal, Fenced, Server};
3010        let group = krabka_client_consumer::ConsumerGroupMetadata {
3011            group_id: "group-a".into(),
3012            generation_id: 3,
3013            member_id: "member-a".into(),
3014            group_instance_id: None,
3015        };
3016        let fatal = |code, state, requests| AfterTransactionError {
3017            trigger: Fatal(code),
3018            state,
3019            send: Fatal(code),
3020            send_offsets: Fatal(code),
3021            commit: Fatal(code),
3022            abort: Fatal(code),
3023            begin_and_abort: Fatal(code),
3024            init_transactions: Fatal(code),
3025            requests,
3026        };
3027        let cases = [
3028            (
3029                "add partitions transactional id authorization",
3030                Trigger::Send,
3031                Coordinator {
3032                    add_partitions: Script::new(&[Code(53)], Code(0)),
3033                    ..Coordinator::default()
3034                },
3035                fatal(
3036                    53,
3037                    TxnState::FatalError,
3038                    CoordinatorRequests {
3039                        add_partitions: 1,
3040                        ..CoordinatorRequests::default()
3041                    },
3042                ),
3043            ),
3044            (
3045                "add partitions producer fenced",
3046                Trigger::Send,
3047                Coordinator {
3048                    add_partitions: Script::new(&[Code(90)], Code(0)),
3049                    ..Coordinator::default()
3050                },
3051                AfterTransactionError {
3052                    trigger: Fenced,
3053                    state: TxnState::Fenced,
3054                    send: Fenced,
3055                    send_offsets: Fenced,
3056                    commit: Fenced,
3057                    abort: Fenced,
3058                    begin_and_abort: Fenced,
3059                    init_transactions: Fenced,
3060                    requests: CoordinatorRequests {
3061                        add_partitions: 1,
3062                        ..CoordinatorRequests::default()
3063                    },
3064                },
3065            ),
3066            (
3067                "add offsets invalid producer id mapping",
3068                Trigger::SendOffsets,
3069                Coordinator {
3070                    add_offsets: Script::new(&[Code(49)], Code(0)),
3071                    ..Coordinator::default()
3072                },
3073                fatal(
3074                    49,
3075                    TxnState::FatalError,
3076                    CoordinatorRequests {
3077                        add_offsets: 1,
3078                        ..CoordinatorRequests::default()
3079                    },
3080                ),
3081            ),
3082            (
3083                "end txn invalid txn state",
3084                Trigger::Commit,
3085                Coordinator {
3086                    end_txn: Script::new(&[Code(48)], Code(0)),
3087                    ..Coordinator::default()
3088                },
3089                fatal(
3090                    48,
3091                    TxnState::FatalError,
3092                    CoordinatorRequests {
3093                        end_txn: 1,
3094                        ..CoordinatorRequests::default()
3095                    },
3096                ),
3097            ),
3098            (
3099                "failed batch is abortable",
3100                Trigger::Send,
3101                Coordinator {
3102                    // 42 is INVALID_REQUEST, which Kafka does not retry.
3103                    produce: Script::new(&[Code(42)], Code(0)),
3104                    ..Coordinator::default()
3105                },
3106                AfterTransactionError {
3107                    trigger: Server(42),
3108                    state: TxnState::InTransaction,
3109                    send: Server(42),
3110                    send_offsets: Server(42),
3111                    commit: Server(42),
3112                    abort: TxnResult::Ok,
3113                    begin_and_abort: TxnResult::Ok,
3114                    init_transactions: TxnResult::Ok,
3115                    requests: CoordinatorRequests {
3116                        add_partitions: 1,
3117                        produce: 1,
3118                        end_txn: 2,
3119                        init_producer_id: 1,
3120                        ..CoordinatorRequests::default()
3121                    },
3122                },
3123            ),
3124        ];
3125        for (name, trigger, coordinator, expected) in cases {
3126            let (mock, producer, coordinator) = scripted_producer(coordinator).await;
3127            let record = || ProducerRecord {
3128                topic: "topic".to_owned(),
3129                partition: Some(0),
3130                value: Some(bytes::Bytes::from_static(b"v")),
3131                ..Default::default()
3132            };
3133            let transaction = producer
3134                .begin_transaction()
3135                .await
3136                .expect("begin transaction");
3137            let (trigger_result, transaction) = match trigger {
3138                Trigger::Send => {
3139                    let result = producer.send(record()).await.map(drop);
3140                    (TxnResult::from(result), transaction)
3141                }
3142                Trigger::SendOffsets => {
3143                    let result = producer
3144                        .send_offsets_to_transaction([(("topic".to_owned(), 0), 42)], &group)
3145                        .await;
3146                    (TxnResult::from(result), transaction)
3147                }
3148                Trigger::Commit => match transaction.commit().await {
3149                    Ok(()) => panic!("{name}: the commit succeeded"),
3150                    Err(error) => (TxnResult::from(Err(error.source)), error.transaction),
3151                },
3152            };
3153            let state = *producer.txn_state.lock().await;
3154            let send = TxnResult::from(producer.send(record()).await.map(drop));
3155            let send_offsets = TxnResult::from(
3156                producer
3157                    .send_offsets_to_transaction([(("topic".to_owned(), 0), 43)], &group)
3158                    .await,
3159            );
3160            let (commit, transaction) = match transaction.commit().await {
3161                Ok(()) => (TxnResult::Ok, None),
3162                Err(error) => (TxnResult::from(Err(error.source)), Some(error.transaction)),
3163            };
3164            let abort = match transaction {
3165                Some(transaction) => {
3166                    TxnResult::from(transaction.abort().await.map_err(|error| error.source))
3167                }
3168                None => TxnResult::Other("the commit succeeded".to_owned()),
3169            };
3170            let begin_and_abort = match producer.begin_transaction().await {
3171                Ok(transaction) => {
3172                    TxnResult::from(transaction.abort().await.map_err(|error| error.source))
3173                }
3174                Err(error) => TxnResult::from(Err(error)),
3175            };
3176            let init_transactions = TxnResult::from(producer.init_transactions().await);
3177            let actual = AfterTransactionError {
3178                trigger: trigger_result,
3179                state,
3180                send,
3181                send_offsets,
3182                commit,
3183                abort,
3184                begin_and_abort,
3185                init_transactions,
3186                requests: CoordinatorRequests::of(&coordinator),
3187            };
3188            mock.stop();
3189            assert2::assert!(actual == expected, "{name}");
3190        }
3191    }
3192
3193    /// How a coordinator call with a rejected authentication ended.
3194    #[derive(Debug, PartialEq, Eq)]
3195    struct RejectedCoordinator {
3196        rejected: bool,
3197        rejecting_handshakes: usize,
3198        state: TxnState,
3199    }
3200
3201    /// Kafka's `Sender.runOnce` catches an `AuthenticationException` from the
3202    /// transaction coordinator and fails every pending transactional request
3203    /// (`TransactionManager.authenticationFailed`). The producer does not
3204    /// retry `AddPartitionsToTxn` or `EndTxn`, and it does not find another
3205    /// coordinator.
3206    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3207    async fn coordinator_sasl_rejection_is_not_retried() {
3208        use krabka_client_core::{
3209            Client, MockReply, MockSaslAnswer,
3210            security::{ClientSecurity, SaslCredentials},
3211        };
3212        use krabka_security::ListenerProtocol;
3213
3214        #[derive(Clone, Copy, Debug)]
3215        enum Call {
3216            AddPartitions,
3217            Commit,
3218        }
3219
3220        for (call, state) in [
3221            (Call::AddPartitions, TxnState::InTransaction),
3222            (Call::Commit, TxnState::RecoveryRequired),
3223        ] {
3224            let (mock, producer, _coordinator) = scripted_producer(Coordinator::default()).await;
3225            let handshakes = Arc::new(AtomicUsize::new(0));
3226            let counter = Arc::clone(&handshakes);
3227            let answer = MockSaslAnswer::AuthenticateError(58);
3228            let rejecting =
3229                MockBroker::start_with_replies(move |api_key, version, _corr, _body| {
3230                    if api_key == sasl_handshake_request::API_KEY {
3231                        counter.fetch_add(1, Ordering::SeqCst);
3232                    }
3233                    answer
3234                        .reply(api_key, version)
3235                        .unwrap()
3236                        .unwrap_or(MockReply::Silent)
3237                })
3238                .await
3239                .unwrap();
3240            let rejecting_client = Client::builder()
3241                .bootstrap(rejecting.addr.to_string())
3242                .security(ClientSecurity {
3243                    protocol: ListenerProtocol::SaslPlaintext,
3244                    tls: None,
3245                    sasl: Some(SaslCredentials::Plain {
3246                        username: "alice".into(),
3247                        password: "secret".into(),
3248                    }),
3249                    sasl_host: None,
3250                })
3251                .build()
3252                .await
3253                .expect("client");
3254            let transaction = producer
3255                .begin_transaction()
3256                .await
3257                .expect("begin transaction");
3258            *producer.txn_coord_client.lock().await = Some(rejecting_client);
3259
3260            let result = match call {
3261                Call::AddPartitions => producer.register_transaction_partition("topic", 0).await,
3262                Call::Commit => transaction.commit().await.map_err(|error| error.source),
3263            };
3264            let actual = RejectedCoordinator {
3265                rejected: matches!(
3266                    result,
3267                    Err(ProducerError::Client(ClientError::Authentication { .. }))
3268                ),
3269                rejecting_handshakes: handshakes.load(Ordering::SeqCst),
3270                state: *producer.txn_state.lock().await,
3271            };
3272
3273            rejecting.stop();
3274            mock.stop();
3275            assert2::assert!(
3276                actual
3277                    == RejectedCoordinator {
3278                        rejected: true,
3279                        rejecting_handshakes: 1,
3280                        state,
3281                    },
3282                "{call:?}"
3283            );
3284        }
3285    }
3286
3287    /// The transaction state each `send` / `send_offsets_to_transaction`
3288    /// case starts from.
3289    #[derive(Debug, Clone, Copy)]
3290    enum StartState {
3291        /// Before `init_transactions`.
3292        Uninitialized,
3293        /// After `init_transactions`, before `begin_transaction`.
3294        Ready,
3295        /// Between `begin_transaction` and `commit`/`abort`.
3296        InTransaction,
3297    }
3298
3299    #[derive(Debug, Clone, Copy)]
3300    enum Operation {
3301        SendRecord,
3302        SendOffsets,
3303    }
3304
3305    /// Kafka's `TransactionManager.maybeAddPartition` and
3306    /// `.sendOffsetsToTransaction` throw `IllegalStateException` synchronously,
3307    /// and send nothing, when `currentState != IN_TRANSACTION`. `send` and
3308    /// `send_offsets_to_transaction` must refuse the same way, sending nothing,
3309    /// outside `InTransaction`, and must succeed, sending requests, inside it.
3310    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3311    async fn send_and_send_offsets_require_an_open_transaction() {
3312        use Operation::{SendOffsets, SendRecord};
3313        use StartState::{InTransaction, Ready, Uninitialized};
3314
3315        let cases = [
3316            ("send, uninitialized", Uninitialized, SendRecord, false),
3317            ("send, ready", Ready, SendRecord, false),
3318            ("send, in transaction", InTransaction, SendRecord, true),
3319            (
3320                "send_offsets_to_transaction, uninitialized",
3321                Uninitialized,
3322                SendOffsets,
3323                false,
3324            ),
3325            (
3326                "send_offsets_to_transaction, ready",
3327                Ready,
3328                SendOffsets,
3329                false,
3330            ),
3331            (
3332                "send_offsets_to_transaction, in transaction",
3333                InTransaction,
3334                SendOffsets,
3335                true,
3336            ),
3337        ];
3338
3339        for (name, state, operation, should_succeed) in cases {
3340            let (mock, producer, coordinator) = match state {
3341                Uninitialized => scripted_producer_raw(Coordinator::default()).await,
3342                Ready | InTransaction => scripted_producer(Coordinator::default()).await,
3343            };
3344            let transaction = match state {
3345                InTransaction => Some(
3346                    producer
3347                        .begin_transaction()
3348                        .await
3349                        .expect("begin_transaction"),
3350                ),
3351                Uninitialized | Ready => None,
3352            };
3353            assert2::assert!(
3354                matches!(
3355                    (*producer.txn_state.lock().await, state),
3356                    (TxnState::Uninitialized, Uninitialized)
3357                        | (TxnState::Ready, Ready)
3358                        | (TxnState::InTransaction, InTransaction)
3359                ),
3360                "{name}"
3361            );
3362
3363            let group = krabka_client_consumer::ConsumerGroupMetadata {
3364                group_id: "group-a".into(),
3365                generation_id: 3,
3366                member_id: "member-a".into(),
3367                group_instance_id: None,
3368            };
3369            let result = match operation {
3370                SendRecord => producer
3371                    .send(ProducerRecord {
3372                        topic: "topic".into(),
3373                        value: Some(bytes::Bytes::from_static(b"v")),
3374                        ..Default::default()
3375                    })
3376                    .await
3377                    .map(|_| ()),
3378                SendOffsets => {
3379                    producer
3380                        .send_offsets_to_transaction([(("topic".to_owned(), 0), 5)], &group)
3381                        .await
3382                }
3383            };
3384
3385            assert2::assert!(result.is_ok() == should_succeed, "{name}: {result:?}");
3386            if !should_succeed {
3387                assert2::assert!(
3388                    matches!(result, Err(ProducerError::InvalidTransactionState(_))),
3389                    "{name}: {result:?}"
3390                );
3391            }
3392
3393            let requests_sent = coordinator
3394                .lock()
3395                .expect("scripted coordinator")
3396                .transaction_requests
3397                .len();
3398            if should_succeed {
3399                assert2::assert!(requests_sent > 0, "{name}: expected requests to be sent");
3400            } else {
3401                assert2::assert!(requests_sent == 0, "{name}: sent {requests_sent} requests");
3402            }
3403
3404            drop(transaction);
3405            mock.stop();
3406        }
3407    }
3408}