thywill
Version:
A Node.js clustered framework for single page web applications based on asynchronous messaging.
390 lines (353 loc) • 13.4 kB
JavaScript
/**
* @fileOverview
* RedisCluster class definition.
*/
var util = require('util');
var childProcess = require('child_process');
var path = require('path');
var async = require('async');
var Thywill = require('thywill');
//-----------------------------------------------------------
// Class Definition
//-----------------------------------------------------------
/**
* @class
* A Cluster implementation backed by Redis. Communication between cluster
* members is managed via Redis publish/subscribe mechanisms.
*
* The RedisCluster implementation spawns a separate process to run a heartbeat
* and check on other cluster member heartbeats via a publish/subscribe
* channel.
*
* The heartbeat runs in a separate process and channel to ensure that it runs
* on time, and is lagged as little as possible, thus allowed faster detection
* of cluster member process failures and restarts.
*
* @see Cluster
*/
function RedisCluster() {
RedisCluster.super_.call(this);
}
util.inherits(RedisCluster, Thywill.getBaseClass('Cluster'));
var p = RedisCluster.prototype;
//-----------------------------------------------------------
// 'Static' parameters
//-----------------------------------------------------------
RedisCluster.CONFIG_TEMPLATE = {
clusterMemberIds: {
_configInfo: {
description: 'The IDs of all the cluster members.',
types: 'array',
required: true
}
},
communication: {
_configInfo: {
description: 'Wrapper for configuration of communication between cluster members.',
types: 'object',
required: true
},
publishRedisClient: {
_configInfo: {
description: 'A Redis client instance from package "redis". This must not be the same instance as the subscribeRedisClient.',
types: 'object',
required: true
}
},
subscribeRedisClient: {
_configInfo: {
description: 'A Redis client instance from package "redis". This will be used for pub/sub subscriptions, so should not be reused elsewhere.',
types: 'object',
required: true
}
}
},
heartbeat: {
_configInfo: {
description: 'Wrapper for heartbeat configuration.',
types: 'object',
required: true
},
interval: {
_configInfo: {
description: 'Milliseconds between broadcast heartbeats for a cluster member.',
types: 'integer',
required: true
}
},
timeout: {
_configInfo: {
description: 'Milliseconds since the last received heartbeat when a cluster member is presumed dead.',
types: 'integer',
required: true
}
}
},
localClusterMemberId: {
_configInfo: {
description: 'The cluster member ID for this process.',
types: 'string',
required: true
}
},
redisPrefix: {
_configInfo: {
description: 'A prefix used for channels and keys in Redis.',
types: 'string',
required: true
}
}
};
//-----------------------------------------------------------
// Initialization
//-----------------------------------------------------------
/**
* @see Component#_configure
*/
p._configure = function (thywill, config, callback) {
var self = this;
this.thywill = thywill;
this.config = config;
this.readyCallback = callback;
// ---------------------------------------------------------
// Set up cluster communication via pub/sub.
// ---------------------------------------------------------
// Define channel names.
this.channels = {};
this.config.clusterMemberIds.forEach(function (clusterMemberId, index, array) {
self.channels[clusterMemberId] = self.config.redisPrefix + clusterMemberId;
});
this.allChannel = this.config.redisPrefix + 'all';
// If the channel emits, then emit from this instance.
this.config.communication.subscribeRedisClient.on('message', function (channel, json) {
var data;
try {
data = JSON.parse(json);
} catch (e) {
self.thywill.log.error(e);
}
if (data && data.taskName) {
// Messages to others are sent to all, but with an 'ignoreIfOriginator' flag.
if (!data.ignoreIfOriginator || data.clusterMemberId !== self.config.localClusterMemberId) {
self.emit(data.taskName, data);
}
}
});
// ---------------------------------------------------------
// Set up heartbeats via the same pub/sub mechanism.
// ---------------------------------------------------------
this.heartbeatTimestamps = {};
this.heartbeatStatus = {};
// Cluster member status starts out UNKNOWN - no alerting happens unless
// status switches from UP to DOWN or vice versa. A switch from UNKNOWN
// to one of those two does nothing.
this.config.clusterMemberIds.forEach(function (clusterMemberId, index, array) {
self.heartbeatStatus[clusterMemberId] = self.clusterMemberStatus.UNKNOWN;
});
// Only start the heartbeat after the expensive setup stuff is done and
// Thywill is ready, otherwise there might be all sorts of blocking issues
// taking place to disrupt timing.
//
// As things stand, we still throw off heartbeat management into a separate
// thread to try to ensure that the intervals and time checks runs as much
// on time as possible.
this.thywill.on('thywill.ready', function () {
self.launchHeartbeatProcess();
});
// ---------------------------------------------------------
// Subscribe to the channels to start picking up tasks.
// ---------------------------------------------------------
var fns = [
function (asyncCallback) {
self.config.communication.subscribeRedisClient.subscribe(self.channels[self.config.localClusterMemberId], asyncCallback);
},
function (asyncCallback) {
self.config.communication.subscribeRedisClient.subscribe(self.allChannel, asyncCallback);
}
];
async.series(fns, function (error) {
self._announceReady(error);
});
};
//-----------------------------------------------------------
// Methods
//-----------------------------------------------------------
/**
* @see Cluster#getClusterMemberIds
*/
p.getClusterMemberIds = function () {
return this.config.clusterMemberIds;
};
/**
* @see Cluster#getLocalClusterMemberId
*/
p.getLocalClusterMemberId = function () {
return this.config.localClusterMemberId;
};
/**
* @see Cluster#getClusterMemberStatus
*/
p.getClusterMemberStatus = function (clusterMemberId, callback) {
callback(this.NO_ERRORS, this.getClusterMemberStatusSync(clusterMemberId));
};
/**
* Since cluster member status is maintained in memory, the async method isn't
* required for this implementation.
*
* @see Cluster#getClusterMemberStatus
*/
p.getClusterMemberStatusSync = function (clusterMemberId) {
return this.heartbeatStatus[clusterMemberId];
};
/**
* @see Cluster#isDesignatedHandlerFor
*/
p.isDesignatedHandlerFor = function (clusterMemberId, callback) {
// A simplistic implementation that doesn't account for multiple cluster
// members falling over. The handler is just the next one along in the array
// of cluster members.
var index = this.config.clusterMemberIds.indexOf(clusterMemberId) + 1;
if (index === this.config.clusterMemberIds.length) {
index = 0;
}
var isDesignatedHandler = (index === this.config.clusterMemberIds.indexOf(this.getLocalClusterMemberId()));
callback(this.NO_ERRORS, isDesignatedHandler);
};
/**
* @see Cluster#sendTo
*/
p.sendTo = function (clusterMemberId, taskName, data) {
data = data || {};
data.taskName = taskName;
data.clusterMemberId = this.config.localClusterMemberId;
if (clusterMemberId === this.config.localClusterMemberId) {
this.emit(taskName, data);
} else if (this.channels[clusterMemberId]) {
this.config.communication.publishRedisClient.publish(this.channels[clusterMemberId], JSON.stringify(data));
}
};
/**
* @see Cluster#sendToAll
*/
p.sendToAll = function (taskName, data) {
data = data || {};
data.taskName = taskName;
data.clusterMemberId = this.config.localClusterMemberId;
this.config.communication.publishRedisClient.publish(this.allChannel, JSON.stringify(data));
};
/**
* @see Cluster#sendToOthers
*/
p.sendToOthers = function (taskName, data) {
var self = this;
data = data || {};
data.ignoreIfOriginator = true;
this.sendToAll(taskName, data);
};
//-----------------------------------------------------------
// Methods: Heartbeat process.
//-----------------------------------------------------------
/**
* Fork a new process to run the heartbeat and heartbeat monitor.
*/
p.launchHeartbeatProcess = function () {
var self = this;
// Set up the heartbeat process arguments by converting them to JSON and
// then a base64 encoded string.
var heartbeatProcessArguments = {
clusterMemberIds: this.config.clusterMemberIds,
heartbeatInterval: this.config.heartbeat.interval,
heartbeatTimeout: this.config.heartbeat.timeout,
localClusterMemberId: this.config.localClusterMemberId,
redisPort: this.config.communication.publishRedisClient.port,
redisHost: this.config.communication.publishRedisClient.host,
redisOptions: this.config.communication.publishRedisClient.options,
redisPrefix: this.config.redisPrefix
};
heartbeatProcessArguments = JSON.stringify(heartbeatProcessArguments);
heartbeatProcessArguments = new Buffer(heartbeatProcessArguments, 'utf8').toString('base64');
this.heartbeatProcess = childProcess.fork(
path.join(__dirname, 'redisClusterHeartbeat.js'),
[heartbeatProcessArguments],
{
// Pass over all of the environment.
env: process.ENV,
silent: false
}
);
// Listen for messages from the heartbeat child process.
this.heartbeatProcess.on('message', function (message) {
self.heartbeatProcessMessage(message);
});
// Helper functions added to the child process to manage shutdown.
this.heartbeatProcess.onUnexpectedExit = function (code, signal) {
self.thywill.log.error('RedisCluster: heartbeat process terminated with code: ' + code);
process.exit(1);
}
this.heartbeatProcess.shutdown = function () {
this.removeListener('exit', this.onUnexpectedExit);
this.kill('SIGTERM');
}
// Make sure the child process dies along with this parent process, as far as
// is possible. I believe a SIGKILL to this process is going to leave the
// child heartbeat processes hanging for a little while on trying to send the
// last message over the channel to the now-vanished parent.
//
// This combination of items works under most circumstances; e.g. running
// ordinarily, running as child process in test code, running under a monitor
// process, etc.
process.once('SIGTERM', function () {
self.heartbeatProcess.shutdown();
});
process.once('exit', function () {
self.heartbeatProcess.shutdown();
});
// Kind of ugly, but works.
process.once('uncaughtException', function (error) {
// If this was the last of the listeners, then shut down the child and
// rethrow. Our assumption here is that any other code listening for an
// uncaught exception is going to do the sensible thing and call
// process.exit().
if (process.listeners('uncaughtException').length === 0) {
self.heartbeatProcess.shutdown();
throw error;
}
});
// If the child process dies, then we have a problem, and need to die also.
this.heartbeatProcess.on('exit', this.heartbeatProcess.onUnexpectedExit);
};
/**
* Invoked when a message is sent from the heartbeat process.
*
* @param {object} message
* A message from the forked heartbeat process.
*/
p.heartbeatProcessMessage = function (message) {
switch (message.type) {
case 'log':
this.thywill.log[message.level](message.message);
break;
case 'up':
if (this.heartbeatStatus[message.clusterMemberId] === this.clusterMemberStatus.DOWN) {
this.heartbeatStatus[message.clusterMemberId] = this.clusterMemberStatus.UP;
this.thywill.log.warn('RedisCluster: ' + message.clusterMemberId + ' is up.');
this.emit(this.eventNames.CLUSTER_MEMBER_UP, {
clusterMemberId: message.clusterMemberId
});
} else if (this.heartbeatStatus[message.clusterMemberId] === this.clusterMemberStatus.UNKNOWN) {
this.heartbeatStatus[message.clusterMemberId] = this.clusterMemberStatus.UP;
}
break;
case 'down':
this.heartbeatStatus[message.clusterMemberId] = this.clusterMemberStatus.DOWN;
this.thywill.log.warn('RedisCluster: ' + message.clusterMemberId + ' is down. ' + message.sinceLast + 'ms since last heartbeat.');
this.emit(this.eventNames.CLUSTER_MEMBER_DOWN, {
clusterMemberId: message.clusterMemberId
});
break;
}
};
//-----------------------------------------------------------
// Exports - Class Constructor
//-----------------------------------------------------------
module.exports = RedisCluster;