GH-221: Revise the manual checkpoint logic

Fixes https://github.com/spring-projects/spring-integration-aws/issues/221

* Introduce a `RequestShardForSequenceException` to control the flow for
requesting the `KinesisMessageDrivenChannelAdapter` for shard iterator at specific sequence
This commit is contained in:
abilan
2023-04-24 17:20:23 -04:00
parent 136cce6177
commit 63b40836ce
7 changed files with 133 additions and 69 deletions

View File

@@ -398,7 +398,7 @@ Can be specified as `null` with meaning no conversion and the target `Message` i
Additional headers like `AwsHeaders.RECEIVED_STREAM`, `AwsHeaders.SHARD`, `AwsHeaders.RECEIVED_PARTITION_KEY` and `AwsHeaders.RECEIVED_SEQUENCE_NUMBER` are populated to the message for downstream logic.
When `CheckpointMode.manual` is used the `Checkpointer` instance is populated to the `AwsHeaders.CHECKPOINTER` header for an acknowledgment in the downstream logic manually.
The `KinesisMessageDrivenChannelAdapter` ca be configured with the `ListenerMode` `record` or `batch` to process records one by one or send the whole just polled batch of records.
The `KinesisMessageDrivenChannelAdapter` can be configured with the `ListenerMode` `record` or `batch` to process records one by one or send the whole just polled batch of records.
If `Converter` is configured to `null`, the entire `List<Record>` is sent as a payload.
Otherwise, a list of converted `Record.getData().array()` is wrapped to the payload of message to send.
In this case the `AwsHeaders.RECEIVED_PARTITION_KEY` and `AwsHeaders.RECEIVED_SEQUENCE_NUMBER` headers contains values as a `List<String>` of partition keys and sequence numbers of converted records respectively.
@@ -433,6 +433,9 @@ For example, users may want to fully read any parent shards before starting to r
}
```
Starting with _version 3.0_. the `RequestShardForSequenceException` can be used for flow control to request the shard iterator for specific sequence.
For example, when consumer has failed processing batch at specific record, throwing this exception with a sequence of that record will ensure at-least-once delivery since the shard iterator will move back to the requested record sequence.
Also, the `KclMessageDrivenChannelAdapter` is provided for performing streams consumption by [Kinesis Client Library][].
See its JavaDocs for more information.

View File

@@ -11,7 +11,7 @@ plugins {
id 'checkstyle'
id 'org.ajoberstar.grgit' version '4.1.1'
id 'io.spring.dependency-management' version '1.1.0'
id 'com.jfrog.artifactory' version '4.31.7'
id 'com.jfrog.artifactory' version '4.31.9'
}
description = 'Spring Integration AWS Support'
@@ -32,15 +32,15 @@ repositories {
ext {
assertjVersion = '3.24.2'
awaitilityVersion = '4.2.0'
awsSdkVersion = '2.20.35'
jacksonVersion = '2.14.2'
awsSdkVersion = '2.20.51'
jacksonVersion = '2.15.0'
junitVersion = '5.9.2'
log4jVersion = '2.19.0'
log4jVersion = '2.20.0'
servletApiVersion = '6.0.0'
springCloudAwsVersion = '3.0.0-RC2'
springIntegrationVersion = '6.0.4'
springIntegrationVersion = '6.0.5'
kinesisClientVersion = '2.4.8'
kinesisProducerVersion = '0.15.5'
kinesisProducerVersion = '0.15.6'
testcontainersVersion = '1.18.0'
idPrefix = 'aws'

View File

@@ -1091,46 +1091,25 @@ public class KinesisMessageDrivenChannelAdapter extends MessageProducerSupport
if (!records.isEmpty()) {
processRecords(records);
}
this.shardIterator = result.nextShardIterator();
}
}
catch (RequestShardForSequenceException requestShardForSequenceException) {
// Something wrong happened and not all records were processed.
// Must start from the provided sequence
KinesisShardOffset newOffset = new KinesisShardOffset(this.shardOffset);
newOffset.setSequenceNumber(requestShardForSequenceException.getSequenceNumber());
newOffset.setIteratorType(ShardIteratorType.AT_SEQUENCE_NUMBER);
GetShardIteratorRequest shardIteratorRequest = newOffset.toShardIteratorRequest();
this.shardIterator =
KinesisMessageDrivenChannelAdapter.this.amazonKinesis
.getShardIterator(shardIteratorRequest)
.join()
.shardIterator();
}
finally {
attributesHolder.remove();
if (result != null) {
// If using manual checkpointer, we have to make sure we are allowed to use the next shard iterator
// Because if the manual checkpointer was not set to the latest record, it means there are records to be reprocessed
// and if we use the nextShardIterator, we will be skipping records that need to be reprocessed
List<Record> records = result.records();
if (CheckpointMode.manual.equals(KinesisMessageDrivenChannelAdapter.this.checkpointMode) &&
!records.isEmpty()) {
logger.info("Manual checkpointer. Must validate if should use getNextShardIterator()");
String lastRecordSequence = records.get(records.size() - 1).sequenceNumber();
String lastCheckpointSequence = this.checkpointer.getCheckpoint();
if (lastCheckpointSequence.equals(lastRecordSequence)) {
logger.info("latestCheckpointSequence is same as latestRecordSequence. " +
"Should getNextShardIterator()");
// Means the manual checkpointer has processed the last record, Should move forward
this.shardIterator = result.nextShardIterator();
}
else {
logger.info("latestCheckpointSequence is not the same as latestRecordSequence. " +
"Should Get a new iterator AFTER_SEQUENCE_NUMBER latestCheckpointSequence");
// Something wrong happened and not all records were processed.
// Must start from the latest known checkpoint
KinesisShardOffset newOffset = new KinesisShardOffset(this.shardOffset);
newOffset.setSequenceNumber(lastCheckpointSequence);
newOffset.setIteratorType(ShardIteratorType.AFTER_SEQUENCE_NUMBER);
GetShardIteratorRequest shardIteratorRequest = newOffset.toShardIteratorRequest();
this.shardIterator =
KinesisMessageDrivenChannelAdapter.this.amazonKinesis
.getShardIterator(shardIteratorRequest)
.join()
.shardIterator();
}
}
else {
this.shardIterator = result.nextShardIterator();
}
if (this.shardIterator == null) {
if (KinesisMessageDrivenChannelAdapter.this.lockRegistry != null) {
KinesisMessageDrivenChannelAdapter.this.shardConsumerManager.shardOffsetsToConsumer
@@ -1138,21 +1117,17 @@ public class KinesisMessageDrivenChannelAdapter extends MessageProducerSupport
}
// Shard is closed: nothing to consume anymore.
// Checkpoint endingSequenceNumber to ensure shard is marked exhausted.
// If in CheckpointMode.manual, only checkpoint if lastCheckpointValue is also null, as this
// means that no records have ever been read and so the shard was empty
if (!CheckpointMode.manual.equals(KinesisMessageDrivenChannelAdapter.this.checkpointMode)
|| this.checkpointer.getLastCheckpointValue() == null) {
for (Shard shard : readShardList(this.shardOffset.getStream())) {
if (shard.shardId().equals(this.shardOffset.getShard())) {
String endingSequenceNumber =
shard.sequenceNumberRange().endingSequenceNumber();
if (endingSequenceNumber != null) {
checkpointSwallowingProvisioningExceptions(endingSequenceNumber);
}
break;
for (Shard shard : readShardList(this.shardOffset.getStream())) {
if (shard.shardId().equals(this.shardOffset.getShard())) {
String endingSequenceNumber =
shard.sequenceNumberRange().endingSequenceNumber();
if (endingSequenceNumber != null) {
checkpointSwallowingProvisioningExceptions(endingSequenceNumber);
}
break;
}
}
// Resharding is possible.
if (KinesisMessageDrivenChannelAdapter.this.applicationEventPublisher != null) {
KinesisMessageDrivenChannelAdapter.this.applicationEventPublisher.publishEvent(
@@ -1185,7 +1160,7 @@ public class KinesisMessageDrivenChannelAdapter extends MessageProducerSupport
}
catch (ProvisionedThroughputExceededException ignored) {
// This exception is ignored to guarantee that an exhausted shard is marked as CLOSED
// even in the case it's not possible to checkpoint. Otherwise the ShardConsumer is
// even in the case it's not possible to checkpoint. Otherwise, the ShardConsumer is
// left in an illegal state where the shard iterator is null without any possibility
// of recovering from it.
logger.debug(ignored, "Exception while checkpointing empty shards");
@@ -1341,16 +1316,39 @@ public class KinesisMessageDrivenChannelAdapter extends MessageProducerSupport
try {
sendMessage(messageToSend);
}
catch (Exception ex) {
logger.info(ex, () ->
"Got an exception during sending a '"
+ messageToSend
+ "'"
+ "\nfor the '"
+ rawRecord
+ "'.\n"
+ "Consider to use 'errorChannel' flow for the compensation logic.");
catch (RequestShardForSequenceException requestShardForSequenceException) {
// Rethrow
throw requestShardForSequenceException;
}
catch (Exception ex) {
RequestShardForSequenceException requestShardForSequenceExceptionInCause =
findRequestShardForSequenceExceptionInCause(ex);
if (requestShardForSequenceExceptionInCause != null) {
throw requestShardForSequenceExceptionInCause;
}
else {
logger.info(ex, () ->
"Got an exception during sending a '"
+ messageToSend
+ "'"
+ "\nfor the '"
+ rawRecord
+ "'.\n"
+ "Consider to use 'errorChannel' flow for the compensation logic.");
}
}
}
@Nullable
private static RequestShardForSequenceException findRequestShardForSequenceExceptionInCause(Throwable ex) {
if (ex instanceof RequestShardForSequenceException requestShardForSequenceException) {
return requestShardForSequenceException;
}
Throwable cause = ex.getCause();
if (cause != null && cause != ex) {
return findRequestShardForSequenceExceptionInCause(cause);
}
return null;
}
private void checkpointIfBatchMode() {

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2023 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.integration.aws.inbound.kinesis;
import software.amazon.awssdk.services.kinesis.model.ShardIteratorType;
/**
* The flow control exception to notify the {@link KinesisMessageDrivenChannelAdapter}
* that specific shard iterator ({@link ShardIteratorType#AT_SEQUENCE_NUMBER})
* must be requested instead of checkpointing.
*
* @author Artem Bilan
*
* @since 3.0
*/
@SuppressWarnings("serial")
public class RequestShardForSequenceException extends RuntimeException {
private final String sequenceNumber;
public RequestShardForSequenceException(String sequenceNumber) {
this.sequenceNumber = sequenceNumber;
}
public RequestShardForSequenceException(String sequenceNumber, Throwable cause) {
super(cause);
this.sequenceNumber = sequenceNumber;
}
public String getSequenceNumber() {
return this.sequenceNumber;
}
}

View File

@@ -148,8 +148,6 @@ public class KinesisMessageDrivenChannelAdapterTests {
Checkpointer checkpointer = headers.get(AwsHeaders.CHECKPOINTER, Checkpointer.class);
assertThat(checkpointer).isNotNull();
checkpointer.checkpoint();
message = this.kinesisChannel.receive(10000);
assertThat(message).isNotNull();
assertThat(message.getPayload()).isEqualTo("bar");
@@ -161,6 +159,8 @@ public class KinesisMessageDrivenChannelAdapterTests {
assertThat(this.kinesisChannel.receive(10)).isNull();
checkpointer.checkpoint();
assertThat(this.checkpointStore.get("SpringIntegration" + ":" + STREAM1 + ":" + "1")).isEqualTo("2");
this.kinesisMessageDrivenChannelAdapter.stop();

View File

@@ -19,11 +19,13 @@ package org.springframework.integration.aws.kinesis;
import java.util.Date;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.kinesis.KinesisAsyncClient;
import software.amazon.awssdk.services.kinesis.model.Record;
import org.springframework.beans.DirectFieldAccessor;
import org.springframework.beans.factory.annotation.Autowired;
@@ -34,6 +36,7 @@ import org.springframework.integration.annotation.ServiceActivator;
import org.springframework.integration.aws.LocalstackContainerTest;
import org.springframework.integration.aws.inbound.kinesis.KinesisMessageDrivenChannelAdapter;
import org.springframework.integration.aws.inbound.kinesis.KinesisMessageHeaderErrorMessageStrategy;
import org.springframework.integration.aws.inbound.kinesis.RequestShardForSequenceException;
import org.springframework.integration.aws.outbound.KinesisMessageHandler;
import org.springframework.integration.aws.support.AwsHeaders;
import org.springframework.integration.channel.QueueChannel;
@@ -114,6 +117,14 @@ public class KinesisIntegrationTests implements LocalstackContainerTest {
.contains("Channel 'kinesisReceiveChannel' expected one of the following data types "
+ "[class java.util.Date], but received [class java.lang.String]");
String errorSequenceNumber = errorMessage.getHeaders().get(AwsHeaders.RAW_RECORD, Record.class).sequenceNumber();
// Second exception for the same record since we have requested via RequestShardForSequenceException
errorMessage = this.errorChannel.receive(30_000);
assertThat(errorMessage).isNotNull();
assertThat(errorMessage.getHeaders().get(AwsHeaders.RAW_RECORD, Record.class).sequenceNumber())
.isEqualTo(errorSequenceNumber);
for (int i = 0; i < 2; i++) {
this.kinesisSendChannel
.send(MessageBuilder.withPayload(new Date()).setHeader(AwsHeaders.STREAM, TEST_STREAM).build());
@@ -208,10 +219,14 @@ public class KinesisIntegrationTests implements LocalstackContainerTest {
QueueChannel queueChannel = new QueueChannel();
queueChannel.addInterceptor(new ChannelInterceptor() {
private final AtomicBoolean thrown = new AtomicBoolean();
@Override
public void postSend(Message<?> message, MessageChannel channel, boolean sent) {
if (message instanceof ErrorMessage) {
throw (RuntimeException) ((ErrorMessage) message).getPayload();
if (message instanceof ErrorMessage errorMessage && this.thrown.compareAndSet(false, true)) {
throw new RequestShardForSequenceException(
errorMessage.getHeaders().get(AwsHeaders.RAW_RECORD, Record.class).sequenceNumber(),
errorMessage.getPayload());
}
}

View File

@@ -9,7 +9,7 @@
<Logger name="org.springframework" level="warn"/>
<Logger name="org.springframework.integration" level="warn"/>
<Logger name="org.springframework.integration.aws" level="info"/>
<Logger name="org.testcontainers" level="debug"/>
<Logger name="org.testcontainers" level="warn"/>
<Root level="warn">
<AppenderRef ref="STDOUT"/>
</Root>