@adhd/sox-embedding-provider 0.3.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.
- package/dist/fastembed.d.ts +4 -4
- package/dist/fastembed.d.ts.map +1 -1
- package/dist/fastembed.js +12 -38
- package/dist/fastembed.js.map +1 -1
- package/dist/fastembedLock.d.ts +15 -0
- package/dist/fastembedLock.d.ts.map +1 -1
- package/dist/fastembedLock.js.map +1 -1
- package/dist/fastembedModels.d.ts +54 -0
- package/dist/fastembedModels.d.ts.map +1 -0
- package/dist/fastembedModels.js +98 -0
- package/dist/fastembedModels.js.map +1 -0
- package/dist/fastembedProcessHost.d.ts.map +1 -1
- package/dist/fastembedProcessHost.js +96 -39
- package/dist/fastembedProcessHost.js.map +1 -1
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/package.json +2 -2
- package/dist/sharedFastembedProcess.d.ts +399 -4
- package/dist/sharedFastembedProcess.d.ts.map +1 -1
- package/dist/sharedFastembedProcess.js +945 -13
- package/dist/sharedFastembedProcess.js.map +1 -1
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
355
|
-
_singleton
|
|
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
|
/**
|