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 7dad7adffd26 CAMEL-26710: camel-core - Split EIP: do not hang when the
watermark cannot be evaluated or stored (#29406)
7dad7adffd26 is described below
commit 7dad7adffd26874bce01d3b2b7fa35b9e2ed81d7
Author: allthingssecurity <[email protected]>
AuthorDate: Sun Nov 8 16:31:21 2026 +0530
CAMEL-26710: camel-core - Split EIP: do not hang when the watermark cannot
be evaluated or stored (#29406)
* CAMEL-26710: camel-core - Split EIP: do not hang when the watermark
cannot be evaluated or stored
* CAMEL-26710: camel-core - Split EIP: upgrade guide entry for a watermark
that cannot be stored
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 +-
.../java/org/apache/camel/processor/Splitter.java | 31 +++--
.../SplitterWatermarkStoreFailureTest.java | 145 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 10 ++
5 files changed, 182 insertions(+), 10 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 43bc9ec864be..755fe4090c0d 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
@@ -1433,7 +1433,8 @@ NOTE: The `watermarkExpression` option uses the Simple
language for expression e
NOTE: The watermark is only updated when the split completes successfully. If
the split is aborted
(e.g., due to exceeding `maxFailedRecords`), the watermark is not updated,
which allows the
-failed batch to be retried.
+failed batch to be retried. The same applies when the `watermarkExpression`
fails, or when the resume
+strategy fails to store the watermark: the split fails with that exception,
and the items are processed again.
IMPORTANT: Watermark tracking assumes sequential route invocations (e.g.,
batch jobs triggered by
a `timer` or `scheduler`). If multiple exchanges hit the same route
concurrently, they will read
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 43bc9ec864be..755fe4090c0d 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
@@ -1433,7 +1433,8 @@ NOTE: The `watermarkExpression` option uses the Simple
language for expression e
NOTE: The watermark is only updated when the split completes successfully. If
the split is aborted
(e.g., due to exceeding `maxFailedRecords`), the watermark is not updated,
which allows the
-failed batch to be retried.
+failed batch to be retried. The same applies when the `watermarkExpression`
fails, or when the resume
+strategy fails to store the watermark: the split fails with that exception,
and the items are processed again.
IMPORTANT: Watermark tracking assumes sequential route invocations (e.g.,
batch jobs triggered by
a `timer` or `scheduler`). If multiple exchanges hit the same route
concurrently, they will read
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 8cc7a7e896e2..0e2fb025bd47 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
@@ -234,13 +234,20 @@ public class Splitter extends MulticastProcessor {
return callback;
}
return doneSync -> {
- if (hasErrorThreshold) {
- buildSplitResult(exchange);
- }
- if (hasWatermark) {
- updateWatermark(exchange);
+ try {
+ if (hasErrorThreshold) {
+ buildSplitResult(exchange);
+ }
+ if (hasWatermark) {
+ updateWatermark(exchange);
+ }
+ } catch (Throwable e) {
+ // for example the resume strategy failed to store the
watermark
+ exchange.setException(e);
+ } finally {
+ // the callback must always be called, otherwise the exchange
never completes
+ callback.done(doneSync);
}
- callback.done(doneSync);
};
}
@@ -727,7 +734,14 @@ public class Splitter extends MulticastProcessor {
// evaluate watermark expression on completed sub-exchange (only
if no exception)
Exchange subExchange = delegate.getExchange();
if (subExchange.getException() == null) {
- String value = watermarkExpression.evaluate(subExchange,
String.class);
+ String value;
+ try {
+ value = watermarkExpression.evaluate(subExchange,
String.class);
+ } catch (Exception e) {
+ // fail the item, as an exception thrown here would make
the split exchange hang (never complete)
+ subExchange.setException(e);
+ return;
+ }
if (value != null) {
int itemIndex = delegate.getIndex();
// Use accumulateAndGet to keep the value from the
highest-indexed item.
@@ -780,12 +794,13 @@ public class Splitter extends MulticastProcessor {
}
private void persistWatermark(String value) {
- watermarkCache.put(watermarkKey, value);
try {
resumeStrategy.updateLastOffset(OffsetKeys.of(watermarkKey),
Offsets.of(value));
} catch (Exception e) {
throw RuntimeCamelException.wrapRuntimeCamelException(e);
}
+ // only cache the watermark once it is stored, so a failed exchange is
processed again
+ watermarkCache.put(watermarkKey, value);
}
private void buildSplitResult(Exchange exchange) {
diff --git
a/core/camel-core/src/test/java/org/apache/camel/processor/SplitterWatermarkStoreFailureTest.java
b/core/camel-core/src/test/java/org/apache/camel/processor/SplitterWatermarkStoreFailureTest.java
new file mode 100644
index 000000000000..4bfa265e4a96
--- /dev/null
+++
b/core/camel-core/src/test/java/org/apache/camel/processor/SplitterWatermarkStoreFailureTest.java
@@ -0,0 +1,145 @@
+/*
+ * 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.Arrays;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.RuntimeExpressionException;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.resume.Offset;
+import org.apache.camel.resume.OffsetKey;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * The split must complete, with the exception set, when the watermark cannot
be evaluated or stored.
+ */
+class SplitterWatermarkStoreFailureTest extends ContextTestSupport {
+
+ private final Map<String, String> store = new ConcurrentHashMap<>();
+ private final FailingResumeStrategy strategy = new
FailingResumeStrategy(store);
+
+ @Test
+ void testSplitCompletesWhenStoringTheWatermarkFails() throws Exception {
+ strategy.failures.set(1);
+ getMockEndpoint("mock:split").expectedMessageCount(3);
+
+ Exchange out = template.asyncSend("direct:start", e ->
e.getIn().setBody(Arrays.asList("a", "b", "c")))
+ .get(5, TimeUnit.SECONDS);
+
+ MockEndpoint.assertIsSatisfied(context);
+ assertNotNull(out.getException());
+ assertInstanceOf(IllegalStateException.class,
out.getException().getCause());
+ assertFalse(store.containsKey("failJob"));
+ }
+
+ @Test
+ void testFailedWatermarkIsNotUsedByTheNextSplit() throws Exception {
+ strategy.failures.set(1);
+ Exchange out = template.asyncSend("direct:start", e ->
e.getIn().setBody(Arrays.asList("a", "b", "c")))
+ .get(5, TimeUnit.SECONDS);
+ assertNotNull(out.getException());
+
+ // the store works again: the items whose watermark was not stored are
processed again
+ MockEndpoint mock = getMockEndpoint("mock:split");
+ mock.reset();
+ mock.expectedBodiesReceived("a", "b", "c");
+
+ out = template.asyncSend("direct:start", e ->
e.getIn().setBody(Arrays.asList("a", "b", "c")))
+ .get(5, TimeUnit.SECONDS);
+
+ mock.assertIsSatisfied();
+ assertNull(out.getException());
+ assertEquals("2", store.get("failJob"));
+ }
+
+ @Test
+ void testRedeliveryProcessesTheItemsAgainWhenStoringTheWatermarkFails()
throws Exception {
+ strategy.failures.set(1);
+ getMockEndpoint("mock:redeliver").expectedBodiesReceived("a", "b",
"c", "a", "b", "c");
+
+ Exchange out = template.asyncSend("direct:redeliver", e ->
e.getIn().setBody(Arrays.asList("a", "b", "c")))
+ .get(5, TimeUnit.SECONDS);
+
+ MockEndpoint.assertIsSatisfied(context);
+ assertNull(out.getException());
+ assertEquals("2", store.get("redeliverJob"));
+ }
+
+ @Test
+ void testSplitCompletesWhenTheWatermarkExpressionFails() throws Exception {
+ getMockEndpoint("mock:value").expectedMessageCount(2);
+
+ Exchange out = template.asyncSend("direct:value", e ->
e.getIn().setBody(Arrays.asList("a", "b")))
+ .get(5, TimeUnit.SECONDS);
+
+ MockEndpoint.assertIsSatisfied(context);
+ assertInstanceOf(RuntimeExpressionException.class, out.getException());
+ assertFalse(store.containsKey("valueJob"));
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:start")
+ .split(body()).resumeStrategy(strategy, "failJob")
+ .to("mock:split");
+
+ from("direct:redeliver")
+
.errorHandler(defaultErrorHandler().maximumRedeliveries(1).redeliveryDelay(0))
+ .split(body()).resumeStrategy(strategy, "redeliverJob")
+ .to("mock:redeliver");
+
+ // the watermark expression fails on every item
+ from("direct:value")
+ .split(body()).resumeStrategy(strategy,
"valueJob").watermarkExpression("${body.noSuchMethod()}")
+ .to("mock:value");
+ }
+ };
+ }
+
+ private static class FailingResumeStrategy extends
SplitterTestResumeStrategy {
+
+ private final AtomicInteger failures = new AtomicInteger();
+
+ FailingResumeStrategy(Map<String, String> store) {
+ super(store);
+ }
+
+ @Override
+ public void updateLastOffset(OffsetKey<?> offsetKey, Offset<?>
offsetValue) {
+ if (failures.getAndDecrement() > 0) {
+ throw new IllegalStateException("Cannot store the watermark");
+ }
+ super.updateLastOffset(offsetKey, offsetValue);
+ }
+ }
+}
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 790e3865ee8b..084e3f22476b 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
@@ -669,6 +669,16 @@ now receive the input that was split, instead of that one
split item. A route th
read the `CamelSplitResult` exchange property, set as before:
`SplitResult.getFailures()` has the index and the
exception of the failed items.
+=== camel-core - Split EIP processes the items again when the watermark cannot
be stored
+
+When the resume strategy of a split with watermark tracking fails to store the
watermark, the split now fails with that
+exception, and the next split processes the same items again. Before, with
`synchronous()`, the split also failed, but
+the watermark was kept in memory anyway, so the next split skipped those items
although the watermark was never stored.
+Without `synchronous()`, the exchange never completed (fixed by the same
change).
+
+A `watermarkExpression` that throws on an item now fails that item, so
`stopOnException`, `maxFailedRecords` and
+`errorThreshold` apply as for any failed item.
+
=== camel-core - shareUnitOfWork: a part sent to seda no longer takes the on
completions of the parent exchange
Since Camel 4.14 the parts of a Split, Multicast or Recipient List with
`shareUnitOfWork` use the unit of work of the