@bymax-one/nest-queue 1.2.13 → 1.2.14

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,97 @@ versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
11
11
 
12
12
  ---
13
13
 
14
+ ## [1.2.14] — 2026-11-14
15
+
16
+ Several defaults change in this release, so read the Apply note below. A boot that succeeds, an
17
+ enqueue that Redis answers and a job that completes behave as before.
18
+
19
+ ### Fixed
20
+
21
+ - **A failed Mode B startup no longer leaves a client reconnecting.** When Redis was not ready in
22
+ time the library rejected the boot but kept the `ioredis` client it had opened, which kept
23
+ reconnecting and held the process open. The
24
+ client is now closed on every startup failure.
25
+ - **`connectionReadyTimeoutMs` is a budget, not a first-error limit.** The first connection error
26
+ used to reject the boot at once with the raw `ioredis` error, so a
27
+ Redis restarting during a deploy failed the application. Errors while the client is
28
+ reconnecting are now waited through until the budget ends; the boot then rejects with
29
+ `queue.connection_timeout` whose `details` carry `timeoutMs` and `lastErrorCode` (for example
30
+ `ECONNREFUSED`), never the message, which can name the host. A refused credential
31
+ (`WRONGPASS`, `NOAUTH`) or a client that stopped reconnecting rejects at once with
32
+ `queue.connection_invalid`. Code that matched the raw `ioredis` error of a failed boot should
33
+ match the `QueueException` code instead.
34
+
35
+ ### Changed
36
+
37
+ - **A producer call can no longer hang on a Redis that does not answer.** On the connection the
38
+ library opens, every command now carries a 5 s deadline (`commandTimeoutMs`, or the ioredis
39
+ `commandTimeout` you set), and `enqueue` and `enqueueBulk` reject with `CONNECTION_TIMEOUT` after
40
+ `enqueueTimeoutMs` (10 s) in either connection mode. A command that hits the connection's deadline
41
+ is reported the same way, as `CONNECTION_TIMEOUT` with `details.operation` and `timeoutMs` and the
42
+ original error as `cause`.
43
+ - **The owned connection no longer buffers commands during an outage.** Its offline queue is off, so
44
+ a call made while Redis is down fails at once instead of waiting in memory. Set `options.enableOfflineQueue: true` to keep the old
45
+ behaviour. Worker and QueueEvents connections keep their offline queue and have no command deadline.
46
+ - **A shared client with `maxRetriesPerRequest: null` is reported at boot.** Calls other than `enqueue` and
47
+ `enqueueBulk` then wait for as long as an outage lasts; the library warns and does not refuse.
48
+ - **Shutdown is bounded by one budget, not one per worker.** Workers are closed in parallel and the
49
+ queue-events and connection steps share what is left of `drainTimeoutMs`; a connection that has not
50
+ quit by then is disconnected. With several stuck workers the previous sequence took the budget once
51
+ for each.
52
+ - **`unregister` releases the connection even when the worker's `close()` throws.** It then rethrows
53
+ that error; the registry entry is dropped and the socket closed either way.
54
+ - **`jobTimeoutMs` bounds a handler that never settles.** It is a new worker option with no default,
55
+ a whole number of milliseconds from 1 to 2 147 483 647, as are `commandTimeoutMs` and `enqueueTimeoutMs`.
56
+ When it passes, the job fails with a `queue.job_timeout` error, retries under its `attempts` and
57
+ `backoff`, and its concurrency slot is free again. Handlers receive an `AbortSignal` as a second
58
+ argument, which aborts at that moment or when BullMQ cancels the job (`worker.cancelJob()`,
59
+ `worker.cancelAllJobs()`); closing the worker does not abort it. Before, a hung handler held its
60
+ slot for good and the rest of the queue waited.
61
+ - **Retries are spread by default.** The default backoff now carries 50% jitter: a retry waits between
62
+ half of its exponential delay and the whole of it, so jobs that failed together no longer retry in
63
+ the same instant after an outage. A `backoff` you set yourself is used as given.
64
+ - **`getJobs` reads a bounded window.** At most 1 000 jobs per call, with integer bounds and
65
+ `0 <= start <= end`; an `end` of `-1`, which BullMQ reads as the whole list, is refused with
66
+ `INVALID_OPTIONS`.
67
+
68
+ ### Documentation
69
+
70
+ - **Queue names are documented as keys, not user input**, because each distinct name keeps a queue and
71
+ its Redis resources for the life of the process.
72
+ - **The `prefix` option's JSDoc says it is a namespace, not an access control**.
73
+ - **A timed-out `enqueue` may still have been added**, and the README says to pass a deterministic
74
+ `jobId` for anything that must not run twice.
75
+ - **The README no longer says a Redis outage fails fast regardless of configuration.** It now states
76
+ what bounds a producer call and when the bound does not apply.
77
+
78
+ #### Apply to a derived backend
79
+
80
+ - If you matched ioredis's `Command timed out` error on a producer or inspection call, match
81
+ `CONNECTION_TIMEOUT` instead; the original error is its `cause`.
82
+ - If you rely on an `enqueue` waiting out a Redis outage, it now rejects with `CONNECTION_TIMEOUT`
83
+ after `enqueueTimeoutMs` (the add may still complete in Redis afterwards, so retry with a stable
84
+ `jobId` or `deduplication`); raise the option to keep waiting longer.
85
+ - If you assert exact retry delays, expect them between 50% and 100% of the computed value; set
86
+ `defaultJobOptions.backoff` without `jitter` to keep exact delays.
87
+ - If you call `getJobs` with an `end` of `-1` or a window over 1 000 jobs, those calls now throw
88
+ `INVALID_OPTIONS`. Page through the list with several calls of at most 1 000 jobs, for example
89
+ `getJobs(queue, status, start, start + 499)` in a loop until a page comes back short; the README
90
+ has the loop.
91
+ - Nothing changes until you set `jobTimeoutMs`. If you do, a handler that runs longer than it is
92
+ failed and retried while its own promise keeps running unless it honours the `AbortSignal`, so keep
93
+ handlers idempotent. If you wrap `ProgrammaticWorkerConfig.handler`, accept the new optional second
94
+ parameter.
95
+ - If you relied on shutdown taking up to one `drainTimeoutMs` per stuck worker, it now takes at most
96
+ one in total; raise `drainTimeoutMs` if jobs need longer.
97
+ - If your Mode B code calls a `QueueService` method during a Redis outage and expected it to wait for
98
+ the reconnect, it now rejects at once, since the owned connection has no offline queue; set
99
+ `connection.options.enableOfflineQueue: true` to restore that.
100
+ - A command that takes longer than 5 s on the owned connection (a very large `getJobs` range, for
101
+ example) now rejects; raise `commandTimeoutMs`.
102
+
103
+ ---
104
+
14
105
  ## [1.2.13] — 2026-11-12
