@nicnocquee/dataqueue 1.22.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.
Files changed (34) hide show
  1. package/README.md +44 -0
  2. package/dist/index.cjs +2822 -583
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +589 -12
  5. package/dist/index.d.ts +589 -12
  6. package/dist/index.js +2818 -584
  7. package/dist/index.js.map +1 -1
  8. package/migrations/1751131910825_add_timeout_seconds_to_job_queue.sql +2 -2
  9. package/migrations/1751186053000_add_job_events_table.sql +12 -8
  10. package/migrations/1751984773000_add_tags_to_job_queue.sql +1 -1
  11. package/migrations/1765809419000_add_force_kill_on_timeout_to_job_queue.sql +6 -0
  12. package/migrations/1771100000000_add_idempotency_key_to_job_queue.sql +7 -0
  13. package/migrations/1781200000000_add_wait_support.sql +12 -0
  14. package/migrations/1781200000001_create_waitpoints_table.sql +18 -0
  15. package/migrations/1781200000002_add_performance_indexes.sql +34 -0
  16. package/migrations/1781200000003_add_progress_to_job_queue.sql +7 -0
  17. package/package.json +20 -6
  18. package/src/backend.ts +163 -0
  19. package/src/backends/postgres.ts +1111 -0
  20. package/src/backends/redis-scripts.ts +533 -0
  21. package/src/backends/redis.test.ts +543 -0
  22. package/src/backends/redis.ts +834 -0
  23. package/src/db-util.ts +4 -2
  24. package/src/handler-validation.test.ts +414 -0
  25. package/src/handler-validation.ts +168 -0
  26. package/src/index.test.ts +230 -1
  27. package/src/index.ts +128 -32
  28. package/src/processor.test.ts +612 -16
  29. package/src/processor.ts +759 -47
  30. package/src/queue.test.ts +736 -3
  31. package/src/queue.ts +346 -660
  32. package/src/test-util.ts +32 -0
  33. package/src/types.ts +451 -16
  34. package/src/wait.test.ts +698 -0
package/src/queue.test.ts CHANGED
@@ -72,6 +72,8 @@ describe('queue integration', () => {
72
72
  jobType: 'email',
73
73
  payload: { to: 'done@example.com' },
74
74
  });
75
+ // Claim the job first (sets status to 'processing')
76
+ await queue.getNextBatch(pool, 'worker-complete', 1);
75
77
  await queue.completeJob(pool, jobId);
76
78
  const job = await queue.getJob(pool, jobId);
77
79
  expect(job?.status).toBe('completed');
@@ -124,6 +126,8 @@ describe('queue integration', () => {
124
126
  jobType: 'email',
125
127
  payload: { to: 'cleanup@example.com' },
126
128
  });
129
+ // Claim then complete the job
130
+ await queue.getNextBatch(pool, 'worker-cleanup', 1);
127
131
  await queue.completeJob(pool, jobId);
128
132
  // Manually update updated_at to be old
129
133
  await pool.query(
@@ -154,12 +158,543 @@ describe('queue integration', () => {
154
158
  payload: { to: 'done@example.com' },
155
159
  },
156
160
  );
161
+ await queue.getNextBatch(pool, 'worker-cancel-done', 1);
157
162
  await queue.completeJob(pool, jobId2);
158
163
  await queue.cancelJob(pool, jobId2);
159
164
  const completedJob = await queue.getJob(pool, jobId2);
160
165
  expect(completedJob?.status).toBe('completed');
161
166
  });
162
167
 
