@bymax-one/nest-queue 1.2.10 → 1.2.12

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);
@@ -193,30 +192,51 @@ function isClientUsable(client) {
193
192
  return client.status === "ready" || client.status === "connecting";
194
193
  }
195
194
 
195
+ // src/server/utils/describe-error.ts
196
+ function describeError(cause) {
197
+ try {
198
+ if (typeof cause === "object" && cause !== null && "message" in cause) {
199
+ if (typeof cause.message === "string") return cause.message;
200
+ }
201
+ return String(cause);
202
+ } catch {
203
+ return "unknown error";
204
+ }
205
+ }
206
+
207
+ // src/server/utils/attach-client-error-listener.ts
208
+ function describeClientError(err) {
209
+ const message = describeError(err);
210
+ if (message !== "") return message;
211
+ try {
212
+ const { name, code } = err;
213
+ const label = typeof name === "string" && name !== "" ? name : "unknown error";
214
+ return typeof code === "string" ? `${label} [${code}]` : label;
215
+ } catch {
216
+ return "unknown error";
217
+ }
218
+ }
219
+ function attachClientErrorListener(client, logger, role) {
220
+ client.on("error", (err) => {
221
+ logger.error(`Redis connection (${role}) emitted an error: ${describeClientError(err)}`);
222
+ });
223
+ }
224
+
196
225
  // src/server/utils/duplicate-connection.ts
197
226
  function duplicateConnection(client) {
198
227
  return client.duplicate({ maxRetriesPerRequest: null });
199
228
  }
200
229
 
201
230
  // src/server/services/connection-resolver.service.ts
202
- var _client, _options;
231
+ var _logger, _client, _options;
203
232
  exports.ConnectionResolver = class ConnectionResolver {
204
233
  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
- */
234
+ __privateAdd(this, _logger, new common.Logger(exports.ConnectionResolver.name));
213
235
  __privateAdd(this, _client);
214
- /** The consumer's module options, which carry the Redis credentials. */
215
236
  __privateAdd(this, _options);
216
237
  __publicField(this, "mode");
217
238
  __privateSet(this, _options, options);
218
239
  }
