@warlock.js/queue 5.33.0 → 5.34.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +9 -54
  2. package/README.md +18 -3
  3. package/cjs/index.cjs +644 -168
  4. package/cjs/index.cjs.map +1 -1
  5. package/esm/dashboard-boot.mjs +1 -0
  6. package/esm/dashboard-boot.mjs.map +1 -1
  7. package/esm/dashboard.d.mts.map +1 -1
  8. package/esm/dashboard.mjs +2 -1
  9. package/esm/dashboard.mjs.map +1 -1
  10. package/esm/define-job.d.mts +0 -1
  11. package/esm/define-job.d.mts.map +1 -1
  12. package/esm/define-job.mjs +11 -64
  13. package/esm/define-job.mjs.map +1 -1
  14. package/esm/driver.d.mts +23 -0
  15. package/esm/driver.d.mts.map +1 -0
  16. package/esm/drivers/bullmq.mjs +132 -0
  17. package/esm/drivers/bullmq.mjs.map +1 -0
  18. package/esm/drivers/local-store.mjs +9 -0
  19. package/esm/drivers/local-store.mjs.map +1 -0
  20. package/esm/drivers/local.mjs +307 -0
  21. package/esm/drivers/local.mjs.map +1 -0
  22. package/esm/drivers/memory-store.mjs +48 -0
  23. package/esm/drivers/memory-store.mjs.map +1 -0
  24. package/esm/drivers/sqlite-store.mjs +89 -0
  25. package/esm/drivers/sqlite-store.mjs.map +1 -0
  26. package/esm/failed-jobs.d.mts +2 -8
  27. package/esm/failed-jobs.d.mts.map +1 -1
  28. package/esm/failed-jobs.mjs +5 -30
  29. package/esm/failed-jobs.mjs.map +1 -1
  30. package/esm/index.d.mts +4 -2
  31. package/esm/index.mjs +2 -1
  32. package/esm/job-options.d.mts +9 -0
  33. package/esm/job-options.d.mts.map +1 -0
  34. package/esm/job-options.mjs +20 -0
  35. package/esm/job-options.mjs.map +1 -0
  36. package/esm/queue-driver.d.mts +6 -0
  37. package/esm/queue-driver.d.mts.map +1 -0
  38. package/esm/queue-driver.mjs +37 -0
  39. package/esm/queue-driver.mjs.map +1 -0
  40. package/esm/queue-manager.d.mts +5 -26
  41. package/esm/queue-manager.d.mts.map +1 -1
  42. package/esm/queue-manager.mjs +28 -84
  43. package/esm/queue-manager.mjs.map +1 -1
  44. package/esm/types.d.mts +18 -5
  45. package/esm/types.d.mts.map +1 -1
  46. package/llms-full.txt +129 -3
  47. package/llms.txt +2 -1
  48. package/package.json +4 -4
  49. package/skills/configure-queue/SKILL.md +1 -1
  50. package/skills/index.md +4 -2
  51. package/skills/local-queues/SKILL.md +121 -0
  52. package/skills/overview/SKILL.md +3 -2
package/cjs/index.cjs CHANGED
@@ -1,6 +1,10 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
2
  let _warlock_js_logger = require("@warlock.js/logger");
3
3
  let bullmq = require("bullmq");
4
+ let node_crypto = require("node:crypto");
5
+ let node_fs = require("node:fs");
6
+ let node_module = require("node:module");
7
+ let node_path = require("node:path");
4
8
 
5
9
  //#region ../../packages/queue/src/errors.ts
