DATAREDIS-1230 - Revise error handling in StreamReceiver and StreamMessageListenerContainer.
Reading and deserialization in StreamReceiver and StreamMessageListener is now decoupled from each other to allow fine-grained control over errors and resumption. Previously, we used the Template API to read and deserialize stream records. Now the actual read happens before the deserialization so that errors during deserialization of individual messages can be handled properly. This split also allows advancing in the stream read. Previously, the failed deserialization prevented of getting hold of the non-serialized Stream record which caused the stream receiver to remain at the offset that fetched the offending record which effectively lead to an infinite loop. We also support a resume function in StreamReceiver to control whether stream reads should be resumed or terminated. Original Pull Request: #576
This commit is contained in:
committed by
Christoph Strobl
parent
7d2bf8f852
commit
a5700ba71a
@@ -35,12 +35,14 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestInstance;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
|
||||
import org.springframework.core.convert.ConversionFailedException;
|
||||
import org.springframework.data.redis.SettingsUtils;
|
||||
import org.springframework.data.redis.connection.RedisConnection;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnection;
|
||||
import org.springframework.data.redis.connection.lettuce.extension.LettuceConnectionFactoryExtension;
|
||||
import org.springframework.data.redis.connection.stream.ByteRecord;
|
||||
import org.springframework.data.redis.connection.stream.Consumer;
|
||||
import org.springframework.data.redis.connection.stream.MapRecord;
|
||||
import org.springframework.data.redis.connection.stream.ObjectRecord;
|
||||
@@ -276,7 +278,7 @@ public class StreamMessageListenerContainerIntegrationTests {
|
||||
|
||||
StreamReadRequest<String> readRequest = StreamReadRequest
|
||||
.builder(StreamOffset.create("my-stream", ReadOffset.lastConsumed())) //
|
||||
.errorHandler(failures::add) // //
|
||||
.errorHandler(failures::add) //
|
||||
.cancelOnError(t -> false) //
|
||||
.consumer(Consumer.from("my-group", "my-consumer")) //
|
||||
.build();
|
||||
@@ -298,6 +300,52 @@ public class StreamMessageListenerContainerIntegrationTests {
|
||||
cancelAwait(subscription);
|
||||
}
|
||||
|
||||
@Test // DATAREDIS-1230
|
||||
void deserializationShouldContinueStreamRead() throws InterruptedException {
|
||||
|
||||
StreamMessageListenerContainerOptions<String, ObjectRecord<String, Long>> containerOptions = StreamMessageListenerContainerOptions
|
||||
.builder().batchSize(1).pollTimeout(Duration.ofMillis(100)).targetType(Long.class).build();
|
||||
|
||||
BlockingQueue<ObjectRecord<String, Long>> records = new LinkedBlockingQueue<>();
|
||||
BlockingQueue<Throwable> failures = new LinkedBlockingQueue<>();
|
||||
|
||||
StreamMessageListenerContainer<String, ObjectRecord<String, Long>> container = StreamMessageListenerContainer
|
||||
.create(connectionFactory, containerOptions);
|
||||
|
||||
StreamReadRequest<String> readRequest = StreamReadRequest
|
||||
.builder(StreamOffset.create("my-stream", ReadOffset.from("0-0"))) //
|
||||
.errorHandler(failures::add) //
|
||||
.cancelOnError(t -> false) //
|
||||
.build();
|
||||
|
||||
redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("payload", "1"));
|
||||
redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("payload", "foo"));
|
||||
redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("payload", "3"));
|
||||
|
||||
container.start();
|
||||
Subscription subscription = container.register(readRequest, records::add);
|
||||
|
||||
subscription.await(DEFAULT_TIMEOUT);
|
||||
|
||||
ObjectRecord<String, Long> first = records.poll(1, TimeUnit.SECONDS);
|
||||
Throwable conversionFailure = failures.poll(1, TimeUnit.SECONDS);
|
||||
ObjectRecord<String, Long> third = records.poll(1, TimeUnit.SECONDS);
|
||||
|
||||
assertThat(first).isNotNull();
|
||||
assertThat(first.getValue()).isEqualTo(1L);
|
||||
|
||||
assertThat(conversionFailure).isInstanceOf(ConversionFailedException.class)
|
||||
.hasCauseInstanceOf(ConversionFailedException.class).hasRootCauseInstanceOf(NumberFormatException.class);
|
||||
assertThat(((ConversionFailedException) conversionFailure).getValue()).isInstanceOf(ByteRecord.class);
|
||||
|
||||
assertThat(third).isNotNull();
|
||||
assertThat(third.getValue()).isEqualTo(3L);
|
||||
|
||||
assertThat(subscription.isActive()).isTrue();
|
||||
|
||||
cancelAwait(subscription);
|
||||
}
|
||||
|
||||
@Test // DATAREDIS-864
|
||||
void cancelledStreamShouldNotReceiveMessages() throws InterruptedException {
|
||||
|
||||
|
||||
@@ -22,24 +22,29 @@ import static org.mockito.Mockito.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
|
||||
import org.springframework.core.convert.ConversionFailedException;
|
||||
import org.springframework.data.redis.RedisSystemException;
|
||||
import org.springframework.data.redis.connection.RedisConnection;
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||
import org.springframework.data.redis.connection.lettuce.extension.LettuceConnectionFactoryExtension;
|
||||
import org.springframework.data.redis.connection.stream.ByteBufferRecord;
|
||||
import org.springframework.data.redis.connection.stream.Consumer;
|
||||
import org.springframework.data.redis.connection.stream.MapRecord;
|
||||
import org.springframework.data.redis.connection.stream.ObjectRecord;
|
||||
import org.springframework.data.redis.connection.stream.ReadOffset;
|
||||
import org.springframework.data.redis.connection.stream.Record;
|
||||
import org.springframework.data.redis.connection.stream.StreamOffset;
|
||||
import org.springframework.data.redis.core.ReactiveRedisTemplate;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
@@ -254,6 +259,37 @@ public class StreamReceiverIntegrationTests {
|
||||
.verify(Duration.ofSeconds(5));
|
||||
}
|
||||
|
||||
@Test // DATAREDIS-864
|
||||
void shouldResumeFromError() {
|
||||
|
||||
AtomicReference<Throwable> ref = new AtomicReference<>();
|
||||
StreamReceiverOptions<String, ObjectRecord<String, Long>> options = StreamReceiverOptions.builder()
|
||||
.pollTimeout(Duration.ofMillis(100)).targetType(Long.class).onErrorResume(throwable -> {
|
||||
|
||||
ref.set(throwable);
|
||||
return Mono.empty();
|
||||
}).build();
|
||||
|
||||
StreamReceiver<String, ObjectRecord<String, Long>> receiver = StreamReceiver.create(connectionFactory, options);
|
||||
|
||||
Flux<ObjectRecord<String, Long>> messages = receiver.receive(StreamOffset.fromStart("my-stream"));
|
||||
|
||||
redisTemplate.opsForStream().createGroup("my-stream", ReadOffset.from("0-0"), "my-group");
|
||||
redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("payload", "1"));
|
||||
redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("payload", "foo"));
|
||||
redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("payload", "3"));
|
||||
|
||||
messages.map(Record::getValue).as(StepVerifier::create) //
|
||||
.expectNext(1L) //
|
||||
.expectNext(3L) //
|
||||
.thenCancel() //
|
||||
.verify();
|
||||
|
||||
assertThat(ref.get()).isInstanceOf(ConversionFailedException.class)
|
||||
.hasCauseInstanceOf(ConversionFailedException.class).hasRootCauseInstanceOf(NumberFormatException.class);
|
||||
assertThat(((ConversionFailedException) ref.get()).getValue()).isInstanceOf(ByteBufferRecord.class);
|
||||
}
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
static class LoginEvent {
|
||||
|
||||
Reference in New Issue
Block a user