@remit/search-index-worker 0.0.22 → 0.0.24

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/search-index-worker",
3
- "version": "0.0.22",
3
+ "version": "0.0.24",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -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(await governor.admit(), "admitted");
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(await governor.admit(), "admitted");
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(await governor.admit(), "admitted");
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(await governor.admit(), "admitted");
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(await governor.admit(), "expired");
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(await governor.admit(), "expired");
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(await governor.admit(), "admitted");
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(readAdaptiveEmbeddingConfigFromEnv().stallMaxMs < 300_000);
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(
@@ -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(waitedMs: number) {
80
+ constructor(
81
+ stallMaxMs: number,
82
+ readonly waitedMs: number,
83
+ ) {
80
84
  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",
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 is a startup error, not something to correct silently at runtime.
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
- type Admission = "admitted" | "expired";
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 waitedMs = this.deps.now() - startedAt;
222
- if (waitedMs >= this.config.stallMaxMs) {
223
- this.deps.log.warn("Search index gave up waiting for memory", {
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
- if ((await governor.admit()) === "expired") {
361
- throw new MemoryStallTimeoutError(stallMaxMs);
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[][] = [];
@@ -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
 
package/src/services.ts CHANGED
@@ -8,6 +8,8 @@ import {
8
8
  import {
9
9
  buildEmbeddingServiceFromEnv,
10
10
  buildVectorStoreFromEnv,
11
+ EMBEDDING_PROVIDER_OFF,
12
+ readEmbeddingProviderFromEnv,
11
13
  } from "@remit/search-service/from-env";
12
14
  import { createHeartbeat } from "@remit/sqs-client/heartbeat";
13
15
  import type { StorageService } from "@remit/storage-service";
@@ -74,7 +76,7 @@ let governorResolved = false;
74
76
  export const getMemoryGovernor = (): MemoryGovernor | undefined => {
75
77
  if (governorResolved) return governor;
76
78
  governorResolved = true;
77
- if (process.env.SEARCH_EMBEDDING_PROVIDER !== "local") return undefined;
79
+ if (readEmbeddingProviderFromEnv() !== "local") return undefined;
78
80
 
79
81
  const config = readAdaptiveEmbeddingConfigFromEnv();
80
82
  const metrics = registerAdaptiveEmbedding({
@@ -118,6 +120,19 @@ const governed = (embedder: EmbeddingService): EmbeddingService => {
118
120
  export const getServices = async (): Promise<Services> => {
119
121
  if (cached) return cached;
120
122
 
123
+ // This process exists to embed. `off` is the self-host default and holds the
124
+ // container down behind the `semantic` compose profile
125
+ // (deploy/vps/docker-compose.sqlite.yml), so reaching here with it set means
126
+ // the worker was started against a deployment that asked for no embedding:
127
+ // every message it took off the queue would fail one at a time, forever. Say
128
+ // so once, at startup, and name the command that settles it.
129
+ if (readEmbeddingProviderFromEnv() === EMBEDDING_PROVIDER_OFF) {
130
+ throw new Error(
131
+ "SEARCH_EMBEDDING_PROVIDER is off, so there is nothing for this worker to embed. " +
132
+ "Turn semantic search on with 'remit semantic on', or leave this service down.",
133
+ );
134
+ }
135
+
121
136
  const dataPorts = await buildDataPortsFromEnv();
122
137
 
123
138
  const storageService = createStorageService();
@@ -113,3 +113,38 @@ describe("SqliteOutboxStore", () => {
113
113
  db.close();
114
114
  });
115
115
  });
116
+
117
+ // `remit semantic on` against an existing mailbox makes the first drain pass the
118
+ // whole back catalogue, and the pass re-runs every 2 s. The read is bounded so
119
+ // one tick relays a batch rather than tens of thousands of rows; what it does not
120
+ // take stays unprocessed and is what the next tick selects.
121
+ describe("SqliteOutboxStore, bounded", () => {
122
+ test("reads at most one batch, and the rest on the next pass", async () => {
123
+ const db = makeOutboxDb();
124
+ const total = 620;
125
+ for (let i = 0; i < total; i++) {
126
+ insertRow(db, `r${i}`, `m${i}`, "message.body_synced");
127
+ }
128
+ const store = new SqliteOutboxStore(db as unknown as never);
129
+
130
+ const first = await store.listUnprocessedEvents();
131
+ assert.equal(first.length, 500);
132
+
133
+ const sent: string[] = [];
134
+ const relay = new OutboxRelay({
135
+ store,
136
+ sqs: fakeSqs(sent),
137
+ queueUrl: "q",
138
+ });
139
+
140
+ assert.equal(await relay.drainPending(), 500);
141
+ assert.equal(await relay.drainPending(), total - 500);
142
+ assert.equal(await relay.drainPending(), 0);
143
+
144
+ const stillPending = db
145
+ .prepare("SELECT count(*) AS n FROM outbox WHERE processed_at IS NULL")
146
+ .get() as { n: number };
147
+ assert.equal(stillPending.n, 0, "every row was relayed, none skipped");
148
+ db.close();
149
+ });
150
+ });
@@ -24,6 +24,13 @@ import {
24
24
 
25
25
  const DRAIN_INTERVAL_MS = 2_000;
26
26
 
27
+ // How many distinct messages one drain pass relays. `remit semantic on` on an
28
+ // existing mailbox makes the first pass the whole back catalogue — tens of
29
+ // thousands of rows read, enqueued and marked in one tick — so the pass is
30
+ // bounded and the next tick, 2 s later, takes the following batch. Nothing is
31
+ // dropped: unmarked rows stay unprocessed and are what the next pass selects.
32
+ const DRAIN_BATCH_SIZE = 500;
33
+
27
34
  // A minimal view of the better-sqlite3 surface used here, so the module carries
28
35
  // no static type dependency on the native package (imported dynamically to stay
29
36
  // out of the Lambda bundle).
@@ -46,7 +53,9 @@ export class SqliteOutboxStore implements OutboxStore {
46
53
  const rows = this.db
47
54
  .prepare(
48
55
  `SELECT DISTINCT message_id, event FROM outbox
49
- WHERE event IN (${placeholders}) AND processed_at IS NULL`,
56
+ WHERE event IN (${placeholders}) AND processed_at IS NULL
57
+ ORDER BY message_id
58
+ LIMIT ${DRAIN_BATCH_SIZE}`,
50
59
  )
51
60
  .all(...DRAIN_EVENTS) as Array<{ message_id: string; event: string }>;
52
61
  return rows.map((row) => ({