allthingssecurity commented on code in PR #29296:
URL: https://github.com/apache/camel/pull/29296#discussion_r4490287154
##########
design/exchange-sanitizer.adoc:
##########
@@ -0,0 +1,779 @@
+---
+title: Exchange sanitizer
+authors:
+ - "@allthingssecurity"
+reviewers: ["@davsclaus"]
+approvers: []
+creation-date: 2026-11-05
+last-updated: 2026-11-06
+status: proposed
+see-also:
+ - "CAMEL-26630"
+ - "CAMEL-26338"
+ - "CAMEL-26330"
+ - "CAMEL-25095"
+ - "CAMEL-25122"
+ - "CAMEL-26464"
+ - "CAMEL-26576"
+replaces: []
+superseded-by: []
+---
+
+== Summary
+
+The exchange sanitizer is a checker for development and tests. It watches the
exchanges of a running Camel application
+and reports, when it happens, that the contracts of `AsyncProcessor` and of on
completions are broken: a
+callback called twice, a processor that throws instead of calling its
callback, a consumer's commit that runs twice,
+is taken by the wrong exchange or never runs, and an exchange used by two
threads at the same time. It also lists the
+tasks an executor drops when it is shut down. The name comes from the
sanitizers of other runtimes (ThreadSanitizer,
+the kernel's KCSAN): cheap bookkeeping at a few points, one simple rule
checked at each of them, and a report with the
+stacks that matter.
+
+It checks the code of Camel users as much as Camel's own: a custom
`AsyncProcessor`, a custom component (a consumer
+that commits with an on completion, a producer that calls its callback from
another thread), an `AggregationStrategy`,
+a bean or a route policy. A developer adds the test dependency and one
annotation to the tests they already have, and
+a test that runs a broken path fails with a report that names their class and
the step. It checks the paths the tests
+run; it does not find untested ones. Authors of components outside Apache
Camel get the same check on their own test
+suites.
+
+It is switched on like `camel-chaos`: with its JAR and profile `dev`, or
`camel.sanitizer.enabled=true`, and never with
+profile `prod`. The test dependency and two system properties switch it on for
an existing test suite, so a CI
+pipeline can run its tests with it.
+
+It needs two small listener SPIs in camel-api, one for the calls of the steps
of a route and one for the on
+completions of an exchange. More than one listener can be installed, and they
are separate from the `StepHook` that
+chaos uses and from the advices of the debugger, the tracer and JMX, so all of
them can run together. With no listener
+installed, the routes are built as today and a step costs nothing more.
+
+Everything in this document is design. Nothing is built yet.
+
+== Motivation
+
+Several of the concurrency bugs found in Camel this year share one property:
when they hit, nothing fails at the place
+of the bug, and nothing in the log says which rule was broken. At most a WARN
appears elsewhere: the reactive executor
+logs an exception it ignores, or a request timeout is logged. The exchange
completes twice and fails later far from the
+cause, or it never completes, or the consumer commits a message that failed.
+
+The strongest case is an exchange completed twice: thread A completes it, and
thread B calls the same callback again.
+
+* CAMEL-25095 (camel-jms, fixed in 4.23.0): InOut. When the send fails after
the request timeout or the reply already
+ completed the exchange, the send failure completes it a second time.
+* CAMEL-25122 (camel-sjms, fixed in 4.23.0): InOut. When the send fails, the
reply handler stays registered, and the
+ request timeout completes the exchange a second time.
+* CAMEL-26446 (camel-core, #29040): with the `RedeliveryErrorHandler`, a
processor that throws on the first delivery
+ skipped redelivery, the dead letter channel and `onException`, and the
exchange could be continued twice.
+
+A second call of a callback continues an exchange that another thread already
continued, so the symptom shows up
+wherever the two paths meet, not where the second call was made.
+
+The other cases: a processor that throws instead of calling its callback, so
the exchange never completes, and the
+consumer's commit with a shared unit of work:
+
+[cols="2,3,1"]
+|===
+|Ticket |What broke |Rule
+
+|CAMEL-26430 (#28785), CAMEL-26445 (#28808)
+|An `AsyncProcessor` that throws from `process(exchange, callback)` under the
`RedeliveryErrorHandler`, on a
+redelivery or with no redelivery: the exchange hangs.
+|R1
+
+|CAMEL-26461 (#29041), CAMEL-26464 (#29127)
+|The same under `noErrorHandler()` and in the steps of `doTry` and
`onException`.
+|R1
+
+|CAMEL-26601 (in progress)
+|The same for a recipient of a recipient list inside `doTry`. A recipient is
not a step, so the sanitizer sees the
+recipient list step that never completes.
+|R1 (pending call)
+
+|CAMEL-26575 (#29200, open)
+|An `AggregationStrategy` whose `onCompletion` throws in a multicast or split:
the exchange hangs.
+|R1 (pending call)
+
+|CAMEL-26569, CAMEL-26576 (#29203, open)
+|Parallel `shareUnitOfWork`: the handover of the on completions fails with a
`ConcurrentModificationException`, and a
+part sent to `seda` takes the consumer's commit, which then runs with the
outcome of the seda copy.
+|R2
+
+|CAMEL-26578 (#29194)
+|A `ProducerTemplate` stopped while async requests are queued: their futures
never complete.
+|R4 (listed only)
+|===
+
+CAMEL-26575, CAMEL-26601, CAMEL-26569 and CAMEL-26576 are not merged yet; the
others are.
+
+These are Camel's own bugs, but user code breaks the same contracts in the
same way: a custom `AsyncProcessor` that
+throws before it calls its callback, or calls it from a timeout thread and
again from a reply thread; a custom consumer
+whose commit runs twice; an `AggregationStrategy` that throws. Today a user
who hits one of these sees a hung request,
+a file that never moves, or a message processed twice, and has no way to tell
where it comes from. The sanitizer names
+the exchange, the route, the step, the class and the threads, with the stacks.
+
+== What Camel has today
+
+* The `InflightRepository` and the `inflight` dev console (`camel get
inflight`) show every exchange in flight, how
+ long it has been there and at which node. They answer "is something stuck",
not "why", and they say nothing about an
+ exchange completed twice.
+* The shutdown strategy logs the exchanges still in flight when it gives up
waiting.
+* `EventNotifier` publishes exchange created, completed and failed events.
+* Since CAMEL-26464, the channel of a step without an error handler logs a
WARN when the callback of its processor is
+ called when it is no longer awaited
(`CamelInternalProcessor.AsyncAfterTask.done`). That covers one kind of channel,
+ and the log has no stack of the first call.
+* `StepHook` (CAMEL-26338), asked by the error handler of each step (or by its
channel when there is no such error
+ handler) before it calls the processor of the step, outside the
`InterceptStrategy` wrappers, can delay or fail the
+ step. There is one per `CamelContext`.
+
+The sanitizer builds on these and does not replace them.
+
+== Goals
+
+* Report the broken rules below when they break (for a commit that never runs
or a pending call, when that is
+ known), with the exchange, route, step, class, threads and stacks.
+* Check user code (processors, components, aggregation strategies, beans,
route policies) as well as Camel's, in the
+ tests users already have, with no change to the tests other than the
annotation; on the paths those tests run.
+* On in development (profile `dev`) and in tests, never in production.
+* A JUnit extension that fails the test on the first violation, and a way to
run an existing test suite with the
+ sanitizer on (a system property for any build, a Maven profile in the Camel
build).
+* Quiet on correct code: no report on the camel-core test suite and the test
suites of the async components on main,
+ except in the tests that break a rule on purpose, which say so (see Using
it).
+* No cost per step when it is not switched on. Per on completion added, handed
over or run, one check of an empty
+ list of listeners, measured with JMH before it is merged.
+
+== Non-goals
+
+* Production use. It is a development and test tool, and stays off with
profile `prod`.
+* Pooled exchanges (`exchangeFactory=pooled`). They are deprecated since 4.23,
so the sanitizer does not support them:
+ it stays off and logs a WARN when they are used. CAMEL-26579 (#29195), a
pooled-exchange bug, is out of scope for
+ that reason.
+* A general data race detector for user code. R3 watches the exchange as the
routing engine uses it, not the fields
+ of user objects.
+* A component that answers its own client twice, outside Camel's callbacks,
such as CAMEL-25247 (camel-thrift: the
+ Camel callback was called once, and Thrift's own `AsyncMethodCallback`
twice). The sanitizer does not see it.
+* Changing what Camel does. The sanitizer only reports; it does not stop a
second callback or complete a hung exchange.
+ The one exception is the sampling of R3, which pauses a thread for a short
time on purpose (see R3).
+* MCP tools in the first version. The dev console and `camel get sanitizer`
are enough to read the report, and the
+ camel-jbang MCP server should not grow until it has tool profiles
(CAMEL-25537).
+* The models and the scripts used to find the bugs above. They stay on the
prototype branch.
+
+== The rules
+
+Each rule is something the routing engine relies on. The Javadoc of
`AsyncProcessor` and `AsyncCallback` states part of
+it: the callback is called when the exchange is done, and synchronously when
`process` returns `true`. That it is called
+exactly once, that `process` does not throw (it sets the exception and calls
the callback), that the callback's
+`doneSync` equals the return value, and the rules of R2 for on completions are
not written down today; the 4.24 upgrade
+guide only says "as an `AsyncProcessor` must". The first pull request adds
them to the Javadoc of `AsyncProcessor`,
+`AsyncCallback` and `Synchronization`, so a report can point at them. A
violation is a bug in the code that broke the
+rule, whether that is Camel's or the user's. The rules are checked where the
routing engine already passes: the call of
+the processor of each step, the callback of that call, and the on completions
of the exchange.
+
+=== R1: the callback of a step is called exactly once
+
+A step is any node of a route with its node id (`to`, `process`, `split`,
...), the same steps the `StepHook` sees. Each
+time the processor of a step is called (each attempt, also each redelivery),
its callback must be called exactly once,
+and `process` must not throw. R1 reports:
+
+. *Completed twice.* The callback of a call is called a second time. The
report is logged at the second call, before
+ that call continues the exchange. It has the stack and thread of the first
call of the callback, kept from when it
+ happened, and the stack and thread of the second call.
+. *Threw before calling the callback.* `process(exchange, callback)` throws
and the callback has not been called. Since
+ the fixes above Camel catches this in most places and fails the exchange,
but the processor still breaks the
+ contract of `AsyncProcessor`, and on older Camel versions the exchange
hangs. The report names the step, the
+ processor and the exception. A processor that throws after it called its
callback is reported too.
+. *Callback after the processor threw.* The callback comes after `process`
threw. Camel then schedules a takeover that
+ fails the exchange with the exception, and whichever of the two runs first
continues the exchange; the other is
+ ignored (the takeover) or, before the fixes, continued it a second time
(CAMEL-26446). The listener cannot see which
+ won, so this is its own kind, not "completed twice".
+. *Still pending.* A call whose callback has not come after
`camel.sanitizer.pendingTimeout`. This is not a violation,
+ as a slow endpoint looks the same; it is a warning that adds to the
`inflight` console what it cannot know: which
+ step's callback the exchange waits for, and whether that step threw. Only
the innermost pending call of an exchange
+ is reported, timed from the last call or callback of that exchange, so a
long split or a `to("direct:...")` that
+ waits for its children is not. When the context stops, the calls still
pending are listed.
+
+The check is at the processor of a step. A processor that is not a step, such
as the producer of a recipient of a
+recipient list (CAMEL-26601), is seen through its step: the recipient list
that does not complete.
+
+An example. Route
`from("direct:start").to("jms:queue:req?requestTimeout=500")`, with a broker
that stops answering a
+send. An exchange calls the `to1` step. After 500 ms the request timeout
completes it on a thread of the reply
+manager's timeout pool, and the route goes on. Then the send fails on the
caller thread, and `JmsProducer.process`
+calls the callback again (CAMEL-25095 before its fix). At that second call the
sanitizer logs a report like this
+(illustrative; the format is decided with the prototype):
+
+[source,text]
+----
+Exchange sanitizer: callback called twice (R1)
+ exchange <id>, route route1, step to1 (jms://queue:req)
+ first call: thread ... JmsReplyManagerOnTimeout[req], at
ReplyManagerSupport.processReply(...) ...
+ second call: thread main, at JmsProducer.process(...) ...
+----
+
+Today the same run logs the timeout, and the second continuation shows later,
wherever the two paths meet.
+
+=== R2: each consumer commit runs exactly once, for the unit of work that owns
it
+
+*Commit* here means the consumer's on completion: a `Synchronization` that the
consumer adds to the exchange before it
+routes it (`exchange.getExchangeExtension().addOnCompletion(...)`), to commit
or roll back its message when the
+exchange is done. The file and FTP consumers do this to move or delete the
file, as do other consumers, Camel's and
+third-party ones. A commit is not `UnitOfWork.done`: the unit of work is done
once for each exchange, while a commit
+can move:
+
+* it is handed over to a copy that is routed on its own, such as an `InOnly`
exchange sent to `seda`
+ (`ExchangeHelper.createCorrelatedCopy` with handover); the copy then holds
it, and it runs when the copy is done, with
+ the outcome of the copy;
+* with `shareUnitOfWork`, the parts of a split, multicast or recipient list
use the unit of work of the parent, so the
+ commit stays in that unit of work and runs when the parent is done.
+
+So the owner of a commit is not an exchange but the place it is in: the unit
of work it was added to, or, before an
+exchange has a unit of work, the exchange's own list. Each unit of work was
created for one exchange (the
+`DefaultUnitOfWork` constructor takes it), and other exchanges may share it.
+
+In the code, the commits of an exchange are the on completions it holds when
it gets a unit of work that was created
+for it (they move into the unit of work in `setUnitOfWork`). On completions
added later, such as the tracer's or the
+one that deletes a stream cache spool file, are not commits. The prototype
checks this against the consumers of the
+test runs.
+
+The rule: each commit runs `onComplete` or `onFailure` exactly once, in the
place that holds it at the end, and it
+moves only by a handover made by the exchange its unit of work was created
for. R2 reports:
+
+. a commit that runs a second time;
+. a commit that is handed over through a unit of work by an exchange that it
was not created for, such as a part that
+ shares the unit of work of its parent and sends to `seda` (CAMEL-26576).
This is reported at the handover, before the
+ commit runs with the wrong outcome;
+. two handovers through the same unit of work from two threads that overlap in
time, seen with a watch window on the
+ unit of work, as R3 does for exchanges (CAMEL-26569);
+. a commit that never runs: the exchange its unit of work was created for is
done (its completed or failed event), and
+ the commit was neither run, handed over nor taken; or the context stops
while a commit waits in a copy that was never
+ routed (such as a `seda` queue that is dropped).
+
+The records are per add, not per `Synchronization` instance: one instance can
be on many exchanges (a consumer may add
+the same object to every exchange), and can be taken and added again. On
completions that are taken out as a list
+(`handoverCompletions()` without a target, used by the aggregator, the
enrichers and `onCompletion`) and run by
+another exchange are followed for "runs once" only.
+
+R2 covers consumers that commit with an on completion. A consumer that commits
in its own code after the callback
+(such as JMS with a transacted session) is covered by R1 only.
+
+=== R3: one thread at a time
+
+An exchange can move between threads, but it is never used by two threads at
the same time. Camel has no single API
+for the moment it moves: an asynchronous processor returns `false` from
`process`, and each component hands the
+exchange to its own thread in its own way. So R3 does not look for a handover.
It checks two things that hold whatever
+the threads are:
+
+. *The contract of `AsyncProcessor`* at the call of each step: the value the
callback is called with (`doneSync`)
+ equals the value `process` returns. For a caller that uses the return value,
a processor that returns `true` while
+ its callback was called with `false` on another thread lets two threads
continue the same exchange, and one that
+ returns `true` before its callback was called leaves the exchange with no
thread. Many callers in core continue
+ through the callback and ignore the return value (the `Pipeline`, the
`RedeliveryErrorHandler`), so there a mismatch
+ is harmless today; it still breaks the contract, and the same processor
breaks under a caller that uses it. (The
+ channel itself always continues the exchange through the reactive executor,
so its own `doneSync` says nothing; R3
+ checks the call below the error handler, which is the processor's own call
unless a strategy is in between.)
+ Whether every processor of the core EIPs keeps this today is the first thing
the camel-core run shows; a processor
+ that does not is fixed with its own JIRA, or the rule is narrowed with the
reason written down, before R3 is
+ proposed.
+. *Accesses that overlap in time*, as KCSAN does in the Linux kernel. The
routing engine uses the exchange at known
+ points: a step starts, its callback continues the exchange, an on completion
runs. At a small sample of these points
+ the sanitizer opens a short watch window on the exchange, during which that
thread pauses. If another thread uses
+ the same exchange at one of these points during the window, both are
reported with their stacks. A thread that is
+ only waiting (such as the caller of a circuit breaker that waits for its
task) uses nothing, so it is not reported.
+ The return of `process` is not such a point: after an asynchronous processor
handed the exchange on, its caller
+ only unwinds, and the thread that got the exchange may already call the
callback.
+
+The pause makes a rare overlap more likely, which is the point, but it changes
the timing of the application, and a
+paused I/O thread (such as a Netty event loop) serves nothing else while it
waits. The sampling is therefore off by
+default in profile `dev` and on in the JUnit extension and the CI switch,
where it should run on the test suites of the
+async components (netty, netty-http, vertx-http, JMS request/reply), not only
on camel-core. A test that fails only
+because of the pauses (a short timeout, a throttler) fails with its own
assertion, not with a sanitizer report, so the
+two can be told apart; such a test class turns the sampling off for itself
(see Using it). The pauses and
+`pendingTimeout` use the system clock, not `TimeSource`, so a virtual clock of
camel-test-dst does not stretch them.
+
+=== R4: tasks dropped at shutdown
+
+When an executor of the `ExecutorServiceManager` is shut down, `shutdownNow`
returns the tasks it never ran. R4 lists
+them in the report, with the executor and its source, at WARN. It does not
report them as violations, because the
+sanitizer cannot tell whether someone waits for their result. CAMEL-26578
shows why: before its fix the template
+submitted a lambda, so `shutdownNow` returned the `FutureTask` of the
executor, while the caller waited on another
+`CompletableFuture`; since the fix the template runs its own task class and
completes the caller's future itself when
+it stops. In both cases the list shows what was dropped, and a person can see
whether a caller was left waiting.
+
+R4 is the least important rule and comes last in the order of work.
+
+== Checking your own processors and components
+
+The rules are what every `AsyncProcessor`, `AsyncCallback` and
`Synchronization` must keep, so the sanitizer checks a
+user's code the same way it checks Camel's. Take a custom processor that calls
a slow service and has a timeout of
+its own:
+
+[source,java]
+----
+public class PriceLookup extends AsyncProcessorSupport {
+ public boolean process(Exchange exchange, AsyncCallback callback) {
+ ScheduledFuture<?> timeout = scheduler.schedule(() -> {
+ exchange.setException(new TimeoutException());
+ callback.done(false);
+ }, 2, SECONDS);
+ client.lookup(exchange.getMessage().getBody(String.class))
+ .whenComplete((price, error) -> {
+ timeout.cancel(false); // too late if the timeout
already ran
+ ...
+ callback.done(false);
+ });
+ return false;
+ }
+}
+----
+
+When the service answers after the timeout, the callback is called twice: in
production a message continues twice when
+the service is slow. A test whose stub answers after 3 s, and that waits for
that answer, fails with the R1 report: step
+`process1`, class `PriceLookup` (the report looks through any delegate Camel
may put around a processor, such as the
+`DelegateAsyncProcessor` that `.process(...)` uses for an `AsyncProcessor`
that is not a `Service`), the stack of the
+timer thread that completed first and of the reply thread that completed
second. A test whose stub always answers at
+once does not run that path, and the sanitizer cannot report it. A processor
that throws from `process` instead of
+calling the callback fails any test that runs it with "threw before calling
the callback", naming the class and the
+exception.
+
+What the user adds to the test they already have is the test dependency and
one annotation:
+
+[source,java]
+----
+@CamelSanitizer // the only change
+class PriceRouteTest extends CamelTestSupport {
+ ...
+}
+----
+
+or, for a whole suite in CI, the test dependency and two system properties,
with no change to the tests (see Using it).
+The same holds for a custom component: a consumer whose commit runs twice or
never fails R2, a producer that calls its
+callback from two threads fails R1, and one that returns `true` from `process`
after handing the callback to another
+thread fails R3. Component authors outside Apache Camel get the same check by
adding the extension to their own test
+suites.
+
+The report points at the class that broke the rule, and says in one line what
the usual fix is: for "completed
+twice", let only the first path call the callback (a `compareAndSet` on the
outcome); for "threw before calling the
+callback", catch, set the exception on the exchange and call the callback; for
a `doneSync` mismatch, return what the
+callback was called with. It does not change the code. When the test also
fails on its own, the sanitizer's failure is
+added to it as a suppressed exception, so neither is lost.
+
+== Proposal
+
+=== The listener SPIs
+
+Two new SPIs in camel-api (`@since 4.24`, or the release they land in), one
per kind of event, because they are called
+from different places at different times: a listener for steps only should not
make every exchange report its on
+completions. A listener only observes: it cannot change the exchange or the
routing. All methods have an empty default.
+
+[source,java]
+----
+public interface StepListener {
+
+ // the processor of a step is about to be called; the returned state is
passed to the methods below for this call
+ default @Nullable Object onStepCall(Exchange exchange, String routeId,
String nodeId) { return null; }
+
+ // process(exchange, callback) returned
+ default void onStepReturn(@Nullable Object state, boolean doneSync) { }
+
+ // process(exchange, callback) threw
+ default void onStepThrow(@Nullable Object state, Throwable cause) { }
+
+ // the callback of this call is called (each time it is called), before it
continues the exchange
+ default void onStepCallback(@Nullable Object state, boolean doneSync) { }
+}
+
+public interface OnCompletionListener {
+
+ // an exchange gets a unit of work; moved are the on completions it held
until then
+ default void onUnitOfWork(Exchange exchange, UnitOfWork unitOfWork,
List<Synchronization> moved) { }
+
+ // an on completion is added to a unit of work
+ default void onCompletionAdded(UnitOfWork unitOfWork, Synchronization
onCompletion) { }
+
+ // on completions are handed over from one exchange (and its unit of work,
if any) to another exchange
+ default void onCompletionHandover(
+ Exchange from, @Nullable UnitOfWork fromUnitOfWork, Exchange to,
List<Synchronization> moved) { }
+
+ // on completions are taken out of an exchange as a list, to be run by
someone else
+ default void onCompletionTaken(Exchange from, List<Synchronization> taken)
{ }
+
+ // an on completion is about to run onComplete (failure false) or
onFailure (failure true)
+ default void onCompletionRun(Exchange exchange, Synchronization
onCompletion, boolean failure) { }
+}
+----
+
+The state of a step call works as the state of a
`CamelInternalProcessorAdvice`: `onStepCall` returns it, and the other
+step methods get it back (one slot per listener). It is held by the wrapped
callback of the call, so a listener keeps
+its record of the call there, and the record lives as long as anything can
still call the callback. That is how the
+sanitizer keeps the stack of the first call of a callback for a second call
that comes much later, without a table to
+clean up. The state holds ids, names, threads and stacks, never the exchange,
so a callback that a broken component
+keeps forever does not keep the exchange and its body alive.
+
+Only `onStepCall`, and the on completion methods on the thread that makes the
change, may read the exchange. The other
+step methods run on threads that may not own the exchange (even its id is
created on first use, without
+synchronization), so they get only the state; `onStepCall` gets the route id
from the channel for the same reason. The
+names do not reuse `StepHook.beforeStep`, so one class can implement both.
+
+Exchange created, completed and failed come from the `EventNotifier` that
exists, so they are not in the listeners.
+
+They are installed through `ExtendedCamelContext`, as the `StepHook` is, with
default methods: `addStepListener`,
+`removeStepListener`, `getStepListeners`, and the same for
`OnCompletionListener`. The step methods come first (step 1
+of the order of work); the on completion listener comes with R2 (step 3).
+
+The API changes in short: the two interfaces; the six methods on
`ExtendedCamelContext`; and, for R2, a new default
+method on `UnitOfWork` that hands over and returns what moved
(`handoverSynchronization(Exchange from, Exchange
+target, Predicate<Synchronization> filter)`), as today only
`DefaultUnitOfWork` knows which on completions a
+`SynchronizationVetoable` kept, and only the exchange knows that it is the one
handing over.
+
+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
+ 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
+ still gets the callback of a call it saw start. The on completion places
read them at each event.
+* A listener can never change the routing, also when it fails. Each call of a
listener is in its own `try` with a
+ `catch (Throwable)`, logged at WARN once per listener. The processor is
called whatever `onStepCall` did, the real
+ callback is called in a `finally`, the processor's own exception is thrown
on unchanged, and a listener whose
+ `onStepCall` threw gets no more events for that call. So a failing listener
cannot look like a failing processor to
+ the error handler, which would take over and continue the exchange a second
time.
+* Step listeners are looked at when a route is created, so they are added
before (`onContextInitializing`, as chaos
+ does); a route created while none was installed is not seen until it is
created again (as a reload in dev mode
+ does). Switching it on in a running application is an open question (a
standby flag, see Open questions).
+
+=== Where they are called
+
+*Steps.* When a route is created and at least one step listener is installed,
`DefaultChannel` (only it, not channels
+of a custom `InternalProcessorFactory`) adds one small
`DelegateAsyncProcessor` that calls the listeners. It is added in
+`initChannel` after the `InterceptStrategy` loop, around what the loop
returns, just before that becomes the output of
+the channel. So:
+
+* The strategies run exactly as today. Each gets the processor of the step as
its `target`, so a strategy that checks
+ the type of its `target` (as camel-opentelemetry's does) is not changed by
the sanitizer. The `next` they get and the
+ `canIntercept()` skip check are not touched.
+* The error handler of the step wraps the output of the channel, which is now
the wrapper. It calls the wrapper for each
+ attempt, also each redelivery, and the channel calls it for a step without
such an error handler. With JMX and
+ redelivery, the instrumentation and the `StepHook` processor are put around
the output of the error handler in
+ `postInitChannel`, so the order is: error handler, JMX instrumentation, the
`StepHook` processor, the listener
+ wrapper, the interceptor wrappers, the processor. In every case the
`StepHook` is asked before the wrapper is
+ called, so a step that the hook fails is not a call the listeners see.
+* The wrapper is outside the strategy loop, so it also wraps the `doTry`,
`doCatch` and `doFinally` steps, which the
+ strategies skip: `TryProcessor`, `CatchProcessor` and `FinallyProcessor`
return `false` from `canIntercept()`. The
+ steps inside
+ `doTry`, `doCatch` and `doFinally` have their own channels, so the
strategies wrap them today, and so does the
+ listener wrapper.
+* Code that unwraps processors (`Navigate`) still finds the processor, and
`Channel.getNextProcessor()` still returns
+ it, so the debugger and the `DisabledAware` check are unchanged.
+
+The wrapper reads the listeners, calls `onStepCall`, wraps the callback once
per call, calls what it wraps, and then
+`onStepReturn` or `onStepThrow`; the wrapped callback calls `onStepCallback`
and then the real callback. The
+transacted path calls `process(exchange)` of the same wrapper, through the
await manager.
+
+The wrapper is given the processor of the step, so a report names it, and
lists the strategy wrappers in between when
+there are any. What the listeners see is the call of that chain. With no
strategy, that is the processor's own call.
+The strategies of the tracing components (camel-telemetry,
camel-opentelemetry2, camel-micrometer-observability,
+camel-jfr) and of camel-mdc pass the call and its callback through, so nothing
is lost. Some strategies hide part of
+the processor's call:
+
+* `intercept()` wraps the processor in a pipeline with the intercept route
before it. The pipeline returns `false` and
+ calls the processor later on the reactive executor, so the listeners do not
see the processor's own return value or
+ what it throws. The steps of the intercept route are seen as their own calls.
+* A strategy that returns a plain `Processor`, as the deprecated
camel-opentelemetry can, makes the call of an
+ asynchronous processor wait in the await manager. A second callback of that
processor then reaches the await
+ manager, not the listeners, and is not seen. The same holds for a strategy
that catches what the processor throws.
+
+Some camel-core tests install such strategies (a plain `DelegateProcessor`).
There R1 and R3 check the call of the
+chain, and the quiet run on camel-core shows whether that matters.
+
+With no step listener installed when the route is created, there is no
wrapper, so a step costs nothing more than
+today: not a check, not a volatile read. With listeners, each step call costs
the calls of the listeners and one
+wrapped callback; that is a development cost, measured before the first pull
request is merged (see Validation).
+
+*On completions.* The on completion listener is called where Camel changes or
runs on completions today:
+
+* `onUnitOfWork` in `setUnitOfWork` of the exchange (in `AbstractExchange` and
`ExtendedExchangeExtension`), with the
+ on completions it moves into the unit of work;
+* `onCompletionAdded` in `DefaultUnitOfWork.addSynchronization`. An add that
is part of a move or of a handover is
+ reported there too. The listener tells them apart: `onUnitOfWork` is fired
before the move, and `onCompletionHandover`
+ right after the handover, on the same thread, so the adds in between belong
to them. Adds to an exchange's own list
+ (before it has a unit of work) are not reported; they show up in the move;
+* `onCompletionHandover` in `handoverCompletions(target)` of the exchange,
which knows the exchange that hands over and,
+ through the new `UnitOfWork` method, what moved;
+* `onCompletionTaken` in `handoverCompletions()` without a target;
+* `onCompletionRun` in `UnitOfWorkHelper.doneSynchronizations`, before each on
completion runs.
+
+These calls are per exchange, not per step. With no on completion listener
installed, each place checks `isEmpty()`
+of the cached view it gets from the context extension, and goes on. The JMH
run shows whether that is visible.
+
+*Executors.* R4 needs the list of tasks that
`ExecutorServiceManager.shutdownNow` drops. That is a small hook on the
+`ExecutorServiceManager`, next to its `ThreadFactoryListener`, and is designed
with R4, last.
+
+=== Relation to StepHook
+
+`StepHook` (CAMEL-26338) acts: it can delay or fail a step, before the
processor is called. There is one per
+`CamelContext`, because two hooks that act on the same step need a rule for
how they combine (chaos logs a WARN and does
+not fire at steps when another hook is installed). The step listener only
observes, also after the call (R1 and R3 are
+about what happens after it), so any number can be installed with no rule for
combining them. The hook is asked first,
+outside the `InterceptStrategy` wrappers; if it fails the step, the processor
is not called and the listeners see no
+call (`StepHelper` calls the callback, which R1 does not check). The
`StepHook` stays as it is: it cannot be expressed
+on a listener, which cannot act. A later version could let chaos count its
faults through a listener; that is not
+proposed here.
+
+Making the `StepHook` composable is future work, outside this design. Acting
hooks can compose with a fixed rule: ask
+them in the order they were added, stop at the first that fails the step, and
wait for the sum of the delays. The
+Javadoc of `StepHook` already tells users to combine their hooks in one; a
composite in camel-support, installed in the
+one slot, would do that for them, and chaos would then no longer need its
WARN. The sanitizer does not need it.
+
+=== The sanitizer module
+
+The sanitizer is a module, `camel-sanitizer`, with a `ContextServicePlugin`,
as `camel-chaos` has. When the context
+initializes, before the routes are created, the plugin reads the profile and
the options:
+
+* it installs only with profile `dev`, `camel.sanitizer.enabled=true`, or for
a context built by a test that the JUnit
+ extension marked (see Using it), and never with profile `prod` (checked
first, so no option of the sanitizer can stop
+ an application in prod); with no profile it stays off unless enabled, as for
chaos;
+* when it installs, it logs a WARN that the sanitizer is on and costs time per
step (in tests, once per JVM at WARN
+ and then at DEBUG, so a suite's log is not filled with it);
+* it adds its step listener, its on completion listener, an `EventNotifier`
for exchange created, completed and
+ failed (it ignores the other events), and the R4 hook;
+* it keeps its records of step calls in their state (see above), and a bounded
map of the pending calls per exchange,
+ with weak keys, for R1 "still pending" and R3; its R2 records hold the on
completion and ids, not the exchanges.
+
+It changes nothing else: no route policies, no interceptors, no changed
components.
+
+The module depends on camel-api and camel-support, and on the JUnit Jupiter
API as an optional dependency for its
+extension. Proposed location: `components/camel-sanitizer`, next to
`camel-chaos`. A module whose own tests need
+camel-core cannot also be a dependency of camel-core, as Maven would see a
cycle; so, as for `camel-seda` and
+`camel-direct`, the module has no tests of its own, its tests live in
camel-core, and camel-core can depend on it in
+test scope and run its own suite with it.
+
+=== Options
Review Comment:
Good point, thanks. Added in 59332ce69e03: the bound (10 000 pending calls)
is a safety net. When it is reached, the oldest pending call is dropped and
counted, one WARN says the checks are incomplete, and the count shows in the
dev console and the JUnit report, so a test that reaches the bound is told so
instead of passing.
_Claude Code on behalf of allthingssecurity_
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]