@jjavery/worker-pool
Version:
A worker pool for Node.js applications
411 lines • 15.3 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.UnexpectedExitError = exports.WorkerError = exports.NotReadyError = exports.NotStartedError = void 0;
const events_1 = __importDefault(require("events"));
const path_1 = __importDefault(require("path"));
const os_1 = __importDefault(require("os"));
const debug_1 = require("debug");
const worker_1 = __importDefault(require("./worker"));
const debug = (0, debug_1.debug)('worker-pool');
const debug_call = debug.extend('call');
const DEFAULT_MAX = Math.max(1, Math.min(os_1.default.cpus().length - 1, 3));
const DEFAULT_IDLE_TIMEOUT = 10000;
const DEFAULT_STOP_TIMEOUT = 10000;
const DEFAULT_STOP_SIGNAL = 'SIGTERM';
const DEFAULT_STRATEGY = 'fewest';
const DEFAULT_FULL = 10;
/**
* Thrown when the worker pool is not started
*/
class NotStartedError extends Error {
constructor() {
super('This worker pool has not been started');
}
}
exports.NotStartedError = NotStartedError;
var worker_2 = require("./worker");
Object.defineProperty(exports, "NotReadyError", { enumerable: true, get: function () { return worker_2.NotReadyError; } });
Object.defineProperty(exports, "WorkerError", { enumerable: true, get: function () { return worker_2.WorkerError; } });
Object.defineProperty(exports, "UnexpectedExitError", { enumerable: true, get: function () { return worker_2.UnexpectedExitError; } });
/**
* Provides a load-balancing and (optionally) auto-scaling pool of worker
* processes and the ability to request for worker processes to import modules,
* call their exported functions, and reply with their return values and thrown
* exceptions. Load balancing and auto-scaling are configurable via min/max
* limits, strategies, and timeouts.
* @extends EventEmitter
*/
class WorkerPool extends events_1.default {
/**
* @example
*
* const workerPool = new WorkerPool(
* cwd: `${versionPath}/workers`,
* args: [ '--verbose' ],
* env: { TOKEN: token },
* min: 1,
* max: 4,
* idleTimeout: 30000,
* stopTimeout: 1000,
* stopSignal: 'SIGINT'
* strategy: 'fill',
* full: 100
* });
*
* @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.min=0 - The minimum number of worker processes in the pool
* @param {number} options.max=3 - The maximum number of worker processes in the pool
* @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 a 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 {'fewest'|'fill'|'round-robin'|'random'} options.strategy='fewest' - The strategy to use when routing calls to workers
* @param {number} options.full=10 - The number of requests per worker used by the 'fill' strategy
* @param {boolean} options.start=true - Whether to automatically start this worker pool
*/
constructor({ cwd, args, env, min = 0, max = DEFAULT_MAX, idleTimeout = DEFAULT_IDLE_TIMEOUT, stopTimeout = DEFAULT_STOP_TIMEOUT, stopSignal = DEFAULT_STOP_SIGNAL, strategy = DEFAULT_STRATEGY, full = DEFAULT_FULL, start = true } = {}) {
super();
this._workers = [];
this._stopping = [];
this._round = 0;
if (min > max) {
max = min;
}
this._cwd = cwd;
this._args = args;
this._env = env;
this._min = min;
this._max = max;
this._idleTimeout = idleTimeout;
this._stopTimeout = stopTimeout;
this._stopSignal = stopSignal;
this._strategy = strategy;
this._full = full;
if (start) {
this.start().catch((err) => {
this.emit('error', err);
});
}
}
/**
* The current working directory for worker processes. Takes effect after start/recycle.
*
* @example
*
* workerPool.cwd = `${versionPath}/workers`;
*
* await workerPool.recycle();
*
* @type {string}
*/
get cwd() {
return this._cwd;
}
set cwd(value) {
this._cwd = value;
}
/**
* Arguments to pass to worker processes. Takes effect after start/recycle.
*
* @example
*
* workerPool.args = [ '--verbose' ];
*
* await workerPool.recycle();
*
* @type {string[]}
*/
get args() {
return this._args;
}
set args(value) {
this._args = value;
}
/**
* Environmental variables to set for worker processes. Takes effect after start/recycle.
*
* @example
*
* workerPool.env = { TOKEN: newToken };
*
* await workerPool.recycle();
*
* @type {Object}
*/
get env() {
return this._env;
}
set env(value) {
this._env = value;
}
/**
* True if the worker pool is stopping
* @type {boolean}
*/
get isStopping() {
return this._stopping.length > 0;
}
/**
* True if the worker pool has stopped
* @type {boolean}
*/
get isStopped() {
return this._stopping.length === 0 && this._workers.length === 0;
}
/**
* True if the worker pool has started
* @type {boolean}
*/
get isStarted() {
return this._workers.length > 0;
}
/**
* Gets the current number of worker processes
* @returns {Number} The current number of worker processes
*/
getProcessCount() {
return this._workers.reduce((count, worker) => (worker.pid != null ? ++count : count), 0);
}
/**
* Starts the worker pool
* @returns {Promise}
* @resolves When the worker pool has started
* @rejects {WorkerPool.NotReadyError | Error} When an error has been thrown
*/
async start() {
if (this.isStarted) {
return;
}
debug('Starting worker pool');
this._createWorkers();
return this._startMinWorkers().then(() => {
debug('Worker pool started');
this.emit('start');
});
}
/**
* Stops the worker pool, gracefully shutting down each worker process
* @returns {Promise}
* @resolves When the worker pool has stopped
* @rejects {Error} When an error has been thrown
*/
async stop() {
debug('Stopping worker pool');
const workers = this._workers;
this._workers = [];
return this._stop(workers).then(() => {
debug('Worker pool stopped');
this.emit('stop');
});
}
/**
* Recycle the worker pool, gracefully shutting down existing worker processes
* and starting up new worker processes
* @returns {Promise}
* @resolves When the worker pool has recycled
* @rejects {WorkerPool.NotReadyError | Error} When an error has been thrown
*/
async recycle() {
debug('Recycling worker pool');
const oldWorkers = this._workers;
// Create a new set of workers
this._createWorkers();
// Stop the old workers and start the minimum number of new workers
return Promise.all([this._stop(oldWorkers), this._startMinWorkers()]).then(() => {
debug('Worker pool recycled');
this.emit('recycle');
});
}
_createWorkers() {
this._workers = [];
const workers = this._workers;
for (let i = 0, max = this._max; i < max; ++i) {
const worker = new worker_1.default({
args: this._args,
cwd: this._cwd,
env: this._env,
idleTimeout: this._idleTimeout,
stopTimeout: this._stopTimeout,
stopSignal: this._stopSignal,
stopWhenIdle: () => this._stopWhenIdle()
});
workers.push(worker);
}
}
async _startMinWorkers() {
const workers = this._workers;
const min = this._min;
const startWorkers = workers.slice(0, min).map((worker) => worker.start());
return Promise.all(startWorkers);
}
async _stop(workers) {
const stopping = this._stopping;
stopping.push(...workers);
const stopAllWorkers = workers.map((worker) => worker.stop());
await Promise.all(stopAllWorkers).then(() => {
for (var worker of workers) {
stopping.splice(stopping.indexOf(worker), 1);
}
});
}
/**
* Routes a request to a worker in the pool asking it to import a module and call a function with the provided arguments.
*
* **Note**: WorkerPool#call() uses JSON serialization to communicate with worker processes, so only types/objects that can survive JSON.stringify()/JSON.parse() will be passed through unchanged.
*
* @example
*
* const result = await workerPool.call('user-module', 'hashPassword', password, salt);
*
* @param {string} modulePath - The module path for the worker process to import
* @param {string} functionName - The name of a function expored by the imported module
* @param {...any} args - Arguments to pass when calling the function
* @returns {Promise}
* @resolves {any} The return value of the function call when the call returns
* @rejects {WorkerPool.UnexpectedExitError | WorkerPool.WorkerError | Error} When an error has been thrown
*/
async call(modulePath, functionName, ...args) {
const resolvedModulePath = this._resolve(modulePath);
return this._call(resolvedModulePath, functionName, args);
}
/**
* Creates a proxy function that will call WorkerPool#call() with the provided module path, function name, and arguments. Provided as a convenience and minor performance improvement as the modulePath will only be resolved when creating the proxy, rather than with each call.
*
* **Note**: WorkerPool#proxy() uses JSON serialization to communicate with worker processes, so only types/objects that can survive JSON.stringify()/JSON.parse() will be passed through unchanged.
*
* @example
*
* const hashPassword = workerPool.proxy('user-module', 'hashPassword');
*
* const hashedPassword = await hashPassword(password, salt);
*
* @param {string} modulePath - The module path for the worker process to import
* @param {string} functionName - The name of a function expored by the imported module
* @returns {Function} A function that calls WorkerPool#call() with the provided modulePath, functionName, and args, and returns its Promise
*/
proxy(modulePath, functionName) {
const resolvedModulePath = this._resolve(modulePath);
return async (...args) => {
return this._call(resolvedModulePath, functionName, args);
};
}
async _call(resolvedModulePath, functionName, args) {
debug_call('Calling module "%s" function "%s" with args %j', resolvedModulePath, functionName, args);
const worker = this._getWorker();
const result = await worker.request({
modulePath: resolvedModulePath,
functionName,
args
});
return result;
}
_resolve(modulePath) {
var _a;
if (/^(\/|.\/|..\/)/.test(modulePath)) {
const filename = (_a = module === null || module === void 0 ? void 0 : module.parent) === null || _a === void 0 ? void 0 : _a.filename;
if (filename != null) {
const dirname = path_1.default.dirname(filename);
modulePath = path_1.default.resolve(dirname, modulePath);
}
}
return modulePath;
}
_getWorker() {
if (!this.isStarted) {
throw new NotStartedError();
}
switch (this._strategy) {
case 'fewest':
return this._fewestStrategy();
case 'fill':
return this._fillStrategy();
case 'round-robin':
return this._roundRobinStrategy();
case 'random':
return this._randomStrategy();
default:
throw new Error(`Unknown strategy "${this._strategy}"`);
}
}
/**
* Return the worker with the fewest number of waiting requests, favoring
* workers that are already started
* @private
*/
_fewestStrategy() {
const workers = this._workers;
let worker = workers[0];
for (let i = 1, len = workers.length; i < len; ++i) {
const candidate = workers[i];
if (candidate.waiting < worker.waiting ||
(candidate.waiting === 0 && candidate.isStarted && !worker.isStarted)) {
worker = candidate;
}
}
return worker;
}
/**
* Return the first worker that is not full, or if they are all full, the
* worker with the fewest number of queued requests. This does not prevent
* workers from overfilling. It will fill each worker before moving on to
* the next, and will fall back to the "fewest" strategy when all workers
* are full.
* @private
*/
_fillStrategy() {
const workers = this._workers;
const full = this._full;
let worker = workers[0];
let fewest = worker;
for (let i = 1, len = workers.length; i < len; ++i) {
const candidate = workers[i];
const candidateWaiting = candidate.waiting;
if (candidateWaiting >= worker.waiting && candidateWaiting < full) {
worker = workers[i];
}
if (candidateWaiting < fewest.waiting) {
fewest = candidate;
}
}
if (worker.waiting >= full) {
worker = fewest;
}
return worker;
}
/**
* Return the next worker in the sequence
* @private
*/
_roundRobinStrategy() {
const workers = this._workers;
let round = this._round++;
if (round >= workers.length) {
round = this._round = 0;
}
return workers[round];
}
/**
* Return a random worker
* @private
*/
_randomStrategy() {
const workers = this._workers;
const random = Math.floor(Math.random() * workers.length);
return workers[random];
}
_stopWhenIdle() {
const min = this._min;
if (min === 0) {
return true;
}
const count = this.getProcessCount();
return count > min;
}
}
exports.default = WorkerPool;
//# sourceMappingURL=worker-pool.js.map