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 63ab9eb9d1d8 CAMEL-25613: camel-jmx - consumer reconnects once per 
lost connection and keeps the new connection (#27800)
63ab9eb9d1d8 is described below

commit 63ab9eb9d1d8d53ef537dde2bd604f5d75e9f926
Author: allthingssecurity <[email protected]>
AuthorDate: Wed Oct 14 17:16:04 2026 +0530

    CAMEL-25613: camel-jmx - consumer reconnects once per lost connection and 
keeps the new connection (#27800)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../apache/camel/component/jmx/JMXConsumer.java    | 112 ++++++-
 .../component/jmx/JMXConsumerReconnectTest.java    | 362 +++++++++++++++++++++
 2 files changed, 469 insertions(+), 5 deletions(-)

diff --git 
a/components/camel-jmx/src/main/java/org/apache/camel/component/jmx/JMXConsumer.java
 
b/components/camel-jmx/src/main/java/org/apache/camel/component/jmx/JMXConsumer.java
index 2ddca7354c3f..2debd042d147 100644
--- 
a/components/camel-jmx/src/main/java/org/apache/camel/component/jmx/JMXConsumer.java
+++ 
b/components/camel-jmx/src/main/java/org/apache/camel/component/jmx/JMXConsumer.java
@@ -23,7 +23,9 @@ import java.util.Map;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
 
+import javax.management.ListenerNotFoundException;
 import javax.management.MBeanServerConnection;
 import javax.management.Notification;
 import javax.management.NotificationFilter;
@@ -51,8 +53,8 @@ public class JMXConsumer extends DefaultConsumer implements 
NotificationListener
     private static final Logger LOG = 
LoggerFactory.getLogger(JMXConsumer.class);
 
     private JMXEndpoint mJmxEndpoint;
-    private JMXConnector mConnector;
-    private String mConnectionId;
+    private volatile JMXConnector mConnector;
+    private volatile String mConnectionId;
 
     /**
      * Used for processing notifications (should not block notification thread)
@@ -63,7 +65,13 @@ public class JMXConsumer extends DefaultConsumer implements 
NotificationListener
     /**
      * Used to schedule delayed connection attempts
      */
-    private ScheduledExecutorService mScheduledExecutor;
+    private volatile ScheduledExecutorService mScheduledExecutor;
+
+    /**
+     * Whether a reconnect is scheduled and has not begun: a lost connection 
is reported more than once (the connector
+     * sends "failed" and then "closed" for it) and must be followed by one 
reconnect
+     */
+    private final AtomicBoolean mReconnectPending = new AtomicBoolean();
 
     /**
      * Used to receive notifications about lost connections
@@ -138,6 +146,13 @@ public class JMXConsumer extends DefaultConsumer 
implements NotificationListener
      */
     private void initNetworkConnection() throws IOException {
         if (mConnector != null) {
+            try {
+                // this consumer closes the connection itself, so its "closed" 
notification is not a lost connection:
+                // without the listener it cannot schedule a reconnect for a 
connection that is being replaced
+                
mConnector.removeConnectionNotificationListener(getConnectionNotificationListener());
+            } catch (Exception e) {
+                // ignore, the listener is not registered on a connector that 
could not be opened
+            }
             try {
                 mConnector.close();
             } catch (Exception e) {
@@ -189,10 +204,18 @@ public class JMXConsumer extends DefaultConsumer 
implements NotificationListener
         @Override
         public void handleNotification(Notification notification, Object 
handback) {
             JMXConnectionNotification connectionNotification = 
(JMXConnectionNotification) notification;
+            if 
(connectionNotification.getType().equals(JMXConnectionNotification.OPENED)) {
+                connectorReconnected(connectionNotification.getConnectionId());
+                return;
+            }
             // only reset the connection if the notification is for the 
connection from this endpoint
             if 
(!connectionNotification.getConnectionId().equals(mConnectionId)) {
                 return;
             }
+            if (!isRunAllowed()) {
+                // a notification that arrives after the consumer was stopped
+                return;
+            }
             if 
(connectionNotification.getType().equals(JMXConnectionNotification.NOTIFS_LOST)
                     || 
connectionNotification.getType().equals(JMXConnectionNotification.CLOSED)
                     || 
connectionNotification.getType().equals(JMXConnectionNotification.FAILED)) {
@@ -208,10 +231,59 @@ public class JMXConsumer extends DefaultConsumer 
implements NotificationListener
     }
 
     /**
-     * Schedules an attempt to re-initialize a lost connection after the 
reconnect delay
+     * The connector lost its connection and opened another one by itself (the 
listener is added after the connector is
+     * connected, so the first connection is not reported here). The new 
connection has another id, which the following
+     * notifications carry, and the connector has registered the listener of 
this consumer again only if the MBean
+     * existed at that moment. So the consumer subscribes again on that 
connection, and connects again only if that
+     * fails.
+     */
+    private void connectorReconnected(String connectionId) {
+        if (connectionId == null || connectionId.equals(mConnectionId)) {
+            return;
+        }
+        mConnectionId = connectionId;
+        if (!isRunAllowed()) {
+            return;
+        }
+        if (mJmxEndpoint.isReconnectOnConnectionFailure()) {
+            LOG.warn("The JMX connector has opened a new connection for: {}. 
Subscribing again on that connection.",
+                    URISupport.sanitizeUri(mJmxEndpoint.getEndpointUri()));
+            scheduleReconnect(true);
+        } else {
+            LOG.warn("The JMX connector has opened a new connection for: {}. 
The JMX consumer may not be subscribed"
+                     + " anymore. Use 'reconnectOnConnectionFailure' to enable 
reconnections.",
+                    URISupport.sanitizeUri(mJmxEndpoint.getEndpointUri()));
+        }
+    }
+
+    /**
+     * Schedules an attempt to re-initialize a lost connection after the 
reconnect delay, unless an attempt is already
+     * scheduled or the consumer is stopped
      */
     protected void scheduleReconnect() {
+        scheduleReconnect(false);
+    }
+
+    /**
+     * Schedules an attempt to re-initialize the connection after the 
reconnect delay, unless an attempt is already
+     * scheduled or the consumer is stopped.
+     *
+     * @param resubscribeFirst whether the attempt first subscribes again on 
the current connection, which the connector
+     *                         has restored by itself, and connects again only 
when that fails
+     */
+    private void scheduleReconnect(boolean resubscribeFirst) {
+        if (!isRunAllowed() || !mReconnectPending.compareAndSet(false, true)) {
+            return;
+        }
         Runnable startRunnable = () -> {
+            // from here on a lost connection needs another attempt, also when 
it is reported while this one runs
+            mReconnectPending.set(false);
+            if (!isRunAllowed()) {
+                return;
+            }
+            if (resubscribeFirst && resubscribe()) {
+                return;
+            }
             try {
                 initNetworkConnection();
                 addNotificationListener();
@@ -222,7 +294,35 @@ public class JMXConsumer extends DefaultConsumer 
implements NotificationListener
         };
         LOG.info("Delaying JMX consumer reconnection for endpoint {}. Trying 
again in {} seconds.",
                 URISupport.sanitizeUri(mJmxEndpoint.getEndpointUri()), 
mJmxEndpoint.getReconnectDelay());
-        getExecutor().schedule(startRunnable, 
mJmxEndpoint.getReconnectDelay(), TimeUnit.SECONDS);
+        try {
+            getExecutor().schedule(startRunnable, 
mJmxEndpoint.getReconnectDelay(), TimeUnit.SECONDS);
+        } catch (RuntimeException e) {
+            mReconnectPending.set(false);
+            throw e;
+        }
+    }
+
+    /**
+     * Subscribes again on the current connection: removes every registration 
of this listener (the connector has
+     * registered it again if the MBean existed when it reconnected) and adds 
one. So the consumer is subscribed once,
+     * without closing a connection that works.
+     *
+     * @return false if that failed, then the connection has to be replaced
+     */
+    private boolean resubscribe() {
+        try {
+            try {
+                
getServerConnection().removeNotificationListener(getEndpoint().getJMXObjectName(),
 this);
+            } catch (ListenerNotFoundException e) {
+                // the connector did not register the listener again
+            }
+            addNotificationListener();
+            return true;
+        } catch (Exception e) {
+            LOG.warn("Failed to subscribe again on the connection opened by 
the JMX connector, reconnecting. >> {}",
+                    e.getMessage());
+            return false;
+        }
     }
 
     /**
@@ -267,6 +367,8 @@ public class JMXConsumer extends DefaultConsumer implements 
NotificationListener
             
getEndpoint().getCamelContext().getExecutorServiceManager().shutdownNow(mScheduledExecutor);
             mScheduledExecutor = null;
         }
+        // the scheduled reconnect, if there was one, is cancelled
+        mReconnectPending.set(false);
 
         removeNotificationListeners();
 
diff --git 
a/components/camel-jmx/src/test/java/org/apache/camel/component/jmx/JMXConsumerReconnectTest.java
 
b/components/camel-jmx/src/test/java/org/apache/camel/component/jmx/JMXConsumerReconnectTest.java
new file mode 100644
index 000000000000..003e5c1971b7
--- /dev/null
+++ 
b/components/camel-jmx/src/test/java/org/apache/camel/component/jmx/JMXConsumerReconnectTest.java
@@ -0,0 +1,362 @@
+/*
+ * 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.jmx;
+
+import java.rmi.NoSuchObjectException;
+import java.rmi.registry.LocateRegistry;
+import java.rmi.registry.Registry;
+import java.rmi.server.UnicastRemoteObject;
+import java.util.Collections;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+
+import javax.management.InstanceNotFoundException;
+import javax.management.MBeanServerFactory;
+import javax.management.NotificationListener;
+import javax.management.remote.JMXConnectionNotification;
+import javax.management.remote.JMXConnectorServer;
+import javax.management.remote.JMXConnectorServerFactory;
+import javax.management.remote.JMXServiceURL;
+
+import org.apache.camel.Route;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.test.AvailablePortFinder;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A remote JMX consumer with reconnectOnConnectionFailure=true must replace a 
lost connection once: one reconnect for
+ * the "failed" and "closed" notifications that the connector sends for one 
lost connection, no reconnect for the
+ * connection it closes itself, and none after it was stopped. The connector 
server counts the client connections that
+ * it accepts.
+ */
+class JMXConsumerReconnectTest extends SimpleBeanFixture {
+
+    @RegisterExtension
+    AvailablePortFinder.Port portHolder = AvailablePortFinder.find();
+
+    /**
+     * How long no connection may be opened: three times the reconnect delay 
of one second
+     */
+    private static final int QUIET_SECONDS = 3;
+
+    private final Semaphore opened = new Semaphore(0);
+    private JMXServiceURL url;
+    private JMXConnectorServer connector;
+    private Registry registry;
+    private boolean beanRegistered;
+
+    @BeforeEach
+    @Override
+    public void setUp() throws Exception {
+        url = new JMXServiceURL("service:jmx:rmi:///jndi/rmi://localhost:" + 
portHolder.getPort() + "/" + DOMAIN);
+        registry = LocateRegistry.createRegistry(portHolder.getPort());
+        initServer();
+        initBean();
+        initContext();
+        startContext();
+    }
+
+    @Override
+    @AfterEach
+    public void tearDown() throws Exception {
+        if (!getContext().isStopped()) {
+            getContext().stop();
+        }
+        if (beanRegistered) {
+            unregisterBean(makeObjectName("simpleBean"));
+        }
+        if (connector != null) {
+            connector.stop();
+        }
+        if (registry != null) {
+            try {
+                UnicastRemoteObject.unexportObject(registry, true);
+            } catch (NoSuchObjectException e) {
+                // already unexported
+            }
+        }
+    }
+
+    @Override
+    protected void initServer() throws Exception {
+        server = MBeanServerFactory.newMBeanServer(DOMAIN);
+        connector = JMXConnectorServerFactory.newJMXConnectorServer(url, 
Collections.<String, Object> emptyMap(), server);
+        // one permit for every client connection that this connector server 
accepts
+        connector.addNotificationListener((notification, handback) -> {
+            if 
(JMXConnectionNotification.OPENED.equals(notification.getType())) {
+                opened.release();
+            }
+        }, null, null);
+        connector.start();
+    }
+
+    @Override
+    protected void initBean() throws Exception {
+        super.initBean();
+        beanRegistered = true;
+    }
+
+    @Override
+    protected JMXUriBuilder buildFromURI() {
+        return 
super.buildFromURI().withServerName(url.toString()).withReconnectDelay(1)
+                .withReconnectOnConnectionFailure(true);
+    }
+
+    /**
+     * A lost connection: the connector reports it with "failed" and then, 
when it closes itself, with "closed", both
+     * with the id of that connection (observed with the RMI connector of JDK 
21 when the server can no longer be
+     * reached). That is one lost connection, so the consumer must connect 
once.
+     */
+    @Test
+    void testReconnectsOnceWhenTheConnectionFailed() throws Exception {
+        MockEndpoint mock = getMockFixture().getMockEndpoint();
+        awaitFirstConnection(mock);
+
+        String connectionId = connector.getConnectionIds()[0];
+        NotificationListener listener = connectionListener();
+        listener.handleNotification(new JMXConnectionNotification(
+                JMXConnectionNotification.FAILED, connector, connectionId, 1, 
"Failed to communicate with the server",
+                null), null);
+        listener.handleNotification(new JMXConnectionNotification(
+                JMXConnectionNotification.CLOSED, connector, connectionId, 2, 
"Client has been closed", null), null);
+
+        assertTrue(opened.tryAcquire(20, TimeUnit.SECONDS), "the consumer did 
not replace its connection");
+
+        assertKeepsConnectionAndIsSubscribedOnce(mock);
+    }
+
+    /**
+     * The server restarts and the MBean is registered a little later than the 
connector is back (the usual order when
+     * an application restarts). Whether the connector re-establishes the 
connection itself or reports it as lost
+     * depends on timing; the consumer must keep trying to subscribe either 
way, and keep the connection it subscribed
+     * on.
+     */
+    @Test
+    void testSubscribesWhenTheBeanIsRegisteredAfterTheServerIsBack() throws 
Exception {
+        MockEndpoint mock = getMockFixture().getMockEndpoint();
+        awaitFirstConnection(mock);
+
+        // the server goes away and comes back without the MBean
+        connector.stop();
+        beanRegistered = false;
+        initServer();
+
+        // two connections: a connection without a subscription is not the 
end, the consumer connects again
+        assertTrue(opened.tryAcquire(2, 30, TimeUnit.SECONDS),
+                "the consumer did not keep trying to subscribe after the 
server came back without the MBean");
+        assertFalse(server.isRegistered(makeObjectName("simpleBean")));
+
+        // now the MBean is there: one of the next attempts subscribes
+        mock.reset();
+        mock.setMinimumExpectedMessageCount(1);
+        initBean();
+        awaitSubscribed(mock);
+
+        // The consumer is subscribed, so it has no reason to connect again. 
The check does not depend on the moment
+        // at which it starts: it tolerates a connection that is being opened 
right now and fails only when the
+        // consumer keeps connecting.
+        assertTrue(awaitNoConnectionFor(QUIET_SECONDS, 3),
+                "the consumer kept connecting although it was subscribed on a 
working connection");
+
+        assertDeliversOnce(mock);
+    }
+
+    /**
+     * The consumer replaces its connection when the server reports lost 
notifications. It closes the old connection
+     * itself, which must not count as one more lost connection.
+     */
+    @Test
+    void testReconnectsOnceWhenNotificationsWereLost() throws Exception {
+        MockEndpoint mock = getMockFixture().getMockEndpoint();
+        awaitFirstConnection(mock);
+
+        String connectionId = connector.getConnectionIds()[0];
+        connectionListener().handleNotification(new JMXConnectionNotification(
+                JMXConnectionNotification.NOTIFS_LOST, connector, 
connectionId, 1, "lost notifications", 1L), null);
+
+        assertTrue(opened.tryAcquire(20, TimeUnit.SECONDS), "the consumer did 
not replace its connection");
+
+        assertKeepsConnectionAndIsSubscribedOnce(mock);
+    }
+
+    /**
+     * The connector reports that it has opened a new connection by itself (as 
the RMI connector does after an I/O error
+     * when the server can be reached), and has registered the listener of the 
consumer again because the MBean was
+     * there. The consumer subscribes again on that connection: it does not 
close a connection that works, and it is
+     * subscribed once.
+     */
+    @Test
+    void testSubscribesAgainOnTheConnectionThatTheConnectorOpenedByItself() 
throws Exception {
+        MockEndpoint mock = getMockFixture().getMockEndpoint();
+        awaitFirstConnection(mock);
+
+        connectorReconnected();
+
+        assertKeepsConnectionAndIsSubscribedOnce(mock);
+    }
+
+    /**
+     * As above, but the connector could not register the listener of the 
consumer again (the MBean was not there when
+     * it reconnected). The consumer subscribes again on the connection that 
the connector opened.
+     */
+    @Test
+    void testSubscribesAgainWhenTheConnectorReconnectedWithoutTheListener() 
throws Exception {
+        MockEndpoint mock = getMockFixture().getMockEndpoint();
+        awaitFirstConnection(mock);
+
+        JMXConsumer consumer = consumer();
+        
consumer.getServerConnection().removeNotificationListener(makeObjectName("simpleBean"),
 consumer);
+        connectorReconnected();
+
+        mock.reset();
+        mock.setMinimumExpectedMessageCount(1);
+        awaitSubscribed(mock);
+
+        assertKeepsConnectionAndIsSubscribedOnce(mock);
+    }
+
+    /**
+     * As above, but the consumer cannot subscribe on the connection that the 
connector opened (here: the MBean is not
+     * registered). Then it connects again, until it can subscribe.
+     */
+    @Test
+    void 
testReconnectsWhenItCannotSubscribeAgainOnTheConnectionThatTheConnectorOpened() 
throws Exception {
+        MockEndpoint mock = getMockFixture().getMockEndpoint();
+        awaitFirstConnection(mock);
+
+        unregisterBean(makeObjectName("simpleBean"));
+        beanRegistered = false;
+        connectorReconnected();
+
+        assertTrue(opened.tryAcquire(20, TimeUnit.SECONDS),
+                "the consumer did not connect again after it could not 
subscribe on the connection of the connector");
+
+        mock.reset();
+        mock.setMinimumExpectedMessageCount(1);
+        initBean();
+        awaitSubscribed(mock);
+
+        assertTrue(awaitNoConnectionFor(QUIET_SECONDS, 3),
+                "the consumer kept connecting although it was subscribed on a 
working connection");
+
+        assertDeliversOnce(mock);
+    }
+
+    /**
+     * A notification about a lost connection that arrives after the consumer 
was stopped must not make it connect
+     * again.
+     */
+    @Test
+    void testDoesNotReconnectAfterStop() throws Exception {
+        MockEndpoint mock = getMockFixture().getMockEndpoint();
+        awaitFirstConnection(mock);
+
+        String connectionId = connector.getConnectionIds()[0];
+        NotificationListener listener = connectionListener();
+        Route route = getContext().getRoutes().get(0);
+        getContext().getRouteController().stopRoute(route.getRouteId());
+
+        listener.handleNotification(new JMXConnectionNotification(
+                JMXConnectionNotification.FAILED, connector, connectionId, 1, 
"Failed to communicate with the server",
+                null), null);
+
+        assertFalse(opened.tryAcquire(QUIET_SECONDS, TimeUnit.SECONDS),
+                "the consumer connected again although it was stopped");
+    }
+
+    private JMXConsumer consumer() {
+        return (JMXConsumer) getContext().getRoutes().get(0).getConsumer();
+    }
+
+    private NotificationListener connectionListener() {
+        return consumer().getConnectionNotificationListener();
+    }
+
+    /**
+     * Sends the notification of a connector that has opened a new connection 
by itself.
+     */
+    private void connectorReconnected() {
+        connectionListener().handleNotification(new JMXConnectionNotification(
+                JMXConnectionNotification.OPENED, connector, 
"rmi://reconnected 2", 1, "Reconnected to server", null),
+                null);
+    }
+
+    private void awaitFirstConnection(MockEndpoint mock) throws Exception {
+        assertTrue(opened.tryAcquire(20, TimeUnit.SECONDS), "the consumer did 
not connect at start");
+        getSimpleMXBean().touch();
+        mock.assertIsSatisfied(20000);
+    }
+
+    /**
+     * The consumer is subscribed on a connection that works: it must not 
connect again (waits three times the reconnect
+     * delay of one second for a connection that must not come), and one 
notification is delivered once.
+     */
+    private void assertKeepsConnectionAndIsSubscribedOnce(MockEndpoint mock) 
throws Exception {
+        assertFalse(opened.tryAcquire(QUIET_SECONDS, TimeUnit.SECONDS),
+                "the consumer connected again although it was subscribed on a 
working connection");
+
+        assertDeliversOnce(mock);
+    }
+
+    private void assertDeliversOnce(MockEndpoint mock) throws Exception {
+        mock.reset();
+        mock.setExpectedMessageCount(1);
+        // keep checking after the first message, so that a duplicate 
subscription is detected
+        mock.setAssertPeriod(1000);
+        getSimpleMXBean().touch();
+        mock.assertIsSatisfied(20000);
+    }
+
+    /**
+     * Waits until the connector server has accepted no connection for the 
given number of seconds; gives up after the
+     * given number of periods that had a connection.
+     */
+    private boolean awaitNoConnectionFor(int seconds, int attempts) throws 
InterruptedException {
+        for (int i = 0; i < attempts; i++) {
+            if (!opened.tryAcquire(seconds, TimeUnit.SECONDS)) {
+                return true;
+            }
+            opened.drainPermits();
+        }
+        return false;
+    }
+
+    /**
+     * Touches the bean until the consumer delivers a notification (bounded: 
150 rounds of at most 200 ms).
+     */
+    private void awaitSubscribed(MockEndpoint mock) throws Exception {
+        for (int i = 0; i < 150; i++) {
+            try {
+                getSimpleMXBean().touch();
+            } catch (Exception e) {
+                if (!(e.getCause() instanceof InstanceNotFoundException)) {
+                    throw e;
+                }
+            }
+            if (mock.await(200, TimeUnit.MILLISECONDS)) {
+                return;
+            }
+        }
+        mock.assertIsSatisfied(0);
+    }
+}

Reply via email to