@remit/search-index-worker 0.0.18 → 0.0.19
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 +1 -1
- package/src/consumer.ts +0 -154
- package/src/index-stats.test.ts +0 -56
- package/src/index-stats.ts +0 -62
- package/src/run-worker.ts +0 -26
- package/src/shutdown.test.ts +0 -54
- package/src/shutdown.ts +0 -39
package/package.json
CHANGED
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
|
-
};
|
package/src/index-stats.test.ts
DELETED
|
@@ -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
|
-
});
|
package/src/index-stats.ts
DELETED
|
@@ -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();
|
package/src/shutdown.test.ts
DELETED
|
@@ -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
|
-
};
|