@warlock.js/queue 5.42.0 → 5.44.0

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/CHANGELOG.md CHANGED
@@ -4,6 +4,13 @@ All notable changes to `@warlock.js/queue` are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
6
6
 
7
+ ## 5.43.0 - 2026-11-01
8
+
9
+ ### Added
10
+
11
+ - Job timeouts: `defineJob({ timeout: "2m", failOnTimeout?: boolean })`, also accepted per dispatch and in `queue.defaultJobOptions` (dispatch > `defineJob` > defaults). An attempt that outlives its limit fails with the new exported `JobTimeoutError` (job name, limit in `timeout`, `failOnTimeout`), so failed jobs and events show a clear reason, and normal `attempts` and `backoff` apply. `failOnTimeout: true` fails the job without retrying (BullMQ `UnrecoverableError` on Redis). Enforced in the shared processing path, so BullMQ, `local` and `memory` behave the same. A zero, negative or unparsable timeout throws.
12
+ - `ctx.signal` is now passed to handlers on every driver whenever the job has a `timeout`, and aborts with the `JobTimeoutError`. A handler that ignores the signal keeps running in the background after its attempt fails, so pass the signal to `fetch` and database calls.
13
+
7
14
  ## 5.34.3 - 2026-10-20
8
15
 
9
16
  ### Changed
package/cjs/index.cjs CHANGED
@@ -67,6 +67,20 @@ var UnrecoverableJobError = class extends Error {
67
67
  this.name = "UnrecoverableJobError";
68
68
  }
69
69
  };
70
+ /**
71
+ * The failure recorded when a job attempt outlives its `timeout`. It appears
72
+ * as the failed reason of the attempt and as the abort reason of
73
+ * `ctx.signal`. With `failOnTimeout` the job is not retried.
74
+ */
75
+ var JobTimeoutError = class extends Error {
76
+ constructor(jobName, timeout, failOnTimeout = false) {
77
+ super(`Job "${jobName}" timed out after ${timeout}ms.`);
78
+ this.jobName = jobName;
79
+ this.timeout = timeout;
80
+ this.failOnTimeout = failOnTimeout;
81
+ this.name = "JobTimeoutError";
82
+ }
83
+ };
70
84
 
71
85
  //#endregion
72
86
  //#region ../../packages/queue/src/config.ts
@@ -192,6 +206,86 @@ function onJobRegistered(listener) {
192
206
  return () => listeners.delete(listener);
193
207
  }
194
208
 
