UNPKG

pixl-server-multi

Version:

A multi-server cluster manager component for pixl-server.

321 lines (280 loc) 9.1 kB
// Multi-Server Cluster Manager // A component for the pixl-server daemon framework. // Copyright (c) 2015 Joseph Huckaby // Released under the MIT License var Class = require("pixl-class"); var Component = require("pixl-server/component"); var Tools = require("pixl-tools"); var dgram = require("dgram"); var os = require('os'); var Netmask = require('netmask').Netmask; module.exports = Class.create({ __name: 'MultiServer', __parent: Component, defaultConfig: { comm_port: 3014, heartbeat_freq: 20, check_beats: 3, exit_on_conflict: false }, servers: null, listener: null, master: false, slave: false, eligible: true, hostname: '', masterHostname: '', data: null, startup: function(callback) { // startup var self = this; this.hostname = this.server.hostname; // emit events in outer server as well this.on('master', function() { self.server.emit('master'); } ); this.on('slave', function() { self.server.emit('slave'); } ); // user data system this.data = {}; // setup initial cluster this.servers = {}; this.servers[ this.hostname ] = { hostname: this.hostname, ip: this.server.ip, master: 0, eligible: this.eligible ? 1 : 0, self: 1, now: Tools.timeNow(), uptime: 0, data: this.data }; // guess best broadcast IP this.broadcastIP = this.config.get('broadcast_ip') || this.calcBroadcastIP(); this.logDebug(4, "Using broadcast IP: " + this.broadcastIP ); // start heartbeat tickers this.tickTimer = setInterval( function() { self.tick(); }, this.config.get('heartbeat_freq') * 1000 ); this.tockTimer = setInterval( function() { self.tock(); }, this.config.get('heartbeat_freq') * 1000 * this.config.get('check_beats') ); // start UDP socket listener this.logDebug(4, "Starting UDP server on port: " + this.config.get('comm_port')); var listener = this.listener = dgram.createSocket("udp4"); listener.on("message", function (msg, rinfo) { self.receive( msg, rinfo ); } ); listener.on("error", function (err) { self.logError(1, "UDP socket listener error: " + err); // if we got an error during startup, shut down now if (!self.server.started) self.server.shutdown(); } ); listener.bind( this.config.get('comm_port'), function() { callback(); } ); this.tick(); }, receive: function(msg, rinfo) { // receive UDP message from another server this.logDebug(10, "Received UDP message: " + msg + " from " + rinfo.address + ":" + rinfo.port); var text = msg.toString(); if (text.match(/^\{/)) { // appears to be JSON var json = null; try { json = JSON.parse(text); } catch (e) { this.logError(1, "Failed to parse JSON message: " + e); } if (json && json.action) { switch (json.action) { case 'heartbeat': if (json.hostname) { json.now = Tools.timeNow(); if (json.hostname == this.hostname) { json.self = 1; json.data = this.data; } delete json.action; if (!this.servers[ json.hostname ]) { // first time we've seen this server this.servers[ json.hostname ] = json; this.emit('addserver', json ); } else { // update from existing server this.servers[ json.hostname ] = json; } this.logDebug(10, "Received heartbeat from: " + json.hostname, json); } break; case 'shutdown': // server is shutting down, update cluster immediately this.logDebug(9, "Received shutdown notice from: " + json.hostname, json); if (this.servers[ json.hostname ]) { this.servers[ json.hostname ].now = 1; this.tock(); } break; } // switch action } // got json } // appears to be json }, tick: function() { // broadcast heartbeat tick (every N seconds) var self = this; var now = Tools.timeNow(true); if (this.shut) return; this.broadcast( 'heartbeat', { hostname: this.hostname, ip: this.server.ip, master: this.master ? 1 : 0, eligible: this.eligible ? 1 : 0, uptime: now - (this.server.started || now), data: this.data } ); }, tock: function() { // master cluster check if (this.shut) return; var now = Tools.timeNow(); var max_time = this.config.get('heartbeat_freq') * this.config.get('check_beats'); // for sanity's sake, assume we are always active this.servers[ this.hostname ].now = now; // first, prune any servers which didn't report in for (var hostname in this.servers) { var server = this.servers[hostname]; if (!server.self && !server.locked && (server.now < now - max_time)) { this.logDebug(8, "Removing dead server from cluster: " + hostname); delete this.servers[hostname]; this.emit('deleteserver', server); // reset masterHostname if the dead server was master if (hostname == this.masterHostname) this.masterHostname = ''; } } // locate current master hostname, if any for (var hostname in this.servers) { var server = this.servers[hostname]; if (server.master) { if ((hostname != this.masterHostname) && (hostname != this.hostname)) { this.logDebug(6, "The master server is now: " + hostname); this.logDebug(9, "Current server cluster: " + Tools.hashKeysToArray(this.servers).join(', ')); this.masterHostname = hostname; if (!this.slave) { // become a slave now this.slave = true; this.emit('slave'); } } // new master break; } // found master } // foreach server if (!this.masterHostname && !this.master && this.eligible) { // determine if we need to become master var found_higher = false; for (var hostname in this.servers) { var server = this.servers[hostname]; if (server.eligible && (hostname < this.hostname)) { found_higher = true; break; } } // foreach server if (!found_higher) { // we become master! this.logDebug(4, "We are now the master server"); this.logDebug(9, "Current server cluster: " + Tools.hashKeysToArray(this.servers).join(', ')); this.slave = false; this.master = true; this.masterHostname = this.hostname; this.servers[ this.hostname ].master = true; this.emit('master'); this.tick(); } } // no master yet // sanity check -- only one master allowed if (this.master) { for (var hostname in this.servers) { var server = this.servers[hostname]; if (server.master && !server.self && (hostname != this.hostname)) { // conflict! relinquish immediately! this.logDebug(1, "MASTER CONFLICT: "+hostname+" also thinks she is master!"); if (this.config.get('exit_on_conflict')) { this.logDebug(1, "The server is shutting down due to master conflict."); this.server.shutdown(); } else { this.relinquish(); } break; } } // foreach hostname } // we are master }, relinquish: function() { // relinquish master control if (this.master) { this.logDebug(1, "We are relinquishing master control"); this.master = false; this.slave = true; this.masterHostname = ''; this.servers[ this.hostname ].master = false; this.emit('slave'); this.tick(); } }, broadcast: function(type, message, callback) { // broadcast message via UDP var self = this; message.action = type; this.logDebug(10, "Broadcasting message: " + type, message); var client = dgram.createSocket('udp4'); var message = Buffer.from( JSON.stringify(message) + "\n" ); client.bind( 0, function() { client.setBroadcast( true ); client.send(message, 0, message.length, self.config.get('comm_port'), self.broadcastIP, function(err) { if (err) self.logDebug(9, "UDP broadcast failed: " + err); client.close(); if (callback) callback(); } ); } ); }, calcBroadcastIP: function() { // Attempt to determine server's Broadcast IP, using the first LAN IP and Netmask // https://en.wikipedia.org/wiki/Broadcast_address var ifaces = os.networkInterfaces(); var addrs = []; for (var key in ifaces) { if (ifaces[key] && ifaces[key].length) { Array.from(ifaces[key]).forEach( function(item) { addrs.push(item); } ); } } var addr = Tools.findObject( addrs, { family: 'IPv4', internal: false } ); if (addr && addr.address && addr.address.match(/^\d+\.\d+\.\d+\.\d+$/)) { // well that was easy var ip = addr.address; var mask = addr.netmask; var block = new Netmask( ip + '/' + mask ); return block.broadcast; } return '255.255.255.255'; }, shutdown: function(callback) { // shutdown var self = this; this.shut = true; // shutdown UDP listener if (this.listener) { this.logDebug(2, "Shutting down UDP server"); this.listener.close(); } if (this.tickTimer) { clearTimeout( this.tickTimer ); delete this.tickTimer; } if (this.tockTimer) { clearTimeout( this.tockTimer ); delete this.tockTimer; } // broadcast our shutdown to the cluster this.broadcast( 'shutdown', { hostname: this.hostname }, callback ); } });