nestworker 2.1.0 → 2.1.3
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/README.md +80 -2
- package/dist/core/worker.interfaces.d.ts +30 -15
- package/dist/core/worker.pool.d.ts +34 -5
- package/dist/core/worker.pool.js +388 -196
- package/dist/core/worker.pool.js.map +1 -1
- package/dist/core/worker.service.d.ts +2 -0
- package/dist/core/worker.service.js +71 -31
- package/dist/core/worker.service.js.map +1 -1
- package/dist/example/bench.d.ts +13 -0
- package/dist/example/bench.js +86 -0
- package/dist/example/bench.js.map +1 -0
- package/dist/example/image.service.d.ts +2 -0
- package/dist/example/image.service.js +10 -0
- package/dist/example/image.service.js.map +1 -1
- package/dist/example/main.js +1 -1
- package/dist/example/main.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/worker/worker-runtime.js +151 -73
- package/dist/worker/worker-runtime.js.map +1 -1
- package/package.json +10 -1
package/README.md
CHANGED
|
@@ -23,6 +23,8 @@ Enterprise-grade worker thread module for NestJS. Offload CPU-bound work to a ma
|
|
|
23
23
|
|
|
24
24
|
- **Worker pool** — pre-spawned threads, warmed up before the first request
|
|
25
25
|
- **Zero cold start** — pool initialises on `onModuleInit`, not on the first call
|
|
26
|
+
- **Per-worker concurrency** — opt-in pipelining (`concurrency > 1`) keeps each worker busy across awaits and short tasks
|
|
27
|
+
- **Automatic message batching** — jobs and results are coalesced into a single `postMessage` per scheduling pass, amortising `structuredClone` overhead
|
|
26
28
|
- **Priority queue** — `HIGH / NORMAL / LOW`, binary-search sorted; no jobs are ever dropped
|
|
27
29
|
- **Decorator discovery** — `@WorkerClass` + `@WorkerTask` replace all manual registration
|
|
28
30
|
- **deps** — services serialised into the worker via `vm.runInContext()` + snapshot; use for plain config/data helpers
|
|
@@ -42,7 +44,7 @@ Enterprise-grade worker thread module for NestJS. Offload CPU-bound work to a ma
|
|
|
42
44
|
|
|
43
45
|
| Package | Version |
|
|
44
46
|
|---|---|
|
|
45
|
-
| Node.js | ≥
|
|
47
|
+
| Node.js | ≥ 18 (uses the global `structuredClone`, available since Node 17) |
|
|
46
48
|
| `@nestjs/common` | `^10` or `^11` |
|
|
47
49
|
| `@nestjs/core` | `^10` or `^11` |
|
|
48
50
|
| `reflect-metadata` | `^0.1` or `^0.2` |
|
|
@@ -151,6 +153,7 @@ export class ImageController {
|
|
|
151
153
|
| Option | Type | Default | Description |
|
|
152
154
|
|---|---|---|---|
|
|
153
155
|
| `poolSize` | `number` | `os.cpus().length` | Worker thread count |
|
|
156
|
+
| `concurrency` | `number` | `1` | Max in-flight jobs **per worker**. Set `> 1` to pipeline jobs so workers don't sit idle between results, or while a task is awaiting I/O (proxy IPC, `fetch`, `fs`, …). Keep at `1` for purely CPU-bound, fully blocking tasks. |
|
|
154
157
|
| `shutdownTimeout` | `number` | `30_000` | Ms to wait for in-flight jobs on shutdown |
|
|
155
158
|
| `asyncLocalStorages` | `AsyncLocalStorage[]` | `[]` | ALS instances to propagate into workers |
|
|
156
159
|
|
|
@@ -183,7 +186,9 @@ Marks a method to be offloaded to a worker thread.
|
|
|
183
186
|
| `priority` | `'HIGH' \| 'NORMAL' \| 'LOW'` | `'NORMAL'` | Queue priority |
|
|
184
187
|
| `timeout` | `number` | — | Reject after this many ms |
|
|
185
188
|
| `retry` | `number` | `0` | Extra attempts after first failure |
|
|
186
|
-
| `retryDelay` | `number` | `0` | Ms between retry attempts |
|
|
189
|
+
| `retryDelay` | `number \| (attempt: number) => number` | `0` | Ms between retry attempts. See note below. |
|
|
190
|
+
|
|
191
|
+
> **`retryDelay` as a function:** functions can't cross the worker boundary, so when a function is supplied it's evaluated on the main thread at discovery time as the average of `fn(1)`, `fn(2)`, `fn(3)` and a warning is logged. For precise control (including exponential backoff) pass a number and recreate the curve with the per-call `RunOptions.retryDelay` override.
|
|
187
192
|
|
|
188
193
|
---
|
|
189
194
|
|
|
@@ -219,6 +224,26 @@ workerService.onDead((event) => { ... }); // job exhausted all retries
|
|
|
219
224
|
|
|
220
225
|
---
|
|
221
226
|
|
|
227
|
+
### `WorkerService.stats()`
|
|
228
|
+
|
|
229
|
+
Returns a point-in-time snapshot of the pool — used by the health indicator and metrics service, but also useful on its own:
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
const { poolSize, idle, busy, queued, warmingUp } = workerService.stats();
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
interface PoolStats {
|
|
237
|
+
poolSize: number;
|
|
238
|
+
idle: number;
|
|
239
|
+
busy: number;
|
|
240
|
+
queued: number;
|
|
241
|
+
warmingUp: number;
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
222
247
|
## `deps` vs `proxy`
|
|
223
248
|
|
|
224
249
|
This is the most important decision when declaring a `@WorkerClass`.
|
|
@@ -405,6 +430,29 @@ process(): void {
|
|
|
405
430
|
|
|
406
431
|
---
|
|
407
432
|
|
|
433
|
+
## OpenTelemetry Trace Propagation
|
|
434
|
+
|
|
435
|
+
If `@opentelemetry/api` is installed in your app, nestworker captures the active span context on every `run()` and re-activates it inside the worker before the task runs — distributed traces stay continuous across the thread boundary. There is **no hard dependency**: the lookup is a one-shot cached `require()` and silently no-ops when the package isn't present.
|
|
436
|
+
|
|
437
|
+
```bash
|
|
438
|
+
npm install @opentelemetry/api
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
```ts
|
|
442
|
+
// Spans created inside @WorkerTask methods will be children of the
|
|
443
|
+
// active span on the main thread at the moment run() was called.
|
|
444
|
+
@WorkerTask()
|
|
445
|
+
async heavyWork(): Promise<void> {
|
|
446
|
+
const tracer = trace.getTracer('my-app');
|
|
447
|
+
await tracer.startActiveSpan('heavy-work', async (span) => {
|
|
448
|
+
// ...
|
|
449
|
+
span.end();
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
---
|
|
455
|
+
|
|
408
456
|
## Health Indicator
|
|
409
457
|
|
|
410
458
|
```ts
|
|
@@ -502,6 +550,36 @@ await Promise.all([
|
|
|
502
550
|
|
|
503
551
|
---
|
|
504
552
|
|
|
553
|
+
## Per-Worker Concurrency
|
|
554
|
+
|
|
555
|
+
By default each worker processes one job at a time. When tasks are short, or
|
|
556
|
+
they `await` I/O (proxy IPC round-trips, `fetch`, `fs`, queue calls), the worker
|
|
557
|
+
sits idle while the main thread processes the previous result. Set
|
|
558
|
+
`concurrency > 1` to pipeline jobs into each worker and keep them saturated:
|
|
559
|
+
|
|
560
|
+
```ts
|
|
561
|
+
WorkerModule.forRoot({
|
|
562
|
+
poolSize: 4, // 4 worker threads
|
|
563
|
+
concurrency: 8, // up to 8 in-flight jobs per worker → 32 concurrent jobs
|
|
564
|
+
})
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
Guidance:
|
|
568
|
+
|
|
569
|
+
- **CPU-bound, fully blocking tasks** → keep at `1`. Extra concurrency cannot
|
|
570
|
+
help when the JS thread never yields.
|
|
571
|
+
- **Short tasks (sub-millisecond)** → `2–4` is usually enough to hide the
|
|
572
|
+
per-job postMessage cost.
|
|
573
|
+
- **Tasks awaiting I/O or proxy calls** → match `concurrency` to your typical
|
|
574
|
+
in-flight wait depth (e.g. `8–32`).
|
|
575
|
+
|
|
576
|
+
Internally the pool also coalesces every job it dispatches in a single
|
|
577
|
+
scheduling pass into one `postMessage` envelope per worker, and the worker
|
|
578
|
+
flushes accumulated results once per microtask tick. Batching is automatic —
|
|
579
|
+
there is nothing to configure.
|
|
580
|
+
|
|
581
|
+
---
|
|
582
|
+
|
|
505
583
|
## Constraints
|
|
506
584
|
|
|
507
585
|
### Arguments and return values
|
|
@@ -1,27 +1,22 @@
|
|
|
1
1
|
import type { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
2
|
export type TaskPriority = 'HIGH' | 'NORMAL' | 'LOW';
|
|
3
3
|
export interface WorkerJob {
|
|
4
|
-
jobId:
|
|
4
|
+
jobId: number;
|
|
5
5
|
serviceName: string;
|
|
6
6
|
methodName: string;
|
|
7
7
|
args: unknown[];
|
|
8
|
-
priority: TaskPriority;
|
|
9
|
-
timeout?: number;
|
|
10
|
-
/** Retry policy — sourced from @WorkerTask or overridden per call */
|
|
11
|
-
retry?: number;
|
|
12
|
-
retryDelay?: number;
|
|
13
|
-
/** Current attempt index (0 = first attempt) */
|
|
14
|
-
attempt?: number;
|
|
15
8
|
proxyServices?: ProxyServiceDescriptor[];
|
|
16
9
|
/** ALS context snapshot — restored in worker before task runs */
|
|
17
10
|
alsContext?: Record<string, unknown>;
|
|
18
11
|
/** OTEL trace context — W3C traceparent/tracestate headers */
|
|
19
12
|
traceContext?: Record<string, string>;
|
|
20
13
|
/** AbortSignal is non-transferable; we send the signal ID instead */
|
|
21
|
-
abortSignalId?:
|
|
14
|
+
abortSignalId?: number;
|
|
22
15
|
}
|
|
23
16
|
export interface WorkerResult<T = unknown> {
|
|
24
17
|
type: 'result';
|
|
18
|
+
/** ID of the job this result is for (required when concurrency > 1) */
|
|
19
|
+
jobId?: number;
|
|
25
20
|
ok: boolean;
|
|
26
21
|
data?: T;
|
|
27
22
|
error?: SerializedError;
|
|
@@ -36,7 +31,7 @@ export interface SerializedError {
|
|
|
36
31
|
}
|
|
37
32
|
export interface WorkerAbortMessage {
|
|
38
33
|
type: 'abort';
|
|
39
|
-
abortSignalId:
|
|
34
|
+
abortSignalId: number;
|
|
40
35
|
}
|
|
41
36
|
export interface ProxyServiceDescriptor {
|
|
42
37
|
propertyKey: string;
|
|
@@ -44,14 +39,14 @@ export interface ProxyServiceDescriptor {
|
|
|
44
39
|
}
|
|
45
40
|
export interface IpcInvokeRequest {
|
|
46
41
|
type: 'ipc:invoke';
|
|
47
|
-
callId:
|
|
42
|
+
callId: number;
|
|
48
43
|
propertyKey: string;
|
|
49
44
|
methodName: string;
|
|
50
45
|
args: unknown[];
|
|
51
46
|
}
|
|
52
47
|
export interface IpcInvokeResponse {
|
|
53
48
|
type: 'ipc:result';
|
|
54
|
-
callId:
|
|
49
|
+
callId: number;
|
|
55
50
|
ok: boolean;
|
|
56
51
|
data?: unknown;
|
|
57
52
|
error?: string;
|
|
@@ -59,10 +54,18 @@ export interface IpcInvokeResponse {
|
|
|
59
54
|
export interface WorkerReadySignal {
|
|
60
55
|
type: 'worker:ready';
|
|
61
56
|
}
|
|
62
|
-
export
|
|
63
|
-
|
|
57
|
+
export interface WorkerJobBatch {
|
|
58
|
+
type: 'batch';
|
|
59
|
+
jobs: WorkerJob[];
|
|
60
|
+
}
|
|
61
|
+
export interface WorkerResultBatch {
|
|
62
|
+
type: 'results';
|
|
63
|
+
results: WorkerResult[];
|
|
64
|
+
}
|
|
65
|
+
export type WorkerInboundMessage = WorkerJob | WorkerJobBatch | IpcInvokeResponse | WorkerAbortMessage;
|
|
66
|
+
export type WorkerOutboundMessage = WorkerResult | WorkerResultBatch | IpcInvokeRequest | WorkerReadySignal;
|
|
64
67
|
export interface DeadLetterEvent {
|
|
65
|
-
jobId:
|
|
68
|
+
jobId: number;
|
|
66
69
|
serviceName: string;
|
|
67
70
|
methodName: string;
|
|
68
71
|
args: unknown[];
|
|
@@ -91,6 +94,18 @@ export interface ProxyInstance {
|
|
|
91
94
|
export interface WorkerModuleOptions {
|
|
92
95
|
/** Number of worker threads. Defaults to os.cpus().length */
|
|
93
96
|
poolSize?: number;
|
|
97
|
+
/**
|
|
98
|
+
* Maximum number of in-flight jobs per worker. Defaults to 1.
|
|
99
|
+
*
|
|
100
|
+
* Set > 1 to pipeline jobs into each worker: the main thread will keep
|
|
101
|
+
* dispatching to a worker as long as its in-flight count is below this
|
|
102
|
+
* limit, so the worker never sits idle between jobs while the main thread
|
|
103
|
+
* is processing a result. Significant throughput win for short tasks and
|
|
104
|
+
* for tasks that await I/O (proxy IPC, fetch, fs, ...).
|
|
105
|
+
*
|
|
106
|
+
* Safe to keep at 1 for purely CPU-bound, blocking tasks.
|
|
107
|
+
*/
|
|
108
|
+
concurrency?: number;
|
|
94
109
|
/**
|
|
95
110
|
* How long (ms) to wait for in-flight jobs to finish before force-killing
|
|
96
111
|
* workers on application shutdown. Defaults to 30_000.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
|
-
import type { WorkerJob, ProxyInstance, DeadLetterEvent, SerializedError, PoolStats } from './worker.interfaces';
|
|
2
|
+
import type { WorkerJob, TaskPriority, ProxyInstance, DeadLetterEvent, SerializedError, PoolStats } from './worker.interfaces';
|
|
3
3
|
import type { SerializedService } from '../di/worker-container';
|
|
4
4
|
export declare interface WorkerPool {
|
|
5
5
|
on(event: 'dead', listener: (event: DeadLetterEvent) => void): this;
|
|
@@ -18,21 +18,50 @@ export declare class WorkerPool extends EventEmitter {
|
|
|
18
18
|
private readonly size;
|
|
19
19
|
private readonly shutdownTimeout;
|
|
20
20
|
private readonly workers;
|
|
21
|
+
/**
|
|
22
|
+
* Available "slots" — each worker is pushed `concurrency` times when it
|
|
23
|
+
* becomes ready, then popped/pushed as jobs are dispatched/completed.
|
|
24
|
+
* This naturally supports per-worker pipelining without any per-job
|
|
25
|
+
* counter bookkeeping.
|
|
26
|
+
*/
|
|
21
27
|
private readonly idle;
|
|
22
28
|
private readonly warmingUp;
|
|
23
29
|
private readonly queue;
|
|
30
|
+
private queueHead;
|
|
24
31
|
private destroyed;
|
|
25
|
-
private
|
|
32
|
+
private activeCount;
|
|
33
|
+
/**
|
|
34
|
+
* `schedule()` is invoked many times in a single synchronous burst (e.g.
|
|
35
|
+
* `for (...) ws.run(...)` floods 20k enqueues). Running the dispatch loop
|
|
36
|
+
* after every enqueue limits us to batches of size 1 per worker — the
|
|
37
|
+
* whole point of batching is then defeated. Defer to the next microtask
|
|
38
|
+
* so all synchronously-enqueued jobs land in one schedule pass and get
|
|
39
|
+
* coalesced into a single postMessage per worker.
|
|
40
|
+
*/
|
|
41
|
+
private scheduleQueued;
|
|
26
42
|
/** Maps abortSignalId → worker currently running that job */
|
|
27
43
|
private readonly signalWorkerMap;
|
|
44
|
+
private readonly concurrency;
|
|
28
45
|
private readonly proxyMap;
|
|
29
|
-
constructor(services: SerializedService[], proxyInstances: ProxyInstance[], size?: number, shutdownTimeout?: number);
|
|
30
|
-
execute<T = unknown>(job: WorkerJob,
|
|
46
|
+
constructor(services: SerializedService[], proxyInstances: ProxyInstance[], size?: number, shutdownTimeout?: number, concurrency?: number);
|
|
47
|
+
execute<T = unknown>(job: WorkerJob, meta: {
|
|
48
|
+
priority: TaskPriority;
|
|
49
|
+
timeout?: number;
|
|
50
|
+
retry?: number;
|
|
51
|
+
retryDelay?: number;
|
|
52
|
+
}, signal?: AbortSignal): Promise<T>;
|
|
31
53
|
stats(): PoolStats;
|
|
32
54
|
private spawnWorker;
|
|
55
|
+
private handleIpcInvoke;
|
|
33
56
|
private enqueue;
|
|
34
57
|
private schedule;
|
|
35
|
-
|
|
58
|
+
/** Pre-bound for queueMicrotask — avoids closure allocation per schedule. */
|
|
59
|
+
private readonly drain;
|
|
60
|
+
private prepareDispatch;
|
|
61
|
+
/** Called from the persistent message listener when a job result arrives. */
|
|
62
|
+
private completeJob;
|
|
63
|
+
private handleFailure;
|
|
64
|
+
private handleTimeout;
|
|
36
65
|
private handleWorkerError;
|
|
37
66
|
private handleWorkerExit;
|
|
38
67
|
private replaceWorker;
|