Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion docs/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ const sentinel = await createSentinel({
| Property | Default | Description |
|----------------------------|-----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| name | | The sentinel identifier for a particular database cluster |
| sentinelRootNodes | | An array of root nodes that are part of the sentinel cluster, which will be used to get the topology. Each element in the array is a client configuration object. There is no need to specify every node in the cluster: 3 should be enough to reliably connect and obtain the sentinel configuration from the server |
| sentinelRootNodes | | An array of root nodes that are part of the sentinel cluster, which will be used to get the topology. Each element in the array is a client configuration object. There is no need to specify every node in the cluster: 3 should be enough to reliably connect and obtain the sentinel configuration from the server. These nodes are treated as seeds and are always kept as reconnection candidates — see [Reconnecting after an outage](#reconnecting-after-an-outage). |
| maxCommandRediscovers | `16` | The maximum number of times a command will retry due to topology changes. |
| nodeClientOptions | | The configuration values for every node in the cluster. Use this for example when specifying an ACL user to connect with |
| sentinelClientOptions | | The configuration values for every sentinel in the cluster. Use this for example when specifying an ACL user to connect with |
Expand All @@ -85,6 +85,17 @@ const sentinel = await createSentinel({
| passthroughClientErrorEvents | `false` | When `true`, error events from client instances inside the sentinel will be propagated to the sentinel instance. This allows handling all client errors through a single error handler on the sentinel instance. |
| reserveClient | `false` | When `true`, one client will be reserved for the sentinel object. When `false`, the sentinel object will wait for the first available client from the pool. |

## Reconnecting after an outage

As the client learns the sentinel topology it discovers additional sentinel nodes (reported by the sentinels as IP addresses). The nodes you pass in `sentinelRootNodes` are kept as **seeds**: they are always retained as reconnection candidates and are tried first, alongside the discovered nodes. This matters after an outage where the whole sentinel set restarts.

Whether the client can recover depends on what the seeds resolve to:

- **Hostname seeds** (e.g. a DNS name or a Kubernetes service) re-resolve on every reconnect attempt, so the client follows the sentinels to their new addresses even if every IP changed. This is the most robust configuration and is recommended for environments with ephemeral addressing (Kubernetes, cloud autoscaling, DHCP).
- **IP-literal seeds** recover only if the sentinels come back at the same addresses (static IP / bare-metal / fixed-IP container setups). If every sentinel restarts on a new IP and the seeds are IP literals, the client has no resolvable address left to reconnect to — there is no information from which to discover the new addresses. Use hostnames to avoid this.

A stale seed that never comes back is harmless: the client fails to connect to it and moves on to the next candidate.

## PubSub

It supports PubSub via the normal mechanisms, including migrating the listeners if the node they are connected to goes down.
Expand Down
71 changes: 71 additions & 0 deletions packages/client/lib/sentinel/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ScanIteratorInterruptedError, WatchError } from "../errors";
import { RedisSentinelConfig, SentinelFramework } from "./test-util";
import { RedisSentinelEvent, RedisSentinelType, RedisSentinelClientType, RedisNode } from "./types";
import RedisSentinel from "./index";
import { mergeSentinelNodes } from "./utils";
import { RedisArgument, RedisModules, RedisFunctions, RedisScripts, RespVersions, TypeMapping } from '../RESP/types';
import { promisify } from 'node:util';
import { exec } from 'node:child_process';
Expand Down Expand Up @@ -131,6 +132,76 @@ describe('RedisSentinel', () => {
assert.equal(duplicated.commandOptions?.timeout, overrideTimeout);
});

describe('mergeSentinelNodes (issue #3237)', () => {
// Regression: transform() used to replace sentinelRootNodes with the
// discovered list alone, dropping hostname-based seeds. After a full outage
// where sentinels restart with new IPs, the client then had no resolvable
// address left to reconnect to. mergeSentinelNodes keeps the configured
// hostname seeds available alongside discovered nodes.

it('keeps hostname seeds when sentinel reports only new IPs', () => {
const seeds = [
{ host: 'redis-sentinel-0.svc.local', port: 26379 },
{ host: 'redis-sentinel-1.svc.local', port: 26380 },
];
const discovered = [
{ host: '10.0.0.1', port: 26379 },
{ host: '10.0.0.2', port: 26380 },
];

const merged = mergeSentinelNodes(seeds, discovered);

assert.deepEqual(merged, [...seeds, ...discovered]);
});

it('places seeds first, then appends discovered nodes', () => {
const seeds = [{ host: 'seed.local', port: 26379 }];
const discovered = [
{ host: '10.0.0.1', port: 26379 },
{ host: '10.0.0.2', port: 26380 },
];

const merged = mergeSentinelNodes(seeds, discovered);

assert.equal(merged[0].host, 'seed.local');
assert.deepEqual(merged.slice(1), discovered);
});

it('dedupes by host:port across seeds and discovered nodes', () => {
const seeds = [{ host: '10.0.0.1', port: 26379 }];
const discovered = [
{ host: '10.0.0.1', port: 26379 }, // duplicate of seed
{ host: '10.0.0.2', port: 26380 },
];

const merged = mergeSentinelNodes(seeds, discovered);

assert.equal(merged.length, 2);
assert.deepEqual(merged, [
{ host: '10.0.0.1', port: 26379 },
{ host: '10.0.0.2', port: 26380 },
]);
});

it('treats same host with different ports as distinct nodes', () => {
const seeds = [{ host: '10.0.0.1', port: 26379 }];
const discovered = [{ host: '10.0.0.1', port: 26380 }];

const merged = mergeSentinelNodes(seeds, discovered);

assert.equal(merged.length, 2);
});

it('returns just the seeds when nothing is discovered', () => {
const seeds = [
{ host: 'seed-0.local', port: 26379 },
{ host: 'seed-1.local', port: 26380 },
];

assert.deepEqual(mergeSentinelNodes(seeds, []), seeds);
});
Comment thread
cursor[bot] marked this conversation as resolved.
});

it('should not have HOTKEYS commands (requires session affinity)', () => {
// HOTKEYS commands require session affinity and are only available on standalone clients
const sentinel = RedisSentinel.create({
Expand Down
12 changes: 8 additions & 4 deletions packages/client/lib/sentinel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { CommandOptions } from '../client/commands-queue';
import { attachConfig } from '../commander';
import { NON_STICKY_COMMANDS } from '../commands';
import { ClientErrorEvent, NamespaceProxySentinel, NamespaceProxySentinelClient, NodeAddressMap, ProxySentinel, ProxySentinelClient, RedisNode, RedisSentinelClientType, RedisSentinelEvent, RedisSentinelOptions, RedisSentinelType, SentinelCommander } from './types';
import { clientSocketToNode, createCommand, createFunctionCommand, createModuleCommand, createNodeList, createScriptCommand, getMappedNode, parseNode } from './utils';
import { clientSocketToNode, createCommand, createFunctionCommand, createModuleCommand, createNodeList, createScriptCommand, getMappedNode, mergeSentinelNodes, parseNode } from './utils';
import { RedisMultiQueuedCommand } from '../multi-command';
import RedisSentinelMultiCommand, { RedisSentinelMultiCommandType } from './multi-commands';
import { PubSubListener } from '../client/pub-sub';
Expand Down Expand Up @@ -810,6 +810,8 @@ export class RedisSentinelInternal<

this.#RESP = options.RESP;
this.#sentinelSeedNodes = Array.from(options.sentinelRootNodes);
// Initial root nodes start as a copy of the seed nodes; transform() later
// merges discovered nodes on top while preserving these seeds.
this.#sentinelRootNodes = Array.from(this.#sentinelSeedNodes);
this.#maxCommandRediscovers = options.maxCommandRediscovers ?? 16;
this.#masterPoolSize = options.masterPoolSize ?? 1;
Expand Down Expand Up @@ -1519,11 +1521,13 @@ export class RedisSentinelInternal<
}
}

if (this.#sentinelNodeListKey(analyzed.sentinelList) !== this.#sentinelNodeListKey(this.#sentinelRootNodes)) {
this.#sentinelRootNodes = analyzed.sentinelList;
const mergedSentinelList = mergeSentinelNodes(this.#sentinelSeedNodes, analyzed.sentinelList);

if (this.#sentinelNodeListKey(mergedSentinelList) !== this.#sentinelNodeListKey(this.#sentinelRootNodes)) {
this.#sentinelRootNodes = mergedSentinelList;
const event: RedisSentinelEvent = {
type: "SENTINE_LIST_CHANGE",
size: analyzed.sentinelList.length
size: mergedSentinelList.length
}
this.emit('topology-change', event);
}
Expand Down
28 changes: 27 additions & 1 deletion packages/client/lib/sentinel/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function parseNode(node: Record<string, string>): RedisNode | undefined{
}

export function createNodeList(nodes: UnwrapReply<ArrayReply<Record<string, string>>>) {
var nodeList: Array<RedisNode> = [];
const nodeList: Array<RedisNode> = [];

for (const nodeData of nodes) {
const node = parseNode(nodeData)
Expand All @@ -28,6 +28,32 @@ export function createNodeList(nodes: UnwrapReply<ArrayReply<Record<string, stri
return nodeList;
}

/**
* Merges configured seed nodes with nodes discovered from a sentinel, deduping
* by `host:port`. Seeds are kept first so DNS-based hostnames remain available
* for reconnection even after a full outage where sentinels return new IPs
* (see issue #3237).
*/
export function mergeSentinelNodes(
seedNodes: Array<RedisNode>,
discoveredNodes: Array<RedisNode>
): Array<RedisNode> {
const seen = new Set<string>();
const merged: Array<RedisNode> = [];

for (const node of [...seedNodes, ...discoveredNodes]) {
const key = `${node.host}:${node.port}`;
if (!seen.has(key)) {
// Clone so the working root-nodes list never aliases the frozen seed
// node objects (or the discovered ones).
merged.push({ host: node.host, port: node.port });
seen.add(key);
}
}

return merged;
}
Comment thread
aartisonigra marked this conversation as resolved.

export function clientSocketToNode(socket: RedisSocketOptions): RedisNode {
const s = socket as RedisTcpSocketOptions;

Expand Down