Skip to content

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
masterfrom
async-message-stream-processing
Open

Async, non-blocking consumer processing with heartbeat lease (lib-data-stream-redis + lib-cmd-queue-redis)#84
pditommaso wants to merge 4 commits into
masterfrom
async-message-stream-processing

Conversation

@pditommaso

Copy link
Copy Markdown
Contributor

Summary

Reworks lib-data-stream-redis so 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-redis adopts the shared model and drops its own timeout/lock machinery. Behaviour is opt-in via concurrency() (default 1).

This fixes three coupled defects that were previously impossible to resolve by tuning a single claim-timeout:

  • Head-of-line blocking — the dispatcher thread hands each message to a virtual-thread executor and never runs a handler inline, so a slow handler no longer stalls the poll loop.
  • 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, preserving real dead-consumer failover.
  • Slow status detection — a not-yet-terminal command keeps its lease and is re-invoked in-process after pollInterval (Model B), decoupling re-poll cadence from claim-timeout.

Sched tickets addressed

  • seqeralabs/sched#600 — VM provisioning blocked the command-queue dispatch thread (and, via claim-timeout reclaim, was re-executed cross-pod → churn). Fixed by async dispatch (no blocking) + heartbeat lease (no duplicate).
  • seqeralabs/sched#303 — Nextflow-observed task start/complete lagged real ECS transitions by up to ~60s because RUNNING commands were only re-polled once idle crossed the 60s claim-timeout. Fixed by in-process re-poll at pollInterval (~1s), independent of claim-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-redis 1.5.0 → 1.6.0

  • SPI: Lease<M> record + poll / renew / ack / release; new heartbeatInterval() / maxProcessingTime() (the Redis impl wires them from RedisStreamConfig so the renew cadence tracks the configured claim-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 via withHandlerExecutor(...)); in-flight bounded by a Semaphore (concurrency(), default 1).
  • RedisStreamConfig: getHeartbeatInterval() (default claim-timeout/3) and getMaxProcessingTime() (default 15m).
  • LocalMessageStream gets the lease triad (renew no-op, release re-offer) and benefits from async dispatch; no PEL ⇒ no heartbeat needed.
  • README: new Architecture section + diagram.

lib-cmd-queue-redis 0.4.0 → 0.5.0

  • Removed executeWithTimeout / BLOCKING future.get(timeout) / the per-command lock. execute() and checkStatus() run directly on the shared virtual-thread executor; cross-replica single-runner comes from the per-message lease.
  • CommandQueue.concurrency() = 1000 (in-flight ceiling; requires lib-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-compatible consume().

Design spec: docs/superpowers/specs/2026-07-11-async-message-stream-processing.md.

Notes for reviewers / rollout

  • Behaviour change for all consumers even at concurrency()=1: ack is deferred to handler completion; re-poll of a not-yet-terminal message is pollInterval (was claim-timeout); dispatch is off-thread. wave should get a regression pass before raising its concurrency().
  • A separate status-poll interval (distinct from pollInterval) is intentionally deferred until backend API rate limits require it.
  • Known follow-up (not a blocker; lib-cmd-queue-redis is insulated): a direct AbstractMessageStream consumer whose handler throws every invocation re-polls every pollInterval with no backoff while holding its permit.

🤖 Generated with Claude Code

pditommaso and others added 2 commits July 11, 2026 13:55
…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>
pditommaso and others added 2 commits July 11, 2026 14:14
…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>
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