GH-1508: Fix DefaultARProcessor for EOSMode.BETA
Resolves https://github.com/spring-projects/spring-kafka/issues/1508 - also validate configuration when `commitRecovered` is `true`; deprecate setters.
This commit is contained in:
committed by
Artem Bilan
parent
76f8819965
commit
7974331b33
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2018-2019 the original author or authors.
|
||||
* Copyright 2018-2020 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -21,6 +21,8 @@ import java.util.List;
|
||||
import org.apache.kafka.clients.consumer.Consumer;
|
||||
import org.apache.kafka.clients.consumer.ConsumerRecord;
|
||||
|
||||
import org.springframework.kafka.listener.ContainerProperties.EOSMode;
|
||||
|
||||
/**
|
||||
* Invoked by a listener container with remaining, unprocessed, records
|
||||
* (including the failed record). Implementations should seek the desired
|
||||
@@ -55,9 +57,37 @@ public interface AfterRollbackProcessor<K, V> {
|
||||
* @param recoverable the recoverable.
|
||||
* @since 2.2
|
||||
* @see #isProcessInTransaction()
|
||||
* @deprecated in favor of {@link #process(List, Consumer, Exception, boolean,
|
||||
* EOSMode)}.
|
||||
*/
|
||||
@Deprecated
|
||||
void process(List<ConsumerRecord<K, V>> records, Consumer<K, V> consumer, Exception exception, boolean recoverable);
|
||||
|
||||
/**
|
||||
* Process the remaining records. Recoverable will be true if the container is
|
||||
* processing individual records; this allows the processor to recover (skip) the
|
||||
* failed record rather than re-seeking it. This is not possible with a batch listener
|
||||
* since only the listener itself knows which record in the batch keeps failing.
|
||||
* IMPORTANT: If invoked in a transaction when the listener was invoked with a single
|
||||
* record, the transaction id will be based on the container group.id and the
|
||||
* topic/partition of the failed record, to avoid issues with zombie fencing (unless
|
||||
* the {@link EOSMode} is {@link EOSMode#BETA}). So, generally, only its offset should
|
||||
* be sent to the transaction. For other behavior the process method should manage its
|
||||
* own transaction.
|
||||
* @param records the records.
|
||||
* @param consumer the consumer.
|
||||
* @param exception the exception
|
||||
* @param recoverable the recoverable.
|
||||
* @param eosMode the {@link EOSMode}.
|
||||
* @since 2.5.3
|
||||
* @see #isProcessInTransaction()
|
||||
*/
|
||||
default void process(List<ConsumerRecord<K, V>> records, Consumer<K, V> consumer, Exception exception,
|
||||
boolean recoverable, EOSMode eosMode) {
|
||||
|
||||
process(records, consumer, exception, recoverable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional method to clear thread state; will be called just before a consumer
|
||||
* thread terminates.
|
||||
@@ -68,13 +98,13 @@ public interface AfterRollbackProcessor<K, V> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true to invoke {@link #process(List, Consumer, Exception, boolean)} in a new
|
||||
* transaction. Because the container cannot infer the desired behavior, the processor
|
||||
* is responsible for sending the offset to the transaction if it decides to skip the
|
||||
* failing record.
|
||||
* Return true to invoke {@link #process(List, Consumer, Exception, boolean, EOSMode)}
|
||||
* in a new transaction. Because the container cannot infer the desired behavior, the
|
||||
* processor is responsible for sending the offset to the transaction if it decides to
|
||||
* skip the failing record.
|
||||
* @return true to run in a transaction; default false.
|
||||
* @since 2.2.5
|
||||
* @see #process(List, Consumer, Exception, boolean)
|
||||
* @see #process(List, Consumer, Exception, boolean, EOSMode)
|
||||
*/
|
||||
default boolean isProcessInTransaction() {
|
||||
return false;
|
||||
|
||||
@@ -25,9 +25,12 @@ import org.apache.kafka.clients.consumer.ConsumerRecord;
|
||||
import org.apache.kafka.clients.consumer.OffsetAndMetadata;
|
||||
import org.apache.kafka.common.TopicPartition;
|
||||
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.kafka.core.KafkaOperations;
|
||||
import org.springframework.kafka.core.KafkaTemplate;
|
||||
import org.springframework.kafka.listener.ContainerProperties.EOSMode;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.backoff.BackOff;
|
||||
|
||||
/**
|
||||
@@ -46,9 +49,10 @@ import org.springframework.util.backoff.BackOff;
|
||||
* @since 1.3.5
|
||||
*
|
||||
*/
|
||||
public class DefaultAfterRollbackProcessor<K, V> extends FailedRecordProcessor implements AfterRollbackProcessor<K, V> {
|
||||
public class DefaultAfterRollbackProcessor<K, V> extends FailedRecordProcessor
|
||||
implements AfterRollbackProcessor<K, V>, InitializingBean {
|
||||
|
||||
private KafkaOperations<K, V> kafkaTemplate;
|
||||
private KafkaOperations<?, ?> kafkaTemplate;
|
||||
|
||||
/**
|
||||
* Construct an instance with the default recoverer which simply logs the record after
|
||||
@@ -91,21 +95,66 @@ public class DefaultAfterRollbackProcessor<K, V> extends FailedRecordProcessor i
|
||||
public DefaultAfterRollbackProcessor(@Nullable BiConsumer<ConsumerRecord<?, ?>, Exception> recoverer,
|
||||
BackOff backOff) {
|
||||
|
||||
this(recoverer, backOff, null, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct an instance with the provided recoverer which will be called after the
|
||||
* backOff returns STOP for a topic/partition/offset.
|
||||
* @param recoverer the recoverer; if null, the default (logging) recoverer is used.
|
||||
* @param backOff the {@link BackOff}.
|
||||
* @param kafkaOperations for sending the recovered offset to the transaction.
|
||||
* @param commitRecovered true to commit the recovered record's offset; requires a
|
||||
* {@link KafkaOperations}.
|
||||
* @since 2.5.3
|
||||
*/
|
||||
public DefaultAfterRollbackProcessor(@Nullable BiConsumer<ConsumerRecord<?, ?>, Exception> recoverer,
|
||||
BackOff backOff, @Nullable KafkaOperations<?, ?> kafkaOperations, boolean commitRecovered) {
|
||||
|
||||
super(recoverer, backOff);
|
||||
this.kafkaTemplate = kafkaOperations;
|
||||
super.setCommitRecovered(commitRecovered);
|
||||
checkConfig();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterPropertiesSet() {
|
||||
// remove InitializingBean when the deprecated setters are removed.
|
||||
checkConfig();
|
||||
}
|
||||
|
||||
private void checkConfig() {
|
||||
Assert.isTrue(!isCommitRecovered() || this.kafkaTemplate != null,
|
||||
"A KafkaOperations is required when 'commitRecovered' is true");
|
||||
}
|
||||
|
||||
@Override
|
||||
@Deprecated
|
||||
public void process(List<ConsumerRecord<K, V>> records, Consumer<K, V> consumer, Exception exception,
|
||||
boolean recoverable) {
|
||||
|
||||
process(records, consumer, exception, recoverable, EOSMode.ALPHA);
|
||||
}
|
||||
|
||||
@SuppressWarnings({ "unchecked", "rawtypes" })
|
||||
@Override
|
||||
public void process(List<ConsumerRecord<K, V>> records, Consumer<K, V> consumer, Exception exception,
|
||||
boolean recoverable) {
|
||||
boolean recoverable, @Nullable EOSMode eosMode) {
|
||||
|
||||
if (SeekUtils.doSeeks(((List) records), consumer, exception, recoverable,
|
||||
getSkipPredicate((List) records, exception), this.logger)
|
||||
&& isCommitRecovered() && this.kafkaTemplate != null && this.kafkaTemplate.isTransactional()) {
|
||||
&& isCommitRecovered() && this.kafkaTemplate.isTransactional()) {
|
||||
ConsumerRecord<K, V> skipped = records.get(0);
|
||||
this.kafkaTemplate.sendOffsetsToTransaction(
|
||||
Collections.singletonMap(new TopicPartition(skipped.topic(), skipped.partition()),
|
||||
new OffsetAndMetadata(skipped.offset() + 1)));
|
||||
if (EOSMode.ALPHA.equals(eosMode)) {
|
||||
this.kafkaTemplate.sendOffsetsToTransaction(
|
||||
Collections.singletonMap(new TopicPartition(skipped.topic(), skipped.partition()),
|
||||
new OffsetAndMetadata(skipped.offset() + 1)));
|
||||
}
|
||||
else {
|
||||
this.kafkaTemplate.sendOffsetsToTransaction(
|
||||
Collections.singletonMap(new TopicPartition(skipped.topic(), skipped.partition()),
|
||||
new OffsetAndMetadata(skipped.offset() + 1)), consumer.groupMetadata());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,29 +164,32 @@ public class DefaultAfterRollbackProcessor<K, V> extends FailedRecordProcessor i
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
* Set to true and the container will run the
|
||||
* {@link #process(List, Consumer, Exception, boolean)} method in a transaction and,
|
||||
* if a record is skipped and recovered, we will send its offset to the transaction.
|
||||
* Requires a {@link KafkaOperations}.
|
||||
* {@inheritDoc} Set to true and the container will run the {@link #process(List,
|
||||
* Consumer, Exception, boolean, EOSMode)} method in a transaction and, if a record
|
||||
* is skipped and recovered, we will send its offset to the transaction. Requires a
|
||||
* {@link KafkaOperations}.
|
||||
* @param commitRecovered true to process in a transaction.
|
||||
* @since 2.3
|
||||
* @deprecated in favor of
|
||||
* {@link #DefaultAfterRollbackProcessor(BiConsumer, BackOff, KafkaOperations, boolean)}.
|
||||
* @see #isProcessInTransaction()
|
||||
* @see #process(List, Consumer, Exception, boolean)
|
||||
* @see #setKafkaOperations(KafkaOperations)
|
||||
* @see #process(List, Consumer, Exception, boolean, EOSMode)
|
||||
*/
|
||||
@Deprecated
|
||||
@Override
|
||||
public void setCommitRecovered(boolean commitRecovered) { // NOSONAR enhanced javadoc
|
||||
super.setCommitRecovered(commitRecovered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a {@link KafkaTemplate} to use to send the offset of a recovered record
|
||||
* to a transaction.
|
||||
* Set a {@link KafkaTemplate} to use to send the offset of a recovered record to a
|
||||
* transaction.
|
||||
* @param kafkaTemplate the template.
|
||||
* @since 2.2.5
|
||||
* @deprecated in favor of
|
||||
* {@link #DefaultAfterRollbackProcessor(BiConsumer, BackOff, KafkaOperations,
|
||||
* boolean)}.
|
||||
* @see #setCommitRecovered(boolean)
|
||||
* @deprecated in favor of {@link #setKafkaOperations(KafkaOperations)}.
|
||||
*/
|
||||
@Deprecated
|
||||
public void setKafkaTemplate(KafkaTemplate<K, V> kafkaTemplate) {
|
||||
@@ -145,12 +197,16 @@ public class DefaultAfterRollbackProcessor<K, V> extends FailedRecordProcessor i
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a {@link KafkaOperations} to use to send the offset of a recovered record
|
||||
* to a transaction.
|
||||
* Set a {@link KafkaOperations} to use to send the offset of a recovered record to a
|
||||
* transaction.
|
||||
* @param kafkaOperations the operations.
|
||||
* @since 2.5.1
|
||||
* @deprecated in favor of
|
||||
* {@link #DefaultAfterRollbackProcessor(BiConsumer, BackOff, KafkaOperations,
|
||||
* boolean)}.
|
||||
* @see #setCommitRecovered(boolean)
|
||||
*/
|
||||
@Deprecated
|
||||
public void setKafkaOperations(KafkaOperations<K, V> kafkaOperations) {
|
||||
this.kafkaTemplate = kafkaOperations;
|
||||
}
|
||||
|
||||
@@ -1430,10 +1430,11 @@ public class KafkaMessageListenerContainer<K, V> // NOSONAR line count
|
||||
try {
|
||||
if (recordList == null) {
|
||||
afterRollbackProcessorToUse.process(createRecordList(records), this.consumer, rollbackException,
|
||||
false);
|
||||
false, this.eosMode);
|
||||
}
|
||||
else {
|
||||
afterRollbackProcessorToUse.process(recordList, this.consumer, rollbackException, false);
|
||||
afterRollbackProcessorToUse.process(recordList, this.consumer, rollbackException, false,
|
||||
this.eosMode);
|
||||
}
|
||||
}
|
||||
catch (KafkaException ke) {
|
||||
@@ -1690,14 +1691,15 @@ public class KafkaMessageListenerContainer<K, V> // NOSONAR line count
|
||||
|
||||
@Override
|
||||
protected void doInTransactionWithoutResult(TransactionStatus status) {
|
||||
afterRollbackProcessorToUse.process(unprocessed, ListenerConsumer.this.consumer, e, true);
|
||||
afterRollbackProcessorToUse.process(unprocessed, ListenerConsumer.this.consumer, e, true,
|
||||
ListenerConsumer.this.eosMode);
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
else {
|
||||
try {
|
||||
afterRollbackProcessorToUse.process(unprocessed, this.consumer, e, true);
|
||||
afterRollbackProcessorToUse.process(unprocessed, this.consumer, e, true, this.eosMode);
|
||||
}
|
||||
catch (KafkaException ke) {
|
||||
ke.selfLog("AfterRollbackProcessor threw an exception", this.logger);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2019 the original author or authors.
|
||||
* Copyright 2019-2020 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -17,10 +17,12 @@
|
||||
package org.springframework.kafka.listener;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyMap;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
@@ -30,12 +32,14 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.apache.kafka.clients.consumer.Consumer;
|
||||
import org.apache.kafka.clients.consumer.ConsumerGroupMetadata;
|
||||
import org.apache.kafka.clients.consumer.ConsumerRecord;
|
||||
import org.apache.kafka.common.TopicPartition;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InOrder;
|
||||
|
||||
import org.springframework.kafka.core.KafkaTemplate;
|
||||
import org.springframework.kafka.core.KafkaOperations;
|
||||
import org.springframework.kafka.listener.ContainerProperties.EOSMode;
|
||||
import org.springframework.kafka.support.serializer.DeserializationException;
|
||||
|
||||
/**
|
||||
@@ -49,40 +53,42 @@ public class DefaultAfterRollbackProcessorTests {
|
||||
public void testClassifier() {
|
||||
AtomicReference<ConsumerRecord<?, ?>> recovered = new AtomicReference<>();
|
||||
AtomicBoolean recovererShouldFail = new AtomicBoolean(false);
|
||||
@SuppressWarnings("unchecked")
|
||||
KafkaOperations<String, String> template = mock(KafkaOperations.class);
|
||||
given(template.isTransactional()).willReturn(true);
|
||||
DefaultAfterRollbackProcessor<String, String> processor = new DefaultAfterRollbackProcessor<>((r, t) -> {
|
||||
if (recovererShouldFail.getAndSet(false)) {
|
||||
throw new RuntimeException("test recoverer failure");
|
||||
}
|
||||
recovered.set(r);
|
||||
});
|
||||
@SuppressWarnings("unchecked")
|
||||
KafkaTemplate<String, String> template = mock(KafkaTemplate.class);
|
||||
given(template.isTransactional()).willReturn(true);
|
||||
processor.setKafkaOperations(template);
|
||||
processor.setCommitRecovered(true);
|
||||
}, SeekUtils.DEFAULT_BACK_OFF, template, true);
|
||||
ConsumerRecord<String, String> record1 = new ConsumerRecord<>("foo", 0, 0L, "foo", "bar");
|
||||
ConsumerRecord<String, String> record2 = new ConsumerRecord<>("foo", 1, 1L, "foo", "bar");
|
||||
List<ConsumerRecord<String, String>> records = Arrays.asList(record1, record2);
|
||||
IllegalStateException illegalState = new IllegalStateException();
|
||||
@SuppressWarnings("unchecked")
|
||||
Consumer<String, String> consumer = mock(Consumer.class);
|
||||
processor.process(records, consumer, illegalState, true);
|
||||
processor.process(records, consumer, new DeserializationException("intended", null, false, illegalState), true);
|
||||
given(consumer.groupMetadata()).willReturn(new ConsumerGroupMetadata("foo"));
|
||||
processor.process(records, consumer, illegalState, true, EOSMode.ALPHA);
|
||||
processor.process(records, consumer, new DeserializationException("intended", null, false, illegalState), true,
|
||||
EOSMode.ALPHA);
|
||||
verify(template).sendOffsetsToTransaction(anyMap());
|
||||
verify(template, never()).sendOffsetsToTransaction(anyMap(), any(ConsumerGroupMetadata.class));
|
||||
assertThat(recovered.get()).isSameAs(record1);
|
||||
processor.addNotRetryableException(IllegalStateException.class);
|
||||
recovered.set(null);
|
||||
recovererShouldFail.set(true);
|
||||
processor.process(records, consumer, illegalState, true);
|
||||
verify(template).sendOffsetsToTransaction(anyMap()); // recovery failed
|
||||
processor.process(records, consumer, illegalState, true);
|
||||
verify(template, times(2)).sendOffsetsToTransaction(anyMap());
|
||||
processor.process(records, consumer, illegalState, true, EOSMode.ALPHA);
|
||||
verify(template, times(1)).sendOffsetsToTransaction(anyMap()); // recovery failed
|
||||
processor.process(records, consumer, illegalState, true, EOSMode.BETA);
|
||||
verify(template, times(1)).sendOffsetsToTransaction(anyMap(), any(ConsumerGroupMetadata.class));
|
||||
assertThat(recovered.get()).isSameAs(record1);
|
||||
InOrder inOrder = inOrder(consumer);
|
||||
inOrder.verify(consumer).seek(new TopicPartition("foo", 0), 0L); // not recovered so seek
|
||||
inOrder.verify(consumer, times(2)).seek(new TopicPartition("foo", 1), 1L);
|
||||
inOrder.verify(consumer).seek(new TopicPartition("foo", 0), 0L); // recovery failed
|
||||
inOrder.verify(consumer, times(2)).seek(new TopicPartition("foo", 1), 1L);
|
||||
inOrder.verify(consumer).groupMetadata();
|
||||
inOrder.verifyNoMoreInteractions();
|
||||
}
|
||||
|
||||
|
||||
@@ -571,7 +571,7 @@ public class TransactionalContainerTests {
|
||||
consumer.close();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@SuppressWarnings({ "unchecked", "deprecation" })
|
||||
@Test
|
||||
public void testMaxFailures() throws Exception {
|
||||
logger.info("Start testMaxFailures");
|
||||
@@ -619,9 +619,7 @@ public class TransactionalContainerTests {
|
||||
|
||||
};
|
||||
DefaultAfterRollbackProcessor<Object, Object> afterRollbackProcessor =
|
||||
spy(new DefaultAfterRollbackProcessor<>(recoverer, new FixedBackOff(0L, 2L)));
|
||||
afterRollbackProcessor.setCommitRecovered(true);
|
||||
afterRollbackProcessor.setKafkaOperations(dlTemplate);
|
||||
spy(new DefaultAfterRollbackProcessor<>(recoverer, new FixedBackOff(0L, 2L), dlTemplate, true));
|
||||
container.setAfterRollbackProcessor(afterRollbackProcessor);
|
||||
final CountDownLatch stopLatch = new CountDownLatch(1);
|
||||
container.setApplicationEventPublisher(e -> {
|
||||
@@ -666,7 +664,8 @@ public class TransactionalContainerTests {
|
||||
assertThat(stopLatch.await(10, TimeUnit.SECONDS)).isTrue();
|
||||
verify(afterRollbackProcessor, times(4)).isProcessInTransaction();
|
||||
ArgumentCaptor<Exception> captor = ArgumentCaptor.forClass(Exception.class);
|
||||
verify(afterRollbackProcessor, times(4)).process(any(), any(), captor.capture(), anyBoolean());
|
||||
verify(afterRollbackProcessor, never()).process(any(), any(), captor.capture(), anyBoolean());
|
||||
verify(afterRollbackProcessor, times(4)).process(any(), any(), captor.capture(), anyBoolean(), any());
|
||||
assertThat(captor.getValue()).isInstanceOf(ListenerExecutionFailedException.class)
|
||||
.extracting(ex -> ((ListenerExecutionFailedException) ex).getGroupId())
|
||||
.isEqualTo("groupInARBP");
|
||||
@@ -794,7 +793,7 @@ public class TransactionalContainerTests {
|
||||
inOrder.verifyNoMoreInteractions();
|
||||
assertThat(deliveryCount.get()).isEqualTo(1);
|
||||
|
||||
verify(arp, never()).process(any(), any(), any(), anyBoolean());
|
||||
verify(arp, never()).process(any(), any(), any(), anyBoolean(), any());
|
||||
|
||||
container.stop();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user