@remit/sqs-client 0.0.9 → 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.9",
3
+ "version": "0.0.10",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -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;
package/src/poller.ts CHANGED
@@ -2,6 +2,7 @@ import { inspect } from "node:util";
2
2
  import {
3
3
  DeleteMessageCommand,
4
4
  ReceiveMessageCommand,
5
+ type ReceiveMessageCommandOutput,
5
6
  } from "@aws-sdk/client-sqs";
6
7
  import type {
7
8
  Context,
@@ -65,6 +66,7 @@ const pollTarget = async (
65
66
  target: QueuePollerTarget,
66
67
  log: QueuePollerLog,
67
68
  isShuttingDown: () => boolean,
69
+ shutdownSignal: AbortSignal,
68
70
  buildHeartbeat: (name: string) => Heartbeat,
69
71
  ): Promise<void> => {
70
72
  const queueName = new URL(target.queueUrl).pathname.split("/").pop();
@@ -101,15 +103,30 @@ const pollTarget = async (
101
103
  );
102
104
  });
103
105
 
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
- );
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
+ }
113
130
 
114
131
  if (!response.Messages || response.Messages.length === 0) {
115
132
  continue;
@@ -190,10 +207,11 @@ const pollTarget = async (
190
207
  };
191
208
 
192
209
  /**
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.
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.
197
215
  *
198
216
  * Every loop writes its own heartbeat file (see heartbeat.ts) at the top of its
199
217
  * receive attempt, which is how a loop that hangs instead of throwing — the
@@ -219,9 +237,13 @@ export const runQueuePoller = async (
219
237
 
220
238
  let shuttingDown = false;
221
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();
222
243
  const onSignal = (signal: NodeJS.Signals) => {
223
244
  log.info({ signal }, "poller: shutdown signal received");
224
245
  shuttingDown = true;
246
+ shutdown.abort();
225
247
  };
226
248
  for (const signal of signals) {
227
249
  process.on(signal, onSignal);
@@ -231,7 +253,7 @@ export const runQueuePoller = async (
231
253
 
232
254
  await Promise.all(
233
255
  targets.map((target) =>
234
- pollTarget(target, log, isShuttingDown, buildHeartbeat),
256
+ pollTarget(target, log, isShuttingDown, shutdown.signal, buildHeartbeat),
235
257
  ),
236
258
  );
237
259
  };