@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,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'
|