@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,459 @@
1
+ /**
2
+ * Cross-process multi-worker smoke (PRD F2 / Milestone 6 precursor).
3
+ *
4
+ * Forks real OS worker processes (`child_process.fork`) that each host their
5
+ * own injector, Redis client and `RedisTaskRunner`, sharing one broker. The
6
+ * Task + TaskReplayLog datasets are bound to `@furystack/redis-store` so task
7
+ * state is coherent across processes (this is what F1 + F1b unblocked — the
8
+ * runner cannot use an in-memory store cross-process). The cross-node bus is
9
+ * in-process per child: the scenarios below (claim concurrency, fleet cap,
10
+ * visibility reclaim, drain) are all broker- or worker-local and need no
11
+ * cross-process bus delivery.
12
+ *
13
+ * Scenarios:
14
+ *
15
+ * 1. **Claim concurrency / no double-execute** — two worker processes drain a
16
+ * batch of tasks; every task runs exactly once across the fleet.
17
+ * 2. **Fleet cap** — `concurrencyLimits` holds the max-concurrent count for a
18
+ * type across both processes (measured via a shared Redis counter).
19
+ * 3. **Visibility reclaim** — a worker is `SIGKILL`ed mid-handler; a second
20
+ * worker reclaims the stalled claim (`XAUTOCLAIM`) and completes it.
21
+ * 4. **Graceful drain** — a worker drains mid-flight: the in-flight task
22
+ * finishes and no further task is claimed.
23
+ *
24
+ * **Build prereq.** Children execute the compiled `esm/*.js`, so the package
25
+ * must be built before this runs. CI runs `yarn build` before `yarn test`;
26
+ * a local isolated run needs `yarn workspace @furystack/redis-task-runner build`
27
+ * first.
28
+ *
29
+ * **Redis prereq.** Broker reachable at `REDIS_URL` (default
30
+ * `redis://localhost:6379`); CI brings it up via `docker compose up -d`.
31
+ */
32
+ import { fork, type ChildProcess } from 'node:child_process'
33
+ import { randomUUID } from 'node:crypto'
34
+ import { dirname, resolve } from 'node:path'
35
+ import { fileURLToPath } from 'node:url'
36
+ import { BlobStore, InMemoryBlobStore } from '@furystack/blob-store'
37
+ import type { PhysicalStore } from '@furystack/core'
38
+ import { CrossNodeBus, defineInProcessCrossNodeBus } from '@furystack/cross-node-bus'
39
+ import { createInjector, type Injector } from '@furystack/inject'
40
+ import { RedisStore } from '@furystack/redis-store'
41
+ import {
42
+ defineTaskHandler,
43
+ isTerminalStatus,
44
+ Task,
45
+ TaskReplayLogEntry,
46
+ TaskReplayLogStore,
47
+ TaskRunner,
48
+ TaskStore,
49
+ type AnyTaskHandlerDescriptor,
50
+ type TaskStatus,
51
+ } from '@furystack/task-runner'
52
+ import { defineRedisTaskRunner } from './define-redis-task-runner.js'
53
+ import { createClient, type RedisClientType } from 'redis'
54
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
55
+
56
+ const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379'
57
+ const CHILD_CONFIG_ENV = 'CHILD_CONFIG'
58
+
59
+ type ChildConfig = {
60
+ id: string
61
+ topicPrefix: string
62
+ storePrefix: string
63
+ concurrency: number
64
+ visibilityMs: number
65
+ capLimit?: number
66
+ }
67
+
68
+ type ParentToChild = { kind: 'drain' } | { kind: 'shutdown' }
69
+ type ChildToParent = { kind: 'ready'; id: string } | { kind: 'drained'; id: string } | { kind: 'log'; line: string }
70
+
71
+ /** Tracks the max concurrent value seen in `KEYS[1]` against the live count. */
72
+ const MAX_SCRIPT = `
73
+ local m = tonumber(redis.call('GET', KEYS[1]) or '0')
74
+ local v = tonumber(ARGV[1])
75
+ if v > m then redis.call('SET', KEYS[1], v) end
76
+ return v
77
+ `
78
+
79
+ const sleep = (ms: number): Promise<void> => new Promise((resolveSleep) => setTimeout(resolveSleep, ms))
80
+
81
+ type RedisLikeClient = RedisClientType
82
+
83
+ const evalScript = (client: RedisLikeClient, script: string, keys: string[], args: string[]): Promise<unknown> => {
84
+ const evalFn = client.eval as (s: string, opts: { keys: string[]; arguments: string[] }) => Promise<unknown>
85
+ return evalFn.call(client, script, { keys, arguments: args })
86
+ }
87
+
88
+ /**
89
+ * Handlers shared by every worker. Each records progress in shared Redis keys
90
+ * (under the `${storePrefix}c:` namespace) so the parent can assert behavior
91
+ * across processes.
92
+ */
93
+ const buildHandlers = (client: RedisLikeClient, ns: string): AnyTaskHandlerDescriptor[] => {
94
+ const echo = defineTaskHandler<Record<string, never>, { ok: true }>({
95
+ type: 'echo',
96
+ version: 1,
97
+ handler: async (ctx) => {
98
+ await client.incr(`${ns}exec:${ctx.taskId}`)
99
+ return { ok: true }
100
+ },
101
+ })
102
+
103
+ const capped = defineTaskHandler<Record<string, never>, void>({
104
+ type: 'capped',
105
+ version: 1,
106
+ handler: async () => {
107
+ const live = await client.incr(`${ns}live`)
108
+ await evalScript(client, MAX_SCRIPT, [`${ns}max`], [String(live)])
109
+ await sleep(300)
110
+ await client.decr(`${ns}live`)
111
+ },
112
+ })
113
+
114
+ const reclaim = defineTaskHandler<Record<string, never>, void>({
115
+ type: 'reclaim',
116
+ version: 1,
117
+ retryPolicy: { maxAttempts: 1, backoff: 'none', baseDelayMs: 0, jitter: 0 },
118
+ handler: async () => {
119
+ await client.incr(`${ns}reclaim:starts`)
120
+ await sleep(5000)
121
+ await client.incr(`${ns}reclaim:finishes`)
122
+ },
123
+ })
124
+
125
+ const drainable = defineTaskHandler<Record<string, never>, void>({
126
+ type: 'drain',
127
+ version: 1,
128
+ handler: async () => {
129
+ await client.incr(`${ns}drain:started`)
130
+ await sleep(700)
131
+ await client.incr(`${ns}drain:done`)
132
+ },
133
+ })
134
+
135
+ return [echo, capped, reclaim, drainable]
136
+ }
137
+
138
+ const bindRuntime = (
139
+ injector: Injector,
140
+ client: RedisLikeClient,
141
+ config: Pick<ChildConfig, 'id' | 'topicPrefix' | 'storePrefix' | 'visibilityMs' | 'capLimit'>,
142
+ ): void => {
143
+ injector.bind(BlobStore, ({ onDispose }) => {
144
+ const store = new InMemoryBlobStore({ name: `${config.id}-blobs` })
145
+ // eslint-disable-next-line furystack/prefer-using-wrapper -- delegated to onDispose
146
+ onDispose(() => store[Symbol.dispose]())
147
+ return store
148
+ })
149
+ injector.bind(CrossNodeBus, defineInProcessCrossNodeBus())
150
+ // `as PhysicalStore` bridges the method-param variance gap between
151
+ // `RedisStore`'s `TWriteableData` (id required) and the StoreToken's
152
+ // `WithOptionalId` — the same shape `defineStore` accepts via bivariance.
153
+ injector.bind(
154
+ TaskStore,
155
+ () =>
156
+ new RedisStore({ model: Task, primaryKey: 'id', client, keyPrefix: `${config.storePrefix}t` }) as PhysicalStore<
157
+ Task,
158
+ 'id'
159
+ >,
160
+ )
161
+ injector.bind(
162
+ TaskReplayLogStore,
163
+ () =>
164
+ new RedisStore({
165
+ model: TaskReplayLogEntry,
166
+ primaryKey: 'id',
167
+ client,
168
+ keyPrefix: `${config.storePrefix}r`,
169
+ }) as PhysicalStore<TaskReplayLogEntry, 'id'>,
170
+ )
171
+ injector.bind(
172
+ TaskRunner,
173
+ defineRedisTaskRunner({
174
+ client,
175
+ serviceName: config.id,
176
+ topicPrefix: config.topicPrefix,
177
+ visibilityTimeoutMs: config.visibilityMs,
178
+ blockTimeoutMs: 50,
179
+ retryBackoffMs: 50,
180
+ schedulerIntervalMs: 50,
181
+ reconcilerIntervalMs: 1_000_000,
182
+ concurrencyLimits: config.capLimit !== undefined ? { capped: config.capLimit } : undefined,
183
+ }),
184
+ )
185
+ }
186
+
187
+ // ─────────────────────────────────────────────────────────────────────────────
188
+ // Child worker process.
189
+ // ─────────────────────────────────────────────────────────────────────────────
190
+
191
+ const runChild = async (config: ChildConfig): Promise<void> => {
192
+ const send = (message: ChildToParent): void => {
193
+ process.send?.(message)
194
+ }
195
+
196
+ const client = createClient({ url: REDIS_URL })
197
+ await client.connect()
198
+
199
+ const injector = createInjector()
200
+ bindRuntime(injector, client, config)
201
+ const runner = injector.get(TaskRunner)
202
+ const worker = runner.registerWorker({
203
+ name: config.id,
204
+ handlers: buildHandlers(client, `${config.storePrefix}c:`),
205
+ concurrency: config.concurrency,
206
+ tags: [],
207
+ compatibleVersions: {},
208
+ })
209
+
210
+ process.on('message', (message: ParentToChild) => {
211
+ void (async () => {
212
+ if (message.kind === 'drain') {
213
+ await worker.drain({ timeoutMs: 5000 })
214
+ send({ kind: 'drained', id: config.id })
215
+ } else {
216
+ worker[Symbol.dispose]()
217
+ await injector[Symbol.asyncDispose]()
218
+ if (client.isOpen) await client.quit()
219
+ process.exit(0)
220
+ }
221
+ })().catch((error: unknown) => send({ kind: 'log', line: `${config.id} crashed: ${String(error)}` }))
222
+ })
223
+
224
+ send({ kind: 'ready', id: config.id })
225
+ }
226
+
227
+ const isChildMode = process.argv[2] === 'child'
228
+
229
+ if (isChildMode) {
230
+ const raw = process.env[CHILD_CONFIG_ENV]
231
+ if (!raw) {
232
+ process.stderr.write('cross-process-smoke child requires CHILD_CONFIG\n')
233
+ process.exit(1)
234
+ } else {
235
+ runChild(JSON.parse(raw) as ChildConfig).catch((error: unknown) => {
236
+ process.stderr.write(`child crashed: ${(error as Error).stack ?? String(error)}\n`)
237
+ process.exit(1)
238
+ })
239
+ }
240
+ }
241
+
242
+ // ─────────────────────────────────────────────────────────────────────────────
243
+ // Parent / vitest side.
244
+ // ─────────────────────────────────────────────────────────────────────────────
245
+
246
+ const COMPILED_SPEC_PATH = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'esm', 'cross-process-smoke.spec.js')
247
+
248
+ type Worker = {
249
+ id: string
250
+ child: ChildProcess
251
+ receive: (predicate: (message: ChildToParent) => boolean, timeoutMs?: number) => Promise<ChildToParent>
252
+ }
253
+
254
+ const forkWorker = (config: ChildConfig): Worker => {
255
+ const inbox: ChildToParent[] = []
256
+ const listeners = new Set<(message: ChildToParent) => void>()
257
+
258
+ const child = fork(COMPILED_SPEC_PATH, ['child', config.id], {
259
+ env: { ...process.env, [CHILD_CONFIG_ENV]: JSON.stringify(config) },
260
+ })
261
+
262
+ child.on('message', (message) => {
263
+ const typed = message as ChildToParent
264
+ inbox.push(typed)
265
+ for (const listener of listeners) listener(typed)
266
+ })
267
+
268
+ const receive = (predicate: (message: ChildToParent) => boolean, timeoutMs = 8000): Promise<ChildToParent> => {
269
+ const matched = inbox.find(predicate)
270
+ if (matched) return Promise.resolve(matched)
271
+ return new Promise((resolveReceive, reject) => {
272
+ const timer = setTimeout(() => {
273
+ listeners.delete(handler)
274
+ reject(new Error(`Timed out after ${timeoutMs}ms waiting on ${config.id}`))
275
+ }, timeoutMs)
276
+ const handler = (message: ChildToParent): void => {
277
+ if (predicate(message)) {
278
+ clearTimeout(timer)
279
+ listeners.delete(handler)
280
+ resolveReceive(message)
281
+ }
282
+ }
283
+ listeners.add(handler)
284
+ })
285
+ }
286
+
287
+ return { id: config.id, child, receive }
288
+ }
289
+
290
+ const shutdownWorker = async (worker: Worker): Promise<void> => {
291
+ if (worker.child.exitCode !== null || worker.child.signalCode !== null) return
292
+ worker.child.send({ kind: 'shutdown' } satisfies ParentToChild)
293
+ await new Promise<void>((resolveExit) => {
294
+ if (worker.child.exitCode !== null) resolveExit()
295
+ else worker.child.once('exit', () => resolveExit())
296
+ })
297
+ }
298
+
299
+ type Parent = {
300
+ runner: TaskRunner
301
+ client: RedisLikeClient
302
+ [Symbol.asyncDispose]: () => Promise<void>
303
+ }
304
+
305
+ const buildParent = async (topicPrefix: string, storePrefix: string): Promise<Parent> => {
306
+ const client = createClient({ url: REDIS_URL })
307
+ await client.connect()
308
+ const injector = createInjector()
309
+ bindRuntime(injector, client, { id: 'parent', topicPrefix, storePrefix, visibilityMs: 60_000 })
310
+ const runner = injector.get(TaskRunner)
311
+ return {
312
+ runner,
313
+ client,
314
+ [Symbol.asyncDispose]: async () => {
315
+ await injector[Symbol.asyncDispose]()
316
+ if (client.isOpen) await client.quit()
317
+ },
318
+ }
319
+ }
320
+
321
+ const waitFor = async (predicate: () => Promise<boolean>, timeoutMs: number): Promise<void> => {
322
+ const deadline = Date.now() + timeoutMs
323
+ while (Date.now() < deadline) {
324
+ if (await predicate()) return
325
+ await sleep(75)
326
+ }
327
+ throw new Error(`waitFor: timed out after ${timeoutMs}ms`)
328
+ }
329
+
330
+ const pollStatus = async (parent: Parent, taskId: string, timeoutMs: number): Promise<TaskStatus | undefined> => {
331
+ const deadline = Date.now() + timeoutMs
332
+ while (Date.now() < deadline) {
333
+ const task = await parent.runner.get(taskId)
334
+ if (task && isTerminalStatus(task.status)) return task.status
335
+ await sleep(100)
336
+ }
337
+ return (await parent.runner.get(taskId))?.status
338
+ }
339
+
340
+ const num = (value: string | null): number => (value ? Number(value) : 0)
341
+
342
+ if (!isChildMode) {
343
+ describe('cross-process smoke (PRD F2)', () => {
344
+ let topicPrefix: string
345
+ let storePrefix: string
346
+ let active: Worker[] = []
347
+
348
+ const fork_ = (config: Omit<ChildConfig, 'topicPrefix' | 'storePrefix'>): Worker => {
349
+ const worker = forkWorker({ ...config, topicPrefix, storePrefix })
350
+ active.push(worker)
351
+ return worker
352
+ }
353
+
354
+ const awaitReady = async (...workers: Worker[]): Promise<void> => {
355
+ await Promise.all(workers.map((worker) => worker.receive((message) => message.kind === 'ready')))
356
+ }
357
+
358
+ beforeEach(() => {
359
+ topicPrefix = `xp-${randomUUID().slice(0, 8)}/`
360
+ storePrefix = `xps-${randomUUID().slice(0, 8)}/`
361
+ active = []
362
+ })
363
+
364
+ afterEach(async () => {
365
+ await Promise.all(active.map(shutdownWorker))
366
+ active = []
367
+ const client = createClient({ url: REDIS_URL })
368
+ try {
369
+ await client.connect()
370
+ for (const prefix of [topicPrefix, storePrefix]) {
371
+ const keys = await client.keys(`${prefix}*`)
372
+ if (keys.length > 0) await client.del(keys)
373
+ }
374
+ } catch {
375
+ // Redis unreachable — the test bodies already failed loudly.
376
+ } finally {
377
+ if (client.isOpen) await client.quit()
378
+ }
379
+ }, 15_000)
380
+
381
+ it('runs each task exactly once across two worker processes', async () => {
382
+ await using parent = await buildParent(topicPrefix, storePrefix)
383
+ const w1 = fork_({ id: 'w1', concurrency: 2, visibilityMs: 60_000 })
384
+ const w2 = fork_({ id: 'w2', concurrency: 2, visibilityMs: 60_000 })
385
+ await awaitReady(w1, w2)
386
+
387
+ const tasks = await Promise.all(
388
+ Array.from({ length: 8 }, () => parent.runner.submit({ type: 'echo', payload: {}, handlerVersion: 1 })),
389
+ )
390
+
391
+ for (const task of tasks) {
392
+ expect(await pollStatus(parent, task.id, 15_000)).toBe('succeeded')
393
+ }
394
+ for (const task of tasks) {
395
+ expect(num(await parent.client.get(`${storePrefix}c:exec:${task.id}`))).toBe(1)
396
+ }
397
+ }, 40_000)
398
+
399
+ it('enforces a fleet-wide concurrency cap across two worker processes', async () => {
400
+ await using parent = await buildParent(topicPrefix, storePrefix)
401
+ const w1 = fork_({ id: 'w1', concurrency: 2, visibilityMs: 60_000, capLimit: 2 })
402
+ const w2 = fork_({ id: 'w2', concurrency: 2, visibilityMs: 60_000, capLimit: 2 })
403
+ await awaitReady(w1, w2)
404
+
405
+ const tasks = await Promise.all(
406
+ Array.from({ length: 6 }, () => parent.runner.submit({ type: 'capped', payload: {}, handlerVersion: 1 })),
407
+ )
408
+
409
+ for (const task of tasks) {
410
+ expect(await pollStatus(parent, task.id, 20_000)).toBe('succeeded')
411
+ }
412
+
413
+ const maxConcurrent = num(await parent.client.get(`${storePrefix}c:max`))
414
+ expect(maxConcurrent).toBeLessThanOrEqual(2)
415
+ expect(maxConcurrent).toBe(2)
416
+ }, 40_000)
417
+
418
+ it('reclaims a task after the owning worker process is killed', async () => {
419
+ await using parent = await buildParent(topicPrefix, storePrefix)
420
+ const victim = fork_({ id: 'victim', concurrency: 1, visibilityMs: 1000 })
421
+ await awaitReady(victim)
422
+
423
+ const task = await parent.runner.submit({ type: 'reclaim', payload: {}, handlerVersion: 1 })
424
+ await waitFor(async () => num(await parent.client.get(`${storePrefix}c:reclaim:starts`)) >= 1, 10_000)
425
+
426
+ // Hard-kill the worker mid-handler; its claim goes stale in the PEL.
427
+ victim.child.kill('SIGKILL')
428
+
429
+ const survivor = fork_({ id: 'survivor', concurrency: 1, visibilityMs: 1000 })
430
+ await awaitReady(survivor)
431
+
432
+ expect(await pollStatus(parent, task.id, 25_000)).toBe('succeeded')
433
+ expect(num(await parent.client.get(`${storePrefix}c:reclaim:finishes`))).toBeGreaterThanOrEqual(1)
434
+ }, 45_000)
435
+
436
+ it('drains a worker process: in-flight task finishes, no new task is claimed', async () => {
437
+ await using parent = await buildParent(topicPrefix, storePrefix)
438
+ const worker = fork_({ id: 'w1', concurrency: 1, visibilityMs: 60_000 })
439
+ await awaitReady(worker)
440
+
441
+ const inFlight = await parent.runner.submit({ type: 'drain', payload: {}, handlerVersion: 1 })
442
+ await waitFor(async () => num(await parent.client.get(`${storePrefix}c:drain:started`)) >= 1, 10_000)
443
+
444
+ worker.child.send({ kind: 'drain' } satisfies ParentToChild)
445
+ const queued = await parent.runner.submit({ type: 'drain', payload: {}, handlerVersion: 1 })
446
+
447
+ await worker.receive((message) => message.kind === 'drained', 10_000)
448
+
449
+ // The in-flight task completed despite the drain.
450
+ expect((await parent.runner.get(inFlight.id))?.status).toBe('succeeded')
451
+ expect(num(await parent.client.get(`${storePrefix}c:drain:done`))).toBe(1)
452
+
453
+ // The second task was never claimed by the drained worker.
454
+ await sleep(500)
455
+ expect((await parent.runner.get(queued.id))?.status).toBe('pending')
456
+ expect(num(await parent.client.get(`${storePrefix}c:drain:started`))).toBe(1)
457
+ }, 40_000)
458
+ })
459
+ }
@@ -0,0 +1,91 @@
1
+ import { BlobStore as BlobStoreToken } from '@furystack/blob-store'
2
+ import { CrossNodeBus as CrossNodeBusToken } from '@furystack/cross-node-bus'
3
+ import type { ServiceFactory } from '@furystack/inject'
4
+ import { TaskDataSet, TaskReplayLogDataSet, TaskRunnerTelemetryToken, type TaskRunner } from '@furystack/task-runner'
5
+ import type { RedisQueueAdapterOptions } from './redis-queue-adapter.js'
6
+ import { RedisTaskRunner, type RedisTaskRunnerOptions } from './redis-task-runner.js'
7
+
8
+ /**
9
+ * Options accepted by {@link defineRedisTaskRunner}. `client` and
10
+ * `serviceName` are required; the remaining knobs forward to
11
+ * {@link RedisQueueAdapterOptions}.
12
+ */
13
+ export type DefineRedisTaskRunnerOptions = Omit<RedisQueueAdapterOptions, 'client' | 'serviceName'> & {
14
+ client: RedisQueueAdapterOptions['client']
15
+ serviceName: string
16
+ } & RedisTaskRunnerOptions
17
+
18
+ /**
19
+ * Returns a {@link ServiceFactory} for {@link TaskRunner}, backed by
20
+ * Redis Streams. Override the (intentionally throwing) default
21
+ * binding at boot:
22
+ *
23
+ * ```ts
24
+ * const client = createClient({ url: process.env.REDIS_URL })
25
+ * await client.connect()
26
+ *
27
+ * injector.bind(
28
+ * TaskRunner,
29
+ * defineRedisTaskRunner({
30
+ * client,
31
+ * serviceName: 'svc-a',
32
+ * topicPrefix: 'svc-a/',
33
+ * visibilityTimeoutMs: 60_000,
34
+ * }),
35
+ * )
36
+ * ```
37
+ *
38
+ * Caller still owns `client.connect()` / `client.quit()` —
39
+ * the adapter never closes it.
40
+ */
41
+ export const defineRedisTaskRunner = (options: DefineRedisTaskRunnerOptions): ServiceFactory<TaskRunner> => {
42
+ const {
43
+ client,
44
+ serviceName,
45
+ topicPrefix,
46
+ consumerGroup,
47
+ visibilityTimeoutMs,
48
+ visibilityTimeoutByType,
49
+ blockTimeoutMs,
50
+ retryBackoffMs,
51
+ idempotencyTtlSec,
52
+ schedulerIntervalMs,
53
+ concurrencyLimits,
54
+ reconcilerIntervalMs,
55
+ sweepIntervalMs,
56
+ } = options
57
+
58
+ return ({ inject, injector, onDispose }) => {
59
+ const bus = inject(CrossNodeBusToken)
60
+ const blobStore = inject(BlobStoreToken)
61
+ const taskDs = inject(TaskDataSet)
62
+ const replayDs = inject(TaskReplayLogDataSet)
63
+ const telemetry = inject(TaskRunnerTelemetryToken)
64
+
65
+ const runner = new RedisTaskRunner(
66
+ {
67
+ injector,
68
+ bus,
69
+ blobStore,
70
+ taskDs,
71
+ replayDs,
72
+ telemetry,
73
+ client,
74
+ serviceName,
75
+ topicPrefix,
76
+ consumerGroup,
77
+ visibilityTimeoutMs,
78
+ visibilityTimeoutByType,
79
+ blockTimeoutMs,
80
+ retryBackoffMs,
81
+ idempotencyTtlSec,
82
+ schedulerIntervalMs,
83
+ concurrencyLimits,
84
+ },
85
+ { reconcilerIntervalMs, sweepIntervalMs },
86
+ )
87
+ // eslint-disable-next-line furystack/prefer-using-wrapper -- delegated to onDispose
88
+ onDispose(() => runner[Symbol.dispose]())
89
+ return runner
90
+ }
91
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export { RedisQueueAdapter } from './redis-queue-adapter.js'
2
+ export type { RedisQueueAdapterOptions } from './redis-queue-adapter.js'
3
+
4
+ export { RedisTaskRunner } from './redis-task-runner.js'
5
+ export type { RedisTaskRunnerOptions, RedisTaskRunnerDeps } from './redis-task-runner.js'
6
+
7
+ export { defineRedisTaskRunner } from './define-redis-task-runner.js'
8
+ export type { DefineRedisTaskRunnerOptions } from './define-redis-task-runner.js'