Skip to content
Draft
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
46 changes: 46 additions & 0 deletions docs/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,51 @@ await sentinel.close();

In the above example, we configure the sentinel object to fetch the configuration for the database Redis Sentinel is monitoring as "sentinel-db" with one of the sentinels being located at `example:1234`, then using it like a regular Redis client.

## Node Address Map

A mapping between the addresses returned by sentinel and the addresses the client should connect to.
Useful when the sentinel nodes are running on a different network to the client.

```javascript
import { createSentinel } from 'redis';

// Use either a static mapping:
const sentinel = await createSentinel({
name: 'sentinel-db',
sentinelRootNodes: [{
host: 'example',
port: 1234
}],
nodeAddressMap: {
'10.0.0.1:6379': {
host: 'external-host.io',
port: 6379
},
'10.0.0.2:6379': {
host: 'external-host.io',
port: 6380
}
}
}).connect();

// or create the mapping dynamically, as a function:
const sentinel = await createSentinel({
name: 'sentinel-db',
sentinelRootNodes: [{
host: 'example',
port: 1234
}],
nodeAddressMap(address) {
const [host, port] = address.split(':');

return {
host: `external-${host}.io`,
port: Number(port)
};
}
}).connect();
```

## `createSentinel` configuration

| Property | Default | Description |
Expand All @@ -35,6 +80,7 @@ In the above example, we configure the sentinel object to fetch the configuratio
| sentinelClientOptions | | The configuration values for every sentinel in the cluster. Use this for example when specifying an ACL user to connect with |
| masterPoolSize | `1` | The number of clients connected to the master node |
| replicaPoolSize | `0` | The number of clients connected to each replica node. When greater than 0, the client will distribute the load by executing read-only commands (such as `GET`, `GEOSEARCH`, etc.) across all the cluster nodes. |
| nodeAddressMap | | Defines the [node address mapping](#node-address-map) |
| scanInterval | `10000` | Interval in milliseconds to periodically scan for changes in the sentinel topology. The client will query the sentinel for changes at this interval. |
| 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. |
Expand Down
49 changes: 42 additions & 7 deletions packages/client/lib/sentinel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import RedisClient, { RedisClientOptions, RedisClientType } from '../client';
import { CommandOptions } from '../client/commands-queue';
import { attachConfig } from '../commander';
import COMMANDS from '../commands';
import { ClientErrorEvent, NamespaceProxySentinel, NamespaceProxySentinelClient, ProxySentinel, ProxySentinelClient, RedisNode, RedisSentinelClientType, RedisSentinelEvent, RedisSentinelOptions, RedisSentinelType, SentinelCommander } from './types';
import { ClientErrorEvent, NamespaceProxySentinel, NamespaceProxySentinelClient, NodeAddressMap, ProxySentinel, ProxySentinelClient, RedisNode, RedisSentinelClientType, RedisSentinelEvent, RedisSentinelOptions, RedisSentinelType, SentinelCommander } from './types';
import { clientSocketToNode, createCommand, createFunctionCommand, createModuleCommand, createNodeList, createScriptCommand, parseNode } from './utils';
import { RedisMultiQueuedCommand } from '../multi-command';
import RedisSentinelMultiCommand, { RedisSentinelMultiCommandType } from './multi-commands';
Expand Down Expand Up @@ -623,6 +623,7 @@ class RedisSentinelInternal<
readonly #name: string;
readonly #nodeClientOptions: RedisClientOptions<M, F, S, RESP, TYPE_MAPPING, RedisTcpSocketOptions>;
readonly #sentinelClientOptions: RedisClientOptions<typeof RedisSentinelModule, RedisFunctions, RedisScripts, RespVersions, TypeMapping, RedisTcpSocketOptions>;
readonly #nodeAddressMap?: NodeAddressMap;
readonly #scanInterval: number;
readonly #passthroughClientErrorEvents: boolean;
readonly #RESP?: RespVersions;
Expand Down Expand Up @@ -679,6 +680,7 @@ class RedisSentinelInternal<
this.#maxCommandRediscovers = options.maxCommandRediscovers ?? 16;
this.#masterPoolSize = options.masterPoolSize ?? 1;
this.#replicaPoolSize = options.replicaPoolSize ?? 0;
this.#nodeAddressMap = options.nodeAddressMap;
this.#scanInterval = options.scanInterval ?? 0;
this.#passthroughClientErrorEvents = options.passthroughClientErrorEvents ?? false;

Expand Down Expand Up @@ -716,16 +718,30 @@ class RedisSentinelInternal<
);
}

#getNodeAddress(address: string): RedisNode | undefined {
switch (typeof this.#nodeAddressMap) {
case 'object':
return this.#nodeAddressMap[address];

case 'function':
return this.#nodeAddressMap(address);
}
}

