@reftrixmcp/ml 0.3.0 → 0.5.1

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.
Files changed (36) hide show
  1. package/dist/config/worker-resource-limits.d.ts +89 -0
  2. package/dist/config/worker-resource-limits.d.ts.map +1 -0
  3. package/dist/config/worker-resource-limits.js +156 -0
  4. package/dist/config/worker-resource-limits.js.map +1 -0
  5. package/dist/dinov2/service.d.ts.map +1 -1
  6. package/dist/dinov2/service.js +70 -10
  7. package/dist/dinov2/service.js.map +1 -1
  8. package/dist/dinov2/worker-thread.js +72 -9
  9. package/dist/dinov2/worker-thread.js.map +1 -1
  10. package/dist/embeddings/service.d.ts.map +1 -1
  11. package/dist/embeddings/service.js +13 -1
  12. package/dist/embeddings/service.js.map +1 -1
  13. package/dist/embeddings/worker-thread.d.ts.map +1 -1
  14. package/dist/embeddings/worker-thread.js +8 -83
  15. package/dist/embeddings/worker-thread.js.map +1 -1
  16. package/dist/index.d.ts +3 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +4 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/onnx-availability.d.ts +29 -0
  21. package/dist/onnx-availability.d.ts.map +1 -0
  22. package/dist/onnx-availability.js +69 -0
  23. package/dist/onnx-availability.js.map +1 -0
  24. package/dist/onnx-provider-detect.d.ts +47 -0
  25. package/dist/onnx-provider-detect.d.ts.map +1 -0
  26. package/dist/onnx-provider-detect.js +111 -0
  27. package/dist/onnx-provider-detect.js.map +1 -0
  28. package/package.json +5 -3
  29. package/src/config/worker-resource-limits.ts +176 -0
  30. package/src/dinov2/service.ts +88 -10
  31. package/src/dinov2/worker-thread.ts +85 -10
  32. package/src/embeddings/service.ts +13 -1
  33. package/src/embeddings/worker-thread.ts +13 -102
  34. package/src/index.ts +15 -0
  35. package/src/onnx-availability.ts +78 -0
  36. package/src/onnx-provider-detect.ts +134 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"onnx-provider-detect.js","sourceRoot":"","sources":["../src/onnx-provider-detect.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reftrixmcp/ml",
3
- "version": "0.3.0",
3
+ "version": "0.5.1",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "description": "ML/Embedding services for Reftrix",
@@ -30,16 +30,18 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@huggingface/transformers": "^3.8.1",
33
- "onnxruntime-node": "1.21.0",
34
33
  "p-limit": "^3.1.0",
35
34
  "zod": "^3.24.0"
36
35
  },
36
+ "optionalDependencies": {
37
+ "onnxruntime-node": "1.21.0"
38
+ },
37
39
  "devDependencies": {
38
40
  "@types/node": "^22.0.0",
39
41
  "@vitest/coverage-v8": "^4.0.14",
40
42
  "typescript": "^5.9.0",
41
43
  "vitest": "^4.0.14",
42
- "@reftrixmcp/config": "0.3.0"
44
+ "@reftrixmcp/config": "0.5.1"
43
45
  },
