@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/dist/index.cjs +76 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +13 -6
- package/dist/index.d.ts +13 -6
- package/dist/index.js +76 -24
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/processor.test.ts +129 -0
- package/src/processor.ts +107 -28
- package/src/types.ts +13 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nicnocquee/dataqueue",
|
|
3
|
-
"version": "1.
|
|
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",
|
package/src/processor.test.ts
CHANGED
|
@@ -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
|
-
|
|
862
|
-
let
|
|
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
|
-
* -
|
|
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
|
-
//
|
|
922
|
-
//
|
|
923
|
-
|
|
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
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
|
|
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
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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
|
-
|
|
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 (
|
|
952
|
-
clearTimeout(
|
|
953
|
-
|
|
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 (
|
|
964
|
-
clearTimeout(
|
|
965
|
-
|
|
1043
|
+
if (pollTimer) {
|
|
1044
|
+
clearTimeout(pollTimer);
|
|
1045
|
+
pollTimer = null;
|
|
966
1046
|
}
|
|
967
|
-
// Wait for
|
|
968
|
-
if (
|
|
1047
|
+
// Wait for all in-flight jobs to finish, with a timeout.
|
|
1048
|
+
if (inFlightJobs.size > 0) {
|
|
969
1049
|
await Promise.race([
|
|
970
|
-
|
|
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
|
|
571
|
-
* - If not provided,
|
|
572
|
-
* -
|
|
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
|
|
577
|
-
* -
|
|
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
|
-
* -
|
|
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.
|