This is an automated email from the ASF dual-hosted git repository.

apupier 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 c949b429a938 CAMEL-24944: camel-core - Aggregate EIP: send a 
pre-completed group also when the next exchange fails to aggregate
c949b429a938 is described below

commit c949b429a938a83852011c3f19200cdcb45d1f09
Author: smjain <[email protected]>
AuthorDate: Wed Sep 23 18:07:51 2026 +0530

    CAMEL-24944: camel-core - Aggregate EIP: send a pre-completed group also 
when the next exchange fails to aggregate
    
    When the AggregationStrategy pre-completes a group (canPreComplete), 
doAggregation
    removes the old group from the repository and keeps it in a local list, then
    aggregates the new exchange and adds it as a new group. If that second part 
did
    not finish normally the list was dropped and the completed group was never 
sent:
    
    - with optimistic locking the add of the new group fails with an
      OptimisticLockingException when another exchange created a group for the 
same
      key in between; the exception left doAggregation (only 
CamelExchangeException
      is caught) and the exchange was retried from scratch,
    - the strategy throws when aggregating the new exchange,
    - the same with discardOnAggregationFailure, where doAggregation returned 
null.
    
    The group had already been removed from the repository (and the key closed
    and its timeout removed), so it was lost, or only re-delivered later by the
    recover task of a recoverable repository.
    
    doAggregation now adds completed exchanges to a list owned by doProcess, 
which
    submits them after releasing the lock also when doAggregation throws or the
    exchange is retried.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../processor/aggregate/AggregateProcessor.java    |  24 +--
 .../AggregatePreCompleteLostGroupTest.java         | 213 +++++++++++++++++++++
 2 files changed, 223 insertions(+), 14 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 289360076d45..9bb85dc6f065 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
@@ -448,7 +448,7 @@ public class AggregateProcessor extends BaseProcessorSupport
         removeFlagCompleteAllGroups(copy);
         removeFlagCompleteAllGroupsInclusive(copy);
 
-        List<Exchange> aggregated = null;
+        List<Exchange> aggregated = new ArrayList<>();
         lock.lock();
         try {
             // check again under the lock (and on every optimistic locking 
retry), as the key may have been closed
@@ -456,16 +456,14 @@ public class AggregateProcessor extends 
BaseProcessorSupport
             if (closedCorrelationKeys != null && 
closedCorrelationKeys.containsKey(key)) {
                 throw new ClosedCorrelationKeyException(key, exchange);
             }
