@nicnocquee/dataqueue 1.39.0 → 1.40.0-beta.20260612032253

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nicnocquee/dataqueue",
3
- "version": "1.39.0",
3
+ "version": "1.40.0-beta.20260612032253",
4
4
  "description": "PostgreSQL or Redis-backed job queue for Node.js applications with support for serverless environments",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -33,6 +33,25 @@ async function claimJob(p: Pool, jobId: number) {
33
33
  );
34
34
  }
35
35
 
36
+ /**
37
+ * Polls `predicate` until it is truthy or the timeout elapses.
38
+ * Throws if the condition is never met, so a hung pool fails fast instead of
39
+ * timing out the whole test.
40
+ */
41
+ async function waitFor(
42
+ predicate: () => boolean | Promise<boolean>,
43
+ timeoutMs: number,
44
+ pollMs = 20,
45
+ ): Promise<void> {
46
+ const deadline = Date.now() + timeoutMs;
47
+ while (!(await predicate())) {
48
+ if (Date.now() > deadline) {
49
+ throw new Error(`waitFor: condition not met within ${timeoutMs}ms`);
50
+ }
51
+ await new Promise((r) => setTimeout(r, pollMs));
52
+ }
53
+ }
54
+
36
55
  // Integration tests for processor
37
56
 
38
57
  describe('processor integration', () => {
@@ -494,6 +513,116 @@ describe('concurrency option', () => {
494
513
  'Processor option "groupConcurrency" must be a positive integer when provided.',
495
514
  );
496
515
  });
516
+
517
+ it('should refill freed group slots while a slow job runs, honoring groupConcurrency (continuous pool)', async () => {
518
+ // Regression guard for the batch-barrier stall on grouped pipelines.
519
+ // groupConcurrency caps each claim below the number of ready jobs, so the
520
+ // rest land in later claims. With the old loop those later claims never
521
+ // happened until the whole in-flight batch drained, so one slow job stalled
522
+ // the entire group. The continuous pool must keep refilling the freed group
523
+ // slot — without ever exceeding groupConcurrency — so every fast job
524
+ // completes while the slow job is still in flight.
525
+ const GROUP = 'pipeline-1';
526
+ const FAST_JOBS = 5;
527
+
528
+ let releaseSlow!: () => void;
529
+ const slowReleased = new Promise<void>((resolve) => {
530
+ releaseSlow = resolve;
531
+ });
532
+ let markSlowStarted!: () => void;
533
+ const slowStarted = new Promise<void>((resolve) => {
534
+ markSlowStarted = resolve;
535
+ });
536
+ let fastCompleted = 0;
537
+ let inHandler = 0;
538
+ let maxInHandler = 0;
539
+
540
+ const handler = async (payload: { slow?: boolean }) => {
541
+ inHandler++;
542
+ maxInHandler = Math.max(maxInHandler, inHandler);
543
+ try {
544
+ if (payload.slow) {
545
+ markSlowStarted();
546
+ await slowReleased;
547
+
548
+ return;
549
+ }
550
+ fastCompleted++;
551
+ } finally {
552
+ inHandler--;
553
+ }
554
+ };
555
+ const handlers = { test: handler };
556
+
557
+ // High priority so the slow job is always claimed into the first group slot.
558
+ const slowId = await queue.addJob<{ test: { slow?: boolean } }, 'test'>(
559
+ pool,
560
+ {
561
+ jobType: 'test',
562
+ payload: { slow: true },
563
+ priority: 10,
564
+ group: { id: GROUP },
565
+ },
566
+ );
567
+ for (let i = 0; i < FAST_JOBS; i++) {
568
+ await queue.addJob<{ test: { slow?: boolean } }, 'test'>(pool, {
569
+ jobType: 'test',
570
+ payload: {},
571
+ group: { id: GROUP },
572
+ });
573
+ }
574
+
575
+ const processor = createProcessor(backend, handlers, {
576
+ batchSize: 10,
577
+ concurrency: 2,
578
+ groupConcurrency: 2,
579
+ pollInterval: 50,
580
+ });
581
+ processor.startInBackground();
582
+ try {
583
+ // Wait until the slow job actually occupies a group slot.
584
+ await slowStarted;
585
+
586
+ // Every fast job must finish while the slow job is still in flight. Under
587
+ // the old barrier this never happens (the loop waits on the slow job
588
+ // before claiming the rest of the group), so waitFor throws instead of
589
+ // the whole test hanging.
590
+ await waitFor(() => fastCompleted === FAST_JOBS, 5000);
591
+
592
+ expect(fastCompleted).toBe(FAST_JOBS);
593
+ // groupConcurrency was honored throughout (slow + at most one fast).
594
+ expect(maxInHandler).toBeLessThanOrEqual(2);
595
+
596
+ // The slow job never blocked the group — it is still processing.
597
+ const slowJob = await queue.getJob<{ test: { slow?: boolean } }, 'test'>(
598
+ pool,
599
+ slowId,
600
+ );
601
+
602
+ expect(slowJob?.status).toBe('processing');
603
+
604
+ // Release the slow job and let everything drain cleanly.
605
+ releaseSlow();
606
+ await waitFor(async () => {
607
+ const job = await queue.getJob<{ test: { slow?: boolean } }, 'test'>(
608
+ pool,
609
+ slowId,
610
+ );
611
+
612
+ return job?.status === 'completed';
613
+ }, 5000);
614
+ } finally {
615
+ releaseSlow();
616
+ await processor.stopAndDrain(5000);
617
+ }
618
+
619
+ const completed = await queue.getJobsByStatus<
620
+ { test: { slow?: boolean } },
621
+ 'test'
622
+ >(pool, 'completed');
623
+
624
+ expect(completed.length).toBe(FAST_JOBS + 1);
625
+ });
497
626
  });