6
10
  /**
@@ -278,111 +282,640 @@ async function processJob(job) {
278
282
  }
279
283
 
280
284
  //#endregion
281
- //#region ../../packages/queue/src/queue-manager.ts
282
- const DEFAULT_SHUTDOWN_TIMEOUT = 3e4;
283
- const queues = /* @__PURE__ */ new Map();
284
- const workers = /* @__PURE__ */ new Map();
285
- let stopListening;
286
- /**
287
- * The BullMQ queue for `name`, created on first use with the configured
288
- * connection and prefix.
289
- */
290
- function getQueue(name) {
291
- let queue = queues.get(name);
292
- if (!queue) {
293
- const config = getQueueConfig();
294
- queue = new bullmq.Queue(name, {
295
- connection: config.connection,
296
- prefix: config.prefix ?? "warlock"
285
+ //#region ../../packages/queue/src/drivers/bullmq.ts
286
+ var BullMQDriver = class {
287
+ constructor(config) {
288
+ this.config = config;
289
+ this.queues = /* @__PURE__ */ new Map();
290
+ this.workers = /* @__PURE__ */ new Map();
291
+ if (!config.connection) throw new Error("The bullmq queue driver requires `connection`.");
292
+ }
293
+ getQueue(name) {
294
+ let queue = this.queues.get(name);
295
+ if (!queue) {
296
+ const connection = this.config.connection;
297
+ if (!connection) throw new Error("The bullmq queue driver requires `connection`.");
298
+ queue = new bullmq.Queue(name, {
299
+ connection,
300
+ prefix: this.config.prefix ?? "warlock"
301
+ });
302
+ queue.on("error", (error) => _warlock_js_logger.log.error("queue", "connection", error));
303
+ this.queues.set(name, queue);
304
+ }
305
+ return queue;
306
+ }
307
+ async dispatch(request) {
308
+ const { backoff, ...rest } = request.options;
309
+ const options = {
310
+ ...rest,
311
+ backoff: typeof backoff === "number" ? {
312
+ type: "fixed",
313
+ delay: backoff
314
+ } : backoff
315
+ };
316
+ const job = await this.getQueue(request.queue).add(request.name, request.data, options);
317
+ return {
318
+ id: String(job.id),
319
+ name: request.name,
320
+ queue: request.queue
321
+ };
322
+ }
323
+ async find(queue, id) {
324
+ const source = this.getQueue(queue);
325
+ const initial = await source.getJob(id);
326
+ if (!initial) return void 0;
327
+ const state = await initial.getState();
328
+ return toSnapshot(await source.getJob(id) ?? initial, state);
329
+ }
330
+ async failed(queue, start, end) {
331
+ return (await this.getQueue(queue).getFailed(start, end)).map((job) => ({
332
+ id: String(job.id),
333
+ name: job.name,
334
+ queue,
335
+ payload: unwrapPayload(job.data).payload,
336
+ attemptsMade: job.attemptsMade,
337
+ failedReason: job.failedReason,
338
+ stacktrace: job.stacktrace ?? [],
339
+ failedAt: job.finishedOn ? new Date(job.finishedOn) : void 0,
340
+ retry: () => job.retry("failed")
341
+ }));
342
+ }
343
+ async retry(queue, id) {
344
+ const job = await this.getQueue(queue).getJob(id);
345
+ if (!job || !await job.isFailed()) throw new FailedJobNotFoundError(id, queue);
346
+ await job.retry("failed");
347
+ }
348
+ async start(queue) {
349
+ let worker = this.workers.get(queue);
350
+ if (!worker) {
351
+ const connection = this.config.connection;
352
+ if (!connection) throw new Error("The bullmq queue driver requires `connection`.");
353
+ worker = new bullmq.Worker(queue, processJob, {
354
+ connection,
355
+ prefix: this.config.prefix ?? "warlock",
356
+ concurrency: this.config.workers?.concurrency ?? 1
357
+ });
358
+ worker.on("error", (error) => _warlock_js_logger.log.error("queue", "worker", error));
359
+ worker.on("failed", (job, error) => _warlock_js_logger.log.error("queue", "job.failed", `${job?.name ?? "unknown"} (${job?.id ?? "?"}): ${error.message}`));
360
+ this.workers.set(queue, worker);
361
+ }
362
+ await worker.waitUntilReady();
363
+ }
364
+ running() {
365
+ return [...this.workers.keys()];
366
+ }
367
+ async close(timeout) {
368
+ const workers = [...this.workers.values()];
369
+ this.workers.clear();
370
+ await Promise.all(workers.map((worker) => closeWorker(worker, timeout)));
371
+ const queues = [...this.queues.values()];
372
+ this.queues.clear();
373
+ await Promise.all(queues.map((queue) => queue.close()));
374
+ }
375
+ };
376
+ function toSnapshot(job, state) {
377
+ return {
378
+ id: String(job.id),
379
+ name: job.name,
380
+ queue: job.queueName,
381
+ state,
382
+ payload: unwrapPayload(job.data).payload,
383
+ progress: job.progress,
384
+ attemptsMade: job.attemptsMade,
385
+ result: job.returnvalue,
386
+ failedReason: job.failedReason || void 0,
387
+ createdAt: new Date(job.timestamp),
388
+ finishedAt: job.finishedOn ? new Date(job.finishedOn) : void 0
389
+ };
390
+ }
391
+ async function closeWorker(worker, timeout) {
392
+ let timer;
393
+ const timedOut = new Promise((resolve) => {
394
+ timer = setTimeout(() => resolve("timeout"), timeout);
395
+ });
396
+ const closing = worker.close().then(() => "closed");
397
+ closing.catch(() => void 0);
398
+ try {
399
+ if (await Promise.race([closing, timedOut]) === "timeout") {
400
+ _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.`);
401
+ await worker.disconnect();
402
+ }
403
+ } finally {
404
+ clearTimeout(timer);
405
+ }
406
+ }
407
+
408
+ //#endregion
409
+ //#region ../../packages/queue/src/drivers/local-store.ts
410
+ /** Match JSON transport on every backend, without sharing mutable objects. */
411
+ function jsonCopy(value) {
412
+ return JSON.parse(JSON.stringify({ value })).value;
413
+ }
414
+
415
+ //#endregion
416
+ //#region ../../packages/queue/src/drivers/local.ts
417
+ /** Shared scheduling and lease semantics for the SQLite and memory stores. */
418
+ var LocalDriver = class {
419
+ constructor(store, config) {
420
+ this.store = store;
421
+ this.workers = /* @__PURE__ */ new Map();
422
+ this.active = /* @__PURE__ */ new Set();
423
+ this.closed = false;
424
+ this.stopping = false;
425
+ try {
426
+ this.leaseDuration = positiveInteger(config.local?.leaseDuration ?? 3e4, "local.leaseDuration", 30);
427
+ this.pollInterval = positiveInteger(config.local?.pollInterval ?? 100, "local.pollInterval");
428
+ this.concurrency = positiveInteger(config.workers?.concurrency ?? 1, "workers.concurrency");
429
+ } catch (error) {
430
+ store.close();
431
+ throw error;
432
+ }
433
+ }
434
+ async dispatch(request) {
435
+ this.assertOpen();
436
+ validateOptions(request);
437
+ const id = request.options.jobId ?? (0, node_crypto.randomUUID)();
438
+ const now = Date.now();
439
+ return {
440
+ id: this.store.transaction(() => {
441
+ const previous = this.store.get(request.queue, id);
442
+ if (previous) return previous;
443
+ const entry = {
444
+ id,
445
+ name: request.name,
446
+ queue: request.queue,
447
+ data: jsonCopy(request.data),
448
+ options: jsonCopy(request.options),
449
+ state: request.options.delay ? "delayed" : request.options.priority ? "prioritized" : "waiting",
450
+ createdAt: now,
451
+ readyAt: now + (request.options.delay ?? 0),
452
+ attemptsMade: 0,
453
+ priority: request.options.priority ?? 0,
454
+ progress: 0,
455
+ logs: [],
456
+ stacktrace: [],
457
+ leaseUntil: 0
458
+ };
459
+ this.store.put(entry);
460
+ return entry;
461
+ }).id,
462
+ name: request.name,
463
+ queue: request.queue
464
+ };
465
+ }
466
+ async find(queue, id) {
467
+ this.assertOpen();
468
+ const job = this.store.get(queue, id);
469
+ return job ? snapshot(job) : void 0;
470
+ }
471
+ async failed(queue, start, end) {
472
+ this.assertOpen();
473
+ const jobs = newestFirst(this.store.list(queue, "failed"));
474
+ const last = end < 0 ? jobs.length + end : end;
475
+ return jobs.slice(start, last + 1).map((job) => ({
476
+ id: job.id,
477
+ name: job.name,
478
+ queue,
479
+ payload: unwrapPayload(job.data).payload,
480
+ attemptsMade: job.attemptsMade,
481
+ failedReason: job.failedReason ?? "",
482
+ stacktrace: job.stacktrace,
483
+ failedAt: job.finishedAt === void 0 ? void 0 : new Date(job.finishedAt),
484
+ retry: () => this.retry(queue, job.id)
485
+ }));
486
+ }
487
+ async retry(queue, id) {
488
+ this.assertOpen();
489
+ this.store.transaction(() => {
490
+ const job = this.store.get(queue, id);
491
+ if (!job || job.state !== "failed") throw new FailedJobNotFoundError(id, queue);
492
+ job.state = job.priority ? "prioritized" : "waiting";
493
+ job.attemptsMade = 0;
494
+ job.readyAt = Date.now();
495
+ delete job.finishedAt;
496
+ delete job.failedReason;
497
+ delete job.result;
498
+ job.stacktrace = [];
499
+ this.store.put(job);
500
+ });
501
+ }
502
+ async start(queue) {
503
+ this.assertOpen();
504
+ if (this.stopping) throw new Error("Queue workers are shutting down.");
505
+ if (this.workers.has(queue)) return;
506
+ const worker = { active: /* @__PURE__ */ new Set() };
507
+ this.workers.set(queue, worker);
508
+ this.schedule(queue, worker, 0);
509
+ }
510
+ running() {
511
+ return [...this.workers.keys()];
512
+ }
513
+ async close(timeout) {
514
+ if (this.closed) return;
515
+ this.stopping = true;
516
+ for (const worker of this.workers.values()) clearTimeout(worker.timer);
517
+ this.workers.clear();
518
+ let timer;
519
+ const timedOut = new Promise((resolve) => {
520
+ timer = setTimeout(resolve, timeout);
521
+ });
522
+ try {
523
+ await Promise.race([Promise.allSettled([...this.active].map((entry) => entry.done)), timedOut]);
524
+ } finally {
525
+ clearTimeout(timer);
526
+ try {
527
+ for (const entry of this.active) {
528
+ clearInterval(entry.heartbeat);
529
+ entry.controller.abort(/* @__PURE__ */ new Error("Queue shutdown timeout elapsed."));
530
+ this.mutate(entry.job, entry.token, (job) => {
531
+ job.state = job.priority ? "prioritized" : "waiting";
532
+ job.readyAt = Date.now();
533
+ job.leaseUntil = 0;
534
+ delete job.token;
535
+ });
536
+ }
537
+ } finally {
538
+ this.closed = true;
539
+ for (const entry of this.active) clearInterval(entry.heartbeat);
540
+ this.store.close();
541
+ }
542
+ }
543
+ }
544
+ schedule(queue, worker, delay) {
545
+ if (this.stopping) return;
546
+ worker.timer = setTimeout(() => {
547
+ try {
548
+ while (!this.stopping && worker.active.size < this.concurrency) {
549
+ const token = (0, node_crypto.randomUUID)();
550
+ const job = this.store.transaction(() => {
551
+ const now = Date.now();
552
+ const candidate = this.store.ready(queue, now);
553
+ if (!candidate) return void 0;
554
+ candidate.state = "active";
555
+ candidate.token = token;
556
+ candidate.leaseUntil = now + this.leaseDuration;
557
+ this.store.put(candidate);
558
+ return candidate;
559
+ });
560
+ if (!job) break;
561
+ this.run(job, token, worker);
562
+ }
563
+ } catch (error) {
564
+ _warlock_js_logger.log.error("queue", "worker", error);
565
+ }
566
+ this.schedule(queue, worker, this.pollInterval);
567
+ }, delay);
568
+ }
569
+ run(job, token, worker) {
570
+ const controller = new AbortController();
571
+ const heartbeat = setInterval(() => {
572
+ try {
573
+ if (!this.mutate(job, token, (current) => {
574
+ current.leaseUntil = Date.now() + this.leaseDuration;
575
+ })) {
576
+ controller.abort(/* @__PURE__ */ new Error("Queue job lease expired or changed owner."));
577
+ clearInterval(heartbeat);
578
+ }
579
+ } catch (error) {
580
+ controller.abort(error);
581
+ clearInterval(heartbeat);
582
+ _warlock_js_logger.log.error("queue", "lease", error);
583
+ }
584
+ }, Math.floor(this.leaseDuration / 3));
585
+ const entry = {
586
+ job,
587
+ token,
588
+ controller,
589
+ heartbeat,
590
+ done: Promise.resolve()
591
+ };
592
+ this.active.add(entry);
593
+ worker.active.add(entry);
594
+ entry.done = this.execute(entry).catch((error) => {
595
+ _warlock_js_logger.log.error("queue", "job", error);
596
+ }).finally(() => {
597
+ clearInterval(heartbeat);
598
+ this.active.delete(entry);
599
+ worker.active.delete(entry);
297
600
  });
298
- queue.on("error", (error) => {
299
- _warlock_js_logger.log.error("queue", "connection", error);
601
+ }
602
+ async execute(entry) {
603
+ const { job, token, controller } = entry;
604
+ try {
605
+ const definition = findRegisteredJob(job.name);
606
+ if (!definition) throw new UnrecoverableJobError(`No job named "${job.name}" is defined in this process. Import its definition in the worker.`);
607
+ const { payload, context: captured } = unwrapPayload(job.data);
608
+ const context = {
609
+ id: job.id,
610
+ name: job.name,
611
+ queue: job.queue,
612
+ attempt: job.attemptsMade + 1,
613
+ maxAttempts: job.options.attempts ?? 1,
614
+ context: captured,
615
+ signal: controller.signal,
616
+ progress: async (value) => {
617
+ if (!this.mutate(job, token, (current) => {
618
+ current.progress = jsonCopy(value);
619
+ })) throw new Error("Queue job lease lost.");
620
+ },
621
+ log: async (line) => {
622
+ if (!this.mutate(job, token, (current) => {
623
+ current.logs.push(line);
624
+ })) throw new Error("Queue job lease lost.");
625
+ }
626
+ };
627
+ const run = async () => definition.handle(payload, context);
628
+ const queueContext = getQueueContext();
629
+ const result = jsonCopy(captured !== void 0 && queueContext ? await queueContext.restore(captured, run) : await run());
630
+ this.mutate(job, token, (current) => {
631
+ current.attemptsMade++;
632
+ current.result = result;
633
+ current.state = "completed";
634
+ current.finishedAt = Date.now();
635
+ current.leaseUntil = 0;
636
+ delete current.token;
637
+ }, true);
638
+ } catch (error) {
639
+ this.mutate(job, token, (current) => {
640
+ current.attemptsMade++;
641
+ current.failedReason = error instanceof Error ? error.message : String(error);
642
+ current.stacktrace.push(error instanceof Error ? error.stack ?? error.message : String(error));
643
+ current.leaseUntil = 0;
644
+ delete current.token;
645
+ if (error instanceof UnrecoverableJobError || current.attemptsMade >= (current.options.attempts ?? 1)) {
646
+ current.state = "failed";
647
+ current.finishedAt = Date.now();
648
+ } else {
649
+ const backoff = current.options.backoff;
650
+ const base = typeof backoff === "number" ? backoff : backoff?.delay ?? 0;
651
+ const delay = typeof backoff === "object" && backoff.type === "exponential" ? base * 2 ** (current.attemptsMade - 1) : base;
652
+ current.readyAt = Math.min(Number.MAX_SAFE_INTEGER, Date.now() + delay);
653
+ current.state = delay > 0 ? "delayed" : current.priority ? "prioritized" : "waiting";
654
+ }
655
+ }, true);
656
+ }
657
+ }
658
+ /** Fencing: expired owners cannot renew, finish, or overwrite a new owner's job. */
659
+ mutate(job, token, update, retain = false) {
660
+ if (this.closed) return false;
661
+ return this.store.transaction(() => {
662
+ const current = this.store.get(job.queue, job.id);
663
+ if (!current || !ownsLease(current, token)) return false;
664
+ update(current);
665
+ this.store.put(current);
666
+ if (retain && (current.state === "completed" || current.state === "failed")) {
667
+ const retention = current.state === "completed" ? current.options.removeOnComplete : current.options.removeOnFail;
668
+ if (retention === true) this.store.remove(current.queue, current.id);
669
+ if (typeof retention === "number") for (const old of newestFirst(this.store.list(current.queue, current.state)).slice(retention)) this.store.remove(old.queue, old.id);
670
+ }
671
+ return true;
300
672
  });
301
- queues.set(name, queue);
302
673
  }
303
- return queue;
674
+ assertOpen() {
675
+ if (this.closed) throw new Error("Queue driver is closed.");
676
+ }
677
+ };
678
+ function snapshot(job) {
679
+ return {
680
+ id: job.id,
681
+ name: job.name,
682
+ queue: job.queue,
683
+ state: job.state,
684
+ payload: unwrapPayload(job.data).payload,
685
+ progress: job.progress,
686
+ attemptsMade: job.attemptsMade,
687
+ result: job.result,
688
+ failedReason: job.failedReason,
689
+ createdAt: new Date(job.createdAt),
690
+ finishedAt: job.finishedAt === void 0 ? void 0 : new Date(job.finishedAt)
691
+ };
304
692
  }
305
- /**
306
- * Start one worker per queue that has a registered job, and keep starting
307
- * workers for queues whose first job is defined later.
308
- *
309
- * A no-op returning `[]` when `workers.enabled` is `false`, or when this
310
- * process does not serve the `worker` role (`Application.hasRole`) — a
311
- * process started with `--role=web`, for example, produces jobs but never
312
- * runs them in-process. Calling it again while workers run starts only the
313
- * missing ones.
314
- *
315
- * @returns the queue names that now have a worker in this process.
316
- */
693
+ function newestFirst(jobs) {
694
+ return jobs.reverse().sort((a, b) => (b.finishedAt ?? 0) - (a.finishedAt ?? 0));
695
+ }
696
+ function positiveInteger(value, name, minimum = 1) {
697
+ if (!Number.isSafeInteger(value) || value < minimum || value > 2147483647) throw new Error(`${name} must be an integer between ${minimum} and 2147483647.`);
698
+ return value;
699
+ }
700
+ function validateOptions({ options }) {
701
+ positiveInteger(options.attempts ?? 1, "attempts");
702
+ 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.");
703
+ const backoff = options.backoff;
704
+ const delay = typeof backoff === "number" ? backoff : backoff?.delay ?? 0;
705
+ 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.");
706
+ if (options.delay !== void 0 && (!Number.isFinite(options.delay) || options.delay < 0)) throw new Error("delay must be non-negative and finite.");
707
+ for (const retention of [options.removeOnComplete, options.removeOnFail]) if (typeof retention === "number" && (!Number.isInteger(retention) || retention < 0)) throw new Error("Job retention must be a non-negative integer or boolean.");
708
+ }
709
+ function ownsLease(job, token) {
710
+ return job.state === "active" && job.token === token && job.leaseUntil > Date.now();
711
+ }
712
+
713
+ //#endregion
714
+ //#region ../../packages/queue/src/drivers/memory-store.ts
715
+ var MemoryStore = class {
716
+ constructor() {
717
+ this.jobs = /* @__PURE__ */ new Map();
718
+ }
719
+ transaction(run) {
720
+ const before = new Map(this.jobs);
721
+ try {
722
+ return run();
723
+ } catch (error) {
724
+ this.jobs = before;
725
+ throw error;
726
+ }
727
+ }
728
+ key(queue, id) {
729
+ return JSON.stringify([queue, id]);
730
+ }
731
+ get(queue, id) {
732
+ return jsonCopy(this.jobs.get(this.key(queue, id)));
733
+ }
734
+ list(queue, state) {
735
+ return [...this.jobs.values()].filter((job) => job.queue === queue && (!state || job.state === state)).map(jsonCopy);
736
+ }
737
+ ready(queue, now) {
738
+ let candidate;
739
+ for (const job of this.list(queue)) if ((job.state === "active" && job.leaseUntil <= now || [
740
+ "waiting",
741
+ "delayed",
742
+ "prioritized"
743
+ ].includes(job.state) && job.readyAt <= now) && (!candidate || job.priority < candidate.priority)) candidate = job;
744
+ return candidate;
745
+ }
746
+ put(job) {
747
+ this.jobs.set(this.key(job.queue, job.id), jsonCopy(job));
748
+ }
749
+ remove(queue, id) {
750
+ this.jobs.delete(this.key(queue, id));
751
+ }
752
+ close() {
753
+ this.jobs.clear();
754
+ }
755
+ };
756
+
757
+ //#endregion
758
+ //#region ../../packages/queue/src/drivers/sqlite-store.ts
759
+ /** node:sqlite is loaded only when the local backend is selected. */
760
+ var SqliteStore = class {
761
+ constructor(config) {
762
+ const path = (0, node_path.resolve)(config.local?.path ?? ".warlock/queue.sqlite");
763
+ if (config.local?.path === ":memory:") throw new Error("Use driver: 'memory' for non-durable queues.");
764
+ let sqlite;
765
+ try {
766
+ sqlite = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href)("node:sqlite");
767
+ } catch (cause) {
768
+ throw new Error("The local queue driver requires Node >= 22.15 with node:sqlite available without flags.", { cause });
769
+ }
770
+ if (typeof sqlite.DatabaseSync !== "function") throw new Error("node:sqlite.DatabaseSync is unavailable.");
771
+ (0, node_fs.mkdirSync)((0, node_path.dirname)(path), { recursive: true });
772
+ this.db = new sqlite.DatabaseSync(path);
773
+ this.namespace = config.prefix ?? "warlock";
774
+ try {
775
+ this.db.exec(`
776
+ PRAGMA busy_timeout = 5000;
777
+ PRAGMA journal_mode = WAL;
778
+ PRAGMA synchronous = FULL;
779
+ CREATE TABLE IF NOT EXISTS warlock_queue_jobs (
780
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
781
+ namespace TEXT NOT NULL, queue TEXT NOT NULL, id TEXT NOT NULL,
782
+ state TEXT NOT NULL, ready_at REAL NOT NULL, lease_until REAL NOT NULL,
783
+ priority INTEGER NOT NULL, body TEXT NOT NULL,
784
+ UNIQUE(namespace, queue, id)
785
+ );
786
+ CREATE INDEX IF NOT EXISTS warlock_queue_ready
787
+ ON warlock_queue_jobs(namespace, queue, state, ready_at, lease_until);
788
+ `);
789
+ } catch (error) {
790
+ this.db.close();
791
+ throw error;
792
+ }
793
+ }
794
+ transaction(run) {
795
+ this.db.exec("BEGIN IMMEDIATE");
796
+ try {
797
+ const result = run();
798
+ this.db.exec("COMMIT");
799
+ return result;
800
+ } catch (error) {
801
+ this.db.exec("ROLLBACK");
802
+ throw error;
803
+ }
804
+ }
805
+ get(queue, id) {
806
+ return decode(this.db.prepare("SELECT body FROM warlock_queue_jobs WHERE namespace = ? AND queue = ? AND id = ?").get(this.namespace, queue, id));
807
+ }
808
+ list(queue, state) {
809
+ return (state ? this.db.prepare("SELECT body FROM warlock_queue_jobs WHERE namespace = ? AND queue = ? AND state = ? ORDER BY seq").all(this.namespace, queue, state) : this.db.prepare("SELECT body FROM warlock_queue_jobs WHERE namespace = ? AND queue = ? ORDER BY seq").all(this.namespace, queue)).flatMap((row) => {
810
+ const job = decode(row);
811
+ return job ? [job] : [];
812
+ });
813
+ }
814
+ ready(queue, now) {
815
+ return decode(this.db.prepare(`SELECT body FROM warlock_queue_jobs
816
+ WHERE namespace = ? AND queue = ? AND (
817
+ (state IN ('waiting', 'delayed', 'prioritized') AND ready_at <= ?) OR
818
+ (state = 'active' AND lease_until <= ?)
819
+ ) ORDER BY priority, seq LIMIT 1`).get(this.namespace, queue, now, now));
820
+ }
821
+ put(job) {
822
+ this.db.prepare(`INSERT INTO warlock_queue_jobs
823
+ (namespace, queue, id, state, ready_at, lease_until, priority, body)
824
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
825
+ ON CONFLICT(namespace, queue, id) DO UPDATE SET
826
+ state = excluded.state, ready_at = excluded.ready_at,
827
+ lease_until = excluded.lease_until, priority = excluded.priority, body = excluded.body`).run(this.namespace, job.queue, job.id, job.state, job.readyAt, job.leaseUntil, job.priority, JSON.stringify(job));
828
+ }
829
+ remove(queue, id) {
830
+ this.db.prepare("DELETE FROM warlock_queue_jobs WHERE namespace = ? AND queue = ? AND id = ?").run(this.namespace, queue, id);
831
+ }
832
+ close() {
833
+ this.db.close();
834
+ }
835
+ };
836
+ function decode(row) {
837
+ return typeof row?.body === "string" ? JSON.parse(row.body) : void 0;
838
+ }
839
+
840
+ //#endregion
841
+ //#region ../../packages/queue/src/queue-driver.ts
842
+ let activeDriver;
843
+ /** The configured backend, created on first use. Close it before changing config. */
844
+ function getQueueDriver() {
845
+ if (!activeDriver) {
846
+ const config = getQueueConfig();
847
+ switch (config.driver ?? "bullmq") {
848
+ case "bullmq":
849
+ activeDriver = new BullMQDriver(config);
850
+ break;
851
+ case "local":
852
+ activeDriver = new LocalDriver(new SqliteStore(config), config);
853
+ break;
854
+ case "memory":
855
+ activeDriver = new LocalDriver(new MemoryStore(), config);
856
+ break;
857
+ default: throw new Error(`Unknown queue driver: ${String(config.driver)}`);
858
+ }
859
+ }
860
+ return activeDriver;
861
+ }
862
+ function currentQueueDriver() {
863
+ return activeDriver;
864
+ }
865
+ function forgetQueueDriver() {
866
+ activeDriver = void 0;
867
+ }
868
+
869
+ //#endregion
870
+ //#region ../../packages/queue/src/queue-manager.ts
871
+ let stopListening;
872
+ let closing;
873
+ /** Low-level BullMQ access. For portable operations use defineJob / failedJobs. */
874
+ function getQueue(name) {
875
+ const driver = getQueueDriver();
876
+ if (!(driver instanceof BullMQDriver)) throw new Error("getQueue() requires the bullmq queue driver.");
877
+ return driver.getQueue(name);
878
+ }
879
+ /** Start workers for registered queues, honouring process roles and workers.enabled. */
317
880
  async function startWorkers() {
881
+ if (closing) await closing;
318
882
  const { Application } = await import("@warlock.js/core");
319
883
  if (!Application.hasRole("worker")) {
320
884
  _warlock_js_logger.log.info("queue", "workers", `queue: workers not started (role: ${[...Application.roles].join(",")})`);
321
885
  return [];
322
886
  }
323
887
  if (getQueueConfig().workers?.enabled === false) return [];
324
- for (const job of registeredJobs()) ensureWorker(queueOf(job));
888
+ const driver = getQueueDriver();
325
889
  stopListening ??= onJobRegistered((job) => {
326
- ensureWorker(queueOf(job));
890
+ driver.start(queueOf(job)).catch((error) => _warlock_js_logger.log.error("queue", "worker", error));
327
891
  });
328
- await Promise.all([...workers.values()].map((worker) => worker.waitUntilReady()));
329
- return [...workers.keys()];
892
+ await Promise.all([...new Set(registeredJobs().map(queueOf))].map((queue) => driver.start(queue)));
893
+ return driver.running();
330
894
  }
331
- /** The queue names with a running worker in this process. */
895
+ /** Names of queues with workers in this process. */
332
896
  function runningWorkers() {
333
- return [...workers.keys()];
334
- }
335
- function ensureWorker(queueName) {
336
- if (workers.has(queueName)) return;
337
- const config = getQueueConfig();
338
- const worker = new bullmq.Worker(queueName, processJob, {
339
- connection: config.connection,
340
- prefix: config.prefix ?? "warlock",
341
- concurrency: config.workers?.concurrency ?? 1
342
- });
343
- worker.on("error", (error) => {
344
- _warlock_js_logger.log.error("queue", "worker", error);
345
- });
346
- worker.on("failed", (job, error) => {
347
- _warlock_js_logger.log.error("queue", "job.failed", `${job?.name ?? "unknown"} (${job?.id ?? "?"}): ${error.message}`);
348
- });
349
- workers.set(queueName, worker);
897
+ return currentQueueDriver()?.running() ?? [];
350
898
  }
351
- /**
352
- * Graceful shutdown: stop workers taking new jobs and wait for active ones
353
- * (bounded by `timeout`, then force-close), then close every queue
354
- * connection. Safe to call when nothing was started, and more than once.
355
- */
356
- async function closeQueue(options = {}) {
899
+ /** Stop taking jobs, drain active handlers, then release leases and close storage. */
900
+ function closeQueue(options = {}) {
901
+ if (closing) return closing;
357
902
  stopListening?.();
358
903
  stopListening = void 0;
904
+ const driver = currentQueueDriver();
905
+ if (!driver) return Promise.resolve();
359
906
  const timeout = options.timeout ?? configuredShutdownTimeout();
360
- const closingWorkers = [...workers.values()];
361
- workers.clear();
362
- await Promise.all(closingWorkers.map((worker) => closeWorker(worker, timeout)));
363
- const closingQueues = [...queues.values()];
364
- queues.clear();
365
- await Promise.all(closingQueues.map((queue) => queue.close()));
907
+ if (!Number.isFinite(timeout) || timeout < 0) return Promise.reject(/* @__PURE__ */ new Error("Queue shutdown timeout must be non-negative and finite."));
908
+ closing = driver.close(timeout).finally(() => {
909
+ forgetQueueDriver();
910
+ closing = void 0;
911
+ });
912
+ return closing;
366
913
  }
367
914
  function configuredShutdownTimeout() {
368
915
  try {
369
- return getQueueConfig().workers?.shutdownTimeout ?? DEFAULT_SHUTDOWN_TIMEOUT;
916
+ return getQueueConfig().workers?.shutdownTimeout ?? 3e4;
370
917
  } catch {
371
- return DEFAULT_SHUTDOWN_TIMEOUT;
372
- }
373
- }
374
- async function closeWorker(worker, timeout) {
375
- let timer;
376
- const timedOut = new Promise((resolve) => {
377
- timer = setTimeout(() => resolve("timeout"), timeout);
378
- });
379
- const closing = worker.close().then(() => "closed");
380
- closing.catch(() => void 0);
381
- const outcome = await Promise.race([closing, timedOut]);
382
- clearTimeout(timer);
383
- if (outcome === "timeout") {
384
- _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.`);
385
- await worker.disconnect();
918
+ return 3e4;
386
919
  }
387
920
  }
388
921
 
@@ -402,6 +935,7 @@ async function closeWorker(worker, timeout) {
402
935
  * await queueDashboard(getHttpServer(), { basePath: "/admin/queues" });
403
936
  */
404
937
  async function queueDashboard(server, options = {}) {
938
+ if ((getQueueConfig().driver ?? "bullmq") !== "bullmq") throw new Error("The bull-board dashboard requires the bullmq queue driver. Use job.find() and failedJobs() with local or memory queues.");
405
939
  const basePath = options.basePath ?? "/admin/queues";
406
940
  const { createBullBoard, BullMQAdapter, FastifyAdapter } = await loadBullBoard();
407
941
  const queueNames = options.queues ?? [.../* @__PURE__ */ new Set([defaultQueueName(), ...registeredJobs().map((job) => queueOf(job))])];
@@ -473,6 +1007,7 @@ const DEFAULT_DASHBOARD_PATH = "/admin/queues";
473
1007
  async function mountQueueDashboard(server, config) {
474
1008
  const dashboard = config.dashboard;
475
1009
  if (!dashboard?.enabled) return;
1010
+ if ((config.driver ?? "bullmq") !== "bullmq") throw new Error("The bull-board dashboard requires the bullmq queue driver.");
476
1011
  const middlewareList = dashboard.middleware ?? [];
477
1012
  if (middlewareList.length === 0) {
478
1013
  if (process.env.NODE_ENV === "production") throw new QueueDashboardUnguardedError();
@@ -524,6 +1059,21 @@ function toMilliseconds(value) {
524
1059
  return Math.round(Number(match[1]) * UNIT_MILLISECONDS[match[2]]);
525
1060
  }
526
1061
 
1062
+ //#endregion
1063
+ //#region ../../packages/queue/src/job-options.ts
1064
+ function resolveJobOptions(definition, options) {
1065
+ const defaults = getQueueConfig().defaultJobOptions ?? {};
1066
+ return {
1067
+ attempts: options.attempts ?? definition.attempts ?? defaults.attempts,
1068
+ backoff: options.backoff ?? definition.backoff ?? defaults.backoff,
1069
+ removeOnComplete: definition.removeOnComplete ?? defaults.removeOnComplete,
1070
+ removeOnFail: definition.removeOnFail ?? defaults.removeOnFail,
1071
+ delay: options.delay === void 0 ? void 0 : toMilliseconds(options.delay),
1072
+ priority: options.priority,
1073
+ jobId: options.jobId
1074
+ };
1075
+ }
1076
+
527
1077
  //#endregion
528
1078
  //#region ../../packages/queue/src/define-job.ts
529
1079
  /**
@@ -562,12 +1112,12 @@ function defineJob(definition) {
562
1112
  options
563
1113
  });
564
1114
  const captured = definition.context === false ? void 0 : getQueueContext()?.capture();
565
- const job = await getQueue(queueName).add(definition.name, wrapPayload(payload, captured), toBullJobOptions(definition, options));
566
- return {
567
- id: String(job.id),
1115
+ return getQueueDriver().dispatch({
568
1116
  name: definition.name,
569
- queue: queueName
570
- };
1117
+ queue: queueName,
1118
+ data: wrapPayload(payload, captured),
1119
+ options: resolveJobOptions(definition, options)
1120
+ });
571
1121
  },
572
1122
  async find(id) {
573
1123
  const interceptor = getDispatchInterceptor();
@@ -576,11 +1126,8 @@ function defineJob(definition) {
576
1126
  queue: queueOf(definition),
577
1127
  id
578
1128
  });
579
- const queue = getQueue(queueOf(definition));
580
- const initialJob = await queue.getJob(id);
581
- if (!initialJob || initialJob.name !== definition.name) return;
582
- const state = await initialJob.getState();
583
- return toSnapshot(await queue.getJob(id) ?? initialJob, state);
1129
+ const snapshot = await getQueueDriver().find(queueOf(definition), id);
1130
+ return snapshot?.name === definition.name ? snapshot : void 0;
584
1131
  }
585
1132
  };
586
1133
  }
@@ -589,88 +1136,16 @@ function assertValidDefinition(definition) {
589
1136
  if (typeof definition.handle !== "function") throw new InvalidJobDefinitionError(`defineJob("${definition.name}") requires a \`handle(payload, ctx)\` function.`);
590
1137
  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}.`);
591
1138
  }
592
- /**
593
- * Merge app defaults < job definition < dispatch options into BullMQ's shape.
594
- */
595
- function toBullJobOptions(definition, options) {
596
- const defaults = getQueueConfig().defaultJobOptions ?? {};
597
- const attempts = options.attempts ?? definition.attempts ?? defaults.attempts;
598
- const backoff = options.backoff ?? definition.backoff ?? defaults.backoff;
599
- const removeOnComplete = definition.removeOnComplete ?? defaults.removeOnComplete;
600
- const removeOnFail = definition.removeOnFail ?? defaults.removeOnFail;
601
- const bullOptions = {};
602
- if (attempts !== void 0) bullOptions.attempts = attempts;
603
- if (backoff !== void 0) bullOptions.backoff = toBullBackoff(backoff);
604
- if (removeOnComplete !== void 0) bullOptions.removeOnComplete = removeOnComplete;
605
- if (removeOnFail !== void 0) bullOptions.removeOnFail = removeOnFail;
606
- if (options.delay !== void 0) bullOptions.delay = toMilliseconds(options.delay);
607
- if (options.priority !== void 0) bullOptions.priority = options.priority;
608
- if (options.jobId !== void 0) bullOptions.jobId = options.jobId;
609
- return bullOptions;
610
- }
611
- function toBullBackoff(backoff) {
612
- return typeof backoff === "number" ? {
613
- type: "fixed",
614
- delay: backoff
615
- } : backoff;
616
- }
617
- /**
618
- * A plain view of a BullMQ job.
619
- *
620
- * @param job The job to read fields from.
621
- * @param state The job's state; pass a state read *before* `job` was
622
- * fetched (or re-fetched) so the returned snapshot's fields are consistent
623
- * with it. If omitted, the state is read from `job` directly.
624
- */
625
- async function toSnapshot(job, state) {
626
- const resolvedState = state ?? await job.getState();
627
- return {
628
- id: String(job.id),
629
- name: job.name,
630
- queue: job.queueName,
631
- state: resolvedState,
632
- payload: unwrapPayload(job.data).payload,
633
- progress: job.progress,
634
- attemptsMade: job.attemptsMade,
635
- result: job.returnvalue,
636
- failedReason: job.failedReason || void 0,
637
- createdAt: new Date(job.timestamp),
638
- finishedAt: job.finishedOn ? new Date(job.finishedOn) : void 0
639
- };
640
- }
641
1139
 
642
1140
  //#endregion
643
1141
  //#region ../../packages/queue/src/failed-jobs.ts
644
- /**
645
- * List failed jobs, newest first — jobs that used up every attempt, or
646
- * failed unrecoverably. Each entry can be retried.
647
- */
1142
+ /** List failed jobs, newest first, using the selected driver. */
648
1143
  async function failedJobs(options = {}) {
649
- const queueName = options.queue ?? defaultQueueName();
650
- return (await getQueue(queueName).getFailed(options.start ?? 0, options.end ?? 99)).map((job) => toFailedJob(job));
1144
+ return getQueueDriver().failed(options.queue ?? defaultQueueName(), options.start ?? 0, options.end ?? 99);
651
1145
  }
652
- /**
653
- * Retry one failed job by id: it goes back to waiting with its attempts reset.
654
- * Throws {@link FailedJobNotFoundError} when no FAILED job has that id.
655
- */
1146
+ /** Move a failed job back to waiting, resetting its attempts. */
656
1147
  async function retryFailedJob(id, options = {}) {
657
- const queueName = options.queue ?? defaultQueueName();
658
- const job = await getQueue(queueName).getJob(id);
659
- if (!job || !await job.isFailed()) throw new FailedJobNotFoundError(id, queueName);
660
- await job.retry("failed");
661
- }
662
- function toFailedJob(job) {
663
- return {
664
- id: String(job.id),
665
- name: job.name,
666
- queue: job.queueName,
667
- payload: unwrapPayload(job.data).payload,
668
- attemptsMade: job.attemptsMade,
669
- failedReason: job.failedReason,
670
- stacktrace: job.stacktrace ?? [],
671
- failedAt: job.finishedOn ? new Date(job.finishedOn) : void 0,
672
- retry: () => job.retry("failed")
673
- };
1148
+ await getQueueDriver().retry(options.queue ?? defaultQueueName(), id);
674
1149
  }
675
1150
 
676
1151
  //#endregion
@@ -774,6 +1249,7 @@ exports.getDispatchInterceptor = getDispatchInterceptor;
774
1249
  exports.getQueue = getQueue;
775
1250
  exports.getQueueConfig = getQueueConfig;
776
1251
  exports.getQueueContext = getQueueContext;
1252
+ exports.getQueueDriver = getQueueDriver;
777
1253
  exports.loadBullBoard = loadBullBoard;
778
1254
  exports.mountQueueDashboard = mountQueueDashboard;
779
1255
  exports.queueConnector = queueConnector;