1
0
mirror of https://github.com/redis/node-redis.git synced 2025-12-11 09:22:35 +03:00

fix(ssubscribe): properly resubscribe in case of shard failover (#3098)

* fix(ssubscribe): properly resubscribe in case of shard failover

1) when RE failover happens, there is a disconnect
2) affected Client reconnects and tries to resubscribe all existing listeners
ISSUE #1: CROSSSLOT Error - client was doing ssubscribe ch1 ch2.. chN which, after the failover could result in CROSSSLOT ( naturally, becasuse now some slots could be owned by other shards )
FIX: send one ssubscribe command per channel instead of one ssubscribe for all channels
ISSUE #2: MOVED Error - some/all of the channels might be moved somewhere else
FIX: 1: Propagate the error to the Cluster. 2: Cluster rediscovers topology.
3: Cluster resubscribes all listeners of the failed client ( possibly some/all of those will end up in a different client after the rediscovery ) 

fixes: #2902
This commit is contained in:
Nikolay Karadzhov
2025-10-13 11:59:08 +03:00
committed by GitHub
parent bd11e382d0
commit 96d6445d66
9 changed files with 828 additions and 25 deletions

View File

@@ -2,7 +2,7 @@ import { RedisClusterClientOptions, RedisClusterOptions } from '.';
import { RootNodesUnavailableError } from '../errors';
import RedisClient, { RedisClientOptions, RedisClientType } from '../client';
import { EventEmitter } from 'node:stream';
import { ChannelListeners, PUBSUB_TYPE, PubSubTypeListeners } from '../client/pub-sub';
import { ChannelListeners, PUBSUB_TYPE, PubSubListeners, PubSubTypeListeners } from '../client/pub-sub';
import { RedisArgument, RedisFunctions, RedisModules, RedisScripts, RespVersions, TypeMapping } from '../RESP/types';
import calculateSlot from 'cluster-key-slot';
import { RedisSocketOptions } from '../client/socket';
@@ -185,6 +185,7 @@ export default class RedisClusterSlots<
async #discover(rootNode: RedisClusterClientOptions) {
this.clientSideCache?.clear();
this.clientSideCache?.disable();
try {
const addressesInUse = new Set<string>(),
promises: Array<Promise<unknown>> = [],
@@ -224,6 +225,7 @@ export default class RedisClusterSlots<
}
}
//Keep only the nodes that are still in use
for (const [address, node] of this.nodeByAddress.entries()) {
if (addressesInUse.has(address)) continue;
@@ -337,23 +339,29 @@ export default class RedisClusterSlots<
const socket =
this.#getNodeAddress(node.address) ??
{ host: node.host, port: node.port, };
const client = Object.freeze({
const clientInfo = Object.freeze({
host: socket.host,
port: socket.port,
});
const emit = this.#emit;
return this.#clientFactory(
const client = this.#clientFactory(
this.#clientOptionsDefaults({
clientSideCache: this.clientSideCache,
RESP: this.#options.RESP,
socket,
readonly,
}))
.on('error', error => emit('node-error', error, client))
.on('reconnecting', () => emit('node-reconnecting', client))
.once('ready', () => emit('node-ready', client))
.once('connect', () => emit('node-connect', client))
.once('end', () => emit('node-disconnect', client));
.on('error', error => emit('node-error', error, clientInfo))
.on('reconnecting', () => emit('node-reconnecting', clientInfo))
.once('ready', () => emit('node-ready', clientInfo))
.once('connect', () => emit('node-connect', clientInfo))
.once('end', () => emit('node-disconnect', clientInfo))
.on('__MOVED', async (allPubSubListeners: PubSubListeners) => {
await this.rediscover(client);
this.#emit('__resubscribeAllPubSubListeners', allPubSubListeners);
});
return client;
}
#createNodeClient(node: ShardNode<M, F, S, RESP, TYPE_MAPPING>, readonly?: boolean) {
@@ -374,7 +382,9 @@ export default class RedisClusterSlots<
async rediscover(startWith: RedisClientType<M, F, S, RESP>): Promise<void> {
this.#runningRediscoverPromise ??= this.#rediscover(startWith)
.finally(() => this.#runningRediscoverPromise = undefined);
.finally(() => {
this.#runningRediscoverPromise = undefined
});
return this.#runningRediscoverPromise;
}

View File

@@ -6,7 +6,7 @@ import { EventEmitter } from 'node:events';
import { attachConfig, functionArgumentsPrefix, getTransformReply, scriptArgumentsPrefix } from '../commander';
import RedisClusterSlots, { NodeAddressMap, ShardNode } from './cluster-slots';
import RedisClusterMultiCommand, { RedisClusterMultiCommandType } from './multi-command';
import { PubSubListener } from '../client/pub-sub';
import { PubSubListener, PubSubListeners } from '../client/pub-sub';
import { ErrorReply } from '../errors';
import { RedisTcpSocketOptions } from '../client/socket';
import { ClientSideCacheConfig, PooledClientSideCacheProvider } from '../client/cache';
@@ -310,6 +310,7 @@ export default class RedisCluster<
this._options = options;
this._slots = new RedisClusterSlots(options, this.emit.bind(this));
this.on('__resubscribeAllPubSubListeners', this.resubscribeAllPubSubListeners.bind(this));
if (options?.commandOptions) {
this._commandOptions = options.commandOptions;
@@ -584,6 +585,33 @@ export default class RedisCluster<
);
}
resubscribeAllPubSubListeners(allListeners: PubSubListeners) {
for(const [channel, listeners] of allListeners.CHANNELS) {
listeners.buffers.forEach(bufListener => {
this.subscribe(channel, bufListener, true);
});
listeners.strings.forEach(strListener => {
this.subscribe(channel, strListener);
});
};
for (const [channel, listeners] of allListeners.PATTERNS) {
listeners.buffers.forEach(bufListener => {
this.pSubscribe(channel, bufListener, true);
});
listeners.strings.forEach(strListener => {
this.pSubscribe(channel, strListener);
});
};
for (const [channel, listeners] of allListeners.SHARDED) {
listeners.buffers.forEach(bufListener => {
this.sSubscribe(channel, bufListener, true);
});
listeners.strings.forEach(strListener => {
this.sSubscribe(channel, strListener);
});
};
}
sUnsubscribe = this.SUNSUBSCRIBE;
/**