@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/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
- getNextBatch,
14
- completeJob,
15
- failJob,
16
- setPendingReasonForUnpickedJobs,
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
- pool: Pool,
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
- // Default: graceful shutdown with AbortController
295
- const jobPromise = handler(job.payload, controller.signal);
296
- if (timeoutMs && timeoutMs > 0) {
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
- timeoutId = setTimeout(() => {
301
- controller.abort();
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
- await completeJob(pool, job.id);
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 any).failureReason === FailureReason.Timeout
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
- pool: Pool,
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(pool, job, jobHandlers)),
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(pool, job, jobHandlers)
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 pool - The database pool
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
- pool: Pool,
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
- pool,
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
- // Background: process batches repeatedly if needed
451
- const processBatches = async () => {
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
- const processed = await processJobs();
454
- if (processed === batchSize && running) {
455
- setImmediate(processBatches);
888
+ if (immediate) {
889
+ intervalId = setTimeout(loop, 0);
890
+ } else {
891
+ intervalId = setTimeout(loop, pollInterval);
456
892
  }
457
893
  };
458
- processBatches(); // Process immediately on start
459
- intervalId = setInterval(processJobs, pollInterval);
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
- clearInterval(intervalId);
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.