@segment/redis-clustr
Version:
Redis cluster client
826 lines (688 loc) • 23.3 kB
JavaScript
;
var setupCommands = require('./setupCommands');
var calculateSlot = require('cluster-key-slot');
var redis = require('redis');
var RedisBatch = require('./RedisBatch');
var Events = require('events').EventEmitter;
var util = require('util');
var Queue = require('denque');
var RedisClustr = module.exports = function(config) {
var self = this;
Events.call(self);
// handle just an array of clients
if (Array.isArray(config)) {
config = {
servers: config
};
}
self.config = config;
self.slots = [];
self.connections = {};
self.subscriptions = null;
self.ready = false;
self.connected = false;
for (var i = 0; i < config.servers.length; i++) {
var c = config.servers[i];
self.getClient(c.port, c.host);
}
// fetch slots from the cluster immediately to ensure slots are correct
self.getSlots();
// ability to update slots on an interval (should be unnecessary)
if (config.slotInterval) self._slotInterval = setInterval(self.getSlots.bind(self), config.slotInterval);
};
util.inherits(RedisClustr, Events);
RedisClustr.prototype.createClient = function(port, host) {
var self = this;
var createClient = self.config.createClient || redis.createClient;
var cli = createClient(port, host, self.config.redisOptions);
return cli;
};
/**
* Get a Redis client via the connection cache (one per host)
* @date 2015-02-14
* @param {number} port Port to connect to
* @param {string} host Host to connect to
* @param {boolean} master Whether this client is a master or not (a slave)
* @return {Redis} The Redis client
*/
RedisClustr.prototype.getClient = function(port, host, master) {
var self = this;
var name = host + ':' + port;
var cli = self.connections[name];
// already have a connection to this client, return that
if (cli) {
cli.master = master;
return cli;
}
cli = self.createClient(port, host);
cli.master = master;
cli.on('error', function(err) {
if (
err.code === 'CONNECTION_BROKEN' ||
err.code === 'UNCERTAIN_STATE' ||
err.code === 'NR_CLOSED' ||
/Redis connection to .* failed.*/.test(err.message)
) {
// broken/closed connection so force a new client to be created (node_redis should reconnect other errors)
if (err.code === 'CONNECTION_BROKEN' || err.code === 'NR_CLOSED') self.connections[name] = null;
self.emit('connectionError', err, cli);
self.getSlots();
return;
}
// re-emit the error ourselves
self.emit('error', err, cli);
});
// as soon as one client is ready, we're connected (ready to fetch slot allocation)
cli.on('ready', function() {
if (!self.connected) {
self.connected = true;
self.emit('connect');
}
});
cli.on('end', function() {
var wasConnected = self.connected;
self.connected = Object.keys(self.connections).some(function(c) {
return self.connections[c] && self.connections[c].ready;
});
if (!self.connected && wasConnected) self.emit('disconnect');
// set connection to null so we create a new client if we want to reconnect
if (cli.closing) self.connections[name] = null;
// setImmediate as node_redis sets emitted_end after emitting end
setImmediate(function() {
var wasEnded = self.ended;
self.ended = Object.keys(self.connections).every(function(c) {
var cc = self.connections[c];
return !cc || (!cc.connected && cc.emitted_end);
});
if (self.ended && !wasEnded) self.emit('end');
});
});
self.connections[name] = cli;
return cli;
};
/**
* Get a random Redis connection
* @date 2015-02-18
* @param {array} exclude List of addresses to exclude (falsy to ignore none)
* @param {boolean} forceSlaves Include slaves, regardless of configuration
* @return {Redis} A random, ready, Redis connection.
*/
RedisClustr.prototype.getRandomConnection = function(exclude, forceSlaves) {
var self = this;
var masterOnly = !forceSlaves && self.config.slaves === 'never';
var available = Object.keys(self.connections).filter(function(f) {
var con = self.connections[f];
return con &&
con.ready &&
(!exclude || exclude.indexOf(f) === -1) &&
(!masterOnly || con.master);
});
var randomIndex = Math.floor(Math.random() * available.length);
return self.connections[available[randomIndex]];
};
/**
* Get the cluster slot allocation
* @date 2015-02-14
* @param {Function} cb
*/
RedisClustr.prototype.getSlots = function(cb) {
var self = this;
var q = self._slotQ;
if (q) {
if (!cb) return;
if (self.config.maxQueueLength && q.length >= self.config.maxQueueLength) {
var err = new Error('max slot queue length reached');
if (self.config.queueShift !== false) {
// shift the earliest queue item off and give it an error
q.shift()(err);
} else {
// send this callback the error instead
return cb(err);
}
}
return q.push(cb);
}
self._slotQ = q = new Queue();
if (cb) q.push(cb);
var runCbs = function(err, slots) {
if (!self._slotQ) return
var cb;
while ((cb = self._slotQ.shift())) {
cb(err, slots);
}
self._slotQ = false;
};
var exclude = [];
var tryErrors = null;
var tryClient = function() {
if (self.quitting) return runCbs(new Error('cluster is quitting'));
var client = self.getRandomConnection(exclude, true);
if (!client) {
var err = new Error('couldn\'t get slot allocation');
err.errors = tryErrors;
return runCbs(err);
}
client.cluster('slots', function(err, slots) {
if (!err && slots.length === 0) {
err = new Error('no slots found, cluster is not in ok state yet')
}
if (err) {
// exclude this client from then next attempt
exclude.push(client.address);
if (!tryErrors) tryErrors = [];
tryErrors.push(err);
return tryClient();
}
if (self.quitting) return runCbs(new Error('cluster is quitting'));
const [slotMap, seenClients] = self._buildSlotMap(slots)
self.slots = slotMap
// quit now-unused clients
for (var i in self.connections) {
if (!self.connections[i]) continue;
if (seenClients.indexOf(i) === -1) {
self.connections[i].quit();
self.connections[i] = null;
}
}
if (!self.ready) {
self.ready = true;
self.emit('ready');
}
if (self.fullReady) {
runCbs(null, self.slots);
} else {
self._waitUntilAllReady(seenClients, function() {
self.fullReady = true;
self.emit('fullReady');
runCbs(null, self.slots);
})
}
});
};
self.waitFor('connect', self.connected, tryClient);
};
/**
* Given a raw CLUSTER SLOTS response, return a tuple with an array that maps
* all slots to the client that owns it and a list of all seen clients
* (host:port string).
* @date 2020-09-23
* @param {array} slots Raw CLUSTER SLOTS response from Redis.
* @return {array} Tuple of slot clients array and seen clients array.
*/
RedisClustr.prototype._buildSlotMap = function(slots) {
const self = this;
const slotList = [];
const seenClients = new Set();
for (var i = 0; i < slots.length; i++) {
var s = slots[i];
var start = s[0];
var end = s[1];
// array of all clients, clients[0] = master, others are slaves
var clients = s.slice(2).map(function(c, index) {
var name = c[0] + ':' + c[1];
seenClients.add(name);
return self.getClient(c[1], c[0], index === 0);
});
for (var j = start; j <= end; j++) {
slotList[j] = clients;
}
}
return [slotList, Array.from(seenClients)]
}
/**
* Call the given callback when all of the given Redis connections have reached
* the 'ready' state.
* @date 2020-09-23
* @param {array} clients Clients to wait for, as "host:port" strings.
* @param {function} cb Callback to call when all given clients are
* ready.
*/
RedisClustr.prototype._waitUntilAllReady = function(clients, cb) {
const self = this;
var ready = 0;
for (var i = 0; i < clients.length; i++) {
var c = self.connections[clients[i]];
if (c.ready) {
if (++ready === clients.length) cb();
continue;
}
c.once('ready', function() {
if (++ready === clients.length) cb();
});
}
}
/**
* Select a Redis client for the given key and conf
* @date 2015-11-23
* @param {string} key The Redis key (can also be an Array or Buffer)
* @param {object} conf Configuration relating to the command (e.g. if it's readOnly)
* @return {Redis} A Redis client
*/
RedisClustr.prototype.selectClient = function(key, conf) {
var self = this;
// this command doesnt have keys, return any connection
if (conf.keyless) return self.getRandomConnection();
if (Array.isArray(key)) key = key[0];
if (Buffer.isBuffer(key)) key = key.toString();
var slot = calculateSlot(key);
var clients = self.slots[slot];
// if we haven't got config for this slot, try any connection
if (!clients || !clients.length) return self.getRandomConnection();
var index = 0;
// always, never, share
if (conf.readOnly && self.config.slaves && self.config.slaves !== 'never' && clients.length > 1) {
// always use a slave for read commands
if (self.config.slaves === 'always') {
index = Math.floor(Math.random() * (clients.length - 1)) + 1;
}
// share read commands across master + slaves
if (self.config.slaves === 'share') {
index = Math.floor(Math.random() * clients.length);
}
}
var cli = clients[index];
if (!cli.ready) {
self.getSlots();
// this could be improved to select another slave
return self.getRandomConnection();
}
if (index === 0 && cli.readOnly) {
cli.send_command('readwrite', []);
cli.readOnly = false;
}
if (index > 0 && !cli.readOnly) {
cli.send_command('readonly', []);
cli.readOnly = true;
}
return cli;
};
/**
* Take arguments and convert them to an array of Redis command args and a callback
* @date 2015-11-23
* @param {array} args Arguments which can be in a few different formats
* @param {Function} [cb] Callback function so we can wait for the slot allocation
* @return {array} The parsed arguments and the callback function
*/
RedisClustr.prototype.parseArgs = function(args, cb) {
var self = this;
var commandCB = function(err) {
if (err) self.emit('error', err);
};
var argsCb = typeof args[args.length - 1] === 'function';
if (argsCb) {
commandCB = args[args.length - 1];
}
if (!self.slots.length && cb) {
self.getSlots(function(err) {
if (err) return commandCB(err);
self.parseArgs(args, cb);
});
return;
}
// now take cb off args so we can attach our own callback wrapper
if (argsCb) args = args.slice(0, -1);
if (Array.isArray(args[0])) {
args = args[0];
}
if (cb) cb(null, args, commandCB);
return [ args, commandCB ];
};
/**
* Handle Redis commands
* @date 2013-11-23
* @param {string} cmd The Redis command (e.g. set)
* @param {object} conf Configuration related to this command (e.g. whether the key is readOnly)
* @param {array} args Arguments to be passed to the command (including commandCallback)
*/
RedisClustr.prototype.doCommand = function(cmd, conf, args) {
var self = this;
self.parseArgs(args, function(_, args, cb) {
var key = args[0];
if (!key && !conf.keyless) return cb(new Error('no key for command: ' + cmd));
var r = self.selectClient(key, conf);
if (!r) return cb(new Error('couldn\'t get client'));
if (!r[cmd]) return cb(new Error('NodeRedis doesn\'t know the ' + cmd + ' command'));
self.commandCallback(r, cmd, args, cb);
r[cmd].apply(r, args);
});
};
/**
* Handle Redis commands that may contain multiple keys (and therefore need splitting across slots)
* @date 2013-11-23
* @param {string} cmd The Redis command (e.g. mset)
* @param {object} conf Configuration related to this command (e.g. the key interval)
* @param {array} args Arguments to be passed to the command (including commandCallback)
*/
RedisClustr.prototype.doMultiKeyCommand = function(cmd, conf, origArgs) {
var self = this;
self.parseArgs(origArgs, function(_, args, cb) {
// already split into an individual command
if (args.length === conf.interval) {
return self.doCommand(cmd, conf, origArgs);
}
// batch the multi-key command into individual ones
var b = self.batch();
for (var i = 0; i < args.length; i += conf.interval) {
b[cmd].apply(b, args.slice(i, i + conf.interval));
}
b.exec(function(err, resp) {
if (resp) resp = conf.group(resp);
cb(err, resp);
});
});
};
/**
* Adds a custom callback to command args so cluster errors can be properly handled
* @date 2015-11-14
* @param {Redis} cli A Redis client
* @param {string} cmd The Redis command (e.g. get)
* @param {array} args Arguments to be passed to the command (and to have our callback added to)
* @param {Function} cb The main callback to wrap around
*/
RedisClustr.prototype.commandCallback = function(cli, cmd, args, cb) {
var self = this;
// number of attempts/redirects when we get connection errors
// or when we get MOVED/ASK responses
// https://github.com/antirez/redis-rb-cluster/blob/fd931ed34dfc53159e2f52c9ea2d4a5073faabeb/cluster.rb#L29
var retries = 16;
args.push(function(err, resp) {
if (err && err.message && retries--) {
var msg = err.message;
var ask = msg.substr(0, 4) === 'ASK ';
var moved = !ask && msg.substr(0, 6) === 'MOVED ';
if (moved || ask) {
// key has been moved!
// lets refetch slots from redis to get an up to date allocation
if (moved) self.getSlots();
// REQUERY THE NEW ONE (we've got the correct details)
var addr = err.message.split(' ')[2];
var saddr = addr.split(':');
var c = self.getClient(saddr[1], saddr[0], true);
if (ask) c.send_command('asking', []);
c[cmd].apply(c, args);
return;
}
if (err.code === 'CLUSTERDOWN' || msg.substr(0, 8) === 'TRYAGAIN') {
// TRYAGAIN response or cluster down, retry with backoff up to 1280ms
setTimeout(function() {
cli[cmd].apply(cli, args);
}, Math.pow(2, 16 - Math.max(retries, 9)) * 10);
return;
}
}
cb(err, resp);
});
};
/**
* Run a command on all master nodes
* @date 2015-11-23
* @param {string} cmd The Redis command (e.g. script)
* @param {array} args Arguments to be passed to the command
* @param {Function} cb
* @example
* redis.onMasters('script', [ 'load', 'return redis.call("get", "a-key")' ], function(err) {});
*/
RedisClustr.prototype.onMasters = function(cmd, args, cb) {
var self = this;
if (!self.slots.length) {
self.getSlots(function(err) {
if (err) return cb(err);
self.onMasters(cmd, args, cb);
});
return;
}
var todo = 0;
var errs = null;
var fullResp = [];
var isDone = function(err, resp) {
if (err) {
if (!errs) errs = [];
errs.push(err);
}
fullResp.push(resp);
if (!--todo) {
for (var i = 1; i < fullResp.length; i++) {
// if we've got different responses, callback with the full array
if (fullResp[i] !== fullResp[0]) return cb(errs, fullResp);
}
// callback with the first response if they're all the same
cb(errs, fullResp[0]);
}
};
for (var i in self.connections) {
var cli = self.connections[i];
if (!cli || !cli.master) continue;
todo++;
cli.send_command(cmd, args, isDone);
}
};
/**
* Wait for an event, or call back immediately if it's already been fired
* @date 2015-11-23
* @param {string} evt The event to wait for
* @param {boolean} [already=self[evt]] The property that indicates if the event has already been fired
* @param {Function} cb
* @example
* redis.waitFor('ready', function(err) { });
* @example
* redis.waitFor('connect', redis.connected, function(err) {}) ;
*/
RedisClustr.prototype.waitFor = function(evt, already, cb) {
var self = this;
if (!cb && typeof already === 'function') {
cb = already;
already = self[evt];
}
if (self.quitting) return cb(new Error('cluster is quitting'));
if (already) return cb();
var waitTimeout;
var done = function() {
if (waitTimeout) clearTimeout(waitTimeout);
cb();
};
self.once(evt, done);
// don't set a timeout (wait indefinitely for connection)
if (!self.config.wait) return;
waitTimeout = setTimeout(function() {
self.removeListener(evt, done);
cb(new Error('ready timeout reached'));
}, self.config.wait);
};
/**
* Create/recreate a subscription client and resubscribe to all pub/sub channels
* @date 2015-11-23
* @return {Redis} A Redis client which can be used to subscribe
*/
RedisClustr.prototype.subscribeAll = function(exclude) {
var self = this;
if (self.quitting) return;
if (self.subscribeClient) {
self.subscribeClient.removeAllListeners();
self.subscribeClient.quit(function() {
// ignore errors
});
self.subscribeClient = null;
}
var con = self.getRandomConnection(exclude);
if (!con) {
if (!self.ready) self.wait('ready', self.subscribeAll.bind(self, exclude));
return false;
}
// duplicate the random connection and make that our subscriber client
var cli = self.subscribeClient = self.createClient(con.connection_options.port, con.connection_options.host);
cli.on('error', function(err) {
if (
err.code === 'CONNECTION_BROKEN' ||
err.code === 'UNCERTAIN_STATE' ||
err.code === 'NR_CLOSED' ||
/Redis connection to .* failed.*/.test(err.message)
) {
self.emit('connectionError', err, cli);
// immediately try to re-subscribe
self.subscribeAll([ cli.address ]);
return;
}
// re-emit the error ourselves
self.emit('error', err, cli);
});
cli.once('end', function() {
self.subscribeAll([ cli.address ]);
});
// bubble all messages for pubsub
var events = [
'message',
'pmessage',
'subscribe',
'unsubscribe',
'psubscribe',
'punsubscribe'
];
events.forEach(function(evt) {
cli.on(evt, function(a, b, c) {
self.emit(evt, a, b, c);
});
});
if (self.subscriptions) {
for (var cmd in self.subscriptions) {
cli[cmd](Object.keys(self.subscriptions[cmd]));
}
}
return cli;
};
setupCommands(RedisClustr);
/**
* Start a new batch/group of pipelined commands
* @date 2014-11-19
* @return {RedisBatch} A RedisBatch which has a very similar interface to redis/
*/
RedisClustr.prototype.batch = RedisClustr.prototype.multi = function(commands) {
var self = this;
var batch = new RedisBatch(self);
if (Array.isArray(commands) && commands.length > 0) {
commands.forEach(function (command) {
var args = [];
if (command.length > 1) {
args = command.slice(1);
}
batch[command[0]].apply(batch, args);
});
}
return batch;
};
/**
* Run script commands on all master connections (especially for script load etc)
* @date 2015-11-23
*/
RedisClustr.prototype.script = function() {
var self = this;
var args = new Array(arguments.length);
for (var i = 0; i < arguments.length; i++) {
args[i] = arguments[i];
}
self.parseArgs(args, function(_, args, cb) {
self.onMasters('script', args, cb);
});
};
/**
* Custom handling for eval and evalsha to try to select the
* correct node based on the given keys
* @date 2015-11-23
* @param {string} cmd The Redis command - eval or evalsha
* @param {array} args Arguments to be passed to the command
* @private
*/
RedisClustr.prototype._eval = function(cmd, args) {
var self = this;
self.parseArgs(args, function(_, args, cb) {
var numKeys = args[1];
var r;
if (!numKeys) {
r = self.getRandomConnection();
} else {
// select based on the first KEYS argument
// we *could* validate that all keys are together, but it's easier
// to allow redis to error instead
r = self.selectClient(args[2], {});
}
self.commandCallback(r, cmd, args, cb);
r[cmd].apply(r, args);
});
};
var overwriteFn = function(handler, fn) {
return function() {
var args = new Array(arguments.length);
for (var i = 0; i < arguments.length; i++) {
args[i] = arguments[i];
}
this[handler](fn, args);
return this;
};
};
RedisClustr.prototype.eval = overwriteFn('_eval', 'eval');
RedisClustr.prototype.evalsha = overwriteFn('_eval', 'evalsha');
/**
* Handle subscription commands, creating sub client if necessary and remembers what channels
* we're currently subscribed to
* @date 2015-11-23
* @param {string} cmd The subscription command (subscribe, unsubscribe...)
* @param {array} args Arguments to be passed to the command (list of channels) (including commandCallback)
* @private
*/
RedisClustr.prototype._subscribe = function(cmd, args) {
var self = this;
self.parseArgs(args, function(_, args, cb) {
var cli = self.subscribeClient;
if (!cli) cli = self.subscribeAll();
if (!cli) return cb(new Error('couldn\'t get subscriber client'));
var del = cmd === 'unsubscribe' || cmd === 'punsubscribe';
var key = cmd;
if (key === 'unsubscribe') key = 'subscribe';
if (key === 'punsubscribe') key = 'psubscribe';
if (!self.subscriptions) self.subscriptions = {};
if (!self.subscriptions[key]) self.subscriptions[key] = {};
// kill all subscriptions
if (del && !args.length) {
self.subscriptions[key] = {};
cli[cmd](cb);
return;
}
for (var i = 0; i < args.length; i++) {
if (del) {
delete self.subscriptions[key][args[i]];
} else {
self.subscriptions[key][args[i]] = true;
}
}
cli[cmd](args, cb);
});
};
RedisClustr.prototype.subscribe = overwriteFn('_subscribe', 'subscribe');
RedisClustr.prototype.psubscribe = overwriteFn('_subscribe', 'psubscribe');
RedisClustr.prototype.unsubscribe = overwriteFn('_subscribe', 'unsubscribe');
RedisClustr.prototype.punsubscribe = overwriteFn('_subscribe', 'punsubscribe');
/**
* Quit the Redis cluster, closing all underlying connections
* @date 2014-07-29
* @param {Function} cb
*/
RedisClustr.prototype.quit = function(cb) {
var self = this;
self.quitting = true;
if (self._slotInterval) clearInterval(self._slotInterval);
var todo = 0;
var errs = null;
var quitCb = function(err) {
if (err && !errs) errs = [];
if (err) errs.push(err);
if (!--todo && cb) cb(errs);
};
for (var i in self.connections) {
if (!self.connections[i]) continue;
todo++;
self.connections[i].quit(quitCb);
}
if (self.subscribeClient) {
todo++;
self.subscribeClient.quit(quitCb);
}
};