168
+ it('should edit a pending job with all fields', async () => {
169
+ const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
170
+ jobType: 'email',
171
+ payload: { to: 'original@example.com' },
172
+ priority: 0,
173
+ maxAttempts: 3,
174
+ timeoutMs: 10000,
175
+ tags: ['original'],
176
+ });
177
+ const originalJob = await queue.getJob(pool, jobId);
178
+ const originalUpdatedAt = originalJob?.updatedAt;
179
+
180
+ // Wait a bit to ensure updated_at changes
181
+ await new Promise((r) => setTimeout(r, 10));
182
+
183
+ await queue.editJob<{ email: { to: string } }, 'email'>(pool, jobId, {
184
+ payload: { to: 'updated@example.com' },
185
+ priority: 10,
186
+ maxAttempts: 5,
187
+ runAt: new Date(Date.now() + 60000),
188
+ timeoutMs: 20000,
189
+ tags: ['updated', 'priority'],
190
+ });
191
+
192
+ const updatedJob = await queue.getJob(pool, jobId);
193
+ expect(updatedJob?.payload).toEqual({ to: 'updated@example.com' });
194
+ expect(updatedJob?.priority).toBe(10);
195
+ expect(updatedJob?.maxAttempts).toBe(5);
196
+ expect(updatedJob?.timeoutMs).toBe(20000);
197
+ expect(updatedJob?.tags).toEqual(['updated', 'priority']);
198
+ expect(updatedJob?.status).toBe('pending');
199
+ expect(updatedJob?.updatedAt.getTime()).toBeGreaterThan(
200
+ originalUpdatedAt?.getTime() || 0,
201
+ );
202
+ });
203
+
204
+ it('should edit a pending job with partial fields', async () => {
205
+ const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
206
+ jobType: 'email',
207
+ payload: { to: 'original@example.com' },
208
+ priority: 0,
209
+ maxAttempts: 3,
210
+ });
211
+
212
+ // Only update payload
213
+ await queue.editJob<{ email: { to: string } }, 'email'>(pool, jobId, {
214
+ payload: { to: 'updated@example.com' },
215
+ });
216
+
217
+ const updatedJob = await queue.getJob(pool, jobId);
218
+ expect(updatedJob?.payload).toEqual({ to: 'updated@example.com' });
219
+ expect(updatedJob?.priority).toBe(0); // Unchanged
220
+ expect(updatedJob?.maxAttempts).toBe(3); // Unchanged
221
+
222
+ // Only update priority
223
+ await queue.editJob<{ email: { to: string } }, 'email'>(pool, jobId, {
224
+ priority: 5,
225
+ });
226
+
227
+ const updatedJob2 = await queue.getJob(pool, jobId);
228
+ expect(updatedJob2?.payload).toEqual({ to: 'updated@example.com' }); // Still updated
229
+ expect(updatedJob2?.priority).toBe(5); // Now updated
230
+ });
231
+
232
+ it('should silently fail when editing a non-pending job', async () => {
233
+ const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
234
+ jobType: 'email',
235
+ payload: { to: 'original@example.com' },
236
+ });
237
+ await queue.getNextBatch(pool, 'worker-edit-noop', 1);
238
+ await queue.completeJob(pool, jobId);
239
+
240
+ // Try to edit a completed job - should silently fail
241
+ await queue.editJob<{ email: { to: string } }, 'email'>(pool, jobId, {
242
+ payload: { to: 'updated@example.com' },
243
+ });
244
+
245
+ const job = await queue.getJob(pool, jobId);
246
+ expect(job?.status).toBe('completed');
247
+ expect(job?.payload).toEqual({ to: 'original@example.com' }); // Unchanged
248
+
249
+ // Try to edit a processing job
250
+ const jobId2 = await queue.addJob<{ email: { to: string } }, 'email'>(
251
+ pool,
252
+ {
253
+ jobType: 'email',
254
+ payload: { to: 'processing@example.com' },
255
+ },
256
+ );
257
+ await queue.getNextBatch(pool, 'worker-edit', 1);
258
+ await queue.editJob<{ email: { to: string } }, 'email'>(pool, jobId2, {
259
+ payload: { to: 'updated@example.com' },
260
+ });
261
+
262
+ const job2 = await queue.getJob(pool, jobId2);
263
+ expect(job2?.status).toBe('processing');
264
+ expect(job2?.payload).toEqual({ to: 'processing@example.com' }); // Unchanged
265
+ });
266
+
267
+ it('should record edited event when editing a job', async () => {
268
+ const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
269
+ jobType: 'email',
270
+ payload: { to: 'original@example.com' },
271
+ });
272
+
273
+ await queue.editJob<{ email: { to: string } }, 'email'>(pool, jobId, {
274
+ payload: { to: 'updated@example.com' },
275
+ priority: 10,
276
+ });
277
+
278
+ const res = await pool.query(
279
+ 'SELECT * FROM job_events WHERE job_id = $1 ORDER BY created_at ASC',
280
+ [jobId],
281
+ );
282
+ const events = res.rows.map(
283
+ (row) => objectKeysToCamelCase(row) as JobEvent,
284
+ );
285
+ const editEvent = events.find((e) => e.eventType === JobEventType.Edited);
286
+ expect(editEvent).not.toBeUndefined();
287
+ expect(editEvent?.metadata).toMatchObject({
288
+ payload: { to: 'updated@example.com' },
289
+ priority: 10,
290
+ });
291
+ });
292
+
293
+ it('should update updated_at timestamp when editing', async () => {
294
+ const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
295
+ jobType: 'email',
296
+ payload: { to: 'original@example.com' },
297
+ });
298
+ const originalJob = await queue.getJob(pool, jobId);
299
+ const originalUpdatedAt = originalJob?.updatedAt;
300
+
301
+ // Wait a bit to ensure timestamp difference
302
+ await new Promise((r) => setTimeout(r, 10));
303
+
304
+ await queue.editJob<{ email: { to: string } }, 'email'>(pool, jobId, {
305
+ priority: 5,
306
+ });
307
+
308
+ const updatedJob = await queue.getJob(pool, jobId);
309
+ expect(updatedJob?.updatedAt.getTime()).toBeGreaterThan(
310
+ originalUpdatedAt?.getTime() || 0,
311
+ );
312
+ });
313
+
314
+ it('should handle editing with null values', async () => {
315
+ const futureDate = new Date(Date.now() + 60000);
316
+ const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
317
+ jobType: 'email',
318
+ payload: { to: 'original@example.com' },
319
+ runAt: futureDate,
320
+ timeoutMs: 10000,
321
+ tags: ['original'],
322
+ });
323
+
324
+ await queue.editJob<{ email: { to: string } }, 'email'>(pool, jobId, {
325
+ runAt: null,
326
+ timeoutMs: null,
327
+ tags: null,
328
+ });
329
+
330
+ const updatedJob = await queue.getJob(pool, jobId);
331
+ expect(updatedJob?.runAt).not.toBeNull(); // runAt null means use default (now)
332
+ expect(updatedJob?.timeoutMs).toBeNull();
333
+ expect(updatedJob?.tags).toBeNull();
334
+ });
335
+
336
+ it('should do nothing when editing with no fields', async () => {
337
+ const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
338
+ jobType: 'email',
339
+ payload: { to: 'original@example.com' },
340
+ });
341
+ const originalJob = await queue.getJob(pool, jobId);
342
+
343
+ await queue.editJob<{ email: { to: string } }, 'email'>(pool, jobId, {});
344
+
345
+ const job = await queue.getJob(pool, jobId);
346
+ expect(job?.payload).toEqual(originalJob?.payload);
347
+ expect(job?.priority).toBe(originalJob?.priority);
348
+ });
349
+
350
+ it('should edit all pending jobs without filters', async () => {
351
+ // Add three pending jobs
352
+ const jobId1 = await queue.addJob<{ email: { to: string } }, 'email'>(
353
+ pool,
354
+ {
355
+ jobType: 'email',
356
+ payload: { to: 'batch1@example.com' },
357
+ priority: 0,
358
+ },
359
+ );
360
+ const jobId2 = await queue.addJob<{ email: { to: string } }, 'email'>(
361
+ pool,
362
+ {
363
+ jobType: 'email',
364
+ payload: { to: 'batch2@example.com' },
365
+ priority: 0,
366
+ },
367
+ );
368
+ const jobId3 = await queue.addJob<{ email: { to: string } }, 'email'>(
369
+ pool,
370
+ {
371
+ jobType: 'email',
372
+ payload: { to: 'batch3@example.com' },
373
+ priority: 0,
374
+ },
375
+ );
376
+ // Add a completed job (set via SQL since this test is about edit behavior)
377
+ const jobId4 = await queue.addJob<{ email: { to: string } }, 'email'>(
378
+ pool,
379
+ {
380
+ jobType: 'email',
381
+ payload: { to: 'done@example.com' },
382
+ },
383
+ );
384
+ await pool.query(
385
+ `UPDATE job_queue SET status = 'completed' WHERE id = $1`,
386
+ [jobId4],
387
+ );
388
+
389
+ // Edit all pending jobs
390
+ const editedCount = await queue.editAllPendingJobs<
391
+ { email: { to: string } },
392
+ 'email'
393
+ >(pool, undefined, {
394
+ priority: 10,
395
+ });
396
+ expect(editedCount).toBeGreaterThanOrEqual(3);
397
+
398
+ // Check that all pending jobs are updated
399
+ const job1 = await queue.getJob(pool, jobId1);
400
+ const job2 = await queue.getJob(pool, jobId2);
401
+ const job3 = await queue.getJob(pool, jobId3);
402
+ expect(job1?.priority).toBe(10);
403
+ expect(job2?.priority).toBe(10);
404
+ expect(job3?.priority).toBe(10);
405
+
406
+ // Completed job should remain unchanged
407
+ const completedJob = await queue.getJob(pool, jobId4);
408
+ expect(completedJob?.priority).toBe(0);
409
+ });
410
+
411
+ it('should edit pending jobs filtered by jobType', async () => {
412
+ const emailJobId1 = await queue.addJob<{ email: { to: string } }, 'email'>(
413
+ pool,
414
+ {
415
+ jobType: 'email',
416
+ payload: { to: 'email1@example.com' },
417
+ priority: 0,
418
+ },
419
+ );
420
+ const emailJobId2 = await queue.addJob<{ email: { to: string } }, 'email'>(
421
+ pool,
422
+ {
423
+ jobType: 'email',
424
+ payload: { to: 'email2@example.com' },
425
+ priority: 0,
426
+ },
427
+ );
428
+ const smsJobId = await queue.addJob<{ sms: { to: string } }, 'sms'>(pool, {
429
+ jobType: 'sms',
430
+ payload: { to: 'sms@example.com' },
431
+ priority: 0,
432
+ });
433
+
434
+ // Edit only email jobs
435
+ const editedCount = await queue.editAllPendingJobs<
436
+ { email: { to: string }; sms: { to: string } },
437
+ 'email'
438
+ >(
439
+ pool,
440
+ { jobType: 'email' },
441
+ {
442
+ priority: 5,
443
+ },
444
+ );
445
+ expect(editedCount).toBeGreaterThanOrEqual(2);
446
+
447
+ const emailJob1 = await queue.getJob(pool, emailJobId1);
448
+ const emailJob2 = await queue.getJob(pool, emailJobId2);
449
+ const smsJob = await queue.getJob(pool, smsJobId);
450
+ expect(emailJob1?.priority).toBe(5);
451
+ expect(emailJob2?.priority).toBe(5);
452
+ expect(smsJob?.priority).toBe(0);
453
+ });
454
+
455
+ it('should edit pending jobs filtered by priority', async () => {
456
+ const lowPriorityJobId1 = await queue.addJob<
457
+ { email: { to: string } },
458
+ 'email'
459
+ >(pool, {
460
+ jobType: 'email',
461
+ payload: { to: 'low1@example.com' },
462
+ priority: 1,
463
+ });
464
+ const lowPriorityJobId2 = await queue.addJob<
465
+ { email: { to: string } },
466
+ 'email'
467
+ >(pool, {
468
+ jobType: 'email',
469
+ payload: { to: 'low2@example.com' },
470
+ priority: 1,
471
+ });
472
+ const highPriorityJobId = await queue.addJob<
473
+ { email: { to: string } },
474
+ 'email'
475
+ >(pool, {
476
+ jobType: 'email',
477
+ payload: { to: 'high@example.com' },
478
+ priority: 10,
479
+ });
480
+
481
+ // Edit only low priority jobs
482
+ const editedCount = await queue.editAllPendingJobs<
483
+ { email: { to: string } },
484
+ 'email'
485
+ >(
486
+ pool,
487
+ { priority: 1 },
488
+ {
489
+ priority: 5,
490
+ },
491
+ );
492
+ expect(editedCount).toBeGreaterThanOrEqual(2);
493
+
494
+ const lowJob1 = await queue.getJob(pool, lowPriorityJobId1);
495
+ const lowJob2 = await queue.getJob(pool, lowPriorityJobId2);
496
+ const highJob = await queue.getJob(pool, highPriorityJobId);
497
+ expect(lowJob1?.priority).toBe(5);
498
+ expect(lowJob2?.priority).toBe(5);
499
+ expect(highJob?.priority).toBe(10);
500
+ });
501
+
502
+ it('should edit pending jobs filtered by tags', async () => {
503
+ const taggedJobId1 = await queue.addJob<{ email: { to: string } }, 'email'>(
504
+ pool,
505
+ {
506
+ jobType: 'email',
507
+ payload: { to: 'tagged1@example.com' },
508
+ tags: ['urgent', 'priority'],
509
+ },
510
+ );
511
+ const taggedJobId2 = await queue.addJob<{ email: { to: string } }, 'email'>(
512
+ pool,
513
+ {
514
+ jobType: 'email',
515
+ payload: { to: 'tagged2@example.com' },
516
+ tags: ['urgent', 'priority'],
517
+ },
518
+ );
519
+ const untaggedJobId = await queue.addJob<
520
+ { email: { to: string } },
521
+ 'email'
522
+ >(pool, {
523
+ jobType: 'email',
524
+ payload: { to: 'untagged@example.com' },
525
+ tags: ['other'],
526
+ });
527
+
528
+ // Edit only jobs with 'urgent' tag
529
+ const editedCount = await queue.editAllPendingJobs<
530
+ { email: { to: string } },
531
+ 'email'
532
+ >(
533
+ pool,
534
+ { tags: { values: ['urgent'], mode: 'any' } },
535
+ {
536
+ priority: 10,
537
+ },
538
+ );
539
+ expect(editedCount).toBeGreaterThanOrEqual(2);
540
+
541
+ const taggedJob1 = await queue.getJob(pool, taggedJobId1);
542
+ const taggedJob2 = await queue.getJob(pool, taggedJobId2);
543
+ const untaggedJob = await queue.getJob(pool, untaggedJobId);
544
+ expect(taggedJob1?.priority).toBe(10);
545
+ expect(taggedJob2?.priority).toBe(10);
546
+ expect(untaggedJob?.priority).toBe(0);
547
+ });
548
+
549
+ it('should edit pending jobs filtered by runAt', async () => {
550
+ const futureDate = new Date(Date.now() + 60000);
551
+ const pastDate = new Date(Date.now() - 60000);
552
+
553
+ const futureJobId1 = await queue.addJob<{ email: { to: string } }, 'email'>(
554
+ pool,
555
+ {
556
+ jobType: 'email',
557
+ payload: { to: 'future1@example.com' },
558
+ runAt: futureDate,
559
+ },
560
+ );
561
+ const futureJobId2 = await queue.addJob<{ email: { to: string } }, 'email'>(
562
+ pool,
563
+ {
564
+ jobType: 'email',
565
+ payload: { to: 'future2@example.com' },
566
+ runAt: futureDate,
567
+ },
568
+ );
569
+ const pastJobId = await queue.addJob<{ email: { to: string } }, 'email'>(
570
+ pool,
571
+ {
572
+ jobType: 'email',
573
+ payload: { to: 'past@example.com' },
574
+ runAt: pastDate,
575
+ },
576
+ );
577
+
578
+ // Edit only jobs scheduled in the future
579
+ const editedCount = await queue.editAllPendingJobs<
580
+ { email: { to: string } },
581
+ 'email'
582
+ >(
583
+ pool,
584
+ { runAt: { gte: new Date() } },
585
+ {
586
+ priority: 10,
587
+ },
588
+ );
589
+ expect(editedCount).toBeGreaterThanOrEqual(2);
590
+
591
+ const futureJob1 = await queue.getJob(pool, futureJobId1);
592
+ const futureJob2 = await queue.getJob(pool, futureJobId2);
593
+ const pastJob = await queue.getJob(pool, pastJobId);
594
+ expect(futureJob1?.priority).toBe(10);
595
+ expect(futureJob2?.priority).toBe(10);
596
+ expect(pastJob?.priority).toBe(0);
597
+ });
598
+
599
+ it('should not edit non-pending jobs', async () => {
600
+ // Create processingJobId first so it gets picked up by getNextBatch
601
+ const processingJobId = await queue.addJob<
602
+ { email: { to: string } },
603
+ 'email'
604
+ >(pool, {
605
+ jobType: 'email',
606
+ payload: { to: 'processing@example.com' },
607
+ priority: 0,
608
+ });
609
+ const pendingJobId = await queue.addJob<{ email: { to: string } }, 'email'>(
610
+ pool,
611
+ {
612
+ jobType: 'email',
613
+ payload: { to: 'pending@example.com' },
614
+ priority: 0,
615
+ },
616
+ );
617
+ // Mark as processing (this will pick up processingJobId since it was created first)
618
+ await queue.getNextBatch(pool, 'worker-batch', 1);
619
+ const completedJobId = await queue.addJob<
620
+ { email: { to: string } },
621
+ 'email'
622
+ >(pool, {
623
+ jobType: 'email',
624
+ payload: { to: 'completed@example.com' },
625
+ priority: 0,
626
+ });
627
+ // Set to completed via SQL (bypassing status check since we're testing edit behavior)
628
+ await pool.query(
629
+ `UPDATE job_queue SET status = 'completed' WHERE id = $1`,
630
+ [completedJobId],
631
+ );
632
+
633
+ // Edit all pending jobs
634
+ const editedCount = await queue.editAllPendingJobs<
635
+ { email: { to: string } },
636
+ 'email'
637
+ >(pool, undefined, {
638
+ priority: 10,
639
+ });
640
+
641
+ const pendingJob = await queue.getJob(pool, pendingJobId);
642
+ const processingJob = await queue.getJob(pool, processingJobId);
643
+ const completedJob = await queue.getJob(pool, completedJobId);
644
+ expect(pendingJob?.priority).toBe(10);
645
+ expect(processingJob?.priority).toBe(0);
646
+ expect(completedJob?.priority).toBe(0);
647
+ expect(editedCount).toBeGreaterThanOrEqual(1);
648
+ });
649
+
650
+ it('should record edit events for each edited job', async () => {
651
+ const jobId1 = await queue.addJob<{ email: { to: string } }, 'email'>(
652
+ pool,
653
+ {
654
+ jobType: 'email',
655
+ payload: { to: 'event1@example.com' },
656
+ },
657
+ );
658
+ const jobId2 = await queue.addJob<{ email: { to: string } }, 'email'>(
659
+ pool,
660
+ {
661
+ jobType: 'email',
662
+ payload: { to: 'event2@example.com' },
663
+ },
664
+ );
665
+
666
+ await queue.editAllPendingJobs<{ email: { to: string } }, 'email'>(
667
+ pool,
668
+ undefined,
669
+ {
670
+ priority: 10,
671
+ },
672
+ );
673
+
674
+ const events1 = await queue.getJobEvents(pool, jobId1);
675
+ const events2 = await queue.getJobEvents(pool, jobId2);
676
+ const editEvent1 = events1.find((e) => e.eventType === JobEventType.Edited);
677
+ const editEvent2 = events2.find((e) => e.eventType === JobEventType.Edited);
678
+ expect(editEvent1).not.toBeUndefined();
679
+ expect(editEvent2).not.toBeUndefined();
680
+ expect(editEvent1?.metadata).toMatchObject({ priority: 10 });
681
+ expect(editEvent2?.metadata).toMatchObject({ priority: 10 });
682
+ });
683
+
684
+ it('should return 0 when no fields to update', async () => {
685
+ await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
686
+ jobType: 'email',
687
+ payload: { to: 'empty@example.com' },
688
+ });
689
+
690
+ const editedCount = await queue.editAllPendingJobs<
691
+ { email: { to: string } },
692
+ 'email'
693
+ >(pool, undefined, {});
694
+
695
+ expect(editedCount).toBe(0);
696
+ });
697
+
163
698
  it('should cancel all upcoming jobs', async () => {
164
699
  // Add three pending jobs
165
700
  const jobId1 = await queue.addJob<{ email: { to: string } }, 'email'>(
@@ -183,7 +718,7 @@ describe('queue integration', () => {
183
718
  payload: { to: 'cancelall3@example.com' },
184
719
  },
185
720
  );
186
- // Add a completed job
721
+ // Add a completed job (set via SQL since this test is about cancel behavior)
187
722
  const jobId4 = await queue.addJob<{ email: { to: string } }, 'email'>(
188
723
  pool,
189
724
  {
@@ -191,7 +726,10 @@ describe('queue integration', () => {
191
726
  payload: { to: 'done@example.com' },
192
727
  },
193
728
  );
194
- await queue.completeJob(pool, jobId4);
729
+ await pool.query(
730
+ `UPDATE job_queue SET status = 'completed' WHERE id = $1`,
731
+ [jobId4],
732
+ );
195
733
 
196
734
  // Cancel all upcoming jobs
197
735
  const cancelledCount = await queue.cancelAllUpcomingJobs(pool);
@@ -287,8 +825,12 @@ describe('queue integration', () => {
287
825
  jobType: 'email',
288
826
  payload: { to: 'failhistory@example.com' },
289
827
  });
290
- // Fail the job twice with different errors
828
+ // Claim and fail the job (first error)
829
+ await queue.getNextBatch(pool, 'worker-fail-1', 1);
291
830
  await queue.failJob(pool, jobId, new Error('first error'));
831
+ // Retry, claim again, and fail again (second error)
832
+ await queue.retryJob(pool, jobId);
833
+ await queue.getNextBatch(pool, 'worker-fail-2', 1);
292
834
  await queue.failJob(pool, jobId, new Error('second error'));
293
835
  const job = await queue.getJob(pool, jobId);
294
836
  expect(job?.status).toBe('failed');
@@ -321,6 +863,50 @@ describe('queue integration', () => {
321
863
  expect(job?.lockedAt).toBeNull();
322
864
  expect(job?.lockedBy).toBeNull();
323
865
  });
866
+
867
+ it('should not reclaim a job whose timeoutMs exceeds the reclaim threshold', async () => {
868
+ // Add a job with a 30-minute timeout
869
+ const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
870
+ jobType: 'email',
871
+ payload: { to: 'long-timeout@example.com' },
872
+ timeoutMs: 30 * 60 * 1000, // 30 minutes
873
+ });
874
+ // Simulate: processing for 15 minutes (exceeds 10-min global threshold but within 30-min job timeout)
875
+ await pool.query(
876
+ `UPDATE job_queue SET status = 'processing', locked_at = NOW() - INTERVAL '15 minutes' WHERE id = $1`,
877
+ [jobId],
878
+ );
879
+ let job = await queue.getJob(pool, jobId);
880
+ expect(job?.status).toBe('processing');
881
+
882
+ // Reclaim with 10-minute global threshold — should NOT reclaim this job
883
+ const reclaimed = await queue.reclaimStuckJobs(pool, 10);
884
+ expect(reclaimed).toBe(0);
885
+ job = await queue.getJob(pool, jobId);
886
+ expect(job?.status).toBe('processing');
887
+ });
888
+
889
+ it('should reclaim a job whose timeoutMs has also been exceeded', async () => {
890
+ // Add a job with a 20-minute timeout
891
+ const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
892
+ jobType: 'email',
893
+ payload: { to: 'expired-timeout@example.com' },
894
+ timeoutMs: 20 * 60 * 1000, // 20 minutes
895
+ });
896
+ // Simulate: processing for 25 minutes (exceeds both 10-min threshold and 20-min job timeout)
897
+ await pool.query(
898
+ `UPDATE job_queue SET status = 'processing', locked_at = NOW() - INTERVAL '25 minutes' WHERE id = $1`,
899
+ [jobId],
900
+ );
901
+ let job = await queue.getJob(pool, jobId);
902
+ expect(job?.status).toBe('processing');
903
+
904
+ // Reclaim with 10-minute global threshold — should reclaim since 25 min > 20 min timeout
905
+ const reclaimed = await queue.reclaimStuckJobs(pool, 10);
906
+ expect(reclaimed).toBeGreaterThanOrEqual(1);
907
+ job = await queue.getJob(pool, jobId);
908
+ expect(job?.status).toBe('pending');
909
+ });
324
910
  });
