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