@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/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
- const pool = createPool(pgConfig.databaseConfig);
38
- backend = new PostgresBackend(pool);
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 = (config as RedisJobQueueConfig).redisConfig;
41
- backend = new RedisBackend(redisConfig);
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) => backend.retryJob(jobId),
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
- (jobId: number) => backend.cancelJob(jobId),
150
- config.verbose ?? false,
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>(backend, handlers, options, async () => {
210
- await enqueueDueCronJobsImpl();
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)) {
@@ -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<void> {
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('message', (message: { type: string; error?: any }) => {
199
- if (resolved) return;
200
- resolved = true;
199
+ worker.on(
200
+ 'message',
201
+ (message: { type: string; error?: any; output?: unknown }) => {
202
+ if (resolved) return;
203
+ resolved = true;
201
204
 
202
- if (message.type === 'success') {
203
- resolve();
204
- } else if (message.type === 'timeout') {
205
- const timeoutError = new Error(
206
- `Job timed out after ${timeoutMs} ms and was forcefully terminated`,
207
- );
208
- // @ts-ignore
209
- timeoutError.failureReason = FailureReason.Timeout;
210
- reject(timeoutError);
211
- } else if (message.type === 'error') {
212
- const error = new Error(message.error.message);
213
- error.stack = message.error.stack;
214
- error.name = message.error.name;
215
- reject(error);
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
- await backend.failJob(
522
- job.id,
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(handler, job.payload, timeoutMs, job.jobType);
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
- await backend.failJob(
680
- job.id,
681
- error instanceof Error ? error : new Error(String(error)),
682
- failureReason,
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) => processJobWithHandlers(backend, job, jobHandlers)),
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
- * @returns {Processor} The processor instance.
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
- onError(error instanceof Error ? error : new Error(String(error)));
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
  };