219
- /** Resolve and validate the connection. Call once during module bootstrap. */
220
240
  async init() {
221
241
  const cfg = __privateGet(this, _options).connection;
222
242
  if ("client" in cfg) {
@@ -225,22 +245,14 @@ exports.ConnectionResolver = class ConnectionResolver {
225
245
  }
226
246
  this.mode = "mode-b-owned";
227
247
  __privateSet(this, _client, "url" in cfg ? new ioredis.Redis(cfg.url, this.ownedOptions(cfg.options)) : new ioredis.Redis(this.ownedOptions(cfg.options)));
248
+ attachClientErrorListener(__privateGet(this, _client), __privateGet(this, _logger), "owned");
228
249
  await this.waitReady(
229
250
  __privateGet(this, _options).connectionReadyTimeoutMs ?? DEFAULT_CONNECTION_READY_TIMEOUT_MS
230
251
  );
231
252
  }
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
253
  ownedOptions(options) {
241
254
  return { ...options ?? {}, lazyConnect: false };
242
255
  }
243
- /** The resolved Queue-role client. */
244
256
  getClient() {
245
257
  if (!__privateGet(this, _client)) {
246
258
  throw new QueueException(
@@ -253,7 +265,6 @@ exports.ConnectionResolver = class ConnectionResolver {
253
265
  }
254
266
  return __privateGet(this, _client);
255
267
  }
256
- /** The resolved connection mode. */
257
268
  getMode() {
258
269
  if (!this.mode) {
259
270
  throw new QueueException(
@@ -266,11 +277,9 @@ exports.ConnectionResolver = class ConnectionResolver {
266
277
  }
267
278
  return this.mode;
268
279
  }
269
- /** Whether the library owns (and must close) the connection. */
270
280
  isOwned() {
271
281
  return this.mode === "mode-b-owned";
272
282
  }
273
- /** Close the library-owned connection on shutdown; never touch a BYO client. */
274
283
  async teardown() {
275
284
  if (this.isOwned() && __privateGet(this, _client)) {
276
285
  const client = __privateGet(this, _client);
@@ -279,7 +288,6 @@ exports.ConnectionResolver = class ConnectionResolver {
279
288
  });
280
289
  }
281
290
  }
282
- /** Validate and adopt a bring-your-own client for the Queue role. */
283
291
  initModeA(client) {
284
292
  this.mode = "mode-a-byo";
285
293
  __privateSet(this, _client, client);
@@ -291,13 +299,13 @@ exports.ConnectionResolver = class ConnectionResolver {
291
299
  );
292
300
  }
293
301
  const probe = duplicateConnection(client);
302
+ attachClientErrorListener(probe, __privateGet(this, _logger), "probe");
294
303
  try {
295
304
  assertBlockingConnection(probe);
296
305
  } finally {
297
306
  probe.disconnect();
298
307
  }
299
308
  }
300
- /** Resolve when the owned client reaches `ready`, or reject on timeout/error. */
301
309
  async waitReady(timeoutMs) {
302
310
  const client = this.getClient();
303
311
  if (client.status === "ready") return;
@@ -330,6 +338,7 @@ exports.ConnectionResolver = class ConnectionResolver {
330
338
  });
331
339
  }
332
340
  };
341
+ _logger = new WeakMap();
333
342
  _client = new WeakMap();
334
343
  _options = new WeakMap();
335
344
  exports.ConnectionResolver = __decorateClass([
@@ -368,18 +377,6 @@ function validateJobSchedulerOptions(repeat) {
368
377
  }
369
378
  }
370
379
 
371
- // src/server/utils/describe-error.ts
372
- function describeError(cause) {
373
- try {
374
- if (typeof cause === "object" && cause !== null && "message" in cause) {
375
- if (typeof cause.message === "string") return cause.message;
376
- }
377
- return String(cause);
378
- } catch {
379
- return "unknown error";
380
- }
381
- }
382
-
383
380
  // src/server/utils/attach-error-listener.ts
384
381
  function attachDefaultErrorListener(emitter, logger, kind, queueName) {
385
382
  emitter.on("error", (err) => {
@@ -409,14 +406,6 @@ exports.QueueService = class QueueService {
409
406
  }
410
407
  connection;
411
408
  options;
412
- /**
413
- * Return the cached `Queue` for `queueName`, or create one with the module
414
- * defaults applied. Subsequent calls with the same name return the same
415
- * instance.
416
- *
417
- * @param queueName - Unique queue name (used as the Redis key prefix).
418
- * @param overrides - Optional per-queue option overrides.
419
- */
420
409
  getOrCreateQueue(queueName, overrides) {
421
410
  const existing = __privateGet(this, _queues).get(queueName);
422
411
  if (existing) return existing;
@@ -432,77 +421,13 @@ exports.QueueService = class QueueService {
432
421
  __privateGet(this, _queues).set(queueName, queue);
433
422
  return queue;
434
423
  }
435
- /** Resolve the raw cached queue (default generics) for internal delegation. */
436
424
  queueFor(queueName) {
437
425
  return this.getOrCreateQueue(queueName);
438
426
  }
439
- /**
440
- * Add a single job to a queue. The queue is created lazily if needed.
441
- *
442
- * Delivery is **at-least-once** — a handler may run more than once for the same
443
- * job (worker crash, lock expiry, or a shutdown force-close). Make handlers
444
- * idempotent: use an idempotency key on writes, prefer upserts over inserts, or
445
- * keep an "already-processed" marker keyed by `job.id`.
446
- *
447
- * `options` surfaces BullMQ natives directly — including `jobId` (idempotent
448
- * insert: a second add with the same id is a no-op while the first exists) and
449
- * `deduplication` (windowed deduplication). The library writes no deduplication
450
- * code; BullMQ owns the behavior. The deduplication key is independent of
451
- * `jobId`, so both can be set together or apart. Both collapse duplicate
452
- * **producers** and do not change the at-least-once guarantee on the
453
- * **consumer** side.
454
- *
455
- * The deduplication `id` is a shared key across the queue, so a multi-tenant
456
- * producer MUST scope it by tenant: keyed on the value alone
457
- * (`` `reindex:${term}` ``), tenant A enqueuing a term suppresses tenant B's job
458
- * for the same term.
459
- *
460
- * Scoping alone is not enough. Two variable fields joined by a delimiter that
461
- * either may contain do not produce a unique key: with `` `${tenantId}:${term}` ``,
462
- * the pairs `('a', 'b:c')` and `('a:b', 'c')` both yield `a:b:c`. Percent-encode
463
- * every variable field so the delimiter cannot appear inside one:
464
- * `` `${encodeURIComponent(tenantId)}:${encodeURIComponent(term)}` ``.
465
- *
466
- * Deduplication modes (`deduplication: { id, ttl?, extend?, replace?, keepLastIfActive? }`):
467
- * - Simple `{ id }` — collapse duplicates until the in-flight job completes or fails.
468
- * - Throttle `{ id, ttl }` — ignore duplicates for `ttl` milliseconds.
469
- * - Debounce `{ id, ttl, extend: true, replace: true }` — keep only the latest
470
- * data; each duplicate resets the TTL.
471
- * - keep-last-if-active `{ id, keepLastIfActive: true }` — while a job is active,
472
- * store the latest data and run a single follow-up when it finishes.
473
- *
474
- * @param queueName - Target queue (created lazily if absent).
475
- * @param jobName - Job identifier used by `@Process` to dispatch.
476
- * @param data - Typed job payload.
477
- * @param options - Per-job BullMQ options (priority, delay, `jobId`, `deduplication`, ...).
478
- * @returns The job BullMQ returned. When a custom `jobId` already exists, or a `deduplication`
479
- * rule keeps the job already there (one with `replace` swaps it for the new job), nothing new
480
- * is added: BullMQ answers with the id of the EXISTING job and the data and options of THIS
481
- * call, so the returned object does not describe what is stored. Call
482
- * {@link QueueService.getJob} when you need the stored job.
483
- * @example Throttle: at most one reindex per term every 5 seconds. Both variable
484
- * fields are percent-encoded, so no other tenant/term pair can build this key.
485
- * const dedupId = `${encodeURIComponent(tenantId)}:reindex:${encodeURIComponent(term)}`
486
- * await queueService.enqueue('search', 'reindex', { term }, {
487
- * deduplication: { id: dedupId, ttl: 5_000 },
488
- * })
489
- */
490
427
  async enqueue(queueName, jobName, data, options) {
491
428
  const job = await this.queueFor(queueName).add(jobName, data, options);
492
429
  return job;
493
430
  }
494
- /**
495
- * Enqueue multiple jobs in a single Redis roundtrip. The batch is bounded by
496
- * {@link MAX_BULK_SIZE} to guard against a self-inflicted memory spike; any
497
- * failure is wrapped in a `QueueException`. Delivery is at-least-once, so every
498
- * job's handler must be idempotent (see {@link QueueService.enqueue}).
499
- *
500
- * @param queueName - Target queue.
501
- * @param jobs - Job descriptors (length must not exceed the bulk size cap).
502
- * @returns The jobs BullMQ returned, in input order. An entry whose custom `jobId` already
503
- * exists is not added again: it carries the EXISTING id with the data of this call, as for
504
- * {@link QueueService.enqueue}.
505
- */
506
431
  async enqueueBulk(queueName, jobs) {
507
432
  if (jobs.length > MAX_BULK_SIZE) {
508
433
  throw new QueueException(
@@ -534,33 +459,14 @@ exports.QueueService = class QueueService {
534
459
  );
535
460
  }
536
461
  }
537
- /**
538
- * Fetch a job by id. Returns `null` when the job does not exist.
539
- *
540
- * @param queueName - Target queue.
541
- * @param jobId - The job identifier.
542
- */
543
462
  async getJob(queueName, jobId) {
544
463
  const job = await this.queueFor(queueName).getJob(jobId);
545
464
  return job ?? null;
546
465
  }
547
- /**
548
- * Fetch jobs in a given status with pagination.
549
- *
550
- * @param queueName - Target queue.
551
- * @param status - Status filter.
552
- * @param start - Starting index (inclusive). Default: 0.
553
- * @param end - Ending index (inclusive). Default: 50.
554
- */
555
466
  async getJobs(queueName, status, start = 0, end = 50) {
556
467
  const jobs = await this.queueFor(queueName).getJobs([status], start, end);
557
468
  return jobs;
558
469
  }
559
- /**
560
- * Return an uncached snapshot of job counts grouped by status.
561
- *
562
- * @param queueName - Target queue.
563
- */
564
470
  async getMetrics(queueName) {
565
471
  const counts = await this.queueFor(queueName).getJobCounts(
566
472
  "waiting",
@@ -575,24 +481,6 @@ exports.QueueService = class QueueService {
575
481
  collectedAt: (/* @__PURE__ */ new Date()).toISOString()
576
482
  };
577
483
  }
578
- /**
579
- * Create or update a Job Scheduler — the current BullMQ recurring-jobs API.
580
- * Idempotent by `schedulerId`: BullMQ performs an atomic `override: true`
581
- * upsert, so re-registering on every boot updates the schedule in place and
582
- * never duplicates it. This supersedes the repeatable-jobs surface removed in
583
- * BullMQ v6.
584
- *
585
- * The schedule is validated first, and a cron pattern BullMQ's parser rejects
586
- * is `INVALID_REPEAT_OPTIONS` (400) before Redis is asked; any other failure
587
- * propagates as it is.
588
- *
589
- * @param queueName - Target queue.
590
- * @param schedulerId - Stable, unique scheduler identifier (the upsert key).
591
- * @param repeat - Cron `pattern` OR `every` (ms), plus optional tz/limit/start/end.
592
- * @param template - Optional job template; `name` defaults to `schedulerId`, `data` to `{}`.
593
- * @returns The first scheduled (delayed) job, or `undefined`.
594
- * @throws {QueueException} `INVALID_REPEAT_OPTIONS` (400) for an invalid schedule.
595
- */
596
484
  async upsertJobScheduler(queueName, schedulerId, repeat, template) {
597
485
  validateJobSchedulerOptions(repeat);
598
486
  const queue = this.queueFor(queueName);
@@ -605,62 +493,24 @@ exports.QueueService = class QueueService {
605
493
  const job = await queue.upsertJobScheduler(schedulerId, repeat, jobTemplate);
606
494
  return job;
607
495
  }
608
- /**
609
- * Remove a Job Scheduler by id.
610
- *
611
- * @param queueName - Target queue.
612
- * @param schedulerId - The scheduler identifier to remove.
613
- * @returns `true` when a scheduler was removed, `false` otherwise.
614
- */
615
496
  async removeJobScheduler(queueName, schedulerId) {
616
497
  return this.queueFor(queueName).removeJobScheduler(schedulerId);
617
498
  }
618
- /**
619
- * List the Job Schedulers registered on a queue, for inspection or health checks.
620
- *
621
- * @param queueName - Target queue.
622
- * @param start - Page start index (inclusive). Default: 0.
623
- * @param end - Page end index (inclusive). Default: 50.
624
- * @param asc - Sort ascending by next run time. Default: true.
625
- * @returns The registered schedulers in the requested page.
626
- */
627
499
  async getJobSchedulers(queueName, start = 0, end = 50, asc = true) {
628
500
  return this.queueFor(queueName).getJobSchedulers(start, end, asc);
629
501
  }
630
- /** Pause a queue, halting the processing of new jobs. */
631
502
  async pauseQueue(queueName) {
632
503
  await this.queueFor(queueName).pause();
633
504
  }
634
- /** Resume a previously paused queue. */
635
505
  async resumeQueue(queueName) {
636
506
  await this.queueFor(queueName).resume();
637
507
  }
638
- /**
639
- * Remove up to `limit` jobs older than `gracePeriodMs` in a given status. The
640
- * argument order mirrors BullMQ's `Queue.clean(grace, limit, type?)` exactly;
641
- * `limit` is required and `0` means no limit. Returns the removed job ids.
642
- *
643
- * @param queueName - Target queue.
644
- * @param gracePeriodMs - Keep jobs younger than this many milliseconds.
645
- * @param limit - Maximum number of jobs to remove (`0` = no limit).
646
- * @param status - Status of jobs to clean.
647
- */
648
508
  async cleanQueue(queueName, gracePeriodMs, limit, status) {
649
509
  return this.queueFor(queueName).clean(gracePeriodMs, limit, status);
650
510
  }
651
- /** Return a read-only view of the cached queues. */
652
511
  getCachedQueues() {
653
512
  return __privateGet(this, _queues);
654
513
  }
655
- /**
656
- * Close every cached queue (swallowing per-queue errors) and clear the cache.
657
- *
658
- * Deliberately NOT named `onModuleDestroy`: NestJS invokes lifecycle hooks by
659
- * method name, so a hook here would run on Nest's own schedule, concurrently
660
- * with — and possibly before — the bounded drain in `QueueLifecycle`. Closing a
661
- * queue while its worker is still draining is exactly what the ordered sequence
662
- * exists to prevent. `QueueLifecycle` is the only caller.
663
- */
664
514
  async closeAll() {
665
515
  for (const queue of __privateGet(this, _queues).values()) {
666
516
  await queue.close().catch(() => void 0);
@@ -681,77 +531,26 @@ exports.FlowService = class FlowService {
681
531
  if (enabled) {
682
532
  this.producer = new bullmq.FlowProducer({
683
533
  connection: connection.getClient(),
684
- // Match the producer Queue's key prefix so flow jobs land in the same
685
- // keyspace the workers poll; a non-default prefix breaks flows otherwise.
686
534
  prefix,
687
535
  ...telemetry ? { telemetry } : {}
688
536
  });
689
537
  attachDefaultErrorListener(this.producer, this.logger, "FlowProducer", "*");
690
538
  }
691
539
  }
692
- /**
693
- * Add a flow tree (a parent with its descendant children). The root job
694
- * becomes processable only after every descendant has completed.
695
- *
696
- * By BullMQ default a failed (retry-exhausted) child does NOT fail its parent:
697
- * the parent stays in the `waiting-children` state until the child eventually
698
- * completes. To make a child's failure propagate upward and fail its parent,
699
- * set `failParentOnFailure: true` on that child (it cascades when ancestors
700
- * also set it). To let a parent proceed despite a failed child, set
701
- * `ignoreDependencyOnFailure: true` on that child. Bound each child's
702
- * `attempts` so an unrecoverable child cannot leave the parent waiting forever.
703
- *
704
- * @param flow - Flow tree definition.
705
- * @returns The root `JobNode` containing the created job and its children.
706
- * A job whose custom `jobId` already exists is not added again: its node carries the EXISTING
707
- * id with the data of this call, so the tree does not describe what is stored; call
708
- * `QueueService.getJob` when you need it.
709
- * @throws {QueueException} `FLOW_DISABLED` (503) when flows are not enabled.
710
- */
711
540
  async add(flow) {
712
541
  return this.requireProducer().add(flow);
713
542
  }
714
- /**
715
- * Add multiple flow trees in a single Redis roundtrip.
716
- *
717
- * @param flows - Flow tree definitions.
718
- * @returns The root `JobNode` for each flow, in input order.
719
- * A job whose custom `jobId` already exists is not added again; see {@link FlowService.add}.
720
- * @throws {QueueException} `FLOW_DISABLED` (503) when flows are not enabled.
721
- */
722
543
  async addBulk(flows) {
723
544
  return this.requireProducer().addBulk([...flows]);
724
545
  }
725
- /**
726
- * Escape hatch returning the underlying `FlowProducer` for advanced use cases.
727
- * Prefer {@link FlowService.add} / {@link FlowService.addBulk} when possible.
728
- *
729
- * @returns The underlying BullMQ `FlowProducer`.
730
- * @throws {QueueException} `FLOW_DISABLED` (503) when flows are not enabled.
731
- */
732
546
  getProducer() {
733
547
  return this.requireProducer();
734
548
  }
735
- /**
736
- * Close the producer when active; a no-op when inactive. A failed `close()` is
737
- * swallowed so it never aborts the shutdown sequence.
738
- *
739
- * Deliberately NOT named `onModuleDestroy`: NestJS invokes lifecycle hooks by
740
- * method name, so a hook here would run in addition to the call the ordered
741
- * sequence already makes, closing the producer twice. `QueueLifecycle` is the
742
- * only caller.
743
- */
744
549
  async close() {
745
550
  if (this.producer) {
746
551
  await this.producer.close().catch(() => void 0);
747
552
  }
748
553
  }
749
- /**
750
- * Return the active producer or throw when flows are disabled. The producer is
751
- * present if and only if flows were enabled at construction time.
752
- *
753
- * @throws {QueueException} `FLOW_DISABLED` (503) when no producer exists.
754
- */
755
554
  requireProducer() {
756
555
  if (!this.producer) {
757
556
  throw new QueueException(QUEUE_ERROR_CODES.FLOW_DISABLED, common.HttpStatus.SERVICE_UNAVAILABLE);
@@ -772,14 +571,6 @@ exports.MetricsService = class MetricsService {
772
571
  enabled;
773
572
  ttlMs;
774
573
  cache = /* @__PURE__ */ new Map();
775
- /**
776
- * Return cached metrics for `queueName`, or fetch fresh (and cache them) on a
777
- * miss or after expiry.
778
- *
779
- * @param queueName - Target queue.
780
- * @returns The current metrics snapshot.
781
- * @throws {QueueException} `METRICS_DISABLED` (503) when metrics are disabled.
782
- */
783
574
  async get(queueName) {
784
575
  this.ensureEnabled();
785
576
  const entry = this.cache.get(queueName);
@@ -790,23 +581,11 @@ exports.MetricsService = class MetricsService {
790
581
  this.cache.set(queueName, { metrics, expiresAt: Date.now() + this.ttlMs });
791
582
  return metrics;
792
583
  }
793
- /**
794
- * Return a metrics snapshot for every queue currently cached in `QueueService`.
795
- *
796
- * @returns Metrics for all known queues.
797
- * @throws {QueueException} `METRICS_DISABLED` (503) when metrics are disabled.
798
- */
799
584
  async getAll() {
800
585
  this.ensureEnabled();
801
586
  const names = Array.from(this.queueService.getCachedQueues().keys());
802
587
  return Promise.all(names.map((name) => this.get(name)));
803
588
  }
804
- /**
805
- * Invalidate the cache for `queueName`, or the whole cache when omitted.
806
- *
807
- * @param queueName - The queue entry to drop; omit to clear everything.
808
- * @throws {QueueException} `METRICS_DISABLED` (503) when metrics are disabled.
809
- */
810
589
  invalidate(queueName) {
811
590
  this.ensureEnabled();
812
591
  if (queueName === void 0) {
@@ -815,11 +594,6 @@ exports.MetricsService = class MetricsService {
815
594
  }
816
595
  this.cache.delete(queueName);
817
596
  }
818
- /**
819
- * Guard every operation behind the opt-in flag.
820
- *
821
- * @throws {QueueException} `METRICS_DISABLED` (503) when metrics are disabled.
822
- */
823
597
  ensureEnabled() {
824
598
  if (!this.enabled) {
825
599
  throw new QueueException(QUEUE_ERROR_CODES.METRICS_DISABLED, common.HttpStatus.SERVICE_UNAVAILABLE);
@@ -839,19 +613,6 @@ exports.WorkerRegistry = class WorkerRegistry {
839
613
  }
840
614
  connection;
841
615
  options;
842
- /**
843
- * Register an in-process worker with a handler function. The BullMQ `Worker`
844
- * constructor is synchronous — no Redis handshake is performed at registration
845
- * time. The duplicated connection is disconnected immediately if construction
846
- * fails, preventing a Redis connection leak.
847
- *
848
- * @param config - Queue name, handler, and optional worker options.
849
- * @returns The created BullMQ `Worker` instance.
850
- * @throws {QueueException} `DUPLICATE_PROCESSOR` when a worker for
851
- * `queueName` already exists.
852
- * @throws {QueueException} `WORKER_REGISTRATION_FAILED` when options are
853
- * invalid or the BullMQ `Worker` constructor throws.
854
- */
855
616
  register(config) {
856
617
  const { queueName, handler, options } = config;
857
618
  this.guardDuplicate(queueName);
@@ -876,21 +637,6 @@ exports.WorkerRegistry = class WorkerRegistry {
876
637
  __privateGet(this, _entries).set(queueName, { worker, connection: conn });
877
638
  return worker;
878
639
  }
879
- /**
880
- * Register a sandboxed (file-based, out-of-process) worker. The processor
881
- * file must be an absolute path or a `file:` URL pointing to a compiled
882
- * `.js`/`.cjs`/`.mjs` artifact. NestJS DI is **not** available inside the
883
- * processor. The BullMQ `Worker` constructor is synchronous.
884
- *
885
- * @param config - Queue name, processor file path/URL, and optional options.
886
- * @returns The created BullMQ `Worker` instance.
887
- * @throws {QueueException} `WORKER_REGISTRATION_FAILED` when `processorFile`
888
- * is a relative path, has a disallowed extension, or uses a non-`file:` URL protocol.
889
- * @throws {QueueException} `DUPLICATE_PROCESSOR` when a worker for
890
- * `queueName` already exists.
891
- * @throws {QueueException} `WORKER_REGISTRATION_FAILED` when options are
892
- * invalid or the BullMQ `Worker` constructor throws.
893
- */
894
640
  registerSandboxed(config) {
895
641
  const { queueName, processorFile, options } = config;
896
642
  this.validateProcessorFile(processorFile);
@@ -919,13 +665,6 @@ exports.WorkerRegistry = class WorkerRegistry {
919
665
  __privateGet(this, _entries).set(queueName, { worker, connection: conn });
920
666
  return worker;
921
667
  }
922
- /**
923
- * Stop and remove the worker for the given queue, releasing the duplicated
924
- * connection the library opened for it. If no worker exists for `queueName`
925
- * this is a no-op.
926
- *
927
- * @param queueName - The queue whose worker should be removed.
928
- */
929
668
  async unregister(queueName) {
930
669
  const entry = __privateGet(this, _entries).get(queueName);
931
670
  if (!entry) return;
@@ -933,44 +672,19 @@ exports.WorkerRegistry = class WorkerRegistry {
933
672
  await this.closeConnection(entry.connection);
934
673
  __privateGet(this, _entries).delete(queueName);
935
674
  }
936
- /**
937
- * Returns the queue names of all currently registered workers.
938
- *
939
- * @returns An immutable array of queue names.
940
- */
941
675
  list() {
942
676
  return Array.from(__privateGet(this, _entries).keys());
943
677
  }
944
- /**
945
- * Returns a read-only view of the registered workers keyed by queue name.
946
- * Intended for the shutdown orchestrator only.
947
- *
948
- * @returns A read-only map of queue name to worker.
949
- */
950
678
  getAll() {
951
679
  return new Map(
952
680
  Array.from(__privateGet(this, _entries), ([name, entry]) => [name, entry.worker])
953
681
  );
954
682
  }
955
- /**
956
- * Returns a read-only view of the duplicated connections the library opened
957
- * for its workers, keyed by queue name. Intended for the shutdown orchestrator
958
- * so it can close exactly the connections the library created (Mode A never
959
- * exposes the consumer's shared client here).
960
- *
961
- * @returns A read-only map of queue name to duplicated connection.
962
- */
963
683
  getConnections() {
964
684
  return new Map(
965
685
  Array.from(__privateGet(this, _entries), ([name, entry]) => [name, entry.connection])
966
686
  );
967
687
  }
968
- /**
969
- * Gracefully quit a duplicated connection, falling back to a hard disconnect
970
- * if the quit handshake fails so a socket is never leaked.
971
- *
972
- * @param connection - The duplicated connection to release.
973
- */
974
688
  async closeConnection(connection) {
975
689
  try {
976
690
  await connection.quit();
@@ -978,12 +692,6 @@ exports.WorkerRegistry = class WorkerRegistry {
978
692
  connection.disconnect();
979
693
  }
980
694
  }
981
- /**
982
- * Guard against registering two workers for the same queue.
983
- *
984
- * @param queueName - Queue name to check.
985
- * @throws {QueueException} `DUPLICATE_PROCESSOR` if the queue is already registered.
986
- */
987
695
  guardDuplicate(queueName) {
988
696
  if (__privateGet(this, _entries).has(queueName)) {
989
697
  throw new QueueException(
@@ -993,14 +701,6 @@ exports.WorkerRegistry = class WorkerRegistry {
993
701
  );
994
702
  }
995
703
  }
996
- /**
997
- * Validate tunable options before constructing a Worker.
998
- * Enforces `concurrency` in `[1, MAX_WORKER_CONCURRENCY]` and
999
- * `limiter.max`/`limiter.duration` `>= 1`.
1000
- *
1001
- * @param opts - The caller-supplied options (may be undefined).
1002
- * @throws {QueueException} `WORKER_REGISTRATION_FAILED` when values are out of range.
1003
- */
1004
704
  validateOptions(opts) {
1005
705
  if (opts?.concurrency !== void 0) {
1006
706
  if (opts.concurrency < 1) {
@@ -1047,15 +747,6 @@ exports.WorkerRegistry = class WorkerRegistry {
1047
747
  }
1048
748
  }
1049
749
  }
1050
- /**
1051
- * Validate that `processorFile` is a safe, reachable artifact path. Accepts
1052
- * absolute paths with `.js`/`.cjs`/`.mjs` extensions and `file:` URLs. Rejects
1053
- * relative paths, non-JS extensions, and non-`file:` URL protocols to prevent
1054
- * path-traversal and arbitrary-file-execution attacks.
1055
- *
1056
- * @param processorFile - The path or URL supplied by the caller.
1057
- * @throws {QueueException} `WORKER_REGISTRATION_FAILED` when validation fails.
1058
- */
1059
750
  validateProcessorFile(processorFile) {
1060
751
  if (processorFile instanceof URL) {
1061
752
  if (processorFile.protocol !== "file:") {
@@ -1081,13 +772,6 @@ exports.WorkerRegistry = class WorkerRegistry {
1081
772
  }
1082
773
  this.assertProcessorExtension(processorFile);
1083
774
  }
1084
- /**
1085
- * Reject a processor path whose extension is not an executable JS module,
1086
- * the last line of defense against loading an arbitrary file as a worker.
1087
- *
1088
- * @param filePath - The filesystem path (or file: URL pathname) to check.
1089
- * @throws {QueueException} `WORKER_REGISTRATION_FAILED` for a disallowed extension.
1090
- */
1091
775
  assertProcessorExtension(filePath) {
1092
776
  const allowedExtensions = /* @__PURE__ */ new Set([".js", ".cjs", ".mjs"]);
1093
777
  if (!allowedExtensions.has(path.extname(filePath))) {
@@ -1100,23 +784,11 @@ exports.WorkerRegistry = class WorkerRegistry {
1100
784
  );
1101
785
  }
1102
786
  }
1103
- /**
1104
- * Build a BullMQ `WorkerOptions` object from the library's `WorkerOptions`
1105
- * shape. Only forwards optional fields that are explicitly set to preserve
1106
- * `exactOptionalPropertyTypes` correctness. Accepts the pre-created duplicated
1107
- * connection so callers can close it on construction failure.
1108
- *
1109
- * @param opts - Library-level worker options.
1110
- * @param conn - Pre-created duplicated Redis connection.
1111
- * @returns A BullMQ-compatible options object.
1112
- */
1113
787
  buildBullOptions(opts, conn) {
1114
788
  const result = {
1115
789
  connection: conn,
1116
790
  concurrency: opts?.concurrency ?? DEFAULT_WORKER_CONCURRENCY,
1117
791
  autorun: opts?.autorun ?? true,
1118
- // Match the producer Queue's key prefix so the worker polls the same
1119
- // keyspace; without this a non-default prefix leaves jobs unconsumed.
1120
792
  prefix: this.options.prefix
1121
793
  };
1122
794
  if (opts?.limiter !== void 0) result.limiter = opts.limiter;
@@ -1143,18 +815,11 @@ exports.QueueEventsRegistry = class QueueEventsRegistry {
1143
815
  }
1144
816
  connection;
1145
817
  options;
1146
- /**
1147
- * Returns the cached `QueueEvents` for `queueName`, creating one on first
1148
- * call. Subsequent calls for the same name return the same instance without
1149
- * opening a second Redis connection.
1150
- *
1151
- * @param queueName - The BullMQ queue to observe.
1152
- * @returns The `QueueEvents` instance for the queue.
1153
- */
1154
818
  getOrCreate(queueName) {
1155
819
  const existing = __privateGet(this, _events).get(queueName);
1156
820
  if (existing) return existing;
1157
821
  const conn = duplicateConnection(this.connection.getClient());
822
+ attachClientErrorListener(conn, this.logger, `queue events: ${queueName}`);
1158
823
  let qe;
1159
824
  try {
1160
825
  qe = new bullmq.QueueEvents(queueName, { connection: conn, prefix: this.options.prefix });
@@ -1167,35 +832,12 @@ exports.QueueEventsRegistry = class QueueEventsRegistry {
1167
832
  __privateGet(this, _connections).set(queueName, conn);
1168
833
  return qe;
1169
834
  }
1170
- /**
1171
- * Returns the queue names for which a `QueueEvents` connection is open.
1172
- *
1173
- * @returns An immutable array of queue names.
1174
- */
1175
835
  list() {
1176
836
  return Array.from(__privateGet(this, _events).keys());
1177
837
  }
1178
- /**
1179
- * Returns the live `QueueEvents` map. Intended for the shutdown orchestrator.
1180
- *
1181
- * @returns A read-only view of the internal map.
1182
- */
1183
838
  getAll() {
1184
839
  return __privateGet(this, _events);
1185
840
  }
1186
- /**
1187
- * Returns a read-only view of the duplicated connections the library opened
1188
- * for its `QueueEvents`, keyed by queue name. Intended for the shutdown
1189
- * orchestrator so it can close exactly the connections the library created
1190
- * (Mode A never exposes the consumer's shared client here).
1191
- *
1192
- * Returns a copy. `ReadonlyMap` is a compile-time claim only — handing back the
1193
- * live registry lets any consumer cast it away and delete a connection the
1194
- * shutdown sequence is about to close. The allocation costs nothing on a path
1195
- * that runs once per shutdown.
1196
- *
1197
- * @returns A read-only snapshot of queue name to duplicated connection.
1198
- */
1199
841
  getConnections() {
1200
842
  return new Map(__privateGet(this, _connections));
1201
843
  }
@@ -1227,10 +869,6 @@ var ProcessorDiscoveryService = class {
1227
869
  events;
1228
870
  logger = new common.Logger(ProcessorDiscoveryService.name);
1229
871
  registeredQueues = /* @__PURE__ */ new Set();
1230
- /**
1231
- * Entry point called by NestJS after the module is fully initialized.
1232
- * Discovers all `@Processor`-annotated providers and wires them.
1233
- */
1234
872
  onModuleInit() {
1235
873
  const providers = this.discovery.getProviders();
1236
874
  for (const wrapper of providers) {
@@ -1242,15 +880,6 @@ var ProcessorDiscoveryService = class {
1242
880
  this.wireProcessor(instance, processorMeta);
1243
881
  }
1244
882
  }
1245
- /**
1246
- * Build a dispatcher function that routes `job.name` to the correct handler.
1247
- * Named handlers take priority; a catch-all (`@Process()`) handles everything
1248
- * else. Throws if no matching handler is found at job-processing time.
1249
- *
1250
- * @param instance - The provider instance whose methods serve as handlers.
1251
- * @param handlers - Accumulated `@Process` metadata entries for the class.
1252
- * @returns A dispatcher `(job: Job) => Promise<unknown>`.
1253
- */
1254
883
  buildDispatcher(instance, handlers) {
1255
884
  const namedHandlers = /* @__PURE__ */ new Map();
1256
885
  let catchAll;
@@ -1275,13 +904,6 @@ var ProcessorDiscoveryService = class {
1275
904
  );
1276
905
  };
1277
906
  }
1278
- /**
1279
- * Wire a single discovered processor class: validate uniqueness, register a
1280
- * Worker, bind worker-local and queue-global event listeners.
1281
- *
1282
- * @param instance - The resolved provider instance.
1283
- * @param processorMeta - Metadata written by `@Processor`.
1284
- */
1285
907
  wireProcessor(instance, processorMeta) {
1286
908
  const { queueName, workerOptions, _warnedNoConcurrency } = processorMeta;
1287
909
  const ctor = instance.constructor;
@@ -1339,20 +961,6 @@ var ProcessorDiscoveryService = class {
1339
961
  }
1340
962
  }
1341
963
  }
1342
- /**
1343
- * Bind an event listener so a failing handler is reported instead of escaping.
1344
- *
1345
- * An `EventEmitter` neither awaits a listener nor catches its throw: a rejected promise becomes
1346
- * an `unhandledRejection`, which ends the process by default, and BullMQ re-emits a synchronous
1347
- * throw as `error`. A failing handler is the consumer's defect, never a reason to
1348
- * stop the application, but it must still be visible, so the failure is logged with the handler
1349
- * and a stable code and the process carries on.
1350
- *
1351
- * @param instance - The provider the handler belongs to (its `this`).
1352
- * @param method - The handler method.
1353
- * @param origin - Which decorator, event and method name the listener comes from.
1354
- * @returns The listener to register on the emitter.
1355
- */
1356
964
  guardListener(instance, method, origin) {
1357
965
  const report = (cause) => {
1358
966
  this.logger.error(
@@ -1392,29 +1000,23 @@ var QueueLifecycle = class {
1392
1000
  connection;
1393
1001
  resolved;
1394
1002
  logger = new common.Logger(QueueLifecycle.name);
1395
- /**
1396
- * Run the ordered shutdown sequence. Called by NestJS when the application
1397
- * context closes (e.g. on `SIGTERM` with shutdown hooks enabled).
1398
- */
1003
+ startedAt;
1004
+ forcedWorkers = 0;
1399
1005
  async onModuleDestroy() {
1400
- const start = Date.now();
1401
- const forced = await this.closeWorkers();
1006
+ this.startedAt = Date.now();
1007
+ this.forcedWorkers = await this.closeWorkers();
1402
1008
  await this.closeQueueEvents();
1403
1009
  await this.closeDuplicatedConnections();
1010
+ }
1011
+ async onApplicationShutdown() {
1404
1012
  await this.drainQueuesIfRequested();
1405
1013
  await this.closeFlowProducer();
1406
1014
  await this.closeQueues();
1407
1015
  await this.teardownConnection();
1408
1016
  this.logger.log(
1409
- `Queue shutdown complete in ${String(Date.now() - start)}ms (forced ${String(forced)} worker(s))`
1017
+ `Queue shutdown complete in ${String(Date.now() - (this.startedAt ?? Date.now()))}ms (forced ${String(this.forcedWorkers)} worker(s))`
1410
1018
  );
1411
1019
  }
1412
- /**
1413
- * Close every worker with a bounded drain, force-closing any that exceed the
1414
- * budget. Returns the number of workers that had to be force-closed.
1415
- *
1416
- * @returns The count of force-closed workers.
1417
- */
1418
1020
  async closeWorkers() {
1419
1021
  let forced = 0;
1420
1022
  const drainTimeoutMs = this.resolved.shutdown.drainTimeoutMs;
@@ -1431,14 +1033,6 @@ var QueueLifecycle = class {
1431
1033
  }
1432
1034
  return forced;
1433
1035
  }
1434
- /**
1435
- * Race a graceful `worker.close()` against a timer. `worker.close()` already
1436
- * drains active jobs and takes no timeout argument, so the timer is the only
1437
- * way to bound the wait. Rejects with a timeout error when the budget elapses.
1438
- *
1439
- * @param worker - The worker to close.
1440
- * @param timeoutMs - The drain budget in milliseconds.
1441
- */
1442
1036
  async closeWorkerWithTimeout(worker, timeoutMs) {
1443
1037
  let timer;
1444
1038
  const closed = worker.close();
@@ -1456,18 +1050,11 @@ var QueueLifecycle = class {
1456
1050
  clearTimeout(timer);
1457
1051
  }
1458
1052
  }
1459
- /** Close every cached `QueueEvents`, swallowing individual failures. */
1460
1053
  async closeQueueEvents() {
1461
1054
  for (const [, queueEvents] of this.events.getAll()) {
1462
1055
  await queueEvents.close().catch(() => void 0);
1463
1056
  }
1464
1057
  }
1465
- /**
1466
- * Close the duplicated worker/queue-events connections the library opened.
1467
- * BullMQ treats a passed-in client as shared and never closes it, so the
1468
- * library must release these explicitly. In Mode A the consumer's shared
1469
- * client is never among them.
1470
- */
1471
1058
  async closeDuplicatedConnections() {
1472
1059
  const connections = [
1473
1060
  ...this.workers.getConnections().values(),
@@ -1481,10 +1068,6 @@ var QueueLifecycle = class {
1481
1068
  }
1482
1069
  }
1483
1070
  }
1484
- /**
1485
- * Drain every cached queue when `drainOnShutdown` is enabled (dev/test only).
1486
- * Removes waiting jobs and leaves delayed and active ones; off by default to stay production-safe.
1487
- */
1488
1071
  async drainQueuesIfRequested() {
1489
1072
  if (!this.resolved.shutdown.drainOnShutdown) return;
1490
1073
  for (const [name, queue] of this.queues.getCachedQueues()) {
@@ -1495,18 +1078,12 @@ var QueueLifecycle = class {
1495
1078
  });
1496
1079
  }
1497
1080
  }
1498
- /** Close the flow producer (idempotent), swallowing any failure. */
1499
1081
  async closeFlowProducer() {
1500
1082
  await this.flow.close().catch(() => void 0);
1501
1083
  }
1502
- /** Close every cached queue and clear the cache, swallowing individual failures. */
1503
1084
  async closeQueues() {
1504
1085
  await this.queues.closeAll().catch(() => void 0);
1505
1086
  }
1506
- /**
1507
- * Tear down the Queue-role connection. Mode B quits the library-owned client;
1508
- * Mode A is a no-op for the consumer's shared client.
1509
- */
1510
1087
  async teardownConnection() {
1511
1088
  await this.connection.teardown().catch(() => void 0);
1512
1089
  }
@@ -1523,24 +1100,12 @@ QueueLifecycle = __decorateClass([
1523
1100
 
1524
1101
  // src/server/bymax-queue.module.ts
1525
1102
  var { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE } = (
1526
- // The `moduleName` shapes the generated option token's identifier — `BYMAX_QUEUE_MODULE_OPTIONS`,
1527
- // which the module spec asserts by value; emptying the name would degrade it to an opaque
1528
- // `CONFIGURABLE_MODULE_OPTIONS[<hash>]`, so the argument is a caught regression, not a free label.
1529
1103
  new common.ConfigurableModuleBuilder({ moduleName: "BymaxQueue" }).setClassMethodName("forRoot").setFactoryMethodName("createQueueOptions").setExtras({ isGlobal: true }, (definition, extras) => ({
1530
1104
  ...definition,
1531
1105
  global: extras.isGlobal
1532
1106
  })).build()
1533
1107
  );
1534
1108
  exports.BymaxQueueModule = class BymaxQueueModule extends ConfigurableModuleClass {
1535
- /**
1536
- * Synchronous registration. Validates options, resolves defaults, and wires the
1537
- * connection resolver (initialized via async factory), the queue service, the
1538
- * worker registry, the queue-events registry, processor discovery, and the
1539
- * graceful-shutdown lifecycle.
1540
- *
1541
- * @param options - Static module options.
1542
- * @returns The configured dynamic module.
1543
- */
1544
1109
  static forRoot(options) {
1545
1110
  validateOptions(options);
1546
1111
  const resolved = applyDefaults(options);
@@ -1566,17 +1131,6 @@ exports.BymaxQueueModule = class BymaxQueueModule extends ConfigurableModuleClas
1566
1131
  exports: exports.BymaxQueueModule.buildExports()
1567
1132
  };
1568
1133
  }
1569
- /**
1570
- * Asynchronous registration. Use when options depend on other modules
1571
- * (ConfigService, BymaxCacheModule, etc.). Mirrors the NestJS standard async
1572
- * dynamic-module pattern (`useFactory` | `useClass` | `useExisting` + `inject`)
1573
- * and registers the same provider graph and exports as {@link forRoot}, with
1574
- * `global` applied from the `isGlobal` extra. The resolved options and the
1575
- * initialized connection are derived from the async-resolved options at runtime.
1576
- *
1577
- * @param options - Asynchronous module options.
1578
- * @returns The configured dynamic module.
1579
- */
1580
1134
  static forRootAsync(options) {
1581
1135
  const base = super.forRootAsync(options);
1582
1136
  return {
@@ -1607,14 +1161,6 @@ exports.BymaxQueueModule = class BymaxQueueModule extends ConfigurableModuleClas
1607
1161
  exports: exports.BymaxQueueModule.buildExports()
1608
1162
  };
1609
1163
  }
1610
- /**
1611
- * Providers shared verbatim by both registration paths: the options alias, the
1612
- * initialized connection resolver, the resolved client/mode tokens, and the
1613
- * singleton services. `BYMAX_QUEUE_RESOLVED_OPTIONS`, `FlowService`, and
1614
- * `MetricsService` are intentionally omitted — they differ per path.
1615
- *
1616
- * @returns The shared provider list.
1617
- */
1618
1164
  static buildCoreProviders() {
1619
1165
  return [
1620
1166
  { provide: BYMAX_QUEUE_OPTIONS, useExisting: MODULE_OPTIONS_TOKEN },
@@ -1644,11 +1190,6 @@ exports.BymaxQueueModule = class BymaxQueueModule extends ConfigurableModuleClas
1644
1190
  QueueLifecycle
1645
1191
  ];
1646
1192
  }
1647
- /**
1648
- * The public exports shared by both registration paths.
1649
- *
1650
- * @returns A fresh exports list (services and injection tokens).
1651
- */
1652
1193
  static buildExports() {
1653
1194
  return [
1654
1195
  exports.QueueService,