Skip to content

Commit ae84140

Browse files
aartisonigrankaradzhovclaude
authored
fix(sentinel): preserve seed nodes as reconnection candidates (redis#3237) (redis#3306)
transform() replaced sentinelRootNodes with the discovered peer list, so once the connected sentinel was a discovered IP the configured hostname seeds were evicted. After a full outage with new IPs the client then had no resolvable address left to reconnect to. Add mergeSentinelNodes(seeds, discovered): seeds are always kept as reconnection candidates (first), discovered nodes appended, deduped by host:port. transform() rebuilds sentinelRootNodes from this merge each cycle. Hostname seeds re-resolve via DNS and recover from full-IP-change outages; IP-literal seeds recover when sentinels return at the same address. Fixes redis#3237 Co-authored-by: Nikolay Karadzhov <nkaradzhov89@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6afc2b6 commit ae84140

4 files changed

Lines changed: 118 additions & 6 deletions

File tree

docs/sentinel.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ const sentinel = await createSentinel({
7474
| Property | Default | Description |
7575
|----------------------------|-----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
7676
| name | | The sentinel identifier for a particular database cluster |
77-
| 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 |
77+
| 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). |
7878
| maxCommandRediscovers | `16` | The maximum number of times a command will retry due to topology changes. |
7979
| nodeClientOptions | | The configuration values for every node in the cluster. Use this for example when specifying an ACL user to connect with |
8080
| sentinelClientOptions | | The configuration values for every sentinel in the cluster. Use this for example when specifying an ACL user to connect with |
@@ -85,6 +85,17 @@ const sentinel = await createSentinel({
8585
| 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. |
8686
| 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. |
8787

88+
## Reconnecting after an outage
89+
90+
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.
91+
92+
Whether the client can recover depends on what the seeds resolve to:
93+
94+
- **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).
95+
- **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.
96+
97+
A stale seed that never comes back is harmless: the client fails to connect to it and moves on to the next candidate.
98+
8899
## PubSub
89100

90101
It supports PubSub via the normal mechanisms, including migrating the listeners if the node they are connected to goes down.

packages/client/lib/sentinel/index.spec.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ScanIteratorInterruptedError, WatchError } from "../errors";
66
import { RedisSentinelConfig, SentinelFramework } from "./test-util";
77
import { RedisSentinelEvent, RedisSentinelType, RedisSentinelClientType, RedisNode } from "./types";
88
import RedisSentinel from "./index";
9+
import { mergeSentinelNodes } from "./utils";
910
import { RedisArgument, RedisModules, RedisFunctions, RedisScripts, RespVersions, TypeMapping } from '../RESP/types';
1011
import { promisify } from 'node:util';
1112
import { exec } from 'node:child_process';
@@ -131,6 +132,76 @@ describe('RedisSentinel', () => {
131132
assert.equal(duplicated.commandOptions?.timeout, overrideTimeout);
132133
});
133134

135+
describe('mergeSentinelNodes (issue #3237)', () => {
136+
// Regression: transform() used to replace sentinelRootNodes with the
137+
// discovered list alone, dropping hostname-based seeds. After a full outage
138+
// where sentinels restart with new IPs, the client then had no resolvable
139+
// address left to reconnect to. mergeSentinelNodes keeps the configured
140+
// hostname seeds available alongside discovered nodes.
141+
142+
it('keeps hostname seeds when sentinel reports only new IPs', () => {
143+
const seeds = [
144+
{ host: 'redis-sentinel-0.svc.local', port: 26379 },
145+
{ host: 'redis-sentinel-1.svc.local', port: 26380 },
146+
];
147+
const discovered = [
148+
{ host: '10.0.0.1', port: 26379 },
149+
{ host: '10.0.0.2', port: 26380 },
150+
];
151+
152+
const merged = mergeSentinelNodes(seeds, discovered);
153+
154+
assert.deepEqual(merged, [...seeds, ...discovered]);
155+
});
156+
157+
it('places seeds first, then appends discovered nodes', () => {
158+
const seeds = [{ host: 'seed.local', port: 26379 }];
159+
const discovered = [
160+
{ host: '10.0.0.1', port: 26379 },
161+
{ host: '10.0.0.2', port: 26380 },
162+
];
163+
164+
const merged = mergeSentinelNodes(seeds, discovered);
165+
166+
assert.equal(merged[0].host, 'seed.local');
167+
assert.deepEqual(merged.slice(1), discovered);
168+
});
169+
170+
it('dedupes by host:port across seeds and discovered nodes', () => {
171+
const seeds = [{ host: '10.0.0.1', port: 26379 }];
172+
const discovered = [
173+
{ host: '10.0.0.1', port: 26379 }, // duplicate of seed
174+
{ host: '10.0.0.2', port: 26380 },
175+
];
176+
177+
const merged = mergeSentinelNodes(seeds, discovered);
178+
179+
assert.equal(merged.length, 2);
180+
assert.deepEqual(merged, [
181+
{ host: '10.0.0.1', port: 26379 },
182+
{ host: '10.0.0.2', port: 26380 },
183+
]);
184+
});
185+
186+
it('treats same host with different ports as distinct nodes', () => {
187+
const seeds = [{ host: '10.0.0.1', port: 26379 }];
188+
const discovered = [{ host: '10.0.0.1', port: 26380 }];
189+
190+
const merged = mergeSentinelNodes(seeds, discovered);
191+
192+
assert.equal(merged.length, 2);
193+
});
194+
195+
it('returns just the seeds when nothing is discovered', () => {
196+
const seeds = [
197+
{ host: 'seed-0.local', port: 26379 },
198+
{ host: 'seed-1.local', port: 26380 },
199+
];
200+
201+
assert.deepEqual(mergeSentinelNodes(seeds, []), seeds);
202+
});
203+
});
204+
134205
it('should not have HOTKEYS commands (requires session affinity)', () => {
135206
// HOTKEYS commands require session affinity and are only available on standalone clients
136207
const sentinel = RedisSentinel.create({

packages/client/lib/sentinel/index.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { CommandOptions } from '../client/commands-queue';
66
import { attachConfig } from '../commander';
77
import { NON_STICKY_COMMANDS } from '../commands';
88
import { ClientErrorEvent, NamespaceProxySentinel, NamespaceProxySentinelClient, NodeAddressMap, ProxySentinel, ProxySentinelClient, RedisNode, RedisSentinelClientType, RedisSentinelEvent, RedisSentinelOptions, RedisSentinelType, SentinelCommander } from './types';
9-
import { clientSocketToNode, createCommand, createFunctionCommand, createModuleCommand, createNodeList, createScriptCommand, getMappedNode, parseNode } from './utils';
9+
import { clientSocketToNode, createCommand, createFunctionCommand, createModuleCommand, createNodeList, createScriptCommand, getMappedNode, mergeSentinelNodes, parseNode } from './utils';
1010
import { RedisMultiQueuedCommand } from '../multi-command';
1111
import RedisSentinelMultiCommand, { RedisSentinelMultiCommandType } from './multi-commands';
1212
import { PubSubListener } from '../client/pub-sub';
@@ -810,6 +810,8 @@ export class RedisSentinelInternal<
810810

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

1522-
if (this.#sentinelNodeListKey(analyzed.sentinelList) !== this.#sentinelNodeListKey(this.#sentinelRootNodes)) {
1523-
this.#sentinelRootNodes = analyzed.sentinelList;
1524+
const mergedSentinelList = mergeSentinelNodes(this.#sentinelSeedNodes, analyzed.sentinelList);
1525+
1526+
if (this.#sentinelNodeListKey(mergedSentinelList) !== this.#sentinelNodeListKey(this.#sentinelRootNodes)) {
1527+
this.#sentinelRootNodes = mergedSentinelList;
15241528
const event: RedisSentinelEvent = {
15251529
type: "SENTINE_LIST_CHANGE",
1526-
size: analyzed.sentinelList.length
1530+
size: mergedSentinelList.length
15271531
}
15281532
this.emit('topology-change', event);
15291533
}

packages/client/lib/sentinel/utils.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export function parseNode(node: Record<string, string>): RedisNode | undefined{
1515
}
1616

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

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

31+
/**
32+
* Merges configured seed nodes with nodes discovered from a sentinel, deduping
33+
* by `host:port`. Seeds are kept first so DNS-based hostnames remain available
34+
* for reconnection even after a full outage where sentinels return new IPs
35+
* (see issue #3237).
36+
*/
37+
export function mergeSentinelNodes(
38+
seedNodes: Array<RedisNode>,
39+
discoveredNodes: Array<RedisNode>
40+
): Array<RedisNode> {
41+
const seen = new Set<string>();
42+
const merged: Array<RedisNode> = [];
43+
44+
for (const node of [...seedNodes, ...discoveredNodes]) {
45+
const key = `${node.host}:${node.port}`;
46+
if (!seen.has(key)) {
47+
// Clone so the working root-nodes list never aliases the frozen seed
48+
// node objects (or the discovered ones).
49+
merged.push({ host: node.host, port: node.port });
50+
seen.add(key);
51+
}
52+
}
53+
54+
return merged;
55+
}
56+
3157
export function clientSocketToNode(socket: RedisSocketOptions): RedisNode {
3258
const s = socket as RedisTcpSocketOptions;
3359

0 commit comments

Comments
 (0)