44
46
  "publishConfig": {
45
47
  "access": "public"
@@ -0,0 +1,176 @@
1
+ // SPDX-FileCopyrightText: 2025-2026 Reftrix Contributors
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+
4
+ /**
5
+ * ML Worker Thread Resource Limits (PR7e-β1)
6
+ *
7
+ * ONNX Runtime が CPU fallback (onnxruntime-node) に切り替わったとき、
8
+ * `execArgv: []` を指定した Worker Thread は親プロセスの
9
+ * `--max-old-space-size` を継承しないため、デフォルトでは V8 old space
10
+ * が 4GB 未満の既定値に制限されてしまう。その結果、e5-base / DINOv2 の
11
+ * CPU 推論で OOM クラッシュが発生し、Queue-based Backfill が永久に
12
+ * `in_progress` のまま滞留する。
13
+ *
14
+ * When ONNX Runtime falls back to CPU (`onnxruntime-node`), a Worker Thread
15
+ * spawned with `execArgv: []` does NOT inherit the parent's
16
+ * `--max-old-space-size` flag, so V8 old-space defaults to less than 4GB.
17
+ * This causes OOM crashes during e5-base / DINOv2 CPU inference and leaves
18
+ * Queue-based Backfill jobs stuck `in_progress` indefinitely.
19
+ *
20
+ * Fix: explicitly pass `resourceLimits` to `new Worker(...)` so that the
21
+ * worker gets an explicit old-space / young-space / code-range ceiling.
22
+ *
23
+ * 参照 / References:
24
+ * - ADR-0012 §3 BLOCKER 4
25
+ * - Node.js docs: https://nodejs.org/api/worker_threads.html#new-workerfilename-options
26
+ *
27
+ * @module config/worker-resource-limits
28
+ */
29
+
30
+ import { z } from "zod";
31
+
32
+ /**
33
+ * Default old-space ceiling (MB) for ML Worker Threads.
34
+ *
35
+ * 4096 MB は e5-base (~500MB arena) + DINOv2 (~800MB arena) + transformers.js
36
+ * pipeline overhead を同時に CPU でロードしても OOM にならない実測値。
37
+ * さらに β1 では Phase 5 親プロセス RSS 上限を 4096 MB に段階緩和するため、
38
+ * Worker old-space をそれと同水準に揃えることで、親プロセスと子 Worker の
39
+ * メモリ上限を対称化する。
40
+ *
41
+ * 4096 MB is empirically the minimum old-space ceiling that keeps e5-base
42
+ * (~500MB arena) + DINOv2 (~800MB arena) + transformers.js pipeline overhead
43
+ * from OOMing on CPU. β1 also raises the Phase 5 parent-process RSS ceiling
44
+ * to 4096 MB, so aligning the Worker old-space with the parent RSS keeps
45
+ * parent/child memory ceilings symmetric.
46
+ */
47
+ const DEFAULT_ML_WORKER_MAX_OLD_SPACE_MB = 4096;
48
+
49
+ /**
50
+ * Fixed secondary limits.
51
+ *
52
+ * young-space=512MB and code-range=256MB are Node.js defaults scaled for
53
+ * ONNX Runtime's large tensor allocations; exposing them as env vars would
54
+ * create too many configuration surfaces.
55
+ */
56
+ const ML_WORKER_MAX_YOUNG_GENERATION_MB = 512;
57
+ const ML_WORKER_CODE_RANGE_MB = 256;
58
+
59
+ /**
60
+ * 許容レンジ
61
+ * - 下限 512 MB: e5-base の初期化 (tokenizer + model metadata) だけでも
62
+ * 約 300MB 必要で、ヘッドルームを確保する最小値
63
+ * - 上限 8192 MB: 単一 Worker Thread で 8GB を超える old-space は現実的な
64
+ * ユースケースが存在せず、over-provisioning 防御
65
+ *
66
+ * Allowed range:
67
+ * - Lower bound 512 MB: e5-base init (tokenizer + model metadata) alone needs
68
+ * ~300MB, so this is the minimum that preserves any headroom.
69
+ * - Upper bound 8192 MB: no realistic use case for > 8GB old-space in a single
70
+ * Worker Thread; guards against over-provisioning.
71
+ */
72
+ const ML_WORKER_MAX_OLD_SPACE_MIN_MB = 512;
73
+ const ML_WORKER_MAX_OLD_SPACE_MAX_MB = 8192;
74
+
75
+ /**
76
+ * ML Worker resource-limits Zod schema
77
+ */
78
+ export const MLWorkerResourceLimitsSchema = z.object({
79
+ /**
80
+ * Worker Thread の V8 old-space 上限(MB)
81
+ * V8 old-space ceiling (MB) for the Worker Thread
82
+ */
83
+ maxOldGenerationSizeMb: z
84
+ .number()
85
+ .int()
86
+ .min(ML_WORKER_MAX_OLD_SPACE_MIN_MB)
87
+ .max(ML_WORKER_MAX_OLD_SPACE_MAX_MB)
88
+ .default(DEFAULT_ML_WORKER_MAX_OLD_SPACE_MB),
89
+ /**
90
+ * Worker Thread の V8 young-space 上限(MB)
91
+ * V8 young-space ceiling (MB) for the Worker Thread
92
+ */
93
+ maxYoungGenerationSizeMb: z.number().int().positive().default(ML_WORKER_MAX_YOUNG_GENERATION_MB),
94
+ /**
95
+ * Worker Thread の code-range 上限(MB)
96
+ * Code-range ceiling (MB) for the Worker Thread
97
+ */
98
+ codeRangeSizeMb: z.number().int().positive().default(ML_WORKER_CODE_RANGE_MB),
99
+ });
100
+
101
+ export type MLWorkerResourceLimits = z.infer<typeof MLWorkerResourceLimitsSchema>;
102
+
103
+ /**
104
+ * 環境変数から ML Worker Thread の resource-limits をロードする。
105
+ * Load ML Worker Thread resource-limits from environment variables.
106
+ *
107
+ * 環境変数 / Environment variables:
108
+ * - `ML_WORKER_MAX_OLD_SPACE_MB`: V8 old-space ceiling (MB, integer, 512..8192)
109
+ *
110
+ * 不正値(NaN / Infinity / 負値 / 範囲外)は warn してデフォルトにフォールバック。
111
+ * Invalid values (NaN / Infinity / negative / out of range) warn and fall back
112
+ * to defaults.
113
+ *
114
+ * 副作用 / Side effects:
115
+ * - なし (純粋関数) / None (pure function)
116
+ * - 呼び出し側でログ出力すること / Caller is responsible for logging
117
+ */
118
+ export function loadMLWorkerResourceLimits(): MLWorkerResourceLimits {
119
+ const raw = process.env["ML_WORKER_MAX_OLD_SPACE_MB"];
120
+
121
+ // 未設定 → Zod default
122
+ // Unset → Zod default
123
+ if (raw === undefined || raw === "") {
124
+ return MLWorkerResourceLimitsSchema.parse({});
125
+ }
126
+
127
+ const parsed = Number(raw);
128
+
129
+ // NaN / Infinity 早期拒絶 (Zod でも弾けるが明示的に)
130
+ // Reject NaN / Infinity explicitly
131
+ if (!Number.isFinite(parsed)) {
132
+ return {
133
+ maxOldGenerationSizeMb: DEFAULT_ML_WORKER_MAX_OLD_SPACE_MB,
134
+ maxYoungGenerationSizeMb: ML_WORKER_MAX_YOUNG_GENERATION_MB,
135
+ codeRangeSizeMb: ML_WORKER_CODE_RANGE_MB,
136
+ };
137
+ }
138
+
139
+ const result = MLWorkerResourceLimitsSchema.safeParse({ maxOldGenerationSizeMb: parsed });
140
+ if (!result.success) {
141
+ return {
142
+ maxOldGenerationSizeMb: DEFAULT_ML_WORKER_MAX_OLD_SPACE_MB,
143
+ maxYoungGenerationSizeMb: ML_WORKER_MAX_YOUNG_GENERATION_MB,
144
+ codeRangeSizeMb: ML_WORKER_CODE_RANGE_MB,
145
+ };
146
+ }
147
+
148
+ return result.data;
149
+ }
150
+
151
+ /**
152
+ * 新しい Worker Thread を作成する際に渡す resourceLimits オプションを返す。
153
+ * Returns the `resourceLimits` option object to pass to `new Worker(...)`.
154
+ *
155
+ * 使用例 / Usage:
156
+ * ```ts
157
+ * import { getMLWorkerThreadOptions } from "@reftrixmcp/ml/config/worker-resource-limits";
158
+ * new Worker(scriptPath, { execArgv: [], ...getMLWorkerThreadOptions() });
159
+ * ```
160
+ */
161
+ export function getMLWorkerThreadOptions(): {
162
+ resourceLimits: {
163
+ maxOldGenerationSizeMb: number;
164
+ maxYoungGenerationSizeMb: number;
165
+ codeRangeSizeMb: number;
166
+ };
167
+ } {
168
+ const limits = loadMLWorkerResourceLimits();
169
+ return {
170
+ resourceLimits: {
171
+ maxOldGenerationSizeMb: limits.maxOldGenerationSizeMb,
172
+ maxYoungGenerationSizeMb: limits.maxYoungGenerationSizeMb,
173
+ codeRangeSizeMb: limits.codeRangeSizeMb,
174
+ },
175
+ };
176
+ }
@@ -23,6 +23,9 @@ import { Worker } from "node:worker_threads";
23
23
  import { fileURLToPath } from "node:url";
24
24
  import path from "node:path";
25
25
  import type { DINOv2WorkerMessage, DINOv2WorkerResponse } from "./worker-thread-types.js";
26
+ import { safeImportOnnx } from "../onnx-availability.js";
27
+ import { detectExecutionProvider, isLdLibraryPathSetAtOsLevel } from "../onnx-provider-detect.js";
28
+ import { getMLWorkerThreadOptions } from "../config/worker-resource-limits.js";
26
29
 
27
30
  // =====================================================
28
31
  // Constants
@@ -174,7 +177,13 @@ export class DINOv2Service {
174
177
 
175
178
  // Worker threads inherit the parent's execArgv by default.
176
179
  // Pass empty array to prevent parent flags from propagating.
177
- this.worker = new Worker(scriptPath, { execArgv: [] });
180
+ //
181
+ // PR7e-β1: Add explicit resourceLimits to prevent OOM when ONNX
182
+ // falls back to CPU (onnxruntime-node). DINOv2 ViT-B/14 arena is
183
+ // ~800MB; without resourceLimits V8 old-space < 4GB triggers OOM
184
+ // during CPU inference. See ADR-0012 §3 BLOCKER 4.
185
+ const workerOptions = { execArgv: [], ...getMLWorkerThreadOptions() };
186
+ this.worker = new Worker(scriptPath, workerOptions);
178
187
 
179
188
  this.worker.on("message", (response: DINOv2WorkerResponse) => {
180
189
  this.handleWorkerResponse(response);
@@ -280,18 +289,67 @@ export class DINOv2Service {
280
289
  // =====================================================
281
290
 
282
291
  private async initializeInProcess(): Promise<void> {
283
- const ortModule = await import("onnxruntime-node");
284
- this.inProcessSession = await ortModule.InferenceSession.create(this.modelPath, {
285
- executionProviders: ["cpu"],
286
- enableCpuMemArena: false,
287
- enableMemPattern: false,
288
- });
292
+ const provider = detectExecutionProvider("DINOv2");
293
+
294
+ // Safety check: LD_LIBRARY_PATH must be set at OS level for CUDA
295
+ let effectiveProvider = provider;
296
+ if (effectiveProvider === "cuda" && !isLdLibraryPathSetAtOsLevel()) {
297
+ console.warn(
298
+ "[DINOv2] CUDA requested but LD_LIBRARY_PATH not set at OS level. " + "Falling back to CPU."
299
+ );
300
+ effectiveProvider = "cpu";
301
+ }
302
+
303
+ const sessionOptions =
304
+ effectiveProvider === "cuda"
305
+ ? {
306
+ executionProviders: ["CUDAExecutionProvider", "CPUExecutionProvider"],
307
+ enableMemPattern: false,
308
+ }
309
+ : {
310
+ executionProviders: ["cpu"],
311
+ enableCpuMemArena: false,
312
+ enableMemPattern: false,
313
+ };
314
+
315
+ const ortModule = await safeImportOnnx();
316
+
317
+ // Attempt session creation with resolved provider.
318
+ // If CUDA init fails, catch and retry with CPU.
319
+ try {
320
+ this.inProcessSession = await ortModule.InferenceSession.create(
321
+ this.modelPath,
322
+ sessionOptions
323
+ );
324
+ } catch (providerError) {
325
+ if (effectiveProvider !== "cpu") {
326
+ const errorMsg =
327
+ providerError instanceof Error ? providerError.message : String(providerError);
328
+ console.warn(
329
+ "[DINOv2] CUDA in-process session creation failed, falling back to CPU: %s",
330
+ errorMsg
331
+ );
332
+ this.inProcessSession = await ortModule.InferenceSession.create(this.modelPath, {
333
+ executionProviders: ["cpu"],
334
+ enableCpuMemArena: false,
335
+ enableMemPattern: false,
336
+ });
337
+ effectiveProvider = "cpu";
338
+ } else {
339
+ throw providerError;
340
+ }
341
+ }
342
+
289
343
  // eslint-disable-next-line no-console
290
- console.log("[DINOv2] Session initialized in-process:", this.modelPath);
344
+ console.log(
345
+ "[DINOv2] Session initialized in-process (provider: %s):",
346
+ effectiveProvider,
347
+ this.modelPath
348
+ );
291
349
  }
292
350
 
293
351
  private async generateInProcess(imageBuffer: Buffer): Promise<number[]> {
294
- const ortModule = await import("onnxruntime-node");
352
+ const ortModule = await safeImportOnnx();
295
353
 
296
354
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
297
355
  const sess = this.inProcessSession as any;
@@ -319,7 +377,18 @@ export class DINOv2Service {
319
377
  }
320
378
 
321
379
  const inputTensor = new ortModule.Tensor("float32", float32, [1, 3, height, width]);
322
- const output = await sess.run({ pixel_values: inputTensor });
380
+
381
+ // OOM-FIX-1: Use try/finally to ensure ONNX tensor disposal.
382
+ // ONNX Tensor holds native C++ memory that V8 GC cannot reclaim.
383
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
384
+ let output: any;
385
+ try {
386
+ output = await sess.run({ pixel_values: inputTensor });
387
+ } finally {
388
+ if (typeof inputTensor.dispose === "function") {
389
+ inputTensor.dispose();
390
+ }
391
+ }
323
392
 
324
393
  const lastHiddenState = output.last_hidden_state;
325
394
  if (!lastHiddenState) {
@@ -334,6 +403,15 @@ export class DINOv2Service {
334
403
  clsToken[i] = data[i]!;
335
404
  }
336
405
 
406
+ // OOM-FIX-1: Dispose output tensors after data extraction
407
+ for (const key of Object.keys(output)) {
408
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
409
+ const tensor = output[key] as any;
410
+ if (typeof tensor?.dispose === "function") {
411
+ tensor.dispose();
412
+ }
413
+ }
414
+
337
415
  // L2 normalize
338
416
  let norm = 0;
339
417
  for (let i = 0; i < clsToken.length; i++) {
@@ -28,16 +28,20 @@ import type {
28
28
  } from "./worker-thread-types.js";
29
29
 
30
30
  // =====================================================
31
- // Dynamic import of onnxruntime-node
31
+ // Dynamic import of onnxruntime-node (optional dependency)
32
32
  // =====================================================
33
33
 
34
+ import { safeImportOnnx } from "../onnx-availability.js";
35
+ import { detectExecutionProvider, isLdLibraryPathSetAtOsLevel } from "../onnx-provider-detect.js";
36
+ import type { ExecutionProvider } from "../onnx-provider-detect.js";
37
+
34
38
  // eslint-disable-next-line @typescript-eslint/consistent-type-imports
35
39
  type OrtModule = typeof import("onnxruntime-node");
36
40
  let ort: OrtModule | null = null;
37
41
 
38
42
  async function getOrt(): Promise<OrtModule> {
39
43
  if (!ort) {
40
- ort = await import("onnxruntime-node");
44
+ ort = await safeImportOnnx();
41
45
  }
42
46
  return ort;
43
47
  }
@@ -137,18 +141,71 @@ function l2Normalize(vec: Float32Array): Float32Array {
137
141
  // Session management
138
142
  // =====================================================
139
143
 
144
+ /** Resolved execution provider for the current session. */
145
+ let resolvedProvider: ExecutionProvider = "cpu";
146
+
140
147
  async function initializeSession(modelPath: string): Promise<void> {
141
148
  if (session) return;
142
149
 
150
+ const provider = detectExecutionProvider("DINOv2Worker");
151
+
152
+ // Safety check: if CUDA is requested but LD_LIBRARY_PATH was not set at
153
+ // the OS level (only set via loadEnvLocal at runtime), CUDA init will crash
154
+ // because dlopen() can't find CUDA shared libraries. Fall back to CPU.
155
+ let effectiveProvider = provider;
156
+ if (effectiveProvider === "cuda" && !isLdLibraryPathSetAtOsLevel()) {
157
+ console.warn(
158
+ "[DINOv2Worker] CUDA requested but LD_LIBRARY_PATH not set at OS level. " +
159
+ "dlopen() cannot find CUDA libraries. Falling back to CPU. " +
160
+ "To use CUDA, set LD_LIBRARY_PATH before starting the Node.js process."
161
+ );
162
+ effectiveProvider = "cpu";
163
+ }
164
+
165
+ const sessionOptions =
166
+ effectiveProvider === "cuda"
167
+ ? {
168
+ executionProviders: ["CUDAExecutionProvider", "CPUExecutionProvider"],
169
+ enableMemPattern: false,
170
+ }
171
+ : {
172
+ executionProviders: ["cpu"],
173
+ enableCpuMemArena: false,
174
+ enableMemPattern: false,
175
+ };
176
+
143
177
  const ortModule = await getOrt();
144
- session = (await ortModule.InferenceSession.create(modelPath, {
145
- executionProviders: ["cpu"],
146
- enableCpuMemArena: false,
147
- enableMemPattern: false,
148
- })) as unknown as OrtSession;
178
+
179
+ // Attempt to create session with the resolved provider.
180
+ // If CUDA init fails (e.g. library not found), catch and retry with CPU.
181
+ try {
182
+ session = (await ortModule.InferenceSession.create(
183
+ modelPath,
184
+ sessionOptions
185
+ )) as unknown as OrtSession;
186
+ resolvedProvider = effectiveProvider;
187
+ } catch (providerError) {
188
+ if (effectiveProvider !== "cpu") {
189
+ // CUDA init failed — fallback to CPU
190
+ const errorMsg =
191
+ providerError instanceof Error ? providerError.message : String(providerError);
192
+ console.warn(
193
+ "[DINOv2Worker] CUDA session creation failed, falling back to CPU: %s",
194
+ errorMsg
195
+ );
196
+ session = (await ortModule.InferenceSession.create(modelPath, {
197
+ executionProviders: ["cpu"],
198
+ enableCpuMemArena: false,
199
+ enableMemPattern: false,
200
+ })) as unknown as OrtSession;
201
+ resolvedProvider = "cpu";
202
+ } else {
203
+ throw providerError;
204
+ }
205
+ }
149
206
 
150
207
  // eslint-disable-next-line no-console
151
- console.log("[DINOv2Worker] Session initialized:", modelPath);
208
+ console.log("[DINOv2Worker] Session initialized (provider: %s):", resolvedProvider, modelPath);
152
209
  }
153
210
 
154
211
  async function disposeSession(): Promise<void> {
@@ -187,8 +244,18 @@ async function runInference(
187
244
  // Create input tensor [1, 3, height, width]
188
245
  const inputTensor = new ortModule.Tensor("float32", inputData, [1, 3, height, width]);
189
246
 
190
- // Run inference
191
- const output = await session.run({ pixel_values: inputTensor });
247
+ // Run inference — use try/finally to ensure tensor disposal (OOM-FIX-1)
248
+ // ONNX Tensor holds native C++ memory that V8 GC cannot reclaim.
249
+ // Without explicit dispose(), ~1.4MB/inference leaks as native memory.
250
+ let output: Record<string, { data: Float32Array; dims: readonly number[] }>;
251
+ try {
252
+ output = await session.run({ pixel_values: inputTensor });
253
+ } finally {
254
+ // Dispose input tensor immediately after inference completes
255
+ if (typeof (inputTensor as unknown as { dispose?: () => void }).dispose === "function") {
256
+ (inputTensor as unknown as { dispose: () => void }).dispose();
257
+ }
258
+ }
192
259
 
193
260
  // Extract CLS token from last_hidden_state [1, 257, 768]
194
261
  const lastHiddenState = output.last_hidden_state;
@@ -212,6 +279,14 @@ async function runInference(
212
279
  clsToken[i] = data[i]!;
213
280
  }
214
281
 
282
+ // Dispose output tensors after data extraction (OOM-FIX-1)
283
+ for (const key of Object.keys(output)) {
284
+ const tensor = output[key] as unknown as { dispose?: () => void };
285
+ if (typeof tensor?.dispose === "function") {
286
+ tensor.dispose();
287
+ }
288
+ }
289
+
215
290
  // L2 normalize with NaN/Infinity defense
216
291
  const normalized = l2Normalize(clsToken);
217
292
 
@@ -21,6 +21,8 @@ import { fileURLToPath } from "node:url";
21
21
  import path from "node:path";
22
22
  import type { EmbeddingTextType, EmbeddingServiceConfig, CacheStats } from "./types.js";
23
23
  import type { WorkerMessage, WorkerResponse } from "./worker-thread-types.js";
24
+ import { OnnxRuntimeUnavailableError } from "../onnx-availability.js";
25
+ import { getMLWorkerThreadOptions } from "../config/worker-resource-limits.js";
24
26
 
25
27
  /**
26
28
  * Disposable pipeline interface (used only in in-process fallback mode).
@@ -306,7 +308,13 @@ export class EmbeddingService {
306
308
  // worker_threads Workers, so we must explicitly pass an empty array
307
309
  // to prevent the parent's flags (set by WorkerSupervisor fork())
308
310
  // from propagating.
309
- this.worker = new Worker(scriptPath, { execArgv: [] });
311
+ //
312
+ // PR7e-β1: Add explicit resourceLimits to prevent OOM when ONNX
313
+ // falls back to CPU (onnxruntime-node). Without resourceLimits,
314
+ // V8 old-space defaults to < 4GB and e5-base CPU inference OOMs.
315
+ // See ADR-0012 §3 BLOCKER 4 and config/worker-resource-limits.ts.
316
+ const workerOptions = { execArgv: [], ...getMLWorkerThreadOptions() };
317
+ this.worker = new Worker(scriptPath, workerOptions);
310
318
 
311
319
  // Handle worker messages
312
320
  this.worker.on("message", (response: WorkerResponse) => {
@@ -502,6 +510,10 @@ export class EmbeddingService {
502
510
  }
503
511
  } catch (error) {
504
512
  this.initPromise = null;
513
+ const code = (error as { code?: string }).code;
514
+ if (code === "MODULE_NOT_FOUND" || code === "ERR_MODULE_NOT_FOUND") {
515
+ throw new OnnxRuntimeUnavailableError("text embedding");
516
+ }
505
517
  const message = error instanceof Error ? error.message : "Unknown error";
506
518
  throw new Error(`Failed to load embedding model: ${message}`);
507
519
  }
@@ -20,10 +20,14 @@
20
20
  */
21
21
 
22
22
  import { parentPort } from "node:worker_threads";
23
- import { createRequire } from "node:module";
24
- import fs from "node:fs";
25
- import nodePath from "node:path";
26
23
  import type { WorkerMessage, WorkerResponse, WorkerErrorResponse } from "./worker-thread-types.js";
24
+ import { OnnxRuntimeUnavailableError } from "../onnx-availability.js";
25
+ import {
26
+ detectExecutionProvider,
27
+ verifyCudaAvailability,
28
+ isLdLibraryPathSetAtOsLevel,
29
+ } from "../onnx-provider-detect.js";
30
+ import type { ExecutionProvider } from "../onnx-provider-detect.js";
27
31
 
28
32
  // =====================================================
29
33
  // Pipeline types (mirrors service.ts DisposablePipeline)
@@ -38,83 +42,6 @@ interface DisposablePipeline {
38
42
  dispose?: () => Promise<unknown[]>;
39
43
  }
40
44
 
41
- // =====================================================
42
- // GPU execution provider detection
43
- // =====================================================
44
-
45
- type ExecutionProvider = "cpu" | "cuda";
46
-
47
- /**
48
- * Detect the ONNX execution provider based on environment configuration.
49
- *
50
- * Checks ONNX_EXECUTION_PROVIDER env var and verifies that the CUDA
51
- * provider shared library is available on disk. Falls back to CPU
52
- * gracefully if the provider is not installed.
53
- */
54
- function detectExecutionProvider(): ExecutionProvider {
55
- const envProvider = process.env.ONNX_EXECUTION_PROVIDER;
56
-
57
- if (envProvider === "cuda" || envProvider === "rocm") {
58
- const cudaAvailable = verifyCudaAvailability();
59
- if (cudaAvailable) {
60
- return "cuda";
61
- }
62
- console.warn(
63
- "[EmbeddingWorker] ONNX_EXECUTION_PROVIDER=%s but CUDA provider not available, falling back to CPU",
64
- envProvider
65
- );
66
- return "cpu";
67
- }
68
-
69
- return "cpu";
70
- }
71
-
72
- /**
73
- * Verify that CUDA provider (libonnxruntime_providers_cuda.so) is available.
74
- *
75
- * onnxruntime-node can download the CUDA provider shared library via
76
- * `ONNXRUNTIME_NODE_INSTALL_CUDA=v12 node .../install.js`. This function
77
- * checks for its presence on disk rather than trying to resolve a
78
- * non-existent npm package (onnxruntime-gpu is Python-only).
79
- */
80
- function verifyCudaAvailability(): boolean {
81
- try {
82
- const esmRequire = createRequire(import.meta.url);
83
- const ortNodePath = esmRequire.resolve("onnxruntime-node");
84
- // require.resolve returns e.g. .../onnxruntime-node/dist/index.js
85
- // Walk up to package root by finding the directory containing package.json
86
- let packageDir = nodePath.dirname(ortNodePath);
87
- for (let i = 0; i < 5; i++) {
88
- if (fs.existsSync(nodePath.join(packageDir, "package.json"))) break;
89
- packageDir = nodePath.dirname(packageDir);
90
- }
91
-
92
- // Search across napi versions (v3, v6, etc.) for CUDA provider
93
- const binDir = nodePath.join(packageDir, "bin");
94
- if (fs.existsSync(binDir)) {
95
- const napiDirs = fs.readdirSync(binDir).filter((d) => d.startsWith("napi-v"));
96
- for (const napiDir of napiDirs) {
97
- const cudaProviderPath = nodePath.join(
98
- binDir,
99
- napiDir,
100
- "linux",
101
- "x64",
102
- "libonnxruntime_providers_cuda.so"
103
- );
104
- if (fs.existsSync(cudaProviderPath)) {
105
- return true;
106
- }
107
- }
108
- }
109
-
110
- console.warn("[EmbeddingWorker] CUDA provider not found in: %s", binDir);
111
- return false;
112
- } catch {
113
- console.warn("[EmbeddingWorker] Cannot verify CUDA provider: onnxruntime-node not found");
114
- return false;
115
- }
116
- }
117
-
118
45
  // =====================================================
119
46
  // Worker state
120
47
  // =====================================================
@@ -146,26 +73,6 @@ function normalizeVector(vector: number[]): number[] {
146
73
  return vector.map((val) => val / norm);
147
74
  }
148
75
 
149
- /**
150
- * Verify that LD_LIBRARY_PATH is set at the OS level (not just process.env).
151
- *
152
- * dlopen() reads LD_LIBRARY_PATH from the kernel environment (/proc/self/environ),
153
- * NOT from process.env modifications made at runtime. If LD_LIBRARY_PATH was set
154
- * after process startup (e.g. by loadEnvLocal()), dlopen() cannot find CUDA
155
- * libraries and ONNX Runtime CUDA provider initialization will segfault or throw.
156
- *
157
- * Returns true only if LD_LIBRARY_PATH was present in the original process environment.
158
- */
159
- function isLdLibraryPathSetAtOsLevel(): boolean {
160
- try {
161
- const procEnv = fs.readFileSync("/proc/self/environ", "utf-8");
162
- return procEnv.includes("LD_LIBRARY_PATH");
163
- } catch {
164
- // /proc/self/environ not available (non-Linux) — assume set
165
- return true;
166
- }
167
- }
168
-
169
76
  async function initializePipeline(): Promise<void> {
170
77
  if (pipeline) return;
171
78
  if (initPromise) return initPromise;
@@ -175,7 +82,7 @@ async function initializePipeline(): Promise<void> {
175
82
  // Only auto-detect from env on first init. If provider was explicitly
176
83
  // set by a switch-provider message, respect that setting.
177
84
  if (!providerExplicitlySet) {
178
- resolvedProvider = detectExecutionProvider();
85
+ resolvedProvider = detectExecutionProvider("EmbeddingWorker");
179
86
  }
180
87
 
181
88
  // Safety check: if CUDA is requested but LD_LIBRARY_PATH was not set at
@@ -236,6 +143,10 @@ async function initializePipeline(): Promise<void> {
236
143
  console.log("[EmbeddingWorker] ONNX pipeline ready (provider: %s)", resolvedProvider);
237
144
  } catch (error) {
238
145
  initPromise = null;
146
+ const code = (error as { code?: string }).code;
147
+ if (code === "MODULE_NOT_FOUND" || code === "ERR_MODULE_NOT_FOUND") {
148
+ throw new OnnxRuntimeUnavailableError("text embedding");
149
+ }
239
150
  const message = error instanceof Error ? error.message : "Unknown error";
240
151
  throw new Error(`Failed to load embedding model in worker thread: ${message}`);
241
152
  }
@@ -446,7 +357,7 @@ async function handleMessage(message: WorkerMessage): Promise<void> {
446
357
 
447
358
  // If switching to CUDA, verify availability first
448
359
  if (targetProvider === "cuda") {
449
- const canUseCuda = verifyCudaAvailability();
360
+ const canUseCuda = verifyCudaAvailability("EmbeddingWorker");
450
361
  if (!canUseCuda) {
451
362
  // Cannot switch to CUDA — respond with current (cpu) provider
452
363
  sendResponse({