@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/processor.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Pool } from 'pg';
|
|
2
1
|
import { Worker } from 'worker_threads';
|
|
2
|
+
import { Pool } from 'pg';
|
|
3
3
|
import {
|
|
4
4
|
JobRecord,
|
|
5
5
|
ProcessorOptions,
|
|
@@ -8,15 +8,76 @@ import {
|
|
|
8
8
|
JobType,
|
|
9
9
|
FailureReason,
|
|
10
10
|
JobHandlers,
|
|
11
|
+
JobContext,
|
|
12
|
+
OnTimeoutCallback,
|
|
13
|
+
WaitSignal,
|
|
14
|
+
WaitDuration,
|
|
15
|
+
WaitTokenResult,
|
|
11
16
|
} from './types.js';
|
|
17
|
+
import { QueueBackend } from './backend.js';
|
|
18
|
+
import { PostgresBackend } from './backends/postgres.js';
|
|
12
19
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
20
|
+
waitJob,
|
|
21
|
+
updateStepData,
|
|
22
|
+
createWaitpoint,
|
|
23
|
+
getWaitpoint,
|
|
17
24
|
} from './queue.js';
|
|
18
25
|
import { log, setLogContext } from './log-context.js';
|
|
19
26
|
|
|
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
|
+
|
|
20
81
|
/**
|
|
21
82
|
* Validates that a handler can be serialized for worker thread execution.
|
|
22
83
|
* Throws an error with helpful message if serialization fails.
|
|
@@ -253,6 +314,254 @@ async function runHandlerInWorker<
|
|
|
253
314
|
});
|
|
254
315
|
}
|
|
255
316
|
|
|
317
|
+
/**
|
|
318
|
+
* Convert a WaitDuration to a target Date.
|
|
319
|
+
*/
|
|
320
|
+
function calculateWaitUntil(duration: WaitDuration): Date {
|
|
321
|
+
const now = Date.now();
|
|
322
|
+
let ms = 0;
|
|
323
|
+
if (duration.seconds) ms += duration.seconds * 1000;
|
|
324
|
+
if (duration.minutes) ms += duration.minutes * 60 * 1000;
|
|
325
|
+
if (duration.hours) ms += duration.hours * 60 * 60 * 1000;
|
|
326
|
+
if (duration.days) ms += duration.days * 24 * 60 * 60 * 1000;
|
|
327
|
+
if (duration.weeks) ms += duration.weeks * 7 * 24 * 60 * 60 * 1000;
|
|
328
|
+
if (duration.months) ms += duration.months * 30 * 24 * 60 * 60 * 1000;
|
|
329
|
+
if (duration.years) ms += duration.years * 365 * 24 * 60 * 60 * 1000;
|
|
330
|
+
if (ms <= 0) {
|
|
331
|
+
throw new Error(
|
|
332
|
+
'waitFor duration must be positive. Provide at least one positive duration field.',
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
return new Date(now + ms);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Create a no-op JobContext for cases where prolong/onTimeout are not supported
|
|
340
|
+
* (e.g. forceKillOnTimeout mode or no timeout set).
|
|
341
|
+
*/
|
|
342
|
+
function createNoOpContext(
|
|
343
|
+
backend: QueueBackend,
|
|
344
|
+
jobId: number,
|
|
345
|
+
reason: string,
|
|
346
|
+
): JobContext {
|
|
347
|
+
return {
|
|
348
|
+
prolong: () => {
|
|
349
|
+
log(`prolong() called but ignored: ${reason}`);
|
|
350
|
+
},
|
|
351
|
+
onTimeout: () => {
|
|
352
|
+
log(`onTimeout() called but ignored: ${reason}`);
|
|
353
|
+
},
|
|
354
|
+
run: async <T>(_stepName: string, fn: () => Promise<T>): Promise<T> => {
|
|
355
|
+
// In no-op context (forceKillOnTimeout), just execute the function directly
|
|
356
|
+
return fn();
|
|
357
|
+
},
|
|
358
|
+
waitFor: async () => {
|
|
359
|
+
throw new Error(
|
|
360
|
+
`waitFor() is not supported when forceKillOnTimeout is enabled. ${reason}`,
|
|
361
|
+
);
|
|
362
|
+
},
|
|
363
|
+
waitUntil: async () => {
|
|
364
|
+
throw new Error(
|
|
365
|
+
`waitUntil() is not supported when forceKillOnTimeout is enabled. ${reason}`,
|
|
366
|
+
);
|
|
367
|
+
},
|
|
368
|
+
createToken: async () => {
|
|
369
|
+
throw new Error(
|
|
370
|
+
`createToken() is not supported when forceKillOnTimeout is enabled. ${reason}`,
|
|
371
|
+
);
|
|
372
|
+
},
|
|
373
|
+
waitForToken: async () => {
|
|
374
|
+
throw new Error(
|
|
375
|
+
`waitForToken() is not supported when forceKillOnTimeout is enabled. ${reason}`,
|
|
376
|
+
);
|
|
377
|
+
},
|
|
378
|
+
setProgress: async (percent: number) => {
|
|
379
|
+
if (percent < 0 || percent > 100)
|
|
380
|
+
throw new Error('Progress must be between 0 and 100');
|
|
381
|
+
await backend.updateProgress(jobId, Math.round(percent));
|
|
382
|
+
},
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Pre-process stepData before handler re-invocation.
|
|
388
|
+
* Marks pending waits as completed and fetches token outputs.
|
|
389
|
+
*/
|
|
390
|
+
async function resolveCompletedWaits(
|
|
391
|
+
pool: Pool,
|
|
392
|
+
stepData: Record<string, any>,
|
|
393
|
+
): Promise<void> {
|
|
394
|
+
for (const key of Object.keys(stepData)) {
|
|
395
|
+
if (!key.startsWith('__wait_')) continue;
|
|
396
|
+
const entry = stepData[key];
|
|
397
|
+
if (!entry || typeof entry !== 'object' || entry.completed) continue;
|
|
398
|
+
|
|
399
|
+
if (entry.type === 'duration' || entry.type === 'date') {
|
|
400
|
+
// Time-based wait has elapsed (we got picked up, so it must have)
|
|
401
|
+
stepData[key] = { ...entry, completed: true };
|
|
402
|
+
} else if (entry.type === 'token' && entry.tokenId) {
|
|
403
|
+
// Token-based wait -- fetch the waitpoint result
|
|
404
|
+
const wp = await getWaitpoint(pool, entry.tokenId);
|
|
405
|
+
if (wp && wp.status === 'completed') {
|
|
406
|
+
stepData[key] = {
|
|
407
|
+
...entry,
|
|
408
|
+
completed: true,
|
|
409
|
+
result: { ok: true, output: wp.output },
|
|
410
|
+
};
|
|
411
|
+
} else if (wp && wp.status === 'timed_out') {
|
|
412
|
+
stepData[key] = {
|
|
413
|
+
...entry,
|
|
414
|
+
completed: true,
|
|
415
|
+
result: { ok: false, error: 'Token timed out' },
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
// If still waiting (shouldn't happen), leave as pending
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Build the extended JobContext with step tracking and wait support.
|
|
425
|
+
*/
|
|
426
|
+
function buildWaitContext(
|
|
427
|
+
backend: QueueBackend,
|
|
428
|
+
pool: Pool,
|
|
429
|
+
jobId: number,
|
|
430
|
+
stepData: Record<string, any>,
|
|
431
|
+
baseCtx: {
|
|
432
|
+
prolong: JobContext['prolong'];
|
|
433
|
+
onTimeout: JobContext['onTimeout'];
|
|
434
|
+
},
|
|
435
|
+
): JobContext {
|
|
436
|
+
// Wait counter always starts at 0 per invocation.
|
|
437
|
+
// The handler replays from the top each time, so the counter position
|
|
438
|
+
// must match the order of waitFor/waitUntil/waitForToken calls in code.
|
|
439
|
+
let waitCounter = 0;
|
|
440
|
+
|
|
441
|
+
const ctx: JobContext = {
|
|
442
|
+
prolong: baseCtx.prolong,
|
|
443
|
+
onTimeout: baseCtx.onTimeout,
|
|
444
|
+
|
|
445
|
+
run: async <T>(stepName: string, fn: () => Promise<T>): Promise<T> => {
|
|
446
|
+
// Check if step was already completed in a previous invocation
|
|
447
|
+
const cached = stepData[stepName];
|
|
448
|
+
if (cached && typeof cached === 'object' && cached.__completed) {
|
|
449
|
+
log(`Step "${stepName}" replayed from cache for job ${jobId}`);
|
|
450
|
+
return cached.result as T;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Execute the step
|
|
454
|
+
const result = await fn();
|
|
455
|
+
|
|
456
|
+
// Persist step result
|
|
457
|
+
stepData[stepName] = { __completed: true, result };
|
|
458
|
+
await updateStepData(pool, jobId, stepData);
|
|
459
|
+
|
|
460
|
+
return result;
|
|
461
|
+
},
|
|
462
|
+
|
|
463
|
+
waitFor: async (duration: WaitDuration): Promise<void> => {
|
|
464
|
+
const waitKey = `__wait_${waitCounter++}`;
|
|
465
|
+
|
|
466
|
+
// Check if this wait was already completed (from a previous invocation)
|
|
467
|
+
const cached = stepData[waitKey];
|
|
468
|
+
if (cached && typeof cached === 'object' && cached.completed) {
|
|
469
|
+
log(`Wait "${waitKey}" already completed for job ${jobId}, skipping`);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Calculate when to resume
|
|
474
|
+
const waitUntilDate = calculateWaitUntil(duration);
|
|
475
|
+
|
|
476
|
+
// Record this wait as pending in step data
|
|
477
|
+
stepData[waitKey] = { type: 'duration', completed: false };
|
|
478
|
+
|
|
479
|
+
// Throw WaitSignal to pause the handler
|
|
480
|
+
throw new WaitSignal('duration', waitUntilDate, undefined, stepData);
|
|
481
|
+
},
|
|
482
|
+
|
|
483
|
+
waitUntil: async (date: Date): Promise<void> => {
|
|
484
|
+
const waitKey = `__wait_${waitCounter++}`;
|
|
485
|
+
|
|
486
|
+
// Check if this wait was already completed
|
|
487
|
+
const cached = stepData[waitKey];
|
|
488
|
+
if (cached && typeof cached === 'object' && cached.completed) {
|
|
489
|
+
log(`Wait "${waitKey}" already completed for job ${jobId}, skipping`);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Record this wait as pending
|
|
494
|
+
stepData[waitKey] = { type: 'date', completed: false };
|
|
495
|
+
|
|
496
|
+
// Throw WaitSignal to pause the handler
|
|
497
|
+
throw new WaitSignal('date', date, undefined, stepData);
|
|
498
|
+
},
|
|
499
|
+
|
|
500
|
+
createToken: async (options?) => {
|
|
501
|
+
const token = await createWaitpoint(pool, jobId, options);
|
|
502
|
+
return token;
|
|
503
|
+
},
|
|
504
|
+
|
|
505
|
+
waitForToken: async <T = any>(
|
|
506
|
+
tokenId: string,
|
|
507
|
+
): Promise<WaitTokenResult<T>> => {
|
|
508
|
+
const waitKey = `__wait_${waitCounter++}`;
|
|
509
|
+
|
|
510
|
+
// Check if this wait was already completed
|
|
511
|
+
const cached = stepData[waitKey];
|
|
512
|
+
if (cached && typeof cached === 'object' && cached.completed) {
|
|
513
|
+
log(
|
|
514
|
+
`Token wait "${waitKey}" already completed for job ${jobId}, returning cached result`,
|
|
515
|
+
);
|
|
516
|
+
return cached.result as WaitTokenResult<T>;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Check if the token is already completed (e.g., completed while job was still processing)
|
|
520
|
+
const wp = await getWaitpoint(pool, tokenId);
|
|
521
|
+
if (wp && wp.status === 'completed') {
|
|
522
|
+
const result: WaitTokenResult<T> = {
|
|
523
|
+
ok: true,
|
|
524
|
+
output: wp.output as T,
|
|
525
|
+
};
|
|
526
|
+
stepData[waitKey] = {
|
|
527
|
+
type: 'token',
|
|
528
|
+
tokenId,
|
|
529
|
+
completed: true,
|
|
530
|
+
result,
|
|
531
|
+
};
|
|
532
|
+
await updateStepData(pool, jobId, stepData);
|
|
533
|
+
return result;
|
|
534
|
+
}
|
|
535
|
+
if (wp && wp.status === 'timed_out') {
|
|
536
|
+
const result: WaitTokenResult<T> = {
|
|
537
|
+
ok: false,
|
|
538
|
+
error: 'Token timed out',
|
|
539
|
+
};
|
|
540
|
+
stepData[waitKey] = {
|
|
541
|
+
type: 'token',
|
|
542
|
+
tokenId,
|
|
543
|
+
completed: true,
|
|
544
|
+
result,
|
|
545
|
+
};
|
|
546
|
+
await updateStepData(pool, jobId, stepData);
|
|
547
|
+
return result;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Token not yet completed -- save pending state and throw WaitSignal
|
|
551
|
+
stepData[waitKey] = { type: 'token', tokenId, completed: false };
|
|
552
|
+
throw new WaitSignal('token', undefined, tokenId, stepData);
|
|
553
|
+
},
|
|
554
|
+
|
|
555
|
+
setProgress: async (percent: number) => {
|
|
556
|
+
if (percent < 0 || percent > 100)
|
|
557
|
+
throw new Error('Progress must be between 0 and 100');
|
|
558
|
+
await backend.updateProgress(jobId, Math.round(percent));
|
|
559
|
+
},
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
return ctx;
|
|
563
|
+
}
|
|
564
|
+
|
|
256
565
|
/**
|
|
257
566
|
* Process a single job using the provided handler map
|
|
258
567
|
*/
|
|
@@ -260,20 +569,18 @@ export async function processJobWithHandlers<
|
|
|
260
569
|
PayloadMap,
|
|
261
570
|
T extends keyof PayloadMap & string,
|
|
262
571
|
>(
|
|
263
|
-
|
|
572
|
+
backend: QueueBackend,
|
|
264
573
|
job: JobRecord<PayloadMap, T>,
|
|
265
574
|
jobHandlers: JobHandlers<PayloadMap>,
|
|
266
575
|
): Promise<void> {
|
|
267
576
|
const handler = jobHandlers[job.jobType];
|
|
268
577
|
|
|
269
578
|
if (!handler) {
|
|
270
|
-
await setPendingReasonForUnpickedJobs(
|
|
271
|
-
pool,
|
|
579
|
+
await backend.setPendingReasonForUnpickedJobs(
|
|
272
580
|
`No handler registered for job type: ${job.jobType}`,
|
|
273
581
|
job.jobType,
|
|
274
582
|
);
|
|
275
|
-
await failJob(
|
|
276
|
-
pool,
|
|
583
|
+
await backend.failJob(
|
|
277
584
|
job.id,
|
|
278
585
|
new Error(`No handler registered for job type: ${job.jobType}`),
|
|
279
586
|
FailureReason.NoHandler,
|
|
@@ -281,6 +588,22 @@ export async function processJobWithHandlers<
|
|
|
281
588
|
return;
|
|
282
589
|
}
|
|
283
590
|
|
|
591
|
+
// Load step data (may contain completed steps from previous invocations)
|
|
592
|
+
const stepData: Record<string, any> = { ...(job.stepData || {}) };
|
|
593
|
+
|
|
594
|
+
// Try to get pool for wait features (PostgreSQL-only)
|
|
595
|
+
const pool = tryExtractPool(backend);
|
|
596
|
+
|
|
597
|
+
// If resuming from a wait, resolve any pending wait entries
|
|
598
|
+
const hasStepHistory = Object.keys(stepData).some((k) =>
|
|
599
|
+
k.startsWith('__wait_'),
|
|
600
|
+
);
|
|
601
|
+
if (hasStepHistory && pool) {
|
|
602
|
+
await resolveCompletedWaits(pool, stepData);
|
|
603
|
+
// Persist the resolved step data
|
|
604
|
+
await updateStepData(pool, job.id, stepData);
|
|
605
|
+
}
|
|
606
|
+
|
|
284
607
|
// Per-job timeout logic
|
|
285
608
|
const timeoutMs = job.timeoutMs ?? undefined;
|
|
286
609
|
const forceKillOnTimeout = job.forceKillOnTimeout ?? false;
|
|
@@ -288,24 +611,100 @@ export async function processJobWithHandlers<
|
|
|
288
611
|
const controller = new AbortController();
|
|
289
612
|
try {
|
|
290
613
|
// If forceKillOnTimeout is true, run handler in a worker thread
|
|
614
|
+
// Note: wait features are not available in forceKillOnTimeout mode
|
|
291
615
|
if (forceKillOnTimeout && timeoutMs && timeoutMs > 0) {
|
|
292
616
|
await runHandlerInWorker(handler, job.payload, timeoutMs, job.jobType);
|
|
293
617
|
} else {
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
|
|
618
|
+
// Build the JobContext for prolong/onTimeout support
|
|
619
|
+
let onTimeoutCallback: OnTimeoutCallback | undefined;
|
|
620
|
+
|
|
621
|
+
// Reference to the reject function of the timeout promise so we can re-arm it
|
|
622
|
+
let timeoutReject: ((error: Error) => void) | undefined;
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Arms (or re-arms) the timeout. When it fires:
|
|
626
|
+
* 1. If an onTimeout callback is registered, call it first.
|
|
627
|
+
* - If it returns a positive number, re-arm with that duration and update DB.
|
|
628
|
+
* - Otherwise, proceed with abort.
|
|
629
|
+
* 2. If no onTimeout callback, proceed with abort.
|
|
630
|
+
*/
|
|
631
|
+
const armTimeout = (ms: number) => {
|
|
632
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
633
|
+
timeoutId = setTimeout(() => {
|
|
634
|
+
// Check if an onTimeout callback wants to extend
|
|
635
|
+
if (onTimeoutCallback) {
|
|
636
|
+
try {
|
|
637
|
+
const extension = onTimeoutCallback();
|
|
638
|
+
if (typeof extension === 'number' && extension > 0) {
|
|
639
|
+
// Extend: re-arm timeout and update DB
|
|
640
|
+
backend.prolongJob(job.id).catch(() => {});
|
|
641
|
+
armTimeout(extension);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
} catch (callbackError) {
|
|
645
|
+
log(
|
|
646
|
+
`onTimeout callback threw for job ${job.id}: ${callbackError}`,
|
|
647
|
+
);
|
|
648
|
+
// Treat as "no extension" and proceed with abort
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
// No extension -- proceed with abort
|
|
652
|
+
controller.abort();
|
|
653
|
+
const timeoutError = new Error(`Job timed out after ${ms} ms`);
|
|
654
|
+
// @ts-ignore
|
|
655
|
+
timeoutError.failureReason = FailureReason.Timeout;
|
|
656
|
+
if (timeoutReject) {
|
|
657
|
+
timeoutReject(timeoutError);
|
|
658
|
+
}
|
|
659
|
+
}, ms);
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
const hasTimeout = timeoutMs != null && timeoutMs > 0;
|
|
663
|
+
|
|
664
|
+
// Build base prolong/onTimeout context
|
|
665
|
+
const baseCtx = hasTimeout
|
|
666
|
+
? {
|
|
667
|
+
prolong: (ms?: number) => {
|
|
668
|
+
const duration = ms ?? timeoutMs;
|
|
669
|
+
if (duration != null && duration > 0) {
|
|
670
|
+
armTimeout(duration);
|
|
671
|
+
// Update DB locked_at to prevent reclaimStuckJobs
|
|
672
|
+
backend.prolongJob(job.id).catch(() => {});
|
|
673
|
+
}
|
|
674
|
+
},
|
|
675
|
+
onTimeout: (callback: OnTimeoutCallback) => {
|
|
676
|
+
onTimeoutCallback = callback;
|
|
677
|
+
},
|
|
678
|
+
}
|
|
679
|
+
: {
|
|
680
|
+
prolong: () => {
|
|
681
|
+
log('prolong() called but ignored: job has no timeout set');
|
|
682
|
+
},
|
|
683
|
+
onTimeout: () => {
|
|
684
|
+
log('onTimeout() called but ignored: job has no timeout set');
|
|
685
|
+
},
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
// Build context: full wait support for PostgreSQL, basic for others
|
|
689
|
+
const ctx = pool
|
|
690
|
+
? buildWaitContext(backend, pool, job.id, stepData, baseCtx)
|
|
691
|
+
: buildBasicContext(backend, job.id, baseCtx);
|
|
692
|
+
|
|
693
|
+
// If forceKillOnTimeout was set but timeoutMs was missing, warn
|
|
694
|
+
if (forceKillOnTimeout && !hasTimeout) {
|
|
695
|
+
log(
|
|
696
|
+
`forceKillOnTimeout is set but no timeoutMs for job ${job.id}, running without force kill`,
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const jobPromise = handler(job.payload, controller.signal, ctx);
|
|
701
|
+
|
|
702
|
+
if (hasTimeout) {
|
|
297
703
|
await Promise.race([
|
|
298
704
|
jobPromise,
|
|
299
|
-
new Promise((_, reject) => {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
const timeoutError = new Error(
|
|
303
|
-
`Job timed out after ${timeoutMs} ms`,
|
|
304
|
-
);
|
|
305
|
-
// @ts-ignore
|
|
306
|
-
timeoutError.failureReason = FailureReason.Timeout;
|
|
307
|
-
reject(timeoutError);
|
|
308
|
-
}, timeoutMs);
|
|
705
|
+
new Promise<never>((_, reject) => {
|
|
706
|
+
timeoutReject = reject;
|
|
707
|
+
armTimeout(timeoutMs!);
|
|
309
708
|
}),
|
|
310
709
|
]);
|
|
311
710
|
} else {
|
|
@@ -313,21 +712,50 @@ export async function processJobWithHandlers<
|
|
|
313
712
|
}
|
|
314
713
|
}
|
|
315
714
|
if (timeoutId) clearTimeout(timeoutId);
|
|
316
|
-
|
|
715
|
+
|
|
716
|
+
// Job completed successfully -- complete via backend
|
|
717
|
+
await backend.completeJob(job.id);
|
|
317
718
|
} catch (error) {
|
|
318
719
|
if (timeoutId) clearTimeout(timeoutId);
|
|
720
|
+
|
|
721
|
+
// Check if this is a WaitSignal (not a real error)
|
|
722
|
+
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
|
+
log(
|
|
736
|
+
`Job ${job.id} entering wait: type=${error.type}, waitUntil=${error.waitUntil?.toISOString() ?? 'none'}, tokenId=${error.tokenId ?? 'none'}`,
|
|
737
|
+
);
|
|
738
|
+
await waitJob(pool, job.id, {
|
|
739
|
+
waitUntil: error.waitUntil,
|
|
740
|
+
waitTokenId: error.tokenId,
|
|
741
|
+
stepData: error.stepData,
|
|
742
|
+
});
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Real error -- handle as failure
|
|
319
747
|
console.error(`Error processing job ${job.id}:`, error);
|
|
320
748
|
let failureReason = FailureReason.HandlerError;
|
|
321
749
|
if (
|
|
322
750
|
error &&
|
|
323
751
|
typeof error === 'object' &&
|
|
324
752
|
'failureReason' in error &&
|
|
325
|
-
(error as
|
|
753
|
+
(error as { failureReason?: FailureReason }).failureReason ===
|
|
754
|
+
FailureReason.Timeout
|
|
326
755
|
) {
|
|
327
756
|
failureReason = FailureReason.Timeout;
|
|
328
757
|
}
|
|
329
|
-
await failJob(
|
|
330
|
-
pool,
|
|
758
|
+
await backend.failJob(
|
|
331
759
|
job.id,
|
|
332
760
|
error instanceof Error ? error : new Error(String(error)),
|
|
333
761
|
failureReason,
|
|
@@ -339,15 +767,15 @@ export async function processJobWithHandlers<
|
|
|
339
767
|
* Process a batch of jobs using the provided handler map and concurrency limit
|
|
340
768
|
*/
|
|
341
769
|
export async function processBatchWithHandlers<PayloadMap>(
|
|
342
|
-
|
|
770
|
+
backend: QueueBackend,
|
|
343
771
|
workerId: string,
|
|
344
772
|
batchSize: number,
|
|
345
773
|
jobType: string | string[] | undefined,
|
|
346
774
|
jobHandlers: JobHandlers<PayloadMap>,
|
|
347
775
|
concurrency?: number,
|
|
776
|
+
onError?: (error: Error) => void,
|
|
348
777
|
): Promise<number> {
|
|
349
|
-
const jobs = await getNextBatch<PayloadMap, JobType<PayloadMap>>(
|
|
350
|
-
pool,
|
|
778
|
+
const jobs = await backend.getNextBatch<PayloadMap, JobType<PayloadMap>>(
|
|
351
779
|
workerId,
|
|
352
780
|
batchSize,
|
|
353
781
|
jobType,
|
|
@@ -355,7 +783,7 @@ export async function processBatchWithHandlers<PayloadMap>(
|
|
|
355
783
|
if (!concurrency || concurrency >= jobs.length) {
|
|
356
784
|
// Default: all in parallel
|
|
357
785
|
await Promise.all(
|
|
358
|
-
jobs.map((job) => processJobWithHandlers(
|
|
786
|
+
jobs.map((job) => processJobWithHandlers(backend, job, jobHandlers)),
|
|
359
787
|
);
|
|
360
788
|
return jobs.length;
|
|
361
789
|
}
|
|
@@ -369,7 +797,7 @@ export async function processBatchWithHandlers<PayloadMap>(
|
|
|
369
797
|
while (running < concurrency && idx < jobs.length) {
|
|
370
798
|
const job = jobs[idx++];
|
|
371
799
|
running++;
|
|
372
|
-
processJobWithHandlers(
|
|
800
|
+
processJobWithHandlers(backend, job, jobHandlers)
|
|
373
801
|
.then(() => {
|
|
374
802
|
running--;
|
|
375
803
|
finished++;
|
|
@@ -378,6 +806,9 @@ export async function processBatchWithHandlers<PayloadMap>(
|
|
|
378
806
|
.catch((err) => {
|
|
379
807
|
running--;
|
|
380
808
|
finished++;
|
|
809
|
+
if (onError) {
|
|
810
|
+
onError(err instanceof Error ? err : new Error(String(err)));
|
|
811
|
+
}
|
|
381
812
|
next();
|
|
382
813
|
});
|
|
383
814
|
}
|
|
@@ -388,13 +819,13 @@ export async function processBatchWithHandlers<PayloadMap>(
|
|
|
388
819
|
|
|
389
820
|
/**
|
|
390
821
|
* Start a job processor that continuously processes jobs
|
|
391
|
-
* @param
|
|
822
|
+
* @param backend - The queue backend
|
|
392
823
|
* @param handlers - The job handlers for this processor instance
|
|
393
824
|
* @param options - The processor options. Leave pollInterval empty to run only once. Use jobType to filter jobs by type.
|
|
394
825
|
* @returns {Processor} The processor instance
|
|
395
826
|
*/
|
|
396
827
|
export const createProcessor = <PayloadMap = any>(
|
|
397
|
-
|
|
828
|
+
backend: QueueBackend,
|
|
398
829
|
handlers: JobHandlers<PayloadMap>,
|
|
399
830
|
options: ProcessorOptions = {},
|
|
400
831
|
): Processor => {
|
|
@@ -409,6 +840,7 @@ export const createProcessor = <PayloadMap = any>(
|
|
|
409
840
|
|
|
410
841
|
let running = false;
|
|
411
842
|
let intervalId: NodeJS.Timeout | null = null;
|
|
843
|
+
let currentBatchPromise: Promise<number> | null = null;
|
|
412
844
|
|
|
413
845
|
setLogContext(options.verbose ?? false);
|
|
414
846
|
|
|
@@ -421,12 +853,13 @@ export const createProcessor = <PayloadMap = any>(
|
|
|
421
853
|
|
|
422
854
|
try {
|
|
423
855
|
const processed = await processBatchWithHandlers(
|
|
424
|
-
|
|
856
|
+
backend,
|
|
425
857
|
workerId,
|
|
426
858
|
batchSize,
|
|
427
859
|
jobType,
|
|
428
860
|
handlers,
|
|
429
861
|
concurrency,
|
|
862
|
+
onError,
|
|
430
863
|
);
|
|
431
864
|
// Only process one batch in start; do not schedule next batch here
|
|
432
865
|
return processed;
|
|
@@ -447,27 +880,62 @@ export const createProcessor = <PayloadMap = any>(
|
|
|
447
880
|
|
|
448
881
|
log(`Starting job processor with workerId: ${workerId}`);
|
|
449
882
|
running = true;
|
|
450
|
-
|
|
451
|
-
|
|
883
|
+
|
|
884
|
+
// Single serialized loop: process a batch, then either immediately
|
|
885
|
+
// continue (if full batch was returned) or wait pollInterval.
|
|
886
|
+
const scheduleNext = (immediate: boolean) => {
|
|
452
887
|
if (!running) return;
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
888
|
+
if (immediate) {
|
|
889
|
+
intervalId = setTimeout(loop, 0);
|
|
890
|
+
} else {
|
|
891
|
+
intervalId = setTimeout(loop, pollInterval);
|
|
456
892
|
}
|
|
457
893
|
};
|
|
458
|
-
|
|
459
|
-
|
|
894
|
+
|
|
895
|
+
const loop = async () => {
|
|
896
|
+
if (!running) return;
|
|
897
|
+
currentBatchPromise = processJobs();
|
|
898
|
+
const processed = await currentBatchPromise;
|
|
899
|
+
currentBatchPromise = null;
|
|
900
|
+
// If we got a full batch, there may be more work — process immediately
|
|
901
|
+
scheduleNext(processed === batchSize);
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
// Start the first iteration immediately
|
|
905
|
+
loop();
|
|
460
906
|
},
|
|
461
907
|
/**
|
|
462
|
-
* Stop the job processor that runs in the background
|
|
908
|
+
* Stop the job processor that runs in the background.
|
|
909
|
+
* Does not wait for in-flight jobs.
|
|
463
910
|
*/
|
|
464
911
|
stop: () => {
|
|
465
912
|
log(`Stopping job processor with workerId: ${workerId}`);
|
|
466
913
|
running = false;
|
|
467
914
|
if (intervalId) {
|
|
468
|
-
|
|
915
|
+
clearTimeout(intervalId);
|
|
916
|
+
intervalId = null;
|
|
917
|
+
}
|
|
918
|
+
},
|
|
919
|
+
/**
|
|
920
|
+
* Stop the job processor and wait for all in-flight jobs to complete.
|
|
921
|
+
* Useful for graceful shutdown (e.g., SIGTERM handling).
|
|
922
|
+
*/
|
|
923
|
+
stopAndDrain: async (drainTimeoutMs = 30000) => {
|
|
924
|
+
log(`Stopping and draining job processor with workerId: ${workerId}`);
|
|
925
|
+
running = false;
|
|
926
|
+
if (intervalId) {
|
|
927
|
+
clearTimeout(intervalId);
|
|
469
928
|
intervalId = null;
|
|
470
929
|
}
|
|
930
|
+
// Wait for current batch to finish, with a timeout
|
|
931
|
+
if (currentBatchPromise) {
|
|
932
|
+
await Promise.race([
|
|
933
|
+
currentBatchPromise.catch(() => {}),
|
|
934
|
+
new Promise<void>((resolve) => setTimeout(resolve, drainTimeoutMs)),
|
|
935
|
+
]);
|
|
936
|
+
currentBatchPromise = null;
|
|
937
|
+
}
|
|
938
|
+
log(`Job processor ${workerId} drained`);
|
|
471
939
|
},
|
|
472
940
|
/**
|
|
473
941
|
* Start the job processor synchronously.
|