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 e373d74b8ccf CAMEL-26445: camel-core - RedeliveryErrorHandler: an
AsyncProcessor that throws completes the exchange instead of hanging when the
error handler has no redelivery (#28808)
e373d74b8ccf is described below
commit e373d74b8ccff3e230d25dcd19f3c22c57d23515
Author: allthingssecurity <[email protected]>
AuthorDate: Mon Nov 2 19:57:06 2026 +0530
CAMEL-26445: camel-core - RedeliveryErrorHandler: an AsyncProcessor that
throws completes the exchange instead of hanging when the error handler has no
redelivery (#28808)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../errorhandler/RedeliveryErrorHandler.java | 98 +++++-
.../SimpleTaskProcessorThrowsPooledTest.java | 38 +++
.../SimpleTaskProcessorThrowsTest.java | 341 +++++++++++++++++++++
.../SimpleTaskProcessorThrowsTestSupport.java | 199 ++++++++++++
4 files changed, 674 insertions(+), 2 deletions(-)
diff --git
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/errorhandler/RedeliveryErrorHandler.java
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/errorhandler/RedeliveryErrorHandler.java
index bdab8527ecf0..9f7f7792b095 100644
---
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/errorhandler/RedeliveryErrorHandler.java
+++
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/errorhandler/RedeliveryErrorHandler.java
@@ -24,6 +24,7 @@ import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicIntegerFieldUpdater;
import org.apache.camel.AsyncCallback;
import org.apache.camel.AsyncProcessor;
@@ -459,9 +460,20 @@ public abstract class RedeliveryErrorHandler extends
ErrorHandlerSupport
* Simple task to perform calling the processor with no redelivery support
*/
protected class SimpleTask implements PooledExchangeTask, AsyncCallback {
+ private static final AtomicIntegerFieldUpdater<SimpleTask> AWAITED
+ = AtomicIntegerFieldUpdater.newUpdater(SimpleTask.class,
"awaited");
+
private Exchange exchange;
private AsyncCallback callback;
private boolean first;
+ // the number of the call of the processor whose callback is still to
come, or 0 when none. The callback (done)
+ // and the takeover of a processor that throws before it calls the
callback compete for it (compareAndSet), so
+ // the exchange is continued exactly once. A field, not an object per
call, as this task runs nearly every step
+ private volatile int awaited;
+ // numbers the calls of the processor by this task (it is reused when
tasks are pooled)
+ private int calls;
+ // the exchange whose failure this task took over (it is then not used
again), for the log of a late callback
+ private volatile String takenOverExchangeId;
public SimpleTask() {
}
@@ -480,7 +492,9 @@ public abstract class RedeliveryErrorHandler extends
ErrorHandlerSupport
public void reset() {
this.exchange = null;
this.callback = null;
- this.first = true;
+ // not first: a callback that a processor calls again after the
task was released must not start the task
+ // (prepare sets it back when the task is used again)
+ this.first = false;
}
@Override
@@ -494,6 +508,30 @@ public abstract class RedeliveryErrorHandler extends
ErrorHandlerSupport
*/
@Override
public void run() {
+ if (!first) {
+ // this task is the callback of the processor: called by done,
or by run when the callback is scheduled on
+ // the reactive executor
+ final int call = awaited;
+ if (call == 0 || !AWAITED.compareAndSet(this, call, 0)) {
+ final String takenOver = takenOverExchangeId;
+ if (takenOver != null) {
+ // the processor threw before it called this callback,
and the error handler took over that failure
+ LOG.warn("The callback of the processor of exchangeId:
{} was called after the processor had thrown"
+ + " an exception, which the error handler
handled instead. This callback is ignored.",
+ takenOver);
+ } else {
+ // the processor called its callback a second time, or
after it had thrown while the takeover is
+ // under way (a task used again meanwhile cannot be
told apart)
+ LOG.warn("The callback of a processor was called when
it was no longer awaited (more than once, or"
+ + " after the processor had thrown an
exception). This callback is ignored.");
+ }
+ return;
+ }
+ }
+ doRun();
+ }
+
+ private void doRun() {
// can we still run
if (shutdownStrategy.isForceShutdown() || isStoppingOrStopped()) {
runNotAllowed();
@@ -534,7 +572,63 @@ public abstract class RedeliveryErrorHandler extends
ErrorHandlerSupport
private void handleFirst() {
first = false;
- processOutput(exchange, this);
+ // read while this task still owns them: once the callback is
called, the task may be released and reused
+ final Exchange processed = exchange;
+ final AsyncCallback cb = callback;
+ int call = ++calls;
+ if (call == 0) {
+ call = ++calls;
+ }
+ // an ordered write is enough: the callback reaches another thread
only through a hand-off that publishes it
+ AWAITED.lazySet(this, call);
+ try {
+ processOutput(processed, this);
+ } catch (Throwable e) {
+ // a Throwable, not only an Exception: an Error (such as
NoClassDefFoundError or StackOverflowError) thrown
+ // before the callback is called would also leave the exchange
hanging, as the reactive executor catches
+ // only exceptions
+ if (awaited != call) {
+ // the callback was called before the exception was
thrown, and it continued the exchange (this task
+ // may already be released), so leave the exception to the
reactive executor, as before (a precise
+ // rethrow: only unchecked exceptions and errors reach
here)
+ throw e;
+ }
+ // the processor threw before it called the callback, such as
an AsyncProcessor that throws from
+ // process(exchange, callback) instead of setting the
exception on the exchange. The reactive executor
+ // would drop the exception, and the exchange would never
complete. Take over through the reactive
+ // executor, after the work already queued there, so a
callback that the processor handed on and that this
+ // queued work calls (such as the reply of a route behind a
direct producer whose steps each schedule
+ // the next one ahead of the takeover) still comes first and
continues the exchange, as before. A callback
+ // that only work queued after the takeover calls loses to it
+ final int thrownCall = call;
+ final String exchangeId = processed.getExchangeId();
+ reactiveExecutor.schedule(() ->
takeOverThrownFailure(thrownCall, processed, exchangeId, cb, e));
+ }
+ }
+
+ private void takeOverThrownFailure(
+ int call, Exchange processed, String exchangeId, AsyncCallback
cb, Throwable cause) {
+ if (AWAITED.compareAndSet(this, call, 0)) {
+ // a failure of the processor, so continue as when the
processor sets the exception on the exchange and
+ // calls the callback. Continue in a new task and do not
release this one: the processor may still call
+ // its callback (this task), which must find it with no call
awaited and ignore it, instead of
+ // continuing an exchange that reuses this task
+ processed.setException(cause);
+ takenOverExchangeId = exchangeId;
+ // this task keeps no reference to the exchange, which a
broken processor may hold on to with its callback
+ exchange = null;
+ callback = null;
+ // acquire it from the task factory, which also releases it,
so the pooled statistics stay consistent
+ SimpleTask task = (SimpleTask) taskFactory.acquire(processed,
cb);
+ task.first = false;
+ task.doRun();
+ } else {
+ // the queued callback continued the exchange (which may
already be done and released), so this
+ // exception is only logged, as before
+ LOG.warn(
+ "Error processing exchangeId: {} in processor: {} due
to {}. The exchange continued, so this exception is ignored.",
+ exchangeId, outputAsync, cause.getMessage(), cause);
+ }
}
/**
diff --git
a/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/SimpleTaskProcessorThrowsPooledTest.java
b/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/SimpleTaskProcessorThrowsPooledTest.java
new file mode 100644
index 000000000000..84f5d3b43a4a
--- /dev/null
+++
b/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/SimpleTaskProcessorThrowsPooledTest.java
@@ -0,0 +1,38 @@
+/*
+ * 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.errorhandler;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.ExtendedCamelContext;
+import org.apache.camel.impl.engine.PooledExchangeFactory;
+import org.apache.camel.impl.engine.PooledProcessorExchangeFactory;
+
+/**
+ * The tests of {@link SimpleTaskProcessorThrowsTestSupport} with pooled
exchanges, so the error handler uses pooled
+ * simple tasks, which are reset and reused when they are released.
+ */
+class SimpleTaskProcessorThrowsPooledTest extends
SimpleTaskProcessorThrowsTestSupport {
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext camelContext = super.createCamelContext();
+ ExtendedCamelContext ecc = camelContext.getCamelContextExtension();
+ ecc.setExchangeFactory(new PooledExchangeFactory());
+ ecc.setProcessorExchangeFactory(new PooledProcessorExchangeFactory());
+ return camelContext;
+ }
+}
diff --git
a/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/SimpleTaskProcessorThrowsTest.java
b/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/SimpleTaskProcessorThrowsTest.java
new file mode 100644
index 000000000000..54121539afc4
--- /dev/null
+++
b/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/SimpleTaskProcessorThrowsTest.java
@@ -0,0 +1,341 @@
+/*
+ * 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.errorhandler;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+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.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.camel.AsyncCallback;
+import org.apache.camel.AsyncProcessor;
+import org.apache.camel.AsyncProducer;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.support.AsyncProcessorSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.awaitility.Awaitility.await;
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * An {@link AsyncProcessor} that throws from {@code process(exchange,
callback)}, instead of setting the exception on
+ * the exchange, in a route whose error handler has no redelivery, no
exception policies and no dead letter channel (so
+ * it runs the step with its simple task): the exchange must complete with the
thrown exception instead of hanging. Each
+ * send is bounded, so a hang fails the test.
+ */
+class SimpleTaskProcessorThrowsTest extends
SimpleTaskProcessorThrowsTestSupport {
+
+ /**
+ * Throws on the first call, and succeeds on the later calls.
+ */
+ private final class ThrowsFirst extends AsyncProcessorSupport {
+ @Override
+ public boolean process(Exchange exchange, AsyncCallback callback) {
+ if (calls.incrementAndGet() == 1) {
+ throw new IllegalStateException("thrown");
+ }
+ callback.done(true);
+ return true;
+ }
+ }
+
+ @Test
+ void defaultErrorHandler() throws Exception {
+ start(new ThrowsFirst());
+ assertFailedWithTheThrownException(send());
+ }
+
+ @Test
+ void defaultErrorHandlerProcessorThrowsError() throws Exception {
+ // an Error, not an Exception: the reactive executor does not catch it
either, so the exchange would hang
+ start(new AsyncProcessorSupport() {
+ @Override
+ public boolean process(Exchange exchange, AsyncCallback callback) {
+ calls.incrementAndGet();
+ throw new NoClassDefFoundError("thrown");
+ }
+ });
+ Exchange out = send();
+ // the exchange wraps a Throwable that is not an Exception in a
CamelExecutionException
+ assertInstanceOf(NoClassDefFoundError.class,
out.getException().getCause());
+ assertEquals("thrown", out.getException().getCause().getMessage());
+ assertEquals(1, calls.get(), "calls");
+ assertEquals(0, getMockEndpoint("mock:result").getReceivedCounter(),
"mock:result");
+ }
+
+ @Test
+ void afterAnAsynchronousStep() throws Exception {
+ // the step runs on the thread of the asynchronous delay, not on the
caller's
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:start").delay(10).asyncDelayed().process(new
ThrowsFirst()).to("mock:result");
+ }
+ });
+ context.start();
+ assertFailedWithTheThrownException(send());
+ }
+
+ @Test
+ void transacted() throws Exception {
+ // a transacted exchange runs on the reactive executor's queue
+ start(new ThrowsFirst());
+ assertFailedWithTheThrownException(send(true));
+ }
+
+ @Test
+ void calledRouteFailsLikeAPlainProcessorThatThrows() throws Exception {
+ // the called routes have no exception policy (simple task), their
caller has one: the AsyncProcessor that
+ // throws must fail the called route, and so its caller, exactly as a
plain Processor that throws does
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:async").process(new
ThrowsFirst()).to("mock:result");
+ from("direct:plain").process(exchange -> {
+ throw new IllegalStateException("thrown");
+ }).to("mock:result");
+ }
+ });
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+
onException(IllegalStateException.class).handled(true).to("mock:handled");
+ from("direct:callAsync").to("direct:async").to("mock:after");
+ from("direct:callPlain").to("direct:plain").to("mock:after");
+ }
+ });
+ context.start();
+
+ Exchange plain = asyncSend("direct:callPlain", false).get(10,
TimeUnit.SECONDS);
+ int handledPlain =
getMockEndpoint("mock:handled").getReceivedCounter();
+ Exchange async = asyncSend("direct:callAsync", false).get(10,
TimeUnit.SECONDS);
+ int handledAsync =
getMockEndpoint("mock:handled").getReceivedCounter() - handledPlain;
+
+ assertEquals(String.valueOf(plain.getException()),
String.valueOf(async.getException()), "exception");
+ assertEquals(handledPlain, handledAsync, "mock:handled");
+ // the error handler of the called route gives the failure back as
exhausted, so the caller does not handle it
+ assertInstanceOf(IllegalStateException.class, async.getException());
+ assertEquals(0, handledAsync, "mock:handled");
+ assertEquals(0, getMockEndpoint("mock:after").getReceivedCounter(),
"mock:after");
+ assertEquals(0, getMockEndpoint("mock:result").getReceivedCounter(),
"mock:result");
+ assertEquals(1, calls.get(), "calls");
+ }
+
+ @Test
+ void nextExchangeIsProcessed() throws Exception {
+ start(new ThrowsFirst());
+ assertFailedWithTheThrownException(send());
+
+ Exchange out = send();
+ assertNull(out.getException());
+ assertEquals(1, getMockEndpoint("mock:result").getReceivedCounter(),
"mock:result");
+ assertEquals(2, calls.get(), "calls");
+ }
+
+ @Test
+ void controlPlainProcessorThrows() throws Exception {
+ // control, as before: the converter of a plain Processor to an
AsyncProcessor catches the exception and sets it
+ // on the exchange
+ start(exchange -> {
+ calls.incrementAndGet();
+ throw new IllegalStateException("thrown");
+ });
+ assertFailedWithTheThrownException(send());
+ }
+
+ @Test
+ void controlCallbackCalledBeforeTheProcessorThrows() throws Exception {
+ // control, as before: the processor calls the callback and then
throws. The callback continued the exchange, so
+ // the exception is left to the reactive executor (which logs it), and
the exchange completes once
+ MockEndpoint result = getMockEndpoint("mock:result");
+ result.expectedMessageCount(1);
+ start(new AsyncProcessorSupport() {
+ @Override
+ public boolean process(Exchange exchange, AsyncCallback callback) {
+ calls.incrementAndGet();
+ callback.done(true);
+ throw new IllegalStateException("after the callback");
+ }
+ });
+ AtomicInteger done = countDoneExchanges();
+
+ Exchange out = sendOnThisThread();
+ assertNull(out.getException());
+ result.assertIsSatisfied();
+ assertEquals(1, result.getReceivedCounter(), "mock:result");
+ assertEquals(1, done.get(), "the exchange was done once");
+ assertEquals(1, calls.get(), "calls");
+ }
+
+ @Test
+ void controlCallbackQueuedBeforeTheProcessorThrows() throws Exception {
+ // control, as before: the processor queues the call of the callback
on the reactive executor and then throws.
+ // The error handler takes over the thrown failure through the
reactive executor, after the work already queued
+ // there, so the queued callback comes first and continues the exchange
+ start(new AsyncProcessorSupport() {
+ @Override
+ public boolean process(Exchange exchange, AsyncCallback callback) {
+ calls.incrementAndGet();
+
exchange.getContext().getCamelContextExtension().getReactiveExecutor().schedule(()
-> {
+ exchange.getMessage().setBody("queued");
+ callback.done(false);
+ });
+ throw new IllegalStateException("after the callback was
queued");
+ }
+ });
+ AtomicInteger done = countDoneExchanges();
+
+ Exchange out = sendOnThisThread();
+ assertNull(out.getException());
+ assertEquals("queued", out.getMessage().getBody());
+ assertEquals(1, getMockEndpoint("mock:result").getReceivedCounter(),
"mock:result");
+ assertEquals(1, done.get(), "the exchange was done once");
+ assertEquals(1, calls.get(), "calls");
+ }
+
+ @Test
+ void controlCallbackOfARouteBeforeTheProcessorThrows() throws Exception {
+ // control, as before: the processor hands the exchange and the
callback to a direct route (with the default
+ // error handler) and then throws. Each step of that route runs ahead
of the takeover queued on the reactive
+ // executor, and the route schedules the callback (this task) there,
so the route continues the exchange: the
+ // exchange completes with the reply of that route, once. (A route
with noErrorHandler() whose later steps are
+ // queued only after the takeover loses to it, as any callback called
by work queued after the takeover.)
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+
from("direct:inner").setBody(constant("inner")).setHeader("inner",
constant("yes"));
+ }
+ });
+ AtomicReference<AsyncProducer> producer = new AtomicReference<>();
+ start(new AsyncProcessorSupport() {
+ @Override
+ public boolean process(Exchange exchange, AsyncCallback callback) {
+ calls.incrementAndGet();
+ producer.get().process(exchange, callback);
+ throw new IllegalStateException("after the callback was handed
to a route");
+ }
+ });
+ AsyncProducer direct =
context.getEndpoint("direct:inner").createAsyncProducer();
+ direct.start();
+ producer.set(direct);
+ AtomicInteger done = countDoneExchanges();
+ try {
+ Exchange out = sendOnThisThread();
+ assertNull(out.getException());
+ assertEquals("inner", out.getMessage().getBody());
+ assertEquals("yes", out.getMessage().getHeader("inner"));
+ assertEquals(1,
getMockEndpoint("mock:result").getReceivedCounter(), "mock:result");
+ assertEquals(1, done.get(), "the exchange was done once");
+ assertEquals(1, calls.get(), "calls");
+ } finally {
+ direct.stop();
+ }
+ }
+
+ @Test
+ void callbackCalledAfterTheProcessorThrewIsIgnored() throws Exception {
+ // the processor hands the callback to another thread and then throws.
The error handler cannot tell this from a
+ // callback that never comes, so it takes over the thrown failure
(before, the exchange waited for the callback),
+ // and the late callback must not continue the exchange a second time
+ CountDownLatch release = new CountDownLatch(1);
+ AtomicReference<Thread> late = new AtomicReference<>();
+ AtomicReference<Throwable> lateFailure = new AtomicReference<>();
+ AtomicInteger done = countDoneExchanges();
+ start(new AsyncProcessorSupport() {
+ @Override
+ public boolean process(Exchange exchange, AsyncCallback callback) {
+ calls.incrementAndGet();
+ Thread thread = new Thread(() -> {
+ try {
+ if (release.await(10, TimeUnit.SECONDS)) {
+ callback.done(false);
+ }
+ } catch (Throwable t) {
+ lateFailure.set(t);
+ }
+ }, "late-callback");
+ late.set(thread);
+ thread.start();
+ throw new IllegalStateException("thrown");
+ }
+ });
+
+ assertFailedWithTheThrownException(send());
+
+ // now the processor calls the callback, after the exchange completed
+ release.countDown();
+ late.get().join(TimeUnit.SECONDS.toMillis(10));
+ assertFalse(late.get().isAlive(), "the late callback thread ended");
+ assertNull(lateFailure.get(), "the late callback failed");
+ assertEquals(1, done.get(), "the exchange was done once");
+ assertEquals(0, getMockEndpoint("mock:result").getReceivedCounter(),
"mock:result");
+ }
+
+ @Test
+ void callbackAndThrowAtTheSameTimeContinueEachExchangeOnce() throws
Exception {
+ // the processor hands the callback to another thread, which calls it
at once, and throws: the callback and the
+ // takeover race, and whichever wins, each exchange is continued once
(as a success or with the thrown failure)
+ int count = 50;
+ ExecutorService callbacks = Executors.newFixedThreadPool(2);
+ try {
+ start(new AsyncProcessorSupport() {
+ @Override
+ public boolean process(Exchange exchange, AsyncCallback
callback) {
+ calls.incrementAndGet();
+ callbacks.execute(() -> callback.done(false));
+ throw new IllegalStateException("thrown");
+ }
+ });
+ AtomicInteger done = countDoneExchanges();
+
+ List<CompletableFuture<Exchange>> sends = new ArrayList<>();
+ for (int i = 0; i < count; i++) {
+ sends.add(asyncSend("direct:start", false));
+ }
+ int succeeded = 0;
+ for (CompletableFuture<Exchange> send : sends) {
+ Exchange out = send.get(20, TimeUnit.SECONDS);
+ if (out.getException() == null) {
+ succeeded++;
+ } else {
+ assertEquals("thrown", out.getException().getMessage());
+ }
+ }
+ await().atMost(10, TimeUnit.SECONDS).until(() -> done.get() >=
count);
+ // the callbacks that lost have run too
+ callbacks.shutdown();
+ assertTrue(callbacks.awaitTermination(10, TimeUnit.SECONDS), "the
callbacks ran");
+
+ assertEquals(count, done.get(), "each exchange was done once");
+ assertEquals(succeeded,
getMockEndpoint("mock:result").getReceivedCounter(), "mock:result");
+ assertEquals(count, calls.get(), "calls");
+ } finally {
+ callbacks.shutdownNow();
+ }
+ }
+}
diff --git
a/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/SimpleTaskProcessorThrowsTestSupport.java
b/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/SimpleTaskProcessorThrowsTestSupport.java
new file mode 100644
index 000000000000..c92859b27dc5
--- /dev/null
+++
b/core/camel-core/src/test/java/org/apache/camel/processor/errorhandler/SimpleTaskProcessorThrowsTestSupport.java
@@ -0,0 +1,199 @@
+/*
+ * 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.errorhandler;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.camel.AsyncCallback;
+import org.apache.camel.AsyncProcessor;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.Processor;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.spi.CamelEvent;
+import org.apache.camel.spi.CamelEvent.ExchangeCompletedEvent;
+import org.apache.camel.spi.CamelEvent.ExchangeFailedEvent;
+import org.apache.camel.support.AsyncProcessorSupport;
+import org.apache.camel.support.EventNotifierSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.awaitility.Awaitility.await;
+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.assertNull;
+
+/**
+ * Shared by {@link SimpleTaskProcessorThrowsTest} and {@link
SimpleTaskProcessorThrowsPooledTest}: an
+ * {@link AsyncProcessor} that throws from {@code process(exchange,
callback)}, instead of setting the exception on the
+ * exchange, in a route whose error handler has no redelivery, no exception
policies and no dead letter channel (so it
+ * runs the step with its simple task). The tests here depend on whether the
simple tasks are pooled, so they run with
+ * and without pooling. Each send is bounded, so a hang fails the test.
+ */
+abstract class SimpleTaskProcessorThrowsTestSupport extends ContextTestSupport
{
+
+ protected final AtomicInteger calls = new AtomicInteger();
+
+ @Override
+ public boolean isUseRouteBuilder() {
+ return false;
+ }
+
+ protected void start(Processor processor) throws Exception {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:start").process(processor).to("mock:result");
+ }
+ });
+ context.start();
+ }
+
+ protected Exchange send() throws Exception {
+ return send(false);
+ }
+
+ protected Exchange send(boolean transacted) throws Exception {
+ return asyncSend("direct:start", transacted).get(10, TimeUnit.SECONDS);
+ }
+
+ protected CompletableFuture<Exchange> asyncSend(String uri, boolean
transacted) {
+ // bounded by the caller: on a hang the future does not complete and
get times out
+ return template.asyncSend(uri, e -> {
+ e.getMessage().setBody("Hello");
+ e.getExchangeExtension().setTransacted(transacted);
+ });
+ }
+
+ /**
+ * Sends on this thread, which runs the reactive work of the exchange
until none is left: when it returns, the
+ * takeover of a thrown failure has run too.
+ */
+ protected Exchange sendOnThisThread() {
+ return template.send("direct:start", e ->
e.getMessage().setBody("Hello"));
+ }
+
+ /**
+ * Counts the exchanges done: a second continuation of an exchange would
complete its unit of work again.
+ */
+ protected AtomicInteger countDoneExchanges() {
+ AtomicInteger done = new AtomicInteger();
+ context.getManagementStrategy().addEventNotifier(new
EventNotifierSupport() {
+ @Override
+ public void notify(CamelEvent event) {
+ done.incrementAndGet();
+ }
+
+ @Override
+ public boolean isEnabled(CamelEvent event) {
+ return event instanceof ExchangeCompletedEvent || event
instanceof ExchangeFailedEvent;
+ }
+ });
+ return done;
+ }
+
+ protected void assertFailedWithTheThrownException(Exchange out) {
+ assertInstanceOf(IllegalStateException.class, out.getException());
+ assertEquals("thrown", out.getException().getMessage());
+ assertEquals(1, calls.get(), "calls");
+ assertEquals(0, getMockEndpoint("mock:result").getReceivedCounter(),
"mock:result");
+ }
+
+ @Test
+ void callbackCalledAfterTheProcessorThrewDoesNotContinueTheNextExchange()
throws Exception {
+ // as above, but the late callback comes while the next exchange waits
for the callback of its own call of the
+ // processor: it must not continue that exchange (with pooled tasks,
the next exchange may get a task again)
+ CountDownLatch releaseFirst = new CountDownLatch(1);
+ CountDownLatch releaseSecond = new CountDownLatch(1);
+ AtomicReference<Thread> first = new AtomicReference<>();
+ AtomicReference<Thread> second = new AtomicReference<>();
+ AtomicReference<Throwable> lateFailure = new AtomicReference<>();
+ start(new AsyncProcessorSupport() {
+ @Override
+ public boolean process(Exchange exchange, AsyncCallback callback) {
+ int call = calls.incrementAndGet();
+ CountDownLatch release = call == 1 ? releaseFirst :
releaseSecond;
+ Thread thread = new Thread(() -> {
+ try {
+ if (release.await(10, TimeUnit.SECONDS)) {
+ callback.done(false);
+ }
+ } catch (Throwable t) {
+ lateFailure.set(t);
+ }
+ }, "callback-" + call);
+ (call == 1 ? first : second).set(thread);
+ thread.start();
+ if (call == 1) {
+ throw new IllegalStateException("thrown");
+ }
+ return false;
+ }
+ });
+
+ assertFailedWithTheThrownException(send());
+
+ CompletableFuture<Exchange> next = asyncSend("direct:start", false);
+ await().atMost(10, TimeUnit.SECONDS).until(() -> second.get() != null);
+
+ // the callback of the first call, while the second call waits for its
own
+ releaseFirst.countDown();
+ first.get().join(TimeUnit.SECONDS.toMillis(10));
+ assertFalse(first.get().isAlive(), "the late callback thread ended");
+ assertFalse(next.isDone(), "the late callback of the first exchange
continued the next exchange");
+ assertEquals(0, getMockEndpoint("mock:result").getReceivedCounter(),
"mock:result");
+
+ releaseSecond.countDown();
+ Exchange out = next.get(10, TimeUnit.SECONDS);
+ assertNull(out.getException());
+ assertEquals(1, getMockEndpoint("mock:result").getReceivedCounter(),
"mock:result");
+ second.get().join(TimeUnit.SECONDS.toMillis(10));
+ assertNull(lateFailure.get(), "a callback failed");
+ assertEquals(2, calls.get(), "calls");
+ }
+
+ @Test
+ void callbackCalledTwiceContinuesTheExchangeOnce() throws Exception {
+ // a processor that calls its callback twice (also a broken
processor): the second call is ignored, also when the
+ // first one released the task (pooled tasks are reset when they are
released)
+ AtomicReference<Throwable> secondCall = new AtomicReference<>();
+ start(new AsyncProcessorSupport() {
+ @Override
+ public boolean process(Exchange exchange, AsyncCallback callback) {
+ calls.incrementAndGet();
+ callback.done(true);
+ try {
+ callback.done(true);
+ } catch (Throwable t) {
+ secondCall.set(t);
+ }
+ return true;
+ }
+ });
+ AtomicInteger done = countDoneExchanges();
+
+ Exchange out = sendOnThisThread();
+ assertNull(out.getException());
+ assertNull(secondCall.get(), "the second call of the callback failed");
+ assertEquals(1, done.get(), "the exchange was done once");
+ assertEquals(1, getMockEndpoint("mock:result").getReceivedCounter(),
"mock:result");
+ }
+}