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 93b7f0643186 CAMEL-25785: camel-aws2-s3 - a timestamp window uploaded
more than once is stored in more than one object (#28136)
93b7f0643186 is described below
commit 93b7f064318628d7290d33bc615b22dea57acbe6
Author: allthingssecurity <[email protected]>
AuthorDate: Wed Oct 21 00:30:49 2026 +0530
CAMEL-25785: camel-aws2-s3 - a timestamp window uploaded more than once is
stored in more than one object (#28136)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../camel/catalog/docs/aws2-s3-streaming.adoc | 18 +-
.../src/main/docs/aws2-s3-streaming.adoc | 18 +-
.../aws2/s3/stream/AWS2S3StreamUploadProducer.java | 101 +++++++-
...S3StreamUploadTimestampGroupingEdgeCasesIT.java | 20 +-
.../S3StreamUploadTimestampGroupingIT.java | 40 +--
.../AWS2S3StreamUploadProducerTimestampTest.java | 22 +-
...S3StreamUploadTimestampWindowOverwriteTest.java | 282 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 15 ++
8 files changed, 484 insertions(+), 32 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/aws2-s3-streaming.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/aws2-s3-streaming.adoc
index 8bb1352c9381..1fa950c8cc9d 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/aws2-s3-streaming.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/aws2-s3-streaming.adoc
@@ -98,7 +98,23 @@ Messages are grouped based on their timestamps extracted
from the specified head
- Date: Java Date object
- String: String representation of Unix timestamp in milliseconds
-Files are automatically named using a timestamp-based pattern that includes
the time window information.
+Files are automatically named using a timestamp-based pattern that includes
the time window information, for example
+`grouped-messages_20240101_0800_0800-0805.txt` for the window from 08:00 to
08:05 (UTC). The upload of a window is
+completed when it reaches `batchMessageNumber` messages or `batchSize` bytes,
or when `streamingUploadTimeout` runs. When
+more messages of that window come after it, they are uploaded to a new object
of the window, named with the number of
+the uploads of the window before it:
`grouped-messages_20240101_0800_0800-0805-1.txt`, then `-2`, and so on. The
first
+time a producer uploads a window, it lists the objects of the window in the
bucket (one `ListObjectsV2` request with the
+window's key as prefix), so after a restart it continues after the highest
number already stored instead of replacing
+that object. This needs the `s3:ListBucket` permission: without it the
producer logs a WARN and numbers the uploads of
+the window from the first one, so an upload after a restart can replace an
object of the window again.
+
+These keys do not sort in upload order: `...0805-1.txt` sorts before
`...0805.txt` (`-` sorts before `.`), and `-10`
+sorts before `-2`. To read the uploads of a window in order, sort them by the
number after the time range (none for
+the first upload).
+
+Producers that run at the same time (for example two routes or two
applications) with the same `keyName` and
+timestamp grouping still choose the same names for the same window, so one can
replace the object of the other. Give
+each of them its own `keyName`.
==== Example Configuration
diff --git
a/components/camel-aws/camel-aws2-s3/src/main/docs/aws2-s3-streaming.adoc
b/components/camel-aws/camel-aws2-s3/src/main/docs/aws2-s3-streaming.adoc
index 8bb1352c9381..1fa950c8cc9d 100644
--- a/components/camel-aws/camel-aws2-s3/src/main/docs/aws2-s3-streaming.adoc
+++ b/components/camel-aws/camel-aws2-s3/src/main/docs/aws2-s3-streaming.adoc
@@ -98,7 +98,23 @@ Messages are grouped based on their timestamps extracted
from the specified head
- Date: Java Date object
- String: String representation of Unix timestamp in milliseconds
-Files are automatically named using a timestamp-based pattern that includes
the time window information.
+Files are automatically named using a timestamp-based pattern that includes
the time window information, for example
+`grouped-messages_20240101_0800_0800-0805.txt` for the window from 08:00 to
08:05 (UTC). The upload of a window is
+completed when it reaches `batchMessageNumber` messages or `batchSize` bytes,
or when `streamingUploadTimeout` runs. When
+more messages of that window come after it, they are uploaded to a new object
of the window, named with the number of
+the uploads of the window before it:
`grouped-messages_20240101_0800_0800-0805-1.txt`, then `-2`, and so on. The
first
+time a producer uploads a window, it lists the objects of the window in the
bucket (one `ListObjectsV2` request with the
+window's key as prefix), so after a restart it continues after the highest
number already stored instead of replacing
+that object. This needs the `s3:ListBucket` permission: without it the
producer logs a WARN and numbers the uploads of
+the window from the first one, so an upload after a restart can replace an
object of the window again.
+
+These keys do not sort in upload order: `...0805-1.txt` sorts before
`...0805.txt` (`-` sorts before `.`), and `-10`
+sorts before `-2`. To read the uploads of a window in order, sort them by the
number after the time range (none for
+the first upload).
+
+Producers that run at the same time (for example two routes or two
applications) with the same `keyName` and
+timestamp grouping still choose the same names for the same window, so one can
replace the object of the other. Give
+each of them its own `keyName`.
==== Example Configuration
diff --git
a/components/camel-aws/camel-aws2-s3/src/main/java/org/apache/camel/component/aws2/s3/stream/AWS2S3StreamUploadProducer.java
b/components/camel-aws/camel-aws2-s3/src/main/java/org/apache/camel/component/aws2/s3/stream/AWS2S3StreamUploadProducer.java
index 2e7cc2c75a16..660062fd6a9b 100644
---
a/components/camel-aws/camel-aws2-s3/src/main/java/org/apache/camel/component/aws2/s3/stream/AWS2S3StreamUploadProducer.java
+++
b/components/camel-aws/camel-aws2-s3/src/main/java/org/apache/camel/component/aws2/s3/stream/AWS2S3StreamUploadProducer.java
@@ -22,6 +22,7 @@ import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TimeZone;
@@ -57,6 +58,7 @@ import software.amazon.awssdk.services.s3.model.CompletedPart;
import software.amazon.awssdk.services.s3.model.CreateMultipartUploadRequest;
import software.amazon.awssdk.services.s3.model.CreateMultipartUploadResponse;
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
+import software.amazon.awssdk.services.s3.model.ListObjectsV2Response;
import software.amazon.awssdk.services.s3.model.ObjectCannedACL;
import software.amazon.awssdk.services.s3.model.S3Object;
import software.amazon.awssdk.services.s3.model.UploadPartRequest;
@@ -71,9 +73,22 @@ public class AWS2S3StreamUploadProducer extends
DefaultProducer {
private static final Logger LOG =
LoggerFactory.getLogger(AWS2S3StreamUploadProducer.class);
private static final String TIMEOUT_CHECKER_EXECUTOR_NAME =
"S3_Streaming_Upload_Timeout_Checker";
+ // the number of timestamp windows whose completed uploads are counted
+ private static final int MAX_COUNTED_WINDOWS = 10000;
private AtomicInteger part = new AtomicInteger();
private UploadState uploadAggregate = null;
private final Map<Long, UploadState> timestampBasedUploads = new
ConcurrentHashMap<>();
+ // The uploads completed for each timestamp window, guarded by the lock. A
window is uploaded again when it gets
+ // more messages after its upload was completed (batchMessageNumber,
batchSize or streamingUploadTimeout); the new
+ // object gets a name of its own, as an object with the name of the first
one would replace it. The count of a
+ // window starts from the objects of the window already in the bucket
(uploaded before a restart). Windows come in
+ // time order, so only the windows seen last are kept, and a long-running
producer does not keep every window.
+ private final Map<Long, Integer> completedUploadsByWindow = new
LinkedHashMap<>() {
+ @Override
+ protected boolean removeEldestEntry(Map.Entry<Long, Integer> eldest) {
+ return size() > MAX_COUNTED_WINDOWS;
+ }
+ };
private final Lock lock = new ReentrantLock();
private transient String s3ProducerToString;
private ScheduledExecutorService timeoutCheckerExecutorService;
@@ -109,10 +124,12 @@ public class AWS2S3StreamUploadProducer extends
DefaultProducer {
}
// Complete any pending timestamp-based uploads
- for (UploadState state : timestampBasedUploads.values()) {
+ for (Map.Entry<Long, UploadState> entry :
timestampBasedUploads.entrySet()) {
+ UploadState state = entry.getValue();
if (ObjectHelper.isNotEmpty(state) && state.buffer.size() > 0)
{
uploadPart(state);
completeUpload(state);
+ completedUploadsByWindow.merge(entry.getKey(), 1,
Integer::sum);
}
}
timestampBasedUploads.clear();
@@ -155,6 +172,7 @@ public class AWS2S3StreamUploadProducer extends
DefaultProducer {
try {
uploadPart(state);
completeUpload(state);
+ completedUploadsByWindow.merge(entry.getKey(),
1, Integer::sum);
} catch (Exception e) {
LOG.warn("Error during timeout-triggered
upload flush for {}", state.dynamicKeyName, e);
}
@@ -373,7 +391,8 @@ public class AWS2S3StreamUploadProducer extends
DefaultProducer {
final String keyName = getConfiguration().getKeyName();
final String fileName =
AWS2S3Utils.determineFileName(keyName);
final String extension =
AWS2S3Utils.determineFileExtension(keyName);
- newState.dynamicKeyName =
generateTimestampBasedFileName(fileName, extension, timestampWindow);
+ newState.dynamicKeyName =
generateTimestampBasedFileName(fileName, extension, timestampWindow,
+ completedUploads(timestampWindow, fileName,
extension));
// Initialize multipart upload
CreateMultipartUploadRequest.Builder
createMultipartUploadRequest
@@ -423,6 +442,7 @@ public class AWS2S3StreamUploadProducer extends
DefaultProducer {
uploadPart(state);
CompleteMultipartUploadResponse uploadResult =
completeUpload(state);
timestampBasedUploads.remove(timestampWindow);
+ completedUploadsByWindow.merge(timestampWindow, 1,
Integer::sum);
if (getConfiguration().isMultiPartUpload()) {
maxRead =
Math.toIntExact(getConfiguration().getPartSize());
}
@@ -544,7 +564,79 @@ public class AWS2S3StreamUploadProducer extends
DefaultProducer {
return (timestamp / windowSize) * windowSize;
}
- private String generateTimestampBasedFileName(String baseFileName, String
extension, long timestampWindow) {
+ /**
+ * The number of the completed uploads of a timestamp window. The first
time this producer sees the window, it is
+ * the number of the objects of the window in the bucket, so an upload
after a restart does not replace them.
+ */
+ private int completedUploads(long timestampWindow, String fileName, String
extension) {
+ Integer completed = completedUploadsByWindow.get(timestampWindow);
+ if (completed == null) {
+ completed =
uploadsInBucket(generateTimestampBasedFileName(fileName, null, timestampWindow,
0), extension);
+ completedUploadsByWindow.put(timestampWindow, completed);
+ }
+ return completed;
+ }
+
+ /**
+ * The number of the uploads of a timestamp window stored in the bucket:
an object named like the first upload of
+ * the window counts 1, an object of the later upload number N counts N +
1, and the highest wins. When the bucket
+ * cannot be listed (for example without the s3:ListBucket permission),
the count starts from 0.
+ */
+ private int uploadsInBucket(String windowKey, String extension) {
+ String ext = extension != null ? extension : "";
+ int uploads = 0;
+ try {
+ String continuationToken = null;
+ do {
+ ListObjectsV2Request.Builder request
+ =
ListObjectsV2Request.builder().bucket(getConfiguration().getBucketName()).prefix(windowKey);
+ if (continuationToken != null) {
+ request.continuationToken(continuationToken);
+ }
+ ListObjectsV2Response response =
getEndpoint().getS3Client().listObjectsV2(request.build());
+ for (S3Object object : response.contents()) {
+ uploads = Math.max(uploads, uploadNumber(object.key(),
windowKey, ext));
+ }
+ continuationToken =
Boolean.TRUE.equals(response.isTruncated()) ? response.nextContinuationToken()
: null;
+ } while (continuationToken != null);
+ } catch (Exception e) {
+ LOG.warn("Cannot list the objects of {} in bucket {}, so its
uploads are numbered from the first one and may"
+ + " replace objects uploaded before this producer
started: {}",
+ windowKey + ext, getConfiguration().getBucketName(),
e.getMessage());
+ LOG.debug("Listing the objects of {} failed", windowKey + ext, e);
+ return 0;
+ }
+ return uploads;
+ }
+
+ /**
+ * 1 for the object of the first upload of the window, N + 1 for the
object of the later upload number N, 0 for an
+ * object that is not an upload of the window.
+ */
+ private static int uploadNumber(String key, String windowKey, String
extension) {
+ if (key == null || key.length() < windowKey.length() +
extension.length() || !key.startsWith(windowKey)
+ || !key.endsWith(extension)) {
+ return 0;
+ }
+ String suffix = key.substring(windowKey.length(), key.length() -
extension.length());
+ if (suffix.isEmpty()) {
+ return 1;
+ }
+ // at most 9 digits, so the number fits an int
+ if (suffix.length() > 1 && suffix.length() <= 10 && suffix.charAt(0)
== '-'
+ && suffix.substring(1).chars().allMatch(c -> c >= '0' && c <=
'9')) {
+ return Integer.parseInt(suffix.substring(1)) + 1;
+ }
+ return 0;
+ }
+
+ /**
+ * The name of an upload of a timestamp window: the first upload of the
window has the name of the window, a later
+ * one (the window got more messages after its upload was completed) gets
the number of the uploads before it as a
+ * suffix, so it does not replace them.
+ */
+ private String generateTimestampBasedFileName(
+ String baseFileName, String extension, long timestampWindow, int
completedUploads) {
Date windowStart = new Date(timestampWindow);
Date windowEnd = new Date(timestampWindow +
getConfiguration().getTimestampWindowSizeMillis());
@@ -564,6 +656,9 @@ public class AWS2S3StreamUploadProducer extends
DefaultProducer {
SimpleDateFormat timeFormat = new SimpleDateFormat(timePattern);
timeFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
String timeRange = timeFormat.format(windowStart) + "-" +
timeFormat.format(windowEnd);
+ if (completedUploads > 0) {
+ timeRange = timeRange + "-" + completedUploads;
+ }
if (extension != null && !extension.isEmpty()) {
return baseFileName + "_" + sdf.format(windowStart) + "_" +
timeRange + extension;
diff --git
a/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/integration/S3StreamUploadTimestampGroupingEdgeCasesIT.java
b/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/integration/S3StreamUploadTimestampGroupingEdgeCasesIT.java
index bf26f73164f4..d75cfc9cacfa 100644
---
a/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/integration/S3StreamUploadTimestampGroupingEdgeCasesIT.java
+++
b/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/integration/S3StreamUploadTimestampGroupingEdgeCasesIT.java
@@ -27,6 +27,7 @@ import org.apache.camel.builder.RouteBuilder;
import org.apache.camel.component.aws2.s3.AWS2S3Constants;
import org.apache.camel.component.aws2.s3.AWS2S3Operations;
import org.apache.camel.component.mock.MockEndpoint;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.s3.model.S3Object;
@@ -41,6 +42,21 @@ public class S3StreamUploadTimestampGroupingEdgeCasesIT
extends Aws2S3Base {
@EndpointInject("mock:result")
private MockEndpoint result;
+ @BeforeEach
+ void removeTheObjectsOfAnEarlierRun() {
+ // a surefire rerun uses the same bucket, and a new producer numbers
the uploads of a window after the objects of
+ // that window already in the bucket: start from an empty bucket, so
only the objects of this test are listed
+ Exchange ex = template.request("direct:listObjects",
+ exchange ->
exchange.getIn().setHeader(AWS2S3Constants.S3_OPERATION,
AWS2S3Operations.listObjects));
+ List<S3Object> objects = ex.getMessage().getBody(List.class);
+ for (S3Object object : objects) {
+ template.request("direct:listObjects", exchange -> {
+ exchange.getIn().setHeader(AWS2S3Constants.S3_OPERATION,
AWS2S3Operations.deleteObject);
+ exchange.getIn().setHeader(AWS2S3Constants.KEY, object.key());
+ });
+ }
+ }
+
@Test
public void testTimestampGroupingWithInvalidStringTimestamp() throws
Exception {
result.reset();
@@ -128,7 +144,9 @@ public class S3StreamUploadTimestampGroupingEdgeCasesIT
extends Aws2S3Base {
});
List<S3Object> resp = ex.getMessage().getBody(List.class);
- assertEquals(1, resp.size(), "Should create only 1 file for large time
window");
+ // one window; batchMessageNumber=5 uploads it twice, and the second
upload gets its own object
+ assertEquals(List.of("largeWindowTest_20240101_0800_0800-0900-1.txt",
"largeWindowTest_20240101_0800_0800-0900.txt"),
+ resp.stream().map(S3Object::key).sorted().toList());
}
@Test
diff --git
a/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/integration/S3StreamUploadTimestampGroupingIT.java
b/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/integration/S3StreamUploadTimestampGroupingIT.java
index 548b00730545..a3320be01642 100644
---
a/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/integration/S3StreamUploadTimestampGroupingIT.java
+++
b/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/integration/S3StreamUploadTimestampGroupingIT.java
@@ -27,6 +27,7 @@ import org.apache.camel.builder.RouteBuilder;
import org.apache.camel.component.aws2.s3.AWS2S3Constants;
import org.apache.camel.component.aws2.s3.AWS2S3Operations;
import org.apache.camel.component.mock.MockEndpoint;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.s3.model.S3Object;
@@ -41,6 +42,21 @@ public class S3StreamUploadTimestampGroupingIT extends
Aws2S3Base {
@EndpointInject("mock:result")
private MockEndpoint result;
+ @BeforeEach
+ void removeTheObjectsOfAnEarlierRun() {
+ // a surefire rerun uses the same bucket, and a new producer numbers
the uploads of a window after the objects of
+ // that window already in the bucket: start from an empty bucket, so
only the objects of this test are listed
+ Exchange ex = template.request("direct:listObjects",
+ exchange ->
exchange.getIn().setHeader(AWS2S3Constants.S3_OPERATION,
AWS2S3Operations.listObjects));
+ List<S3Object> objects = ex.getMessage().getBody(List.class);
+ for (S3Object object : objects) {
+ template.request("direct:listObjects", exchange -> {
+ exchange.getIn().setHeader(AWS2S3Constants.S3_OPERATION,
AWS2S3Operations.deleteObject);
+ exchange.getIn().setHeader(AWS2S3Constants.KEY, object.key());
+ });
+ }
+ }
+
@Test
public void testTimestampGroupingBasic() throws Exception {
result.reset();
@@ -75,23 +91,13 @@ public class S3StreamUploadTimestampGroupingIT extends
Aws2S3Base {
});
List<S3Object> resp = ex.getMessage().getBody(List.class);
- assertEquals(2, resp.size(), "Should create 2 files for 2 different
timestamp windows");
-
- // Verify files have timestamp-based naming
- boolean foundWindow1File = false;
- boolean foundWindow2File = false;
-
- for (S3Object s3Object : resp) {
- String key = s3Object.key();
- if (key.contains("20240101_0800_0800-0805")) {
- foundWindow1File = true;
- } else if (key.contains("20240101_0805_0805-0810")) {
- foundWindow2File = true;
- }
- }
-
- assertTrue(foundWindow1File, "Should find file for first timestamp
window");
- assertTrue(foundWindow2File, "Should find file for second timestamp
window");
+ // batchMessageNumber=5: each window is uploaded twice, and the second
upload of a window gets its own object
+ assertEquals(List.of(
+ "basicTest_20240101_0800_0800-0805-1.txt",
+ "basicTest_20240101_0800_0800-0805.txt",
+ "basicTest_20240101_0805_0805-0810-1.txt",
+ "basicTest_20240101_0805_0805-0810.txt"),
+ resp.stream().map(S3Object::key).sorted().toList());
}
@Test
diff --git
a/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/stream/AWS2S3StreamUploadProducerTimestampTest.java
b/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/stream/AWS2S3StreamUploadProducerTimestampTest.java
index 4089a9669d8a..a1ef35f116fa 100644
---
a/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/stream/AWS2S3StreamUploadProducerTimestampTest.java
+++
b/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/stream/AWS2S3StreamUploadProducerTimestampTest.java
@@ -85,28 +85,32 @@ public class AWS2S3StreamUploadProducerTimestampTest {
@Test
public void testGenerateTimestampBasedFileName() throws Exception {
Method method = AWS2S3StreamUploadProducer.class.getDeclaredMethod(
- "generateTimestampBasedFileName", String.class, String.class,
long.class);
+ "generateTimestampBasedFileName", String.class, String.class,
long.class, int.class);
method.setAccessible(true);
when(configuration.getTimestampWindowSizeMillis()).thenReturn(300000L); // 5
minutes
// Test with extension
long timestampWindow = 1704096000000L; // 2024-01-01 08:00:00 UTC
- String fileName = (String) method.invoke(producer, "testfile", ".txt",
timestampWindow);
+ String fileName = (String) method.invoke(producer, "testfile", ".txt",
timestampWindow, 0);
assertEquals("testfile_20240101_0800_0800-0805.txt", fileName);
// Test without extension
- fileName = (String) method.invoke(producer, "testfile", null,
timestampWindow);
+ fileName = (String) method.invoke(producer, "testfile", null,
timestampWindow, 0);
assertEquals("testfile_20240101_0800_0800-0805", fileName);
// Test with empty extension
- fileName = (String) method.invoke(producer, "testfile", "",
timestampWindow);
+ fileName = (String) method.invoke(producer, "testfile", "",
timestampWindow, 0);
assertEquals("testfile_20240101_0800_0800-0805", fileName);
// Test different time window
long timestampWindow2 = 1704096300000L; // 2024-01-01 08:05:00 UTC
- fileName = (String) method.invoke(producer, "myfile", ".log",
timestampWindow2);
+ fileName = (String) method.invoke(producer, "myfile", ".log",
timestampWindow2, 0);
assertEquals("myfile_20240101_0805_0805-0810.log", fileName);
+
+ // a later upload of a window that was already uploaded
+ fileName = (String) method.invoke(producer, "myfile", ".log",
timestampWindow2, 2);
+ assertEquals("myfile_20240101_0805_0805-0810-2.log", fileName);
}
@Test
@@ -259,24 +263,24 @@ public class AWS2S3StreamUploadProducerTimestampTest {
@Test
public void testGenerateTimestampBasedFileNameWithDifferentWindowSizes()
throws Exception {
Method method = AWS2S3StreamUploadProducer.class.getDeclaredMethod(
- "generateTimestampBasedFileName", String.class, String.class,
long.class);
+ "generateTimestampBasedFileName", String.class, String.class,
long.class, int.class);
method.setAccessible(true);
long timestampWindow = 1704096000000L; // 2024-01-01 08:00:00 UTC
// Test 1-minute window
when(configuration.getTimestampWindowSizeMillis()).thenReturn(60000L);
- String fileName1min = (String) method.invoke(producer, "test", ".txt",
timestampWindow);
+ String fileName1min = (String) method.invoke(producer, "test", ".txt",
timestampWindow, 0);
assertEquals("test_20240101_0800_0800-0801.txt", fileName1min);
// Test 1-hour window
when(configuration.getTimestampWindowSizeMillis()).thenReturn(3600000L);
- String fileName1hour = (String) method.invoke(producer, "test",
".txt", timestampWindow);
+ String fileName1hour = (String) method.invoke(producer, "test",
".txt", timestampWindow, 0);
assertEquals("test_20240101_0800_0800-0900.txt", fileName1hour);
// Test 30-minute window
when(configuration.getTimestampWindowSizeMillis()).thenReturn(1800000L);
- String fileName30min = (String) method.invoke(producer, "test",
".txt", timestampWindow);
+ String fileName30min = (String) method.invoke(producer, "test",
".txt", timestampWindow, 0);
assertEquals("test_20240101_0800_0800-0830.txt", fileName30min);
}
}
diff --git
a/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/stream/AWS2S3StreamUploadTimestampWindowOverwriteTest.java
b/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/stream/AWS2S3StreamUploadTimestampWindowOverwriteTest.java
new file mode 100644
index 000000000000..87f304d5b47c
--- /dev/null
+++
b/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/stream/AWS2S3StreamUploadTimestampWindowOverwriteTest.java
@@ -0,0 +1,282 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ * http://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.apache.camel.component.aws2.s3.stream;
+
+import java.io.ByteArrayOutputStream;
+import java.lang.reflect.Constructor;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.component.aws2.s3.AWS2S3Configuration;
+import org.apache.camel.component.aws2.s3.AWS2S3Endpoint;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.DefaultExchange;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.http.SdkHttpResponse;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.CompleteMultipartUploadRequest;
+import
software.amazon.awssdk.services.s3.model.CompleteMultipartUploadResponse;
+import software.amazon.awssdk.services.s3.model.CreateMultipartUploadRequest;
+import software.amazon.awssdk.services.s3.model.CreateMultipartUploadResponse;
+import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
+import software.amazon.awssdk.services.s3.model.ListObjectsV2Response;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+import software.amazon.awssdk.services.s3.model.S3Object;
+import software.amazon.awssdk.services.s3.model.UploadPartRequest;
+import software.amazon.awssdk.services.s3.model.UploadPartResponse;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+/**
+ * With timestamp grouping, a window whose upload is completed (by
batchMessageNumber, batchSize or the streaming upload
+ * timeout) and then gets more messages is uploaded again. The new object must
not replace the first one: a bucket keeps
+ * one object per key. Uses a mocked S3 client that keeps the completed
objects by key, like a bucket.
+ */
+public class AWS2S3StreamUploadTimestampWindowOverwriteTest {
+
+ private static final long WINDOW_START = 1704096000000L; // 2024-01-01
08:00:00 UTC
+
+ @Mock
+ private AWS2S3Endpoint endpoint;
+
+ @Mock
+ private AWS2S3Configuration configuration;
+
+ @Mock
+ private S3Client s3Client;
+
+ // the bucket: completed objects by key
+ private final Map<String, String> bucket = new ConcurrentHashMap<>();
+ private final Map<String, String> keysByUploadId = new
ConcurrentHashMap<>();
+ private final Map<String, ByteArrayOutputStream> partsByUploadId = new
ConcurrentHashMap<>();
+ private final AtomicInteger uploadIds = new AtomicInteger();
+
+ private AWS2S3StreamUploadProducer producer;
+ private DefaultCamelContext camelContext;
+
+ @BeforeEach
+ public void setUp() {
+ MockitoAnnotations.openMocks(this);
+ camelContext = new DefaultCamelContext();
+
+ when(endpoint.getConfiguration()).thenReturn(configuration);
+ when(endpoint.getCamelContext()).thenReturn(camelContext);
+ when(endpoint.getS3Client()).thenReturn(s3Client);
+
+ when(configuration.getBucketName()).thenReturn("test-bucket");
+ when(configuration.getKeyName()).thenReturn("grouped-messages.txt");
+ when(configuration.isMultiPartUpload()).thenReturn(false);
+ when(configuration.getPartSize()).thenReturn(25L * 1024 * 1024);
+
when(configuration.getNamingStrategy()).thenReturn(AWSS3NamingStrategyEnum.progressive);
+
when(configuration.getRestartingPolicy()).thenReturn(AWSS3RestartingPolicyEnum.override);
+ // the defaults of the endpoint
+ when(configuration.getBatchMessageNumber()).thenReturn(10);
+ when(configuration.getBatchSize()).thenReturn(1000000);
+ when(configuration.getBufferSize()).thenReturn(1000000);
+ when(configuration.isTimestampGroupingEnabled()).thenReturn(true);
+ when(configuration.getTimestampWindowSizeMillis()).thenReturn(300000L);
+
when(configuration.getTimestampHeaderName()).thenReturn(Exchange.MESSAGE_TIMESTAMP);
+
+ SdkHttpResponse httpResponse =
SdkHttpResponse.builder().statusCode(200).build();
+
+
when(s3Client.createMultipartUpload(any(CreateMultipartUploadRequest.class))).thenAnswer(invocation
-> {
+ CreateMultipartUploadRequest request = invocation.getArgument(0);
+ String uploadId = "upload-" + uploadIds.incrementAndGet();
+ keysByUploadId.put(uploadId, request.key());
+ partsByUploadId.put(uploadId, new ByteArrayOutputStream());
+ return
CreateMultipartUploadResponse.builder().uploadId(uploadId).sdkHttpResponse(httpResponse).build();
+ });
+
+ when(s3Client.uploadPart(any(UploadPartRequest.class),
any(RequestBody.class))).thenAnswer(invocation -> {
+ UploadPartRequest request = invocation.getArgument(0);
+ RequestBody body = invocation.getArgument(1);
+
partsByUploadId.get(request.uploadId()).write(body.contentStreamProvider().newStream().readAllBytes());
+ return
UploadPartResponse.builder().eTag("etag").sdkHttpResponse(httpResponse).build();
+ });
+
+
when(s3Client.completeMultipartUpload(any(CompleteMultipartUploadRequest.class))).thenAnswer(invocation
-> {
+ CompleteMultipartUploadRequest request = invocation.getArgument(0);
+ // like S3: the completed object replaces any object of the same
key
+ bucket.put(keysByUploadId.get(request.uploadId()),
+
partsByUploadId.get(request.uploadId()).toString(StandardCharsets.UTF_8));
+ return
CompleteMultipartUploadResponse.builder().eTag("final-etag").sdkHttpResponse(httpResponse).build();
+ });
+
+ // like S3: the keys with the prefix in lexical order, at most two per
page
+
when(s3Client.listObjectsV2(any(ListObjectsV2Request.class))).thenAnswer(invocation
-> {
+ ListObjectsV2Request request = invocation.getArgument(0);
+ List<String> keys = bucket.keySet().stream().filter(key ->
key.startsWith(request.prefix())).sorted()
+ .toList();
+ int from = request.continuationToken() != null ?
Integer.parseInt(request.continuationToken()) : 0;
+ int to = Math.min(from + 2, keys.size());
+ ListObjectsV2Response.Builder response =
ListObjectsV2Response.builder()
+ .contents(keys.subList(from, to).stream().map(key ->
S3Object.builder().key(key).build()).toList())
+ .isTruncated(to < keys.size());
+ if (to < keys.size()) {
+ response.nextContinuationToken(String.valueOf(to));
+ }
+ return response.build();
+ });
+
+ producer = new AWS2S3StreamUploadProducer(endpoint);
+ }
+
+ @Test
+ public void
everyMessageOfAWindowIsStoredWhenTheWindowIsUploadedMoreThanOnce() throws
Exception {
+ // the example of the documentation: a message every 10 seconds, in 5
minute windows, so 30 messages per
+ // window; with batchMessageNumber=10 the window is uploaded after the
10th, the 20th and the 30th message
+ List<String> sent = new ArrayList<>();
+ for (int i = 0; i < 30; i++) {
+ String body = "message-" + i + "\n";
+ sent.add(body);
+ Exchange exchange = new DefaultExchange(camelContext);
+ exchange.getIn().setBody(body);
+ exchange.getIn().setHeader(Exchange.MESSAGE_TIMESTAMP,
WINDOW_START + i * 10000L);
+ producer.process(exchange);
+ }
+
+ assertEquals(3, uploadIds.get(), "the window is uploaded three times");
+ assertEquals(3, bucket.size(),
+ "each upload of the window must be its own object, but the
bucket has " + bucket.keySet());
+ StringBuilder stored = new StringBuilder();
+ bucket.keySet().stream().sorted().forEach(key ->
stored.append(bucket.get(key)));
+ List<String> storedMessages = new ArrayList<>();
+ for (String line : stored.toString().split("\n")) {
+ storedMessages.add(line + "\n");
+ }
+ assertEquals(sent.size(), storedMessages.size(), "every message sent
must be in the bucket");
+ assertEquals(sent.stream().sorted().toList(),
storedMessages.stream().sorted().toList());
+ }
+
+ @Test
+ public void
everyMessageOfAWindowIsStoredWhenTheTimeoutUploadsTheWindowBeforeItEnds()
throws Exception {
+ // streamingUploadTimeout: the timer uploads the window, and more
messages of the window come after it
+ Runnable timeoutTask = timeoutTask();
+ for (int i = 0; i < 10; i++) {
+ Exchange exchange = new DefaultExchange(camelContext);
+ exchange.getIn().setBody("message-" + i + "\n");
+ exchange.getIn().setHeader(Exchange.MESSAGE_TIMESTAMP,
WINDOW_START + i * 10000L);
+ producer.process(exchange);
+ if (i == 4) {
+ timeoutTask.run();
+ }
+ }
+ timeoutTask.run();
+
+ assertEquals(2, uploadIds.get(), "the window is uploaded twice");
+ assertEquals(2, bucket.size(),
+ "each upload of the window must be its own object, but the
bucket has " + bucket.keySet());
+ assertEquals("message-0\nmessage-1\nmessage-2\nmessage-3\nmessage-4\n",
+ bucket.get("grouped-messages_20240101_0800_0800-0805.txt"));
+ assertEquals("message-5\nmessage-6\nmessage-7\nmessage-8\nmessage-9\n",
+ bucket.get("grouped-messages_20240101_0800_0800-0805-1.txt"));
+ }
+
+ private Runnable timeoutTask() throws Exception {
+ Class<?> taskClass =
Class.forName(AWS2S3StreamUploadProducer.class.getName() +
"$StreamingUploadTimeoutTask");
+ Constructor<?> constructor =
taskClass.getDeclaredConstructor(AWS2S3StreamUploadProducer.class);
+ constructor.setAccessible(true);
+ return (Runnable) constructor.newInstance(producer);
+ }
+
+ @Test
+ public void theFirstUploadOfAWindowKeepsItsName() throws Exception {
+ for (int i = 0; i < 10; i++) {
+ Exchange exchange = new DefaultExchange(camelContext);
+ exchange.getIn().setBody("message-" + i + "\n");
+ exchange.getIn().setHeader(Exchange.MESSAGE_TIMESTAMP,
WINDOW_START + i * 10000L);
+ producer.process(exchange);
+ }
+
+ assertEquals(List.of("grouped-messages_20240101_0800_0800-0805.txt"),
new ArrayList<>(bucket.keySet()));
+ }
+
+ @Test
+ public void anUploadAfterARestartDoesNotReplaceTheObjectsOfTheWindow()
throws Exception {
+ // stopping the producer uploads the open window; after a restart (a
new producer) the next upload of the
+ // window must not replace that object
+ producer.start();
+ sendMessages(0, 5);
+ producer.stop();
+ assertEquals(List.of("grouped-messages_20240101_0800_0800-0805.txt"),
new ArrayList<>(bucket.keySet()));
+
+ producer = new AWS2S3StreamUploadProducer(endpoint);
+ producer.start();
+ sendMessages(5, 10);
+ producer.stop();
+
+ assertEquals("message-0\nmessage-1\nmessage-2\nmessage-3\nmessage-4\n",
+ bucket.get("grouped-messages_20240101_0800_0800-0805.txt"));
+ assertEquals("message-5\nmessage-6\nmessage-7\nmessage-8\nmessage-9\n",
+ bucket.get("grouped-messages_20240101_0800_0800-0805-1.txt"));
+ assertEquals(2, bucket.size(), "the bucket has " + bucket.keySet());
+ }
+
+ @Test
+ public void theNextUploadAfterARestartFollowsTheHighestUploadInTheBucket()
throws Exception {
+ // listed in lexical order "-1", "-10", "-2": the highest number wins,
not the last key listed
+ bucket.put("grouped-messages_20240101_0800_0800-0805.txt", "a");
+ bucket.put("grouped-messages_20240101_0800_0800-0805-1.txt", "b");
+ bucket.put("grouped-messages_20240101_0800_0800-0805-10.txt", "c");
+ bucket.put("grouped-messages_20240101_0800_0800-0805-2.txt", "d");
+ // not uploads of the window
+ bucket.put("grouped-messages_20240101_0800_0800-0805-99.log", "e");
+ bucket.put("grouped-messages_20240101_0800_0800-0805-x.txt", "f");
+ bucket.put("grouped-messages_20240101_0800_0800-0805-12-1.txt", "g");
+
+ sendMessages(0, 10);
+
+
assertEquals("message-0\nmessage-1\nmessage-2\nmessage-3\nmessage-4\nmessage-5\nmessage-6\nmessage-7\n"
+ + "message-8\nmessage-9\n",
+ bucket.get("grouped-messages_20240101_0800_0800-0805-11.txt"));
+ assertEquals(8, bucket.size(), "the bucket has " + bucket.keySet());
+ }
+
+ @Test
+ public void
theUploadsOfAWindowAreNumberedFromTheFirstWhenTheBucketCannotBeListed() throws
Exception {
+ // without s3:ListBucket the producer still uploads, numbering the
uploads of the window from the first one
+ when(s3Client.listObjectsV2(any(ListObjectsV2Request.class)))
+
.thenThrow(S3Exception.builder().statusCode(403).message("Access
Denied").build());
+
+ sendMessages(0, 20);
+
+ assertEquals(2, bucket.size(), "the bucket has " + bucket.keySet());
+
assertTrue(bucket.containsKey("grouped-messages_20240101_0800_0800-0805.txt"));
+
assertTrue(bucket.containsKey("grouped-messages_20240101_0800_0800-0805-1.txt"));
+ }
+
+ private void sendMessages(int from, int to) throws Exception {
+ for (int i = from; i < to; i++) {
+ Exchange exchange = new DefaultExchange(camelContext);
+ exchange.getIn().setBody("message-" + i + "\n");
+ exchange.getIn().setHeader(Exchange.MESSAGE_TIMESTAMP,
WINDOW_START + i * 10000L);
+ producer.process(exchange);
+ }
+ }
+}
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 21a1aef9398e..7e9cb0ad2b31 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
@@ -1445,6 +1445,21 @@ context. A check sends two `LookupEvents` requests, one
per event name, and one
always one request, for all Secrets Manager events); reads of secrets no
longer add requests. LookupEvents allows two
requests per second per account and Region.
+=== camel-aws2-s3 - a timestamp window uploaded more than once is stored in
more than one object
+
+With `streamingUploadMode=true` and `timestampGroupingEnabled=true`, the
upload of a time window is completed when it
+reaches `batchMessageNumber` messages (10 by default) or `batchSize` bytes, or
when `streamingUploadTimeout` runs. A
+later message of the same window started a new upload with the same key, and
S3 replaced the object of the first upload
+with it, so the messages of the earlier uploads were lost (unless the bucket
is versioned). A later upload of a window
+now gets the number of the uploads of that window before it after the time
range: for example
+`grouped-messages_20240101_0800_0800-0805.txt`, then
`grouped-messages_20240101_0800_0800-0805-1.txt`,
+`grouped-messages_20240101_0800_0800-0805-2.txt`, and so on. The first upload
of a window keeps the name it had. The
+first time a producer uploads a window, it lists the objects of the window in
the bucket (one `ListObjectsV2` request
+per window), so after a restart it continues after the highest number stored;
this needs the `s3:ListBucket`
+permission, and without it the producer logs a WARN and numbers the uploads of
the window from the first one. These
+keys do not sort in upload order (`-1` sorts before the first upload's name,
`-10` before `-2`). Producers running at
+the same time with the same `keyName` still need distinct key names.
+
=== camel-aws2-kinesis - the consumer continues after the last record when a
shard iterator expired
When a cached shard iterator has expired (an iterator is valid for 5 minutes,
so after a route was suspended for