@remit/search-index-worker 0.0.18 → 0.0.20

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.18",
3
+ "version": "0.0.20",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -20,7 +20,6 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@remit/outbox-relay": "*",
23
- "p-map": "*",
24
23
  "@remit/logger-lambda": "*",
25
24
  "@remit/sqs-client": "*",
26
25
  "@remit/data-ports": "*",
@@ -34,11 +33,7 @@
34
33
  "prom-client": "^15.1.3"
35
34
  },
36
35
  "devDependencies": {
37
- "@aws-sdk/client-dynamodb": "*",
38
- "@aws-sdk/core": "*",
39
- "@aws-sdk/lib-dynamodb": "*",
40
- "tsx": "*",
41
- "zod": "*"
36
+ "tsx": "*"
42
37
  },
43
38
  "license": "MIT",
44
39
  "publishConfig": {
package/src/consumer.ts DELETED
@@ -1,154 +0,0 @@
1
- import {
2
- DeleteMessageCommand,
3
- ReceiveMessageCommand,
4
- type ReceiveMessageCommandOutput,
5
- type SQSClient,
6
- } from "@aws-sdk/client-sqs";
7
- import type { Logger } from "@remit/logger-lambda";
8
- import { createQueueProducer } from "@remit/sqs-client/producer";
9
- import type { SQSRecord } from "aws-lambda";
10
- import { type IndexOutcome, processBatch } from "./handler.js";
11
- import { createIndexWorkStats, type IndexWorkStats } from "./index-stats.js";
12
- import { parseQueueMessage } from "./parse.js";
13
- import type { Services } from "./services.js";
14
- import type { RunningConsumer } from "./shutdown.js";
15
-
16
- // `processBatch` takes a real `SQSRecord[]` (the Lambda event shape); the
17
- // long-poll `ReceiveMessageCommand` result carries the same message id/body
18
- // plus SDK-specific fields, so this fills in the rest with inert placeholders
19
- // — `processBatch` only reads `record.body` and `record.messageId`.
20
- const toSqsRecord = (
21
- sqsMessage: NonNullable<ReceiveMessageCommandOutput["Messages"]>[number],
22
- ): SQSRecord =>
23
- ({
24
- messageId: sqsMessage.MessageId ?? sqsMessage.ReceiptHandle ?? "",
25
- body: sqsMessage.Body ?? "",
26
- receiptHandle: sqsMessage.ReceiptHandle ?? "",
27
- attributes: {} as SQSRecord["attributes"],
28
- messageAttributes: {},
29
- md5OfBody: sqsMessage.MD5OfBody ?? "",
30
- eventSource: "aws:sqs",
31
- eventSourceARN: "",
32
- awsRegion: "",
33
- }) satisfies SQSRecord;
34
-
35
- const createSqsClient = (queueUrl: string): SQSClient =>
36
- createQueueProducer({
37
- queueUrl,
38
- localCredentials: { accessKeyId: "local", secretAccessKey: "local" },
39
- });
40
-
41
- export interface SqsConsumerConfig {
42
- /** Search-index queue URL; defaults to `SQS_QUEUE_URL_SEARCH_INDEX`. */
43
- queueUrl?: string;
44
- services: Services;
45
- logger: Logger;
46
- }
47
-
48
- /**
49
- * Long-polls the search-index SQS queue and processes one message at a time
50
- * via `processBatch` (a batch of one) — the same per-message logic the AWS
51
- * Lambda handler runs, reused so the two deployment shapes (Lambda event
52
- * source mapping vs. a long-running container/pm2 process) share one
53
- * indexing implementation. Used by the self-host stack, where the search-index
54
- * queue has no Lambda event source: the outbox drain relays committed events
55
- * onto this queue (the producer side) and this consumer takes them off it.
56
- *
57
- * A message is deleted only when `processBatch` reports no failure for it;
58
- * a failure leaves it on the queue so its visibility timeout lapses and SQS
59
- * redelivers (and eventually dead-letters) it — SQS owns the retry, same as
60
- * the Lambda path's `batchItemFailures`.
61
- *
62
- * Every outcome is fed to `IndexWorkStats` (via `Services.onIndexOutcome`)
63
- * and flushed on a lazy interval — the pg-only "index work summary" signal
64
- * that surfaces over-triggering (#1082) CloudWatch alarms can't see.
65
- */
66
- export const startSqsConsumer = (
67
- config: SqsConsumerConfig,
68
- ): RunningConsumer => {
69
- const { logger: log } = config;
70
- const queueUrl = config.queueUrl ?? process.env.SQS_QUEUE_URL_SEARCH_INDEX;
71
- if (!queueUrl) throw new Error("SQS_QUEUE_URL_SEARCH_INDEX is required");
72
-
73
- const sqs = createSqsClient(queueUrl);
74
-
75
- const stats: IndexWorkStats = createIndexWorkStats();
76
- const flushStats = (): void => {
77
- const summary = stats.drain();
78
- if (summary) log.info("index work summary", { ...summary });
79
- };
80
- const SUMMARY_INTERVAL_MS = 60_000;
81
- const summaryTimer = setInterval(flushStats, SUMMARY_INTERVAL_MS);
82
- summaryTimer.unref();
83
-
84
- const controller = new AbortController();
85
- const consume = async (): Promise<void> => {
86
- while (!controller.signal.aborted) {
87
- let response: ReceiveMessageCommandOutput;
88
- try {
89
- response = await sqs.send(
90
- new ReceiveMessageCommand({
91
- QueueUrl: queueUrl,
92
- MaxNumberOfMessages: 10,
93
- WaitTimeSeconds: 20,
94
- VisibilityTimeout: 300,
95
- }),
96
- { abortSignal: controller.signal },
97
- );
98
- } catch (error) {
99
- if (controller.signal.aborted) return;
100
- throw error;
101
- }
102
-
103
- for (const sqsMessage of response.Messages ?? []) {
104
- if (!sqsMessage.Body || !sqsMessage.ReceiptHandle) continue;
105
-
106
- const body = sqsMessage.Body;
107
- const parsed = await Promise.resolve()
108
- .then(() => parseQueueMessage(body))
109
- .catch((error: unknown) => {
110
- log.error("parse failed", { body, error: String(error) });
111
- return null;
112
- });
113
- if (!parsed) continue;
114
- const force = parsed.kind === "upsert" ? parsed.force : false;
115
-
116
- let lastOutcome: IndexOutcome | undefined;
117
- const services: Services = {
118
- ...config.services,
119
- onIndexOutcome: (outcome) => {
120
- lastOutcome = outcome;
121
- },
122
- };
123
-
124
- const { batchItemFailures } = await processBatch(
125
- [toSqsRecord(sqsMessage)],
126
- services,
127
- log,
128
- );
129
-
130
- if (lastOutcome) stats.record(lastOutcome, force);
131
-
132
- if (batchItemFailures.length === 0) {
133
- await sqs.send(
134
- new DeleteMessageCommand({
135
- QueueUrl: queueUrl,
136
- ReceiptHandle: sqsMessage.ReceiptHandle,
137
- }),
138
- );
139
- }
140
- }
141
- }
142
- };
143
- const consumer = consume();
144
-
145
- return {
146
- stop: async () => {
147
- clearInterval(summaryTimer);
148
- flushStats();
149
- controller.abort();
150
- await consumer;
151
- sqs.destroy();
152
- },
153
- };
154
- };
@@ -1,56 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, test } from "node:test";
3
- import { createIndexWorkStats } from "./index-stats.js";
4
-
5
- describe("createIndexWorkStats", () => {
6
- test("counts an upserted:0 indexed outcome as noop (the over-trigger signal)", () => {
7
- const stats = createIndexWorkStats();
8
- stats.record({ status: "indexed", upserted: 0, skipped: 4 }, false);
9
- stats.record({ status: "indexed", upserted: 2, skipped: 1 }, false);
10
- assert.deepStrictEqual(stats.drain(), {
11
- processed: 2,
12
- embedded: 1,
13
- noop: 1,
14
- deferred: 0,
15
- dropped: 0,
16
- forced: 0,
17
- });
18
- });
19
-
20
- test("separates transient (deferred) from terminal (dropped) skips", () => {
21
- const stats = createIndexWorkStats();
22
- stats.record(
23
- { status: "skipped", reason: "parsed-body-not-found", retryable: true },
24
- false,
25
- );
26
- stats.record(
27
- { status: "skipped", reason: "no-indexable-content", retryable: false },
28
- false,
29
- );
30
- assert.deepStrictEqual(stats.drain(), {
31
- processed: 2,
32
- embedded: 0,
33
- noop: 0,
34
- deferred: 1,
35
- dropped: 1,
36
- forced: 0,
37
- });
38
- });
39
-
40
- test("counts force re-indexes (moves) separately", () => {
41
- const stats = createIndexWorkStats();
42
- stats.record({ status: "indexed", upserted: 3, skipped: 0 }, true);
43
- stats.record({ status: "indexed", upserted: 1, skipped: 0 }, false);
44
- const summary = stats.drain();
45
- assert.equal(summary?.forced, 1);
46
- assert.equal(summary?.processed, 2);
47
- });
48
-
49
- test("drain resets the window and returns null when empty", () => {
50
- const stats = createIndexWorkStats();
51
- assert.equal(stats.drain(), null, "nothing recorded yet");
52
- stats.record({ status: "indexed", upserted: 1, skipped: 0 }, false);
53
- assert.ok(stats.drain());
54
- assert.equal(stats.drain(), null, "drained window is empty again");
55
- });
56
- });
@@ -1,62 +0,0 @@
1
- import type { IndexOutcome } from "./handler.js";
2
-
3
- /**
4
- * A window of index outcomes. `noop` is the proxy metric for over-triggering:
5
- * an indexed message that embedded nothing (`upserted: 0`) means an event fired
6
- * for content already in the store. A high noop rate is the signal that error
7
- * and DLQ alarms can't see — "too much successful work" — and is what let the
8
- * AWS search-index cost blowup ramp unnoticed (#1082). Pg-only: the Lambda path
9
- * never wires `Services.onIndexOutcome`, so this only accumulates in the
10
- * long-running Postgres consumer (`consumer.ts`).
11
- */
12
- export interface IndexWorkSummary {
13
- processed: number;
14
- /** Indexed and wrote vectors — real work. */
15
- embedded: number;
16
- /** Indexed but wrote nothing (`upserted: 0`) — an event for unchanged content. */
17
- noop: number;
18
- /** Transient skip (thread/body not visible yet); left undrained for retry. */
19
- deferred: number;
20
- /** Terminal skip; drained, will never index. */
21
- dropped: number;
22
- /** Of the above, how many were force re-indexes (moves) — always re-embedded. */
23
- forced: number;
24
- }
25
-
26
- export interface IndexWorkStats {
27
- record(outcome: IndexOutcome, force: boolean): void;
28
- /** Return the accumulated window and reset it; null if nothing was recorded. */
29
- drain(): IndexWorkSummary | null;
30
- }
31
-
32
- const empty = (): IndexWorkSummary => ({
33
- processed: 0,
34
- embedded: 0,
35
- noop: 0,
36
- deferred: 0,
37
- dropped: 0,
38
- forced: 0,
39
- });
40
-
41
- export const createIndexWorkStats = (): IndexWorkStats => {
42
- let window = empty();
43
- return {
44
- record: (outcome, force) => {
45
- window.processed += 1;
46
- if (force) window.forced += 1;
47
- if (outcome.status === "indexed") {
48
- if (outcome.upserted > 0) window.embedded += 1;
49
- else window.noop += 1;
50
- return;
51
- }
52
- if (outcome.retryable) window.deferred += 1;
53
- else window.dropped += 1;
54
- },
55
- drain: () => {
56
- if (window.processed === 0) return null;
57
- const summary = window;
58
- window = empty();
59
- return summary;
60
- },
61
- };
62
- };
package/src/run-worker.ts DELETED
@@ -1,26 +0,0 @@
1
- import { createLogger } from "@remit/logger-lambda";
2
- import { startSqsConsumer } from "./consumer.js";
3
- import { getServices } from "./services.js";
4
- import { runShutdown } from "./shutdown.js";
5
-
6
- const SHUTDOWN_TIMEOUT_MS = 10_000;
7
- const log = createLogger();
8
-
9
- const main = async (): Promise<void> => {
10
- const services = await getServices();
11
- const consumer = startSqsConsumer({ services, logger: log });
12
-
13
- const shutdown = (): void =>
14
- runShutdown(consumer, {
15
- timeoutMs: SHUTDOWN_TIMEOUT_MS,
16
- exit: (code) => process.exit(code),
17
- onError: (error) =>
18
- log.error("shutdown failed", { error: String(error) }),
19
- });
20
- process.on("SIGINT", shutdown);
21
- process.on("SIGTERM", shutdown);
22
-
23
- log.info("search-index-worker consumer started");
24
- };
25
-
26
- await main();
@@ -1,54 +0,0 @@
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 DELETED
@@ -1,39 +0,0 @@
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
- };