diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/nats-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/nats-component.adoc
index 8d11c415f0878..b83225d25e829 100644
--- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/nats-component.adoc
+++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/nats-component.adoc
@@ -292,3 +292,33 @@ from("nats:mytopic?jetstreamEnabled=true&jetstreamName=mystream&durableName=myco
If the route completes without calling any NatsManualAck method, the message remains unacknowledged
and NATS will redeliver it after `ackWait` expires (default 30 seconds).
+
+=== JetStream push consumer whose consumer was deleted
+
+With `jetstreamEnabled=true` and `pullSubscription=false`, the server deletes an ephemeral
+JetStream consumer (no `durableName`, and no durable name in the `consumerConfiguration`)
+after about 5 seconds without a subscriber, for example while the client is disconnected, and
+loses it on a restart. After every reconnect the Camel consumer asks the server for its
+consumer, and subscribes again when its ephemeral consumer is gone:
+
+* The new consumer starts at the first message the route did not acknowledge, so the route can
+see again the messages after that one (with `ackPolicy=None`, after the last message
+delivered). A message that was not acknowledged and was already delivered `maxDeliver` times
+does not count, as the server does not deliver it again.
+* When nothing was delivered yet, a consumer with the deliver policy `New` (or `Last`) starts
+after (or at) the last message of the stream at the subscribe.
+* On a stream with `WorkQueue` retention, and on a stream that was created again since the
+route subscribed, the new consumer uses the consumer configuration as it is (from the first
+message of the new stream for `New` and `Last`).
+* When the consumer cannot be checked or created again (for example JetStream is not ready yet
+after a server restart, or the stream is gone until it is created again), the Camel consumer
+keeps trying while the route runs, a few times a second apart and then up to every 30 seconds,
+and logs a WARN after the first attempts.
+
+A durable consumer is not created again: when it is gone (deleted by an administrator, or
+after its `inactiveThreshold`), the Camel consumer logs a WARN and keeps its subscription, and
+messages arrive again when the durable is created again with the same deliver subject.
+
+The consumer is checked only after a reconnect. A consumer that is deleted while the
+connection stays up (for example with `nats consumer rm`) is not detected, and the route
+receives nothing more until it is restarted.
diff --git a/components/camel-nats/src/main/docs/nats-component.adoc b/components/camel-nats/src/main/docs/nats-component.adoc
index 8d11c415f0878..b83225d25e829 100644
--- a/components/camel-nats/src/main/docs/nats-component.adoc
+++ b/components/camel-nats/src/main/docs/nats-component.adoc
@@ -292,3 +292,33 @@ from("nats:mytopic?jetstreamEnabled=true&jetstreamName=mystream&durableName=myco
If the route completes without calling any NatsManualAck method, the message remains unacknowledged
and NATS will redeliver it after `ackWait` expires (default 30 seconds).
+
+=== JetStream push consumer whose consumer was deleted
+
+With `jetstreamEnabled=true` and `pullSubscription=false`, the server deletes an ephemeral
+JetStream consumer (no `durableName`, and no durable name in the `consumerConfiguration`)
+after about 5 seconds without a subscriber, for example while the client is disconnected, and
+loses it on a restart. After every reconnect the Camel consumer asks the server for its
+consumer, and subscribes again when its ephemeral consumer is gone:
+
+* The new consumer starts at the first message the route did not acknowledge, so the route can
+see again the messages after that one (with `ackPolicy=None`, after the last message
+delivered). A message that was not acknowledged and was already delivered `maxDeliver` times
+does not count, as the server does not deliver it again.
+* When nothing was delivered yet, a consumer with the deliver policy `New` (or `Last`) starts
+after (or at) the last message of the stream at the subscribe.
+* On a stream with `WorkQueue` retention, and on a stream that was created again since the
+route subscribed, the new consumer uses the consumer configuration as it is (from the first
+message of the new stream for `New` and `Last`).
+* When the consumer cannot be checked or created again (for example JetStream is not ready yet
+after a server restart, or the stream is gone until it is created again), the Camel consumer
+keeps trying while the route runs, a few times a second apart and then up to every 30 seconds,
+and logs a WARN after the first attempts.
+
+A durable consumer is not created again: when it is gone (deleted by an administrator, or
+after its `inactiveThreshold`), the Camel consumer logs a WARN and keeps its subscription, and
+messages arrive again when the durable is created again with the same deliver subject.
+
+The consumer is checked only after a reconnect. A consumer that is deleted while the
+connection stays up (for example with `nats consumer rm`) is not detected, and the route
+receives nothing more until it is restarted.
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 88026b0dbb2c2..4e1903c166030 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
@@ -65,7 +65,10 @@ public class NatsConsumer extends DefaultConsumer {
private Connection connection;
private Dispatcher dispatcher;
private boolean active;
+ // set by the consuming task, and again by the push consumer keeper, and read when the consumer stops
private volatile JetStreamSubscription jetStreamSubscription;
+ // set by the consuming task and read when the consumer stops
+ private volatile NatsJetStreamPushConsumerKeeper pushConsumerKeeper;
public NatsConsumer(NatsEndpoint endpoint, Processor processor) {
super(endpoint, processor);
@@ -103,6 +106,11 @@ protected void doStart() throws Exception {
protected void doStop() throws Exception {
final NatsConfiguration configuration = this.getEndpoint().getConfiguration();
+ if (this.pushConsumerKeeper != null) {
+ this.pushConsumerKeeper.stop();
+ this.pushConsumerKeeper = null;
+ }
+
if (configuration.isFlushConnection() && ObjectHelper.isNotEmpty(this.connection)) {
LOG.debug("Flushing Messages before stopping");
this.connection.flush(Duration.ofMillis(configuration.getFlushTimeout()));
@@ -275,13 +283,28 @@ private void setupJetStreamConsumer(String topic, String queueName) throws IOExc
configuration.getTopic(), queueName, configuration.getAckPolicy(), configuration.getAckWait(),
configuration.getNackWait());
+ // the keeper subscribes again when the server deleted the ephemeral consumer (after a disconnect of
+ // more than its inactive threshold, server restart); a durable consumer is not created again
+ NatsJetStreamPushConsumerKeeper keeper = new NatsJetStreamPushConsumerKeeper(
+ NatsConsumer.this, this.connection, streamName, configuration.getTopic(), queueName,
+ dispatcher, messageHandler, cc, NatsConsumer.this.executor,
+ sub -> NatsConsumer.this.jetStreamSubscription = sub);
NatsConsumer.this.jetStreamSubscription = this.connection.jetStream().subscribe(
NatsConsumer.this.getEndpoint().getConfiguration().getTopic(),
queueName,
dispatcher,
- messageHandler,
+ keeper,
autoAck,
pushOptions);
+ NatsConsumer.this.pushConsumerKeeper = keeper;
+ keeper.start(NatsConsumer.this.jetStreamSubscription,
+ streamInfo != null ? streamInfo.getCreateTime() : null,
+ streamInfo != null && streamInfo.getStreamState() != null
+ ? streamInfo.getStreamState().getLastSequence() : -1);
+ if (!NatsConsumer.this.isRunAllowed()) {
+ // stopped while subscribing: doStop did not see the keeper
+ keeper.stop();
+ }
NatsConsumer.this.setActive(true);
}
diff --git a/components/camel-nats/src/main/java/org/apache/camel/component/nats/NatsJetStreamPushConsumerKeeper.java b/components/camel-nats/src/main/java/org/apache/camel/component/nats/NatsJetStreamPushConsumerKeeper.java
new file mode 100644
index 0000000000000..25bd9a1eb984d
--- /dev/null
+++ b/components/camel-nats/src/main/java/org/apache/camel/component/nats/NatsJetStreamPushConsumerKeeper.java
@@ -0,0 +1,393 @@
+/*
+ * 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.concurrent.ConcurrentSkipListMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.Consumer;
+
+import io.nats.client.Connection;
+import io.nats.client.ConnectionListener;
+import io.nats.client.Dispatcher;
+import io.nats.client.JetStreamApiException;
+import io.nats.client.JetStreamSubscription;
+import io.nats.client.Message;
+import io.nats.client.MessageHandler;
+import io.nats.client.PushSubscribeOptions;
+import io.nats.client.api.AckPolicy;
+import io.nats.client.api.ConsumerConfiguration;
+import io.nats.client.api.DeliverPolicy;
+import io.nats.client.api.RetentionPolicy;
+import io.nats.client.api.StreamInfo;
+import io.nats.client.impl.AckType;
+import org.apache.camel.spi.ExceptionHandler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Keeps a JetStream push subscription consuming when the server deleted its consumer.
+ *
+ * The server deletes an ephemeral push consumer (no durable name) when nobody is subscribed to its deliver subject for
+ * its inactive threshold (5 seconds by default), for example while the client was disconnected for a few seconds, and a
+ * server restart loses it. jnats subscribes the deliver subject again after the reconnect, but it does not create the
+ * consumer again, so the subscription would never receive anything again. After every reconnect this asks the server
+ * for the consumer and, when an ephemeral consumer is gone, subscribes again: the new consumer starts at the first
+ * message that was delivered to the route and not acknowledged (or after the last one delivered), so that nothing is
+ * skipped and the stream is not replayed from the deliver policy. A durable consumer is not created again: it is gone
+ * only when an administrator deleted it or after its inactive threshold, and creating it again from its configuration
+ * would replay the stream from its deliver policy. A WARN says so, and the subscription is kept.
+ *
+ * jnats answers the consumer lookup with null (not an exception) when the server has no such consumer (api error
+ * 10014). On a stream with WorkQueue retention, which accepts only the deliver policy All and has already removed the
+ * acknowledged messages, and on a stream that was created again since the subscribe (its sequences started again at 1),
+ * the new consumer uses the consumer configuration as it is.
+ *
+ * When the consumer cannot be checked or created again (JetStream not ready yet after a server restart, the stream is
+ * gone), this keeps trying while the route runs: a few times one second apart, then with a growing delay up to 30
+ * seconds, with a WARN after the first attempts.
+ *
+ * The consumer is checked only after a reconnect (when jnats has subscribed the deliver subject again). A consumer that
+ * is deleted while the connection stays up (for example by an administrator) is not detected, and the route receives
+ * nothing more: an idle heartbeat would detect it, but it is not allowed with a queue group, and its alarm goes to the
+ * error listener of the connection, which the route does not own when the connection is shared.
+ *
+ * The delivered messages that are not settled yet are found with {@link Message#lastAck()} on each delivery. jnats
+ * writes that field on the thread that acknowledges, without synchronization, so a read here can miss a recent
+ * acknowledgement: the consumer then resumes at an earlier message and the server delivers it again, nothing is
+ * skipped.
+ */
+final class NatsJetStreamPushConsumerKeeper implements MessageHandler, ConnectionListener {
+
+ private static final Logger LOG = LoggerFactory.getLogger(NatsJetStreamPushConsumerKeeper.class);
+ // the attempts one second apart, before the delay grows up to the maximum
+ private static final int CHECK_ATTEMPTS = 5;
+ private static final long CHECK_RETRY_DELAY_MILLIS = 1000;
+ private static final long MAX_CHECK_RETRY_DELAY_MILLIS = 30000;
+
+ private final NatsConsumer consumer;
+ private final Connection connection;
+ private final String streamName;
+ private final String topic;
+ private final String queueName;
+ private final Dispatcher dispatcher;
+ private final MessageHandler handler;
+ private final ConsumerConfiguration configuration;
+ private final ExecutorService executor;
+ private final Consumer onSubscribed;
+ private final Lock lock = new ReentrantLock();
+ // whether a check is submitted and has not started yet, so that reconnects do not queue several checks
+ private final AtomicBoolean checkQueued = new AtomicBoolean();
+ // the JetStream messages delivered to the route that may still need a delivery, by stream sequence
+ private final ConcurrentSkipListMap unacknowledged = new ConcurrentSkipListMap<>();
+ private final AtomicLong highestDelivered = new AtomicLong();
+ // with AckPolicy.None the delivery is the acknowledgement: no delivered message waits for an ack
+ private final boolean trackUnacknowledged;
+ // the server delivers a message at most maxDeliver times (when set): a message delivered that often is settled
+ private final long maxDeliver;
+ private volatile JetStreamSubscription subscription;
+ // when the stream was created, as seen at the subscribe: a different time later means that the stream was created
+ // again and its sequences started again at 1
+ private volatile ZonedDateTime streamCreated;
+ // the last sequence of the stream at the subscribe (-1 if not known), where a consumer that starts at the new (or
+ // last) messages started
+ private volatile long streamLastSequence = -1;
+ // why the consumer counts as gone, for the log
+ private volatile String goneReason;
+
+ NatsJetStreamPushConsumerKeeper(NatsConsumer consumer, Connection connection, String streamName, String topic,
+ String queueName, Dispatcher dispatcher, MessageHandler handler,
+ ConsumerConfiguration configuration, ExecutorService executor,
+ Consumer onSubscribed) {
+ this.consumer = consumer;
+ this.connection = connection;
+ this.streamName = streamName;
+ this.topic = topic;
+ this.queueName = queueName;
+ this.dispatcher = dispatcher;
+ this.handler = handler;
+ this.configuration = configuration;
+ this.executor = executor;
+ this.onSubscribed = onSubscribed;
+ this.trackUnacknowledged = configuration.getAckPolicy() != AckPolicy.None;
+ this.maxDeliver = configuration.getMaxDeliver();
+ }
+
+ /**
+ * Starts watching the connection for reconnects, once the first subscription is made.
+ *
+ * @param streamCreated when the stream was created, as seen before the subscribe (null if not known)
+ * @param streamLastSequence the last sequence of the stream, as seen before the subscribe (-1 if not known)
+ */
+ void start(JetStreamSubscription subscription, ZonedDateTime streamCreated, long streamLastSequence) {
+ this.subscription = subscription;
+ this.streamCreated = streamCreated;
+ this.streamLastSequence = streamLastSequence;
+ connection.addConnectionListener(this);
+ }
+
+ void stop() {
+ connection.removeConnectionListener(this);
+ }
+
+ @Override
+ public void onMessage(Message msg) throws InterruptedException {
+ if (msg.isJetStream()) {
+ long seq = msg.metaData().streamSequence();
+ if (trackUnacknowledged) {
+ unacknowledged.values().removeIf(this::settled);
+ unacknowledged.put(seq, msg);
+ }
+ highestDelivered.accumulateAndGet(seq, Math::max);
+ }
+ handler.onMessage(msg);
+ }
+
+ @Override
+ public void connectionEvent(Connection conn, Events type) {
+ // jnats has subscribed the deliver subject again: is the consumer still there?
+ if (type == Events.RESUBSCRIBED && consumer.isRunAllowed() && checkQueued.compareAndSet(false, true)) {
+ try {
+ executor.submit(this::checkConsumer);
+ } catch (Exception e) {
+ checkQueued.set(false);
+ LOG.debug("Cannot check the JetStream consumer of the push subscription on topic {}", topic, e);
+ }
+ }
+ }
+
+ void checkConsumer() {
+ try {
+ // a check runs on the executor of the consumer (a pool), and another check may hold the lock while it
+ // waits to try again: the stop of the consumer interrupts both (shutdownNow), and this one then leaves at
+ // once instead of waiting for the lock
+ lock.lockInterruptibly();
+ } catch (InterruptedException e) {
+ checkQueued.set(false);
+ Thread.currentThread().interrupt();
+ return;
+ }
+ try {
+ // a reconnect from now on queues the next check
+ checkQueued.set(false);
+ long delay = CHECK_RETRY_DELAY_MILLIS;
+ boolean reported = false;
+ for (int attempt = 1; consumer.isRunAllowed(); attempt++) {
+ Boolean gone = consumerGone();
+ if (Boolean.FALSE.equals(gone)) {
+ if (attempt > CHECK_ATTEMPTS) {
+ LOG.info("The JetStream consumer of the push subscription on topic {} is there again", topic);
+ }
+ return;
+ }
+ if (Boolean.TRUE.equals(gone)) {
+ if (consumerDeleted(!reported)) {
+ return;
+ }
+ // the failure went to the exception handler once, the next ones are logged at DEBUG
+ reported = true;
+ }
+ // could not check (not connected, JetStream not ready, timeout), or could not subscribe again: try
+ // again while the route runs, as nothing else makes this subscription receive again
+ if (attempt == CHECK_ATTEMPTS) {
+ LOG.warn("Cannot check, or create again, the JetStream consumer of the push subscription on topic {}"
+ + " after {} attempts; the route receives nothing until it succeeds, trying again every"
+ + " {} seconds at most",
+ topic, attempt, MAX_CHECK_RETRY_DELAY_MILLIS / 1000);
+ }
+ if (attempt >= CHECK_ATTEMPTS) {
+ delay = Math.min(delay * 2, MAX_CHECK_RETRY_DELAY_MILLIS);
+ }
+ Thread.sleep(delay);
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /**
+ * @return whether the server no longer has the consumer, or null if that could not be checked
+ */
+ private Boolean consumerGone() {
+ try {
+ // jnats answers null when the server has no such consumer (api error 10014), and throws for any other
+ // error, for example 404 stream not found (10059)
+ if (subscription.getConsumerInfo() == null) {
+ goneReason = "consumer not found";
+ return true;
+ }
+ return false;
+ } catch (JetStreamApiException e) {
+ if (e.getErrorCode() == 404) {
+ // jnats answers consumer not found with null, so this is another 404, such as stream not found
+ goneReason = e.getMessage();
+ return true;
+ }
+ LOG.debug("Cannot check the JetStream consumer of the push subscription on topic {}", topic, e);
+ } catch (Exception e) {
+ LOG.debug("Cannot check the JetStream consumer of the push subscription on topic {}", topic, e);
+ }
+ return null;
+ }
+
+ /**
+ * The server no longer has the consumer: subscribes again for an ephemeral consumer, and only logs a WARN for a
+ * durable one.
+ *
+ * @param report whether a failure goes to the exception handler (else it is logged at DEBUG)
+ * @return false if subscribing again failed, to try again
+ */
+ private boolean consumerDeleted(boolean report) {
+ if (configuration.getDurable() != null) {
+ LOG.warn("The durable JetStream consumer {} of the push subscription on topic {} is gone ({}); it is not"
+ + " created again, and messages arrive again when it is created again with the same deliver"
+ + " subject",
+ configuration.getDurable(), topic, goneReason);
+ return true;
+ }
+ if (!consumer.isRunAllowed()) {
+ return true;
+ }
+ try {
+ subscribeAgain();
+ return true;
+ } catch (Exception e) {
+ if (!consumer.isRunAllowed()) {
+ // the consumer stopped meanwhile (its stop can close the dispatcher): nothing to report
+ LOG.debug("Not subscribing again as the consumer of topic {} is stopping", topic, e);
+ return true;
+ }
+ if (report) {
+ // jnats reports some subscribe failures as IllegalStateException, such as SUB-90007 when the stream
+ // is gone
+ exceptionHandler().handleException(
+ "Error subscribing again after the JetStream consumer of the push subscription was deleted", e);
+ } else {
+ LOG.debug("Error subscribing again after the JetStream consumer of the push subscription on topic {}"
+ + " was deleted",
+ topic, e);
+ }
+ return false;
+ }
+ }
+
+ /**
+ * Subscribes again after the server deleted the ephemeral consumer, starting at the first message delivered to the
+ * route and not acknowledged.
+ *
+ * A message that is not acknowledged stays in the unacknowledged map until this resubscribe, and the new consumer
+ * starts at its sequence, so the route sees the messages after it again (at-least-once). A message delivered
+ * maxDeliver times (when set) is settled, as the server does not deliver it again. With ackPolicy=None no message
+ * is tracked: the new consumer starts after the highest one delivered. When nothing was delivered yet, a consumer
+ * with the deliver policy New (or Last) starts after (or at) the last message of the stream at the subscribe, so
+ * that the messages published meanwhile are not skipped.
+ *
+ * The consumer configuration is used as it is (no start sequence) for a stream with WorkQueue retention, which
+ * accepts only the deliver policy All and has already removed the acknowledged messages, and for a stream that was
+ * created again since the subscribe (its sequences started again at 1).
+ */
+ private void subscribeAgain() throws Exception {
+ StreamInfo stream = connection.jetStreamManagement().getStreamInfo(streamName);
+ ZonedDateTime created = stream != null ? stream.getCreateTime() : null;
+ if (created != null && streamCreated != null && !created.isEqual(streamCreated)) {
+ LOG.warn("The JetStream stream {} was created again since the subscribe; consuming it from its deliver"
+ + " policy (from its first message for the deliver policy New or Last)",
+ streamName);
+ unacknowledged.clear();
+ highestDelivered.set(0);
+ // every message of the new stream came after the subscribe
+ streamLastSequence = 0;
+ }
+ if (created != null) {
+ streamCreated = created;
+ }
+ boolean workQueue = stream != null && stream.getConfiguration() != null
+ && stream.getConfiguration().getRetentionPolicy() == RetentionPolicy.WorkQueue;
+ ConsumerConfiguration again = configuration;
+ unacknowledged.values().removeIf(this::settled);
+ long highest = highestDelivered.get();
+ DeliverPolicy policy = configuration.getDeliverPolicy();
+ long lastAtSubscribe = streamLastSequence;
+ // the stream sequence to start at, 0 to use the consumer configuration as it is
+ long start = 0;
+ if (workQueue) {
+ LOG.debug("The JetStream stream {} has WorkQueue retention: subscribing with the consumer configuration",
+ streamName);
+ } else if (highest > 0) {
+ start = unacknowledged.isEmpty() ? highest + 1 : unacknowledged.firstKey();
+ } else if (policy == DeliverPolicy.New && lastAtSubscribe >= 0) {
+ start = lastAtSubscribe + 1;
+ } else if (policy == DeliverPolicy.Last && lastAtSubscribe >= 0) {
+ start = Math.max(lastAtSubscribe, 1);
+ }
+ boolean resume = start > 0;
+ if (resume) {
+ // a start time (DeliverPolicy.ByStartTime) together with a start sequence is rejected by the server
+ again = ConsumerConfiguration.builder(configuration)
+ .deliverPolicy(DeliverPolicy.ByStartSequence)
+ .startSequence(start)
+ .startTime(null)
+ .build();
+ }
+ LOG.warn("The JetStream consumer of the push subscription on topic {} is gone ({}); subscribing again{}",
+ topic, goneReason, resume ? " from stream sequence " + start : "");
+ PushSubscribeOptions options = PushSubscribeOptions.builder()
+ .configuration(again)
+ .deliverGroup(queueName)
+ .build();
+ JetStreamSubscription old = subscription;
+ subscription = connection.jetStream().subscribe(topic, queueName, dispatcher, this, false, options);
+ // the new consumer delivers those again; a message it delivered meanwhile stays tracked
+ unacknowledged.values().removeIf(m -> m.getSubscription() == old);
+ onSubscribed.accept(subscription);
+ try {
+ dispatcher.unsubscribe(old);
+ } catch (Exception e) {
+ LOG.debug("Error unsubscribing the push subscription of the deleted consumer", e);
+ }
+ if (!consumer.isRunAllowed()) {
+ // the consumer was stopped while subscribing: its stop may have ended the previous subscription only
+ try {
+ dispatcher.unsubscribe(subscription);
+ } catch (Exception e) {
+ LOG.debug("Error unsubscribing the push subscription made while the consumer stopped", e);
+ }
+ }
+ }
+
+ private ExceptionHandler exceptionHandler() {
+ return consumer.getExceptionHandler();
+ }
+
+ /**
+ * Whether the server does not deliver the message again: it is acknowledged or terminated, or it was delivered
+ * maxDeliver times.
+ */
+ private boolean settled(Message msg) {
+ AckType ack = msg.lastAck();
+ if (ack == AckType.AckAck || ack == AckType.AckTerm) {
+ return true;
+ }
+ return maxDeliver > 0 && msg.metaData().deliveredCount() >= maxDeliver;
+ }
+}
diff --git a/components/camel-nats/src/test/java/org/apache/camel/component/nats/NatsConsumerPushConsumerDeletedTest.java b/components/camel-nats/src/test/java/org/apache/camel/component/nats/NatsConsumerPushConsumerDeletedTest.java
new file mode 100644
index 0000000000000..f663f04fe437b
--- /dev/null
+++ b/components/camel-nats/src/test/java/org/apache/camel/component/nats/NatsConsumerPushConsumerDeletedTest.java
@@ -0,0 +1,866 @@
+/*
+ * 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.lang.reflect.Proxy;
+import java.nio.charset.StandardCharsets;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import io.nats.client.Connection;
+import io.nats.client.ConnectionListener;
+import io.nats.client.Dispatcher;
+import io.nats.client.JetStream;
+import io.nats.client.JetStreamApiException;
+import io.nats.client.JetStreamManagement;
+import io.nats.client.JetStreamSubscription;
+import io.nats.client.Message;
+import io.nats.client.MessageHandler;
+import io.nats.client.PushSubscribeOptions;
+import io.nats.client.api.AckPolicy;
+import io.nats.client.api.ApiResponse;
+import io.nats.client.api.ConsumerConfiguration;
+import io.nats.client.api.ConsumerInfo;
+import io.nats.client.api.DeliverPolicy;
+import io.nats.client.api.Error;
+import io.nats.client.api.RetentionPolicy;
+import io.nats.client.api.ServerInfo;
+import io.nats.client.api.StreamInfo;
+import io.nats.client.impl.AckType;
+import io.nats.client.impl.NatsJetStreamMetaData;
+import io.nats.client.impl.NatsMessage;
+import io.nats.client.support.DateTimeUtils;
+import io.nats.client.support.JsonParser;
+import io.nats.client.support.Status;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.spi.ExceptionHandler;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * With pullSubscription=false the JetStream consumer uses a push subscription, with an ephemeral consumer when no
+ * durableName is set. The NATS server deletes an ephemeral push consumer when nobody is subscribed to its deliver
+ * subject for its inactive threshold (5 seconds by default), for example while the client was disconnected for a few
+ * seconds, and a server restart loses it. jnats subscribes the deliver subject again after the reconnect but does not
+ * create the consumer again. The consumer must then subscribe again, starting at the first message it did not
+ * acknowledge, instead of waiting for ever on a deliver subject nobody delivers to. A durable consumer that is gone is
+ * not created again (that would replay the stream from its deliver policy).
+ */
+class NatsConsumerPushConsumerDeletedTest extends CamelTestSupport {
+
+ private final FakeServer server = new FakeServer();
+ private final AtomicInteger attempts = new AtomicInteger();
+ private final List handled = new CopyOnWriteArrayList<>();
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ context.getRegistry().bind("conn", server.connection());
+ context.getRegistry().bind("handler", new ExceptionHandler() {
+ @Override
+ public void handleException(Throwable exception) {
+ handled.add(exception);
+ }
+
+ @Override
+ public void handleException(String message, Throwable exception) {
+ handled.add(exception);
+ }
+
+ @Override
+ public void handleException(String message, Exchange exchange, Throwable exception) {
+ handled.add(exception);
+ }
+ });
+ // an ephemeral consumer that starts at a time: the server rejects a start time next to a start sequence
+ context.getRegistry().bind("byStartTime", ConsumerConfiguration.builder()
+ .deliverPolicy(DeliverPolicy.ByStartTime).startTime(ZonedDateTime.now().minusDays(1)).build());
+ // an ephemeral consumer of the messages published after the subscribe
+ context.getRegistry().bind("deliverNew", ConsumerConfiguration.builder().deliverPolicy(DeliverPolicy.New).build());
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("nats:orders?connection=#conn&jetstreamEnabled=true&jetstreamName=ORDERS&pullSubscription=false")
+ .convertBodyTo(String.class)
+ .process(e -> {
+ if ("fail-once".equals(e.getMessage().getBody()) && attempts.incrementAndGet() == 1) {
+ // the client is disconnected while the message is processed, for longer than the
+ // inactive threshold: the server deletes the consumer, the nak is lost
+ server.disconnect();
+ throw new IllegalStateException("processing failed");
+ }
+ })
+ .to("mock:result");
+ }
+ };
+ }
+
+ @Test
+ void consumesAgainAfterTheServerDeletedTheEphemeralConsumer() throws Exception {
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedBodiesReceived("order-1");
+ server.publish("order-1");
+ mock.assertIsSatisfied();
+
+ // disconnected for longer than the inactive threshold: the server deletes the consumer
+ server.disconnect();
+ server.publish("order-2");
+ server.publish("order-3");
+ server.reconnect();
+
+ mock.reset();
+ mock.expectedBodiesReceived("order-2", "order-3");
+ mock.setResultWaitTime(20_000);
+ mock.assertIsSatisfied();
+ assertEquals(2, server.consumersCreated, "one consumer created again");
+ }
+
+ @Test
+ void redeliversTheMessageWhoseNakWasLostWithTheConsumer() throws Exception {
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedBodiesReceived("order-1");
+ server.publish("order-1");
+ mock.assertIsSatisfied();
+
+ server.publish("fail-once");
+ server.awaitDisconnected();
+ server.publish("order-3");
+ server.reconnect();
+
+ mock.reset();
+ mock.expectedBodiesReceived("fail-once", "order-3");
+ mock.setResultWaitTime(20_000);
+ mock.assertIsSatisfied();
+ }
+
+ @Test
+ void endsTheSubscriptionItMadeWhileTheRouteStopped() throws Exception {
+ consumeWith("exceptionHandler=#handler");
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedBodiesReceived("order-1");
+ server.publish("order-1");
+ mock.assertIsSatisfied();
+
+ // the route is stopped while the consumer subscribes again: the stop ends the subscription of the deleted
+ // consumer only, so the consumer must end the one it makes
+ String routeId = context.getRoutes().get(0).getId();
+ server.beforeSubscribe = () -> {
+ server.beforeSubscribe = null;
+ Thread stopper = new Thread(() -> {
+ try {
+ context.getRouteController().stopRoute(routeId);
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ }, "route-stopper");
+ stopper.start();
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20);
+ while (stopper.isAlive() && System.nanoTime() < deadline) {
+ try {
+ stopper.join(1000);
+ } catch (InterruptedException e) {
+ // the stop interrupts the executor of the consumer, which runs this
+ }
+ }
+ };
+ int before;
+ synchronized (server) {
+ before = server.consumers.size();
+ }
+ server.disconnect();
+ server.reconnect();
+
+ // the new subscription is ended, or (when the stop closes the dispatcher) jnats refuses to subscribe on the
+ // closed dispatcher; either way no subscription is left and nothing is reported during the normal stop
+ await().atMost(20, TimeUnit.SECONDS).untilAsserted(() -> {
+ synchronized (server) {
+ FakeServer.FakeConsumer last = server.consumers.get(server.consumers.size() - 1);
+ assertTrue(server.consumers.size() == before + 1 && last.unsubscribed || server.refusedSubscribes > 0,
+ "the subscription made while the route stopped is ended");
+ }
+ });
+ assertEquals(List.of(), handled, "a subscribe that fails as the route stops is not reported");
+ }
+
+ @Test
+ void removesItsConnectionListenerWhenTheRouteStopsWhileItSubscribes() throws Exception {
+ int added = server.listenersAdded.get();
+ // the route is stopped while the consumer makes its first subscription, so the stop does not see the keeper
+ // yet: the keeper must not stay registered on the (shared) connection
+ server.beforeSubscribe = () -> {
+ server.beforeSubscribe = null;
+ String routeId = context.getRoutes().get(0).getId();
+ Thread stopper = new Thread(() -> {
+ try {
+ context.getRouteController().stopRoute(routeId);
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ }, "route-stopper");
+ stopper.start();
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20);
+ while (stopper.isAlive() && System.nanoTime() < deadline) {
+ try {
+ stopper.join(1000);
+ } catch (InterruptedException e) {
+ // the stop interrupts the executor of the consumer, which runs this
+ }
+ }
+ };
+ consumeWith("exceptionHandler=#handler");
+
+ // the keeper of the new route registered its listener after the stop, or (when the stop closes the dispatcher,
+ // CAMEL-25847) jnats refused the subscribe on the closed dispatcher and no keeper was started
+ await().atMost(20, TimeUnit.SECONDS).until(() -> {
+ synchronized (server) {
+ return server.listenersAdded.get() == added + 1 || server.refusedSubscribes > 0;
+ }
+ });
+ await().atMost(20, TimeUnit.SECONDS).untilAsserted(
+ () -> assertEquals(List.of(), server.listeners, "no keeper listens to the connection after the stop"));
+ }
+
+ /**
+ * Replaces the route of the test by one consuming with the given options.
+ */
+ private void consumeWith(String options) throws Exception {
+ String routeId = context.getRoutes().get(0).getId();
+ context.getRouteController().stopRoute(routeId);
+ context.removeRoute(routeId);
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("nats:orders?connection=#conn&jetstreamEnabled=true&jetstreamName=ORDERS&pullSubscription=false&"
+ + options)
+ .convertBodyTo(String.class)
+ .process(e -> {
+ if ("poison".equals(e.getMessage().getBody())) {
+ throw new IllegalStateException("cannot process the poison message");
+ }
+ })
+ .to("mock:result");
+ }
+ });
+ }
+
+ @Test
+ void doesNotCreateAGoneDurableConsumerAgain() throws Exception {
+ consumeWith("durableName=orders");
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedBodiesReceived("order-1");
+ server.publish("order-1");
+ mock.assertIsSatisfied();
+ int created;
+ synchronized (server) {
+ created = server.consumersCreated;
+ assertEquals("orders-durable", server.configurations.get(server.configurations.size() - 1).getDurable());
+ }
+
+ // the durable consumer is deleted (as by an administrator) while the client is disconnected
+ server.disconnect();
+ server.reconnect();
+
+ // creating it again would replay the stream from its deliver policy (DeliverPolicy.All: order-1 again)
+ await().during(3, TimeUnit.SECONDS).atMost(10, TimeUnit.SECONDS).untilAsserted(() -> {
+ synchronized (server) {
+ assertEquals(created, server.consumersCreated, "the durable consumer must not be created again");
+ }
+ });
+ assertEquals(1, mock.getReceivedCounter());
+ }
+
+ @Test
+ void reportsAFailedSubscribeAndTriesAgain() throws Exception {
+ consumeWith("exceptionHandler=#handler");
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedBodiesReceived("order-1");
+ server.publish("order-1");
+ mock.assertIsSatisfied();
+
+ // jnats reports some subscribe failures as IllegalStateException (SUB-90007 when the stream is gone)
+ server.beforeSubscribe = () -> {
+ server.beforeSubscribe = null;
+ throw new IllegalStateException("[SUB-90007] No matching streams for subject.");
+ };
+ server.disconnect();
+ server.publish("order-2");
+ server.reconnect();
+
+ mock.reset();
+ mock.expectedBodiesReceived("order-2");
+ mock.setResultWaitTime(20_000);
+ mock.assertIsSatisfied();
+ assertEquals(1, handled.size(), "the failed subscribe goes to the exception handler");
+ assertInstanceOf(IllegalStateException.class, handled.get(0));
+ }
+
+ @Test
+ void resumesAnEphemeralConsumerThatStartedAtATime() throws Exception {
+ consumeWith("consumerConfiguration=#byStartTime");
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedBodiesReceived("order-1");
+ server.publish("order-1");
+ mock.assertIsSatisfied();
+
+ server.disconnect();
+ server.publish("order-2");
+ server.reconnect();
+
+ mock.reset();
+ mock.expectedBodiesReceived("order-2");
+ mock.setResultWaitTime(20_000);
+ mock.assertIsSatisfied();
+ }
+
+ @Test
+ void workQueueStreamIsSubscribedAgainWithDeliverPolicyAll() throws Exception {
+ server.retention(RetentionPolicy.WorkQueue);
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedBodiesReceived("order-1");
+ server.publish("order-1");
+ mock.assertIsSatisfied();
+ // the route acknowledges order-1 once the exchange is complete: the stream removes it
+ await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> {
+ synchronized (server) {
+ assertTrue(server.removed.contains(1L), "order-1 is acknowledged and removed");
+ }
+ });
+
+ server.disconnect();
+ server.publish("order-2");
+ server.reconnect();
+
+ // a work queue stream refuses a consumer whose deliver policy is not All (10101); order-1 was acknowledged
+ // and is removed, so the consumer configuration replays nothing
+ mock.reset();
+ mock.expectedBodiesReceived("order-2");
+ mock.setResultWaitTime(20_000);
+ mock.assertIsSatisfied();
+ synchronized (server) {
+ assertEquals(2, server.consumersCreated, "one consumer created again");
+ assertEquals(DeliverPolicy.All, server.configurations.get(1).getDeliverPolicy(),
+ "a work queue stream accepts only the deliver policy All");
+ }
+ }
+
+ @Test
+ void consumesAStreamCreatedAgainFromItsStart() throws Exception {
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedBodiesReceived("order-1", "order-2");
+ server.publish("order-1");
+ server.publish("order-2");
+ mock.assertIsSatisfied();
+
+ // a memory stream after a server restart: the consumer is gone, and order-3 is sequence 1 of the new stream
+ server.disconnect();
+ server.createStreamAgain();
+ server.publish("order-3");
+ server.reconnect();
+
+ mock.reset();
+ mock.expectedBodiesReceived("order-3");
+ mock.setResultWaitTime(20_000);
+ mock.assertIsSatisfied();
+ synchronized (server) {
+ assertEquals(DeliverPolicy.All, server.configurations.get(1).getDeliverPolicy(),
+ "the consumer configuration, not a start sequence of the old stream");
+ }
+ }
+
+ @Test
+ void ackPolicyNoneResumesAfterTheHighestDelivered() throws Exception {
+ // the route never calls ack: with ackPolicy=None the delivery is the acknowledgement
+ consumeWith("manualAck=true&ackPolicy=None");
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedBodiesReceived("order-1", "order-2");
+ server.publish("order-1");
+ server.publish("order-2");
+ mock.assertIsSatisfied();
+
+ server.disconnect();
+ server.publish("order-3");
+ server.reconnect();
+
+ mock.reset();
+ mock.expectedBodiesReceived("order-3");
+ mock.setResultWaitTime(20_000);
+ mock.assertIsSatisfied();
+ synchronized (server) {
+ ConsumerConfiguration again = server.configurations.get(server.configurations.size() - 1);
+ assertEquals(AckPolicy.None, again.getAckPolicy());
+ assertEquals(DeliverPolicy.ByStartSequence, again.getDeliverPolicy());
+ assertEquals(3, again.getStartSequence(), "starts after the highest delivered sequence");
+ }
+ }
+
+ @Test
+ void aMessageDeliveredMaxDeliverTimesDoesNotHoldBackTheResume() throws Exception {
+ consumeWith("maxDeliver=3");
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedBodiesReceived("order-2", "order-3");
+ // the route fails the poison message: it is nak'ed until the server stops delivering it (maxDeliver)
+ server.publish("poison");
+ server.publish("order-2");
+ server.publish("order-3");
+ mock.assertIsSatisfied();
+ await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> {
+ synchronized (server) {
+ assertEquals(3, server.consumers.get(server.consumers.size() - 1).deliveries.get(1L),
+ "the poison message is delivered maxDeliver times");
+ }
+ });
+
+ server.disconnect();
+ server.publish("order-4");
+ server.reconnect();
+
+ // the server never delivers the poison message again, so the new consumer starts after the highest delivered
+ mock.reset();
+ mock.expectedBodiesReceived("order-4");
+ mock.setResultWaitTime(20_000);
+ mock.assertIsSatisfied();
+ synchronized (server) {
+ ConsumerConfiguration again = server.configurations.get(server.configurations.size() - 1);
+ assertEquals(4, again.getStartSequence(), "starts after the highest delivered sequence");
+ }
+ }
+
+ @Test
+ void keepsCheckingUntilTheStreamIsCreatedAgain() throws Exception {
+ consumeWith("exceptionHandler=#handler");
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedBodiesReceived("order-1");
+ server.publish("order-1");
+ mock.assertIsSatisfied();
+
+ // a memory stream lost on a server restart, and created again only later
+ server.disconnect();
+ server.deleteStream();
+ server.reconnect();
+ // more checks than the first attempts one second apart
+ await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> {
+ synchronized (server) {
+ assertTrue(server.streamNotFound >= 6, "the consumer keeps checking, " + server.streamNotFound);
+ }
+ });
+ server.createStreamAgain();
+ server.publish("order-2");
+
+ mock.reset();
+ mock.expectedBodiesReceived("order-2");
+ mock.setResultWaitTime(40_000);
+ mock.assertIsSatisfied();
+ assertEquals(1, handled.size(), "the first failure goes to the exception handler, the next ones are logged");
+ }
+
+ @Test
+ void deliverPolicyNewWithNothingDeliveredResumesAfterTheLastMessageAtTheSubscribe() throws Exception {
+ consumeWith("consumerConfiguration=#deliverNew");
+ // the route subscribes on the executor of the consumer, and then listens to the connection
+ await().atMost(10, TimeUnit.SECONDS).until(() -> !server.listeners.isEmpty());
+
+ // nothing was delivered when the consumer is deleted: the messages published meanwhile must not be skipped
+ server.disconnect();
+ server.publish("order-1");
+ server.reconnect();
+
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedBodiesReceived("order-1");
+ mock.setResultWaitTime(20_000);
+ mock.assertIsSatisfied();
+ synchronized (server) {
+ ConsumerConfiguration again = server.configurations.get(server.configurations.size() - 1);
+ assertEquals(DeliverPolicy.ByStartSequence, again.getDeliverPolicy());
+ assertEquals(1, again.getStartSequence(), "starts after the last message of the stream at the subscribe");
+ }
+ }
+
+ /**
+ * A JetStream server with one stream, faked at the level of the jnats interfaces the consumer calls. A push
+ * consumer delivers from its start sequence on its own thread (like the dispatcher of jnats), one message at a
+ * time; a nak'ed message is delivered again. While the client is disconnected nothing is delivered and the server
+ * deletes the ephemeral consumers (their inactive threshold passes); acknowledgements to a deleted consumer are
+ * lost. A reconnect fires RESUBSCRIBED like jnats once it subscribed the deliver subjects again. getConsumerInfo
+ * answers null for a deleted consumer, as jnats does when the server answers consumer not found (10014), and the
+ * info of a live one. A stream with WorkQueue retention removes the acknowledged messages and refuses a consumer
+ * whose deliver policy is not All (400, 10101), and a stream created again starts its sequences at 1 again with a
+ * new create time. A message nak'ed is not delivered again once it was delivered maxDeliver times (the delivered
+ * count is in the reply subject, as on the server). While the stream does not exist, getStreamInfo and
+ * getConsumerInfo throw 404 stream not found (10059) and subscribe throws IllegalStateException (SUB-90007), as
+ * jnats does. A closed dispatcher (closeDispatcher) refuses a subscribe with IllegalStateException, as jnats does.
+ */
+ static final class FakeServer {
+ final List stream = new ArrayList<>();
+ final Set removed = new HashSet<>();
+ RetentionPolicy retention = RetentionPolicy.Limits;
+ ZonedDateTime created = DateTimeUtils.gmtNow().minusHours(1);
+ final List consumers = new ArrayList<>();
+ final List configurations = new ArrayList<>();
+ final List listeners = new CopyOnWriteArrayList<>();
+ // the connection listeners added so far
+ final AtomicInteger listenersAdded = new AtomicInteger();
+ boolean connected = true;
+ boolean streamExists = true;
+ // the consumer lookups answered with stream not found
+ int streamNotFound;
+ // the subscribes refused as their dispatcher was closed
+ int refusedSubscribes;
+ final Set closedDispatchers = new HashSet<>();
+ int consumersCreated;
+ // runs on the thread that subscribes, before the subscription is made
+ volatile Runnable beforeSubscribe;
+ Connection connection;
+
+ final class FakeConsumer {
+ long maxDeliver;
+ final Map deliveries = new HashMap<>();
+ long next;
+ boolean deleted;
+ boolean unsubscribed;
+ final List redeliver = new ArrayList<>();
+ }
+
+ synchronized void retention(RetentionPolicy retention) {
+ this.retention = retention;
+ }
+
+ /**
+ * The stream is created again, for example a memory stream after a server restart: its sequences start again at
+ * 1.
+ */
+ synchronized void createStreamAgain() {
+ streamExists = true;
+ stream.clear();
+ removed.clear();
+ created = created.plusMinutes(1);
+ for (FakeConsumer c : consumers) {
+ c.deleted = true;
+ }
+ notifyAll();
+ }
+
+ /**
+ * The stream is deleted, for example a memory stream lost on a server restart.
+ */
+ synchronized void deleteStream() {
+ streamExists = false;
+ stream.clear();
+ removed.clear();
+ for (FakeConsumer c : consumers) {
+ c.deleted = true;
+ }
+ notifyAll();
+ }
+
+ synchronized void publish(String body) {
+ stream.add(body.getBytes(StandardCharsets.UTF_8));
+ notifyAll();
+ }
+
+ synchronized void disconnect() {
+ connected = false;
+ for (FakeConsumer c : consumers) {
+ c.deleted = true;
+ }
+ notifyAll();
+ }
+
+ synchronized void awaitDisconnected() throws InterruptedException {
+ long deadline = System.currentTimeMillis() + 20_000;
+ while (connected && System.currentTimeMillis() < deadline) {
+ wait(100);
+ }
+ }
+
+ void reconnect() {
+ synchronized (this) {
+ connected = true;
+ notifyAll();
+ }
+ for (ConnectionListener l : listeners) {
+ l.connectionEvent(connection, ConnectionListener.Events.RECONNECTED);
+ l.connectionEvent(connection, ConnectionListener.Events.RESUBSCRIBED);
+ }
+ }
+
+ Connection connection() {
+ JetStreamManagement jsm = proxy(JetStreamManagement.class, (name, args) -> switch (name) {
+ case "getStreamInfo", "addStream" -> streamInfo();
+ default -> null;
+ });
+ JetStream js = proxy(JetStream.class, (name, args) -> {
+ if ("subscribe".equals(name) && args.length == 6 && args[5] instanceof PushSubscribeOptions o) {
+ Runnable hook = beforeSubscribe;
+ if (hook != null) {
+ hook.run();
+ }
+ synchronized (this) {
+ if (closedDispatchers.contains((Dispatcher) args[2])) {
+ // as jnats NatsDispatcher.subscribeImplJetStream (checkBeforeSubImpl)
+ refusedSubscribes++;
+ throw new IllegalStateException("Dispatcher is closed");
+ }
+ }
+ return subscribe(o.getConsumerConfiguration(), (MessageHandler) args[3]);
+ }
+ throw new UnsupportedOperationException(name);
+ });
+ ServerInfo info = new ServerInfo("INFO {\"server_id\":\"fake\",\"jetstream\":true}");
+ connection = proxy(Connection.class, (name, args) -> switch (name) {
+ case "getServerInfo" -> info;
+ case "jetStreamManagement" -> jsm;
+ case "jetStream" -> js;
+ case "createDispatcher" -> dispatcher();
+ case "addConnectionListener" -> {
+ listenersAdded.incrementAndGet();
+ yield listeners.add((ConnectionListener) args[0]);
+ }
+ case "closeDispatcher" -> {
+ synchronized (this) {
+ closedDispatchers.add((Dispatcher) args[0]);
+ }
+ yield null;
+ }
+ case "removeConnectionListener" -> listeners.remove((ConnectionListener) args[0]);
+ case "getStatus" -> Connection.Status.CONNECTED;
+ default -> null;
+ });
+ return connection;
+ }
+
+ /**
+ * A new dispatcher, as jnats creates one per createDispatcher call.
+ */
+ private static Dispatcher dispatcher() {
+ return proxy(Dispatcher.class, (name, args) -> {
+ if ("unsubscribe".equals(name) && args.length == 1 && args[0] instanceof JetStreamSubscription s) {
+ s.unsubscribe();
+ }
+ return null;
+ });
+ }
+
+ private synchronized void requireStream() throws JetStreamApiException {
+ if (!streamExists) {
+ throw new JetStreamApiException(
+ new ApiResponse(
+ JsonParser.parseUnchecked(
+ "{\"error\":{\"code\":404,\"err_code\":10059,\"description\":\"stream not found\"}}")) {
+ });
+ }
+ }
+
+ private synchronized StreamInfo streamInfo() throws JetStreamApiException {
+ requireStream();
+ return new StreamInfo(
+ JsonParser.parseUnchecked("{\"config\":{\"name\":\"ORDERS\",\"subjects\":[\"orders\"],"
+ + "\"retention\":\"" + retention + "\"},\"created\":\""
+ + DateTimeUtils.toRfc3339(created) + "\",\"state\":{\"last_seq\":" + stream.size()
+ + "}}"));
+ }
+
+ private synchronized JetStreamSubscription subscribe(ConsumerConfiguration cc, MessageHandler handler)
+ throws JetStreamApiException {
+ if (!streamExists) {
+ throw new IllegalStateException("[SUB-90007] No matching streams for subject.");
+ }
+ if (retention == RetentionPolicy.WorkQueue && cc.getDeliverPolicy() != DeliverPolicy.All) {
+ // the server's answer as jnats reads it (ApiResponse is abstract)
+ throw new JetStreamApiException(
+ new ApiResponse(
+ JsonParser.parseUnchecked(
+ "{\"error\":{\"code\":400,\"err_code\":10101,"
+ + "\"description\":\"consumer must be deliver all on workqueue stream\"}}")) {
+ });
+ }
+ if (cc != null && cc.getDeliverPolicy() == DeliverPolicy.ByStartSequence && cc.getStartTime() != null) {
+ // as nats-server: a start time is not allowed with DeliverPolicy.ByStartSequence
+ throw new JetStreamApiException(
+ Error.convert(new Status(
+ 400, "consumer delivery policy is deliver by"
+ + " start sequence, but optional start time is also set")));
+ }
+ configurations.add(cc);
+ FakeConsumer c = new FakeConsumer();
+ DeliverPolicy policy = cc != null ? cc.getDeliverPolicy() : DeliverPolicy.All;
+ if (policy == DeliverPolicy.ByStartSequence) {
+ c.next = cc.getStartSequence();
+ } else if (policy == DeliverPolicy.New) {
+ c.next = stream.size() + 1;
+ } else if (policy == DeliverPolicy.Last) {
+ c.next = Math.max(stream.size(), 1);
+ } else {
+ c.next = 1;
+ }
+ c.maxDeliver = cc != null ? cc.getMaxDeliver() : -1;
+ consumers.add(c);
+ consumersCreated++;
+ JetStreamSubscription sub = proxy(JetStreamSubscription.class, (name, args) -> switch (name) {
+ case "getConsumerInfo" -> consumerInfo(c);
+ case "unsubscribe" -> {
+ synchronized (this) {
+ c.unsubscribed = true;
+ notifyAll();
+ }
+ yield null;
+ }
+ case "isActive" -> true;
+ default -> null;
+ });
+ Thread t = new Thread(() -> deliver(c, sub, handler), "fake-push-" + consumersCreated);
+ t.setDaemon(true);
+ t.start();
+ return sub;
+ }
+
+ private synchronized ConsumerInfo consumerInfo(FakeConsumer c) throws JetStreamApiException {
+ // jnats lookupConsumerInfo maps only consumer not found to null: stream not found is thrown
+ if (!streamExists) {
+ // counts the checks of a consumer while the stream is gone
+ streamNotFound++;
+ }
+ requireStream();
+ if (c.deleted) {
+ // jnats lookupConsumerInfo maps consumer not found (10014) to null
+ return null;
+ }
+ return new ConsumerInfo(JsonParser.parseUnchecked("{\"stream_name\":\"ORDERS\",\"name\":\"c\",\"config\":{}}"));
+ }
+
+ private void deliver(FakeConsumer c, JetStreamSubscription sub, MessageHandler handler) {
+ while (true) {
+ long seq;
+ int delivered;
+ synchronized (this) {
+ while (!c.deleted && !c.unsubscribed
+ && (!connected || (c.redeliver.isEmpty() && c.next > stream.size()))) {
+ try {
+ wait();
+ } catch (InterruptedException e) {
+ return;
+ }
+ }
+ if (c.deleted || c.unsubscribed) {
+ return;
+ }
+ seq = c.redeliver.isEmpty() ? c.next++ : c.redeliver.remove(0);
+ if (removed.contains(seq)) {
+ continue;
+ }
+ delivered = c.deliveries.merge(seq, 1, Integer::sum);
+ }
+ try {
+ handler.onMessage(message(c, sub, seq, delivered));
+ } catch (InterruptedException e) {
+ return;
+ }
+ }
+ }
+
+ private Message message(FakeConsumer c, JetStreamSubscription sub, long seq, int delivered) {
+ byte[] data;
+ synchronized (this) {
+ data = stream.get((int) seq - 1);
+ }
+ // a JetStream message carries its metadata in the reply subject of the acknowledgement
+ NatsJetStreamMetaData meta = new NatsJetStreamMetaData(
+ new NatsMessage("orders", "$JS.ACK.ORDERS.c." + delivered + "." + seq + "." + seq + ".0.0", data) {
+ @Override
+ public boolean isJetStream() {
+ return true;
+ }
+ });
+ AckType[] lastAck = new AckType[1];
+ return proxy(Message.class, (name, args) -> switch (name) {
+ case "getData" -> data;
+ case "getSubject" -> "orders";
+ case "getSID" -> "1";
+ case "getSubscription" -> sub;
+ case "isJetStream" -> true;
+ case "isStatusMessage" -> false;
+ case "metaData" -> meta;
+ case "lastAck" -> lastAck[0];
+ case "ack" -> {
+ lastAck[0] = AckType.AckAck;
+ removeFromWorkQueue(c, seq);
+ yield null;
+ }
+ case "term" -> {
+ lastAck[0] = AckType.AckTerm;
+ removeFromWorkQueue(c, seq);
+ yield null;
+ }
+ case "nak", "nakWithDelay" -> {
+ lastAck[0] = AckType.AckNak;
+ synchronized (this) {
+ // the server stops delivering a message delivered maxDeliver times
+ if (!c.deleted && (c.maxDeliver <= 0 || delivered < c.maxDeliver)) {
+ c.redeliver.add(seq);
+ notifyAll();
+ }
+ }
+ yield null;
+ }
+ default -> null;
+ });
+ }
+
+ private synchronized void removeFromWorkQueue(FakeConsumer c, long seq) {
+ if (retention == RetentionPolicy.WorkQueue && !c.deleted) {
+ removed.add(seq);
+ }
+ }
+
+ @FunctionalInterface
+ interface Handler {
+ Object handle(String method, Object[] args) throws Exception;
+ }
+
+ @SuppressWarnings("unchecked")
+ static T proxy(Class type, Handler handler) {
+ return (T) Proxy.newProxyInstance(FakeServer.class.getClassLoader(), new Class>[] { type },
+ (p, m, a) -> {
+ if (m.getDeclaringClass() == Object.class) {
+ return switch (m.getName()) {
+ case "hashCode" -> System.identityHashCode(p);
+ case "equals" -> p == a[0];
+ default -> type.getSimpleName() + "@fake";
+ };
+ }
+ Object r = handler.handle(m.getName(), a == null ? new Object[0] : a);
+ if (r == null && m.getReturnType() == boolean.class) {
+ return false;
+ }
+ return r;
+ });
+ }
+ }
+}
diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index c513494b38f69..7f47d241db2a0 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -2169,6 +2169,14 @@ wraps an `ExecutionException`, with the `RuntimeCamelException` (status and body
order of the messages is the same. The steps after the producer now run on the calling thread, and no longer on a
thread of the HTTP client.
+=== camel-nats - the JetStream push consumer subscribes again when its ephemeral consumer was deleted
+
+With `jetstreamEnabled=true` and `pullSubscription=false`, the consumer now creates its ephemeral JetStream consumer
+again after a reconnect when the server deleted it, starting at the first message the route did not acknowledge, so
+the route can see again the messages after that one. A durable consumer is not created again (a WARN is logged), and a
+consumer deleted while the connection stays up is not detected. See the
+xref:components::nats-component.adoc[NATS component] page for the details.
+
=== camel-vertx-websocket - the client consumer makes one reconnect attempt at a time
When the WebSocket of a consumer with `consumeAsClient=true` is closed, the consumer now makes one reconnect attempt