shashank created CAMEL-25062:
--------------------------------

             Summary: camel-cluster - ClusteredRoutePolicy: CamelContext stop 
or removeRoute can deadlock with a leadership change that is starting routes 
(follow-up to CAMEL-24545)
                 Key: CAMEL-25062
                 URL: https://issues.apache.org/jira/browse/CAMEL-25062
             Project: Camel
          Issue Type: Bug
          Components: camel-core
            Reporter: shashank


CAMEL-24545 reported a JVM that hangs on shutdown: 
{{ClusteredRoutePolicy.onRemove}} and the ZooKeeper leader selector thread take 
the same two locks in opposite order. The fix (PR #25842, in 4.18.5, 4.22.1 and 
4.23.0) changed only {{ZooKeeperClusterView}}, so that it no longer fires a 
leadership event while it stops. The reviews on that PR described the root 
cause as the AB-BA inversion between the {{ClusteredRoutePolicy}} lock and the 
{{StampedLock}} of {{AbstractCamelClusterView}}, accepted the ZooKeeper guard 
as the minimal fix, and suggested a follow-up for the other cluster views. The 
inversion itself is still in {{ClusteredRoutePolicy}} on main, for every 
cluster service (file, kubernetes, consul, infinispan, jgroups-raft) and for 
ZooKeeper events other than the stop event.

h3. The lock orders (main, 65f315628)

* *Event dispatch.* {{AbstractCamelClusterView.fireLeadershipChangedEvent}} 
calls every listener while it holds the read lock of the view 
({{AbstractCamelClusterView.java:111-122}}). The listener of the policy 
({{ClusteredRoutePolicy.java:376}}) calls {{setLeader}}, which takes the policy 
lock ({{:267}}) and starts or stops the routes on the same thread. 
{{startRoute}} ({{:310}}) needs the CamelContext route lock 
({{AbstractCamelContext.startRoute}}).
* *Release.* {{ClusteredRoutePolicy.releaseClusterView}} is called from 
{{onRemove}} of the last route of the policy and from {{doShutdown}}, so it 
runs on every CamelContext stop and on every route removal or reload. It takes 
the policy lock ({{:235}}) and, while it holds it, calls 
{{removeEventListener}} ({{:239}}), which needs the write lock of the view 
({{AbstractCamelClusterView.java:104}}).
* *removeRoute.* {{AbstractCamelContext.removeRoute}} holds the CamelContext 
route lock while it shuts the route down and calls {{onRemove}}.

{{ClusteredRoutePolicyFactory}} creates one policy per route, and each one 
registers its own listener. A leadership event runs the listeners one after the 
other, and each one starts its route inside the dispatch, with the read lock of 
the view held. Two cycles follow:
# *CamelContext stop* (two locks). The stop releases a policy further down the 
list: it takes that policy's lock and waits for the write lock of the view. The 
dispatch reaches that policy's listener and waits for the same policy lock.
# *removeRoute* (three locks). The operator holds the CamelContext route lock 
and waits in {{releaseClusterView}} for the policy lock (or, once that is 
lock-free, for the write lock of the view). The dispatch holds the read lock of 
the view and the policy lock, and waits in {{startRoute}} for the CamelContext 
route lock.

This also applies to a single policy shared by several routes: it holds its own 
lock across {{startRoute}}, while {{removeRoute}}, holding the CamelContext 
route lock, needs the same lock.

The window is a leadership change that is still starting routes, for example a 
consumer that takes a while to connect to a broker. A rolling restart produces 
exactly this: A stops, B takes over and starts its routes, and B is then 
stopped or has a route reloaded.

With the file cluster service the stuck thread is the leadership thread of 
{{FileLockClusterService}}. It stops writing the heartbeat but keeps the file 
lock, so no other node can become the leader until the JVM is killed (for 
example by the orchestrator after its stop timeout).

h3. Reproduction

Two nodes share one {{FileLockClusterService}} root. Each registers 
{{ClusteredRoutePolicyFactory.forNamespace("ns")}} and has two routes: "slow", 
whose consumer takes 1.5 s to start, and "other". A is the leader. A is 
stopped, so B takes the leadership and its view dispatches the event; the 
policy of "slow" starts its route. 300 ms later B's CamelContext is stopped, or 
"other" is removed.

CamelContext stop, blocked in 3 of 3 runs:
{noformat}
CamelContext.stop() of B STILL BLOCKED after 10006 ms
  thread 'FileLockClusterService-B' WAITING at 
ClusteredRoutePolicy.setLeader:267
      from 
ClusteredRoutePolicy$CamelClusterLeadershipListener.leadershipChanged:376, 
AbstractCamelClusterView.fireLeadershipChangedEvent
  thread 'operator' WAITING at LockHelper.doWithWriteLock:81
      from AbstractCamelClusterView.removeEventListener:104, 
ClusteredRoutePolicy.releaseClusterView:239
{noformat}

removeRoute("other"), blocked:
{noformat}
removeRoute(other) STILL BLOCKED after 10001 ms
  thread 'FileLockClusterService-B' WAITING at 
