@nicnocquee/dataqueue 1.24.0 → 1.25.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.
@@ -0,0 +1,543 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { initJobQueue } from '../index.js';
3
+ import { createRedisTestPrefix, cleanupRedisPrefix } from '../test-util.js';
4
+ import type { RedisJobQueueConfig } from '../types.js';
5
+
6
+ interface TestPayloadMap {
7
+ email: { to: string };
8
+ sms: { to: string };
9
+ test: { foo: string };
10
+ }
11
+
12
+ const REDIS_URL = process.env.REDIS_TEST_URL || 'redis://localhost:6379';
13
+
14
+ describe('Redis backend integration', () => {
15
+ let prefix: string;
16
+ let jobQueue: ReturnType<typeof initJobQueue<TestPayloadMap>>;
17
+ let redisClient: any;
18
+
19
+ beforeEach(async () => {
20
+ prefix = createRedisTestPrefix();
21
+ const config: RedisJobQueueConfig = {
22
+ backend: 'redis',
23
+ redisConfig: {
24
+ url: REDIS_URL,
25
+ keyPrefix: prefix,
26
+ },
27
+ };
28
+ jobQueue = initJobQueue<TestPayloadMap>(config);
29
+ redisClient = jobQueue.getRedisClient();
30
+ });
31
+
32
+ afterEach(async () => {
33
+ await cleanupRedisPrefix(redisClient, prefix);
34
+ await redisClient.quit();
35
+ });
36
+
37
+ it('should add a job and retrieve it', async () => {
38
+ const jobId = await jobQueue.addJob({
39
+ jobType: 'email',
40
+ payload: { to: 'test@example.com' },
41
+ });
42
+ expect(typeof jobId).toBe('number');
43
+ const job = await jobQueue.getJob(jobId);
44
+ expect(job).not.toBeNull();
45
+ expect(job?.jobType).toBe('email');
46
+ expect(job?.payload).toEqual({ to: 'test@example.com' });
47
+ expect(job?.status).toBe('pending');
48
+ });
49
+
50
+ it('should get jobs by status', async () => {
51
+ const jobId1 = await jobQueue.addJob({
52
+ jobType: 'email',
53
+ payload: { to: 'a@example.com' },
54
+ });
55
+ const jobId2 = await jobQueue.addJob({
56
+ jobType: 'sms',
57
+ payload: { to: 'b@example.com' },
58
+ });
59
+ const jobs = await jobQueue.getJobsByStatus('pending');
60
+ const ids = jobs.map((j) => j.id);
61
+ expect(ids).toContain(jobId1);
62
+ expect(ids).toContain(jobId2);
63
+ });
64
+
65
+ it('should get all jobs', async () => {
66
+ await jobQueue.addJob({ jobType: 'email', payload: { to: 'a@b.com' } });
67
+ await jobQueue.addJob({ jobType: 'sms', payload: { to: 'c@d.com' } });
68
+ const jobs = await jobQueue.getAllJobs();
69
+ expect(jobs.length).toBe(2);
70
+ });
71
+
72
+ it('should process a job with a registered handler', async () => {
73
+ const handler = vi.fn(async (_payload: any, _signal: any) => {});
74
+ const jobId = await jobQueue.addJob({
75
+ jobType: 'test',
76
+ payload: { foo: 'bar' },
77
+ });
78
+ const processor = jobQueue.createProcessor(
79
+ {
80
+ email: vi.fn(async () => {}),
81
+ sms: vi.fn(async () => {}),
82
+ test: handler,
83
+ },
84
+ { pollInterval: 100 },
85
+ );
86
+ await processor.start();
87
+ expect(handler).toHaveBeenCalledWith(
88
+ { foo: 'bar' },
89
+ expect.any(Object),
90
+ expect.any(Object),
91
+ );
92
+ const job = await jobQueue.getJob(jobId);
93
+ expect(job?.status).toBe('completed');
94
+ });
95
+
96
+ it('should retry a failed job', async () => {
97
+ const jobId = await jobQueue.addJob({
98
+ jobType: 'email',
99
+ payload: { to: 'fail@example.com' },
100
+ });
101
+ // Use a handler that fails
102
+ const processor = jobQueue.createProcessor(
103
+ {
104
+ email: async () => {
105
+ throw new Error('boom');
106
+ },
107
+ sms: vi.fn(async () => {}),
108
+ test: vi.fn(async () => {}),
109
+ },
110
+ { pollInterval: 100 },
111
+ );
112
+ await processor.start();
113
+ let job = await jobQueue.getJob(jobId);
114
+ expect(job?.status).toBe('failed');
115
+
116
+ await jobQueue.retryJob(jobId);
117
+ job = await jobQueue.getJob(jobId);
118
+ expect(job?.status).toBe('pending');
119
+ });
120
+
121
+ it('should cancel a pending job', async () => {
122
+ const jobId = await jobQueue.addJob({
123
+ jobType: 'email',
124
+ payload: { to: 'cancelme@example.com' },
125
+ });
126
+ await jobQueue.cancelJob(jobId);
127
+ const job = await jobQueue.getJob(jobId);
128
+ expect(job?.status).toBe('cancelled');
129
+ });
130
+
131
+ it('should not cancel a non-pending job', async () => {
132
+ const jobId = await jobQueue.addJob({
133
+ jobType: 'test',
134
+ payload: { foo: 'done' },
135
+ });
136
+ // Process it first
137
+ const processor = jobQueue.createProcessor(
138
+ {
139
+ email: vi.fn(async () => {}),
140
+ sms: vi.fn(async () => {}),
141
+ test: vi.fn(async () => {}),
142
+ },
143
+ { pollInterval: 100 },
144
+ );
145
+ await processor.start();
146
+ const completedJob = await jobQueue.getJob(jobId);
147
+ expect(completedJob?.status).toBe('completed');
148
+
149
+ await jobQueue.cancelJob(jobId);
150
+ const job = await jobQueue.getJob(jobId);
151
+ expect(job?.status).toBe('completed'); // unchanged
152
+ });
153
+
154
+ it('should cancel all upcoming jobs', async () => {
155
+ const jobId1 = await jobQueue.addJob({
156
+ jobType: 'email',
157
+ payload: { to: 'a@example.com' },
158
+ });
159
+ const jobId2 = await jobQueue.addJob({
160
+ jobType: 'email',
161
+ payload: { to: 'b@example.com' },
162
+ });
163
+ const cancelled = await jobQueue.cancelAllUpcomingJobs();
164
+ expect(cancelled).toBe(2);
165
+ const job1 = await jobQueue.getJob(jobId1);
166
+ const job2 = await jobQueue.getJob(jobId2);
167
+ expect(job1?.status).toBe('cancelled');
168
+ expect(job2?.status).toBe('cancelled');
169
+ });
170
+
171
+ it('should cancel all upcoming jobs by jobType', async () => {
172
+ const jobId1 = await jobQueue.addJob({
173
+ jobType: 'email',
174
+ payload: { to: 'a@example.com' },
175
+ });
176
+ const jobId2 = await jobQueue.addJob({
177
+ jobType: 'sms',
178
+ payload: { to: 'b@example.com' },
179
+ });
180
+ const cancelled = await jobQueue.cancelAllUpcomingJobs({
181
+ jobType: 'email',
182
+ });
183
+ expect(cancelled).toBe(1);
184
+ expect((await jobQueue.getJob(jobId1))?.status).toBe('cancelled');
185
+ expect((await jobQueue.getJob(jobId2))?.status).toBe('pending');
186
+ });
187
+
188
+ it('should cancel all upcoming jobs by priority', async () => {
189
+ const jobId1 = await jobQueue.addJob({
190
+ jobType: 'email',
191
+ payload: { to: 'a@example.com' },
192
+ priority: 1,
193
+ });
194
+ const jobId2 = await jobQueue.addJob({
195
+ jobType: 'email',
196
+ payload: { to: 'b@example.com' },
197
+ priority: 2,
198
+ });
199
+ const cancelled = await jobQueue.cancelAllUpcomingJobs({ priority: 2 });
200
+ expect(cancelled).toBe(1);
201
+ expect((await jobQueue.getJob(jobId1))?.status).toBe('pending');
202
+ expect((await jobQueue.getJob(jobId2))?.status).toBe('cancelled');
203
+ });
204
+
205
+ it('should edit a pending job', async () => {
206
+ const jobId = await jobQueue.addJob({
207
+ jobType: 'email',
208
+ payload: { to: 'original@example.com' },
209
+ priority: 0,
210
+ maxAttempts: 3,
211
+ });
212
+
213
+ await jobQueue.editJob(jobId, {
214
+ payload: { to: 'updated@example.com' },
215
+ priority: 10,
216
+ maxAttempts: 5,
217
+ });
218
+
219
+ const job = await jobQueue.getJob(jobId);
220
+ expect(job?.payload).toEqual({ to: 'updated@example.com' });
221
+ expect(job?.priority).toBe(10);
222
+ expect(job?.maxAttempts).toBe(5);
223
+ });
224
+
225
+ it('should edit all pending jobs', async () => {
226
+ await jobQueue.addJob({
227
+ jobType: 'email',
228
+ payload: { to: 'a@example.com' },
229
+ priority: 0,
230
+ });
231
+ await jobQueue.addJob({
232
+ jobType: 'email',
233
+ payload: { to: 'b@example.com' },
234
+ priority: 0,
235
+ });
236
+ const smsId = await jobQueue.addJob({
237
+ jobType: 'sms',
238
+ payload: { to: 'c@example.com' },
239
+ priority: 0,
240
+ });
241
+
242
+ const edited = await jobQueue.editAllPendingJobs(
243
+ { jobType: 'email' },
244
+ { priority: 5 },
245
+ );
246
+ expect(edited).toBe(2);
247
+ const smsJob = await jobQueue.getJob(smsId);
248
+ expect(smsJob?.priority).toBe(0); // unchanged
249
+ });
250
+
251
+ it('should record and retrieve job events', async () => {
252
+ const jobId = await jobQueue.addJob({
253
+ jobType: 'email',
254
+ payload: { to: 'events@example.com' },
255
+ });
256
+ const events = await jobQueue.getJobEvents(jobId);
257
+ expect(events.length).toBeGreaterThanOrEqual(1);
258
+ expect(events[0].eventType).toBe('added');
259
+ });
260
+
261
+ it('should record edited event', async () => {
262
+ const jobId = await jobQueue.addJob({
263
+ jobType: 'email',
264
+ payload: { to: 'original@example.com' },
265
+ });
266
+ await jobQueue.editJob(jobId, {
267
+ payload: { to: 'updated@example.com' },
268
+ priority: 10,
269
+ });
270
+ const events = await jobQueue.getJobEvents(jobId);
271
+ const editEvent = events.find((e) => e.eventType === 'edited');
272
+ expect(editEvent).not.toBeUndefined();
273
+ expect(editEvent?.metadata).toMatchObject({
274
+ payload: { to: 'updated@example.com' },
275
+ priority: 10,
276
+ });
277
+ });
278
+
279
+ it('should support idempotency keys', async () => {
280
+ const jobId1 = await jobQueue.addJob({
281
+ jobType: 'email',
282
+ payload: { to: 'idem@example.com' },
283
+ idempotencyKey: 'unique-key-123',
284
+ });
285
+ const jobId2 = await jobQueue.addJob({
286
+ jobType: 'email',
287
+ payload: { to: 'idem@example.com' },
288
+ idempotencyKey: 'unique-key-123',
289
+ });
290
+ expect(jobId1).toBe(jobId2);
291
+ });
292
+
293
+ it('should support tags and getJobsByTags', async () => {
294
+ await jobQueue.addJob({
295
+ jobType: 'email',
296
+ payload: { to: 'tagged1@example.com' },
297
+ tags: ['foo', 'bar'],
298
+ });
299
+ await jobQueue.addJob({
300
+ jobType: 'email',
301
+ payload: { to: 'tagged2@example.com' },
302
+ tags: ['foo'],
303
+ });
304
+ await jobQueue.addJob({
305
+ jobType: 'email',
306
+ payload: { to: 'tagged3@example.com' },
307
+ tags: ['baz'],
308
+ });
309
+
310
+ // mode: 'all' - has both foo AND bar
311
+ const allJobs = await jobQueue.getJobsByTags(['foo', 'bar'], 'all');
312
+ expect(allJobs.length).toBe(1);
313
+ expect(allJobs[0].payload).toEqual({ to: 'tagged1@example.com' });
314
+
315
+ // mode: 'any' - has foo OR bar
316
+ const anyJobs = await jobQueue.getJobsByTags(['foo', 'bar'], 'any');
317
+ expect(anyJobs.length).toBe(2);
318
+
319
+ // mode: 'exact' - exactly ['foo', 'bar']
320
+ const exactJobs = await jobQueue.getJobsByTags(['foo', 'bar'], 'exact');
321
+ expect(exactJobs.length).toBe(1);
322
+
323
+ // mode: 'none' - neither foo nor bar
324
+ const noneJobs = await jobQueue.getJobsByTags(['foo', 'bar'], 'none');
325
+ expect(noneJobs.length).toBe(1);
326
+ expect(noneJobs[0].payload).toEqual({ to: 'tagged3@example.com' });
327
+ });
328
+
329
+ it('should support priority ordering in processing', async () => {
330
+ const processed: string[] = [];
331
+ const jobId1 = await jobQueue.addJob({
332
+ jobType: 'email',
333
+ payload: { to: 'low@example.com' },
334
+ priority: 1,
335
+ });
336
+ const jobId2 = await jobQueue.addJob({
337
+ jobType: 'email',
338
+ payload: { to: 'high@example.com' },
339
+ priority: 10,
340
+ });
341
+ const processor = jobQueue.createProcessor(
342
+ {
343
+ email: async (payload: any) => {
344
+ processed.push(payload.to);
345
+ },
346
+ sms: vi.fn(async () => {}),
347
+ test: vi.fn(async () => {}),
348
+ },
349
+ { batchSize: 10, concurrency: 1 },
350
+ );
351
+ await processor.start();
352
+ // Higher priority should be first
353
+ expect(processed[0]).toBe('high@example.com');
354
+ expect(processed[1]).toBe('low@example.com');
355
+ });
356
+
357
+ it('should cleanup old completed jobs', async () => {
358
+ const jobId = await jobQueue.addJob({
359
+ jobType: 'test',
360
+ payload: { foo: 'cleanup' },
361
+ });
362
+ // Complete it
363
+ const processor = jobQueue.createProcessor({
364
+ email: vi.fn(async () => {}),
365
+ sms: vi.fn(async () => {}),
366
+ test: vi.fn(async () => {}),
367
+ });
368
+ await processor.start();
369
+ const completedJob = await jobQueue.getJob(jobId);
370
+ expect(completedJob?.status).toBe('completed');
371
+
372
+ // Manually set updatedAt to 31 days ago
373
+ const oldMs = Date.now() - 31 * 24 * 60 * 60 * 1000;
374
+ await redisClient.hset(
375
+ `${prefix}job:${jobId}`,
376
+ 'updatedAt',
377
+ oldMs.toString(),
378
+ );
379
+
380
+ const deleted = await jobQueue.cleanupOldJobs(30);
381
+ expect(deleted).toBe(1);
382
+ const job = await jobQueue.getJob(jobId);
383
+ expect(job).toBeNull();
384
+ });
385
+
386
+ it('should reclaim stuck jobs', async () => {
387
+ const jobId = await jobQueue.addJob({
388
+ jobType: 'email',
389
+ payload: { to: 'stuck@example.com' },
390
+ });
391
+ // Manually set to processing with old lockedAt
392
+ const oldMs = Date.now() - 15 * 60 * 1000; // 15 minutes ago
393
+ await redisClient.hmset(
394
+ `${prefix}job:${jobId}`,
395
+ 'status',
396
+ 'processing',
397
+ 'lockedAt',
398
+ oldMs.toString(),
399
+ 'lockedBy',
400
+ 'dead-worker',
401
+ );
402
+ await redisClient.srem(`${prefix}status:pending`, jobId.toString());
403
+ await redisClient.sadd(`${prefix}status:processing`, jobId.toString());
404
+ await redisClient.zrem(`${prefix}queue`, jobId.toString());
405
+
406
+ const reclaimed = await jobQueue.reclaimStuckJobs(10);
407
+ expect(reclaimed).toBe(1);
408
+ const job = await jobQueue.getJob(jobId);
409
+ expect(job?.status).toBe('pending');
410
+ expect(job?.lockedAt).toBeNull();
411
+ });
412
+
413
+ it('should not reclaim a job whose timeoutMs exceeds the reclaim threshold', async () => {
414
+ const jobId = await jobQueue.addJob({
415
+ jobType: 'email',
416
+ payload: { to: 'long-timeout@example.com' },
417
+ timeoutMs: 30 * 60 * 1000, // 30 minutes
418
+ });
419
+ // Simulate: processing for 15 minutes (exceeds 10-min global threshold but within 30-min job timeout)
420
+ const oldMs = Date.now() - 15 * 60 * 1000;
421
+ await redisClient.hmset(
422
+ `${prefix}job:${jobId}`,
423
+ 'status',
424
+ 'processing',
425
+ 'lockedAt',
426
+ oldMs.toString(),
427
+ 'lockedBy',
428
+ 'some-worker',
429
+ );
430
+ await redisClient.srem(`${prefix}status:pending`, jobId.toString());
431
+ await redisClient.sadd(`${prefix}status:processing`, jobId.toString());
432
+ await redisClient.zrem(`${prefix}queue`, jobId.toString());
433
+
434
+ const reclaimed = await jobQueue.reclaimStuckJobs(10);
435
+ expect(reclaimed).toBe(0);
436
+ const job = await jobQueue.getJob(jobId);
437
+ expect(job?.status).toBe('processing');
438
+ });
439
+
440
+ it('should reclaim a job whose timeoutMs has also been exceeded', async () => {
441
+ const jobId = await jobQueue.addJob({
442
+ jobType: 'email',
443
+ payload: { to: 'expired-timeout@example.com' },
444
+ timeoutMs: 20 * 60 * 1000, // 20 minutes
445
+ });
446
+ // Simulate: processing for 25 minutes (exceeds both 10-min threshold and 20-min job timeout)
447
+ const oldMs = Date.now() - 25 * 60 * 1000;
448
+ await redisClient.hmset(
449
+ `${prefix}job:${jobId}`,
450
+ 'status',
451
+ 'processing',
452
+ 'lockedAt',
453
+ oldMs.toString(),
454
+ 'lockedBy',
455
+ 'some-worker',
456
+ );
457
+ await redisClient.srem(`${prefix}status:pending`, jobId.toString());
458
+ await redisClient.sadd(`${prefix}status:processing`, jobId.toString());
459
+ await redisClient.zrem(`${prefix}queue`, jobId.toString());
460
+
461
+ const reclaimed = await jobQueue.reclaimStuckJobs(10);
462
+ expect(reclaimed).toBe(1);
463
+ const job = await jobQueue.getJob(jobId);
464
+ expect(job?.status).toBe('pending');
465
+ });
466
+
467
+ it('getPool should throw for Redis backend', () => {
468
+ expect(() => jobQueue.getPool()).toThrow(
469
+ 'getPool() is only available with the PostgreSQL backend',
470
+ );
471
+ });
472
+
473
+ it('getRedisClient should return the Redis client', () => {
474
+ const client = jobQueue.getRedisClient() as { get: unknown };
475
+ expect(client).toBeDefined();
476
+ expect(typeof client.get).toBe('function');
477
+ });
478
+
479
+ it('should get jobs with filters', async () => {
480
+ await jobQueue.addJob({
481
+ jobType: 'email',
482
+ payload: { to: 'a@example.com' },
483
+ priority: 1,
484
+ });
485
+ await jobQueue.addJob({
486
+ jobType: 'sms',
487
+ payload: { to: 'b@example.com' },
488
+ priority: 2,
489
+ });
490
+ await jobQueue.addJob({
491
+ jobType: 'email',
492
+ payload: { to: 'c@example.com' },
493
+ priority: 3,
494
+ });
495
+
496
+ const emailJobs = await jobQueue.getJobs({ jobType: 'email' });
497
+ expect(emailJobs.length).toBe(2);
498
+
499
+ const priorityJobs = await jobQueue.getJobs({ priority: 2 });
500
+ expect(priorityJobs.length).toBe(1);
501
+ expect(priorityJobs[0].jobType).toBe('sms');
502
+ });
503
+
504
+ it('should cancel all upcoming jobs by tags', async () => {
505
+ const jobId1 = await jobQueue.addJob({
506
+ jobType: 'email',
507
+ payload: { to: 'tag1@example.com' },
508
+ tags: ['foo', 'bar'],
509
+ });
510
+ const jobId2 = await jobQueue.addJob({
511
+ jobType: 'email',
512
+ payload: { to: 'tag2@example.com' },
513
+ tags: ['baz'],
514
+ });
515
+ const cancelled = await jobQueue.cancelAllUpcomingJobs({
516
+ tags: { values: ['foo'], mode: 'all' },
517
+ });
518
+ expect(cancelled).toBe(1);
519
+ expect((await jobQueue.getJob(jobId1))?.status).toBe('cancelled');
520
+ expect((await jobQueue.getJob(jobId2))?.status).toBe('pending');
521
+ });
522
+
523
+ it('should handle scheduled jobs (runAt in the future)', async () => {
524
+ const futureDate = new Date(Date.now() + 60 * 60 * 1000); // 1 hour later
525
+ const jobId = await jobQueue.addJob({
526
+ jobType: 'email',
527
+ payload: { to: 'scheduled@example.com' },
528
+ runAt: futureDate,
529
+ });
530
+
531
+ // Should not be picked up immediately
532
+ const processor = jobQueue.createProcessor({
533
+ email: vi.fn(async () => {}),
534
+ sms: vi.fn(async () => {}),
535
+ test: vi.fn(async () => {}),
536
+ });
537
+ const processed = await processor.start();
538
+ expect(processed).toBe(0);
539
+
540
+ const job = await jobQueue.getJob(jobId);
541
+ expect(job?.status).toBe('pending');
542
+ });
543
+ });