15
106
 
16
107
  No action required. Measured with `diff -r` of the unpacked 1.2.12 tarball against a fresh
@@ -922,7 +1013,8 @@ v6 peer range.
922
1013
 
923
1014
  ---
924
1015
 
925
- [Unreleased]: https://github.com/bymaxone/nest-queue/compare/v1.2.13...HEAD
1016
+ [Unreleased]: https://github.com/bymaxone/nest-queue/compare/v1.2.14...HEAD
1017
+ [1.2.14]: https://github.com/bymaxone/nest-queue/compare/v1.2.13...v1.2.14
926
1018
  [1.2.13]: https://github.com/bymaxone/nest-queue/compare/v1.2.12...v1.2.13
927
1019
  [1.2.12]: https://github.com/bymaxone/nest-queue/compare/v1.2.11...v1.2.12
928
1020
  [1.2.11]: https://github.com/bymaxone/nest-queue/compare/v1.2.10...v1.2.11
package/README.md CHANGED
@@ -64,7 +64,7 @@ peer dependencies, so you control exact versions and the supply-chain surface st
64
64
  ### 🔌 Connections & Shutdown
65
65
 
66
66
  - ✅ **Dual-mode connection** — bring your own `ioredis` client, or let the library open its own from a URL or an options object
67
- - ✅ **Per-role retry policy** — `maxRetriesPerRequest: null` reaches only the duplicated Worker and QueueEvents connections; the producer keeps ioredis' defaults, so a Redis outage fails fast instead of hanging a request
67
+ - ✅ **Per-role retry policy** — `maxRetriesPerRequest: null` reaches only the duplicated Worker and QueueEvents connections; the producer keeps a finite retry limit, a 5 s command deadline and no offline queue on the connection the library opens, and `enqueue` has its own 10 s deadline, so a Redis outage rejects a call in seconds instead of holding the request; a shared client with `maxRetriesPerRequest: null` is the exception, and the library warns about it at boot
68
68
  - ✅ **One namespace, four objects** — the configured `prefix` reaches `Queue`, `Worker`, `QueueEvents` **and** `FlowProducer`, so producers and consumers can never end up on separate keyspaces
