@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
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { initJobQueue } from '../index.js';
|
|
3
|
+
import { createRedisTestPrefix, cleanupRedisPrefix } from '../test-util.js';
|
|
4
|
+
import type { RedisJobQueueConfig } from '../types.js';
|
|
5
|
+
|
|
6
|
+
interface TestPayloadMap {
|
|
7
|
+
email: { to: string };
|
|
8
|
+
sms: { to: string };
|
|
9
|
+
test: { foo: string };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const REDIS_URL = process.env.REDIS_TEST_URL || 'redis://localhost:6379';
|
|
13
|
+
|
|
14
|
+
describe('Redis backend integration', () => {
|
|
15
|
+
let prefix: string;
|
|
16
|
+
let jobQueue: ReturnType<typeof initJobQueue<TestPayloadMap>>;
|
|
17
|
+
let redisClient: any;
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
prefix = createRedisTestPrefix();
|
|
21
|
+
const config: RedisJobQueueConfig = {
|
|
22
|
+
backend: 'redis',
|
|
23
|
+
redisConfig: {
|
|
24
|
+
url: REDIS_URL,
|
|
25
|
+
keyPrefix: prefix,
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
jobQueue = initJobQueue<TestPayloadMap>(config);
|
|
29
|
+
redisClient = jobQueue.getRedisClient();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(async () => {
|
|
33
|
+
await cleanupRedisPrefix(redisClient, prefix);
|
|
34
|
+
await redisClient.quit();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should add a job and retrieve it', async () => {
|
|
38
|
+
const jobId = await jobQueue.addJob({
|
|
39
|
+
jobType: 'email',
|
|
40
|
+
payload: { to: 'test@example.com' },
|
|
41
|
+
});
|
|
42
|
+
expect(typeof jobId).toBe('number');
|
|
43
|
+
const job = await jobQueue.getJob(jobId);
|
|
44
|
+
expect(job).not.toBeNull();
|
|
45
|
+
expect(job?.jobType).toBe('email');
|
|
46
|
+
expect(job?.payload).toEqual({ to: 'test@example.com' });
|
|
47
|
+
expect(job?.status).toBe('pending');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should get jobs by status', async () => {
|
|
51
|
+
const jobId1 = await jobQueue.addJob({
|
|
52
|
+
jobType: 'email',
|
|
53
|
+
payload: { to: 'a@example.com' },
|
|
54
|
+
});
|
|
55
|
+
const jobId2 = await jobQueue.addJob({
|
|
56
|
+
jobType: 'sms',
|
|
57
|
+
payload: { to: 'b@example.com' },
|
|
58
|
+
});
|
|
59
|
+
const jobs = await jobQueue.getJobsByStatus('pending');
|
|
60
|
+
const ids = jobs.map((j) => j.id);
|
|
61
|
+
expect(ids).toContain(jobId1);
|
|
62
|
+
expect(ids).toContain(jobId2);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should get all jobs', async () => {
|
|
66
|
+
await jobQueue.addJob({ jobType: 'email', payload: { to: 'a@b.com' } });
|
|
67
|
+
await jobQueue.addJob({ jobType: 'sms', payload: { to: 'c@d.com' } });
|
|
68
|
+
const jobs = await jobQueue.getAllJobs();
|
|
69
|
+
expect(jobs.length).toBe(2);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should process a job with a registered handler', async () => {
|
|
73
|
+
const handler = vi.fn(async (_payload: any, _signal: any) => {});
|
|
74
|
+
const jobId = await jobQueue.addJob({
|
|
75
|
+
jobType: 'test',
|
|
76
|
+
payload: { foo: 'bar' },
|
|
77
|
+
});
|
|
78
|
+
const processor = jobQueue.createProcessor(
|
|
79
|
+
{
|
|
80
|
+
email: vi.fn(async () => {}),
|
|
81
|
+
sms: vi.fn(async () => {}),
|
|
82
|
+
test: handler,
|
|
83
|
+
},
|
|
84
|
+
{ pollInterval: 100 },
|
|
85
|
+
);
|
|
86
|
+
await processor.start();
|
|
87
|
+
expect(handler).toHaveBeenCalledWith(
|
|
88
|
+
{ foo: 'bar' },
|
|
89
|
+
expect.any(Object),
|
|
90
|
+
expect.any(Object),
|
|
91
|
+
);
|
|
92
|
+
const job = await jobQueue.getJob(jobId);
|
|
93
|
+
expect(job?.status).toBe('completed');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should retry a failed job', async () => {
|
|
97
|
+
const jobId = await jobQueue.addJob({
|
|
98
|
+
jobType: 'email',
|
|
99
|
+
payload: { to: 'fail@example.com' },
|
|
100
|
+
});
|
|
101
|
+
// Use a handler that fails
|
|
102
|
+
const processor = jobQueue.createProcessor(
|
|
103
|
+
{
|
|
104
|
+
email: async () => {
|
|
105
|
+
throw new Error('boom');
|
|
106
|
+
},
|
|
107
|
+
sms: vi.fn(async () => {}),
|
|
108
|
+
test: vi.fn(async () => {}),
|
|
109
|
+
},
|
|
110
|
+
{ pollInterval: 100 },
|
|
111
|
+
);
|
|
112
|
+
await processor.start();
|
|
113
|
+
let job = await jobQueue.getJob(jobId);
|
|
114
|
+
expect(job?.status).toBe('failed');
|
|
115
|
+
|
|
116
|
+
await jobQueue.retryJob(jobId);
|
|
117
|
+
job = await jobQueue.getJob(jobId);
|
|
118
|
+
expect(job?.status).toBe('pending');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should cancel a pending job', async () => {
|
|
122
|
+
const jobId = await jobQueue.addJob({
|
|
123
|
+
jobType: 'email',
|
|
124
|
+
payload: { to: 'cancelme@example.com' },
|
|
125
|
+
});
|
|
126
|
+
await jobQueue.cancelJob(jobId);
|
|
127
|
+
const job = await jobQueue.getJob(jobId);
|
|
128
|
+
expect(job?.status).toBe('cancelled');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should not cancel a non-pending job', async () => {
|
|
132
|
+
const jobId = await jobQueue.addJob({
|
|
133
|
+
jobType: 'test',
|
|
134
|
+
payload: { foo: 'done' },
|
|
135
|
+
});
|
|
136
|
+
// Process it first
|
|
137
|
+
const processor = jobQueue.createProcessor(
|
|
138
|
+
{
|
|
139
|
+
email: vi.fn(async () => {}),
|
|
140
|
+
sms: vi.fn(async () => {}),
|
|
141
|
+
test: vi.fn(async () => {}),
|
|
142
|
+
},
|
|
143
|
+
{ pollInterval: 100 },
|
|
144
|
+
);
|
|
145
|
+
await processor.start();
|
|
146
|
+
const completedJob = await jobQueue.getJob(jobId);
|
|
147
|
+
expect(completedJob?.status).toBe('completed');
|
|
148
|
+
|
|
149
|
+
await jobQueue.cancelJob(jobId);
|
|
150
|
+
const job = await jobQueue.getJob(jobId);
|
|
151
|
+
expect(job?.status).toBe('completed'); // unchanged
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should cancel all upcoming jobs', async () => {
|
|
155
|
+
const jobId1 = await jobQueue.addJob({
|
|
156
|
+
jobType: 'email',
|
|
157
|
+
payload: { to: 'a@example.com' },
|
|
158
|
+
});
|
|
159
|
+
const jobId2 = await jobQueue.addJob({
|
|
160
|
+
jobType: 'email',
|
|
161
|
+
payload: { to: 'b@example.com' },
|
|
162
|
+
});
|
|
163
|
+
const cancelled = await jobQueue.cancelAllUpcomingJobs();
|
|
164
|
+
expect(cancelled).toBe(2);
|
|
165
|
+
const job1 = await jobQueue.getJob(jobId1);
|
|
166
|
+
const job2 = await jobQueue.getJob(jobId2);
|
|
167
|
+
expect(job1?.status).toBe('cancelled');
|
|
168
|
+
expect(job2?.status).toBe('cancelled');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should cancel all upcoming jobs by jobType', async () => {
|
|
172
|
+
const jobId1 = await jobQueue.addJob({
|
|
173
|
+
jobType: 'email',
|
|
174
|
+
payload: { to: 'a@example.com' },
|
|
175
|
+
});
|
|
176
|
+
const jobId2 = await jobQueue.addJob({
|
|
177
|
+
jobType: 'sms',
|
|
178
|
+
payload: { to: 'b@example.com' },
|
|
179
|
+
});
|
|
180
|
+
const cancelled = await jobQueue.cancelAllUpcomingJobs({
|
|
181
|
+
jobType: 'email',
|
|
182
|
+
});
|
|
183
|
+
expect(cancelled).toBe(1);
|
|
184
|
+
expect((await jobQueue.getJob(jobId1))?.status).toBe('cancelled');
|
|
185
|
+
expect((await jobQueue.getJob(jobId2))?.status).toBe('pending');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should cancel all upcoming jobs by priority', async () => {
|
|
189
|
+
const jobId1 = await jobQueue.addJob({
|
|
190
|
+
jobType: 'email',
|
|
191
|
+
payload: { to: 'a@example.com' },
|
|
192
|
+
priority: 1,
|
|
193
|
+
});
|
|
194
|
+
const jobId2 = await jobQueue.addJob({
|
|
195
|
+
jobType: 'email',
|
|
196
|
+
payload: { to: 'b@example.com' },
|
|
197
|
+
priority: 2,
|
|
198
|
+
});
|
|
199
|
+
const cancelled = await jobQueue.cancelAllUpcomingJobs({ priority: 2 });
|
|
200
|
+
expect(cancelled).toBe(1);
|
|
201
|
+
expect((await jobQueue.getJob(jobId1))?.status).toBe('pending');
|
|
202
|
+
expect((await jobQueue.getJob(jobId2))?.status).toBe('cancelled');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should edit a pending job', async () => {
|
|
206
|
+
const jobId = await jobQueue.addJob({
|
|
207
|
+
jobType: 'email',
|
|
208
|
+
payload: { to: 'original@example.com' },
|
|
209
|
+
priority: 0,
|
|
210
|
+
maxAttempts: 3,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
await jobQueue.editJob(jobId, {
|
|
214
|
+
payload: { to: 'updated@example.com' },
|
|
215
|
+
priority: 10,
|
|
216
|
+
maxAttempts: 5,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const job = await jobQueue.getJob(jobId);
|
|
220
|
+
expect(job?.payload).toEqual({ to: 'updated@example.com' });
|
|
221
|
+
expect(job?.priority).toBe(10);
|
|
222
|
+
expect(job?.maxAttempts).toBe(5);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should edit all pending jobs', async () => {
|
|
226
|
+
await jobQueue.addJob({
|
|
227
|
+
jobType: 'email',
|
|
228
|
+
payload: { to: 'a@example.com' },
|
|
229
|
+
priority: 0,
|
|
230
|
+
});
|
|
231
|
+
await jobQueue.addJob({
|
|
232
|
+
jobType: 'email',
|
|
233
|
+
payload: { to: 'b@example.com' },
|
|
234
|
+
priority: 0,
|
|
235
|
+
});
|
|
236
|
+
const smsId = await jobQueue.addJob({
|
|
237
|
+
jobType: 'sms',
|
|
238
|
+
payload: { to: 'c@example.com' },
|
|
239
|
+
priority: 0,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const edited = await jobQueue.editAllPendingJobs(
|
|
243
|
+
{ jobType: 'email' },
|
|
244
|
+
{ priority: 5 },
|
|
245
|
+
);
|
|
246
|
+
expect(edited).toBe(2);
|
|
247
|
+
const smsJob = await jobQueue.getJob(smsId);
|
|
248
|
+
expect(smsJob?.priority).toBe(0); // unchanged
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('should record and retrieve job events', async () => {
|
|
252
|
+
const jobId = await jobQueue.addJob({
|
|
253
|
+
jobType: 'email',
|
|
254
|
+
payload: { to: 'events@example.com' },
|
|
255
|
+
});
|
|
256
|
+
const events = await jobQueue.getJobEvents(jobId);
|
|
257
|
+
expect(events.length).toBeGreaterThanOrEqual(1);
|
|
258
|
+
expect(events[0].eventType).toBe('added');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('should record edited event', async () => {
|
|
262
|
+
const jobId = await jobQueue.addJob({
|
|
263
|
+
jobType: 'email',
|
|
264
|
+
payload: { to: 'original@example.com' },
|
|
265
|
+
});
|
|
266
|
+
await jobQueue.editJob(jobId, {
|
|
267
|
+
payload: { to: 'updated@example.com' },
|
|
268
|
+
priority: 10,
|
|
269
|
+
});
|
|
270
|
+
const events = await jobQueue.getJobEvents(jobId);
|
|
271
|
+
const editEvent = events.find((e) => e.eventType === 'edited');
|
|
272
|
+
expect(editEvent).not.toBeUndefined();
|
|
273
|
+
expect(editEvent?.metadata).toMatchObject({
|
|
274
|
+
payload: { to: 'updated@example.com' },
|
|
275
|
+
priority: 10,
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('should support idempotency keys', async () => {
|
|
280
|
+
const jobId1 = await jobQueue.addJob({
|
|
281
|
+
jobType: 'email',
|
|
282
|
+
payload: { to: 'idem@example.com' },
|
|
283
|
+
idempotencyKey: 'unique-key-123',
|
|
284
|
+
});
|
|
285
|
+
const jobId2 = await jobQueue.addJob({
|
|
286
|
+
jobType: 'email',
|
|
287
|
+
payload: { to: 'idem@example.com' },
|
|
288
|
+
idempotencyKey: 'unique-key-123',
|
|
289
|
+
});
|
|
290
|
+
expect(jobId1).toBe(jobId2);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should support tags and getJobsByTags', async () => {
|
|
294
|
+
await jobQueue.addJob({
|
|
295
|
+
jobType: 'email',
|
|
296
|
+
payload: { to: 'tagged1@example.com' },
|
|
297
|
+
tags: ['foo', 'bar'],
|
|
298
|
+
});
|
|
299
|
+
await jobQueue.addJob({
|
|
300
|
+
jobType: 'email',
|
|
301
|
+
payload: { to: 'tagged2@example.com' },
|
|
302
|
+
tags: ['foo'],
|
|
303
|
+
});
|
|
304
|
+
await jobQueue.addJob({
|
|
305
|
+
jobType: 'email',
|
|
306
|
+
payload: { to: 'tagged3@example.com' },
|
|
307
|
+
tags: ['baz'],
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// mode: 'all' - has both foo AND bar
|
|
311
|
+
const allJobs = await jobQueue.getJobsByTags(['foo', 'bar'], 'all');
|
|
312
|
+
expect(allJobs.length).toBe(1);
|
|
313
|
+
expect(allJobs[0].payload).toEqual({ to: 'tagged1@example.com' });
|
|
314
|
+
|
|
315
|
+
// mode: 'any' - has foo OR bar
|
|
316
|
+
const anyJobs = await jobQueue.getJobsByTags(['foo', 'bar'], 'any');
|
|
317
|
+
expect(anyJobs.length).toBe(2);
|
|
318
|
+
|
|
319
|
+
// mode: 'exact' - exactly ['foo', 'bar']
|
|
320
|
+
const exactJobs = await jobQueue.getJobsByTags(['foo', 'bar'], 'exact');
|
|
321
|
+
expect(exactJobs.length).toBe(1);
|
|
322
|
+
|
|
323
|
+
// mode: 'none' - neither foo nor bar
|
|
324
|
+
const noneJobs = await jobQueue.getJobsByTags(['foo', 'bar'], 'none');
|
|
325
|
+
expect(noneJobs.length).toBe(1);
|
|
326
|
+
expect(noneJobs[0].payload).toEqual({ to: 'tagged3@example.com' });
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('should support priority ordering in processing', async () => {
|
|
330
|
+
const processed: string[] = [];
|
|
331
|
+
const jobId1 = await jobQueue.addJob({
|
|
332
|
+
jobType: 'email',
|
|
333
|
+
payload: { to: 'low@example.com' },
|
|
334
|
+
priority: 1,
|
|
335
|
+
});
|
|
336
|
+
const jobId2 = await jobQueue.addJob({
|
|
337
|
+
jobType: 'email',
|
|
338
|
+
payload: { to: 'high@example.com' },
|
|
339
|
+
priority: 10,
|
|
340
|
+
});
|
|
341
|
+
const processor = jobQueue.createProcessor(
|
|
342
|
+
{
|
|
343
|
+
email: async (payload: any) => {
|
|
344
|
+
processed.push(payload.to);
|
|
345
|
+
},
|
|
346
|
+
sms: vi.fn(async () => {}),
|
|
347
|
+
test: vi.fn(async () => {}),
|
|
348
|
+
},
|
|
349
|
+
{ batchSize: 10, concurrency: 1 },
|
|
350
|
+
);
|
|
351
|
+
await processor.start();
|
|
352
|
+
// Higher priority should be first
|
|
353
|
+
expect(processed[0]).toBe('high@example.com');
|
|
354
|
+
expect(processed[1]).toBe('low@example.com');
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('should cleanup old completed jobs', async () => {
|
|
358
|
+
const jobId = await jobQueue.addJob({
|
|
359
|
+
jobType: 'test',
|
|
360
|
+
payload: { foo: 'cleanup' },
|
|
361
|
+
});
|
|
362
|
+
// Complete it
|
|
363
|
+
const processor = jobQueue.createProcessor({
|
|
364
|
+
email: vi.fn(async () => {}),
|
|
365
|
+
sms: vi.fn(async () => {}),
|
|
366
|
+
test: vi.fn(async () => {}),
|
|
367
|
+
});
|
|
368
|
+
await processor.start();
|
|
369
|
+
const completedJob = await jobQueue.getJob(jobId);
|
|
370
|
+
expect(completedJob?.status).toBe('completed');
|
|
371
|
+
|
|
372
|
+
// Manually set updatedAt to 31 days ago
|
|
373
|
+
const oldMs = Date.now() - 31 * 24 * 60 * 60 * 1000;
|
|
374
|
+
await redisClient.hset(
|
|
375
|
+
`${prefix}job:${jobId}`,
|
|
376
|
+
'updatedAt',
|
|
377
|
+
oldMs.toString(),
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
const deleted = await jobQueue.cleanupOldJobs(30);
|
|
381
|
+
expect(deleted).toBe(1);
|
|
382
|
+
const job = await jobQueue.getJob(jobId);
|
|
383
|
+
expect(job).toBeNull();
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('should reclaim stuck jobs', async () => {
|
|
387
|
+
const jobId = await jobQueue.addJob({
|
|
388
|
+
jobType: 'email',
|
|
389
|
+
payload: { to: 'stuck@example.com' },
|
|
390
|
+
});
|
|
391
|
+
// Manually set to processing with old lockedAt
|
|
392
|
+
const oldMs = Date.now() - 15 * 60 * 1000; // 15 minutes ago
|
|
393
|
+
await redisClient.hmset(
|
|
394
|
+
`${prefix}job:${jobId}`,
|
|
395
|
+
'status',
|
|
396
|
+
'processing',
|
|
397
|
+
'lockedAt',
|
|
398
|
+
oldMs.toString(),
|
|
399
|
+
'lockedBy',
|
|
400
|
+
'dead-worker',
|
|
401
|
+
);
|
|
402
|
+
await redisClient.srem(`${prefix}status:pending`, jobId.toString());
|
|
403
|
+
await redisClient.sadd(`${prefix}status:processing`, jobId.toString());
|
|
404
|
+
await redisClient.zrem(`${prefix}queue`, jobId.toString());
|
|
405
|
+
|
|
406
|
+
const reclaimed = await jobQueue.reclaimStuckJobs(10);
|
|
407
|
+
expect(reclaimed).toBe(1);
|
|
408
|
+
const job = await jobQueue.getJob(jobId);
|
|
409
|
+
expect(job?.status).toBe('pending');
|
|
410
|
+
expect(job?.lockedAt).toBeNull();
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('should not reclaim a job whose timeoutMs exceeds the reclaim threshold', async () => {
|
|
414
|
+
const jobId = await jobQueue.addJob({
|
|
415
|
+
jobType: 'email',
|
|
416
|
+
payload: { to: 'long-timeout@example.com' },
|
|
417
|
+
timeoutMs: 30 * 60 * 1000, // 30 minutes
|
|
418
|
+
});
|
|
419
|
+
// Simulate: processing for 15 minutes (exceeds 10-min global threshold but within 30-min job timeout)
|
|
420
|
+
const oldMs = Date.now() - 15 * 60 * 1000;
|
|
421
|
+
await redisClient.hmset(
|
|
422
|
+
`${prefix}job:${jobId}`,
|
|
423
|
+
'status',
|
|
424
|
+
'processing',
|
|
425
|
+
'lockedAt',
|
|
426
|
+
oldMs.toString(),
|
|
427
|
+
'lockedBy',
|
|
428
|
+
'some-worker',
|
|
429
|
+
);
|
|
430
|
+
await redisClient.srem(`${prefix}status:pending`, jobId.toString());
|
|
431
|
+
await redisClient.sadd(`${prefix}status:processing`, jobId.toString());
|
|
432
|
+
await redisClient.zrem(`${prefix}queue`, jobId.toString());
|
|
433
|
+
|
|
434
|
+
const reclaimed = await jobQueue.reclaimStuckJobs(10);
|
|
435
|
+
expect(reclaimed).toBe(0);
|
|
436
|
+
const job = await jobQueue.getJob(jobId);
|
|
437
|
+
expect(job?.status).toBe('processing');
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('should reclaim a job whose timeoutMs has also been exceeded', async () => {
|
|
441
|
+
const jobId = await jobQueue.addJob({
|
|
442
|
+
jobType: 'email',
|
|
443
|
+
payload: { to: 'expired-timeout@example.com' },
|
|
444
|
+
timeoutMs: 20 * 60 * 1000, // 20 minutes
|
|
445
|
+
});
|
|
446
|
+
// Simulate: processing for 25 minutes (exceeds both 10-min threshold and 20-min job timeout)
|
|
447
|
+
const oldMs = Date.now() - 25 * 60 * 1000;
|
|
448
|
+
await redisClient.hmset(
|
|
449
|
+
`${prefix}job:${jobId}`,
|
|
450
|
+
'status',
|
|
451
|
+
'processing',
|
|
452
|
+
'lockedAt',
|
|
453
|
+
oldMs.toString(),
|
|
454
|
+
'lockedBy',
|
|
455
|
+
'some-worker',
|
|
456
|
+
);
|
|
457
|
+
await redisClient.srem(`${prefix}status:pending`, jobId.toString());
|
|
458
|
+
await redisClient.sadd(`${prefix}status:processing`, jobId.toString());
|
|
459
|
+
await redisClient.zrem(`${prefix}queue`, jobId.toString());
|
|
460
|
+
|
|
461
|
+
const reclaimed = await jobQueue.reclaimStuckJobs(10);
|
|
462
|
+
expect(reclaimed).toBe(1);
|
|
463
|
+
const job = await jobQueue.getJob(jobId);
|
|
464
|
+
expect(job?.status).toBe('pending');
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('getPool should throw for Redis backend', () => {
|
|
468
|
+
expect(() => jobQueue.getPool()).toThrow(
|
|
469
|
+
'getPool() is only available with the PostgreSQL backend',
|
|
470
|
+
);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('getRedisClient should return the Redis client', () => {
|
|
474
|
+
const client = jobQueue.getRedisClient() as { get: unknown };
|
|
475
|
+
expect(client).toBeDefined();
|
|
476
|
+
expect(typeof client.get).toBe('function');
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('should get jobs with filters', async () => {
|
|
480
|
+
await jobQueue.addJob({
|
|
481
|
+
jobType: 'email',
|
|
482
|
+
payload: { to: 'a@example.com' },
|
|
483
|
+
priority: 1,
|
|
484
|
+
});
|
|
485
|
+
await jobQueue.addJob({
|
|
486
|
+
jobType: 'sms',
|
|
487
|
+
payload: { to: 'b@example.com' },
|
|
488
|
+
priority: 2,
|
|
489
|
+
});
|
|
490
|
+
await jobQueue.addJob({
|
|
491
|
+
jobType: 'email',
|
|
492
|
+
payload: { to: 'c@example.com' },
|
|
493
|
+
priority: 3,
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
const emailJobs = await jobQueue.getJobs({ jobType: 'email' });
|
|
497
|
+
expect(emailJobs.length).toBe(2);
|
|
498
|
+
|
|
499
|
+
const priorityJobs = await jobQueue.getJobs({ priority: 2 });
|
|
500
|
+
expect(priorityJobs.length).toBe(1);
|
|
501
|
+
expect(priorityJobs[0].jobType).toBe('sms');
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('should cancel all upcoming jobs by tags', async () => {
|
|
505
|
+
const jobId1 = await jobQueue.addJob({
|
|
506
|
+
jobType: 'email',
|
|
507
|
+
payload: { to: 'tag1@example.com' },
|
|
508
|
+
tags: ['foo', 'bar'],
|
|
509
|
+
});
|
|
510
|
+
const jobId2 = await jobQueue.addJob({
|
|
511
|
+
jobType: 'email',
|
|
512
|
+
payload: { to: 'tag2@example.com' },
|
|
513
|
+
tags: ['baz'],
|
|
514
|
+
});
|
|
515
|
+
const cancelled = await jobQueue.cancelAllUpcomingJobs({
|
|
516
|
+
tags: { values: ['foo'], mode: 'all' },
|
|
517
|
+
});
|
|
518
|
+
expect(cancelled).toBe(1);
|
|
519
|
+
expect((await jobQueue.getJob(jobId1))?.status).toBe('cancelled');
|
|
520
|
+
expect((await jobQueue.getJob(jobId2))?.status).toBe('pending');
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it('should handle scheduled jobs (runAt in the future)', async () => {
|
|
524
|
+
const futureDate = new Date(Date.now() + 60 * 60 * 1000); // 1 hour later
|
|
525
|
+
const jobId = await jobQueue.addJob({
|
|
526
|
+
jobType: 'email',
|
|
527
|
+
payload: { to: 'scheduled@example.com' },
|
|
528
|
+
runAt: futureDate,
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// Should not be picked up immediately
|
|
532
|
+
const processor = jobQueue.createProcessor({
|
|
533
|
+
email: vi.fn(async () => {}),
|
|
534
|
+
sms: vi.fn(async () => {}),
|
|
535
|
+
test: vi.fn(async () => {}),
|
|
536
|
+
});
|
|
537
|
+
const processed = await processor.start();
|
|
538
|
+
expect(processed).toBe(0);
|
|
539
|
+
|
|
540
|
+
const job = await jobQueue.getJob(jobId);
|
|
541
|
+
expect(job?.status).toBe('pending');
|
|
542
|
+
});
|
|
543
|
+
});
|