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 6bc46f9cffc1 CAMEL-26884: camel-nats - the stop of a consumer or
producer goes on when the flush fails, and a consumer ends a pull subscription
made while it stops (#29788)
6bc46f9cffc1 is described below
commit 6bc46f9cffc112cf25030101ca1e895b5267dc6a
Author: allthingssecurity <[email protected]>
AuthorDate: Fri Nov 13 20:23:14 2026 +0530
CAMEL-26884: camel-nats - the stop of a consumer or producer goes on when
the flush fails, and a consumer ends a pull subscription made while it stops
(#29788)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../apache/camel/component/nats/NatsConsumer.java | 94 +++++--
.../apache/camel/component/nats/NatsProducer.java | 24 +-
.../component/nats/NatsConsumerStopGapsTest.java | 309 +++++++++++++++++++++
3 files changed, 403 insertions(+), 24 deletions(-)
diff --git
a/components/camel-nats/src/main/java/org/apache/camel/component/nats/NatsConsumer.java
b/components/camel-nats/src/main/java/org/apache/camel/component/nats/NatsConsumer.java
index e5e0d7343996..a346e298b56a 100644
---
a/components/camel-nats/src/main/java/org/apache/camel/component/nats/NatsConsumer.java
+++
b/components/camel-nats/src/main/java/org/apache/camel/component/nats/NatsConsumer.java
@@ -113,6 +113,9 @@ public class NatsConsumer extends DefaultConsumer {
@Override
protected void doStop() throws Exception {
final NatsConfiguration configuration =
this.getEndpoint().getConfiguration();
+ // an interrupt during the flush is kept until the end: jnats drops a
message to the server (an UNSUB) that is
+ // queued on an interrupted thread
+ boolean interrupted = false;
if (this.pushConsumerKeeper != null) {
this.pushConsumerKeeper.stop();
@@ -121,7 +124,19 @@ public class NatsConsumer extends DefaultConsumer {
if (configuration.isFlushConnection() &&
ObjectHelper.isNotEmpty(this.connection)) {
LOG.debug("Flushing Messages before stopping");
-
this.connection.flush(Duration.ofMillis(configuration.getFlushTimeout()));
+ try {
+
this.connection.flush(Duration.ofMillis(configuration.getFlushTimeout()));
+ } catch (InterruptedException e) {
+ interrupted = true;
+ LOG.warn("Interrupted while flushing the NATS connection
before stopping the consumer of topic {}",
+ configuration.getTopic());
+ } catch (Exception e) {
+ // best effort (jnats fails the flush of a connection that is
not connected): the consumer must still
+ // end its subscriptions, its executor and its own connection
+ LOG.warn("Cannot flush the NATS connection before stopping the
consumer of topic {} due to: {}",
+ configuration.getTopic(), e.toString());
+ LOG.debug("Flush failure", e);
+ }
}
final Dispatcher stoppedDispatcher = this.dispatcher;
@@ -159,28 +174,40 @@ public class NatsConsumer extends DefaultConsumer {
this.dispatcherHandler = null;
}
- if (ObjectHelper.isNotEmpty(this.jetStreamSubscription) &&
this.jetStreamSubscription.isActive()) {
+ final JetStreamSubscription stoppedSubscription =
this.jetStreamSubscription;
+ if (ObjectHelper.isNotEmpty(stoppedSubscription) &&
stoppedSubscription.isActive()) {
try {
- this.jetStreamSubscription.unsubscribe();
+ stoppedSubscription.unsubscribe();
} catch (final Exception e) {
- this.getExceptionHandler().handleException("Error during
unsubscribing", e);
+ if (stoppedSubscription.isActive()) {
+ this.getExceptionHandler().handleException("Error during
unsubscribing", e);
+ } else {
+ // the consuming task ended it meanwhile, as it saw the
stop after its subscribe
+ LOG.debug("Not unsubscribing the subscription of the
stopped consumer, as it is ended already", e);
+ }
}
}
LOG.debug("Stopping Nats Consumer");
- if (this.executor != null) {
- if (this.getEndpoint() != null &&
this.getEndpoint().getCamelContext() != null) {
-
this.getEndpoint().getCamelContext().getExecutorServiceManager().shutdownNow(this.executor);
- } else {
- this.executor.shutdownNow();
+ try {
+ if (this.executor != null) {
+ if (this.getEndpoint() != null &&
this.getEndpoint().getCamelContext() != null) {
+
this.getEndpoint().getCamelContext().getExecutorServiceManager().shutdownNow(this.executor);
+ } else {
+ this.executor.shutdownNow();
+ }
}
- }
- this.executor = null;
+ this.executor = null;
- if (ObjectHelper.isEmpty(configuration.getConnection()) &&
ObjectHelper.isNotEmpty(this.connection)) {
- LOG.debug("Closing Nats Connection");
- if (!this.connection.getStatus().equals(Connection.Status.CLOSED))
{
- this.connection.close();
+ if (ObjectHelper.isEmpty(configuration.getConnection()) &&
ObjectHelper.isNotEmpty(this.connection)) {
+ LOG.debug("Closing Nats Connection");
+ if
(!this.connection.getStatus().equals(Connection.Status.CLOSED)) {
+ this.connection.close();
+ }
+ }
+ } finally {
+ if (interrupted) {
+ Thread.currentThread().interrupt();
}
}
super.doStop();
@@ -331,9 +358,16 @@ public class NatsConsumer extends DefaultConsumer {
configuration.getTopic(),
configuration.getAckPolicy(), configuration.getAckWait(),
configuration.getNackWait(),
configuration.getPullBatchSize(), configuration.getPullFetchTimeout());
- NatsConsumer.this.jetStreamSubscription =
this.connection.jetStream().subscribe(
+ JetStreamSubscription pullSubscription =
this.connection.jetStream().subscribe(
NatsConsumer.this.getEndpoint().getConfiguration().getTopic(),
pullOptions);
+ NatsConsumer.this.jetStreamSubscription = pullSubscription;
+ if (!NatsConsumer.this.isRunAllowed()) {
+ // stopped while subscribing: doStop may have run before
the subscription was set, and then did
+ // not end it
+ unsubscribeStopped(pullSubscription);
+ return;
+ }
NatsConsumer.this.setActive(true);
runPullFetchLoop(NatsConsumer.this.jetStreamSubscription,
messageHandler, cc);
@@ -385,6 +419,25 @@ public class NatsConsumer extends DefaultConsumer {
}
}
+ private void unsubscribeStopped(JetStreamSubscription subscription) {
+ // the stop interrupts this thread, and jnats drops a message to
the server that is queued on an interrupted
+ // thread: clear the interrupt for the unsubscribe, and keep it
afterwards
+ boolean interrupted = Thread.interrupted();
+ try {
+ if (subscription.isActive()) {
+ subscription.unsubscribe();
+ }
+ } catch (Exception e) {
+ // doStop may have ended it meanwhile
+ LOG.debug("Cannot unsubscribe the pull subscription of the
stopped consumer of topic {}",
+ configuration.getTopic(), e);
+ } finally {
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
private void runPullFetchLoop(
JetStreamSubscription subscription, CamelNatsMessageHandler
messageHandler, ConsumerConfiguration cc) {
JetStreamSubscription sub = subscription;
@@ -576,12 +629,9 @@ public class NatsConsumer extends DefaultConsumer {
unacknowledged.clear();
NatsConsumer.this.jetStreamSubscription = sub;
if (!NatsConsumer.this.isRunAllowed()) {
- // stopping: doStop may have unsubscribed the old subscription
before this one was assigned
- try {
- sub.unsubscribe();
- } catch (Exception e) {
- LOG.debug("Error unsubscribing the new pull subscription
while stopping", e);
- }
+ // stopping: doStop may have unsubscribed the old subscription
before this one was assigned, and the
+ // stop interrupts this thread
+ unsubscribeStopped(sub);
}
try {
old.unsubscribe();
diff --git
a/components/camel-nats/src/main/java/org/apache/camel/component/nats/NatsProducer.java
b/components/camel-nats/src/main/java/org/apache/camel/component/nats/NatsProducer.java
index 3c308a7035be..ee4a2493e6fe 100644
---
a/components/camel-nats/src/main/java/org/apache/camel/component/nats/NatsProducer.java
+++
b/components/camel-nats/src/main/java/org/apache/camel/component/nats/NatsProducer.java
@@ -191,11 +191,31 @@ public class NatsProducer extends DefaultAsyncProducer {
if
(ObjectHelper.isEmpty(this.getEndpoint().getConfiguration().getConnection())) {
LOG.debug("Closing Nats Connection");
if (this.connection != null &&
!this.connection.getStatus().equals(Status.CLOSED)) {
+ // an interrupt during the flush is kept until the connection
is closed
+ boolean interrupted = false;
if (this.getEndpoint().getConfiguration().isFlushConnection())
{
LOG.debug("Flushing Nats Connection");
-
this.connection.flush(Duration.ofMillis(this.getEndpoint().getConfiguration().getFlushTimeout()));
+ try {
+
this.connection.flush(Duration.ofMillis(this.getEndpoint().getConfiguration().getFlushTimeout()));
+ } catch (InterruptedException e) {
+ interrupted = true;
+ LOG.warn("Interrupted while flushing the NATS
connection before stopping the producer of topic {}",
+
this.getEndpoint().getConfiguration().getTopic());
+ } catch (Exception e) {
+ // best effort (jnats fails the flush of a connection
that is not connected): the connection
+ // of the producer must still be closed
+ LOG.warn("Cannot flush the NATS connection before
stopping the producer of topic {} due to: {}",
+
this.getEndpoint().getConfiguration().getTopic(), e.toString());
+ LOG.debug("Flush failure", e);
+ }
+ }
+ try {
+ this.connection.close();
+ } finally {
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
}
- this.connection.close();
}
}
super.doStop();
diff --git
a/components/camel-nats/src/test/java/org/apache/camel/component/nats/NatsConsumerStopGapsTest.java
b/components/camel-nats/src/test/java/org/apache/camel/component/nats/NatsConsumerStopGapsTest.java
new file mode 100644
index 000000000000..69399ee86c04
--- /dev/null
+++
b/components/camel-nats/src/test/java/org/apache/camel/component/nats/NatsConsumerStopGapsTest.java
@@ -0,0 +1,309 @@
+/*
+ * 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.nats;
+
+import java.time.ZonedDateTime;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import io.nats.client.Connection;
+import io.nats.client.JetStream;
+import io.nats.client.JetStreamManagement;
+import io.nats.client.JetStreamStatusException;
+import io.nats.client.JetStreamSubscription;
+import io.nats.client.PullSubscribeOptions;
+import io.nats.client.api.ServerInfo;
+import io.nats.client.api.StreamInfo;
+import io.nats.client.support.DateTimeUtils;
+import io.nats.client.support.JsonParser;
+import io.nats.client.support.Status;
+import org.apache.camel.Producer;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static
org.apache.camel.component.nats.NatsConsumerPullConsumerDeletedTest.FakeServer.proxy;
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+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 stop of a NATS consumer when the flush of the connection fails, and
when the consumer stops while its JetStream
+ * pull subscribe, or its subscribe again after the server deleted the
consumer, is in flight.
+ */
+class NatsConsumerStopGapsTest extends CamelTestSupport {
+
+ private final NatsConsumerJetStreamPushStopTest.FakeServer server = new
NatsConsumerJetStreamPushStopTest.FakeServer();
+
+ // the pull subscribe of the second test
+ private final CountDownLatch subscribing = new CountDownLatch(1);
+ private final CountDownLatch subscribeDone = new CountDownLatch(1);
+ private final AtomicInteger pullUnsubscribes = new AtomicInteger();
+ private final AtomicBoolean pullActive = new AtomicBoolean(true);
+ private final AtomicBoolean unsubscribedOnInterruptedThread = new
AtomicBoolean();
+
+ // the pull subscribe again of the third test
+ private final CountDownLatch resubscribing = new CountDownLatch(1);
+ private final CountDownLatch resubscribeDone = new CountDownLatch(1);
+ private final AtomicInteger newPullUnsubscribes = new AtomicInteger();
+ private final AtomicBoolean newPullActive = new AtomicBoolean(true);
+ private final AtomicBoolean newUnsubscribedOnInterruptedThread = new
AtomicBoolean();
+
+ NatsConsumerStopGapsTest() {
+ testConfiguration().withUseRouteBuilder(false);
+ }
+
+ @Test
+ void stopWhenTheFlushFailsEndsTheSubscription() throws Exception {
+ context.getRegistry().bind("conn", server.connection());
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+
from("nats:orders?connection=#conn&jetstreamEnabled=true&jetstreamName=ORDERS&pullSubscription=false")
+ .routeId("orders").to("mock:result");
+ }
+ });
+ context.start();
+ await().atMost(10, TimeUnit.SECONDS).until(() ->
server.activeSubscriptions() == 1);
+
+ // jnats fails the flush of a connection that is not connected (the
server is down)
+ server.onFlush = () -> sneakyThrow(new TimeoutException("Timeout out
waiting for connection before flush."));
+
+ assertDoesNotThrow(() ->
context.getRouteController().stopRoute("orders"));
+
+ // the stop still ends the subscription and closes the dispatcher
+ assertEquals(0, server.activeSubscriptions());
+ await().atMost(10, TimeUnit.SECONDS).until(() ->
server.closedDispatchers() == 1);
+
assertTrue(context.getRouteController().getRouteStatus("orders").isStopped());
+ }
+
+ @Test
+ void pullSubscriptionMadeWhileStoppingIsEnded() throws Exception {
+ context.getRegistry().bind("conn", pullConnection());
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+
from("nats:orders?connection=#conn&jetstreamEnabled=true&jetstreamName=ORDERS&flushConnection=false")
+ .routeId("orders").to("mock:result");
+ }
+ });
+ context.start();
+ assertTrue(subscribing.await(10, TimeUnit.SECONDS));
+
+ // the subscribe completes just before the stop interrupts the task,
and the task stores the subscription after
+ // doStop looked for it
+ context.getRouteController().stopRoute("orders");
+ subscribeDone.countDown();
+
+ await().atMost(10, TimeUnit.SECONDS).untilAsserted(() ->
assertEquals(1, pullUnsubscribes.get(),
+ "the subscription made for the stopped consumer must be
ended"));
+ assertFalse(unsubscribedOnInterruptedThread.get(), "the unsubscribe
must not run on an interrupted thread");
+ }
+
+ @Test
+ void pullSubscriptionMadeAgainWhileStoppingIsEnded() throws Exception {
+ context.getRegistry().bind("conn", resubscribeConnection());
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+
from("nats:orders?connection=#conn&jetstreamEnabled=true&jetstreamName=ORDERS&flushConnection=false")
+ .routeId("orders").to("mock:result");
+ }
+ });
+ context.start();
+ // the first fetch tells that the server deleted the ephemeral
consumer, and the task subscribes again
+ assertTrue(resubscribing.await(10, TimeUnit.SECONDS));
+
+ // the subscribe again completes just before the stop interrupts the
task, and the task stores the new
+ // subscription after doStop looked for it
+ context.getRouteController().stopRoute("orders");
+ resubscribeDone.countDown();
+
+ await().atMost(10, TimeUnit.SECONDS).untilAsserted(() ->
assertEquals(1, newPullUnsubscribes.get(),
+ "the subscription made again for the stopped consumer must be
ended"));
+ assertFalse(newUnsubscribedOnInterruptedThread.get(), "the unsubscribe
must not run on an interrupted thread");
+ }
+
+ @Test
+ void producerStopWhenTheFlushFailsClosesItsConnection() throws Exception {
+ AtomicInteger closes = new AtomicInteger();
+ Connection connection = proxy(Connection.class, (name, args) -> switch
(name) {
+ case "flush" -> throw new TimeoutException("Timeout out waiting
for connection before flush.");
+ case "close" -> {
+ closes.incrementAndGet();
+ yield null;
+ }
+ case "getStatus" -> Connection.Status.DISCONNECTED;
+ default -> null;
+ });
+ context.start();
+ NatsComponent component = context.getComponent("nats",
NatsComponent.class);
+ NatsConfiguration configuration = new NatsConfiguration();
+ configuration.setTopic("orders");
+ // the producer creates its own connection (no connection option),
which the stop must close
+ NatsEndpoint endpoint = new NatsEndpoint("nats:orders", component,
configuration) {
+ @Override
+ public Connection getConnection() {
+ return connection;
+ }
+ };
+ endpoint.setCamelContext(context);
+ Producer producer = endpoint.createProducer();
+ producer.start();
+
+ assertDoesNotThrow(producer::stop);
+
+ assertEquals(1, closes.get(), "the connection of the producer must be
closed");
+ assertTrue(((NatsProducer) producer).isStopped());
+ }
+
+ private Connection pullConnection() {
+ StreamInfo streamInfo = new StreamInfo(
+
JsonParser.parseUnchecked("{\"config\":{\"name\":\"ORDERS\",\"subjects\":[\"orders\"]},\"created\":\""
+ +
DateTimeUtils.toRfc3339(ZonedDateTime.now()) + "\",\"state\":{}}"));
+ JetStreamManagement jsm = proxy(JetStreamManagement.class, (name,
args) -> switch (name) {
+ case "getStreamInfo", "addStream" -> streamInfo;
+ default -> null;
+ });
+ JetStreamSubscription subscription =
proxy(JetStreamSubscription.class, (name, args) -> switch (name) {
+ case "isActive" -> pullActive.get();
+ case "unsubscribe" -> {
+ // jnats drops the UNSUB that it queues on an interrupted
thread
+ unsubscribedOnInterruptedThread.compareAndSet(false,
Thread.currentThread().isInterrupted());
+ pullUnsubscribes.incrementAndGet();
+ pullActive.set(false);
+ yield null;
+ }
+ case "fetch" -> List.of();
+ default -> null;
+ });
+ JetStream js = proxy(JetStream.class, (name, args) -> {
+ if ("subscribe".equals(name) && args.length == 2 && args[1]
instanceof PullSubscribeOptions) {
+ subscribing.countDown();
+ // the subscribe completed before the interrupt of the stop
reached it: the task gets the subscription
+ // after doStop looked for it
+ boolean interrupted = false;
+ while (true) {
+ try {
+ assertTrue(subscribeDone.await(20, TimeUnit.SECONDS));
+ break;
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ }
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ return subscription;
+ }
+ throw new UnsupportedOperationException(name);
+ });
+ ServerInfo info = new ServerInfo("INFO
{\"server_id\":\"fake\",\"jetstream\":true}");
+ return proxy(Connection.class, (name, args) -> switch (name) {
+ case "getServerInfo" -> info;
+ case "jetStreamManagement" -> jsm;
+ case "jetStream" -> js;
+ case "getStatus" -> Connection.Status.CONNECTED;
+ default -> null;
+ });
+ }
+
+ private Connection resubscribeConnection() {
+ StreamInfo streamInfo = new StreamInfo(
+
JsonParser.parseUnchecked("{\"config\":{\"name\":\"ORDERS\",\"subjects\":[\"orders\"]},\"created\":\""
+ +
DateTimeUtils.toRfc3339(ZonedDateTime.now()) + "\",\"state\":{}}"));
+ JetStreamManagement jsm = proxy(JetStreamManagement.class, (name,
args) -> switch (name) {
+ case "getStreamInfo", "addStream" -> streamInfo;
+ default -> null;
+ });
+ // the subscription of the first subscribe, whose consumer the server
deleted
+ AtomicBoolean firstActive = new AtomicBoolean(true);
+ AtomicBoolean toldDeleted = new AtomicBoolean();
+ JetStreamSubscription[] first = new JetStreamSubscription[1];
+ first[0] = proxy(JetStreamSubscription.class, (name, args) -> switch
(name) {
+ case "isActive" -> firstActive.get();
+ case "unsubscribe" -> {
+ firstActive.set(false);
+ yield null;
+ }
+ case "fetch" -> {
+ if (toldDeleted.compareAndSet(false, true)) {
+ throw new JetStreamStatusException(new Status(409,
Status.CONSUMER_DELETED), first[0]);
+ }
+ yield List.of();
+ }
+ default -> null;
+ });
+ // the subscription made again
+ JetStreamSubscription again = proxy(JetStreamSubscription.class,
(name, args) -> switch (name) {
+ case "isActive" -> newPullActive.get();
+ case "unsubscribe" -> {
+ // jnats drops the UNSUB that it queues on an interrupted
thread
+ newUnsubscribedOnInterruptedThread.compareAndSet(false,
Thread.currentThread().isInterrupted());
+ newPullUnsubscribes.incrementAndGet();
+ newPullActive.set(false);
+ yield null;
+ }
+ case "fetch" -> List.of();
+ default -> null;
+ });
+ AtomicInteger subscribes = new AtomicInteger();
+ JetStream js = proxy(JetStream.class, (name, args) -> {
+ if ("subscribe".equals(name) && args.length == 2 && args[1]
instanceof PullSubscribeOptions) {
+ if (subscribes.incrementAndGet() == 1) {
+ return first[0];
+ }
+ resubscribing.countDown();
+ // the subscribe again completed before the interrupt of the
stop reached it: the task gets the new
+ // subscription after doStop looked for it
+ boolean interrupted = false;
+ while (true) {
+ try {
+ assertTrue(resubscribeDone.await(20,
TimeUnit.SECONDS));
+ break;
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ }
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ return again;
+ }
+ throw new UnsupportedOperationException(name);
+ });
+ ServerInfo info = new ServerInfo("INFO
{\"server_id\":\"fake\",\"jetstream\":true}");
+ return proxy(Connection.class, (name, args) -> switch (name) {
+ case "getServerInfo" -> info;
+ case "jetStreamManagement" -> jsm;
+ case "jetStream" -> js;
+ case "getStatus" -> Connection.Status.CONNECTED;
+ default -> null;
+ });
+ }
+
+ @SuppressWarnings("unchecked")
+ private static <E extends Throwable> void sneakyThrow(Throwable e) throws
E {
+ throw (E) e;
+ }
+}