@remit/search-index-worker 0.0.22 → 0.0.23

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.23",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
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) => ({