209
+ //#endregion
210
+ //#region ../../packages/queue/src/duration.ts
211
+ const UNIT_MILLISECONDS = {
212
+ ms: 1,
213
+ s: 1e3,
214
+ m: 6e4,
215
+ h: 36e5,
216
+ d: 864e5
217
+ };
218
+ const DURATION_PATTERN = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/;
219
+ /**
220
+ * Convert a {@link Duration} to milliseconds. Numbers are already
221
+ * milliseconds. Anything else is rejected loudly rather than guessed at.
222
+ */
223
+ function toMilliseconds(value) {
224
+ if (typeof value === "number") {
225
+ if (!Number.isFinite(value) || value < 0) throw new InvalidDurationError(value);
226
+ return Math.round(value);
227
+ }
228
+ const match = typeof value === "string" ? DURATION_PATTERN.exec(value.trim()) : null;
229
+ if (!match) throw new InvalidDurationError(value);
230
+ return Math.round(Number(match[1]) * UNIT_MILLISECONDS[match[2]]);
231
+ }
232
+
233
+ //#endregion
234
+ //#region ../../packages/queue/src/job-timeout.ts
235
+ /**
236
+ * Convert a `timeout` option to milliseconds. Unparsable values throw
237
+ * `InvalidDurationError`; zero and negative limits throw
238
+ * `InvalidJobDefinitionError` — a timeout is never silently repaired or ignored.
239
+ */
240
+ function resolveTimeout(value, label = "timeout") {
241
+ const milliseconds = toMilliseconds(value);
242
+ if (milliseconds <= 0) throw new InvalidJobDefinitionError(`\`${label}\` must be greater than zero, got ${JSON.stringify(value)}.`);
243
+ return milliseconds;
244
+ }
245
+ /** Throw unless `failOnTimeout` is a boolean (or unset). */
246
+ function assertFailOnTimeout(value, label = "failOnTimeout") {
247
+ if (value !== void 0 && typeof value !== "boolean") throw new InvalidJobDefinitionError(`\`${label}\` must be a boolean, got ${JSON.stringify(value)}.`);
248
+ }
249
+ /**
250
+ * One attempt's time limit, shared by every driver. Without a `timeout` it
251
+ * passes the parent signal through and adds nothing. On expiry the signal
252
+ * aborts with the `JobTimeoutError` and the attempt fails; a handler that
253
+ * ignores the signal keeps running in the background, because JavaScript
254
+ * cannot stop it.
255
+ */
256
+ function createJobAbort(name, settings, parent) {
257
+ const { timeout } = settings;
258
+ if (timeout === void 0) return {
259
+ signal: parent,
260
+ guard: (work) => work(),
261
+ dispose() {}
262
+ };
263
+ const controller = new AbortController();
264
+ const onParentAbort = () => controller.abort(parent?.reason);
265
+ if (parent?.aborted) controller.abort(parent.reason);
266
+ else parent?.addEventListener("abort", onParentAbort, { once: true });
267
+ let timer;
268
+ return {
269
+ signal: controller.signal,
270
+ guard(work) {
271
+ const expired = new Promise((_, reject) => {
272
+ timer = setTimeout(() => {
273
+ const error = new JobTimeoutError(name, timeout, settings.failOnTimeout === true);
274
+ controller.abort(error);
275
+ reject(error);
276
+ }, timeout);
277
+ });
278
+ const running = work();
279
+ running.catch(() => {});
280
+ return Promise.race([running, expired]);
281
+ },
282
+ dispose() {
283
+ clearTimeout(timer);
284
+ parent?.removeEventListener("abort", onParentAbort);
285
+ }
286
+ };
287
+ }
288
+
195
289
  //#endregion
196
290
  //#region ../../packages/queue/src/queue-context.ts
