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 61952f53597b CAMEL-25864: camel-core - an aggregator recovers only the 
exchanges it completed in an auto-discovered KeyValueRepository (#28383)
61952f53597b is described below

commit 61952f53597b0c9dc61aae6118f2e56fe3558c2a
Author: allthingssecurity <[email protected]>
AuthorDate: Sun Oct 25 13:00:25 2026 +0530

    CAMEL-25864: camel-core - an aggregator recovers only the exchanges it 
completed in an auto-discovered KeyValueRepository (#28383)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../camel/catalog/docs/keyValueRepository.adoc     |  10 ++
 .../modules/eips/pages/keyValueRepository.adoc     |  10 ++
 .../processor/aggregate/AggregateProcessor.java    |   5 +-
 ...AutoDiscoveredKeyValueRepositorySharedTest.java | 101 +++++++++++++++++++++
 .../support/KeyValueAggregationRepositoryTest.java |  28 ++++++
 .../support/KeyValueAggregationRepository.java     |  35 ++++++-
 .../modules/ROOT/pages/key-value-repository.adoc   |  10 ++
 7 files changed, 193 insertions(+), 6 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/keyValueRepository.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/keyValueRepository.adoc
index c8398fe57026..119785e05086 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/keyValueRepository.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/keyValueRepository.adoc
@@ -1154,6 +1154,16 @@ store. For backends without native TTL (JDBC, JPA, 
Kafka), aggregation-in-progre
 persist until completion. Use a backend with durable storage (JDBC, JPA, 
Infinispan) when
 crash recovery of in-flight aggregations is required.
 
+NOTE: Every aggregator without an `aggregationRepository` wraps the same 
auto-discovered
+`KeyValueRepository`, and keeps its completed exchanges for recovery under its 
route id and
+aggregator id (`<routeId>/<id>`), so it recovers only its own. With a 
persistent store, set explicit
+route and aggregator ids: Camel generates them (`route1`, `aggregate1`) when 
they are not set, and
+they can change when routes are added or reordered between restarts. When 
several aggregators use one
+`KeyValueRepository` through explicit `KeyValueAggregationRepository` beans, 
create the beans with the
+`KeyValueAggregationRepository(repository, recoveryName)` constructor and a 
different name for each
+aggregator. Without a name, the completed exchanges of all the aggregators are 
kept under the same
+prefix, and each aggregator recovers, and sends again on its own route, the 
exchanges of the others.
+
 == See Also
 
 * xref:cache-eip.adoc[Cache EIP]
diff --git 
a/core/camel-core-engine/src/main/docs/modules/eips/pages/keyValueRepository.adoc
 
b/core/camel-core-engine/src/main/docs/modules/eips/pages/keyValueRepository.adoc
index c8398fe57026..119785e05086 100644
--- 
a/core/camel-core-engine/src/main/docs/modules/eips/pages/keyValueRepository.adoc
+++ 
b/core/camel-core-engine/src/main/docs/modules/eips/pages/keyValueRepository.adoc
@@ -1154,6 +1154,16 @@ store. For backends without native TTL (JDBC, JPA, 
Kafka), aggregation-in-progre
 persist until completion. Use a backend with durable storage (JDBC, JPA, 
Infinispan) when
 crash recovery of in-flight aggregations is required.
 
+NOTE: Every aggregator without an `aggregationRepository` wraps the same 
auto-discovered
+`KeyValueRepository`, and keeps its completed exchanges for recovery under its 
route id and
+aggregator id (`<routeId>/<id>`), so it recovers only its own. With a 
persistent store, set explicit
+route and aggregator ids: Camel generates them (`route1`, `aggregate1`) when 
they are not set, and
+they can change when routes are added or reordered between restarts. When 
several aggregators use one
+`KeyValueRepository` through explicit `KeyValueAggregationRepository` beans, 
create the beans with the
+`KeyValueAggregationRepository(repository, recoveryName)` constructor and a 
different name for each
+aggregator. Without a name, the completed exchanges of all the aggregators are 
kept under the same
+prefix, and each aggregator recovers, and sends again on its own route, the 
exchanges of the others.
+
 == See Also
 
 * xref:cache-eip.adoc[Cache EIP]
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 cb007e4f7ee1..f7cf84d3fcf9 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
@@ -1905,7 +1905,10 @@ public class AggregateProcessor extends 
BaseProcessorSupport
             // Auto-discover a KeyValueRepository from the registry and wrap it
             KeyValueRepository kvRepo = 
camelContext.getRegistry().findSingleByType(KeyValueRepository.class);
             if (kvRepo != null) {
-                setAggregationRepository(new 
KeyValueAggregationRepository(kvRepo));
+                // every aggregator without a repository discovers the same 
KeyValueRepository, so each one keeps its
+                // completed exchanges under its own name and recovers only 
those
+                String recoveryName = routeId != null && id != null ? routeId 
+ "/" + id : null;
+                setAggregationRepository(new 
KeyValueAggregationRepository(kvRepo, recoveryName));
                 LOG.info("Auto-discovered KeyValueRepository from registry, 
wrapping as AggregationRepository");
             } else {
                 aggregationRepository = new 
MemoryAggregationRepository(optimisticLocking);
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/aggregator/AggregateAutoDiscoveredKeyValueRepositorySharedTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/aggregator/AggregateAutoDiscoveredKeyValueRepositorySharedTest.java
new file mode 100644
index 000000000000..8b73d9730fb0
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/aggregator/AggregateAutoDiscoveredKeyValueRepositorySharedTest.java
@@ -0,0 +1,101 @@
+/*
+ * 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.Future;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.processor.BodyInAggregatingStrategy;
+import org.apache.camel.spi.Registry;
+import org.apache.camel.support.MemoryKeyValueRepository;
+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.assertTrue;
+
+/**
+ * Every aggregator without a repository auto-discovers the single {@code 
KeyValueRepository} of the registry. An
+ * aggregator must only recover the exchanges it completed itself, not an 
exchange that another aggregator completed and
+ * is still processing.
+ */
+class AggregateAutoDiscoveredKeyValueRepositorySharedTest extends 
ContextTestSupport {
+
+    private final MemoryKeyValueRepository kvRepo = new 
MemoryKeyValueRepository();
+    private final CountDownLatch release = new CountDownLatch(1);
+
+    @Override
+    protected Registry createCamelRegistry() throws Exception {
+        Registry registry = super.createCamelRegistry();
+        registry.bind("kvRepo", kvRepo);
+        return registry;
+    }
+
+    @AfterEach
+    void releaseHeldExchange() {
+        // also when the test fails before, so that the held exchange does not 
keep the shutdown waiting
+        release.countDown();
+    }
+
+    @Test
+    void testOtherAggregatorDoesNotRecoverExchangeInProgress() throws 
Exception {
+        MockEndpoint orders = getMockEndpoint("mock:orders");
+        orders.expectedBodiesReceived("A+B");
+        MockEndpoint invoices = getMockEndpoint("mock:invoices");
+        invoices.expectedMessageCount(0);
+        // the recover checkers run 1 second after start and then every 5 
seconds
+        invoices.setAssertPeriod(7000);
+
+        template.sendBodyAndHeader("direct:orders", "A", "id", 1);
+        // the group completes, and the aggregated exchange waits in the 
orders route
+        Future<Object> completing = 
template.asyncRequestBodyAndHeader("direct:orders", "B", "id", 1);
+        await().atMost(5, TimeUnit.SECONDS)
+                .untilAsserted(() -> 
assertTrue(kvRepo.keys().stream().anyMatch(k -> k.startsWith("completed"))));
+
+        // the aggregator of the invoices route must not send the exchange of 
the orders route
+        invoices.assertIsSatisfied();
+
+        release.countDown();
+        completing.get(5, TimeUnit.SECONDS);
+        orders.assertIsSatisfied();
+        await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> 
assertTrue(kvRepo.keys().isEmpty()));
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                // no aggregationRepository: both aggregators auto-discover 
the KeyValueRepository of the registry
+                from("direct:orders").routeId("orders")
+                        .aggregate(header("id"), new 
BodyInAggregatingStrategy())
+                        .completionSize(2)
+                        .process(e -> release.await(20, TimeUnit.SECONDS))
+                        .to("mock:orders");
+
+                from("direct:invoices").routeId("invoices")
+                        .aggregate(header("id"), new 
BodyInAggregatingStrategy())
+                        .completionSize(2)
+                        .to("mock:invoices");
+            }
+        };
+    }
+}
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/support/KeyValueAggregationRepositoryTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/support/KeyValueAggregationRepositoryTest.java
index d40b85f84bc7..63157cfb31f3 100644
--- 
a/core/camel-core/src/test/java/org/apache/camel/support/KeyValueAggregationRepositoryTest.java
+++ 
b/core/camel-core/src/test/java/org/apache/camel/support/KeyValueAggregationRepositoryTest.java
@@ -223,6 +223,34 @@ class KeyValueAggregationRepositoryTest {
         assertThat(created.getRepository()).isSameAs(kvRepository);
     }
 
+    @Test
+    void testRecoveryNameKeepsCompletedExchangesApart() throws Exception {
+        KeyValueAggregationRepository orders = new 
KeyValueAggregationRepository(kvRepository, "orders/aggregate1");
+        KeyValueAggregationRepository invoices = new 
KeyValueAggregationRepository(kvRepository, "invoices/aggregate2");
+        orders.setCamelContext(camelContext);
+        invoices.setCamelContext(camelContext);
+        orders.start();
+        invoices.start();
+
+        Exchange order = createExchange("order");
+        orders.add(camelContext, "1", order);
+        orders.remove(camelContext, "1", order);
+        Exchange invoice = createExchange("invoice");
+        invoices.add(camelContext, "2", invoice);
+        invoices.remove(camelContext, "2", invoice);
+
+        // each adapter only scans and recovers the exchanges it completed, 
and so does an adapter without a name
+        
assertThat(orders.scan(camelContext)).containsExactly(order.getExchangeId());
+        
assertThat(invoices.scan(camelContext)).containsExactly(invoice.getExchangeId());
+        assertThat(aggregationRepository.scan(camelContext)).isEmpty();
+        assertThat(orders.recover(camelContext, 
order.getExchangeId()).getIn().getBody(String.class)).isEqualTo("order");
+        assertThat(orders.recover(camelContext, 
invoice.getExchangeId())).isNull();
+
+        orders.confirm(camelContext, order.getExchangeId());
+        assertThat(orders.scan(camelContext)).isEmpty();
+        
assertThat(invoices.scan(camelContext)).containsExactly(invoice.getExchangeId());
+    }
+
     private Exchange createExchange(String body) {
         Exchange exchange = new DefaultExchange(camelContext);
         exchange.getIn().setBody(body);
diff --git 
a/core/camel-support/src/main/java/org/apache/camel/support/KeyValueAggregationRepository.java
 
b/core/camel-support/src/main/java/org/apache/camel/support/KeyValueAggregationRepository.java
index ccf651a8c266..393492249da2 100644
--- 
a/core/camel-support/src/main/java/org/apache/camel/support/KeyValueAggregationRepository.java
+++ 
b/core/camel-support/src/main/java/org/apache/camel/support/KeyValueAggregationRepository.java
@@ -69,6 +69,8 @@ public class KeyValueAggregationRepository extends 
ServiceSupport
     private static final String COMPLETED_PREFIX = "completed:";
 
     private KeyValueRepository repository;
+    // the completed exchanges kept for recovery are stored under this prefix
+    private String completedPrefix = COMPLETED_PREFIX;
     private CamelContext camelContext;
     private boolean useRecovery = true;
     private String deadLetterUri;
@@ -96,6 +98,29 @@ public class KeyValueAggregationRepository extends 
ServiceSupport
         this.repository = repository;
     }
 
+    /**
+     * Creates an aggregation repository adapter backed by the given key-value 
repository, which keeps the completed
+     * exchanges for recovery under the given name.
+     * <p/>
+     * Use this when several aggregators share the same key-value repository: 
the recover task of an aggregator scans
+     * all the completed exchanges of its repository and sends them again, so 
an aggregator would otherwise recover, and
+     * send a second time on its own route, an exchange that another 
aggregator completed and is still processing.
+     *
+     * @param repository   the underlying key-value repository; must not be 
{@code null}
+     * @param recoveryName the name under which the completed exchanges are 
kept, such as the route id and the id of the
+     *                     aggregator; {@code null} keeps them under the 
default prefix shared by all the adapters
+     *                     without a name. The name should not contain {@code 
]:}, as the keys of such a name can start
+     *                     with the prefix of another name (the keys of {@code 
a]:b} start with the prefix of {@code a})
+     * @since              4.24
+     */
+    public KeyValueAggregationRepository(KeyValueRepository repository, String 
recoveryName) {
+        this(repository);
+        if (recoveryName != null) {
+            // not under the default prefix, so that an adapter without a name 
does not scan them
+            this.completedPrefix = "completed[" + recoveryName + "]:";
+        }
+    }
+
     /**
      * Creates a new {@link KeyValueAggregationRepository} wrapping the given 
{@link KeyValueRepository}.
      *
@@ -128,7 +153,7 @@ public class KeyValueAggregationRepository extends 
ServiceSupport
             // it can be older than the exchange: when a group is completed by 
an incoming exchange, the last
             // aggregated exchange is not added to the repository before it is 
removed.
             LOG.trace("Moving Exchange with ID {} to completed (pending 
confirmation)", exchange.getExchangeId());
-            repository.put(COMPLETED_PREFIX + exchange.getExchangeId(),
+            repository.put(completedPrefix + exchange.getExchangeId(),
                     DefaultExchangeHolder.marshal(exchange, true, 
allowSerializedHeaders));
         }
     }
@@ -136,7 +161,7 @@ public class KeyValueAggregationRepository extends 
ServiceSupport
     @Override
     public void confirm(CamelContext camelContext, String exchangeId) {
         LOG.trace("Confirming an Exchange with ID {}", exchangeId);
-        repository.delete(COMPLETED_PREFIX + exchangeId);
+        repository.delete(completedPrefix + exchangeId);
     }
 
     @Override
@@ -152,8 +177,8 @@ public class KeyValueAggregationRepository extends 
ServiceSupport
     public Set<String> scan(CamelContext camelContext) {
         LOG.trace("Scanning for exchanges to recover in {} context", 
camelContext.getName());
         Set<String> scanned = repository.keys().stream()
-                .filter(k -> k.startsWith(COMPLETED_PREFIX))
-                .map(k -> k.substring(COMPLETED_PREFIX.length()))
+                .filter(k -> k.startsWith(completedPrefix))
+                .map(k -> k.substring(completedPrefix.length()))
                 .collect(Collectors.toUnmodifiableSet());
         LOG.trace("Found {} keys for exchanges to recover in {} context", 
scanned.size(), camelContext.getName());
         return scanned;
@@ -165,7 +190,7 @@ public class KeyValueAggregationRepository extends 
ServiceSupport
         if (!useRecovery) {
             return null;
         }
-        return unmarshallExchange(camelContext, (DefaultExchangeHolder) 
repository.get(COMPLETED_PREFIX + exchangeId));
+        return unmarshallExchange(camelContext, (DefaultExchangeHolder) 
repository.get(completedPrefix + exchangeId));
     }
 
     @Override
diff --git a/docs/user-manual/modules/ROOT/pages/key-value-repository.adoc 
b/docs/user-manual/modules/ROOT/pages/key-value-repository.adoc
index 1b7e44909e35..1d5040b5765b 100644
--- a/docs/user-manual/modules/ROOT/pages/key-value-repository.adoc
+++ b/docs/user-manual/modules/ROOT/pages/key-value-repository.adoc
@@ -318,6 +318,16 @@ With this single bean in the registry:
 
 No explicit wiring is needed.
 
+NOTE: Every aggregator without an `aggregationRepository` wraps the same 
auto-discovered
+`KeyValueRepository`, and keeps its completed exchanges for recovery under its 
route id and
+aggregator id (`<routeId>/<id>`), so it recovers only its own. With a 
persistent store, set explicit
+route and aggregator ids: Camel generates them (`route1`, `aggregate1`) when 
they are not set, and
+they can change when routes are added or reordered between restarts. When 
several aggregators use one
+`KeyValueRepository` through explicit `KeyValueAggregationRepository` beans, 
create the beans with the
+`KeyValueAggregationRepository(repository, recoveryName)` constructor and a 
different name for each
+aggregator. Without a name, the completed exchanges of all the aggregators are 
kept under the same
+prefix, and each aggregator recovers, and sends again on its own route, the 
exchanges of the others.
+
 === Explicit wiring
 
 You can also wire the backend explicitly when you need more control:

Reply via email to