@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,440 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { BlobStore, InMemoryBlobStore } from '@furystack/blob-store'
3
+ import { CrossNodeBus, defineInProcessCrossNodeBus } from '@furystack/cross-node-bus'
4
+ import { createInjector, type Injector } from '@furystack/inject'
5
+ import {
6
+ defineTaskHandler,
7
+ TaskRunner,
8
+ type AnyTaskHandlerDescriptor,
9
+ type TaskRunnerCapabilities,
10
+ type Worker,
11
+ } from '@furystack/task-runner'
12
+ import { runTaskToCompletion } from '@furystack/task-runner/testing'
13
+ import { createClient, type RedisClientType } from 'redis'
14
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
15
+ import { defineRedisTaskRunner } from './define-redis-task-runner.js'
16
+
17
+ const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379'
18
+
19
+ type Harness = {
20
+ injector: Injector
21
+ runner: TaskRunner
22
+ workers: Worker[]
23
+ prefix: string
24
+ client: RedisClientType
25
+ [Symbol.asyncDispose]: () => Promise<void>
26
+ }
27
+
28
+ const buildHarness = async (options: {
29
+ prefix: string
30
+ workerCount: number
31
+ handlers: AnyTaskHandlerDescriptor[]
32
+ visibilityTimeoutMs?: number
33
+ schedulerIntervalMs?: number
34
+ concurrencyLimits?: Record<string, number>
35
+ workerConcurrency?: number
36
+ }): Promise<Harness> => {
37
+ const client = createClient({ url: REDIS_URL })
38
+ await client.connect()
39
+
40
+ const injector = createInjector()
41
+ injector.bind(BlobStore, ({ onDispose }) => {
42
+ const store = new InMemoryBlobStore({ name: 'integration-blobs' })
43
+ // eslint-disable-next-line furystack/prefer-using-wrapper -- delegated to onDispose
44
+ onDispose(() => store[Symbol.dispose]())
45
+ return store
46
+ })
47
+ injector.bind(CrossNodeBus, defineInProcessCrossNodeBus())
48
+
49
+ injector.bind(
50
+ TaskRunner,
51
+ defineRedisTaskRunner({
52
+ client,
53
+ serviceName: 'integration',
54
+ topicPrefix: options.prefix,
55
+ visibilityTimeoutMs: options.visibilityTimeoutMs ?? 60_000,
56
+ blockTimeoutMs: 50,
57
+ retryBackoffMs: 50,
58
+ schedulerIntervalMs: options.schedulerIntervalMs ?? 50,
59
+ reconcilerIntervalMs: 200,
60
+ sweepIntervalMs: 100,
61
+ concurrencyLimits: options.concurrencyLimits,
62
+ }),
63
+ )
64
+
65
+ const runner = injector.get(TaskRunner)
66
+ const workers: Worker[] = []
67
+ for (let i = 0; i < options.workerCount; i++) {
68
+ workers.push(
69
+ runner.registerWorker({
70
+ name: `worker-${i}`,
71
+ handlers: options.handlers,
72
+ concurrency: options.workerConcurrency ?? 2,
73
+ tags: [],
74
+ compatibleVersions: {},
75
+ }),
76
+ )
77
+ }
78
+
79
+ return {
80
+ injector,
81
+ runner,
82
+ workers,
83
+ prefix: options.prefix,
84
+ client,
85
+ async [Symbol.asyncDispose]() {
86
+ for (const w of workers) w[Symbol.dispose]()
87
+ await injector[Symbol.asyncDispose]()
88
+ if (client.isOpen) await client.quit()
89
+ },
90
+ }
91
+ }
92
+
93
+ const cleanupStreams = async (prefix: string): Promise<void> => {
94
+ const client = createClient({ url: REDIS_URL })
95
+ try {
96
+ await client.connect()
97
+ const keys = await client.keys(`${prefix}*`)
98
+ if (keys.length > 0) await client.del(keys)
99
+ } catch {
100
+ // Redis unreachable; setup will fail loudly in a per-test path.
101
+ } finally {
102
+ if (client.isOpen) await client.quit()
103
+ }
104
+ }
105
+
106
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
107
+
108
+ describe('RedisTaskRunner (integration)', () => {
109
+ let prefix: string
110
+
111
+ beforeEach(() => {
112
+ prefix = `rtr-${randomUUID().slice(0, 8)}/`
113
+ })
114
+
115
+ afterEach(async () => {
116
+ await cleanupStreams(prefix)
117
+ })
118
+
119
+ it('declares persistent + delayedDispatch=true capabilities', async () => {
120
+ await using harness = await buildHarness({ prefix, workerCount: 0, handlers: [] })
121
+ const caps: TaskRunnerCapabilities = harness.runner.capabilities
122
+ expect(caps.persistent).toBe(true)
123
+ expect(caps.delayedDispatch).toBe(true)
124
+ expect(caps.fleetCapEnforcement).toBe(true)
125
+ })
126
+
127
+ it('honors notBefore by parking the task in the scheduler ZSET until it is due', async () => {
128
+ const ranAt: number[] = []
129
+ const stamper = defineTaskHandler<Record<string, never>, void>({
130
+ type: 'stamp',
131
+ version: 1,
132
+ handler: async (ctx) => {
133
+ ranAt.push(ctx.now().getTime())
134
+ },
135
+ })
136
+
137
+ await using harness = await buildHarness({ prefix, workerCount: 1, handlers: [stamper] })
138
+ const submittedAt = Date.now()
139
+ const dueAt = submittedAt + 400
140
+ const task = await harness.runner.submit({
141
+ type: 'stamp',
142
+ payload: {},
143
+ handlerVersion: 1,
144
+ notBefore: new Date(dueAt),
145
+ })
146
+
147
+ // Task should still be 'pending' shortly after submit and the
148
+ // queue stream should be empty — payload is parked in the
149
+ // scheduler ZSET. Allow a small tolerance for clock drift.
150
+ await sleep(120)
151
+ const streamLen = await harness.client.xLen(`${prefix}tasks:queue:stamp:v1`).catch(() => 0)
152
+ expect(streamLen).toBe(0)
153
+ const schedSize = await harness.client.zCard(`${prefix}tasks:scheduler`).catch(() => 0)
154
+ expect(schedSize).toBe(1)
155
+
156
+ const completed = await runTaskToCompletion({ runner: harness.runner, taskId: task.id, timeoutMs: 5000 })
157
+ expect(completed.status).toBe('succeeded')
158
+ expect(ranAt).toHaveLength(1)
159
+ // First and only run should not have started before the due time.
160
+ expect(ranAt[0]).toBeGreaterThanOrEqual(dueAt - 50)
161
+ }, 15_000)
162
+
163
+ it('submit → claim → handler runs → terminal succeeded (happy path)', async () => {
164
+ const echo = defineTaskHandler<{ value: string }, { echoed: string }>({
165
+ type: 'echo',
166
+ version: 1,
167
+ handler: async (_ctx, payload) => ({ echoed: payload.value }),
168
+ })
169
+
170
+ await using harness = await buildHarness({ prefix, workerCount: 1, handlers: [echo] })
171
+ const task = await harness.runner.submit({
172
+ type: 'echo',
173
+ payload: { value: 'hello' },
174
+ handlerVersion: 1,
175
+ })
176
+
177
+ const completed = await runTaskToCompletion({ runner: harness.runner, taskId: task.id, timeoutMs: 5000 })
178
+ expect(completed.status).toBe('succeeded')
179
+ expect(completed.result).toEqual({ echoed: 'hello' })
180
+ }, 15_000)
181
+
182
+ it('two workers do not double-execute the same task across the fleet', async () => {
183
+ const runs = new Set<string>()
184
+ const counter = defineTaskHandler<{ n: number }, { ran: boolean }>({
185
+ type: 'count-once',
186
+ version: 1,
187
+ handler: async (ctx) => {
188
+ if (runs.has(ctx.taskId)) {
189
+ throw new Error(`Task ${ctx.taskId} ran twice`)
190
+ }
191
+ runs.add(ctx.taskId)
192
+ return { ran: true }
193
+ },
194
+ })
195
+
196
+ await using harness = await buildHarness({ prefix, workerCount: 2, handlers: [counter] })
197
+ const tasks = await Promise.all(
198
+ Array.from({ length: 8 }, (_, i) =>
199
+ harness.runner.submit({ type: 'count-once', payload: { n: i }, handlerVersion: 1 }),
200
+ ),
201
+ )
202
+
203
+ for (const task of tasks) {
204
+ const completed = await runTaskToCompletion({ runner: harness.runner, taskId: task.id, timeoutMs: 8000 })
205
+ expect(completed.status).toBe('succeeded')
206
+ }
207
+ expect(runs.size).toBe(8)
208
+ }, 30_000)
209
+
210
+ it('reclaims a stalled handler via XAUTOCLAIM and runs it on another consumer', async () => {
211
+ const seenAttempts: number[] = []
212
+
213
+ const flaky = defineTaskHandler<Record<string, never>, { ok: true }>({
214
+ type: 'flaky-handler',
215
+ version: 1,
216
+ visibilityTimeoutMs: 250,
217
+ retryPolicy: { maxAttempts: 1, backoff: 'none', baseDelayMs: 0, jitter: 0 },
218
+ handler: async (ctx) => {
219
+ seenAttempts.push(ctx.attempt)
220
+ if (ctx.attempt === 1) {
221
+ // Stall until our cancellation signal fires (the runner core
222
+ // aborts the prior AC when the broker-side reclaim hands the
223
+ // task to a fresh consumer). The throw lets the runner core
224
+ // unwind cleanly and recognise the AC was replaced.
225
+ await new Promise<void>((_, reject) => {
226
+ ctx.cancellationSignal.addEventListener('abort', () => reject(new Error('reclaimed')), { once: true })
227
+ })
228
+ }
229
+ return { ok: true }
230
+ },
231
+ })
232
+
233
+ await using harness = await buildHarness({
234
+ prefix,
235
+ workerCount: 2,
236
+ handlers: [flaky],
237
+ visibilityTimeoutMs: 250,
238
+ })
239
+
240
+ const task = await harness.runner.submit({
241
+ type: 'flaky-handler',
242
+ payload: {},
243
+ handlerVersion: 1,
244
+ })
245
+
246
+ const deadline = Date.now() + 10_000
247
+ while (Date.now() < deadline) {
248
+ const t = await harness.runner.get(task.id)
249
+ if (t?.status === 'succeeded') break
250
+ await sleep(100)
251
+ }
252
+ const final = await harness.runner.get(task.id)
253
+ expect(final?.status).toBe('succeeded')
254
+ expect(seenAttempts.length).toBeGreaterThanOrEqual(2)
255
+ expect(seenAttempts[0]).toBe(1)
256
+ }, 30_000)
257
+
258
+ it('draft does not enqueue; start releases to the queue', async () => {
259
+ let observed = 0
260
+ const observe = defineTaskHandler<Record<string, never>, void>({
261
+ type: 'observe',
262
+ version: 1,
263
+ handler: async () => {
264
+ observed += 1
265
+ },
266
+ })
267
+
268
+ await using harness = await buildHarness({ prefix, workerCount: 1, handlers: [observe] })
269
+
270
+ const drafted = await harness.runner.draft({ type: 'observe', payload: {}, handlerVersion: 1 })
271
+ expect(drafted.status).toBe('draft')
272
+
273
+ // Stream should not exist (or have zero entries) before start.
274
+ const streamKey = `${prefix}tasks:queue:observe:v1`
275
+ const lenBefore = await harness.client.xLen(streamKey).catch(() => 0)
276
+ expect(lenBefore).toBe(0)
277
+ expect(observed).toBe(0)
278
+
279
+ await harness.runner.start(drafted.id)
280
+ const completed = await runTaskToCompletion({ runner: harness.runner, taskId: drafted.id, timeoutMs: 5000 })
281
+ expect(completed.status).toBe('succeeded')
282
+ expect(observed).toBe(1)
283
+ }, 15_000)
284
+
285
+ it('cancel aborts a running handler via the bus broadcast', async () => {
286
+ let aborted = false
287
+ const long = defineTaskHandler<Record<string, never>, void>({
288
+ type: 'long-running',
289
+ version: 1,
290
+ handler: async (ctx) => {
291
+ try {
292
+ await ctx.sleep(60_000)
293
+ } catch {
294
+ aborted = true
295
+ throw ctx.cancellationSignal.reason instanceof Error ? ctx.cancellationSignal.reason : new Error('cancelled')
296
+ }
297
+ },
298
+ })
299
+
300
+ await using harness = await buildHarness({ prefix, workerCount: 1, handlers: [long] })
301
+ const task = await harness.runner.submit({ type: 'long-running', payload: {}, handlerVersion: 1 })
302
+
303
+ await sleep(300)
304
+ await harness.runner.cancel(task.id, 'integration test')
305
+
306
+ const deadline = Date.now() + 5000
307
+ while (Date.now() < deadline) {
308
+ const t = await harness.runner.get(task.id)
309
+ if (t?.status === 'cancelled') break
310
+ await sleep(50)
311
+ }
312
+ const final = await harness.runner.get(task.id)
313
+ expect(final?.status).toBe('cancelled')
314
+ expect(aborted).toBe(true)
315
+ }, 15_000)
316
+
317
+ it('enforces a fleet-wide concurrency cap across two workers', async () => {
318
+ let live = 0
319
+ let maxLive = 0
320
+ const capped = defineTaskHandler<Record<string, never>, void>({
321
+ type: 'capped',
322
+ version: 1,
323
+ handler: async (ctx) => {
324
+ live += 1
325
+ maxLive = Math.max(maxLive, live)
326
+ try {
327
+ await ctx.sleep(300)
328
+ } finally {
329
+ live -= 1
330
+ }
331
+ },
332
+ })
333
+
334
+ // 2 workers × concurrency 2 = 4 fleet slots, but the cap is 2 — so at
335
+ // most two `capped` handlers may run at once across the whole fleet.
336
+ await using harness = await buildHarness({
337
+ prefix,
338
+ workerCount: 2,
339
+ workerConcurrency: 2,
340
+ handlers: [capped],
341
+ concurrencyLimits: { capped: 2 },
342
+ })
343
+
344
+ const tasks = await Promise.all(
345
+ Array.from({ length: 6 }, () => harness.runner.submit({ type: 'capped', payload: {}, handlerVersion: 1 })),
346
+ )
347
+
348
+ for (const task of tasks) {
349
+ const completed = await runTaskToCompletion({ runner: harness.runner, taskId: task.id, timeoutMs: 15_000 })
350
+ expect(completed.status).toBe('succeeded')
351
+ }
352
+
353
+ // Cap holds (never exceeded) and is actually exercised (parallelism
354
+ // reached the cap rather than serializing).
355
+ expect(maxLive).toBeLessThanOrEqual(2)
356
+ expect(maxLive).toBe(2)
357
+ }, 30_000)
358
+
359
+ it('drains a worker: in-flight task finishes, no new tasks are claimed', async () => {
360
+ let started = 0
361
+ let finished = 0
362
+ let resolveStarted: () => void = () => {}
363
+ const startedSignal = new Promise<void>((resolve) => {
364
+ resolveStarted = resolve
365
+ })
366
+
367
+ const slow = defineTaskHandler<Record<string, never>, void>({
368
+ type: 'drainable',
369
+ version: 1,
370
+ handler: async (ctx) => {
371
+ started += 1
372
+ resolveStarted()
373
+ await ctx.sleep(600)
374
+ finished += 1
375
+ },
376
+ })
377
+
378
+ await using harness = await buildHarness({
379
+ prefix,
380
+ workerCount: 1,
381
+ workerConcurrency: 1,
382
+ handlers: [slow],
383
+ })
384
+
385
+ const inFlight = await harness.runner.submit({ type: 'drainable', payload: {}, handlerVersion: 1 })
386
+ await startedSignal
387
+
388
+ // Begin draining while the first task is mid-handler, then submit a
389
+ // second task that the draining worker must not pick up.
390
+ const drainPromise = harness.workers[0]?.drain({ timeoutMs: 5000 }) ?? Promise.resolve()
391
+ const queued = await harness.runner.submit({ type: 'drainable', payload: {}, handlerVersion: 1 })
392
+
393
+ await drainPromise
394
+
395
+ // The in-flight task completed despite the drain.
396
+ const inFlightFinal = await harness.runner.get(inFlight.id)
397
+ expect(inFlightFinal?.status).toBe('succeeded')
398
+ expect(finished).toBe(1)
399
+
400
+ // The second task was never claimed by the drained worker.
401
+ await sleep(400)
402
+ const queuedFinal = await harness.runner.get(queued.id)
403
+ expect(queuedFinal?.status).toBe('pending')
404
+ expect(started).toBe(1)
405
+ }, 20_000)
406
+
407
+ it('reaps a child spawned-without-await when the parent succeeds (orphan reaping)', async () => {
408
+ const blocker = defineTaskHandler<Record<string, never>, void>({
409
+ type: 'reap-blocker',
410
+ version: 1,
411
+ handler: async (ctx) => {
412
+ await ctx.sleep(60_000)
413
+ },
414
+ })
415
+ const spawnAndForget = defineTaskHandler<Record<string, never>, { done: true }>({
416
+ type: 'reap-parent',
417
+ version: 1,
418
+ handler: async (ctx) => {
419
+ await ctx.spawnChild<Record<string, never>, void>('reap-blocker', {})
420
+ return { done: true }
421
+ },
422
+ })
423
+
424
+ await using harness = await buildHarness({
425
+ prefix,
426
+ workerCount: 1,
427
+ workerConcurrency: 2,
428
+ handlers: [blocker, spawnAndForget],
429
+ })
430
+
431
+ const task = await harness.runner.submit({ type: 'reap-parent', payload: {}, handlerVersion: 1 })
432
+ const parent = await runTaskToCompletion({ runner: harness.runner, taskId: task.id, timeoutMs: 8000 })
433
+ expect(parent.status).toBe('succeeded')
434
+
435
+ const childId = parent.childTaskIds[0]
436
+ expect(childId).toBeTruthy()
437
+ const child = await runTaskToCompletion({ runner: harness.runner, taskId: childId, timeoutMs: 8000 })
438
+ expect(child.status).toBe('cancelled')
439
+ }, 30_000)
440
+ })
@@ -0,0 +1,67 @@
1
+ import type { BlobStore } from '@furystack/blob-store'
2
+ import type { CrossNodeBus } from '@furystack/cross-node-bus'
3
+ import type { Injector } from '@furystack/inject'
4
+ import type { DataSet } from '@furystack/repository'
5
+ import type { Task, TaskReplayLogEntry } from '@furystack/task-runner'
6
+ import { TaskRunnerCore, type TaskRunnerCoreOptions } from '@furystack/task-runner'
7
+ import type { TaskRunnerTelemetry } from '@furystack/task-runner'
8
+ import { RedisQueueAdapter, type RedisQueueAdapterOptions } from './redis-queue-adapter.js'
9
+
10
+ export type RedisTaskRunnerOptions = TaskRunnerCoreOptions
11
+
12
+ export type RedisTaskRunnerDeps = Omit<RedisQueueAdapterOptions, 'client' | 'serviceName'> & {
13
+ injector: Injector
14
+ bus: CrossNodeBus
15
+ blobStore: BlobStore
16
+ taskDs: DataSet<Task, 'id'>
17
+ replayDs: DataSet<TaskReplayLogEntry, 'id'>
18
+ telemetry: TaskRunnerTelemetry
19
+ client: RedisQueueAdapterOptions['client']
20
+ serviceName: string
21
+ }
22
+
23
+ /**
24
+ * Redis-Streams-backed {@link TaskRunnerCore}. Uses
25
+ * `@furystack/redis-task-runner` consumer-group claim plumbing while
26
+ * inheriting all task lifecycle, replay, retry, cancel-cascade, and
27
+ * telemetry behavior from the core class. Apps bind it via
28
+ * {@link defineRedisTaskRunner} so the adapter shares lifetime with
29
+ * the surrounding injector scope.
30
+ */
31
+ export class RedisTaskRunner extends TaskRunnerCore {
32
+ readonly #queueAdapter: RedisQueueAdapter
33
+
34
+ constructor(deps: RedisTaskRunnerDeps, options?: RedisTaskRunnerOptions) {
35
+ const queueAdapter = new RedisQueueAdapter({
36
+ client: deps.client,
37
+ serviceName: deps.serviceName,
38
+ topicPrefix: deps.topicPrefix,
39
+ consumerGroup: deps.consumerGroup,
40
+ visibilityTimeoutMs: deps.visibilityTimeoutMs,
41
+ visibilityTimeoutByType: deps.visibilityTimeoutByType,
42
+ blockTimeoutMs: deps.blockTimeoutMs,
43
+ retryBackoffMs: deps.retryBackoffMs,
44
+ idempotencyTtlSec: deps.idempotencyTtlSec,
45
+ schedulerIntervalMs: deps.schedulerIntervalMs,
46
+ concurrencyLimits: deps.concurrencyLimits,
47
+ })
48
+ super(
49
+ {
50
+ injector: deps.injector,
51
+ bus: deps.bus,
52
+ blobStore: deps.blobStore,
53
+ taskDs: deps.taskDs,
54
+ replayDs: deps.replayDs,
55
+ telemetry: deps.telemetry,
56
+ queueAdapter,
57
+ },
58
+ options,
59
+ )
60
+ this.#queueAdapter = queueAdapter
61
+ }
62
+
63
+ public override [Symbol.dispose](): void {
64
+ super[Symbol.dispose]()
65
+ this.#queueAdapter[Symbol.dispose]()
66
+ }
67
+ }
@@ -0,0 +1,110 @@
1
+ import { BlobStore, InMemoryBlobStore } from '@furystack/blob-store'
2
+ import { CrossNodeBus, defineInProcessCrossNodeBus } from '@furystack/cross-node-bus'
3
+ import { createInjector } from '@furystack/inject'
4
+ import { TaskRunner } from '@furystack/task-runner'
5
+ import { usingAsync } from '@furystack/utils'
6
+ import { describe, expect, it, vi } from 'vitest'
7
+ import { defineRedisTaskRunner } from './define-redis-task-runner.js'
8
+ import { RedisTaskRunner } from './redis-task-runner.js'
9
+
10
+ type RedisClientArg = Parameters<typeof defineRedisTaskRunner>[0]['client']
11
+
12
+ /**
13
+ * Minimal stand-in for the `redis` client. The adapter only touches the
14
+ * client lazily (it `duplicate()`s + `connect()`s a read client on
15
+ * construction and issues stream commands on enqueue/subscribe), so a
16
+ * service-less unit run can exercise the runner end to end without a
17
+ * live broker. `duplicate()` returns the same mock so dispose assertions
18
+ * observe the read client teardown.
19
+ */
20
+ const buildMockClient = () => {
21
+ const client = {
22
+ xAdd: vi.fn().mockResolvedValue('1-0'),
23
+ xGroupCreate: vi.fn().mockResolvedValue('OK'),
24
+ xReadGroup: vi.fn().mockResolvedValue(null),
25
+ xAutoClaim: vi.fn().mockResolvedValue({ nextId: '0-0', messages: [] }),
26
+ xAck: vi.fn().mockResolvedValue(1),
27
+ xClaimJustId: vi.fn().mockResolvedValue([]),
28
+ set: vi.fn().mockResolvedValue('OK'),
29
+ get: vi.fn().mockResolvedValue(null),
30
+ zAdd: vi.fn().mockResolvedValue(1),
31
+ zRem: vi.fn().mockResolvedValue(1),
32
+ eval: vi.fn().mockResolvedValue(0),
33
+ isOpen: true,
34
+ destroy: vi.fn(),
35
+ duplicate: vi.fn(),
36
+ connect: vi.fn(),
37
+ }
38
+ client.duplicate = vi.fn(() => client)
39
+ client.connect = vi.fn().mockResolvedValue(client)
40
+ return client
41
+ }
42
+
43
+ const bindRunner = (injector: ReturnType<typeof createInjector>, client: ReturnType<typeof buildMockClient>): void => {
44
+ injector.bind(BlobStore, ({ onDispose }) => {
45
+ const store = new InMemoryBlobStore({ name: 'unit-blobs' })
46
+ // eslint-disable-next-line furystack/prefer-using-wrapper -- delegated to onDispose
47
+ onDispose(() => store[Symbol.dispose]())
48
+ return store
49
+ })
50
+ injector.bind(CrossNodeBus, defineInProcessCrossNodeBus())
51
+ injector.bind(
52
+ TaskRunner,
53
+ defineRedisTaskRunner({
54
+ client: client as unknown as RedisClientArg,
55
+ serviceName: 'unit',
56
+ topicPrefix: 'unit/',
57
+ // Park the background timers so the unit run does not tick the
58
+ // reconciler/scheduler against the mock broker.
59
+ reconcilerIntervalMs: 1_000_000,
60
+ sweepIntervalMs: 1_000_000,
61
+ schedulerIntervalMs: 1_000_000,
62
+ }),
63
+ )
64
+ }
65
+
66
+ describe('@furystack/redis-task-runner', () => {
67
+ describe('defineRedisTaskRunner', () => {
68
+ it('resolves a RedisTaskRunner with Redis-backed capabilities', async () => {
69
+ await usingAsync(createInjector(), async (injector) => {
70
+ bindRunner(injector, buildMockClient())
71
+ const runner = injector.get(TaskRunner)
72
+
73
+ expect(runner).toBeInstanceOf(RedisTaskRunner)
74
+ expect(runner.capabilities.persistent).toBe(true)
75
+ expect(runner.capabilities.fleetCapEnforcement).toBe(true)
76
+ expect(runner.capabilities.delayedDispatch).toBe(true)
77
+ expect(runner.capabilities.maxPayloadBytes).toBe(Infinity)
78
+ })
79
+ })
80
+
81
+ it('tears down the duplicated read client when the owning injector is disposed', async () => {
82
+ const client = buildMockClient()
83
+ await usingAsync(createInjector(), async (injector) => {
84
+ bindRunner(injector, client)
85
+ injector.get(TaskRunner)
86
+ })
87
+
88
+ // duplicate() is called once for the dedicated read connection, which
89
+ // is force-closed via destroy() on dispose.
90
+ expect(client.duplicate).toHaveBeenCalledTimes(1)
91
+ expect(client.destroy).toHaveBeenCalledTimes(1)
92
+ })
93
+
94
+ it('routes submit() onto the per-(type,version) Redis stream', async () => {
95
+ const client = buildMockClient()
96
+ await usingAsync(createInjector(), async (injector) => {
97
+ bindRunner(injector, client)
98
+ const runner = injector.get(TaskRunner)
99
+
100
+ const task = await runner.submit({ type: 'echo', payload: { value: 'hi' }, handlerVersion: 1 })
101
+
102
+ expect(client.xAdd).toHaveBeenCalledWith('unit/tasks:queue:echo:v1', '*', {
103
+ taskId: task.id,
104
+ type: 'echo',
105
+ handlerVersion: '1',
106
+ })
107
+ })
108
+ })
109
+ })
110
+ })