Repository navigation
CAMEL-25815: camel-nats - the JetStream pull consumer subscribes again when the server deleted its consumer - #28204
Conversation
…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>
|
🌟 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. 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.
-
Blocking: a durable consumer is also created again, from its own configuration. With the default
DeliverPolicy.Allthe 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 aninactiveThreshold. 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. -
Not blocking, but worth fixing: in the empty-fetch path
subscribeAgainis not wrapped like in the 409 path. jnats reports some subscribe failures asIllegalStateException, for exampleNo 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 thecatch (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. -
Optional: when the user's
consumerConfigurationusesDeliverPolicy.ByStartTime, the copied configuration keeps its start time next toByStartSequence. 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). -
Optional: a sequence that is never acknowledged stays in
unacknowledgeduntil the next resubscribe. Examples are a message nak'd untilmaxDeliveris used up, ormanualAckwithackPolicy=Nonewhere the route never callsack. 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 ofsubscribeAgainwould help the next reader. -
Optional: if the route is stopped while
subscribeAgainruns,doStopcan unsubscribe the old subscription before the new one is assigned tojetStreamSubscription. The new subscription then stays open on a sharedconnection. CheckingisRunAllowed()after the subscribe and unsubscribing would cover it. Also, the one-argumentDefaultNatsManualAckconstructor is now unused, and the comment in theJetStreamStatusExceptioncatch ("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>
|
Thanks Claus.
Unit tests: 10 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 (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>
|
🧪 CI tested the following changed modules:
🔁 1 test passed only after a retry on JDK 25 (1 retried attempt) Recovered flaky tests on JDK 25 (1)
🔬 Scalpel shadow comparison — Scalpel: 8 of 707 tested, 26 compile-only — current: 8 all testedMaveniverse 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)
Modules with tests skipped (26)
All tested modules (35 modules, 5m 26s total)Total reactor time: 5m 26s
Top 20 slowest modules:
|
davsclaus
left a comment
There was a problem hiding this comment.
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>
|
Thanks Claus. Done in 939b4a5: after an empty fetch that ended before Claude Code on behalf of allthingssecurity |
davsclaus
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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 returnsnullfromgetConsumerInfo()for a deleted consumer instead of throwing, soconsumerGonereports it as present.NatsConsumer.java:408-416: theByStartSequenceresume does not look at the stream (WorkQueue retention, stream created again).
Other findings: 3 (2 non-blocking, 1 suggestion)
- Blocker, the consumer check (
NatsConsumer.java:353-359):NatsJetStreamSubscription.getConsumerInfo()returnsjs.lookupConsumerInfo(stream, consumerName)(NatsJetStreamSubscription.java:279-281). That method returnsnullwhen the server answersconsumer 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.consumerGoneignores the return value and only reacts to aJetStreamApiException, which now only comes forstream not found(10059). After an inactivity deletion or a restart no pull request is waiting, so no409arrives either, and the route stays idle as on main. The tests pass because the fake'sconsumerInfothrows a 404 for a deleted consumer and returnsnullfor a live one, the reverse of jnats. TreatinggetConsumerInfo() == nullas gone, and letting the fake returnnulllike jnats does, fixes it. CI runs the camel-nats JetStream ITs against the container, so an IT that deletes the consumer withjsm.deleteConsumer(...)while the route is busy on a message (no pull request waiting) would cover the real client. - 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 originalccis the right configuration here: acknowledged messages are already removed, soDeliverPolicy.Allreplays 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 thatfailedSubscribeIsTriedAgainAtTheNextCheckdescribes, and a stream an administrator re-created.selectStartingSeqNoadjusts the start only whenOptStartSeqis 0 (consumer.go:6381), so a start past the new last sequence is kept, and every message of the new stream belowstartis skipped without an error; in that testorder-2would be sequence 1 and never be routed. - Possible fix: look up the stream first (
jsm.getStreamInfo(...)). KeepccforRetentionPolicy.WorkQueue. WhengetCreateTime()differs from the one seen at the first subscribe, clearunacknowledged/highestFetchedand usecc. The fake server could model both cases.
- WorkQueue retention: a work-queue stream refuses any deliver policy other than
- 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.INFOabout every 5 s. A gone consumer makes jnats end the fetch at once (503 isSTATUS_TERMINUSinPullMessageManager.manageStatus). Checking only after an empty fetch that ended well beforepullFetchTimeoutwould therefore spare healthy consumers; otherwise the upgrade note could mention the traffic. TheJetStreamStatusExceptionbranch also still fetches again without a pause for other error statuses (for example409 Consumer is push based); the new wait would help there too. - Non-blocking (
NatsConsumer.java:440-446):doStartwarns that ack has no effect withmanualAck=trueandackPolicy=None, so such a route may never callack().unacknowledgedthen grows for the life of the route, and a resubscribe starts at the first message ever fetched. WithAckPolicy.Nonedelivery is the acknowledgement, sohighestFetched + 1is the right start; not tracking sequences for that policy removes both problems. - Suggestion (
NatsConsumerPullConsumerDeletedTest.java:86,:102):assertIsSatisfied(20_000)applies its timeout only when no message is expected (the parameter istimeoutForEmptyEndpoints), so the wait stays at the default 10 s.mock.setResultWaitTime(20_000), as inNatsConsumerPullConsumerGoneTest, 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
left a comment
There was a problem hiding this comment.
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 treatsnullfromgetConsumerInfo()as gone — the key fix (jnats returns null for api error 10014, not a 404 exception)subscribeAgain()looks up the stream first: WorkQueue retention usesDeliverPolicy.All, stream-recreated clears tracked sequencesendedEarlygate preventsgetConsumerInfocalls on healthy idle consumerstrackUnacknowledged=falsefor AckPolicy.None — resume starts fromhighestFetched + 1setResultWaitTime(20_000)instead ofassertIsSatisfied(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.
…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>
|
Thanks Andrea.
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>
|
On the bot's minor gap: done in abb0d97. The generic Backport labels are up to the committers; the same Claude Code on behalf of allthingssecurity |
gnodet-bot
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
Thanks Shashank. f946dae and abb0d97 address Andrea's blockers and the bot's remark, and the code reads right to me:
consumerGonenow treats anullconsumer info as gone, as jnats returns for 10014, and the fake answers the same way. BecauselastConsumerCheckonly moves when a check runs, the first early-ended fetch after a deletion checks at once.subscribeAgainkeeps the original configuration on a WorkQueue stream and on a stream that was created again (create time compared withisEqual), and clears the tracked sequences in the second case.- With the
endedEarlygate, idle healthy consumers send no CONSUMER.INFO. Other error statuses and unexpected failures now wait for the rest ofpullFetchTimeout, 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>
|
Thanks Claus, done in 72628e0: both back-to-back tests now check the bound with Claude Code on behalf of allthingssecurity |
davsclaus
left a comment
There was a problem hiding this comment.
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.
…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>
…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
left a comment
There was a problem hiding this comment.
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.
…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>
|
@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 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>
Description
CAMEL-25815
With
jetstreamEnabled=truethe consumer uses a pull subscription by default, and withoutdurableNamethe 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 with409 Consumer Deleted, which the pull loop logged and continued. The route stayed started and consumed nothing again until it was restarted.Changes (
NatsConsumer,DefaultNatsManualAck):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 noCONSUMER.INFOrequest (review follow-up f946dae). jnats 2.26.4 answersnullwhen the server has no such consumer (lookupConsumerInfomaps api error 10014 tonull):null, a 404 (for example stream not found, 10059), or a409 Consumer Deletedfrom a fetch means the consumer is gone. A failed check (not connected, timeout) changes nothing.WorkQueueretention (which accepts onlyDeliverPolicy.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 aDeliverPolicy.ByStartTimeconfiguration (.startTime(null); jnats then leavesopt_start_timeout of the request), as a start time does not go withByStartSequence. A sequence that is never acknowledged (a message nak'd untilmaxDeliveris used up) stays a start candidate until the next resubscribe, so the route can see the messages after it again (at-least-once). WithAckPolicy.Noneno sequence is tracked (the delivery is the acknowledgement): the new consumer starts after the highest one fetched.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.PullMessageManager.manageStatusreturnsSTATUS_TERMINUS), so the loop sent pull requests back to back (also on main). After an empty fetch that ended beforepullFetchTimeout, 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 than409 Consumer Deleted(for example409 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 inunexpectedFetchFailureIsNotFetchedAgainBackToBack).IllegalStateException, for example SUB-90007No matching streams for subjectwhen 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 the409path use the same helper.doStopmay already have unsubscribed the old one).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
durableNameconsumes again (a WARN says it subscribed again and from which stream sequence). A pull consumer withdurableNamewhose 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; acamel-natsnote in the 4.24 upgrade guide describes the ephemeral case and says that a durable is not created again.Tests:
NatsConsumerPullConsumerDeletedTest(4 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; aWorkQueuestream removes acknowledged messages and rejects a deliver policy other thanAll(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>andExpected: <1> but was: <0>).consumesAStreamCreatedAgainFromItsStartandworkQueueStreamIsSubscribedAgainWithDeliverPolicyAllfail 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.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 perpullFetchTimeout: without the wait the test counted 145357735 fetches in 6 s),failedSubscribeIsTriedAgainAtTheNextCheck(the first subscribe after the deletion throwsIllegalStateException: it goes to the exception handler, and the next check subscribes),startTimeIsNotSentWithTheStartSequence(aByStartTimeconfiguration is sent again asByStartSequencewithout 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 basedon 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).NatsJetstreamConsumerPullDeletedIT(real server in a container, runs in CI): deletes the ephemeral consumer withjsm.deleteConsumerwhile the route is busy with a message (no pull request waiting, so no409), and expects the next message to be routed.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
ByStartSequenceor a non-Alldeliver policy on aWorkQueuestream 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
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. 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