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 b1ebadeffa08 CAMEL-26476: camel-core - Split EIP: a nested split
corrupts the processed items of the outer split (#28820)
b1ebadeffa08 is described below
commit b1ebadeffa08d2d93b5951771478cf4b4ca4c4a1
Author: Adriano Machado <[email protected]>
AuthorDate: Mon Nov 2 01:30:10 2026 -0500
CAMEL-26476: camel-core - Split EIP: a nested split corrupts the processed
items of the outer split (#28820)
…essed items of the outer split
Splitter.afterSend read the failure tracker from the properties of the item
exchange, which inherits them, without checking that this splitter has
thresholds. An inner split without thresholds counted its items in the
tracker
of the outer split, and one with thresholds removed that tracker from the
exchange of the outer item. errorThreshold divides by the processed items,
so
the outer split did not stop when it should.
afterSend now gets the original exchange, as shouldContinueOnFailure does,
and
has the same check as the other places that use the tracker.
_Claude Code on behalf of Adriano Machado (@ammachado)_
_This was generated by an AI agent and may contain inaccuracies.
Please verify before relying on it._
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../apache/camel/processor/MulticastProcessor.java | 16 ++++-
.../java/org/apache/camel/processor/Splitter.java | 13 ++--
.../SplitterNestedFailureTrackerLeakTest.java | 73 ++++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 7 +++
4 files changed, 102 insertions(+), 7 deletions(-)
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 f2ede9dc193a..7966de01cc42 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
@@ -680,7 +680,7 @@ public class MulticastProcessor extends BaseProcessorSupport
StopWatch watch = beforeSend(pair);
AsyncCallback taskCallback = (doneSync) -> {
- afterSend(pair, watch);
+ afterSend(pair, watch, original);
// Decide whether to continue with the multicast or
not; similar logic to the Pipeline
// remember to test for stop on exception and
aggregate before copying back results
@@ -813,7 +813,7 @@ public class MulticastProcessor extends BaseProcessorSupport
} catch (Exception e) {
exchange.setException(e);
} finally {
- afterSend(pair, watch);
+ afterSend(pair, watch, original);
}
// Decide whether to continue with the multicast or not; similar
logic to the Pipeline
@@ -921,6 +921,18 @@ public class MulticastProcessor extends
BaseProcessorSupport
return watch;
}
+ /**
+ * Called when an item was sent.
+ *
+ * @param pair the pair of the item
+ * @param watch the watch from {@link
#beforeSend(ProcessorExchangePair)}
+ * @param original the exchange that was split or multicasted. Properties
should be read from this exchange, as an
+ * item can have inherited them from an outer split, or a
nested split can have changed them.
+ */
+ protected void afterSend(ProcessorExchangePair pair, StopWatch watch,
Exchange original) {
+ afterSend(pair, watch);
+ }
+
protected void afterSend(ProcessorExchangePair pair, StopWatch watch) {
// we are done with the exchange pair
pair.done();
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 c3ab307b41e1..b13ffd8aed62 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
@@ -562,11 +562,14 @@ public class Splitter extends MulticastProcessor {
}
@Override
- protected void afterSend(ProcessorExchangePair pair, StopWatch watch) {
- super.afterSend(pair, watch);
- SplitFailureTracker tracker =
pair.getExchange().getProperty(SPLIT_FAILURE_TRACKER,
SplitFailureTracker.class);
- if (tracker != null) {
- tracker.incrementProcessedItems();
+ protected void afterSend(ProcessorExchangePair pair, StopWatch watch,
Exchange original) {
+ super.afterSend(pair, watch, original);
+ // only when THIS splitter has thresholds configured to avoid
corrupting an outer tracker
+ if (errorThreshold > 0 || maxFailedRecords > 0) {
+ SplitFailureTracker tracker =
original.getProperty(SPLIT_FAILURE_TRACKER, SplitFailureTracker.class);
+ if (tracker != null) {
+ tracker.incrementProcessedItems();
+ }
}
}
diff --git
a/core/camel-core/src/test/java/org/apache/camel/processor/SplitterNestedFailureTrackerLeakTest.java
b/core/camel-core/src/test/java/org/apache/camel/processor/SplitterNestedFailureTrackerLeakTest.java
index baf416580b3e..c087263f484e 100644
---
a/core/camel-core/src/test/java/org/apache/camel/processor/SplitterNestedFailureTrackerLeakTest.java
+++
b/core/camel-core/src/test/java/org/apache/camel/processor/SplitterNestedFailureTrackerLeakTest.java
@@ -20,10 +20,12 @@ import java.util.List;
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.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
@@ -77,6 +79,53 @@ class SplitterNestedFailureTrackerLeakTest extends
ContextTestSupport {
"Inner split absorbs its failure within threshold, outer
should succeed");
}
+ @Test
+ void testInnerSplitWithoutThresholdIsNotCountedByOuterSplit() {
+ // the 3 items of each inner split are not items of the outer split
+ Exchange result = template.send("direct:outerMaxFailed",
+ e -> e.getIn().setBody(List.of(List.of("a", "b", "c"),
List.of("d", "e", "f"))));
+
+ SplitResult splitResult = result.getProperty(Exchange.SPLIT_RESULT,
SplitResult.class);
+ assertEquals(2, splitResult.getTotalItems());
+ assertEquals(2, splitResult.getProcessedItems());
+ }
+
+ @Test
+ void testInnerSplitWithThresholdDoesNotHideOuterItems() {
+ // an inner split with its own tracker removes it from the exchange of
the outer item when it completes
+ Exchange result = template.send("direct:bothThresholds",
+ e -> e.getIn().setBody(List.of(List.of("a", "b", "c"),
List.of("d", "e", "f"))));
+
+ SplitResult splitResult = result.getProperty(Exchange.SPLIT_RESULT,
SplitResult.class);
+ assertEquals(2, splitResult.getTotalItems());
+ assertEquals(2, splitResult.getProcessedItems());
+ }
+
+ @Test
+ void testOuterErrorThresholdIsNotDilutedByInnerItems() throws Exception {
+ // 1 failure of 2 outer items reaches the threshold of 0.5, so the
third outer item must not run. Counting the
+ // 9 items of the inner split would make that 1 of 11, and the outer
split would continue
+ MockEndpoint mock = getMockEndpoint("mock:outerRatio");
+ mock.expectedMessageCount(9);
+
+ template.send("direct:outerRatio", e -> e.getIn().setBody(List.of(
+ List.of("1", "2", "3", "4", "5", "6", "7", "8", "9"),
List.of("FAIL_INNER"), List.of("not reached"))));
+
+ mock.assertIsSatisfied();
+ }
+
+ @Test
+ void testOuterErrorThresholdWorksWithInnerThreshold() throws Exception {
+ // without processed items the ratio is never computed, so the outer
split would never stop
+ MockEndpoint mock = getMockEndpoint("mock:outerRatioInnerThreshold");
+ mock.expectedMessageCount(1);
+
+ template.send("direct:outerRatioInnerThreshold", e ->
e.getIn().setBody(List.of(
+ List.of("1"), List.of("FAIL_INNER"), List.of("not reached"))));
+
+ mock.assertIsSatisfied();
+ }
+
@Override
protected RouteBuilder createRouteBuilder() {
return new RouteBuilder() {
@@ -96,6 +145,30 @@ class SplitterNestedFailureTrackerLeakTest extends
ContextTestSupport {
.end()
.end();
+ from("direct:outerRatio")
+ .split(body()).errorThreshold(0.5)
+ .split(body())
+ .process(exchange -> {
+ if
("FAIL_INNER".equals(exchange.getIn().getBody(String.class))) {
+ throw new IllegalArgumentException("Inner
failure");
+ }
+ })
+ .to("mock:outerRatio")
+ .end()
+ .end();
+
+ from("direct:outerRatioInnerThreshold")
+ .split(body()).errorThreshold(0.5)
+ .split(body()).maxFailedRecords(1)
+ .process(exchange -> {
+ if
("FAIL_INNER".equals(exchange.getIn().getBody(String.class))) {
+ throw new IllegalArgumentException("Inner
failure");
+ }
+ })
+ .to("mock:outerRatioInnerThreshold")
+ .end()
+ .end();
+
// Both outer and inner with maxFailedRecords
from("direct:bothThresholds")
.split(body()).maxFailedRecords(1)
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 b50ad720d318..a90914d81fb9 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
@@ -107,6 +107,13 @@ at most once per millisecond, so that the JMX statistics
no longer generate an e
several exchanges complete within the same millisecond, the attribute has the
id of the first one, not of the last one.
Failed exchanges are not affected: `LastExchangeFailureExchangeId` is still
updated for every failed exchange.
+=== camel-core - a nested split no longer changes the processed items of the
outer split
+
+With `errorThreshold` or `maxFailedRecords` on a split that contains another
split, the inner split changed the number of
+processed items of the outer split. It counted its own items when it had no
thresholds, and hid the items of the outer
+split when it had them. `SplitResult.getProcessedItems()` now reports the
items of that split only. A nested split with
+`errorThreshold` can now stop earlier than before, as the ratio is computed
from the correct number.
+
=== camel-core - RedeliveryErrorHandler.defensiveCopyExchangeIfNeeded is
deprecated
When redelivery is enabled, the error handler (`DefaultErrorHandler`,
`DeadLetterChannel` and the transaction error