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 9aee81df989a CAMEL-25815: camel-nats - the JetStream pull consumer 
subscribes again when the server deleted its consumer (#28204)
9aee81df989a is described below

commit 9aee81df989a1d96790204b21baef703662cd192
Author: allthingssecurity <[email protected]>
AuthorDate: Fri Oct 23 22:03:07 2026 +0530

    CAMEL-25815: camel-nats - the JetStream pull consumer subscribes again when 
the server deleted its consumer (#28204)
    
    * CAMEL-25815: camel-nats - the JetStream pull consumer subscribes again 
when the server deleted its consumer
    
    With jetstreamEnabled=true and the default pull subscription, and without a
    durableName, the consumer subscribes with an ephemeral JetStream consumer.
    The NATS server deletes an ephemeral consumer that had no waiting pull
    request for its inactive threshold (5 seconds by default) once no ack is
    pending, for example while the client was disconnected for a few seconds,
    and a server restart loses it. jnats does not give a pull subscription a new
    consumer: every fetch then ends with an empty list (the pull request has no
    responder, status 503), or once with 409 Consumer Deleted, which the pull 
loop
    logged and continued. The route stayed started and consumed nothing again
    until it was restarted.
    
    The pull loop now asks the server for its consumer when a fetch returned
    nothing (at most every 5 seconds) and handles 409 Consumer Deleted. When the
    consumer is gone it subscribes again: an ephemeral consumer is created again
    starting at the first stream sequence the route did not acknowledge (or 
after
    the last one fetched), so that nothing is skipped and the stream is not
    replayed from the deliver policy; a durable consumer is created again from 
its
    configuration.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * CAMEL-25815: camel-nats - address review: subscribe again only for an 
ephemeral consumer
    
    A durable consumer is gone only when an administrator deleted it or after 
its
    inactiveThreshold; creating it again from its configuration replayed the
    stream from its deliver policy (the whole stream with the default
    DeliverPolicy.All). The pull loop now creates only an ephemeral consumer
    again; for a durable it logs a WARN once and keeps the subscription, so the
    fetches work again when the durable is created again with the same name.
    
    The empty-fetch path and the 409 path share one helper, which reports a
    failed subscribe (jnats throws IllegalStateException for some, such as
    SUB-90007 when the stream is gone) through the exception handler and tries
    again at the next consumer check, instead of ending the fetch loop. The new
    consumer is sent without a start time (ByStartTime together with
    ByStartSequence), a subscription made while the route stops is unsubscribed,
    the unused DefaultNatsManualAck constructor is removed, and the upgrade 
guide
    entry covers the ephemeral consumer only.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * CAMEL-25815: camel-nats - address review: wait for the rest of the fetch 
timeout after an early empty fetch
    
    While the consumer of a pull subscription is gone, jnats ends each fetch
    at once (status 503, no responders), so the fetch loop sent pull
    requests back to back: up to the next consumer check for an ephemeral
    consumer, and for good for a durable that is not created again. After an
    empty fetch that ended before its timeout, the loop now waits for the
    rest of pullFetchTimeout; a stop interrupts the wait.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * CAMEL-25815: camel-nats - address review: treat a null consumer info as 
gone, resume only where the stream allows it
    
    jnats 2.26.4 answers getConsumerInfo() with null when the server has no
    such consumer (lookupConsumerInfo maps api error 10014 to null), so the
    consumer check never found a deleted consumer: it now treats null as
    gone, and the fake server answers like jnats (null for a deleted
    consumer, the info for a live one).
    
    subscribeAgain now looks the stream up first. A stream with WorkQueue
    retention accepts only the deliver policy All and has already removed
    the acknowledged messages, so the consumer configuration is used as it
    is. A stream whose create time differs from the one seen at the
    subscribe was created again (its sequences start at 1 again): the
    tracked sequences are cleared and the consumer configuration is used.
    
    The consumer check runs only after an empty fetch that ended before half
    of pullFetchTimeout (a gone consumer ends the fetch at once with 503),
    so an idle healthy consumer no longer asks for CONSUMER.INFO every 5
    seconds. Other error statuses than 409 Consumer Deleted wait for the
    rest of the fetch timeout instead of fetching again back to back. With
    AckPolicy.None no sequence waits for an ack: a resubscribe starts after
    the highest fetched one. The tests wait with setResultWaitTime, and a
    JetStream IT deletes the consumer while the route is busy.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * CAMEL-25815: camel-nats - address review: wait for the rest of the fetch 
timeout after an unexpected fetch failure too
    
    From gnodet-bot's review of #28204: the generic catch of runPullFetchLoop 
sent the next pull request at
    once after an unexpected exception (for example while the connection 
recovers), unlike the error
    statuses, which wait for the rest of pullFetchTimeout since the previous 
commit. It now waits as well.
    
    New test unexpectedFetchFailureIsNotFetchedAgainBackToBack: every fetch 
fails with a RuntimeException.
    Without the change the loop made 96635 fetches in one second.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * CAMEL-25815: camel-nats - address review: no Thread.sleep in the 
back-to-back fetch tests
    
    Both tests now check the bound with Awaitility during the whole second.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../camel/component/nats/DefaultNatsManualAck.java |   6 +-
 .../apache/camel/component/nats/NatsConsumer.java  | 251 +++++++++++-
 .../nats/NatsConsumerPullConsumerDeletedTest.java  | 421 +++++++++++++++++++++
 .../nats/NatsConsumerPullConsumerGoneTest.java     | 263 +++++++++++++
 .../NatsJetstreamConsumerPullDeletedIT.java        | 107 ++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  14 +
 6 files changed, 1046 insertions(+), 16 deletions(-)

diff --git 
a/components/camel-nats/src/main/java/org/apache/camel/component/nats/DefaultNatsManualAck.java
 
b/components/camel-nats/src/main/java/org/apache/camel/component/nats/DefaultNatsManualAck.java
index 7d3b011663ba..9b0859919817 100644
--- 
a/components/camel-nats/src/main/java/org/apache/camel/component/nats/DefaultNatsManualAck.java
+++ 
b/components/camel-nats/src/main/java/org/apache/camel/component/nats/DefaultNatsManualAck.java
@@ -23,14 +23,17 @@ import io.nats.client.Message;
 class DefaultNatsManualAck implements NatsManualAck {
 
     private final Message message;
+    private final Runnable onAcknowledged;
 
-    DefaultNatsManualAck(Message message) {
+    DefaultNatsManualAck(Message message, Runnable onAcknowledged) {
         this.message = message;
+        this.onAcknowledged = onAcknowledged;
     }
 
     @Override
     public void ack() {
         message.ack();
+        onAcknowledged.run();
     }
 
     @Override
@@ -46,6 +49,7 @@ class DefaultNatsManualAck implements NatsManualAck {
     @Override
     public void term() {
         message.term();
+        onAcknowledged.run();
     }
 
     @Override
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 af23bed3aac9..88026b0dbb2c 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
@@ -18,11 +18,15 @@ package org.apache.camel.component.nats;
 
 import java.io.IOException;
 import java.time.Duration;
+import java.time.ZonedDateTime;
 import java.util.List;
+import java.util.NavigableSet;
+import java.util.concurrent.ConcurrentSkipListSet;
 import java.util.concurrent.ExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
 
 import io.nats.client.Connection;
-import io.nats.client.Connection.Status;
 import io.nats.client.Dispatcher;
 import io.nats.client.JetStreamApiException;
 import io.nats.client.JetStreamManagement;
@@ -34,7 +38,11 @@ import io.nats.client.PullSubscribeOptions;
 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.StreamConfiguration;
+import io.nats.client.api.StreamInfo;
+import io.nats.client.support.Status;
 import org.apache.camel.Exchange;
 import org.apache.camel.Processor;
 import org.apache.camel.spi.HeaderFilterStrategy;
@@ -49,13 +57,15 @@ import org.slf4j.LoggerFactory;
 public class NatsConsumer extends DefaultConsumer {
 
     private static final Logger LOG = 
LoggerFactory.getLogger(NatsConsumer.class);
+    // an idle JetStream pull loop asks the server for its consumer at most 
this often
+    private static final long CONSUMER_CHECK_INTERVAL_NANOS = 5_000_000_000L;
 
     private final Processor processor;
     private ExecutorService executor;
     private Connection connection;
     private Dispatcher dispatcher;
     private boolean active;
-    private JetStreamSubscription jetStreamSubscription;
+    private volatile JetStreamSubscription jetStreamSubscription;
 
     public NatsConsumer(NatsEndpoint endpoint, Processor processor) {
         super(endpoint, processor);
@@ -126,7 +136,7 @@ public class NatsConsumer extends DefaultConsumer {
 
         if (ObjectHelper.isEmpty(configuration.getConnection()) && 
ObjectHelper.isNotEmpty(this.connection)) {
             LOG.debug("Closing Nats Connection");
-            if (!this.connection.getStatus().equals(Status.CLOSED)) {
+            if (!this.connection.getStatus().equals(Connection.Status.CLOSED)) 
{
                 this.connection.close();
             }
         }
@@ -145,6 +155,18 @@ public class NatsConsumer extends DefaultConsumer {
 
         private final Connection connection;
         private final NatsConfiguration configuration;
+        // stream sequences of the JetStream messages fetched by the pull loop 
and not acknowledged yet, and the highest
+        // fetched: where a new ephemeral consumer must start when the server 
deleted the one this consumer used
+        private final NavigableSet<Long> unacknowledged = new 
ConcurrentSkipListSet<>();
+        private final AtomicLong highestFetched = new AtomicLong();
+        // with AckPolicy.None the delivery is the acknowledgement: no fetched 
sequence waits for an ack
+        private boolean trackUnacknowledged = true;
+        // 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 ZonedDateTime streamCreated;
+        private long lastConsumerCheck = System.nanoTime();
+        // whether the WARN that the durable consumer is gone was logged, so 
that it is logged once until it is back
+        private boolean durableGoneReported;
 
         NatsConsumingTask(Connection connection, NatsConfiguration 
configuration) {
             this.connection = connection;
@@ -187,7 +209,8 @@ public class NatsConsumer extends DefaultConsumer {
                     this.configuration.getTopic());
 
             JetStreamManagement jsm = connection.jetStreamManagement();
-            if (streamExists(jsm, streamName)) {
+            StreamInfo streamInfo = lookupStream(jsm, streamName);
+            if (streamInfo != null) {
                 LOG.debug("JetStream stream '{}' already exists. Skipping 
creation.", streamName);
             } else {
                 LOG.debug("Creating JetStream {}", streamName);
@@ -195,9 +218,10 @@ public class NatsConsumer extends DefaultConsumer {
                         .name(streamName)
                         .subjects(topic)
                         .build();
-                jsm.addStream(streamConfig);
+                streamInfo = jsm.addStream(streamConfig);
                 LOG.info("JetStream stream '{}' created successfully.", 
streamName);
             }
+            streamCreated = streamInfo != null ? streamInfo.getCreateTime() : 
null;
 
             ConsumerConfiguration cc = 
configuration.getConsumerConfiguration();
             if (cc == null) {
@@ -217,6 +241,7 @@ public class NatsConsumer extends DefaultConsumer {
                 }
                 cc = ccBuilder.build();
             }
+            trackUnacknowledged = cc.getAckPolicy() != AckPolicy.None;
 
             CamelNatsMessageHandler messageHandler = new 
CamelNatsMessageHandler();
 
@@ -235,7 +260,7 @@ public class NatsConsumer extends DefaultConsumer {
                         pullOptions);
 
                 NatsConsumer.this.setActive(true);
-                runPullFetchLoop(NatsConsumer.this.jetStreamSubscription, 
messageHandler);
+                runPullFetchLoop(NatsConsumer.this.jetStreamSubscription, 
messageHandler, cc);
             } else {
                 NatsConsumer.this.dispatcher = 
this.connection.createDispatcher(messageHandler);
 
@@ -262,17 +287,37 @@ public class NatsConsumer extends DefaultConsumer {
             }
         }
 
-        private void runPullFetchLoop(JetStreamSubscription sub, 
CamelNatsMessageHandler messageHandler) {
+        private void runPullFetchLoop(
+                JetStreamSubscription subscription, CamelNatsMessageHandler 
messageHandler, ConsumerConfiguration cc) {
+            JetStreamSubscription sub = subscription;
             int batchSize = configuration.getPullBatchSize();
             Duration fetchTimeout = 
Duration.ofMillis(configuration.getPullFetchTimeout());
             while (NatsConsumer.this.isRunAllowed() && 
!Thread.currentThread().isInterrupted()) {
+                long fetchStarted = System.nanoTime();
                 try {
                     List<Message> messages = sub.fetch(batchSize, 
fetchTimeout);
+                    if (messages.isEmpty()) {
+                        // jnats ends a fetch at once when its pull request 
has no responder (status 503), for example
+                        // while the consumer is gone, whereas an idle healthy 
consumer ends it at its expiry, shortly
+                        // before the fetch timeout: only an empty fetch that 
ended early asks the server for the
+                        // consumer, so that an idle healthy consumer does not 
send that request every few seconds
+                        boolean endedEarly = System.nanoTime() - fetchStarted 
< fetchTimeout.toNanos() / 2;
+                        if (endedEarly && consumerCheckDue() && 
consumerGone(sub)) {
+                            sub = consumerDeleted(sub, cc);
+                            continue;
+                        }
+                        // wait for the rest of the fetch timeout, so that the 
loop does not send pull requests back to
+                        // back
+                        waitRestOfFetch(fetchStarted, fetchTimeout);
+                        continue;
+                    }
+                    durableGoneReported = false;
                     for (Message msg : messages) {
                         if (!NatsConsumer.this.isRunAllowed()) {
                             break;
                         }
                         try {
+                            track(msg);
                             messageHandler.onMessage(msg);
                         } catch (InterruptedException e) {
                             Thread.currentThread().interrupt();
@@ -280,17 +325,187 @@ public class NatsConsumer extends DefaultConsumer {
                         }
                     }
                 } catch (JetStreamStatusException e) {
-                    // Synchronous pull calls may throw 
JetStreamStatusException for transient
-                    // statuses such as 409 (consumer deleted/push-based). Log 
and continue so the
-                    // consumer can recover when the consumer state is 
restored.
+                    // Synchronous pull calls may throw 
JetStreamStatusException for statuses such as 409. Report it and
+                    // continue; for 409 Consumer Deleted an ephemeral 
consumer is created again (see consumerDeleted).
                     
NatsConsumer.this.getExceptionHandler().handleException("JetStream status 
during pull fetch", e);
+                    Status status = e.getStatus();
+                    if (status != null && 
Status.CONSUMER_DELETED.equals(status.getMessage())) {
+                        sub = consumerDeleted(sub, cc);
+                    } else {
+                        // for example 409 Consumer is push based: do not send 
pull requests back to back
+                        try {
+                            waitRestOfFetch(fetchStarted, fetchTimeout);
+                        } catch (InterruptedException ie) {
+                            Thread.currentThread().interrupt();
+                            return;
+                        }
+                    }
                 } catch (IllegalStateException e) {
                     // Subscription was closed (e.g. consumer is stopping), 
exit the loop
                     LOG.debug("Pull subscription closed, stopping fetch loop", 
e);
                     return;
+                } catch (InterruptedException e) {
+                    // the consumer is stopping
+                    Thread.currentThread().interrupt();
+                    return;
                 } catch (Exception e) {
                     
NatsConsumer.this.getExceptionHandler().handleException("Error during JetStream 
pull fetch", e);
+                    // as for the other error statuses: do not send the next 
pull request at once
+                    try {
+                        waitRestOfFetch(fetchStarted, fetchTimeout);
+                    } catch (InterruptedException ie) {
+                        Thread.currentThread().interrupt();
+                        return;
+                    }
+                }
+            }
+        }
+
+        private void waitRestOfFetch(long fetchStarted, Duration fetchTimeout) 
throws InterruptedException {
+            long left = fetchTimeout.toNanos() - (System.nanoTime() - 
fetchStarted);
+            if (left > 0) {
+                TimeUnit.NANOSECONDS.sleep(left);
+            }
+        }
+
+        /**
+         * Whether the server no longer has the consumer of this pull 
subscription. The server deletes an ephemeral
+         * consumer (no durable name) that saw no pull request for its 
inactive threshold (5 seconds by default), for
+         * example while the connection was down for a few seconds, and a 
server restart loses it. jnats then answers
+         * every fetch with an empty list (the pull request has no responder), 
and the subscription never receives
+         * anything again.
+         */
+        private boolean consumerCheckDue() {
+            long now = System.nanoTime();
+            if (now - lastConsumerCheck < CONSUMER_CHECK_INTERVAL_NANOS) {
+                return false;
+            }
+            lastConsumerCheck = now;
+            return true;
+        }
+
+        private boolean consumerGone(JetStreamSubscription sub) {
+            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 (sub.getConsumerInfo() == null) {
+                    return true;
                 }
+                durableGoneReported = false;
+                return false;
+            } catch (JetStreamApiException e) {
+                return e.getErrorCode() == 404;
+            } catch (Exception e) {
+                // not connected, timeout: the consumer may well still be 
there, the next fetch tells
+                LOG.debug("Cannot check the JetStream consumer of the pull 
subscription", e);
+                return false;
+            }
+        }
+
+        /**
+         * The server no longer has the consumer of the pull subscription. An 
ephemeral consumer is created again (see
+         * {@link #subscribeAgain}); when that fails, the failure goes to the 
exception handler and the next consumer
+         * check tries again. 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. Its subscription is kept, so the fetches work 
again when the durable is created again
+         * with the same name.
+         *
+         * @return the subscription to fetch from
+         */
+        private JetStreamSubscription consumerDeleted(JetStreamSubscription 
sub, ConsumerConfiguration cc) {
+            if (cc.getDurable() != null) {
+                if (!durableGoneReported) {
+                    durableGoneReported = true;
+                    LOG.warn("The durable JetStream consumer {} of the pull 
subscription on topic {} is gone; it is not"
+                             + " created again, and the fetches resume when it 
is created again with the same name",
+                            cc.getDurable(), configuration.getTopic());
+                }
+                return sub;
+            }
+            try {
+                return subscribeAgain(sub, cc);
+            } catch (Exception e) {
+                NatsConsumer.this.getExceptionHandler().handleException(
+                        "Error subscribing again after the JetStream consumer 
was deleted", e);
+                return sub;
+            }
+        }
+
+        /**
+         * Subscribes again after the server deleted the ephemeral consumer, 
starting at the first message this consumer
+         * has not acknowledged, so that nothing is skipped and the stream is 
not replayed from its deliver policy.
+         * <p>
+         * A sequence that is never acknowledged stays in the unacknowledged 
set until this resubscribe, for example a
+         * message nak'd until maxDeliver is used up. The new consumer then 
starts at that old sequence, and the route
+         * sees the messages after it again (at-least-once). With 
ackPolicy=None no sequence is tracked: the new
+         * consumer starts after the highest one fetched.
+         * <p>
+         * 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 JetStreamSubscription subscribeAgain(JetStreamSubscription 
old, ConsumerConfiguration cc)
+                throws IOException, JetStreamApiException {
+            StreamInfo stream = 
this.connection.jetStreamManagement().getStreamInfo(configuration.getJetstreamName());
+            ZonedDateTime created = stream.getCreateTime();
+            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",
+                        configuration.getJetstreamName());
+                unacknowledged.clear();
+                highestFetched.set(0);
+            }
+            if (created != null) {
+                streamCreated = created;
+            }
+            boolean workQueue = stream.getConfiguration() != null
+                    && stream.getConfiguration().getRetentionPolicy() == 
RetentionPolicy.WorkQueue;
+            ConsumerConfiguration again = cc;
+            long start = unacknowledged.isEmpty() ? highestFetched.get() + 1 : 
unacknowledged.first();
+            boolean resume = !workQueue && highestFetched.get() > 0;
+            if (resume) {
+                // a start time (DeliverPolicy.ByStartTime) together with a 
start sequence is rejected by the server
+                again = ConsumerConfiguration.builder(cc)
+                        .deliverPolicy(DeliverPolicy.ByStartSequence)
+                        .startSequence(start)
+                        .startTime(null)
+                        .build();
+            }
+            LOG.warn("The JetStream consumer of the pull subscription on topic 
{} is gone; subscribing again{}",
+                    configuration.getTopic(), resume ? " from stream sequence 
" + start : "");
+            JetStreamSubscription sub = 
this.connection.jetStream().subscribe(configuration.getTopic(),
+                    
PullSubscribeOptions.builder().configuration(again).build());
+            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);
+                }
+            }
+            try {
+                old.unsubscribe();
+            } catch (Exception e) {
+                LOG.debug("Error unsubscribing the pull subscription of the 
deleted consumer", e);
+            }
+            return sub;
+        }
+
+        private void track(Message msg) {
+            if (msg.isJetStream()) {
+                long seq = msg.metaData().streamSequence();
+                if (trackUnacknowledged) {
+                    unacknowledged.add(seq);
+                }
+                highestFetched.accumulateAndGet(seq, Math::max);
+            }
+        }
+
+        private void acknowledged(Message msg) {
+            if (msg.isJetStream()) {
+                unacknowledged.remove(msg.metaData().streamSequence());
             }
         }
 
@@ -325,6 +540,7 @@ public class NatsConsumer extends DefaultConsumer {
                         public void onComplete(Exchange exchange) {
                             LOG.debug("ACK");
                             msg.ack();
+                            acknowledged(msg);
                         }
 
                         @Override
@@ -333,6 +549,7 @@ public class NatsConsumer extends DefaultConsumer {
                                 // ACK policy is none which means that we 
should auto ACK even if the message processed failed in Camel
                                 LOG.debug("ACK");
                                 msg.ack();
+                                acknowledged(msg);
                             } else {
                                 LOG.debug("NACK (delay:{})", 
configuration.getNackWait());
                                 if (configuration.getNackWait() <= 0) {
@@ -379,7 +596,8 @@ public class NatsConsumer extends DefaultConsumer {
                             evalRedeliveryMessageTrait(msg, exchange));
 
                     if (manualAckEnabled) {
-                        
exchange.getIn().setHeader(NatsConstants.NATS_MANUAL_ACK, new 
DefaultNatsManualAck(msg));
+                        
exchange.getIn().setHeader(NatsConstants.NATS_MANUAL_ACK,
+                                new DefaultNatsManualAck(msg, () -> 
acknowledged(msg)));
                     }
 
                     NatsConsumer.this.processor.process(exchange);
@@ -407,13 +625,16 @@ public class NatsConsumer extends DefaultConsumer {
         }
     }
 
-    private boolean streamExists(JetStreamManagement jsm, String streamName) 
throws IOException, JetStreamApiException {
+    /**
+     * @return the stream, or null when the server does not have it
+     */
+    private static StreamInfo lookupStream(JetStreamManagement jsm, String 
streamName)
+            throws IOException, JetStreamApiException {
         try {
-            jsm.getStreamInfo(streamName);
-            return true;
+            return jsm.getStreamInfo(streamName);
         } catch (JetStreamApiException jsae) {
             if (jsae.getErrorCode() == 404) {
-                return false;
+                return null;
             }
             throw jsae;
         }
diff --git 
a/components/camel-nats/src/test/java/org/apache/camel/component/nats/NatsConsumerPullConsumerDeletedTest.java
 
b/components/camel-nats/src/test/java/org/apache/camel/component/nats/NatsConsumerPullConsumerDeletedTest.java
new file mode 100644
index 000000000000..ce09e17c508b
--- /dev/null
+++ 
b/components/camel-nats/src/test/java/org/apache/camel/component/nats/NatsConsumerPullConsumerDeletedTest.java
@@ -0,0 +1,421 @@
+/*
+ * 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.Duration;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import io.nats.client.Connection;
+import io.nats.client.JetStream;
+import io.nats.client.JetStreamApiException;
+import io.nats.client.JetStreamManagement;
+import io.nats.client.JetStreamStatusException;
+import io.nats.client.JetStreamSubscription;
+import io.nats.client.Message;
+import io.nats.client.PullSubscribeOptions;
+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.RetentionPolicy;
+import io.nats.client.api.ServerInfo;
+import io.nats.client.api.StreamInfo;
+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.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * The JetStream pull consumer (the default with jetstreamEnabled=true) uses 
an ephemeral consumer when no durableName
+ * is set. The NATS server deletes an ephemeral consumer that saw no pull 
request 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. The
+ * consumer must then subscribe again, starting at the first message it did 
not acknowledge, instead of fetching from
+ * the deleted consumer for ever.
+ */
+class NatsConsumerPullConsumerDeletedTest extends CamelTestSupport {
+
+    private final FakeServer server = new FakeServer();
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        context.getRegistry().bind("conn", server.connection());
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from("nats:orders?connection=#conn&jetstreamEnabled=true&jetstreamName=ORDERS&pullFetchTimeout=100")
+                        .convertBodyTo(String.class)
+                        .to("mock:result");
+            }
+        };
+    }
+
+    @Test
+    void consumesAgainAfterTheServerDeletedTheEphemeralConsumer() throws 
Exception {
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("order-1");
+        server.publish("order-1");
+        mock.assertIsSatisfied();
+
+        // the client was disconnected for longer than the inactive threshold: 
the server deleted the consumer
+        server.deleteConsumers(false);
+
+        mock.reset();
+        mock.expectedBodiesReceived("order-2", "order-3");
+        server.publish("order-2");
+        server.publish("order-3");
+        mock.setResultWaitTime(20_000);
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void consumesAgainAfterAWaitingPullGotConsumerDeleted() throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("order-1");
+        server.publish("order-1");
+        mock.assertIsSatisfied();
+
+        // the consumer is deleted while a pull request waits: that request 
gets 409 Consumer Deleted
+        server.deleteConsumers(true);
+
+        mock.reset();
+        mock.expectedBodiesReceived("order-2");
+        server.publish("order-2");
+        mock.setResultWaitTime(20_000);
+        mock.assertIsSatisfied();
+        assertEquals(2, server.consumersCreated(), "one consumer created 
again");
+    }
+
+    @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.createStreamAgain();
+
+        mock.reset();
+        mock.expectedBodiesReceived("order-3");
+        server.publish("order-3");
+        mock.setResultWaitTime(20_000);
+        mock.assertIsSatisfied();
+        ConsumerConfiguration again = server.subscribed().get(1);
+        assertEquals(DeliverPolicy.All, again.getDeliverPolicy(), "the 
consumer configuration, not a start sequence");
+    }
+
+    @Test
+    void workQueueStreamIsSubscribedAgainWithDeliverPolicyAll() throws 
Exception {
+        server.retention(RetentionPolicy.WorkQueue);
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("order-1");
+        server.publish("order-1");
+        mock.assertIsSatisfied();
+
+        server.deleteConsumers(false);
+
+        mock.reset();
+        mock.expectedBodiesReceived("order-2");
+        server.publish("order-2");
+        mock.setResultWaitTime(20_000);
+        mock.assertIsSatisfied();
+        assertEquals(2, server.consumersCreated(), "one consumer created 
again");
+        assertEquals(DeliverPolicy.All, 
server.subscribed().get(1).getDeliverPolicy(),
+                "a work queue stream accepts only the deliver policy All");
+    }
+
+    /**
+     * A JetStream server with one stream, faked at the level of the jnats 
interfaces the consumer calls. A consumer
+     * delivers from its start sequence; once deleted, a fetch on it gets 
nothing at once (jnats ends the fetch on the
+     * 503 "no responders" status), and getConsumerInfo answers null, as jnats 
does when the server answers consumer not
+     * found (10014). A live consumer answers its info. 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.
+     */
+    static final class FakeServer {
+        final List<byte[]> stream = new ArrayList<>();
+        final Set<Long> removed = new HashSet<>();
+        final List<FakeConsumer> consumers = new ArrayList<>();
+        final List<ConsumerConfiguration> subscribed = new ArrayList<>();
+        RetentionPolicy retention = RetentionPolicy.Limits;
+        ZonedDateTime created = DateTimeUtils.gmtNow().minusHours(1);
+        int consumersCreated;
+        int fetches;
+        int consumerInfoRequests;
+        RuntimeException failNextSubscribe;
+        Status fetchStatus;
+        RuntimeException fetchFailure;
+
+        final class FakeConsumer {
+            long next;
+            boolean deleted;
+            boolean tellDeleted;
+            final List<Long> redeliver = new ArrayList<>();
+        }
+
+        synchronized int consumersCreated() {
+            return consumersCreated;
+        }
+
+        synchronized int fetches() {
+            return fetches;
+        }
+
+        synchronized int consumerInfoRequests() {
+            return consumerInfoRequests;
+        }
+
+        synchronized void retention(RetentionPolicy retention) {
+            this.retention = retention;
+        }
+
+        /**
+         * Every fetch fails with this status, for example 409 Consumer is 
push based.
+         */
+        synchronized void failFetches(Status status) {
+            fetchStatus = status;
+        }
+
+        /**
+         * Every fetch fails with this exception, for example an error of the 
connection while it recovers.
+         */
+        synchronized void failFetches(RuntimeException failure) {
+            fetchFailure = failure;
+        }
+
+        /**
+         * The stream is created again, for example a memory stream after a 
server restart: its consumers are gone and
+         * its sequences start again at 1.
+         */
+        synchronized void createStreamAgain() {
+            stream.clear();
+            removed.clear();
+            created = created.plusMinutes(1);
+            deleteConsumers(false);
+        }
+
+        synchronized void publish(String body) {
+            stream.add(body.getBytes(StandardCharsets.UTF_8));
+            notifyAll();
+        }
+
+        synchronized void deleteConsumers(boolean waitingRequest) {
+            for (FakeConsumer c : consumers) {
+                c.deleted = true;
+                c.tellDeleted = waitingRequest;
+            }
+            notifyAll();
+        }
+
+        /**
+         * The deleted consumers are created again with the same name (a 
durable created again by an administrator).
+         */
+        synchronized void restoreConsumers() {
+            for (FakeConsumer c : consumers) {
+                c.deleted = false;
+            }
+            notifyAll();
+        }
+
+        synchronized void failNextSubscribe(RuntimeException e) {
+            failNextSubscribe = e;
+        }
+
+        synchronized List<ConsumerConfiguration> subscribed() {
+            return new ArrayList<>(subscribed);
+        }
+
+        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 == 2 && args[1] 
instanceof PullSubscribeOptions o) {
+                    return subscribe(o.getConsumerConfiguration());
+                }
+                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 synchronized StreamInfo streamInfo() {
+            return new StreamInfo(
+                    
JsonParser.parseUnchecked("{\"config\":{\"name\":\"ORDERS\",\"subjects\":[\"orders\"],"
+                                              + "\"retention\":\"" + retention 
+ "\"},\"created\":\""
+                                              + 
DateTimeUtils.toRfc3339(created) + "\",\"state\":{}}"));
+        }
+
+        private synchronized JetStreamSubscription 
subscribe(ConsumerConfiguration cc) throws JetStreamApiException {
+            if (failNextSubscribe != null) {
+                RuntimeException e = failNextSubscribe;
+                failNextSubscribe = null;
+                throw e;
+            }
+            if (retention == RetentionPolicy.WorkQueue && 
cc.getDeliverPolicy() != DeliverPolicy.All) {
+                // the server's answer as jnats reads it (ApiResponse is 
abstract)
+                throw new JetStreamApiException(
+                        new ApiResponse<Void>(
+                                JsonParser.parseUnchecked(
+                                        
"{\"error\":{\"code\":400,\"err_code\":10101,"
+                                                          + 
"\"description\":\"consumer must be deliver all on workqueue stream\"}}")) {
+                        });
+            }
+            subscribed.add(cc);
+            FakeConsumer c = new FakeConsumer();
+            c.next = cc != null && cc.getDeliverPolicy() == 
DeliverPolicy.ByStartSequence ? cc.getStartSequence() : 1;
+            consumers.add(c);
+            consumersCreated++;
+            JetStreamSubscription[] self = new JetStreamSubscription[1];
+            self[0] = proxy(JetStreamSubscription.class, (name, args) -> 
switch (name) {
+                case "fetch" -> fetch(c, self[0], (Integer) args[0], 
(Duration) args[1]);
+                case "getConsumerInfo" -> consumerInfo(c);
+                case "isActive" -> true;
+                default -> null;
+            });
+            return self[0];
+        }
+
+        private synchronized ConsumerInfo consumerInfo(FakeConsumer c) {
+            consumerInfoRequests++;
+            if (c.deleted) {
+                return null;
+            }
+            return new 
ConsumerInfo(JsonParser.parseUnchecked("{\"stream_name\":\"ORDERS\",\"name\":\"c\",\"config\":{}}"));
+        }
+
+        private synchronized List<Message> fetch(FakeConsumer c, 
JetStreamSubscription sub, int batch, Duration timeout) {
+            fetches++;
+            long deadline = System.nanoTime() + timeout.toNanos();
+            List<Message> out = new ArrayList<>();
+            if (fetchStatus != null) {
+                throw new JetStreamStatusException(fetchStatus, sub);
+            }
+            if (fetchFailure != null) {
+                throw fetchFailure;
+            }
+            while (true) {
+                if (c.deleted) {
+                    if (c.tellDeleted) {
+                        c.tellDeleted = false;
+                        throw new JetStreamStatusException(new Status(409, 
Status.CONSUMER_DELETED), sub);
+                    }
+                    return out;
+                }
+                while (out.size() < batch && (!c.redeliver.isEmpty() || c.next 
<= stream.size())) {
+                    long seq = c.redeliver.isEmpty() ? c.next++ : 
c.redeliver.remove(0);
+                    if (!removed.contains(seq)) {
+                        out.add(message(c, sub, seq));
+                    }
+                }
+                long left = deadline - System.nanoTime();
+                if (!out.isEmpty() || left <= 0) {
+                    return out;
+                }
+                try {
+                    TimeUnit.NANOSECONDS.timedWait(this, left);
+                } catch (InterruptedException e) {
+                    // as jnats: an interrupted fetch keeps the interrupt and 
returns what it has
+                    Thread.currentThread().interrupt();
+                    return out;
+                }
+            }
+        }
+
+        private Message message(FakeConsumer c, JetStreamSubscription sub, 
long seq) {
+            byte[] 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.1." + seq + 
"." + seq + ".0.0", data) {
+                        @Override
+                        public boolean isJetStream() {
+                            return true;
+                        }
+                    });
+            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 "nak" -> {
+                    synchronized (this) {
+                        c.redeliver.add(seq);
+                    }
+                    yield null;
+                }
+                case "ack", "term" -> {
+                    synchronized (this) {
+                        if (retention == RetentionPolicy.WorkQueue) {
+                            removed.add(seq);
+                        }
+                    }
+                    yield null;
+                }
+                default -> null;
+            });
+        }
+
+        @FunctionalInterface
+        interface Handler {
+            Object handle(String method, Object[] args) throws Exception;
+        }
+
+        @SuppressWarnings("unchecked")
+        static <T> T proxy(Class<T> 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/components/camel-nats/src/test/java/org/apache/camel/component/nats/NatsConsumerPullConsumerGoneTest.java
 
b/components/camel-nats/src/test/java/org/apache/camel/component/nats/NatsConsumerPullConsumerGoneTest.java
new file mode 100644
index 000000000000..72456842c1f3
--- /dev/null
+++ 
b/components/camel-nats/src/test/java/org/apache/camel/component/nats/NatsConsumerPullConsumerGoneTest.java
@@ -0,0 +1,263 @@
+/*
+ * 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.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+
+import io.nats.client.api.AckPolicy;
+import io.nats.client.api.ConsumerConfiguration;
+import io.nats.client.api.DeliverPolicy;
+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.component.nats.NatsConsumerPullConsumerDeletedTest.FakeServer;
+import org.apache.camel.spi.ExceptionHandler;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.Logger;
+import org.apache.logging.log4j.core.appender.AbstractAppender;
+import org.apache.logging.log4j.core.config.Property;
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * What the JetStream pull consumer does when the server no longer has its 
consumer, besides creating an ephemeral
+ * consumer again (see {@link NatsConsumerPullConsumerDeletedTest}): a durable 
consumer is not created again, a failed
+ * subscribe is tried again, and a start time is not sent together with the 
start sequence.
+ */
+class NatsConsumerPullConsumerGoneTest extends CamelTestSupport {
+
+    private final FakeServer server = new FakeServer();
+    private final List<Throwable> handled = new CopyOnWriteArrayList<>();
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        context.getRegistry().bind("conn", server.connection());
+        context.getRegistry().bind("eh", 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);
+            }
+        });
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                // each test adds its route
+            }
+        };
+    }
+
+    private void addRoute(String options) throws Exception {
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from("nats:orders?connection=#conn&jetstreamEnabled=true&jetstreamName=ORDERS&pullFetchTimeout=100"
+                     + "&exceptionHandler=#eh" + options)
+                        .convertBodyTo(String.class)
+                        .to("mock:result");
+            }
+        });
+    }
+
+    @Test
+    void durableConsumerIsNotCreatedAgain() throws Exception {
+        List<String> warnings = new CopyOnWriteArrayList<>();
+        AbstractAppender appender = new AbstractAppender("CaptureWarn", null, 
null, true, Property.EMPTY_ARRAY) {
+            @Override
+            public void append(LogEvent event) {
+                if (event.getLevel() == Level.WARN
+                        && 
event.getMessage().getFormattedMessage().contains("durable JetStream 
consumer")) {
+                    warnings.add(event.getMessage().getFormattedMessage());
+                }
+            }
+        };
+        appender.start();
+        Logger logger = (Logger) LogManager.getLogger(NatsConsumer.class);
+        logger.addAppender(appender);
+        try {
+            addRoute("&durableName=orders");
+            MockEndpoint mock = getMockEndpoint("mock:result");
+            mock.expectedBodiesReceived("order-1");
+            server.publish("order-1");
+            mock.assertIsSatisfied();
+
+            // an administrator deleted the durable: a waiting pull gets 409 
Consumer Deleted, then the fetches get
+            // nothing and the consumer check (every 5 seconds) finds it gone
+            server.deleteConsumers(true);
+            await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> 
assertEquals(1, warnings.size()));
+            int fetchesBefore = server.fetches();
+            long waitStarted = System.nanoTime();
+            await().during(6, TimeUnit.SECONDS).atMost(8, 
TimeUnit.SECONDS).untilAsserted(() -> {
+                assertEquals(1, server.consumersCreated(), "the durable is not 
created again");
+                assertEquals(1, warnings.size(), "the WARN is logged once");
+            });
+            // while the durable is gone each fetch ends at once: the loop 
waits pullFetchTimeout (100 ms) between them
+            long waitedMillis = 
TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - waitStarted);
+            int fetches = server.fetches() - fetchesBefore;
+            assertTrue(fetches <= waitedMillis / 100 + 5,
+                    "no pull requests back to back: " + fetches + " fetches in 
" + waitedMillis + " ms");
+
+            // the durable is created again with the same name: the same 
subscription fetches again
+            mock.reset();
+            mock.expectedBodiesReceived("order-2");
+            server.restoreConsumers();
+            server.publish("order-2");
+            mock.setResultWaitTime(20_000);
+            mock.assertIsSatisfied();
+            assertEquals(1, server.consumersCreated());
+        } finally {
+            logger.removeAppender(appender);
+            appender.stop();
+        }
+    }
+
+    @Test
+    void failedSubscribeIsTriedAgainAtTheNextCheck() throws Exception {
+        addRoute("");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("order-1");
+        server.publish("order-1");
+        mock.assertIsSatisfied();
+
+        // the subscribe fails, for example while the stream itself is gone: 
jnats reports that failure as an
+        // IllegalStateException
+        server.failNextSubscribe(new IllegalStateException("[SUB-90007] No 
matching streams for subject."));
+        server.deleteConsumers(false);
+
+        mock.reset();
+        mock.expectedBodiesReceived("order-2");
+        server.publish("order-2");
+        mock.setResultWaitTime(30_000);
+        mock.assertIsSatisfied();
+        assertEquals(2, server.consumersCreated(), "created again at the check 
after the failed one");
+        
assertTrue(handled.stream().anyMatch(IllegalStateException.class::isInstance),
+                "the failed subscribe goes to the exception handler");
+    }
+
+    @Test
+    void idleHealthyConsumerIsNotAskedForItsInfo() throws Exception {
+        addRoute("");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("order-1");
+        server.publish("order-1");
+        mock.assertIsSatisfied();
+
+        // each empty fetch of a healthy consumer lasts the whole fetch 
timeout: no consumer check after it
+        await().during(6, TimeUnit.SECONDS).atMost(8, TimeUnit.SECONDS)
+                .untilAsserted(() -> assertEquals(0, 
server.consumerInfoRequests()));
+    }
+
+    @Test
+    void otherErrorStatusIsNotFetchedAgainBackToBack() throws Exception {
+        server.failFetches(new Status(409, "Consumer is push based"));
+        addRoute("");
+        await().atMost(5, TimeUnit.SECONDS).until(() -> !handled.isEmpty());
+        int fetchesBefore = server.fetches();
+        long waitStarted = System.nanoTime();
+        // the loop waits for the rest of pullFetchTimeout (100 ms) after each 
failed fetch
+        await().during(1, TimeUnit.SECONDS).atMost(2, 
TimeUnit.SECONDS).untilAsserted(() -> {
+            long waitedMillis = 
TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - waitStarted);
+            int fetches = server.fetches() - fetchesBefore;
+            assertTrue(fetches <= waitedMillis / 100 + 5,
+                    "no pull requests back to back: " + fetches + " fetches in 
" + waitedMillis + " ms");
+        });
+    }
+
+    @Test
+    void unexpectedFetchFailureIsNotFetchedAgainBackToBack() throws Exception {
+        server.failFetches(new RuntimeException("connection is recovering"));
+        addRoute("");
+        await().atMost(5, TimeUnit.SECONDS).until(() -> !handled.isEmpty());
+        int fetchesBefore = server.fetches();
+        long waitStarted = System.nanoTime();
+        // the loop waits for the rest of pullFetchTimeout (100 ms) after each 
failed fetch
+        await().during(1, TimeUnit.SECONDS).atMost(2, 
TimeUnit.SECONDS).untilAsserted(() -> {
+            long waitedMillis = 
TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - waitStarted);
+            int fetches = server.fetches() - fetchesBefore;
+            assertTrue(fetches <= waitedMillis / 100 + 5,
+                    "no pull requests back to back: " + fetches + " fetches in 
" + waitedMillis + " ms");
+        });
+    }
+
+    @Test
+    void ackPolicyNoneResumesAfterTheHighestFetched() throws Exception {
+        // the route never calls ack: with ackPolicy=None the delivery is the 
acknowledgement
+        addRoute("&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.deleteConsumers(false);
+
+        mock.reset();
+        mock.expectedBodiesReceived("order-3");
+        server.publish("order-3");
+        mock.setResultWaitTime(20_000);
+        mock.assertIsSatisfied();
+        ConsumerConfiguration again = server.subscribed().get(1);
+        assertEquals(AckPolicy.None, again.getAckPolicy());
+        assertEquals(DeliverPolicy.ByStartSequence, again.getDeliverPolicy());
+        assertEquals(3, again.getStartSequence(), "starts after the highest 
fetched sequence");
+    }
+
+    @Test
+    void startTimeIsNotSentWithTheStartSequence() throws Exception {
+        context.getRegistry().bind("cc", ConsumerConfiguration.builder()
+                .deliverPolicy(DeliverPolicy.ByStartTime)
+                .startTime(ZonedDateTime.now().minusHours(1))
+                .build());
+        addRoute("&consumerConfiguration=#cc");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("order-1");
+        server.publish("order-1");
+        mock.assertIsSatisfied();
+
+        server.deleteConsumers(true);
+
+        mock.reset();
+        mock.expectedBodiesReceived("order-2");
+        server.publish("order-2");
+        mock.setResultWaitTime(20_000);
+        mock.assertIsSatisfied();
+        ConsumerConfiguration again = server.subscribed().get(1);
+        assertEquals(DeliverPolicy.ByStartSequence, again.getDeliverPolicy());
+        assertEquals(2, again.getStartSequence());
+        assertNull(again.getStartTime());
+    }
+}
diff --git 
a/components/camel-nats/src/test/java/org/apache/camel/component/nats/jetstream/NatsJetstreamConsumerPullDeletedIT.java
 
b/components/camel-nats/src/test/java/org/apache/camel/component/nats/jetstream/NatsJetstreamConsumerPullDeletedIT.java
new file mode 100644
index 000000000000..9d747783652e
--- /dev/null
+++ 
b/components/camel-nats/src/test/java/org/apache/camel/component/nats/jetstream/NatsJetstreamConsumerPullDeletedIT.java
@@ -0,0 +1,107 @@
+/*
+ * 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.jetstream;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import io.nats.client.Connection;
+import io.nats.client.JetStreamManagement;
+import io.nats.client.Nats;
+import io.nats.client.Options;
+import org.apache.camel.EndpointInject;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.component.nats.NatsComponent;
+import org.apache.camel.component.nats.integration.NatsITSupport;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.Isolated;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The server deletes the ephemeral consumer of a pull subscription while the 
route is busy with a message, so that no
+ * pull request is waiting and no 409 Consumer Deleted arrives: the consumer 
must find out that its consumer is gone and
+ * subscribe again after the message it routed.
+ */
+@Isolated
+class NatsJetstreamConsumerPullDeletedIT extends NatsITSupport {
+
+    private static final String STREAM = "mystream-pull-deleted";
+
+    private final CountDownLatch busy = new CountDownLatch(1);
+    private final CountDownLatch release = new CountDownLatch(1);
+
+    @EndpointInject("mock:result")
+    MockEndpoint mockResultEndpoint;
+
+    @Test
+    void consumesAgainAfterTheEphemeralConsumerWasDeleted() throws Exception {
+        waitForNatsConsumers(1);
+        mockResultEndpoint.expectedBodiesReceived("order-1", "order-2");
+
+        template.sendBody("direct:send", "order-1");
+        assertTrue(busy.await(20, TimeUnit.SECONDS), "order-1 is routed");
+
+        String[] servers = Arrays.stream(
+                context.getComponent("nats", 
NatsComponent.class).getConfiguration().getServers().split(","))
+                .map(server -> server.contains("://") ? server : "nats://" + 
server)
+                .toArray(String[]::new);
+        try (Connection connection = Nats.connect(new 
Options.Builder().servers(servers).build())) {
+            JetStreamManagement jsm = connection.jetStreamManagement();
+            List<String> consumers = jsm.getConsumerNames(STREAM);
+            assertEquals(1, consumers.size(), "the ephemeral consumer of the 
route");
+            jsm.deleteConsumer(STREAM, consumers.get(0));
+        }
+        release.countDown();
+
+        template.sendBody("direct:send", "order-2");
+        mockResultEndpoint.setResultWaitTime(30_000);
+        mockResultEndpoint.assertIsSatisfied();
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                String producerUri = 
"nats:mytopic-pull-deleted?jetstreamEnabled=true&jetstreamName=" + STREAM
+                                     + "&jetstreamAsync=false";
+                String consumerUri = 
"nats:mytopic-pull-deleted?jetstreamEnabled=true&jetstreamName=" + STREAM
+                                     + 
"&pullSubscription=true&pullFetchTimeout=500";
+
+                from("direct:send")
+                        // when running full test suite then send can fail due 
to nats server setup/teardown
+                        
.errorHandler(defaultErrorHandler().maximumRedeliveries(5))
+                        .to(producerUri);
+
+                from(consumerUri)
+                        .convertBodyTo(String.class)
+                        .process(exchange -> {
+                            if 
("order-1".equals(exchange.getIn().getBody(String.class))) {
+                                busy.countDown();
+                                release.await(30, TimeUnit.SECONDS);
+                            }
+                        })
+                        .to(mockResultEndpoint);
+            }
+        };
+    }
+}
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 55758c2545c6..982ce7102bdc 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
@@ -2143,6 +2143,20 @@ in full and rejected by the broker.
 
 A settlement request the broker rejects is now reported through the consumer's 
exception handler.
 
+=== camel-nats - the JetStream pull consumer subscribes again when its 
ephemeral consumer was deleted
+
+With `jetstreamEnabled=true` and a pull subscription (the default), the `nats` 
consumer kept fetching from a JetStream
+consumer that the server had deleted, and consumed nothing more until the 
route was restarted. It now subscribes again
+when the server no longer has its ephemeral consumer (no `durableName`, and no 
durable name in the
+`consumerConfiguration`), which the server deletes after about 5 seconds 
without a pull request, for example while the
+client is disconnected, or loses on a restart. 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
+fetched). 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. The consumer asks the 
server for its consumer only after a fetch
+that ended empty well before `pullFetchTimeout`, at most every 5 seconds. A 
durable consumer is not created again: when
+it is gone (deleted by an administrator, or after its `inactiveThreshold`), 
the consumer logs a WARN and keeps fetching,
+and the fetches work again when the durable is created again with the same 
name.
+
 === camel-telegram - a failed send fails the exchange once
 
 When Telegram answers a message with an error status (for example `400` for an 
unknown chat), or with a body that

Reply via email to