GH-987: DeserializationException and dead-letters

Resolves https://github.com/spring-projects/spring-kafka/issues/987

`DeadLetterPublishingRecoverer` now detects `DeserializationException`s
and sets the value of the republished record to the original incoming
`byte[]`.

- STCEH - fix `defaultClassifier` to traverse exception causes
This commit is contained in:
Gary Russell
2019-03-04 16:41:52 -05:00
committed by Artem Bilan
parent d31e4d6d1b
commit 6e702f24ca
7 changed files with 222 additions and 58 deletions

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2018 the original author or authors.
* Copyright 2018-2019 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.
@@ -20,6 +20,9 @@ import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.Map;
import java.util.Optional;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
@@ -34,7 +37,11 @@ import org.apache.kafka.common.header.internals.RecordHeaders;
import org.springframework.kafka.core.KafkaOperations;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.support.KafkaHeaders;
import org.springframework.kafka.support.serializer.DeserializationException;
import org.springframework.kafka.support.serializer.ErrorHandlingDeserializer2;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
/**
* A {@link BiConsumer} that publishes a failed record to a dead-letter topic.
@@ -47,8 +54,13 @@ public class DeadLetterPublishingRecoverer implements BiConsumer<ConsumerRecord<
private static final Log logger = LogFactory.getLog(DeadLetterPublishingRecoverer.class); // NOSONAR
private static final BiFunction<ConsumerRecord<?, ?>, Exception, TopicPartition>
DEFAULT_DESTINATION_RESOLVER = (cr, e) -> new TopicPartition(cr.topic() + ".DLT", cr.partition());
private final KafkaTemplate<Object, Object> template;
private final Map<Class<?>, KafkaTemplate<?, ?>> templates;
private final boolean transactional;
private final BiFunction<ConsumerRecord<?, ?>, Exception, TopicPartition> destinationResolver;
@@ -60,25 +72,65 @@ public class DeadLetterPublishingRecoverer implements BiConsumer<ConsumerRecord<
* dead-letter topic must have at least as many partitions as the original topic.
* @param template the {@link KafkaTemplate} to use for publishing.
*/
public DeadLetterPublishingRecoverer(KafkaTemplate<Object, Object> template) {
this(template, (cr, e) -> new TopicPartition(cr.topic() + ".DLT", cr.partition()));
public DeadLetterPublishingRecoverer(KafkaTemplate<? extends Object, ? extends Object> template) {
this(template, DEFAULT_DESTINATION_RESOLVER);
}
/**
* Create an instance with the provided template and destination resolving function,
* that receives the failed consumer record and the exception and returns a
* {@link TopicPartition}. If the partition in the {@link TopicPartition} is less than 0, no
* partition is set when publishing to the topic.
* {@link TopicPartition}. If the partition in the {@link TopicPartition} is less than
* 0, no partition is set when publishing to the topic.
* @param template the {@link KafkaTemplate} to use for publishing.
* @param destinationResolver the resolving function.
*/
public DeadLetterPublishingRecoverer(KafkaTemplate<Object, Object> template,
public DeadLetterPublishingRecoverer(KafkaTemplate<? extends Object, ? extends Object> template,
BiFunction<ConsumerRecord<?, ?>, Exception, TopicPartition> destinationResolver) {
this(Collections.singletonMap(Object.class, template), destinationResolver);
}
/**
* Create an instance with the provided templates and a default destination resolving
* function that returns a TopicPartition based on the original topic (appended with
* ".DLT") from the failed record, and the same partition as the failed record.
* Therefore the dead-letter topic must have at least as many partitions as the
* original topic. The templates map keys are classes and the value the corresponding
* template to use for objects (producer record values) of that type. A
* {@link java.util.LinkedHashMap} is recommended when there is more than one
* template, to ensure the map is traversed in order.
* @param templates the {@link KafkaTemplate}s to use for publishing.
*/
public DeadLetterPublishingRecoverer(Map<Class<?>, KafkaTemplate<? extends Object, ? extends Object>> templates) {
this(templates, DEFAULT_DESTINATION_RESOLVER);
}
/**
* Create an instance with the provided templates and destination resolving function,
* that receives the failed consumer record and the exception and returns a
* {@link TopicPartition}. If the partition in the {@link TopicPartition} is less than
* 0, no partition is set when publishing to the topic. The templates map keys are
* classes and the value the corresponding template to use for objects (producer
* record values) of that type. A {@link java.util.LinkedHashMap} is recommended when
* there is more than one template, to ensure the map is traversed in order.
* @param templates the {@link KafkaTemplate}s to use for publishing.
* @param destinationResolver the resolving function.
*/
@SuppressWarnings("unchecked")
public DeadLetterPublishingRecoverer(Map<Class<?>, KafkaTemplate<? extends Object, ? extends Object>> templates,
BiFunction<ConsumerRecord<?, ?>, Exception, TopicPartition> destinationResolver) {
Assert.notNull(template, "The template cannot be null");
Assert.isTrue(!ObjectUtils.isEmpty(templates), "At least one template is required");
Assert.notNull(destinationResolver, "The destinationResolver cannot be null");
this.template = template;
this.transactional = template.isTransactional();
this.template = templates.size() == 1 ? (KafkaTemplate<Object, Object>) templates.values().iterator().next() : null;
this.templates = templates;
this.transactional = templates.values().iterator().next().isTransactional();
Boolean tx = this.transactional;
Assert.isTrue(!templates.values()
.stream()
.map(t -> t.isTransactional())
.filter(t -> !t.equals(tx))
.findFirst()
.isPresent(), "All templates must have the same setting for transactional");
this.destinationResolver = destinationResolver;
}
@@ -87,42 +139,72 @@ public class DeadLetterPublishingRecoverer implements BiConsumer<ConsumerRecord<
TopicPartition tp = this.destinationResolver.apply(record, exception);
RecordHeaders headers = new RecordHeaders(record.headers().toArray());
enhanceHeaders(headers, record, exception);
ProducerRecord<Object, Object> outRecord = createProducerRecord(record, tp, headers);
if (this.transactional && !this.template.inTransaction()) {
this.template.executeInTransaction(t -> {
DeserializationException deserEx = ListenerUtils.getExceptionFromHeader(record,
ErrorHandlingDeserializer2.VALUE_DESERIALIZER_EXCEPTION_HEADER, logger);
if (deserEx == null) {
deserEx = ListenerUtils.getExceptionFromHeader(record,
ErrorHandlingDeserializer2.KEY_DESERIALIZER_EXCEPTION_HEADER, logger);
}
ProducerRecord<Object, Object> outRecord = createProducerRecord(record, tp, headers,
deserEx == null ? null : deserEx.getData());
KafkaTemplate<Object, Object> kafkaTemplate = findTemplateForValue(outRecord.value());
if (this.transactional && !kafkaTemplate.inTransaction()) {
kafkaTemplate.executeInTransaction(t -> {
publish(outRecord, t);
return null;
});
}
else {
publish(outRecord, this.template);
publish(outRecord, kafkaTemplate);
}
}
@SuppressWarnings("unchecked")
private KafkaTemplate<Object, Object> findTemplateForValue(Object value) {
if (this.template != null) {
return this.template;
}
Optional<Class<?>> key = this.templates.keySet()
.stream()
.filter((k) -> k.isAssignableFrom(value.getClass()))
.findFirst();
if (key.isPresent()) {
return (KafkaTemplate<Object, Object>) this.templates.get(key.get());
}
if (logger.isWarnEnabled()) {
logger.warn("Failed to find a template for " + value.getClass() + " attemting to use the last entry");
}
return (KafkaTemplate<Object, Object>) this.templates.values()
.stream()
.reduce((first, second) -> second)
.get();
}
/**
* Subclasses can override this method to customize the producer record to send to the DLQ.
* The default implementation simply copies the key and value from the consumer record
* and adds the headers. The timestamp is not set (the original timestamp is in one of
* the headers).
* IMPORTANT: if the partition in the {@link TopicPartition} is less than 0, it must be set to null
* in the {@link ProducerRecord}.
* Subclasses can override this method to customize the producer record to send to the
* DLQ. The default implementation simply copies the key and value from the consumer
* record and adds the headers. The timestamp is not set (the original timestamp is in
* one of the headers). IMPORTANT: if the partition in the {@link TopicPartition} is
* less than 0, it must be set to null in the {@link ProducerRecord}.
* @param record the failed record
* @param topicPartition the {@link TopicPartition} returned by the destination resolver.
* @param topicPartition the {@link TopicPartition} returned by the destination
* resolver.
* @param headers the headers - original record headers plus DLT headers.
* @param value the value to use instead of the consumer record value.
* @return the producer record to send.
* @see KafkaHeaders
*/
protected ProducerRecord<Object, Object> createProducerRecord(ConsumerRecord<?, ?> record,
TopicPartition topicPartition, RecordHeaders headers) {
TopicPartition topicPartition, RecordHeaders headers, @Nullable byte[] value) {
return new ProducerRecord<>(topicPartition.topic(),
topicPartition.partition() < 0 ? null : topicPartition.partition(),
record.key(), record.value(), headers);
record.key(), value == null ? record.value() : value, headers);
}
private void publish(ProducerRecord<Object, Object> outRecord, KafkaOperations<Object, Object> template) {
private void publish(ProducerRecord<Object, Object> outRecord, KafkaOperations<Object, Object> kafkaTemplate) {
try {
template.send(outRecord).addCallback(result -> {
kafkaTemplate.send(outRecord).addCallback(result -> {
if (logger.isDebugEnabled()) {
logger.debug("Successful dead-letter publication: " + result);
}

View File

@@ -16,9 +16,6 @@
package org.springframework.kafka.listener;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.lang.reflect.Type;
import java.time.Duration;
import java.util.ArrayList;
@@ -54,9 +51,6 @@ import org.apache.kafka.common.Metric;
import org.apache.kafka.common.MetricName;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.errors.WakeupException;
import org.apache.kafka.common.header.Header;
import org.apache.kafka.common.header.Headers;
import org.apache.kafka.common.header.internals.RecordHeaders;
import org.springframework.core.task.SimpleAsyncTaskExecutor;
import org.springframework.kafka.KafkaException;
@@ -1341,21 +1335,9 @@ public class KafkaMessageListenerContainer<K, V> // NOSONAR line count
}
public void checkDeser(final ConsumerRecord<K, V> record, String headerName) {
Header header = record.headers().lastHeader(headerName);
if (header != null) {
try {
DeserializationException ex = (DeserializationException) new ObjectInputStream(
new ByteArrayInputStream(header.value())).readObject();
Headers headers = new RecordHeaders(Arrays.stream(record.headers().toArray())
.filter(h -> !h.key()
.startsWith(ErrorHandlingDeserializer2.KEY_DESERIALIZER_EXCEPTION_HEADER_PREFIX))
.collect(Collectors.toList()));
ex.setHeaders(headers);
throw ex;
}
catch (IOException | ClassNotFoundException | ClassCastException e) {
this.logger.error("Failed to deserialize a deserialization exception", e);
}
DeserializationException exception = ListenerUtils.getExceptionFromHeader(record, headerName, this.logger);
if (exception != null) {
throw exception;
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2017 the original author or authors.
* Copyright 2017-2019 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.
@@ -16,6 +16,21 @@
package org.springframework.kafka.listener;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.util.Arrays;
import java.util.stream.Collectors;
import org.apache.commons.logging.Log;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.common.header.Header;
import org.apache.kafka.common.header.Headers;
import org.apache.kafka.common.header.internals.RecordHeaders;
import org.springframework.kafka.support.serializer.DeserializationException;
import org.springframework.kafka.support.serializer.ErrorHandlingDeserializer2;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/**
@@ -55,4 +70,36 @@ public final class ListenerUtils {
return listenerType;
}
/**
* Extract a {@link DeserializationException} from the supplied header name, if
* present.
* @param record the consumer record.
* @param headerName the header name.
* @param logger the logger for logging errors.
* @return the exception or null.
* @since 2.3
*/
@Nullable
public static DeserializationException getExceptionFromHeader(final ConsumerRecord<?, ?> record,
String headerName, Log logger) {
Header header = record.headers().lastHeader(headerName);
if (header != null) {
try {
DeserializationException ex = (DeserializationException) new ObjectInputStream(
new ByteArrayInputStream(header.value())).readObject();
Headers headers = new RecordHeaders(Arrays.stream(record.headers().toArray())
.filter(h -> !h.key()
.startsWith(ErrorHandlingDeserializer2.KEY_DESERIALIZER_EXCEPTION_HEADER_PREFIX))
.collect(Collectors.toList()));
ex.setHeaders(headers);
return ex;
}
catch (IOException | ClassNotFoundException | ClassCastException e) {
logger.error("Failed to deserialize a deserialization exception", e);
}
}
return null;
}
}

View File

@@ -241,7 +241,9 @@ public class SeekToCurrentErrorHandler implements ContainerAwareErrorHandler {
}
}
private BiPredicate<ConsumerRecord<?, ?>, Exception> getSkipPredicate(List<ConsumerRecord<?, ?>> records, Exception thrownException) {
private BiPredicate<ConsumerRecord<?, ?>, Exception> getSkipPredicate(List<ConsumerRecord<?, ?>> records,
Exception thrownException) {
if (this.classifier.classify(thrownException)) {
return this.failureTracker::skip;
}
@@ -263,7 +265,9 @@ public class SeekToCurrentErrorHandler implements ContainerAwareErrorHandler {
classified.put(MethodArgumentResolutionException.class, false);
classified.put(NoSuchMethodException.class, false);
classified.put(ClassCastException.class, false);
return new ExtendedBinaryExceptionClassifier(classified, true);
ExtendedBinaryExceptionClassifier defaultClassifier = new ExtendedBinaryExceptionClassifier(classified, true);
defaultClassifier.setTraverseCauses(true);
return defaultClassifier;
}
/**

View File

@@ -31,6 +31,7 @@ import static org.mockito.Mockito.verifyNoMoreInteractions;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
@@ -45,6 +46,8 @@ import org.apache.kafka.clients.consumer.OffsetAndMetadata;
import org.apache.kafka.clients.consumer.OffsetCommitCallback;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.serialization.ByteArraySerializer;
import org.apache.kafka.common.serialization.Serializer;
import org.junit.ClassRule;
import org.junit.Test;
@@ -54,6 +57,8 @@ import org.springframework.kafka.core.DefaultKafkaProducerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.event.ConsumerStoppedEvent;
import org.springframework.kafka.listener.ContainerProperties.AckMode;
import org.springframework.kafka.support.serializer.ErrorHandlingDeserializer2;
import org.springframework.kafka.support.serializer.JsonDeserializer;
import org.springframework.kafka.test.EmbeddedKafkaBroker;
import org.springframework.kafka.test.rule.EmbeddedKafkaRule;
import org.springframework.kafka.test.utils.KafkaTestUtils;
@@ -79,7 +84,8 @@ public class SeekToCurrentRecovererTests {
Map<String, Object> props = KafkaTestUtils.consumerProps("seekTestMaxFailures", "false", embeddedKafka);
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
props.put(ConsumerConfig.ISOLATION_LEVEL_CONFIG, "read_committed");
DefaultKafkaConsumerFactory<Integer, String> cf = new DefaultKafkaConsumerFactory<>(props);
DefaultKafkaConsumerFactory<Integer, String> cf = new DefaultKafkaConsumerFactory<>(props, null,
new ErrorHandlingDeserializer2<>(new JsonDeserializer<>(String.class)));
ContainerProperties containerProps = new ContainerProperties(topic1);
containerProps.setPollTimeout(10_000);
@@ -87,6 +93,10 @@ public class SeekToCurrentRecovererTests {
senderProps.put(ProducerConfig.RETRIES_CONFIG, 1);
DefaultKafkaProducerFactory<Object, Object> pf = new DefaultKafkaProducerFactory<>(senderProps);
final KafkaTemplate<Object, Object> template = new KafkaTemplate<>(pf);
Serializer<?> byteArraySerializer = new ByteArraySerializer();
@SuppressWarnings("unchecked")
DefaultKafkaProducerFactory<Object, Object> dltPf =
new DefaultKafkaProducerFactory<Object, Object>(senderProps, null, (Serializer<Object>) byteArraySerializer);
final CountDownLatch latch = new CountDownLatch(1);
AtomicReference<String> data = new AtomicReference<>();
containerProps.setMessageListener((MessageListener<Integer, String>) message -> {
@@ -102,8 +112,12 @@ public class SeekToCurrentRecovererTests {
container.setBeanName("testSeekMaxFailures");
final CountDownLatch recoverLatch = new CountDownLatch(1);
final AtomicReference<String> failedGroupId = new AtomicReference<>();
DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(template,
(r, e) -> new TopicPartition(topic1DLT, r.partition())) {
Map<Class<?>, KafkaTemplate<?, ?>> templates = new LinkedHashMap<>();
templates.put(String.class, template);
templates.put(byte[].class, new KafkaTemplate<>(dltPf));
DeadLetterPublishingRecoverer recoverer =
new DeadLetterPublishingRecoverer(templates,
(r, e) -> new TopicPartition(topic1DLT, r.partition())) {
@Override
public void accept(ConsumerRecord<?, ?> record, Exception exception) {
@@ -126,19 +140,28 @@ public class SeekToCurrentRecovererTests {
container.start();
template.setDefaultTopic(topic1);
template.sendDefault(0, 0, "foo");
template.sendDefault(0, 0, "bar");
template.sendDefault(0, 0, "\"foo\"");
template.sendDefault(0, 0, "\"bar\"");
assertThat(latch.await(60, TimeUnit.SECONDS)).isTrue();
assertThat(data.get()).isEqualTo("bar");
assertThat(recoverLatch.await(10, TimeUnit.SECONDS)).isTrue();
assertThat(failedGroupId.get()).isEqualTo("seekTestMaxFailures");
container.stop();
Consumer<Integer, String> consumer = cf.createConsumer();
props.put(ConsumerConfig.GROUP_ID_CONFIG, "seekTestMaxFailures.dlt");
DefaultKafkaConsumerFactory<Integer, String> dltcf = new DefaultKafkaConsumerFactory<>(props);
Consumer<Integer, String> consumer = dltcf.createConsumer();
embeddedKafka.consumeFromAnEmbeddedTopic(consumer, topic1DLT);
ConsumerRecord<Integer, String> dltRecord = KafkaTestUtils.getSingleRecord(consumer, topic1DLT);
assertThat(dltRecord.value()).isEqualTo("foo");
template.sendDefault(0, 0, "junkJson");
dltRecord = KafkaTestUtils.getSingleRecord(consumer, topic1DLT);
assertThat(dltRecord.value()).isEqualTo("junkJson");
container.stop();
pf.destroy();
dltPf.destroy();
consumer.close();
assertThat(stopLatch.await(10, TimeUnit.SECONDS)).isTrue();
verify(errorHandler, times(4)).handle(any(), any(), any(), any());
verify(errorHandler).clearThreadState();
}
@@ -155,7 +178,7 @@ public class SeekToCurrentRecovererTests {
eh.handle(new RuntimeException(), records, consumer, null);
fail("Expected exception");
}
catch (KafkaException e) {
catch (@SuppressWarnings("unused") KafkaException e) {
// NOSONAR
}
verify(consumer).seek(new TopicPartition("foo", 0), 0L);
@@ -197,7 +220,7 @@ public class SeekToCurrentRecovererTests {
eh.handle(new RuntimeException(), records, consumer, container);
fail("Expected exception");
}
catch (KafkaException e) {
catch (@SuppressWarnings("unused") KafkaException e) {
// NOSONAR
}
verify(consumer).seek(new TopicPartition("foo", 0), 0L);
@@ -234,7 +257,7 @@ public class SeekToCurrentRecovererTests {
eh.handle(new RuntimeException(), records, consumer, null);
fail("Expected exception");
}
catch (KafkaException e) {
catch (@SuppressWarnings("unused") KafkaException e) {
// NOSONAR
}
}

View File

@@ -3084,6 +3084,28 @@ The record sent to the dead-letter topic is enhanced with the following headers:
* `KafkaHeaders.DLT_ORIGINAL_TIMESTAMP_TYPE`: The original timestamp type.
Starting with version 2.3, when used in conjunction with an `ErrorHandlingDeserializer2`, the publisher will restore the record `value()`, in the dead-letter producer record, to the original value that failed to be deserialized.
Previously, the `value()` was null and user code had to decode the `DeserializationException` from the message headers.
In addition, you can provide multiple `KafkaTemplate` s to the publisher; this might be needed, for example, if you want to publish the `byte[]` from a `DeserializationException`, as well as values using a different serializer from records that were deserialized successfully.
Here is an example of configuring the publisher with `KafkaTemplate` s that use a `String` and `byte[]` serializer:
====
[source, java]
----
@Bean
public DeadLetterPublishingRecoverer publisher(KafkaTemplate<?, ?> stringTemplate,
KafkaTemplate<?, ?> bytesTemplate) {
Map<Class<?>, KafkaTemplate<?, ?>> templates = new LinkedHashMap<>();
templates.put(String.class, stringTemplate);
templates.put(byte[].class, bytesTemplate);
return new DeadLetterPublishingRecoverer(templates);
}
----
====
The publisher uses the map keys to locate a template that is suitable for the `value()` about to be published.
A `LinkedHashMap` is recommended so that the keys are examined in order.
[[kerberos]]
==== Kerberos

View File

@@ -21,6 +21,10 @@ It now sets it to false automatically unless specifically set in the consumer fa
The `SeekToCurrentErrorHandler` now treats certain exceptions as fatal and disables retry for those, invoking the recoverer on first failure.
See <<seek-to-current>> for more information.
The `DeadLetterPublishingRecoverer`, when used in conjunction with an `ErrorHandlingDeserializer2`, now sets the payload of the message sent to the dead-letter topic, to the original value that could not be deserialized.
Previously, it was `null` and user code needed to extract the `DeserializationException` from the message headers.
See <<dead-letters>> for more information.
==== TopicBuilder
A new class `TopicBuilder` is provided for more convenient creation of `NewTopic` `@Bean` s for automatic topic provisioning.