@remit/search-index-worker 0.0.21 → 0.0.22
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/package.json +1 -1
- package/src/adaptive-embedder.test.ts +523 -0
- package/src/adaptive-embedder.ts +377 -0
- package/src/handler.test.ts +87 -0
- package/src/memory.test.ts +28 -0
- package/src/memory.ts +38 -0
- package/src/metrics.test.ts +36 -1
- package/src/metrics.ts +46 -1
- package/src/poller.ts +6 -0
- package/src/services.ts +90 -2
package/package.json
CHANGED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { EmbeddingService } from "@remit/search-service";
|
|
4
|
+
import {
|
|
5
|
+
type AdaptiveEmbeddingConfig,
|
|
6
|
+
createAdaptiveEmbeddingService,
|
|
7
|
+
type EmbeddingPlan,
|
|
8
|
+
type GovernorDeps,
|
|
9
|
+
MemoryGovernor,
|
|
10
|
+
MemoryStallTimeoutError,
|
|
11
|
+
readAdaptiveEmbeddingConfigFromEnv,
|
|
12
|
+
} from "./adaptive-embedder.js";
|
|
13
|
+
import type { MemoryReader } from "./memory.js";
|
|
14
|
+
|
|
15
|
+
const MB = 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
const CONFIG: AdaptiveEmbeddingConfig = {
|
|
18
|
+
minBatchSize: 2,
|
|
19
|
+
maxBatchSize: 8,
|
|
20
|
+
maxConcurrency: 2,
|
|
21
|
+
headroomBytes: 768 * MB,
|
|
22
|
+
rampMarginBytes: 256 * MB,
|
|
23
|
+
rampAfterReadings: 3,
|
|
24
|
+
rssCeilingBytes: 1536 * MB,
|
|
25
|
+
criticalBytes: 384 * MB,
|
|
26
|
+
pauseMs: 10,
|
|
27
|
+
stallMaxMs: 100,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Comfortably above `headroomBytes + rampMarginBytes`. */
|
|
31
|
+
const ROOMY = 2000;
|
|
32
|
+
/** Above the shed threshold but inside the ramp margin: the dead band. */
|
|
33
|
+
const DEAD_BAND = [800, 780, 900, 820, 1000, 790];
|
|
34
|
+
|
|
35
|
+
interface Reading {
|
|
36
|
+
availableMb: number;
|
|
37
|
+
rssMb?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One entry per read; the last value repeats forever. */
|
|
41
|
+
const reads = (script: readonly Reading[]): MemoryReader => {
|
|
42
|
+
let next = 0;
|
|
43
|
+
return () => {
|
|
44
|
+
const entry = script[Math.min(next, script.length - 1)];
|
|
45
|
+
next += 1;
|
|
46
|
+
return {
|
|
47
|
+
availableBytes: entry.availableMb * MB,
|
|
48
|
+
rssBytes: (entry.rssMb ?? 512) * MB,
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const available = (...availableMb: number[]): MemoryReader =>
|
|
54
|
+
reads(availableMb.map((mb) => ({ availableMb: mb })));
|
|
55
|
+
|
|
56
|
+
class Harness {
|
|
57
|
+
readonly plans: EmbeddingPlan[] = [];
|
|
58
|
+
readonly sleeps: number[] = [];
|
|
59
|
+
readonly lines: string[] = [];
|
|
60
|
+
stalls = 0;
|
|
61
|
+
beats = 0;
|
|
62
|
+
clock = 0;
|
|
63
|
+
beatFails = false;
|
|
64
|
+
readonly deps: GovernorDeps;
|
|
65
|
+
|
|
66
|
+
constructor(readMemory: MemoryReader) {
|
|
67
|
+
const record = (message: string) => {
|
|
68
|
+
this.lines.push(message);
|
|
69
|
+
};
|
|
70
|
+
this.deps = {
|
|
71
|
+
readMemory,
|
|
72
|
+
// A fake clock advanced by the waits themselves, so the stall budget is
|
|
73
|
+
// exercised without spending it.
|
|
74
|
+
sleep: async (ms) => {
|
|
75
|
+
this.sleeps.push(ms);
|
|
76
|
+
this.clock += ms;
|
|
77
|
+
},
|
|
78
|
+
now: () => this.clock,
|
|
79
|
+
log: { info: record, warn: record, error: record },
|
|
80
|
+
beat: async () => {
|
|
81
|
+
this.beats += 1;
|
|
82
|
+
if (this.beatFails) throw new Error("no space left on device");
|
|
83
|
+
},
|
|
84
|
+
onPlan: (plan) => {
|
|
85
|
+
this.plans.push(plan);
|
|
86
|
+
},
|
|
87
|
+
onStall: () => {
|
|
88
|
+
this.stalls += 1;
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const harness = (readMemory: MemoryReader): Harness => new Harness(readMemory);
|
|
95
|
+
|
|
96
|
+
const settleTimes = (governor: MemoryGovernor, times: number): void => {
|
|
97
|
+
for (let i = 0; i < times; i++) governor.settle();
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const embedderRecording = (): {
|
|
101
|
+
service: EmbeddingService;
|
|
102
|
+
batches: number[];
|
|
103
|
+
maxInFlight: () => number;
|
|
104
|
+
} => {
|
|
105
|
+
const batches: number[] = [];
|
|
106
|
+
let inFlight = 0;
|
|
107
|
+
let peak = 0;
|
|
108
|
+
return {
|
|
109
|
+
batches,
|
|
110
|
+
maxInFlight: () => peak,
|
|
111
|
+
service: {
|
|
112
|
+
dimensions: 3,
|
|
113
|
+
embeddingId: "fake@3",
|
|
114
|
+
embed: async (texts: string[]) => {
|
|
115
|
+
inFlight += 1;
|
|
116
|
+
peak = Math.max(peak, inFlight);
|
|
117
|
+
batches.push(texts.length);
|
|
118
|
+
await Promise.resolve();
|
|
119
|
+
inFlight -= 1;
|
|
120
|
+
return texts.map(() => [0, 0, 0]);
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
describe("the memory governor", () => {
|
|
127
|
+
it("starts at the floor: the smallest batch, one inference", () => {
|
|
128
|
+
const governor = new MemoryGovernor(CONFIG, harness(available(ROOMY)).deps);
|
|
129
|
+
assert.deepEqual(governor.plan, { batchSize: 2, concurrency: 1 });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("ramps the batch first, then parallelism, while headroom holds", () => {
|
|
133
|
+
const h = harness(available(ROOMY));
|
|
134
|
+
const governor = new MemoryGovernor(CONFIG, h.deps);
|
|
135
|
+
settleTimes(governor, CONFIG.rampAfterReadings);
|
|
136
|
+
assert.deepEqual(governor.plan, { batchSize: 4, concurrency: 1 });
|
|
137
|
+
settleTimes(governor, CONFIG.rampAfterReadings);
|
|
138
|
+
assert.deepEqual(governor.plan, { batchSize: 8, concurrency: 1 });
|
|
139
|
+
settleTimes(governor, CONFIG.rampAfterReadings);
|
|
140
|
+
assert.deepEqual(governor.plan, { batchSize: 8, concurrency: 2 });
|
|
141
|
+
assert.deepEqual(h.plans.at(-1), governor.plan);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("makes a ramp cost several consecutive readings", () => {
|
|
145
|
+
const h = harness(available(ROOMY));
|
|
146
|
+
const governor = new MemoryGovernor(CONFIG, h.deps);
|
|
147
|
+
settleTimes(governor, CONFIG.rampAfterReadings - 1);
|
|
148
|
+
assert.deepEqual(governor.plan, { batchSize: 2, concurrency: 1 });
|
|
149
|
+
governor.settle();
|
|
150
|
+
assert.deepEqual(governor.plan, { batchSize: 4, concurrency: 1 });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("never ramps past the configured ceiling, and stops logging there", () => {
|
|
154
|
+
const h = harness(available(ROOMY));
|
|
155
|
+
const governor = new MemoryGovernor(CONFIG, h.deps);
|
|
156
|
+
settleTimes(governor, 60);
|
|
157
|
+
assert.deepEqual(governor.plan, { batchSize: 8, concurrency: 2 });
|
|
158
|
+
// One line per change of plan, not one per batch: three changes, three
|
|
159
|
+
// lines, and nothing for the settles that changed nothing.
|
|
160
|
+
assert.equal(h.lines.length, 3);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// The oscillation this exists to prevent: a box parked near the threshold,
|
|
164
|
+
// ramping and shedding on alternate batches, logging every one of them.
|
|
165
|
+
it("holds its plan while readings hover in the dead band", () => {
|
|
166
|
+
const h = harness(reads(DEAD_BAND.map((mb) => ({ availableMb: mb }))));
|
|
167
|
+
const governor = new MemoryGovernor(CONFIG, h.deps);
|
|
168
|
+
settleTimes(governor, 30);
|
|
169
|
+
assert.deepEqual(governor.plan, { batchSize: 2, concurrency: 1 });
|
|
170
|
+
assert.deepEqual(h.lines, []);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("holds a ramped plan in the dead band too, rather than shedding it", () => {
|
|
174
|
+
const script = [
|
|
175
|
+
...Array(CONFIG.rampAfterReadings).fill({ availableMb: ROOMY }),
|
|
176
|
+
...DEAD_BAND.map((mb) => ({ availableMb: mb })),
|
|
177
|
+
];
|
|
178
|
+
const h = harness(reads(script));
|
|
179
|
+
const governor = new MemoryGovernor(CONFIG, h.deps);
|
|
180
|
+
settleTimes(governor, CONFIG.rampAfterReadings);
|
|
181
|
+
assert.deepEqual(governor.plan, { batchSize: 4, concurrency: 1 });
|
|
182
|
+
|
|
183
|
+
settleTimes(governor, 30);
|
|
184
|
+
assert.deepEqual(governor.plan, { batchSize: 4, concurrency: 1 });
|
|
185
|
+
assert.equal(h.lines.length, 1);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// A shed cannot lower resident memory that onnxruntime's arena has already
|
|
189
|
+
// claimed, so the ramp is gated on this process's own RSS as well: free
|
|
190
|
+
// memory on the box is no licence to grow when the worker is already the
|
|
191
|
+
// reason it might run out.
|
|
192
|
+
it("never ramps while its own RSS is above the ceiling", () => {
|
|
193
|
+
const h = harness(reads([{ availableMb: 8000, rssMb: 2000 }]));
|
|
194
|
+
const governor = new MemoryGovernor(CONFIG, h.deps);
|
|
195
|
+
settleTimes(governor, 30);
|
|
196
|
+
assert.deepEqual(governor.plan, { batchSize: 2, concurrency: 1 });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("sheds a ramped plan when its own RSS crosses the ceiling", () => {
|
|
200
|
+
const script = [
|
|
201
|
+
...Array(6).fill({ availableMb: ROOMY, rssMb: 512 }),
|
|
202
|
+
{ availableMb: ROOMY, rssMb: 2000 },
|
|
203
|
+
];
|
|
204
|
+
const h = harness(reads(script));
|
|
205
|
+
const governor = new MemoryGovernor(CONFIG, h.deps);
|
|
206
|
+
settleTimes(governor, 6);
|
|
207
|
+
assert.deepEqual(governor.plan, { batchSize: 8, concurrency: 1 });
|
|
208
|
+
governor.settle();
|
|
209
|
+
assert.deepEqual(governor.plan, { batchSize: 4, concurrency: 1 });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("halves the batch and drops to one inference when headroom goes", () => {
|
|
213
|
+
const h = harness(available(ROOMY, ROOMY, ROOMY, 500));
|
|
214
|
+
const governor = new MemoryGovernor(CONFIG, h.deps);
|
|
215
|
+
settleTimes(governor, CONFIG.rampAfterReadings);
|
|
216
|
+
assert.deepEqual(governor.plan, { batchSize: 4, concurrency: 1 });
|
|
217
|
+
governor.settle();
|
|
218
|
+
assert.deepEqual(governor.plan, { batchSize: 2, concurrency: 1 });
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("paces itself between batches once it has shed", async () => {
|
|
222
|
+
const h = harness(available(ROOMY, ROOMY, ROOMY, 500));
|
|
223
|
+
const governor = new MemoryGovernor(CONFIG, h.deps);
|
|
224
|
+
settleTimes(governor, CONFIG.rampAfterReadings + 1);
|
|
225
|
+
|
|
226
|
+
assert.equal(await governor.admit(), "admitted");
|
|
227
|
+
assert.deepEqual(h.sleeps, [CONFIG.pauseMs]);
|
|
228
|
+
// The pause is per shed, not sticky: an admit that follows no shed runs on.
|
|
229
|
+
assert.equal(await governor.admit(), "admitted");
|
|
230
|
+
assert.deepEqual(h.sleeps, [CONFIG.pauseMs]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("keeps pacing at the floor, where there is no smaller batch left", async () => {
|
|
234
|
+
const h = harness(available(500));
|
|
235
|
+
const governor = new MemoryGovernor(CONFIG, h.deps);
|
|
236
|
+
governor.settle();
|
|
237
|
+
assert.deepEqual(governor.plan, { batchSize: 2, concurrency: 1 });
|
|
238
|
+
assert.deepEqual(h.lines, []);
|
|
239
|
+
assert.equal(await governor.admit(), "admitted");
|
|
240
|
+
assert.deepEqual(h.sleeps, [CONFIG.pauseMs]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("stops and waits below the critical floor, then resumes", async () => {
|
|
244
|
+
const h = harness(available(ROOMY, ROOMY, ROOMY, 300, 300, 1000));
|
|
245
|
+
const governor = new MemoryGovernor(CONFIG, h.deps);
|
|
246
|
+
settleTimes(governor, CONFIG.rampAfterReadings);
|
|
247
|
+
assert.deepEqual(governor.plan, { batchSize: 4, concurrency: 1 });
|
|
248
|
+
|
|
249
|
+
assert.equal(await governor.admit(), "admitted");
|
|
250
|
+
assert.equal(h.stalls, 1);
|
|
251
|
+
assert.equal(h.sleeps.length, 2);
|
|
252
|
+
// A stop is the loudest signal the worker has, and it comes back at the
|
|
253
|
+
// floor rather than resuming at the size that emptied the box.
|
|
254
|
+
assert.deepEqual(governor.plan, { batchSize: 2, concurrency: 1 });
|
|
255
|
+
assert.match(h.lines.at(-2) ?? "", /paused/);
|
|
256
|
+
assert.match(h.lines.at(-1) ?? "", /resumed/);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// The poller's visibility timeout redelivers the record underneath a handler
|
|
260
|
+
// that waits too long, so the wait ends first and says so.
|
|
261
|
+
it("gives up once the stall budget is spent", async () => {
|
|
262
|
+
const h = harness(available(300));
|
|
263
|
+
const governor = new MemoryGovernor(CONFIG, h.deps);
|
|
264
|
+
|
|
265
|
+
assert.equal(await governor.admit(), "expired");
|
|
266
|
+
assert.equal(h.clock, CONFIG.stallMaxMs);
|
|
267
|
+
assert.match(h.lines.at(-1) ?? "", /gave up/);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("keeps the poll loop's heartbeat fresh while it waits", async () => {
|
|
271
|
+
const h = harness(available(300));
|
|
272
|
+
const governor = new MemoryGovernor(CONFIG, h.deps);
|
|
273
|
+
|
|
274
|
+
await governor.admit();
|
|
275
|
+
assert.equal(h.beats, CONFIG.stallMaxMs / CONFIG.pauseMs);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("stays up when the heartbeat cannot be written", async () => {
|
|
279
|
+
const h = harness(available(300));
|
|
280
|
+
h.beatFails = true;
|
|
281
|
+
const governor = new MemoryGovernor(CONFIG, h.deps);
|
|
282
|
+
|
|
283
|
+
assert.equal(await governor.admit(), "expired");
|
|
284
|
+
assert.ok(h.lines.some((line) => /heartbeat/.test(line)));
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("does not stall while the box is merely tight", async () => {
|
|
288
|
+
const h = harness(available(500));
|
|
289
|
+
const governor = new MemoryGovernor(CONFIG, h.deps);
|
|
290
|
+
assert.equal(await governor.admit(), "admitted");
|
|
291
|
+
assert.equal(h.stalls, 0);
|
|
292
|
+
assert.deepEqual(h.sleeps, []);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe("the governed embedder", () => {
|
|
297
|
+
it("sends the floor batch first and returns every vector in order", async () => {
|
|
298
|
+
const inner = embedderRecording();
|
|
299
|
+
const h = harness(available(ROOMY));
|
|
300
|
+
const service = createAdaptiveEmbeddingService(
|
|
301
|
+
inner.service,
|
|
302
|
+
new MemoryGovernor(CONFIG, h.deps),
|
|
303
|
+
);
|
|
304
|
+
const texts = Array.from({ length: 20 }, (_, i) => `chunk ${i}`);
|
|
305
|
+
|
|
306
|
+
const vectors = await service.embed(texts);
|
|
307
|
+
|
|
308
|
+
assert.equal(inner.batches[0], CONFIG.minBatchSize);
|
|
309
|
+
assert.equal(
|
|
310
|
+
inner.batches.reduce((sum, size) => sum + size, 0),
|
|
311
|
+
texts.length,
|
|
312
|
+
);
|
|
313
|
+
assert.equal(vectors.length, texts.length);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("never sends more than the configured maximum in one call", async () => {
|
|
317
|
+
const inner = embedderRecording();
|
|
318
|
+
const h = harness(available(ROOMY));
|
|
319
|
+
const service = createAdaptiveEmbeddingService(
|
|
320
|
+
inner.service,
|
|
321
|
+
new MemoryGovernor(CONFIG, h.deps),
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
await service.embed(Array.from({ length: 500 }, (_, i) => `chunk ${i}`));
|
|
325
|
+
|
|
326
|
+
assert.ok(Math.max(...inner.batches) <= CONFIG.maxBatchSize);
|
|
327
|
+
assert.ok(inner.maxInFlight() <= CONFIG.maxConcurrency);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("keeps one inference in flight on a box that never has headroom", async () => {
|
|
331
|
+
const inner = embedderRecording();
|
|
332
|
+
const h = harness(available(500));
|
|
333
|
+
const service = createAdaptiveEmbeddingService(
|
|
334
|
+
inner.service,
|
|
335
|
+
new MemoryGovernor(CONFIG, h.deps),
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
await service.embed(Array.from({ length: 40 }, (_, i) => `chunk ${i}`));
|
|
339
|
+
|
|
340
|
+
assert.deepEqual(new Set(inner.batches), new Set([CONFIG.minBatchSize]));
|
|
341
|
+
assert.equal(inner.maxInFlight(), 1);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("embeds nothing, and reads nothing, for an empty list", async () => {
|
|
345
|
+
const inner = embedderRecording();
|
|
346
|
+
let readCount = 0;
|
|
347
|
+
const h = harness(() => {
|
|
348
|
+
readCount += 1;
|
|
349
|
+
return { rssBytes: 0, availableBytes: ROOMY * MB };
|
|
350
|
+
});
|
|
351
|
+
const service = createAdaptiveEmbeddingService(
|
|
352
|
+
inner.service,
|
|
353
|
+
new MemoryGovernor(CONFIG, h.deps),
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
assert.deepEqual(await service.embed([]), []);
|
|
357
|
+
assert.deepEqual(inner.batches, []);
|
|
358
|
+
assert.equal(readCount, 0);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("passes the embedding identity through, so no index is invalidated", () => {
|
|
362
|
+
const inner = embedderRecording();
|
|
363
|
+
const service = createAdaptiveEmbeddingService(
|
|
364
|
+
inner.service,
|
|
365
|
+
new MemoryGovernor(CONFIG, harness(available(ROOMY)).deps),
|
|
366
|
+
);
|
|
367
|
+
assert.equal(service.embeddingId, inner.service.embeddingId);
|
|
368
|
+
assert.equal(service.dimensions, inner.service.dimensions);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("raises a stall timeout rather than embedding past the budget", async () => {
|
|
372
|
+
const inner = embedderRecording();
|
|
373
|
+
const h = harness(available(300));
|
|
374
|
+
const service = createAdaptiveEmbeddingService(
|
|
375
|
+
inner.service,
|
|
376
|
+
new MemoryGovernor(CONFIG, h.deps),
|
|
377
|
+
CONFIG.stallMaxMs,
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
await assert.rejects(
|
|
381
|
+
() => service.embed(["one"]),
|
|
382
|
+
(error: unknown) => error instanceof MemoryStallTimeoutError,
|
|
383
|
+
);
|
|
384
|
+
assert.deepEqual(inner.batches, []);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// The throttle paces work; it never turns a fault into a quiet retry.
|
|
388
|
+
it("lets a model failure propagate", async () => {
|
|
389
|
+
const h = harness(available(ROOMY));
|
|
390
|
+
const service = createAdaptiveEmbeddingService(
|
|
391
|
+
{
|
|
392
|
+
dimensions: 3,
|
|
393
|
+
embeddingId: "broken@3",
|
|
394
|
+
embed: async () => {
|
|
395
|
+
throw new Error("model could not be loaded");
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
new MemoryGovernor(CONFIG, h.deps),
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
await assert.rejects(
|
|
402
|
+
() => service.embed(["one"]),
|
|
403
|
+
/model could not be loaded/,
|
|
404
|
+
);
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
const withEnv = (
|
|
409
|
+
overrides: Record<string, string | undefined>,
|
|
410
|
+
fn: () => void,
|
|
411
|
+
): void => {
|
|
412
|
+
const saved: Record<string, string | undefined> = {};
|
|
413
|
+
for (const key of Object.keys(overrides)) saved[key] = process.env[key];
|
|
414
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
415
|
+
if (value === undefined) delete process.env[key];
|
|
416
|
+
else process.env[key] = value;
|
|
417
|
+
}
|
|
418
|
+
try {
|
|
419
|
+
fn();
|
|
420
|
+
} finally {
|
|
421
|
+
for (const [key, value] of Object.entries(saved)) {
|
|
422
|
+
if (value === undefined) delete process.env[key];
|
|
423
|
+
else process.env[key] = value;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const UNSET = {
|
|
429
|
+
SEARCH_INDEX_EMBED_BATCH_MIN: undefined,
|
|
430
|
+
SEARCH_INDEX_EMBED_BATCH_MAX: undefined,
|
|
431
|
+
SEARCH_INDEX_EMBED_CONCURRENCY_MAX: undefined,
|
|
432
|
+
SEARCH_INDEX_EMBED_RAMP_AFTER: undefined,
|
|
433
|
+
SEARCH_INDEX_MEMORY_HEADROOM_MB: undefined,
|
|
434
|
+
SEARCH_INDEX_MEMORY_RAMP_MARGIN_MB: undefined,
|
|
435
|
+
SEARCH_INDEX_RSS_CEILING_MB: undefined,
|
|
436
|
+
SEARCH_INDEX_MEMORY_CRITICAL_MB: undefined,
|
|
437
|
+
SEARCH_INDEX_MEMORY_PAUSE_MS: undefined,
|
|
438
|
+
SEARCH_INDEX_MEMORY_STALL_MAX_MS: undefined,
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
describe("the configured thresholds", () => {
|
|
442
|
+
it("defaults to the documented floor, ceiling and thresholds", () => {
|
|
443
|
+
withEnv(UNSET, () => {
|
|
444
|
+
assert.deepEqual(readAdaptiveEmbeddingConfigFromEnv(), {
|
|
445
|
+
minBatchSize: 4,
|
|
446
|
+
maxBatchSize: 32,
|
|
447
|
+
maxConcurrency: 2,
|
|
448
|
+
rampAfterReadings: 3,
|
|
449
|
+
headroomBytes: 768 * MB,
|
|
450
|
+
rampMarginBytes: 256 * MB,
|
|
451
|
+
rssCeilingBytes: 1536 * MB,
|
|
452
|
+
criticalBytes: 384 * MB,
|
|
453
|
+
pauseMs: 2000,
|
|
454
|
+
stallMaxMs: 240_000,
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// The stall has to end before the queue redelivers the record underneath it.
|
|
460
|
+
it("gives up well inside the poller's 300 s visibility timeout", () => {
|
|
461
|
+
withEnv(UNSET, () => {
|
|
462
|
+
assert.ok(readAdaptiveEmbeddingConfigFromEnv().stallMaxMs < 300_000);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("reads megabytes from the environment", () => {
|
|
467
|
+
withEnv(
|
|
468
|
+
{
|
|
469
|
+
...UNSET,
|
|
470
|
+
SEARCH_INDEX_MEMORY_HEADROOM_MB: "2048",
|
|
471
|
+
SEARCH_INDEX_MEMORY_CRITICAL_MB: "1024",
|
|
472
|
+
SEARCH_INDEX_RSS_CEILING_MB: "4096",
|
|
473
|
+
},
|
|
474
|
+
() => {
|
|
475
|
+
const config = readAdaptiveEmbeddingConfigFromEnv();
|
|
476
|
+
assert.equal(config.headroomBytes, 2048 * MB);
|
|
477
|
+
assert.equal(config.criticalBytes, 1024 * MB);
|
|
478
|
+
assert.equal(config.rssCeilingBytes, 4096 * MB);
|
|
479
|
+
},
|
|
480
|
+
);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("refuses a critical floor at or above the ramp headroom", () => {
|
|
484
|
+
withEnv(
|
|
485
|
+
{
|
|
486
|
+
...UNSET,
|
|
487
|
+
SEARCH_INDEX_MEMORY_HEADROOM_MB: "512",
|
|
488
|
+
SEARCH_INDEX_MEMORY_CRITICAL_MB: "512",
|
|
489
|
+
},
|
|
490
|
+
() => {
|
|
491
|
+
assert.throws(
|
|
492
|
+
readAdaptiveEmbeddingConfigFromEnv,
|
|
493
|
+
/SEARCH_INDEX_MEMORY_CRITICAL_MB must be below/,
|
|
494
|
+
);
|
|
495
|
+
},
|
|
496
|
+
);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("refuses a floor batch above the ceiling", () => {
|
|
500
|
+
withEnv(
|
|
501
|
+
{
|
|
502
|
+
...UNSET,
|
|
503
|
+
SEARCH_INDEX_EMBED_BATCH_MIN: "64",
|
|
504
|
+
SEARCH_INDEX_EMBED_BATCH_MAX: "32",
|
|
505
|
+
},
|
|
506
|
+
() => {
|
|
507
|
+
assert.throws(
|
|
508
|
+
readAdaptiveEmbeddingConfigFromEnv,
|
|
509
|
+
/SEARCH_INDEX_EMBED_BATCH_MIN must not exceed/,
|
|
510
|
+
);
|
|
511
|
+
},
|
|
512
|
+
);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it("refuses a value that is not a positive integer", () => {
|
|
516
|
+
withEnv({ ...UNSET, SEARCH_INDEX_EMBED_BATCH_MAX: "0" }, () => {
|
|
517
|
+
assert.throws(
|
|
518
|
+
readAdaptiveEmbeddingConfigFromEnv,
|
|
519
|
+
/SEARCH_INDEX_EMBED_BATCH_MAX must be a positive integer/,
|
|
520
|
+
);
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
});
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import type { EmbeddingService } from "@remit/search-service";
|
|
2
|
+
import type { MemoryReader, MemoryReading } from "./memory.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How much work the worker allows itself right now: how many chunk texts go
|
|
6
|
+
* into one `embed` call, and how many such calls are in flight at once.
|
|
7
|
+
*/
|
|
8
|
+
export interface EmbeddingPlan {
|
|
9
|
+
readonly batchSize: number;
|
|
10
|
+
readonly concurrency: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface AdaptiveEmbeddingConfig {
|
|
14
|
+
/** The plan the worker starts at and sheds back to. */
|
|
15
|
+
readonly minBatchSize: number;
|
|
16
|
+
readonly maxBatchSize: number;
|
|
17
|
+
readonly maxConcurrency: number;
|
|
18
|
+
/** Shed below this much free memory on the box. */
|
|
19
|
+
readonly headroomBytes: number;
|
|
20
|
+
/** Ramp only above `headroomBytes + rampMarginBytes`. */
|
|
21
|
+
readonly rampMarginBytes: number;
|
|
22
|
+
/** Consecutive comfortable readings a ramp costs. */
|
|
23
|
+
readonly rampAfterReadings: number;
|
|
24
|
+
/** Shed at or above this much resident memory in this process. */
|
|
25
|
+
readonly rssCeilingBytes: number;
|
|
26
|
+
/** Stop and wait while free memory on the box is below this. */
|
|
27
|
+
readonly criticalBytes: number;
|
|
28
|
+
/** Wait between batches after shedding, and between reads while stopped. */
|
|
29
|
+
readonly pauseMs: number;
|
|
30
|
+
/** Give the message back to the queue after stopping this long. */
|
|
31
|
+
readonly stallMaxMs: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface GovernorLog {
|
|
35
|
+
info(message: string, fields?: Record<string, unknown>): void;
|
|
36
|
+
warn(message: string, fields?: Record<string, unknown>): void;
|
|
37
|
+
error(message: string, fields?: Record<string, unknown>): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface GovernorDeps {
|
|
41
|
+
readonly readMemory: MemoryReader;
|
|
42
|
+
readonly sleep: (ms: number) => Promise<void>;
|
|
43
|
+
readonly now: () => number;
|
|
44
|
+
readonly log: GovernorLog;
|
|
45
|
+
/**
|
|
46
|
+
* The poll loop's own liveness file. A stop happens inside a handler, which
|
|
47
|
+
* is between two receives and so between two beats — without this the
|
|
48
|
+
* container's healthcheck reads a stall as a wedged loop.
|
|
49
|
+
*/
|
|
50
|
+
readonly beat?: () => Promise<void>;
|
|
51
|
+
readonly onPlan?: (plan: EmbeddingPlan) => void;
|
|
52
|
+
readonly onStall?: () => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const MB = 1024 * 1024;
|
|
56
|
+
|
|
57
|
+
export const DEFAULT_ADAPTIVE_EMBEDDING_CONFIG: AdaptiveEmbeddingConfig = {
|
|
58
|
+
minBatchSize: 4,
|
|
59
|
+
maxBatchSize: 32,
|
|
60
|
+
maxConcurrency: 2,
|
|
61
|
+
headroomBytes: 768 * MB,
|
|
62
|
+
rampMarginBytes: 256 * MB,
|
|
63
|
+
rampAfterReadings: 3,
|
|
64
|
+
rssCeilingBytes: 1536 * MB,
|
|
65
|
+
criticalBytes: 384 * MB,
|
|
66
|
+
pauseMs: 2000,
|
|
67
|
+
stallMaxMs: 240_000,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Raised when the box stayed below the critical floor for the whole stall
|
|
72
|
+
* budget. The handler already treats any throw from an upsert as a per-message
|
|
73
|
+
* failure, which is exactly the wanted outcome: the record is reported as a
|
|
74
|
+
* batch item failure and redelivered, rather than held past the queue's
|
|
75
|
+
* visibility timeout while the process waits.
|
|
76
|
+
*/
|
|
77
|
+
export class MemoryStallTimeoutError extends Error {
|
|
78
|
+
readonly code = "ERR_SEARCH_INDEX_MEMORY_STALL";
|
|
79
|
+
constructor(waitedMs: number) {
|
|
80
|
+
super(
|
|
81
|
+
`Search index waited ${Math.round(waitedMs / 1000)}s for the box to ` +
|
|
82
|
+
"free memory and gave up; the message goes back on the queue",
|
|
83
|
+
);
|
|
84
|
+
this.name = "MemoryStallTimeoutError";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const positiveInt = (name: string, raw: string): number => {
|
|
89
|
+
const parsed = Number(raw);
|
|
90
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
91
|
+
throw new Error(`${name} must be a positive integer, got: ${raw}`);
|
|
92
|
+
}
|
|
93
|
+
return parsed;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** `fallback` is already in the target unit; `scale` converts the env value. */
|
|
97
|
+
const fromEnv = (name: string, fallback: number, scale = 1): number => {
|
|
98
|
+
const raw = process.env[name];
|
|
99
|
+
if (!raw) return fallback;
|
|
100
|
+
return positiveInt(name, raw) * scale;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Every threshold is an env var so the same image bounds itself against a 4 GB
|
|
105
|
+
* VPS and a 32 GB box without a rebuild. A configuration that cannot hold —
|
|
106
|
+
* a critical floor at or above the ramp headroom, a floor batch above the
|
|
107
|
+
* ceiling — is a startup error, not something to correct silently at runtime.
|
|
108
|
+
*/
|
|
109
|
+
export const readAdaptiveEmbeddingConfigFromEnv =
|
|
110
|
+
(): AdaptiveEmbeddingConfig => {
|
|
111
|
+
const d = DEFAULT_ADAPTIVE_EMBEDDING_CONFIG;
|
|
112
|
+
const config: AdaptiveEmbeddingConfig = {
|
|
113
|
+
minBatchSize: fromEnv("SEARCH_INDEX_EMBED_BATCH_MIN", d.minBatchSize),
|
|
114
|
+
maxBatchSize: fromEnv("SEARCH_INDEX_EMBED_BATCH_MAX", d.maxBatchSize),
|
|
115
|
+
maxConcurrency: fromEnv(
|
|
116
|
+
"SEARCH_INDEX_EMBED_CONCURRENCY_MAX",
|
|
117
|
+
d.maxConcurrency,
|
|
118
|
+
),
|
|
119
|
+
rampAfterReadings: fromEnv(
|
|
120
|
+
"SEARCH_INDEX_EMBED_RAMP_AFTER",
|
|
121
|
+
d.rampAfterReadings,
|
|
122
|
+
),
|
|
123
|
+
headroomBytes: fromEnv(
|
|
124
|
+
"SEARCH_INDEX_MEMORY_HEADROOM_MB",
|
|
125
|
+
d.headroomBytes,
|
|
126
|
+
MB,
|
|
127
|
+
),
|
|
128
|
+
rampMarginBytes: fromEnv(
|
|
129
|
+
"SEARCH_INDEX_MEMORY_RAMP_MARGIN_MB",
|
|
130
|
+
d.rampMarginBytes,
|
|
131
|
+
MB,
|
|
132
|
+
),
|
|
133
|
+
rssCeilingBytes: fromEnv(
|
|
134
|
+
"SEARCH_INDEX_RSS_CEILING_MB",
|
|
135
|
+
d.rssCeilingBytes,
|
|
136
|
+
MB,
|
|
137
|
+
),
|
|
138
|
+
criticalBytes: fromEnv(
|
|
139
|
+
"SEARCH_INDEX_MEMORY_CRITICAL_MB",
|
|
140
|
+
d.criticalBytes,
|
|
141
|
+
MB,
|
|
142
|
+
),
|
|
143
|
+
pauseMs: fromEnv("SEARCH_INDEX_MEMORY_PAUSE_MS", d.pauseMs),
|
|
144
|
+
stallMaxMs: fromEnv("SEARCH_INDEX_MEMORY_STALL_MAX_MS", d.stallMaxMs),
|
|
145
|
+
};
|
|
146
|
+
if (config.minBatchSize > config.maxBatchSize) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
"SEARCH_INDEX_EMBED_BATCH_MIN must not exceed SEARCH_INDEX_EMBED_BATCH_MAX",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (config.criticalBytes >= config.headroomBytes) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
"SEARCH_INDEX_MEMORY_CRITICAL_MB must be below SEARCH_INDEX_MEMORY_HEADROOM_MB",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return config;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
type Admission = "admitted" | "expired";
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Bounds the worker's resident memory against the box it shares, which
|
|
163
|
+
* `--max-old-space-size` cannot: the embedding model, its inference arenas and
|
|
164
|
+
* its tensors are native allocations outside V8's old space (#585).
|
|
165
|
+
*
|
|
166
|
+
* It steers on two numbers, because neither is sufficient alone. The box's
|
|
167
|
+
* `MemAvailable` says whether the rest of the stack still has room. This
|
|
168
|
+
* process's own RSS says whether the worker is the reason it does not — and it
|
|
169
|
+
* is the one a shed cannot walk back: onnxruntime sizes its CPU arena to the
|
|
170
|
+
* largest batch it has ever run and does not hand that back, so a plan that
|
|
171
|
+
* ramps on free memory alone raises a floor it can never lower. Above the RSS
|
|
172
|
+
* ceiling the worker sheds and stays shed.
|
|
173
|
+
*
|
|
174
|
+
* Ramping costs several consecutive comfortable readings and needs a margin
|
|
175
|
+
* above the shed threshold; shedding is immediate at it. Without that gap a box
|
|
176
|
+
* sitting near the threshold — the ordinary steady state — would ramp and shed
|
|
177
|
+
* on alternate batches, logging every one of them and halving throughput for
|
|
178
|
+
* nothing.
|
|
179
|
+
*
|
|
180
|
+
* Below the critical floor it stops entirely rather than pushing the host into
|
|
181
|
+
* swap, where the kernel picks its OOM victim by size and takes the backend
|
|
182
|
+
* rather than the indexer. That stop is bounded: past the budget the message
|
|
183
|
+
* goes back on the queue, because a handler that waits longer than the queue's
|
|
184
|
+
* visibility timeout has its record redelivered underneath it anyway.
|
|
185
|
+
*/
|
|
186
|
+
export class MemoryGovernor {
|
|
187
|
+
private batchSize: number;
|
|
188
|
+
private concurrency = 1;
|
|
189
|
+
private pauseBeforeNextBatch = false;
|
|
190
|
+
private comfortableReadings = 0;
|
|
191
|
+
|
|
192
|
+
constructor(
|
|
193
|
+
private readonly config: AdaptiveEmbeddingConfig,
|
|
194
|
+
private readonly deps: GovernorDeps,
|
|
195
|
+
) {
|
|
196
|
+
this.batchSize = config.minBatchSize;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
get plan(): EmbeddingPlan {
|
|
200
|
+
return { batchSize: this.batchSize, concurrency: this.concurrency };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Blocks until the box can afford the next batch, or the budget runs out. */
|
|
204
|
+
admit = async (): Promise<Admission> => {
|
|
205
|
+
let reading = this.deps.readMemory();
|
|
206
|
+
if (reading.availableBytes >= this.config.criticalBytes) {
|
|
207
|
+
if (!this.pauseBeforeNextBatch) return "admitted";
|
|
208
|
+
this.pauseBeforeNextBatch = false;
|
|
209
|
+
await this.deps.sleep(this.config.pauseMs);
|
|
210
|
+
return "admitted";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
this.deps.onStall?.();
|
|
214
|
+
this.deps.log.warn(
|
|
215
|
+
"Search index paused: the box is below the critical memory floor",
|
|
216
|
+
this.fields(reading),
|
|
217
|
+
);
|
|
218
|
+
this.reset();
|
|
219
|
+
const startedAt = this.deps.now();
|
|
220
|
+
while (reading.availableBytes < this.config.criticalBytes) {
|
|
221
|
+
const waitedMs = this.deps.now() - startedAt;
|
|
222
|
+
if (waitedMs >= this.config.stallMaxMs) {
|
|
223
|
+
this.deps.log.warn("Search index gave up waiting for memory", {
|
|
224
|
+
waitedMs,
|
|
225
|
+
...this.fields(reading),
|
|
226
|
+
});
|
|
227
|
+
return "expired";
|
|
228
|
+
}
|
|
229
|
+
await this.keepAlive();
|
|
230
|
+
await this.deps.sleep(this.config.pauseMs);
|
|
231
|
+
reading = this.deps.readMemory();
|
|
232
|
+
}
|
|
233
|
+
this.pauseBeforeNextBatch = false;
|
|
234
|
+
this.deps.log.info(
|
|
235
|
+
"Search index resumed: memory recovered",
|
|
236
|
+
this.fields(reading),
|
|
237
|
+
);
|
|
238
|
+
return "admitted";
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/** Measures what the batch just cost and moves the plan at most one step. */
|
|
242
|
+
settle = (): void => {
|
|
243
|
+
const reading = this.deps.readMemory();
|
|
244
|
+
if (this.underPressure(reading)) {
|
|
245
|
+
this.comfortableReadings = 0;
|
|
246
|
+
this.shed(reading);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (!this.roomToGrow(reading)) {
|
|
250
|
+
this.comfortableReadings = 0;
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
this.comfortableReadings += 1;
|
|
254
|
+
if (this.comfortableReadings < this.config.rampAfterReadings) return;
|
|
255
|
+
this.comfortableReadings = 0;
|
|
256
|
+
this.ramp(reading);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
private underPressure(reading: MemoryReading): boolean {
|
|
260
|
+
return (
|
|
261
|
+
reading.rssBytes >= this.config.rssCeilingBytes ||
|
|
262
|
+
reading.availableBytes < this.config.headroomBytes
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private roomToGrow(reading: MemoryReading): boolean {
|
|
267
|
+
return (
|
|
268
|
+
reading.rssBytes < this.config.rssCeilingBytes &&
|
|
269
|
+
reading.availableBytes >=
|
|
270
|
+
this.config.headroomBytes + this.config.rampMarginBytes
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// A write that fails must not take the worker down with it, for the same
|
|
275
|
+
// reason the poll loop's own beat does not: a full disk is the likeliest
|
|
276
|
+
// cause and the moment to stay up. The missed beat is itself the signal.
|
|
277
|
+
private keepAlive = async (): Promise<void> => {
|
|
278
|
+
await this.deps.beat?.().catch((error: unknown) => {
|
|
279
|
+
this.deps.log.error("Search index heartbeat write failed", {
|
|
280
|
+
error: String(error),
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
private shed(reading: MemoryReading): void {
|
|
286
|
+
// Pacing applies whenever the box is tight, including at the floor, where
|
|
287
|
+
// there is no smaller batch left to fall back to.
|
|
288
|
+
this.pauseBeforeNextBatch = true;
|
|
289
|
+
const batchSize = Math.max(
|
|
290
|
+
this.config.minBatchSize,
|
|
291
|
+
Math.floor(this.batchSize / 2),
|
|
292
|
+
);
|
|
293
|
+
if (batchSize === this.batchSize && this.concurrency === 1) return;
|
|
294
|
+
this.batchSize = batchSize;
|
|
295
|
+
this.concurrency = 1;
|
|
296
|
+
this.announce("shed", reading);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private ramp(reading: MemoryReading): void {
|
|
300
|
+
if (this.batchSize < this.config.maxBatchSize) {
|
|
301
|
+
this.batchSize = Math.min(this.config.maxBatchSize, this.batchSize * 2);
|
|
302
|
+
} else if (this.concurrency < this.config.maxConcurrency) {
|
|
303
|
+
this.concurrency += 1;
|
|
304
|
+
} else {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
this.announce("ramp", reading);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private reset(): void {
|
|
311
|
+
this.batchSize = this.config.minBatchSize;
|
|
312
|
+
this.concurrency = 1;
|
|
313
|
+
this.comfortableReadings = 0;
|
|
314
|
+
this.deps.onPlan?.(this.plan);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// One line per change of plan, never one per batch: a first index is
|
|
318
|
+
// hundreds of thousands of batches and a per-batch line is not a log.
|
|
319
|
+
private announce(decision: "ramp" | "shed", reading: MemoryReading): void {
|
|
320
|
+
this.deps.onPlan?.(this.plan);
|
|
321
|
+
this.deps.log.info(`Search index embedding ${decision}`, {
|
|
322
|
+
batchSize: this.batchSize,
|
|
323
|
+
concurrency: this.concurrency,
|
|
324
|
+
...this.fields(reading),
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private fields(reading: MemoryReading): Record<string, unknown> {
|
|
329
|
+
return {
|
|
330
|
+
availableMb: Math.round(reading.availableBytes / MB),
|
|
331
|
+
rssMb: Math.round(reading.rssBytes / MB),
|
|
332
|
+
headroomMb: Math.round(this.config.headroomBytes / MB),
|
|
333
|
+
criticalMb: Math.round(this.config.criticalBytes / MB),
|
|
334
|
+
rssCeilingMb: Math.round(this.config.rssCeilingBytes / MB),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Wraps an embedder so its work passes through the governor. The whole text
|
|
341
|
+
* list arrives as one call today (one email's chunks); this splits it into
|
|
342
|
+
* governed batches and holds only the current wave's inputs, so the model's
|
|
343
|
+
* outputs from a finished batch are unreachable before the next one starts.
|
|
344
|
+
*
|
|
345
|
+
* `dimensions` and `embeddingId` pass straight through: `embeddingId` feeds the
|
|
346
|
+
* content hash that decides what needs re-embedding, so wrapping the embedder
|
|
347
|
+
* must not invalidate an existing index.
|
|
348
|
+
*/
|
|
349
|
+
export const createAdaptiveEmbeddingService = (
|
|
350
|
+
inner: EmbeddingService,
|
|
351
|
+
governor: MemoryGovernor,
|
|
352
|
+
stallMaxMs: number = DEFAULT_ADAPTIVE_EMBEDDING_CONFIG.stallMaxMs,
|
|
353
|
+
): EmbeddingService => ({
|
|
354
|
+
dimensions: inner.dimensions,
|
|
355
|
+
embeddingId: inner.embeddingId,
|
|
356
|
+
embed: async (texts: string[]): Promise<number[][]> => {
|
|
357
|
+
const vectors: number[][] = [];
|
|
358
|
+
let next = 0;
|
|
359
|
+
while (next < texts.length) {
|
|
360
|
+
if ((await governor.admit()) === "expired") {
|
|
361
|
+
throw new MemoryStallTimeoutError(stallMaxMs);
|
|
362
|
+
}
|
|
363
|
+
const { batchSize, concurrency } = governor.plan;
|
|
364
|
+
const wave: string[][] = [];
|
|
365
|
+
while (wave.length < concurrency && next < texts.length) {
|
|
366
|
+
wave.push(texts.slice(next, next + batchSize));
|
|
367
|
+
next += batchSize;
|
|
368
|
+
}
|
|
369
|
+
const embedded = await Promise.all(
|
|
370
|
+
wave.map((batch) => inner.embed(batch)),
|
|
371
|
+
);
|
|
372
|
+
for (const batch of embedded) vectors.push(...batch);
|
|
373
|
+
governor.settle();
|
|
374
|
+
}
|
|
375
|
+
return vectors;
|
|
376
|
+
},
|
|
377
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
4
|
+
import type { SQSRecord } from "aws-lambda";
|
|
5
|
+
import { MemoryStallTimeoutError } from "./adaptive-embedder.js";
|
|
6
|
+
import { processBatch } from "./handler.js";
|
|
7
|
+
import type { Services } from "./services.js";
|
|
8
|
+
|
|
9
|
+
const MESSAGE_ID = "11111111-1111-4111-8111-111111111111";
|
|
10
|
+
const ACCOUNT_ID = "22222222-2222-4222-8222-222222222222";
|
|
11
|
+
|
|
12
|
+
const record = (): SQSRecord =>
|
|
13
|
+
({
|
|
14
|
+
messageId: "sqs-1",
|
|
15
|
+
eventSourceARN: "http://queue:9324/000000000000/remit-search-index",
|
|
16
|
+
body: JSON.stringify({
|
|
17
|
+
eventName: "INSERT",
|
|
18
|
+
entity: "Message",
|
|
19
|
+
eventID: "e1",
|
|
20
|
+
eventTimestamp: 0,
|
|
21
|
+
accountId: ACCOUNT_ID,
|
|
22
|
+
keys: { pk: "pk", sk: "sk" },
|
|
23
|
+
messageId: MESSAGE_ID,
|
|
24
|
+
}),
|
|
25
|
+
}) as unknown as SQSRecord;
|
|
26
|
+
|
|
27
|
+
const silent = {
|
|
28
|
+
info: () => {},
|
|
29
|
+
error: () => {},
|
|
30
|
+
} as unknown as Logger;
|
|
31
|
+
|
|
32
|
+
const servicesThatFail = (error: Error): Services =>
|
|
33
|
+
({
|
|
34
|
+
accountService: {
|
|
35
|
+
get: async () => ({
|
|
36
|
+
accountConfigId: "config-1",
|
|
37
|
+
deletedAt: undefined,
|
|
38
|
+
}),
|
|
39
|
+
},
|
|
40
|
+
threadMessageService: {
|
|
41
|
+
findByMessageId: async () => ({
|
|
42
|
+
accountConfigId: "config-1",
|
|
43
|
+
threadId: "thread-1",
|
|
44
|
+
mailboxId: "mailbox-1",
|
|
45
|
+
sentDate: 0,
|
|
46
|
+
isRead: false,
|
|
47
|
+
hasAttachment: false,
|
|
48
|
+
hasStars: false,
|
|
49
|
+
category: "primary",
|
|
50
|
+
}),
|
|
51
|
+
},
|
|
52
|
+
storageService: {
|
|
53
|
+
retrieveParsedBody: async () => ({ text: "hello", html: "" }),
|
|
54
|
+
},
|
|
55
|
+
searchService: {
|
|
56
|
+
prepareVectors: async () => {
|
|
57
|
+
throw error;
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
}) as unknown as Services;
|
|
61
|
+
|
|
62
|
+
describe("a message the worker cannot index right now", () => {
|
|
63
|
+
// The governor stops rather than pushing the box into swap, and gives up
|
|
64
|
+
// before the queue's visibility timeout redelivers the record underneath it
|
|
65
|
+
// (#585). Reporting the item as failed is what makes that redelivery clean:
|
|
66
|
+
// the record goes back, its siblings still index, and the retry count is the
|
|
67
|
+
// queue's rather than something this process tracks.
|
|
68
|
+
it("goes back on the queue when indexing stalls out of memory", async () => {
|
|
69
|
+
const response = await processBatch(
|
|
70
|
+
[record()],
|
|
71
|
+
servicesThatFail(new MemoryStallTimeoutError(240_000)),
|
|
72
|
+
silent,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
assert.deepEqual(response.batchItemFailures, [{ itemIdentifier: "sqs-1" }]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("reports a model failure the same way, rather than dropping the message", async () => {
|
|
79
|
+
const response = await processBatch(
|
|
80
|
+
[record()],
|
|
81
|
+
servicesThatFail(new Error("model could not be loaded")),
|
|
82
|
+
silent,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
assert.deepEqual(response.batchItemFailures, [{ itemIdentifier: "sqs-1" }]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { parseMemAvailableBytes } from "./memory.js";
|
|
4
|
+
|
|
5
|
+
const MEMINFO = [
|
|
6
|
+
"MemTotal: 3908340 kB",
|
|
7
|
+
"MemFree: 131072 kB",
|
|
8
|
+
"MemAvailable: 786432 kB",
|
|
9
|
+
"Buffers: 1024 kB",
|
|
10
|
+
].join("\n");
|
|
11
|
+
|
|
12
|
+
describe("reading what the box has left", () => {
|
|
13
|
+
it("reads MemAvailable, in bytes", () => {
|
|
14
|
+
assert.equal(parseMemAvailableBytes(MEMINFO), 786432 * 1024);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// MemFree on a box with a warm page cache reads near zero, which would stall
|
|
18
|
+
// the worker permanently; picking the wrong line must fail, not approximate.
|
|
19
|
+
it("does not settle for MemFree", () => {
|
|
20
|
+
const withoutAvailable = MEMINFO.split("\n")
|
|
21
|
+
.filter((line) => !line.startsWith("MemAvailable:"))
|
|
22
|
+
.join("\n");
|
|
23
|
+
assert.throws(
|
|
24
|
+
() => parseMemAvailableBytes(withoutAvailable),
|
|
25
|
+
/MemAvailable/,
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
});
|
package/src/memory.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the governor steers on. `rssBytes` is this process's whole resident set,
|
|
5
|
+
* which is the number that matters here: the embedding model, its inference
|
|
6
|
+
* arenas and its per-batch tensors are allocated by onnxruntime's addon, so
|
|
7
|
+
* none of them appear in the V8 heap and none are bounded by
|
|
8
|
+
* `--max-old-space-size`. `availableBytes` is the box's, not the container's —
|
|
9
|
+
* a container with no `mem_limit` reads the host's `/proc/meminfo`, which is
|
|
10
|
+
* exactly the quantity a first index must not exhaust.
|
|
11
|
+
*/
|
|
12
|
+
export interface MemoryReading {
|
|
13
|
+
rssBytes: number;
|
|
14
|
+
availableBytes: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type MemoryReader = () => MemoryReading;
|
|
18
|
+
|
|
19
|
+
const MEMINFO_PATH = "/proc/meminfo";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `MemAvailable`, not `MemFree`: free memory excludes reclaimable page cache and
|
|
23
|
+
* reads near zero on any box that has been up a while, which would stall the
|
|
24
|
+
* worker permanently. `MemAvailable` is the kernel's own estimate of what a new
|
|
25
|
+
* allocation can take without swapping.
|
|
26
|
+
*/
|
|
27
|
+
export const parseMemAvailableBytes = (meminfo: string): number => {
|
|
28
|
+
const match = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(meminfo);
|
|
29
|
+
if (!match) {
|
|
30
|
+
throw new Error(`${MEMINFO_PATH} has no MemAvailable line`);
|
|
31
|
+
}
|
|
32
|
+
return Number(match[1]) * 1024;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const readSystemMemory: MemoryReader = () => ({
|
|
36
|
+
rssBytes: process.memoryUsage().rss,
|
|
37
|
+
availableBytes: parseMemAvailableBytes(readFileSync(MEMINFO_PATH, "utf8")),
|
|
38
|
+
});
|
package/src/metrics.test.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { beforeEach, describe, it } from "node:test";
|
|
3
3
|
import { renderMetrics, resetMetrics } from "@remit/logger-lambda/metrics";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
registerAdaptiveEmbedding,
|
|
6
|
+
registerSearchIndexBacklog,
|
|
7
|
+
} from "./metrics.js";
|
|
5
8
|
|
|
6
9
|
const backlogLines = (text: string): string[] =>
|
|
7
10
|
text
|
|
@@ -45,3 +48,35 @@ describe("the search index backlog series", () => {
|
|
|
45
48
|
await assert.rejects(renderMetrics(), /database is locked/);
|
|
46
49
|
});
|
|
47
50
|
});
|
|
51
|
+
|
|
52
|
+
describe("the adaptive embedding series", () => {
|
|
53
|
+
beforeEach(() => resetMetrics());
|
|
54
|
+
|
|
55
|
+
it("reports the plan it starts at, and each change after it", async () => {
|
|
56
|
+
const metrics = registerAdaptiveEmbedding({
|
|
57
|
+
batchSize: 4,
|
|
58
|
+
concurrency: 1,
|
|
59
|
+
});
|
|
60
|
+
const start = await renderMetrics();
|
|
61
|
+
assert.match(start, /^remit_search_index_embed_batch_size 4$/m);
|
|
62
|
+
assert.match(start, /^remit_search_index_embed_concurrency 1$/m);
|
|
63
|
+
|
|
64
|
+
metrics.recordPlan({ batchSize: 16, concurrency: 2 });
|
|
65
|
+
const ramped = await renderMetrics();
|
|
66
|
+
assert.match(ramped, /^remit_search_index_embed_batch_size 16$/m);
|
|
67
|
+
assert.match(ramped, /^remit_search_index_embed_concurrency 2$/m);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("counts every stop, which is what a slow first index is diagnosed from", async () => {
|
|
71
|
+
const metrics = registerAdaptiveEmbedding({
|
|
72
|
+
batchSize: 4,
|
|
73
|
+
concurrency: 1,
|
|
74
|
+
});
|
|
75
|
+
metrics.recordStall();
|
|
76
|
+
metrics.recordStall();
|
|
77
|
+
assert.match(
|
|
78
|
+
await renderMetrics(),
|
|
79
|
+
/^remit_search_index_memory_stalls_total 2$/m,
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
});
|
package/src/metrics.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { onScrape, registry } from "@remit/logger-lambda/metrics";
|
|
2
|
-
import { Gauge } from "prom-client";
|
|
2
|
+
import { Counter, Gauge } from "prom-client";
|
|
3
|
+
import type { EmbeddingPlan } from "./adaptive-embedder.js";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Put the search index backlog (standalone-observability D3) on this process's
|
|
@@ -29,3 +30,47 @@ export const registerSearchIndexBacklog = (
|
|
|
29
30
|
});
|
|
30
31
|
onScrape(async () => backlogRows.set(await count()));
|
|
31
32
|
};
|
|
33
|
+
|
|
34
|
+
const BATCH_SIZE = "remit_search_index_embed_batch_size";
|
|
35
|
+
const CONCURRENCY = "remit_search_index_embed_concurrency";
|
|
36
|
+
const STALLS = "remit_search_index_memory_stalls_total";
|
|
37
|
+
|
|
38
|
+
export interface AdaptiveEmbeddingMetrics {
|
|
39
|
+
recordPlan(plan: EmbeddingPlan): void;
|
|
40
|
+
recordStall(): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What the governor (#585) decided, for the same reason as the backlog above:
|
|
45
|
+
* the three series exist only in the process that computes them. Together they
|
|
46
|
+
* answer the question a slow first index raises — whether the worker is being
|
|
47
|
+
* held back by the box, and how often it had to stop outright.
|
|
48
|
+
*/
|
|
49
|
+
export const registerAdaptiveEmbedding = (
|
|
50
|
+
initial: EmbeddingPlan,
|
|
51
|
+
): AdaptiveEmbeddingMetrics => {
|
|
52
|
+
for (const name of [BATCH_SIZE, CONCURRENCY, STALLS]) {
|
|
53
|
+
registry.removeSingleMetric(name);
|
|
54
|
+
}
|
|
55
|
+
const batchSize = new Gauge({
|
|
56
|
+
name: BATCH_SIZE,
|
|
57
|
+
help: "Chunk texts the search-index worker sends to the embedder per call.",
|
|
58
|
+
registers: [registry],
|
|
59
|
+
});
|
|
60
|
+
const concurrency = new Gauge({
|
|
61
|
+
name: CONCURRENCY,
|
|
62
|
+
help: "Embedding calls the search-index worker keeps in flight at once.",
|
|
63
|
+
registers: [registry],
|
|
64
|
+
});
|
|
65
|
+
const stalls = new Counter({
|
|
66
|
+
name: STALLS,
|
|
67
|
+
help: "Times indexing stopped and waited for the box to free memory.",
|
|
68
|
+
registers: [registry],
|
|
69
|
+
});
|
|
70
|
+
const recordPlan = (plan: EmbeddingPlan): void => {
|
|
71
|
+
batchSize.set(plan.batchSize);
|
|
72
|
+
concurrency.set(plan.concurrency);
|
|
73
|
+
};
|
|
74
|
+
recordPlan(initial);
|
|
75
|
+
return { recordPlan, recordStall: () => stalls.inc() };
|
|
76
|
+
};
|
package/src/poller.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { runQueuePoller } from "@remit/sqs-client/poller";
|
|
|
3
3
|
import { env } from "expect-env";
|
|
4
4
|
import { handler } from "./index.js";
|
|
5
5
|
import { registerSearchIndexBacklog } from "./metrics.js";
|
|
6
|
+
import { getMemoryGovernor } from "./services.js";
|
|
6
7
|
import { maybeStartSqliteOutboxDrain } from "./sqlite-outbox-drain.js";
|
|
7
8
|
|
|
8
9
|
/** Production queue poller — no e2e shim exists for this queue today (the
|
|
@@ -24,6 +25,11 @@ if (drain) {
|
|
|
24
25
|
registerSearchIndexBacklog(() => drain.countBacklog());
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
// The memory governor (#585) is built here, before the first message: a
|
|
29
|
+
// threshold the operator typed wrong must fail the container rather than one
|
|
30
|
+
// email, and its gauges must be on the registry before the first scrape.
|
|
31
|
+
getMemoryGovernor();
|
|
32
|
+
|
|
27
33
|
// /metrics and nothing else, on the compose network (D2). Started before the
|
|
28
34
|
// poll loop, which blocks until shutdown.
|
|
29
35
|
startMetricsServer();
|
package/src/services.ts
CHANGED
|
@@ -1,15 +1,30 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
+
import { createLogger } from "@remit/logger-lambda";
|
|
3
|
+
import {
|
|
4
|
+
createSearchService,
|
|
5
|
+
type EmbeddingService,
|
|
6
|
+
type SearchService,
|
|
7
|
+
} from "@remit/search-service";
|
|
2
8
|
import {
|
|
3
9
|
buildEmbeddingServiceFromEnv,
|
|
4
10
|
buildVectorStoreFromEnv,
|
|
5
11
|
} from "@remit/search-service/from-env";
|
|
12
|
+
import { createHeartbeat } from "@remit/sqs-client/heartbeat";
|
|
6
13
|
import type { StorageService } from "@remit/storage-service";
|
|
7
14
|
import { createStorageService } from "@remit/storage-service/s3";
|
|
15
|
+
import {
|
|
16
|
+
type AdaptiveEmbeddingConfig,
|
|
17
|
+
createAdaptiveEmbeddingService,
|
|
18
|
+
MemoryGovernor,
|
|
19
|
+
readAdaptiveEmbeddingConfigFromEnv,
|
|
20
|
+
} from "./adaptive-embedder.js";
|
|
8
21
|
import {
|
|
9
22
|
buildDataPortsFromEnv,
|
|
10
23
|
type SearchIndexDataPorts,
|
|
11
24
|
} from "./data-ports.js";
|
|
12
25
|
import type { IndexOutcome } from "./handler.js";
|
|
26
|
+
import { readSystemMemory } from "./memory.js";
|
|
27
|
+
import { registerAdaptiveEmbedding } from "./metrics.js";
|
|
13
28
|
|
|
14
29
|
export interface Services {
|
|
15
30
|
accountService: SearchIndexDataPorts["account"];
|
|
@@ -27,6 +42,79 @@ export interface Services {
|
|
|
27
42
|
|
|
28
43
|
let cached: Services | undefined;
|
|
29
44
|
|
|
45
|
+
const MB = 1024 * 1024;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The stall's heartbeat writes the poll loop's own file, not a second one. A
|
|
49
|
+
* file of its own would be fresh only while stalling and stale the rest of the
|
|
50
|
+
* time, and the healthcheck reads the oldest file it finds — an idle worker
|
|
51
|
+
* would report unhealthy. Undefined off the standalone deployment, where
|
|
52
|
+
* `createHeartbeat` writes nothing anyway.
|
|
53
|
+
*/
|
|
54
|
+
const searchIndexQueueName = (): string | undefined => {
|
|
55
|
+
const url = process.env.SQS_QUEUE_URL_SEARCH_INDEX;
|
|
56
|
+
if (!url) return undefined;
|
|
57
|
+
return new URL(url).pathname.split("/").pop();
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
let governor: MemoryGovernor | undefined;
|
|
61
|
+
let governorConfig: AdaptiveEmbeddingConfig | undefined;
|
|
62
|
+
let governorResolved = false;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Built at startup rather than on the first message, so a threshold the
|
|
66
|
+
* operator typed wrong fails the container instead of one email, and so the
|
|
67
|
+
* gauges render from the first scrape instead of from whenever mail arrives.
|
|
68
|
+
*
|
|
69
|
+
* Only the in-process embedder gets one: its memory is native and unbounded —
|
|
70
|
+
* the model, its arenas and its tensors are onnxruntime allocations that no V8
|
|
71
|
+
* heap ceiling covers (#585). Bedrock and the deterministic test embedder hold
|
|
72
|
+
* nothing on this box and are left alone.
|
|
73
|
+
*/
|
|
74
|
+
export const getMemoryGovernor = (): MemoryGovernor | undefined => {
|
|
75
|
+
if (governorResolved) return governor;
|
|
76
|
+
governorResolved = true;
|
|
77
|
+
if (process.env.SEARCH_EMBEDDING_PROVIDER !== "local") return undefined;
|
|
78
|
+
|
|
79
|
+
const config = readAdaptiveEmbeddingConfigFromEnv();
|
|
80
|
+
const metrics = registerAdaptiveEmbedding({
|
|
81
|
+
batchSize: config.minBatchSize,
|
|
82
|
+
concurrency: 1,
|
|
83
|
+
});
|
|
84
|
+
const log = createLogger();
|
|
85
|
+
log.info("Search index embedding governed by memory", {
|
|
86
|
+
batchSize: config.minBatchSize,
|
|
87
|
+
maxBatchSize: config.maxBatchSize,
|
|
88
|
+
maxConcurrency: config.maxConcurrency,
|
|
89
|
+
headroomMb: Math.round(config.headroomBytes / MB),
|
|
90
|
+
criticalMb: Math.round(config.criticalBytes / MB),
|
|
91
|
+
rssCeilingMb: Math.round(config.rssCeilingBytes / MB),
|
|
92
|
+
stallMaxMs: config.stallMaxMs,
|
|
93
|
+
});
|
|
94
|
+
const queueName = searchIndexQueueName();
|
|
95
|
+
governorConfig = config;
|
|
96
|
+
governor = new MemoryGovernor(config, {
|
|
97
|
+
readMemory: readSystemMemory,
|
|
98
|
+
sleep: (ms) => delay(ms),
|
|
99
|
+
now: () => Date.now(),
|
|
100
|
+
log,
|
|
101
|
+
beat: queueName ? createHeartbeat(queueName) : undefined,
|
|
102
|
+
onPlan: metrics.recordPlan,
|
|
103
|
+
onStall: metrics.recordStall,
|
|
104
|
+
});
|
|
105
|
+
return governor;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const governed = (embedder: EmbeddingService): EmbeddingService => {
|
|
109
|
+
const memoryGovernor = getMemoryGovernor();
|
|
110
|
+
if (!memoryGovernor) return embedder;
|
|
111
|
+
return createAdaptiveEmbeddingService(
|
|
112
|
+
embedder,
|
|
113
|
+
memoryGovernor,
|
|
114
|
+
governorConfig?.stallMaxMs,
|
|
115
|
+
);
|
|
116
|
+
};
|
|
117
|
+
|
|
30
118
|
export const getServices = async (): Promise<Services> => {
|
|
31
119
|
if (cached) return cached;
|
|
32
120
|
|
|
@@ -50,7 +138,7 @@ export const getServices = async (): Promise<Services> => {
|
|
|
50
138
|
|
|
51
139
|
// Build the embedder first so we can pass its dimension count to the
|
|
52
140
|
// sqlite-vec store — the vec0 table dimension must match the embedder.
|
|
53
|
-
const embedder = buildEmbeddingServiceFromEnv();
|
|
141
|
+
const embedder = governed(buildEmbeddingServiceFromEnv());
|
|
54
142
|
const searchService = createSearchService({
|
|
55
143
|
store: buildVectorStoreFromEnv(embedder.dimensions),
|
|
56
144
|
embedder,
|