@kitmi/jacaranda
Version:
JavaScript application framework
204 lines (166 loc) • 6.25 kB
JavaScript
import { Worker } from 'node:worker_threads';
import { InvalidConfiguration } from '@kitmi/types';
import Feature from '../Feature';
import Deque from 'collections/deque';
const recreateWorkerError = (sourceError) => {
const error = new Error(sourceError.message);
for (const [key, value] of Object.entries(sourceError)) {
if (key !== 'message') {
error[key] = value;
}
}
return error;
};
let poolIdCounter = 0;
export class WorkerPool {
constructor(app, options) {
const { name, workerFile, lowThreadNum, highThreadNum, workerOptions } = options;
this.app = app;
this.workerFile = workerFile;
this.lowThreadNum = lowThreadNum;
this.highThreadNum = highThreadNum;
this.workerOptions = workerOptions;
this.poolId = poolIdCounter++;
this.name = name ?? `${this.app.name}_tp${this.poolId}`;
this.taskIdCounter = 0;
this.tasks = new Map();
this.idleWorkers = new Deque();
this.busyWorkers = new Map();
if (lowThreadNum > 0) {
for (let i = 0; i < lowThreadNum; i++) {
this.createWorker(true);
}
}
}
createWorker(idle) {
const worker = new Worker(this.workerFile, this.workerOptions);
worker.on('message', (message) => {
if (message.id === '$CALLBACK') {
const { task, payload } = message;
const handler = this.handlers?.[task];
if (handler == null) {
throw new Error(`Unknown callback task "${task}".`);
}
Promise.resolve(handler(payload)).catch(this.app.logError);
} else {
const task = this.tasks.get(message.id);
if (task) {
this.tasks.delete(message.id);
const workerContext = this.busyWorkers.get(worker.threadId);
workerContext.ongoing--;
if (workerContext.ongoing === 0) {
worker.unref();
this.busyWorkers.delete(worker.threadId);
if (this.idleWorkers.length < this.lowThreadNum) {
this.idleWorkers.push(worker);
} else {
worker.terminate();
}
}
if (message.error == null) {
task.resolve(message.value);
} else {
task.reject(recreateWorkerError(message.error));
}
}
}
});
worker.on('error', (error) => {
// Any error here is effectively an equivalent of segfault, and have no scope, so we just throw it on callback level
throw error;
});
if (idle) {
this.idleWorkers.push(worker);
}
return worker;
}
getNextWorker() {
// If we have idle workers, just use them
if (this.idleWorkers.length > 0) {
const worker = this.idleWorkers.shift();
this.busyWorkers.set(worker.threadId, { ongoing: 1, worker });
return worker;
}
// If we have less than highThreadNum workers, create a new one
if (this.busyWorkers.size < this.highThreadNum) {
const worker = this.createWorker();
this.busyWorkers.set(worker.threadId, { ongoing: 1, worker });
return worker;
}
// Otherwise, just use the first worker
const [workerThreadId, workerContext] = this.busyWorkers.entries().next().value;
workerContext.ongoing++;
this.busyWorkers.delete(workerThreadId);
this.busyWorkers.set(workerThreadId, workerContext);
return workerContext.worker;
}
async runTask_(task, payload, transferList) {
return new Promise((resolve, reject) => {
const taskId = this.taskIdCounter++;
this.tasks.set(taskId, { resolve, reject });
const worker = this.getNextWorker();
worker.ref();
worker.postMessage({ id: taskId, task, payload }, transferList);
});
}
setCallbackHandlers(handlers) {
this.handlers = { ...this.handlers, ...handlers };
}
}
/**
* Thread pool to run tasks in parallel
* @module Feature_ThreadPool
*/
export default {
/**
* This feature is loaded at service stage
* @member {string}
*/
stage: Feature.SERVICE,
groupable: true,
packages: ['collections'],
/**
* Load the feature
* @param {App} app - The app module object
* @param {object} [options] - Options for the feature
* @returns {Promise.<void>}
*
*/
load_: async function (app, options, name) {
options = app.featureConfig(
options,
{
schema: {
name: { type: 'text', optional: true },
workerFile: { type: 'text' },
workerOptions: { type: 'object', optional: true },
lowThreadNum: { type: 'integer', default: 0 },
highThreadNum: { type: 'integer', default: 1 },
},
keepUnsanitized: true,
},
name
);
const { lowThreadNum, highThreadNum } = options;
if (highThreadNum < lowThreadNum) {
throw new InvalidConfiguration(
'"highThreadNum" must be greater than or equal to "lowThreadNum".',
app,
`${name}.highThreadNum`
);
}
if (highThreadNum === 0) {
throw new InvalidConfiguration('"highThreadNum" must be greater than 0.', app, `${name}.highThreadNum`);
}
const pool = new WorkerPool(app, options);
app.on('stopping', async () => {
for (const worker of pool.idleWorkers) {
worker.terminate();
}
for (const { worker } of pool.busyWorkers.values()) {
worker.terminate();
}
});
app.registerService(name, pool);
},
};