gl-hydra
Version:
Hydra is a NodeJS light-weight library for building distributed computing applications such as microservices
1,447 lines (1,371 loc) • 76.9 kB
JavaScript
'use strict';
const debug = require('debug')('hydra');
const Promise = require('bluebird');
Promise.series = (iterable, action) => {
return Promise.mapSeries(
iterable.map(action),
(value, index, _length) => value || iterable[index].name || null
);
};
const EventEmitter = require('events');
const util = require('util');
const uuid = require('uuid');
const Route = require('route-parser');
const os = require('os');
const Utils = require('./lib/utils');
const UMFMessage = require('./lib/umfmessage');
const RedisConnection = require('./lib/redis-connection');
const ServerResponse = require('./lib/server-response');
let serverResponse = new ServerResponse();
const ServerRequest = require('./lib/server-request');
let serverRequest = new ServerRequest();
const Cache = require('./lib/cache');
let HYDRA_REDIS_DB = 0;
const redisPreKey = 'hydra:service';
const mcMessageKey = 'hydra:service:mc';
const MAX_ENTRIES_IN_HEALTH_LOG = 64;
const ONE_SECOND = 1000; // milliseconds
const ONE_WEEK_IN_SECONDS = 604800;
const PRESENCE_UPDATE_INTERVAL = ONE_SECOND;
const HEALTH_UPDATE_INTERVAL = ONE_SECOND * 5;
const KEY_EXPIRATION_TTL = process.env.KEY_EXPIRATION_TTL || 120; // two minutes
const KEYS_PER_SCAN = '100';
const UMF_INVALID_MESSAGE = 'UMF message requires "to", "from" and "body" fields';
const INSTANCE_ID_NOT_SET = 'not set';
/**
* @name Hydra
* @summary Base class for Hydra.
* @fires Hydra#log
* @fires Hydra#message
*/
class Hydra extends EventEmitter {
/**
* @name constructor
* @return {undefined}
*/
constructor() {
super();
this.instanceID = INSTANCE_ID_NOT_SET;
this.mcMessageChannelClient;
this.mcDirectMessageChannelClient;
this.messageChannelPool = {};
this.config = null;
this.serviceName = '';
this.serviceDescription = '';
this.serviceVersion = '';
this.isService = false;
this.redisdb = null;
this._updatePresence = this._updatePresence.bind(this);
this._updateHealthCheck = this._updateHealthCheck.bind(this);
this.registeredRoutes = [];
this.registeredPlugins = [];
this.presenceTimerInteval = null;
this.healthTimerInterval = null;
this.initialized = false;
this.hostName = os.hostname();
this.internalCache = new Cache();
this.ready = () => Promise.reject(new Error('You must call hydra.init() before invoking hydra.ready()'));
}
/**
* @name use
* @summary Adds plugins to Hydra
* @param {...object} plugins - plugins to register
* @return {object} - Promise which will resolve when all plugins are registered
*/
use(...plugins) {
return Promise.series(plugins, (plugin) => this._registerPlugin(plugin));
}
/**
* @name _registerPlugin
* @summary Registers a plugin with Hydra
* @param {object} plugin - HydraPlugin to use
* @return {object} Promise or value
*/
_registerPlugin(plugin) {
this.registeredPlugins.push(plugin);
return plugin.setHydra(this);
}
/**
* @name init
* @summary Register plugins then continue initialization
* @param {mixed} config - a string with a path to a configuration file or an
* object containing hydra specific keys/values
* @param {boolean} testMode - whether hydra is being started in unit test mode
* @return {object} promise - resolves with this._init or rejects with an appropriate
* error if something went wrong
*/
init(config, testMode) {
// Reject() if we've already been called successfully
if (INSTANCE_ID_NOT_SET !== this.instanceID) {
return Promise.reject(new Error('Hydra.init() already invoked'));
}
this.testMode = testMode;
if (typeof config === 'string') {
const configHelper = require('./lib/config');
return configHelper.init(config)
.then(() => {
return this.init(configHelper.getObject(), testMode);
});
}
const initPromise = new Promise((resolve, reject) => {
let loader = (newConfig) => {
return Promise.series(this.registeredPlugins, (plugin) => plugin.setConfig(newConfig.hydra))
.then((..._results) => {
return this._init(newConfig.hydra);
})
.then(() => {
resolve(newConfig);
return 0;
})
.catch((err) => {
this._logMessage('error', err.toString());
reject(err);
});
};
if (!config || !config.hydra) {
config = Object.assign({
'hydra': {
'serviceIP': '',
'servicePort': 0,
'serviceType': '',
'serviceDescription': '',
'redis': {
'url': 'redis://127.0.0.1:6379/15'
}
}
});
}
if (!config.hydra.redis) {
config.hydra = Object.assign(config.hydra, {
'redis': {
'url': 'redis://127.0.0.1:6379/15'
}
});
}
if (process.env.HYDRA_REDIS_URL) {
Object.assign(config.hydra, {
redis: {
url: process.env.HYDRA_REDIS_URL
}
});
}
let partialConfig = true;
if (process.env.HYDRA_SERVICE) {
let hydraService = process.env.HYDRA_SERVICE.trim();
if (hydraService[0] === '{') {
let newHydraBranch = Utils.safeJSONParse(hydraService);
Object.assign(config.hydra, newHydraBranch);
partialConfig = false;
}
if (hydraService.includes('|')) {
hydraService = hydraService.replace(/(\r\n|\r|\n)/g, '');
let newHydraBranch = {};
let key = '';
let val = '';
let segs = hydraService.split('|');
segs.forEach((segment) => {
segment = segment.trim();
[key, val] = segment.split('=');
newHydraBranch[key] = val;
});
Object.assign(config.hydra, newHydraBranch);
partialConfig = false;
}
}
if (!config.hydra.serviceName || (!config.hydra.servicePort && !config.hydra.servicePort === 0)) {
reject(new Error('Config missing serviceName or servicePort'));
return;
}
if (config.hydra.serviceName.includes(':')) {
reject(new Error('serviceName can not have a colon character in its name'));
return;
}
if (config.hydra.serviceName.includes(' ')) {
reject(new Error('serviceName can not have a space character in its name'));
return;
}
if (partialConfig && process.env.HYDRA_REDIS_URL) {
this._connectToRedis({redis: {url: process.env.HYDRA_REDIS_URL}})
.then(() => {
if (!this.redisdb) {
reject(new Error('No Redis connection'));
return;
}
this.redisdb.select(HYDRA_REDIS_DB, (err, _result) => {
if (!err) {
this._getConfig(process.env.HYDRA_SERVICE)
.then((storedConfig) => {
this.redisdb.quit();
if (!storedConfig) {
reject(new Error('Invalid service stored config'));
} else {
return loader(storedConfig);
}
})
.catch((err) => reject(err));
} else {
reject(new Error('Invalid service stored config'));
}
});
});
} else {
return loader(config);
}
});
this.ready = () => initPromise;
return initPromise;
}
/**
* @name _init
* @summary Initialize Hydra with config object.
* @param {object} config - configuration object containing hydra specific keys/values
* @return {object} promise - resolving if init success or rejecting otherwise
*/
_init(config) {
return new Promise((resolve, reject) => {
let ready = () => {
Promise.series(this.registeredPlugins, (plugin) => plugin.onServiceReady()).then((..._results) => {
resolve();
}).catch((err) => this._logMessage('error', err.toString()));
};
this.config = config;
this._connectToRedis(this.config).then(() => {
if (!this.redisdb) {
reject(new Error('No Redis connection'));
return;
}
let p = this._parseServicePortConfig(this.config.servicePort);
p.then((port) => {
this.config.servicePort = port;
this.serviceName = config.serviceName;
if (this.serviceName && this.serviceName.length > 0) {
this.serviceName = this.serviceName.toLowerCase();
}
this.serviceDescription = this.config.serviceDescription || 'not specified';
this.config.serviceVersion = this.serviceVersion = this.config.serviceVersion || this._getParentPackageJSONVersion();
/**
* Determine network DNS/IP for this service.
* - First check whether serviceDNS is defined. If so, this is expected to be a DNS entry.
* - Else check whether serviceIP exists and is not empty ('') and is not an segemented IP
* such as 192.168.100.106 If so, then use DNS lookup to determine an actual dotted IP address.
* - Else check whether serviceIP exists and *IS* set to '' - that means the service author is
* asking Hydra to determine the machine's IP address.
* - And final else - the serviceIP is expected to be populated with an actual dotted IP address
* or serviceDNS contains a valid DNS entry.
*/
if (this.config.serviceDNS && this.config.serviceDNS !== '') {
this.config.serviceIP = this.config.serviceDNS;
this._updateInstanceData();
ready();
} else {
const net = require('net');
if (this.config.serviceIP && this.config.serviceIP !== '' && net.isIP(this.config.serviceIP) === 0) {
const dns = require('dns');
dns.lookup(this.config.serviceIP, (err, result) => {
this.config.serviceIP = result;
this._updateInstanceData();
ready();
});
} else if (!this.config.serviceIP || this.config.serviceIP === '') {
// handle IP selection
const os = require('os');
let interfaces = os.networkInterfaces();
if (this.config.serviceInterface && this.config.serviceInterface !== '') {
let segments = this.config.serviceInterface.split('/');
if (segments && segments.length === 2) {
let interfaceName = segments[0];
let interfaceMask = segments[1];
Object.keys(interfaces).
forEach((itf) => {
interfaces[itf].forEach((interfaceRecord)=>{
if (itf === interfaceName && interfaceRecord.netmask === interfaceMask && interfaceRecord.family === 'IPv4') {
this.config.serviceIP = interfaceRecord.address;
}
});
});
} else {
throw new Error('config serviceInterface is not a valid format');
}
} else {
// not using serviceInterface - just select first eth0 entry.
let firstSelected = false;
Object.keys(interfaces).
forEach((itf) => {
interfaces[itf].forEach((interfaceRecord)=>{
if (!firstSelected && interfaceRecord.family === 'IPv4' && interfaceRecord.address !== '127.0.0.1') {
this.config.serviceIP = interfaceRecord.address;
firstSelected = true;
}
});
});
}
this._updateInstanceData();
ready();
} else {
this._updateInstanceData();
ready();
}
}
return 0;
}).catch((err) => reject(err));
return p;
}).catch((err) => reject(err));
});
}
/**
* @name _updateInstanceData
* @summary Update instance id and direct message key
* @return {undefined}
*/
_updateInstanceData() {
this.instanceID = this._serverInstanceID();
this.initialized = true;
}
/**
* @name _shutdown
* @summary Shutdown hydra safely.
* @return {undefined}
*/
_shutdown() {
return new Promise((resolve) => {
clearInterval(this.presenceTimerInteval);
clearInterval(this.healthTimerInterval);
const promises = [];
if (!this.testMode) {
this._logMessage('error', 'Service is shutting down.');
this.redisdb.batch()
.expire(`${redisPreKey}:${this.serviceName}:${this.instanceID}:health`, KEY_EXPIRATION_TTL)
.expire(`${redisPreKey}:${this.serviceName}:${this.instanceID}:health:log`, ONE_WEEK_IN_SECONDS)
.exec();
if (this.mcMessageChannelClient) {
promises.push(this.mcMessageChannelClient.quitAsync());
}
if (this.mcDirectMessageChannelClient) {
promises.push(this.mcDirectMessageChannelClient.quitAsync());
}
}
Object.keys(this.messageChannelPool).forEach((keyname) => {
promises.push(this.messageChannelPool[keyname].quitAsync());
});
if (this.redisdb) {
this.redisdb.del(`${redisPreKey}:${this.serviceName}:${this.instanceID}:presence`, () => {
this.redisdb.quit();
Promise.all(promises).then(resolve);
});
this.redisdb.quit();
Promise.all(promises).then(resolve);
} else {
Promise.all(promises).then(resolve);
}
this.initialized = false;
});
}
/**
* @name _connectToRedis
* @summary Configure access to Redis and monitor emitted events.
* @private
* @param {object} config - Redis client configuration
* @return {object} promise - resolves or reject
*/
_connectToRedis(config) {
let retryStrategy = config.redis.retry_strategy;
delete config.redis.retry_strategy;
let redisConnection = new RedisConnection(config.redis, 0, this.testMode);
HYDRA_REDIS_DB = redisConnection.redisConfig.db;
return redisConnection.connect(retryStrategy)
.then((client) => {
this.redisdb = client;
client
.on('reconnecting', () => {
this._logMessage('error', 'Reconnecting to Redis server...');
})
.on('warning', (warning) => {
this._logMessage('error', `Redis warning: ${warning}`);
})
.on('end', () => {
this._logMessage('error', 'Established Redis server connection has closed');
})
.on('error', (err) => {
this._logMessage('error', `Redis error: ${err}`);
});
return client;
});
}
/**
* @name _getKeys
* @summary Retrieves a list of Redis keys based on pattern.
* @param {string} pattern - pattern to filter with
* @return {object} promise - promise resolving to array of keys or or empty array
*/
_getKeys(pattern) {
return new Promise((resolve, _reject) => {
if (this.testMode) {
this.redisdb.keys(pattern, (err, result) => {
if (err) {
resolve([]);
} else {
resolve(result);
}
});
} else {
let doScan = (cursor, pattern, retSet) => {
this.redisdb.scan(cursor, 'MATCH', pattern, 'COUNT', KEYS_PER_SCAN, (err, result) => {
if (!err) {
cursor = result[0];
let keys = result[1];
keys.forEach((key, _i) => {
retSet.add(key);
});
if (cursor === '0') {
resolve(Array.from(retSet));
} else {
doScan(cursor, pattern, retSet);
}
} else {
resolve([]);
}
});
};
let results = new Set();
doScan('0', pattern, results);
}
});
}
/**
* @name _getServiceName
* @summary Retrieves the service name of the current instance.
* @private
* @throws Throws an error if this machine isn't an instance.
* @return {string} serviceName - returns the service name.
*/
_getServiceName() {
if (!this.initialized) {
let msg = 'init() not called, Hydra requires a configuration object.';
this._logMessage('error', msg);
throw new Error(msg);
}
return this.serviceName;
}
/**
* @name _serverInstanceID
* @summary Returns the server instance ID.
* @private
* @return {string} instance id
*/
_serverInstanceID() {
return uuid.
v4().
replace(RegExp('-', 'g'), '');
}
/**
* @name _registerService
* @summary Registers this machine as a Hydra instance.
* @description This is an optional call as this module might just be used to monitor and query instances.
* @private
* @return {object} promise - resolving if registration success or rejecting otherwise
*/
_registerService() {
return new Promise((resolve, reject) => {
if (!this.initialized) {
let msg = 'init() not called, Hydra requires a configuration object.';
this._logMessage('error', msg);
reject(new Error(msg));
return;
}
if (!this.redisdb) {
let msg = 'No Redis connection';
this._logMessage('error', msg);
reject(new Error(msg));
return;
}
this.isService = true;
let serviceName = this.serviceName;
let serviceEntry = Utils.safeJSONStringify({
serviceName,
type: this.config.serviceType,
registeredOn: this._getTimeStamp()
});
this.redisdb.set(`${redisPreKey}:${serviceName}:service`, serviceEntry, (err, _result) => {
if (err) {
let msg = 'Unable to set :service key in Redis db.';
this._logMessage('error', msg);
reject(new Error(msg));
} else {
let testRedis;
if (this.testMode) {
let redisConnection;
redisConnection = new RedisConnection(this.config.redis, 0, this.testMode);
testRedis = redisConnection.getRedis();
}
// Setup service message courier channels
this.mcMessageChannelClient = this.testMode ? testRedis.createClient() : this.redisdb.duplicate();
this.mcMessageChannelClient.subscribe(`${mcMessageKey}:${serviceName}`);
this.mcMessageChannelClient.on('message', (channel, message) => {
let msg = Utils.safeJSONParse(message);
if (msg) {
let umfMsg = UMFMessage.createMessage(msg);
this.emit('message', umfMsg.toShort());
}
});
this.mcDirectMessageChannelClient = this.testMode ? testRedis.createClient() : this.redisdb.duplicate();
this.mcDirectMessageChannelClient.subscribe(`${mcMessageKey}:${serviceName}:${this.instanceID}`);
this.mcDirectMessageChannelClient.on('message', (channel, message) => {
let msg = Utils.safeJSONParse(message);
if (msg) {
let umfMsg = UMFMessage.createMessage(msg);
this.emit('message', umfMsg.toShort());
}
});
// Schedule periodic updates
this.presenceTimerInteval = setInterval(this._updatePresence, PRESENCE_UPDATE_INTERVAL);
this.healthTimerInterval = setInterval(this._updateHealthCheck, HEALTH_UPDATE_INTERVAL);
// Update presence immediately without waiting for next update interval.
this._updatePresence();
resolve({
serviceName: this.serviceName,
serviceIP: this.config.serviceIP,
servicePort: this.config.servicePort
});
}
});
});
}
/**
* @name _registerRoutes
* @summary Register routes
* @description Routes must be formatted as UMF To routes. https://github.com/cjus/umf#%20To%20field%20(routing)
* @private
* @param {array} routes - array of routes
* @return {object} Promise - resolving or rejecting
*/
_registerRoutes(routes) {
return new Promise((resolve, reject) => {
if (!this.redisdb) {
reject(new Error('No Redis connection'));
return;
}
this._flushRoutes().then(() => {
let routesKey = `${redisPreKey}:${this.serviceName}:service:routes`;
let trans = this.redisdb.multi();
[
`[get]/${this.serviceName}`,
`[get]/${this.serviceName}/`,
`[get]/${this.serviceName}/:rest`
].forEach((pattern) => {
routes.push(pattern);
});
routes.forEach((route) => {
trans.sadd(routesKey, route);
});
trans.exec((err, _result) => {
if (err) {
reject(err);
} else {
return this._getRoutes()
.then((routeList) => {
if (routeList.length) {
this.registeredRoutes = [];
routeList.forEach((route) => {
this.registeredRoutes.push(new Route(route));
});
if (this.serviceName !== 'hydra-router') {
// let routers know that a new service route was registered
resolve();
return this._sendBroadcastMessage(UMFMessage.createMessage({
to: 'hydra-router:/refresh',
from: `${this.serviceName}:/`,
body: {
action: 'refresh',
serviceName: this.serviceName
}
}));
} else {
resolve();
}
} else {
resolve();
}
})
.catch(reject);
}
});
}).catch(reject);
});
}
/**
* @name _getRoutes
* @summary Retrieves a array list of routes
* @param {string} serviceName - name of service to retrieve list of routes.
* If param is undefined, then the current serviceName is used.
* @return {object} Promise - resolving to array of routes or rejection
*/
_getRoutes(serviceName) {
if (serviceName === undefined) {
serviceName = this.serviceName;
}
return new Promise((resolve, reject) => {
let routesKey = `${redisPreKey}:${serviceName}:service:routes`;
this.redisdb.smembers(routesKey, (err, result) => {
if (err) {
reject(err);
} else {
resolve(result);
}
});
});
}
/**
* @name _getAllServiceRoutes
* @summary Retrieve all service routes.
* @return {object} Promise - resolving to an object with keys and arrays of routes
*/
_getAllServiceRoutes() {
return new Promise((resolve, reject) => {
if (!this.redisdb) {
let msg = 'No Redis connection';
this._logMessage('error', msg);
reject(new Error(msg));
return;
}
let promises = [];
let serviceNames = [];
this._getKeys('*:routes')
.then((serviceRoutes) => {
serviceRoutes.forEach((service) => {
let segments = service.split(':');
let serviceName = segments[2];
serviceNames.push(serviceName);
promises.push(this._getRoutes(serviceName));
});
return Promise.all(promises);
})
.then((routes) => {
let resObj = {};
let idx = 0;
routes.forEach((routesList) => {
resObj[serviceNames[idx]] = routesList;
idx += 1;
});
resolve(resObj);
})
.catch((err) => {
reject(err);
});
});
}
/**
* @name _matchRoute
* @summary Matches a route path to a list of registered routes
* @private
* @param {string} routePath - a URL path to match
* @return {boolean} match - true if match, false if not
*/
_matchRoute(routePath) {
let ret = false;
for (let route of this.registeredRoutes) {
if (route.match(routePath)) {
ret = true;
break;
}
}
return ret;
}
/**
* @name _flushRoutes
* @summary Delete's the services routes.
* @return {object} Promise - resolving or rejection
*/
_flushRoutes() {
return new Promise((resolve, reject) => {
let routesKey = `${redisPreKey}:${this.serviceName}:service:routes`;
this.redisdb.del(routesKey, (err, result) => {
if (err) {
reject(err);
} else {
resolve(result);
}
});
});
}
/**
* @name _updatePresence
* @summary Update service presence.
* @private
* @return {undefined}
*/
_updatePresence() {
let entry = Utils.safeJSONStringify({
serviceName: this.serviceName,
serviceDescription: this.serviceDescription,
version: this.serviceVersion,
instanceID: this.instanceID,
updatedOn: this._getTimeStamp(),
processID: process.pid,
ip: this.config.serviceIP,
port: this.config.servicePort,
hostName: this.hostName
});
if (entry && !this.redisdb.closing) {
let cmd = (this.testMode) ? 'multi' : 'batch';
this.redisdb[cmd]()
.setex(`${redisPreKey}:${this.serviceName}:${this.instanceID}:presence`, KEY_EXPIRATION_TTL, this.instanceID)
.hset(`${redisPreKey}:nodes`, this.instanceID, entry)
.exec();
}
}
/**
* @name _updateHealthCheck
* @summary Update service heath.
* @private
* @return {undefined}
*/
_updateHealthCheck() {
let entry = Object.assign({
updatedOn: this._getTimeStamp()
}, this._getHealth());
let cmd = (this.testMode) ? 'multi' : 'batch';
this.redisdb[cmd]()
.setex(`${redisPreKey}:${this.serviceName}:${this.instanceID}:health`, KEY_EXPIRATION_TTL, Utils.safeJSONStringify(entry))
.expire(`${redisPreKey}:${this.serviceName}:${this.instanceID}:health:log`, ONE_WEEK_IN_SECONDS)
.exec();
}
/**
* @name _getHealth
* @summary Retrieve server health info.
* @private
* @return {object} obj - object containing server info
*/
_getHealth() {
let lines = [];
let keyval = [];
let map = {};
let memory = util.inspect(process.memoryUsage());
memory = memory.replace(/[\ \{\}.|\n]/g, '');
lines = memory.split(',');
lines.forEach((line) => {
keyval = line.split(':');
map[keyval[0]] = Number(keyval[1]);
});
let uptimeInSeconds = process.uptime();
return {
serviceName: this.serviceName,
instanceID: this.instanceID,
hostName: this.hostName,
sampledOn: this._getTimeStamp(),
processID: process.pid,
architecture: process.arch,
platform: process.platform,
nodeVersion: process.version,
memory: map,
uptimeSeconds: uptimeInSeconds
};
}
/**
* @name _logMessage
* @summary Log a message to the service's health log queue.
* @private
* @throws Throws an error if this machine isn't an instance.
* @event Hydra#log
* @param {string} type - type of message ('error', 'info', 'debug' or user defined)
* @param {string} message - message to log
* @param {boolean} suppressEmit - false by default. If true then suppress log emit
* @return {undefined}
*/
_logMessage(type, message, suppressEmit) {
let errMessage = {
ts: this._getTimeStamp(),
serviceName: this.serviceName || 'not a service',
type,
processID: process.pid,
msg: message
};
let entry = Utils.safeJSONStringify(errMessage);
debug(entry);
if (!suppressEmit) {
this.emit('log', errMessage);
}
if (entry) {
// If issue is with Redis we can't use Redis to log this error.
// however the above call to the application logger would be one way of detecting the issue.
if (this.isService) {
if (entry.toLowerCase().indexOf('redis') === -1) {
if (!this.redisdb.closing) {
let key = `${redisPreKey}:${this.serviceName}:${this.instanceID}:health:log`;
this.redisdb.multi()
.select(HYDRA_REDIS_DB)
.lpush(key, entry)
.ltrim(key, 0, MAX_ENTRIES_IN_HEALTH_LOG - 1)
.exec();
}
}
}
} else {
console.log('Unable to log this message', type, message);
}
}
/**
* @name _getServices
* @summary Retrieve a list of available services.
* @private
* @return {promise} promise - returns a promise
*/
_getServices() {
return new Promise((resolve, reject) => {
if (!this.redisdb) {
reject(new Error('No Redis connection'));
return;
}
this._getKeys('*:service')
.then((services) => {
let trans = this.redisdb.multi();
services.forEach((service) => {
trans.get(service);
});
trans.exec((err, result) => {
if (err) {
reject(err);
} else {
let serviceList = result.map((service) => {
return Utils.safeJSONParse(service);
});
resolve(serviceList);
}
});
});
});
}
/**
* @name _getServiceNodes
* @summary Retrieve a list of services even if inactive.
* @private
* @return {promise} promise - returns a promise
*/
_getServiceNodes() {
return new Promise((resolve, reject) => {
if (!this.redisdb) {
reject(new Error('No Redis connection'));
return;
}
let now = (new Date()).getTime();
this.redisdb.hgetall(`${redisPreKey}:nodes`, (err, data) => {
if (err) {
reject(err);
} else {
let nodes = [];
if (data) {
Object.keys(data).forEach((entry) => {
let item = Utils.safeJSONParse(data[entry]);
item.elapsed = parseInt((now - (new Date(item.updatedOn)).getTime()) / ONE_SECOND);
nodes.push(item);
});
}
resolve(nodes);
}
});
});
}
/**
* @name _findService
* @summary Find a service.
* @private
* @param {string} name - service name - note service name is case insensitive
* @return {promise} promise - which resolves with service
*/
_findService(name) {
return new Promise((resolve, reject) => {
if (!this.redisdb) {
reject(new Error('No Redis connection'));
return;
}
this.redisdb.get(`${redisPreKey}:${name}:service`, (err, result) => {
if (err) {
reject(err);
} else {
if (!result) {
reject(new Error(`Can't find ${name} service`));
} else {
let js = Utils.safeJSONParse(result);
resolve(js);
}
}
});
});
}
/**
* @name _checkServicePresence
* @summary Retrieves all the "present" service instances information.
* @description Differs from getServicePresence (which calls this one)
* in that this performs only bare minimum fatal error checking that
* would throw a reject(). This is useful when it's expected to perhaps
* have some dead serivces, etc. as used in getServiceHealthAll()
* for example.
* @param {string} [name=our service name] - service name - note service name is case insensitive
* @return {promise} promise - which resolves with a randomized service presence array or else
* a reject() if a "fatal" error occured (Redis error for example)
*/
_checkServicePresence(name) {
name = name || this._getServiceName();
return new Promise((resolve, reject) => {
console.time('Service presence time');
let cacheKey = `checkServicePresence:${name}`;
let cachedValue = this.internalCache.get(cacheKey);
if (cachedValue) {
// Re-randomized the array each call to make sure we return a good
// random set each time we access the cache... no need to store
// the new random array again since it will just be randomzied again next call
Utils.shuffleArray(cachedValue);
resolve(cachedValue);
console.log('Cache hit!').
console.timeEnd('Service presence time');
return;
}
this._getKeys(`*:${name}:*:presence`)
.then((instances) => {
if (instances.length === 0) {
resolve([]);
console.log('No instances.');
console.timeEnd('Service presence time');
return;
}
let trans = this.redisdb.multi();
instances.forEach((instance) => {
let instanceId = instance.split(':')[3];
trans.hget(`${redisPreKey}:nodes`, instanceId);
});
trans.exec((err, result) => {
if (err) {
reject(err);
} else {
let instanceList = [];
result.forEach((instance) => {
if (instance) {
let instanceObj = Utils.safeJSONParse(instance);
if (instanceObj) {
instanceObj.updatedOnTS = (new Date(instanceObj.updatedOn).getTime());
}
instanceList.push(instanceObj);
}
});
if (instanceList.length) {
Utils.shuffleArray(instanceList);
console.log('Putting instances in cache. TTL: ' + KEY_EXPIRATION_TTL);
this.internalCache.put(cacheKey, instanceList, KEY_EXPIRATION_TTL);
}
console.log('Full redis RT');
console.timeEnd('Service presence time');
resolve(instanceList);
}
});
});
});
}
/**
* @name getServicePresence
* @summary Retrieve a service / instance's presence info.
* @private
* @param {string} name - service name - note service name is case insensitive
* @return {promise} promise - which resolves with service presence
*/
_getServicePresence(name) {
if (name === undefined) {
name = this._getServiceName();
}
return new Promise((resolve, reject) => {
return this._checkServicePresence(name)
.then((result) => {
if (result === null) {
let msg = `Service instance for ${name} is unavailable`;
this._logMessage('error', msg);
reject(new Error(msg));
} else {
resolve(result);
}
})
.catch((err) => {
reject(err);
});
});
}
/**
* @name _getServiceHealth
* @summary Retrieve the health status of an instance service.
* @private
* @param {string} name - name of instance service.
* @description If not specified then the current instance is assumed. - note service name is case insensitive.
* @return {promise} promise - a promise resolving to the instance's health info
*/
_getServiceHealth(name) {
if (name === undefined && !this.isService) {
let err = new Error('getServiceHealth() failed. Cant get health log since this machine isn\'t a instance.');
throw err;
}
if (name === undefined) {
name = this._getServiceName();
}
return new Promise((resolve, reject) => {
let cacheKey = `getServiceHealth:${name}`;
let cachedValue = this.internalCache.get(cacheKey);
if (cachedValue) {
resolve(cachedValue);
return;
}
this._getKeys(`*:${name}:*:health`)
.then((instances) => {
if (instances.length === 0) {
resolve([]);
return;
}
let trans = this.redisdb.multi();
instances.forEach((instance) => {
trans.get(instance);
});
trans.exec((err, result) => {
if (err) {
reject(err);
} else {
let instanceList = result.map((instance) => {
return Utils.safeJSONParse(instance);
});
this.internalCache.put(cacheKey, instanceList, KEY_EXPIRATION_TTL);
resolve(instanceList);
}
});
});
});
}
/**
* @name _getInstanceID
* @summary Return the instance id for this process
* @return {number} id - instanceID
*/
_getInstanceID() {
return this.instanceID;
}
/**
* @name _getInstanceVersion
* @summary Return the version of this instance
* @return {number} version - instance version
*/
_getInstanceVersion() {
return this.serviceVersion;
}
/**
* @name _getServiceHealthLog
* @summary Get this service's health log.
* @private
* @throws Throws an error if this machine isn't a instance
* @param {string} name - name of instance service. If not specified then the current instance is assumed.
* @return {promise} promise - resolves to log entries
*/
_getServiceHealthLog(name) {
if (name === undefined && !this.isService) {
let err = new Error('getServiceHealthLog() failed. Can\'t get health log since this machine isn\'t an instance.');
throw err;
}
if (name === undefined) {
name = this._getServiceName();
}
return new Promise((resolve, reject) => {
this._getKeys(`*:${name}:*:health:log`)
.then((instances) => {
if (instances.length === 0) {
resolve([]);
return;
}
let trans = this.redisdb.multi();
instances.forEach((instance) => {
trans.lrange(instance, 0, MAX_ENTRIES_IN_HEALTH_LOG - 1);
});
trans.exec((err, result) => {
if (err) {
reject(err);
} else {
let response = [];
if (result && result.length > 0) {
result = result[0];
result.forEach((entry) => {
response.push(Utils.safeJSONParse(entry));
});
}
resolve(response);
}
});
});
});
}
/**
* @name _getServiceHealthAll
* @summary Retrieve the health status of all instance services.
* @private
* @return {promise} promise - resolves with an array of objects containing instance health information.
*/
_getServiceHealthAll() {
return new Promise((resolve, reject) => {
if (!this.redisdb) {
reject(new Error('No Redis connection'));
return;
}
this._getServices()
.then((services) => {
let listOfPromises = [];
services.forEach((service) => {
let serviceName = service.serviceName;
listOfPromises.push(this._getServiceHealth(serviceName));
listOfPromises.push(this._getServiceHealthLog(serviceName));
listOfPromises.push(this._checkServicePresence(serviceName));
});
return Promise.all(listOfPromises);
})
.then((values) => {
let response = [];
for (let i = 0; i < values.length; i += 3) {
response.push({
health: values[i],
log: values[i + 1],
presence: values[i + 2]
});
}
resolve(response);
})
.catch((err) => {
reject(err);
});
});
}
/**
* @name _chooseServiceInstance
* @summary Choose an instance from a list of service instances.
* @private
* @param {array} instanceList - array list of service instances
* @param {string} defaultInstance - default instance
* @return {object} promise - resolved or rejected
*/
_chooseServiceInstance(instanceList, defaultInstance) {
return new Promise((resolve, reject) => {
let instance;
if (defaultInstance) {
for (let i = 0; i < instanceList.length; i++) {
if (instanceList[i].instanceID === defaultInstance) {
instance = instanceList[i];
break;
}
}
}
instance = instance || instanceList[0];
this.redisdb.get(`${redisPreKey}:${instance.serviceName}:${instance.instanceID}:presence`, (err, _result) => {
if (err) {
reject(err);
} else {
this.redisdb.hget(`${redisPreKey}:nodes`, instance.instanceID, (err, result) => {
if (err) {
reject(err);
} else {
resolve(Utils.safeJSONParse(result));
}
});
}
});
});
}
/**
* @name _tryAPIRequest
* @summary Attempt an API request to a hydra service.
* @description
* @param {array} instanceList - array of service instance objects
* @param {object} parsedRoute - parsed route
* @param {object} umfmsg - UMF message
* @param {function} resolve - promise resolve function
* @param {function} reject - promise reject function
* @param {object} sendOpts - serverResponse.send options
* @return {undefined}
*/
_tryAPIRequest(instanceList, parsedRoute, umfmsg, resolve, reject, sendOpts) {
let instance;
if (parsedRoute) {
for (let i = 0; i < instanceList.length; i++) {
if (instanceList[i].instanceID === parsedRoute.instance) {
instance = instanceList[i];
break;
}
}
}
instance = instance || instanceList[0];
this.redisdb.get(`${redisPreKey}:${instance.serviceName}:${instance.instanceID}:presence`, (err, _result) => {
if (err) {
this.emit('metric', `service:unavailable|${instance.serviceName}|${instance.instanceID}|presence:not:found`);
reject(err);
} else {
this.redisdb.hget(`${redisPreKey}:nodes`, instance.instanceID, (err, result) => {
if (err) {
this.emit('metric', `service:unavailable|${instance.serviceName}|${instance.instanceID}|instance:not:found`);
reject(err);
} else {
instance = Utils.safeJSONParse(result);
let options = {
host: instance.ip,
port: instance.port,
path: parsedRoute.apiRoute,
method: parsedRoute.httpMethod.toUpperCase()
};
let preHeaders = {};
if (options.method === 'POST' || options.method === 'PUT' || options.method === 'PATCH') {
preHeaders['content-type'] = 'application/json';
}
options.headers = Object.assign(preHeaders, umfmsg.headers);
if (umfmsg.authorization) {
options.headers.Authorization = umfmsg.authorization;
}
if (umfmsg.timeout) {
options.timeout = umfmsg.timeout;
}
options.body = Utils.safeJSONStringify(umfmsg.body);
serverRequest.send(Object.assign(options, sendOpts))
.then((res) => {
if (res.payLoad && res.headers['content-type'] && res.headers['content-type'].indexOf('json') > -1) {
res = Object.assign(res, Utils.safeJSONParse(res.payLoad.toString('utf8')));
delete res.payLoad;
}
console.timeEnd('Time from reception to response in _makeAPIRequest.');
resolve(serverResponse.createResponseObject(res.statusCode, res));
})
.catch((err) => {
instanceList.shift();
if (instanceList.length === 0) {
this.emit('metric', `service:unavailable|${instance.serviceName}|${instance.instanceID}|${err.message}`);
this.emit('metric', `service:unavailable|${instance.serviceName}|${instance.instanceID}|attempts:exhausted`);
console.timeEnd('Time from reception to response in _makeAPIRequest.');
resolve(this._createServerResponseWithReason(ServerResponse.HTTP_SERVICE_UNAVAILABLE, `An instance of ${instance.serviceName} is unavailable`));
} else {
this.emit('metric', `service:unavailable|${instance.serviceName}|${instance.instanceID}|${err.message}`);
this._tryAPIRequest(instanceList, parsedRoute, umfmsg, resolve, reject, sendOpts);
}
});
}
});
}
});
}
/**
* @name _makeAPIRequest
* @summary Makes an API request to a hydra service.
* @description If the service isn't present and the message object has its
* message.body.fallbackToQueue value set to true, then the
* message will be sent to the services message queue.
* @param {object} message - UMF formatted message
* @param {object} sendOpts - serverResponse.send options
* @return {promise} promise - response from API in resolved promise or
* error in rejected promise.
*/
_makeAPIRequest(message, sendOpts = { }) {
return new Promise((resolve, reject) => {
console.time('Time from reception to response in _makeAPIRequest.');
let umfmsg = UMFMessage.createMessage(message);
if (!umfmsg.validate()) {
resolve(this._createServerResponseWithReason(ServerResponse.HTTP_BAD_REQUEST, UMF_INVALID_MESSAGE));
return;
}
let parsedRoute = UMFMessage.parseRoute(umfmsg.to);
if (parsedRoute.error) {
resolve(this._createServerResponseWithReason(ServerResponse.HTTP_BAD_REQUEST, parsedRoute.error));
return;
}
if (!parsedRoute.httpMethod) {
resolve(this._createServerResponseWithReason(ServerResponse.HTTP_BAD_REQUEST, 'HTTP method not specified in `to` field'));
return;
}
if (parsedRoute.apiRoute === '') {
resolve(this._createServerResponseWithReason(ServerResponse.HTTP_BAD_REQUEST, 'message `to` field does not specify a valid route'));
return;
}
console.time('_makeAPIRequest presence');
this._getServicePresence(parsedRoute.serviceName)
.then((instances) => {
if (instances.length === 0) {
this.emit('metric', `service:unavailable|${parsedRoute.serviceName}`);
resolve(this._createServerResponseWithReason(ServerResponse.HTTP_SERVICE_UNAVAILABLE, `Unavailable ${parsedRoute.serviceName} instances`));
return;
}
console.timeEnd('_makeAPIRequest presence');
this._tryAPIRequest(instances, parsedRoute, umfmsg, resolve, reject, sendOpts);
return 0;
})
.catch((err) => {
resolve(this._createServerResponseWithReason(ServerResponse.HTTP_SERVER_ERROR, err.message));
});
});
}
/**
* @name _sendMessageThroughChannel
* @summary Sends a message to a Redis pubsub channel
* @param {string} channel - channel name
* @param {object} message - UMF formatted message object
* @return {undefined}
*/
_sendMessageThroughChannel(channel, message) {
let messageChannel;
let chash = Utils.stringHash(channel);
if (this.messageChannelPool[chash]) {
messageChannel = this.messageChannelPool[chash];
} else {
messageChannel = this.redisdb.duplicate();
this.messageChannelPool[chash] = messageChannel;
}
if (messageChannel) {
let msg = UMFMessage.createMessage(message);
let strMessage = Utils.safeJSONStringify(msg.toShort());
messageChannel.publish(channel, strMessage);
}
}
/**
* @name sendMessage
* @summary Sends a message to an instances of a hydra service.
* @param {object} message - UMF formatted message object
* @return {object} promise - resolved promise if sent or
* HTTP error in resolve() if something bad happened
*/
_sendMessage(message) {
return new Promise((resolve, _reject) => {
let {
serviceName,
instance
} = UMFMessage.parseRoute(message.to);
this._getServicePresence(serviceName)
.then((instances) => {
if (instances.length === 0) {
let msg = `Unavailable ${serviceName} instances`;
this._logMessage('error', msg);
resolve(this._createServerResponseWithReason(ServerResponse.HTTP_SERVICE_UNAVAILABLE, msg));
return;
}
// Did the user specify a specific service instance to use?
if (instance && instance !== '') {
// Make sure supplied instance actually exists in the array
let found = instances.filter((entry) => entry.instanceID === instance);
if (found.length > 0) {
this._sendMessageThroughChannel(`${mcMessageKey}:${serviceName}:${instance}`, message);
} else {
let msg = `Unavailable ${serviceName} instance named ${instance}`;
this._logMessage('error', msg);
resolve(this._createServerResponseWithReason(ServerResponse.HTTP_SERVICE_UNAVAILABLE, msg));
return;
}
} else {
// Send to a random service. It's random beause currently _getServicePresence()
// returns a shuffled array.
let serviceInstance = instances[0];
this._sendMessageThroughChannel(`${mcMessageKey}:${serviceName}:${serviceInstance.instanceID}`, message);
}
resolve();
})
.catch((err) => {
let msg = err.message;
this._logMessage('error', msg);
resolve(this._createServerResponseWithReason(ServerResponse.HTTP_SERVER_ERROR, msg));
});
});
}
/**
* @name _sendReplyMessage
* @summary Sends a reply message based on the original message received.
* @param {object} originalMessage - UMF formatted message object
* @param {object} messageResponse - UMF formatt