@remit/sqs-client 0.0.16 → 0.0.18

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.
@@ -1,58 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { afterEach, describe, it } from "node:test";
3
- import { attemptBudget } from "./attempt-budget.js";
4
-
5
- describe("attemptBudget", () => {
6
- afterEach(() => {
7
- delete process.env.EXAMPLE_MAX_ATTEMPTS;
8
- });
9
-
10
- it("parses the named env var as a base-10 integer", () => {
11
- assert.equal(
12
- attemptBudget("EXAMPLE_MAX_ATTEMPTS", 3, {
13
- EXAMPLE_MAX_ATTEMPTS: "5",
14
- }),
15
- 5,
16
- );
17
- assert.equal(
18
- attemptBudget("EXAMPLE_MAX_ATTEMPTS", 3, {
19
- EXAMPLE_MAX_ATTEMPTS: "10",
20
- }),
21
- 10,
22
- );
23
- });
24
-
25
- it("reads only the named var, so budgets stay independent", () => {
26
- assert.equal(
27
- attemptBudget("EXAMPLE_MAX_ATTEMPTS", 3, {
28
- OTHER_MAX_ATTEMPTS: "9",
29
- }),
30
- 3,
31
- );
32
- });
33
-
34
- it("falls back when the var is absent or empty", () => {
35
- assert.equal(attemptBudget("EXAMPLE_MAX_ATTEMPTS", 3, {}), 3);
36
- assert.equal(
37
- attemptBudget("EXAMPLE_MAX_ATTEMPTS", 7, { EXAMPLE_MAX_ATTEMPTS: "" }),
38
- 7,
39
- );
40
- });
41
-
42
- it("falls back on a value that is not a positive integer", () => {
43
- for (const raw of ["nope", "0", "-1", "NaN", " "]) {
44
- assert.equal(
45
- attemptBudget("EXAMPLE_MAX_ATTEMPTS", 3, {
46
- EXAMPLE_MAX_ATTEMPTS: raw,
47
- }),
48
- 3,
49
- `expected ${JSON.stringify(raw)} to fall back`,
50
- );
51
- }
52
- });
53
-
54
- it("reads process.env when no environment is supplied", () => {
55
- process.env.EXAMPLE_MAX_ATTEMPTS = "4";
56
- assert.equal(attemptBudget("EXAMPLE_MAX_ATTEMPTS", 3), 4);
57
- });
58
- });
@@ -1,102 +0,0 @@
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
- });
package/src/index.test.ts DELETED
@@ -1,39 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { test } from "node:test";
3
- import { resolveSqsCredentials } from "./index.js";
4
-
5
- test("returns the SQS pair when both are set", () => {
6
- const creds = resolveSqsCredentials({
7
- SQS_ACCESS_KEY_ID: "SCWXXX",
8
- SQS_SECRET_ACCESS_KEY: "secret",
9
- });
10
- assert.deepEqual(creds, { accessKeyId: "SCWXXX", secretAccessKey: "secret" });
11
- });
12
-
13
- test("falls back to the default chain when the access key is missing", () => {
14
- assert.equal(
15
- resolveSqsCredentials({ SQS_SECRET_ACCESS_KEY: "secret" }),
16
- undefined,
17
- );
18
- });
19
-
20
- test("falls back to the default chain when the secret is missing", () => {
21
- assert.equal(
22
- resolveSqsCredentials({ SQS_ACCESS_KEY_ID: "SCWXXX" }),
23
- undefined,
24
- );
25
- });
26
-
27
- test("falls back to the default chain when neither is set", () => {
28
- assert.equal(resolveSqsCredentials({}), undefined);
29
- });
30
-
31
- test("ignores the AWS S3 credentials — reads only the SQS pair", () => {
32
- assert.equal(
33
- resolveSqsCredentials({
34
- AWS_ACCESS_KEY_ID: "AKIA",
35
- AWS_SECRET_ACCESS_KEY: "aws-secret",
36
- }),
37
- undefined,
38
- );
39
- });
@@ -1,301 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it, mock } from "node:test";
3
- import { SQSClient } from "@aws-sdk/client-sqs";
4
- import type { Context, SQSBatchResponse, SQSEvent } from "aws-lambda";
5
- import { runQueuePoller } from "./poller.js";
6
-
7
- const QUEUE_URL = "http://localhost:9324/000000000000/remit-test";
8
-
9
- const buildLog = () => ({
10
- info: mock.fn((_fields: Record<string, unknown>, _message: string) => {}),
11
- error: mock.fn((_fields: Record<string, unknown>, _message: string) => {}),
12
- });
13
-
14
- const buildMessage = (id: string) => ({
15
- MessageId: id,
16
- ReceiptHandle: `receipt-${id}`,
17
- Body: "{}",
18
- Attributes: { ApproximateReceiveCount: "1" },
19
- });
20
-
21
- describe("runQueuePoller", () => {
22
- it("deletes succeeded messages and leaves batchItemFailures for redelivery, then stops on signal", async () => {
23
- const deletedReceiptHandles: string[] = [];
24
- let receiveCalls = 0;
25
-
26
- // SQSClient.prototype.send is mocked globally (poller.ts constructs its
27
- // own client internally — there is no injection seam), matching the
28
- // same shape every AWS SDK v3 command carries: `{ input, constructor }`.
29
- const sendMock = mock.method(
30
- SQSClient.prototype,
31
- "send",
32
- // biome-ignore lint/suspicious/noExplicitAny: minimal SDK command shape, not worth typing per-command here
33
- async function (this: SQSClient, command: any) {
34
- if (command.constructor.name === "ReceiveMessageCommand") {
35
- receiveCalls++;
36
- if (receiveCalls === 1) {
37
- // Simulate the shutdown signal landing while this batch is
38
- // in flight — the loop is expected to finish processing it
39
- // (delete succeeded / leave failures) and then exit before
40
- // issuing a second ReceiveMessage.
41
- process.emit("SIGWINCH", "SIGWINCH");
42
- return { Messages: [buildMessage("m1"), buildMessage("m2")] };
43
- }
44
- throw new Error(
45
- "unexpected second ReceiveMessage — poller did not stop on signal",
46
- );
47
- }
48
- if (command.constructor.name === "DeleteMessageCommand") {
49
- deletedReceiptHandles.push(command.input.ReceiptHandle);
50
- return {};
51
- }
52
- throw new Error(`unexpected command: ${command.constructor.name}`);
53
- },
54
- );
55
-
56
- const handler = mock.fn(
57
- async (_event: SQSEvent): Promise<SQSBatchResponse> => ({
58
- batchItemFailures: [{ itemIdentifier: "m2" }],
59
- }),
60
- );
61
-
62
- try {
63
- await runQueuePoller({
64
- targets: [{ queueUrl: QUEUE_URL, handler, functionName: "test-fn" }],
65
- log: buildLog(),
66
- signals: ["SIGWINCH"],
67
- });
68
- } finally {
69
- sendMock.mock.restore();
70
- }
71
-
72
- assert.equal(handler.mock.calls.length, 1);
73
- const event = handler.mock.calls[0]?.arguments[0] as SQSEvent;
74
- assert.deepEqual(
75
- event.Records.map((r) => r.messageId),
76
- ["m1", "m2"],
77
- );
78
-
79
- // m1 succeeded (not in batchItemFailures) -> deleted. m2 failed -> left
80
- // for redelivery, matching the SQS batchItemFailures contract.
81
- assert.deepEqual(deletedReceiptHandles, ["receipt-m1"]);
82
- });
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
-
234
- // The `requestId` every line of one invocation is grouped by
235
- // (deploy/vps/README.md, "Logs") is minted here, and it has to reach both the
236
- // handler and the poller's own bracketing lines — otherwise grouping on it
237
- // stops right before the line saying what happened to the message.
238
- it("gives each batch its own requestId, on the handler context and on its own lines", async () => {
239
- let receiveCalls = 0;
240
- const sendMock = mock.method(
241
- SQSClient.prototype,
242
- "send",
243
- // biome-ignore lint/suspicious/noExplicitAny: minimal SDK command shape, not worth typing per-command here
244
- async function (this: SQSClient, command: any) {
245
- if (command.constructor.name === "ReceiveMessageCommand") {
246
- receiveCalls++;
247
- if (receiveCalls >= 2) process.emit("SIGWINCH", "SIGWINCH");
248
- return { Messages: [buildMessage(`m${receiveCalls}`)] };
249
- }
250
- if (command.constructor.name === "DeleteMessageCommand") return {};
251
- throw new Error(`unexpected command: ${command.constructor.name}`);
252
- },
253
- );
254
-
255
- const handler = mock.fn(
256
- async (
257
- _event: SQSEvent,
258
- _context: Context,
259
- ): Promise<SQSBatchResponse> => ({ batchItemFailures: [] }),
260
- );
261
- const log = buildLog();
262
-
263
- try {
264
- await runQueuePoller({
265
- targets: [{ queueUrl: QUEUE_URL, handler, functionName: "test-fn" }],
266
- log,
267
- signals: ["SIGWINCH"],
268
- createHeartbeat: () => async () => {},
269
- });
270
- } finally {
271
- sendMock.mock.restore();
272
- }
273
-
274
- const requestIds = handler.mock.calls.map(
275
- (call) => call.arguments[1].awsRequestId,
276
- );
277
- assert.equal(requestIds.length, 2);
278
- for (const requestId of requestIds)
279
- assert.match(requestId, /^[0-9a-f-]{36}$/);
280
- assert.notEqual(requestIds[0], requestIds[1]);
281
-
282
- const bracketing = ["poller: invoking handler", "poller: batch processed"];
283
- for (const message of bracketing) {
284
- const lines = log.info.mock.calls.filter(
285
- (call) => call.arguments[1] === message,
286
- );
287
- assert.equal(lines.length, 2, `expected two ${message} lines`);
288
- assert.deepEqual(
289
- lines.map((call) => call.arguments[0].requestId),
290
- requestIds,
291
- );
292
- }
293
- });
294
-
295
- it("throws when constructed with no targets", async () => {
296
- await assert.rejects(
297
- () => runQueuePoller({ targets: [], log: buildLog() }),
298
- /no targets configured/,
299
- );
300
- });
301
- });