#createClient(node: RedisNode, clientOptions: RedisClientOptions, reconnectStrategy?: false) {
const address = `${node.host}:${node.port}`;
const socket =
this.#getNodeAddress(address) ??
{ host: node.host, port: node.port };
return RedisClient.create({
//first take the globally set RESP
RESP: this.#RESP,
//then take the client options, which can in theory overwrite it
...clientOptions,
socket: {
...clientOptions.socket,
host: node.host,
port: node.port,
host: socket.host,
port: socket.port,
...(reconnectStrategy !== undefined && { reconnectStrategy })
}
});
Expand Down Expand Up @@ -1426,6 +1442,16 @@ export class RedisSentinelFactory extends EventEmitter {
this.#sentinelRootNodes = options.sentinelRootNodes;
}

#getNodeAddress(address: string): RedisNode | undefined {
switch (typeof this.options.nodeAddressMap) {
case 'object':
return this.options.nodeAddressMap[address];

case 'function':
return this.options.nodeAddressMap(address);
}
}

async updateSentinelRootNodes() {
for (const node of this.#sentinelRootNodes) {
const client = RedisClient.create({
Expand Down Expand Up @@ -1508,12 +1534,16 @@ export class RedisSentinelFactory extends EventEmitter {

async getMasterClient() {
const master = await this.getMasterNode();
const address = `${master.host}:${master.port}`;
const socket =
this.#getNodeAddress(address) ??
{ host: master.host, port: master.port };
return RedisClient.create({
...this.options.nodeClientOptions,
socket: {
...this.options.nodeClientOptions?.socket,
host: master.host,
port: master.port
host: socket.host,
port: socket.port
}
});
}
Expand Down Expand Up @@ -1576,12 +1606,17 @@ export class RedisSentinelFactory extends EventEmitter {
this.#replicaIdx = 0;
}

const replica = replicas[this.#replicaIdx];
const address = `${replica.host}:${replica.port}`;
const socket =
this.#getNodeAddress(address) ??
{ host: replica.host, port: replica.port };
return RedisClient.create({
...this.options.nodeClientOptions,
socket: {
...this.options.nodeClientOptions?.socket,
host: replicas[this.#replicaIdx].host,
port: replicas[this.#replicaIdx].port
host: socket.host,
port: socket.port
}
});
}
Expand Down
96 changes: 96 additions & 0 deletions packages/client/lib/sentinel/node-address-map.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import { NodeAddressMap } from './types';

describe('NodeAddressMap', () => {
describe('type checking', () => {
it('should accept object mapping', () => {
const map: NodeAddressMap = {
'10.0.0.1:6379': {
host: 'external-host.io',
port: 6379
}
};

assert.ok(map);
});

it('should accept function mapping', () => {
const map: NodeAddressMap = (address: string) => {
const [host, port] = address.split(':');
return {
host: `external-${host}.io`,
port: Number(port)
};
};

assert.ok(map);
});
});

describe('object mapping', () => {
it('should map addresses correctly', () => {
const map: NodeAddressMap = {
'10.0.0.1:6379': {
host: 'external-host.io',
port: 6379
},
'10.0.0.2:6379': {
host: 'external-host.io',
port: 6380
}
};

assert.deepEqual(map['10.0.0.1:6379'], {
host: 'external-host.io',
port: 6379
});

assert.deepEqual(map['10.0.0.2:6379'], {
host: 'external-host.io',
port: 6380
});
});
});

describe('function mapping', () => {
it('should map addresses dynamically', () => {
const map: NodeAddressMap = (address: string) => {
const [host, port] = address.split(':');
return {
host: `external-${host}.io`,
port: Number(port)
};
};

const result1 = map('10.0.0.1:6379');
assert.deepEqual(result1, {
host: 'external-10.0.0.1.io',
port: 6379
});

const result2 = map('10.0.0.2:6380');
assert.deepEqual(result2, {
host: 'external-10.0.0.2.io',
port: 6380
});
});

it('should return undefined for unmapped addresses', () => {
const map: NodeAddressMap = (address: string) => {
if (address.startsWith('10.0.0.')) {
const [host, port] = address.split(':');
return {
host: `external-${host}.io`,
port: Number(port)
};
}
return undefined;
};

const result = map('192.168.1.1:6379');
assert.equal(result, undefined);
});
});
});

11 changes: 10 additions & 1 deletion packages/client/lib/sentinel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export interface RedisNode {
port: number;
}

export type NodeAddressMap = {
[address: string]: RedisNode;
} | ((address: string) => RedisNode | undefined);

export interface RedisSentinelOptions<
M extends RedisModules = RedisModules,
F extends RedisFunctions = RedisFunctions,
Expand Down Expand Up @@ -49,10 +53,15 @@ export interface RedisSentinelOptions<
* When greater than 0, the client will distribute the load by executing read-only commands (such as `GET`, `GEOSEARCH`, etc.) across all the cluster nodes.
*/
replicaPoolSize?: number;
/**
* Mapping between the addresses returned by sentinel and the addresses the client should connect to
* Useful when the sentinel nodes are running on another network
*/
nodeAddressMap?: NodeAddressMap;
/**
* Interval in milliseconds to periodically scan for changes in the sentinel topology.
* The client will query the sentinel for changes at this interval.
*
*
* Default: 10000 (10 seconds)
*/
scanInterval?: number;
Expand Down