server/services/ringpop_service/service.js

'use strict';

/* eslint-disable security/detect-object-injection */

/**
 * Module dependencies, required for ALL Twyr' modules
 * @ignore
 */

/**
 * Module dependencies, required for this module
 * @ignore
 */
const TwyrBaseService = require('twyr-base-service').TwyrBaseService;
const TwyrSrvcError = require('twyr-service-error').TwyrServiceError;

/**
 * @class   RingpopService
 * @extends {TwyrBaseService}
 * @classdesc The Twyr Web Application Server Ringpop Service.
 *
 * @description
 * Allows the rest of the Twyr Modules to use Ringpop SDK API.
 *
 */
class RingpopService extends TwyrBaseService {
	// #region Constructor
	constructor(parent, loader) {
		super(parent, loader);
	}
	// #endregion

	// #region startup/teardown code
	/**
	 * @async
	 * @function
	 * @override
	 * @instance
	 * @memberof RingpopService
	 * @name     _setup
	 *
	 * @returns  {null} Nothing.
	 *
	 * @summary  Sets up the connection to the Ringpop SDK.
	 */
	async _setup() {
		try {
			await super._setup();

			const Promise = require('bluebird');
			const TChannel = require('tchannel');
			const Ringpop = require('ringpop');

			const self = this; // eslint-disable-line consistent-this
			const loggerProxy = {
				'log': function() {
					if(!self.$dependencies.LoggerService)
						return;

					if(!self.$dependencies.LoggerService.log)
						return;

					self.$dependencies.LoggerService.log(...arguments);
				},
				'trace': function() {
					if(!self.$dependencies.LoggerService)
						return;

					if(!self.$dependencies.LoggerService.silly)
						return;

					self.$dependencies.LoggerService.silly(...arguments);
				},
				'silly': function() {
					if(!self.$dependencies.LoggerService)
						return;

					if(!self.$dependencies.LoggerService.silly)
						return;

					self.$dependencies.LoggerService.silly(...arguments);
				},

				'debug': function() {
					if(!self.$dependencies.LoggerService)
						return;

					if(!self.$dependencies.LoggerService.debug)
						return;

					self.$dependencies.LoggerService.debug(...arguments);
				},

				'verbose': function() {
					if(!self.$dependencies.LoggerService)
						return;

					if(!self.$dependencies.LoggerService.verbose)
						return;

					self.$dependencies.LoggerService.verbose(...arguments);
				},

				'info': function() {
					if(!self.$dependencies.LoggerService)
						return;

					if(!self.$dependencies.LoggerService.info)
						return;

					self.$dependencies.LoggerService.info(...arguments);
				},

				'warn': function() {
					if(!self.$dependencies.LoggerService)
						return;

					if(!self.$dependencies.LoggerService.warn)
						return;

					self.$dependencies.LoggerService.warn(...arguments);
				},

				'error': function() {
					if(!self.$dependencies.LoggerService)
						return;

					if(!self.$dependencies.LoggerService.error)
						return;

					self.$dependencies.LoggerService.error(...arguments);
				}
			};

			if(!this.$config.host) {
				const networkInterfaceList = require('os').networkInterfaces();
				let hosts = null;

				Object.keys(networkInterfaceList).forEach((networkInterface) => {
					const interfaceAddresses = networkInterfaceList[networkInterface].filter((address) => {
						if(address.internal)
							return false;

						if(address.family.toLowerCase() !== 'ipv4')
							return false;

						return true;
					});

					if(!interfaceAddresses.length)
						return;

					hosts = interfaceAddresses.map((interfaceAddress) => {
						return interfaceAddress.address;
					});
				});

				this.$config.port = Number(this.$config.port);
				if(hosts.length === 1)
					this.$config.host = hosts[0];
				else
					throw new Error(`Multiple possible IPv4 addresses for clustering: ${JSON.stringify(hosts)}`);
			}

			return new Promise((resolve, reject) => {
				try {
					const tchannel = new TChannel({
						'statTags': {
							'app': this.$parent.$application,
							'cluster': process.title
						},

						'logger': loggerProxy,
						'trace': (twyrEnv === 'development' || twyrEnv === 'test')
					});

					const subChannel = tchannel.makeSubChannel({
						'serviceName': 'ringpop',
						'trace': (twyrEnv === 'development' || twyrEnv === 'test')
					});

					const ringpop = new Ringpop({
						'app': this.$parent.$application,
						'hostPort': `${this.$config.host}:${this.$config.port}`,
						'channel': subChannel,

                        'joinSize': 1,
						'joinTimeout': 100,

                        'logger': loggerProxy
					});

					ringpop.appChannel = tchannel.makeSubChannel({
						'serviceName': process.title
					});

					ringpop.setupChannel();
					tchannel.listen(this.$config.port, this.$config.host, () => {
						ringpop.bootstrap({
							'joinParallelismFactor': 2,
							'hosts': (this.$config.bootstrapNodes && this.$config.bootstrapNodes.length) ? this.$config.bootstrapNodes : [`${this.$config.host}:${this.$config.port}`]
						}, (err) => {
							if(err) {
								this.onRingpopError(err);
								reject(err);

								return;
							}

							this.$ringpop = ringpop;
							this.$ringpop.on('ringChanged', this.onRingChanged.bind(this));
							this.$ringpop.on('error', this.onRingpopError.bind(this));

							this.$parent.once('server-online', this._printInformation.bind(this));
							resolve();

							return;
						});
					});
				}
				catch(err) {
					reject(new TwyrSrvcError(`${this.name}::_setup::inner error`, err));
				}
			});
		}
		catch(err) {
			throw new TwyrSrvcError(`${this.name}::_setup::outer error`, err);
		}
	}

