@remit/search-index-worker 0.0.11 → 0.0.13

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.11",
3
+ "version": "0.0.13",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -34,7 +34,8 @@
34
34
  "drizzle-orm": "^0.45.2",
35
35
  "expect-env": "*",
36
36
  "@types/aws-lambda": "*",
37
- "@types/pg": "^8.0.0"
37
+ "@types/pg": "^8.0.0",
38
+ "prom-client": "^15.1.3"
38
39
  },
39
40
  "devDependencies": {
40
41
  "@aws-sdk/client-dynamodb": "*",
package/src/handler.ts CHANGED
@@ -3,8 +3,8 @@ import { NotFoundError } from "@remit/data-ports/errors";
3
3
  import {
4
4
  createLogger,
5
5
  type Logger,
6
- MetricUnit,
7
- metrics,
6
+ queueNameFromEventSource,
7
+ recordQueueEvent,
8
8
  withTelemetry,
9
9
  } from "@remit/logger-lambda";
10
10
  import type { VectorRecord } from "@remit/search-service";
@@ -38,27 +38,28 @@ export const processBatch = async (
38
38
  log: Logger,
39
39
  ): Promise<SQSBatchResponse> => {
40
40
  const batchItemFailures: { itemIdentifier: string }[] = [];
41
- const processingStart = Date.now();
42
41
 
43
42
  for (const record of records) {
44
43
  const message = parseQueueMessage(record.body);
44
+ const start = Date.now();
45
45
 
46
46
  const failed =
47
47
  message.kind === "delete"
48
48
  ? await deleteMessage(message, services, log)
49
49
  : await upsertMessage(message, services, log);
50
50
 
51
+ recordQueueEvent({
52
+ queue: queueNameFromEventSource(record.eventSourceARN),
53
+ eventType: message.kind,
54
+ outcome: failed ? "failure" : "success",
55
+ durationMs: Date.now() - start,
56
+ });
57
+
51
58
  if (failed) {
52
59
  batchItemFailures.push({ itemIdentifier: record.messageId });
53
60
  }
54
61
  }
55
62
 
56
- metrics.addMetric(
57
- "searchIndexProcessingDuration",
58
- MetricUnit.Milliseconds,
59
- Date.now() - processingStart,
60
- );
61
-
62
63
  return { batchItemFailures };
63
64
  };
64
65
 
@@ -71,7 +72,6 @@ const deleteMessage = async (
71
72
  .delete(message.messageId)
72
73
  .then(() => {
73
74
  log.info("Deleted search vectors", { messageId: message.messageId });
74
- metrics.addMetric("searchIndexProcessed", MetricUnit.Count, 1);
75
75
  return false;
76
76
  })
77
77
  .catch((error) => {
@@ -79,7 +79,6 @@ const deleteMessage = async (
79
79
  error: inspect(error),
80
80
  messageId: message.messageId,
81
81
  });
82
- metrics.addMetric("searchIndexFailures", MetricUnit.Count, 1);
83
82
  return true;
84
83
  });
85
84
 
@@ -105,7 +104,6 @@ export const upsertMessage = async (
105
104
  accountId: message.accountId,
106
105
  messageId: message.messageId,
107
106
  });
108
- metrics.addMetric("searchIndexFailures", MetricUnit.Count, 1);
109
107
  return true;
110
108
  });
111
109
 
@@ -152,8 +150,6 @@ const indexMessage = async (
152
150
  upserted,
153
151
  skipped,
154
152
  });
155
- metrics.addMetric("searchIndexProcessed", MetricUnit.Count, upserted);
156
- metrics.addMetric("searchIndexSkipped", MetricUnit.Count, skipped);
157
153
  services.onIndexOutcome?.({ status: "indexed", upserted, skipped });
158
154
  };
159
155
 
@@ -280,6 +276,7 @@ const prepareUpsert = async (
280
276
  fromName: threadMessage.fromName ?? null,
281
277
  subject: threadMessage.subject ?? "",
282
278
  category: threadMessage.category,
279
+ ...(threadMessage.listId ? { listId: threadMessage.listId } : {}),
283
280
  },
284
281
  });
285
282
  };
