@adhd/sox-embedding-provider 0.3.0 → 0.4.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.
@@ -15,10 +15,11 @@
15
15
  * process alive) but forks a child **process** instead of constructing a
16
16
  * `worker_threads.Worker`.
17
17
  */
18
- import { fork } from 'node:child_process';
18
+ import { fork, execSync } from 'node:child_process';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { dirname, join } from 'node:path';
21
21
  import { existsSync, readFileSync } from 'node:fs';
22
+ import * as os from 'node:os';
22
23
  import { performance } from 'node:perf_hooks';
23
24
  import { log } from '@adhd/sox-telemetry';
24
25
  import { resolveFastembedLockPath } from './fastembedLock.js';
@@ -76,28 +77,169 @@ function isPidAlive(pid) {
76
77
  * missing/unreadable/stale lock file is silently treated as "no competing
77
78
  * host", never thrown — this must never be able to break or slow a real
78
79
  * embed call.
80
+ *
81
+ * (DEBT-EPIC-HOTPATH-REDUNDANT-IO-001) This used to be an UNCONDITIONAL
82
+ * `existsSync` + `readFileSync` pair on EVERY call to `request()` below — i.e.
83
+ * on every single embed, since `embedSingle`/`embedBatch` route through it.
84
+ * Measured (Node 20, warm fs cache, 5000 iterations against a real lock
85
+ * file): ~9.6us/call synchronously blocking the event loop each time — small
86
+ * relative to a ~619ms warm bge-base embed, but it is a real, avoidable
87
+ * per-call syscall pair on the single hottest path in the write pipeline, and
88
+ * a sync fs call blocks OTHER concurrent work in the same process regardless
89
+ * of its own cost (the mechanism argument the epic calls out). The value this
90
+ * reads — whether a second fastembed host process is alive — cannot change on
91
+ * a sub-second cadence (it only flips at process start/exit), so a short TTL
92
+ * cache removes the per-call I/O without weakening the signal: a genuinely
93
+ * competing host is still detected and logged, just at worst
94
+ * `COMPETING_HOST_CACHE_TTL_MS` later than before.
95
+ */
96
+ const COMPETING_HOST_CACHE_TTL_MS = 3000;
97
+ let _competingHostCache = null;
98
+ let _competingHostCacheOwnPid;
99
+ let _competingHostCacheOwnPoolGroup;
100
+ let _competingHostCacheAt = -Infinity;
101
+ /** TEST-ONLY: clear the TTL cache so a test can force a fresh fs read. */
102
+ export function __resetCompetingHostCacheForTests() {
103
+ _competingHostCache = null;
104
+ _competingHostCacheOwnPid = undefined;
105
+ _competingHostCacheOwnPoolGroup = undefined;
106
+ _competingHostCacheAt = -Infinity;
107
+ }
108
+ /**
109
+ * Exported for direct unit testing (DEBT-EPIC-HOTPATH-REDUNDANT-IO-001) — see the
110
+ * TTL-cache doc comment above for why this used to be a per-call sync fs read.
111
+ *
112
+ * (BUG-MEMORY-EMBED-HEAD-OF-LINE-BLOCKING-001) `ownPoolGroup`, when supplied,
113
+ * suppresses a "competing host" result whose lock entry carries the SAME
114
+ * `poolGroup` — i.e. another member of this client's own `FastembedProcessPool`,
115
+ * not a genuinely unrelated fastembed host. Without this, the BL-432
116
+ * `competing_host_pid` telemetry field would read as permanently "contended"
117
+ * for every pooled request, which is exactly the false-positive noise BL-331's
118
+ * own postmortem warns against trusting.
79
119
  */
80
- function detectCompetingFastembedHost(ownPid) {
120
+ export function detectCompetingFastembedHost(ownPid, ownPoolGroup) {
121
+ const now = performance.now();
122
+ if (now - _competingHostCacheAt < COMPETING_HOST_CACHE_TTL_MS &&
123
+ _competingHostCacheOwnPid === ownPid &&
124
+ _competingHostCacheOwnPoolGroup === ownPoolGroup) {
125
+ return _competingHostCache;
126
+ }
127
+ _competingHostCacheAt = now;
128
+ _competingHostCacheOwnPid = ownPid;
129
+ _competingHostCacheOwnPoolGroup = ownPoolGroup;
81
130
  try {
82
131
  const lockPath = resolveFastembedLockPath();
83
- if (!existsSync(lockPath))
132
+ if (!existsSync(lockPath)) {
133
+ _competingHostCache = null;
84
134
  return null;
135
+ }
85
136
  const raw = JSON.parse(readFileSync(lockPath, 'utf8'));
86
137
  const pid = typeof raw.pid === 'number' ? raw.pid : null;
87
- if (pid === null || pid === ownPid || !isPidAlive(pid))
138
+ const isKnownPoolSibling = typeof raw.poolGroup === 'string' && ownPoolGroup !== undefined && raw.poolGroup === ownPoolGroup;
139
+ if (pid === null || pid === ownPid || !isPidAlive(pid) || isKnownPoolSibling) {
140
+ _competingHostCache = null;
88
141
  return null;
89
- return { pid, startedAt: typeof raw.startedAt === 'string' ? raw.startedAt : 'unknown' };
142
+ }
143
+ _competingHostCache = { pid, startedAt: typeof raw.startedAt === 'string' ? raw.startedAt : 'unknown' };
144
+ return _competingHostCache;
90
145
  }
91
146
  catch {
147
+ _competingHostCache = null;
92
148
  return null;
93
149
  }
94
150
  }
151
+ /**
152
+ * (BUG-MEMORY-EMBED-HEAD-OF-LINE-BLOCKING-001) Thrown by `FastembedProcessPool
153
+ * .request()` when every pool member already has `admissionLimit` or more
154
+ * requests in flight — the "a queue that never refuses hides its own
155
+ * failure" backstop the bug item asked for. This is NOT the primary fix (the
156
+ * pool itself is — see the class below); it exists purely so a burst that
157
+ * genuinely exceeds total pool capacity gets a fast, typed, retryable
158
+ * rejection instead of silently joining a multi-minute queue. Named
159
+ * `TransientEmbeddingError`-shaped (has `retryAfterMs`) but defined locally
160
+ * rather than importing from `./index.js`, to keep this module free of a
161
+ * dependency on the public interface skeleton — callers can duck-type on
162
+ * `.name === 'FastembedBusyError'` or `instanceof FastembedBusyError`.
163
+ */
164
+ export class FastembedBusyError extends Error {
165
+ retryAfterMs;
166
+ constructor(message, retryAfterMs) {
167
+ super(message);
168
+ this.name = 'FastembedBusyError';
169
+ this.retryAfterMs = retryAfterMs;
170
+ }
171
+ }
95
172
  export class SharedFastembedProcessClient {
96
173
  child = null;
97
174
  startingPromise = null;
175
+ /**
176
+ * (BUG-MEMORYSERVER-SHUTDOWN-LEAKS-FASTEMBED-CHILD-001) True once
177
+ * `terminate()` has been called on THIS client — permanent, never reset.
178
+ * Distinct from an unexpected crash (the `c.on('exit')`/`c.on('error')`
179
+ * handlers below null `this.child` WITHOUT setting this flag, so BUG-021's
180
+ * transparent-respawn-and-reinit behaviour for a genuinely crashed child is
181
+ * completely unaffected).
182
+ *
183
+ * ROOT CAUSE this closes: `fastembed.ts`'s `initModel()` retries a
184
+ * cache-hit warmup up to `WARMUP_CACHE_HIT_ATTEMPTS` times, each attempt
185
+ * calling `this.shared.request({ type: 'init', ... })`. `coordinatedShutdown`
186
+ * (`memory-server/src/backend.ts`) races step 0 (`flushPendingEmbeds`)
187
+ * against a short bound and, on timeout, ABANDONS it as a background
188
+ * "loser" promise that keeps running concurrently with step 1
189
+ * (`terminateEmbedWorkers`). If that abandoned promise's in-flight
190
+ * `initModel()` attempt is still on its first attempt when step 1's
191
+ * `terminate()` below nulls `this.child`/`this.startingPromise` and rejects
192
+ * every pending request (including that attempt), `initModel()`'s catch
193
+ * block loops and fires attempt 2 — which, before this fix, called
194
+ * `ensureProcess()` and found `this.child === null`, so it happily forked a
195
+ * BRAND NEW, completely untracked child process. That child was never
196
+ * subject to any further termination step (`coordinatedShutdown` had
197
+ * already moved past step 1) and was abandoned as a live orphan the instant
198
+ * `exit(0)` fired — reproduced directly: a real backend forked under
199
+ * `SOX_PROXY_BACKEND=1`, given one `memory_write` (to start Phase-B's async
200
+ * embed), then SIGTERM'd immediately — server pid exited cleanly at
201
+ * t+694ms, but a SECOND `fastembedProcessHost.js` child (forked ~150ms
202
+ * AFTER the coordinated shutdown's own step 4 already finished) was still
203
+ * alive seconds later, orphaned under the process's original parent.
204
+ *
205
+ * `ensureProcess()` now refuses to fork once this flag is set — any request
206
+ * racing a real `terminate()` fails fast with a clear error instead of
207
+ * spawning a child nothing will ever reap.
208
+ */
209
+ terminated = false;
98
210
  nextId = 1;
99
211
  pending = new Map();
100
212
  hostPath;
213
+ poolGroup;
214
+ poolSize;
215
+ memberIndex;
216
+ /**
217
+ * (BUG-021) The last `{ type: 'init', model, cacheDir }` payload this
218
+ * member has ever been asked to load — kept independently of any pool
219
+ * wrapper above it so a LONE (non-pooled) client is self-healing too.
220
+ * `null` until the first successful `init`.
221
+ */
222
+ lastInitPayload = null;
223
+ /**
224
+ * (BUG-021 root cause) True once the CURRENT `this.child` has actually
225
+ * loaded the model named by `lastInitPayload`. Reset to `false` every time
226
+ * `ensureProcess()` forks a NEW child — including a transparent respawn
227
+ * after the previous child died (crash, OOM-kill, the BL-426/std::bad_alloc
228
+ * native-teardown hazards documented on `fastembedProcessHost.ts`, etc).
229
+ *
230
+ * Before this fix, `ensureProcess()`'s `c.on('exit')`/`c.on('error')`
231
+ * handlers nulled out `this.child`/`this.startingPromise` on an unexpected
232
+ * death, but nothing told the NEXT `request()` call that the freshly
233
+ * (re)forked replacement child has an empty `_embedder` — every real
234
+ * `embed`/`embedBatch` request sent to it failed with "Model not
235
+ * initialized" forever, because the higher-level `FastembedProvider.ready`
236
+ * flag was already latched `true` from the original (pre-crash) init and
237
+ * `ensureReady()` never re-sends `init` once `ready` is true. Live incident
238
+ * BUG-021: the lock file showed a child forked mere SECONDS before every
239
+ * request against it failed — exactly this respawn-without-reinit gap, not
240
+ * a stale/orphaned lock as first suspected.
241
+ */
242
+ childInitialized = false;
101
243
  /**
102
244
  * @param hostPathOverride Test-only injection point (BL-410): points the
103
245
  * fork target at a lightweight fixture host instead of the real
@@ -105,16 +247,70 @@ export class SharedFastembedProcessClient {
105
247
  * contract without loading fastembed or downloading a model. Production
106
248
  * code (`getSharedFastembedProcess()`) never passes this — it always
107
249
  * resolves the real host path.
250
+ * @param poolGroup (BUG-MEMORY-EMBED-HEAD-OF-LINE-BLOCKING-001) Set by
251
+ * `FastembedProcessPool` to a group id shared by every member of that pool.
252
+ * Forwarded to the forked child via `SOX_FASTEMBED_POOL_GROUP` so the
253
+ * BL-331 advisory lock can tell "another member of my own pool" apart from
254
+ * "a genuinely unrelated fastembed host" — see `fastembedLock.ts`'s
255
+ * `poolGroup` doc comment. `undefined` for a lone (non-pooled) client,
256
+ * which preserves today's exact single-host lock behaviour there.
257
+ * @param poolSize / @param memberIndex (BUG-EMBED-POOL-SIZE-DARWIN-FREEMEM-001,
258
+ * observability addendum) Set by `FastembedProcessPool` so every
259
+ * `fastembed_process.request.*` telemetry record this client emits carries
260
+ * the pool's actual size and this member's index alongside `queue_depth`/
261
+ * `response_ms`. Diagnosing THIS incident required an agent to read source
262
+ * and reconstruct `resolveFastembedPoolSize()`'s arithmetic by hand,
263
+ * because no telemetry field ever recorded what size the pool actually
264
+ * resolved to at runtime — `queue_depth` alone cannot distinguish
265
+ * "contention on an inert 1-member pool" from "genuine over-capacity on a
266
+ * 4-member pool". `undefined` for a lone (non-pooled) client, which omits
267
+ * both fields from telemetry exactly as before this addendum.
108
268
  */
109
- constructor(hostPathOverride) {
269
+ constructor(hostPathOverride, poolGroup, poolSize, memberIndex) {
110
270
  this.hostPath = hostPathOverride;
271
+ this.poolGroup = poolGroup;
272
+ this.poolSize = poolSize;
273
+ this.memberIndex = memberIndex;
111
274
  }
112
275
  /** True once the underlying child process has been forked. */
113
276
  get started() {
114
277
  return this.child !== null;
115
278
  }
279
+ /**
280
+ * (BUG-MEMORY-EMBED-HEAD-OF-LINE-BLOCKING-001) Number of requests already
281
+ * admitted-and-unsettled on this specific child, i.e. how many are ahead
282
+ * of a hypothetical next request. Exposed read-only so `FastembedProcessPool`
283
+ * (below) can route a new request to whichever pool member is least loaded
284
+ * — the exact `this.pending.size` value `request()` already measures
285
+ * internally for the `queue_depth` telemetry field, just made visible to a
286
+ * caller one level up instead of re-derived.
287
+ */
288
+ get pendingCount() {
289
+ return this.pending.size;
290
+ }
291
+ /**
292
+ * (BL-575) Update the `pool_size`/`member_index` telemetry fields this
293
+ * client stamps on every `fastembed_process.request.*` record. Package-
294
+ * private (no `readonly`) specifically so `AdaptiveFastembedProcessPool`
295
+ * can keep them truthful as the pool grows/shrinks at runtime — a FIXED
296
+ * `FastembedProcessPool` never calls this (its size is constant for the
297
+ * member's whole lifetime, set once at construction).
298
+ */
299
+ setPoolMeta(poolSize, memberIndex) {
300
+ this.poolSize = poolSize;
301
+ this.memberIndex = memberIndex;
302
+ }
116
303
  /** Lazily fork (exactly once) and return the single shared child process. */
117
304
  ensureProcess() {
305
+ // (BUG-MEMORYSERVER-SHUTDOWN-LEAKS-FASTEMBED-CHILD-001) A caller racing a
306
+ // real `terminate()` must never re-fork a child no one will ever reap —
307
+ // see `terminated`'s own doc comment for the exact reproduction. Checked
308
+ // FIRST, before the `this.child`/`this.startingPromise` fast paths, so it
309
+ // also covers the narrow window where `terminate()` has set this flag but
310
+ // a stale (about-to-be-killed) `this.child` reference is still non-null.
311
+ if (this.terminated) {
312
+ return Promise.reject(new Error('shared fastembed process terminated'));
313
+ }
118
314
  if (this.child)
119
315
  return Promise.resolve(this.child);
120
316
  if (this.startingPromise)
@@ -126,6 +322,9 @@ export class SharedFastembedProcessClient {
126
322
  // Real inference is CPU-bound in native code; no need to keep the
127
323
  // parent process alive on this child's account.
128
324
  detached: false,
325
+ ...(this.poolGroup !== undefined
326
+ ? { env: { ...process.env, SOX_FASTEMBED_POOL_GROUP: this.poolGroup } }
327
+ : {}),
129
328
  });
130
329
  c.unref();
131
330
  c.on('message', (msg) => {
@@ -147,6 +346,12 @@ export class SharedFastembedProcessClient {
147
346
  this.pending.clear();
148
347
  this.child = null;
149
348
  this.startingPromise = null;
349
+ // (BUG-021) A dead child never reappears un-loaded — the NEXT
350
+ // ensureProcess() forks a genuinely new one, which starts this flag
351
+ // at false again anyway, but clearing it here too closes any window
352
+ // where a caller reads `childInitialized` between the crash and the
353
+ // next fork.
354
+ this.childInitialized = false;
150
355
  });
151
356
  c.on('exit', (code) => {
152
357
  if (code !== 0 && code !== null) {
@@ -157,6 +362,7 @@ export class SharedFastembedProcessClient {
157
362
  }
158
363
  this.child = null;
159
364
  this.startingPromise = null;
365
+ this.childInitialized = false;
160
366
  });
161
367
  // Same re-unref pattern as `sharedOnnxWorker.ts` — attaching listeners
162
368
  // can re-ref the underlying handle; re-assert `unref()` once every
@@ -234,15 +440,63 @@ export class SharedFastembedProcessClient {
234
440
  * 3. `competing_host_pid` — present only when a second, still-live
235
441
  * `fastembedProcessHost` process is detected (BL-331's advisory lock),
236
442
  * since that changes embed latency 25-50x independent of queueing.
443
+ *
444
+ * BL-576: `signal`, when supplied, is an external cancellation source —
445
+ * e.g. `operation-guard.ts`'s `withOperationDeadline` abort controller, so
446
+ * a deadline that fires while an embed is queued behind an unrelated slow
447
+ * request stops WAITING on it immediately rather than riding the deadline
448
+ * out via its own separate mechanism. This does NOT kill the underlying
449
+ * fastembed child mid-inference (the same "no native cancellation
450
+ * primitive" constraint `operation-guard.ts` documents applies here too —
451
+ * the child process is shared across other pending requests, so killing
452
+ * it on one caller's abort would collaterally fail every other in-flight
453
+ * request on that member) — it settles the CALLER's promise immediately
454
+ * with an `AbortError` and removes the pending entry so a late reply from
455
+ * the child (once the real work finishes) is silently dropped instead of
456
+ * resolving/rejecting a promise nobody is awaiting anymore. If `signal`
457
+ * is already aborted when `request()` is called, it rejects immediately
458
+ * without ever sending to the child.
237
459
  */
238
- async request(payload, timeoutMs) {
239
- const child = await this.ensureProcess();
460
+ async request(payload, timeoutMs, signal) {
461
+ if (signal?.aborted) {
462
+ throw signal.reason instanceof Error
463
+ ? signal.reason
464
+ : new Error('shared fastembed process request aborted before it was sent');
465
+ }
466
+ const isInitRequest = payload['type'] === 'init';
467
+ if (isInitRequest) {
468
+ this.lastInitPayload = payload;
469
+ }
470
+ let child = await this.ensureProcess();
471
+ if (!isInitRequest && this.lastInitPayload && !this.childInitialized) {
472
+ // (BUG-021 fix) `child` was just (re)spawned — by this call's own
473
+ // `ensureProcess()` above, OR by an earlier request on this member
474
+ // after the previous child died unexpectedly — and has never loaded a
475
+ // model. Replay the last known-good `init` payload before letting the
476
+ // real request through, exactly mirroring what `FastembedProcessPool`/
477
+ // `AdaptiveFastembedProcessPool` already do for a brand-new member on
478
+ // grow, but here for the respawn-in-place case those pools never
479
+ // detect (they only (re)send `init` when THEY create a member; they
480
+ // have no visibility into this client transparently re-forking its own
481
+ // dead child). Recurses through this exact method so the replay gets
482
+ // identical telemetry/timeout/admission treatment to any other init.
483
+ await this.request(this.lastInitPayload, timeoutMs, signal);
484
+ // Re-fetch: `ensureProcess()` is idempotent for a live child, but never
485
+ // trust a reference captured before an `await` against a process that
486
+ // can die out from under it.
487
+ child = await this.ensureProcess();
488
+ }
240
489
  const id = this.nextId++;
241
490
  const queueDepth = this.pending.size;
242
- const competing = detectCompetingFastembedHost(child.pid);
491
+ const competing = detectCompetingFastembedHost(child.pid, this.poolGroup);
243
492
  const baseFields = {
244
493
  queue_depth: queueDepth,
245
494
  ...(competing ? { competing_host_pid: competing.pid } : {}),
495
+ // (BUG-EMBED-POOL-SIZE-DARWIN-FREEMEM-001) See the constructor's
496
+ // `poolSize`/`memberIndex` doc comment: makes "was this process even
497
+ // pooled, and at what size" a directly observable telemetry field
498
+ // instead of something an agent has to re-derive from source.
499
+ ...(this.poolSize !== undefined ? { pool_size: this.poolSize, member_index: this.memberIndex } : {}),
246
500
  };
247
501
  return new Promise((resolve, reject) => {
248
502
  let to;
@@ -265,10 +519,24 @@ export class SharedFastembedProcessClient {
265
519
  if (typeof to.unref === 'function')
266
520
  to.unref();
267
521
  }
522
+ let onAbort;
523
+ const detachAbort = () => {
524
+ if (onAbort && signal)
525
+ signal.removeEventListener('abort', onAbort);
526
+ };
268
527
  this.pending.set(id, {
269
528
  resolve: (v) => {
270
529
  if (to)
271
530
  clearTimeout(to);
531
+ detachAbort();
532
+ // (BUG-021) Only a genuinely SUCCESSFUL init reply proves this
533
+ // child now has a loaded model — flip the flag here, not
534
+ // optimistically at send time, so a rejected init (model load
535
+ // error) correctly leaves `childInitialized` false and the next
536
+ // real request replays init again rather than sailing through
537
+ // believing a failed load succeeded.
538
+ if (isInitRequest)
539
+ this.childInitialized = true;
272
540
  log.info('fastembed_process.request.finish', {
273
541
  ...baseFields,
274
542
  response_ms: Math.round(performance.now() - sentAt),
@@ -278,6 +546,7 @@ export class SharedFastembedProcessClient {
278
546
  reject: (e) => {
279
547
  if (to)
280
548
  clearTimeout(to);
549
+ detachAbort();
281
550
  log.warn('fastembed_process.request.error', {
282
551
  ...baseFields,
283
552
  response_ms: Math.round(performance.now() - sentAt),
@@ -286,6 +555,32 @@ export class SharedFastembedProcessClient {
286
555
  reject(e);
287
556
  },
288
557
  });
558
+ // BL-576: an external abort (e.g. operation-guard.ts's deadline)
559
+ // settles the CALLER's promise immediately — via the SAME pending-map
560
+ // removal + `unrefIfIdle()` path the internal `timeoutMs` timer above
561
+ // already uses — without waiting for `timeoutMs` (which may be unset,
562
+ // or longer than the caller's own deadline) and without touching the
563
+ // child process itself: the real work, if the child is still grinding
564
+ // on it, keeps running and its late reply (if any) is silently
565
+ // dropped by the `c.on('message')` handler above (`pending.get(msg.id)`
566
+ // returns undefined once this entry is deleted).
567
+ if (signal) {
568
+ onAbort = () => {
569
+ if (!this.pending.has(id))
570
+ return;
571
+ this.pending.delete(id);
572
+ this.unrefIfIdle();
573
+ if (to)
574
+ clearTimeout(to);
575
+ log.warn('fastembed_process.request.aborted', {
576
+ ...baseFields,
577
+ response_ms: Math.round(performance.now() - sentAt),
578
+ reason: signal.reason instanceof Error ? signal.reason.message : String(signal.reason),
579
+ });
580
+ reject(signal.reason instanceof Error ? signal.reason : new Error('shared fastembed process request aborted'));
581
+ };
582
+ signal.addEventListener('abort', onAbort, { once: true });
583
+ }
289
584
  // BL-410: keep the parent's event loop ref'd until this request settles.
290
585
  this.refForPending();
291
586
  log.info('fastembed_process.request.admitted', baseFields);
@@ -309,13 +604,34 @@ export class SharedFastembedProcessClient {
309
604
  * shutdown sequence waiting on a child that will never exit gracefully.
310
605
  */
311
606
  async terminate() {
607
+ // (BUG-MEMORYSERVER-SHUTDOWN-LEAKS-FASTEMBED-CHILD-001) Set FIRST and
608
+ // synchronously — before anything else in this method runs — so any
609
+ // `ensureProcess()` call racing this `terminate()` (including one already
610
+ // past its own `if (this.child)`/`if (this.startingPromise)` fast-path
611
+ // checks in the SAME microtask, which cannot happen after this
612
+ // synchronous write) observes `terminated` and refuses to fork a new,
613
+ // unreapable child. See `terminated`'s own doc comment for the exact
614
+ // reproduction this closes.
615
+ this.terminated = true;
312
616
  const c = this.child;
617
+ // A fork that was ALREADY in flight (started by an `ensureProcess()` call
618
+ // that ran to completion of its `fork()` before this `terminate()` set
619
+ // the flag above) has no `this.child` yet — `c` above would miss it
620
+ // entirely. Capture and await `startingPromise` too so that in-flight
621
+ // fork is killed once it resolves, instead of completing unobserved and
622
+ // orphaning exactly like the bug this fixes.
623
+ const starting = this.startingPromise;
313
624
  this.child = null;
314
625
  this.startingPromise = null;
315
626
  for (const { reject } of this.pending.values()) {
316
627
  reject(new Error('shared fastembed process terminated'));
317
628
  }
318
629
  this.pending.clear();
630
+ if (c === null && starting) {
631
+ void starting.then((lateChild) => {
632
+ lateChild.kill();
633
+ }).catch(() => undefined);
634
+ }
319
635
  if (!c)
320
636
  return;
321
637
  const exited = new Promise((resolve) => {
@@ -325,8 +641,13 @@ export class SharedFastembedProcessClient {
325
641
  if (c.connected)
326
642
  c.send({ __shutdown: true });
327
643
  }
328
- catch {
329
- // IPC already gone — kill() below is the only path left.
644
+ catch (err) {
645
+ // IPC already gone — kill() below is the only path left. Deliberately silent:
646
+ // process.send() throws EPIPE if the IPC channel is gone; this is the
647
+ // expected path when the child exits without a graceful __shutdown message.
648
+ log.debug('embedding_provider.fastembed.terminate.send_failed', {
649
+ reason: err instanceof Error ? err.message : 'IPC channel unavailable',
650
+ });
330
651
  }
331
652
  const timedOut = await Promise.race([
332
653
  exited.then(() => false),
@@ -342,6 +663,664 @@ export class SharedFastembedProcessClient {
342
663
  }
343
664
  /** (BL-405) How long `terminate()` waits for the graceful `__shutdown` message before falling back to `kill()`. */
344
665
  const TERMINATE_GRACE_MS = 1000;
666
+ // ── FastembedProcessPool (BUG-MEMORY-EMBED-HEAD-OF-LINE-BLOCKING-001) ───────
667
+ //
668
+ // PRODUCTION MEASUREMENT (n=3769, 11 days, from the bug report this fixes):
669
+ //
670
+ // response_ms p50=1011ms p90=5674ms p99=40518ms max=109089ms
671
+ // qdepth 0 n=1335 p50=534ms p90=1501ms
672
+ // qdepth 1-2 n=1905 p50=1164ms p90=4367ms
673
+ // qdepth 3-5 n=354 p50=2821ms p90=15441ms
674
+ // qdepth 6-10 n=105 p50=5519ms p90=18862ms
675
+ // qdepth 11+ n=70 p50=26723ms p90=76712ms
676
+ //
677
+ // Root cause: ALL embed requests in a process share ONE `SharedFastembedProcessClient`
678
+ // (`getSharedFastembedProcess()`), which forks exactly ONE child process, whose
679
+ // `fastembedProcessHost.ts` processes requests through a single serialized
680
+ // `_queue` promise chain (`enqueue()`). Every request beyond the first-in-flight
681
+ // waits for every earlier one to fully finish — classic head-of-line blocking,
682
+ // and it is why the slope above is so clean: `response_ms` scales almost
683
+ // linearly with `queue_depth`.
684
+ //
685
+ // The single-child-PROCESS design is NOT what's wrong — it is the proven fix
686
+ // for BL-238 (fastembed's onnxruntime-node@1.21.0 cannot share a THREAD, or
687
+ // even sequential same-thread loading, with transformers.js's onnxruntime-node
688
+ // @1.24.3; see the file-header comment in `fastembedProcessHost.ts`). What's
689
+ // wrong is that there is only ONE such child. A POOL of N independent child
690
+ // PROCESSES preserves the exact isolation property BL-238 requires (each pool
691
+ // member is its own OS process — the hazard classes BL-238 documents are both
692
+ // structurally about SHARING a thread/address space, never about how many
693
+ // separate processes exist) while removing the forced serialization across
694
+ // members: N members can each process one request at a time, so N requests
695
+ // run concurrently instead of 1.
696
+ //
697
+ // Routing: least-loaded-member (by `pendingCount`), not round-robin — under
698
+ // bursty arrival (the production shape: several agents writing at once) a
699
+ // least-loaded pick keeps the queue-depth distribution across members far
700
+ // tighter than round-robin, which can stack several slow requests on the same
701
+ // member by bad luck.
702
+ //
703
+ // Admission control: once EVERY member already has `admissionLimit` or more
704
+ // requests in flight, `request()` for a non-init payload throws
705
+ // `FastembedBusyError` instead of enqueuing — "a queue that never refuses is
706
+ // a queue that hides its own failure" (bug item, direction #2). The default
707
+ // (`Number.POSITIVE_INFINITY`, i.e. disabled) preserves today's "always admit"
708
+ // behaviour; ops can opt in via `SOX_EMBED_POOL_ADMISSION_LIMIT` once the pool
709
+ // alone is proven sufficient in production (see the benchmark harness results
710
+ // cited in this bug's resolution notes — a 4-member pool absorbs the
711
+ // qdepth-11+ regime the production telemetry measured with room to spare, so
712
+ // admission control is shipped as an available backstop, not defaulted on).
713
+ //
714
+ // Batch coalescing (bug item direction #3) was evaluated and rejected for
715
+ // this pass: `embedBatch` already exists and batches WITHIN one caller's
716
+ // texts; merging batches ACROSS independent concurrent callers would require
717
+ // buffering/deadline logic (wait up to Xms for more arrivals before sending)
718
+ // that trades a bounded latency floor for a throughput gain the pool already
719
+ // captures via parallelism. The pool is strictly simpler, has no added
720
+ // latency floor, and the measured numbers below show it closes the gap
721
+ // without it.
722
+ //
723
+ // ── Memory cost — MEASURED, not assumed (post-review addendum) ─────────────
724
+ //
725
+ // A pool member is a full onnxruntime-node `InferenceSession`, and the
726
+ // question of whether N of them cost N times the model's on-disk size was
727
+ // open until measured directly: forked 1 and 4 REAL `fastembedProcessHost.js`
728
+ // children loading the production default model (bge-base-en-v1.5, 219MB on
729
+ // disk) and read each child's `footprint` (Apple's private+compressed
730
+ // physical-footprint accounting, which — unlike raw RSS — correctly
731
+ // distinguishes real per-process cost from pages shared/mmap'd across
732
+ // processes):
733
+ //
734
+ // 1 child: phys_footprint ≈ 261 MB, of which 207 MB is a single
735
+ // `MALLOC_LARGE` DIRTY (not Clean/Reclaimable) allocation.
736
+ // 4 children: EACH independently shows phys_footprint ≈ 262-271 MB with
737
+ // its OWN ~207-208 MB MALLOC_LARGE dirty block — no shared/clean
738
+ // mmap'd region for the model weights at all.
739
+ //
740
+ // Conclusion: onnxruntime-node HEAP-ALLOCATES the model per process; it does
741
+ // NOT mmap it read-only for the OS to share across children. The cost is
742
+ // real and scales linearly with pool size — a 4-member pool for this model
743
+ // costs ~4×265MB ≈ 1.06GB of additional resident memory, not a few hundred
744
+ // extra KB for session arenas. Other configured models range further: per
745
+ // `fastembedModels.ts`'s own descriptions, codexembed-400m is documented at
746
+ // "~1.6GB RAM" per instance — a 4-member pool of that model would be ~6.4GB.
747
+ //
748
+ // This matters because embedding-provider has no way to know at
749
+ // `getSharedFastembedProcess()` call time (before any `init`) which model
750
+ // will be loaded, so pool sizing CANNOT be exact per-model — and the box
751
+ // this was measured on had 128MB of free physical memory at measurement
752
+ // time (`top -l 1`, `PhysMem: 31G used … 128M unused`), out of 32GB total,
753
+ // with 11GB already in the compressor. An unconditional hardware-sized
754
+ // default (the original `floor(cpus/2)`, cap 4) would have shipped a
755
+ // default that, on THIS box alone, tries to allocate ~800MB more than is
756
+ // physically free — risking heavy swap or an OOM kill mid-embed-write, which
757
+ // is exactly the unclean-shutdown shape this subsystem's corruption class
758
+ // feeds on. A latency fix that increases corruption exposure is not a win.
759
+ //
760
+ // So sizing is now BOTH memory-aware (auto-computed from `os.freemem()`
761
+ // against a conservative measured per-member budget, `DEFAULT_PER_MEMBER_MB`
762
+ // below — calibrated to the measured bge-base-en-v1.5 figure with margin, the
763
+ // package's own default model) AND still capped by CPU count, defaulting to
764
+ // the SAFE end when the two disagree. On a memory-constrained box this
765
+ // resolves to pool size 1 — the exact pre-fix single-child topology — rather
766
+ // than silently trying to grab memory that isn't there. `SOX_EMBED_POOL_SIZE`
767
+ // remains an unconditional override for an operator who has measured their
768
+ // own model's real footprint and confirmed headroom; it is honored exactly,
769
+ // with no memory clamp applied (the operator's explicit judgement wins).
770
+ /** Conservative measured per-member memory budget (MB) used only for the
771
+ * memory-aware AUTO-sizing path (i.e. when `SOX_EMBED_POOL_SIZE` is not
772
+ * set) — calibrated to the measured bge-base-en-v1.5 `phys_footprint`
773
+ * (~262-271MB observed) with margin. Models configured with a materially
774
+ * different footprint (bge-m3, codexembed-400m — "~1.6GB RAM" per
775
+ * `fastembedModels.ts`) are NOT auto-detected here (pool size is resolved
776
+ * before any model is known); an operator running one of those should set
777
+ * `SOX_EMBED_POOL_PER_CHILD_MB` (or just pin `SOX_EMBED_POOL_SIZE`
778
+ * directly) rather than trust this default. */
779
+ const DEFAULT_PER_MEMBER_MB = 300;
780
+ /** Auto-sizing never lets the pool consume the machine's last headroom —
781
+ * this many MB of free memory are always left unclaimed by the pool-sizing
782
+ * calculation (the rest of the process, and everything else on the
783
+ * machine, still needs to run). */
784
+ const MEMORY_SAFETY_MARGIN_MB = 1024;
785
+ /**
786
+ * (BUG-EMBED-POOL-SIZE-DARWIN-FREEMEM-001) Estimate REAL available memory
787
+ * (MB), platform-aware — the input `resolveFastembedPoolSize()`'s
788
+ * memory-cap arithmetic below actually needs, as opposed to what
789
+ * `os.freemem()` alone reports.
790
+ *
791
+ * `os.freemem()` is not a usable proxy for "memory this process could
792
+ * actually claim" on macOS: it maps to Mach's raw "free" page count only,
793
+ * which deliberately EXCLUDES "inactive"/"speculative"/"purgeable" pages —
794
+ * pages the kernel is using as disk cache but will hand back instantly
795
+ * (zero swap-in cost) under real pressure. Measured via `vm_stat` on the
796
+ * exact box this defect was diagnosed on (32GB physical, page size 16384):
797
+ * `os.freemem()` reported ~299MB while `Pages inactive` ALONE was 345,579
798
+ * pages (~5.4GB) — the overwhelming majority of genuinely-available memory
799
+ * was invisible to the metric `resolveFastembedPoolSize()` used to
800
+ * compute `memoryCap`. Because `MEMORY_SAFETY_MARGIN_MB` (1024) routinely
801
+ * exceeds `os.freemem()`'s ~300MB-ish reading on macOS regardless of real
802
+ * load, `memoryCap` collapsed to `Math.max(1, negative) === 1` on every
803
+ * macOS box, unconditionally — the pool was permanently INERT (silently
804
+ * behaving exactly like the pre-fix single-child topology) unless an
805
+ * operator manually overrode `SOX_EMBED_POOL_SIZE`. `hol-pool-sizing.spec.ts`
806
+ * even self-documents "this suite was itself first run on a box with only
807
+ * ~128MB free" — the sizing design was validated against the very macOS
808
+ * quirk that made it universally wrong, not a genuinely memory-constrained
809
+ * machine.
810
+ *
811
+ * Fix: compute "available" as free + inactive + speculative + purgeable
812
+ * pages (the standard macOS "reclaimable without swapping" heuristic —
813
+ * matches what `htop`-family tools approximate; NOT Apple's undocumented
814
+ * memory-pressure internals, which have no public API). On Linux,
815
+ * `/proc/meminfo`'s `MemAvailable` is already the kernel's own equivalent
816
+ * estimate and is used directly. Any other platform, or any parse/exec
817
+ * failure, falls back to `os.freemem()` unchanged — this must never throw
818
+ * or block pool sizing on a shell-out failing; it runs once per process
819
+ * (at `getSharedFastembedProcess()` construction), never per-request.
820
+ */
821
+ export function estimateAvailableMemMb() {
822
+ try {
823
+ if (process.platform === 'darwin') {
824
+ const out = execSync('vm_stat', { encoding: 'utf8', timeout: 2000 });
825
+ const pageSizeMatch = /page size of (\d+) bytes/.exec(out);
826
+ const pageSize = pageSizeMatch ? Number(pageSizeMatch[1]) : 4096;
827
+ const pages = (label) => {
828
+ const m = new RegExp(`${label}:\\s+(\\d+)\\.`).exec(out);
829
+ return m ? Number(m[1]) : 0;
830
+ };
831
+ const availablePages = pages('Pages free') + pages('Pages inactive') + pages('Pages speculative') + pages('Pages purgeable');
832
+ const availableMb = (availablePages * pageSize) / (1024 * 1024);
833
+ if (Number.isFinite(availableMb) && availableMb > 0)
834
+ return availableMb;
835
+ }
836
+ else if (process.platform === 'linux') {
837
+ const meminfo = readFileSync('/proc/meminfo', 'utf8');
838
+ const m = /MemAvailable:\s+(\d+)\s+kB/.exec(meminfo);
839
+ if (m) {
840
+ const availableMb = Number(m[1]) / 1024;
841
+ if (Number.isFinite(availableMb) && availableMb > 0)
842
+ return availableMb;
843
+ }
844
+ }
845
+ }
846
+ catch (err) {
847
+ log.debug('embedding_provider.fastembed.pool_sizing.mem_estimate_failed', {
848
+ error: err instanceof Error ? err.message : String(err),
849
+ platform: process.platform,
850
+ });
851
+ }
852
+ return os.freemem() / (1024 * 1024);
853
+ }
854
+ /**
855
+ * (BL-575) The explicit `SOX_EMBED_POOL_SIZE` override, if set — a HARD PIN.
856
+ * Split out of `resolveFastembedPoolSize()` so `getSharedFastembedProcess()`
857
+ * can tell "operator pinned an exact size" (disables ALL adaptation —
858
+ * `AdaptiveFastembedProcessPool` is never constructed) apart from "no
859
+ * override, compute a ceiling for adaptive sizing to grow toward" — the two
860
+ * cases used to be indistinguishable from the return value of a single
861
+ * function that already folded the CPU/memory-cap arithmetic into the
862
+ * "no override" branch.
863
+ */
864
+ export function resolveFastembedPoolPin() {
865
+ const raw = Number(process.env['SOX_EMBED_POOL_SIZE']);
866
+ if (Number.isFinite(raw) && raw >= 1)
867
+ return Math.floor(raw);
868
+ return null;
869
+ }
870
+ /**
871
+ * (BL-575) The maximum pool size the memory/CPU budget allows — i.e. what
872
+ * `resolveFastembedPoolSize()` used to compute unconditionally in its
873
+ * "no override" branch. Now used two ways: (a) unchanged, as
874
+ * `resolveFastembedPoolSize()`'s own fallback when no pin is set, so every
875
+ * existing caller of that function keeps its exact prior behavior; (b) as
876
+ * `AdaptiveFastembedProcessPool`'s `maxSize` — the ceiling it is allowed to
877
+ * grow toward under sustained load, never exceeded regardless of how
878
+ * sustained the demand is.
879
+ *
880
+ * Auto-sized from BOTH real available memory (`estimateAvailableMemMb()` —
881
+ * see its doc comment for why this is NOT simply `os.freemem()`, and
882
+ * BUG-EMBED-POOL-SIZE-DARWIN-FREEMEM-001 for the incident this fixes —
883
+ * against `DEFAULT_PER_MEMBER_MB`, less `MEMORY_SAFETY_MARGIN_MB` headroom —
884
+ * override the per-member budget via `SOX_EMBED_POOL_PER_CHILD_MB` for a
885
+ * non-default model) AND CPU count (half the logical CPUs, cap 4 — fastembed
886
+ * inference is CPU/ANE-bound per request, not embarrassingly parallel across
887
+ * all cores), taking the SMALLER of the two so a memory-constrained box
888
+ * never gets sized past what's actually free. See the module doc comment
889
+ * above for the measurement (footprint/vmmap on 1 vs 4 real children) that
890
+ * justifies this.
891
+ *
892
+ * @param getAvailableMemMb Test-only injection point — production always
893
+ * uses the default `estimateAvailableMemMb`. Lets tests exercise the sizing
894
+ * arithmetic against deterministic MB values instead of the real, inherently
895
+ * machine/moment-dependent OS memory state.
896
+ */
897
+ export function resolveFastembedPoolCeiling(getAvailableMemMb = estimateAvailableMemMb) {
898
+ const perMemberMb = Number(process.env['SOX_EMBED_POOL_PER_CHILD_MB']);
899
+ const memberBudgetMb = Number.isFinite(perMemberMb) && perMemberMb > 0 ? perMemberMb : DEFAULT_PER_MEMBER_MB;
900
+ const freeMb = getAvailableMemMb();
901
+ const memoryCap = Math.max(1, Math.floor((freeMb - MEMORY_SAFETY_MARGIN_MB) / memberBudgetMb));
902
+ const cpus = os.cpus().length || 1;
903
+ const cpuCap = Math.max(1, Math.min(4, Math.floor(cpus / 2)));
904
+ return Math.max(1, Math.min(cpuCap, memoryCap));
905
+ }
906
+ /**
907
+ * Number of independent fastembed child processes a FIXED (non-adaptive)
908
+ * pool should use. Preserved unchanged for backward compatibility (existing
909
+ * callers/tests) — `SOX_EMBED_POOL_SIZE` honored exactly if set
910
+ * (`resolveFastembedPoolPin()`), else the memory/CPU ceiling
911
+ * (`resolveFastembedPoolCeiling()`). `getSharedFastembedProcess()` itself no
912
+ * longer calls this for its default (adaptive) path as of BL-575 — see
913
+ * `resolveFastembedPoolPin`/`resolveFastembedPoolCeiling`'s doc comments.
914
+ *
915
+ * @param getAvailableMemMb Test-only injection point, forwarded to
916
+ * `resolveFastembedPoolCeiling`.
917
+ */
918
+ export function resolveFastembedPoolSize(getAvailableMemMb = estimateAvailableMemMb) {
919
+ return resolveFastembedPoolPin() ?? resolveFastembedPoolCeiling(getAvailableMemMb);
920
+ }
921
+ /** Per-member in-flight cap above which `FastembedProcessPool.request()` fast-rejects
922
+ * a non-init request with `FastembedBusyError` instead of enqueuing. Disabled
923
+ * (`Infinity`) by default — see the pool doc comment above for why. Override
924
+ * via `SOX_EMBED_POOL_ADMISSION_LIMIT` (a finite number opts in). */
925
+ export function resolveFastembedAdmissionLimit() {
926
+ const raw = Number(process.env['SOX_EMBED_POOL_ADMISSION_LIMIT']);
927
+ if (Number.isFinite(raw) && raw >= 0)
928
+ return raw;
929
+ return Number.POSITIVE_INFINITY;
930
+ }
931
+ /**
932
+ * A pool of `size` independent `SharedFastembedProcessClient`s — i.e. `size`
933
+ * independent fastembed-hosting OS child processes, each fully isolated from
934
+ * the others (preserving BL-238's process-isolation requirement) — routing
935
+ * each request to whichever member currently has the fewest requests in
936
+ * flight. See the module-level doc comment above for the full rationale and
937
+ * the production measurements this fixes.
938
+ */
939
+ export class FastembedProcessPool {
940
+ members;
941
+ admissionLimit;
942
+ /**
943
+ * (BUG-MEMORY-EMBED-HEAD-OF-LINE-BLOCKING-001) Routing/admission MUST NOT
944
+ * read `member.pendingCount` for the decision — that counter is only
945
+ * incremented deep inside `SharedFastembedProcessClient.request()`, AFTER
946
+ * its own `await this.ensureProcess()`, i.e. at least one microtask tick
947
+ * after the call starts. Several callers issued in the same synchronous
948
+ * burst (`Promise.all(callers.map(() => pool.request(...)))`, the exact
949
+ * shape a real caller under load produces) all run their OWN synchronous
950
+ * prefix — including this pool's `leastLoaded()`/admission check — before
951
+ * ANY of them reaches that tick, so every one of them would read
952
+ * `pendingCount === 0` for every member and pile onto `members[0]`,
953
+ * silently defeating both load-balancing and admission control for
954
+ * exactly the bursty-arrival case this fix exists for. `inFlight` is
955
+ * incremented SYNCHRONOUSLY the instant a member is chosen (before any
956
+ * `await`), so the very next synchronous call in the same burst already
957
+ * sees it.
958
+ */
959
+ inFlight;
960
+ /** Cached so a member added to routing after the first `init` (there is
961
+ * none today — pool size is fixed at construction — but kept so a future
962
+ * dynamic-resize doesn't silently skip initializing a new member) can be
963
+ * brought up to date. Also lets `request()` short-circuit a redundant
964
+ * broadcast if the model/cacheDir haven't changed. */
965
+ lastInitPayload = null;
966
+ constructor(size, hostPathOverride, admissionLimit = resolveFastembedAdmissionLimit()) {
967
+ if (!Number.isFinite(size) || size < 1) {
968
+ throw new Error(`FastembedProcessPool: size must be >= 1, got ${size}`);
969
+ }
970
+ // One group id shared by every member — see `fastembedLock.ts`'s
971
+ // `poolGroup` doc comment: this is what lets the BL-331 advisory lock
972
+ // recognize a sibling pool member instead of warning about it as an
973
+ // unrelated competing host.
974
+ const poolGroup = `pool-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
975
+ this.members = Array.from({ length: size }, (_, i) => new SharedFastembedProcessClient(hostPathOverride, poolGroup, size, i));
976
+ this.inFlight = new Array(size).fill(0);
977
+ this.admissionLimit = admissionLimit;
978
+ }
979
+ /** True once at least one member has forked its child process. */
980
+ get started() {
981
+ return this.members.some((m) => m.started);
982
+ }
983
+ /** The last `{ type: 'init', model, cacheDir }` payload broadcast to every
984
+ * member, or `null` before the first init. Exposed for introspection/tests
985
+ * only — `request()` itself doesn't need to read this back today (pool
986
+ * size is fixed at construction, so every member is always initialized by
987
+ * the broadcast), it's retained purely so a future dynamic-resize path has
988
+ * the payload on hand to bring a newly-added member up to date. */
989
+ get lastInit() {
990
+ return this.lastInitPayload;
991
+ }
992
+ /** Sum of in-flight requests across every member — the pool-wide
993
+ * head-of-line depth an arriving caller would experience. */
994
+ get pendingCount() {
995
+ return this.members.reduce((sum, m) => sum + m.pendingCount, 0);
996
+ }
997
+ /** Index of the least-loaded member by the SYNCHRONOUS `inFlight` counter
998
+ * — see that field's doc comment for why `member.pendingCount` itself is
999
+ * unsafe to read here. */
1000
+ leastLoadedIndex() {
1001
+ let bestIdx = 0;
1002
+ for (let i = 1; i < this.inFlight.length; i++) {
1003
+ if (this.inFlight[i] < this.inFlight[bestIdx])
1004
+ bestIdx = i;
1005
+ }
1006
+ return bestIdx;
1007
+ }
1008
+ async request(payload, timeoutMs) {
1009
+ // `init` must reach EVERY member — an embed/embedBatch request can be
1010
+ // routed to ANY member, so every member needs the model loaded before
1011
+ // it can serve one. Broadcasting once (per distinct model/cacheDir) is
1012
+ // cheap relative to a real model load, and members race the load
1013
+ // concurrently rather than serially, so this does not multiply the
1014
+ // warmup budget by pool size.
1015
+ if (payload['type'] === 'init') {
1016
+ this.lastInitPayload = payload;
1017
+ const results = await Promise.all(this.members.map((m) => m.request(payload, timeoutMs)));
1018
+ return results[0];
1019
+ }
1020
+ // Reserve the slot SYNCHRONOUSLY (see `inFlight`'s doc comment) before
1021
+ // any `await` — this is what makes routing/admission correct for a
1022
+ // burst of calls issued in the same microtask (e.g. `Promise.all` over
1023
+ // many concurrent callers), not just for calls staggered by real I/O.
1024
+ const idx = this.leastLoadedIndex();
1025
+ if (this.inFlight[idx] >= this.admissionLimit) {
1026
+ const retryAfterMs = Math.min(2000, 100 * (this.inFlight[idx] + 1));
1027
+ throw new FastembedBusyError(`fastembed pool saturated: every one of ${this.members.length} member(s) already has ` +
1028
+ `>= ${this.admissionLimit} requests in flight`, retryAfterMs);
1029
+ }
1030
+ this.inFlight[idx] += 1;
1031
+ const target = this.members[idx];
1032
+ try {
1033
+ return await target.request(payload, timeoutMs);
1034
+ }
1035
+ finally {
1036
+ this.inFlight[idx] -= 1;
1037
+ }
1038
+ }
1039
+ async terminate() {
1040
+ await Promise.all(this.members.map((m) => m.terminate()));
1041
+ }
1042
+ }
1043
+ const GROW_QUEUE_RATIO_THRESHOLD = 1.5;
1044
+ const GROW_SUSTAIN_COUNT = 3;
1045
+ /**
1046
+ * (BL-575 hysteresis fix, post-ship — real-inference sweep by a peer agent,
1047
+ * 2026-08-17) `growSustainCount` alone has NO time dimension:
1048
+ * `avgPendingPerMember` is a function of INSTANTANEOUS concurrency, not
1049
+ * sustained demand. When N callers submit near-simultaneously against a
1050
+ * small pool (e.g. concurrency=8 against size=1), `pendingCount` ramps past
1051
+ * the threshold within MILLISECONDS — "3 consecutive over-threshold
1052
+ * admissions" can all land in the same millisecond, so a single momentary
1053
+ * burst (exactly the case §HYSTERESIS POLICY above says should NOT trigger
1054
+ * a grow) satisfied the count condition every time. Measured: concurrency=8,
1055
+ * n=24 (where the pool should stay at size 1 the whole run per the cited
1056
+ * benchmark) grew 1->2 on 2/2 real trials, making both wall time and p99
1057
+ * WORSE than the correct behavior (wall 7896/6690ms, p99 4749/3959ms vs the
1058
+ * real fixed-pool=1 baseline of ~3041ms). `GROW_COOLDOWN_MS` does not help —
1059
+ * it only gates the SECOND+ grow, not this premature first one.
1060
+ *
1061
+ * Fix: require the over-threshold streak to span at least
1062
+ * `GROW_SUSTAIN_WINDOW_MS` of REAL elapsed time, in addition to the
1063
+ * consecutive-count requirement — a burst of simultaneous submissions that
1064
+ * resolves within milliseconds (the common case at low-moderate concurrency,
1065
+ * where each request completes in low seconds) cannot satisfy both; genuine
1066
+ * sustained backlog (queue depth staying elevated for multiple seconds while
1067
+ * throughput fails to keep up) can. 2000ms is deliberately shorter than a
1068
+ * single solo inference's own typical latency (~2.5-3s measured) — long
1069
+ * enough to rule out "everyone submitted at once", short enough not to blunt
1070
+ * the responsiveness a genuinely overloaded pool needs.
1071
+ */
1072
+ const GROW_SUSTAIN_WINDOW_MS = 2_000;
1073
+ const GROW_COOLDOWN_MS = 15_000;
1074
+ const SHRINK_IDLE_MS = 60_000;
1075
+ const SHRINK_CHECK_INTERVAL_MS = 15_000;
1076
+ export class AdaptiveFastembedProcessPool {
1077
+ members;
1078
+ inFlight;
1079
+ minSize;
1080
+ maxSize;
1081
+ hostPathOverride;
1082
+ admissionLimit;
1083
+ poolGroup;
1084
+ clock;
1085
+ shrinkTimer;
1086
+ growQueueRatioThreshold;
1087
+ growSustainCount;
1088
+ growSustainWindowMs;
1089
+ growCooldownMs;
1090
+ shrinkIdleMs;
1091
+ lastInitPayload = null;
1092
+ growConsecutiveOverThreshold = 0;
1093
+ /** Wall-clock timestamp the CURRENT over-threshold streak began — `null`
1094
+ * when not currently in a streak. See `GROW_SUSTAIN_WINDOW_MS`'s doc
1095
+ * comment for why a count alone is insufficient. */
1096
+ growStreakStartedAt = null;
1097
+ lastGrowAt = -Infinity;
1098
+ idleSinceMs;
1099
+ /** Serializes concurrent grow attempts — `request()` can observe the
1100
+ * over-threshold condition from several concurrent callers in the same
1101
+ * burst; only one grow should actually happen. */
1102
+ growInFlight = null;
1103
+ terminated = false;
1104
+ /** Counts of grow/shrink actions taken — exposed for tests/observability,
1105
+ * not consumed by any routing logic. */
1106
+ growCount = 0;
1107
+ shrinkCount = 0;
1108
+ constructor(opts) {
1109
+ this.minSize = Math.max(1, Math.floor(opts.minSize ?? 1));
1110
+ this.maxSize = Math.max(this.minSize, Math.floor(opts.maxSize));
1111
+ this.hostPathOverride = opts.hostPathOverride;
1112
+ this.admissionLimit = opts.admissionLimit ?? resolveFastembedAdmissionLimit();
1113
+ this.clock = opts.now ?? (() => Date.now());
1114
+ this.growQueueRatioThreshold = opts.growQueueRatioThreshold ?? GROW_QUEUE_RATIO_THRESHOLD;
1115
+ this.growSustainCount = opts.growSustainCount ?? GROW_SUSTAIN_COUNT;
1116
+ this.growSustainWindowMs = opts.growSustainWindowMs ?? GROW_SUSTAIN_WINDOW_MS;
1117
+ this.growCooldownMs = opts.growCooldownMs ?? GROW_COOLDOWN_MS;
1118
+ this.shrinkIdleMs = opts.shrinkIdleMs ?? SHRINK_IDLE_MS;
1119
+ this.poolGroup = `apool-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
1120
+ this.members = Array.from({ length: this.minSize }, (_, i) => new SharedFastembedProcessClient(this.hostPathOverride, this.poolGroup, this.minSize, i));
1121
+ this.inFlight = new Array(this.minSize).fill(0);
1122
+ this.idleSinceMs = this.clock();
1123
+ // Unref'd — a periodic shrink-check timer must never keep an otherwise
1124
+ // idle process alive (BL-370's exact failure shape, applied here).
1125
+ this.shrinkTimer = setInterval(() => {
1126
+ this.maybeShrink();
1127
+ }, opts.shrinkCheckIntervalMs ?? SHRINK_CHECK_INTERVAL_MS);
1128
+ if (typeof this.shrinkTimer.unref === 'function')
1129
+ this.shrinkTimer.unref();
1130
+ }
1131
+ get started() {
1132
+ return this.members.some((m) => m.started);
1133
+ }
1134
+ get lastInit() {
1135
+ return this.lastInitPayload;
1136
+ }
1137
+ get pendingCount() {
1138
+ return this.members.reduce((sum, m) => sum + m.pendingCount, 0);
1139
+ }
1140
+ /** Current pool size — exposed for tests/observability. */
1141
+ get size() {
1142
+ return this.members.length;
1143
+ }
1144
+ leastLoadedIndex() {
1145
+ let bestIdx = 0;
1146
+ for (let i = 1; i < this.inFlight.length; i++) {
1147
+ if (this.inFlight[i] < this.inFlight[bestIdx])
1148
+ bestIdx = i;
1149
+ }
1150
+ return bestIdx;
1151
+ }
1152
+ /** Renumber every member's `pool_size`/`member_index` telemetry fields to
1153
+ * match current pool composition — called after every grow/shrink so
1154
+ * `fastembed_process.request.*` records stay truthful. */
1155
+ renumberMembers() {
1156
+ for (let i = 0; i < this.members.length; i++) {
1157
+ this.members[i].setPoolMeta(this.members.length, i);
1158
+ }
1159
+ }
1160
+ /**
1161
+ * Attempt to add one member, up to `maxSize`. Best-effort and internally
1162
+ * serialized (`growInFlight`) — safe to call from multiple concurrent
1163
+ * `request()` admissions without double-growing. If a `lastInitPayload`
1164
+ * exists (the pool has already been initialized with a model), the new
1165
+ * member is sent the SAME init payload before being added to routing —
1166
+ * an un-initialized member would fail every real embed request it was
1167
+ * routed to.
1168
+ */
1169
+ async grow() {
1170
+ // (BUG-MEMORYSERVER-SHUTDOWN-LEAKS-FASTEMBED-CHILD-001) A `request()`
1171
+ // admission racing a real `terminate()` must not add a brand-new,
1172
+ // never-terminated member to a pool that has already been told to shut
1173
+ // down — defense in depth alongside the per-member `terminated` guard in
1174
+ // `SharedFastembedProcessClient.ensureProcess()`, which is what actually
1175
+ // stops the fork in the reproduced scenario (a single-member pool, the
1176
+ // default topology); this closes the analogous pool-level growth path.
1177
+ if (this.terminated)
1178
+ return;
1179
+ if (this.growInFlight)
1180
+ return this.growInFlight;
1181
+ if (this.members.length >= this.maxSize)
1182
+ return;
1183
+ this.growInFlight = (async () => {
1184
+ const newIndex = this.members.length;
1185
+ const member = new SharedFastembedProcessClient(this.hostPathOverride, this.poolGroup, newIndex + 1, newIndex);
1186
+ try {
1187
+ if (this.lastInitPayload) {
1188
+ await member.request(this.lastInitPayload);
1189
+ }
1190
+ this.members.push(member);
1191
+ this.inFlight.push(0);
1192
+ this.renumberMembers();
1193
+ this.growCount += 1;
1194
+ this.lastGrowAt = this.clock();
1195
+ this.growConsecutiveOverThreshold = 0;
1196
+ this.growStreakStartedAt = null;
1197
+ log.info('fastembed_process.pool.grew', {
1198
+ pool_group: this.poolGroup,
1199
+ new_size: this.members.length,
1200
+ max_size: this.maxSize,
1201
+ });
1202
+ }
1203
+ catch (err) {
1204
+ // A failed init (model load error, fork failure) must not corrupt
1205
+ // routing state — the member is simply discarded; the pool stays
1206
+ // at its previous size and can retry growing on a later admission.
1207
+ log.warn('fastembed_process.pool.grow_failed', {
1208
+ pool_group: this.poolGroup,
1209
+ attempted_size: newIndex + 1,
1210
+ error: err instanceof Error ? err.message : String(err),
1211
+ });
1212
+ await member.terminate().catch(() => undefined);
1213
+ }
1214
+ })();
1215
+ try {
1216
+ await this.growInFlight;
1217
+ }
1218
+ finally {
1219
+ this.growInFlight = null;
1220
+ }
1221
+ }
1222
+ /** Periodic shrink check — terminates exactly ONE idle member if the
1223
+ * entire pool has been continuously idle for `SHRINK_IDLE_MS`. Never
1224
+ * shrinks below `minSize`. Resets the idle clock after shrinking so a
1225
+ * demand spike right after only costs one re-grow step, not a full
1226
+ * rebuild, and so consecutive shrinks are still spaced `SHRINK_IDLE_MS`
1227
+ * apart (the same conservative cadence, not a rapid drain to `minSize`). */
1228
+ maybeShrink() {
1229
+ if (this.terminated)
1230
+ return;
1231
+ if (this.members.length <= this.minSize)
1232
+ return;
1233
+ if (this.pendingCount > 0) {
1234
+ this.idleSinceMs = null;
1235
+ return;
1236
+ }
1237
+ if (this.idleSinceMs === null) {
1238
+ this.idleSinceMs = this.clock();
1239
+ return;
1240
+ }
1241
+ if (this.clock() - this.idleSinceMs < this.shrinkIdleMs)
1242
+ return;
1243
+ const doomed = this.members.pop();
1244
+ this.inFlight.pop();
1245
+ this.renumberMembers();
1246
+ this.shrinkCount += 1;
1247
+ this.idleSinceMs = this.clock();
1248
+ log.info('fastembed_process.pool.shrank', {
1249
+ pool_group: this.poolGroup,
1250
+ new_size: this.members.length,
1251
+ min_size: this.minSize,
1252
+ });
1253
+ void doomed.terminate().catch((err) => {
1254
+ log.warn('fastembed_process.pool.shrink_terminate_failed', {
1255
+ pool_group: this.poolGroup,
1256
+ error: err instanceof Error ? err.message : String(err),
1257
+ });
1258
+ });
1259
+ }
1260
+ async request(payload, timeoutMs, signal) {
1261
+ if (payload['type'] === 'init') {
1262
+ this.lastInitPayload = payload;
1263
+ const results = await Promise.all(this.members.map((m) => m.request(payload, timeoutMs, signal)));
1264
+ return results[0];
1265
+ }
1266
+ // Any real admission means the pool is not idle right now — cancel any
1267
+ // in-progress idle clock so `maybeShrink()` requires a fresh full
1268
+ // `SHRINK_IDLE_MS` window starting from here.
1269
+ this.idleSinceMs = null;
1270
+ // Evaluate the grow condition BEFORE reserving this request's own slot —
1271
+ // the ratio should reflect backlog that existed independent of this
1272
+ // admission, matching "sustained queue depth", not "this one request
1273
+ // pushed the ratio over the line by itself".
1274
+ //
1275
+ // BL-575 hysteresis fix: a count of consecutive over-threshold
1276
+ // admissions has NO time dimension on its own — see
1277
+ // `GROW_SUSTAIN_WINDOW_MS`'s doc comment for the real-inference trial
1278
+ // that caught this (concurrency=8 grew 1->2 on every run, which the
1279
+ // design explicitly says should never happen). BOTH the count AND a
1280
+ // minimum REAL elapsed span since the streak began are now required.
1281
+ const avgPendingPerMember = this.pendingCount / this.members.length;
1282
+ const now = this.clock();
1283
+ if (avgPendingPerMember >= this.growQueueRatioThreshold) {
1284
+ if (this.growConsecutiveOverThreshold === 0)
1285
+ this.growStreakStartedAt = now;
1286
+ this.growConsecutiveOverThreshold += 1;
1287
+ }
1288
+ else {
1289
+ this.growConsecutiveOverThreshold = 0;
1290
+ this.growStreakStartedAt = null;
1291
+ }
1292
+ const streakSpanMs = this.growStreakStartedAt === null ? 0 : now - this.growStreakStartedAt;
1293
+ if (this.growConsecutiveOverThreshold >= this.growSustainCount &&
1294
+ streakSpanMs >= this.growSustainWindowMs &&
1295
+ this.members.length < this.maxSize &&
1296
+ now - this.lastGrowAt >= this.growCooldownMs &&
1297
+ !this.growInFlight) {
1298
+ // Fire-and-forget: growth must not add its own (model-load-scale)
1299
+ // latency to THIS request, which should be routed to an existing
1300
+ // member right now, not wait for a brand new one to spin up.
1301
+ void this.grow();
1302
+ }
1303
+ const idx = this.leastLoadedIndex();
1304
+ if (this.inFlight[idx] >= this.admissionLimit) {
1305
+ const retryAfterMs = Math.min(2000, 100 * (this.inFlight[idx] + 1));
1306
+ throw new FastembedBusyError(`fastembed pool saturated: every one of ${this.members.length} member(s) already has ` +
1307
+ `>= ${this.admissionLimit} requests in flight`, retryAfterMs);
1308
+ }
1309
+ this.inFlight[idx] += 1;
1310
+ const target = this.members[idx];
1311
+ try {
1312
+ return await target.request(payload, timeoutMs, signal);
1313
+ }
1314
+ finally {
1315
+ this.inFlight[idx] -= 1;
1316
+ }
1317
+ }
1318
+ async terminate() {
1319
+ this.terminated = true;
1320
+ clearInterval(this.shrinkTimer);
1321
+ await Promise.all(this.members.map((m) => m.terminate()));
1322
+ }
1323
+ }
345
1324
  let _singleton = null;
346
1325
  /**
347
1326
  * Process-wide singleton accessor — the ONLY sanctioned place a fastembed
@@ -349,10 +1328,37 @@ let _singleton = null;
349
1328
  * (BL-238/BL-171). Every `FastembedProvider` obtains its handle through this
350
1329
  * function instead of constructing its own `worker_threads.Worker` or
351
1330
  * `child_process`.
1331
+ *
1332
+ * (BL-575) Two shapes, chosen by whether `SOX_EMBED_POOL_SIZE` is set:
1333
+ *
1334
+ * - PINNED (`resolveFastembedPoolPin()` returns non-null): a FIXED
1335
+ * `FastembedProcessPool` at exactly that size, no adaptation — an
1336
+ * operator who set this has already judged their model's footprint and
1337
+ * the box's headroom; adaptive sizing would second-guess that judgement.
1338
+ * - DEFAULT (no override): an `AdaptiveFastembedProcessPool` starting at
1339
+ * `minSize: 1` (the topology real measurement shows wins below ~20
1340
+ * concurrency) and growing toward `resolveFastembedPoolCeiling()` (the
1341
+ * SAME ceiling `FastembedProcessPool` used to apply unconditionally)
1342
+ * only under sustained demand — see that class's doc comment for the
1343
+ * full measurement and hysteresis policy (BUG-MEMORY-EMBED-HEAD-OF-LINE-
1344
+ * BLOCKING-001's original qdepth-11+ production regime is exactly the
1345
+ * sustained-demand case this still grows to meet; the difference is it
1346
+ * no longer pays that pool's cost on every LOWER-concurrency request
1347
+ * too).
1348
+ *
1349
+ * Every existing caller keeps working unchanged either way — both classes
1350
+ * implement the same `SharedFastembedClient` shape (`request()`/
1351
+ * `terminate()`/`started`). Setting `SOX_EMBED_POOL_SIZE=1` recovers the
1352
+ * exact pre-BL-575 (and pre-pool) single-child topology.
352
1353
  */
353
1354
  export function getSharedFastembedProcess() {
354
- if (!_singleton)
355
- _singleton = new SharedFastembedProcessClient();
1355
+ if (_singleton)
1356
+ return _singleton;
1357
+ const pin = resolveFastembedPoolPin();
1358
+ _singleton =
1359
+ pin !== null
1360
+ ? new FastembedProcessPool(pin)
1361
+ : new AdaptiveFastembedProcessPool({ minSize: 1, maxSize: resolveFastembedPoolCeiling() });
356
1362
  return _singleton;
357
1363
  }
358
1364
  /**