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 4fe61e034b8b CAMEL-26784: camel-hazelcast - HazelcastRoutePolicy 
releases the leadership when its route is removed and keeps a follower stopped 
on resume (#29532)
4fe61e034b8b is described below

commit 4fe61e034b8ba7fa2161fc71af9e7163f0719137
Author: allthingssecurity <[email protected]>
AuthorDate: Tue Nov 10 18:39:21 2026 +0530

    CAMEL-26784: camel-hazelcast - HazelcastRoutePolicy releases the leadership 
when its route is removed and keeps a follower stopped on resume (#29532)
    
    HazelcastRoutePolicy did not override onRemove, so a route removed or
    reloaded on the leader kept the Hazelcast lock and the route then ran 
nowhere
    in the cluster. It did not override onResume either, so a resume on a
    follower started its consumer next to the leader's. The policy now counts 
its
    routes and the removal of the last one ends the leadership (the thread waits
    on a latch and releases the lock without being interrupted, so a client
    instance can unlock), onInit starts a leadership thread for a route added
    again, and onResume keeps a follower's consumer stopped. "Leadership lost" 
is
    now logged (the condition was inverted).
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../hazelcast/policy/HazelcastRoutePolicy.java     | 199 ++++++++++--
 .../HazelcastRoutePolicyRouteLifecycleTest.java    | 360 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |   8 +
 3 files changed, 538 insertions(+), 29 deletions(-)

diff --git 
a/components/camel-hazelcast/src/main/java/org/apache/camel/component/hazelcast/policy/HazelcastRoutePolicy.java
 
b/components/camel-hazelcast/src/main/java/org/apache/camel/component/hazelcast/policy/HazelcastRoutePolicy.java
index 0956bcb8abac..9bf14d299004 100644
--- 
a/components/camel-hazelcast/src/main/java/org/apache/camel/component/hazelcast/policy/HazelcastRoutePolicy.java
+++ 
b/components/camel-hazelcast/src/main/java/org/apache/camel/component/hazelcast/policy/HazelcastRoutePolicy.java
@@ -16,8 +16,11 @@
  */
 package org.apache.camel.component.hazelcast.policy;
 
+import java.util.ArrayList;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Set;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
@@ -43,8 +46,13 @@ public class HazelcastRoutePolicy extends RoutePolicySupport 
implements CamelCon
     private final boolean managedInstance;
     private final AtomicBoolean leader;
     private final Set<Route> suspendedRoutes;
+    private final Set<Route> routes;
+    private final Set<Route> suspendedWhileStopped;
+    // the leadership threads told to end that may still wait for or release 
the lock (guarded by lock)
+    private final List<Future<Void>> releasedFutures;
 
-    private Route route;
+    // a route of the policy, for JMX
+    private volatile Route route;
     private CamelContext camelContext;
     private ExecutorService executorService;
     private HazelcastInstance instance;
@@ -55,6 +63,8 @@ public class HazelcastRoutePolicy extends RoutePolicySupport 
implements CamelCon
     private TimeUnit tryLockTimeoutUnit;
     private IMap<String, String> locks;
     private volatile Future<Void> future;
+    // counted down to make the leadership thread release the lock and end 
(guarded by lock)
+    private CountDownLatch leadershipRelease;
     private boolean shouldStopConsumer;
 
     public HazelcastRoutePolicy() {
@@ -69,6 +79,9 @@ public class HazelcastRoutePolicy extends RoutePolicySupport 
implements CamelCon
         this.instance = instance;
         this.managedInstance = managedInstance;
         this.suspendedRoutes = new HashSet<>();
+        this.routes = new HashSet<>();
+        this.suspendedWhileStopped = new HashSet<>();
+        this.releasedFutures = new ArrayList<>();
         this.leader = new AtomicBoolean();
         this.lockMapName = null;
         this.lockKey = null;
@@ -93,7 +106,34 @@ public class HazelcastRoutePolicy extends 
RoutePolicySupport implements CamelCon
     @Override
     public void onInit(Route route) {
         super.onInit(route);
-        this.route = route;
+
+        lock.lock();
+        try {
+            addRoute(route);
+        } finally {
+            lock.unlock();
+        }
+    }
+
+    @Override
+    public void onRemove(Route route) {
+        lock.lock();
+        try {
+            suspendedRoutes.remove(route);
+            suspendedWhileStopped.remove(route);
+            if (routes.remove(route)) {
+                if (this.route == route) {
+                    this.route = routes.isEmpty() ? null : 
routes.iterator().next();
+                }
+                if (routes.isEmpty()) {
+                    // no route uses this policy any more: give up the 
leadership, so another node runs the route. The
+                    // thread is only interrupted when the CamelContext stops 
(as the stop of the policy does)
+                    stopLeadership(camelContext != null && 
camelContext.isStopping());
+                }
+            }
+        } finally {
+            lock.unlock();
+        }
     }
 
     @Override
@@ -108,6 +148,7 @@ public class HazelcastRoutePolicy extends 
RoutePolicySupport implements CamelCon
         lock.lock();
         try {
             suspendedRoutes.remove(route);
+            suspendedWhileStopped.remove(route);
         } finally {
             lock.unlock();
         }
@@ -117,7 +158,31 @@ public class HazelcastRoutePolicy extends 
RoutePolicySupport implements CamelCon
     public void onSuspend(Route route) {
         lock.lock();
         try {
-            suspendedRoutes.remove(route);
+            if (suspendedRoutes.remove(route)) {
+                suspendedWhileStopped.add(route);
+            }
+        } finally {
+            lock.unlock();
+        }
+    }
+
+    @Override
+    public void onResume(Route route) {
+        lock.lock();
+        try {
+            boolean stoppedByPolicy = suspendedWhileStopped.remove(route);
+            if (!leader.get() && shouldStopConsumer) {
+                // resuming the route resumed its consumer, which a follower 
keeps stopped (as when the route starts)
+                stopConsumer(route);
+            } else if (stoppedByPolicy) {
+                // the leadership was taken while the route was suspended: 
resuming a consumer that this policy had
+                // stopped does not start it again (a SEDA consumer is marked 
started without consuming), so restart it
+                LOGGER.debug("Restarting consumer for {} ({})", route.getId(), 
route.getConsumer());
+                stopConsumer(route.getConsumer());
+                startConsumer(route.getConsumer());
+            }
+        } catch (Exception e) {
+            handleException(e);
         } finally {
             lock.unlock();
         }
@@ -133,17 +198,19 @@ public class HazelcastRoutePolicy extends 
RoutePolicySupport implements CamelCon
         executorService = 
getCamelContext().getExecutorServiceManager().newSingleThreadExecutor(this, 
"HazelcastRoutePolicy");
 
         locks = instance.getMap(lockMapName);
-        future = executorService.submit(this::acquireLeadership);
+        // under the lock: BaseService.start() holds it (the same lock as the 
route callbacks of the policy)
+        if (!routes.isEmpty()) {
+            startLeadership();
+        }
 
         super.doStart();
     }
 
     @Override
     protected void doStop() throws Exception {
-        if (future != null) {
-            future.cancel(true);
-            future = null;
-        }
+        // also interrupt the threads, so a wait for the lock does not delay 
the stop on a Hazelcast client (on a member
+        // tryLock ends after its timeout). Under the lock, which 
BaseService.stop() holds
+        stopLeadership(true);
 
         if (managedInstance) {
             instance.shutdown();
@@ -157,6 +224,49 @@ public class HazelcastRoutePolicy extends 
RoutePolicySupport implements CamelCon
     //
     // 
*************************************************************************
 
+    // under the lock
+    private void addRoute(Route route) {
+        if (routes.add(route)) {
+            if (this.route == null) {
+                this.route = route;
+            }
+            // a route added to a policy that gave up the leadership when its 
routes were removed
+            if (leadershipRelease == null && isStarted()) {
+                startLeadership();
+            }
+        }
+    }
+
+    // under the lock
+    private void startLeadership() {
+        CountDownLatch release = new CountDownLatch(1);
+        leadershipRelease = release;
+        future = executorService.submit(() -> acquireLeadership(release));
+    }
+
+    // under the lock: the leadership thread releases the lock and ends, and 
this node is no longer the leader. The
+    // thread is told to end with its own latch, as isRunAllowed() stays true 
while the policy runs, and is not
+    // interrupted unless the policy stops: an interrupted thread cannot 
release the lock on a client instance, and a
+    // wait for the lock ends by itself after the tryLock timeout
+    private void stopLeadership(boolean interrupt) {
+        if (leadershipRelease != null) {
+            leadershipRelease.countDown();
+            leadershipRelease = null;
+        }
+        if (future != null) {
+            // a thread that waits for the lock ends after the tryLock 
timeout, or (on a Hazelcast client) when it is
+            // interrupted
+            releasedFutures.add(future);
+            future = null;
+        }
+        releasedFutures.removeIf(Future::isDone);
+        if (interrupt) {
+            releasedFutures.forEach(f -> f.cancel(true));
+            releasedFutures.clear();
+        }
+        setLeader(false);
+    }
+
     protected void setLeader(boolean isLeader) {
         if (isLeader && leader.compareAndSet(false, isLeader)) {
             LOGGER.info("Leadership taken (map={}, key={}, val={})",
@@ -165,13 +275,11 @@ public class HazelcastRoutePolicy extends 
RoutePolicySupport implements CamelCon
                     lockValue);
 
             startAllStoppedConsumers();
-        } else {
-            if (!leader.getAndSet(isLeader) && isLeader) {
-                LOGGER.info("Leadership lost (map={}, key={} val={})",
-                        lockMapName,
-                        lockKey,
-                        lockValue);
-            }
+        } else if (!isLeader && leader.getAndSet(false)) {
+            LOGGER.info("Leadership lost (map={}, key={}, val={})",
+                    lockMapName,
+                    lockKey,
+                    lockValue);
         }
     }
 
@@ -212,16 +320,18 @@ public class HazelcastRoutePolicy extends 
RoutePolicySupport implements CamelCon
 
     @ManagedAttribute(description = "The route id")
     public String getRouteId() {
-        if (route != null) {
-            return route.getId();
+        Route r = route;
+        if (r != null) {
+            return r.getId();
         }
         return null;
     }
 
     @ManagedAttribute(description = "The consumer endpoint", mask = true)
     public String getEndpointUrl() {
-        if (route != null && route.getConsumer() != null && 
route.getConsumer().getEndpoint() != null) {
-            return route.getConsumer().getEndpoint().toString();
+        Route r = route;
+        if (r != null && r.getConsumer() != null && 
r.getConsumer().getEndpoint() != null) {
+            return r.getConsumer().getEndpoint().toString();
         }
         return null;
     }
@@ -294,18 +404,18 @@ public class HazelcastRoutePolicy extends 
RoutePolicySupport implements CamelCon
     //
     // 
*************************************************************************
 
-    private Void acquireLeadership() throws Exception {
+    private Void acquireLeadership(CountDownLatch release) {
         boolean locked = false;
-        while (isRunAllowed()) {
+        while (isRunAllowed() && release.getCount() > 0) {
             try {
                 locked = locks.tryLock(lockKey, tryLockTimeout, 
tryLockTimeoutUnit);
-                if (locked) {
+                if (locked && release.getCount() > 0) {
                     locks.put(lockKey, lockValue);
-                    setLeader(true);
-
-                    // Wait almost forever
-                    Thread.sleep(Long.MAX_VALUE);
-                } else {
+                    if (takeLeadership(release)) {
+                        // hold the lock until the policy stops or its routes 
are removed
+                        release.await();
+                    }
+                } else if (!locked) {
                     LOGGER.debug("Failed to acquire lock (map={}, key={}, 
val={}) after {} {}",
                             lockMapName,
                             lockKey,
@@ -319,8 +429,7 @@ public class HazelcastRoutePolicy extends 
RoutePolicySupport implements CamelCon
                 getExceptionHandler().handleException(e);
             } finally {
                 if (locked) {
-                    locks.remove(lockKey);
-                    locks.unlock(lockKey);
+                    releaseLock();
                     locked = false;
                 }
 
@@ -330,4 +439,36 @@ public class HazelcastRoutePolicy extends 
RoutePolicySupport implements CamelCon
 
         return null;
     }
+
+    private boolean takeLeadership(CountDownLatch release) {
+        // under the lock, so the leadership is not taken after the routes 
were removed (stopLeadership)
+        lock.lock();
+        try {
+            if (release.getCount() == 0) {
+                return false;
+            }
+            setLeader(true);
+            return true;
+        } finally {
+            lock.unlock();
+        }
+    }
+
+    private void releaseLock() {
+        // with the interrupt flag cleared: on a client instance an 
interrupted thread fails to remove and unlock
+        boolean interrupted = Thread.interrupted();
+        try {
+            try {
+                locks.remove(lockKey);
+            } finally {
+                locks.unlock(lockKey);
+            }
+        } catch (Exception e) {
+            getExceptionHandler().handleException(e);
+        } finally {
+            if (interrupted) {
+                Thread.currentThread().interrupt();
+            }
+        }
+    }
 }
diff --git 
a/components/camel-hazelcast/src/test/java/org/apache/camel/component/hazelcast/policy/HazelcastRoutePolicyRouteLifecycleTest.java
 
b/components/camel-hazelcast/src/test/java/org/apache/camel/component/hazelcast/policy/HazelcastRoutePolicyRouteLifecycleTest.java
new file mode 100644
index 000000000000..44ae27d53825
--- /dev/null
+++ 
b/components/camel-hazelcast/src/test/java/org/apache/camel/component/hazelcast/policy/HazelcastRoutePolicyRouteLifecycleTest.java
@@ -0,0 +1,360 @@
+/*
+ * 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.hazelcast.policy;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+import com.hazelcast.client.HazelcastClient;
+import com.hazelcast.client.config.ClientConfig;
+import com.hazelcast.cluster.Address;
+import com.hazelcast.config.Config;
+import com.hazelcast.core.Hazelcast;
+import com.hazelcast.core.HazelcastInstance;
+import org.apache.camel.CamelContext;
+import org.apache.camel.Route;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.service.ServiceHelper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * {@link HazelcastRoutePolicy} on two nodes (two CamelContexts on one 
embedded Hazelcast member, the lock is held by
+ * the leadership thread of a node) when a route is removed, added again, 
suspended and resumed.
+ */
+class HazelcastRoutePolicyRouteLifecycleTest {
+
+    private static final String ROUTE_ID = "clustered";
+
+    private final List<CamelContext> contexts = new ArrayList<>();
+    private HazelcastInstance member;
+    private HazelcastInstance client;
+
+    @BeforeEach
+    void startMember() {
+        member = Hazelcast.newHazelcastInstance(memberConfig("route-policy-" + 
UUID.randomUUID()));
+    }
+
+    @AfterEach
+    void stopNodes() {
+        contexts.forEach(CamelContext::stop);
+        if (client != null) {
+            client.shutdown();
+        }
+        if (member != null) {
+            member.shutdown();
+        }
+    }
+
+    @Test
+    void removedRouteGivesUpTheLeadership() throws Exception {
+        HazelcastRoutePolicy policyA = newPolicy("a");
+        CamelContext a = startNode("a", policyA);
+        awaitLeader(a, policyA);
+        HazelcastRoutePolicy policyB = newPolicy("b");
+        CamelContext b = startNode("b", policyB);
+        assertFollower(b, policyB);
+
+        a.getRouteController().stopRoute(ROUTE_ID);
+        a.removeRoute(ROUTE_ID);
+
+        // the route runs nowhere on node a any more, so node b must take over
+        awaitLeader(b, policyB);
+        assertFalse(policyA.isLeader(), "The policy of the removed route is 
still the leader");
+        assertConsumes(b);
+    }
+
+    @Test
+    void removedRouteGivesUpTheLeadershipWithAClientInstance() throws 
Exception {
+        // node a uses a Hazelcast client: its thread must release the lock 
(an interrupted client call fails)
+        HazelcastRoutePolicy policyA = newPolicy(newClient(), "a");
+        CamelContext a = startNode("a", policyA);
+        awaitLeader(a, policyA);
+        HazelcastRoutePolicy policyB = newPolicy("b");
+        CamelContext b = startNode("b", policyB);
+        assertFollower(b, policyB);
+
+        a.getRouteController().stopRoute(ROUTE_ID);
+        a.removeRoute(ROUTE_ID);
+
+        awaitLeader(b, policyB);
+        assertFalse(policyA.isLeader(), "The policy of the removed route is 
still the leader");
+        assertConsumes(b);
+    }
+
+    @Test
+    void policySharedByTwoRoutesKeepsTheLeadershipUntilBothAreRemoved() throws 
Exception {
+        HazelcastRoutePolicy policyA = newPolicy("a");
+        CamelContext a = startNode("a", policyA);
+        a.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from("seda:other").routeId("other").routePolicy(policyA).to("mock:other");
+            }
+        });
+        awaitLeader(a, policyA);
+        HazelcastRoutePolicy policyB = newPolicy("b");
+        CamelContext b = startNode("b", policyB);
+        assertFollower(b, policyB);
+
+        // the policy still has a route on node a
+        a.getRouteController().stopRoute("other");
+        a.removeRoute("other");
+        // JMX shows a route that still uses the policy
+        assertEquals(ROUTE_ID, policyA.getRouteId());
+        await().during(1, TimeUnit.SECONDS).atMost(5, TimeUnit.SECONDS)
+                .untilAsserted(() -> {
+                    assertTrue(policyA.isLeader(), "Node a gave up the 
leadership while a route still uses the policy");
+                    assertFalse(policyB.isLeader(), "Node b took the 
leadership while node a has a route");
+                });
+        assertConsumes(a);
+
+        a.getRouteController().stopRoute(ROUTE_ID);
+        a.removeRoute(ROUTE_ID);
+        assertNull(policyA.getRouteId());
+        awaitLeader(b, policyB);
+        assertConsumes(b);
+    }
+
+    @Test
+    void routeReloadedWithANewPolicyRunsOnOneNode() throws Exception {
+        HazelcastRoutePolicy policyA = newPolicy("a");
+        CamelContext a = startNode("a", policyA);
+        awaitLeader(a, policyA);
+        HazelcastRoutePolicy policyB = newPolicy("b");
+        CamelContext b = startNode("b", policyB);
+        assertFollower(b, policyB);
+
+        // a reload on the leader: the route is removed and added again, with 
a new policy
+        a.getRouteController().stopRoute(ROUTE_ID);
+        a.removeRoute(ROUTE_ID);
+        HazelcastRoutePolicy policyA2 = newPolicy("a");
+        a.addRoutes(route(policyA2));
+
+        awaitOneLeader(a, policyA2, b, policyB);
+        assertFalse(policyA.isLeader(), "The policy of the removed route is 
still the leader");
+    }
+
+    @Test
+    void routeAddedAgainWithTheSamePolicyRunsOnOneNode() throws Exception {
+        HazelcastRoutePolicy policyA = newPolicy("a");
+        CamelContext a = startNode("a", policyA);
+        awaitLeader(a, policyA);
+        HazelcastRoutePolicy policyB = newPolicy("b");
+        CamelContext b = startNode("b", policyB);
+        assertFollower(b, policyB);
+
+        // the same policy instance (for example a bean referenced by the 
route) for the route added again
+        a.getRouteController().stopRoute(ROUTE_ID);
+        a.removeRoute(ROUTE_ID);
+        a.addRoutes(route(policyA));
+
+        awaitOneLeader(a, policyA, b, policyB);
+    }
+
+    @Test
+    void resumedRouteOnAFollowerKeepsItsConsumerStopped() throws Exception {
+        HazelcastRoutePolicy policyA = newPolicy("a");
+        CamelContext a = startNode("a", policyA);
+        awaitLeader(a, policyA);
+        HazelcastRoutePolicy policyB = newPolicy("b");
+        CamelContext b = startNode("b", policyB);
+        assertFollower(b, policyB);
+
+        b.getRouteController().suspendRoute(ROUTE_ID);
+        b.getRouteController().resumeRoute(ROUTE_ID);
+
+        assertFalse(consumerStarted(b), "The consumer of the follower runs 
after the route was resumed");
+        assertFalse(policyB.isLeader());
+
+        // and the follower still starts its consumer when it takes over
+        a.stop();
+        awaitLeader(b, policyB);
+        assertConsumes(b);
+    }
+
+    @Test
+    void routeResumedAfterTheFollowerTookOverConsumes() throws Exception {
+        HazelcastRoutePolicy policyA = newPolicy("a");
+        CamelContext a = startNode("a", policyA);
+        awaitLeader(a, policyA);
+        HazelcastRoutePolicy policyB = newPolicy("b");
+        CamelContext b = startNode("b", policyB);
+        assertFollower(b, policyB);
+
+        // the route of the follower is suspended when the follower takes over
+        b.getRouteController().suspendRoute(ROUTE_ID);
+        a.stop();
+        await().atMost(20, TimeUnit.SECONDS).until(policyB::isLeader);
+
+        b.getRouteController().resumeRoute(ROUTE_ID);
+        assertTrue(consumerStarted(b), "The consumer of the leader is not 
started after the route was resumed");
+        assertConsumes(b);
+    }
+
+    @Test
+    void restartedNodeCompetesForTheLeadershipAgain() throws Exception {
+        HazelcastRoutePolicy policyA = newPolicy("a");
+        CamelContext a = startNode("a", policyA);
+        awaitLeader(a, policyA);
+        HazelcastRoutePolicy policyB = newPolicy("b");
+        CamelContext b = startNode("b", policyB);
+        assertFollower(b, policyB);
+
+        // the stop of the CamelContext removes the route from the policy 
(onRemove), and the start gives it back
+        a.stop();
+        awaitLeader(b, policyB);
+        a.start();
+        assertFollower(a, policyA);
+
+        // node a waits for the lock again, so it takes over when node b stops
+        b.stop();
+        awaitLeader(a, policyA);
+        assertConsumes(a);
+    }
+
+    @Test
+    void stopAfterTheRouteWasRemovedDoesNotWaitForTheLock() throws Exception {
+        HazelcastRoutePolicy policyA = newPolicy("a");
+        CamelContext a = startNode("a", policyA);
+        awaitLeader(a, policyA);
+        // node b uses a Hazelcast client, whose wait in tryLock ends when the 
thread is interrupted (on a member it
+        // ends after the tryLock timeout)
+        HazelcastRoutePolicy policyB = newPolicy(newClient(), "b");
+        // the follower waits a long time for the lock, longer than the 
graceful shutdown of its thread pool
+        policyB.setTryLockTimeout(60, TimeUnit.SECONDS);
+        CamelContext b = startNode("b", policyB);
+        
b.getExecutorServiceManager().setShutdownAwaitTermination(TimeUnit.SECONDS.toMillis(30));
+        assertFollower(b, policyB);
+
+        // the route is removed while the leadership thread of node b waits 
for the lock
+        b.getRouteController().stopRoute(ROUTE_ID);
+        b.removeRoute(ROUTE_ID);
+
+        // the stop of the policy ends that thread too, instead of waiting for 
the end of its tryLock
+        long start = System.nanoTime();
+        b.stop();
+        long took = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
+        assertTrue(took < TimeUnit.SECONDS.toMillis(10), "The stop of node b 
took " + took + " ms");
+    }
+
+    private HazelcastRoutePolicy newPolicy(String node) {
+        return newPolicy(member, node);
+    }
+
+    private static HazelcastRoutePolicy newPolicy(HazelcastInstance instance, 
String node) {
+        HazelcastRoutePolicy policy = new HazelcastRoutePolicy(instance);
+        policy.setLockMapName("camel-route-policy");
+        policy.setLockKey("route-lock");
+        policy.setLockValue(node);
+        policy.setTryLockTimeout(1, TimeUnit.SECONDS);
+        return policy;
+    }
+
+    private CamelContext startNode(String node, HazelcastRoutePolicy policy) 
throws Exception {
+        DefaultCamelContext context = new DefaultCamelContext();
+        context.disableJMX();
+        context.getCamelContextExtension().setName(node);
+        contexts.add(context);
+        context.addRoutes(route(policy));
+        context.start();
+        return context;
+    }
+
+    private static RouteBuilder route(HazelcastRoutePolicy policy) {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from("seda:in").routeId(ROUTE_ID).routePolicy(policy).to("mock:out");
+            }
+        };
+    }
+
+    private static boolean consumerStarted(CamelContext context) {
+        Route route = context.getRoute(ROUTE_ID);
+        return route != null && ServiceHelper.isStarted(route.getConsumer());
+    }
+
+    private static void awaitLeader(CamelContext context, HazelcastRoutePolicy 
policy) {
+        await().atMost(20, TimeUnit.SECONDS)
+                .untilAsserted(() -> {
+                    assertTrue(policy.isLeader(), "Node " + context.getName() 
+ " is not the leader");
+                    assertTrue(consumerStarted(context), "The consumer of the 
leader " + context.getName() + " is not started");
+                });
+    }
+
+    private static void assertFollower(CamelContext context, 
HazelcastRoutePolicy policy) {
+        assertFalse(policy.isLeader(), "Node " + context.getName() + " is the 
leader");
+        assertFalse(consumerStarted(context), "The consumer of the follower " 
+ context.getName() + " is started");
+    }
+
+    private static void awaitOneLeader(
+            CamelContext a, HazelcastRoutePolicy policyA, CamelContext b, 
HazelcastRoutePolicy policyB)
+            throws InterruptedException {
+        await().atMost(20, TimeUnit.SECONDS)
+                .untilAsserted(() -> {
+                    assertNotEquals(policyA.isLeader(), policyB.isLeader(), 
"Exactly one node must be the leader");
+                    assertTrue(consumerStarted(policyA.isLeader() ? a : b), 
"The consumer of the leader is not started");
+                    assertFalse(consumerStarted(policyA.isLeader() ? b : a), 
"The consumer of the follower is started");
+                });
+        assertConsumes(policyA.isLeader() ? a : b);
+    }
+
+    private static void assertConsumes(CamelContext context) throws 
InterruptedException {
+        MockEndpoint mock = context.getEndpoint("mock:out", 
MockEndpoint.class);
+        mock.reset();
+        mock.setResultWaitTime(TimeUnit.SECONDS.toMillis(20));
+        mock.expectedBodiesReceived("hello");
+        
context.createFluentProducerTemplate().to("seda:in").withBody("hello").send();
+        mock.assertIsSatisfied();
+    }
+
+    private HazelcastInstance newClient() {
+        Address address = member.getCluster().getLocalMember().getAddress();
+        ClientConfig config = new ClientConfig();
+        config.setClusterName(member.getConfig().getClusterName());
+        config.getNetworkConfig().addAddress(address.getHost() + ":" + 
address.getPort());
+        
config.getConnectionStrategyConfig().getConnectionRetryConfig().setClusterConnectTimeoutMillis(30000);
+        client = HazelcastClient.newHazelcastClient(config);
+        return client;
+    }
+
+    private static Config memberConfig(String clusterName) {
+        Config config = new Config();
+        config.setClusterName(clusterName);
+        // a client of the test connects to the member on the loopback address
+        config.setProperty("hazelcast.local.localAddress", "127.0.0.1");
+        
config.getNetworkConfig().getInterfaces().setEnabled(true).addInterface("127.0.0.1");
+        config.getMetricsConfig().setEnabled(false);
+        
config.getNetworkConfig().getJoin().getMulticastConfig().setEnabled(false);
+        
config.getNetworkConfig().getJoin().getAutoDetectionConfig().setEnabled(false);
+        config.getNetworkConfig().getJoin().getTcpIpConfig().setEnabled(false);
+        return config;
+    }
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index bc04938d7a7a..f96092f0c261 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -2895,6 +2895,14 @@ for another reason, such as a destroyed topic, the 
consumer reports it to its ex
 default); restart the route to receive messages again. A listener cancelled 
because the Hazelcast instance or the
 CamelContext is shutting down is only logged at DEBUG.
 
+=== camel-hazelcast - HazelcastRoutePolicy gives up the leadership when its 
route is removed
+
+A `HazelcastRoutePolicy` kept the Hazelcast lock until the CamelContext 
stopped, also after its route was removed (with
+`removeRoute`, or by a route reload), so no other node could become the leader 
and the route ran nowhere in the
+cluster. The policy now releases the lock when the last route that uses it is 
removed, so another node takes over, and
+it competes for the lock again when a route that uses it is added again (with 
the same or a new policy instance). So a
+route reloaded on the leader can now move to another node.
+
 === camel-sql, camel-mybatis, camel-jooq - an exchange marked rollback only 
last is not consumed either
 
 The SQL, MyBatis and jOOQ consumers already left a message unconsumed when the 
route marked the exchange rollback

Reply via email to