This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 6827b9194109 CAMEL-26660: camel-aws2-kinesis - batch producer sends
the failed records again and splits requests at 10 MiB (#29269)
6827b9194109 is described below
commit 6827b91941097cdfd948d6cf346e0fc15575f86a
Author: Federico Mariani <[email protected]>
AuthorDate: Fri Nov 6 08:21:39 2026 +0100
CAMEL-26660: camel-aws2-kinesis - batch producer sends the failed records
again and splits requests at 10 MiB (#29269)
The Kinesis batch producer sends the records that failed again, with
backoff and jitter, splits requests at 10 MiB, and sets the written/failed
count headers also when putRecords throws.
AI-generated with Claude Code on behalf of Federico Mariani.
Co-Authored-By: Claude <[email protected]>
---
.../camel/catalog/docs/aws2-kinesis-component.adoc | 35 +++-
.../src/main/docs/aws2-kinesis-component.adoc | 35 +++-
.../component/aws2/kinesis/Kinesis2Producer.java | 141 +++++++++++++--
.../aws2/kinesis/Kinesis2ProducerTest.java | 196 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 20 +++
5 files changed, 408 insertions(+), 19 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/aws2-kinesis-component.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/aws2-kinesis-component.adoc
index 00c03c331a4b..0c2d556f2023 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/aws2-kinesis-component.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/aws2-kinesis-component.adoc
@@ -124,12 +124,45 @@ the other shards; a subclass should compare the key with
`getShardId()` the same
This component implements the Batch Producer.
This allows you to send multiple messages in a single request to Amazon
Kinesis.
-Messages with batch size more than 500 is allowed. Producer will split them
into multiple requests.
+A batch can have any number of records: the producer splits it into
`PutRecords` requests of at most 500 records
+and 10 MiB, the limits of a request (the size of a record is its data plus its
partition key).
+A record above 10 MiB, the largest record that Kinesis accepts, fails the
exchange with an `IllegalArgumentException`
+before any request is sent. A stream can have a lower limit for the size of
its records (1 to 10 MiB), which Kinesis
+checks.
The batch type needs to implement the `Iterable` interface. For example, it
can be a `List`, `Set` or any other collection type.
The message type can be one or more of types `byte[]`, `ByteBuffer`, UTF-8
`String`, or `InputStream`. Other types are not supported.
A `java.nio.file.Path` body is not a batch: like a `java.io.File`, it is sent
as a single record with the content of the file.
+A `PutRecords` request can be partly written: Kinesis writes some of its
records and returns an error code for the
+others (for example `ProvisionedThroughputExceededException`). The producer
then sends only the records that failed
+again, up to 3 times, waiting 100, 200 and 400 milliseconds plus a random part
of up to a quarter of the delay (so
+that producers throttled at the same time do not all send again at the same
time), and then goes on with the next
+requests of the batch.
+When every record fails, for example when the whole stream is throttled, each
request waits 700 to 875 milliseconds in
+total before its records are counted as not written, so a batch of 10,000
records (20 requests of 500 records) can
+block the caller for 14 to 17.5 seconds, plus the time of the 80 calls and of
the retries of the AWS SDK client.
+
+A record that is sent again is written after the next records of its request:
if record A fails and record B, with
+the same partition key, is written, the stream has B before A. `PutRecords`
does not guarantee the order of the
+records either. When the order of the records of a partition key matters, send
them one by one, with a body that is
+not an `Iterable`.
+
+After the last request, the `CamelAwsKinesisRecordCount` header has the number
of records of the batch, and the
+`CamelAwsKinesisFailedRecordCount` header the number of records that are still
not written. When that number is not
+0, the exchange fails with an exception that gives both numbers and the first
error code.
+When a `PutRecords` call throws, for example an `InvalidArgumentException`, a
KMS error, or an error of the AWS SDK
+client after its own retries, the exchange fails at once with that exception,
and the next requests of the batch are
+not sent. Both headers are set in that case too: the records of that call and
of the requests that were not sent are
+counted as not written, even if the call may have written some of them (for
example after a timeout).
+
+A failure does not undo the records that are already written. A redelivery of
the exchange, by the error handler of
+the route or by the source of the message (for example a transacted JMS or an
SQS consumer), sends the whole batch
+again, so the records that were written before the failure are written twice.
`PutRecords` has no idempotency token:
+a request whose outcome is unknown, for example after a timeout, can also
write a record twice when it is sent again,
+for example by the retries of the AWS SDK client. A consumer of the stream
that must not process a record twice needs
+an id in the data of the record to detect a duplicate.
+
A body that is not an `Iterable` is sent as a single record, converted to a
`byte[]`.
A body that does not convert to a `byte[]` fails with an
`InvalidPayloadException`.
diff --git
a/components/camel-aws/camel-aws2-kinesis/src/main/docs/aws2-kinesis-component.adoc
b/components/camel-aws/camel-aws2-kinesis/src/main/docs/aws2-kinesis-component.adoc
index 00c03c331a4b..0c2d556f2023 100644
---
a/components/camel-aws/camel-aws2-kinesis/src/main/docs/aws2-kinesis-component.adoc
+++
b/components/camel-aws/camel-aws2-kinesis/src/main/docs/aws2-kinesis-component.adoc
@@ -124,12 +124,45 @@ the other shards; a subclass should compare the key with
`getShardId()` the same
This component implements the Batch Producer.
This allows you to send multiple messages in a single request to Amazon
Kinesis.
-Messages with batch size more than 500 is allowed. Producer will split them
into multiple requests.
+A batch can have any number of records: the producer splits it into
`PutRecords` requests of at most 500 records
+and 10 MiB, the limits of a request (the size of a record is its data plus its
partition key).
+A record above 10 MiB, the largest record that Kinesis accepts, fails the
exchange with an `IllegalArgumentException`
+before any request is sent. A stream can have a lower limit for the size of
its records (1 to 10 MiB), which Kinesis
+checks.
The batch type needs to implement the `Iterable` interface. For example, it
can be a `List`, `Set` or any other collection type.
The message type can be one or more of types `byte[]`, `ByteBuffer`, UTF-8
`String`, or `InputStream`. Other types are not supported.
A `java.nio.file.Path` body is not a batch: like a `java.io.File`, it is sent
as a single record with the content of the file.
+A `PutRecords` request can be partly written: Kinesis writes some of its
records and returns an error code for the
+others (for example `ProvisionedThroughputExceededException`). The producer
then sends only the records that failed
+again, up to 3 times, waiting 100, 200 and 400 milliseconds plus a random part
of up to a quarter of the delay (so
+that producers throttled at the same time do not all send again at the same
time), and then goes on with the next
+requests of the batch.
+When every record fails, for example when the whole stream is throttled, each
request waits 700 to 875 milliseconds in
+total before its records are counted as not written, so a batch of 10,000
records (20 requests of 500 records) can
+block the caller for 14 to 17.5 seconds, plus the time of the 80 calls and of
the retries of the AWS SDK client.
+
+A record that is sent again is written after the next records of its request:
if record A fails and record B, with
+the same partition key, is written, the stream has B before A. `PutRecords`
does not guarantee the order of the
+records either. When the order of the records of a partition key matters, send
them one by one, with a body that is
+not an `Iterable`.
+
+After the last request, the `CamelAwsKinesisRecordCount` header has the number
of records of the batch, and the
+`CamelAwsKinesisFailedRecordCount` header the number of records that are still
not written. When that number is not
+0, the exchange fails with an exception that gives both numbers and the first
error code.
+When a `PutRecords` call throws, for example an `InvalidArgumentException`, a
KMS error, or an error of the AWS SDK
+client after its own retries, the exchange fails at once with that exception,
and the next requests of the batch are
+not sent. Both headers are set in that case too: the records of that call and
of the requests that were not sent are
+counted as not written, even if the call may have written some of them (for
example after a timeout).
+
+A failure does not undo the records that are already written. A redelivery of
the exchange, by the error handler of
+the route or by the source of the message (for example a transacted JMS or an
SQS consumer), sends the whole batch
+again, so the records that were written before the failure are written twice.
`PutRecords` has no idempotency token:
+a request whose outcome is unknown, for example after a timeout, can also
write a record twice when it is sent again,
+for example by the retries of the AWS SDK client. A consumer of the stream
that must not process a record twice needs
+an id in the data of the record to detect a duplicate.
+
A body that is not an `Iterable` is sent as a single record, converted to a
`byte[]`.
A body that does not convert to a `byte[]` fails with an
`InvalidPayloadException`.
diff --git
a/components/camel-aws/camel-aws2-kinesis/src/main/java/org/apache/camel/component/aws2/kinesis/Kinesis2Producer.java
b/components/camel-aws/camel-aws2-kinesis/src/main/java/org/apache/camel/component/aws2/kinesis/Kinesis2Producer.java
index 03ae59779917..d41484fbb285 100644
---
a/components/camel-aws/camel-aws2-kinesis/src/main/java/org/apache/camel/component/aws2/kinesis/Kinesis2Producer.java
+++
b/components/camel-aws/camel-aws2-kinesis/src/main/java/org/apache/camel/component/aws2/kinesis/Kinesis2Producer.java
@@ -18,26 +18,48 @@ package org.apache.camel.component.aws2.kinesis;
import java.io.InputStream;
import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
+import java.util.concurrent.ThreadLocalRandom;
import org.apache.camel.Exchange;
import org.apache.camel.InvalidPayloadException;
import org.apache.camel.Message;
+import org.apache.camel.RuntimeCamelException;
import org.apache.camel.support.DefaultProducer;
import org.apache.camel.util.ObjectHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import software.amazon.awssdk.core.SdkBytes;
import software.amazon.awssdk.services.kinesis.model.PutRecordRequest;
import software.amazon.awssdk.services.kinesis.model.PutRecordResponse;
import software.amazon.awssdk.services.kinesis.model.PutRecordsRequest;
import software.amazon.awssdk.services.kinesis.model.PutRecordsRequestEntry;
import software.amazon.awssdk.services.kinesis.model.PutRecordsResponse;
+import software.amazon.awssdk.services.kinesis.model.PutRecordsResultEntry;
public class Kinesis2Producer extends DefaultProducer {
- // Maximum number of records that can be sent in a single PutRecords
request
+ private static final Logger LOG =
LoggerFactory.getLogger(Kinesis2Producer.class);
+
+ // Limits of a single PutRecords request: 500 records and 10 MiB,
partition keys included
private static final int MAX_BATCH_SIZE = 500;
+ private static final long MAX_BATCH_BYTES = 10L * 1024 * 1024;
+ // Largest record (data and partition key) that PutRecords accepts. A
stream can have a lower limit (1 to 10 MiB).
+ private static final long MAX_RECORD_BYTES = 10L * 1024 * 1024;
+ // How many times the records that failed in a PutRecords request are sent
again, and the delay before the first
+ // time. The delay doubles at each retry, and a random part of up to a
quarter of it is added, so that producers
+ // throttled at the same time do not all send again at the same time.
+ private static final int MAX_RETRIES = 3;
+ private static final long RETRY_DELAY_MILLIS = 100;
+ // The failure of a record that has no result in the PutRecords response.
Kinesis returns a result for every record,
+ // but a record without one is not known to be written, so it is sent
again like a failed record.
+ private static final PutRecordsResultEntry MISSING_RESULT =
PutRecordsResultEntry.builder()
+ .errorCode("MissingResult")
+ .errorMessage("The PutRecords response has no result for this
record")
+ .build();
private KinesisConnection connection;
@@ -85,33 +107,99 @@ public class Kinesis2Producer extends DefaultProducer {
} else if (partitionKeys.isEmpty()) {
throw new IllegalArgumentException("CamelAwsKinesisPartitionKeys
header must not be empty");
}
+ // every record is converted and checked before the first request is
sent
List<List<PutRecordsRequestEntry>> requestBatchList
= createRequestBatchList(exchange, partitionKey,
partitionKeys);
int totalRecordCount = 0;
- int totalFailedCount = 0;
for (List<PutRecordsRequestEntry> requestBatch : requestBatchList) {
+ totalRecordCount += requestBatch.size();
+ }
+ BatchProgress progress = new BatchProgress();
+ try {
+ // the next requests are sent even when records of an earlier one
could not be written
+ for (List<PutRecordsRequestEntry> requestBatch : requestBatchList)
{
+ sendRequestBatch(requestBatch, progress);
+ }
+ } finally {
+ // also set when a PutRecords call throws: the records that
Kinesis did not report as written, those of the
+ // requests that were not sent included, are counted as failed
+ Message message = exchange.getMessage();
+ message.setHeader(Kinesis2Constants.RECORD_COUNT,
totalRecordCount);
+ message.setHeader(Kinesis2Constants.FAILED_RECORD_COUNT,
totalRecordCount - progress.writtenCount);
+ }
+ if (progress.firstFailure != null) {
+ throw new RuntimeCamelException(
+ "Failed to send " + (totalRecordCount -
progress.writtenCount) + " of " + totalRecordCount
+ + " records to the Kinesis stream "
+ +
getEndpoint().getConfiguration().getStreamName() + " after " + MAX_RETRIES
+ + " retries, " +
progress.writtenCount
+ + " records were written. First
error: " + progress.firstFailure.errorCode()
+ + ": " +
progress.firstFailure.errorMessage());
+ }
+ }
+
+ /**
+ * Sends the records of one PutRecords request, then sends again the
records that failed, at most
+ * {@link #MAX_RETRIES} times. Adds the records that are written, and the
first failure of a record that is still
+ * not written, to the progress of the batch.
+ */
+ private void sendRequestBatch(List<PutRecordsRequestEntry> requestBatch,
BatchProgress progress) {
+ String streamName = getEndpoint().getConfiguration().getStreamName();
+ List<PutRecordsRequestEntry> pending = requestBatch;
+ for (int retry = 0;; retry++) {
PutRecordsRequest putRecordsRequest = PutRecordsRequest.builder()
-
.streamName(getEndpoint().getConfiguration().getStreamName())
- .records(requestBatch)
+ .streamName(streamName)
+ .records(pending)
.build();
PutRecordsResponse putRecordsResponse =
connection.getClient(getEndpoint()).putRecords(putRecordsRequest);
- totalRecordCount += putRecordsResponse.records().size();
- totalFailedCount += putRecordsResponse.failedRecordCount();
- if (putRecordsResponse.failedRecordCount() > 0) {
- throw new RuntimeException(
- "Failed to send records " +
putRecordsResponse.failedRecordCount() + " of "
- +
putRecordsResponse.records().size());
+ // the result of a record is at the same position as the record in
the request,
+ // and the result of a record that was not written has an error
code
+ List<PutRecordsResultEntry> results = putRecordsResponse.records();
+ List<PutRecordsRequestEntry> failedEntries = new ArrayList<>();
+ List<PutRecordsResultEntry> failures = new ArrayList<>();
+ for (int i = 0; i < pending.size(); i++) {
+ PutRecordsResultEntry result = i < results.size() ?
results.get(i) : MISSING_RESULT;
+ if (result.errorCode() != null) {
+ failedEntries.add(pending.get(i));
+ failures.add(result);
+ }
+ }
+ progress.writtenCount += pending.size() - failures.size();
+ if (failures.isEmpty() || retry == MAX_RETRIES) {
+ if (progress.firstFailure == null && !failures.isEmpty()) {
+ progress.firstFailure = failures.get(0);
+ }
+ return;
+ }
+ long delay = retryDelayMillis(retry);
+ LOG.debug("{} of {} records were not written to the Kinesis stream
{} ({}), sending them again in {} ms",
+ failures.size(), pending.size(), streamName,
failures.get(0).errorCode(), delay);
+ try {
+ Thread.sleep(delay);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeCamelException(
+ "Interrupted before sending again " + failures.size()
+ " records to the Kinesis stream " + streamName,
+ e);
}
+ pending = failedEntries;
}
- Message message = exchange.getMessage();
- message.setHeader(Kinesis2Constants.RECORD_COUNT, totalRecordCount);
- message.setHeader(Kinesis2Constants.FAILED_RECORD_COUNT,
totalFailedCount);
+ }
+
+ /**
+ * The delay before the given retry (0 for the first one): {@link
#RETRY_DELAY_MILLIS}, doubled at each retry, plus
+ * a random part of up to a quarter of it.
+ */
+ static long retryDelayMillis(int retry) {
+ long delay = RETRY_DELAY_MILLIS << retry;
+ return delay + ThreadLocalRandom.current().nextLong(delay / 4 + 1);
}
private List<List<PutRecordsRequestEntry>> createRequestBatchList(
Exchange exchange, Object partitionKey, List<String>
partitionKeys) {
List<List<PutRecordsRequestEntry>> requestBatchList = new
ArrayList<>();
List<PutRecordsRequestEntry> requestBatch = new
ArrayList<>(MAX_BATCH_SIZE);
+ long requestBatchBytes = 0;
int index = 0;
for (Object record : exchange.getIn().getBody(Iterable.class)) {
SdkBytes sdkBytes;
@@ -136,15 +224,26 @@ public class Kinesis2Producer extends DefaultProducer {
key = partitionKey.toString();
}
+ long recordBytes = sdkBytes.asByteArrayUnsafe().length
+ + (key != null ? (long)
key.getBytes(StandardCharsets.UTF_8).length : 0);
+ if (recordBytes > MAX_RECORD_BYTES) {
+ throw new IllegalArgumentException(
+ "The record at index " + index + " of the batch is " +
recordBytes
+ + " bytes with its
partition key, more than "
+ + MAX_RECORD_BYTES + "
bytes, the largest record that Kinesis accepts");
+ }
+ if (!requestBatch.isEmpty()
+ && (requestBatch.size() == MAX_BATCH_SIZE ||
requestBatchBytes + recordBytes > MAX_BATCH_BYTES)) {
+ requestBatchList.add(requestBatch);
+ requestBatch = new ArrayList<>(MAX_BATCH_SIZE);
+ requestBatchBytes = 0;
+ }
PutRecordsRequestEntry putRecordsRequestEntry =
PutRecordsRequestEntry.builder()
.data(sdkBytes)
.partitionKey(key)
.build();
requestBatch.add(putRecordsRequestEntry);
- if (requestBatch.size() == MAX_BATCH_SIZE) {
- requestBatchList.add(requestBatch);
- requestBatch = new ArrayList<>(MAX_BATCH_SIZE);
- }
+ requestBatchBytes += recordBytes;
index++;
}
if (!requestBatch.isEmpty()) {
@@ -183,4 +282,12 @@ public class Kinesis2Producer extends DefaultProducer {
throw new IllegalArgumentException("Partition key must be
specified");
}
}
+
+ /**
+ * How many records of a batch Kinesis wrote, and the first failure of a
record that is still not written.
+ */
+ private static final class BatchProgress {
+ private int writtenCount;
+ private PutRecordsResultEntry firstFailure;
+ }
}
diff --git
a/components/camel-aws/camel-aws2-kinesis/src/test/java/org/apache/camel/component/aws2/kinesis/Kinesis2ProducerTest.java
b/components/camel-aws/camel-aws2-kinesis/src/test/java/org/apache/camel/component/aws2/kinesis/Kinesis2ProducerTest.java
index 466df6e43a4d..84d1235c2099 100644
---
a/components/camel-aws/camel-aws2-kinesis/src/test/java/org/apache/camel/component/aws2/kinesis/Kinesis2ProducerTest.java
+++
b/components/camel-aws/camel-aws2-kinesis/src/test/java/org/apache/camel/component/aws2/kinesis/Kinesis2ProducerTest.java
@@ -19,13 +19,19 @@ package org.apache.camel.component.aws2.kinesis;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.ArrayList;
import java.util.Arrays;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
+import java.util.Set;
+import java.util.function.Predicate;
+import java.util.stream.IntStream;
import org.apache.camel.CamelContext;
import org.apache.camel.Exchange;
import org.apache.camel.InvalidPayloadException;
+import org.apache.camel.RuntimeCamelException;
import org.apache.camel.impl.DefaultCamelContext;
import org.apache.camel.support.DefaultExchange;
import org.junit.jupiter.api.BeforeEach;
@@ -36,6 +42,7 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import software.amazon.awssdk.services.kinesis.KinesisClient;
+import software.amazon.awssdk.services.kinesis.model.KmsThrottlingException;
import software.amazon.awssdk.services.kinesis.model.PutRecordRequest;
import software.amazon.awssdk.services.kinesis.model.PutRecordResponse;
import software.amazon.awssdk.services.kinesis.model.PutRecordsRequest;
@@ -44,6 +51,7 @@ import
software.amazon.awssdk.services.kinesis.model.PutRecordsResponse;
import software.amazon.awssdk.services.kinesis.model.PutRecordsResultEntry;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -59,6 +67,8 @@ public class Kinesis2ProducerTest {
private final CamelContext context = new DefaultCamelContext();
private final Kinesis2Component component = new Kinesis2Component(context);
+ private final List<PutRecordsRequest> requests = new ArrayList<>();
+ private final List<PutRecordsRequestEntry> written = new ArrayList<>();
private Kinesis2Producer producer;
@BeforeEach
@@ -174,6 +184,149 @@ public class Kinesis2ProducerTest {
assertThrows(IllegalArgumentException.class, () ->
producer.process(exchange));
}
+ @Test
+ public void batchSendsAgainOnlyTheFailedRecordAndThenTheNextRequests()
throws Exception {
+ // the first record of the second request fails once
+ Set<String> failedOnce = new HashSet<>();
+ stubPutRecords(entry -> entry.data().asUtf8String().equals("r500") &&
failedOnce.add("r500"));
+
+ Exchange exchange = new DefaultExchange(context);
+ exchange.getIn().setHeader(Kinesis2Constants.PARTITION_KEY, "key");
+ exchange.getIn().setBody(records(1200));
+
+ producer.process(exchange);
+
+ assertEquals(List.of(500, 500, 1, 200), requestSizes());
+ assertEquals(List.of("r500"), data(requests.get(2).records()));
+ // every record is written once
+ assertEquals(1200, written.size());
+ assertEquals(new HashSet<>(records(1200)), new
HashSet<>(data(written)));
+ assertEquals(1200,
exchange.getMessage().getHeader(Kinesis2Constants.RECORD_COUNT, Integer.class));
+ assertEquals(0,
exchange.getMessage().getHeader(Kinesis2Constants.FAILED_RECORD_COUNT,
Integer.class));
+ }
+
+ @Test
+ public void
batchWithARecordThatKeepsFailingThrowsAfterTheOtherRequestsAreSent() {
+ stubPutRecords(entry -> entry.data().asUtf8String().equals("r100"));
+
+ Exchange exchange = new DefaultExchange(context);
+ exchange.getIn().setHeader(Kinesis2Constants.PARTITION_KEY, "key");
+ exchange.getIn().setBody(records(600));
+
+ RuntimeCamelException e = assertThrows(RuntimeCamelException.class, ()
-> producer.process(exchange));
+
+ // the failed record is sent again 3 times, then the second request is
sent
+ assertEquals(List.of(500, 1, 1, 1, 100), requestSizes());
+ assertEquals(599, written.size());
+ assertEquals(600,
exchange.getMessage().getHeader(Kinesis2Constants.RECORD_COUNT, Integer.class));
+ assertEquals(1,
exchange.getMessage().getHeader(Kinesis2Constants.FAILED_RECORD_COUNT,
Integer.class));
+ assertTrue(e.getMessage().contains("Failed to send 1 of 600 records"),
e.getMessage());
+ assertTrue(e.getMessage().contains("599 records were written"),
e.getMessage());
+
assertTrue(e.getMessage().contains("ProvisionedThroughputExceededException"),
e.getMessage());
+ }
+
+ @Test
+ public void batchSetsTheCountsWhenAPutRecordsCallThrows() {
+ // a record of the second request fails, and the call that sends it
again throws
+ KmsThrottlingException failure =
KmsThrottlingException.builder().message("Rate exceeded").build();
+
when(kinesisClient.putRecords(any(PutRecordsRequest.class))).thenAnswer(invocation
-> {
+ PutRecordsRequest request = invocation.getArgument(0);
+ if (requests.size() == 2) {
+ requests.add(request);
+ throw failure;
+ }
+ return respond(request, entry ->
entry.data().asUtf8String().equals("r600"));
+ });
+
+ Exchange exchange = new DefaultExchange(context);
+ exchange.getIn().setHeader(Kinesis2Constants.PARTITION_KEY, "key");
+ exchange.getIn().setBody(records(1200));
+
+ KmsThrottlingException e = assertThrows(KmsThrottlingException.class,
() -> producer.process(exchange));
+
+ // the exception of the call fails the exchange as it is, and the last
request is not sent
+ assertSame(failure, e);
+ assertEquals(List.of(500, 500, 1), requestSizes());
+ assertEquals(999, written.size());
+ // the record of the call that threw and the 200 records of the
request that was not sent are not written
+ assertEquals(1200,
exchange.getMessage().getHeader(Kinesis2Constants.RECORD_COUNT, Integer.class));
+ assertEquals(201,
exchange.getMessage().getHeader(Kinesis2Constants.FAILED_RECORD_COUNT,
Integer.class));
+ }
+
+ @Test
+ public void batchSendsAgainARecordWithoutResultInTheResponse() throws
Exception {
+ // the first response has no result for the last record of the request
+
when(kinesisClient.putRecords(any(PutRecordsRequest.class))).thenAnswer(invocation
-> {
+ PutRecordsRequest request = invocation.getArgument(0);
+ if (requests.isEmpty()) {
+ PutRecordsResponse response = respond(request, entry ->
entry.data().asUtf8String().equals("r2"));
+ return
response.toBuilder().failedRecordCount(0).records(response.records().subList(0,
2)).build();
+ }
+ return respond(request, entry -> false);
+ });
+
+ Exchange exchange = new DefaultExchange(context);
+ exchange.getIn().setHeader(Kinesis2Constants.PARTITION_KEY, "key");
+ exchange.getIn().setBody(records(3));
+
+ producer.process(exchange);
+
+ assertEquals(List.of(3, 1), requestSizes());
+ assertEquals(List.of("r2"), data(requests.get(1).records()));
+ assertEquals(records(3), data(written));
+ assertEquals(3,
exchange.getMessage().getHeader(Kinesis2Constants.RECORD_COUNT, Integer.class));
+ assertEquals(0,
exchange.getMessage().getHeader(Kinesis2Constants.FAILED_RECORD_COUNT,
Integer.class));
+ }
+
+ @Test
+ public void retryDelayDoublesAndHasARandomPart() {
+ for (int retry = 0; retry < 3; retry++) {
+ long base = 100L << retry;
+ Set<Long> delays = new HashSet<>();
+ for (int i = 0; i < 200; i++) {
+ long delay = Kinesis2Producer.retryDelayMillis(retry);
+ assertTrue(delay >= base && delay <= base + base / 4, "delay "
+ delay + " ms for retry " + retry);
+ delays.add(delay);
+ }
+ // producers throttled at the same time do not all wait the same
time before they send again
+ assertTrue(delays.size() > 1, "the delay of retry " + retry + "
has no random part: " + delays);
+ }
+ }
+
+ @Test
+ public void batchIsSplitAtTheRequestSizeLimit() throws Exception {
+ stubPutRecords(entry -> false);
+
+ // with its partition key of 2 bytes a record is 1 MiB, so 10 records
fill a request of 10 MiB
+ List<byte[]> body = new ArrayList<>();
+ for (int i = 0; i < 21; i++) {
+ body.add(new byte[1024 * 1024 - 2]);
+ }
+ Exchange exchange = new DefaultExchange(context);
+ exchange.getIn().setHeader(Kinesis2Constants.PARTITION_KEY, "pk");
+ exchange.getIn().setBody(body);
+
+ producer.process(exchange);
+
+ assertEquals(List.of(10, 10, 1), requestSizes());
+ assertEquals(21, written.size());
+ assertEquals(21,
exchange.getMessage().getHeader(Kinesis2Constants.RECORD_COUNT, Integer.class));
+ }
+
+ @Test
+ public void
batchWithARecordAboveTheRecordLimitFailsBeforeAnyRequestIsSent() {
+ // 10 MiB of data and a partition key of 2 bytes
+ List<Object> body = List.of("r0", new byte[10 * 1024 * 1024]);
+ Exchange exchange = new DefaultExchange(context);
+ exchange.getIn().setHeader(Kinesis2Constants.PARTITION_KEY, "pk");
+ exchange.getIn().setBody(body);
+
+ IllegalArgumentException e =
assertThrows(IllegalArgumentException.class, () -> producer.process(exchange));
+
+ assertTrue(e.getMessage().contains("The record at index 1 of the batch
is 10485762 bytes"), e.getMessage());
+ verify(kinesisClient,
never()).putRecords(any(PutRecordsRequest.class));
+ }
+
@Test
public void pathBodyIsSentAsSingleRecordWithTheFileContent(@TempDir Path
dir) throws Exception {
when(kinesisClient.putRecord(any(PutRecordRequest.class)))
@@ -205,4 +358,47 @@ public class Kinesis2ProducerTest {
assertTrue(e.getMessage().contains("java.util."), e.getMessage());
verify(kinesisClient, never()).putRecord(any(PutRecordRequest.class));
}
+
+ /**
+ * Kinesis writes the records of a PutRecords request, except the records
the predicate selects: their result has an
+ * error code, as in a partly failed request.
+ */
+ private void stubPutRecords(Predicate<PutRecordsRequestEntry> fails) {
+ when(kinesisClient.putRecords(any(PutRecordsRequest.class)))
+ .thenAnswer(invocation -> respond(invocation.getArgument(0),
fails));
+ }
+
+ private PutRecordsResponse respond(PutRecordsRequest request,
Predicate<PutRecordsRequestEntry> fails) {
+ requests.add(request);
+ List<PutRecordsResultEntry> results = new ArrayList<>();
+ int failed = 0;
+ for (PutRecordsRequestEntry entry : request.records()) {
+ if (fails.test(entry)) {
+ failed++;
+ results.add(PutRecordsResultEntry.builder()
+ .errorCode("ProvisionedThroughputExceededException")
+ .errorMessage("Rate exceeded for shard
shardId-000000000000")
+ .build());
+ } else {
+ written.add(entry);
+ results.add(PutRecordsResultEntry.builder()
+ .shardId("shardId-000000000000")
+ .sequenceNumber(String.valueOf(written.size()))
+ .build());
+ }
+ }
+ return
PutRecordsResponse.builder().failedRecordCount(failed).records(results).build();
+ }
+
+ private List<Integer> requestSizes() {
+ return requests.stream().map(request ->
request.records().size()).toList();
+ }
+
+ private static List<String> records(int count) {
+ return IntStream.range(0, count).mapToObj(i -> "r" + i).toList();
+ }
+
+ private static List<String> data(List<PutRecordsRequestEntry> entries) {
+ return entries.stream().map(entry ->
entry.data().asUtf8String()).toList();
+ }
}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 4de48ed4ae14..aea6f09f6e66 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -874,6 +874,26 @@ closed that client for every other endpoint.
Camel now closes only the clients it created. A client set with
`amazonKinesisClient` or `amazonKinesisAsyncClient`, on
the component or on an endpoint, is no longer closed when Camel stops: close
it yourself when you no longer need it.
+=== camel-aws2-kinesis - the batch producer sends the failed records again and
splits at 10 MiB
+
+When the body is an `Iterable`, the producer now splits the batch into
`PutRecords` requests of at most 500 records
+and 10 MiB (the data and partition keys of the records). Before, only the
number of records was limited, and a request
+above 10 MiB was rejected by Kinesis. A record above 10 MiB now fails the
exchange with an `IllegalArgumentException`
+before any request is sent.
+
+When some records of a request are not written, the producer now sends only
those records again, up to 3 times with a
+delay of 100 to 400 milliseconds (plus a random part of up to a quarter of
it), and then sends the next requests of the
+batch. Before, the exchange failed at once and
+the next requests were not sent. If records are still not written, the
exchange fails after the last request, with a
+`RuntimeCamelException` (a `RuntimeException`, as before) whose message gives
the number of records written and not
+written. The `CamelAwsKinesisRecordCount` and
`CamelAwsKinesisFailedRecordCount` headers are now also set when the
+exchange fails, including when a `PutRecords` call throws: the exchange then
fails with the exception of the call, as
+before, and the records of that call and of the requests that were not sent
are counted as not written.
+Because of the retries, a batch whose records all fail now blocks the caller
longer before it fails (700 to 875
+milliseconds per request, 14 to 17.5 seconds for 10,000 records), and a record
that is sent again is written after the
+next records of its request. A redelivery of the exchange still sends the
whole batch again: see the Batch Producer
+section of xref:components::aws2-kinesis-component.adoc[AWS Kinesis].
+
=== camel-stitch - a response with an empty body, and the default sequence of
a message
A response of Stitch with an empty body now sets the `CamelStitchCode`,
`CamelStitchStatus` and `CamelStitchHeaders`