AbstractCamelContext.startRoute:1318
      from InternalRouteController.startRoute:124, 
DefaultRouteController.startRoute:133
  thread 'operator' WAITING at ClusteredRoutePolicy.releaseClusterView:235
      from ReferenceCount.release:71, ClusteredRoutePolicy.onRemove:199
{noformat}

The removeRoute interleaving is racy in this harness. The unit test in the PR 
makes both cases deterministic: a consumer whose start waits on a latch, and a 
gate listener that holds the dispatch until the view is being released.

h3. Proposed fix

Making the release lock-free is not enough: with {{releaseClusterView}} taking 
no policy lock, removeRoute still deadlocks, now between the write lock of the 
view (operator, holding the CamelContext route lock) and the CamelContext route 
lock (dispatch, holding the read lock of the view). The routes must not be 
started or stopped on the thread that dispatches the event. The proposed change 
to {{ClusteredRoutePolicy}}:
* The leadership listener hands the change to the policy's own thread, which 
starts or stops the routes under the policy lock. The view lock is then never 
held while the CamelContext route lock is awaited, and a slow route start no 
longer blocks the leadership thread of the cluster service (the file service's 
heartbeat).
* Threading: the thread comes from a pool created through the 
{{ExecutorServiceManager}} (named and managed like the other Camel pools) with 
no core thread, at most one thread and a keep-alive of one second. One thread 
at most keeps the changes of a policy in order, and it exits when the 
leadership does not change, so {{ClusteredRoutePolicyFactory}} (a policy per 
route) does not keep a thread per route, and a removed route leaves no thread 
behind. One shared thread per CamelContext was not used: it would need an owner 
with its own lifecycle, and a slow route start would delay the changes of every 
other policy.
* The policy thread reads the leadership when it applies a change, so the last 
change wins, and at most one change is queued. A change is never applied on the 
dispatching thread: after the policy has been shut down it is rejected, logged 
and ignored. Errors while applying a change are logged.
* {{retainClusterView}} and {{releaseClusterView}} take no policy lock. The 
view reference is swapped atomically, and the release only resets the leader 
flag, as no route is left to stop. Retain and release are serialized by a 
separate small lock that only the threads adding and removing routes take, so a 
shared policy cannot release a view that a concurrent route addition has just 
retained. The policy thread ignores a change for a view that has been released 
in the meantime.
* {{retainClusterView}} reads the current leadership right away, so {{onInit}} 
still lets the route controller start a route added while the node is the 
leader.
* The deferred start after the CamelContext has started is checked under the 
policy lock, as the policy thread may now apply the leadership while the 
CamelContext is starting.
* Leadership changes, taken or lost, that reach the policy thread while the 
CamelContext is stopping are ignored, in line with CAMEL-24545. The 
CamelContext is stopping all the routes, consumers first, and stopping a route 
from the policy thread would run a second shutdown of it through the same 
{{ShutdownStrategy}}.

Behaviour change: the routes are started and stopped shortly after the 
leadership event, on the policy thread, instead of inside it. Most cluster 
services fire the event from their own threads, but some fire it on the calling 
thread (when a view starts or a listener is added). Code that fires the event 
itself (as some tests do) should wait for the route status. {{isLeader}} (JMX) 
reflects the view right away, and the routes follow.

An alternative is to iterate over a snapshot of the listeners in 
{{AbstractCamelClusterView.doWithListener}}, so no view lock is held during the 
callbacks. That changes every cluster view and camel-master, so it is only 
mentioned as an option.

A TLA+ model of the view lock and two policy locks shows the two-lock cycle. It 
does not model the CamelContext route lock, so it does not cover the 
removeRoute case.

h3. Follow-up

Once this is fixed, views could notify the listeners that are still registered 
when a view is force-stopped (JMX {{stopView}}, or stop of the cluster 
service). Today a leader whose view is force-stopped keeps its clustered and 
master routes running while another node takes over (file and ZooKeeper). This 
is left for a separate issue.

Affected: long-standing. In 4.4.0 {{releaseClusterView}} and {{setLeader}} were 
{{synchronized}} methods and the view already used a {{StampedLock}}, so the 
lock order was the same; CAMEL-20199 (4.8.0) replaced the monitor with a 
{{ReentrantLock}}. Main, 4.22.x, 4.18.x and 4.14.x are affected. The ZooKeeper 
stop trigger is fixed in 4.18.5 and 4.22.1, but other ZooKeeper events and all 
other cluster services still hit it.

Duplicate check (2026-09-27): JIRA text "ClusteredRoutePolicy" returns 8 
issues. CAMEL-24545 is the ZooKeeper-only fix of the same inversion, 
CAMEL-24626 fixed the same pattern in camel-master, and CAMEL-17090 (2021) is a 
different lock-up. GitHub PRs for "ClusteredRoutePolicy": #25842 touches only 
{{ZooKeeperClusterView}}. No open PR changes {{ClusteredRoutePolicy}} or 
{{AbstractCamelClusterView}}.

_Filed with Claude Code on behalf of allthingssecurity._




--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to