Async, non-blocking consumer processing with heartbeat lease (lib-data-stream-redis + lib-cmd-queue-redis)#84
Open
pditommaso wants to merge 4 commits into
Open
Conversation
…odel B) Rework lib-data-stream-redis so the listener thread dispatches each message to a virtual-thread executor instead of running handlers inline, and hold a heartbeated per-message lease for the life of a command. This fixes three coupled defects: - sched#600 head-of-line blocking: a slow handler no longer stalls the poll loop. - sched#600 duplicate execution: a worker-scoped heartbeat (XCLAIM ... JUSTID every claim-timeout/3) keeps an alive consumer from being reclaimed, regardless of how long its handler runs; the lease lapses on crash to preserve real failover. - sched#303 slow status detection: a not-yet-terminal command keeps its lease and is re-invoked in-process after pollInterval (Model B), so re-poll cadence is decoupled from claim-timeout. lib-data-stream-redis (1.5.0 -> 1.6.0): - MessageStream SPI: add Lease<M> + poll/renew/ack/release; add heartbeatInterval()/ maxProcessingTime() (RedisMessageStream wires them from RedisStreamConfig so the renew cadence tracks the configured claim-timeout); consume() retained as a default. - AbstractMessageStream: dispatcher + scheduled re-poll + heartbeat daemon; handlers run on a shared virtual-thread executor (swap in the Micronaut BLOCKING executor via withHandlerExecutor); in-flight bounded by a semaphore (concurrency(), default 1). - RedisStreamConfig: getHeartbeatInterval() (claim-timeout/3) and getMaxProcessingTime(). lib-cmd-queue-redis (0.4.0 -> 0.5.0): - Remove executeWithTimeout / BLOCKING future.get / per-command lock; run execute() and checkStatus() directly on the shared virtual-thread executor; single-runner comes from the per-message lease. CommandQueue.concurrency()=1000 (in-flight ceiling). Design spec under docs/superpowers/specs. Verified: 43 tests green (unit + Testcontainers Redis integration), including no-reclaim-of-live-work, crash failover, and re-poll cadence. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r async/lease model Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jul 11, 2026
…lease invariant Behaviour-preserving cleanup (ponytail + Kent Implementation Patterns lenses): - run() decomposed into invokeHandler/acknowledge/shouldRepoll/scheduleRepoll (Composed Method + Guard Clause); cyclomatic complexity of the top method drops. - Extract releaseLease(key) so the 'a permit is held iff its key is in-flight' invariant lives in one place (was hand-maintained in run + heartbeatTick). - dispatchOne: single permit-release path via finally (was released in two branches). - processMessage: drop the write-only AtomicInteger count parameter. No behaviour change. 43 tests green (28 stream + 15 cmd-queue, incl. Testcontainers). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the binary 'RUNNING ? checkStatus : execute' with an explicit switch: SUBMITTED -> execute, RUNNING -> checkStatus, and any other status throws IllegalStateException (caught -> FAILED + ack) instead of silently falling into execute(). Only SUBMITTED/RUNNING are reachable today (verified across libseqera/sched/wave: nothing produces CommandStatus.PENDING); the default guards against a future status. No behaviour change. 43 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jul 11, 2026
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.
Summary
Reworks
lib-data-stream-redisso message handlers run asynchronously and concurrently off the poll thread, while a heartbeated per-message lease guarantees a single live runner across replicas.lib-cmd-queue-redisadopts the shared model and drops its own timeout/lock machinery. Behaviour is opt-in viaconcurrency()(default1).This fixes three coupled defects that were previously impossible to resolve by tuning a single
claim-timeout:XCLAIM … JUSTIDeveryclaim-timeout/3) keeps an alive consumer from being reclaimed regardless of how long its handler runs; the lease lapses on crash, preserving real dead-consumer failover.pollInterval(Model B), decoupling re-poll cadence fromclaim-timeout.Sched tickets addressed
claim-timeoutreclaim, was re-executed cross-pod → churn). Fixed by async dispatch (no blocking) + heartbeat lease (no duplicate).RUNNINGcommands were only re-polled once idle crossed the 60sclaim-timeout. Fixed by in-process re-poll atpollInterval(~1s), independent ofclaim-timeout.Supersedes the accept()-scoped heartbeat proposal in libseqera#73 (that lease was only correct while
accept()blocked the listener thread; async dispatch requires a worker-scoped lease).Changes
lib-data-stream-redis1.5.0 → 1.6.0Lease<M>record +poll/renew/ack/release; newheartbeatInterval()/maxProcessingTime()(the Redis impl wires them fromRedisStreamConfigso the renew cadence tracks the configuredclaim-timeout).consume(streamId, consumer)retained as a backward-compatible default over the triad.AbstractMessageStream: dispatcher + re-poll scheduler + heartbeat daemon; handlers run on a shared virtual-thread executor (swap in the Micronaut@Named(BLOCKING)executor viawithHandlerExecutor(...)); in-flight bounded by aSemaphore(concurrency(), default1).RedisStreamConfig:getHeartbeatInterval()(defaultclaim-timeout/3) andgetMaxProcessingTime()(default15m).LocalMessageStreamgets the lease triad (renew no-op, release re-offer) and benefits from async dispatch; no PEL ⇒ no heartbeat needed.lib-cmd-queue-redis0.4.0 → 0.5.0executeWithTimeout/BLOCKING future.get(timeout)/ the per-command lock.execute()andcheckStatus()run directly on the shared virtual-thread executor; cross-replica single-runner comes from the per-message lease.CommandQueue.concurrency() = 1000(in-flight ceiling; requireslib-data-stream-redis 1.6.0+).Verification
43 tests green — unit + Testcontainers Redis integration — including: no reclaim of live work, crash failover, re-poll cadence decoupled from
claim-timeout, serial-per-command, backpressure, and backward-compatibleconsume().Design spec:
docs/superpowers/specs/2026-07-11-async-message-stream-processing.md.Notes for reviewers / rollout
concurrency()=1: ack is deferred to handler completion; re-poll of a not-yet-terminal message ispollInterval(wasclaim-timeout); dispatch is off-thread.waveshould get a regression pass before raising itsconcurrency().pollInterval) is intentionally deferred until backend API rate limits require it.lib-cmd-queue-redisis insulated): a directAbstractMessageStreamconsumer whose handler throws every invocation re-polls everypollIntervalwith no backoff while holding its permit.🤖 Generated with Claude Code