	/**
	 * @async
	 * @function
	 * @override
	 * @instance
	 * @memberof RingpopService
	 * @name     _teardown
	 *
	 * @returns  {undefined} Nothing.
	 *
	 * @summary  Quits the connection to the Ringpop SDK.
	 */
	async _teardown() {
		if(!this.$ringpop)
			return null;

		try {
			this.$ringpop.off('error', this.onRingpopError.bind(this));
			this.$ringpop.off('ringChanged', this.onRingChanged.bind(this));

			this.$ringpop.destroy();
			delete this.$ringpop;

			await super._teardown();
			return null;
		}
		catch(err) {
			throw new TwyrSrvcError(`${this.name}::_teardown error`, err);
		}
	}
	// #endregion

	// #region Private Methods
	async _printInformation() {
		if((twyrEnv !== 'development') && (twyrEnv !== 'test'))
			return;

		await snooze(600);
		this.$dependencies.LoggerService.debug(`Ringpop cluster initialized at ${this.$ringpop.whoami()}`);
	}

	async onRingChanged(event) {
		const ringpop = this.$ringpop;
		const leader = ringpop.lookup('LEADER');
		if(leader !== ringpop.whoami())
			return;

		const loggerService = this.$dependencies.LoggerService;
		const mailerService = this.$dependencies.MailerService;

		const mailOptions = {
			'from': 'root@twyr.io',
			'to': 'shadyvd@hotmail.com',
			'subject': 'Twyr Web Application Server Ring Changed',
			'text': `Added Servers: ${JSON.stringify((event.added || []), null, '\t')}\n\nRemoved Servers: ${JSON.stringify((event.removed || []), null, '\t')}`
		};

		const mailInfo = await mailerService.sendMailAsync(mailOptions);
		loggerService.info(`Ring Changed:\nAdded Servers: ${JSON.stringify((event.added || []), null, '\t')}\n\nRemoved Servers: ${JSON.stringify((event.removed || []), null, '\t')}\nEmail Sent: ${JSON.stringify(mailInfo, null, '\t')}`);
	}

	async onRingpopError(error) {
		if(!(error instanceof TwyrSrvcError)) error = new TwyrSrvcError(`${this.name}::_setup::ringpop bootstrap error`, error);

		const ringpop = this.$ringpop;
		const leader = ringpop.lookup('LEADER');
		if(leader !== ringpop.whoami())
			return;

		const loggerService = this.$dependencies.LoggerService;
		const mailerService = this.$dependencies.MailerService;

		const mailOptions = {
			'from': 'root@twyr.io',
			'to': 'shadyvd@hotmail.com',
			'subject': 'Twyr Web Application Server Ringpop Error',
			'text': `Ringpop Error:\n${error.toString()}`
		};

		const mailInfo = await mailerService.sendMailAsync(mailOptions);
		loggerService.error(`Ringpop Error: ${error.toString()}\nEmail Sent: ${JSON.stringify(mailInfo, null, '\t')}`);
	}
	// #endregion

	// #region Properties
	/**
	 * @override
	 */
	get Interface() {
		return this.$ringpop;
	}

	/**
	 * @override
	 */
	get dependencies() {
		return ['ConfigurationService', 'LoggerService', 'MailerService'].concat(super.dependencies);
	}

	/**
	 * @override
	 */
	get basePath() {
		return __dirname;
	}
	// #endregion
}

exports.service = RingpopService;