@warlock.js/queue 5.41.0 → 5.43.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 +7 -0
- package/cjs/index.cjs +120 -29
- package/cjs/index.cjs.map +1 -1
- package/esm/define-job.d.mts.map +1 -1
- package/esm/define-job.mjs +7 -0
- package/esm/define-job.mjs.map +1 -1
- package/esm/drivers/local.mjs +9 -4
- package/esm/drivers/local.mjs.map +1 -1
- package/esm/errors.d.mts +20 -0
- package/esm/errors.d.mts.map +1 -1
- package/esm/errors.mjs +15 -1
- package/esm/errors.mjs.map +1 -1
- package/esm/index.d.mts +2 -2
- package/esm/index.mjs +3 -3
- package/esm/job-options.d.mts +3 -1
- package/esm/job-options.d.mts.map +1 -1
- package/esm/job-options.mjs +6 -0
- package/esm/job-options.mjs.map +1 -1
- package/esm/job-timeout.mjs +61 -0
- package/esm/job-timeout.mjs.map +1 -0
- package/esm/process-job.mjs +9 -3
- package/esm/process-job.mjs.map +1 -1
- package/esm/types.d.mts +20 -1
- package/esm/types.d.mts.map +1 -1
- package/llms-full.txt +5 -1
- package/llms.txt +1 -1
- package/package.json +3 -3
- package/skills/define-jobs/SKILL.md +5 -1
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 ?
|
|
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:
|
|
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 ?
|
|
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;
|