@remit/queue-sidecar 0.0.11 → 0.0.13

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 CHANGED
@@ -1,41 +1,41 @@
1
1
  {
2
- "name": "@remit/queue-sidecar",
3
- "version": "0.0.11",
4
- "type": "module",
5
- "main": "src/index.ts",
6
- "types": "src/index.ts",
7
- "exports": {
8
- ".": {
9
- "types": "./src/index.ts",
10
- "default": "./src/index.ts"
11
- }
12
- },
13
- "bin": {
14
- "queue-sidecar": "src/main.ts"
15
- },
16
- "scripts": {
17
- "start": "node --import tsx src/main.ts",
18
- "test:typecheck": "tsgo --noEmit",
19
- "test:run": "node $NODE_TEST_FLAGS --import tsx --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=93 --test 'src/**/*.test.ts'",
20
- "test": "npm run test:typecheck && npm run test:run"
21
- },
22
- "dependencies": {
23
- "@remit/logger-lambda": "*",
24
- "better-sqlite3": "^12.11.1",
25
- "@types/better-sqlite3": "^7.6.13",
26
- "prom-client": "^15.1.3"
27
- },
28
- "devDependencies": {
29
- "@aws-sdk/client-sqs": "*",
30
- "@aws-sdk/core": "*"
31
- },
32
- "license": "MIT",
33
- "publishConfig": {
34
- "access": "public"
35
- },
36
- "repository": {
37
- "type": "git",
38
- "url": "git+https://github.com/remit-mail/reader.git",
39
- "directory": "packages/queue-sidecar"
40
- }
2
+ "name": "@remit/queue-sidecar",
3
+ "version": "0.0.13",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./src/index.ts",
10
+ "default": "./src/index.ts"
11
+ }
12
+ },
13
+ "bin": {
14
+ "queue-sidecar": "src/main.ts"
15
+ },
16
+ "scripts": {
17
+ "start": "node --import tsx src/main.ts",
18
+ "test:typecheck": "tsgo --noEmit",
19
+ "test:run": "node $NODE_TEST_FLAGS --import tsx --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=93 --test 'src/**/*.test.ts'",
20
+ "test": "npm run test:typecheck && npm run test:run"
21
+ },
22
+ "dependencies": {
23
+ "@remit/logger-lambda": "*",
24
+ "better-sqlite3": "^12.11.1",
25
+ "@types/better-sqlite3": "^7.6.13",
26
+ "prom-client": "^15.1.3"
27
+ },
28
+ "devDependencies": {
29
+ "@aws-sdk/client-sqs": "*",
30
+ "@aws-sdk/core": "*"
31
+ },
32
+ "license": "MIT",
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/remit-mail/reader.git",
39
+ "directory": "packages/queue-sidecar"
40
+ }
41
41
  }
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ export {
4
4
  parseQueuesConfig,
5
5
  type QueuesConfig,
6
6
  } from "./queues-config.js";
7
+ export { redriveOnRelease } from "./redrive.js";
7
8
  export {
8
9
  type CreateSidecarOptions,
9
10
  createSidecarServer,
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, () => {
@@ -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,9 @@
1
+ import { runRedriveCli } from "./redrive.js";
2
+
3
+ process.exit(
4
+ await runRedriveCli(process.argv.slice(2), {
5
+ env: process.env,
6
+ out: (text) => process.stdout.write(text),
7
+ err: (text) => process.stderr.write(text),
8
+ }),
9
+ );
@@ -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, 1, "receive count resets in the DLQ");
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.moveToDeadLetter(candidate, queue.deadLetterTargetName, now);
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
- private moveToDeadLetter(
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
- deadLetterName: string,
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
- deadLetterName,
517
+ targetName,
469
518
  message.body,
470
519
  message.md5_body,
471
520
  message.group_id,