Skip to content

Commit b9c4793

Browse files
authored
feat: support customize dns lookup function (#723)
Since Redis cluster doesn't support hostname at all (redis/redis#2410), it's reasonable to resolve the hostnames to IPs before connecting.
1 parent 6d13c54 commit b9c4793

14 files changed

+286
-70
lines changed

lib/cluster/ClusterOptions.ts

+22-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import {NodeRole} from './util'
2+
import {lookup} from 'dns'
3+
4+
export type DNSLookupFunction = (hostname: string, callback: (err: NodeJS.ErrnoException, address: string, family: number) => void) => void
25

36
/**
47
* Options for Cluster constructor
@@ -93,9 +96,26 @@ export interface IClusterOptions {
9396
redisOptions?: any
9497

9598
/**
99+
* By default, When a new Cluster instance is created,
100+
* it will connect to the Redis cluster automatically.
101+
* If you want to keep the instance disconnected until the first command is called,
102+
* set this option to `true`.
103+
*
96104
* @default false
97105
*/
98106
lazyConnect?: boolean
107+
108+
/**
109+
* Hostnames will be resolved to IP addresses via this function.
110+
* This is needed when the addresses of startup nodes are hostnames instead
111+
* of IPs.
112+
*
113+
* You may provide a custom `lookup` function when you want to customize
114+
* the cache behavior of the default function.
115+
*
116+
* @default require('dns').lookup
117+
*/
118+
dnsLookup?: DNSLookupFunction
99119
}
100120

