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 68329f0919bc CAMEL-25684: camel-slack - the consumer keeps the 
messages of a poll it did not route when the route is suspended
68329f0919bc is described below

commit 68329f0919bc2b7f22a6ff2ecfc373b58269948c
Author: allthingssecurity <[email protected]>
AuthorDate: Fri Oct 16 20:29:09 2026 +0530

    CAMEL-25684: camel-slack - the consumer keeps the messages of a poll it did 
not route when the route is suspended
    
    [CAMEL-25684](https://issues.apache.org/jira/browse/CAMEL-25684)
    
    `SlackConsumer.createExchanges` moved the timestamp to the most recent 
message of the poll before `processBatch` routes the messages. `processBatch` 
stops when `isBatchAllowed()` is false, which `ScheduledBatchPollingConsumer` 
returns when the route is being suspended (`RouteController.suspendRoute`, also 
from a route policy that suspends the route through the route controller, such 
as `DurationRoutePolicy`, or `CamelContext.suspend()`, which resumes the same 
consumer). The rest of th [...]
    
    Closes #27923
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../camel/component/slack/SlackConsumer.java       |  44 ++-
 ...ckConsumerSuspendedBatchPooledExchangeTest.java |  74 +++++
 .../slack/SlackConsumerSuspendedBatchTest.java     | 311 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |   8 +
 4 files changed, 430 insertions(+), 7 deletions(-)

diff --git 
a/components/camel-slack/src/main/java/org/apache/camel/component/slack/SlackConsumer.java
 
b/components/camel-slack/src/main/java/org/apache/camel/component/slack/SlackConsumer.java
index 216534e61dd5..1b9ad2b628bf 100644
--- 
a/components/camel-slack/src/main/java/org/apache/camel/component/slack/SlackConsumer.java
+++ 
b/components/camel-slack/src/main/java/org/apache/camel/component/slack/SlackConsumer.java
@@ -53,6 +53,9 @@ public class SlackConsumer extends 
ScheduledBatchPollingConsumer {
     private final SlackEndpoint slackEndpoint;
     private Slack slack;
     private String timestamp;
+    // messages of a previous poll, the most recent first, that were not 
routed because the batch ended early. The
+    // timestamp is already past them, so they are routed with the messages of 
the next poll
+    private final List<Message> notRouted = new ArrayList<>();
     private String channelId;
 
     public SlackConsumer(SlackEndpoint endpoint, Processor processor) {
@@ -120,6 +123,18 @@ public class SlackConsumer extends 
ScheduledBatchPollingConsumer {
             }
         }
 
+        if (ObjectHelper.isNotEmpty(messages)) {
+            // the most recent message comes first
+            timestamp = messages.get(0).getTs();
+        }
+        if (!notRouted.isEmpty()) {
+            // they are older than the messages of this poll
+            List<Message> all = messages != null ? new ArrayList<>(messages) : 
new ArrayList<>();
+            all.addAll(notRouted);
+            notRouted.clear();
+            messages = all;
+        }
+
         Queue<Exchange> exchanges = createExchanges(messages);
         return processBatch(CastUtils.cast(exchanges));
     }
@@ -148,18 +163,12 @@ public class SlackConsumer extends 
ScheduledBatchPollingConsumer {
             if (slackEndpoint.isNaturalOrder()) {
                 for (int i = list.size() - 1; i >= 0; i--) {
                     Message message = list.get(i);
-                    if (i == 0) {
-                        timestamp = message.getTs();
-                    }
                     Exchange exchange = createExchange(message);
                     answer.add(exchange);
                 }
             } else {
                 for (int i = 0; i < list.size(); i++) {
                     Message message = list.get(i);
-                    if (i == 0) {
-                        timestamp = message.getTs();
-                    }
                     Exchange exchange = createExchange(message);
                     answer.add(exchange);
                 }
@@ -171,6 +180,7 @@ public class SlackConsumer extends 
ScheduledBatchPollingConsumer {
     @Override
     public int processBatch(Queue<Object> exchanges) throws Exception {
         int total = exchanges.size();
+        int answer = 0;
 
         for (int index = 0; index < total && isBatchAllowed(); index++) {
             // only loop if we are started (allowed to run)
@@ -186,9 +196,29 @@ public class SlackConsumer extends 
ScheduledBatchPollingConsumer {
             // use default consumer callback
             AsyncCallback cb = defaultConsumerCallback(exchange, true);
             getAsyncProcessor().process(exchange, cb);
+            answer++;
+        }
+
+        // the batch ended early as the consumer is suspending or stopping: 
the following polls do not read these
+        // messages again, so keep them for the next poll. Their exchanges are 
never routed, and nothing else gives
+        // them back to the exchange factory
+        Exchange notRoutedExchange;
+        while ((notRoutedExchange = (Exchange) exchanges.poll()) != null) {
+            notRouted.add(notRoutedExchange.getIn().getBody(Message.class));
+            releaseExchange(notRoutedExchange, true);
+        }
+        if (slackEndpoint.isNaturalOrder()) {
+            // kept the most recent first
+            Collections.reverse(notRouted);
         }
+        if (!notRouted.isEmpty()) {
+            LOG.debug("The batch of channel {} ended early, {} messages are 
kept for the next poll",
+                    slackEndpoint.getChannel(), notRouted.size());
+        }
+        // nothing of this batch is pending anymore: the messages that were 
not routed are kept for the next poll
+        pendingExchanges = 0;
 
-        return total;
+        return answer;
     }
 
     private String getChannelId(final String channel, final String cursor) {
diff --git 
a/components/camel-slack/src/test/java/org/apache/camel/component/slack/SlackConsumerSuspendedBatchPooledExchangeTest.java
 
b/components/camel-slack/src/test/java/org/apache/camel/component/slack/SlackConsumerSuspendedBatchPooledExchangeTest.java
new file mode 100644
index 000000000000..95a016a29fd0
--- /dev/null
+++ 
b/components/camel-slack/src/test/java/org/apache/camel/component/slack/SlackConsumerSuspendedBatchPooledExchangeTest.java
@@ -0,0 +1,74 @@
+/*
+ * 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.slack;
+
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.impl.engine.PooledExchangeFactory;
+import org.apache.camel.spi.ExchangeFactory;
+import org.junit.jupiter.api.Test;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * The same with the pooled exchange factory: the exchanges created for the 
messages that a suspending consumer does not
+ * route are given back to the pool, once, so the messages routed after the 
resume do not share an exchange.
+ */
+class SlackConsumerSuspendedBatchPooledExchangeTest extends 
SlackConsumerSuspendedBatchTest {
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        CamelContext context = super.createCamelContext();
+        PooledExchangeFactory factory = new PooledExchangeFactory();
+        factory.setStatisticsEnabled(true);
+        context.getCamelContextExtension().setExchangeFactory(factory);
+        return context;
+    }
+
+    @Test
+    void exchangesOfTheMessagesThatWereNotRoutedAreGivenBackToThePool() throws 
Exception {
+        
context.getCamelContextExtension().getExchangeFactoryManager().setStatisticsEnabled(true);
+        startRouteAndReceiveFirstMessage();
+        suspendAtMessage = "m4";
+
+        postBurst();
+        awaitRouteSuspended();
+        assertEquals(List.of("m5", "m4"), routed);
+        // one exchange for the first message, and one per message of the 
burst: two were routed, and the two that
+        // were not are given back as well
+        awaitExchangesTakenAndReleased(5);
+
+        context.getRouteController().resumeRoute(ROUTE_ID);
+        await().atMost(10, TimeUnit.SECONDS).untilAsserted(
+                () -> assertEquals(List.of("m5", "m4", "m3", "m2"), routed, 
"messages routed after the resume"));
+        // and only once: the pool does not hold an exchange twice
+        awaitExchangesTakenAndReleased(7);
+    }
+
+    private void awaitExchangesTakenAndReleased(int expected) {
+        await().atMost(20, TimeUnit.SECONDS).untilAsserted(() -> {
+            ExchangeFactory.Statistics stats
+                    = 
context.getCamelContextExtension().getExchangeFactoryManager().getStatistics();
+            assertEquals(expected, stats.getCreatedCounter() + 
stats.getAcquiredCounter(), "exchanges taken");
+            assertEquals(expected, stats.getReleasedCounter(), "exchanges 
given back to the pool");
+            assertEquals(0, stats.getDiscardedCounter(), "exchanges 
discarded");
+        });
+    }
+}
diff --git 
a/components/camel-slack/src/test/java/org/apache/camel/component/slack/SlackConsumerSuspendedBatchTest.java
 
b/components/camel-slack/src/test/java/org/apache/camel/component/slack/SlackConsumerSuspendedBatchTest.java
new file mode 100644
index 000000000000..0cea42a77b13
--- /dev/null
+++ 
b/components/camel-slack/src/test/java/org/apache/camel/component/slack/SlackConsumerSuspendedBatchTest.java
@@ -0,0 +1,311 @@
+/*
+ * 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.slack;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.math.BigDecimal;
+import java.net.InetSocketAddress;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+
+import com.slack.api.model.Message;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.apache.camel.ServiceStatus;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+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;
+
+/**
+ * A route that is suspended while the consumer routes the messages of a poll, 
and resumed: the messages of that poll
+ * that were not routed must be routed after the resume. The consumer has 
moved its timestamp past them, so the
+ * following polls do not read them again.
+ */
+class SlackConsumerSuspendedBatchTest extends CamelTestSupport {
+
+    static final String ROUTE_ID = "slack";
+
+    private final List<String> channel = new CopyOnWriteArrayList<>();
+    private HttpServer server;
+    private final AtomicInteger historyRequests = new AtomicInteger();
+    private boolean naturalOrder;
+
+    final List<String> routed = new CopyOnWriteArrayList<>();
+    // the text of the message whose processor suspends the route (once)
+    volatile String suspendAtMessage;
+    // suspend the whole CamelContext instead of the route
+    private volatile boolean suspendContext;
+    private final AtomicBoolean suspendRequested = new AtomicBoolean();
+    private final AtomicReference<Exception> suspendFailure = new 
AtomicReference<>();
+    private volatile Thread suspender;
+
+    @Override
+    protected void doPreSetup() throws Exception {
+        server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
+        server.createContext("/api/conversations.list", 
this::conversationsList);
+        server.createContext("/api/conversations.history", 
this::conversationsHistory);
+        server.start();
+    }
+
+    @AfterEach
+    void stopServer() {
+        // stop polling before the server goes away
+        context.stop();
+        if (server != null) {
+            server.stop(0);
+        }
+    }
+
+    @Override
+    public boolean isUseRouteBuilder() {
+        return false;
+    }
+
+    @Test
+    void messagesNotRoutedByASuspendingConsumerAreRoutedAfterTheResume() 
throws Exception {
+        startRouteAndReceiveFirstMessage();
+        suspendAtMessage = "m4";
+
+        // the most recent message is routed first
+        postBurst();
+        awaitRouteSuspended();
+        assertEquals(List.of("m5", "m4"), routed, "the suspend ends the batch 
after the message that is being routed");
+
+        context.getRouteController().resumeRoute(ROUTE_ID);
+        await().atMost(10, TimeUnit.SECONDS).untilAsserted(
+                () -> assertEquals(List.of("m5", "m4", "m3", "m2"), routed, 
"messages routed after the resume"));
+        assertNothingElseIsRouted(List.of("m5", "m4", "m3", "m2"));
+    }
+
+    @Test
+    void 
messagesNotRoutedByASuspendingConsumerAreRoutedAfterTheResumeInNaturalOrder() 
throws Exception {
+        naturalOrder = true;
+        startRouteAndReceiveFirstMessage();
+        suspendAtMessage = "m3";
+
+        postBurst();
+        awaitRouteSuspended();
+        assertEquals(List.of("m2", "m3"), routed, "the suspend ends the batch 
after the message that is being routed");
+
+        context.getRouteController().resumeRoute(ROUTE_ID);
+        await().atMost(10, TimeUnit.SECONDS).untilAsserted(
+                () -> assertEquals(List.of("m2", "m3", "m4", "m5"), routed, 
"messages routed after the resume"));
+        assertNothingElseIsRouted(List.of("m2", "m3", "m4", "m5"));
+    }
+
+    @Test
+    void messagesPostedWhileSuspendedAreRoutedWithTheOnesThatWereNotRouted() 
throws Exception {
+        startRouteAndReceiveFirstMessage();
+        suspendAtMessage = "m5";
+
+        postBurst();
+        awaitRouteSuspended();
+        assertEquals(List.of("m5"), routed);
+        channel.add("m6");
+
+        context.getRouteController().resumeRoute(ROUTE_ID);
+        // the most recent first: the new message, then the ones that were left
+        await().atMost(10, TimeUnit.SECONDS).untilAsserted(
+                () -> assertEquals(List.of("m5", "m6", "m4", "m3", "m2"), 
routed, "messages routed after the resume"));
+        assertNothingElseIsRouted(List.of("m5", "m6", "m4", "m3", "m2"));
+    }
+
+    @Test
+    void 
messagesNotRoutedWhenTheCamelContextIsSuspendedAreRoutedAfterTheResume() throws 
Exception {
+        suspendContext = true;
+        startRouteAndReceiveFirstMessage();
+        suspendAtMessage = "m4";
+
+        postBurst();
+        awaitRouteSuspended();
+        assertEquals(List.of("m5", "m4"), routed, "the suspend ends the batch 
after the message that is being routed");
+
+        // the same consumer is resumed
+        context.resume();
+        await().atMost(10, TimeUnit.SECONDS).untilAsserted(
+                () -> assertEquals(List.of("m5", "m4", "m3", "m2"), routed, 
"messages routed after the resume"));
+        assertNothingElseIsRouted(List.of("m5", "m4", "m3", "m2"));
+    }
+
+    void startRouteAndReceiveFirstMessage() throws Exception {
+        channel.add("m1");
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                
fromF("slack://general?token=RAW(xoxp-test)&serverUrl=http://localhost:%d&maxResults=10&delay=20";
+                      + "&naturalOrder=%s",
+                        server.getAddress().getPort(), naturalOrder)
+                        .routeId(ROUTE_ID)
+                        .process(exchange -> {
+                            String text = 
exchange.getMessage().getBody(Message.class).getText();
+                            if (text.equals(suspendAtMessage)) {
+                                suspendRouteDuringThePoll();
+                            }
+                            routed.add(text);
+                        });
+            }
+        });
+        context.start();
+
+        // the first poll reads the last message of the history
+        await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> 
assertEquals(List.of("m1"), routed));
+        routed.clear();
+    }
+
+    void postBurst() {
+        // added at once, so that no poll sees only a part of the burst
+        channel.addAll(List.of("m2", "m3", "m4", "m5"));
+    }
+
+    private void assertNothingElseIsRouted(List<String> expected) {
+        // a few more polls: nothing is routed twice
+        int before = historyRequests.get();
+        await().atMost(10, TimeUnit.SECONDS).until(() -> historyRequests.get() 
>= before + 3);
+        assertEquals(expected, routed);
+    }
+
+    /**
+     * Called by the thread that polls: suspends the route from another 
thread, as a route must not suspend itself, and
+     * returns when the suspend has reached the consumer (it no longer routes 
the following messages of the batch).
+     */
+    private void suspendRouteDuringThePoll() {
+        if (!suspendRequested.compareAndSet(false, true)) {
+            return;
+        }
+        SlackConsumer consumer = (SlackConsumer) 
context.getRoute(ROUTE_ID).getConsumer();
+        suspender = new Thread(() -> {
+            try {
+                if (suspendContext) {
+                    context.suspend();
+                } else {
+                    context.getRouteController().suspendRoute(ROUTE_ID);
+                }
+            } catch (Exception e) {
+                suspendFailure.set(e);
+            }
+        }, "suspend-route");
+        suspender.start();
+
+        CountDownLatch batchEnded = new CountDownLatch(1);
+        Thread watcher = new Thread(() -> {
+            await().atMost(20, TimeUnit.SECONDS).until(() -> 
!consumer.isBatchAllowed());
+            batchEnded.countDown();
+        }, "watch-consumer");
+        watcher.start();
+
+        // an interrupt of the thread that polls must not end this wait
+        boolean interrupted = false;
+        boolean ended = false;
+        long remaining = TimeUnit.SECONDS.toNanos(30);
+        long deadline = System.nanoTime() + remaining;
+        while (!ended && remaining > 0) {
+            try {
+                ended = batchEnded.await(remaining, TimeUnit.NANOSECONDS);
+            } catch (InterruptedException e) {
+                interrupted = true;
+            }
+            remaining = deadline - System.nanoTime();
+        }
+        if (interrupted) {
+            Thread.currentThread().interrupt();
+        }
+    }
+
+    void awaitRouteSuspended() throws InterruptedException {
+        await().atMost(20, TimeUnit.SECONDS).until(() -> suspender != null);
+        suspender.join(TimeUnit.SECONDS.toMillis(60));
+        assertFalse(suspender.isAlive(), "the route suspend has returned");
+        assertNull(suspendFailure.get());
+        assertEquals(ServiceStatus.Suspended, 
context.getRouteController().getRouteStatus(ROUTE_ID));
+    }
+
+    private void conversationsList(HttpExchange http) throws IOException {
+        respond(http, 
"{\"ok\":true,\"channels\":[{\"id\":\"C1\",\"name\":\"general\"}],"
+                      + "\"response_metadata\":{\"next_cursor\":\"\"}}");
+    }
+
+    /**
+     * Answers as Slack does: the messages after {@code oldest} (exclusive), 
the most recent first, {@code limit} at a
+     * time.
+     */
+    private void conversationsHistory(HttpExchange http) throws IOException {
+        historyRequests.incrementAndGet();
+        Map<String, String> params = form(http);
+        String oldest = params.get("oldest");
+        int limit = Integer.parseInt(params.get("limit"));
+
+        List<Integer> range = new ArrayList<>();
+        for (int i = 0; i < channel.size(); i++) {
+            if (oldest == null || new BigDecimal(ts(i)).compareTo(new 
BigDecimal(oldest)) > 0) {
+                range.add(i);
+            }
+        }
+        range.sort(Comparator.reverseOrder());
+        List<Integer> page = range.subList(0, Math.min(limit, range.size()));
+
+        String messages = page.stream()
+                .map(i -> "{\"type\":\"message\",\"text\":\"" + channel.get(i) 
+ "\",\"ts\":\"" + ts(i) + "\"}")
+                .collect(Collectors.joining(","));
+        respond(http, "{\"ok\":true,\"messages\":[" + messages + 
"],\"has_more\":false,"
+                      + "\"response_metadata\":{\"next_cursor\":\"\"}}");
+    }
+
+    private static String ts(int index) {
+        return (1700000000 + index) + ".000100";
+    }
+
+    private static Map<String, String> form(HttpExchange http) throws 
IOException {
+        Map<String, String> answer = new HashMap<>();
+        String body = new String(http.getRequestBody().readAllBytes(), 
StandardCharsets.UTF_8);
+        for (String pair : body.split("&")) {
+            int idx = pair.indexOf('=');
+            if (idx > 0) {
+                answer.put(URLDecoder.decode(pair.substring(0, idx), 
StandardCharsets.UTF_8),
+                        URLDecoder.decode(pair.substring(idx + 1), 
StandardCharsets.UTF_8));
+            }
+        }
+        return answer;
+    }
+
+    private static void respond(HttpExchange http, String json) throws 
IOException {
+        byte[] bytes = json.getBytes(StandardCharsets.UTF_8);
+        http.getResponseHeaders().add("Content-Type", "application/json; 
charset=utf-8");
+        http.sendResponseHeaders(200, bytes.length);
+        try (OutputStream os = http.getResponseBody()) {
+            os.write(bytes);
+        }
+    }
+}
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 8a6f106f08a6..8bcccd225fc9 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
@@ -932,6 +932,14 @@ failed (for example a 404 for a deleted account, or a 
server error). Such a comm
 for it. Its `CamelGitHubCommitAuthor` and `CamelGitHubCommitCommitter` headers 
then have the name that git recorded in
 the commit instead of the name of the GitHub profile, and are not set when the 
commit has no name either.
 
+=== camel-slack - consumer and a suspended route
+
+When a route is suspended (`RouteController.suspendRoute`, also from a route 
policy that suspends the route such as
+`DurationRoutePolicy`, or `CamelContext.suspend()`) while the `slack` consumer 
routes the messages of a poll, the
+consumer stops after the message that is being routed. The other messages of 
that poll were skipped when the route
+was resumed. They are now kept and routed by the first poll after the resume, 
together with the messages posted in
+the meantime. They are kept in memory only: a route that is stopped and 
started again does not route them.
+
 === camel-smpp - the host name of the SMSC is verified over SSL
 
 With `smpps` (or `usingSSL=true`), directly or through an HTTP proxy, the 
connection now checks that the certificate of

Reply via email to