@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.
- package/README.md +44 -0
- package/dist/index.cjs +2822 -583
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +589 -12
- package/dist/index.d.ts +589 -12
- package/dist/index.js +2818 -584
- 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 +6 -0
- 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/handler-validation.test.ts +414 -0
- package/src/handler-validation.ts +168 -0
- package/src/index.test.ts +230 -1
- package/src/index.ts +128 -32
- package/src/processor.test.ts +612 -16
- package/src/processor.ts +759 -47
- package/src/queue.test.ts +736 -3
- package/src/queue.ts +346 -660
- package/src/test-util.ts +32 -0
- package/src/types.ts +451 -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,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
|
|
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
|
-
//
|
|
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
|
});
|