@xylabs/threads
Version:
Web workers & worker threads as simple as a function call
301 lines (296 loc) • 10.2 kB
JavaScript
// src/master/pool-browser.ts
import DebugLogger from "debug";
import {
multicast,
Observable,
Subject
} from "observable-fns";
// src/master/implementation.browser.ts
var defaultPoolSize = typeof navigator !== "undefined" && navigator.hardwareConcurrency ? navigator.hardwareConcurrency : 4;
// src/master/pool-types.ts
var PoolEventType = /* @__PURE__ */ ((PoolEventType2) => {
PoolEventType2["initialized"] = "initialized";
PoolEventType2["taskCanceled"] = "taskCanceled";
PoolEventType2["taskCompleted"] = "taskCompleted";
PoolEventType2["taskFailed"] = "taskFailed";
PoolEventType2["taskQueued"] = "taskQueued";
PoolEventType2["taskQueueDrained"] = "taskQueueDrained";
PoolEventType2["taskStart"] = "taskStart";
PoolEventType2["terminated"] = "terminated";
return PoolEventType2;
})(PoolEventType || {});
// src/symbols.ts
var $errors = /* @__PURE__ */ Symbol("thread.errors");
var $events = /* @__PURE__ */ Symbol("thread.events");
var $terminate = /* @__PURE__ */ Symbol("thread.terminate");
// src/master/thread.ts
function fail(message) {
throw new Error(message);
}
var Thread = {
/** Return an observable that can be used to subscribe to all errors happening in the thread. */
errors(thread) {
return thread[$errors] || fail("Error observable not found. Make sure to pass a thread instance as returned by the spawn() promise.");
},
/** Return an observable that can be used to subscribe to internal events happening in the thread. Useful for debugging. */
events(thread) {
return thread[$events] || fail("Events observable not found. Make sure to pass a thread instance as returned by the spawn() promise.");
},
/** Terminate a thread. Remember to terminate every thread when you are done using it. */
terminate(thread) {
return thread[$terminate]();
}
};
// src/master/pool-browser.ts
var nextPoolID = 1;
function createArray(size) {
const array = [];
for (let index = 0; index < size; index++) {
array.push(index);
}
return array;
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function flatMap(array, mapper) {
return array.reduce((flattened, element) => [...flattened, ...mapper(element)], []);
}
function slugify(text) {
return text.replaceAll(/\W/g, " ").trim().replaceAll(/\s+/g, "-");
}
function spawnWorkers(spawnWorker, count) {
return createArray(count).map(
() => ({
init: spawnWorker(),
runningTasks: []
})
);
}
var WorkerPool = class {
static EventType = PoolEventType;
debug;
eventObservable;
options;
workers;
eventSubject = new Subject();
initErrors = [];
isClosing = false;
nextTaskID = 1;
taskQueue = [];
constructor(spawnWorker, optionsOrSize) {
const options = typeof optionsOrSize === "number" ? { size: optionsOrSize } : optionsOrSize || {};
const { size = defaultPoolSize } = options;
this.debug = DebugLogger(`threads:pool:${slugify(options.name || String(nextPoolID++))}`);
this.options = options;
this.workers = spawnWorkers(spawnWorker, size);
this.eventObservable = multicast(Observable.from(this.eventSubject));
Promise.all(this.workers.map((worker) => worker.init)).then(
() => this.eventSubject.next({
size: this.workers.length,
type: "initialized" /* initialized */
}),
(error) => {
this.debug("Error while initializing pool worker:", error);
this.eventSubject.error(error);
this.initErrors.push(error);
}
);
}
findIdlingWorker() {
const { concurrency = 1 } = this.options;
return this.workers.find((worker) => worker.runningTasks.length < concurrency);
}
async runPoolTask(worker, task) {
const workerID = this.workers.indexOf(worker) + 1;
this.debug(`Running task #${task.id} on worker #${workerID}...`);
this.eventSubject.next({
taskID: task.id,
type: "taskStart" /* taskStart */,
workerID
});
try {
const returnValue = await task.run(await worker.init);
this.debug(`Task #${task.id} completed successfully`);
this.eventSubject.next({
returnValue,
taskID: task.id,
type: "taskCompleted" /* taskCompleted */,
workerID
});
} catch (ex) {
const error = ex;
this.debug(`Task #${task.id} failed`);
this.eventSubject.next({
error,
taskID: task.id,
type: "taskFailed" /* taskFailed */,
workerID
});
}
}
run(worker, task) {
const runPromise = (async () => {
const removeTaskFromWorkersRunningTasks = () => {
worker.runningTasks = worker.runningTasks.filter((someRunPromise) => someRunPromise !== runPromise);
};
await delay(0);
try {
await this.runPoolTask(worker, task);
} finally {
removeTaskFromWorkersRunningTasks();
if (!this.isClosing) {
this.scheduleWork();
}
}
})();
worker.runningTasks.push(runPromise);
}
scheduleWork() {
this.debug("Attempt de-queueing a task in order to run it...");
const availableWorker = this.findIdlingWorker();
if (!availableWorker) return;
const nextTask = this.taskQueue.shift();
if (!nextTask) {
this.debug("Task queue is empty");
this.eventSubject.next({ type: "taskQueueDrained" /* taskQueueDrained */ });
return;
}
this.run(availableWorker, nextTask);
}
taskCompletion(taskID) {
return new Promise((resolve, reject) => {
const eventSubscription = this.events().subscribe((event) => {
if (event.type === "taskCompleted" /* taskCompleted */ && event.taskID === taskID) {
eventSubscription.unsubscribe();
resolve(event.returnValue);
} else if (event.type === "taskFailed" /* taskFailed */ && event.taskID === taskID) {
eventSubscription.unsubscribe();
reject(event.error);
} else if (event.type === "terminated" /* terminated */) {
eventSubscription.unsubscribe();
reject(new Error("Pool has been terminated before task was run."));
}
});
});
}
async settled(allowResolvingImmediately = false) {
const getCurrentlyRunningTasks = () => flatMap(this.workers, (worker) => worker.runningTasks);
const taskFailures = [];
const failureSubscription = this.eventObservable.subscribe((event) => {
if (event.type === "taskFailed" /* taskFailed */) {
taskFailures.push(event.error);
}
});
if (this.initErrors.length > 0) {
throw this.initErrors[0];
}
if (allowResolvingImmediately && this.taskQueue.length === 0) {
await Promise.allSettled(getCurrentlyRunningTasks());
return taskFailures;
}
await new Promise((resolve, reject) => {
const subscription = this.eventObservable.subscribe({
error: reject,
next(event) {
if (event.type === "taskQueueDrained" /* taskQueueDrained */) {
subscription.unsubscribe();
resolve(void 0);
}
}
// make a pool-wide error reject the completed() result promise
});
});
await Promise.allSettled(getCurrentlyRunningTasks());
failureSubscription.unsubscribe();
return taskFailures;
}
async completed(allowResolvingImmediately = false) {
const settlementPromise = this.settled(allowResolvingImmediately);
const earlyExitPromise = new Promise((resolve, reject) => {
const subscription = this.eventObservable.subscribe({
error: reject,
next(event) {
if (event.type === "taskQueueDrained" /* taskQueueDrained */) {
subscription.unsubscribe();
resolve(settlementPromise);
} else if (event.type === "taskFailed" /* taskFailed */) {
subscription.unsubscribe();
reject(event.error);
}
}
// make a pool-wide error reject the completed() result promise
});
});
const errors = await Promise.race([settlementPromise, earlyExitPromise]);
if (errors.length > 0) {
throw errors[0];
}
}
events() {
return this.eventObservable;
}
queue(taskFunction) {
const { maxQueuedJobs = Number.POSITIVE_INFINITY } = this.options;
if (this.isClosing) {
throw new Error("Cannot schedule pool tasks after terminate() has been called.");
}
if (this.initErrors.length > 0) {
throw this.initErrors[0];
}
const taskID = this.nextTaskID++;
const taskCompletion = this.taskCompletion(taskID);
taskCompletion.catch((error) => {
this.debug(`Task #${taskID} errored:`, error);
});
const task = {
cancel: () => {
if (!this.taskQueue.includes(task)) return;
this.taskQueue = this.taskQueue.filter((someTask) => someTask !== task);
this.eventSubject.next({
taskID: task.id,
type: "taskCanceled" /* taskCanceled */
});
},
id: taskID,
run: taskFunction,
then: taskCompletion.then.bind(taskCompletion)
};
if (this.taskQueue.length >= maxQueuedJobs) {
throw new Error(
"Maximum number of pool tasks queued. Refusing to queue another one.\nThis usually happens for one of two reasons: We are either at peak workload right now or some tasks just won't finish, thus blocking the pool."
);
}
this.debug(`Queueing task #${task.id}...`);
this.taskQueue.push(task);
this.eventSubject.next({
taskID: task.id,
type: "taskQueued" /* taskQueued */
});
this.scheduleWork();
return task;
}
async terminate(force) {
this.isClosing = true;
if (!force) {
await this.completed(true);
}
this.eventSubject.next({
remainingQueue: [...this.taskQueue],
type: "terminated" /* terminated */
});
this.eventSubject.complete();
await Promise.all(this.workers.map(async (worker) => Thread.terminate(await worker.init)));
}
};
function PoolConstructor(spawnWorker, optionsOrSize) {
return new WorkerPool(spawnWorker, optionsOrSize);
}
PoolConstructor.EventType = PoolEventType;
var Pool = PoolConstructor;
export {
Pool,
PoolEventType,
Thread
};
//# sourceMappingURL=pool-browser.mjs.map