@remit/queue-sidecar 0.0.1

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 ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@remit/queue-sidecar",
3
+ "version": "0.0.1",
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 --import tsx --test 'src/**/*.test.ts'",
20
+ "test": "npm run test:typecheck && npm run test:run"
21
+ },
22
+ "dependencies": {
23
+ "better-sqlite3": "^12.11.1"
24
+ },
25
+ "devDependencies": {
26
+ "@aws-sdk/client-sqs": "*",
27
+ "@aws-sdk/core": "*",
28
+ "@types/better-sqlite3": "^7.6.13"
29
+ },
30
+ "license": "MIT",
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/remit-mail/reader.git",
37
+ "directory": "packages/queue-sidecar"
38
+ }
39
+ }
@@ -0,0 +1,128 @@
1
+ import assert from "node:assert/strict";
2
+ import { type ChildProcess, spawn } from "node:child_process";
3
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
4
+ import { createServer, get } from "node:http";
5
+ import type { AddressInfo } from "node:net";
6
+ import { join } from "node:path";
7
+ import { after, describe, it } from "node:test";
8
+ import { fileURLToPath } from "node:url";
9
+ import {
10
+ ReceiveMessageCommand,
11
+ SendMessageCommand,
12
+ SQSClient,
13
+ } from "@aws-sdk/client-sqs";
14
+ import { AwsQueryProtocol } from "@aws-sdk/core/protocols";
15
+
16
+ const here = fileURLToPath(new URL(".", import.meta.url));
17
+ const tmpRoot = join(here, "..", ".tmp", "crash");
18
+ const mainEntry = join(here, "main.ts");
19
+
20
+ const delay = (ms: number): Promise<void> =>
21
+ new Promise((resolve) => setTimeout(resolve, ms));
22
+
23
+ const freePort = (): Promise<number> =>
24
+ new Promise((resolve) => {
25
+ const probe = createServer();
26
+ probe.listen(0, "127.0.0.1", () => {
27
+ const { port } = probe.address() as AddressInfo;
28
+ probe.close(() => resolve(port));
29
+ });
30
+ });
31
+
32
+ const httpOk = (url: string): Promise<boolean> =>
33
+ new Promise((resolve) => {
34
+ const req = get(url, (res) => {
35
+ res.resume();
36
+ resolve(res.statusCode === 200);
37
+ });
38
+ req.on("error", () => resolve(false));
39
+ });
40
+
41
+ const waitForHealth = async (port: number): Promise<void> => {
42
+ const deadline = Date.now() + 15_000;
43
+ while (Date.now() < deadline) {
44
+ if (await httpOk(`http://127.0.0.1:${port}/health`)) return;
45
+ await delay(200);
46
+ }
47
+ throw new Error("sidecar child did not become healthy in time");
48
+ };
49
+
50
+ const spawnSidecar = async (
51
+ port: number,
52
+ dbPath: string,
53
+ configPath: string,
54
+ ): Promise<ChildProcess> => {
55
+ const child = spawn(process.execPath, ["--import", "tsx", mainEntry], {
56
+ env: {
57
+ ...process.env,
58
+ QUEUE_SIDECAR_HOST: "127.0.0.1",
59
+ QUEUE_SIDECAR_PORT: String(port),
60
+ QUEUE_SIDECAR_DB: dbPath,
61
+ QUEUE_SIDECAR_QUEUES_CONFIG: configPath,
62
+ },
63
+ stdio: "ignore",
64
+ });
65
+ await waitForHealth(port);
66
+ return child;
67
+ };
68
+
69
+ const waitForExit = (child: ChildProcess): Promise<void> =>
70
+ new Promise((resolve) => child.on("exit", () => resolve()));
71
+
72
+ describe("crash recovery (SIGKILL, no graceful close)", () => {
73
+ after(() => {
74
+ rmSync(tmpRoot, { recursive: true, force: true });
75
+ });
76
+
77
+ it("recovers a committed send after the process is killed uncleanly", async () => {
78
+ mkdirSync(tmpRoot, { recursive: true });
79
+ const dbPath = join(tmpRoot, "queue.db");
80
+ const configPath = join(tmpRoot, "queues.json");
81
+ writeFileSync(
82
+ configPath,
83
+ JSON.stringify({
84
+ queues: [{ name: "outbox", visibilityTimeoutSeconds: 30 }],
85
+ }),
86
+ );
87
+
88
+ const port = await freePort();
89
+ const client = new SQSClient({
90
+ endpoint: `http://127.0.0.1:${port}`,
91
+ protocol: AwsQueryProtocol,
92
+ region: "local",
93
+ credentials: { accessKeyId: "local", secretAccessKey: "local" },
94
+ maxAttempts: 1,
95
+ });
96
+ const queueUrl = `http://127.0.0.1:${port}/000000000000/outbox`;
97
+
98
+ const first = await spawnSidecar(port, dbPath, configPath);
99
+ // The send resolves only after the sidecar has committed it — under
100
+ // synchronous=FULL that commit is fsync'd to the WAL before the response.
101
+ await client.send(
102
+ new SendMessageCommand({
103
+ QueueUrl: queueUrl,
104
+ MessageBody: "unsent-mail",
105
+ }),
106
+ );
107
+
108
+ // Kill -9: no SIGTERM handler runs, no graceful close, no WAL
109
+ // checkpoint. This is the host-crash / power-loss case, not a clean
110
+ // restart — the recovery path the outbox durability requirement is about.
111
+ first.kill("SIGKILL");
112
+ await waitForExit(first);
113
+
114
+ const second = await spawnSidecar(port, dbPath, configPath);
115
+ const received = await client.send(
116
+ new ReceiveMessageCommand({
117
+ QueueUrl: queueUrl,
118
+ MaxNumberOfMessages: 10,
119
+ WaitTimeSeconds: 2,
120
+ }),
121
+ );
122
+ second.kill("SIGKILL");
123
+ await waitForExit(second);
124
+
125
+ assert.equal(received.Messages?.length, 1);
126
+ assert.equal(received.Messages?.[0].Body, "unsent-mail");
127
+ });
128
+ });
package/src/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ export {
2
+ bootstrapQueues,
3
+ loadQueuesConfig,
4
+ parseQueuesConfig,
5
+ type QueuesConfig,
6
+ } from "./queues-config.js";
7
+ export {
8
+ type CreateSidecarOptions,
9
+ createSidecarServer,
10
+ type SidecarLog,
11
+ } from "./server.js";
12
+ export {
13
+ type FifoSendInput,
14
+ InvalidParameterValueError,
15
+ MissingParameterError,
16
+ type QueueAttributes,
17
+ type QueueDefinition,
18
+ QueueDoesNotExistError,
19
+ type QueueRecord,
20
+ QueueStore,
21
+ type ReceivedMessage,
22
+ type SendResult,
23
+ SqsError,
24
+ validateFifoSend,
25
+ } from "./store.js";
package/src/main.ts ADDED
@@ -0,0 +1,54 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { bootstrapQueues, loadQueuesConfig } from "./queues-config.js";
4
+ import { createSidecarServer, type SidecarLog } from "./server.js";
5
+ import { QueueStore } from "./store.js";
6
+
7
+ const log: SidecarLog = {
8
+ info: (fields, message) =>
9
+ process.stdout.write(
10
+ `${JSON.stringify({ level: "info", message, ...fields })}\n`,
11
+ ),
12
+ error: (fields, message) =>
13
+ process.stderr.write(
14
+ `${JSON.stringify({ level: "error", message, ...fields })}\n`,
15
+ ),
16
+ };
17
+
18
+ const port = Number(process.env.QUEUE_SIDECAR_PORT ?? "9324");
19
+ const host = process.env.QUEUE_SIDECAR_HOST ?? "0.0.0.0";
20
+ const dbPath = process.env.QUEUE_SIDECAR_DB ?? "/data/queue/queue.db";
21
+ const accountId = process.env.QUEUE_SIDECAR_ACCOUNT_ID ?? "000000000000";
22
+ const queuesConfigPath = process.env.QUEUE_SIDECAR_QUEUES_CONFIG;
23
+
24
+ mkdirSync(dirname(dbPath), { recursive: true });
25
+ const store = new QueueStore(dbPath);
26
+
27
+ if (queuesConfigPath) {
28
+ const config = loadQueuesConfig(queuesConfigPath);
29
+ bootstrapQueues(store, config);
30
+ log.info(
31
+ { path: queuesConfigPath, queues: config.queues.length },
32
+ "sidecar: queues bootstrapped",
33
+ );
34
+ } else {
35
+ log.info({}, "sidecar: no queues config; queues created on demand");
36
+ }
37
+
38
+ const server = createSidecarServer({ store, accountId, log });
39
+
40
+ server.listen(port, host, () => {
41
+ log.info({ host, port, dbPath }, "sidecar: listening");
42
+ });
43
+
44
+ const shutdown = (signal: NodeJS.Signals): void => {
45
+ log.info({ signal }, "sidecar: shutting down");
46
+ server.close(() => {
47
+ store.close();
48
+ process.exit(0);
49
+ });
50
+ };
51
+
52
+ for (const signal of ["SIGINT", "SIGTERM"] as const) {
53
+ process.on(signal, shutdown);
54
+ }
@@ -0,0 +1,151 @@
1
+ const XMLNS = "http://queue.amazonaws.com/doc/2012-11-05/";
2
+
3
+ export const escapeXml = (value: string): string =>
4
+ value
5
+ .replace(/&/g, "&amp;")
6
+ .replace(/</g, "&lt;")
7
+ .replace(/>/g, "&gt;")
8
+ .replace(/"/g, "&quot;")
9
+ .replace(/'/g, "&apos;");
10
+
11
+ const tag = (name: string, value: string): string =>
12
+ `<${name}>${escapeXml(value)}</${name}>`;
13
+
14
+ const metadata = (requestId: string): string =>
15
+ `<ResponseMetadata><RequestId>${requestId}</RequestId></ResponseMetadata>`;
16
+
17
+ /**
18
+ * Wraps an action's result body in the AWS Query envelope the SDK's
19
+ * `AwsQueryProtocol` deserializer expects: `<ActionResponse>` containing
20
+ * `<ActionResult>` and `<ResponseMetadata>`.
21
+ */
22
+ export const queryResponse = (
23
+ action: string,
24
+ resultBody: string,
25
+ requestId: string,
26
+ ): string =>
27
+ `<?xml version="1.0"?>` +
28
+ `<${action}Response xmlns="${XMLNS}">` +
29
+ `<${action}Result>${resultBody}</${action}Result>` +
30
+ metadata(requestId) +
31
+ `</${action}Response>`;
32
+
33
+ /** For actions with no result payload (DeleteMessage, PurgeQueue). */
34
+ export const queryResponseNoResult = (
35
+ action: string,
36
+ requestId: string,
37
+ ): string =>
38
+ `<?xml version="1.0"?>` +
39
+ `<${action}Response xmlns="${XMLNS}">` +
40
+ metadata(requestId) +
41
+ `</${action}Response>`;
42
+
43
+ export const errorResponse = (
44
+ code: string,
45
+ message: string,
46
+ requestId: string,
47
+ senderFault = true,
48
+ ): string =>
49
+ `<?xml version="1.0"?>` +
50
+ `<ErrorResponse xmlns="${XMLNS}">` +
51
+ `<Error>` +
52
+ tag("Type", senderFault ? "Sender" : "Receiver") +
53
+ tag("Code", code) +
54
+ tag("Message", message) +
55
+ `</Error>` +
56
+ `<RequestId>${requestId}</RequestId>` +
57
+ `</ErrorResponse>`;
58
+
59
+ export const sendMessageResult = (result: {
60
+ messageId: string;
61
+ md5OfBody: string;
62
+ sequenceNumber: string | null;
63
+ }): string =>
64
+ tag("MessageId", result.messageId) +
65
+ tag("MD5OfMessageBody", result.md5OfBody) +
66
+ (result.sequenceNumber ? tag("SequenceNumber", result.sequenceNumber) : "");
67
+
68
+ export interface BatchSuccessEntry {
69
+ id: string;
70
+ messageId: string;
71
+ md5OfBody: string;
72
+ sequenceNumber: string | null;
73
+ }
74
+
75
+ export interface BatchErrorEntry {
76
+ id: string;
77
+ code: string;
78
+ message: string;
79
+ senderFault: boolean;
80
+ }
81
+
82
+ export const sendMessageBatchResult = (
83
+ successful: BatchSuccessEntry[],
84
+ failed: BatchErrorEntry[],
85
+ ): string => {
86
+ const successXml = successful
87
+ .map(
88
+ (e) =>
89
+ `<SendMessageBatchResultEntry>` +
90
+ tag("Id", e.id) +
91
+ tag("MessageId", e.messageId) +
92
+ tag("MD5OfMessageBody", e.md5OfBody) +
93
+ (e.sequenceNumber ? tag("SequenceNumber", e.sequenceNumber) : "") +
94
+ `</SendMessageBatchResultEntry>`,
95
+ )
96
+ .join("");
97
+ const errorXml = failed
98
+ .map(
99
+ (e) =>
100
+ `<BatchResultErrorEntry>` +
101
+ tag("Id", e.id) +
102
+ tag("Code", e.code) +
103
+ tag("Message", e.message) +
104
+ tag("SenderFault", e.senderFault ? "true" : "false") +
105
+ `</BatchResultErrorEntry>`,
106
+ )
107
+ .join("");
108
+ return successXml + errorXml;
109
+ };
110
+
111
+ export interface ReceiveMessageXml {
112
+ messageId: string;
113
+ receiptHandle: string;
114
+ md5OfBody: string;
115
+ body: string;
116
+ attributes: Record<string, string>;
117
+ }
118
+
119
+ export const receiveMessageResult = (messages: ReceiveMessageXml[]): string =>
120
+ messages
121
+ .map((m) => {
122
+ const attrs = Object.entries(m.attributes)
123
+ .map(
124
+ ([name, value]) =>
125
+ `<Attribute>${tag("Name", name)}${tag("Value", value)}</Attribute>`,
126
+ )
127
+ .join("");
128
+ return (
129
+ `<Message>` +
130
+ tag("MessageId", m.messageId) +
131
+ tag("ReceiptHandle", m.receiptHandle) +
132
+ tag("MD5OfBody", m.md5OfBody) +
133
+ tag("Body", m.body) +
134
+ attrs +
135
+ `</Message>`
136
+ );
137
+ })
138
+ .join("");
139
+
140
+ export const queueUrlResult = (queueUrl: string): string =>
141
+ tag("QueueUrl", queueUrl);
142
+
143
+ export const queueAttributesResult = (
144
+ attributes: Record<string, string>,
145
+ ): string =>
146
+ Object.entries(attributes)
147
+ .map(
148
+ ([name, value]) =>
149
+ `<Attribute>${tag("Name", name)}${tag("Value", value)}</Attribute>`,
150
+ )
151
+ .join("");
@@ -0,0 +1,104 @@
1
+ import { readFileSync } from "node:fs";
2
+ import type { QueueDefinition, QueueStore } from "./store.js";
3
+
4
+ export interface QueuesConfig {
5
+ readonly accountId: string;
6
+ readonly queues: readonly QueueDefinition[];
7
+ }
8
+
9
+ interface RawDeadLetter {
10
+ name: unknown;
11
+ maxReceiveCount: unknown;
12
+ }
13
+
14
+ interface RawQueue {
15
+ name?: unknown;
16
+ fifo?: unknown;
17
+ visibilityTimeoutSeconds?: unknown;
18
+ contentBasedDeduplication?: unknown;
19
+ deadLetter?: RawDeadLetter;
20
+ }
21
+
22
+ interface RawConfig {
23
+ accountId?: unknown;
24
+ queues?: unknown;
25
+ }
26
+
27
+ const DEFAULT_VISIBILITY_TIMEOUT_SECONDS = 30;
28
+
29
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
30
+ typeof value === "object" && value !== null;
31
+
32
+ const parseQueue = (raw: unknown): QueueDefinition => {
33
+ if (!isRecord(raw)) throw new Error("queue entry must be an object");
34
+ const { name, fifo, visibilityTimeoutSeconds, contentBasedDeduplication } =
35
+ raw as RawQueue;
36
+ if (typeof name !== "string" || name.length === 0) {
37
+ throw new Error("queue entry requires a non-empty string name");
38
+ }
39
+ const isFifo = typeof fifo === "boolean" ? fifo : name.endsWith(".fifo");
40
+
41
+ const definition: QueueDefinition = {
42
+ name,
43
+ fifo: isFifo,
44
+ visibilityTimeoutSeconds:
45
+ typeof visibilityTimeoutSeconds === "number"
46
+ ? visibilityTimeoutSeconds
47
+ : DEFAULT_VISIBILITY_TIMEOUT_SECONDS,
48
+ contentBasedDeduplication: contentBasedDeduplication === true,
49
+ };
50
+
51
+ const deadLetter = (raw as RawQueue).deadLetter;
52
+ if (deadLetter === undefined) return definition;
53
+ if (
54
+ typeof deadLetter.name !== "string" ||
55
+ typeof deadLetter.maxReceiveCount !== "number"
56
+ ) {
57
+ throw new Error(
58
+ `queue ${name}: deadLetter requires a string name and numeric maxReceiveCount`,
59
+ );
60
+ }
61
+ return {
62
+ ...definition,
63
+ deadLetterTargetName: deadLetter.name,
64
+ maxReceiveCount: deadLetter.maxReceiveCount,
65
+ };
66
+ };
67
+
68
+ export const parseQueuesConfig = (raw: unknown): QueuesConfig => {
69
+ if (!isRecord(raw)) throw new Error("queues config must be an object");
70
+ const { accountId, queues } = raw as RawConfig;
71
+ if (!Array.isArray(queues)) {
72
+ throw new Error("queues config requires a `queues` array");
73
+ }
74
+ return {
75
+ accountId: typeof accountId === "string" ? accountId : "000000000000",
76
+ queues: queues.map(parseQueue),
77
+ };
78
+ };
79
+
80
+ export const loadQueuesConfig = (path: string): QueuesConfig =>
81
+ parseQueuesConfig(JSON.parse(readFileSync(path, "utf8")));
82
+
83
+ /**
84
+ * Declares every queue in the config on the store. Dead-letter targets are
85
+ * declared before the source queues that reference them so a redrive never
86
+ * points at a queue the store has not seen.
87
+ */
88
+ export const bootstrapQueues = (
89
+ store: QueueStore,
90
+ config: QueuesConfig,
91
+ ): void => {
92
+ const deadLetterNames = new Set(
93
+ config.queues
94
+ .map((q) => q.deadLetterTargetName)
95
+ .filter((name): name is string => name !== undefined),
96
+ );
97
+ const ordered = [
98
+ ...config.queues.filter((q) => deadLetterNames.has(q.name)),
99
+ ...config.queues.filter((q) => !deadLetterNames.has(q.name)),
100
+ ];
101
+ for (const queue of ordered) {
102
+ store.upsertQueue(queue);
103
+ }
104
+ };