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 bfc5f60d1a5e CAMEL-26575: camel-core - Multicast, Split and 
RecipientList: an AggregationStrategy whose onCompletion throws fails the 
exchange instead of hanging it (#29200)
bfc5f60d1a5e is described below

commit bfc5f60d1a5edc6c2f4e59e6505176b6fd6dd507
Author: allthingssecurity <[email protected]>
AuthorDate: Thu Nov 5 17:34:55 2026 +0530

    CAMEL-26575: camel-core - Multicast, Split and RecipientList: an 
AggregationStrategy whose onCompletion throws fails the exchange instead of 
hanging it (#29200)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../apache/camel/processor/MulticastProcessor.java |  21 +-
 .../processor/MulticastOnCompletionThrowsTest.java | 406 +++++++++++++++++++++
 2 files changed, 426 insertions(+), 1 deletion(-)

diff --git 
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/MulticastProcessor.java
 
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/MulticastProcessor.java
index dfc453d993bb..2abdf93f82b4 100644
--- 
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/MulticastProcessor.java
+++ 
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/MulticastProcessor.java
@@ -983,8 +983,14 @@ public class MulticastProcessor extends 
BaseProcessorSupport
 
         AggregationStrategy strategy = getAggregationStrategy(subExchange);
         // invoke the on completion callback
+        Throwable completionFailure = null;
         if (strategy != null) {
-            strategy.onCompletion(subExchange, original);
+            try {
+                strategy.onCompletion(subExchange, original);
+            } catch (Throwable e) {
+                // the task is already done: fail the exchange below, and 
still call the callback, or the exchange hangs
+                completionFailure = e;
+            }
         }
 
         // cleanup any per exchange aggregation strategy
@@ -1027,6 +1033,19 @@ public class MulticastProcessor extends 
BaseProcessorSupport
                 
original.setProperty(RedeliveryErrorHandler.CUTOFF_WARN_LOGGED, Boolean.TRUE);
             }
         }
