@nicnocquee/dataqueue 1.31.0 → 1.32.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/dist/index.cjs +2418 -1936
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +151 -16
- package/dist/index.d.ts +151 -16
- package/dist/index.js +2418 -1936
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/backend.ts +70 -4
- package/src/backends/postgres.ts +345 -29
- package/src/backends/redis-scripts.ts +197 -22
- package/src/backends/redis.test.ts +621 -0
- package/src/backends/redis.ts +400 -21
- package/src/index.ts +12 -29
- package/src/processor.ts +14 -93
- package/src/queue.test.ts +29 -0
- package/src/queue.ts +19 -251
- package/src/types.ts +28 -10
package/src/processor.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Worker } from 'worker_threads';
|
|
2
|
-
import { Pool } from 'pg';
|
|
3
2
|
import {
|
|
4
3
|
JobRecord,
|
|
5
4
|
ProcessorOptions,
|
|
@@ -15,69 +14,8 @@ import {
|
|
|
15
14
|
WaitTokenResult,
|
|
16
15
|
} from './types.js';
|
|
17
16
|
import { QueueBackend } from './backend.js';
|
|
18
|
-
import { PostgresBackend } from './backends/postgres.js';
|
|
19
|
-
import {
|
|
20
|
-
waitJob,
|
|
21
|
-
updateStepData,
|
|
22
|
-
createWaitpoint,
|
|
23
|
-
getWaitpoint,
|
|
24
|
-
} from './queue.js';
|
|
25
17
|
import { log, setLogContext } from './log-context.js';
|
|
26
18
|
|
|
27
|
-
/**
|
|
28
|
-
* Try to extract the underlying pg Pool from a QueueBackend.
|
|
29
|
-
* Returns null for non-PostgreSQL backends.
|
|
30
|
-
*/
|
|
31
|
-
function tryExtractPool(backend: QueueBackend): Pool | null {
|
|
32
|
-
if (backend instanceof PostgresBackend) {
|
|
33
|
-
return backend.getPool();
|
|
34
|
-
}
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Build a JobContext without wait support (for non-PostgreSQL backends).
|
|
40
|
-
* prolong/onTimeout work normally; wait-related methods throw helpful errors.
|
|
41
|
-
*/
|
|
42
|
-
function buildBasicContext(
|
|
43
|
-
backend: QueueBackend,
|
|
44
|
-
jobId: number,
|
|
45
|
-
baseCtx: {
|
|
46
|
-
prolong: JobContext['prolong'];
|
|
47
|
-
onTimeout: JobContext['onTimeout'];
|
|
48
|
-
},
|
|
49
|
-
): JobContext {
|
|
50
|
-
const waitError = () =>
|
|
51
|
-
new Error(
|
|
52
|
-
'Wait features (waitFor, waitUntil, createToken, waitForToken, ctx.run) are currently only supported with the PostgreSQL backend.',
|
|
53
|
-
);
|
|
54
|
-
return {
|
|
55
|
-
prolong: baseCtx.prolong,
|
|
56
|
-
onTimeout: baseCtx.onTimeout,
|
|
57
|
-
run: async <T>(_stepName: string, fn: () => Promise<T>): Promise<T> => {
|
|
58
|
-
// Without PostgreSQL, just execute the function directly (no persistence)
|
|
59
|
-
return fn();
|
|
60
|
-
},
|
|
61
|
-
waitFor: async () => {
|
|
62
|
-
throw waitError();
|
|
63
|
-
},
|
|
64
|
-
waitUntil: async () => {
|
|
65
|
-
throw waitError();
|
|
66
|
-
},
|
|
67
|
-
createToken: async () => {
|
|
68
|
-
throw waitError();
|
|
69
|
-
},
|
|
70
|
-
waitForToken: async () => {
|
|
71
|
-
throw waitError();
|
|
72
|
-
},
|
|
73
|
-
setProgress: async (percent: number) => {
|
|
74
|
-
if (percent < 0 || percent > 100)
|
|
75
|
-
throw new Error('Progress must be between 0 and 100');
|
|
76
|
-
await backend.updateProgress(jobId, Math.round(percent));
|
|
77
|
-
},
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
19
|
/**
|
|
82
20
|
* Validates that a handler can be serialized for worker thread execution.
|
|
83
21
|
* Throws an error with helpful message if serialization fails.
|
|
@@ -388,7 +326,7 @@ function createNoOpContext(
|
|
|
388
326
|
* Marks pending waits as completed and fetches token outputs.
|
|
389
327
|
*/
|
|
390
328
|
async function resolveCompletedWaits(
|
|
391
|
-
|
|
329
|
+
backend: QueueBackend,
|
|
392
330
|
stepData: Record<string, any>,
|
|
393
331
|
): Promise<void> {
|
|
394
332
|
for (const key of Object.keys(stepData)) {
|
|
@@ -401,7 +339,7 @@ async function resolveCompletedWaits(
|
|
|
401
339
|
stepData[key] = { ...entry, completed: true };
|
|
402
340
|
} else if (entry.type === 'token' && entry.tokenId) {
|
|
403
341
|
// Token-based wait -- fetch the waitpoint result
|
|
404
|
-
const wp = await getWaitpoint(
|
|
342
|
+
const wp = await backend.getWaitpoint(entry.tokenId);
|
|
405
343
|
if (wp && wp.status === 'completed') {
|
|
406
344
|
stepData[key] = {
|
|
407
345
|
...entry,
|
|
@@ -422,10 +360,10 @@ async function resolveCompletedWaits(
|
|
|
422
360
|
|
|
423
361
|
/**
|
|
424
362
|
* Build the extended JobContext with step tracking and wait support.
|
|
363
|
+
* Works with any QueueBackend (Postgres or Redis).
|
|
425
364
|
*/
|
|
426
365
|
function buildWaitContext(
|
|
427
366
|
backend: QueueBackend,
|
|
428
|
-
pool: Pool,
|
|
429
367
|
jobId: number,
|
|
430
368
|
stepData: Record<string, any>,
|
|
431
369
|
baseCtx: {
|
|
@@ -455,7 +393,7 @@ function buildWaitContext(
|
|
|
455
393
|
|
|
456
394
|
// Persist step result
|
|
457
395
|
stepData[stepName] = { __completed: true, result };
|
|
458
|
-
await updateStepData(
|
|
396
|
+
await backend.updateStepData(jobId, stepData);
|
|
459
397
|
|
|
460
398
|
return result;
|
|
461
399
|
},
|
|
@@ -498,7 +436,7 @@ function buildWaitContext(
|
|
|
498
436
|
},
|
|
499
437
|
|
|
500
438
|
createToken: async (options?) => {
|
|
501
|
-
const token = await createWaitpoint(
|
|
439
|
+
const token = await backend.createWaitpoint(jobId, options);
|
|
502
440
|
return token;
|
|
503
441
|
},
|
|
504
442
|
|
|
@@ -517,7 +455,7 @@ function buildWaitContext(
|
|
|
517
455
|
}
|
|
518
456
|
|
|
519
457
|
// Check if the token is already completed (e.g., completed while job was still processing)
|
|
520
|
-
const wp = await getWaitpoint(
|
|
458
|
+
const wp = await backend.getWaitpoint(tokenId);
|
|
521
459
|
if (wp && wp.status === 'completed') {
|
|
522
460
|
const result: WaitTokenResult<T> = {
|
|
523
461
|
ok: true,
|
|
@@ -529,7 +467,7 @@ function buildWaitContext(
|
|
|
529
467
|
completed: true,
|
|
530
468
|
result,
|
|
531
469
|
};
|
|
532
|
-
await updateStepData(
|
|
470
|
+
await backend.updateStepData(jobId, stepData);
|
|
533
471
|
return result;
|
|
534
472
|
}
|
|
535
473
|
if (wp && wp.status === 'timed_out') {
|
|
@@ -543,7 +481,7 @@ function buildWaitContext(
|
|
|
543
481
|
completed: true,
|
|
544
482
|
result,
|
|
545
483
|
};
|
|
546
|
-
await updateStepData(
|
|
484
|
+
await backend.updateStepData(jobId, stepData);
|
|
547
485
|
return result;
|
|
548
486
|
}
|
|
549
487
|
|
|
@@ -591,17 +529,14 @@ export async function processJobWithHandlers<
|
|
|
591
529
|
// Load step data (may contain completed steps from previous invocations)
|
|
592
530
|
const stepData: Record<string, any> = { ...(job.stepData || {}) };
|
|
593
531
|
|
|
594
|
-
// Try to get pool for wait features (PostgreSQL-only)
|
|
595
|
-
const pool = tryExtractPool(backend);
|
|
596
|
-
|
|
597
532
|
// If resuming from a wait, resolve any pending wait entries
|
|
598
533
|
const hasStepHistory = Object.keys(stepData).some((k) =>
|
|
599
534
|
k.startsWith('__wait_'),
|
|
600
535
|
);
|
|
601
|
-
if (hasStepHistory
|
|
602
|
-
await resolveCompletedWaits(
|
|
536
|
+
if (hasStepHistory) {
|
|
537
|
+
await resolveCompletedWaits(backend, stepData);
|
|
603
538
|
// Persist the resolved step data
|
|
604
|
-
await updateStepData(
|
|
539
|
+
await backend.updateStepData(job.id, stepData);
|
|
605
540
|
}
|
|
606
541
|
|
|
607
542
|
// Per-job timeout logic
|
|
@@ -685,10 +620,8 @@ export async function processJobWithHandlers<
|
|
|
685
620
|
},
|
|
686
621
|
};
|
|
687
622
|
|
|
688
|
-
// Build context: full wait support for
|
|
689
|
-
const ctx =
|
|
690
|
-
? buildWaitContext(backend, pool, job.id, stepData, baseCtx)
|
|
691
|
-
: buildBasicContext(backend, job.id, baseCtx);
|
|
623
|
+
// Build context: full wait support for all backends
|
|
624
|
+
const ctx = buildWaitContext(backend, job.id, stepData, baseCtx);
|
|
692
625
|
|
|
693
626
|
// If forceKillOnTimeout was set but timeoutMs was missing, warn
|
|
694
627
|
if (forceKillOnTimeout && !hasTimeout) {
|
|
@@ -720,22 +653,10 @@ export async function processJobWithHandlers<
|
|
|
720
653
|
|
|
721
654
|
// Check if this is a WaitSignal (not a real error)
|
|
722
655
|
if (error instanceof WaitSignal) {
|
|
723
|
-
if (!pool) {
|
|
724
|
-
// Wait signals should never happen with non-PostgreSQL backends
|
|
725
|
-
// since the context methods throw, but guard just in case
|
|
726
|
-
await backend.failJob(
|
|
727
|
-
job.id,
|
|
728
|
-
new Error(
|
|
729
|
-
'WaitSignal received but wait features require the PostgreSQL backend.',
|
|
730
|
-
),
|
|
731
|
-
FailureReason.HandlerError,
|
|
732
|
-
);
|
|
733
|
-
return;
|
|
734
|
-
}
|
|
735
656
|
log(
|
|
736
657
|
`Job ${job.id} entering wait: type=${error.type}, waitUntil=${error.waitUntil?.toISOString() ?? 'none'}, tokenId=${error.tokenId ?? 'none'}`,
|
|
737
658
|
);
|
|
738
|
-
await waitJob(
|
|
659
|
+
await backend.waitJob(job.id, {
|
|
739
660
|
waitUntil: error.waitUntil,
|
|
740
661
|
waitTokenId: error.tokenId,
|
|
741
662
|
stepData: error.stepData,
|
package/src/queue.test.ts
CHANGED
|
@@ -141,6 +141,35 @@ describe('queue integration', () => {
|
|
|
141
141
|
expect(job).toBeNull();
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
+
it('should cleanup old completed jobs in batches', async () => {
|
|
145
|
+
// Add and complete 5 jobs
|
|
146
|
+
const ids: number[] = [];
|
|
147
|
+
for (let i = 0; i < 5; i++) {
|
|
148
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(
|
|
149
|
+
pool,
|
|
150
|
+
{
|
|
151
|
+
jobType: 'email',
|
|
152
|
+
payload: { to: `batch-${i}@example.com` },
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
await queue.getNextBatch(pool, 'worker-batch-cleanup', 1);
|
|
156
|
+
await queue.completeJob(pool, jobId);
|
|
157
|
+
ids.push(jobId);
|
|
158
|
+
}
|
|
159
|
+
// Manually backdate all 5
|
|
160
|
+
await pool.query(
|
|
161
|
+
`UPDATE job_queue SET updated_at = NOW() - INTERVAL '31 days' WHERE id = ANY($1::int[])`,
|
|
162
|
+
[ids],
|
|
163
|
+
);
|
|
164
|
+
// Cleanup with batchSize=2 so it takes multiple iterations
|
|
165
|
+
const deleted = await queue.cleanupOldJobs(pool, 30, 2);
|
|
166
|
+
expect(deleted).toBe(5);
|
|
167
|
+
for (const id of ids) {
|
|
168
|
+
const job = await queue.getJob(pool, id);
|
|
169
|
+
expect(job).toBeNull();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
144
173
|
it('should cancel a scheduled job', async () => {
|
|
145
174
|
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
146
175
|
jobType: 'email',
|
package/src/queue.ts
CHANGED
|
@@ -18,8 +18,6 @@ import {
|
|
|
18
18
|
WaitpointRecord,
|
|
19
19
|
} from './types.js';
|
|
20
20
|
import { PostgresBackend } from './backends/postgres.js';
|
|
21
|
-
import { randomUUID } from 'crypto';
|
|
22
|
-
import { log } from './log-context.js';
|
|
23
21
|
|
|
24
22
|
/* Thin wrappers — every function creates a lightweight backend wrapper
|
|
25
23
|
around the given pool and forwards the call. The class itself holds
|
|
@@ -94,7 +92,9 @@ export const retryJob = async (pool: Pool, jobId: number): Promise<void> =>
|
|
|
94
92
|
export const cleanupOldJobs = async (
|
|
95
93
|
pool: Pool,
|
|
96
94
|
daysToKeep = 30,
|
|
97
|
-
|
|
95
|
+
batchSize = 1000,
|
|
96
|
+
): Promise<number> =>
|
|
97
|
+
new PostgresBackend(pool).cleanupOldJobs(daysToKeep, batchSize);
|
|
98
98
|
|
|
99
99
|
export const cancelJob = async (pool: Pool, jobId: number): Promise<void> =>
|
|
100
100
|
new PostgresBackend(pool).cancelJob(jobId);
|
|
@@ -214,12 +214,9 @@ export const updateProgress = async (
|
|
|
214
214
|
progress: number,
|
|
215
215
|
): Promise<void> => new PostgresBackend(pool).updateProgress(jobId, progress);
|
|
216
216
|
|
|
217
|
-
// ── Wait support functions (
|
|
217
|
+
// ── Wait support functions (backward-compatible delegates) ────────────────────
|
|
218
218
|
|
|
219
|
-
/**
|
|
220
|
-
* Transition a job to 'waiting' status with wait_until and/or wait_token_id.
|
|
221
|
-
* Saves step_data so the handler can resume from where it left off.
|
|
222
|
-
*/
|
|
219
|
+
/** @deprecated Use backend.waitJob() directly. Delegates to PostgresBackend. */
|
|
223
220
|
export const waitJob = async (
|
|
224
221
|
pool: Pool,
|
|
225
222
|
jobId: number,
|
|
@@ -228,266 +225,37 @@ export const waitJob = async (
|
|
|
228
225
|
waitTokenId?: string;
|
|
229
226
|
stepData: Record<string, any>;
|
|
230
227
|
},
|
|
231
|
-
): Promise<void> =>
|
|
232
|
-
const client = await pool.connect();
|
|
233
|
-
try {
|
|
234
|
-
const result = await client.query(
|
|
235
|
-
`
|
|
236
|
-
UPDATE job_queue
|
|
237
|
-
SET status = 'waiting',
|
|
238
|
-
wait_until = $2,
|
|
239
|
-
wait_token_id = $3,
|
|
240
|
-
step_data = $4,
|
|
241
|
-
locked_at = NULL,
|
|
242
|
-
locked_by = NULL,
|
|
243
|
-
updated_at = NOW()
|
|
244
|
-
WHERE id = $1 AND status = 'processing'
|
|
245
|
-
`,
|
|
246
|
-
[
|
|
247
|
-
jobId,
|
|
248
|
-
options.waitUntil ?? null,
|
|
249
|
-
options.waitTokenId ?? null,
|
|
250
|
-
JSON.stringify(options.stepData),
|
|
251
|
-
],
|
|
252
|
-
);
|
|
253
|
-
if (result.rowCount === 0) {
|
|
254
|
-
log(
|
|
255
|
-
`Job ${jobId} could not be set to waiting (may have been reclaimed or is no longer processing)`,
|
|
256
|
-
);
|
|
257
|
-
return;
|
|
258
|
-
}
|
|
259
|
-
await recordJobEvent(pool, jobId, JobEventType.Waiting, {
|
|
260
|
-
waitUntil: options.waitUntil?.toISOString() ?? null,
|
|
261
|
-
waitTokenId: options.waitTokenId ?? null,
|
|
262
|
-
});
|
|
263
|
-
log(`Job ${jobId} set to waiting`);
|
|
264
|
-
} catch (error) {
|
|
265
|
-
log(`Error setting job ${jobId} to waiting: ${error}`);
|
|
266
|
-
throw error;
|
|
267
|
-
} finally {
|
|
268
|
-
client.release();
|
|
269
|
-
}
|
|
270
|
-
};
|
|
228
|
+
): Promise<void> => new PostgresBackend(pool).waitJob(jobId, options);
|
|
271
229
|
|
|
272
|
-
/**
|
|
273
|
-
* Update step_data for a job. Called after each ctx.run() step completes
|
|
274
|
-
* to persist intermediate progress.
|
|
275
|
-
*/
|
|
230
|
+
/** @deprecated Use backend.updateStepData() directly. Delegates to PostgresBackend. */
|
|
276
231
|
export const updateStepData = async (
|
|
277
232
|
pool: Pool,
|
|
278
233
|
jobId: number,
|
|
279
234
|
stepData: Record<string, any>,
|
|
280
|
-
): Promise<void> =>
|
|
281
|
-
const client = await pool.connect();
|
|
282
|
-
try {
|
|
283
|
-
await client.query(
|
|
284
|
-
`UPDATE job_queue SET step_data = $2, updated_at = NOW() WHERE id = $1`,
|
|
285
|
-
[jobId, JSON.stringify(stepData)],
|
|
286
|
-
);
|
|
287
|
-
} catch (error) {
|
|
288
|
-
log(`Error updating step_data for job ${jobId}: ${error}`);
|
|
289
|
-
// Best-effort: do not throw to avoid killing the running handler
|
|
290
|
-
} finally {
|
|
291
|
-
client.release();
|
|
292
|
-
}
|
|
293
|
-
};
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* Parse a timeout string like '10m', '1h', '24h', '7d' into milliseconds.
|
|
297
|
-
*/
|
|
298
|
-
/**
|
|
299
|
-
* Maximum allowed timeout in milliseconds (~365 days).
|
|
300
|
-
* Prevents overflow to Infinity when computing Date offsets.
|
|
301
|
-
*/
|
|
302
|
-
const MAX_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000;
|
|
235
|
+
): Promise<void> => new PostgresBackend(pool).updateStepData(jobId, stepData);
|
|
303
236
|
|
|
304
|
-
|
|
305
|
-
const match = timeout.match(/^(\d+)(s|m|h|d)$/);
|
|
306
|
-
if (!match) {
|
|
307
|
-
throw new Error(
|
|
308
|
-
`Invalid timeout format: "${timeout}". Expected format like "10m", "1h", "24h", "7d".`,
|
|
309
|
-
);
|
|
310
|
-
}
|
|
311
|
-
const value = parseInt(match[1], 10);
|
|
312
|
-
const unit = match[2];
|
|
313
|
-
let ms: number;
|
|
314
|
-
switch (unit) {
|
|
315
|
-
case 's':
|
|
316
|
-
ms = value * 1000;
|
|
317
|
-
break;
|
|
318
|
-
case 'm':
|
|
319
|
-
ms = value * 60 * 1000;
|
|
320
|
-
break;
|
|
321
|
-
case 'h':
|
|
322
|
-
ms = value * 60 * 60 * 1000;
|
|
323
|
-
break;
|
|
324
|
-
case 'd':
|
|
325
|
-
ms = value * 24 * 60 * 60 * 1000;
|
|
326
|
-
break;
|
|
327
|
-
default:
|
|
328
|
-
throw new Error(`Unknown timeout unit: "${unit}"`);
|
|
329
|
-
}
|
|
330
|
-
if (!Number.isFinite(ms) || ms > MAX_TIMEOUT_MS) {
|
|
331
|
-
throw new Error(
|
|
332
|
-
`Timeout value "${timeout}" is too large. Maximum allowed is 365 days.`,
|
|
333
|
-
);
|
|
334
|
-
}
|
|
335
|
-
return ms;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Create a waitpoint token in the database.
|
|
340
|
-
* The token can be used to pause a job until an external signal completes it.
|
|
341
|
-
*
|
|
342
|
-
* @param pool - The database pool
|
|
343
|
-
* @param jobId - The job ID to associate with the token (null if created outside a handler)
|
|
344
|
-
* @param options - Optional timeout and tags
|
|
345
|
-
* @returns The created waitpoint token
|
|
346
|
-
*/
|
|
237
|
+
/** @deprecated Use backend.createWaitpoint() directly. Delegates to PostgresBackend. */
|
|
347
238
|
export const createWaitpoint = async (
|
|
348
239
|
pool: Pool,
|
|
349
240
|
jobId: number | null,
|
|
350
241
|
options?: { timeout?: string; tags?: string[] },
|
|
351
|
-
): Promise<{ id: string }> =>
|
|
352
|
-
|
|
353
|
-
try {
|
|
354
|
-
const id = `wp_${randomUUID()}`;
|
|
355
|
-
let timeoutAt: Date | null = null;
|
|
356
|
-
|
|
357
|
-
if (options?.timeout) {
|
|
358
|
-
const ms = parseTimeoutString(options.timeout);
|
|
359
|
-
timeoutAt = new Date(Date.now() + ms);
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
await client.query(
|
|
363
|
-
`INSERT INTO waitpoints (id, job_id, status, timeout_at, tags) VALUES ($1, $2, 'waiting', $3, $4)`,
|
|
364
|
-
[id, jobId, timeoutAt, options?.tags ?? null],
|
|
365
|
-
);
|
|
242
|
+
): Promise<{ id: string }> =>
|
|
243
|
+
new PostgresBackend(pool).createWaitpoint(jobId, options);
|
|
366
244
|
|
|
367
|
-
|
|
368
|
-
return { id };
|
|
369
|
-
} catch (error) {
|
|
370
|
-
log(`Error creating waitpoint: ${error}`);
|
|
371
|
-
throw error;
|
|
372
|
-
} finally {
|
|
373
|
-
client.release();
|
|
374
|
-
}
|
|
375
|
-
};
|
|
376
|
-
|
|
377
|
-
/**
|
|
378
|
-
* Complete a waitpoint token, optionally providing output data.
|
|
379
|
-
* This also moves the associated job from 'waiting' back to 'pending' so
|
|
380
|
-
* it gets picked up by the polling loop.
|
|
381
|
-
*/
|
|
245
|
+
/** @deprecated Use backend.completeWaitpoint() directly. Delegates to PostgresBackend. */
|
|
382
246
|
export const completeWaitpoint = async (
|
|
383
247
|
pool: Pool,
|
|
384
248
|
tokenId: string,
|
|
385
249
|
data?: any,
|
|
386
|
-
): Promise<void> =>
|
|
387
|
-
const client = await pool.connect();
|
|
388
|
-
try {
|
|
389
|
-
await client.query('BEGIN');
|
|
390
|
-
|
|
391
|
-
// Update the waitpoint
|
|
392
|
-
const wpResult = await client.query(
|
|
393
|
-
`UPDATE waitpoints SET status = 'completed', output = $2, completed_at = NOW()
|
|
394
|
-
WHERE id = $1 AND status = 'waiting'
|
|
395
|
-
RETURNING job_id`,
|
|
396
|
-
[tokenId, data != null ? JSON.stringify(data) : null],
|
|
397
|
-
);
|
|
398
|
-
|
|
399
|
-
if (wpResult.rows.length === 0) {
|
|
400
|
-
await client.query('ROLLBACK');
|
|
401
|
-
log(`Waitpoint ${tokenId} not found or already completed`);
|
|
402
|
-
return;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
const jobId = wpResult.rows[0].job_id;
|
|
406
|
-
|
|
407
|
-
// Move the associated job back to 'pending' so it gets picked up
|
|
408
|
-
if (jobId != null) {
|
|
409
|
-
await client.query(
|
|
410
|
-
`UPDATE job_queue
|
|
411
|
-
SET status = 'pending', wait_token_id = NULL, wait_until = NULL, updated_at = NOW()
|
|
412
|
-
WHERE id = $1 AND status = 'waiting'`,
|
|
413
|
-
[jobId],
|
|
414
|
-
);
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
await client.query('COMMIT');
|
|
418
|
-
log(`Completed waitpoint ${tokenId} for job ${jobId}`);
|
|
419
|
-
} catch (error) {
|
|
420
|
-
await client.query('ROLLBACK');
|
|
421
|
-
log(`Error completing waitpoint ${tokenId}: ${error}`);
|
|
422
|
-
throw error;
|
|
423
|
-
} finally {
|
|
424
|
-
client.release();
|
|
425
|
-
}
|
|
426
|
-
};
|
|
250
|
+
): Promise<void> => new PostgresBackend(pool).completeWaitpoint(tokenId, data);
|
|
427
251
|
|
|
428
|
-
/**
|
|
429
|
-
* Retrieve a waitpoint token by its ID.
|
|
430
|
-
*/
|
|
252
|
+
/** @deprecated Use backend.getWaitpoint() directly. Delegates to PostgresBackend. */
|
|
431
253
|
export const getWaitpoint = async (
|
|
432
254
|
pool: Pool,
|
|
433
255
|
tokenId: string,
|
|
434
|
-
): Promise<WaitpointRecord | null> =>
|
|
435
|
-
|
|
436
|
-
try {
|
|
437
|
-
const result = await client.query(
|
|
438
|
-
`SELECT id, job_id AS "jobId", status, output, timeout_at AS "timeoutAt", created_at AS "createdAt", completed_at AS "completedAt", tags FROM waitpoints WHERE id = $1`,
|
|
439
|
-
[tokenId],
|
|
440
|
-
);
|
|
441
|
-
if (result.rows.length === 0) return null;
|
|
442
|
-
return result.rows[0] as WaitpointRecord;
|
|
443
|
-
} catch (error) {
|
|
444
|
-
log(`Error getting waitpoint ${tokenId}: ${error}`);
|
|
445
|
-
throw error;
|
|
446
|
-
} finally {
|
|
447
|
-
client.release();
|
|
448
|
-
}
|
|
449
|
-
};
|
|
450
|
-
|
|
451
|
-
/**
|
|
452
|
-
* Expire timed-out waitpoint tokens and move their associated jobs back to 'pending'.
|
|
453
|
-
* Should be called periodically (e.g., alongside reclaimStuckJobs).
|
|
454
|
-
*/
|
|
455
|
-
export const expireTimedOutWaitpoints = async (pool: Pool): Promise<number> => {
|
|
456
|
-
const client = await pool.connect();
|
|
457
|
-
try {
|
|
458
|
-
await client.query('BEGIN');
|
|
459
|
-
|
|
460
|
-
// Find and expire timed-out waitpoints
|
|
461
|
-
const result = await client.query(
|
|
462
|
-
`UPDATE waitpoints
|
|
463
|
-
SET status = 'timed_out'
|
|
464
|
-
WHERE status = 'waiting' AND timeout_at IS NOT NULL AND timeout_at <= NOW()
|
|
465
|
-
RETURNING id, job_id`,
|
|
466
|
-
);
|
|
467
|
-
|
|
468
|
-
// Move associated jobs back to 'pending'
|
|
469
|
-
for (const row of result.rows) {
|
|
470
|
-
if (row.job_id != null) {
|
|
471
|
-
await client.query(
|
|
472
|
-
`UPDATE job_queue
|
|
473
|
-
SET status = 'pending', wait_token_id = NULL, wait_until = NULL, updated_at = NOW()
|
|
474
|
-
WHERE id = $1 AND status = 'waiting'`,
|
|
475
|
-
[row.job_id],
|
|
476
|
-
);
|
|
477
|
-
}
|
|
478
|
-
}
|
|
256
|
+
): Promise<WaitpointRecord | null> =>
|
|
257
|
+
new PostgresBackend(pool).getWaitpoint(tokenId);
|
|
479
258
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
log(`Expired ${count} timed-out waitpoints`);
|
|
484
|
-
}
|
|
485
|
-
return count;
|
|
486
|
-
} catch (error) {
|
|
487
|
-
await client.query('ROLLBACK');
|
|
488
|
-
log(`Error expiring timed-out waitpoints: ${error}`);
|
|
489
|
-
throw error;
|
|
490
|
-
} finally {
|
|
491
|
-
client.release();
|
|
492
|
-
}
|
|
493
|
-
};
|
|
259
|
+
/** @deprecated Use backend.expireTimedOutWaitpoints() directly. Delegates to PostgresBackend. */
|
|
260
|
+
export const expireTimedOutWaitpoints = async (pool: Pool): Promise<number> =>
|
|
261
|
+
new PostgresBackend(pool).expireTimedOutWaitpoints();
|
package/src/types.ts
CHANGED
|
@@ -485,6 +485,23 @@ export interface PostgresJobQueueConfig {
|
|
|
485
485
|
user?: string;
|
|
486
486
|
password?: string;
|
|
487
487
|
ssl?: DatabaseSSLConfig;
|
|
488
|
+
/**
|
|
489
|
+
* Maximum number of clients in the pool (default: 10).
|
|
490
|
+
* Increase when running multiple processors in the same process.
|
|
491
|
+
*/
|
|
492
|
+
max?: number;
|
|
493
|
+
/**
|
|
494
|
+
* Minimum number of idle clients in the pool (default: 0).
|
|
495
|
+
*/
|
|
496
|
+
min?: number;
|
|
497
|
+
/**
|
|
498
|
+
* Milliseconds a client must sit idle before being closed (default: 10000).
|
|
499
|
+
*/
|
|
500
|
+
idleTimeoutMillis?: number;
|
|
501
|
+
/**
|
|
502
|
+
* Milliseconds to wait for a connection before throwing (default: 0, no timeout).
|
|
503
|
+
*/
|
|
504
|
+
connectionTimeoutMillis?: number;
|
|
488
505
|
};
|
|
489
506
|
verbose?: boolean;
|
|
490
507
|
}
|
|
@@ -690,12 +707,21 @@ export interface JobQueue<PayloadMap> {
|
|
|
690
707
|
retryJob: (jobId: number) => Promise<void>;
|
|
691
708
|
/**
|
|
692
709
|
* Cleanup jobs that are older than the specified number of days.
|
|
710
|
+
* Deletes in batches for scale safety.
|
|
711
|
+
* @param daysToKeep - Number of days to retain completed jobs (default 30).
|
|
712
|
+
* @param batchSize - Number of rows to delete per batch (default 1000 for PostgreSQL, 200 for Redis).
|
|
693
713
|
*/
|
|
694
|
-
cleanupOldJobs: (daysToKeep?: number) => Promise<number>;
|
|
714
|
+
cleanupOldJobs: (daysToKeep?: number, batchSize?: number) => Promise<number>;
|
|
695
715
|
/**
|
|
696
716
|
* Cleanup job events that are older than the specified number of days.
|
|
717
|
+
* Deletes in batches for scale safety.
|
|
718
|
+
* @param daysToKeep - Number of days to retain events (default 30).
|
|
719
|
+
* @param batchSize - Number of rows to delete per batch (default 1000).
|
|
697
720
|
*/
|
|
698
|
-
cleanupOldJobEvents: (
|
|
721
|
+
cleanupOldJobEvents: (
|
|
722
|
+
daysToKeep?: number,
|
|
723
|
+
batchSize?: number,
|
|
724
|
+
) => Promise<number>;
|
|
699
725
|
/**
|
|
700
726
|
* Cancel a job given its ID.
|
|
701
727
|
* - This will set the job status to 'cancelled' and clear the locked_at and locked_by.
|
|
@@ -779,8 +805,6 @@ export interface JobQueue<PayloadMap> {
|
|
|
779
805
|
* Tokens can be completed externally to resume a waiting job.
|
|
780
806
|
* Can be called outside of handlers (e.g., from an API route).
|
|
781
807
|
*
|
|
782
|
-
* **PostgreSQL backend only.** Throws if the backend is Redis.
|
|
783
|
-
*
|
|
784
808
|
* @param options - Optional token configuration (timeout, tags).
|
|
785
809
|
* @returns A token object with `id`.
|
|
786
810
|
*/
|
|
@@ -790,8 +814,6 @@ export interface JobQueue<PayloadMap> {
|
|
|
790
814
|
* Complete a waitpoint token, resuming the associated waiting job.
|
|
791
815
|
* Can be called from anywhere (API routes, external services, etc.).
|
|
792
816
|
*
|
|
793
|
-
* **PostgreSQL backend only.** Throws if the backend is Redis.
|
|
794
|
-
*
|
|
795
817
|
* @param tokenId - The ID of the token to complete.
|
|
796
818
|
* @param data - Optional data to pass to the waiting handler.
|
|
797
819
|
*/
|
|
@@ -800,8 +822,6 @@ export interface JobQueue<PayloadMap> {
|
|
|
800
822
|
/**
|
|
801
823
|
* Retrieve a waitpoint token by its ID.
|
|
802
824
|
*
|
|
803
|
-
* **PostgreSQL backend only.** Throws if the backend is Redis.
|
|
804
|
-
*
|
|
805
825
|
* @param tokenId - The ID of the token to retrieve.
|
|
806
826
|
* @returns The token record, or null if not found.
|
|
807
827
|
*/
|
|
@@ -811,8 +831,6 @@ export interface JobQueue<PayloadMap> {
|
|
|
811
831
|
* Expire timed-out waitpoint tokens and resume their associated jobs.
|
|
812
832
|
* Call this periodically (e.g., alongside `reclaimStuckJobs`).
|
|
813
833
|
*
|
|
814
|
-
* **PostgreSQL backend only.** Throws if the backend is Redis.
|
|
815
|
-
*
|
|
816
834
|
* @returns The number of tokens that were expired.
|
|
817
835
|
*/
|
|
818
836
|
expireTimedOutTokens: () => Promise<number>;
|