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