101121
export const DEFAULT_CLUSTER_OPTIONS: IClusterOptions = {
@@ -108,5 +128,6 @@ export const DEFAULT_CLUSTER_OPTIONS: IClusterOptions = {
108128
retryDelayOnClusterDown: 100,
109129
retryDelayOnTryAgain: 100,
110130
slotsRefreshTimeout: 1000,
111-
slotsRefreshInterval: 5000
131+
slotsRefreshInterval: 5000,
132+
dnsLookup: lookup
112133
}

lib/cluster/index.ts

+116-50
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,16 @@ import {EventEmitter} from 'events'
22
import ClusterAllFailedError from '../errors/ClusterAllFailedError'
33
import {defaults, noop} from '../utils/lodash'
44
import ConnectionPool from './ConnectionPool'
5-
import {NodeKey, IRedisOptions, normalizeNodeOptions, NodeRole} from './util'
5+
import {NodeKey, IRedisOptions, normalizeNodeOptions, NodeRole, getUniqueHostnamesFromOptions} from './util'
66
import ClusterSubscriber from './ClusterSubscriber'
77
import DelayQueue from './DelayQueue'
88
import ScanStream from '../ScanStream'
9-
import {AbortError} from 'redis-errors'
9+
import {AbortError, RedisError} from 'redis-errors'
1010
import * as asCallback from 'standard-as-callback'
1111
import * as PromiseContainer from '../promiseContainer'
1212
import {CallbackFunction} from '../types';
1313
import {IClusterOptions, DEFAULT_CLUSTER_OPTIONS} from './ClusterOptions'
14-
import {sample, CONNECTION_CLOSED_ERROR_MSG, shuffle, timeout} from '../utils'
14+
import {sample, CONNECTION_CLOSED_ERROR_MSG, shuffle, timeout, zipMap} from '../utils'
1515
import * as commands from 'redis-commands'
1616

1717
const Deque = require('denque')
@@ -30,7 +30,7 @@ type ClusterStatus = 'end' | 'close' | 'wait' | 'connecting' | 'connect' | 'read
3030
*/
3131
class Cluster extends EventEmitter {
3232
private options: IClusterOptions
33-
private startupNodes: IRedisOptions[]
33+
private startupNodes: Array<string | number | object>
3434
private connectionPool: ConnectionPool
3535
private slots: Array<NodeKey[]> = []
3636
private manuallyClosing: boolean
@@ -43,6 +43,18 @@ class Cluster extends EventEmitter {
4343
private status: ClusterStatus
4444
private isRefreshing: boolean = false
4545

46+
/**
47+
* Every time Cluster#connect() is called, this value will be
48+
* auto-incrementing. The purpose of this value is used for
49+
* discarding previous connect attampts when creating a new
50+
* connection.
51+
*
52+
* @private
53+
* @type {number}
54+
* @memberof Cluster
55+
*/
56+
private connectionEpoch: number = 0
57+
4658
/**
4759
* Creates an instance of Cluster.
4860
*
@@ -54,7 +66,7 @@ class Cluster extends EventEmitter {
5466
super()
5567
Commander.call(this)
5668

57-
this.startupNodes = normalizeNodeOptions(startupNodes)
69+
this.startupNodes = startupNodes
5870
this.options = defaults(this.options, options, DEFAULT_CLUSTER_OPTIONS)
5971

6072
// validate options
@@ -117,59 +129,68 @@ class Cluster extends EventEmitter {
117129
reject(new Error('Redis is already connecting/connected'))
118130
return
119131
}
132+
const epoch = ++this.connectionEpoch
120133
this.setStatus('connecting')
121134

122-
if (!Array.isArray(this.startupNodes) || this.startupNodes.length === 0) {
123-
throw new Error('`startupNodes` should contain at least one node.')
124-
}
125-
126-
this.connectionPool.reset(this.startupNodes)
127-
128-
function readyHandler() {
129-
this.setStatus('ready')
130-
this.retryAttempts = 0
131-
this.executeOfflineCommands()
132-
this.resetNodesRefreshInterval()
133-
resolve()
134-
}
135+
this.resolveStartupNodeHostnames().then((nodes) => {
136+
if (this.connectionEpoch !== epoch) {
137+
debug('discard connecting after resolving startup nodes because epoch not match: %d != %d', epoch, this.connectionEpoch)
138+
reject(new RedisError('Connection is discarded because a new connection is made'))
139+
return
140+
}
141+
if (this.status !== 'connecting') {
142+
debug('discard connecting after resolving startup nodes because the status changed to %s', this.status)
143+
reject(new RedisError('Connection is aborted'))
144+
return
145+
}
146+
this.connectionPool.reset(nodes)
147+
148+
function readyHandler() {
149+
this.setStatus('ready')
150+
this.retryAttempts = 0
151+
this.executeOfflineCommands()
152+
this.resetNodesRefreshInterval()
153+
resolve()
154+
}
135155

136-
let closeListener: () => void
137-
const refreshListener = () => {
138-
this.removeListener('close', closeListener)
139-
this.manuallyClosing = false
140-
this.setStatus('connect')
141-
if (this.options.enableReadyCheck) {
142-
this.readyCheck((err, fail) => {
143-
if (err || fail) {
144-
debug('Ready check failed (%s). Reconnecting...', err || fail)
145-
if (this.status === 'connect') {
146-
this.disconnect(true)
156+
let closeListener: () => void
157+
const refreshListener = () => {
158+
this.removeListener('close', closeListener)
159+
this.manuallyClosing = false
160+
this.setStatus('connect')
161+
if (this.options.enableReadyCheck) {
162+
this.readyCheck((err, fail) => {
163+
if (err || fail) {
164+
debug('Ready check failed (%s). Reconnecting...', err || fail)
165+
if (this.status === 'connect') {
166+
this.disconnect(true)
167+
}
168+
} else {
169+
readyHandler.call(this)
147170
}
148-
} else {
149-
readyHandler.call(this)
150-
}
151-
})
152-
} else {
153-
readyHandler.call(this)
171+
})
172+
} else {
173+
readyHandler.call(this)
174+
}
154175
}
155-
}
156176

157-
closeListener = function () {
158-
this.removeListener('refresh', refreshListener)
159-
reject(new Error('None of startup nodes is available'))
160-
}
177+
closeListener = function () {
178+
this.removeListener('refresh', refreshListener)
179+
reject(new Error('None of startup nodes is available'))
180+
}
161181

162-
this.once('refresh', refreshListener)
163-
this.once('close', closeListener)
164-
this.once('close', this.handleCloseEvent.bind(this))
182+
this.once('refresh', refreshListener)
183+
this.once('close', closeListener)
184+
this.once('close', this.handleCloseEvent.bind(this))
165185

166-
this.refreshSlotsCache(function (err) {
167-
if (err && err.message === 'Failed to refresh slots cache.') {
168-
Redis.prototype.silentEmit.call(this, 'error', err)
169-
this.connectionPool.reset([])
170-
}
171-
}.bind(this))
172-
this.subscriber.start()
186+
this.refreshSlotsCache(function (err) {
187+
if (err && err.message === 'Failed to refresh slots cache.') {
188+
Redis.prototype.silentEmit.call(this, 'error', err)
189+
this.connectionPool.reset([])
190+
}
191+
}.bind(this))
192+
this.subscriber.start()
193+
}).catch(reject)
173194
})
174195
}
175196

@@ -639,6 +660,51 @@ class Cluster extends EventEmitter {
639660
}
640661
})
641662
}
663+
664+
private dnsLookup (hostname: string): Promise<string> {
665+
return new Promise((resolve, reject) => {
666+
this.options.dnsLookup(hostname, (err, address) => {
667+
if (err) {
668+
debug('failed to resolve hostname %s to IP: %s', hostname, err.message)
669+
reject(err)
670+
} else {
671+
debug('resolved hostname %s to IP %s', hostname, address)
672+
resolve(address)
673+
}
674+
})
675+
});
676+
}
677+
678+
/**
679+
* Normalize startup nodes, and resolving hostnames to IPs.
680+
*
681+
* This process happens every time when #connect() is called since
682+
* #startupNodes and DNS records may chanage.
683+
*
684+
* @private
685+
* @returns {Promise<IRedisOptions[]>}
686+
*/
687+
private resolveStartupNodeHostnames(): Promise<IRedisOptions[]> {
688+
if (!Array.isArray(this.startupNodes) || this.startupNodes.length === 0) {
689+
return Promise.reject(new Error('`startupNodes` should contain at least one node.'))
690+
}
691+
const startupNodes = normalizeNodeOptions(this.startupNodes)
692+
693+
const hostnames = getUniqueHostnamesFromOptions(startupNodes)
694+
if (hostnames.length === 0) {
695+
return Promise.resolve(startupNodes)
696+
}
697+
698+
return Promise.all(hostnames.map((hostname) => this.dnsLookup(hostname))).then((ips) => {
699+
const hostnameToIP = zipMap(hostnames, ips)
700+
701+
return startupNodes.map((node) => (
702+
hostnameToIP.has(node.host)
703+
? Object.assign({}, node, {host: hostnameToIP.get(node.host)})
704+
: node
705+
))
706+
})
707+
}
642708
}
643709

644710
Object.getOwnPropertyNames(Commander.prototype).forEach(name => {

lib/cluster/util.ts

+14-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import {parseURL} from '../utils'
2+
import {isIP} from 'net'
3+
import {DNSLookupFunction} from './ClusterOptions';
24

35
export type NodeKey = string
46
export type NodeRole = 'master' | 'slave' | 'all'
57

68
export interface IRedisOptions {
79
port: number,
8-
host: string
10+
host: string,
11+
password?: string,
12+
[key: string]: any
913
}
1014

1115
export function getNodeKey(node: IRedisOptions): NodeKey {
@@ -43,3 +47,12 @@ export function normalizeNodeOptions(nodes: Array<string | number | object>): IR
4347
return options
4448
})
4549
}
50+
51+
export function getUniqueHostnamesFromOptions (nodes: IRedisOptions[]): string[] {
52+
const uniqueHostsMap = {}
53+
nodes.forEach((node) => {
54+
uniqueHostsMap[node.host] = true
55+
})
56+
57+
return Object.keys(uniqueHostsMap).filter(host => !isIP(host))
58+
}

lib/types.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export type CallbackFunction<T = void> = (err?: Error | null, result?: T) => void
1+
export type CallbackFunction<T = void> = (err?: NodeJS.ErrnoException | null, result?: T) => void

lib/utils/index.ts

+8
Original file line numberDiff line numberDiff line change
@@ -325,3 +325,11 @@ export function shuffle<T> (array: T[]): T[] {
325325
* Error message for connection being disconnected
326326
*/
327327
export const CONNECTION_CLOSED_ERROR_MSG = 'Connection is closed.'
328+
329+
export function zipMap<K, V> (keys: K[], values: V[]): Map<K, V> {
330+
const map = new Map<K, V>()
331+
keys.forEach((key, index) => {
332+
map.set(key, values[index])
333+
})
334+
return map
335+
}

test/functional/cluster/connect.js

+9
Original file line numberDiff line numberDiff line change
@@ -353,4 +353,13 @@ describe('cluster:connect', function () {
353353
{ host: '127.0.0.1', port: '30001' }
354354
], { slotsRefreshInterval: 100, redisOptions: { lazyConnect: false } });
355355
});
356+
357+
it('throws when startupNodes is empty', (done) => {
358+
const cluster = new Redis.Cluster(null, {lazyConnect: true})
359+
cluster.connect().catch(err => {
360+
expect(err.message).to.eql('`startupNodes` should contain at least one node.')
361+
cluster.disconnect()
362+
done()
363+
})
364+
})
356365
});

