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 24cd63f10fb2 CAMEL-25682: camel-resilience4j - a timed-out call waits 
for a write-back of the task that is in progress
24cd63f10fb2 is described below

commit 24cd63f10fb22a520b1a83fba00a47459422943b
Author: allthingssecurity <[email protected]>
AuthorDate: Fri Oct 16 20:29:21 2026 +0530

    CAMEL-25682: camel-resilience4j - a timed-out call waits for a write-back 
of the task that is in progress
    
    [CAMEL-25682](https://issues.apache.org/jira/browse/CAMEL-25682)
    
    CAMEL-24134 added a write guard so that the task of the circuit breaker 
does not write its result into the exchange after a timeout handed the exchange 
to the fallback. The task claims the guard with `compareAndSet(false, true)` 
and then copies its result (`ResilienceProcessor.processTask`, and 
`processTaskAsync` in asynchronous mode). The fallback task only did 
`exchangeWriteGuard.set(true)` before writing the exchange. When the timeout 
fires after the task has won the guard and befo [...]
    
    - with `onFallback`, the body of the fallback is replaced by the body of 
the task, or `CamelCircuitBreakerResponseFromFallback` is set back to `false` 
(and `CamelCircuitBreakerResponseSuccessfulExecution` to `true`) after the 
fallback set them, while the exchange already continues in the route;
    - without fallback, `ExchangeHelper.copyResults` ends with 
`setException(null)` and clears the `TimeoutException` that the caller has just 
set;
    - headers and properties are written by two threads.
    
    The caller does not wait for the task here (the task runs in 
`CompletableFuture.supplyAsync` behind `TimeLimiter.decorateFutureSupplier`, or 
`decorateCompletionStage` in async mode), so the window is the time the task 
needs to copy its result; it needs a task that ends at about the moment of the 
timeout.
    
    Closes #27921
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../resilience4j/ResilienceProcessor.java          | 121 ++++++++---
 .../ResilienceTimeoutDuringWriteBackTest.java      | 239 +++++++++++++++++++++
 2 files changed, 332 insertions(+), 28 deletions(-)

diff --git 
a/components/camel-resilience4j/src/main/java/org/apache/camel/component/resilience4j/ResilienceProcessor.java
 
b/components/camel-resilience4j/src/main/java/org/apache/camel/component/resilience4j/ResilienceProcessor.java
index c35f096ee343..792964d56106 100644
--- 
a/components/camel-resilience4j/src/main/java/org/apache/camel/component/resilience4j/ResilienceProcessor.java
+++ 
b/components/camel-resilience4j/src/main/java/org/apache/camel/component/resilience4j/ResilienceProcessor.java
@@ -26,8 +26,9 @@ import java.util.concurrent.CompletionStage;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeoutException;
-import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
 import java.util.function.Function;
 import java.util.function.Predicate;
 import java.util.function.Supplier;
@@ -581,7 +582,7 @@ public class ResilienceProcessor extends 
BaseProcessorSupport
             task = (CircuitBreakerTask) taskFactory.acquire(exchange, 
callback);
             // guard to prevent the worker thread from writing results back to 
the original exchange
             // after a timeout has triggered fallback processing on the caller 
thread
-            AtomicBoolean exchangeWriteGuard = new AtomicBoolean(false);
+            ExchangeWriteGuard exchangeWriteGuard = new ExchangeWriteGuard();
             task.exchangeWriteGuard = exchangeWriteGuard;
             fallbackTask.exchangeWriteGuard = exchangeWriteGuard;
             final CircuitBreakerTask ftask = task; // annoying final java 
thingy!
@@ -640,7 +641,7 @@ public class ResilienceProcessor extends 
BaseProcessorSupport
         CircuitBreakerFallbackTask fallbackTask = null;
         try {
             fallbackTask = (CircuitBreakerFallbackTask) 
fallbackTaskFactory.acquire(exchange, callback);
-            AtomicBoolean exchangeWriteGuard = new AtomicBoolean(false);
+            ExchangeWriteGuard exchangeWriteGuard = new ExchangeWriteGuard();
             fallbackTask.exchangeWriteGuard = exchangeWriteGuard;
 
             if (LOG.isTraceEnabled()) {
@@ -716,7 +717,7 @@ public class ResilienceProcessor extends 
BaseProcessorSupport
         
exchange.setProperty(ExchangePropertyKey.CIRCUIT_BREAKER_RESPONSE_STATE, 
circuitBreaker.getState().name());
     }
 
-    private Exchange processTask(Exchange exchange, AtomicBoolean 
exchangeWriteGuard) {
+    private Exchange processTask(Exchange exchange, ExchangeWriteGuard 
exchangeWriteGuard) {
         String state = circuitBreaker.getState().name();
 
         Exchange copy = null;
@@ -748,19 +749,27 @@ public class ResilienceProcessor extends 
BaseProcessorSupport
             processor.process(copy);
 
             // handle the processing result, but only write back if the 
fallback has not taken over
-            if (exchangeWriteGuard.compareAndSet(false, true)) {
-                if (copy.getException() != null) {
-                    exchange.setException(copy.getException());
-                } else {
-                    // copy the result as its regarded as success
-                    ExchangeHelper.copyResults(exchange, copy);
-                    
exchange.setProperty(ExchangePropertyKey.CIRCUIT_BREAKER_RESPONSE_SUCCESSFUL_EXECUTION,
 true);
-                    
exchange.setProperty(ExchangePropertyKey.CIRCUIT_BREAKER_RESPONSE_FROM_FALLBACK,
 false);
+            if (exchangeWriteGuard.tryClaimForWriteBack()) {
+                try {
+                    if (copy.getException() != null) {
+                        exchange.setException(copy.getException());
+                    } else {
+                        // copy the result as its regarded as success
+                        ExchangeHelper.copyResults(exchange, copy);
+                        
exchange.setProperty(ExchangePropertyKey.CIRCUIT_BREAKER_RESPONSE_SUCCESSFUL_EXECUTION,
 true);
+                        
exchange.setProperty(ExchangePropertyKey.CIRCUIT_BREAKER_RESPONSE_FROM_FALLBACK,
 false);
+                    }
+                } finally {
+                    exchangeWriteGuard.writeBackDone();
                 }
             }
         } catch (Exception e) {
-            if (exchangeWriteGuard.compareAndSet(false, true)) {
-                exchange.setException(e);
+            if (exchangeWriteGuard.tryClaimForWriteBack()) {
+                try {
+                    exchange.setException(e);
+                } finally {
+                    exchangeWriteGuard.writeBackDone();
+                }
             }
         } finally {
             // must done uow
@@ -782,7 +791,7 @@ public class ResilienceProcessor extends 
BaseProcessorSupport
     }
 
     private CompletableFuture<Exchange> processTaskAsync(
-            Exchange exchange, AtomicBoolean exchangeWriteGuard, 
CompletableFuture<Exchange> future) {
+            Exchange exchange, ExchangeWriteGuard exchangeWriteGuard, 
CompletableFuture<Exchange> future) {
         String state = circuitBreaker.getState().name();
 
         Exchange copy = null;
@@ -809,18 +818,27 @@ public class ResilienceProcessor extends 
BaseProcessorSupport
             AsyncProcessor asyncProcessor = 
AsyncProcessorConverterHelper.convert(processor);
             asyncProcessor.process(copy, doneSync -> {
                 try {
-                    if (exchangeWriteGuard.compareAndSet(false, true)) {
-                        if (fCopy.getException() != null) {
-                            exchange.setException(fCopy.getException());
-                        } else {
-                            ExchangeHelper.copyResults(exchange, fCopy);
-                            
exchange.setProperty(ExchangePropertyKey.CIRCUIT_BREAKER_RESPONSE_SUCCESSFUL_EXECUTION,
 true);
-                            
exchange.setProperty(ExchangePropertyKey.CIRCUIT_BREAKER_RESPONSE_FROM_FALLBACK,
 false);
+                    if (exchangeWriteGuard.tryClaimForWriteBack()) {
+                        try {
+                            if (fCopy.getException() != null) {
+                                exchange.setException(fCopy.getException());
+                            } else {
+                                ExchangeHelper.copyResults(exchange, fCopy);
+                                
exchange.setProperty(ExchangePropertyKey.CIRCUIT_BREAKER_RESPONSE_SUCCESSFUL_EXECUTION,
+                                        true);
+                                
exchange.setProperty(ExchangePropertyKey.CIRCUIT_BREAKER_RESPONSE_FROM_FALLBACK,
 false);
+                            }
+                        } finally {
+                            exchangeWriteGuard.writeBackDone();
                         }
                     }
                 } catch (Exception e) {
-                    if (exchangeWriteGuard.compareAndSet(false, true)) {
-                        exchange.setException(e);
+                    if (exchangeWriteGuard.tryClaimForWriteBack()) {
+                        try {
+                            exchange.setException(e);
+                        } finally {
+                            exchangeWriteGuard.writeBackDone();
+                        }
                     }
                 } finally {
                     UnitOfWorkHelper.doneUow(fUow, fCopy);
@@ -845,10 +863,56 @@ public class ResilienceProcessor extends 
BaseProcessorSupport
         return future;
     }
 
+    /**
+     * Makes the write-back of the task and the takeover by the caller (the 
fallback, or the timeout exception) mutually
+     * exclusive, for one call. The task writes its result only if it claims 
the exchange before the caller. The caller
+     * always writes: if it takes over while the write-back of the task is in 
progress, it waits until that is done, and
+     * then works on the exchange that holds the result of the task.
+     */
+    private static final class ExchangeWriteGuard {
+        private final Lock lock = new ReentrantLock();
+        private boolean claimed;
+
+        /**
+         * Claims the exchange for the write-back of the task. When this 
returns true, the lock is held until
+         * {@link #writeBackDone()} is called.
+         */
+        boolean tryClaimForWriteBack() {
+            lock.lock();
+            if (claimed) {
+                lock.unlock();
+                return false;
+            }
+            claimed = true;
+            return true;
+        }
+
+        /**
+         * Ends the write-back of the task, after {@link 
#tryClaimForWriteBack()} returned true. Must be called in a
+         * {@code finally} block, as the lock stays held until then.
+         */
+        void writeBackDone() {
+            lock.unlock();
+        }
+
+        /**
+         * Claims the exchange for the caller. This always succeeds; it waits 
for a write-back of the task that is in
+         * progress, and stops the task from writing afterwards.
+         */
+        void claim() {
+            lock.lock();
+            try {
+                claimed = true;
+            } finally {
+                lock.unlock();
+            }
+        }
+    }
+
     private final class CircuitBreakerTask implements PooledExchangeTask, 
Callable<Exchange>, Supplier<Exchange> {
 
         private Exchange exchange;
-        private AtomicBoolean exchangeWriteGuard;
+        private ExchangeWriteGuard exchangeWriteGuard;
 
         @Override
         public void prepare(Exchange exchange, AsyncCallback callback) {
@@ -885,7 +949,7 @@ public class ResilienceProcessor extends 
BaseProcessorSupport
     private final class CircuitBreakerFallbackTask implements 
PooledExchangeTask, Function<Throwable, Exchange> {
 
         private Exchange exchange;
-        private AtomicBoolean exchangeWriteGuard;
+        private ExchangeWriteGuard exchangeWriteGuard;
 
         @Override
         public void prepare(Exchange exchange, AsyncCallback callback) {
@@ -906,9 +970,10 @@ public class ResilienceProcessor extends 
BaseProcessorSupport
 
         @Override
         public Exchange apply(Throwable throwable) {
-            // prevent the worker thread from writing results back to the 
original exchange
+            // prevent the worker thread from writing results back to the 
original exchange (waits for a
+            // write-back that the worker has already started)
             if (exchangeWriteGuard != null) {
-                exchangeWriteGuard.set(true);
+                exchangeWriteGuard.claim();
             }
 
             String state = circuitBreaker.getState().name();
diff --git 
a/components/camel-resilience4j/src/test/java/org/apache/camel/component/resilience4j/ResilienceTimeoutDuringWriteBackTest.java
 
b/components/camel-resilience4j/src/test/java/org/apache/camel/component/resilience4j/ResilienceTimeoutDuringWriteBackTest.java
new file mode 100644
index 000000000000..a9a654f71095
--- /dev/null
+++ 
b/components/camel-resilience4j/src/test/java/org/apache/camel/component/resilience4j/ResilienceTimeoutDuringWriteBackTest.java
@@ -0,0 +1,239 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.resilience4j;
+
+import java.util.Arrays;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.ExchangePropertyKey;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.support.DefaultMessage;
+import org.apache.camel.support.ExchangeHelper;
+import org.apache.camel.support.SynchronizationAdapter;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The timeout fires while the task is copying its result into the exchange 
(it has already won the write guard). The
+ * caller must not change the exchange at the same time (fallback or timeout 
exception): it waits until the copy is
+ * done, then sets the timeout result or runs the fallback on the exchange, 
which then holds the result of the task (as
+ * when the timeout fires just after the copy). The copy does not change the 
exchange after the caller took it over.
+ * Sync and async ({@code asynchronous=true}) mode.
+ * <p>
+ * The task holds its copy at a gate inside {@code ExchangeHelper.copyResults} 
(the first read of the body of its
+ * result). The gate is opened when the caller has returned (it did not wait 
for the copy), or when the caller has not
+ * returned 2 seconds after the timeout (it waits for the copy).
+ */
+class ResilienceTimeoutDuringWriteBackTest extends CamelTestSupport {
+
+    private static final int TIMEOUT = 500;
+
+    private final Gate gate = new Gate();
+    private final CountDownLatch taskDone = new CountDownLatch(1);
+
+    @Test
+    void testFallbackIsNotOverwrittenByWriteBackInProgress() throws Exception {
+        assertFallbackResult(sendWhileTaskHoldsWriteBack("direct:fallback"));
+    }
+
+    @Test
+    void testTimeoutExceptionIsNotClearedByWriteBackInProgress() throws 
Exception {
+        assertTimeoutResult(sendWhileTaskHoldsWriteBack("direct:nofallback"));
+    }
+
+    @Test
+    void testAsyncFallbackIsNotOverwrittenByWriteBackInProgress() throws 
Exception {
+        
assertFallbackResult(sendWhileTaskHoldsWriteBack("direct:asyncFallback"));
+    }
+
+    @Test
+    void testAsyncTimeoutExceptionIsNotClearedByWriteBackInProgress() throws 
Exception {
+        
assertTimeoutResult(sendWhileTaskHoldsWriteBack("direct:asyncNofallback"));
+    }
+
+    private static void assertFallbackResult(Exchange exchange) {
+        assertEquals("Fallback result", 
exchange.getMessage().getBody(String.class));
+        // the fallback ran after the write-back of the task, on the exchange 
that holds the result of the task
+        assertEquals("task", exchange.getMessage().getHeader("Written-By"));
+        assertEquals(Boolean.TRUE, 
exchange.getProperty(ExchangePropertyKey.CIRCUIT_BREAKER_RESPONSE_FROM_FALLBACK));
+        assertEquals(Boolean.FALSE,
+                
exchange.getProperty(ExchangePropertyKey.CIRCUIT_BREAKER_RESPONSE_SUCCESSFUL_EXECUTION));
+    }
+
+    private static void assertTimeoutResult(Exchange exchange) {
+        assertInstanceOf(TimeoutException.class, exchange.getException());
+        // the timeout was set after the write-back of the task, on the 
exchange that holds the result of the task
+        assertEquals("task", exchange.getMessage().getHeader("Written-By"));
+        assertEquals(Boolean.TRUE, 
exchange.getProperty(ExchangePropertyKey.CIRCUIT_BREAKER_RESPONSE_TIMED_OUT));
+        assertEquals(Boolean.FALSE,
+                
exchange.getProperty(ExchangePropertyKey.CIRCUIT_BREAKER_RESPONSE_SUCCESSFUL_EXECUTION));
+    }
+
+    private Exchange sendWhileTaskHoldsWriteBack(String uri) throws Exception {
+        Future<Exchange> reply = template.asyncSend(uri, e -> 
e.getIn().setBody("Hello"));
+
+        assertTrue(gate.entered.await(10, TimeUnit.SECONDS), "the task should 
reach the write-back");
+        StackTraceElement[] where = gate.where.get();
+        assertTrue(Arrays.stream(where).anyMatch(f -> 
f.getMethodName().equals("doCopyResults")),
+                "the gate should hold the task inside copyResults, not at " + 
Arrays.toString(where));
+
+        // the caller times out while the task holds the gate: either it 
returns without waiting for the task, or it
+        // waits for the task, then it has not returned 2 seconds after the 
timeout
+        try {
+            reply.get(TIMEOUT + 2000, TimeUnit.MILLISECONDS);
+        } catch (TimeoutException e) {
+            // the caller waits for the write-back of the task
+        }
+        gate.open.countDown();
+
+        Exchange exchange = reply.get(10, TimeUnit.SECONDS);
+        assertNotNull(exchange);
+        assertTrue(taskDone.await(10, TimeUnit.SECONDS), "the task should be 
done");
+        return exchange;
+    }
+
+    @Override
+    protected RoutesBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("direct:fallback")
+                        .circuitBreaker()
+                            
.resilience4jConfiguration().timeoutEnabled(true).timeoutDuration(TIMEOUT).end()
+                            .process(e -> holdWriteBack(e))
+                        .onFallback()
+                            .setBody(constant("Fallback result"))
+                        .end();
+
+                from("direct:nofallback")
+                        .circuitBreaker()
+                            
.resilience4jConfiguration().timeoutEnabled(true).timeoutDuration(TIMEOUT).end()
+                            .process(e -> holdWriteBack(e))
+                        .end();
+
+                from("direct:asyncFallback")
+                        .circuitBreaker()
+                            
.resilience4jConfiguration().asynchronous(true).timeoutEnabled(true).timeoutDuration(TIMEOUT)
+                            .end()
+                            .process(e -> holdWriteBack(e))
+                        .onFallback()
+                            .setBody(constant("Fallback result"))
+                        .end();
+
+                from("direct:asyncNofallback")
+                        .circuitBreaker()
+                            
.resilience4jConfiguration().asynchronous(true).timeoutEnabled(true).timeoutDuration(TIMEOUT)
+                            .end()
+                            .process(e -> holdWriteBack(e))
+                        .end();
+            }
+        };
+    }
+
+    private void holdWriteBack(Exchange copy) {
+        GatedMessage result = new GatedMessage(copy, gate);
+        result.setBody("Task result");
+        result.setHeader("Written-By", "task");
+        copy.setIn(result);
+        // done when the task has finished with its copy of the exchange
+        copy.getExchangeExtension().addOnCompletion(new 
SynchronizationAdapter() {
+            @Override
+            public void onDone(Exchange exchange) {
+                taskDone.countDown();
+            }
+        });
+        result.armed = true;
+    }
+
+    private static final class Gate {
+        final CountDownLatch entered = new CountDownLatch(1);
+        final CountDownLatch open = new CountDownLatch(1);
+        final AtomicReference<StackTraceElement[]> where = new 
AtomicReference<>();
+
+        void pass() {
+            where.set(Thread.currentThread().getStackTrace());
+            entered.countDown();
+            // keep waiting if the task thread is interrupted, bounded, and 
keep the interrupt status
+            boolean interrupted = false;
+            long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20);
+            try {
+                while (true) {
+                    long left = deadline - System.nanoTime();
+                    if (left <= 0) {
+                        return;
+                    }
+                    try {
+                        if (open.await(left, TimeUnit.NANOSECONDS)) {
+                            return;
+                        }
+                    } catch (InterruptedException e) {
+                        interrupted = true;
+                    }
+                }
+            } finally {
+                if (interrupted) {
+                    Thread.currentThread().interrupt();
+                }
+            }
+        }
+    }
+
+    /**
+     * The result message of the task: the first read of its body by {@code 
ExchangeHelper.copyResults} after the task
+     * is done holds at the gate (other reads, such as the stream caching 
advice, pass).
+     */
+    private static final class GatedMessage extends DefaultMessage {
+        private final Gate gate;
+        volatile boolean armed;
+
+        GatedMessage(Exchange exchange, Gate gate) {
+            super(exchange);
+            this.gate = gate;
+        }
+
+        @Override
+        public Object getBody() {
+            if (armed && calledByCopyResults()) {
+                armed = false;
+                gate.pass();
+            }
+            return super.getBody();
+        }
+
+        private static boolean calledByCopyResults() {
+            return StackWalker.getInstance()
+                    .walk(frames -> frames.anyMatch(f -> 
f.getClassName().equals(ExchangeHelper.class.getName())
+                            && f.getMethodName().equals("doCopyResults")));
+        }
+
+        @Override
+        public DefaultMessage newInstance() {
+            return new DefaultMessage(getExchange());
+        }
+    }
+}

Reply via email to