Retry a command whose handler throws instead of terminal-failing it (cmd-queue 0.4.1) - #102
Merged
Merged
Conversation
pditommaso
added a commit
that referenced
this pull request
Jul 31, 2026
Recovers the error tracking originally released as 0.6.0 (e54229e, #89), dropped by the revert to 0.4.0 in #100. VERSION is deliberately untouched. Companion to the retry fix in #102: once a thrown handler is retried instead of terminal-failed, a command can retry indefinitely with nothing recording that it is happening. These fields make that visible. - errorsCount: consecutive processing errors since the last successful processing - modifiedAt: last-write timestamp - error: now also carries the message of a transient (non-terminal) processing error. It holds the most recent message, transient or terminal; a terminal failure is identified by status == FAILED, not by error being non-null. recordError is best-effort — a failed write is logged and never changes control flow, so the command is still kept in the queue and retried. The streak is reset on recovery, with a single write and only when there is something to reset, so healthy re-polls stay write-free. Backward-compatible: the new fields default to 0/null when older serialized state is read. One deliberate adaptation from e54229e, required by this tree: 0.4.x still has executeWithTimeout, which wraps a handler exception in a generic RuntimeException("Command execution failed"). #89 was written against #84, which had removed that method, so recording e.getMessage() verbatim was correct there but here would stamp every transient error on the execute() path with the same useless string. recordError now records the root cause's message via rootMessage(), which is also correct for the checkStatus() path where the exception propagates directly. Caught by #89's own test asserting error == 'Persistent boom'; it failed with 'Command execution failed' before the adaptation. Tests: the two specs from #89 plus its CommandState serialization coverage. Module suite 18/18 green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pditommaso
added a commit
that referenced
this pull request
Jul 31, 2026
Recovers the error tracking originally released as 0.6.0 (e54229e, #89), dropped by the revert to 0.4.0 in #100. VERSION is deliberately untouched. Companion to the retry fix in #102: once a thrown handler is retried instead of terminal-failed, a command can retry indefinitely with nothing recording that it is happening. These fields make that visible. - errorsCount: consecutive processing errors since the last successful processing - modifiedAt: last-write timestamp - error: now also carries the message of a transient (non-terminal) processing error. It holds the most recent message, transient or terminal; a terminal failure is identified by status == FAILED, not by error being non-null. recordError is best-effort — a failed write is logged and never changes control flow, so the command is still kept in the queue and retried. The streak is reset on recovery, with a single write and only when there is something to reset, so healthy re-polls stay write-free. Backward-compatible: the new fields default to 0/null when older serialized state is read. One deliberate adaptation from e54229e, required by this tree: 0.4.x still has executeWithTimeout, which wraps a handler exception in a generic RuntimeException("Command execution failed"). #89 was written against #84, which had removed that method, so recording e.getMessage() verbatim was correct there but here would stamp every transient error on the execute() path with the same useless string. recordError now records the root cause's message via rootMessage(), which is also correct for the checkStatus() path where the exception propagates directly. Caught by #89's own test asserting error == 'Persistent boom'; it failed with 'Command execution failed' before the adaptation. Tests: the two specs from #89 plus its CommandState serialization coverage. Module suite 18/18 green. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
pditommaso
changed the base branch from
revert/stream-1.5.0-cmdqueue-0.4.0
to
master
July 31, 2026 09:35
Restores the fix originally released as 0.5.1 (6c7b171, #87) and dropped by the revert to 0.4.0 in #100. Same hunk, re-cut on top of the 0.4.0 tree. No VERSION or changelog change — code and tests only. The catch in CommandServiceImpl.processCommandWithHandler treated any escaping exception as a terminal command outcome: it persisted a FAILED CommandState and returned true, so RedisMessageStream.consume acked and deleted the entry. There was no retry. That conflates "the handler threw" with "the command failed", and it fails asymmetrically because the two live in different stores. Command state is in Redis; the domain work is in Postgres. When Micronaut closes the HikariCP pool while the queue is still draining, the handler throws a JDBC error and the catch records a permanent verdict using the store that still works, about a failure caused by the store that does not — while the domain entity was never transitioned. Queue empty, command FAILED, entity dangling, nothing left to advance it, polling clients hanging (seqeralabs/sched#712). Fix: log and return false. The entry stays unacked in the PEL for XAUTOCLAIM to hand to a live consumer, and since the catch never persists started(), status stays SUBMITTED so the next delivery re-enters execute(). A genuine failure is signalled by returning a FAILED CommandResult, which the terminal branch above already handles. Independent of the async/heartbeat-lease model that #100 removed: this works on the synchronous 1.5.0 stream because consume() only xacks/xdels when the consumer returns true. Unchanged: a returned FAILED CommandResult is still terminal, and an unknown command type is still failed and acked (no handler exists to retry) — both covered by existing tests. Test: the spec from #87, verified to fail against the 0.4.0 catch and pass with the fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Recovers the error tracking originally released as 0.6.0 (e54229e, #89), dropped by the revert to 0.4.0 in #100. No VERSION or changelog change — code and tests only. Companion to the retry fix in the parent commit: once a thrown handler is retried instead of terminal-failed, a command can retry indefinitely with nothing recording that it is happening. These fields make that visible. - errorsCount: consecutive processing errors since the last successful processing - modifiedAt: last-write timestamp - error: now also carries the message of a transient (non-terminal) processing error. It holds the most recent message, transient or terminal; a terminal failure is identified by status == FAILED, not by error being non-null. recordError is best-effort — a failed write is logged and never changes control flow, so the command is still kept in the queue and retried. The streak is reset on recovery, with a single write and only when there is something to reset, so healthy re-polls stay write-free. Backward-compatible: the new fields default to 0/null when older serialized state is read. One deliberate adaptation from e54229e, required by this tree: 0.4.x still has executeWithTimeout, which wraps a handler exception in a generic RuntimeException("Command execution failed"). #89 was written against #84, which had removed that method, so recording e.getMessage() verbatim was correct there but here would stamp every transient error on the execute() path with the same useless string. recordError now records the root cause's message via rootMessage(), which is also correct for the checkStatus() path where the exception propagates directly. Caught by #89's own test asserting error == 'Persistent boom'; it failed with 'Command execution failed' before the adaptation. Tests: the two specs from #89 plus its CommandState serialization coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pditommaso
force-pushed
the
fix/cmd-queue-retry-on-handler-exception
branch
from
July 31, 2026 09:40
90465d4 to
53acfcd
Compare
pditommaso
added a commit
that referenced
this pull request
Jul 31, 2026
#100 and #102 (which also carried #103) are now on master, so this branch retargets there instead of stacking on the revert branch. Resolution: - VERSION and changelog changes are dropped from this branch entirely. Master merged #102/#103 without bumping cmd-queue past 0.4.0 or adding a changelog entry, so versioning for the whole 0.4.x line is one decision to make in one place, not something this PR should pre-empt. - CommandServiceImpl and AbstractMessageStream keep this branch's side, which is master's tree plus the drain: the #102 catch and #103 recordError/rootMessage reached this branch by cherry-pick before they were squashed onto master, so both sides carry them and only the drain is genuinely new. Verified: the diff against master is now the drain and nothing else — 5 files, no VERSION, no changelog. Signed-off-by: Paolo Di Tommaso <paolo.ditommaso@gmail.com>
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.
Stacked on #100 — base is
revert/stream-1.5.0-cmdqueue-0.4.0. Merge #100 first.Recovers the fix originally released as 0.5.1 (
6c7b171, #87), which #100's revert to 0.4.0 dropped. Same hunk, re-cut on the 0.4.0 tree, as @jordeu requested in their review of #100.The bug this restores the fix for
CommandServiceImpl.processCommandWithHandler's catch treated any escaping exception as a terminal command outcome:return truemakesRedisMessageStream.consume:155runxack+xdel, so the entry leaves both the stream and the PEL. No redelivery, ever.That conflates "the handler threw" with "the command failed", and it fails asymmetrically because the two live in different stores — command state in Redis, the domain work in Postgres:
execute()/checkStatus()throws a JDBC error.So the code records a permanent verdict using the store that still works, about a failure caused by the store that doesn't — while the domain entity was never transitioned. Queue empty, command
FAILED, entity dangling, nothing left to advance it, polling clients hanging forever (seqeralabs/sched#712).The fix
Log and
return false. The entry stays unacked in the PEL forXAUTOCLAIMto hand to a live consumer, and since the catch never persistsstarted(), status staysSUBMITTEDso the next delivery re-entersexecute(). A genuine failure is signalled by returning aFAILEDCommandResult, which the terminal branch above already handles.Independent of the async model #100 removed. This works on the synchronous 1.5.0 stream precisely because
consume()onlyxack/xdels when the consumer returnstrue— no heartbeat-lease machinery involved.Unchanged: a returned
FAILEDCommandResult is still terminal, and an unknown command type is still failed and acked atCommandServiceImpl:208(no handler exists to retry). Both are outside this catch and covered by existing tests.Both this PR and #101 are stacked on #100 and both currently claim 0.4.1. And sched's
fix/ordered-shutdown-drain(f8f09293f, sched#888) already pins0.4.1expecting the drain.Proposal: 0.4.1 = this fix (smaller, more urgent, one logical change), 0.4.2 = #101's drain, and sched#888 bumps its pin to
0.4.2. Happy to invert it instead — but the two cannot both be 0.4.1.Worth noting they are complementary, not alternatives: the drain narrows the window in which a handler is cut short, but step 2 is deadline-bounded (
CommandServiceImpl:128) and on expiry it closes the queue and returnsfalseanyway (:138-144). Any handler still running then throws — into this catch. Drain narrows the window; this makes what's left recoverable. Neither is sufficient alone.Also note there is no safety net behind this: sched#772 re-accepted the regression assuming ReconcileCron would re-drive stranded tasks, but
6d36bad0b("re-drive PENDING tasks stranded by a lost command") is not reachable from sched'sorigin/master, and there is no ReconcileCron on master at all.Not included
#94's api-scope fix —CommandQueuepublicly extendsAbstractMessageStreamand exposesMessageConsumer, but #100 restoresimplementation project(':lib-data-stream-redis'), breaking downstream subclassers in Gradle composite builds. Out of scope here (this PR is #87 only); it needs its own hunk somewhere in the 0.4.x line.Release marker
No
[release]in the title, matching #100. But unlike #100, 0.4.1 is a new version — it will not reach the Maven repo, and sched cannot consume it, until a merge commit carries[release]. Add the marker when you actually want it published.Verification
should retry a handler that throws instead of failing it terminally(the one from [release] Retry command on handler exception instead of terminal-failing it #87) — confirmed to fail against the 0.4.0 catch and pass with the fix, so it genuinely guards the behaviour.:lib-cmd-queue-redis:test— 16 tests, 0 failures, on a forced--rerun.🤖 Generated with Claude Code