197
291
  /**
@@ -259,6 +353,7 @@ async function processJob(job) {
259
353
  const definition = findRegisteredJob(job.name);
260
354
  if (!definition) throw new bullmq.UnrecoverableError(`No job named "${job.name}" is defined in this process. Make sure the module that calls defineJob() is imported by the worker process.`);
261
355
  const { payload, context: captured } = unwrapPayload(job.data);
356
+ const abort = createJobAbort(job.name, job.opts);
262
357
  const context = {
263
358
  id: String(job.id),
264
359
  name: job.name,
@@ -269,15 +364,19 @@ async function processJob(job) {
269
364
  log: async (line) => {
270
365
  await job.log(line);
271
366
  },
272
- context: captured
367
+ context: captured,
368
+ signal: abort.signal
273
369
  };
274
370
  const run = async () => definition.handle(payload, context);
275
371
  const queueContext = getQueueContext();
276
372
  try {
277
- return captured !== void 0 && queueContext ? await queueContext.restore(captured, run) : await run();
373
+ return await abort.guard(async () => captured !== void 0 && queueContext ? queueContext.restore(captured, run) : run());
278
374
  } catch (error) {
279
375
  if (error instanceof UnrecoverableJobError) throw new bullmq.UnrecoverableError(error.message);
376
+ if (error instanceof JobTimeoutError && error.failOnTimeout) throw new bullmq.UnrecoverableError(error.message);
280
377
  throw error;
378
+ } finally {
379
+ abort.dispose();
281
380
  }
282
381
  }
283
382
 
@@ -601,6 +700,7 @@ var LocalDriver = class {
601
700
  }
602
701
  async execute(entry) {
603
702
  const { job, token, controller } = entry;
703
+ const abort = createJobAbort(job.name, job.options, controller.signal);
604
704
  try {
605
705
  const definition = findRegisteredJob(job.name);
606
706
  if (!definition) throw new UnrecoverableJobError(`No job named "${job.name}" is defined in this process. Import its definition in the worker.`);
@@ -612,7 +712,7 @@ var LocalDriver = class {
612
712
  attempt: job.attemptsMade + 1,
613
713
  maxAttempts: job.options.attempts ?? 1,
614
714
  context: captured,
615
- signal: controller.signal,
715
+ signal: abort.signal,
616
716
  progress: async (value) => {
617
717
  if (!this.mutate(job, token, (current) => {
618
718
  current.progress = jsonCopy(value);
@@ -626,7 +726,7 @@ var LocalDriver = class {
626
726
  };
627
727
  const run = async () => definition.handle(payload, context);
628
728
  const queueContext = getQueueContext();
629
- const result = jsonCopy(captured !== void 0 && queueContext ? await queueContext.restore(captured, run) : await run());
729
+ const result = jsonCopy(await abort.guard(async () => captured !== void 0 && queueContext ? queueContext.restore(captured, run) : run()));
630
730
  this.mutate(job, token, (current) => {
631
731
  current.attemptsMade++;
632
732
  current.result = result;
@@ -642,7 +742,7 @@ var LocalDriver = class {
642
742
  current.stacktrace.push(error instanceof Error ? error.stack ?? error.message : String(error));
643
743
  current.leaseUntil = 0;
644
744
  delete current.token;
645
- if (error instanceof UnrecoverableJobError || current.attemptsMade >= (current.options.attempts ?? 1)) {
745
+ if (error instanceof UnrecoverableJobError || error instanceof JobTimeoutError && error.failOnTimeout || current.attemptsMade >= (current.options.attempts ?? 1)) {
646
746
  current.state = "failed";
647
747
  current.finishedAt = Date.now();
648
748
  } else {
@@ -653,6 +753,8 @@ var LocalDriver = class {
653
753
  current.state = delay > 0 ? "delayed" : current.priority ? "prioritized" : "waiting";
654
754
  }
655
755
  }, true);
756
+ } finally {
757
+ abort.dispose();
656
758
  }
657
759
  }
658
760
  /** Fencing: expired owners cannot renew, finish, or overwrite a new owner's job. */
