|  | 
| 47 | 47 | import org.apache.kafka.common.header.Header; | 
| 48 | 48 | import org.apache.kafka.common.header.internals.RecordHeader; | 
| 49 | 49 | import org.apache.kafka.common.header.internals.RecordHeaders; | 
|  | 50 | +import org.jspecify.annotations.Nullable; | 
| 50 | 51 | import org.junit.jupiter.api.BeforeAll; | 
| 51 | 52 | import org.junit.jupiter.api.Test; | 
| 52 | 53 | import org.mockito.ArgumentCaptor; | 
|  | 
| 75 | 76 | import org.springframework.kafka.transaction.KafkaTransactionManager; | 
| 76 | 77 | import org.springframework.messaging.MessageHeaders; | 
| 77 | 78 | import org.springframework.transaction.TransactionDefinition; | 
|  | 79 | +import org.springframework.transaction.TransactionExecution; | 
|  | 80 | +import org.springframework.transaction.TransactionExecutionListener; | 
|  | 81 | +import org.springframework.transaction.annotation.Transactional; | 
| 78 | 82 | import org.springframework.transaction.support.DefaultTransactionDefinition; | 
| 79 | 83 | import org.springframework.util.backoff.FixedBackOff; | 
| 80 | 84 | 
 | 
| @@ -140,6 +144,8 @@ public class TransactionalContainerTests { | 
| 140 | 144 | 
 | 
| 141 | 145 | 	public static final String topic10 = "txTopic10"; | 
| 142 | 146 | 
 | 
|  | 147 | +	public static final String topic11 = "txTopic11"; | 
|  | 148 | + | 
| 143 | 149 | 	private static EmbeddedKafkaBroker embeddedKafka; | 
| 144 | 150 | 
 | 
| 145 | 151 | 	@BeforeAll | 
| @@ -1148,4 +1154,82 @@ public void onMessage(List<ConsumerRecord<Integer, String>> data) { | 
| 1148 | 1154 | 		container.stop(); | 
| 1149 | 1155 | 	} | 
| 1150 | 1156 | 
 | 
|  | 1157 | +	@Test | 
|  | 1158 | +	void testSendOffsetOnlyOnActiveTransaction() throws InterruptedException { | 
|  | 1159 | +		// init producer | 
|  | 1160 | +		Map<String, Object> producerProperties = KafkaTestUtils.producerProps(embeddedKafka); | 
|  | 1161 | +		DefaultKafkaProducerFactory<Object, Object> pf = new DefaultKafkaProducerFactory<>(producerProperties); | 
|  | 1162 | +		pf.setTransactionIdPrefix("testSendOffsetOnlyOnActiveTransaction.recordListener"); | 
|  | 1163 | +		final KafkaTemplate<Object, Object> template = new KafkaTemplate<>(pf); | 
|  | 1164 | + | 
|  | 1165 | +		// init consumer | 
|  | 1166 | +		String group = "testSendOffsetOnlyOnActiveTransaction"; | 
|  | 1167 | +		Map<String, Object> consumerProperties = KafkaTestUtils.consumerProps(embeddedKafka, group, false); | 
|  | 1168 | +		consumerProperties.put(ConsumerConfig.ISOLATION_LEVEL_CONFIG, "read_committed"); | 
|  | 1169 | +		DefaultKafkaConsumerFactory<Integer, String> cf = new DefaultKafkaConsumerFactory<>(consumerProperties); | 
|  | 1170 | +		ContainerProperties containerProps = new ContainerProperties(topic11); | 
|  | 1171 | +		containerProps.setPollTimeout(10_000); | 
|  | 1172 | +		containerProps.setMessageListener(new MessageListener<Integer, String>() { | 
|  | 1173 | +			@Transactional("testSendOffsetOnlyOnActiveTransaction") | 
|  | 1174 | +			@Override | 
|  | 1175 | +			public void onMessage(ConsumerRecord<Integer, String> data) { | 
|  | 1176 | +			} | 
|  | 1177 | +		}); | 
|  | 1178 | + | 
|  | 1179 | +		// init container | 
|  | 1180 | +		KafkaTransactionManager<Object, Object> tm = new KafkaTransactionManager<>(pf); | 
|  | 1181 | +		AtomicInteger txCount = new AtomicInteger(0); | 
|  | 1182 | +		tm.addListener(new TransactionExecutionListener() { | 
|  | 1183 | +			@Override | 
|  | 1184 | +			public void afterCommit(TransactionExecution transaction, @Nullable Throwable commitFailure) { | 
|  | 1185 | +				txCount.incrementAndGet(); | 
|  | 1186 | +				TransactionExecutionListener.super.afterCommit(transaction, commitFailure); | 
|  | 1187 | +			} | 
|  | 1188 | +		}); | 
|  | 1189 | +		containerProps.setKafkaAwareTransactionManager(tm); | 
|  | 1190 | + | 
|  | 1191 | +		KafkaMessageListenerContainer<Integer, String> container = new KafkaMessageListenerContainer<>(cf, containerProps); | 
|  | 1192 | +		container.setBeanName("testSendOffsetOnlyOnActiveTransaction"); | 
|  | 1193 | +		final var interceptorLatch = new AtomicReference<>(new CountDownLatch(1)); | 
|  | 1194 | +		container.setRecordInterceptor(new RecordInterceptor<Integer, String>() { | 
|  | 1195 | +			boolean isFirst = true; | 
|  | 1196 | + | 
|  | 1197 | +			@Override | 
|  | 1198 | +			public @Nullable ConsumerRecord<Integer, String> intercept( | 
|  | 1199 | +					ConsumerRecord<Integer, String> record, | 
|  | 1200 | +					Consumer<Integer, String> consumer) { | 
|  | 1201 | +				if (isFirst) { | 
|  | 1202 | +					isFirst = false; | 
|  | 1203 | +					return record; | 
|  | 1204 | +				} | 
|  | 1205 | +				return null; | 
|  | 1206 | +			} | 
|  | 1207 | + | 
|  | 1208 | +			@Override | 
|  | 1209 | +			public void afterRecord( | 
|  | 1210 | +					ConsumerRecord<Integer, String> record, | 
|  | 1211 | +					Consumer<Integer, String> consumer) { | 
|  | 1212 | +				interceptorLatch.get().countDown(); | 
|  | 1213 | +			} | 
|  | 1214 | +		}); | 
|  | 1215 | +		container.start(); | 
|  | 1216 | + | 
|  | 1217 | +		template.executeInTransaction(t -> { | 
|  | 1218 | +			template.send(new ProducerRecord<>(topic11, 0, 0, "bar1")); | 
|  | 1219 | +			return null; | 
|  | 1220 | +		}); | 
|  | 1221 | +		assertThat(interceptorLatch.get().await(30, TimeUnit.SECONDS)).isTrue(); | 
|  | 1222 | +		assertThat(txCount.get()).isEqualTo(1); | 
|  | 1223 | + | 
|  | 1224 | +		interceptorLatch.set(new CountDownLatch(1)); | 
|  | 1225 | +		template.executeInTransaction(t -> { | 
|  | 1226 | +			template.send(new ProducerRecord<>(topic11, 0, 0, "bar2")); | 
|  | 1227 | +			return null; | 
|  | 1228 | +		}); | 
|  | 1229 | +		assertThat(interceptorLatch.get().await(30, TimeUnit.SECONDS)).isTrue(); | 
|  | 1230 | +		assertThat(txCount.get()).isEqualTo(1); | 
|  | 1231 | + | 
|  | 1232 | +		container.stop(); | 
|  | 1233 | +		pf.destroy(); | 
|  | 1234 | +	} | 
| 1151 | 1235 | } | 
0 commit comments