@remit/search-index-worker 0.0.23 → 0.0.25
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 +142 -10
- package/src/adaptive-embedder.ts +57 -16
- package/src/handler.test.ts +1 -1
- package/src/outbox-drain-coverage.test.ts +138 -0
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
3
|
import type { EmbeddingService } from "@remit/search-service";
|
|
4
|
+
import { DEFAULT_VISIBILITY_TIMEOUT_SECONDS } from "@remit/sqs-client/poller";
|
|
4
5
|
import {
|
|
5
6
|
type AdaptiveEmbeddingConfig,
|
|
6
7
|
createAdaptiveEmbeddingService,
|
|
@@ -53,10 +54,28 @@ const reads = (script: readonly Reading[]): MemoryReader => {
|
|
|
53
54
|
const available = (...availableMb: number[]): MemoryReader =>
|
|
54
55
|
reads(availableMb.map((mb) => ({ availableMb: mb })));
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* A box that sits below the critical floor, frees memory for exactly one
|
|
59
|
+
* reading, and dips again — the intermittent shape, as against a sustained
|
|
60
|
+
* stall. `lowReads` sets how long each dip lasts.
|
|
61
|
+
*/
|
|
62
|
+
const recoveringBox = (lowReads: number): MemoryReader => {
|
|
63
|
+
let lowLeft = lowReads;
|
|
64
|
+
return () => {
|
|
65
|
+
const low = lowLeft > 0;
|
|
66
|
+
lowLeft = low ? lowLeft - 1 : lowReads;
|
|
67
|
+
return {
|
|
68
|
+
availableBytes: (low ? 300 : ROOMY) * MB,
|
|
69
|
+
rssBytes: 512 * MB,
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
56
74
|
class Harness {
|
|
57
75
|
readonly plans: EmbeddingPlan[] = [];
|
|
58
76
|
readonly sleeps: number[] = [];
|
|
59
77
|
readonly lines: string[] = [];
|
|
78
|
+
readonly logged: { message: string; fields?: Record<string, unknown> }[] = [];
|
|
60
79
|
stalls = 0;
|
|
61
80
|
beats = 0;
|
|
62
81
|
clock = 0;
|
|
@@ -64,8 +83,9 @@ class Harness {
|
|
|
64
83
|
readonly deps: GovernorDeps;
|
|
65
84
|
|
|
66
85
|
constructor(readMemory: MemoryReader) {
|
|
67
|
-
const record = (message: string) => {
|
|
86
|
+
const record = (message: string, fields?: Record<string, unknown>) => {
|
|
68
87
|
this.lines.push(message);
|
|
88
|
+
this.logged.push({ message, fields });
|
|
69
89
|
};
|
|
70
90
|
this.deps = {
|
|
71
91
|
readMemory,
|
|
@@ -123,6 +143,26 @@ const embedderRecording = (): {
|
|
|
123
143
|
};
|
|
124
144
|
};
|
|
125
145
|
|
|
146
|
+
/** An embedder whose work advances the fake clock, so waves spend the budget. */
|
|
147
|
+
const embedderCosting = (
|
|
148
|
+
h: Harness,
|
|
149
|
+
costMs: number,
|
|
150
|
+
): { service: EmbeddingService; batches: number[] } => {
|
|
151
|
+
const batches: number[] = [];
|
|
152
|
+
return {
|
|
153
|
+
batches,
|
|
154
|
+
service: {
|
|
155
|
+
dimensions: 3,
|
|
156
|
+
embeddingId: "fake@3",
|
|
157
|
+
embed: async (texts: string[]) => {
|
|
158
|
+
h.clock += costMs;
|
|
159
|
+
batches.push(texts.length);
|
|
160
|
+
return texts.map(() => [0, 0, 0]);
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
|
|
126
166
|
describe("the memory governor", () => {
|
|
127
167
|
it("starts at the floor: the smallest batch, one inference", () => {
|
|
128
168
|
const governor = new MemoryGovernor(CONFIG, harness(available(ROOMY)).deps);
|
|
@@ -223,10 +263,16 @@ describe("the memory governor", () => {
|
|
|
223
263
|
const governor = new MemoryGovernor(CONFIG, h.deps);
|
|
224
264
|
settleTimes(governor, CONFIG.rampAfterReadings + 1);
|
|
225
265
|
|
|
226
|
-
assert.equal(
|
|
266
|
+
assert.equal(
|
|
267
|
+
(await governor.admit(governor.stallDeadline())).status,
|
|
268
|
+
"admitted",
|
|
269
|
+
);
|
|
227
270
|
assert.deepEqual(h.sleeps, [CONFIG.pauseMs]);
|
|
228
271
|
// The pause is per shed, not sticky: an admit that follows no shed runs on.
|
|
229
|
-
assert.equal(
|
|
272
|
+
assert.equal(
|
|
273
|
+
(await governor.admit(governor.stallDeadline())).status,
|
|
274
|
+
"admitted",
|
|
275
|
+
);
|
|
230
276
|
assert.deepEqual(h.sleeps, [CONFIG.pauseMs]);
|
|
231
277
|
});
|
|
232
278
|
|
|
@@ -236,7 +282,10 @@ describe("the memory governor", () => {
|
|
|
236
282
|
governor.settle();
|
|
237
283
|
assert.deepEqual(governor.plan, { batchSize: 2, concurrency: 1 });
|
|
238
284
|
assert.deepEqual(h.lines, []);
|
|
239
|
-
assert.equal(
|
|
285
|
+
assert.equal(
|
|
286
|
+
(await governor.admit(governor.stallDeadline())).status,
|
|
287
|
+
"admitted",
|
|
288
|
+
);
|
|
240
289
|
assert.deepEqual(h.sleeps, [CONFIG.pauseMs]);
|
|
241
290
|
});
|
|
242
291
|
|
|
@@ -246,7 +295,10 @@ describe("the memory governor", () => {
|
|
|
246
295
|
settleTimes(governor, CONFIG.rampAfterReadings);
|
|
247
296
|
assert.deepEqual(governor.plan, { batchSize: 4, concurrency: 1 });
|
|
248
297
|
|
|
249
|
-
assert.equal(
|
|
298
|
+
assert.equal(
|
|
299
|
+
(await governor.admit(governor.stallDeadline())).status,
|
|
300
|
+
"admitted",
|
|
301
|
+
);
|
|
250
302
|
assert.equal(h.stalls, 1);
|
|
251
303
|
assert.equal(h.sleeps.length, 2);
|
|
252
304
|
// A stop is the loudest signal the worker has, and it comes back at the
|
|
@@ -262,7 +314,10 @@ describe("the memory governor", () => {
|
|
|
262
314
|
const h = harness(available(300));
|
|
263
315
|
const governor = new MemoryGovernor(CONFIG, h.deps);
|
|
264
316
|
|
|
265
|
-
assert.equal(
|
|
317
|
+
assert.equal(
|
|
318
|
+
(await governor.admit(governor.stallDeadline())).status,
|
|
319
|
+
"expired",
|
|
320
|
+
);
|
|
266
321
|
assert.equal(h.clock, CONFIG.stallMaxMs);
|
|
267
322
|
assert.match(h.lines.at(-1) ?? "", /gave up/);
|
|
268
323
|
});
|
|
@@ -271,7 +326,7 @@ describe("the memory governor", () => {
|
|
|
271
326
|
const h = harness(available(300));
|
|
272
327
|
const governor = new MemoryGovernor(CONFIG, h.deps);
|
|
273
328
|
|
|
274
|
-
await governor.admit();
|
|
329
|
+
await governor.admit(governor.stallDeadline());
|
|
275
330
|
assert.equal(h.beats, CONFIG.stallMaxMs / CONFIG.pauseMs);
|
|
276
331
|
});
|
|
277
332
|
|
|
@@ -280,14 +335,20 @@ describe("the memory governor", () => {
|
|
|
280
335
|
h.beatFails = true;
|
|
281
336
|
const governor = new MemoryGovernor(CONFIG, h.deps);
|
|
282
337
|
|
|
283
|
-
assert.equal(
|
|
338
|
+
assert.equal(
|
|
339
|
+
(await governor.admit(governor.stallDeadline())).status,
|
|
340
|
+
"expired",
|
|
341
|
+
);
|
|
284
342
|
assert.ok(h.lines.some((line) => /heartbeat/.test(line)));
|
|
285
343
|
});
|
|
286
344
|
|
|
287
345
|
it("does not stall while the box is merely tight", async () => {
|
|
288
346
|
const h = harness(available(500));
|
|
289
347
|
const governor = new MemoryGovernor(CONFIG, h.deps);
|
|
290
|
-
assert.equal(
|
|
348
|
+
assert.equal(
|
|
349
|
+
(await governor.admit(governor.stallDeadline())).status,
|
|
350
|
+
"admitted",
|
|
351
|
+
);
|
|
291
352
|
assert.equal(h.stalls, 0);
|
|
292
353
|
assert.deepEqual(h.sleeps, []);
|
|
293
354
|
});
|
|
@@ -384,6 +445,55 @@ describe("the governed embedder", () => {
|
|
|
384
445
|
assert.deepEqual(inner.batches, []);
|
|
385
446
|
});
|
|
386
447
|
|
|
448
|
+
// The budget bounds the handler, not one wave of it. A box that recovers just
|
|
449
|
+
// inside it on every wave held the record for waves × budget, well past the
|
|
450
|
+
// queue's visibility timeout, and reported no failure.
|
|
451
|
+
it("holds the budget across waves when memory recovers between them", async () => {
|
|
452
|
+
const inner = embedderRecording();
|
|
453
|
+
const h = harness(recoveringBox(CONFIG.stallMaxMs / CONFIG.pauseMs - 1));
|
|
454
|
+
const service = createAdaptiveEmbeddingService(
|
|
455
|
+
inner.service,
|
|
456
|
+
new MemoryGovernor(CONFIG, h.deps),
|
|
457
|
+
CONFIG.stallMaxMs,
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
await assert.rejects(
|
|
461
|
+
() => service.embed(Array.from({ length: 20 }, (_, i) => `chunk ${i}`)),
|
|
462
|
+
(error: unknown) => error instanceof MemoryStallTimeoutError,
|
|
463
|
+
);
|
|
464
|
+
// Memory did come back and work did get through, so this is the
|
|
465
|
+
// intermittent stall rather than the sustained one.
|
|
466
|
+
assert.ok(h.stalls > 1);
|
|
467
|
+
assert.ok(inner.batches.length > 0);
|
|
468
|
+
assert.ok(h.clock <= CONFIG.stallMaxMs + CONFIG.pauseMs);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// Waves spend the same budget the stall does, so a message can fail on a dip
|
|
472
|
+
// that arrives late and is short. What it reports has to be the dip it
|
|
473
|
+
// measured, not the budget: the two are no longer the same number.
|
|
474
|
+
it("spends the budget on the waves themselves, and says what it waited", async () => {
|
|
475
|
+
const h = harness(available(ROOMY, ROOMY, ROOMY, ROOMY, ROOMY, ROOMY, 300));
|
|
476
|
+
const inner = embedderCosting(h, 30);
|
|
477
|
+
const service = createAdaptiveEmbeddingService(
|
|
478
|
+
inner.service,
|
|
479
|
+
new MemoryGovernor(CONFIG, h.deps),
|
|
480
|
+
CONFIG.stallMaxMs,
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
await assert.rejects(
|
|
484
|
+
() => service.embed(Array.from({ length: 20 }, (_, i) => `chunk ${i}`)),
|
|
485
|
+
(error: unknown) =>
|
|
486
|
+
error instanceof MemoryStallTimeoutError &&
|
|
487
|
+
error.waitedMs === CONFIG.pauseMs &&
|
|
488
|
+
/ran out of its/.test(error.message),
|
|
489
|
+
);
|
|
490
|
+
assert.equal(inner.batches.length, 3);
|
|
491
|
+
assert.equal(h.clock, CONFIG.stallMaxMs);
|
|
492
|
+
assert.equal(h.stalls, 1);
|
|
493
|
+
const gaveUp = h.logged.find((line) => /gave up/.test(line.message));
|
|
494
|
+
assert.equal(gaveUp?.fields?.waitedMs, CONFIG.pauseMs);
|
|
495
|
+
});
|
|
496
|
+
|
|
387
497
|
// The throttle paces work; it never turns a fault into a quiet retry.
|
|
388
498
|
it("lets a model failure propagate", async () => {
|
|
389
499
|
const h = harness(available(ROOMY));
|
|
@@ -459,7 +569,10 @@ describe("the configured thresholds", () => {
|
|
|
459
569
|
// The stall has to end before the queue redelivers the record underneath it.
|
|
460
570
|
it("gives up well inside the poller's 300 s visibility timeout", () => {
|
|
461
571
|
withEnv(UNSET, () => {
|
|
462
|
-
assert.ok(
|
|
572
|
+
assert.ok(
|
|
573
|
+
readAdaptiveEmbeddingConfigFromEnv().stallMaxMs <
|
|
574
|
+
DEFAULT_VISIBILITY_TIMEOUT_SECONDS * 1000,
|
|
575
|
+
);
|
|
463
576
|
});
|
|
464
577
|
});
|
|
465
578
|
|
|
@@ -512,6 +625,25 @@ describe("the configured thresholds", () => {
|
|
|
512
625
|
);
|
|
513
626
|
});
|
|
514
627
|
|
|
628
|
+
// A budget that reaches the visibility timeout has the record redelivered
|
|
629
|
+
// underneath the handler still holding it: the redelivery it exists to avoid.
|
|
630
|
+
it("refuses a stall budget at or above the visibility timeout", () => {
|
|
631
|
+
withEnv(
|
|
632
|
+
{
|
|
633
|
+
...UNSET,
|
|
634
|
+
SEARCH_INDEX_MEMORY_STALL_MAX_MS: String(
|
|
635
|
+
DEFAULT_VISIBILITY_TIMEOUT_SECONDS * 1000,
|
|
636
|
+
),
|
|
637
|
+
},
|
|
638
|
+
() => {
|
|
639
|
+
assert.throws(
|
|
640
|
+
readAdaptiveEmbeddingConfigFromEnv,
|
|
641
|
+
/SEARCH_INDEX_MEMORY_STALL_MAX_MS must be below/,
|
|
642
|
+
);
|
|
643
|
+
},
|
|
644
|
+
);
|
|
645
|
+
});
|
|
646
|
+
|
|
515
647
|
it("refuses a value that is not a positive integer", () => {
|
|
516
648
|
withEnv({ ...UNSET, SEARCH_INDEX_EMBED_BATCH_MAX: "0" }, () => {
|
|
517
649
|
assert.throws(
|
package/src/adaptive-embedder.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { EmbeddingService } from "@remit/search-service";
|
|
2
|
+
import { DEFAULT_VISIBILITY_TIMEOUT_SECONDS } from "@remit/sqs-client/poller";
|
|
2
3
|
import type { MemoryReader, MemoryReading } from "./memory.js";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -76,10 +77,14 @@ export const DEFAULT_ADAPTIVE_EMBEDDING_CONFIG: AdaptiveEmbeddingConfig = {
|
|
|
76
77
|
*/
|
|
77
78
|
export class MemoryStallTimeoutError extends Error {
|
|
78
79
|
readonly code = "ERR_SEARCH_INDEX_MEMORY_STALL";
|
|
79
|
-
constructor(
|
|
80
|
+
constructor(
|
|
81
|
+
stallMaxMs: number,
|
|
82
|
+
readonly waitedMs: number,
|
|
83
|
+
) {
|
|
80
84
|
super(
|
|
81
|
-
`Search index
|
|
82
|
-
|
|
85
|
+
`Search index ran out of its ${Math.round(stallMaxMs / 1000)}s budget ` +
|
|
86
|
+
`for this message with the box below the memory floor for the last ` +
|
|
87
|
+
`${Math.round(waitedMs / 1000)}s; the message goes back on the queue`,
|
|
83
88
|
);
|
|
84
89
|
this.name = "MemoryStallTimeoutError";
|
|
85
90
|
}
|
|
@@ -100,11 +105,21 @@ const fromEnv = (name: string, fallback: number, scale = 1): number => {
|
|
|
100
105
|
return positiveInt(name, raw) * scale;
|
|
101
106
|
};
|
|
102
107
|
|
|
108
|
+
/**
|
|
109
|
+
* A stall budget that reaches the queue's visibility timeout has the record
|
|
110
|
+
* redelivered underneath the handler still holding it, which is the redelivery
|
|
111
|
+
* the budget exists to prevent. The poller's default is the authority: the
|
|
112
|
+
* search index queue passes no override, so what `deploy/vps/queues.json` sets
|
|
113
|
+
* on the queue never reaches this code and has to match it by hand.
|
|
114
|
+
*/
|
|
115
|
+
const VISIBILITY_TIMEOUT_MS = DEFAULT_VISIBILITY_TIMEOUT_SECONDS * 1000;
|
|
116
|
+
|
|
103
117
|
/**
|
|
104
118
|
* Every threshold is an env var so the same image bounds itself against a 4 GB
|
|
105
119
|
* VPS and a 32 GB box without a rebuild. A configuration that cannot hold —
|
|
106
120
|
* a critical floor at or above the ramp headroom, a floor batch above the
|
|
107
|
-
* ceiling
|
|
121
|
+
* ceiling, a stall budget at or above the visibility timeout — is a startup
|
|
122
|
+
* error, not something to correct silently at runtime.
|
|
108
123
|
*/
|
|
109
124
|
export const readAdaptiveEmbeddingConfigFromEnv =
|
|
110
125
|
(): AdaptiveEmbeddingConfig => {
|
|
@@ -153,10 +168,22 @@ export const readAdaptiveEmbeddingConfigFromEnv =
|
|
|
153
168
|
"SEARCH_INDEX_MEMORY_CRITICAL_MB must be below SEARCH_INDEX_MEMORY_HEADROOM_MB",
|
|
154
169
|
);
|
|
155
170
|
}
|
|
171
|
+
if (config.stallMaxMs >= VISIBILITY_TIMEOUT_MS) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`SEARCH_INDEX_MEMORY_STALL_MAX_MS must be below the queue's ${VISIBILITY_TIMEOUT_MS} ms visibility timeout`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
156
176
|
return config;
|
|
157
177
|
};
|
|
158
178
|
|
|
159
|
-
|
|
179
|
+
/**
|
|
180
|
+
* `waitedMs` is the time this stop actually spent below the floor, which the
|
|
181
|
+
* budget no longer stands in for: embedding work spends the same budget, so a
|
|
182
|
+
* message can fail on a two-second dip that arrived late.
|
|
183
|
+
*/
|
|
184
|
+
type Admission =
|
|
185
|
+
| { readonly status: "admitted" }
|
|
186
|
+
| { readonly status: "expired"; readonly waitedMs: number };
|
|
160
187
|
|
|
161
188
|
/**
|
|
162
189
|
* Bounds the worker's resident memory against the box it shares, which
|
|
@@ -181,7 +208,9 @@ type Admission = "admitted" | "expired";
|
|
|
181
208
|
* swap, where the kernel picks its OOM victim by size and takes the backend
|
|
182
209
|
* rather than the indexer. That stop is bounded: past the budget the message
|
|
183
210
|
* 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.
|
|
211
|
+
* visibility timeout has its record redelivered underneath it anyway. The
|
|
212
|
+
* budget belongs to the message, so its deadline is handed in rather than
|
|
213
|
+
* minted here — one that restarted per stop would bound no handler at all.
|
|
185
214
|
*/
|
|
186
215
|
export class MemoryGovernor {
|
|
187
216
|
private batchSize: number;
|
|
@@ -200,14 +229,17 @@ export class MemoryGovernor {
|
|
|
200
229
|
return { batchSize: this.batchSize, concurrency: this.concurrency };
|
|
201
230
|
}
|
|
202
231
|
|
|
232
|
+
/** The end of one message's stall budget, taken once per governed call. */
|
|
233
|
+
stallDeadline = (): number => this.deps.now() + this.config.stallMaxMs;
|
|
234
|
+
|
|
203
235
|
/** Blocks until the box can afford the next batch, or the budget runs out. */
|
|
204
|
-
admit = async (): Promise<Admission> => {
|
|
236
|
+
admit = async (stallDeadline: number): Promise<Admission> => {
|
|
205
237
|
let reading = this.deps.readMemory();
|
|
206
238
|
if (reading.availableBytes >= this.config.criticalBytes) {
|
|
207
|
-
if (!this.pauseBeforeNextBatch) return "admitted";
|
|
239
|
+
if (!this.pauseBeforeNextBatch) return { status: "admitted" };
|
|
208
240
|
this.pauseBeforeNextBatch = false;
|
|
209
241
|
await this.deps.sleep(this.config.pauseMs);
|
|
210
|
-
return "admitted";
|
|
242
|
+
return { status: "admitted" };
|
|
211
243
|
}
|
|
212
244
|
|
|
213
245
|
this.deps.onStall?.();
|
|
@@ -218,13 +250,15 @@ export class MemoryGovernor {
|
|
|
218
250
|
this.reset();
|
|
219
251
|
const startedAt = this.deps.now();
|
|
220
252
|
while (reading.availableBytes < this.config.criticalBytes) {
|
|
221
|
-
const
|
|
222
|
-
if (
|
|
223
|
-
|
|
253
|
+
const now = this.deps.now();
|
|
254
|
+
if (now >= stallDeadline) {
|
|
255
|
+
const waitedMs = now - startedAt;
|
|
256
|
+
this.deps.log.warn("Search index gave up: its memory budget ran out", {
|
|
224
257
|
waitedMs,
|
|
258
|
+
stallMaxMs: this.config.stallMaxMs,
|
|
225
259
|
...this.fields(reading),
|
|
226
260
|
});
|
|
227
|
-
return "expired";
|
|
261
|
+
return { status: "expired", waitedMs };
|
|
228
262
|
}
|
|
229
263
|
await this.keepAlive();
|
|
230
264
|
await this.deps.sleep(this.config.pauseMs);
|
|
@@ -235,7 +269,7 @@ export class MemoryGovernor {
|
|
|
235
269
|
"Search index resumed: memory recovered",
|
|
236
270
|
this.fields(reading),
|
|
237
271
|
);
|
|
238
|
-
return "admitted";
|
|
272
|
+
return { status: "admitted" };
|
|
239
273
|
};
|
|
240
274
|
|
|
241
275
|
/** Measures what the batch just cost and moves the plan at most one step. */
|
|
@@ -342,6 +376,11 @@ export class MemoryGovernor {
|
|
|
342
376
|
* governed batches and holds only the current wave's inputs, so the model's
|
|
343
377
|
* outputs from a finished batch are unreachable before the next one starts.
|
|
344
378
|
*
|
|
379
|
+
* The stall budget is taken once here and spans every wave, because what has to
|
|
380
|
+
* stay inside the queue's visibility timeout is the handler, not one wave of it:
|
|
381
|
+
* a box that recovers just inside the budget on each wave would otherwise hold
|
|
382
|
+
* the record for as many budgets as the email has chunks.
|
|
383
|
+
*
|
|
345
384
|
* `dimensions` and `embeddingId` pass straight through: `embeddingId` feeds the
|
|
346
385
|
* content hash that decides what needs re-embedding, so wrapping the embedder
|
|
347
386
|
* must not invalidate an existing index.
|
|
@@ -355,10 +394,12 @@ export const createAdaptiveEmbeddingService = (
|
|
|
355
394
|
embeddingId: inner.embeddingId,
|
|
356
395
|
embed: async (texts: string[]): Promise<number[][]> => {
|
|
357
396
|
const vectors: number[][] = [];
|
|
397
|
+
const deadline = governor.stallDeadline();
|
|
358
398
|
let next = 0;
|
|
359
399
|
while (next < texts.length) {
|
|
360
|
-
|
|
361
|
-
|
|
400
|
+
const admission = await governor.admit(deadline);
|
|
401
|
+
if (admission.status === "expired") {
|
|
402
|
+
throw new MemoryStallTimeoutError(stallMaxMs, admission.waitedMs);
|
|
362
403
|
}
|
|
363
404
|
const { batchSize, concurrency } = governor.plan;
|
|
364
405
|
const wave: string[][] = [];
|
package/src/handler.test.ts
CHANGED
|
@@ -68,7 +68,7 @@ describe("a message the worker cannot index right now", () => {
|
|
|
68
68
|
it("goes back on the queue when indexing stalls out of memory", async () => {
|
|
69
69
|
const response = await processBatch(
|
|
70
70
|
[record()],
|
|
71
|
-
servicesThatFail(new MemoryStallTimeoutError(240_000)),
|
|
71
|
+
servicesThatFail(new MemoryStallTimeoutError(240_000, 2_000)),
|
|
72
72
|
silent,
|
|
73
73
|
);
|
|
74
74
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { describe, test } from "node:test";
|
|
4
|
+
import type { SendMessageCommand } from "@aws-sdk/client-sqs";
|
|
5
|
+
import {
|
|
6
|
+
DrizzleMessageRepository,
|
|
7
|
+
OUTBOX_EVENTS,
|
|
8
|
+
} from "@remit/drizzle-service";
|
|
9
|
+
import { createShippedSqliteDb } from "@remit/drizzle-service/test-sqlite";
|
|
10
|
+
import { OutboxRelay } from "@remit/outbox-relay";
|
|
11
|
+
import { SqliteOutboxStore } from "./sqlite-outbox-drain.js";
|
|
12
|
+
|
|
13
|
+
type SqliteHandle = ReturnType<typeof createShippedSqliteDb>["sqlite"];
|
|
14
|
+
|
|
15
|
+
// The outbox invariant (reader#1063): every event kind a producer writes has a
|
|
16
|
+
// consumer that drains it. `message.created` was written once per message and
|
|
17
|
+
// drained by nothing, so a live instance carried 30,906 rows that could never
|
|
18
|
+
// clear. This runs the real producers (the message repo) and the real consumer
|
|
19
|
+
// (the relay over the SQLite store) against the shipped migrations, so a kind
|
|
20
|
+
// with no drain shows up as a row that stays undrained.
|
|
21
|
+
//
|
|
22
|
+
// What holds the invariant is the union `OUTBOX_EVENTS`, not this file: the
|
|
23
|
+
// column's type is what stops a producer in another package inventing a kind,
|
|
24
|
+
// and the compiler is what reports it. This covers the kinds the union declares
|
|
25
|
+
// and the producers in this repo's message repo — a producer elsewhere reaches
|
|
26
|
+
// it only by adding its kind to the union first.
|
|
27
|
+
|
|
28
|
+
const fakeSqs = (sent: string[]) =>
|
|
29
|
+
({
|
|
30
|
+
send: async (cmd: SendMessageCommand) => {
|
|
31
|
+
sent.push(String(cmd.input.MessageBody));
|
|
32
|
+
return {};
|
|
33
|
+
},
|
|
34
|
+
}) as unknown as ConstructorParameters<typeof OutboxRelay>[0]["sqs"];
|
|
35
|
+
|
|
36
|
+
const drainAll = async (sqlite: SqliteHandle): Promise<void> => {
|
|
37
|
+
const relay = new OutboxRelay({
|
|
38
|
+
store: new SqliteOutboxStore(sqlite as unknown as never),
|
|
39
|
+
sqs: fakeSqs([]),
|
|
40
|
+
queueUrl: "q",
|
|
41
|
+
});
|
|
42
|
+
while ((await relay.drainPending()) > 0) {
|
|
43
|
+
// Drain reads a bounded batch; repeat until the table is quiet.
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const undrainedEvents = (sqlite: SqliteHandle): string[] =>
|
|
48
|
+
(
|
|
49
|
+
sqlite
|
|
50
|
+
.prepare(
|
|
51
|
+
"SELECT DISTINCT event FROM outbox WHERE processed_at IS NULL ORDER BY event",
|
|
52
|
+
)
|
|
53
|
+
.all() as Array<{ event: string }>
|
|
54
|
+
).map((row) => row.event);
|
|
55
|
+
|
|
56
|
+
const writtenEvents = (sqlite: SqliteHandle): string[] =>
|
|
57
|
+
(
|
|
58
|
+
sqlite
|
|
59
|
+
.prepare("SELECT DISTINCT event FROM outbox ORDER BY event")
|
|
60
|
+
.all() as Array<{ event: string }>
|
|
61
|
+
).map((row) => row.event);
|
|
62
|
+
|
|
63
|
+
const NOW = 1700000000000;
|
|
64
|
+
const MAILBOX_ID = "00000000-0000-0000-4444-000000000001";
|
|
65
|
+
const DEST_MAILBOX_ID = "00000000-0000-0000-4444-000000000002";
|
|
66
|
+
|
|
67
|
+
const createInput = (messageId: string) => ({
|
|
68
|
+
messageId,
|
|
69
|
+
mailboxId: MAILBOX_ID,
|
|
70
|
+
uid: 1,
|
|
71
|
+
sequenceNumber: 1,
|
|
72
|
+
rfc822Size: 512,
|
|
73
|
+
internalDate: NOW,
|
|
74
|
+
envelopeId: randomUUID(),
|
|
75
|
+
rootBodyPartId: randomUUID(),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("outbox drain coverage", () => {
|
|
79
|
+
test("every event the message repo writes is drained", async () => {
|
|
80
|
+
const { db, sqlite, close } = createShippedSqliteDb();
|
|
81
|
+
const repo = new DrizzleMessageRepository(
|
|
82
|
+
db as unknown as ConstructorParameters<
|
|
83
|
+
typeof DrizzleMessageRepository
|
|
84
|
+
>[0],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const bodySyncedId = randomUUID();
|
|
88
|
+
await repo.create(createInput(bodySyncedId));
|
|
89
|
+
await repo.update(bodySyncedId, { bodyStorageKey: "body/1.json" });
|
|
90
|
+
|
|
91
|
+
const movedId = randomUUID();
|
|
92
|
+
await repo.create(createInput(movedId));
|
|
93
|
+
await repo.updateUid(movedId, 99, DEST_MAILBOX_ID);
|
|
94
|
+
|
|
95
|
+
const removedId = randomUUID();
|
|
96
|
+
await repo.create(createInput(removedId));
|
|
97
|
+
await repo.delete(removedId);
|
|
98
|
+
|
|
99
|
+
assert.deepEqual(
|
|
100
|
+
writtenEvents(sqlite),
|
|
101
|
+
[...OUTBOX_EVENTS].sort(),
|
|
102
|
+
"the producers write exactly the declared outbox vocabulary",
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
await drainAll(sqlite);
|
|
106
|
+
|
|
107
|
+
assert.deepEqual(
|
|
108
|
+
undrainedEvents(sqlite),
|
|
109
|
+
[],
|
|
110
|
+
"a kind left undrained here grows the outbox forever",
|
|
111
|
+
);
|
|
112
|
+
close();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("every kind the union declares is drained", async () => {
|
|
116
|
+
const { sqlite, close } = createShippedSqliteDb();
|
|
117
|
+
|
|
118
|
+
for (const event of OUTBOX_EVENTS) {
|
|
119
|
+
sqlite
|
|
120
|
+
.prepare(
|
|
121
|
+
`INSERT INTO outbox (id, message_id, event, payload, created_at)
|
|
122
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
123
|
+
)
|
|
124
|
+
.run(
|
|
125
|
+
randomUUID(),
|
|
126
|
+
`m-${event}`,
|
|
127
|
+
event,
|
|
128
|
+
JSON.stringify({ messageId: `m-${event}` }),
|
|
129
|
+
Date.now(),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
await drainAll(sqlite);
|
|
134
|
+
|
|
135
|
+
assert.deepEqual(undrainedEvents(sqlite), []);
|
|
136
|
+
close();
|
|
137
|
+
});
|
|
138
|
+
});
|