Stop a broker restart from silently killing a service's JMS consumer - #2033
Open
jcschaff wants to merge 4 commits into
Open
Stop a broker restart from silently killing a service's JMS consumer#2033jcschaff wants to merge 4 commits into
jcschaff wants to merge 4 commits into
Conversation
JmsFailoverWatchdog only reaches its terminal action from the TransportListener that attach() installs, which leaves two gaps: a caller that detects a lost session for itself has no way to escalate it, and attach() installs nothing at all for a non-ActiveMQ connection, so any other provider has no route to the terminal action. Add onTerminalFailure(what, cause) as that route, and fold the existing transport path through it. Passing "transport" reproduces the previous log line exactly, so nothing reading logs today has to change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PFAraxXXN2KvRqgo9GPmv5
ConsumerContextJms treated every javax.jms.IllegalStateException out of receive() as deliberate shutdown and broke out of the poll loop. Half of that is right: close() unblocks a thread parked in receive(), and looping on a closed consumer would spin. But the provider raises the same exception when the session dies underneath a consumer we are still meant to be polling, and that is not shutdown -- it is the case that most needs reporting. The thread then ends for the life of the process. Nothing else notices: the process stays up, the pod stays Ready, and the service consumes nothing. Dev's submit service sat in exactly that state for 6h50m on 2026-08-24 after a network outage restarted the brokers, and the only external symptom was the sim health check timing out, which reads as a compute problem. It took a log-volume comparison against prod to find (issue #2031). bProcessing alone already identifies deliberate shutdown -- closeAll() and stopAndClose() both clear it before close() -- so test it alone, and route a session lost while still processing to the failover watchdog's terminal handler. The test injects the failure at the consumer rather than by stopping the broker: stopping the broker also drives the failover transport to the same terminal handler, so that version of the test passes on the broken code for the wrong reason. Verified against the pre-fix bytecode -- the regression case fails, and the two controls (ordinary shutdown, transient poll failure) pass both before and after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PFAraxXXN2KvRqgo9GPmv5
…n it Section 8 said the watchdog exits the JVM in production. That was the intent, not the behaviour -- no service had taken the escape hatch, so the terminal action was a no-op everywhere. Say which services opt in, and describe the consumer-side route that covers a session lost without the transport noticing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PFAraxXXN2KvRqgo9GPmv5
The failover transport gives up after maxReconnectAttempts, and d58cd12 made that deliberate: in K8s a pod restart is the right response to a sustained broker outage. It added jvmExitOnTerminal() as the escape hatch and left every service on the logOnly() default, to be taken up when a service wanted it. Nothing ever took it up. The result is that the terminal condition is detected and logged correctly and then nothing happens -- dev's submit service logged FATAL JMS transport unrecoverable, invoking terminal handler at 19:59:16Z on 2026-08-24 and kept running for another six hours around a connection that could never be used again. Take the hatch for the four processes whose whole job is consuming from the broker. Short-lived batch processes (SolverPreprocessor, SolverPostprocessor, JavaSimulationExecutable) and the API server keep the log-only default; they outlive neither the broker outage nor their own task. A restarted pod does not crash-loop while the broker is still down: startupMaxReconnectAttempts=-1 leaves the initial connect unbounded, so the process waits at boot and picks up when the broker returns. Refs #2031 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PFAraxXXN2KvRqgo9GPmv5
jcschaff
force-pushed
the
fix/jms-consumer-death-2031
branch
from
August 25, 2026 17:52
d1d558f to
6c597ad
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #2031.
A broker restart could kill a service's JMS consumer thread permanently while the process stayed
up and the pod stayed
Ready. Dev'ssubmitservice ran for 6h50m on 2026-08-24 in exactlythat state, consuming nothing.
What actually happened
The dev logs give the whole sequence, and it turned out to be two defects rather than one:
481 seconds between interrupt and giving up, matching
maxReconnectAttempts=20with the 1s→30sbackoff. Then nothing at all for six hours.
1. The consumer misread a dead session as shutdown.
ConsumerContextJmstreated everyjavax.jms.IllegalStateExceptionout ofreceive()as deliberate shutdown and broke out of thepoll loop. Half of that is right —
close()unblocks a thread parked inreceive(), and loopingon a closed consumer would spin. But the provider raises the same exception when the session dies
underneath a consumer we are still meant to be polling, which is the case that most needs
reporting.
2. The terminal condition was detected and then ignored.
d58cd1292ebounded the failoverreconnect budget on the reasoning that in K8s a pod restart is the right response to a sustained
broker outage, added
jvmExitOnTerminal()as the escape hatch, and left every service onlogOnly()for a future caller to take up.git log -S setFailoverWatchdogreturns exactly thatone commit — nobody ever did. So the FATAL above invoked a no-op.
Either defect alone is enough to produce the zombie: fixing only (1) leaves the process alive
around a connection it can never use again, and fixing only (2) leaves the consumer thread dying
silently wherever the watchdog is not attached —
attach()installs nothing for anon-
ActiveMQConnection, which matters if the Artemis work ever moves off the OpenWire client.The change
JmsFailoverWatchdog.onTerminalFailure(what, cause)— a route to the terminal action for afailure the caller detected itself. The existing transport path folds through it; passing
"transport"reproduces today's log line exactly.ConsumerContextJmsnow testsbProcessingalone to recognise shutdown (bothcloseAll()andstopAndClose()clear it beforeclose(), so it is sufficient), and escalates a session lostwhile still processing instead of exiting quietly.
submit,sched,dataanddbbuild their messaging service withcreateForLongLivedConsumerService(), taking the escape hatch. Short-lived batch processes(
SolverPreprocessor,SolverPostprocessor,JavaSimulationExecutable,OptimizationBatchServer)and the API server keep the log-only default — they outlive neither the broker outage nor their
own task.
docs/MESSAGING.md§8 said the watchdog exits the JVM in production. That was the intent, notthe behaviour; it now says who opts in.
A restarted pod does not crash-loop while the broker is still down:
startupMaxReconnectAttempts=-1leaves the initial connect unbounded, so the process waits at boot and picks up when the broker
returns.
Tests
ConsumerContextJmsTest— one regression case and two controls.The failure is injected at the consumer rather than by stopping the broker on purpose. Stopping
the broker also drives the failover transport to the same terminal handler, so that version of
the test passes on the broken code for the wrong reason; here the transport stays healthy and the
consumer's own escalation is the only thing that can fire the latch.
Verified against the pre-fix bytecode (
javap-checked, not just assumed):aSessionLostWhileStillPollingIsEscalatedanOrdinaryShutdownIsNotEscalatedaTransientPollFailureIsNotEscalatedvcell-serverFastgroup is otherwise unchanged.Not covered here
Nothing outside the process would still have noticed. #2032 tracks the missing
livenessProbeonthese four services — worth doing regardless, since this fix covers one way a consumer can die and
a probe covers all of them.
🤖 Generated with Claude Code
https://claude.ai/code/session_01PFAraxXXN2KvRqgo9GPmv5