Drain in-flight work before releasing the queue - #101
Open
pditommaso wants to merge 6 commits into
Open
Conversation
Restore both module trees to their state at 8bad0f5 ([release] lib-data-stream-redis@1.5.0, 14 May 2026) — the commit where stream was 1.5.0 and cmd-queue 0.4.0 — undoing everything released on top of them: - stream 2.0.0 / cmd-queue 0.5.0: async, non-blocking consumer processing with heartbeat lease and the poll/renew/ack/release SPI (PR #84, bdf9374), plus doc follow-ups 11fdd3a and 86ae9ac - cmd-queue 0.5.1: retry command on handler exception (PR #87, 6c7b171) - cmd-queue 0.6.0: error tracking on CommandState (PR #89, e54229e, 1f124f0) - cmd-queue 0.7.0: migration to lib-data-workqueue(-redis) and the CommandStatus SUBMITTED->PENDING / RUNNING->PROCESSING rename (PR #86, f3f4ac4); README follow-up PR #91 (e8fe916) - the parts of PR #94 (d38afb0) that touched these two modules' sources cmd-queue therefore depends on lib-data-stream-redis again. lib-data-workqueue and lib-data-workqueue-redis are left in place untouched — they carry the lease-based design forward and were never published. Two build-infra bits from PR #94 are deliberately kept rather than reverted, since they are repo-wide conventions and not module API: the io.seqera.micronaut-library-conventions plugin id (Java 25 target for Micronaut modules) and Groovy 4.0.31 for stream's test dependencies (4.0.24 cannot run on a JDK 25 toolchain). The changelogs keep a REVERTED entry recording the withdrawn versions with their PRs, and cmd-queue's notes the downgrade hazard: 0.7.0-persisted command state uses PENDING/PROCESSING with a 7-day TTL, which this 0.4.0 code cannot decode. Pre-revert state is preserved on branch archive/workqueue-pre-revert (2ecc744), which also carries the unmerged invocation-lease rework. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A consumer still running when the application shuts down could be cut short and then fail against collaborators that had already been destroyed. In sched this stranded tasks: a command handler mid-execution lost its database connection, failed, and failed again trying to record that failure, leaving nothing to advance the entity (seqeralabs/sched#888). Neither module could express "stop taking work, but let what is running finish". lib-data-stream-redis (1.5.1): - awaitQuiescent(Duration): set a `closing` flag the dispatcher observes at the head of its loop, then wait for it to exit. The cycle already in progress always runs to completion, which is what gives a consumer time to finish. - close() now drains cooperatively first and only interrupts a dispatcher that overran closeTimeout(). Interrupt was previously the primary mechanism, and interrupting a thread parked in a Redis read can hand a RESP-desynced connection back to the pool (libseqera#92) — the very failure mitigated separately by redis.pool.testOnBorrow, which defaults to false. - Poll-interval and backoff sleeps are sliced so neither holds up a drain. - Interrupts are recognised in the catch arm and exit the loop instead of being logged as stream errors with a backoff. lib-cmd-queue-redis (0.4.1): - drain(Duration): quiesce the dispatcher, wait for in-flight handlers, then release the queue — in that order, so steps 1-2 run with every collaborator still usable. Returns false if the timeout was reached with work outstanding, leaving the caller to decide. - activeCommands(): count of executions in progress. - The counter is incremented around the task submitted to the executor rather than around Future.get, because executeWithTimeout abandons the future on overrun while the handler keeps running. That abandoned work is precisely what a drain has to wait for, and it is invisible from the shared, container-managed executor. Deliberately framework-agnostic: drain() takes a timeout and returns a boolean, leaving the trigger and the budget to the caller. This module targets Micronaut 4.8, which predates the 4.9 graceful-shutdown API, so the binding cannot live here anyway. stop() is unchanged and remains the immediate-release path. Tests: 5 covering cooperative quiescence, no-further-claims, timeout reporting and that close() no longer interrupts; 3 covering drain against a handler abandoned by the execute timeout. Both module suites green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Paolo Di Tommaso <paolo.ditommaso@gmail.com>
This was referenced Jul 31, 2026
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. 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. Module suite 16/16 green. 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. 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>
The three changes now in this release landed as separate branches, each adding its own bullets at the top of the entry. Reordered into dependency order — retry fix, then the error tracking that exists because of it, then the drain — and added the window split between the retry fix and the drain: an execution that throws while the dispatcher is still waiting is retried by the catch, whereas one abandoned on execute-timeout keeps running with its exception never retrieved from the Future, so it can only be waited for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Paolo Di Tommaso <paolo.ditommaso@gmail.com>
#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>
pditommaso
changed the base branch from
revert/stream-1.5.0-cmdqueue-0.4.0
to
master
July 31, 2026 09:52
jordeu
self-requested a review
July 31, 2026 09:58
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.
Retargeted to
masternow that #100 and #102 have landed. No VERSION or changelog changes — see below.The diff against master is the drain and nothing else: 5 files, +516/−14.
CommandService.javadrain(Duration)+activeCommands()CommandServiceImpl.javaAbstractMessageStream.javaawaitQuiescent(Duration), cooperativeclose()CommandServiceDrainTest.groovyAbstractMessageStreamDrainTest.groovyWhy
Neither module can express "stop taking work, but let what is already running finish". A consumer still executing when the application shuts down is cut short, and then fails against collaborators that have already been destroyed.
In sched this stranded tasks (seqeralabs/sched#888): a rollout destroyed the Hikari pool while a command handler was mid-execution. The handler failed on its next query, failed again trying to record that failure, and the command was acked as terminally failed — 6 tasks across 2 runs left in a state clients read as
SUBMITTED, still being polled 27h later.The consumer had no way to say "wait for me", and the caller had no way to ask.
lib-data-stream-redis
awaitQuiescent(Duration)— sets aclosingflag the dispatcher observes at the head of its loop, then waits for it to exit. The cycle already in progress always runs to completion, which is what gives a consumer time to finish its work and its writes.close()now drains cooperatively first, interrupting only a dispatcher that overran the newcloseTimeout()(10s, overridable).This is the behavioural change worth a second look. Interrupt used to be the primary mechanism, and interrupting a thread parked in a Redis read can hand a RESP-desynced connection back to the pool — precisely what [release] Add redis.pool.testOnBorrow option to lib-jedis-pool — v1.1.0 #95 mitigates via
redis.pool.testOnBorrow, which defaults tofalse. This removes the cause rather than relying on a disabled-by-default mitigation.Poll-interval and error-backoff sleeps are sliced (50ms) so neither holds up a drain.
An interrupt is now recognised in the dispatcher's catch arm and exits the loop, instead of being logged at ERROR as a stream error and retried after a backoff — previously every forced stop produced that noise.
lib-cmd-queue-redis
drain(Duration)— quiesce the dispatcher → wait for in-flight handlers → then release the queue. That order matters: steps 1–2 run with every collaborator still usable. Returnsfalseif the timeout elapsed with work outstanding, leaving the caller to decide.activeCommands()— count of executions in progress.Future.get.executeWithTimeoutabandons the future on overrun while the handler keeps running, and that abandoned execution is exactly what a drain must wait for. It also cannot be observed from the shared, container-managedTaskExecutors.BLOCKINGpool, so the bookkeeping has to be explicit.stop()is unchanged and remains the immediate-release path.Framework-agnostic by necessity, not taste: this module targets Micronaut 4.8.18, and
GracefulShutdownCapablelanded in 4.9.0. The binding lives in sched (on 4.10), which hooksdrain()toShutdownEvent— published byDefaultBeanContext.stop()before any bean is destroyed.How this relates to #102, now that it has landed
They cover different windows, and neither subsumes the other:
execute-timeout) reaches Retry a command whose handler throws instead of terminal-failing it (cmd-queue 0.4.1) #102's catch and is retried.executeWithTimeoutnever retrieves its result — so an exception it throws is swallowed by the un-awaitedFutureand never reaches that catch. It can only be waited for, which is what this PR adds.Where
execute-timeoutis short relative to real handler duration, the abandoned case is the common one. In sched,execute-timeoutis 1s against measured 5–7s batches.Also worth correcting something I wrote earlier in this PR: #102 does not fix the sched#888 path.
TaskLaunchBatchHandlercatchesExceptionitself and returnsCommandResult.failure(...), so its exception never reaches #102's catch. #102 protects a different and larger surface — chieflyTaskSubmitHandler.execute, which has notryblock at all.No VERSION or changelog changes — deliberate
Master merged #102 and #103 together in
d457e5dwithout bumping cmd-queue past0.4.0and without a changelog entry. So the 0.4.x line currently has three unreleased functional changes (retry-on-throw, error tracking, and this drain) and no version to describe them.Rather than have this PR pick a number and write an entry covering two changes it did not make, versioning is left as one decision in one place. Whoever cuts the release sets
VERSIONand writes a single entry for all three. Nothing here can be consumed downstream until that happens — and.github/workflows/build.yml:66publishes only when the master head commit message contains[release].Note seqeralabs/sched#890 currently pins
0.4.1/1.5.1in anticipation; those pins need to match whatever numbers are actually cut.Verification
:lib-cmd-queue-redis:test+:lib-data-stream-redis:testwith--rerun-tasks, against master's tree: 0 failures. Full repo suite also green (1058 tests).AbstractMessageStreamDrainTestfalsereported when the consumer outlives the timeout;close()no longer interrupts; no-op with no consumer registeredCommandServiceDrainTestfalsewith the count still visible on overrun; no-op when never startedThe drain spec sets
execute-timeout: 200msagainst a 1.5s handler, reproducing the production shape where an execution outlives the timeout and continues afterexecute()has already returned to the dispatcher.Honest limits
XAUTOCLAIM.🤖 Generated with Claude Code