Skip to content

CAMEL-25815: camel-nats - the JetStream pull consumer subscribes again when the server deleted its consumer - #28204

Merged
davsclaus merged 6 commits into
apache:mainfrom
allthingssecurity:camel-nats-jetstream-pull-resubscribe
Oct 23, 2026
Merged

davsclaus merged 6 commits into
apache:mainfrom
allthingssecurity:camel-nats-jetstream-pull-resubscribe

Conversation

@allthingssecurity

@allthingssecurity allthingssecurity commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Description

CAMEL-25815

With jetstreamEnabled=true the consumer uses a pull subscription by default, and without durableName the JetStream consumer behind it is ephemeral. 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 before jnats reconnected, and a server restart loses it too. jnats does not give a pull subscription a new consumer: every fetch then ends with an empty list (the pull request has no responder), 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.

Changes (NatsConsumer, DefaultNatsManualAck):

  • When a fetch returned nothing well before pullFetchTimeout (less than half of it; a gone consumer ends the fetch at once with 503, an idle healthy one only at its expiry), the pull loop asks the server for its consumer (getConsumerInfo), at most every 5 seconds, so an idle healthy consumer sends no CONSUMER.INFO request (review follow-up f946dae). jnats 2.26.4 answers null when the server has no such consumer (lookupConsumerInfo maps api error 10014 to null): null, a 404 (for example stream not found, 10059), or a 409 Consumer Deleted from a fetch means the consumer is gone. A failed check (not connected, timeout) changes nothing.
  • When an ephemeral consumer (no durable name) is gone, the loop subscribes again, starting at the first stream sequence the route has not acknowledged (or after the last one fetched): nothing is skipped, and the stream is not replayed from the deliver policy. It looks the stream up first: for a stream with WorkQueue retention (which accepts only DeliverPolicy.All, error 10101, and has already removed the acknowledged messages), and for a stream whose create time differs from the one seen at the subscribe (created again, its sequences start at 1 again; the tracked sequences are cleared), the consumer configuration is used as it is. The new configuration is sent without the start time of a DeliverPolicy.ByStartTime configuration (.startTime(null); jnats then leaves opt_start_time out of the request), as a start time does not go with ByStartSequence. A sequence that is never acknowledged (a message nak'd until maxDeliver is used up) stays a start candidate until the next resubscribe, so the route can see the messages after it again (at-least-once). With AckPolicy.None no sequence is tracked (the delivery is the acknowledgement): the new consumer starts after the highest one fetched.
  • When a durable consumer is gone, it is not created again: it goes away only when an administrator deletes it or after its inactiveThreshold, and creating it again from its configuration would replay the stream from its deliver policy. The loop logs a WARN (once, until the consumer is back) and keeps the subscription, so the fetches work again when the durable is created again with the same name.
  • While the consumer is gone, jnats ends each fetch at once (status 503, no responders; PullMessageManager.manageStatus returns STATUS_TERMINUS), so the loop sent pull requests back to back (also on main). After an empty fetch that ended before pullFetchTimeout, the loop now waits for the rest of it; a stop interrupts the wait (review follow-up 939b4a5). A fetch that fails with another status than 409 Consumer Deleted (for example 409 Consumer is push based) waits the same way. So does a fetch that fails with any other exception, for example while the connection recovers (review follow-up abb0d97; without it a failing fetch was repeated about 96,000 times a second in unexpectedFetchFailureIsNotFetchedAgainBackToBack).
  • A subscribe that fails (jnats reports some failures as IllegalStateException, for example SUB-90007 No matching streams for subject when the stream itself is gone) goes to the consumer's exception handler, and the next consumer check tries again; the fetch loop no longer ends on it. The empty-fetch path and the 409 path use the same helper.
  • If the route stops while it subscribes again, the new subscription is unsubscribed (doStop may already have unsubscribed the old one).
  • To know where to start, the loop keeps the stream sequences it fetched and has not acknowledged yet; an ack by the consumer or through the manual ack header (ack, term) removes them.

What users can notice: after an outage of more than about 5 seconds or a server restart, a pull consumer without durableName consumes again (a WARN says it subscribed again and from which stream sequence). A pull consumer with durableName whose JetStream consumer was deleted (for example by an administrator) is not created again: it logs a WARN, where before it was silent, and consumes again once the durable is created again with the same name. No option changed; a camel-nats note in the 4.24 upgrade guide describes the ephemeral case and says that a durable is not created again.

Tests:

  • New NatsConsumerPullConsumerDeletedTest (4 tests) with a fake JetStream server behind the jnats interfaces the consumer calls. It answers like jnats: getConsumerInfo() returns null for a deleted consumer and the info for a live one; a WorkQueue stream removes acknowledged messages and rejects a deliver policy other than All (400, 10101); a stream created again restarts its sequences with a new create time. Without the change the first two fail: after the deletion nothing is routed (Expected: <2> but was: <0> and Expected: <1> but was: <0>). consumesAStreamCreatedAgainFromItsStart and workQueueStreamIsSubscribedAgainWithDeliverPolicyAll fail with only the stream lookup removed (Expected: <1> but was: <0> routed). With the change all pass, and the message acknowledged before the deletion is not routed again.
  • New NatsConsumerPullConsumerGoneTest (3 tests, same fake server): durableConsumerIsNotCreatedAgain (a deleted durable: one WARN, no new consumer past the next consumer check, and messages again once the durable is back; while it is gone at most one fetch per pullFetchTimeout: without the wait the test counted 145357735 fetches in 6 s), failedSubscribeIsTriedAgainAtTheNextCheck (the first subscribe after the deletion throws IllegalStateException: it goes to the exception handler, and the next check subscribes), startTimeIsNotSentWithTheStartSequence (a ByStartTime configuration is sent again as ByStartSequence without a start time). All three fail with the consumer of the first commit of this PR (no WARN, Expected: <1> but was: <0> routed, start time still set). Three more: idleHealthyConsumerIsNotAskedForItsInfo (no consumer info request in 6 s of idle fetches; expected: <0> but was: <1> with a check after every empty fetch), otherErrorStatusIsNotFetchedAgainBackToBack (409 Consumer is push based on every fetch: without the wait 83418 fetches in 1 s), ackPolicyNoneResumesAfterTheHighestFetched (manualAck=true&ackPolicy=None, the route never acks: the new consumer starts at sequence 3, not at the first message ever fetched; Expected: <order-3> but was: <order-1> with tracking).
  • New NatsJetstreamConsumerPullDeletedIT (real server in a container, runs in CI): deletes the ephemeral consumer with jsm.deleteConsumer while the route is busy with a message (no pull request waiting, so no 409), and expects the next message to be routed.
  • With the change the camel-nats unit tests pass: 16 run, 0 failures.

How it was found: deterministic simulation testing (the camel-test-dst harness of CAMEL-25738). The world runs the real consumer against a fake JetStream server with connection outages and restarts, ephemeral and durable consumers. 274 of 1000 seeds left published messages unrouted on main, all with an ephemeral consumer deleted after an outage or a restart, and 0 of 1000 with this change. The server rule is the one of nats-server consumer.go (JsDeleteWaitTimeDefault), the client behaviour that of jnats 2.26.4.

What was not run: a real NATS server (the integration tests need a container; the new IT was compiled, not run), so that nats-server rejects a start time together with ByStartSequence or a non-All deliver policy on a WorkQueue stream is not checked here; clearing it is harmless either way (jnats leaves a null start time out of the request); the full root build.

Target

  • I checked that the commit is targeting the correct branch (Camel 4 uses the main branch)

Tracking

  • If this is a large change, bug fix, or code improvement, I checked there is a JIRA issue filed for the change (usually before you start working on it): CAMEL-25815.

Apache Camel coding standards and style

  • I checked that each commit in the pull request has a meaningful subject line and body.
  • I have run mvn clean install -DskipTests locally from root folder and I have committed all auto-generated changes.
    (I built and tested camel-nats. No option and no component page changed. I did not run the full root build.)

AI-assisted contributions

  • If this PR includes AI-generated code, commits have proper co-authorship attribution (e.g., Co-authored-by trailers) and the PR description identifies the AI tool used.
    This PR was prepared with Claude Code (Claude Opus 5.5). Each commit carries a Co-Authored-By trailer.

Claude Code on behalf of allthingssecurity

🤖 Generated with Claude Code

…n 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 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🌟 Thank you for your contribution to the Apache Camel project! 🌟
🤖 CI automation will test this PR automatically.

🐫 Apache Camel Committers, please review the following items:

  • First-time contributors require MANUAL approval for the GitHub Actions to run
  • You can use the command /component-test (camel-)component-name1 (camel-)component-name2.. to request a test from the test bot although they are normally detected and executed by CI.
  • You can label PRs using skip-tests and test-dependents to fine-tune the checks executed by this PR.
  • Build and test logs are available in the summary page. Only Apache Camel committers have access to the summary.

⚠️ Be careful when sharing logs. Review their contents before sharing them publicly.

@davsclaus davsclaus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Shashank. The bug is real for the ephemeral case. jnats 2.26.4 gives a legacy pull subscription no new consumer. PullMessageManager turns 409 Consumer Deleted into a JetStreamStatusException, and after that every fetch ends empty. runPullFetchLoop on main just continues, so the route stays started and consumes nothing. The fix checks the consumer with getConsumerInfo() after an empty fetch (at most every 5 s) and also reacts to the 409. It then creates the ephemeral consumer again from the first stream sequence the route has not acknowledged. Tracking that sequence through the ack, the manual ack header and term looks right. The fake server test would fail on main and also shows the acknowledged message is not routed again. A few points, the first one is the one I would like settled before merge.

  1. Blocking: a durable consumer is also created again, from its own configuration. With the default DeliverPolicy.All the route then consumes the whole stream again. A durable does not go away on its own after a short disconnect or a restart of a file-backed stream. It goes away when an administrator deletes it, or when the user set an inactiveThreshold. In both cases creating it again and replaying the stream is a big surprise, and with only a WARN to show for it. I would only subscribe again for an ephemeral consumer. For a durable, log a WARN when the check finds it gone (so it is no longer silent) and keep the subscription: the fetches work again if the durable is created again with the same name. The upgrade guide entry would then only need the ephemeral part.

  2. Not blocking, but worth fixing: in the empty-fetch path subscribeAgain is not wrapped like in the 409 path. jnats reports some subscribe failures as IllegalStateException, for example No matching streams for subject (SUB-90007) when the stream itself is gone, such as a memory-storage stream after a server restart. That lands in the catch (IllegalStateException e) branch, which treats it as "subscription closed" and ends the fetch loop with only a DEBUG log. A small helper used by both paths would report the failure through the exception handler and try again at the next check.

  3. Optional: when the user's consumerConfiguration uses DeliverPolicy.ByStartTime, the copied configuration keeps its start time next to ByStartSequence. I believe nats-server rejects that combination, so every attempt to subscribe again would fail. Clearing the start time avoids it (see the inline suggestion).

  4. Optional: a sequence that is never acknowledged stays in unacknowledged until the next resubscribe. Examples are a message nak'd until maxDeliver is used up, or manualAck with ackPolicy=None where the route never calls ack. A new ephemeral consumer then starts at that old sequence and the route sees everything after it again. That is still at-least-once, so fine, but a line in the javadoc of subscribeAgain would help the next reader.

  5. Optional: if the route is stopped while subscribeAgain runs, doStop can unsubscribe the old subscription before the new one is assigned to jetStreamSubscription. The new subscription then stays open on a shared connection. Checking isRunAllowed() after the subscribe and unsubscribing would cover it. Also, the one-argument DefaultNatsManualAck constructor is now unused, and the comment in the JetStreamStatusException catch ("so the consumer can recover when the consumer state is restored") could now say what the code does.

Claude Code on behalf of davsclaus. This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.

… 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 <noreply@anthropic.com>
@allthingssecurity

Copy link
Copy Markdown
Contributor Author

Thanks Claus.

  1. Done in 3ac611e: only an ephemeral consumer is created again. When a durable is found gone (409 or the consumer check), the consumer logs a WARN once and keeps the subscription, so the fetches work again when the durable is created again with the same name. The upgrade guide entry now covers the ephemeral consumer and says a durable is not created again. New test with a deleted durable: one WARN, no new consumer, messages again after it is created again.
  2. Done in 3ac611e: the empty-fetch path and the 409 path use one helper, which reports a failed subscribe through the exception handler and tries again at the next check instead of ending the loop. Test with a subscribe that throws IllegalStateException once.
  3. Done in 3ac611e: .startTime(null). jnats leaves a null start time out of the request, so it is harmless in any case. I could not check the server side here.
  4. Done in 3ac611e: javadoc on subscribeAgain.
  5. Done in 3ac611e: the new subscription is unsubscribed if the route is stopping (the field is volatile now), the one-argument DefaultNatsManualAck constructor is removed, and the comment says what the code does.

Unit tests: 10 run, 0 failures.

Claude Code on behalf of allthingssecurity

allthingssecurity added a commit to allthingssecurity/camel that referenced this pull request Oct 21, 2026
…n when the server deleted its ephemeral consumer

With pullSubscription=false and no durableName the JetStream consumer
subscribes with an ephemeral push consumer. The NATS server deletes it
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, so the route stayed started and received
nothing, without any log.

After every reconnect (RESUBSCRIBED) the consumer now asks the server
for its consumer and, when its ephemeral consumer is gone (404),
subscribes again, starting at the first message 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. The
new consumer is sent without a start time (a ByStartTime configuration
would otherwise carry a start time next to the start sequence, which
the server rejects).

A durable consumer is not created again: it is gone only when an
administrator deleted it or after its inactiveThreshold, and creating it
again from its configuration would replay the stream from its deliver
policy (the whole stream with the default DeliverPolicy.All). The keeper
logs a WARN and keeps the subscription. This follows Claus's review of
the pull twin (CAMEL-25815, apache#28204).

A failed subscribe (jnats throws IllegalStateException for some, such as
SUB-90007 when the stream is gone) goes to the exception handler and is
tried again a second later, up to five attempts per reconnect. A
subscription made while the consumer was being stopped is ended again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

🧪 CI tested the following changed modules:

  • components/camel-nats
  • docs

🔁 1 test passed only after a retry on JDK 25 (1 retried attempt)

Recovered flaky tests on JDK 25 (1)
Module Test Failed attempts First failure
dsl/camel-jbang/camel-jbang-plugin-tui LaunchManagerOutcomeTest.startedWhenTheStatusFileSaysSo 1 expected: null but was: LaunchOutcome[ok=false, log=null]

ℹ️ These tests did not fail the build. Surefire retried them and they passed.
Retries are enabled project-wide by surefire.rerunFailingTestsCount in parent/pom.xml.


🔬 Scalpel shadow comparison — Scalpel: 8 of 707 tested, 26 compile-only — current: 8 all tested

Maveniverse Scalpel detected 8 affected modules (current approach: 8).

Skip-tests mode would test 8 modules (2 direct + 7 downstream), skip tests for 26 (generated code, meta-modules)

Modules Scalpel would test (8)
  • camel-jbang-mcp ← downstream of org.apache.camel:camel-catalog
  • camel-jbang-plugin-mcp ← downstream of org.apache.camel:camel-jbang-core
  • camel-jbang-plugin-tui ← downstream of org.apache.camel:camel-catalog
  • camel-jbang-plugin-validate ← downstream of org.apache.camel:camel-yaml-dsl-validator
  • camel-launcher-container ← downstream of org.apache.camel:camel-launcher
  • camel-nats ← components/camel-nats/src/main/java/org/apache/camel/component/nats/DefaultNatsManualAck.java, components/camel-nats/src/main/java/org/apache/camel/component/nats/NatsConsumer.java, components/camel-nats/src/test/java/org/apache/camel/component/nats/NatsConsumerPullConsumerDeletedTest.java, components/camel-nats/src/test/java/org/apache/camel/component/nats/NatsConsumerPullConsumerGoneTest.java, components/camel-nats/src/test/java/org/apache/camel/component/nats/jetstream/NatsJetstreamConsumerPullDeletedIT.java
  • camel-yaml-dsl-validator ← downstream of org.apache.camel:camel-catalog
  • camel-yaml-dsl-validator-maven-plugin ← downstream of org.apache.camel:camel-yaml-dsl-validator
Modules with tests skipped (26)
  • apache-camel
  • camel-allcomponents
  • camel-catalog
  • camel-catalog-console
  • camel-catalog-maven
  • camel-catalog-suggest
  • camel-componentdsl
  • camel-endpointdsl
  • camel-endpointdsl-support
  • camel-itest
  • camel-jbang-core
  • camel-jbang-it
  • camel-jbang-main
  • camel-jbang-plugin-edit
  • camel-jbang-plugin-generate
  • camel-jbang-plugin-kubernetes
  • camel-jbang-plugin-test
  • camel-kamelet-main
  • camel-launcher
  • camel-report-maven-plugin
  • camel-route-parser
  • camel-yaml-dsl
  • camel-yaml-dsl-deserializers
  • camel-yaml-dsl-maven-plugin
  • coverage
  • dummy-component

ℹ️ Shadow mode — Scalpel observes but does not affect test execution. Learn more

All tested modules (35 modules, 5m 26s total)

Total reactor time: 5m 26s

Module Duration Status
Camel :: Launcher 49.9s SUCCESS
Camel :: JBang :: MCP 41.3s SUCCESS
Camel :: JBang :: Plugin :: Validate 28.5s SUCCESS
Camel :: Component DSL 26.3s SUCCESS
Camel :: YAML DSL 25.5s SUCCESS
Camel :: Catalog :: Camel Catalog 23.0s SUCCESS
Camel :: YAML DSL :: Validator 21.3s SUCCESS
Camel :: Kamelet Main 17.2s SUCCESS
Camel :: Docs 15.2s SUCCESS
Camel :: JBang :: Plugin :: Testing 12.6s SUCCESS
Camel :: JBang :: Plugin :: Kubernetes 11.7s SUCCESS
Camel :: YAML DSL :: Deserializers 8.2s SUCCESS
Camel :: Catalog :: Camel Route Parser 7.7s SUCCESS
Camel :: Catalog :: Camel Report Maven Plugin 7.2s SUCCESS
Camel :: All Components Sync point 5.1s SUCCESS
Camel :: YAML DSL :: Validator Maven Plugin 4.4s SUCCESS
Camel :: YAML DSL :: Maven Plugins 3.5s SUCCESS
Camel :: Catalog :: Maven 2.8s SUCCESS
Camel :: Catalog :: Suggest (deprecated) 2.3s SUCCESS
Camel :: Coverage 1.7s SUCCESS
Camel :: JBang :: Plugin :: Edit 1.4s SUCCESS
Camel :: Assembly 1.2s SUCCESS
Camel :: JBang :: Plugin :: Generate 1.2s SUCCESS
Camel :: Catalog :: Dummy Component 1.1s SUCCESS
Camel :: Catalog :: Console 1.0s SUCCESS
Camel :: JBang :: Integration tests 1.0s SUCCESS
Camel :: JBang :: Main 0.9s SUCCESS
Camel :: JBang :: Plugin :: MCP 0.9s SUCCESS
Camel :: Endpoint DSL :: Support 0.8s SUCCESS
Camel :: Launcher :: Container 0.7s SUCCESS
Camel :: Endpoint DSL n/a
Camel :: Integration Tests n/a
Camel :: JBang :: Core n/a
Camel :: JBang :: Plugin :: TUI n/a
Camel :: Nats n/a

Top 20 slowest modules:

  • Camel :: Launcher (49.9s)
  • Camel :: JBang :: MCP (41.3s)
  • Camel :: JBang :: Plugin :: Validate (28.5s)
  • Camel :: Component DSL (26.3s)
  • Camel :: YAML DSL (25.5s)
  • Camel :: Catalog :: Camel Catalog (23.0s)
  • Camel :: YAML DSL :: Validator (21.3s)
  • Camel :: Kamelet Main (17.2s)
  • Camel :: Docs (15.2s)
  • Camel :: JBang :: Plugin :: Testing (12.6s)
  • Camel :: JBang :: Plugin :: Kubernetes (11.7s)
  • Camel :: YAML DSL :: Deserializers (8.2s)
  • Camel :: Catalog :: Camel Route Parser (7.7s)
  • Camel :: Catalog :: Camel Report Maven Plugin (7.2s)
  • Camel :: All Components Sync point (5.1s)
  • Camel :: YAML DSL :: Validator Maven Plugin (4.4s)
  • Camel :: YAML DSL :: Maven Plugins (3.5s)
  • Camel :: Catalog :: Maven (2.8s)
  • Camel :: Catalog :: Suggest (deprecated) (2.3s)
  • Camel :: Coverage (1.7s)

⚙️ View full build and test results

@davsclaus davsclaus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, all the points of my earlier review are addressed: a durable consumer is no longer created again (one WARN, and the subscription is kept), a failed subscribe goes to the exception handler and is tried again at the next check, the start time is cleared together with ByStartSequence, the stop race is closed with the isRunAllowed() check after the assignment, and the upgrade guide matches. The new tests cover each case.

One non-blocking remark inline. LGTM once the build jobs are green.

Claude Code on behalf of davsclaus. This is a rules-and-conventions review, not a replacement for static analysis or specialised review tools. This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.

…tch 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 <noreply@anthropic.com>
@allthingssecurity

Copy link
Copy Markdown
Contributor Author

Thanks Claus. Done in 939b4a5: after an empty fetch that ended before pullFetchTimeout, the loop waits for the rest of the timeout; a stop interrupts the wait. I checked jnats 2.26.4: PullMessageManager.manageStatus ends the fetch on 503, as you said. The durable test now counts the fetches while the durable is gone; without the wait it counted about 145 million in 6 seconds, now at most one per pullFetchTimeout.

Claude Code on behalf of allthingssecurity

@davsclaus davsclaus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Shashank, my inline remark is addressed. After an empty fetch that ended before pullFetchTimeout, runPullFetchLoop now waits for the rest of the timeout, so a gone consumer (the 503 case) no longer makes the loop send pull requests back to back. In the normal case the fetch already took its timeout and there is no wait. doStop uses shutdownNow, so the wait is interrupted on stop, and the new InterruptedException catch ends the loop. The fetch count in NatsConsumerPullConsumerGoneTest only has an upper bound, so a slow CI machine cannot make it flaky.

LGTM once the build jobs are green.

Claude Code on behalf of davsclaus. This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.

@oscerd oscerd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TL;DR

Resuming an ephemeral consumer from the first unacknowledged sequence and leaving a durable alone is the right approach. Two things need fixing before merge. With the real jnats client the consumer check never sees a deleted consumer, so the main case of this PR (an ephemeral consumer deleted during a disconnect, or lost on a restart) is still not recovered. And the resume configuration is rejected on WorkQueue streams and skips messages of a stream that was created again.

Blockers

  • NatsConsumer.java:353-359: jnats 2.26.4 returns null from getConsumerInfo() for a deleted consumer instead of throwing, so consumerGone reports it as present.
  • NatsConsumer.java:408-416: the ByStartSequence resume does not look at the stream (WorkQueue retention, stream created again).

Other findings: 3 (2 non-blocking, 1 suggestion)

  1. Blocker, the consumer check (NatsConsumer.java:353-359): NatsJetStreamSubscription.getConsumerInfo() returns js.lookupConsumerInfo(stream, consumerName) (NatsJetStreamSubscription.java:279-281). That method returns null when the server answers consumer not found (api error code 10014, or a 404 whose description mentions "consumer", NatsJetStreamImpl.java:227-228), and nats-server answers exactly that for a deleted consumer. consumerGone ignores the return value and only reacts to a JetStreamApiException, which now only comes for stream not found (10059). After an inactivity deletion or a restart no pull request is waiting, so no 409 arrives either, and the route stays idle as on main. The tests pass because the fake's consumerInfo throws a 404 for a deleted consumer and returns null for a live one, the reverse of jnats. Treating getConsumerInfo() == null as gone, and letting the fake return null like jnats does, fixes it. CI runs the camel-nats JetStream ITs against the container, so an IT that deletes the consumer with jsm.deleteConsumer(...) while the route is busy on a message (no pull request waiting) would cover the real client.
  2. Blocker, subscribeAgain (NatsConsumer.java:408-416), checked against nats-server 2.15.1, the test-infra image:
    • WorkQueue retention: a work-queue stream refuses any deliver policy other than all (consumer.go:1187, JSConsumerWQConsumerNotDeliverAllErr, 400/10101). Once a message has been fetched, every resubscribe fails and the exception handler reports it every 5 s. The original cc is the right configuration here: acknowledged messages are already removed, so DeliverPolicy.All replays nothing.
    • A stream created again: its sequences start again at 1, but the consumer is created at the old start. This covers the memory stream after a restart that failedSubscribeIsTriedAgainAtTheNextCheck describes, and a stream an administrator re-created. selectStartingSeqNo adjusts the start only when OptStartSeq is 0 (consumer.go:6381), so a start past the new last sequence is kept, and every message of the new stream below start is skipped without an error; in that test order-2 would be sequence 1 and never be routed.
    • Possible fix: look up the stream first (jsm.getStreamInfo(...)). Keep cc for RetentionPolicy.WorkQueue. When getCreateTime() differs from the one seen at the first subscribe, clear unacknowledged/highestFetched and use cc. The fake server could model both cases.
  3. Non-blocking (NatsConsumer.java:288, :315-322): the check runs after any empty fetch once 5 s have passed, so every idle, healthy pull consumer, durable or not, now sends $JS.API.CONSUMER.INFO about every 5 s. A gone consumer makes jnats end the fetch at once (503 is STATUS_TERMINUS in PullMessageManager.manageStatus). Checking only after an empty fetch that ended well before pullFetchTimeout would therefore spare healthy consumers; otherwise the upgrade note could mention the traffic. The JetStreamStatusException branch also still fetches again without a pause for other error statuses (for example 409 Consumer is push based); the new wait would help there too.
  4. Non-blocking (NatsConsumer.java:440-446): doStart warns that ack has no effect with manualAck=true and ackPolicy=None, so such a route may never call ack(). unacknowledged then grows for the life of the route, and a resubscribe starts at the first message ever fetched. With AckPolicy.None delivery is the acknowledgement, so highestFetched + 1 is the right start; not tracking sequences for that policy removes both problems.
  5. Suggestion (NatsConsumerPullConsumerDeletedTest.java:86, :102): assertIsSatisfied(20_000) applies its timeout only when no message is expected (the parameter is timeoutForEmptyEndpoints), so the wait stays at the default 10 s. mock.setResultWaitTime(20_000), as in NatsConsumerPullConsumerGoneTest, gives the intended margin.

Also checked: nats-server rejects a start time together with by_start_sequence, so .startTime(null) is required, not only harmless.

Reviewed with Claude Code on behalf of oscerd. This review was generated by an AI agent and may contain inaccuracies; please verify all suggestions before applying.

…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 <noreply@anthropic.com>

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All four concerns from oscerd's review and all five from davsclaus's earlier review are addressed in the latest commit. The approach is correct.

Addressed (f946dae):

  • consumerGone() now treats null from getConsumerInfo() as gone — the key fix (jnats returns null for api error 10014, not a 404 exception)
  • subscribeAgain() looks up the stream first: WorkQueue retention uses DeliverPolicy.All, stream-recreated clears tracked sequences
  • endedEarly gate prevents getConsumerInfo calls on healthy idle consumers
  • trackUnacknowledged=false for AckPolicy.None — resume starts from highestFetched + 1
  • setResultWaitTime(20_000) instead of assertIsSatisfied(20_000) — test timeout now applies unconditionally

One minor gap: The catch (Exception e) in runPullFetchLoop (pre-existing) does not call waitRestOfFetch() after an unexpected exception — so a transient error could cause back-to-back retries until jnats reconnects. The PR correctly improved this for JetStreamStatusException, but the generic fallback still doesn't wait. Consider adding a waitRestOfFetch call there as well.

Backport: This is a bug fix on main — if the bug affects camel-4.23.x/camel-4.22.x/camel-4.18.x (which it does — same NatsConsumer.java was in those branches), add port/camel-4.23.x, port/camel-4.22.x, and port/camel-4.18.x labels so the backport bot picks it up.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

allthingssecurity added a commit to allthingssecurity/camel that referenced this pull request Oct 21, 2026
…n when the server deleted its ephemeral consumer

With pullSubscription=false and no durableName the JetStream consumer
subscribes with an ephemeral push consumer. The NATS server deletes it
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, so the route stayed started and received
nothing, without any log.

After every reconnect (RESUBSCRIBED) the consumer now asks the server
for its consumer and, when its ephemeral consumer is gone, subscribes
again. jnats 2.26.4 answers getConsumerInfo() with null when the server
has no such consumer (lookupConsumerInfo maps api error 10014 to null),
so null counts as gone, as does a 404 error. The new consumer starts at
the first message 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. With AckPolicy.None the delivery
is the acknowledgement: no message is tracked, and the new consumer
starts after the last one delivered. The new consumer is sent without a
start time (a ByStartTime configuration would otherwise carry a start
time next to the start sequence, which the server rejects).

Before subscribing again the consumer looks the stream up. A stream
with WorkQueue retention accepts only the deliver policy All (error
10101 otherwise) 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, a start sequence of the old stream would
skip its messages): the tracked messages are cleared and the consumer
configuration is used. This follows the review of the pull twin
(CAMEL-25815, apache#28204).

A durable consumer is not created again: it is gone only when an
administrator deleted it or after its inactiveThreshold, and creating it
again from its configuration would replay the stream from its deliver
policy (the whole stream with the default DeliverPolicy.All). The keeper
logs a WARN and keeps the subscription.

A failed subscribe (jnats throws IllegalStateException for some, such as
SUB-90007 when the stream is gone) goes to the exception handler and is
tried again a second later, up to five attempts per reconnect. A
subscription made while the consumer was being stopped is ended again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@allthingssecurity

Copy link
Copy Markdown
Contributor Author

Thanks Andrea.

  1. Confirmed in jnats 2.26.4: lookupConsumerInfo maps 10014 to null, so consumerGone now treats null as gone, and the fake answers like jnats (null for a deleted consumer, the info for a live one); with that fake the previous commit no longer recovers.
  2. subscribeAgain looks the stream up first: on a WorkQueue stream it uses the configuration as is, and when the create time differs from the one seen at the subscribe it clears the tracked sequences and uses the configuration as is. The fake now models both, with a test each.
  3. The check runs only after an empty fetch that ended before half of pullFetchTimeout, so an idle healthy consumer sends no CONSUMER.INFO; other error statuses now wait for the rest of the fetch timeout.
  4. With AckPolicy.None nothing is tracked, the new consumer starts after the highest fetched sequence.
  5. Both use setResultWaitTime(20_000) now. I also added NatsJetstreamConsumerPullDeletedIT, which deletes the consumer with jsm.deleteConsumer while the route is busy; I could not run it here (no Docker), CI will (f946dae).

Claude Code on behalf of allthingssecurity

…tch timeout after an unexpected fetch failure too

From gnodet-bot's review of apache#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 <noreply@anthropic.com>
@allthingssecurity

Copy link
Copy Markdown
Contributor Author

On the bot's minor gap: done in abb0d97. The generic catch (Exception e) of runPullFetchLoop now also waits for the rest of pullFetchTimeout, as the error statuses do. New unexpectedFetchFailureIsNotFetchedAgainBackToBack (every fetch throws a RuntimeException): without the wait the loop made 96,635 fetches in one second. camel-nats unit tests: 16 run, 0 failures.

Backport labels are up to the committers; the same NatsConsumer pull loop is on 4.23.x, 4.22.x and 4.18.x.

Claude Code on behalf of allthingssecurity

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My earlier remark is addressed. LGTM.

The catch (Exception e) in runPullFetchLoop now calls waitRestOfFetch, consistent with how JetStreamStatusException non-CONSUMER_DELETED statuses are handled. InterruptedException is properly propagated. The new test unexpectedFetchFailureIsNotFetchedAgainBackToBack validates this directly: without the wait it recorded 96,635 fetches in one second; the bound (waitedMillis / 100 + 5) would have been exceeded immediately.

One note for committers: the same NatsConsumer pull loop exists on camel-4.23.x, camel-4.22.x, and camel-4.18.x. Adding port/camel-4.23.x, port/camel-4.22.x, and port/camel-4.18.x labels would let the backport bot pick it up.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

@davsclaus davsclaus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Shashank. f946dae and abb0d97 address Andrea's blockers and the bot's remark, and the code reads right to me:

  • consumerGone now treats a null consumer info as gone, as jnats returns for 10014, and the fake answers the same way. Because lastConsumerCheck only moves when a check runs, the first early-ended fetch after a deletion checks at once.
  • subscribeAgain keeps the original configuration on a WorkQueue stream and on a stream that was created again (create time compared with isEqual), and clears the tracked sequences in the second case.
  • With the endedEarly gate, idle healthy consumers send no CONSUMER.INFO. Other error statuses and unexpected failures now wait for the rest of pullFetchTimeout, and the waits end the loop on interrupt.
  • With AckPolicy.None, the resume starts after the highest fetched sequence. The upgrade guide matches.

One small thing inline: the two new back-to-back tests use Thread.sleep. Not a correctness issue.

LGTM once the build jobs are green.

This review was generated by an AI agent (Claude Code on behalf of davsclaus) and may contain inaccuracies. Please verify all suggestions before applying.

…-to-back fetch tests

Both tests now check the bound with Awaitility during the whole second.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@allthingssecurity

Copy link
Copy Markdown
Contributor Author

Thanks Claus, done in 72628e0: both back-to-back tests now check the bound with await().during(1, SECONDS) over the whole second, without Thread.sleep.

Claude Code on behalf of allthingssecurity

@davsclaus davsclaus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Shashank, 72628e0 replaces both Thread.sleep(1000) calls with the during pattern, which also checks the bound over the whole second. LGTM once CI is green.

This review was generated by an AI agent (Claude Code on behalf of davsclaus) and may contain inaccuracies. Please verify all suggestions before applying.

allthingssecurity added a commit to allthingssecurity/camel that referenced this pull request Oct 22, 2026
…n when the server deleted its ephemeral consumer

With pullSubscription=false and no durableName the JetStream consumer
subscribes with an ephemeral push consumer. The NATS server deletes it
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, so the route stayed started and received
nothing, without any log.

After every reconnect (RESUBSCRIBED) the consumer now asks the server
for its consumer and, when its ephemeral consumer is gone, subscribes
again. jnats (2.26.3) answers getConsumerInfo() with null when the server
has no such consumer (lookupConsumerInfo maps api error 10014 to null),
so null counts as gone, as does a 404 error. The new consumer starts at
the first message 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. With AckPolicy.None the delivery
is the acknowledgement: no message is tracked, and the new consumer
starts after the last one delivered. A message delivered maxDeliver
times (when set) counts as settled, as the server does not deliver it
again, so a poison message does not hold the resume point back. When
nothing was delivered yet, a consumer with the deliver policy New (or
Last) starts after (or at) the last stream sequence seen at the
subscribe, so the messages published meanwhile are not skipped. The new consumer is sent without a
start time (a ByStartTime configuration would otherwise carry a start
time next to the start sequence, which the server rejects).

Before subscribing again the consumer looks the stream up. A stream
with WorkQueue retention accepts only the deliver policy All (error
10101 otherwise) 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, a start sequence of the old stream would
skip its messages): the tracked messages are cleared and the consumer
configuration is used. This follows the review of the pull twin
(CAMEL-25815, apache#28204).

A durable consumer is not created again: it is gone only when an
administrator deleted it or after its inactiveThreshold, and creating it
again from its configuration would replay the stream from its deliver
policy (the whole stream with the default DeliverPolicy.All). The keeper
logs a WARN and keeps the subscription.

A failed check or subscribe is tried again while the route runs: five
attempts a second apart, then with a delay that doubles up to 30
seconds, with a WARN after the fifth attempt (JetStream not ready yet
after a server restart, or a stream that is gone until it is created
again). The first failed subscribe of a reconnect (jnats throws
IllegalStateException for some, such as SUB-90007 when the stream is
gone) goes to the exception handler, the next ones are logged at DEBUG.
A reconnect does not queue a second check while one waits. A
subscription made while the consumer was being stopped is ended again,
and a subscribe that fails because the consumer stops is not reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
allthingssecurity added a commit to allthingssecurity/camel that referenced this pull request Oct 22, 2026
…n when the server deleted its ephemeral consumer

With pullSubscription=false and no durableName the JetStream consumer
subscribes with an ephemeral push consumer. The NATS server deletes it
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, so the route stayed started and received
nothing, without any log.

After every reconnect (RESUBSCRIBED) the consumer now asks the server
for its consumer and, when its ephemeral consumer is gone, subscribes
again. jnats (2.26.3) answers getConsumerInfo() with null when the server
has no such consumer (lookupConsumerInfo maps api error 10014 to null),
so null counts as gone, as does a 404 error. The new consumer starts at
the first message 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. With AckPolicy.None the delivery
is the acknowledgement: no message is tracked, and the new consumer
starts after the last one delivered. A message delivered maxDeliver
times (when set) counts as settled, as the server does not deliver it
again, so a poison message does not hold the resume point back. When
nothing was delivered yet, a consumer with the deliver policy New (or
Last) starts after (or at) the last stream sequence seen at the
subscribe, so the messages published meanwhile are not skipped. The new consumer is sent without a
start time (a ByStartTime configuration would otherwise carry a start
time next to the start sequence, which the server rejects).

Before subscribing again the consumer looks the stream up. A stream
with WorkQueue retention accepts only the deliver policy All (error
10101 otherwise) 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, a start sequence of the old stream would
skip its messages): the tracked messages are cleared and the consumer
configuration is used. This follows the review of the pull twin
(CAMEL-25815, apache#28204).

A durable consumer is not created again: it is gone only when an
administrator deleted it or after its inactiveThreshold, and creating it
again from its configuration would replay the stream from its deliver
policy (the whole stream with the default DeliverPolicy.All). The keeper
logs a WARN and keeps the subscription.

A failed check or subscribe is tried again while the route runs: five
attempts a second apart, then with a delay that doubles up to 30
seconds, with a WARN after the fifth attempt (JetStream not ready yet
after a server restart, or a stream that is gone until it is created
again). The first failed subscribe of a reconnect (jnats throws
IllegalStateException for some, such as SUB-90007 when the stream is
gone) goes to the exception handler, the next ones are logged at DEBUG.
A reconnect does not queue a second check while one waits. A
subscription made while the consumer was being stopped is ended again,
and a subscribe that fails because the consumer stops is not reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@gnodet-bot gnodet-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on my earlier review — previous findings are all addressed.

The new commit (72628e0) addresses my earlier note about Thread.sleep in the two back-to-back fetch tests. Both otherErrorStatusIsNotFetchedAgainBackToBack and unexpectedFetchFailureIsNotFetchedAgainBackToBack now use await().during(1, TimeUnit.SECONDS).atMost(2, TimeUnit.SECONDS).untilAsserted(...) instead of Thread.sleep(1000). The during pattern is strictly better: it verifies the bound holds continuously throughout the whole second, not just at the end — meaning a burst of fetches in the first 500 ms followed by silence would have passed with Thread.sleep but fails correctly with during. waitStarted is captured before the await() call (effectively final closure capture), and server.fetches() is thread-safe to call from the lambda. LGTM once CI is green.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

allthingssecurity added a commit to allthingssecurity/camel that referenced this pull request Oct 23, 2026
…n when the server deleted its ephemeral consumer

With pullSubscription=false and no durableName the JetStream consumer
subscribes with an ephemeral push consumer. The NATS server deletes it
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, so the route stayed started and received
nothing, without any log.

After every reconnect (RESUBSCRIBED) the consumer now asks the server
for its consumer and, when its ephemeral consumer is gone, subscribes
again. jnats (2.26.3) answers getConsumerInfo() with null when the server
has no such consumer (lookupConsumerInfo maps api error 10014 to null),
so null counts as gone, as does a 404 error. The new consumer starts at
the first message 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. With AckPolicy.None the delivery
is the acknowledgement: no message is tracked, and the new consumer
starts after the last one delivered. A message delivered maxDeliver
times (when set) counts as settled, as the server does not deliver it
again, so a poison message does not hold the resume point back. When
nothing was delivered yet, a consumer with the deliver policy New (or
Last) starts after (or at) the last stream sequence seen at the
subscribe, so the messages published meanwhile are not skipped. The new consumer is sent without a
start time (a ByStartTime configuration would otherwise carry a start
time next to the start sequence, which the server rejects).

Before subscribing again the consumer looks the stream up. A stream
with WorkQueue retention accepts only the deliver policy All (error
10101 otherwise) 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, a start sequence of the old stream would
skip its messages): the tracked messages are cleared and the consumer
configuration is used. This follows the review of the pull twin
(CAMEL-25815, apache#28204).

A durable consumer is not created again: it is gone only when an
administrator deleted it or after its inactiveThreshold, and creating it
again from its configuration would replay the stream from its deliver
policy (the whole stream with the default DeliverPolicy.All). The keeper
logs a WARN and keeps the subscription.

A failed check or subscribe is tried again while the route runs: five
attempts a second apart, then with a delay that doubles up to 30
seconds, with a WARN after the fifth attempt (JetStream not ready yet
after a server restart, or a stream that is gone until it is created
again). The first failed subscribe of a reconnect (jnats throws
IllegalStateException for some, such as SUB-90007 when the stream is
gone) goes to the exception handler, the next ones are logged at DEBUG.
A reconnect does not queue a second check while one waits. A
subscription made while the consumer was being stopped is ended again,
and a subscribe that fails because the consumer stops is not reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@allthingssecurity

Copy link
Copy Markdown
Contributor Author

@oscerd when you have a moment, could you take another look? Both blockers from your review are addressed in f946dae (details in my reply of 2026-10-21 11:28 UTC): a null consumer info now counts as gone, and the resume looks at the stream first (WorkQueue retention and a re-created stream use the consumer configuration as it is). Claus has approved since. The push twin #28318 follows the same rules.

Claude Code on behalf of allthingssecurity

@davsclaus
davsclaus merged commit 9aee81d into apache:main Oct 23, 2026
7 checks passed
@allthingssecurity
allthingssecurity deleted the camel-nats-jetstream-pull-resubscribe branch October 23, 2026 16:38
allthingssecurity added a commit to allthingssecurity/camel that referenced this pull request Oct 23, 2026
…n when the server deleted its ephemeral consumer

With pullSubscription=false and no durableName the JetStream consumer
subscribes with an ephemeral push consumer. The NATS server deletes it
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, so the route stayed started and received
nothing, without any log.

After every reconnect (RESUBSCRIBED) the consumer now asks the server
for its consumer and, when its ephemeral consumer is gone, subscribes
again. jnats (2.26.3) answers getConsumerInfo() with null when the server
has no such consumer (lookupConsumerInfo maps api error 10014 to null),
so null counts as gone, as does a 404 error. The new consumer starts at
the first message 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. With AckPolicy.None the delivery
is the acknowledgement: no message is tracked, and the new consumer
starts after the last one delivered. A message delivered maxDeliver
times (when set) counts as settled, as the server does not deliver it
again, so a poison message does not hold the resume point back. When
nothing was delivered yet, a consumer with the deliver policy New (or
Last) starts after (or at) the last stream sequence seen at the
subscribe, so the messages published meanwhile are not skipped. The new consumer is sent without a
start time (a ByStartTime configuration would otherwise carry a start
time next to the start sequence, which the server rejects).

Before subscribing again the consumer looks the stream up. A stream
with WorkQueue retention accepts only the deliver policy All (error
10101 otherwise) 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, a start sequence of the old stream would
skip its messages): the tracked messages are cleared and the consumer
configuration is used. This follows the review of the pull twin
(CAMEL-25815, apache#28204).

A durable consumer is not created again: it is gone only when an
administrator deleted it or after its inactiveThreshold, and creating it
again from its configuration would replay the stream from its deliver
policy (the whole stream with the default DeliverPolicy.All). The keeper
logs a WARN and keeps the subscription.

A failed check or subscribe is tried again while the route runs: five
attempts a second apart, then with a delay that doubles up to 30
seconds, with a WARN after the fifth attempt (JetStream not ready yet
after a server restart, or a stream that is gone until it is created
again). The first failed subscribe of a reconnect (jnats throws
IllegalStateException for some, such as SUB-90007 when the stream is
gone) goes to the exception handler, the next ones are logged at DEBUG.
A reconnect does not queue a second check while one waits. A
subscription made while the consumer was being stopped is ended again,
and a subscribe that fails because the consumer stops is not reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants