Replace lib-data-stream-redis with lib-data-workqueue - #1115
Conversation
Move the job queues from the message stream API onto the work queue API of libseqera. `lib-data-workqueue-redis` is `lib-data-stream-redis` 1.5.0 on lease semantics: the same Redis stream layout (xadd/xreadGroup/xautoclaim, xack+xdel on settle, same `data` field), so the queue keys and the consumer group are left untouched and messages queued before the upgrade are still delivered afterwards. What the lease buys over the previous claim-timeout heuristic: an in-flight entry is renewed at visibility-timeout/4, so a slow consumer's message can no longer be claimed by another replica mid-processing; and close() waits cooperatively for the current cycle instead of interrupting the listener thread after 1s, letting a consumer finish its writes before teardown. `JobManager` keeps its own admission control - launchJob/processJob are unchanged and still return a boolean, now mapped onto ACK/RETRY at the addConsumer site - so the maxRunningJobs cap on concurrently running jobs is unaffected. Config keys are renamed `wave.message-stream.*` -> `wave.work-queue.*` (claim-timeout -> visibility-timeout). The consumer group keeps defaulting to `wave-message-stream` so existing pending entries stay visible. The prod/stage overrides in platform-deployment need the matching rename. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@claude review |
There was a problem hiding this comment.
Commenting rather than approving — not because I found a defect, but because the safety of this swap rests on lib-data-workqueue:2.0.0 semantics I could not read from this checkout (no network access, the artifacts aren't in the local Gradle cache). The in-repo diff itself is clean and reads correctly.
What I verified in the repo
- No remaining references to
io.seqera.data.stream,MessageStreamorwave.message-streamanywhere undersrc/— the only survivors are the renamed docs section — so droppinglib-data-stream-redisleaves nothing dangling. - No
wave.message-stream.*keys inapplication*.yml(main or test); the defaults only ever lived in the@Valueannotations, soRedisWorkQueueConfigBeanis a faithful 1:1 replacement ofRedisStreamConfigBean. - Queue ids are byte-identical (
jobs-pending/v2,jobs-queue/v1) and the consumer group default stayswave-message-stream— the "no migration" claim holds at the level I can check, and the javadoc note explaining why that default looks stale is the right thing to have written down. - The exception paths in
launchJob/processJobstill returntrue→ACK, so a job that throws is still consumed rather than looped on. Unchanged from before. - CI is green, including
Build Wave (25).
What the correctness of this turns on, which I can't see
-
length()vs. in-flight entries.launchJob0gates admission onprocessingQueue.length() < config.maxRunningJobs— that only works iflength()counts leased-but-unsettled entries (the PEL), which it did under the oldxack+xdel-on-settle layout. IfAbstractWorkQueue.length()in 2.0.0 reports only unclaimed entries, every running job disappears from the count andmaxRunningJobssilently stops capping anything. The PR body says the Redis layout is identical, which implies this is fine — worth a one-line confirmation since it is the load-bearing assumption for the whole job pipeline. -
Delivery-attempt cap / dead-lettering.
processJobreturnsfalse→RETRYon every poll for the entire life of a running job, andpoll-intervaldefaults to1s. A 30-minute build settles on the order of 1800RETRYs against one entry. If 2.0.0 has any max-delivery-count that drops or dead-letters an entry, the job is stranded: nonotifyJobCompletion, nojobService.cleanup, and the build hangs until its await timeout. Nothing in the description suggests such a cap exists ("still at-least-once"), but "unbounded retries are fine" is exactly the kind of thing a 2.0 of a queue library might have changed. -
When a
RETRYbecomes visible again. The description's retry-pacing row implies a retried entry comes back on the next poll cycle (~1s), not aftervisibility-timeout. If it's the latter, job-completion detection latency goes from ~1s to the configured 45s in prod — a large behaviour change that wouldn't show up in the local test runs listed.
If those three are as the description says, this is a straight approve from me.
Release ordering
The wave.message-stream.* → wave.work-queue.* rename is silent in both directions: Micronaut ignores unknown keys, so an unrenamed ConfigMap doesn't fail, it just drops prod/stage from 45s to the 5s default. The description already flags this and says the platform-deployment change is prepared but not pushed — worth making that a hard merge dependency rather than a follow-up.
For the rollback direction specifically, accepting the old key as a nested fallback for one release makes it order-independent both ways with no coordination at all:
@Value('${wave.work-queue.visibility-timeout:${wave.message-stream.claim-timeout:5s}}')
Duration visibilityTimeoutSame for consumer-warn-timeout / consumer-group-name. Optional, but it removes the one hazard the description calls out.
Nits (non-blocking)
JobManagerTestexerciseslaunchJob/processJobdirectly and returns booleans, so nothing asserts the newboolean → ACK/RETRYmapping at theaddConsumersite. It's one line per queue, but it's the line that decides whether a message is deleted, and it's currently uncovered.- The PR body's changes table says
docs/configuration.md; the file actually touched isdocs/install/reference.md.
I could not run the suite or inspect the library jars in this environment, so treat the above as a read of the diff plus repo-wide greps, not as verification of the runtime behaviour.
|
Verified the three open questions against the
So all three are as the description says — no behaviour change from the swap. Separately actioning the two nits (mapping-site test coverage; PR body should read |
|
@munishchouhan can you please have a review at this PR ? |
|
Moves the job queues from the message stream API onto the work queue API of libseqera:
lib-data-stream-redis:1.2.0→lib-data-workqueue:2.0.0+lib-data-workqueue-redis:2.0.0.lib-data-workqueue-redisislib-data-stream-redis1.5.0 re-landed on lease semantics — identical Redis stream layout (xadd/xreadGroup/xautoclaim,xack+xdelon settle, samedatafield). Queue keys (jobs-pending/v2,jobs-queue/v1) and the consumer group are left untouched, so no migration is needed and messages queued before the upgrade are still delivered afterwards.Changes
build.gradleservice/data/workqueue/BaseWorkQueueBaseMessageStream, now onAbstractWorkQueue; same Moshi encoding strategyservice/data/workqueue/RedisWorkQueueConfigBeanRedisStreamConfigBean, now implementsRedisWorkQueueConfigJobPendingQueue/JobProcessingQueueBaseWorkQueue; queue ids unchangedJobManager(job) -> booleanbecomes(job, lease) -> Decision.ACK/RETRYdocs/configuration.mdlaunchJob/processJobare unchanged and still return a boolean, mapped ontoACK/RETRYat theaddConsumersite — soJobManagerkeeps its own admission control and themaxRunningJobscap is unaffected.Why: work queue vs message stream
claim-timeoutcan be claimed and run concurrently by another replica. The timeout had to exceed worst-case processing time — hence 45s in prod.visibility-timeout/4while the owner is alive (oneXCLAIM JUSTIDper queue per tick), holding the idle clock near zero. The timeout now only governs recovery from a dead owner.thread.interrupt()thenjoin(1s)— severs a consumer mid-processing, and an interrupt can hand a RESP-desynced connection back to the Jedis pool.closingflag checked at loop head, waits (default 10s) for the current cycle, never interrupts. AprocessJobcycle completes itsnotifyJobCompletion/cleanupbefore teardown.awaitQuiescent(timeout)available for a bounded drain.falseskipped the poll-interval sleep.ACK/DEFERREDcount as progress, so a queue that is merely retrying (job still running, pending queue full) paces at its poll interval.XPENDINGownership check first: an entry that drifted to another consumer is dropped and logged rather than seized back — the residual duplicate window is observable, not silent.Not used by this PR, but now available:
MessageConsumer.ready()(an admission gate checked before claiming — the natural home for themaxRunningJobscheck, which currently claims a message and then refuses it),MessageLease/DEFERREDwithretryAfter(delay), and lease/renewal/saturation metrics. Still at-least-once: renewal narrows the duplicate window, it does not close it.Rollout
No Redis migration. Entries queued by the old code are read as-is (the integration test logs
consume group=wave-message-stream already exists), and entries left pending by a terminating pod are reclaimed after the visibility timeout — in-flight jobs resume. Mixed-version replicas are safe both ways: an entry held by a new replica is renewed so an old replica's 45s-min-idle claim never sees it; an entry held by an old replica may be claimed after 45s idle, exactly as today.Config keys are renamed
wave.message-stream.*→wave.work-queue.*(claim-timeout→visibility-timeout), so the matching rename in platform-deployment (prod + stage,45s) must land with this. That change is prepared but not yet pushed.message-stream.claim-timeout, falling back to a 5s claim timeout with no renewal. Keeping both key sets in the ConfigMap for one release makes it order-independent in both directions. Running pods are unaffected either way — the ConfigMap is mounted viasubPath(never updated in place) and the Deployment has no config checksum annotation, so config is only read at container start.Also note the cooperative
close()can take up to 10s per queue, sequentially across two beans, against the default 30sterminationGracePeriodSeconds. In practice the dispatcher exits at its next loop-head check unless mid-consume; raising the grace period would add headroom.The consumer group keeps defaulting to
wave-message-streamdeliberately — it is a wire identifier now, and renaming it would strand the pending-entry list.Testing
compileGroovycleanio.seqera.wave.service.job.*— 39 tests, 0 failuresBuildStoreRedisTest(7) andRegistryControllerRedisTest(2) green against real Redis; logs confirmRedisWorkQueuereusing the existing consumer group and picking up the config bean (lease renewal period=1250ms)Full suite not run locally — left to CI.
🤖 Generated with Claude Code