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 91e523dafe45 CAMEL-25521: camel-aws2-s3 - Keep the in-progress 
repository of the consumer accurate with fileName and on stop (#27672)
91e523dafe45 is described below

commit 91e523dafe457ce6be9f9a1841adf56a7b985eff
Author: allthingssecurity <[email protected]>
AuthorDate: Sun Oct 11 13:25:03 2026 +0530

    CAMEL-25521: camel-aws2-s3 - Keep the in-progress repository of the 
consumer accurate with fileName and on stop (#27672)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../camel/component/aws2/s3/AWS2S3Consumer.java    |  71 +++--
 .../aws2/s3/AWS2S3ConsumerInProgressTest.java      | 313 +++++++++++++++++++++
 .../camel/component/aws2/s3/FakeS3Server.java      | 194 +++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    |   7 +
 4 files changed, 568 insertions(+), 17 deletions(-)

diff --git 
a/components/camel-aws/camel-aws2-s3/src/main/java/org/apache/camel/component/aws2/s3/AWS2S3Consumer.java
 
b/components/camel-aws/camel-aws2-s3/src/main/java/org/apache/camel/component/aws2/s3/AWS2S3Consumer.java
index a0e5fce60195..57c114127251 100644
--- 
a/components/camel-aws/camel-aws2-s3/src/main/java/org/apache/camel/component/aws2/s3/AWS2S3Consumer.java
+++ 
b/components/camel-aws/camel-aws2-s3/src/main/java/org/apache/camel/component/aws2/s3/AWS2S3Consumer.java
@@ -16,6 +16,7 @@
  */
 package org.apache.camel.component.aws2.s3;
 
+import java.io.Closeable;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -124,23 +125,23 @@ public class AWS2S3Consumer extends 
ScheduledBatchPollingConsumer {
         if (!doneFileCheckPasses(bucketName, doneFileName)) {
             exchanges = new LinkedList<>();
         } else if (fileName != null) {
-            LOG.trace("Getting object in bucket [{}] with file name [{}]...", 
bucketName, fileName);
-
-            GetObjectRequest.Builder getRequest = 
GetObjectRequest.builder().bucket(bucketName).key(fileName);
-            if (getConfiguration().isUseCustomerKey()) {
-                if 
(ObjectHelper.isNotEmpty(getConfiguration().getCustomerKeyId())) {
-                    
getRequest.sseCustomerKey(getConfiguration().getCustomerKeyId());
-                }
-                if 
(ObjectHelper.isNotEmpty(getConfiguration().getCustomerKeyMD5())) {
-                    
getRequest.sseCustomerKeyMD5(getConfiguration().getCustomerKeyMD5());
-                }
-                if 
(ObjectHelper.isNotEmpty(getConfiguration().getCustomerAlgorithm())) {
-                    
getRequest.sseCustomerAlgorithm(getConfiguration().getCustomerAlgorithm());
+            // check if file is already in progress (add false = duplicate 
file)
+            if (!getEndpoint().getInProgressRepository().add(fileName)) {
+                LOG.trace("Skipping as s3 object is already in progress: {}", 
fileName);
+                exchanges = new LinkedList<>();
+            } else {
+                ResponseInputStream<GetObjectResponse> s3Object = null;
+                try {
+                    s3Object = getObject(bucketName, fileName);
+                    exchanges = createExchanges(s3Object, fileName);
+                } catch (Exception e) {
+                    // close the stream to release the underlying HTTP 
connection
+                    IOHelper.close(s3Object);
+                    // remove in progress as we failed
+                    getEndpoint().getInProgressRepository().remove(fileName);
+                    throw e;
                 }
             }
-            ResponseInputStream<GetObjectResponse> s3Object
-                    = getAmazonS3Client().getObject(getRequest.build());
-            exchanges = createExchanges(s3Object, fileName);
         } else {
             LOG.trace("Queueing objects in bucket [{}]...", bucketName);
 
@@ -203,6 +204,24 @@ public class AWS2S3Consumer extends 
ScheduledBatchPollingConsumer {
         return processBatch(CastUtils.cast(exchanges));
     }
 
+    private ResponseInputStream<GetObjectResponse> getObject(String 
bucketName, String fileName) {
+        LOG.trace("Getting object in bucket [{}] with file name [{}]...", 
bucketName, fileName);
+
+        GetObjectRequest.Builder getRequest = 
GetObjectRequest.builder().bucket(bucketName).key(fileName);
+        if (getConfiguration().isUseCustomerKey()) {
+            if 
(ObjectHelper.isNotEmpty(getConfiguration().getCustomerKeyId())) {
+                
getRequest.sseCustomerKey(getConfiguration().getCustomerKeyId());
+            }
+            if 
(ObjectHelper.isNotEmpty(getConfiguration().getCustomerKeyMD5())) {
+                
getRequest.sseCustomerKeyMD5(getConfiguration().getCustomerKeyMD5());
+            }
+            if 
(ObjectHelper.isNotEmpty(getConfiguration().getCustomerAlgorithm())) {
+                
getRequest.sseCustomerAlgorithm(getConfiguration().getCustomerAlgorithm());
+            }
+        }
+        return getAmazonS3Client().getObject(getRequest.build());
+    }
+
     private boolean doneFileCheckPasses(String bucketName, String 
doneFileName) {
         if (doneFileName == null) {
             return true;
@@ -290,6 +309,8 @@ public class AWS2S3Consumer extends 
ScheduledBatchPollingConsumer {
                 if (key != null) {
                     getEndpoint().getInProgressRepository().remove(key);
                 }
+                // the exchange is never routed, so nothing else releases it 
to the exchange factory
+                releaseExchange(exchange, true);
             }
             throw e;
         }
@@ -361,6 +382,21 @@ public class AWS2S3Consumer extends 
ScheduledBatchPollingConsumer {
             AsyncCallback cb = defaultConsumerCallback(exchange, true);
             getAsyncProcessor().process(exchange, cb);
         }
+        // the remaining exchanges are not processed as the consumer is 
stopping: a later poll must consume their
+        // objects again, and with includeBody=false their bodies are the 
object streams, which nothing else closes
+        for (Object notRouted : exchanges) {
+            Exchange exchange = (Exchange) notRouted;
+            String key = exchange.getProperty(AWS2S3Constants.KEY, 
String.class);
+            if (key != null) {
+                getEndpoint().getInProgressRepository().remove(key);
+            }
+            if (exchange.getIn().getBody() instanceof Closeable body) {
+                IOHelper.close(body);
+            }
+            // nothing else releases an exchange that is never routed to the 
exchange factory (which resets a pooled
+            // exchange, so this comes last)
+            releaseExchange(exchange, true);
+        }
 
         return total;
     }
@@ -493,11 +529,12 @@ public class AWS2S3Consumer extends 
ScheduledBatchPollingConsumer {
 
         /*
          * If includeBody == true, it is safe to close the object here because 
the S3Object
-         * was consumed already. If includeBody != true, the caller is 
responsible for
+         * was consumed already. The same goes for ignoreBody == true, as the 
stream is then not
+         * the body, so nothing else can read or close it. Otherwise the 
caller is responsible for
          * closing the stream once the body has been fully consumed or use the 
autoCloseBody
          * configuration to automatically schedule the body closing at the end 
of exchange.
          */
-        if (getConfiguration().isIncludeBody()) {
+        if (getConfiguration().isIncludeBody() || 
getConfiguration().isIgnoreBody()) {
             IOHelper.close(s3Object);
         } else {
             if (getConfiguration().isAutocloseBody()) {
diff --git 
a/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/AWS2S3ConsumerInProgressTest.java
 
b/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/AWS2S3ConsumerInProgressTest.java
new file mode 100644
index 000000000000..6b7259dec654
--- /dev/null
+++ 
b/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/AWS2S3ConsumerInProgressTest.java
@@ -0,0 +1,313 @@
+/*
+ * 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;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Queue;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Consumer;
+import org.apache.camel.Endpoint;
+import org.apache.camel.Exchange;
+import org.apache.camel.RuntimeCamelException;
+import org.apache.camel.ServiceStatus;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.spi.PollingConsumerPollStrategy;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.core.ResponseInputStream;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.GetObjectRequest;
+import software.amazon.awssdk.services.s3.model.GetObjectResponse;
+
+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.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The consumer keeps the objects whose exchanges are being processed in its 
in-progress repository: with fileName, an
+ * object still in flight is not consumed again by the next poll, a failed get 
does not leave the object in progress,
+ * and the objects of a batch that a stopping consumer does not process are 
consumed when the route is started again,
+ * and their object streams are closed and their exchanges released. With 
ignoreBody=true the object stream is closed
+ * when the exchange is created, and with fileName it is closed when the 
exchange cannot be created.
+ */
+class AWS2S3ConsumerInProgressTest extends CamelTestSupport {
+
+    private final CountDownLatch inRoute = new CountDownLatch(1);
+    private final CountDownLatch twoPolls = new CountDownLatch(2);
+    private final CountDownLatch failedPoll = new CountDownLatch(1);
+    private final CountDownLatch release = new CountDownLatch(1);
+    private FakeS3Server s3;
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        s3 = new FakeS3Server("bkt");
+        s3.start();
+        CamelContext context = super.createCamelContext();
+        // counts the polls that completed (the exchanges of the poll were 
handed to the route)
+        context.getRegistry().bind("countPolls", new 
PollingConsumerPollStrategy() {
+            @Override
+            public boolean begin(Consumer consumer, Endpoint endpoint) {
+                return true;
+            }
+
+            @Override
+            public void commit(Consumer consumer, Endpoint endpoint, int 
polledMessages) {
+                twoPolls.countDown();
+            }
+
+            @Override
+            public boolean rollback(Consumer consumer, Endpoint endpoint, int 
retryCounter, Exception e) {
+                failedPoll.countDown();
+                return false;
+            }
+        });
+        return context;
+    }
+
+    @AfterEach
+    void stopServer() {
+        release.countDown();
+        if (s3 != null) {
+            s3.stop();
+        }
+    }
+
+    private String s3Uri(String options) {
+        return 
"aws2-s3://bkt?accessKey=xxx&secretKey=yyy&region=us-east-1&overrideEndpoint=true&forcePathStyle=true"
+               + "&uriEndpointOverride=" + s3.endpoint() + "&delay=10" + 
options;
+    }
+
+    @Test
+    void objectInFlightIsNotConsumedAgainWithFileName() throws Exception {
+        s3.put("a.txt", "hello");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("hello");
+
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from(s3Uri("&fileName=a.txt&pollStrategy=#countPolls"))
+                        .to("seda:hold");
+
+                // seda hands the on completions (the delete) over to this 
route
+                from("seda:hold")
+                        .process(e -> {
+                            inRoute.countDown();
+                            release.await(20, TimeUnit.SECONDS);
+                        })
+                        .convertBodyTo(String.class)
+                        .to("mock:result");
+            }
+        });
+
+        assertTrue(inRoute.await(20, TimeUnit.SECONDS), "the object was not 
consumed");
+        // the second poll has completed while the first exchange is still 
held in the seda route
+        assertTrue(twoPolls.await(20, TimeUnit.SECONDS), "the consumer did not 
poll again");
+        int gets = s3.gets("a.txt");
+        release.countDown();
+
+        assertEquals(1, gets, "a.txt was consumed again by a later poll while 
its first exchange was still in flight");
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void objectWithFileNameIsConsumedOnceItExists() throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("hello");
+
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from(s3Uri("&fileName=a.txt&pollStrategy=#countPolls"))
+                        .convertBodyTo(String.class)
+                        .to("mock:result");
+            }
+        });
+
+        // the get fails (NoSuchKey) while the object does not exist, which 
must not leave it in progress
+        assertTrue(failedPoll.await(20, TimeUnit.SECONDS), "the get of the 
missing object did not fail");
+        s3.put("a.txt", "hello");
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void objectsOfAStoppedBatchAreConsumedAfterRestart() throws Exception {
+        s3.put("a.txt", "a");
+        s3.put("b.txt", "b");
+        s3.put("c.txt", "c");
+        AtomicBoolean first = new AtomicBoolean(true);
+        MockEndpoint mock = getMockEndpoint("mock:result");
+
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from(s3Uri("")).routeId("s3")
+                        .process(e -> {
+                            if (first.compareAndSet(true, false)) {
+                                // stop the route while the first exchange of 
the batch is processed, and wait until
+                                // the consumer does not process the rest of 
the batch
+                                AWS2S3Consumer consumer = (AWS2S3Consumer) 
context.getRoute("s3").getConsumer();
+                                CompletableFuture.runAsync(() -> {
+                                    try {
+                                        
context.getRouteController().stopRoute("s3");
+                                    } catch (Exception ex) {
+                                        throw new RuntimeCamelException(ex);
+                                    }
+                                });
+                                await().atMost(20, TimeUnit.SECONDS).until(() 
-> !consumer.isBatchAllowed());
+                            }
+                        })
+                        .convertBodyTo(String.class)
+                        .to("mock:result");
+            }
+        });
+
+        mock.expectedBodiesReceived("a");
+        mock.assertIsSatisfied();
+        await().atMost(20, TimeUnit.SECONDS)
+                .until(() -> context.getRouteController().getRouteStatus("s3") 
== ServiceStatus.Stopped);
+
+        mock.reset();
+        mock.expectedBodiesReceivedInAnyOrder("b", "c");
+        context.getRouteController().startRoute("s3");
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void notProcessedExchangesReleaseTheirObjects() throws Exception {
+        AWS2S3Endpoint endpoint = 
context.getEndpoint(s3Uri("&includeBody=false"), AWS2S3Endpoint.class);
+        List<Boolean> closedWhenReleased = new ArrayList<>();
+        AtomicBoolean closed = new AtomicBoolean();
+        // never started, so the consumer does not process any exchange of the 
batch
+        AWS2S3Consumer consumer = new AWS2S3Consumer(endpoint, exchange -> {
+        }) {
+            @Override
+            public void releaseExchange(Exchange exchange, boolean 
autoRelease) {
+                closedWhenReleased.add(closed.get());
+                super.releaseExchange(exchange, autoRelease);
+            }
+        };
+        endpoint.getInProgressRepository().add("b.txt");
+        Exchange exchange = endpoint.createExchange();
+        exchange.setProperty(AWS2S3Constants.KEY, "b.txt");
+        exchange.getIn().setBody(new 
ByteArrayInputStream("b".getBytes(StandardCharsets.UTF_8)) {
+            @Override
+            public void close() {
+                closed.set(true);
+            }
+        });
+        Queue<Object> batch = new LinkedList<>();
+        batch.add(exchange);
+
+        consumer.processBatch(batch);
+
+        assertFalse(endpoint.getInProgressRepository().contains("b.txt"), 
"b.txt is still in progress");
+        assertTrue(closed.get(), "the object stream of b.txt was not closed");
+        // released to the exchange factory, after the stream was closed as a 
pooled exchange is reset when released
+        assertEquals(List.of(true), closedWhenReleased, "the exchange of b.txt 
was not released after closing its stream");
+    }
+
+    @Test
+    void ignoredBodyStreamIsClosed() throws Exception {
+        AWS2S3Endpoint endpoint = 
context.getEndpoint(s3Uri("&includeBody=false&ignoreBody=true"), 
AWS2S3Endpoint.class);
+        AWS2S3Consumer consumer = (AWS2S3Consumer) 
endpoint.createConsumer(exchange -> {
+        });
+        AtomicBoolean closed = new AtomicBoolean();
+        InputStream content = new 
ByteArrayInputStream("b".getBytes(StandardCharsets.UTF_8)) {
+            @Override
+            public void close() {
+                closed.set(true);
+            }
+        };
+
+        Exchange exchange = consumer.createExchange(
+                new 
ResponseInputStream<>(GetObjectResponse.builder().eTag("tag").build(), 
content), "b.txt");
+
+        // the stream is not the body, so nothing else can close it (an 
exchange that is never routed does not even
+        // run the on completion of autocloseBody)
+        assertTrue(closed.get(), "the object stream of b.txt was not closed");
+        assertEquals("tag", exchange.getIn().getHeader(AWS2S3Constants.E_TAG));
+        assertEquals("b.txt", exchange.getIn().getHeader(AWS2S3Constants.KEY));
+    }
+
+    @Test
+    void objectStreamIsClosedWhenTheExchangeOfAFileNamePollCannotBeCreated() 
throws Exception {
+        AWS2S3Endpoint endpoint = 
context.getEndpoint(s3Uri("&fileName=a.txt"), AWS2S3Endpoint.class);
+        AtomicBoolean closed = new AtomicBoolean();
+        // the download of the object fails while its content is read into the 
body (includeBody=true)
+        InputStream content = new InputStream() {
+            @Override
+            public int read() throws IOException {
+                throw new IOException("connection reset");
+            }
+
+            @Override
+            public void close() {
+                closed.set(true);
+            }
+        };
+        S3Client client = new S3Client() {
+            @Override
+            public ResponseInputStream<GetObjectResponse> 
getObject(GetObjectRequest request) {
+                return new 
ResponseInputStream<>(GetObjectResponse.builder().eTag("tag").build(), content);
+            }
+
+            @Override
+            public String serviceName() {
+                return SERVICE_NAME;
+            }
+
+            @Override
+            public void close() {
+                // noop
+            }
+        };
+        AWS2S3Consumer consumer = new AWS2S3Consumer(endpoint, exchange -> {
+        }) {
+            @Override
+            protected S3Client getAmazonS3Client() {
+                return client;
+            }
+        };
+
+        RuntimeCamelException e = assertThrows(RuntimeCamelException.class, 
consumer::poll);
+
+        assertInstanceOf(IOException.class, e.getCause());
+        assertEquals("connection reset", e.getCause().getMessage());
+        assertFalse(endpoint.getInProgressRepository().contains("a.txt"), 
"a.txt is still in progress");
+        // nothing else has a reference to the stream, so its connection would 
never be released
+        assertTrue(closed.get(), "the object stream of a.txt was not closed");
+    }
+}
diff --git 
a/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/FakeS3Server.java
 
b/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/FakeS3Server.java
new file mode 100644
index 000000000000..1e2b4aa4ea26
--- /dev/null
+++ 
b/components/camel-aws/camel-aws2-s3/src/test/java/org/apache/camel/component/aws2/s3/FakeS3Server.java
@@ -0,0 +1,194 @@
+/*
+ * 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;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.NavigableMap;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentSkipListMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+
+/**
+ * A minimal in-process fake of the S3 API calls the consumer makes for a 
single bucket (path-style requests): head
+ * bucket, list objects (v2, paginated with max-keys and 
start-after/continuation-token), head, get and delete object.
+ */
+final class FakeS3Server {
+
+    private final String bucket;
+    private final HttpServer server;
+    private final NavigableMap<String, byte[]> objects = new 
ConcurrentSkipListMap<>();
+    private final Map<String, AtomicInteger> gets = new ConcurrentHashMap<>();
+
+    FakeS3Server(String bucket) throws IOException {
+        this.bucket = bucket;
+        this.server = HttpServer.create(new InetSocketAddress("localhost", 0), 
0);
+        this.server.createContext("/", this::handle);
+    }
+
+    void start() {
+        server.start();
+    }
+
+    void stop() {
+        server.stop(0);
+    }
+
+    String endpoint() {
+        return "http://localhost:"; + server.getAddress().getPort();
+    }
+
+    void put(String key, String content) {
+        objects.put(key, content.getBytes(StandardCharsets.UTF_8));
+    }
+
+    int gets(String key) {
+        AtomicInteger n = gets.get(key);
+        return n != null ? n.get() : 0;
+    }
+
+    private void handle(HttpExchange http) throws IOException {
+        String path = http.getRequestURI().getRawPath();
+        String key = path.length() > bucket.length() + 2 ? 
decode(path.substring(bucket.length() + 2)) : null;
+        String method = http.getRequestMethod();
+        if (key == null) {
+            if ("GET".equals(method) && query(http).containsKey("list-type")) {
+                list(http, query(http));
+            } else {
+                // head bucket, location, ...
+                respond(http, 200, null, null);
+            }
+            return;
+        }
+        byte[] data = objects.get(key);
+        switch (method) {
+            case "HEAD" -> {
+                if (data == null) {
+                    respond(http, 404, null, null);
+                } else {
+                    objectHeaders(http, data);
+                    http.sendResponseHeaders(200, -1);
+                    http.close();
+                }
+            }
+            case "GET" -> {
+                if (data == null) {
+                    noSuchKey(http, key);
+                } else {
+                    gets.computeIfAbsent(key, k -> new 
AtomicInteger()).incrementAndGet();
+                    objectHeaders(http, data);
+                    http.sendResponseHeaders(200, data.length);
+                    try (OutputStream os = http.getResponseBody()) {
+                        os.write(data);
+                    }
+                }
+            }
+            case "DELETE" -> {
+                objects.remove(key);
+                http.sendResponseHeaders(204, -1);
+                http.close();
+            }
+            default -> respond(http, 405, null, null);
+        }
+    }
+
+    private void list(HttpExchange http, Map<String, String> query) throws 
IOException {
+        int maxKeys = query.containsKey("max-keys") ? 
Integer.parseInt(query.get("max-keys")) : 1000;
+        String after = query.get("continuation-token");
+        if (after == null) {
+            after = query.get("start-after");
+        }
+        NavigableMap<String, byte[]> view = after != null ? 
objects.tailMap(after, false) : objects;
+        StringBuilder contents = new StringBuilder();
+        int count = 0;
+        String last = null;
+        for (Map.Entry<String, byte[]> e : view.entrySet()) {
+            if (count == maxKeys) {
+                break;
+            }
+            
contents.append("<Contents><Key>").append(e.getKey()).append("</Key>")
+                    
.append("<LastModified>2026-10-09T00:00:00.000Z</LastModified>")
+                    
.append("<ETag>\"0123456789abcdef0123456789abcdef\"</ETag>")
+                    
.append("<Size>").append(e.getValue().length).append("</Size>")
+                    
.append("<StorageClass>STANDARD</StorageClass></Contents>");
+            last = e.getKey();
+            count++;
+        }
+        boolean truncated = last != null && objects.higherKey(last) != null;
+        String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+                     + "<ListBucketResult 
xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\";>"
+                     + "<Name>" + bucket + "</Name><Prefix></Prefix>"
+                     + "<KeyCount>" + count + "</KeyCount><MaxKeys>" + maxKeys 
+ "</MaxKeys>"
+                     + "<IsTruncated>" + truncated + "</IsTruncated>"
+                     + (truncated ? "<NextContinuationToken>" + last + 
"</NextContinuationToken>" : "")
+                     + contents + "</ListBucketResult>";
+        respond(http, 200, "application/xml", xml);
+    }
+
+    private static void objectHeaders(HttpExchange http, byte[] data) {
+        http.getResponseHeaders().add("ETag", 
"\"0123456789abcdef0123456789abcdef\"");
+        http.getResponseHeaders().add("Last-Modified", "Fri, 09 Oct 2026 
00:00:00 GMT");
+        http.getResponseHeaders().add("Content-Type", "text/plain");
+        http.getResponseHeaders().add("Content-Length", 
String.valueOf(data.length));
+    }
+
+    private void noSuchKey(HttpExchange http, String key) throws IOException {
+        String xml = "<?xml version=\"1.0\" 
encoding=\"UTF-8\"?><Error><Code>NoSuchKey</Code>"
+                     + "<Message>The specified key does not 
exist.</Message><Key>" + key + "</Key>"
+                     + "<BucketName>" + bucket + "</BucketName><Resource>/" + 
bucket + "/" + key + "</Resource>"
+                     + "<RequestId>1</RequestId><HostId>1</HostId></Error>";
+        respond(http, 404, "application/xml", xml);
+    }
+
+    private static void respond(HttpExchange http, int status, String 
contentType, String body) throws IOException {
+        if (body == null) {
+            http.sendResponseHeaders(status, -1);
+            http.close();
+            return;
+        }
+        byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+        http.getResponseHeaders().add("Content-Type", contentType);
+        http.sendResponseHeaders(status, bytes.length);
+        try (OutputStream os = http.getResponseBody()) {
+            os.write(bytes);
+        }
+    }
+
+    private static Map<String, String> query(HttpExchange http) {
+        Map<String, String> answer = new HashMap<>();
+        String q = http.getRequestURI().getRawQuery();
+        if (q != null) {
+            for (String part : q.split("&")) {
+                int eq = part.indexOf('=');
+                answer.put(decode(eq >= 0 ? part.substring(0, eq) : part), eq 
>= 0 ? decode(part.substring(eq + 1)) : "");
+            }
+        }
+        return answer;
+    }
+
+    private static String decode(String s) {
+        return URLDecoder.decode(s, StandardCharsets.UTF_8);
+    }
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index ced580f92f30..ae3640b4cef0 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -615,6 +615,13 @@ A header that carries a literal `${...}` string is now 
used as the object key ve
 A configured `keyName` or `bucketName` whose Simple expression resolves to 
`null` now fails with an
 `IllegalArgumentException` at the producer, instead of passing a `null` 
key/bucket on to the AWS SDK.
 
+=== camel-aws2-s3 - objects in progress in the consumer
+
+With `fileName` set, the consumer now skips the object while an exchange of an 
earlier poll is still processing it, as
+it already did when listing the bucket (with `deleteAfterRead=false` it is 
consumed again once its exchange is done).
+The objects of a poll that a stopping consumer does not process are no longer 
kept in the `inProgressRepository`, so
+they are consumed when the route is started again.
+
 === camel-aws2-sqs
 
 The `batchSeparator` option (default `,`), which splits a `String` body into 
the messages of the `sendBatchMessage`

Reply via email to