@basaltkit/queue 1.2.0 → 1.2.1
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/README.md +6 -0
- package/dist/bridge.d.ts +18 -0
- package/dist/bridge.js +24 -0
- package/dist/driver.d.ts +72 -0
- package/dist/driver.js +1 -0
- package/dist/drivers/bullmq.d.ts +31 -0
- package/dist/drivers/bullmq.js +94 -0
- package/dist/drivers/sync.d.ts +26 -0
- package/dist/drivers/sync.js +36 -0
- package/dist/index.d.ts +13 -289
- package/dist/index.js +84 -426
- package/dist/job.d.ts +76 -0
- package/dist/job.js +59 -0
- package/dist/manager.d.ts +61 -0
- package/dist/manager.js +161 -0
- package/package.json +12 -13
package/dist/index.js
CHANGED
|
@@ -1,430 +1,88 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
1
|
+
import { createToken, definePlugin, ensureMetadata } from '@basaltkit/core';
|
|
2
|
+
import { BullmqQueueDriver } from './drivers/bullmq.js';
|
|
3
|
+
import { SyncQueueDriver } from './drivers/sync.js';
|
|
4
|
+
import { QueueManager } from './manager.js';
|
|
5
|
+
export { defineJob, JobValidationError, JobNotRegisteredError, } from './job.js';
|
|
6
|
+
export { QueueManager, UnknownJobError, UnsupportedJobOptionError, } from './manager.js';
|
|
7
|
+
export { queuedOn } from './bridge.js';
|
|
8
|
+
export { SyncQueueDriver } from './drivers/sync.js';
|
|
9
|
+
export { BullmqQueueDriver } from './drivers/bullmq.js';
|
|
10
|
+
export const QUEUE = createToken('queue');
|
|
11
|
+
export function queuePlugin(options = {}) {
|
|
12
|
+
return definePlugin({
|
|
13
|
+
name: 'basalt:queue',
|
|
14
|
+
register({ container }) {
|
|
15
|
+
registerQueueCommands(container);
|
|
16
|
+
container.singleton(QUEUE, () => {
|
|
17
|
+
const driver = options.driver ??
|
|
18
|
+
(options.connection
|
|
19
|
+
? new BullmqQueueDriver({ connection: options.connection })
|
|
20
|
+
: new SyncQueueDriver());
|
|
21
|
+
const manager = new QueueManager(driver, {
|
|
22
|
+
...(options.onUnsupported !== undefined ? { onUnsupported: options.onUnsupported } : {}),
|
|
23
|
+
...(options.removeOnComplete !== undefined ? { removeOnComplete: options.removeOnComplete } : {}),
|
|
24
|
+
...(options.removeOnFail !== undefined ? { removeOnFail: options.removeOnFail } : {}),
|
|
25
|
+
});
|
|
26
|
+
for (const job of options.jobs ?? [])
|
|
27
|
+
manager.register(job);
|
|
28
|
+
return manager;
|
|
29
|
+
});
|
|
30
|
+
},
|
|
31
|
+
boot({ container }) {
|
|
32
|
+
const manager = container.get(QUEUE);
|
|
33
|
+
for (const worker of options.workers ?? []) {
|
|
34
|
+
manager.work(worker.queue, worker.concurrency !== undefined ? { concurrency: worker.concurrency } : {});
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
async shutdown({ container }) {
|
|
38
|
+
await container.get(QUEUE).close();
|
|
39
|
+
},
|
|
35
40
|
});
|
|
36
|
-
}
|
|
37
|
-
startWorker(queue, options = {}) {
|
|
38
|
-
this.workers.push(
|
|
39
|
-
new Worker(queue, async (job) => this.executor?.(job.name, job.data), {
|
|
40
|
-
connection: this.connection,
|
|
41
|
-
concurrency: options.concurrency ?? 1
|
|
42
|
-
})
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
async stats(queue) {
|
|
46
|
-
const c = await this.queue(queue).getJobCounts("waiting", "active", "completed", "failed", "delayed");
|
|
47
|
-
return {
|
|
48
|
-
waiting: c["waiting"] ?? 0,
|
|
49
|
-
active: c["active"] ?? 0,
|
|
50
|
-
completed: c["completed"] ?? 0,
|
|
51
|
-
failed: c["failed"] ?? 0,
|
|
52
|
-
delayed: c["delayed"] ?? 0
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
async retryFailed(queue, options = {}) {
|
|
56
|
-
const limit = options.limit ?? 1e3;
|
|
57
|
-
const failed = await this.queue(queue).getFailed(0, limit - 1);
|
|
58
|
-
let retried = 0;
|
|
59
|
-
for (const job of failed) {
|
|
60
|
-
await job.retry();
|
|
61
|
-
retried++;
|
|
62
|
-
}
|
|
63
|
-
return retried;
|
|
64
|
-
}
|
|
65
|
-
async close() {
|
|
66
|
-
await Promise.all(this.workers.map((worker) => worker.close()));
|
|
67
|
-
await Promise.all([...this.queues.values()].map((queue) => queue.close()));
|
|
68
|
-
}
|
|
69
|
-
queue(name) {
|
|
70
|
-
let queue = this.queues.get(name);
|
|
71
|
-
if (!queue) {
|
|
72
|
-
queue = new Queue(name, { connection: this.connection });
|
|
73
|
-
this.queues.set(name, queue);
|
|
74
|
-
}
|
|
75
|
-
return queue;
|
|
76
|
-
}
|
|
77
|
-
};
|
|
78
|
-
function parseRedisUrl(url) {
|
|
79
|
-
const parsed = new URL(url);
|
|
80
|
-
return {
|
|
81
|
-
host: parsed.hostname,
|
|
82
|
-
port: parsed.port ? Number(parsed.port) : 6379,
|
|
83
|
-
...parsed.username ? { username: parsed.username } : {},
|
|
84
|
-
...parsed.password ? { password: parsed.password } : {},
|
|
85
|
-
...parsed.pathname && parsed.pathname !== "/" ? { db: Number(parsed.pathname.slice(1)) } : {},
|
|
86
|
-
...parsed.protocol === "rediss:" ? { tls: {} } : {},
|
|
87
|
-
// required by BullMQ for workers
|
|
88
|
-
maxRetriesPerRequest: null
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// src/drivers/sync.ts
|
|
93
|
-
var SyncQueueDriver = class {
|
|
94
|
-
name = "sync";
|
|
95
|
-
// Runs inline on dispatch: retries are honored (immediately), but there is no
|
|
96
|
-
// deferred delivery and no ordering, so delayed/priority are not supported.
|
|
97
|
-
capabilities = { delayed: false, priority: false, retries: true, backoff: false };
|
|
98
|
-
executor;
|
|
99
|
-
/** execution history — useful in test assertions */
|
|
100
|
-
executed = [];
|
|
101
|
-
setExecutor(executor) {
|
|
102
|
-
this.executor = executor;
|
|
103
|
-
}
|
|
104
|
-
async add(queue, jobName, data, options) {
|
|
105
|
-
let lastError;
|
|
106
|
-
for (let attempt = 1; attempt <= options.attempts; attempt++) {
|
|
107
|
-
try {
|
|
108
|
-
await this.executor?.(jobName, data);
|
|
109
|
-
this.executed.push({ queue, jobName, attempts: attempt });
|
|
110
|
-
return;
|
|
111
|
-
} catch (error) {
|
|
112
|
-
lastError = error;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
this.executed.push({ queue, jobName, attempts: options.attempts });
|
|
116
|
-
throw lastError;
|
|
117
|
-
}
|
|
118
|
-
startWorker() {
|
|
119
|
-
}
|
|
120
|
-
async close() {
|
|
121
|
-
}
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
// src/manager.ts
|
|
125
|
-
import {
|
|
126
|
-
BasaltError as BasaltError2,
|
|
127
|
-
parseDuration,
|
|
128
|
-
runWithContext,
|
|
129
|
-
tryCtx
|
|
130
|
-
} from "@basaltkit/core";
|
|
131
|
-
|
|
132
|
-
// src/job.ts
|
|
133
|
-
import { BasaltError } from "@basaltkit/core";
|
|
134
|
-
var JobValidationError = class extends BasaltError {
|
|
135
|
-
constructor(job, issues) {
|
|
136
|
-
super("JOB_INVALID", `Invalid payload for job "${job}": ${JSON.stringify(issues)}`);
|
|
137
|
-
this.job = job;
|
|
138
|
-
this.issues = issues;
|
|
139
|
-
}
|
|
140
|
-
job;
|
|
141
|
-
issues;
|
|
142
|
-
};
|
|
143
|
-
var JobNotRegisteredError = class extends BasaltError {
|
|
144
|
-
constructor(job) {
|
|
145
|
-
super(
|
|
146
|
-
"QUEUE_JOB_NOT_REGISTERED",
|
|
147
|
-
`Job "${job}" has not been registered in a QueueManager yet. Add it to queuePlugin({ jobs: [...] }) or call manager.register(job).`
|
|
148
|
-
);
|
|
149
|
-
}
|
|
150
|
-
};
|
|
151
|
-
function defineJob(config) {
|
|
152
|
-
let dispatcher;
|
|
153
|
-
const job = {
|
|
154
|
-
name: config.name,
|
|
155
|
-
schema: config.schema,
|
|
156
|
-
queue: config.queue ?? "default",
|
|
157
|
-
attempts: config.attempts ?? 1,
|
|
158
|
-
backoff: config.backoff,
|
|
159
|
-
removeOnComplete: config.removeOnComplete,
|
|
160
|
-
removeOnFail: config.removeOnFail,
|
|
161
|
-
handle: config.handle,
|
|
162
|
-
async dispatch(payload, options) {
|
|
163
|
-
if (!dispatcher) throw new JobNotRegisteredError(config.name);
|
|
164
|
-
return dispatcher.dispatch(job, payload, options);
|
|
165
|
-
},
|
|
166
|
-
__bind(d) {
|
|
167
|
-
dispatcher = d;
|
|
168
|
-
}
|
|
169
|
-
};
|
|
170
|
-
return job;
|
|
171
|
-
}
|
|
172
|
-
function validatePayload(job, payload) {
|
|
173
|
-
if (!job.schema) return payload;
|
|
174
|
-
const result = job.schema.safeParse(payload);
|
|
175
|
-
if (!result.success) {
|
|
176
|
-
const issues = result.error?.issues ?? result.error ?? "unknown";
|
|
177
|
-
throw new JobValidationError(job.name, issues);
|
|
178
|
-
}
|
|
179
|
-
return result.data;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// src/manager.ts
|
|
183
|
-
function resolveRetention(retention) {
|
|
184
|
-
if (retention === void 0) return void 0;
|
|
185
|
-
if (typeof retention === "boolean" || typeof retention === "number") return retention;
|
|
186
|
-
const out = {};
|
|
187
|
-
if (retention.age !== void 0) out.ageMs = parseDuration(retention.age);
|
|
188
|
-
if (retention.count !== void 0) out.count = retention.count;
|
|
189
|
-
return out;
|
|
190
|
-
}
|
|
191
|
-
var UnknownJobError = class extends BasaltError2 {
|
|
192
|
-
constructor(job) {
|
|
193
|
-
super(
|
|
194
|
-
"QUEUE_UNKNOWN_JOB",
|
|
195
|
-
`Job "${job}" reached the worker but is not registered in this process. Make sure the worker registers the same jobs as the producer.`
|
|
196
|
-
);
|
|
197
|
-
}
|
|
198
|
-
};
|
|
199
|
-
var UnsupportedJobOptionError = class extends BasaltError2 {
|
|
200
|
-
status = 500;
|
|
201
|
-
constructor(driver, job, features) {
|
|
202
|
-
super(
|
|
203
|
-
"QUEUE_UNSUPPORTED_OPTION",
|
|
204
|
-
`The "${driver}" queue driver does not support ${features.join(", ")} (job "${job}"). Use a driver that supports it, remove the option, or set queuePlugin({ onUnsupported: 'warn' | 'ignore' }).`
|
|
205
|
-
);
|
|
206
|
-
}
|
|
207
|
-
};
|
|
208
|
-
var requiredCapabilities = (options) => {
|
|
209
|
-
const needed = [];
|
|
210
|
-
if (options.delayMs !== void 0 && options.delayMs > 0) needed.push("delayed");
|
|
211
|
-
if (options.priority !== void 0) needed.push("priority");
|
|
212
|
-
if (options.attempts > 1) needed.push("retries");
|
|
213
|
-
if (options.backoff && options.attempts > 1) needed.push("backoff");
|
|
214
|
-
return needed;
|
|
215
|
-
};
|
|
216
|
-
var FEATURE_LABELS = {
|
|
217
|
-
delayed: "delayed jobs (delay)",
|
|
218
|
-
priority: "priority",
|
|
219
|
-
retries: "retries (attempts > 1)",
|
|
220
|
-
backoff: "retry backoff"
|
|
221
|
-
};
|
|
222
|
-
var SNAPSHOT_FIELDS = ["requestId", "correlationId", "traceId", "userId", "tenantId"];
|
|
223
|
-
var QueueManager = class {
|
|
224
|
-
constructor(driver, options = {}) {
|
|
225
|
-
this.driver = driver;
|
|
226
|
-
this.onUnsupported = options.onUnsupported ?? "warn";
|
|
227
|
-
this.warn = options.warn ?? ((message) => console.warn(message));
|
|
228
|
-
this.defaultRemoveOnComplete = options.removeOnComplete;
|
|
229
|
-
this.defaultRemoveOnFail = options.removeOnFail;
|
|
230
|
-
driver.setExecutor((jobName, data) => this.execute(jobName, data));
|
|
231
|
-
}
|
|
232
|
-
driver;
|
|
233
|
-
jobs = /* @__PURE__ */ new Map();
|
|
234
|
-
onUnsupported;
|
|
235
|
-
warn;
|
|
236
|
-
warned = /* @__PURE__ */ new Set();
|
|
237
|
-
defaultRemoveOnComplete;
|
|
238
|
-
defaultRemoveOnFail;
|
|
239
|
-
/**
|
|
240
|
-
* Checks the dispatch's options against the driver's declared capabilities.
|
|
241
|
-
* A driver that omits `capabilities` is assumed fully capable (back-compat).
|
|
242
|
-
*/
|
|
243
|
-
assertSupported(jobName, options) {
|
|
244
|
-
const caps = this.driver.capabilities;
|
|
245
|
-
if (!caps || this.onUnsupported === "ignore") return;
|
|
246
|
-
const missing = requiredCapabilities(options).filter((cap) => !caps[cap]);
|
|
247
|
-
if (missing.length === 0) return;
|
|
248
|
-
const driverName = this.driver.name ?? "queue";
|
|
249
|
-
const features = missing.map((cap) => FEATURE_LABELS[cap]);
|
|
250
|
-
if (this.onUnsupported === "throw") throw new UnsupportedJobOptionError(driverName, jobName, features);
|
|
251
|
-
const key = `${jobName}:${missing.join(",")}`;
|
|
252
|
-
if (this.warned.has(key)) return;
|
|
253
|
-
this.warned.add(key);
|
|
254
|
-
this.warn(
|
|
255
|
-
`[basalt/queue] The "${driverName}" driver does not support ${features.join(", ")} \u2014 job "${jobName}" will run without it.`
|
|
256
|
-
);
|
|
257
|
-
}
|
|
258
|
-
register(job) {
|
|
259
|
-
this.jobs.set(job.name, job);
|
|
260
|
-
job.__bind(this);
|
|
261
|
-
return this;
|
|
262
|
-
}
|
|
263
|
-
async dispatch(job, payload, options = {}) {
|
|
264
|
-
if (!this.jobs.has(job.name)) this.register(job);
|
|
265
|
-
const envelope = {
|
|
266
|
-
payload: validatePayload(job, payload),
|
|
267
|
-
context: snapshotContext()
|
|
268
|
-
};
|
|
269
|
-
const addOptions = {
|
|
270
|
-
attempts: job.attempts,
|
|
271
|
-
backoff: job.backoff ? { type: job.backoff.type, delayMs: parseDuration(job.backoff.delay) } : void 0,
|
|
272
|
-
delayMs: options.delay === void 0 ? void 0 : parseDuration(options.delay),
|
|
273
|
-
priority: options.priority,
|
|
274
|
-
// Per-job overrides the queuePlugin default; undefined leaves the driver default.
|
|
275
|
-
removeOnComplete: resolveRetention(job.removeOnComplete ?? this.defaultRemoveOnComplete),
|
|
276
|
-
removeOnFail: resolveRetention(job.removeOnFail ?? this.defaultRemoveOnFail)
|
|
277
|
-
};
|
|
278
|
-
this.assertSupported(job.name, addOptions);
|
|
279
|
-
await this.driver.add(job.queue, job.name, envelope, addOptions);
|
|
280
|
-
}
|
|
281
|
-
/** Starts a worker for the queue. With the sync driver it is a no-op. */
|
|
282
|
-
work(queue = "default", options = {}) {
|
|
283
|
-
this.driver.startWorker(queue, options);
|
|
284
|
-
}
|
|
285
|
-
/** Job counts per state, or `undefined` if the driver can't introspect. */
|
|
286
|
-
async stats(queue = "default") {
|
|
287
|
-
return this.driver.stats?.(queue);
|
|
288
|
-
}
|
|
289
|
-
/**
|
|
290
|
-
* Re-enqueues failed jobs; returns the count, or `undefined` if the driver
|
|
291
|
-
* doesn't support retrying (e.g. the inline sync driver).
|
|
292
|
-
*/
|
|
293
|
-
async retryFailed(queue = "default", options = {}) {
|
|
294
|
-
return this.driver.retryFailed?.(queue, options);
|
|
295
|
-
}
|
|
296
|
-
async close() {
|
|
297
|
-
await this.driver.close();
|
|
298
|
-
}
|
|
299
|
-
/** Executes a job received from the driver: validates, restores the context, runs the handler. */
|
|
300
|
-
async execute(jobName, data) {
|
|
301
|
-
const job = this.jobs.get(jobName);
|
|
302
|
-
if (!job) throw new UnknownJobError(jobName);
|
|
303
|
-
const envelope = data;
|
|
304
|
-
const payload = validatePayload(job, envelope.payload);
|
|
305
|
-
await runWithContext({ ...envelope.context ?? {} }, () => job.handle(payload));
|
|
306
|
-
}
|
|
307
|
-
};
|
|
308
|
-
function snapshotContext() {
|
|
309
|
-
const context = tryCtx();
|
|
310
|
-
if (!context) return void 0;
|
|
311
|
-
const snapshot = {};
|
|
312
|
-
for (const field of SNAPSHOT_FIELDS) {
|
|
313
|
-
if (context[field] !== void 0) snapshot[field] = context[field];
|
|
314
|
-
}
|
|
315
|
-
const tenant = context["tenant"];
|
|
316
|
-
if (tenant?.id) snapshot["tenant"] = { id: tenant.id };
|
|
317
|
-
const user = context["user"];
|
|
318
|
-
if (user?.id && snapshot["userId"] === void 0) snapshot["userId"] = user.id;
|
|
319
|
-
return Object.keys(snapshot).length > 0 ? snapshot : void 0;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// src/bridge.ts
|
|
323
|
-
function queuedOn(bus, manager, event, handler, options = {}) {
|
|
324
|
-
const job = defineJob({
|
|
325
|
-
name: `listener:${event.name}`,
|
|
326
|
-
...event.schema ? { schema: event.schema } : {},
|
|
327
|
-
...options.queue ? { queue: options.queue } : {},
|
|
328
|
-
...options.attempts !== void 0 ? { attempts: options.attempts } : {},
|
|
329
|
-
...options.backoff ? { backoff: options.backoff } : {},
|
|
330
|
-
handle: handler
|
|
331
|
-
});
|
|
332
|
-
manager.register(job);
|
|
333
|
-
return bus.on(event, async (payload) => {
|
|
334
|
-
await job.dispatch(payload);
|
|
335
|
-
});
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// src/index.ts
|
|
339
|
-
var QUEUE = createToken("queue");
|
|
340
|
-
function queuePlugin(options = {}) {
|
|
341
|
-
return definePlugin({
|
|
342
|
-
name: "basalt:queue",
|
|
343
|
-
register({ container }) {
|
|
344
|
-
registerQueueCommands(container);
|
|
345
|
-
container.singleton(QUEUE, () => {
|
|
346
|
-
const driver = options.driver ?? (options.connection ? new BullmqQueueDriver({ connection: options.connection }) : new SyncQueueDriver());
|
|
347
|
-
const manager = new QueueManager(driver, {
|
|
348
|
-
...options.onUnsupported !== void 0 ? { onUnsupported: options.onUnsupported } : {},
|
|
349
|
-
...options.removeOnComplete !== void 0 ? { removeOnComplete: options.removeOnComplete } : {},
|
|
350
|
-
...options.removeOnFail !== void 0 ? { removeOnFail: options.removeOnFail } : {}
|
|
351
|
-
});
|
|
352
|
-
for (const job of options.jobs ?? []) manager.register(job);
|
|
353
|
-
return manager;
|
|
354
|
-
});
|
|
355
|
-
},
|
|
356
|
-
boot({ container }) {
|
|
357
|
-
const manager = container.get(QUEUE);
|
|
358
|
-
for (const worker of options.workers ?? []) {
|
|
359
|
-
manager.work(
|
|
360
|
-
worker.queue,
|
|
361
|
-
worker.concurrency !== void 0 ? { concurrency: worker.concurrency } : {}
|
|
362
|
-
);
|
|
363
|
-
}
|
|
364
|
-
},
|
|
365
|
-
async shutdown({ container }) {
|
|
366
|
-
await container.get(QUEUE).close();
|
|
367
|
-
}
|
|
368
|
-
});
|
|
369
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Registers `queue:work`, `queue:stats` and `queue:retry` into the CLI command
|
|
44
|
+
* bucket. Commands resolve the manager lazily, so they work with whatever driver
|
|
45
|
+
* the app configured. Registered structurally to avoid a hard @basaltkit/cli dep.
|
|
46
|
+
*/
|
|
370
47
|
function registerQueueCommands(container) {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
io.log(unsupported);
|
|
412
|
-
return;
|
|
413
|
-
}
|
|
414
|
-
io.log(`Re-enqueued ${retried} failed job(s) on "${queue}".`);
|
|
415
|
-
}
|
|
416
|
-
});
|
|
48
|
+
const manager = () => container.get(QUEUE);
|
|
49
|
+
const unsupported = 'Not supported by the active queue driver — the inline sync driver keeps no job state. Use the BullMQ driver (a Redis `connection`).';
|
|
50
|
+
ensureMetadata(container).add('commands', {
|
|
51
|
+
name: 'queue:work',
|
|
52
|
+
description: 'Run a worker that processes jobs for a queue (Ctrl+C to stop)',
|
|
53
|
+
async handle({ io, flags }) {
|
|
54
|
+
const queue = typeof flags['queue'] === 'string' ? flags['queue'] : 'default';
|
|
55
|
+
const concurrency = typeof flags['concurrency'] === 'string' ? Number(flags['concurrency']) : undefined;
|
|
56
|
+
manager().work(queue, concurrency !== undefined ? { concurrency } : {});
|
|
57
|
+
io.log(`Worker started on queue "${queue}"${concurrency ? ` (concurrency ${concurrency})` : ''}. Ctrl+C to stop.`);
|
|
58
|
+
await new Promise((resolve) => process.once('SIGINT', resolve));
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
ensureMetadata(container).add('commands', {
|
|
62
|
+
name: 'queue:stats',
|
|
63
|
+
description: 'Show job counts (waiting/active/completed/failed/delayed) for a queue',
|
|
64
|
+
async handle({ io, flags, }) {
|
|
65
|
+
const queue = typeof flags['queue'] === 'string' ? flags['queue'] : 'default';
|
|
66
|
+
const stats = await manager().stats(queue);
|
|
67
|
+
if (!stats) {
|
|
68
|
+
io.log(unsupported);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
io.table([{ queue, ...stats }]);
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
ensureMetadata(container).add('commands', {
|
|
75
|
+
name: 'queue:retry',
|
|
76
|
+
description: 'Re-enqueue failed jobs on a queue',
|
|
77
|
+
async handle({ io, flags, }) {
|
|
78
|
+
const queue = typeof flags['queue'] === 'string' ? flags['queue'] : 'default';
|
|
79
|
+
const limit = typeof flags['limit'] === 'string' ? Number(flags['limit']) : undefined;
|
|
80
|
+
const retried = await manager().retryFailed(queue, limit !== undefined ? { limit } : {});
|
|
81
|
+
if (retried === undefined) {
|
|
82
|
+
io.log(unsupported);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
io.log(`Re-enqueued ${retried} failed job(s) on "${queue}".`);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
417
88
|
}
|
|
418
|
-
export {
|
|
419
|
-
BullmqQueueDriver,
|
|
420
|
-
JobNotRegisteredError,
|
|
421
|
-
JobValidationError,
|
|
422
|
-
QUEUE,
|
|
423
|
-
QueueManager,
|
|
424
|
-
SyncQueueDriver,
|
|
425
|
-
UnknownJobError,
|
|
426
|
-
UnsupportedJobOptionError,
|
|
427
|
-
defineJob,
|
|
428
|
-
queuePlugin,
|
|
429
|
-
queuedOn
|
|
430
|
-
};
|
package/dist/job.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { BasaltError, type DurationInput } from '@basaltkit/core';
|
|
2
|
+
/** Structural schema compatible with Zod. */
|
|
3
|
+
export interface JobSchema<T> {
|
|
4
|
+
safeParse(input: unknown): {
|
|
5
|
+
success: boolean;
|
|
6
|
+
data?: T;
|
|
7
|
+
error?: unknown;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export declare class JobValidationError extends BasaltError {
|
|
11
|
+
readonly job: string;
|
|
12
|
+
readonly issues: unknown;
|
|
13
|
+
constructor(job: string, issues: unknown);
|
|
14
|
+
}
|
|
15
|
+
export declare class JobNotRegisteredError extends BasaltError {
|
|
16
|
+
constructor(job: string);
|
|
17
|
+
}
|
|
18
|
+
export interface DispatchOptions {
|
|
19
|
+
delay?: DurationInput;
|
|
20
|
+
priority?: number;
|
|
21
|
+
}
|
|
22
|
+
export interface JobBackoff {
|
|
23
|
+
type: 'exponential' | 'fixed';
|
|
24
|
+
delay: DurationInput;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Redis retention for finished jobs (BullMQ driver): `true` removes it as soon as
|
|
28
|
+
* it finishes, `false` keeps it forever, a number keeps that many most-recent, and
|
|
29
|
+
* `{ age, count }` keeps by age and/or count. Defaults: completed `{ count: 1000 }`,
|
|
30
|
+
* failed `false` (keep all). The sync driver ignores it (it stores nothing).
|
|
31
|
+
*/
|
|
32
|
+
export type JobRetention = boolean | number | {
|
|
33
|
+
age?: DurationInput;
|
|
34
|
+
count?: number;
|
|
35
|
+
};
|
|
36
|
+
export interface JobDefinition<T = unknown> {
|
|
37
|
+
readonly name: string;
|
|
38
|
+
readonly schema?: JobSchema<T> | undefined;
|
|
39
|
+
readonly queue: string;
|
|
40
|
+
readonly attempts: number;
|
|
41
|
+
readonly backoff?: JobBackoff | undefined;
|
|
42
|
+
/** Retention for completed jobs. Overrides the queuePlugin default. */
|
|
43
|
+
readonly removeOnComplete?: JobRetention | undefined;
|
|
44
|
+
/** Retention for failed jobs. Overrides the queuePlugin default. */
|
|
45
|
+
readonly removeOnFail?: JobRetention | undefined;
|
|
46
|
+
handle(payload: T): void | Promise<void>;
|
|
47
|
+
/** Enqueues the job — available after registration in a QueueManager. */
|
|
48
|
+
dispatch(payload: T, options?: DispatchOptions): Promise<void>;
|
|
49
|
+
/** @internal used by the QueueManager when registering */
|
|
50
|
+
__bind(dispatcher: JobDispatcher): void;
|
|
51
|
+
}
|
|
52
|
+
export interface JobDispatcher {
|
|
53
|
+
dispatch<T>(job: JobDefinition<T>, payload: T, options?: DispatchOptions): Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Defines a declarative job:
|
|
57
|
+
*
|
|
58
|
+
* export const SendWelcomeEmail = defineJob({
|
|
59
|
+
* name: 'email.welcome',
|
|
60
|
+
* schema: z.object({ userId: z.string() }),
|
|
61
|
+
* attempts: 3,
|
|
62
|
+
* backoff: { type: 'exponential', delay: '30s' },
|
|
63
|
+
* async handle({ userId }) { ... },
|
|
64
|
+
* })
|
|
65
|
+
*/
|
|
66
|
+
export declare function defineJob<T = unknown>(config: {
|
|
67
|
+
name: string;
|
|
68
|
+
schema?: JobSchema<T>;
|
|
69
|
+
queue?: string;
|
|
70
|
+
attempts?: number;
|
|
71
|
+
backoff?: JobBackoff;
|
|
72
|
+
removeOnComplete?: JobRetention;
|
|
73
|
+
removeOnFail?: JobRetention;
|
|
74
|
+
handle(payload: T): void | Promise<void>;
|
|
75
|
+
}): JobDefinition<T>;
|
|
76
|
+
export declare function validatePayload<T>(job: JobDefinition<T>, payload: unknown): T;
|
package/dist/job.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { BasaltError } from '@basaltkit/core';
|
|
2
|
+
export class JobValidationError extends BasaltError {
|
|
3
|
+
job;
|
|
4
|
+
issues;
|
|
5
|
+
constructor(job, issues) {
|
|
6
|
+
super('JOB_INVALID', `Invalid payload for job "${job}": ${JSON.stringify(issues)}`);
|
|
7
|
+
this.job = job;
|
|
8
|
+
this.issues = issues;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export class JobNotRegisteredError extends BasaltError {
|
|
12
|
+
constructor(job) {
|
|
13
|
+
super('QUEUE_JOB_NOT_REGISTERED', `Job "${job}" has not been registered in a QueueManager yet. ` +
|
|
14
|
+
'Add it to queuePlugin({ jobs: [...] }) or call manager.register(job).');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Defines a declarative job:
|
|
19
|
+
*
|
|
20
|
+
* export const SendWelcomeEmail = defineJob({
|
|
21
|
+
* name: 'email.welcome',
|
|
22
|
+
* schema: z.object({ userId: z.string() }),
|
|
23
|
+
* attempts: 3,
|
|
24
|
+
* backoff: { type: 'exponential', delay: '30s' },
|
|
25
|
+
* async handle({ userId }) { ... },
|
|
26
|
+
* })
|
|
27
|
+
*/
|
|
28
|
+
export function defineJob(config) {
|
|
29
|
+
let dispatcher;
|
|
30
|
+
const job = {
|
|
31
|
+
name: config.name,
|
|
32
|
+
schema: config.schema,
|
|
33
|
+
queue: config.queue ?? 'default',
|
|
34
|
+
attempts: config.attempts ?? 1,
|
|
35
|
+
backoff: config.backoff,
|
|
36
|
+
removeOnComplete: config.removeOnComplete,
|
|
37
|
+
removeOnFail: config.removeOnFail,
|
|
38
|
+
handle: config.handle,
|
|
39
|
+
async dispatch(payload, options) {
|
|
40
|
+
if (!dispatcher)
|
|
41
|
+
throw new JobNotRegisteredError(config.name);
|
|
42
|
+
return dispatcher.dispatch(job, payload, options);
|
|
43
|
+
},
|
|
44
|
+
__bind(d) {
|
|
45
|
+
dispatcher = d;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
return job;
|
|
49
|
+
}
|
|
50
|
+
export function validatePayload(job, payload) {
|
|
51
|
+
if (!job.schema)
|
|
52
|
+
return payload;
|
|
53
|
+
const result = job.schema.safeParse(payload);
|
|
54
|
+
if (!result.success) {
|
|
55
|
+
const issues = result.error?.issues ?? result.error ?? 'unknown';
|
|
56
|
+
throw new JobValidationError(job.name, issues);
|
|
57
|
+
}
|
|
58
|
+
return result.data;
|
|
59
|
+
}
|