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 136ffd929263 CAMEL-26802: camel-zookeeper-master - MasterRoutePolicy
gives up the mastership when its route is removed, and keeps a slave stopped
(#29583)
136ffd929263 is described below
commit 136ffd929263047c90bc2e6ce87d91e3a670ae06
Author: allthingssecurity <[email protected]>
AuthorDate: Fri Nov 13 03:15:16 2026 +0530
CAMEL-26802: camel-zookeeper-master - MasterRoutePolicy gives up the
mastership when its route is removed, and keeps a slave stopped (#29583)
MasterRoutePolicy joined the ZooKeeper group in doStart and left it only
when
the CamelContext stopped, so a removed route kept the mastership (the route
ran
nowhere), a route added again failed with a NullPointerException or never
started its consumer, and a slave started its consumer on startRoute or
resumeRoute.
- onRemove leaves the group, so another node becomes the master
- doStart joins only when the policy has a route; a route added to a running
CamelContext joins on its RouteAddedEvent, after its consumer was created
- onStart and onResume keep the consumer of a slave stopped
- election and disconnect callbacks ignore a removed route; JMX attributes
read
the group listener once
- with a user CuratorFramework, the group listener is removed from it when
the
policy leaves the group; a failed join undoes itself
- a policy stopped on its own leaves the group as when its route is removed
- 4.24 upgrade-guide entry; the javadoc says to use one policy per route
Follows the same fixes in HazelcastRoutePolicy (CAMEL-26784).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
components/camel-zookeeper-master/pom.xml | 15 +
.../zookeepermaster/policy/MasterRoutePolicy.java | 334 +++++++++++++---
.../MasterRoutePolicyRouteLifecycleTest.java | 441 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 14 +
4 files changed, 753 insertions(+), 51 deletions(-)
diff --git a/components/camel-zookeeper-master/pom.xml
b/components/camel-zookeeper-master/pom.xml
index 21711e5e3b88..6ef9eb82e874 100644
--- a/components/camel-zookeeper-master/pom.xml
+++ b/components/camel-zookeeper-master/pom.xml
@@ -110,6 +110,21 @@
<scope>test</scope>
</dependency>
+ <!-- the ZooKeeper server that MasterRoutePolicyRouteLifecycleTest
runs in the test JVM needs these two
+ (both are "provided" dependencies of zookeeper) -->
+ <dependency>
+ <groupId>io.dropwizard.metrics</groupId>
+ <artifactId>metrics-core</artifactId>
+ <version>${metrics-version}</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.xerial.snappy</groupId>
+ <artifactId>snappy-java</artifactId>
+ <version>${snappy-java-version}</version>
+ <scope>test</scope>
+ </dependency>
+
<!-- test infra -->
<dependency>
<groupId>org.apache.camel</groupId>
diff --git
a/components/camel-zookeeper-master/src/main/java/org/apache/camel/component/zookeepermaster/policy/MasterRoutePolicy.java
b/components/camel-zookeeper-master/src/main/java/org/apache/camel/component/zookeepermaster/policy/MasterRoutePolicy.java
index f4e633892008..978ae584567d 100644
---
a/components/camel-zookeeper-master/src/main/java/org/apache/camel/component/zookeepermaster/policy/MasterRoutePolicy.java
+++
b/components/camel-zookeeper-master/src/main/java/org/apache/camel/component/zookeepermaster/policy/MasterRoutePolicy.java
@@ -26,6 +26,7 @@ import java.util.concurrent.locks.ReentrantLock;
import org.apache.camel.CamelContext;
import org.apache.camel.CamelContextAware;
+import org.apache.camel.NonManagedService;
import org.apache.camel.Route;
import org.apache.camel.api.management.ManagedAttribute;
import org.apache.camel.api.management.ManagedOperation;
@@ -34,7 +35,11 @@ import
org.apache.camel.component.zookeepermaster.CamelNodeState;
import org.apache.camel.component.zookeepermaster.ContainerIdFactory;
import org.apache.camel.component.zookeepermaster.DefaultContainerIdFactory;
import
org.apache.camel.component.zookeepermaster.ZookeeperGroupListenerSupport;
+import org.apache.camel.component.zookeepermaster.group.Group;
+import org.apache.camel.spi.CamelEvent;
+import org.apache.camel.spi.CamelEvent.RouteAddedEvent;
import org.apache.camel.support.RoutePolicySupport;
+import org.apache.camel.support.SimpleEventNotifierSupport;
import org.apache.camel.support.service.ServiceHelper;
import org.apache.camel.util.ObjectHelper;
import org.apache.camel.util.StringHelper;
@@ -49,6 +54,9 @@ import tools.jackson.databind.json.JsonMapper;
* <p/>
* <b>Important:</b> Make sure to set the route to autoStartup=false as the
route lifecycle is controlled by this route
* policy which will start/stop the route accordingly to being the master in
the zookeeper cluster group.
+ * <p/>
+ * Use one policy instance per route. When the route is removed (or reloaded)
the policy leaves the group, so another
+ * node can become the master.
*/
@ManagedResource(description = "Managed MasterRoutePolicy")
public class MasterRoutePolicy extends RoutePolicySupport implements
CamelContextAware {
@@ -77,10 +85,12 @@ public class MasterRoutePolicy extends RoutePolicySupport
implements CamelContex
private int failedStarts;
// read by the group, Curator connection and retry threads
- private volatile ZookeeperGroupListenerSupport groupListener;
+ private volatile ZookeeperGroupListenerSupport<CamelNodeState>
groupListener;
private volatile CamelNodeState thisNodeState;
private CamelContext camelContext;
- private Route route;
+ // the route of the policy: set when the route is initialized, cleared
when it is removed (under the service lock)
+ private volatile Route route;
+ private RouteAddedNotifier routeAddedNotifier;
@Override
public CamelContext getCamelContext() {
@@ -175,30 +185,27 @@ public class MasterRoutePolicy extends RoutePolicySupport
implements CamelContex
@ManagedAttribute(description = "Are we connected to ZooKeeper")
public boolean isConnected() {
- if (groupListener == null) {
- return false;
- }
- return groupListener.getGroup().isConnected();
+ Group<CamelNodeState> group = currentGroup();
+ return group != null && group.isConnected();
}
@ManagedAttribute(description = "Are we the master")
public boolean isMaster() {
- if (groupListener == null) {
- return false;
- }
- return groupListener.getGroup().isMaster();
+ Group<CamelNodeState> group = currentGroup();
+ return group != null && group.isMaster();
}
@ManagedOperation(description = "Information about all the slaves")
public String slaves() {
- if (groupListener == null) {
+ Group<CamelNodeState> group = currentGroup();
+ if (group == null) {
return null;
}
try {
return JsonMapper.builderWithJackson2Defaults()
.enable(SerializationFeature.INDENT_OUTPUT)
.build()
- .writeValueAsString(groupListener.getGroup().slaves());
+ .writeValueAsString(group.slaves());
} catch (Exception e) {
return null;
}
@@ -206,10 +213,11 @@ public class MasterRoutePolicy extends RoutePolicySupport
implements CamelContex
@ManagedOperation(description = "Information about the last event in the
cluster group")
public String lastEvent() {
- if (groupListener == null) {
+ Group<CamelNodeState> group = currentGroup();
+ if (group == null) {
return null;
}
- Object event = groupListener.getGroup().getLastState();
+ Object event = group.getLastState();
return event != null ? event.toString() : null;
}
@@ -221,7 +229,76 @@ public class MasterRoutePolicy extends RoutePolicySupport
implements CamelContex
@Override
public void onInit(Route route) {
super.onInit(route);
- this.route = route;
+
+ lock.lock();
+ try {
+ // a route that is added to a running CamelContext (a route reload
with the same or a new policy) joins the
+ // group when it is added (routeAdded), as its consumer is not
created yet
+ this.route = route;
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ // the route was added to the running CamelContext, after its consumer was
created
+ private void routeAdded(Route added) {
+ lock.lock();
+ try {
+ if (added == this.route && isRunAllowed() && groupListener ==
null) {
+ joinGroup();
+ }
+ } catch (Exception e) {
+ getExceptionHandler().handleException("Failed to join the group
of: " + added.getEndpoint(), e);
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ @Override
+ public void onRemove(Route route) {
+ lock.lock();
+ try {
+ if (route != this.route) {
+ return;
+ }
+ // the route is removed: leave the group, so that another node
becomes the master and runs the route. The
+ // policy is a service of the CamelContext and keeps running, so
it joins again when the route is added again
+ leaveGroup();
+ this.route = null;
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ @Override
+ public void onStart(Route route) {
+ keepConsumerStoppedUnlessMaster(route);
+ }
+
+ @Override
+ public void onResume(Route route) {
+ keepConsumerStoppedUnlessMaster(route);
+ }
+
+ // the route was started or resumed (from JMX, the controlbus, a scheduled
route policy, or as it is not set to
+ // autoStartup=false), which started or resumed its consumer: a node that
is not the master keeps it stopped, and
+ // starts it when it is elected (a resumed consumer that is marked as
started would not be started again then)
+ private void keepConsumerStoppedUnlessMaster(Route route) {
+ leadershipLock.lock();
+ try {
+ // a master that has not started its consumer yet starts it with
its pending start (or retry)
+ if (route != this.route || masterConsumer.get() || isLeader()) {
+ return;
+ }
+ LOG.debug("Not the master. Keeping the consumer stopped: {}",
route.getEndpoint());
+ stopConsumer(route.getConsumer());
+ } catch (Exception e) {
+ getExceptionHandler().handleException("Failed to stop the consumer
of: " + route.getEndpoint()
+ + " which only the master
runs",
+ e);
+ } finally {
+ leadershipLock.unlock();
+ }
}
@Override
@@ -231,41 +308,155 @@ public class MasterRoutePolicy extends
RoutePolicySupport implements CamelContex
ObjectHelper.notNull(camelContext, "CamelContext");
StringHelper.notEmpty("groupName", groupName);
- String path = getCamelClusterPath(groupName);
this.retryExecutor = camelContext.getExecutorServiceManager()
.newSingleThreadScheduledExecutor(this,
"ZooKeeperMasterRoutePolicyRetry");
- this.groupListener = new ZookeeperGroupListenerSupport(path,
route.getEndpoint(), onLockOwned(), onDisconnected());
- this.groupListener.setCamelContext(camelContext);
- this.groupListener.setCurator(curator);
-
this.groupListener.setMaximumConnectionTimeout(maximumConnectionTimeout);
- this.groupListener.setZooKeeperUrl(zooKeeperUrl);
- this.groupListener.setZooKeeperPassword(zooKeeperPassword);
- ServiceHelper.startService(groupListener);
+ this.routeAddedNotifier = new RouteAddedNotifier();
+
camelContext.getManagementStrategy().addEventNotifier(routeAddedNotifier);
+ // a policy added to a running CamelContext is started before its
route is initialized: it joins the group
+ // when the route is added
+ if (route != null) {
+ joinGroup();
+ }
+ }
+
+ // under the service lock: joins the group of the nodes that run the route
+ private void joinGroup() throws Exception {
+ String path = getCamelClusterPath(groupName);
+ ZookeeperGroupListenerSupport<CamelNodeState> listener
+ = new ZookeeperGroupListenerSupport<>(path,
route.getEndpoint(), onLockOwned(), onDisconnected());
+ listener.setCamelContext(camelContext);
+ listener.setCurator(curator);
+ listener.setMaximumConnectionTimeout(maximumConnectionTimeout);
+ listener.setZooKeeperUrl(zooKeeperUrl);
+ listener.setZooKeeperPassword(zooKeeperPassword);
+ // set before it starts, as the group may tell it that this node is
the master while it starts
+ this.groupListener = listener;
+ try {
+ ServiceHelper.startService(listener);
+
+ LOG.info("Attempting to become master for endpoint: {} in {} with
singletonID: {}", route.getEndpoint(),
+ getCamelContext(), getGroupName());
+ thisNodeState = createNodeState(route);
+ listener.updateState(thisNodeState);
+ } catch (Exception e) {
+ // undo the half joined state: the JMX attributes and the
leadership checks must not read the group of a
+ // join that failed, and the policy joins again when the route is
added again or the policy is started again
+ try {
+ closeGroup(listener);
+ } catch (Exception e2) {
+ e.addSuppressed(e2);
+ }
+ throw e;
+ }
+ }
- LOG.info("Attempting to become master for endpoint: {} in {} with
singletonID: {}", route.getEndpoint(),
- getCamelContext(), getGroupName());
- thisNodeState = createNodeState();
- groupListener.updateState(thisNodeState);
+ // under the service lock: leaves the group, and this node is no longer
the master
+ private void leaveGroup() {
+ ZookeeperGroupListenerSupport<CamelNodeState> listener = groupListener;
+ if (listener == null) {
+ return;
+ }
+ try {
+ closeGroup(listener);
+ } catch (Exception e) {
+ LOG.warn("Failed to leave the group of: {}. This exception is
ignored.", route.getEndpoint(), e);
+ }
+ if (camelContext.isStopping()) {
+ LOG.debug("Left the group as the route was removed: {}",
route.getEndpoint());
+ } else {
+ LOG.info("Left the group as the route was removed: {}",
route.getEndpoint());
+ }
+ }
+
+ // under the service lock: closes the group the listener joined (or tried
to join), when the route is removed, the
+ // policy stops, or the join failed. Throws the first failure once all of
it is cleaned up
+ private void closeGroup(ZookeeperGroupListenerSupport<CamelNodeState>
listener) throws Exception {
+ // the client the listener registered itself on, which outlives it
+ CuratorFramework client = registeredCurator();
+ // a start (or retry) that checks the leadership from now on does not
start the consumer
+ groupListener = null;
+ Exception failure = null;
+ // the group thread is interrupted before the leadership lock is
taken: a consumer start in flight on it holds the
+ // lock. Closing the group tells the listeners that it is
disconnected, which stops the consumer
+ try {
+ ServiceHelper.stopAndShutdownServices(listener);
+ } catch (Exception e) {
+ failure = e;
+ }
+ if (client != null) {
+ client.getConnectionStateListenable().removeListener(listener);
+ }
+ leadershipLock.lock();
+ try {
+ thisNodeState = null;
+ failedStarts = 0;
+ cancelRetry();
+ Route current = this.route;
+ if (masterConsumer.getAndSet(false) && current != null) {
+ // a start that completed while the group was closed
+ stopConsumer(current.getConsumer());
+ }
+ } catch (Exception e) {
+ if (failure == null) {
+ failure = e;
+ } else {
+ failure.addSuppressed(e);
+ }
+ } finally {
+ leadershipLock.unlock();
+ }
+ if (failure != null) {
+ throw failure;
+ }
+ }
+
+ // the CuratorFramework the group listener registered itself on
(ZookeeperGroupSupport.setCurator): the curator of
+ // the policy, or else the one named curator in the registry. It does not
register on a client its group factory
+ // creates, which the factory closes
+ private CuratorFramework registeredCurator() {
+ if (curator != null) {
+ return curator;
+ }
+ try {
+ return camelContext.getRegistry().lookupByNameAndType("curator",
CuratorFramework.class);
+ } catch (RuntimeException e) {
+ return null;
+ }
}
@Override
protected void doStop() throws Exception {
super.doStop();
+ if (routeAddedNotifier != null) {
+
camelContext.getManagementStrategy().removeEventNotifier(routeAddedNotifier);
+ routeAddedNotifier = null;
+ }
+
// the retry thread and the group thread are interrupted first (as the
group did before): a consumer start in
// flight on them holds the leadership lock, and the stop must not
wait for it to complete
if (retryExecutor != null) {
camelContext.getExecutorServiceManager().shutdownNow(retryExecutor);
retryExecutor = null;
}
- ServiceHelper.stopAndShutdownServices(groupListener);
- leadershipLock.lock();
- try {
- failedStarts = 0;
- cancelRetry();
- masterConsumer.set(false);
- } finally {
- leadershipLock.unlock();
+ // the same clean up as when the route is removed (the policy may be
stopped on its own, from JMX), so that a
+ // shared CuratorFramework does not keep the listener and the JMX
attributes do not read a closed group
+ ZookeeperGroupListenerSupport<CamelNodeState> listener = groupListener;
+ if (listener != null) {
+ try {
+ closeGroup(listener);
+ } catch (Exception e) {
+ LOG.warn("Failed to leave the group of: {}. This exception is
ignored.", groupName, e);
+ }
+ } else {
+ leadershipLock.lock();
+ try {
+ failedStarts = 0;
+ cancelRetry();
+ masterConsumer.set(false);
+ } finally {
+ leadershipLock.unlock();
+ }
}
}
@@ -282,21 +473,23 @@ public class MasterRoutePolicy extends RoutePolicySupport
implements CamelContex
}
// the event may be stale: the group sets connected to false
before it tells the listeners that it is
// disconnected, and the disconnect may have been handled since
the event checked it
- if (!isRunAllowed() || masterConsumer.get() || !isLeader()) {
+ Route current = this.route;
+ if (!isRunAllowed() || masterConsumer.get() || !isLeader() ||
current == null
+ || current.getConsumer() == null) {
return;
}
try {
// ensure endpoint is also started
- LOG.info("Elected as master. Starting consumer: {}",
route.getEndpoint());
- startConsumer(route.getConsumer());
+ LOG.info("Elected as master. Starting consumer: {}",
current.getEndpoint());
+ startConsumer(current.getConsumer());
} catch (Exception e) {
if (!isRunAllowed()) {
- LOG.debug("Stopping while starting master consumer for:
{}", route.getEndpoint(), e);
+ LOG.debug("Stopping while starting master consumer for:
{}", current.getEndpoint(), e);
return;
}
// try again later, as long as this node is the master (no
later event may come)
failedStarts++;
- getExceptionHandler().handleException("Failed to start master
consumer for: " + route.getEndpoint()
+ getExceptionHandler().handleException("Failed to start master
consumer for: " + current.getEndpoint()
+ " (attempt #" +
failedStarts + "). Trying again in " + retryDelay
+ " millis.",
e);
@@ -310,22 +503,32 @@ public class MasterRoutePolicy extends RoutePolicySupport
implements CamelContex
try {
// Lets show we are starting the consumer.
- thisNodeState = createNodeState();
- thisNodeState.setStarted(true);
- groupListener.updateState(thisNodeState);
+ ZookeeperGroupListenerSupport<CamelNodeState> listener =
groupListener;
+ if (listener != null) {
+ thisNodeState = createNodeState(current);
+ thisNodeState.setStarted(true);
+ listener.updateState(thisNodeState);
+ }
} catch (Exception e) {
- getExceptionHandler().handleException("Failed to publish the
started state of: " + route.getEndpoint(), e);
+ getExceptionHandler().handleException("Failed to publish the
started state of: " + current.getEndpoint(), e);
}
- LOG.info("Elected as master. Consumer started: {}",
route.getEndpoint());
+ LOG.info("Elected as master. Consumer started: {}",
current.getEndpoint());
} finally {
leadershipLock.unlock();
}
}
private boolean isLeader() {
- return groupListener != null && groupListener.getGroup() != null &&
groupListener.getGroup().isConnected()
- && groupListener.getGroup().isMaster();
+ Group<CamelNodeState> group = currentGroup();
+ return group != null && group.isConnected() && group.isMaster();
+ }
+
+ // the group this node is in, or null: the listener is cleared when the
route is removed, and has no group before it
+ // is started
+ private Group<CamelNodeState> currentGroup() {
+ ZookeeperGroupListenerSupport<CamelNodeState> listener = groupListener;
+ return listener != null ? listener.getGroup() : null;
}
// under the lock
@@ -363,9 +566,16 @@ public class MasterRoutePolicy extends RoutePolicySupport
implements CamelContex
masterConsumer.set(false);
failedStarts = 0;
cancelRetry();
- stopConsumer(route.getConsumer());
- } catch (Exception e) {
- LOG.warn("Failed to stop master consumer: {}",
route.getEndpoint(), e);
+ Route current = this.route;
+ // the route was removed
+ if (current == null) {
+ return;
+ }
+ try {
+ stopConsumer(current.getConsumer());
+ } catch (Exception e) {
+ LOG.warn("Failed to stop master consumer: {}",
current.getEndpoint(), e);
+ }
} finally {
leadershipLock.unlock();
}
@@ -380,11 +590,33 @@ public class MasterRoutePolicy extends RoutePolicySupport
implements CamelContex
return path;
}
- private CamelNodeState createNodeState() {
+ private CamelNodeState createNodeState(Route route) {
String containerId = getContainerIdFactory().newContainerId();
CamelNodeState state = new CamelNodeState(getGroupName(), containerId);
state.setConsumer(route.getEndpoint().getEndpointUri());
return state;
}
+ // joins the group when the route of the policy is added to the running
CamelContext
+ private final class RouteAddedNotifier extends SimpleEventNotifierSupport
implements NonManagedService {
+
+ RouteAddedNotifier() {
+ // only route events, so the notifier adds no cost to the exchanges
+ setIgnoreCamelContextInitEvents(true);
+ setIgnoreCamelContextEvents(true);
+ setIgnoreServiceEvents(true);
+ setIgnoreExchangeEvents(true);
+ setIgnoreStepEvents(true);
+ }
+
+ @Override
+ public boolean isEnabled(CamelEvent event) {
+ return event instanceof RouteAddedEvent;
+ }
+
+ @Override
+ public void notify(CamelEvent event) {
+ routeAdded(((RouteAddedEvent) event).getRoute());
+ }
+ }
}
diff --git
a/components/camel-zookeeper-master/src/test/java/org/apache/camel/component/zookeepermaster/policy/MasterRoutePolicyRouteLifecycleTest.java
b/components/camel-zookeeper-master/src/test/java/org/apache/camel/component/zookeepermaster/policy/MasterRoutePolicyRouteLifecycleTest.java
new file mode 100644
index 000000000000..372743d8d645
--- /dev/null
+++
b/components/camel-zookeeper-master/src/test/java/org/apache/camel/component/zookeepermaster/policy/MasterRoutePolicyRouteLifecycleTest.java
@@ -0,0 +1,441 @@
+/*
+ * 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.zookeepermaster.policy;
+
+import java.io.File;
+import java.net.InetSocketAddress;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Consumer;
+import org.apache.camel.Processor;
+import org.apache.camel.Producer;
+import org.apache.camel.ProducerTemplate;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.component.zookeepermaster.ZookeeperGroupSupport;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.DefaultConsumer;
+import org.apache.camel.support.DefaultEndpoint;
+import org.apache.camel.support.service.ServiceHelper;
+import org.apache.camel.test.AvailablePortFinder;
+import org.apache.curator.framework.CuratorFramework;
+import org.apache.curator.framework.CuratorFrameworkFactory;
+import org.apache.curator.framework.listen.ListenerManager;
+import org.apache.curator.retry.RetryOneTime;
+import org.apache.zookeeper.server.NIOServerCnxnFactory;
+import org.apache.zookeeper.server.ZooKeeperServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * {@link MasterRoutePolicy} when its route is removed, added again, started
or resumed, with two CamelContexts (two
+ * nodes) and a ZooKeeper server in this JVM.
+ */
+class MasterRoutePolicyRouteLifecycleTest {
+
+ private static final String ROUTE = "master";
+
+ @TempDir
+ Path dataDir;
+
+ private final int port = AvailablePortFinder.getNextAvailable();
+ private final List<CamelContext> contexts = new ArrayList<>();
+ private final List<CuratorFramework> clients = new ArrayList<>();
+
+ private ZooKeeperServer server;
+ private NIOServerCnxnFactory factory;
+
+ @BeforeEach
+ void startServer() throws Exception {
+ File dir = dataDir.toFile();
+ server = new ZooKeeperServer(dir, dir, 2000);
+ factory = new NIOServerCnxnFactory();
+ factory.configure(new InetSocketAddress("127.0.0.1", port), 60);
+ factory.startup(server);
+ }
+
+ @AfterEach
+ void stopServer() {
+ // the CamelContexts first, so their policies leave the group while
the server runs
+ contexts.forEach(CamelContext::stop);
+ clients.forEach(CuratorFramework::close);
+ if (factory != null) {
+ factory.shutdown();
+ }
+ if (server != null) {
+ server.shutdown();
+ }
+ }
+
+ @Test
+ void removedRouteGivesUpTheMastership() throws Exception {
+ CamelContext a = startNode(newPolicy());
+ awaitConsuming(a);
+ CamelContext b = startNode(newPolicy());
+ awaitSlave(b);
+
+ // the route is removed on the master: the other node must take over,
otherwise the route runs nowhere
+ a.getRouteController().stopRoute(ROUTE);
+ assertTrue(a.removeRoute(ROUTE));
+
+ awaitConsuming(b);
+ assertConsumes(b);
+ }
+
+ @Test
+ void removedRouteOfASlaveLeavesTheGroup() throws Exception {
+ CamelContext a = startNode(newPolicy());
+ awaitConsuming(a);
+ CamelContext b = startNode(newPolicy());
+ awaitSlave(b);
+ await().atMost(30, TimeUnit.SECONDS).until(() -> groupMembers() == 2);
+
+ // the route is removed on the slave: its policy must not be elected
later, as it would start the consumer of
+ // the removed route
+ b.getRouteController().stopRoute(ROUTE);
+ assertTrue(b.removeRoute(ROUTE));
+ await().atMost(30, TimeUnit.SECONDS).untilAsserted(
+ () -> assertEquals(1, groupMembers(), "The policy of a removed
route must leave the group"));
+ }
+
+ @Test
+ void routeAddedToARunningContextJoinsTheGroupWithItsConsumer() throws
Exception {
+ CamelContext a = startNode(newPolicy());
+ awaitConsuming(a);
+
+ // a route with a new policy is added to the running CamelContext: the
policy must join the group only once the
+ // consumer of the route is created, as a master that is elected
before cannot start it
+ MasterRoutePolicy policy = newPolicy();
+ policy.setGroupName("added");
+ AtomicBoolean joinedBeforeConsumer = new AtomicBoolean();
+ a.addEndpoint("watched:in", new WatchedEndpoint(() ->
joinedBeforeConsumer.set(policy.thisNode() != null)));
+ a.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+
from("watched:in").routeId("added").autoStartup(false).routePolicy(policy).to("mock:added");
+ }
+ });
+
+ assertFalse(joinedBeforeConsumer.get(), "The policy joined the group
before the consumer was created");
+ await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> assertTrue(
+ policy.isMaster() &&
ServiceHelper.isStarted(a.getRoute("added").getConsumer()),
+ "The node must become the master and start its consumer"));
+ }
+
+ @Test
+ void restartedContextRunsTheRouteAgain() throws Exception {
+ // a control (passes on main too): the policy leaves the group when
the CamelContext stops, and joins it again
+ CamelContext a = startNode(newPolicy());
+ awaitConsuming(a);
+
+ a.stop();
+ a.start();
+
+ awaitConsuming(a);
+ assertConsumes(a);
+ }
+
+ @Test
+ void routeAddedAgainWithANewPolicyRunsAgain() throws Exception {
+ CamelContext a = startNode(newPolicy());
+ awaitConsuming(a);
+
+ // a route reload: the route is removed and added again, with a new
policy instance
+ a.getRouteController().stopRoute(ROUTE);
+ assertTrue(a.removeRoute(ROUTE));
+ a.addRoutes(route(newPolicy()));
+
+ awaitConsuming(a);
+ assertConsumes(a);
+ }
+
+ @Test
+ void routeAddedAgainWithTheSamePolicyRunsAgain() throws Exception {
+ MasterRoutePolicy policy = newPolicy();
+ CamelContext a = startNode(policy);
+ awaitConsuming(a);
+
+ a.getRouteController().stopRoute(ROUTE);
+ assertTrue(a.removeRoute(ROUTE));
+ a.addRoutes(route(policy));
+
+ awaitConsuming(a);
+ assertConsumes(a);
+ }
+
+ @Test
+ void routeOfASlaveIsKeptStoppedWhenStarted() throws Exception {
+ CamelContext a = startNode(newPolicy());
+ awaitConsuming(a);
+ CamelContext b = startNode(newPolicy());
+ awaitSlave(b);
+
+ // the route of the slave is started (from JMX or the controlbus): its
consumer must stay stopped
+ b.getRouteController().startRoute(ROUTE);
+ assertFalse(isConsumerStarted(b), "The slave must not consume");
+
+ // the master goes away: the slave takes over and consumes
+ a.stop();
+ awaitConsuming(b);
+ assertConsumes(b);
+ }
+
+ @Test
+ void routeOfASlaveIsKeptStoppedWhenResumed() throws Exception {
+ CamelContext a = startNode(newPolicy());
+ awaitConsuming(a);
+ CamelContext b = startNode(newPolicy());
+ awaitSlave(b);
+
+ // the route of the slave is resumed, which starts the consumer of a
route that is not started, and then it is
+ // started, suspended and resumed: the consumer must stay stopped
+ b.getRouteController().resumeRoute(ROUTE);
+ assertFalse(isConsumerStarted(b), "The slave must not consume after a
resume");
+ b.getRouteController().startRoute(ROUTE);
+ b.getRouteController().suspendRoute(ROUTE);
+ b.getRouteController().resumeRoute(ROUTE);
+ assertFalse(isConsumerStarted(b), "The slave must not consume after a
suspend and resume");
+
+ a.stop();
+ awaitConsuming(b);
+ assertConsumes(b);
+ }
+
+ @Test
+ void routeWithAutoStartupRunsOnlyOnTheMaster() throws Exception {
+ CamelContext a = startNode(newPolicy(), true);
+ awaitConsuming(a);
+ CamelContext b = startNode(newPolicy(), true);
+ awaitSlave(b);
+
+ a.stop();
+ awaitConsuming(b);
+ assertConsumes(b);
+ }
+
+ @Test
+ void failedJoinLeavesNoHalfJoinedState() throws Exception {
+ CuratorFramework client = newClient();
+ MasterRoutePolicy policy = newPolicy();
+ policy.setCurator(client);
+ // the first join fails after the group listener started (the node
state cannot be created)
+ AtomicInteger failures = new AtomicInteger(1);
+ policy.setContainerIdFactory(() -> {
+ if (failures.getAndDecrement() > 0) {
+ throw new IllegalStateException("Forced failure to create the
container id");
+ }
+ return "node-a";
+ });
+ CamelContext a = new DefaultCamelContext();
+ contexts.add(a);
+ a.start();
+
+ // the route is added to the running CamelContext, and the join fails:
the policy must not keep the listener
+ // (the JMX attributes would read its group) nor leave it on the
shared CuratorFramework
+ a.addRoutes(route(policy));
+ assertTrue(failures.get() <= 0, "The policy must have tried to join");
+ assertFalse(policy.isConnected(), "The policy must not keep the group
of a failed join");
+ assertEquals(0, groupListenersOn(client), "The listener of a failed
join must be removed from the curator");
+ assertFalse(isConsumerStarted(a));
+
+ // the route is added again: the policy joins the group and the node
runs the route
+ a.getRouteController().stopRoute(ROUTE);
+ assertTrue(a.removeRoute(ROUTE));
+ a.addRoutes(route(policy));
+ awaitConsuming(a);
+ assertConsumes(a);
+ assertEquals(1, groupListenersOn(client));
+ }
+
+ @Test
+ void failedJoinAtStartLeavesNoListenerOnTheCurator() throws Exception {
+ CuratorFramework client = newClient();
+ MasterRoutePolicy policy = newPolicy();
+ policy.setCurator(client);
+ policy.setContainerIdFactory(() -> {
+ throw new IllegalStateException("Forced failure to create the
container id");
+ });
+ CamelContext a = new DefaultCamelContext();
+ contexts.add(a);
+ a.addRoutes(route(policy));
+
+ // the policy joins when the CamelContext starts, which fails with the
join. A control: the CamelContext that
+ // fails to start also removes the route, whose onRemove cleaned up
before the join undid itself
+ assertThrows(Exception.class, a::start);
+ assertFalse(policy.isConnected());
+ assertEquals(0, groupListenersOn(client), "The listener of a failed
join must be removed from the curator");
+ }
+
+ @Test
+ void policyStoppedOnItsOwnLeavesTheGroupAndJoinsAgainWhenStarted() throws
Exception {
+ CuratorFramework client = newClient();
+ MasterRoutePolicy policy = newPolicy();
+ policy.setCurator(client);
+ CamelContext a = startNode(policy);
+ awaitConsuming(a);
+ assertEquals(1, groupListenersOn(client));
+
+ // the policy is stopped on its own (from JMX): it leaves the group
like a removed route, and the shared
+ // CuratorFramework must not keep its listener
+ ServiceHelper.stopService(policy);
+ assertEquals(0, groupListenersOn(client), "The stopped policy must
remove its listener from the curator");
+ assertFalse(policy.isConnected());
+ assertFalse(policy.isMaster());
+ assertFalse(isConsumerStarted(a), "A node that left the group must not
consume");
+
+ // started again, it joins the group again and runs the route
+ ServiceHelper.startService(policy);
+ awaitConsuming(a);
+ assertConsumes(a);
+ assertEquals(1, groupListenersOn(client));
+ }
+
+ private MasterRoutePolicy newPolicy() {
+ MasterRoutePolicy policy = new MasterRoutePolicy();
+ policy.setZooKeeperUrl("127.0.0.1:" + port);
+ policy.setZkRoot("/camel/lifecycle");
+ policy.setGroupName("lifecycle");
+ return policy;
+ }
+
+ private static RouteBuilder route(MasterRoutePolicy policy) {
+ return route(policy, false);
+ }
+
+ private static RouteBuilder route(MasterRoutePolicy policy, boolean
autoStartup) {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+
from("seda:in").routeId(ROUTE).autoStartup(autoStartup).routePolicy(policy).to("mock:out");
+ }
+ };
+ }
+
+ private CamelContext startNode(MasterRoutePolicy policy) throws Exception {
+ return startNode(policy, false);
+ }
+
+ private CamelContext startNode(MasterRoutePolicy policy, boolean
autoStartup) throws Exception {
+ CamelContext context = new DefaultCamelContext();
+ contexts.add(context);
+ context.addRoutes(route(policy, autoStartup));
+ context.start();
+ return context;
+ }
+
+ // a CuratorFramework of the user, shared by the policies, which outlives
them
+ private CuratorFramework newClient() throws Exception {
+ CuratorFramework client =
CuratorFrameworkFactory.newClient("127.0.0.1:" + port, new RetryOneTime(100));
+ clients.add(client);
+ client.start();
+ assertTrue(client.blockUntilConnected(20, TimeUnit.SECONDS));
+ return client;
+ }
+
+ // the group listeners of the policies that are registered on the client
+ private static int groupListenersOn(CuratorFramework client) {
+ AtomicInteger count = new AtomicInteger();
+ ((ListenerManager<?, ?>)
client.getConnectionStateListenable()).forEach(listener -> {
+ if (listener instanceof ZookeeperGroupSupport) {
+ count.incrementAndGet();
+ }
+ });
+ return count.get();
+ }
+
+ // the members of the group in ZooKeeper
+ private int groupMembers() throws Exception {
+ try (CuratorFramework client
+ = CuratorFrameworkFactory.newClient("127.0.0.1:" + port, new
RetryOneTime(100))) {
+ client.start();
+ client.blockUntilConnected(20, TimeUnit.SECONDS);
+ return
client.getChildren().forPath("/camel/lifecycle/lifecycle").size();
+ }
+ }
+
+ private static MasterRoutePolicy policyOf(CamelContext context) {
+ return (MasterRoutePolicy)
context.getRoute(ROUTE).getRoutePolicyList().get(0);
+ }
+
+ private static boolean isConsumerStarted(CamelContext context) {
+ Consumer consumer = context.getRoute(ROUTE).getConsumer();
+ return ServiceHelper.isStarted(consumer);
+ }
+
+ private static void awaitConsuming(CamelContext context) {
+ await().atMost(30, TimeUnit.SECONDS).untilAsserted(
+ () -> assertTrue(policyOf(context).isMaster() &&
isConsumerStarted(context),
+ "The node must become the master and start its
consumer"));
+ }
+
+ private static void awaitSlave(CamelContext context) {
+ await().atMost(30, TimeUnit.SECONDS)
+ .until(() -> policyOf(context).isConnected() &&
policyOf(context).thisNode() != null);
+ assertFalse(policyOf(context).isMaster());
+ assertFalse(isConsumerStarted(context));
+ }
+
+ // a message sent to the node is routed, so the consumer runs and is not
only marked as started
+ private static void assertConsumes(CamelContext context) throws Exception {
+ MockEndpoint mock = context.getEndpoint("mock:out",
MockEndpoint.class);
+ mock.reset();
+ mock.expectedBodiesReceived("hello");
+ try (ProducerTemplate template = context.createProducerTemplate()) {
+ template.sendBody("seda:in", "hello");
+ }
+ MockEndpoint.assertIsSatisfied(context, 20, TimeUnit.SECONDS);
+ }
+
+ // an endpoint that runs a check when its consumer is created
+ private static final class WatchedEndpoint extends DefaultEndpoint {
+ private final Runnable onCreateConsumer;
+
+ WatchedEndpoint(Runnable onCreateConsumer) {
+ this.onCreateConsumer = onCreateConsumer;
+ }
+
+ @Override
+ protected String createEndpointUri() {
+ return "watched:in";
+ }
+
+ @Override
+ public Producer createProducer() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Consumer createConsumer(Processor processor) {
+ onCreateConsumer.run();
+ return new DefaultConsumer(this, processor);
+ }
+ }
+}
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 14a1ddf8a885..949c547b870c 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
@@ -3887,6 +3887,20 @@ a `NoNodeException`, as it did without `create=true`.
With `listChildren=true`, a failed write or delete of an InOut exchange now
fails the exchange, instead of being
replaced by the listing of the child _znodes_.
+=== camel-zookeeper-master - MasterRoutePolicy and route removal, and the
consumer of a slave
+
+A `MasterRoutePolicy` stayed in the ZooKeeper group until the CamelContext
stopped, also after its route was removed
+(with `removeRoute`, or by a route reload), so no other node became the master
and the route ran nowhere in the
+cluster. The policy now leaves the group when its route is removed, so another
node takes over, and joins it again when
+the route is added again. So a route reloaded on the master can now move to
another node.
+
+On a node that is not the master, the policy now stops the consumer of its
route again right after the route was
+started or resumed (from JMX, the controlbus or a route policy), and starts it
when the node becomes the master. Before,
+the consumer of such a route kept running next to the one of the master. This
includes a route left at
+`autoStartup=true`: it now consumes only once its node is the master, also
when the CamelContext starts, so it does not
+consume while the node cannot reach ZooKeeper. Setting the route to
`autoStartup=false` is still recommended, so the
+consumer does not run for a moment when the CamelContext starts.
+
=== camel-milo - each client connection has thread pools of its own
A `milo-client` or `milo-browse` connection now handles the outcome of its
write, method call, read and browse