@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.
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,6 +158,7 @@ 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);
@@ -229,6 +234,7 @@ describe('queue integration', () => {
229
234
  jobType: 'email',
230
235
  payload: { to: 'original@example.com' },
231
236
  });
237
+ await queue.getNextBatch(pool, 'worker-edit-noop', 1);
232
238
  await queue.completeJob(pool, jobId);
233
239
 
234
240
  // Try to edit a completed job - should silently fail
@@ -367,7 +373,7 @@ describe('queue integration', () => {
367
373
  priority: 0,
368
374
  },
369
375
  );
370
- // Add a completed job
376
+ // Add a completed job (set via SQL since this test is about edit behavior)
371
377
  const jobId4 = await queue.addJob<{ email: { to: string } }, 'email'>(
372
378
  pool,
373
379
  {
@@ -375,7 +381,10 @@ describe('queue integration', () => {
375
381
  payload: { to: 'done@example.com' },
376
382
  },
377
383
  );
378
- await queue.completeJob(pool, jobId4);
384
+ await pool.query(
385
+ `UPDATE job_queue SET status = 'completed' WHERE id = $1`,
386
+ [jobId4],
387
+ );
379
388
 
380
389
  // Edit all pending jobs
381
390
  const editedCount = await queue.editAllPendingJobs<
@@ -615,7 +624,11 @@ describe('queue integration', () => {
615
624
  payload: { to: 'completed@example.com' },
616
625
  priority: 0,
617
626
  });
618
- await queue.completeJob(pool, completedJobId);
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
+ );
619
632
 
620
633
  // Edit all pending jobs
621
634
  const editedCount = await queue.editAllPendingJobs<
@@ -705,7 +718,7 @@ describe('queue integration', () => {
705
718
  payload: { to: 'cancelall3@example.com' },
706
719
  },
707
720
  );
708
- // Add a completed job
721
+ // Add a completed job (set via SQL since this test is about cancel behavior)
709
722
  const jobId4 = await queue.addJob<{ email: { to: string } }, 'email'>(
710
723
  pool,
711
724
  {
@@ -713,7 +726,10 @@ describe('queue integration', () => {
713
726
  payload: { to: 'done@example.com' },
714
727
  },
715
728
  );
716
- await queue.completeJob(pool, jobId4);
729
+ await pool.query(
730
+ `UPDATE job_queue SET status = 'completed' WHERE id = $1`,
731
+ [jobId4],
732
+ );
717
733
 
718
734
  // Cancel all upcoming jobs
719
735
  const cancelledCount = await queue.cancelAllUpcomingJobs(pool);
@@ -809,8 +825,12 @@ describe('queue integration', () => {
809
825
  jobType: 'email',
810
826
  payload: { to: 'failhistory@example.com' },
811
827
  });
812
- // Fail the job twice with different errors
828
+ // Claim and fail the job (first error)
829
+ await queue.getNextBatch(pool, 'worker-fail-1', 1);
813
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);
814
834
  await queue.failJob(pool, jobId, new Error('second error'));
815
835
  const job = await queue.getJob(pool, jobId);
816
836
  expect(job?.status).toBe('failed');
@@ -843,6 +863,50 @@ describe('queue integration', () => {
843
863
  expect(job?.lockedAt).toBeNull();
844
864
  expect(job?.lockedBy).toBeNull();
845
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
+ });
846
910
  });
847
911
 
848
912
  describe('job event tracking', () => {
@@ -1718,4 +1782,151 @@ describe('getJobs', () => {
1718
1782
  expect(jobs.map((j) => j.id)).toContain(id1);
1719
1783
  expect(jobs.map((j) => j.id)).not.toContain(id2);
1720
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
+ });
1721
1932
  });