@bymax-one/nest-queue 1.2.6 → 1.2.8

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,51 @@ versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
11
11
 
12
12
  ---
13
13
 
14
+ ## [1.2.8] — 2026-11-01
15
+
16
+ No action required: behaviour, the public API, the types and the peer ranges are unchanged.
17
+ Measured with `diff -r` of a fresh build against the unpacked 1.2.7 tarball, `dist/` is identical;
18
+ the package otherwise changes in `README.md`, this file and the version in `package.json`.
19
+
20
+ ### Documentation
21
+
22
+ - **The README now states how many Redis connections the library holds.** A new "Redis connections"
23
+ section gives the budget measured against Redis 8: 1 for the module in Mode B and none of its own
24
+ in Mode A, 2 per worker, 2 per queue observed with `@OnQueueEvent`, and none for producers and
25
+ flows, all released on close. It carries a worked example (20 workers and 5 observed queues hold
26
+ 51 connections in Mode B) and a note on connection quotas of managed Redis plans.
27
+ - **The Mode B description of the connections was wrong.** It said the library opens one connection
28
+ per worker and per observed queue; each of those holds two, a blocking connection and a command
29
+ connection.
30
+
31
+ ---
32
+
33
+ ## [1.2.7] — 2026-10-31
34
+
35
+ Action required if your `connection.url` can be empty or malformed: an invalid or empty
36
+ `connection.url` now fails the boot with `INVALID_OPTIONS`; before 1.2.7 it silently connected to
37
+ `localhost:6379`. A valid `url` needs no change. The public API, the types (`index.d.ts` and
38
+ `index.d.cts` are identical) and the peer ranges are unchanged. Measured with `diff -r` of a fresh
39
+ build against the unpacked 1.2.6 tarball, only the two runtime bundles (`index.mjs`, `index.cjs`)
40
+ differ.
41
+
42
+ ### Fixed
43
+
44
+ - **`connection.url` is now validated.** `forRoot` refuses a `url` that is not a non-empty string
45
+ with `INVALID_OPTIONS` ("connection.url must be a non-empty string"). Before, `ioredis` read an
46
+ absent, empty, whitespace-only, `null` or object `url` as no url at all and connected to
47
+ `localhost:6379`, and a number as a port, so a missing `REDIS_URL` surfaced as a connection to the
48
+ wrong server instead of an error. A string `ioredis` cannot parse (`not a url`, `redis://h:abc`)
49
+ is refused the same way ("connection.url is not a valid Redis URL"), where it used to throw a raw
50
+ `Invalid URL` from the client constructor. Unix socket paths, `host:port` and `redis://` /
51
+ `rediss://` URLs, with or without `options`, are unaffected.
52
+ - **A worker that fails to start no longer hides the failure behind a second error.** The
53
+ `WORKER_REGISTRATION_FAILED` details were built with `String(cause)`, which throws a `TypeError`
54
+ for a null-prototype value; the cause is now described by a helper that cannot throw, so the
55
+ registration failure reaches the caller as the typed exception.
56
+
57
+ ---
58
+
14
59
  ## [1.2.6] — 2026-10-31
15
60
 
16
61
  No action required: behaviour, the public API, the types and the peer ranges are unchanged.
@@ -702,7 +747,9 @@ v6 peer range.
702
747
 
703
748
  ---
704
749
 
705
- [Unreleased]: https://github.com/bymaxone/nest-queue/compare/v1.2.6...HEAD
750
+ [Unreleased]: https://github.com/bymaxone/nest-queue/compare/v1.2.8...HEAD
751
+ [1.2.8]: https://github.com/bymaxone/nest-queue/compare/v1.2.7...v1.2.8
752
+ [1.2.7]: https://github.com/bymaxone/nest-queue/compare/v1.2.6...v1.2.7
706
753
  [1.2.6]: https://github.com/bymaxone/nest-queue/compare/v1.2.5...v1.2.6
707
754
  [1.2.5]: https://github.com/bymaxone/nest-queue/compare/v1.2.4...v1.2.5
708
755
  [1.2.4]: https://github.com/bymaxone/nest-queue/compare/v1.2.3...v1.2.4
