Repository navigation
CAMEL-25845: camel-nats - the JetStream push consumer subscribes again when the server deleted its ephemeral consumer - #28318
Conversation
|
🌟 Thank you for your contribution to the Apache Camel project! 🌟 🐫 Apache Camel Committers, please review the following items:
|
davsclaus
left a comment
There was a problem hiding this comment.
Thanks Shashank, this is a careful push twin of CAMEL-25815 (#28204). Pulling the logic into its own NatsJetStreamPushConsumerKeeper keeps NatsConsumer readable. The resume rules match #28204: an ephemeral consumer only, start at the first unacked sequence, startTime(null), WorkQueue and a re-created stream use the configuration as it is. The tests fail without the fix. The fake is large but every behaviour it models is used by a test, and it follows the style of the #28204 fake, so I don't think it is over-engineered.
TL;DR
Looks good, no bugs found. A scope question and some consistency points with #28204 below. CI is still running.
Blockers: None
Other findings: 4
Non-blocking
-
The check runs only after a reconnect. A consumer that is deleted while the connection stays up (for example
nats consumer rm, which is exactly whatNatsJetstreamConsumerPullDeletedITin #28204 does) is never noticed, and the route silently stops consuming. The pull consumer does notice this case through its empty-fetch check, so the two modes now differ. I think the reconnect-only scope is fine for this JIRA. An idle heartbeat cannot be used with a queue group (JsSubFcHbNotValidQueue), and its alarm goes to the connection'sErrorListener, which Camel does not own when the connection is shared. Could you state the limitation in the keeper javadoc and the upgrade-guide entry ("only after a reconnect; a consumer deleted while connected is not detected")? See the inline comment. -
Two ways to track unacknowledged messages. #28204 removes a sequence from its set through explicit callbacks (
acknowledged(msg)inonComplete/onFailure, plusDefaultNatsManualAck'sonAcknowledged). This PR instead scansMessage.lastAck()on every delivery. Both work, and iflastAckis stale (the field is not volatile in jnats) the consumer resumes earlier and redelivers rather than skipping, so it fails safe. Still, the resume code (stream re-created, WorkQueue, start-sequence calculation) is now in the codebase twice. They also already differ: push handlesDeliverPolicy.New/Lastwhen nothing was delivered yet, and pull does not (resume = !workQueue && highestFetched.get() > 0). Not for this PR, but a follow-up JIRA to share one resume helper between the two modes would keep them from drifting further. -
Merge conflict with #28204. Merging both onto main gives a small textual conflict in
NatsConsumer.java: theio.nats.client.support.Statusimport and thevolatile JetStreamSubscription jetStreamSubscriptionfield (this PR adds a comment and thepushConsumerKeeperfield next to it). The identicalstreamExists->lookupStreamchange and the upgrade-guide entries merge cleanly. Whichever PR goes in second needs a rebase. This PR merges cleanly with #28320, and the two work together: after #28320 closes the dispatcher on stop, a keeper subscribe that races the stop is refused with "Dispatcher is closed" and swallowed becauseisRunAllowed()is false.endsTheSubscriptionItMadeWhileTheRouteStoppedalready allows for that. -
Keeper registered after a stop that came during startup. See the inline comment. This is a narrow window, and the subscription itself has the same race on main today.
Suggestions
- An IT against a real nats-server would check the jnats behaviour the fake assumes:
getConsumerInfo()returns null for 10014, andRESUBSCRIBEDfires after the reconnect. With a sharedconnection,jsm.deleteConsumer(...)followed byconnection.forceReconnect()should exercise the whole path, in the same way as the pull IT in #28204. - The upgrade-guide entry is about 20 lines and reads more like reference documentation. The upgrade guide is meant for migration notes, so maybe keep two or three sentences there (the push consumer now creates a deleted ephemeral consumer again after a reconnect, resuming at the first unacked message, and a durable is not created again) and move the details to
nats-component.adoc. That is optional, since #28204 used the same style. - Three test classes (#28204, this PR, #28320) now each have their own copy of the
FakeServer/proxy(...)scaffolding. A shared test helper in a later cleanup would save about 100 lines per copy.
Claude Code on behalf of @davsclaus. This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.
|
🧪 CI tested the following changed modules:
🔁 2 tests passed only after a retry on JDK 21 (3 retried attempts) Recovered flaky tests on JDK 21 (2)
🔬 Scalpel shadow comparison — Scalpel: 8 of 707 tested, 25 compile-only — current: 8 all testedMaveniverse Scalpel detected 8 affected modules (current approach: 8). Skip-tests mode would test 8 modules (3 direct + 7 downstream), skip tests for 25 (generated code, meta-modules) Modules Scalpel would test (8)
Modules with tests skipped (25)
All tested modules (35 modules, 5m 39s total)Total reactor time: 5m 39s
Top 20 slowest modules:
|
…topped while it subscribes Review of apache#28318: - NatsConsumer: when the route is stopped while the consuming task subscribes, doStop does not see the keeper yet, so the keeper stayed registered as a listener of the (shared) connection. After the subscribe the task now stops the keeper when the consumer is no longer allowed to run. pushConsumerKeeper is volatile (written by the task, read by doStop). - NatsJetStreamPushConsumerKeeper javadoc: the consumer is checked only after a reconnect (a consumer deleted while the connection stays up is not detected), and Message.lastAck() can be stale, which makes the consumer resume earlier and the server redeliver, never skip. - Upgrade guide: the entry is shortened to the migration note, with the limitation; the details are in a new section of nats-component.adoc (and its catalog copy). Test: removesItsConnectionListenerWhenTheRouteStopsWhileItSubscribes (the route is stopped from the subscribe of its first subscription) fails without the change: the keeper is left in the connection's listeners. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@davsclaus thanks, addressed in a9dbaf2:
Upgrade guide: shortened to the migration note (what changed, durable not created again, the limitation). The details moved to a new section "JetStream push consumer whose consumer was deleted" in Not done here: an IT against a real nats-server ( Tests (offline, Follow-up commit d0c258b: the new test also accepts the refused subscribe on a closed dispatcher, so it passes once this PR is merged with #28320. On a local merge of both branches camel-nats has 21 tests, 0 failures. Claude Code on behalf of allthingssecurity |
gnodet-bot
left a comment
There was a problem hiding this comment.
Well-structured self-healing mechanism for JetStream push consumers with careful concurrency handling and thorough tests. Two minor points worth addressing:
This review was generated by an AI agent, Hermès on behalf of @gnodet.
…sumer stops Review of apache#28318: the keeper runs its consumer check on the executor of the consumer, a fixed pool (poolSize, 10 by default), so a check queued by a reconnect can wait for the lock while an earlier check holds it between attempts (up to 30 seconds). The stop of the consumer calls shutdownNow on that executor, which does not wait for its threads but interrupts them; the check holding the lock leaves on the interrupt, while lock() kept the waiting one blocked until it got the lock. checkConsumer() now uses lockInterruptibly(): on an interrupt it restores the interrupt flag, clears checkQueued and returns without a check. No new test: the stop does not wait for the executor, so the only difference is how long a pool thread of a stopping consumer lingers, which no route behaviour shows. The camel-nats unit tests pass (18, among them the 13 of NatsConsumerPushConsumerDeletedTest). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gnodet-bot
left a comment
There was a problem hiding this comment.
Following up on my earlier review — both findings have been addressed.
✅ [Finding 1] lock.lock() not interruptible — Fixed in b1fbb1a. lockInterruptibly() is correctly applied: on InterruptedException, checkQueued is cleared (correct — the check never ran, so a future reconnect must be able to queue a new one), and the interrupt flag is restored before returning. The outer try/finally for lock.unlock() is only entered after a successful lock acquisition, so the pairing is preserved.
✅ [Finding 2] removeIf O(n) scan — The author's explanation is convincing: settled entries are evicted before adding each new delivery, and the default synchronous dispatcher means one message is in flight at a time. The map stays bounded in practice.
LGTM.
This review was generated by an AI agent, Hermès on behalf of @gnodet.
davsclaus
left a comment
There was a problem hiding this comment.
Thanks, all three points are addressed.
- The reconnect-only limitation is in the keeper javadoc (
NatsJetStreamPushConsumerKeeper.java:68-71), the new section of nats-component.adoc and the shortened upgrade-guide entry; the catalog copy matches. - The stale
lastAck()note is in the keeper javadoc (lines 73-76). - The stop-while-subscribing guard (
NatsConsumer.java:281-284) withpushConsumerKeepervolatile closes the window: each side writes one volatile and reads the other, so eitherdoStopor the task stops the keeper.removesItsConnectionListenerWhenTheRouteStopsWhileItSubscribescovers it.
lockInterruptibly() in checkConsumer() looks right; the unlock in finally only runs after the lock was acquired.
Optional nit: a stop followed by a start in place while the first subscribe is still running leaves the old keeper listening, as isRunAllowed() is true again by then. It only leaks a listener (its executor is shut down and connectionEvent swallows the rejection). Checking isShutdown() on the executor of the task's own run, next to isRunAllowed(), would make the guard per run.
This PR and #28320 merge cleanly together and your tests already accept the refused subscribe on a closed dispatcher. Whichever lands second should get a fresh CI run on top of the other before merging.
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.
|
@davsclaus on the optional nit (a stop and then a start in place while the first subscribe still runs, which leaves the old keeper listening): it goes into the drafted follow-up JIRA (the one for a consumer deleted while connected), not into this PR. That case only leaks a listener. The first run's executor is shut down, so its On CI: agreed. Whichever of #28318 and #28320 lands second should get a fresh CI run on top of the other before it is merged. Locally, #28320's head merged with this branch passes the camel-nats unit tests (22 run, 0 failures). Claude Code on behalf of allthingssecurity |
…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>
…topped while it subscribes Review of apache#28318: - NatsConsumer: when the route is stopped while the consuming task subscribes, doStop does not see the keeper yet, so the keeper stayed registered as a listener of the (shared) connection. After the subscribe the task now stops the keeper when the consumer is no longer allowed to run. pushConsumerKeeper is volatile (written by the task, read by doStop). - NatsJetStreamPushConsumerKeeper javadoc: the consumer is checked only after a reconnect (a consumer deleted while the connection stays up is not detected), and Message.lastAck() can be stale, which makes the consumer resume earlier and the server redeliver, never skip. - Upgrade guide: the entry is shortened to the migration note, with the limitation; the details are in a new section of nats-component.adoc (and its catalog copy). Test: removesItsConnectionListenerWhenTheRouteStopsWhileItSubscribes (the route is stopped from the subscribe of its first subscription) fails without the change: the keeper is left in the connection's listeners. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 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>
…sumer stops Review of apache#28318: the keeper runs its consumer check on the executor of the consumer, a fixed pool (poolSize, 10 by default), so a check queued by a reconnect can wait for the lock while an earlier check holds it between attempts (up to 30 seconds). The stop of the consumer calls shutdownNow on that executor, which does not wait for its threads but interrupts them; the check holding the lock leaves on the interrupt, while lock() kept the waiting one blocked until it got the lock. checkConsumer() now uses lockInterruptibly(): on an interrupt it restores the interrupt flag, clears checkQueued and returns without a check. No new test: the stop does not wait for the executor, so the only difference is how long a pool thread of a stopping consumer lingers, which no route behaviour shows. The camel-nats unit tests pass (18, among them the 13 of NatsConsumerPushConsumerDeletedTest). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
b1fbb1a to
fd79fbd
Compare
|
Rebased onto main after #28204 (CAMEL-25815) merged. The new head is fd79fbd; the 4 commits are kept. The camel-nats unit tests on the rebased branch: 29 run, 0 failures. This includes #28204's It still merges cleanly with #28320 (CAMEL-25847, 3fe5f7b). On a local merge of the two: 33 run, 0 failures. @davsclaus this one landed second, so CI is now running on top of #28204. Claude Code on behalf of allthingssecurity |
|
This failed the CI build of #28320, which only adds a comment line to Could you make the test independent of the others, so it passes on its own? Thanks. Claude Code on behalf of davsclaus |
Description
CAMEL-25845
With
jetstreamEnabled=trueandpullSubscription=falsethe consumer uses a JetStream push subscription, and withoutdurableNamethe JetStream consumer behind it is ephemeral. The NATS server deletes an ephemeral push consumer when nobody is subscribed to its deliver subject for its inactive threshold (5 seconds by default), for example while the client was disconnected for a few seconds before jnats reconnected, and a server restart loses it too. jnats subscribes the deliver subject again after the reconnect but does not create the consumer again. The route stayed started and consumed nothing again, without any log, until it was restarted.This is the push twin of CAMEL-25815 (#28204), whose fix only changes the pull loop. It follows the reviews of #28204: only an ephemeral consumer is created again, a failed subscribe is reported and tried again, the new consumer carries no start time, a
nullconsumer info counts as gone (that is how jnats reports a deleted consumer), and the resume looks at the stream first (WorkQueue retention, a stream created again). Both changes makeNatsConsumer.jetStreamSubscriptionvolatile and replacestreamExistswith the samelookupStream. #28204 merged first, and this branch is rebased onto main after it (the conflict was theStatusimport and the field next tojetStreamSubscription; thelookupStreamlines were identical and merged cleanly).Changes (
NatsConsumer, new package-privateNatsJetStreamPushConsumerKeeper, upgrade guide,nats-component.adoc):RESUBSCRIBEDit asks the server for the consumer (getConsumerInfo, on the consumer's executor). A reconnect does not queue a second check while one waits.getConsumerInfo()withnullwhen the server has no such consumer (lookupConsumerInfomaps api error 10014, consumer not found, tonull) and throws only for other errors, such as 10059 stream not found. The keeper treatsnullas gone (and still a 404 error); a check that waited for an exception never saw a deleted consumer.lastAck()of the delivered messages, so a nak'ed message counts as not acknowledged), or after the last one delivered: nothing is skipped, and the stream is not replayed from the deliver policy. The new configuration has no start time (withDeliverPolicy.ByStartTimethe copied start time next to the start sequence would be rejected by the server). A message deliveredmaxDelivertimes (when set, by the endpoint option or theconsumerConfiguration) counts as settled even when it was nak'ed, as the server does not deliver it again (deliveredCount()of its metadata): a poison message no longer holds the resume point back for the life of the route, which would replay everything after it at the next deletion. A message that is not acknowledged and can still be delivered keeps the old start (at-least-once). WithAckPolicy.Nonethe delivery is the acknowledgement: the keeper does not keep the delivered messages (a route withmanualAck=truemay never callack(), which would keep them for the life of the route) and the new consumer starts after the last one delivered.New(orLast) starts after (or at) the last stream sequence seen at the subscribe (NatsConsumerpasses it from the stream lookup), so the messages published during the outage are not skipped (the configuration as it is would start at the new messages again).getStreamInfo). A stream withWorkQueueretention refuses any deliver policy other thanAll(400, error 10101) 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 (for example a memory stream after a server restart, or by an administrator); its sequences start at 1 again, and a start sequence of the old stream would make the server skip its messages without an error. The keeper then clears what it tracked, logs a WARN and uses the consumer configuration.NatsConsumerpasses the create time it saw when it looked the stream up at start (streamExistsbecomeslookupStream, which returns theStreamInfo).durableName, or a durable name in theconsumerConfiguration) is not created again: it is gone only when an administrator deleted it or after itsinactiveThreshold, and creating it again from its configuration would replay the stream from its deliver policy (the whole stream with the defaultDeliverPolicy.All). The keeper logs a WARN and keeps the subscription; messages arrive again when the durable is created again with the same deliver subject.getConsumerInfoandgetStreamInfo). The first failed subscribe of a reconnect goes to the consumer's exception handler (jnats throwsIllegalStateExceptionfor some, for example SUB-90007 when the stream is gone), the next ones are logged at DEBUG. A subscribe that fails because the consumer is stopping (with CAMEL-25847 the stop closes the dispatcher, and jnats refuses a subscribe on a closed dispatcher) is not reported. The WARNs name why the consumer counts as gone (consumer not found, or the 404 error such as stream not found).doStopdid not see the keeper yet; the task now stops it when the consumer may no longer run, andpushConsumerKeeperis volatile). A subscription made while the consumer was being stopped is ended by the keeper. After a resubscribe only the messages of the old subscription are dropped from tracking, so a message the new consumer delivers meanwhile stays tracked.ackPolicy=None,maxDeliver,New/Last, WorkQueue and re-created streams, the retries) are in a new section ofnats-component.adoc(and its catalog copy), as asked in review.What users can notice: after an outage of more than about 5 seconds or a server restart, a push consumer without
durableNameconsumes again (a WARN says it subscribed again and from which stream sequence). A durable consumer that is gone now gets a WARN after a reconnect. No option changed.Traffic: the keeper asks the server for the consumer only after a reconnect (
RESUBSCRIBED): oneCONSUMER.INFOrequest per reconnect (more, at most every 30 seconds, while it fails), plus oneSTREAM.INFOwhen it subscribes again. An idle healthy push consumer sends nothing extra (the pull twin had to limit its periodic check; the push keeper has none).Stopping: the keeper only checks while the consumer runs, and when the consumer was stopped while it subscribed again, it ends the subscription it just made (the stop may have ended only the previous one).
NatsConsumer.jetStreamSubscriptionis nowvolatile, as the keeper sets it on another thread than the one that stops the consumer.Not changed (review): the consumer is checked only after a reconnect, so a consumer deleted while the connection stays up (
nats consumer rm) is not detected, unlike the pull consumer of CAMEL-25815; an idle heartbeat is not allowed with a queue group and its alarm goes to the connection'sErrorListener. This is stated in the keeper javadoc, the guide and the component page, and a follow-up JIRA is drafted. A shared resume helper for pull and push (and a sharedFakeServertest helper) is a second drafted follow-up. The keeper javadoc also says thatMessage.lastAck()can be read stale, which makes the consumer resume earlier (redelivery), never skip.Tests:
NatsConsumerPushConsumerDeletedTest(13 tests) with a fake JetStream server behind the jnats interfaces the consumer calls. It answers like jnats:getConsumerInfo()returnsnullfor a deleted consumer and the info for a live one; a WorkQueue stream removes acknowledged messages and refuses a consumer whose deliver policy is notAll(10101); a stream created again starts at sequence 1 with a new create time; a start time next toByStartSequenceis rejected, as nats-server does; a nak'ed message is not delivered again aftermaxDeliverdeliveries, and the delivered count is in the reply subject; while the stream is gone,getStreamInfoandgetConsumerInfothrow 404 stream not found (10059) and a subscribe throws SUB-90007;DeliverPolicy.Newstarts after the last message; a new dispatcher percreateDispatcher, and a closed dispatcher refuses a subscribe, as jnats does.consumesAgainAfterTheServerDeletedTheEphemeralConsumer,redeliversTheMessageWhoseNakWasLostWithTheConsumer: the messages published during the outage are routed, a message whose nak was lost with the deleted consumer is delivered again, and the message acknowledged before the deletion is not routed again.endsTheSubscriptionItMadeWhileTheRouteStoppedstops the route while the consumer subscribes again; the new subscription is ended (or, merged with CAMEL-25847, refused by the closed dispatcher), and nothing goes to the exception handler.doesNotCreateAGoneDurableConsumerAgain(durableName=orders): no new consumer after the reconnect (asserted for 3 s).reportsAFailedSubscribeAndTriesAgain: the first subscribe after the reconnect throwsIllegalStateException(SUB-90007); the endpoint'sexceptionHandlergets it and the next attempt routes the message.resumesAnEphemeralConsumerThatStartedAtATime(DeliverPolicy.ByStartTime).workQueueStreamIsSubscribedAgainWithDeliverPolicyAll,consumesAStreamCreatedAgainFromItsStart(the stream is created again during the outage;order-3is sequence 1 of the new stream) andackPolicyNoneResumesAfterTheHighestDelivered(manualAck=true&ackPolicy=None, the route never acks; the new consumer starts at sequence 3).Expected: <2> but was: <0>,Expected: <1> but was: <0>), because anullconsumer info never counted as gone;doesNotCreateAGoneDurableConsumerAgainpasses there for the same reason. With only thenullfix applied, the three new tests still fail: WorkQueueExpected: <1> but was: <0>(every resubscribe rejected with 10101), re-created streamExpected: <1> but was: <0>(the new consumer starts at sequence 3 of the old stream),ackPolicy=NoneBody of message: 0. Expected: <order-3> but was: <order-1>(the never-acked messages are replayed). With the change all 9 pass.aMessageDeliveredMaxDeliverTimesDoesNotHoldBackTheResume(maxDeliver=3, a poison message nak'ed three times, then two messages routed, then the consumer deleted) withBody of message: 0. Expected: <order-4> but was: <order-2>;keepsCheckingUntilTheStreamIsCreatedAgain(the stream is gone after the reconnect and created again after six checks) withthe consumer keeps checking, 5 ==> expected: <true> but was: <false> within 30 seconds;deliverPolicyNewWithNothingDeliveredResumesAfterTheLastMessageAtTheSubscribewithReceived message count. Expected: <1> but was: <0>.removesItsConnectionListenerWhenTheRouteStopsWhileItSubscribes(found in review): the route is stopped from inside its first subscribe; no keeper may stay registered on the connection. Without the guard it fails withno keeper listens to the connection after the stop ==> expected: <[]> but was: <[...NatsJetStreamPushConsumerKeeper@...]>. It also accepts a subscribe refused on the closed dispatcher, which is what happens once merged with CAMEL-25847 (CAMEL-25847: camel-nats - a stopped JetStream push consumer unsubscribes through its dispatcher #28320, d0c258b).endsTheSubscriptionItMadeWhileTheRouteStoppedfails there withexpected: <[]> but was: <[java.lang.IllegalStateException: Dispatcher is closed]>.NatsConsumerPullConsumerDeletedTest,NatsConsumerPullConsumerGoneTest) (formatter and import sort in the build). Rebased onto main after CAMEL-25815: camel-nats - the JetStream pull consumer subscribes again when the server deleted its consumer #28204 merged; it still merges cleanly with CAMEL-25847: camel-nats - a stopped JetStream push consumer unsubscribes through its dispatcher #28320.What was not run: a real NATS server (the integration tests need a container), so an IT against nats-server (
jsm.deleteConsumer(...)plusforceReconnect()), suggested in review, is not part of this PR; the full root build.Target
mainbranch)Tracking
Apache Camel coding standards and style
mvn clean install -DskipTestslocally from root folder and I have committed all auto-generated changes.(I built and tested camel-nats. No option and no component page changed; the upgrade guide has a new entry. I did not run the full root build.)
AI-assisted contributions
Co-authored-bytrailers) 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-Bytrailer.Claude Code on behalf of allthingssecurity
🤖 Generated with Claude Code