@remit/search-index-worker 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,54 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import type { RunningConsumer } from "./shutdown.js";
4
+ import { runShutdown } from "./shutdown.js";
5
+
6
+ const nextTick = (): Promise<void> =>
7
+ new Promise((resolve) => setImmediate(resolve));
8
+
9
+ describe("runShutdown", () => {
10
+ test("force-exits with code 1 when stop() never settles", async () => {
11
+ const hanging: RunningConsumer = {
12
+ stop: () => new Promise<void>(() => {}),
13
+ };
14
+ const codes: number[] = [];
15
+
16
+ runShutdown(hanging, {
17
+ timeoutMs: 5,
18
+ exit: (code) => codes.push(code),
19
+ });
20
+
21
+ await new Promise((resolve) => setTimeout(resolve, 25));
22
+ assert.deepStrictEqual(codes, [1], "timeout forces a code-1 exit");
23
+ });
24
+
25
+ test("exits 0 when stop() resolves before the deadline", async () => {
26
+ const clean: RunningConsumer = { stop: async () => {} };
27
+ const codes: number[] = [];
28
+
29
+ runShutdown(clean, { timeoutMs: 10_000, exit: (code) => codes.push(code) });
30
+
31
+ await nextTick();
32
+ assert.deepStrictEqual(codes, [0], "clean stop exits 0");
33
+ });
34
+
35
+ test("exits 1 and reports when stop() rejects", async () => {
36
+ const failing: RunningConsumer = {
37
+ stop: async () => {
38
+ throw new Error("consumer stop failed");
39
+ },
40
+ };
41
+ const codes: number[] = [];
42
+ const errors: unknown[] = [];
43
+
44
+ runShutdown(failing, {
45
+ timeoutMs: 10_000,
46
+ exit: (code) => codes.push(code),
47
+ onError: (error) => errors.push(error),
48
+ });
49
+
50
+ await nextTick();
51
+ assert.deepStrictEqual(codes, [1], "a failed stop exits 1");
52
+ assert.equal(errors.length, 1);
53
+ });
54
+ });
@@ -0,0 +1,39 @@
1
+ export interface RunningConsumer {
2
+ stop(): Promise<void>;
3
+ }
4
+
5
+ export interface ShutdownOptions {
6
+ timeoutMs: number;
7
+ exit: (code: number) => void;
8
+ onError?: (error: unknown) => void;
9
+ }
10
+
11
+ /**
12
+ * Race a clean `consumer.stop()` against a hard deadline, then exit regardless.
13
+ *
14
+ * `stop()` awaits the SQS long-poll loop; under a saturated event loop that can
15
+ * hang. A shutdown that never exits leaves the process ignoring SIGTERM and
16
+ * orphaning to init still burning CPU (issue #1171), so the deadline
17
+ * force-exits even when `stop()` never settles.
18
+ */
19
+ export const runShutdown = (
20
+ consumer: RunningConsumer,
21
+ options: ShutdownOptions,
22
+ ): void => {
23
+ const { timeoutMs, exit, onError } = options;
24
+
25
+ const forceExit = setTimeout(() => exit(1), timeoutMs);
26
+ forceExit.unref();
27
+
28
+ consumer
29
+ .stop()
30
+ .then(() => {
31
+ clearTimeout(forceExit);
32
+ exit(0);
33
+ })
34
+ .catch((error: unknown) => {
35
+ clearTimeout(forceExit);
36
+ onError?.(error);
37
+ exit(1);
38
+ });
39
+ };
@@ -0,0 +1,90 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import type { SendMessageCommand } from "@aws-sdk/client-sqs";
4
+ import { OutboxRelay } from "@remit/outbox-relay";
5
+ import Database from "better-sqlite3";
6
+ import { SqliteOutboxStore } from "./sqlite-outbox-drain.js";
7
+
8
+ // The SQLite half of the outbox drain (RFC 036 D2): the raw row access against
9
+ // the shared file. The relay/enqueue logic on top of it is the shared
10
+ // OutboxRelay, already covered in remit-outbox-relay; here a real SQLite outbox
11
+ // table proves the SQL — read undrained rows, mark exactly the drained ids.
12
+
13
+ const makeOutboxDb = () => {
14
+ const db = new Database(":memory:");
15
+ db.exec(`CREATE TABLE outbox (
16
+ id TEXT PRIMARY KEY,
17
+ message_id TEXT NOT NULL,
18
+ event TEXT NOT NULL,
19
+ payload TEXT NOT NULL,
20
+ created_at INTEGER NOT NULL,
21
+ processed_at INTEGER
22
+ )`);
23
+ return db;
24
+ };
25
+
26
+ const insertRow = (
27
+ db: Database.Database,
28
+ id: string,
29
+ messageId: string,
30
+ event: string,
31
+ ) =>
32
+ db
33
+ .prepare(
34
+ `INSERT INTO outbox (id, message_id, event, payload, created_at)
35
+ VALUES (?, ?, ?, ?, ?)`,
36
+ )
37
+ .run(id, messageId, event, JSON.stringify({ messageId }), Date.now());
38
+
39
+ const fakeSqs = (sent: string[]) =>
40
+ ({
41
+ send: async (cmd: SendMessageCommand) => {
42
+ sent.push(String(cmd.input.MessageBody));
43
+ return {};
44
+ },
45
+ }) as unknown as ConstructorParameters<typeof OutboxRelay>[0]["sqs"];
46
+
47
+ describe("SqliteOutboxStore", () => {
48
+ test("drains unprocessed rows and marks them processed", async () => {
49
+ const db = makeOutboxDb();
50
+ insertRow(db, "r1", "m1", "message.body_synced");
51
+ insertRow(db, "r2", "m2", "message.moved");
52
+ insertRow(db, "r3", "m3", "message.removed");
53
+
54
+ const sent: string[] = [];
55
+ const relay = new OutboxRelay({
56
+ store: new SqliteOutboxStore(db as unknown as never),
57
+ sqs: fakeSqs(sent),
58
+ queueUrl: "q",
59
+ });
60
+
61
+ const count = await relay.drainPending();
62
+ assert.equal(count, 3);
63
+ assert.equal(sent.length, 3);
64
+
65
+ const stillPending = db
66
+ .prepare("SELECT count(*) AS n FROM outbox WHERE processed_at IS NULL")
67
+ .get() as { n: number };
68
+ assert.equal(stillPending.n, 0, "every relayed row is marked processed");
69
+
70
+ // A second pass has nothing left to do.
71
+ assert.equal(await relay.drainPending(), 0);
72
+ db.close();
73
+ });
74
+
75
+ test("a row appended after the id capture is not swallowed", async () => {
76
+ const db = makeOutboxDb();
77
+ insertRow(db, "r1", "m1", "message.body_synced");
78
+ const store = new SqliteOutboxStore(db as unknown as never);
79
+
80
+ // Capture the pending ids for m1 (mirrors the relay's pre-send capture),
81
+ // then a second row for the same message+event lands before marking.
82
+ const captured = await store.listPendingRowIds("m1", "message.body_synced");
83
+ insertRow(db, "r2", "m1", "message.body_synced");
84
+ await store.markRowsProcessed(captured);
85
+
86
+ const pending = await store.listPendingRowIds("m1", "message.body_synced");
87
+ assert.deepEqual(pending, ["r2"], "the mid-flight row stays pending");
88
+ db.close();
89
+ });
90
+ });
@@ -0,0 +1,161 @@
1
+ import type { Logger } from "@remit/logger-lambda";
2
+ import {
3
+ createSqsClient,
4
+ DRAIN_EVENTS,
5
+ isForceEvent,
6
+ isRemoveEvent,
7
+ OutboxRelay,
8
+ type OutboxStore,
9
+ type PendingIndexEvent,
10
+ } from "@remit/outbox-relay";
11
+
12
+ // The SQLite outbox wake (RFC 036 D2). SQLite has no cross-process NOTIFY, so
13
+ // the durable outbox drain that backstops Postgres becomes the primary
14
+ // mechanism here: a 2-second poll inside the search-index-worker — already
15
+ // resident, already the queue's consuming side — relays undrained rows onto the
16
+ // search-index queue. Against a local file with the outbox's partial index on
17
+ // unprocessed rows the query is microseconds, and a file has none of the
18
+ // scale-to-zero concern that forbade constant polling on Postgres.
19
+ //
20
+ // This process opens its own connection for the drain: it issues only single
21
+ // UPDATE statements in autocommit (no transactions), so it never opens a
22
+ // savepoint and needs no in-process write serialization — cross-process safety
23
+ // with the writers is WAL plus busy_timeout, the same as every other writer.
24
+
25
+ const DRAIN_INTERVAL_MS = 2_000;
26
+
27
+ // A minimal view of the better-sqlite3 surface used here, so the module carries
28
+ // no static type dependency on the native package (imported dynamically to stay
29
+ // out of the Lambda bundle).
30
+ interface SqliteStatement {
31
+ all(...params: unknown[]): unknown[];
32
+ run(...params: unknown[]): unknown;
33
+ }
34
+ interface SqliteDatabase {
35
+ pragma(source: string): unknown;
36
+ prepare(sql: string): SqliteStatement;
37
+ close(): void;
38
+ }
39
+
40
+ export class SqliteOutboxStore implements OutboxStore {
41
+ constructor(private readonly db: SqliteDatabase) {}
42
+
43
+ async listUnprocessedEvents(): Promise<PendingIndexEvent[]> {
44
+ const placeholders = DRAIN_EVENTS.map(() => "?").join(", ");
45
+ const rows = this.db
46
+ .prepare(
47
+ `SELECT DISTINCT message_id, event FROM outbox
48
+ WHERE event IN (${placeholders}) AND processed_at IS NULL`,
49
+ )
50
+ .all(...DRAIN_EVENTS) as Array<{ message_id: string; event: string }>;
51
+ return rows.map((row) => ({
52
+ messageId: row.message_id,
53
+ event: row.event,
54
+ force: isForceEvent(row.event),
55
+ remove: isRemoveEvent(row.event),
56
+ }));
57
+ }
58
+
59
+ async listPendingRowIds(messageId: string, event: string): Promise<string[]> {
60
+ const rows = this.db
61
+ .prepare(
62
+ `SELECT id FROM outbox
63
+ WHERE message_id = ? AND event = ? AND processed_at IS NULL`,
64
+ )
65
+ .all(messageId, event) as Array<{ id: string }>;
66
+ return rows.map((row) => row.id);
67
+ }
68
+
69
+ async markRowsProcessed(ids: string[]): Promise<void> {
70
+ if (ids.length === 0) return;
71
+ const placeholders = ids.map(() => "?").join(", ");
72
+ this.db
73
+ .prepare(
74
+ `UPDATE outbox SET processed_at = ?
75
+ WHERE id IN (${placeholders}) AND processed_at IS NULL`,
76
+ )
77
+ .run(Date.now(), ...ids);
78
+ }
79
+ }
80
+
81
+ export interface RunningDrain {
82
+ stop(): Promise<void>;
83
+ }
84
+
85
+ export interface SqliteOutboxDrainConfig {
86
+ dbPath?: string;
87
+ queueUrl?: string;
88
+ logger: Logger;
89
+ intervalMs?: number;
90
+ }
91
+
92
+ /**
93
+ * Start the SQLite outbox drain: a boot drain to catch anything pending while
94
+ * the worker was down, then a poll every `intervalMs` (default 2 s). Overlapping
95
+ * ticks are skipped so a slow drain never stacks; the timer is unref'd so it
96
+ * never keeps the process alive on its own.
97
+ */
98
+ export const startSqliteOutboxDrain = async (
99
+ config: SqliteOutboxDrainConfig,
100
+ ): Promise<RunningDrain> => {
101
+ const dbPath = config.dbPath ?? process.env.SQLITE_DB_PATH;
102
+ if (!dbPath) throw new Error("SQLITE_DB_PATH is required");
103
+ const queueUrl = config.queueUrl ?? process.env.SQS_QUEUE_URL_SEARCH_INDEX;
104
+ if (!queueUrl) throw new Error("SQS_QUEUE_URL_SEARCH_INDEX is required");
105
+ const log = config.logger;
106
+
107
+ const { default: Database } = await import("better-sqlite3");
108
+ const sqlite = new Database(dbPath) as unknown as SqliteDatabase;
109
+ sqlite.pragma("journal_mode = WAL");
110
+ sqlite.pragma("busy_timeout = 5000");
111
+ sqlite.pragma("synchronous = NORMAL");
112
+
113
+ const sqs = createSqsClient(queueUrl);
114
+ const relay = new OutboxRelay({
115
+ store: new SqliteOutboxStore(sqlite),
116
+ sqs,
117
+ queueUrl,
118
+ });
119
+
120
+ let draining = false;
121
+ let inFlight: Promise<unknown> = Promise.resolve();
122
+ const drainOnce = (): void => {
123
+ if (draining) return;
124
+ draining = true;
125
+ inFlight = relay
126
+ .drainPending()
127
+ .catch((error: unknown) =>
128
+ log.error("sqlite outbox drain failed", { error: String(error) }),
129
+ )
130
+ .finally(() => {
131
+ draining = false;
132
+ });
133
+ };
134
+
135
+ const bootCount = await relay.drainPending();
136
+ log.info("sqlite outbox boot drain enqueued", { count: bootCount });
137
+
138
+ const timer = setInterval(drainOnce, config.intervalMs ?? DRAIN_INTERVAL_MS);
139
+ timer.unref();
140
+
141
+ return {
142
+ stop: async () => {
143
+ clearInterval(timer);
144
+ await inFlight;
145
+ sqs.destroy();
146
+ sqlite.close();
147
+ },
148
+ };
149
+ };
150
+
151
+ /**
152
+ * Start the drain only on the SQLite backend; a no-op returning `undefined` on
153
+ * every other backend, so the shared poller entrypoint can call it
154
+ * unconditionally.
155
+ */
156
+ export const maybeStartSqliteOutboxDrain = async (
157
+ logger: Logger,
158
+ ): Promise<RunningDrain | undefined> => {
159
+ if (process.env.DATA_BACKEND !== "sqlite") return undefined;
160
+ return startSqliteOutboxDrain({ logger });
161
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src/**/*.ts"]
8
+ }