@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/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
1
2
|
import { Worker } from 'worker_threads';
|
|
2
3
|
import { AsyncLocalStorage } from 'async_hooks';
|
|
3
4
|
import { Pool } from 'pg';
|
|
@@ -7,7 +8,7 @@ import { randomUUID } from 'crypto';
|
|
|
7
8
|
import { createRequire } from 'module';
|
|
8
9
|
import { Cron } from 'croner';
|
|
9
10
|
|
|
10
|
-
// src/
|
|
11
|
+
// src/index.ts
|
|
11
12
|
|
|
12
13
|
// src/types.ts
|
|
13
14
|
var JobEventType = /* @__PURE__ */ ((JobEventType2) => {
|
|
@@ -143,9 +144,9 @@ async function runHandlerInWorker(handler, payload, timeoutMs, jobType) {
|
|
|
143
144
|
}
|
|
144
145
|
|
|
145
146
|
handlerFn(payload, signal)
|
|
146
|
-
.then(() => {
|
|
147
|
+
.then((result) => {
|
|
147
148
|
clearTimeout(timeoutId);
|
|
148
|
-
parentPort.postMessage({ type: 'success' });
|
|
149
|
+
parentPort.postMessage({ type: 'success', output: result });
|
|
149
150
|
})
|
|
150
151
|
.catch((error) => {
|
|
151
152
|
clearTimeout(timeoutId);
|
|
@@ -180,24 +181,27 @@ async function runHandlerInWorker(handler, payload, timeoutMs, jobType) {
|
|
|
180
181
|
}
|
|
181
182
|
});
|
|
182
183
|
let resolved = false;
|
|
183
|
-
worker.on(
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
184
|
+
worker.on(
|
|
185
|
+
"message",
|
|
186
|
+
(message) => {
|
|
187
|
+
if (resolved) return;
|
|
188
|
+
resolved = true;
|
|
189
|
+
if (message.type === "success") {
|
|
190
|
+
resolve(message.output);
|
|
191
|
+
} else if (message.type === "timeout") {
|
|
192
|
+
const timeoutError = new Error(
|
|
193
|
+
`Job timed out after ${timeoutMs} ms and was forcefully terminated`
|
|
194
|
+
);
|
|
195
|
+
timeoutError.failureReason = "timeout" /* Timeout */;
|
|
196
|
+
reject(timeoutError);
|
|
197
|
+
} else if (message.type === "error") {
|
|
198
|
+
const error = new Error(message.error.message);
|
|
199
|
+
error.stack = message.error.stack;
|
|
200
|
+
error.name = message.error.name;
|
|
201
|
+
reject(error);
|
|
202
|
+
}
|
|
199
203
|
}
|
|
200
|
-
|
|
204
|
+
);
|
|
201
205
|
worker.on("error", (error) => {
|
|
202
206
|
if (resolved) return;
|
|
203
207
|
resolved = true;
|
|
@@ -354,22 +358,30 @@ function buildWaitContext(backend, jobId, stepData, baseCtx) {
|
|
|
354
358
|
if (percent < 0 || percent > 100)
|
|
355
359
|
throw new Error("Progress must be between 0 and 100");
|
|
356
360
|
await backend.updateProgress(jobId, Math.round(percent));
|
|
361
|
+
},
|
|
362
|
+
setOutput: async (data) => {
|
|
363
|
+
await backend.updateOutput(jobId, data);
|
|
357
364
|
}
|
|
358
365
|
};
|
|
359
366
|
return ctx;
|
|
360
367
|
}
|
|
361
|
-
async function processJobWithHandlers(backend, job, jobHandlers) {
|
|
368
|
+
async function processJobWithHandlers(backend, job, jobHandlers, emit) {
|
|
362
369
|
const handler = jobHandlers[job.jobType];
|
|
363
370
|
if (!handler) {
|
|
364
371
|
await backend.setPendingReasonForUnpickedJobs(
|
|
365
372
|
`No handler registered for job type: ${job.jobType}`,
|
|
366
373
|
job.jobType
|
|
367
374
|
);
|
|
368
|
-
|
|
369
|
-
job.
|
|
370
|
-
new Error(`No handler registered for job type: ${job.jobType}`),
|
|
371
|
-
"no_handler" /* NoHandler */
|
|
375
|
+
const noHandlerError = new Error(
|
|
376
|
+
`No handler registered for job type: ${job.jobType}`
|
|
372
377
|
);
|
|
378
|
+
await backend.failJob(job.id, noHandlerError, "no_handler" /* NoHandler */);
|
|
379
|
+
emit?.("job:failed", {
|
|
380
|
+
jobId: job.id,
|
|
381
|
+
jobType: job.jobType,
|
|
382
|
+
error: noHandlerError,
|
|
383
|
+
willRetry: false
|
|
384
|
+
});
|
|
373
385
|
return;
|
|
374
386
|
}
|
|
375
387
|
const stepData = { ...job.stepData || {} };
|
|
@@ -384,9 +396,16 @@ async function processJobWithHandlers(backend, job, jobHandlers) {
|
|
|
384
396
|
const forceKillOnTimeout = job.forceKillOnTimeout ?? false;
|
|
385
397
|
let timeoutId;
|
|
386
398
|
const controller = new AbortController();
|
|
399
|
+
let setOutputCalled = false;
|
|
400
|
+
let handlerReturnValue;
|
|
387
401
|
try {
|
|
388
402
|
if (forceKillOnTimeout && timeoutMs && timeoutMs > 0) {
|
|
389
|
-
await runHandlerInWorker(
|
|
403
|
+
handlerReturnValue = await runHandlerInWorker(
|
|
404
|
+
handler,
|
|
405
|
+
job.payload,
|
|
406
|
+
timeoutMs,
|
|
407
|
+
job.jobType
|
|
408
|
+
);
|
|
390
409
|
} else {
|
|
391
410
|
let onTimeoutCallback;
|
|
392
411
|
let timeoutReject;
|
|
@@ -438,6 +457,22 @@ async function processJobWithHandlers(backend, job, jobHandlers) {
|
|
|
438
457
|
}
|
|
439
458
|
};
|
|
440
459
|
const ctx = buildWaitContext(backend, job.id, stepData, baseCtx);
|
|
460
|
+
if (emit) {
|
|
461
|
+
const originalSetProgress = ctx.setProgress;
|
|
462
|
+
ctx.setProgress = async (percent) => {
|
|
463
|
+
await originalSetProgress(percent);
|
|
464
|
+
emit("job:progress", {
|
|
465
|
+
jobId: job.id,
|
|
466
|
+
progress: Math.round(percent)
|
|
467
|
+
});
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
const originalSetOutput = ctx.setOutput;
|
|
471
|
+
ctx.setOutput = async (data) => {
|
|
472
|
+
setOutputCalled = true;
|
|
473
|
+
await originalSetOutput(data);
|
|
474
|
+
emit?.("job:output", { jobId: job.id, output: data });
|
|
475
|
+
};
|
|
441
476
|
if (forceKillOnTimeout && !hasTimeout) {
|
|
442
477
|
log(
|
|
443
478
|
`forceKillOnTimeout is set but no timeoutMs for job ${job.id}, running without force kill`
|
|
@@ -445,7 +480,7 @@ async function processJobWithHandlers(backend, job, jobHandlers) {
|
|
|
445
480
|
}
|
|
446
481
|
const jobPromise = handler(job.payload, controller.signal, ctx);
|
|
447
482
|
if (hasTimeout) {
|
|
448
|
-
await Promise.race([
|
|
483
|
+
handlerReturnValue = await Promise.race([
|
|
449
484
|
jobPromise,
|
|
450
485
|
new Promise((_, reject) => {
|
|
451
486
|
timeoutReject = reject;
|
|
@@ -453,11 +488,13 @@ async function processJobWithHandlers(backend, job, jobHandlers) {
|
|
|
453
488
|
})
|
|
454
489
|
]);
|
|
455
490
|
} else {
|
|
456
|
-
await jobPromise;
|
|
491
|
+
handlerReturnValue = await jobPromise;
|
|
457
492
|
}
|
|
458
493
|
}
|
|
459
494
|
if (timeoutId) clearTimeout(timeoutId);
|
|
460
|
-
|
|
495
|
+
const completionOutput = setOutputCalled || handlerReturnValue === void 0 ? void 0 : handlerReturnValue;
|
|
496
|
+
await backend.completeJob(job.id, completionOutput);
|
|
497
|
+
emit?.("job:completed", { jobId: job.id, jobType: job.jobType });
|
|
461
498
|
} catch (error) {
|
|
462
499
|
if (timeoutId) clearTimeout(timeoutId);
|
|
463
500
|
if (error instanceof WaitSignal) {
|
|
@@ -469,6 +506,7 @@ async function processJobWithHandlers(backend, job, jobHandlers) {
|
|
|
469
506
|
waitTokenId: error.tokenId,
|
|
470
507
|
stepData: error.stepData
|
|
471
508
|
});
|
|
509
|
+
emit?.("job:waiting", { jobId: job.id, jobType: job.jobType });
|
|
472
510
|
return;
|
|
473
511
|
}
|
|
474
512
|
console.error(`Error processing job ${job.id}:`, error);
|
|
@@ -476,22 +514,33 @@ async function processJobWithHandlers(backend, job, jobHandlers) {
|
|
|
476
514
|
if (error && typeof error === "object" && "failureReason" in error && error.failureReason === "timeout" /* Timeout */) {
|
|
477
515
|
failureReason = "timeout" /* Timeout */;
|
|
478
516
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
517
|
+
const failError = error instanceof Error ? error : new Error(String(error));
|
|
518
|
+
await backend.failJob(job.id, failError, failureReason);
|
|
519
|
+
emit?.("job:failed", {
|
|
520
|
+
jobId: job.id,
|
|
521
|
+
jobType: job.jobType,
|
|
522
|
+
error: failError,
|
|
523
|
+
willRetry: job.attempts + 1 < job.maxAttempts
|
|
524
|
+
});
|
|
484
525
|
}
|
|
485
526
|
}
|
|
486
|
-
async function processBatchWithHandlers(backend, workerId, batchSize, jobType, jobHandlers, concurrency, onError) {
|
|
527
|
+
async function processBatchWithHandlers(backend, workerId, batchSize, jobType, jobHandlers, concurrency, groupConcurrency, onError, emit) {
|
|
487
528
|
const jobs = await backend.getNextBatch(
|
|
488
529
|
workerId,
|
|
489
530
|
batchSize,
|
|
490
|
-
jobType
|
|
531
|
+
jobType,
|
|
532
|
+
groupConcurrency
|
|
491
533
|
);
|
|
534
|
+
if (emit) {
|
|
535
|
+
for (const job of jobs) {
|
|
536
|
+
emit("job:processing", { jobId: job.id, jobType: job.jobType });
|
|
537
|
+
}
|
|
538
|
+
}
|
|
492
539
|
if (!concurrency || concurrency >= jobs.length) {
|
|
493
540
|
await Promise.all(
|
|
494
|
-
jobs.map(
|
|
541
|
+
jobs.map(
|
|
542
|
+
(job) => processJobWithHandlers(backend, job, jobHandlers, emit)
|
|
543
|
+
)
|
|
495
544
|
);
|
|
496
545
|
return jobs.length;
|
|
497
546
|
}
|
|
@@ -504,7 +553,7 @@ async function processBatchWithHandlers(backend, workerId, batchSize, jobType, j
|
|
|
504
553
|
while (running < concurrency && idx < jobs.length) {
|
|
505
554
|
const job = jobs[idx++];
|
|
506
555
|
running++;
|
|
507
|
-
processJobWithHandlers(backend, job, jobHandlers).then(() => {
|
|
556
|
+
processJobWithHandlers(backend, job, jobHandlers, emit).then(() => {
|
|
508
557
|
running--;
|
|
509
558
|
finished++;
|
|
510
559
|
next();
|
|
@@ -521,15 +570,21 @@ async function processBatchWithHandlers(backend, workerId, batchSize, jobType, j
|
|
|
521
570
|
next();
|
|
522
571
|
});
|
|
523
572
|
}
|
|
524
|
-
var createProcessor = (backend, handlers, options = {}, onBeforeBatch) => {
|
|
573
|
+
var createProcessor = (backend, handlers, options = {}, onBeforeBatch, emit) => {
|
|
525
574
|
const {
|
|
526
575
|
workerId = `worker-${Math.random().toString(36).substring(2, 9)}`,
|
|
527
576
|
batchSize = 10,
|
|
528
577
|
pollInterval = 5e3,
|
|
529
578
|
onError = (error) => console.error("Job processor error:", error),
|
|
530
579
|
jobType,
|
|
531
|
-
concurrency = 3
|
|
580
|
+
concurrency = 3,
|
|
581
|
+
groupConcurrency
|
|
532
582
|
} = options;
|
|
583
|
+
if (groupConcurrency !== void 0 && (!Number.isInteger(groupConcurrency) || groupConcurrency <= 0)) {
|
|
584
|
+
throw new Error(
|
|
585
|
+
'Processor option "groupConcurrency" must be a positive integer when provided.'
|
|
586
|
+
);
|
|
587
|
+
}
|
|
533
588
|
let running = false;
|
|
534
589
|
let intervalId = null;
|
|
535
590
|
let currentBatchPromise = null;
|
|
@@ -541,11 +596,11 @@ var createProcessor = (backend, handlers, options = {}, onBeforeBatch) => {
|
|
|
541
596
|
await onBeforeBatch();
|
|
542
597
|
} catch (hookError) {
|
|
543
598
|
log(`onBeforeBatch hook error: ${hookError}`);
|
|
599
|
+
const err = hookError instanceof Error ? hookError : new Error(String(hookError));
|
|
544
600
|
if (onError) {
|
|
545
|
-
onError(
|
|
546
|
-
hookError instanceof Error ? hookError : new Error(String(hookError))
|
|
547
|
-
);
|
|
601
|
+
onError(err);
|
|
548
602
|
}
|
|
603
|
+
emit?.("error", err);
|
|
549
604
|
}
|
|
550
605
|
}
|
|
551
606
|
log(
|
|
@@ -559,11 +614,15 @@ var createProcessor = (backend, handlers, options = {}, onBeforeBatch) => {
|
|
|
559
614
|
jobType,
|
|
560
615
|
handlers,
|
|
561
616
|
concurrency,
|
|
562
|
-
|
|
617
|
+
groupConcurrency,
|
|
618
|
+
onError,
|
|
619
|
+
emit
|
|
563
620
|
);
|
|
564
621
|
return processed;
|
|
565
622
|
} catch (error) {
|
|
566
|
-
|
|
623
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
624
|
+
onError(err);
|
|
625
|
+
emit?.("error", err);
|
|
567
626
|
}
|
|
568
627
|
return 0;
|
|
569
628
|
};
|
|
@@ -642,6 +701,138 @@ var createProcessor = (backend, handlers, options = {}, onBeforeBatch) => {
|
|
|
642
701
|
isRunning: () => running
|
|
643
702
|
};
|
|
644
703
|
};
|
|
704
|
+
|
|
705
|
+
// src/supervisor.ts
|
|
706
|
+
var createSupervisor = (backend, options = {}, emit) => {
|
|
707
|
+
const {
|
|
708
|
+
intervalMs = 6e4,
|
|
709
|
+
stuckJobsTimeoutMinutes = 10,
|
|
710
|
+
cleanupJobsDaysToKeep = 30,
|
|
711
|
+
cleanupEventsDaysToKeep = 30,
|
|
712
|
+
cleanupBatchSize = 1e3,
|
|
713
|
+
reclaimStuckJobs = true,
|
|
714
|
+
expireTimedOutTokens = true,
|
|
715
|
+
onError = (error) => console.error("Supervisor maintenance error:", error),
|
|
716
|
+
verbose = false
|
|
717
|
+
} = options;
|
|
718
|
+
let running = false;
|
|
719
|
+
let timeoutId = null;
|
|
720
|
+
let currentRunPromise = null;
|
|
721
|
+
setLogContext(verbose);
|
|
722
|
+
const runOnce = async () => {
|
|
723
|
+
setLogContext(verbose);
|
|
724
|
+
const result = {
|
|
725
|
+
reclaimedJobs: 0,
|
|
726
|
+
cleanedUpJobs: 0,
|
|
727
|
+
cleanedUpEvents: 0,
|
|
728
|
+
expiredTokens: 0
|
|
729
|
+
};
|
|
730
|
+
if (reclaimStuckJobs) {
|
|
731
|
+
try {
|
|
732
|
+
result.reclaimedJobs = await backend.reclaimStuckJobs(
|
|
733
|
+
stuckJobsTimeoutMinutes
|
|
734
|
+
);
|
|
735
|
+
if (result.reclaimedJobs > 0) {
|
|
736
|
+
log(`Supervisor: reclaimed ${result.reclaimedJobs} stuck jobs`);
|
|
737
|
+
}
|
|
738
|
+
} catch (e) {
|
|
739
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
740
|
+
onError(err);
|
|
741
|
+
emit?.("error", err);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
if (cleanupJobsDaysToKeep > 0) {
|
|
745
|
+
try {
|
|
746
|
+
result.cleanedUpJobs = await backend.cleanupOldJobs(
|
|
747
|
+
cleanupJobsDaysToKeep,
|
|
748
|
+
cleanupBatchSize
|
|
749
|
+
);
|
|
750
|
+
if (result.cleanedUpJobs > 0) {
|
|
751
|
+
log(`Supervisor: cleaned up ${result.cleanedUpJobs} old jobs`);
|
|
752
|
+
}
|
|
753
|
+
} catch (e) {
|
|
754
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
755
|
+
onError(err);
|
|
756
|
+
emit?.("error", err);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
if (cleanupEventsDaysToKeep > 0) {
|
|
760
|
+
try {
|
|
761
|
+
result.cleanedUpEvents = await backend.cleanupOldJobEvents(
|
|
762
|
+
cleanupEventsDaysToKeep,
|
|
763
|
+
cleanupBatchSize
|
|
764
|
+
);
|
|
765
|
+
if (result.cleanedUpEvents > 0) {
|
|
766
|
+
log(
|
|
767
|
+
`Supervisor: cleaned up ${result.cleanedUpEvents} old job events`
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
} catch (e) {
|
|
771
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
772
|
+
onError(err);
|
|
773
|
+
emit?.("error", err);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
if (expireTimedOutTokens) {
|
|
777
|
+
try {
|
|
778
|
+
result.expiredTokens = await backend.expireTimedOutWaitpoints();
|
|
779
|
+
if (result.expiredTokens > 0) {
|
|
780
|
+
log(`Supervisor: expired ${result.expiredTokens} timed-out tokens`);
|
|
781
|
+
}
|
|
782
|
+
} catch (e) {
|
|
783
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
784
|
+
onError(err);
|
|
785
|
+
emit?.("error", err);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
return result;
|
|
789
|
+
};
|
|
790
|
+
return {
|
|
791
|
+
start: async () => {
|
|
792
|
+
return runOnce();
|
|
793
|
+
},
|
|
794
|
+
startInBackground: () => {
|
|
795
|
+
if (running) return;
|
|
796
|
+
log("Supervisor: starting background maintenance loop");
|
|
797
|
+
running = true;
|
|
798
|
+
const loop = async () => {
|
|
799
|
+
if (!running) return;
|
|
800
|
+
currentRunPromise = runOnce();
|
|
801
|
+
await currentRunPromise;
|
|
802
|
+
currentRunPromise = null;
|
|
803
|
+
if (running) {
|
|
804
|
+
timeoutId = setTimeout(loop, intervalMs);
|
|
805
|
+
}
|
|
806
|
+
};
|
|
807
|
+
loop();
|
|
808
|
+
},
|
|
809
|
+
stop: () => {
|
|
810
|
+
running = false;
|
|
811
|
+
if (timeoutId !== null) {
|
|
812
|
+
clearTimeout(timeoutId);
|
|
813
|
+
timeoutId = null;
|
|
814
|
+
}
|
|
815
|
+
log("Supervisor: stopped");
|
|
816
|
+
},
|
|
817
|
+
stopAndDrain: async (timeoutMs = 3e4) => {
|
|
818
|
+
running = false;
|
|
819
|
+
if (timeoutId !== null) {
|
|
820
|
+
clearTimeout(timeoutId);
|
|
821
|
+
timeoutId = null;
|
|
822
|
+
}
|
|
823
|
+
if (currentRunPromise) {
|
|
824
|
+
log("Supervisor: draining current maintenance run\u2026");
|
|
825
|
+
await Promise.race([
|
|
826
|
+
currentRunPromise,
|
|
827
|
+
new Promise((resolve) => setTimeout(resolve, timeoutMs))
|
|
828
|
+
]);
|
|
829
|
+
currentRunPromise = null;
|
|
830
|
+
}
|
|
831
|
+
log("Supervisor: drained and stopped");
|
|
832
|
+
},
|
|
833
|
+
isRunning: () => running
|
|
834
|
+
};
|
|
835
|
+
};
|
|
645
836
|
function loadPemOrFile(value) {
|
|
646
837
|
if (!value) return void 0;
|
|
647
838
|
if (value.startsWith("file://")) {
|
|
@@ -793,6 +984,14 @@ var PostgresBackend = class {
|
|
|
793
984
|
}
|
|
794
985
|
}
|
|
795
986
|
// ── Job CRUD ──────────────────────────────────────────────────────────
|
|
987
|
+
/**
|
|
988
|
+
* Add a job and return its numeric ID.
|
|
989
|
+
*
|
|
990
|
+
* @param job - Job configuration.
|
|
991
|
+
* @param options - Optional. Pass `{ db }` to run the INSERT on an external
|
|
992
|
+
* client (e.g., inside a transaction) so the job is part of the caller's
|
|
993
|
+
* transaction. The event INSERT also uses the same client.
|
|
994
|
+
*/
|
|
796
995
|
async addJob({
|
|
797
996
|
jobType,
|
|
798
997
|
payload,
|
|
@@ -802,17 +1001,22 @@ var PostgresBackend = class {
|
|
|
802
1001
|
timeoutMs = void 0,
|
|
803
1002
|
forceKillOnTimeout = false,
|
|
804
1003
|
tags = void 0,
|
|
805
|
-
idempotencyKey = void 0
|
|
806
|
-
|
|
807
|
-
|
|
1004
|
+
idempotencyKey = void 0,
|
|
1005
|
+
retryDelay = void 0,
|
|
1006
|
+
retryBackoff = void 0,
|
|
1007
|
+
retryDelayMax = void 0,
|
|
1008
|
+
group = void 0
|
|
1009
|
+
}, options) {
|
|
1010
|
+
const externalClient = options?.db;
|
|
1011
|
+
const client = externalClient ?? await this.pool.connect();
|
|
808
1012
|
try {
|
|
809
1013
|
let result;
|
|
810
1014
|
const onConflict = idempotencyKey ? `ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING` : "";
|
|
811
1015
|
if (runAt) {
|
|
812
1016
|
result = await client.query(
|
|
813
1017
|
`INSERT INTO job_queue
|
|
814
|
-
(job_type, payload, max_attempts, priority, run_at, timeout_ms, force_kill_on_timeout, tags, idempotency_key)
|
|
815
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
1018
|
+
(job_type, payload, max_attempts, priority, run_at, timeout_ms, force_kill_on_timeout, tags, idempotency_key, retry_delay, retry_backoff, retry_delay_max, group_id, group_tier)
|
|
1019
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
|
816
1020
|
${onConflict}
|
|
817
1021
|
RETURNING id`,
|
|
818
1022
|
[
|
|
@@ -824,14 +1028,19 @@ var PostgresBackend = class {
|
|
|
824
1028
|
timeoutMs ?? null,
|
|
825
1029
|
forceKillOnTimeout ?? false,
|
|
826
1030
|
tags ?? null,
|
|
827
|
-
idempotencyKey ?? null
|
|
1031
|
+
idempotencyKey ?? null,
|
|
1032
|
+
retryDelay ?? null,
|
|
1033
|
+
retryBackoff ?? null,
|
|
1034
|
+
retryDelayMax ?? null,
|
|
1035
|
+
group?.id ?? null,
|
|
1036
|
+
group?.tier ?? null
|
|
828
1037
|
]
|
|
829
1038
|
);
|
|
830
1039
|
} else {
|
|
831
1040
|
result = await client.query(
|
|
832
1041
|
`INSERT INTO job_queue
|
|
833
|
-
(job_type, payload, max_attempts, priority, timeout_ms, force_kill_on_timeout, tags, idempotency_key)
|
|
834
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
1042
|
+
(job_type, payload, max_attempts, priority, timeout_ms, force_kill_on_timeout, tags, idempotency_key, retry_delay, retry_backoff, retry_delay_max, group_id, group_tier)
|
|
1043
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
|
835
1044
|
${onConflict}
|
|
836
1045
|
RETURNING id`,
|
|
837
1046
|
[
|
|
@@ -842,7 +1051,12 @@ var PostgresBackend = class {
|
|
|
842
1051
|
timeoutMs ?? null,
|
|
843
1052
|
forceKillOnTimeout ?? false,
|
|
844
1053
|
tags ?? null,
|
|
845
|
-
idempotencyKey ?? null
|
|
1054
|
+
idempotencyKey ?? null,
|
|
1055
|
+
retryDelay ?? null,
|
|
1056
|
+
retryBackoff ?? null,
|
|
1057
|
+
retryDelayMax ?? null,
|
|
1058
|
+
group?.id ?? null,
|
|
1059
|
+
group?.tier ?? null
|
|
846
1060
|
]
|
|
847
1061
|
);
|
|
848
1062
|
}
|
|
@@ -865,25 +1079,191 @@ var PostgresBackend = class {
|
|
|
865
1079
|
log(
|
|
866
1080
|
`Added job ${jobId}: payload ${JSON.stringify(payload)}, ${runAt ? `runAt ${runAt.toISOString()}, ` : ""}priority ${priority}, maxAttempts ${maxAttempts}, jobType ${jobType}, tags ${JSON.stringify(tags)}${idempotencyKey ? `, idempotencyKey "${idempotencyKey}"` : ""}`
|
|
867
1081
|
);
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
1082
|
+
if (externalClient) {
|
|
1083
|
+
try {
|
|
1084
|
+
await client.query(
|
|
1085
|
+
`INSERT INTO job_events (job_id, event_type, metadata) VALUES ($1, $2, $3)`,
|
|
1086
|
+
[
|
|
1087
|
+
jobId,
|
|
1088
|
+
"added" /* Added */,
|
|
1089
|
+
JSON.stringify({ jobType, payload, tags, idempotencyKey })
|
|
1090
|
+
]
|
|
1091
|
+
);
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
log(`Error recording job event for job ${jobId}: ${error}`);
|
|
1094
|
+
}
|
|
1095
|
+
} else {
|
|
1096
|
+
await this.recordJobEvent(jobId, "added" /* Added */, {
|
|
1097
|
+
jobType,
|
|
1098
|
+
payload,
|
|
1099
|
+
tags,
|
|
1100
|
+
idempotencyKey
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
874
1103
|
return jobId;
|
|
875
1104
|
} catch (error) {
|
|
876
1105
|
log(`Error adding job: ${error}`);
|
|
877
1106
|
throw error;
|
|
878
1107
|
} finally {
|
|
879
|
-
client.release();
|
|
1108
|
+
if (!externalClient) client.release();
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Insert multiple jobs in a single database round-trip.
|
|
1113
|
+
*
|
|
1114
|
+
* Uses a multi-row INSERT with ON CONFLICT handling for idempotency keys.
|
|
1115
|
+
* Returns IDs in the same order as the input array.
|
|
1116
|
+
*/
|
|
1117
|
+
async addJobs(jobs, options) {
|
|
1118
|
+
if (jobs.length === 0) return [];
|
|
1119
|
+
const externalClient = options?.db;
|
|
1120
|
+
const client = externalClient ?? await this.pool.connect();
|
|
1121
|
+
try {
|
|
1122
|
+
const COLS_PER_JOB = 14;
|
|
1123
|
+
const valueClauses = [];
|
|
1124
|
+
const params = [];
|
|
1125
|
+
const hasAnyIdempotencyKey = jobs.some((j) => j.idempotencyKey);
|
|
1126
|
+
for (let i = 0; i < jobs.length; i++) {
|
|
1127
|
+
const {
|
|
1128
|
+
jobType,
|
|
1129
|
+
payload,
|
|
1130
|
+
maxAttempts = 3,
|
|
1131
|
+
priority = 0,
|
|
1132
|
+
runAt = null,
|
|
1133
|
+
timeoutMs = void 0,
|
|
1134
|
+
forceKillOnTimeout = false,
|
|
1135
|
+
tags = void 0,
|
|
1136
|
+
idempotencyKey = void 0,
|
|
1137
|
+
retryDelay = void 0,
|
|
1138
|
+
retryBackoff = void 0,
|
|
1139
|
+
retryDelayMax = void 0,
|
|
1140
|
+
group = void 0
|
|
1141
|
+
} = jobs[i];
|
|
1142
|
+
const base = i * COLS_PER_JOB;
|
|
1143
|
+
valueClauses.push(
|
|
1144
|
+
`($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, COALESCE($${base + 5}::timestamptz, CURRENT_TIMESTAMP), $${base + 6}, $${base + 7}, $${base + 8}, $${base + 9}, $${base + 10}, $${base + 11}, $${base + 12}, $${base + 13}, $${base + 14})`
|
|
1145
|
+
);
|
|
1146
|
+
params.push(
|
|
1147
|
+
jobType,
|
|
1148
|
+
payload,
|
|
1149
|
+
maxAttempts,
|
|
1150
|
+
priority,
|
|
1151
|
+
runAt,
|
|
1152
|
+
timeoutMs ?? null,
|
|
1153
|
+
forceKillOnTimeout ?? false,
|
|
1154
|
+
tags ?? null,
|
|
1155
|
+
idempotencyKey ?? null,
|
|
1156
|
+
retryDelay ?? null,
|
|
1157
|
+
retryBackoff ?? null,
|
|
1158
|
+
retryDelayMax ?? null,
|
|
1159
|
+
group?.id ?? null,
|
|
1160
|
+
group?.tier ?? null
|
|
1161
|
+
);
|
|
1162
|
+
}
|
|
1163
|
+
const onConflict = hasAnyIdempotencyKey ? `ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING` : "";
|
|
1164
|
+
const result = await client.query(
|
|
1165
|
+
`INSERT INTO job_queue
|
|
1166
|
+
(job_type, payload, max_attempts, priority, run_at, timeout_ms, force_kill_on_timeout, tags, idempotency_key, retry_delay, retry_backoff, retry_delay_max, group_id, group_tier)
|
|
1167
|
+
VALUES ${valueClauses.join(", ")}
|
|
1168
|
+
${onConflict}
|
|
1169
|
+
RETURNING id, idempotency_key`,
|
|
1170
|
+
params
|
|
1171
|
+
);
|
|
1172
|
+
const returnedKeyToId = /* @__PURE__ */ new Map();
|
|
1173
|
+
const returnedNullKeyIds = [];
|
|
1174
|
+
for (const row of result.rows) {
|
|
1175
|
+
if (row.idempotency_key != null) {
|
|
1176
|
+
returnedKeyToId.set(row.idempotency_key, row.id);
|
|
1177
|
+
} else {
|
|
1178
|
+
returnedNullKeyIds.push(row.id);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
const missingKeys = [];
|
|
1182
|
+
for (const job of jobs) {
|
|
1183
|
+
if (job.idempotencyKey && !returnedKeyToId.has(job.idempotencyKey)) {
|
|
1184
|
+
missingKeys.push(job.idempotencyKey);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
if (missingKeys.length > 0) {
|
|
1188
|
+
const existing = await client.query(
|
|
1189
|
+
`SELECT id, idempotency_key FROM job_queue WHERE idempotency_key = ANY($1)`,
|
|
1190
|
+
[missingKeys]
|
|
1191
|
+
);
|
|
1192
|
+
for (const row of existing.rows) {
|
|
1193
|
+
returnedKeyToId.set(row.idempotency_key, row.id);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
let nullKeyIdx = 0;
|
|
1197
|
+
const ids = [];
|
|
1198
|
+
for (const job of jobs) {
|
|
1199
|
+
if (job.idempotencyKey) {
|
|
1200
|
+
const id = returnedKeyToId.get(job.idempotencyKey);
|
|
1201
|
+
if (id === void 0) {
|
|
1202
|
+
throw new Error(
|
|
1203
|
+
`Failed to resolve job ID for idempotency key "${job.idempotencyKey}"`
|
|
1204
|
+
);
|
|
1205
|
+
}
|
|
1206
|
+
ids.push(id);
|
|
1207
|
+
} else {
|
|
1208
|
+
ids.push(returnedNullKeyIds[nullKeyIdx++]);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
log(`Batch-inserted ${jobs.length} jobs, IDs: [${ids.join(", ")}]`);
|
|
1212
|
+
const newJobEvents = [];
|
|
1213
|
+
for (let i = 0; i < jobs.length; i++) {
|
|
1214
|
+
const job = jobs[i];
|
|
1215
|
+
const wasInserted = !job.idempotencyKey || !missingKeys.includes(job.idempotencyKey);
|
|
1216
|
+
if (wasInserted) {
|
|
1217
|
+
newJobEvents.push({
|
|
1218
|
+
jobId: ids[i],
|
|
1219
|
+
eventType: "added" /* Added */,
|
|
1220
|
+
metadata: {
|
|
1221
|
+
jobType: job.jobType,
|
|
1222
|
+
payload: job.payload,
|
|
1223
|
+
tags: job.tags,
|
|
1224
|
+
idempotencyKey: job.idempotencyKey
|
|
1225
|
+
}
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
if (newJobEvents.length > 0) {
|
|
1230
|
+
if (externalClient) {
|
|
1231
|
+
const evtValues = [];
|
|
1232
|
+
const evtParams = [];
|
|
1233
|
+
let evtIdx = 1;
|
|
1234
|
+
for (const evt of newJobEvents) {
|
|
1235
|
+
evtValues.push(`($${evtIdx++}, $${evtIdx++}, $${evtIdx++})`);
|
|
1236
|
+
evtParams.push(
|
|
1237
|
+
evt.jobId,
|
|
1238
|
+
evt.eventType,
|
|
1239
|
+
evt.metadata ? JSON.stringify(evt.metadata) : null
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
try {
|
|
1243
|
+
await client.query(
|
|
1244
|
+
`INSERT INTO job_events (job_id, event_type, metadata) VALUES ${evtValues.join(", ")}`,
|
|
1245
|
+
evtParams
|
|
1246
|
+
);
|
|
1247
|
+
} catch (error) {
|
|
1248
|
+
log(`Error recording batch job events: ${error}`);
|
|
1249
|
+
}
|
|
1250
|
+
} else {
|
|
1251
|
+
await this.recordJobEventsBatch(newJobEvents);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
return ids;
|
|
1255
|
+
} catch (error) {
|
|
1256
|
+
log(`Error batch-inserting jobs: ${error}`);
|
|
1257
|
+
throw error;
|
|
1258
|
+
} finally {
|
|
1259
|
+
if (!externalClient) client.release();
|
|
880
1260
|
}
|
|
881
1261
|
}
|
|
882
1262
|
async getJob(id) {
|
|
883
1263
|
const client = await this.pool.connect();
|
|
884
1264
|
try {
|
|
885
1265
|
const result = await client.query(
|
|
886
|
-
`SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", tags, idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress FROM job_queue WHERE id = $1`,
|
|
1266
|
+
`SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", tags, idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", group_id AS "groupId", group_tier AS "groupTier", output FROM job_queue WHERE id = $1`,
|
|
887
1267
|
[id]
|
|
888
1268
|
);
|
|
889
1269
|
if (result.rows.length === 0) {
|
|
@@ -910,7 +1290,7 @@ var PostgresBackend = class {
|
|
|
910
1290
|
const client = await this.pool.connect();
|
|
911
1291
|
try {
|
|
912
1292
|
const result = await client.query(
|
|
913
|
-
`SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress FROM job_queue WHERE status = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`,
|
|
1293
|
+
`SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", group_id AS "groupId", group_tier AS "groupTier", output FROM job_queue WHERE status = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`,
|
|
914
1294
|
[status, limit, offset]
|
|
915
1295
|
);
|
|
916
1296
|
log(`Found ${result.rows.length} jobs by status ${status}`);
|
|
@@ -932,7 +1312,7 @@ var PostgresBackend = class {
|
|
|
932
1312
|
const client = await this.pool.connect();
|
|
933
1313
|
try {
|
|
934
1314
|
const result = await client.query(
|
|
935
|
-
`SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress FROM job_queue ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
|
|
1315
|
+
`SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", group_id AS "groupId", group_tier AS "groupTier", output FROM job_queue ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
|
|
936
1316
|
[limit, offset]
|
|
937
1317
|
);
|
|
938
1318
|
log(`Found ${result.rows.length} jobs (all)`);
|
|
@@ -952,7 +1332,7 @@ var PostgresBackend = class {
|
|
|
952
1332
|
async getJobs(filters, limit = 100, offset = 0) {
|
|
953
1333
|
const client = await this.pool.connect();
|
|
954
1334
|
try {
|
|
955
|
-
let query = `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", tags, idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress FROM job_queue`;
|
|
1335
|
+
let query = `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", tags, idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", group_id AS "groupId", group_tier AS "groupTier", output FROM job_queue`;
|
|
956
1336
|
const params = [];
|
|
957
1337
|
const where = [];
|
|
958
1338
|
let paramIdx = 1;
|
|
@@ -1053,7 +1433,7 @@ var PostgresBackend = class {
|
|
|
1053
1433
|
async getJobsByTags(tags, mode = "all", limit = 100, offset = 0) {
|
|
1054
1434
|
const client = await this.pool.connect();
|
|
1055
1435
|
try {
|
|
1056
|
-
let query = `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", tags, idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress
|
|
1436
|
+
let query = `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", tags, idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", group_id AS "groupId", group_tier AS "groupTier", output
|
|
1057
1437
|
FROM job_queue`;
|
|
1058
1438
|
let params = [];
|
|
1059
1439
|
switch (mode) {
|
|
@@ -1100,7 +1480,7 @@ var PostgresBackend = class {
|
|
|
1100
1480
|
}
|
|
1101
1481
|
}
|
|
1102
1482
|
// ── Processing lifecycle ──────────────────────────────────────────────
|
|
1103
|
-
async getNextBatch(workerId, batchSize = 10, jobType) {
|
|
1483
|
+
async getNextBatch(workerId, batchSize = 10, jobType, groupConcurrency) {
|
|
1104
1484
|
const client = await this.pool.connect();
|
|
1105
1485
|
try {
|
|
1106
1486
|
await client.query("BEGIN");
|
|
@@ -1108,49 +1488,120 @@ var PostgresBackend = class {
|
|
|
1108
1488
|
const params = [workerId, batchSize];
|
|
1109
1489
|
if (jobType) {
|
|
1110
1490
|
if (Array.isArray(jobType)) {
|
|
1111
|
-
jobTypeFilter = ` AND job_type = ANY($3)`;
|
|
1491
|
+
jobTypeFilter = ` AND candidate.job_type = ANY($3)`;
|
|
1112
1492
|
params.push(jobType);
|
|
1113
1493
|
} else {
|
|
1114
|
-
jobTypeFilter = ` AND job_type = $3`;
|
|
1494
|
+
jobTypeFilter = ` AND candidate.job_type = $3`;
|
|
1115
1495
|
params.push(jobType);
|
|
1116
1496
|
}
|
|
1117
1497
|
}
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
WHERE (
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1498
|
+
let result;
|
|
1499
|
+
if (groupConcurrency === void 0) {
|
|
1500
|
+
result = await client.query(
|
|
1501
|
+
`
|
|
1502
|
+
UPDATE job_queue
|
|
1503
|
+
SET status = 'processing',
|
|
1504
|
+
locked_at = NOW(),
|
|
1505
|
+
locked_by = $1,
|
|
1506
|
+
attempts = CASE WHEN status = 'waiting' THEN attempts ELSE attempts + 1 END,
|
|
1507
|
+
updated_at = NOW(),
|
|
1508
|
+
pending_reason = NULL,
|
|
1509
|
+
started_at = COALESCE(started_at, NOW()),
|
|
1510
|
+
last_retried_at = CASE WHEN status != 'waiting' AND attempts > 0 THEN NOW() ELSE last_retried_at END,
|
|
1511
|
+
wait_until = NULL
|
|
1512
|
+
WHERE id IN (
|
|
1513
|
+
SELECT id FROM job_queue candidate
|
|
1514
|
+
WHERE (
|
|
1515
|
+
(
|
|
1516
|
+
(candidate.status = 'pending' OR (candidate.status = 'failed' AND candidate.next_attempt_at <= NOW()))
|
|
1517
|
+
AND (candidate.attempts < candidate.max_attempts)
|
|
1518
|
+
AND candidate.run_at <= NOW()
|
|
1519
|
+
)
|
|
1520
|
+
OR (
|
|
1521
|
+
candidate.status = 'waiting'
|
|
1522
|
+
AND candidate.wait_until IS NOT NULL
|
|
1523
|
+
AND candidate.wait_until <= NOW()
|
|
1524
|
+
AND candidate.wait_token_id IS NULL
|
|
1525
|
+
)
|
|
1137
1526
|
)
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1527
|
+
${jobTypeFilter}
|
|
1528
|
+
ORDER BY candidate.priority DESC, candidate.created_at ASC
|
|
1529
|
+
LIMIT $2
|
|
1530
|
+
FOR UPDATE SKIP LOCKED
|
|
1531
|
+
)
|
|
1532
|
+
RETURNING id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", group_id AS "groupId", group_tier AS "groupTier", output
|
|
1533
|
+
`,
|
|
1534
|
+
params
|
|
1535
|
+
);
|
|
1536
|
+
} else {
|
|
1537
|
+
const constrainedParams = [...params, groupConcurrency];
|
|
1538
|
+
const groupConcurrencyParamIndex = constrainedParams.length;
|
|
1539
|
+
result = await client.query(
|
|
1540
|
+
`
|
|
1541
|
+
WITH eligible AS (
|
|
1542
|
+
SELECT candidate.id, candidate.group_id, candidate.priority, candidate.created_at
|
|
1543
|
+
FROM job_queue candidate
|
|
1544
|
+
WHERE (
|
|
1545
|
+
(
|
|
1546
|
+
(candidate.status = 'pending' OR (candidate.status = 'failed' AND candidate.next_attempt_at <= NOW()))
|
|
1547
|
+
AND (candidate.attempts < candidate.max_attempts)
|
|
1548
|
+
AND candidate.run_at <= NOW()
|
|
1549
|
+
)
|
|
1550
|
+
OR (
|
|
1551
|
+
candidate.status = 'waiting'
|
|
1552
|
+
AND candidate.wait_until IS NOT NULL
|
|
1553
|
+
AND candidate.wait_until <= NOW()
|
|
1554
|
+
AND candidate.wait_token_id IS NULL
|
|
1555
|
+
)
|
|
1143
1556
|
)
|
|
1557
|
+
${jobTypeFilter}
|
|
1558
|
+
FOR UPDATE SKIP LOCKED
|
|
1559
|
+
),
|
|
1560
|
+
ranked AS (
|
|
1561
|
+
SELECT
|
|
1562
|
+
eligible.id,
|
|
1563
|
+
eligible.group_id,
|
|
1564
|
+
eligible.priority,
|
|
1565
|
+
eligible.created_at,
|
|
1566
|
+
ROW_NUMBER() OVER (
|
|
1567
|
+
PARTITION BY eligible.group_id
|
|
1568
|
+
ORDER BY eligible.priority DESC, eligible.created_at ASC
|
|
1569
|
+
) AS group_rank,
|
|
1570
|
+
COALESCE((
|
|
1571
|
+
SELECT COUNT(*)
|
|
1572
|
+
FROM job_queue processing_jobs
|
|
1573
|
+
WHERE processing_jobs.status = 'processing'
|
|
1574
|
+
AND processing_jobs.group_id = eligible.group_id
|
|
1575
|
+
), 0) AS active_group_count
|
|
1576
|
+
FROM eligible
|
|
1577
|
+
),
|
|
1578
|
+
selected AS (
|
|
1579
|
+
SELECT ranked.id
|
|
1580
|
+
FROM ranked
|
|
1581
|
+
WHERE ranked.group_id IS NULL
|
|
1582
|
+
OR (
|
|
1583
|
+
ranked.active_group_count < $${groupConcurrencyParamIndex}
|
|
1584
|
+
AND ranked.group_rank <= ($${groupConcurrencyParamIndex} - ranked.active_group_count)
|
|
1585
|
+
)
|
|
1586
|
+
ORDER BY ranked.priority DESC, ranked.created_at ASC
|
|
1587
|
+
LIMIT $2
|
|
1144
1588
|
)
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1589
|
+
UPDATE job_queue
|
|
1590
|
+
SET status = 'processing',
|
|
1591
|
+
locked_at = NOW(),
|
|
1592
|
+
locked_by = $1,
|
|
1593
|
+
attempts = CASE WHEN status = 'waiting' THEN attempts ELSE attempts + 1 END,
|
|
1594
|
+
updated_at = NOW(),
|
|
1595
|
+
pending_reason = NULL,
|
|
1596
|
+
started_at = COALESCE(started_at, NOW()),
|
|
1597
|
+
last_retried_at = CASE WHEN status != 'waiting' AND attempts > 0 THEN NOW() ELSE last_retried_at END,
|
|
1598
|
+
wait_until = NULL
|
|
1599
|
+
WHERE id IN (SELECT id FROM selected)
|
|
1600
|
+
RETURNING id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", force_kill_on_timeout AS "forceKillOnTimeout", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", idempotency_key AS "idempotencyKey", wait_until AS "waitUntil", wait_token_id AS "waitTokenId", step_data AS "stepData", progress, retry_delay AS "retryDelay", retry_backoff AS "retryBackoff", retry_delay_max AS "retryDelayMax", group_id AS "groupId", group_tier AS "groupTier", output
|
|
1601
|
+
`,
|
|
1602
|
+
constrainedParams
|
|
1603
|
+
);
|
|
1604
|
+
}
|
|
1154
1605
|
log(`Found ${result.rows.length} jobs to process`);
|
|
1155
1606
|
await client.query("COMMIT");
|
|
1156
1607
|
if (result.rows.length > 0) {
|
|
@@ -1175,17 +1626,19 @@ var PostgresBackend = class {
|
|
|
1175
1626
|
client.release();
|
|
1176
1627
|
}
|
|
1177
1628
|
}
|
|
1178
|
-
async completeJob(jobId) {
|
|
1629
|
+
async completeJob(jobId, output) {
|
|
1179
1630
|
const client = await this.pool.connect();
|
|
1180
1631
|
try {
|
|
1632
|
+
const outputJson = output !== void 0 ? JSON.stringify(output) : null;
|
|
1181
1633
|
const result = await client.query(
|
|
1182
1634
|
`
|
|
1183
1635
|
UPDATE job_queue
|
|
1184
1636
|
SET status = 'completed', updated_at = NOW(), completed_at = NOW(),
|
|
1185
|
-
step_data = NULL, wait_until = NULL, wait_token_id = NULL
|
|
1637
|
+
step_data = NULL, wait_until = NULL, wait_token_id = NULL,
|
|
1638
|
+
output = COALESCE($2::jsonb, output)
|
|
1186
1639
|
WHERE id = $1 AND status = 'processing'
|
|
1187
1640
|
`,
|
|
1188
|
-
[jobId]
|
|
1641
|
+
[jobId, outputJson]
|
|
1189
1642
|
);
|
|
1190
1643
|
if (result.rowCount === 0) {
|
|
1191
1644
|
log(
|
|
@@ -1209,9 +1662,17 @@ var PostgresBackend = class {
|
|
|
1209
1662
|
UPDATE job_queue
|
|
1210
1663
|
SET status = 'failed',
|
|
1211
1664
|
updated_at = NOW(),
|
|
1212
|
-
next_attempt_at = CASE
|
|
1213
|
-
WHEN attempts
|
|
1214
|
-
|
|
1665
|
+
next_attempt_at = CASE
|
|
1666
|
+
WHEN attempts >= max_attempts THEN NULL
|
|
1667
|
+
WHEN retry_delay IS NULL AND retry_backoff IS NULL AND retry_delay_max IS NULL
|
|
1668
|
+
THEN NOW() + (POWER(2, attempts) * INTERVAL '1 minute')
|
|
1669
|
+
WHEN COALESCE(retry_backoff, true) = true
|
|
1670
|
+
THEN NOW() + (LEAST(
|
|
1671
|
+
COALESCE(retry_delay_max, 2147483647),
|
|
1672
|
+
COALESCE(retry_delay, 60) * POWER(2, attempts)
|
|
1673
|
+
) * (0.5 + 0.5 * random()) * INTERVAL '1 second')
|
|
1674
|
+
ELSE
|
|
1675
|
+
NOW() + (COALESCE(retry_delay, 60) * INTERVAL '1 second')
|
|
1215
1676
|
END,
|
|
1216
1677
|
error_history = COALESCE(error_history, '[]'::jsonb) || $2::jsonb,
|
|
1217
1678
|
failure_reason = $3,
|
|
@@ -1280,6 +1741,21 @@ var PostgresBackend = class {
|
|
|
1280
1741
|
client.release();
|
|
1281
1742
|
}
|
|
1282
1743
|
}
|
|
1744
|
+
// ── Output ────────────────────────────────────────────────────────────
|
|
1745
|
+
async updateOutput(jobId, output) {
|
|
1746
|
+
const client = await this.pool.connect();
|
|
1747
|
+
try {
|
|
1748
|
+
await client.query(
|
|
1749
|
+
`UPDATE job_queue SET output = $2::jsonb, updated_at = NOW() WHERE id = $1`,
|
|
1750
|
+
[jobId, JSON.stringify(output)]
|
|
1751
|
+
);
|
|
1752
|
+
log(`Updated output for job ${jobId}`);
|
|
1753
|
+
} catch (error) {
|
|
1754
|
+
log(`Error updating output for job ${jobId}: ${error}`);
|
|
1755
|
+
} finally {
|
|
1756
|
+
client.release();
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1283
1759
|
// ── Job management ────────────────────────────────────────────────────
|
|
1284
1760
|
async retryJob(jobId) {
|
|
1285
1761
|
const client = await this.pool.connect();
|
|
@@ -1449,6 +1925,18 @@ var PostgresBackend = class {
|
|
|
1449
1925
|
updateFields.push(`tags = $${paramIdx++}`);
|
|
1450
1926
|
params.push(updates.tags ?? null);
|
|
1451
1927
|
}
|
|
1928
|
+
if (updates.retryDelay !== void 0) {
|
|
1929
|
+
updateFields.push(`retry_delay = $${paramIdx++}`);
|
|
1930
|
+
params.push(updates.retryDelay ?? null);
|
|
1931
|
+
}
|
|
1932
|
+
if (updates.retryBackoff !== void 0) {
|
|
1933
|
+
updateFields.push(`retry_backoff = $${paramIdx++}`);
|
|
1934
|
+
params.push(updates.retryBackoff ?? null);
|
|
1935
|
+
}
|
|
1936
|
+
if (updates.retryDelayMax !== void 0) {
|
|
1937
|
+
updateFields.push(`retry_delay_max = $${paramIdx++}`);
|
|
1938
|
+
params.push(updates.retryDelayMax ?? null);
|
|
1939
|
+
}
|
|
1452
1940
|
if (updateFields.length === 0) {
|
|
1453
1941
|
log(`No fields to update for job ${jobId}`);
|
|
1454
1942
|
return;
|
|
@@ -1470,6 +1958,12 @@ var PostgresBackend = class {
|
|
|
1470
1958
|
if (updates.timeoutMs !== void 0)
|
|
1471
1959
|
metadata.timeoutMs = updates.timeoutMs;
|
|
1472
1960
|
if (updates.tags !== void 0) metadata.tags = updates.tags;
|
|
1961
|
+
if (updates.retryDelay !== void 0)
|
|
1962
|
+
metadata.retryDelay = updates.retryDelay;
|
|
1963
|
+
if (updates.retryBackoff !== void 0)
|
|
1964
|
+
metadata.retryBackoff = updates.retryBackoff;
|
|
1965
|
+
if (updates.retryDelayMax !== void 0)
|
|
1966
|
+
metadata.retryDelayMax = updates.retryDelayMax;
|
|
1473
1967
|
await this.recordJobEvent(jobId, "edited" /* Edited */, metadata);
|
|
1474
1968
|
log(`Edited job ${jobId}: ${JSON.stringify(metadata)}`);
|
|
1475
1969
|
} catch (error) {
|
|
@@ -1513,6 +2007,18 @@ var PostgresBackend = class {
|
|
|
1513
2007
|
updateFields.push(`tags = $${paramIdx++}`);
|
|
1514
2008
|
params.push(updates.tags ?? null);
|
|
1515
2009
|
}
|
|
2010
|
+
if (updates.retryDelay !== void 0) {
|
|
2011
|
+
updateFields.push(`retry_delay = $${paramIdx++}`);
|
|
2012
|
+
params.push(updates.retryDelay ?? null);
|
|
2013
|
+
}
|
|
2014
|
+
if (updates.retryBackoff !== void 0) {
|
|
2015
|
+
updateFields.push(`retry_backoff = $${paramIdx++}`);
|
|
2016
|
+
params.push(updates.retryBackoff ?? null);
|
|
2017
|
+
}
|
|
2018
|
+
if (updates.retryDelayMax !== void 0) {
|
|
2019
|
+
updateFields.push(`retry_delay_max = $${paramIdx++}`);
|
|
2020
|
+
params.push(updates.retryDelayMax ?? null);
|
|
2021
|
+
}
|
|
1516
2022
|
if (updateFields.length === 0) {
|
|
1517
2023
|
log(`No fields to update for batch edit`);
|
|
1518
2024
|
return 0;
|
|
@@ -1754,8 +2260,8 @@ var PostgresBackend = class {
|
|
|
1754
2260
|
`INSERT INTO cron_schedules
|
|
1755
2261
|
(schedule_name, cron_expression, job_type, payload, max_attempts,
|
|
1756
2262
|
priority, timeout_ms, force_kill_on_timeout, tags, timezone,
|
|
1757
|
-
allow_overlap, next_run_at)
|
|
1758
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
|
2263
|
+
allow_overlap, next_run_at, retry_delay, retry_backoff, retry_delay_max)
|
|
2264
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
|
1759
2265
|
RETURNING id`,
|
|
1760
2266
|
[
|
|
1761
2267
|
input.scheduleName,
|
|
@@ -1769,7 +2275,10 @@ var PostgresBackend = class {
|
|
|
1769
2275
|
input.tags ?? null,
|
|
1770
2276
|
input.timezone,
|
|
1771
2277
|
input.allowOverlap,
|
|
1772
|
-
input.nextRunAt
|
|
2278
|
+
input.nextRunAt,
|
|
2279
|
+
input.retryDelay,
|
|
2280
|
+
input.retryBackoff,
|
|
2281
|
+
input.retryDelayMax
|
|
1773
2282
|
]
|
|
1774
2283
|
);
|
|
1775
2284
|
const id = result.rows[0].id;
|
|
@@ -1799,7 +2308,9 @@ var PostgresBackend = class {
|
|
|
1799
2308
|
timezone, allow_overlap AS "allowOverlap", status,
|
|
1800
2309
|
last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
|
|
1801
2310
|
next_run_at AS "nextRunAt",
|
|
1802
|
-
created_at AS "createdAt", updated_at AS "updatedAt"
|
|
2311
|
+
created_at AS "createdAt", updated_at AS "updatedAt",
|
|
2312
|
+
retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
|
|
2313
|
+
retry_delay_max AS "retryDelayMax"
|
|
1803
2314
|
FROM cron_schedules WHERE id = $1`,
|
|
1804
2315
|
[id]
|
|
1805
2316
|
);
|
|
@@ -1824,7 +2335,9 @@ var PostgresBackend = class {
|
|
|
1824
2335
|
timezone, allow_overlap AS "allowOverlap", status,
|
|
1825
2336
|
last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
|
|
1826
2337
|
next_run_at AS "nextRunAt",
|
|
1827
|
-
created_at AS "createdAt", updated_at AS "updatedAt"
|
|
2338
|
+
created_at AS "createdAt", updated_at AS "updatedAt",
|
|
2339
|
+
retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
|
|
2340
|
+
retry_delay_max AS "retryDelayMax"
|
|
1828
2341
|
FROM cron_schedules WHERE schedule_name = $1`,
|
|
1829
2342
|
[name]
|
|
1830
2343
|
);
|
|
@@ -1848,7 +2361,9 @@ var PostgresBackend = class {
|
|
|
1848
2361
|
timezone, allow_overlap AS "allowOverlap", status,
|
|
1849
2362
|
last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
|
|
1850
2363
|
next_run_at AS "nextRunAt",
|
|
1851
|
-
created_at AS "createdAt", updated_at AS "updatedAt"
|
|
2364
|
+
created_at AS "createdAt", updated_at AS "updatedAt",
|
|
2365
|
+
retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
|
|
2366
|
+
retry_delay_max AS "retryDelayMax"
|
|
1852
2367
|
FROM cron_schedules`;
|
|
1853
2368
|
const params = [];
|
|
1854
2369
|
if (status) {
|
|
@@ -1953,6 +2468,18 @@ var PostgresBackend = class {
|
|
|
1953
2468
|
updateFields.push(`allow_overlap = $${paramIdx++}`);
|
|
1954
2469
|
params.push(updates.allowOverlap);
|
|
1955
2470
|
}
|
|
2471
|
+
if (updates.retryDelay !== void 0) {
|
|
2472
|
+
updateFields.push(`retry_delay = $${paramIdx++}`);
|
|
2473
|
+
params.push(updates.retryDelay);
|
|
2474
|
+
}
|
|
2475
|
+
if (updates.retryBackoff !== void 0) {
|
|
2476
|
+
updateFields.push(`retry_backoff = $${paramIdx++}`);
|
|
2477
|
+
params.push(updates.retryBackoff);
|
|
2478
|
+
}
|
|
2479
|
+
if (updates.retryDelayMax !== void 0) {
|
|
2480
|
+
updateFields.push(`retry_delay_max = $${paramIdx++}`);
|
|
2481
|
+
params.push(updates.retryDelayMax);
|
|
2482
|
+
}
|
|
1956
2483
|
if (nextRunAt !== void 0) {
|
|
1957
2484
|
updateFields.push(`next_run_at = $${paramIdx++}`);
|
|
1958
2485
|
params.push(nextRunAt);
|
|
@@ -1988,7 +2515,9 @@ var PostgresBackend = class {
|
|
|
1988
2515
|
timezone, allow_overlap AS "allowOverlap", status,
|
|
1989
2516
|
last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
|
|
1990
2517
|
next_run_at AS "nextRunAt",
|
|
1991
|
-
created_at AS "createdAt", updated_at AS "updatedAt"
|
|
2518
|
+
created_at AS "createdAt", updated_at AS "updatedAt",
|
|
2519
|
+
retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
|
|
2520
|
+
retry_delay_max AS "retryDelayMax"
|
|
1992
2521
|
FROM cron_schedules
|
|
1993
2522
|
WHERE status = 'active'
|
|
1994
2523
|
AND next_run_at IS NOT NULL
|
|
@@ -2272,6 +2801,11 @@ local forceKillOnTimeout = ARGV[7]
|
|
|
2272
2801
|
local tagsJson = ARGV[8] -- "null" or JSON array string
|
|
2273
2802
|
local idempotencyKey = ARGV[9] -- "null" string if not set
|
|
2274
2803
|
local nowMs = tonumber(ARGV[10])
|
|
2804
|
+
local retryDelay = ARGV[11] -- "null" or seconds string
|
|
2805
|
+
local retryBackoff = ARGV[12] -- "null" or "true"/"false"
|
|
2806
|
+
local retryDelayMax = ARGV[13] -- "null" or seconds string
|
|
2807
|
+
local groupId = ARGV[14] -- "null" or group ID
|
|
2808
|
+
local groupTier = ARGV[15] -- "null" or group tier
|
|
2275
2809
|
|
|
2276
2810
|
-- Idempotency check
|
|
2277
2811
|
if idempotencyKey ~= "null" then
|
|
@@ -2315,7 +2849,12 @@ redis.call('HMSET', jobKey,
|
|
|
2315
2849
|
'idempotencyKey', idempotencyKey,
|
|
2316
2850
|
'waitUntil', 'null',
|
|
2317
2851
|
'waitTokenId', 'null',
|
|
2318
|
-
'stepData', 'null'
|
|
2852
|
+
'stepData', 'null',
|
|
2853
|
+
'retryDelay', retryDelay,
|
|
2854
|
+
'retryBackoff', retryBackoff,
|
|
2855
|
+
'retryDelayMax', retryDelayMax,
|
|
2856
|
+
'groupId', groupId,
|
|
2857
|
+
'groupTier', groupTier
|
|
2319
2858
|
)
|
|
2320
2859
|
|
|
2321
2860
|
-- Status index
|
|
@@ -2356,12 +2895,134 @@ end
|
|
|
2356
2895
|
|
|
2357
2896
|
return id
|
|
2358
2897
|
`;
|
|
2898
|
+
var ADD_JOBS_SCRIPT = `
|
|
2899
|
+
local prefix = KEYS[1]
|
|
2900
|
+
local jobsJson = ARGV[1]
|
|
2901
|
+
local nowMs = tonumber(ARGV[2])
|
|
2902
|
+
|
|
2903
|
+
local jobs = cjson.decode(jobsJson)
|
|
2904
|
+
local results = {}
|
|
2905
|
+
|
|
2906
|
+
for i, job in ipairs(jobs) do
|
|
2907
|
+
local jobType = job.jobType
|
|
2908
|
+
local payloadJson = job.payload
|
|
2909
|
+
local maxAttempts = tonumber(job.maxAttempts)
|
|
2910
|
+
local priority = tonumber(job.priority)
|
|
2911
|
+
local runAtMs = tostring(job.runAtMs)
|
|
2912
|
+
local timeoutMs = tostring(job.timeoutMs)
|
|
2913
|
+
local forceKillOnTimeout = tostring(job.forceKillOnTimeout)
|
|
2914
|
+
local tagsJson = tostring(job.tags)
|
|
2915
|
+
local idempotencyKey = tostring(job.idempotencyKey)
|
|
2916
|
+
local retryDelay = tostring(job.retryDelay)
|
|
2917
|
+
local retryBackoff = tostring(job.retryBackoff)
|
|
2918
|
+
local retryDelayMax = tostring(job.retryDelayMax)
|
|
2919
|
+
local groupId = tostring(job.groupId)
|
|
2920
|
+
local groupTier = tostring(job.groupTier)
|
|
2921
|
+
|
|
2922
|
+
-- Idempotency check
|
|
2923
|
+
local skip = false
|
|
2924
|
+
if idempotencyKey ~= "null" then
|
|
2925
|
+
local existing = redis.call('GET', prefix .. 'idempotency:' .. idempotencyKey)
|
|
2926
|
+
if existing then
|
|
2927
|
+
results[i] = tonumber(existing)
|
|
2928
|
+
skip = true
|
|
2929
|
+
end
|
|
2930
|
+
end
|
|
2931
|
+
|
|
2932
|
+
if not skip then
|
|
2933
|
+
-- Generate ID
|
|
2934
|
+
local id = redis.call('INCR', prefix .. 'id_seq')
|
|
2935
|
+
local jobKey = prefix .. 'job:' .. id
|
|
2936
|
+
local runAt = runAtMs ~= "0" and tonumber(runAtMs) or nowMs
|
|
2937
|
+
|
|
2938
|
+
-- Store the job hash
|
|
2939
|
+
redis.call('HMSET', jobKey,
|
|
2940
|
+
'id', id,
|
|
2941
|
+
'jobType', jobType,
|
|
2942
|
+
'payload', payloadJson,
|
|
2943
|
+
'status', 'pending',
|
|
2944
|
+
'maxAttempts', maxAttempts,
|
|
2945
|
+
'attempts', 0,
|
|
2946
|
+
'priority', priority,
|
|
2947
|
+
'runAt', runAt,
|
|
2948
|
+
'timeoutMs', timeoutMs,
|
|
2949
|
+
'forceKillOnTimeout', forceKillOnTimeout,
|
|
2950
|
+
'createdAt', nowMs,
|
|
2951
|
+
'updatedAt', nowMs,
|
|
2952
|
+
'lockedAt', 'null',
|
|
2953
|
+
'lockedBy', 'null',
|
|
2954
|
+
'nextAttemptAt', 'null',
|
|
2955
|
+
'pendingReason', 'null',
|
|
2956
|
+
'errorHistory', '[]',
|
|
2957
|
+
'failureReason', 'null',
|
|
2958
|
+
'completedAt', 'null',
|
|
2959
|
+
'startedAt', 'null',
|
|
2960
|
+
'lastRetriedAt', 'null',
|
|
2961
|
+
'lastFailedAt', 'null',
|
|
2962
|
+
'lastCancelledAt', 'null',
|
|
2963
|
+
'tags', tagsJson,
|
|
2964
|
+
'idempotencyKey', idempotencyKey,
|
|
2965
|
+
'waitUntil', 'null',
|
|
2966
|
+
'waitTokenId', 'null',
|
|
2967
|
+
'stepData', 'null',
|
|
2968
|
+
'retryDelay', retryDelay,
|
|
2969
|
+
'retryBackoff', retryBackoff,
|
|
2970
|
+
'retryDelayMax', retryDelayMax,
|
|
2971
|
+
'groupId', groupId,
|
|
2972
|
+
'groupTier', groupTier
|
|
2973
|
+
)
|
|
2974
|
+
|
|
2975
|
+
-- Status index
|
|
2976
|
+
redis.call('SADD', prefix .. 'status:pending', id)
|
|
2977
|
+
|
|
2978
|
+
-- Type index
|
|
2979
|
+
redis.call('SADD', prefix .. 'type:' .. jobType, id)
|
|
2980
|
+
|
|
2981
|
+
-- Tag indexes
|
|
2982
|
+
if tagsJson ~= "null" then
|
|
2983
|
+
local tags = cjson.decode(tagsJson)
|
|
2984
|
+
for _, tag in ipairs(tags) do
|
|
2985
|
+
redis.call('SADD', prefix .. 'tag:' .. tag, id)
|
|
2986
|
+
end
|
|
2987
|
+
for _, tag in ipairs(tags) do
|
|
2988
|
+
redis.call('SADD', prefix .. 'job:' .. id .. ':tags', tag)
|
|
2989
|
+
end
|
|
2990
|
+
end
|
|
2991
|
+
|
|
2992
|
+
-- Idempotency mapping
|
|
2993
|
+
if idempotencyKey ~= "null" then
|
|
2994
|
+
redis.call('SET', prefix .. 'idempotency:' .. idempotencyKey, id)
|
|
2995
|
+
end
|
|
2996
|
+
|
|
2997
|
+
-- All-jobs sorted set
|
|
2998
|
+
redis.call('ZADD', prefix .. 'all', nowMs, id)
|
|
2999
|
+
|
|
3000
|
+
-- Queue or delayed
|
|
3001
|
+
if runAt <= nowMs then
|
|
3002
|
+
local score = priority * ${SCORE_RANGE} + (${SCORE_RANGE} - nowMs)
|
|
3003
|
+
redis.call('ZADD', prefix .. 'queue', score, id)
|
|
3004
|
+
else
|
|
3005
|
+
redis.call('ZADD', prefix .. 'delayed', runAt, id)
|
|
3006
|
+
end
|
|
3007
|
+
|
|
3008
|
+
results[i] = id
|
|
3009
|
+
end
|
|
3010
|
+
end
|
|
3011
|
+
|
|
3012
|
+
return results
|
|
3013
|
+
`;
|
|
2359
3014
|
var GET_NEXT_BATCH_SCRIPT = `
|
|
2360
3015
|
local prefix = KEYS[1]
|
|
2361
3016
|
local workerId = ARGV[1]
|
|
2362
3017
|
local batchSize = tonumber(ARGV[2])
|
|
2363
3018
|
local nowMs = tonumber(ARGV[3])
|
|
2364
3019
|
local jobTypeFilter = ARGV[4] -- "null" or JSON array or single string
|
|
3020
|
+
local groupConcurrencyRaw = ARGV[5] -- "null" or positive integer
|
|
3021
|
+
local groupConcurrency = nil
|
|
3022
|
+
if groupConcurrencyRaw ~= "null" then
|
|
3023
|
+
groupConcurrency = tonumber(groupConcurrencyRaw)
|
|
3024
|
+
end
|
|
3025
|
+
local groupActiveKey = prefix .. 'group:active'
|
|
2365
3026
|
|
|
2366
3027
|
-- 1. Move ready delayed jobs into queue
|
|
2367
3028
|
local delayed = redis.call('ZRANGEBYSCORE', prefix .. 'delayed', '-inf', nowMs, 'LIMIT', 0, 200)
|
|
@@ -2462,36 +3123,53 @@ for i = 1, #candidates, 2 do
|
|
|
2462
3123
|
-- Not ready yet: move to delayed
|
|
2463
3124
|
redis.call('ZADD', prefix .. 'delayed', runAt, jobId)
|
|
2464
3125
|
else
|
|
2465
|
-
|
|
2466
|
-
local
|
|
2467
|
-
local
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
3126
|
+
local groupId = redis.call('HGET', jk, 'groupId')
|
|
3127
|
+
local hasGroup = groupId and groupId ~= 'null'
|
|
3128
|
+
local canClaim = true
|
|
3129
|
+
if hasGroup and groupConcurrency then
|
|
3130
|
+
local activeCount = tonumber(redis.call('HGET', groupActiveKey, groupId) or '0')
|
|
3131
|
+
if activeCount >= groupConcurrency then
|
|
3132
|
+
table.insert(putBack, score)
|
|
3133
|
+
table.insert(putBack, jobId)
|
|
3134
|
+
canClaim = false
|
|
3135
|
+
end
|
|
3136
|
+
end
|
|
2471
3137
|
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
'
|
|
2475
|
-
'
|
|
2476
|
-
'
|
|
2477
|
-
'
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
'
|
|
2481
|
-
|
|
3138
|
+
if canClaim then
|
|
3139
|
+
-- Claim this job
|
|
3140
|
+
local attempts = tonumber(redis.call('HGET', jk, 'attempts'))
|
|
3141
|
+
local startedAt = redis.call('HGET', jk, 'startedAt')
|
|
3142
|
+
local lastRetriedAt = redis.call('HGET', jk, 'lastRetriedAt')
|
|
3143
|
+
if startedAt == 'null' then startedAt = nowMs end
|
|
3144
|
+
if attempts > 0 then lastRetriedAt = nowMs end
|
|
3145
|
+
|
|
3146
|
+
redis.call('HMSET', jk,
|
|
3147
|
+
'status', 'processing',
|
|
3148
|
+
'lockedAt', nowMs,
|
|
3149
|
+
'lockedBy', workerId,
|
|
3150
|
+
'attempts', attempts + 1,
|
|
3151
|
+
'updatedAt', nowMs,
|
|
3152
|
+
'pendingReason', 'null',
|
|
3153
|
+
'startedAt', startedAt,
|
|
3154
|
+
'lastRetriedAt', lastRetriedAt
|
|
3155
|
+
)
|
|
2482
3156
|
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
3157
|
+
-- Update status sets
|
|
3158
|
+
redis.call('SREM', prefix .. 'status:pending', jobId)
|
|
3159
|
+
redis.call('SADD', prefix .. 'status:processing', jobId)
|
|
3160
|
+
if hasGroup and groupConcurrency then
|
|
3161
|
+
redis.call('HINCRBY', groupActiveKey, groupId, 1)
|
|
3162
|
+
end
|
|
2486
3163
|
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
3164
|
+
-- Return job data as flat array
|
|
3165
|
+
local data = redis.call('HGETALL', jk)
|
|
3166
|
+
for _, v in ipairs(data) do
|
|
3167
|
+
table.insert(results, v)
|
|
3168
|
+
end
|
|
3169
|
+
-- Separator
|
|
3170
|
+
table.insert(results, '__JOB_SEP__')
|
|
3171
|
+
jobsClaimed = jobsClaimed + 1
|
|
2491
3172
|
end
|
|
2492
|
-
-- Separator
|
|
2493
|
-
table.insert(results, '__JOB_SEP__')
|
|
2494
|
-
jobsClaimed = jobsClaimed + 1
|
|
2495
3173
|
end
|
|
2496
3174
|
end
|
|
2497
3175
|
end
|
|
@@ -2508,18 +3186,34 @@ var COMPLETE_JOB_SCRIPT = `
|
|
|
2508
3186
|
local prefix = KEYS[1]
|
|
2509
3187
|
local jobId = ARGV[1]
|
|
2510
3188
|
local nowMs = ARGV[2]
|
|
3189
|
+
local outputJson = ARGV[3]
|
|
2511
3190
|
local jk = prefix .. 'job:' .. jobId
|
|
3191
|
+
local groupId = redis.call('HGET', jk, 'groupId')
|
|
2512
3192
|
|
|
2513
|
-
|
|
3193
|
+
local fields = {
|
|
2514
3194
|
'status', 'completed',
|
|
2515
3195
|
'updatedAt', nowMs,
|
|
2516
3196
|
'completedAt', nowMs,
|
|
2517
3197
|
'stepData', 'null',
|
|
2518
3198
|
'waitUntil', 'null',
|
|
2519
3199
|
'waitTokenId', 'null'
|
|
2520
|
-
|
|
3200
|
+
}
|
|
3201
|
+
|
|
3202
|
+
if outputJson ~= '__NONE__' then
|
|
3203
|
+
fields[#fields + 1] = 'output'
|
|
3204
|
+
fields[#fields + 1] = outputJson
|
|
3205
|
+
end
|
|
3206
|
+
|
|
3207
|
+
redis.call('HMSET', jk, unpack(fields))
|
|
2521
3208
|
redis.call('SREM', prefix .. 'status:processing', jobId)
|
|
2522
3209
|
redis.call('SADD', prefix .. 'status:completed', jobId)
|
|
3210
|
+
if groupId and groupId ~= 'null' then
|
|
3211
|
+
local activeKey = prefix .. 'group:active'
|
|
3212
|
+
local remaining = redis.call('HINCRBY', activeKey, groupId, -1)
|
|
3213
|
+
if tonumber(remaining) <= 0 then
|
|
3214
|
+
redis.call('HDEL', activeKey, groupId)
|
|
3215
|
+
end
|
|
3216
|
+
end
|
|
2523
3217
|
|
|
2524
3218
|
return 1
|
|
2525
3219
|
`;
|
|
@@ -2530,15 +3224,43 @@ local errorJson = ARGV[2]
|
|
|
2530
3224
|
local failureReason = ARGV[3]
|
|
2531
3225
|
local nowMs = tonumber(ARGV[4])
|
|
2532
3226
|
local jk = prefix .. 'job:' .. jobId
|
|
3227
|
+
local groupId = redis.call('HGET', jk, 'groupId')
|
|
2533
3228
|
|
|
2534
3229
|
local attempts = tonumber(redis.call('HGET', jk, 'attempts'))
|
|
2535
3230
|
local maxAttempts = tonumber(redis.call('HGET', jk, 'maxAttempts'))
|
|
2536
3231
|
|
|
2537
|
-
--
|
|
3232
|
+
-- Read per-job retry config (may be "null")
|
|
3233
|
+
local rdRaw = redis.call('HGET', jk, 'retryDelay')
|
|
3234
|
+
local rbRaw = redis.call('HGET', jk, 'retryBackoff')
|
|
3235
|
+
local rmRaw = redis.call('HGET', jk, 'retryDelayMax')
|
|
3236
|
+
|
|
2538
3237
|
local nextAttemptAt = 'null'
|
|
2539
3238
|
if attempts < maxAttempts then
|
|
2540
|
-
local
|
|
2541
|
-
|
|
3239
|
+
local allNull = (rdRaw == 'null' or rdRaw == false)
|
|
3240
|
+
and (rbRaw == 'null' or rbRaw == false)
|
|
3241
|
+
and (rmRaw == 'null' or rmRaw == false)
|
|
3242
|
+
if allNull then
|
|
3243
|
+
-- Legacy formula: 2^attempts minutes
|
|
3244
|
+
local delayMs = math.pow(2, attempts) * 60000
|
|
3245
|
+
nextAttemptAt = nowMs + delayMs
|
|
3246
|
+
else
|
|
3247
|
+
local retryDelaySec = 60
|
|
3248
|
+
if rdRaw and rdRaw ~= 'null' then retryDelaySec = tonumber(rdRaw) end
|
|
3249
|
+
local useBackoff = true
|
|
3250
|
+
if rbRaw and rbRaw ~= 'null' then useBackoff = (rbRaw == 'true') end
|
|
3251
|
+
local maxDelaySec = nil
|
|
3252
|
+
if rmRaw and rmRaw ~= 'null' then maxDelaySec = tonumber(rmRaw) end
|
|
3253
|
+
|
|
3254
|
+
local delaySec
|
|
3255
|
+
if useBackoff then
|
|
3256
|
+
delaySec = retryDelaySec * math.pow(2, attempts)
|
|
3257
|
+
if maxDelaySec then delaySec = math.min(delaySec, maxDelaySec) end
|
|
3258
|
+
delaySec = delaySec * (0.5 + 0.5 * math.random())
|
|
3259
|
+
else
|
|
3260
|
+
delaySec = retryDelaySec
|
|
3261
|
+
end
|
|
3262
|
+
nextAttemptAt = nowMs + math.floor(delaySec * 1000)
|
|
3263
|
+
end
|
|
2542
3264
|
end
|
|
2543
3265
|
|
|
2544
3266
|
-- Append to error_history
|
|
@@ -2560,6 +3282,13 @@ redis.call('HMSET', jk,
|
|
|
2560
3282
|
)
|
|
2561
3283
|
redis.call('SREM', prefix .. 'status:processing', jobId)
|
|
2562
3284
|
redis.call('SADD', prefix .. 'status:failed', jobId)
|
|
3285
|
+
if groupId and groupId ~= 'null' then
|
|
3286
|
+
local activeKey = prefix .. 'group:active'
|
|
3287
|
+
local remaining = redis.call('HINCRBY', activeKey, groupId, -1)
|
|
3288
|
+
if tonumber(remaining) <= 0 then
|
|
3289
|
+
redis.call('HDEL', activeKey, groupId)
|
|
3290
|
+
end
|
|
3291
|
+
end
|
|
2563
3292
|
|
|
2564
3293
|
-- Schedule retry if applicable
|
|
2565
3294
|
if nextAttemptAt ~= 'null' then
|
|
@@ -2576,6 +3305,7 @@ local jk = prefix .. 'job:' .. jobId
|
|
|
2576
3305
|
|
|
2577
3306
|
local oldStatus = redis.call('HGET', jk, 'status')
|
|
2578
3307
|
if oldStatus ~= 'failed' and oldStatus ~= 'processing' then return 0 end
|
|
3308
|
+
local groupId = redis.call('HGET', jk, 'groupId')
|
|
2579
3309
|
|
|
2580
3310
|
redis.call('HMSET', jk,
|
|
2581
3311
|
'status', 'pending',
|
|
@@ -2589,6 +3319,13 @@ redis.call('HMSET', jk,
|
|
|
2589
3319
|
-- Remove from old status, add to pending
|
|
2590
3320
|
redis.call('SREM', prefix .. 'status:' .. oldStatus, jobId)
|
|
2591
3321
|
redis.call('SADD', prefix .. 'status:pending', jobId)
|
|
3322
|
+
if oldStatus == 'processing' and groupId and groupId ~= 'null' then
|
|
3323
|
+
local activeKey = prefix .. 'group:active'
|
|
3324
|
+
local remaining = redis.call('HINCRBY', activeKey, groupId, -1)
|
|
3325
|
+
if tonumber(remaining) <= 0 then
|
|
3326
|
+
redis.call('HDEL', activeKey, groupId)
|
|
3327
|
+
end
|
|
3328
|
+
end
|
|
2592
3329
|
|
|
2593
3330
|
-- Remove from retry sorted set if present
|
|
2594
3331
|
redis.call('ZREM', prefix .. 'retry', jobId)
|
|
@@ -2675,6 +3412,14 @@ for _, jobId in ipairs(processing) do
|
|
|
2675
3412
|
)
|
|
2676
3413
|
redis.call('SREM', prefix .. 'status:processing', jobId)
|
|
2677
3414
|
redis.call('SADD', prefix .. 'status:pending', jobId)
|
|
3415
|
+
local groupId = redis.call('HGET', jk, 'groupId')
|
|
3416
|
+
if groupId and groupId ~= 'null' then
|
|
3417
|
+
local activeKey = prefix .. 'group:active'
|
|
3418
|
+
local remaining = redis.call('HINCRBY', activeKey, groupId, -1)
|
|
3419
|
+
if tonumber(remaining) <= 0 then
|
|
3420
|
+
redis.call('HDEL', activeKey, groupId)
|
|
3421
|
+
end
|
|
3422
|
+
end
|
|
2678
3423
|
|
|
2679
3424
|
-- Re-add to queue
|
|
2680
3425
|
local priority = tonumber(redis.call('HGET', jk, 'priority') or '0')
|
|
@@ -2741,6 +3486,7 @@ local jk = prefix .. 'job:' .. jobId
|
|
|
2741
3486
|
|
|
2742
3487
|
local status = redis.call('HGET', jk, 'status')
|
|
2743
3488
|
if status ~= 'processing' then return 0 end
|
|
3489
|
+
local groupId = redis.call('HGET', jk, 'groupId')
|
|
2744
3490
|
|
|
2745
3491
|
redis.call('HMSET', jk,
|
|
2746
3492
|
'status', 'waiting',
|
|
@@ -2753,6 +3499,13 @@ redis.call('HMSET', jk,
|
|
|
2753
3499
|
)
|
|
2754
3500
|
redis.call('SREM', prefix .. 'status:processing', jobId)
|
|
2755
3501
|
redis.call('SADD', prefix .. 'status:waiting', jobId)
|
|
3502
|
+
if groupId and groupId ~= 'null' then
|
|
3503
|
+
local activeKey = prefix .. 'group:active'
|
|
3504
|
+
local remaining = redis.call('HINCRBY', activeKey, groupId, -1)
|
|
3505
|
+
if tonumber(remaining) <= 0 then
|
|
3506
|
+
redis.call('HDEL', activeKey, groupId)
|
|
3507
|
+
end
|
|
3508
|
+
end
|
|
2756
3509
|
|
|
2757
3510
|
-- Add to waiting sorted set if time-based wait
|
|
2758
3511
|
if waitUntilMs ~= 'null' then
|
|
@@ -2950,9 +3703,23 @@ function deserializeJob(h) {
|
|
|
2950
3703
|
progress: numOrNull(h.progress),
|
|
2951
3704
|
waitUntil: dateOrNull(h.waitUntil),
|
|
2952
3705
|
waitTokenId: nullish(h.waitTokenId),
|
|
2953
|
-
stepData: parseStepData(h.stepData)
|
|
3706
|
+
stepData: parseStepData(h.stepData),
|
|
3707
|
+
retryDelay: numOrNull(h.retryDelay),
|
|
3708
|
+
retryBackoff: h.retryBackoff === "true" ? true : h.retryBackoff === "false" ? false : null,
|
|
3709
|
+
retryDelayMax: numOrNull(h.retryDelayMax),
|
|
3710
|
+
groupId: nullish(h.groupId),
|
|
3711
|
+
groupTier: nullish(h.groupTier),
|
|
3712
|
+
output: parseJsonField(h.output)
|
|
2954
3713
|
};
|
|
2955
3714
|
}
|
|
3715
|
+
function parseJsonField(raw) {
|
|
3716
|
+
if (!raw || raw === "null") return null;
|
|
3717
|
+
try {
|
|
3718
|
+
return JSON.parse(raw);
|
|
3719
|
+
} catch {
|
|
3720
|
+
return null;
|
|
3721
|
+
}
|
|
3722
|
+
}
|
|
2956
3723
|
function parseStepData(raw) {
|
|
2957
3724
|
if (!raw || raw === "null") return void 0;
|
|
2958
3725
|
try {
|
|
@@ -2962,7 +3729,23 @@ function parseStepData(raw) {
|
|
|
2962
3729
|
}
|
|
2963
3730
|
}
|
|
2964
3731
|
var RedisBackend = class {
|
|
2965
|
-
|
|
3732
|
+
/**
|
|
3733
|
+
* Create a RedisBackend.
|
|
3734
|
+
*
|
|
3735
|
+
* @param configOrClient - Either `redisConfig` from the config file (the
|
|
3736
|
+
* library creates a new ioredis client) or an existing ioredis client
|
|
3737
|
+
* instance (bring your own).
|
|
3738
|
+
* @param keyPrefix - Key prefix, only used when `configOrClient` is an
|
|
3739
|
+
* external client. Ignored when `redisConfig` is passed (uses
|
|
3740
|
+
* `redisConfig.keyPrefix` instead). Default: `'dq:'`.
|
|
3741
|
+
*/
|
|
3742
|
+
constructor(configOrClient, keyPrefix) {
|
|
3743
|
+
if (configOrClient && typeof configOrClient.eval === "function") {
|
|
3744
|
+
this.client = configOrClient;
|
|
3745
|
+
this.prefix = keyPrefix ?? "dq:";
|
|
3746
|
+
return;
|
|
3747
|
+
}
|
|
3748
|
+
const redisConfig = configOrClient;
|
|
2966
3749
|
let IORedis;
|
|
2967
3750
|
try {
|
|
2968
3751
|
const _require = createRequire(import.meta.url);
|
|
@@ -3035,8 +3818,17 @@ var RedisBackend = class {
|
|
|
3035
3818
|
timeoutMs = void 0,
|
|
3036
3819
|
forceKillOnTimeout = false,
|
|
3037
3820
|
tags = void 0,
|
|
3038
|
-
idempotencyKey = void 0
|
|
3039
|
-
|
|
3821
|
+
idempotencyKey = void 0,
|
|
3822
|
+
retryDelay = void 0,
|
|
3823
|
+
retryBackoff = void 0,
|
|
3824
|
+
retryDelayMax = void 0,
|
|
3825
|
+
group = void 0
|
|
3826
|
+
}, options) {
|
|
3827
|
+
if (options?.db) {
|
|
3828
|
+
throw new Error(
|
|
3829
|
+
"The db option is not supported with the Redis backend. Transactional job creation is only available with PostgreSQL."
|
|
3830
|
+
);
|
|
3831
|
+
}
|
|
3040
3832
|
const now = this.nowMs();
|
|
3041
3833
|
const runAtMs = runAt ? runAt.getTime() : 0;
|
|
3042
3834
|
const result = await this.client.eval(
|
|
@@ -3052,7 +3844,12 @@ var RedisBackend = class {
|
|
|
3052
3844
|
forceKillOnTimeout ? "true" : "false",
|
|
3053
3845
|
tags ? JSON.stringify(tags) : "null",
|
|
3054
3846
|
idempotencyKey ?? "null",
|
|
3055
|
-
now
|
|
3847
|
+
now,
|
|
3848
|
+
retryDelay !== void 0 ? retryDelay.toString() : "null",
|
|
3849
|
+
retryBackoff !== void 0 ? retryBackoff.toString() : "null",
|
|
3850
|
+
retryDelayMax !== void 0 ? retryDelayMax.toString() : "null",
|
|
3851
|
+
group?.id ?? "null",
|
|
3852
|
+
group?.tier ?? "null"
|
|
3056
3853
|
);
|
|
3057
3854
|
const jobId = Number(result);
|
|
3058
3855
|
log(
|
|
@@ -3066,6 +3863,60 @@ var RedisBackend = class {
|
|
|
3066
3863
|
});
|
|
3067
3864
|
return jobId;
|
|
3068
3865
|
}
|
|
3866
|
+
/**
|
|
3867
|
+
* Insert multiple jobs atomically via a single Lua script.
|
|
3868
|
+
* Returns IDs in the same order as the input array.
|
|
3869
|
+
*/
|
|
3870
|
+
async addJobs(jobs, options) {
|
|
3871
|
+
if (jobs.length === 0) return [];
|
|
3872
|
+
if (options?.db) {
|
|
3873
|
+
throw new Error(
|
|
3874
|
+
"The db option is not supported with the Redis backend. Transactional job creation is only available with PostgreSQL."
|
|
3875
|
+
);
|
|
3876
|
+
}
|
|
3877
|
+
const now = this.nowMs();
|
|
3878
|
+
const jobsPayload = jobs.map((job) => ({
|
|
3879
|
+
jobType: job.jobType,
|
|
3880
|
+
payload: JSON.stringify(job.payload),
|
|
3881
|
+
maxAttempts: job.maxAttempts ?? 3,
|
|
3882
|
+
priority: job.priority ?? 0,
|
|
3883
|
+
runAtMs: job.runAt ? job.runAt.getTime() : 0,
|
|
3884
|
+
timeoutMs: job.timeoutMs !== void 0 ? job.timeoutMs.toString() : "null",
|
|
3885
|
+
forceKillOnTimeout: job.forceKillOnTimeout ? "true" : "false",
|
|
3886
|
+
tags: job.tags ? JSON.stringify(job.tags) : "null",
|
|
3887
|
+
idempotencyKey: job.idempotencyKey ?? "null",
|
|
3888
|
+
retryDelay: job.retryDelay !== void 0 ? job.retryDelay.toString() : "null",
|
|
3889
|
+
retryBackoff: job.retryBackoff !== void 0 ? job.retryBackoff.toString() : "null",
|
|
3890
|
+
retryDelayMax: job.retryDelayMax !== void 0 ? job.retryDelayMax.toString() : "null",
|
|
3891
|
+
groupId: job.group?.id ?? "null",
|
|
3892
|
+
groupTier: job.group?.tier ?? "null"
|
|
3893
|
+
}));
|
|
3894
|
+
const result = await this.client.eval(
|
|
3895
|
+
ADD_JOBS_SCRIPT,
|
|
3896
|
+
1,
|
|
3897
|
+
this.prefix,
|
|
3898
|
+
JSON.stringify(jobsPayload),
|
|
3899
|
+
now
|
|
3900
|
+
);
|
|
3901
|
+
const ids = result.map(Number);
|
|
3902
|
+
log(`Batch-inserted ${jobs.length} jobs, IDs: [${ids.join(", ")}]`);
|
|
3903
|
+
const existingIdempotencyIds = /* @__PURE__ */ new Set();
|
|
3904
|
+
for (let i = 0; i < jobs.length; i++) {
|
|
3905
|
+
if (jobs[i].idempotencyKey) {
|
|
3906
|
+
if (existingIdempotencyIds.has(ids[i])) {
|
|
3907
|
+
continue;
|
|
3908
|
+
}
|
|
3909
|
+
existingIdempotencyIds.add(ids[i]);
|
|
3910
|
+
}
|
|
3911
|
+
await this.recordJobEvent(ids[i], "added" /* Added */, {
|
|
3912
|
+
jobType: jobs[i].jobType,
|
|
3913
|
+
payload: jobs[i].payload,
|
|
3914
|
+
tags: jobs[i].tags,
|
|
3915
|
+
idempotencyKey: jobs[i].idempotencyKey
|
|
3916
|
+
});
|
|
3917
|
+
}
|
|
3918
|
+
return ids;
|
|
3919
|
+
}
|
|
3069
3920
|
async getJob(id) {
|
|
3070
3921
|
const data = await this.client.hgetall(`${this.prefix}job:${id}`);
|
|
3071
3922
|
if (!data || Object.keys(data).length === 0) {
|
|
@@ -3136,7 +3987,7 @@ var RedisBackend = class {
|
|
|
3136
3987
|
return jobs.slice(offset, offset + limit);
|
|
3137
3988
|
}
|
|
3138
3989
|
// ── Processing lifecycle ──────────────────────────────────────────────
|
|
3139
|
-
async getNextBatch(workerId, batchSize = 10, jobType) {
|
|
3990
|
+
async getNextBatch(workerId, batchSize = 10, jobType, groupConcurrency) {
|
|
3140
3991
|
const now = this.nowMs();
|
|
3141
3992
|
const jobTypeFilter = jobType === void 0 ? "null" : Array.isArray(jobType) ? JSON.stringify(jobType) : jobType;
|
|
3142
3993
|
const result = await this.client.eval(
|
|
@@ -3146,7 +3997,8 @@ var RedisBackend = class {
|
|
|
3146
3997
|
workerId,
|
|
3147
3998
|
batchSize,
|
|
3148
3999
|
now,
|
|
3149
|
-
jobTypeFilter
|
|
4000
|
+
jobTypeFilter,
|
|
4001
|
+
groupConcurrency !== void 0 ? groupConcurrency : "null"
|
|
3150
4002
|
);
|
|
3151
4003
|
if (!result || result.length === 0) {
|
|
3152
4004
|
log("Found 0 jobs to process");
|
|
@@ -3171,9 +4023,17 @@ var RedisBackend = class {
|
|
|
3171
4023
|
}
|
|
3172
4024
|
return jobs;
|
|
3173
4025
|
}
|
|
3174
|
-
async completeJob(jobId) {
|
|
4026
|
+
async completeJob(jobId, output) {
|
|
3175
4027
|
const now = this.nowMs();
|
|
3176
|
-
|
|
4028
|
+
const outputArg = output !== void 0 ? JSON.stringify(output) : "__NONE__";
|
|
4029
|
+
await this.client.eval(
|
|
4030
|
+
COMPLETE_JOB_SCRIPT,
|
|
4031
|
+
1,
|
|
4032
|
+
this.prefix,
|
|
4033
|
+
jobId,
|
|
4034
|
+
now,
|
|
4035
|
+
outputArg
|
|
4036
|
+
);
|
|
3177
4037
|
await this.recordJobEvent(jobId, "completed" /* Completed */);
|
|
3178
4038
|
log(`Completed job ${jobId}`);
|
|
3179
4039
|
}
|
|
@@ -3226,6 +4086,22 @@ var RedisBackend = class {
|
|
|
3226
4086
|
log(`Error updating progress for job ${jobId}: ${error}`);
|
|
3227
4087
|
}
|
|
3228
4088
|
}
|
|
4089
|
+
// ── Output ────────────────────────────────────────────────────────────
|
|
4090
|
+
async updateOutput(jobId, output) {
|
|
4091
|
+
try {
|
|
4092
|
+
const now = this.nowMs();
|
|
4093
|
+
await this.client.hset(
|
|
4094
|
+
`${this.prefix}job:${jobId}`,
|
|
4095
|
+
"output",
|
|
4096
|
+
JSON.stringify(output),
|
|
4097
|
+
"updatedAt",
|
|
4098
|
+
now.toString()
|
|
4099
|
+
);
|
|
4100
|
+
log(`Updated output for job ${jobId}`);
|
|
4101
|
+
} catch (error) {
|
|
4102
|
+
log(`Error updating output for job ${jobId}: ${error}`);
|
|
4103
|
+
}
|
|
4104
|
+
}
|
|
3229
4105
|
// ── Job management ────────────────────────────────────────────────────
|
|
3230
4106
|
async retryJob(jobId) {
|
|
3231
4107
|
const now = this.nowMs();
|
|
@@ -3332,6 +4208,27 @@ var RedisBackend = class {
|
|
|
3332
4208
|
}
|
|
3333
4209
|
metadata.tags = updates.tags;
|
|
3334
4210
|
}
|
|
4211
|
+
if (updates.retryDelay !== void 0) {
|
|
4212
|
+
fields.push(
|
|
4213
|
+
"retryDelay",
|
|
4214
|
+
updates.retryDelay !== null ? updates.retryDelay.toString() : "null"
|
|
4215
|
+
);
|
|
4216
|
+
metadata.retryDelay = updates.retryDelay;
|
|
4217
|
+
}
|
|
4218
|
+
if (updates.retryBackoff !== void 0) {
|
|
4219
|
+
fields.push(
|
|
4220
|
+
"retryBackoff",
|
|
4221
|
+
updates.retryBackoff !== null ? updates.retryBackoff.toString() : "null"
|
|
4222
|
+
);
|
|
4223
|
+
metadata.retryBackoff = updates.retryBackoff;
|
|
4224
|
+
}
|
|
4225
|
+
if (updates.retryDelayMax !== void 0) {
|
|
4226
|
+
fields.push(
|
|
4227
|
+
"retryDelayMax",
|
|
4228
|
+
updates.retryDelayMax !== null ? updates.retryDelayMax.toString() : "null"
|
|
4229
|
+
);
|
|
4230
|
+
metadata.retryDelayMax = updates.retryDelayMax;
|
|
4231
|
+
}
|
|
3335
4232
|
if (fields.length === 0) {
|
|
3336
4233
|
log(`No fields to update for job ${jobId}`);
|
|
3337
4234
|
return;
|
|
@@ -3806,7 +4703,13 @@ var RedisBackend = class {
|
|
|
3806
4703
|
"createdAt",
|
|
3807
4704
|
now.toString(),
|
|
3808
4705
|
"updatedAt",
|
|
3809
|
-
now.toString()
|
|
4706
|
+
now.toString(),
|
|
4707
|
+
"retryDelay",
|
|
4708
|
+
input.retryDelay !== null && input.retryDelay !== void 0 ? input.retryDelay.toString() : "null",
|
|
4709
|
+
"retryBackoff",
|
|
4710
|
+
input.retryBackoff !== null && input.retryBackoff !== void 0 ? input.retryBackoff.toString() : "null",
|
|
4711
|
+
"retryDelayMax",
|
|
4712
|
+
input.retryDelayMax !== null && input.retryDelayMax !== void 0 ? input.retryDelayMax.toString() : "null"
|
|
3810
4713
|
];
|
|
3811
4714
|
await this.client.hmset(key, ...fields);
|
|
3812
4715
|
await this.client.set(
|
|
@@ -3960,6 +4863,24 @@ var RedisBackend = class {
|
|
|
3960
4863
|
if (updates.allowOverlap !== void 0) {
|
|
3961
4864
|
fields.push("allowOverlap", updates.allowOverlap ? "true" : "false");
|
|
3962
4865
|
}
|
|
4866
|
+
if (updates.retryDelay !== void 0) {
|
|
4867
|
+
fields.push(
|
|
4868
|
+
"retryDelay",
|
|
4869
|
+
updates.retryDelay !== null ? updates.retryDelay.toString() : "null"
|
|
4870
|
+
);
|
|
4871
|
+
}
|
|
4872
|
+
if (updates.retryBackoff !== void 0) {
|
|
4873
|
+
fields.push(
|
|
4874
|
+
"retryBackoff",
|
|
4875
|
+
updates.retryBackoff !== null ? updates.retryBackoff.toString() : "null"
|
|
4876
|
+
);
|
|
4877
|
+
}
|
|
4878
|
+
if (updates.retryDelayMax !== void 0) {
|
|
4879
|
+
fields.push(
|
|
4880
|
+
"retryDelayMax",
|
|
4881
|
+
updates.retryDelayMax !== null ? updates.retryDelayMax.toString() : "null"
|
|
4882
|
+
);
|
|
4883
|
+
}
|
|
3963
4884
|
if (nextRunAt !== void 0) {
|
|
3964
4885
|
const val = nextRunAt !== null ? nextRunAt.getTime().toString() : "null";
|
|
3965
4886
|
fields.push("nextRunAt", val);
|
|
@@ -4078,7 +4999,10 @@ var RedisBackend = class {
|
|
|
4078
4999
|
lastJobId: numOrNull(h.lastJobId),
|
|
4079
5000
|
nextRunAt: dateOrNull(h.nextRunAt),
|
|
4080
5001
|
createdAt: new Date(Number(h.createdAt)),
|
|
4081
|
-
updatedAt: new Date(Number(h.updatedAt))
|
|
5002
|
+
updatedAt: new Date(Number(h.updatedAt)),
|
|
5003
|
+
retryDelay: numOrNull(h.retryDelay),
|
|
5004
|
+
retryBackoff: h.retryBackoff === "true" ? true : h.retryBackoff === "false" ? false : null,
|
|
5005
|
+
retryDelayMax: numOrNull(h.retryDelayMax)
|
|
4082
5006
|
};
|
|
4083
5007
|
}
|
|
4084
5008
|
// ── Private helpers (filters) ─────────────────────────────────────────
|
|
@@ -4201,14 +5125,37 @@ var initJobQueue = (config) => {
|
|
|
4201
5125
|
let backend;
|
|
4202
5126
|
if (backendType === "postgres") {
|
|
4203
5127
|
const pgConfig = config;
|
|
4204
|
-
|
|
4205
|
-
|
|
5128
|
+
if (pgConfig.pool) {
|
|
5129
|
+
backend = new PostgresBackend(pgConfig.pool);
|
|
5130
|
+
} else if (pgConfig.databaseConfig) {
|
|
5131
|
+
const pool = createPool(pgConfig.databaseConfig);
|
|
5132
|
+
backend = new PostgresBackend(pool);
|
|
5133
|
+
} else {
|
|
5134
|
+
throw new Error(
|
|
5135
|
+
'PostgreSQL backend requires either "databaseConfig" or "pool" to be provided.'
|
|
5136
|
+
);
|
|
5137
|
+
}
|
|
4206
5138
|
} else if (backendType === "redis") {
|
|
4207
|
-
const redisConfig = config
|
|
4208
|
-
|
|
5139
|
+
const redisConfig = config;
|
|
5140
|
+
if (redisConfig.client) {
|
|
5141
|
+
backend = new RedisBackend(
|
|
5142
|
+
redisConfig.client,
|
|
5143
|
+
redisConfig.keyPrefix
|
|
5144
|
+
);
|
|
5145
|
+
} else if (redisConfig.redisConfig) {
|
|
5146
|
+
backend = new RedisBackend(redisConfig.redisConfig);
|
|
5147
|
+
} else {
|
|
5148
|
+
throw new Error(
|
|
5149
|
+
'Redis backend requires either "redisConfig" or "client" to be provided.'
|
|
5150
|
+
);
|
|
5151
|
+
}
|
|
4209
5152
|
} else {
|
|
4210
5153
|
throw new Error(`Unknown backend: ${backendType}`);
|
|
4211
5154
|
}
|
|
5155
|
+
const emitter = new EventEmitter();
|
|
5156
|
+
const emit = (event, data) => {
|
|
5157
|
+
emitter.emit(event, data);
|
|
5158
|
+
};
|
|
4212
5159
|
const enqueueDueCronJobsImpl = async () => {
|
|
4213
5160
|
const dueSchedules = await backend.getDueCronSchedules();
|
|
4214
5161
|
let count = 0;
|
|
@@ -4236,7 +5183,10 @@ var initJobQueue = (config) => {
|
|
|
4236
5183
|
priority: schedule.priority,
|
|
4237
5184
|
timeoutMs: schedule.timeoutMs ?? void 0,
|
|
4238
5185
|
forceKillOnTimeout: schedule.forceKillOnTimeout,
|
|
4239
|
-
tags: schedule.tags
|
|
5186
|
+
tags: schedule.tags,
|
|
5187
|
+
retryDelay: schedule.retryDelay ?? void 0,
|
|
5188
|
+
retryBackoff: schedule.retryBackoff ?? void 0,
|
|
5189
|
+
retryDelayMax: schedule.retryDelayMax ?? void 0
|
|
4240
5190
|
});
|
|
4241
5191
|
const nextRunAt = getNextCronOccurrence(
|
|
4242
5192
|
schedule.cronExpression,
|
|
@@ -4255,7 +5205,21 @@ var initJobQueue = (config) => {
|
|
|
4255
5205
|
return {
|
|
4256
5206
|
// Job queue operations
|
|
4257
5207
|
addJob: withLogContext(
|
|
4258
|
-
(job) =>
|
|
5208
|
+
async (job, options) => {
|
|
5209
|
+
const jobId = await backend.addJob(job, options);
|
|
5210
|
+
emit("job:added", { jobId, jobType: job.jobType });
|
|
5211
|
+
return jobId;
|
|
5212
|
+
},
|
|
5213
|
+
config.verbose ?? false
|
|
5214
|
+
),
|
|
5215
|
+
addJobs: withLogContext(
|
|
5216
|
+
async (jobs, options) => {
|
|
5217
|
+
const jobIds = await backend.addJobs(jobs, options);
|
|
5218
|
+
for (let i = 0; i < jobIds.length; i++) {
|
|
5219
|
+
emit("job:added", { jobId: jobIds[i], jobType: jobs[i].jobType });
|
|
5220
|
+
}
|
|
5221
|
+
return jobIds;
|
|
5222
|
+
},
|
|
4259
5223
|
config.verbose ?? false
|
|
4260
5224
|
),
|
|
4261
5225
|
getJob: withLogContext(
|
|
@@ -4274,13 +5238,16 @@ var initJobQueue = (config) => {
|
|
|
4274
5238
|
(filters, limit, offset) => backend.getJobs(filters, limit, offset),
|
|
4275
5239
|
config.verbose ?? false
|
|
4276
5240
|
),
|
|
4277
|
-
retryJob: (jobId) =>
|
|
5241
|
+
retryJob: async (jobId) => {
|
|
5242
|
+
await backend.retryJob(jobId);
|
|
5243
|
+
emit("job:retried", { jobId });
|
|
5244
|
+
},
|
|
4278
5245
|
cleanupOldJobs: (daysToKeep, batchSize) => backend.cleanupOldJobs(daysToKeep, batchSize),
|
|
4279
5246
|
cleanupOldJobEvents: (daysToKeep, batchSize) => backend.cleanupOldJobEvents(daysToKeep, batchSize),
|
|
4280
|
-
cancelJob: withLogContext(
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
),
|
|
5247
|
+
cancelJob: withLogContext(async (jobId) => {
|
|
5248
|
+
await backend.cancelJob(jobId);
|
|
5249
|
+
emit("job:cancelled", { jobId });
|
|
5250
|
+
}, config.verbose ?? false),
|
|
4284
5251
|
editJob: withLogContext(
|
|
4285
5252
|
(jobId, updates) => backend.editJob(jobId, updates),
|
|
4286
5253
|
config.verbose ?? false
|
|
@@ -4305,9 +5272,17 @@ var initJobQueue = (config) => {
|
|
|
4305
5272
|
config.verbose ?? false
|
|
4306
5273
|
),
|
|
4307
5274
|
// Job processing — automatically enqueues due cron jobs before each batch
|
|
4308
|
-
createProcessor: (handlers, options) => createProcessor(
|
|
4309
|
-
|
|
4310
|
-
|
|
5275
|
+
createProcessor: (handlers, options) => createProcessor(
|
|
5276
|
+
backend,
|
|
5277
|
+
handlers,
|
|
5278
|
+
options,
|
|
5279
|
+
async () => {
|
|
5280
|
+
await enqueueDueCronJobsImpl();
|
|
5281
|
+
},
|
|
5282
|
+
emit
|
|
5283
|
+
),
|
|
5284
|
+
// Background supervisor — automated maintenance
|
|
5285
|
+
createSupervisor: (options) => createSupervisor(backend, options, emit),
|
|
4311
5286
|
// Job events
|
|
4312
5287
|
getJobEvents: withLogContext(
|
|
4313
5288
|
(jobId) => backend.getJobEvents(jobId),
|
|
@@ -4354,7 +5329,10 @@ var initJobQueue = (config) => {
|
|
|
4354
5329
|
tags: options.tags,
|
|
4355
5330
|
timezone: options.timezone ?? "UTC",
|
|
4356
5331
|
allowOverlap: options.allowOverlap ?? false,
|
|
4357
|
-
nextRunAt
|
|
5332
|
+
nextRunAt,
|
|
5333
|
+
retryDelay: options.retryDelay ?? null,
|
|
5334
|
+
retryBackoff: options.retryBackoff ?? null,
|
|
5335
|
+
retryDelayMax: options.retryDelayMax ?? null
|
|
4358
5336
|
};
|
|
4359
5337
|
return backend.addCronSchedule(input);
|
|
4360
5338
|
},
|
|
@@ -4406,6 +5384,23 @@ var initJobQueue = (config) => {
|
|
|
4406
5384
|
() => enqueueDueCronJobsImpl(),
|
|
4407
5385
|
config.verbose ?? false
|
|
4408
5386
|
),
|
|
5387
|
+
// Event hooks
|
|
5388
|
+
on: (event, listener) => {
|
|
5389
|
+
emitter.on(event, listener);
|
|
5390
|
+
},
|
|
5391
|
+
once: (event, listener) => {
|
|
5392
|
+
emitter.once(event, listener);
|
|
5393
|
+
},
|
|
5394
|
+
off: (event, listener) => {
|
|
5395
|
+
emitter.off(event, listener);
|
|
5396
|
+
},
|
|
5397
|
+
removeAllListeners: (event) => {
|
|
5398
|
+
if (event) {
|
|
5399
|
+
emitter.removeAllListeners(event);
|
|
5400
|
+
} else {
|
|
5401
|
+
emitter.removeAllListeners();
|
|
5402
|
+
}
|
|
5403
|
+
},
|
|
4409
5404
|
// Advanced access
|
|
4410
5405
|
getPool: () => {
|
|
4411
5406
|
if (!(backend instanceof PostgresBackend)) {
|