1use std::{
6 fmt,
7 str::FromStr,
8 sync::{
9 Arc,
10 atomic::{AtomicI32, Ordering},
11 },
12};
13
14use crate::{error::ProducerError, producer::Producer};
15
16const NO_TXN_ERROR: i32 = i32::MIN;
19
20const ABORTABLE_TIMEOUT: i32 = i32::MIN + 1;
25
26const FATAL_FENCED: i32 = i32::MIN + 2;
28
29const FATAL_CODE_OFFSET: i32 = 0x1_8000;
33
34const FATAL_CODE_MIN: i32 = 0x1_0000;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum AbortableError {
51 Server(i16),
53 Timeout,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum FatalError {
69 Fenced,
72 Server(i16),
74}
75
76impl FatalError {
77 pub(crate) const fn state(self) -> TxnState {
79 match self {
80 Self::Fenced => TxnState::Fenced,
81 Self::Server(_) => TxnState::FatalError,
82 }
83 }
84
85 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#[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 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 pub(crate) fn set_abortable(&self, code: i16) {
123 self.store_abortable(i32::from(code));
124 }
125
126 pub(crate) fn set_abortable_timeout(&self) {
130 self.store_abortable(ABORTABLE_TIMEOUT);
131 }
132
133 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 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 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 pub(crate) fn clear_abortable(&self) {
167 self.store_abortable(NO_TXN_ERROR);
168 }
169}
170
171const 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 Uninitialized,
181 Ready,
183 InTransaction,
185 Preparing,
187 Prepared,
189 Initializing,
191 CommittingOrAborting,
193 RecoveryRequired,
196 Fenced,
200 FatalError,
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
212pub struct PreparedTransactionState {
213 producer_id: i64,
214 producer_epoch: i16,
215}
216
217impl PreparedTransactionState {
218 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 #[must_use]
239 pub const fn producer_id(self) -> i64 {
240 self.producer_id
241 }
242
243 #[must_use]
245 pub const fn producer_epoch(self) -> i16 {
246 self.producer_epoch
247 }
248
249 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
301#[error("invalid prepared transaction state; expected producer_id:producer_epoch")]
302pub struct PreparedTransactionStateParseError;
303
304#[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 pub async fn prepare(&self) -> Result<PreparedTransactionState, ProducerError> {
338 self.producer.prepare_transaction().await
339 }
340
341 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 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#[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 pub async fn prepare(&self) -> Result<PreparedTransactionState, ProducerError> {
414 self.producer.prepare_transaction().await
415 }
416
417 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 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#[derive(Debug, thiserror::Error)]
474#[error("{source}")]
475pub struct EndTransactionError<T> {
476 pub transaction: T,
480 #[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 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 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 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 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 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 #[derive(Debug, Clone, Copy)]
956 enum Reply {
957 Silent,
959 Code(i16),
961 }
962
963 #[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 #[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 txn_lookup: Script,
1003 group_lookup: Script,
1005 find_coordinator_requests: usize,
1006 init_producer_id_requests: usize,
1008 metadata_requests: usize,
1010 add_partitions_versions: Vec<i16>,
1012 add_partitions_range: Option<(i16, i16)>,
1015 two_phase_commit: bool,
1018 linger: Option<Duration>,
1020 transaction_version: Option<i16>,
1025 transaction_requests: Vec<(i16, i16)>,
1028 txn_offset_commit_max: Option<i16>,
1031 topic_id: Uuid,
1033 txn_offset_commit_topics: Vec<(i16, Vec<TxnOffsetCommitRequestTopic>)>,
1036 }
1037
1038 type SharedCoordinator = Arc<Mutex<Coordinator>>;
1039
1040 #[derive(Debug, PartialEq, Eq)]
1042 enum TxnResult {
1043 Ok,
1044 Fenced,
1045 ConcurrentTransactions,
1046 Server(i16),
1047 Fatal(i16),
1049 OutcomeUnknown,
1050 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[derive(Debug, PartialEq, Eq)]
1754 struct ScriptedAddPartitionsVersion {
1755 result: TxnResult,
1756 versions: Vec<i16>,
1757 }
1758
1759 #[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 #[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 #[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 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 fn lookups(coordinator: &Coordinator) -> usize {
2017 coordinator.find_coordinator_requests.min(MANY_LOOKUPS)
2018 }
2019
2020 const MANY_LOOKUPS: usize = 5;
2023
2024 #[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 #[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 #[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 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 #[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 #[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 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 #[derive(Debug, PartialEq, Eq)]
2439 struct SentOffsetCommits {
2440 result: TxnResult,
2441 requests: Vec<(i16, Vec<TxnOffsetCommitRequestTopic>)>,
2444 }
2445
2446 #[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 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 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 #[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 #[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 #[derive(Debug, PartialEq, Eq)]
2637 struct PreparedSend {
2638 outcome: Result<(), String>,
2639 metadata_requests: usize,
2640 }
2641
2642 #[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 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 #[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 #[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 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 #[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 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 #[derive(Debug, PartialEq, Eq)]
2898 struct PrepareAfterFailedFlush {
2899 record: TxnResult,
2900 prepare: TxnResult,
2901 state: TxnState,
2902 }
2903
2904 #[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 produce: Script::new(&[Reply::Code(42)], Reply::Code(0)),
2912 two_phase_commit: true,
2913 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 #[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 #[derive(Debug, Clone, Copy)]
2977 enum Trigger {
2978 Send,
2979 SendOffsets,
2980 Commit,
2981 }
2982
2983 #[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 #[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 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 #[derive(Debug, PartialEq, Eq)]
3195 struct RejectedCoordinator {
3196 rejected: bool,
3197 rejecting_handshakes: usize,
3198 state: TxnState,
3199 }
3200
3201 #[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 #[derive(Debug, Clone, Copy)]
3290 enum StartState {
3291 Uninitialized,
3293 Ready,
3295 InTransaction,
3297 }
3298
3299 #[derive(Debug, Clone, Copy)]
3300 enum Operation {
3301 SendRecord,
3302 SendOffsets,
3303 }
3304
3305 #[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}