325
911
 
326
912
  describe('job event tracking', () => {
@@ -1196,4 +1782,151 @@ describe('getJobs', () => {
1196
1782
  expect(jobs.map((j) => j.id)).toContain(id1);
1197
1783
  expect(jobs.map((j) => j.id)).not.toContain(id2);
1198
1784
  });
1785
+
1786
+ // --- Idempotency tests ---
1787
+
1788
+ it('should store and return idempotencyKey when provided', async () => {
1789
+ const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
1790
+ jobType: 'email',
1791
+ payload: { to: 'test@example.com' },
1792
+ idempotencyKey: 'unique-key-1',
1793
+ });
1794
+ const job = await queue.getJob(pool, jobId);
1795
+ expect(job).not.toBeNull();
1796
+ expect(job?.idempotencyKey).toBe('unique-key-1');
1797
+ });
1798
+
1799
+ it('should return the same job ID when adding a job with a duplicate idempotencyKey', async () => {
1800
+ const jobId1 = await queue.addJob<{ email: { to: string } }, 'email'>(
1801
+ pool,
1802
+ {
1803
+ jobType: 'email',
1804
+ payload: { to: 'first@example.com' },
1805
+ idempotencyKey: 'dedup-key',
1806
+ },
1807
+ );
1808
+ const jobId2 = await queue.addJob<{ email: { to: string } }, 'email'>(
1809
+ pool,
1810
+ {
1811
+ jobType: 'email',
1812
+ payload: { to: 'second@example.com' },
1813
+ idempotencyKey: 'dedup-key',
1814
+ },
1815
+ );
1816
+ expect(jobId1).toBe(jobId2);
1817
+
1818
+ // The original job's payload should be preserved (not updated)
1819
+ const job = await queue.getJob(pool, jobId1);
1820
+ expect(job?.payload).toEqual({ to: 'first@example.com' });
1821
+ });
1822
+
1823
+ it('should create separate jobs when no idempotencyKey is provided', async () => {
1824
+ const jobId1 = await queue.addJob<{ email: { to: string } }, 'email'>(
1825
+ pool,
1826
+ {
1827
+ jobType: 'email',
1828
+ payload: { to: 'a@example.com' },
1829
+ },
1830
+ );
1831
+ const jobId2 = await queue.addJob<{ email: { to: string } }, 'email'>(
1832
+ pool,
1833
+ {
1834
+ jobType: 'email',
1835
+ payload: { to: 'a@example.com' },
1836
+ },
1837
+ );
1838
+ expect(jobId1).not.toBe(jobId2);
1839
+ });
1840
+
1841
+ it('should create separate jobs when different idempotencyKeys are provided', async () => {
1842
+ const jobId1 = await queue.addJob<{ email: { to: string } }, 'email'>(
1843
+ pool,
1844
+ {
1845
+ jobType: 'email',
1846
+ payload: { to: 'same@example.com' },
1847
+ idempotencyKey: 'key-a',
1848
+ },
1849
+ );
1850
+ const jobId2 = await queue.addJob<{ email: { to: string } }, 'email'>(
1851
+ pool,
1852
+ {
1853
+ jobType: 'email',
1854
+ payload: { to: 'same@example.com' },
1855
+ idempotencyKey: 'key-b',
1856
+ },
1857
+ );
1858
+ expect(jobId1).not.toBe(jobId2);
1859
+ });
1860
+
1861
+ it('should only record the added event once for duplicate idempotencyKey', async () => {
1862
+ const jobId1 = await queue.addJob<{ email: { to: string } }, 'email'>(
1863
+ pool,
1864
+ {
1865
+ jobType: 'email',
1866
+ payload: { to: 'once@example.com' },
1867
+ idempotencyKey: 'event-dedup-key',
1868
+ },
1869
+ );
1870
+ // Add again with same key
1871
+ await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
1872
+ jobType: 'email',
1873
+ payload: { to: 'twice@example.com' },
1874
+ idempotencyKey: 'event-dedup-key',
1875
+ });
1876
+
1877
+ const events = await queue.getJobEvents(pool, jobId1);
1878
+ const addedEvents = events.filter(
1879
+ (e: JobEvent) => e.eventType === JobEventType.Added,
1880
+ );
1881
+ expect(addedEvents.length).toBe(1);
1882
+ });
1883
+
1884
+ it('should return null idempotencyKey for jobs created without one', async () => {
1885
+ const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
1886
+ jobType: 'email',
1887
+ payload: { to: 'nokey@example.com' },
1888
+ });
1889
+ const job = await queue.getJob(pool, jobId);
1890
+ expect(job).not.toBeNull();
1891
+ expect(job?.idempotencyKey).toBeNull();
1892
+ });
1893
+
1894
+ it('should permanently fail a job when max attempts are exhausted', async () => {
1895
+ const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
1896
+ jobType: 'email',
1897
+ payload: { to: 'exhaust@example.com' },
1898
+ maxAttempts: 2,
1899
+ });
1900
+
1901
+ // Claim the job (attempt 1)
1902
+ const batch1 = await queue.getNextBatch(pool, 'worker-1', 1);
1903
+ expect(batch1.length).toBe(1);
1904
+ expect(batch1[0].attempts).toBe(1);
1905
+
1906
+ // Fail it
1907
+ await queue.failJob(pool, jobId, new Error('attempt 1 failed'));
1908
+ let job = await queue.getJob(pool, jobId);
1909
+ expect(job?.status).toBe('failed');
1910
+ expect(job?.nextAttemptAt).not.toBeNull(); // Should have a retry scheduled
1911
+
1912
+ // Wait a moment so next_attempt_at <= NOW() and claim again (attempt 2)
1913
+ await pool.query(
1914
+ `UPDATE job_queue SET next_attempt_at = NOW() WHERE id = $1`,
1915
+ [jobId],
1916
+ );
1917
+ const batch2 = await queue.getNextBatch(pool, 'worker-1', 1);
1918
+ expect(batch2.length).toBe(1);
1919
+ expect(batch2[0].attempts).toBe(2);
1920
+
1921
+ // Fail it again — now attempts === maxAttempts
1922
+ await queue.failJob(pool, jobId, new Error('attempt 2 failed'));
1923
+ job = await queue.getJob(pool, jobId);
1924
+ expect(job?.status).toBe('failed');
1925
+ expect(job?.nextAttemptAt).toBeNull(); // No more retries
1926
+ expect(job?.errorHistory?.length).toBe(2);
1927
+
1928
+ // Should NOT be picked up again
1929
+ const batch3 = await queue.getNextBatch(pool, 'worker-1', 1);
1930
+ expect(batch3.length).toBe(0);
1931
+ });
1199
1932
  });