UNPKG

@geia/cluster-institute

Version:

Fork cluster with configs

349 lines (275 loc) 10 kB
import { by } from '@geia/by'; import { DISCONNECT, EXIT, UNCAUGHT_EXCEPTION, ERROR } from '@geia/enum-events'; import { MASTER, WORKER } from '@geia/enum-roles'; import { INFO } from '@spare/enum-loggers'; import { decoFlat } from '@spare/logger'; import { says } from '@spare/says'; import { nullish } from '@typen/nullish'; import { dateTime } from '@valjoux/timestamp-pretty'; import cluster from 'cluster'; import os from 'os'; const UNEXPECTED_EXIT = 'unexpectedExit'; const REACH_REFORK_LIMIT = 'reachReforkLimit'; const DISABLE_REFORK = 'disableRefork'; const GENE = 'gene'; /** * * @param {cluster.Worker} worker * @return {string} */ const workerExitKey = worker => worker.hasOwnProperty('exitedAfterDisconnect') ? 'exitedAfterDisconnect' : 'suicide'; const defer = global.setImmediate || process.nextTick; /** * @typedef {Object} ForkConfig * @typedef {string} [ForkConfig.exec] file path to worker file * @typedef {number} [ForkConfig.count] worker num, default is `os.cpus().length - 1` * @typedef {string[]} [ForkConfig.args] string arguments passed to worker * @typedef {Object} [ForkConfig.env] key/value pairs to add to worker process environment * @typedef {string[]} [ForkConfig.execArgv] list of string arguments passed to the Node.js executable. * @typedef {boolean} [ForkConfig.silent] whether or not to send output to parent's stdio, default is `false` * @typedef {boolean} [ForkConfig.refork] refork when disconnect and unexpected exit, default is `true` * @typedef {boolean} [ForkConfig.autoCoverage] auto fork with istanbul when `running_under_istanbul` env set, default is `false` * @typedef {boolean} [ForkConfig.windowsHide] hide the forked processes console window that would normally be created on Windows systems. Default: false. * @typedef {Array} [ForkConfig.slaves] slave processes * @typedef {number} [ForkConfig.limit] limit of number of reforks within duration time * @typedef {number} [ForkConfig.duration] duration to apply ForkConfig.limit. if running time expires duration, ForkConfig.limit becomes invalid. */ /** * A factory to fork cluster worker, inspired by cfork */ class Institute { /** @type {string} */ name = by(process, MASTER); /** @type {*} */ logger = says[this.name].attach(dateTime); /** @type {number} */ count; /** @type {boolean} */ refork; /** @type {number} */ limit; /** @type {number} */ duration; /** @type {Array} */ reforks = []; /** @type {Object} */ attachedEnv; /** @type {Object} */ disconnects = {}; /** @type {number} */ disconnectCount = 0; /** @type {number} */ unexpectedCount = 0; // 1 min /** * @param {ForkConfig} [p] */ constructor(p = {}) { if (cluster.isWorker) { return void 0; } this.count = p.count ?? (os.cpus().length - 1 || 1); this.refork = p.refork ?? true; this.limit = p.limit || 60; this.duration = p.duration || 60000; this.attachedEnv = p.env || {}; if (p.exec) { const settings = { exec: p.exec }; if (!nullish(p.execArgv)) { settings.execArgv = p.execArgv; } if (!nullish(p.args)) { settings.args = p.args; } if (!nullish(p.silent)) { settings.silent = p.silent; } if (!nullish(p.windowsHide)) { settings.windowsHide = p.windowsHide; } if (p.autoCoverage && process.env.running_under_istanbul) { var _settings$args; // Multiple Process under istanbul // https://github.com/gotwarlost/istanbul#multiple-process-usage // use coverage for forked process // disabled reporting and output for child process // enable pid in child process coverage filename const args = ['cover', '--report', 'none', '--print', 'none', '--include-pid', settings.exec]; if (settings !== null && settings !== void 0 && (_settings$args = settings.args) !== null && _settings$args !== void 0 && _settings$args.length) { args.push('--', ...settings.args); } settings.exec = './node_modules/.bin/istanbul'; settings.args = args; } cluster.setupMaster(settings); } cluster.on(DISCONNECT, this.onDisconnect.bind(this)); cluster.on(EXIT, this.onExit.bind(this)); // defer to set the listeners, so that these listeners can be customized defer(() => { if (!process.listeners(UNCAUGHT_EXCEPTION).length) { process.on(UNCAUGHT_EXCEPTION, this.onUncaughtException.bind(this)); } if (!cluster.listeners(UNEXPECTED_EXIT).length) { cluster.on(UNEXPECTED_EXIT, this.onUnexpectedExit.bind(this)); } if (!cluster.listeners(REACH_REFORK_LIMIT).length) { cluster.on(REACH_REFORK_LIMIT, this.onReachReforkLimit.bind(this)); } }); for (let i = 0; i < this.count; i++) { this.graduate(); } // fork slaves after workers are forked if (p.slaves) { const slaves = Array.isArray(p.slaves) ? p.slaves : [p.slaves]; for (const settings of slaves.map(this.normalizeSlaveConfig)) if (settings) { this.graduate({ settings }); } } } /** * @param {ForkConfig} p * @return {Institute} */ static build(p) { return new Institute(p); } /** @return {module:cluster.Cluster} */ getCluster() { return cluster; } graduate({ env = this.attachedEnv, settings, gene } = {}) { if (settings) cluster.setupMaster(settings); const worker = cluster.fork(env); worker[GENE] = gene ?? settings ?? cluster.settings; return worker; } /** allow refork */ get allowRefork() { if (!this.refork) { return false; } if (this.reforks.push(Date.now()) > this.limit) { this.reforks.shift(); } return this.withinForkLimit || (cluster.emit(REACH_REFORK_LIMIT), false); } get withinForkLimit() { const { reforks } = this, span = reforks[reforks.length - 1] - reforks[0]; return reforks.length < this.limit || span > this.duration; } onDisconnect(worker) { const logger = this.logger.level(worker[DISABLE_REFORK] ? INFO : ERROR); const W = by(worker, WORKER); this.disconnectCount++; logger(`${W} disconnects (${decoFlat(this.exitInfo({ worker }))})`); if (worker !== null && worker !== void 0 && worker.isDead()) { // worker has terminated before disconnect return void logger(`not forking, because ${W} exit event emits before disconnect`); } if (worker[DISABLE_REFORK]) { // worker has terminated by master, like egg-cluster master will set disableRefork to true return void logger(`not forking, because ${W} will be killed soon`); } this.disconnects[worker.process.pid] = dateTime(); if (!this.allowRefork) { logger(`not forking new worker (refork: ${this.refork})`); } else { const newWorker = this.graduate({ settings: worker[GENE] }); logger(`${dateTime()} new ${by(newWorker, WORKER)} fork (state: ${newWorker.state})`); } } onExit(worker, code, signal) { const logger = this.logger.level(worker[DISABLE_REFORK] ? INFO : ERROR); const W = by(worker, WORKER); const isExpected = !!this.disconnects[worker.process.pid]; const info = this.exitInfo({ worker, code, signal }); logger(`${W} exit (${decoFlat(info)}) isExpected (${isExpected})`); if (isExpected) { return void delete this.disconnects[worker.process.pid]; } // worker disconnect first, exit expected if (worker[DISABLE_REFORK]) { return void 0; } // worker is killed by master this.unexpectedCount++; if (!this.allowRefork) { logger(`not forking new worker (refork: ${this.refork})`); } else { const newWorker = this.graduate({ settings: worker[GENE] }); logger(`new ${by(newWorker, WORKER)} fork (state: ${newWorker.state})`); } cluster.emit(UNEXPECTED_EXIT, worker, code, signal); } onUncaughtException(err) { var _ref, _ref2, _ref3; // uncaughtException default handler if (!err) { return; } _ref = `master uncaughtException: ${err.stack}`, this.logger.level(ERROR)(_ref); _ref2 = `[error] ${err}`, this.logger(_ref2); _ref3 = `(total ${this.disconnectCount} disconnect, ${this.unexpectedCount} unexpected exit)`, this.logger(_ref3); } onUnexpectedExit(worker, code, signal) { var _worker; // unexpectedExit default handler const exitCode = worker.process.exitCode; const exitKey = (_worker = worker, workerExitKey(_worker)); const err = new Error(`${by(worker, WORKER)} died unexpectedly (code: ${exitCode}, signal: ${signal}, ${exitKey}: ${worker[exitKey]}, state: ${worker.state})`); err.name = 'WorkerDiedUnexpectedError'; this.logger.level(ERROR)(`(total ${this.disconnectCount} disconnect, ${this.unexpectedCount} unexpected exit) ${err.stack}`); } onReachReforkLimit() { // reachReforkLimit default handler this.logger.level(ERROR)(`worker died too fast (total ${this.disconnectCount} disconnect, ${this.unexpectedCount} unexpected exit)`); } exitInfo({ worker, code, signal } = {}) { var _worker2; const exitKey = (_worker2 = worker, workerExitKey(_worker2)); const info = {}; if (!nullish(code)) info.code = code; if (!nullish(signal)) info.signal = signal; if (!nullish(exitKey)) info[exitKey] = worker[exitKey]; Object.assign(info, { state: worker.state, isDead: worker.isDead && worker.isDead(), worker: { disableRefork: worker.disableRefork } }); return info; } normalizeSlaveConfig(opt) { /** normalize slave config */ if (typeof opt === 'string') { opt = { exec: opt }; } // exec path return opt.exec ? opt : null; } } export { Institute };