@bymax-one/nest-queue 1.2.8 → 1.2.10

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.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,80 @@ versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
11
11
 
12
12
  ---
13
13
 
14
+ ## [1.2.10] — 2026-11-01
15
+
16
+ Action required only if code relies on the status of a failed schedule: `upsertJobScheduler` no
17
+ longer reports every failure as `INVALID_REPEAT_OPTIONS` (400), see the Apply note. Healthy use is
18
+ unchanged. The public API, the types (the declaration files differ only in JSDoc comments) and the
19
+ peer ranges are unchanged. Measured with `diff -r` of a fresh build against the unpacked 1.2.9 tarball,
20
+ the runtime bundles (`index.mjs`, `index.cjs`) differ in code.
21
+
22
+ ### Fixed
23
+
24
+ - **A failed schedule write was reported as an invalid cron.** `upsertJobScheduler` turned every
25
+ error without a string `code` into `INVALID_REPEAT_OPTIONS` (400, "pattern must be a valid cron
26
+ expression"). A Redis reply error carries no `code`, so a replica refusing the write (`READONLY`),
27
+ an out-of-memory server (`OOM`) or a missing login (`NOAUTH`) was reported as the caller's mistake,
28
+ and the caller was told not to retry. The pattern is now checked first with BullMQ's own repeat
29
+ strategy, a pure function with no Redis call, and only a pattern it refuses is
30
+ `INVALID_REPEAT_OPTIONS`; every failure of the write itself reaches the caller as it is. A
31
+ consumer-supplied `settings.repeatStrategy` replaces the parser, so the pattern is not checked.
32
+ A malformed pattern on a schedule whose iteration `limit` is already reached is now rejected as
33
+ well; BullMQ's own upsert returns before parsing such a schedule and silently accepts it.
34
+ - **A failing `@OnWorkerEvent` or `@OnQueueEvent` handler could end the process.** The listener was
35
+ bound straight onto the emitter, which neither awaits it nor catches a throw: an async handler
36
+ that rejected became an unhandled rejection, which ends a Node process by default, and a
37
+ synchronous throw was re-emitted as `error`. The failure is now logged with the handler's name
38
+ and the code `queue.event_handler_failed`, and the process carries on.
39
+ - **`drainOnShutdown` failed silently.** A queue whose drain failed kept its waiting jobs with no
40
+ trace in the log; it now logs a warning naming the queue and the cause.
41
+ - **A worker whose close failed was logged as a drain timeout.** Any rejection of the graceful
42
+ close was reported as "exceeded the drain budget"; a real error, such as a lost connection, now
43
+ has its own message with the cause. The worker is force-closed either way.
44
+
45
+ ### Documentation
46
+
47
+ - **A duplicate does not add a job.** The README and the JSDoc of `enqueue`, `enqueueBulk` and the
48
+ flow methods now say that when a custom `jobId` already exists, or a `deduplication` rule keeps
49
+ the job already there, BullMQ answers with the id of the existing job together with the data of
50
+ this call, so the returned object does not describe what is stored; `getJob` returns it.
51
+ - **The scheduler and event-handler behaviour above is described in the README.**
52
+ - **Correction to 1.2.9.** On 1.2.8 only the `FlowProducer` listener could end the process; on
53
+ `Queue`, `Worker` and `QueueEvents` BullMQ's wrapper re-emitted the `TypeError` and the error was
54
+ reported with a misleading log line. 1.2.9 fixes both.
55
+
56
+ #### Apply to a derived backend
57
+
58
+ - If you branch on `INVALID_REPEAT_OPTIONS` to detect any failure of `upsertJobScheduler`, change
59
+ it: a valid pattern whose write fails now throws the original error (a Redis reply error, a
60
+ connection error), not a 400 `QueueException`. An unparseable pattern is still
61
+ `INVALID_REPEAT_OPTIONS`, now thrown before Redis is asked.
62
+ - A listener that fails is now logged instead of ending the process. If something relied on the
63
+ crash to stop a faulty deploy, watch for the log code `queue.event_handler_failed`.
64
+
65
+ ---
66
+
67
+ ## [1.2.9] — 2026-11-01
68
+
69
+ No action required: behaviour for an `Error` with a string `message` is unchanged, and the public API, the types
70
+ (`index.d.ts` and `index.d.cts` are identical) and the peer ranges are unchanged. Measured with
71
+ `diff -r` of a fresh build against the unpacked 1.2.8 tarball, only the two runtime bundles
72
+ (`index.mjs`, `index.cjs`) differ.
73
+
74
+ ### Fixed
75
+
76
+ - **The fallback `error` listener no longer throws on an emission that is not an `Error`.** The
77
+ listener the library attaches to every `Queue`, `Worker` and `QueueEvents`, and to the `FlowProducer` when flows are enabled, read
78
+ `err.message` directly, so `emit('error', undefined)` or `emit('error', null)` threw a `TypeError`
79
+ out of the listener, and out of `emit` to its caller, which can end the process. It now logs
80
+ whatever value arrived, falling back to a fixed phrase for one it cannot print.
81
+ - **A rejected `enqueueBulk` with a non-`Error` reason still produces `BULK_ENQUEUE_FAILED`.**
82
+ The cause was read with `(err as Error).message`, which threw a `TypeError` for a `null` or
83
+ `undefined` rejection, replacing the typed exception; it now carries a string cause for any
84
+ rejected value.
85
+
86
+ ---
87
+
14
88
  ## [1.2.8] — 2026-11-01
15
89
 
16
90
  No action required: behaviour, the public API, the types and the peer ranges are unchanged.
@@ -747,7 +821,9 @@ v6 peer range.
747
821
 
748
822
  ---
749
823
 
750
- [Unreleased]: https://github.com/bymaxone/nest-queue/compare/v1.2.8...HEAD
824
+ [Unreleased]: https://github.com/bymaxone/nest-queue/compare/v1.2.10...HEAD
825
+ [1.2.10]: https://github.com/bymaxone/nest-queue/compare/v1.2.9...v1.2.10
826
+ [1.2.9]: https://github.com/bymaxone/nest-queue/compare/v1.2.8...v1.2.9
751
827
  [1.2.8]: https://github.com/bymaxone/nest-queue/compare/v1.2.7...v1.2.8
752
828
  [1.2.7]: https://github.com/bymaxone/nest-queue/compare/v1.2.6...v1.2.7
753
829
  [1.2.6]: https://github.com/bymaxone/nest-queue/compare/v1.2.5...v1.2.6
package/README.md CHANGED
@@ -557,6 +557,8 @@ onFailed(job: Job | undefined, err: Error): void {
557
557
  }
558
558
  ```
559
559
 
560
+ A listener that throws, or returns a promise that rejects, is logged with the handler's name and the code `queue.event_handler_failed` and the process carries on; it is never an unhandled rejection. Handle the error inside the listener when the failure matters.
561
+
560
562
  ### `@OnQueueEvent(eventName)`
561
563
 
562
564
  Listens to BullMQ `QueueEvents` (global, cross-process events). The library opens one shared `QueueEvents` connection per queue when it discovers the first listener for it; there is no option to enable.
@@ -602,6 +604,7 @@ const schedulers = await this.queue.getJobSchedulers('notifications')
602
604
 
603
605
  Cron-with-seconds (6-field patterns) is supported. Patterns are validated by BullMQ's
604
606
  own `cron-parser`, not a hand-rolled regex.
607
+ A pattern the parser rejects is `INVALID_REPEAT_OPTIONS` (400) before Redis is asked. Every other failure of the upsert, such as a read-only replica or a lost connection, reaches you as it is.
605
608
 
606
609
  ---
607
610
 
@@ -643,6 +646,10 @@ await this.queue.enqueue(
643
646
  )
644
647
  ```
645
648
 
649
+ ### A duplicate does not add a job
650
+
651
+ When a custom `jobId` already exists, or a `deduplication` rule keeps the job that is already there (a rule with `replace` swaps it for the new one instead), `enqueue`, `enqueueBulk` and the flow methods add nothing: BullMQ answers with the id of the **existing** job together with the data and options of **this** call, so the returned object does not describe what is stored. Call `QueueService.getJob` when you need the stored job.
652
+
646
653
  ### OpenTelemetry
647
654
 
648
655
  Pass a `bullmq-otel` telemetry instance (optional peer dep) to trace spans from `enqueue` through the handler:
@@ -368,17 +368,36 @@ function validateJobSchedulerOptions(repeat) {
368
368
  }
369
369
  }
