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 8fc0dc2f36bb CAMEL-26577: camel-support - 
RebalancingCamelClusterService: release the views a rebalancing pass looks at 
(#29202)
8fc0dc2f36bb is described below

commit 8fc0dc2f36bbe48257b662865b8f654e28cc8c00
Author: allthingssecurity <[email protected]>
AuthorDate: Thu Nov 5 17:38:02 2026 +0530

    CAMEL-26577: camel-support - RebalancingCamelClusterService: release the 
views a rebalancing pass looks at (#29202)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../RebalancingClusterServiceReleaseViewTest.java  | 233 +++++++++++++++++++++
 .../cluster/AbstractCamelClusterService.java       |  29 +++
 .../cluster/RebalancingCamelClusterService.java    |  93 ++++++--
 3 files changed, 340 insertions(+), 15 deletions(-)

diff --git 
a/core/camel-core/src/test/java/org/apache/camel/cluster/RebalancingClusterServiceReleaseViewTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/cluster/RebalancingClusterServiceReleaseViewTest.java
new file mode 100644
index 000000000000..c4ea649a9bd6
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/cluster/RebalancingClusterServiceReleaseViewTest.java
@@ -0,0 +1,233 @@
+/*
+ * 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.cluster;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.ServiceStatus;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.impl.cluster.ClusteredRoutePolicy;
+import org.apache.camel.support.cluster.AbstractCamelClusterService;
+import org.apache.camel.support.cluster.AbstractCamelClusterView;
+import org.apache.camel.support.cluster.RebalancingCamelClusterService;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The rebalancing cluster service looks at the views of the delegate on every 
pass; it must not keep a reference to
+ * them, nor start a view again that its last user has released.
+ */
+public class RebalancingClusterServiceReleaseViewTest {
+
+    private CamelContext context;
+    private TestClusterService delegate;
+    private TestRebalancingClusterService service;
+
+    @BeforeEach
+    public void setUp() throws Exception {
+        context = new DefaultCamelContext();
+        delegate = new TestClusterService("node-1");
+        // a long period, so the only passes are the one at start-up and the 
ones the tests run
+        service = new TestRebalancingClusterService(delegate);
+        context.addService(service);
+    }
+
+    @AfterEach
+    public void tearDown() {
+        context.stop();
+    }
+
+    @Test
+    public void testRemovedRouteStopsView() throws Exception {
+        ClusteredRoutePolicy policy = 
ClusteredRoutePolicy.forNamespace(service, "my-ns");
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from("seda:foo").routeId("foo").routePolicy(policy).to("mock:foo");
+            }
+        });
+        context.start();
+        assertTrue(service.firstPass.await(10, TimeUnit.SECONDS));
+
+        TestClusterView view = delegate.view("my-ns");
+        assertTrue(view.isRunAllowed());
+
+        // a rebalancing pass while the route uses the view
+        service.reconcile();
+        assertTrue(view.isRunAllowed());
+
+        context.getRouteController().stopRoute("foo");
+        context.removeRoute("foo");
+
+        // no route uses the namespace any more, so this node must leave the 
election
+        assertEquals(ServiceStatus.Stopped, view.getStatus());
+    }
+
+    @Test
+    public void testRebalancingDoesNotStartReleasedView() throws Exception {
+        context.start();
+        assertTrue(service.firstPass.await(10, TimeUnit.SECONDS));
+
+        CamelClusterView used = delegate.getView("my-ns");
+        TestClusterView view = delegate.view("my-ns");
+        assertTrue(view.isRunAllowed());
+        delegate.releaseView(used);
+        assertEquals(ServiceStatus.Stopped, view.getStatus());
+        int starts = view.starts.get();
+
+        service.reconcile();
+
+        // not even started and stopped again within the pass (that would take 
the leadership for a moment)
+        assertEquals(ServiceStatus.Stopped, view.getStatus());
+        assertEquals(starts, view.starts.get());
+    }
+
+    @Test
+    public void testRebalancingStillOpensViewInUse() throws Exception {
+        context.start();
+        assertTrue(service.firstPass.await(10, TimeUnit.SECONDS));
+
+        CamelClusterView used = delegate.getView("my-ns");
+        TestClusterView view = delegate.view("my-ns");
+        view.setDisabled(true);
+
+        // one member and one partition that this member does not own: the 
pass opens it
+        service.reconcile();
+
+        assertFalse(view.isDisabled());
+        assertTrue(view.isRunAllowed());
+        delegate.releaseView(used);
+        assertEquals(ServiceStatus.Stopped, view.getStatus());
+    }
+
+    // *********************************
+    // Helpers
+    // *********************************
+
+    private final class TestRebalancingClusterService extends 
RebalancingCamelClusterService {
+        private final CountDownLatch firstPass = new CountDownLatch(1);
+
+        TestRebalancingClusterService(CamelPreemptiveClusterService delegate) {
+            super(delegate, TimeUnit.HOURS.toMillis(1));
+        }
+
+        @Override
+        protected void reconcile() {
+            try {
+                super.reconcile();
+            } finally {
+                firstPass.countDown();
+            }
+        }
+    }
+
+    private static final class TestClusterView extends 
AbstractCamelClusterView implements CamelPreemptiveClusterView {
+        private final CamelClusterMember local;
+        private final AtomicInteger starts = new AtomicInteger();
+        private volatile boolean disabled;
+
+        TestClusterView(CamelClusterService cluster, String namespace) {
+            super(cluster, namespace);
+            this.local = new CamelClusterMember() {
+                @Override
+                public boolean isLeader() {
+                    return false;
+                }
+
+                @Override
+                public boolean isLocal() {
+                    return true;
+                }
+
+                @Override
+                public String getId() {
+                    return getClusterService().getId();
+                }
+            };
+        }
+
+        @Override
+        public Optional<CamelClusterMember> getLeader() {
+            return Optional.empty();
+        }
+
+        @Override
+        public CamelClusterMember getLocalMember() {
+            return local;
+        }
+
+        @Override
+        public List<CamelClusterMember> getMembers() {
+            return List.of(local);
+        }
+
+        @Override
+        public void setDisabled(boolean disabled) {
+            this.disabled = disabled;
+        }
+
+        @Override
+        public boolean isDisabled() {
+            return disabled;
+        }
+
+        @Override
+        protected void doStart() {
+            starts.incrementAndGet();
+        }
+
+        @Override
+        protected void doStop() {
+        }
+    }
+
+    private static final class TestClusterService extends 
AbstractCamelClusterService<TestClusterView>
+            implements CamelPreemptiveClusterService {
+        private final Map<String, TestClusterView> created = new 
ConcurrentHashMap<>();
+
+        TestClusterService(String id) {
+            super(id);
+        }
+
+        @Override
+        protected TestClusterView createView(String namespace) {
+            return created.computeIfAbsent(namespace, ns -> new 
TestClusterView(this, ns));
+        }
+
+        @Override
+        public CamelPreemptiveClusterView getView(String namespace) throws 
Exception {
+            return (CamelPreemptiveClusterView) super.getView(namespace);
+        }
+
+        TestClusterView view(String namespace) {
+            return created.get(namespace);
+        }
+    }
+}
diff --git 
a/core/camel-support/src/main/java/org/apache/camel/support/cluster/AbstractCamelClusterService.java
 
