@remit/sqs-client 0.0.8 → 0.0.9

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/sqs-client",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -0,0 +1,102 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, readdir, readFile, stat, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { afterEach, describe, it } from "node:test";
6
+ import { clearHeartbeats, createHeartbeat } from "./heartbeat.js";
7
+
8
+ const heartbeatPrefix = async (): Promise<string> => {
9
+ const dir = await mkdtemp(join(tmpdir(), "remit-heartbeat-"));
10
+ return join(dir, "imap-worker");
11
+ };
12
+
13
+ describe("createHeartbeat", () => {
14
+ afterEach(() => {
15
+ delete process.env.WORKER_HEARTBEAT_PREFIX;
16
+ });
17
+
18
+ it("writes one file per queue and advances its mtime on the next beat", async () => {
19
+ const prefix = await heartbeatPrefix();
20
+ const body = createHeartbeat("remit-body", prefix);
21
+ const flags = createHeartbeat("remit-flags.fifo", prefix);
22
+
23
+ await body();
24
+ await flags();
25
+ assert.deepEqual((await readdir(dirname(prefix))).sort(), [
26
+ "imap-worker.remit-body",
27
+ "imap-worker.remit-flags.fifo",
28
+ ]);
29
+
30
+ const contents = await readFile(`${prefix}.remit-body`, "utf8");
31
+ assert.ok(
32
+ !Number.isNaN(Date.parse(contents.trim())),
33
+ `heartbeat contents should be a timestamp, got ${contents}`,
34
+ );
35
+
36
+ // The healthcheck reads mtime and nothing else, so a rewrite that leaves
37
+ // the file byte-identical would have to advance it anyway.
38
+ const first = await stat(`${prefix}.remit-body`);
39
+ await new Promise((resolve) => setTimeout(resolve, 20));
40
+ await body();
41
+ const second = await stat(`${prefix}.remit-body`);
42
+ assert.ok(
43
+ second.mtimeMs > first.mtimeMs,
44
+ `the second beat did not advance mtime: ${second.mtimeMs} <= ${first.mtimeMs}`,
45
+ );
46
+ });
47
+
48
+ it("writes under WORKER_HEARTBEAT_PREFIX when no prefix is given", async () => {
49
+ const prefix = await heartbeatPrefix();
50
+ process.env.WORKER_HEARTBEAT_PREFIX = prefix;
51
+
52
+ await createHeartbeat("remit-smtp")();
53
+
54
+ assert.equal((await stat(`${prefix}.remit-smtp`)).isFile(), true);
55
+ });
56
+
57
+ it("writes nothing when WORKER_HEARTBEAT_PREFIX is unset", async () => {
58
+ const prefix = await heartbeatPrefix();
59
+
60
+ await createHeartbeat("remit-smtp")();
61
+
62
+ assert.deepEqual(await readdir(dirname(prefix)), []);
63
+ });
64
+
65
+ it("rejects when the heartbeat directory does not exist", async () => {
66
+ const heartbeat = createHeartbeat(
67
+ "remit-smtp",
68
+ "/nonexistent/remit/worker",
69
+ );
70
+
71
+ await assert.rejects(() => heartbeat(), /ENOENT/);
72
+ });
73
+ });
74
+
75
+ describe("clearHeartbeats", () => {
76
+ afterEach(() => {
77
+ delete process.env.WORKER_HEARTBEAT_PREFIX;
78
+ });
79
+
80
+ it("removes this service's files and leaves every other service's alone", async () => {
81
+ const prefix = await heartbeatPrefix();
82
+ const directory = dirname(prefix);
83
+ await writeFile(`${prefix}.remit-body`, "stale\n");
84
+ await writeFile(`${prefix}.remit-retired-queue`, "stale\n");
85
+ await writeFile(join(directory, "smtp-worker.remit-smtp"), "fresh\n");
86
+
87
+ await clearHeartbeats(prefix);
88
+
89
+ assert.deepEqual(await readdir(directory), ["smtp-worker.remit-smtp"]);
90
+ });
91
+
92
+ it("does nothing when WORKER_HEARTBEAT_PREFIX is unset", async () => {
93
+ await clearHeartbeats();
94
+ });
95
+
96
+ it("rejects when the heartbeat directory does not exist", async () => {
97
+ await assert.rejects(
98
+ () => clearHeartbeats("/nonexistent/remit/worker"),
99
+ /ENOENT/,
100
+ );
101
+ });
102
+ });
@@ -0,0 +1,68 @@
1
+ import { readdir, rm, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, join } from "node:path";
3
+
4
+ /**
5
+ * Poll-loop liveness as a file (docs/design/standalone-observability.md, D1).
6
+ *
7
+ * A worker whose poll loop wedges inside a socket read never exits, so
8
+ * `restart: unless-stopped` never fires and `docker compose ps` reports it
9
+ * running while mail stops moving. Each loop rewrites its own file once per
10
+ * receive attempt; a container healthcheck reads the age of the oldest of them
11
+ * and fails past a threshold above the longest legitimate handler run.
12
+ *
13
+ * One file per loop, not one per container: a worker runs a loop per queue, and
14
+ * a receive loop only ever talks to the queue sidecar. One handler wedged in an
15
+ * IMAP read leaves imap-worker's other five loops long-polling, so a
16
+ * container-wide file stays fresh through exactly the failure this exists to
17
+ * catch. One writer per file also means a reader cannot catch a torn write.
18
+ *
19
+ * A file rather than an HTTP route because an HTTP handler answers from a
20
+ * process whose poll loop is wedged — any honest health endpoint would have to
21
+ * consult this same timestamp.
22
+ *
23
+ * The heartbeat proves the loop is turning, not that the last unit of work
24
+ * succeeded. A loop that receives, fails every handler and rewrites its file
25
+ * each cycle is healthy here.
26
+ */
27
+ export type Heartbeat = () => Promise<void>;
28
+
29
+ const noHeartbeat: Heartbeat = () => Promise.resolve();
30
+
31
+ /**
32
+ * `WORKER_HEARTBEAT_PREFIX` is set per worker service by the self-host compose
33
+ * stack, which owns both the path and the volume it lands on; each loop appends
34
+ * its own queue name. Unset — the Lambda deployment, where the poller does not
35
+ * run at all, and any local process — writes nothing.
36
+ *
37
+ * The written value is the timestamp itself, so the file is readable as well as
38
+ * stat-able: an empty rewrite is not guaranteed to advance mtime when the file
39
+ * is already zero-length, and mtime is the whole signal.
40
+ */
41
+ export const createHeartbeat = (
42
+ name: string,
43
+ prefix: string | undefined = process.env.WORKER_HEARTBEAT_PREFIX,
44
+ ): Heartbeat => {
45
+ if (!prefix) return noHeartbeat;
46
+ const filePath = `${prefix}.${name}`;
47
+ return () => writeFile(filePath, `${new Date().toISOString()}\n`);
48
+ };
49
+
50
+ /**
51
+ * Drops the files this service left on the volume, before its loops start
52
+ * writing again. The healthcheck fails on the oldest file it finds, so a queue
53
+ * a later version stops polling would otherwise leave a file nobody rewrites,
54
+ * and a worker that can never report healthy again.
55
+ */
56
+ export const clearHeartbeats = async (
57
+ prefix: string | undefined = process.env.WORKER_HEARTBEAT_PREFIX,
58
+ ): Promise<void> => {
59
+ if (!prefix) return;
60
+ const directory = dirname(prefix);
61
+ const owned = `${basename(prefix)}.`;
62
+ const names = await readdir(directory);
63
+ await Promise.all(
64
+ names
65
+ .filter((name) => name.startsWith(owned))
66
+ .map((name) => rm(join(directory, name))),
67
+ );
68
+ };
@@ -7,8 +7,8 @@ import { runQueuePoller } from "./poller.js";
7
7
  const QUEUE_URL = "http://localhost:9324/000000000000/remit-test";
