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 22e3ffa04972 CAMEL-26688: camel-core - step listener SPI for the
exchange sanitizer (#29374)
22e3ffa04972 is described below
commit 22e3ffa0497230da6c98f178a5b42dd8972ac675
Author: allthingssecurity <[email protected]>
AuthorDate: Sat Nov 7 20:19:37 2026 +0530
CAMEL-26688: camel-core - step listener SPI for the exchange sanitizer
(#29374)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../main/java/org/apache/camel/AsyncCallback.java | 8 +-
.../main/java/org/apache/camel/AsyncProcessor.java | 30 +-
.../org/apache/camel/ExtendedCamelContext.java | 37 ++
.../java/org/apache/camel/spi/StepListener.java | 118 +++++
.../java/org/apache/camel/spi/Synchronization.java | 20 +
.../impl/engine/DefaultCamelContextExtension.java | 22 +
.../apache/camel/impl/engine/DefaultChannel.java | 9 +
.../camel/impl/engine/StepListenerProcessor.java | 198 ++++++++
.../StepListenerInterceptStrategyTest.java | 213 ++++++++
.../processor/StepListenerNotInstalledTest.java | 95 ++++
.../camel/processor/StepListenerRecorder.java | 112 +++++
.../apache/camel/processor/StepListenerTest.java | 539 +++++++++++++++++++++
.../camel/management/ManagedStepListenerTest.java | 171 +++++++
design/exchange-sanitizer.adoc | 2 +-
14 files changed, 1567 insertions(+), 7 deletions(-)
diff --git a/core/camel-api/src/main/java/org/apache/camel/AsyncCallback.java
b/core/camel-api/src/main/java/org/apache/camel/AsyncCallback.java
index 2692c50a26c6..5256468c267f 100644
--- a/core/camel-api/src/main/java/org/apache/camel/AsyncCallback.java
+++ b/core/camel-api/src/main/java/org/apache/camel/AsyncCallback.java
@@ -32,9 +32,15 @@ public interface AsyncCallback extends Runnable {
* <p/>
* If an exception occurred while processing the exchange, the exception
field of the {@link Exchange} being
* processed will hold the caused exception.
+ * <p/>
+ * It is called exactly once for each call of {@link
AsyncProcessor#process(Exchange, AsyncCallback)} it was given
+ * to, also when the processing failed, and never a second time: a second
call can continue an exchange that was
+ * already continued. See the contract in {@link AsyncProcessor}.
*
* @param doneSync is <tt>true</tt> if the processing of the {@link
Exchange} was completed by a synchronous thread.
- * Otherwise, its <tt>false</tt> to indicate it was
completed by an asynchronous thread.
+ * Otherwise, its <tt>false</tt> to indicate it was
completed by an asynchronous thread. It is the
+ * value that {@link AsyncProcessor#process(Exchange,
AsyncCallback)} returns, except for a
+ * processor that hands its callback to the reactive
executor (see {@link AsyncProcessor}).
*/
void done(boolean doneSync);
diff --git a/core/camel-api/src/main/java/org/apache/camel/AsyncProcessor.java
b/core/camel-api/src/main/java/org/apache/camel/AsyncProcessor.java
index e2db9d1f2634..a24c7a43bb5d 100644
--- a/core/camel-api/src/main/java/org/apache/camel/AsyncProcessor.java
+++ b/core/camel-api/src/main/java/org/apache/camel/AsyncProcessor.java
@@ -24,6 +24,24 @@ import java.util.concurrent.CompletableFuture;
* <p/>
* Any processor can be coerced to have an {@link AsyncProcessor} interface by
using the
* {@link org.apache.camel.support.AsyncProcessorConverterHelper#convert
AsyncProcessorConverterHelper.convert} method.
+ * <p/>
+ * <b>The contract of {@link #process(Exchange, AsyncCallback)}.</b> The
routing engine relies on it, and a processor
+ * that breaks the first two rules is a bug, whether it is Camel's or a user's:
+ * <ul>
+ * <li>The callback is called exactly once for each call of {@code process}:
also on a failure, and never a second time
+ * (for example once from a timeout and again from a reply that comes late). A
second call can continue an exchange that
+ * was already continued.</li>
+ * <li>{@code process} does not throw. On a failure it sets the exception on
the exchange
+ * ({@link Exchange#setException(Throwable)}) and calls the callback.</li>
+ * <li>The value the callback is called with ({@code doneSync}) is the value
{@code process} returns. When
+ * {@code process} returns {@code true}, the callback has been called already,
on the calling thread; when it returns
+ * {@code false}, the callback is called with {@code false}, possibly on
another thread and possibly before
+ * {@code process} returns. A processor that hands its callback to the {@link
org.apache.camel.spi.ReactiveExecutor}
+ * instead of calling it, as the channel of each step of a route does, is the
exception: it returns the value of the
+ * processor it calls, and the reactive executor runs its callback with {@code
false}, at once or after the work the
+ * thread is running. So a caller of such a processor must go on through the
callback, not through the return
+ * value.</li>
+ * </ul>
*
* @see Processor
* @see AsyncCallback
@@ -34,14 +52,16 @@ public interface AsyncProcessor extends Processor {
* Processes the message exchange. Similar to {@link Processor#process},
but the caller supports having the exchange
* asynchronously processed.
* <p/>
- * If there was a failure processing then the caused {@link Exception}
would be set on the {@link Exchange}.
+ * If there was a failure processing then the caused {@link Exception}
would be set on the {@link Exchange}, and the
+ * callback is called; this method does not throw. See the contract in the
class Javadoc.
*
* @param exchange the message exchange
- * @param callback the {@link AsyncCallback} will be invoked when the
processing of the exchange is completed. If
- * the exchange is completed synchronously, then the
callback is also invoked synchronously. The
- * callback should therefore be careful of starting
recursive loop.
+ * @param callback the {@link AsyncCallback} will be invoked exactly once
when the processing of the exchange is
+ * completed. If the exchange is completed synchronously,
then the callback is also invoked
+ * synchronously. The callback should therefore be
careful of starting recursive loop.
* @return (doneSync) <tt>true</tt> to continue execution
synchronously, <tt>false</tt> to continue being
- * executed asynchronously
+ * executed asynchronously; the same value the callback
is called with, except for a processor that
+ * hands its callback to the reactive executor (see above)
*/
boolean process(Exchange exchange, AsyncCallback callback);
diff --git
a/core/camel-api/src/main/java/org/apache/camel/ExtendedCamelContext.java
b/core/camel-api/src/main/java/org/apache/camel/ExtendedCamelContext.java
index dc72deee55a7..a703b5e0d01b 100644
--- a/core/camel-api/src/main/java/org/apache/camel/ExtendedCamelContext.java
+++ b/core/camel-api/src/main/java/org/apache/camel/ExtendedCamelContext.java
@@ -16,6 +16,7 @@
*/
package org.apache.camel;
+import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -45,6 +46,7 @@ import org.apache.camel.spi.RouteStartupOrder;
import org.apache.camel.spi.SendHook;
import org.apache.camel.spi.StartupStepRecorder;
import org.apache.camel.spi.StepHook;
+import org.apache.camel.spi.StepListener;
import org.jspecify.annotations.Nullable;
/**
@@ -541,6 +543,41 @@ public interface ExtendedCamelContext {
throw new UnsupportedOperationException("This CamelContext does not
support a StepHook");
}
+ /**
+ * Adds a {@link StepListener} that observes each call of the processor of
a step of a route. Many listeners can be
+ * installed; adding a listener that is already installed (equal to one
installed) is ignored. Whether a route calls
+ * the listeners is decided when it is created, so add them before the
routes are created: a route created while no
+ * listener was installed is not seen. The CamelContext does not start or
stop the listeners, and they stay
+ * installed when the CamelContext is stopped and started again.
+ *
+ * @param listener the listener
+ * @since 4.24
+ */
+ default void addStepListener(StepListener listener) {
+ throw new UnsupportedOperationException("This CamelContext does not
support a StepListener");
+ }
+
+ /**
+ * Removes a {@link StepListener} (one equal to it). A call of a step that
started before keeps the listener for its
+ * callback.
+ *
+ * @param listener the listener
+ * @since 4.24
+ */
+ default void removeStepListener(StepListener listener) {
+ // no listener can be installed, so there is nothing to remove
+ }
+
+ /**
+ * Gets the {@link StepListener}s, in the order they were added.
+ *
+ * @return an unmodifiable view of the listeners (empty if none is
installed), which reflects later adds and removes
+ * @since 4.24
+ */
+ default List<StepListener> getStepListeners() {
+ return Collections.emptyList();
+ }
+
/**
* Setup management according to whether JMX is enabled or disabled.
*
diff --git
a/core/camel-api/src/main/java/org/apache/camel/spi/StepListener.java
b/core/camel-api/src/main/java/org/apache/camel/spi/StepListener.java
new file mode 100644
index 000000000000..79c09c5ff68d
--- /dev/null
+++ b/core/camel-api/src/main/java/org/apache/camel/spi/StepListener.java
@@ -0,0 +1,118 @@
+/*
+ * 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.spi;
+
+import org.apache.camel.Exchange;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * A listener that observes each call of the processor of a step of a route:
the call, its return or what it throws, and
+ * each call of its callback. A step is any node of a route with its node id
({@code to}, {@code process},
+ * {@code split}, ...), the same steps the {@link StepHook} is asked for. A
listener only observes: it must not change
+ * the exchange, and what it throws cannot change the routing. It is meant for
development and test tools that check the
+ * contracts of {@link org.apache.camel.AsyncProcessor} and {@link
org.apache.camel.AsyncCallback}.
+ * <p/>
+ * Listeners are installed with {@link
org.apache.camel.ExtendedCamelContext#addStepListener(StepListener)}. Unlike the
+ * {@link StepHook}, many can be installed. They are called in the order they
were added, and must not depend on that
+ * order.
+ * <p/>
+ * <b>Where it is called.</b> Whether a step gets the listeners is decided
when its route is created. When at least one
+ * listener is installed then, the channel of each step (the default channel
of Camel, not a channel of a custom
+ * {@link InternalProcessorFactory}) adds a small wrapper around the processor
of the step and the
+ * {@link InterceptStrategy} wrappers of the step, below the error handler of
the step. So the listeners see each call
+ * of that chain: each attempt, also each redelivery of the error handler, and
each call by the channel of a step
+ * without such an error handler. The {@link StepHook} is asked before the
wrapper is called, so a step that the hook
+ * fails is not a call the listeners see. A route that is created while no
listener is installed has no wrapper and is
+ * not seen until it is created again; add the listeners before the routes are
created. With no listener installed when
+ * a route is created, the route is built as without this SPI, and a step
costs nothing more. A step that has the
+ * wrapper reads the listeners installed at the start of each call, so a
listener added later is called by the routes
+ * that have the wrapper.
+ * <p/>
+ * With no {@link InterceptStrategy} on the step, the listeners see the call
of the processor of the step itself. A
+ * strategy can hide part of that call: {@code intercept()} calls the
processor later, after the intercept route, so the
+ * listeners do not see its own return value or what it throws; a strategy
that returns a plain
+ * {@link org.apache.camel.Processor} makes the call wait until the processor
is done, so a second call of the callback
+ * of the processor is not seen; and a strategy that catches what the
processor throws hides the throw.
+ * <p/>
+ * <b>The state of a call.</b> {@link #onStepCall(Exchange, String, String)}
returns a state for the call, and the other
+ * methods get it back. The state is held by the callback of the call, so it
lives as long as anything can still call
+ * that callback; it should therefore hold ids, names, threads or stacks,
never the exchange.
+ * <p/>
+ * <b>Threads.</b> The listeners are called on the thread of the event, by
many threads at the same time: they must be
+ * thread-safe and quick. Only {@link #onStepCall(Exchange, String, String)}
may read the exchange. The other methods
+ * can run on a thread that does not own the exchange, so they get only the
state.
+ * {@link #onStepCallback(Object, boolean)} can come before {@link
#onStepReturn(Object, boolean)}, on the same thread
+ * (the callback is called before {@code process} returns) or on another one.
+ * <p/>
+ * <b>Failures.</b> What a listener throws cannot change the routing. It is
caught and logged at WARN once per listener
+ * (then at DEBUG). The processor is called whatever {@link
#onStepCall(Exchange, String, String)} did, the callback of
+ * the call is called after the listeners, and what the processor throws is
thrown on unchanged. A listener whose
+ * {@link #onStepCall(Exchange, String, String)} threw gets no more events for
that call.
+ * <p/>
+ * A call keeps the listeners that were installed when it started, so a
listener that is removed meanwhile still gets
+ * the callback of a call it saw start.
+ * <p/>
+ * All methods have an empty default. Their names differ from {@link
StepHook#beforeStep(Exchange, String)}, so one
+ * class can implement both.
+ *
+ * @since 4.24
+ */
+public interface StepListener {
+
+ /**
+ * The processor of a step is about to be called (each attempt, also each
redelivery).
+ *
+ * @param exchange the exchange
+ * @param routeId the id of the route of the step
+ * @param nodeId the id of the step: the id of its node in the route,
such as {@code to1}, or the id set with
+ * {@code id("myStep")}
+ * @return the state of this call, passed to the other methods of
this listener for this call; can be
+ * {@code null}
+ */
+ default @Nullable Object onStepCall(Exchange exchange, String routeId,
String nodeId) {
+ return null;
+ }
+
+ /**
+ * {@code process(exchange, callback)} of the call returned.
+ *
+ * @param state the state returned by {@link #onStepCall(Exchange,
String, String)} for this call
+ * @param doneSync the value {@code process} returned
+ */
+ default void onStepReturn(@Nullable Object state, boolean doneSync) {
+ }
+
+ /**
+ * {@code process(exchange, callback)} of the call threw. The exception is
thrown on unchanged after the listeners.
+ *
+ * @param state the state returned by {@link #onStepCall(Exchange, String,
String)} for this call
+ * @param cause what {@code process} threw
+ */
+ default void onStepThrow(@Nullable Object state, Throwable cause) {
+ }
+
+ /**
+ * The callback of the call is called, before it continues the exchange.
It is called each time the callback is
+ * called, so a second call of the callback is seen too.
+ *
+ * @param state the state returned by {@link #onStepCall(Exchange,
String, String)} for this call
+ * @param doneSync the value the callback is called with
+ */
+ default void onStepCallback(@Nullable Object state, boolean doneSync) {
+ }
+
+}
diff --git
a/core/camel-api/src/main/java/org/apache/camel/spi/Synchronization.java
b/core/camel-api/src/main/java/org/apache/camel/spi/Synchronization.java
index 22d5202a904d..5c288e3676d9 100644
--- a/core/camel-api/src/main/java/org/apache/camel/spi/Synchronization.java
+++ b/core/camel-api/src/main/java/org/apache/camel/spi/Synchronization.java
@@ -23,6 +23,26 @@ import org.jspecify.annotations.Nullable;
* Provides a hook for custom {@link org.apache.camel.Processor} or {@link
org.apache.camel.Component} instances to
* respond to completed or failed processing of an {@link Exchange} rather
like Spring's
* {@code org.springframework.transaction.support.TransactionSynchronization}
+ * <p/>
+ * <b>The commit of a consumer.</b> A consumer typically adds a
synchronization to the exchange before it routes it, to
+ * commit or roll back its message when the exchange is done (such as moving
or deleting a file). It moves into the unit
+ * of work that is created for the exchange. For such a commit the routing
engine keeps this contract:
+ * <ul>
+ * <li>Each time it is added, exactly one of {@link #onComplete(Exchange)} or
{@link #onFailure(Exchange)} is called,
+ * once (unless it is removed with {@link
UnitOfWork#removeSynchronization(Synchronization)}).</li>
+ * <li>It runs where it is held at the end: when the unit of work it was added
to is done, with the outcome of its
+ * exchange; or, when it was handed over, where it was handed over to. An
exchange that has no unit of work yet (such as
+ * a copy waiting in a {@code seda} queue) holds its commits itself, and they
move into the unit of work it gets, or
+ * back to the exchange that handed them over when the copy is not routed
(such as a copy that {@code seda} does not
+ * queue).</li>
+ * <li>While it is in a unit of work, it moves only by a handover through that
unit of work made by the exchange the
+ * unit of work was created for: {@link
org.apache.camel.ExchangeExtension#handoverCompletions(Exchange)} moves it to a
+ * copy that is routed on its own (such as an {@code InOnly} exchange sent to
{@code seda}), and it runs when the copy
+ * is done, with the outcome of the copy; {@link
org.apache.camel.ExchangeExtension#handoverCompletions()} takes it out
+ * to be run by someone else (such as the aggregator, the enrichers or {@code
onCompletion}). A
+ * {@link SynchronizationVetoable} can refuse to be handed over. The parts of
a split, multicast or recipient list with
+ * {@code shareUnitOfWork} use the unit of work of their parent, and do not
hand over the commit of the parent.</li>
+ * </ul>
*/
public interface Synchronization {
diff --git
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultCamelContextExtension.java
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultCamelContextExtension.java
index 180bfcf79cff..7f31883855bc 100644
---
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultCamelContextExtension.java
+++
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultCamelContextExtension.java
@@ -17,6 +17,7 @@
package org.apache.camel.impl.engine;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@@ -78,6 +79,7 @@ import org.apache.camel.spi.SendHook;
import org.apache.camel.spi.ShutdownStrategy;
import org.apache.camel.spi.StartupStepRecorder;
import org.apache.camel.spi.StepHook;
+import org.apache.camel.spi.StepListener;
import org.apache.camel.spi.StreamCachingStrategy;
import org.apache.camel.spi.Tracer;
import org.apache.camel.spi.TransformerRegistry;
@@ -89,6 +91,7 @@ import org.apache.camel.support.NormalizedUri;
import org.apache.camel.support.PluginHelper;
import org.apache.camel.support.service.ServiceHelper;
import org.apache.camel.support.startup.DefaultStartupStepRecorder;
+import org.apache.camel.util.ObjectHelper;
import org.apache.camel.util.StringHelper;
import org.apache.camel.util.URISupport;
import org.apache.camel.util.concurrent.ContextValue;
@@ -105,6 +108,8 @@ class DefaultCamelContextExtension implements
ExtendedCamelContext {
private final Map<String, FactoryFinder> factories = new
ConcurrentHashMap<>();
private final Map<String, FactoryFinder> bootstrapFactories = new
ConcurrentHashMap<>();
private final Set<LogListener> logListeners = new LinkedHashSet<>();
+ private final CopyOnWriteArrayList<StepListener> stepListeners = new
CopyOnWriteArrayList<>();
+ private final List<StepListener> stepListenersView =
Collections.unmodifiableList(stepListeners);
private final PluginManager pluginManager = new
DefaultContextPluginManager();
private final RouteController internalRouteController;
@@ -535,6 +540,23 @@ class DefaultCamelContextExtension implements
ExtendedCamelContext {
this.stepHook = stepHook;
}
+ @Override
+ public void addStepListener(StepListener listener) {
+ ObjectHelper.notNull(listener, "listener");
+ // a listener equal to one already installed is ignored
+ stepListeners.addIfAbsent(listener);
+ }
+
+ @Override
+ public void removeStepListener(StepListener listener) {
+ stepListeners.remove(listener);
+ }
+
+ @Override
+ public List<StepListener> getStepListeners() {
+ return stepListenersView;
+ }
+
@Override
public Set<LogListener> getLogListeners() {
return logListeners;
diff --git
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultChannel.java
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultChannel.java
index 28f45d41c63a..e7a287ab9efb 100644
---
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultChannel.java
+++
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultChannel.java
@@ -41,6 +41,7 @@ import org.apache.camel.spi.InterceptStrategy;
import org.apache.camel.spi.InterceptableProcessor;
import org.apache.camel.spi.ManagementInterceptStrategy;
import org.apache.camel.spi.MessageHistoryFactory;
+import org.apache.camel.spi.StepListener;
import org.apache.camel.spi.Tracer;
import org.apache.camel.spi.WrapAwareProcessor;
import org.apache.camel.support.OrderedComparator;
@@ -265,6 +266,14 @@ public class DefaultChannel extends CamelInternalProcessor
implements Channel {
}
}
+ // the step listeners observe each call of what the strategies
returned, below the error handler of the step;
+ // with no listener installed there is no wrapper, so the step is
built as without listeners
+ List<StepListener> stepListeners =
camelContext.getCamelContextExtension().getStepListeners();
+ if (!stepListeners.isEmpty()) {
+ target = StepListenerProcessor.create(stepListeners,
route.getRouteId(), targetOutputDef.getId(),
+ nextProcessor, target);
+ }
+
if (route.isStreamCaching()) {
addAdvice(new
StreamCachingAdvice(camelContext.getStreamCachingStrategy()));
}
diff --git
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/StepListenerProcessor.java
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/StepListenerProcessor.java
new file mode 100644
index 000000000000..ac99bed41a0d
--- /dev/null
+++
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/StepListenerProcessor.java
@@ -0,0 +1,198 @@
+/*
+ * 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.impl.engine;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.WeakHashMap;
+
+import org.apache.camel.AsyncCallback;
+import org.apache.camel.Exchange;
+import org.apache.camel.Processor;
+import org.apache.camel.spi.ErrorHandlerAware;
+import org.apache.camel.spi.StepListener;
+import org.apache.camel.support.processor.DelegateAsyncProcessor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Calls the {@link StepListener}s around each call of the processor of a
step. {@link DefaultChannel} adds it after the
+ * {@link org.apache.camel.spi.InterceptStrategy} wrappers of the step, only
when a listener is installed when the route
+ * is created, so it is the output of the channel that the error handler of
the step calls for each attempt.
+ */
+class StepListenerProcessor extends DelegateAsyncProcessor {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(StepListenerProcessor.class);
+
+ private static final StepListener[] NO_LISTENERS = new StepListener[0];
+
+ // the state of a listener whose onStepCall threw: it gets no more events
for that call
+ private static final Object FAILED = new Object();
+
+ // the listeners that already failed once, so they are logged at WARN once
(weak keys: a listener is not kept)
+ private static final Map<StepListener, Boolean> FAILED_LISTENERS =
Collections.synchronizedMap(new WeakHashMap<>());
+
+ private final List<StepListener> listeners;
+ private final Processor step;
+ private final String routeId;
+ private final String nodeId;
+
+ StepListenerProcessor(List<StepListener> listeners, String routeId, String
nodeId, Processor step,
+ Processor processor) {
+ super(processor);
+ this.listeners = listeners;
+ this.routeId = routeId;
+ this.nodeId = nodeId;
+ this.step = step;
+ }
+
+ /**
+ * Creates the wrapper, which is also {@link ErrorHandlerAware} when what
it wraps is, so the error handler of the
+ * step is still given to the processor (multicast, recipient list, bean)
when the error handler of the step is
+ * created, as without the wrapper. (At the end of route creation, {@code
RouteReifier} gives these processors the
+ * error handler of the route anyway, as it finds them through {@link
org.apache.camel.Channel#getNextProcessor()}.)
+ */
+ static StepListenerProcessor create(
+ List<StepListener> listeners, String routeId, String nodeId,
Processor step, Processor processor) {
+ if (processor instanceof ErrorHandlerAware) {
+ return new ErrorHandlerAwareStepListenerProcessor(listeners,
routeId, nodeId, step, processor);
+ }
+ return new StepListenerProcessor(listeners, routeId, nodeId, step,
processor);
+ }
+
+ @Override
+ public boolean process(Exchange exchange, AsyncCallback callback) {
+ // the listeners when the call starts are kept for its callback
+ final StepListener[] called = listeners.toArray(NO_LISTENERS);
+ if (called.length == 0) {
+ return processor.process(exchange, callback);
+ }
+ final Object[] states = new Object[called.length];
+ for (int i = 0; i < called.length; i++) {
+ try {
+ states[i] = called[i].onStepCall(exchange, routeId, nodeId);
+ } catch (Throwable e) {
+ states[i] = FAILED;
+ failed(called[i], "onStepCall", e);
+ }
+ }
+ final StepCallback stepCallback = new StepCallback(called, states,
callback);
+ final boolean doneSync;
+ try {
+ doneSync = processor.process(exchange, stepCallback);
+ } catch (Throwable e) {
+ for (int i = 0; i < called.length; i++) {
+ if (states[i] != FAILED) {
+ try {
+ called[i].onStepThrow(states[i], e);
+ } catch (Throwable t) {
+ failed(called[i], "onStepThrow", t);
+ }
+ }
+ }
+ // what the processor threw is thrown on unchanged
+ throw e;
+ }
+ for (int i = 0; i < called.length; i++) {
+ if (states[i] != FAILED) {
+ try {
+ called[i].onStepReturn(states[i], doneSync);
+ } catch (Throwable t) {
+ failed(called[i], "onStepReturn", t);
+ }
+ }
+ }
+ return doneSync;
+ }
+
+ static void failed(StepListener listener, String method, Throwable cause) {
+ try {
+ if (FAILED_LISTENERS.put(listener, Boolean.TRUE) == null) {
+ LOG.warn("StepListener {} failed in {} (logged once for this
listener, then at DEBUG)", listener, method,
+ cause);
+ } else {
+ LOG.debug("StepListener {} failed in {}", listener, method,
cause);
+ }
+ } catch (Throwable t) {
+ // a listener cannot change the routing, also when logging it
fails (such as its toString)
+ }
+ }
+
+ @Override
+ public String toString() {
+ return step != null ? step.toString() : super.toString();
+ }
+
+ /**
+ * The callback of one call: calls the listeners, and then the callback of
the call, each time it is called.
+ */
+ private static final class StepCallback implements AsyncCallback {
+
+ private final StepListener[] called;
+ private final Object[] states;
+ private final AsyncCallback callback;
+
+ StepCallback(StepListener[] called, Object[] states, AsyncCallback
callback) {
+ this.called = called;
+ this.states = states;
+ this.callback = callback;
+ }
+
+ @Override
+ public void done(boolean doneSync) {
+ try {
+ for (int i = 0; i < called.length; i++) {
+ if (states[i] != FAILED) {
+ try {
+ called[i].onStepCallback(states[i], doneSync);
+ } catch (Throwable t) {
+ failed(called[i], "onStepCallback", t);
+ }
+ }
+ }
+ } finally {
+ callback.done(doneSync);
+ }
+ }
+ }
+
+ /**
+ * The wrapper of a processor that is {@link ErrorHandlerAware}.
+ */
+ private static final class ErrorHandlerAwareStepListenerProcessor extends
StepListenerProcessor
+ implements ErrorHandlerAware {
+
+ private final ErrorHandlerAware errorHandlerAware;
+
+ ErrorHandlerAwareStepListenerProcessor(List<StepListener> listeners,
String routeId, String nodeId,
+ Processor step, Processor
processor) {
+ super(listeners, routeId, nodeId, step, processor);
+ this.errorHandlerAware = (ErrorHandlerAware) processor;
+ }
+
+ @Override
+ public void setErrorHandler(Processor errorHandler) {
+ errorHandlerAware.setErrorHandler(errorHandler);
+ }
+
+ @Override
+ public Processor getErrorHandler() {
+ return errorHandlerAware.getErrorHandler();
+ }
+ }
+}
diff --git
a/core/camel-core/src/test/java/org/apache/camel/processor/StepListenerInterceptStrategyTest.java
b/core/camel-core/src/test/java/org/apache/camel/processor/StepListenerInterceptStrategyTest.java
new file mode 100644
index 000000000000..90687b498a1c
--- /dev/null
+++
b/core/camel-core/src/test/java/org/apache/camel/processor/StepListenerInterceptStrategyTest.java
@@ -0,0 +1,213 @@
+/*
+ * 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.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.AsyncCallback;
+import org.apache.camel.CamelContext;
+import org.apache.camel.Channel;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.NamedNode;
+import org.apache.camel.Processor;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.spi.InterceptStrategy;
+import org.apache.camel.support.AsyncProcessorSupport;
+import org.apache.camel.support.processor.DelegateAsyncProcessor;
+import org.junit.jupiter.api.AfterEach;
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The {@link InterceptStrategy}s still get the processor of the step as their
target when step listeners are installed,
+ * and the listeners see the call of the chain of the strategies. With {@code
intercept()} and with a strategy that
+ * returns a plain {@link Processor}, the listeners see less of the call of
the processor, as documented.
+ */
+class StepListenerInterceptStrategyTest extends ContextTestSupport {
+
+ private final StepListenerRecorder recorder = new StepListenerRecorder();
+ // what the strategy got, per node id (the strategies are called on one
thread when the routes are created)
+ private final Map<String, Processor> targets =
Collections.synchronizedMap(new HashMap<>());
+ private final Map<String, Processor> nexts =
Collections.synchronizedMap(new HashMap<>());
+ private final ExecutorService worker = Executors.newSingleThreadExecutor(r
-> new Thread(r, "StepListenerWorker"));
+ private final CountDownLatch twiceDone = new CountDownLatch(2);
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext context = super.createCamelContext();
+ context.getCamelContextExtension().addStepListener(recorder);
+ context.getCamelContextExtension().addInterceptStrategy(new
TypeCheckingStrategy());
+ return context;
+ }
+
+ @AfterEach
+ void stopWorker() {
+ worker.shutdownNow();
+ }
+
+ @Override
+ protected RoutesBuilder[] createRouteBuilders() {
+ return new RoutesBuilder[] { new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:typed").routeId("typed")
+ .to("mock:typed").id("typedTo");
+
+ // the processor calls its callback twice, on the worker thread
+ from("direct:twice").routeId("twice")
+ .errorHandler(noErrorHandler())
+ .process(new TwiceDone()).id("twiceStep")
+ .to("mock:twice").id("twiceTo");
+
+ from("direct:plain").routeId("plain")
+ .errorHandler(noErrorHandler())
+ .process(new TwiceDone()).id("plainStep")
+ .to("mock:plain").id("plainTo");
+ }
+ }, new RouteBuilder() {
+ @Override
+ public void configure() {
+ intercept().to("mock:intercepted").id("interceptTo");
+
+ from("direct:intercepted").routeId("intercepted")
+ .process(e ->
e.getMessage().setBody("Bye")).id("interceptedStep");
+ }
+ } };
+ }
+
+ @Test
+ void testStrategyGetsTheProcessorOfTheStep() throws Exception {
+ Channel channel =
StepListenerRecorder.channels(context.getRoute("typed")).get(0);
+ // as camel-opentelemetry checks the type of its target
+ assertInstanceOf(SendProcessor.class, targets.get("typedTo"));
+ assertSame(channel.getNextProcessor(), targets.get("typedTo"));
+ assertTrue(nexts.containsKey("typedTo"));
+ assertNull(nexts.get("typedTo"));
+
+ getMockEndpoint("mock:typed").expectedMessageCount(1);
+ template.sendBody("direct:typed", "Hello");
+ assertMockEndpointsSatisfied();
+
+ // the listener sees the call of the chain of the strategy
+ assertEquals(List.of("call typed/typedTo", "strategy typed/typedTo",
"callback typed/typedTo true",
+ "return typed/typedTo true"), recorder.of("typed"));
+ }
+
+ @Test
+ void testSecondCallbackIsSeenWithoutPlainProcessorStrategy() throws
Exception {
+ getMockEndpoint("mock:twice").expectedMessageCount(1);
+
+ template.sendBody("direct:twice", "Hello");
+
+ assertTrue(twiceDone.await(10, TimeUnit.SECONDS));
+ assertMockEndpointsSatisfied();
+ List<String> events = recorder.of("twice");
+ assertEquals(1, events.stream().filter(e -> e.equals("return
twice/twiceStep false")).count(), events.toString());
+ assertEquals(2, events.stream().filter(e -> e.equals("callback
twice/twiceStep false")).count(),
+ events.toString());
+ }
+
+ @Test
+ void testPlainProcessorStrategyHidesTheSecondCallback() throws Exception {
+ getMockEndpoint("mock:plain").expectedMessageCount(1);
+
+ template.sendBody("direct:plain", "Hello");
+
+ assertTrue(twiceDone.await(10, TimeUnit.SECONDS));
+ assertMockEndpointsSatisfied();
+ // the call waits in the await manager, so the listener sees one
synchronous call, and the second callback of
+ // the processor reaches the await manager, not the listener
+ List<String> events = recorder.of("plain");
+ assertEquals(List.of("call plain/plainStep", "callback plain/plainStep
true", "return plain/plainStep true"),
+ events.subList(0, 3));
+ assertEquals(1, events.stream().filter(e -> e.startsWith("callback
plain/plainStep ")).count(), events.toString());
+ }
+
+ @Test
+ void testInterceptHidesTheReturnValueOfTheProcessor() throws Exception {
+ getMockEndpoint("mock:intercepted").expectedMessageCount(1);
+
+ Object out = template.requestBody("direct:intercepted", "Hello");
+
+ assertEquals("Bye", out);
+ assertMockEndpointsSatisfied();
+ // the processor returns true, but the pipeline of intercept() returns
false and calls it later; the step of
+ // the intercept route is its own call
+ assertEquals(List.of(
+ "call intercepted/interceptedStep", "return
intercepted/interceptedStep false",
+ "call intercepted/interceptTo", "callback
intercepted/interceptTo true",
+ "return intercepted/interceptTo true", "callback
intercepted/interceptedStep false"),
+ recorder.of("intercepted"));
+ }
+
+ /**
+ * Calls its callback twice from the worker thread.
+ */
+ private final class TwiceDone extends AsyncProcessorSupport {
+ @Override
+ public boolean process(Exchange exchange, AsyncCallback callback) {
+ worker.submit(() -> {
+ callback.done(false);
+ twiceDone.countDown();
+ callback.done(false);
+ twiceDone.countDown();
+ });
+ return false;
+ }
+ }
+
+ /**
+ * Wraps the steps whose target is a {@link SendProcessor} in the route
typed, as camel-opentelemetry checks the
+ * type of its target, and wraps the steps of the route plain in a plain
{@link Processor}.
+ */
+ private final class TypeCheckingStrategy implements InterceptStrategy {
+ @Override
+ public Processor wrapProcessorInInterceptors(
+ CamelContext context, NamedNode definition, Processor target,
Processor nextTarget) {
+ String id = definition.getId();
+ targets.put(id, target);
+ nexts.put(id, nextTarget);
+ if (id.startsWith("typed") && target instanceof SendProcessor) {
+ return new DelegateAsyncProcessor(target) {
+ @Override
+ public boolean process(Exchange exchange, AsyncCallback
callback) {
+ recorder.events.add("strategy typed/" + id);
+ return super.process(exchange, callback);
+ }
+ };
+ }
+ if ("plainStep".equals(id)) {
+ return target::process;
+ }
+ return target;
+ }
+ }
+}
diff --git
a/core/camel-core/src/test/java/org/apache/camel/processor/StepListenerNotInstalledTest.java
b/core/camel-core/src/test/java/org/apache/camel/processor/StepListenerNotInstalledTest.java
new file mode 100644
index 000000000000..70d42c32c64c
--- /dev/null
+++
b/core/camel-core/src/test/java/org/apache/camel/processor/StepListenerNotInstalledTest.java
@@ -0,0 +1,95 @@
+/*
+ * 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.List;
+
+import org.apache.camel.Channel;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Route;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.spi.StepListener;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * With no {@link StepListener} installed when the routes are created, no
channel has the wrapper of the listeners: the
+ * error handler of each step (or the channel of a step without one) calls the
processor of the step itself, as before
+ * the listeners existed.
+ */
+class StepListenerNotInstalledTest extends ContextTestSupport {
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:start").routeId("start")
+ .setHeader("greeting", constant("Hello"))
+ .process(e -> e.getMessage().setBody("Bye"))
+ .doTry()
+ .to("mock:try")
+ .doCatch(IllegalArgumentException.class)
+ .to("mock:caught")
+ .doFinally()
+ .to("mock:finally")
+ .end()
+ .split(body())
+ .to("mock:split")
+ .end()
+ .multicast()
+ .to("mock:mc1")
+ .to("mock:mc2")
+ .end()
+ .to("mock:result");
+
+ from("direct:noErrorHandler").routeId("noErrorHandler")
+ .errorHandler(noErrorHandler())
+ .to("mock:noEh1")
+ .to("mock:noEh2");
+
+ from("direct:retry").routeId("retry")
+
.errorHandler(deadLetterChannel("mock:dead").maximumRedeliveries(2).redeliveryDelay(0))
+ .to("mock:retried");
+ }
+ };
+ }
+
+ @Test
+ void testNoWrapperWithoutListener() throws Exception {
+
assertTrue(context.getCamelContextExtension().getStepListeners().isEmpty());
+ int steps = 0;
+ for (Route route : context.getRoutes()) {
+ List<Channel> channels = StepListenerRecorder.channels(route);
+ for (Channel channel : channels) {
+ // the error handler (or the channel) calls the processor of
the step, not a wrapper
+ assertSame(channel.getNextProcessor(),
StepListenerRecorder.callOf(channel),
+ route.getRouteId() + " " + channel);
+ steps++;
+ }
+ }
+ // the steps of all the routes, also the nested ones
+ assertEquals(17, steps);
+
+ getMockEndpoint("mock:result").expectedMessageCount(1);
+ template.sendBody("direct:start", "Hello");
+ assertMockEndpointsSatisfied();
+ }
+}
diff --git
a/core/camel-core/src/test/java/org/apache/camel/processor/StepListenerRecorder.java
b/core/camel-core/src/test/java/org/apache/camel/processor/StepListenerRecorder.java
new file mode 100644
index 000000000000..c668d0116691
--- /dev/null
+++
b/core/camel-core/src/test/java/org/apache/camel/processor/StepListenerRecorder.java
@@ -0,0 +1,112 @@
+/*
+ * 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.Map;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentLinkedQueue;
+
+import org.apache.camel.Channel;
+import org.apache.camel.Exchange;
+import org.apache.camel.Navigate;
+import org.apache.camel.Processor;
+import org.apache.camel.Route;
+import org.apache.camel.processor.errorhandler.ErrorHandlerSupport;
+import org.apache.camel.spi.StepListener;
+
+/**
+ * A {@link StepListener} for tests that records each event as a line {@code
"<event> <routeId>/<nodeId> [<value>]"},
+ * and the threads of the callbacks. Its state is the {@code
"<routeId>/<nodeId>"} of the call.
+ */
+class StepListenerRecorder implements StepListener {
+
+ final Queue<String> events = new ConcurrentLinkedQueue<>();
+ final Map<String, String> callbackThreads = new ConcurrentHashMap<>();
+ final Map<String, String> returnThreads = new ConcurrentHashMap<>();
+
+ @Override
+ public Object onStepCall(Exchange exchange, String routeId, String nodeId)
{
+ String state = routeId + "/" + nodeId;
+ events.add("call " + state);
+ return state;
+ }
+
+ @Override
+ public void onStepReturn(Object state, boolean doneSync) {
+ returnThreads.put((String) state, Thread.currentThread().getName());
+ events.add("return " + state + " " + doneSync);
+ }
+
+ @Override
+ public void onStepThrow(Object state, Throwable cause) {
+ events.add("throw " + state + " " + cause.getMessage());
+ }
+
+ @Override
+ public void onStepCallback(Object state, boolean doneSync) {
+ callbackThreads.put((String) state, Thread.currentThread().getName());
+ events.add("callback " + state + " " + doneSync);
+ }
+
+ /**
+ * The events of the steps of one route, in order.
+ */
+ List<String> of(String routeId) {
+ List<String> answer = new ArrayList<>();
+ for (String event : events) {
+ if (event.contains(" " + routeId + "/")) {
+ answer.add(event);
+ }
+ }
+ return answer;
+ }
+
+ /**
+ * The channels of all the steps of a route, also the nested ones.
+ */
+ static List<Channel> channels(Route route) {
+ List<Channel> answer = new ArrayList<>();
+ collect(route.navigate(), answer);
+ return answer;
+ }
+
+ private static void collect(Object node, List<Channel> answer) {
+ if (node instanceof Channel channel) {
+ answer.add(channel);
+ collect(channel.getNextProcessor(), answer);
+ } else if (node instanceof Navigate<?> navigate && navigate.hasNext())
{
+ for (Object child : navigate.next()) {
+ collect(child, answer);
+ }
+ }
+ }
+
+ /**
+ * What the error handler of the channel calls for each attempt, or the
channel itself when the step has no error
+ * handler.
+ */
+ static Processor callOf(Channel channel) {
+ Processor errorHandler = channel.getErrorHandler();
+ if (errorHandler instanceof ErrorHandlerSupport support) {
+ return support.getOutput();
+ }
+ return channel.getOutput();
+ }
+}
diff --git
a/core/camel-core/src/test/java/org/apache/camel/processor/StepListenerTest.java
b/core/camel-core/src/test/java/org/apache/camel/processor/StepListenerTest.java
new file mode 100644
index 000000000000..a9f089bc1cf0
--- /dev/null
+++
b/core/camel-core/src/test/java/org/apache/camel/processor/StepListenerTest.java
@@ -0,0 +1,539 @@
+/*
+ * 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.Map;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+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.CamelContext;
+import org.apache.camel.Channel;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.Processor;
+import org.apache.camel.Route;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.spi.ErrorHandlerAware;
+import org.apache.camel.spi.StepHook;
+import org.apache.camel.spi.StepListener;
+import org.apache.camel.support.AsyncProcessorSupport;
+import org.apache.camel.support.processor.DelegateAsyncProcessor;
+import org.junit.jupiter.api.AfterEach;
+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.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The {@link StepListener}s see each call of the processor of each step: each
attempt of the error handler, the steps
+ * without an error handler, the steps of doTry, doCatch and doFinally, a
multicast and its branches, and callbacks on
+ * another thread. They run with a {@link StepHook}, and a listener that fails
does not change the routing.
+ */
+class StepListenerTest extends ContextTestSupport {
+
+ private final StepListenerRecorder recorder = new StepListenerRecorder();
+ private final FailingListener failing = new FailingListener();
+ private final Map<String, AtomicInteger> hookAsked = new
ConcurrentHashMap<>();
+ private final ExecutorService worker = Executors.newSingleThreadExecutor(r
-> new Thread(r, "StepListenerWorker"));
+ // the async step calls its callback on the worker thread after the
listener saw process return
+ private final CountDownLatch asyncReturned = new CountDownLatch(1);
+ private final CountDownLatch gate = new CountDownLatch(1);
+ private final IllegalStateException forced = new
IllegalStateException("Forced");
+ private final AtomicReference<Object> caught = new AtomicReference<>();
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext context = super.createCamelContext();
+ // the failing listener first, so its failures come before the
recorder is called
+ context.getCamelContextExtension().addStepListener(failing);
+ context.getCamelContextExtension().addStepListener(recorder);
+ context.getCamelContextExtension().setStepHook(this::beforeStep);
+ return context;
+ }
+
+ private long beforeStep(Exchange exchange, String nodeId) {
+ int n = hookAsked.computeIfAbsent(nodeId, k -> new
AtomicInteger()).incrementAndGet();
+ if ("hookedStep".equals(nodeId) && n == 1) {
+ exchange.setException(new IllegalArgumentException("Failed by the
step hook"));
+ }
+ return StepHook.PROCEED;
+ }
+
+ @AfterEach
+ void stopWorker() {
+ gate.countDown();
+ worker.shutdownNow();
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:steps").routeId("steps")
+ .setHeader("greeting",
constant("Hello")).id("setHeader")
+ .process(e ->
e.getMessage().setBody("Bye")).id("process")
+ .to("mock:result").id("toResult");
+
+ AtomicInteger flakyCalls = new AtomicInteger();
+ from("direct:retry").routeId("retry")
+
.errorHandler(defaultErrorHandler().maximumRedeliveries(2).redeliveryDelay(0))
+ .process(e -> {
+ if (flakyCalls.incrementAndGet() <= 2) {
+ e.setException(new
IllegalArgumentException("Flaky " + flakyCalls.get()));
+ }
+ }).id("flaky")
+ .to("mock:retried").id("retried");
+
+ AtomicInteger throwingCalls = new AtomicInteger();
+ from("direct:throwOnce").routeId("throwOnce")
+
.errorHandler(defaultErrorHandler().maximumRedeliveries(1).redeliveryDelay(0))
+ .process(new AsyncProcessorSupport() {
+ @Override
+ public boolean process(Exchange exchange,
AsyncCallback callback) {
+ if (throwingCalls.incrementAndGet() == 1) {
+ throw new IllegalStateException("Thrown
once");
+ }
+ callback.done(true);
+ return true;
+ }
+ }).id("throwsOnce")
+ .to("mock:afterThrowOnce").id("afterThrowOnce");
+
+ AtomicInteger branchCalls = new AtomicInteger();
+ from("direct:multicast").routeId("multicast")
+
.errorHandler(defaultErrorHandler().maximumRedeliveries(1).redeliveryDelay(0))
+ .multicast().id("multicastStep")
+ .process(e -> {
+ if (branchCalls.incrementAndGet() == 1) {
+ e.setException(new
IllegalArgumentException("Failed branch"));
+ }
+ }).id("failingBranch")
+ .to("mock:otherBranch").id("otherBranch")
+ .end()
+ .to("mock:afterMulticast").id("afterMulticast");
+
+ from("direct:noErrorHandler").routeId("noErrorHandler")
+ .errorHandler(noErrorHandler())
+ .to("mock:noEh1").id("noEh1")
+ .to("mock:noEh2").id("noEh2");
+
+ from("direct:try").routeId("try")
+ .doTry().id("doTry")
+ .process(e -> {
+ throw new IllegalArgumentException("Forced in
try");
+ }).id("inTry")
+ .doCatch(IllegalArgumentException.class).id("doCatch")
+ .to("mock:caught").id("inCatch")
+ .doFinally().id("doFinally")
+ .to("mock:finally").id("inFinally")
+ .end()
+ .to("mock:afterTry").id("afterTry");
+
+ from("direct:async").routeId("async")
+ .process(new AsyncProcessorSupport() {
+ @Override
+ public boolean process(Exchange exchange,
AsyncCallback callback) {
+ worker.submit(() -> {
+ try {
+ asyncReturned.await(10,
TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ exchange.getMessage().setBody("Async");
+ callback.done(false);
+ });
+ return false;
+ }
+ }).id("asyncStep")
+ .to("mock:afterAsync").id("afterAsync");
+
+ from("direct:gated").routeId("gated")
+ .process(new AsyncProcessorSupport() {
+ @Override
+ public boolean process(Exchange exchange,
AsyncCallback callback) {
+ worker.submit(() -> {
+ try {
+ gate.await(10, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ callback.done(false);
+ });
+ return false;
+ }
+ }).id("gatedStep")
+ .to("mock:gated").id("toGated");
+
+ from("direct:hooked").routeId("hooked")
+
.errorHandler(defaultErrorHandler().maximumRedeliveries(1).redeliveryDelay(0))
+ .to("mock:hooked").id("hookedStep");
+
+ from("direct:listenerFails").routeId("listenerFails")
+ .process(e ->
e.getMessage().setBody("A")).id("callFails")
+ .process(e ->
e.getMessage().setBody(e.getMessage().getBody(String.class) +
"B")).id("othersFail")
+ .to("mock:listenerFails").id("toListenerFails");
+
+ from("direct:throwUnchanged").routeId("throwUnchanged")
+ .onException(IllegalStateException.class).handled(true)
+ .process(e ->
caught.set(e.getProperty(Exchange.EXCEPTION_CAUGHT))).id("handler")
+ .end()
+ .process(new AsyncProcessorSupport() {
+ @Override
+ public boolean process(Exchange exchange,
AsyncCallback callback) {
+ throw forced;
+ }
+ }).id("throwsUnchanged");
+ }
+ };
+ }
+
+ @Test
+ void testEveryStepIsSeen() throws Exception {
+ getMockEndpoint("mock:result").expectedBodiesReceived("Bye");
+
+ template.sendBody("direct:steps", "Hello");
+
+ assertMockEndpointsSatisfied();
+ assertEquals(List.of(
+ "call steps/setHeader", "callback steps/setHeader true",
"return steps/setHeader true",
+ "call steps/process", "callback steps/process true", "return
steps/process true",
+ "call steps/toResult", "callback steps/toResult true", "return
steps/toResult true"),
+ recorder.of("steps"));
+ }
+
+ @Test
+ void testEachRedeliveryIsSeen() throws Exception {
+ getMockEndpoint("mock:retried").expectedMessageCount(1);
+
+ template.sendBody("direct:retry", "Hello");
+
+ assertMockEndpointsSatisfied();
+ List<String> flaky = new ArrayList<>(recorder.of("retry"));
+ flaky.removeIf(e -> !e.contains("/flaky"));
+ assertEquals(List.of(
+ "call retry/flaky", "callback retry/flaky true", "return
retry/flaky true",
+ "call retry/flaky", "callback retry/flaky true", "return
retry/flaky true",
+ "call retry/flaky", "callback retry/flaky true", "return
retry/flaky true"),
+ flaky);
+ }
+
+ @Test
+ void testProcessorThatThrowsIsSeenAndRedelivered() throws Exception {
+ getMockEndpoint("mock:afterThrowOnce").expectedMessageCount(1);
+
+ template.sendBody("direct:throwOnce", "Hello");
+
+ assertMockEndpointsSatisfied();
+ assertEquals(List.of(
+ "call throwOnce/throwsOnce", "throw throwOnce/throwsOnce
Thrown once",
+ "call throwOnce/throwsOnce", "callback throwOnce/throwsOnce
true", "return throwOnce/throwsOnce true",
+ "call throwOnce/afterThrowOnce", "callback
throwOnce/afterThrowOnce true",
+ "return throwOnce/afterThrowOnce true"),
+ recorder.of("throwOnce"));
+ }
+
+ @Test
+ void testErrorHandlerAwareStepAndItsBranches() throws Exception {
+ getMockEndpoint("mock:otherBranch").expectedMessageCount(1);
+ getMockEndpoint("mock:afterMulticast").expectedMessageCount(1);
+
+ template.sendBody("direct:multicast", "Hello");
+
+ assertMockEndpointsSatisfied();
+ List<String> events = recorder.of("multicast");
+ // the multicast once, and the failing branch once for each attempt of
the error handler that the multicast
+ // wraps its branches in
+ assertEquals(1, count(events, "call multicast/multicastStep"),
events.toString());
+ assertEquals(1, count(events, "callback multicast/multicastStep"),
events.toString());
+ assertEquals(1, count(events, "return multicast/multicastStep"),
events.toString());
+ assertEquals(2, count(events, "call multicast/failingBranch"),
events.toString());
+ assertEquals(2, count(events, "callback multicast/failingBranch"),
events.toString());
+ assertEquals(1, count(events, "call multicast/otherBranch"),
events.toString());
+ assertEquals(1, count(events, "callback multicast/otherBranch"),
events.toString());
+ assertEquals(1, count(events, "call multicast/afterMulticast"),
events.toString());
+ assertTrue(events.indexOf("call multicast/multicastStep") <
events.indexOf("call multicast/failingBranch"),
+ events.toString());
+ assertTrue(events.indexOf("call multicast/otherBranch") <
firstIndex(events, "callback multicast/multicastStep"),
+ events.toString());
+
+ // the wrapper of the multicast is ErrorHandlerAware, and passes the
error handler of the step through
+ Channel channel =
StepListenerRecorder.channels(context.getRoute("multicast")).stream()
+ .filter(c -> c.getNextProcessor() instanceof
MulticastProcessor)
+ .findFirst().orElseThrow();
+ MulticastProcessor multicast = (MulticastProcessor)
channel.getNextProcessor();
+ Processor call = StepListenerRecorder.callOf(channel);
+ assertEquals("ErrorHandlerAwareStepListenerProcessor",
call.getClass().getSimpleName());
+ ErrorHandlerAware wrapper = assertInstanceOf(ErrorHandlerAware.class,
call);
+ Processor errorHandler = multicast.getErrorHandler();
+ assertNotNull(errorHandler);
+ assertSame(errorHandler, wrapper.getErrorHandler());
+ Processor other = e -> {
+ };
+ wrapper.setErrorHandler(other);
+ assertSame(other, multicast.getErrorHandler());
+ wrapper.setErrorHandler(errorHandler);
+ assertSame(errorHandler, multicast.getErrorHandler());
+ }
+
+ /**
+ * The number of events of one kind for one step, such as {@code "callback
multicast/otherBranch"}, whatever their
+ * value.
+ */
+ private static long count(List<String> events, String event) {
+ return events.stream().filter(e -> isEvent(e, event)).count();
+ }
+
+ private static int firstIndex(List<String> events, String event) {
+ for (int i = 0; i < events.size(); i++) {
+ if (isEvent(events.get(i), event)) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private static boolean isEvent(String recorded, String event) {
+ return recorded.equals(event) || recorded.startsWith(event + " ");
+ }
+
+ @Test
+ void testStepsWithoutErrorHandler() throws Exception {
+ getMockEndpoint("mock:noEh2").expectedMessageCount(1);
+
+ template.sendBody("direct:noErrorHandler", "Hello");
+
+ assertMockEndpointsSatisfied();
+ assertEquals(List.of(
+ "call noErrorHandler/noEh1", "callback noErrorHandler/noEh1
true", "return noErrorHandler/noEh1 true",
+ "call noErrorHandler/noEh2", "callback noErrorHandler/noEh2
true", "return noErrorHandler/noEh2 true"),
+ recorder.of("noErrorHandler"));
+ }
+
+ @Test
+ void testStepsOfDoTryDoCatchDoFinally() throws Exception {
+ getMockEndpoint("mock:caught").expectedMessageCount(1);
+ getMockEndpoint("mock:finally").expectedMessageCount(1);
+ getMockEndpoint("mock:afterTry").expectedMessageCount(1);
+
+ template.sendBody("direct:try", "Hello");
+
+ assertMockEndpointsSatisfied();
+ List<String> events = recorder.of("try");
+ // the doTry step itself, which the intercept strategies skip, and
each step inside it
+ for (String step : List.of("doTry", "inTry", "inCatch", "inFinally",
"afterTry")) {
+ assertEquals(1, events.stream().filter(e -> e.equals("call try/" +
step)).count(), step + " in " + events);
+ assertEquals(1, events.stream().filter(e -> e.startsWith("callback
try/" + step + " ")).count(),
+ step + " in " + events);
+ assertEquals(1, events.stream().filter(e -> e.startsWith("return
try/" + step + " ")).count(),
+ step + " in " + events);
+ }
+ assertTrue(events.indexOf("call try/doTry") < events.indexOf("call
try/inTry"), events.toString());
+ assertTrue(events.indexOf("call try/inTry") < events.indexOf("call
try/inCatch"), events.toString());
+ assertTrue(events.indexOf("call try/inCatch") < events.indexOf("call
try/inFinally"), events.toString());
+ assertTrue(events.indexOf("call try/inFinally") < events.indexOf("call
try/afterTry"), events.toString());
+ }
+
+ @Test
+ void testCallbackOnAnotherThread() throws Exception {
+ // the async step calls its callback once the listener saw process
return
+ StepListener onReturn = new StepListener() {
+ @Override
+ public void onStepReturn(Object state, boolean doneSync) {
+ asyncReturned.countDown();
+ }
+ };
+ context.getCamelContextExtension().addStepListener(onReturn);
+ getMockEndpoint("mock:afterAsync").expectedBodiesReceived("Async");
+
+ Object out = template.requestBody("direct:async", "Hello");
+
+ assertEquals("Async", out);
+ assertMockEndpointsSatisfied();
+ List<String> events = recorder.of("async");
+ assertEquals(List.of("call async/asyncStep", "return async/asyncStep
false", "callback async/asyncStep false"),
+ events.subList(0, 3));
+ assertEquals(Thread.currentThread().getName(),
recorder.returnThreads.get("async/asyncStep"));
+ assertEquals("StepListenerWorker",
recorder.callbackThreads.get("async/asyncStep"));
+ }
+
+ @Test
+ void testStepFailedByTheStepHookIsNotACall() throws Exception {
+ getMockEndpoint("mock:hooked").expectedMessageCount(1);
+
+ template.sendBody("direct:hooked", "Hello");
+
+ assertMockEndpointsSatisfied();
+ // the hook is asked for each attempt, and fails the first one before
the processor is called
+ assertEquals(2, hookAsked.get("hookedStep").get());
+ assertEquals(List.of("call hooked/hookedStep", "callback
hooked/hookedStep true", "return hooked/hookedStep true"),
+ recorder.of("hooked"));
+ }
+
+ @Test
+ void testFailingListenerDoesNotChangeTheRouting() throws Exception {
+ getMockEndpoint("mock:listenerFails").expectedBodiesReceived("AB");
+
+ template.sendBody("direct:listenerFails", "Hello");
+
+ assertMockEndpointsSatisfied();
+ // the other listener sees every event
+ assertEquals(List.of(
+ "call listenerFails/callFails", "callback
listenerFails/callFails true",
+ "return listenerFails/callFails true",
+ "call listenerFails/othersFail", "callback
listenerFails/othersFail true",
+ "return listenerFails/othersFail true",
+ "call listenerFails/toListenerFails", "callback
listenerFails/toListenerFails true",
+ "return listenerFails/toListenerFails true"),
+ recorder.of("listenerFails"));
+ // a listener whose onStepCall threw gets no more events for that call
+ assertEquals(List.of("call callFails", "call othersFail", "callback
othersFail", "return othersFail",
+ "call toListenerFails", "callback toListenerFails", "return
toListenerFails"),
+ new ArrayList<>(failing.events));
+ }
+
+ @Test
+ void testExceptionOfTheProcessorIsThrownOnUnchanged() throws Exception {
+ template.sendBody("direct:throwUnchanged", "Hello");
+
+ // the failing listener threw from onStepThrow, and the error handler
got the exception of the processor
+ assertSame(forced, caught.get());
+ assertTrue(failing.events.contains("throw throwsUnchanged"),
failing.events.toString());
+ assertTrue(recorder.of("throwUnchanged").contains("throw
throwUnchanged/throwsUnchanged Forced"),
+ recorder.of("throwUnchanged").toString());
+ }
+
+ @Test
+ void testRemovedListenerGetsTheCallbackOfACallItSawStart() throws
Exception {
+ StepListenerRecorder late = new StepListenerRecorder();
+ CountDownLatch lateCalled = new CountDownLatch(1);
+ // added after late, so late has recorded the return of the call when
the gate is opened
+ StepListener lateCall = new StepListener() {
+ @Override
+ public void onStepReturn(Object state, boolean doneSync) {
+ lateCalled.countDown();
+ }
+ };
+ context.getCamelContextExtension().addStepListener(late);
+ context.getCamelContextExtension().addStepListener(lateCall);
+ getMockEndpoint("mock:gated").expectedMessageCount(2);
+
+ Future<Object> first = template.asyncRequestBody("direct:gated",
"Hello");
+ assertTrue(lateCalled.await(10, TimeUnit.SECONDS));
+ context.getCamelContextExtension().removeStepListener(late);
+ context.getCamelContextExtension().removeStepListener(lateCall);
+
assertFalse(context.getCamelContextExtension().getStepListeners().contains(late));
+ gate.countDown();
+ first.get(10, TimeUnit.SECONDS);
+
+ // the step that started before the removal, and none after it
+ assertEquals(List.of("call gated/gatedStep", "return gated/gatedStep
false", "callback gated/gatedStep false"),
+ late.of("gated"));
+
+ template.requestBody("direct:gated", "Hello");
+ assertMockEndpointsSatisfied();
+ assertEquals(3, late.of("gated").size());
+ }
+
+ @Test
+ void testManyListenersInTheOrderTheyWereAdded() {
+ List<StepListener> listeners =
context.getCamelContextExtension().getStepListeners();
+ assertEquals(List.of(failing, recorder), listeners);
+ // the same instance twice is ignored
+ context.getCamelContextExtension().addStepListener(recorder);
+ assertEquals(List.of(failing, recorder),
context.getCamelContextExtension().getStepListeners());
+ assertThrows(UnsupportedOperationException.class, () ->
listeners.add(recorder));
+ }
+
+ @Test
+ void testTheWrapperIsBelowTheErrorHandlerAroundTheProcessor() {
+ Route route = context.getRoute("steps");
+ List<Channel> channels = StepListenerRecorder.channels(route);
+ assertEquals(3, channels.size());
+ for (Channel channel : channels) {
+ Processor step = channel.getNextProcessor();
+ Processor call = StepListenerRecorder.callOf(channel);
+ assertNotSame(step, call);
+ DelegateAsyncProcessor wrapper =
assertInstanceOf(DelegateAsyncProcessor.class, call);
+ assertEquals("StepListenerProcessor",
wrapper.getClass().getSimpleName());
+ assertSame(step, wrapper.getProcessor());
+ assertEquals(step.toString(), wrapper.toString());
+ }
+ // navigating the route still finds the processors of the steps
+ assertEquals(1, route.filter("toResult").size());
+ assertSame(channels.get(2).getNextProcessor(),
route.filter("toResult").get(0));
+ }
+
+ /**
+ * A listener that fails: in onStepCall at the step callFails, and in the
other methods at the steps othersFail and
+ * throwsUnchanged. It records the events it gets.
+ */
+ static final class FailingListener implements StepListener {
+
+ final Queue<String> events = new ConcurrentLinkedQueue<>();
+
+ @Override
+ public Object onStepCall(Exchange exchange, String routeId, String
nodeId) {
+ events.add("call " + nodeId);
+ if ("callFails".equals(nodeId)) {
+ throw new IllegalStateException("onStepCall failed");
+ }
+ return nodeId;
+ }
+
+ @Override
+ public void onStepReturn(Object state, boolean doneSync) {
+ events.add("return " + state);
+ failAt(state);
+ }
+
+ @Override
+ public void onStepThrow(Object state, Throwable cause) {
+ events.add("throw " + state);
+ failAt(state);
+ }
+
+ @Override
+ public void onStepCallback(Object state, boolean doneSync) {
+ events.add("callback " + state);
+ failAt(state);
+ }
+
+ private static void failAt(Object state) {
+ if ("othersFail".equals(state) || "throwsUnchanged".equals(state))
{
+ throw new IllegalStateException("Listener failed at " + state);
+ }
+ }
+ }
+}
diff --git
a/core/camel-management/src/test/java/org/apache/camel/management/ManagedStepListenerTest.java
b/core/camel-management/src/test/java/org/apache/camel/management/ManagedStepListenerTest.java
new file mode 100644
index 000000000000..1dcbf901c992
--- /dev/null
+++
b/core/camel-management/src/test/java/org/apache/camel/management/ManagedStepListenerTest.java
@@ -0,0 +1,171 @@
+/*
+ * 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.management;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import javax.management.MBeanServer;
+import javax.management.ObjectName;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Channel;
+import org.apache.camel.Exchange;
+import org.apache.camel.Navigate;
+import org.apache.camel.Processor;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.processor.errorhandler.RedeliveryErrorHandler;
+import org.apache.camel.spi.BacklogDebugger;
+import org.apache.camel.spi.StepHook;
+import org.apache.camel.spi.StepListener;
+import org.apache.camel.support.processor.DelegateAsyncProcessor;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+
+import static
org.apache.camel.management.DefaultManagementObjectNameStrategy.TYPE_PROCESSOR;
+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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A {@link StepListener} runs together with a {@link StepHook} (as
camel-chaos uses), JMX, the tracer and the backlog
+ * debugger, and each still works: a step that the hook fails is not a call
the listener sees, JMX counts each call, the
+ * tracer traces and the debugger stops at its breakpoint before the listener
sees the step.
+ */
+@DisabledOnOs(OS.AIX)
+class ManagedStepListenerTest extends ManagementTestSupport {
+
+ private final Queue<String> events = new ConcurrentLinkedQueue<>();
+ private final AtomicInteger hookAsked = new AtomicInteger();
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext context = super.createCamelContext();
+ context.getCamelContextExtension().addStepListener(new StepListener() {
+ @Override
+ public Object onStepCall(Exchange exchange, String routeId, String
nodeId) {
+ events.add("call " + nodeId);
+ return nodeId;
+ }
+
+ @Override
+ public void onStepCallback(Object state, boolean doneSync) {
+ events.add("callback " + state);
+ }
+ });
+ context.getCamelContextExtension().setStepHook(this::beforeStep);
+ return context;
+ }
+
+ private long beforeStep(Exchange exchange, String nodeId) {
+ if ("flaky".equals(nodeId) && hookAsked.incrementAndGet() == 1) {
+ exchange.setException(new IllegalArgumentException("Failed by the
step hook"));
+ }
+ return StepHook.PROCEED;
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ context.setTracing(true);
+ context.setDebugging(true);
+
+ from("direct:combo").routeId("combo")
+
.errorHandler(defaultErrorHandler().maximumRedeliveries(2).redeliveryDelay(0).logStackTrace(false))
+ .to("mock:flaky").id("flaky")
+ .to("mock:bar").id("bar")
+ .to("mock:result").id("result");
+ }
+ };
+ }
+
+ @Test
+ void testTogetherWithStepHookJmxTracerAndDebugger() throws Exception {
+ // with JMX and an error handler that can redeliver: error handler,
JMX instrumentation, the step hook
+ // processor, the listener wrapper, the processor
+ Channel channel = findChannel(context.getRoute("combo").navigate(),
"flaky");
+ assertNotNull(channel);
+ RedeliveryErrorHandler errorHandler =
assertInstanceOf(RedeliveryErrorHandler.class, channel.getErrorHandler());
+ DelegateAsyncProcessor instrumentation
+ = assertInstanceOf(DefaultInstrumentationProcessor.class,
errorHandler.getOutput());
+ DelegateAsyncProcessor hook =
assertInstanceOf(DelegateAsyncProcessor.class, instrumentation.getProcessor());
+ assertEquals("StepHookProcessor", hook.getClass().getSimpleName());
+ DelegateAsyncProcessor wrapper =
assertInstanceOf(DelegateAsyncProcessor.class, hook.getProcessor());
+ assertEquals("StepListenerProcessor",
wrapper.getClass().getSimpleName());
+ assertSame(channel.getNextProcessor(), wrapper.getProcessor());
+
+ BacklogDebugger debugger = context.hasService(BacklogDebugger.class);
+ assertNotNull(debugger);
+ debugger.enableDebugger();
+ debugger.addBreakpoint("bar");
+ getMockEndpoint("mock:result").expectedMessageCount(1);
+
+ Future<Object> out = template.asyncRequestBody("direct:combo",
"Hello");
+
+ // the debugger stops before the step bar, so the listener has not
seen it yet
+ await().atMost(10, TimeUnit.SECONDS).until(() ->
debugger.getSuspendedBreakpointNodeIds().contains("bar"));
+ assertFalse(events.contains("call bar"), events.toString());
+ debugger.resumeBreakpoint("bar");
+ assertEquals("Hello", out.get(10, TimeUnit.SECONDS));
+ assertMockEndpointsSatisfied();
+
+ // the hook failed the first attempt of flaky before its processor was
called, so the listener sees one call
+ assertEquals(2, hookAsked.get());
+ assertEquals(List.of("call flaky", "callback flaky", "call bar",
"callback bar", "call result",
+ "callback result"), new ArrayList<>(events));
+
+ // JMX counts the attempt that the hook failed as a failure of the step
+ MBeanServer mbeanServer = getMBeanServer();
+ ObjectName on = getCamelObjectName(TYPE_PROCESSOR, "flaky");
+ assertEquals(1L, mbeanServer.getAttribute(on, "ExchangesFailed"));
+ assertEquals(1L, mbeanServer.getAttribute(on, "ExchangesCompleted"));
+ on = getCamelObjectName(TYPE_PROCESSOR, "result");
+ assertEquals(1L, mbeanServer.getAttribute(on, "ExchangesCompleted"));
+
+ // the tracer traced the steps
+ assertTrue(context.getTracer().getTraceCounter() > 0);
+ }
+
+ private Channel findChannel(Object node, String nodeId) {
+ if (node instanceof Channel channel) {
+ Processor step = context.getRoute("combo").filter(nodeId).get(0);
+ if (channel.getNextProcessor() == step) {
+ return channel;
+ }
+ } else if (node instanceof Navigate<?> navigate && navigate.hasNext())
{
+ for (Object child : navigate.next()) {
+ Channel found = findChannel(child, nodeId);
+ if (found != null) {
+ return found;
+ }
+ }
+ }
+ return null;
+ }
+}
diff --git a/design/exchange-sanitizer.adoc b/design/exchange-sanitizer.adoc
index 318c57289f64..660871616c78 100644
--- a/design/exchange-sanitizer.adoc
+++ b/design/exchange-sanitizer.adoc
@@ -424,7 +424,7 @@ target, Predicate<Synchronization> filter)`), as today only
`DefaultUnitOfWork`
Rules for listeners, in the Javadoc:
* Many can be installed, each kind in a copy-on-write array on the context
extension (the getter returns a cached
- unmodifiable view, so a call site checks `isEmpty()` without copying; the
same instance twice is ignored). They are
+ unmodifiable view, so a call site checks `isEmpty()` without copying; an
equal listener is ignored). They are
called in the order they were added, on the thread of the event, by many
threads at once: they must be thread-safe
and quick (the R3 pauses are a deliberate exception of the sanitizer), and
must not depend on the order.
* A call of a step reads the listeners when it starts and keeps them for its
callback, so a listener removed meanwhile