@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 +1 -1
- package/src/poller.test.ts +54 -0
- package/src/poller.ts +36 -14
package/package.json
CHANGED
package/src/poller.test.ts
CHANGED
|
@@ -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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
*
|
|
194
|
-
*
|
|
195
|
-
* current
|
|
196
|
-
* throws — a stuck poller should exit loudly,
|
|
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
|
};
|