@xylabs/threads 4.7.7 → 4.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,151 @@
1
+ /* eslint-disable import-x/no-internal-modules */
2
+ /* eslint-disable @typescript-eslint/no-explicit-any */
3
+ /*
4
+ * This source file contains the code for proxying calls in the master thread to calls in the workers
5
+ * by `.postMessage()`-ing.
6
+ *
7
+ * Keep in mind that this code can make or break the program's performance! Need to optimize more…
8
+ */
9
+
10
+ import DebugLogger from 'debug'
11
+ import { multicast, Observable } from 'observable-fns'
12
+
13
+ import { deserialize, serialize } from '../common.ts'
14
+ import { ObservablePromise } from '../observable-promise.ts'
15
+ import { isTransferDescriptor } from '../transferable.ts'
16
+ import type {
17
+ ModuleMethods, ModuleProxy, ProxyableFunction, Worker as WorkerType,
18
+ } from '../types/master.ts'
19
+ import type {
20
+ MasterJobCancelMessage,
21
+ MasterJobRunMessage,
22
+ WorkerJobErrorMessage,
23
+ WorkerJobResultMessage,
24
+ WorkerJobStartMessage,
25
+ } from '../types/messages.ts'
26
+ import {
27
+ MasterMessageType,
28
+ WorkerMessageType,
29
+ } from '../types/messages.ts'
30
+
31
+ const debugMessages = DebugLogger('threads:master:messages')
32
+
33
+ let nextJobUID = 1
34
+
35
+ const dedupe = <T>(array: T[]): T[] => [...new Set(array)]
36
+
37
+ const isJobErrorMessage = (data: any): data is WorkerJobErrorMessage => data && data.type === WorkerMessageType.error
38
+ const isJobResultMessage = (data: any): data is WorkerJobResultMessage => data && data.type === WorkerMessageType.result
39
+ const isJobStartMessage = (data: any): data is WorkerJobStartMessage => data && data.type === WorkerMessageType.running
40
+
41
+ function createObservableForJob<ResultType>(worker: WorkerType, jobUID: number): Observable<ResultType> {
42
+ return new Observable((observer) => {
43
+ let asyncType: 'observable' | 'promise' | undefined
44
+
45
+ const messageHandler = ((event: MessageEvent) => {
46
+ debugMessages('Message from worker:', event.data)
47
+ if (!event.data || event.data.uid !== jobUID) return
48
+
49
+ if (isJobStartMessage(event.data)) {
50
+ asyncType = event.data.resultType
51
+ } else if (isJobResultMessage(event.data)) {
52
+ if (asyncType === 'promise') {
53
+ if (event.data.payload !== undefined) {
54
+ observer.next(deserialize(event.data.payload))
55
+ }
56
+ observer.complete()
57
+ worker.removeEventListener('message', messageHandler)
58
+ } else {
59
+ if (event.data.payload) {
60
+ observer.next(deserialize(event.data.payload))
61
+ }
62
+ if (event.data.complete) {
63
+ observer.complete()
64
+ worker.removeEventListener('message', messageHandler)
65
+ }
66
+ }
67
+ } else if (isJobErrorMessage(event.data)) {
68
+ const error = deserialize(event.data.error as any)
69
+ if (asyncType === 'promise' || !asyncType) {
70
+ observer.error(error)
71
+ } else {
72
+ observer.error(error)
73
+ }
74
+ worker.removeEventListener('message', messageHandler)
75
+ }
76
+ }) as EventListener
77
+
78
+ worker.addEventListener('message', messageHandler)
79
+
80
+ return () => {
81
+ if (asyncType === 'observable' || !asyncType) {
82
+ const cancelMessage: MasterJobCancelMessage = {
83
+ type: MasterMessageType.cancel,
84
+ uid: jobUID,
85
+ }
86
+ worker.postMessage(cancelMessage)
87
+ }
88
+ worker.removeEventListener('message', messageHandler)
89
+ }
90
+ })
91
+ }
92
+
93
+ function prepareArguments(rawArgs: any[]): { args: any[]; transferables: Transferable[] } {
94
+ if (rawArgs.length === 0) {
95
+ // Exit early if possible
96
+ return {
97
+ args: [],
98
+ transferables: [],
99
+ }
100
+ }
101
+
102
+ const args: any[] = []
103
+ const transferables: Transferable[] = []
104
+
105
+ for (const arg of rawArgs) {
106
+ if (isTransferDescriptor(arg)) {
107
+ args.push(serialize(arg.send))
108
+ transferables.push(...arg.transferables)
109
+ } else {
110
+ args.push(serialize(arg))
111
+ }
112
+ }
113
+
114
+ return {
115
+ args,
116
+ transferables: transferables.length === 0 ? transferables : dedupe(transferables),
117
+ }
118
+ }
119
+
120
+ export function createProxyFunction<Args extends any[], ReturnType>(worker: WorkerType, method?: string) {
121
+ return ((...rawArgs: Args) => {
122
+ const uid = nextJobUID++
123
+ const { args, transferables } = prepareArguments(rawArgs)
124
+ const runMessage: MasterJobRunMessage = {
125
+ args,
126
+ method,
127
+ type: MasterMessageType.run,
128
+ uid,
129
+ }
130
+
131
+ debugMessages('Sending command to run function to worker:', runMessage)
132
+
133
+ try {
134
+ worker.postMessage(runMessage, transferables)
135
+ } catch (error) {
136
+ return ObservablePromise.from(Promise.reject(error))
137
+ }
138
+
139
+ return ObservablePromise.from(multicast(createObservableForJob<ReturnType>(worker, uid)))
140
+ }) as any as ProxyableFunction<Args, ReturnType>
141
+ }
142
+
143
+ export function createProxyModule<Methods extends ModuleMethods>(worker: WorkerType, methodNames: string[]): ModuleProxy<Methods> {
144
+ const proxy: any = {}
145
+
146
+ for (const methodName of methodNames) {
147
+ proxy[methodName] = createProxyFunction(worker, methodName)
148
+ }
149
+
150
+ return proxy
151
+ }
@@ -0,0 +1,400 @@
1
+ /* eslint-disable import-x/export */
2
+ /* eslint-disable unicorn/no-thenable */
3
+
4
+ /* eslint-disable @typescript-eslint/member-ordering */
5
+ /* eslint-disable unicorn/no-array-reduce */
6
+ /* eslint-disable @typescript-eslint/no-explicit-any */
7
+ /* eslint-disable @typescript-eslint/no-namespace */
8
+
9
+ /// <reference lib="esnext" />
10
+
11
+ import DebugLogger from 'debug'
12
+ import {
13
+ multicast, Observable, Subject,
14
+ } from 'observable-fns'
15
+
16
+ import { allSettled } from '../ponyfills.ts'
17
+ import { defaultPoolSize } from './implementation.browser.ts'
18
+ import type {
19
+ PoolEvent, QueuedTask, TaskRunFunction, WorkerDescriptor,
20
+ } from './pool-types.ts'
21
+ import { PoolEventType } from './pool-types.ts'
22
+ import { Thread } from './thread.ts'
23
+
24
+ export declare namespace Pool {
25
+ type Event<ThreadType extends Thread = any> = PoolEvent<ThreadType>
26
+ type EventType = PoolEventType
27
+ }
28
+
29
+ let nextPoolID = 1
30
+
31
+ function createArray(size: number): number[] {
32
+ const array: number[] = []
33
+ for (let index = 0; index < size; index++) {
34
+ array.push(index)
35
+ }
36
+ return array
37
+ }
38
+
39
+ function delay(ms: number) {
40
+ return new Promise(resolve => setTimeout(resolve, ms))
41
+ }
42
+
43
+ function flatMap<In, Out>(array: In[], mapper: (element: In) => Out[]): Out[] {
44
+ return array.reduce<Out[]>((flattened, element) => [...flattened, ...mapper(element)], [])
45
+ }
46
+
47
+ function slugify(text: string) {
48
+ return text.replaceAll(/\W/g, ' ').trim().replaceAll(/\s+/g, '-')
49
+ }
50
+
51
+ function spawnWorkers<ThreadType extends Thread>(spawnWorker: () => Promise<ThreadType>, count: number): Array<WorkerDescriptor<ThreadType>> {
52
+ return createArray(count).map(
53
+ (): WorkerDescriptor<ThreadType> => ({
54
+ init: spawnWorker(),
55
+ runningTasks: [],
56
+ }),
57
+ )
58
+ }
59
+
60
+ /**
61
+ * Thread pool managing a set of worker threads.
62
+ * Use it to queue tasks that are run on those threads with limited
63
+ * concurrency.
64
+ */
65
+ export interface Pool<ThreadType extends Thread> {
66
+ /**
67
+ * Returns a promise that resolves once the task queue is emptied.
68
+ * Promise will be rejected if any task fails.
69
+ *
70
+ * @param allowResolvingImmediately Set to `true` to resolve immediately if task queue is currently empty.
71
+ */
72
+ completed(allowResolvingImmediately?: boolean): Promise<any>
73
+
74
+ /**
75
+ * Returns a promise that resolves once the task queue is emptied.
76
+ * Failing tasks will not cause the promise to be rejected.
77
+ *
78
+ * @param allowResolvingImmediately Set to `true` to resolve immediately if task queue is currently empty.
79
+ */
80
+ settled(allowResolvingImmediately?: boolean): Promise<Error[]>
81
+
82
+ /**
83
+ * Returns an observable that yields pool events.
84
+ */
85
+ events(): Observable<PoolEvent<ThreadType>>
86
+
87
+ /**
88
+ * Queue a task and return a promise that resolves once the task has been dequeued,
89
+ * started and finished.
90
+ *
91
+ * @param task An async function that takes a thread instance and invokes it.
92
+ */
93
+ queue<Return>(task: TaskRunFunction<ThreadType, Return>): QueuedTask<ThreadType, Return>
94
+
95
+ /**
96
+ * Terminate all pool threads.
97
+ *
98
+ * @param force Set to `true` to kill the thread even if it cannot be stopped gracefully.
99
+ */
100
+ terminate(force?: boolean): Promise<void>
101
+ }
102
+
103
+ interface PoolOptions {
104
+ /** Maximum no. of tasks to run on one worker thread at a time. Defaults to one. */
105
+ concurrency?: number
106
+
107
+ /** Maximum no. of jobs to be queued for execution before throwing an error. */
108
+ maxQueuedJobs?: number
109
+
110
+ /** Gives that pool a name to be used for debug logging, letting you distinguish between log output of different pools. */
111
+ name?: string
112
+
113
+ /** No. of worker threads to spawn and to be managed by the pool. */
114
+ size?: number
115
+ }
116
+
117
+ class WorkerPool<ThreadType extends Thread> implements Pool<ThreadType> {
118
+ static EventType = PoolEventType
119
+
120
+ private readonly debug: DebugLogger.Debugger
121
+ private readonly eventObservable: Observable<PoolEvent<ThreadType>>
122
+ private readonly options: PoolOptions
123
+ private readonly workers: Array<WorkerDescriptor<ThreadType>>
124
+
125
+ private readonly eventSubject = new Subject<PoolEvent<ThreadType>>()
126
+ private initErrors: Error[] = []
127
+ private isClosing = false
128
+ private nextTaskID = 1
129
+ private taskQueue: Array<QueuedTask<ThreadType, any>> = []
130
+
131
+ constructor(spawnWorker: () => Promise<ThreadType>, optionsOrSize?: number | PoolOptions) {
132
+ const options: PoolOptions = typeof optionsOrSize === 'number' ? { size: optionsOrSize } : optionsOrSize || {}
133
+
134
+ const { size = defaultPoolSize } = options
135
+
136
+ this.debug = DebugLogger(`threads:pool:${slugify(options.name || String(nextPoolID++))}`)
137
+ this.options = options
138
+ this.workers = spawnWorkers(spawnWorker, size)
139
+
140
+ this.eventObservable = multicast(Observable.from(this.eventSubject))
141
+
142
+ Promise.all(this.workers.map(worker => worker.init)).then(
143
+ () =>
144
+ this.eventSubject.next({
145
+ size: this.workers.length,
146
+ type: PoolEventType.initialized,
147
+ }),
148
+ (error) => {
149
+ this.debug('Error while initializing pool worker:', error)
150
+ this.eventSubject.error(error)
151
+ this.initErrors.push(error)
152
+ },
153
+ )
154
+ }
155
+
156
+ private findIdlingWorker(): WorkerDescriptor<ThreadType> | undefined {
157
+ const { concurrency = 1 } = this.options
158
+ return this.workers.find(worker => worker.runningTasks.length < concurrency)
159
+ }
160
+
161
+ private async runPoolTask(worker: WorkerDescriptor<ThreadType>, task: QueuedTask<ThreadType, any>) {
162
+ const workerID = this.workers.indexOf(worker) + 1
163
+
164
+ this.debug(`Running task #${task.id} on worker #${workerID}...`)
165
+ this.eventSubject.next({
166
+ taskID: task.id,
167
+ type: PoolEventType.taskStart,
168
+ workerID,
169
+ })
170
+
171
+ try {
172
+ const returnValue = await task.run(await worker.init)
173
+
174
+ this.debug(`Task #${task.id} completed successfully`)
175
+ this.eventSubject.next({
176
+ returnValue,
177
+ taskID: task.id,
178
+ type: PoolEventType.taskCompleted,
179
+ workerID,
180
+ })
181
+ } catch (ex) {
182
+ const error = ex as Error
183
+ this.debug(`Task #${task.id} failed`)
184
+ this.eventSubject.next({
185
+ error,
186
+ taskID: task.id,
187
+ type: PoolEventType.taskFailed,
188
+ workerID,
189
+ })
190
+ }
191
+ }
192
+
193
+ private run(worker: WorkerDescriptor<ThreadType>, task: QueuedTask<ThreadType, any>) {
194
+ const runPromise = (async () => {
195
+ const removeTaskFromWorkersRunningTasks = () => {
196
+ worker.runningTasks = worker.runningTasks.filter(someRunPromise => someRunPromise !== runPromise)
197
+ }
198
+
199
+ // Defer task execution by one tick to give handlers time to subscribe
200
+ await delay(0)
201
+
202
+ try {
203
+ await this.runPoolTask(worker, task)
204
+ } finally {
205
+ removeTaskFromWorkersRunningTasks()
206
+
207
+ if (!this.isClosing) {
208
+ this.scheduleWork()
209
+ }
210
+ }
211
+ })()
212
+
213
+ worker.runningTasks.push(runPromise)
214
+ }
215
+
216
+ private scheduleWork() {
217
+ this.debug('Attempt de-queueing a task in order to run it...')
218
+
219
+ const availableWorker = this.findIdlingWorker()
220
+ if (!availableWorker) return
221
+
222
+ const nextTask = this.taskQueue.shift()
223
+ if (!nextTask) {
224
+ this.debug('Task queue is empty')
225
+ this.eventSubject.next({ type: PoolEventType.taskQueueDrained })
226
+ return
227
+ }
228
+
229
+ this.run(availableWorker, nextTask)
230
+ }
231
+
232
+ private taskCompletion(taskID: number) {
233
+ return new Promise<any>((resolve, reject) => {
234
+ const eventSubscription = this.events().subscribe((event) => {
235
+ if (event.type === PoolEventType.taskCompleted && event.taskID === taskID) {
236
+ eventSubscription.unsubscribe()
237
+ resolve(event.returnValue)
238
+ } else if (event.type === PoolEventType.taskFailed && event.taskID === taskID) {
239
+ eventSubscription.unsubscribe()
240
+ reject(event.error)
241
+ } else if (event.type === PoolEventType.terminated) {
242
+ eventSubscription.unsubscribe()
243
+ reject(new Error('Pool has been terminated before task was run.'))
244
+ }
245
+ })
246
+ })
247
+ }
248
+
249
+ async settled(allowResolvingImmediately: boolean = false): Promise<Error[]> {
250
+ const getCurrentlyRunningTasks = () => flatMap(this.workers, worker => worker.runningTasks)
251
+
252
+ const taskFailures: Error[] = []
253
+
254
+ const failureSubscription = this.eventObservable.subscribe((event) => {
255
+ if (event.type === PoolEventType.taskFailed) {
256
+ taskFailures.push(event.error)
257
+ }
258
+ })
259
+
260
+ if (this.initErrors.length > 0) {
261
+ throw this.initErrors[0]
262
+ }
263
+ if (allowResolvingImmediately && this.taskQueue.length === 0) {
264
+ await allSettled(getCurrentlyRunningTasks())
265
+ return taskFailures
266
+ }
267
+
268
+ await new Promise<void>((resolve, reject) => {
269
+ const subscription = this.eventObservable.subscribe({
270
+ error: reject,
271
+ next(event) {
272
+ if (event.type === PoolEventType.taskQueueDrained) {
273
+ subscription.unsubscribe()
274
+ resolve(void 0)
275
+ }
276
+ }, // make a pool-wide error reject the completed() result promise
277
+ })
278
+ })
279
+
280
+ await allSettled(getCurrentlyRunningTasks())
281
+ failureSubscription.unsubscribe()
282
+
283
+ return taskFailures
284
+ }
285
+
286
+ async completed(allowResolvingImmediately: boolean = false) {
287
+ const settlementPromise = this.settled(allowResolvingImmediately)
288
+
289
+ const earlyExitPromise = new Promise<Error[]>((resolve, reject) => {
290
+ const subscription = this.eventObservable.subscribe({
291
+ error: reject,
292
+ next(event) {
293
+ if (event.type === PoolEventType.taskQueueDrained) {
294
+ subscription.unsubscribe()
295
+ resolve(settlementPromise)
296
+ } else if (event.type === PoolEventType.taskFailed) {
297
+ subscription.unsubscribe()
298
+ reject(event.error)
299
+ }
300
+ }, // make a pool-wide error reject the completed() result promise
301
+ })
302
+ })
303
+
304
+ const errors = await Promise.race([settlementPromise, earlyExitPromise])
305
+
306
+ if (errors.length > 0) {
307
+ throw errors[0]
308
+ }
309
+ }
310
+
311
+ events() {
312
+ return this.eventObservable
313
+ }
314
+
315
+ queue(taskFunction: TaskRunFunction<ThreadType, any>) {
316
+ const { maxQueuedJobs = Number.POSITIVE_INFINITY } = this.options
317
+
318
+ if (this.isClosing) {
319
+ throw new Error('Cannot schedule pool tasks after terminate() has been called.')
320
+ }
321
+ if (this.initErrors.length > 0) {
322
+ throw this.initErrors[0]
323
+ }
324
+
325
+ const taskID = this.nextTaskID++
326
+ const taskCompletion = this.taskCompletion(taskID)
327
+
328
+ taskCompletion.catch((error) => {
329
+ // Prevent unhandled rejections here as we assume the user will use
330
+ // `pool.completed()`, `pool.settled()` or `task.catch()` to handle errors
331
+ this.debug(`Task #${taskID} errored:`, error)
332
+ })
333
+
334
+ const task: QueuedTask<ThreadType, any> = {
335
+ cancel: () => {
336
+ if (!this.taskQueue.includes(task)) return
337
+ this.taskQueue = this.taskQueue.filter(someTask => someTask !== task)
338
+ this.eventSubject.next({
339
+ taskID: task.id,
340
+ type: PoolEventType.taskCanceled,
341
+ })
342
+ },
343
+ id: taskID,
344
+ run: taskFunction,
345
+ then: taskCompletion.then.bind(taskCompletion),
346
+ }
347
+
348
+ if (this.taskQueue.length >= maxQueuedJobs) {
349
+ throw new Error(
350
+ 'Maximum number of pool tasks queued. Refusing to queue another one.\n'
351
+ + 'This usually happens for one of two reasons: We are either at peak '
352
+ + "workload right now or some tasks just won't finish, thus blocking the pool.",
353
+ )
354
+ }
355
+
356
+ this.debug(`Queueing task #${task.id}...`)
357
+ this.taskQueue.push(task)
358
+
359
+ this.eventSubject.next({
360
+ taskID: task.id,
361
+ type: PoolEventType.taskQueued,
362
+ })
363
+
364
+ this.scheduleWork()
365
+ return task
366
+ }
367
+
368
+ async terminate(force?: boolean) {
369
+ this.isClosing = true
370
+ if (!force) {
371
+ await this.completed(true)
372
+ }
373
+ this.eventSubject.next({
374
+ remainingQueue: [...this.taskQueue],
375
+ type: PoolEventType.terminated,
376
+ })
377
+ this.eventSubject.complete()
378
+ await Promise.all(this.workers.map(async worker => Thread.terminate(await worker.init)))
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Thread pool constructor. Creates a new pool and spawns its worker threads.
384
+ */
385
+ function PoolConstructor<ThreadType extends Thread>(spawnWorker: () => Promise<ThreadType>, optionsOrSize?: number | PoolOptions) {
386
+ // The function exists only so we don't need to use `new` to create a pool (we still can, though).
387
+ // If the Pool is a class or not is an implementation detail that should not concern the user.
388
+ return new WorkerPool(spawnWorker, optionsOrSize)
389
+ }
390
+
391
+ ;(PoolConstructor as any).EventType = PoolEventType
392
+
393
+ /**
394
+ * Thread pool constructor. Creates a new pool and spawns its worker threads.
395
+ */
396
+ export const Pool = PoolConstructor as typeof PoolConstructor & { EventType: typeof PoolEventType }
397
+
398
+ export type { PoolEvent, QueuedTask } from './pool-types.ts'
399
+ export { PoolEventType } from './pool-types.ts'
400
+ export { Thread } from './thread.ts'