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