@furystack/redis-task-runner 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +128 -0
  2. package/esm/cross-process-smoke.spec.d.ts +2 -0
  3. package/esm/cross-process-smoke.spec.d.ts.map +1 -0
  4. package/esm/cross-process-smoke.spec.js +463 -0
  5. package/esm/cross-process-smoke.spec.js.map +1 -0
  6. package/esm/define-redis-task-runner.d.ts +38 -0
  7. package/esm/define-redis-task-runner.d.ts.map +1 -0
  8. package/esm/define-redis-task-runner.js +60 -0
  9. package/esm/define-redis-task-runner.js.map +1 -0
  10. package/esm/index.d.ts +7 -0
  11. package/esm/index.d.ts.map +1 -0
  12. package/esm/index.js +4 -0
  13. package/esm/index.js.map +1 -0
  14. package/esm/multi-service-smoke.spec.d.ts +2 -0
  15. package/esm/multi-service-smoke.spec.d.ts.map +1 -0
  16. package/esm/multi-service-smoke.spec.js +468 -0
  17. package/esm/multi-service-smoke.spec.js.map +1 -0
  18. package/esm/redis-queue-adapter.d.ts +125 -0
  19. package/esm/redis-queue-adapter.d.ts.map +1 -0
  20. package/esm/redis-queue-adapter.js +566 -0
  21. package/esm/redis-queue-adapter.js.map +1 -0
  22. package/esm/redis-queue-adapter.unit.spec.d.ts +2 -0
  23. package/esm/redis-queue-adapter.unit.spec.d.ts.map +1 -0
  24. package/esm/redis-queue-adapter.unit.spec.js +860 -0
  25. package/esm/redis-queue-adapter.unit.spec.js.map +1 -0
  26. package/esm/redis-task-runner.d.ts +33 -0
  27. package/esm/redis-task-runner.d.ts.map +1 -0
  28. package/esm/redis-task-runner.integration.spec.d.ts +2 -0
  29. package/esm/redis-task-runner.integration.spec.d.ts.map +1 -0
  30. package/esm/redis-task-runner.integration.spec.js +536 -0
  31. package/esm/redis-task-runner.integration.spec.js.map +1 -0
  32. package/esm/redis-task-runner.js +43 -0
  33. package/esm/redis-task-runner.js.map +1 -0
  34. package/esm/redis-task-runner.unit.spec.d.ts +2 -0
  35. package/esm/redis-task-runner.unit.spec.d.ts.map +1 -0
  36. package/esm/redis-task-runner.unit.spec.js +97 -0
  37. package/esm/redis-task-runner.unit.spec.js.map +1 -0
  38. package/package.json +63 -0
  39. package/src/cross-process-smoke.spec.ts +459 -0
  40. package/src/define-redis-task-runner.ts +91 -0
  41. package/src/index.ts +8 -0
  42. package/src/multi-service-smoke.spec.ts +511 -0
  43. package/src/redis-queue-adapter.ts +733 -0
  44. package/src/redis-queue-adapter.unit.spec.ts +613 -0
  45. package/src/redis-task-runner.integration.spec.ts +440 -0
  46. package/src/redis-task-runner.ts +67 -0
  47. package/src/redis-task-runner.unit.spec.ts +110 -0
