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