1 import type { Worker
} from
'node:cluster'
2 import type { UUID
} from
'node:crypto'
3 import type { MessagePort
} from
'node:worker_threads'
5 import { AsyncLocalStorage
} from
'node:async_hooks'
6 import { performance
} from
'node:perf_hooks'
7 import { isPromise
} from
'node:util/types'
12 TaskFunctionProperties
,
15 } from
'../utility-types.js'
20 TaskFunctionOperationResult
,
23 } from
'./task-functions.js'
26 buildTaskFunctionProperties
,
32 import { AbortError
} from
'./abort-error.js'
34 checkTaskFunctionName
,
35 checkValidTaskFunctionObjectEntry
,
36 checkValidWorkerOptions
,
38 import { KillBehaviors
, type WorkerOptions
} from
'./worker-options.js'
40 const DEFAULT_MAX_INACTIVE_TIME
= 60000
41 const DEFAULT_WORKER_OPTIONS
: Readonly
<WorkerOptions
> = Object.freeze({
43 * The kill behavior option on this worker or its default value.
45 killBehavior
: KillBehaviors
.SOFT
,
47 * The function to call when the worker is killed.
49 killHandler
: EMPTY_FUNCTION
,
51 * The maximum time to keep this worker active while idle.
52 * The pool automatically checks and terminates this worker when the time expires.
54 maxInactiveTime
: DEFAULT_MAX_INACTIVE_TIME
,
58 * Base class that implements some shared logic for all poolifier workers.
59 * @template MainWorker - Type of main worker.
60 * @template Data - Type of data this worker receives from pool's execution. This can only be structured-cloneable data.
61 * @template Response - Type of response the worker sends back to the main worker. This can only be structured-cloneable data.
63 export abstract class AbstractWorker
<
64 MainWorker
extends MessagePort
| Worker
,
69 * Handler id of the `activeInterval` worker activity check.
71 protected activeInterval
?: NodeJS
.Timeout
75 protected abstract readonly id
: number
77 * Timestamp of the last task processed by this worker.
79 protected lastTaskTimestamp
!: number
82 * Performance statistics computation requirements.
84 protected statistics
?: WorkerStatistics
87 * Task abort functions processed by the worker when task operation 'abort' is received.
89 protected taskAbortFunctions
: Map
<UUID
, () => void>
92 * Correlation of task function operations across protected handler overrides.
94 protected readonly taskFunctionOperationContext
: AsyncLocalStorage
<
96 > = new AsyncLocalStorage
<undefined | UUID
>()
99 * Task function object(s) processed by the worker when the pool's `execute` method is invoked.
101 protected taskFunctions
!: Map
<string, TaskFunctionObject
<Data
, Response
>>
104 * The base removal result and name that confirm absence for the current operation.
106 private missingTaskFunctionRemoval
?: TaskFunctionOperationResult
107 private missingTaskFunctionRemovalName
?: string
109 private readonly taskFunctionOperationIds
= new WeakMap
<
110 TaskFunctionProperties
,
115 * Constructs a new poolifier worker.
116 * @param isMain - Whether this is the main worker or not.
117 * @param mainWorker - Reference to main worker.
118 * @param taskFunctions - Task function(s) processed by the worker when the pool's `execute` method is invoked. The first function is the default function.
119 * @param opts - Options for the worker.
122 protected readonly isMain
: boolean | undefined,
123 private readonly mainWorker
: MainWorker
| null | undefined,
124 taskFunctions
: TaskFunction
<Data
, Response
> | TaskFunctions
<Data
, Response
>,
125 protected opts
: WorkerOptions
= DEFAULT_WORKER_OPTIONS
127 if (this.isMain
== null) {
128 throw new Error('isMain parameter is mandatory')
130 this.checkTaskFunctions(taskFunctions
)
131 this.taskAbortFunctions
= new Map
<UUID
, () => void>()
132 this.checkWorkerOptions(this.opts
)
134 this.getMainWorker().once('message', this.handleReadyMessage
.bind(this))
139 * Adds a task function to the worker.
140 * If a task function with the same name already exists, it is replaced.
141 * @param name - The name of the task function to add.
142 * @param fn - The task function to add.
143 * @returns Whether the task function was added or not.
145 public addTaskFunction (
147 fn
: TaskFunction
<Data
, Response
> | TaskFunctionObject
<Data
, Response
>
148 ): TaskFunctionOperationResult
{
150 checkTaskFunctionName(name
)
151 if (name
=== DEFAULT_TASK_NAME
) {
153 'Cannot add a task function with the default reserved name'
156 if (typeof fn
=== 'function') {
157 fn
= { taskFunction
: fn
} satisfies TaskFunctionObject
<Data
, Response
>
159 checkValidTaskFunctionObjectEntry
<Data
, Response
>(name
, fn
)
160 fn
.taskFunction
= fn
.taskFunction
.bind(this)
162 this.taskFunctions
.get(name
) ===
163 this.taskFunctions
.get(DEFAULT_TASK_NAME
)
165 this.taskFunctions
.set(DEFAULT_TASK_NAME
, fn
)
167 this.taskFunctions
.set(name
, fn
)
168 this.sendTaskFunctionsPropertiesToMainWorker()
169 return { status: true }
171 return { error
: error
as Error, status: false }
176 * Checks if the worker has a task function with the given name.
177 * @param name - The name of the task function to check.
178 * @returns Whether the worker has a task function with the given name or not.
180 public hasTaskFunction (name
: string): TaskFunctionOperationResult
{
182 checkTaskFunctionName(name
)
184 return { error
: error
as Error, status: false }
186 return { status: this.taskFunctions
.has(name
) }
190 * Lists the properties of the worker's task functions.
191 * @returns The properties of the worker's task functions.
193 public listTaskFunctionsProperties (): TaskFunctionProperties
[] {
194 let defaultTaskFunctionName
= DEFAULT_TASK_NAME
195 for (const [name
, fnObj
] of this.taskFunctions
) {
197 name
!== DEFAULT_TASK_NAME
&&
198 fnObj
=== this.taskFunctions
.get(DEFAULT_TASK_NAME
)
200 defaultTaskFunctionName
= name
204 const taskFunctionsProperties
: TaskFunctionProperties
[] = []
205 for (const [name
, fnObj
] of this.taskFunctions
) {
206 if (name
=== DEFAULT_TASK_NAME
|| name
=== defaultTaskFunctionName
) {
209 taskFunctionsProperties
.push(buildTaskFunctionProperties(name
, fnObj
))
212 buildTaskFunctionProperties(
214 this.taskFunctions
.get(DEFAULT_TASK_NAME
)
216 buildTaskFunctionProperties(
217 defaultTaskFunctionName
,
218 this.taskFunctions
.get(defaultTaskFunctionName
)
220 ...taskFunctionsProperties
,
225 * Removes a task function from the worker.
226 * @param name - The name of the task function to remove.
227 * @returns Whether the task function existed and was removed or not.
229 public removeTaskFunction (name
: string): TaskFunctionOperationResult
{
231 checkTaskFunctionName(name
)
232 if (name
=== DEFAULT_TASK_NAME
) {
234 'Cannot remove the task function with the default reserved name'
238 this.taskFunctions
.get(name
) ===
239 this.taskFunctions
.get(DEFAULT_TASK_NAME
)
242 'Cannot remove the task function used as the default task function'
245 const deleteStatus
= this.taskFunctions
.delete(name
)
246 this.sendTaskFunctionsPropertiesToMainWorker()
247 const response
= { status: deleteStatus
}
249 this.missingTaskFunctionRemoval
= response
250 this.missingTaskFunctionRemovalName
= name
254 return { error
: error
as Error, status: false }
259 * Sets the default task function to use in the worker.
260 * @param name - The name of the task function to use as default task function.
261 * @returns Whether the default task function was set or not.
263 public setDefaultTaskFunction (name
: string): TaskFunctionOperationResult
{
265 checkTaskFunctionName(name
)
266 if (name
=== DEFAULT_TASK_NAME
) {
268 'Cannot set the default task function reserved name as the default task function'
271 if (!this.taskFunctions
.has(name
)) {
273 'Cannot set the default task function to a non-existing task function'
276 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
277 this.taskFunctions
.set(DEFAULT_TASK_NAME
, this.taskFunctions
.get(name
)!)
278 this.sendTaskFunctionsPropertiesToMainWorker()
279 return { status: true }
281 return { error
: error
as Error, status: false }
286 * Returns the main worker.
287 * @returns Reference to the main worker.
288 * @throws {Error} If the main worker is not set.
290 protected getMainWorker (): MainWorker
{
291 if (this.mainWorker
== null) {
292 throw new Error('Main worker not set')
294 return this.mainWorker
298 * Gets the task function operation UUID for a request or response.
299 * @param message - The task function operation message.
300 * @returns The task function operation UUID if available.
302 protected getTaskFunctionOperationId (
303 message
: MessageValue
304 ): undefined | UUID
{
306 message
.taskFunctionOperationId
??
307 (message
.taskFunctionProperties
!= null
308 ? this.taskFunctionOperationIds
.get(message
.taskFunctionProperties
)
310 this.taskFunctionOperationContext
.getStore()
315 * Handles a worker error.
316 * @param error - The error raised by the worker.
317 * @returns The worker error object.
319 protected abstract handleError (error
: Error): {
327 * Handles a kill message sent by the main worker.
328 * @param message - The kill message.
330 protected handleKillMessage (message
: MessageValue
<Data
>): void {
331 this.stopCheckActive()
332 let promise
: Promise
<void> | undefined
334 promise
= this.getResultPromise(this.opts
.killHandler
?.())
336 this.sendToMainWorker({ kill
: 'failure' })
339 if (promise
!= null) {
343 this.sendToMainWorker({ kill
: 'success' })
347 this.sendToMainWorker({ kill
: 'failure' })
351 .catch((error
: unknown
) => {
352 queueMicrotask(() => {
357 this.sendToMainWorker({ kill
: 'success' })
362 * Handles the ready message sent by the main worker.
363 * @param message - The ready message.
365 protected abstract handleReadyMessage (message
: MessageValue
<Data
>): void
368 * Handles a task function operation.
369 * Deferred overrides must retain the task function properties reference or the
370 * operation UUID when forwarding outside the incoming asynchronous context.
371 * An acknowledgement without a recoverable UUID rejects the pool operation.
372 * @param message - The task function operation request.
374 protected handleTaskFunctionOperationMessage (
375 message
: MessageValue
<Data
>
377 const { taskFunction
, taskFunctionOperation
, taskFunctionProperties
} =
379 if (taskFunctionProperties
== null) {
381 'Cannot handle task function operation message without task function properties'
384 let taskFunctionOperationNotFound
= false
385 let response
: TaskFunctionOperationResult
386 switch (taskFunctionOperation
) {
388 if (typeof taskFunction
!== 'string') {
390 `Cannot handle task function operation ${taskFunctionOperation} message without task function`
393 response
= this.addTaskFunction(taskFunctionProperties
.name
, {
394 // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func, @typescript-eslint/no-unsafe-call
395 taskFunction
: new Function(
396 `return (${taskFunction})`
397 )() as TaskFunction
<Data
, Response
>,
398 ...(taskFunctionProperties
.priority
!= null && {
399 priority
: taskFunctionProperties
.priority
,
401 ...(taskFunctionProperties
.strategy
!= null && {
402 strategy
: taskFunctionProperties
.strategy
,
404 ...(taskFunctionProperties
.workerNodeKeys
!= null && {
405 workerNodeKeys
: taskFunctionProperties
.workerNodeKeys
,
410 response
= this.setDefaultTaskFunction(taskFunctionProperties
.name
)
413 this.missingTaskFunctionRemoval
= undefined
414 this.missingTaskFunctionRemovalName
= undefined
416 const name
= taskFunctionProperties
.name
417 response
= this.removeTaskFunction(name
)
418 taskFunctionOperationNotFound
=
420 response
.error
== null &&
421 Object.is(response
, this.missingTaskFunctionRemoval
) &&
422 Object.is(name
, this.missingTaskFunctionRemovalName
)
424 this.missingTaskFunctionRemoval
= undefined
425 this.missingTaskFunctionRemovalName
= undefined
431 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
432 `Unknown task function operation: ${taskFunctionOperation!}`
438 const { error
, status } = response
439 this.sendToMainWorker({
440 taskFunctionOperation
,
441 taskFunctionOperationId
: this.getTaskFunctionOperationId(message
),
442 taskFunctionOperationStatus
: status,
443 taskFunctionProperties
,
444 ...(taskFunctionOperationNotFound
&& { taskFunctionOperationNotFound
}),
448 name
: taskFunctionProperties
.name
,
449 ...this.handleError(error
),
456 * Worker message listener.
457 * @param message - The received message.
459 protected messageListener (message
: MessageValue
<Data
>): void {
460 this.checkMessageWorkerId(message
)
466 taskFunctionOperation
,
470 if (statistics
!= null) {
471 // Statistics message received
472 this.statistics
= statistics
473 } else if (checkActive
!= null) {
474 // Check active message received
475 checkActive
? this.startCheckActive() : this.stopCheckActive()
476 } else if (taskFunctionOperation
!= null) {
477 // Task function operation message received
479 message
.taskFunctionProperties
!= null &&
480 typeof message
.taskFunctionProperties
=== 'object' &&
481 message
.taskFunctionOperationId
!= null
483 this.taskFunctionOperationIds
.set(
484 message
.taskFunctionProperties
,
485 message
.taskFunctionOperationId
488 this.taskFunctionOperationContext
.run(
489 message
.taskFunctionOperationId
,
490 this.runTaskFunctionOperation
,
493 } else if (taskId
!= null && data
!= null) {
494 // Task message received
496 } else if (taskOperation
=== 'abort' && taskId
!= null) {
497 // Abort task operation message received
498 if (this.taskAbortFunctions
.has(taskId
)) {
499 this.taskAbortFunctions
.get(taskId
)?.()
501 } else if (kill
=== true) {
502 // Kill message received
503 this.handleKillMessage(message
)
508 * Runs the given task.
509 * @param task - The task to execute.
511 protected readonly run
= (task
: Task
<Data
>): void => {
512 const { abortable
, name
, taskId
} = task
513 const taskFunctionName
= name
?? DEFAULT_TASK_NAME
514 if (!this.taskFunctions
.has(taskFunctionName
)) {
517 new Error(`Task function '${taskFunctionName}' not found`)
521 let fn
: TaskFunction
<Data
, Response
>
522 if (abortable
=== true) {
523 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
524 fn
= this.getAbortableTaskFunction(taskFunctionName
, taskId
!)
526 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
527 fn
= this.taskFunctions
.get(taskFunctionName
)!.taskFunction
529 if (isAsyncFunction(fn
)) {
530 this.runAsync(fn
as TaskAsyncFunction
<Data
, Response
>, task
)
532 this.runSync(fn
as TaskSyncFunction
<Data
, Response
>, task
)
537 * Runs the given task function asynchronously.
538 * @param fn - Task function that will be executed.
539 * @param task - Input data for the task function.
541 protected readonly runAsync
= (
542 fn
: TaskAsyncFunction
<Data
, Response
>,
546 const taskPerformance
= this.beginTaskPerformance(task
.name
)
547 const result
= fn(task
.data
)
548 this.completeAsyncTask(
549 this.getResultPromise(result
) ?? Promise
.resolve(result
),
555 this.sendTaskError(task
, error
as Error)
557 this.finishTask(task
)
563 * Runs the given task function synchronously.
564 * @param fn - Task function that will be executed.
565 * @param task - Input data for the task function.
567 protected readonly runSync
= (
568 fn
: TaskSyncFunction
<Data
, Response
>,
571 const { data
, name
, taskId
} = task
572 let asynchronous
= false
574 let taskPerformance
= this.beginTaskPerformance(name
)
576 const promise
= this.getResultPromise(res
)
577 if (promise
!= null) {
578 this.completeAsyncTask(promise
, task
, taskPerformance
)
582 taskPerformance
= this.endTaskPerformance(taskPerformance
)
583 this.sendToMainWorker({
589 this.sendTaskError(task
, error
as Error)
591 if (!asynchronous
) this.finishTask(task
)
596 * Sends task functions properties to the main worker.
598 protected sendTaskFunctionsPropertiesToMainWorker (): void {
599 this.sendToMainWorker({
600 taskFunctionsProperties
: this.listTaskFunctionsProperties(),
605 * Sends a message to main worker.
606 * @param message - The response message.
608 protected abstract sendToMainWorker (
609 message
: MessageValue
<Response
, Data
>
612 private beginTaskPerformance (name
?: string): TaskPerformance
{
613 if (this.statistics
== null) {
614 throw new Error('Performance statistics computation requirements not set')
617 name
: name
?? DEFAULT_TASK_NAME
,
618 timestamp
: performance
.now(),
619 ...(this.statistics
.elu
&& {
620 elu
: performance
.eventLoopUtilization(),
626 * Checks if the worker should be terminated, because its living too long.
628 private checkActive (): void {
630 performance
.now() - this.lastTaskTimestamp
>
631 (this.opts
.maxInactiveTime
?? DEFAULT_MAX_INACTIVE_TIME
)
633 this.sendToMainWorker({ kill
: this.opts
.killBehavior
})
638 * Check if the message worker id is set and matches the worker id.
639 * @param message - The message to check.
640 * @throws {Error} If the message worker id is not set or does not match the worker id.
642 private checkMessageWorkerId (message
: MessageValue
<Data
>): void {
643 if (message
.workerId
== null) {
645 `Message worker id is not set: ${JSON.stringify(message)}`
648 if (message
.workerId
!== this.id
) {
650 `Message worker id ${message.workerId.toString()} does not match the worker id ${this.id.toString()}: ${JSON.stringify(message)}`
656 * Checks if the `taskFunctions` parameter is passed to the constructor and valid.
657 * @param taskFunctions - The task function(s) parameter that should be checked.
659 private checkTaskFunctions (
661 | TaskFunction
<Data
, Response
>
662 | TaskFunctions
<Data
, Response
>
665 if (taskFunctions
== null) {
666 throw new Error('taskFunctions parameter is mandatory')
668 this.taskFunctions
= new Map
<string, TaskFunctionObject
<Data
, Response
>>()
669 if (typeof taskFunctions
=== 'function') {
670 const fnObj
= { taskFunction
: taskFunctions
.bind(this) }
671 this.taskFunctions
.set(DEFAULT_TASK_NAME
, fnObj
)
672 this.taskFunctions
.set(
673 typeof taskFunctions
.name
=== 'string' &&
674 taskFunctions
.name
.trim().length
> 0
679 } else if (isPlainObject(taskFunctions
)) {
680 let firstEntry
= true
681 for (let [name
, fnObj
] of Object.entries(taskFunctions
)) {
682 if (typeof fnObj
=== 'function') {
683 fnObj
= { taskFunction
: fnObj
} satisfies TaskFunctionObject
<
688 checkValidTaskFunctionObjectEntry
<Data
, Response
>(name
, fnObj
)
689 fnObj
.taskFunction
= fnObj
.taskFunction
.bind(this)
691 this.taskFunctions
.set(DEFAULT_TASK_NAME
, fnObj
)
694 this.taskFunctions
.set(name
, fnObj
)
697 throw new Error('taskFunctions parameter object is empty')
701 'taskFunctions parameter is not a function or a plain object'
706 private checkWorkerOptions (opts
: WorkerOptions
): void {
707 checkValidWorkerOptions(opts
)
708 this.opts
= { ...DEFAULT_WORKER_OPTIONS
, ...opts
}
711 private completeAsyncTask (
712 promise
: Promise
<Response
>,
714 taskPerformance
: TaskPerformance
716 const { taskId
} = task
719 taskPerformance
= this.endTaskPerformance(taskPerformance
)
720 this.sendToMainWorker({
727 .catch((error
: unknown
) => {
728 this.sendTaskError(task
, error
as Error)
731 this.finishTask(task
)
733 .catch((error
: unknown
) => {
734 queueMicrotask(() => {
740 private endTaskPerformance (
741 taskPerformance
: TaskPerformance
743 if (this.statistics
== null) {
744 throw new Error('Performance statistics computation requirements not set')
748 ...(this.statistics
.runTime
&& {
749 runTime
: performance
.now() - taskPerformance
.timestamp
,
751 ...(this.statistics
.elu
&& {
752 elu
: performance
.eventLoopUtilization(taskPerformance
.elu
),
757 private finishTask (task
: Task
<Data
>): void {
758 this.updateLastTaskTimestamp()
759 if (task
.abortable
=== true && task
.taskId
!= null) {
760 this.taskAbortFunctions
.delete(task
.taskId
)
765 * Gets abortable task function.
766 * An abortable promise is built to permit the task to be aborted.
767 * @param name - The name of the task.
768 * @param taskId - The task id.
769 * @returns The abortable task function.
771 private getAbortableTaskFunction (
774 ): TaskAsyncFunction
<Data
, Response
> {
775 return async (data
?: Data
): Promise
<Response
> => {
776 let result
: PromiseLike
<Response
> | Response
| undefined
777 // Keep cancellation active while awaiting the task. Resolve only this gate,
778 // not the task result, to avoid an extra promise assimilation.
779 await new Promise
<void>((resolve
, reject
: (reason
?: unknown
) => void) => {
780 this.taskAbortFunctions
.set(taskId
, () => {
781 reject(new AbortError(`Task '${name}' id '${taskId}' aborted`))
783 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
784 const taskFunction
= this.taskFunctions
.get(name
)!.taskFunction
785 const value
= taskFunction(data
)
786 const promise
= this.getResultPromise(value
)
787 if (promise
!= null) {
788 // eslint-disable-next-line promise/catch-or-return -- The second callback handles rejection.
789 promise
.then(value
=> {
799 return result
as Response
803 private getResultPromise
<Result
>(
804 result
: PromiseLike
<Result
> | Result
805 ): Promise
<Result
> | undefined {
808 (typeof result
!== 'object' && typeof result
!== 'function')
814 Object.getPrototypeOf(result
) === Promise
.prototype
&&
815 !Object.hasOwn(result
, 'then')
817 return result
as Promise
<Result
>
819 const then
: unknown
= Reflect
.get(result
, 'then')
820 if (typeof then
=== 'function') {
821 // The declared result type determines the callable's fulfillment type.
822 const promiseThen
= then
as PromiseLike
<Result
>['then']
823 return Promise
.resolve({
824 then (...args
: Parameters
<typeof promiseThen
>): void {
825 Reflect
.apply(promiseThen
, result
, args
)
832 private readonly runTaskFunctionOperation
= (
833 message
: MessageValue
<Data
>
835 this.handleTaskFunctionOperationMessage(message
)
838 private sendTaskError (task
: Task
<Data
>, error
: Error): void {
842 ...((task
.transferList
== null || task
.transferList
.length
=== 0) && {
846 ...this.handleError(error
),
848 } satisfies MessageValue
<Response
, Data
>
850 this.sendToMainWorker(message
)
851 } catch (sendError
) {
853 !(sendError
instanceof Error) ||
854 sendError
.name
!== 'DataCloneError' ||
855 !Object.hasOwn(message
.workerError
, 'data')
859 delete message
.workerError
.data
860 this.sendToMainWorker(message
)
865 * Starts the worker check active interval.
867 private startCheckActive (): void {
868 this.lastTaskTimestamp
= performance
.now()
869 this.activeInterval
= setInterval(
870 this.checkActive
.bind(this),
871 (this.opts
.maxInactiveTime
?? DEFAULT_MAX_INACTIVE_TIME
) / 2
873 this.activeInterval
.unref()
877 * Stops the worker check active interval.
879 private stopCheckActive (): void {
880 if (this.activeInterval
!= null) {
881 clearInterval(this.activeInterval
)
882 this.activeInterval
= undefined
886 private updateLastTaskTimestamp (): void {
887 if (this.activeInterval
!= null) {
888 this.lastTaskTimestamp
= performance
.now()