shashank created CAMEL-25682:
--------------------------------
Summary: camel-resilience4j - Circuit Breaker EIP: a timeout that
fires while the task copies its result into the exchange lets the fallback (or
the timeout exception) and that copy change the exchange at the same time
Key: CAMEL-25682
URL: https://issues.apache.org/jira/browse/CAMEL-25682
Project: Camel
Issue Type: Bug
Components: eip
Reporter: shashank
Assignee: shashank
CAMEL-24134 added a write guard between the task of the circuit breaker (it
runs on the timeout executor and copies its result from the correlated copy
into the exchange) and the fallback that the caller runs after a timeout. The
task claims the guard with {{compareAndSet(false, true)}} and then writes
({{ResilienceProcessor.processTask}}, lines 751-764 at main cd2ce899764e; async
mode {{processTaskAsync}} 812-824). The fallback task only does
{{exchangeWriteGuard.set(true)}} (line 911) and then writes the exchange. So
when the timeout fires after the task has won the guard but before it has
finished its write-back, both write the same exchange at the same time:
* with {{onFallback}}: the task's {{ExchangeHelper.copyResults}} replaces the
body of the fallback by the body of the task, or sets
{{CamelCircuitBreakerResponseFromFallback}} back to {{false}} and
{{CamelCircuitBreakerResponseSuccessfulExecution}} to {{true}} after the
fallback has set them, while the exchange already continues in the route;
* without fallback: {{copyResults}} ends with {{setException(null)}} and clears
the {{TimeoutException}} the caller has just set (a timed-out call continues as
a success);
* the headers and properties maps of the exchange are written by two threads.
The caller does not wait for the task: with a timeout the task runs in
{{CompletableFuture.supplyAsync}} (591) behind
{{TimeLimiter.decorateFutureSupplier}} (599); async mode uses
{{TimeLimiter.decorateCompletionStage}} (672). The window is the time the task
needs for {{copyResults}} and two properties, so it needs a task that ends at
about the moment of the timeout.
h3. Reproduction
* New {{ResilienceTimeoutDuringWriteBackTest}} (4 tests: sync and
{{asynchronous=true}}, with and without {{onFallback}}, timeout 500 ms). The
task's result message holds the first read of its body by
{{ExchangeHelper.copyResults}} (the test checks the stack) until the caller has
returned, or until the caller has not returned 2 seconds after the timeout. On
main all 4 fail: {{expected: <Fallback result> but was: <Task result>}} (sync
and async), and {{expected: <java.util.concurrent.TimeoutException> but was:
<null>}} (sync and async).
* Without the hold: a probe (not part of the change) with 20000 calls from 32
threads, timeout 20 ms and a task of 20 ms +/- 0.3 ms, a 64-thread timeout
executor: on main 26 exchanges ended inconsistent (for example the fallback
body with {{CamelCircuitBreakerResponseFromFallback=false}}); with the fix 0.
One run each.
h3. Proposed fix
The guard becomes a small lock-based guard per call: the task claims it and
writes while holding the lock; the fallback task claims it under the same lock,
so a caller that times out while the task is writing waits until that write is
done (bounded: {{copyResults}} and two properties). It then sets the timeout
result or runs the fallback on the exchange, which then holds the result of the
task (as today when the timeout fires just after the copy). The task writes
only if it claims the exchange before the caller; the caller always writes
after a timeout. In async mode the waiting thread is a thread of the time
limiter's scheduler pool, which already runs the fallback. Sync and async mode.
One {{ReentrantLock}} per call instead of one {{AtomicBoolean}}.
camel-microprofile-fault-tolerance has the same {{set(true)}}
({{FaultToleranceProcessor}} 298) but is not affected: the same test (adapted)
passes on main, because with SmallRye Fault Tolerance 6.11.4 the caller gets
the {{TimeoutException}} only after the task has returned (it waited the whole
hold in the test), so the task and the fallback do not overlap.
camel-resilience4j tests with the fix: 81 run, 0 failures.
Not changed (follow-ups): an exception thrown by {{copyResults}} after the task
claimed the exchange is dropped and the call counts as a success (same on
main); and whether a caller that finds the write-back already claimed should
keep the task's result instead of running the fallback (it would change the
outcome of such calls, so it needs a decision).
How it was found: the backlog of an earlier review round listed the
{{set(true)}}. A small TLA+ model of the guard (task: claim, write body, write
properties; caller: timeout at any time before the task returned, claim,
fallback, continue) violates "the task does not write after the caller took the
exchange over" and "the final exchange tells one story" on main in 18 / 36-49
states, and both hold with the lock (28-31 states). The model is a sketch; the
test and the probe are the evidence. The microprofile part of the model
assumption (the caller continues while the task runs) turned out to be false
for SmallRye, see above.
Affected: main (cd2ce899764e; tested), camel-4.23.x and camel-4.22.x (same
{{set(true)}}; read, not run). camel-4.18.x has no guard at all (before
CAMEL-24134).
Duplicate check (2026-10-15): JIRA text "circuit breaker timeout worker write
back" (CAMEL-24134 only, resolved, the fix this completes),
"exchangeWriteGuard" (CAMEL-25129, CAMEL-24209: other topics); GitHub pull
requests "exchangeWriteGuard", "FaultToleranceProcessor" (#24797 = CAMEL-24134,
merged; nothing open).
_Filed with Claude Code on behalf of allthingssecurity._
--
This message was sent by Atlassian Jira
(v8.20.10#820010)