498
627
 
499
628
  describe('per-job timeout', () => {
package/src/processor.ts CHANGED
@@ -858,8 +858,16 @@ export const createProcessor = <PayloadMap = any>(
858
858
  }
859
859
 
860
860
  let running = false;
861
- let intervalId: NodeJS.Timeout | null = null;
862
- let currentBatchPromise: Promise<number> | null = null;
861
+ // Periodic timer that drives cron enqueueing and idle polling.
862
+ let pollTimer: NodeJS.Timeout | null = null;
863
+ // True while a getNextBatch claim is in progress, so claims stay serialized.
864
+ let claimInProgress = false;
865
+ // Set when a refill was requested while a claim was already running.
866
+ let pumpRequested = false;
867
+ // Number of jobs this processor currently has in flight.
868
+ let inFlight = 0;
869
+ // Promises for the in-flight jobs, awaited during graceful drain.
870
+ const inFlightJobs = new Set<Promise<void>>();
863
871
 
864
872
  setLogContext(options.verbose ?? false);
865
873
 
@@ -909,7 +917,10 @@ export const createProcessor = <PayloadMap = any>(
909
917
  return {
910
918
  /**
911
919
  * Start the job processor in the background.
912
- * - This will run periodically (every pollInterval milliseconds or 5 seconds if not provided) and process jobs as they become available.
920
+ * - Keeps up to `concurrency` jobs in flight, refilling each slot as soon as
921
+ * it frees instead of waiting for a whole batch to settle.
922
+ * - Polls every `pollInterval` milliseconds (5 seconds if not provided) for
923
+ * new work and to enqueue due cron jobs.
913
924
  * - You have to call the stop method to stop the processor.
914
925
  */
915
926
  startInBackground: () => {
@@ -918,28 +929,97 @@ export const createProcessor = <PayloadMap = any>(
918
929
  log(`Starting job processor with workerId: ${workerId}`);
919
930
  running = true;
920
931
 
921
- // Single serialized loop: process a batch, then either immediately
922
- // continue (if full batch was returned) or wait pollInterval.
923
- const scheduleNext = (immediate: boolean) => {
932
+ // Continuous worker pool: keep up to `concurrency` jobs in flight and
933
+ // refill each slot the instant it frees, rather than waiting for a whole
934
+ // batch to settle. This removes the head-of-line blocking where one slow
935
+ // job stalls the other slots (and, with groupConcurrency, the whole
936
+ // group) until it finishes.
937
+ const pump = async (): Promise<void> => {
924
938
  if (!running) return;
925
- if (immediate) {
926
- intervalId = setTimeout(loop, 0);
927
- } else {
928
- intervalId = setTimeout(loop, pollInterval);
939
+ // Serialize claims; remember the request so we refill afterwards.
940
+ if (claimInProgress) {
941
+ pumpRequested = true;
942
+ return;
943
+ }
944
+ // Pool is full; a finishing job will call pump() again.
945
+ if (inFlight >= concurrency) return;
946
+
947
+ claimInProgress = true;
948
+ pumpRequested = false;
949
+ let claimLimit = 0;
950
+ let claimed = 0;
951
+ try {
952
+ claimLimit = Math.min(concurrency - inFlight, batchSize);
953
+ const jobs = await backend.getNextBatch<
954
+ PayloadMap,
955
+ JobType<PayloadMap>
956
+ >(workerId, claimLimit, jobType, groupConcurrency);
957
+ claimed = jobs.length;
958
+
959
+ for (const job of jobs) {
960
+ emit?.('job:processing', { jobId: job.id, jobType: job.jobType });
961
+ inFlight++;
962
+ const jobPromise: Promise<void> = processJobWithHandlers(
963
+ backend,
964
+ job,
965
+ handlers,
966
+ emit,
967
+ )
968
+ .catch((err) => {
969
+ onError(err instanceof Error ? err : new Error(String(err)));
970
+ })
971
+ .finally(() => {
972
+ inFlight--;
973
+ inFlightJobs.delete(jobPromise);
974
+ // A slot just freed — try to claim more work immediately.
975
+ void pump();
976
+ });
977
+ inFlightJobs.add(jobPromise);
978
+ }
979
+ } catch (error) {
980
+ const err = error instanceof Error ? error : new Error(String(error));
981
+ onError(err);
982
+ emit?.('error', err);
983
+ } finally {
984
+ claimInProgress = false;
985
+ }
986
+
987
+ // Claim again right away when the queue likely still has ready work
988
+ // (we filled the whole request) or when a slot freed mid-claim.
989
+ const moreLikely = claimed === claimLimit || pumpRequested;
990
+ if (running && moreLikely && inFlight < concurrency) {
991
+ void pump();
929
992
  }
930
993
  };
931
994
 
932
- const loop = async () => {
995
+ // Periodic tick: enqueue due cron jobs (onBeforeBatch) on a steady cadence
996
+ // regardless of pool saturation, then top up the pool. Also the idle poll
997
+ // so newly added jobs are picked up within pollInterval.
998
+ const tick = async (): Promise<void> => {
933
999
  if (!running) return;
934
- currentBatchPromise = processJobs();
935
- const processed = await currentBatchPromise;
936
- currentBatchPromise = null;
937
- // If we got a full batch, there may be more work — process immediately
938
- scheduleNext(processed === batchSize);
1000
+ if (onBeforeBatch) {
1001
+ try {
1002
+ await onBeforeBatch();
1003
+ } catch (hookError) {
1004
+ log(`onBeforeBatch hook error: ${hookError}`);
1005
+ const err =
1006
+ hookError instanceof Error
1007
+ ? hookError
1008
+ : new Error(String(hookError));
1009
+ onError(err);
1010
+ emit?.('error', err);
1011
+ }
1012
+ }
1013
+ await pump();
1014
+ if (running) {
1015
+ pollTimer = setTimeout(() => {
1016
+ void tick();
1017
+ }, pollInterval);
1018
+ }
939
1019
  };
940
1020
 
941
- // Start the first iteration immediately
942
- loop();
1021
+ // Start the first iteration immediately.
1022
+ void tick();
943
1023
  },
944
1024
  /**
945
1025
  * Stop the job processor that runs in the background.
@@ -948,9 +1028,9 @@ export const createProcessor = <PayloadMap = any>(
948
1028
  stop: () => {
949
1029
  log(`Stopping job processor with workerId: ${workerId}`);
950
1030
  running = false;
951
- if (intervalId) {
952
- clearTimeout(intervalId);
953
- intervalId = null;
1031
+ if (pollTimer) {
1032
+ clearTimeout(pollTimer);
1033
+ pollTimer = null;
954
1034
  }
955
1035
  },
956
1036
  /**
@@ -960,17 +1040,16 @@ export const createProcessor = <PayloadMap = any>(
960
1040
  stopAndDrain: async (drainTimeoutMs = 30000) => {
961
1041
  log(`Stopping and draining job processor with workerId: ${workerId}`);
962
1042
  running = false;
963
- if (intervalId) {
964
- clearTimeout(intervalId);
965
- intervalId = null;
1043
+ if (pollTimer) {
1044
+ clearTimeout(pollTimer);
1045
+ pollTimer = null;
966
1046
  }
967
- // Wait for current batch to finish, with a timeout
968
- if (currentBatchPromise) {
1047
+ // Wait for all in-flight jobs to finish, with a timeout.
1048
+ if (inFlightJobs.size > 0) {
969
1049
  await Promise.race([
970
- currentBatchPromise.catch(() => {}),
1050
+ Promise.allSettled(Array.from(inFlightJobs)),
971
1051
  new Promise<void>((resolve) => setTimeout(resolve, drainTimeoutMs)),
972
1052
  ]);
973
- currentBatchPromise = null;
974
1053
  }
975
1054
  log(`Job processor ${workerId} drained`);
976
1055
  },
package/src/types.ts CHANGED
@@ -567,14 +567,20 @@ export type JobHandlers<PayloadMap> = {
567
567
  export interface ProcessorOptions {
568
568
  workerId?: string;
569
569
  /**
570
- * The number of jobs to process at a time.
571
- * - If not provided, the processor will process 10 jobs at a time.
572
- * - In serverless functions, it's better to process less jobs at a time since serverless functions are charged by the second and have a timeout.
570
+ * The maximum number of jobs to claim from the queue per poll.
571
+ * - If not provided, up to 10 jobs are claimed per poll.
572
+ * - With `startInBackground`, claims are capped to the number of free
573
+ * concurrency slots, so this only matters when it is below `concurrency`.
574
+ * - In serverless functions, it's better to claim fewer jobs at a time since serverless functions are charged by the second and have a timeout.
573
575
  */
574
576
  batchSize?: number;
575
577
  /**
576
- * The maximum number of jobs to process in parallel per batch.
577
- * - If not provided, all jobs in the batch are processed in parallel.
578
+ * The maximum number of jobs to process in parallel.
579
+ * - With `startInBackground`, this is the steady number of jobs kept in
580
+ * flight: each slot is refilled as soon as it frees, so a slow job never
581
+ * blocks the other slots.
582
+ * - With the one-shot `start`, this caps parallelism within the single batch.
583
+ * - If not provided, defaults to 3.
578
584
  * - Set to 1 to process jobs sequentially.
579
585
  * - Set to a lower value to avoid resource exhaustion.
580
586
  */
@@ -604,7 +610,8 @@ export interface ProcessorOptions {
604
610
  export interface Processor {
605
611
  /**
606
612
  * Start the job processor in the background.
607
- * - This will run periodically (every pollInterval milliseconds or 5 seconds if not provided) and process jobs (as many as batchSize) as they become available.
613
+ * - Keeps up to `concurrency` jobs in flight, refilling each slot as soon as it frees instead of waiting for a whole batch to settle.
614
+ * - Polls every pollInterval milliseconds (5 seconds if not provided) for new work as it becomes available.
608
615
  * - **You have to call the stop method to stop the processor.**
609
616
  * - Handlers are provided per-processor when calling createProcessor.
610
617
  * - In serverless functions, it's recommended to call start instead and await it to finish.