Skip to content

Commit 0803f4e

Browse files
leibaleguyroyse
andauthored
add nodeAddressMap config for cluster (#1827)
* add `nodeAddressMap` config for cluster * Update cluster-slots.ts * Update cluster-slots.ts * update docs Co-authored-by: Guy Royse <guy@guyroyse.com> Co-authored-by: Guy Royse <guy@guyroyse.com>
1 parent 6dd15d9 commit 0803f4e

File tree

5 files changed

+100
-54
lines changed

5 files changed

+100
-54
lines changed

docs/clustering.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,32 @@ import { createCluster } from 'redis';
3838
| defaults | | The default configuration values for every client in the cluster. Use this for example when specifying an ACL user to connect with |
3939
| useReplicas | `false` | When `true`, distribute load by executing readonly commands (such as `GET`, `GEOSEARCH`, etc.) across all cluster nodes. When `false`, only use master nodes |
4040
| maxCommandRedirections | `16` | The maximum number of times a command will be redirected due to `MOVED` or `ASK` errors |
41+
| nodeAddressMap | | Object defining the [node address mapping](#node-address-map) |
4142
| modules | | Object defining which [Redis Modules](../README.md#modules) to include |
4243
| scripts | | Object defining Lua Scripts to use with this client (see [Lua Scripts](../README.md#lua-scripts)) |
4344

45+
## Node Address Map
46+
47+
Your cluster might be configured to work within an internal network that your local environment doesn't have access to. For example, your development machine could only have access to external addresses, but the cluster returns its internal addresses. In this scenario, it's useful to provide a map from those internal addresses to the external ones.
48+
49+
The configuration for this is a simple mapping. Just provide a `nodeAddressMap` property mapping the internal addresses and ports to the external addresses and ports. Then, any address provided to `rootNodes` or returned from the cluster will be mapped accordingly:
50+
51+
```javascript
52+
createCluster({
53+
rootNodes: [{
54+
url: '10.0.0.1:30001'
55+
}, {
56+
url: '10.0.0.2:30002'
57+
}],
58+
nodeAddressMap: {
59+
'10.0.0.1:30001': 'external-host-1.io:30001',
60+
'10.0.0.2:30002': 'external-host-2.io:30002'
61+
}
62+
});
63+
```
64+
65+
> This is a common problem when using ElastiCache. See [Accessing ElastiCache from outside AWS](https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/accessing-elasticache.html) for more information on that.
66+
4467
## Command Routing
4568

4669
### Commands that operate on Redis Keys

packages/client/lib/cluster/cluster-slots.ts

Lines changed: 51 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ export interface ClusterNode<M extends RedisModules, S extends RedisScripts> {
1414
client: RedisClientType<M, S>;
1515
}
1616

17+
interface NodeAddress {
18+
host: string;
19+
port: number;
20+
}
21+
22+
export type NodeAddressMap = {
23+
[address: string]: NodeAddress;
24+
} | ((address: string) => NodeAddress | undefined);
25+
1726
interface SlotNodes<M extends RedisModules, S extends RedisScripts> {
1827
master: ClusterNode<M, S>;
1928
replicas: Array<ClusterNode<M, S>>;
@@ -26,7 +35,7 @@ export default class RedisClusterSlots<M extends RedisModules, S extends RedisSc
2635
readonly #options: RedisClusterOptions<M, S>;
2736
readonly #Client: InstantiableRedisClient<M, S>;
2837
readonly #onError: OnError;
29-
readonly #nodeByUrl = new Map<string, ClusterNode<M, S>>();
38+
readonly #nodeByAddress = new Map<string, ClusterNode<M, S>>();
3039
readonly #slots: Array<SlotNodes<M, S>> = [];
3140

3241
constructor(options: RedisClusterOptions<M, S>, onError: OnError) {
@@ -37,7 +46,7 @@ export default class RedisClusterSlots<M extends RedisModules, S extends RedisSc
3746

3847
async connect(): Promise<void> {
3948
for (const rootNode of this.#options.rootNodes) {
40-
if (await this.#discoverNodes(this.#clientOptionsDefaults(rootNode))) return;
49+
if (await this.#discoverNodes(rootNode)) return;
4150
}
4251

4352
throw new RootNodesUnavailableError();
@@ -75,7 +84,7 @@ export default class RedisClusterSlots<M extends RedisModules, S extends RedisSc
7584
async #rediscover(startWith: RedisClientType<M, S>): Promise<void> {
7685
if (await this.#discoverNodes(startWith.options)) return;
7786

78-
for (const { client } of this.#nodeByUrl.values()) {
87+
for (const { client } of this.#nodeByAddress.values()) {
7988
if (client === startWith) continue;
8089

8190
if (await this.#discoverNodes(client.options)) return;
@@ -85,7 +94,7 @@ export default class RedisClusterSlots<M extends RedisModules, S extends RedisSc
8594
}
8695

8796
async #reset(masters: Array<RedisClusterMasterNode>): Promise<void> {
88-
// Override this.#slots and add not existing clients to this.#nodeByUrl
97+
// Override this.#slots and add not existing clients to this.#nodeByAddress
8998
const promises: Array<Promise<void>> = [],
9099
clientsInUse = new Set<string>();
91100
for (const master of masters) {
@@ -94,7 +103,7 @@ export default class RedisClusterSlots<M extends RedisModules, S extends RedisSc
94103
replicas: this.#options.useReplicas ?
95104
master.replicas.map(replica => this.#initiateClientForNode(replica, true, clientsInUse, promises)) :
96105
[],
97-
clientIterator: undefined // will be initiated in use
106+
clientIterator: undefined // will be initiated in use
98107
};
99108

100109
for (const { from, to } of master.slots) {
@@ -104,12 +113,12 @@ export default class RedisClusterSlots<M extends RedisModules, S extends RedisSc
104113
}
105114
}
106115

107-
// Remove unused clients from this.#nodeByUrl using clientsInUse
108-
for (const [url, { client }] of this.#nodeByUrl.entries()) {
109-
if (clientsInUse.has(url)) continue;
116+
// Remove unused clients from this.#nodeByAddress using clientsInUse
117+
for (const [address, { client }] of this.#nodeByAddress.entries()) {
118+
if (clientsInUse.has(address)) continue;
110119

111120
promises.push(client.disconnect());
112-
this.#nodeByUrl.delete(url);
121+
this.#nodeByAddress.delete(address);
113122
}
114123

115124
await Promise.all(promises);
@@ -118,38 +127,49 @@ export default class RedisClusterSlots<M extends RedisModules, S extends RedisSc
118127
#clientOptionsDefaults(options?: RedisClusterClientOptions): RedisClusterClientOptions | undefined {
119128
if (!this.#options.defaults) return options;
120129

121-
const merged = Object.assign({}, this.#options.defaults, options);
122-
123-
if (options?.socket && this.#options.defaults.socket) {
124-
Object.assign({}, this.#options.defaults.socket, options.socket);
125-
}
126-
127-
return merged;
130+
return {
131+
...this.#options.defaults,
132+
...options,
133+
socket: this.#options.defaults.socket && options?.socket ? {
134+
...this.#options.defaults.socket,
135+
...options.socket
136+
} : this.#options.defaults.socket ?? options?.socket
137+
};
128138
}
129139

130140
#initiateClient(options?: RedisClusterClientOptions): RedisClientType<M, S> {
131141
return new this.#Client(this.#clientOptionsDefaults(options))
132142
.on('error', this.#onError);
133143
}
134144

145+
#getNodeAddress(address: string): NodeAddress | undefined {
146+
switch (typeof this.#options.nodeAddressMap) {
147+
case 'object':
148+
return this.#options.nodeAddressMap[address];
149+
150+
case 'function':
151+
return this.#options.nodeAddressMap(address);
152+
}
153+
}
154+
135155
#initiateClientForNode(nodeData: RedisClusterMasterNode | RedisClusterReplicaNode, readonly: boolean, clientsInUse: Set<string>, promises: Array<Promise<void>>): ClusterNode<M, S> {
136-
const url = `${nodeData.host}:${nodeData.port}`;
137-
clientsInUse.add(url);
156+
const address = `${nodeData.host}:${nodeData.port}`;
157+
clientsInUse.add(address);
138158

139-
let node = this.#nodeByUrl.get(url);
159+
let node = this.#nodeByAddress.get(address);
140160
if (!node) {
141161
node = {
142162
id: nodeData.id,
143163
client: this.#initiateClient({
144-
socket: {
164+
socket: this.#getNodeAddress(address) ?? {
145165
host: nodeData.host,
146166
port: nodeData.port
147167
},
148168
readonly
149169
})
150170
};
151171
promises.push(node.client.connect());
152-
this.#nodeByUrl.set(url, node);
172+
this.#nodeByAddress.set(address, node);
153173
}
154174

155175
return node;
@@ -186,12 +206,12 @@ export default class RedisClusterSlots<M extends RedisModules, S extends RedisSc
186206
#randomClientIterator?: IterableIterator<ClusterNode<M, S>>;
187207

188208
#getRandomClient(): RedisClientType<M, S> {
189-
if (!this.#nodeByUrl.size) {
209+
if (!this.#nodeByAddress.size) {
190210
throw new Error('Cluster is not connected');
191211
}
192212

193213
if (!this.#randomClientIterator) {
194-
this.#randomClientIterator = this.#nodeByUrl.values();
214+
this.#randomClientIterator = this.#nodeByAddress.values();
195215
}
196216

197217
const {done, value} = this.#randomClientIterator.next();
@@ -218,8 +238,7 @@ export default class RedisClusterSlots<M extends RedisModules, S extends RedisSc
218238

219239
getMasters(): Array<ClusterNode<M, S>> {
220240
const masters = [];
221-
222-
for (const node of this.#nodeByUrl.values()) {
241+
for (const node of this.#nodeByAddress.values()) {
223242
if (node.client.options?.readonly) continue;
224243

225244
masters.push(node);
@@ -228,8 +247,11 @@ export default class RedisClusterSlots<M extends RedisModules, S extends RedisSc
228247
return masters;
229248
}
230249

231-
getNodeByUrl(url: string): ClusterNode<M, S> | undefined {
232-
return this.#nodeByUrl.get(url);
250+
getNodeByAddress(address: string): ClusterNode<M, S> | undefined {
251+
const mappedAddress = this.#getNodeAddress(address);
252+
return this.#nodeByAddress.get(
253+
mappedAddress ? `${mappedAddress.host}:${mappedAddress.port}` : address
254+
);
233255
}
234256

235257
quit(): Promise<void> {
@@ -242,13 +264,13 @@ export default class RedisClusterSlots<M extends RedisModules, S extends RedisSc
242264

243265
async #destroy(fn: (client: RedisClientType<M, S>) => Promise<unknown>): Promise<void> {
244266
const promises = [];
245-
for (const { client } of this.#nodeByUrl.values()) {
267+
for (const { client } of this.#nodeByAddress.values()) {
246268
promises.push(fn(client));
247269
}
248270

249271
await Promise.all(promises);
250272

251-
this.#nodeByUrl.clear();
273+
this.#nodeByAddress.clear();
252274
this.#slots.splice(0);
253275
}
254276
}

packages/client/lib/cluster/index.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import COMMANDS from './commands';
22
import { RedisCommand, RedisCommandArgument, RedisCommandArguments, RedisCommandRawReply, RedisCommandReply, RedisModules, RedisPlugins, RedisScript, RedisScripts } from '../commands';
33
import { ClientCommandOptions, RedisClientCommandSignature, RedisClientOptions, RedisClientType, WithModules, WithScripts } from '../client';
4-
import RedisClusterSlots, { ClusterNode } from './cluster-slots';
4+
import RedisClusterSlots, { ClusterNode, NodeAddressMap } from './cluster-slots';
55
import { extendWithModulesAndScripts, transformCommandArguments, transformCommandReply, extendWithCommands } from '../commander';
66
import { EventEmitter } from 'events';
77
import RedisClusterMultiCommand, { RedisClusterMultiCommandType } from './multi-command';
@@ -17,6 +17,7 @@ export interface RedisClusterOptions<
1717
defaults?: Partial<RedisClusterClientOptions>;
1818
useReplicas?: boolean;
1919
maxCommandRedirections?: number;
20+
nodeAddressMap?: NodeAddressMap;
2021
}
2122

2223
type WithCommands = {
@@ -144,16 +145,16 @@ export default class RedisCluster<M extends RedisModules, S extends RedisScripts
144145
}
145146

146147
if (err.message.startsWith('ASK')) {
147-
const url = err.message.substring(err.message.lastIndexOf(' ') + 1);
148-
if (this.#slots.getNodeByUrl(url)?.client === client) {
148+
const address = err.message.substring(err.message.lastIndexOf(' ') + 1);
149+
if (this.#slots.getNodeByAddress(address)?.client === client) {
149150
await client.asking();
150151
continue;
151152
}
152153

153154
await this.#slots.rediscover(client);
154-
const redirectTo = this.#slots.getNodeByUrl(url);
155+
const redirectTo = this.#slots.getNodeByAddress(address);
155156
if (!redirectTo) {
156-
throw new Error(`Cannot find node ${url}`);
157+
throw new Error(`Cannot find node ${address}`);
157158
}
158159

159160
await redirectTo.client.asking();

packages/client/lib/commands/CLUSTER_NODES.spec.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ describe('CLUSTER NODES', () => {
1919
].join('\n')),
2020
[{
2121
id: 'master',
22-
url: '127.0.0.1:30001@31001',
22+
address: '127.0.0.1:30001@31001',
2323
host: '127.0.0.1',
2424
port: 30001,
2525
cport: 31001,
@@ -34,7 +34,7 @@ describe('CLUSTER NODES', () => {
3434
}],
3535
replicas: [{
3636
id: 'slave',
37-
url: '127.0.0.1:30002@31002',
37+
address: '127.0.0.1:30002@31002',
3838
host: '127.0.0.1',
3939
port: 30002,
4040
cport: 31002,
@@ -48,14 +48,14 @@ describe('CLUSTER NODES', () => {
4848
);
4949
});
5050

51-
it('should support urls without cport', () => {
51+
it('should support addresses without cport', () => {
5252
assert.deepEqual(
5353
transformReply(
5454
'id 127.0.0.1:30001 master - 0 0 0 connected 0-16384\n'
5555
),
5656
[{
5757
id: 'id',
58-
url: '127.0.0.1:30001',
58+
address: '127.0.0.1:30001',
5959
host: '127.0.0.1',
6060
port: 30001,
6161
cport: null,
@@ -80,7 +80,7 @@ describe('CLUSTER NODES', () => {
8080
),
8181
[{
8282
id: 'id',
83-
url: '127.0.0.1:30001@31001',
83+
address: '127.0.0.1:30001@31001',
8484
host: '127.0.0.1',
8585
port: 30001,
8686
cport: 31001,
@@ -102,7 +102,7 @@ describe('CLUSTER NODES', () => {
102102
),
103103
[{
104104
id: 'id',
105-
url: '127.0.0.1:30001@31001',
105+
address: '127.0.0.1:30001@31001',
106106
host: '127.0.0.1',
107107
port: 30001,
108108
cport: 31001,

packages/client/lib/commands/CLUSTER_NODES.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@ export enum RedisClusterNodeLinkStates {
77
DISCONNECTED = 'disconnected'
88
}
99

10-
interface RedisClusterNodeTransformedUrl {
10+
interface RedisClusterNodeAddress {
1111
host: string;
1212
port: number;
1313
cport: number | null;
1414
}
1515

16-
export interface RedisClusterReplicaNode extends RedisClusterNodeTransformedUrl {
16+
export interface RedisClusterReplicaNode extends RedisClusterNodeAddress {
1717
id: string;
18-
url: string;
18+
address: string;
1919
flags: Array<string>;
2020
pingSent: number;
2121
pongRecv: number;
@@ -39,11 +39,11 @@ export function transformReply(reply: string): Array<RedisClusterMasterNode> {
3939
replicasMap = new Map<string, Array<RedisClusterReplicaNode>>();
4040

4141
for (const line of lines) {
42-
const [id, url, flags, masterId, pingSent, pongRecv, configEpoch, linkState, ...slots] = line.split(' '),
42+
const [id, address, flags, masterId, pingSent, pongRecv, configEpoch, linkState, ...slots] = line.split(' '),
4343
node = {
4444
id,
45-
url,
46-
...transformNodeUrl(url),
45+
address,
46+
...transformNodeAddress(address),
4747
flags: flags.split(','),
4848
pingSent: Number(pingSent),
4949
pongRecv: Number(pongRecv),
@@ -84,22 +84,22 @@ export function transformReply(reply: string): Array<RedisClusterMasterNode> {
8484
return [...mastersMap.values()];
8585
}
8686

87-
function transformNodeUrl(url: string): RedisClusterNodeTransformedUrl {
88-
const indexOfColon = url.indexOf(':'),
89-
indexOfAt = url.indexOf('@', indexOfColon),
90-
host = url.substring(0, indexOfColon);
87+
function transformNodeAddress(address: string): RedisClusterNodeAddress {
88+
const indexOfColon = address.indexOf(':'),
89+
indexOfAt = address.indexOf('@', indexOfColon),
90+
host = address.substring(0, indexOfColon);
9191

9292
if (indexOfAt === -1) {
9393
return {
9494
host,
95-
port: Number(url.substring(indexOfColon + 1)),
95+
port: Number(address.substring(indexOfColon + 1)),
9696
cport: null
9797
};
9898
}
9999

100100
return {
101-
host: url.substring(0, indexOfColon),
102-
port: Number(url.substring(indexOfColon + 1, indexOfAt)),
103-
cport: Number(url.substring(indexOfAt + 1))
101+
host: address.substring(0, indexOfColon),
102+
port: Number(address.substring(indexOfColon + 1, indexOfAt)),
103+
cport: Number(address.substring(indexOfAt + 1))
104104
};
105105
}

0 commit comments

Comments
 (0)