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 663cf622626e CAMEL-26706: camel-core - Aggregate EIP: fail the 
aggregated exchange when the AggregationStrategy onCompletion throws (#29403)
663cf622626e is described below

commit 663cf622626e99e4d9062de4cc2e5c0a7ce0db33
Author: allthingssecurity <[email protected]>
AuthorDate: Sun Nov 8 13:31:06 2026 +0530

    CAMEL-26706: camel-core - Aggregate EIP: fail the aggregated exchange when 
the AggregationStrategy onCompletion throws (#29403)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../processor/aggregate/AggregateProcessor.java    |  53 +++--
 .../AggregateOnCompletionThrowsTest.java           | 263 +++++++++++++++++++++
 2 files changed, 297 insertions(+), 19 deletions(-)

diff --git 
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/aggregate/AggregateProcessor.java
 
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/aggregate/AggregateProcessor.java
index 541261258eba..5c0a3d8051ee 100644
--- 
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/aggregate/AggregateProcessor.java
+++ 
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/aggregate/AggregateProcessor.java
@@ -1098,22 +1098,23 @@ public class AggregateProcessor extends 
BaseProcessorSupport
 
     /**
      * Sends the aggregated exchanges (more than one when the batch consumer 
completes all its groups). None of them is
-     * in the repository any more, so when the thread pool rejects one of 
them, the others are still sent (or failed if
-     * rejected too), and then the first rejection is thrown.
+     * in the repository any more, so when one of them fails to be submitted 
(the thread pool rejects it, or the
+     * aggregation strategy throws an exception on completion), the others are 
still sent (or failed too), and then the
+     * first failure is thrown.
      */
     private void submitCompletions(String key, List<Exchange> aggregated) {
-        RejectedExecutionException rejected = null;
+        RuntimeException failure = null;
         for (Exchange agg : aggregated) {
             try {
                 onSubmitCompletion(key, agg);
-            } catch (RejectedExecutionException e) {
-                if (rejected == null) {
-                    rejected = e;
+            } catch (RuntimeException e) {
+                if (failure == null) {
+                    failure = e;
                 }
             }
         }
-        if (rejected != null) {
-            throw rejected;
+        if (failure != null) {
+            throw failure;
         }
     }
 
@@ -1129,7 +1130,15 @@ public class AggregateProcessor extends 
BaseProcessorSupport
         // registered as in progress, so no longer needs to be marked as being 
completed
         unmarkCompleting(exchange.getExchangeId());
         // invoke the on completion callback
-        aggregationStrategy.onCompletion(exchange);
+        try {
+            aggregationStrategy.onCompletion(exchange);
+        } catch (Throwable e) {
+            // it must not throw, but the exchange is in progress already: 
fail it as when the thread pool rejects it
+            exchange.getExchangeExtension().addOnCompletion(new 
AggregateOnCompletion(exchange.getExchangeId()));
+            failCompletion(exchange, e, "Error processing aggregated exchange: 
AggregationStrategy onCompletion failed");
+            // and let the caller fail as before
+            throw e;
+        }
 
         if (getStatistics().isStatisticsEnabled()) {
             totalCompleted.incrementAndGet();
@@ -1172,23 +1181,23 @@ public class AggregateProcessor extends 
BaseProcessorSupport
                 }
             });
         } catch (RejectedExecutionException e) {
-            onRejectedCompletion(exchange, e);
+            failCompletion(exchange, e, "Error processing aggregated exchange: 
the thread pool rejected it");
             // and let the caller fail as before (such as the exchange that 
completed the group)
             throw e;
         }
     }
 
     /**
-     * The thread pool rejected the task that sends the aggregated exchange. 
The group has already been removed from the
-     * aggregation repository, so the aggregated exchange fails as if its 
processing had failed: its on completions run
-     * (the aggregator then no longer counts it as in progress, and does not 
confirm it, so a recoverable repository
-     * recovers it), and it is passed to the exception handler.
+     * The aggregated exchange cannot be sent: the thread pool rejected the 
task that sends it, or the aggregation
+     * strategy failed on completion. The group has already been removed from 
the aggregation repository, so the
+     * aggregated exchange fails as if its processing had failed: its on 
completions run (the aggregator then no longer
+     * counts it as in progress, and does not confirm it, so a recoverable 
repository recovers it), and it is passed to
+     * the exception handler.
      */
-    private void onRejectedCompletion(Exchange exchange, 
RejectedExecutionException e) {
-        exchange.setException(e);
+    private void failCompletion(Exchange exchange, Throwable cause, String 
message) {
+        exchange.setException(cause);
         UnitOfWorkHelper.doneSynchronizations(exchange, 
exchange.getExchangeExtension().handoverCompletions());
-        getExceptionHandler().handleException("Error processing aggregated 
exchange: the thread pool rejected it",
-                exchange, e);
+        getExceptionHandler().handleException(message, exchange, cause);
     }
 
     private void aggregateCompletionCounter(Exchange exchange) {
@@ -1888,7 +1897,13 @@ public class AggregateProcessor extends 
BaseProcessorSupport
                                             data.redeliveryCounter, 
exchangeId);
 
                                     // not exhaust so resubmit the recovered 
exchange
-                                    onSubmitCompletion(key, exchange);
+                                    try {
+                                        onSubmitCompletion(key, exchange);
+                                    } catch (RuntimeException e) {
+                                        // it has been failed and passed to 
the exception handler, and is recovered
+                                        // again on a later run, so go on with 
the other exchanges
+                                        LOG.debug("Error resubmitting 
recovered exchange with id: {}", exchangeId, e);
+                                    }
                                 }
                             }
                         }
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/aggregator/AggregateOnCompletionThrowsTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/aggregator/AggregateOnCompletionThrowsTest.java
new file mode 100644
index 000000000000..9b5943d132f0
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/aggregator/AggregateOnCompletionThrowsTest.java
@@ -0,0 +1,263 @@
+/*
+ * 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.aggregator;
+
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CopyOnWriteArraySet;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.processor.BodyInAggregatingStrategy;
+import org.apache.camel.processor.aggregate.AggregateProcessor;
+import org.apache.camel.spi.AggregationRepository;
+import org.apache.camel.spi.ExceptionHandler;
+import org.apache.camel.support.KeyValueAggregationRepository;
+import org.apache.camel.support.MemoryKeyValueRepository;
+import org.apache.camel.support.SynchronizationAdapter;
+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.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * An aggregation strategy whose onCompletion throws (its javadoc says it must 
not): the aggregated exchange is booked
+ * as in progress already, and its group has been removed from the repository. 
The aggregated exchange must then be
+ * failed, as when the thread pool rejects it, so that the aggregator no 
longer counts it as in progress and a
+ * recoverable repository recovers it, instead of skipping it forever as being 
in progress.
+ */
+class AggregateOnCompletionThrowsTest extends ContextTestSupport {
+
+    private final AtomicBoolean throwOnce = new AtomicBoolean(true);
+    private final Set<String> alwaysThrow = ConcurrentHashMap.newKeySet();
+    private final List<String> failures = new CopyOnWriteArrayList<>();
+    private final List<Exchange> handled = new CopyOnWriteArrayList<>();
+
+    @Override
+    public boolean isUseRouteBuilder() {
+        return false;
+    }
+
+    @Test
+    void testFailedAggregatedExchangeIsRecovered() throws Exception {
+        AggregateProcessor aggregator = addRoute(recoverableRepository(), 
false);
+
+        MockEndpoint mock = getMockEndpoint("mock:aggregated");
+        mock.expectedBodiesReceived("A1+A2");
+        mock.expectedHeaderReceived(Exchange.REDELIVERED, true);
+        mock.setAssertPeriod(500);
+
+        send("A1", "A");
+        Exchange last = send("A2", "A");
+        // the message that completed the group fails with the exception from 
onCompletion, as before
+        assertInstanceOf(IllegalStateException.class, last.getException());
+
+        // A1+A2 is still in the completed store of the repository, so it is 
recovered
+        assertMockEndpointsSatisfied();
+        await().atMost(5, TimeUnit.SECONDS)
+                .untilAsserted(() -> assertEquals(0, 
aggregator.getInProgressCompleteExchanges()));
+    }
+
+    @Test
+    void testFailedAggregatedExchangeIsNoLongerInProgress() throws Exception {
+        AggregateProcessor aggregator = addRoute(null, false);
+
+        getMockEndpoint("mock:aggregated").expectedBodiesReceived("B1+B2");
+
+        send("A1", "A");
+        Exchange last = send("A2", "A");
+        assertInstanceOf(IllegalStateException.class, last.getException());
+
+        // the aggregated exchange is handed to the exception handler, and its 
on completions run as failed
+        assertEquals(1, handled.size());
+        assertEquals("A1+A2", handled.get(0).getIn().getBody(String.class));
+        assertInstanceOf(IllegalStateException.class, 
handled.get(0).getException());
+        assertEquals(List.of("A1+A2"), failures);
+        assertEquals(0, aggregator.getInProgressCompleteExchanges());
+
+        // the next group is sent as usual
+        send("B1", "B");
+        send("B2", "B");
+        assertMockEndpointsSatisfied();
+    }
+
+    @Test
+    void testFailedBatchCompletionSendsOtherGroups() throws Exception {
+        AggregateProcessor aggregator = addRoute(recoverableRepository(), 
true);
+
+        // the batch consumer completes the groups A and B at once: whichever 
group's onCompletion throws is
+        // recovered, and the other one is still sent
+        MockEndpoint mock = getMockEndpoint("mock:aggregated");
+        mock.expectedBodiesReceivedInAnyOrder("A1+A2", "B1");
+        mock.setAssertPeriod(500);
+
+        sendBatch("A1", "A");
+        sendBatch("B1", "B");
+        Exchange last = sendBatch("A2", "A");
+        assertInstanceOf(IllegalStateException.class, last.getException());
+
+        assertMockEndpointsSatisfied();
+        await().atMost(5, TimeUnit.SECONDS)
+                .untilAsserted(() -> assertEquals(0, 
aggregator.getInProgressCompleteExchanges()));
+    }
+
+    @Test
+    void testRecoverGoesOnAfterAnExchangeThatFailsAgain() throws Exception {
+        addRoute(new OrderedRecoverableRepository(), false);
+        // onCompletion of A1+A2 throws each time, so it fails again each time 
the recover task resubmits it, and it is
+        // the first one the recover task finds: the recover task must still 
go on and recover B1+B2
+        alwaysThrow.add("A1+A2");
+
+        MockEndpoint mock = getMockEndpoint("mock:aggregated");
+        mock.expectedBodiesReceived("B1+B2");
+        mock.setAssertPeriod(500);
+
+        send("A1", "A");
+        send("A2", "A");
+        // onCompletion of B1+B2 throws once (throwOnce), so it is in the 
completed store too
+        send("B1", "B");
+        Exchange last = send("B2", "B");
+        assertInstanceOf(IllegalStateException.class, last.getException());
+
+        assertMockEndpointsSatisfied();
+        // and A1+A2 was recovered and failed again
+        assertTrue(failures.stream().filter("A1+A2"::equals).count() >= 2);
+    }
+
+    private static KeyValueAggregationRepository recoverableRepository() {
+        KeyValueAggregationRepository repository = new 
KeyValueAggregationRepository(new MemoryKeyValueRepository());
+        repository.setUseRecovery(true);
+        repository.setRecoveryInterval(100);
+        return repository;
+    }
+
+    private Exchange send(String body, String id) {
+        return template.send("direct:start", exchange -> {
+            exchange.getIn().setBody(body);
+            exchange.getIn().setHeader("id", id);
+        });
+    }
+
+    private Exchange sendBatch(String body, String id) {
+        return template.send("direct:start", exchange -> {
+            exchange.getIn().setBody(body);
+            exchange.getIn().setHeader("id", id);
+            exchange.setProperty(Exchange.BATCH_SIZE, 3);
+        });
+    }
+
+    private AggregateProcessor addRoute(AggregationRepository repository, 
boolean batch) throws Exception {
+        // onCompletion throws the first time only (and each time for the 
bodies in alwaysThrow), after adding an on
+        // completion to the aggregated exchange (as the zip and tar 
strategies do to delete their temporary file)
+        BodyInAggregatingStrategy strategy = new BodyInAggregatingStrategy() {
+            @Override
+            public void onCompletion(Exchange exchange) {
+                String body = exchange.getIn().getBody(String.class);
+                exchange.getExchangeExtension().addOnCompletion(new 
SynchronizationAdapter() {
+                    @Override
+                    public void onFailure(Exchange exchange) {
+                        failures.add(body);
+                    }
+                });
+                if (alwaysThrow.contains(body) || 
throwOnce.compareAndSet(true, false)) {
+                    throw new IllegalStateException("onCompletion failed for " 
+ body);
+                }
+            }
+        };
+
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                var aggregate = from("direct:start").aggregate(header("id"), 
strategy).id("aggregator");
+                if (batch) {
+                    aggregate.completionFromBatchConsumer();
+                } else {
+                    aggregate.completionSize(2);
+                }
+                if (repository != null) {
+                    aggregate.aggregationRepository(repository);
+                }
+                aggregate.to("mock:aggregated");
+            }
+        });
+        context.start();
+
+        AggregateProcessor aggregator = context.getProcessor("aggregator", 
AggregateProcessor.class);
+        aggregator.setExceptionHandler(new ExceptionHandler() {
+            @Override
+            public void handleException(Throwable exception) {
+                handleException(null, null, exception);
+            }
+
+            @Override
+            public void handleException(String message, Throwable exception) {
+                handleException(message, null, exception);
+            }
+
+            @Override
+            public void handleException(String message, Exchange exchange, 
Throwable exception) {
+                if (exchange != null) {
+                    handled.add(exchange);
+                }
+            }
+        });
+        return aggregator;
+    }
+
+    /**
+     * A recoverable repository whose scan returns the exchanges in the order 
they were completed.
+     */
+    private static final class OrderedRecoverableRepository extends 
KeyValueAggregationRepository {
+        private final Set<String> completed = new CopyOnWriteArraySet<>();
+
+        private OrderedRecoverableRepository() {
+            super(new MemoryKeyValueRepository());
+            setUseRecovery(true);
+            setRecoveryInterval(100);
+            // no limit (as the JDBC repository by default), so the exchange 
that fails again is never exhausted
+            setMaximumRedeliveries(0);
+        }
+
+        @Override
+        public void remove(CamelContext camelContext, String key, Exchange 
exchange) {
+            super.remove(camelContext, key, exchange);
+            completed.add(exchange.getExchangeId());
+        }
+
+        @Override
+        public Set<String> scan(CamelContext camelContext) {
+            Set<String> scanned = super.scan(camelContext);
+            Set<String> answer = new LinkedHashSet<>();
+            for (String exchangeId : completed) {
+                if (scanned.contains(exchangeId)) {
+                    answer.add(exchangeId);
+                }
+            }
+            return answer;
+        }
+    }
+}

Reply via email to