370
370
 
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
+
371
383
  // src/server/utils/attach-error-listener.ts
372
384
  function attachDefaultErrorListener(emitter, logger, kind, queueName) {
373
385
  emitter.on("error", (err) => {
374
- logger.error(`${kind} for queue "${queueName}" emitted an error: ${err.message}`);
386
+ logger.error(`${kind} for queue "${queueName}" emitted an error: ${describeError(err)}`);
375
387
  });
376
388
  }
377
389
 
378
390
  // src/server/services/queue.service.ts
379
391
  var MAX_BULK_SIZE = 1e3;
380
- function isOperationalError(err) {
381
- return typeof err?.code === "string";
392
+ function assertValidCron(repeat, queueOptions) {
393
+ if (queueOptions.settings?.repeatStrategy !== void 0) return;
394
+ try {
395
+ bullmq.defaultRepeatStrategy(Date.now(), repeat);
396
+ } catch {
397
+ throw new QueueException(QUEUE_ERROR_CODES.INVALID_REPEAT_OPTIONS, common.HttpStatus.BAD_REQUEST, {
398
+ reason: "pattern must be a valid cron expression (5- or 6-field)"
399
+ });
400
+ }
382
401
  }
383
402
  var _queues;
384
403
  exports.QueueService = class QueueService {
@@ -456,7 +475,11 @@ exports.QueueService = class QueueService {
456
475
  * @param jobName - Job identifier used by `@Process` to dispatch.
457
476
  * @param data - Typed job payload.
458
477
  * @param options - Per-job BullMQ options (priority, delay, `jobId`, `deduplication`, ...).
459
- * @returns The created job.
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.
460
483
  * @example Throttle: at most one reindex per term every 5 seconds. Both variable
461
484
  * fields are percent-encoded, so no other tenant/term pair can build this key.
462
485
  * const dedupId = `${encodeURIComponent(tenantId)}:reindex:${encodeURIComponent(term)}`
@@ -476,6 +499,9 @@ exports.QueueService = class QueueService {
476
499
  *
477
500
  * @param queueName - Target queue.
478
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}.
479
505
  */
480
506
  async enqueueBulk(queueName, jobs) {
481
507
  if (jobs.length > MAX_BULK_SIZE) {
@@ -503,7 +529,7 @@ exports.QueueService = class QueueService {
503
529
  QUEUE_ERROR_CODES.BULK_ENQUEUE_FAILED,
504
530
  common.HttpStatus.INTERNAL_SERVER_ERROR,
505
531
  {
506
- cause: err.message
532
+ cause: describeError(err)
507
533
  }
508
534
  );
509
535
  }
@@ -556,9 +582,9 @@ exports.QueueService = class QueueService {
556
582
  * never duplicates it. This supersedes the repeatable-jobs surface removed in
557
583
  * BullMQ v6.
558
584
  *
559
- * The schedule is structurally validated first; the cron string itself is
560
- * parsed by BullMQ at registration time, and a parse failure is rethrown as
561
- * `INVALID_REPEAT_OPTIONS` (400).
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.
562
588
  *
563
589
  * @param queueName - Target queue.
564
590
  * @param schedulerId - Stable, unique scheduler identifier (the upsert key).
@@ -575,17 +601,7 @@ exports.QueueService = class QueueService {
575
601
  data: template?.data ?? {},
576
602
  ...template?.opts === void 0 ? {} : { opts: template.opts }
577
603
  };
578
- if ("pattern" in repeat) {
579
- try {
580
- const job2 = await queue.upsertJobScheduler(schedulerId, repeat, jobTemplate);
581
- return job2;
582
- } catch (err) {
583
- if (isOperationalError(err)) throw err;
584
- throw new QueueException(QUEUE_ERROR_CODES.INVALID_REPEAT_OPTIONS, common.HttpStatus.BAD_REQUEST, {
585
- reason: "pattern must be a valid cron expression (5- or 6-field)"
586
- });
587
- }
588
- }
604
+ if ("pattern" in repeat) assertValidCron(repeat, this.options.queueOptions);
589
605
  const job = await queue.upsertJobScheduler(schedulerId, repeat, jobTemplate);
590
606
  return job;
591
607
  }
@@ -687,6 +703,9 @@ exports.FlowService = class FlowService {
687
703
  *
688
704
  * @param flow - Flow tree definition.
689
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.
690
709
  * @throws {QueueException} `FLOW_DISABLED` (503) when flows are not enabled.
691
710
  */
692
711
  async add(flow) {
@@ -697,6 +716,7 @@ exports.FlowService = class FlowService {
697
716
  *
698
717
  * @param flows - Flow tree definitions.
699
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}.
700
720
  * @throws {QueueException} `FLOW_DISABLED` (503) when flows are not enabled.
701
721
  */
702
722
  async addBulk(flows) {
@@ -809,20 +829,6 @@ exports.MetricsService = class MetricsService {
809
829
  exports.MetricsService = __decorateClass([
810
830
  common.Injectable()
811
831
  ], exports.MetricsService);
812
-
813
- // src/server/utils/describe-error.ts
814
- function describeError(cause) {
815
- try {
816
- if (typeof cause === "object" && cause !== null && "message" in cause) {
817
- if (typeof cause.message === "string") return cause.message;
818
- }
819
- return String(cause);
820
- } catch {
821
- return "unknown error";
822
- }
823
- }
824
-
825
- // src/server/services/worker-registry.service.ts
826
832
  var _entries;
827
833
  exports.WorkerRegistry = class WorkerRegistry {
828
834
  constructor(connection, options) {
@@ -1209,6 +1215,7 @@ var WORKER_EVENT_LISTENERS_METADATA_KEY = /* @__PURE__ */ Symbol("bymax_queue:wo
1209
1215
  var QUEUE_EVENT_LISTENERS_METADATA_KEY = /* @__PURE__ */ Symbol("bymax_queue:queue_event_listeners");
1210
1216
 
1211
1217
  // src/server/services/processor-discovery.service.ts
1218
+ var EVENT_HANDLER_FAILED = "queue.event_handler_failed";
1212
1219
  var ProcessorDiscoveryService = class {
1213
1220
  constructor(discovery, workers, events) {
1214
1221
  this.discovery = discovery;
@@ -1303,7 +1310,14 @@ var ProcessorDiscoveryService = class {
1303
1310
  for (const { eventName, methodKey } of workerListeners) {
1304
1311
  const method = instance[methodKey];
1305
1312
  if (typeof method === "function") {
1306
- workerEmitter.on(eventName, method.bind(instance));
1313
+ workerEmitter.on(
1314
+ eventName,
1315
+ this.guardListener(instance, method, {
1316
+ decorator: "@OnWorkerEvent",
1317
+ eventName,
1318
+ methodKey
1319
+ })
1320
+ );
1307
1321
  }
1308
1322
  }
1309
1323
  const queueListeners = Reflect.getOwnMetadata(QUEUE_EVENT_LISTENERS_METADATA_KEY, ctor) ?? [];
@@ -1313,11 +1327,46 @@ var ProcessorDiscoveryService = class {
1313
1327
  for (const { eventName, methodKey } of queueListeners) {
1314
1328
  const method = instance[methodKey];
1315
1329
  if (typeof method === "function") {
1316
- qeEmitter.on(eventName, method.bind(instance));
1330
+ qeEmitter.on(
1331
+ eventName,
1332
+ this.guardListener(instance, method, {
1333
+ decorator: "@OnQueueEvent",
1334
+ eventName,
1335
+ methodKey
1336
+ })
1337
+ );
1317
1338
  }
1318
1339
  }
1319
1340
  }
1320
1341
  }
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
+ guardListener(instance, method, origin) {
1357
+ const report = (cause) => {
1358
+ this.logger.error(
1359
+ `${origin.decorator}("${origin.eventName}") handler ${instance.constructor.name}.${String(origin.methodKey)} failed (${EVENT_HANDLER_FAILED}): ${describeError(cause)}`
1360
+ );
1361
+ };
1362
+ return (...args) => {
1363
+ try {
1364
+ void Promise.resolve(method.apply(instance, args)).catch(report);
1365
+ } catch (cause) {
1366
+ report(cause);
1367
+ }
1368
+ };
1369
+ }
1321
1370
  };
1322
1371
  ProcessorDiscoveryService = __decorateClass([
1323
1372
  common.Injectable(),
@@ -1325,6 +1374,8 @@ ProcessorDiscoveryService = __decorateClass([
1325
1374
  __decorateParam(1, common.Inject(exports.WorkerRegistry)),
1326
1375
  __decorateParam(2, common.Inject(exports.QueueEventsRegistry))
1327
1376
  ], ProcessorDiscoveryService);
1377
+ var DrainTimeoutError = class extends Error {
1378
+ };
1328
1379
  var QueueLifecycle = class {
1329
1380
  constructor(workers, events, queues, flow, connection, resolved) {
1330
1381
  this.workers = workers;
@@ -1370,10 +1421,10 @@ var QueueLifecycle = class {
1370
1421
  for (const [name, worker] of this.workers.getAll()) {
1371
1422
  try {
1372
1423
  await this.closeWorkerWithTimeout(worker, drainTimeoutMs);
1373
- } catch {
1424
+ } catch (cause) {
1374
1425
  forced++;
1375
1426
  this.logger.warn(
1376
- `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`
1427
+ 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`
1377
1428
  );
1378
1429
  void worker.close(true).catch(() => void 0);
1379
1430
  }
@@ -1397,7 +1448,7 @@ var QueueLifecycle = class {
1397
1448
  closed,
1398
1449
  new Promise((_resolve, reject) => {
1399
1450
  timer = setTimeout(() => {
1400
- reject(new Error("drain timeout"));
1451
+ reject(new DrainTimeoutError());
1401
1452
  }, timeoutMs);
1402
1453
  })
1403
1454
  ]);
@@ -1436,8 +1487,12 @@ var QueueLifecycle = class {
1436
1487
  */
1437
1488
  async drainQueuesIfRequested() {
1438
1489
  if (!this.resolved.shutdown.drainOnShutdown) return;
1439
- for (const [, queue] of this.queues.getCachedQueues()) {
1440
- await queue.drain().catch(() => void 0);
1490
+ for (const [name, queue] of this.queues.getCachedQueues()) {
1491
+ await queue.drain().catch((cause) => {
1492
+ this.logger.warn(
1493
+ `Queue "${name}" could not be drained on shutdown (drainOnShutdown): ${describeError(cause)}; its waiting jobs remain`
1494
+ );
1495
+ });
1441
1496
  }
1442
1497
  }
1443
1498
  /** Close the flow producer (idempotent), swallowing any failure. */
@@ -453,7 +453,11 @@ declare class QueueService {
453
453
  * @param jobName - Job identifier used by `@Process` to dispatch.
454
454
  * @param data - Typed job payload.
455
455
  * @param options - Per-job BullMQ options (priority, delay, `jobId`, `deduplication`, ...).
456
- * @returns The created job.
456
+ * @returns The job BullMQ returned. When a custom `jobId` already exists, or a `deduplication`
457
+ * rule keeps the job already there (one with `replace` swaps it for the new job), nothing new
458
+ * is added: BullMQ answers with the id of the EXISTING job and the data and options of THIS
459
+ * call, so the returned object does not describe what is stored. Call
460
+ * {@link QueueService.getJob} when you need the stored job.
457
461
  * @example Throttle: at most one reindex per term every 5 seconds. Both variable
458
462
  * fields are percent-encoded, so no other tenant/term pair can build this key.
459
463
  * const dedupId = `${encodeURIComponent(tenantId)}:reindex:${encodeURIComponent(term)}`
@@ -470,6 +474,9 @@ declare class QueueService {
470
474
  *
471
475
  * @param queueName - Target queue.
472
476
  * @param jobs - Job descriptors (length must not exceed the bulk size cap).
477
+ * @returns The jobs BullMQ returned, in input order. An entry whose custom `jobId` already
478
+ * exists is not added again: it carries the EXISTING id with the data of this call, as for
479
+ * {@link QueueService.enqueue}.
473
480
  */
474
481
  enqueueBulk<TData = unknown, TResult = unknown>(queueName: string, jobs: readonly BulkJob<TData>[]): Promise<TypedJob<TData, TResult>[]>;
475
482
  /**
@@ -501,9 +508,9 @@ declare class QueueService {
501
508
  * never duplicates it. This supersedes the repeatable-jobs surface removed in
502
509
  * BullMQ v6.
503
510
  *
504
- * The schedule is structurally validated first; the cron string itself is
505
- * parsed by BullMQ at registration time, and a parse failure is rethrown as
506
- * `INVALID_REPEAT_OPTIONS` (400).
511
+ * The schedule is validated first, and a cron pattern BullMQ's parser rejects
512
+ * is `INVALID_REPEAT_OPTIONS` (400) before Redis is asked; any other failure
513
+ * propagates as it is.
507
514
  *
508
515
  * @param queueName - Target queue.
509
516
  * @param schedulerId - Stable, unique scheduler identifier (the upsert key).
@@ -607,6 +614,9 @@ declare class FlowService {
607
614
  *
608
615
  * @param flow - Flow tree definition.
609
616
  * @returns The root `JobNode` containing the created job and its children.
617
+ * A job whose custom `jobId` already exists is not added again: its node carries the EXISTING
618
+ * id with the data of this call, so the tree does not describe what is stored; call
619
+ * `QueueService.getJob` when you need it.
610
620
  * @throws {QueueException} `FLOW_DISABLED` (503) when flows are not enabled.
611
621
  */
612
622
  add(flow: FlowJob): Promise<JobNode>;
@@ -615,6 +625,7 @@ declare class FlowService {
615
625
  *
616
626
  * @param flows - Flow tree definitions.
617
627
  * @returns The root `JobNode` for each flow, in input order.
628
+ * A job whose custom `jobId` already exists is not added again; see {@link FlowService.add}.
618
629
  * @throws {QueueException} `FLOW_DISABLED` (503) when flows are not enabled.
619
630
  */
620
631
  addBulk(flows: readonly FlowJob[]): Promise<JobNode[]>;
@@ -453,7 +453,11 @@ declare class QueueService {
453
453
  * @param jobName - Job identifier used by `@Process` to dispatch.
454
454
  * @param data - Typed job payload.
455
455
  * @param options - Per-job BullMQ options (priority, delay, `jobId`, `deduplication`, ...).
456
- * @returns The created job.
456
+ * @returns The job BullMQ returned. When a custom `jobId` already exists, or a `deduplication`
457
+ * rule keeps the job already there (one with `replace` swaps it for the new job), nothing new
458
+ * is added: BullMQ answers with the id of the EXISTING job and the data and options of THIS
459
+ * call, so the returned object does not describe what is stored. Call
460
+ * {@link QueueService.getJob} when you need the stored job.
457
461
  * @example Throttle: at most one reindex per term every 5 seconds. Both variable
458
462
  * fields are percent-encoded, so no other tenant/term pair can build this key.
459
463
  * const dedupId = `${encodeURIComponent(tenantId)}:reindex:${encodeURIComponent(term)}`
@@ -470,6 +474,9 @@ declare class QueueService {
470
474
  *
471
475
  * @param queueName - Target queue.
472
476
  * @param jobs - Job descriptors (length must not exceed the bulk size cap).
477
+ * @returns The jobs BullMQ returned, in input order. An entry whose custom `jobId` already
478
+ * exists is not added again: it carries the EXISTING id with the data of this call, as for
479
+ * {@link QueueService.enqueue}.
473
480
  */
474
481
  enqueueBulk<TData = unknown, TResult = unknown>(queueName: string, jobs: readonly BulkJob<TData>[]): Promise<TypedJob<TData, TResult>[]>;
475
482
  /**
@@ -501,9 +508,9 @@ declare class QueueService {
501
508
  * never duplicates it. This supersedes the repeatable-jobs surface removed in
502
509
  * BullMQ v6.
503
510
  *
504
- * The schedule is structurally validated first; the cron string itself is
505
- * parsed by BullMQ at registration time, and a parse failure is rethrown as
506
- * `INVALID_REPEAT_OPTIONS` (400).
511
+ * The schedule is validated first, and a cron pattern BullMQ's parser rejects
512
+ * is `INVALID_REPEAT_OPTIONS` (400) before Redis is asked; any other failure
513
+ * propagates as it is.
507
514
  *
508
515
  * @param queueName - Target queue.
509
516
  * @param schedulerId - Stable, unique scheduler identifier (the upsert key).
@@ -607,6 +614,9 @@ declare class FlowService {
607
614
  *
608
615
  * @param flow - Flow tree definition.
609
616
  * @returns The root `JobNode` containing the created job and its children.
617
+ * A job whose custom `jobId` already exists is not added again: its node carries the EXISTING
618
+ * id with the data of this call, so the tree does not describe what is stored; call
619
+ * `QueueService.getJob` when you need it.
610
620
  * @throws {QueueException} `FLOW_DISABLED` (503) when flows are not enabled.
611
621
  */
612
622
  add(flow: FlowJob): Promise<JobNode>;
@@ -615,6 +625,7 @@ declare class FlowService {
615
625
  *
616
626
  * @param flows - Flow tree definitions.
617
627
  * @returns The root `JobNode` for each flow, in input order.
628
+ * A job whose custom `jobId` already exists is not added again; see {@link FlowService.add}.
618
629
  * @throws {QueueException} `FLOW_DISABLED` (503) when flows are not enabled.
619
630
  */
620
631
  addBulk(flows: readonly FlowJob[]): Promise<JobNode[]>;
@@ -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;
@@ -366,17 +366,36 @@ function validateJobSchedulerOptions(repeat) {
366
366
  }
367
367
  }
368
368
 
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
+
369
381
  // src/server/utils/attach-error-listener.ts
370
382
  function attachDefaultErrorListener(emitter, logger, kind, queueName) {
371
383
  emitter.on("error", (err) => {
372
- logger.error(`${kind} for queue "${queueName}" emitted an error: ${err.message}`);
384
+ logger.error(`${kind} for queue "${queueName}" emitted an error: ${describeError(err)}`);
373
385
  });
374
386
  }
375
387
 
376
388
  // src/server/services/queue.service.ts
377
389
  var MAX_BULK_SIZE = 1e3;
378
- function isOperationalError(err) {
379
- return typeof err?.code === "string";
390
+ function assertValidCron(repeat, queueOptions) {
391
+ if (queueOptions.settings?.repeatStrategy !== void 0) return;
392
+ try {
393
+ defaultRepeatStrategy(Date.now(), repeat);
394
+ } catch {
395
+ throw new QueueException(QUEUE_ERROR_CODES.INVALID_REPEAT_OPTIONS, HttpStatus.BAD_REQUEST, {
396
+ reason: "pattern must be a valid cron expression (5- or 6-field)"
397
+ });
398
+ }
380
399
  }
381
400
  var _queues;
382
401
  var QueueService = class {
@@ -454,7 +473,11 @@ var QueueService = class {
454
473
  * @param jobName - Job identifier used by `@Process` to dispatch.
455
474
  * @param data - Typed job payload.
456
475
  * @param options - Per-job BullMQ options (priority, delay, `jobId`, `deduplication`, ...).
457
- * @returns The created job.
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.
458
481
  * @example Throttle: at most one reindex per term every 5 seconds. Both variable
459
482
  * fields are percent-encoded, so no other tenant/term pair can build this key.
460
483
  * const dedupId = `${encodeURIComponent(tenantId)}:reindex:${encodeURIComponent(term)}`
@@ -474,6 +497,9 @@ var QueueService = class {
474
497
  *
475
498
  * @param queueName - Target queue.
476
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}.
477
503
  */
478
504
  async enqueueBulk(queueName, jobs) {
479
505
  if (jobs.length > MAX_BULK_SIZE) {
@@ -501,7 +527,7 @@ var QueueService = class {
501
527
  QUEUE_ERROR_CODES.BULK_ENQUEUE_FAILED,
502
528
  HttpStatus.INTERNAL_SERVER_ERROR,
503
529
  {
504
- cause: err.message
530
+ cause: describeError(err)
505
531
  }
506
532
  );
507
533
  }
@@ -554,9 +580,9 @@ var QueueService = class {
554
580
  * never duplicates it. This supersedes the repeatable-jobs surface removed in
555
581
  * BullMQ v6.
556
582
  *
557
- * The schedule is structurally validated first; the cron string itself is
558
- * parsed by BullMQ at registration time, and a parse failure is rethrown as
559
- * `INVALID_REPEAT_OPTIONS` (400).
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.
560
586
  *
561
587
  * @param queueName - Target queue.
562
588
  * @param schedulerId - Stable, unique scheduler identifier (the upsert key).
@@ -573,17 +599,7 @@ var QueueService = class {
573
599
  data: template?.data ?? {},
574
600
  ...template?.opts === void 0 ? {} : { opts: template.opts }
575
601
  };
576
- if ("pattern" in repeat) {
577
- try {
578
- const job2 = await queue.upsertJobScheduler(schedulerId, repeat, jobTemplate);
579
- return job2;
580
- } catch (err) {
581
- if (isOperationalError(err)) throw err;
582
- throw new QueueException(QUEUE_ERROR_CODES.INVALID_REPEAT_OPTIONS, HttpStatus.BAD_REQUEST, {
583
- reason: "pattern must be a valid cron expression (5- or 6-field)"
584
- });
585
- }
586
- }
602
+ if ("pattern" in repeat) assertValidCron(repeat, this.options.queueOptions);
587
603
  const job = await queue.upsertJobScheduler(schedulerId, repeat, jobTemplate);
588
604
  return job;
589
605
  }
@@ -685,6 +701,9 @@ var FlowService = class {
685
701
  *
686
702
  * @param flow - Flow tree definition.
687
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.
688
707
  * @throws {QueueException} `FLOW_DISABLED` (503) when flows are not enabled.
689
708
  */
690
709
  async add(flow) {
@@ -695,6 +714,7 @@ var FlowService = class {
695
714
  *
696
715
  * @param flows - Flow tree definitions.
697
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}.
698
718
  * @throws {QueueException} `FLOW_DISABLED` (503) when flows are not enabled.
699
719
  */
700
720
  async addBulk(flows) {
@@ -807,20 +827,6 @@ var MetricsService = class {
807
827
  MetricsService = __decorateClass([
808
828
  Injectable()
809
829
  ], MetricsService);
810
-
811
- // src/server/utils/describe-error.ts
812
- function describeError(cause) {
813
- try {
814
- if (typeof cause === "object" && cause !== null && "message" in cause) {
815
- if (typeof cause.message === "string") return cause.message;
816
- }
817
- return String(cause);
818
- } catch {
819
- return "unknown error";
820
- }
821
- }
822
-
823
- // src/server/services/worker-registry.service.ts
824
830
  var _entries;
825
831
  var WorkerRegistry = class {
826
832
  constructor(connection, options) {
@@ -1207,6 +1213,7 @@ var WORKER_EVENT_LISTENERS_METADATA_KEY = /* @__PURE__ */ Symbol("bymax_queue:wo
1207
1213
  var QUEUE_EVENT_LISTENERS_METADATA_KEY = /* @__PURE__ */ Symbol("bymax_queue:queue_event_listeners");
1208
1214
 
1209
1215
  // src/server/services/processor-discovery.service.ts
1216
+ var EVENT_HANDLER_FAILED = "queue.event_handler_failed";
1210
1217
  var ProcessorDiscoveryService = class {
1211
1218
  constructor(discovery, workers, events) {
1212
1219
  this.discovery = discovery;
@@ -1301,7 +1308,14 @@ var ProcessorDiscoveryService = class {
1301
1308
  for (const { eventName, methodKey } of workerListeners) {
1302
1309
  const method = instance[methodKey];
1303
1310
  if (typeof method === "function") {
1304
- workerEmitter.on(eventName, method.bind(instance));
1311
+ workerEmitter.on(
1312
+ eventName,
1313
+ this.guardListener(instance, method, {
1314
+ decorator: "@OnWorkerEvent",
1315
+ eventName,
1316
+ methodKey
1317
+ })
1318
+ );
1305
1319
  }
1306
1320
  }
1307
1321
  const queueListeners = Reflect.getOwnMetadata(QUEUE_EVENT_LISTENERS_METADATA_KEY, ctor) ?? [];
@@ -1311,11 +1325,46 @@ var ProcessorDiscoveryService = class {
1311
1325
  for (const { eventName, methodKey } of queueListeners) {
1312
1326
  const method = instance[methodKey];
1313
1327
  if (typeof method === "function") {
1314
- qeEmitter.on(eventName, method.bind(instance));
1328
+ qeEmitter.on(
1329
+ eventName,
1330
+ this.guardListener(instance, method, {
1331
+ decorator: "@OnQueueEvent",
1332
+ eventName,
1333
+ methodKey
1334
+ })
1335
+ );
1315
1336
  }
1316
1337
  }
1317
1338
  }
1318
1339
  }
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
+ guardListener(instance, method, origin) {
1355
+ const report = (cause) => {
1356
+ this.logger.error(
1357
+ `${origin.decorator}("${origin.eventName}") handler ${instance.constructor.name}.${String(origin.methodKey)} failed (${EVENT_HANDLER_FAILED}): ${describeError(cause)}`
1358
+ );
1359
+ };
1360
+ return (...args) => {
1361
+ try {
1362
+ void Promise.resolve(method.apply(instance, args)).catch(report);
1363
+ } catch (cause) {
1364
+ report(cause);
1365
+ }
1366
+ };
1367
+ }
1319
1368
  };
1320
1369
  ProcessorDiscoveryService = __decorateClass([
1321
1370
  Injectable(),
@@ -1323,6 +1372,8 @@ ProcessorDiscoveryService = __decorateClass([
1323
1372
  __decorateParam(1, Inject(WorkerRegistry)),
1324
1373
  __decorateParam(2, Inject(QueueEventsRegistry))
1325
1374
  ], ProcessorDiscoveryService);
1375
+ var DrainTimeoutError = class extends Error {
1376
+ };
1326
1377
  var QueueLifecycle = class {
1327
1378
  constructor(workers, events, queues, flow, connection, resolved) {
1328
1379
  this.workers = workers;
@@ -1368,10 +1419,10 @@ var QueueLifecycle = class {
1368
1419
  for (const [name, worker] of this.workers.getAll()) {
1369
1420
  try {
1370
1421
  await this.closeWorkerWithTimeout(worker, drainTimeoutMs);
1371
- } catch {
1422
+ } catch (cause) {
1372
1423
  forced++;
1373
1424
  this.logger.warn(
1374
- `Worker "${name}" exceeded the ${String(drainTimeoutMs)}ms drain budget \u2014 forcing close (${QUEUE_ERROR_CODES.SHUTDOWN_TIMEOUT_EXCEEDED}); the in-flight job becomes stalled and is retried`
1425
+ cause instanceof DrainTimeoutError ? `Worker "${name}" exceeded the ${String(drainTimeoutMs)}ms drain budget \u2014 forcing close (${QUEUE_ERROR_CODES.SHUTDOWN_TIMEOUT_EXCEEDED}); the in-flight job becomes stalled and is retried` : `Worker "${name}" failed to close gracefully (${describeError(cause)}) \u2014 forcing close; the in-flight job becomes stalled and is retried`
1375
1426
  );
1376
1427
  void worker.close(true).catch(() => void 0);
1377
1428
  }
@@ -1395,7 +1446,7 @@ var QueueLifecycle = class {
1395
1446
  closed,
1396
1447
  new Promise((_resolve, reject) => {
1397
1448
  timer = setTimeout(() => {
1398
- reject(new Error("drain timeout"));
1449
+ reject(new DrainTimeoutError());
1399
1450
  }, timeoutMs);
1400
1451
  })
1401
1452
  ]);
@@ -1434,8 +1485,12 @@ var QueueLifecycle = class {
1434
1485
  */
1435
1486
  async drainQueuesIfRequested() {
1436
1487
  if (!this.resolved.shutdown.drainOnShutdown) return;
1437
- for (const [, queue] of this.queues.getCachedQueues()) {
1438
- await queue.drain().catch(() => void 0);
1488
+ for (const [name, queue] of this.queues.getCachedQueues()) {
1489
+ await queue.drain().catch((cause) => {
1490
+ this.logger.warn(
1491
+ `Queue "${name}" could not be drained on shutdown (drainOnShutdown): ${describeError(cause)}; its waiting jobs remain`
1492
+ );
1493
+ });
1439
1494
  }
1440
1495
  }
1441
1496
  /** Close the flow producer (idempotent), swallowing any failure. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-queue",
3
- "version": "1.2.8",
3
+ "version": "1.2.10",
4
4
  "description": "NestJS dynamic module wrapping BullMQ — typed jobs, flows, job schedulers, deduplication, OpenTelemetry, graceful shutdown",
5
5
  "author": "Bymax One <support@bymax.one>",
6
6
  "license": "MIT",
@@ -55,6 +55,7 @@
55
55
  },
56
56
  "scripts": {
57
57
  "build": "pnpm clean && tsup",
58
+ "check:api": "node scripts/check-api-surface.mjs",
58
59
  "check:exports": "attw --pack .",
59
60
  "check:mutants": "node scripts/check-mutation-directives.mjs",
60
61
  "check:published": "node scripts/check-published-surface.mjs",