UNPKG

@jjavery/worker-pool

Version:

A worker pool for Node.js applications

362 lines 14.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.UnexpectedExitError = exports.WorkerError = exports.NotReadyError = void 0; const child_process_1 = require("child_process"); const debug_1 = require("debug"); const serialize_error_1 = require("serialize-error"); const debug = (0, debug_1.debug)('worker-pool:worker'); debug.color = 3; const DEFAULT_IDLE_TIMEOUT = 10000; const DEFAULT_STOP_TIMEOUT = 10000; const DEFAULT_STOP_SIGNAL = 'SIGTERM'; const WORKER_MAIN = `${__dirname}/worker-main`; const CHILD_PROCESS_READY_TIMEOUT = 1000; const HOUSEKEEPING_TIMEOUT = 1000; class NotReadyError extends Error { constructor(options) { super(`Timed out waiting for worker process [${options.pid}] to send ready message`); } } exports.NotReadyError = NotReadyError; class WorkerError extends Error { constructor(err) { const deserialized = (0, serialize_error_1.deserializeError)(err); super(deserialized.message); this.name = deserialized.name; this.stack = deserialized.stack; } } exports.WorkerError = WorkerError; class UnexpectedExitError extends Error { constructor() { super(`Child process exited unexpectedly`); } } exports.UnexpectedExitError = UnexpectedExitError; /** * Responsible for starting and stopping worker processes and handling requests * and replies * @private */ class Worker { /** * @param {object} options={} - Optional parameters * @param {string} options.cwd - The current working directory for worker processes * @param {string[]} options.args - Arguments to pass to worker processes * @param {Object} options.env - Environmental variables to set for worker processes * @param {number} options.idleTimeout=10000 - Milliseconds before an idle worker process will be asked to stop via options.stopSignal * @param {number} options.stopTimeout=10000 - Milliseconds before an idle worker process will receive SIGKILL after it has been asked to stop * @param {'SIGTERM'|'SIGINT'|'SIGHUP'|'SIGKILL'} options.stopSignal='SIGTERM' - Initial signal to send when stopping worker processes * @param {Function} options.stopWhenIdle - The worker will call this function to determine whether to stop an idle worker process * @private */ constructor({ args, cwd, env, idleTimeout = DEFAULT_IDLE_TIMEOUT, stopTimeout = DEFAULT_STOP_TIMEOUT, stopSignal = DEFAULT_STOP_SIGNAL, stopWhenIdle } = {}) { this._id = getNextWorkerID(); this._waiting = 0; this._requests = new Map(); this._serialization = 'json'; this._args = args; this._cwd = cwd; this._env = env; this._idleTimeout = idleTimeout; this._stopTimeout = stopTimeout; this._stopSignal = stopSignal; this._stopWhenIdle = stopWhenIdle; const timer = setInterval(() => { this._housekeeping(); }, HOUSEKEEPING_TIMEOUT); timer.unref(); } get id() { return this._id; } get waiting() { return this._waiting; } get pid() { var _a, _b; return (_b = (_a = this._childProcess) === null || _a === void 0 ? void 0 : _a.pid) !== null && _b !== void 0 ? _b : null; } get isStarted() { return this.pid != null; } /** * Start the worker process * @returns {Promise} * @throws {Worker.NotReadyError} * @protected */ async start() { const destroyingChildProcess = this._destroyingChildProcess; if (destroyingChildProcess) { await destroyingChildProcess; await wait(1); } let childProcess = this._childProcess; // If this worker already has a child process then there's nothing to do if (childProcess != null) { return; } const creatingChildProcess = this._creatingChildProcess; // If this worker is currently creating a child process, return the promise // that will resolve when a child process has been created if (creatingChildProcess != null) { return creatingChildProcess; } // No child process and no promise so create the promise and create // a child process return (this._creatingChildProcess = new Promise((resolve, reject) => { this._createChildProcess() .then((childProcess) => { this._childProcess = childProcess; this._idleTimestamp = new Date().getTime(); this._createTimestamp = new Date().getTime(); this._creatingChildProcess = undefined; resolve(); }) .catch(reject); })); } /** * Stop the worker process * @returns {Promise} * @protected */ async stop() { const creatingChildProcess = this._creatingChildProcess; if (creatingChildProcess) { await creatingChildProcess; await wait(1); } const childProcess = this._childProcess; // If this worker doesn't have a child process then there's nothing to do if (childProcess == null) { return; } this._childProcess = undefined; this._idleTimestamp = undefined; this._createTimestamp = undefined; const destroyingChildProcess = this._destroyingChildProcess; // If this worker is currently destroying a child process, return the promise // that will resolve when a child process has been destroyed if (destroyingChildProcess != null) { return destroyingChildProcess; } return (this._destroyingChildProcess = new Promise((resolve, reject) => { this._destroyChildProcess(childProcess) .then(() => { this._destroyingChildProcess = undefined; resolve(); }) .catch(reject); })); } /** * Send a request to the worker process and wait for and return the result * @param {any} message * @returns {Promise} * @resolves {any} - The result of the function invocation * @rejects {Worker.UnexpectedExitError|Error} * @protected */ async request(message = {}) { await this.start(); const childProcess = this._childProcess; if (childProcess == null) return; this._waiting++; this._idleTimestamp = undefined; const id = getNextRequestID(); const messageToSend = Object.assign({}, message, { id }); let result; try { debug('Sending message to child process [%d]:', childProcess === null || childProcess === void 0 ? void 0 : childProcess.pid); debug('%j', messageToSend); childProcess.send(messageToSend); result = await new Promise((resolve, reject) => { this._requests.set(id, { resolve, reject }); }); } catch (err) { throw err; } finally { this._waiting--; if (this._waiting === 0) { this._idleTimestamp = new Date().getTime(); } } return result; } _handleResponse(message) { var _a; const { id, err, result } = message; debug('Received message from child process [%d]:', (_a = this._childProcess) === null || _a === void 0 ? void 0 : _a.pid); debug('%j', message); const request = this._requests.get(id); if (request == null) return; const { resolve, reject } = request; this._requests.delete(id); if (err != null) { reject(new WorkerError(err)); } else { resolve(result); } } _housekeeping() { const idleTimestamp = this._idleTimestamp; const idleTimeout = this._idleTimeout; const stopWhenIdle = this._stopWhenIdle; if (idleTimestamp != null && new Date().getTime() > idleTimestamp + idleTimeout && (stopWhenIdle == null || stopWhenIdle())) { // TODO: Handle these errors this.stop() .then(() => { }) .catch((err) => { }); } } async _createChildProcess() { const modulePath = WORKER_MAIN; const args = this._args; const cwd = this._cwd; const env = this._env; return new Promise((resolve, reject) => { debug('Creating child process from "%s"', modulePath); const options = { cwd, env, serialization: this._serialization }; // Start a child process const childProcess = (0, child_process_1.fork)(modulePath, args, options); const handleMessage = (message) => { if (message === 'ready') { removeStartupListeners(); this._addListeners(childProcess); debug('Created child process [%d]', childProcess.pid); // this.emit('createChildProcess', childProcess); resolve(childProcess); } }; // Wait for a 'ready' message childProcess.on('message', handleMessage); const handleError = (err) => { removeStartupListeners(); debug('Child process error [%d]', childProcess.pid); debug('%j', err); reject(err); }; // Handle startup error childProcess.once('error', handleError); // Time out waiting for 'ready' message const timer = setTimeout(() => { removeStartupListeners(); reject(new NotReadyError(childProcess)); }, CHILD_PROCESS_READY_TIMEOUT); timer.unref(); function removeStartupListeners() { childProcess.removeListener('message', handleMessage); childProcess.removeListener('error', handleError); clearTimeout(timer); } }); } async _destroyChildProcess(childProcess) { this._removeListeners(childProcess); if (childProcess.exitCode !== null) { debug("Won't destroy child process [%d] because it has already exited", childProcess.pid); return; } return new Promise((resolve, reject) => { debug('Destroying child process [%d]', childProcess.pid); const handleExit = () => { removeShutdownListeners(); debug('Destroyed child process [%d]', childProcess.pid); // this.emit('destroyChildProcess', childProcess); resolve(childProcess); }; childProcess.once('exit', handleExit); const handleError = (err) => { removeShutdownListeners(); debug('Child process error [%d]', childProcess.pid); debug('%j', err); reject(err); }; childProcess.once('error', handleError); const signal = this._stopSignal; let timer; // Don't bother with the timeout if the first signal is SIGKILL if (signal !== 'SIGKILL') { // Set up a timer to send SIGKILL to the child process after the timeout timer = setTimeout(() => { debug('Child process [%d] [%s] timed out; sending SIGKILL', childProcess.pid, signal); childProcess.kill('SIGKILL'); }, this._stopTimeout); // Don't let this timer keep the (parent) process alive timer.unref(); } // Ask the child process to stop childProcess.kill(signal); function removeShutdownListeners() { childProcess.removeListener('exit', handleExit); childProcess.removeListener('error', handleError); // If the child process does exit before the timeout, clear the timer if (timer != null) { clearTimeout(timer); } } }); } _addListeners(childProcess) { const messageListener = (this._messageListener = (message) => this._handleResponse(message)); const exitListener = (this._exitListener = () => this._handleUnexpectedExit()); childProcess.on('message', messageListener); childProcess.once('exit', exitListener); } _removeListeners(childProcess) { if (childProcess == null) return; const messageListener = this._messageListener; const exitListener = this._exitListener; if (messageListener) { childProcess.removeListener('message', messageListener); } if (exitListener) { childProcess.removeListener('exit', exitListener); } this._messageListener = undefined; this._exitListener = undefined; } _handleUnexpectedExit() { const childProcess = this._childProcess; this._childProcess = undefined; this._removeListeners(childProcess); const requests = this._requests; if (requests.size > 0) { debug('Child process [%d] exited unexpectedly', childProcess === null || childProcess === void 0 ? void 0 : childProcess.pid); for (let [id, { reject }] of requests) { requests.delete(id); reject(new UnexpectedExitError()); } } const stopWhenIdle = this._stopWhenIdle; if (stopWhenIdle != null && !stopWhenIdle()) { // TODO: Handle these errors this.start() .then(() => { }) .catch((err) => { }); } } } exports.default = Worker; let workerID = 0; function getNextWorkerID() { return workerID++; } let requestID = 0; function getNextRequestID() { return requestID++; } async function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } //# sourceMappingURL=worker.js.map