@@ -0,0 +1,733 @@
1
+ import type {
2
+ ClaimedTask,
3
+ ClaimOutcome,
4
+ EnqueueInput,
5
+ IdempotencyLeaseInput,
6
+ QueueAdapter,
7
+ QueueAdapterCapabilities,
8
+ WorkerSubscription,
9
+ } from '@furystack/task-runner'
10
+ import type { RedisClientType } from 'redis'
11
+
12
+ type RedisLikeClient = RedisClientType
13
+
14
+ /**
15
+ * Options accepted by {@link RedisQueueAdapter}.
16
+ */
17
+ export type RedisQueueAdapterOptions = {
18
+ /**
19
+ * Caller-owned `redis` client. Must be connected before the adapter
20
+ * is used. The adapter never closes it — lifecycle stays with the
21
+ * application, mirroring `@furystack/redis-cross-node-bus` and
22
+ * `@furystack/s3-blob-store`.
23
+ */
24
+ client: RedisLikeClient
25
+ /**
26
+ * Logical service name. Used in telemetry attribution and (when
27
+ * `topicPrefix` is left default) helps debug shared brokers.
28
+ */
29
+ serviceName: string
30
+ /**
31
+ * Wire-level prefix applied to every stream key. Multi-service
32
+ * deployments pick a distinct prefix per service.
33
+ */
34
+ topicPrefix?: string
35
+ /**
36
+ * Stable consumer group name. All workers join this group; PRD §7.4
37
+ * relies on consumer-group competing-consumer semantics for fan-out.
38
+ * Default: `runner`.
39
+ */
40
+ consumerGroup?: string
41
+ /**
42
+ * Default visibility timeout (milliseconds). A handler attempt is
43
+ * considered stale when its PEL idle time exceeds this; XAUTOCLAIM
44
+ * reclaims it for another consumer. Apps can override per task type
45
+ * via {@link RedisQueueAdapterOptions.visibilityTimeoutByType}.
46
+ * Default: `60_000`.
47
+ */
48
+ visibilityTimeoutMs?: number
49
+ /**
50
+ * Per-task-type visibility-timeout override map. Useful when one
51
+ * type does GPU-bound work and another short polls.
52
+ */
53
+ visibilityTimeoutByType?: Record<string, number>
54
+ /**
55
+ * `XREADGROUP BLOCK` timeout (milliseconds). Lower values reduce
56
+ * wakeup latency on shutdown; higher values reduce broker chatter.
57
+ * Default: `200`.
58
+ */
59
+ blockTimeoutMs?: number
60
+ /**
61
+ * Backoff between `XREADGROUP` attempts after a transport error so
62
+ * a disconnected broker does not produce a tight reconnect spin.
63
+ * Default: `250`.
64
+ */
65
+ retryBackoffMs?: number
66
+ /**
67
+ * Idempotency-lease TTL (seconds) for
68
+ * {@link RedisQueueAdapter.acquireIdempotencyLease}. Default: 24h.
69
+ */
70
+ idempotencyTtlSec?: number
71
+ /**
72
+ * Delayed-dispatch tick interval (milliseconds). Each tick atomically
73
+ * pops due entries from the scheduler ZSET and `XADD`s them onto the
74
+ * matching `(type, version)` stream. Lower values = lower delay
75
+ * jitter at the cost of broker chatter; higher values = looser
76
+ * floor on `notBefore` granularity. Default: `250`.
77
+ */
78
+ schedulerIntervalMs?: number
79
+ /**
80
+ * Fleet-wide max-concurrent claims per task type. A claim whose lane
81
+ * is at its cap is released back to the stream and retried, so the
82
+ * limit holds across every worker process sharing the broker (PRD
83
+ * §11 fleet cap). Types absent from the map are uncapped. Enforced by
84
+ * a per-type ZSET of holder tokens scored by expiry (crash-safe TTL
85
+ * tied to the type's visibility timeout).
86
+ */
87
+ concurrencyLimits?: Record<string, number>
88
+ }
89
+
90
+ const CAPABILITIES: QueueAdapterCapabilities = Object.freeze({
91
+ persistent: true,
92
+ distributed: true,
93
+ delayedDispatch: true,
94
+ fleetCapEnforcement: true,
95
+ brokerSideReclaim: true,
96
+ })
97
+
98
+ const DEFAULT_GROUP = 'runner'
99
+ const DEFAULT_VISIBILITY_MS = 60_000
100
+ const DEFAULT_BLOCK_MS = 200
101
+ const DEFAULT_RETRY_BACKOFF_MS = 250
102
+ const DEFAULT_IDEMPOTENCY_TTL_SEC = 86_400
103
+ const DEFAULT_SCHEDULER_INTERVAL_MS = 250
104
+ const SCHEDULER_BATCH_LIMIT = 64
105
+
106
+ /**
107
+ * Atomically: pop ZSET members whose score ≤ now, parse their JSON
108
+ * envelope, XADD the resulting message to the per-`(type, version)`
109
+ * stream, and ZREM the member. Single round-trip prevents two adapter
110
+ * instances from double-dispatching the same delayed task.
111
+ *
112
+ * `KEYS[1]` — scheduler ZSET key.
113
+ * `ARGV[1]` — current epoch ms (caller-supplied so the script's own
114
+ * server-clock drift cannot diverge from the runner's notion of `now`).
115
+ * `ARGV[2]` — batch limit.
116
+ * `ARGV[3]` — `topicPrefix` used to build the destination stream key.
117
+ *
118
+ * Returns the number of dispatched entries.
119
+ */
120
+ const SCHEDULER_DISPATCH_SCRIPT = `
121
+ local now = tonumber(ARGV[1])
122
+ local limit = tonumber(ARGV[2])
123
+ local prefix = ARGV[3]
124
+ local sched = KEYS[1]
125
+
126
+ local members = redis.call('ZRANGEBYSCORE', sched, '-inf', now, 'LIMIT', 0, limit)
127
+ local dispatched = 0
128
+ for _, member in ipairs(members) do
129
+ local removed = redis.call('ZREM', sched, member)
130
+ if removed == 1 then
131
+ local meta = cjson.decode(member)
132
+ local stream = prefix .. 'tasks:queue:' .. meta.type .. ':v' .. tostring(meta.handlerVersion)
133
+ redis.call('XADD', stream, '*',
134
+ 'taskId', meta.taskId,
135
+ 'type', meta.type,
136
+ 'handlerVersion', tostring(meta.handlerVersion))
137
+ dispatched = dispatched + 1
138
+ end
139
+ end
140
+ return dispatched
141
+ `
142
+
143
+ /**
144
+ * Atomically admit one holder to a fleet-cap lane. Evicts expired
145
+ * holders (score ≤ now), then admits the caller's token only if the
146
+ * lane is below its cap.
147
+ *
148
+ * `KEYS[1]` — per-type cap ZSET key.
149
+ * `ARGV[1]` — current epoch ms (used to evict expired holders).
150
+ * `ARGV[2]` — cap (max concurrent holders).
151
+ * `ARGV[3]` — caller's holder token.
152
+ * `ARGV[4]` — holder expiry epoch ms (now + visibility timeout). The
153
+ * TTL frees a crashed holder's slot exactly when its claim becomes
154
+ * reclaimable.
155
+ *
156
+ * Returns `1` when admitted, `0` when the lane is full.
157
+ */
158
+ const CAP_ACQUIRE_SCRIPT = `
159
+ local now = tonumber(ARGV[1])
160
+ local cap = tonumber(ARGV[2])
161
+ redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now)
162
+ if redis.call('ZCARD', KEYS[1]) < cap then
163
+ redis.call('ZADD', KEYS[1], ARGV[4], ARGV[3])
164
+ return 1
165
+ end
166
+ return 0
167
+ `
168
+
169
+ type RedisReceipt = {
170
+ stream: string
171
+ msgId: string
172
+ consumer: string
173
+ deliveryCount: number
174
+ }
175
+
176
+ type StreamMessageEntry = { id: string; message: Record<string, string> }
177
+
178
+ type XReadGroupReply = Array<{
179
+ name: string
180
+ messages: StreamMessageEntry[]
181
+ }>
182
+
183
+ type XAutoClaimReply = {
184
+ nextId: string
185
+ messages: Array<StreamMessageEntry | null>
186
+ }
187
+
188
+ type SubscriptionState = {
189
+ subscription: WorkerSubscription
190
+ consumers: string[]
191
+ streams: string[]
192
+ abortController: AbortController
193
+ slotPromises: Array<Promise<void>>
194
+ }
195
+
196
+ /**
197
+ * Redis Streams implementation of {@link QueueAdapter}. Each
198
+ * `(type, handlerVersion)` lane gets its own stream
199
+ * (`${topicPrefix}tasks:queue:${type}:v${handlerVersion}`); workers
200
+ * register against the streams whose `(type, version)` they support
201
+ * via the {@link WorkerSubscription.compatibleVersions} map.
202
+ *
203
+ * **Concurrency.** Each subscription spawns
204
+ * {@link WorkerSubscription.concurrency} parallel claim slots. A slot
205
+ * is one consumer in the consumer group named
206
+ * `${workerId}-slot-${index}`; messages handed to a slot live in that
207
+ * consumer's PEL until ack. Multiple slots share the same group, so
208
+ * Redis itself enforces competing-consumer semantics.
209
+ *
210
+ * **Visibility reclaim.** Every slot iteration runs `XAUTOCLAIM` first
211
+ * (idle threshold = the type's `visibilityTimeoutMs`); reclaimed
212
+ * entries are delivered through the same `onClaim` path as fresh
213
+ * messages, with an attempt re-classification done by the runner core
214
+ * (the dataset still records the prior attempt as `'timed-out'`).
215
+ *
216
+ * **Heartbeat.** {@link RedisQueueAdapter.heartbeat} resets the PEL
217
+ * idle counter via `XCLAIM JUSTID` so long-running handlers don't get
218
+ * reclaimed, and refreshes the holder's fleet-cap lease.
219
+ *
220
+ * **Delayed dispatch.** `notBefore` parks the entry in a per-prefix
221
+ * scheduler ZSET; a timer atomically pops due entries onto the matching
222
+ * stream (`delayedDispatch: true`).
223
+ *
224
+ * **Fleet cap.** When a type appears in
225
+ * {@link RedisQueueAdapterOptions.concurrencyLimits}, each claim must
226
+ * admit a holder token into a per-type ZSET (scored by expiry) before
227
+ * the handler runs; a claim that finds its lane full is released back to
228
+ * the stream and retried, so the cap holds fleet-wide
229
+ * (`fleetCapEnforcement: true`).
230
+ */
231
+ export class RedisQueueAdapter implements QueueAdapter {
232
+ public readonly capabilities: QueueAdapterCapabilities = CAPABILITIES
233
+
234
+ readonly #client: RedisLikeClient
235
+ readonly #topicPrefix: string
236
+ readonly #group: string
237
+ readonly #defaultVisibilityMs: number
238
+ readonly #visibilityByType: Record<string, number>
239
+ readonly #blockMs: number
240
+ readonly #retryBackoffMs: number
241
+ readonly #idempotencyTtlSec: number
242
+ readonly #schedulerIntervalMs: number
243
+ readonly #concurrencyLimits: Record<string, number>
244
+
245
+ readonly #subscriptions = new Set<SubscriptionState>()
246
+ readonly #ensuredGroups = new Set<string>()
247
+
248
+ /**
249
+ * Dedicated connection for the blocking `XREADGROUP` / `XAUTOCLAIM`
250
+ * slot loops. A blocking command holds the connection's reply stream
251
+ * until it returns, so sharing the main client would stall every store
252
+ * read/write, `XADD`, and `XACK` behind an in-flight `BLOCK` for up to
253
+ * `blockTimeoutMs` (head-of-line blocking). Duplicating mirrors
254
+ * `RedisCrossNodeBus`; the caller's client stays free for everything else.
255
+ */
256
+ readonly #readClient: RedisLikeClient
257
+ readonly #readConnectPromise: Promise<void>
258
+
259
+ readonly #schedulerTimer: ReturnType<typeof setInterval>
260
+ #schedulerRunning = false
261
+ #disposed = false
262
+
263
+ constructor(options: RedisQueueAdapterOptions) {
264
+ if (!options.serviceName || options.serviceName.length === 0) {
265
+ throw new Error('RedisQueueAdapter: `serviceName` is required')
266
+ }
267
+ this.#client = options.client
268
+ this.#topicPrefix = options.topicPrefix ?? ''
269
+ this.#group = options.consumerGroup ?? DEFAULT_GROUP
270
+ this.#defaultVisibilityMs = options.visibilityTimeoutMs ?? DEFAULT_VISIBILITY_MS
271
+ this.#visibilityByType = options.visibilityTimeoutByType ?? {}
272
+ this.#blockMs = options.blockTimeoutMs ?? DEFAULT_BLOCK_MS
273
+ this.#retryBackoffMs = options.retryBackoffMs ?? DEFAULT_RETRY_BACKOFF_MS
274
+ this.#idempotencyTtlSec = options.idempotencyTtlSec ?? DEFAULT_IDEMPOTENCY_TTL_SEC
275
+ this.#schedulerIntervalMs = options.schedulerIntervalMs ?? DEFAULT_SCHEDULER_INTERVAL_MS
276
+ this.#concurrencyLimits = options.concurrencyLimits ?? {}
277
+
278
+ this.#readClient = options.client.duplicate()
279
+ this.#readConnectPromise = this.#readClient
280
+ .connect()
281
+ .then(() => {
282
+ // Disposed before the connection finished coming up — close it now.
283
+ if (this.#disposed) this.#safeDestroyReadClient()
284
+ })
285
+ .catch(() => {
286
+ // Read connection failed to come up; slot reads no-op and back off.
287
+ })
288
+
289
+ // `unref()` so an idle scheduler timer never holds the process
290
+ // open in single-shot scripts / tests.
291
+ this.#schedulerTimer = setInterval(() => void this.#runSchedulerTick(), this.#schedulerIntervalMs)
292
+ this.#schedulerTimer.unref?.()
293
+ }
294
+
295
+ // ── Public API ────────────────────────────────────────────────────
296
+
297
+ public async enqueue(input: EnqueueInput): Promise<void> {
298
+ this.#ensureLive()
299
+ const stream = this.#streamKey(input.type, input.handlerVersion)
300
+ // Ensure the consumer group exists eagerly — workers subscribing
301
+ // later will pick up XADDs that happen between scheduler dispatch
302
+ // and the worker's group-create call only when the group already
303
+ // claims the stream's history.
304
+ await this.#ensureGroup(stream)
305
+
306
+ const dueAt = input.notBefore?.getTime()
307
+ if (dueAt !== undefined && dueAt > Date.now()) {
308
+ const member = JSON.stringify({
309
+ taskId: input.taskId,
310
+ type: input.type,
311
+ handlerVersion: input.handlerVersion,
312
+ })
313
+ await this.#client.zAdd(this.#schedulerKey(), { score: dueAt, value: member })
314
+ return
315
+ }
316
+
317
+ await this.#client.xAdd(stream, '*', {
318
+ taskId: input.taskId,
319
+ type: input.type,
320
+ handlerVersion: String(input.handlerVersion),
321
+ })
322
+ }
323
+
324
+ public subscribe(subscription: WorkerSubscription): Disposable {
325
+ this.#ensureLive()
326
+
327
+ const streams = this.#streamsFor(subscription)
328
+ const ac = new AbortController()
329
+ const consumers: string[] = []
330
+ const slotPromises: Array<Promise<void>> = []
331
+
332
+ const state: SubscriptionState = {
333
+ subscription,
334
+ consumers,
335
+ streams,
336
+ abortController: ac,
337
+ slotPromises,
338
+ }
339
+ this.#subscriptions.add(state)
340
+
341
+ void (async () => {
342
+ for (const stream of streams) {
343
+ await this.#ensureGroup(stream)
344
+ }
345
+ for (let i = 0; i < subscription.concurrency; i++) {
346
+ const consumerName = `${subscription.workerId}-slot-${i}`
347
+ consumers.push(consumerName)
348
+ slotPromises.push(this.#runSlot(state, consumerName, ac.signal))
349
+ }
350
+ })().catch(() => {
351
+ // Setup failure leaves the subscription empty; disposal is still
352
+ // safe, and the caller will see no claims arrive — surfaceable
353
+ // via telemetry once the runner core wires it through.
354
+ })
355
+
356
+ return {
357
+ [Symbol.dispose]: () => {
358
+ ac.abort()
359
+ this.#subscriptions.delete(state)
360
+ },
361
+ }
362
+ }
363
+
364
+ public async heartbeat(claim: ClaimedTask): Promise<void> {
365
+ if (this.#disposed) return
366
+ const receipt = claim.receipt as RedisReceipt
367
+ try {
368
+ await this.#client.xClaimJustId(receipt.stream, this.#group, receipt.consumer, 0, [receipt.msgId])
369
+ } catch {
370
+ // Heartbeat is best-effort. A transient broker error means the
371
+ // PEL idle counter keeps ticking; XAUTOCLAIM will reclaim the
372
+ // attempt if the issue persists past the visibility timeout.
373
+ }
374
+ if (this.#concurrencyLimits[claim.type] !== undefined) {
375
+ await this.#refreshCap(claim.type, `${receipt.consumer}:${receipt.msgId}`)
376
+ }
377
+ }
378
+
379
+ public async acquireIdempotencyLease(input: IdempotencyLeaseInput): Promise<string> {
380
+ this.#ensureLive()
381
+ const key = this.#idempotencyKey(input.type, input.key)
382
+ const set = await this.#client.set(key, input.taskId, {
383
+ NX: true,
384
+ EX: this.#idempotencyTtlSec,
385
+ })
386
+ if (set === 'OK') return input.taskId
387
+
388
+ const existing = await this.#client.get(key)
389
+ return typeof existing === 'string' && existing.length > 0 ? existing : input.taskId
390
+ }
391
+
392
+ public [Symbol.dispose](): void {
393
+ if (this.#disposed) return
394
+ this.#disposed = true
395
+ clearInterval(this.#schedulerTimer)
396
+ for (const state of this.#subscriptions) {
397
+ state.abortController.abort()
398
+ }
399
+ this.#subscriptions.clear()
400
+ this.#safeDestroyReadClient()
401
+ }
402
+
403
+ #safeDestroyReadClient(): void {
404
+ try {
405
+ if (this.#readClient.isOpen) this.#readClient.destroy()
406
+ } catch {
407
+ // Best-effort: the duplicated connection is force-closed on dispose.
408
+ }
409
+ }
410
+
411
+ // ── Internal helpers ──────────────────────────────────────────────
412
+
413
+ #streamKey(type: string, version: number): string {
414
+ return `${this.#topicPrefix}tasks:queue:${type}:v${version}`
415
+ }
416
+
417
+ #idempotencyKey(type: string, key: string): string {
418
+ return `${this.#topicPrefix}tasks:idem:${type}:${key}`
419
+ }
420
+
421
+ #schedulerKey(): string {
422
+ return `${this.#topicPrefix}tasks:scheduler`
423
+ }
424
+
425
+ /**
426
+ * Drains due delayed entries from the scheduler ZSET. Re-entrant
427
+ * guard prevents overlapping ticks on slow brokers; errors are
428
+ * swallowed so a transient `EVAL` failure does not poison the
429
+ * timer. The Lua script is the only authority for race-free
430
+ * dispatch — see {@link SCHEDULER_DISPATCH_SCRIPT}.
431
+ */
432
+ async #runSchedulerTick(): Promise<void> {
433
+ if (this.#disposed || this.#schedulerRunning) return
434
+ this.#schedulerRunning = true
435
+ try {
436
+ await this.#eval(
437
+ SCHEDULER_DISPATCH_SCRIPT,
438
+ [this.#schedulerKey()],
439
+ [String(Date.now()), String(SCHEDULER_BATCH_LIMIT), this.#topicPrefix],
440
+ )
441
+ } catch {
442
+ // Best-effort: a transient broker failure means due entries stay
443
+ // in the ZSET and the next tick retries.
444
+ } finally {
445
+ this.#schedulerRunning = false
446
+ }
447
+ }
448
+
449
+ /**
450
+ * Runs a Lua script via the redis client's `eval` (keys/arguments
451
+ * option shape). The cast keeps {@link RedisLikeClient} opaque to the
452
+ * exact `redis` command typings.
453
+ */
454
+ #eval(script: string, keys: string[], args: string[]): Promise<unknown> {
455
+ const evalFn = this.#client.eval as (
456
+ script: string,
457
+ opts: { keys: string[]; arguments: string[] },
458
+ ) => Promise<unknown>
459
+ return evalFn.call(this.#client, script, { keys, arguments: args })
460
+ }
461
+
462
+ // ── Fleet cap ─────────────────────────────────────────────────────
463
+
464
+ #capKey(type: string): string {
465
+ return `${this.#topicPrefix}tasks:cap:${type}`
466
+ }
467
+
468
+ /**
469
+ * Admit a holder token into the type's fleet-cap lane. Returns `false`
470
+ * when the lane is full (caller releases the claim back to the stream)
471
+ * or when the broker errors — failing closed avoids over-admitting.
472
+ */
473
+ async #acquireCap(type: string, token: string, cap: number): Promise<boolean> {
474
+ const now = Date.now()
475
+ const expiry = now + this.#visibilityFor(type)
476
+ try {
477
+ const result = await this.#eval(
478
+ CAP_ACQUIRE_SCRIPT,
479
+ [this.#capKey(type)],
480
+ [String(now), String(cap), token, String(expiry)],
481
+ )
482
+ return result === 1
483
+ } catch {
484
+ return false
485
+ }
486
+ }
487
+
488
+ async #releaseCap(type: string, token: string): Promise<void> {
489
+ try {
490
+ await this.#client.zRem(this.#capKey(type), token)
491
+ } catch {
492
+ // Best-effort: a leaked holder expires via its ZSET score TTL.
493
+ }
494
+ }
495
+
496
+ async #refreshCap(type: string, token: string): Promise<void> {
497
+ const expiry = Date.now() + this.#visibilityFor(type)
498
+ try {
499
+ await this.#client.zAdd(this.#capKey(type), { score: expiry, value: token }, { XX: true })
500
+ } catch {
501
+ // Best-effort: a missed refresh only risks an early reclaim, which
502
+ // the reclaim path already handles idempotently.
503
+ }
504
+ }
505
+
506
+ #streamsFor(subscription: WorkerSubscription): string[] {
507
+ const streams = new Set<string>()
508
+ for (const type of subscription.types) {
509
+ const versions = subscription.compatibleVersions[type]
510
+ const expanded = versions && versions.length > 0 ? Array.from(versions) : [1]
511
+ for (const version of expanded) {
512
+ streams.add(this.#streamKey(type, version))
513
+ }
514
+ }
515
+ return Array.from(streams)
516
+ }
517
+
518
+ #visibilityFor(type: string): number {
519
+ const override = this.#visibilityByType[type]
520
+ return typeof override === 'number' ? override : this.#defaultVisibilityMs
521
+ }
522
+
523
+ async #ensureGroup(stream: string): Promise<void> {
524
+ if (this.#ensuredGroups.has(stream)) return
525
+ try {
526
+ await this.#client.xGroupCreate(stream, this.#group, '$', { MKSTREAM: true })
527
+ } catch (error) {
528
+ // BUSYGROUP = group already exists (idempotent setup).
529
+ if (!isBusyGroupError(error)) throw error
530
+ }
531
+ this.#ensuredGroups.add(stream)
532
+ }
533
+
534
+ async #runSlot(state: SubscriptionState, consumerName: string, signal: AbortSignal): Promise<void> {
535
+ const { subscription, streams } = state
536
+
537
+ // Block until the dedicated read connection is live before issuing any
538
+ // blocking command on it.
539
+ await this.#readConnectPromise
540
+
541
+ while (!signal.aborted && !this.#disposed) {
542
+ if (subscription.shouldDrain()) return
543
+
544
+ // 1. Try to reclaim any stale PEL entries (broker-side reclaim).
545
+ const reclaimed = await this.#tryReclaim(streams, consumerName, signal)
546
+ if (reclaimed) {
547
+ await this.#deliver(state, reclaimed.stream, reclaimed.entry, consumerName, signal)
548
+ continue
549
+ }
550
+
551
+ if (signal.aborted || this.#disposed) return
552
+ if (subscription.shouldDrain()) return
553
+
554
+ // 2. Read fresh entries.
555
+ const fresh = await this.#tryRead(streams, consumerName)
556
+ if (!fresh) {
557
+ // Broker error — short backoff to avoid spin.
558
+ await sleep(this.#retryBackoffMs, signal)
559
+ continue
560
+ }
561
+ if (fresh.entry) {
562
+ await this.#deliver(state, fresh.stream, fresh.entry, consumerName, signal)
563
+ }
564
+ }
565
+ }
566
+
567
+ async #tryReclaim(
568
+ streams: string[],
569
+ consumerName: string,
570
+ signal: AbortSignal,
571
+ ): Promise<{ stream: string; entry: StreamMessageEntry } | undefined> {
572
+ for (const stream of streams) {
573
+ if (signal.aborted || this.#disposed) return undefined
574
+ const minIdle = this.#visibilityForStream(stream)
575
+ let reply: XAutoClaimReply | undefined
576
+ try {
577
+ reply = await this.#readClient.xAutoClaim(stream, this.#group, consumerName, minIdle, '0', {
578
+ COUNT: 1,
579
+ })
580
+ } catch {
581
+ return undefined
582
+ }
583
+ const entry = pickFirstEntry(reply?.messages)
584
+ if (entry) return { stream, entry }
585
+ }
586
+ return undefined
587
+ }
588
+
589
+ async #tryRead(
590
+ streams: string[],
591
+ consumerName: string,
592
+ ): Promise<{ stream: string; entry: StreamMessageEntry | undefined } | undefined> {
593
+ let reply: XReadGroupReply | null
594
+ try {
595
+ reply = await this.#readClient.xReadGroup(
596
+ this.#group,
597
+ consumerName,
598
+ streams.map((key) => ({ key, id: '>' })),
599
+ { BLOCK: this.#blockMs, COUNT: 1 },
600
+ )
601
+ } catch {
602
+ return undefined
603
+ }
604
+ if (!reply || reply.length === 0) return { stream: streams[0] ?? '', entry: undefined }
605
+ const first = reply[0]
606
+ if (!first) return { stream: streams[0] ?? '', entry: undefined }
607
+ const entry = first.messages[0]
608
+ return { stream: first.name, entry }
609
+ }
610
+
611
+ async #deliver(
612
+ state: SubscriptionState,
613
+ stream: string,
614
+ entry: StreamMessageEntry,
615
+ consumerName: string,
616
+ signal: AbortSignal,
617
+ ): Promise<void> {
618
+ const { taskId } = entry.message
619
+ const { type } = entry.message
620
+ if (typeof taskId !== 'string' || typeof type !== 'string') {
621
+ // Malformed entry — drop it. Without a taskId we can't route it,
622
+ // so leaving it in the PEL would block the consumer forever.
623
+ try {
624
+ await this.#client.xAck(stream, this.#group, entry.id)
625
+ } catch {
626
+ // Swallow — the autoclaim loop will retry on the next cycle.
627
+ }
628
+ return
629
+ }
630
+
631
+ const limit = this.#concurrencyLimits[type]
632
+ const capToken = `${consumerName}:${entry.id}`
633
+ if (limit !== undefined) {
634
+ const acquired = await this.#acquireCap(type, capToken, limit)
635
+ if (!acquired) {
636
+ // Lane at fleet cap: release the entry back to the stream tail and
637
+ // back off so this slot does not hot-loop re-reading it. Ordering
638
+ // churn is the accepted trade-off of FIFO + fleet cap (PRD §11).
639
+ try {
640
+ await this.#client.xAck(stream, this.#group, entry.id)
641
+ await this.#client.xAdd(stream, '*', entry.message)
642
+ } catch {
643
+ // Ack/re-add failed: entry stays in PEL, XAUTOCLAIM recovers it.
644
+ }
645
+ await sleep(this.#retryBackoffMs, signal)
646
+ return
647
+ }
648
+ }
649
+
650
+ const claim: ClaimedTask = {
651
+ taskId,
652
+ type,
653
+ receipt: {
654
+ stream,
655
+ msgId: entry.id,
656
+ consumer: consumerName,
657
+ deliveryCount: 0,
658
+ } satisfies RedisReceipt,
659
+ }
660
+
661
+ try {
662
+ let outcome: ClaimOutcome
663
+ try {
664
+ outcome = await state.subscription.onClaim(claim)
665
+ } catch {
666
+ outcome = { kind: 'requeue' }
667
+ }
668
+
669
+ try {
670
+ if (outcome.kind === 'requeue') {
671
+ await this.#client.xAck(stream, this.#group, entry.id)
672
+ await this.#client.xAdd(stream, '*', entry.message)
673
+ } else {
674
+ await this.#client.xAck(stream, this.#group, entry.id)
675
+ }
676
+ } catch {
677
+ // Ack failure leaves the entry in PEL; the next XAUTOCLAIM cycle
678
+ // will surface it for re-delivery once the visibility timeout
679
+ // expires. Better than silently leaking the message.
680
+ }
681
+ } finally {
682
+ // Release the fleet-cap slot on every outcome — including
683
+ // `suspended` (awaitChildren frees the worker slot, so it must
684
+ // free the lane too, else a deep DAG deadlocks its own lane).
685
+ if (limit !== undefined) await this.#releaseCap(type, capToken)
686
+ }
687
+ }
688
+
689
+ #visibilityForStream(stream: string): number {
690
+ // Stream key shape: `${prefix}tasks:queue:${type}:v${version}`.
691
+ // Slice to recover the type for per-type override lookup.
692
+ const marker = 'tasks:queue:'
693
+ const idx = stream.indexOf(marker)
694
+ if (idx === -1) return this.#defaultVisibilityMs
695
+ const tail = stream.slice(idx + marker.length)
696
+ const versionIdx = tail.lastIndexOf(':v')
697
+ const type = versionIdx === -1 ? tail : tail.slice(0, versionIdx)
698
+ return this.#visibilityFor(type)
699
+ }
700
+
701
+ #ensureLive(): void {
702
+ if (this.#disposed) {
703
+ throw new Error('RedisQueueAdapter has been disposed')
704
+ }
705
+ }
706
+ }
707
+
708
+ const isBusyGroupError = (error: unknown): boolean => error instanceof Error && /BUSYGROUP/i.test(error.message)
709
+
710
+ const pickFirstEntry = (messages: Array<StreamMessageEntry | null> | undefined): StreamMessageEntry | undefined => {
711
+ if (!messages) return undefined
712
+ for (const entry of messages) {
713
+ if (entry) return entry
714
+ }
715
+ return undefined
716
+ }
717
+
718
+ const sleep = (ms: number, signal: AbortSignal): Promise<void> =>
719
+ new Promise<void>((resolve) => {
720
+ if (signal.aborted) {
721
+ resolve()
722
+ return
723
+ }
724
+ const timer = setTimeout(() => {
725
+ signal.removeEventListener('abort', onAbort)
726
+ resolve()
727
+ }, ms)
728
+ const onAbort = (): void => {
729
+ clearTimeout(timer)
730
+ resolve()
731
+ }
732
+ signal.addEventListener('abort', onAbort, { once: true })
733
+ })