@bymax-one/nest-queue 1.2.9 → 1.2.11

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.
@@ -165,7 +165,6 @@ function applyDefaults(opts) {
165
165
  Object.defineProperty(base, "connection", {
166
166
  get: () => connection,
167
167
  enumerable: false,
168
- // Stryker disable next-line BooleanLiteral: equivalent HERE — this object is `Object.freeze`d on the way out, and freezing makes every property non-configurable regardless. The flag stays because it states the guarantee at the point the accessor is defined, and because the sibling packages that withhold secrets the same way are NOT frozen, where it is load-bearing
169
168
  configurable: false
170
169
  });
171
170
  return Object.freeze(base);
@@ -202,21 +201,11 @@ function duplicateConnection(client) {
202
201
  var _client, _options;
203
202
  exports.ConnectionResolver = class ConnectionResolver {
204
203
  constructor(options) {
205
- /**
206
- * The resolved Redis client.
207
- *
208
- * An ECMAScript private field rather than a TypeScript `private` one, which
209
- * is erased at runtime: an ioredis instance carries `options.password` as a
210
- * plain field, so leaving this enumerable would let anything that serializes
211
- * a service holding this resolver walk into the credentials.
212
- */
213
204
  __privateAdd(this, _client);
214
- /** The consumer's module options, which carry the Redis credentials. */
215
205
  __privateAdd(this, _options);
216
206
  __publicField(this, "mode");
217
207
  __privateSet(this, _options, options);
218
208
  }
219
- /** Resolve and validate the connection. Call once during module bootstrap. */
220
209
  async init() {
221
210
  const cfg = __privateGet(this, _options).connection;
222
211
  if ("client" in cfg) {
@@ -229,18 +218,9 @@ exports.ConnectionResolver = class ConnectionResolver {
229
218
  __privateGet(this, _options).connectionReadyTimeoutMs ?? DEFAULT_CONNECTION_READY_TIMEOUT_MS
230
219
  );
231
220
  }
232
- /**
233
- * Merge the consumer's options with the owned-connection policy
234
- * (`lazyConnect: false`, so the client connects eagerly and `init` can wait
235
- * for `ready`) and narrow the result to the shape ioredis 6's constructor
236
- * accepts. The runtime object is unchanged — the cast only drops the
237
- * `undefined` that `RedisOptions.replyMapping` carries and the constructor
238
- * rejects.
239
- */
240
221
  ownedOptions(options) {
241
222
  return { ...options ?? {}, lazyConnect: false };
242
223
  }
243
- /** The resolved Queue-role client. */
244
224
  getClient() {
245
225
  if (!__privateGet(this, _client)) {
246
226
  throw new QueueException(
@@ -253,7 +233,6 @@ exports.ConnectionResolver = class ConnectionResolver {
253
233
  }
254
234
  return __privateGet(this, _client);
255
235
  }
256
- /** The resolved connection mode. */
257
236
  getMode() {
258
237
  if (!this.mode) {
259
238
  throw new QueueException(
@@ -266,11 +245,9 @@ exports.ConnectionResolver = class ConnectionResolver {
266
245
  }
267
246
  return this.mode;
268
247
  }
269
- /** Whether the library owns (and must close) the connection. */
270
248
  isOwned() {
271
249
  return this.mode === "mode-b-owned";
272
250
  }
273
- /** Close the library-owned connection on shutdown; never touch a BYO client. */
274
251
  async teardown() {
275
252
  if (this.isOwned() && __privateGet(this, _client)) {
276
253
  const client = __privateGet(this, _client);
@@ -279,7 +256,6 @@ exports.ConnectionResolver = class ConnectionResolver {
279
256
  });
280
257
  }
281
258
  }
282
- /** Validate and adopt a bring-your-own client for the Queue role. */
283
259
  initModeA(client) {
284
260
  this.mode = "mode-a-byo";
285
261
  __privateSet(this, _client, client);
@@ -297,7 +273,6 @@ exports.ConnectionResolver = class ConnectionResolver {
297
273
  probe.disconnect();
298
274
  }
299
275
  }
300
- /** Resolve when the owned client reaches `ready`, or reject on timeout/error. */
301
276
  async waitReady(timeoutMs) {
302
277
  const client = this.getClient();
303
278
  if (client.status === "ready") return;
@@ -389,8 +364,15 @@ function attachDefaultErrorListener(emitter, logger, kind, queueName) {
389
364
 
390
365
  // src/server/services/queue.service.ts
391
366
  var MAX_BULK_SIZE = 1e3;
392
- function isOperationalError(err) {
393
- return typeof err?.code === "string";
367
+ function assertValidCron(repeat, queueOptions) {
368
+ if (queueOptions.settings?.repeatStrategy !== void 0) return;
369
+ try {
370
+ bullmq.defaultRepeatStrategy(Date.now(), repeat);
371
+ } catch {
372
+ throw new QueueException(QUEUE_ERROR_CODES.INVALID_REPEAT_OPTIONS, common.HttpStatus.BAD_REQUEST, {
373
+ reason: "pattern must be a valid cron expression (5- or 6-field)"
374
+ });
375
+ }
394
376
  }
395
377
  var _queues;
396
378
  exports.QueueService = class QueueService {
@@ -402,14 +384,6 @@ exports.QueueService = class QueueService {
402
384
  }
403
385
  connection;
404
386
  options;
405
- /**
406
- * Return the cached `Queue` for `queueName`, or create one with the module
407
- * defaults applied. Subsequent calls with the same name return the same
408
- * instance.
409
- *
410
- * @param queueName - Unique queue name (used as the Redis key prefix).
411
- * @param overrides - Optional per-queue option overrides.
412
- */
413
387
  getOrCreateQueue(queueName, overrides) {
414
388
  const existing = __privateGet(this, _queues).get(queueName);
415
389
  if (existing) return existing;
@@ -425,70 +399,13 @@ exports.QueueService = class QueueService {
425
399
  __privateGet(this, _queues).set(queueName, queue);
426
400
  return queue;
427
401
  }
428
- /** Resolve the raw cached queue (default generics) for internal delegation. */
429
402
  queueFor(queueName) {
430
403
  return this.getOrCreateQueue(queueName);
431
404
  }
432
- /**
433
- * Add a single job to a queue. The queue is created lazily if needed.
434
- *
435
- * Delivery is **at-least-once** — a handler may run more than once for the same
436
- * job (worker crash, lock expiry, or a shutdown force-close). Make handlers
437
- * idempotent: use an idempotency key on writes, prefer upserts over inserts, or
438
- * keep an "already-processed" marker keyed by `job.id`.
439
- *
440
- * `options` surfaces BullMQ natives directly — including `jobId` (idempotent
441
- * insert: a second add with the same id is a no-op while the first exists) and
442
- * `deduplication` (windowed deduplication). The library writes no deduplication
443
- * code; BullMQ owns the behavior. The deduplication key is independent of
444
- * `jobId`, so both can be set together or apart. Both collapse duplicate
445
- * **producers** and do not change the at-least-once guarantee on the
446
- * **consumer** side.
447
- *
448
- * The deduplication `id` is a shared key across the queue, so a multi-tenant
449
- * producer MUST scope it by tenant: keyed on the value alone
450
- * (`` `reindex:${term}` ``), tenant A enqueuing a term suppresses tenant B's job
451
- * for the same term.
452
- *
453
- * Scoping alone is not enough. Two variable fields joined by a delimiter that
454
- * either may contain do not produce a unique key: with `` `${tenantId}:${term}` ``,
455
- * the pairs `('a', 'b:c')` and `('a:b', 'c')` both yield `a:b:c`. Percent-encode
456
- * every variable field so the delimiter cannot appear inside one:
457
- * `` `${encodeURIComponent(tenantId)}:${encodeURIComponent(term)}` ``.
458
- *
459
- * Deduplication modes (`deduplication: { id, ttl?, extend?, replace?, keepLastIfActive? }`):
460
- * - Simple `{ id }` — collapse duplicates until the in-flight job completes or fails.
461
- * - Throttle `{ id, ttl }` — ignore duplicates for `ttl` milliseconds.
462
- * - Debounce `{ id, ttl, extend: true, replace: true }` — keep only the latest
463
- * data; each duplicate resets the TTL.
464
- * - keep-last-if-active `{ id, keepLastIfActive: true }` — while a job is active,
465
- * store the latest data and run a single follow-up when it finishes.
466
- *
467
- * @param queueName - Target queue (created lazily if absent).
468
- * @param jobName - Job identifier used by `@Process` to dispatch.
469
- * @param data - Typed job payload.
470
- * @param options - Per-job BullMQ options (priority, delay, `jobId`, `deduplication`, ...).
471
- * @returns The created job.
472
- * @example Throttle: at most one reindex per term every 5 seconds. Both variable
473
- * fields are percent-encoded, so no other tenant/term pair can build this key.
474
- * const dedupId = `${encodeURIComponent(tenantId)}:reindex:${encodeURIComponent(term)}`
475
- * await queueService.enqueue('search', 'reindex', { term }, {
476
- * deduplication: { id: dedupId, ttl: 5_000 },
477
- * })
478
- */
479
405
  async enqueue(queueName, jobName, data, options) {
480
406
  const job = await this.queueFor(queueName).add(jobName, data, options);
481
407
  return job;
482
408
  }
483
- /**
484
- * Enqueue multiple jobs in a single Redis roundtrip. The batch is bounded by
485
- * {@link MAX_BULK_SIZE} to guard against a self-inflicted memory spike; any
486
- * failure is wrapped in a `QueueException`. Delivery is at-least-once, so every
487
- * job's handler must be idempotent (see {@link QueueService.enqueue}).
488
- *
489
- * @param queueName - Target queue.
490
- * @param jobs - Job descriptors (length must not exceed the bulk size cap).
491
- */
492
409
  async enqueueBulk(queueName, jobs) {
493
410
  if (jobs.length > MAX_BULK_SIZE) {
494
411
  throw new QueueException(
@@ -520,33 +437,14 @@ exports.QueueService = class QueueService {
520
437
  );
521
438
  }
522
439
  }
523
- /**
524
- * Fetch a job by id. Returns `null` when the job does not exist.
525
- *
526
- * @param queueName - Target queue.
527
- * @param jobId - The job identifier.
528
- */
529
440
  async getJob(queueName, jobId) {
530
441
  const job = await this.queueFor(queueName).getJob(jobId);
531
442
  return job ?? null;
532
443
  }
533
- /**
534
- * Fetch jobs in a given status with pagination.
535
- *
536
- * @param queueName - Target queue.
537
- * @param status - Status filter.
538
- * @param start - Starting index (inclusive). Default: 0.
539
- * @param end - Ending index (inclusive). Default: 50.
540
- */
541
444
  async getJobs(queueName, status, start = 0, end = 50) {
542
445
  const jobs = await this.queueFor(queueName).getJobs([status], start, end);
543
446
  return jobs;
544
447
  }
545
- /**
546
- * Return an uncached snapshot of job counts grouped by status.
547
- *
548
- * @param queueName - Target queue.
549
- */
550
448
  async getMetrics(queueName) {
551
449
  const counts = await this.queueFor(queueName).getJobCounts(
552
450
  "waiting",
@@ -561,24 +459,6 @@ exports.QueueService = class QueueService {
561
459
  collectedAt: (/* @__PURE__ */ new Date()).toISOString()
562
460
  };
563
461
  }
564
- /**
565
- * Create or update a Job Scheduler — the current BullMQ recurring-jobs API.
566
- * Idempotent by `schedulerId`: BullMQ performs an atomic `override: true`
567
- * upsert, so re-registering on every boot updates the schedule in place and
568
- * never duplicates it. This supersedes the repeatable-jobs surface removed in
569
- * BullMQ v6.
570
- *
571
- * The schedule is structurally validated first; the cron string itself is
572
- * parsed by BullMQ at registration time, and a parse failure is rethrown as
573
- * `INVALID_REPEAT_OPTIONS` (400).
574
- *
575
- * @param queueName - Target queue.
576
- * @param schedulerId - Stable, unique scheduler identifier (the upsert key).
577
- * @param repeat - Cron `pattern` OR `every` (ms), plus optional tz/limit/start/end.
578
- * @param template - Optional job template; `name` defaults to `schedulerId`, `data` to `{}`.
579
- * @returns The first scheduled (delayed) job, or `undefined`.
580
- * @throws {QueueException} `INVALID_REPEAT_OPTIONS` (400) for an invalid schedule.
581
- */
582
462
  async upsertJobScheduler(queueName, schedulerId, repeat, template) {
583
463
  validateJobSchedulerOptions(repeat);
584
464
  const queue = this.queueFor(queueName);
@@ -587,76 +467,28 @@ exports.QueueService = class QueueService {
587
467
  data: template?.data ?? {},
588
468
  ...template?.opts === void 0 ? {} : { opts: template.opts }
589
469
  };
590
- if ("pattern" in repeat) {
591
- try {
592
- const job2 = await queue.upsertJobScheduler(schedulerId, repeat, jobTemplate);
593
- return job2;
594
- } catch (err) {
595
- if (isOperationalError(err)) throw err;
596
- throw new QueueException(QUEUE_ERROR_CODES.INVALID_REPEAT_OPTIONS, common.HttpStatus.BAD_REQUEST, {
597
- reason: "pattern must be a valid cron expression (5- or 6-field)"
598
- });
599
- }
600
- }
470
+ if ("pattern" in repeat) assertValidCron(repeat, this.options.queueOptions);
601
471
  const job = await queue.upsertJobScheduler(schedulerId, repeat, jobTemplate);
602
472
  return job;
603
473
  }
604
- /**
605
- * Remove a Job Scheduler by id.
606
- *
607
- * @param queueName - Target queue.
608
- * @param schedulerId - The scheduler identifier to remove.
609
- * @returns `true` when a scheduler was removed, `false` otherwise.
610
- */
611
474
  async removeJobScheduler(queueName, schedulerId) {
612
475
  return this.queueFor(queueName).removeJobScheduler(schedulerId);
613
476
  }
614
- /**
615
- * List the Job Schedulers registered on a queue, for inspection or health checks.
616
- *
617
- * @param queueName - Target queue.
618
- * @param start - Page start index (inclusive). Default: 0.
619
- * @param end - Page end index (inclusive). Default: 50.
620
- * @param asc - Sort ascending by next run time. Default: true.
621
- * @returns The registered schedulers in the requested page.
622
- */
623
477
  async getJobSchedulers(queueName, start = 0, end = 50, asc = true) {
624
478
  return this.queueFor(queueName).getJobSchedulers(start, end, asc);
625
479
  }
626
- /** Pause a queue, halting the processing of new jobs. */
627
480
  async pauseQueue(queueName) {
628
481
  await this.queueFor(queueName).pause();
629
482
  }
630
- /** Resume a previously paused queue. */
631
483
  async resumeQueue(queueName) {
632
484
  await this.queueFor(queueName).resume();
633
485
  }
634
- /**
635
- * Remove up to `limit` jobs older than `gracePeriodMs` in a given status. The
636
- * argument order mirrors BullMQ's `Queue.clean(grace, limit, type?)` exactly;
637
- * `limit` is required and `0` means no limit. Returns the removed job ids.
638
- *
639
- * @param queueName - Target queue.
640
- * @param gracePeriodMs - Keep jobs younger than this many milliseconds.
641
- * @param limit - Maximum number of jobs to remove (`0` = no limit).
642
- * @param status - Status of jobs to clean.
643
- */
644
486
  async cleanQueue(queueName, gracePeriodMs, limit, status) {
645
487
  return this.queueFor(queueName).clean(gracePeriodMs, limit, status);
646
488
  }
647
- /** Return a read-only view of the cached queues. */
648
489
  getCachedQueues() {
649
490
  return __privateGet(this, _queues);
650
491
  }
651
- /**
652
- * Close every cached queue (swallowing per-queue errors) and clear the cache.
653
- *
654
- * Deliberately NOT named `onModuleDestroy`: NestJS invokes lifecycle hooks by
655
- * method name, so a hook here would run on Nest's own schedule, concurrently
656
- * with — and possibly before — the bounded drain in `QueueLifecycle`. Closing a
657
- * queue while its worker is still draining is exactly what the ordered sequence
658
- * exists to prevent. `QueueLifecycle` is the only caller.
659
- */
660
492
  async closeAll() {
661
493
  for (const queue of __privateGet(this, _queues).values()) {
662
494
  await queue.close().catch(() => void 0);
@@ -677,73 +509,26 @@ exports.FlowService = class FlowService {
677
509
  if (enabled) {
678
510
  this.producer = new bullmq.FlowProducer({
679
511
  connection: connection.getClient(),
680
- // Match the producer Queue's key prefix so flow jobs land in the same
681
- // keyspace the workers poll; a non-default prefix breaks flows otherwise.
682
512
  prefix,
683
513
  ...telemetry ? { telemetry } : {}
684
514
  });
685
515
  attachDefaultErrorListener(this.producer, this.logger, "FlowProducer", "*");
686
516
  }
687
517
  }
688
- /**
689
- * Add a flow tree (a parent with its descendant children). The root job
690
- * becomes processable only after every descendant has completed.
691
- *
692
- * By BullMQ default a failed (retry-exhausted) child does NOT fail its parent:
693
- * the parent stays in the `waiting-children` state until the child eventually
694
- * completes. To make a child's failure propagate upward and fail its parent,
695
- * set `failParentOnFailure: true` on that child (it cascades when ancestors
696
- * also set it). To let a parent proceed despite a failed child, set
697
- * `ignoreDependencyOnFailure: true` on that child. Bound each child's
698
- * `attempts` so an unrecoverable child cannot leave the parent waiting forever.
699
- *
700
- * @param flow - Flow tree definition.
701
- * @returns The root `JobNode` containing the created job and its children.
702
- * @throws {QueueException} `FLOW_DISABLED` (503) when flows are not enabled.
703
- */
704
518
  async add(flow) {
705
519
  return this.requireProducer().add(flow);
706
520
  }
707
- /**
708
- * Add multiple flow trees in a single Redis roundtrip.
709
- *
710
- * @param flows - Flow tree definitions.
711
- * @returns The root `JobNode` for each flow, in input order.
712
- * @throws {QueueException} `FLOW_DISABLED` (503) when flows are not enabled.
713
- */
714
521
  async addBulk(flows) {
715
522
  return this.requireProducer().addBulk([...flows]);
716
523
  }
717
- /**
718
- * Escape hatch returning the underlying `FlowProducer` for advanced use cases.
719
- * Prefer {@link FlowService.add} / {@link FlowService.addBulk} when possible.
720
- *
721
- * @returns The underlying BullMQ `FlowProducer`.
722
- * @throws {QueueException} `FLOW_DISABLED` (503) when flows are not enabled.
723
- */
724
524
  getProducer() {
725
525
  return this.requireProducer();
726
526
  }
727
- /**
728
- * Close the producer when active; a no-op when inactive. A failed `close()` is
729
- * swallowed so it never aborts the shutdown sequence.
730
- *
731
- * Deliberately NOT named `onModuleDestroy`: NestJS invokes lifecycle hooks by
732
- * method name, so a hook here would run in addition to the call the ordered
733
- * sequence already makes, closing the producer twice. `QueueLifecycle` is the
734
- * only caller.
735
- */
736
527
  async close() {
737
528
  if (this.producer) {
738
529
  await this.producer.close().catch(() => void 0);
739
530
  }
740
531
  }
741
- /**
742
- * Return the active producer or throw when flows are disabled. The producer is
743
- * present if and only if flows were enabled at construction time.
744
- *
745
- * @throws {QueueException} `FLOW_DISABLED` (503) when no producer exists.
746
- */
747
532
  requireProducer() {
748
533
  if (!this.producer) {
749
534
  throw new QueueException(QUEUE_ERROR_CODES.FLOW_DISABLED, common.HttpStatus.SERVICE_UNAVAILABLE);
@@ -764,14 +549,6 @@ exports.MetricsService = class MetricsService {
764
549
  enabled;
765
550
  ttlMs;
766
551
  cache = /* @__PURE__ */ new Map();
767
- /**
768
- * Return cached metrics for `queueName`, or fetch fresh (and cache them) on a
769
- * miss or after expiry.
770
- *
771
- * @param queueName - Target queue.
772
- * @returns The current metrics snapshot.
773
- * @throws {QueueException} `METRICS_DISABLED` (503) when metrics are disabled.
774
- */
775
552
  async get(queueName) {
776
553
  this.ensureEnabled();
777
554
  const entry = this.cache.get(queueName);
@@ -782,23 +559,11 @@ exports.MetricsService = class MetricsService {
782
559
  this.cache.set(queueName, { metrics, expiresAt: Date.now() + this.ttlMs });
783
560
  return metrics;
784
561
  }
785
- /**
786
- * Return a metrics snapshot for every queue currently cached in `QueueService`.
787
- *
788
- * @returns Metrics for all known queues.
789
- * @throws {QueueException} `METRICS_DISABLED` (503) when metrics are disabled.
790
- */
791
562
  async getAll() {
792
563
  this.ensureEnabled();
793
564
  const names = Array.from(this.queueService.getCachedQueues().keys());
794
565
  return Promise.all(names.map((name) => this.get(name)));
795
566
  }
796
- /**
797
- * Invalidate the cache for `queueName`, or the whole cache when omitted.
798
- *
799
- * @param queueName - The queue entry to drop; omit to clear everything.
800
- * @throws {QueueException} `METRICS_DISABLED` (503) when metrics are disabled.
801
- */
802
567
  invalidate(queueName) {
803
568
  this.ensureEnabled();
804
569
  if (queueName === void 0) {
@@ -807,11 +572,6 @@ exports.MetricsService = class MetricsService {
807
572
  }
808
573
  this.cache.delete(queueName);
809
574
  }
810
- /**
811
- * Guard every operation behind the opt-in flag.
812
- *
813
- * @throws {QueueException} `METRICS_DISABLED` (503) when metrics are disabled.
814
- */
815
575
  ensureEnabled() {
816
576
  if (!this.enabled) {
817
577
  throw new QueueException(QUEUE_ERROR_CODES.METRICS_DISABLED, common.HttpStatus.SERVICE_UNAVAILABLE);
@@ -831,19 +591,6 @@ exports.WorkerRegistry = class WorkerRegistry {
831
591
  }
832
592
  connection;
833
593
  options;
834
- /**
835
- * Register an in-process worker with a handler function. The BullMQ `Worker`
836
- * constructor is synchronous — no Redis handshake is performed at registration
837
- * time. The duplicated connection is disconnected immediately if construction
838
- * fails, preventing a Redis connection leak.
839
- *
840
- * @param config - Queue name, handler, and optional worker options.
841
- * @returns The created BullMQ `Worker` instance.
842
- * @throws {QueueException} `DUPLICATE_PROCESSOR` when a worker for
843
- * `queueName` already exists.
844
- * @throws {QueueException} `WORKER_REGISTRATION_FAILED` when options are
845
- * invalid or the BullMQ `Worker` constructor throws.
846
- */
847
594
  register(config) {
848
595
  const { queueName, handler, options } = config;
849
596
  this.guardDuplicate(queueName);
@@ -868,21 +615,6 @@ exports.WorkerRegistry = class WorkerRegistry {
868
615
  __privateGet(this, _entries).set(queueName, { worker, connection: conn });
869
616
  return worker;
870
617
  }
871
- /**
872
- * Register a sandboxed (file-based, out-of-process) worker. The processor
873
- * file must be an absolute path or a `file:` URL pointing to a compiled
874
- * `.js`/`.cjs`/`.mjs` artifact. NestJS DI is **not** available inside the
875
- * processor. The BullMQ `Worker` constructor is synchronous.
876
- *
877
- * @param config - Queue name, processor file path/URL, and optional options.
878
- * @returns The created BullMQ `Worker` instance.
879
- * @throws {QueueException} `WORKER_REGISTRATION_FAILED` when `processorFile`
880
- * is a relative path, has a disallowed extension, or uses a non-`file:` URL protocol.
881
- * @throws {QueueException} `DUPLICATE_PROCESSOR` when a worker for
882
- * `queueName` already exists.
883
- * @throws {QueueException} `WORKER_REGISTRATION_FAILED` when options are
884
- * invalid or the BullMQ `Worker` constructor throws.
885
- */
886
618
  registerSandboxed(config) {
887
619
  const { queueName, processorFile, options } = config;
888
620
  this.validateProcessorFile(processorFile);
@@ -911,13 +643,6 @@ exports.WorkerRegistry = class WorkerRegistry {
911
643
  __privateGet(this, _entries).set(queueName, { worker, connection: conn });
912
644
  return worker;
913
645
  }
914
- /**
915
- * Stop and remove the worker for the given queue, releasing the duplicated
916
- * connection the library opened for it. If no worker exists for `queueName`
917
- * this is a no-op.
918
- *
919
- * @param queueName - The queue whose worker should be removed.
920
- */
921
646
  async unregister(queueName) {
922
647
  const entry = __privateGet(this, _entries).get(queueName);
923
648
  if (!entry) return;
@@ -925,44 +650,19 @@ exports.WorkerRegistry = class WorkerRegistry {
925
650
  await this.closeConnection(entry.connection);
926
651
  __privateGet(this, _entries).delete(queueName);
927
652
  }
928
- /**
929
- * Returns the queue names of all currently registered workers.
930
- *
931
- * @returns An immutable array of queue names.
932
- */
933
653
  list() {
934
654
  return Array.from(__privateGet(this, _entries).keys());
935
655
  }
936
- /**
937
- * Returns a read-only view of the registered workers keyed by queue name.
938
- * Intended for the shutdown orchestrator only.
939
- *
940
- * @returns A read-only map of queue name to worker.
941
- */
942
656
  getAll() {
943
657
  return new Map(
944
658
  Array.from(__privateGet(this, _entries), ([name, entry]) => [name, entry.worker])
945
659
  );
946
660
  }
947
- /**
948
- * Returns a read-only view of the duplicated connections the library opened
949
- * for its workers, keyed by queue name. Intended for the shutdown orchestrator
950
- * so it can close exactly the connections the library created (Mode A never
951
- * exposes the consumer's shared client here).
952
- *
953
- * @returns A read-only map of queue name to duplicated connection.
954
- */
955
661
  getConnections() {
956
662
  return new Map(
957
663
  Array.from(__privateGet(this, _entries), ([name, entry]) => [name, entry.connection])
958
664
  );
959
665
  }
960
- /**
961
- * Gracefully quit a duplicated connection, falling back to a hard disconnect
962
- * if the quit handshake fails so a socket is never leaked.
963
- *
964
- * @param connection - The duplicated connection to release.
965
- */
966
666
  async closeConnection(connection) {
967
667
  try {
968
668
  await connection.quit();
@@ -970,12 +670,6 @@ exports.WorkerRegistry = class WorkerRegistry {
970
670
  connection.disconnect();
971
671
  }
972
672
  }
973
- /**
974
- * Guard against registering two workers for the same queue.
975
- *
976
- * @param queueName - Queue name to check.
977
- * @throws {QueueException} `DUPLICATE_PROCESSOR` if the queue is already registered.
978
- */
979
673
  guardDuplicate(queueName) {
980
674
  if (__privateGet(this, _entries).has(queueName)) {
981
675
  throw new QueueException(
@@ -985,14 +679,6 @@ exports.WorkerRegistry = class WorkerRegistry {
985
679
  );
986
680
  }
987
681
  }
988
- /**
989
- * Validate tunable options before constructing a Worker.
990
- * Enforces `concurrency` in `[1, MAX_WORKER_CONCURRENCY]` and
991
- * `limiter.max`/`limiter.duration` `>= 1`.
992
- *
993
- * @param opts - The caller-supplied options (may be undefined).
994
- * @throws {QueueException} `WORKER_REGISTRATION_FAILED` when values are out of range.
995
- */
996
682
  validateOptions(opts) {
997
683
  if (opts?.concurrency !== void 0) {
998
684
  if (opts.concurrency < 1) {
@@ -1039,15 +725,6 @@ exports.WorkerRegistry = class WorkerRegistry {
1039
725
  }
1040
726
  }
1041
727
  }
1042
- /**
1043
- * Validate that `processorFile` is a safe, reachable artifact path. Accepts
1044
- * absolute paths with `.js`/`.cjs`/`.mjs` extensions and `file:` URLs. Rejects
1045
- * relative paths, non-JS extensions, and non-`file:` URL protocols to prevent
1046
- * path-traversal and arbitrary-file-execution attacks.
1047
- *
1048
- * @param processorFile - The path or URL supplied by the caller.
1049
- * @throws {QueueException} `WORKER_REGISTRATION_FAILED` when validation fails.
1050
- */
1051
728
  validateProcessorFile(processorFile) {
1052
729
  if (processorFile instanceof URL) {
1053
730
  if (processorFile.protocol !== "file:") {
@@ -1073,13 +750,6 @@ exports.WorkerRegistry = class WorkerRegistry {
1073
750
  }
1074
751
  this.assertProcessorExtension(processorFile);
1075
752
  }
1076
- /**
1077
- * Reject a processor path whose extension is not an executable JS module,
1078
- * the last line of defense against loading an arbitrary file as a worker.
1079
- *
1080
- * @param filePath - The filesystem path (or file: URL pathname) to check.
1081
- * @throws {QueueException} `WORKER_REGISTRATION_FAILED` for a disallowed extension.
1082
- */
1083
753
  assertProcessorExtension(filePath) {
1084
754
  const allowedExtensions = /* @__PURE__ */ new Set([".js", ".cjs", ".mjs"]);
1085
755
  if (!allowedExtensions.has(path.extname(filePath))) {
@@ -1092,23 +762,11 @@ exports.WorkerRegistry = class WorkerRegistry {
1092
762
  );
1093
763
  }
1094
764
  }
1095
- /**
1096
- * Build a BullMQ `WorkerOptions` object from the library's `WorkerOptions`
1097
- * shape. Only forwards optional fields that are explicitly set to preserve
1098
- * `exactOptionalPropertyTypes` correctness. Accepts the pre-created duplicated
1099
- * connection so callers can close it on construction failure.
1100
- *
1101
- * @param opts - Library-level worker options.
1102
- * @param conn - Pre-created duplicated Redis connection.
1103
- * @returns A BullMQ-compatible options object.
1104
- */
1105
765
  buildBullOptions(opts, conn) {
1106
766
  const result = {
1107
767
  connection: conn,
1108
768
  concurrency: opts?.concurrency ?? DEFAULT_WORKER_CONCURRENCY,
1109
769
  autorun: opts?.autorun ?? true,
1110
- // Match the producer Queue's key prefix so the worker polls the same
1111
- // keyspace; without this a non-default prefix leaves jobs unconsumed.
1112
770
  prefix: this.options.prefix
1113
771
  };
1114
772
  if (opts?.limiter !== void 0) result.limiter = opts.limiter;
@@ -1135,14 +793,6 @@ exports.QueueEventsRegistry = class QueueEventsRegistry {
1135
793
  }
1136
794
  connection;
1137
795
  options;
1138
- /**
1139
- * Returns the cached `QueueEvents` for `queueName`, creating one on first
1140
- * call. Subsequent calls for the same name return the same instance without
1141
- * opening a second Redis connection.
1142
- *
1143
- * @param queueName - The BullMQ queue to observe.
1144
- * @returns The `QueueEvents` instance for the queue.
1145
- */
1146
796
  getOrCreate(queueName) {
1147
797
  const existing = __privateGet(this, _events).get(queueName);
1148
798
  if (existing) return existing;
@@ -1159,35 +809,12 @@ exports.QueueEventsRegistry = class QueueEventsRegistry {
1159
809
  __privateGet(this, _connections).set(queueName, conn);
1160
810
  return qe;
1161
811
  }
1162
- /**
1163
- * Returns the queue names for which a `QueueEvents` connection is open.
1164
- *
1165
- * @returns An immutable array of queue names.
1166
- */
1167
812
  list() {
1168
813
  return Array.from(__privateGet(this, _events).keys());
1169
814
  }
1170
- /**
1171
- * Returns the live `QueueEvents` map. Intended for the shutdown orchestrator.
1172
- *
1173
- * @returns A read-only view of the internal map.
1174
- */
1175
815
  getAll() {
1176
816
  return __privateGet(this, _events);
1177
817
  }
1178
- /**
1179
- * Returns a read-only view of the duplicated connections the library opened
1180
- * for its `QueueEvents`, keyed by queue name. Intended for the shutdown
1181
- * orchestrator so it can close exactly the connections the library created
1182
- * (Mode A never exposes the consumer's shared client here).
1183
- *
1184
- * Returns a copy. `ReadonlyMap` is a compile-time claim only — handing back the
1185
- * live registry lets any consumer cast it away and delete a connection the
1186
- * shutdown sequence is about to close. The allocation costs nothing on a path
1187
- * that runs once per shutdown.
1188
- *
1189
- * @returns A read-only snapshot of queue name to duplicated connection.
1190
- */
1191
818
  getConnections() {
1192
819
  return new Map(__privateGet(this, _connections));
1193
820
  }
@@ -1207,6 +834,7 @@ var WORKER_EVENT_LISTENERS_METADATA_KEY = /* @__PURE__ */ Symbol("bymax_queue:wo
1207
834
  var QUEUE_EVENT_LISTENERS_METADATA_KEY = /* @__PURE__ */ Symbol("bymax_queue:queue_event_listeners");
1208
835
 
1209
836
  // src/server/services/processor-discovery.service.ts
837
+ var EVENT_HANDLER_FAILED = "queue.event_handler_failed";
1210
838
  var ProcessorDiscoveryService = class {
1211
839
  constructor(discovery, workers, events) {
1212
840
  this.discovery = discovery;
@@ -1218,10 +846,6 @@ var ProcessorDiscoveryService = class {
1218
846
  events;
1219
847
  logger = new common.Logger(ProcessorDiscoveryService.name);
1220
848
  registeredQueues = /* @__PURE__ */ new Set();
1221
- /**
1222
- * Entry point called by NestJS after the module is fully initialized.
1223
- * Discovers all `@Processor`-annotated providers and wires them.
1224
- */
1225
849
  onModuleInit() {
1226
850
  const providers = this.discovery.getProviders();
1227
851
  for (const wrapper of providers) {
@@ -1233,15 +857,6 @@ var ProcessorDiscoveryService = class {
1233
857
  this.wireProcessor(instance, processorMeta);
1234
858
  }
1235
859
  }
1236
- /**
1237
- * Build a dispatcher function that routes `job.name` to the correct handler.
1238
- * Named handlers take priority; a catch-all (`@Process()`) handles everything
1239
- * else. Throws if no matching handler is found at job-processing time.
1240
- *
1241
- * @param instance - The provider instance whose methods serve as handlers.
1242
- * @param handlers - Accumulated `@Process` metadata entries for the class.
1243
- * @returns A dispatcher `(job: Job) => Promise<unknown>`.
1244
- */
1245
860
  buildDispatcher(instance, handlers) {
1246
861
  const namedHandlers = /* @__PURE__ */ new Map();
1247
862
  let catchAll;
@@ -1266,13 +881,6 @@ var ProcessorDiscoveryService = class {
1266
881
  );
1267
882
  };
1268
883
  }
1269
- /**
1270
- * Wire a single discovered processor class: validate uniqueness, register a
1271
- * Worker, bind worker-local and queue-global event listeners.
1272
- *
1273
- * @param instance - The resolved provider instance.
1274
- * @param processorMeta - Metadata written by `@Processor`.
1275
- */
1276
884
  wireProcessor(instance, processorMeta) {
1277
885
  const { queueName, workerOptions, _warnedNoConcurrency } = processorMeta;
1278
886
  const ctor = instance.constructor;
@@ -1301,7 +909,14 @@ var ProcessorDiscoveryService = class {
1301
909
  for (const { eventName, methodKey } of workerListeners) {
1302
910
  const method = instance[methodKey];
1303
911
  if (typeof method === "function") {
1304
- workerEmitter.on(eventName, method.bind(instance));
912
+ workerEmitter.on(
913
+ eventName,
914
+ this.guardListener(instance, method, {
915
+ decorator: "@OnWorkerEvent",
916
+ eventName,
917
+ methodKey
918
+ })
919
+ );
1305
920
  }
1306
921
  }
1307
922
  const queueListeners = Reflect.getOwnMetadata(QUEUE_EVENT_LISTENERS_METADATA_KEY, ctor) ?? [];
@@ -1311,11 +926,32 @@ var ProcessorDiscoveryService = class {
1311
926
  for (const { eventName, methodKey } of queueListeners) {
1312
927
  const method = instance[methodKey];
1313
928
  if (typeof method === "function") {
1314
- qeEmitter.on(eventName, method.bind(instance));
929
+ qeEmitter.on(
930
+ eventName,
931
+ this.guardListener(instance, method, {
932
+ decorator: "@OnQueueEvent",
933
+ eventName,
934
+ methodKey
935
+ })
936
+ );
1315
937
  }
1316
938
  }
1317
939
  }
1318
940
  }
941
+ guardListener(instance, method, origin) {
942
+ const report = (cause) => {
943
+ this.logger.error(
944
+ `${origin.decorator}("${origin.eventName}") handler ${instance.constructor.name}.${String(origin.methodKey)} failed (${EVENT_HANDLER_FAILED}): ${describeError(cause)}`
945
+ );
946
+ };
947
+ return (...args) => {
948
+ try {
949
+ void Promise.resolve(method.apply(instance, args)).catch(report);
950
+ } catch (cause) {
951
+ report(cause);
952
+ }
953
+ };
954
+ }
1319
955
  };
1320
956
  ProcessorDiscoveryService = __decorateClass([
1321
957
  common.Injectable(),
@@ -1323,6 +959,8 @@ ProcessorDiscoveryService = __decorateClass([
1323
959
  __decorateParam(1, common.Inject(exports.WorkerRegistry)),
1324
960
  __decorateParam(2, common.Inject(exports.QueueEventsRegistry))
1325
961
  ], ProcessorDiscoveryService);
962
+ var DrainTimeoutError = class extends Error {
963
+ };
1326
964
  var QueueLifecycle = class {
1327
965
  constructor(workers, events, queues, flow, connection, resolved) {
1328
966
  this.workers = workers;
@@ -1339,53 +977,39 @@ var QueueLifecycle = class {
1339
977
  connection;
1340
978
  resolved;
1341
979
  logger = new common.Logger(QueueLifecycle.name);
1342
- /**
1343
- * Run the ordered shutdown sequence. Called by NestJS when the application
1344
- * context closes (e.g. on `SIGTERM` with shutdown hooks enabled).
1345
- */
980
+ startedAt;
981
+ forcedWorkers = 0;
1346
982
  async onModuleDestroy() {
1347
- const start = Date.now();
1348
- const forced = await this.closeWorkers();
983
+ this.startedAt = Date.now();
984
+ this.forcedWorkers = await this.closeWorkers();
1349
985
  await this.closeQueueEvents();
1350
986
  await this.closeDuplicatedConnections();
987
+ }
988
+ async onApplicationShutdown() {
1351
989
  await this.drainQueuesIfRequested();
1352
990
  await this.closeFlowProducer();
1353
991
  await this.closeQueues();
1354
992
  await this.teardownConnection();
1355
993
  this.logger.log(
1356
- `Queue shutdown complete in ${String(Date.now() - start)}ms (forced ${String(forced)} worker(s))`
994
+ `Queue shutdown complete in ${String(Date.now() - (this.startedAt ?? Date.now()))}ms (forced ${String(this.forcedWorkers)} worker(s))`
1357
995
  );
1358
996
  }
1359
- /**
1360
- * Close every worker with a bounded drain, force-closing any that exceed the
1361
- * budget. Returns the number of workers that had to be force-closed.
1362
- *
1363
- * @returns The count of force-closed workers.
1364
- */
1365
997
  async closeWorkers() {
1366
998
  let forced = 0;
1367
999
  const drainTimeoutMs = this.resolved.shutdown.drainTimeoutMs;
1368
1000
  for (const [name, worker] of this.workers.getAll()) {
1369
1001
  try {
1370
1002
  await this.closeWorkerWithTimeout(worker, drainTimeoutMs);
1371
- } catch {
1003
+ } catch (cause) {
1372
1004
  forced++;
1373
1005
  this.logger.warn(
1374
- `Worker "${name}" exceeded the ${String(drainTimeoutMs)}ms drain budget \u2014 forcing close (${QUEUE_ERROR_CODES.SHUTDOWN_TIMEOUT_EXCEEDED}); the in-flight job becomes stalled and is retried`
1006
+ cause instanceof DrainTimeoutError ? `Worker "${name}" exceeded the ${String(drainTimeoutMs)}ms drain budget \u2014 forcing close (${QUEUE_ERROR_CODES.SHUTDOWN_TIMEOUT_EXCEEDED}); the in-flight job becomes stalled and is retried` : `Worker "${name}" failed to close gracefully (${describeError(cause)}) \u2014 forcing close; the in-flight job becomes stalled and is retried`
1375
1007
  );
1376
1008
  void worker.close(true).catch(() => void 0);
1377
1009
  }
1378
1010
  }
1379
1011
  return forced;
1380
1012
  }
1381
- /**
1382
- * Race a graceful `worker.close()` against a timer. `worker.close()` already
1383
- * drains active jobs and takes no timeout argument, so the timer is the only
1384
- * way to bound the wait. Rejects with a timeout error when the budget elapses.
1385
- *
1386
- * @param worker - The worker to close.
1387
- * @param timeoutMs - The drain budget in milliseconds.
1388
- */
1389
1013
  async closeWorkerWithTimeout(worker, timeoutMs) {
1390
1014
  let timer;
1391
1015
  const closed = worker.close();
@@ -1395,7 +1019,7 @@ var QueueLifecycle = class {
1395
1019
  closed,
1396
1020
  new Promise((_resolve, reject) => {
1397
1021
  timer = setTimeout(() => {
1398
- reject(new Error("drain timeout"));
1022
+ reject(new DrainTimeoutError());
1399
1023
  }, timeoutMs);
1400
1024
  })
1401
1025
  ]);
@@ -1403,18 +1027,11 @@ var QueueLifecycle = class {
1403
1027
  clearTimeout(timer);
1404
1028
  }
1405
1029
  }
1406
- /** Close every cached `QueueEvents`, swallowing individual failures. */
1407
1030
  async closeQueueEvents() {
1408
1031
  for (const [, queueEvents] of this.events.getAll()) {
1409
1032
  await queueEvents.close().catch(() => void 0);
1410
1033
  }
1411
1034
  }
1412
- /**
1413
- * Close the duplicated worker/queue-events connections the library opened.
1414
- * BullMQ treats a passed-in client as shared and never closes it, so the
1415
- * library must release these explicitly. In Mode A the consumer's shared
1416
- * client is never among them.
1417
- */
1418
1035
  async closeDuplicatedConnections() {
1419
1036
  const connections = [
1420
1037
  ...this.workers.getConnections().values(),
@@ -1428,28 +1045,22 @@ var QueueLifecycle = class {
1428
1045
  }
1429
1046
  }
1430
1047
  }
1431
- /**
1432
- * Drain every cached queue when `drainOnShutdown` is enabled (dev/test only).
1433
- * Removes waiting jobs and leaves delayed and active ones; off by default to stay production-safe.
1434
- */
1435
1048
  async drainQueuesIfRequested() {
1436
1049
  if (!this.resolved.shutdown.drainOnShutdown) return;
1437
- for (const [, queue] of this.queues.getCachedQueues()) {
1438
- await queue.drain().catch(() => void 0);
1050
+ for (const [name, queue] of this.queues.getCachedQueues()) {
1051
+ await queue.drain().catch((cause) => {
1052
+ this.logger.warn(
1053
+ `Queue "${name}" could not be drained on shutdown (drainOnShutdown): ${describeError(cause)}; its waiting jobs remain`
1054
+ );
1055
+ });
1439
1056
  }
1440
1057
  }
1441
- /** Close the flow producer (idempotent), swallowing any failure. */
1442
1058
  async closeFlowProducer() {
1443
1059
  await this.flow.close().catch(() => void 0);
1444
1060
  }
1445
- /** Close every cached queue and clear the cache, swallowing individual failures. */
1446
1061
  async closeQueues() {
1447
1062
  await this.queues.closeAll().catch(() => void 0);
1448
1063
  }
1449
- /**
1450
- * Tear down the Queue-role connection. Mode B quits the library-owned client;
1451
- * Mode A is a no-op for the consumer's shared client.
1452
- */
1453
1064
  async teardownConnection() {
1454
1065
  await this.connection.teardown().catch(() => void 0);
1455
1066
  }
@@ -1466,24 +1077,12 @@ QueueLifecycle = __decorateClass([
1466
1077
 
1467
1078
  // src/server/bymax-queue.module.ts
1468
1079
  var { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE } = (
1469
- // The `moduleName` shapes the generated option token's identifier — `BYMAX_QUEUE_MODULE_OPTIONS`,
1470
- // which the module spec asserts by value; emptying the name would degrade it to an opaque
1471
- // `CONFIGURABLE_MODULE_OPTIONS[<hash>]`, so the argument is a caught regression, not a free label.
1472
1080
  new common.ConfigurableModuleBuilder({ moduleName: "BymaxQueue" }).setClassMethodName("forRoot").setFactoryMethodName("createQueueOptions").setExtras({ isGlobal: true }, (definition, extras) => ({
1473
1081
  ...definition,
1474
1082
  global: extras.isGlobal
1475
1083
  })).build()
1476
1084
  );
1477
1085
  exports.BymaxQueueModule = class BymaxQueueModule extends ConfigurableModuleClass {
1478
- /**
1479
- * Synchronous registration. Validates options, resolves defaults, and wires the
1480
- * connection resolver (initialized via async factory), the queue service, the
1481
- * worker registry, the queue-events registry, processor discovery, and the
1482
- * graceful-shutdown lifecycle.
1483
- *
1484
- * @param options - Static module options.
1485
- * @returns The configured dynamic module.
1486
- */
1487
1086
  static forRoot(options) {
1488
1087
  validateOptions(options);
1489
1088
  const resolved = applyDefaults(options);
@@ -1509,17 +1108,6 @@ exports.BymaxQueueModule = class BymaxQueueModule extends ConfigurableModuleClas
1509
1108
  exports: exports.BymaxQueueModule.buildExports()
1510
1109
  };
1511
1110
  }
1512
- /**
1513
- * Asynchronous registration. Use when options depend on other modules
1514
- * (ConfigService, BymaxCacheModule, etc.). Mirrors the NestJS standard async
1515
- * dynamic-module pattern (`useFactory` | `useClass` | `useExisting` + `inject`)
1516
- * and registers the same provider graph and exports as {@link forRoot}, with
1517
- * `global` applied from the `isGlobal` extra. The resolved options and the
1518
- * initialized connection are derived from the async-resolved options at runtime.
1519
- *
1520
- * @param options - Asynchronous module options.
1521
- * @returns The configured dynamic module.
1522
- */
1523
1111
  static forRootAsync(options) {
1524
1112
  const base = super.forRootAsync(options);
1525
1113
  return {
@@ -1550,14 +1138,6 @@ exports.BymaxQueueModule = class BymaxQueueModule extends ConfigurableModuleClas
1550
1138
  exports: exports.BymaxQueueModule.buildExports()
1551
1139
  };
1552
1140
  }
1553
- /**
1554
- * Providers shared verbatim by both registration paths: the options alias, the
1555
- * initialized connection resolver, the resolved client/mode tokens, and the
1556
- * singleton services. `BYMAX_QUEUE_RESOLVED_OPTIONS`, `FlowService`, and
1557
- * `MetricsService` are intentionally omitted — they differ per path.
1558
- *
1559
- * @returns The shared provider list.
1560
- */
1561
1141
  static buildCoreProviders() {
1562
1142
  return [
1563
1143
  { provide: BYMAX_QUEUE_OPTIONS, useExisting: MODULE_OPTIONS_TOKEN },
@@ -1587,11 +1167,6 @@ exports.BymaxQueueModule = class BymaxQueueModule extends ConfigurableModuleClas
1587
1167
  QueueLifecycle
1588
1168
  ];
1589
1169
  }
1590
- /**
1591
- * The public exports shared by both registration paths.
1592
- *
1593
- * @returns A fresh exports list (services and injection tokens).
1594
- */
1595
1170
  static buildExports() {
1596
1171
  return [
1597
1172
  exports.QueueService,