@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/wait.test.ts
ADDED
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
import { Pool } from 'pg';
|
|
2
|
+
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
|
3
|
+
import { processJobWithHandlers } from './processor.js';
|
|
4
|
+
import * as queue from './queue.js';
|
|
5
|
+
import { createTestDbAndPool, destroyTestDb } from './test-util.js';
|
|
6
|
+
import { JobHandler, JobContext, WaitSignal } from './types.js';
|
|
7
|
+
import { PostgresBackend } from './backends/postgres.js';
|
|
8
|
+
|
|
9
|
+
// Payload map for wait-related tests
|
|
10
|
+
interface WaitPayloadMap {
|
|
11
|
+
stepJob: { value: string };
|
|
12
|
+
waitJob: { step: number };
|
|
13
|
+
tokenJob: { userId: string };
|
|
14
|
+
multiWait: { data: string };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Claims a job by transitioning it to 'processing' status (simulates getNextBatch).
|
|
19
|
+
* Tests that call processJobWithHandlers directly need the job in 'processing' state.
|
|
20
|
+
*/
|
|
21
|
+
async function claimJob(p: Pool, jobId: number) {
|
|
22
|
+
await p.query(
|
|
23
|
+
`UPDATE job_queue SET status = 'processing', locked_by = 'test-worker', locked_at = NOW() WHERE id = $1`,
|
|
24
|
+
[jobId],
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Full handlers object (all job types must be present)
|
|
29
|
+
function makeHandlers(overrides: Partial<Record<keyof WaitPayloadMap, any>>) {
|
|
30
|
+
return {
|
|
31
|
+
stepJob: vi.fn(async () => {}),
|
|
32
|
+
waitJob: vi.fn(async () => {}),
|
|
33
|
+
tokenJob: vi.fn(async () => {}),
|
|
34
|
+
multiWait: vi.fn(async () => {}),
|
|
35
|
+
...overrides,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('ctx.run step tracking', () => {
|
|
40
|
+
let pool: Pool;
|
|
41
|
+
let dbName: string;
|
|
42
|
+
let backend: PostgresBackend;
|
|
43
|
+
|
|
44
|
+
beforeEach(async () => {
|
|
45
|
+
const setup = await createTestDbAndPool();
|
|
46
|
+
pool = setup.pool;
|
|
47
|
+
dbName = setup.dbName;
|
|
48
|
+
backend = new PostgresBackend(pool);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(async () => {
|
|
52
|
+
await pool.end();
|
|
53
|
+
await destroyTestDb(dbName);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should execute steps and persist step data', async () => {
|
|
57
|
+
const executionOrder: string[] = [];
|
|
58
|
+
|
|
59
|
+
const handler: JobHandler<WaitPayloadMap, 'stepJob'> = async (
|
|
60
|
+
payload,
|
|
61
|
+
_signal,
|
|
62
|
+
ctx,
|
|
63
|
+
) => {
|
|
64
|
+
const result1 = await ctx.run('step1', async () => {
|
|
65
|
+
executionOrder.push('step1');
|
|
66
|
+
return 'result-1';
|
|
67
|
+
});
|
|
68
|
+
expect(result1).toBe('result-1');
|
|
69
|
+
|
|
70
|
+
const result2 = await ctx.run('step2', async () => {
|
|
71
|
+
executionOrder.push('step2');
|
|
72
|
+
return 42;
|
|
73
|
+
});
|
|
74
|
+
expect(result2).toBe(42);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const handlers = makeHandlers({ stepJob: handler });
|
|
78
|
+
const jobId = await queue.addJob<WaitPayloadMap, 'stepJob'>(pool, {
|
|
79
|
+
jobType: 'stepJob',
|
|
80
|
+
payload: { value: 'test' },
|
|
81
|
+
});
|
|
82
|
+
await claimJob(pool, jobId);
|
|
83
|
+
const job = await queue.getJob<WaitPayloadMap, 'stepJob'>(pool, jobId);
|
|
84
|
+
await processJobWithHandlers(backend, job!, handlers);
|
|
85
|
+
|
|
86
|
+
// Job should be completed
|
|
87
|
+
const completed = await queue.getJob(pool, jobId);
|
|
88
|
+
expect(completed?.status).toBe('completed');
|
|
89
|
+
expect(executionOrder).toEqual(['step1', 'step2']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should replay completed steps from cache on re-invocation', async () => {
|
|
93
|
+
const executionOrder: string[] = [];
|
|
94
|
+
let invocationCount = 0;
|
|
95
|
+
|
|
96
|
+
const handler: JobHandler<WaitPayloadMap, 'stepJob'> = async (
|
|
97
|
+
payload,
|
|
98
|
+
_signal,
|
|
99
|
+
ctx,
|
|
100
|
+
) => {
|
|
101
|
+
invocationCount++;
|
|
102
|
+
|
|
103
|
+
await ctx.run('step1', async () => {
|
|
104
|
+
executionOrder.push('step1-executed');
|
|
105
|
+
return 'done';
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// On first invocation, this will throw WaitSignal
|
|
109
|
+
// On second invocation, the wait is already completed
|
|
110
|
+
await ctx.waitFor({ seconds: 1 });
|
|
111
|
+
|
|
112
|
+
await ctx.run('step2', async () => {
|
|
113
|
+
executionOrder.push('step2-executed');
|
|
114
|
+
return 'done';
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const handlers = makeHandlers({ stepJob: handler });
|
|
119
|
+
|
|
120
|
+
// First invocation: step1 executes, waitFor throws WaitSignal
|
|
121
|
+
const jobId = await queue.addJob<WaitPayloadMap, 'stepJob'>(pool, {
|
|
122
|
+
jobType: 'stepJob',
|
|
123
|
+
payload: { value: 'test' },
|
|
124
|
+
});
|
|
125
|
+
await claimJob(pool, jobId);
|
|
126
|
+
let job = await queue.getJob<WaitPayloadMap, 'stepJob'>(pool, jobId);
|
|
127
|
+
await processJobWithHandlers(backend, job!, handlers);
|
|
128
|
+
|
|
129
|
+
// Job should be in 'waiting' status
|
|
130
|
+
job = await queue.getJob<WaitPayloadMap, 'stepJob'>(pool, jobId);
|
|
131
|
+
expect(job?.status).toBe('waiting');
|
|
132
|
+
expect(job?.waitUntil).toBeInstanceOf(Date);
|
|
133
|
+
expect(executionOrder).toEqual(['step1-executed']);
|
|
134
|
+
|
|
135
|
+
// Simulate wait elapsed by setting wait_until to the past
|
|
136
|
+
const client = await pool.connect();
|
|
137
|
+
await client.query(
|
|
138
|
+
`UPDATE job_queue SET wait_until = NOW() - INTERVAL '1 second' WHERE id = $1`,
|
|
139
|
+
[jobId],
|
|
140
|
+
);
|
|
141
|
+
client.release();
|
|
142
|
+
|
|
143
|
+
// Pick up the job again (simulating processor poll)
|
|
144
|
+
const batch = await queue.getNextBatch<WaitPayloadMap, 'stepJob'>(
|
|
145
|
+
pool,
|
|
146
|
+
'worker-test',
|
|
147
|
+
1,
|
|
148
|
+
);
|
|
149
|
+
expect(batch.length).toBe(1);
|
|
150
|
+
|
|
151
|
+
// Second invocation: step1 replayed from cache, wait skipped, step2 executes
|
|
152
|
+
await processJobWithHandlers(backend, batch[0]!, handlers);
|
|
153
|
+
|
|
154
|
+
const completed = await queue.getJob(pool, jobId);
|
|
155
|
+
expect(completed?.status).toBe('completed');
|
|
156
|
+
expect(invocationCount).toBe(2);
|
|
157
|
+
// step1 should only have executed once (replayed from cache on second run)
|
|
158
|
+
expect(executionOrder).toEqual(['step1-executed', 'step2-executed']);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should not increment attempts when resuming from wait', async () => {
|
|
162
|
+
const handler: JobHandler<WaitPayloadMap, 'stepJob'> = async (
|
|
163
|
+
_payload,
|
|
164
|
+
_signal,
|
|
165
|
+
ctx,
|
|
166
|
+
) => {
|
|
167
|
+
await ctx.run('step1', async () => 'done');
|
|
168
|
+
await ctx.waitFor({ seconds: 1 });
|
|
169
|
+
await ctx.run('step2', async () => 'done');
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const handlers = makeHandlers({ stepJob: handler });
|
|
173
|
+
const jobId = await queue.addJob<WaitPayloadMap, 'stepJob'>(pool, {
|
|
174
|
+
jobType: 'stepJob',
|
|
175
|
+
payload: { value: 'test' },
|
|
176
|
+
maxAttempts: 3,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// First invocation
|
|
180
|
+
await claimJob(pool, jobId);
|
|
181
|
+
let job = await queue.getJob<WaitPayloadMap, 'stepJob'>(pool, jobId);
|
|
182
|
+
await processJobWithHandlers(backend, job!, handlers);
|
|
183
|
+
|
|
184
|
+
// Check attempts after first wait
|
|
185
|
+
job = await queue.getJob<WaitPayloadMap, 'stepJob'>(pool, jobId);
|
|
186
|
+
expect(job?.status).toBe('waiting');
|
|
187
|
+
|
|
188
|
+
// Simulate wait elapsed
|
|
189
|
+
const client = await pool.connect();
|
|
190
|
+
await client.query(
|
|
191
|
+
`UPDATE job_queue SET wait_until = NOW() - INTERVAL '1 second' WHERE id = $1`,
|
|
192
|
+
[jobId],
|
|
193
|
+
);
|
|
194
|
+
client.release();
|
|
195
|
+
|
|
196
|
+
// Pick up the job (should NOT increment attempts)
|
|
197
|
+
const batch = await queue.getNextBatch<WaitPayloadMap, 'stepJob'>(
|
|
198
|
+
pool,
|
|
199
|
+
'worker-test',
|
|
200
|
+
1,
|
|
201
|
+
);
|
|
202
|
+
expect(batch.length).toBe(1);
|
|
203
|
+
|
|
204
|
+
// The attempts should still be 0 (waiting jobs don't increment)
|
|
205
|
+
expect(batch[0]!.attempts).toBe(0);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('ctx.waitFor / ctx.waitUntil', () => {
|
|
210
|
+
let pool: Pool;
|
|
211
|
+
let dbName: string;
|
|
212
|
+
let backend: PostgresBackend;
|
|
213
|
+
|
|
214
|
+
beforeEach(async () => {
|
|
215
|
+
const setup = await createTestDbAndPool();
|
|
216
|
+
pool = setup.pool;
|
|
217
|
+
dbName = setup.dbName;
|
|
218
|
+
backend = new PostgresBackend(pool);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
afterEach(async () => {
|
|
222
|
+
await pool.end();
|
|
223
|
+
await destroyTestDb(dbName);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should set job to waiting status with wait_until', async () => {
|
|
227
|
+
const handler: JobHandler<WaitPayloadMap, 'waitJob'> = async (
|
|
228
|
+
_payload,
|
|
229
|
+
_signal,
|
|
230
|
+
ctx,
|
|
231
|
+
) => {
|
|
232
|
+
await ctx.waitFor({ hours: 1 });
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const handlers = makeHandlers({ waitJob: handler });
|
|
236
|
+
const jobId = await queue.addJob<WaitPayloadMap, 'waitJob'>(pool, {
|
|
237
|
+
jobType: 'waitJob',
|
|
238
|
+
payload: { step: 0 },
|
|
239
|
+
});
|
|
240
|
+
await claimJob(pool, jobId);
|
|
241
|
+
const job = await queue.getJob<WaitPayloadMap, 'waitJob'>(pool, jobId);
|
|
242
|
+
await processJobWithHandlers(backend, job!, handlers);
|
|
243
|
+
|
|
244
|
+
const waiting = await queue.getJob(pool, jobId);
|
|
245
|
+
expect(waiting?.status).toBe('waiting');
|
|
246
|
+
expect(waiting?.waitUntil).toBeInstanceOf(Date);
|
|
247
|
+
// wait_until should be approximately 1 hour from now
|
|
248
|
+
const diff = waiting!.waitUntil!.getTime() - Date.now();
|
|
249
|
+
expect(diff).toBeGreaterThan(55 * 60 * 1000); // at least 55 minutes
|
|
250
|
+
expect(diff).toBeLessThan(65 * 60 * 1000); // at most 65 minutes
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should set job to waiting status with waitUntil date', async () => {
|
|
254
|
+
const targetDate = new Date(Date.now() + 2 * 60 * 60 * 1000); // 2 hours
|
|
255
|
+
|
|
256
|
+
const handler: JobHandler<WaitPayloadMap, 'waitJob'> = async (
|
|
257
|
+
_payload,
|
|
258
|
+
_signal,
|
|
259
|
+
ctx,
|
|
260
|
+
) => {
|
|
261
|
+
await ctx.waitUntil(targetDate);
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const handlers = makeHandlers({ waitJob: handler });
|
|
265
|
+
const jobId = await queue.addJob<WaitPayloadMap, 'waitJob'>(pool, {
|
|
266
|
+
jobType: 'waitJob',
|
|
267
|
+
payload: { step: 0 },
|
|
268
|
+
});
|
|
269
|
+
await claimJob(pool, jobId);
|
|
270
|
+
const job = await queue.getJob<WaitPayloadMap, 'waitJob'>(pool, jobId);
|
|
271
|
+
await processJobWithHandlers(backend, job!, handlers);
|
|
272
|
+
|
|
273
|
+
const waiting = await queue.getJob(pool, jobId);
|
|
274
|
+
expect(waiting?.status).toBe('waiting');
|
|
275
|
+
const timeDiff = Math.abs(
|
|
276
|
+
waiting!.waitUntil!.getTime() - targetDate.getTime(),
|
|
277
|
+
);
|
|
278
|
+
expect(timeDiff).toBeLessThan(1000); // within 1 second of target
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('should record a waiting event', async () => {
|
|
282
|
+
const handler: JobHandler<WaitPayloadMap, 'waitJob'> = async (
|
|
283
|
+
_payload,
|
|
284
|
+
_signal,
|
|
285
|
+
ctx,
|
|
286
|
+
) => {
|
|
287
|
+
await ctx.waitFor({ minutes: 30 });
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const handlers = makeHandlers({ waitJob: handler });
|
|
291
|
+
const jobId = await queue.addJob<WaitPayloadMap, 'waitJob'>(pool, {
|
|
292
|
+
jobType: 'waitJob',
|
|
293
|
+
payload: { step: 0 },
|
|
294
|
+
});
|
|
295
|
+
await claimJob(pool, jobId);
|
|
296
|
+
const job = await queue.getJob<WaitPayloadMap, 'waitJob'>(pool, jobId);
|
|
297
|
+
await processJobWithHandlers(backend, job!, handlers);
|
|
298
|
+
|
|
299
|
+
const events = await queue.getJobEvents(pool, jobId);
|
|
300
|
+
const waitingEvents = events.filter((e) => e.eventType === 'waiting');
|
|
301
|
+
expect(waitingEvents.length).toBe(1);
|
|
302
|
+
expect(waitingEvents[0]!.metadata).toHaveProperty('waitUntil');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('waiting jobs should not be picked up before wait_until', async () => {
|
|
306
|
+
const handler: JobHandler<WaitPayloadMap, 'waitJob'> = async (
|
|
307
|
+
_payload,
|
|
308
|
+
_signal,
|
|
309
|
+
ctx,
|
|
310
|
+
) => {
|
|
311
|
+
await ctx.waitFor({ hours: 1 });
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const handlers = makeHandlers({ waitJob: handler });
|
|
315
|
+
const jobId = await queue.addJob<WaitPayloadMap, 'waitJob'>(pool, {
|
|
316
|
+
jobType: 'waitJob',
|
|
317
|
+
payload: { step: 0 },
|
|
318
|
+
});
|
|
319
|
+
await claimJob(pool, jobId);
|
|
320
|
+
const job = await queue.getJob<WaitPayloadMap, 'waitJob'>(pool, jobId);
|
|
321
|
+
await processJobWithHandlers(backend, job!, handlers);
|
|
322
|
+
|
|
323
|
+
// Try to pick up -- should get nothing (wait_until is in the future)
|
|
324
|
+
const batch = await queue.getNextBatch<WaitPayloadMap, 'waitJob'>(
|
|
325
|
+
pool,
|
|
326
|
+
'worker-test',
|
|
327
|
+
1,
|
|
328
|
+
);
|
|
329
|
+
expect(batch.length).toBe(0);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('should handle multiple sequential waits', async () => {
|
|
333
|
+
let phase = 0;
|
|
334
|
+
|
|
335
|
+
const handler: JobHandler<WaitPayloadMap, 'multiWait'> = async (
|
|
336
|
+
_payload,
|
|
337
|
+
_signal,
|
|
338
|
+
ctx,
|
|
339
|
+
) => {
|
|
340
|
+
await ctx.run('phase1', async () => {
|
|
341
|
+
phase = 1;
|
|
342
|
+
});
|
|
343
|
+
await ctx.waitFor({ seconds: 1 });
|
|
344
|
+
|
|
345
|
+
await ctx.run('phase2', async () => {
|
|
346
|
+
phase = 2;
|
|
347
|
+
});
|
|
348
|
+
await ctx.waitFor({ seconds: 1 });
|
|
349
|
+
|
|
350
|
+
await ctx.run('phase3', async () => {
|
|
351
|
+
phase = 3;
|
|
352
|
+
});
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const handlers = makeHandlers({ multiWait: handler });
|
|
356
|
+
const jobId = await queue.addJob<WaitPayloadMap, 'multiWait'>(pool, {
|
|
357
|
+
jobType: 'multiWait',
|
|
358
|
+
payload: { data: 'test' },
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// First invocation: phase1 runs, first waitFor triggers
|
|
362
|
+
await claimJob(pool, jobId);
|
|
363
|
+
let job = await queue.getJob<WaitPayloadMap, 'multiWait'>(pool, jobId);
|
|
364
|
+
await processJobWithHandlers(backend, job!, handlers);
|
|
365
|
+
expect(phase).toBe(1);
|
|
366
|
+
|
|
367
|
+
let waiting = await queue.getJob(pool, jobId);
|
|
368
|
+
expect(waiting?.status).toBe('waiting');
|
|
369
|
+
|
|
370
|
+
// Simulate wait elapsed
|
|
371
|
+
const client = await pool.connect();
|
|
372
|
+
await client.query(
|
|
373
|
+
`UPDATE job_queue SET wait_until = NOW() - INTERVAL '1 second' WHERE id = $1`,
|
|
374
|
+
[jobId],
|
|
375
|
+
);
|
|
376
|
+
client.release();
|
|
377
|
+
|
|
378
|
+
// Second invocation: phase1 cached, first wait skipped, phase2 runs, second waitFor triggers
|
|
379
|
+
let batch = await queue.getNextBatch<WaitPayloadMap, 'multiWait'>(
|
|
380
|
+
pool,
|
|
381
|
+
'worker-test',
|
|
382
|
+
1,
|
|
383
|
+
);
|
|
384
|
+
await processJobWithHandlers(backend, batch[0]!, handlers);
|
|
385
|
+
expect(phase).toBe(2);
|
|
386
|
+
|
|
387
|
+
waiting = await queue.getJob(pool, jobId);
|
|
388
|
+
expect(waiting?.status).toBe('waiting');
|
|
389
|
+
|
|
390
|
+
// Simulate second wait elapsed
|
|
391
|
+
const client2 = await pool.connect();
|
|
392
|
+
await client2.query(
|
|
393
|
+
`UPDATE job_queue SET wait_until = NOW() - INTERVAL '1 second' WHERE id = $1`,
|
|
394
|
+
[jobId],
|
|
395
|
+
);
|
|
396
|
+
client2.release();
|
|
397
|
+
|
|
398
|
+
// Third invocation: all previous steps cached, both waits skipped, phase3 runs
|
|
399
|
+
batch = await queue.getNextBatch<WaitPayloadMap, 'multiWait'>(
|
|
400
|
+
pool,
|
|
401
|
+
'worker-test',
|
|
402
|
+
1,
|
|
403
|
+
);
|
|
404
|
+
await processJobWithHandlers(backend, batch[0]!, handlers);
|
|
405
|
+
expect(phase).toBe(3);
|
|
406
|
+
|
|
407
|
+
const completed = await queue.getJob(pool, jobId);
|
|
408
|
+
expect(completed?.status).toBe('completed');
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
describe('ctx.waitForToken', () => {
|
|
413
|
+
let pool: Pool;
|
|
414
|
+
let dbName: string;
|
|
415
|
+
let backend: PostgresBackend;
|
|
416
|
+
|
|
417
|
+
beforeEach(async () => {
|
|
418
|
+
const setup = await createTestDbAndPool();
|
|
419
|
+
pool = setup.pool;
|
|
420
|
+
dbName = setup.dbName;
|
|
421
|
+
backend = new PostgresBackend(pool);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
afterEach(async () => {
|
|
425
|
+
await pool.end();
|
|
426
|
+
await destroyTestDb(dbName);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('should pause job and resume when token is completed', async () => {
|
|
430
|
+
let tokenId: string | undefined;
|
|
431
|
+
let tokenResult: any;
|
|
432
|
+
|
|
433
|
+
const handler: JobHandler<WaitPayloadMap, 'tokenJob'> = async (
|
|
434
|
+
_payload,
|
|
435
|
+
_signal,
|
|
436
|
+
ctx,
|
|
437
|
+
) => {
|
|
438
|
+
const token = await ctx.run('create-token', async () => {
|
|
439
|
+
return await ctx.createToken({ timeout: '10m' });
|
|
440
|
+
});
|
|
441
|
+
tokenId = token.id;
|
|
442
|
+
|
|
443
|
+
const result = await ctx.waitForToken<{ status: string }>(token.id);
|
|
444
|
+
tokenResult = result;
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
const handlers = makeHandlers({ tokenJob: handler });
|
|
448
|
+
|
|
449
|
+
// First invocation: creates token, then pauses
|
|
450
|
+
const jobId = await queue.addJob<WaitPayloadMap, 'tokenJob'>(pool, {
|
|
451
|
+
jobType: 'tokenJob',
|
|
452
|
+
payload: { userId: 'user-123' },
|
|
453
|
+
});
|
|
454
|
+
await claimJob(pool, jobId);
|
|
455
|
+
let job = await queue.getJob<WaitPayloadMap, 'tokenJob'>(pool, jobId);
|
|
456
|
+
await processJobWithHandlers(backend, job!, handlers);
|
|
457
|
+
|
|
458
|
+
expect(tokenId).toBeDefined();
|
|
459
|
+
job = await queue.getJob<WaitPayloadMap, 'tokenJob'>(pool, jobId);
|
|
460
|
+
expect(job?.status).toBe('waiting');
|
|
461
|
+
expect(job?.waitTokenId).toBe(tokenId);
|
|
462
|
+
|
|
463
|
+
// Verify the waitpoint exists
|
|
464
|
+
const wp = await queue.getWaitpoint(pool, tokenId!);
|
|
465
|
+
expect(wp).not.toBeNull();
|
|
466
|
+
expect(wp?.status).toBe('waiting');
|
|
467
|
+
|
|
468
|
+
// Complete the token externally
|
|
469
|
+
await queue.completeWaitpoint(pool, tokenId!, {
|
|
470
|
+
status: 'approved',
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// Job should be back to 'pending'
|
|
474
|
+
job = await queue.getJob<WaitPayloadMap, 'tokenJob'>(pool, jobId);
|
|
475
|
+
expect(job?.status).toBe('pending');
|
|
476
|
+
|
|
477
|
+
// Second invocation: step1 replayed, waitForToken returns the result
|
|
478
|
+
const batch = await queue.getNextBatch<WaitPayloadMap, 'tokenJob'>(
|
|
479
|
+
pool,
|
|
480
|
+
'worker-test',
|
|
481
|
+
1,
|
|
482
|
+
);
|
|
483
|
+
expect(batch.length).toBe(1);
|
|
484
|
+
await processJobWithHandlers(backend, batch[0]!, handlers);
|
|
485
|
+
|
|
486
|
+
const completed = await queue.getJob(pool, jobId);
|
|
487
|
+
expect(completed?.status).toBe('completed');
|
|
488
|
+
expect(tokenResult).toEqual({ ok: true, output: { status: 'approved' } });
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('should handle token timeout', async () => {
|
|
492
|
+
let tokenId: string | undefined;
|
|
493
|
+
let tokenResult: any;
|
|
494
|
+
|
|
495
|
+
const handler: JobHandler<WaitPayloadMap, 'tokenJob'> = async (
|
|
496
|
+
_payload,
|
|
497
|
+
_signal,
|
|
498
|
+
ctx,
|
|
499
|
+
) => {
|
|
500
|
+
const token = await ctx.run('create-token', async () => {
|
|
501
|
+
return await ctx.createToken({ timeout: '1s' });
|
|
502
|
+
});
|
|
503
|
+
tokenId = token.id;
|
|
504
|
+
|
|
505
|
+
const result = await ctx.waitForToken<{ status: string }>(token.id);
|
|
506
|
+
tokenResult = result;
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
const handlers = makeHandlers({ tokenJob: handler });
|
|
510
|
+
const jobId = await queue.addJob<WaitPayloadMap, 'tokenJob'>(pool, {
|
|
511
|
+
jobType: 'tokenJob',
|
|
512
|
+
payload: { userId: 'user-456' },
|
|
513
|
+
});
|
|
514
|
+
await claimJob(pool, jobId);
|
|
515
|
+
let job = await queue.getJob<WaitPayloadMap, 'tokenJob'>(pool, jobId);
|
|
516
|
+
await processJobWithHandlers(backend, job!, handlers);
|
|
517
|
+
|
|
518
|
+
expect(tokenId).toBeDefined();
|
|
519
|
+
job = await queue.getJob<WaitPayloadMap, 'tokenJob'>(pool, jobId);
|
|
520
|
+
expect(job?.status).toBe('waiting');
|
|
521
|
+
|
|
522
|
+
// Simulate token timeout by setting timeout_at in the past
|
|
523
|
+
const client = await pool.connect();
|
|
524
|
+
await client.query(
|
|
525
|
+
`UPDATE waitpoints SET timeout_at = NOW() - INTERVAL '1 second' WHERE id = $1`,
|
|
526
|
+
[tokenId],
|
|
527
|
+
);
|
|
528
|
+
client.release();
|
|
529
|
+
|
|
530
|
+
// Expire timed-out tokens
|
|
531
|
+
const expired = await queue.expireTimedOutWaitpoints(pool);
|
|
532
|
+
expect(expired).toBe(1);
|
|
533
|
+
|
|
534
|
+
// Job should be back to 'pending'
|
|
535
|
+
job = await queue.getJob<WaitPayloadMap, 'tokenJob'>(pool, jobId);
|
|
536
|
+
expect(job?.status).toBe('pending');
|
|
537
|
+
|
|
538
|
+
// Verify waitpoint is timed_out
|
|
539
|
+
const wp = await queue.getWaitpoint(pool, tokenId!);
|
|
540
|
+
expect(wp?.status).toBe('timed_out');
|
|
541
|
+
|
|
542
|
+
// Second invocation: waitForToken returns timeout result
|
|
543
|
+
const batch = await queue.getNextBatch<WaitPayloadMap, 'tokenJob'>(
|
|
544
|
+
pool,
|
|
545
|
+
'worker-test',
|
|
546
|
+
1,
|
|
547
|
+
);
|
|
548
|
+
expect(batch.length).toBe(1);
|
|
549
|
+
await processJobWithHandlers(backend, batch[0]!, handlers);
|
|
550
|
+
|
|
551
|
+
const completed = await queue.getJob(pool, jobId);
|
|
552
|
+
expect(completed?.status).toBe('completed');
|
|
553
|
+
expect(tokenResult).toEqual({ ok: false, error: 'Token timed out' });
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
describe('cancel waiting job', () => {
|
|
558
|
+
let pool: Pool;
|
|
559
|
+
let dbName: string;
|
|
560
|
+
let backend: PostgresBackend;
|
|
561
|
+
|
|
562
|
+
beforeEach(async () => {
|
|
563
|
+
const setup = await createTestDbAndPool();
|
|
564
|
+
pool = setup.pool;
|
|
565
|
+
dbName = setup.dbName;
|
|
566
|
+
backend = new PostgresBackend(pool);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
afterEach(async () => {
|
|
570
|
+
await pool.end();
|
|
571
|
+
await destroyTestDb(dbName);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it('should cancel a job in waiting status', async () => {
|
|
575
|
+
const handler: JobHandler<WaitPayloadMap, 'waitJob'> = async (
|
|
576
|
+
_payload,
|
|
577
|
+
_signal,
|
|
578
|
+
ctx,
|
|
579
|
+
) => {
|
|
580
|
+
await ctx.waitFor({ hours: 24 });
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
const handlers = makeHandlers({ waitJob: handler });
|
|
584
|
+
const jobId = await queue.addJob<WaitPayloadMap, 'waitJob'>(pool, {
|
|
585
|
+
jobType: 'waitJob',
|
|
586
|
+
payload: { step: 0 },
|
|
587
|
+
});
|
|
588
|
+
await claimJob(pool, jobId);
|
|
589
|
+
const job = await queue.getJob<WaitPayloadMap, 'waitJob'>(pool, jobId);
|
|
590
|
+
await processJobWithHandlers(backend, job!, handlers);
|
|
591
|
+
|
|
592
|
+
// Verify waiting
|
|
593
|
+
let waiting = await queue.getJob(pool, jobId);
|
|
594
|
+
expect(waiting?.status).toBe('waiting');
|
|
595
|
+
|
|
596
|
+
// Cancel the waiting job
|
|
597
|
+
await queue.cancelJob(pool, jobId);
|
|
598
|
+
|
|
599
|
+
const cancelled = await queue.getJob(pool, jobId);
|
|
600
|
+
expect(cancelled?.status).toBe('cancelled');
|
|
601
|
+
expect(cancelled?.waitUntil).toBeNull();
|
|
602
|
+
expect(cancelled?.waitTokenId).toBeNull();
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
describe('createToken / completeToken outside handlers', () => {
|
|
607
|
+
let pool: Pool;
|
|
608
|
+
let dbName: string;
|
|
609
|
+
let backend: PostgresBackend;
|
|
610
|
+
|
|
611
|
+
beforeEach(async () => {
|
|
612
|
+
const setup = await createTestDbAndPool();
|
|
613
|
+
pool = setup.pool;
|
|
614
|
+
dbName = setup.dbName;
|
|
615
|
+
backend = new PostgresBackend(pool);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
afterEach(async () => {
|
|
619
|
+
await pool.end();
|
|
620
|
+
await destroyTestDb(dbName);
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it('should create and retrieve a token', async () => {
|
|
624
|
+
const token = await queue.createWaitpoint(pool, null, {
|
|
625
|
+
timeout: '10m',
|
|
626
|
+
tags: ['approval', 'user:123'],
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
expect(token.id).toMatch(/^wp_/);
|
|
630
|
+
|
|
631
|
+
const retrieved = await queue.getWaitpoint(pool, token.id);
|
|
632
|
+
expect(retrieved).not.toBeNull();
|
|
633
|
+
expect(retrieved?.status).toBe('waiting');
|
|
634
|
+
expect(retrieved?.timeoutAt).toBeInstanceOf(Date);
|
|
635
|
+
expect(retrieved?.tags).toEqual(['approval', 'user:123']);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
it('should complete a token and store output', async () => {
|
|
639
|
+
const token = await queue.createWaitpoint(pool, null);
|
|
640
|
+
await queue.completeWaitpoint(pool, token.id, { approved: true });
|
|
641
|
+
|
|
642
|
+
const retrieved = await queue.getWaitpoint(pool, token.id);
|
|
643
|
+
expect(retrieved?.status).toBe('completed');
|
|
644
|
+
expect(retrieved?.output).toEqual({ approved: true });
|
|
645
|
+
expect(retrieved?.completedAt).toBeInstanceOf(Date);
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
describe('WaitSignal class', () => {
|
|
650
|
+
it('should be an instance of Error', () => {
|
|
651
|
+
const signal = new WaitSignal('duration', new Date(), undefined, {});
|
|
652
|
+
expect(signal).toBeInstanceOf(Error);
|
|
653
|
+
expect(signal).toBeInstanceOf(WaitSignal);
|
|
654
|
+
expect(signal.isWaitSignal).toBe(true);
|
|
655
|
+
expect(signal.name).toBe('WaitSignal');
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
describe('existing handlers without wait features', () => {
|
|
660
|
+
let pool: Pool;
|
|
661
|
+
let dbName: string;
|
|
662
|
+
let backend: PostgresBackend;
|
|
663
|
+
|
|
664
|
+
beforeEach(async () => {
|
|
665
|
+
const setup = await createTestDbAndPool();
|
|
666
|
+
pool = setup.pool;
|
|
667
|
+
dbName = setup.dbName;
|
|
668
|
+
backend = new PostgresBackend(pool);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
afterEach(async () => {
|
|
672
|
+
await pool.end();
|
|
673
|
+
await destroyTestDb(dbName);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it('should still work without using ctx.run or wait methods', async () => {
|
|
677
|
+
const handler: JobHandler<WaitPayloadMap, 'stepJob'> = async (
|
|
678
|
+
payload,
|
|
679
|
+
_signal,
|
|
680
|
+
_ctx,
|
|
681
|
+
) => {
|
|
682
|
+
// Traditional handler that ignores ctx.run and waits
|
|
683
|
+
expect(payload.value).toBe('hello');
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
const handlers = makeHandlers({ stepJob: handler });
|
|
687
|
+
const jobId = await queue.addJob<WaitPayloadMap, 'stepJob'>(pool, {
|
|
688
|
+
jobType: 'stepJob',
|
|
689
|
+
payload: { value: 'hello' },
|
|
690
|
+
});
|
|
691
|
+
await claimJob(pool, jobId);
|
|
692
|
+
const job = await queue.getJob<WaitPayloadMap, 'stepJob'>(pool, jobId);
|
|
693
|
+
await processJobWithHandlers(backend, job!, handlers);
|
|
694
|
+
|
|
695
|
+
const completed = await queue.getJob(pool, jobId);
|
|
696
|
+
expect(completed?.status).toBe('completed');
|
|
697
|
+
});
|
|
698
|
+
});
|