package/README.md CHANGED
@@ -313,8 +313,41 @@ BymaxQueueModule.forRoot({
313
313
  })
314
314
  ```
315
315
 
316
- The library opens one connection for queues, one per worker and one per queue that has an
317
- `@OnQueueEvent` listener. The worker and `QueueEvents` connections carry `maxRetriesPerRequest: null`.
316
+ The library opens one connection for all queues and flows, and two for each worker and for each
317
+ queue that has an `@OnQueueEvent` listener ([the budget is below](#-redis-connections)). The worker
318
+ and `QueueEvents` connections carry `maxRetriesPerRequest: null`.
319
+
320
+ ---
321
+
322
+ ## 🔌 Redis connections
323
+
324
+ How many connections the library holds is linear in what you register, so you can budget it before
325
+ you deploy. Counts were read from `INFO clients` against Redis 8 with BullMQ 6 and `ioredis` 6, and
326
+ every connection is released when the application context closes.
327
+
328
+ | What you register | Connections held |
329
+ | ---------------------------------------------- | ------------------------------------------------------------------------------ |
330
+ | The module (Mode B, library-owned connection) | 1, shared by every producer and every flow |
331
+ | The module (Mode A, your client) | 0 of its own; your client is yours and is never counted |
332
+ | A queue you enqueue to, with `QueueService` | 0 more: queues share the module's connection |
333
+ | `FlowService` (flows enabled) | 0 more |
334
+ | Each worker (`@Processor` or `WorkerRegistry`) | 2: a blocking connection that waits for jobs, and a command connection |
335
+ | Each queue observed with `@OnQueueEvent` | 2: a blocking connection that reads the event stream, and a command connection |
336
+
337
+ Producers and flows add nothing, so the cost comes from consumers. The blocking connection of a
338
+ worker cannot be shared: it is parked inside a blocking command until a job arrives.
339
+
340
+ **Worked example.** One application instance in Mode B with 20 workers and 5 queues observed with
341
+ `@OnQueueEvent` holds 1 + (2 × 20) + (2 × 5) = **51** connections; in Mode A the same instance holds
342
+ 50, plus whatever your own client uses. Each instance of the application opens its own set, so three
343
+ instances hold 153.
344
+
345
+ **Managed Redis.** Hosted Redis plans cap the number of concurrent connections, and a plan sized for
346
+ a handful of clients is exhausted by one instance with a few dozen workers. Multiply the table by
347
+ the number of instances you run (include rolling deploys, where old and new instances overlap) and
348
+ compare it with the limit of your plan; self-managed Redis allows 10 000 by default (`maxclients`).
349
+ In `CLIENT LIST`, the blocking connection of a worker has a name that starts with
350
+ `<prefix>:<base64 of the queue name>`, which identifies it; the command connection is unnamed.
318
351
 
319
352
  ---
320
353
 
@@ -84,6 +84,19 @@ function invalid(reason) {
84
84
  reason
85
85
  });
86
86
  }
87
+ function isParseableUrl(url) {
88
+ const candidate = /^rediss?:\/\//i.test(url) ? url : url.startsWith("//") ? `redis:${url}` : `redis://${url}`;
89
+ if (!URL.canParse(candidate)) return false;
90
+ const { username, password } = new URL(candidate);
91
+ let decodable = true;
92
+ try {
93
+ decodeURIComponent(username);
94
+ decodeURIComponent(password);
95
+ } catch {
96
+ decodable = false;
97
+ }
98
+ return decodable;
99
+ }
87
100
  function validateOptions(opts) {
88
101
  const cfg = opts.connection;
89
102
  if (cfg === null || typeof cfg !== "object") {
@@ -98,6 +111,15 @@ function validateOptions(opts) {
98
111
  if (hasClient && (hasUrl || hasOptions)) {
99
112
  invalid("connection.client is mutually exclusive with url/options");
100
113
  }
114
+ if (hasUrl) {
115
+ const url = cfg.url;
116
+ if (typeof url !== "string" || url.trim() === "") {
117
+ invalid("connection.url must be a non-empty string");
118
+ }
119
+ if (!isParseableUrl(url)) {
120
+ invalid("connection.url is not a valid Redis URL");
121
+ }
122
+ }
101
123
  const drainTimeoutMs = opts.shutdown?.drainTimeoutMs;
102
124
  if (drainTimeoutMs !== void 0 && drainTimeoutMs <= 0) {
103
125
  invalid("shutdown.drainTimeoutMs must be > 0");
@@ -787,6 +809,20 @@ exports.MetricsService = class MetricsService {
787
809
  exports.MetricsService = __decorateClass([
788
810
  common.Injectable()
789
811
  ], 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
790
826
  var _entries;
791
827
  exports.WorkerRegistry = class WorkerRegistry {
792
828
  constructor(connection, options) {
@@ -826,7 +862,7 @@ exports.WorkerRegistry = class WorkerRegistry {
826
862
  common.HttpStatus.INTERNAL_SERVER_ERROR,
827
863
  {
828
864
  queueName,
829
- cause: cause instanceof Error ? cause.message : String(cause)
865
+ cause: describeError(cause)
830
866
  }
831
867
  );
832
868
  }
@@ -869,7 +905,7 @@ exports.WorkerRegistry = class WorkerRegistry {
869
905
  common.HttpStatus.INTERNAL_SERVER_ERROR,
870
906
  {
871
907
  queueName,
872
- cause: cause instanceof Error ? cause.message : String(cause)
908
+ cause: describeError(cause)
873
909
  }
874
910
  );
875
911
  }
@@ -82,6 +82,19 @@ function invalid(reason) {
82
82
  reason
83
83
  });
84
84
  }
85
+ function isParseableUrl(url) {
86
+ const candidate = /^rediss?:\/\//i.test(url) ? url : url.startsWith("//") ? `redis:${url}` : `redis://${url}`;
87
+ if (!URL.canParse(candidate)) return false;
88
+ const { username, password } = new URL(candidate);
89
+ let decodable = true;
90
+ try {
91
+ decodeURIComponent(username);
92
+ decodeURIComponent(password);
93
+ } catch {
94
+ decodable = false;
95
+ }
96
+ return decodable;
97
+ }
85
98
  function validateOptions(opts) {
86
99
  const cfg = opts.connection;
87
100
  if (cfg === null || typeof cfg !== "object") {
@@ -96,6 +109,15 @@ function validateOptions(opts) {
96
109
  if (hasClient && (hasUrl || hasOptions)) {
97
110
  invalid("connection.client is mutually exclusive with url/options");
98
111
  }
112
+ if (hasUrl) {
113
+ const url = cfg.url;
114
+ if (typeof url !== "string" || url.trim() === "") {
115
+ invalid("connection.url must be a non-empty string");
116
+ }
117
+ if (!isParseableUrl(url)) {
118
+ invalid("connection.url is not a valid Redis URL");
119
+ }
120
+ }
99
121
  const drainTimeoutMs = opts.shutdown?.drainTimeoutMs;
100
122
  if (drainTimeoutMs !== void 0 && drainTimeoutMs <= 0) {
101
123
  invalid("shutdown.drainTimeoutMs must be > 0");
@@ -785,6 +807,20 @@ var MetricsService = class {
785
807
  MetricsService = __decorateClass([
786
808
  Injectable()
787
809
  ], 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
788
824
  var _entries;
789
825
  var WorkerRegistry = class {
790
826
  constructor(connection, options) {
@@ -824,7 +860,7 @@ var WorkerRegistry = class {
824
860
  HttpStatus.INTERNAL_SERVER_ERROR,
825
861
  {
826
862
  queueName,
827
- cause: cause instanceof Error ? cause.message : String(cause)
863
+ cause: describeError(cause)
828
864
  }
829
865
  );
830
866
  }
@@ -867,7 +903,7 @@ var WorkerRegistry = class {
867
903
  HttpStatus.INTERNAL_SERVER_ERROR,
868
904
  {
869
905
  queueName,
870
- cause: cause instanceof Error ? cause.message : String(cause)
906
+ cause: describeError(cause)
871
907
  }
872
908
  );
873
909
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-queue",
3
- "version": "1.2.6",
3
+ "version": "1.2.8",
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",