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 b130c548223e CAMEL-26539: camel-aws2-s3 - streaming upload writes 
concurrent exchanges one at a time (#29122)
b130c548223e is described below

commit b130c548223e0cb6e28486e411779361dbdd59cf
Author: allthingssecurity <[email protected]>
AuthorDate: Wed Nov 4 15:05:15 2026 +0530

    CAMEL-26539: camel-aws2-s3 - streaming upload writes concurrent exchanges 
one at a time (#29122)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../aws2/s3/stream/AWS2S3StreamUploadProducer.java |  91 ++++----
 .../AWS2S3StreamUploadConcurrentExchangesTest.java | 242 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |   8 +
 3 files changed, 293 insertions(+), 48 deletions(-)

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 660062fd6a9b..cf466437f177 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
@@ -199,59 +199,64 @@ public class AWS2S3StreamUploadProducer extends 
DefaultProducer {
     private void processWithoutTimestampGrouping(final Exchange exchange) 
throws Exception {
         InputStream is = exchange.getIn().getMandatoryBody(InputStream.class);
 
+        // one exchange at a time: the exchanges share the open upload and the 
part number of the object names
+        lock.lock();
+        try {
+            writeWithoutTimestampGrouping(exchange, is);
+        } finally {
+            lock.unlock();
+        }
+    }
+
+    private void writeWithoutTimestampGrouping(final Exchange exchange, final 
InputStream is) throws Exception {
         UploadState state = null;
         int totalSize = 0;
         byte[] b;
         int maxRead = (getConfiguration().isMultiPartUpload()
                 ? Math.toIntExact(getConfiguration().getPartSize()) : 
getConfiguration().getBufferSize());
-        lock.lock();
-        try {
-            if (uploadAggregate != null) {
-                uploadAggregate.index++;
-                maxRead -= uploadAggregate.buffer.size();
-                maxRead = Math.max(1, maxRead);
-            }
-        } finally {
-            lock.unlock();
+        if (uploadAggregate != null) {
+            uploadAggregate.index++;
+            maxRead -= uploadAggregate.buffer.size();
+            maxRead = Math.max(1, maxRead);
         }
 
         while ((b = AWS2S3Utils.toByteArray(is, maxRead)) != null && b.length 
> 0) {
             totalSize += b.length;
             if (getConfiguration().isMultiPartUpload())
                 maxRead -= b.length;
-            lock.lock();
-            try {
-                // aggregate with previously received exchanges
-                if (ObjectHelper.isNotEmpty(uploadAggregate)) {
-                    uploadAggregate.buffer.write(b);
-                    if (getConfiguration().isMultiPartUpload() &&
-                            uploadAggregate.buffer.size() >= 
getConfiguration().getPartSize()) {
+            // aggregate with previously received exchanges
+            if (ObjectHelper.isNotEmpty(uploadAggregate)) {
+                uploadAggregate.buffer.write(b);
+                if (getConfiguration().isMultiPartUpload() &&
+                        uploadAggregate.buffer.size() >= 
getConfiguration().getPartSize()) {
+                    uploadPart(uploadAggregate);
+                    maxRead = 
Math.toIntExact(getConfiguration().getPartSize());
+                    continue;
+                }
+                if (uploadAggregate.buffer.size() >= 
getConfiguration().getBatchSize()
+                        || (uploadAggregate.index >= 
getConfiguration().getBatchMessageNumber()
+                                && uploadAggregate.buffer.size() < 
getConfiguration().getPartSize())) {
+
+                    if (uploadAggregate.buffer.size() > 0)
                         uploadPart(uploadAggregate);
+                    CompleteMultipartUploadResponse uploadResult;
+                    try {
+                        uploadResult = completeUpload(uploadAggregate);
+                    } finally {
+                        // completeUpload aborts the upload when it fails: do 
not append to it any longer
+                        this.uploadAggregate = null;
+                    }
+                    if (getConfiguration().isMultiPartUpload()) {
                         maxRead = 
Math.toIntExact(getConfiguration().getPartSize());
-                        continue;
                     }
-                    if (uploadAggregate.buffer.size() >= 
getConfiguration().getBatchSize()
-                            || (uploadAggregate.index >= 
getConfiguration().getBatchMessageNumber()
-                                    && uploadAggregate.buffer.size() < 
getConfiguration().getPartSize())) {
-
-                        if (uploadAggregate.buffer.size() > 0)
-                            uploadPart(uploadAggregate);
-                        CompleteMultipartUploadResponse uploadResult = 
completeUpload(uploadAggregate);
-                        this.uploadAggregate = null;
-                        if (getConfiguration().isMultiPartUpload()) {
-                            maxRead = 
Math.toIntExact(getConfiguration().getPartSize());
-                        }
-                        Message message = getMessageForResponse(exchange);
-                        message.setHeader(AWS2S3Constants.E_TAG, 
uploadResult.eTag());
-                        if (uploadResult.versionId() != null) {
-                            message.setHeader(AWS2S3Constants.VERSION_ID, 
uploadResult.versionId());
-                        }
-                        populateHttpResponseCode(uploadResult, message);
+                    Message message = getMessageForResponse(exchange);
+                    message.setHeader(AWS2S3Constants.E_TAG, 
uploadResult.eTag());
+                    if (uploadResult.versionId() != null) {
+                        message.setHeader(AWS2S3Constants.VERSION_ID, 
uploadResult.versionId());
                     }
-                    continue;
+                    populateHttpResponseCode(uploadResult, message);
                 }
-            } finally {
-                lock.unlock();
+                continue;
             }
             if (state == null) {
                 state = new UploadState();
@@ -337,17 +342,7 @@ public class AWS2S3StreamUploadProducer extends 
DefaultProducer {
 
         if (ObjectHelper.isNotEmpty(state)) {
             // exchange wasn't large enough to send, batch it with subsequent 
exchanges.
-            lock.lock();
-            try {
-                if (ObjectHelper.isEmpty(this.uploadAggregate)) {
-                    this.uploadAggregate = state;
-                } else {
-                    // handle potential race condition.
-                    
this.uploadAggregate.buffer.write(state.buffer.toByteArray());
-                }
-            } finally {
-                lock.unlock();
-            }
+            this.uploadAggregate = state;
         }
     }
 
diff --git 
a/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/stream/AWS2S3StreamUploadConcurrentExchangesTest.java
 
b/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/stream/AWS2S3StreamUploadConcurrentExchangesTest.java
new file mode 100644
index 000000000000..83542bc3e9cb
--- /dev/null
+++ 
b/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/stream/AWS2S3StreamUploadConcurrentExchangesTest.java
@@ -0,0 +1,242 @@
+/*
+ * 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.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+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.AfterEach;
+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.AbortMultipartUploadRequest;
+import software.amazon.awssdk.services.s3.model.AbortMultipartUploadResponse;
+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.UploadPartRequest;
+import software.amazon.awssdk.services.s3.model.UploadPartResponse;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+/**
+ * Exchanges sent at the same time through one streaming upload producer must 
not start two uploads. The mocked S3Client
+ * keeps the completed objects by key and holds the first 
createMultipartUpload call until the test releases it.
+ */
+public class AWS2S3StreamUploadConcurrentExchangesTest {
+
+    @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<>();
+    // the uploads neither completed nor aborted
+    private final Map<String, String> openUploads = new ConcurrentHashMap<>();
+    private final AtomicInteger uploadIds = new AtomicInteger();
+    // a failure of an exchange sent from another thread
+    private final AtomicReference<Throwable> failure = new AtomicReference<>();
+
+    // the first createMultipartUpload call waits in the S3 client until 
released
+    private final CountDownLatch firstCreateEntered = new CountDownLatch(1);
+    private final CountDownLatch releaseFirstCreate = new CountDownLatch(1);
+
+    private DefaultCamelContext camelContext;
+    private AWS2S3StreamUploadProducer producer;
+
+    @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("camel.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);
+
+        SdkHttpResponse httpResponse = 
SdkHttpResponse.builder().statusCode(200).build();
+
+        
when(s3Client.createMultipartUpload(any(CreateMultipartUploadRequest.class))).thenAnswer(invocation
 -> {
+            CreateMultipartUploadRequest request = invocation.getArgument(0);
+            int id = uploadIds.incrementAndGet();
+            if (id == 1) {
+                firstCreateEntered.countDown();
+                assertTrue(releaseFirstCreate.await(20, TimeUnit.SECONDS), 
"the first upload was not released");
+            }
+            String uploadId = "upload-" + id;
+            keysByUploadId.put(uploadId, request.key());
+            partsByUploadId.put(uploadId, new ByteArrayOutputStream());
+            openUploads.put(uploadId, request.key());
+            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);
+            ByteArrayOutputStream parts = 
partsByUploadId.get(request.uploadId());
+            synchronized (parts) {
+                
parts.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);
+            openUploads.remove(request.uploadId());
+            // 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();
+        });
+
+        
when(s3Client.abortMultipartUpload(any(AbortMultipartUploadRequest.class))).thenAnswer(invocation
 -> {
+            AbortMultipartUploadRequest request = invocation.getArgument(0);
+            openUploads.remove(request.uploadId());
+            return AbortMultipartUploadResponse.builder().build();
+        });
+
+        producer = new AWS2S3StreamUploadProducer(endpoint);
+    }
+
+    @AfterEach
+    public void tearDown() {
+        releaseFirstCreate.countDown();
+        camelContext.stop();
+    }
+
+    @Test
+    public void 
anExchangeThatStartedAnUploadWhileAnotherRanDoesNotReplaceAnObject() throws 
Exception {
+        producer.start();
+
+        // exchange 0 waits in createMultipartUpload while 10 other exchanges 
arrive (batchMessageNumber=10)
+        sendWhileTheFirstUploadIsCreated("message-0\n", () -> send(1, 10));
+        send(11, 9);
+        producer.stop();
+
+        assertNull(failure.get(), () -> "an exchange failed: " + 
failure.get());
+        List<String> stored = storedLines();
+        for (int i = 0; i < 20; i++) {
+            assertEquals(1, Collections.frequency(stored, "message-" + i),
+                    "message-" + i + " must be stored once, but the bucket has 
" + bucket);
+        }
+        assertEquals(20, stored.size(), "every message sent must be in the 
bucket: " + bucket);
+        assertEquals(Collections.emptyMap(), openUploads, "every upload is 
completed or aborted");
+    }
+
+    @Test
+    public void 
theFirstPartOfALargeBodyIsNotLostWhenAnotherExchangeOpensTheUpload() throws 
Exception {
+        // multipart upload; the body of exchange A is one part and a half 
(the reads of the producer are 4096 bytes)
+        when(configuration.isMultiPartUpload()).thenReturn(true);
+        when(configuration.getPartSize()).thenReturn(8192L);
+        String large = "A".repeat(8191) + "\n" + "B".repeat(4095) + "\n";
+        producer.start();
+
+        // the large exchange reads its first part and waits in 
createMultipartUpload while a small exchange arrives
+        sendWhileTheFirstUploadIsCreated(large, () -> send(0, 1));
+        producer.stop();
+
+        assertNull(failure.get(), () -> "an exchange failed: " + 
failure.get());
+        String all = String.join("", bucket.values());
+        assertTrue(all.contains(large), "the large body must be stored whole, 
but the bucket has "
+                                        + bucket.keySet() + " with " + 
all.length() + " characters");
+        assertTrue(all.contains("message-0\n"), "the small body must be 
stored");
+        assertEquals(large.length() + "message-0\n".length(), all.length(), 
"nothing else is stored");
+        assertEquals(Collections.emptyMap(), openUploads, "every upload is 
completed or aborted");
+    }
+
+    private void sendWhileTheFirstUploadIsCreated(String firstBody, Runnable 
others) throws Exception {
+        Thread first = new Thread(() -> {
+            try {
+                Exchange exchange = new DefaultExchange(camelContext);
+                exchange.getIn().setBody(firstBody);
+                producer.process(exchange);
+            } catch (Throwable e) {
+                failure.set(e);
+            }
+        }, "first");
+        first.start();
+        assertTrue(firstCreateEntered.await(20, TimeUnit.SECONDS));
+
+        Thread other = new Thread(others, "others");
+        other.start();
+        // the other exchanges either ran (the first one holds no lock) or 
wait for the first one
+        await().atMost(20, TimeUnit.SECONDS)
+                .until(() -> !other.isAlive() || other.getState() == 
Thread.State.WAITING);
+        releaseFirstCreate.countDown();
+        first.join(20000);
+        other.join(20000);
+        assertFalse(first.isAlive() || other.isAlive(), "the exchanges did not 
complete");
+    }
+
+    private void send(int from, int count) {
+        for (int i = from; i < from + count; i++) {
+            Exchange exchange = new DefaultExchange(camelContext);
+            exchange.getIn().setBody("message-" + i + "\n");
+            try {
+                producer.process(exchange);
+            } catch (Throwable e) {
+                failure.set(e);
+            }
+        }
+    }
+
+    private List<String> storedLines() {
+        return bucket.values().stream().flatMap(content -> 
Arrays.stream(content.split("\n"))).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 3e329077add8..ed00683ba05a 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
@@ -3560,6 +3560,14 @@ named after `keyName` alone (for example `camel.txt`), 
and only the later files
 the same `keyName`, wrote its first file to the same key and replaced the 
first file written before. Every file now
 gets the UUID (or the timestamp), the first one too: `camel-<uuid>.txt`. The 
progressive naming strategy is unchanged.
 
+=== camel-aws2-s3 - streaming upload writes concurrent exchanges one at a time
+
+With `streamingUploadMode=true` (without `timestampGroupingEnabled`), the 
producer now writes one exchange at a time,
+so exchanges sent concurrently (concurrent consumers, parallel splits, 
`threads()`) no longer lose objects or parts.
+The body of an exchange is read while the other exchanges of the same producer 
wait, and bodies that complete an
+upload of their own (`batchSize` or more, or `batchMessageNumber=1`) are no 
longer uploaded in parallel. To upload
+large bodies in parallel, use the producer without `streamingUploadMode`.
+
 === camel-atom - sortEntries sorts by the updated date and routes the oldest 
entry first
 
 With `sortEntries=true` the `atom` consumer sorted the entries of a feed by 
their published date and routed them from

Reply via email to