@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/README.md +44 -0
- package/dist/index.cjs +2754 -972
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +440 -12
- package/dist/index.d.ts +440 -12
- package/dist/index.js +2752 -973
- package/dist/index.js.map +1 -1
- package/migrations/1751131910825_add_timeout_seconds_to_job_queue.sql +2 -2
- package/migrations/1751186053000_add_job_events_table.sql +12 -8
- package/migrations/1751984773000_add_tags_to_job_queue.sql +1 -1
- package/migrations/1765809419000_add_force_kill_on_timeout_to_job_queue.sql +1 -1
- package/migrations/1771100000000_add_idempotency_key_to_job_queue.sql +7 -0
- package/migrations/1781200000000_add_wait_support.sql +12 -0
- package/migrations/1781200000001_create_waitpoints_table.sql +18 -0
- package/migrations/1781200000002_add_performance_indexes.sql +34 -0
- package/migrations/1781200000003_add_progress_to_job_queue.sql +7 -0
- package/package.json +20 -6
- package/src/backend.ts +163 -0
- package/src/backends/postgres.ts +1111 -0
- package/src/backends/redis-scripts.ts +533 -0
- package/src/backends/redis.test.ts +543 -0
- package/src/backends/redis.ts +834 -0
- package/src/db-util.ts +4 -2
- package/src/index.test.ts +6 -1
- package/src/index.ts +99 -36
- package/src/processor.test.ts +559 -18
- package/src/processor.ts +512 -44
- package/src/queue.test.ts +217 -6
- package/src/queue.ts +311 -902
- package/src/test-util.ts +32 -0
- package/src/types.ts +349 -16
- 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,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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
});
|