+        if (completionFailure != null) {
+            // after copyResults, which would overwrite it; an exchange that 
has failed already keeps its exception
+            // (an Error, such as a NoClassDefFoundError, is wrapped by 
setException)
+            Exception cause = original.getException();
+            if (cause == null) {
+                original.setException(completionFailure);
+                // no error handler has seen it yet, so it is not exhausted 
(as when aggregate throws)
+                exhaust = false;
+            } else if (cause != completionFailure) {
+                cause.addSuppressed(completionFailure);
+            }
+            exception = true;
+        }
 
         if (processorExchangeFactory != null && pairs instanceof Collection) {
             // the exchanges on the pairs was created with a factory, so they 
should be released
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/MulticastOnCompletionThrowsTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/MulticastOnCompletionThrowsTest.java
new file mode 100644
index 000000000000..7a5e97d09cba
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/MulticastOnCompletionThrowsTest.java
@@ -0,0 +1,406 @@
+/*
+ * 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.processor;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.camel.AggregationStrategy;
+import org.apache.camel.CamelExecutionException;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.model.ProcessorDefinition;
+import org.apache.camel.model.RouteDefinition;
+import org.apache.camel.spi.CamelEvent;
+import org.apache.camel.spi.CamelEvent.ExchangeCompletedEvent;
+import org.apache.camel.spi.CamelEvent.ExchangeFailedEvent;
+import org.apache.camel.support.EventNotifierSupport;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import static org.apache.camel.builder.Builder.constant;
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+
+/**
+ * An {@link AggregationStrategy} whose {@code onCompletion} throws, in a 
multicast, split or recipient list: the
+ * exchange must fail with the exception and go on, once, instead of hanging 
(the multicast was marked done before
+ * {@code onCompletion}, so the failure could not complete it anymore), and 
the error handler of the route must handle
+ * that failure as when the strategy throws from {@code aggregate}. Each send 
is bounded, so a hang fails the test.
+ */
+class MulticastOnCompletionThrowsTest extends ContextTestSupport {
+
+    // releases the slow output of the timeout test
+    private final CountDownLatch release = new CountDownLatch(1);
+
+    @Override
+    public boolean isUseRouteBuilder() {
+        return false;
+    }
+
+    @AfterEach
+    void releaseSlowOutput() {
+        release.countDown();
+    }
+
+    private static class Strategy implements AggregationStrategy {
+        private final boolean throwOnCompletion;
+
+        private Strategy(boolean throwOnCompletion) {
+            this.throwOnCompletion = throwOnCompletion;
+        }
+
+        @Override
+        public Exchange aggregate(Exchange oldExchange, Exchange newExchange) {
+            return newExchange;
+        }
+
+        @Override
+        public void onCompletion(Exchange exchange, Exchange inputExchange) {
+            if (throwOnCompletion) {
+                throw new IllegalStateException("onCompletion");
+            }
+        }
+    }
+
+    /**
+     * The EIP from direct:{id}, with the given strategy, followed by 
mock:{id}-result.
+     */
+    private interface Eip {
+        ProcessorDefinition<?> configure(RouteDefinition route, 
AggregationStrategy strategy);
+    }
+
+    /**
+     * The route with the strategy that does not throw (from direct:ok, the 
control) and with the one that throws (from
+     * direct:start): the second fails with the exception from onCompletion, 
and both are done as many times.
+     */
+    private Exchange assertFailsOnce(Eip eip, String okOutcome, String 
thrownOutcome) throws Exception {
+        return assertFailsOnce(eip, new Strategy(true), false, okOutcome, 
thrownOutcome);
+    }
+
+    private Exchange assertFailsOnce(
+            Eip eip, AggregationStrategy throwing, boolean transacted, String 
okOutcome, String thrownOutcome)
+            throws Exception {
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("direct:slow").process(e -> release.await(20, 
TimeUnit.SECONDS));
+                eip.configure(from("direct:ok"), new 
Strategy(false)).to("mock:ok-result");
+                eip.configure(from("direct:start"), 
throwing).to("mock:start-result");
+            }
+        });
+        context.start();
+
+        // the exchanges done (with the copies of a multicast): a second 
continuation would complete one again
+        AtomicInteger done = countDoneExchanges();
+        Exchange ok = template.asyncSend("direct:ok", e -> {
+            e.getExchangeExtension().setTransacted(transacted);
+            e.getMessage().setBody("a,b");
+        }).get(10, TimeUnit.SECONDS);
+        assertEquals(okOutcome, outcome(ok, "ok"), "the strategy that does not 
throw");
+        await().atMost(10, TimeUnit.SECONDS).until(() -> done.get() >= 1);
+        int okDone = done.getAndSet(0);
+
+        Exchange out = template.asyncSend("direct:start", e -> {
+            e.getExchangeExtension().setTransacted(transacted);
+            e.getMessage().setBody("a,b");
+        }).get(10, TimeUnit.SECONDS);
+        assertEquals(thrownOutcome, outcome(out, "start"), "the strategy that 
throws from onCompletion");
+        await().atMost(10, TimeUnit.SECONDS).until(() -> done.get() >= okDone);
+        assertEquals(okDone, done.get(), "the exchanges done, as with the 
strategy that does not throw");
+        return out;
+    }
+
+    private AtomicInteger countDoneExchanges() {
+        AtomicInteger done = new AtomicInteger();
+        context.getManagementStrategy().addEventNotifier(new 
EventNotifierSupport() {
+            @Override
+            public void notify(CamelEvent event) {
+                done.incrementAndGet();
+            }
+
+            @Override
+            public boolean isEnabled(CamelEvent event) {
+                return event instanceof ExchangeCompletedEvent || event 
instanceof ExchangeFailedEvent;
+            }
+        });
+        return done;
+    }
+
+    private String outcome(Exchange out, String id) {
+        Exception e = out.getException();
+        // the message of a CamelExchangeException ends with the exchange, 
which differs between the routes
+        String message = e == null ? null : e.getMessage().replaceAll("\\. 
Exchange\\[.*", "");
+        if (e instanceof CamelExecutionException && e.getCause() instanceof 
Error error) {
+            // setException wraps an Error: show the Error
+            message = error.getClass().getSimpleName() + ":" + 
error.getMessage();
+        }
+        return "exception=" + (e == null ? "none" : 
e.getClass().getSimpleName() + ":" + message)
+               + " result=" + getMockEndpoint("mock:" + id + 
"-result").getReceivedCounter();
+    }
+
+    private static final String OK = "exception=none result=1";
+    private static final String FAILED = 
"exception=IllegalStateException:onCompletion result=0";
+    private static final String ERROR_FAILED
+            = 
"exception=CamelExecutionException:NoClassDefFoundError:onCompletion result=0";
+
+    /**
+     * A strategy whose onCompletion throws an Error, such as user code that 
misses a class.
+     */
+    private static Strategy throwsError() {
+        return new Strategy(false) {
+            @Override
+            public void onCompletion(Exchange exchange, Exchange 
inputExchange) {
+                throw new NoClassDefFoundError("onCompletion");
+            }
+        };
+    }
+
+    @Test
+    void multicast() throws Exception {
+        assertFailsOnce((r, s) -> r.multicast(s).to("mock:a", "mock:b").end(), 
OK, FAILED);
+    }
+
+    @Test
+    void multicastParallel() throws Exception {
+        assertFailsOnce((r, s) -> 
r.multicast(s).parallelProcessing().to("mock:a", "mock:b").end(), OK, FAILED);
+    }
+
+    @Test
+    void multicastSynchronous() throws Exception {
+        assertFailsOnce((r, s) -> r.multicast(s).synchronous().to("mock:a", 
"mock:b").end(), OK, FAILED);
+    }
+
+    @Test
+    void multicastStopOnException() throws Exception {
+        // the multicast is done from the callback of the failed output
+        // (the exchange keeps the exception of the output, which tells where 
it failed, with the exception from
+        // onCompletion as suppressed)
+        String failed = "exception=CamelExchangeException:Multicast processing 
failed for number 1 result=0";
+        Exchange out = assertFailsOnce((r, s) -> 
r.multicast(s).stopOnException().to("mock:a")
+                .throwException(new IllegalArgumentException("failed")).end(), 
failed, failed);
+        assertSuppressedOnCompletion(out);
+    }
+
+    @Test
+    void multicastTimeout() throws Exception {
+        // the multicast is done by the timeout, while the second output still 
waits
+        assertFailsOnce((r, s) -> 
r.multicast(s).parallelProcessing().timeout(100).to("mock:a", 
"direct:slow").end(), OK,
+                FAILED);
+    }
+
+    @Test
+    void multicastTransacted() throws Exception {
+        // the MulticastTransactedTask path
+        assertFailsOnce((r, s) -> r.multicast(s).to("mock:a", "mock:b").end(), 
new Strategy(true), true, OK, FAILED);
+    }
+
+    @Test
+    void multicastError() throws Exception {
+        // an Error from onCompletion fails the exchange too (wrapped by 
setException), instead of hanging it
+        Exchange out = assertFailsOnce((r, s) -> r.multicast(s).to("mock:a", 
"mock:b").end(), throwsError(), false, OK,
+                ERROR_FAILED);
+        assertInstanceOf(NoClassDefFoundError.class, 
out.getException().getCause());
+    }
+
+    @Test
+    void multicastParallelError() throws Exception {
+        // done on a thread of the multicast pool, from aggregateCompleted
+        assertFailsOnce((r, s) -> 
r.multicast(s).parallelProcessing().to("mock:a", "mock:b").end(), 
throwsError(), false,
+                OK, ERROR_FAILED);
+    }
+
+    @Test
+    void multicastTransactedError() throws Exception {
+        assertFailsOnce((r, s) -> r.multicast(s).to("mock:a", "mock:b").end(), 
throwsError(), true, OK, ERROR_FAILED);
+    }
+
+    @Test
+    void recipientListError() throws Exception {
+        assertFailsOnce((r, s) -> 
r.recipientList(constant("mock:a,mock:b")).aggregationStrategy(s).end(), 
throwsError(),
+                false, OK, ERROR_FAILED);
+    }
+
+    @Test
+    void split() throws Exception {
+        assertFailsOnce((r, s) -> r.split(body().tokenize(","), 
s).to("mock:a").end(), OK, FAILED);
+    }
+
+    @Test
+    void splitStreaming() throws Exception {
+        assertFailsOnce((r, s) -> r.split(body().tokenize(","), 
s).streaming().to("mock:a").end(), OK, FAILED);
+    }
+
+    @Test
+    void recipientList() throws Exception {
+        assertFailsOnce((r, s) -> 
r.recipientList(constant("mock:a,mock:b")).aggregationStrategy(s).end(), OK, 
FAILED);
+    }
+
+    @Test
+    void splitInDoTry() throws Exception {
+        // the doCatch runs once
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("direct:start")
+                        .doTry()
+                            .split(body().tokenize(","), new 
Strategy(true)).synchronous()
+                                .to("mock:a")
+                            .end()
+                            .to("mock:start-result")
+                        .endDoTry()
+                        .doCatch(IllegalStateException.class)
+                            .to("mock:caught")
+                        .end();
+            }
+        });
+        context.start();
+        getMockEndpoint("mock:caught").expectedMessageCount(1);
+
+        Exchange out = template.asyncSend("direct:start", e -> 
e.getMessage().setBody("a,b")).get(10, TimeUnit.SECONDS);
+
+        assertEquals("exception=none result=0", outcome(out, "start"));
+        assertMockEndpointsSatisfied();
+        assertEquals("onCompletion", 
out.getProperty(Exchange.EXCEPTION_CAUGHT, Exception.class).getMessage());
+    }
+
+    /**
+     * The split with the given strategy, in a route whose error handler 
handles IllegalStateException
+     * (onException(...).handled(true) to mock:handled) or redelivers it, so 
the failure must reach the error handler of
+     * the route, not be handed back as exhausted.
+     */
+    private Exchange sendToSplitWithErrorHandler(AggregationStrategy strategy, 
boolean redeliver) throws Exception {
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                if (redeliver) {
+                    
errorHandler(defaultErrorHandler().maximumRedeliveries(2).redeliveryDelay(0));
+                } else {
+                    
onException(IllegalStateException.class).handled(true).to("mock:handled");
+                }
+                from("direct:start")
+                        .split(body().tokenize(","), strategy)
+                            .to("mock:a")
+                        .end()
+                        .to("mock:start-result");
+            }
+        });
+        context.start();
+        return template.asyncSend("direct:start", e -> 
e.getMessage().setBody("a,b")).get(10, TimeUnit.SECONDS);
+    }
+
+    @Test
+    void splitFailureHandledByOnException() throws Exception {
+        getMockEndpoint("mock:handled").expectedMessageCount(1);
+
+        Exchange out = sendToSplitWithErrorHandler(new Strategy(true), false);
+
+        assertEquals("exception=none result=0", outcome(out, "start"));
+        assertMockEndpointsSatisfied();
+        assertEquals("onCompletion", 
out.getProperty(Exchange.EXCEPTION_CAUGHT, Exception.class).getMessage());
+    }
+
+    @Test
+    void splitFailureRedelivered() throws Exception {
+        // the split is redelivered twice, as when the strategy throws from 
aggregate: each attempt sends both parts
+        Exchange out = sendToSplitWithErrorHandler(new Strategy(true), true);
+
+        assertEquals("exception=IllegalStateException:onCompletion result=0", 
outcome(out, "start"));
+        assertEquals(6, getMockEndpoint("mock:a").getReceivedCounter());
+    }
+
+    @Test
+    void splitAggregateFailureRedeliveredControl() throws Exception {
+        // control (same on main): the strategy that throws from aggregate, on 
the first part, so each of the three
+        // attempts sends one part
+        Exchange out = sendToSplitWithErrorHandler(new Strategy(false) {
+            @Override
+            public Exchange aggregate(Exchange oldExchange, Exchange 
newExchange) {
+                throw new IllegalStateException("aggregate");
+            }
+        }, true);
+
+        assertEquals("exception=IllegalStateException:aggregate result=0", 
outcome(out, "start"));
+        assertEquals(3, getMockEndpoint("mock:a").getReceivedCounter());
+    }
+
+    @Test
+    void aggregateAndOnCompletionThrow() throws Exception {
+        // the exchange keeps the exception of aggregate, with the one of 
onCompletion as suppressed
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("direct:start")
+                        .multicast(new Strategy(true) {
+                            @Override
+                            public Exchange aggregate(Exchange oldExchange, 
Exchange newExchange) {
+                                throw new IllegalStateException("aggregate");
+                            }
+                        })
+                        .to("mock:a", "mock:b")
+                        .end()
+                        .to("mock:start-result");
+            }
+        });
+        context.start();
+
+        Exchange out = template.asyncSend("direct:start", e -> 
e.getMessage().setBody("a,b")).get(10, TimeUnit.SECONDS);
+
+        assertEquals("exception=IllegalStateException:aggregate result=0", 
outcome(out, "start"));
+        assertSuppressedOnCompletion(out);
+    }
+
+    @Test
+    void splitExpressionThrows() throws Exception {
+        // the split fails before it sends anything: the exchange keeps the 
exception of the expression, with the one
+        // of onCompletion as suppressed
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("direct:start")
+                        .split(method(MulticastOnCompletionThrowsTest.class, 
"fail"), new Strategy(true))
+                            .to("mock:a")
+                        .end()
+                        .to("mock:start-result");
+            }
+        });
+        context.start();
+
+        Exchange out = template.asyncSend("direct:start", e -> 
e.getMessage().setBody("a,b")).get(10, TimeUnit.SECONDS);
+
+        Throwable cause = out.getException();
+        while (cause != null && !"expression".equals(cause.getMessage())) {
+            cause = cause.getCause();
+        }
+        assertEquals("expression", cause != null ? cause.getMessage() : null, 
"the exception of the expression");
+        assertSuppressedOnCompletion(out);
+        assertEquals(0, getMockEndpoint("mock:a").getReceivedCounter());
+    }
+
+    public static Object fail(Exchange exchange) {
+        throw new IllegalArgumentException("expression");
+    }
+
+    private static void assertSuppressedOnCompletion(Exchange out) {
+        Throwable[] suppressed = out.getException().getSuppressed();
+        assertEquals(1, suppressed.length);
+        assertEquals("onCompletion", suppressed[0].getMessage());
+    }
+}

Reply via email to