@remit/sqs-client 0.0.8 → 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/heartbeat.test.ts +102 -0
- package/src/heartbeat.ts +68 -0
- package/src/poller.test.ts +152 -2
- package/src/poller.ts +87 -18
package/package.json
CHANGED
|
@@ -0,0 +1,102 @@
|
|
|
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/heartbeat.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { readdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Poll-loop liveness as a file (docs/design/standalone-observability.md, D1).
|
|
6
|
+
*
|
|
7
|
+
* A worker whose poll loop wedges inside a socket read never exits, so
|
|
8
|
+
* `restart: unless-stopped` never fires and `docker compose ps` reports it
|
|
9
|
+
* running while mail stops moving. Each loop rewrites its own file once per
|
|
10
|
+
* receive attempt; a container healthcheck reads the age of the oldest of them
|
|
11
|
+
* and fails past a threshold above the longest legitimate handler run.
|
|
12
|
+
*
|
|
13
|
+
* One file per loop, not one per container: a worker runs a loop per queue, and
|
|
14
|
+
* a receive loop only ever talks to the queue sidecar. One handler wedged in an
|
|
15
|
+
* IMAP read leaves imap-worker's other five loops long-polling, so a
|
|
16
|
+
* container-wide file stays fresh through exactly the failure this exists to
|
|
17
|
+
* catch. One writer per file also means a reader cannot catch a torn write.
|
|
18
|
+
*
|
|
19
|
+
* A file rather than an HTTP route because an HTTP handler answers from a
|
|
20
|
+
* process whose poll loop is wedged — any honest health endpoint would have to
|
|
21
|
+
* consult this same timestamp.
|
|
22
|
+
*
|
|
23
|
+
* The heartbeat proves the loop is turning, not that the last unit of work
|
|
24
|
+
* succeeded. A loop that receives, fails every handler and rewrites its file
|
|
25
|
+
* each cycle is healthy here.
|
|
26
|
+
*/
|
|
27
|
+
export type Heartbeat = () => Promise<void>;
|
|
28
|
+
|
|
29
|
+
const noHeartbeat: Heartbeat = () => Promise.resolve();
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `WORKER_HEARTBEAT_PREFIX` is set per worker service by the self-host compose
|
|
33
|
+
* stack, which owns both the path and the volume it lands on; each loop appends
|
|
34
|
+
* its own queue name. Unset — the Lambda deployment, where the poller does not
|
|
35
|
+
* run at all, and any local process — writes nothing.
|
|
36
|
+
*
|
|
37
|
+
* The written value is the timestamp itself, so the file is readable as well as
|
|
38
|
+
* stat-able: an empty rewrite is not guaranteed to advance mtime when the file
|
|
39
|
+
* is already zero-length, and mtime is the whole signal.
|
|
40
|
+
*/
|
|
41
|
+
export const createHeartbeat = (
|
|
42
|
+
name: string,
|
|
43
|
+
prefix: string | undefined = process.env.WORKER_HEARTBEAT_PREFIX,
|
|
44
|
+
): Heartbeat => {
|
|
45
|
+
if (!prefix) return noHeartbeat;
|
|
46
|
+
const filePath = `${prefix}.${name}`;
|
|
47
|
+
return () => writeFile(filePath, `${new Date().toISOString()}\n`);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Drops the files this service left on the volume, before its loops start
|
|
52
|
+
* writing again. The healthcheck fails on the oldest file it finds, so a queue
|
|
53
|
+
* a later version stops polling would otherwise leave a file nobody rewrites,
|
|
54
|
+
* and a worker that can never report healthy again.
|
|
55
|
+
*/
|
|
56
|
+
export const clearHeartbeats = async (
|
|
57
|
+
prefix: string | undefined = process.env.WORKER_HEARTBEAT_PREFIX,
|
|
58
|
+
): Promise<void> => {
|
|
59
|
+
if (!prefix) return;
|
|
60
|
+
const directory = dirname(prefix);
|
|
61
|
+
const owned = `${basename(prefix)}.`;
|
|
62
|
+
const names = await readdir(directory);
|
|
63
|
+
await Promise.all(
|
|
64
|
+
names
|
|
65
|
+
.filter((name) => name.startsWith(owned))
|
|
66
|
+
.map((name) => rm(join(directory, name))),
|
|
67
|
+
);
|
|
68
|
+
};
|
package/src/poller.test.ts
CHANGED
|
@@ -7,8 +7,8 @@ import { runQueuePoller } from "./poller.js";
|
|
|
7
7
|
const QUEUE_URL = "http://localhost:9324/000000000000/remit-test";
|
|
8
8
|
|
|
9
9
|
const buildLog = () => ({
|
|
10
|
-
info: mock.fn(() => {}),
|
|
11
|
-
error: mock.fn(() => {}),
|
|
10
|
+
info: mock.fn((_fields: Record<string, unknown>, _message: string) => {}),
|
|
11
|
+
error: mock.fn((_fields: Record<string, unknown>, _message: string) => {}),
|
|
12
12
|
});
|
|
13
13
|
|
|
14
14
|
const buildMessage = (id: string) => ({
|
|
@@ -81,6 +81,156 @@ 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
|
+
|
|
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
|
+
|
|
84
234
|
it("throws when constructed with no targets", async () => {
|
|
85
235
|
await assert.rejects(
|
|
86
236
|
() => runQueuePoller({ targets: [], log: buildLog() }),
|
package/src/poller.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { inspect } from "node:util";
|
|
1
2
|
import {
|
|
2
3
|
DeleteMessageCommand,
|
|
3
4
|
ReceiveMessageCommand,
|
|
5
|
+
type ReceiveMessageCommandOutput,
|
|
4
6
|
} from "@aws-sdk/client-sqs";
|
|
5
7
|
import type {
|
|
6
8
|
Context,
|
|
@@ -8,6 +10,11 @@ import type {
|
|
|
8
10
|
SQSEvent,
|
|
9
11
|
SQSHandler,
|
|
10
12
|
} from "aws-lambda";
|
|
13
|
+
import {
|
|
14
|
+
clearHeartbeats,
|
|
15
|
+
createHeartbeat,
|
|
16
|
+
type Heartbeat,
|
|
17
|
+
} from "./heartbeat.js";
|
|
11
18
|
import { createQueueProducer } from "./producer.js";
|
|
12
19
|
|
|
13
20
|
/**
|
|
@@ -41,17 +48,29 @@ export interface RunQueuePollerOptions {
|
|
|
41
48
|
readonly targets: readonly QueuePollerTarget[];
|
|
42
49
|
readonly log: QueuePollerLog;
|
|
43
50
|
readonly signals?: readonly NodeJS.Signals[];
|
|
51
|
+
/** One per target. Defaults to a file under `WORKER_HEARTBEAT_PREFIX`. */
|
|
52
|
+
readonly createHeartbeat?: (name: string) => Heartbeat;
|
|
44
53
|
}
|
|
45
54
|
|
|
46
|
-
|
|
55
|
+
// One message per receive, not the SQS maximum of 10. Handlers process a batch
|
|
56
|
+
// sequentially, so a batch of ten is ten handler runs between two heartbeats:
|
|
57
|
+
// ten stalled SMTP sends at nodemailer's 300 s socket timeout is 50 minutes of
|
|
58
|
+
// legitimate silence, and a threshold that tolerates it detects nothing. At one
|
|
59
|
+
// message the quiet stretch a healthy loop can produce is one handler run, and
|
|
60
|
+
// the extra receive per message is a millisecond against the local queue
|
|
61
|
+
// sidecar — the same total work either way.
|
|
62
|
+
const DEFAULT_MAX_MESSAGES = 1;
|
|
47
63
|
const DEFAULT_VISIBILITY_TIMEOUT_SECONDS = 300;
|
|
48
64
|
|
|
49
65
|
const pollTarget = async (
|
|
50
66
|
target: QueuePollerTarget,
|
|
51
67
|
log: QueuePollerLog,
|
|
52
68
|
isShuttingDown: () => boolean,
|
|
69
|
+
shutdownSignal: AbortSignal,
|
|
70
|
+
buildHeartbeat: (name: string) => Heartbeat,
|
|
53
71
|
): Promise<void> => {
|
|
54
72
|
const queueName = new URL(target.queueUrl).pathname.split("/").pop();
|
|
73
|
+
const heartbeat = buildHeartbeat(queueName ?? target.functionName);
|
|
55
74
|
const sqs = createQueueProducer({ queueUrl: target.queueUrl });
|
|
56
75
|
const maxMessages = target.maxMessages ?? DEFAULT_MAX_MESSAGES;
|
|
57
76
|
const visibilityTimeout =
|
|
@@ -68,15 +87,46 @@ const pollTarget = async (
|
|
|
68
87
|
const LONG_POLL_WAIT_SECONDS = 20;
|
|
69
88
|
|
|
70
89
|
while (!isShuttingDown()) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
90
|
+
// Top of the receive attempt, before the long poll: this loop's own file,
|
|
91
|
+
// so a loop that stops turning — wedged in a socket read, or in a handler
|
|
92
|
+
// that never returns — goes stale on its own however busy its siblings are.
|
|
93
|
+
//
|
|
94
|
+
// A write that fails must not take the worker down with it. A full disk is
|
|
95
|
+
// the likeliest cause and the moment the loop should stay up, failing
|
|
96
|
+
// records into the dead-letter queues with logs rather than crash-looping
|
|
97
|
+
// on the monitoring. The missed beat is itself the signal: the healthcheck
|
|
98
|
+
// goes stale, which is what this file exists to report.
|
|
99
|
+
await heartbeat().catch((error) => {
|
|
100
|
+
log.error(
|
|
101
|
+
{ queue: queueName, error: inspect(error) },
|
|
102
|
+
"poller: heartbeat write failed",
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
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
|
+
}
|
|
80
130
|
|
|
81
131
|
if (!response.Messages || response.Messages.length === 0) {
|
|
82
132
|
continue;
|
|
@@ -129,9 +179,9 @@ const pollTarget = async (
|
|
|
129
179
|
);
|
|
130
180
|
const succeeded = messages.filter((m) => !failedIds.has(m.messageId));
|
|
131
181
|
|
|
132
|
-
// Batch is capped at maxMessages (
|
|
133
|
-
// concurrent DeleteMessage calls is safe and avoids paying
|
|
134
|
-
// round trips between every receive/handler cycle.
|
|
182
|
+
// Batch is capped at maxMessages (one by default, 10 is the SQS ceiling),
|
|
183
|
+
// so a handful of concurrent DeleteMessage calls is safe and avoids paying
|
|
184
|
+
// N sequential round trips between every receive/handler cycle.
|
|
135
185
|
await Promise.all(
|
|
136
186
|
succeeded.map((message) =>
|
|
137
187
|
sqs.send(
|
|
@@ -157,26 +207,43 @@ const pollTarget = async (
|
|
|
157
207
|
};
|
|
158
208
|
|
|
159
209
|
/**
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
* current
|
|
163
|
-
* 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.
|
|
215
|
+
*
|
|
216
|
+
* Every loop writes its own heartbeat file (see heartbeat.ts) at the top of its
|
|
217
|
+
* receive attempt, which is how a loop that hangs instead of throwing — the
|
|
218
|
+
* case this cannot make loud by itself — becomes visible.
|
|
164
219
|
*/
|
|
165
220
|
export const runQueuePoller = async (
|
|
166
221
|
options: RunQueuePollerOptions,
|
|
167
222
|
): Promise<void> => {
|
|
168
223
|
const { targets, log } = options;
|
|
169
224
|
const signals = options.signals ?? ["SIGINT", "SIGTERM"];
|
|
225
|
+
const buildHeartbeat = options.createHeartbeat ?? createHeartbeat;
|
|
170
226
|
|
|
171
227
|
if (targets.length === 0) {
|
|
172
228
|
throw new Error("runQueuePoller: no targets configured");
|
|
173
229
|
}
|
|
174
230
|
|
|
231
|
+
// Files from the previous run of this service, before its loops write again:
|
|
232
|
+
// the healthcheck reads the oldest file it finds, so one left behind by a
|
|
233
|
+
// queue this version no longer polls would fail the check forever.
|
|
234
|
+
await clearHeartbeats().catch((error) => {
|
|
235
|
+
log.error({ error: inspect(error) }, "poller: heartbeat cleanup failed");
|
|
236
|
+
});
|
|
237
|
+
|
|
175
238
|
let shuttingDown = false;
|
|
176
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();
|
|
177
243
|
const onSignal = (signal: NodeJS.Signals) => {
|
|
178
244
|
log.info({ signal }, "poller: shutdown signal received");
|
|
179
245
|
shuttingDown = true;
|
|
246
|
+
shutdown.abort();
|
|
180
247
|
};
|
|
181
248
|
for (const signal of signals) {
|
|
182
249
|
process.on(signal, onSignal);
|
|
@@ -185,6 +252,8 @@ export const runQueuePoller = async (
|
|
|
185
252
|
log.info({ queues: targets.map((t) => t.queueUrl) }, "poller: starting");
|
|
186
253
|
|
|
187
254
|
await Promise.all(
|
|
188
|
-
targets.map((target) =>
|
|
255
|
+
targets.map((target) =>
|
|
256
|
+
pollTarget(target, log, isShuttingDown, shutdown.signal, buildHeartbeat),
|
|
257
|
+
),
|
|
189
258
|
);
|
|
190
259
|
};
|