@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.
- package/package.json +50 -0
- package/src/consumer.ts +163 -0
- package/src/data-ports.test.ts +96 -0
- package/src/data-ports.ts +142 -0
- package/src/handler.ts +285 -0
- package/src/index-stats.test.ts +56 -0
- package/src/index-stats.ts +62 -0
- package/src/index.ts +2 -0
- package/src/parse.ts +19 -0
- package/src/poller.ts +31 -0
- package/src/reindex.ts +63 -0
- package/src/run-reindex.ts +22 -0
- package/src/run-worker.ts +26 -0
- package/src/search-index-message.ts +1 -0
- package/src/services.ts +81 -0
- package/src/shutdown.test.ts +54 -0
- package/src/shutdown.ts +39 -0
- package/src/sqlite-outbox-drain.test.ts +90 -0
- package/src/sqlite-outbox-drain.ts +161 -0
- package/tsconfig.json +8 -0
|
@@ -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
|
+
});
|
package/src/shutdown.ts
ADDED
|
@@ -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
|
+
};
|