@@ -700,6 +802,7 @@ function positiveInteger(value, name, minimum = 1) {
700
802
  function validateOptions({ options }) {
701
803
  positiveInteger(options.attempts ?? 1, "attempts");
702
804
  if (options.priority !== void 0 && (!Number.isInteger(options.priority) || options.priority < 0 || options.priority > 2097152)) throw new Error("priority must be an integer between 0 and 2097152.");
805
+ if (options.timeout !== void 0 && !(Number.isFinite(options.timeout) && options.timeout > 0)) throw new Error("timeout must be a positive finite number of milliseconds.");
703
806
  const backoff = options.backoff;
704
807
  const delay = typeof backoff === "number" ? backoff : backoff?.delay ?? 0;
705
808
  if (!Number.isFinite(delay) || delay < 0 || typeof backoff === "object" && !["fixed", "exponential"].includes(backoff.type)) throw new Error("backoff must have a non-negative finite delay and fixed or exponential type.");
@@ -1035,39 +1138,20 @@ function getDispatchInterceptor() {
1035
1138
  return globalThis[INTERCEPTOR_KEY];
1036
1139
  }
1037
1140
 
1038
- //#endregion
1039
- //#region ../../packages/queue/src/duration.ts
1040
- const UNIT_MILLISECONDS = {
1041
- ms: 1,
1042
- s: 1e3,
1043
- m: 6e4,
1044
- h: 36e5,
1045
- d: 864e5
1046
- };
1047
- const DURATION_PATTERN = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/;
1048
- /**
1049
- * Convert a {@link Duration} to milliseconds. Numbers are already
1050
- * milliseconds. Anything else is rejected loudly rather than guessed at.
1051
- */
1052
- function toMilliseconds(value) {
1053
- if (typeof value === "number") {
1054
- if (!Number.isFinite(value) || value < 0) throw new InvalidDurationError(value);
1055
- return Math.round(value);
1056
- }
1057
- const match = typeof value === "string" ? DURATION_PATTERN.exec(value.trim()) : null;
1058
- if (!match) throw new InvalidDurationError(value);
1059
- return Math.round(Number(match[1]) * UNIT_MILLISECONDS[match[2]]);
1060
- }
1061
-
1062
1141
  //#endregion
1063
1142
  //#region ../../packages/queue/src/job-options.ts
1064
1143
  function resolveJobOptions(definition, options) {
1065
1144
  const defaults = getQueueConfig().defaultJobOptions ?? {};
1145
+ const timeout = options.timeout ?? definition.timeout ?? defaults.timeout;
1146
+ const failOnTimeout = options.failOnTimeout ?? definition.failOnTimeout ?? defaults.failOnTimeout;
1147
+ assertFailOnTimeout(failOnTimeout);
1066
1148
  return {
1067
1149
  attempts: options.attempts ?? definition.attempts ?? defaults.attempts,
1068
1150
  backoff: options.backoff ?? definition.backoff ?? defaults.backoff,
1069
1151
  removeOnComplete: definition.removeOnComplete ?? defaults.removeOnComplete,
1070
1152
  removeOnFail: definition.removeOnFail ?? defaults.removeOnFail,
1153
+ timeout: timeout === void 0 ? void 0 : resolveTimeout(timeout),
1154
+ failOnTimeout,
1071
1155
  delay: options.delay === void 0 ? void 0 : toMilliseconds(options.delay),
1072
1156
  priority: options.priority,
1073
1157
  jobId: options.jobId
@@ -1135,6 +1219,12 @@ function assertValidDefinition(definition) {
1135
1219
  if (typeof definition.name !== "string" || definition.name.trim() === "") throw new InvalidJobDefinitionError("defineJob() requires a non-empty `name`.");
1136
1220
  if (typeof definition.handle !== "function") throw new InvalidJobDefinitionError(`defineJob("${definition.name}") requires a \`handle(payload, ctx)\` function.`);
1137
1221
  if (definition.attempts !== void 0 && !(Number.isInteger(definition.attempts) && definition.attempts >= 1)) throw new InvalidJobDefinitionError(`defineJob("${definition.name}"): \`attempts\` must be an integer >= 1, got ${definition.attempts}.`);
1222
+ if (definition.timeout !== void 0) try {
1223
+ resolveTimeout(definition.timeout);
1224
+ } catch (error) {
1225
+ throw new InvalidJobDefinitionError(`defineJob("${definition.name}"): ${error instanceof Error ? error.message : String(error)}`);
1226
+ }
1227
+ assertFailOnTimeout(definition.failOnTimeout);
1138
1228
  }
1139
1229
 
1140
1230
  //#endregion
@@ -1235,6 +1325,7 @@ exports.DEFAULT_DASHBOARD_PATH = DEFAULT_DASHBOARD_PATH;
1235
1325
  exports.FailedJobNotFoundError = FailedJobNotFoundError;
1236
1326
  exports.InvalidDurationError = InvalidDurationError;
1237
1327
  exports.InvalidJobDefinitionError = InvalidJobDefinitionError;
1328
+ exports.JobTimeoutError = JobTimeoutError;
1238
1329
  exports.QUEUE_CONNECTOR_PRIORITY = QUEUE_CONNECTOR_PRIORITY;
1239
1330
  exports.QueueDashboardDependencyError = QueueDashboardDependencyError;
1240
1331
  exports.QueueDashboardUnguardedError = QueueDashboardUnguardedError;