Skip to content

Drain in-flight work before releasing the queue - #101

Open
pditommaso wants to merge 6 commits into
masterfrom
feat/queue-drain-shutdown
Open

Drain in-flight work before releasing the queue#101
pditommaso wants to merge 6 commits into
masterfrom
feat/queue-drain-shutdown

Conversation

@pditommaso

@pditommaso pditommaso commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Retargeted to master now 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.

File
CommandService.java drain(Duration) + activeCommands()
CommandServiceImpl.java drain implementation, in-flight counter
AbstractMessageStream.java awaitQuiescent(Duration), cooperative close()
CommandServiceDrainTest.groovy new
AbstractMessageStreamDrainTest.groovy new

Why

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 a closing flag 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 new closeTimeout() (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 to false. 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. Returns false if the timeout elapsed with work outstanding, leaving the caller to decide.
  • activeCommands() — count of executions in progress.
  • The counter is taken around the task submitted to the executor, not around Future.get. executeWithTimeout abandons 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-managed TaskExecutors.BLOCKING pool, 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 GracefulShutdownCapable landed in 4.9.0. The binding lives in sched (on 4.10), which hooks drain() to ShutdownEvent — published by DefaultBeanContext.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:

  • An execution that throws while the dispatcher is still waiting on it (duration < 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.
  • An execution abandoned on timeout keeps running on the executor, and executeWithTimeout never retrieves its result — so an exception it throws is swallowed by the un-awaited Future and never reaches that catch. It can only be waited for, which is what this PR adds.

Where execute-timeout is short relative to real handler duration, the abandoned case is the common one. In sched, execute-timeout is 1s against measured 5–7s batches.

Also worth correcting something I wrote earlier in this PR: #102 does not fix the sched#888 path. TaskLaunchBatchHandler catches Exception itself and returns CommandResult.failure(...), so its exception never reaches #102's catch. #102 protects a different and larger surface — chiefly TaskSubmitHandler.execute, which has no try block at all.

No VERSION or changelog changes — deliberate

Master merged #102 and #103 together in d457e5d without bumping cmd-queue past 0.4.0 and 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 VERSION and writes a single entry for all three. Nothing here can be consumed downstream until that happens — and .github/workflows/build.yml:66 publishes only when the master head commit message contains [release].

Note seqeralabs/sched#890 currently pins 0.4.1 / 1.5.1 in anticipation; those pins need to match whatever numbers are actually cut.

Verification

:lib-cmd-queue-redis:test + :lib-data-stream-redis:test with --rerun-tasks, against master's tree: 0 failures. Full repo suite also green (1058 tests).

Spec Tests
AbstractMessageStreamDrainTest 5 — cooperative quiescence lets an in-progress consumer finish uninterrupted; no further messages claimed after the drain begins; false reported when the consumer outlives the timeout; close() no longer interrupts; no-op with no consumer registered
CommandServiceDrainTest 3 — waits for a handler abandoned by the execute-timeout and never interrupts it; reports false with the count still visible on overrun; no-op when never started

The drain spec sets execute-timeout: 200ms against a 1.5s handler, reproducing the production shape where an execution outlives the timeout and continues after execute() has already returned to the dispatcher.

Honest limits

  • Bounded, therefore not a guarantee. The caller's timeout is a hard cap; on expiry the queue is released and teardown proceeds with handlers still running. Per the window split above, an abandoned execution's exception is swallowed, so on overrun that work is lost with nothing recorded. Handler-side idempotency remains necessary (seqeralabs/sched#889).
  • Only covers graceful termination. SIGKILL, OOM-kill, node loss and spot reclamation produce no drain at all.
  • Recovery latency after an incomplete drain is bounded by the stream claim timeout (60s in sched), since an unacked PEL entry waits for XAUTOCLAIM.
  • Capables drain in parallel, not in order on the consumer side, so the HTTP server does not necessarily stop accepting before the queue quiesces. A late request can enqueue a command this replica will not process; benign, since a peer picks it up, but not sequenced.
  • A single stuck handler can burn the whole budget, starving the rest of the drain. No per-command deadline.

🤖 Generated with Claude Code

pditommaso and others added 2 commits July 31, 2026 09:22
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>
pditommaso and others added 3 commits July 31, 2026 11:10
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>
@pditommaso pditommaso changed the title Drain in-flight work before releasing the queue (stream 1.5.1 / cmd-queue 0.4.1) Ordered shutdown for the command queue: retry-on-throw, error tracking, and drain (stream 1.5.1 / cmd-queue 0.4.1) 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>
@pditommaso pditommaso changed the title Ordered shutdown for the command queue: retry-on-throw, error tracking, and drain (stream 1.5.1 / cmd-queue 0.4.1) Drain in-flight work before releasing the queue Jul 31, 2026
@pditommaso
pditommaso changed the base branch from revert/stream-1.5.0-cmdqueue-0.4.0 to master July 31, 2026 09:52
@jordeu
jordeu self-requested a review July 31, 2026 09:58
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