ioredis 5.10.0 → 5.11.0

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/README.md CHANGED
@@ -729,13 +729,16 @@ const stream = redis.zscanStream("myhash", {
729
729
  match: "age:??",
730
730
  });
731
731
  ```
732
+
732
733
  The `hscanStream` also accepts the `noValues` option to specify whether Redis should return only the keys in the hash table without their corresponding values.
734
+
733
735
  ```javascript
734
736
  const stream = redis.hscanStream("myhash", {
735
737
  match: "age:??",
736
738
  noValues: true,
737
739
  });
738
740
  ```
741
+
739
742
  You can learn more from the [Redis documentation](http://redis.io/commands/scan).
740
743
 
741
744
  **Useful Tips**
@@ -815,6 +818,7 @@ const redis = new Redis({
815
818
  ```
816
819
 
817
820
  When enabled:
821
+
818
822
  - For commands with a finite timeout (e.g., `blpop("key", 5)`), ioredis sets a client-side deadline based on the command's timeout plus a small grace period (`blockingTimeoutGrace`, default 100ms). If no reply arrives before the deadline, the command resolves with `null`—the same value Redis returns when a blocking command times out normally.
819
823
  - For commands that block forever (e.g., `timeout = 0` or `BLOCK 0`), the `blockingTimeout` value is used as a safety net.
820
824
 
@@ -842,15 +846,15 @@ On ElastiCache instances with Auto-failover enabled, `reconnectOnError` does not
842
846
 
843
847
  The Redis instance will emit some events about the state of the connection to the Redis server.
844
848
 
