@remit/queue-sidecar 0.0.10 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/queue-sidecar",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
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, () => {
@@ -141,6 +141,36 @@ describe("the sidecar /metrics endpoint", () => {
141
141
  assert.equal(post.status, 400);
142
142
  });
143
143
 
144
+ it("exports how long each queue has gone without handing out a message", async () => {
145
+ store.purgeQueue("work");
146
+ store.sendMessage({ queueName: "work", body: "waiting" });
147
+ const idle = await get("/metrics");
148
+ assert.match(
149
+ idle.body,
150
+ /^remit_queue_last_receive_age_seconds\{queue="work",role="work"\} \d+$/m,
151
+ );
152
+
153
+ const quiet = store.lastReceiveAgeSeconds("work", Date.now() + 901_000);
154
+ assert.ok(quiet >= 900);
155
+
156
+ store.receiveMessages({ queueName: "work", maxMessages: 1 });
157
+ assert.equal(store.lastReceiveAgeSeconds("work"), 0);
158
+ store.purgeQueue("work");
159
+ });
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
+
144
174
  it("counts only visible messages as depth", () => {
145
175
  store.sendMessage({ queueName: "work", body: "c" });
146
176
  store.receiveMessages({ queueName: "work", maxMessages: 1 });
@@ -165,6 +195,7 @@ describe("a store that cannot answer for any queue", () => {
165
195
  getQueueAttributes: () => {
166
196
  throw new Error("never reached");
167
197
  },
198
+ lastReceiveAgeSeconds: () => 0,
168
199
  }),
169
200
  /holds no queues/,
170
201
  );
package/src/metrics.ts CHANGED
@@ -17,9 +17,23 @@ 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
+
27
+ const queueLastReceiveAge = new Gauge({
28
+ name: "remit_queue_last_receive_age_seconds",
29
+ help: "Seconds since a queue last handed a message to a consumer, or since the sidecar started if it has not.",
30
+ labelNames: ["queue", "role"],
31
+ registers: [registry],
32
+ });
33
+
20
34
  export type QueueDepthSource = Pick<
21
35
  QueueStore,
22
- "listQueues" | "getQueueAttributes"
36
+ "listQueues" | "getQueueAttributes" | "lastReceiveAgeSeconds"
23
37
  >;
24
38
 
25
39
  export const queueRoles = (
@@ -59,11 +73,18 @@ export const collectQueueDepths = (store: QueueDepthSource): void => {
59
73
  }
60
74
  const roles = queueRoles(queues);
61
75
  queueMessages.reset();
76
+ queueMessagesInFlight.reset();
77
+ queueLastReceiveAge.reset();
62
78
  for (const queue of queues) {
63
79
  const attributes = store.getQueueAttributes(queue.name);
64
- queueMessages.set(
65
- { queue: queue.name, role: roles.get(queue.name) ?? "work" },
66
- attributes.approximateNumberOfMessages,
80
+ const labels = { queue: queue.name, role: roles.get(queue.name) ?? "work" };
81
+ queueMessages.set(labels, attributes.approximateNumberOfMessages);
82
+ queueMessagesInFlight.set(
83
+ labels,
84
+ attributes.approximateNumberOfMessagesNotVisible,
67
85
  );
86
+ if (labels.role === "work") {
87
+ queueLastReceiveAge.set(labels, store.lastReceiveAgeSeconds(queue.name));
88
+ }
68
89
  }
69
90
  };
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
@@ -97,6 +97,8 @@ const toQueueRecord = (row: QueueRow): QueueRecord => ({
97
97
  export class QueueStore {
98
98
  private readonly db: Database.Database;
99
99
  private sequenceCounter: bigint;
100
+ private readonly startedAt = Date.now();
101
+ private readonly lastReceiveAt = new Map<string, number>();
100
102
 
101
103
  constructor(filename: string) {
102
104
  this.db = new Database(filename);
@@ -148,6 +150,10 @@ export class QueueStore {
148
150
  created_at INTEGER NOT NULL,
149
151
  PRIMARY KEY (queue_name, dedup_id)
150
152
  );
153
+ CREATE TABLE IF NOT EXISTS release_redrive (
154
+ id INTEGER PRIMARY KEY CHECK (id = 1),
155
+ release TEXT NOT NULL
156
+ );
151
157
  `);
152
158
  }
153
159
 
@@ -319,7 +325,9 @@ export class QueueStore {
319
325
  queue.deadLetterTargetName !== null &&
320
326
  candidate.receive_count >= queue.maxReceiveCount
321
327
  ) {
322
- this.moveToDeadLetter(candidate, queue.deadLetterTargetName, now);
328
+ this.db
329
+ .prepare("UPDATE messages SET queue_name = ? WHERE id = ?")
330
+ .run(queue.deadLetterTargetName, candidate.id);
323
331
  continue;
324
332
  }
325
333
 
@@ -357,7 +365,15 @@ export class QueueStore {
357
365
  return picked;
358
366
  });
359
367
 
360
- return receive();
368
+ const received = receive();
369
+ if (received.length > 0) this.lastReceiveAt.set(input.queueName, now);
370
+ return received;
371
+ }
372
+
373
+ lastReceiveAgeSeconds(queueName: string, now = Date.now()): number {
374
+ this.requireQueue(queueName);
375
+ const since = this.lastReceiveAt.get(queueName) ?? this.startedAt;
376
+ return Math.max(0, Math.floor((now - since) / 1000));
361
377
  }
362
378
 
363
379
  deleteMessage(queueName: string, receiptHandle: string): void {
@@ -439,9 +455,52 @@ export class QueueStore {
439
455
  .all({ queue: queue.name, now, limit }) as MessageRow[];
440
456
  }
441
457
 
442
- 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(
443
502
  message: MessageRow,
444
- deadLetterName: string,
503
+ targetName: string,
445
504
  now: number,
446
505
  ): void {
447
506
  this.db.prepare("DELETE FROM messages WHERE id = ?").run(message.id);
@@ -455,7 +514,7 @@ export class QueueStore {
455
514
  )
456
515
  .run(
457
516
  message.message_id,
458
- deadLetterName,
517
+ targetName,
459
518
  message.body,
460
519
  message.md5_body,
461
520
  message.group_id,