@remit/sqs-client 0.0.8 → 0.0.10

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.10",
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,156 @@ describe("runQueuePoller", () => {
81
81
  assert.deepEqual(deletedReceiptHandles, ["receipt-m1"]);
82
82
  });
83
83
 
84
+ it("aborts an in-flight idle long-poll on signal instead of waiting it out", async () => {
85
+ let receiveCalls = 0;
86
+ let sawAbortSignal = false;
87
+
88
+ const sendMock = mock.method(
89
+ SQSClient.prototype,
90
+ "send",
91
+ // biome-ignore lint/suspicious/noExplicitAny: minimal SDK command shape
92
+ async function (this: SQSClient, command: any, options?: any) {
93
+ if (command.constructor.name === "ReceiveMessageCommand") {
94
+ receiveCalls++;
95
+ if (receiveCalls > 1) {
96
+ throw new Error(
97
+ "a second ReceiveMessage was issued — the abort did not end the poll",
98
+ );
99
+ }
100
+ const signal: AbortSignal | undefined = options?.abortSignal;
101
+ sawAbortSignal = signal instanceof AbortSignal;
102
+ // Model an empty long poll that only ever ends because it is
103
+ // aborted: request shutdown, then resolve solely on abort. A
104
+ // poller that waited out WaitTimeSeconds would hang this test.
105
+ process.emit("SIGWINCH", "SIGWINCH");
106
+ return await new Promise((_resolve, reject) => {
107
+ const fail = () =>
108
+ reject(
109
+ Object.assign(new Error("aborted"), { name: "AbortError" }),
110
+ );
111
+ if (signal?.aborted) return fail();
112
+ signal?.addEventListener("abort", fail, { once: true });
113
+ });
114
+ }
115
+ throw new Error(`unexpected command: ${command.constructor.name}`);
116
+ },
117
+ );
118
+
119
+ try {
120
+ await runQueuePoller({
121
+ targets: [
122
+ { queueUrl: QUEUE_URL, handler: mock.fn(), functionName: "test-fn" },
123
+ ],
124
+ log: buildLog(),
125
+ signals: ["SIGWINCH"],
126
+ // A no-op heartbeat keeps this case about the abort alone, off the
127
+ // real heartbeat file the default writer would touch.
128
+ createHeartbeat: () => async () => {},
129
+ });
130
+ } finally {
131
+ sendMock.mock.restore();
132
+ }
133
+
134
+ assert.ok(sawAbortSignal, "the receive was issued without an abort signal");
135
+ assert.equal(receiveCalls, 1);
136
+ });
137
+
138
+ it("gives every target its own heartbeat, beaten at the top of each receive attempt", async () => {
139
+ const beatsByQueue = new Map<string, number>();
140
+ let receiveCalls = 0;
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
+ // Two empty receives per target, then shut down: the heartbeat has
151
+ // to be written before the receive, and on an empty poll too — an
152
+ // idle queue is the normal state of a healthy worker.
153
+ receiveCalls++;
154
+ if (receiveCalls >= 4) process.emit("SIGWINCH", "SIGWINCH");
155
+ return { Messages: [] };
156
+ },
157
+ );
158
+
159
+ try {
160
+ await runQueuePoller({
161
+ targets: [
162
+ { queueUrl: QUEUE_URL, handler: async () => {}, functionName: "a" },
163
+ {
164
+ queueUrl: `${QUEUE_URL}-2`,
165
+ handler: async () => {},
166
+ functionName: "b",
167
+ },
168
+ ],
169
+ log: buildLog(),
170
+ signals: ["SIGWINCH"],
171
+ createHeartbeat: (name) => async () => {
172
+ beatsByQueue.set(name, (beatsByQueue.get(name) ?? 0) + 1);
173
+ },
174
+ });
175
+ } finally {
176
+ sendMock.mock.restore();
177
+ }
178
+
179
+ assert.ok(receiveCalls >= 4, `only ${receiveCalls} receives ran`);
180
+ // One heartbeat per queue, named after it: a wedged loop has to go stale
181
+ // on its own rather than ride on a sibling that is still turning.
182
+ assert.deepEqual([...beatsByQueue.keys()].sort(), [
183
+ "remit-test",
184
+ "remit-test-2",
185
+ ]);
186
+ assert.equal(
187
+ [...beatsByQueue.values()].reduce((sum, count) => sum + count, 0),
188
+ receiveCalls,
189
+ );
190
+ });
191
+
192
+ it("keeps polling and logs when a heartbeat write fails", async () => {
193
+ let receiveCalls = 0;
194
+ const log = buildLog();
195
+
196
+ const sendMock = mock.method(
197
+ SQSClient.prototype,
198
+ "send",
199
+ // biome-ignore lint/suspicious/noExplicitAny: minimal SDK command shape, not worth typing per-command here
200
+ async function (this: SQSClient, command: any) {
201
+ if (command.constructor.name !== "ReceiveMessageCommand") {
202
+ throw new Error(`unexpected command: ${command.constructor.name}`);
203
+ }
204
+ receiveCalls++;
205
+ if (receiveCalls >= 2) process.emit("SIGWINCH", "SIGWINCH");
206
+ return { Messages: [] };
207
+ },
208
+ );
209
+
210
+ try {
211
+ // A full disk is the likeliest cause, and the moment the loop must stay
212
+ // up: the healthcheck reports the missed beat by itself.
213
+ await runQueuePoller({
214
+ targets: [
215
+ { queueUrl: QUEUE_URL, handler: async () => {}, functionName: "a" },
216
+ ],
217
+ log,
218
+ signals: ["SIGWINCH"],
219
+ createHeartbeat: () => () =>
220
+ Promise.reject(new Error("ENOSPC: no space left on device")),
221
+ });
222
+ } finally {
223
+ sendMock.mock.restore();
224
+ }
225
+
226
+ assert.ok(receiveCalls >= 2, `only ${receiveCalls} receives ran`);
227
+ const failures = log.error.mock.calls.filter(
228
+ (call) => call.arguments[1] === "poller: heartbeat write failed",
229
+ );
230
+ assert.equal(failures.length, receiveCalls);
231
+ assert.match(String(failures[0]?.arguments[0]?.error), /ENOSPC/);
232
+ });
233
+
84
234
  it("throws when constructed with no targets", async () => {
85
235
  await assert.rejects(
86
236
  () => runQueuePoller({ targets: [], log: buildLog() }),
package/src/poller.ts CHANGED
@@ -1,6 +1,8 @@
1
+ import { inspect } from "node:util";
1
2
  import {
2
3
  DeleteMessageCommand,
3
4
  ReceiveMessageCommand,
5
+ type ReceiveMessageCommandOutput,
4
6
  } from "@aws-sdk/client-sqs";
5
7
  import type {
6
8
  Context,
@@ -8,6 +10,11 @@ import type {
8
10
  SQSEvent,
9
11
  SQSHandler,
10
12
  } from "aws-lambda";
13
+ import {
14
+ clearHeartbeats,
15
+ createHeartbeat,
16
+ type Heartbeat,
17
+ } from "./heartbeat.js";
11
18
  import { createQueueProducer } from "./producer.js";
12
19
 
13
20
  /**
@@ -41,17 +48,29 @@ export interface RunQueuePollerOptions {
41
48
  readonly targets: readonly QueuePollerTarget[];
42
49
  readonly log: QueuePollerLog;
43
50
  readonly signals?: readonly NodeJS.Signals[];
51
+ /** One per target. Defaults to a file under `WORKER_HEARTBEAT_PREFIX`. */
52
+ readonly createHeartbeat?: (name: string) => Heartbeat;
44
53
  }
45
54
 
46
- const DEFAULT_MAX_MESSAGES = 10; // SQS API limit
55
+ // One message per receive, not the SQS maximum of 10. Handlers process a batch
56
+ // sequentially, so a batch of ten is ten handler runs between two heartbeats:
57
+ // ten stalled SMTP sends at nodemailer's 300 s socket timeout is 50 minutes of
58
+ // legitimate silence, and a threshold that tolerates it detects nothing. At one
59
+ // message the quiet stretch a healthy loop can produce is one handler run, and
60
+ // the extra receive per message is a millisecond against the local queue
61
+ // sidecar — the same total work either way.
62
+ const DEFAULT_MAX_MESSAGES = 1;
47
63
  const DEFAULT_VISIBILITY_TIMEOUT_SECONDS = 300;
48
64
 
49
65
  const pollTarget = async (
50
66
  target: QueuePollerTarget,
51
67
  log: QueuePollerLog,
52
68
  isShuttingDown: () => boolean,
69
+ shutdownSignal: AbortSignal,
70
+ buildHeartbeat: (name: string) => Heartbeat,
53
71
  ): Promise<void> => {
54
72
  const queueName = new URL(target.queueUrl).pathname.split("/").pop();
73
+ const heartbeat = buildHeartbeat(queueName ?? target.functionName);
55
74
  const sqs = createQueueProducer({ queueUrl: target.queueUrl });
56
75
  const maxMessages = target.maxMessages ?? DEFAULT_MAX_MESSAGES;
57
76
  const visibilityTimeout =
@@ -68,15 +87,46 @@ const pollTarget = async (
68
87
  const LONG_POLL_WAIT_SECONDS = 20;
69
88
 
70
89
  while (!isShuttingDown()) {
71
- const response = await sqs.send(
72
- new ReceiveMessageCommand({
73
- QueueUrl: target.queueUrl,
74
- MaxNumberOfMessages: maxMessages,
75
- WaitTimeSeconds: LONG_POLL_WAIT_SECONDS,
76
- VisibilityTimeout: visibilityTimeout,
77
- MessageSystemAttributeNames: ["ApproximateReceiveCount"],
78
- }),
79
- );
90
+ // Top of the receive attempt, before the long poll: this loop's own file,
91
+ // so a loop that stops turning — wedged in a socket read, or in a handler
92
+ // that never returns — goes stale on its own however busy its siblings are.
93
+ //
94
+ // A write that fails must not take the worker down with it. A full disk is
95
+ // the likeliest cause and the moment the loop should stay up, failing
96
+ // records into the dead-letter queues with logs rather than crash-looping
97
+ // on the monitoring. The missed beat is itself the signal: the healthcheck
98
+ // goes stale, which is what this file exists to report.
99
+ await heartbeat().catch((error) => {
100
+ log.error(
101
+ { queue: queueName, error: inspect(error) },
102
+ "poller: heartbeat write failed",
103
+ );
104
+ });
105
+
106
+ let response: ReceiveMessageCommandOutput;
107
+ try {
108
+ // Abort the receive on shutdown. Without this, a container stopped
109
+ // while idle waits out the current 20s long poll before the loop
110
+ // checks the flag again, and a self-update then burns the whole
111
+ // stop grace on every idle worker — the bulk of the visible restart
112
+ // downtime. The abort ends the in-flight receive at once; an
113
+ // in-flight handler below is never interrupted, so a message being
114
+ // processed still finishes cleanly. The heartbeat above already wrote
115
+ // this iteration, so the abort exits without a further beat.
116
+ response = await sqs.send(
117
+ new ReceiveMessageCommand({
118
+ QueueUrl: target.queueUrl,
119
+ MaxNumberOfMessages: maxMessages,
120
+ WaitTimeSeconds: LONG_POLL_WAIT_SECONDS,
121
+ VisibilityTimeout: visibilityTimeout,
122
+ MessageSystemAttributeNames: ["ApproximateReceiveCount"],
123
+ }),
124
+ { abortSignal: shutdownSignal },
125
+ );
126
+ } catch (error) {
127
+ if (shutdownSignal.aborted) break;
128
+ throw error;
129
+ }
80
130
 
81
131
  if (!response.Messages || response.Messages.length === 0) {
82
132
  continue;
@@ -129,9 +179,9 @@ const pollTarget = async (
129
179
  );
130
180
  const succeeded = messages.filter((m) => !failedIds.has(m.messageId));
131
181
 
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.
182
+ // Batch is capped at maxMessages (one by default, 10 is the SQS ceiling),
183
+ // so a handful of concurrent DeleteMessage calls is safe and avoids paying
184
+ // N sequential round trips between every receive/handler cycle.
135
185
  await Promise.all(
136
186
  succeeded.map((message) =>
137
187
  sqs.send(
@@ -157,26 +207,43 @@ const pollTarget = async (
157
207
  };
158
208
 
159
209
  /**
160
- * Runs every target's poll loop concurrently until a shutdown signal is
161
- * received (default: SIGINT, SIGTERM), then lets each loop finish its
162
- * current iteration and returns. Rejects (crashes the process) if any loop
163
- * throws — a stuck poller should exit loudly, not degrade silently.
210
+ * received (default: SIGINT, SIGTERM). The signal aborts each loop's in-flight
211
+ * long-poll receive so an idle worker returns at once instead of waiting out
212
+ * the current 20s poll; a loop mid-handler finishes that handler first. Rejects
213
+ * (crashes the process) if any loop throws — a stuck poller should exit loudly,
214
+ * not degrade silently.
215
+ *
216
+ * Every loop writes its own heartbeat file (see heartbeat.ts) at the top of its
217
+ * receive attempt, which is how a loop that hangs instead of throwing — the
218
+ * case this cannot make loud by itself — becomes visible.
164
219
  */
165
220
  export const runQueuePoller = async (
166
221
  options: RunQueuePollerOptions,
167
222
  ): Promise<void> => {
168
223
  const { targets, log } = options;
169
224
  const signals = options.signals ?? ["SIGINT", "SIGTERM"];
225
+ const buildHeartbeat = options.createHeartbeat ?? createHeartbeat;
170
226
 
171
227
  if (targets.length === 0) {
172
228
  throw new Error("runQueuePoller: no targets configured");
173
229
  }
174
230
 
231
+ // Files from the previous run of this service, before its loops write again:
232
+ // the healthcheck reads the oldest file it finds, so one left behind by a
233
+ // queue this version no longer polls would fail the check forever.
234
+ await clearHeartbeats().catch((error) => {
235
+ log.error({ error: inspect(error) }, "poller: heartbeat cleanup failed");
236
+ });
237
+
175
238
  let shuttingDown = false;
176
239
  const isShuttingDown = () => shuttingDown;
240
+ // Aborted alongside the flag so a loop currently blocked in its long-poll
241
+ // receive returns at once, rather than after up to WaitTimeSeconds.
242
+ const shutdown = new AbortController();
177
243
  const onSignal = (signal: NodeJS.Signals) => {
178
244
  log.info({ signal }, "poller: shutdown signal received");
179
245
  shuttingDown = true;
246
+ shutdown.abort();
180
247
  };
181
248
  for (const signal of signals) {
182
249
  process.on(signal, onSignal);
@@ -185,6 +252,8 @@ export const runQueuePoller = async (
185
252
  log.info({ queues: targets.map((t) => t.queueUrl) }, "poller: starting");
186
253
 
187
254
  await Promise.all(
188
- targets.map((target) => pollTarget(target, log, isShuttingDown)),
255
+ targets.map((target) =>
256
+ pollTarget(target, log, isShuttingDown, shutdown.signal, buildHeartbeat),
257
+ ),
189
258
  );
190
259
  };