@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.
- package/README.md +128 -0
- package/esm/cross-process-smoke.spec.d.ts +2 -0
- package/esm/cross-process-smoke.spec.d.ts.map +1 -0
- package/esm/cross-process-smoke.spec.js +463 -0
- package/esm/cross-process-smoke.spec.js.map +1 -0
- package/esm/define-redis-task-runner.d.ts +38 -0
- package/esm/define-redis-task-runner.d.ts.map +1 -0
- package/esm/define-redis-task-runner.js +60 -0
- package/esm/define-redis-task-runner.js.map +1 -0
- package/esm/index.d.ts +7 -0
- package/esm/index.d.ts.map +1 -0
- package/esm/index.js +4 -0
- package/esm/index.js.map +1 -0
- package/esm/multi-service-smoke.spec.d.ts +2 -0
- package/esm/multi-service-smoke.spec.d.ts.map +1 -0
- package/esm/multi-service-smoke.spec.js +468 -0
- package/esm/multi-service-smoke.spec.js.map +1 -0
- package/esm/redis-queue-adapter.d.ts +125 -0
- package/esm/redis-queue-adapter.d.ts.map +1 -0
- package/esm/redis-queue-adapter.js +566 -0
- package/esm/redis-queue-adapter.js.map +1 -0
- package/esm/redis-queue-adapter.unit.spec.d.ts +2 -0
- package/esm/redis-queue-adapter.unit.spec.d.ts.map +1 -0
- package/esm/redis-queue-adapter.unit.spec.js +860 -0
- package/esm/redis-queue-adapter.unit.spec.js.map +1 -0
- package/esm/redis-task-runner.d.ts +33 -0
- package/esm/redis-task-runner.d.ts.map +1 -0
- package/esm/redis-task-runner.integration.spec.d.ts +2 -0
- package/esm/redis-task-runner.integration.spec.d.ts.map +1 -0
- package/esm/redis-task-runner.integration.spec.js +536 -0
- package/esm/redis-task-runner.integration.spec.js.map +1 -0
- package/esm/redis-task-runner.js +43 -0
- package/esm/redis-task-runner.js.map +1 -0
- package/esm/redis-task-runner.unit.spec.d.ts +2 -0
- package/esm/redis-task-runner.unit.spec.d.ts.map +1 -0
- package/esm/redis-task-runner.unit.spec.js +97 -0
- package/esm/redis-task-runner.unit.spec.js.map +1 -0
- package/package.json +63 -0
- package/src/cross-process-smoke.spec.ts +459 -0
- package/src/define-redis-task-runner.ts +91 -0
- package/src/index.ts +8 -0
- package/src/multi-service-smoke.spec.ts +511 -0
- package/src/redis-queue-adapter.ts +733 -0
- package/src/redis-queue-adapter.unit.spec.ts +613 -0
- package/src/redis-task-runner.integration.spec.ts +440 -0
- package/src/redis-task-runner.ts +67 -0
- 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
|
+
})
|