@remit/queue-sidecar 0.0.11 → 0.0.12
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/index.ts +1 -0
- package/src/main.ts +4 -0
- package/src/metrics.test.ts +13 -0
- package/src/metrics.ts +12 -0
- package/src/protocol.ts +3 -0
- package/src/redrive-cli.ts +9 -0
- package/src/redrive.test.ts +104 -0
- package/src/redrive.ts +139 -0
- package/src/release-redrive.test.ts +86 -0
- package/src/server.ts +22 -0
- package/src/sqs.test.ts +58 -0
- package/src/store.test.ts +1 -1
- package/src/store.ts +53 -4
package/package.json
CHANGED
package/src/index.ts
CHANGED
package/src/main.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdirSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { bootstrapQueues, loadQueuesConfig } from "./queues-config.js";
|
|
4
|
+
import { redriveOnRelease } from "./redrive.js";
|
|
4
5
|
import { createSidecarServer, type SidecarLog } from "./server.js";
|
|
5
6
|
import { QueueStore } from "./store.js";
|
|
6
7
|
|
|
@@ -50,6 +51,9 @@ if (queuesConfigPath) {
|
|
|
50
51
|
log.info({}, "sidecar: no queues config; queues created on demand");
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
const release = process.env.QUEUE_SIDECAR_RELEASE;
|
|
55
|
+
if (release) redriveOnRelease(store, release);
|
|
56
|
+
|
|
53
57
|
const server = createSidecarServer({ store, accountId, log });
|
|
54
58
|
|
|
55
59
|
server.listen(port, host, () => {
|
package/src/metrics.test.ts
CHANGED
|
@@ -158,6 +158,19 @@ describe("the sidecar /metrics endpoint", () => {
|
|
|
158
158
|
store.purgeQueue("work");
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
+
it("exports a received-but-undeleted message as in flight", async () => {
|
|
162
|
+
store.purgeQueue("work-dlq");
|
|
163
|
+
store.sendMessage({ queueName: "work-dlq", body: "peeked" });
|
|
164
|
+
store.receiveMessages({ queueName: "work-dlq", maxMessages: 1 });
|
|
165
|
+
const { body } = await get("/metrics");
|
|
166
|
+
assert.match(
|
|
167
|
+
body,
|
|
168
|
+
/^remit_queue_messages_in_flight\{queue="work-dlq",role="dead_letter"\} 1$/m,
|
|
169
|
+
);
|
|
170
|
+
assert.equal(sample(body, ['queue="work-dlq"']), 0);
|
|
171
|
+
store.purgeQueue("work-dlq");
|
|
172
|
+
});
|
|
173
|
+
|
|
161
174
|
it("counts only visible messages as depth", () => {
|
|
162
175
|
store.sendMessage({ queueName: "work", body: "c" });
|
|
163
176
|
store.receiveMessages({ queueName: "work", maxMessages: 1 });
|
package/src/metrics.ts
CHANGED
|
@@ -17,6 +17,13 @@ const queueMessages = new Gauge({
|
|
|
17
17
|
registers: [registry],
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
+
const queueMessagesInFlight = new Gauge({
|
|
21
|
+
name: "remit_queue_messages_in_flight",
|
|
22
|
+
help: "Messages received from a queue and not yet deleted or visible again, by queue and role.",
|
|
23
|
+
labelNames: ["queue", "role"],
|
|
24
|
+
registers: [registry],
|
|
25
|
+
});
|
|
26
|
+
|
|
20
27
|
const queueLastReceiveAge = new Gauge({
|
|
21
28
|
name: "remit_queue_last_receive_age_seconds",
|
|
22
29
|
help: "Seconds since a queue last handed a message to a consumer, or since the sidecar started if it has not.",
|
|
@@ -66,11 +73,16 @@ export const collectQueueDepths = (store: QueueDepthSource): void => {
|
|
|
66
73
|
}
|
|
67
74
|
const roles = queueRoles(queues);
|
|
68
75
|
queueMessages.reset();
|
|
76
|
+
queueMessagesInFlight.reset();
|
|
69
77
|
queueLastReceiveAge.reset();
|
|
70
78
|
for (const queue of queues) {
|
|
71
79
|
const attributes = store.getQueueAttributes(queue.name);
|
|
72
80
|
const labels = { queue: queue.name, role: roles.get(queue.name) ?? "work" };
|
|
73
81
|
queueMessages.set(labels, attributes.approximateNumberOfMessages);
|
|
82
|
+
queueMessagesInFlight.set(
|
|
83
|
+
labels,
|
|
84
|
+
attributes.approximateNumberOfMessagesNotVisible,
|
|
85
|
+
);
|
|
74
86
|
if (labels.role === "work") {
|
|
75
87
|
queueLastReceiveAge.set(labels, store.lastReceiveAgeSeconds(queue.name));
|
|
76
88
|
}
|
package/src/protocol.ts
CHANGED
|
@@ -140,6 +140,9 @@ export const receiveMessageResult = (messages: ReceiveMessageXml[]): string =>
|
|
|
140
140
|
export const queueUrlResult = (queueUrl: string): string =>
|
|
141
141
|
tag("QueueUrl", queueUrl);
|
|
142
142
|
|
|
143
|
+
export const taskHandleResult = (taskHandle: string): string =>
|
|
144
|
+
tag("TaskHandle", taskHandle);
|
|
145
|
+
|
|
143
146
|
export const queueAttributesResult = (
|
|
144
147
|
attributes: Record<string, string>,
|
|
145
148
|
): string =>
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import type { Server } from "node:http";
|
|
4
|
+
import type { AddressInfo } from "node:net";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { after, before, describe, it } from "node:test";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { bootstrapQueues, loadQueuesConfig } from "./queues-config.js";
|
|
9
|
+
import { runRedriveCli } from "./redrive.js";
|
|
10
|
+
import { createSidecarServer } from "./server.js";
|
|
11
|
+
import { QueueStore } from "./store.js";
|
|
12
|
+
|
|
13
|
+
const here = fileURLToPath(new URL(".", import.meta.url));
|
|
14
|
+
const tmpRoot = join(here, "..", ".tmp", "redrive");
|
|
15
|
+
|
|
16
|
+
describe("remit redrive, against the shipped queue definitions", () => {
|
|
17
|
+
let store: QueueStore;
|
|
18
|
+
let server: Server;
|
|
19
|
+
let port: number;
|
|
20
|
+
|
|
21
|
+
before(async () => {
|
|
22
|
+
mkdirSync(tmpRoot, { recursive: true });
|
|
23
|
+
store = new QueueStore(join(mkdtempSync(join(tmpRoot, "q-")), "q.db"));
|
|
24
|
+
bootstrapQueues(
|
|
25
|
+
store,
|
|
26
|
+
loadQueuesConfig(join(here, "..", "..", "..", "deploy/vps/queues.json")),
|
|
27
|
+
);
|
|
28
|
+
server = createSidecarServer({ store });
|
|
29
|
+
port = await new Promise((resolve) =>
|
|
30
|
+
server.listen(0, "127.0.0.1", () =>
|
|
31
|
+
resolve((server.address() as AddressInfo).port),
|
|
32
|
+
),
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
after(async () => {
|
|
37
|
+
await new Promise((resolve) => server.close(resolve));
|
|
38
|
+
store.close();
|
|
39
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const cli = async (args: string[]) => {
|
|
43
|
+
const out: string[] = [];
|
|
44
|
+
const err: string[] = [];
|
|
45
|
+
const code = await runRedriveCli(args, {
|
|
46
|
+
env: { QUEUE_SIDECAR_PORT: String(port) },
|
|
47
|
+
out: (text) => out.push(text),
|
|
48
|
+
err: (text) => err.push(text),
|
|
49
|
+
});
|
|
50
|
+
return { code, out: out.join(""), err: err.join("") };
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
it("returns a body dead-letter queue to the body queue", async () => {
|
|
54
|
+
store.sendMessage({ queueName: "remit-body-dlq", body: "batch-1" });
|
|
55
|
+
store.sendMessage({ queueName: "remit-body-dlq", body: "batch-2" });
|
|
56
|
+
|
|
57
|
+
const result = await cli(["remit-body-dlq"]);
|
|
58
|
+
|
|
59
|
+
assert.equal(result.code, 0);
|
|
60
|
+
assert.match(result.out, /Moved 2 message\(s\) from remit-body-dlq/);
|
|
61
|
+
assert.deepEqual(
|
|
62
|
+
store
|
|
63
|
+
.receiveMessages({ queueName: "remit-body", maxMessages: 10 })
|
|
64
|
+
.map((message) => message.body),
|
|
65
|
+
["batch-1", "batch-2"],
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("refuses the send dead-letter queue, leaving it where it is", async () => {
|
|
70
|
+
store.sendMessage({ queueName: "remit-smtp-dlq", body: "send" });
|
|
71
|
+
|
|
72
|
+
const result = await cli(["remit-smtp-dlq"]);
|
|
73
|
+
|
|
74
|
+
assert.equal(result.code, 2);
|
|
75
|
+
assert.match(result.err, /resend|deliver mail/);
|
|
76
|
+
assert.equal(
|
|
77
|
+
store.receiveMessages({ queueName: "remit-smtp", maxMessages: 10 })
|
|
78
|
+
.length,
|
|
79
|
+
0,
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("points a sync dead-letter queue at a purge instead", async () => {
|
|
84
|
+
const result = await cli(["remit-mailboxes-dlq.fifo"]);
|
|
85
|
+
|
|
86
|
+
assert.equal(result.code, 2);
|
|
87
|
+
assert.match(result.err, /Purge it instead/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("names the queues it redrives for any other name", async () => {
|
|
91
|
+
const result = await cli(["remit-body"]);
|
|
92
|
+
|
|
93
|
+
assert.equal(result.code, 2);
|
|
94
|
+
assert.match(result.err, /it redrives remit-body-dlq/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("takes exactly one queue", async () => {
|
|
98
|
+
assert.equal((await cli([])).code, 2);
|
|
99
|
+
assert.equal(
|
|
100
|
+
(await cli(["remit-body-dlq", "remit-flags-dlq.fifo"])).code,
|
|
101
|
+
2,
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
});
|
package/src/redrive.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { QueueStore } from "./store.js";
|
|
2
|
+
|
|
3
|
+
export type RedriveVerdict =
|
|
4
|
+
| { kind: "redrive" }
|
|
5
|
+
| { kind: "refuse"; reason: string };
|
|
6
|
+
|
|
7
|
+
const REPLAYABLE = new Set([
|
|
8
|
+
"remit-body-dlq",
|
|
9
|
+
"remit-flags-dlq.fifo",
|
|
10
|
+
"remit-message-mgmt-dlq",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const RESYNCED = new Set([
|
|
14
|
+
"remit-mailboxes-dlq.fifo",
|
|
15
|
+
"remit-messages-dlq.fifo",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Which dead-letter queues are safe to replay. Body syncs, flag pushes and
|
|
20
|
+
* filings re-read the row before they act, so a replay months later does the
|
|
21
|
+
* right thing or nothing. A send does not: its outbox row may still read
|
|
22
|
+
* `sending`, and the replay would deliver mail the user gave up on.
|
|
23
|
+
*/
|
|
24
|
+
export const redriveVerdict = (deadLetterQueue: string): RedriveVerdict => {
|
|
25
|
+
if (REPLAYABLE.has(deadLetterQueue)) return { kind: "redrive" };
|
|
26
|
+
if (deadLetterQueue === "remit-smtp-dlq") {
|
|
27
|
+
return {
|
|
28
|
+
kind: "refuse",
|
|
29
|
+
reason:
|
|
30
|
+
"remit-smtp-dlq holds sends. Replaying one can deliver mail the user stopped expecting, possibly months old; read each message and delete it instead.",
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (RESYNCED.has(deadLetterQueue)) {
|
|
34
|
+
return {
|
|
35
|
+
kind: "refuse",
|
|
36
|
+
reason: `${deadLetterQueue} holds folder syncs, and the scheduler queues a fresh one for every folder on its own. Purge it instead.`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
kind: "refuse",
|
|
41
|
+
reason: `${deadLetterQueue} is not a dead-letter queue remit redrives; it redrives remit-body-dlq, remit-flags-dlq.fifo and remit-message-mgmt-dlq.`,
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const redriveOnRelease = (store: QueueStore, release: string): void =>
|
|
46
|
+
store.redriveForRelease(
|
|
47
|
+
release,
|
|
48
|
+
store
|
|
49
|
+
.listQueues()
|
|
50
|
+
.map((queue) => queue.name)
|
|
51
|
+
.filter((name) => redriveVerdict(name).kind === "redrive"),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
export interface RedriveOptions {
|
|
55
|
+
deadLetterQueue: string;
|
|
56
|
+
baseUrl: string;
|
|
57
|
+
accountId?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type RedriveOutcome =
|
|
61
|
+
| { kind: "moved"; count: number }
|
|
62
|
+
| { kind: "refused"; reason: string }
|
|
63
|
+
| { kind: "failed"; reason: string };
|
|
64
|
+
|
|
65
|
+
const call = async (
|
|
66
|
+
baseUrl: string,
|
|
67
|
+
params: Record<string, string>,
|
|
68
|
+
): Promise<{ ok: true; body: string } | { ok: false; reason: string }> => {
|
|
69
|
+
const response = await fetch(`${baseUrl}/`, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
72
|
+
body: new URLSearchParams({ ...params, Version: "2012-11-05" }),
|
|
73
|
+
});
|
|
74
|
+
const body = await response.text();
|
|
75
|
+
if (response.ok) return { ok: true, body };
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
reason: body.match(/<Message>(.*)<\/Message>/)?.[1] ?? body,
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const redrive = async (
|
|
83
|
+
options: RedriveOptions,
|
|
84
|
+
): Promise<RedriveOutcome> => {
|
|
85
|
+
const { deadLetterQueue, baseUrl } = options;
|
|
86
|
+
const accountId = options.accountId ?? "000000000000";
|
|
87
|
+
const verdict = redriveVerdict(deadLetterQueue);
|
|
88
|
+
if (verdict.kind === "refuse") {
|
|
89
|
+
return { kind: "refused", reason: verdict.reason };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const depth = await call(baseUrl, {
|
|
93
|
+
Action: "GetQueueAttributes",
|
|
94
|
+
QueueUrl: `${baseUrl}/${accountId}/${deadLetterQueue}`,
|
|
95
|
+
"AttributeName.1": "ApproximateNumberOfMessages",
|
|
96
|
+
});
|
|
97
|
+
if (!depth.ok) return { kind: "failed", reason: depth.reason };
|
|
98
|
+
|
|
99
|
+
const moved = await call(baseUrl, {
|
|
100
|
+
Action: "StartMessageMoveTask",
|
|
101
|
+
SourceArn: `arn:aws:sqs:local:${accountId}:${deadLetterQueue}`,
|
|
102
|
+
});
|
|
103
|
+
if (!moved.ok) return { kind: "failed", reason: moved.reason };
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
kind: "moved",
|
|
107
|
+
count: Number(depth.body.match(/<Value>(\d+)<\/Value>/)?.[1] ?? "0"),
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export interface RedriveCliIo {
|
|
112
|
+
env: NodeJS.ProcessEnv;
|
|
113
|
+
out: (text: string) => void;
|
|
114
|
+
err: (text: string) => void;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const runRedriveCli = async (
|
|
118
|
+
args: string[],
|
|
119
|
+
io: RedriveCliIo,
|
|
120
|
+
): Promise<number> => {
|
|
121
|
+
const [deadLetterQueue, ...rest] = args;
|
|
122
|
+
if (!deadLetterQueue || rest.length > 0) {
|
|
123
|
+
io.err("usage: redrive <dead-letter-queue>\n");
|
|
124
|
+
return 2;
|
|
125
|
+
}
|
|
126
|
+
const outcome = await redrive({
|
|
127
|
+
deadLetterQueue,
|
|
128
|
+
baseUrl: `http://127.0.0.1:${io.env.QUEUE_SIDECAR_PORT ?? "9324"}`,
|
|
129
|
+
accountId: io.env.QUEUE_SIDECAR_ACCOUNT_ID,
|
|
130
|
+
});
|
|
131
|
+
if (outcome.kind === "moved") {
|
|
132
|
+
io.out(
|
|
133
|
+
`Moved ${outcome.count} message(s) from ${deadLetterQueue} back to the queue it came from.\n`,
|
|
134
|
+
);
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
io.err(`redrive: ${outcome.reason}\n`);
|
|
138
|
+
return outcome.kind === "refused" ? 2 : 1;
|
|
139
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { after, beforeEach, describe, it } from "node:test";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { bootstrapQueues, loadQueuesConfig } from "./queues-config.js";
|
|
7
|
+
import { redriveOnRelease } from "./redrive.js";
|
|
8
|
+
import { QueueStore } from "./store.js";
|
|
9
|
+
|
|
10
|
+
const here = fileURLToPath(new URL(".", import.meta.url));
|
|
11
|
+
const tmpRoot = join(here, "..", ".tmp", "release-redrive");
|
|
12
|
+
const shippedQueues = loadQueuesConfig(
|
|
13
|
+
join(here, "..", "..", "..", "deploy/vps/queues.json"),
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const bodies = (store: QueueStore, queueName: string): string[] =>
|
|
17
|
+
store
|
|
18
|
+
.receiveMessages({
|
|
19
|
+
queueName,
|
|
20
|
+
maxMessages: 10,
|
|
21
|
+
visibilityTimeoutSeconds: 0,
|
|
22
|
+
})
|
|
23
|
+
.map((message) => message.body);
|
|
24
|
+
|
|
25
|
+
const failUntilDeadLettered = (store: QueueStore, queueName: string): void => {
|
|
26
|
+
for (let attempt = 0; attempt <= 3; attempt++) {
|
|
27
|
+
store.receiveMessages({
|
|
28
|
+
queueName,
|
|
29
|
+
maxMessages: 10,
|
|
30
|
+
visibilityTimeoutSeconds: 0,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
describe("the dead-letter redrive a release runs on start", () => {
|
|
36
|
+
let dbPath: string;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
mkdirSync(tmpRoot, { recursive: true });
|
|
40
|
+
dbPath = join(mkdtempSync(join(tmpRoot, "q-")), "q.db");
|
|
41
|
+
const store = new QueueStore(dbPath);
|
|
42
|
+
bootstrapQueues(store, shippedQueues);
|
|
43
|
+
store.close();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
after(() => rmSync(tmpRoot, { recursive: true, force: true }));
|
|
47
|
+
|
|
48
|
+
const boot = <T>(run: (store: QueueStore) => T): T => {
|
|
49
|
+
const store = new QueueStore(dbPath);
|
|
50
|
+
try {
|
|
51
|
+
return run(store);
|
|
52
|
+
} finally {
|
|
53
|
+
store.close();
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
it("returns a dead-lettered body batch to the body queue once per release", () => {
|
|
58
|
+
boot((store) =>
|
|
59
|
+
store.sendMessage({ queueName: "remit-body-dlq", body: "batch-1" }),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
boot((store) => {
|
|
63
|
+
redriveOnRelease(store, "v1.4.0");
|
|
64
|
+
assert.deepEqual(bodies(store, "remit-body"), ["batch-1"]);
|
|
65
|
+
failUntilDeadLettered(store, "remit-body");
|
|
66
|
+
assert.equal(
|
|
67
|
+
store.getQueueAttributes("remit-body-dlq").approximateNumberOfMessages,
|
|
68
|
+
1,
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
boot((store) => {
|
|
73
|
+
redriveOnRelease(store, "v1.4.0");
|
|
74
|
+
assert.deepEqual(bodies(store, "remit-body"), []);
|
|
75
|
+
assert.equal(
|
|
76
|
+
store.getQueueAttributes("remit-body-dlq").approximateNumberOfMessages,
|
|
77
|
+
1,
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
boot((store) => {
|
|
82
|
+
redriveOnRelease(store, "v1.5.0");
|
|
83
|
+
assert.deepEqual(bodies(store, "remit-body"), ["batch-1"]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
});
|
package/src/server.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
receiveMessageResult,
|
|
18
18
|
sendMessageBatchResult,
|
|
19
19
|
sendMessageResult,
|
|
20
|
+
taskHandleResult,
|
|
20
21
|
} from "./protocol.js";
|
|
21
22
|
import {
|
|
22
23
|
QueueDoesNotExistError,
|
|
@@ -73,6 +74,12 @@ class InvalidRequestError extends SqsError {
|
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
const queueNameFromArn = (arn: string): string => {
|
|
78
|
+
const name = arn.split(":").at(-1);
|
|
79
|
+
if (!name) throw new InvalidRequestError(`malformed queue ARN: ${arn}`);
|
|
80
|
+
return name;
|
|
81
|
+
};
|
|
82
|
+
|
|
76
83
|
const collectIndexed = (params: URLSearchParams, prefix: string): string[] => {
|
|
77
84
|
const values: string[] = [];
|
|
78
85
|
for (let index = 1; ; index += 1) {
|
|
@@ -208,6 +215,21 @@ export const createSidecarServer = (options: CreateSidecarOptions): Server => {
|
|
|
208
215
|
};
|
|
209
216
|
}
|
|
210
217
|
|
|
218
|
+
if (action === "StartMessageMoveTask") {
|
|
219
|
+
const sourceArn = params.get("SourceArn");
|
|
220
|
+
if (!sourceArn) {
|
|
221
|
+
throw new InvalidRequestError(
|
|
222
|
+
"StartMessageMoveTask requires SourceArn",
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
const moved = store.redriveDeadLetters(queueNameFromArn(sourceArn));
|
|
226
|
+
log.info({ sourceArn, moved }, "sidecar: dead letters redriven");
|
|
227
|
+
return {
|
|
228
|
+
status: 200,
|
|
229
|
+
body: queryResponse(action, taskHandleResult(randomUUID()), requestId),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
211
233
|
const queueName = requireQueueName(params);
|
|
212
234
|
|
|
213
235
|
if (action === "SendMessage") {
|
package/src/sqs.test.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
SendMessageBatchCommand,
|
|
15
15
|
SendMessageCommand,
|
|
16
16
|
SQSClient,
|
|
17
|
+
StartMessageMoveTaskCommand,
|
|
17
18
|
} from "@aws-sdk/client-sqs";
|
|
18
19
|
import { AwsQueryProtocol } from "@aws-sdk/core/protocols";
|
|
19
20
|
import { bootstrapQueues, parseQueuesConfig } from "./queues-config.js";
|
|
@@ -335,6 +336,63 @@ describe("SQS wire protocol via the AWS SDK", () => {
|
|
|
335
336
|
assert.equal(dlq.Messages?.[0].Body, "poison");
|
|
336
337
|
});
|
|
337
338
|
|
|
339
|
+
it("moves a dead-lettered message back to its source with a fresh budget", async () => {
|
|
340
|
+
const url = h.queueUrl("work");
|
|
341
|
+
const dlqUrl = h.queueUrl("work-dlq");
|
|
342
|
+
await h.client.send(new PurgeQueueCommand({ QueueUrl: url }));
|
|
343
|
+
await h.client.send(new PurgeQueueCommand({ QueueUrl: dlqUrl }));
|
|
344
|
+
await h.client.send(
|
|
345
|
+
new SendMessageCommand({ QueueUrl: url, MessageBody: "stranded" }),
|
|
346
|
+
);
|
|
347
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
348
|
+
await h.client.send(
|
|
349
|
+
new ReceiveMessageCommand({ QueueUrl: url, WaitTimeSeconds: 2 }),
|
|
350
|
+
);
|
|
351
|
+
await delay(1200);
|
|
352
|
+
}
|
|
353
|
+
await h.client.send(
|
|
354
|
+
new ReceiveMessageCommand({ QueueUrl: url, WaitTimeSeconds: 1 }),
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
const task = await h.client.send(
|
|
358
|
+
new StartMessageMoveTaskCommand({
|
|
359
|
+
SourceArn: "arn:aws:sqs:local:000000000000:work-dlq",
|
|
360
|
+
}),
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
assert.ok(task.TaskHandle);
|
|
364
|
+
const dlqDepth = await h.client.send(
|
|
365
|
+
new GetQueueAttributesCommand({
|
|
366
|
+
QueueUrl: dlqUrl,
|
|
367
|
+
AttributeNames: ["ApproximateNumberOfMessages"],
|
|
368
|
+
}),
|
|
369
|
+
);
|
|
370
|
+
assert.equal(dlqDepth.Attributes?.ApproximateNumberOfMessages, "0");
|
|
371
|
+
const redelivered = await h.client.send(
|
|
372
|
+
new ReceiveMessageCommand({
|
|
373
|
+
QueueUrl: url,
|
|
374
|
+
WaitTimeSeconds: 2,
|
|
375
|
+
MessageSystemAttributeNames: ["ApproximateReceiveCount"],
|
|
376
|
+
}),
|
|
377
|
+
);
|
|
378
|
+
assert.equal(redelivered.Messages?.[0]?.Body, "stranded");
|
|
379
|
+
assert.equal(
|
|
380
|
+
redelivered.Messages?.[0]?.Attributes?.ApproximateReceiveCount,
|
|
381
|
+
"1",
|
|
382
|
+
);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("refuses to move messages out of a queue that does not exist", async () => {
|
|
386
|
+
await assert.rejects(
|
|
387
|
+
h.client.send(
|
|
388
|
+
new StartMessageMoveTaskCommand({
|
|
389
|
+
SourceArn: "arn:aws:sqs:local:000000000000:nowhere-dlq",
|
|
390
|
+
}),
|
|
391
|
+
),
|
|
392
|
+
/does not exist/,
|
|
393
|
+
);
|
|
394
|
+
});
|
|
395
|
+
|
|
338
396
|
it("reports queue attributes for DLQ depth monitoring", async () => {
|
|
339
397
|
const url = h.queueUrl("sync.fifo");
|
|
340
398
|
await h.client.send(new PurgeQueueCommand({ QueueUrl: url }));
|
package/src/store.test.ts
CHANGED
|
@@ -284,7 +284,7 @@ describe("QueueStore", () => {
|
|
|
284
284
|
});
|
|
285
285
|
assert.equal(dlq.length, 1);
|
|
286
286
|
assert.equal(dlq[0].body, "poison");
|
|
287
|
-
assert.equal(dlq[0].receiveCount,
|
|
287
|
+
assert.equal(dlq[0].receiveCount, 3, "the DLQ keeps the receive count");
|
|
288
288
|
});
|
|
289
289
|
|
|
290
290
|
it("persists enqueued messages across a store restart", () => {
|
package/src/store.ts
CHANGED
|
@@ -150,6 +150,10 @@ export class QueueStore {
|
|
|
150
150
|
created_at INTEGER NOT NULL,
|
|
151
151
|
PRIMARY KEY (queue_name, dedup_id)
|
|
152
152
|
);
|
|
153
|
+
CREATE TABLE IF NOT EXISTS release_redrive (
|
|
154
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
155
|
+
release TEXT NOT NULL
|
|
156
|
+
);
|
|
153
157
|
`);
|
|
154
158
|
}
|
|
155
159
|
|
|
@@ -321,7 +325,9 @@ export class QueueStore {
|
|
|
321
325
|
queue.deadLetterTargetName !== null &&
|
|
322
326
|
candidate.receive_count >= queue.maxReceiveCount
|
|
323
327
|
) {
|
|
324
|
-
this.
|
|
328
|
+
this.db
|
|
329
|
+
.prepare("UPDATE messages SET queue_name = ? WHERE id = ?")
|
|
330
|
+
.run(queue.deadLetterTargetName, candidate.id);
|
|
325
331
|
continue;
|
|
326
332
|
}
|
|
327
333
|
|
|
@@ -449,9 +455,52 @@ export class QueueStore {
|
|
|
449
455
|
.all({ queue: queue.name, now, limit }) as MessageRow[];
|
|
450
456
|
}
|
|
451
457
|
|
|
452
|
-
|
|
458
|
+
/**
|
|
459
|
+
* Return everything a dead-letter queue holds to the queue it came from, as
|
|
460
|
+
* SQS StartMessageMoveTask does. Each message goes to the back of that queue
|
|
461
|
+
* with a fresh receive count, so it gets the full redelivery budget again and
|
|
462
|
+
* a FIFO group still sees it after anything sent since.
|
|
463
|
+
*/
|
|
464
|
+
redriveDeadLetters(deadLetterName: string, now = Date.now()): number {
|
|
465
|
+
this.requireQueue(deadLetterName);
|
|
466
|
+
const source = this.listQueues().find(
|
|
467
|
+
(queue) => queue.deadLetterTargetName === deadLetterName,
|
|
468
|
+
);
|
|
469
|
+
if (!source) {
|
|
470
|
+
throw new InvalidParameterValueError(
|
|
471
|
+
`${deadLetterName} is not the dead-letter queue of any queue`,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
const destination = source.name;
|
|
475
|
+
|
|
476
|
+
const redrive = this.db.transaction((): number => {
|
|
477
|
+
const rows = this.db
|
|
478
|
+
.prepare("SELECT * FROM messages WHERE queue_name = ? ORDER BY id ASC")
|
|
479
|
+
.all(deadLetterName) as MessageRow[];
|
|
480
|
+
for (const row of rows) this.moveToQueue(row, destination, now);
|
|
481
|
+
return rows.length;
|
|
482
|
+
});
|
|
483
|
+
return redrive();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
redriveForRelease(release: string, deadLetterNames: string[]): void {
|
|
487
|
+
this.db.transaction((): void => {
|
|
488
|
+
const marker = this.db
|
|
489
|
+
.prepare("SELECT release FROM release_redrive WHERE id = 1")
|
|
490
|
+
.get() as { release: string } | undefined;
|
|
491
|
+
if (marker?.release === release) return;
|
|
492
|
+
for (const name of deadLetterNames) this.redriveDeadLetters(name);
|
|
493
|
+
this.db
|
|
494
|
+
.prepare(
|
|
495
|
+
"INSERT INTO release_redrive (id, release) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET release = excluded.release",
|
|
496
|
+
)
|
|
497
|
+
.run(release);
|
|
498
|
+
})();
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
private moveToQueue(
|
|
453
502
|
message: MessageRow,
|
|
454
|
-
|
|
503
|
+
targetName: string,
|
|
455
504
|
now: number,
|
|
456
505
|
): void {
|
|
457
506
|
this.db.prepare("DELETE FROM messages WHERE id = ?").run(message.id);
|
|
@@ -465,7 +514,7 @@ export class QueueStore {
|
|
|
465
514
|
)
|
|
466
515
|
.run(
|
|
467
516
|
message.message_id,
|
|
468
|
-
|
|
517
|
+
targetName,
|
|
469
518
|
message.body,
|
|
470
519
|
message.md5_body,
|
|
471
520
|
message.group_id,
|