69
69
  - ✅ **Bounded graceful shutdown** — a single ordered sequence drains every worker under a deadline and then force-closes; delivery is at-least-once by design, so handlers must be idempotent
70
70
  - ✅ **No unlistened emitter** — every `Queue`, `Worker`, `QueueEvents` and `FlowProducer` the library creates carries a fallback `error` listener, so a transient Redis fault cannot become an uncaught exception in your process; your own `@OnWorkerEvent('error')` or `@OnQueueEvent('error')` still runs alongside it
@@ -229,20 +229,22 @@ export class EmailModule {}
229
229
 
230
230
  Every option passed to `forRoot` / `forRootAsync`. Only `connection` is required.
231
231
 
232
- | Option | Type | Default | Description |
233
- | -------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
234
- | `connection` | `{ client } \| { url, options? } \| { options }` | — | **Required.** Mode A supplies an existing `ioredis` client; Mode B lets the library open its own. The arms are mutually exclusive. |
235
- | `prefix` | `string` | `'bull'` | Redis key namespace. Applied to the `Queue`, `Worker`, `QueueEvents` **and** `FlowProducer` — see [Architecture](#-architecture). |
236
- | `defaultJobOptions` | `JobsOptions` | `attempts: 3` · exponential backoff `2000` ms · `removeOnComplete: { age: 24 h, count: 1000 }` · `removeOnFail: { age: 7 d, count: 5000 }` | Merged into every job enqueued through the service; per-job options win. The bounded `removeOn*` caps are what keep Redis from growing without limit under a failure storm. |
237
- | `queueOptions` | `Partial<QueueOptions>` | `{}` | Extra BullMQ `Queue` options. `connection`, `defaultJobOptions` and `prefix` are owned by the library and cannot be set here. |
238
- | `flows.enabled` | `boolean` | `false` | `FlowService` is always provided; while disabled, every method except `close()` throws `FLOW_DISABLED`. |
239
- | `metrics.enabled` | `boolean` | `false` | `MetricsService` is always provided; while disabled, every method throws `METRICS_DISABLED`. |
240
- | `metrics.cacheTtlMs` | `number` | `5_000` | TTL of the metrics snapshot cache. `0` disables caching. Must be `>= 0`. |
241
- | `telemetry` | `Telemetry` | — | A BullMQ `Telemetry` instance (typically `new BullMQOtel(...)` from the optional `bullmq-otel` peer), attached to every `Queue` and `Worker`. |
242
- | `shutdown.drainTimeoutMs` | `number` | `30_000` | Upper bound on the graceful drain before workers are force-closed. Must be `> 0`. |
243
- | `shutdown.drainOnShutdown` | `boolean` | `false` | Development and test only: once the HTTP server has stopped, call `drain()` on every cached queue, which **removes** its waiting jobs (delayed and active jobs are left alone). Leave it `false` in production. |
244
- | `connectionReadyTimeoutMs` | `number` | `10_000` | Mode B only: how long to wait for Redis `ready` before failing with `CONNECTION_TIMEOUT`. |
245
- | `isGlobal` | `boolean` | `true` | Register the module globally, so `QueueService` is injectable without re-importing. |
232
+ | Option | Type | Default | Description |
233
+ | -------------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
234
+ | `connection` | `{ client } \| { url, options? } \| { options }` | — | **Required.** Mode A supplies an existing `ioredis` client; Mode B lets the library open its own. The arms are mutually exclusive. |
235
+ | `prefix` | `string` | `'bull'` | Redis key namespace. Applied to the `Queue`, `Worker`, `QueueEvents` **and** `FlowProducer` — see [Architecture](#-architecture). |
236
+ | `defaultJobOptions` | `JobsOptions` | `attempts: 3` · exponential backoff `2000` ms with 50% jitter · `removeOnComplete: { age: 24 h, count: 1000 }` · `removeOnFail: { age: 7 d, count: 5000 }` | Merged into every job enqueued through the service; per-job options win. The bounded `removeOn*` caps are what keep Redis from growing without limit under a failure storm. |
237
+ | `queueOptions` | `Partial<QueueOptions>` | `{}` | Extra BullMQ `Queue` options. `connection`, `defaultJobOptions` and `prefix` are owned by the library and cannot be set here. |
238
+ | `flows.enabled` | `boolean` | `false` | `FlowService` is always provided; while disabled, every method except `close()` throws `FLOW_DISABLED`. |
239
+ | `metrics.enabled` | `boolean` | `false` | `MetricsService` is always provided; while disabled, every method throws `METRICS_DISABLED`. |
240
+ | `metrics.cacheTtlMs` | `number` | `5_000` | TTL of the metrics snapshot cache. `0` disables caching. Must be `>= 0`. |
241
+ | `telemetry` | `Telemetry` | — | A BullMQ `Telemetry` instance (typically `new BullMQOtel(...)` from the optional `bullmq-otel` peer), attached to every `Queue` and `Worker`. |
242
+ | `shutdown.drainTimeoutMs` | `number` | `30_000` | Upper bound on the graceful drain before workers are force-closed. Must be `> 0`. |
243
+ | `shutdown.drainOnShutdown` | `boolean` | `false` | Development and test only: once the HTTP server has stopped, call `drain()` on every cached queue, which **removes** its waiting jobs (delayed and active jobs are left alone). Leave it `false` in production. |
244
+ | `connectionReadyTimeoutMs` | `number` | `10_000` | Mode B only: the budget for Redis to reach `ready`. A connection error while the client is still reconnecting does not end the wait; when the budget ends, startup fails with `CONNECTION_TIMEOUT` carrying `lastErrorCode` (a code such as `ECONNREFUSED`, never the message) and the client the library opened is closed. |
245
+ | `commandTimeoutMs` | `number` | `5_000` | Mode B only: the deadline for one Redis command on the connection the library opened (ioredis `commandTimeout`); an `options.commandTimeout` wins. A call that hits it rejects with `CONNECTION_TIMEOUT` (`details` carries `operation` and `timeoutMs`), as when `enqueueTimeoutMs` passes. Worker and QueueEvents connections have none. |
246
+ | `enqueueTimeoutMs` | `number` | `10_000` | The deadline for one `enqueue` or `enqueueBulk`, in either mode; it rejects with `CONNECTION_TIMEOUT`, and the add may still complete in Redis, so retry with a stable `jobId` or `deduplication`. |
247
+ | `isGlobal` | `boolean` | `true` | Register the module globally, so `QueueService` is injectable without re-importing. |
246
248
 
247
249
  > [!WARNING]
248
250
  > **`shutdown.drainOnShutdown` deletes jobs.** When it is `true`, shutdown calls `drain()` on every
@@ -436,6 +438,10 @@ await this.queue.enqueue(
436
438
 
437
439
  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.
438
440
 
441
+ ### A timed-out `enqueue` may still have been added
442
+
443
+ When Redis does not answer in time, whether within `commandTimeoutMs` on the connection the library opened or within `enqueueTimeoutMs`, `enqueue` and `enqueueBulk` reject with `CONNECTION_TIMEOUT`, but the library cannot cancel the command: if Redis applies it late, the job exists anyway. Retrying a call that timed out can therefore add the job twice. For anything that must not run twice, pass a deterministic `jobId` (for example one derived from the business key): BullMQ ignores a second add with the same `jobId` for as long as the first job is still stored, so a retry inside that window is safe. A completed job is removed by `removeOnComplete`, and from then on the same `jobId` adds a new job, so keep the retry inside that window or keep the handler idempotent. A `deduplication` rule gives the same guarantee for work that is allowed to repeat after a window.
444
+
439
445
  ### OpenTelemetry
440
446
 
441
447
  Pass a `bullmq-otel` telemetry instance (optional peer dep) to trace spans from `enqueue` through the handler:
@@ -456,12 +462,15 @@ BymaxQueueModule.forRoot({
456
462
  `QueueLifecycle` runs automatically when NestJS closes the application context, through its `onModuleDestroy` and `onApplicationShutdown` hooks (call `app.enableShutdownHooks()` so that a termination signal closes the context).
457
463
  It implements a **bounded drain protocol** with at-least-once semantics:
458
464
 
459
- 1. All workers are closed with a grace period (`drainTimeoutMs`, default 30 s).
460
- 2. A `Promise.race` resolves either when the worker drains normally or when the
461
- timeout fires — whichever comes first.
465
+ 1. All workers are closed **in parallel**, within one budget (`drainTimeoutMs`, default 30 s) that
466
+ covers steps 1 to 4 together: however many workers are stuck, consumption stops after one budget,
467
+ not one per worker.
468
+ 2. A worker resolves either when it drains normally or when the budget runs out — whichever comes
469
+ first.
462
470
  3. On timeout, the worker is force-closed (`worker.close(true)`), preserving stalled
463
471
  jobs for reprocessing by the next instance.
464
- 4. Queue-events and the connections the library opened for workers and queue-events are closed.
472
+ 4. Queue-events and the connections the library opened for workers and queue-events are closed,
473
+ each within what is left of the budget; a connection that has not quit by then is disconnected.
465
474
  5. Once NestJS has stopped the HTTP server (`onApplicationShutdown`), the flow producer, the
466
475
  cached queues and, in Mode B, the library's own Redis connection are closed. NestJS runs
467
476
  `onModuleDestroy` before it stops the HTTP server, so a request that is still being served can
@@ -598,6 +607,30 @@ consumed from another. It is **not** an access control: anything that can reach
598
607
  instance can read every namespace on it. Cross-tenant isolation that must survive a
599
608
  compromised client belongs in Redis ACLs or separate instances.
600
609
 
610
+ ### Reading more than 1 000 jobs
611
+
612
+ `getJobs` reads at most 1 000 jobs per call, and refuses BullMQ's `-1` ("everything") with `INVALID_OPTIONS`, because that reads a status list of any size into memory in one reply. Page through it instead:
613
+
614
+ ```typescript
615
+ import type { QueueService } from '@bymax-one/nest-queue'
616
+
617
+ async function inspectFailed(
618
+ queueService: QueueService,
619
+ inspect: (job: unknown) => Promise<void>,
620
+ ): Promise<void> {
621
+ const PAGE = 500
622
+ for (let start = 0; ; start += PAGE) {
623
+ const page = await queueService.getJobs('reports', 'failed', start, start + PAGE - 1)
624
+ for (const job of page) await inspect(job)
625
+ if (page.length < PAGE) break
626
+ }
627
+ }
628
+ ```
629
+
630
+ ### Queue names are keys, not user input
631
+
632
+ Every queue name used with `QueueService` keeps a `Queue` object and its Redis resources for the life of the process, and names are never evicted. A name built from request data (a tenant id, a URL segment) lets whoever controls it grow memory without limit and create keys in your Redis. Take queue names from a fixed set in code or validate them against an allowlist before use.
633
+
601
634
  ### Shutdown is bounded and at-least-once
602
635
 
603
636
  A job still running when the drain deadline expires is **not** acknowledged, so BullMQ's stalled-job
@@ -629,7 +662,7 @@ check re-queues it and it runs again. **Every handler must be idempotent** — s
629
662
  | Error payloads | `QueueException.details` restricted to scalar values — no `job.data`, no connection object |
630
663
  | Job payloads | Treated as opaque; never deep-merged (prototype-pollution guard), never logged |
631
664
  | Namespacing | `prefix` applied uniformly to `Queue`, `Worker`, `QueueEvents` and `FlowProducer` |
632
- | Connection policy | `maxRetriesPerRequest: null` confined to duplicated consumer connections; producers keep fail-fast retries |
665
+ | Connection policy | `maxRetriesPerRequest: null` confined to duplicated consumer connections; producers keep finite retries, a command deadline and no offline queue (Mode B) |
633
666
  | Delivery | At-least-once with a bounded drain; unacknowledged work is re-queued by BullMQ's stalled check |
634
667
  | Supply chain | SHA-pinned third-party Actions, OSV-Scanner, TruffleHog, OpenSSF Scorecard; npm publish over OIDC |
635
668
  | Input validation | Options validated at bootstrap; cron patterns validated by BullMQ's own parser, never a hand-rolled regex |
@@ -692,7 +725,7 @@ import type {
692
725
  | `enqueue` | `<TData, TResult>(queue, name, data, opts?) → Promise<Job<TData, TResult>>` | Add a single job |
693
726
  | `enqueueBulk` | `<TData, TResult>(queue, jobs) → Promise<Job<TData, TResult>[]>` | Add up to 1 000 jobs in one Redis roundtrip |
694
727
  | `getJob` | `<TData, TResult>(queue, jobId) → Promise<Job<TData, TResult> \| null>` | Get job by ID; `null` when it does not exist |
695
- | `getJobs` | `<TData, TResult>(queue, status, start?, end?) → Promise<Job<TData, TResult>[]>` | Page jobs in one status (default `0`–`50`) |
728
+ | `getJobs` | `<TData, TResult>(queue, status, start?, end?) → Promise<Job<TData, TResult>[]>` | Page jobs in one status (default `0`–`50`, at most 1 000 per call; `-1` is refused) |
696
729
  | `getMetrics` | `(queue) → Promise<QueueMetrics>` | Uncached snapshot of job counts per status, with a collection timestamp |
697
730
  | `pauseQueue` | `(queue) → Promise<void>` | Pause a queue |
698
731
  | `resumeQueue` | `(queue) → Promise<void>` | Resume a paused queue |
@@ -817,18 +850,18 @@ try {
817
850
  }
818
851
  ```
819
852
 
820
- | Code | HTTP | When thrown |
821
- | ---------------------------------------- | :--: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
822
- | `queue.invalid_options` | 500 | Module options failed bootstrap validation — missing `connection`, mutually exclusive connection arms, non-positive `shutdown.drainTimeoutMs`, or negative `metrics.cacheTtlMs` |
823
- | `queue.connection_invalid` | 500 | The resolver has no usable client: not yet initialized, or Mode A's injected client is in an unusable `status` |
824
- | `queue.connection_requires_null_retries` | 500 | A consumer connection could not be coerced to `maxRetriesPerRequest: null` |
825
- | `queue.connection_timeout` | 500 | Mode B: Redis did not reach `ready` within `connectionReadyTimeoutMs` |
826
- | `queue.invalid_repeat_options` | 400 | A Job Scheduler schedule was rejected — invalid cron pattern, or neither `pattern` nor `every` |
827
- | `queue.duplicate_processor` | 500 | Two `@Processor` classes claim the same queue name |
828
- | `queue.flow_disabled` | 503 | `FlowService` used without `flows.enabled` |
829
- | `queue.metrics_disabled` | 503 | `MetricsService` used without `metrics.enabled` |
830
- | `queue.bulk_enqueue_failed` | 500 | `enqueueBulk` exceeded the 1 000-job cap, or the underlying `addBulk` failed |
831
- | `queue.worker_registration_failed` | 500 | A worker could not be constructed, started, or closed |
853
+ | Code | HTTP | When thrown |
854
+ | ---------------------------------------- | :--: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
855
+ | `queue.invalid_options` | 500 | Module options failed bootstrap validation — missing `connection`, mutually exclusive connection arms, non-positive `shutdown.drainTimeoutMs`, or negative `metrics.cacheTtlMs` |
856
+ | `queue.connection_invalid` | 500 | The resolver has no usable client: not yet initialized, Mode A's injected client is in an unusable `status`, or a Mode B client was refused by the server (`WRONGPASS`, `NOAUTH`) or stopped reconnecting during startup |
857
+ | `queue.connection_requires_null_retries` | 500 | A consumer connection could not be coerced to `maxRetriesPerRequest: null` |
858
+ | `queue.connection_timeout` | 500 | Mode B: Redis did not reach `ready` within `connectionReadyTimeoutMs` (`details` carries `timeoutMs` and the last error's `lastErrorCode`), or a `QueueService`/`FlowService` call got no answer within the connection's `commandTimeoutMs`, or `enqueue`/`enqueueBulk` within `enqueueTimeoutMs` (`details` carries `operation` and `timeoutMs`; an enqueue may still have been added) |
859
+ | `queue.invalid_repeat_options` | 400 | A Job Scheduler schedule was rejected — invalid cron pattern, or neither `pattern` nor `every` |
860
+ | `queue.duplicate_processor` | 500 | Two `@Processor` classes claim the same queue name |
861
+ | `queue.flow_disabled` | 503 | `FlowService` used without `flows.enabled` |
862
+ | `queue.metrics_disabled` | 503 | `MetricsService` used without `metrics.enabled` |
863
+ | `queue.bulk_enqueue_failed` | 500 | `enqueueBulk` exceeded the 1 000-job cap, or the underlying `addBulk` failed |
864
+ | `queue.worker_registration_failed` | 500 | A worker could not be constructed, started, or closed |
832
865
 
833
866
  ### Reserved codes
834
867
 
@@ -874,7 +907,22 @@ that way. This error means one did not — measured with ioredis, a client creat
874
907
  If you see it, the client you passed is not a plain ioredis client (a wrapper or a test
875
908
  double whose `duplicate()` ignores its options). Pass the ioredis client itself, or let the
876
909
  library open its own connection with Mode B. The producer keeps the client's own retry
877
- policy, which is what lets it fail fast during an outage.
910
+ policy; with a finite `maxRetriesPerRequest` that is what lets it fail fast during an outage, and
911
+ `enqueue` is bounded by `enqueueTimeoutMs` whatever the client does.
912
+
913
+ ### A handler never settles (the queue stops moving)
914
+
915
+ A handler whose promise never settles holds its concurrency slot for as long as the process lives, and BullMQ keeps renewing its lock, so the stalled-job check never reclaims it: with every slot held, the rest of the queue waits. Set `jobTimeoutMs` in the worker `options` to bound it. When the time passes, the job fails with a `queue.job_timeout` error and retries under its `attempts` and `backoff`, and the slot is free again. The handler receives the job's `AbortSignal` as its second argument; it aborts at that moment, or when BullMQ cancels the job (`worker.cancelJob()`, `worker.cancelAllJobs()`), and closing the worker does not abort it; pass it to whatever the handler awaits so the work stops with the job. A handler that ignores the signal keeps running in the background, and its retry can overlap it, so it must stay idempotent. There is no default, and sandboxed workers do not accept the option.
916
+
917
+ ```typescript
918
+ @Processor('reports', { jobTimeoutMs: 60_000 })
919
+ export class ReportsProcessor {
920
+ @Process()
921
+ async build(job: Job, signal?: AbortSignal): Promise<void> {
922
+ await fetch('https://reports.internal/build', { method: 'POST', signal: signal ?? null })
923
+ }
924
+ }
925
+ ```
878
926
 
879
927
  ### Jobs get stuck (not processed after restart)
880
928