@nicnocquee/dataqueue 1.34.0 → 1.35.0-beta.20260224075710

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,32 @@ 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, onError, emit) {
494
535
  const jobs = await backend.getNextBatch(
495
536
  workerId,
496
537
  batchSize,
497
538
  jobType
498
539
  );
540
+ if (emit) {
541
+ for (const job of jobs) {
542
+ emit("job:processing", { jobId: job.id, jobType: job.jobType });
543
+ }
544
+ }
499
545
  if (!concurrency || concurrency >= jobs.length) {
500
546
  await Promise.all(
501
- jobs.map((job) => processJobWithHandlers(backend, job, jobHandlers))
547
+ jobs.map(
548
+ (job) => processJobWithHandlers(backend, job, jobHandlers, emit)
549
+ )
502
550
  );
503
551
  return jobs.length;
504
552
  }
@@ -511,7 +559,7 @@ async function processBatchWithHandlers(backend, workerId, batchSize, jobType, j
511
559
  while (running < concurrency && idx < jobs.length) {
512
560
  const job = jobs[idx++];
513
561
  running++;
514
- processJobWithHandlers(backend, job, jobHandlers).then(() => {
562
+ processJobWithHandlers(backend, job, jobHandlers, emit).then(() => {
515
563
  running--;
516
564
  finished++;
517
565
  next();
@@ -528,7 +576,7 @@ async function processBatchWithHandlers(backend, workerId, batchSize, jobType, j
528
576
  next();
529
577
  });
530
578
  }
531
- var createProcessor = (backend, handlers, options = {}, onBeforeBatch) => {
579
+ var createProcessor = (backend, handlers, options = {}, onBeforeBatch, emit) => {
532
580
  const {
533
581
  workerId = `worker-${Math.random().toString(36).substring(2, 9)}`,
534
582
  batchSize = 10,
@@ -548,11 +596,11 @@ var createProcessor = (backend, handlers, options = {}, onBeforeBatch) => {
548
596
  await onBeforeBatch();
549
597
  } catch (hookError) {
550
598
  log(`onBeforeBatch hook error: ${hookError}`);
599
+ const err = hookError instanceof Error ? hookError : new Error(String(hookError));
551
600
  if (onError) {
552
- onError(
553
- hookError instanceof Error ? hookError : new Error(String(hookError))
554
- );
601
+ onError(err);
555
602
  }
603
+ emit?.("error", err);
556
604
  }
557
605
  }
558
606
  log(
@@ -566,11 +614,14 @@ var createProcessor = (backend, handlers, options = {}, onBeforeBatch) => {
566
614
  jobType,
567
615
  handlers,
568
616
  concurrency,
569
- onError
617
+ onError,
618
+ emit
570
619
  );
571
620
  return processed;
572
621
  } catch (error) {
573
- onError(error instanceof Error ? error : new Error(String(error)));
622
+ const err = error instanceof Error ? error : new Error(String(error));
623
+ onError(err);
624
+ emit?.("error", err);
574
625
  }
575
626
  return 0;
576
627
  };
@@ -649,6 +700,138 @@ var createProcessor = (backend, handlers, options = {}, onBeforeBatch) => {
649
700
  isRunning: () => running
650
701
  };
651
702
  };
703
+
704
+ // src/supervisor.ts
705
+ var createSupervisor = (backend, options = {}, emit) => {
706
+ const {
707
+ intervalMs = 6e4,
708
+ stuckJobsTimeoutMinutes = 10,
709
+ cleanupJobsDaysToKeep = 30,
710
+ cleanupEventsDaysToKeep = 30,
711
+ cleanupBatchSize = 1e3,
712
+ reclaimStuckJobs = true,
713
+ expireTimedOutTokens = true,
714
+ onError = (error) => console.error("Supervisor maintenance error:", error),
715
+ verbose = false
716
+ } = options;
717
+ let running = false;
718
+ let timeoutId = null;
719
+ let currentRunPromise = null;
720
+ setLogContext(verbose);
721
+ const runOnce = async () => {
722
+ setLogContext(verbose);
723
+ const result = {
724
+ reclaimedJobs: 0,
725
+ cleanedUpJobs: 0,
726
+ cleanedUpEvents: 0,
727
+ expiredTokens: 0
728
+ };
729
+ if (reclaimStuckJobs) {
730
+ try {
731
+ result.reclaimedJobs = await backend.reclaimStuckJobs(
732
+ stuckJobsTimeoutMinutes
733
+ );
734
+ if (result.reclaimedJobs > 0) {
735
+ log(`Supervisor: reclaimed ${result.reclaimedJobs} stuck jobs`);
736
+ }
737
+ } catch (e) {
738
+ const err = e instanceof Error ? e : new Error(String(e));
739
+ onError(err);
740
+ emit?.("error", err);
741
+ }
742
+ }
743
+ if (cleanupJobsDaysToKeep > 0) {
744
+ try {
745
+ result.cleanedUpJobs = await backend.cleanupOldJobs(
746
+ cleanupJobsDaysToKeep,
747
+ cleanupBatchSize
748
+ );
749
+ if (result.cleanedUpJobs > 0) {
750
+ log(`Supervisor: cleaned up ${result.cleanedUpJobs} old jobs`);
751
+ }
752
+ } catch (e) {
753
+ const err = e instanceof Error ? e : new Error(String(e));
754
+ onError(err);
755
+ emit?.("error", err);
756
+ }
757
+ }
758
+ if (cleanupEventsDaysToKeep > 0) {
759
+ try {
760
+ result.cleanedUpEvents = await backend.cleanupOldJobEvents(
761
+ cleanupEventsDaysToKeep,
762
+ cleanupBatchSize
763
+ );
764
+ if (result.cleanedUpEvents > 0) {
765
+ log(
766
+ `Supervisor: cleaned up ${result.cleanedUpEvents} old job events`
767
+ );
768
+ }
769
+ } catch (e) {
770
+ const err = e instanceof Error ? e : new Error(String(e));
771
+ onError(err);
772
+ emit?.("error", err);
773
+ }
774
+ }
775
+ if (expireTimedOutTokens) {
776
+ try {
777
+ result.expiredTokens = await backend.expireTimedOutWaitpoints();
778
+ if (result.expiredTokens > 0) {
779
+ log(`Supervisor: expired ${result.expiredTokens} timed-out tokens`);
780
+ }
781
+ } catch (e) {
782
+ const err = e instanceof Error ? e : new Error(String(e));
783
+ onError(err);
784
+ emit?.("error", err);
785
+ }
786
+ }
787
+ return result;
788
+ };
789
+ return {
790
+ start: async () => {
791
+ return runOnce();
792
+ },
793
+ startInBackground: () => {
794
+ if (running) return;
795
+ log("Supervisor: starting background maintenance loop");
796
+ running = true;
797
+ const loop = async () => {
798
+ if (!running) return;
799
+ currentRunPromise = runOnce();
800
+ await currentRunPromise;
801
+ currentRunPromise = null;
802
+ if (running) {
803
+ timeoutId = setTimeout(loop, intervalMs);
804
+ }
805
+ };
806
+ loop();
807
+ },
808
+ stop: () => {
809
+ running = false;
810
+ if (timeoutId !== null) {
811
+ clearTimeout(timeoutId);
812
+ timeoutId = null;
813
+ }
814
+ log("Supervisor: stopped");
815
+ },
816
+ stopAndDrain: async (timeoutMs = 3e4) => {
817
+ running = false;
818
+ if (timeoutId !== null) {
819
+ clearTimeout(timeoutId);
820
+ timeoutId = null;
821
+ }
822
+ if (currentRunPromise) {
823
+ log("Supervisor: draining current maintenance run\u2026");
824
+ await Promise.race([
825
+ currentRunPromise,
826
+ new Promise((resolve) => setTimeout(resolve, timeoutMs))
827
+ ]);
828
+ currentRunPromise = null;
829
+ }
830
+ log("Supervisor: drained and stopped");
831
+ },
832
+ isRunning: () => running
833
+ };
834
+ };
652
835
  function loadPemOrFile(value) {
653
836
  if (!value) return void 0;
654
837
  if (value.startsWith("file://")) {
@@ -800,6 +983,14 @@ var PostgresBackend = class {
800
983
  }
801
984
  }
802
985
  // ── Job CRUD ──────────────────────────────────────────────────────────
986
+ /**
987
+ * Add a job and return its numeric ID.
988
+ *
989
+ * @param job - Job configuration.
990
+ * @param options - Optional. Pass `{ db }` to run the INSERT on an external
991
+ * client (e.g., inside a transaction) so the job is part of the caller's
992
+ * transaction. The event INSERT also uses the same client.
993
+ */
803
994
  async addJob({
804
995
  jobType,
805
996
  payload,
@@ -809,17 +1000,21 @@ var PostgresBackend = class {
809
1000
  timeoutMs = void 0,
810
1001
  forceKillOnTimeout = false,
811
1002
  tags = void 0,
812
- idempotencyKey = void 0
813
- }) {
814
- const client = await this.pool.connect();
1003
+ idempotencyKey = void 0,
1004
+ retryDelay = void 0,
1005
+ retryBackoff = void 0,
1006
+ retryDelayMax = void 0
1007
+ }, options) {
1008
+ const externalClient = options?.db;
1009
+ const client = externalClient ?? await this.pool.connect();
815
1010
  try {
816
1011
  let result;
817
1012
  const onConflict = idempotencyKey ? `ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING` : "";
818
1013
  if (runAt) {
819
1014
  result = await client.query(
820
1015
  `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)
1016
+ (job_type, payload, max_attempts, priority, run_at, timeout_ms, force_kill_on_timeout, tags, idempotency_key, retry_delay, retry_backoff, retry_delay_max)
1017
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
823
1018
  ${onConflict}
824
1019
  RETURNING id`,
825
1020
  [
@@ -831,14 +1026,17 @@ var PostgresBackend = class {
831
1026
  timeoutMs ?? null,
832
1027
  forceKillOnTimeout ?? false,
833
1028
  tags ?? null,
834
- idempotencyKey ?? null
1029
+ idempotencyKey ?? null,
1030
+ retryDelay ?? null,
1031
+ retryBackoff ?? null,
1032
+ retryDelayMax ?? null
835
1033
  ]
836
1034
  );
837
1035
  } else {
838
1036
  result = await client.query(
839
1037
  `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)
1038
+ (job_type, payload, max_attempts, priority, timeout_ms, force_kill_on_timeout, tags, idempotency_key, retry_delay, retry_backoff, retry_delay_max)
1039
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
842
1040
  ${onConflict}
843
1041
  RETURNING id`,
844
1042
  [
@@ -849,7 +1047,10 @@ var PostgresBackend = class {
849
1047
  timeoutMs ?? null,
850
1048
  forceKillOnTimeout ?? false,
851
1049
  tags ?? null,
852
- idempotencyKey ?? null
1050
+ idempotencyKey ?? null,
1051
+ retryDelay ?? null,
1052
+ retryBackoff ?? null,
1053
+ retryDelayMax ?? null
853
1054
  ]
854
1055
  );
855
1056
  }
@@ -872,25 +1073,188 @@ var PostgresBackend = class {
872
1073
  log(
873
1074
  `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
1075
  );
875
- await this.recordJobEvent(jobId, "added" /* Added */, {
876
- jobType,
877
- payload,
878
- tags,
879
- idempotencyKey
880
- });
1076
+ if (externalClient) {
1077
+ try {
1078
+ await client.query(
1079
+ `INSERT INTO job_events (job_id, event_type, metadata) VALUES ($1, $2, $3)`,
1080
+ [
1081
+ jobId,
1082
+ "added" /* Added */,
1083
+ JSON.stringify({ jobType, payload, tags, idempotencyKey })
1084
+ ]
1085
+ );
1086
+ } catch (error) {
1087
+ log(`Error recording job event for job ${jobId}: ${error}`);
1088
+ }
1089
+ } else {
1090
+ await this.recordJobEvent(jobId, "added" /* Added */, {
1091
+ jobType,
1092
+ payload,
1093
+ tags,
1094
+ idempotencyKey
1095
+ });
1096
+ }
881
1097
  return jobId;
882
1098
  } catch (error) {
883
1099
  log(`Error adding job: ${error}`);
884
1100
  throw error;
885
1101
  } finally {
886
- client.release();
1102
+ if (!externalClient) client.release();
1103
+ }
1104
+ }
1105
+ /**
1106
+ * Insert multiple jobs in a single database round-trip.
1107
+ *
1108
+ * Uses a multi-row INSERT with ON CONFLICT handling for idempotency keys.
1109
+ * Returns IDs in the same order as the input array.
1110
+ */
1111
+ async addJobs(jobs, options) {
1112
+ if (jobs.length === 0) return [];
1113
+ const externalClient = options?.db;
1114
+ const client = externalClient ?? await this.pool.connect();
1115
+ try {
1116
+ const COLS_PER_JOB = 12;
1117
+ const valueClauses = [];
1118
+ const params = [];
1119
+ const hasAnyIdempotencyKey = jobs.some((j) => j.idempotencyKey);
1120
+ for (let i = 0; i < jobs.length; i++) {
1121
+ const {
1122
+ jobType,
1123
+ payload,
1124
+ maxAttempts = 3,
1125
+ priority = 0,
1126
+ runAt = null,
1127
+ timeoutMs = void 0,
1128
+ forceKillOnTimeout = false,
1129
+ tags = void 0,
1130
+ idempotencyKey = void 0,
1131
+ retryDelay = void 0,
1132
+ retryBackoff = void 0,
1133
+ retryDelayMax = void 0
1134
+ } = jobs[i];
1135
+ const base = i * COLS_PER_JOB;
1136
+ valueClauses.push(
1137
+ `($${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})`
1138
+ );
1139
+ params.push(
1140
+ jobType,
1141
+ payload,
1142
+ maxAttempts,
1143
+ priority,
1144
+ runAt,
1145
+ timeoutMs ?? null,
1146
+ forceKillOnTimeout ?? false,
1147
+ tags ?? null,
1148
+ idempotencyKey ?? null,
1149
+ retryDelay ?? null,
1150
+ retryBackoff ?? null,
1151
+ retryDelayMax ?? null
1152
+ );
1153
+ }
1154
+ const onConflict = hasAnyIdempotencyKey ? `ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING` : "";
1155
+ const result = await client.query(
1156
+ `INSERT INTO job_queue
1157
+ (job_type, payload, max_attempts, priority, run_at, timeout_ms, force_kill_on_timeout, tags, idempotency_key, retry_delay, retry_backoff, retry_delay_max)
1158
+ VALUES ${valueClauses.join(", ")}
1159
+ ${onConflict}
1160
+ RETURNING id, idempotency_key`,
1161
+ params
1162
+ );
1163
+ const returnedKeyToId = /* @__PURE__ */ new Map();
1164
+ const returnedNullKeyIds = [];
1165
+ for (const row of result.rows) {
1166
+ if (row.idempotency_key != null) {
1167
+ returnedKeyToId.set(row.idempotency_key, row.id);
1168
+ } else {
1169
+ returnedNullKeyIds.push(row.id);
1170
+ }
1171
+ }
1172
+ const missingKeys = [];
1173
+ for (const job of jobs) {
1174
+ if (job.idempotencyKey && !returnedKeyToId.has(job.idempotencyKey)) {
1175
+ missingKeys.push(job.idempotencyKey);
1176
+ }
1177
+ }
1178
+ if (missingKeys.length > 0) {
1179
+ const existing = await client.query(
1180
+ `SELECT id, idempotency_key FROM job_queue WHERE idempotency_key = ANY($1)`,
1181
+ [missingKeys]
1182
+ );
1183
+ for (const row of existing.rows) {
1184
+ returnedKeyToId.set(row.idempotency_key, row.id);
1185
+ }
1186
+ }
1187
+ let nullKeyIdx = 0;
1188
+ const ids = [];
1189
+ for (const job of jobs) {
1190
+ if (job.idempotencyKey) {
1191
+ const id = returnedKeyToId.get(job.idempotencyKey);
1192
+ if (id === void 0) {
1193
+ throw new Error(
1194
+ `Failed to resolve job ID for idempotency key "${job.idempotencyKey}"`
1195
+ );
1196
+ }
1197
+ ids.push(id);
1198
+ } else {
1199
+ ids.push(returnedNullKeyIds[nullKeyIdx++]);
1200
+ }
1201
+ }
1202
+ log(`Batch-inserted ${jobs.length} jobs, IDs: [${ids.join(", ")}]`);
1203
+ const newJobEvents = [];
1204
+ for (let i = 0; i < jobs.length; i++) {
1205
+ const job = jobs[i];
1206
+ const wasInserted = !job.idempotencyKey || !missingKeys.includes(job.idempotencyKey);
1207
+ if (wasInserted) {
1208
+ newJobEvents.push({
1209
+ jobId: ids[i],
1210
+ eventType: "added" /* Added */,
1211
+ metadata: {
1212
+ jobType: job.jobType,
1213
+ payload: job.payload,
1214
+ tags: job.tags,
1215
+ idempotencyKey: job.idempotencyKey
1216
+ }
1217
+ });
1218
+ }
1219
+ }
1220
+ if (newJobEvents.length > 0) {
1221
+ if (externalClient) {
1222
+ const evtValues = [];
1223
+ const evtParams = [];
1224
+ let evtIdx = 1;
1225
+ for (const evt of newJobEvents) {
1226
+ evtValues.push(`($${evtIdx++}, $${evtIdx++}, $${evtIdx++})`);
1227
+ evtParams.push(
1228
+ evt.jobId,
1229
+ evt.eventType,
1230
+ evt.metadata ? JSON.stringify(evt.metadata) : null
1231
+ );
1232
+ }
1233
+ try {
1234
+ await client.query(
1235
+ `INSERT INTO job_events (job_id, event_type, metadata) VALUES ${evtValues.join(", ")}`,
1236
+ evtParams
1237
+ );
1238
+ } catch (error) {
1239
+ log(`Error recording batch job events: ${error}`);
1240
+ }
1241
+ } else {
1242
+ await this.recordJobEventsBatch(newJobEvents);
1243
+ }
1244
+ }
1245
+ return ids;
1246
+ } catch (error) {
1247
+ log(`Error batch-inserting jobs: ${error}`);
1248
+ throw error;
1249
+ } finally {
1250
+ if (!externalClient) client.release();
887
1251
  }
888
1252
  }
889
1253
  async getJob(id) {
890
1254
  const client = await this.pool.connect();
891
1255
  try {
892
1256
  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`,
1257
+ `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", output FROM job_queue WHERE id = $1`,
894
1258
  [id]
895
1259
  );
896
1260
  if (result.rows.length === 0) {
@@ -917,7 +1281,7 @@ var PostgresBackend = class {
917
1281
  const client = await this.pool.connect();
918
1282
  try {
919
1283
  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`,
1284
+ `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", output FROM job_queue WHERE status = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`,
921
1285
  [status, limit, offset]
922
1286
  );
923
1287
  log(`Found ${result.rows.length} jobs by status ${status}`);
@@ -939,7 +1303,7 @@ var PostgresBackend = class {
939
1303
  const client = await this.pool.connect();
940
1304
  try {
941
1305
  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`,
1306
+ `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", output FROM job_queue ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
943
1307
  [limit, offset]
944
1308
  );
945
1309
  log(`Found ${result.rows.length} jobs (all)`);
@@ -959,7 +1323,7 @@ var PostgresBackend = class {
959
1323
  async getJobs(filters, limit = 100, offset = 0) {
960
1324
  const client = await this.pool.connect();
961
1325
  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`;
1326
+ 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", output FROM job_queue`;
963
1327
  const params = [];
964
1328
  const where = [];
965
1329
  let paramIdx = 1;
@@ -1060,7 +1424,7 @@ var PostgresBackend = class {
1060
1424
  async getJobsByTags(tags, mode = "all", limit = 100, offset = 0) {
1061
1425
  const client = await this.pool.connect();
1062
1426
  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
1427
+ 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", output
1064
1428
  FROM job_queue`;
1065
1429
  let params = [];
1066
1430
  switch (mode) {
@@ -1154,7 +1518,7 @@ var PostgresBackend = class {
1154
1518
  LIMIT $2
1155
1519
  FOR UPDATE SKIP LOCKED
1156
1520
  )
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
1521
+ 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", output
1158
1522
  `,
1159
1523
  params
1160
1524
  );
@@ -1182,17 +1546,19 @@ var PostgresBackend = class {
1182
1546
  client.release();
1183
1547
  }
1184
1548
  }
1185
- async completeJob(jobId) {
1549
+ async completeJob(jobId, output) {
1186
1550
  const client = await this.pool.connect();
1187
1551
  try {
1552
+ const outputJson = output !== void 0 ? JSON.stringify(output) : null;
1188
1553
  const result = await client.query(
1189
1554
  `
1190
1555
  UPDATE job_queue
1191
1556
  SET status = 'completed', updated_at = NOW(), completed_at = NOW(),
1192
- step_data = NULL, wait_until = NULL, wait_token_id = NULL
1557
+ step_data = NULL, wait_until = NULL, wait_token_id = NULL,
1558
+ output = COALESCE($2::jsonb, output)
1193
1559
  WHERE id = $1 AND status = 'processing'
1194
1560
  `,
1195
- [jobId]
1561
+ [jobId, outputJson]
1196
1562
  );
1197
1563
  if (result.rowCount === 0) {
1198
1564
  log(
@@ -1216,9 +1582,17 @@ var PostgresBackend = class {
1216
1582
  UPDATE job_queue
1217
1583
  SET status = 'failed',
1218
1584
  updated_at = NOW(),
1219
- next_attempt_at = CASE
1220
- WHEN attempts < max_attempts THEN NOW() + (POWER(2, attempts) * INTERVAL '1 minute')
1221
- ELSE NULL
1585
+ next_attempt_at = CASE
1586
+ WHEN attempts >= max_attempts THEN NULL
1587
+ WHEN retry_delay IS NULL AND retry_backoff IS NULL AND retry_delay_max IS NULL
1588
+ THEN NOW() + (POWER(2, attempts) * INTERVAL '1 minute')
1589
+ WHEN COALESCE(retry_backoff, true) = true
1590
+ THEN NOW() + (LEAST(
1591
+ COALESCE(retry_delay_max, 2147483647),
1592
+ COALESCE(retry_delay, 60) * POWER(2, attempts)
1593
+ ) * (0.5 + 0.5 * random()) * INTERVAL '1 second')
1594
+ ELSE
1595
+ NOW() + (COALESCE(retry_delay, 60) * INTERVAL '1 second')
1222
1596
  END,
1223
1597
  error_history = COALESCE(error_history, '[]'::jsonb) || $2::jsonb,
1224
1598
  failure_reason = $3,
@@ -1287,6 +1661,21 @@ var PostgresBackend = class {
1287
1661
  client.release();
1288
1662
  }
1289
1663
  }
1664
+ // ── Output ────────────────────────────────────────────────────────────
1665
+ async updateOutput(jobId, output) {
1666
+ const client = await this.pool.connect();
1667
+ try {
1668
+ await client.query(
1669
+ `UPDATE job_queue SET output = $2::jsonb, updated_at = NOW() WHERE id = $1`,
1670
+ [jobId, JSON.stringify(output)]
1671
+ );
1672
+ log(`Updated output for job ${jobId}`);
1673
+ } catch (error) {
1674
+ log(`Error updating output for job ${jobId}: ${error}`);
1675
+ } finally {
1676
+ client.release();
1677
+ }
1678
+ }
1290
1679
  // ── Job management ────────────────────────────────────────────────────
1291
1680
  async retryJob(jobId) {
1292
1681
  const client = await this.pool.connect();
@@ -1456,6 +1845,18 @@ var PostgresBackend = class {
1456
1845
  updateFields.push(`tags = $${paramIdx++}`);
1457
1846
  params.push(updates.tags ?? null);
1458
1847
  }
1848
+ if (updates.retryDelay !== void 0) {
1849
+ updateFields.push(`retry_delay = $${paramIdx++}`);
1850
+ params.push(updates.retryDelay ?? null);
1851
+ }
1852
+ if (updates.retryBackoff !== void 0) {
1853
+ updateFields.push(`retry_backoff = $${paramIdx++}`);
1854
+ params.push(updates.retryBackoff ?? null);
1855
+ }
1856
+ if (updates.retryDelayMax !== void 0) {
1857
+ updateFields.push(`retry_delay_max = $${paramIdx++}`);
1858
+ params.push(updates.retryDelayMax ?? null);
1859
+ }
1459
1860
  if (updateFields.length === 0) {
1460
1861
  log(`No fields to update for job ${jobId}`);
1461
1862
  return;
@@ -1477,6 +1878,12 @@ var PostgresBackend = class {
1477
1878
  if (updates.timeoutMs !== void 0)
1478
1879
  metadata.timeoutMs = updates.timeoutMs;
1479
1880
  if (updates.tags !== void 0) metadata.tags = updates.tags;
1881
+ if (updates.retryDelay !== void 0)
1882
+ metadata.retryDelay = updates.retryDelay;
1883
+ if (updates.retryBackoff !== void 0)
1884
+ metadata.retryBackoff = updates.retryBackoff;
1885
+ if (updates.retryDelayMax !== void 0)
1886
+ metadata.retryDelayMax = updates.retryDelayMax;
1480
1887
  await this.recordJobEvent(jobId, "edited" /* Edited */, metadata);
1481
1888
  log(`Edited job ${jobId}: ${JSON.stringify(metadata)}`);
1482
1889
  } catch (error) {
@@ -1520,6 +1927,18 @@ var PostgresBackend = class {
1520
1927
  updateFields.push(`tags = $${paramIdx++}`);
1521
1928
  params.push(updates.tags ?? null);
1522
1929
  }
1930
+ if (updates.retryDelay !== void 0) {
1931
+ updateFields.push(`retry_delay = $${paramIdx++}`);
1932
+ params.push(updates.retryDelay ?? null);
1933
+ }
1934
+ if (updates.retryBackoff !== void 0) {
1935
+ updateFields.push(`retry_backoff = $${paramIdx++}`);
1936
+ params.push(updates.retryBackoff ?? null);
1937
+ }
1938
+ if (updates.retryDelayMax !== void 0) {
1939
+ updateFields.push(`retry_delay_max = $${paramIdx++}`);
1940
+ params.push(updates.retryDelayMax ?? null);
1941
+ }
1523
1942
  if (updateFields.length === 0) {
1524
1943
  log(`No fields to update for batch edit`);
1525
1944
  return 0;
@@ -1761,8 +2180,8 @@ var PostgresBackend = class {
1761
2180
  `INSERT INTO cron_schedules
1762
2181
  (schedule_name, cron_expression, job_type, payload, max_attempts,
1763
2182
  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)
2183
+ allow_overlap, next_run_at, retry_delay, retry_backoff, retry_delay_max)
2184
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
1766
2185
  RETURNING id`,
1767
2186
  [
1768
2187
  input.scheduleName,
@@ -1776,7 +2195,10 @@ var PostgresBackend = class {
1776
2195
  input.tags ?? null,
1777
2196
  input.timezone,
1778
2197
  input.allowOverlap,
1779
- input.nextRunAt
2198
+ input.nextRunAt,
2199
+ input.retryDelay,
2200
+ input.retryBackoff,
2201
+ input.retryDelayMax
1780
2202
  ]
1781
2203
  );
1782
2204
  const id = result.rows[0].id;
@@ -1806,7 +2228,9 @@ var PostgresBackend = class {
1806
2228
  timezone, allow_overlap AS "allowOverlap", status,
1807
2229
  last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
1808
2230
  next_run_at AS "nextRunAt",
1809
- created_at AS "createdAt", updated_at AS "updatedAt"
2231
+ created_at AS "createdAt", updated_at AS "updatedAt",
2232
+ retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
2233
+ retry_delay_max AS "retryDelayMax"
1810
2234
  FROM cron_schedules WHERE id = $1`,
1811
2235
  [id]
1812
2236
  );
@@ -1831,7 +2255,9 @@ var PostgresBackend = class {
1831
2255
  timezone, allow_overlap AS "allowOverlap", status,
1832
2256
  last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
1833
2257
  next_run_at AS "nextRunAt",
1834
- created_at AS "createdAt", updated_at AS "updatedAt"
2258
+ created_at AS "createdAt", updated_at AS "updatedAt",
2259
+ retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
2260
+ retry_delay_max AS "retryDelayMax"
1835
2261
  FROM cron_schedules WHERE schedule_name = $1`,
1836
2262
  [name]
1837
2263
  );
@@ -1855,7 +2281,9 @@ var PostgresBackend = class {
1855
2281
  timezone, allow_overlap AS "allowOverlap", status,
1856
2282
  last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
1857
2283
  next_run_at AS "nextRunAt",
1858
- created_at AS "createdAt", updated_at AS "updatedAt"
2284
+ created_at AS "createdAt", updated_at AS "updatedAt",
2285
+ retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
2286
+ retry_delay_max AS "retryDelayMax"
1859
2287
  FROM cron_schedules`;
1860
2288
  const params = [];
1861
2289
  if (status) {
@@ -1960,6 +2388,18 @@ var PostgresBackend = class {
1960
2388
  updateFields.push(`allow_overlap = $${paramIdx++}`);
1961
2389
  params.push(updates.allowOverlap);
1962
2390
  }
2391
+ if (updates.retryDelay !== void 0) {
2392
+ updateFields.push(`retry_delay = $${paramIdx++}`);
2393
+ params.push(updates.retryDelay);
2394
+ }
2395
+ if (updates.retryBackoff !== void 0) {
2396
+ updateFields.push(`retry_backoff = $${paramIdx++}`);
2397
+ params.push(updates.retryBackoff);
2398
+ }
2399
+ if (updates.retryDelayMax !== void 0) {
2400
+ updateFields.push(`retry_delay_max = $${paramIdx++}`);
2401
+ params.push(updates.retryDelayMax);
2402
+ }
1963
2403
  if (nextRunAt !== void 0) {
1964
2404
  updateFields.push(`next_run_at = $${paramIdx++}`);
1965
2405
  params.push(nextRunAt);
@@ -1995,7 +2435,9 @@ var PostgresBackend = class {
1995
2435
  timezone, allow_overlap AS "allowOverlap", status,
1996
2436
  last_enqueued_at AS "lastEnqueuedAt", last_job_id AS "lastJobId",
1997
2437
  next_run_at AS "nextRunAt",
1998
- created_at AS "createdAt", updated_at AS "updatedAt"
2438
+ created_at AS "createdAt", updated_at AS "updatedAt",
2439
+ retry_delay AS "retryDelay", retry_backoff AS "retryBackoff",
2440
+ retry_delay_max AS "retryDelayMax"
1999
2441
  FROM cron_schedules
2000
2442
  WHERE status = 'active'
2001
2443
  AND next_run_at IS NOT NULL
@@ -2279,6 +2721,9 @@ local forceKillOnTimeout = ARGV[7]
2279
2721
  local tagsJson = ARGV[8] -- "null" or JSON array string
2280
2722
  local idempotencyKey = ARGV[9] -- "null" string if not set
2281
2723
  local nowMs = tonumber(ARGV[10])
2724
+ local retryDelay = ARGV[11] -- "null" or seconds string
2725
+ local retryBackoff = ARGV[12] -- "null" or "true"/"false"
2726
+ local retryDelayMax = ARGV[13] -- "null" or seconds string
2282
2727
 
2283
2728
  -- Idempotency check
2284
2729
  if idempotencyKey ~= "null" then
@@ -2322,7 +2767,10 @@ redis.call('HMSET', jobKey,
2322
2767
  'idempotencyKey', idempotencyKey,
2323
2768
  'waitUntil', 'null',
2324
2769
  'waitTokenId', 'null',
2325
- 'stepData', 'null'
2770
+ 'stepData', 'null',
2771
+ 'retryDelay', retryDelay,
2772
+ 'retryBackoff', retryBackoff,
2773
+ 'retryDelayMax', retryDelayMax
2326
2774
  )
2327
2775
 
2328
2776
  -- Status index
@@ -2363,6 +2811,118 @@ end
2363
2811
 
2364
2812
  return id
2365
2813
  `;
2814
+ var ADD_JOBS_SCRIPT = `
2815
+ local prefix = KEYS[1]
2816
+ local jobsJson = ARGV[1]
2817
+ local nowMs = tonumber(ARGV[2])
2818
+
2819
+ local jobs = cjson.decode(jobsJson)
2820
+ local results = {}
2821
+
2822
+ for i, job in ipairs(jobs) do
2823
+ local jobType = job.jobType
2824
+ local payloadJson = job.payload
2825
+ local maxAttempts = tonumber(job.maxAttempts)
2826
+ local priority = tonumber(job.priority)
2827
+ local runAtMs = tostring(job.runAtMs)
2828
+ local timeoutMs = tostring(job.timeoutMs)
2829
+ local forceKillOnTimeout = tostring(job.forceKillOnTimeout)
2830
+ local tagsJson = tostring(job.tags)
2831
+ local idempotencyKey = tostring(job.idempotencyKey)
2832
+ local retryDelay = tostring(job.retryDelay)
2833
+ local retryBackoff = tostring(job.retryBackoff)
2834
+ local retryDelayMax = tostring(job.retryDelayMax)
2835
+
2836
+ -- Idempotency check
2837
+ local skip = false
2838
+ if idempotencyKey ~= "null" then
2839
+ local existing = redis.call('GET', prefix .. 'idempotency:' .. idempotencyKey)
2840
+ if existing then
2841
+ results[i] = tonumber(existing)
2842
+ skip = true
2843
+ end
2844
+ end
2845
+
2846
+ if not skip then
2847
+ -- Generate ID
2848
+ local id = redis.call('INCR', prefix .. 'id_seq')
2849
+ local jobKey = prefix .. 'job:' .. id
2850
+ local runAt = runAtMs ~= "0" and tonumber(runAtMs) or nowMs
2851
+
2852
+ -- Store the job hash
2853
+ redis.call('HMSET', jobKey,
2854
+ 'id', id,
2855
+ 'jobType', jobType,
2856
+ 'payload', payloadJson,
2857
+ 'status', 'pending',
2858
+ 'maxAttempts', maxAttempts,
2859
+ 'attempts', 0,
2860
+ 'priority', priority,
2861
+ 'runAt', runAt,
2862
+ 'timeoutMs', timeoutMs,
2863
+ 'forceKillOnTimeout', forceKillOnTimeout,
2864
+ 'createdAt', nowMs,
2865
+ 'updatedAt', nowMs,
2866
+ 'lockedAt', 'null',
2867
+ 'lockedBy', 'null',
2868
+ 'nextAttemptAt', 'null',
2869
+ 'pendingReason', 'null',
2870
+ 'errorHistory', '[]',
2871
+ 'failureReason', 'null',
2872
+ 'completedAt', 'null',
2873
+ 'startedAt', 'null',
2874
+ 'lastRetriedAt', 'null',
2875
+ 'lastFailedAt', 'null',
2876
+ 'lastCancelledAt', 'null',
2877
+ 'tags', tagsJson,
2878
+ 'idempotencyKey', idempotencyKey,
2879
+ 'waitUntil', 'null',
2880
+ 'waitTokenId', 'null',
2881
+ 'stepData', 'null',
2882
+ 'retryDelay', retryDelay,
2883
+ 'retryBackoff', retryBackoff,
2884
+ 'retryDelayMax', retryDelayMax
2885
+ )
2886
+
2887
+ -- Status index
2888
+ redis.call('SADD', prefix .. 'status:pending', id)
2889
+
2890
+ -- Type index
2891
+ redis.call('SADD', prefix .. 'type:' .. jobType, id)
2892
+
2893
+ -- Tag indexes
2894
+ if tagsJson ~= "null" then
2895
+ local tags = cjson.decode(tagsJson)
2896
+ for _, tag in ipairs(tags) do
2897
+ redis.call('SADD', prefix .. 'tag:' .. tag, id)
2898
+ end
2899
+ for _, tag in ipairs(tags) do
2900
+ redis.call('SADD', prefix .. 'job:' .. id .. ':tags', tag)
2901
+ end
2902
+ end
2903
+
2904
+ -- Idempotency mapping
2905
+ if idempotencyKey ~= "null" then
2906
+ redis.call('SET', prefix .. 'idempotency:' .. idempotencyKey, id)
2907
+ end
2908
+
2909
+ -- All-jobs sorted set
2910
+ redis.call('ZADD', prefix .. 'all', nowMs, id)
2911
+
2912
+ -- Queue or delayed
2913
+ if runAt <= nowMs then
2914
+ local score = priority * ${SCORE_RANGE} + (${SCORE_RANGE} - nowMs)
2915
+ redis.call('ZADD', prefix .. 'queue', score, id)
2916
+ else
2917
+ redis.call('ZADD', prefix .. 'delayed', runAt, id)
2918
+ end
2919
+
2920
+ results[i] = id
2921
+ end
2922
+ end
2923
+
2924
+ return results
2925
+ `;
2366
2926
  var GET_NEXT_BATCH_SCRIPT = `
2367
2927
  local prefix = KEYS[1]
2368
2928
  local workerId = ARGV[1]
@@ -2515,16 +3075,24 @@ var COMPLETE_JOB_SCRIPT = `
2515
3075
  local prefix = KEYS[1]
2516
3076
  local jobId = ARGV[1]
2517
3077
  local nowMs = ARGV[2]
3078
+ local outputJson = ARGV[3]
2518
3079
  local jk = prefix .. 'job:' .. jobId
2519
3080
 
2520
- redis.call('HMSET', jk,
3081
+ local fields = {
2521
3082
  'status', 'completed',
2522
3083
  'updatedAt', nowMs,
2523
3084
  'completedAt', nowMs,
2524
3085
  'stepData', 'null',
2525
3086
  'waitUntil', 'null',
2526
3087
  'waitTokenId', 'null'
2527
- )
3088
+ }
3089
+
3090
+ if outputJson ~= '__NONE__' then
3091
+ fields[#fields + 1] = 'output'
3092
+ fields[#fields + 1] = outputJson
3093
+ end
3094
+
3095
+ redis.call('HMSET', jk, unpack(fields))
2528
3096
  redis.call('SREM', prefix .. 'status:processing', jobId)
2529
3097
  redis.call('SADD', prefix .. 'status:completed', jobId)
2530
3098
 
@@ -2541,11 +3109,38 @@ local jk = prefix .. 'job:' .. jobId
2541
3109
  local attempts = tonumber(redis.call('HGET', jk, 'attempts'))
2542
3110
  local maxAttempts = tonumber(redis.call('HGET', jk, 'maxAttempts'))
2543
3111
 
2544
- -- Compute next_attempt_at: 2^attempts minutes from now
3112
+ -- Read per-job retry config (may be "null")
3113
+ local rdRaw = redis.call('HGET', jk, 'retryDelay')
3114
+ local rbRaw = redis.call('HGET', jk, 'retryBackoff')
3115
+ local rmRaw = redis.call('HGET', jk, 'retryDelayMax')
3116
+
2545
3117
  local nextAttemptAt = 'null'
2546
3118
  if attempts < maxAttempts then
2547
- local delayMs = math.pow(2, attempts) * 60000
2548
- nextAttemptAt = nowMs + delayMs
3119
+ local allNull = (rdRaw == 'null' or rdRaw == false)
3120
+ and (rbRaw == 'null' or rbRaw == false)
3121
+ and (rmRaw == 'null' or rmRaw == false)
3122
+ if allNull then
3123
+ -- Legacy formula: 2^attempts minutes
3124
+ local delayMs = math.pow(2, attempts) * 60000
3125
+ nextAttemptAt = nowMs + delayMs
3126
+ else
3127
+ local retryDelaySec = 60
3128
+ if rdRaw and rdRaw ~= 'null' then retryDelaySec = tonumber(rdRaw) end
3129
+ local useBackoff = true
3130
+ if rbRaw and rbRaw ~= 'null' then useBackoff = (rbRaw == 'true') end
3131
+ local maxDelaySec = nil
3132
+ if rmRaw and rmRaw ~= 'null' then maxDelaySec = tonumber(rmRaw) end
3133
+
3134
+ local delaySec
3135
+ if useBackoff then
3136
+ delaySec = retryDelaySec * math.pow(2, attempts)
3137
+ if maxDelaySec then delaySec = math.min(delaySec, maxDelaySec) end
3138
+ delaySec = delaySec * (0.5 + 0.5 * math.random())
3139
+ else
3140
+ delaySec = retryDelaySec
3141
+ end
3142
+ nextAttemptAt = nowMs + math.floor(delaySec * 1000)
3143
+ end
2549
3144
  end
2550
3145
 
2551
3146
  -- Append to error_history
@@ -2957,9 +3552,21 @@ function deserializeJob(h) {
2957
3552
  progress: numOrNull(h.progress),
2958
3553
  waitUntil: dateOrNull(h.waitUntil),
2959
3554
  waitTokenId: nullish(h.waitTokenId),
2960
- stepData: parseStepData(h.stepData)
3555
+ stepData: parseStepData(h.stepData),
3556
+ retryDelay: numOrNull(h.retryDelay),
3557
+ retryBackoff: h.retryBackoff === "true" ? true : h.retryBackoff === "false" ? false : null,
3558
+ retryDelayMax: numOrNull(h.retryDelayMax),
3559
+ output: parseJsonField(h.output)
2961
3560
  };
2962
3561
  }
3562
+ function parseJsonField(raw) {
3563
+ if (!raw || raw === "null") return null;
3564
+ try {
3565
+ return JSON.parse(raw);
3566
+ } catch {
3567
+ return null;
3568
+ }
3569
+ }
2963
3570
  function parseStepData(raw) {
2964
3571
  if (!raw || raw === "null") return void 0;
2965
3572
  try {
@@ -2969,7 +3576,23 @@ function parseStepData(raw) {
2969
3576
  }
2970
3577
  }
2971
3578
  var RedisBackend = class {
2972
- constructor(redisConfig) {
3579
+ /**
3580
+ * Create a RedisBackend.
3581
+ *
3582
+ * @param configOrClient - Either `redisConfig` from the config file (the
3583
+ * library creates a new ioredis client) or an existing ioredis client
3584
+ * instance (bring your own).
3585
+ * @param keyPrefix - Key prefix, only used when `configOrClient` is an
3586
+ * external client. Ignored when `redisConfig` is passed (uses
3587
+ * `redisConfig.keyPrefix` instead). Default: `'dq:'`.
3588
+ */
3589
+ constructor(configOrClient, keyPrefix) {
3590
+ if (configOrClient && typeof configOrClient.eval === "function") {
3591
+ this.client = configOrClient;
3592
+ this.prefix = keyPrefix ?? "dq:";
3593
+ return;
3594
+ }
3595
+ const redisConfig = configOrClient;
2973
3596
  let IORedis;
2974
3597
  try {
2975
3598
  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 +3665,16 @@ var RedisBackend = class {
3042
3665
  timeoutMs = void 0,
3043
3666
  forceKillOnTimeout = false,
3044
3667
  tags = void 0,
3045
- idempotencyKey = void 0
3046
- }) {
3668
+ idempotencyKey = void 0,
3669
+ retryDelay = void 0,
3670
+ retryBackoff = void 0,
3671
+ retryDelayMax = void 0
3672
+ }, options) {
3673
+ if (options?.db) {
3674
+ throw new Error(
3675
+ "The db option is not supported with the Redis backend. Transactional job creation is only available with PostgreSQL."
3676
+ );
3677
+ }
3047
3678
  const now = this.nowMs();
3048
3679
  const runAtMs = runAt ? runAt.getTime() : 0;
3049
3680
  const result = await this.client.eval(
@@ -3059,7 +3690,10 @@ var RedisBackend = class {
3059
3690
  forceKillOnTimeout ? "true" : "false",
3060
3691
  tags ? JSON.stringify(tags) : "null",
3061
3692
  idempotencyKey ?? "null",
3062
- now
3693
+ now,
3694
+ retryDelay !== void 0 ? retryDelay.toString() : "null",
3695
+ retryBackoff !== void 0 ? retryBackoff.toString() : "null",
3696
+ retryDelayMax !== void 0 ? retryDelayMax.toString() : "null"
3063
3697
  );
3064
3698
  const jobId = Number(result);
3065
3699
  log(
@@ -3073,6 +3707,58 @@ var RedisBackend = class {
3073
3707
  });
3074
3708
  return jobId;
3075
3709
  }
3710
+ /**
3711
+ * Insert multiple jobs atomically via a single Lua script.
3712
+ * Returns IDs in the same order as the input array.
3713
+ */
3714
+ async addJobs(jobs, options) {
3715
+ if (jobs.length === 0) return [];
3716
+ if (options?.db) {
3717
+ throw new Error(
3718
+ "The db option is not supported with the Redis backend. Transactional job creation is only available with PostgreSQL."
3719
+ );
3720
+ }
3721
+ const now = this.nowMs();
3722
+ const jobsPayload = jobs.map((job) => ({
3723
+ jobType: job.jobType,
3724
+ payload: JSON.stringify(job.payload),
3725
+ maxAttempts: job.maxAttempts ?? 3,
3726
+ priority: job.priority ?? 0,
3727
+ runAtMs: job.runAt ? job.runAt.getTime() : 0,
3728
+ timeoutMs: job.timeoutMs !== void 0 ? job.timeoutMs.toString() : "null",
3729
+ forceKillOnTimeout: job.forceKillOnTimeout ? "true" : "false",
3730
+ tags: job.tags ? JSON.stringify(job.tags) : "null",
3731
+ idempotencyKey: job.idempotencyKey ?? "null",
3732
+ retryDelay: job.retryDelay !== void 0 ? job.retryDelay.toString() : "null",
3733
+ retryBackoff: job.retryBackoff !== void 0 ? job.retryBackoff.toString() : "null",
3734
+ retryDelayMax: job.retryDelayMax !== void 0 ? job.retryDelayMax.toString() : "null"
3735
+ }));
3736
+ const result = await this.client.eval(
3737
+ ADD_JOBS_SCRIPT,
3738
+ 1,
3739
+ this.prefix,
3740
+ JSON.stringify(jobsPayload),
3741
+ now
3742
+ );
3743
+ const ids = result.map(Number);
3744
+ log(`Batch-inserted ${jobs.length} jobs, IDs: [${ids.join(", ")}]`);
3745
+ const existingIdempotencyIds = /* @__PURE__ */ new Set();
3746
+ for (let i = 0; i < jobs.length; i++) {
3747
+ if (jobs[i].idempotencyKey) {
3748
+ if (existingIdempotencyIds.has(ids[i])) {
3749
+ continue;
3750
+ }
3751
+ existingIdempotencyIds.add(ids[i]);
3752
+ }
3753
+ await this.recordJobEvent(ids[i], "added" /* Added */, {
3754
+ jobType: jobs[i].jobType,
3755
+ payload: jobs[i].payload,
3756
+ tags: jobs[i].tags,
3757
+ idempotencyKey: jobs[i].idempotencyKey
3758
+ });
3759
+ }
3760
+ return ids;
3761
+ }
3076
3762
  async getJob(id) {
3077
3763
  const data = await this.client.hgetall(`${this.prefix}job:${id}`);
3078
3764
  if (!data || Object.keys(data).length === 0) {
@@ -3178,9 +3864,17 @@ var RedisBackend = class {
3178
3864
  }
3179
3865
  return jobs;
3180
3866
  }
3181
- async completeJob(jobId) {
3867
+ async completeJob(jobId, output) {
3182
3868
  const now = this.nowMs();
3183
- await this.client.eval(COMPLETE_JOB_SCRIPT, 1, this.prefix, jobId, now);
3869
+ const outputArg = output !== void 0 ? JSON.stringify(output) : "__NONE__";
3870
+ await this.client.eval(
3871
+ COMPLETE_JOB_SCRIPT,
3872
+ 1,
3873
+ this.prefix,
3874
+ jobId,
3875
+ now,
3876
+ outputArg
3877
+ );
3184
3878
  await this.recordJobEvent(jobId, "completed" /* Completed */);
3185
3879
  log(`Completed job ${jobId}`);
3186
3880
  }
@@ -3233,6 +3927,22 @@ var RedisBackend = class {
3233
3927
  log(`Error updating progress for job ${jobId}: ${error}`);
3234
3928
  }
3235
3929
  }
3930
+ // ── Output ────────────────────────────────────────────────────────────
3931
+ async updateOutput(jobId, output) {
3932
+ try {
3933
+ const now = this.nowMs();
3934
+ await this.client.hset(
3935
+ `${this.prefix}job:${jobId}`,
3936
+ "output",
3937
+ JSON.stringify(output),
3938
+ "updatedAt",
3939
+ now.toString()
3940
+ );
3941
+ log(`Updated output for job ${jobId}`);
3942
+ } catch (error) {
3943
+ log(`Error updating output for job ${jobId}: ${error}`);
3944
+ }
3945
+ }
3236
3946
  // ── Job management ────────────────────────────────────────────────────
3237
3947
  async retryJob(jobId) {
3238
3948
  const now = this.nowMs();
@@ -3339,6 +4049,27 @@ var RedisBackend = class {
3339
4049
  }
3340
4050
  metadata.tags = updates.tags;
3341
4051
  }
4052
+ if (updates.retryDelay !== void 0) {
4053
+ fields.push(
4054
+ "retryDelay",
4055
+ updates.retryDelay !== null ? updates.retryDelay.toString() : "null"
4056
+ );
4057
+ metadata.retryDelay = updates.retryDelay;
4058
+ }
4059
+ if (updates.retryBackoff !== void 0) {
4060
+ fields.push(
4061
+ "retryBackoff",
4062
+ updates.retryBackoff !== null ? updates.retryBackoff.toString() : "null"
4063
+ );
4064
+ metadata.retryBackoff = updates.retryBackoff;
4065
+ }
4066
+ if (updates.retryDelayMax !== void 0) {
4067
+ fields.push(
4068
+ "retryDelayMax",
4069
+ updates.retryDelayMax !== null ? updates.retryDelayMax.toString() : "null"
4070
+ );
4071
+ metadata.retryDelayMax = updates.retryDelayMax;
4072
+ }
3342
4073
  if (fields.length === 0) {
3343
4074
  log(`No fields to update for job ${jobId}`);
3344
4075
  return;
@@ -3813,7 +4544,13 @@ var RedisBackend = class {
3813
4544
  "createdAt",
3814
4545
  now.toString(),
3815
4546
  "updatedAt",
3816
- now.toString()
4547
+ now.toString(),
4548
+ "retryDelay",
4549
+ input.retryDelay !== null && input.retryDelay !== void 0 ? input.retryDelay.toString() : "null",
4550
+ "retryBackoff",
4551
+ input.retryBackoff !== null && input.retryBackoff !== void 0 ? input.retryBackoff.toString() : "null",
4552
+ "retryDelayMax",
4553
+ input.retryDelayMax !== null && input.retryDelayMax !== void 0 ? input.retryDelayMax.toString() : "null"
3817
4554
  ];
3818
4555
  await this.client.hmset(key, ...fields);
3819
4556
  await this.client.set(
@@ -3967,6 +4704,24 @@ var RedisBackend = class {
3967
4704
  if (updates.allowOverlap !== void 0) {
3968
4705
  fields.push("allowOverlap", updates.allowOverlap ? "true" : "false");
3969
4706
  }
4707
+ if (updates.retryDelay !== void 0) {
4708
+ fields.push(
4709
+ "retryDelay",
4710
+ updates.retryDelay !== null ? updates.retryDelay.toString() : "null"
4711
+ );
4712
+ }
4713
+ if (updates.retryBackoff !== void 0) {
4714
+ fields.push(
4715
+ "retryBackoff",
4716
+ updates.retryBackoff !== null ? updates.retryBackoff.toString() : "null"
4717
+ );
4718
+ }
4719
+ if (updates.retryDelayMax !== void 0) {
4720
+ fields.push(
4721
+ "retryDelayMax",
4722
+ updates.retryDelayMax !== null ? updates.retryDelayMax.toString() : "null"
4723
+ );
4724
+ }
3970
4725
  if (nextRunAt !== void 0) {
3971
4726
  const val = nextRunAt !== null ? nextRunAt.getTime().toString() : "null";
3972
4727
  fields.push("nextRunAt", val);
@@ -4085,7 +4840,10 @@ var RedisBackend = class {
4085
4840
  lastJobId: numOrNull(h.lastJobId),
4086
4841
  nextRunAt: dateOrNull(h.nextRunAt),
4087
4842
  createdAt: new Date(Number(h.createdAt)),
4088
- updatedAt: new Date(Number(h.updatedAt))
4843
+ updatedAt: new Date(Number(h.updatedAt)),
4844
+ retryDelay: numOrNull(h.retryDelay),
4845
+ retryBackoff: h.retryBackoff === "true" ? true : h.retryBackoff === "false" ? false : null,
4846
+ retryDelayMax: numOrNull(h.retryDelayMax)
4089
4847
  };
4090
4848
  }
4091
4849
  // ── Private helpers (filters) ─────────────────────────────────────────
@@ -4208,14 +4966,37 @@ var initJobQueue = (config) => {
4208
4966
  let backend;
4209
4967
  if (backendType === "postgres") {
4210
4968
  const pgConfig = config;
4211
- const pool = createPool(pgConfig.databaseConfig);
4212
- backend = new PostgresBackend(pool);
4969
+ if (pgConfig.pool) {
4970
+ backend = new PostgresBackend(pgConfig.pool);
4971
+ } else if (pgConfig.databaseConfig) {
4972
+ const pool = createPool(pgConfig.databaseConfig);
4973
+ backend = new PostgresBackend(pool);
4974
+ } else {
4975
+ throw new Error(
4976
+ 'PostgreSQL backend requires either "databaseConfig" or "pool" to be provided.'
4977
+ );
4978
+ }
4213
4979
  } else if (backendType === "redis") {
4214
- const redisConfig = config.redisConfig;
4215
- backend = new RedisBackend(redisConfig);
4980
+ const redisConfig = config;
4981
+ if (redisConfig.client) {
4982
+ backend = new RedisBackend(
4983
+ redisConfig.client,
4984
+ redisConfig.keyPrefix
4985
+ );
4986
+ } else if (redisConfig.redisConfig) {
4987
+ backend = new RedisBackend(redisConfig.redisConfig);
4988
+ } else {
4989
+ throw new Error(
4990
+ 'Redis backend requires either "redisConfig" or "client" to be provided.'
4991
+ );
4992
+ }
4216
4993
  } else {
4217
4994
  throw new Error(`Unknown backend: ${backendType}`);
4218
4995
  }
4996
+ const emitter = new events.EventEmitter();
4997
+ const emit = (event, data) => {
4998
+ emitter.emit(event, data);
4999
+ };
4219
5000
  const enqueueDueCronJobsImpl = async () => {
4220
5001
  const dueSchedules = await backend.getDueCronSchedules();
4221
5002
  let count = 0;
@@ -4243,7 +5024,10 @@ var initJobQueue = (config) => {
4243
5024
  priority: schedule.priority,
4244
5025
  timeoutMs: schedule.timeoutMs ?? void 0,
4245
5026
  forceKillOnTimeout: schedule.forceKillOnTimeout,
4246
- tags: schedule.tags
5027
+ tags: schedule.tags,
5028
+ retryDelay: schedule.retryDelay ?? void 0,
5029
+ retryBackoff: schedule.retryBackoff ?? void 0,
5030
+ retryDelayMax: schedule.retryDelayMax ?? void 0
4247
5031
  });
4248
5032
  const nextRunAt = getNextCronOccurrence(
4249
5033
  schedule.cronExpression,
@@ -4262,7 +5046,21 @@ var initJobQueue = (config) => {
4262
5046
  return {
4263
5047
  // Job queue operations
4264
5048
  addJob: withLogContext(
4265
- (job) => backend.addJob(job),
5049
+ async (job, options) => {
5050
+ const jobId = await backend.addJob(job, options);
5051
+ emit("job:added", { jobId, jobType: job.jobType });
5052
+ return jobId;
5053
+ },
5054
+ config.verbose ?? false
5055
+ ),
5056
+ addJobs: withLogContext(
5057
+ async (jobs, options) => {
5058
+ const jobIds = await backend.addJobs(jobs, options);
5059
+ for (let i = 0; i < jobIds.length; i++) {
5060
+ emit("job:added", { jobId: jobIds[i], jobType: jobs[i].jobType });
5061
+ }
5062
+ return jobIds;
5063
+ },
4266
5064
  config.verbose ?? false
4267
5065
  ),
4268
5066
  getJob: withLogContext(
@@ -4281,13 +5079,16 @@ var initJobQueue = (config) => {
4281
5079
  (filters, limit, offset) => backend.getJobs(filters, limit, offset),
4282
5080
  config.verbose ?? false
4283
5081
  ),
4284
- retryJob: (jobId) => backend.retryJob(jobId),
5082
+ retryJob: async (jobId) => {
5083
+ await backend.retryJob(jobId);
5084
+ emit("job:retried", { jobId });
5085
+ },
4285
5086
  cleanupOldJobs: (daysToKeep, batchSize) => backend.cleanupOldJobs(daysToKeep, batchSize),
4286
5087
  cleanupOldJobEvents: (daysToKeep, batchSize) => backend.cleanupOldJobEvents(daysToKeep, batchSize),
4287
- cancelJob: withLogContext(
4288
- (jobId) => backend.cancelJob(jobId),
4289
- config.verbose ?? false
4290
- ),
5088
+ cancelJob: withLogContext(async (jobId) => {
5089
+ await backend.cancelJob(jobId);
5090
+ emit("job:cancelled", { jobId });
5091
+ }, config.verbose ?? false),
4291
5092
  editJob: withLogContext(
4292
5093
  (jobId, updates) => backend.editJob(jobId, updates),
4293
5094
  config.verbose ?? false
@@ -4312,9 +5113,17 @@ var initJobQueue = (config) => {
4312
5113
  config.verbose ?? false
4313
5114
  ),
4314
5115
  // Job processing — automatically enqueues due cron jobs before each batch
4315
- createProcessor: (handlers, options) => createProcessor(backend, handlers, options, async () => {
4316
- await enqueueDueCronJobsImpl();
4317
- }),
5116
+ createProcessor: (handlers, options) => createProcessor(
5117
+ backend,
5118
+ handlers,
5119
+ options,
5120
+ async () => {
5121
+ await enqueueDueCronJobsImpl();
5122
+ },
5123
+ emit
5124
+ ),
5125
+ // Background supervisor — automated maintenance
5126
+ createSupervisor: (options) => createSupervisor(backend, options, emit),
4318
5127
  // Job events
4319
5128
  getJobEvents: withLogContext(
4320
5129
  (jobId) => backend.getJobEvents(jobId),
@@ -4361,7 +5170,10 @@ var initJobQueue = (config) => {
4361
5170
  tags: options.tags,
4362
5171
  timezone: options.timezone ?? "UTC",
4363
5172
  allowOverlap: options.allowOverlap ?? false,
4364
- nextRunAt
5173
+ nextRunAt,
5174
+ retryDelay: options.retryDelay ?? null,
5175
+ retryBackoff: options.retryBackoff ?? null,
5176
+ retryDelayMax: options.retryDelayMax ?? null
4365
5177
  };
4366
5178
  return backend.addCronSchedule(input);
4367
5179
  },
@@ -4413,6 +5225,23 @@ var initJobQueue = (config) => {
4413
5225
  () => enqueueDueCronJobsImpl(),
4414
5226
  config.verbose ?? false
4415
5227
  ),
5228
+ // Event hooks
5229
+ on: (event, listener) => {
5230
+ emitter.on(event, listener);
5231
+ },
5232
+ once: (event, listener) => {
5233
+ emitter.once(event, listener);
5234
+ },
5235
+ off: (event, listener) => {
5236
+ emitter.off(event, listener);
5237
+ },
5238
+ removeAllListeners: (event) => {
5239
+ if (event) {
5240
+ emitter.removeAllListeners(event);
5241
+ } else {
5242
+ emitter.removeAllListeners();
5243
+ }
5244
+ },
4416
5245
  // Advanced access
4417
5246
  getPool: () => {
4418
5247
  if (!(backend instanceof PostgresBackend)) {