@@ -0,0 +1,47 @@
1
+ import assert from "node:assert/strict";
2
+ import { beforeEach, describe, it } from "node:test";
3
+ import { renderMetrics, resetMetrics } from "@remit/logger-lambda/metrics";
4
+ import { registerSearchIndexBacklog } from "./metrics.js";
5
+
6
+ const backlogLines = (text: string): string[] =>
7
+ text
8
+ .split("\n")
9
+ .filter(
10
+ (line) =>
11
+ line.startsWith("remit_search_index_backlog") && line.length > 0,
12
+ );
13
+
14
+ describe("the search index backlog series", () => {
15
+ beforeEach(() => resetMetrics());
16
+
17
+ // The reason this gauge is declared inside a function, in this package, and
18
+ // not at module scope in the shared registry: it carries no labels, so it
19
+ // renders from the moment it exists. Declared where every service can import
20
+ // it, four processes that cannot count an outbox would each publish a
21
+ // confident 0.
22
+ it("renders nothing in a process that never registered it", async () => {
23
+ assert.deepEqual(backlogLines(await renderMetrics()), []);
24
+ });
25
+
26
+ it("reports the count the registered reader returns", async () => {
27
+ registerSearchIndexBacklog(async () => 7);
28
+ const text = await renderMetrics();
29
+ assert.match(text, /^# TYPE remit_search_index_backlog_rows gauge$/m);
30
+ assert.match(text, /^remit_search_index_backlog_rows 7$/m);
31
+ });
32
+
33
+ it("re-reads on every scrape", async () => {
34
+ let rows = 3;
35
+ registerSearchIndexBacklog(async () => rows);
36
+ assert.match(await renderMetrics(), /^remit_search_index_backlog_rows 3$/m);
37
+ rows = 0;
38
+ assert.match(await renderMetrics(), /^remit_search_index_backlog_rows 0$/m);
39
+ });
40
+
41
+ it("fails the scrape when the outbox cannot be counted", async () => {
42
+ registerSearchIndexBacklog(async () => {
43
+ throw new Error("database is locked");
44
+ });
45
+ await assert.rejects(renderMetrics(), /database is locked/);
46
+ });
47
+ });
package/src/metrics.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { onScrape, registry } from "@remit/logger-lambda/metrics";
2
+ import { Gauge } from "prom-client";
3
+
4
+ /**
5
+ * Put the search index backlog (standalone-observability D3) on this process's
6
+ * registry, read from `count` when a scrape arrives.
7
+ *
8
+ * The gauge is constructed here rather than at module scope, and this module is
9
+ * not the shared one, for the same reason: it carries no labels, so prom-client
10
+ * renders it from the moment it exists. Declared in the shared registry module
11
+ * it would be a confident `0` in the backend, the queue sidecar and three
12
+ * workers that cannot know the answer — five series separated only by
13
+ * `instance`, four of them permanently wrong. Declared at this module's scope it
14
+ * would be a `0` on every backend that has no outbox to count. The series exists
15
+ * only where something computes it.
16
+ */
17
+ const NAME = "remit_search_index_backlog_rows";
18
+
19
+ export const registerSearchIndexBacklog = (
20
+ count: () => Promise<number>,
21
+ ): void => {
22
+ // Registering twice replaces the reader rather than throwing on the duplicate
23
+ // name — the caller is saying where the count comes from now.
24
+ registry.removeSingleMetric(NAME);
25
+ const backlogRows = new Gauge({
26
+ name: NAME,
27
+ help: "Search-index outbox rows that have not been relayed yet.",
28
+ registers: [registry],
29
+ });
30
+ onScrape(async () => backlogRows.set(await count()));
31
+ };
package/src/poller.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { createLogger } from "@remit/logger-lambda";
1
+ import { createLogger, startMetricsServer } from "@remit/logger-lambda";
2
2
  import { runQueuePoller } from "@remit/sqs-client/poller";
3
3
  import { env } from "expect-env";
4
4
  import { handler } from "./index.js";
5
+ import { registerSearchIndexBacklog } from "./metrics.js";
5
6
  import { maybeStartSqliteOutboxDrain } from "./sqlite-outbox-drain.js";
6
7
 
7
8
  /** Production queue poller — no e2e shim exists for this queue today (the
@@ -15,6 +16,18 @@ const log = createLogger();
15
16
  // loop, which blocks until shutdown, then stopped after it returns.
16
17
  const drain = await maybeStartSqliteOutboxDrain(log);
17
18
 
19
+ // The backlog is a count of rows in the shared file, so it is read when a
20
+ // scrape arrives rather than tracked as work moves. Registered only where there
21
+ // is an outbox to count, so on every other backend the series is absent rather
22
+ // than a zero nothing computed.
23
+ if (drain) {
24
+ registerSearchIndexBacklog(() => drain.countBacklog());
25
+ }
26
+
27
+ // /metrics and nothing else, on the compose network (D2). Started before the
28
+ // poll loop, which blocks until shutdown.
29
+ startMetricsServer();
30
+
18
31
  try {
19
32
  await runQueuePoller({
20
33
  log,
@@ -87,4 +87,29 @@ describe("SqliteOutboxStore", () => {
87
87
  assert.deepEqual(pending, ["r2"], "the mid-flight row stays pending");
88
88
  db.close();
89
89
  });
90
+
91
+ test("counts undrained rows as the exported search index backlog", async () => {
92
+ const db = makeOutboxDb();
93
+ const store = new SqliteOutboxStore(db as unknown as never);
94
+ assert.equal(await store.countUnprocessedRows(), 0);
95
+
96
+ // Two rows for one message count as two: the backlog is outstanding work,
97
+ // not the number of messages it concerns.
98
+ insertRow(db, "r1", "m1", "message.body_synced");
99
+ insertRow(db, "r2", "m1", "message.body_synced");
100
+ insertRow(db, "r3", "m2", "message.moved");
101
+ assert.equal(await store.countUnprocessedRows(), 3);
102
+
103
+ await store.markRowsProcessed(["r1", "r2"]);
104
+ assert.equal(await store.countUnprocessedRows(), 1);
105
+ db.close();
106
+ });
107
+
108
+ test("ignores an event the drain does not relay", async () => {
109
+ const db = makeOutboxDb();
110
+ const store = new SqliteOutboxStore(db as unknown as never);
111
+ insertRow(db, "r1", "m1", "message.something_else");
112
+ assert.equal(await store.countUnprocessedRows(), 0);
113
+ db.close();
114
+ });
90
115
  });
@@ -29,6 +29,7 @@ const DRAIN_INTERVAL_MS = 2_000;
29
29
  // out of the Lambda bundle).
30
30
  interface SqliteStatement {
31
31
  all(...params: unknown[]): unknown[];
32
+ get(...params: unknown[]): unknown;
32
33
  run(...params: unknown[]): unknown;
33
34
  }
34
35
  interface SqliteDatabase {
@@ -66,6 +67,23 @@ export class SqliteOutboxStore implements OutboxStore {
66
67
  return rows.map((row) => row.id);
67
68
  }
68
69
 
70
+ /**
71
+ * The search index backlog (standalone-observability D3): rows committed but
72
+ * not yet relayed onto the queue. Counted per row rather than per distinct
73
+ * event, so it is the work outstanding and not the number of messages it
74
+ * concerns.
75
+ */
76
+ async countUnprocessedRows(): Promise<number> {
77
+ const placeholders = DRAIN_EVENTS.map(() => "?").join(", ");
78
+ const row = this.db
79
+ .prepare(
80
+ `SELECT COUNT(*) AS n FROM outbox
81
+ WHERE event IN (${placeholders}) AND processed_at IS NULL`,
82
+ )
83
+ .get(...DRAIN_EVENTS) as { n: number };
84
+ return row.n;
85
+ }
86
+
69
87
  async markRowsProcessed(ids: string[]): Promise<void> {
70
88
  if (ids.length === 0) return;
71
89
  const placeholders = ids.map(() => "?").join(", ");
@@ -80,6 +98,8 @@ export class SqliteOutboxStore implements OutboxStore {
80
98
 
81
99
  export interface RunningDrain {
82
100
  stop(): Promise<void>;
101
+ /** Undrained outbox rows, read fresh — the D3 search index backlog. */
102
+ countBacklog(): Promise<number>;
83
103
  }
84
104
 
85
105
  export interface SqliteOutboxDrainConfig {
@@ -111,11 +131,8 @@ export const startSqliteOutboxDrain = async (
111
131
  sqlite.pragma("synchronous = NORMAL");
112
132
 
113
133
  const sqs = createSqsClient(queueUrl);
114
- const relay = new OutboxRelay({
115
- store: new SqliteOutboxStore(sqlite),
116
- sqs,
117
- queueUrl,
118
- });
134
+ const store = new SqliteOutboxStore(sqlite);
135
+ const relay = new OutboxRelay({ store, sqs, queueUrl });
119
136
 
120
137
  let draining = false;
121
138
  let inFlight: Promise<unknown> = Promise.resolve();
@@ -139,6 +156,7 @@ export const startSqliteOutboxDrain = async (
139
156
  timer.unref();
140
157
 
141
158
  return {
159
+ countBacklog: () => store.countUnprocessedRows(),
142
160
  stop: async () => {
143
161
  clearInterval(timer);
144
162
  await inFlight;