@warlock.js/queue 5.13.0 → 5.15.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 +27 -0
- package/README.md +43 -7
- package/cjs/index.cjs +525 -29
- package/cjs/index.cjs.map +1 -1
- package/esm/dashboard-boot.d.mts +20 -0
- package/esm/dashboard-boot.d.mts.map +1 -0
- package/esm/dashboard-boot.mjs +38 -0
- package/esm/dashboard-boot.mjs.map +1 -0
- package/esm/dashboard-guard-plugin.mjs +28 -0
- package/esm/dashboard-guard-plugin.mjs.map +1 -0
- package/esm/dashboard-middleware-adapter.mjs +39 -0
- package/esm/dashboard-middleware-adapter.mjs.map +1 -0
- package/esm/dashboard.d.mts +8 -0
- package/esm/dashboard.d.mts.map +1 -1
- package/esm/dashboard.mjs +3 -1
- package/esm/dashboard.mjs.map +1 -1
- package/esm/define-job.mjs +13 -6
- package/esm/define-job.mjs.map +1 -1
- package/esm/index.d.mts +4 -2
- package/esm/index.mjs +3 -1
- package/esm/queue-connector.d.mts.map +1 -1
- package/esm/queue-connector.mjs +17 -1
- package/esm/queue-connector.mjs.map +1 -1
- package/esm/queue-dashboard-unguarded.error.d.mts +13 -0
- package/esm/queue-dashboard-unguarded.error.d.mts.map +1 -0
- package/esm/queue-dashboard-unguarded.error.mjs +17 -0
- package/esm/queue-dashboard-unguarded.error.mjs.map +1 -0
- package/esm/types.d.mts +19 -2
- package/esm/types.d.mts.map +1 -1
- package/llms-full.txt +45 -9
- package/llms.txt +2 -2
- package/package.json +3 -17
- package/skills/configure-queue/SKILL.md +4 -0
- package/skills/manage-failed-jobs/SKILL.md +33 -4
- package/skills/overview/SKILL.md +1 -1
- package/skills/queue-notifications/SKILL.md +6 -4
- package/cjs/define-job-DideGKQK.cjs +0 -468
- package/cjs/define-job-DideGKQK.cjs.map +0 -1
- package/cjs/notifications/index.cjs +0 -68
- package/cjs/notifications/index.cjs.map +0 -1
- package/esm/notifications/index.d.mts +0 -2
- package/esm/notifications/index.mjs +0 -3
- package/esm/notifications/queue-notification-dispatcher.d.mts +0 -34
- package/esm/notifications/queue-notification-dispatcher.d.mts.map +0 -1
- package/esm/notifications/queue-notification-dispatcher.mjs +0 -67
- package/esm/notifications/queue-notification-dispatcher.mjs.map +0 -1
package/cjs/index.cjs
CHANGED
|
@@ -1,7 +1,312 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
2
|
-
const require_define_job = require('./define-job-DideGKQK.cjs');
|
|
3
2
|
let _warlock_js_logger = require("@warlock.js/logger");
|
|
3
|
+
let bullmq = require("bullmq");
|
|
4
4
|
|
|
5
|
+
//#region ../queue/src/errors.ts
|
|
6
|
+
/**
|
|
7
|
+
* Thrown when the queue is used before `setQueueConfig` (or the queue
|
|
8
|
+
* connector) supplied a configuration.
|
|
9
|
+
*/
|
|
10
|
+
var QueueNotConfiguredError = class extends Error {
|
|
11
|
+
constructor() {
|
|
12
|
+
super("@warlock.js/queue is not configured. Add src/config/queue.ts exporting a QueueConfig and register queueConnector() in warlock.config.ts > connectors, or call setQueueConfig() yourself.");
|
|
13
|
+
this.name = "QueueNotConfiguredError";
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Thrown for a malformed duration such as `"10 minutes"`.
|
|
18
|
+
*/
|
|
19
|
+
var InvalidDurationError = class extends Error {
|
|
20
|
+
constructor(value) {
|
|
21
|
+
super(`Invalid duration ${JSON.stringify(value)}: expected milliseconds as a non-negative number or a string like "500ms", "30s", "10m", "2h", "1d".`);
|
|
22
|
+
this.name = "InvalidDurationError";
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Thrown by `defineJob` for an invalid definition.
|
|
27
|
+
*/
|
|
28
|
+
var InvalidJobDefinitionError = class extends Error {
|
|
29
|
+
constructor(message) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "InvalidJobDefinitionError";
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Thrown by `retryFailedJob` when no failed job has the given id.
|
|
36
|
+
*/
|
|
37
|
+
var FailedJobNotFoundError = class extends Error {
|
|
38
|
+
constructor(id, queue) {
|
|
39
|
+
super(`No failed job with id "${id}" on queue "${queue}".`);
|
|
40
|
+
this.name = "FailedJobNotFoundError";
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Thrown by `queueDashboard` when an optional bull-board package is not
|
|
45
|
+
* installed.
|
|
46
|
+
*/
|
|
47
|
+
var QueueDashboardDependencyError = class extends Error {
|
|
48
|
+
constructor(missing) {
|
|
49
|
+
super(`The queue dashboard needs the optional package "${missing}", which is not installed.\nInstall both bull-board packages:
|
|
50
|
+
|
|
51
|
+
npm install @bull-board/api @bull-board/fastify
|
|
52
|
+
`);
|
|
53
|
+
this.name = "QueueDashboardDependencyError";
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region ../queue/src/config.ts
|
|
59
|
+
let activeConfig;
|
|
60
|
+
/**
|
|
61
|
+
* Set the active queue configuration. In a Warlock app the queue connector
|
|
62
|
+
* calls this at boot with `src/config/queue.ts`; scripts and tests may call
|
|
63
|
+
* it directly. Replaces (does not merge) any previous configuration.
|
|
64
|
+
*/
|
|
65
|
+
function setQueueConfig(config) {
|
|
66
|
+
activeConfig = config;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The active queue configuration. Throws {@link QueueNotConfiguredError}
|
|
70
|
+
* when none was set.
|
|
71
|
+
*/
|
|
72
|
+
function getQueueConfig() {
|
|
73
|
+
if (!activeConfig) throw new QueueNotConfiguredError();
|
|
74
|
+
return activeConfig;
|
|
75
|
+
}
|
|
76
|
+
/** Forget the active configuration. */
|
|
77
|
+
function resetQueueConfig() {
|
|
78
|
+
activeConfig = void 0;
|
|
79
|
+
}
|
|
80
|
+
/** The queue a job runs on when it names none. */
|
|
81
|
+
function defaultQueueName() {
|
|
82
|
+
return activeConfig?.defaultQueue ?? "default";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region ../queue/src/dashboard-middleware-adapter.ts
|
|
87
|
+
/**
|
|
88
|
+
* Run a Warlock middleware list against a raw Fastify request/reply pair.
|
|
89
|
+
*
|
|
90
|
+
* Bull-board's `FastifyAdapter` hands back a plain Fastify plugin, not a
|
|
91
|
+
* Warlock route — there is no `Route`, no validation pipeline, and none of
|
|
92
|
+
* `createRequestStore`'s context wiring (CSP, tracing, `@warlock.js/context`
|
|
93
|
+
* stores). Reusing that full pipeline here would pull the whole request
|
|
94
|
+
* machinery into a place it was never meant to run. Instead this builds the
|
|
95
|
+
* minimal `Request`/`Response` pair — enough for guard-style middleware
|
|
96
|
+
* (`authMiddleware`, `ipFilterMiddleware`, a custom check) to read the
|
|
97
|
+
* request and short-circuit with a response, which covers every one of
|
|
98
|
+
* bull-board's routes because they all sit behind the same hook.
|
|
99
|
+
*
|
|
100
|
+
* `@warlock.js/core` is imported dynamically so this module never drags
|
|
101
|
+
* core's runtime graph into a process that never mounts the dashboard.
|
|
102
|
+
*
|
|
103
|
+
* @returns `true` when a middleware sent a response and the caller must not
|
|
104
|
+
* continue (bull-board's handler must not run); `false` to continue.
|
|
105
|
+
*/
|
|
106
|
+
async function runDashboardMiddleware(middlewareList, fastifyRequest, fastifyReply) {
|
|
107
|
+
if (middlewareList.length === 0) return false;
|
|
108
|
+
const { Request, Response } = await import("@warlock.js/core");
|
|
109
|
+
const request = new Request();
|
|
110
|
+
const response = new Response();
|
|
111
|
+
response.setResponse(fastifyReply);
|
|
112
|
+
request.response = response;
|
|
113
|
+
response.request = request;
|
|
114
|
+
request.setRequest(fastifyRequest);
|
|
115
|
+
for (const middlewareFunction of middlewareList) if (await middlewareFunction({
|
|
116
|
+
request,
|
|
117
|
+
response
|
|
118
|
+
})) return true;
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
//#endregion
|
|
123
|
+
//#region ../queue/src/dashboard-guard-plugin.ts
|
|
124
|
+
/**
|
|
125
|
+
* Wrap bull-board's Fastify plugin in a scope that runs `middlewareList`
|
|
126
|
+
* on every request before bull-board's own routes see it.
|
|
127
|
+
*
|
|
128
|
+
* Bull-board's `FastifyAdapter.registerPlugin()` returns a plain Fastify
|
|
129
|
+
* plugin with no hook for Warlock middleware to run through, and Fastify
|
|
130
|
+
* only lets an `onRequest` hook be added to a plugin scope — never spliced
|
|
131
|
+
* into a plugin someone else wrote. So this builds ONE plugin that adds the
|
|
132
|
+
* hook to its own scope and then registers bull-board's plugin as a child of
|
|
133
|
+
* that scope; Fastify's encapsulation runs the hook for every route the
|
|
134
|
+
* child registers, which is every dashboard route.
|
|
135
|
+
*/
|
|
136
|
+
function buildDashboardGuardPlugin(middlewareList, bullBoardPlugin) {
|
|
137
|
+
return function dashboardGuardPlugin(instance, _options, done) {
|
|
138
|
+
if (middlewareList.length > 0) instance.addHook("onRequest", async (request, reply) => {
|
|
139
|
+
if (await runDashboardMiddleware(middlewareList, request, reply)) return reply;
|
|
140
|
+
});
|
|
141
|
+
instance.register(bullBoardPlugin);
|
|
142
|
+
done();
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
//#endregion
|
|
147
|
+
//#region ../queue/src/job-registry.ts
|
|
148
|
+
const jobs = /* @__PURE__ */ new Map();
|
|
149
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
150
|
+
/**
|
|
151
|
+
* Register a definition under its name.
|
|
152
|
+
*
|
|
153
|
+
* Re-registering a name REPLACES the previous definition: in development a
|
|
154
|
+
* job module is re-evaluated on every reload, and refusing the second
|
|
155
|
+
* evaluation would break the reload. Job names must therefore be unique
|
|
156
|
+
* across the app — two different modules using one name leave only the
|
|
157
|
+
* later handler active.
|
|
158
|
+
*/
|
|
159
|
+
function registerJob(job) {
|
|
160
|
+
jobs.set(job.name, job);
|
|
161
|
+
for (const listener of listeners) listener(job);
|
|
162
|
+
}
|
|
163
|
+
/** The definition registered under `name`, if any. */
|
|
164
|
+
function findRegisteredJob(name) {
|
|
165
|
+
return jobs.get(name);
|
|
166
|
+
}
|
|
167
|
+
/** Every registered definition. */
|
|
168
|
+
function registeredJobs() {
|
|
169
|
+
return [...jobs.values()];
|
|
170
|
+
}
|
|
171
|
+
/** The queue a definition runs on, resolved against the active config. */
|
|
172
|
+
function queueOf(job) {
|
|
173
|
+
return job.queue ?? defaultQueueName();
|
|
174
|
+
}
|
|
175
|
+
/** Be told whenever a job is registered. Returns an unsubscribe function. */
|
|
176
|
+
function onJobRegistered(listener) {
|
|
177
|
+
listeners.add(listener);
|
|
178
|
+
return () => listeners.delete(listener);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
//#endregion
|
|
182
|
+
//#region ../queue/src/process-job.ts
|
|
183
|
+
/**
|
|
184
|
+
* The single BullMQ processor every worker runs: route the job to the
|
|
185
|
+
* handler registered under its name.
|
|
186
|
+
*
|
|
187
|
+
* A name with no handler in this process fails with `UnrecoverableError` —
|
|
188
|
+
* retrying cannot make a missing definition appear, so it must not burn
|
|
189
|
+
* through its attempts.
|
|
190
|
+
*/
|
|
191
|
+
async function processJob(job) {
|
|
192
|
+
const definition = findRegisteredJob(job.name);
|
|
193
|
+
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.`);
|
|
194
|
+
const context = {
|
|
195
|
+
id: String(job.id),
|
|
196
|
+
name: job.name,
|
|
197
|
+
queue: job.queueName,
|
|
198
|
+
attempt: job.attemptsMade + 1,
|
|
199
|
+
maxAttempts: job.opts.attempts ?? 1,
|
|
200
|
+
progress: (value) => job.updateProgress(value),
|
|
201
|
+
log: async (line) => {
|
|
202
|
+
await job.log(line);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
return definition.handle(job.data, context);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
//#endregion
|
|
209
|
+
//#region ../queue/src/queue-manager.ts
|
|
210
|
+
const DEFAULT_SHUTDOWN_TIMEOUT = 3e4;
|
|
211
|
+
const queues = /* @__PURE__ */ new Map();
|
|
212
|
+
const workers = /* @__PURE__ */ new Map();
|
|
213
|
+
let stopListening;
|
|
214
|
+
/**
|
|
215
|
+
* The BullMQ queue for `name`, created on first use with the configured
|
|
216
|
+
* connection and prefix.
|
|
217
|
+
*/
|
|
218
|
+
function getQueue(name) {
|
|
219
|
+
let queue = queues.get(name);
|
|
220
|
+
if (!queue) {
|
|
221
|
+
const config = getQueueConfig();
|
|
222
|
+
queue = new bullmq.Queue(name, {
|
|
223
|
+
connection: config.connection,
|
|
224
|
+
prefix: config.prefix ?? "warlock"
|
|
225
|
+
});
|
|
226
|
+
queue.on("error", (error) => {
|
|
227
|
+
_warlock_js_logger.log.error("queue", "connection", error);
|
|
228
|
+
});
|
|
229
|
+
queues.set(name, queue);
|
|
230
|
+
}
|
|
231
|
+
return queue;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Start one worker per queue that has a registered job, and keep starting
|
|
235
|
+
* workers for queues whose first job is defined later.
|
|
236
|
+
*
|
|
237
|
+
* A no-op returning `[]` when `workers.enabled` is `false`. Calling it again
|
|
238
|
+
* while workers run starts only the missing ones.
|
|
239
|
+
*
|
|
240
|
+
* @returns the queue names that now have a worker in this process.
|
|
241
|
+
*/
|
|
242
|
+
async function startWorkers() {
|
|
243
|
+
if (getQueueConfig().workers?.enabled === false) return [];
|
|
244
|
+
for (const job of registeredJobs()) ensureWorker(queueOf(job));
|
|
245
|
+
stopListening ??= onJobRegistered((job) => {
|
|
246
|
+
ensureWorker(queueOf(job));
|
|
247
|
+
});
|
|
248
|
+
await Promise.all([...workers.values()].map((worker) => worker.waitUntilReady()));
|
|
249
|
+
return [...workers.keys()];
|
|
250
|
+
}
|
|
251
|
+
/** The queue names with a running worker in this process. */
|
|
252
|
+
function runningWorkers() {
|
|
253
|
+
return [...workers.keys()];
|
|
254
|
+
}
|
|
255
|
+
function ensureWorker(queueName) {
|
|
256
|
+
if (workers.has(queueName)) return;
|
|
257
|
+
const config = getQueueConfig();
|
|
258
|
+
const worker = new bullmq.Worker(queueName, processJob, {
|
|
259
|
+
connection: config.connection,
|
|
260
|
+
prefix: config.prefix ?? "warlock",
|
|
261
|
+
concurrency: config.workers?.concurrency ?? 1
|
|
262
|
+
});
|
|
263
|
+
worker.on("error", (error) => {
|
|
264
|
+
_warlock_js_logger.log.error("queue", "worker", error);
|
|
265
|
+
});
|
|
266
|
+
worker.on("failed", (job, error) => {
|
|
267
|
+
_warlock_js_logger.log.error("queue", "job.failed", `${job?.name ?? "unknown"} (${job?.id ?? "?"}): ${error.message}`);
|
|
268
|
+
});
|
|
269
|
+
workers.set(queueName, worker);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Graceful shutdown: stop workers taking new jobs and wait for active ones
|
|
273
|
+
* (bounded by `timeout`, then force-close), then close every queue
|
|
274
|
+
* connection. Safe to call when nothing was started, and more than once.
|
|
275
|
+
*/
|
|
276
|
+
async function closeQueue(options = {}) {
|
|
277
|
+
stopListening?.();
|
|
278
|
+
stopListening = void 0;
|
|
279
|
+
const timeout = options.timeout ?? configuredShutdownTimeout();
|
|
280
|
+
const closingWorkers = [...workers.values()];
|
|
281
|
+
workers.clear();
|
|
282
|
+
await Promise.all(closingWorkers.map((worker) => closeWorker(worker, timeout)));
|
|
283
|
+
const closingQueues = [...queues.values()];
|
|
284
|
+
queues.clear();
|
|
285
|
+
await Promise.all(closingQueues.map((queue) => queue.close()));
|
|
286
|
+
}
|
|
287
|
+
function configuredShutdownTimeout() {
|
|
288
|
+
try {
|
|
289
|
+
return getQueueConfig().workers?.shutdownTimeout ?? DEFAULT_SHUTDOWN_TIMEOUT;
|
|
290
|
+
} catch {
|
|
291
|
+
return DEFAULT_SHUTDOWN_TIMEOUT;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
async function closeWorker(worker, timeout) {
|
|
295
|
+
let timer;
|
|
296
|
+
const timedOut = new Promise((resolve) => {
|
|
297
|
+
timer = setTimeout(() => resolve("timeout"), timeout);
|
|
298
|
+
});
|
|
299
|
+
const closing = worker.close().then(() => "closed");
|
|
300
|
+
closing.catch(() => void 0);
|
|
301
|
+
const outcome = await Promise.race([closing, timedOut]);
|
|
302
|
+
clearTimeout(timer);
|
|
303
|
+
if (outcome === "timeout") {
|
|
304
|
+
_warlock_js_logger.log.warn("queue", "shutdown", `Worker for "${worker.name}" still had active jobs after ${timeout}ms; disconnecting. Those jobs are retried once their lock expires.`);
|
|
305
|
+
await worker.disconnect();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
//#endregion
|
|
5
310
|
//#region ../queue/src/dashboard.ts
|
|
6
311
|
/**
|
|
7
312
|
* Mount the bull-board failed/active/delayed job UI on Warlock's Fastify
|
|
@@ -19,14 +324,15 @@ let _warlock_js_logger = require("@warlock.js/logger");
|
|
|
19
324
|
async function queueDashboard(server, options = {}) {
|
|
20
325
|
const basePath = options.basePath ?? "/admin/queues";
|
|
21
326
|
const { createBullBoard, BullMQAdapter, FastifyAdapter } = await loadBullBoard();
|
|
22
|
-
const queueNames = options.queues ?? [...new Set([
|
|
327
|
+
const queueNames = options.queues ?? [...new Set([defaultQueueName(), ...registeredJobs().map((job) => queueOf(job))])];
|
|
23
328
|
const serverAdapter = new FastifyAdapter();
|
|
24
329
|
serverAdapter.setBasePath(basePath);
|
|
25
330
|
createBullBoard({
|
|
26
|
-
queues: queueNames.map((name) => new BullMQAdapter(
|
|
331
|
+
queues: queueNames.map((name) => new BullMQAdapter(getQueue(name))),
|
|
27
332
|
serverAdapter
|
|
28
333
|
});
|
|
29
|
-
|
|
334
|
+
const guardedPlugin = buildDashboardGuardPlugin(options.middleware ?? [], serverAdapter.registerPlugin());
|
|
335
|
+
await server.register(guardedPlugin, { prefix: basePath });
|
|
30
336
|
}
|
|
31
337
|
/**
|
|
32
338
|
* Load the optional bull-board packages. Exported for tests of the missing
|
|
@@ -46,7 +352,7 @@ async function importOptional(importer, specifier) {
|
|
|
46
352
|
try {
|
|
47
353
|
return await importer(specifier);
|
|
48
354
|
} catch (error) {
|
|
49
|
-
if (isModuleNotFound(error)) throw new
|
|
355
|
+
if (isModuleNotFound(error)) throw new QueueDashboardDependencyError(specifier.split("/").slice(0, 2).join("/"));
|
|
50
356
|
throw error;
|
|
51
357
|
}
|
|
52
358
|
}
|
|
@@ -55,6 +361,178 @@ function isModuleNotFound(error) {
|
|
|
55
361
|
return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
|
|
56
362
|
}
|
|
57
363
|
|
|
364
|
+
//#endregion
|
|
365
|
+
//#region ../queue/src/queue-dashboard-unguarded.error.ts
|
|
366
|
+
/**
|
|
367
|
+
* Thrown at boot when `queue.dashboard.enabled` is `true` in production with
|
|
368
|
+
* no guard middleware. The dashboard can retry and delete jobs; mounting it
|
|
369
|
+
* on the open internet without a guard is a production incident waiting to
|
|
370
|
+
* happen, so this fails the boot instead of shipping the hole.
|
|
371
|
+
*/
|
|
372
|
+
var QueueDashboardUnguardedError = class extends Error {
|
|
373
|
+
constructor() {
|
|
374
|
+
super("queue.dashboard.enabled is true in production with no middleware. The dashboard can retry and delete jobs, so it must be guarded before it is exposed.\n\nAdd a guard middleware:\n\n import { middleware } from \"@warlock.js/core\";\n import { authMiddleware } from \"@warlock.js/auth\";\n\n const queueConfig: QueueConfig = {\n // ...\n dashboard: {\n enabled: true,\n middleware: [authMiddleware(\"admin\")],\n },\n };\n");
|
|
375
|
+
this.name = "QueueDashboardUnguardedError";
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
//#endregion
|
|
380
|
+
//#region ../queue/src/dashboard-boot.ts
|
|
381
|
+
/** Default path the dashboard mounts on when `dashboard.path` is not set. */
|
|
382
|
+
const DEFAULT_DASHBOARD_PATH = "/admin/queues";
|
|
383
|
+
/**
|
|
384
|
+
* Mount the bull-board dashboard for `queue.dashboard` in `config`, applying
|
|
385
|
+
* the safety rule: `enabled` in production with no middleware throws
|
|
386
|
+
* {@link QueueDashboardUnguardedError} instead of booting exposed; outside
|
|
387
|
+
* production with no middleware it logs one warning and mounts anyway.
|
|
388
|
+
*
|
|
389
|
+
* Called by `queueConnector()` at boot, once the HTTP server exists but
|
|
390
|
+
* before it starts listening — see `queue-connector.ts`. Exported so it can
|
|
391
|
+
* be unit-tested without going through the whole connector lifecycle.
|
|
392
|
+
*/
|
|
393
|
+
async function mountQueueDashboard(server, config) {
|
|
394
|
+
const dashboard = config.dashboard;
|
|
395
|
+
if (!dashboard?.enabled) return;
|
|
396
|
+
const middlewareList = dashboard.middleware ?? [];
|
|
397
|
+
if (middlewareList.length === 0) {
|
|
398
|
+
if (process.env.NODE_ENV === "production") throw new QueueDashboardUnguardedError();
|
|
399
|
+
_warlock_js_logger.log.warn("queue", "dashboard", "queue.dashboard.enabled is true with no middleware. The dashboard can retry and delete jobs — add a guard middleware before this ships to production.");
|
|
400
|
+
}
|
|
401
|
+
if (!server) {
|
|
402
|
+
_warlock_js_logger.log.warn("queue", "dashboard", "queue.dashboard.enabled is true but no HTTP server was found; the dashboard was not mounted.");
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
await queueDashboard(server, {
|
|
406
|
+
basePath: dashboard.path ?? "/admin/queues",
|
|
407
|
+
middleware: middlewareList
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
//#endregion
|
|
412
|
+
//#region ../queue/src/duration.ts
|
|
413
|
+
const UNIT_MILLISECONDS = {
|
|
414
|
+
ms: 1,
|
|
415
|
+
s: 1e3,
|
|
416
|
+
m: 6e4,
|
|
417
|
+
h: 36e5,
|
|
418
|
+
d: 864e5
|
|
419
|
+
};
|
|
420
|
+
const DURATION_PATTERN = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/;
|
|
421
|
+
/**
|
|
422
|
+
* Convert a {@link Duration} to milliseconds. Numbers are already
|
|
423
|
+
* milliseconds. Anything else is rejected loudly rather than guessed at.
|
|
424
|
+
*/
|
|
425
|
+
function toMilliseconds(value) {
|
|
426
|
+
if (typeof value === "number") {
|
|
427
|
+
if (!Number.isFinite(value) || value < 0) throw new InvalidDurationError(value);
|
|
428
|
+
return Math.round(value);
|
|
429
|
+
}
|
|
430
|
+
const match = typeof value === "string" ? DURATION_PATTERN.exec(value.trim()) : null;
|
|
431
|
+
if (!match) throw new InvalidDurationError(value);
|
|
432
|
+
return Math.round(Number(match[1]) * UNIT_MILLISECONDS[match[2]]);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
//#endregion
|
|
436
|
+
//#region ../queue/src/define-job.ts
|
|
437
|
+
/**
|
|
438
|
+
* Define a background job.
|
|
439
|
+
*
|
|
440
|
+
* The definition is registered by name so any worker in the process can run
|
|
441
|
+
* it; the returned object dispatches it with a typed payload.
|
|
442
|
+
*
|
|
443
|
+
* @example
|
|
444
|
+
* export const sendInvoice = defineJob({
|
|
445
|
+
* name: "invoices.send",
|
|
446
|
+
* attempts: 5,
|
|
447
|
+
* backoff: { type: "exponential", delay: 2000 },
|
|
448
|
+
* async handle(payload: { invoiceId: string }, ctx) {
|
|
449
|
+
* await ctx.progress(50);
|
|
450
|
+
* },
|
|
451
|
+
* });
|
|
452
|
+
*
|
|
453
|
+
* await sendInvoice.dispatch({ invoiceId: "42" }, { delay: "10m", priority: 1 });
|
|
454
|
+
*/
|
|
455
|
+
function defineJob(definition) {
|
|
456
|
+
assertValidDefinition(definition);
|
|
457
|
+
registerJob(definition);
|
|
458
|
+
return {
|
|
459
|
+
name: definition.name,
|
|
460
|
+
get queue() {
|
|
461
|
+
return queueOf(definition);
|
|
462
|
+
},
|
|
463
|
+
async dispatch(payload, options = {}) {
|
|
464
|
+
const queueName = queueOf(definition);
|
|
465
|
+
const job = await getQueue(queueName).add(definition.name, payload, toBullJobOptions(definition, options));
|
|
466
|
+
return {
|
|
467
|
+
id: String(job.id),
|
|
468
|
+
name: definition.name,
|
|
469
|
+
queue: queueName
|
|
470
|
+
};
|
|
471
|
+
},
|
|
472
|
+
async find(id) {
|
|
473
|
+
const queue = getQueue(queueOf(definition));
|
|
474
|
+
const initialJob = await queue.getJob(id);
|
|
475
|
+
if (!initialJob || initialJob.name !== definition.name) return;
|
|
476
|
+
const state = await initialJob.getState();
|
|
477
|
+
return toSnapshot(await queue.getJob(id) ?? initialJob, state);
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
function assertValidDefinition(definition) {
|
|
482
|
+
if (typeof definition.name !== "string" || definition.name.trim() === "") throw new InvalidJobDefinitionError("defineJob() requires a non-empty `name`.");
|
|
483
|
+
if (typeof definition.handle !== "function") throw new InvalidJobDefinitionError(`defineJob("${definition.name}") requires a \`handle(payload, ctx)\` function.`);
|
|
484
|
+
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}.`);
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Merge app defaults < job definition < dispatch options into BullMQ's shape.
|
|
488
|
+
*/
|
|
489
|
+
function toBullJobOptions(definition, options) {
|
|
490
|
+
const defaults = getQueueConfig().defaultJobOptions ?? {};
|
|
491
|
+
const attempts = options.attempts ?? definition.attempts ?? defaults.attempts;
|
|
492
|
+
const backoff = options.backoff ?? definition.backoff ?? defaults.backoff;
|
|
493
|
+
const removeOnComplete = definition.removeOnComplete ?? defaults.removeOnComplete;
|
|
494
|
+
const removeOnFail = definition.removeOnFail ?? defaults.removeOnFail;
|
|
495
|
+
const bullOptions = {};
|
|
496
|
+
if (attempts !== void 0) bullOptions.attempts = attempts;
|
|
497
|
+
if (backoff !== void 0) bullOptions.backoff = toBullBackoff(backoff);
|
|
498
|
+
if (removeOnComplete !== void 0) bullOptions.removeOnComplete = removeOnComplete;
|
|
499
|
+
if (removeOnFail !== void 0) bullOptions.removeOnFail = removeOnFail;
|
|
500
|
+
if (options.delay !== void 0) bullOptions.delay = toMilliseconds(options.delay);
|
|
501
|
+
if (options.priority !== void 0) bullOptions.priority = options.priority;
|
|
502
|
+
if (options.jobId !== void 0) bullOptions.jobId = options.jobId;
|
|
503
|
+
return bullOptions;
|
|
504
|
+
}
|
|
505
|
+
function toBullBackoff(backoff) {
|
|
506
|
+
return typeof backoff === "number" ? {
|
|
507
|
+
type: "fixed",
|
|
508
|
+
delay: backoff
|
|
509
|
+
} : backoff;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* A plain view of a BullMQ job.
|
|
513
|
+
*
|
|
514
|
+
* @param job The job to read fields from.
|
|
515
|
+
* @param state The job's state; pass a state read *before* `job` was
|
|
516
|
+
* fetched (or re-fetched) so the returned snapshot's fields are consistent
|
|
517
|
+
* with it. If omitted, the state is read from `job` directly.
|
|
518
|
+
*/
|
|
519
|
+
async function toSnapshot(job, state) {
|
|
520
|
+
const resolvedState = state ?? await job.getState();
|
|
521
|
+
return {
|
|
522
|
+
id: String(job.id),
|
|
523
|
+
name: job.name,
|
|
524
|
+
queue: job.queueName,
|
|
525
|
+
state: resolvedState,
|
|
526
|
+
payload: job.data,
|
|
527
|
+
progress: job.progress,
|
|
528
|
+
attemptsMade: job.attemptsMade,
|
|
529
|
+
result: job.returnvalue,
|
|
530
|
+
failedReason: job.failedReason || void 0,
|
|
531
|
+
createdAt: new Date(job.timestamp),
|
|
532
|
+
finishedAt: job.finishedOn ? new Date(job.finishedOn) : void 0
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
58
536
|
//#endregion
|
|
59
537
|
//#region ../queue/src/failed-jobs.ts
|
|
60
538
|
/**
|
|
@@ -62,16 +540,16 @@ function isModuleNotFound(error) {
|
|
|
62
540
|
* failed unrecoverably. Each entry can be retried.
|
|
63
541
|
*/
|
|
64
542
|
async function failedJobs(options = {}) {
|
|
65
|
-
return (await
|
|
543
|
+
return (await getQueue(options.queue ?? defaultQueueName()).getFailed(options.start ?? 0, options.end ?? 99)).map((job) => toFailedJob(job));
|
|
66
544
|
}
|
|
67
545
|
/**
|
|
68
546
|
* Retry one failed job by id: it goes back to waiting with its attempts reset.
|
|
69
547
|
* Throws {@link FailedJobNotFoundError} when no FAILED job has that id.
|
|
70
548
|
*/
|
|
71
549
|
async function retryFailedJob(id, options = {}) {
|
|
72
|
-
const queueName = options.queue ??
|
|
73
|
-
const job = await
|
|
74
|
-
if (!job || !await job.isFailed()) throw new
|
|
550
|
+
const queueName = options.queue ?? defaultQueueName();
|
|
551
|
+
const job = await getQueue(queueName).getJob(id);
|
|
552
|
+
if (!job || !await job.isFailed()) throw new FailedJobNotFoundError(id, queueName);
|
|
75
553
|
await job.retry("failed");
|
|
76
554
|
}
|
|
77
555
|
function toFailedJob(job) {
|
|
@@ -119,15 +597,30 @@ function queueConnector(options = {}) {
|
|
|
119
597
|
priority: 11,
|
|
120
598
|
lifecyclePhase: "late",
|
|
121
599
|
isActive: () => active,
|
|
122
|
-
|
|
600
|
+
/**
|
|
601
|
+
* Mounts the dashboard, when configured, here rather than in `start()`:
|
|
602
|
+
* `boot()` runs for every late-phase connector, in priority order, before
|
|
603
|
+
* any of them `start()`s — so by the time this runs, the HTTP connector
|
|
604
|
+
* (priority 5, before queue's 11) has already built its Fastify instance
|
|
605
|
+
* and registered its own plugins, but has not yet called `listen()`.
|
|
606
|
+
* Fastify refuses new plugin registrations after `listen()`, so this is
|
|
607
|
+
* the only point in the boot sequence where mounting is possible.
|
|
608
|
+
*/
|
|
609
|
+
async boot() {
|
|
610
|
+
const queueConfig = options.config ?? await readQueueConfig();
|
|
611
|
+
if (!queueConfig?.dashboard?.enabled) return;
|
|
612
|
+
setQueueConfig(queueConfig);
|
|
613
|
+
const { getHttpServer } = await import("@warlock.js/core");
|
|
614
|
+
await mountQueueDashboard(getHttpServer(), queueConfig);
|
|
615
|
+
},
|
|
123
616
|
async start() {
|
|
124
617
|
const queueConfig = options.config ?? await readQueueConfig();
|
|
125
618
|
if (!queueConfig) {
|
|
126
619
|
_warlock_js_logger.log.warn("queue", "configured", "queueConnector() is registered but no `queue` config was found (src/config/queue.ts); queue not started");
|
|
127
620
|
return;
|
|
128
621
|
}
|
|
129
|
-
|
|
130
|
-
const started = await
|
|
622
|
+
setQueueConfig(queueConfig);
|
|
623
|
+
const started = await startWorkers();
|
|
131
624
|
active = true;
|
|
132
625
|
_warlock_js_logger.log.info("queue", "configured", started.length > 0 ? `Queue workers running for: ${started.join(", ")}` : "Queue configured (no in-process workers)");
|
|
133
626
|
},
|
|
@@ -137,8 +630,8 @@ function queueConnector(options = {}) {
|
|
|
137
630
|
},
|
|
138
631
|
async shutdown() {
|
|
139
632
|
if (!active) return;
|
|
140
|
-
await
|
|
141
|
-
|
|
633
|
+
await closeQueue();
|
|
634
|
+
resetQueueConfig();
|
|
142
635
|
active = false;
|
|
143
636
|
},
|
|
144
637
|
shouldRestart(changedFiles) {
|
|
@@ -156,25 +649,28 @@ async function readQueueConfig() {
|
|
|
156
649
|
}
|
|
157
650
|
|
|
158
651
|
//#endregion
|
|
159
|
-
exports.
|
|
160
|
-
exports.
|
|
161
|
-
exports.
|
|
652
|
+
exports.DEFAULT_DASHBOARD_PATH = DEFAULT_DASHBOARD_PATH;
|
|
653
|
+
exports.FailedJobNotFoundError = FailedJobNotFoundError;
|
|
654
|
+
exports.InvalidDurationError = InvalidDurationError;
|
|
655
|
+
exports.InvalidJobDefinitionError = InvalidJobDefinitionError;
|
|
162
656
|
exports.QUEUE_CONNECTOR_PRIORITY = QUEUE_CONNECTOR_PRIORITY;
|
|
163
|
-
exports.QueueDashboardDependencyError =
|
|
164
|
-
exports.
|
|
165
|
-
exports.
|
|
166
|
-
exports.
|
|
167
|
-
exports.
|
|
657
|
+
exports.QueueDashboardDependencyError = QueueDashboardDependencyError;
|
|
658
|
+
exports.QueueDashboardUnguardedError = QueueDashboardUnguardedError;
|
|
659
|
+
exports.QueueNotConfiguredError = QueueNotConfiguredError;
|
|
660
|
+
exports.closeQueue = closeQueue;
|
|
661
|
+
exports.defaultQueueName = defaultQueueName;
|
|
662
|
+
exports.defineJob = defineJob;
|
|
168
663
|
exports.failedJobs = failedJobs;
|
|
169
|
-
exports.getQueue =
|
|
170
|
-
exports.getQueueConfig =
|
|
664
|
+
exports.getQueue = getQueue;
|
|
665
|
+
exports.getQueueConfig = getQueueConfig;
|
|
171
666
|
exports.loadBullBoard = loadBullBoard;
|
|
667
|
+
exports.mountQueueDashboard = mountQueueDashboard;
|
|
172
668
|
exports.queueConnector = queueConnector;
|
|
173
669
|
exports.queueDashboard = queueDashboard;
|
|
174
|
-
exports.resetQueueConfig =
|
|
670
|
+
exports.resetQueueConfig = resetQueueConfig;
|
|
175
671
|
exports.retryFailedJob = retryFailedJob;
|
|
176
|
-
exports.runningWorkers =
|
|
177
|
-
exports.setQueueConfig =
|
|
178
|
-
exports.startWorkers =
|
|
179
|
-
exports.toMilliseconds =
|
|
672
|
+
exports.runningWorkers = runningWorkers;
|
|
673
|
+
exports.setQueueConfig = setQueueConfig;
|
|
674
|
+
exports.startWorkers = startWorkers;
|
|
675
|
+
exports.toMilliseconds = toMilliseconds;
|
|
180
676
|
//# sourceMappingURL=index.cjs.map
|