Skip to content

Retry a command whose handler throws instead of terminal-failing it (cmd-queue 0.4.1) - #102

Merged
pditommaso merged 2 commits into
masterfrom
fix/cmd-queue-retry-on-handler-exception
Jul 31, 2026
Merged

Retry a command whose handler throws instead of terminal-failing it (cmd-queue 0.4.1)#102
pditommaso merged 2 commits into
masterfrom
fix/cmd-queue-retry-on-handler-exception

Conversation

@pditommaso

Copy link
Copy Markdown
Contributor

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:

} catch (Exception e) {
    log.error("Command processing failed: id={}", msg.commandId(), e);
    store.save(state.failed(e.getMessage()));   // CommandStatus.FAILED — terminal
    return true;                                 // consume() then xacks + xdels
}

return true makes RedisMessageStream.consume:155 run xack + 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:

  1. Micronaut closes the HikariCP pool while the queue is still draining.
  2. The handler's execute()/checkStatus() throws a JDBC error.
  3. The catch's Redis write succeeds — Redis is still up.
  4. The entry is acked and deleted.

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 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 model #100 removed. This works on the synchronous 1.5.0 stream precisely because consume() only xack/xdels when the consumer returns true — no heartbeat-lease machinery involved.

Unchanged: a returned FAILED CommandResult is still terminal, and an unknown command type is still failed and acked at CommandServiceImpl:208 (no handler exists to retry). Both are outside this catch and covered by existing tests.

⚠️ Version collision with #101 — needs a decision

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 pins 0.4.1 expecting 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 returns false anyway (: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's origin/master, and there is no ReconcileCron on master at all.

Not included

#94's api-scope fix — CommandQueue publicly extends AbstractMessageStream and exposes MessageConsumer, but #100 restores implementation 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

🤖 Generated with Claude Code

@pditommaso
pditommaso requested a review from jordeu July 31, 2026 08:54
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
pditommaso changed the base branch from revert/stream-1.5.0-cmdqueue-0.4.0 to master July 31, 2026 09:35
pditommaso and others added 2 commits July 31, 2026 11:37
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
pditommaso force-pushed the fix/cmd-queue-retry-on-handler-exception branch from 90465d4 to 53acfcd Compare July 31, 2026 09:40
@pditommaso
pditommaso merged commit d457e5d into master Jul 31, 2026
3 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant