@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,613 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import type { MockInstance } from 'vitest'
3
+ import { RedisQueueAdapter } from './redis-queue-adapter.js'
4
+
5
+ // `vi.fn()` without generics returns `MockInstance<(...args: any[]) => any>`,
6
+ // which is the most permissive MockInstance and matches the loose redis
7
+ // client surface — typing each method tightly here would force every
8
+ // stub to ape the real `redis` client signatures, which is not what
9
+ // these unit tests care about.
10
+ type AnyMock = MockInstance<(...args: never[]) => unknown>
11
+
12
+ type MockClient = {
13
+ xAdd: AnyMock
14
+ xGroupCreate: AnyMock
15
+ xReadGroup: AnyMock
16
+ xAutoClaim: AnyMock
17
+ xAck: AnyMock
18
+ xClaimJustId: AnyMock
19
+ set: AnyMock
20
+ get: AnyMock
21
+ zAdd: AnyMock
22
+ zRem: AnyMock
23
+ eval: AnyMock
24
+ duplicate: AnyMock
25
+ connect: AnyMock
26
+ destroy: AnyMock
27
+ isOpen: boolean
28
+ }
29
+
30
+ /**
31
+ * Default mocks for the redis client. `xReadGroup` simulates BLOCK
32
+ * by awaiting `opts.BLOCK` ms before resolving — without this the
33
+ * slot loop spins as fast as possible and `mock.calls` accumulates
34
+ * unbounded (OOMs vitest).
35
+ */
36
+ const buildClient = (overrides: Partial<MockClient> = {}): MockClient => {
37
+ const client: MockClient = {
38
+ xAdd: vi.fn().mockResolvedValue('1-0'),
39
+ xGroupCreate: vi.fn().mockResolvedValue('OK'),
40
+ xReadGroup: vi.fn().mockImplementation(async (_g, _c, _streams, opts?: { BLOCK?: number }) => {
41
+ if (opts?.BLOCK) await new Promise<void>((r) => setTimeout(r, opts.BLOCK))
42
+ return null
43
+ }),
44
+ xAutoClaim: vi.fn().mockImplementation(async () => {
45
+ await new Promise<void>((r) => setTimeout(r, 1))
46
+ return { nextId: '0-0', messages: [] }
47
+ }),
48
+ xAck: vi.fn().mockResolvedValue(1),
49
+ xClaimJustId: vi.fn().mockResolvedValue([]),
50
+ set: vi.fn().mockResolvedValue('OK'),
51
+ get: vi.fn().mockResolvedValue(null),
52
+ zAdd: vi.fn().mockResolvedValue(1),
53
+ zRem: vi.fn().mockResolvedValue(1),
54
+ eval: vi.fn().mockResolvedValue(0),
55
+ duplicate: vi.fn(),
56
+ connect: vi.fn(),
57
+ destroy: vi.fn(),
58
+ isOpen: true,
59
+ ...overrides,
60
+ }
61
+ // The adapter duplicates the client for its blocking read loop. Return the
62
+ // same mock so blocking-read / reclaim assertions still observe the calls.
63
+ client.duplicate = vi.fn(() => client)
64
+ client.connect = vi.fn().mockResolvedValue(client)
65
+ return client
66
+ }
67
+
68
+ const makeAdapter = (
69
+ client: MockClient,
70
+ prefix = 'svc-a/',
71
+ schedulerIntervalMs = 1_000_000,
72
+ concurrencyLimits?: Record<string, number>,
73
+ ): RedisQueueAdapter => {
74
+ return new RedisQueueAdapter({
75
+ client: client as unknown as ConstructorParameters<typeof RedisQueueAdapter>[0]['client'],
76
+ serviceName: 'svc-a',
77
+ topicPrefix: prefix,
78
+ blockTimeoutMs: 5,
79
+ retryBackoffMs: 5,
80
+ // Disable the auto-tick by default — tests that exercise the
81
+ // scheduler trigger it manually via internal test hooks. A huge
82
+ // value keeps `setInterval` cheap without rewriting timer plumbing.
83
+ schedulerIntervalMs,
84
+ concurrencyLimits,
85
+ })
86
+ }
87
+
88
+ describe('RedisQueueAdapter', () => {
89
+ describe('capabilities', () => {
90
+ it('declares persistent + distributed + brokerSideReclaim + delayedDispatch', () => {
91
+ using adapter = makeAdapter(buildClient())
92
+ expect(adapter.capabilities.persistent).toBe(true)
93
+ expect(adapter.capabilities.distributed).toBe(true)
94
+ expect(adapter.capabilities.brokerSideReclaim).toBe(true)
95
+ expect(adapter.capabilities.delayedDispatch).toBe(true)
96
+ })
97
+
98
+ it('declares fleetCapEnforcement support', () => {
99
+ using adapter = makeAdapter(buildClient())
100
+ expect(adapter.capabilities.fleetCapEnforcement).toBe(true)
101
+ })
102
+ })
103
+
104
+ describe('fleet cap', () => {
105
+ it('admits a claim via the cap script and releases the slot after onClaim', async () => {
106
+ const onClaim = vi.fn().mockResolvedValue({ kind: 'success' })
107
+ const xReadGroup = oneShotXReadGroup({
108
+ stream: 'svc-a/tasks:queue:echo:v1',
109
+ message: { id: '1-0', fields: { taskId: 't1', type: 'echo', handlerVersion: '1' } },
110
+ })
111
+ // eval resolves 1 → admitted.
112
+ const client = buildClient({ xReadGroup, eval: vi.fn().mockResolvedValue(1) })
113
+ using adapter = makeAdapter(client, 'svc-a/', 1_000_000, { echo: 2 })
114
+ using sub = adapter.subscribe({
115
+ workerId: 'w-1',
116
+ concurrency: 1,
117
+ tags: [],
118
+ types: ['echo'],
119
+ compatibleVersions: { echo: [1] },
120
+ shouldDrain: () => false,
121
+ onClaim,
122
+ })
123
+ void sub
124
+
125
+ await waitFor(() => onClaim.mock.calls.length > 0, 1000)
126
+ const evalCall = client.eval.mock.calls[0] as unknown as [string, { keys: string[]; arguments: string[] }]
127
+ expect(evalCall[1].keys).toEqual(['svc-a/tasks:cap:echo'])
128
+ // ARGV[2] is the cap.
129
+ expect(evalCall[1].arguments[1]).toBe('2')
130
+ await waitFor(() => client.zRem.mock.calls.length > 0, 1000)
131
+ expect(client.zRem).toHaveBeenCalledWith('svc-a/tasks:cap:echo', 'w-1-slot-0:1-0')
132
+ })
133
+
134
+ it('releases the entry back to the stream without running onClaim when the lane is full', async () => {
135
+ const onClaim = vi.fn().mockResolvedValue({ kind: 'success' })
136
+ const xReadGroup = oneShotXReadGroup({
137
+ stream: 'svc-a/tasks:queue:echo:v1',
138
+ message: { id: '1-0', fields: { taskId: 't1', type: 'echo', handlerVersion: '1' } },
139
+ })
140
+ // eval resolves 0 → lane full.
141
+ const client = buildClient({ xReadGroup, eval: vi.fn().mockResolvedValue(0) })
142
+ using adapter = makeAdapter(client, 'svc-a/', 1_000_000, { echo: 1 })
143
+ using sub = adapter.subscribe({
144
+ workerId: 'w-1',
145
+ concurrency: 1,
146
+ tags: [],
147
+ types: ['echo'],
148
+ compatibleVersions: { echo: [1] },
149
+ shouldDrain: () => false,
150
+ onClaim,
151
+ })
152
+ void sub
153
+
154
+ await waitFor(() => client.xAdd.mock.calls.length > 0, 1000)
155
+ expect(onClaim).not.toHaveBeenCalled()
156
+ expect(client.xAck).toHaveBeenCalledWith('svc-a/tasks:queue:echo:v1', 'runner', '1-0')
157
+ expect(client.xAdd).toHaveBeenCalledWith('svc-a/tasks:queue:echo:v1', '*', {
158
+ taskId: 't1',
159
+ type: 'echo',
160
+ handlerVersion: '1',
161
+ })
162
+ // Slot never admitted → nothing to release.
163
+ expect(client.zRem).not.toHaveBeenCalled()
164
+ })
165
+
166
+ it('does not consult the cap script for uncapped types', async () => {
167
+ const onClaim = vi.fn().mockResolvedValue({ kind: 'success' })
168
+ const xReadGroup = oneShotXReadGroup({
169
+ stream: 'svc-a/tasks:queue:echo:v1',
170
+ message: { id: '1-0', fields: { taskId: 't1', type: 'echo', handlerVersion: '1' } },
171
+ })
172
+ const client = buildClient({ xReadGroup })
173
+ using adapter = makeAdapter(client, 'svc-a/', 1_000_000)
174
+ using sub = adapter.subscribe({
175
+ workerId: 'w-1',
176
+ concurrency: 1,
177
+ tags: [],
178
+ types: ['echo'],
179
+ compatibleVersions: { echo: [1] },
180
+ shouldDrain: () => false,
181
+ onClaim,
182
+ })
183
+ void sub
184
+
185
+ await waitFor(() => onClaim.mock.calls.length > 0, 1000)
186
+ expect(client.eval).not.toHaveBeenCalled()
187
+ expect(client.zRem).not.toHaveBeenCalled()
188
+ })
189
+
190
+ it('refreshes the holder lease on heartbeat for capped types', async () => {
191
+ const client = buildClient()
192
+ using adapter = makeAdapter(client, 'svc-a/', 1_000_000, { echo: 2 })
193
+ await adapter.heartbeat({
194
+ taskId: 't1',
195
+ type: 'echo',
196
+ receipt: { stream: 'svc-a/tasks:queue:echo:v1', msgId: '1-0', consumer: 'w-0', deliveryCount: 0 },
197
+ })
198
+ expect(client.zAdd).toHaveBeenCalledWith('svc-a/tasks:cap:echo', expect.objectContaining({ value: 'w-0:1-0' }), {
199
+ XX: true,
200
+ })
201
+ })
202
+
203
+ it('does not refresh a holder lease on heartbeat for uncapped types', async () => {
204
+ const client = buildClient()
205
+ using adapter = makeAdapter(client)
206
+ await adapter.heartbeat({
207
+ taskId: 't1',
208
+ type: 'echo',
209
+ receipt: { stream: 'svc-a/tasks:queue:echo:v1', msgId: '1-0', consumer: 'w-0', deliveryCount: 0 },
210
+ })
211
+ expect(client.zAdd).not.toHaveBeenCalled()
212
+ })
213
+ })
214
+
215
+ describe('enqueue', () => {
216
+ it('writes to per-(type,version) stream with the documented field shape', async () => {
217
+ const client = buildClient()
218
+ using adapter = makeAdapter(client)
219
+ await adapter.enqueue({ taskId: 'task-1', type: 'video-encode', handlerVersion: 2 })
220
+
221
+ expect(client.xGroupCreate).toHaveBeenCalledWith('svc-a/tasks:queue:video-encode:v2', 'runner', '$', {
222
+ MKSTREAM: true,
223
+ })
224
+ expect(client.xAdd).toHaveBeenCalledWith('svc-a/tasks:queue:video-encode:v2', '*', {
225
+ taskId: 'task-1',
226
+ type: 'video-encode',
227
+ handlerVersion: '2',
228
+ })
229
+ })
230
+
231
+ it('caches consumer-group creation per stream', async () => {
232
+ const client = buildClient()
233
+ using adapter = makeAdapter(client)
234
+ await adapter.enqueue({ taskId: 't1', type: 'echo', handlerVersion: 1 })
235
+ await adapter.enqueue({ taskId: 't2', type: 'echo', handlerVersion: 1 })
236
+
237
+ expect(client.xGroupCreate).toHaveBeenCalledTimes(1)
238
+ expect(client.xAdd).toHaveBeenCalledTimes(2)
239
+ })
240
+
241
+ it('tolerates BUSYGROUP errors when the consumer group already exists', async () => {
242
+ const client = buildClient({
243
+ xGroupCreate: vi.fn().mockRejectedValue(new Error('BUSYGROUP Consumer Group name already exists')),
244
+ })
245
+ using adapter = makeAdapter(client)
246
+ await expect(adapter.enqueue({ taskId: 't1', type: 'echo', handlerVersion: 1 })).resolves.toBeUndefined()
247
+ expect(client.xAdd).toHaveBeenCalledOnce()
248
+ })
249
+ })
250
+
251
+ describe('delayed dispatch', () => {
252
+ it('ZADDs to the scheduler ZSET when notBefore is in the future', async () => {
253
+ const client = buildClient()
254
+ using adapter = makeAdapter(client)
255
+ const future = new Date(Date.now() + 60_000)
256
+ await adapter.enqueue({ taskId: 't1', type: 'echo', handlerVersion: 1, notBefore: future })
257
+
258
+ expect(client.xAdd).not.toHaveBeenCalled()
259
+ expect(client.zAdd).toHaveBeenCalledWith('svc-a/tasks:scheduler', {
260
+ score: future.getTime(),
261
+ value: JSON.stringify({ taskId: 't1', type: 'echo', handlerVersion: 1 }),
262
+ })
263
+ })
264
+
265
+ it('XADDs immediately when notBefore is in the past', async () => {
266
+ const client = buildClient()
267
+ using adapter = makeAdapter(client)
268
+ await adapter.enqueue({
269
+ taskId: 't1',
270
+ type: 'echo',
271
+ handlerVersion: 1,
272
+ notBefore: new Date(Date.now() - 1000),
273
+ })
274
+ expect(client.zAdd).not.toHaveBeenCalled()
275
+ expect(client.xAdd).toHaveBeenCalledOnce()
276
+ })
277
+
278
+ it('eagerly creates the consumer group on the destination stream so scheduler XADDs are not lost', async () => {
279
+ const client = buildClient()
280
+ using adapter = makeAdapter(client)
281
+ await adapter.enqueue({
282
+ taskId: 't1',
283
+ type: 'echo',
284
+ handlerVersion: 1,
285
+ notBefore: new Date(Date.now() + 60_000),
286
+ })
287
+ expect(client.xGroupCreate).toHaveBeenCalledWith('svc-a/tasks:queue:echo:v1', 'runner', '$', {
288
+ MKSTREAM: true,
289
+ })
290
+ })
291
+
292
+ it('runs the scheduler tick on the configured interval and EVALs the dispatch script', async () => {
293
+ const client = buildClient()
294
+ using adapter = makeAdapter(client, 'svc-a/', 20)
295
+ // Block until at least one tick fired against the broker.
296
+ await waitFor(() => client.eval.mock.calls.length > 0, 500)
297
+ const firstCall = client.eval.mock.calls[0] as unknown as [string, { keys: string[]; arguments: string[] }]
298
+ const opts = firstCall[1]
299
+ expect(opts.keys).toEqual(['svc-a/tasks:scheduler'])
300
+ expect(opts.arguments[2]).toBe('svc-a/')
301
+ // ARGV[1] is `now` epoch ms; sanity-check it parses.
302
+ expect(Number.isFinite(Number(opts.arguments[0]))).toBe(true)
303
+ // ARGV[2] is the batch limit — must be a positive integer.
304
+ expect(Number(opts.arguments[1])).toBeGreaterThan(0)
305
+ void adapter
306
+ })
307
+
308
+ it('swallows EVAL errors so a transient broker failure does not poison the timer', async () => {
309
+ const errors: unknown[] = []
310
+ const client = buildClient({
311
+ eval: vi.fn().mockImplementation(() => {
312
+ const error = new Error('LOADING Redis is starting')
313
+ errors.push(error)
314
+ throw error
315
+ }),
316
+ })
317
+ using adapter = makeAdapter(client, 'svc-a/', 10)
318
+ await waitFor(() => errors.length >= 2, 500)
319
+ void adapter
320
+ })
321
+ })
322
+
323
+ describe('acquireIdempotencyLease', () => {
324
+ it('returns own taskId when SET NX wins the race', async () => {
325
+ const client = buildClient({ set: vi.fn().mockResolvedValue('OK') })
326
+ using adapter = makeAdapter(client)
327
+ const winner = await adapter.acquireIdempotencyLease({ type: 'echo', key: 'nightly', taskId: 'mine' })
328
+ expect(winner).toBe('mine')
329
+ expect(client.set).toHaveBeenCalledWith('svc-a/tasks:idem:echo:nightly', 'mine', {
330
+ NX: true,
331
+ EX: 86_400,
332
+ })
333
+ })
334
+
335
+ it('returns the stored taskId when SET NX loses', async () => {
336
+ const client = buildClient({
337
+ set: vi.fn().mockResolvedValue(null),
338
+ get: vi.fn().mockResolvedValue('previous'),
339
+ })
340
+ using adapter = makeAdapter(client)
341
+ const winner = await adapter.acquireIdempotencyLease({ type: 'echo', key: 'nightly', taskId: 'mine' })
342
+ expect(winner).toBe('previous')
343
+ })
344
+
345
+ it('falls back to own taskId when SET loses but GET sees no value (TTL expired between calls)', async () => {
346
+ const client = buildClient({
347
+ set: vi.fn().mockResolvedValue(null),
348
+ get: vi.fn().mockResolvedValue(null),
349
+ })
350
+ using adapter = makeAdapter(client)
351
+ const winner = await adapter.acquireIdempotencyLease({ type: 'echo', key: 'nightly', taskId: 'mine' })
352
+ expect(winner).toBe('mine')
353
+ })
354
+ })
355
+
356
+ describe('heartbeat', () => {
357
+ it('XCLAIMs the message id with idle=0 to refresh PEL idle counter', async () => {
358
+ const client = buildClient()
359
+ using adapter = makeAdapter(client)
360
+ await adapter.heartbeat({
361
+ taskId: 't1',
362
+ type: 'echo',
363
+ receipt: { stream: 'svc-a/tasks:queue:echo:v1', msgId: '1-0', consumer: 'w-0', deliveryCount: 0 },
364
+ })
365
+ expect(client.xClaimJustId).toHaveBeenCalledWith('svc-a/tasks:queue:echo:v1', 'runner', 'w-0', 0, ['1-0'])
366
+ })
367
+
368
+ it('swallows broker errors so transient failures do not bubble into handler land', async () => {
369
+ const client = buildClient({
370
+ xClaimJustId: vi.fn().mockRejectedValue(new Error('connection lost')),
371
+ })
372
+ using adapter = makeAdapter(client)
373
+ await expect(
374
+ adapter.heartbeat({
375
+ taskId: 't1',
376
+ type: 'echo',
377
+ receipt: { stream: 'svc-a/tasks:queue:echo:v1', msgId: '1-0', consumer: 'w-0', deliveryCount: 0 },
378
+ }),
379
+ ).resolves.toBeUndefined()
380
+ })
381
+ })
382
+
383
+ describe('subscribe — claim loop', () => {
384
+ it('reads from per-(type,version) streams matching compatibleVersions', async () => {
385
+ const seenStreams: string[][] = []
386
+ const client = buildClient({
387
+ xReadGroup: vi.fn().mockImplementation((_g, _c, streams: Array<{ key: string }>) => {
388
+ seenStreams.push(streams.map((s) => s.key))
389
+ return Promise.resolve(null)
390
+ }),
391
+ })
392
+ using adapter = makeAdapter(client)
393
+ const onClaim = vi.fn().mockResolvedValue({ kind: 'success' })
394
+ using sub = adapter.subscribe({
395
+ workerId: 'w-1',
396
+ concurrency: 1,
397
+ tags: [],
398
+ types: ['video-encode'],
399
+ compatibleVersions: { 'video-encode': [1, 2] },
400
+ shouldDrain: () => false,
401
+ onClaim,
402
+ })
403
+ void sub
404
+
405
+ await waitFor(() => seenStreams.length > 0, 500)
406
+ expect(seenStreams[0]?.sort()).toEqual(['svc-a/tasks:queue:video-encode:v1', 'svc-a/tasks:queue:video-encode:v2'])
407
+ })
408
+
409
+ it('defaults to v1 stream when compatibleVersions for a type is missing', async () => {
410
+ const seenStreams: string[][] = []
411
+ const client = buildClient({
412
+ xReadGroup: vi.fn().mockImplementation((_g, _c, streams: Array<{ key: string }>) => {
413
+ seenStreams.push(streams.map((s) => s.key))
414
+ return Promise.resolve(null)
415
+ }),
416
+ })
417
+ using adapter = makeAdapter(client)
418
+ using sub = adapter.subscribe({
419
+ workerId: 'w-1',
420
+ concurrency: 1,
421
+ tags: [],
422
+ types: ['echo'],
423
+ compatibleVersions: {},
424
+ shouldDrain: () => false,
425
+ onClaim: vi.fn().mockResolvedValue({ kind: 'success' }),
426
+ })
427
+ void sub
428
+
429
+ await waitFor(() => seenStreams.length > 0, 500)
430
+ expect(seenStreams[0]).toEqual(['svc-a/tasks:queue:echo:v1'])
431
+ })
432
+
433
+ it('delivers a claim and ACKs on success', async () => {
434
+ const claims: Array<{ taskId: string; type: string }> = []
435
+ const onClaim = vi.fn().mockImplementation((claim: { taskId: string; type: string }) => {
436
+ claims.push({ taskId: claim.taskId, type: claim.type })
437
+ return Promise.resolve({ kind: 'success' })
438
+ })
439
+ const xReadGroup = oneShotXReadGroup({
440
+ stream: 'svc-a/tasks:queue:echo:v1',
441
+ message: { id: '1-0', fields: { taskId: 't1', type: 'echo', handlerVersion: '1' } },
442
+ })
443
+ const client = buildClient({ xReadGroup })
444
+
445
+ using adapter = makeAdapter(client)
446
+ using sub = adapter.subscribe({
447
+ workerId: 'w-1',
448
+ concurrency: 1,
449
+ tags: [],
450
+ types: ['echo'],
451
+ compatibleVersions: { echo: [1] },
452
+ shouldDrain: () => false,
453
+ onClaim,
454
+ })
455
+ void sub
456
+
457
+ await waitFor(() => claims.length > 0, 1000)
458
+ expect(claims[0]).toEqual({ taskId: 't1', type: 'echo' })
459
+ await waitFor(() => client.xAck.mock.calls.length > 0, 1000)
460
+ expect(client.xAck).toHaveBeenCalledWith('svc-a/tasks:queue:echo:v1', 'runner', '1-0')
461
+ })
462
+
463
+ it('ACKs the original entry and re-XADDs when onClaim returns requeue', async () => {
464
+ const xReadGroup = oneShotXReadGroup({
465
+ stream: 'svc-a/tasks:queue:echo:v1',
466
+ message: { id: '1-0', fields: { taskId: 't1', type: 'echo', handlerVersion: '1' } },
467
+ })
468
+ const client = buildClient({ xReadGroup })
469
+ using adapter = makeAdapter(client)
470
+ using sub = adapter.subscribe({
471
+ workerId: 'w-1',
472
+ concurrency: 1,
473
+ tags: [],
474
+ types: ['echo'],
475
+ compatibleVersions: { echo: [1] },
476
+ shouldDrain: () => false,
477
+ onClaim: vi.fn().mockResolvedValue({ kind: 'requeue' }),
478
+ })
479
+ void sub
480
+
481
+ await waitFor(() => client.xAck.mock.calls.length > 0, 1000)
482
+ await waitFor(() => client.xAdd.mock.calls.length > 0, 1000)
483
+ expect(client.xAck).toHaveBeenCalledWith('svc-a/tasks:queue:echo:v1', 'runner', '1-0')
484
+ expect(client.xAdd).toHaveBeenCalledWith('svc-a/tasks:queue:echo:v1', '*', {
485
+ taskId: 't1',
486
+ type: 'echo',
487
+ handlerVersion: '1',
488
+ })
489
+ })
490
+
491
+ it('runs XAUTOCLAIM and delivers the reclaimed entry through onClaim', async () => {
492
+ const claims: Array<{ taskId: string; type: string }> = []
493
+ let autoClaimCall = 0
494
+ const xAutoClaim = vi.fn().mockImplementation(async () => {
495
+ autoClaimCall += 1
496
+ await new Promise<void>((r) => setTimeout(r, 1))
497
+ if (autoClaimCall === 1) {
498
+ return {
499
+ nextId: '2-0',
500
+ messages: [{ id: '1-0', message: { taskId: 't-stale', type: 'echo', handlerVersion: '1' } }],
501
+ }
502
+ }
503
+ return { nextId: '0-0', messages: [] }
504
+ })
505
+ const client = buildClient({ xAutoClaim })
506
+ using adapter = makeAdapter(client)
507
+ using sub = adapter.subscribe({
508
+ workerId: 'w-1',
509
+ concurrency: 1,
510
+ tags: [],
511
+ types: ['echo'],
512
+ compatibleVersions: { echo: [1] },
513
+ shouldDrain: () => false,
514
+ onClaim: vi.fn().mockImplementation((claim: { taskId: string; type: string }) => {
515
+ claims.push({ taskId: claim.taskId, type: claim.type })
516
+ return Promise.resolve({ kind: 'success' })
517
+ }),
518
+ })
519
+ void sub
520
+
521
+ await waitFor(() => claims.length > 0, 1000)
522
+ expect(claims[0]).toEqual({ taskId: 't-stale', type: 'echo' })
523
+ })
524
+
525
+ it('drops malformed entries (missing taskId) without invoking onClaim', async () => {
526
+ const onClaim = vi.fn()
527
+ const xReadGroup = oneShotXReadGroup({
528
+ stream: 'svc-a/tasks:queue:echo:v1',
529
+ message: { id: '1-0', fields: { type: 'echo' } },
530
+ })
531
+ const client = buildClient({ xReadGroup })
532
+ using adapter = makeAdapter(client)
533
+ using sub = adapter.subscribe({
534
+ workerId: 'w-1',
535
+ concurrency: 1,
536
+ tags: [],
537
+ types: ['echo'],
538
+ compatibleVersions: { echo: [1] },
539
+ shouldDrain: () => false,
540
+ onClaim,
541
+ })
542
+ void sub
543
+
544
+ await waitFor(() => client.xAck.mock.calls.length > 0, 1000)
545
+ expect(onClaim).not.toHaveBeenCalled()
546
+ expect(client.xAck).toHaveBeenCalledWith('svc-a/tasks:queue:echo:v1', 'runner', '1-0')
547
+ })
548
+
549
+ it('stops the slot loop when subscription.shouldDrain() flips to true', async () => {
550
+ let draining = false
551
+ const xReadGroup = vi.fn().mockResolvedValue(null)
552
+ const client = buildClient({ xReadGroup })
553
+ using adapter = makeAdapter(client)
554
+ using sub = adapter.subscribe({
555
+ workerId: 'w-1',
556
+ concurrency: 1,
557
+ tags: [],
558
+ types: ['echo'],
559
+ compatibleVersions: { echo: [1] },
560
+ shouldDrain: () => draining,
561
+ onClaim: vi.fn().mockResolvedValue({ kind: 'success' }),
562
+ })
563
+ void sub
564
+
565
+ await waitFor(() => xReadGroup.mock.calls.length > 0, 500)
566
+ const callsBefore = xReadGroup.mock.calls.length
567
+ draining = true
568
+ await new Promise<void>((r) => setTimeout(r, 80))
569
+ const callsAfter = xReadGroup.mock.calls.length
570
+ // The loop may issue one or two more reads before observing the drain
571
+ // signal between iterations; what matters is that it stops growing.
572
+ await new Promise<void>((r) => setTimeout(r, 50))
573
+ const callsAfterSettle = xReadGroup.mock.calls.length
574
+ expect(callsAfter).toBeLessThanOrEqual(callsBefore + 4)
575
+ expect(callsAfterSettle).toBeLessThanOrEqual(callsAfter + 1)
576
+ })
577
+ })
578
+ })
579
+
580
+ const waitFor = async (predicate: () => boolean, timeoutMs: number): Promise<void> => {
581
+ const deadline = Date.now() + timeoutMs
582
+ while (Date.now() < deadline) {
583
+ if (predicate()) return
584
+ await new Promise<void>((r) => setTimeout(r, 5))
585
+ }
586
+ throw new Error(`waitFor: predicate did not satisfy within ${timeoutMs}ms`)
587
+ }
588
+
589
+ /**
590
+ * Returns an `xReadGroup` mock that yields `message` exactly once on
591
+ * the first call, then returns `null` on every subsequent call (with
592
+ * the BLOCK delay simulated so the slot loop paces itself and
593
+ * `mock.calls` does not grow unbounded).
594
+ */
595
+ const oneShotXReadGroup = (delivery: {
596
+ stream: string
597
+ message: { id: string; fields: Record<string, string> }
598
+ }): AnyMock => {
599
+ let delivered = false
600
+ return vi.fn().mockImplementation(async (_g, _c, _streams, opts?: { BLOCK?: number }) => {
601
+ if (!delivered) {
602
+ delivered = true
603
+ return [
604
+ {
605
+ name: delivery.stream,
606
+ messages: [{ id: delivery.message.id, message: delivery.message.fields }],
607
+ },
608
+ ]
609
+ }
610
+ if (opts?.BLOCK) await new Promise<void>((r) => setTimeout(r, opts.BLOCK))
611
+ return null
612
+ })
613
+ }