Skip to content

CAMEL-25847: camel-nats - a stopped JetStream push consumer unsubscribes through its dispatcher - #28320

Merged
davsclaus merged 4 commits into
apache:mainfrom
allthingssecurity:camel-nats-jetstream-push-consumer-stop-unsubscribes
Oct 25, 2026
Merged

davsclaus merged 4 commits into
apache:mainfrom
allthingssecurity:camel-nats-jetstream-push-consumer-stop-unsubscribes

Conversation

@allthingssecurity

@allthingssecurity allthingssecurity commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Description

CAMEL-25847

A JetStream push subscription (jetstreamEnabled=true, pullSubscription=false) belongs to the dispatcher the consumer creates. On stop the consumer unsubscribed the topic from that dispatcher (the push subscription is on its deliver subject, not on the topic) and then called unsubscribe() on the subscription, which jnats refuses for a subscription of a dispatcher ("cannot respond to unsubscribe directly"), so only a WARN was logged. The dispatcher was never closed.

When the consumer owns its connection, closing the connection ends everything. With a shared connection (the connection option) the subscription stayed active: the stopped route kept getting messages, which failed and were nak'ed and delivered again at once, and after the route was started again every message was routed twice. In plain NATS mode the dispatcher thread of every stopped consumer was left behind.

Change (NatsConsumer.doStop):

  • End the push subscription through its dispatcher (dispatcher.unsubscribe(jetStreamSubscription), which jnats accepts for a subscription of that dispatcher). This does not touch the dispatcher thread.
  • Close the dispatcher the consumer created (connection.closeDispatcher, while the connection is open), which ends its thread, once no message is in flight on it. jnats closes a dispatcher with NatsDispatcher.stop(true), which cancels its thread with Future.cancel(true), an interrupt (javap, jnats 2.26.3), and that thread runs the route: Camel stops a consumer that is not Suspendable before it waits for the exchanges in flight, so closing it right away would interrupt the step in flight (a bean waiting on I/O, a delay). The message handler of the dispatcher (one per start) counts its messages in flight; when one is in flight at the stop, the dispatcher is closed when it is done (on that thread, after the message). The count is kept per dispatcher, not per consumer (review): a consumer that a route policy stops and starts in place while a message of the earlier run is in flight closes each dispatcher of its own run.
  • The existing unsubscribe calls are kept.
  • NatsConsumingTask.run logs at DEBUG, instead of reporting "Error during processing" to the exception handler (a WARN), when the consumer is no longer allowed to run (review). A stop while the task is still in jetStream().subscribe(...) closes the dispatcher that the subscribe uses, and jnats refuses it ("Dispatcher is closed"). Any other error is reported as before.

