@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/dist/index.cjs CHANGED
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ var events = require('events');
3
4
  var worker_threads = require('worker_threads');
4
5
  var async_hooks = require('async_hooks');
5
6
  var pg = require('pg');
@@ -14,7 +15,7 @@ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
14
15
 
15
16
  var fs__default = /*#__PURE__*/_interopDefault(fs);
16
17
 
17
- // src/processor.ts
18
+ // src/index.ts
18
19
 
19
20
  // src/types.ts
20
21
  var JobEventType = /* @__PURE__ */ ((JobEventType2) => {
@@ -150,9 +151,9 @@ async function runHandlerInWorker(handler, payload, timeoutMs, jobType) {
150
151
  }
151
152
 
152
153
  handlerFn(payload, signal)
153
- .then(() => {
154
+ .then((result) => {
154
155
  clearTimeout(timeoutId);
155
- parentPort.postMessage({ type: 'success' });
156
+ parentPort.postMessage({ type: 'success', output: result });
156
157
  })
157
158
  .catch((error) => {
158
159
  clearTimeout(timeoutId);
@@ -187,24 +188,27 @@ async function runHandlerInWorker(handler, payload, timeoutMs, jobType) {
187
188
  }
188
189
  });
189
190
  let resolved = false;
190
- worker.on("message", (message) => {
191
- if (resolved) return;
192
- resolved = true;
193
- if (message.type === "success") {
194
- resolve();
195
- } else if (message.type === "timeout") {
196
- const timeoutError = new Error(
197
- `Job timed out after ${timeoutMs} ms and was forcefully terminated`
198
- );
199
- timeoutError.failureReason = "timeout" /* Timeout */;
200
- reject(timeoutError);
201
- } else if (message.type === "error") {
202
- const error = new Error(message.error.message);
203
- error.stack = message.error.stack;
204
- error.name = message.error.name;
205
- reject(error);
191
+ worker.on(
192
+ "message",
193
+ (message) => {
194
+ if (resolved) return;
195
+ resolved = true;
196
+ if (message.type === "success") {
197
+ resolve(message.output);
198
+ } else if (message.type === "timeout") {
199
+ const timeoutError = new Error(
200
+ `Job timed out after ${timeoutMs} ms and was forcefully terminated`
201
+ );
202
+ timeoutError.failureReason = "timeout" /* Timeout */;
203
+ reject(timeoutError);
204
+ } else if (message.type === "error") {
205
+ const error = new Error(message.error.message);
206
+ error.stack = message.error.stack;
207
+ error.name = message.error.name;
208
+ reject(error);
209
+ }
206
210
  }
207
- });
211
+ );
208
212
  worker.on("error", (error) => {
209
213
  if (resolved) return;
210
214
  resolved = true;
@@ -361,22 +365,30 @@ function buildWaitContext(backend, jobId, stepData, baseCtx) {
361
365
  if (percent < 0 || percent > 100)
362
366
  throw new Error("Progress must be between 0 and 100");
363
367
  await backend.updateProgress(jobId, Math.round(percent));
368
+ },
369
+ setOutput: async (data) => {
370
+ await backend.updateOutput(jobId, data);
364
371
  }
365
372
  };
366
373
  return ctx;
367
374
  }
368
- async function processJobWithHandlers(backend, job, jobHandlers) {
375
+ async function processJobWithHandlers(backend, job, jobHandlers, emit) {
369
376
  const handler = jobHandlers[job.jobType];
370
377
  if (!handler) {
371
378
  await backend.setPendingReasonForUnpickedJobs(
372
379
  `No handler registered for job type: ${job.jobType}`,
373
380
  job.jobType
374
381
  );
375
- await backend.failJob(
376
- job.id,
377
- new Error(`No handler registered for job type: ${job.jobType}`),
378
- "no_handler" /* NoHandler */
382
+ const noHandlerError = new Error(
383
+ `No handler registered for job type: ${job.jobType}`
379
384
  );
385
+ await backend.failJob(job.id, noHandlerError, "no_handler" /* NoHandler */);
386
+ emit?.("job:failed", {
387
+ jobId: job.id,
388
+ jobType: job.jobType,
389
+ error: noHandlerError,
390
+ willRetry: false
391
+ });
380
392
  return;
381
393
  }
382
394
  const stepData = { ...job.stepData || {} };
@@ -391,9 +403,16 @@ async function processJobWithHandlers(backend, job, jobHandlers) {
391
403
  const forceKillOnTimeout = job.forceKillOnTimeout ?? false;
392
404
  let timeoutId;
393
405
  const controller = new AbortController();
406
+ let setOutputCalled = false;
407
+ let handlerReturnValue;
394
408
  try {
395
409
  if (forceKillOnTimeout && timeoutMs && timeoutMs > 0) {
396
- await runHandlerInWorker(handler, job.payload, timeoutMs, job.jobType);
410
+ handlerReturnValue = await runHandlerInWorker(
411
+ handler,
412
+ job.payload,
413
+ timeoutMs,
414
+ job.jobType
415
+ );
397
416
  } else {
398
417
  let onTimeoutCallback;
399
418
  let timeoutReject;
@@ -445,6 +464,22 @@ async function processJobWithHandlers(backend, job, jobHandlers) {
445
464
  }
446
465
  };
447
466
  const ctx = buildWaitContext(backend, job.id, stepData, baseCtx);
467
+ if (emit) {
468
+ const originalSetProgress = ctx.setProgress;
469
+ ctx.setProgress = async (percent) => {
470
+ await originalSetProgress(percent);
471
+ emit("job:progress", {
472
+ jobId: job.id,
473
+ progress: Math.round(percent)
474
+ });
475
+ };
476
+ }
477
+ const originalSetOutput = ctx.setOutput;
478
+ ctx.setOutput = async (data) => {
479
+ setOutputCalled = true;
480
+ await originalSetOutput(data);
481
+ emit?.("job:output", { jobId: job.id, output: data });
482
+ };
448
483
  if (forceKillOnTimeout && !hasTimeout) {
449
484
  log(
450
485
  `forceKillOnTimeout is set but no timeoutMs for job ${job.id}, running without force kill`
@@ -452,7 +487,7 @@ async function processJobWithHandlers(backend, job, jobHandlers) {
452
487
  }
453
488
  const jobPromise = handler(job.payload, controller.signal, ctx);
454
489
  if (hasTimeout) {
455
- await Promise.race([
490
+ handlerReturnValue = await Promise.race([
456
491
  jobPromise,
457
492
  new Promise((_, reject) => {
458
493
  timeoutReject = reject;
@@ -460,11 +495,13 @@ async function processJobWithHandlers(backend, job, jobHandlers) {
460
495
  })
461
496
  ]);
462
497
  } else {
463
- await jobPromise;
498
+ handlerReturnValue = await jobPromise;
464
499
  }
465
500
  }
466
501
  if (timeoutId) clearTimeout(timeoutId);
467
- await backend.completeJob(job.id);
502
+ const completionOutput = setOutputCalled || handlerReturnValue === void 0 ? void 0 : handlerReturnValue;
503
+ await backend.completeJob(job.id, completionOutput);
504
+ emit?.("job:completed", { jobId: job.id, jobType: job.jobType });
468
505
  } catch (error) {
469
506
  if (timeoutId) clearTimeout(timeoutId);
470
507
  if (error instanceof WaitSignal) {
@@ -476,6 +513,7 @@ async function processJobWithHandlers(backend, job, jobHandlers) {
476
513
  waitTokenId: error.tokenId,
477
514
  stepData: error.stepData
478
515
  });
516
+ emit?.("job:waiting", { jobId: job.id, jobType: job.jobType });
479
517
  return;
480
518
  }
481
519
  console.error(`Error processing job ${job.id}:`, error);
@@ -483,22 +521,33 @@ async function processJobWithHandlers(backend, job, jobHandlers) {
483
521
  if (error && typeof error === "object" && "failureReason" in error && error.failureReason === "timeout" /* Timeout */) {
484
522
  failureReason = "timeout" /* Timeout */;
485
523
  }
486
- await backend.failJob(
487
- job.id,
488
- error instanceof Error ? error : new Error(String(error)),
489
- failureReason
490
- );
524
+ const failError = error instanceof Error ? error : new Error(String(error));
525
+ await backend.failJob(job.id, failError, failureReason);
526
+ emit?.("job:failed", {
527
+ jobId: job.id,
528
+ jobType: job.jobType,
529
+ error: failError,
530
+ willRetry: job.attempts + 1 < job.maxAttempts
531
+ });
491
532
  }
492
533
  }
493
- async function processBatchWithHandlers(backend, workerId, batchSize, jobType, jobHandlers, concurrency, onError) {
534
+ async function processBatchWithHandlers(backend, workerId, batchSize, jobType, jobHandlers, concurrency, groupConcurrency, onError, emit) {
494
535
  const jobs = await backend.getNextBatch(
495
536
  workerId,
496
537
  batchSize,
497
- jobType
538
+ jobType,
539
+ groupConcurrency
498
540
  );
541
+ if (emit) {
542
+ for (const job of jobs) {
543
+ emit("job:processing", { jobId: job.id, jobType: job.jobType });
544
+ }
545
+ }
499
546
  if (!concurrency || concurrency >= jobs.length) {
500
547
  await Promise.all(
501
- jobs.map((job) => processJobWithHandlers(backend, job, jobHandlers))
548
+ jobs.map(
549
+ (job) => processJobWithHandlers(backend, job, jobHandlers, emit)
550
+ )
502
551
  );
503
552
  return jobs.length;
504
553
  }
@@ -511,7 +560,7 @@ async function processBatchWithHandlers(backend, workerId, batchSize, jobType, j
511
560
  while (running < concurrency && idx < jobs.length) {
512
561
  const job = jobs[idx++];
513
562
  running++;
514
- processJobWithHandlers(backend, job, jobHandlers).then(() => {
563
+ processJobWithHandlers(backend, job, jobHandlers, emit).then(() => {
515
564
  running--;
516
565
  finished++;
517
566
  next();
@@ -528,15 +577,21 @@ async function processBatchWithHandlers(backend, workerId, batchSize, jobType, j
528
577
  next();
529
578
  });
530
579
  }
531
- var createProcessor = (backend, handlers, options = {}, onBeforeBatch) => {
580
+ var createProcessor = (backend, handlers, options = {}, onBeforeBatch, emit) => {
532
581
  const {
533
582
  workerId = `worker-${Math.random().toString(36).substring(2, 9)}`,
534
583
  batchSize = 10,
535
584
  pollInterval = 5e3,
536
585
  onError = (error) => console.error("Job processor error:", error),
537
586
  jobType,
538
- concurrency = 3
587
+ concurrency = 3,
588
+ groupConcurrency
539
589
  } = options;
590
+ if (groupConcurrency !== void 0 && (!Number.isInteger(groupConcurrency) || groupConcurrency <= 0)) {
591
+ throw new Error(
592
+ 'Processor option "groupConcurrency" must be a positive integer when provided.'
593
+ );
594
+ }
540
595
  let running = false;
541
596
  let intervalId = null;
542
597
  let currentBatchPromise = null;
@@ -548,11 +603,11 @@ var createProcessor = (backend, handlers, options = {}, onBeforeBatch) => {
548
603
  await onBeforeBatch();
549
604
  } catch (hookError) {
550
605
  log(`onBeforeBatch hook error: ${hookError}`);
606
+ const err = hookError instanceof Error ? hookError : new Error(String(hookError));
551
607
  if (onError) {
552
- onError(
553
- hookError instanceof Error ? hookError : new Error(String(hookError))
554
- );
608
+ onError(err);
555
609
  }
610
+ emit?.("error", err);
556
611
  }
557
612
  }
558
613
  log(
@@ -566,11 +621,15 @@ var createProcessor = (backend, handlers, options = {}, onBeforeBatch) => {
566
621
  jobType,
567
622
  handlers,
568
623
  concurrency,
569
- onError
624
+ groupConcurrency,
625
+ onError,
626
+ emit
570
627
  );
571
628
  return processed;
572
629
  } catch (error) {
573
- onError(error instanceof Error ? error : new Error(String(error)));
630
+ const err = error instanceof Error ? error : new Error(String(error));
631
+ onError(err);
632
+ emit?.("error", err);
574
633
  }
575
634
  return 0;
576
635
  };
@@ -649,6 +708,138 @@ var createProcessor = (backend, handlers, options = {}, onBeforeBatch) => {
649
708
  isRunning: () => running
650
709
  };
651
710
  };
711
+
712
+ // src/supervisor.ts
713
+ var createSupervisor = (backend, options = {}, emit) => {
714
+ const {
715
+ intervalMs = 6e4,
716
+ stuckJobsTimeoutMinutes = 10,
717
+ cleanupJobsDaysToKeep = 30,
718
+ cleanupEventsDaysToKeep = 30,
719
+ cleanupBatchSize = 1e3,
720
+ reclaimStuckJobs = true,
721
+ expireTimedOutTokens = true,
722
+ onError = (error) => console.error("Supervisor maintenance error:", error),
723
+ verbose = false
724
+ } = options;
725
+ let running = false;
726
+ let timeoutId = null;
727
+ let currentRunPromise = null;
728
+ setLogContext(verbose);
729
+ const runOnce = async () => {
730
+ setLogContext(verbose);
731
+ const result = {
732
+ reclaimedJobs: 0,
733
+ cleanedUpJobs: 0,
734
+ cleanedUpEvents: 0,
735
+ expiredTokens: 0
736
+ };
737
+ if (reclaimStuckJobs) {
738
+ try {
739
+ result.reclaimedJobs = await backend.reclaimStuckJobs(
740
+ stuckJobsTimeoutMinutes
741
+ );
742
+ if (result.reclaimedJobs > 0) {
743
+ log(`Supervisor: reclaimed ${result.reclaimedJobs} stuck jobs`);
744
+ }
745
+ } catch (e) {
746
+ const err = e instanceof Error ? e : new Error(String(e));
747
+ onError(err);
748
+ emit?.("error", err);
749
+ }
750
+ }
751
+ if (cleanupJobsDaysToKeep > 0) {
752
+ try {
753
+ result.cleanedUpJobs = await backend.cleanupOldJobs(
754
+ cleanupJobsDaysToKeep,
755
+ cleanupBatchSize
756
+ );
757
+ if (result.cleanedUpJobs > 0) {
758
+ log(`Supervisor: cleaned up ${result.cleanedUpJobs} old jobs`);
759
+ }
760
+ } catch (e) {
761
+ const err = e instanceof Error ? e : new Error(String(e));
762
+ onError(err);
763
+ emit?.("error", err);
764
+ }
765
+ }
766
+ if (cleanupEventsDaysToKeep > 0) {
767
+ try {
768
+ result.cleanedUpEvents = await backend.cleanupOldJobEvents(
769
+ cleanupEventsDaysToKeep,
770
+ cleanupBatchSize
771
+ );
772
+ if (result.cleanedUpEvents > 0) {
773
+ log(
774
+ `Supervisor: cleaned up ${result.cleanedUpEvents} old job events`
775
+ );
776
+ }
777
+ } catch (e) {
778
+ const err = e instanceof Error ? e : new Error(String(e));
779
+ onError(err);
780
+ emit?.("error", err);
781
+ }
782
+ }
783
+ if (expireTimedOutTokens) {
784
+ try {
785
+ result.expiredTokens = await backend.expireTimedOutWaitpoints();
786
+ if (result.expiredTokens > 0) {
787
+ log(`Supervisor: expired ${result.expiredTokens} timed-out tokens`);
788
+ }
789
+ } catch (e) {
790
+ const err = e instanceof Error ? e : new Error(String(e));
791
+ onError(err);
792
+ emit?.("error", err);
793
+ }
794
+ }
795
+ return result;
796
+ };
797
+ return {
798
+ start: async () => {
799
+ return runOnce();
800
+ },
801
+ startInBackground: () => {
802
+ if (running) return;
803
+ log("Supervisor: starting background maintenance loop");
804
+ running = true;
805
+ const loop = async () => {
806
+ if (!running) return;
807
+ currentRunPromise = runOnce();
808
+ await currentRunPromise;
809
+ currentRunPromise = null;
810
+ if (running) {
811
+ timeoutId = setTimeout(loop, intervalMs);
812
+ }
813
+ };
814
+ loop();
815
+ },
816
+ stop: () => {
817
+ running = false;
818
+ if (timeoutId !== null) {
819
+ clearTimeout(timeoutId);
820
+ timeoutId = null;
821
+ }
822
+ log("Supervisor: stopped");
823
+ },
824
+ stopAndDrain: async (timeoutMs = 3e4) => {
825
+ running = false;
826
+ if (timeoutId !== null) {
827
+ clearTimeout(timeoutId);
828
+ timeoutId = null;
829
+ }
830
+ if (currentRunPromise) {
831
+ log("Supervisor: draining current maintenance run\u2026");
832
+ await Promise.race([
833
+ currentRunPromise,
834
+ new Promise((resolve) => setTimeout(resolve, timeoutMs))
835
+ ]);
836
+ currentRunPromise = null;
837
+ }
838
+ log("Supervisor: drained and stopped");
839
+ },
840
+ isRunning: () => running
841
+ };
842
+ };
652
843
  function loadPemOrFile(value) {
653
844
  if (!value) return void 0;
654
845
  if (value.startsWith("file://")) {
@@ -800,6 +991,14 @@ var PostgresBackend = class {
800
991
  }
801
992
  }
802
993
  // ── Job CRUD ──────────────────────────────────────────────────────────
994
+ /**
995
+ * Add a job and return its numeric ID.
996
+ *
997
+ * @param job - Job configuration.
998
+ * @param options - Optional. Pass `{ db }` to run the INSERT on an external
999
+ * client (e.g., inside a transaction) so the job is part of the caller's
1000
+ * transaction. The event INSERT also uses the same client.
1001
+ */
803
1002
  async addJob({
804
1003
  jobType,
805
1004
  payload,
@@ -809,17 +1008,22 @@ var PostgresBackend = class {
809
1008
  timeoutMs = void 0,
810
1009
  forceKillOnTimeout = false,
811
1010
  tags = void 0,
812
- idempotencyKey = void 0
813
- }) {
814
- const client = await this.pool.connect();
1011
+ idempotencyKey = void 0,
1012
+ retryDelay = void 0,
1013
+ retryBackoff = void 0,
1014
+ retryDelayMax = void 0,
1015
+ group = void 0
1016
+ }, options) {
1017
+ const externalClient = options?.db;
1018
+ const client = externalClient ?? await this.pool.connect();
815
1019
  try {
816
1020
  let result;
817
1021
  const onConflict = idempotencyKey ? `ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING` : "";
818
1022
  if (runAt) {
819
1023
  result = await client.query(
820
1024
  `INSERT INTO job_queue
821
- (job_type, payload, max_attempts, priority, run_at, timeout_ms, force_kill_on_timeout, tags, idempotency_key)
822
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
1025
+ (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)
1026
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
823
1027
  ${onConflict}
824
1028
  RETURNING id`,
825
1029
  [
@@ -831,14 +1035,19 @@ var PostgresBackend = class {
831
1035
  timeoutMs ?? null,
832
1036
  forceKillOnTimeout ?? false,
833
1037
  tags ?? null,
834
- idempotencyKey ?? null
1038
+ idempotencyKey ?? null,
1039
+ retryDelay ?? null,
1040
+ retryBackoff ?? null,
1041
+ retryDelayMax ?? null,
1042
+ group?.id ?? null,
1043
+ group?.tier ?? null
835
1044
  ]
836
1045
  );
837
1046
  } else {
838
1047
  result = await client.query(
839
1048
  `INSERT INTO job_queue
840
- (job_type, payload, max_attempts, priority, timeout_ms, force_kill_on_timeout, tags, idempotency_key)
841
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
1049
+ (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)
1050
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
842
1051
  ${onConflict}
843
1052
  RETURNING id`,
844
1053
  [
@@ -849,7 +1058,12 @@ var PostgresBackend = class {
849
1058
  timeoutMs ?? null,
850
1059
  forceKillOnTimeout ?? false,
851
1060
  tags ?? null,
852
- idempotencyKey ?? null
1061
+ idempotencyKey ?? null,
1062
+ retryDelay ?? null,
1063
+ retryBackoff ?? null,
1064
+ retryDelayMax ?? null,
1065
+ group?.id ?? null,
1066
+ group?.tier ?? null
853
1067
  ]
854
1068
  );
855
1069
  }
@@ -872,25 +1086,191 @@ var PostgresBackend = class {
872
1086
  log(
873
1087
  `Added job ${jobId}: payload ${JSON.stringify(payload)}, ${runAt ? `runAt ${runAt.toISOString()}, ` : ""}priority ${priority}, maxAttempts ${maxAttempts}, jobType ${jobType}, tags ${JSON.stringify(tags)}${idempotencyKey ? `, idempotencyKey "${idempotencyKey}"` : ""}`
874
1088
  );
875
- await this.recordJobEvent(jobId, "added" /* Added */, {
876
- jobType,
877
- payload,
878
- tags,
879
- idempotencyKey
880
- });
1089
+ if (externalClient) {
1090
+ try {
1091
+ await client.query(
1092
+ `INSERT INTO job_events (job_id, event_type, metadata) VALUES ($1, $2, $3)`,
1093
+ [
1094
+ jobId,
1095
+ "added" /* Added */,
1096
+ JSON.stringify({ jobType, payload, tags, idempotencyKey })
1097
+ ]
1098
+ );
1099
+ } catch (error) {
1100
+ log(`Error recording job event for job ${jobId}: ${error}`);
1101
+ }
1102
+ } else {
1103
+ await this.recordJobEvent(jobId, "added" /* Added */, {
1104
+ jobType,
1105
+ payload,
1106
+ tags,
1107
+ idempotencyKey
1108
+ });
1109
+ }
881
1110
  return jobId;
882
1111
  } catch (error) {
883
1112
  log(`Error adding job: ${error}`);
884
1113
  throw error;
885
1114
  } finally {
886
- client.release();
1115
+ if (!externalClient) client.release();
1116
+ }
1117
+ }
1118
+ /**
1119
+ * Insert multiple jobs in a single database round-trip.
1120
+ *
1121
+ * Uses a multi-row INSERT with ON CONFLICT handling for idempotency keys.
1122
+ * Returns IDs in the same order as the input array.
1123
+ */
1124
+ async addJobs(jobs, options) {
1125
+ if (jobs.length === 0) return [];
1126
+ const externalClient = options?.db;
1127
+ const client = externalClient ?? await this.pool.connect();
1128
+ try {
1129
+ const COLS_PER_JOB = 14;
1130
+ const valueClauses = [];
1131
+ const params = [];
1132
+ const hasAnyIdempotencyKey = jobs.some((j) => j.idempotencyKey);
1133
+ for (let i = 0; i < jobs.length; i++) {
1134
+ const {
1135
+ jobType,
1136
+ payload,
1137
+ maxAttempts = 3,
1138
+ priority = 0,
1139
+ runAt = null,
1140
+ timeoutMs = void 0,
1141
+ forceKillOnTimeout = false,
1142
+ tags = void 0,
1143
+ idempotencyKey = void 0,
1144
+ retryDelay = void 0,
1145
+ retryBackoff = void 0,
1146
+ retryDelayMax = void 0,
1147
+ group = void 0
1148
+ } = jobs[i];
1149
+ const base = i * COLS_PER_JOB;
1150
+ valueClauses.push(
1151
+ `($${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})`
1152
+ );
1153
+ params.push(
1154
+ jobType,
1155
+ payload,
1156
+ maxAttempts,
1157
+ priority,
1158
+ runAt,
1159
+ timeoutMs ?? null,
1160
+ forceKillOnTimeout ?? false,
1161
+ tags ?? null,
1162
+ idempotencyKey ?? null,
1163
+ retryDelay ?? null,
1164
+ retryBackoff ?? null,
1165
+ retryDelayMax ?? null,
1166
+ group?.id ?? null,
1167
+ group?.tier ?? null
1168
+ );
1169
+ }
1170
+ const onConflict = hasAnyIdempotencyKey ? `ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING` : "";
1171
+ const result = await client.query(
1172
+ `INSERT INTO job_queue
1173
+ (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)
1174
+ VALUES ${valueClauses.join(", ")}
1175
+ ${onConflict}
1176
+ RETURNING id, idempotency_key`,
1177
+ params
1178
+ );
1179
+ const returnedKeyToId = /* @__PURE__ */ new Map();
1180
+ const returnedNullKeyIds = [];
1181
+ for (const row of result.rows) {
1182
+ if (row.idempotency_key != null) {
1183
+ returnedKeyToId.set(row.idempotency_key, row.id);
1184
+ } else {
1185
+ returnedNullKeyIds.push(row.id);
1186
+ }
1187
+ }
1188
+ const missingKeys = [];
1189
+ for (const job of jobs) {
1190
+ if (job.idempotencyKey && !returnedKeyToId.has(job.idempotencyKey)) {
1191
+ missingKeys.push(job.idempotencyKey);
1192
+ }
1193
+ }
1194
+ if (missingKeys.length > 0) {
1195
+ const existing = await client.query(
1196
+ `SELECT id, idempotency_key FROM job_queue WHERE idempotency_key = ANY($1)`,
1197
+ [missingKeys]
1198
+ );
1199
+ for (const row of existing.rows) {
1200
+ returnedKeyToId.set(row.idempotency_key, row.id);
1201
+ }
1202
+ }
1203
+ let nullKeyIdx = 0;
1204
+ const ids = [];
1205
+ for (const job of jobs) {
1206
+ if (job.idempotencyKey) {
1207
+ const id = returnedKeyToId.get(job.idempotencyKey);
1208
+ if (id === void 0) {
1209
+ throw new Error(
1210
+ `Failed to resolve job ID for idempotency key "${job.idempotencyKey}"`
1211
+ );
1212
+ }
1213
+ ids.push(id);
1214
+ } else {
1215
+ ids.push(returnedNullKeyIds[nullKeyIdx++]);
1216
+ }
1217
+ }
1218
+ log(`Batch-inserted ${jobs.length} jobs, IDs: [${ids.join(", ")}]`);
1219
+ const newJobEvents = [];
1220
+ for (let i = 0; i < jobs.length; i++) {
1221
+ const job = jobs[i];
1222
+ const wasInserted = !job.idempotencyKey || !missingKeys.includes(job.idempotencyKey);
1223
+ if (wasInserted) {
1224
+ newJobEvents.push({
1225
+ jobId: ids[i],
1226
+ eventType: "added" /* Added */,
1227
+ metadata: {
1228
+ jobType: job.jobType,
1229
+ payload: job.payload,
1230
+ tags: job.tags,
1231
+ idempotencyKey: job.idempotencyKey
1232
+ }
1233
+ });
1234
+ }
1235
+ }
1236
+ if (newJobEvents.length > 0) {
1237
+ if (externalClient) {
1238
+ const evtValues = [];
1239
+ const evtParams = [];
1240
+ let evtIdx = 1;
1241
+ for (const evt of newJobEvents) {
1242
+ evtValues.push(`($${evtIdx++}, $${evtIdx++}, $${evtIdx++})`);
1243
+ evtParams.push(
1244
+ evt.jobId,
1245
+ evt.eventType,
1246
+ evt.metadata ? JSON.stringify(evt.metadata) : null
1247
+ );
1248
+ }
1249
+ try {
1250
+ await client.query(
1251
+ `INSERT INTO job_events (job_id, event_type, metadata) VALUES ${evtValues.join(", ")}`,
1252
+ evtParams
1253
+ );
1254
+ } catch (error) {
1255
+ log(`Error recording batch job events: ${error}`);
1256
+ }
1257
+ } else {
1258
+ await this.recordJobEventsBatch(newJobEvents);
1259
+ }
1260
+ }
1261
+ return ids;
1262
+ } catch (error) {
1263
+ log(`Error batch-inserting jobs: ${error}`);
1264
+ throw error;
1265
+ } finally {
1266
+ if (!externalClient) client.release();
887
1267
  }
888
1268
  }
889
1269
  async getJob(id) {
890
1270
  const client = await this.pool.connect();
891
1271
  try {
892
1272
  const result = await client.query(
893
- `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`,
1273
+ `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`,
894
1274
  [id]
895
1275
  );
896
1276
  if (result.rows.length === 0) {
@@ -917,7 +1297,7 @@ var PostgresBackend = class {
917
1297
  const client = await this.pool.connect();
918
1298
  try {
919
1299
  const result = await client.query(
920
- `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`,
1300
+ `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`,
921
1301
  [status, limit, offset]
922
1302
  );
923
1303
  log(`Found ${result.rows.length} jobs by status ${status}`);
@@ -939,7 +1319,7 @@ var PostgresBackend = class {
939
1319
  const client = await this.pool.connect();
940
1320
  try {
941
1321
  const result = await client.query(
942
- `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`,
1322
+ `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`,
943
1323
  [limit, offset]
944
1324
  );
945
1325
  log(`Found ${result.rows.length} jobs (all)`);
@@ -959,7 +1339,7 @@ var PostgresBackend = class {
959
1339
  async getJobs(filters, limit = 100, offset = 0) {
960
1340
  const client = await this.pool.connect();
961
1341
  try {
962
- 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`;
1342
+ 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`;
963
1343
  const params = [];
964
1344
  const where = [];
965
1345
  let paramIdx = 1;
@@ -1060,7 +1440,7 @@ var PostgresBackend = class {
1060
1440
  async getJobsByTags(tags, mode = "all", limit = 100, offset = 0) {
1061
1441
  const client = await this.pool.connect();
1062
1442
  try {
1063
- 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
1443
+ 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
1064
1444
  FROM job_queue`;
1065
1445
  let params = [];
1066
1446
  switch (mode) {
@@ -1107,7 +1487,7 @@ var PostgresBackend = class {
1107
1487
  }
1108
1488
  }
1109
1489
  // ── Processing lifecycle ──────────────────────────────────────────────
1110
- async getNextBatch(workerId, batchSize = 10, jobType) {
1490
+ async getNextBatch(workerId, batchSize = 10, jobType, groupConcurrency) {
1111
1491
  const client = await this.pool.connect();
1112
1492
  try {
1113
1493
  await client.query("BEGIN");
@@ -1115,49 +1495,120 @@ var PostgresBackend = class {
1115
1495
  const params = [workerId, batchSize];
1116
1496
  if (jobType) {
1117
1497
  if (Array.isArray(jobType)) {
1118
- jobTypeFilter = ` AND job_type = ANY($3)`;
1498
+ jobTypeFilter = ` AND candidate.job_type = ANY($3)`;
1119
1499
  params.push(jobType);
1120
1500
  } else {
1121
- jobTypeFilter = ` AND job_type = $3`;
1501
+ jobTypeFilter = ` AND candidate.job_type = $3`;
1122
1502
  params.push(jobType);
1123
1503
  }
1124
1504
  }
1125
- const result = await client.query(
1126
- `
1127
- UPDATE job_queue
1128
- SET status = 'processing',
1129
- locked_at = NOW(),
1130
- locked_by = $1,
1131
- attempts = CASE WHEN status = 'waiting' THEN attempts ELSE attempts + 1 END,
1132
- updated_at = NOW(),
1133
- pending_reason = NULL,
1134
- started_at = COALESCE(started_at, NOW()),
1135
- last_retried_at = CASE WHEN status != 'waiting' AND attempts > 0 THEN NOW() ELSE last_retried_at END,
1136
- wait_until = NULL
1137
- WHERE id IN (
1138
- SELECT id FROM job_queue
1139
- WHERE (
1140
- (
1141
- (status = 'pending' OR (status = 'failed' AND next_attempt_at <= NOW()))
1142
- AND (attempts < max_attempts)
1143
- AND run_at <= NOW()
1505
+ let result;
1506
+ if (groupConcurrency === void 0) {
1507
+ result = await client.query(
1508
+ `
1509
+ UPDATE job_queue
1510
+ SET status = 'processing',
1511
+ locked_at = NOW(),
1512
+ locked_by = $1,
1513
+ attempts = CASE WHEN status = 'waiting' THEN attempts ELSE attempts + 1 END,
1514
+ updated_at = NOW(),
1515
+ pending_reason = NULL,
1516
+ started_at = COALESCE(started_at, NOW()),
1517
+ last_retried_at = CASE WHEN status != 'waiting' AND attempts > 0 THEN NOW() ELSE last_retried_at END,
1518
+ wait_until = NULL
1519
+ WHERE id IN (
1520
+ SELECT id FROM job_queue candidate
1521
+ WHERE (
1522
+ (
1523
+ (candidate.status = 'pending' OR (candidate.status = 'failed' AND candidate.next_attempt_at <= NOW()))
1524
+ AND (candidate.attempts < candidate.max_attempts)
1525
+ AND candidate.run_at <= NOW()
1526
+ )
1527
+ OR (
1528
+ candidate.status = 'waiting'
1529
+ AND candidate.wait_until IS NOT NULL
1530
+ AND candidate.wait_until <= NOW()
1531
+ AND candidate.wait_token_id IS NULL
1532
+ )
1144
1533
  )
1145
- OR (
1146
- status = 'waiting'
1147
- AND wait_until IS NOT NULL
1148
- AND wait_until <= NOW()
1149
- AND wait_token_id IS NULL
1534
+ ${jobTypeFilter}
1535
+ ORDER BY candidate.priority DESC, candidate.created_at ASC
1536
+ LIMIT $2
1537
+ FOR UPDATE SKIP LOCKED
1538
+ )
1539
+ 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
1540
+ `,
1541
+ params
1542
+ );
1543
+ } else {
1544
+ const constrainedParams = [...params, groupConcurrency];
1545
+ const groupConcurrencyParamIndex = constrainedParams.length;
1546
+ result = await client.query(
1547
+ `
1548
+ WITH eligible AS (
1549
+ SELECT candidate.id, candidate.group_id, candidate.priority, candidate.created_at
1550
+ FROM job_queue candidate
1551
+ WHERE (
1552
+ (
1553
+ (candidate.status = 'pending' OR (candidate.status = 'failed' AND candidate.next_attempt_at <= NOW()))
1554
+ AND (candidate.attempts < candidate.max_attempts)
1555
+ AND candidate.run_at <= NOW()
1556
+ )
1557
+ OR (
1558
+ candidate.status = 'waiting'
1559
+ AND candidate.wait_until IS NOT NULL
1560
+ AND candidate.wait_until <= NOW()
1561
+ AND candidate.wait_token_id IS NULL
1562
+ )
1150
1563
  )
1564
+ ${jobTypeFilter}
1565
+ FOR UPDATE SKIP LOCKED
1566
+ ),
1567
+ ranked AS (
1568
+ SELECT
1569
+ eligible.id,
1570
+ eligible.group_id,
1571
+ eligible.priority,
1572
+ eligible.created_at,
1573
+ ROW_NUMBER() OVER (
1574
+ PARTITION BY eligible.group_id
1575
+ ORDER BY eligible.priority DESC, eligible.created_at ASC
1576
+ ) AS group_rank,
1577
+ COALESCE((
1578
+ SELECT COUNT(*)
1579
+ FROM job_queue processing_jobs
1580
+ WHERE processing_jobs.status = 'processing'
1581
+ AND processing_jobs.group_id = eligible.group_id
1582
+ ), 0) AS active_group_count
1583
+ FROM eligible
1584
+ ),
1585
+ selected AS (
1586
+ SELECT ranked.id
1587
+ FROM ranked
1588
+ WHERE ranked.group_id IS NULL
1589
+ OR (
1590
+ ranked.active_group_count < $${groupConcurrencyParamIndex}
1591
+ AND ranked.group_rank <= ($${groupConcurrencyParamIndex} - ranked.active_group_count)
1592
+ )
1593
+ ORDER BY ranked.priority DESC, ranked.created_at ASC
1594
+ LIMIT $2
1151
1595
  )
1152
- ${jobTypeFilter}
1153
- ORDER BY priority DESC, created_at ASC
1154
- LIMIT $2
1155
- FOR UPDATE SKIP LOCKED
1156
- )
1157
- 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
1158
- `,
1159
- params
1160
- );
1596
+ UPDATE job_queue
1597
+ SET status = 'processing',
1598
+ locked_at = NOW(),
1599
+ locked_by = $1,
1600
+ attempts = CASE WHEN status = 'waiting' THEN attempts ELSE attempts + 1 END,
1601
+ updated_at = NOW(),
1602
+ pending_reason = NULL,
1603
+ started_at = COALESCE(started_at, NOW()),
1604
+ last_retried_at = CASE WHEN status != 'waiting' AND attempts > 0 THEN NOW() ELSE last_retried_at END,
1605
+ wait_until = NULL
1606
+ WHERE id IN (SELECT id FROM selected)
1607
+ 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
1608
+ `,
1609
+ constrainedParams
1610
+ );
1611
+ }
1161
1612
  log(`Found ${result.rows.length} jobs to process`);
1162
1613
  await client.query("COMMIT");
1163
1614
  if (result.rows.length > 0) {
@@ -1182,17 +1633,19 @@ var PostgresBackend = class {
1182
1633
  client.release();
1183
1634
  }
1184
1635
  }
1185
- async completeJob(jobId) {
1636
+ async completeJob(jobId, output) {
1186
1637
  const client = await this.pool.connect();
1187
1638
  try {
1639
+ const outputJson = output !== void 0 ? JSON.stringify(output) : null;
1188
1640
  const result = await client.query(
1189
1641
  `
1190
1642
  UPDATE job_queue
1191
1643
  SET status = 'completed', updated_at = NOW(), completed_at = NOW(),
1192
- step_data = NULL, wait_until = NULL, wait_token_id = NULL
1644
+ step_data = NULL, wait_until = NULL, wait_token_id = NULL,
1645
+ output = COALESCE($2::jsonb, output)
1193
1646
  WHERE id = $1 AND status = 'processing'
1194
1647
  `,
1195
- [jobId]
1648
+ [jobId, outputJson]
1196
1649
  );
1197
1650
  if (result.rowCount === 0) {
1198
1651
  log(
@@ -1216,9 +1669,17 @@ var PostgresBackend = class {
1216
1669
  UPDATE job_queue
1217
1670
  SET status = 'failed',
1218
1671
  updated_at = NOW(),
1219
- next_attempt_at = CASE
1220
- WHEN attempts < max_attempts THEN NOW() + (POWER(2, attempts) * INTERVAL '1 minute')
1221
- ELSE NULL
1672
+ next_attempt_at = CASE
1673
+ WHEN attempts >= max_attempts THEN NULL
1674
+ WHEN retry_delay IS NULL AND retry_backoff IS NULL AND retry_delay_max IS NULL
1675
+ THEN NOW() + (POWER(2, attempts) * INTERVAL '1 minute')
1676
+ WHEN COALESCE(retry_backoff, true) = true
1677
+ THEN NOW() + (LEAST(
1678
+ COALESCE(retry_delay_max, 2147483647),
1679
+ COALESCE(retry_delay, 60) * POWER(2, attempts)
1680
+ ) * (0.5 + 0.5 * random()) * INTERVAL '1 second')
1681
+ ELSE
1682
+ NOW() + (COALESCE(retry_delay, 60) * INTERVAL '1 second')
1222
1683
  END,
1223
1684
  error_history = COALESCE(error_history, '[]'::jsonb) || $2::jsonb,
1224
1685
  failure_reason = $3,
@@ -1287,6 +1748,21 @@ var PostgresBackend = class {
1287
1748
  client.release();
1288
1749
  }
1289
1750
  }
1751
+ // ── Output ────────────────────────────────────────────────────────────
1752
+ async updateOutput(jobId, output) {
1753
+ const client = await this.pool.connect();
1754
+ try {
1755
+ await client.query(
1756
+ `UPDATE job_queue SET output = $2::jsonb, updated_at = NOW() WHERE id = $1`,
1757
+ [jobId, JSON.stringify(output)]
1758
+ );
1759
+ log(`Updated output for job ${jobId}`);
1760
+ } catch (error) {
1761
+ log(`Error updating output for job ${jobId}: ${error}`);
1762
+ } finally {
1763
+ client.release();
1764
+ }
1765
+ }
1290
1766
  // ── Job management ────────────────────────────────────────────────────
1291
1767
  async retryJob(jobId) {
1292
1768
  const client = await this.pool.connect();
@@ -1456,6 +1932,18 @@ var PostgresBackend = class {
1456
1932
  updateFields.push(`tags = $${paramIdx++}`);
1457
1933
  params.push(updates.tags ?? null);
1458
1934
  }
1935
+ if (updates.retryDelay !== void 0) {
1936
+ updateFields.push(`retry_delay = $${paramIdx++}`);
1937
+ params.push(updates.retryDelay ?? null);
1938
+ }
1939
+ if (updates.retryBackoff !== void 0) {
1940
+ updateFields.push(`retry_backoff = $${paramIdx++}`);
1941
+ params.push(updates.retryBackoff ?? null);
1942
+ }
1943
+ if (updates.retryDelayMax !== void 0) {
1944
+ updateFields.push(`retry_delay_max = $${paramIdx++}`);
1945
+ params.push(updates.retryDelayMax ?? null);
1946
+ }
1459
1947
  if (updateFields.length === 0) {
1460
1948
  log(`No fields to update for job ${jobId}`);
1461
1949
  return;
@@ -1477,6 +1965,12 @@ var PostgresBackend = class {
1477
1965
  if (updates.timeoutMs !== void 0)
1478
1966
  metadata.timeoutMs = updates.timeoutMs;
1479
1967
  if (updates.tags !== void 0) metadata.tags = updates.tags;
1968
+ if (updates.retryDelay !== void 0)
1969
+ metadata.retryDelay = updates.retryDelay;
1970
+ if (updates.retryBackoff !== void 0)
1971
+ metadata.retryBackoff = updates.retryBackoff;
1972
+ if (updates.retryDelayMax !== void 0)
1973
+ metadata.retryDelayMax = updates.retryDelayMax;
1480
1974
  await this.recordJobEvent(jobId, "edited" /* Edited */, metadata);
1481
1975
  log(`Edited job ${jobId}: ${JSON.stringify(metadata)}`);
1482
1976
  } catch (error) {
@@ -1520,6 +2014,18 @@ var PostgresBackend = class {
1520
2014
  updateFields.push(`tags = $${paramIdx++}`);
1521
2015
  params.push(updates.tags ?? null);
1522
2016
  }
2017
+ if (updates.retryDelay !== void 0) {
2018
+ updateFields.push(`retry_delay = $${paramIdx++}`);
2019
+ params.push(updates.retryDelay ?? null);
2020
+ }
2021
+ if (updates.retryBackoff !== void 0) {
2022
+ updateFields.push(`retry_backoff = $${paramIdx++}`);
2023
+ params.push(updates.retryBackoff ?? null);
2024
+ }
2025
+ if (updates.retryDelayMax !== void 0) {
2026
+ updateFields.push(`retry_delay_max = $${paramIdx++}`);
2027
+ params.push(updates.retryDelayMax ?? null);
2028
+ }
1523
2029
  if (updateFields.length === 0) {
1524
2030
  log(`No fields to update for batch edit`);
1525
2031
  return 0;
@@ -1761,8 +2267,8 @@ var PostgresBackend = class {
1761
2267
  `INSERT INTO cron_schedules
1762
2268
  (schedule_name, cron_expression, job_type, payload, max_attempts,
1763
2269
  priority, timeout_ms, force_kill_on_timeout, tags, timezone,
1764
- allow_overlap, next_run_at)
1765
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
2270
+ allow_overlap, next_run_at, retry_delay, retry_backoff, retry_delay_max)
2271
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
1766
2272
  RETURNING id`,
1767
2273
  [
1768
2274
  input.scheduleName,
@@ -1776,7 +2282,10 @@ var PostgresBackend = class {
1776
2282
  input.tags ?? null,
1777
2283
  input.timezone,
1778
2284
  input.allowOverlap,
1779
- input.nextRunAt
2285
+ input.nextRunAt,
2286
+ input.retryDelay,
2287
+ input.retryBackoff,
2288
+ input.retryDelayMax
1780
2289
  ]
1781
2290
  );
1782
2291
  const id = result.rows[0].id;
@@ -1806,7 +2315,9 @@ var PostgresBackend = class {
1806
2315
  timezone, allow_overlap AS "allowOverlap", status,
1807
2316
  last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
1808
2317
  next_run_at AS "nextRunAt",
1809
- created_at AS "createdAt", updated_at AS "updatedAt"
2318
+ created_at AS "createdAt", updated_at AS "updatedAt",
2319
+ retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
2320
+ retry_delay_max AS "retryDelayMax"
1810
2321
  FROM cron_schedules WHERE id = $1`,
1811
2322
  [id]
1812
2323
  );
@@ -1831,7 +2342,9 @@ var PostgresBackend = class {
1831
2342
  timezone, allow_overlap AS "allowOverlap", status,
1832
2343
  last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
1833
2344
  next_run_at AS "nextRunAt",
1834
- created_at AS "createdAt", updated_at AS "updatedAt"
2345
+ created_at AS "createdAt", updated_at AS "updatedAt",
2346
+ retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
2347
+ retry_delay_max AS "retryDelayMax"
1835
2348
  FROM cron_schedules WHERE schedule_name = $1`,
1836
2349
  [name]
1837
2350
  );
@@ -1855,7 +2368,9 @@ var PostgresBackend = class {
1855
2368
  timezone, allow_overlap AS "allowOverlap", status,
1856
2369
  last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
1857
2370
  next_run_at AS "nextRunAt",
1858
- created_at AS "createdAt", updated_at AS "updatedAt"
2371
+ created_at AS "createdAt", updated_at AS "updatedAt",
2372
+ retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
2373
+ retry_delay_max AS "retryDelayMax"
1859
2374
  FROM cron_schedules`;
1860
2375
  const params = [];
1861
2376
  if (status) {
@@ -1960,6 +2475,18 @@ var PostgresBackend = class {
1960
2475
  updateFields.push(`allow_overlap = $${paramIdx++}`);
1961
2476
  params.push(updates.allowOverlap);
1962
2477
  }
2478
+ if (updates.retryDelay !== void 0) {
2479
+ updateFields.push(`retry_delay = $${paramIdx++}`);
2480
+ params.push(updates.retryDelay);
2481
+ }
2482
+ if (updates.retryBackoff !== void 0) {
2483
+ updateFields.push(`retry_backoff = $${paramIdx++}`);
2484
+ params.push(updates.retryBackoff);
2485
+ }
2486
+ if (updates.retryDelayMax !== void 0) {
2487
+ updateFields.push(`retry_delay_max = $${paramIdx++}`);
2488
+ params.push(updates.retryDelayMax);
2489
+ }
1963
2490
  if (nextRunAt !== void 0) {
1964
2491
  updateFields.push(`next_run_at = $${paramIdx++}`);
1965
2492
  params.push(nextRunAt);
@@ -1995,7 +2522,9 @@ var PostgresBackend = class {
1995
2522
  timezone, allow_overlap AS "allowOverlap", status,
1996
2523
  last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
1997
2524
  next_run_at AS "nextRunAt",
1998
- created_at AS "createdAt", updated_at AS "updatedAt"
2525
+ created_at AS "createdAt", updated_at AS "updatedAt",
2526
+ retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
2527
+ retry_delay_max AS "retryDelayMax"
1999
2528
  FROM cron_schedules
2000
2529
  WHERE status = 'active'
2001
2530
  AND next_run_at IS NOT NULL
@@ -2279,6 +2808,11 @@ local forceKillOnTimeout = ARGV[7]
2279
2808
  local tagsJson = ARGV[8] -- "null" or JSON array string
2280
2809
  local idempotencyKey = ARGV[9] -- "null" string if not set
2281
2810
  local nowMs = tonumber(ARGV[10])
2811
+ local retryDelay = ARGV[11] -- "null" or seconds string
2812
+ local retryBackoff = ARGV[12] -- "null" or "true"/"false"
2813
+ local retryDelayMax = ARGV[13] -- "null" or seconds string
2814
+ local groupId = ARGV[14] -- "null" or group ID
2815
+ local groupTier = ARGV[15] -- "null" or group tier
2282
2816
 
2283
2817
  -- Idempotency check
2284
2818
  if idempotencyKey ~= "null" then
@@ -2322,7 +2856,12 @@ redis.call('HMSET', jobKey,
2322
2856
  'idempotencyKey', idempotencyKey,
2323
2857
  'waitUntil', 'null',
2324
2858
  'waitTokenId', 'null',
2325
- 'stepData', 'null'
2859
+ 'stepData', 'null',
2860
+ 'retryDelay', retryDelay,
2861
+ 'retryBackoff', retryBackoff,
2862
+ 'retryDelayMax', retryDelayMax,
2863
+ 'groupId', groupId,
2864
+ 'groupTier', groupTier
2326
2865
  )
2327
2866
 
2328
2867
  -- Status index
@@ -2363,12 +2902,134 @@ end
2363
2902
 
2364
2903
  return id
2365
2904
  `;
2905
+ var ADD_JOBS_SCRIPT = `
2906
+ local prefix = KEYS[1]
2907
+ local jobsJson = ARGV[1]
2908
+ local nowMs = tonumber(ARGV[2])
2909
+
2910
+ local jobs = cjson.decode(jobsJson)
2911
+ local results = {}
2912
+
2913
+ for i, job in ipairs(jobs) do
2914
+ local jobType = job.jobType
2915
+ local payloadJson = job.payload
2916
+ local maxAttempts = tonumber(job.maxAttempts)
2917
+ local priority = tonumber(job.priority)
2918
+ local runAtMs = tostring(job.runAtMs)
2919
+ local timeoutMs = tostring(job.timeoutMs)
2920
+ local forceKillOnTimeout = tostring(job.forceKillOnTimeout)
2921
+ local tagsJson = tostring(job.tags)
2922
+ local idempotencyKey = tostring(job.idempotencyKey)
2923
+ local retryDelay = tostring(job.retryDelay)
2924
+ local retryBackoff = tostring(job.retryBackoff)
2925
+ local retryDelayMax = tostring(job.retryDelayMax)
2926
+ local groupId = tostring(job.groupId)
2927
+ local groupTier = tostring(job.groupTier)
2928
+
2929
+ -- Idempotency check
2930
+ local skip = false
2931
+ if idempotencyKey ~= "null" then
2932
+ local existing = redis.call('GET', prefix .. 'idempotency:' .. idempotencyKey)
2933
+ if existing then
2934
+ results[i] = tonumber(existing)
2935
+ skip = true
2936
+ end
2937
+ end
2938
+
2939
+ if not skip then
2940
+ -- Generate ID
2941
+ local id = redis.call('INCR', prefix .. 'id_seq')
2942
+ local jobKey = prefix .. 'job:' .. id
2943
+ local runAt = runAtMs ~= "0" and tonumber(runAtMs) or nowMs
2944
+
2945
+ -- Store the job hash
2946
+ redis.call('HMSET', jobKey,
2947
+ 'id', id,
2948
+ 'jobType', jobType,
2949
+ 'payload', payloadJson,
2950
+ 'status', 'pending',
2951
+ 'maxAttempts', maxAttempts,
2952
+ 'attempts', 0,
2953
+ 'priority', priority,
2954
+ 'runAt', runAt,
2955
+ 'timeoutMs', timeoutMs,
2956
+ 'forceKillOnTimeout', forceKillOnTimeout,
2957
+ 'createdAt', nowMs,
2958
+ 'updatedAt', nowMs,
2959
+ 'lockedAt', 'null',
2960
+ 'lockedBy', 'null',
2961
+ 'nextAttemptAt', 'null',
2962
+ 'pendingReason', 'null',
2963
+ 'errorHistory', '[]',
2964
+ 'failureReason', 'null',
2965
+ 'completedAt', 'null',
2966
+ 'startedAt', 'null',
2967
+ 'lastRetriedAt', 'null',
2968
+ 'lastFailedAt', 'null',
2969
+ 'lastCancelledAt', 'null',
2970
+ 'tags', tagsJson,
2971
+ 'idempotencyKey', idempotencyKey,
2972
+ 'waitUntil', 'null',
2973
+ 'waitTokenId', 'null',
2974
+ 'stepData', 'null',
2975
+ 'retryDelay', retryDelay,
2976
+ 'retryBackoff', retryBackoff,
2977
+ 'retryDelayMax', retryDelayMax,
2978
+ 'groupId', groupId,
2979
+ 'groupTier', groupTier
2980
+ )
2981
+
2982
+ -- Status index
2983
+ redis.call('SADD', prefix .. 'status:pending', id)
2984
+
2985
+ -- Type index
2986
+ redis.call('SADD', prefix .. 'type:' .. jobType, id)
2987
+
2988
+ -- Tag indexes
2989
+ if tagsJson ~= "null" then
2990
+ local tags = cjson.decode(tagsJson)
2991
+ for _, tag in ipairs(tags) do
2992
+ redis.call('SADD', prefix .. 'tag:' .. tag, id)
2993
+ end
2994
+ for _, tag in ipairs(tags) do
2995
+ redis.call('SADD', prefix .. 'job:' .. id .. ':tags', tag)
2996
+ end
2997
+ end
2998
+
2999
+ -- Idempotency mapping
3000
+ if idempotencyKey ~= "null" then
3001
+ redis.call('SET', prefix .. 'idempotency:' .. idempotencyKey, id)
3002
+ end
3003
+
3004
+ -- All-jobs sorted set
3005
+ redis.call('ZADD', prefix .. 'all', nowMs, id)
3006
+
3007
+ -- Queue or delayed
3008
+ if runAt <= nowMs then
3009
+ local score = priority * ${SCORE_RANGE} + (${SCORE_RANGE} - nowMs)
3010
+ redis.call('ZADD', prefix .. 'queue', score, id)
3011
+ else
3012
+ redis.call('ZADD', prefix .. 'delayed', runAt, id)
3013
+ end
3014
+
3015
+ results[i] = id
3016
+ end
3017
+ end
3018
+
3019
+ return results
3020
+ `;
2366
3021
  var GET_NEXT_BATCH_SCRIPT = `
2367
3022
  local prefix = KEYS[1]
2368
3023
  local workerId = ARGV[1]
2369
3024
  local batchSize = tonumber(ARGV[2])
2370
3025
  local nowMs = tonumber(ARGV[3])
2371
3026
  local jobTypeFilter = ARGV[4] -- "null" or JSON array or single string
3027
+ local groupConcurrencyRaw = ARGV[5] -- "null" or positive integer
3028
+ local groupConcurrency = nil
3029
+ if groupConcurrencyRaw ~= "null" then
3030
+ groupConcurrency = tonumber(groupConcurrencyRaw)
3031
+ end
3032
+ local groupActiveKey = prefix .. 'group:active'
2372
3033
 
2373
3034
  -- 1. Move ready delayed jobs into queue
2374
3035
  local delayed = redis.call('ZRANGEBYSCORE', prefix .. 'delayed', '-inf', nowMs, 'LIMIT', 0, 200)
@@ -2469,36 +3130,53 @@ for i = 1, #candidates, 2 do
2469
3130
  -- Not ready yet: move to delayed
2470
3131
  redis.call('ZADD', prefix .. 'delayed', runAt, jobId)
2471
3132
  else
2472
- -- Claim this job
2473
- local attempts = tonumber(redis.call('HGET', jk, 'attempts'))
2474
- local startedAt = redis.call('HGET', jk, 'startedAt')
2475
- local lastRetriedAt = redis.call('HGET', jk, 'lastRetriedAt')
2476
- if startedAt == 'null' then startedAt = nowMs end
2477
- if attempts > 0 then lastRetriedAt = nowMs end
3133
+ local groupId = redis.call('HGET', jk, 'groupId')
3134
+ local hasGroup = groupId and groupId ~= 'null'
3135
+ local canClaim = true
3136
+ if hasGroup and groupConcurrency then
3137
+ local activeCount = tonumber(redis.call('HGET', groupActiveKey, groupId) or '0')
3138
+ if activeCount >= groupConcurrency then
3139
+ table.insert(putBack, score)
3140
+ table.insert(putBack, jobId)
3141
+ canClaim = false
3142
+ end
3143
+ end
2478
3144
 
2479
- redis.call('HMSET', jk,
2480
- 'status', 'processing',
2481
- 'lockedAt', nowMs,
2482
- 'lockedBy', workerId,
2483
- 'attempts', attempts + 1,
2484
- 'updatedAt', nowMs,
2485
- 'pendingReason', 'null',
2486
- 'startedAt', startedAt,
2487
- 'lastRetriedAt', lastRetriedAt
2488
- )
3145
+ if canClaim then
3146
+ -- Claim this job
3147
+ local attempts = tonumber(redis.call('HGET', jk, 'attempts'))
3148
+ local startedAt = redis.call('HGET', jk, 'startedAt')
3149
+ local lastRetriedAt = redis.call('HGET', jk, 'lastRetriedAt')
3150
+ if startedAt == 'null' then startedAt = nowMs end
3151
+ if attempts > 0 then lastRetriedAt = nowMs end
3152
+
3153
+ redis.call('HMSET', jk,
3154
+ 'status', 'processing',
3155
+ 'lockedAt', nowMs,
3156
+ 'lockedBy', workerId,
3157
+ 'attempts', attempts + 1,
3158
+ 'updatedAt', nowMs,
3159
+ 'pendingReason', 'null',
3160
+ 'startedAt', startedAt,
3161
+ 'lastRetriedAt', lastRetriedAt
3162
+ )
2489
3163
 
2490
- -- Update status sets
2491
- redis.call('SREM', prefix .. 'status:pending', jobId)
2492
- redis.call('SADD', prefix .. 'status:processing', jobId)
3164
+ -- Update status sets
3165
+ redis.call('SREM', prefix .. 'status:pending', jobId)
3166
+ redis.call('SADD', prefix .. 'status:processing', jobId)
3167
+ if hasGroup and groupConcurrency then
3168
+ redis.call('HINCRBY', groupActiveKey, groupId, 1)
3169
+ end
2493
3170
 
2494
- -- Return job data as flat array
2495
- local data = redis.call('HGETALL', jk)
2496
- for _, v in ipairs(data) do
2497
- table.insert(results, v)
3171
+ -- Return job data as flat array
3172
+ local data = redis.call('HGETALL', jk)
3173
+ for _, v in ipairs(data) do
3174
+ table.insert(results, v)
3175
+ end
3176
+ -- Separator
3177
+ table.insert(results, '__JOB_SEP__')
3178
+ jobsClaimed = jobsClaimed + 1
2498
3179
  end
2499
- -- Separator
2500
- table.insert(results, '__JOB_SEP__')
2501
- jobsClaimed = jobsClaimed + 1
2502
3180
  end
2503
3181
  end
2504
3182
  end
@@ -2515,18 +3193,34 @@ var COMPLETE_JOB_SCRIPT = `
2515
3193
  local prefix = KEYS[1]
2516
3194
  local jobId = ARGV[1]
2517
3195
  local nowMs = ARGV[2]
3196
+ local outputJson = ARGV[3]
2518
3197
  local jk = prefix .. 'job:' .. jobId
3198
+ local groupId = redis.call('HGET', jk, 'groupId')
2519
3199
 
2520
- redis.call('HMSET', jk,
3200
+ local fields = {
2521
3201
  'status', 'completed',
2522
3202
  'updatedAt', nowMs,
2523
3203
  'completedAt', nowMs,
2524
3204
  'stepData', 'null',
2525
3205
  'waitUntil', 'null',
2526
3206
  'waitTokenId', 'null'
2527
- )
3207
+ }
3208
+
3209
+ if outputJson ~= '__NONE__' then
3210
+ fields[#fields + 1] = 'output'
3211
+ fields[#fields + 1] = outputJson
3212
+ end
3213
+
3214
+ redis.call('HMSET', jk, unpack(fields))
2528
3215
  redis.call('SREM', prefix .. 'status:processing', jobId)
2529
3216
  redis.call('SADD', prefix .. 'status:completed', jobId)
3217
+ if groupId and groupId ~= 'null' then
3218
+ local activeKey = prefix .. 'group:active'
3219
+ local remaining = redis.call('HINCRBY', activeKey, groupId, -1)
3220
+ if tonumber(remaining) <= 0 then
3221
+ redis.call('HDEL', activeKey, groupId)
3222
+ end
3223
+ end
2530
3224
 
2531
3225
  return 1
2532
3226
  `;
@@ -2537,15 +3231,43 @@ local errorJson = ARGV[2]
2537
3231
  local failureReason = ARGV[3]
2538
3232
  local nowMs = tonumber(ARGV[4])
2539
3233
  local jk = prefix .. 'job:' .. jobId
3234
+ local groupId = redis.call('HGET', jk, 'groupId')
2540
3235
 
2541
3236
  local attempts = tonumber(redis.call('HGET', jk, 'attempts'))
2542
3237
  local maxAttempts = tonumber(redis.call('HGET', jk, 'maxAttempts'))
2543
3238
 
2544
- -- Compute next_attempt_at: 2^attempts minutes from now
3239
+ -- Read per-job retry config (may be "null")
3240
+ local rdRaw = redis.call('HGET', jk, 'retryDelay')
3241
+ local rbRaw = redis.call('HGET', jk, 'retryBackoff')
3242
+ local rmRaw = redis.call('HGET', jk, 'retryDelayMax')
3243
+
2545
3244
  local nextAttemptAt = 'null'
2546
3245
  if attempts < maxAttempts then
2547
- local delayMs = math.pow(2, attempts) * 60000
2548
- nextAttemptAt = nowMs + delayMs
3246
+ local allNull = (rdRaw == 'null' or rdRaw == false)
3247
+ and (rbRaw == 'null' or rbRaw == false)
3248
+ and (rmRaw == 'null' or rmRaw == false)
3249
+ if allNull then
3250
+ -- Legacy formula: 2^attempts minutes
3251
+ local delayMs = math.pow(2, attempts) * 60000
3252
+ nextAttemptAt = nowMs + delayMs
3253
+ else
3254
+ local retryDelaySec = 60
3255
+ if rdRaw and rdRaw ~= 'null' then retryDelaySec = tonumber(rdRaw) end
3256
+ local useBackoff = true
3257
+ if rbRaw and rbRaw ~= 'null' then useBackoff = (rbRaw == 'true') end
3258
+ local maxDelaySec = nil
3259
+ if rmRaw and rmRaw ~= 'null' then maxDelaySec = tonumber(rmRaw) end
3260
+
3261
+ local delaySec
3262
+ if useBackoff then
3263
+ delaySec = retryDelaySec * math.pow(2, attempts)
3264
+ if maxDelaySec then delaySec = math.min(delaySec, maxDelaySec) end
3265
+ delaySec = delaySec * (0.5 + 0.5 * math.random())
3266
+ else
3267
+ delaySec = retryDelaySec
3268
+ end
3269
+ nextAttemptAt = nowMs + math.floor(delaySec * 1000)
3270
+ end
2549
3271
  end
2550
3272
 
2551
3273
  -- Append to error_history
@@ -2567,6 +3289,13 @@ redis.call('HMSET', jk,
2567
3289
  )
2568
3290
  redis.call('SREM', prefix .. 'status:processing', jobId)
2569
3291
  redis.call('SADD', prefix .. 'status:failed', jobId)
3292
+ if groupId and groupId ~= 'null' then
3293
+ local activeKey = prefix .. 'group:active'
3294
+ local remaining = redis.call('HINCRBY', activeKey, groupId, -1)
3295
+ if tonumber(remaining) <= 0 then
3296
+ redis.call('HDEL', activeKey, groupId)
3297
+ end
3298
+ end
2570
3299
 
2571
3300
  -- Schedule retry if applicable
2572
3301
  if nextAttemptAt ~= 'null' then
@@ -2583,6 +3312,7 @@ local jk = prefix .. 'job:' .. jobId
2583
3312
 
2584
3313
  local oldStatus = redis.call('HGET', jk, 'status')
2585
3314
  if oldStatus ~= 'failed' and oldStatus ~= 'processing' then return 0 end
3315
+ local groupId = redis.call('HGET', jk, 'groupId')
2586
3316
 
2587
3317
  redis.call('HMSET', jk,
2588
3318
  'status', 'pending',
@@ -2596,6 +3326,13 @@ redis.call('HMSET', jk,
2596
3326
  -- Remove from old status, add to pending
2597
3327
  redis.call('SREM', prefix .. 'status:' .. oldStatus, jobId)
2598
3328
  redis.call('SADD', prefix .. 'status:pending', jobId)
3329
+ if oldStatus == 'processing' and groupId and groupId ~= 'null' then
3330
+ local activeKey = prefix .. 'group:active'
3331
+ local remaining = redis.call('HINCRBY', activeKey, groupId, -1)
3332
+ if tonumber(remaining) <= 0 then
3333
+ redis.call('HDEL', activeKey, groupId)
3334
+ end
3335
+ end
2599
3336
 
2600
3337
  -- Remove from retry sorted set if present
2601
3338
  redis.call('ZREM', prefix .. 'retry', jobId)
@@ -2682,6 +3419,14 @@ for _, jobId in ipairs(processing) do
2682
3419
  )
2683
3420
  redis.call('SREM', prefix .. 'status:processing', jobId)
2684
3421
  redis.call('SADD', prefix .. 'status:pending', jobId)
3422
+ local groupId = redis.call('HGET', jk, 'groupId')
3423
+ if groupId and groupId ~= 'null' then
3424
+ local activeKey = prefix .. 'group:active'
3425
+ local remaining = redis.call('HINCRBY', activeKey, groupId, -1)
3426
+ if tonumber(remaining) <= 0 then
3427
+ redis.call('HDEL', activeKey, groupId)
3428
+ end
3429
+ end
2685
3430
 
2686
3431
  -- Re-add to queue
2687
3432
  local priority = tonumber(redis.call('HGET', jk, 'priority') or '0')
@@ -2748,6 +3493,7 @@ local jk = prefix .. 'job:' .. jobId
2748
3493
 
2749
3494
  local status = redis.call('HGET', jk, 'status')
2750
3495
  if status ~= 'processing' then return 0 end
3496
+ local groupId = redis.call('HGET', jk, 'groupId')
2751
3497
 
2752
3498
  redis.call('HMSET', jk,
2753
3499
  'status', 'waiting',
@@ -2760,6 +3506,13 @@ redis.call('HMSET', jk,
2760
3506
  )
2761
3507
  redis.call('SREM', prefix .. 'status:processing', jobId)
2762
3508
  redis.call('SADD', prefix .. 'status:waiting', jobId)
3509
+ if groupId and groupId ~= 'null' then
3510
+ local activeKey = prefix .. 'group:active'
3511
+ local remaining = redis.call('HINCRBY', activeKey, groupId, -1)
3512
+ if tonumber(remaining) <= 0 then
3513
+ redis.call('HDEL', activeKey, groupId)
3514
+ end
3515
+ end
2763
3516
 
2764
3517
  -- Add to waiting sorted set if time-based wait
2765
3518
  if waitUntilMs ~= 'null' then
@@ -2957,9 +3710,23 @@ function deserializeJob(h) {
2957
3710
  progress: numOrNull(h.progress),
2958
3711
  waitUntil: dateOrNull(h.waitUntil),
2959
3712
  waitTokenId: nullish(h.waitTokenId),
2960
- stepData: parseStepData(h.stepData)
3713
+ stepData: parseStepData(h.stepData),
3714
+ retryDelay: numOrNull(h.retryDelay),
3715
+ retryBackoff: h.retryBackoff === "true" ? true : h.retryBackoff === "false" ? false : null,
3716
+ retryDelayMax: numOrNull(h.retryDelayMax),
3717
+ groupId: nullish(h.groupId),
3718
+ groupTier: nullish(h.groupTier),
3719
+ output: parseJsonField(h.output)
2961
3720
  };
2962
3721
  }
3722
+ function parseJsonField(raw) {
3723
+ if (!raw || raw === "null") return null;
3724
+ try {
3725
+ return JSON.parse(raw);
3726
+ } catch {
3727
+ return null;
3728
+ }
3729
+ }
2963
3730
  function parseStepData(raw) {
2964
3731
  if (!raw || raw === "null") return void 0;
2965
3732
  try {
@@ -2969,7 +3736,23 @@ function parseStepData(raw) {
2969
3736
  }
2970
3737
  }
2971
3738
  var RedisBackend = class {
2972
- constructor(redisConfig) {
3739
+ /**
3740
+ * Create a RedisBackend.
3741
+ *
3742
+ * @param configOrClient - Either `redisConfig` from the config file (the
3743
+ * library creates a new ioredis client) or an existing ioredis client
3744
+ * instance (bring your own).
3745
+ * @param keyPrefix - Key prefix, only used when `configOrClient` is an
3746
+ * external client. Ignored when `redisConfig` is passed (uses
3747
+ * `redisConfig.keyPrefix` instead). Default: `'dq:'`.
3748
+ */
3749
+ constructor(configOrClient, keyPrefix) {
3750
+ if (configOrClient && typeof configOrClient.eval === "function") {
3751
+ this.client = configOrClient;
3752
+ this.prefix = keyPrefix ?? "dq:";
3753
+ return;
3754
+ }
3755
+ const redisConfig = configOrClient;
2973
3756
  let IORedis;
2974
3757
  try {
2975
3758
  const _require = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)));
@@ -3042,8 +3825,17 @@ var RedisBackend = class {
3042
3825
  timeoutMs = void 0,
3043
3826
  forceKillOnTimeout = false,
3044
3827
  tags = void 0,
3045
- idempotencyKey = void 0
3046
- }) {
3828
+ idempotencyKey = void 0,
3829
+ retryDelay = void 0,
3830
+ retryBackoff = void 0,
3831
+ retryDelayMax = void 0,
3832
+ group = void 0
3833
+ }, options) {
3834
+ if (options?.db) {
3835
+ throw new Error(
3836
+ "The db option is not supported with the Redis backend. Transactional job creation is only available with PostgreSQL."
3837
+ );
3838
+ }
3047
3839
  const now = this.nowMs();
3048
3840
  const runAtMs = runAt ? runAt.getTime() : 0;
3049
3841
  const result = await this.client.eval(
@@ -3059,7 +3851,12 @@ var RedisBackend = class {
3059
3851
  forceKillOnTimeout ? "true" : "false",
3060
3852
  tags ? JSON.stringify(tags) : "null",
3061
3853
  idempotencyKey ?? "null",
3062
- now
3854
+ now,
3855
+ retryDelay !== void 0 ? retryDelay.toString() : "null",
3856
+ retryBackoff !== void 0 ? retryBackoff.toString() : "null",
3857
+ retryDelayMax !== void 0 ? retryDelayMax.toString() : "null",
3858
+ group?.id ?? "null",
3859
+ group?.tier ?? "null"
3063
3860
  );
3064
3861
  const jobId = Number(result);
3065
3862
  log(
@@ -3073,6 +3870,60 @@ var RedisBackend = class {
3073
3870
  });
3074
3871
  return jobId;
3075
3872
  }
3873
+ /**
3874
+ * Insert multiple jobs atomically via a single Lua script.
3875
+ * Returns IDs in the same order as the input array.
3876
+ */
3877
+ async addJobs(jobs, options) {
3878
+ if (jobs.length === 0) return [];
3879
+ if (options?.db) {
3880
+ throw new Error(
3881
+ "The db option is not supported with the Redis backend. Transactional job creation is only available with PostgreSQL."
3882
+ );
3883
+ }
3884
+ const now = this.nowMs();
3885
+ const jobsPayload = jobs.map((job) => ({
3886
+ jobType: job.jobType,
3887
+ payload: JSON.stringify(job.payload),
3888
+ maxAttempts: job.maxAttempts ?? 3,
3889
+ priority: job.priority ?? 0,
3890
+ runAtMs: job.runAt ? job.runAt.getTime() : 0,
3891
+ timeoutMs: job.timeoutMs !== void 0 ? job.timeoutMs.toString() : "null",
3892
+ forceKillOnTimeout: job.forceKillOnTimeout ? "true" : "false",
3893
+ tags: job.tags ? JSON.stringify(job.tags) : "null",
3894
+ idempotencyKey: job.idempotencyKey ?? "null",
3895
+ retryDelay: job.retryDelay !== void 0 ? job.retryDelay.toString() : "null",
3896
+ retryBackoff: job.retryBackoff !== void 0 ? job.retryBackoff.toString() : "null",
3897
+ retryDelayMax: job.retryDelayMax !== void 0 ? job.retryDelayMax.toString() : "null",
3898
+ groupId: job.group?.id ?? "null",
3899
+ groupTier: job.group?.tier ?? "null"
3900
+ }));
3901
+ const result = await this.client.eval(
3902
+ ADD_JOBS_SCRIPT,
3903
+ 1,
3904
+ this.prefix,
3905
+ JSON.stringify(jobsPayload),
3906
+ now
3907
+ );
3908
+ const ids = result.map(Number);
3909
+ log(`Batch-inserted ${jobs.length} jobs, IDs: [${ids.join(", ")}]`);
3910
+ const existingIdempotencyIds = /* @__PURE__ */ new Set();
3911
+ for (let i = 0; i < jobs.length; i++) {
3912
+ if (jobs[i].idempotencyKey) {
3913
+ if (existingIdempotencyIds.has(ids[i])) {
3914
+ continue;
3915
+ }
3916
+ existingIdempotencyIds.add(ids[i]);
3917
+ }
3918
+ await this.recordJobEvent(ids[i], "added" /* Added */, {
3919
+ jobType: jobs[i].jobType,
3920
+ payload: jobs[i].payload,
3921
+ tags: jobs[i].tags,
3922
+ idempotencyKey: jobs[i].idempotencyKey
3923
+ });
3924
+ }
3925
+ return ids;
3926
+ }
3076
3927
  async getJob(id) {
3077
3928
  const data = await this.client.hgetall(`${this.prefix}job:${id}`);
3078
3929
  if (!data || Object.keys(data).length === 0) {
@@ -3143,7 +3994,7 @@ var RedisBackend = class {
3143
3994
  return jobs.slice(offset, offset + limit);
3144
3995
  }
3145
3996
  // ── Processing lifecycle ──────────────────────────────────────────────
3146
- async getNextBatch(workerId, batchSize = 10, jobType) {
3997
+ async getNextBatch(workerId, batchSize = 10, jobType, groupConcurrency) {
3147
3998
  const now = this.nowMs();
3148
3999
  const jobTypeFilter = jobType === void 0 ? "null" : Array.isArray(jobType) ? JSON.stringify(jobType) : jobType;
3149
4000
  const result = await this.client.eval(
@@ -3153,7 +4004,8 @@ var RedisBackend = class {
3153
4004
  workerId,
3154
4005
  batchSize,
3155
4006
  now,
3156
- jobTypeFilter
4007
+ jobTypeFilter,
4008
+ groupConcurrency !== void 0 ? groupConcurrency : "null"
3157
4009
  );
3158
4010
  if (!result || result.length === 0) {
3159
4011
  log("Found 0 jobs to process");
@@ -3178,9 +4030,17 @@ var RedisBackend = class {
3178
4030
  }
3179
4031
  return jobs;
3180
4032
  }
3181
- async completeJob(jobId) {
4033
+ async completeJob(jobId, output) {
3182
4034
  const now = this.nowMs();
3183
- await this.client.eval(COMPLETE_JOB_SCRIPT, 1, this.prefix, jobId, now);
4035
+ const outputArg = output !== void 0 ? JSON.stringify(output) : "__NONE__";
4036
+ await this.client.eval(
4037
+ COMPLETE_JOB_SCRIPT,
4038
+ 1,
4039
+ this.prefix,
4040
+ jobId,
4041
+ now,
4042
+ outputArg
4043
+ );
3184
4044
  await this.recordJobEvent(jobId, "completed" /* Completed */);
3185
4045
  log(`Completed job ${jobId}`);
3186
4046
  }
@@ -3233,6 +4093,22 @@ var RedisBackend = class {
3233
4093
  log(`Error updating progress for job ${jobId}: ${error}`);
3234
4094
  }
3235
4095
  }
4096
+ // ── Output ────────────────────────────────────────────────────────────
4097
+ async updateOutput(jobId, output) {
4098
+ try {
4099
+ const now = this.nowMs();
4100
+ await this.client.hset(
4101
+ `${this.prefix}job:${jobId}`,
4102
+ "output",
4103
+ JSON.stringify(output),
4104
+ "updatedAt",
4105
+ now.toString()
4106
+ );
4107
+ log(`Updated output for job ${jobId}`);
4108
+ } catch (error) {
4109
+ log(`Error updating output for job ${jobId}: ${error}`);
4110
+ }
4111
+ }
3236
4112
  // ── Job management ────────────────────────────────────────────────────
3237
4113
  async retryJob(jobId) {
3238
4114
  const now = this.nowMs();
@@ -3339,6 +4215,27 @@ var RedisBackend = class {
3339
4215
  }
3340
4216
  metadata.tags = updates.tags;
3341
4217
  }
4218
+ if (updates.retryDelay !== void 0) {
4219
+ fields.push(
4220
+ "retryDelay",
4221
+ updates.retryDelay !== null ? updates.retryDelay.toString() : "null"
4222
+ );
4223
+ metadata.retryDelay = updates.retryDelay;
4224
+ }
4225
+ if (updates.retryBackoff !== void 0) {
4226
+ fields.push(
4227
+ "retryBackoff",
4228
+ updates.retryBackoff !== null ? updates.retryBackoff.toString() : "null"
4229
+ );
4230
+ metadata.retryBackoff = updates.retryBackoff;
4231
+ }
4232
+ if (updates.retryDelayMax !== void 0) {
4233
+ fields.push(
4234
+ "retryDelayMax",
4235
+ updates.retryDelayMax !== null ? updates.retryDelayMax.toString() : "null"
4236
+ );
4237
+ metadata.retryDelayMax = updates.retryDelayMax;
4238
+ }
3342
4239
  if (fields.length === 0) {
3343
4240
  log(`No fields to update for job ${jobId}`);
3344
4241
  return;
@@ -3813,7 +4710,13 @@ var RedisBackend = class {
3813
4710
  "createdAt",
3814
4711
  now.toString(),
3815
4712
  "updatedAt",
3816
- now.toString()
4713
+ now.toString(),
4714
+ "retryDelay",
4715
+ input.retryDelay !== null && input.retryDelay !== void 0 ? input.retryDelay.toString() : "null",
4716
+ "retryBackoff",
4717
+ input.retryBackoff !== null && input.retryBackoff !== void 0 ? input.retryBackoff.toString() : "null",
4718
+ "retryDelayMax",
4719
+ input.retryDelayMax !== null && input.retryDelayMax !== void 0 ? input.retryDelayMax.toString() : "null"
3817
4720
  ];
3818
4721
  await this.client.hmset(key, ...fields);
3819
4722
  await this.client.set(
@@ -3967,6 +4870,24 @@ var RedisBackend = class {
3967
4870
  if (updates.allowOverlap !== void 0) {
3968
4871
  fields.push("allowOverlap", updates.allowOverlap ? "true" : "false");
3969
4872
  }
4873
+ if (updates.retryDelay !== void 0) {
4874
+ fields.push(
4875
+ "retryDelay",
4876
+ updates.retryDelay !== null ? updates.retryDelay.toString() : "null"
4877
+ );
4878
+ }
4879
+ if (updates.retryBackoff !== void 0) {
4880
+ fields.push(
4881
+ "retryBackoff",
4882
+ updates.retryBackoff !== null ? updates.retryBackoff.toString() : "null"
4883
+ );
4884
+ }
4885
+ if (updates.retryDelayMax !== void 0) {
4886
+ fields.push(
4887
+ "retryDelayMax",
4888
+ updates.retryDelayMax !== null ? updates.retryDelayMax.toString() : "null"
4889
+ );
4890
+ }
3970
4891
  if (nextRunAt !== void 0) {
3971
4892
  const val = nextRunAt !== null ? nextRunAt.getTime().toString() : "null";
3972
4893
  fields.push("nextRunAt", val);
@@ -4085,7 +5006,10 @@ var RedisBackend = class {
4085
5006
  lastJobId: numOrNull(h.lastJobId),
4086
5007
  nextRunAt: dateOrNull(h.nextRunAt),
4087
5008
  createdAt: new Date(Number(h.createdAt)),
4088
- updatedAt: new Date(Number(h.updatedAt))
5009
+ updatedAt: new Date(Number(h.updatedAt)),
5010
+ retryDelay: numOrNull(h.retryDelay),
5011
+ retryBackoff: h.retryBackoff === "true" ? true : h.retryBackoff === "false" ? false : null,
5012
+ retryDelayMax: numOrNull(h.retryDelayMax)
4089
5013
  };
4090
5014
  }
4091
5015
  // ── Private helpers (filters) ─────────────────────────────────────────
@@ -4208,14 +5132,37 @@ var initJobQueue = (config) => {
4208
5132
  let backend;
4209
5133
  if (backendType === "postgres") {
4210
5134
  const pgConfig = config;
4211
- const pool = createPool(pgConfig.databaseConfig);
4212
- backend = new PostgresBackend(pool);
5135
+ if (pgConfig.pool) {
5136
+ backend = new PostgresBackend(pgConfig.pool);
5137
+ } else if (pgConfig.databaseConfig) {
5138
+ const pool = createPool(pgConfig.databaseConfig);
5139
+ backend = new PostgresBackend(pool);
5140
+ } else {
5141
+ throw new Error(
5142
+ 'PostgreSQL backend requires either "databaseConfig" or "pool" to be provided.'
5143
+ );
5144
+ }
4213
5145
  } else if (backendType === "redis") {
4214
- const redisConfig = config.redisConfig;
4215
- backend = new RedisBackend(redisConfig);
5146
+ const redisConfig = config;
5147
+ if (redisConfig.client) {
5148
+ backend = new RedisBackend(
5149
+ redisConfig.client,
5150
+ redisConfig.keyPrefix
5151
+ );
5152
+ } else if (redisConfig.redisConfig) {
5153
+ backend = new RedisBackend(redisConfig.redisConfig);
5154
+ } else {
5155
+ throw new Error(
5156
+ 'Redis backend requires either "redisConfig" or "client" to be provided.'
5157
+ );
5158
+ }
4216
5159
  } else {
4217
5160
  throw new Error(`Unknown backend: ${backendType}`);
4218
5161
  }
5162
+ const emitter = new events.EventEmitter();
5163
+ const emit = (event, data) => {
5164
+ emitter.emit(event, data);
5165
+ };
4219
5166
  const enqueueDueCronJobsImpl = async () => {
4220
5167
  const dueSchedules = await backend.getDueCronSchedules();
4221
5168
  let count = 0;
@@ -4243,7 +5190,10 @@ var initJobQueue = (config) => {
4243
5190
  priority: schedule.priority,
4244
5191
  timeoutMs: schedule.timeoutMs ?? void 0,
4245
5192
  forceKillOnTimeout: schedule.forceKillOnTimeout,
4246
- tags: schedule.tags
5193
+ tags: schedule.tags,
5194
+ retryDelay: schedule.retryDelay ?? void 0,
5195
+ retryBackoff: schedule.retryBackoff ?? void 0,
5196
+ retryDelayMax: schedule.retryDelayMax ?? void 0
4247
5197
  });
4248
5198
  const nextRunAt = getNextCronOccurrence(
4249
5199
  schedule.cronExpression,
@@ -4262,7 +5212,21 @@ var initJobQueue = (config) => {
4262
5212
  return {
4263
5213
  // Job queue operations
4264
5214
  addJob: withLogContext(
4265
- (job) => backend.addJob(job),
5215
+ async (job, options) => {
5216
+ const jobId = await backend.addJob(job, options);
5217
+ emit("job:added", { jobId, jobType: job.jobType });
5218
+ return jobId;
5219
+ },
5220
+ config.verbose ?? false
5221
+ ),
5222
+ addJobs: withLogContext(
5223
+ async (jobs, options) => {
5224
+ const jobIds = await backend.addJobs(jobs, options);
5225
+ for (let i = 0; i < jobIds.length; i++) {
5226
+ emit("job:added", { jobId: jobIds[i], jobType: jobs[i].jobType });
5227
+ }
5228
+ return jobIds;
5229
+ },
4266
5230
  config.verbose ?? false
4267
5231
  ),
4268
5232
  getJob: withLogContext(
@@ -4281,13 +5245,16 @@ var initJobQueue = (config) => {
4281
5245
  (filters, limit, offset) => backend.getJobs(filters, limit, offset),
4282
5246
  config.verbose ?? false
4283
5247
  ),
4284
- retryJob: (jobId) => backend.retryJob(jobId),
5248
+ retryJob: async (jobId) => {
5249
+ await backend.retryJob(jobId);
5250
+ emit("job:retried", { jobId });
5251
+ },
4285
5252
  cleanupOldJobs: (daysToKeep, batchSize) => backend.cleanupOldJobs(daysToKeep, batchSize),
4286
5253
  cleanupOldJobEvents: (daysToKeep, batchSize) => backend.cleanupOldJobEvents(daysToKeep, batchSize),
4287
- cancelJob: withLogContext(
4288
- (jobId) => backend.cancelJob(jobId),
4289
- config.verbose ?? false
4290
- ),
5254
+ cancelJob: withLogContext(async (jobId) => {
5255
+ await backend.cancelJob(jobId);
5256
+ emit("job:cancelled", { jobId });
5257
+ }, config.verbose ?? false),
4291
5258
  editJob: withLogContext(
4292
5259
  (jobId, updates) => backend.editJob(jobId, updates),
4293
5260
  config.verbose ?? false
@@ -4312,9 +5279,17 @@ var initJobQueue = (config) => {
4312
5279
  config.verbose ?? false
4313
5280
  ),
4314
5281
  // Job processing — automatically enqueues due cron jobs before each batch
4315
- createProcessor: (handlers, options) => createProcessor(backend, handlers, options, async () => {
4316
- await enqueueDueCronJobsImpl();
4317
- }),
5282
+ createProcessor: (handlers, options) => createProcessor(
5283
+ backend,
5284
+ handlers,
5285
+ options,
5286
+ async () => {
5287
+ await enqueueDueCronJobsImpl();
5288
+ },
5289
+ emit
5290
+ ),
5291
+ // Background supervisor — automated maintenance
5292
+ createSupervisor: (options) => createSupervisor(backend, options, emit),
4318
5293
  // Job events
4319
5294
  getJobEvents: withLogContext(
4320
5295
  (jobId) => backend.getJobEvents(jobId),
@@ -4361,7 +5336,10 @@ var initJobQueue = (config) => {
4361
5336
  tags: options.tags,
4362
5337
  timezone: options.timezone ?? "UTC",
4363
5338
  allowOverlap: options.allowOverlap ?? false,
4364
- nextRunAt
5339
+ nextRunAt,
5340
+ retryDelay: options.retryDelay ?? null,
5341
+ retryBackoff: options.retryBackoff ?? null,
5342
+ retryDelayMax: options.retryDelayMax ?? null
4365
5343
  };
4366
5344
  return backend.addCronSchedule(input);
4367
5345
  },
@@ -4413,6 +5391,23 @@ var initJobQueue = (config) => {
4413
5391
  () => enqueueDueCronJobsImpl(),
4414
5392
  config.verbose ?? false
4415
5393
  ),
5394
+ // Event hooks
5395
+ on: (event, listener) => {
5396
+ emitter.on(event, listener);
5397
+ },
5398
+ once: (event, listener) => {
5399
+ emitter.once(event, listener);
5400
+ },
5401
+ off: (event, listener) => {
5402
+ emitter.off(event, listener);
5403
+ },
5404
+ removeAllListeners: (event) => {
5405
+ if (event) {
5406
+ emitter.removeAllListeners(event);
5407
+ } else {
5408
+ emitter.removeAllListeners();
5409
+ }
5410
+ },
4416
5411
  // Advanced access
4417
5412
  getPool: () => {
4418
5413
  if (!(backend instanceof PostgresBackend)) {