@remit/sqs-client 0.0.9 → 0.0.11

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.9",
3
+ "version": "0.0.11",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -1,7 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it, mock } from "node:test";
3
3
  import { SQSClient } from "@aws-sdk/client-sqs";
4
- import type { SQSBatchResponse, SQSEvent } from "aws-lambda";
4
+ import type { Context, SQSBatchResponse, SQSEvent } from "aws-lambda";
5
5
  import { runQueuePoller } from "./poller.js";
6
6
 
7
7
  const QUEUE_URL = "http://localhost:9324/000000000000/remit-test";
@@ -81,6 +81,60 @@ 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
+
84
138
  it("gives every target its own heartbeat, beaten at the top of each receive attempt", async () => {
85
139
  const beatsByQueue = new Map<string, number>();
86
140
  let receiveCalls = 0;
@@ -177,6 +231,67 @@ describe("runQueuePoller", () => {
177
231
  assert.match(String(failures[0]?.arguments[0]?.error), /ENOSPC/);
178
232
  });
179
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
+
180
295
  it("throws when constructed with no targets", async () => {
181
296
  await assert.rejects(
182
297
  () => runQueuePoller({ targets: [], log: buildLog() }),
package/src/poller.ts CHANGED
@@ -1,7 +1,9 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { inspect } from "node:util";
2
3
  import {
3
4
  DeleteMessageCommand,
4
5
  ReceiveMessageCommand,
6
+ type ReceiveMessageCommandOutput,
5
7
  } from "@aws-sdk/client-sqs";
6
8
  import type {
7
9
  Context,
@@ -65,6 +67,7 @@ const pollTarget = async (
65
67
  target: QueuePollerTarget,
66
68
  log: QueuePollerLog,
67
69
  isShuttingDown: () => boolean,
70
+ shutdownSignal: AbortSignal,
68
71
  buildHeartbeat: (name: string) => Heartbeat,
69
72
  ): Promise<void> => {
70
73
  const queueName = new URL(target.queueUrl).pathname.split("/").pop();
@@ -73,8 +76,6 @@ const pollTarget = async (
73
76
  const maxMessages = target.maxMessages ?? DEFAULT_MAX_MESSAGES;
74
77
  const visibilityTimeout =
75
78
  target.visibilityTimeoutSeconds ?? DEFAULT_VISIBILITY_TIMEOUT_SECONDS;
76
- const lambdaContext = { functionName: target.functionName } as Context;
77
-
78
79
  log.info({ queue: queueName, maxMessages }, "poller: started");
79
80
 
80
81
  // Always long-poll (SQS max WaitTimeSeconds). An empty long poll is free
@@ -101,15 +102,30 @@ const pollTarget = async (
101
102
  );
102
103
  });
103
104
 
104
- const response = await sqs.send(
105
- new ReceiveMessageCommand({
106
- QueueUrl: target.queueUrl,
107
- MaxNumberOfMessages: maxMessages,
108
- WaitTimeSeconds: LONG_POLL_WAIT_SECONDS,
109
- VisibilityTimeout: visibilityTimeout,
110
- MessageSystemAttributeNames: ["ApproximateReceiveCount"],
111
- }),
112
- );
105
+ let response: ReceiveMessageCommandOutput;
106
+ try {
107
+ // Abort the receive on shutdown. Without this, a container stopped
108
+ // while idle waits out the current 20s long poll before the loop
109
+ // checks the flag again, and a self-update then burns the whole
110
+ // stop grace on every idle worker — the bulk of the visible restart
111
+ // downtime. The abort ends the in-flight receive at once; an
112
+ // in-flight handler below is never interrupted, so a message being
113
+ // processed still finishes cleanly. The heartbeat above already wrote
114
+ // this iteration, so the abort exits without a further beat.
115
+ response = await sqs.send(
116
+ new ReceiveMessageCommand({
117
+ QueueUrl: target.queueUrl,
118
+ MaxNumberOfMessages: maxMessages,
119
+ WaitTimeSeconds: LONG_POLL_WAIT_SECONDS,
120
+ VisibilityTimeout: visibilityTimeout,
121
+ MessageSystemAttributeNames: ["ApproximateReceiveCount"],
122
+ }),
123
+ { abortSignal: shutdownSignal },
124
+ );
125
+ } catch (error) {
126
+ if (shutdownSignal.aborted) break;
127
+ throw error;
128
+ }
113
129
 
114
130
  if (!response.Messages || response.Messages.length === 0) {
115
131
  continue;
@@ -129,8 +145,26 @@ const pollTarget = async (
129
145
  );
130
146
  if (messages.length === 0) continue;
131
147
 
148
+ // One batch is one handler invocation, so the id is minted per batch: it is
149
+ // the `requestId` every line of that invocation is grouped by (see
150
+ // deploy/vps/README.md, "Logs"). A context built once per target would put
151
+ // the whole queue's lifetime under one id, which correlates nothing.
152
+ //
153
+ // Minted before the poller's own bracketing lines rather than next to the
154
+ // handler call: grouping on the id has to reach the line saying whether the
155
+ // message was deleted or left for redelivery, which is what someone looks
156
+ // for after a failure.
157
+ const lambdaContext = {
158
+ functionName: target.functionName,
159
+ awsRequestId: randomUUID(),
160
+ } as Context;
161
+
132
162
  log.info(
133
- { queue: queueName, count: messages.length },
163
+ {
164
+ queue: queueName,
165
+ count: messages.length,
166
+ requestId: lambdaContext.awsRequestId,
167
+ },
134
168
  "poller: invoking handler",
135
169
  );
136
170
 
@@ -181,6 +215,7 @@ const pollTarget = async (
181
215
  queue: queueName,
182
216
  deleted: succeeded.length,
183
217
  leftForRedelivery: failedIds.size,
218
+ requestId: lambdaContext.awsRequestId,
184
219
  },
185
220
  "poller: batch processed",
186
221
  );
@@ -190,10 +225,11 @@ const pollTarget = async (
190
225
  };
191
226
 
192
227
  /**
193
- * Runs every target's poll loop concurrently until a shutdown signal is
194
- * received (default: SIGINT, SIGTERM), then lets each loop finish its
195
- * current iteration and returns. Rejects (crashes the process) if any loop
196
- * throws — a stuck poller should exit loudly, not degrade silently.
228
+ * received (default: SIGINT, SIGTERM). The signal aborts each loop's in-flight
229
+ * long-poll receive so an idle worker returns at once instead of waiting out
230
+ * the current 20s poll; a loop mid-handler finishes that handler first. Rejects
231
+ * (crashes the process) if any loop throws — a stuck poller should exit loudly,
232
+ * not degrade silently.
197
233
  *
198
234
  * Every loop writes its own heartbeat file (see heartbeat.ts) at the top of its
199
235
  * receive attempt, which is how a loop that hangs instead of throwing — the
@@ -219,9 +255,13 @@ export const runQueuePoller = async (
219
255
 
220
256
  let shuttingDown = false;
221
257
  const isShuttingDown = () => shuttingDown;
258
+ // Aborted alongside the flag so a loop currently blocked in its long-poll
259
+ // receive returns at once, rather than after up to WaitTimeSeconds.
260
+ const shutdown = new AbortController();
222
261
  const onSignal = (signal: NodeJS.Signals) => {
223
262
  log.info({ signal }, "poller: shutdown signal received");
224
263
  shuttingDown = true;
264
+ shutdown.abort();
225
265
  };
226
266
  for (const signal of signals) {
227
267
  process.on(signal, onSignal);
@@ -231,7 +271,7 @@ export const runQueuePoller = async (
231
271
 
232
272
  await Promise.all(
233
273
  targets.map((target) =>
234
- pollTarget(target, log, isShuttingDown, buildHeartbeat),
274
+ pollTarget(target, log, isShuttingDown, shutdown.signal, buildHeartbeat),
235
275
  ),
236
276
  );
237
277
  };