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