test/functional/cluster/dnsLookup.js

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
describe('cluster:dnsLookup', () => {
2+
it('resolve hostnames to IPs', (done) => {
3+
const slotTable = [
4+
[0, 1000, ['127.0.0.1', 30001]],
5+
[1001, 16383, ['127.0.0.1', 30002]]
6+
]
7+
new MockServer(30001, (argv, c) => {
8+
}, slotTable)
9+
new MockServer(30002, (argv, c) => {
10+
}, slotTable)
11+
12+
const cluster = new Redis.Cluster([
13+
{ host: 'localhost', port: '30001' }
14+
])
15+
cluster.on('ready', () => {
16+
const nodes = cluster.nodes('master')
17+
expect(nodes.length).to.eql(2)
18+
expect(nodes[0].options.host).to.eql('127.0.0.1')
19+
expect(nodes[1].options.host).to.eql('127.0.0.1')
20+
cluster.disconnect()
21+
done()
22+
})
23+
})
24+
25+
it('support customize dnsLookup function', (done) => {
26+
let dnsLookupCalledCount = 0
27+
const slotTable = [
28+
[0, 1000, ['127.0.0.1', 30001]],
29+
[1001, 16383, ['127.0.0.1', 30002]]
30+
]
31+
new MockServer(30001, (argv, c) => {
32+
}, slotTable)
33+
new MockServer(30002, (argv, c) => {
34+
}, slotTable)
35+
36+
const cluster = new Redis.Cluster([
37+
{ host: 'a.com', port: '30001' }
38+
], {
39+
dnsLookup (hostname, callback) {
40+
dnsLookupCalledCount += 1
41+
if (hostname === 'a.com') {
42+
callback(null, '127.0.0.1')
43+
} else {
44+
callback(new Error('Unknown hostname'))
45+
}
46+
}
47+
})
48+
cluster.on('ready', () => {
49+
const nodes = cluster.nodes('master')
50+
expect(nodes.length).to.eql(2)
51+
expect(nodes[0].options.host).to.eql('127.0.0.1')
52+
expect(nodes[1].options.host).to.eql('127.0.0.1')
53+
expect(dnsLookupCalledCount).to.eql(1)
54+
cluster.disconnect()
55+
done()
56+
})
57+
})
58+
})

test/functional/cluster/quit.js

+2
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ describe('cluster:quit', () => {
2323
cluster.quit((err, res) => {
2424
expect(err).to.eql(null)
2525
expect(res).to.eql('OK')
26+
cluster.disconnect()
2627
done()
2728
})
2829
})
@@ -51,6 +52,7 @@ describe('cluster:quit', () => {
5152
cluster.on('ready', () => {
5253
cluster.quit((err) => {
5354
expect(err.message).to.eql(ERROR_MESSAGE)
55+
cluster.disconnect()
5456
done()
5557
})
5658
})

0 commit comments

Comments
 (0)