Note: Camel itself fails an exchange that is in flight when its consumer stops at its next step (RouteStoppingException, CAMEL-25502: DefaultConsumer.doStop stops the route's processors), on main as well. What the change keeps is that the step in progress is not interrupted.

Side effect: the dispatcher of a core NATS consumer (jetstreamEnabled=false) is now closed when the consumer stops with a shared connection; before, it was left behind with its thread.

What users can notice: stopping a route with a JetStream push consumer on a shared connection really stops it, and no WARN "Error during unsubscribing" is logged. With a shared connection, a stopped core NATS consumer no longer leaves its dispatcher thread behind. No option changed, so no upgrade guide entry.

Tests:

  • New NatsConsumerJetStreamPushStopTest with a fake JetStream server behind the jnats interfaces the consumer calls (like jnats, it refuses unsubscribe() on a subscription of a dispatcher, unsubscribes by subscription, and closing a dispatcher ends its subscriptions and interrupts the thread that delivers its messages; a closed dispatcher refuses to subscribe or unsubscribe). stoppedRouteGetsNoMessagesAndRestartedRouteGetsEachMessageOnce fails without the change: after a stop and start of the route one message is routed twice (Expected: <1> but was: <2>). With the change it passes, with no subscription left after the stop and the dispatcher of the first run closed.
  • stoppingTheRouteDoesNotInterruptTheExchangeInFlight (found in review): the route stops while a step waits on a latch; the step must not be interrupted and complete, and the dispatcher is closed afterwards. On the first version of this branch (closing the dispatcher in doStop) it fails with the step in flight must not be interrupted by the stop ==> expected: <false> but was: <true>.
  • eachRunOfTheConsumerClosesItsOwnDispatcher (found in review): the route's consumer is stopped and started in place (as a route policy does with a consumer that is not Suspendable) while a message of the first run is in flight, then stopped again: the second run's dispatcher is closed at once and the first one when its message is done. With the in-flight count on the consumer it fails with the dispatcher of the second run is closed when it stops ==> expected: <1> but was: <0>.
  • stopWhileSubscribingIsNotReportedAsAnError (found in review): the consumer stops from inside the subscribe of the fake server; nothing must reach the exception handler. Without the change it fails with reported: [java.lang.IllegalStateException: Dispatcher is closed].
  • With the change the camel-nats unit tests pass: 9 run, 0 failures. Merged with the CAMEL-25845 branch (CAMEL-25845: camel-nats - the JetStream push consumer subscribes again when the server deleted its ephemeral consumer #28318, rebased onto main after CAMEL-25815 merged): 33 run, 0 failures. Merge-tree clean with main (which now has CAMEL-25815) and with the CAMEL-25845 branch.

What was not run: a real NATS server (the integration tests need a container); 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-25847.

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

@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, good catch. On main, doStop cannot end a push subscription: dispatcher.unsubscribe(topic) misses it because it is on the deliver inbox, and jetStreamSubscription.unsubscribe() is refused by jnats (NatsSubscription.unsubscribe throws "Subscriptions that belong to a dispatcher cannot respond to unsubscribe directly"). With a shared connection, the stopped route therefore keeps getting messages. Unsubscribing through the dispatcher and closing it fixes that. Deferring the close until the in-flight message is done is a nice touch, because jnats NatsDispatcher.stop cancels its thread with Future.cancel(true), and that would interrupt the exchange. Both tests fail without the fix.

TL;DR

LGTM once CI is green (still running). One optional suggestion.

Blockers: None

Other findings: 1

Non-blocking

  1. The in-flight counter is per consumer, but there is one dispatcher per run. messagesInFlight and closeDispatcherWhenIdle belong to the consumer, while every start creates a new dispatcher and a new CamelNatsMessageHandler. Restarting in place is supported, so suppose the route is started again while an exchange of the previous run is still in flight (only possible after the shutdown timeout forced the stop). The old dispatcher's close then waits until the new run is idle too, and a second stop before that overwrites closeDispatcherWhenIdle, so the first dispatcher is never closed and its thread stays parked on the connection's executor. Keeping the counter and the pending close on the handler instance (one per dispatcher), and keeping a reference to that handler next to this.dispatcher, would remove the coupling. See the inline comment. This is a narrow case, so I'm fine with a follow-up.

Notes

  • This also closes the dispatcher of a core NATS consumer (non-JetStream) on stop with a shared connection, which was leaked before. That's a good side effect; maybe say so in the PR description.
  • No conflict with #28318: the two merge cleanly, and #28318's stop-race test already allows for the closed dispatcher. They also merge cleanly with #28204.

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

@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

🧪 CI tested the following changed modules:

  • components/camel-nats

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

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

Skip-tests mode would test 8 modules (1 direct + 7 downstream), skip tests for 27 (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/NatsConsumer.java, components/camel-nats/src/test/java/org/apache/camel/component/nats/NatsConsumerJetStreamPushStopTest.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 (27)
  • 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
  • docs
  • dummy-component

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

All tested modules (35 modules, 4m 12s total)

Total reactor time: 4m 12s

Module Duration Status
Camel :: JBang :: Plugin :: TUI 53.3s SUCCESS
Camel :: JBang :: Plugin :: Validate 24.1s SUCCESS
Camel :: Launcher 23.3s SUCCESS
Camel :: YAML DSL 18.5s SUCCESS
Camel :: JBang :: MCP 17.4s SUCCESS
Camel :: YAML DSL :: Validator 16.8s SUCCESS
Camel :: Catalog :: Camel Catalog 14.9s SUCCESS
Camel :: Component DSL 13.6s SUCCESS
Camel :: Docs 10.1s SUCCESS
Camel :: Kamelet Main 8.6s SUCCESS
Camel :: JBang :: Plugin :: Kubernetes 7.4s SUCCESS
Camel :: YAML DSL :: Deserializers 5.9s SUCCESS
Camel :: Catalog :: Camel Route Parser 5.8s SUCCESS
Camel :: Catalog :: Camel Report Maven Plugin 5.7s SUCCESS
Camel :: JBang :: Plugin :: Testing 4.5s SUCCESS
Camel :: YAML DSL :: Validator Maven Plugin 4.0s SUCCESS
Camel :: All Components Sync point 3.5s SUCCESS
Camel :: YAML DSL :: Maven Plugins 2.1s SUCCESS
Camel :: Catalog :: Maven 1.8s SUCCESS
Camel :: Catalog :: Suggest (deprecated) 1.7s SUCCESS
Camel :: Assembly 1.2s SUCCESS
Camel :: JBang :: Integration tests 1.2s SUCCESS
Camel :: Catalog :: Console 0.9s SUCCESS
Camel :: JBang :: Plugin :: Generate 0.9s SUCCESS
Camel :: Catalog :: Dummy Component 0.8s SUCCESS
Camel :: Endpoint DSL :: Support 0.8s SUCCESS
Camel :: Coverage 0.8s SUCCESS
Camel :: JBang :: Plugin :: Edit 0.7s SUCCESS
Camel :: Launcher :: Container 0.6s SUCCESS
Camel :: JBang :: Main 0.5s SUCCESS
Camel :: JBang :: Plugin :: MCP 0.4s SUCCESS
Camel :: Endpoint DSL n/a
Camel :: Integration Tests n/a
Camel :: JBang :: Core n/a
Camel :: Nats n/a

Top 20 slowest modules:

  • Camel :: JBang :: Plugin :: TUI (53.3s)
  • Camel :: JBang :: Plugin :: Validate (24.1s)
  • Camel :: Launcher (23.3s)
  • Camel :: YAML DSL (18.5s)
  • Camel :: JBang :: MCP (17.4s)
  • Camel :: YAML DSL :: Validator (16.8s)
  • Camel :: Catalog :: Camel Catalog (14.9s)
  • Camel :: Component DSL (13.6s)
  • Camel :: Docs (10.1s)
  • Camel :: Kamelet Main (8.6s)
  • Camel :: JBang :: Plugin :: Kubernetes (7.4s)
  • Camel :: YAML DSL :: Deserializers (5.9s)
  • Camel :: Catalog :: Camel Route Parser (5.8s)
  • Camel :: Catalog :: Camel Report Maven Plugin (5.7s)
  • Camel :: JBang :: Plugin :: Testing (4.5s)
  • Camel :: YAML DSL :: Validator Maven Plugin (4.0s)
  • Camel :: All Components Sync point (3.5s)
  • Camel :: YAML DSL :: Maven Plugins (2.1s)
  • Camel :: Catalog :: Maven (1.8s)
  • Camel :: Catalog :: Suggest (deprecated) (1.7s)

⚙️ View full build and test results

allthingssecurity added a commit to allthingssecurity/camel that referenced this pull request Oct 23, 2026
… not per consumer

Review of apache#28320 (optional point 1): the in-flight count and the pending close of the dispatcher
were fields of the consumer, while each start makes a new dispatcher and a new
CamelNatsMessageHandler. A consumer that is stopped and started again in place (as a route
policy such as a throttling policy does with a consumer that is not Suspendable) while an
exchange of the previous run is still in flight closed the old dispatcher only once the new run
was idle too, and a second stop before that overwrote the pending close, so the first dispatcher
was never closed.

The count and the pending close now live on the CamelNatsMessageHandler, one per dispatcher. The
consumer keeps the handler next to its dispatcher, and doStop asks that handler to close the
dispatcher now or once its message in flight is done.

Test: eachRunOfTheConsumerClosesItsOwnDispatcher (stop, start and stop the same consumer while a
message of the first run is in flight) fails without the change: the dispatcher of the second
run is not closed when it stops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
allthingssecurity added a commit to allthingssecurity/camel that referenced this pull request Oct 23, 2026
… once the stop closes the dispatcher

removesItsConnectionListenerWhenTheRouteStopsWhileItSubscribes waited for the keeper to register
its listener. Merged with CAMEL-25847 (apache#28320), the stop closes the dispatcher, jnats refuses the
subscribe on it and no keeper is started, so the wait timed out. The test now waits for either
the keeper's listener or the refused subscribe, and then checks that no keeper listens to the
connection. Checked on a local merge of both branches: camel-nats 21 tests, 0 failures (the
previous version of this test failed there). On this branch alone it still fails without the
keeper guard of the previous commit.

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

Copy link
Copy Markdown
Contributor Author

@davsclaus thanks for the review. The optional point is done in 15aa72e: the in-flight state now lives on the per-dispatcher CamelNatsMessageHandler, and each run closes its own dispatcher. New test eachRunOfTheConsumerClosesItsOwnDispatcher (the same consumer stopped and started in place, as a route policy does, while a message of the first run is in flight) fails without the change. The side effect you mention (the dispatcher of a core NATS consumer is now closed on stop with a shared connection) will be added to the description.

Tests (offline, -DskipITs): NatsConsumerJetStreamPushStopTest 3 run, 0 failures; camel-nats unit tests 8 run, 0 failures. Merged locally with #28318: camel-nats 21 run, 0 failures.

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, that is what I had in mind. The in-flight count and the pending close now live on CamelNatsMessageHandler (NatsConsumer.java:363-379), and doStop asks this run's handler to close its own dispatcher (lines 126-130), so a second stop can no longer overwrite the first run's close. eachRunOfTheConsumerClosesItsOwnDispatcher models the in-place restart well.

One non-blocking point (from the first commit): when the route is stopped while the task is still in jetStream().subscribe(...), doStop closes the dispatcher the task already created, jnats refuses the subscribe with "Dispatcher is closed", and the catch-all in NatsConsumingTask.run (line 213) reports it as "Error during processing". A route stopped right after it starts would log a WARN it did not log before. Logging at DEBUG there when !isRunAllowed(), like the keeper of #28318 does in consumerDeleted, would keep the stop quiet.

LGTM once CI is green.

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

@allthingssecurity

Copy link
Copy Markdown
Contributor Author

@davsclaus done in 3fe5f7b. The catch-all in NatsConsumingTask.run now logs at DEBUG ("Not consuming from topic ... as the consumer is stopping") when !isRunAllowed(), as the keeper of #28318 does in consumerDeleted. Otherwise it reports "Error during processing" to the exception handler as before.

New test stopWhileSubscribingIsNotReportedAsAnError: the consumer is stopped from inside the fake server's subscribe, so the subscribe is refused with "Dispatcher is closed", and the test checks that nothing reaches the exception handler (the default one is what logs the WARN). Without the change it fails with reported: [java.lang.IllegalStateException: Dispatcher is closed].

Tests (offline, -DskipITs): camel-nats unit tests 9 run, 0 failures. Merged with #28318's branch: 22 run, 0 failures. git merge-tree is clean against #28318's branch, #28204's branch and main. #28318 and #28204 still conflict with each other in NatsConsumer.java, as before this commit, and this commit adds nothing to those hunks.

The guard checks the consumer, not the run. If the same consumer is stopped and started in place while the first run's subscribe is still in progress, that refused subscribe is still logged at WARN. This is the same gap as your nit on #28318, and it is in the same follow-up draft.

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.

The fix is well-scoped and correct. The in-flight tracking per-handler (CamelNatsMessageHandler) is the right design — one counter per handler avoids shared-state races when multiple handlers coexist on the same Connection.

One minor nit on closeWhenIdle:

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
… once the stop closes the dispatcher

removesItsConnectionListenerWhenTheRouteStopsWhileItSubscribes waited for the keeper to register
its listener. Merged with CAMEL-25847 (apache#28320), the stop closes the dispatcher, jnats refuses the
subscribe on it and no keeper is started, so the wait timed out. The test now waits for either
the keeper's listener or the refused subscribe, and then checks that no keeper listens to the
connection. Checked on a local merge of both branches: camel-nats 21 tests, 0 failures (the
previous version of this test failed there). On this branch alone it still fails without the
keeper guard of the previous commit.

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

@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.

TL;DR

The new commit is correct: a stop while the consumer subscribes is now logged at DEBUG, and real errors still go to the exception handler.

Blockers: None

Other findings: 0

Thanks @allthingssecurity, this covers the point from the earlier review.

  • NatsConsumer.java:213-222: the catch in NatsConsumingTask.run logs at DEBUG only when isRunAllowed() is false. isRunAllowed() is true from STARTING to SUSPENDED, so an error while the consumer starts or runs is still reported as before. The guard also covers the other ways a stop can break the setup: jnats refuses a null dispatcher after doStop cleared the field, and an owned connection is closed.
  • NatsConsumerJetStreamPushStopTest.java:179-211: stopping the consumer from inside the fake subscribe hits the exact window. CI ran it, and all 4 tests pass.
  • CI for this commit ran before #28204 (CAMEL-25815) changed NatsConsumer.java on main. The file merges cleanly with today's main, and with #28318 in either order, with the same result. I also built camel-nats locally with today's main, #28318 and this PR merged together: 33 unit tests and 31 integration tests pass, including the new tests of all three PRs.
  • The open nit from the automated review (a comment before close.run() at line 385) is optional.

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

allthingssecurity added a commit to allthingssecurity/camel that referenced this pull request Oct 23, 2026
… not per consumer

Review of apache#28320 (optional point 1): the in-flight count and the pending close of the dispatcher
were fields of the consumer, while each start makes a new dispatcher and a new
CamelNatsMessageHandler. A consumer that is stopped and started again in place (as a route
policy such as a throttling policy does with a consumer that is not Suspendable) while an
exchange of the previous run is still in flight closed the old dispatcher only once the new run
was idle too, and a second stop before that overwrote the pending close, so the first dispatcher
was never closed.

The count and the pending close now live on the CamelNatsMessageHandler, one per dispatcher. The
consumer keeps the handler next to its dispatcher, and doStop asks that handler to close the
dispatcher now or once its message in flight is done.

Test: eachRunOfTheConsumerClosesItsOwnDispatcher (stop, start and stop the same consumer while a
message of the first run is in flight) fails without the change: the dispatcher of the second
run is not closed when it stops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@allthingssecurity
allthingssecurity force-pushed the camel-nats-jetstream-push-consumer-stop-unsubscribes branch from 3fe5f7b to fbbb4f1 Compare October 23, 2026 23:28
@allthingssecurity

Copy link
Copy Markdown
Contributor Author

@davsclaus #28318 is merged, so as you asked this one is rebased onto main on top of it (fbbb4f1; the three commits applied without conflict) to get a fresh CI run. camel-nats unit tests on the rebased branch: 33 run, 0 failures, including the pull tests of #28204 and the push tests of #28318.

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.

Following up on my earlier review — the in-flight tracking nit remains open.

The concurrency design is correct: closeWhenIdle is called after the dispatcher is unsubscribed, so no new messages can arrive between the lock release and close.run() — the apparent window is not a real race. The two new tests (stoppedRouteGetsNoMessagesAndRestartedRouteGetsEachMessageOnce and stoppingTheRouteDoesNotInterruptTheExchangeInFlight) correctly validate both the unsubscription and the deferred-close scenarios. The rebase onto main (after #28318 merged) is clean.

One low-priority nit from the earlier review still stands — see inline.

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.

Approving again after e1cde59, which only adds the comment line the bot asked for in NatsConsumer.closeWhenIdle.

Claude Code on behalf of davsclaus

allthingssecurity and others added 4 commits October 25, 2026 09:05
…bes through its dispatcher

A JetStream push subscription belongs to the dispatcher of the consumer.
On stop the consumer unsubscribed the topic from the dispatcher (the push
subscription is on its deliver subject, not on the topic) and then called
unsubscribe() on the subscription, which jnats refuses for a subscription
of a dispatcher ("cannot respond to unsubscribe directly"; only a WARN).
With a shared connection (connection option), which stays open, the
subscription stayed active: the stopped route kept getting messages,
which failed and were nak'ed and delivered again at once, and after the
route was started again every message was routed twice (the old and the
new subscription). The dispatcher and its thread were also never closed.

The consumer now unsubscribes the push subscription through its
dispatcher when it stops. It also closes the dispatcher it created,
which ends its thread, once no message is in flight on it: jnats
closes a dispatcher by cancelling its thread with an interrupt, and
that thread runs the route, which Camel stops before it waits for the
exchanges in flight; when a message is in flight, the dispatcher is
closed when that message is done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… not per consumer

Review of apache#28320 (optional point 1): the in-flight count and the pending close of the dispatcher
were fields of the consumer, while each start makes a new dispatcher and a new
CamelNatsMessageHandler. A consumer that is stopped and started again in place (as a route
policy such as a throttling policy does with a consumer that is not Suspendable) while an
exchange of the previous run is still in flight closed the old dispatcher only once the new run
was idle too, and a second stop before that overwrote the pending close, so the first dispatcher
was never closed.

The count and the pending close now live on the CamelNatsMessageHandler, one per dispatcher. The
consumer keeps the handler next to its dispatcher, and doStop asks that handler to close the
dispatcher now or once its message in flight is done.

Test: eachRunOfTheConsumerClosesItsOwnDispatcher (stop, start and stop the same consumer while a
message of the first run is in flight) fails without the change: the dispatcher of the second
run is not closed when it stops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… is not reported as an error

When the consumer stops while its task is still in jetStream().subscribe(...), the stop closes the
dispatcher that the task passes to the subscribe, and jnats refuses it ("Dispatcher is closed").
The task reported that to the exception handler as "Error during processing", which logs a WARN for
a route that is only being stopped. When the consumer is no longer allowed to run, the task now logs
it at DEBUG instead; otherwise it is still reported as before.

New test stopWhileSubscribingIsNotReportedAsAnError: the consumer stops from inside the subscribe of
the fake server; nothing must reach the exception handler. Without the change it fails with
"reported: [java.lang.IllegalStateException: Dispatcher is closed]".

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Claus Ibsen <claus.ibsen@gmail.com>
@davsclaus
davsclaus force-pushed the camel-nats-jetstream-push-consumer-stop-unsubscribes branch from e1cde59 to 799c2f9 Compare October 25, 2026 08:05

@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.

Previous findings are all addressed. LGTM.

The comment line explaining why close.run() is called outside the synchronized block (to avoid blocking while holding the lock) is exactly what was needed. The concurrency design is sound: the in-flight tracking per CamelNatsMessageHandler ensures each dispatcher run is independently managed, and the deferred close correctly avoids interrupting the exchange in flight. All four tests validate the fix end-to-end.

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

@davsclaus davsclaus added this to the 4.24.0 milestone Oct 25, 2026
@davsclaus davsclaus added the bug Something isn't working label Oct 25, 2026
@davsclaus
davsclaus merged commit 187bcde into apache:main Oct 25, 2026
6 checks passed
@gnodet

gnodet commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

🔄 Backport Bot

This bugfix targets main and may need porting to:

  • camel-4.22.x

Add a port/<branch> label to automatically create a backport PR for that branch.

Backport PRs will be created automatically on merge (once a port label is added). Comment /port to create them immediately.

ℹ️ If you push additional commits after /port, use /port again to update the port PRs.

@allthingssecurity
allthingssecurity deleted the camel-nats-jetstream-push-consumer-stop-unsubscribes branch October 25, 2026 10:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants