diff --git a/docs/sentinel.md b/docs/sentinel.md index f10b2953df5..9f1c62d1d3e 100644 --- a/docs/sentinel.md +++ b/docs/sentinel.md @@ -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 | @@ -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. | diff --git a/packages/client/lib/sentinel/index.spec.ts b/packages/client/lib/sentinel/index.spec.ts index ef1702eab13..c3b3de4f5c7 100644 --- a/packages/client/lib/sentinel/index.spec.ts +++ b/packages/client/lib/sentinel/index.spec.ts @@ -65,6 +65,43 @@ describe('RedisSentinel', () => { }) }); + + describe('nodeAddressMap', () => { + testUtils.testWithClientSentinel('should apply object mapping', async sentinel => { + await sentinel.set('key', 'value'); + assert.equal(await sentinel.get('key'), 'value'); + }, { + ...GLOBAL.SENTINEL.OPEN, + sentinelOptions: { + nodeAddressMap: { + '127.0.0.1:6379': { host: '127.0.0.1', port: 6379 } + } + } + }); + + testUtils.testWithClientSentinel('should apply function mapping', async sentinel => { + await sentinel.set('key', 'value'); + assert.equal(await sentinel.get('key'), 'value'); + }, { + ...GLOBAL.SENTINEL.OPEN, + sentinelOptions: { + nodeAddressMap: (address: string) => { + const [host, port] = address.split(':'); + return { host, port: Number(port) }; + } + } + }); + + testUtils.testWithClientSentinel('should fall back to original address when function returns undefined', async sentinel => { + await sentinel.set('key', 'value'); + assert.equal(await sentinel.get('key'), 'value'); + }, { + ...GLOBAL.SENTINEL.OPEN, + sentinelOptions: { + nodeAddressMap: () => undefined + } + }); + }); }); }); diff --git a/packages/client/lib/sentinel/index.ts b/packages/client/lib/sentinel/index.ts index a9a2b9a5e5d..44a3ecf56f6 100644 --- a/packages/client/lib/sentinel/index.ts +++ b/packages/client/lib/sentinel/index.ts @@ -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'; @@ -623,6 +623,7 @@ class RedisSentinelInternal< readonly #name: string; readonly #nodeClientOptions: RedisClientOptions; readonly #sentinelClientOptions: RedisClientOptions; + readonly #nodeAddressMap?: NodeAddressMap; readonly #scanInterval: number; readonly #passthroughClientErrorEvents: boolean; readonly #RESP?: RespVersions; @@ -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; @@ -716,7 +718,21 @@ 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, @@ -724,8 +740,8 @@ class RedisSentinelInternal< ...clientOptions, socket: { ...clientOptions.socket, - host: node.host, - port: node.port, + host: socket.host, + port: socket.port, ...(reconnectStrategy !== undefined && { reconnectStrategy }) } }); @@ -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({ @@ -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 } }); } @@ -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 } }); } diff --git a/packages/client/lib/sentinel/node-address-map.spec.ts b/packages/client/lib/sentinel/node-address-map.spec.ts new file mode 100644 index 00000000000..c9ef070753f --- /dev/null +++ b/packages/client/lib/sentinel/node-address-map.spec.ts @@ -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); + }); + }); +}); + diff --git a/packages/client/lib/sentinel/types.ts b/packages/client/lib/sentinel/types.ts index e72f2eec2a0..3e5327004f4 100644 --- a/packages/client/lib/sentinel/types.ts +++ b/packages/client/lib/sentinel/types.ts @@ -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, @@ -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;