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 737ed0f5b35b CAMEL-24523: camel-paho - restart the consumer route when
resubscribe fails after reconnect
737ed0f5b35b is described below
commit 737ed0f5b35b349632ad9632403b72f60a406ddb
Author: nkokitkar <[email protected]>
AuthorDate: Wed Sep 16 01:48:58 2026 -0700
CAMEL-24523: camel-paho - restart the consumer route when resubscribe fails
after reconnect
When Paho's automatic reconnect succeeds but the topic resubscription
fails, the consumer silently stops receiving messages while the route
still reports Started. The consumer now restarts its route asynchronously
(off the Paho callback thread, on a dedicated executor, a CAS against a
double restart, stale callbacks of a replaced client ignored, no restart
while the consumer or the context is stopping) when it owns the client;
with an externally provided client it logs an error and leaves the route
alone, keeping the owned/shared cleanup semantics of CAMEL-24465.
Mirrors the camel-paho-mqtt5 recovery of CAMEL-24511. camel-paho is
deprecated; this is a narrow reliability fix for MQTT 3.1/3.1.1 users
who cannot migrate yet. Documented in the 4.23 upgrade guide with the
cleanSession and SupervisingRouteController guidance.
Closes #26476
Co-authored-by: Copilot <[email protected]>
---
.../apache/camel/component/paho/PahoConsumer.java | 69 +++++-
.../component/paho/PahoResubscribeFailureTest.java | 250 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 22 ++
3 files changed, 336 insertions(+), 5 deletions(-)
diff --git
a/components/camel-paho/src/main/java/org/apache/camel/component/paho/PahoConsumer.java
b/components/camel-paho/src/main/java/org/apache/camel/component/paho/PahoConsumer.java
index 19269c2cc05e..2727a81418f1 100644
---
a/components/camel-paho/src/main/java/org/apache/camel/component/paho/PahoConsumer.java
+++
b/components/camel-paho/src/main/java/org/apache/camel/component/paho/PahoConsumer.java
@@ -16,6 +16,9 @@
*/
package org.apache.camel.component.paho;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.atomic.AtomicBoolean;
+
import org.apache.camel.AsyncCallback;
import org.apache.camel.Endpoint;
import org.apache.camel.Exchange;
@@ -40,6 +43,7 @@ public class PahoConsumer extends DefaultConsumer {
private volatile String clientId;
private volatile boolean stopClient;
private volatile MqttConnectOptions connectOptions;
+ private final AtomicBoolean restarting = new AtomicBoolean(false);
public PahoConsumer(Endpoint endpoint, Processor processor) {
super(endpoint, processor);
@@ -74,15 +78,26 @@ public class PahoConsumer extends DefaultConsumer {
client.connect(connectOptions);
}
- client.setCallback(new MqttCallbackExtended() {
+ MqttClient callbackClient = client;
+ boolean isOwnedClient = stopClient;
+ callbackClient.setCallback(new MqttCallbackExtended() {
@Override
public void connectComplete(boolean reconnect, String
serverURI) {
- if (reconnect) {
+ if (reconnect && isRunAllowed() && callbackClient ==
client) {
try {
- client.subscribe(getEndpoint().getTopic(),
getEndpoint().getConfiguration().getQos());
+ callbackClient.subscribe(getEndpoint().getTopic(),
getEndpoint().getConfiguration().getQos());
} catch (MqttException e) {
- LOG.error("MQTT resubscribe failed {}",
e.getMessage(), e);
+ if (isOwnedClient) {
+ LOG.warn("MQTT resubscribe failed on
reconnect, restarting route for recovery: {}",
+ e.getMessage(), e);
+ restartRouteAsync();
+ } else {
+ LOG.error(
+ "MQTT resubscribe failed on reconnect
with externally provided client,"
+ + " route will not be
auto-restarted: {}",
+ e.getMessage(), e);
+ }
}
}
}
@@ -109,7 +124,7 @@ public class PahoConsumer extends DefaultConsumer {
});
LOG.debug("Subscribing client: {} to topic: {}", clientId,
getEndpoint().getTopic());
- client.subscribe(getEndpoint().getTopic(),
getEndpoint().getConfiguration().getQos());
+ callbackClient.subscribe(getEndpoint().getTopic(),
getEndpoint().getConfiguration().getQos());
} catch (Exception startException) {
MqttClient ownedClient = stopClient ? client : null;
if (ownedClient != null) {
@@ -128,6 +143,50 @@ public class PahoConsumer extends DefaultConsumer {
}
}
+ private void restartRouteAsync() {
+ if (!restarting.compareAndSet(false, true)) {
+ LOG.debug("Route restart already in progress, skipping duplicate
restart");
+ return;
+ }
+ String threadName = "Paho-RestartRoute-" + getRouteId();
+ ExecutorService executor = null;
+ try {
+ executor
+ =
getEndpoint().getCamelContext().getExecutorServiceManager().newSingleThreadExecutor(this,
threadName);
+ ExecutorService restartExecutor = executor;
+ restartExecutor.submit(() -> {
+ try {
+ if (!isRunAllowed() ||
!getEndpoint().getCamelContext().isRunAllowed()) {
+ LOG.debug("Consumer or Camel context is stopping,
skipping route restart");
+ return;
+ }
+ String routeId = getRouteId();
+ LOG.info("Stopping route {} for restart after resubscribe
failure", routeId);
+
getEndpoint().getCamelContext().getRouteController().stopRoute(routeId);
+ if (!getEndpoint().getCamelContext().isRunAllowed()) {
+ LOG.debug("Camel context is stopping, not restarting
route {}", routeId);
+ return;
+ }
+ LOG.info("Restarting route {}", routeId);
+
getEndpoint().getCamelContext().getRouteController().startRoute(routeId);
+ } catch (Exception e) {
+ getExceptionHandler().handleException(
+ "Failed to restart route after resubscribe
failure", e);
+ } finally {
+ restarting.set(false);
+
getEndpoint().getCamelContext().getExecutorServiceManager().shutdownNow(restartExecutor);
+ }
+ });
+ } catch (RuntimeException e) {
+ restarting.set(false);
+ if (executor != null) {
+
getEndpoint().getCamelContext().getExecutorServiceManager().shutdownNow(executor);
+ }
+ getExceptionHandler().handleException(
+ "Failed to schedule route restart after resubscribe
failure", e);
+ }
+ }
+
@Override
protected void doStop() throws Exception {
MqttClient ownedClient = stopClient ? client : null;
diff --git
a/components/camel-paho/src/test/java/org/apache/camel/component/paho/PahoResubscribeFailureTest.java
b/components/camel-paho/src/test/java/org/apache/camel/component/paho/PahoResubscribeFailureTest.java
new file mode 100644
index 000000000000..612304586bc8
--- /dev/null
+++
b/components/camel-paho/src/test/java/org/apache/camel/component/paho/PahoResubscribeFailureTest.java
@@ -0,0 +1,250 @@
+/*
+ * 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.paho;
+
+import java.util.ArrayDeque;
+import java.util.Arrays;
+import java.util.Deque;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.camel.Consumer;
+import org.apache.camel.Endpoint;
+import org.apache.camel.Processor;
+import org.apache.camel.ServiceStatus;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.eclipse.paho.client.mqttv3.MqttCallbackExtended;
+import org.eclipse.paho.client.mqttv3.MqttClient;
+import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
+import org.eclipse.paho.client.mqttv3.MqttException;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class PahoResubscribeFailureTest extends CamelTestSupport {
+
+ private static final String ROUTE_ID = "mqtt-consumer";
+
+ @Override
+ public boolean isUseAdviceWith() {
+ return true;
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ }
+ };
+ }
+
+ @Test
+ void successfulResubscribeOnReconnectShouldKeepRouteStarted() throws
Exception {
+ MqttClient client = mock(MqttClient.class);
+ MqttCallbackExtended callback = startRouteWithExternalClient(client);
+
+ callback.connectComplete(true, "tcp://localhost:1883");
+
+ verify(client, times(2)).subscribe("test", 2);
+
assertThat(context.getRouteController().getRouteStatus(ROUTE_ID)).isEqualTo(ServiceStatus.Started);
+ }
+
+ @Test
+ void
resubscribeFailureWithOwnedClientShouldRestartRouteAndReplaceOwnedClient()
throws Exception {
+ MqttClient failedClient = connectedClient();
+ MqttClient recoveredClient = connectedClient();
+ AtomicInteger createdClients = new AtomicInteger();
+ MqttCallbackExtended callback =
startRouteWithOwnedClients(createdClients, failedClient, recoveredClient);
+ doThrow(new MqttException(MqttException.REASON_CODE_CLIENT_EXCEPTION))
+ .when(failedClient).subscribe(anyString(), anyInt());
+
+ callback.connectComplete(true, "tcp://localhost:1883");
+
+ await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> {
+
assertThat(context.getRouteController().getRouteStatus(ROUTE_ID)).isEqualTo(ServiceStatus.Started);
+ assertThat(createdClients).hasValue(2);
+ verify(failedClient).close(true);
+ verify(recoveredClient).connect(any(MqttConnectOptions.class));
+ verify(recoveredClient).subscribe("test", 2);
+ verify(recoveredClient, never()).close(true);
+ });
+
+ callback.connectComplete(true, "tcp://localhost:1883");
+
+ assertThat(createdClients).hasValue(2);
+ verify(failedClient, times(2)).subscribe("test", 2);
+ }
+
+ @Test
+ void resubscribeFailureWithExternalClientShouldNotRestartOrCloseClient()
throws Exception {
+ MqttClient client = connectedClient();
+ MqttCallbackExtended callback = startRouteWithExternalClient(client);
+ doThrow(new MqttException(MqttException.REASON_CODE_CLIENT_EXCEPTION))
+ .when(client).subscribe(anyString(), anyInt());
+
+ callback.connectComplete(true, "tcp://localhost:1883");
+
+
assertThat(context.getRouteController().getRouteStatus(ROUTE_ID)).isEqualTo(ServiceStatus.Started);
+ verify(client, times(2)).subscribe("test", 2);
+ verify(client, never()).connect(any(MqttConnectOptions.class));
+ verify(client, never()).close(true);
+ }
+
+ @Test
+ void concurrentReconnectFailuresShouldTriggerOnlyOneRestart() throws
Exception {
+ MqttClient failedClient = connectedClient();
+ MqttClient recoveredClient = connectedClient();
+ AtomicInteger createdClients = new AtomicInteger();
+ MqttCallbackExtended callback =
startRouteWithOwnedClients(createdClients, failedClient, recoveredClient);
+ CyclicBarrier subscribeBarrier = new CyclicBarrier(2);
+ CountDownLatch closeEntered = new CountDownLatch(1);
+ CountDownLatch releaseClose = new CountDownLatch(1);
+ doAnswer(invocation -> {
+ subscribeBarrier.await(5, TimeUnit.SECONDS);
+ throw new
MqttException(MqttException.REASON_CODE_CLIENT_EXCEPTION);
+ }).when(failedClient).subscribe(anyString(), anyInt());
+ doAnswer(invocation -> {
+ closeEntered.countDown();
+ assertThat(releaseClose.await(5, TimeUnit.SECONDS)).isTrue();
+ return null;
+ }).when(failedClient).close(true);
+
+ CompletableFuture<Void> first = CompletableFuture.runAsync(
+ () -> callback.connectComplete(true, "tcp://localhost:1883"));
+ CompletableFuture<Void> second = CompletableFuture.runAsync(
+ () -> callback.connectComplete(true, "tcp://localhost:1883"));
+
+ assertThat(closeEntered.await(5, TimeUnit.SECONDS)).isTrue();
+ first.get(5, TimeUnit.SECONDS);
+ second.get(5, TimeUnit.SECONDS);
+ releaseClose.countDown();
+
+ await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> {
+
assertThat(context.getRouteController().getRouteStatus(ROUTE_ID)).isEqualTo(ServiceStatus.Started);
+ assertThat(createdClients).hasValue(2);
+ verify(failedClient).close(true);
+ verify(recoveredClient).connect(any(MqttConnectOptions.class));
+ });
+ }
+
+ @Test
+ void reconnectCallbackAfterShutdownShouldNotResubscribeOrRestart() throws
Exception {
+ MqttClient client = connectedClient();
+ AtomicInteger createdClients = new AtomicInteger();
+ MqttCallbackExtended callback =
startRouteWithOwnedClients(createdClients, client);
+
+ context.getRouteController().stopRoute(ROUTE_ID);
+ callback.connectComplete(true, "tcp://localhost:1883");
+
+
assertThat(context.getRouteController().getRouteStatus(ROUTE_ID)).isEqualTo(ServiceStatus.Stopped);
+ assertThat(createdClients).hasValue(1);
+ verify(client, times(1)).subscribe("test", 2);
+ verify(client).close(true);
+ }
+
+ @Test
+ void initialConnectShouldNotResubscribe() throws Exception {
+ MqttClient client = mock(MqttClient.class);
+ MqttCallbackExtended callback = startRouteWithExternalClient(client);
+
+ callback.connectComplete(false, "tcp://localhost:1883");
+
+ verify(client, times(1)).subscribe("test", 2);
+
assertThat(context.getRouteController().getRouteStatus(ROUTE_ID)).isEqualTo(ServiceStatus.Started);
+ }
+
+ private MqttCallbackExtended startRouteWithExternalClient(MqttClient
client) throws Exception {
+ PahoEndpoint endpoint = context.getEndpoint(
+ "paho:test?brokerUrl=tcp://localhost:1883",
PahoEndpoint.class);
+ endpoint.setClient(client);
+
context.addRoutes(createRoute("paho:test?brokerUrl=tcp://localhost:1883"));
+ context.start();
+ return captureCallback(client);
+ }
+
+ private MqttCallbackExtended startRouteWithOwnedClients(
+ AtomicInteger createdClients, MqttClient... clients)
+ throws Exception {
+ PahoConfiguration configuration = new PahoConfiguration();
+ configuration.setAutomaticReconnect(true);
+ configuration.setBrokerUrl("tcp://localhost:1883");
+ Deque<MqttClient> availableClients = new
ArrayDeque<>(Arrays.asList(clients));
+ PahoComponent component = new PahoComponent(context) {
+ @Override
+ protected Endpoint createEndpoint(String uri, String remaining,
Map<String, Object> parameters) {
+ PahoEndpoint endpoint = new PahoEndpoint(uri, remaining, this,
configuration.copy()) {
+ @Override
+ public Consumer createConsumer(Processor processor) throws
Exception {
+ PahoConsumer consumer = new PahoConsumer(this,
processor) {
+ @Override
+ MqttClient createClient() {
+ createdClients.incrementAndGet();
+ return availableClients.removeFirst();
+ }
+ };
+ configureConsumer(consumer);
+ return consumer;
+ }
+ };
+ return endpoint;
+ }
+ };
+ context.addComponent("paho-owned", component);
+ context.addRoutes(createRoute("paho-owned:test"));
+ context.start();
+ return captureCallback(clients[0]);
+ }
+
+ private RouteBuilder createRoute(String uri) {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from(uri).id(ROUTE_ID).to("mock:result");
+ }
+ };
+ }
+
+ private MqttCallbackExtended captureCallback(MqttClient client) throws
Exception {
+ ArgumentCaptor<MqttCallbackExtended> callbackCaptor =
ArgumentCaptor.forClass(MqttCallbackExtended.class);
+ verify(client).setCallback(callbackCaptor.capture());
+ return callbackCaptor.getValue();
+ }
+
+ private static MqttClient connectedClient() {
+ MqttClient client = mock(MqttClient.class);
+ when(client.isConnected()).thenReturn(true);
+ return client;
+ }
+}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index a1256c0d26db..79d921a41d34 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -1014,6 +1014,28 @@ Deployments that relied on the previous behaviour — a
development cluster with
certificate, for example — must either configure a truststore or set
`camel.knative.client.ssl.trust.all`
explicitly. `KnativeOidcClientOptions` extends this class and is affected the
same way.
+=== camel-paho
+
+When `automaticReconnect=true` and the MQTT broker reconnects, the consumer
now restarts the route
+if the post-reconnect `subscribe()` call fails. Previously a failed
resubscription (for example,
+when the broker does not send a SUBACK and the Paho keepAlive timer triggers
`MqttException 32000`)
+was only logged at ERROR level with no recovery action, leaving the route in
`Started` state while
+silently consuming no messages (zombie state).
+
+If the resubscribe fails and the consumer owns the MQTT client (the default),
it automatically stops
+and restarts the route to force a clean reconnect. If the restart also fails
(for example, the broker
+is still unavailable), the route is left in `Stopped` state. With
`cleanSession=true`, the consumer
+unsubscribes before disconnecting; with `cleanSession=false`, it keeps the
durable subscription. Routes
+using a user-provided client are not affected by this change. Configuring
Camel's `SupervisingRouteController`
+allows the framework to keep retrying with exponential backoff until the
broker recovers:
+
+[source,properties]
+----
+camel.routeController.enabled = true
+camel.routeController.backOffDelay = 2000
+camel.routeController.backOffMaxDelay = 60000
+----
+
=== camel-paho-mqtt5
When `automaticReconnect=true` and the MQTT broker reconnects, the consumer
now restarts the route