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 8bd61afc3b7d CAMEL-26321: camel-core - Split EIP keeps the original 
message when maxFailedRecords or errorThreshold stops it (#28781)
8bd61afc3b7d is described below

commit 8bd61afc3b7d7b8ffc7e34502d73fb766412acd6
Author: allthingssecurity <[email protected]>
AuthorDate: Sun Nov 1 13:49:56 2026 +0530

    CAMEL-26321: camel-core - Split EIP keeps the original message when 
maxFailedRecords or errorThreshold stops it (#28781)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../org/apache/camel/catalog/docs/split-eip.adoc   |   3 +
 .../main/docs/modules/eips/pages/split-eip.adoc    |   3 +
 .../apache/camel/processor/MulticastProcessor.java |  18 +-
 .../java/org/apache/camel/processor/Splitter.java  |  21 ++
 ...itterThresholdStopKeepsOriginalMessageTest.java | 280 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  12 +
 6 files changed, 335 insertions(+), 2 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/split-eip.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/split-eip.adoc
index df5289871bbb..db0d1f2ae172 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/split-eip.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/split-eip.adoc
@@ -1263,6 +1263,9 @@ In this example, if 50% or more of the processed items 
have failed, the splitter
 Both `maxFailedRecords` and `errorThreshold` can be combined. The splitter 
stops when either
 threshold is exceeded.
 
+When the splitter stops because a threshold is reached, the exchange keeps the 
original input message
+and has the exception set, the same as with `stopOnException`.
+
 IMPORTANT: The `stopOnException` option is mutually exclusive with 
`maxFailedRecords` and
 `errorThreshold`. You cannot use `stopOnException` together with either of 
these options.
 
diff --git 
a/core/camel-core-engine/src/main/docs/modules/eips/pages/split-eip.adoc 
b/core/camel-core-engine/src/main/docs/modules/eips/pages/split-eip.adoc
index df5289871bbb..db0d1f2ae172 100644
--- a/core/camel-core-engine/src/main/docs/modules/eips/pages/split-eip.adoc
+++ b/core/camel-core-engine/src/main/docs/modules/eips/pages/split-eip.adoc
@@ -1263,6 +1263,9 @@ In this example, if 50% or more of the processed items 
have failed, the splitter
 Both `maxFailedRecords` and `errorThreshold` can be combined. The splitter 
stops when either
 threshold is exceeded.
 
+When the splitter stops because a threshold is reached, the exchange keeps the 
original input message
+and has the exception set, the same as with `stopOnException`.
+
 IMPORTANT: The `stopOnException` option is mutually exclusive with 
`maxFailedRecords` and
 `errorThreshold`. You cannot use `stopOnException` together with either of 
these options.
 
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 9b54724c468b..f2ede9dc193a 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
@@ -875,6 +875,19 @@ public class MulticastProcessor extends 
BaseProcessorSupport
         return !stopOnException;
     }
 
+    /**
+     * Whether processing was stopped because {@link 
#shouldContinueOnFailure(Exchange, Exchange, int)} returned
+     * {@code false} for a failed sub-exchange although {@code 
stopOnException} is not enabled (e.g., the Splitter
+     * reached its error threshold). The original exchange is then kept and 
only gets the exception, the same as with
+     * {@code stopOnException}.
+     *
+     * @param  original the original exchange
+     * @return          {@code true} if processing was stopped on a failure
+     */
+    protected boolean isStoppedOnFailure(Exchange original) {
+        return false;
+    }
+
     protected ScheduledFuture<?> schedule(Executor executor, Runnable 
runnable, long delay, TimeUnit unit) {
         if (executor instanceof ScheduledExecutorService 
scheduledExecutorService) {
             return scheduledExecutorService.schedule(runnable, delay, unit);
@@ -946,7 +959,8 @@ public class MulticastProcessor extends BaseProcessorSupport
         // cleanup any per exchange aggregation strategy
         removeAggregationStrategyFromExchange(original);
 
-        // we need to know if there was an exception, and if the 
stopOnException option was enabled
+        // we need to know if there was an exception, and if we stopped on it 
(the stopOnException option, or a failure
+        // policy such as the error threshold of the Splitter)
         // also we would need to know if any error handler has attempted 
redelivery and exhausted
         boolean stoppedOnException = false;
         boolean exception = false;
@@ -954,7 +968,7 @@ public class MulticastProcessor extends BaseProcessorSupport
                 && (subExchange.getException() != null || 
subExchange.getExchangeExtension().isRedeliveryExhausted());
         if (original.getException() != null || subExchange != null && 
subExchange.getException() != null) {
             // there was an exception and we stopped
-            stoppedOnException = isStopOnException();
+            stoppedOnException = isStopOnException() || 
isStoppedOnFailure(original);
             exception = true;
         }
 
diff --git 
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/Splitter.java
 
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/Splitter.java
index c00f2c13045f..c3ab307b41e1 100644
--- 
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/Splitter.java
+++ 
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/Splitter.java
@@ -596,6 +596,7 @@ public class Splitter extends MulticastProcessor {
 
             // check if we've exceeded the max failed records
             if (maxFailedRecords > 0 && tracker.getFailureCount() >= 
maxFailedRecords) {
+                tracker.markStopped();
                 return false;
             }
             // check if we've exceeded the error ratio threshold
@@ -604,6 +605,7 @@ public class Splitter extends MulticastProcessor {
                 if (processed > 0) {
                     double ratio = (double) tracker.getFailureCount() / 
processed;
                     if (ratio >= errorThreshold) {
+                        tracker.markStopped();
                         return false;
                     }
                 }
@@ -615,11 +617,22 @@ public class Splitter extends MulticastProcessor {
         return true;
     }
 
+    @Override
+    protected boolean isStoppedOnFailure(Exchange original) {
+        // the error threshold of THIS splitter was reached, so keep the 
original exchange as stopOnException does
+        if (errorThreshold <= 0 && maxFailedRecords <= 0) {
+            return false;
+        }
+        SplitFailureTracker tracker = 
original.getProperty(SPLIT_FAILURE_TRACKER, SplitFailureTracker.class);
+        return tracker != null && tracker.isStopped();
+    }
+
     static final class SplitFailureTracker {
         private final AtomicInteger failureCount = new AtomicInteger();
         private final AtomicInteger totalItems = new AtomicInteger();
         private final AtomicInteger processedItems = new AtomicInteger();
         private final CopyOnWriteArrayList<SplitResult.Failure> failures = new 
CopyOnWriteArrayList<>();
+        private volatile boolean stopped;
 
         void recordFailure(int index, Exception exception) {
             failureCount.incrementAndGet();
@@ -646,6 +659,14 @@ public class Splitter extends MulticastProcessor {
             return failureCount.get();
         }
 
+        void markStopped() {
+            stopped = true;
+        }
+
+        boolean isStopped() {
+            return stopped;
+        }
+
         List<SplitResult.Failure> getFailures() {
             return List.copyOf(failures);
         }
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/SplitterThresholdStopKeepsOriginalMessageTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/SplitterThresholdStopKeepsOriginalMessageTest.java
new file mode 100644
index 000000000000..e198d548974b
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/SplitterThresholdStopKeepsOriginalMessageTest.java
@@ -0,0 +1,280 @@
+/*
+ * 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.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+import org.apache.camel.AggregationStrategy;
+import org.apache.camel.CamelExchangeException;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.SplitResult;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * When the Splitter stops because maxFailedRecords or errorThreshold is 
reached, the exchange keeps the original input
+ * message and gets the exception, the same as with stopOnException, instead 
of the message of the failed split item.
+ */
+class SplitterThresholdStopKeepsOriginalMessageTest extends ContextTestSupport 
{
+
+    private final RecordingAggregationStrategy stopStrategy = new 
RecordingAggregationStrategy();
+    private final RecordingAggregationStrategy maxStrategy = new 
RecordingAggregationStrategy();
+
+    private Exchange send(String uri, Object body) {
+        return send(uri, body, false);
+    }
+
+    private Exchange send(String uri, Object body, boolean transacted) {
+        return template.send(uri, e -> {
+            e.getMessage().setBody(body);
+            e.getMessage().setHeader("orig", "yes");
+            e.setProperty("origProp", "yes");
+            e.getExchangeExtension().setTransacted(transacted);
+        });
+    }
+
+    private static void assertOriginalKept(Exchange out, String body) {
+        assertInstanceOf(IllegalStateException.class, 
out.getException().getCause());
+        assertOriginalKept(out, (Object) body);
+    }
+
+    private static void assertOriginalKept(Exchange out, Object body) {
+        assertInstanceOf(CamelExchangeException.class, out.getException());
+        assertEquals(body, out.getMessage().getBody(), "body of the original 
message");
+        assertEquals("yes", out.getMessage().getHeader("orig"));
+        assertNull(out.getMessage().getHeader("item"), "header of the failed 
split item");
+        assertNull(out.getProperty("itemProp"), "property of the failed split 
item");
+        assertNull(out.getVariable("itemVar"), "variable of the failed split 
item");
+        assertEquals("yes", out.getProperty("origProp"), "property removed in 
the failed split item");
+    }
+
+    @Test
+    void stopOnExceptionKeepsOriginal() {
+        assertOriginalKept(send("direct:stop", "a,b,c"), "a,b,c");
+    }
+
+    @Test
+    void maxFailedRecordsKeepsOriginal() {
+        Exchange out = send("direct:max", "a,b,c");
+        assertOriginalKept(out, "a,b,c");
+        assertTrue(out.getProperty(Exchange.SPLIT_RESULT, 
SplitResult.class).isAborted());
+    }
+
+    @Test
+    void maxFailedRecordsAfterToleratedFailureKeepsOriginal() {
+        Exchange out = send("direct:max2", "a,b,c");
+        assertOriginalKept(out, "a,b,c");
+        // the split result still has the index and the exception of each 
failed item
+        SplitResult result = out.getProperty(Exchange.SPLIT_RESULT, 
SplitResult.class);
+        assertTrue(result.isAborted());
+        assertEquals(List.of(0, 1), 
result.getFailures().stream().map(SplitResult.Failure::index).toList());
+        assertInstanceOf(IllegalStateException.class, 
result.getFailures().get(1).exception());
+    }
+
+    @Test
+    void shareUnitOfWorkMaxFailedRecordsKeepsOriginal() {
+        assertOriginalKept(send("direct:share", "a,b,c"), "a,b,c");
+    }
+
+    @Test
+    void nestedSplitKeepsOriginalOfOuterSplit() {
+        // only the outer split has a threshold, the inner split fails the 
outer item
+        List<List<String>> body = List.of(List.of("a", "FAIL"), List.of("b"));
+        Exchange out = send("direct:nested", body);
+        assertOriginalKept(out, body);
+        assertNull(out.getMessage().getHeader("outerItem"), "header of the 
failed outer split item");
+    }
+
+    @Test
+    void nestedSplitsWithThresholdsKeepOriginalOfOuterSplit() {
+        List<List<String>> body = List.of(List.of("a", "FAIL"), List.of("b"));
+        Exchange out = send("direct:nestedBoth", body);
+        assertOriginalKept(out, body);
+        assertNull(out.getMessage().getHeader("outerItem"), "header of the 
failed outer split item");
+    }
+
+    @Test
+    void errorThresholdKeepsOriginal() {
+        Exchange out = send("direct:ratio", "a,b,c");
+        assertOriginalKept(out, "a,b,c");
+        assertTrue(out.getProperty(Exchange.SPLIT_RESULT, 
SplitResult.class).isAborted());
+    }
+
+    @Test
+    void parallelMaxFailedRecordsKeepsOriginal() {
+        assertOriginalKept(send("direct:parallel", "a,b,c"), "a,b,c");
+    }
+
+    @Test
+    void streamingMaxFailedRecordsKeepsOriginal() {
+        assertOriginalKept(send("direct:streaming", "a,b,c"), "a,b,c");
+    }
+
+    @Test
+    void transactedMaxFailedRecordsKeepsOriginal() {
+        // the transacted code path (MulticastTransactedTask)
+        assertOriginalKept(send("direct:max", "a,b,c", true), "a,b,c");
+    }
+
+    @Test
+    void sameExceptionAndPropertiesAsStopOnException() {
+        Exchange stop = send("direct:stop", "a,b,c");
+        Exchange max = send("direct:max", "a,b,c");
+
+        // the same exception, which says which split item failed (and the id 
of its exchange)
+        assertTrue(stop.getException().getMessage().startsWith("Multicast 
processing failed for number 0."));
+        assertTrue(max.getException().getMessage().startsWith("Multicast 
processing failed for number 0."));
+        assertEquals(stop.getException().getClass(), 
max.getException().getClass());
+        assertEquals(stop.getException().getCause().getClass(), 
max.getException().getCause().getClass());
+        assertEquals(stop.getException().getCause().getMessage(), 
max.getException().getCause().getMessage());
+        assertEquals(stop.getExchangeExtension().isRedeliveryExhausted(), 
max.getExchangeExtension().isRedeliveryExhausted());
+        Set<String> stopProps = new TreeSet<>(stop.getProperties().keySet());
+        Set<String> maxProps = new TreeSet<>(max.getProperties().keySet());
+        // only the threshold options set the split result
+        maxProps.remove(Exchange.SPLIT_RESULT);
+        assertEquals(stopProps, maxProps);
+    }
+
+    @Test
+    void doCatchAroundSplitGetsOriginal() throws Exception {
+        MockEndpoint error = getMockEndpoint("mock:error");
+        error.expectedBodiesReceived("a,b,c");
+        error.expectedHeaderReceived("orig", "yes");
+        error.message(0).header("item").isNull();
+
+        send("direct:trycatch", "a,b,c");
+
+        error.assertIsSatisfied();
+    }
+
+    @Test
+    void doCatchAroundStopOnExceptionSplitGetsOriginal() throws Exception {
+        MockEndpoint error = getMockEndpoint("mock:error");
+        error.expectedBodiesReceived("a,b,c");
+        error.expectedHeaderReceived("orig", "yes");
+        error.message(0).header("item").isNull();
+
+        send("direct:trycatchstop", "a,b,c");
+
+        error.assertIsSatisfied();
+    }
+
+    @Test
+    void aggregationStrategySeesTheStopAsWithStopOnException() {
+        Exchange stop = send("direct:aggstop", "ok,FAIL,ok");
+        Exchange max = send("direct:aggmax", "ok,FAIL,ok");
+
+        assertOriginalKept(stop, "ok,FAIL,ok");
+        assertOriginalKept(max, "ok,FAIL,ok");
+        // the item before the failed one is aggregated, the failed one is 
only given to onCompletion
+        assertEquals(List.of("ok"), stopStrategy.aggregated);
+        assertEquals(stopStrategy.aggregated, maxStrategy.aggregated);
+        assertNotNull(stopStrategy.completedWith);
+        assertNotNull(maxStrategy.completedWith);
+        assertInstanceOf(CamelExchangeException.class, 
maxStrategy.completedWith.getException());
+        assertEquals("FAIL", 
maxStrategy.completedWith.getMessage().getHeader("item"));
+        assertEquals(stopStrategy.completedWith.getMessage().getHeader("item"),
+                maxStrategy.completedWith.getMessage().getHeader("item"));
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from("direct:stop").split(body()).stopOnException().to("direct:item").end();
+                
from("direct:max").split(body()).maxFailedRecords(1).to("direct:item").end();
+                
from("direct:max2").split(body()).maxFailedRecords(2).to("direct:item").end();
+                
from("direct:ratio").split(body()).errorThreshold(0.5).to("direct:item").end();
+                
from("direct:parallel").split(body()).parallelProcessing().maxFailedRecords(1).to("direct:item").end();
+                
from("direct:streaming").split(body()).streaming().maxFailedRecords(1).to("direct:item").end();
+                
from("direct:share").split(body()).shareUnitOfWork().maxFailedRecords(1).to("direct:item").end();
+
+                from("direct:nested").split(body()).maxFailedRecords(1)
+                        .setHeader("outerItem", simple("${body}"))
+                        .split(body()).to("direct:failing").end()
+                        .end();
+                from("direct:nestedBoth").split(body()).maxFailedRecords(1)
+                        .setHeader("outerItem", simple("${body}"))
+                        
.split(body()).maxFailedRecords(1).to("direct:failing").end()
+                        .end();
+
+                from("direct:trycatch")
+                        .doTry()
+                            
.split(body()).maxFailedRecords(1).to("direct:item").end()
+                        .endDoTry()
+                        .doCatch(Exception.class)
+                            .to("mock:error")
+                        .end();
+                from("direct:trycatchstop")
+                        .doTry()
+                            
.split(body()).stopOnException().to("direct:item").end()
+                        .endDoTry()
+                        .doCatch(Exception.class)
+                            .to("mock:error")
+                        .end();
+
+                from("direct:aggstop").split(body(), 
stopStrategy).stopOnException().to("direct:failing").end();
+                from("direct:aggmax").split(body(), 
maxStrategy).maxFailedRecords(1).to("direct:failing").end();
+
+                from("direct:item")
+                        .setHeader("item", simple("${body}"))
+                        .setProperty("itemProp", constant("x"))
+                        .removeProperty("origProp")
+                        .setVariable("itemVar", constant("x"))
+                        .setBody(constant("changed"))
+                        .throwException(new IllegalStateException("boom"));
+
+                from("direct:failing")
+                        .setHeader("item", simple("${body}"))
+                        .setProperty("itemProp", constant("x"))
+                        .removeProperty("origProp")
+                        .setVariable("itemVar", constant("x"))
+                        .filter(body().isEqualTo("FAIL"))
+                        .throwException(new IllegalStateException("boom"));
+            }
+        };
+    }
+
+    private static final class RecordingAggregationStrategy implements 
AggregationStrategy {
+
+        private final List<String> aggregated = new ArrayList<>();
+        private volatile Exchange completedWith;
+
+        @Override
+        public Exchange aggregate(Exchange oldExchange, Exchange newExchange) {
+            aggregated.add(newExchange.getMessage().getBody(String.class));
+            return newExchange;
+        }
+
+        @Override
+        public void onCompletion(Exchange exchange, Exchange inputExchange) {
+            completedWith = exchange;
+        }
+    }
+}
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 3dd6687f2d48..eda9a6370f42 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
@@ -272,6 +272,18 @@ An exception thrown by the `isReadyToBeClosed` method of a 
`halfOpenHandler` is
 the policy), and the circuit is kept open and checked again after 
`halfOpenAfter`; before, the timer thread died and the
 circuit stayed open.
 
+=== camel-core - Split EIP keeps the original message when maxFailedRecords or 
errorThreshold stops it
+
+When the Split EIP stops because `maxFailedRecords` or `errorThreshold` is 
reached, the exchange now keeps its original
+input message, headers, exchange properties and variables, and only gets the 
exception, as with `stopOnException`.
+Before, the message, headers, exchange properties and variables of the split 
item that reached the threshold, as the
+steps inside the split had changed them, were copied onto the exchange.
+
+So a `doCatch` around the split, an `onException`, an `onCompletion`, a dead 
letter channel or the caller of the route
+now receive the input that was split, instead of that one split item. A route 
that needs to know which items failed can
+read the `CamelSplitResult` exchange property, set as before: 
`SplitResult.getFailures()` has the index and the
+exception of the failed items.
+
 === camel-main - a failure to start is logged
 
 A Camel Main application that fails to start now logs why, at ERROR: `Error 
starting Camel: ...` with the stack trace,

Reply via email to