This is an automated email from the ASF dual-hosted git repository.

apupier pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git

commit fd4c5603c6cae715fed3777c9fffbbdcf087f515
Author: smjain <[email protected]>
AuthorDate: Wed Sep 23 16:34:09 2026 +0530

    CAMEL-24922: camel-core - Sticky failover without round robin should also 
try the endpoints before the last good one
    
    With sticky mode and no round robin, the failover load balancer started 
from the
    last known good endpoint and gave up at the end of the list, so the 
endpoints
    before it were never tried again and every exchange failed once that 
endpoint
    was down, even with maximumFailoverAttempts -1. Continue from the first 
endpoint
    until each endpoint has been tried once.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../loadbalancer/FailOverLoadBalancer.java         |  15 +++
 .../processor/FailoverStickyWrapAroundTest.java    | 104 +++++++++++++++++++++
 2 files changed, 119 insertions(+)

diff --git 
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/loadbalancer/FailOverLoadBalancer.java
 
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/loadbalancer/FailOverLoadBalancer.java
index 1ac119f11259..a3ffb283509c 100644
--- 
a/core/camel-core-processor/src/main/java/org/apache/camel/processor/loadbalancer/FailOverLoadBalancer.java
+++ 
b/core/camel-core-processor/src/main/java/org/apache/camel/processor/loadbalancer/FailOverLoadBalancer.java
@@ -181,6 +181,7 @@ public class FailOverLoadBalancer extends 
LoadBalancerSupport implements Traceab
         final AsyncCallback callback;
         final AsyncProcessor[] processors;
         int index;
+        int start;
         int attempts;
         // use a copy of the original exchange before failover to avoid 
populating side effects
         // directly into the original exchange
@@ -198,6 +199,7 @@ public class FailOverLoadBalancer extends 
LoadBalancerSupport implements Traceab
             } else if (isRoundRobin()) {
                 index = counter.updateAndGet(x -> ++x < processors.length ? x 
: 0);
             }
+            start = index;
             LOG.trace("Failover starting with endpoint index {}", index);
         }
 
@@ -245,6 +247,11 @@ public class FailOverLoadBalancer extends 
LoadBalancerSupport implements Traceab
                     LOG.trace("Failover is round robin enabled and therefore 
starting from the first endpoint");
                     index = 0;
                     counter.set(0);
+                } else if (isSticky() && start > 0) {
+                    // sticky mode started from the last known good endpoint, 
so the endpoints
+                    // before it have not been tried yet
+                    LOG.trace("Failover is sticky enabled and therefore 
continuing from the first endpoint");
+                    index = 0;
                 } else {
                     // no more processors to try
                     LOG.trace("Breaking out of failover as we reached the end 
of endpoints to use for failover");
@@ -254,6 +261,14 @@ public class FailOverLoadBalancer extends 
LoadBalancerSupport implements Traceab
                 }
             }
 
+            if (copy != null && isSticky() && !isRoundRobin() && index == 
start) {
+                // sticky mode (without round robin) has tried all endpoints 
once
+                LOG.trace("Breaking out of failover as all endpoints have been 
tried");
+                ExchangeHelper.copyResults(exchange, copy);
+                callback.done(false);
+                return;
+            }
+
             // try again but copy original exchange before we failover
             copy = prepareExchangeForFailover(exchange);
             AsyncProcessor processor = processors[index];
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/processor/FailoverStickyWrapAroundTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/processor/FailoverStickyWrapAroundTest.java
new file mode 100644
index 000000000000..6bd3909596dc
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/processor/FailoverStickyWrapAroundTest.java
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.processor;
+
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.apache.camel.CamelExecutionException;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.builder.RouteBuilder;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Sticky failover (without round robin) must also try the endpoints before 
the last known good endpoint, once each.
+ */
+public class FailoverStickyWrapAroundTest extends ContextTestSupport {
+
+    private final Set<String> down = ConcurrentHashMap.newKeySet();
+
+    @Test
+    public void testFailoverStickyContinuesFromFirstEndpoint() throws 
Exception {
+        // a and b are down, so c becomes the last known good endpoint
+        down.add("a");
+        down.add("b");
+        getMockEndpoint("mock:a").expectedMessageCount(1);
+        getMockEndpoint("mock:b").expectedMessageCount(1);
+        getMockEndpoint("mock:c").expectedBodiesReceived("Hello World");
+        template.sendBody("direct:start", "Hello World");
+        assertMockEndpointsSatisfied();
+
+        // now c is down and a and b are up: sticky starts from c, and must 
then continue from a
+        resetMocks();
+        down.clear();
+        down.add("c");
+        getMockEndpoint("mock:a").expectedBodiesReceived("Bye World");
+        getMockEndpoint("mock:b").expectedMessageCount(0);
+        getMockEndpoint("mock:c").expectedBodiesReceived("Bye World");
+        template.sendBody("direct:start", "Bye World");
+        assertMockEndpointsSatisfied();
+
+        // and a is now the last known good endpoint
+        resetMocks();
+        getMockEndpoint("mock:a").expectedBodiesReceived("Hi World");
+        getMockEndpoint("mock:b").expectedMessageCount(0);
+        getMockEndpoint("mock:c").expectedMessageCount(0);
+        template.sendBody("direct:start", "Hi World");
+        assertMockEndpointsSatisfied();
+    }
+
+    @Test
+    public void testFailoverStickyTriesEachEndpointOnceWhenAllDown() throws 
Exception {
+        down.add("a");
+        down.add("b");
+        getMockEndpoint("mock:c").expectedMessageCount(1);
+        template.sendBody("direct:start", "Hello World");
+        assertMockEndpointsSatisfied();
+
+        resetMocks();
+        down.add("c");
+        getMockEndpoint("mock:a").expectedMessageCount(1);
+        getMockEndpoint("mock:b").expectedMessageCount(1);
+        getMockEndpoint("mock:c").expectedMessageCount(1);
+        assertThrows(CamelExecutionException.class, () -> 
template.sendBody("direct:start", "Bye World"));
+        assertMockEndpointsSatisfied();
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("direct:start")
+                        .loadBalance().failover(-1, false, false, true)
+                        .to("direct:a", "direct:b", "direct:c");
+
+                from("direct:a").to("mock:a").process(e -> failIfDown("a"));
+                from("direct:b").to("mock:b").process(e -> failIfDown("b"));
+                from("direct:c").to("mock:c").process(e -> failIfDown("c"));
+            }
+        };
+    }
+
+    private void failIfDown(String name) {
+        if (down.contains(name)) {
+            throw new IllegalArgumentException(name + " is down");
+        }
+    }
+}

Reply via email to