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 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 | ≥ 16 |
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: string;
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?: string;
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: string;
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: string;
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: string;
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 type WorkerInboundMessage = WorkerJob | IpcInvokeResponse | WorkerAbortMessage;
63
- export type WorkerOutboundMessage = WorkerResult | IpcInvokeRequest | WorkerReadySignal;
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: string;
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 readonly active;
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, signal?: AbortSignal): Promise<T>;
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
- private dispatch;
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;