@nicnocquee/dataqueue 1.16.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/LICENSE +21 -0
- package/cli.cjs +38 -0
- package/dist/index.cjs +663 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +227 -0
- package/dist/index.d.ts +227 -0
- package/dist/index.js +659 -0
- package/dist/index.js.map +1 -0
- package/migrations/1751131910823_initial.sql +32 -0
- package/migrations/1751131910825_add_timeout_seconds_to_job_queue.sql +7 -0
- package/migrations/1751186053000_add_job_events_table.sql +29 -0
- package/package.json +67 -0
- package/src/db-util.ts +7 -0
- package/src/index.test.ts +282 -0
- package/src/index.ts +97 -0
- package/src/log-context.ts +20 -0
- package/src/processor.test.ts +478 -0
- package/src/processor.ts +242 -0
- package/src/queue.test.ts +502 -0
- package/src/queue.ts +547 -0
- package/src/test-util.ts +56 -0
- package/src/types.ts +247 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import { Pool } from 'pg';
|
|
2
|
+
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
import * as queue from './queue.js';
|
|
4
|
+
import { createTestDbAndPool, destroyTestDb } from './test-util.js';
|
|
5
|
+
import { JobEventType } from './types.js';
|
|
6
|
+
|
|
7
|
+
// Example integration test setup
|
|
8
|
+
|
|
9
|
+
describe('queue integration', () => {
|
|
10
|
+
let pool: Pool;
|
|
11
|
+
let dbName: string;
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
const setup = await createTestDbAndPool();
|
|
15
|
+
pool = setup.pool;
|
|
16
|
+
dbName = setup.dbName;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
await pool.end();
|
|
21
|
+
await destroyTestDb(dbName);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should add a job and retrieve it', async () => {
|
|
25
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
26
|
+
job_type: 'email',
|
|
27
|
+
payload: { to: 'test@example.com' },
|
|
28
|
+
});
|
|
29
|
+
expect(typeof jobId).toBe('number');
|
|
30
|
+
const job = await queue.getJob(pool, jobId);
|
|
31
|
+
expect(job).not.toBeNull();
|
|
32
|
+
expect(job?.job_type).toBe('email');
|
|
33
|
+
expect(job?.payload).toEqual({ to: 'test@example.com' });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should get jobs by status', async () => {
|
|
37
|
+
// Add two jobs
|
|
38
|
+
const jobId1 = await queue.addJob<{ email: { to: string } }, 'email'>(
|
|
39
|
+
pool,
|
|
40
|
+
{
|
|
41
|
+
job_type: 'email',
|
|
42
|
+
payload: { to: 'a@example.com' },
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
const jobId2 = await queue.addJob<{ sms: { to: string } }, 'sms'>(pool, {
|
|
46
|
+
job_type: 'sms',
|
|
47
|
+
payload: { to: 'b@example.com' },
|
|
48
|
+
});
|
|
49
|
+
// All jobs should be 'pending' by default
|
|
50
|
+
const jobs = await queue.getJobsByStatus(pool, 'pending');
|
|
51
|
+
const ids = jobs.map((j) => j.id);
|
|
52
|
+
expect(ids).toContain(jobId1);
|
|
53
|
+
expect(ids).toContain(jobId2);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should retry a failed job', async () => {
|
|
57
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
58
|
+
job_type: 'email',
|
|
59
|
+
payload: { to: 'fail@example.com' },
|
|
60
|
+
});
|
|
61
|
+
// Mark as failed
|
|
62
|
+
await queue.failJob(pool, jobId, new Error('fail reason'));
|
|
63
|
+
let job = await queue.getJob(pool, jobId);
|
|
64
|
+
expect(job?.status).toBe('failed');
|
|
65
|
+
// Retry
|
|
66
|
+
await queue.retryJob(pool, jobId);
|
|
67
|
+
job = await queue.getJob(pool, jobId);
|
|
68
|
+
expect(job?.status).toBe('pending');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should mark a job as completed', async () => {
|
|
72
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
73
|
+
job_type: 'email',
|
|
74
|
+
payload: { to: 'done@example.com' },
|
|
75
|
+
});
|
|
76
|
+
await queue.completeJob(pool, jobId);
|
|
77
|
+
const job = await queue.getJob(pool, jobId);
|
|
78
|
+
expect(job?.status).toBe('completed');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should get the next batch of jobs to process', async () => {
|
|
82
|
+
// Add jobs (do not set run_at, use DB default)
|
|
83
|
+
const jobId1 = await queue.addJob<{ email: { to: string } }, 'email'>(
|
|
84
|
+
pool,
|
|
85
|
+
{
|
|
86
|
+
job_type: 'email',
|
|
87
|
+
payload: { to: 'batch1@example.com' },
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
const jobId2 = await queue.addJob<{ email: { to: string } }, 'email'>(
|
|
91
|
+
pool,
|
|
92
|
+
{
|
|
93
|
+
job_type: 'email',
|
|
94
|
+
payload: { to: 'batch2@example.com' },
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
const jobs = await queue.getNextBatch(pool, 'worker-1', 2);
|
|
98
|
+
const ids = jobs.map((j) => j.id);
|
|
99
|
+
expect(ids).toContain(jobId1);
|
|
100
|
+
expect(ids).toContain(jobId2);
|
|
101
|
+
// They should now be 'processing'
|
|
102
|
+
const job1 = await queue.getJob(pool, jobId1);
|
|
103
|
+
expect(job1?.status).toBe('processing');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should not pick up jobs scheduled in the future', async () => {
|
|
107
|
+
// Add a job scheduled 1 day in the future
|
|
108
|
+
const futureDate = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
|
109
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
110
|
+
job_type: 'email',
|
|
111
|
+
payload: { to: 'future@example.com' },
|
|
112
|
+
run_at: futureDate,
|
|
113
|
+
});
|
|
114
|
+
const jobs = await queue.getNextBatch(pool, 'worker-2', 1);
|
|
115
|
+
const ids = jobs.map((j) => j.id);
|
|
116
|
+
expect(ids).not.toContain(jobId);
|
|
117
|
+
// The job should still be pending
|
|
118
|
+
const job = await queue.getJob(pool, jobId);
|
|
119
|
+
expect(job?.status).toBe('pending');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should cleanup old completed jobs', async () => {
|
|
123
|
+
// Add and complete a job
|
|
124
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
125
|
+
job_type: 'email',
|
|
126
|
+
payload: { to: 'cleanup@example.com' },
|
|
127
|
+
});
|
|
128
|
+
await queue.completeJob(pool, jobId);
|
|
129
|
+
// Manually update updated_at to be old
|
|
130
|
+
await pool.query(
|
|
131
|
+
`UPDATE job_queue SET updated_at = NOW() - INTERVAL '31 days' WHERE id = $1`,
|
|
132
|
+
[jobId],
|
|
133
|
+
);
|
|
134
|
+
// Cleanup jobs older than 30 days
|
|
135
|
+
const deleted = await queue.cleanupOldJobs(pool, 30);
|
|
136
|
+
expect(deleted).toBeGreaterThanOrEqual(1);
|
|
137
|
+
const job = await queue.getJob(pool, jobId);
|
|
138
|
+
expect(job).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should cancel a scheduled job', async () => {
|
|
142
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
143
|
+
job_type: 'email',
|
|
144
|
+
payload: { to: 'cancelme@example.com' },
|
|
145
|
+
});
|
|
146
|
+
await queue.cancelJob(pool, jobId);
|
|
147
|
+
const job = await queue.getJob(pool, jobId);
|
|
148
|
+
expect(job?.status).toBe('cancelled');
|
|
149
|
+
|
|
150
|
+
// Try to cancel a job that is already completed
|
|
151
|
+
const jobId2 = await queue.addJob<{ email: { to: string } }, 'email'>(
|
|
152
|
+
pool,
|
|
153
|
+
{
|
|
154
|
+
job_type: 'email',
|
|
155
|
+
payload: { to: 'done@example.com' },
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
await queue.completeJob(pool, jobId2);
|
|
159
|
+
await queue.cancelJob(pool, jobId2);
|
|
160
|
+
const completedJob = await queue.getJob(pool, jobId2);
|
|
161
|
+
expect(completedJob?.status).toBe('completed');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should cancel all upcoming jobs', async () => {
|
|
165
|
+
// Add three pending jobs
|
|
166
|
+
const jobId1 = await queue.addJob<{ email: { to: string } }, 'email'>(
|
|
167
|
+
pool,
|
|
168
|
+
{
|
|
169
|
+
job_type: 'email',
|
|
170
|
+
payload: { to: 'cancelall1@example.com' },
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
const jobId2 = await queue.addJob<{ email: { to: string } }, 'email'>(
|
|
174
|
+
pool,
|
|
175
|
+
{
|
|
176
|
+
job_type: 'email',
|
|
177
|
+
payload: { to: 'cancelall2@example.com' },
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
const jobId3 = await queue.addJob<{ email: { to: string } }, 'email'>(
|
|
181
|
+
pool,
|
|
182
|
+
{
|
|
183
|
+
job_type: 'email',
|
|
184
|
+
payload: { to: 'cancelall3@example.com' },
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
// Add a completed job
|
|
188
|
+
const jobId4 = await queue.addJob<{ email: { to: string } }, 'email'>(
|
|
189
|
+
pool,
|
|
190
|
+
{
|
|
191
|
+
job_type: 'email',
|
|
192
|
+
payload: { to: 'done@example.com' },
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
await queue.completeJob(pool, jobId4);
|
|
196
|
+
|
|
197
|
+
// Cancel all upcoming jobs
|
|
198
|
+
const cancelledCount = await queue.cancelAllUpcomingJobs(pool);
|
|
199
|
+
expect(cancelledCount).toBeGreaterThanOrEqual(3);
|
|
200
|
+
|
|
201
|
+
// Check that all pending jobs are now cancelled
|
|
202
|
+
const job1 = await queue.getJob(pool, jobId1);
|
|
203
|
+
const job2 = await queue.getJob(pool, jobId2);
|
|
204
|
+
const job3 = await queue.getJob(pool, jobId3);
|
|
205
|
+
expect(job1?.status).toBe('cancelled');
|
|
206
|
+
expect(job2?.status).toBe('cancelled');
|
|
207
|
+
expect(job3?.status).toBe('cancelled');
|
|
208
|
+
|
|
209
|
+
// Completed job should remain completed
|
|
210
|
+
const completedJob = await queue.getJob(pool, jobId4);
|
|
211
|
+
expect(completedJob?.status).toBe('completed');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should store and retrieve run_at in UTC without timezone shift', async () => {
|
|
215
|
+
const utcDate = new Date(Date.UTC(2030, 0, 1, 12, 0, 0, 0)); // 2030-01-01T12:00:00.000Z
|
|
216
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
217
|
+
job_type: 'email',
|
|
218
|
+
payload: { to: 'utc@example.com' },
|
|
219
|
+
run_at: utcDate,
|
|
220
|
+
});
|
|
221
|
+
const job = await queue.getJob(pool, jobId);
|
|
222
|
+
expect(job).not.toBeNull();
|
|
223
|
+
// The run_at value should match exactly (toISOString) what we inserted
|
|
224
|
+
expect(job?.run_at.toISOString()).toBe(utcDate.toISOString());
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('should get all jobs', async () => {
|
|
228
|
+
// Add three jobs
|
|
229
|
+
const jobId1 = await queue.addJob<{ email: { to: string } }, 'email'>(
|
|
230
|
+
pool,
|
|
231
|
+
{
|
|
232
|
+
job_type: 'email',
|
|
233
|
+
payload: { to: 'all1@example.com' },
|
|
234
|
+
},
|
|
235
|
+
);
|
|
236
|
+
const jobId2 = await queue.addJob<{ sms: { to: string } }, 'sms'>(pool, {
|
|
237
|
+
job_type: 'sms',
|
|
238
|
+
payload: { to: 'all2@example.com' },
|
|
239
|
+
});
|
|
240
|
+
const jobId3 = await queue.addJob<{ push: { to: string } }, 'push'>(pool, {
|
|
241
|
+
job_type: 'push',
|
|
242
|
+
payload: { to: 'all3@example.com' },
|
|
243
|
+
});
|
|
244
|
+
// Get all jobs
|
|
245
|
+
const jobs = await queue.getAllJobs(pool);
|
|
246
|
+
const ids = jobs.map((j) => j.id);
|
|
247
|
+
expect(ids).toContain(jobId1);
|
|
248
|
+
expect(ids).toContain(jobId2);
|
|
249
|
+
expect(ids).toContain(jobId3);
|
|
250
|
+
// Should return correct job data
|
|
251
|
+
const job1 = jobs.find((j) => j.id === jobId1);
|
|
252
|
+
expect(job1?.job_type).toBe('email');
|
|
253
|
+
expect(job1?.payload).toEqual({ to: 'all1@example.com' });
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('should support pagination in getAllJobs', async () => {
|
|
257
|
+
// Add four jobs
|
|
258
|
+
await queue.addJob<{ a: { n: number } }, 'a'>(pool, {
|
|
259
|
+
job_type: 'a',
|
|
260
|
+
payload: { n: 1 },
|
|
261
|
+
});
|
|
262
|
+
await queue.addJob<{ b: { n: number } }, 'b'>(pool, {
|
|
263
|
+
job_type: 'b',
|
|
264
|
+
payload: { n: 2 },
|
|
265
|
+
});
|
|
266
|
+
await queue.addJob<{ c: { n: number } }, 'c'>(pool, {
|
|
267
|
+
job_type: 'c',
|
|
268
|
+
payload: { n: 3 },
|
|
269
|
+
});
|
|
270
|
+
await queue.addJob<{ d: { n: number } }, 'd'>(pool, {
|
|
271
|
+
job_type: 'd',
|
|
272
|
+
payload: { n: 4 },
|
|
273
|
+
});
|
|
274
|
+
// Get first two jobs
|
|
275
|
+
const firstTwo = await queue.getAllJobs(pool, 2, 0);
|
|
276
|
+
expect(firstTwo.length).toBe(2);
|
|
277
|
+
// Get next two jobs
|
|
278
|
+
const nextTwo = await queue.getAllJobs(pool, 2, 2);
|
|
279
|
+
expect(nextTwo.length).toBe(2);
|
|
280
|
+
// No overlap in IDs
|
|
281
|
+
const firstIds = firstTwo.map((j) => j.id);
|
|
282
|
+
const nextIds = nextTwo.map((j) => j.id);
|
|
283
|
+
expect(firstIds.some((id) => nextIds.includes(id))).toBe(false);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('should track error history for failed jobs', async () => {
|
|
287
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
288
|
+
job_type: 'email',
|
|
289
|
+
payload: { to: 'failhistory@example.com' },
|
|
290
|
+
});
|
|
291
|
+
// Fail the job twice with different errors
|
|
292
|
+
await queue.failJob(pool, jobId, new Error('first error'));
|
|
293
|
+
await queue.failJob(pool, jobId, new Error('second error'));
|
|
294
|
+
const job = await queue.getJob(pool, jobId);
|
|
295
|
+
expect(job?.status).toBe('failed');
|
|
296
|
+
expect(Array.isArray(job?.error_history)).toBe(true);
|
|
297
|
+
expect(job?.error_history?.length).toBeGreaterThanOrEqual(2);
|
|
298
|
+
expect(job?.error_history?.[0].message).toBe('first error');
|
|
299
|
+
expect(job?.error_history?.[1].message).toBe('second error');
|
|
300
|
+
expect(typeof job?.error_history?.[0].timestamp).toBe('string');
|
|
301
|
+
expect(typeof job?.error_history?.[1].timestamp).toBe('string');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('should reclaim stuck processing jobs', async () => {
|
|
305
|
+
// Add a job and set it to processing with an old locked_at
|
|
306
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
307
|
+
job_type: 'email',
|
|
308
|
+
payload: { to: 'stuck@example.com' },
|
|
309
|
+
});
|
|
310
|
+
await pool.query(
|
|
311
|
+
`UPDATE job_queue SET status = 'processing', locked_at = NOW() - INTERVAL '15 minutes' WHERE id = $1`,
|
|
312
|
+
[jobId],
|
|
313
|
+
);
|
|
314
|
+
// Should be processing and locked_at is old
|
|
315
|
+
let job = await queue.getJob(pool, jobId);
|
|
316
|
+
expect(job?.status).toBe('processing');
|
|
317
|
+
// Reclaim stuck jobs (threshold 10 minutes)
|
|
318
|
+
const reclaimed = await queue.reclaimStuckJobs(pool, 10);
|
|
319
|
+
expect(reclaimed).toBeGreaterThanOrEqual(1);
|
|
320
|
+
job = await queue.getJob(pool, jobId);
|
|
321
|
+
expect(job?.status).toBe('pending');
|
|
322
|
+
expect(job?.locked_at).toBeNull();
|
|
323
|
+
expect(job?.locked_by).toBeNull();
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
describe('job event tracking', () => {
|
|
328
|
+
let pool: Pool;
|
|
329
|
+
let dbName: string;
|
|
330
|
+
|
|
331
|
+
beforeEach(async () => {
|
|
332
|
+
const setup = await createTestDbAndPool();
|
|
333
|
+
pool = setup.pool;
|
|
334
|
+
dbName = setup.dbName;
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
afterEach(async () => {
|
|
338
|
+
await pool.end();
|
|
339
|
+
await destroyTestDb(dbName);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
async function getEvents(jobId: number) {
|
|
343
|
+
const res = await pool.query(
|
|
344
|
+
'SELECT * FROM job_events WHERE job_id = $1 ORDER BY created_at ASC',
|
|
345
|
+
[jobId],
|
|
346
|
+
);
|
|
347
|
+
return res.rows;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
it('records added and processing events', async () => {
|
|
351
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
352
|
+
job_type: 'email',
|
|
353
|
+
payload: { to: 'event1@example.com' },
|
|
354
|
+
});
|
|
355
|
+
// Pick up for processing
|
|
356
|
+
await queue.getNextBatch(pool, 'worker-evt', 1);
|
|
357
|
+
const events = await getEvents(jobId);
|
|
358
|
+
expect(events.map((e) => e.event_type)).toEqual([
|
|
359
|
+
JobEventType.Added,
|
|
360
|
+
JobEventType.Processing,
|
|
361
|
+
]);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('records completed event', async () => {
|
|
365
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
366
|
+
job_type: 'email',
|
|
367
|
+
payload: { to: 'event2@example.com' },
|
|
368
|
+
});
|
|
369
|
+
await queue.getNextBatch(pool, 'worker-evt', 1);
|
|
370
|
+
await queue.completeJob(pool, jobId);
|
|
371
|
+
const events = await getEvents(jobId);
|
|
372
|
+
expect(events.map((e) => e.event_type)).toContain(JobEventType.Completed);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('records failed and retried events', async () => {
|
|
376
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
377
|
+
job_type: 'email',
|
|
378
|
+
payload: { to: 'event3@example.com' },
|
|
379
|
+
});
|
|
380
|
+
await queue.getNextBatch(pool, 'worker-evt', 1);
|
|
381
|
+
await queue.failJob(pool, jobId, new Error('fail for event'));
|
|
382
|
+
await queue.retryJob(pool, jobId);
|
|
383
|
+
const events = await getEvents(jobId);
|
|
384
|
+
expect(events.map((e) => e.event_type)).toEqual(
|
|
385
|
+
expect.arrayContaining([JobEventType.Failed, JobEventType.Retried]),
|
|
386
|
+
);
|
|
387
|
+
const failEvent = events.find((e) => e.event_type === JobEventType.Failed);
|
|
388
|
+
expect(failEvent.metadata).toMatchObject({ message: 'fail for event' });
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('records cancelled event', async () => {
|
|
392
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
393
|
+
job_type: 'email',
|
|
394
|
+
payload: { to: 'event4@example.com' },
|
|
395
|
+
});
|
|
396
|
+
await queue.cancelJob(pool, jobId);
|
|
397
|
+
const events = await getEvents(jobId);
|
|
398
|
+
expect(events.map((e) => e.event_type)).toContain(JobEventType.Cancelled);
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
describe('job lifecycle timestamp columns', () => {
|
|
403
|
+
let pool: Pool;
|
|
404
|
+
let dbName: string;
|
|
405
|
+
|
|
406
|
+
beforeEach(async () => {
|
|
407
|
+
const setup = await createTestDbAndPool();
|
|
408
|
+
pool = setup.pool;
|
|
409
|
+
dbName = setup.dbName;
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
afterEach(async () => {
|
|
413
|
+
await pool.end();
|
|
414
|
+
await destroyTestDb(dbName);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
async function getJobRow(jobId: number) {
|
|
418
|
+
const res = await pool.query('SELECT * FROM job_queue WHERE id = $1', [
|
|
419
|
+
jobId,
|
|
420
|
+
]);
|
|
421
|
+
return res.rows[0];
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
it('sets started_at when job is picked up for processing', async () => {
|
|
425
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
426
|
+
job_type: 'email',
|
|
427
|
+
payload: { to: 'ts1@example.com' },
|
|
428
|
+
});
|
|
429
|
+
await queue.getNextBatch(pool, 'worker-ts', 1);
|
|
430
|
+
const job = await getJobRow(jobId);
|
|
431
|
+
expect(job.started_at).not.toBeNull();
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('sets completed_at when job is completed', async () => {
|
|
435
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
436
|
+
job_type: 'email',
|
|
437
|
+
payload: { to: 'ts2@example.com' },
|
|
438
|
+
});
|
|
439
|
+
await queue.getNextBatch(pool, 'worker-ts', 1);
|
|
440
|
+
await queue.completeJob(pool, jobId);
|
|
441
|
+
const job = await getJobRow(jobId);
|
|
442
|
+
expect(job.completed_at).not.toBeNull();
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('sets last_failed_at when job fails', async () => {
|
|
446
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
447
|
+
job_type: 'email',
|
|
448
|
+
payload: { to: 'ts3@example.com' },
|
|
449
|
+
});
|
|
450
|
+
await queue.getNextBatch(pool, 'worker-ts', 1);
|
|
451
|
+
await queue.failJob(pool, jobId, new Error('fail for ts'));
|
|
452
|
+
const job = await getJobRow(jobId);
|
|
453
|
+
expect(job.last_failed_at).not.toBeNull();
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('sets last_retried_at when job is retried', async () => {
|
|
457
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
458
|
+
job_type: 'email',
|
|
459
|
+
payload: { to: 'ts4@example.com' },
|
|
460
|
+
});
|
|
461
|
+
await queue.getNextBatch(pool, 'worker-ts', 1);
|
|
462
|
+
await queue.failJob(pool, jobId, new Error('fail for ts retry'));
|
|
463
|
+
// Make the job eligible for retry immediately
|
|
464
|
+
await pool.query(
|
|
465
|
+
'UPDATE job_queue SET next_attempt_at = NOW() WHERE id = $1',
|
|
466
|
+
[jobId],
|
|
467
|
+
);
|
|
468
|
+
// Pick up for processing again (should increment attempts and set last_retried_at)
|
|
469
|
+
await queue.getNextBatch(pool, 'worker-ts', 1);
|
|
470
|
+
const job = await getJobRow(jobId);
|
|
471
|
+
expect(job.last_retried_at).not.toBeNull();
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('sets last_cancelled_at when job is cancelled', async () => {
|
|
475
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
476
|
+
job_type: 'email',
|
|
477
|
+
payload: { to: 'ts5@example.com' },
|
|
478
|
+
});
|
|
479
|
+
await queue.cancelJob(pool, jobId);
|
|
480
|
+
const job = await getJobRow(jobId);
|
|
481
|
+
expect(job.last_cancelled_at).not.toBeNull();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it('sets last_retried_at when job is picked up for processing again (attempts > 0)', async () => {
|
|
485
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
486
|
+
job_type: 'email',
|
|
487
|
+
payload: { to: 'ts6@example.com' },
|
|
488
|
+
});
|
|
489
|
+
// First pick up and fail the job
|
|
490
|
+
await queue.getNextBatch(pool, 'worker-ts', 1);
|
|
491
|
+
await queue.failJob(pool, jobId, new Error('fail for ts retry'));
|
|
492
|
+
// Make the job eligible for retry immediately
|
|
493
|
+
await pool.query(
|
|
494
|
+
'UPDATE job_queue SET next_attempt_at = NOW() WHERE id = $1',
|
|
495
|
+
[jobId],
|
|
496
|
+
);
|
|
497
|
+
// Pick up for processing again (should increment attempts and set last_retried_at)
|
|
498
|
+
await queue.getNextBatch(pool, 'worker-ts', 1);
|
|
499
|
+
const job = await getJobRow(jobId);
|
|
500
|
+
expect(job.last_retried_at).not.toBeNull();
|
|
501
|
+
});
|
|
502
|
+
});
|