@nicnocquee/dataqueue 1.34.0 → 1.35.0-beta.20260224110011
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/ai/docs-content.json +27 -15
- package/ai/rules/advanced.md +78 -1
- package/ai/rules/basic.md +73 -3
- package/ai/rules/react-dashboard.md +5 -1
- package/ai/skills/dataqueue-advanced/SKILL.md +181 -0
- package/ai/skills/dataqueue-core/SKILL.md +109 -3
- package/ai/skills/dataqueue-react/SKILL.md +19 -7
- package/dist/index.cjs +1168 -173
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +394 -13
- package/dist/index.d.ts +394 -13
- package/dist/index.js +1168 -173
- package/dist/index.js.map +1 -1
- package/migrations/1781200000005_add_retry_config_to_job_queue.sql +17 -0
- package/migrations/1781200000006_add_output_to_job_queue.sql +3 -0
- package/migrations/1781200000007_add_group_fields_to_job_queue.sql +16 -0
- package/package.json +1 -1
- package/src/backend.ts +37 -3
- package/src/backends/postgres.ts +458 -76
- package/src/backends/redis-scripts.ts +273 -37
- package/src/backends/redis.test.ts +753 -0
- package/src/backends/redis.ts +253 -15
- package/src/db-util.ts +1 -1
- package/src/index.test.ts +811 -12
- package/src/index.ts +106 -14
- package/src/processor.test.ts +18 -0
- package/src/processor.ts +147 -49
- package/src/queue.test.ts +584 -0
- package/src/queue.ts +22 -3
- package/src/supervisor.test.ts +340 -0
- package/src/supervisor.ts +177 -0
- package/src/types.ts +353 -3
package/src/index.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
1
2
|
import { createProcessor } from './processor.js';
|
|
3
|
+
import { createSupervisor } from './supervisor.js';
|
|
2
4
|
import {
|
|
3
5
|
JobQueueConfig,
|
|
4
6
|
JobQueue,
|
|
5
7
|
JobOptions,
|
|
8
|
+
AddJobOptions,
|
|
6
9
|
ProcessorOptions,
|
|
10
|
+
SupervisorOptions,
|
|
7
11
|
JobHandlers,
|
|
8
12
|
JobType,
|
|
9
13
|
PostgresJobQueueConfig,
|
|
@@ -11,6 +15,9 @@ import {
|
|
|
11
15
|
CronScheduleOptions,
|
|
12
16
|
CronScheduleStatus,
|
|
13
17
|
EditCronScheduleOptions,
|
|
18
|
+
QueueEventMap,
|
|
19
|
+
QueueEventName,
|
|
20
|
+
QueueEmitFn,
|
|
14
21
|
} from './types.js';
|
|
15
22
|
import { QueueBackend, CronScheduleInput } from './backend.js';
|
|
16
23
|
import { setLogContext } from './log-context.js';
|
|
@@ -23,6 +30,8 @@ import { getNextCronOccurrence, validateCronExpression } from './cron.js';
|
|
|
23
30
|
* Initialize the job queue system.
|
|
24
31
|
*
|
|
25
32
|
* Defaults to PostgreSQL when `backend` is omitted.
|
|
33
|
+
* For PostgreSQL, provide either `databaseConfig` or `pool` (bring your own).
|
|
34
|
+
* For Redis, provide either `redisConfig` or `client` (bring your own).
|
|
26
35
|
*/
|
|
27
36
|
export const initJobQueue = <PayloadMap = any>(
|
|
28
37
|
config: JobQueueConfig,
|
|
@@ -34,15 +43,39 @@ export const initJobQueue = <PayloadMap = any>(
|
|
|
34
43
|
|
|
35
44
|
if (backendType === 'postgres') {
|
|
36
45
|
const pgConfig = config as PostgresJobQueueConfig;
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
if (pgConfig.pool) {
|
|
47
|
+
backend = new PostgresBackend(pgConfig.pool);
|
|
48
|
+
} else if (pgConfig.databaseConfig) {
|
|
49
|
+
const pool = createPool(pgConfig.databaseConfig);
|
|
50
|
+
backend = new PostgresBackend(pool);
|
|
51
|
+
} else {
|
|
52
|
+
throw new Error(
|
|
53
|
+
'PostgreSQL backend requires either "databaseConfig" or "pool" to be provided.',
|
|
54
|
+
);
|
|
55
|
+
}
|
|
39
56
|
} else if (backendType === 'redis') {
|
|
40
|
-
const redisConfig =
|
|
41
|
-
|
|
57
|
+
const redisConfig = config as RedisJobQueueConfig;
|
|
58
|
+
if (redisConfig.client) {
|
|
59
|
+
backend = new RedisBackend(
|
|
60
|
+
redisConfig.client as any,
|
|
61
|
+
redisConfig.keyPrefix,
|
|
62
|
+
);
|
|
63
|
+
} else if (redisConfig.redisConfig) {
|
|
64
|
+
backend = new RedisBackend(redisConfig.redisConfig);
|
|
65
|
+
} else {
|
|
66
|
+
throw new Error(
|
|
67
|
+
'Redis backend requires either "redisConfig" or "client" to be provided.',
|
|
68
|
+
);
|
|
69
|
+
}
|
|
42
70
|
} else {
|
|
43
71
|
throw new Error(`Unknown backend: ${backendType}`);
|
|
44
72
|
}
|
|
45
73
|
|
|
74
|
+
const emitter = new EventEmitter();
|
|
75
|
+
const emit: QueueEmitFn = (event, data) => {
|
|
76
|
+
emitter.emit(event, data);
|
|
77
|
+
};
|
|
78
|
+
|
|
46
79
|
/**
|
|
47
80
|
* Enqueue due cron jobs. Shared by the public API and the processor hook.
|
|
48
81
|
*/
|
|
@@ -84,6 +117,9 @@ export const initJobQueue = <PayloadMap = any>(
|
|
|
84
117
|
timeoutMs: schedule.timeoutMs ?? undefined,
|
|
85
118
|
forceKillOnTimeout: schedule.forceKillOnTimeout,
|
|
86
119
|
tags: schedule.tags,
|
|
120
|
+
retryDelay: schedule.retryDelay ?? undefined,
|
|
121
|
+
retryBackoff: schedule.retryBackoff ?? undefined,
|
|
122
|
+
retryDelayMax: schedule.retryDelayMax ?? undefined,
|
|
87
123
|
});
|
|
88
124
|
|
|
89
125
|
// Advance to next occurrence
|
|
@@ -107,8 +143,21 @@ export const initJobQueue = <PayloadMap = any>(
|
|
|
107
143
|
return {
|
|
108
144
|
// Job queue operations
|
|
109
145
|
addJob: withLogContext(
|
|
110
|
-
(job: JobOptions<PayloadMap, any
|
|
111
|
-
backend.addJob<PayloadMap, any>(job)
|
|
146
|
+
async (job: JobOptions<PayloadMap, any>, options?: AddJobOptions) => {
|
|
147
|
+
const jobId = await backend.addJob<PayloadMap, any>(job, options);
|
|
148
|
+
emit('job:added', { jobId, jobType: job.jobType });
|
|
149
|
+
return jobId;
|
|
150
|
+
},
|
|
151
|
+
config.verbose ?? false,
|
|
152
|
+
),
|
|
153
|
+
addJobs: withLogContext(
|
|
154
|
+
async (jobs: JobOptions<PayloadMap, any>[], options?: AddJobOptions) => {
|
|
155
|
+
const jobIds = await backend.addJobs<PayloadMap, any>(jobs, options);
|
|
156
|
+
for (let i = 0; i < jobIds.length; i++) {
|
|
157
|
+
emit('job:added', { jobId: jobIds[i], jobType: jobs[i].jobType });
|
|
158
|
+
}
|
|
159
|
+
return jobIds;
|
|
160
|
+
},
|
|
112
161
|
config.verbose ?? false,
|
|
113
162
|
),
|
|
114
163
|
getJob: withLogContext(
|
|
@@ -140,15 +189,18 @@ export const initJobQueue = <PayloadMap = any>(
|
|
|
140
189
|
) => backend.getJobs<PayloadMap, any>(filters, limit, offset),
|
|
141
190
|
config.verbose ?? false,
|
|
142
191
|
),
|
|
143
|
-
retryJob: (jobId: number) =>
|
|
192
|
+
retryJob: async (jobId: number) => {
|
|
193
|
+
await backend.retryJob(jobId);
|
|
194
|
+
emit('job:retried', { jobId });
|
|
195
|
+
},
|
|
144
196
|
cleanupOldJobs: (daysToKeep?: number, batchSize?: number) =>
|
|
145
197
|
backend.cleanupOldJobs(daysToKeep, batchSize),
|
|
146
198
|
cleanupOldJobEvents: (daysToKeep?: number, batchSize?: number) =>
|
|
147
199
|
backend.cleanupOldJobEvents(daysToKeep, batchSize),
|
|
148
|
-
cancelJob: withLogContext(
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
),
|
|
200
|
+
cancelJob: withLogContext(async (jobId: number) => {
|
|
201
|
+
await backend.cancelJob(jobId);
|
|
202
|
+
emit('job:cancelled', { jobId });
|
|
203
|
+
}, config.verbose ?? false),
|
|
152
204
|
editJob: withLogContext(
|
|
153
205
|
<T extends JobType<PayloadMap>>(
|
|
154
206
|
jobId: number,
|
|
@@ -206,9 +258,19 @@ export const initJobQueue = <PayloadMap = any>(
|
|
|
206
258
|
handlers: JobHandlers<PayloadMap>,
|
|
207
259
|
options?: ProcessorOptions,
|
|
208
260
|
) =>
|
|
209
|
-
createProcessor<PayloadMap>(
|
|
210
|
-
|
|
211
|
-
|
|
261
|
+
createProcessor<PayloadMap>(
|
|
262
|
+
backend,
|
|
263
|
+
handlers,
|
|
264
|
+
options,
|
|
265
|
+
async () => {
|
|
266
|
+
await enqueueDueCronJobsImpl();
|
|
267
|
+
},
|
|
268
|
+
emit,
|
|
269
|
+
),
|
|
270
|
+
|
|
271
|
+
// Background supervisor — automated maintenance
|
|
272
|
+
createSupervisor: (options?: SupervisorOptions) =>
|
|
273
|
+
createSupervisor(backend, options, emit),
|
|
212
274
|
|
|
213
275
|
// Job events
|
|
214
276
|
getJobEvents: withLogContext(
|
|
@@ -262,6 +324,9 @@ export const initJobQueue = <PayloadMap = any>(
|
|
|
262
324
|
timezone: options.timezone ?? 'UTC',
|
|
263
325
|
allowOverlap: options.allowOverlap ?? false,
|
|
264
326
|
nextRunAt,
|
|
327
|
+
retryDelay: options.retryDelay ?? null,
|
|
328
|
+
retryBackoff: options.retryBackoff ?? null,
|
|
329
|
+
retryDelayMax: options.retryDelayMax ?? null,
|
|
265
330
|
};
|
|
266
331
|
return backend.addCronSchedule(input);
|
|
267
332
|
},
|
|
@@ -320,6 +385,33 @@ export const initJobQueue = <PayloadMap = any>(
|
|
|
320
385
|
config.verbose ?? false,
|
|
321
386
|
),
|
|
322
387
|
|
|
388
|
+
// Event hooks
|
|
389
|
+
on: <K extends QueueEventName>(
|
|
390
|
+
event: K,
|
|
391
|
+
listener: (data: QueueEventMap[K]) => void,
|
|
392
|
+
) => {
|
|
393
|
+
emitter.on(event, listener as (...args: any[]) => void);
|
|
394
|
+
},
|
|
395
|
+
once: <K extends QueueEventName>(
|
|
396
|
+
event: K,
|
|
397
|
+
listener: (data: QueueEventMap[K]) => void,
|
|
398
|
+
) => {
|
|
399
|
+
emitter.once(event, listener as (...args: any[]) => void);
|
|
400
|
+
},
|
|
401
|
+
off: <K extends QueueEventName>(
|
|
402
|
+
event: K,
|
|
403
|
+
listener: (data: QueueEventMap[K]) => void,
|
|
404
|
+
) => {
|
|
405
|
+
emitter.off(event, listener as (...args: any[]) => void);
|
|
406
|
+
},
|
|
407
|
+
removeAllListeners: (event?: QueueEventName) => {
|
|
408
|
+
if (event) {
|
|
409
|
+
emitter.removeAllListeners(event);
|
|
410
|
+
} else {
|
|
411
|
+
emitter.removeAllListeners();
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
|
|
323
415
|
// Advanced access
|
|
324
416
|
getPool: () => {
|
|
325
417
|
if (!(backend instanceof PostgresBackend)) {
|
package/src/processor.test.ts
CHANGED
|
@@ -436,6 +436,24 @@ describe('concurrency option', () => {
|
|
|
436
436
|
await processor.start();
|
|
437
437
|
expect(maxParallel).toBe(1);
|
|
438
438
|
});
|
|
439
|
+
|
|
440
|
+
it('should throw when groupConcurrency is not a positive integer', async () => {
|
|
441
|
+
const handlers = { test: vi.fn(async () => {}) };
|
|
442
|
+
expect(() =>
|
|
443
|
+
createProcessor(backend, handlers, {
|
|
444
|
+
groupConcurrency: 0,
|
|
445
|
+
}),
|
|
446
|
+
).toThrow(
|
|
447
|
+
'Processor option "groupConcurrency" must be a positive integer when provided.',
|
|
448
|
+
);
|
|
449
|
+
expect(() =>
|
|
450
|
+
createProcessor(backend, handlers, {
|
|
451
|
+
groupConcurrency: 1.5,
|
|
452
|
+
}),
|
|
453
|
+
).toThrow(
|
|
454
|
+
'Processor option "groupConcurrency" must be a positive integer when provided.',
|
|
455
|
+
);
|
|
456
|
+
});
|
|
439
457
|
});
|
|
440
458
|
|
|
441
459
|
describe('per-job timeout', () => {
|
package/src/processor.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
WaitSignal,
|
|
13
13
|
WaitDuration,
|
|
14
14
|
WaitTokenResult,
|
|
15
|
+
QueueEmitFn,
|
|
15
16
|
} from './types.js';
|
|
16
17
|
import { QueueBackend } from './backend.js';
|
|
17
18
|
import { log, setLogContext } from './log-context.js';
|
|
@@ -90,7 +91,7 @@ async function runHandlerInWorker<
|
|
|
90
91
|
payload: PayloadMap[T],
|
|
91
92
|
timeoutMs: number,
|
|
92
93
|
jobType: string,
|
|
93
|
-
): Promise<
|
|
94
|
+
): Promise<unknown> {
|
|
94
95
|
// Validate handler can be serialized before attempting to run in worker
|
|
95
96
|
validateHandlerSerializable(handler, jobType);
|
|
96
97
|
|
|
@@ -155,9 +156,9 @@ async function runHandlerInWorker<
|
|
|
155
156
|
}
|
|
156
157
|
|
|
157
158
|
handlerFn(payload, signal)
|
|
158
|
-
.then(() => {
|
|
159
|
+
.then((result) => {
|
|
159
160
|
clearTimeout(timeoutId);
|
|
160
|
-
parentPort.postMessage({ type: 'success' });
|
|
161
|
+
parentPort.postMessage({ type: 'success', output: result });
|
|
161
162
|
})
|
|
162
163
|
.catch((error) => {
|
|
163
164
|
clearTimeout(timeoutId);
|
|
@@ -195,26 +196,29 @@ async function runHandlerInWorker<
|
|
|
195
196
|
|
|
196
197
|
let resolved = false;
|
|
197
198
|
|
|
198
|
-
worker.on(
|
|
199
|
-
|
|
200
|
-
|
|
199
|
+
worker.on(
|
|
200
|
+
'message',
|
|
201
|
+
(message: { type: string; error?: any; output?: unknown }) => {
|
|
202
|
+
if (resolved) return;
|
|
203
|
+
resolved = true;
|
|
201
204
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
205
|
+
if (message.type === 'success') {
|
|
206
|
+
resolve(message.output);
|
|
207
|
+
} else if (message.type === 'timeout') {
|
|
208
|
+
const timeoutError = new Error(
|
|
209
|
+
`Job timed out after ${timeoutMs} ms and was forcefully terminated`,
|
|
210
|
+
);
|
|
211
|
+
// @ts-ignore
|
|
212
|
+
timeoutError.failureReason = FailureReason.Timeout;
|
|
213
|
+
reject(timeoutError);
|
|
214
|
+
} else if (message.type === 'error') {
|
|
215
|
+
const error = new Error(message.error.message);
|
|
216
|
+
error.stack = message.error.stack;
|
|
217
|
+
error.name = message.error.name;
|
|
218
|
+
reject(error);
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
);
|
|
218
222
|
|
|
219
223
|
worker.on('error', (error) => {
|
|
220
224
|
if (resolved) return;
|
|
@@ -318,6 +322,9 @@ function createNoOpContext(
|
|
|
318
322
|
throw new Error('Progress must be between 0 and 100');
|
|
319
323
|
await backend.updateProgress(jobId, Math.round(percent));
|
|
320
324
|
},
|
|
325
|
+
setOutput: async (data: unknown) => {
|
|
326
|
+
await backend.updateOutput(jobId, data);
|
|
327
|
+
},
|
|
321
328
|
};
|
|
322
329
|
}
|
|
323
330
|
|
|
@@ -495,13 +502,21 @@ function buildWaitContext(
|
|
|
495
502
|
throw new Error('Progress must be between 0 and 100');
|
|
496
503
|
await backend.updateProgress(jobId, Math.round(percent));
|
|
497
504
|
},
|
|
505
|
+
setOutput: async (data: unknown) => {
|
|
506
|
+
await backend.updateOutput(jobId, data);
|
|
507
|
+
},
|
|
498
508
|
};
|
|
499
509
|
|
|
500
510
|
return ctx;
|
|
501
511
|
}
|
|
502
512
|
|
|
503
513
|
/**
|
|
504
|
-
* Process a single job using the provided handler map
|
|
514
|
+
* Process a single job using the provided handler map.
|
|
515
|
+
*
|
|
516
|
+
* @param backend - The queue backend.
|
|
517
|
+
* @param job - The job record to process.
|
|
518
|
+
* @param jobHandlers - Map of job type to handler function.
|
|
519
|
+
* @param emit - Optional callback to emit lifecycle events to the queue's EventEmitter.
|
|
505
520
|
*/
|
|
506
521
|
export async function processJobWithHandlers<
|
|
507
522
|
PayloadMap,
|
|
@@ -510,6 +525,7 @@ export async function processJobWithHandlers<
|
|
|
510
525
|
backend: QueueBackend,
|
|
511
526
|
job: JobRecord<PayloadMap, T>,
|
|
512
527
|
jobHandlers: JobHandlers<PayloadMap>,
|
|
528
|
+
emit?: QueueEmitFn,
|
|
513
529
|
): Promise<void> {
|
|
514
530
|
const handler = jobHandlers[job.jobType];
|
|
515
531
|
|
|
@@ -518,11 +534,16 @@ export async function processJobWithHandlers<
|
|
|
518
534
|
`No handler registered for job type: ${job.jobType}`,
|
|
519
535
|
job.jobType,
|
|
520
536
|
);
|
|
521
|
-
|
|
522
|
-
job.
|
|
523
|
-
new Error(`No handler registered for job type: ${job.jobType}`),
|
|
524
|
-
FailureReason.NoHandler,
|
|
537
|
+
const noHandlerError = new Error(
|
|
538
|
+
`No handler registered for job type: ${job.jobType}`,
|
|
525
539
|
);
|
|
540
|
+
await backend.failJob(job.id, noHandlerError, FailureReason.NoHandler);
|
|
541
|
+
emit?.('job:failed', {
|
|
542
|
+
jobId: job.id,
|
|
543
|
+
jobType: job.jobType,
|
|
544
|
+
error: noHandlerError,
|
|
545
|
+
willRetry: false,
|
|
546
|
+
});
|
|
526
547
|
return;
|
|
527
548
|
}
|
|
528
549
|
|
|
@@ -544,11 +565,18 @@ export async function processJobWithHandlers<
|
|
|
544
565
|
const forceKillOnTimeout = job.forceKillOnTimeout ?? false;
|
|
545
566
|
let timeoutId: NodeJS.Timeout | undefined;
|
|
546
567
|
const controller = new AbortController();
|
|
568
|
+
let setOutputCalled = false;
|
|
569
|
+
let handlerReturnValue: unknown;
|
|
547
570
|
try {
|
|
548
571
|
// If forceKillOnTimeout is true, run handler in a worker thread
|
|
549
|
-
// Note: wait features are not available in forceKillOnTimeout mode
|
|
572
|
+
// Note: wait features and setOutput are not available in forceKillOnTimeout mode
|
|
550
573
|
if (forceKillOnTimeout && timeoutMs && timeoutMs > 0) {
|
|
551
|
-
await runHandlerInWorker(
|
|
574
|
+
handlerReturnValue = await runHandlerInWorker(
|
|
575
|
+
handler,
|
|
576
|
+
job.payload,
|
|
577
|
+
timeoutMs,
|
|
578
|
+
job.jobType,
|
|
579
|
+
);
|
|
552
580
|
} else {
|
|
553
581
|
// Build the JobContext for prolong/onTimeout support
|
|
554
582
|
let onTimeoutCallback: OnTimeoutCallback | undefined;
|
|
@@ -623,6 +651,26 @@ export async function processJobWithHandlers<
|
|
|
623
651
|
// Build context: full wait support for all backends
|
|
624
652
|
const ctx = buildWaitContext(backend, job.id, stepData, baseCtx);
|
|
625
653
|
|
|
654
|
+
// Wrap setProgress to also emit the event
|
|
655
|
+
if (emit) {
|
|
656
|
+
const originalSetProgress = ctx.setProgress;
|
|
657
|
+
ctx.setProgress = async (percent: number) => {
|
|
658
|
+
await originalSetProgress(percent);
|
|
659
|
+
emit('job:progress', {
|
|
660
|
+
jobId: job.id,
|
|
661
|
+
progress: Math.round(percent),
|
|
662
|
+
});
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Wrap setOutput to track calls and emit the event
|
|
667
|
+
const originalSetOutput = ctx.setOutput;
|
|
668
|
+
ctx.setOutput = async (data: unknown) => {
|
|
669
|
+
setOutputCalled = true;
|
|
670
|
+
await originalSetOutput(data);
|
|
671
|
+
emit?.('job:output', { jobId: job.id, output: data });
|
|
672
|
+
};
|
|
673
|
+
|
|
626
674
|
// If forceKillOnTimeout was set but timeoutMs was missing, warn
|
|
627
675
|
if (forceKillOnTimeout && !hasTimeout) {
|
|
628
676
|
log(
|
|
@@ -633,7 +681,7 @@ export async function processJobWithHandlers<
|
|
|
633
681
|
const jobPromise = handler(job.payload, controller.signal, ctx);
|
|
634
682
|
|
|
635
683
|
if (hasTimeout) {
|
|
636
|
-
await Promise.race([
|
|
684
|
+
handlerReturnValue = await Promise.race([
|
|
637
685
|
jobPromise,
|
|
638
686
|
new Promise<never>((_, reject) => {
|
|
639
687
|
timeoutReject = reject;
|
|
@@ -641,13 +689,22 @@ export async function processJobWithHandlers<
|
|
|
641
689
|
}),
|
|
642
690
|
]);
|
|
643
691
|
} else {
|
|
644
|
-
await jobPromise;
|
|
692
|
+
handlerReturnValue = await jobPromise;
|
|
645
693
|
}
|
|
646
694
|
}
|
|
647
695
|
if (timeoutId) clearTimeout(timeoutId);
|
|
648
696
|
|
|
697
|
+
// Determine the output to persist on completion.
|
|
698
|
+
// If setOutput() was called, the value is already in the DB -- pass undefined
|
|
699
|
+
// so completeJob preserves it. Otherwise, use the handler's return value.
|
|
700
|
+
const completionOutput =
|
|
701
|
+
setOutputCalled || handlerReturnValue === undefined
|
|
702
|
+
? undefined
|
|
703
|
+
: handlerReturnValue;
|
|
704
|
+
|
|
649
705
|
// Job completed successfully -- complete via backend
|
|
650
|
-
await backend.completeJob(job.id);
|
|
706
|
+
await backend.completeJob(job.id, completionOutput);
|
|
707
|
+
emit?.('job:completed', { jobId: job.id, jobType: job.jobType });
|
|
651
708
|
} catch (error) {
|
|
652
709
|
if (timeoutId) clearTimeout(timeoutId);
|
|
653
710
|
|
|
@@ -661,6 +718,7 @@ export async function processJobWithHandlers<
|
|
|
661
718
|
waitTokenId: error.tokenId,
|
|
662
719
|
stepData: error.stepData,
|
|
663
720
|
});
|
|
721
|
+
emit?.('job:waiting', { jobId: job.id, jobType: job.jobType });
|
|
664
722
|
return;
|
|
665
723
|
}
|
|
666
724
|
|
|
@@ -676,16 +734,29 @@ export async function processJobWithHandlers<
|
|
|
676
734
|
) {
|
|
677
735
|
failureReason = FailureReason.Timeout;
|
|
678
736
|
}
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
737
|
+
const failError = error instanceof Error ? error : new Error(String(error));
|
|
738
|
+
await backend.failJob(job.id, failError, failureReason);
|
|
739
|
+
emit?.('job:failed', {
|
|
740
|
+
jobId: job.id,
|
|
741
|
+
jobType: job.jobType,
|
|
742
|
+
error: failError,
|
|
743
|
+
willRetry: job.attempts + 1 < job.maxAttempts,
|
|
744
|
+
});
|
|
684
745
|
}
|
|
685
746
|
}
|
|
686
747
|
|
|
687
748
|
/**
|
|
688
|
-
* Process a batch of jobs using the provided handler map and concurrency limit
|
|
749
|
+
* Process a batch of jobs using the provided handler map and concurrency limit.
|
|
750
|
+
*
|
|
751
|
+
* @param backend - The queue backend.
|
|
752
|
+
* @param workerId - Identifier for the worker claiming jobs.
|
|
753
|
+
* @param batchSize - Maximum jobs to claim per batch.
|
|
754
|
+
* @param jobType - Optional job type filter.
|
|
755
|
+
* @param jobHandlers - Map of job type to handler function.
|
|
756
|
+
* @param concurrency - Max parallel jobs within the batch.
|
|
757
|
+
* @param groupConcurrency - Optional global per-group concurrency limit.
|
|
758
|
+
* @param onError - Legacy error callback.
|
|
759
|
+
* @param emit - Optional callback to emit lifecycle events.
|
|
689
760
|
*/
|
|
690
761
|
export async function processBatchWithHandlers<PayloadMap>(
|
|
691
762
|
backend: QueueBackend,
|
|
@@ -694,17 +765,29 @@ export async function processBatchWithHandlers<PayloadMap>(
|
|
|
694
765
|
jobType: string | string[] | undefined,
|
|
695
766
|
jobHandlers: JobHandlers<PayloadMap>,
|
|
696
767
|
concurrency?: number,
|
|
768
|
+
groupConcurrency?: number,
|
|
697
769
|
onError?: (error: Error) => void,
|
|
770
|
+
emit?: QueueEmitFn,
|
|
698
771
|
): Promise<number> {
|
|
699
772
|
const jobs = await backend.getNextBatch<PayloadMap, JobType<PayloadMap>>(
|
|
700
773
|
workerId,
|
|
701
774
|
batchSize,
|
|
702
775
|
jobType,
|
|
776
|
+
groupConcurrency,
|
|
703
777
|
);
|
|
778
|
+
|
|
779
|
+
// Emit job:processing for each claimed job
|
|
780
|
+
if (emit) {
|
|
781
|
+
for (const job of jobs) {
|
|
782
|
+
emit('job:processing', { jobId: job.id, jobType: job.jobType });
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
704
786
|
if (!concurrency || concurrency >= jobs.length) {
|
|
705
|
-
// Default: all in parallel
|
|
706
787
|
await Promise.all(
|
|
707
|
-
jobs.map((job) =>
|
|
788
|
+
jobs.map((job) =>
|
|
789
|
+
processJobWithHandlers(backend, job, jobHandlers, emit),
|
|
790
|
+
),
|
|
708
791
|
);
|
|
709
792
|
return jobs.length;
|
|
710
793
|
}
|
|
@@ -718,7 +801,7 @@ export async function processBatchWithHandlers<PayloadMap>(
|
|
|
718
801
|
while (running < concurrency && idx < jobs.length) {
|
|
719
802
|
const job = jobs[idx++];
|
|
720
803
|
running++;
|
|
721
|
-
processJobWithHandlers(backend, job, jobHandlers)
|
|
804
|
+
processJobWithHandlers(backend, job, jobHandlers, emit)
|
|
722
805
|
.then(() => {
|
|
723
806
|
running--;
|
|
724
807
|
finished++;
|
|
@@ -740,17 +823,20 @@ export async function processBatchWithHandlers<PayloadMap>(
|
|
|
740
823
|
|
|
741
824
|
/**
|
|
742
825
|
* Start a job processor that continuously processes jobs.
|
|
826
|
+
*
|
|
743
827
|
* @param backend - The queue backend.
|
|
744
828
|
* @param handlers - The job handlers for this processor instance.
|
|
745
829
|
* @param options - The processor options. Leave pollInterval empty to run only once. Use jobType to filter jobs by type.
|
|
746
830
|
* @param onBeforeBatch - Optional callback invoked before each batch. Used internally to enqueue due cron jobs.
|
|
747
|
-
* @
|
|
831
|
+
* @param emit - Optional callback to emit lifecycle events to the queue's EventEmitter.
|
|
832
|
+
* @returns The processor instance.
|
|
748
833
|
*/
|
|
749
834
|
export const createProcessor = <PayloadMap = any>(
|
|
750
835
|
backend: QueueBackend,
|
|
751
836
|
handlers: JobHandlers<PayloadMap>,
|
|
752
837
|
options: ProcessorOptions = {},
|
|
753
838
|
onBeforeBatch?: () => Promise<void>,
|
|
839
|
+
emit?: QueueEmitFn,
|
|
754
840
|
): Processor => {
|
|
755
841
|
const {
|
|
756
842
|
workerId = `worker-${Math.random().toString(36).substring(2, 9)}`,
|
|
@@ -759,8 +845,18 @@ export const createProcessor = <PayloadMap = any>(
|
|
|
759
845
|
onError = (error: Error) => console.error('Job processor error:', error),
|
|
760
846
|
jobType,
|
|
761
847
|
concurrency = 3,
|
|
848
|
+
groupConcurrency,
|
|
762
849
|
} = options;
|
|
763
850
|
|
|
851
|
+
if (
|
|
852
|
+
groupConcurrency !== undefined &&
|
|
853
|
+
(!Number.isInteger(groupConcurrency) || groupConcurrency <= 0)
|
|
854
|
+
) {
|
|
855
|
+
throw new Error(
|
|
856
|
+
'Processor option "groupConcurrency" must be a positive integer when provided.',
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
|
|
764
860
|
let running = false;
|
|
765
861
|
let intervalId: NodeJS.Timeout | null = null;
|
|
766
862
|
let currentBatchPromise: Promise<number> | null = null;
|
|
@@ -776,13 +872,12 @@ export const createProcessor = <PayloadMap = any>(
|
|
|
776
872
|
await onBeforeBatch();
|
|
777
873
|
} catch (hookError) {
|
|
778
874
|
log(`onBeforeBatch hook error: ${hookError}`);
|
|
875
|
+
const err =
|
|
876
|
+
hookError instanceof Error ? hookError : new Error(String(hookError));
|
|
779
877
|
if (onError) {
|
|
780
|
-
onError(
|
|
781
|
-
hookError instanceof Error
|
|
782
|
-
? hookError
|
|
783
|
-
: new Error(String(hookError)),
|
|
784
|
-
);
|
|
878
|
+
onError(err);
|
|
785
879
|
}
|
|
880
|
+
emit?.('error', err);
|
|
786
881
|
}
|
|
787
882
|
}
|
|
788
883
|
|
|
@@ -798,12 +893,15 @@ export const createProcessor = <PayloadMap = any>(
|
|
|
798
893
|
jobType,
|
|
799
894
|
handlers,
|
|
800
895
|
concurrency,
|
|
896
|
+
groupConcurrency,
|
|
801
897
|
onError,
|
|
898
|
+
emit,
|
|
802
899
|
);
|
|
803
|
-
// Only process one batch in start; do not schedule next batch here
|
|
804
900
|
return processed;
|
|
805
901
|
} catch (error) {
|
|
806
|
-
|
|
902
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
903
|
+
onError(err);
|
|
904
|
+
emit?.('error', err);
|
|
807
905
|
}
|
|
808
906
|
return 0;
|
|
809
907
|
};
|