@nadrif/thread 0.1.0

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.
package/src/pool.js ADDED
@@ -0,0 +1,740 @@
1
+ /**
2
+ * @file Managed pool of Web Worker threads.
3
+ *
4
+ * `ThreadPool` maintains a fixed (or dynamically resizable) set of
5
+ * workers and distributes incoming tasks among them. Key features:
6
+ *
7
+ * - **Priority queue** – tasks are dequeued in priority order.
8
+ * - **Dependency resolution** – declare that a task must wait for other
9
+ * tasks to finish before it starts.
10
+ * - **Work stealing** – idle threads can pick up work from busy threads'
11
+ * local queues.
12
+ * - **Affinity routing** – optional `keyHasher` routes tasks with the
13
+ * same key to the same thread (good for caches).
14
+ * - **Auto-restart** – crashed workers are replaced automatically and
15
+ * queued tasks are re-dispatched.
16
+ * - **Dynamic resizing** – call `scaleTo()` at runtime.
17
+ * - **Graceful shutdown** – `terminateGracefully()` waits for all tasks
18
+ * to finish before killing workers.
19
+ *
20
+ * **Quick start:**
21
+ *
22
+ * ```js
23
+ * import { ThreadPool } from './pool.js';
24
+ *
25
+ * const pool = new ThreadPool(4, (x) => x * 2);
26
+ *
27
+ * const { id, promise } = pool.run(21);
28
+ * console.log(await promise); // 42
29
+ *
30
+ * await pool.terminateGracefully();
31
+ * ```
32
+ *
33
+ * **With priorities and dependencies:**
34
+ *
35
+ * ```js
36
+ * const pool = new ThreadPool(2, (x) => x + 1);
37
+ *
38
+ * // High-priority task runs first
39
+ * pool.run(10, { priority: 0 });
40
+ *
41
+ * // Low-priority task
42
+ * pool.run(20, { priority: 10 });
43
+ *
44
+ * // Dependent task – runs after task 0 completes
45
+ * const a = pool.run(1);
46
+ * const b = pool.run(2, { dependsOn: [a.id] });
47
+ * console.log(await b.promise); // 3
48
+ * ```
49
+ *
50
+ * @module pool
51
+ */
52
+
53
+ import { Thread } from "./thread";
54
+ import {
55
+ ThreadAbortError,
56
+ ThreadDependencyError,
57
+ ThreadTerminatedError,
58
+ } from "./error";
59
+ import { Metrics } from "./metrix";
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // ThreadPool class
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * A thread pool with work-stealing, priorities, and dependency resolution.
67
+ */
68
+ export class ThreadPool {
69
+ // -----------------------------------------------------------------------
70
+ // Constructor
71
+ // -----------------------------------------------------------------------
72
+
73
+ /**
74
+ * Create a new thread pool.
75
+ *
76
+ * @param {number} initialSize
77
+ * Number of worker threads to spawn immediately. The pool can be
78
+ * resized later with {@link ThreadPool.scaleTo}.
79
+ * @param {import('./types.js').ThreadDefinition | Function} definition
80
+ * Worker definition forwarded to each {@link Thread} constructor.
81
+ * Can be a plain function or a `{ setup, exec, cleanup }` object.
82
+ * @param {import('./types.js').PoolOptions} [options={}]
83
+ * Pool and thread configuration.
84
+ *
85
+ * @example
86
+ * ```js
87
+ * // Simple pool with 4 workers
88
+ * const pool = new ThreadPool(4, (data) => processData(data));
89
+ * ```
90
+ *
91
+ * @example
92
+ * ```js
93
+ * // Stateful workers with health checks
94
+ * const pool = new ThreadPool(2, {
95
+ * setup() { return { cache: new Map() }; },
96
+ * exec(state, key, ctx) {
97
+ * if (state.cache.has(key)) return state.cache.get(key);
98
+ * const result = expensiveLookup(key);
99
+ * state.cache.set(key, result);
100
+ * ctx.log(`Cached ${key}`);
101
+ * return result;
102
+ * },
103
+ * }, {
104
+ * timeout: 10_000,
105
+ * healthCheckInterval: 5_000,
106
+ * autoRestart: true,
107
+ * });
108
+ * ```
109
+ */
110
+ constructor(initialSize, definition, options = {}) {
111
+ this._definition = definition;
112
+ this._baseOptions = { ...options };
113
+ this._autoRestart = options.autoRestart !== false;
114
+ this._maxSize = options.maxSize || Infinity;
115
+ this._keyHasher = options.keyHasher || ((args) => JSON.stringify(args));
116
+ this._enableStealing = options.enableStealing !== false;
117
+ this._threads = [];
118
+ this._globalQueue = [];
119
+ this._blockedTasks = [];
120
+ this._running = 0;
121
+ this._terminated = false;
122
+ this._idCounter = 0;
123
+ this._taskMap = new Map(); // taskId -> task
124
+ this._dependencyGraph = new Map(); // taskId -> Set of dependent taskIds
125
+ this._completed = new Set();
126
+ this._metrics = new Metrics();
127
+
128
+ for (let i = 0; i < initialSize; i++) {
129
+ this._addThread();
130
+ }
131
+ }
132
+
133
+ // -----------------------------------------------------------------------
134
+ // Thread management (internal)
135
+ // -----------------------------------------------------------------------
136
+
137
+ /**
138
+ * Add a new thread to the pool.
139
+ *
140
+ * Respects `maxSize` – returns `null` if the limit is reached.
141
+ * Registers a crash handler that moves local-queue tasks back to
142
+ * the global queue and spawns a replacement.
143
+ *
144
+ * @returns {Thread | null} The new thread, or `null` if at capacity.
145
+ * @private
146
+ */
147
+ _addThread() {
148
+ if (this._threads.length >= this._maxSize) return null;
149
+ const t = new Thread(this._definition, this._baseOptions);
150
+ t._poolTask = true;
151
+ t._busy = false;
152
+ t._localQueue = [];
153
+ t._taskId = null;
154
+
155
+ if (this._autoRestart) {
156
+ t._onCrash = () => {
157
+ const idx = this._threads.indexOf(t);
158
+ if (idx !== -1) {
159
+ while (t._localQueue.length) {
160
+ this._globalQueue.push(t._localQueue.shift());
161
+ }
162
+ this._threads.splice(idx, 1);
163
+ if (!this._terminated && this._threads.length < this._maxSize) {
164
+ this._addThread();
165
+ }
166
+ this._processQueue();
167
+ }
168
+ };
169
+ }
170
+
171
+ this._threads.push(t);
172
+ return t;
173
+ }
174
+
175
+ /**
176
+ * Remove a thread from the pool, moving its local tasks back to
177
+ * the global queue.
178
+ *
179
+ * @param {Thread} thread - Thread to remove.
180
+ * @private
181
+ */
182
+ _removeThread(thread) {
183
+ const idx = this._threads.indexOf(thread);
184
+ if (idx !== -1) {
185
+ while (thread._localQueue.length) {
186
+ this._globalQueue.push(thread._localQueue.shift());
187
+ }
188
+ thread.terminate();
189
+ this._threads.splice(idx, 1);
190
+ }
191
+ }
192
+
193
+ // -----------------------------------------------------------------------
194
+ // Work stealing (internal)
195
+ // -----------------------------------------------------------------------
196
+
197
+ /**
198
+ * Steal a task from the busiest thread's local queue.
199
+ *
200
+ * @param {Thread} thread - The idle thread that will receive the task.
201
+ * @returns {Object | null} A task object, or `null` if nothing to steal.
202
+ * @private
203
+ */
204
+ _stealWork(thread) {
205
+ if (!this._enableStealing) return null;
206
+ let best = null;
207
+ let maxLocal = 0;
208
+ for (const other of this._threads) {
209
+ if (other === thread || other.terminated) continue;
210
+ if (other._localQueue && other._localQueue.length > maxLocal) {
211
+ maxLocal = other._localQueue.length;
212
+ best = other;
213
+ }
214
+ }
215
+ if (best && best._localQueue.length > 0) {
216
+ return best._localQueue.pop();
217
+ }
218
+ return null;
219
+ }
220
+
221
+ // -----------------------------------------------------------------------
222
+ // Queue processing (internal)
223
+ // -----------------------------------------------------------------------
224
+
225
+ /**
226
+ * Process the global task queue.
227
+ *
228
+ * Called after every task submission, completion, or failure. Flow:
229
+ * 1. Resolve any blocked tasks whose dependencies are now met.
230
+ * 2. Dequeue the highest-priority task (or steal one).
231
+ * 3. Find an idle thread and dispatch the task.
232
+ * 4. If no idle thread is available, push the task back to the front.
233
+ *
234
+ * @private
235
+ */
236
+ _processQueue() {
237
+ if (this._terminated) return;
238
+
239
+ this._resolveBlockedTasks();
240
+
241
+ let task = null;
242
+ if (this._globalQueue.length > 0) {
243
+ task = this._globalQueue.shift();
244
+ } else {
245
+ const idle = this._threads.find((t) => !t._busy && !t.terminated);
246
+ if (idle) {
247
+ const stolen = this._stealWork(idle);
248
+ if (stolen) task = stolen;
249
+ }
250
+ }
251
+ if (!task) return;
252
+
253
+ let thread = this._threads.find((t) => !t._busy && !t.terminated);
254
+ if (!thread) {
255
+ this._globalQueue.unshift(task);
256
+ return;
257
+ }
258
+
259
+ thread._busy = true;
260
+ thread._taskId = task.taskId;
261
+ this._running++;
262
+
263
+ const runOptions = {};
264
+ if (task.timeout != null) runOptions.timeout = task.timeout;
265
+ if (task.transfer) runOptions.transfer = task.transfer;
266
+ if (task.signal) runOptions.signal = task.signal;
267
+ if (task.retries != null) runOptions.retries = task.retries;
268
+
269
+ thread
270
+ .run(...task.args, runOptions)
271
+ .then((result) => {
272
+ this._running--;
273
+ thread._busy = false;
274
+ thread._taskId = null;
275
+ this._taskMap.delete(task.taskId);
276
+ this._completed.add(task.taskId);
277
+ task.resolve(result);
278
+ this._processQueue();
279
+ })
280
+ .catch((err) => {
281
+ this._running--;
282
+ thread._busy = false;
283
+ thread._taskId = null;
284
+ this._taskMap.delete(task.taskId);
285
+ if (this._autoRestart && thread.terminated) {
286
+ const idx = this._threads.indexOf(thread);
287
+ if (idx !== -1) {
288
+ this._threads.splice(idx, 1);
289
+ if (!this._terminated && this._threads.length < this._maxSize) {
290
+ this._addThread();
291
+ }
292
+ }
293
+ }
294
+ task.reject(err);
295
+ this._rejectDependents(task.taskId, err);
296
+ this._processQueue();
297
+ });
298
+ }
299
+
300
+ /**
301
+ * Move blocked tasks whose dependencies are now satisfied into the
302
+ * global queue.
303
+ *
304
+ * @private
305
+ */
306
+ _resolveBlockedTasks() {
307
+ const ready = [];
308
+ const remaining = [];
309
+ for (const task of this._blockedTasks) {
310
+ const allCompleted = task.dependsOn.every((depId) => this._completed.has(depId));
311
+ if (allCompleted) {
312
+ ready.push(task);
313
+ } else {
314
+ remaining.push(task);
315
+ }
316
+ }
317
+ this._blockedTasks = remaining;
318
+ for (const task of ready) {
319
+ task._blocked = false;
320
+ this._enqueueTask(task);
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Reject all tasks that depend on a failed task.
326
+ *
327
+ * When a task fails, any task waiting on it via `dependsOn` is
328
+ * immediately rejected with a {@link ThreadDependencyError} instead of
329
+ * being left blocked forever.
330
+ *
331
+ * @param {number} failedTaskId - The ID of the task that failed.
332
+ * @param {Error} err - The original error.
333
+ * @private
334
+ */
335
+ _rejectDependents(failedTaskId, err) {
336
+ const dependentIds = this._dependencyGraph.get(failedTaskId);
337
+ if (!dependentIds) return;
338
+ const depError = new ThreadDependencyError(
339
+ `Dependency task ${failedTaskId} failed: ${err.message || err}`
340
+ );
341
+ for (const depId of dependentIds) {
342
+ const bIdx = this._blockedTasks.findIndex((t) => t.taskId === depId);
343
+ if (bIdx !== -1) {
344
+ const depTask = this._blockedTasks.splice(bIdx, 1)[0];
345
+ this._taskMap.delete(depId);
346
+ depTask.reject(depError);
347
+ }
348
+ const qIdx = this._globalQueue.findIndex((t) => t.taskId === depId);
349
+ if (qIdx !== -1) {
350
+ const depTask = this._globalQueue.splice(qIdx, 1)[0];
351
+ this._taskMap.delete(depId);
352
+ depTask.reject(depError);
353
+ }
354
+ }
355
+ this._dependencyGraph.delete(failedTaskId);
356
+ }
357
+
358
+ /**
359
+ * Insert a task into the global queue in priority order.
360
+ *
361
+ * Lower `priority` values are dequeued first.
362
+ *
363
+ * @param {Object} task - Task to enqueue.
364
+ * @private
365
+ */
366
+ _enqueueTask(task) {
367
+ let inserted = false;
368
+ for (let i = 0; i < this._globalQueue.length; i++) {
369
+ if (this._globalQueue[i].priority > task.priority) {
370
+ this._globalQueue.splice(i, 0, task);
371
+ inserted = true;
372
+ break;
373
+ }
374
+ }
375
+ if (!inserted) this._globalQueue.push(task);
376
+ this._processQueue();
377
+ }
378
+
379
+ // -----------------------------------------------------------------------
380
+ // Public API – run
381
+ // -----------------------------------------------------------------------
382
+
383
+ /**
384
+ * Submit a task to the pool.
385
+ *
386
+ * The task is placed into a priority queue and dispatched to the
387
+ * first available thread. Returns both a numeric `id` (for
388
+ * dependency tracking and cancellation) and a `promise` that
389
+ * resolves with the result.
390
+ *
391
+ * If the last argument is a {@link PoolRunOptions} object, its pool-
392
+ * specific keys (`priority`, `dependsOn`, `key`) are consumed and its
393
+ * thread-level keys (`timeout`, `transfer`, `signal`, `retries`) are
394
+ * forwarded to the worker.
395
+ *
396
+ * @param {...any} args
397
+ * Arguments forwarded to the worker's `exec` function. The last
398
+ * argument may be a {@link PoolRunOptions} object.
399
+ * @returns {import('./types.js').PoolTaskResult}
400
+ * `{ id: number, promise: Promise<any> }`.
401
+ *
402
+ * @example
403
+ * ```js
404
+ * const pool = new ThreadPool(2, (x) => x + 1);
405
+ *
406
+ * const { id, promise } = pool.run(10);
407
+ * console.log(await promise); // 11
408
+ * console.log(id); // 0
409
+ * ```
410
+ *
411
+ * @example
412
+ * ```js
413
+ * // With priority
414
+ * pool.run(urgent, { priority: 0 }); // runs first
415
+ * pool.run(batch, { priority: 10 }); // runs second
416
+ * ```
417
+ *
418
+ * @example
419
+ * ```js
420
+ * // With dependencies
421
+ * const a = pool.run(data);
422
+ * const b = pool.run(a.id, { dependsOn: [a.id] });
423
+ * await b.promise; // waits for 'a' to finish first
424
+ * ```
425
+ *
426
+ * @example
427
+ * ```js
428
+ * // With abort and timeout
429
+ * const controller = new AbortController();
430
+ * const { promise } = pool.run(bigData, {
431
+ * timeout: 30_000,
432
+ * signal: controller.signal,
433
+ * transfer: [bigData.buffer],
434
+ * retries: 2,
435
+ * });
436
+ * ```
437
+ */
438
+ run(...args) {
439
+ let options = {};
440
+ const last = args[args.length - 1];
441
+ if (
442
+ last &&
443
+ typeof last === 'object' &&
444
+ !Array.isArray(last) &&
445
+ ('priority' in last ||
446
+ 'dependsOn' in last ||
447
+ 'key' in last ||
448
+ 'timeout' in last ||
449
+ 'transfer' in last ||
450
+ 'signal' in last ||
451
+ 'retries' in last)
452
+ ) {
453
+ options = args.pop();
454
+ }
455
+
456
+ const priority = options.priority ?? 0;
457
+ const dependsOn = options.dependsOn || [];
458
+ const key = options.key || null;
459
+
460
+ if (this._terminated) {
461
+ return {
462
+ id: -1,
463
+ promise: Promise.reject(new ThreadTerminatedError('Pool terminated')),
464
+ };
465
+ }
466
+
467
+ const taskId = this._idCounter++;
468
+ let resolveFn, rejectFn;
469
+ const promise = new Promise((resolve, reject) => {
470
+ resolveFn = resolve;
471
+ rejectFn = reject;
472
+ });
473
+
474
+ const task = {
475
+ args,
476
+ resolve: resolveFn,
477
+ reject: rejectFn,
478
+ taskId,
479
+ priority,
480
+ dependsOn,
481
+ key,
482
+ retries: options.retries,
483
+ signal: options.signal,
484
+ timeout: options.timeout,
485
+ transfer: options.transfer,
486
+ _blocked: dependsOn.length > 0,
487
+ };
488
+
489
+ this._taskMap.set(taskId, task);
490
+
491
+ const allCompleted = dependsOn.every((depId) => this._completed.has(depId));
492
+ if (allCompleted) {
493
+ this._enqueueTask(task);
494
+ } else {
495
+ this._blockedTasks.push(task);
496
+ for (const depId of dependsOn) {
497
+ if (!this._completed.has(depId)) {
498
+ if (!this._dependencyGraph.has(depId)) {
499
+ this._dependencyGraph.set(depId, new Set());
500
+ }
501
+ this._dependencyGraph.get(depId).add(taskId);
502
+ }
503
+ }
504
+ }
505
+
506
+ return { id: taskId, promise };
507
+ }
508
+
509
+ /**
510
+ * Cancel a queued task by its ID.
511
+ *
512
+ * Only works for tasks that are **still queued** (either in the global
513
+ * queue or blocked waiting on dependencies). Tasks that are already
514
+ * running on a thread cannot be cancelled this way – use an
515
+ * `AbortSignal` instead.
516
+ *
517
+ * @param {number} taskId - The task ID returned by {@link ThreadPool.run}.
518
+ * @returns {boolean} `true` if the task was found and cancelled.
519
+ *
520
+ * @example
521
+ * ```js
522
+ * const { id } = pool.run(slowTask);
523
+ *
524
+ * // User changed their mind
525
+ * if (pool.cancel(id)) {
526
+ * console.log('Task cancelled before it started');
527
+ * }
528
+ * ```
529
+ */
530
+ cancel(taskId) {
531
+ const idx = this._globalQueue.findIndex((t) => t.taskId === taskId);
532
+ if (idx !== -1) {
533
+ const task = this._globalQueue.splice(idx, 1)[0];
534
+ this._taskMap.delete(taskId);
535
+ task.reject(new ThreadAbortError('Task cancelled'));
536
+ return true;
537
+ }
538
+ const bIdx = this._blockedTasks.findIndex((t) => t.taskId === taskId);
539
+ if (bIdx !== -1) {
540
+ const task = this._blockedTasks.splice(bIdx, 1)[0];
541
+ this._taskMap.delete(taskId);
542
+ task.reject(new ThreadAbortError('Task cancelled'));
543
+ return true;
544
+ }
545
+ return false;
546
+ }
547
+
548
+ // -----------------------------------------------------------------------
549
+ // Public API – pool management
550
+ // -----------------------------------------------------------------------
551
+
552
+ /**
553
+ * Dynamically resize the pool.
554
+ *
555
+ * If `newSize` is larger, new threads are spawned. If smaller, excess
556
+ * threads are removed (busy threads are marked for deferred removal).
557
+ *
558
+ * @param {number} newSize - Desired number of threads.
559
+ * @throws {Error} If `newSize < 0`.
560
+ * @throws {Error} If `newSize > maxSize`.
561
+ *
562
+ * @example
563
+ * ```js
564
+ * // Scale up for a burst of work
565
+ * pool.scaleTo(8);
566
+ * await Promise.all(bigBatch.map((item) => pool.run(item).promise));
567
+ *
568
+ * // Scale back down to conserve resources
569
+ * pool.scaleTo(2);
570
+ * ```
571
+ */
572
+ scaleTo(newSize) {
573
+ if (newSize < 0) throw new Error('Size must be non‑negative');
574
+ if (newSize > this._maxSize) throw new Error(`Exceeds maxSize ${this._maxSize}`);
575
+ const current = this._threads.length;
576
+ if (newSize > current) {
577
+ for (let i = 0; i < newSize - current; i++) this._addThread();
578
+ } else if (newSize < current) {
579
+ const toRemove = this._threads.slice(newSize);
580
+ for (const t of toRemove) {
581
+ if (!t._busy) this._removeThread(t);
582
+ else t._pendingRemoval = true;
583
+ }
584
+ }
585
+ }
586
+
587
+ /**
588
+ * Gracefully shut down after all tasks finish.
589
+ *
590
+ * Polls every 50ms until the global queue, blocked list, and all
591
+ * threads are idle, then calls {@link ThreadPool.terminateAll}.
592
+ *
593
+ * @returns {Promise<void>}
594
+ *
595
+ * @example
596
+ * ```js
597
+ * process.on('SIGTERM', async () => {
598
+ * console.log('Shutting down gracefully...');
599
+ * await pool.terminateGracefully();
600
+ * console.log('All tasks finished');
601
+ * });
602
+ * ```
603
+ */
604
+ async terminateGracefully() {
605
+ while (
606
+ this._globalQueue.length > 0 ||
607
+ this._blockedTasks.length > 0 ||
608
+ this._threads.some((t) => t._busy)
609
+ ) {
610
+ await new Promise((resolve) => setTimeout(resolve, 50));
611
+ }
612
+ this.terminateAll();
613
+ }
614
+
615
+ /**
616
+ * Immediately terminate all threads and clear all queues.
617
+ *
618
+ * - Every thread is killed via `thread.terminate()`.
619
+ * - All queued and blocked tasks are rejected with
620
+ * {@link ThreadTerminatedError}.
621
+ * - The dependency graph, completed set, and task map are cleared.
622
+ *
623
+ * @example
624
+ * ```js
625
+ * // Emergency shutdown
626
+ * pool.terminateAll();
627
+ * console.log(pool.status().total); // 0
628
+ * ```
629
+ */
630
+ terminateAll() {
631
+ this._terminated = true;
632
+ this._threads.forEach((t) => t.terminate());
633
+ this._threads = [];
634
+ this._globalQueue = [];
635
+ for (const task of this._blockedTasks) {
636
+ task.reject(new ThreadTerminatedError('Pool terminated'));
637
+ }
638
+ this._blockedTasks = [];
639
+ for (const [id, task] of this._taskMap) {
640
+ task.reject(new ThreadTerminatedError('Pool terminated'));
641
+ }
642
+ this._taskMap.clear();
643
+ this._dependencyGraph.clear();
644
+ this._completed.clear();
645
+ }
646
+
647
+ /**
648
+ * Return a snapshot of the pool's current state.
649
+ *
650
+ * @returns {import('./types.js').PoolStatus}
651
+ *
652
+ * @example
653
+ * ```js
654
+ * const s = pool.status();
655
+ * console.log(`${s.busy}/${s.total} threads busy, ${s.queued} tasks queued`);
656
+ * ```
657
+ */
658
+ status() {
659
+ const total = this._threads.length;
660
+ const busy = this._threads.filter((t) => t._busy).length;
661
+ const localQueues = this._threads.reduce(
662
+ (sum, t) => sum + (t._localQueue ? t._localQueue.length : 0),
663
+ 0,
664
+ );
665
+ return {
666
+ total,
667
+ busy,
668
+ idle: total - busy,
669
+ queued: this._globalQueue.length + this._blockedTasks.length,
670
+ localQueued: localQueues,
671
+ };
672
+ }
673
+
674
+ /**
675
+ * Wait for all tasks to finish **without** terminating the pool.
676
+ *
677
+ * Useful when you want to process all submitted work and then inspect
678
+ * results, but keep the pool alive for more tasks.
679
+ *
680
+ * @returns {Promise<void>}
681
+ *
682
+ * @example
683
+ * ```js
684
+ * for (const item of items) pool.run(item);
685
+ * await pool.drain();
686
+ * console.log('All items processed');
687
+ * // pool is still alive – submit more tasks if needed
688
+ * ```
689
+ */
690
+ async drain() {
691
+ while (
692
+ this._globalQueue.length > 0 ||
693
+ this._blockedTasks.length > 0 ||
694
+ this._threads.some((t) => t._busy)
695
+ ) {
696
+ await new Promise((resolve) => setTimeout(resolve, 50));
697
+ }
698
+ }
699
+
700
+ /**
701
+ * Warm up all threads by running a no-op task on each.
702
+ *
703
+ * Forces every worker to compile the exec function so the first real
704
+ * task is fast.
705
+ *
706
+ * @param {number} [timeout=5000] - Max ms per warmup task.
707
+ * @returns {Promise<void>}
708
+ *
709
+ * @example
710
+ * ```js
711
+ * const pool = new ThreadPool(4, heavyComputation);
712
+ * await pool.warmup(); // all 4 workers are now compiled
713
+ * ```
714
+ */
715
+ async warmup(timeout = 5000) {
716
+ await Promise.all(this._threads.map((t) => t.warmup(timeout)));
717
+ }
718
+
719
+ // -----------------------------------------------------------------------
720
+ // Getters
721
+ // -----------------------------------------------------------------------
722
+
723
+ /**
724
+ * Cumulative pool-wide metrics snapshot.
725
+ *
726
+ * Only tracks tasks that completed through the pool's own `.run()`
727
+ * method. Individual thread metrics are not aggregated here.
728
+ *
729
+ * @type {import('./types.js').MetricsSnapshot}
730
+ *
731
+ * @example
732
+ * ```js
733
+ * const m = pool.metrics;
734
+ * console.log(`${m.count} tasks, ${(m.errorRate * 100).toFixed(1)}% errors`);
735
+ * ```
736
+ */
737
+ get metrics() {
738
+ return this._metrics.snapshot();
739
+ }
740
+ }