b/core/camel-support/src/main/java/org/apache/camel/support/cluster/AbstractCamelClusterService.java
index 490c42eee837..81b495fb0b0d 100644
--- 
a/core/camel-support/src/main/java/org/apache/camel/support/cluster/AbstractCamelClusterService.java
+++ 
b/core/camel-support/src/main/java/org/apache/camel/support/cluster/AbstractCamelClusterService.java
@@ -216,6 +216,35 @@ public abstract class AbstractCamelClusterService<T 
extends CamelClusterView> ex
                 });
     }
 
+    /**
+     * Takes a reference to the view of the namespace only while the view is 
in use (it has references), so the caller
+     * can look at it without creating it or starting it again after its last 
user has released it. The caller must
+     * release the view with {@link #releaseView(CamelClusterView)}.
+     *
+     * @return the view, or {@code null} if it is not in use
+     */
+    CamelClusterView retainViewIfInUse(String namespace) {
+        return LockHelper.supplyWithWriteLock(
+                lock,
+                () -> {
+                    ViewHolder<T> holder = views.get(namespace);
+                    // the view is in use, so this reference does not start it
+                    return holder != null && holder.count.get() > 0 ? 
holder.retain() : null;
+                });
+    }
+
+    /**
+     * Whether the view of the namespace is in use (it has references).
+     */
+    boolean isViewInUse(String namespace) {
+        return LockHelper.supplyWithReadLock(
+                lock,
+                () -> {
+                    ViewHolder<T> holder = views.get(namespace);
+                    return holder != null && holder.count.get() > 0;
+                });
+    }
+
     @Override
     public boolean isLeader(String namespace) {
         return LockHelper.supplyWithReadLock(
diff --git 
a/core/camel-support/src/main/java/org/apache/camel/support/cluster/RebalancingCamelClusterService.java
 
b/core/camel-support/src/main/java/org/apache/camel/support/cluster/RebalancingCamelClusterService.java
index 40f26bc45817..71a9f62bc47e 100644
--- 
a/core/camel-support/src/main/java/org/apache/camel/support/cluster/RebalancingCamelClusterService.java
+++ 
b/core/camel-support/src/main/java/org/apache/camel/support/cluster/RebalancingCamelClusterService.java
@@ -164,10 +164,18 @@ public class RebalancingCamelClusterService extends 
ServiceSupport implements Ca
 
     protected void setDisabled(String partition, boolean disabled) {
         try {
-            LOG.debug("Setting partition {} to disabled={}...", partition, 
disabled);
-            CamelPreemptiveClusterView view = delegate.getView(partition);
-            if (view.isDisabled() != disabled) {
-                view.setDisabled(disabled);
+            CamelPreemptiveClusterView view = retainViewInUse(partition);
+            if (view == null) {
+                LOG.debug("Partition {} is not in use", partition);
+                return;
+            }
+            try {
+                LOG.debug("Setting partition {} to disabled={}...", partition, 
disabled);
+                if (view.isDisabled() != disabled) {
+                    view.setDisabled(disabled);
+                }
+            } finally {
+                releaseRetainedView(view);
             }
         } catch (Exception ex) {
             LOG.warn("Could not get view {}", partition, ex);
@@ -178,9 +186,17 @@ public class RebalancingCamelClusterService extends 
ServiceSupport implements Ca
         List<String> owned = new ArrayList<>(partitions.size());
         for (String partition : partitions) {
             try {
-                CamelPreemptiveClusterView view = delegate.getView(partition);
-                if (!view.isDisabled() && view.getLocalMember().isLeader()) {
-                    owned.add(partition);
+                CamelPreemptiveClusterView view = retainViewInUse(partition);
+                if (view == null) {
+                    // released since the partitions were listed
+                    continue;
+                }
+                try {
+                    if (!view.isDisabled() && 
view.getLocalMember().isLeader()) {
+                        owned.add(partition);
+                    }
+                } finally {
+                    releaseRetainedView(view);
                 }
             } catch (Exception ex) {
                 LOG.warn("Could not get view {}", partition, ex);
@@ -191,22 +207,37 @@ public class RebalancingCamelClusterService extends 
ServiceSupport implements Ca
     }
 
     protected List<String> partitionList() {
-        ArrayList<String> partitions = new ArrayList<>(this.getNamespaces());
+        List<String> partitions = new ArrayList<>();
+        for (String namespace : this.getNamespaces()) {
+            // a view that no route (or other user) holds any more is not a 
partition to balance
+            if (isViewInUse(namespace)) {
+                partitions.add(namespace);
+            }
+        }
         Collections.sort(partitions);
         return partitions;
     }
 
     protected Integer members() {
         Set<String> members = null;
-        for (String group : this.getNamespaces()) {
+        for (String group : partitionList()) {
             try {
-                CamelPreemptiveClusterView view = delegate.getView(group);
-                Set<String> viewMembers = 
view.getMembers().stream().map(CamelClusterMember::getId).collect(Collectors.toSet());
-                if (members != null && !members.equals(viewMembers)) {
-                    LOG.debug("View members don't match: {} vs {}", members, 
viewMembers);
-                    return null;
+                CamelPreemptiveClusterView view = retainViewInUse(group);
+                if (view == null) {
+                    // released since the partitions were listed
+                    continue;
+                }
+                try {
+                    Set<String> viewMembers
+                            = 
view.getMembers().stream().map(CamelClusterMember::getId).collect(Collectors.toSet());
+                    if (members != null && !members.equals(viewMembers)) {
+                        LOG.debug("View members don't match: {} vs {}", 
members, viewMembers);
+                        return null;
+                    }
+                    members = viewMembers;
+                } finally {
+                    releaseRetainedView(view);
                 }
-                members = viewMembers;
             } catch (Exception ex) {
                 LOG.warn("Could not get view {}", group, ex);
                 return null;
@@ -215,6 +246,38 @@ public class RebalancingCamelClusterService extends 
ServiceSupport implements Ca
         return members != null ? members.size() : 0;
     }
 
+    /**
+     * Whether the view of the partition is held by a route or another user. 
The rebalancing only looks at those views,
+     * so it does not start a view again after its last user has released it.
+     */
+    private boolean isViewInUse(String partition) {
+        if (delegate instanceof AbstractCamelClusterService<?> service) {
+            return service.isViewInUse(partition);
+        }
+        return true;
+    }
+
+    /**
+     * Takes a reference to the view of the partition if it is in use, or 
returns {@code null}. The caller releases it
+     * with {@link #releaseRetainedView(CamelClusterView)}, so the rebalancing 
does not keep a view running after its
+     * last user has released it. A delegate that is not an {@link 
AbstractCamelClusterService} has no such lookup, so
+     * its views are taken with {@code getView} (which may start a released 
view until it is released again).
+     */
+    private CamelPreemptiveClusterView retainViewInUse(String partition) 
throws Exception {
+        if (delegate instanceof AbstractCamelClusterService<?> service) {
+            return (CamelPreemptiveClusterView) 
service.retainViewIfInUse(partition);
+        }
+        return delegate.getView(partition);
+    }
+
+    private void releaseRetainedView(CamelClusterView view) {
+        try {
+            delegate.releaseView(view);
+        } catch (Exception ex) {
+            LOG.warn("Could not release view {}", view.getNamespace(), ex);
+        }
+    }
+
     private void rescheduleAfterDelay() {
         this.serializedExecutor.schedule(this::reconcile,
                 this.periodMillis,

Reply via email to