worktank
Version:
A simple isomorphic library for executing functions inside WebWorkers or Node Threads pools.
224 lines (223 loc) • 9.19 kB
JavaScript
/* IMPORT */
import concurrency from 'isoconcurrency';
import { setInterval, clearInterval, unrefInterval } from 'isotimer';
import makeNakedPromise from 'promise-make-naked';
import Worker from './worker/index.js';
import WorkerError from './worker/error.js';
/* HELPERS */
const clearIntervalRegistry = new FinalizationRegistry(clearInterval);
/* MAIN */
class WorkTank {
/* CONSTRUCTOR */
constructor(options) {
/* HELPERS */
this.getTaskIdle = () => {
for (const task of this.tasksIdle) {
return task;
}
};
this.getWorkerBootloader = (env, methods) => {
if (methods instanceof URL) { // URL object to import
return this.getWorkerBootloader(env, methods.href);
}
else if (typeof methods === 'string') { // URL string to import, or raw bootloader, useful for complex and/or bundled workers
if (/^(file|https?):\/\//.test(methods)) { // URL string to import
const registerEnv = `WorkTankWorkerBackend.registerEnv ( ${JSON.stringify(env)} );`;
const registerMethods = `WorkTankWorkerBackend.registerMethods ( Methods );`;
const ready = 'WorkTankWorkerBackend.ready ();';
const bootloader = `${'import'} ( '${methods}' ).then ( Methods => { \n${registerEnv}\n\n${registerMethods}\n\n${ready}\n } );`;
return bootloader;
}
else { // Raw bootloader
return methods;
}
}
else { // Serializable methods
const registerEnv = `WorkTankWorkerBackend.registerEnv ( ${JSON.stringify(env)} );`;
const serializedMethods = `{ ${Object.keys(methods).map(name => `${name}: ${methods[name].toString()}`).join(',')} }`;
const registerMethods = `WorkTankWorkerBackend.registerMethods ( ${serializedMethods} );`;
const ready = 'WorkTankWorkerBackend.ready ();';
const bootloader = `${registerEnv}\n\n${registerMethods}\n\n${ready}`;
return bootloader;
}
};
this.getWorkerIdle = () => {
for (const worker of this.workersIdle) {
return worker;
}
if (this.workersBusy.size < this.size) {
return this.getWorkerIdleNew();
}
};
this.getWorkerIdleNew = () => {
const name = this.getWorkerName();
const worker = new Worker(name, this.bootloader);
this.workersIdle.add(worker);
return worker;
};
this.getWorkerName = () => {
if (this.size < 2)
return this.name;
const counter = 1 + (this.workersBusy.size + this.workersIdle.size);
return `${this.name} (${counter})`;
};
/* API */
this.cleanup = () => {
if (this.autoTerminate <= 0)
return;
const autoterminateTimestamp = Date.now() - this.autoTerminate;
for (const worker of this.workersIdle) {
if (worker.ready && !worker.busy && worker.timestamp < autoterminateTimestamp) {
worker.terminate();
this.workersIdle.delete(worker);
}
}
};
this.exec = (method, args, options) => {
const { promise, resolve, reject } = makeNakedPromise();
const signal = options?.signal;
const timeout = options?.timeout ?? this.autoAbort;
const transfer = options?.transfer;
const task = { method, args, signal, timeout, transfer, promise, resolve, reject };
this.tasksIdle.add(task);
this.tick();
return promise;
};
this.proxy = () => {
return new Proxy({}, {
get: (_, method) => {
if (method === 'then')
return; //UGLY: Hacky limitation, because a wrapping Promise will lookup this property
return (...args) => {
return this.exec(method, args);
};
}
});
};
this.resize = (size) => {
this.size = size;
/* TO INSTANTIATE */
if (this.autoInstantiate) {
const missingNr = Math.max(0, this.size - this.workersBusy.size - this.workersIdle.size);
for (let i = 0, l = missingNr; i < l; i++) {
this.getWorkerIdleNew();
}
}
/* TO TERMINATE */
const excessNr = Math.max(0, this.workersIdle.size - this.size);
for (let i = 0, l = excessNr; i < l; i++) {
for (const worker of this.workersIdle) {
this.workersIdle.delete(worker);
worker.terminate();
break;
}
}
/* WORK LOOP */
this.tick();
};
this.stats = () => {
return {
tasks: {
busy: this.tasksBusy.size,
idle: this.tasksIdle.size,
total: this.tasksBusy.size + this.tasksIdle.size
},
workers: {
busy: this.workersBusy.size,
idle: this.workersIdle.size,
total: this.workersBusy.size + this.workersIdle.size
}
};
};
this.terminate = () => {
/* TERMINATING TASKS */
const error = new WorkerError(this.name, 'Terminated');
for (const task of this.tasksBusy)
task.reject(error);
for (const task of this.tasksIdle)
task.reject(error);
this.tasksBusy = new Set();
this.tasksIdle = new Set();
/* TERMINATING WORKERS */
for (const worker of this.workersBusy)
worker.terminate();
for (const worker of this.workersIdle)
worker.terminate();
this.workersBusy = new Set();
this.workersIdle = new Set();
};
this.tick = () => {
/* GETTING TASK */
const task = this.getTaskIdle();
if (!task)
return;
/* SIGNAL - ABORTED */
if (task.signal?.aborted) {
this.tasksIdle.delete(task);
task.reject(new WorkerError(this.name, 'Terminated'));
return this.tick();
}
/* GETTING WORKER */
const worker = this.getWorkerIdle();
if (!worker)
return;
/* SETTING UP TASK & WORKER */
this.tasksIdle.delete(task);
this.tasksBusy.add(task);
this.workersIdle.delete(worker);
this.workersBusy.add(worker);
/* SIGNAL - ABORTABLE */
if (task.signal) {
task.signal.addEventListener('abort', worker.terminate, { once: true });
}
/* TIMEOUT */
let timeoutId;
if (task.timeout > 0 && task.timeout !== Infinity) {
timeoutId = setTimeout(worker.terminate, task.timeout);
}
/* CLEAN UP */
const onFinally = () => {
clearTimeout(timeoutId);
this.tasksBusy.delete(task);
this.workersBusy.delete(worker);
if (!worker.terminated) {
if (this.workersIdle.size < this.size) { // Still needed
this.workersIdle.add(worker);
}
else { // No longer needed
worker.terminate();
}
}
this.tick();
};
task.promise.then(onFinally, onFinally);
/* EXECUTING */
worker.exec(task);
/* WORK LOOP */
this.tick();
};
this.name = options.pool?.name ?? 'WorkTank';
this.size = options.pool?.size ?? concurrency;
this.env = { ...globalThis.process?.env, ...options.worker.env };
this.bootloader = this.getWorkerBootloader(this.env, options.worker.methods);
this.autoAbort = options.worker.autoAbort ?? 0;
this.autoInstantiate = options.worker.autoInstantiate ?? false;
this.autoTerminate = options.worker.autoTerminate ?? 0;
this.tasksBusy = new Set();
this.tasksIdle = new Set();
this.workersBusy = new Set();
this.workersIdle = new Set();
this.resize(this.size);
if (this.autoTerminate) {
const thizRef = new WeakRef(this);
const intervalId = setInterval(() => {
thizRef.deref()?.cleanup();
}, this.autoTerminate);
unrefInterval(intervalId);
clearIntervalRegistry.register(this, intervalId);
}
}
}
/* EXPORT */
export default WorkTank;
export { WorkerError };