Add async worker-pool processing to lib-cmd-queue-redis#74
Conversation
Process queued commands on a bounded worker pool instead of serially on the single poll thread, so multiple commands run concurrently and the poll thread never blocks on a handler. - Non-blocking dispatch: processCommand hands each command to a bounded ExecutorService and returns immediately; runCommand runs the handler to completion on a worker. - Single concurrent runner across replicas via a per-command distributed lock (lib-lock tryAcquire + watchdog): the lock is held while the holder is alive and expires on crash, giving at-least-once recovery. Same-replica dedup via an in-flight Set registered before dispatch. - Ack-on-reclaim: the worker never acks; a finished command's stream entry is removed on the next reclaim once the store shows a terminal state, so the shared message-stream interface is untouched. - Backward compatible: the execute()/checkStatus() external-job pattern is preserved (handlers overriding checkStatus poll; in-process handlers re-run on crash recovery); executeTimeout() kept and deprecated; new CommandConfig methods are defaults. Design spec under docs/superpowers/specs. Validated by unit tests plus Testcontainers integration tests proving single-runner across two replicas, crash-recovery dispatch, ack-on-reclaim and non-blocking intake (36 tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jordeu
left a comment
There was a problem hiding this comment.
Reviewed the async worker-pool changes. The core design is sound and well-tested — these are edge / failure-path hardening notes, each independently re-verified by an adversarial check (one was confirmed with a deterministic reproduction). None are happy-path correctness bugs.
- 2 worth fixing — both silently defeat the recovery guarantees the design advertises: the
inFlightleak on atryAcquireexception, and the reflection-basedcheckStatusmisclassification that can strand crash-recovered commands. - 2 low-cost defensive fixes — lock leak on forced
shutdownNow(), and the reserved-qualifier bean collision. - 1 trivial nicety — null
erroron a no-message exception.
Severity/likelihood and a suggested fix are in each inline comment.
| } | ||
|
|
||
| // Single-runner across replicas: only the lock holder runs the handler | ||
| final Lock lock = lockManager.tryAcquire(lockKey(id)); |
There was a problem hiding this comment.
[med–high] inFlight id leaks permanently if tryAcquire throws.
inFlight.add(id) runs at L299 before this unguarded tryAcquire; only pool.execute below is wrapped in try/catch. RedisLockManager.tryAcquire does jedisPool.getResource() + jedis.set(...), which throw an (unchecked) JedisException on a connection blip or pool exhaustion — more reachable now that the worker pool + lock watchdogs hold many connections. On a throw here, id is never removed from inFlight: the three remove sites (L306, L320, L376) are all unreachable on this path. Every later redelivery then hits !inFlight.add(id) → return false forever, so the command is unprocessable on this replica until JVM restart (and the set grows unbounded).
Fix: move inFlight.add(id) to after a successful acquire, or wrap the post-add body so inFlight.remove(id) runs on any exception.
| static boolean overridesCheckStatus(CommandHandler<?, ?> handler) { | ||
| try { | ||
| final Method m = handler.getClass().getMethod("checkStatus", Command.class, CommandState.class); | ||
| return m.getDeclaringClass() != CommandHandler.class; |
There was a problem hiding this comment.
[high when triggered] checkStatus-override detection misclassifies handlers, stranding crash-recovered commands.
getDeclaringClass() != CommandHandler.class is true for any class interposed between the leaf handler and the interface — e.g. an in-process handler extending an abstract base that declares checkStatus (no Micronaut needed), or a Micronaut $Intercepted proxy when type-level advice such as @Transactional/@Timed covers checkStatus. Such a handler is then treated as external-job: after a crash leaves it RUNNING, reclaim dispatches CHECK_STATUS, the default checkStatus returns running(), and the command stays RUNNING forever (redelivered indefinitely, never terminal). A deterministic repro confirmed both the abstract-base and proxy cases.
Fix: prefer an explicit signal (marker interface / registration flag / distinct subtype) over reflecting on class shape.
| try { | ||
| if (!p.awaitTermination(30, TimeUnit.SECONDS)) { | ||
| log.warn("Command worker pool did not drain within 30s - forcing shutdown"); | ||
| p.shutdownNow(); |
There was a problem hiding this comment.
[low–med] Forced shutdownNow() leaks the locks of queued-but-unstarted tasks.
Locks are acquired on the poll thread (L304) and released only in the worker's finally (L377). Up to commandPoolQueueSize (default 100) tasks can sit in the pool queue already holding a lock; shutdownNow() discards them (its returned task list is ignored here), so those locks/watchdogs never release. The common case self-heals via TTL (~1 min), but since stop() is a public method, calling it without a full context teardown leaves the watchdog (on a separate scheduler) renewing the orphaned lock indefinitely — and local mode's ConcurrentHashMap has no TTL at all.
Fix: capture the Runnables returned by shutdownNow() (or track held locks) and release them after the pool terminates.
| /** | ||
| * Reserved qualifier / {@link LockConfig} name for the command-queue single-runner lock. | ||
| */ | ||
| public static final String COMMAND_LOCK = "cmd-queue-internal-lock"; |
There was a problem hiding this comment.
[high if triggered, low likelihood] Reserved string qualifier can cause a NonUniqueBeanException at startup.
@EachBean(LockConfig) in RedisLockManagerFactory/LocalLockManagerFactory propagates each seqera.lock.<name> qualifier onto its LockManager. A host that configures seqera.lock.cmd-queue-internal-lock therefore creates a second @Named("cmd-queue-internal-lock") LockManager, making CommandServiceImpl's injection ambiguous → a hard context-startup failure. There is no programmatic guard (no @Primary), and CommandLockWiringTest can't catch it (it never sets that config).
Fix: a dedicated custom @Qualifier-meta-annotated annotation (e.g. @CommandLock) on the factory methods and the injection point makes the collision structurally impossible and is fully self-contained in this module.
| log.error("Command processing failed: id={}", id, e); | ||
| final var latest = store.findById(id).orElse(null); | ||
| if (latest != null && !latest.status().isTerminal()) { | ||
| store.save(latest.failed(e.getMessage())); |
There was a problem hiding this comment.
[minor] A handler exception with a null message is persisted as error = null.
The removed executeWithTimeout wrapped throws as "Command execution failed"; this now stores e.getMessage(), which is null for a no-message exception (e.g. new IllegalStateException()). Bounded — JVM-generated NPEs carry messages on Java 17/21, and the full stack trace is still logged at L368 — so it's diagnostic-only, no crash.
Fix (trivial): e.getMessage() != null ? e.getMessage() : e.toString().
Summary
Extends
lib-cmd-queue-redisto process queued commands on a bounded worker pool instead of serially on the single poll thread, so multiple commands run concurrently and the poll thread never blocks on a handler — while guaranteeing a command's handler is not run concurrently on more than one replica (exactly-once-ish: single concurrent runner, at-least-once on crash).Design spec:
docs/superpowers/specs/2026-07-01-async-command-processing-design.md.How it works
processCommand(poll thread) hands each command to a boundedExecutorServiceand returns immediately;runCommandruns the handler to completion on a worker.lib-locktryAcquire+ watchdog). The lock is auto-renewed while the holder is alive and expires by TTL on crash (no hand-rolled heartbeat). Same-replica dedup via an in-flightSetregistered before dispatch. In non-Redis mode an in-JVM lock is selected automatically.lib-data-stream-redisinterface is untouched. Result is visible in the store immediately; only the (harmless) stream entry lingers up to one claim timeout.Backward compatibility
execute()→running()+checkStatus()external-job pattern is preserved (handlers overridingcheckStatusare polled; in-process handlers re-run on crash recovery).executeTimeout()retained and@Deprecated(no longer consulted); newCommandConfigmethods aredefaults.MessageStream/AbstractMessageStream/RedisMessageStream.New deps:
lib-lock,lib-lock-redis,lib-activator,redis.clients:jedis.Testing — 36 tests, all green
slow/checkStatusscenario) · 17 new unit · 4 Testcontainers integration.Review
Went through two adversarial review passes (spec + code). All spec-review blockers were fixed and verified; the code review found no blockers.
Not included (release steps)
README.md/changelog.txt/VERSIONbump — left for a separate release commit.🤖 Generated with Claude Code