-            aggregated = doAggregation(key, copy);
+            doAggregation(key, copy, aggregated);
         } catch (CamelExchangeException e) {
             exchange.setException(e);
         } finally {
             lock.unlock();
-        }
-
-        // we are completed so do that work outside the lock
-        if (aggregated != null) {
-            // we are completed so submit to completion
+            // we are completed so submit to completion outside the lock. This 
must also be done when the aggregation
+            // failed, or must be retried due to optimistic locking, after a 
group was completed (such as a group
+            // completed by pre-completion), as that group has already been 
removed from the repository
             aggregated.forEach(agg -> onSubmitCompletion(key, agg));
         }
 
@@ -516,20 +514,19 @@ public class AggregateProcessor extends 
BaseProcessorSupport
      * <p/>
      * This method <b>must</b> be run synchronized as we cannot aggregate the 
same correlation key in parallel.
      * <p/>
-     * The returned {@link Exchange} should be send downstream using the
+     * The completed {@link Exchange}(s) are added to the given list as soon 
as they are completed (and removed from the
+     * repository), also if this method fails afterwards. They should be send 
downstream using the
      * {@link #onSubmitCompletion(String, org.apache.camel.Exchange)} method 
which sends out the aggregated and
      * completed {@link Exchange}.
      *
      * @param  key                                     the correlation key
      * @param  newExchange                             the exchange
-     * @return                                         the aggregated 
exchange(s) which is complete, or <tt>null</tt> if
-     *                                                 not yet complete
+     * @param  list                                    the list to add the 
aggregated exchange(s) which are complete to
      * @throws org.apache.camel.CamelExchangeException is thrown if error 
aggregating
      */
-    private List<Exchange> doAggregation(String key, Exchange newExchange) 
throws CamelExchangeException {
+    private void doAggregation(String key, Exchange newExchange, 
List<Exchange> list) throws CamelExchangeException {
         LOG.trace("onAggregation +++ start +++ with correlation key: {}", key);
 
-        List<Exchange> list = new ArrayList<>();
         String complete = null;
 
         Exchange answer;
@@ -611,7 +608,7 @@ public class AggregateProcessor extends BaseProcessorSupport
                 answer = oldExchange;
                 if (answer == null) {
                     // first message in group failed during aggregation and we 
should just discard this
-                    return null;
+                    return;
                 }
             } else {
                 // must catch any exception from aggregation
@@ -663,7 +660,6 @@ public class AggregateProcessor extends BaseProcessorSupport
         }
 
         LOG.trace("onAggregation +++  end  +++ with correlation key: {}", key);
-        return list;
     }
 
     protected void doAggregationComplete(
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/aggregator/AggregatePreCompleteLostGroupTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/aggregator/AggregatePreCompleteLostGroupTest.java
new file mode 100644
index 000000000000..aa806fd1f3b1
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/aggregator/AggregatePreCompleteLostGroupTest.java
@@ -0,0 +1,213 @@
+/*
+ * 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.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.apache.camel.AggregationStrategy;
+import org.apache.camel.AsyncProcessor;
+import org.apache.camel.CamelContext;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.processor.SendProcessor;
+import org.apache.camel.processor.aggregate.AggregateProcessor;
+import org.apache.camel.processor.aggregate.MemoryAggregationRepository;
+import org.apache.camel.processor.aggregate.OptimisticLockRetryPolicy;
+import org.apache.camel.support.DefaultExchange;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * A group completed by pre-completion has been removed from the repository, 
so it must be sent even if the exchange
+ * that pre-completed it cannot be aggregated afterwards.
+ */
+public class AggregatePreCompleteLostGroupTest extends ContextTestSupport {
+
+    private final CountDownLatch removed = new CountDownLatch(1);
+    private final CountDownLatch releaseRemove = new CountDownLatch(1);
+    private ExecutorService executorService;
+
+    @Override
+    public boolean isUseRouteBuilder() {
+        return false;
+    }
+
+    @Override
+    @BeforeEach
+    public void setUp() throws Exception {
+        super.setUp();
+        executorService = Executors.newSingleThreadExecutor();
+    }
+
+    @Override
+    @AfterEach
+    public void tearDown() throws Exception {
+        releaseRemove.countDown();
+        executorService.shutdownNow();
+        super.tearDown();
+    }
+
+    @Test
+    public void testOptimisticLockingFailureAfterPreCompletion() throws 
Exception {
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceivedInAnyOrder("a1", "c", "START-b");
+
+        // pauses the thread of START-b right after it removed the 
pre-completed group from the repository
+        AtomicBoolean pauseRemove = new AtomicBoolean(true);
+        MemoryAggregationRepository repository = new 
MemoryAggregationRepository(true) {
+            @Override
+            public void remove(CamelContext camelContext, String key, Exchange 
exchange) {
+                super.remove(camelContext, key, exchange);
+                if 
(Thread.currentThread().getName().equals("producer-START-b") && 
pauseRemove.getAndSet(false)) {
+                    removed.countDown();
+                    await(releaseRemove);
+                }
+            }
+        };
+
+        AggregateProcessor ap = createProcessor();
+        ap.setAggregationRepository(repository);
+        ap.setOptimisticLocking(true);
+        // retry at once in the same thread
+        ap.setOptimisticLockRetryPolicy(new 
OptimisticLockRetryPolicy().retryDelay(0).maximumRetries(5));
+        ap.start();
+
+        ap.process(createExchange("a1"));
+
+        // START-b pre-completes the group [a1]: it is removed from the 
repository, and START-b is paused
+        Exchange b = createExchange("START-b");
+        Thread producer = new Thread(() -> process(ap, b), "producer-START-b");
+        producer.start();
+        await(removed);
+
+        // c starts a new group, so START-b fails to add its new group and is 
retried
+        ap.process(createExchange("c"));
+        releaseRemove.countDown();
+        producer.join(10000);
+        assertNull(b.getException());
+
+        // pre-complete the group of START-b
+        ap.process(createExchange("START-end"));
+
+        assertMockEndpointsSatisfied();
+        ap.stop();
+    }
+
+    @Test
+    public void testAggregationFailureAfterPreCompletion() throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("a1");
+
+        AggregateProcessor ap = createProcessor();
+        ap.start();
+
+        ap.process(createExchange("a1"));
+        Exchange bad = createExchange("START-fail");
+        ap.process(bad);
+
+        assertNotNull(bad.getException());
+        assertTrue(ap.getAggregationRepository().getKeys().isEmpty());
+        assertMockEndpointsSatisfied();
+        ap.stop();
+    }
+
+    @Test
+    public void testAggregationFailureDiscardedAfterPreCompletion() throws 
Exception {
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("a1");
+
+        AggregateProcessor ap = createProcessor();
+        ap.setDiscardOnAggregationFailure(true);
+        ap.start();
+
+        ap.process(createExchange("a1"));
+        Exchange bad = createExchange("START-fail");
+        ap.process(bad);
+
+        assertNull(bad.getException());
+        assertTrue(ap.getAggregationRepository().getKeys().isEmpty());
+        assertMockEndpointsSatisfied();
+        ap.stop();
+    }
+
+    private AggregateProcessor createProcessor() {
+        AsyncProcessor done = new 
SendProcessor(context.getEndpoint("mock:result"));
+        // pre-completes the current group when a body starting with START 
arrives
+        AggregationStrategy strategy = new AggregationStrategy() {
+            @Override
+            public boolean canPreComplete() {
+                return true;
+            }
+
+            @Override
+            public boolean preComplete(Exchange oldExchange, Exchange 
newExchange) {
+                return oldExchange != null && 
newExchange.getIn().getBody(String.class).startsWith("START");
+            }
+
+            @Override
+            public Exchange aggregate(Exchange oldExchange, Exchange 
newExchange) {
+                String body = newExchange.getIn().getBody(String.class);
+                if (body.endsWith("fail")) {
+                    throw new IllegalArgumentException("Cannot aggregate " + 
body);
+                }
+                if (oldExchange == null) {
+                    return newExchange;
+                }
+                
oldExchange.getIn().setBody(oldExchange.getIn().getBody(String.class) + "+" + 
body);
+                return oldExchange;
+            }
+        };
+        return new AggregateProcessor(context, done, header("id"), strategy, 
executorService, true);
+    }
+
+    private Exchange createExchange(String body) {
+        Exchange exchange = new DefaultExchange(context);
+        exchange.getIn().setBody(body);
+        exchange.getIn().setHeader("id", 1);
+        return exchange;
+    }
+
+    private static void process(AggregateProcessor ap, Exchange exchange) {
+        try {
+            ap.process(exchange);
+        } catch (Exception e) {
+            exchange.setException(e);
+        }
+    }
+
+    private static void await(CountDownLatch latch) {
+        try {
+            if (!latch.await(10, TimeUnit.SECONDS)) {
+                fail("Timeout waiting for latch");
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            fail("Interrupted");
+        }
+    }
+}

Reply via email to