@adhd/sox-embedding-provider 0.2.0 → 0.4.0

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