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 c19280fb3750 CAMEL-26630: design - exchange sanitizer: dev-mode checks 
for exchanges completed twice, callbacks never called and consumer commits 
(#29296)
c19280fb3750 is described below

commit c19280fb3750ce71f4f379894337249c3b3e2252
Author: allthingssecurity <[email protected]>
AuthorDate: Fri Nov 6 17:07:54 2026 +0530

    CAMEL-26630: design - exchange sanitizer: dev-mode checks for exchanges 
completed twice, callbacks never called and consumer commits (#29296)
    
    Adds design/exchange-sanitizer.adoc, a design proposal for a dev-mode 
checker that reports where the contracts of AsyncProcessor and of on 
completions are broken: a callback called twice or never, a consumer commit run 
more than once or in the wrong unit of work, an exchange used by two threads at 
once, and tasks dropped at shutdown. It proposes two observe-only listener SPIs 
(StepListener, OnCompletionListener) so that the sanitizer composes with chaos, 
the debugger, the tracer and J [...]
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 AGENTS.md                      |   1 +
 design/exchange-sanitizer.adoc | 783 +++++++++++++++++++++++++++++++++++++++++
 2 files changed, 784 insertions(+)

diff --git a/AGENTS.md b/AGENTS.md
index 2db2f3c7c41f..363cbc2b8981 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -113,6 +113,7 @@ Camel is a large, long-lived project — code often looks 
"wrong" but exists for
    [`headers.adoc`](design/headers.adoc) (header naming, constants, upgrade 
policy),
    [`cross-cutting-binding.adoc`](design/cross-cutting-binding.adoc) 
(onException, interceptors, onCompletion, route
    configurations), [`chaos.adoc`](design/chaos.adoc) (proposed `camel-chaos`),
+   [`exchange-sanitizer.adoc`](design/exchange-sanitizer.adoc) (proposed 
exchange sanitizer, dev-mode checks),
    [`java-dsl-parser.adoc`](design/java-dsl-parser.adoc) (`LwJavaParser`, 
reading Java routes without compiling them).
 5. **Understand the broader context**: if a module replaced another (e.g. 
`camel-opentelemetry2`), understand what was
    intentionally changed vs. accidentally omitted.
diff --git a/design/exchange-sanitizer.adoc b/design/exchange-sanitizer.adoc
new file mode 100644
index 000000000000..318c57289f64
--- /dev/null
+++ b/design/exchange-sanitizer.adoc
@@ -0,0 +1,783 @@
+---
+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.
+  The bound (10 000 pending calls) is a safety net, not a sampling limit: when 
it is reached, the oldest pending call
+  is dropped, the sanitizer counts it, logs one WARN ("checks incomplete: N 
pending calls dropped") and shows the count
+  in the dev console and the JUnit report, so a call it stopped watching is 
never lost silently. A test that reaches
+  the bound is told so, rather than passing.
+
+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
+
+[cols="1,1,3"]
+|===
+|Option |Default |Description
+
+|`camel.sanitizer.enabled` |`false`, `true` with profile `dev` or a test 
marked by the JUnit extension |Install the
+sanitizer. Ignored with profile `prod`.
+|`camel.sanitizer.checks` |`callback,commit,threads,tasks` |The checks to run: 
R1, R2, R3, R4.
+|`camel.sanitizer.stackDepth` |`32` |Frames kept of each stack; `0` keeps only 
the thread name, which costs least.
+|`camel.sanitizer.overlapSampling` |`0`, `100` in the JUnit extension |R3 
watch windows: every Nth point (a counter,
+not random, so a rerun samples the same points of the same run); `0` is off.
+|`camel.sanitizer.overlapWindow` |`1ms` |How long a sampled thread waits for 
an overlapping access.
+|`camel.sanitizer.pendingTimeout` |`60s` |When a pending callback is reported 
as still pending (R1).
+|`camel.sanitizer.reportOnShutdown` |`true` |Log the report when the context 
stops.
+|===
+
+The defaults are a starting point, to be set from what the prototype measures. 
They are a
+`SanitizerConfigurationProperties` group in camel-main, as 
`ChaosConfigurationProperties` is for `camel.chaos.*`.
+None of them is marked `insecure`: the sanitizer stays off with profile 
`prod`, and apart from the R3 pauses it only
+reports. That differs from chaos on purpose: chaos marks `camel.chaos.rules` 
`insecure:dev`, so `prod` refuses to
+start with rules, while the sanitizer is ignored in `prod`.
+
+The report holds ids, names, threads, stacks, and the class and message of an 
exception; never bodies or headers. An
+exception message can carry message data, so the report is for development. It 
is in the log, in the `sanitizer` dev
+console (read-only, and served over HTTP only when the `insecure:dev` option 
`camel.management.devConsoleEnabled` is
+set), and in `camel get sanitizer`.
+
+=== Using it
+
+*In development.* `camel run --sanitizer` adds the dependency, as `--chaos` 
does; `--dep camel:sanitizer` works too.
+With profile `dev`, the default of `camel run`, it is on. Violations are 
logged at WARN when they happen; a `sanitizer`
+dev console (`@DevConsole(readOnly = true)`) and `camel get sanitizer` show 
the report: the violations with their
+stacks, the calls still pending, and the counts per rule.
+
+*Spring Boot and Quarkus.* The plugin works with any `CamelContext`. A Spring 
Boot application turns it on in
+development with the Camel profile `dev` (`camel.main.profile=dev`, for 
example in a Spring `dev` profile file) or
+`camel.sanitizer.enabled=true`; a Quarkus application with 
`%dev.camel.sanitizer.enabled=true`. The options are read
+through the properties component, so `application.properties` works, but their 
tooling metadata is in camel-main only.
+`camel run --sanitizer` is refused with `--runtime=spring-boot` or `quarkus` 
for now, as `--chaos` is. (When no profile
+is set, the Javadoc of `camel.main.profile` calls the default `prod`; either 
way the sanitizer is off.)
+
+*In a test.* Add the `camel-sanitizer` dependency in test scope and 
`@CamelSanitizer`, as shown in "Checking your own
+processors and components". `@CamelSanitizer` is meta-annotated with 
`@ExtendWith(CamelSanitizerExtension.class)`, as
+`@CamelMainTest` and `@DstTest` are, and its attributes tune it (`checks`, 
`overlapSampling`). The JUnit types live in
+`org.apache.camel.sanitizer.junit`.
+
+The extension marks the test on its thread in `beforeAll` and `beforeEach`, 
before any `beforeEach` of other extensions
+(such as the one of `@CamelMainTest`, which builds the context there), so the 
order of the annotations does not matter.
+The plugin runs on the thread that builds the context, so a context built for 
the test class gets the sanitizer, with R3
+sampling on, and is tied to that class; profile `prod` still wins. A violation 
is charged to the test of its context,
+and the extension fails that test after it ran, with the first violation and 
the full report. A violation that comes
+after the test ended, such as a late second callback, fails the test class at 
its end. Nothing is set for the whole JVM,
+so test classes that run in parallel in one JVM (the camel-core build sets 
`camel.surefire.parallel=true`) do not see
+each other's violations. It uses only the JUnit Jupiter extension API, so it 
works with `camel-test-junit5` and
+`camel-test-junit6`, and with tests that do not use them, such as the 
camel-core tests. The pending warnings of R1 do
+not fail a test.
+
+A context that is still running when the class it is tied to ends, such as a 
Spring test context that is cached and
+reused by later classes, is untied then. From then on, and for a context that 
was never tied (an application started
+once for the suite on another thread), violations fail the test classes that 
run when they happen (at the end of each
+class); in a parallel run that can name more than one class, and the report 
names the context, route and step, so the
+reader can tell. `@QuarkusTest` runs the application in its own class loader, 
where the mark of the extension is not
+visible; there the first version only logs the violations, unless the 
prototype shows the mark can be passed. In a
+suite without JUnit Jupiter (TestNG, a plain `main`) the violations are logged 
and in the shutdown report, and no test
+fails.
+
+Some tests break a rule on purpose, such as the tests of the fixes above, 
whose processors throw from `process`. They
+say so, and the extension then checks that the violation was reported instead 
of failing the test:
+
+[source,java]
+----
+@Test
+@ExpectSanitizerViolation(value = Violation.THREW_BEFORE_CALLBACK, step = 
"process1")
+void aProcessorThatThrowsFailsTheExchange() { ... }
+----
+
+A test class with timing that the R3 pauses would break turns the sampling off 
for itself with
+`@CamelSanitizer(overlapSampling = 0)`.
+
+*In CI.* The module registers the extension for JUnit's automatic detection. A 
pipeline adds the test dependency and
+runs the existing suite with
+
+[source,bash]
+----
+mvn test -Djunit.jupiter.extensions.autodetection.enabled=true \
+    
-Djunit.jupiter.extensions.autodetection.include=org.apache.camel.sanitizer.junit.*
 \
+    -Dcamel.sanitizer.enabled=true -Dsurefire.rerunFailingTestsCount=0
+----
+
+and every test fails on its first violation, without a change to the tests. 
The include pattern keeps JUnit from
+turning on other extensions found on the classpath. Reruns are turned off 
because a violation that happens now and
+then would pass a rerun and be reported as a flaky test; the extension also 
writes every violation to
+`target/camel-sanitizer/` so a rerun cannot hide it. (Surefire passes `-D` 
options to the forked test JVM; Gradle needs
+`systemProperty` in its `test` task.) Here `camel.sanitizer.enabled` is a 
system property, so every context of the run
+gets the sanitizer; each violation is still charged as described above. In the 
Camel build, a Maven profile
+`sanitizer` in camel-core and in the async components does the same (`mvn test 
-Psanitizer`), with
+`<systemPropertyVariables>` rather than the `<configurationParameters>` block 
of the parent, which it would replace.
+
+=== Validation
+
+Each step below is done before the code it validates is proposed for merge, 
and its result goes in the pull request.
+
+. *R1 first.* A prototype with the step listener and R1 only.
+. *The real bugs.* Run the tests of each fix above on the commit before the 
fix, with the sanitizer on, and show that
+  it reports each one at the right step: CAMEL-25095 and CAMEL-25122 as 
completed twice; CAMEL-26430, CAMEL-26445,
+  CAMEL-26446, CAMEL-26461 and CAMEL-26464 as threw before calling the 
callback, and CAMEL-26446 also as a callback
+  after the throw. Then R2 with CAMEL-26576 and CAMEL-26569, and, with "still 
pending", CAMEL-26575 at the multicast
+  and CAMEL-26601 at the recipient list (with a short `pendingTimeout`), each 
in the step of the order of work that adds
+  the rule.
+. *User code.* Tests with a custom `AsyncProcessor` that calls its callback 
twice, one that throws, a custom consumer
+  whose commit runs twice, and an `AggregationStrategy` that throws, each 
failing with the report that names its class;
+  with `CamelTestSupport`, `@CamelMainTest` and a Spring Boot test with a 
cached context.
+. *Quiet on main.* Run the camel-core test suite and the test suites of netty, 
netty-http, vertx-http and the JMS
+  request/reply tests with the sanitizer on, R3 sampling included, and show 
that it reports nothing on today's main.
+  Each report there is either a bug, which gets its own JIRA, or a false 
report, which is fixed in the sanitizer before
+  it is proposed. A false report would make people switch it off. The tests 
that break a rule on purpose carry
+  `@ExpectSanitizerViolation`.
+. *Together with the others.* One test runs a route with a chaos step rule, 
the tracer, JMX, the backlog debugger and
+  the sanitizer all on, and shows that each still works and the sanitizer 
reports only what it should (a step failed
+  by the hook is not a call of the processor).
+. *Cost.* A JMH benchmark in the camel-performance-tests repository: a route 
with several steps, a consumer commit and
+  an `InOnly` send to `seda` with handover; with no listener (it must not 
differ from main), and with the sanitizer on,
+  with and without stacks. No number is claimed before that run.
+. *The side table.* The records of the sanitizer are concurrent code too: 
callbacks arrive on many threads. Stress
+  tests with many threads check it, and a small TLA+ model of it (a first 
callback, a second callback, a throw and an
+  overlap on different threads) must show no false report and no missed 
violation in any interleaving, before R1 and
+  R3 are proposed.
+
+The rules are the properties that were checked when these bugs were hunted, 
with TLA+ and SPIN models, Fray and
+deterministic tests: "the callback is called once" for the error handlers and 
`NoErrorHandler`, "the commit runs once,
+for its owner" for the shared unit of work, and "a waited-for task is 
completed" for the `ProducerTemplate`.
+
+=== Order of work
+
+Each step is its own pull request after this document:
+
+. camel-core: the step listener SPI in camel-api, the Javadoc of the contracts 
of `AsyncProcessor`, `AsyncCallback`
+  and `Synchronization`, the step wrapper built only when a listener is 
installed, and the JMH benchmark (in
+  camel-performance-tests) that shows no change without a listener.
+. The module, the plugin, the options, R1 (without "still pending"), the 
report in the log, the JUnit extension and
+  the CI switch, with the runs on the commits before the fixes, on user-code 
tests and on main.
+. camel-core: the on completion listener SPI, the new `UnitOfWork` method and 
the calls, with the JMH run; then R2.
+. R3: the `AsyncProcessor` contract, then the sampled overlaps, with the runs 
on the async component suites.
+. The dev console, `camel get sanitizer` and `camel run --sanitizer`.
+. R1 "still pending".
+. R4 and its hook on the `ExecutorServiceManager`, if the earlier steps show 
it is still wanted.
+
+== Alternatives considered
+
+* *An `InterceptStrategy`.* It needs no camel-core change, and it sits inside 
the error handler, so it sees each
+  attempt. Only the `doTry`, `doCatch` and `doFinally` processors themselves 
are skipped (`canIntercept()` returns
+  `false`); the steps inside them are wrapped. The main drawback is that it is 
a user SPI: the sanitizer would become
+  one more strategy on the route and the context, ordered against the user's 
strategies by `Ordered`, and part of
+  the route model that users configure. The strategies that wrap it would get 
its wrapper as `target` instead of the
+  processor. The listener wrapper is built by the channel, only when a 
listener is installed, and no strategy sees it.
+* *A `CamelInternalProcessorAdvice` only.* This is where the debuggers, 
message history, the backlog tracer and the
+  tracer hook in, and it needs no new processor. But an advice runs around the 
error handler: `before` once per step
+  and `after` once, through the after task of the channel. So it sees one 
callback for all attempts, after the error
+  handler, and not the processor's own return value, what the processor threw, 
or which attempt a second callback
+  belongs to. R1 and R3 are about each call of the processor, so they need a 
place below the error handler.
+* *A hybrid: an advice for what is per step, the wrapper for what is per 
attempt.* Every rule of R1 and R3 is per
+  attempt, and R2 and R4 are not about steps, so nothing would be left for the 
advice to do; the wrapper is needed at
+  every step either way. Two mechanisms for the same steps would cost more 
than one. The hybrid becomes worth it if a
+  later rule needs one event per step only.
+* *The `StepHook`.* One per context, and before the call only (see Relation to 
StepHook).
+* *Listener calls in `StepHelper`, next to the hook.* `StepHelper` is called 
just outside the chosen wrapper in most
+  cases: by the error handler for each attempt, or by the `StepHook` processor 
or the channel. Every step
+  already reads the `stepHook` field there, so holding the hook and the 
listeners in one field would add no read per
+  step, and would see listeners added after the routes were built (which would 
answer the standby question below). But
+  it changes the field and the code path that every step uses, with or without 
the sanitizer, and it mixes a hook that
+  acts with listeners that only observe. The wrapper changes nothing when no 
listener is installed. This choice is open
+  (see Open questions).
+* *`EventNotifier` only.* It has the exchange events, but not the call of a 
processor, its return value, its throw or
+  a second call of a callback.
+* *A `UnitOfWorkFactory` for R2.* It is pluggable today and sees 
`addSynchronization`, `handoverSynchronization` and
+  `done` of the unit of work. It cannot tell which exchange asked for a 
handover: a part with `shareUnitOfWork` calls
+  the unit of work of its parent, so CAMEL-26576 looks like a handover by the 
owner.
+* *Log analysis after the fact.* It cannot see a callback that was never 
called, nor the stack of a first call.
+
+== Compatibility
+
+Without the JAR nothing changes. Without a step listener the routes are built 
as today. The on completion places check
+an empty list (measured by the JMH run). The SPIs are new; 
`ExtendedCamelContext` and `UnitOfWork` gain default
+methods, so existing implementations still compile and behave as before. A 
custom `UnitOfWork` that does not override
+the new method hands over as today and reports no moved list, so R2 is weaker 
with it. The options are in a new group.
+
+== Risks
+
+* *False reports.* They would make people switch it off. Mitigations: the TLA+ 
model and stress tests of the side
+  table, the quiet runs on camel-core and the async components before each 
rule is proposed, and R1 "still pending" as
+  a warning only.
+* *The JAR left in a production build.* It stays off with profile `prod` and 
with no profile unless enabled, and logs
+  a WARN when it is on.
+* *Timing and cost.* A stack kept for every call of a step is the main cost 
when it is on; `stackDepth=0` keeps only
+  the thread names for long runs. R3 changes the timing on purpose, which is 
why its sampling is off by default in
+  `dev`.
+
+== Open questions
+
+Each has a proposed answer; the maintainers decide.
+
+* The names of the SPIs. Proposed: `StepListener` and `OnCompletionListener`, 
next to `StepHook`.
+* Where the step listener is called. Proposed: a wrapper that the channel adds 
after the `InterceptStrategy` loop,
+  around the strategy wrappers and below the error handler, where the 
`StepHook` processor delegates to. The strategies
+  keep seeing the processor of the step, and the listeners see each attempt. 
The other choice is `StepHelper`, next to
+  the hook (see Alternatives).
+* Should the per-step part use a `CamelInternalProcessorAdvice`, as the 
debugger and the tracer do? Proposed: no.
+  Every step rule is per attempt and must be checked below the error handler, 
which an advice cannot see (see
+  Alternatives). If a later rule needs one event per step only, an advice is 
the place for it.
+* A standby flag for step listeners in dev mode, as `isDebugStandby` and 
`isBacklogTracingStandby` are read in
+  `DefaultChannel.initChannel`, so the sanitizer can be switched on in a 
running application without creating the
+  routes again. With the flag, the channel would add the wrapper to every 
step, and the wrapper would check for
+  listeners at each call, a cost only in dev. Proposed: not in the first 
version, as `camel run --sanitizer`, the JUnit
+  extension and the CI switch all install the listener before the routes are 
created. Add it when switching on at
+  runtime is wanted; the first code pull request keeps the decision to add the 
wrapper in one place, so the flag is a
+  small change.
+* Module location. Proposed: `components/camel-sanitizer`, next to 
`camel-chaos`, with its tests in camel-core;
+  `core/camel-sanitizer` is the other choice, as it checks the routing engine.
+* Should the Maven `sanitizer` profile go in the parent pom for every module? 
Proposed: start with camel-core and the
+  async components, and widen it once those runs are quiet.
+* Should R2 cover every on completion, not only the consumer's commit? 
Proposed: the commit only in the first version.
+  Other on completions are added by many places with other rules, and a false 
report costs more than a missed one.
+
+== Related tickets
+
+The bugs the rules come from are in the table of Motivation. Others:
+
+* CAMEL-26630: this feature.
+* CAMEL-26338: the `StepHook`.
+* CAMEL-26330: camel-chaos, whose switch the sanitizer follows.
+* CAMEL-25247: a thrift call answered twice, in Thrift's own callback, which 
the sanitizer does not see.
+* CAMEL-25537: tool profiles for the camel-jbang MCP server, before any 
sanitizer MCP tool.

Reply via email to