8
8
 
9
9
  const buildLog = () => ({
10
- info: mock.fn(() => {}),
11
- error: mock.fn(() => {}),
10
+ info: mock.fn((_fields: Record<string, unknown>, _message: string) => {}),
11
+ error: mock.fn((_fields: Record<string, unknown>, _message: string) => {}),
12
12
  });
13
13
 
14
14
  const buildMessage = (id: string) => ({
@@ -81,6 +81,102 @@ describe("runQueuePoller", () => {
81
81
  assert.deepEqual(deletedReceiptHandles, ["receipt-m1"]);
82
82
  });
83
83
 
84
+ it("gives every target its own heartbeat, beaten at the top of each receive attempt", async () => {
85
+ const beatsByQueue = new Map<string, number>();
86
+ let receiveCalls = 0;
87
+
88
+ const sendMock = mock.method(
89
+ SQSClient.prototype,
90
+ "send",
91
+ // biome-ignore lint/suspicious/noExplicitAny: minimal SDK command shape, not worth typing per-command here
92
+ async function (this: SQSClient, command: any) {
93
+ if (command.constructor.name !== "ReceiveMessageCommand") {
94
+ throw new Error(`unexpected command: ${command.constructor.name}`);
95
+ }
96
+ // Two empty receives per target, then shut down: the heartbeat has
97
+ // to be written before the receive, and on an empty poll too — an
98
+ // idle queue is the normal state of a healthy worker.
99
+ receiveCalls++;
100
+ if (receiveCalls >= 4) process.emit("SIGWINCH", "SIGWINCH");
101
+ return { Messages: [] };
102
+ },
103
+ );
104
+
105
+ try {
106
+ await runQueuePoller({
107
+ targets: [
108
+ { queueUrl: QUEUE_URL, handler: async () => {}, functionName: "a" },
109
+ {
110
+ queueUrl: `${QUEUE_URL}-2`,
111
+ handler: async () => {},
112
+ functionName: "b",
113
+ },
114
+ ],
115
+ log: buildLog(),
116
+ signals: ["SIGWINCH"],
117
+ createHeartbeat: (name) => async () => {
118
+ beatsByQueue.set(name, (beatsByQueue.get(name) ?? 0) + 1);
119
+ },
120
+ });
121
+ } finally {
122
+ sendMock.mock.restore();
123
+ }
124
+
125
+ assert.ok(receiveCalls >= 4, `only ${receiveCalls} receives ran`);
126
+ // One heartbeat per queue, named after it: a wedged loop has to go stale
127
+ // on its own rather than ride on a sibling that is still turning.
128
+ assert.deepEqual([...beatsByQueue.keys()].sort(), [
129
+ "remit-test",
130
+ "remit-test-2",
131
+ ]);
132
+ assert.equal(
133
+ [...beatsByQueue.values()].reduce((sum, count) => sum + count, 0),
134
+ receiveCalls,
135
+ );
136
+ });
137
+
138
+ it("keeps polling and logs when a heartbeat write fails", async () => {
139
+ let receiveCalls = 0;
140
+ const log = buildLog();
141
+
142
+ const sendMock = mock.method(
143
+ SQSClient.prototype,
144
+ "send",
145
+ // biome-ignore lint/suspicious/noExplicitAny: minimal SDK command shape, not worth typing per-command here
146
+ async function (this: SQSClient, command: any) {
147
+ if (command.constructor.name !== "ReceiveMessageCommand") {
148
+ throw new Error(`unexpected command: ${command.constructor.name}`);
149
+ }
150
+ receiveCalls++;
151
+ if (receiveCalls >= 2) process.emit("SIGWINCH", "SIGWINCH");
152
+ return { Messages: [] };
153
+ },
154
+ );
155
+
156
+ try {
157
+ // A full disk is the likeliest cause, and the moment the loop must stay
158
+ // up: the healthcheck reports the missed beat by itself.
159
+ await runQueuePoller({
160
+ targets: [
161
+ { queueUrl: QUEUE_URL, handler: async () => {}, functionName: "a" },
162
+ ],
163
+ log,
164
+ signals: ["SIGWINCH"],
165
+ createHeartbeat: () => () =>
166
+ Promise.reject(new Error("ENOSPC: no space left on device")),
167
+ });
168
+ } finally {
169
+ sendMock.mock.restore();
170
+ }
171
+
172
+ assert.ok(receiveCalls >= 2, `only ${receiveCalls} receives ran`);
173
+ const failures = log.error.mock.calls.filter(
174
+ (call) => call.arguments[1] === "poller: heartbeat write failed",
175
+ );
176
+ assert.equal(failures.length, receiveCalls);
177
+ assert.match(String(failures[0]?.arguments[0]?.error), /ENOSPC/);
178
+ });
179
+
84
180
  it("throws when constructed with no targets", async () => {
85
181
  await assert.rejects(
86
182
  () => runQueuePoller({ targets: [], log: buildLog() }),
package/src/poller.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { inspect } from "node:util";
1
2
  import {
2
3
  DeleteMessageCommand,
3
4
  ReceiveMessageCommand,
@@ -8,6 +9,11 @@ import type {
8
9
  SQSEvent,
9
10
  SQSHandler,
10
11
  } from "aws-lambda";
12
+ import {
13
+ clearHeartbeats,
14
+ createHeartbeat,
15
+ type Heartbeat,
16
+ } from "./heartbeat.js";
11
17
  import { createQueueProducer } from "./producer.js";
12
18
 
13
19
  /**
@@ -41,17 +47,28 @@ export interface RunQueuePollerOptions {
41
47
  readonly targets: readonly QueuePollerTarget[];
42
48
  readonly log: QueuePollerLog;
43
49
  readonly signals?: readonly NodeJS.Signals[];
50
+ /** One per target. Defaults to a file under `WORKER_HEARTBEAT_PREFIX`. */
51
+ readonly createHeartbeat?: (name: string) => Heartbeat;
44
52
  }
45
53
 
46
- const DEFAULT_MAX_MESSAGES = 10; // SQS API limit
54
+ // One message per receive, not the SQS maximum of 10. Handlers process a batch
55
+ // sequentially, so a batch of ten is ten handler runs between two heartbeats:
56
+ // ten stalled SMTP sends at nodemailer's 300 s socket timeout is 50 minutes of
57
+ // legitimate silence, and a threshold that tolerates it detects nothing. At one
58
+ // message the quiet stretch a healthy loop can produce is one handler run, and
59
+ // the extra receive per message is a millisecond against the local queue
60
+ // sidecar — the same total work either way.
61
+ const DEFAULT_MAX_MESSAGES = 1;
47
62
  const DEFAULT_VISIBILITY_TIMEOUT_SECONDS = 300;
48
63
 
49
64
  const pollTarget = async (
50
65
  target: QueuePollerTarget,
51
66
  log: QueuePollerLog,
52
67
  isShuttingDown: () => boolean,
68
+ buildHeartbeat: (name: string) => Heartbeat,
53
69
  ): Promise<void> => {
54
70
  const queueName = new URL(target.queueUrl).pathname.split("/").pop();
71
+ const heartbeat = buildHeartbeat(queueName ?? target.functionName);
55
72
  const sqs = createQueueProducer({ queueUrl: target.queueUrl });
56
73
  const maxMessages = target.maxMessages ?? DEFAULT_MAX_MESSAGES;
57
74
  const visibilityTimeout =
@@ -68,6 +85,22 @@ const pollTarget = async (
68
85
  const LONG_POLL_WAIT_SECONDS = 20;
69
86
 
70
87
  while (!isShuttingDown()) {
88
+ // Top of the receive attempt, before the long poll: this loop's own file,
89
+ // so a loop that stops turning — wedged in a socket read, or in a handler
90
+ // that never returns — goes stale on its own however busy its siblings are.
91
+ //
92
+ // A write that fails must not take the worker down with it. A full disk is
93
+ // the likeliest cause and the moment the loop should stay up, failing
94
+ // records into the dead-letter queues with logs rather than crash-looping
95
+ // on the monitoring. The missed beat is itself the signal: the healthcheck
96
+ // goes stale, which is what this file exists to report.
97
+ await heartbeat().catch((error) => {
98
+ log.error(
99
+ { queue: queueName, error: inspect(error) },
100
+ "poller: heartbeat write failed",
101
+ );
102
+ });
103
+
71
104
  const response = await sqs.send(
72
105
  new ReceiveMessageCommand({
73
106
  QueueUrl: target.queueUrl,
@@ -129,9 +162,9 @@ const pollTarget = async (
129
162
  );
130
163
  const succeeded = messages.filter((m) => !failedIds.has(m.messageId));
131
164
 
132
- // Batch is capped at maxMessages (SQS API limit: 10), so a handful of
133
- // concurrent DeleteMessage calls is safe and avoids paying N sequential
134
- // round trips between every receive/handler cycle.
165
+ // Batch is capped at maxMessages (one by default, 10 is the SQS ceiling),
166
+ // so a handful of concurrent DeleteMessage calls is safe and avoids paying
167
+ // N sequential round trips between every receive/handler cycle.
135
168
  await Promise.all(
136
169
  succeeded.map((message) =>
137
170
  sqs.send(
@@ -161,17 +194,29 @@ const pollTarget = async (
161
194
  * received (default: SIGINT, SIGTERM), then lets each loop finish its
162
195
  * current iteration and returns. Rejects (crashes the process) if any loop
163
196
  * throws — a stuck poller should exit loudly, not degrade silently.
197
+ *
198
+ * Every loop writes its own heartbeat file (see heartbeat.ts) at the top of its
199
+ * receive attempt, which is how a loop that hangs instead of throwing — the
200
+ * case this cannot make loud by itself — becomes visible.
164
201
  */
165
202
  export const runQueuePoller = async (
166
203
  options: RunQueuePollerOptions,
167
204
  ): Promise<void> => {
168
205
  const { targets, log } = options;
169
206
  const signals = options.signals ?? ["SIGINT", "SIGTERM"];
207
+ const buildHeartbeat = options.createHeartbeat ?? createHeartbeat;
170
208
 
171
209
  if (targets.length === 0) {
172
210
  throw new Error("runQueuePoller: no targets configured");
173
211
  }
174
212
 
213
+ // Files from the previous run of this service, before its loops write again:
214
+ // the healthcheck reads the oldest file it finds, so one left behind by a
215
+ // queue this version no longer polls would fail the check forever.
216
+ await clearHeartbeats().catch((error) => {
217
+ log.error({ error: inspect(error) }, "poller: heartbeat cleanup failed");
218
+ });
219
+
175
220
  let shuttingDown = false;
176
221
  const isShuttingDown = () => shuttingDown;
177
222
  const onSignal = (signal: NodeJS.Signals) => {
@@ -185,6 +230,8 @@ export const runQueuePoller = async (
185
230
  log.info({ queues: targets.map((t) => t.queueUrl) }, "poller: starting");
186
231
 
187
232
  await Promise.all(
188
- targets.map((target) => pollTarget(target, log, isShuttingDown)),
233
+ targets.map((target) =>
234
+ pollTarget(target, log, isShuttingDown, buildHeartbeat),
235
+ ),
189
236
  );
190
237
  };