nestworker 2.1.1 → 2.1.4

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
@@ -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
 
@@ -547,6 +550,36 @@ await Promise.all([
547
550
 
548
551
  ---
549
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
+
550
583
  ## Constraints
551
584
 
552
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,62 @@ 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
+ /**
59
+ * Synchronous drain used on the COMPLETION path — when a worker becomes
60
+ * idle as a result of a result message arriving, we want to hand it the
61
+ * next queued job in the SAME tick. The microtask-deferred `schedule()`
62
+ * adds a full microtask hop per round-trip, which dominates throughput
63
+ * for short tasks with concurrency=1.
64
+ *
65
+ * Initial-burst dispatch still goes through the deferred `schedule()` so
66
+ * synchronous floods of `execute()` calls get coalesced into per-worker
67
+ * batches.
68
+ */
69
+ private dispatchNow;
70
+ /** Pre-bound for queueMicrotask — avoids closure allocation per schedule. */
71
+ private readonly drain;
72
+ private prepareDispatch;
73
+ /** Called from the persistent message listener when a job result arrives. */
74
+ private completeJob;
75
+ private handleFailure;
76
+ private handleTimeout;
36
77
  private handleWorkerError;
37
78
  private handleWorkerExit;
38
79
  private replaceWorker;