845
- | Event | Description |
846
- | :----------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
847
- | connect | emits when a connection is established to the Redis server. |
848
- | ready | If `enableReadyCheck` is `true`, client will emit `ready` when the server reports that it is ready to receive commands (e.g. finish loading data from disk).<br>Otherwise, `ready` will be emitted immediately right after the `connect` event. |
849
- | error | emits when an error occurs while connecting.<br>However, ioredis emits all `error` events silently (only emits when there's at least one listener) so that your application won't crash if you're not listening to the `error` event.<br>When `redis.connect()` is explicitly called the error will also be rejected from the returned promise, in addition to emitting it. If `redis.connect()` is not called explicitly and `lazyConnect` is true, ioredis will try to connect automatically on the first command and emit the `error` event silently. |
850
- | close | emits when an established Redis server connection has closed. |
851
- | reconnecting | emits after `close` when a reconnection will be made. The argument of the event is the time (in ms) before reconnecting. |
852
- | end | emits after `close` when no more reconnections will be made, or the connection is failed to establish. |
853
- | wait | emits when `lazyConnect` is set and will wait for the first command to be called before connecting. |
849
+ | Event | Description |
850
+ | :----------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
851
+ | connect | emits when a connection is established to the Redis server. |
852
+ | ready | If `enableReadyCheck` is `true`, client will emit `ready` when the server reports that it is ready to receive commands (e.g. finish loading data from disk).<br>Otherwise, `ready` will be emitted immediately right after the `connect` event. |
853
+ | error | emits when an error occurs while connecting.<br>However, ioredis emits all `error` events silently (only emits when there's at least one listener) so that your application won't crash if you're not listening to the `error` event.<br>When `redis.connect()` is explicitly called the error will also be rejected from the returned promise, in addition to emitting it. If `redis.connect()` is not called explicitly and `lazyConnect` is true, ioredis will try to connect automatically on the first command and emit the `error` event silently. |
854
+ | close | emits when an established Redis server connection has closed. |
855
+ | reconnecting | emits after `close` when a reconnection will be made. The argument of the event is the time (in ms) before reconnecting. |
856
+ | end | emits after `close` when no more reconnections will be made, or the connection is failed to establish. |
857
+ | wait | emits when `lazyConnect` is set and will wait for the first command to be called before connecting. |
854
858
 
855
859
  You can also check out the `Redis#status` property to get the current connection status.
856
860
 
@@ -860,6 +864,38 @@ Besides the above connection events, there are several other custom events:
860
864
  | :----- | :------------------------------------------------------------------ |
861
865
  | select | emits when the database changed. The argument is the new db number. |
862
866
 
867
+ ## Diagnostics Channel
868
+
869
+ ioredis publishes telemetry through Node.js [`diagnostics_channel`](https://nodejs.org/api/diagnostics_channel.html), allowing APM tools and custom instrumentation to observe commands, connections, and batch operations without modifying application code.
870
+
871
+ These channels use `TracingChannel#tracePromise()` and emit `start`, `end`, `asyncStart`, `asyncEnd`, and `error` sub-events. Requires Node.js >= 18.19.0; on older versions the channels are silently unavailable (zero overhead). Subscribe via `tracing:<name>:<event>`:
872
+
873
+ ```typescript
874
+ import dc from "node:diagnostics_channel";
875
+
876
+ dc.subscribe("tracing:ioredis:command:start", ({ command, args }) => {
877
+ console.log(`> ${command}`, args);
878
+ });
879
+
880
+ dc.subscribe("tracing:ioredis:command:asyncEnd", ({ command }) => {
881
+ console.log(`${command} settled`);
882
+ });
883
+
884
+ dc.subscribe("tracing:ioredis:command:error", ({ command, error }) => {
885
+ console.error(`${command} failed:`, error);
886
+ });
887
+ ```
888
+
889
+ | Channel name | Payload | Description |
890
+ | :---------------- | :---------------------- | :------------------------------------------------------- |
891
+ | `ioredis:command` | `CommandTraceContext` | Individual command (standalone, pipeline, or within MULTI) |
892
+ | `ioredis:batch` | `BatchOperationContext` | MULTI transaction as a whole |
893
+ | `ioredis:connect` | `ConnectTraceContext` | Socket connection attempt |
894
+
895
+ Command arguments are sanitized before emission using rules adapted from `@opentelemetry/redis-common`. Sensitive values (e.g. values in `SET`, `AUTH` passwords) are replaced with `?`, while read-only commands like `GET` and `DEL` retain all arguments. This is safe by default: unlisted or custom commands have all arguments redacted.
896
+
897
+ Context types (`CommandTraceContext`, `BatchOperationContext`, `ConnectTraceContext`) are exported from `ioredis`.
898
+
863
899
  ## Offline Queue
864
900
 
865
901
  When a command can't be processed by Redis (being sent before the `ready` event), by default, it's added to the offline queue and will be
@@ -1159,6 +1195,7 @@ const cluster = new Redis.Cluster(
1159
1195
  ```
1160
1196
 
1161
1197
  Or you can specify this parameter through function:
1198
+
1162
1199
  ```javascript
1163
1200
  const cluster = new Redis.Cluster(
1164
1201
  [
@@ -1169,7 +1206,7 @@ const cluster = new Redis.Cluster(
1169
1206
  ],
1170
1207
  {
1171
1208
  natMap: (key) => {
1172
- if(key.includes('30001')) {
1209
+ if (key.includes("30001")) {
1173
1210
  return { host: "203.0.113.73", port: 30001 };
1174
1211
  }
1175
1212
 
@@ -1226,28 +1263,33 @@ For sharded Pub/Sub, use the `spublish` and `ssubscribe` commands instead of the
1226
1263
  The following basic example shows you how to use sharded Pub/Sub:
1227
1264
 
1228
1265
  ```javascript
1229
- const cluster: Cluster = new Cluster([{host: host, port: port}], {shardedSubscribers: true});
1266
+ const cluster: Cluster = new Cluster([{ host: host, port: port }], {
1267
+ shardedSubscribers: true,
1268
+ });
1230
1269
 
1231
1270
  //Register the callback
1232
1271
  cluster.on("smessage", (channel, message) => {
1233
- console.log(message);
1272
+ console.log(message);
1234
1273
  });
1235
1274
 
1236
-
1237
1275
  //Subscribe to the channels on the same slot
1238
- cluster.ssubscribe("channel{my}:1", "channel{my}:2").then( ( count: number ) => {
1276
+ cluster
1277
+ .ssubscribe("channel{my}:1", "channel{my}:2")
1278
+ .then((count: number) => {
1239
1279
  console.log(count);
1240
- }).catch( (err) => {
1280
+ })
1281
+ .catch((err) => {
1241
1282
  console.log(err);
1242
- });
1283
+ });
1243
1284
 
1244
1285
  //Publish a message
1245
- cluster.spublish("channel{my}:1", "This is a test message to my first channel.").then((value: number) => {
1286
+ cluster
1287
+ .spublish("channel{my}:1", "This is a test message to my first channel.")
1288
+ .then((value: number) => {
1246
1289
  console.log("Published a message to channel{my}:1");
1247
- });
1290
+ });
1248
1291
  ```
1249
1292
 
1250
-
1251
1293
  ### Events
1252
1294
 
1253
1295
  | Event | Description |
@@ -94,6 +94,7 @@ export default class Command implements Respondable {
94
94
  args: CommandParameter[];
95
95
  inTransaction: boolean;
96
96
  pipelineIndex?: number;
97
+ isTraced: boolean;
97
98
  isResolved: boolean;
98
99
  reject: (err: Error) => void;
99
100
  resolve: (result: any) => void;
package/built/Command.js CHANGED
@@ -37,6 +37,7 @@ class Command {
37
37
  constructor(name, args = [], options = {}, callback) {
38
38
  this.name = name;
39
39
  this.inTransaction = false;
40
+ this.isTraced = false;
40
41
  this.isResolved = false;
41
42
  this.transformed = false;
42
43
  this.replyEncoding = options.replyEncoding;
package/built/Pipeline.js CHANGED
@@ -5,6 +5,7 @@ const commands_1 = require("@ioredis/commands");
5
5
  const standard_as_callback_1 = require("standard-as-callback");
6
6
  const util_1 = require("util");
7
7
  const Command_1 = require("./Command");
8
+ const buffer_1 = require("buffer");
8
9
  const utils_1 = require("./utils");
9
10
  const Commander_1 = require("./utils/Commander");
10
11
  /*
@@ -315,6 +316,15 @@ Pipeline.prototype.exec = function (callback) {
315
316
  buffers.push(writable);
316
317
  }
317
318
  else {
319
+ if (data.length + writable.length >= buffer_1.constants.MAX_STRING_LENGTH) {
320
+ if (!buffers) {
321
+ buffers = [];
322
+ }
323
+ if (data) {
324
+ buffers.push(Buffer.from(data, "utf8"));
325
+ data = "";
326
+ }
327
+ }
318
328
  data += writable;
319
329
  }
320
330
  if (!--writePending) {
package/built/Redis.d.ts CHANGED
@@ -7,6 +7,7 @@ import { RedisOptions } from "./redis/RedisOptions";
7
7
  import ScanStream from "./ScanStream";
8
8
  import { Transaction } from "./transaction";
9
9
  import { Callback, CommandItem, NetStream, ScanStreamOptions, WriteableStream } from "./types";
10
+ import { type BatchOperationContext } from "./tracing";
10
11
  import Commander from "./utils/Commander";
11
12
  import Deque = require("denque");
12
13
  declare type RedisStatus = "wait" | "reconnecting" | "connecting" | "connect" | "ready" | "close" | "end";
@@ -85,6 +86,7 @@ declare class Redis extends Commander implements DataHandledable {
85
86
  * if the connection fails, times out, or if Redis is already connecting/connected.
86
87
  */
87
88
  connect(callback?: Callback<void>): Promise<void>;
89
+ private _connect;
88
90
  /**
89
91
  * Disconnect from Redis.
90
92
  *
@@ -186,6 +188,15 @@ declare class Redis extends Commander implements DataHandledable {
186
188
  * @ignore
187
189
  */
188
190
  handleReconnection(err: Error, item: CommandItem): void;
191
+ /**
192
+ * @ignore
193
+ */
194
+ _getServerAddress(): {
195
+ address: string;
196
+ port: number | undefined;
197
+ };
198
+ private _buildCommandContext;
199
+ _buildBatchContext(batchSize: number): BatchOperationContext;
189
200
  /**
190
201
  * Get description of the connection. Used for debugging.
191
202
  */
package/built/Redis.js CHANGED
@@ -12,6 +12,7 @@ const RedisOptions_1 = require("./redis/RedisOptions");
12
12
  const ScanStream_1 = require("./ScanStream");
13
13
  const transaction_1 = require("./transaction");
14
14
  const utils_1 = require("./utils");
15
+ const tracing_1 = require("./tracing");
15
16
  const applyMixin_1 = require("./utils/applyMixin");
16
17
  const Commander_1 = require("./utils/Commander");
17
18
  const lodash_1 = require("./utils/lodash");
@@ -101,7 +102,18 @@ class Redis extends Commander_1.default {
101
102
  * if the connection fails, times out, or if Redis is already connecting/connected.
102
103
  */
103
104
  connect(callback) {
104
- const promise = new Promise((resolve, reject) => {
105
+ const promise = (0, tracing_1.traceConnect)(() => this._connect(), () => {
106
+ const { address, port } = this._getServerAddress();
107
+ return {
108
+ serverAddress: address,
109
+ serverPort: port,
110
+ connectionEpoch: this.connectionEpoch,
111
+ };
112
+ });
113
+ return (0, standard_as_callback_1.default)(promise, callback);
114
+ }
115
+ _connect() {
116
+ return new Promise((resolve, reject) => {
105
117
  if (this.status === "connecting" ||
106
118
  this.status === "connect" ||
107
119
  this.status === "ready") {
@@ -211,7 +223,6 @@ class Redis extends Commander_1.default {
211
223
  _this.once("close", connectionCloseHandler);
212
224
  });
213
225
  });
214
- return (0, standard_as_callback_1.default)(promise, callback);
215
226
  }
216
227
  /**
217
228
  * Disconnect from Redis.
@@ -416,7 +427,8 @@ class Redis extends Commander_1.default {
416
427
  if (Command_1.default.checkFlag("WILL_DISCONNECT", command.name)) {
417
428
  this.manuallyClosing = true;
418
429
  }
419
- if (this.options.socketTimeout !== undefined && this.socketTimeoutTimer === undefined) {
430
+ if (this.options.socketTimeout !== undefined &&
431
+ this.socketTimeoutTimer === undefined) {
420
432
  this.setSocketTimeout();
421
433
  }
422
434
  }
@@ -428,7 +440,15 @@ class Redis extends Commander_1.default {
428
440
  debug("switch to db [%d]", this.condition.select);
429
441
  }
430
442
  }
431
- return command.promise;
443
+ if (!writable || command.isTraced) {
444
+ return command.promise;
445
+ }
446
+ // Trace on the write path only, and only once per command. Commands may
447
+ // pass through sendCommand multiple times (offline queue flush,
448
+ // prevCommandQueue resend after reconnect). The isTraced flag ensures
449
+ // we don't emit duplicate trace events.
450
+ command.isTraced = true;
451
+ return (0, tracing_1.traceCommand)(() => command.promise, () => this._buildCommandContext(command));
432
452
  }
433
453
  getBlockingTimeoutInMs(command) {
434
454
  var _a;
@@ -444,7 +464,8 @@ class Redis extends Commander_1.default {
444
464
  if (typeof timeout === "number") {
445
465
  if (timeout > 0) {
446
466
  // Finite timeout from command args - add grace period
447
- return timeout + ((_a = this.options.blockingTimeoutGrace) !== null && _a !== void 0 ? _a : RedisOptions_1.DEFAULT_REDIS_OPTIONS.blockingTimeoutGrace);
467
+ return (timeout +
468
+ ((_a = this.options.blockingTimeoutGrace) !== null && _a !== void 0 ? _a : RedisOptions_1.DEFAULT_REDIS_OPTIONS.blockingTimeoutGrace));
448
469
  }
449
470
  // Command has timeout=0 (block forever), use blockingTimeout option as safety net
450
471
  return configuredTimeout;
@@ -575,6 +596,40 @@ class Redis extends Commander_1.default {
575
596
  item.command.reject(err);
576
597
  }
577
598
  }
599
+ /**
600
+ * @ignore
601
+ */
602
+ _getServerAddress() {
603
+ if ("path" in this.options && this.options.path) {
604
+ return { address: this.options.path, port: undefined };
605
+ }
606
+ return {
607
+ address: ("host" in this.options && this.options.host) || "localhost",
608
+ port: ("port" in this.options && this.options.port) || 6379,
609
+ };
610
+ }
611
+ _buildCommandContext(command) {
612
+ var _a, _b, _c;
613
+ const { address, port } = this._getServerAddress();
614
+ return {
615
+ command: command.name,
616
+ args: (0, tracing_1.sanitizeArgs)(command.name, command.args),
617
+ database: (_c = (_b = (_a = this.condition) === null || _a === void 0 ? void 0 : _a.select) !== null && _b !== void 0 ? _b : this.options.db) !== null && _c !== void 0 ? _c : 0,
618
+ serverAddress: address,
619
+ serverPort: port,
620
+ };
621
+ }
622
+ _buildBatchContext(batchSize) {
623
+ var _a, _b, _c;
624
+ const { address, port } = this._getServerAddress();
625
+ return {
626
+ batchMode: "MULTI",
627
+ batchSize,
628
+ database: (_c = (_b = (_a = this.condition) === null || _a === void 0 ? void 0 : _a.select) !== null && _b !== void 0 ? _b : this.options.db) !== null && _c !== void 0 ? _c : 0,
629
+ serverAddress: address,
630
+ serverPort: port,
631
+ };
632
+ }
578
633
  /**
579
634
  * Get description of the connection. Used for debugging.
580
635
  */
@@ -100,6 +100,18 @@ function getFirstValueInFlattenedArray(args) {
100
100
  return undefined;
101
101
  }
102
102
  exports.getFirstValueInFlattenedArray = getFirstValueInFlattenedArray;
103
+ function getFirstKeyForCommand(commandName, args) {
104
+ if ((0, commands_1.exists)(commandName, { caseInsensitive: true })) {
105
+ const flattenedArgs = args.flat();
106
+ const keyIndexes = (0, commands_1.getKeyIndexes)(commandName, flattenedArgs, {
107
+ nameCaseInsensitive: true,
108
+ });
109
+ if (keyIndexes.length) {
110
+ return flattenedArgs[keyIndexes[0]];
111
+ }
112
+ }
113
+ return getFirstValueInFlattenedArray(args);
114
+ }
103
115
  function executeWithAutoPipelining(client, functionName, commandName, args, callback) {
104
116
  // On cluster mode let's wait for slots to be available
105
117
  if (client.isCluster && !client.slots.length) {
@@ -116,11 +128,9 @@ function executeWithAutoPipelining(client, functionName, commandName, args, call
116
128
  }), callback);
117
129
  }
118
130
  // If we have slot information, we can improve routing by grouping slots served by the same subset of nodes
119
- // Note that the first value in args may be a (possibly empty) array.
120
- // ioredis will only flatten one level of the array, in the Command constructor.
121
131
  const prefix = client.options.keyPrefix || "";
122
132
  let slotKey = client.isCluster
123
- ? client.slots[calculateSlot(`${prefix}${getFirstValueInFlattenedArray(args)}`)].join(",")
133
+ ? client.slots[calculateSlot(`${prefix}${getFirstKeyForCommand(commandName, args)}`)].join(",")
124
134
  : "main";
125
135
  // When scaleReads is enabled, separate read and write commands into different pipelines
126
136
  // so they can be routed to replicas and masters respectively
@@ -1,6 +1,7 @@
1
1
  /// <reference types="node" />
2
2
  import * as EventEmitter from "events";
3
3
  import ShardedSubscriber from "./ShardedSubscriber";
4
+ import { ClusterOptions } from "./ClusterOptions";
4
5
  /**
5
6
  * Redis distinguishes between "normal" and sharded PubSub. When using the normal PubSub feature,
6
7
  * exactly one subscriber exists per cluster instance because the Redis cluster bus forwards
@@ -12,6 +13,7 @@ import ShardedSubscriber from "./ShardedSubscriber";
12
13
  */
13
14
  export default class ClusterSubscriberGroup {
14
15
  private readonly subscriberGroupEmitter;
16
+ private readonly options;
15
17
  private shardedSubscribers;
16
18
  private clusterSlots;
17
19
  private subscriberToSlotsIndex;
@@ -27,7 +29,7 @@ export default class ClusterSubscriberGroup {
27
29
  *
28
30
  * @param cluster
29
31
  */
30
- constructor(subscriberGroupEmitter: EventEmitter);
32
+ constructor(subscriberGroupEmitter: EventEmitter, options: ClusterOptions);
31
33
  /**
32
34
  * Get the responsible subscriber.
33
35
  *
@@ -102,4 +104,5 @@ export default class ClusterSubscriberGroup {
102
104
  * @param nodeKey
103
105
  */
104
106
  private handleSubscriberConnectSucceeded;
107
+ private shouldStartSubscriber;
105
108
  }
@@ -20,8 +20,9 @@ class ClusterSubscriberGroup {
20
20
  *
21
21
  * @param cluster
22
22
  */
23
- constructor(subscriberGroupEmitter) {
23
+ constructor(subscriberGroupEmitter, options) {
24
24
  this.subscriberGroupEmitter = subscriberGroupEmitter;
25
+ this.options = options;
25
26
  this.shardedSubscribers = new Map();
26
27
  this.clusterSlots = [];
27
28
  // Simple [min, max] slot ranges aren't enough because you can migrate single slots
@@ -68,7 +69,18 @@ class ClusterSubscriberGroup {
68
69
  */
69
70
  getResponsibleSubscriber(slot) {
70
71
  const nodeKey = this.clusterSlots[slot][0];
71
- return this.shardedSubscribers.get(nodeKey);
72
+ const sub = this.shardedSubscribers.get(nodeKey);
73
+ if (sub && sub.subscriberStatus === "idle") {
74
+ sub
75
+ .start()
76
+ .then(() => {
77
+ this.handleSubscriberConnectSucceeded(sub.getNodeKey());
78
+ })
79
+ .catch((err) => {
80
+ this.handleSubscriberConnectFailed(err, sub.getNodeKey());
81
+ });
82
+ }
83
+ return sub;
72
84
  }
73
85
  /**
74
86
  * Adds a channel for which this subscriber group is responsible
@@ -130,7 +142,7 @@ class ClusterSubscriberGroup {
130
142
  start() {
131
143
  const startPromises = [];
132
144
  for (const s of this.shardedSubscribers.values()) {
133
- if (!s.isStarted()) {
145
+ if (this.shouldStartSubscriber(s)) {
134
146
  startPromises.push(s
135
147
  .start()
136
148
  .then(() => {
@@ -139,6 +151,7 @@ class ClusterSubscriberGroup {
139
151
  .catch((err) => {
140
152
  this.handleSubscriberConnectFailed(err, s.getNodeKey());
141
153
  }));
154
+ this.subscriberGroupEmitter.emit("+subscriber");
142
155
  }
143
156
  }
144
157
  return Promise.all(startPromises);
@@ -162,9 +175,9 @@ class ClusterSubscriberGroup {
162
175
  // For each of the sharded subscribers
163
176
  for (const [nodeKey, shardedSubscriber] of this.shardedSubscribers) {
164
177
  if (
165
- // If the subscriber is still responsible for a slot range and is running then keep it
178
+ // If the subscriber is still responsible for a slot range and is healthy then keep it
166
179
  this.subscriberToSlotsIndex.has(nodeKey) &&
167
- shardedSubscriber.isStarted()) {
180
+ shardedSubscriber.isHealthy()) {
168
181
  debug("Skipping deleting subscriber for %s", nodeKey);
169
182
  continue;
170
183
  }
@@ -177,11 +190,31 @@ class ClusterSubscriberGroup {
177
190
  const startPromises = [];
178
191
  // For each node in slots cache
179
192
  for (const [nodeKey, _] of this.subscriberToSlotsIndex) {
180
- // If we already have a subscriber for this node then keep it
181
- if (this.shardedSubscribers.has(nodeKey)) {
193
+ const existingSubscriber = this.shardedSubscribers.get(nodeKey);
194
+ // If we already have a subscriber for this node, only ensure it is healthy
195
+ // when it now owns slots with active channel subscriptions.
196
+ if (existingSubscriber && existingSubscriber.isHealthy()) {
182
197
  debug("Skipping creating new subscriber for %s", nodeKey);
198
+ if (!existingSubscriber.isStarted() &&
199
+ this.shouldStartSubscriber(existingSubscriber)) {
200
+ startPromises.push(existingSubscriber
201
+ .start()
202
+ .then(() => {
203
+ this.handleSubscriberConnectSucceeded(nodeKey);
204
+ })
205
+ .catch((error) => {
206
+ this.handleSubscriberConnectFailed(error, nodeKey);
207
+ }));
208
+ }
183
209
  continue;
184
210
  }
211
+ // If we have an existing subscriber but it is not healthy, stop it
212
+ if (existingSubscriber && !existingSubscriber.isHealthy()) {
213
+ debug("Replacing subscriber for %s", nodeKey);
214
+ existingSubscriber.stop();
215
+ this.shardedSubscribers.delete(nodeKey);
216
+ this.subscriberGroupEmitter.emit("-subscriber");
217
+ }
185
218
  debug("Creating new subscriber for %s", nodeKey);
186
219
  // Otherwise create a new subscriber
187
220
  const redis = clusterNodes.find((node) => {
@@ -191,16 +224,18 @@ class ClusterSubscriberGroup {
191
224
  debug("Failed to find node for key %s", nodeKey);
192
225
  continue;
193
226
  }
194
- const sub = new ShardedSubscriber_1.default(this.subscriberGroupEmitter, redis.options);
227
+ const sub = new ShardedSubscriber_1.default(this.subscriberGroupEmitter, redis.options, this.options.redisOptions);
195
228
  this.shardedSubscribers.set(nodeKey, sub);
196
- startPromises.push(sub
197
- .start()
198
- .then(() => {
199
- this.handleSubscriberConnectSucceeded(nodeKey);
200
- })
201
- .catch((error) => {
202
- this.handleSubscriberConnectFailed(error, nodeKey);
203
- }));
229
+ if (this.shouldStartSubscriber(sub)) {
230
+ startPromises.push(sub
231
+ .start()
232
+ .then(() => {
233
+ this.handleSubscriberConnectSucceeded(nodeKey);
234
+ })
235
+ .catch((error) => {
236
+ this.handleSubscriberConnectFailed(error, nodeKey);
237
+ }));
238
+ }
204
239
  this.subscriberGroupEmitter.emit("+subscriber");
205
240
  }
206
241
  // It's vital to await the start promises before resubscribing
@@ -228,7 +263,8 @@ class ClusterSubscriberGroup {
228
263
  */
229
264
  _refreshSlots(targetSlots) {
230
265
  //If there was an actual change, then reassign the slot ranges
231
- if (this._slotsAreEqual(targetSlots)) {
266
+ // Also rebuild if subscriberToSlotsIndex is empty (e.g., after stop() was called)
267
+ if (this._slotsAreEqual(targetSlots) && this.subscriberToSlotsIndex.size > 0) {
232
268
  debug("Nothing to refresh because the new cluster map is equal to the previous one.");
233
269
  return false;
234
270
  }
@@ -262,7 +298,7 @@ class ClusterSubscriberGroup {
262
298
  const redis = s.getInstance();
263
299
  const channels = this.channels.get(ss);
264
300
  if (channels && channels.length > 0) {
265
- if (redis.status === "end") {
301
+ if (!redis || redis.status === "end") {
266
302
  return;
267
303
  }
268
304
  if (redis.status === "ready") {
@@ -309,10 +345,26 @@ class ClusterSubscriberGroup {
309
345
  * @returns true if any subscribers need to be recreated
310
346
  */
311
347
  hasUnhealthySubscribers() {
312
- const hasFailedSubscribers = Array.from(this.shardedSubscribers.values()).some((sub) => !sub.isStarted());
348
+ const hasFailedSubscribers = Array.from(this.shardedSubscribers.values()).some((sub) => !sub.isHealthy());
313
349
  const hasMissingSubscribers = Array.from(this.subscriberToSlotsIndex.keys()).some((nodeKey) => !this.shardedSubscribers.has(nodeKey));
314
350
  return hasFailedSubscribers || hasMissingSubscribers;
315
351
  }
352
+ shouldStartSubscriber(sub) {
353
+ if (sub.isStarted()) {
354
+ return false;
355
+ }
356
+ if (!sub.isLazyConnect()) {
357
+ return true;
358
+ }
359
+ const subscriberSlots = this.subscriberToSlotsIndex.get(sub.getNodeKey());
360
+ if (!subscriberSlots) {
361
+ return false;
362
+ }
363
+ return subscriberSlots.some((slot) => {
364
+ const channels = this.channels.get(slot);
365
+ return Boolean(channels && channels.length > 0);
366
+ });
367
+ }
316
368
  }
317
369
  exports.default = ClusterSubscriberGroup;
318
370
  // Retry strategy
@@ -2,19 +2,35 @@
2
2
  import EventEmitter = require("events");
3
3
  import { RedisOptions } from "./util";
4
4
  import Redis from "../Redis";
5
+ import { ClusterOptions } from "./ClusterOptions";
6
+ declare const SubscriberStatus: {
7
+ readonly IDLE: "idle";
8
+ readonly STARTING: "starting";
9
+ readonly CONNECTED: "connected";
10
+ readonly STOPPING: "stopping";
11
+ readonly ENDED: "ended";
12
+ };
13
+ declare type SubscriberStatus = typeof SubscriberStatus[keyof typeof SubscriberStatus];
5
14
  export default class ShardedSubscriber {
6
15
  private readonly emitter;
7
16
  private readonly nodeKey;
8
- private started;
17
+ private status;
9
18
  private instance;
19
+ private connectPromise;
20
+ private lazyConnect;
10
21
  private readonly messageListeners;
11
- constructor(emitter: EventEmitter, options: RedisOptions);
12
- private onEnd;
13
- private onError;
14
- private onMoved;
22
+ constructor(emitter: EventEmitter, options: RedisOptions, redisOptions?: ClusterOptions["redisOptions"]);
15
23
  start(): Promise<void>;
16
24
  stop(): void;
17
25
  isStarted(): boolean;
26
+ get subscriberStatus(): SubscriberStatus;
27
+ isHealthy(): boolean;
18
28
  getInstance(): Redis | null;
19
29
  getNodeKey(): string;
30
+ isLazyConnect(): boolean;
31
+ private onEnd;
32
+ private onError;
33
+ private onMoved;
34
+ private updateStatus;
20
35
  }
36
+ export {};