diff --git a/src/vendor/poolifier/circular-buffer.ts b/src/vendor/poolifier/circular-buffer.ts new file mode 100644 index 0000000..cc10818 --- /dev/null +++ b/src/vendor/poolifier/circular-buffer.ts @@ -0,0 +1,104 @@ +/** + * Default buffer size. + */ +export const defaultBufferSize = 2048 + +/** + * Circular buffer designed for positive numbers. + * + * @internal + */ +export class CircularBuffer { + private readIdx: number + private writeIdx: number + private readonly items: Float32Array + private readonly maxArrayIdx: number + public size: number + + /** + * @param size - Buffer size. @defaultValue defaultBufferSize + * @returns CircularBuffer. + */ + constructor(size: number = defaultBufferSize) { + this.checkSize(size) + this.readIdx = 0 + this.writeIdx = 0 + this.maxArrayIdx = size - 1 + this.size = 0 + this.items = new Float32Array(size).fill(-1) + } + + /** + * Checks whether the buffer is empty. + * + * @returns Whether the buffer is empty. + */ + public empty(): boolean { + return this.size === 0 + } + + /** + * Checks whether the buffer is full. + * + * @returns Whether the buffer is full. + */ + public full(): boolean { + return this.size === this.items.length + } + + /** + * Puts number into buffer. + * + * @param number - Number to put into buffer. + */ + public put(number: number): void { + this.items[this.writeIdx] = number + this.writeIdx = this.writeIdx === this.maxArrayIdx ? 0 : this.writeIdx + 1 + if (this.size < this.items.length) { + ++this.size + } + } + + /** + * Gets number from buffer. + * + * @returns Number from buffer. + */ + public get(): number | undefined { + const number = this.items[this.readIdx] + if (number === -1) { + return + } + this.items[this.readIdx] = -1 + this.readIdx = this.readIdx === this.maxArrayIdx ? 0 : this.readIdx + 1 + --this.size + return number + } + + /** + * Returns buffer as numbers' array. + * + * @returns Numbers' array. + */ + public toArray(): number[] { + return Array.from(this.items.filter((item) => item !== -1)) + } + + /** + * Checks the buffer size. + * + * @param size - Buffer size. + */ + private checkSize(size: number): void { + if (!Number.isSafeInteger(size)) { + throw new TypeError( + `Invalid circular buffer size: '${size.toString()}' is not an integer`, + ) + } + if (size < 0) { + throw new RangeError( + `Invalid circular buffer size: ${size.toString()} < 0`, + ) + } + } +} diff --git a/src/vendor/poolifier/mod.ts b/src/vendor/poolifier/mod.ts new file mode 100644 index 0000000..1cebefd --- /dev/null +++ b/src/vendor/poolifier/mod.ts @@ -0,0 +1,75 @@ +export type { AbstractPool } from './pools/abstract-pool.ts' +export { PoolEvents, PoolTypes } from './pools/pool.ts' +export type { + IPool, + PoolEvent, + PoolInfo, + PoolOptions, + PoolType, + TasksQueueOptions, +} from './pools/pool.ts' +export { WorkerTypes } from './pools/worker.ts' +export type { + ErrorEventHandler, + EventLoopUtilizationMeasurementStatistics, + IWorker, + IWorkerNode, + MeasurementStatistics, + MessageEventErrorHandler, + MessageEventHandler, + StrategyData, + TaskStatistics, + WorkerInfo, + WorkerNodeEventDetail, + WorkerNodeOptions, + WorkerType, + WorkerUsage, +} from './pools/worker.ts' +export { + Measurements, + WorkerChoiceStrategies, +} from './pools/selection-strategies/selection-strategies-types.ts' +export type { + IWorkerChoiceStrategy, + Measurement, + MeasurementOptions, + MeasurementStatisticsRequirements, + StrategyPolicy, + TaskStatisticsRequirements, + WorkerChoiceStrategy, + WorkerChoiceStrategyOptions, +} from './pools/selection-strategies/selection-strategies-types.ts' +export type { WorkerChoiceStrategiesContext } from './pools/selection-strategies/worker-choice-strategies-context.ts' +export { DynamicThreadPool } from './pools/thread/dynamic.ts' +export { FixedThreadPool } from './pools/thread/fixed.ts' +export type { ThreadPoolOptions } from './pools/thread/fixed.ts' +export type { AbstractWorker } from './worker/abstract-worker.ts' +export { ThreadWorker } from './worker/thread-worker.ts' +export { KillBehaviors } from './worker/worker-options.ts' +export type { + KillBehavior, + KillHandler, + WorkerOptions, +} from './worker/worker-options.ts' +export type { + TaskAsyncFunction, + TaskFunction, + TaskFunctionObject, + TaskFunctionOperationResult, + TaskFunctions, + TaskSyncFunction, +} from './worker/task-functions.ts' +export type { + MessageValue, + PromiseResponseWrapper, + Task, + TaskFunctionProperties, + TaskPerformance, + WorkerError, + WorkerStatistics, + Writable, +} from './utility-types.ts' +export type { CircularBuffer } from './circular-buffer.ts' +export type { PriorityQueue } from './queues/priority-queue.ts' +export type { FixedQueueNode, IFixedQueue } from './queues/queue-types.ts' +export { availableParallelism } from './utils.ts' diff --git a/src/vendor/poolifier/pools/abstract-pool.ts b/src/vendor/poolifier/pools/abstract-pool.ts new file mode 100644 index 0000000..ad0bb15 --- /dev/null +++ b/src/vendor/poolifier/pools/abstract-pool.ts @@ -0,0 +1,2264 @@ +import { defaultBucketSize } from '../queues/queue-types.ts' +import type { + MessageValue, + PromiseResponseWrapper, + Task, + TaskFunctionProperties, +} from '../utility-types.ts' +import { + average, + buildTaskFunctionProperties, + DEFAULT_TASK_NAME, + EMPTY_FUNCTION, + exponentialDelay, + isKillBehavior, + isPlainObject, + max, + median, + min, + round, + sleep, +} from '../utils.ts' +import type { + TaskFunction, + TaskFunctionObject, +} from '../worker/task-functions.ts' +import { KillBehaviors } from '../worker/worker-options.ts' +import { + type IPool, + PoolEvents, + type PoolInfo, + type PoolOptions, + type PoolType, + PoolTypes, + type TasksQueueOptions, +} from './pool.ts' +import { + Measurements, + WorkerChoiceStrategies, + type WorkerChoiceStrategy, + type WorkerChoiceStrategyOptions, +} from './selection-strategies/selection-strategies-types.ts' +import { WorkerChoiceStrategiesContext } from './selection-strategies/worker-choice-strategies-context.ts' +import { + checkFileURL, + checkValidPriority, + checkValidTasksQueueOptions, + checkValidWorkerChoiceStrategy, + getDefaultTasksQueueOptions, + updateEluWorkerUsage, + updateRunTimeWorkerUsage, + updateTaskStatisticsWorkerUsage, + updateWaitTimeWorkerUsage, + waitWorkerNodeEvents, +} from './utils.ts' +import { version } from './version.ts' +import { WorkerNode } from './worker-node.ts' +import type { + IWorker, + IWorkerNode, + WorkerInfo, + WorkerNodeEventDetail, + WorkerType, +} from './worker.ts' + +/** + * Base class that implements some shared logic for all poolifier pools. + * + * @typeParam Worker - Type of worker which manages this pool. + * @typeParam Data - Type of data sent to the worker. This can only be structured-cloneable data. + * @typeParam Response - Type of execution response. This can only be structured-cloneable data. + */ +export abstract class AbstractPool< + Worker extends IWorker, + Data = unknown, + Response = unknown, +> implements IPool { + /** @inheritDoc */ + public readonly workerNodes: IWorkerNode[] = [] + + /** @inheritDoc */ + public eventTarget?: EventTarget + + /** + * The task execution response promise map: + * - `key`: The message id of each submitted task. + * - `value`: An object that contains task's worker node key, execution response promise resolve and reject callbacks. + * + * When we receive a message from the worker, we get a map entry with the promise resolve/reject bound to the message id. + */ + protected promiseResponseMap: Map< + `${string}-${string}-${string}-${string}-${string}`, + PromiseResponseWrapper + > = new Map< + `${string}-${string}-${string}-${string}-${string}`, + PromiseResponseWrapper + >() + + /** + * Worker choice strategies context referencing worker choice algorithms implementation. + */ + protected workerChoiceStrategiesContext?: WorkerChoiceStrategiesContext< + Worker, + Data, + Response + > + + /** + * The task functions added at runtime map: + * - `key`: The task function name. + * - `value`: The task function object. + */ + private readonly taskFunctions: Map< + string, + TaskFunctionObject + > + + /** + * Whether the pool is started or not. + */ + private started: boolean + /** + * Whether the pool is starting or not. + */ + private starting: boolean + /** + * Whether the pool is destroying or not. + */ + private destroying: boolean + /** + * Whether the minimum number of workers is starting or not. + */ + private startingMinimumNumberOfWorkers: boolean + /** + * Whether the pool ready event has been emitted or not. + */ + private readyEventEmitted: boolean + /** + * Whether the pool back pressure event has been emitted or not. + */ + private backPressureEventEmitted: boolean + /** + * Whether the pool busy event has been emitted or not. + */ + private busyEventEmitted: boolean + /** + * The start timestamp of the pool. + */ + private startTimestamp?: number + + /** + * Constructs a new poolifier pool. + * + * @param minimumNumberOfWorkers - Minimum number of workers that this pool manages. + * @param fileURL - URL to the worker file. + * @param opts - Options for the pool. + * @param maximumNumberOfWorkers - Maximum number of workers that this pool manages. + */ + public constructor( + protected readonly minimumNumberOfWorkers: number, + protected readonly fileURL: URL, + protected readonly opts: PoolOptions, + protected readonly maximumNumberOfWorkers?: number, + ) { + if (!this.isMain()) { + throw new Error( + 'Cannot start a pool from a worker with the same type as the pool', + ) + } + this.checkPoolType() + checkFileURL(this.fileURL) + this.checkMinimumNumberOfWorkers(this.minimumNumberOfWorkers) + this.checkPoolOptions(this.opts) + + this.chooseWorkerNode = this.chooseWorkerNode.bind(this) + this.executeTask = this.executeTask.bind(this) + this.enqueueTask = this.enqueueTask.bind(this) + + if (this.opts.enableEvents === true) { + this.initEventTarget() + } + this.workerChoiceStrategiesContext = new WorkerChoiceStrategiesContext< + Worker, + Data, + Response + >( + this, + [this.opts.workerChoiceStrategy!], + this.opts.workerChoiceStrategyOptions, + ) + + this.setupHook() + + this.taskFunctions = new Map>() + + this.started = false + this.starting = false + this.destroying = false + this.startingMinimumNumberOfWorkers = false + this.readyEventEmitted = false + this.busyEventEmitted = false + this.backPressureEventEmitted = false + if (this.opts.startWorkers === true) { + this.start() + } + } + + private checkPoolType(): void { + if (this.type === PoolTypes.fixed && this.maximumNumberOfWorkers != null) { + throw new Error( + 'Cannot instantiate a fixed pool with a maximum number of workers specified at initialization', + ) + } + } + + private checkMinimumNumberOfWorkers( + minimumNumberOfWorkers: number | undefined, + ): void { + if (minimumNumberOfWorkers == null) { + throw new Error( + 'Cannot instantiate a pool without specifying the number of workers', + ) + } else if (!Number.isSafeInteger(minimumNumberOfWorkers)) { + throw new TypeError( + 'Cannot instantiate a pool with a non safe integer number of workers', + ) + } else if (minimumNumberOfWorkers < 0) { + throw new RangeError( + 'Cannot instantiate a pool with a negative number of workers', + ) + } else if (this.type === PoolTypes.fixed && minimumNumberOfWorkers === 0) { + throw new RangeError('Cannot instantiate a fixed pool with zero worker') + } + } + + private checkPoolOptions(opts: PoolOptions): void { + if (isPlainObject(opts)) { + this.opts.startWorkers = opts.startWorkers ?? true + checkValidWorkerChoiceStrategy(opts.workerChoiceStrategy) + this.opts.workerChoiceStrategy = opts.workerChoiceStrategy ?? + WorkerChoiceStrategies.ROUND_ROBIN + this.checkValidWorkerChoiceStrategyOptions( + opts.workerChoiceStrategyOptions, + ) + if (opts.workerChoiceStrategyOptions != null) { + this.opts.workerChoiceStrategyOptions = opts.workerChoiceStrategyOptions + } + this.opts.restartWorkerOnError = opts.restartWorkerOnError ?? true + this.opts.enableEvents = opts.enableEvents ?? true + this.opts.enableTasksQueue = opts.enableTasksQueue ?? false + if (this.opts.enableTasksQueue) { + checkValidTasksQueueOptions(opts.tasksQueueOptions) + this.opts.tasksQueueOptions = this.buildTasksQueueOptions( + opts.tasksQueueOptions, + ) + } + } else { + throw new TypeError('Invalid pool options: must be a plain object') + } + } + + private checkValidWorkerChoiceStrategyOptions( + workerChoiceStrategyOptions: WorkerChoiceStrategyOptions | undefined, + ): void { + if ( + workerChoiceStrategyOptions != null && + !isPlainObject(workerChoiceStrategyOptions) + ) { + throw new TypeError( + 'Invalid worker choice strategy options: must be a plain object', + ) + } + if ( + workerChoiceStrategyOptions?.weights != null && + Object.keys(workerChoiceStrategyOptions.weights).length !== + (this.maximumNumberOfWorkers ?? this.minimumNumberOfWorkers) + ) { + throw new Error( + 'Invalid worker choice strategy options: must have a weight for each worker node', + ) + } + if ( + workerChoiceStrategyOptions?.measurement != null && + !Object.values(Measurements).includes( + workerChoiceStrategyOptions.measurement, + ) + ) { + throw new Error( + `Invalid worker choice strategy options: invalid measurement '${workerChoiceStrategyOptions.measurement}'`, + ) + } + } + + private initEventTarget(): void { + this.eventTarget = new EventTarget() + } + + /** @inheritDoc */ + public get info(): PoolInfo { + return { + version, + type: this.type, + worker: this.worker, + started: this.started, + ready: this.ready, + defaultStrategy: this.opts.workerChoiceStrategy!, + strategyRetries: this.workerChoiceStrategiesContext?.retriesCount ?? 0, + minSize: this.minimumNumberOfWorkers, + maxSize: this.maximumNumberOfWorkers ?? this.minimumNumberOfWorkers, + ...(this.workerChoiceStrategiesContext?.getTaskStatisticsRequirements() + .runTime.aggregate === true && + this.workerChoiceStrategiesContext.getTaskStatisticsRequirements() + .waitTime.aggregate && + { + utilization: round(this.utilization), + }), + workerNodes: this.workerNodes.length, + ...(this.type === PoolTypes.dynamic && { + dynamicWorkerNodes: this.workerNodes.reduce( + (accumulator, workerNode) => + workerNode.info.dynamic ? accumulator + 1 : accumulator, + 0, + ), + }), + idleWorkerNodes: this.workerNodes.reduce( + (accumulator, _, workerNodeKey) => + this.isWorkerNodeIdle(workerNodeKey) ? accumulator + 1 : accumulator, + 0, + ), + busyWorkerNodes: this.workerNodes.reduce( + (accumulator, _, workerNodeKey) => + this.isWorkerNodeBusy(workerNodeKey) ? accumulator + 1 : accumulator, + 0, + ), + executedTasks: this.workerNodes.reduce( + (accumulator, workerNode) => + accumulator + workerNode.usage.tasks.executed, + 0, + ), + executingTasks: this.workerNodes.reduce( + (accumulator, workerNode) => + accumulator + workerNode.usage.tasks.executing, + 0, + ), + failedTasks: this.workerNodes.reduce( + (accumulator, workerNode) => + accumulator + workerNode.usage.tasks.failed, + 0, + ), + ...(this.opts.enableTasksQueue === true && { + backPressure: this.backPressure, + backPressureWorkerNodes: this.workerNodes.reduce( + (accumulator, _, workerNodeKey) => + this.isWorkerNodeBackPressured(workerNodeKey) + ? accumulator + 1 + : accumulator, + 0, + ), + stealingWorkerNodes: this.workerNodes.reduce( + (accumulator, _, workerNodeKey) => + this.isWorkerNodeStealing(workerNodeKey) + ? accumulator + 1 + : accumulator, + 0, + ), + queuedTasks: this.workerNodes.reduce( + (accumulator, workerNode) => + accumulator + workerNode.usage.tasks.queued, + 0, + ), + maxQueuedTasks: this.workerNodes.reduce( + (accumulator, workerNode) => + accumulator + (workerNode.usage.tasks.maxQueued ?? 0), + 0, + ), + stolenTasks: this.workerNodes.reduce( + (accumulator, workerNode) => + accumulator + workerNode.usage.tasks.stolen, + 0, + ), + }), + ...(this.workerChoiceStrategiesContext?.getTaskStatisticsRequirements() + .runTime.aggregate === true && { + runTime: { + minimum: round( + min( + ...this.workerNodes.map( + (workerNode) => + workerNode.usage.runTime.minimum ?? Number.POSITIVE_INFINITY, + ), + ), + ), + maximum: round( + max( + ...this.workerNodes.map( + (workerNode) => + workerNode.usage.runTime.maximum ?? Number.NEGATIVE_INFINITY, + ), + ), + ), + ...(this.workerChoiceStrategiesContext.getTaskStatisticsRequirements() + .runTime.average && { + average: round( + average( + this.workerNodes.reduce( + (accumulator, workerNode) => + accumulator.concat( + workerNode.usage.runTime.history.toArray(), + ), + [], + ), + ), + ), + }), + ...(this.workerChoiceStrategiesContext.getTaskStatisticsRequirements() + .runTime.median && { + median: round( + median( + this.workerNodes.reduce( + (accumulator, workerNode) => + accumulator.concat( + workerNode.usage.runTime.history.toArray(), + ), + [], + ), + ), + ), + }), + }, + }), + ...(this.workerChoiceStrategiesContext?.getTaskStatisticsRequirements() + .waitTime.aggregate === true && { + waitTime: { + minimum: round( + min( + ...this.workerNodes.map( + (workerNode) => + workerNode.usage.waitTime.minimum ?? Number.POSITIVE_INFINITY, + ), + ), + ), + maximum: round( + max( + ...this.workerNodes.map( + (workerNode) => + workerNode.usage.waitTime.maximum ?? Number.NEGATIVE_INFINITY, + ), + ), + ), + ...(this.workerChoiceStrategiesContext.getTaskStatisticsRequirements() + .waitTime.average && { + average: round( + average( + this.workerNodes.reduce( + (accumulator, workerNode) => + accumulator.concat( + workerNode.usage.waitTime.history.toArray(), + ), + [], + ), + ), + ), + }), + ...(this.workerChoiceStrategiesContext.getTaskStatisticsRequirements() + .waitTime.median && { + median: round( + median( + this.workerNodes.reduce( + (accumulator, workerNode) => + accumulator.concat( + workerNode.usage.waitTime.history.toArray(), + ), + [], + ), + ), + ), + }), + }, + }), + } + } + + /** + * Whether the pool is ready or not. + * + * @returns The pool readiness boolean status. + */ + private get ready(): boolean { + if (!this.started) { + return false + } + return ( + this.workerNodes.reduce( + (accumulator, workerNode) => + !workerNode.info.dynamic && workerNode.info.ready + ? accumulator + 1 + : accumulator, + 0, + ) >= this.minimumNumberOfWorkers + ) + } + + /** + * The approximate pool utilization. + * + * @returns The pool utilization. + */ + private get utilization(): number { + if (this.startTimestamp == null) { + return 0 + } + const poolTimeCapacity = (performance.now() - this.startTimestamp) * + (this.maximumNumberOfWorkers ?? this.minimumNumberOfWorkers) + const totalTasksRunTime = this.workerNodes.reduce( + (accumulator, workerNode) => + accumulator + (workerNode.usage.runTime.aggregate ?? 0), + 0, + ) + const totalTasksWaitTime = this.workerNodes.reduce( + (accumulator, workerNode) => + accumulator + (workerNode.usage.waitTime.aggregate ?? 0), + 0, + ) + return (totalTasksRunTime + totalTasksWaitTime) / poolTimeCapacity + } + + /** + * The pool type. + * + * If it is `'dynamic'`, it provides the `max` property. + */ + protected abstract get type(): PoolType + + /** + * The worker type. + */ + protected abstract get worker(): WorkerType + + /** + * Checks if the worker id sent in the received message from a worker is valid. + * + * @param message - The received message. + * @throws {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error} If the worker id is invalid. + */ + private checkMessageWorkerId(message: MessageValue): void { + if (message.workerId == null) { + throw new Error('Worker message received without worker id') + } else if (this.getWorkerNodeKeyByWorkerId(message.workerId) === -1) { + throw new Error( + `Worker message received from unknown worker '${message.workerId.toString()}': ${ + JSON.stringify( + message, + ) + }`, + ) + } + } + + /** + * Gets the worker node key given its worker id. + * + * @param workerId - The worker id. + * @returns The worker node key if the worker id is found in the pool worker nodes, `-1` otherwise. + */ + private getWorkerNodeKeyByWorkerId(workerId: string | undefined): number { + return this.workerNodes.findIndex( + (workerNode) => workerNode.info.id === workerId, + ) + } + + /** @inheritDoc */ + public setWorkerChoiceStrategy( + workerChoiceStrategy: WorkerChoiceStrategy, + workerChoiceStrategyOptions?: WorkerChoiceStrategyOptions, + ): void { + let requireSync = false + checkValidWorkerChoiceStrategy(workerChoiceStrategy) + if (workerChoiceStrategyOptions != null) { + requireSync = !this.setWorkerChoiceStrategyOptions( + workerChoiceStrategyOptions, + ) + } + if (workerChoiceStrategy !== this.opts.workerChoiceStrategy) { + this.opts.workerChoiceStrategy = workerChoiceStrategy + this.workerChoiceStrategiesContext?.setDefaultWorkerChoiceStrategy( + this.opts.workerChoiceStrategy, + this.opts.workerChoiceStrategyOptions, + ) + requireSync = true + } + if (requireSync) { + this.workerChoiceStrategiesContext?.syncWorkerChoiceStrategies( + this.getWorkerChoiceStrategies(), + this.opts.workerChoiceStrategyOptions, + ) + for (const workerNodeKey of this.workerNodes.keys()) { + this.sendStatisticsMessageToWorker(workerNodeKey) + } + } + } + + /** @inheritDoc */ + public setWorkerChoiceStrategyOptions( + workerChoiceStrategyOptions: WorkerChoiceStrategyOptions | undefined, + ): boolean { + this.checkValidWorkerChoiceStrategyOptions(workerChoiceStrategyOptions) + if (workerChoiceStrategyOptions != null) { + this.opts.workerChoiceStrategyOptions = { + ...this.opts.workerChoiceStrategyOptions, + ...workerChoiceStrategyOptions, + } + this.workerChoiceStrategiesContext?.setOptions( + this.opts.workerChoiceStrategyOptions, + ) + this.workerChoiceStrategiesContext?.syncWorkerChoiceStrategies( + this.getWorkerChoiceStrategies(), + this.opts.workerChoiceStrategyOptions, + ) + for (const workerNodeKey of this.workerNodes.keys()) { + this.sendStatisticsMessageToWorker(workerNodeKey) + } + return true + } + return false + } + + /** @inheritDoc */ + public enableTasksQueue( + enable: boolean, + tasksQueueOptions?: TasksQueueOptions, + ): void { + if (this.opts.enableTasksQueue === true && !enable) { + this.unsetTaskStealing() + this.unsetTasksStealingOnBackPressure() + this.flushTasksQueues() + } + this.opts.enableTasksQueue = enable + this.setTasksQueueOptions(tasksQueueOptions) + } + + /** @inheritDoc */ + public setTasksQueueOptions( + tasksQueueOptions: TasksQueueOptions | undefined, + ): void { + if (this.opts.enableTasksQueue === true) { + checkValidTasksQueueOptions(tasksQueueOptions) + this.opts.tasksQueueOptions = this.buildTasksQueueOptions( + tasksQueueOptions, + ) + this.setTasksQueueSize(this.opts.tasksQueueOptions.size!) + if (this.opts.tasksQueueOptions.taskStealing === true) { + this.unsetTaskStealing() + this.setTaskStealing() + } else { + this.unsetTaskStealing() + } + if (this.opts.tasksQueueOptions.tasksStealingOnBackPressure === true) { + this.unsetTasksStealingOnBackPressure() + this.setTasksStealingOnBackPressure() + } else { + this.unsetTasksStealingOnBackPressure() + } + } else if (this.opts.tasksQueueOptions != null) { + delete this.opts.tasksQueueOptions + } + } + + private buildTasksQueueOptions( + tasksQueueOptions: TasksQueueOptions | undefined, + ): TasksQueueOptions { + return { + ...getDefaultTasksQueueOptions( + this.maximumNumberOfWorkers ?? this.minimumNumberOfWorkers, + ), + ...this.opts.tasksQueueOptions, + ...tasksQueueOptions, + } + } + + private setTasksQueueSize(size: number): void { + for (const workerNode of this.workerNodes) { + workerNode.tasksQueueBackPressureSize = size + } + } + + private setTaskStealing(): void { + for (const workerNodeKey of this.workerNodes.keys()) { + this.workerNodes[workerNodeKey].addEventListener( + 'idle', + this.handleWorkerNodeIdleEvent as EventListener, + ) + } + } + + private unsetTaskStealing(): void { + for (const workerNodeKey of this.workerNodes.keys()) { + this.workerNodes[workerNodeKey].removeEventListener( + 'idle', + this.handleWorkerNodeIdleEvent as EventListener, + ) + } + } + + private setTasksStealingOnBackPressure(): void { + for (const workerNodeKey of this.workerNodes.keys()) { + this.workerNodes[workerNodeKey].addEventListener( + 'backPressure', + this.handleWorkerNodeBackPressureEvent as EventListener, + ) + } + } + + private unsetTasksStealingOnBackPressure(): void { + for (const workerNodeKey of this.workerNodes.keys()) { + this.workerNodes[workerNodeKey].removeEventListener( + 'backPressure', + this.handleWorkerNodeBackPressureEvent as EventListener, + ) + } + } + + /** + * Whether the pool is back pressured or not. + * + * @returns The pool back pressure boolean status. + */ + protected abstract get backPressure(): boolean + + /** + * Whether the pool is busy or not. + * + * @returns The pool busyness boolean status. + */ + protected abstract get busy(): boolean + + /** + * Whether worker nodes are executing concurrently their tasks quota or not. + * + * @returns Worker nodes busyness boolean status. + */ + protected internalBusy(): boolean { + return ( + this.workerNodes.reduce( + (accumulator, _, workerNodeKey) => + this.isWorkerNodeBusy(workerNodeKey) ? accumulator + 1 : accumulator, + 0, + ) === this.workerNodes.length + ) + } + + private isWorkerNodeBackPressured(workerNodeKey: number): boolean { + const workerNode = this.workerNodes[workerNodeKey] + return workerNode.info.ready && workerNode.info.backPressure + } + + private isWorkerNodeBusy(workerNodeKey: number): boolean { + const workerNode = this.workerNodes[workerNodeKey] + if (this.opts.enableTasksQueue === true) { + return ( + workerNode.info.ready && + workerNode.usage.tasks.executing >= + this.opts.tasksQueueOptions!.concurrency! + ) + } + return workerNode.info.ready && workerNode.usage.tasks.executing > 0 + } + + private isWorkerNodeIdle(workerNodeKey: number): boolean { + const workerNode = this.workerNodes[workerNodeKey] + if (this.opts.enableTasksQueue === true) { + return ( + workerNode.info.ready && + workerNode.usage.tasks.executing === 0 && + this.tasksQueueSize(workerNodeKey) === 0 + ) + } + return workerNode.info.ready && workerNode.usage.tasks.executing === 0 + } + + private isWorkerNodeStealing(workerNodeKey: number): boolean { + const workerNode = this.workerNodes[workerNodeKey] + return ( + workerNode.info.ready && + (workerNode.info.continuousStealing || + workerNode.info.backPressureStealing) + ) + } + + private async sendTaskFunctionOperationToWorker( + workerNodeKey: number, + message: MessageValue, + ): Promise { + return await new Promise((resolve, reject) => { + const taskFunctionOperationListener = ( + message: MessageValue, + ): void => { + this.checkMessageWorkerId(message) + const workerId = this.getWorkerInfo(workerNodeKey)?.id + if ( + message.taskFunctionOperationStatus != null && + message.workerId === workerId + ) { + if (message.taskFunctionOperationStatus) { + resolve(true) + } else { + reject( + new Error( + `Task function operation '${message.taskFunctionOperation?.toString()}' failed on worker ${message.workerId?.toString()} with error: '${message.workerError?.message}'`, + ), + ) + } + this.deregisterWorkerMessageListener( + this.getWorkerNodeKeyByWorkerId(message.workerId), + taskFunctionOperationListener, + ) + } + } + this.registerWorkerMessageListener( + workerNodeKey, + taskFunctionOperationListener, + ) + this.sendToWorker(workerNodeKey, message) + }) + } + + private async sendTaskFunctionOperationToWorkers( + message: MessageValue, + ): Promise { + return await new Promise((resolve, reject) => { + const responsesReceived = new Array>() + const taskFunctionOperationsListener = ( + message: MessageValue, + ): void => { + this.checkMessageWorkerId(message) + if (message.taskFunctionOperationStatus != null) { + responsesReceived.push(message) + if (responsesReceived.length === this.workerNodes.length) { + if ( + responsesReceived.every( + (message) => message.taskFunctionOperationStatus === true, + ) + ) { + resolve(true) + } else if ( + responsesReceived.some( + (message) => message.taskFunctionOperationStatus === false, + ) + ) { + const errorResponse = responsesReceived.find( + (response) => response.taskFunctionOperationStatus === false, + ) + reject( + new Error( + `Task function operation '${message.taskFunctionOperation}' failed on worker ${errorResponse?.workerId?.toString()} with error: '${errorResponse?.workerError?.message}'`, + ), + ) + } + this.deregisterWorkerMessageListener( + this.getWorkerNodeKeyByWorkerId(message.workerId), + taskFunctionOperationsListener, + ) + } + } + } + for (const workerNodeKey of this.workerNodes.keys()) { + this.registerWorkerMessageListener( + workerNodeKey, + taskFunctionOperationsListener, + ) + this.sendToWorker(workerNodeKey, message) + } + }) + } + + /** @inheritDoc */ + public hasTaskFunction(name: string): boolean { + return this.listTaskFunctionsProperties().some( + (taskFunctionProperties) => taskFunctionProperties.name === name, + ) + } + + /** @inheritDoc */ + public async addTaskFunction( + name: string, + fn: TaskFunction | TaskFunctionObject, + ): Promise { + if (typeof name !== 'string') { + throw new TypeError('name argument must be a string') + } + if (typeof name === 'string' && name.trim().length === 0) { + throw new TypeError('name argument must not be an empty string') + } + if (typeof fn === 'function') { + fn = { taskFunction: fn } satisfies TaskFunctionObject + } + if (typeof fn.taskFunction !== 'function') { + throw new TypeError('taskFunction property must be a function') + } + checkValidPriority(fn.priority) + checkValidWorkerChoiceStrategy(fn.strategy) + const opResult = await this.sendTaskFunctionOperationToWorkers({ + taskFunctionOperation: 'add', + taskFunctionProperties: buildTaskFunctionProperties(name, fn), + taskFunction: fn.taskFunction.toString(), + }) + this.taskFunctions.set(name, fn) + this.workerChoiceStrategiesContext?.syncWorkerChoiceStrategies( + this.getWorkerChoiceStrategies(), + ) + for (const workerNodeKey of this.workerNodes.keys()) { + this.sendStatisticsMessageToWorker(workerNodeKey) + } + return opResult + } + + /** @inheritDoc */ + public async removeTaskFunction(name: string): Promise { + if (!this.taskFunctions.has(name)) { + throw new Error( + 'Cannot remove a task function not handled on the pool side', + ) + } + const opResult = await this.sendTaskFunctionOperationToWorkers({ + taskFunctionOperation: 'remove', + taskFunctionProperties: buildTaskFunctionProperties( + name, + this.taskFunctions.get(name), + ), + }) + for (const workerNode of this.workerNodes) { + workerNode.deleteTaskFunctionWorkerUsage(name) + } + this.taskFunctions.delete(name) + this.workerChoiceStrategiesContext?.syncWorkerChoiceStrategies( + this.getWorkerChoiceStrategies(), + ) + for (const workerNodeKey of this.workerNodes.keys()) { + this.sendStatisticsMessageToWorker(workerNodeKey) + } + return opResult + } + + /** @inheritDoc */ + public listTaskFunctionsProperties(): TaskFunctionProperties[] { + for (const workerNode of this.workerNodes) { + if ( + Array.isArray(workerNode.info.taskFunctionsProperties) && + workerNode.info.taskFunctionsProperties.length > 0 + ) { + return workerNode.info.taskFunctionsProperties + } + } + return [] + } + + /** + * Gets task function worker choice strategy, if any. + * + * @param name - The task function name. + * @returns The task function worker choice strategy if the task function worker choice strategy is defined, `undefined` otherwise. + */ + private readonly getTaskFunctionWorkerChoiceStrategy = ( + name?: string, + ): WorkerChoiceStrategy | undefined => { + name = name ?? DEFAULT_TASK_NAME + const taskFunctionsProperties = this.listTaskFunctionsProperties() + if (name === DEFAULT_TASK_NAME) { + name = taskFunctionsProperties[1]?.name + } + return taskFunctionsProperties.find( + (taskFunctionProperties: TaskFunctionProperties) => + taskFunctionProperties.name === name, + )?.strategy + } + + /** + * Gets worker node task function worker choice strategy, if any. + * + * @param workerNodeKey - The worker node key. + * @param name - The task function name. + * @returns The worker node task function worker choice strategy if the worker node task function worker choice strategy is defined, `undefined` otherwise. + */ + private readonly getWorkerNodeTaskFunctionWorkerChoiceStrategy = ( + workerNodeKey: number, + name?: string, + ): WorkerChoiceStrategy | undefined => { + const workerInfo = this.getWorkerInfo(workerNodeKey) + if (workerInfo == null) { + return + } + name = name ?? DEFAULT_TASK_NAME + if (name === DEFAULT_TASK_NAME) { + name = workerInfo.taskFunctionsProperties?.[1]?.name + } + return workerInfo.taskFunctionsProperties?.find( + (taskFunctionProperties: TaskFunctionProperties) => + taskFunctionProperties.name === name, + )?.strategy + } + + /** + * Gets worker node task function priority, if any. + * + * @param workerNodeKey - The worker node key. + * @param name - The task function name. + * @returns The worker node task function priority if the worker node task function priority is defined, `undefined` otherwise. + */ + private readonly getWorkerNodeTaskFunctionPriority = ( + workerNodeKey: number, + name?: string, + ): number | undefined => { + const workerInfo = this.getWorkerInfo(workerNodeKey) + if (workerInfo == null) { + return + } + name = name ?? DEFAULT_TASK_NAME + if (name === DEFAULT_TASK_NAME) { + name = workerInfo.taskFunctionsProperties?.[1]?.name + } + return workerInfo.taskFunctionsProperties?.find( + (taskFunctionProperties: TaskFunctionProperties) => + taskFunctionProperties.name === name, + )?.priority + } + + /** + * Gets the worker choice strategies registered in this pool. + * + * @returns The worker choice strategies. + */ + private readonly getWorkerChoiceStrategies = (): Set< + WorkerChoiceStrategy + > => { + return new Set([ + this.opts.workerChoiceStrategy!, + ...(this.listTaskFunctionsProperties() + .map( + (taskFunctionProperties: TaskFunctionProperties) => + taskFunctionProperties.strategy, + ) + .filter( + (strategy: WorkerChoiceStrategy | undefined) => strategy != null, + ) as WorkerChoiceStrategy[]), + ]) + } + + /** @inheritDoc */ + public async setDefaultTaskFunction(name: string): Promise { + return await this.sendTaskFunctionOperationToWorkers({ + taskFunctionOperation: 'default', + taskFunctionProperties: buildTaskFunctionProperties( + name, + this.taskFunctions.get(name), + ), + }) + } + + private shallExecuteTask(workerNodeKey: number): boolean { + return ( + this.tasksQueueSize(workerNodeKey) === 0 && + this.workerNodes[workerNodeKey].usage.tasks.executing < + this.opts.tasksQueueOptions!.concurrency! + ) + } + + public async internalExecute( + data?: Data, + name?: string, + transferList?: Transferable[], + ): Promise { + return await new Promise((resolve, reject) => { + const timestamp = performance.now() + const workerNodeKey = this.chooseWorkerNode(name) + const task: Task = { + name: name ?? DEFAULT_TASK_NAME, + data: data ?? ({} as Data), + priority: this.getWorkerNodeTaskFunctionPriority(workerNodeKey, name), + strategy: this.getWorkerNodeTaskFunctionWorkerChoiceStrategy( + workerNodeKey, + name, + ), + transferList, + timestamp, + taskId: crypto.randomUUID(), + } + this.promiseResponseMap.set(task.taskId!, { + resolve, + reject, + workerNodeKey, + }) + if ( + this.opts.enableTasksQueue === false || + (this.opts.enableTasksQueue === true && + this.shallExecuteTask(workerNodeKey)) + ) { + this.executeTask(workerNodeKey, task) + } else { + this.enqueueTask(workerNodeKey, task) + } + }) + } + + /** @inheritDoc */ + public async execute( + data?: Data, + name?: string, + transferList?: readonly Transferable[], + ): Promise { + if (!this.started) { + throw new Error('Cannot execute a task on not started pool') + } + if (this.destroying) { + throw new Error('Cannot execute a task on destroying pool') + } + if (name != null && typeof name !== 'string') { + throw new TypeError('name argument must be a string') + } + if (name != null && typeof name === 'string' && name.trim().length === 0) { + throw new TypeError('name argument must not be an empty string') + } + if (transferList != null && !Array.isArray(transferList)) { + throw new TypeError('transferList argument must be an array') + } + return await this.internalExecute(data, name, transferList) + } + + /** @inheritDoc */ + public async mapExecute( + data: Iterable, + name?: string, + transferList?: readonly Transferable[], + ): Promise { + if (!this.started) { + throw new Error('Cannot execute task(s) on not started pool') + } + if (this.destroying) { + throw new Error('Cannot execute task(s) on destroying pool') + } + if (data == null) { + throw new TypeError('data argument must be a defined iterable') + } + if (typeof data[Symbol.iterator] !== 'function') { + throw new TypeError('data argument must be an iterable') + } + if (name != null && typeof name !== 'string') { + throw new TypeError('name argument must be a string') + } + if (name != null && typeof name === 'string' && name.trim().length === 0) { + throw new TypeError('name argument must not be an empty string') + } + if (transferList != null && !Array.isArray(transferList)) { + throw new TypeError('transferList argument must be an array') + } + if (!Array.isArray(data)) { + data = [...data] + } + return await Promise.all( + (data as Data[]).map((data) => + this.internalExecute(data, name, transferList) + ), + ) + } + + /** + * Starts the minimum number of workers. + * + * @param initWorkerNodeUsage - Whether to initialize the worker node usage or not. @defaultValue false + */ + private startMinimumNumberOfWorkers(initWorkerNodeUsage = false): void { + if (this.minimumNumberOfWorkers === 0) { + return + } + this.startingMinimumNumberOfWorkers = true + while ( + this.workerNodes.reduce( + (accumulator, workerNode) => + !workerNode.info.dynamic ? accumulator + 1 : accumulator, + 0, + ) < this.minimumNumberOfWorkers + ) { + const workerNodeKey = this.createAndSetupWorkerNode() + initWorkerNodeUsage && + this.initWorkerNodeUsage(this.workerNodes[workerNodeKey]) + } + this.startingMinimumNumberOfWorkers = false + } + + /** @inheritdoc */ + public start(): void { + if (this.started) { + throw new Error('Cannot start an already started pool') + } + if (this.starting) { + throw new Error('Cannot start an already starting pool') + } + if (this.destroying) { + throw new Error('Cannot start a destroying pool') + } + this.starting = true + this.startMinimumNumberOfWorkers() + this.startTimestamp = performance.now() + this.starting = false + this.started = true + } + + /** @inheritDoc */ + public async destroy(): Promise { + if (!this.started) { + throw new Error('Cannot destroy an already destroyed pool') + } + if (this.starting) { + throw new Error('Cannot destroy an starting pool') + } + if (this.destroying) { + throw new Error('Cannot destroy an already destroying pool') + } + this.destroying = true + await Promise.all( + this.workerNodes.map(async (_, workerNodeKey) => { + await this.destroyWorkerNode(workerNodeKey) + }), + ) + if (this.eventTarget != null) { + this.eventTarget.dispatchEvent( + new CustomEvent(PoolEvents.destroy, { detail: this.info }), + ) + this.readyEventEmitted = false + } + delete this.startTimestamp + this.destroying = false + this.started = false + } + + private async sendKillMessageToWorker(workerNodeKey: number): Promise { + await new Promise((resolve, reject) => { + if (this.workerNodes[workerNodeKey] == null) { + resolve() + return + } + const killMessageListener = (message: MessageValue): void => { + this.checkMessageWorkerId(message) + if (message.kill === 'success') { + resolve() + } else if (message.kill === 'failure') { + reject( + new Error( + `Kill message handling failed on worker ${message.workerId?.toString()}`, + ), + ) + } + } + // FIXME: should be registered only once + this.registerWorkerMessageListener(workerNodeKey, killMessageListener) + this.sendToWorker(workerNodeKey, { kill: true }) + }) + } + + /** + * Terminates the worker node given its worker node key. + * + * @param workerNodeKey - The worker node key. + */ + protected async destroyWorkerNode(workerNodeKey: number): Promise { + this.flagWorkerNodeAsNotReady(workerNodeKey) + const flushedTasks = this.flushTasksQueue(workerNodeKey) + const workerNode = this.workerNodes[workerNodeKey] + await waitWorkerNodeEvents( + workerNode, + 'taskFinished', + flushedTasks, + this.opts.tasksQueueOptions?.tasksFinishedTimeout ?? + getDefaultTasksQueueOptions( + this.maximumNumberOfWorkers ?? this.minimumNumberOfWorkers, + ).tasksFinishedTimeout, + ) + await this.sendKillMessageToWorker(workerNodeKey) + workerNode.terminate() + } + + /** + * Setup hook to execute code before worker nodes are created in the abstract constructor. + * Can be overridden. + * + * @virtual + */ + protected setupHook(): void { + /* Intentionally empty */ + } + + /** + * Returns whether the worker is the main worker or not. + * + * @returns `true` if the worker is the main worker, `false` otherwise. + */ + protected abstract isMain(): boolean + + /** + * Hook executed before the worker task execution. + * Can be overridden. + * + * @param workerNodeKey - The worker node key. + * @param task - The task to execute. + */ + protected beforeTaskExecutionHook( + workerNodeKey: number, + task: Task, + ): void { + if (this.workerNodes[workerNodeKey]?.usage != null) { + const workerUsage = this.workerNodes[workerNodeKey].usage + ++workerUsage.tasks.executing + updateWaitTimeWorkerUsage( + this.workerChoiceStrategiesContext, + workerUsage, + task, + ) + } + if ( + this.shallUpdateTaskFunctionWorkerUsage(workerNodeKey) && + this.workerNodes[workerNodeKey].getTaskFunctionWorkerUsage(task.name!) != + null + ) { + const taskFunctionWorkerUsage = this.workerNodes[ + workerNodeKey + ].getTaskFunctionWorkerUsage(task.name!)! + ++taskFunctionWorkerUsage.tasks.executing + updateWaitTimeWorkerUsage( + this.workerChoiceStrategiesContext, + taskFunctionWorkerUsage, + task, + ) + } + } + + /** + * Hook executed after the worker task execution. + * Can be overridden. + * + * @param workerNodeKey - The worker node key. + * @param message - The received message. + */ + protected afterTaskExecutionHook( + workerNodeKey: number, + message: MessageValue, + ): void { + let needWorkerChoiceStrategiesUpdate = false + if (this.workerNodes[workerNodeKey]?.usage != null) { + const workerUsage = this.workerNodes[workerNodeKey].usage + updateTaskStatisticsWorkerUsage(workerUsage, message) + updateRunTimeWorkerUsage( + this.workerChoiceStrategiesContext, + workerUsage, + message, + ) + updateEluWorkerUsage( + this.workerChoiceStrategiesContext, + workerUsage, + message, + ) + needWorkerChoiceStrategiesUpdate = true + } + if ( + this.shallUpdateTaskFunctionWorkerUsage(workerNodeKey) && + message.taskPerformance?.name != null && + this.workerNodes[workerNodeKey].getTaskFunctionWorkerUsage( + message.taskPerformance.name, + ) != null + ) { + const taskFunctionWorkerUsage = this.workerNodes[ + workerNodeKey + ].getTaskFunctionWorkerUsage(message.taskPerformance.name)! + updateTaskStatisticsWorkerUsage(taskFunctionWorkerUsage, message) + updateRunTimeWorkerUsage( + this.workerChoiceStrategiesContext, + taskFunctionWorkerUsage, + message, + ) + updateEluWorkerUsage( + this.workerChoiceStrategiesContext, + taskFunctionWorkerUsage, + message, + ) + needWorkerChoiceStrategiesUpdate = true + } + if (needWorkerChoiceStrategiesUpdate) { + this.workerChoiceStrategiesContext?.update(workerNodeKey) + } + } + + /** + * Whether the worker node shall update its task function worker usage or not. + * + * @param workerNodeKey - The worker node key. + * @returns `true` if the worker node shall update its task function worker usage, `false` otherwise. + */ + private shallUpdateTaskFunctionWorkerUsage(workerNodeKey: number): boolean { + const workerInfo = this.getWorkerInfo(workerNodeKey) + return ( + workerInfo != null && + Array.isArray(workerInfo.taskFunctionsProperties) && + workerInfo.taskFunctionsProperties.length > 2 + ) + } + + /** + * Chooses a worker node for the next task. + * + * @param name - The task function name. + * @returns The chosen worker node key. + */ + private chooseWorkerNode(name?: string): number { + if (this.shallCreateDynamicWorker()) { + const workerNodeKey = this.createAndSetupDynamicWorkerNode() + if ( + this.workerChoiceStrategiesContext?.getPolicy().dynamicWorkerUsage === + true + ) { + return workerNodeKey + } + } + return this.workerChoiceStrategiesContext!.execute( + this.getTaskFunctionWorkerChoiceStrategy(name), + ) + } + + /** + * Conditions for dynamic worker creation. + * + * @returns Whether to create a dynamic worker or not. + */ + protected abstract shallCreateDynamicWorker(): boolean + + /** + * Sends a message to worker given its worker node key. + * + * @param workerNodeKey - The worker node key. + * @param message - The message. + * @param transferList - The optional array of transferable objects. + */ + protected abstract sendToWorker( + workerNodeKey: number, + message: MessageValue, + transferList?: readonly Transferable[], + ): void + + /** + * Initializes the worker node usage with sensible default values gathered during runtime. + * + * @param workerNode - The worker node. + */ + private initWorkerNodeUsage(workerNode: IWorkerNode): void { + if ( + this.workerChoiceStrategiesContext?.getTaskStatisticsRequirements() + .runTime.aggregate === true + ) { + workerNode.usage.runTime.aggregate = min( + ...this.workerNodes.map( + (workerNode) => + workerNode.usage.runTime.aggregate ?? Number.POSITIVE_INFINITY, + ), + ) + } + if ( + this.workerChoiceStrategiesContext?.getTaskStatisticsRequirements() + .waitTime.aggregate === true + ) { + workerNode.usage.waitTime.aggregate = min( + ...this.workerNodes.map( + (workerNode) => + workerNode.usage.waitTime.aggregate ?? Number.POSITIVE_INFINITY, + ), + ) + } + if ( + this.workerChoiceStrategiesContext?.getTaskStatisticsRequirements().elu + .aggregate === true + ) { + workerNode.usage.elu.active.aggregate = min( + ...this.workerNodes.map( + (workerNode) => + workerNode.usage.elu.active.aggregate ?? Number.POSITIVE_INFINITY, + ), + ) + } + } + + /** + * Creates a new, completely set up worker node. + * + * @returns New, completely set up worker node key. + */ + protected createAndSetupWorkerNode(): number { + const workerNode = this.createWorkerNode() + workerNode.worker.onmessage = this.opts.messageEventHandler ?? + EMPTY_FUNCTION + workerNode.worker.onmessageerror = (messageEvent) => { + this.eventTarget?.dispatchEvent( + new ErrorEvent(PoolEvents.messageerror, { error: messageEvent }), + ) + } + workerNode.worker.onerror = (errorEvent) => { + workerNode.info.ready = false + this.eventTarget?.dispatchEvent( + new ErrorEvent(PoolEvents.error, { + message: errorEvent.message, + filename: errorEvent.filename, + lineno: errorEvent.lineno, + colno: errorEvent.colno, + error: errorEvent.error, + }), + ) + if ( + this.started && + !this.destroying && + this.opts.restartWorkerOnError === true + ) { + if (workerNode.info.dynamic) { + this.createAndSetupDynamicWorkerNode() + } else if (!this.startingMinimumNumberOfWorkers) { + this.startMinimumNumberOfWorkers(true) + } + } + if ( + this.started && + !this.destroying && + this.opts.enableTasksQueue === true + ) { + this.redistributeQueuedTasks(this.workerNodes.indexOf(workerNode)) + } + workerNode?.terminate() + } + workerNode.worker.addEventListener( + 'messageerror', + this.opts.messageEventErrorHandler ?? EMPTY_FUNCTION, + ) + workerNode.worker.addEventListener( + 'error', + this.opts.errorEventHandler ?? EMPTY_FUNCTION, + ) + workerNode.addEventListener( + 'exit', + () => { + this.removeWorkerNode(workerNode) + if ( + this.started && + !this.startingMinimumNumberOfWorkers && + !this.destroying + ) { + this.startMinimumNumberOfWorkers(true) + } + }, + { once: true }, + ) + const workerNodeKey = this.addWorkerNode(workerNode) + this.afterWorkerNodeSetup(workerNodeKey) + return workerNodeKey + } + + /** + * Creates a new, completely set up dynamic worker node. + * + * @returns New, completely set up dynamic worker node key. + */ + protected createAndSetupDynamicWorkerNode(): number { + const workerNodeKey = this.createAndSetupWorkerNode() + this.registerWorkerMessageListener(workerNodeKey, (message) => { + this.checkMessageWorkerId(message) + const localWorkerNodeKey = this.getWorkerNodeKeyByWorkerId( + message.workerId, + ) + // Kill message received from worker + if ( + isKillBehavior(KillBehaviors.HARD, message.kill) || + (isKillBehavior(KillBehaviors.SOFT, message.kill) && + this.isWorkerNodeIdle(localWorkerNodeKey) && + !this.isWorkerNodeStealing(localWorkerNodeKey)) + ) { + // Flag the worker node as not ready immediately + this.flagWorkerNodeAsNotReady(localWorkerNodeKey) + this.destroyWorkerNode(localWorkerNodeKey).catch((error) => { + this.eventTarget?.dispatchEvent( + new ErrorEvent(PoolEvents.error, { error }), + ) + }) + } + }) + this.sendToWorker(workerNodeKey, { + checkActive: true, + }) + if (this.taskFunctions.size > 0) { + for (const [taskFunctionName, taskFunctionObject] of this.taskFunctions) { + this.sendTaskFunctionOperationToWorker(workerNodeKey, { + taskFunctionOperation: 'add', + taskFunctionProperties: buildTaskFunctionProperties( + taskFunctionName, + taskFunctionObject, + ), + taskFunction: taskFunctionObject.taskFunction.toString(), + }).catch((error) => { + this.eventTarget?.dispatchEvent( + new ErrorEvent(PoolEvents.error, { error }), + ) + }) + } + } + const workerNode = this.workerNodes[workerNodeKey] + workerNode.info.dynamic = true + if ( + this.workerChoiceStrategiesContext?.getPolicy().dynamicWorkerReady === + true + ) { + workerNode.info.ready = true + } + this.initWorkerNodeUsage(workerNode) + this.checkAndEmitDynamicWorkerCreationEvents() + return workerNodeKey + } + + /** + * Registers a listener callback on the worker given its worker node key. + * + * @param workerNodeKey - The worker node key. + * @param listener - The message listener callback. + */ + protected abstract registerWorkerMessageListener< + Message extends Data | Response, + >( + workerNodeKey: number, + listener: (message: MessageValue) => void, + ): void + + /** + * Registers once a listener callback on the worker given its worker node key. + * + * @param workerNodeKey - The worker node key. + * @param listener - The message listener callback. + */ + protected abstract registerOnceWorkerMessageListener< + Message extends Data | Response, + >( + workerNodeKey: number, + listener: (message: MessageValue) => void, + ): void + + /** + * Deregisters a listener callback on the worker given its worker node key. + * + * @param workerNodeKey - The worker node key. + * @param listener - The message listener callback. + */ + protected abstract deregisterWorkerMessageListener< + Message extends Data | Response, + >( + workerNodeKey: number, + listener: (message: MessageValue) => void, + ): void + + /** + * Method hooked up after a worker node has been newly created. + * Can be overridden. + * + * @param workerNodeKey - The newly created worker node key. + */ + protected afterWorkerNodeSetup(workerNodeKey: number): void { + // Listen to worker messages. + this.registerWorkerMessageListener( + workerNodeKey, + this.workerMessageListener, + ) + // Send the startup message to worker. + this.sendStartupMessageToWorker(workerNodeKey) + // Send the statistics message to worker. + this.sendStatisticsMessageToWorker(workerNodeKey) + if (this.opts.enableTasksQueue === true) { + if (this.opts.tasksQueueOptions?.taskStealing === true) { + this.workerNodes[workerNodeKey].addEventListener( + 'idle', + this.handleWorkerNodeIdleEvent as EventListener, + ) + } + if (this.opts.tasksQueueOptions?.tasksStealingOnBackPressure === true) { + this.workerNodes[workerNodeKey].addEventListener( + 'backPressure', + this.handleWorkerNodeBackPressureEvent as EventListener, + ) + } + } + } + + /** + * Sends the startup message to worker given its worker node key. + * + * @param workerNodeKey - The worker node key. + */ + protected abstract sendStartupMessageToWorker(workerNodeKey: number): void + + /** + * Sends the statistics message to worker given its worker node key. + * + * @param workerNodeKey - The worker node key. + */ + private sendStatisticsMessageToWorker(workerNodeKey: number): void { + this.sendToWorker(workerNodeKey, { + statistics: { + runTime: + this.workerChoiceStrategiesContext?.getTaskStatisticsRequirements() + .runTime.aggregate ?? false, + // elu: this.workerChoiceStrategiesContext?.getTaskStatisticsRequirements() + // .elu.aggregate ?? false, + }, + }) + } + + private cannotStealTask(): boolean { + return this.workerNodes.length <= 1 || this.info.queuedTasks === 0 + } + + private handleTask(workerNodeKey: number, task: Task): void { + if (this.shallExecuteTask(workerNodeKey)) { + this.executeTask(workerNodeKey, task) + } else { + this.enqueueTask(workerNodeKey, task) + } + } + + private redistributeQueuedTasks(sourceWorkerNodeKey: number): void { + if (sourceWorkerNodeKey === -1 || this.cannotStealTask()) { + return + } + while (this.tasksQueueSize(sourceWorkerNodeKey) > 0) { + const destinationWorkerNodeKey = this.workerNodes.reduce( + (minWorkerNodeKey, workerNode, workerNodeKey, workerNodes) => { + return workerNodeKey !== sourceWorkerNodeKey && + workerNode.info.ready && + workerNode.usage.tasks.queued < + workerNodes[minWorkerNodeKey].usage.tasks.queued + ? workerNodeKey + : minWorkerNodeKey + }, + 0, + ) + this.handleTask( + destinationWorkerNodeKey, + this.dequeueTask(sourceWorkerNodeKey) as Task, + ) + } + } + + private updateTaskStolenStatisticsWorkerUsage( + workerNodeKey: number, + taskName: string, + ): void { + const workerNode = this.workerNodes[workerNodeKey] + if (workerNode?.usage != null) { + ++workerNode.usage.tasks.stolen + } + if ( + this.shallUpdateTaskFunctionWorkerUsage(workerNodeKey) && + workerNode.getTaskFunctionWorkerUsage(taskName) != null + ) { + ++workerNode.getTaskFunctionWorkerUsage(taskName)!.tasks.stolen + } + } + + private updateTaskSequentiallyStolenStatisticsWorkerUsage( + workerNodeKey: number, + taskName?: string, + previousTaskName?: string, + ): void { + const workerNode = this.workerNodes[workerNodeKey] + if (workerNode?.usage != null && taskName != null) { + ++workerNode.usage.tasks.sequentiallyStolen + } + if ( + taskName != null && + this.shallUpdateTaskFunctionWorkerUsage(workerNodeKey) && + workerNode.getTaskFunctionWorkerUsage(taskName) != null + ) { + const taskFunctionWorkerUsage = workerNode.getTaskFunctionWorkerUsage( + taskName, + )! + if ( + taskFunctionWorkerUsage.tasks.sequentiallyStolen === 0 || + (previousTaskName != null && + previousTaskName === taskName && + taskFunctionWorkerUsage.tasks.sequentiallyStolen > 0) + ) { + ++taskFunctionWorkerUsage.tasks.sequentiallyStolen + } else if (taskFunctionWorkerUsage.tasks.sequentiallyStolen > 0) { + taskFunctionWorkerUsage.tasks.sequentiallyStolen = 0 + } + } + } + + private resetTaskSequentiallyStolenStatisticsWorkerUsage( + workerNodeKey: number, + taskName?: string, + ): void { + const workerNode = this.workerNodes[workerNodeKey] + if (workerNode?.usage != null) { + workerNode.usage.tasks.sequentiallyStolen = 0 + } + if ( + taskName != null && + this.shallUpdateTaskFunctionWorkerUsage(workerNodeKey) && + workerNode.getTaskFunctionWorkerUsage(taskName) != null + ) { + workerNode.getTaskFunctionWorkerUsage( + taskName, + )!.tasks.sequentiallyStolen = 0 + } + } + + private readonly stealTask = ( + sourceWorkerNode: IWorkerNode, + destinationWorkerNodeKey: number, + ): Task | undefined => { + const destinationWorkerNode = this.workerNodes[destinationWorkerNodeKey] + if (destinationWorkerNode == null) { + return + } + // Avoid cross and cascade task stealing. Could be smarter by checking stealing/stolen worker ids pair. + if ( + !sourceWorkerNode.info.ready || + sourceWorkerNode.info.stolen || + sourceWorkerNode.info.stealing || + !destinationWorkerNode.info.ready || + destinationWorkerNode.info.stolen || + destinationWorkerNode.info.stealing + ) { + return + } + destinationWorkerNode.info.stealing = true + sourceWorkerNode.info.stolen = true + const stolenTask = sourceWorkerNode.dequeueLastPrioritizedTask()! + sourceWorkerNode.info.stolen = false + destinationWorkerNode.info.stealing = false + this.handleTask(destinationWorkerNodeKey, stolenTask) + this.updateTaskStolenStatisticsWorkerUsage( + destinationWorkerNodeKey, + stolenTask.name!, + ) + return stolenTask + } + + private readonly isStealingRatioReached = (): boolean => { + return ( + this.opts.tasksQueueOptions?.tasksStealingRatio === 0 || + (this.info.stealingWorkerNodes ?? 0) > + Math.ceil( + this.workerNodes.length * + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.opts.tasksQueueOptions!.tasksStealingRatio!, + ) + ) + } + + private readonly handleWorkerNodeIdleEvent = ( + event: CustomEvent, + previousStolenTask?: Task, + ): void => { + const { workerNodeKey } = event.detail + if (workerNodeKey == null) { + throw new Error( + "WorkerNode event detail 'workerNodeKey' property must be defined", + ) + } + const workerNode = this.workerNodes[workerNodeKey] + if (workerNode == null) { + return + } + if ( + !workerNode.info.continuousStealing && + (this.cannotStealTask() || this.isStealingRatioReached()) + ) { + return + } + const workerNodeTasksUsage = workerNode.usage.tasks + if ( + workerNode.info.continuousStealing && + !this.isWorkerNodeIdle(workerNodeKey) + ) { + workerNode.info.continuousStealing = false + if (workerNodeTasksUsage.sequentiallyStolen > 0) { + this.resetTaskSequentiallyStolenStatisticsWorkerUsage( + workerNodeKey, + previousStolenTask?.name, + ) + } + return + } + workerNode.info.continuousStealing = true + const stolenTask = this.workerNodeStealTask(workerNodeKey) + this.updateTaskSequentiallyStolenStatisticsWorkerUsage( + workerNodeKey, + stolenTask?.name, + previousStolenTask?.name, + ) + sleep(exponentialDelay(workerNodeTasksUsage.sequentiallyStolen)) + .then(() => { + this.handleWorkerNodeIdleEvent(event, stolenTask) + }) + .catch((error) => { + this.eventTarget?.dispatchEvent( + new ErrorEvent(PoolEvents.error, { error }), + ) + }) + } + + private readonly workerNodeStealTask = ( + workerNodeKey: number, + ): Task | undefined => { + const workerNodes = this.workerNodes + .slice() + .sort( + (workerNodeA, workerNodeB) => + workerNodeB.usage.tasks.queued - workerNodeA.usage.tasks.queued, + ) + const sourceWorkerNode = workerNodes.find( + (sourceWorkerNode, sourceWorkerNodeKey) => + sourceWorkerNodeKey !== workerNodeKey && + sourceWorkerNode.usage.tasks.queued > 0, + ) + if (sourceWorkerNode != null) { + return this.stealTask(sourceWorkerNode, workerNodeKey) + } + } + + private readonly handleWorkerNodeBackPressureEvent = ( + event: CustomEvent, + ): void => { + if ( + this.cannotStealTask() || + this.backPressure || + this.isStealingRatioReached() + ) { + return + } + const sizeOffset = 1 + if (this.opts.tasksQueueOptions!.size! <= sizeOffset) { + return + } + const { workerId } = event.detail + const sourceWorkerNode = + this.workerNodes[this.getWorkerNodeKeyByWorkerId(workerId)] + const workerNodes = this.workerNodes + .slice() + .sort( + (workerNodeA, workerNodeB) => + workerNodeA.usage.tasks.queued - workerNodeB.usage.tasks.queued, + ) + for (const [workerNodeKey, workerNode] of workerNodes.entries()) { + if (sourceWorkerNode.usage.tasks.queued === 0) { + break + } + if ( + workerNode.info.id !== workerId && + !workerNode.info.backPressureStealing && + workerNode.usage.tasks.queued < + this.opts.tasksQueueOptions!.size! - sizeOffset + ) { + workerNode.info.backPressureStealing = true + this.stealTask(sourceWorkerNode, workerNodeKey) + workerNode.info.backPressureStealing = false + } + } + } + + private setTasksQueuePriority(workerNodeKey: number): void { + this.workerNodes[workerNodeKey].setTasksQueuePriority( + this.getTasksQueuePriority(), + ) + } + + /** + * This method is the message listener registered on each worker. + * + * @param message - The message received from the worker. + */ + protected readonly workerMessageListener = ( + message: MessageValue, + ): void => { + this.checkMessageWorkerId(message) + const { workerId, ready, taskId, taskFunctionsProperties } = message + if (ready != null && taskFunctionsProperties != null) { + // Worker ready response received from worker + this.handleWorkerReadyResponse(message) + } else if (taskFunctionsProperties != null) { + // Task function properties message received from worker + const workerNodeKey = this.getWorkerNodeKeyByWorkerId(workerId) + const workerInfo = this.getWorkerInfo(workerNodeKey) + if (workerInfo != null) { + workerInfo.taskFunctionsProperties = taskFunctionsProperties + this.sendStatisticsMessageToWorker(workerNodeKey) + this.setTasksQueuePriority(workerNodeKey) + } + } else if (taskId != null) { + // Task execution response received from worker + this.handleTaskExecutionResponse(message) + } + } + + private checkAndEmitReadyEvent(): void { + if (this.eventTarget != null && !this.readyEventEmitted && this.ready) { + this.eventTarget.dispatchEvent( + new CustomEvent(PoolEvents.ready, { detail: this.info }), + ) + this.readyEventEmitted = true + } + } + + private handleWorkerReadyResponse(message: MessageValue): void { + const { workerId, ready, taskFunctionsProperties } = message + if (ready == null || !ready) { + throw new Error(`Worker ${workerId?.toString()} failed to initialize`) + } + const workerNodeKey = this.getWorkerNodeKeyByWorkerId(workerId) + const workerNode = this.workerNodes[workerNodeKey] + workerNode.info.ready = ready + workerNode.info.taskFunctionsProperties = taskFunctionsProperties + this.sendStatisticsMessageToWorker(workerNodeKey) + this.setTasksQueuePriority(workerNodeKey) + this.checkAndEmitReadyEvent() + } + + private handleTaskExecutionResponse(message: MessageValue): void { + const { taskId, workerError, data } = message + const promiseResponse = this.promiseResponseMap.get(taskId!) + if (promiseResponse != null) { + const { resolve, reject, workerNodeKey } = promiseResponse + const workerNode = this.workerNodes[workerNodeKey] + if (workerError != null) { + this.eventTarget?.dispatchEvent( + new ErrorEvent(PoolEvents.taskError, { error: workerError }), + ) + reject(workerError.message) + } else { + resolve(data!) + } + this.afterTaskExecutionHook(workerNodeKey, message) + this.checkAndEmitTaskExecutionFinishedEvents() + this.promiseResponseMap.delete(taskId!) + if (this.opts.enableTasksQueue === true && !this.destroying) { + if ( + !this.isWorkerNodeBusy(workerNodeKey) && + this.tasksQueueSize(workerNodeKey) > 0 + ) { + this.executeTask( + workerNodeKey, + this.dequeueTask(workerNodeKey) as Task, + ) + } + if (this.isWorkerNodeIdle(workerNodeKey)) { + workerNode.dispatchEvent( + new CustomEvent('idle', { + detail: { workerNodeKey }, + }), + ) + } + } + // FIXME: cannot be theoretically undefined. Schedule in the next tick to avoid race conditions? + workerNode?.dispatchEvent(new Event('taskFinished')) + if (this.shallCreateDynamicWorker()) { + this.createAndSetupDynamicWorkerNode() + } + } + } + + private checkAndEmitTaskExecutionEvents(): void { + if (this.eventTarget != null && !this.busyEventEmitted && this.busy) { + this.eventTarget.dispatchEvent( + new CustomEvent(PoolEvents.busy, { detail: this.info }), + ) + this.busyEventEmitted = true + } + } + + private checkAndEmitTaskExecutionFinishedEvents(): void { + if (this.eventTarget != null && this.busyEventEmitted && !this.busy) { + this.eventTarget.dispatchEvent( + new CustomEvent(PoolEvents.busyEnd, { detail: this.info }), + ) + this.busyEventEmitted = false + } + } + + private checkAndEmitTaskQueuingEvents(): void { + if ( + this.eventTarget != null && + !this.backPressureEventEmitted && + this.backPressure + ) { + this.eventTarget.dispatchEvent( + new CustomEvent(PoolEvents.backPressure, { + detail: this.info, + }), + ) + this.backPressureEventEmitted = true + } + } + + private checkAndEmitTaskDequeuingEvents(): void { + if ( + this.eventTarget != null && + this.backPressureEventEmitted && + !this.backPressure + ) { + this.eventTarget.dispatchEvent( + new CustomEvent(PoolEvents.backPressureEnd, { + detail: this.info, + }), + ) + this.backPressureEventEmitted = false + } + } + + /** + * Emits dynamic worker creation events. + */ + protected abstract checkAndEmitDynamicWorkerCreationEvents(): void + + /** + * Emits dynamic worker destruction events. + */ + protected abstract checkAndEmitDynamicWorkerDestructionEvents(): void + + /** + * Gets the worker information given its worker node key. + * + * @param workerNodeKey - The worker node key. + * @returns The worker information. + */ + protected getWorkerInfo(workerNodeKey: number): WorkerInfo | undefined { + return this.workerNodes[workerNodeKey]?.info + } + + private getTasksQueuePriority(): boolean { + return this.listTaskFunctionsProperties().some( + (taskFunctionProperties) => taskFunctionProperties.priority != null, + ) + } + + /** + * Creates a worker node. + * + * @returns The created worker node. + */ + private createWorkerNode(): IWorkerNode { + const workerNode = new WorkerNode(this.worker, this.fileURL, { + workerOptions: this.opts.workerOptions, + tasksQueueBackPressureSize: this.opts.tasksQueueOptions?.size ?? + getDefaultTasksQueueOptions( + this.maximumNumberOfWorkers ?? this.minimumNumberOfWorkers, + ).size, + tasksQueueBucketSize: defaultBucketSize, + tasksQueuePriority: this.getTasksQueuePriority(), + }) + // Flag the worker node as ready at pool startup. + if (this.starting) { + workerNode.info.ready = true + } + return workerNode + } + + /** + * Adds the given worker node in the pool worker nodes. + * + * @param workerNode - The worker node. + * @returns The added worker node key. + * @throws {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error} If the added worker node is not found. + */ + private addWorkerNode(workerNode: IWorkerNode): number { + this.workerNodes.push(workerNode) + const workerNodeKey = this.workerNodes.indexOf(workerNode) + if (workerNodeKey === -1) { + throw new Error('Worker node added not found in pool worker nodes') + } + return workerNodeKey + } + + /** + * Removes the worker node from the pool worker nodes. + * + * @param workerNode - The worker node. + */ + protected removeWorkerNode(workerNode: IWorkerNode): void { + const workerNodeKey = this.workerNodes.indexOf(workerNode) + if (workerNodeKey !== -1) { + this.workerNodes.splice(workerNodeKey, 1) + this.workerChoiceStrategiesContext?.remove(workerNodeKey) + workerNode.info.dynamic && + this.checkAndEmitDynamicWorkerDestructionEvents() + } + } + + protected flagWorkerNodeAsNotReady(workerNodeKey: number): void { + const workerInfo = this.getWorkerInfo(workerNodeKey) + if (workerInfo != null) { + workerInfo.ready = false + } + } + + /** + * Whether the worker nodes are back pressured or not. + * + * @returns Worker nodes back pressure boolean status. + */ + protected internalBackPressure(): boolean { + return ( + this.workerNodes.reduce( + (accumulator, _, workerNodeKey) => + this.isWorkerNodeBackPressured(workerNodeKey) + ? accumulator + 1 + : accumulator, + 0, + ) === this.workerNodes.length + ) + } + + /** + * Executes the given task on the worker given its worker node key. + * + * @param workerNodeKey - The worker node key. + * @param task - The task to execute. + */ + private executeTask(workerNodeKey: number, task: Task): void { + this.beforeTaskExecutionHook(workerNodeKey, task) + this.sendToWorker(workerNodeKey, task, task.transferList) + this.checkAndEmitTaskExecutionEvents() + } + + private enqueueTask(workerNodeKey: number, task: Task): number { + const tasksQueueSize = this.workerNodes[workerNodeKey].enqueueTask(task) + this.checkAndEmitTaskQueuingEvents() + return tasksQueueSize + } + + private dequeueTask(workerNodeKey: number): Task | undefined { + const task = this.workerNodes[workerNodeKey].dequeueTask() + this.checkAndEmitTaskDequeuingEvents() + return task + } + + private tasksQueueSize(workerNodeKey: number): number { + return this.workerNodes[workerNodeKey].tasksQueueSize() + } + + protected flushTasksQueue(workerNodeKey: number): number { + let flushedTasks = 0 + while (this.tasksQueueSize(workerNodeKey) > 0) { + this.executeTask( + workerNodeKey, + this.dequeueTask(workerNodeKey) as Task, + ) + ++flushedTasks + } + this.workerNodes[workerNodeKey].clearTasksQueue() + return flushedTasks + } + + private flushTasksQueues(): void { + for (const workerNodeKey of this.workerNodes.keys()) { + this.flushTasksQueue(workerNodeKey) + } + } +} diff --git a/src/vendor/poolifier/pools/pool.ts b/src/vendor/poolifier/pools/pool.ts new file mode 100644 index 0000000..c6d249c --- /dev/null +++ b/src/vendor/poolifier/pools/pool.ts @@ -0,0 +1,386 @@ +import type { TaskFunctionProperties } from '../utility-types.ts' +import type { + TaskFunction, + TaskFunctionObject, +} from '../worker/task-functions.ts' +import type { + WorkerChoiceStrategy, + WorkerChoiceStrategyOptions, +} from './selection-strategies/selection-strategies-types.ts' +import type { + ErrorEventHandler, + IWorker, + IWorkerNode, + MessageEventErrorHandler, + MessageEventHandler, + WorkerType, +} from './worker.ts' + +/** + * Enumeration of pool types. + */ +export const PoolTypes: Readonly<{ fixed: 'fixed'; dynamic: 'dynamic' }> = + Object.freeze( + { + /** + * Fixed pool type. + */ + fixed: 'fixed', + /** + * Dynamic pool type. + */ + dynamic: 'dynamic', + } as const, + ) + +/** + * Pool type. + */ +export type PoolType = keyof typeof PoolTypes + +/** + * Enumeration of pool events. + */ +export const PoolEvents: Readonly<{ + ready: 'ready' + busy: 'busy' + busyEnd: 'busyEnd' + full: 'full' + fullEnd: 'fullEnd' + empty: 'empty' + destroy: 'destroy' + error: 'error' + messageerror: 'messageerror' + taskError: 'taskError' + backPressure: 'backPressure' + backPressureEnd: 'backPressureEnd' +}> = Object.freeze( + { + ready: 'ready', + busy: 'busy', + busyEnd: 'busyEnd', + full: 'full', + fullEnd: 'fullEnd', + empty: 'empty', + destroy: 'destroy', + error: 'error', + messageerror: 'messageerror', + taskError: 'taskError', + backPressure: 'backPressure', + backPressureEnd: 'backPressureEnd', + } as const, +) + +/** + * Pool event. + */ +export type PoolEvent = keyof typeof PoolEvents + +/** + * Pool information. + */ +export interface PoolInfo { + readonly version: string + readonly type: PoolType + readonly worker: WorkerType + readonly started: boolean + readonly ready: boolean + readonly defaultStrategy: WorkerChoiceStrategy + readonly strategyRetries: number + readonly minSize: number + readonly maxSize: number + /** Pool utilization. */ + readonly utilization?: number + /** Pool total worker nodes. */ + readonly workerNodes: number + /** Pool dynamic worker nodes. */ + readonly dynamicWorkerNodes?: number + /** Pool idle worker nodes. */ + readonly idleWorkerNodes: number + /** Pool busy worker nodes. */ + readonly busyWorkerNodes: number + /** Pool tasks stealing worker nodes. */ + readonly stealingWorkerNodes?: number + /** Pool tasks back pressure worker nodes. */ + readonly backPressureWorkerNodes?: number + readonly executedTasks: number + readonly executingTasks: number + readonly queuedTasks?: number + readonly maxQueuedTasks?: number + readonly backPressure?: boolean + readonly stolenTasks?: number + readonly failedTasks: number + readonly runTime?: { + readonly minimum: number + readonly maximum: number + readonly average?: number + readonly median?: number + } + readonly waitTime?: { + readonly minimum: number + readonly maximum: number + readonly average?: number + readonly median?: number + } +} + +/** + * Worker node tasks queue options. + */ +export interface TasksQueueOptions { + /** + * Maximum tasks queue size per worker node flagging it as back pressured. + * + * @defaultValue (pool maximum size)^2 + */ + readonly size?: number + /** + * Maximum number of tasks that can be executed concurrently on a worker node. + * + * @defaultValue 1 + */ + readonly concurrency?: number + /** + * Whether to enable task stealing on idle. + * + * @defaultValue true + */ + readonly taskStealing?: boolean + /** + * Whether to enable tasks stealing under back pressure. + * + * @defaultValue true + */ + readonly tasksStealingOnBackPressure?: boolean + /** + * Ratio of worker nodes that can steal tasks from another worker node. + * + * @defaultValue 0.6 + */ + readonly tasksStealingRatio?: number + /** + * Queued tasks finished timeout in milliseconds at worker node termination. + * + * @defaultValue 2000 + */ + readonly tasksFinishedTimeout?: number +} + +/** + * Options for a poolifier pool. + */ +export interface PoolOptions { + /** + * A function that will listen for message event on each worker. + * + * @defaultValue `() => {}` + */ + messageEventHandler?: MessageEventHandler + /** + * A function that will listen for message event processing error on each worker. + * + * @defaultValue `() => {}` + */ + messageEventErrorHandler?: MessageEventErrorHandler + /** + * A function that will listen for error event on each worker. + * + * @defaultValue `() => {}` + */ + errorEventHandler?: ErrorEventHandler + /** + * Whether to start the minimum number of workers at pool initialization. + * + * @defaultValue true + */ + startWorkers?: boolean + /** + * The default worker choice strategy to use in this pool. + * + * @defaultValue WorkerChoiceStrategies.ROUND_ROBIN + */ + workerChoiceStrategy?: WorkerChoiceStrategy + /** + * The worker choice strategy options. + */ + workerChoiceStrategyOptions?: WorkerChoiceStrategyOptions + /** + * Restart worker on error. + */ + restartWorkerOnError?: boolean + /** + * Pool events emission. + * + * @defaultValue true + */ + enableEvents?: boolean + /** + * Pool worker node tasks queue. + * + * @defaultValue false + */ + enableTasksQueue?: boolean + /** + * Pool worker node tasks queue options. + */ + tasksQueueOptions?: TasksQueueOptions + /** + * Worker options. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Worker/Worker#options + */ + workerOptions?: WorkerOptions +} + +/** + * Contract definition for a poolifier pool. + * + * @typeParam Worker - Type of worker which manages this pool. + * @typeParam Data - Type of data sent to the worker. This can only be structured-cloneable data. + * @typeParam Response - Type of execution response. This can only be structured-cloneable data. + */ +export interface IPool< + Worker extends IWorker, + Data = unknown, + Response = unknown, +> { + /** + * Pool information. + */ + readonly info: PoolInfo + /** + * Pool worker nodes. + * + * @internal + */ + readonly workerNodes: IWorkerNode[] + /** + * Pool event target. + * + * Events that can currently be listened to: + * + * - `'ready'`: Emitted when the number of workers created in the pool has reached the minimum size expected and are ready. If the pool is dynamic with a minimum number of workers set to zero, this event is emitted when the pool is started. + * - `'busy'`: Emitted when the number of workers created in the pool has reached the maximum size expected and are executing concurrently their tasks quota. + * - `'busyEnd'`: Emitted when the number of workers created in the pool has reached the maximum size expected and are no longer executing concurrently their tasks quota. + * - `'full'`: Emitted when the pool is dynamic and the number of workers created has reached the maximum size expected. + * - `'fullEnd'`: Emitted when the pool is dynamic and the number of workers created has no longer reached the maximum size expected. + * - `'empty'`: Emitted when the pool is dynamic with a minimum number of workers set to zero and the number of workers has reached the minimum size expected. + * - `'destroy'`: Emitted when the pool is destroyed. + * - `'error'`: Emitted when an uncaught error occurs. + * - `'messageerror'`: Emitted when an error occurs while processing a message event. + * - `'taskError'`: Emitted when an error occurs while executing a task. + * - `'backPressure'`: Emitted when the number of workers created in the pool has reached the maximum size expected and are back pressured (i.e. their tasks queue is full: queue size \>= maximum queue size). + * - `'backPressureEnd'`: Emitted when the number of workers created in the pool has reached the maximum size expected and are no longer back pressured (i.e. their tasks queue is no longer full: queue size \< maximum queue size). + */ + readonly eventTarget?: EventTarget + /** + * Executes the specified function in the worker constructor with the task data input parameter. + * + * @param data - The optional task input data for the specified task function. This can only be structured-cloneable data. + * @param name - The optional name of the task function to execute. If not specified, the default task function will be executed. + * @param transferList - An optional array of transferable objects to transfer ownership of. Ownership of the transferred objects is given to the chosen pool's web worker and they should not be used in the main thread afterwards. + * @returns Promise with a task function response that will be fulfilled when the task is completed. + */ + readonly execute: ( + data?: Data, + name?: string, + transferList?: readonly Transferable[], + ) => Promise + /** + * Executes the specified function in the worker constructor with the tasks data iterable input parameter. + * + * @param data - The tasks iterable input data for the specified task function. This can only be an iterable of structured-cloneable data. + * @param name - The optional name of the task function to execute. If not specified, the default task function will be executed. + * @param transferList - An optional array of transferable objects to transfer ownership of. Ownership of the transferred objects is given to the chosen pool's worker_threads worker and they should not be used in the main thread afterwards. + * @returns Promise with an array of task function responses that will be fulfilled when the tasks are completed. + */ + readonly mapExecute: ( + data: Iterable, + name?: string, + transferList?: readonly Transferable[], + ) => Promise + /** + * Starts the minimum number of workers in this pool. + */ + readonly start: () => void + /** + * Terminates all workers in this pool. + */ + readonly destroy: () => Promise + /** + * Whether the specified task function exists in this pool. + * + * @param name - The name of the task function. + * @returns `true` if the task function exists, `false` otherwise. + */ + readonly hasTaskFunction: (name: string) => boolean + /** + * Adds a task function to this pool. + * If a task function with the same name already exists, it will be overwritten. + * + * @param name - The name of the task function. + * @param fn - The task function. + * @returns `true` if the task function was added, `false` otherwise. + * @throws {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypeError} If the `name` parameter is not a string or an empty string. + * @throws {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypeError} If the `fn` parameter is not a function or task function object. + */ + readonly addTaskFunction: ( + name: string, + fn: TaskFunction | TaskFunctionObject, + ) => Promise + /** + * Removes a task function from this pool. + * + * @param name - The name of the task function. + * @returns `true` if the task function was removed, `false` otherwise. + */ + readonly removeTaskFunction: (name: string) => Promise + /** + * Lists the properties of task functions available in this pool. + * + * @returns The properties of task functions available in this pool. + */ + readonly listTaskFunctionsProperties: () => TaskFunctionProperties[] + /** + * Sets the default task function in this pool. + * + * @param name - The name of the task function. + * @returns `true` if the default task function was set, `false` otherwise. + */ + readonly setDefaultTaskFunction: (name: string) => Promise + /** + * Sets the default worker choice strategy in this pool. + * + * @param workerChoiceStrategy - The default worker choice strategy. + * @param workerChoiceStrategyOptions - The worker choice strategy options. + */ + readonly setWorkerChoiceStrategy: ( + workerChoiceStrategy: WorkerChoiceStrategy, + workerChoiceStrategyOptions?: WorkerChoiceStrategyOptions, + ) => void + /** + * Sets the worker choice strategy options in this pool. + * + * @param workerChoiceStrategyOptions - The worker choice strategy options. + * @returns `true` if the worker choice strategy options were set, `false` otherwise. + */ + readonly setWorkerChoiceStrategyOptions: ( + workerChoiceStrategyOptions: WorkerChoiceStrategyOptions, + ) => boolean + /** + * Enables/disables the worker node tasks queue in this pool. + * + * @param enable - Whether to enable or disable the worker node tasks queue. + * @param tasksQueueOptions - The worker node tasks queue options. + */ + readonly enableTasksQueue: ( + enable: boolean, + tasksQueueOptions?: TasksQueueOptions, + ) => void + /** + * Sets the worker node tasks queue options in this pool. + * + * @param tasksQueueOptions - The worker node tasks queue options. + */ + readonly setTasksQueueOptions: (tasksQueueOptions: TasksQueueOptions) => void +} diff --git a/src/vendor/poolifier/pools/selection-strategies/abstract-worker-choice-strategy.ts b/src/vendor/poolifier/pools/selection-strategies/abstract-worker-choice-strategy.ts new file mode 100644 index 0000000..71bf7a6 --- /dev/null +++ b/src/vendor/poolifier/pools/selection-strategies/abstract-worker-choice-strategy.ts @@ -0,0 +1,182 @@ +import type { IPool } from '../pool.ts' +import { DEFAULT_MEASUREMENT_STATISTICS_REQUIREMENTS } from '../utils.ts' +import type { IWorker } from '../worker.ts' +import type { + IWorkerChoiceStrategy, + StrategyPolicy, + TaskStatisticsRequirements, + WorkerChoiceStrategyOptions, +} from './selection-strategies-types.ts' +import { + buildWorkerChoiceStrategyOptions, + toggleMedianMeasurementStatisticsRequirements, +} from './selection-strategies-utils.ts' + +/** + * Worker choice strategy abstract base class. + * + * @typeParam Worker - Type of worker which manages the strategy. + * @typeParam Data - Type of data sent to the worker. This can only be structured-cloneable data. + * @typeParam Response - Type of execution response. This can only be structured-cloneable data. + */ +export abstract class AbstractWorkerChoiceStrategy< + Worker extends IWorker, + Data = unknown, + Response = unknown, +> implements IWorkerChoiceStrategy { + /** + * The next worker node key. + */ + protected nextWorkerNodeKey: number | undefined = 0 + + /** + * The previous worker node key. + */ + protected previousWorkerNodeKey = 0 + + /** @inheritDoc */ + public readonly strategyPolicy: StrategyPolicy = { + dynamicWorkerUsage: false, + dynamicWorkerReady: true, + } + + /** @inheritDoc */ + public readonly taskStatisticsRequirements: TaskStatisticsRequirements = { + runTime: DEFAULT_MEASUREMENT_STATISTICS_REQUIREMENTS, + waitTime: DEFAULT_MEASUREMENT_STATISTICS_REQUIREMENTS, + elu: DEFAULT_MEASUREMENT_STATISTICS_REQUIREMENTS, + } + + /** + * Constructs a worker choice strategy bound to the pool. + * + * @param pool - The pool instance. + * @param opts - The worker choice strategy options. + */ + public constructor( + protected readonly pool: IPool, + protected opts?: WorkerChoiceStrategyOptions, + ) { + this.choose = this.choose.bind(this) + this.setOptions(this.opts) + } + + protected setTaskStatisticsRequirements( + opts: WorkerChoiceStrategyOptions | undefined, + ): void { + toggleMedianMeasurementStatisticsRequirements( + this.taskStatisticsRequirements.runTime, + opts!.runTime!.median, + ) + toggleMedianMeasurementStatisticsRequirements( + this.taskStatisticsRequirements.waitTime, + opts!.waitTime!.median, + ) + toggleMedianMeasurementStatisticsRequirements( + this.taskStatisticsRequirements.elu, + opts!.elu!.median, + ) + } + + protected resetWorkerNodeKeyProperties(): void { + this.nextWorkerNodeKey = 0 + this.previousWorkerNodeKey = 0 + } + + /** @inheritDoc */ + public abstract reset(): boolean + + /** @inheritDoc */ + public abstract update(workerNodeKey: number): boolean + + /** @inheritDoc */ + public abstract choose(): number | undefined + + /** @inheritDoc */ + public abstract remove(workerNodeKey: number): boolean + + /** @inheritDoc */ + public setOptions(opts: WorkerChoiceStrategyOptions | undefined): void { + this.opts = buildWorkerChoiceStrategyOptions( + this.pool, + opts, + ) + this.setTaskStatisticsRequirements(this.opts) + } + + /** + * Whether the worker node is ready or not. + * + * @param workerNodeKey - The worker node key. + * @returns Whether the worker node is ready or not. + */ + protected isWorkerNodeReady(workerNodeKey: number): boolean { + return this.pool.workerNodes[workerNodeKey]?.info?.ready ?? false + } + + /** + * Check the next worker node key. + */ + protected checkNextWorkerNodeKey(): void { + if ( + this.nextWorkerNodeKey != null && + (this.nextWorkerNodeKey < 0 || + !this.isWorkerNodeReady(this.nextWorkerNodeKey)) + ) { + delete this.nextWorkerNodeKey + } + } + + /** + * Gets the worker node task runtime. + * If the task statistics require the average runtime, the average runtime is returned. + * If the task statistics require the median runtime, the median runtime is returned. + * + * @param workerNodeKey - The worker node key. + * @returns The worker node task runtime. + */ + protected getWorkerNodeTaskRunTime(workerNodeKey: number): number { + return this.taskStatisticsRequirements.runTime.median + ? (this.pool.workerNodes[workerNodeKey].usage.runTime.median ?? 0) + : (this.pool.workerNodes[workerNodeKey].usage.runTime.average ?? 0) + } + + /** + * Gets the worker node task wait time. + * If the task statistics require the average wait time, the average wait time is returned. + * If the task statistics require the median wait time, the median wait time is returned. + * + * @param workerNodeKey - The worker node key. + * @returns The worker node task wait time. + */ + protected getWorkerNodeTaskWaitTime(workerNodeKey: number): number { + return this.taskStatisticsRequirements.waitTime.median + ? (this.pool.workerNodes[workerNodeKey].usage.waitTime.median ?? 0) + : (this.pool.workerNodes[workerNodeKey].usage.waitTime.average ?? 0) + } + + // /** + // * Gets the worker node task ELU. + // * If the task statistics require the average ELU, the average ELU is returned. + // * If the task statistics require the median ELU, the median ELU is returned. + // * + // * @param workerNodeKey - The worker node key. + // * @returns The worker node task ELU. + // */ + // protected getWorkerNodeTaskElu(workerNodeKey: number): number { + // return this.taskStatisticsRequirements.elu.median + // ? this.pool.workerNodes[workerNodeKey].usage.elu.active.median ?? 0 + // : this.pool.workerNodes[workerNodeKey].usage.elu.active.average ?? 0 + // } + + /** + * Sets safely the previous worker node key. + * + * @param workerNodeKey - The worker node key. + */ + protected setPreviousWorkerNodeKey(workerNodeKey: number | undefined): void { + this.previousWorkerNodeKey = workerNodeKey != null && workerNodeKey >= 0 + ? workerNodeKey + : this.previousWorkerNodeKey + } +} diff --git a/src/vendor/poolifier/pools/selection-strategies/fair-share-worker-choice-strategy.ts b/src/vendor/poolifier/pools/selection-strategies/fair-share-worker-choice-strategy.ts new file mode 100644 index 0000000..69a23ae --- /dev/null +++ b/src/vendor/poolifier/pools/selection-strategies/fair-share-worker-choice-strategy.ts @@ -0,0 +1,140 @@ +import type { IPool } from '../pool.ts' +import type { IWorker } from '../worker.ts' +import { AbstractWorkerChoiceStrategy } from './abstract-worker-choice-strategy.ts' +import type { + IWorkerChoiceStrategy, + TaskStatisticsRequirements, + WorkerChoiceStrategyOptions, +} from './selection-strategies-types.ts' + +/** + * Selects the next worker with a fair share scheduling algorithm. + * Loosely modeled after the fair queueing algorithm: https://en.wikipedia.org/wiki/Fair_queuing. + * + * @typeParam Worker - Type of worker which manages the strategy. + * @typeParam Data - Type of data sent to the worker. This can only be structured-cloneable data. + * @typeParam Response - Type of execution response. This can only be structured-cloneable data. + */ +export class FairShareWorkerChoiceStrategy< + Worker extends IWorker, + Data = unknown, + Response = unknown, +> extends AbstractWorkerChoiceStrategy + implements IWorkerChoiceStrategy { + /** @inheritDoc */ + public override readonly taskStatisticsRequirements: + TaskStatisticsRequirements = { + runTime: { + aggregate: true, + average: true, + median: false, + }, + waitTime: { + aggregate: true, + average: true, + median: false, + }, + elu: { + aggregate: true, + average: true, + median: false, + }, + } + + /** @inheritDoc */ + public constructor( + pool: IPool, + opts?: WorkerChoiceStrategyOptions, + ) { + super(pool, opts) + this.setTaskStatisticsRequirements(this.opts) + } + + /** @inheritDoc */ + public reset(): boolean { + for (const workerNode of this.pool.workerNodes) { + delete workerNode.strategyData?.virtualTaskEndTimestamp + } + return true + } + + /** @inheritDoc */ + public update(workerNodeKey: number): boolean { + this.pool.workerNodes[workerNodeKey].strategyData = { + virtualTaskEndTimestamp: this.computeWorkerNodeVirtualTaskEndTimestamp( + workerNodeKey, + ), + } + return true + } + + /** @inheritDoc */ + public choose(): number | undefined { + this.setPreviousWorkerNodeKey(this.nextWorkerNodeKey) + this.nextWorkerNodeKey = this.fairShareNextWorkerNodeKey() + return this.nextWorkerNodeKey + } + + /** @inheritDoc */ + public remove(): boolean { + return true + } + + private fairShareNextWorkerNodeKey(): number | undefined { + return this.pool.workerNodes.reduce( + (minWorkerNodeKey, workerNode, workerNodeKey, workerNodes) => { + if (workerNode.strategyData?.virtualTaskEndTimestamp == null) { + workerNode.strategyData = { + virtualTaskEndTimestamp: this + .computeWorkerNodeVirtualTaskEndTimestamp(workerNodeKey), + } + } + return this.isWorkerNodeReady(workerNodeKey) && + workerNode.strategyData.virtualTaskEndTimestamp! < + workerNodes[minWorkerNodeKey].strategyData! + .virtualTaskEndTimestamp! + ? workerNodeKey + : minWorkerNodeKey + }, + 0, + ) + } + + /** + * Computes the worker node key virtual task end timestamp. + * + * @param workerNodeKey - The worker node key. + * @returns The worker node key virtual task end timestamp. + */ + private computeWorkerNodeVirtualTaskEndTimestamp( + workerNodeKey: number, + ): number { + return this.getWorkerNodeVirtualTaskEndTimestamp( + workerNodeKey, + this.getWorkerNodeVirtualTaskStartTimestamp(workerNodeKey), + ) + } + + private getWorkerNodeVirtualTaskEndTimestamp( + workerNodeKey: number, + workerNodeVirtualTaskStartTimestamp: number, + ): number { + return ( + workerNodeVirtualTaskStartTimestamp + + this.getWorkerNodeTaskWaitTime(workerNodeKey) + + this.getWorkerNodeTaskRunTime(workerNodeKey) + ) + } + + private getWorkerNodeVirtualTaskStartTimestamp( + workerNodeKey: number, + ): number { + const virtualTaskEndTimestamp = this.pool.workerNodes[workerNodeKey] + ?.strategyData + ?.virtualTaskEndTimestamp + const now = performance.now() + return now < (virtualTaskEndTimestamp ?? Number.NEGATIVE_INFINITY) + ? virtualTaskEndTimestamp! + : now + } +} diff --git a/src/vendor/poolifier/pools/selection-strategies/interleaved-weighted-round-robin-worker-choice-strategy.ts b/src/vendor/poolifier/pools/selection-strategies/interleaved-weighted-round-robin-worker-choice-strategy.ts new file mode 100644 index 0000000..699992e --- /dev/null +++ b/src/vendor/poolifier/pools/selection-strategies/interleaved-weighted-round-robin-worker-choice-strategy.ts @@ -0,0 +1,176 @@ +import type { IPool } from '../pool.ts' +import { DEFAULT_MEASUREMENT_STATISTICS_REQUIREMENTS } from '../utils.ts' +import type { IWorker } from '../worker.ts' +import { AbstractWorkerChoiceStrategy } from './abstract-worker-choice-strategy.ts' +import type { + IWorkerChoiceStrategy, + TaskStatisticsRequirements, + WorkerChoiceStrategyOptions, +} from './selection-strategies-types.ts' + +/** + * Selects the next worker with an interleaved weighted round robin scheduling algorithm. + * + * @typeParam Worker - Type of worker which manages the strategy. + * @typeParam Data - Type of data sent to the worker. This can only be structured-cloneable data. + * @typeParam Response - Type of execution response. This can only be structured-cloneable data. + */ +export class InterleavedWeightedRoundRobinWorkerChoiceStrategy< + Worker extends IWorker, + Data = unknown, + Response = unknown, +> extends AbstractWorkerChoiceStrategy + implements IWorkerChoiceStrategy { + /** @inheritDoc */ + public override readonly taskStatisticsRequirements: + TaskStatisticsRequirements = { + runTime: { + aggregate: true, + average: true, + median: false, + }, + waitTime: { + aggregate: true, + average: true, + median: false, + }, + elu: DEFAULT_MEASUREMENT_STATISTICS_REQUIREMENTS, + } + + /** + * Round id. + */ + private roundId = 0 + /** + * Round weights. + */ + private roundWeights: number[] + /** + * Worker node id. + */ + private workerNodeId = 0 + /** + * Worker node virtual task execution time. + */ + private workerNodeVirtualTaskExecutionTime = 0 + + /** @inheritDoc */ + public constructor( + pool: IPool, + opts?: WorkerChoiceStrategyOptions, + ) { + super(pool, opts) + this.setTaskStatisticsRequirements(this.opts) + this.roundWeights = this.getRoundWeights() + } + + /** @inheritDoc */ + public reset(): boolean { + this.resetWorkerNodeKeyProperties() + this.roundId = 0 + this.workerNodeId = 0 + this.workerNodeVirtualTaskExecutionTime = 0 + return true + } + + /** @inheritDoc */ + public update(): boolean { + return true + } + + /** @inheritDoc */ + public choose(): number | undefined { + for ( + let roundIndex = this.roundId; + roundIndex < this.roundWeights.length; + roundIndex++ + ) { + this.roundId = roundIndex + for ( + let workerNodeKey = this.workerNodeId; + workerNodeKey < this.pool.workerNodes.length; + workerNodeKey++ + ) { + this.workerNodeId = workerNodeKey + if ( + this.workerNodeId !== this.nextWorkerNodeKey && + this.workerNodeVirtualTaskExecutionTime !== 0 + ) { + this.workerNodeVirtualTaskExecutionTime = 0 + } + const workerWeight = this.opts!.weights![workerNodeKey] + if ( + this.isWorkerNodeReady(workerNodeKey) && + workerWeight >= this.roundWeights[roundIndex] && + this.workerNodeVirtualTaskExecutionTime < workerWeight + ) { + this.workerNodeVirtualTaskExecutionTime += + this.getWorkerNodeTaskWaitTime(workerNodeKey) + + this.getWorkerNodeTaskRunTime(workerNodeKey) + this.setPreviousWorkerNodeKey(this.nextWorkerNodeKey) + this.nextWorkerNodeKey = workerNodeKey + return this.nextWorkerNodeKey + } + } + } + this.interleavedWeightedRoundRobinNextWorkerNodeId() + } + + private interleavedWeightedRoundRobinNextWorkerNodeId(): void { + if (this.pool.workerNodes.length === 0) { + this.workerNodeId = 0 + } else if ( + this.roundId === this.roundWeights.length - 1 && + this.workerNodeId === this.pool.workerNodes.length - 1 + ) { + this.roundId = 0 + this.workerNodeId = 0 + } else if (this.workerNodeId === this.pool.workerNodes.length - 1) { + this.roundId = this.roundId + 1 + this.workerNodeId = 0 + } else { + this.workerNodeId = this.workerNodeId + 1 + } + } + + /** @inheritDoc */ + public remove(workerNodeKey: number): boolean { + if (this.pool.workerNodes.length === 0) { + this.resetWorkerNodeKeyProperties() + this.workerNodeId = 0 + this.workerNodeVirtualTaskExecutionTime = 0 + return true + } + if ( + this.workerNodeId === workerNodeKey && + this.workerNodeId > this.pool.workerNodes.length - 1 + ) { + this.workerNodeId = this.pool.workerNodes.length - 1 + } + if ( + this.previousWorkerNodeKey === workerNodeKey && + this.previousWorkerNodeKey > this.pool.workerNodes.length - 1 + ) { + this.previousWorkerNodeKey = this.pool.workerNodes.length - 1 + } + return true + } + + /** @inheritDoc */ + public override setOptions( + opts: WorkerChoiceStrategyOptions | undefined, + ): void { + super.setOptions(opts) + this.roundWeights = this.getRoundWeights() + } + + private getRoundWeights(): number[] { + return [ + ...new Set( + Object.values(this.opts!.weights!) + .slice() + .sort((a, b) => a - b), + ), + ] + } +} diff --git a/src/vendor/poolifier/pools/selection-strategies/least-busy-worker-choice-strategy.ts b/src/vendor/poolifier/pools/selection-strategies/least-busy-worker-choice-strategy.ts new file mode 100644 index 0000000..c9080dd --- /dev/null +++ b/src/vendor/poolifier/pools/selection-strategies/least-busy-worker-choice-strategy.ts @@ -0,0 +1,85 @@ +import type { IPool } from '../pool.ts' +import { DEFAULT_MEASUREMENT_STATISTICS_REQUIREMENTS } from '../utils.ts' +import type { IWorker } from '../worker.ts' +import { AbstractWorkerChoiceStrategy } from './abstract-worker-choice-strategy.ts' +import type { + IWorkerChoiceStrategy, + TaskStatisticsRequirements, + WorkerChoiceStrategyOptions, +} from './selection-strategies-types.ts' + +/** + * Selects the least busy worker. + * + * @typeParam Worker - Type of worker which manages the strategy. + * @typeParam Data - Type of data sent to the worker. This can only be structured-cloneable data. + * @typeParam Response - Type of execution response. This can only be structured-cloneable data. + */ +export class LeastBusyWorkerChoiceStrategy< + Worker extends IWorker, + Data = unknown, + Response = unknown, +> extends AbstractWorkerChoiceStrategy + implements IWorkerChoiceStrategy { + /** @inheritDoc */ + public override readonly taskStatisticsRequirements: + TaskStatisticsRequirements = { + runTime: { + aggregate: true, + average: false, + median: false, + }, + waitTime: { + aggregate: true, + average: false, + median: false, + }, + elu: DEFAULT_MEASUREMENT_STATISTICS_REQUIREMENTS, + } + + /** @inheritDoc */ + public constructor( + pool: IPool, + opts?: WorkerChoiceStrategyOptions, + ) { + super(pool, opts) + this.setTaskStatisticsRequirements(this.opts) + } + + /** @inheritDoc */ + public reset(): boolean { + return true + } + + /** @inheritDoc */ + public update(): boolean { + return true + } + + /** @inheritDoc */ + public choose(): number | undefined { + this.setPreviousWorkerNodeKey(this.nextWorkerNodeKey) + this.nextWorkerNodeKey = this.leastBusyNextWorkerNodeKey() + return this.nextWorkerNodeKey + } + + /** @inheritDoc */ + public remove(): boolean { + return true + } + + private leastBusyNextWorkerNodeKey(): number | undefined { + return this.pool.workerNodes.reduce( + (minWorkerNodeKey, workerNode, workerNodeKey, workerNodes) => { + return this.isWorkerNodeReady(workerNodeKey) && + (workerNode.usage.waitTime.aggregate ?? 0) + + (workerNode.usage.runTime.aggregate ?? 0) < + (workerNodes[minWorkerNodeKey].usage.waitTime.aggregate ?? 0) + + (workerNodes[minWorkerNodeKey].usage.runTime.aggregate ?? 0) + ? workerNodeKey + : minWorkerNodeKey + }, + 0, + ) + } +} diff --git a/src/vendor/poolifier/pools/selection-strategies/least-elu-worker-choice-strategy.ts b/src/vendor/poolifier/pools/selection-strategies/least-elu-worker-choice-strategy.ts new file mode 100644 index 0000000..1ba5333 --- /dev/null +++ b/src/vendor/poolifier/pools/selection-strategies/least-elu-worker-choice-strategy.ts @@ -0,0 +1,78 @@ +import type { IPool } from '../pool.ts' +import { DEFAULT_MEASUREMENT_STATISTICS_REQUIREMENTS } from '../utils.ts' +import type { IWorker } from '../worker.ts' +import { AbstractWorkerChoiceStrategy } from './abstract-worker-choice-strategy.ts' +import type { + IWorkerChoiceStrategy, + TaskStatisticsRequirements, + WorkerChoiceStrategyOptions, +} from './selection-strategies-types.ts' + +/** + * Selects the worker with the least ELU. + * + * @typeParam Worker - Type of worker which manages the strategy. + * @typeParam Data - Type of data sent to the worker. This can only be structured-cloneable data. + * @typeParam Response - Type of execution response. This can only be structured-cloneable data. + */ +export class LeastEluWorkerChoiceStrategy< + Worker extends IWorker, + Data = unknown, + Response = unknown, +> extends AbstractWorkerChoiceStrategy + implements IWorkerChoiceStrategy { + /** @inheritDoc */ + public readonly taskStatisticsRequirements: TaskStatisticsRequirements = { + runTime: DEFAULT_MEASUREMENT_STATISTICS_REQUIREMENTS, + waitTime: DEFAULT_MEASUREMENT_STATISTICS_REQUIREMENTS, + elu: { + aggregate: true, + average: false, + median: false, + }, + } + + /** @inheritDoc */ + public constructor( + pool: IPool, + opts?: WorkerChoiceStrategyOptions, + ) { + super(pool, opts) + this.setTaskStatisticsRequirements(this.opts) + } + + /** @inheritDoc */ + public reset(): boolean { + return true + } + + /** @inheritDoc */ + public update(): boolean { + return true + } + + /** @inheritDoc */ + public choose(): number | undefined { + this.setPreviousWorkerNodeKey(this.nextWorkerNodeKey) + this.nextWorkerNodeKey = this.leastEluNextWorkerNodeKey() + return this.nextWorkerNodeKey + } + + /** @inheritDoc */ + public remove(): boolean { + return true + } + + private leastEluNextWorkerNodeKey(): number | undefined { + return this.pool.workerNodes.reduce( + (minWorkerNodeKey, workerNode, workerNodeKey, workerNodes) => { + return this.isWorkerNodeReady(workerNodeKey) && + (workerNode.usage.elu.active.aggregate ?? 0) < + (workerNodes[minWorkerNodeKey].usage.elu.active.aggregate ?? 0) + ? workerNodeKey + : minWorkerNodeKey + }, + 0, + ) + } +} diff --git a/src/vendor/poolifier/pools/selection-strategies/least-used-worker-choice-strategy.ts b/src/vendor/poolifier/pools/selection-strategies/least-used-worker-choice-strategy.ts new file mode 100644 index 0000000..22c1cd8 --- /dev/null +++ b/src/vendor/poolifier/pools/selection-strategies/least-used-worker-choice-strategy.ts @@ -0,0 +1,65 @@ +import type { IPool } from '../pool.ts' +import type { IWorker } from '../worker.ts' +import { AbstractWorkerChoiceStrategy } from './abstract-worker-choice-strategy.ts' +import type { + IWorkerChoiceStrategy, + WorkerChoiceStrategyOptions, +} from './selection-strategies-types.ts' + +/** + * Selects the least used worker. + * + * @typeParam Worker - Type of worker which manages the strategy. + * @typeParam Data - Type of data sent to the worker. This can only be structured-cloneable data. + * @typeParam Response - Type of execution response. This can only be structured-cloneable data. + */ +export class LeastUsedWorkerChoiceStrategy< + Worker extends IWorker, + Data = unknown, + Response = unknown, +> extends AbstractWorkerChoiceStrategy + implements IWorkerChoiceStrategy { + /** @inheritDoc */ + public constructor( + pool: IPool, + opts?: WorkerChoiceStrategyOptions, + ) { + super(pool, opts) + } + + /** @inheritDoc */ + public reset(): boolean { + return true + } + + /** @inheritDoc */ + public update(): boolean { + return true + } + + /** @inheritDoc */ + public choose(): number | undefined { + this.setPreviousWorkerNodeKey(this.nextWorkerNodeKey) + this.nextWorkerNodeKey = this.leastUsedNextWorkerNodeKey() + return this.nextWorkerNodeKey + } + + /** @inheritDoc */ + public remove(): boolean { + return true + } + + private leastUsedNextWorkerNodeKey(): number | undefined { + return this.pool.workerNodes.reduce( + (minWorkerNodeKey, workerNode, workerNodeKey, workerNodes) => { + return this.isWorkerNodeReady(workerNodeKey) && + workerNode.usage.tasks.executing + workerNode.usage.tasks.queued < + workerNodes[minWorkerNodeKey].usage.tasks.executing + + workerNodes[minWorkerNodeKey].usage.tasks.queued + ? workerNodeKey + : minWorkerNodeKey + }, + 0, + ) + } +} diff --git a/src/vendor/poolifier/pools/selection-strategies/round-robin-worker-choice-strategy.ts b/src/vendor/poolifier/pools/selection-strategies/round-robin-worker-choice-strategy.ts new file mode 100644 index 0000000..3358bf6 --- /dev/null +++ b/src/vendor/poolifier/pools/selection-strategies/round-robin-worker-choice-strategy.ts @@ -0,0 +1,78 @@ +import type { IPool } from '../pool.ts' +import type { IWorker } from '../worker.ts' +import { AbstractWorkerChoiceStrategy } from './abstract-worker-choice-strategy.ts' +import type { + IWorkerChoiceStrategy, + WorkerChoiceStrategyOptions, +} from './selection-strategies-types.ts' + +/** + * Selects the next worker in a round robin fashion. + * + * @typeParam Worker - Type of worker which manages the strategy. + * @typeParam Data - Type of data sent to the worker. This can only be structured-cloneable data. + * @typeParam Response - Type of execution response. This can only be structured-cloneable data. + */ +export class RoundRobinWorkerChoiceStrategy< + Worker extends IWorker, + Data = unknown, + Response = unknown, +> extends AbstractWorkerChoiceStrategy + implements IWorkerChoiceStrategy { + /** @inheritDoc */ + public constructor( + pool: IPool, + opts?: WorkerChoiceStrategyOptions, + ) { + super(pool, opts) + } + + /** @inheritDoc */ + public reset(): boolean { + this.resetWorkerNodeKeyProperties() + return true + } + + /** @inheritDoc */ + public update(): boolean { + return true + } + + /** @inheritDoc */ + public choose(): number | undefined { + const chosenWorkerNodeKey = this.nextWorkerNodeKey + this.setPreviousWorkerNodeKey(chosenWorkerNodeKey) + this.roundRobinNextWorkerNodeKey() + this.checkNextWorkerNodeKey() + return chosenWorkerNodeKey + } + + /** @inheritDoc */ + public remove(workerNodeKey: number): boolean { + if (this.pool.workerNodes.length === 0) { + this.reset() + return true + } + if ( + this.nextWorkerNodeKey === workerNodeKey && + this.nextWorkerNodeKey > this.pool.workerNodes.length - 1 + ) { + this.nextWorkerNodeKey = this.pool.workerNodes.length - 1 + } + if ( + this.previousWorkerNodeKey === workerNodeKey && + this.previousWorkerNodeKey > this.pool.workerNodes.length - 1 + ) { + this.previousWorkerNodeKey = this.pool.workerNodes.length - 1 + } + return true + } + + private roundRobinNextWorkerNodeKey(): number | undefined { + this.nextWorkerNodeKey = + this.nextWorkerNodeKey === this.pool.workerNodes.length - 1 + ? 0 + : (this.nextWorkerNodeKey ?? this.previousWorkerNodeKey) + 1 + return this.nextWorkerNodeKey + } +} diff --git a/src/vendor/poolifier/pools/selection-strategies/selection-strategies-types.ts b/src/vendor/poolifier/pools/selection-strategies/selection-strategies-types.ts new file mode 100644 index 0000000..ed73b72 --- /dev/null +++ b/src/vendor/poolifier/pools/selection-strategies/selection-strategies-types.ts @@ -0,0 +1,221 @@ +/** + * Enumeration of worker choice strategies. + */ +export const WorkerChoiceStrategies: Readonly<{ + ROUND_ROBIN: 'ROUND_ROBIN' + LEAST_USED: 'LEAST_USED' + LEAST_BUSY: 'LEAST_BUSY' + // LEAST_ELU: 'LEAST_ELU' + FAIR_SHARE: 'FAIR_SHARE' + WEIGHTED_ROUND_ROBIN: 'WEIGHTED_ROUND_ROBIN' + INTERLEAVED_WEIGHTED_ROUND_ROBIN: 'INTERLEAVED_WEIGHTED_ROUND_ROBIN' +}> = Object.freeze( + { + /** + * Round robin worker selection strategy. + */ + ROUND_ROBIN: 'ROUND_ROBIN', + /** + * Least used worker selection strategy. + */ + LEAST_USED: 'LEAST_USED', + /** + * Least busy worker selection strategy. + */ + LEAST_BUSY: 'LEAST_BUSY', + // /** + // * Least ELU worker selection strategy. + // */ + // LEAST_ELU: 'LEAST_ELU', + /** + * Fair share worker selection strategy. + */ + FAIR_SHARE: 'FAIR_SHARE', + /** + * Weighted round robin worker selection strategy. + */ + WEIGHTED_ROUND_ROBIN: 'WEIGHTED_ROUND_ROBIN', + /** + * Interleaved weighted round robin worker selection strategy. + * + * @experimental + */ + INTERLEAVED_WEIGHTED_ROUND_ROBIN: 'INTERLEAVED_WEIGHTED_ROUND_ROBIN', + } as const, +) + +/** + * Worker choice strategy. + */ +export type WorkerChoiceStrategy = keyof typeof WorkerChoiceStrategies + +/** + * Enumeration of measurements. + */ +export const Measurements: Readonly<{ + runTime: 'runTime' + waitTime: 'waitTime' + // elu: 'elu' +}> = Object.freeze( + { + runTime: 'runTime', + waitTime: 'waitTime', + // elu: 'elu', + } as const, +) + +/** + * Measurement. + */ +export type Measurement = keyof typeof Measurements + +/** + * Measurement options. + */ +export interface MeasurementOptions { + /** + * Set measurement median. + */ + readonly median: boolean +} + +/** + * Worker choice strategy options. + */ +export interface WorkerChoiceStrategyOptions { + /** + * Measurement to use in worker choice strategy supporting it. + */ + readonly measurement?: Measurement + /** + * Runtime options. + * + * @defaultValue \{ median: false \} + */ + readonly runTime?: MeasurementOptions + /** + * Wait time options. + * + * @defaultValue \{ median: false \} + */ + readonly waitTime?: MeasurementOptions + /** + * Event loop utilization options. + * + * @defaultValue \{ median: false \} + */ + readonly elu?: MeasurementOptions + /** + * Worker weights to use for weighted round robin worker selection strategies. + * A weight is tasks maximum execution time in milliseconds for a worker node. + * + * @defaultValue Weights computed automatically given the CPU performance. + */ + weights?: Record +} + +/** + * Measurement statistics requirements. + * + * @internal + */ +export interface MeasurementStatisticsRequirements { + /** + * Requires measurement aggregate. + */ + aggregate: boolean + /** + * Requires measurement average. + */ + average: boolean + /** + * Requires measurement median. + */ + median: boolean +} + +/** + * Pool worker node worker usage statistics requirements. + * + * @internal + */ +export interface TaskStatisticsRequirements { + /** + * Tasks runtime requirements. + */ + readonly runTime: MeasurementStatisticsRequirements + /** + * Tasks wait time requirements. + */ + readonly waitTime: MeasurementStatisticsRequirements + /** + * Tasks event loop utilization requirements. + */ + readonly elu: MeasurementStatisticsRequirements +} + +/** + * Strategy policy. + * + * @internal + */ +export interface StrategyPolicy { + /** + * Expects tasks execution on the newly created dynamic worker. + */ + readonly dynamicWorkerUsage: boolean + /** + * Expects the newly created dynamic worker to be flagged as ready. + */ + readonly dynamicWorkerReady: boolean +} + +/** + * Worker choice strategy interface. + * + * @internal + */ +export interface IWorkerChoiceStrategy { + /** + * Strategy policy. + */ + readonly strategyPolicy: StrategyPolicy + /** + * Tasks statistics requirements. + */ + readonly taskStatisticsRequirements: TaskStatisticsRequirements + /** + * Resets strategy internals. + * + * @returns `true` if the reset is successful, `false` otherwise. + */ + readonly reset: () => boolean + /** + * Updates the worker node key strategy internals. + * This is called after a task has been executed on a worker node. + * + * @returns `true` if the update is successful, `false` otherwise. + */ + readonly update: (workerNodeKey: number) => boolean + /** + * Chooses a worker node in the pool and returns its key. + * If no worker nodes are not eligible, `undefined` is returned. + * If `undefined` is returned, the caller retry. + * + * @returns The worker node key or `undefined`. + */ + readonly choose: () => number | undefined + /** + * Removes the worker node key from strategy internals. + * + * @param workerNodeKey - The worker node key. + * @returns `true` if the worker node key is removed, `false` otherwise. + */ + readonly remove: (workerNodeKey: number) => boolean + /** + * Sets the worker choice strategy options. + * + * @param opts - The worker choice strategy options. + */ + readonly setOptions: (opts: WorkerChoiceStrategyOptions | undefined) => void +} diff --git a/src/vendor/poolifier/pools/selection-strategies/selection-strategies-utils.ts b/src/vendor/poolifier/pools/selection-strategies/selection-strategies-utils.ts new file mode 100644 index 0000000..b8fdb7b --- /dev/null +++ b/src/vendor/poolifier/pools/selection-strategies/selection-strategies-utils.ts @@ -0,0 +1,214 @@ +import { availableParallelism, runtime } from '../../utils.ts' +import type { IPool } from '../pool.ts' +import type { IWorker } from '../worker.ts' +import { FairShareWorkerChoiceStrategy } from './fair-share-worker-choice-strategy.ts' +import { InterleavedWeightedRoundRobinWorkerChoiceStrategy } from './interleaved-weighted-round-robin-worker-choice-strategy.ts' +import { LeastBusyWorkerChoiceStrategy } from './least-busy-worker-choice-strategy.ts' +// import { LeastEluWorkerChoiceStrategy } from './least-elu-worker-choice-strategy.ts' +import { LeastUsedWorkerChoiceStrategy } from './least-used-worker-choice-strategy.ts' +import { RoundRobinWorkerChoiceStrategy } from './round-robin-worker-choice-strategy.ts' +import { + type IWorkerChoiceStrategy, + type MeasurementStatisticsRequirements, + type StrategyPolicy, + type TaskStatisticsRequirements, + WorkerChoiceStrategies, + type WorkerChoiceStrategy, + type WorkerChoiceStrategyOptions, +} from './selection-strategies-types.ts' +import { WeightedRoundRobinWorkerChoiceStrategy } from './weighted-round-robin-worker-choice-strategy.ts' +import type { WorkerChoiceStrategiesContext } from './worker-choice-strategies-context.ts' + +const estimatedCpuSpeed = (): number => { + const runs = 150000000 + const begin = performance.now() + // deno-lint-ignore no-empty + for (let i = runs; i > 0; i--) {} + const end = performance.now() + const duration = end - begin + return Math.trunc(runs / duration / 1000) // in MHz +} + +const buildCpusInfo = async (): Promise<{ speed: number }[]> => { + // deno-lint-ignore ban-ts-comment + // @ts-ignore + const cpus: { speed: number }[] = (await import('node:os')).cpus() + let estCpuSpeed: number | undefined + if (cpus.every((cpu) => cpu.speed == null || cpu.speed === 0)) { + estCpuSpeed = estimatedCpuSpeed() + } + for (const cpu of cpus) { + if (cpu.speed == null || cpu.speed === 0) { + cpu.speed = cpus.find((cpu) => + cpu.speed != null && cpu.speed !== 0 + )?.speed ?? + estCpuSpeed ?? + 2000 + } + } + return cpus +} + +const cpusCycleTimeWeight = (cpus: { speed: number }[]): number => { + let cpusCycleTimeWeight = 0 + for (const cpu of cpus) { + // CPU estimated cycle time + const numberOfDigits = cpu.speed.toString().length - 1 + const cpuCycleTime = 1 / (cpu.speed / Math.pow(10, numberOfDigits)) + cpusCycleTimeWeight += cpuCycleTime * Math.pow(10, numberOfDigits) + } + return Math.round(cpusCycleTimeWeight / cpus.length) +} + +const computedDefaultWorkerWeight: number = + await (async (): Promise => { + return await { + browser: () => { + const estCpuSpeed = estimatedCpuSpeed() + return cpusCycleTimeWeight( + Array(availableParallelism()).fill({ + speed: estCpuSpeed, + }), + ) + }, + deno: async () => { + return cpusCycleTimeWeight(await buildCpusInfo()) + }, + bun: async () => { + return cpusCycleTimeWeight(await buildCpusInfo()) + }, + }[runtime]() + })() + +const getDefaultWeights = ( + poolMaxSize: number, + defaultWorkerWeight?: number, +): Record => { + defaultWorkerWeight = defaultWorkerWeight ?? computedDefaultWorkerWeight + const weights: Record = {} + for (let workerNodeKey = 0; workerNodeKey < poolMaxSize; workerNodeKey++) { + weights[workerNodeKey] = defaultWorkerWeight + } + return weights +} + +export const getWorkerChoiceStrategiesRetries = < + Worker extends IWorker, + Data, + Response, +>( + pool: IPool, + opts?: WorkerChoiceStrategyOptions, +): number => { + return ( + pool.info.maxSize + + Object.keys(opts?.weights ?? getDefaultWeights(pool.info.maxSize)).length + ) +} + +export const buildWorkerChoiceStrategyOptions = < + Worker extends IWorker, + Data, + Response, +>( + pool: IPool, + opts?: WorkerChoiceStrategyOptions, +): WorkerChoiceStrategyOptions => { + opts = structuredClone(opts ?? {}) + opts.weights = opts.weights ?? getDefaultWeights(pool.info.maxSize) + return { + ...{ + runTime: { median: false }, + waitTime: { median: false }, + elu: { median: false }, + }, + ...opts, + } +} + +export const toggleMedianMeasurementStatisticsRequirements = ( + measurementStatisticsRequirements: MeasurementStatisticsRequirements, + toggleMedian: boolean, +): void => { + if (measurementStatisticsRequirements.average && toggleMedian) { + measurementStatisticsRequirements.average = false + measurementStatisticsRequirements.median = toggleMedian + } + if (measurementStatisticsRequirements.median && !toggleMedian) { + measurementStatisticsRequirements.average = true + measurementStatisticsRequirements.median = toggleMedian + } +} + +export const buildWorkerChoiceStrategiesPolicy = ( + workerChoiceStrategies: Map, +): StrategyPolicy => { + const policies: StrategyPolicy[] = Array.from( + workerChoiceStrategies, + ([_, workerChoiceStrategy]) => workerChoiceStrategy.strategyPolicy, + ) + return { + dynamicWorkerUsage: policies.some((p) => p.dynamicWorkerUsage), + dynamicWorkerReady: policies.some((p) => p.dynamicWorkerReady), + } +} + +export const buildWorkerChoiceStrategiesTaskStatisticsRequirements = ( + workerChoiceStrategies: Map, +): TaskStatisticsRequirements => { + const taskStatisticsRequirements: TaskStatisticsRequirements[] = Array.from( + workerChoiceStrategies, + ([_, workerChoiceStrategy]) => + workerChoiceStrategy.taskStatisticsRequirements, + ) + return { + runTime: { + aggregate: taskStatisticsRequirements.some((r) => r.runTime.aggregate), + average: taskStatisticsRequirements.some((r) => r.runTime.average), + median: taskStatisticsRequirements.some((r) => r.runTime.median), + }, + waitTime: { + aggregate: taskStatisticsRequirements.some((r) => r.waitTime.aggregate), + average: taskStatisticsRequirements.some((r) => r.waitTime.average), + median: taskStatisticsRequirements.some((r) => r.waitTime.median), + }, + elu: { + aggregate: taskStatisticsRequirements.some((r) => r.elu.aggregate), + average: taskStatisticsRequirements.some((r) => r.elu.average), + median: taskStatisticsRequirements.some((r) => r.elu.median), + }, + } +} + +export const getWorkerChoiceStrategy = ( + workerChoiceStrategy: WorkerChoiceStrategy, + pool: IPool, + context: ThisType>, + opts?: WorkerChoiceStrategyOptions, +): IWorkerChoiceStrategy => { + switch (workerChoiceStrategy) { + case WorkerChoiceStrategies.ROUND_ROBIN: + return new (RoundRobinWorkerChoiceStrategy.bind(context))(pool, opts) + case WorkerChoiceStrategies.LEAST_USED: + return new (LeastUsedWorkerChoiceStrategy.bind(context))(pool, opts) + case WorkerChoiceStrategies.LEAST_BUSY: + return new (LeastBusyWorkerChoiceStrategy.bind(context))(pool, opts) + // case WorkerChoiceStrategies.LEAST_ELU: + // return new (LeastEluWorkerChoiceStrategy.bind(context))(pool, opts) + case WorkerChoiceStrategies.FAIR_SHARE: + return new (FairShareWorkerChoiceStrategy.bind(context))(pool, opts) + case WorkerChoiceStrategies.WEIGHTED_ROUND_ROBIN: + return new (WeightedRoundRobinWorkerChoiceStrategy.bind(context))( + pool, + opts, + ) + case WorkerChoiceStrategies.INTERLEAVED_WEIGHTED_ROUND_ROBIN: + return new (InterleavedWeightedRoundRobinWorkerChoiceStrategy.bind( + context, + ))(pool, opts) + default: + throw new Error( + `Worker choice strategy '${workerChoiceStrategy}' is not valid`, + ) + } +} diff --git a/src/vendor/poolifier/pools/selection-strategies/weighted-round-robin-worker-choice-strategy.ts b/src/vendor/poolifier/pools/selection-strategies/weighted-round-robin-worker-choice-strategy.ts new file mode 100644 index 0000000..dfee04a --- /dev/null +++ b/src/vendor/poolifier/pools/selection-strategies/weighted-round-robin-worker-choice-strategy.ts @@ -0,0 +1,115 @@ +import type { IPool } from '../pool.ts' +import { DEFAULT_MEASUREMENT_STATISTICS_REQUIREMENTS } from '../utils.ts' +import type { IWorker } from '../worker.ts' +import { AbstractWorkerChoiceStrategy } from './abstract-worker-choice-strategy.ts' +import type { + IWorkerChoiceStrategy, + TaskStatisticsRequirements, + WorkerChoiceStrategyOptions, +} from './selection-strategies-types.ts' + +/** + * Selects the next worker with a weighted round robin scheduling algorithm. + * Loosely modeled after the weighted round robin queueing algorithm: https://en.wikipedia.org/wiki/Weighted_round_robin. + * + * @typeParam Worker - Type of worker which manages the strategy. + * @typeParam Data - Type of data sent to the worker. This can only be structured-cloneable data. + * @typeParam Response - Type of execution response. This can only be structured-cloneable data. + */ +export class WeightedRoundRobinWorkerChoiceStrategy< + Worker extends IWorker, + Data = unknown, + Response = unknown, +> extends AbstractWorkerChoiceStrategy + implements IWorkerChoiceStrategy { + /** @inheritDoc */ + public override readonly taskStatisticsRequirements: + TaskStatisticsRequirements = { + runTime: { + aggregate: true, + average: true, + median: false, + }, + waitTime: { + aggregate: true, + average: true, + median: false, + }, + elu: DEFAULT_MEASUREMENT_STATISTICS_REQUIREMENTS, + } + + /** + * Worker node virtual task execution time. + */ + private workerNodeVirtualTaskExecutionTime = 0 + + /** @inheritDoc */ + public constructor( + pool: IPool, + opts?: WorkerChoiceStrategyOptions, + ) { + super(pool, opts) + this.setTaskStatisticsRequirements(this.opts) + } + + /** @inheritDoc */ + public reset(): boolean { + this.resetWorkerNodeKeyProperties() + this.workerNodeVirtualTaskExecutionTime = 0 + return true + } + + /** @inheritDoc */ + public update(): boolean { + return true + } + + /** @inheritDoc */ + public choose(): number | undefined { + this.setPreviousWorkerNodeKey(this.nextWorkerNodeKey) + this.weightedRoundRobinNextWorkerNodeKey() + this.checkNextWorkerNodeKey() + return this.nextWorkerNodeKey + } + + /** @inheritDoc */ + public remove(workerNodeKey: number): boolean { + if (this.pool.workerNodes.length === 0) { + this.reset() + return true + } + if (this.nextWorkerNodeKey === workerNodeKey) { + this.workerNodeVirtualTaskExecutionTime = 0 + if (this.nextWorkerNodeKey > this.pool.workerNodes.length - 1) { + this.nextWorkerNodeKey = this.pool.workerNodes.length - 1 + } + } + if ( + this.previousWorkerNodeKey === workerNodeKey && + this.previousWorkerNodeKey > this.pool.workerNodes.length - 1 + ) { + this.previousWorkerNodeKey = this.pool.workerNodes.length - 1 + } + return true + } + + private weightedRoundRobinNextWorkerNodeKey(): number | undefined { + const workerWeight = + this.opts!.weights![this.nextWorkerNodeKey ?? this.previousWorkerNodeKey] + if (this.workerNodeVirtualTaskExecutionTime < workerWeight) { + this.workerNodeVirtualTaskExecutionTime += this.getWorkerNodeTaskWaitTime( + this.nextWorkerNodeKey ?? this.previousWorkerNodeKey, + ) + + this.getWorkerNodeTaskRunTime( + this.nextWorkerNodeKey ?? this.previousWorkerNodeKey, + ) + } else { + this.nextWorkerNodeKey = + this.nextWorkerNodeKey === this.pool.workerNodes.length - 1 + ? 0 + : (this.nextWorkerNodeKey ?? this.previousWorkerNodeKey) + 1 + this.workerNodeVirtualTaskExecutionTime = 0 + } + return this.nextWorkerNodeKey + } +} diff --git a/src/vendor/poolifier/pools/selection-strategies/worker-choice-strategies-context.ts b/src/vendor/poolifier/pools/selection-strategies/worker-choice-strategies-context.ts new file mode 100644 index 0000000..89a9ddc --- /dev/null +++ b/src/vendor/poolifier/pools/selection-strategies/worker-choice-strategies-context.ts @@ -0,0 +1,283 @@ +import type { IPool } from '../pool.ts' +import type { IWorker } from '../worker.ts' +import type { + IWorkerChoiceStrategy, + StrategyPolicy, + TaskStatisticsRequirements, + WorkerChoiceStrategy, + WorkerChoiceStrategyOptions, +} from './selection-strategies-types.ts' +import { WorkerChoiceStrategies } from './selection-strategies-types.ts' +import { + buildWorkerChoiceStrategiesPolicy, + buildWorkerChoiceStrategiesTaskStatisticsRequirements, + getWorkerChoiceStrategiesRetries, + getWorkerChoiceStrategy, +} from './selection-strategies-utils.ts' + +/** + * The worker choice strategies context. + * + * @typeParam Worker - Type of worker. + * @typeParam Data - Type of data sent to the worker. This can only be structured-cloneable data. + * @typeParam Response - Type of execution response. This can only be structured-cloneable data. + * @internal + */ +export class WorkerChoiceStrategiesContext< + Worker extends IWorker, + Data = unknown, + Response = unknown, +> { + /** + * The number of worker choice strategies execution retries. + */ + public retriesCount: number + + /** + * The default worker choice strategy in the context. + */ + private defaultWorkerChoiceStrategy: WorkerChoiceStrategy + + /** + * The worker choice strategies registered in the context. + */ + private readonly workerChoiceStrategies: Map< + WorkerChoiceStrategy, + IWorkerChoiceStrategy + > + + /** + * The active worker choice strategies in the context policy. + */ + private workerChoiceStrategiesPolicy: StrategyPolicy + + /** + * The active worker choice strategies in the context task statistics requirements. + */ + private workerChoiceStrategiesTaskStatisticsRequirements: + TaskStatisticsRequirements + + /** + * The maximum number of worker choice strategies execution retries. + */ + private readonly retries: number + + /** + * Worker choice strategies context constructor. + * + * @param pool - The pool instance. + * @param workerChoiceStrategies - The worker choice strategies. @defaultValue [WorkerChoiceStrategies.ROUND_ROBIN] + * @param opts - The worker choice strategy options. + */ + public constructor( + private readonly pool: IPool, + workerChoiceStrategies: WorkerChoiceStrategy[] = [ + WorkerChoiceStrategies.ROUND_ROBIN, + ], + opts?: WorkerChoiceStrategyOptions, + ) { + this.execute = this.execute.bind(this) + this.defaultWorkerChoiceStrategy = workerChoiceStrategies[0] + this.workerChoiceStrategies = new Map< + WorkerChoiceStrategy, + IWorkerChoiceStrategy + >() + for (const workerChoiceStrategy of workerChoiceStrategies) { + this.addWorkerChoiceStrategy(workerChoiceStrategy, this.pool, opts) + } + this.workerChoiceStrategiesPolicy = buildWorkerChoiceStrategiesPolicy( + this.workerChoiceStrategies, + ) + this.workerChoiceStrategiesTaskStatisticsRequirements = + buildWorkerChoiceStrategiesTaskStatisticsRequirements( + this.workerChoiceStrategies, + ) + this.retriesCount = 0 + this.retries = getWorkerChoiceStrategiesRetries( + this.pool, + opts, + ) + } + + /** + * Gets the active worker choice strategies in the context policy. + * + * @returns The strategies policy. + */ + public getPolicy(): StrategyPolicy { + return this.workerChoiceStrategiesPolicy + } + + /** + * Gets the active worker choice strategies in the context task statistics requirements. + * + * @returns The strategies task statistics requirements. + */ + public getTaskStatisticsRequirements(): TaskStatisticsRequirements { + return this.workerChoiceStrategiesTaskStatisticsRequirements + } + + /** + * Sets the default worker choice strategy to use in the context. + * + * @param workerChoiceStrategy - The default worker choice strategy to set. + * @param opts - The worker choice strategy options. + */ + public setDefaultWorkerChoiceStrategy( + workerChoiceStrategy: WorkerChoiceStrategy, + opts?: WorkerChoiceStrategyOptions, + ): void { + if (workerChoiceStrategy !== this.defaultWorkerChoiceStrategy) { + this.defaultWorkerChoiceStrategy = workerChoiceStrategy + this.addWorkerChoiceStrategy(workerChoiceStrategy, this.pool, opts) + } + } + + /** + * Updates the worker node key in the active worker choice strategies in the context internals. + * + * @param workerNodeKey - The worker node key. + * @returns `true` if the update is successful, `false` otherwise. + */ + public update(workerNodeKey: number): boolean { + return Array.from( + this.workerChoiceStrategies, + ([_, workerChoiceStrategy]) => workerChoiceStrategy.update(workerNodeKey), + ).every((r) => r) + } + + /** + * Executes the given worker choice strategy in the context algorithm. + * + * @param workerChoiceStrategy - The worker choice strategy algorithm to execute. @defaultValue this.defaultWorkerChoiceStrategy + * @returns The key of the worker node. + * @throws {@link https://nodejs.org/api/errors.html#class-error} If after computed retries the worker node key is null or undefined. + */ + public execute( + workerChoiceStrategy: WorkerChoiceStrategy = this + .defaultWorkerChoiceStrategy, + ): number { + return this.executeStrategy( + this.workerChoiceStrategies.get(workerChoiceStrategy)!, + ) + } + + /** + * Executes the given worker choice strategy. + * + * @param workerChoiceStrategy - The worker choice strategy. + * @returns The key of the worker node. + * @throws {@link https://nodejs.org/api/errors.html#class-error} If after computed retries the worker node key is null or undefined. + */ + private executeStrategy(workerChoiceStrategy: IWorkerChoiceStrategy): number { + let workerNodeKey: number | undefined + let chooseCount = 0 + let retriesCount = 0 + do { + workerNodeKey = workerChoiceStrategy.choose() + if (workerNodeKey == null && chooseCount > 0) { + ++retriesCount + ++this.retriesCount + } + ++chooseCount + } while (workerNodeKey == null && retriesCount < this.retries) + if (workerNodeKey == null) { + throw new Error( + `Worker node key chosen is null or undefined after ${retriesCount.toString()} retries`, + ) + } + return workerNodeKey + } + + /** + * Removes the worker node key from the active worker choice strategies in the context. + * + * @param workerNodeKey - The worker node key. + * @returns `true` if the removal is successful, `false` otherwise. + */ + public remove(workerNodeKey: number): boolean { + return Array.from( + this.workerChoiceStrategies, + ([_, workerChoiceStrategy]) => workerChoiceStrategy.remove(workerNodeKey), + ).every((r) => r) + } + + /** + * Sets the active worker choice strategies in the context options. + * + * @param opts - The worker choice strategy options. + */ + public setOptions(opts: WorkerChoiceStrategyOptions | undefined): void { + for (const workerChoiceStrategy of this.workerChoiceStrategies.values()) { + workerChoiceStrategy.setOptions(opts) + } + } + + /** + * Synchronizes the active worker choice strategies in the context with the given worker choice strategies. + * + * @param workerChoiceStrategies - The worker choice strategies to synchronize. + * @param opts - The worker choice strategy options. + */ + public syncWorkerChoiceStrategies( + workerChoiceStrategies: Set, + opts?: WorkerChoiceStrategyOptions, + ): void { + for (const workerChoiceStrategy of this.workerChoiceStrategies.keys()) { + if (!workerChoiceStrategies.has(workerChoiceStrategy)) { + this.removeWorkerChoiceStrategy(workerChoiceStrategy) + } + } + for (const workerChoiceStrategy of workerChoiceStrategies) { + if (!this.workerChoiceStrategies.has(workerChoiceStrategy)) { + this.addWorkerChoiceStrategy(workerChoiceStrategy, this.pool, opts) + } + } + this.workerChoiceStrategiesPolicy = buildWorkerChoiceStrategiesPolicy( + this.workerChoiceStrategies, + ) + this.workerChoiceStrategiesTaskStatisticsRequirements = + buildWorkerChoiceStrategiesTaskStatisticsRequirements( + this.workerChoiceStrategies, + ) + } + + /** + * Adds a worker choice strategy to the context. + * + * @param workerChoiceStrategy - The worker choice strategy to add. + * @param opts - The worker choice strategy options. + * @param pool - The pool instance. + * @returns The worker choice strategies. + */ + private addWorkerChoiceStrategy( + workerChoiceStrategy: WorkerChoiceStrategy, + pool: IPool, + opts?: WorkerChoiceStrategyOptions, + ): Map { + if (!this.workerChoiceStrategies.has(workerChoiceStrategy)) { + return this.workerChoiceStrategies.set( + workerChoiceStrategy, + getWorkerChoiceStrategy( + workerChoiceStrategy, + pool, + this, + opts, + ), + ) + } + return this.workerChoiceStrategies + } + + /** + * Removes a worker choice strategy from the context. + * + * @param workerChoiceStrategy - The worker choice strategy to remove. + * @returns `true` if the worker choice strategy is removed, `false` otherwise. + */ + private removeWorkerChoiceStrategy( + workerChoiceStrategy: WorkerChoiceStrategy, + ): boolean { + return this.workerChoiceStrategies.delete(workerChoiceStrategy) + } +} diff --git a/src/vendor/poolifier/pools/thread/dynamic.ts b/src/vendor/poolifier/pools/thread/dynamic.ts new file mode 100644 index 0000000..9c28182 --- /dev/null +++ b/src/vendor/poolifier/pools/thread/dynamic.ts @@ -0,0 +1,129 @@ +import { PoolEvents, type PoolInfo, type PoolType, PoolTypes } from '../pool.ts' +import { checkDynamicPoolSize } from '../utils.ts' +import { FixedThreadPool, type ThreadPoolOptions } from './fixed.ts' + +/** + * A thread pool with a dynamic number of threads, but a guaranteed minimum number of threads. + * + * This thread pool creates new threads when the others are busy, up to the maximum number of threads. + * When the maximum number of threads is reached and workers are busy, an event is emitted. If you want to listen to this event, use the pool's `eventTarget`. + * + * @typeParam Data - Type of data sent to the worker. This can only be structured-cloneable data. + * @typeParam Response - Type of execution response. This can only be structured-cloneable data. + * @author [Alessandro Pio Ardizio](https://github.com/pioardi) + * @since 0.0.1 + */ +export class DynamicThreadPool< + Data = unknown, + Response = unknown, +> extends FixedThreadPool { + /** + * Whether the pool empty event has been emitted or not + */ + private emptyEventEmitted: boolean + + /** + * Whether the pool full event has been emitted or not. + */ + private fullEventEmitted: boolean + + /** + * Constructs a new poolifier dynamic thread pool. + * + * @param min - Minimum number of threads which are always active. + * @param max - Maximum number of threads that can be created by this pool. + * @param fileURL - URL to an implementation of a `ThreadWorker` file. + * @param opts - Options for this dynamic thread pool. + */ + public constructor( + min: number, + max: number, + fileURL: URL, + opts: ThreadPoolOptions = {}, + ) { + super(min, fileURL, opts, max) + checkDynamicPoolSize( + this.minimumNumberOfWorkers, + this.maximumNumberOfWorkers!, + ) + this.emptyEventEmitted = false + this.fullEventEmitted = false + } + + /** @inheritDoc */ + protected override shallCreateDynamicWorker(): boolean { + return (!this.full && this.internalBusy()) || this.empty + } + + /** @inheritDoc */ + protected override checkAndEmitDynamicWorkerCreationEvents(): void { + if (this.eventTarget != null) { + if (!this.fullEventEmitted && this.full) { + this.eventTarget.dispatchEvent( + new CustomEvent(PoolEvents.full, { detail: this.info }), + ) + this.fullEventEmitted = true + } + if (this.emptyEventEmitted && !this.empty) { + this.emptyEventEmitted = false + } + } + } + + /** @inheritDoc */ + protected override checkAndEmitDynamicWorkerDestructionEvents(): void { + if (this.eventTarget != null) { + if (this.fullEventEmitted && !this.full) { + this.eventTarget.dispatchEvent( + new CustomEvent(PoolEvents.fullEnd, { detail: this.info }), + ) + this.fullEventEmitted = false + } + if (!this.emptyEventEmitted && this.empty) { + this.eventTarget.dispatchEvent( + new CustomEvent(PoolEvents.empty, { detail: this.info }), + ) + this.emptyEventEmitted = true + } + } + } + + /** + * Whether the pool is empty or not. + * + * @returns The pool emptiness boolean status. + */ + private get empty(): boolean { + return ( + this.minimumNumberOfWorkers === 0 && + this.workerNodes.length === this.minimumNumberOfWorkers + ) + } + + /** + * Whether the pool is full or not. + * + * @returns The pool fullness boolean status. + */ + private get full(): boolean { + return ( + this.workerNodes.length >= + (this.maximumNumberOfWorkers ?? this.minimumNumberOfWorkers) + ) + } + + /** @inheritDoc */ + protected override get type(): PoolType { + return PoolTypes.dynamic + } + + /** @inheritDoc */ + protected override get backPressure(): boolean { + return this.full && this.internalBackPressure() + } + + /** @inheritDoc */ + protected override get busy(): boolean { + return this.full && this.internalBusy() + } +} diff --git a/src/vendor/poolifier/pools/thread/fixed.ts b/src/vendor/poolifier/pools/thread/fixed.ts new file mode 100644 index 0000000..f6d7f9a --- /dev/null +++ b/src/vendor/poolifier/pools/thread/fixed.ts @@ -0,0 +1,144 @@ +import type { MessageValue } from '../../utility-types.ts' +import { isWebWorker } from '../../utils.ts' +import { AbstractPool } from '../abstract-pool.ts' +import { type PoolOptions, type PoolType, PoolTypes } from '../pool.ts' +import { messageListenerToEventListener } from '../utils.ts' +import { type WorkerType, WorkerTypes } from '../worker.ts' + +/** + * Options for a poolifier thread pool. + */ +export type ThreadPoolOptions = PoolOptions + +/** + * A thread pool with a fixed number of threads. + * + * @typeParam Data - Type of data sent to the worker. This can only be structured-cloneable data. + * @typeParam Response - Type of execution response. This can only be structured-cloneable data. + * @author [Alessandro Pio Ardizio](https://github.com/pioardi) + * @since 0.0.1 + */ +export class FixedThreadPool< + Data = unknown, + Response = unknown, +> extends AbstractPool { + /** + * Constructs a new poolifier fixed thread pool. + * + * @param numberOfThreads - Number of threads for this pool. + * @param fileURL - URL to an implementation of a `ThreadWorker` file. + * @param opts - Options for this fixed thread pool. + */ + public constructor( + numberOfThreads: number, + fileURL: URL, + opts: ThreadPoolOptions = {}, + maximumNumberOfThreads?: number, + ) { + super(numberOfThreads, fileURL, opts, maximumNumberOfThreads) + } + /** @inheritDoc */ + protected isMain(): boolean { + return !isWebWorker + } + + /** @inheritDoc */ + protected sendToWorker( + workerNodeKey: number, + message: MessageValue, + transferList?: readonly Transferable[], + ): void { + this.workerNodes[workerNodeKey]?.messageChannel?.port1.postMessage( + { + ...message, + workerId: this.getWorkerInfo(workerNodeKey)?.id, + } satisfies MessageValue, + transferList as Transferable[], + ) + } + + /** @inheritDoc */ + protected sendStartupMessageToWorker(workerNodeKey: number): void { + const workerNode = this.workerNodes[workerNodeKey] + const port2 = workerNode.messageChannel!.port2 + workerNode.worker.postMessage( + { + ready: false, + workerId: this.getWorkerInfo(workerNodeKey)?.id, + port: port2, + } satisfies MessageValue, + [port2], + ) + } + + /** @inheritDoc */ + protected registerWorkerMessageListener( + workerNodeKey: number, + listener: (message: MessageValue) => void, + ): void { + this.workerNodes[workerNodeKey].addEventListener( + 'message', + messageListenerToEventListener(listener), + ) + } + + /** @inheritDoc */ + protected registerOnceWorkerMessageListener( + workerNodeKey: number, + listener: (message: MessageValue) => void, + ): void { + this.workerNodes[workerNodeKey].addEventListener( + 'message', + messageListenerToEventListener(listener), + { + once: true, + }, + ) + } + + /** @inheritDoc */ + protected deregisterWorkerMessageListener( + workerNodeKey: number, + listener: (message: MessageValue) => void, + ): void { + this.workerNodes[workerNodeKey].removeEventListener( + 'message', + messageListenerToEventListener(listener), + ) + } + + /** @inheritDoc */ + protected shallCreateDynamicWorker(): boolean { + return false + } + + /** @inheritDoc */ + protected checkAndEmitDynamicWorkerCreationEvents(): void { + /* noop */ + } + + /** @inheritDoc */ + protected checkAndEmitDynamicWorkerDestructionEvents(): void { + /* noop */ + } + + /** @inheritDoc */ + protected get type(): PoolType { + return PoolTypes.fixed + } + + /** @inheritDoc */ + protected get worker(): WorkerType { + return WorkerTypes.web + } + + /** @inheritDoc */ + protected get backPressure(): boolean { + return this.internalBackPressure() + } + + /** @inheritDoc */ + protected get busy(): boolean { + return this.internalBusy() + } +} diff --git a/src/vendor/poolifier/pools/utils.ts b/src/vendor/poolifier/pools/utils.ts new file mode 100644 index 0000000..8caec83 --- /dev/null +++ b/src/vendor/poolifier/pools/utils.ts @@ -0,0 +1,476 @@ +import type { MessageValue, Task } from '../utility-types.ts' +import { + average, + environment, + isPlainObject, + JavaScriptRuntimes, + max, + median, + min, + runtime, +} from '../utils.ts' +import type { TasksQueueOptions } from './pool.ts' +import { + type MeasurementStatisticsRequirements, + WorkerChoiceStrategies, + type WorkerChoiceStrategy, +} from './selection-strategies/selection-strategies-types.ts' +import type { WorkerChoiceStrategiesContext } from './selection-strategies/worker-choice-strategies-context.ts' +import { + type IWorker, + type IWorkerNode, + type MeasurementStatistics, + type WorkerNodeOptions, + type WorkerType, + WorkerTypes, + type WorkerUsage, +} from './worker.ts' + +let exportedUpdateMeasurementStatistics: ( + measurementStatistics: MeasurementStatistics, + measurementRequirements: MeasurementStatisticsRequirements | undefined, + measurementValue: number | undefined, +) => void +export { exportedUpdateMeasurementStatistics } + +/** + * Default measurement statistics requirements. + */ +export const DEFAULT_MEASUREMENT_STATISTICS_REQUIREMENTS: + MeasurementStatisticsRequirements = { + aggregate: false, + average: false, + median: false, + } + +export const getDefaultTasksQueueOptions = ( + poolMaxSize: number, +): Required => { + return { + size: Math.pow(poolMaxSize, 2), + concurrency: 1, + taskStealing: true, + tasksStealingOnBackPressure: true, + tasksStealingRatio: 0.6, + tasksFinishedTimeout: 2000, + } +} + +export const checkFileURL = (fileURL: URL | undefined): void => { + if (fileURL == null) { + throw new TypeError('The worker URL must be specified') + } + if (fileURL instanceof URL === false) { + throw new TypeError('The worker URL must be an instance of URL') + } +} + +export const checkDynamicPoolSize = ( + min: number, + max: number | undefined, +): void => { + if (max == null) { + throw new TypeError( + 'Cannot instantiate a dynamic pool without specifying the maximum pool size', + ) + } else if (!Number.isSafeInteger(max)) { + throw new TypeError( + 'Cannot instantiate a dynamic pool with a non safe integer maximum pool size', + ) + } else if (min > max) { + throw new RangeError( + 'Cannot instantiate a dynamic pool with a maximum pool size inferior to the minimum pool size', + ) + } else if (max === 0) { + throw new RangeError( + 'Cannot instantiate a dynamic pool with a maximum pool size equal to zero', + ) + } else if (min === max) { + throw new RangeError( + 'Cannot instantiate a dynamic pool with a minimum pool size equal to the maximum pool size. Use a fixed pool instead', + ) + } +} + +export const checkValidPriority = (priority: number | undefined): void => { + if (priority != null && !Number.isSafeInteger(priority)) { + throw new TypeError(`Invalid property 'priority': '${priority.toString()}'`) + } + if ( + priority != null && + Number.isSafeInteger(priority) && + (priority < -20 || priority > 19) + ) { + throw new RangeError("Property 'priority' must be between -20 and 19") + } +} + +export const checkValidWorkerChoiceStrategy = ( + workerChoiceStrategy: WorkerChoiceStrategy | undefined, +): void => { + if ( + workerChoiceStrategy != null && + !Object.values(WorkerChoiceStrategies).includes(workerChoiceStrategy) + ) { + throw new Error(`Invalid worker choice strategy '${workerChoiceStrategy}'`) + } +} + +export const checkValidTasksQueueOptions = ( + tasksQueueOptions: TasksQueueOptions | undefined, +): void => { + if (tasksQueueOptions != null && !isPlainObject(tasksQueueOptions)) { + throw new TypeError('Invalid tasks queue options: must be a plain object') + } + if ( + tasksQueueOptions?.concurrency != null && + !Number.isSafeInteger(tasksQueueOptions.concurrency) + ) { + throw new TypeError( + 'Invalid worker node tasks concurrency: must be an integer', + ) + } + if ( + tasksQueueOptions?.concurrency != null && + tasksQueueOptions.concurrency <= 0 + ) { + throw new RangeError( + `Invalid worker node tasks concurrency: ${tasksQueueOptions.concurrency.toString()} is a negative integer or zero`, + ) + } + if ( + tasksQueueOptions?.size != null && + !Number.isSafeInteger(tasksQueueOptions.size) + ) { + throw new TypeError( + 'Invalid worker node tasks queue size: must be an integer', + ) + } + if (tasksQueueOptions?.size != null && tasksQueueOptions.size <= 0) { + throw new RangeError( + `Invalid worker node tasks queue size: ${tasksQueueOptions.size.toString()} is a negative integer or zero`, + ) + } + if ( + tasksQueueOptions?.tasksStealingRatio != null && + typeof tasksQueueOptions.tasksStealingRatio !== 'number' + ) { + throw new TypeError( + 'Invalid worker node tasks stealing ratio: must be a number', + ) + } + if ( + tasksQueueOptions?.tasksStealingRatio != null && + (tasksQueueOptions.tasksStealingRatio < 0 || + tasksQueueOptions.tasksStealingRatio > 1) + ) { + throw new RangeError( + 'Invalid worker node tasks stealing ratio: must be between 0 and 1', + ) + } +} + +export const checkWorkerNodeArguments = ( + type: WorkerType | undefined, + fileURL: URL | undefined, + opts: WorkerNodeOptions | undefined, +): void => { + if (type == null) { + throw new TypeError('Cannot construct a worker node without a worker type') + } + if (!Object.values(WorkerTypes).includes(type)) { + throw new TypeError( + `Cannot construct a worker node with an invalid worker type '${type}'`, + ) + } + checkFileURL(fileURL) + if (opts == null) { + throw new TypeError( + 'Cannot construct a worker node without worker node options', + ) + } + if (!isPlainObject(opts)) { + throw new TypeError( + 'Cannot construct a worker node with invalid worker node options: must be a plain object', + ) + } + if (opts.tasksQueueBackPressureSize == null) { + throw new TypeError( + 'Cannot construct a worker node without a tasks queue back pressure size option', + ) + } + if (!Number.isSafeInteger(opts.tasksQueueBackPressureSize)) { + throw new TypeError( + 'Cannot construct a worker node with a tasks queue back pressure size option that is not an integer', + ) + } + if (opts.tasksQueueBackPressureSize <= 0) { + throw new RangeError( + 'Cannot construct a worker node with a tasks queue back pressure size option that is not a positive integer', + ) + } + if (opts.tasksQueueBucketSize == null) { + throw new TypeError( + 'Cannot construct a worker node without a tasks queue bucket size option', + ) + } + if (!Number.isSafeInteger(opts.tasksQueueBucketSize)) { + throw new TypeError( + 'Cannot construct a worker node with a tasks queue bucket size option that is not an integer', + ) + } + if (opts.tasksQueueBucketSize <= 0) { + throw new RangeError( + 'Cannot construct a worker node with a tasks queue bucket size option that is not a positive integer', + ) + } + if (opts.tasksQueuePriority == null) { + throw new TypeError( + 'Cannot construct a worker node without a tasks queue priority option', + ) + } + if (typeof opts.tasksQueuePriority !== 'boolean') { + throw new TypeError( + 'Cannot construct a worker node with a tasks queue priority option that is not a boolean', + ) + } +} + +/** + * Updates the given measurement statistics. + * + * @param measurementStatistics - The measurement statistics to update. + * @param measurementRequirements - The measurement statistics requirements. + * @param measurementValue - The measurement value. + * @internal + */ +const updateMeasurementStatistics = ( + measurementStatistics: MeasurementStatistics, + measurementRequirements: MeasurementStatisticsRequirements | undefined, + measurementValue: number | undefined, +): void => { + if ( + measurementRequirements != null && + measurementValue != null && + measurementRequirements.aggregate + ) { + measurementStatistics.aggregate = (measurementStatistics.aggregate ?? 0) + + measurementValue + measurementStatistics.minimum = min( + measurementValue, + measurementStatistics.minimum ?? Number.POSITIVE_INFINITY, + ) + measurementStatistics.maximum = max( + measurementValue, + measurementStatistics.maximum ?? Number.NEGATIVE_INFINITY, + ) + if (measurementRequirements.average || measurementRequirements.median) { + measurementStatistics.history.put(measurementValue) + if (measurementRequirements.average) { + measurementStatistics.average = average( + measurementStatistics.history.toArray(), + ) + } else if (measurementStatistics.average != null) { + delete measurementStatistics.average + } + if (measurementRequirements.median) { + measurementStatistics.median = median( + measurementStatistics.history.toArray(), + ) + } else if (measurementStatistics.median != null) { + delete measurementStatistics.median + } + } + } +} +if (environment === 'test') { + exportedUpdateMeasurementStatistics = updateMeasurementStatistics +} + +export const updateWaitTimeWorkerUsage = < + Worker extends IWorker, + Data = unknown, + Response = unknown, +>( + workerChoiceStrategiesContext: + | WorkerChoiceStrategiesContext + | undefined, + workerUsage: WorkerUsage, + task: Task, +): void => { + const timestamp = performance.now() + const taskWaitTime = timestamp - (task.timestamp ?? timestamp) + updateMeasurementStatistics( + workerUsage.waitTime, + workerChoiceStrategiesContext?.getTaskStatisticsRequirements().waitTime, + taskWaitTime, + ) +} + +export const updateTaskStatisticsWorkerUsage = ( + workerUsage: WorkerUsage, + message: MessageValue, +): void => { + const workerTaskStatistics = workerUsage.tasks + if ( + workerTaskStatistics.executing != null && + workerTaskStatistics.executing > 0 + ) { + --workerTaskStatistics.executing + } + if (message.workerError == null) { + ++workerTaskStatistics.executed + } else { + ++workerTaskStatistics.failed + } +} + +export const updateRunTimeWorkerUsage = < + Worker extends IWorker, + Data = unknown, + Response = unknown, +>( + workerChoiceStrategiesContext: + | WorkerChoiceStrategiesContext + | undefined, + workerUsage: WorkerUsage, + message: MessageValue, +): void => { + if (message.workerError != null) { + return + } + updateMeasurementStatistics( + workerUsage.runTime, + workerChoiceStrategiesContext?.getTaskStatisticsRequirements().runTime, + message.taskPerformance?.runTime ?? 0, + ) +} + +export const updateEluWorkerUsage = < + Worker extends IWorker, + Data = unknown, + Response = unknown, +>( + workerChoiceStrategiesContext: + | WorkerChoiceStrategiesContext + | undefined, + workerUsage: WorkerUsage, + message: MessageValue, +): void => { + if (message.workerError != null) { + return + } + const eluTaskStatisticsRequirements = workerChoiceStrategiesContext + ?.getTaskStatisticsRequirements().elu + updateMeasurementStatistics( + workerUsage.elu.active, + eluTaskStatisticsRequirements, + message.taskPerformance?.elu?.active ?? 0, + ) + updateMeasurementStatistics( + workerUsage.elu.idle, + eluTaskStatisticsRequirements, + message.taskPerformance?.elu?.idle ?? 0, + ) + if (eluTaskStatisticsRequirements?.aggregate === true) { + if (message.taskPerformance?.elu != null) { + if (workerUsage.elu.utilization != null) { + workerUsage.elu.utilization = (workerUsage.elu.utilization + + message.taskPerformance.elu.utilization) / + 2 + } else { + workerUsage.elu.utilization = message.taskPerformance.elu.utilization + } + } + } +} + +export const messageListenerToEventListener = ( + listener: (message: MessageValue) => void, +): (event: Event) => void => +(event: Event) => listener((event as MessageEvent>).data) + +export const createWorker = ( + type: WorkerType, + fileURL: URL, + opts: { workerOptions?: WorkerOptions }, +): Worker => { + switch (type) { + case WorkerTypes.web: + return new Worker(fileURL, { + ...(runtime === JavaScriptRuntimes.bun && { smol: true }), + ...opts.workerOptions, + type: 'module', + }) as Worker + default: + throw new Error(`Unknown worker type '${type}'`) + } +} + +/** + * Returns the worker type of the given worker. + * + * @param worker - The worker to get the type of. + * @returns The worker type of the given worker. + * @internal + */ +export const getWorkerType = (worker: IWorker): WorkerType | undefined => { + if (worker instanceof Worker) { + return WorkerTypes.web + } +} + +/** + * Returns the worker id of the given worker. + * + * @param worker - The worker to get the id of. + * @returns The worker id of the given worker. + * @internal + */ +export const getWorkerId = ( + worker: IWorker, +): `${string}-${string}-${string}-${string}-${string}` | undefined => { + if (worker instanceof Worker) { + return crypto.randomUUID() + } +} + +export const waitWorkerNodeEvents = async < + Worker extends IWorker, + Data = unknown, +>( + workerNode: IWorkerNode, + workerNodeEvent: string, + numberOfEventsToWait: number, + timeout: number, +): Promise => { + return await new Promise((resolve) => { + let events = 0 + if (numberOfEventsToWait === 0) { + resolve(events) + return + } + switch (workerNodeEvent) { + case 'message': + case 'messageerror': + case 'taskFinished': + case 'backPressure': + case 'idle': + case 'exit': + workerNode.addEventListener(workerNodeEvent, () => { + ++events + if (events === numberOfEventsToWait) { + resolve(events) + } + }) + break + default: + throw new Error('Invalid worker node event') + } + if (timeout >= 0) { + setTimeout(() => { + resolve(events) + }, timeout) + } + }) +} diff --git a/src/vendor/poolifier/pools/version.ts b/src/vendor/poolifier/pools/version.ts new file mode 100644 index 0000000..4fe59d2 --- /dev/null +++ b/src/vendor/poolifier/pools/version.ts @@ -0,0 +1,3 @@ +// x-release-please-start-version +export const version = '0.4.29' +// x-release-please-end diff --git a/src/vendor/poolifier/pools/worker-node.ts b/src/vendor/poolifier/pools/worker-node.ts new file mode 100644 index 0000000..611ad27 --- /dev/null +++ b/src/vendor/poolifier/pools/worker-node.ts @@ -0,0 +1,293 @@ +import { CircularBuffer } from '../circular-buffer.ts' +import { PriorityQueue } from '../queues/priority-queue.ts' +import type { MessageValue, Task } from '../utility-types.ts' +import { DEFAULT_TASK_NAME } from '../utils.ts' +import { + checkWorkerNodeArguments, + createWorker, + getWorkerId, + getWorkerType, +} from './utils.ts' +import { + type IWorker, + type IWorkerNode, + MeasurementHistorySize, + type StrategyData, + type WorkerInfo, + type WorkerNodeEventDetail, + type WorkerNodeOptions, + type WorkerType, + type WorkerUsage, +} from './worker.ts' + +/** + * Worker node. + * + * @typeParam Worker - Type of worker. + * @typeParam Data - Type of data sent to the worker. This can only be structured-cloneable data. + */ +export class WorkerNode + extends EventTarget + implements IWorkerNode { + /** @inheritdoc */ + public readonly worker: Worker + /** @inheritdoc */ + public readonly info: WorkerInfo + /** @inheritdoc */ + public usage: WorkerUsage + /** @inheritdoc */ + public strategyData?: StrategyData + /** @inheritdoc */ + public messageChannel?: MessageChannel + /** @inheritdoc */ + public tasksQueueBackPressureSize: number + private readonly tasksQueue: PriorityQueue> + private setBackPressureFlag: boolean + private readonly taskFunctionsUsage: Map + + /** + * Constructs a new worker node. + * + * @param type - The worker type. + * @param fileURL - URL to the worker file. + * @param opts - The worker node options. + */ + constructor(type: WorkerType, fileURL: URL, opts: WorkerNodeOptions) { + super() + checkWorkerNodeArguments(type, fileURL, opts) + this.worker = createWorker(type, fileURL, { + workerOptions: opts.workerOptions, + }) + this.info = this.initWorkerInfo(this.worker) + this.usage = this.initWorkerUsage() + this.messageChannel = new MessageChannel() + this.messageChannel.port1.onmessage = ( + messageEvent: MessageEvent>, + ) => { + this.dispatchEvent( + new MessageEvent>('message', { + data: messageEvent.data, + }), + ) + } + this.messageChannel.port1.onmessageerror = ( + messageEvent: MessageEvent>, + ) => { + this.dispatchEvent( + new MessageEvent>('messageerror', { + data: messageEvent.data, + }), + ) + } + this.tasksQueueBackPressureSize = opts.tasksQueueBackPressureSize! + this.tasksQueue = new PriorityQueue>( + opts.tasksQueueBucketSize, + opts.tasksQueuePriority, + ) + this.setBackPressureFlag = false + this.taskFunctionsUsage = new Map() + } + + /** @inheritdoc */ + public setTasksQueuePriority(enablePriority: boolean): void { + this.tasksQueue.enablePriority = enablePriority + } + + /** @inheritdoc */ + public tasksQueueSize(): number { + return this.tasksQueue.size + } + + /** @inheritdoc */ + public enqueueTask(task: Task): number { + const tasksQueueSize = this.tasksQueue.enqueue(task, task.priority) + if ( + !this.setBackPressureFlag && + this.hasBackPressure() && + !this.info.backPressure + ) { + this.setBackPressureFlag = true + this.info.backPressure = true + this.dispatchEvent( + new CustomEvent('backPressure', { + detail: { workerId: this.info.id }, + }), + ) + this.setBackPressureFlag = false + } + return tasksQueueSize + } + + /** @inheritdoc */ + public dequeueTask(bucket?: number): Task | undefined { + const task = this.tasksQueue.dequeue(bucket) + if ( + !this.setBackPressureFlag && + !this.hasBackPressure() && + this.info.backPressure + ) { + this.setBackPressureFlag = true + this.info.backPressure = false + this.setBackPressureFlag = false + } + return task + } + + /** @inheritdoc */ + public dequeueLastPrioritizedTask(): Task | undefined { + // Start from the last empty or partially filled bucket + return this.dequeueTask(this.tasksQueue.buckets + 1) + } + + /** @inheritdoc */ + public clearTasksQueue(): void { + this.tasksQueue.clear() + } + + /** + * Whether the worker node is back pressured or not. + * + * @returns `true` if the worker node is back pressured, `false` otherwise. + */ + private hasBackPressure(): boolean { + return this.tasksQueue.size >= this.tasksQueueBackPressureSize + } + + /** @inheritdoc */ + public terminate(): void { + this.closeMessageChannel() + this.worker.terminate() + this.dispatchEvent(new Event('exit')) + } + + /** @inheritdoc */ + public getTaskFunctionWorkerUsage(name: string): WorkerUsage | undefined { + if (!Array.isArray(this.info.taskFunctionsProperties)) { + throw new Error( + `Cannot get task function worker usage for task function name '${name}' when task function properties list is not yet defined`, + ) + } + if ( + Array.isArray(this.info.taskFunctionsProperties) && + this.info.taskFunctionsProperties.length < 3 + ) { + throw new Error( + `Cannot get task function worker usage for task function name '${name}' when task function properties list has less than 3 elements`, + ) + } + if (name === DEFAULT_TASK_NAME) { + name = this.info.taskFunctionsProperties[1].name + } + if (!this.taskFunctionsUsage.has(name)) { + this.taskFunctionsUsage.set(name, this.initTaskFunctionWorkerUsage(name)) + } + return this.taskFunctionsUsage.get(name) + } + + /** @inheritdoc */ + public deleteTaskFunctionWorkerUsage(name: string): boolean { + return this.taskFunctionsUsage.delete(name) + } + + private closeMessageChannel(): void { + if (this.messageChannel != null) { + this.messageChannel.port1.close() + this.messageChannel.port2.close() + delete this.messageChannel + } + } + + private initWorkerInfo(worker: Worker): WorkerInfo { + return { + id: getWorkerId(worker), + type: getWorkerType(worker)!, + dynamic: false, + ready: false, + stealing: false, + stolen: false, + continuousStealing: false, + backPressureStealing: false, + backPressure: false, + } + } + + private initWorkerUsage(): WorkerUsage { + const getTasksQueueSize = (): number => { + return this.tasksQueue.size + } + const getTasksQueueMaxSize = (): number => { + return this.tasksQueue.maxSize + } + return { + tasks: { + executed: 0, + executing: 0, + get queued(): number { + return getTasksQueueSize() + }, + get maxQueued(): number { + return getTasksQueueMaxSize() + }, + sequentiallyStolen: 0, + stolen: 0, + failed: 0, + }, + runTime: { + history: new CircularBuffer(MeasurementHistorySize), + }, + waitTime: { + history: new CircularBuffer(MeasurementHistorySize), + }, + elu: { + idle: { + history: new CircularBuffer(MeasurementHistorySize), + }, + active: { + history: new CircularBuffer(MeasurementHistorySize), + }, + }, + } + } + + private initTaskFunctionWorkerUsage(name: string): WorkerUsage { + const getTaskFunctionQueueSize = (): number => { + let taskFunctionQueueSize = 0 + for (const task of this.tasksQueue) { + if ( + (task.name === DEFAULT_TASK_NAME && + name === this.info.taskFunctionsProperties![1].name) || + (task.name !== DEFAULT_TASK_NAME && name === task.name) + ) { + ++taskFunctionQueueSize + } + } + return taskFunctionQueueSize + } + return { + tasks: { + executed: 0, + executing: 0, + get queued(): number { + return getTaskFunctionQueueSize() + }, + sequentiallyStolen: 0, + stolen: 0, + failed: 0, + }, + runTime: { + history: new CircularBuffer(MeasurementHistorySize), + }, + waitTime: { + history: new CircularBuffer(MeasurementHistorySize), + }, + elu: { + idle: { + history: new CircularBuffer(MeasurementHistorySize), + }, + active: { + history: new CircularBuffer(MeasurementHistorySize), + }, + }, + } + } +} diff --git a/src/vendor/poolifier/pools/worker.ts b/src/vendor/poolifier/pools/worker.ts new file mode 100644 index 0000000..37b7f3e --- /dev/null +++ b/src/vendor/poolifier/pools/worker.ts @@ -0,0 +1,372 @@ +import type { CircularBuffer } from '../circular-buffer.ts' +import type { Task, TaskFunctionProperties } from '../utility-types.ts' + +/** + * Callback invoked if the worker has received a message event. + */ +export type MessageEventHandler = + // deno-lint-ignore no-explicit-any + ((ev: MessageEvent) => any) | null + +/** + * Callback invoked if the worker raised an error at processing a message event. + */ +export type MessageEventErrorHandler = + // deno-lint-ignore no-explicit-any + ((ev: MessageEvent) => any) | null + +/** + * Callback invoked if the worker raised an error event. + */ +export type ErrorEventHandler = + // deno-lint-ignore no-explicit-any + ((ev: ErrorEvent) => any) | null + +/** + * Measurement history size. + */ +export const MeasurementHistorySize = 386 + +/** + * Measurement statistics. + * + * @internal + */ +export interface MeasurementStatistics { + /** + * Measurement aggregate. + */ + aggregate?: number + /** + * Measurement minimum. + */ + minimum?: number + /** + * Measurement maximum. + */ + maximum?: number + /** + * Measurement average. + */ + average?: number + /** + * Measurement median. + */ + median?: number + /** + * Measurement history. + */ + readonly history: CircularBuffer +} + +/** + * Event loop utilization measurement statistics. + * + * @internal + */ +export interface EventLoopUtilizationMeasurementStatistics { + readonly idle: MeasurementStatistics + readonly active: MeasurementStatistics + utilization?: number +} + +/** + * Task statistics. + * + * @internal + */ +export interface TaskStatistics { + /** + * Number of executed tasks. + */ + executed: number + /** + * Number of executing tasks. + */ + executing: number + /** + * Number of queued tasks. + */ + readonly queued: number + /** + * Maximum number of queued tasks. + */ + readonly maxQueued?: number + /** + * Number of sequentially stolen tasks. + */ + sequentiallyStolen: number + /** + * Number of stolen tasks. + */ + stolen: number + /** + * Number of failed tasks. + */ + failed: number +} + +/** + * Enumeration of worker types. + */ +export const WorkerTypes: Readonly<{ web: 'web' }> = Object.freeze( + { + web: 'web', + } as const, +) + +/** + * Worker type. + */ +export type WorkerType = keyof typeof WorkerTypes + +/** + * Worker information. + * + * @internal + */ +export interface WorkerInfo { + /** + * Worker id. + */ + readonly id: `${string}-${string}-${string}-${string}-${string}` | undefined + /** + * Worker type. + */ + readonly type: WorkerType + /** + * Dynamic flag. + */ + dynamic: boolean + /** + * Ready flag. + */ + ready: boolean + /** + * Stealing flag. + * This flag is set to `true` when worker node is stealing one task from another worker node. + */ + stealing: boolean + /** + * Stolen flag. + * This flag is set to `true` when worker node has one task stolen from another worker node. + */ + stolen: boolean + /** + * Continuous stealing flag. + * This flag is set to `true` when worker node is continuously stealing tasks from other worker nodes. + */ + continuousStealing: boolean + /** + * Back pressure stealing flag. + * This flag is set to `true` when worker node is stealing one task from another back pressured worker node. + */ + backPressureStealing: boolean + /** + * Back pressure flag. + * This flag is set to `true` when worker node tasks queue is back pressured. + */ + backPressure: boolean + /** + * Task functions properties. + */ + taskFunctionsProperties?: TaskFunctionProperties[] +} + +/** + * Worker usage statistics. + * + * @internal + */ +export interface WorkerUsage { + /** + * Tasks statistics. + */ + readonly tasks: TaskStatistics + /** + * Tasks runtime statistics. + */ + readonly runTime: MeasurementStatistics + /** + * Tasks wait time statistics. + */ + readonly waitTime: MeasurementStatistics + /** + * Tasks event loop utilization statistics. + */ + readonly elu: EventLoopUtilizationMeasurementStatistics +} + +/** + * Worker choice strategy data. + * + * @internal + */ +export interface StrategyData { + virtualTaskEndTimestamp?: number +} + +/** + * Worker interface. + */ +export interface IWorker extends EventTarget { + /** + * Worker `message` event handler. + */ + onmessage: MessageEventHandler + /** + * Worker `messageerror` event handler. + */ + onmessageerror: MessageEventErrorHandler + /** + * Worker `error` event handler. + */ + onerror: ErrorEventHandler + /** + * Clones message and transmits it to worker's global environment. transfer can be passed as a list of objects that are to be transferred rather than cloned. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Worker/postMessage) + */ + // deno-lint-ignore no-explicit-any + postMessage(message: any, transfer: Transferable[]): void + // deno-lint-ignore no-explicit-any + postMessage(message: any, options?: StructuredSerializeOptions): void + /** + * Terminates the worker. + */ + terminate: () => void + addEventListener( + type: K, + // deno-lint-ignore no-explicit-any + listener: (this: this, ev: WorkerEventMap[K]) => any, + options?: boolean | AddEventListenerOptions, + ): void + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ): void + removeEventListener( + type: K, + // deno-lint-ignore no-explicit-any + listener: (this: this, ev: WorkerEventMap[K]) => any, + options?: boolean | EventListenerOptions, + ): void + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ): void +} + +/** + * Worker node options. + * + * @internal + */ +export interface WorkerNodeOptions { + workerOptions?: WorkerOptions + tasksQueueBackPressureSize: number | undefined + tasksQueueBucketSize: number | undefined + tasksQueuePriority: boolean | undefined +} + +/** + * Worker node interface. + * + * @typeParam Worker - Type of worker. + * @typeParam Data - Type of data sent to the worker. This can only be structured-cloneable data. + * @internal + */ +export interface IWorkerNode + extends EventTarget { + /** + * Worker. + */ + readonly worker: Worker + /** + * Worker info. + */ + readonly info: WorkerInfo + /** + * Worker usage statistics. + */ + readonly usage: WorkerUsage + /** + * Worker choice strategy data. + * This is used to store data that are specific to the worker choice strategy. + */ + strategyData?: StrategyData + /** + * Message channel (worker thread only). + */ + readonly messageChannel?: MessageChannel + /** + * Tasks queue back pressure size. + * This is the number of tasks that can be enqueued before the worker node has back pressure. + */ + tasksQueueBackPressureSize: number + /** + * Sets tasks queue priority. + * + * @param enablePriority - Whether to enable tasks queue priority. + */ + readonly setTasksQueuePriority: (enablePriority: boolean) => void + /** + * Tasks queue size. + * + * @returns The tasks queue size. + */ + readonly tasksQueueSize: () => number + /** + * Enqueue task. + * + * @param task - The task to queue. + * @returns The tasks queue size. + */ + readonly enqueueTask: (task: Task) => number + /** + * Dequeue task. + * + * @returns The dequeued task. + */ + readonly dequeueTask: (bucket?: number) => Task | undefined + /** + * Dequeue last prioritized task. + * + * @returns The dequeued task. + */ + readonly dequeueLastPrioritizedTask: () => Task | undefined + /** + * Clears tasks queue. + */ + readonly clearTasksQueue: () => void + /** + * Terminates the worker node. + */ + readonly terminate: () => void + /** + * Gets task function worker usage statistics. + * + * @param name - The task function name. + * @returns The task function worker usage statistics if the task function worker usage statistics are initialized, `undefined` otherwise. + */ + readonly getTaskFunctionWorkerUsage: (name: string) => WorkerUsage | undefined + /** + * Deletes task function worker usage statistics. + * + * @param name - The task function name. + * @returns `true` if the task function worker usage statistics were deleted, `false` otherwise. + */ + readonly deleteTaskFunctionWorkerUsage: (name: string) => boolean +} + +/** + * Worker node event detail. + * + * @internal + */ +export interface WorkerNodeEventDetail { + workerId?: `${string}-${string}-${string}-${string}-${string}` + workerNodeKey?: number +} diff --git a/src/vendor/poolifier/queues/abstract-fixed-queue.ts b/src/vendor/poolifier/queues/abstract-fixed-queue.ts new file mode 100644 index 0000000..68b2111 --- /dev/null +++ b/src/vendor/poolifier/queues/abstract-fixed-queue.ts @@ -0,0 +1,121 @@ +import { + defaultQueueSize, + type FixedQueueNode, + type IFixedQueue, +} from './queue-types.ts' + +/** + * Base fixed queue class. + * + * @typeParam T - Type of fixed queue data. + * @internal + */ +export abstract class AbstractFixedQueue implements IFixedQueue { + protected start!: number + /** @inheritdoc */ + public readonly capacity: number + /** @inheritdoc */ + public size!: number + /** @inheritdoc */ + public nodeArray: FixedQueueNode[] + + /** + * Constructs a fixed queue. + * + * @param size - Fixed queue size. @defaultValue defaultQueueSize + * @returns IFixedQueue. + */ + constructor(size: number = defaultQueueSize) { + this.checkSize(size) + this.capacity = size + this.nodeArray = new Array>(this.capacity) + this.clear() + } + + /** @inheritdoc */ + public empty(): boolean { + return this.size === 0 + } + + /** @inheritdoc */ + public full(): boolean { + return this.size === this.capacity + } + + /** @inheritdoc */ + public abstract enqueue(data: T, priority?: number): number + + /** @inheritdoc */ + public get(index: number): T | undefined { + if (this.empty() || index >= this.size) { + return undefined + } + index += this.start + if (index >= this.capacity) { + index -= this.capacity + } + return this.nodeArray[index].data + } + + /** @inheritdoc */ + public dequeue(): T | undefined { + if (this.empty()) { + return undefined + } + const index = this.start + --this.size + ++this.start + if (this.start === this.capacity) { + this.start = 0 + } + return this.nodeArray[index].data + } + + /** @inheritdoc */ + public clear(): void { + this.start = 0 + this.size = 0 + } + + /** @inheritdoc */ + public [Symbol.iterator](): Iterator { + let index = this.start + let i = 0 + return { + next: () => { + if (i >= this.size) { + return { + value: undefined, + done: true, + } + } + const value = this.nodeArray[index].data + ++index + ++i + if (index === this.capacity) { + index = 0 + } + return { + value, + done: false, + } + }, + } + } + + /** + * Checks the fixed queue size. + * + * @param size - Queue size. + */ + private checkSize(size: number): void { + if (!Number.isSafeInteger(size)) { + throw new TypeError( + `Invalid fixed queue size: '${size.toString()}' is not an integer`, + ) + } + if (size < 0) { + throw new RangeError(`Invalid fixed queue size: ${size.toString()} < 0`) + } + } +} diff --git a/src/vendor/poolifier/queues/fixed-priority-queue.ts b/src/vendor/poolifier/queues/fixed-priority-queue.ts new file mode 100644 index 0000000..f9c6175 --- /dev/null +++ b/src/vendor/poolifier/queues/fixed-priority-queue.ts @@ -0,0 +1,41 @@ +import { AbstractFixedQueue } from './abstract-fixed-queue.ts' +import type { IFixedQueue } from './queue-types.ts' + +/** + * Fixed priority queue. + * + * @typeParam T - Type of fixed priority queue data. + * @internal + */ +export class FixedPriorityQueue extends AbstractFixedQueue + implements IFixedQueue { + /** @inheritdoc */ + public enqueue(data: T, priority?: number): number { + if (this.full()) { + throw new Error('Fixed priority queue is full') + } + priority = priority ?? 0 + let inserted = false + let index = this.start + for (let i = 0; i < this.size; i++) { + if (this.nodeArray[index].priority > priority) { + this.nodeArray.splice(index, 0, { data, priority }) + this.nodeArray.length = this.capacity + inserted = true + break + } + ++index + if (index === this.capacity) { + index = 0 + } + } + if (!inserted) { + let index = this.start + this.size + if (index >= this.capacity) { + index -= this.capacity + } + this.nodeArray[index] = { data, priority } + } + return ++this.size + } +} diff --git a/src/vendor/poolifier/queues/fixed-queue.ts b/src/vendor/poolifier/queues/fixed-queue.ts new file mode 100644 index 0000000..b179d60 --- /dev/null +++ b/src/vendor/poolifier/queues/fixed-queue.ts @@ -0,0 +1,24 @@ +import { AbstractFixedQueue } from './abstract-fixed-queue.ts' +import type { IFixedQueue } from './queue-types.ts' + +/** + * Fixed queue. + * + * @typeParam T - Type of fixed queue data. + * @internal + */ +export class FixedQueue extends AbstractFixedQueue + implements IFixedQueue { + /** @inheritdoc */ + public enqueue(data: T, priority?: number): number { + if (this.full()) { + throw new Error('Fixed queue is full') + } + let index = this.start + this.size + if (index >= this.capacity) { + index -= this.capacity + } + this.nodeArray[index] = { data, priority: priority ?? 0 } + return ++this.size + } +} diff --git a/src/vendor/poolifier/queues/priority-queue.ts b/src/vendor/poolifier/queues/priority-queue.ts new file mode 100644 index 0000000..1732626 --- /dev/null +++ b/src/vendor/poolifier/queues/priority-queue.ts @@ -0,0 +1,233 @@ +// Copyright Jerome Benoit. 2024. All Rights Reserved. + +import { FixedPriorityQueue } from '../queues/fixed-priority-queue.ts' +import { FixedQueue } from '../queues/fixed-queue.ts' +import { + defaultBucketSize, + type FixedQueueNode, + type IFixedQueue, + type PriorityQueueNode, +} from './queue-types.ts' + +/** + * Priority queue. + * + * @typeParam T - Type of priority queue data. + * @internal + */ +export class PriorityQueue { + private head!: PriorityQueueNode + private tail!: PriorityQueueNode + private readonly bucketSize: number + private priorityEnabled: boolean + /** The priority queue maximum size. */ + public maxSize!: number + + /** + * Constructs a priority queue. + * + * @param bucketSize - Prioritized bucket size. @defaultValue defaultBucketSize + * @param enablePriority - Whether to enable priority. @defaultValue false + * @returns PriorityQueue. + */ + public constructor( + bucketSize: number = defaultBucketSize, + enablePriority = false, + ) { + if (!Number.isSafeInteger(bucketSize)) { + throw new TypeError( + `Invalid bucket size: '${bucketSize.toString()}' is not an integer`, + ) + } + if (bucketSize < 0) { + throw new RangeError(`Invalid bucket size: ${bucketSize.toString()} < 0`) + } + this.bucketSize = bucketSize + this.priorityEnabled = enablePriority + this.clear() + } + + /** + * The priority queue size. + * + * @returns The priority queue size. + */ + public get size(): number { + let node: PriorityQueueNode | undefined = this.tail + let size = 0 + while (node != null) { + size += node.size + node = node.next + } + return size + } + + /** + * Whether priority is enabled. + * + * @returns Whether priority is enabled. + */ + public get enablePriority(): boolean { + return this.priorityEnabled + } + + /** + * Enables/disables priority. + * + * @param enablePriority - Whether to enable priority. + */ + public set enablePriority(enablePriority: boolean) { + if (this.priorityEnabled === enablePriority) { + return + } + this.priorityEnabled = enablePriority + let head: PriorityQueueNode + let tail: PriorityQueueNode + let prev: PriorityQueueNode | undefined + let node: PriorityQueueNode | undefined = this.tail + let buckets = 0 + while (node != null) { + const currentNode = this.getPriorityQueueNode(node.nodeArray) + if (buckets === 0) { + tail = currentNode + } + if (prev != null) { + prev.next = currentNode + } + prev = currentNode + if (node.next == null) { + head = currentNode + } + ++buckets + node = node.next + } + this.head = head! + this.tail = tail! + } + + /** + * The number of filled prioritized buckets. + * + * @returns The number of filled prioritized buckets. + */ + public get buckets(): number { + return Math.trunc(this.size / this.bucketSize) + } + + /** + * Enqueue data into the priority queue. + * + * @param data - Data to enqueue. + * @param priority - Priority of the data. Lower values have higher priority. + * @returns The new size of the priority queue. + */ + public enqueue(data: T, priority?: number): number { + if (this.head.full()) { + this.head = this.head.next = this.getPriorityQueueNode() + } + this.head.enqueue(data, priority) + const size = this.size + if (size > this.maxSize) { + this.maxSize = size + } + return size + } + + /** + * Dequeue data from the priority queue. + * + * @param bucket - The prioritized bucket to dequeue from. + * @returns The dequeued data or `undefined` if the priority queue is empty. + */ + public dequeue(bucket?: number): T | undefined { + let tail: PriorityQueueNode | undefined = this.tail + let tailChanged = false + if (bucket != null && bucket > 0) { + let currentBucket = 1 + while (tail != null) { + if (currentBucket === bucket) { + break + } + ++currentBucket + tail = tail.next + } + tailChanged = tail !== this.tail + } + const data = tail!.dequeue() + if (tail!.empty()) { + if (!tailChanged && tail!.next != null) { + this.tail = tail!.next + delete tail!.next + } else if (tailChanged) { + let node: PriorityQueueNode | undefined = this.tail + while (node != null) { + if (node.next === tail && tail!.next != null) { + node.next = tail!.next + delete tail!.next + break + } else if (node.next === tail && tail!.next == null) { + delete node.next + this.head = node + break + } + node = node.next + } + } + } + return data + } + + /** + * Clears the priority queue. + */ + public clear(): void { + this.head = this.tail = this.getPriorityQueueNode() + this.maxSize = 0 + } + + /** + * Returns an iterator for the priority queue. + * + * @returns An iterator for the priority queue. + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols + */ + public [Symbol.iterator](): Iterator { + let index = 0 + let node = this.tail + return { + next: () => { + const value = node.get(index) as T + if (value == null) { + return { + value: undefined, + done: true, + } + } + ++index + if (index === node.capacity && node.next != null) { + node = node.next + index = 0 + } + return { + value, + done: false, + } + }, + } + } + + private getPriorityQueueNode( + nodeArray?: FixedQueueNode[], + ): PriorityQueueNode { + let fixedQueue: IFixedQueue + if (this.priorityEnabled) { + fixedQueue = new FixedPriorityQueue(this.bucketSize) + } else { + fixedQueue = new FixedQueue(this.bucketSize) + } + if (nodeArray != null) { + fixedQueue.nodeArray = nodeArray + } + return fixedQueue + } +} diff --git a/src/vendor/poolifier/queues/queue-types.ts b/src/vendor/poolifier/queues/queue-types.ts new file mode 100644 index 0000000..83d7f7f --- /dev/null +++ b/src/vendor/poolifier/queues/queue-types.ts @@ -0,0 +1,83 @@ +/** + * Default queue size. + * @internal + */ +export const defaultQueueSize = 2048 + +/** + * Fixed queue node. + * @typeParam T - Type of fixed queue node data. + * @internal + */ +export interface FixedQueueNode { + data: T + priority: number +} + +/** + * Fixed queue. + * @typeParam T - Type of fixed queue data. + * @internal + */ +export interface IFixedQueue { + /** The fixed queue capacity. */ + readonly capacity: number + /** The fixed queue size. */ + readonly size: number + /** The fixed queue node array. */ + nodeArray: FixedQueueNode[] + /** + * Checks if the fixed queue is empty. + * @returns `true` if the fixed queue is empty, `false` otherwise. + */ + empty: () => boolean + /** + * Checks if the fixed queue is full. + * @returns `true` if the fixed queue is full, `false` otherwise. + */ + full: () => boolean + /** + * Enqueue data into the fixed queue. + * @param data - Data to enqueue. + * @param priority - Priority of the data. Lower values have higher priority. + * @returns The new size of the fixed queue. + * @throws If the fixed queue is full. + */ + enqueue: (data: T, priority?: number) => number + /** + * Gets data from the fixed queue. + * @param index - The index of the data to get. + * @returns The data at the index or `undefined` if the fixed queue is empty or the index is out of bounds. + */ + get: (index: number) => T | undefined + /** + * Dequeue data from the fixed queue. + * @returns The dequeued data or `undefined` if the fixed queue is empty. + */ + dequeue: () => T | undefined + /** + * Clears the fixed queue. + */ + clear: () => void + /** + * Returns an iterator for the fixed queue. + * @returns An iterator for the fixed queue. + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols + */ + [Symbol.iterator]: () => Iterator +} + +/** + * Default bucket size. + * @internal + */ +export const defaultBucketSize = 2048 + +/** + * Priority queue node. + * @typeParam T - Type of priority queue node data. + * @internal + */ +export interface PriorityQueueNode extends IFixedQueue { + next?: IFixedQueue +} diff --git a/src/vendor/poolifier/utility-types.ts b/src/vendor/poolifier/utility-types.ts new file mode 100644 index 0000000..1e176e9 --- /dev/null +++ b/src/vendor/poolifier/utility-types.ts @@ -0,0 +1,222 @@ +import type { WorkerChoiceStrategy } from './pools/selection-strategies/selection-strategies-types.ts' +import type { KillBehavior } from './worker/worker-options.ts' + +/** + * Worker error. + * + * @typeParam Data - Type of data sent to the worker triggering an error. This can only be structured-cloneable data. + */ +export interface WorkerError { + /** + * Task function name triggering the error. + */ + readonly name: string + /** + * Error message. + */ + readonly message: string + /** + * Data triggering the error. + */ + readonly data?: Data +} + +/** + * Event loop utilization. + */ +interface EventLoopUtilization { + idle: number + active: number + utilization: number +} + +/** + * Task performance. + * + * @internal + */ +export interface TaskPerformance { + /** + * Task name. + */ + readonly name: string + /** + * Task performance timestamp. + */ + readonly timestamp: number + /** + * Task runtime. + */ + readonly runTime?: number + /** + * Task event loop utilization. + */ + readonly elu?: EventLoopUtilization +} + +/** + * Worker task performance statistics computation settings. + * + * @internal + */ +export interface WorkerStatistics { + /** + * Whether the worker computes the task runtime or not. + */ + readonly runTime: boolean + // /** + // * Whether the worker computes the task event loop utilization (ELU) or not. + // */ + // readonly elu: boolean +} + +/** + * Task function properties. + */ +export interface TaskFunctionProperties { + /** + * Task function name. + */ + readonly name: string + /** + * Task function priority. Lower values have higher priority. + */ + readonly priority?: number + /** + * Task function worker choice strategy. + */ + readonly strategy?: WorkerChoiceStrategy +} + +/** + * Message object that is passed as a task between main worker and worker. + * + * @typeParam Data - Type of data sent to the worker. This can only be structured-cloneable data. + * @internal + */ +export interface Task { + /** + * Task name. + */ + readonly name?: string + /** + * Task input data that will be passed to the worker. + */ + readonly data?: Data + /** + * Task priority. Lower values have higher priority. + * + * @defaultValue 0 + */ + readonly priority?: number + /** + * Task worker choice strategy. + */ + readonly strategy?: WorkerChoiceStrategy + /** + * Array of transferable objects. + */ + readonly transferList?: Transferable[] + /** + * Timestamp. + */ + readonly timestamp?: number + /** + * Task UUID. + */ + readonly taskId?: `${string}-${string}-${string}-${string}-${string}` +} + +/** + * Message object that is passed between main worker and worker. + * + * @typeParam Data - Type of data sent to the worker or execution response. This can only be structured-cloneable data. + * @typeParam ErrorData - Type of data sent to the worker triggering an error. This can only be structured-cloneable data. + * @internal + */ +export interface MessageValue + extends Task { + /** + * Worker id. + */ + readonly workerId?: `${string}-${string}-${string}-${string}-${string}` + /** + * Kill code. + */ + readonly kill?: KillBehavior | true | 'success' | 'failure' + /** + * Worker error. + */ + readonly workerError?: WorkerError + /** + * Task performance. + */ + readonly taskPerformance?: TaskPerformance + /** + * Task function operation: + * - `'add'` - Add a task function. + * - `'remove'` - Remove a task function. + * - `'default'` - Set a task function as default. + */ + readonly taskFunctionOperation?: 'add' | 'remove' | 'default' + /** + * Whether the task function operation is successful or not. + */ + readonly taskFunctionOperationStatus?: boolean + /** + * Task function properties. + */ + readonly taskFunctionProperties?: TaskFunctionProperties + /** + * Task function serialized to string. + */ + readonly taskFunction?: string + /** + * Task function properties. + */ + readonly taskFunctionsProperties?: TaskFunctionProperties[] + /** + * Whether the worker computes the given statistics or not. + */ + readonly statistics?: WorkerStatistics + /** + * Whether the worker is ready or not. + */ + readonly ready?: boolean + /** + * Whether the worker starts or stops its activity check. + */ + readonly checkActive?: boolean + /** + * Message port. + */ + readonly port?: MessagePort +} + +/** + * An object holding the task execution response promise resolve/reject callbacks. + * + * @typeParam Response - Type of execution response. This can only be structured-cloneable data. + * @internal + */ +export interface PromiseResponseWrapper { + /** + * Resolve callback to fulfill the promise. + */ + readonly resolve: (value: Response | PromiseLike) => void + /** + * Reject callback to reject the promise. + */ + readonly reject: (reason?: unknown) => void + /** + * The worker node key executing the task. + */ + readonly workerNodeKey: number +} + +/** + * Remove readonly modifier from all properties of T. + * @typeParam T - Type to remove readonly modifier. + * @internal + */ +export type Writable = { -readonly [P in keyof T]: T[P] } diff --git a/src/vendor/poolifier/utils.ts b/src/vendor/poolifier/utils.ts new file mode 100644 index 0000000..464874f --- /dev/null +++ b/src/vendor/poolifier/utils.ts @@ -0,0 +1,329 @@ +import type { TaskFunctionProperties } from './utility-types.ts' +import type { TaskFunctionObject } from './worker/task-functions.ts' +import type { KillBehavior } from './worker/worker-options.ts' + +/** + * Default task name. + */ +export const DEFAULT_TASK_NAME = 'default' + +/** + * An intentional empty function. + */ +export const EMPTY_FUNCTION: () => void = Object.freeze(() => { + /* Intentionally empty */ +}) + +/** + * Returns safe host OS optimized estimate of the default amount of parallelism a pool should use. + * Always returns a value greater than zero. + * + * @returns The host OS optimized maximum pool size. + */ +export const availableParallelism = (): number => { + return navigator.hardwareConcurrency ?? 1 +} + +/** + * Sleeps for the given amount of milliseconds. + * + * @param ms - The amount of milliseconds to sleep. + * @returns A promise that resolves after the given amount of milliseconds. + * @internal + */ +export const sleep = async (ms: number): Promise => { + await new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +/** + * Computes the retry delay in milliseconds using an exponential back off algorithm. + * + * @param retryNumber - The number of retries that have already been attempted + * @param delayFactor - The base delay factor in milliseconds + * @returns Delay in milliseconds + * @internal + */ +export const exponentialDelay = ( + retryNumber = 0, + delayFactor = 100, +): number => { + const delay = Math.pow(2, retryNumber) * delayFactor + const randomSum = delay * 0.2 * secureRandom() // 0-20% of the delay + return delay + randomSum +} + +/** + * Computes the average of the given data set. + * + * @param dataSet - Data set. + * @returns The average of the given data set. + * @internal + */ +export const average = (dataSet: number[]): number => { + if (Array.isArray(dataSet) && dataSet.length === 0) { + return 0 + } else if (Array.isArray(dataSet) && dataSet.length === 1) { + return dataSet[0] + } + return ( + dataSet.reduce((accumulator, number) => accumulator + number, 0) / + dataSet.length + ) +} + +/** + * Computes the median of the given data set. + * + * @param dataSet - Data set. + * @returns The median of the given data set. + * @internal + */ +export const median = (dataSet: number[]): number => { + if (Array.isArray(dataSet) && dataSet.length === 0) { + return 0 + } else if (Array.isArray(dataSet) && dataSet.length === 1) { + return dataSet[0] + } + const sortedDataSet = dataSet.slice().sort((a, b) => a - b) + return ( + (sortedDataSet[(sortedDataSet.length - 1) >> 1] + + sortedDataSet[sortedDataSet.length >> 1]) / + 2 + ) +} + +/** + * Rounds the given number to the given scale. + * The rounding is done using the "round half away from zero" method. + * + * @param num - The number to round. + * @param scale - The scale to round to. + * @returns The rounded number. + * @internal + */ +export const round = (num: number, scale = 2): number => { + const rounder = Math.pow(10, scale) + return Math.round(num * rounder * (1 + Number.EPSILON)) / rounder +} + +/** + * Is the given value a plain object? + * + * @param value - The value to check. + * @returns `true` if the given value is a plain object, `false` otherwise. + * @internal + */ +export const isPlainObject = (value: unknown): value is object => + typeof value === 'object' && + value !== null && + value.constructor === Object && + Object.prototype.toString.call(value) === '[object Object]' + +/** + * Detects whether the given value is a kill behavior or not. + * + * @typeParam KB - Which specific KillBehavior type to test against. + * @param killBehavior - Which kind of kill behavior to detect. + * @param value - Unknown value. + * @returns `true` if `value` was strictly equals to `killBehavior`, otherwise `false`. + * @internal + */ +export const isKillBehavior = ( + killBehavior: KB, + value: unknown, +): value is KB => { + return value === killBehavior +} + +type AsyncFunctionType = (...args: A) => PromiseLike + +/** + * Detects whether the given value is an asynchronous function or not. + * + * @param fn - Unknown value. + * @returns `true` if `fn` was an asynchronous function, otherwise `false`. + * @internal + */ +export const isAsyncFunction = ( + fn: unknown, +): fn is AsyncFunctionType => { + return fn?.constructor === (async () => {}).constructor +} + +/** + * Generates a cryptographically secure random number in the [0,1[ range + * + * @returns A number in the [0,1[ range + * @internal + */ +export const secureRandom = (): number => { + return crypto.getRandomValues(new Uint32Array(1))[0] / 0x100000000 +} + +/** + * Returns the minimum of the given numbers. + * If no numbers are given, `Number.POSITIVE_INFINITY` is returned. + * + * @param args - The numbers to get the minimum of. + * @returns The minimum of the given numbers. + * @internal + */ +export const min = (...args: number[]): number => + args.reduce( + (minimum, num) => (minimum < num ? minimum : num), + Number.POSITIVE_INFINITY, + ) + +/** + * Returns the maximum of the given numbers. + * If no numbers are given, `Number.NEGATIVE_INFINITY` is returned. + * + * @param args - The numbers to get the maximum of. + * @returns The maximum of the given numbers. + * @internal + */ +export const max = (...args: number[]): number => + args.reduce( + (maximum, num) => (maximum > num ? maximum : num), + Number.NEGATIVE_INFINITY, + ) + +/** + * Wraps a function so that it can only be called once. + * + * @param fn - The function to wrap. + * @param context - The context to bind the function to. + * @returns The wrapped function. + * + * @typeParam A - The function's arguments. + * @typeParam R - The function's return value. + * @typeParam C - The function's context. + * @internal + */ +// deno-lint-ignore no-explicit-any +export const once = >( + fn: (...args: A) => R, + context: C, +): (...args: A) => R => { + let result: R + return (...args: A) => { + if (fn != null) { + result = fn.apply(context, args) + ;(fn as unknown as undefined) = + (context as unknown as undefined) = + undefined + } + return result + } +} + +export const buildTaskFunctionProperties = ( + name: string, + taskFunctionObject: TaskFunctionObject | undefined, +): TaskFunctionProperties => { + return { + name, + ...(taskFunctionObject?.priority != null && { + priority: taskFunctionObject.priority, + }), + ...(taskFunctionObject?.strategy != null && { + strategy: taskFunctionObject.strategy, + }), + } +} + +/** + * Indicates if running in Bun runtime. + * + * @internal + */ +// deno-lint-ignore no-explicit-any +const isBun: boolean = !!(globalThis as any).Bun || + // deno-lint-ignore no-explicit-any + !!(globalThis as any).process?.versions?.bun + +/** + * Indicates if running in Deno runtime. + * + * @internal + */ +// deno-lint-ignore no-explicit-any +const isDeno: boolean = !!(globalThis as any).Deno + +/** + * Indicates if running in browser runtime. + * + * @internal + */ +// deno-lint-ignore no-explicit-any +const isBrowser: boolean = !!(globalThis as any).navigator + +/** + * JavaScript runtime environments enumeration. + * + * @internal + */ +export enum JavaScriptRuntimes { + bun = 'bun', + deno = 'deno', + browser = 'browser', +} + +/** + * JavaScript runtime environment. + * + * @internal + */ +export const runtime: JavaScriptRuntimes = (() => { + if (isBun) return JavaScriptRuntimes.bun + if (isDeno) return JavaScriptRuntimes.deno + if (isBrowser) return JavaScriptRuntimes.browser + throw new Error('Unsupported JavaScript runtime environment') +})() + +const isMainThread: boolean | undefined = await (async (): Promise< + boolean | undefined +> => { + return await { + browser: () => undefined, + deno: () => undefined, + bun: async () => { + // deno-lint-ignore ban-ts-comment + // @ts-ignore + return (await import('node:worker_threads')).isMainThread + }, + }[runtime]() +})() + +/** + * The current environment name. + * + * @internal + */ +export const environment: string = await (async (): Promise => { + return await { + browser: () => 'production', + deno: () => { + // deno-lint-ignore ban-ts-comment + // @ts-ignore + return Deno.env.get('ENVIRONMENT') ?? 'production' + }, + bun: async () => { + // deno-lint-ignore ban-ts-comment + // @ts-ignore + return (await import('node:process')).env.ENVIRONMENT ?? 'production' + }, + }[runtime]() +})() + +/** + * Whether the current environment is a web worker or not. + * + * @internal + */ +export const isWebWorker: boolean = isMainThread != null + ? !isMainThread + : typeof WorkerGlobalScope !== 'undefined' && + self instanceof WorkerGlobalScope diff --git a/src/vendor/poolifier/worker/abstract-worker.ts b/src/vendor/poolifier/worker/abstract-worker.ts new file mode 100644 index 0000000..0a7785d --- /dev/null +++ b/src/vendor/poolifier/worker/abstract-worker.ts @@ -0,0 +1,658 @@ +import type { + MessageValue, + Task, + TaskFunctionProperties, + TaskPerformance, + WorkerStatistics, +} from '../utility-types.ts' +import { + buildTaskFunctionProperties, + DEFAULT_TASK_NAME, + EMPTY_FUNCTION, + isAsyncFunction, + isPlainObject, +} from '../utils.ts' +import type { + TaskAsyncFunction, + TaskFunction, + TaskFunctionObject, + TaskFunctionOperationResult, + TaskFunctions, + TaskSyncFunction, +} from './task-functions.ts' +import { + checkTaskFunctionName, + checkValidTaskFunctionObjectEntry, + checkValidWorkerOptions, +} from './utils.ts' +import { KillBehaviors, type WorkerOptions } from './worker-options.ts' + +const DEFAULT_MAX_INACTIVE_TIME = 60000 +const DEFAULT_WORKER_OPTIONS: WorkerOptions = { + /** + * The kill behavior option on this worker or its default value. + */ + killBehavior: KillBehaviors.SOFT, + /** + * The maximum time to keep this worker active while idle. + * The pool automatically checks and terminates this worker when the time expires. + */ + maxInactiveTime: DEFAULT_MAX_INACTIVE_TIME, + /** + * The function to call when the worker is killed. + */ + killHandler: EMPTY_FUNCTION, +} + +/** + * Base class that implements some shared logic for all poolifier workers. + * + * @typeParam MainWorker - Type of main worker. + * @typeParam Data - Type of data this worker receives from pool's execution. This can only be structured-cloneable data. + * @typeParam Response - Type of response the worker sends back to the main worker. This can only be structured-cloneable data. + */ +export abstract class AbstractWorker< + MainWorker extends WorkerGlobalScope & typeof globalThis, + Data = unknown, + Response = unknown, +> { + /** + * Worker id. + */ + protected abstract id?: `${string}-${string}-${string}-${string}-${string}` + /** + * Task function(s) object processed by the worker when the pool's `execute` method is invoked. + */ + protected taskFunctions!: Map> + /** + * Timestamp of the last task processed by this worker. + */ + protected lastTaskTimestamp!: number + /** + * Performance statistics computation requirements. + */ + protected statistics?: WorkerStatistics + /** + * Handler id of the `activeInterval` worker activity check. + */ + protected activeInterval?: number + + /** + * Constructs a new poolifier worker. + * @param isMain - Whether this is the main worker or not. + * @param mainWorker - Reference to main worker. + * @param taskFunctions - Task function(s) processed by the worker when the pool's `execute` method is invoked. The first function is the default function. + * @param opts - Options for the worker. + */ + public constructor( + private readonly isMain: boolean | undefined, + private readonly mainWorker: MainWorker | undefined, + taskFunctions: TaskFunction | TaskFunctions, + protected opts: WorkerOptions = DEFAULT_WORKER_OPTIONS, + ) { + if (this.isMain == null) { + throw new Error('isMain parameter is mandatory') + } + this.checkTaskFunctions(taskFunctions) + this.checkWorkerOptions(this.opts) + if (!this.isMain) { + this.getMainWorker().addEventListener( + 'message', + this.handleReadyMessageEvent.bind(this), + { + once: true, + }, + ) + } + } + + private checkWorkerOptions(opts: WorkerOptions): void { + checkValidWorkerOptions(opts) + this.opts = { ...DEFAULT_WORKER_OPTIONS, ...opts } + } + + /** + * Checks if the `taskFunctions` parameter is passed to the constructor and valid. + * + * @param taskFunctions - The task function(s) parameter that should be checked. + */ + private checkTaskFunctions( + taskFunctions: + | TaskFunction + | TaskFunctions + | undefined, + ): void { + if (taskFunctions == null) { + throw new Error('taskFunctions parameter is mandatory') + } + this.taskFunctions = new Map>() + if (typeof taskFunctions === 'function') { + const fnObj = { taskFunction: taskFunctions.bind(this) } + this.taskFunctions.set(DEFAULT_TASK_NAME, fnObj) + this.taskFunctions.set( + typeof taskFunctions.name === 'string' && + taskFunctions.name.trim().length > 0 + ? taskFunctions.name + : 'fn1', + fnObj, + ) + } else if (isPlainObject(taskFunctions)) { + let firstEntry = true + for (let [name, fnObj] of Object.entries(taskFunctions)) { + if (typeof fnObj === 'function') { + fnObj = { taskFunction: fnObj } satisfies TaskFunctionObject< + Data, + Response + > + } + checkValidTaskFunctionObjectEntry(name, fnObj) + fnObj.taskFunction = fnObj.taskFunction.bind(this) + if (firstEntry) { + this.taskFunctions.set(DEFAULT_TASK_NAME, fnObj) + firstEntry = false + } + this.taskFunctions.set(name, fnObj) + } + if (firstEntry) { + throw new Error('taskFunctions parameter object is empty') + } + } else { + throw new TypeError( + 'taskFunctions parameter is not a function or a plain object', + ) + } + } + + /** + * Checks if the worker has a task function with the given name. + * + * @param name - The name of the task function to check. + * @returns Whether the worker has a task function with the given name or not. + */ + public hasTaskFunction(name: string): TaskFunctionOperationResult { + try { + checkTaskFunctionName(name) + } catch (error) { + return { status: false, error: error as Error } + } + return { status: this.taskFunctions.has(name) } + } + + /** + * Adds a task function to the worker. + * If a task function with the same name already exists, it is replaced. + * + * @param name - The name of the task function to add. + * @param fn - The task function to add. + * @returns Whether the task function was added or not. + */ + public addTaskFunction( + name: string, + fn: TaskFunction | TaskFunctionObject, + ): TaskFunctionOperationResult { + try { + checkTaskFunctionName(name) + if (name === DEFAULT_TASK_NAME) { + throw new Error( + 'Cannot add a task function with the default reserved name', + ) + } + if (typeof fn === 'function') { + fn = { taskFunction: fn } satisfies TaskFunctionObject + } + checkValidTaskFunctionObjectEntry(name, fn) + fn.taskFunction = fn.taskFunction.bind(this) + if ( + this.taskFunctions.get(name) === + this.taskFunctions.get(DEFAULT_TASK_NAME) + ) { + this.taskFunctions.set(DEFAULT_TASK_NAME, fn) + } + this.taskFunctions.set(name, fn) + this.sendTaskFunctionsPropertiesToMainWorker() + return { status: true } + } catch (error) { + return { status: false, error: error as Error } + } + } + + /** + * Removes a task function from the worker. + * + * @param name - The name of the task function to remove. + * @returns Whether the task function existed and was removed or not. + */ + public removeTaskFunction(name: string): TaskFunctionOperationResult { + try { + checkTaskFunctionName(name) + if (name === DEFAULT_TASK_NAME) { + throw new Error( + 'Cannot remove the task function with the default reserved name', + ) + } + if ( + this.taskFunctions.get(name) === + this.taskFunctions.get(DEFAULT_TASK_NAME) + ) { + throw new Error( + 'Cannot remove the task function used as the default task function', + ) + } + const deleteStatus = this.taskFunctions.delete(name) + this.sendTaskFunctionsPropertiesToMainWorker() + return { status: deleteStatus } + } catch (error) { + return { status: false, error: error as Error } + } + } + + /** + * Lists the properties of the worker's task functions. + * + * @returns The properties of the worker's task functions. + */ + public listTaskFunctionsProperties(): TaskFunctionProperties[] { + let defaultTaskFunctionName = DEFAULT_TASK_NAME + for (const [name, fnObj] of this.taskFunctions) { + if ( + name !== DEFAULT_TASK_NAME && + fnObj === this.taskFunctions.get(DEFAULT_TASK_NAME) + ) { + defaultTaskFunctionName = name + break + } + } + const taskFunctionsProperties: TaskFunctionProperties[] = [] + for (const [name, fnObj] of this.taskFunctions) { + if (name === DEFAULT_TASK_NAME || name === defaultTaskFunctionName) { + continue + } + taskFunctionsProperties.push(buildTaskFunctionProperties(name, fnObj)) + } + return [ + buildTaskFunctionProperties( + DEFAULT_TASK_NAME, + this.taskFunctions.get(DEFAULT_TASK_NAME), + ), + buildTaskFunctionProperties( + defaultTaskFunctionName, + this.taskFunctions.get(defaultTaskFunctionName), + ), + ...taskFunctionsProperties, + ] + } + + /** + * Sets the default task function to use in the worker. + * + * @param name - The name of the task function to use as default task function. + * @returns Whether the default task function was set or not. + */ + public setDefaultTaskFunction(name: string): TaskFunctionOperationResult { + try { + checkTaskFunctionName(name) + if (name === DEFAULT_TASK_NAME) { + throw new Error( + 'Cannot set the default task function reserved name as the default task function', + ) + } + if (!this.taskFunctions.has(name)) { + throw new Error( + 'Cannot set the default task function to a non-existing task function', + ) + } + this.taskFunctions.set( + DEFAULT_TASK_NAME, + this.taskFunctions.get(name) as TaskFunctionObject, + ) + this.sendTaskFunctionsPropertiesToMainWorker() + return { status: true } + } catch (error) { + return { status: false, error: error as Error } + } + } + + /** + * Handles the ready message event sent by the main worker. + * + * @param messageEvent - The ready message event. + */ + protected abstract handleReadyMessageEvent( + messageEvent: MessageEvent>, + ): void + + /** + * Worker message event listener. + * + * @param messageEvent - The received message event. + */ + protected messageEventListener( + messageEvent: MessageEvent>, + ): void { + const { data } = messageEvent + this.checkMessageWorkerId(data) + const { statistics, checkActive, taskFunctionOperation, taskId, kill } = + data + if (statistics != null) { + // Statistics message received + this.statistics = statistics + } else if (checkActive != null) { + // Check active message received + checkActive ? this.startCheckActive() : this.stopCheckActive() + } else if (taskFunctionOperation != null) { + // Task function operation message received + this.handleTaskFunctionOperationMessage(data) + } else if (taskId != null && data != null) { + // Task message received + this.run(data) + } else if (kill === true) { + // Kill message received + this.handleKillMessage(data) + } + } + + protected handleTaskFunctionOperationMessage( + message: MessageValue, + ): void { + const { taskFunctionOperation, taskFunctionProperties, taskFunction } = + message + if (taskFunctionProperties == null) { + throw new Error( + 'Cannot handle task function operation message without task function properties', + ) + } + let response: TaskFunctionOperationResult + switch (taskFunctionOperation) { + case 'add': + if (typeof taskFunction !== 'string') { + throw new Error( + `Cannot handle task function operation ${taskFunctionOperation} message without task function`, + ) + } + response = this.addTaskFunction(taskFunctionProperties.name, { + taskFunction: new Function( + `return ${taskFunction}`, + )() as TaskFunction, + ...(taskFunctionProperties.priority != null && { + priority: taskFunctionProperties.priority, + }), + ...(taskFunctionProperties.strategy != null && { + strategy: taskFunctionProperties.strategy, + }), + }) + break + case 'remove': + response = this.removeTaskFunction(taskFunctionProperties.name) + break + case 'default': + response = this.setDefaultTaskFunction(taskFunctionProperties.name) + break + default: + response = { + status: false, + error: new Error('Unknown task operation'), + } + break + } + const { status, error } = response + this.sendToMainWorker({ + taskFunctionOperation, + taskFunctionOperationStatus: status, + taskFunctionProperties, + ...(!status && + error != null && { + workerError: { + name: taskFunctionProperties.name, + message: this.handleError(error as Error | string), + }, + }), + }) + } + + /** + * Handles a kill message sent by the main worker. + * + * @param _message - The kill message. + */ + protected handleKillMessage(_message: MessageValue): void { + this.stopCheckActive() + if (isAsyncFunction(this.opts.killHandler)) { + ;(this.opts.killHandler as () => Promise)() + .then(() => { + this.sendToMainWorker({ kill: 'success' }) + }) + .catch(() => { + this.sendToMainWorker({ kill: 'failure' }) + }) + } else { + try { + ;(this.opts.killHandler as (() => void) | undefined)?.() + this.sendToMainWorker({ kill: 'success' }) + } catch { + this.sendToMainWorker({ kill: 'failure' }) + } + } + } + + /** + * Check if the message worker id is set and matches the worker id. + * + * @param message - The message to check. + * @throws {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error} If the message worker id is not set or does not match the worker id. + */ + private checkMessageWorkerId(message: MessageValue): void { + if (message.workerId == null) { + throw new Error('Message worker id is not set') + } else if (message.workerId !== this.id) { + throw new Error( + `Message worker id ${message.workerId.toString()} does not match the worker id ${this.id}`, + ) + } + } + + /** + * Starts the worker check active interval. + */ + private startCheckActive(): void { + this.lastTaskTimestamp = performance.now() + this.activeInterval = setInterval( + this.checkActive.bind(this), + (this.opts.maxInactiveTime ?? DEFAULT_MAX_INACTIVE_TIME) / 2, + ) + } + + /** + * Stops the worker check active interval. + */ + private stopCheckActive(): void { + if (this.activeInterval != null) { + clearInterval(this.activeInterval) + delete this.activeInterval + } + } + + /** + * Checks if the worker should be terminated, because its living too long. + */ + private checkActive(): void { + if ( + performance.now() - this.lastTaskTimestamp > + (this.opts.maxInactiveTime ?? DEFAULT_MAX_INACTIVE_TIME) + ) { + this.sendToMainWorker({ kill: this.opts.killBehavior }) + } + } + + /** + * Returns the main worker. + * + * @returns Reference to the main worker. + * @throws {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error} If the main worker is not set. + */ + protected getMainWorker(): MainWorker { + if (this.mainWorker == null) { + throw new Error('Main worker not set') + } + return this.mainWorker + } + + /** + * Sends a message to main worker. + * + * @param message - The response message. + */ + protected abstract sendToMainWorker( + message: MessageValue, + ): void + + /** + * Sends task functions properties to the main worker. + */ + protected sendTaskFunctionsPropertiesToMainWorker(): void { + this.sendToMainWorker({ + taskFunctionsProperties: this.listTaskFunctionsProperties(), + }) + } + + /** + * Handles an error and convert it to a string so it can be sent back to the main worker. + * + * @param error - The error raised by the worker. + * @returns The error message. + */ + protected handleError(error: Error | string): string { + return error instanceof Error ? error.message : error + } + + /** + * Runs the given task. + * + * @param task - The task to execute. + */ + protected readonly run = (task: Task): void => { + const { name, taskId, data } = task + const taskFunctionName = name ?? DEFAULT_TASK_NAME + if (!this.taskFunctions.has(taskFunctionName)) { + this.sendToMainWorker({ + workerError: { + name: name!, + message: `Task function '${name!}' not found`, + data, + }, + taskId, + }) + return + } + const fn = this.taskFunctions.get(taskFunctionName)?.taskFunction + if (isAsyncFunction(fn)) { + this.runAsync(fn as TaskAsyncFunction, task) + } else { + this.runSync(fn as TaskSyncFunction, task) + } + } + + /** + * Runs the given task function synchronously. + * + * @param fn - Task function that will be executed. + * @param task - Input data for the task function. + */ + protected readonly runSync = ( + fn: TaskSyncFunction, + task: Task, + ): void => { + const { name, taskId, data } = task + try { + let taskPerformance = this.beginTaskPerformance(name) + const res = fn(data) + taskPerformance = this.endTaskPerformance(taskPerformance) + this.sendToMainWorker({ + data: res, + taskPerformance, + taskId, + }) + } catch (error) { + this.sendToMainWorker({ + workerError: { + name: name!, + message: this.handleError(error as Error | string), + data, + }, + taskId, + }) + } finally { + this.updateLastTaskTimestamp() + } + } + + /** + * Runs the given task function asynchronously. + * + * @param fn - Task function that will be executed. + * @param task - Input data for the task function. + */ + protected readonly runAsync = ( + fn: TaskAsyncFunction, + task: Task, + ): void => { + const { name, taskId, data } = task + let taskPerformance = this.beginTaskPerformance(name) + fn(data) + .then((res) => { + taskPerformance = this.endTaskPerformance(taskPerformance) + this.sendToMainWorker({ + data: res, + taskPerformance, + taskId, + }) + }) + .catch((error) => { + this.sendToMainWorker({ + workerError: { + name: name!, + message: this.handleError(error as Error | string), + data, + }, + taskId, + }) + }) + .finally(() => { + this.updateLastTaskTimestamp() + }) + .catch(EMPTY_FUNCTION) + } + + private beginTaskPerformance(name?: string): TaskPerformance { + if (this.statistics == null) { + throw new Error('Performance statistics computation requirements not set') + } + return { + name: name ?? DEFAULT_TASK_NAME, + timestamp: performance.now(), + // ...(this.statistics.elu && { elu: performance.eventLoopUtilization() }), + } + } + + private endTaskPerformance( + taskPerformance: TaskPerformance, + ): TaskPerformance { + if (this.statistics == null) { + throw new Error('Performance statistics computation requirements not set') + } + return { + ...taskPerformance, + ...(this.statistics.runTime && { + runTime: performance.now() - taskPerformance.timestamp, + }), + // ...(this.statistics.elu && { + // elu: performance.eventLoopUtilization(taskPerformance.elu), + // }), + } + } + + private updateLastTaskTimestamp(): void { + if (this.activeInterval != null) { + this.lastTaskTimestamp = performance.now() + } + } +} diff --git a/src/vendor/poolifier/worker/task-functions.ts b/src/vendor/poolifier/worker/task-functions.ts new file mode 100644 index 0000000..adb92c9 --- /dev/null +++ b/src/vendor/poolifier/worker/task-functions.ts @@ -0,0 +1,81 @@ +import type { WorkerChoiceStrategy } from '../pools/selection-strategies/selection-strategies-types.ts' + +/** + * Task synchronous function that can be executed. + * + * @param data - Data sent to the worker. + * @returns Execution response. + * + * @typeParam Data - Type of data sent to the worker. This can only be structured-cloneable data. + * @typeParam Response - Type of execution response. This can only be structured-cloneable data. + */ +export type TaskSyncFunction = ( + data?: Data, +) => Response + +/** + * Task asynchronous function that can be executed. + * This function must return a promise. + * + * @param data - Data sent to the worker. + * @returns Execution response promise. + * + * @typeParam Data - Type of data sent to the worker. This can only be structured-cloneable data. + * @typeParam Response - Type of execution response. This can only be structured-cloneable data. + */ +export type TaskAsyncFunction = ( + data?: Data, +) => Promise + +/** + * Task function that can be executed. + * This function can be synchronous or asynchronous. + * + * @typeParam Data - Type of data sent to the worker. This can only be structured-cloneable data. + * @typeParam Response - Type of execution response. This can only be structured-cloneable data. + */ +export type TaskFunction = + | TaskSyncFunction + | TaskAsyncFunction + +/** + * Task function object. + * + * @typeParam Data - Type of data sent to the worker. This can only be structured-cloneable data. + * @typeParam Response - Type of execution response. This can only be structured-cloneable data. + */ +export interface TaskFunctionObject { + /** + * Task function. + */ + taskFunction: TaskFunction + /** + * Task function priority. Lower values have higher priority. + */ + priority?: number + /** + * Task function worker choice strategy. + */ + strategy?: WorkerChoiceStrategy +} + +/** + * Tasks functions that can be executed. + * The key is the name of the task function or task function object. + * The value is the task function or task function object. + * + * @typeParam Data - Type of data sent to the worker. This can only be structured-cloneable data. + * @typeParam Response - Type of execution response. This can only be structured-cloneable data. + */ +export type TaskFunctions = Record< + string, + TaskFunction | TaskFunctionObject +> + +/** + * Task function operation result. + */ +export interface TaskFunctionOperationResult { + status: boolean + error?: Error +} diff --git a/src/vendor/poolifier/worker/thread-worker.ts b/src/vendor/poolifier/worker/thread-worker.ts new file mode 100644 index 0000000..a2dec0c --- /dev/null +++ b/src/vendor/poolifier/worker/thread-worker.ts @@ -0,0 +1,100 @@ +import type { MessageValue } from '../utility-types.ts' +import { isWebWorker } from '../utils.ts' +import { AbstractWorker } from './abstract-worker.ts' +import type { TaskFunction, TaskFunctions } from './task-functions.ts' +import type { WorkerOptions } from './worker-options.ts' + +/** + * A thread worker used by a poolifier `ThreadPool`. + * + * When this worker is inactive for more than the given `maxInactiveTime`, + * it will send a termination request to its main thread. + * + * If you use a `DynamicThreadPool` the extra workers that were created will be terminated, + * but the minimum number of workers will be guaranteed. + * + * @typeParam Data - Type of data this worker receives from pool's execution. This can only be structured-cloneable data. + * @typeParam Response - Type of response the worker sends back to the main thread. This can only be structured-cloneable data. + * @author [Alessandro Pio Ardizio](https://github.com/pioardi) + * @since 0.0.1 + */ +export class ThreadWorker< + Data = unknown, + Response = unknown, +> extends AbstractWorker< + WorkerGlobalScope & typeof globalThis, + Data, + Response +> { + /** + * Message port used to communicate with the main worker. + */ + private port?: MessagePort + /** @inheritdoc */ + public id?: `${string}-${string}-${string}-${string}-${string}` + + /** + * Constructs a new poolifier thread worker. + * + * @param taskFunctions - Task function(s) processed by the worker when the pool's `execute` method is invoked. + * @param opts - Options for the worker. + */ + public constructor( + taskFunctions: TaskFunction | TaskFunctions, + opts: WorkerOptions = {}, + ) { + super(!isWebWorker, self, taskFunctions, opts) + } + + /** @inheritDoc */ + protected handleReadyMessageEvent( + messageEvent: MessageEvent>, + ): void { + if ( + messageEvent.data?.workerId != null && + messageEvent.data?.ready === false && + messageEvent.data?.port != null + ) { + try { + this.id = messageEvent.data.workerId + this.port = messageEvent.data.port + this.port.onmessage = this.messageEventListener.bind(this) + this.sendToMainWorker({ + ready: true, + taskFunctionsProperties: this.listTaskFunctionsProperties(), + }) + } catch { + this.sendToMainWorker({ + ready: false, + taskFunctionsProperties: this.listTaskFunctionsProperties(), + }) + } + } + } + + /** @inheritDoc */ + protected override handleKillMessage(message: MessageValue): void { + super.handleKillMessage(message) + this.port?.close() + } + + /** @inheritDoc */ + protected readonly sendToMainWorker = ( + message: MessageValue, + ): void => { + this.port?.postMessage( + { + ...message, + workerId: this.id, + } satisfies MessageValue, + ) + } + + /** + * @inheritDoc + * @override + */ + protected override handleError(error: Error | string): string { + return error as string + } +} diff --git a/src/vendor/poolifier/worker/utils.ts b/src/vendor/poolifier/worker/utils.ts new file mode 100644 index 0000000..30d8d18 --- /dev/null +++ b/src/vendor/poolifier/worker/utils.ts @@ -0,0 +1,70 @@ +import { + checkValidPriority, + checkValidWorkerChoiceStrategy, +} from '../pools/utils.ts' +import { isPlainObject } from '../utils.ts' +import type { TaskFunctionObject } from './task-functions.ts' +import { KillBehaviors, type WorkerOptions } from './worker-options.ts' + +export const checkValidWorkerOptions = ( + opts: WorkerOptions | undefined, +): void => { + if (opts != null && !isPlainObject(opts)) { + throw new TypeError('opts worker options parameter is not a plain object') + } + if ( + opts?.killBehavior != null && + !Object.values(KillBehaviors).includes(opts.killBehavior) + ) { + throw new TypeError( + `killBehavior option '${opts.killBehavior}' is not valid`, + ) + } + if ( + opts?.maxInactiveTime != null && + !Number.isSafeInteger(opts.maxInactiveTime) + ) { + throw new TypeError('maxInactiveTime option is not an integer') + } + if (opts?.maxInactiveTime != null && opts.maxInactiveTime < 5) { + throw new TypeError( + 'maxInactiveTime option is not a positive integer greater or equal than 5', + ) + } + if (opts?.killHandler != null && typeof opts.killHandler !== 'function') { + throw new TypeError('killHandler option is not a function') + } +} + +export const checkValidTaskFunctionObjectEntry = < + Data = unknown, + Response = unknown, +>( + name: string, + fnObj: TaskFunctionObject, +): void => { + if (typeof name !== 'string') { + throw new TypeError('A taskFunctions parameter object key is not a string') + } + if (typeof name === 'string' && name.trim().length === 0) { + throw new TypeError( + 'A taskFunctions parameter object key is an empty string', + ) + } + if (typeof fnObj.taskFunction !== 'function') { + throw new TypeError( + `taskFunction object 'taskFunction' property '${fnObj.taskFunction}' is not a function`, + ) + } + checkValidPriority(fnObj.priority) + checkValidWorkerChoiceStrategy(fnObj.strategy) +} + +export const checkTaskFunctionName = (name: string): void => { + if (typeof name !== 'string') { + throw new TypeError('name parameter is not a string') + } + if (typeof name === 'string' && name.trim().length === 0) { + throw new TypeError('name parameter is an empty string') + } +} diff --git a/src/vendor/poolifier/worker/worker-options.ts b/src/vendor/poolifier/worker/worker-options.ts new file mode 100644 index 0000000..81060b2 --- /dev/null +++ b/src/vendor/poolifier/worker/worker-options.ts @@ -0,0 +1,62 @@ +/** + * Enumeration of kill behaviors. + */ +export const KillBehaviors: Readonly<{ SOFT: 'SOFT'; HARD: 'HARD' }> = Object + .freeze( + { + /** + * If `currentTime - lastActiveTime` is greater than `maxInactiveTime` but the worker is stealing tasks or a task is executing or queued, then the worker **wont** be deleted. + */ + SOFT: 'SOFT', + /** + * If `currentTime - lastActiveTime` is greater than `maxInactiveTime` but the worker is stealing tasks or a task is executing or queued, then the worker will be deleted. + */ + HARD: 'HARD', + } as const, + ) + +/** + * Kill behavior. + */ +export type KillBehavior = keyof typeof KillBehaviors + +/** + * Handler called when a worker is killed. + */ +export type KillHandler = () => void | Promise + +/** + * Options for workers. + */ +export interface WorkerOptions { + /** + * `killBehavior` dictates if your worker will be deleted in case a task is active on it. + * + * - SOFT: If `currentTime - lastActiveTime` is greater than `maxInactiveTime` but the worker is stealing tasks or a task is executing or queued, then the worker **won't** be deleted. + * - HARD: If `currentTime - lastActiveTime` is greater than `maxInactiveTime` but the worker is stealing tasks or a task is executing or queued, then the worker will be deleted. + * + * This option only apply to the newly created workers. + * + * @defaultValue KillBehaviors.SOFT + */ + killBehavior?: KillBehavior + /** + * Maximum waiting time in milliseconds for tasks on newly created workers. It must be greater or equal than 5. + * + * After this time, newly created workers will be terminated. + * The last active time of your worker will be updated when it terminates a task. + * + * - If `killBehavior` is set to `KillBehaviors.HARD` this value represents also the timeout for the tasks that you submit to the pool, + * when this timeout expires your tasks is interrupted before completion and removed. The worker is killed if is not part of the minimum size of the pool. + * - If `killBehavior` is set to `KillBehaviors.SOFT` your tasks have no timeout and your workers will not be terminated until your task is completed. + * + * @defaultValue 60000 + */ + maxInactiveTime?: number + /** + * The function to call when a worker is killed. + * + * @defaultValue `() => {}` + */ + killHandler?: KillHandler +}