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