@remit/sqs-client 0.0.8 → 0.0.9
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 +98 -2
- package/src/poller.ts +52 -5
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,102 @@ describe("runQueuePoller", () => {
|
|
|
81
81
|
assert.deepEqual(deletedReceiptHandles, ["receipt-m1"]);
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
+
it("gives every target its own heartbeat, beaten at the top of each receive attempt", async () => {
|
|
85
|
+
const beatsByQueue = new Map<string, number>();
|
|
86
|
+
let receiveCalls = 0;
|
|
87
|
+
|
|
88
|
+
const sendMock = mock.method(
|
|
89
|
+
SQSClient.prototype,
|
|
90
|
+
"send",
|
|
91
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal SDK command shape, not worth typing per-command here
|
|
92
|
+
async function (this: SQSClient, command: any) {
|
|
93
|
+
if (command.constructor.name !== "ReceiveMessageCommand") {
|
|
94
|
+
throw new Error(`unexpected command: ${command.constructor.name}`);
|
|
95
|
+
}
|
|
96
|
+
// Two empty receives per target, then shut down: the heartbeat has
|
|
97
|
+
// to be written before the receive, and on an empty poll too — an
|
|
98
|
+
// idle queue is the normal state of a healthy worker.
|
|
99
|
+
receiveCalls++;
|
|
100
|
+
if (receiveCalls >= 4) process.emit("SIGWINCH", "SIGWINCH");
|
|
101
|
+
return { Messages: [] };
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
await runQueuePoller({
|
|
107
|
+
targets: [
|
|
108
|
+
{ queueUrl: QUEUE_URL, handler: async () => {}, functionName: "a" },
|
|
109
|
+
{
|
|
110
|
+
queueUrl: `${QUEUE_URL}-2`,
|
|
111
|
+
handler: async () => {},
|
|
112
|
+
functionName: "b",
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
log: buildLog(),
|
|
116
|
+
signals: ["SIGWINCH"],
|
|
117
|
+
createHeartbeat: (name) => async () => {
|
|
118
|
+
beatsByQueue.set(name, (beatsByQueue.get(name) ?? 0) + 1);
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
} finally {
|
|
122
|
+
sendMock.mock.restore();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
assert.ok(receiveCalls >= 4, `only ${receiveCalls} receives ran`);
|
|
126
|
+
// One heartbeat per queue, named after it: a wedged loop has to go stale
|
|
127
|
+
// on its own rather than ride on a sibling that is still turning.
|
|
128
|
+
assert.deepEqual([...beatsByQueue.keys()].sort(), [
|
|
129
|
+
"remit-test",
|
|
130
|
+
"remit-test-2",
|
|
131
|
+
]);
|
|
132
|
+
assert.equal(
|
|
133
|
+
[...beatsByQueue.values()].reduce((sum, count) => sum + count, 0),
|
|
134
|
+
receiveCalls,
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("keeps polling and logs when a heartbeat write fails", async () => {
|
|
139
|
+
let receiveCalls = 0;
|
|
140
|
+
const log = buildLog();
|
|
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
|
+
receiveCalls++;
|
|
151
|
+
if (receiveCalls >= 2) process.emit("SIGWINCH", "SIGWINCH");
|
|
152
|
+
return { Messages: [] };
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
// A full disk is the likeliest cause, and the moment the loop must stay
|
|
158
|
+
// up: the healthcheck reports the missed beat by itself.
|
|
159
|
+
await runQueuePoller({
|
|
160
|
+
targets: [
|
|
161
|
+
{ queueUrl: QUEUE_URL, handler: async () => {}, functionName: "a" },
|
|
162
|
+
],
|
|
163
|
+
log,
|
|
164
|
+
signals: ["SIGWINCH"],
|
|
165
|
+
createHeartbeat: () => () =>
|
|
166
|
+
Promise.reject(new Error("ENOSPC: no space left on device")),
|
|
167
|
+
});
|
|
168
|
+
} finally {
|
|
169
|
+
sendMock.mock.restore();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
assert.ok(receiveCalls >= 2, `only ${receiveCalls} receives ran`);
|
|
173
|
+
const failures = log.error.mock.calls.filter(
|
|
174
|
+
(call) => call.arguments[1] === "poller: heartbeat write failed",
|
|
175
|
+
);
|
|
176
|
+
assert.equal(failures.length, receiveCalls);
|
|
177
|
+
assert.match(String(failures[0]?.arguments[0]?.error), /ENOSPC/);
|
|
178
|
+
});
|
|
179
|
+
|
|
84
180
|
it("throws when constructed with no targets", async () => {
|
|
85
181
|
await assert.rejects(
|
|
86
182
|
() => runQueuePoller({ targets: [], log: buildLog() }),
|
package/src/poller.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { inspect } from "node:util";
|
|
1
2
|
import {
|
|
2
3
|
DeleteMessageCommand,
|
|
3
4
|
ReceiveMessageCommand,
|
|
@@ -8,6 +9,11 @@ import type {
|
|
|
8
9
|
SQSEvent,
|
|
9
10
|
SQSHandler,
|
|
10
11
|
} from "aws-lambda";
|
|
12
|
+
import {
|
|
13
|
+
clearHeartbeats,
|
|
14
|
+
createHeartbeat,
|
|
15
|
+
type Heartbeat,
|
|
16
|
+
} from "./heartbeat.js";
|
|
11
17
|
import { createQueueProducer } from "./producer.js";
|
|
12
18
|
|
|
13
19
|
/**
|
|
@@ -41,17 +47,28 @@ export interface RunQueuePollerOptions {
|
|
|
41
47
|
readonly targets: readonly QueuePollerTarget[];
|
|
42
48
|
readonly log: QueuePollerLog;
|
|
43
49
|
readonly signals?: readonly NodeJS.Signals[];
|
|
50
|
+
/** One per target. Defaults to a file under `WORKER_HEARTBEAT_PREFIX`. */
|
|
51
|
+
readonly createHeartbeat?: (name: string) => Heartbeat;
|
|
44
52
|
}
|
|
45
53
|
|
|
46
|
-
|
|
54
|
+
// One message per receive, not the SQS maximum of 10. Handlers process a batch
|
|
55
|
+
// sequentially, so a batch of ten is ten handler runs between two heartbeats:
|
|
56
|
+
// ten stalled SMTP sends at nodemailer's 300 s socket timeout is 50 minutes of
|
|
57
|
+
// legitimate silence, and a threshold that tolerates it detects nothing. At one
|
|
58
|
+
// message the quiet stretch a healthy loop can produce is one handler run, and
|
|
59
|
+
// the extra receive per message is a millisecond against the local queue
|
|
60
|
+
// sidecar — the same total work either way.
|
|
61
|
+
const DEFAULT_MAX_MESSAGES = 1;
|
|
47
62
|
const DEFAULT_VISIBILITY_TIMEOUT_SECONDS = 300;
|
|
48
63
|
|
|
49
64
|
const pollTarget = async (
|
|
50
65
|
target: QueuePollerTarget,
|
|
51
66
|
log: QueuePollerLog,
|
|
52
67
|
isShuttingDown: () => boolean,
|
|
68
|
+
buildHeartbeat: (name: string) => Heartbeat,
|
|
53
69
|
): Promise<void> => {
|
|
54
70
|
const queueName = new URL(target.queueUrl).pathname.split("/").pop();
|
|
71
|
+
const heartbeat = buildHeartbeat(queueName ?? target.functionName);
|
|
55
72
|
const sqs = createQueueProducer({ queueUrl: target.queueUrl });
|
|
56
73
|
const maxMessages = target.maxMessages ?? DEFAULT_MAX_MESSAGES;
|
|
57
74
|
const visibilityTimeout =
|
|
@@ -68,6 +85,22 @@ const pollTarget = async (
|
|
|
68
85
|
const LONG_POLL_WAIT_SECONDS = 20;
|
|
69
86
|
|
|
70
87
|
while (!isShuttingDown()) {
|
|
88
|
+
// Top of the receive attempt, before the long poll: this loop's own file,
|
|
89
|
+
// so a loop that stops turning — wedged in a socket read, or in a handler
|
|
90
|
+
// that never returns — goes stale on its own however busy its siblings are.
|
|
91
|
+
//
|
|
92
|
+
// A write that fails must not take the worker down with it. A full disk is
|
|
93
|
+
// the likeliest cause and the moment the loop should stay up, failing
|
|
94
|
+
// records into the dead-letter queues with logs rather than crash-looping
|
|
95
|
+
// on the monitoring. The missed beat is itself the signal: the healthcheck
|
|
96
|
+
// goes stale, which is what this file exists to report.
|
|
97
|
+
await heartbeat().catch((error) => {
|
|
98
|
+
log.error(
|
|
99
|
+
{ queue: queueName, error: inspect(error) },
|
|
100
|
+
"poller: heartbeat write failed",
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
71
104
|
const response = await sqs.send(
|
|
72
105
|
new ReceiveMessageCommand({
|
|
73
106
|
QueueUrl: target.queueUrl,
|
|
@@ -129,9 +162,9 @@ const pollTarget = async (
|
|
|
129
162
|
);
|
|
130
163
|
const succeeded = messages.filter((m) => !failedIds.has(m.messageId));
|
|
131
164
|
|
|
132
|
-
// Batch is capped at maxMessages (
|
|
133
|
-
// concurrent DeleteMessage calls is safe and avoids paying
|
|
134
|
-
// round trips between every receive/handler cycle.
|
|
165
|
+
// Batch is capped at maxMessages (one by default, 10 is the SQS ceiling),
|
|
166
|
+
// so a handful of concurrent DeleteMessage calls is safe and avoids paying
|
|
167
|
+
// N sequential round trips between every receive/handler cycle.
|
|
135
168
|
await Promise.all(
|
|
136
169
|
succeeded.map((message) =>
|
|
137
170
|
sqs.send(
|
|
@@ -161,17 +194,29 @@ const pollTarget = async (
|
|
|
161
194
|
* received (default: SIGINT, SIGTERM), then lets each loop finish its
|
|
162
195
|
* current iteration and returns. Rejects (crashes the process) if any loop
|
|
163
196
|
* throws — a stuck poller should exit loudly, not degrade silently.
|
|
197
|
+
*
|
|
198
|
+
* Every loop writes its own heartbeat file (see heartbeat.ts) at the top of its
|
|
199
|
+
* receive attempt, which is how a loop that hangs instead of throwing — the
|
|
200
|
+
* case this cannot make loud by itself — becomes visible.
|
|
164
201
|
*/
|
|
165
202
|
export const runQueuePoller = async (
|
|
166
203
|
options: RunQueuePollerOptions,
|
|
167
204
|
): Promise<void> => {
|
|
168
205
|
const { targets, log } = options;
|
|
169
206
|
const signals = options.signals ?? ["SIGINT", "SIGTERM"];
|
|
207
|
+
const buildHeartbeat = options.createHeartbeat ?? createHeartbeat;
|
|
170
208
|
|
|
171
209
|
if (targets.length === 0) {
|
|
172
210
|
throw new Error("runQueuePoller: no targets configured");
|
|
173
211
|
}
|
|
174
212
|
|
|
213
|
+
// Files from the previous run of this service, before its loops write again:
|
|
214
|
+
// the healthcheck reads the oldest file it finds, so one left behind by a
|
|
215
|
+
// queue this version no longer polls would fail the check forever.
|
|
216
|
+
await clearHeartbeats().catch((error) => {
|
|
217
|
+
log.error({ error: inspect(error) }, "poller: heartbeat cleanup failed");
|
|
218
|
+
});
|
|
219
|
+
|
|
175
220
|
let shuttingDown = false;
|
|
176
221
|
const isShuttingDown = () => shuttingDown;
|
|
177
222
|
const onSignal = (signal: NodeJS.Signals) => {
|
|
@@ -185,6 +230,8 @@ export const runQueuePoller = async (
|
|
|
185
230
|
log.info({ queues: targets.map((t) => t.queueUrl) }, "poller: starting");
|
|
186
231
|
|
|
187
232
|
await Promise.all(
|
|
188
|
-
targets.map((target) =>
|
|
233
|
+
targets.map((target) =>
|
|
234
|
+
pollTarget(target, log, isShuttingDown, buildHeartbeat),
|
|
235
|
+
),
|
|
189
236
|
);
|
|
190
237
|
};
|