@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,860 @@
1
+ var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
2
+ if (value !== null && value !== void 0) {
3
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
4
+ var dispose, inner;
5
+ if (async) {
6
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
7
+ dispose = value[Symbol.asyncDispose];
8
+ }
9
+ if (dispose === void 0) {
10
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
11
+ dispose = value[Symbol.dispose];
12
+ if (async) inner = dispose;
13
+ }
14
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
15
+ if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
16
+ env.stack.push({ value: value, dispose: dispose, async: async });
17
+ }
18
+ else if (async) {
19
+ env.stack.push({ async: true });
20
+ }
21
+ return value;
22
+ };
23
+ var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
24
+ return function (env) {
25
+ function fail(e) {
26
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
27
+ env.hasError = true;
28
+ }
29
+ var r, s = 0;
30
+ function next() {
31
+ while (r = env.stack.pop()) {
32
+ try {
33
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
34
+ if (r.dispose) {
35
+ var result = r.dispose.call(r.value);
36
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
37
+ }
38
+ else s |= 1;
39
+ }
40
+ catch (e) {
41
+ fail(e);
42
+ }
43
+ }
44
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
45
+ if (env.hasError) throw env.error;
46
+ }
47
+ return next();
48
+ };
49
+ })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
50
+ var e = new Error(message);
51
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
+ });
53
+ import { describe, expect, it, vi } from 'vitest';
54
+ import { RedisQueueAdapter } from './redis-queue-adapter.js';
55
+ /**
56
+ * Default mocks for the redis client. `xReadGroup` simulates BLOCK
57
+ * by awaiting `opts.BLOCK` ms before resolving — without this the
58
+ * slot loop spins as fast as possible and `mock.calls` accumulates
59
+ * unbounded (OOMs vitest).
60
+ */
61
+ const buildClient = (overrides = {}) => {
62
+ const client = {
63
+ xAdd: vi.fn().mockResolvedValue('1-0'),
64
+ xGroupCreate: vi.fn().mockResolvedValue('OK'),
65
+ xReadGroup: vi.fn().mockImplementation(async (_g, _c, _streams, opts) => {
66
+ if (opts?.BLOCK)
67
+ await new Promise((r) => setTimeout(r, opts.BLOCK));
68
+ return null;
69
+ }),
70
+ xAutoClaim: vi.fn().mockImplementation(async () => {
71
+ await new Promise((r) => setTimeout(r, 1));
72
+ return { nextId: '0-0', messages: [] };
73
+ }),
74
+ xAck: vi.fn().mockResolvedValue(1),
75
+ xClaimJustId: vi.fn().mockResolvedValue([]),
76
+ set: vi.fn().mockResolvedValue('OK'),
77
+ get: vi.fn().mockResolvedValue(null),
78
+ zAdd: vi.fn().mockResolvedValue(1),
79
+ zRem: vi.fn().mockResolvedValue(1),
80
+ eval: vi.fn().mockResolvedValue(0),
81
+ duplicate: vi.fn(),
82
+ connect: vi.fn(),
83
+ destroy: vi.fn(),
84
+ isOpen: true,
85
+ ...overrides,
86
+ };
87
+ // The adapter duplicates the client for its blocking read loop. Return the
88
+ // same mock so blocking-read / reclaim assertions still observe the calls.
89
+ client.duplicate = vi.fn(() => client);
90
+ client.connect = vi.fn().mockResolvedValue(client);
91
+ return client;
92
+ };
93
+ const makeAdapter = (client, prefix = 'svc-a/', schedulerIntervalMs = 1_000_000, concurrencyLimits) => {
94
+ return new RedisQueueAdapter({
95
+ client: client,
96
+ serviceName: 'svc-a',
97
+ topicPrefix: prefix,
98
+ blockTimeoutMs: 5,
99
+ retryBackoffMs: 5,
100
+ // Disable the auto-tick by default — tests that exercise the
101
+ // scheduler trigger it manually via internal test hooks. A huge
102
+ // value keeps `setInterval` cheap without rewriting timer plumbing.
103
+ schedulerIntervalMs,
104
+ concurrencyLimits,
105
+ });
106
+ };
107
+ describe('RedisQueueAdapter', () => {
108
+ describe('capabilities', () => {
109
+ it('declares persistent + distributed + brokerSideReclaim + delayedDispatch', () => {
110
+ const env_1 = { stack: [], error: void 0, hasError: false };
111
+ try {
112
+ const adapter = __addDisposableResource(env_1, makeAdapter(buildClient()), false);
113
+ expect(adapter.capabilities.persistent).toBe(true);
114
+ expect(adapter.capabilities.distributed).toBe(true);
115
+ expect(adapter.capabilities.brokerSideReclaim).toBe(true);
116
+ expect(adapter.capabilities.delayedDispatch).toBe(true);
117
+ }
118
+ catch (e_1) {
119
+ env_1.error = e_1;
120
+ env_1.hasError = true;
121
+ }
122
+ finally {
123
+ __disposeResources(env_1);
124
+ }
125
+ });
126
+ it('declares fleetCapEnforcement support', () => {
127
+ const env_2 = { stack: [], error: void 0, hasError: false };
128
+ try {
129
+ const adapter = __addDisposableResource(env_2, makeAdapter(buildClient()), false);
130
+ expect(adapter.capabilities.fleetCapEnforcement).toBe(true);
131
+ }
132
+ catch (e_2) {
133
+ env_2.error = e_2;
134
+ env_2.hasError = true;
135
+ }
136
+ finally {
137
+ __disposeResources(env_2);
138
+ }
139
+ });
140
+ });
141
+ describe('fleet cap', () => {
142
+ it('admits a claim via the cap script and releases the slot after onClaim', async () => {
143
+ const env_3 = { stack: [], error: void 0, hasError: false };
144
+ try {
145
+ const onClaim = vi.fn().mockResolvedValue({ kind: 'success' });
146
+ const xReadGroup = oneShotXReadGroup({
147
+ stream: 'svc-a/tasks:queue:echo:v1',
148
+ message: { id: '1-0', fields: { taskId: 't1', type: 'echo', handlerVersion: '1' } },
149
+ });
150
+ // eval resolves 1 → admitted.
151
+ const client = buildClient({ xReadGroup, eval: vi.fn().mockResolvedValue(1) });
152
+ const adapter = __addDisposableResource(env_3, makeAdapter(client, 'svc-a/', 1_000_000, { echo: 2 }), false);
153
+ const sub = __addDisposableResource(env_3, adapter.subscribe({
154
+ workerId: 'w-1',
155
+ concurrency: 1,
156
+ tags: [],
157
+ types: ['echo'],
158
+ compatibleVersions: { echo: [1] },
159
+ shouldDrain: () => false,
160
+ onClaim,
161
+ }), false);
162
+ void sub;
163
+ await waitFor(() => onClaim.mock.calls.length > 0, 1000);
164
+ const evalCall = client.eval.mock.calls[0];
165
+ expect(evalCall[1].keys).toEqual(['svc-a/tasks:cap:echo']);
166
+ // ARGV[2] is the cap.
167
+ expect(evalCall[1].arguments[1]).toBe('2');
168
+ await waitFor(() => client.zRem.mock.calls.length > 0, 1000);
169
+ expect(client.zRem).toHaveBeenCalledWith('svc-a/tasks:cap:echo', 'w-1-slot-0:1-0');
170
+ }
171
+ catch (e_3) {
172
+ env_3.error = e_3;
173
+ env_3.hasError = true;
174
+ }
175
+ finally {
176
+ __disposeResources(env_3);
177
+ }
178
+ });
179
+ it('releases the entry back to the stream without running onClaim when the lane is full', async () => {
180
+ const env_4 = { stack: [], error: void 0, hasError: false };
181
+ try {
182
+ const onClaim = vi.fn().mockResolvedValue({ kind: 'success' });
183
+ const xReadGroup = oneShotXReadGroup({
184
+ stream: 'svc-a/tasks:queue:echo:v1',
185
+ message: { id: '1-0', fields: { taskId: 't1', type: 'echo', handlerVersion: '1' } },
186
+ });
187
+ // eval resolves 0 → lane full.
188
+ const client = buildClient({ xReadGroup, eval: vi.fn().mockResolvedValue(0) });
189
+ const adapter = __addDisposableResource(env_4, makeAdapter(client, 'svc-a/', 1_000_000, { echo: 1 }), false);
190
+ const sub = __addDisposableResource(env_4, adapter.subscribe({
191
+ workerId: 'w-1',
192
+ concurrency: 1,
193
+ tags: [],
194
+ types: ['echo'],
195
+ compatibleVersions: { echo: [1] },
196
+ shouldDrain: () => false,
197
+ onClaim,
198
+ }), false);
199
+ void sub;
200
+ await waitFor(() => client.xAdd.mock.calls.length > 0, 1000);
201
+ expect(onClaim).not.toHaveBeenCalled();
202
+ expect(client.xAck).toHaveBeenCalledWith('svc-a/tasks:queue:echo:v1', 'runner', '1-0');
203
+ expect(client.xAdd).toHaveBeenCalledWith('svc-a/tasks:queue:echo:v1', '*', {
204
+ taskId: 't1',
205
+ type: 'echo',
206
+ handlerVersion: '1',
207
+ });
208
+ // Slot never admitted → nothing to release.
209
+ expect(client.zRem).not.toHaveBeenCalled();
210
+ }
211
+ catch (e_4) {
212
+ env_4.error = e_4;
213
+ env_4.hasError = true;
214
+ }
215
+ finally {
216
+ __disposeResources(env_4);
217
+ }
218
+ });
219
+ it('does not consult the cap script for uncapped types', async () => {
220
+ const env_5 = { stack: [], error: void 0, hasError: false };
221
+ try {
222
+ const onClaim = vi.fn().mockResolvedValue({ kind: 'success' });
223
+ const xReadGroup = oneShotXReadGroup({
224
+ stream: 'svc-a/tasks:queue:echo:v1',
225
+ message: { id: '1-0', fields: { taskId: 't1', type: 'echo', handlerVersion: '1' } },
226
+ });
227
+ const client = buildClient({ xReadGroup });
228
+ const adapter = __addDisposableResource(env_5, makeAdapter(client, 'svc-a/', 1_000_000), false);
229
+ const sub = __addDisposableResource(env_5, adapter.subscribe({
230
+ workerId: 'w-1',
231
+ concurrency: 1,
232
+ tags: [],
233
+ types: ['echo'],
234
+ compatibleVersions: { echo: [1] },
235
+ shouldDrain: () => false,
236
+ onClaim,
237
+ }), false);
238
+ void sub;
239
+ await waitFor(() => onClaim.mock.calls.length > 0, 1000);
240
+ expect(client.eval).not.toHaveBeenCalled();
241
+ expect(client.zRem).not.toHaveBeenCalled();
242
+ }
243
+ catch (e_5) {
244
+ env_5.error = e_5;
245
+ env_5.hasError = true;
246
+ }
247
+ finally {
248
+ __disposeResources(env_5);
249
+ }
250
+ });
251
+ it('refreshes the holder lease on heartbeat for capped types', async () => {
252
+ const env_6 = { stack: [], error: void 0, hasError: false };
253
+ try {
254
+ const client = buildClient();
255
+ const adapter = __addDisposableResource(env_6, makeAdapter(client, 'svc-a/', 1_000_000, { echo: 2 }), false);
256
+ await adapter.heartbeat({
257
+ taskId: 't1',
258
+ type: 'echo',
259
+ receipt: { stream: 'svc-a/tasks:queue:echo:v1', msgId: '1-0', consumer: 'w-0', deliveryCount: 0 },
260
+ });
261
+ expect(client.zAdd).toHaveBeenCalledWith('svc-a/tasks:cap:echo', expect.objectContaining({ value: 'w-0:1-0' }), {
262
+ XX: true,
263
+ });
264
+ }
265
+ catch (e_6) {
266
+ env_6.error = e_6;
267
+ env_6.hasError = true;
268
+ }
269
+ finally {
270
+ __disposeResources(env_6);
271
+ }
272
+ });
273
+ it('does not refresh a holder lease on heartbeat for uncapped types', async () => {
274
+ const env_7 = { stack: [], error: void 0, hasError: false };
275
+ try {
276
+ const client = buildClient();
277
+ const adapter = __addDisposableResource(env_7, makeAdapter(client), false);
278
+ await adapter.heartbeat({
279
+ taskId: 't1',
280
+ type: 'echo',
281
+ receipt: { stream: 'svc-a/tasks:queue:echo:v1', msgId: '1-0', consumer: 'w-0', deliveryCount: 0 },
282
+ });
283
+ expect(client.zAdd).not.toHaveBeenCalled();
284
+ }
285
+ catch (e_7) {
286
+ env_7.error = e_7;
287
+ env_7.hasError = true;
288
+ }
289
+ finally {
290
+ __disposeResources(env_7);
291
+ }
292
+ });
293
+ });
294
+ describe('enqueue', () => {
295
+ it('writes to per-(type,version) stream with the documented field shape', async () => {
296
+ const env_8 = { stack: [], error: void 0, hasError: false };
297
+ try {
298
+ const client = buildClient();
299
+ const adapter = __addDisposableResource(env_8, makeAdapter(client), false);
300
+ await adapter.enqueue({ taskId: 'task-1', type: 'video-encode', handlerVersion: 2 });
301
+ expect(client.xGroupCreate).toHaveBeenCalledWith('svc-a/tasks:queue:video-encode:v2', 'runner', '$', {
302
+ MKSTREAM: true,
303
+ });
304
+ expect(client.xAdd).toHaveBeenCalledWith('svc-a/tasks:queue:video-encode:v2', '*', {
305
+ taskId: 'task-1',
306
+ type: 'video-encode',
307
+ handlerVersion: '2',
308
+ });
309
+ }
310
+ catch (e_8) {
311
+ env_8.error = e_8;
312
+ env_8.hasError = true;
313
+ }
314
+ finally {
315
+ __disposeResources(env_8);
316
+ }
317
+ });
318
+ it('caches consumer-group creation per stream', async () => {
319
+ const env_9 = { stack: [], error: void 0, hasError: false };
320
+ try {
321
+ const client = buildClient();
322
+ const adapter = __addDisposableResource(env_9, makeAdapter(client), false);
323
+ await adapter.enqueue({ taskId: 't1', type: 'echo', handlerVersion: 1 });
324
+ await adapter.enqueue({ taskId: 't2', type: 'echo', handlerVersion: 1 });
325
+ expect(client.xGroupCreate).toHaveBeenCalledTimes(1);
326
+ expect(client.xAdd).toHaveBeenCalledTimes(2);
327
+ }
328
+ catch (e_9) {
329
+ env_9.error = e_9;
330
+ env_9.hasError = true;
331
+ }
332
+ finally {
333
+ __disposeResources(env_9);
334
+ }
335
+ });
336
+ it('tolerates BUSYGROUP errors when the consumer group already exists', async () => {
337
+ const env_10 = { stack: [], error: void 0, hasError: false };
338
+ try {
339
+ const client = buildClient({
340
+ xGroupCreate: vi.fn().mockRejectedValue(new Error('BUSYGROUP Consumer Group name already exists')),
341
+ });
342
+ const adapter = __addDisposableResource(env_10, makeAdapter(client), false);
343
+ await expect(adapter.enqueue({ taskId: 't1', type: 'echo', handlerVersion: 1 })).resolves.toBeUndefined();
344
+ expect(client.xAdd).toHaveBeenCalledOnce();
345
+ }
346
+ catch (e_10) {
347
+ env_10.error = e_10;
348
+ env_10.hasError = true;
349
+ }
350
+ finally {
351
+ __disposeResources(env_10);
352
+ }
353
+ });
354
+ });
355
+ describe('delayed dispatch', () => {
356
+ it('ZADDs to the scheduler ZSET when notBefore is in the future', async () => {
357
+ const env_11 = { stack: [], error: void 0, hasError: false };
358
+ try {
359
+ const client = buildClient();
360
+ const adapter = __addDisposableResource(env_11, makeAdapter(client), false);
361
+ const future = new Date(Date.now() + 60_000);
362
+ await adapter.enqueue({ taskId: 't1', type: 'echo', handlerVersion: 1, notBefore: future });
363
+ expect(client.xAdd).not.toHaveBeenCalled();
364
+ expect(client.zAdd).toHaveBeenCalledWith('svc-a/tasks:scheduler', {
365
+ score: future.getTime(),
366
+ value: JSON.stringify({ taskId: 't1', type: 'echo', handlerVersion: 1 }),
367
+ });
368
+ }
369
+ catch (e_11) {
370
+ env_11.error = e_11;
371
+ env_11.hasError = true;
372
+ }
373
+ finally {
374
+ __disposeResources(env_11);
375
+ }
376
+ });
377
+ it('XADDs immediately when notBefore is in the past', async () => {
378
+ const env_12 = { stack: [], error: void 0, hasError: false };
379
+ try {
380
+ const client = buildClient();
381
+ const adapter = __addDisposableResource(env_12, makeAdapter(client), false);
382
+ await adapter.enqueue({
383
+ taskId: 't1',
384
+ type: 'echo',
385
+ handlerVersion: 1,
386
+ notBefore: new Date(Date.now() - 1000),
387
+ });
388
+ expect(client.zAdd).not.toHaveBeenCalled();
389
+ expect(client.xAdd).toHaveBeenCalledOnce();
390
+ }
391
+ catch (e_12) {
392
+ env_12.error = e_12;
393
+ env_12.hasError = true;
394
+ }
395
+ finally {
396
+ __disposeResources(env_12);
397
+ }
398
+ });
399
+ it('eagerly creates the consumer group on the destination stream so scheduler XADDs are not lost', async () => {
400
+ const env_13 = { stack: [], error: void 0, hasError: false };
401
+ try {
402
+ const client = buildClient();
403
+ const adapter = __addDisposableResource(env_13, makeAdapter(client), false);
404
+ await adapter.enqueue({
405
+ taskId: 't1',
406
+ type: 'echo',
407
+ handlerVersion: 1,
408
+ notBefore: new Date(Date.now() + 60_000),
409
+ });
410
+ expect(client.xGroupCreate).toHaveBeenCalledWith('svc-a/tasks:queue:echo:v1', 'runner', '$', {
411
+ MKSTREAM: true,
412
+ });
413
+ }
414
+ catch (e_13) {
415
+ env_13.error = e_13;
416
+ env_13.hasError = true;
417
+ }
418
+ finally {
419
+ __disposeResources(env_13);
420
+ }
421
+ });
422
+ it('runs the scheduler tick on the configured interval and EVALs the dispatch script', async () => {
423
+ const env_14 = { stack: [], error: void 0, hasError: false };
424
+ try {
425
+ const client = buildClient();
426
+ const adapter = __addDisposableResource(env_14, makeAdapter(client, 'svc-a/', 20)
427
+ // Block until at least one tick fired against the broker.
428
+ , false);
429
+ // Block until at least one tick fired against the broker.
430
+ await waitFor(() => client.eval.mock.calls.length > 0, 500);
431
+ const firstCall = client.eval.mock.calls[0];
432
+ const opts = firstCall[1];
433
+ expect(opts.keys).toEqual(['svc-a/tasks:scheduler']);
434
+ expect(opts.arguments[2]).toBe('svc-a/');
435
+ // ARGV[1] is `now` epoch ms; sanity-check it parses.
436
+ expect(Number.isFinite(Number(opts.arguments[0]))).toBe(true);
437
+ // ARGV[2] is the batch limit — must be a positive integer.
438
+ expect(Number(opts.arguments[1])).toBeGreaterThan(0);
439
+ void adapter;
440
+ }
441
+ catch (e_14) {
442
+ env_14.error = e_14;
443
+ env_14.hasError = true;
444
+ }
445
+ finally {
446
+ __disposeResources(env_14);
447
+ }
448
+ });
449
+ it('swallows EVAL errors so a transient broker failure does not poison the timer', async () => {
450
+ const env_15 = { stack: [], error: void 0, hasError: false };
451
+ try {
452
+ const errors = [];
453
+ const client = buildClient({
454
+ eval: vi.fn().mockImplementation(() => {
455
+ const error = new Error('LOADING Redis is starting');
456
+ errors.push(error);
457
+ throw error;
458
+ }),
459
+ });
460
+ const adapter = __addDisposableResource(env_15, makeAdapter(client, 'svc-a/', 10), false);
461
+ await waitFor(() => errors.length >= 2, 500);
462
+ void adapter;
463
+ }
464
+ catch (e_15) {
465
+ env_15.error = e_15;
466
+ env_15.hasError = true;
467
+ }
468
+ finally {
469
+ __disposeResources(env_15);
470
+ }
471
+ });
472
+ });
473
+ describe('acquireIdempotencyLease', () => {
474
+ it('returns own taskId when SET NX wins the race', async () => {
475
+ const env_16 = { stack: [], error: void 0, hasError: false };
476
+ try {
477
+ const client = buildClient({ set: vi.fn().mockResolvedValue('OK') });
478
+ const adapter = __addDisposableResource(env_16, makeAdapter(client), false);
479
+ const winner = await adapter.acquireIdempotencyLease({ type: 'echo', key: 'nightly', taskId: 'mine' });
480
+ expect(winner).toBe('mine');
481
+ expect(client.set).toHaveBeenCalledWith('svc-a/tasks:idem:echo:nightly', 'mine', {
482
+ NX: true,
483
+ EX: 86_400,
484
+ });
485
+ }
486
+ catch (e_16) {
487
+ env_16.error = e_16;
488
+ env_16.hasError = true;
489
+ }
490
+ finally {
491
+ __disposeResources(env_16);
492
+ }
493
+ });
494
+ it('returns the stored taskId when SET NX loses', async () => {
495
+ const env_17 = { stack: [], error: void 0, hasError: false };
496
+ try {
497
+ const client = buildClient({
498
+ set: vi.fn().mockResolvedValue(null),
499
+ get: vi.fn().mockResolvedValue('previous'),
500
+ });
501
+ const adapter = __addDisposableResource(env_17, makeAdapter(client), false);
502
+ const winner = await adapter.acquireIdempotencyLease({ type: 'echo', key: 'nightly', taskId: 'mine' });
503
+ expect(winner).toBe('previous');
504
+ }
505
+ catch (e_17) {
506
+ env_17.error = e_17;
507
+ env_17.hasError = true;
508
+ }
509
+ finally {
510
+ __disposeResources(env_17);
511
+ }
512
+ });
513
+ it('falls back to own taskId when SET loses but GET sees no value (TTL expired between calls)', async () => {
514
+ const env_18 = { stack: [], error: void 0, hasError: false };
515
+ try {
516
+ const client = buildClient({
517
+ set: vi.fn().mockResolvedValue(null),
518
+ get: vi.fn().mockResolvedValue(null),
519
+ });
520
+ const adapter = __addDisposableResource(env_18, makeAdapter(client), false);
521
+ const winner = await adapter.acquireIdempotencyLease({ type: 'echo', key: 'nightly', taskId: 'mine' });
522
+ expect(winner).toBe('mine');
523
+ }
524
+ catch (e_18) {
525
+ env_18.error = e_18;
526
+ env_18.hasError = true;
527
+ }
528
+ finally {
529
+ __disposeResources(env_18);
530
+ }
531
+ });
532
+ });
533
+ describe('heartbeat', () => {
534
+ it('XCLAIMs the message id with idle=0 to refresh PEL idle counter', async () => {
535
+ const env_19 = { stack: [], error: void 0, hasError: false };
536
+ try {
537
+ const client = buildClient();
538
+ const adapter = __addDisposableResource(env_19, makeAdapter(client), false);
539
+ await adapter.heartbeat({
540
+ taskId: 't1',
541
+ type: 'echo',
542
+ receipt: { stream: 'svc-a/tasks:queue:echo:v1', msgId: '1-0', consumer: 'w-0', deliveryCount: 0 },
543
+ });
544
+ expect(client.xClaimJustId).toHaveBeenCalledWith('svc-a/tasks:queue:echo:v1', 'runner', 'w-0', 0, ['1-0']);
545
+ }
546
+ catch (e_19) {
547
+ env_19.error = e_19;
548
+ env_19.hasError = true;
549
+ }
550
+ finally {
551
+ __disposeResources(env_19);
552
+ }
553
+ });
554
+ it('swallows broker errors so transient failures do not bubble into handler land', async () => {
555
+ const env_20 = { stack: [], error: void 0, hasError: false };
556
+ try {
557
+ const client = buildClient({
558
+ xClaimJustId: vi.fn().mockRejectedValue(new Error('connection lost')),
559
+ });
560
+ const adapter = __addDisposableResource(env_20, makeAdapter(client), false);
561
+ await expect(adapter.heartbeat({
562
+ taskId: 't1',
563
+ type: 'echo',
564
+ receipt: { stream: 'svc-a/tasks:queue:echo:v1', msgId: '1-0', consumer: 'w-0', deliveryCount: 0 },
565
+ })).resolves.toBeUndefined();
566
+ }
567
+ catch (e_20) {
568
+ env_20.error = e_20;
569
+ env_20.hasError = true;
570
+ }
571
+ finally {
572
+ __disposeResources(env_20);
573
+ }
574
+ });
575
+ });
576
+ describe('subscribe — claim loop', () => {
577
+ it('reads from per-(type,version) streams matching compatibleVersions', async () => {
578
+ const env_21 = { stack: [], error: void 0, hasError: false };
579
+ try {
580
+ const seenStreams = [];
581
+ const client = buildClient({
582
+ xReadGroup: vi.fn().mockImplementation((_g, _c, streams) => {
583
+ seenStreams.push(streams.map((s) => s.key));
584
+ return Promise.resolve(null);
585
+ }),
586
+ });
587
+ const adapter = __addDisposableResource(env_21, makeAdapter(client), false);
588
+ const onClaim = vi.fn().mockResolvedValue({ kind: 'success' });
589
+ const sub = __addDisposableResource(env_21, adapter.subscribe({
590
+ workerId: 'w-1',
591
+ concurrency: 1,
592
+ tags: [],
593
+ types: ['video-encode'],
594
+ compatibleVersions: { 'video-encode': [1, 2] },
595
+ shouldDrain: () => false,
596
+ onClaim,
597
+ }), false);
598
+ void sub;
599
+ await waitFor(() => seenStreams.length > 0, 500);
600
+ expect(seenStreams[0]?.sort()).toEqual(['svc-a/tasks:queue:video-encode:v1', 'svc-a/tasks:queue:video-encode:v2']);
601
+ }
602
+ catch (e_21) {
603
+ env_21.error = e_21;
604
+ env_21.hasError = true;
605
+ }
606
+ finally {
607
+ __disposeResources(env_21);
608
+ }
609
+ });
610
+ it('defaults to v1 stream when compatibleVersions for a type is missing', async () => {
611
+ const env_22 = { stack: [], error: void 0, hasError: false };
612
+ try {
613
+ const seenStreams = [];
614
+ const client = buildClient({
615
+ xReadGroup: vi.fn().mockImplementation((_g, _c, streams) => {
616
+ seenStreams.push(streams.map((s) => s.key));
617
+ return Promise.resolve(null);
618
+ }),
619
+ });
620
+ const adapter = __addDisposableResource(env_22, makeAdapter(client), false);
621
+ const sub = __addDisposableResource(env_22, adapter.subscribe({
622
+ workerId: 'w-1',
623
+ concurrency: 1,
624
+ tags: [],
625
+ types: ['echo'],
626
+ compatibleVersions: {},
627
+ shouldDrain: () => false,
628
+ onClaim: vi.fn().mockResolvedValue({ kind: 'success' }),
629
+ }), false);
630
+ void sub;
631
+ await waitFor(() => seenStreams.length > 0, 500);
632
+ expect(seenStreams[0]).toEqual(['svc-a/tasks:queue:echo:v1']);
633
+ }
634
+ catch (e_22) {
635
+ env_22.error = e_22;
636
+ env_22.hasError = true;
637
+ }
638
+ finally {
639
+ __disposeResources(env_22);
640
+ }
641
+ });
642
+ it('delivers a claim and ACKs on success', async () => {
643
+ const env_23 = { stack: [], error: void 0, hasError: false };
644
+ try {
645
+ const claims = [];
646
+ const onClaim = vi.fn().mockImplementation((claim) => {
647
+ claims.push({ taskId: claim.taskId, type: claim.type });
648
+ return Promise.resolve({ kind: 'success' });
649
+ });
650
+ const xReadGroup = oneShotXReadGroup({
651
+ stream: 'svc-a/tasks:queue:echo:v1',
652
+ message: { id: '1-0', fields: { taskId: 't1', type: 'echo', handlerVersion: '1' } },
653
+ });
654
+ const client = buildClient({ xReadGroup });
655
+ const adapter = __addDisposableResource(env_23, makeAdapter(client), false);
656
+ const sub = __addDisposableResource(env_23, adapter.subscribe({
657
+ workerId: 'w-1',
658
+ concurrency: 1,
659
+ tags: [],
660
+ types: ['echo'],
661
+ compatibleVersions: { echo: [1] },
662
+ shouldDrain: () => false,
663
+ onClaim,
664
+ }), false);
665
+ void sub;
666
+ await waitFor(() => claims.length > 0, 1000);
667
+ expect(claims[0]).toEqual({ taskId: 't1', type: 'echo' });
668
+ await waitFor(() => client.xAck.mock.calls.length > 0, 1000);
669
+ expect(client.xAck).toHaveBeenCalledWith('svc-a/tasks:queue:echo:v1', 'runner', '1-0');
670
+ }
671
+ catch (e_23) {
672
+ env_23.error = e_23;
673
+ env_23.hasError = true;
674
+ }
675
+ finally {
676
+ __disposeResources(env_23);
677
+ }
678
+ });
679
+ it('ACKs the original entry and re-XADDs when onClaim returns requeue', async () => {
680
+ const env_24 = { stack: [], error: void 0, hasError: false };
681
+ try {
682
+ const xReadGroup = oneShotXReadGroup({
683
+ stream: 'svc-a/tasks:queue:echo:v1',
684
+ message: { id: '1-0', fields: { taskId: 't1', type: 'echo', handlerVersion: '1' } },
685
+ });
686
+ const client = buildClient({ xReadGroup });
687
+ const adapter = __addDisposableResource(env_24, makeAdapter(client), false);
688
+ const sub = __addDisposableResource(env_24, adapter.subscribe({
689
+ workerId: 'w-1',
690
+ concurrency: 1,
691
+ tags: [],
692
+ types: ['echo'],
693
+ compatibleVersions: { echo: [1] },
694
+ shouldDrain: () => false,
695
+ onClaim: vi.fn().mockResolvedValue({ kind: 'requeue' }),
696
+ }), false);
697
+ void sub;
698
+ await waitFor(() => client.xAck.mock.calls.length > 0, 1000);
699
+ await waitFor(() => client.xAdd.mock.calls.length > 0, 1000);
700
+ expect(client.xAck).toHaveBeenCalledWith('svc-a/tasks:queue:echo:v1', 'runner', '1-0');
701
+ expect(client.xAdd).toHaveBeenCalledWith('svc-a/tasks:queue:echo:v1', '*', {
702
+ taskId: 't1',
703
+ type: 'echo',
704
+ handlerVersion: '1',
705
+ });
706
+ }
707
+ catch (e_24) {
708
+ env_24.error = e_24;
709
+ env_24.hasError = true;
710
+ }
711
+ finally {
712
+ __disposeResources(env_24);
713
+ }
714
+ });
715
+ it('runs XAUTOCLAIM and delivers the reclaimed entry through onClaim', async () => {
716
+ const env_25 = { stack: [], error: void 0, hasError: false };
717
+ try {
718
+ const claims = [];
719
+ let autoClaimCall = 0;
720
+ const xAutoClaim = vi.fn().mockImplementation(async () => {
721
+ autoClaimCall += 1;
722
+ await new Promise((r) => setTimeout(r, 1));
723
+ if (autoClaimCall === 1) {
724
+ return {
725
+ nextId: '2-0',
726
+ messages: [{ id: '1-0', message: { taskId: 't-stale', type: 'echo', handlerVersion: '1' } }],
727
+ };
728
+ }
729
+ return { nextId: '0-0', messages: [] };
730
+ });
731
+ const client = buildClient({ xAutoClaim });
732
+ const adapter = __addDisposableResource(env_25, makeAdapter(client), false);
733
+ const sub = __addDisposableResource(env_25, adapter.subscribe({
734
+ workerId: 'w-1',
735
+ concurrency: 1,
736
+ tags: [],
737
+ types: ['echo'],
738
+ compatibleVersions: { echo: [1] },
739
+ shouldDrain: () => false,
740
+ onClaim: vi.fn().mockImplementation((claim) => {
741
+ claims.push({ taskId: claim.taskId, type: claim.type });
742
+ return Promise.resolve({ kind: 'success' });
743
+ }),
744
+ }), false);
745
+ void sub;
746
+ await waitFor(() => claims.length > 0, 1000);
747
+ expect(claims[0]).toEqual({ taskId: 't-stale', type: 'echo' });
748
+ }
749
+ catch (e_25) {
750
+ env_25.error = e_25;
751
+ env_25.hasError = true;
752
+ }
753
+ finally {
754
+ __disposeResources(env_25);
755
+ }
756
+ });
757
+ it('drops malformed entries (missing taskId) without invoking onClaim', async () => {
758
+ const env_26 = { stack: [], error: void 0, hasError: false };
759
+ try {
760
+ const onClaim = vi.fn();
761
+ const xReadGroup = oneShotXReadGroup({
762
+ stream: 'svc-a/tasks:queue:echo:v1',
763
+ message: { id: '1-0', fields: { type: 'echo' } },
764
+ });
765
+ const client = buildClient({ xReadGroup });
766
+ const adapter = __addDisposableResource(env_26, makeAdapter(client), false);
767
+ const sub = __addDisposableResource(env_26, adapter.subscribe({
768
+ workerId: 'w-1',
769
+ concurrency: 1,
770
+ tags: [],
771
+ types: ['echo'],
772
+ compatibleVersions: { echo: [1] },
773
+ shouldDrain: () => false,
774
+ onClaim,
775
+ }), false);
776
+ void sub;
777
+ await waitFor(() => client.xAck.mock.calls.length > 0, 1000);
778
+ expect(onClaim).not.toHaveBeenCalled();
779
+ expect(client.xAck).toHaveBeenCalledWith('svc-a/tasks:queue:echo:v1', 'runner', '1-0');
780
+ }
781
+ catch (e_26) {
782
+ env_26.error = e_26;
783
+ env_26.hasError = true;
784
+ }
785
+ finally {
786
+ __disposeResources(env_26);
787
+ }
788
+ });
789
+ it('stops the slot loop when subscription.shouldDrain() flips to true', async () => {
790
+ const env_27 = { stack: [], error: void 0, hasError: false };
791
+ try {
792
+ let draining = false;
793
+ const xReadGroup = vi.fn().mockResolvedValue(null);
794
+ const client = buildClient({ xReadGroup });
795
+ const adapter = __addDisposableResource(env_27, makeAdapter(client), false);
796
+ const sub = __addDisposableResource(env_27, adapter.subscribe({
797
+ workerId: 'w-1',
798
+ concurrency: 1,
799
+ tags: [],
800
+ types: ['echo'],
801
+ compatibleVersions: { echo: [1] },
802
+ shouldDrain: () => draining,
803
+ onClaim: vi.fn().mockResolvedValue({ kind: 'success' }),
804
+ }), false);
805
+ void sub;
806
+ await waitFor(() => xReadGroup.mock.calls.length > 0, 500);
807
+ const callsBefore = xReadGroup.mock.calls.length;
808
+ draining = true;
809
+ await new Promise((r) => setTimeout(r, 80));
810
+ const callsAfter = xReadGroup.mock.calls.length;
811
+ // The loop may issue one or two more reads before observing the drain
812
+ // signal between iterations; what matters is that it stops growing.
813
+ await new Promise((r) => setTimeout(r, 50));
814
+ const callsAfterSettle = xReadGroup.mock.calls.length;
815
+ expect(callsAfter).toBeLessThanOrEqual(callsBefore + 4);
816
+ expect(callsAfterSettle).toBeLessThanOrEqual(callsAfter + 1);
817
+ }
818
+ catch (e_27) {
819
+ env_27.error = e_27;
820
+ env_27.hasError = true;
821
+ }
822
+ finally {
823
+ __disposeResources(env_27);
824
+ }
825
+ });
826
+ });
827
+ });
828
+ const waitFor = async (predicate, timeoutMs) => {
829
+ const deadline = Date.now() + timeoutMs;
830
+ while (Date.now() < deadline) {
831
+ if (predicate())
832
+ return;
833
+ await new Promise((r) => setTimeout(r, 5));
834
+ }
835
+ throw new Error(`waitFor: predicate did not satisfy within ${timeoutMs}ms`);
836
+ };
837
+ /**
838
+ * Returns an `xReadGroup` mock that yields `message` exactly once on
839
+ * the first call, then returns `null` on every subsequent call (with
840
+ * the BLOCK delay simulated so the slot loop paces itself and
841
+ * `mock.calls` does not grow unbounded).
842
+ */
843
+ const oneShotXReadGroup = (delivery) => {
844
+ let delivered = false;
845
+ return vi.fn().mockImplementation(async (_g, _c, _streams, opts) => {
846
+ if (!delivered) {
847
+ delivered = true;
848
+ return [
849
+ {
850
+ name: delivery.stream,
851
+ messages: [{ id: delivery.message.id, message: delivery.message.fields }],
852
+ },
853
+ ];
854
+ }
855
+ if (opts?.BLOCK)
856
+ await new Promise((r) => setTimeout(r, opts.BLOCK));
857
+ return null;
858
+ });
859
+ };
860
+ //# sourceMappingURL=redis-queue-adapter.unit.spec.js.map