xcraft-core-horde
Version:
Xcraft Horde master
851 lines (731 loc) • 22.2 kB
JavaScript
'use strict';
const path = require('path');
const watt = require('gigawatts');
const EventEmitter = require('events');
const {v4: uuidV4} = require('uuid');
const {performance} = require('perf_hooks');
const xEtc = require('xcraft-core-etc');
const Daemon = require('xcraft-core-daemon');
const xTransport = require('xcraft-core-transport');
const xBusClient = require('xcraft-core-busclient');
const {clearInterval} = require('timers');
const {BusClient} = xBusClient;
const _host = require.resolve('xcraft-core-host');
const host = _host.replace(/(.*[\\/]xcraft-core-host[\\/]).*/, (match, dir) =>
path.join(dir, 'bin/host')
);
let _port = 9229;
class Slave extends EventEmitter {
constructor(horde, resp, hordeId, tribe) {
super();
this._resp = resp;
this._horde = horde;
this._hordeId = hordeId;
this._tribe = tribe;
this._totalTribes = 0;
this._routingKey = tribe ? `${hordeId}-${tribe}` : hordeId;
this._name = uuidV4();
this._daemon = null;
this._busClient = null;
this._commands = [];
watt.wrapAll(this);
}
get id() {
return this._daemon ? this._daemon.proc.pid : this._name;
}
get horde() {
return this._hordeId;
}
get routingKey() {
return this._routingKey;
}
get commands() {
return this._commands;
}
get busClient() {
return this._busClient;
}
get isDaemon() {
return !!this._daemon;
}
get isConnected() {
return this._busClient?.isConnected();
}
get lastErrorReason() {
return this._busClient?.lastErrorReason;
}
get tribe() {
return this._tribe;
}
set totalTribes(totalTribes) {
this._totalTribes = totalTribes;
}
get isPassive() {
return this._passive;
}
/**
* Connect to an existing slave.
*
* It's possible to just connect to an existing slave which was not started
* here.
*
* @yields
* @param {object} busConfig - Settings for connecting to the buses.
* @param {Function} next - Watt's callback.
*/
*connect(busConfig, next) {
this._noForwarding = !!busConfig.noForwarding;
this._passive = !!busConfig.passive;
this._busClient = new BusClient(
busConfig,
this._noForwarding ? null : ['*::*']
);
this._busClient.once('commands.registry', next.parallel());
this._busClient.connect('axon', null, next.parallel());
const results = yield next.sync();
this._commands = this._busClient.getCommandsRegistry();
/* NOTE: added because it seems that there is a race but IMHO
* it should be useless because the registry is already received
* via the autoconnect stuff.
*/
if (results[0]?.token) {
this.emit('commands.registry', null, results[0]);
}
this._busClient.on('commands.registry', (_, {token, time}) => {
if (
token === this._busClient.getToken() &&
time === this._busClient.getCommandsRegistryTime()
) {
return;
}
this._commands = this._busClient.getCommandsRegistry();
this.emit('commands.registry', null, {token, time});
});
this._busClient
.on('token.changed', (...args) => {
this.emit('token.changed', ...args);
})
.on('orcname.changed', (...args) => {
this.emit('orcname.changed', ...args);
})
.on('reconnect', () => {
this.emit('reconnect');
})
.on('reconnect attempt', () => {
this.emit('reconnect attempt');
});
if (this._noForwarding) {
return;
}
/* This catchAll is used as proxy. The Xcraft data are not deserialized. */
this._busClient.events.catchAll((topic, msg) => {
if (topic.startsWith('greathall::')) {
return;
}
if (!msg) {
this._resp.log.warn(`undefined message received via ${topic}`);
return;
}
if (msg._xcraftBroadcasted) {
return;
}
const _msg = msg._xcraftRawMessage ? msg._xcraftRawMessage : msg;
let sent = false;
let routingKey;
if (_msg.forwarding && _msg.forwarding.route) {
/* Replace forwarding by usual orcName/lines/broadcast dispatching */
if (_msg.forwarding.route.includes(this._horde.routingKey)) {
delete _msg.forwarding;
} else {
routingKey = _msg.forwarding.route[0];
}
}
/* Routing by an orcName */
let orcName;
const isOrcEvent = topic.endsWith('.orcished');
if (isOrcEvent) {
/* Extract the orcName from previous token, like orcName@uuid */
const topics = topic.split('.');
orcName = topics[topics.length - 2].split('$')[0];
}
const isCmdEvent =
topic.endsWith('.finished') || topic.endsWith('.error');
if (isCmdEvent || isOrcEvent) {
const isNoForwarding = !!_msg.forwarding;
if (isNoForwarding) {
try {
sent = this._horde.fwcast(routingKey, topic, _msg);
} catch (ex) {
this._resp.log.err(
`horde.fwcast has failed for ${topic}: ${
ex.stack || ex.message || ex
}\n... we try to continue by broadcasting`
);
}
} else {
try {
sent = this._horde.unicast(topic, _msg, orcName);
} catch (ex) {
this._resp.log.err(
`horde.unicast has failed for ${topic}: ${
ex.stack || ex.message || ex
}\n... we try to continue by broadcasting`
);
}
}
}
if (
this._passive &&
!isCmdEvent &&
!isOrcEvent &&
!_msg?.data?._xcraftRPC
) {
return;
}
if (!sent) {
if (_msg.forwarding && _msg.forwarding.appId) {
try {
sent = this._horde.fwcast(routingKey, topic, _msg);
} catch (ex) {
this._resp.log.err(
`horde.fwcast has failed for ${topic}: ${
ex.stack || ex.message || ex
}\n... we try to continue by broadcasting`
);
}
}
}
if (!sent) {
try {
_msg._xcraftHorde = this.horde;
this._horde.broadcast(this.id, topic, _msg);
} catch (ex) {
this._resp.log.err(
`horde.broadcast has failed for ${topic}: ${
ex.stack || ex.message || ex
}\n... the message is lost`
);
}
}
}, true);
}
get noForwarding() {
return this._noForwarding;
}
busConfig(pid) {
const xHost = require('xcraft-core-host');
const goblinsApp = xHost.variantId
? `${this.horde}@${xHost.variantId}`
: this.horde;
const goblinsAppPath = xHost.variantId
? `${this.horde}-${xHost.variantId}`
: this.horde;
let prevGoblinsApp;
try {
prevGoblinsApp = process.env.GOBLINS_APP;
process.env.GOBLINS_APP = goblinsApp;
const root = path.join(xHost.appData, xHost.appCompany, goblinsAppPath);
require('xcraft-server/lib/init-env.js').initEtc(
path.resolve(xHost.appConfigPath, '..', goblinsAppPath),
xHost.projectPath,
this.horde
);
return new xEtc.Etc(root, this._resp).load('xcraft-core-bus', pid);
} finally {
process.env.GOBLINS_APP = prevGoblinsApp;
}
}
/**
* Start a new slave.
*
* When the slave is started, then it's connected on the way.
*
* @param {Function} next - Watt's callback.
*/
start(next) {
let appId = this.horde;
const {variantId} = require('xcraft-core-host');
if (variantId) {
appId += `@${variantId}`;
}
if (!Number.isInteger(this._tribe)) {
throw new Error('A slave cannot be started without tribe number');
}
const argv = [`--app=${appId}`, `--tribe=${this._tribe}`];
if (this._totalTribes > 1) {
argv.push(`--total-tribes=${this._totalTribes}`);
}
this._daemon = new Daemon(
this._name,
host,
{
detached: false,
inspectPort: _port++,
argv,
},
true,
this._resp
);
this._daemon.start();
let retries = 0;
/* The settings file is not available immediatly. For connecting to the
* daemon, we must have this file. Then we retry several times (10x5000ms).
*
* FIXME: replace this stuff by an announce.
*/
const interval = setInterval(() => {
let busConfig = null;
try {
busConfig = this.busConfig(this.id);
} catch (ex) {
++retries;
if (retries === 10) {
clearInterval(interval);
next(ex);
}
return;
}
try {
this.connect(busConfig, next);
} catch (ex) {
next(ex);
} finally {
clearInterval(interval);
}
}, 5000);
}
/**
* Stop the slave gracefully.
*
* @yields
* @param {boolean} shutdown - True for killing the server.
* @param {Function} next - Watt's callback.
*/
*stop(shutdown, next) {
if (this._busClient) {
this._busClient.removeAllListeners();
if (shutdown && !this.noForwarding) {
this._busClient.command.send('shutdown');
}
yield this._busClient.stop(next);
}
if (shutdown && this._daemon) {
this._daemon.stop();
}
}
}
class Horde {
constructor(config) {
const {appId, appArgs} = require('xcraft-core-host');
const args = appArgs();
this._xBus = require('xcraft-core-bus');
this._appId = appId;
this._tribe = args.tribe;
this._config = config || xEtc().load('xcraft-core-horde');
this._slaves = new Map();
this._deltaInterval = new Map();
this._tribeDispatcher = false;
this._routingKey = this._tribe
? `${this._appId}-${this._tribe}`
: this._appId;
/* The topology can be expressed by a string or a real JSON object. The
* mais reason is that Inquirer can not work with real object. The string
* is only used in this case.
*/
this._topology = this._config.topology
? typeof this._config.topology === 'string'
? JSON.parse(this._config.topology)
: this._config.topology
: {};
if (args.topology) {
const {modules} = require('xcraft-core-utils');
const topology = JSON.parse(args.topology);
modules.mergeOverloads(this._topology, topology);
}
watt.wrapAll(this);
}
get routingKey() {
return this._routingKey;
}
_commands(full) {
return Object.assign(
{},
...Array.from(this._slaves.values())
.filter((slave) => full || !slave.noForwarding)
.map((slave) => {
return {[slave.routingKey]: slave.commands};
})
);
}
isNoForwarding(hordeId) {
for (const slave of this._slaves.values()) {
if (slave.horde === hordeId) {
return slave.noForwarding;
}
}
}
get isTribeDispatcher() {
return this._tribeDispatcher;
}
get config() {
return this._config;
}
get commands() {
return this._commands(true);
}
get public() {
return this._commands(false);
}
getTribe(routingKey) {
for (const slave of this._slaves.values()) {
if (slave.routingKey === routingKey) {
return slave.tribe;
}
}
return -1;
}
getSlave(routingKey) {
for (const slave of this._slaves.values()) {
if (slave.routingKey === routingKey) {
return slave;
}
}
return -1;
}
get busClient() {
const command = {
send: (routingKey, cmd, msg) => {
const {appId, appArgs} = require('xcraft-core-host');
const {tribe} = appArgs();
const _routingKey = tribe ? `${appId}-${tribe}` : appId;
for (const slave of this._slaves.values()) {
if (slave.routingKey === routingKey) {
if (slave.noForwarding && !msg.forwarding) {
msg.forwarding = {router: 'ee', appId, tribe};
}
if (!msg.route) {
msg.route = [];
}
msg.route.push(_routingKey);
let nice =
msg.arp && msg.arp[msg.orcName] ? msg.arp[msg.orcName].nice : 0;
nice = nice || 0;
const _nice = slave.busClient.getNice();
msg.arp = {
[msg.orcName]: {
token: this._xBus.getToken(),
nice: nice < _nice ? nice : _nice /* Use the higher priority */,
noForwarding: slave.noForwarding,
nodeName: _routingKey,
},
};
msg.router = slave.busClient.command.connectedWith();
/* HACK: remove the flag used to force a cmd explicitly by RPC */
if (msg?.data?._xcraftRPC) {
delete msg.data._xcraftRPC;
}
slave.busClient.command.send(cmd, msg, null, null, {
forceNested: true,
});
return;
}
}
},
};
return {
command,
};
}
/**
* Broadcast a message to the horde.
*
* All slaves (excepted the slave which has sent the message) will receive
* this event. Each server has a special handler 'broadcast' in order
* to be able to send an event on it's bus.
*
* @param {string} hordeId - ID where the message come from.
* @param {string} topic - Message's topic.
* @param {object} msg - The message itself.
*/
broadcast(hordeId, topic, msg) {
xBusClient.getGlobal().events.send(topic, msg);
const {Router} = require('xcraft-core-transport');
let tokens = [];
const lineId = Router.extractLineId(topic);
if (lineId) {
const remotes = Router.getLines().remotes;
for (const [, lines] of remotes) {
if (lines.has(lineId)) {
tokens = lines
.get(lineId)
.keySeq()
.map((entry) => entry.split('$')[1]);
}
}
}
for (const id of this._slaves.keys()) {
if (`${id}` === `${hordeId}`) {
continue;
}
const slave = this._slaves.get(id);
if (
(tokens.length || tokens.size) &&
!tokens.includes(slave.busClient.getToken())
) {
continue;
}
slave.busClient.command.send(`broadcast`, {topic, msg});
}
}
/**
* Forward an event to a specific server.
*
* @param {string} routingKey - The destination horde (appId-tribe).
* @param {string} topic - Message's topic.
* @param {object} msg - The message itself.
* @returns {boolean} True on success.
*/
fwcast(routingKey, topic, msg) {
for (const slave of this._slaves.values()) {
if (slave.routingKey === routingKey) {
slave.busClient.command.send(`broadcast`, {topic, msg});
return true;
}
}
return false;
}
/**
* Unicast an event to a specific server according to a orcName.
*
* @param {string} topic - Message's topic.
* @param {object} msg - The message itself.
* @param {string} [orcName] - A specific orcName
* @returns {boolean} True on success.
*/
unicast(topic, msg, orcName) {
if (!orcName) {
orcName = msg.orcName;
}
/* Retrieve the routers associated to this orc */
const routers = xTransport.Router.getRouters(orcName, 'axon');
if (!routers) {
return false;
}
/* An event can only be sent with a pub router */
const router = routers.pub;
if (!router) {
return false;
}
// FIXME: factorize with patchMessage()
const {token} = xTransport.Router.getRoute(orcName, 'axon');
msg.token = token;
router.send(topic, msg); // FIXME: select the right socket
return true;
}
*_loadSingle(resp, horde) {
/* tribe 0 (main) */
const slave = new Slave(this, resp, horde, 0);
let tribes = 0;
if (!this._topology[horde]) {
const busConfig = slave.busConfig(0);
if (busConfig.tribes) {
tribes = busConfig.tribes.length;
}
}
slave.totalTribes = tribes + 1;
yield this.add(slave, horde, null);
for (let tribe = 1; tribe <= tribes; ++tribe) {
const slave = new Slave(this, resp, horde, tribe);
slave.totalTribes = tribes + 1;
yield this.add(slave, horde, null);
}
}
*_loadTribes(resp, horde) {
const {appArgs} = require('xcraft-core-host');
const tribe = appArgs().tribe || 0;
const busConfig = require('xcraft-core-etc')().read('xcraft-core-bus');
if (!busConfig.tribes || !busConfig.tribes.length) {
throw new Error('no tribes configured in core-bus');
}
const tribes = [
{
commanderPort: busConfig.commanderPort,
notifierPort: busConfig.notifierPort,
tribe: 0,
},
...busConfig.tribes.map((cfg, idx) => {
return {...cfg, tribe: idx + 1};
}),
];
tribes.splice(tribe, 1);
for (const tribeCfg of tribes) {
const _busConfig = {
...busConfig,
...this._topology[horde],
...tribeCfg,
};
const slave = new Slave(this, resp, horde, tribeCfg.tribe);
slave.totalTribes = tribes.length + 1;
yield this.add(slave, horde, _busConfig);
}
}
/**
* Try to load the whole hordes accordingly to the topology.
*
* @yields
* @param {*} resp - Response object for working with the buses.
*/
*autoload(resp) {
if (!this._config.hordes || !this._config.hordes.length) {
return;
}
for (const horde of this._config.hordes) {
if (this._topology[horde] && this._topology[horde].tribes) {
if (this._appId === horde) {
this._tribeDispatcher = true;
}
yield this._loadTribes(resp, horde);
} else {
yield this._loadSingle(resp, horde);
}
}
}
_useOverlay() {
return this._config.connection.useOverlay;
}
*add(slave, horde, busConfig, next) {
const def = horde;
if (!busConfig) {
busConfig = this._topology[def];
}
const xBus = require('xcraft-core-bus');
slave
.on('commands.registry', () => {
xBus.notifyCmdsRegistry();
})
.on('token.changed', (...args) => {
xBus.notifyTokenChanged(...args);
})
.on('orcname.changed', (...args) => {
xBus.notifyOrcnameChanged(...args);
})
.on('reconnect', () => {
xBus.notifyReconnect('done');
})
.on('reconnect attempt', () => {
xBus.notifyReconnect('attempt');
});
if (busConfig) {
busConfig.hordeId = horde;
let lag = true;
let prevPerf = 0;
let reason = null;
const deltaInterval = setInterval(() => {
if (!slave.isConnected && lag) {
if (reason !== slave.busClient.lastErrorReason) {
reason = slave.busClient.lastErrorReason;
const payload = {horde, noSocket: true, reason};
xBusClient.getGlobal().events.send('greathall::<perf>', payload);
}
return;
}
let noSocket = false;
let overlay = false;
let lastPerf = slave.busClient.events.lastPerf();
if (lastPerf < 0) {
lastPerf = prevPerf;
noSocket = true;
overlay = this._useOverlay();
reason = slave.busClient.lastErrorReason;
} else {
prevPerf = lastPerf;
reason = null;
}
const newPerf = performance.now();
const delta = newPerf - lastPerf;
if (delta < 1000 && lag === true) {
/* Everything is working fine; fired only one time */
const payload = {horde, delta, lag: false, overlay, noSocket, reason};
xBusClient.getGlobal().events.send('greathall::<perf>', payload);
lag = false;
} else if (delta >= 1000 && delta < 10000) {
/* Show lag without overlay */
const payload = {horde, delta, lag: true, overlay, noSocket, reason};
xBusClient.getGlobal().events.send('greathall::<perf>', payload);
lag = true;
} else if (delta >= 10000) {
/* Show overlay */
overlay = this._useOverlay();
const payload = {horde, delta, lag: true, overlay, noSocket, reason};
xBusClient.getGlobal().events.send('greathall::<perf>', payload);
if (
delta >= 20000 &&
process.env.NODE_ENV !== 'development' &&
!busConfig.optimistLag
) {
/* Destroy socket and prepare for the restart */
slave.busClient.destroyPushSocket();
}
lag = true;
}
}, 1000);
this._deltaInterval.set(slave.id, deltaInterval);
const promise = slave.connect(busConfig);
if (!busConfig.passive) {
yield promise;
}
} else {
yield slave.start(next);
}
this._slaves.set(slave.id, slave);
xBus.notifyCmdsRegistry();
return slave.id;
}
_deleteDeltaInterval(id) {
if (!this._deltaInterval.has(id)) {
return;
}
clearInterval(this._deltaInterval.get(id));
this._deltaInterval.delete(id);
}
_deleteDeltaIntervals() {
for (const deltaInterval of this._deltaInterval.values()) {
clearInterval(deltaInterval);
}
this._deltaInterval.clear();
}
*remove(id, resp) {
if (!this._slaves.has(id)) {
resp.log.warn(`slave ${id} is not alive`);
return;
}
this._deleteDeltaInterval(id);
const slave = this._slaves.get(id);
slave.removeAllListeners();
yield slave.stop(false);
this._slaves.delete(id);
}
*stop(all, next) {
if (!next) {
next = all;
all = false;
}
this._deleteDeltaIntervals();
for (const slave of this._slaves.values()) {
slave.removeAllListeners();
slave.stop(all || slave.isDaemon, next.parallel());
}
yield next.sync();
}
*unload(resp, next) {
for (const id of this._slaves.keys()) {
this.remove(id, resp, next.parallel());
}
yield next.sync();
}
getSlaves() {
return Array.from(this._slaves.values()).map((slave) => slave.routingKey);
}
}
module.exports = new Horde();
module.exports.Horde = Horde;