@nestarc/inbox 0.0.0-stage → 0.2.0
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/CHANGELOG.md +72 -0
- package/LICENSE +21 -0
- package/README.md +309 -2
- package/dist/contracts/errors.d.ts +34 -0
- package/dist/contracts/errors.js +73 -0
- package/dist/contracts/errors.js.map +1 -0
- package/dist/contracts/types.d.ts +174 -0
- package/dist/contracts/types.js +3 -0
- package/dist/contracts/types.js.map +1 -0
- package/dist/inbox-admin.service.d.ts +44 -0
- package/dist/inbox-admin.service.js +102 -0
- package/dist/inbox-admin.service.js.map +1 -0
- package/dist/inbox.module.d.ts +13 -0
- package/dist/inbox.module.js +59 -0
- package/dist/inbox.module.js.map +1 -0
- package/dist/inbox.service.d.ts +22 -0
- package/dist/inbox.service.js +74 -0
- package/dist/inbox.service.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/integrations/tenancy.d.ts +25 -0
- package/dist/integrations/tenancy.js +56 -0
- package/dist/integrations/tenancy.js.map +1 -0
- package/dist/internal/handler-context.d.ts +3 -0
- package/dist/internal/handler-context.js +39 -0
- package/dist/internal/handler-context.js.map +1 -0
- package/dist/internal/options.d.ts +5 -0
- package/dist/internal/options.js +86 -0
- package/dist/internal/options.js.map +1 -0
- package/dist/internal/reliability-core/abort.d.ts +2 -0
- package/dist/internal/reliability-core/abort.js +21 -0
- package/dist/internal/reliability-core/abort.js.map +1 -0
- package/dist/internal/reliability-core/index.d.ts +3 -0
- package/dist/internal/reliability-core/index.js +14 -0
- package/dist/internal/reliability-core/index.js.map +1 -0
- package/dist/internal/reliability-core/json.d.ts +13 -0
- package/dist/internal/reliability-core/json.js +125 -0
- package/dist/internal/reliability-core/json.js.map +1 -0
- package/dist/internal/reliability-core/retry.d.ts +8 -0
- package/dist/internal/reliability-core/retry.js +26 -0
- package/dist/internal/reliability-core/retry.js.map +1 -0
- package/dist/internal/validation.d.ts +6 -0
- package/dist/internal/validation.js +123 -0
- package/dist/internal/validation.js.map +1 -0
- package/dist/storage/prisma/prisma-inbox.storage.d.ts +30 -0
- package/dist/storage/prisma/prisma-inbox.storage.js +369 -0
- package/dist/storage/prisma/prisma-inbox.storage.js.map +1 -0
- package/dist/testing/index.d.ts +40 -0
- package/dist/testing/index.js +120 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/worker/inbox-worker.d.ts +31 -0
- package/dist/worker/inbox-worker.js +343 -0
- package/dist/worker/inbox-worker.js.map +1 -0
- package/docs/adr/0001-durable-inbox.md +40 -0
- package/docs/benchmarks/2026-10-27-local.md +31 -0
- package/docs/benchmarks/2026-10-28-preview1-local.md +87 -0
- package/docs/benchmarks/2026-10-28-preview1-run1.json +120 -0
- package/docs/benchmarks/2026-10-28-preview1-run2.json +120 -0
- package/docs/benchmarks/2026-10-28-preview1-run3.json +120 -0
- package/docs/benchmarks/2026-10-28-sustained-local.md +104 -0
- package/docs/benchmarks/2026-10-28-sustained-run1.json +2553 -0
- package/docs/benchmarks/2026-10-28-sustained-run2.json +2553 -0
- package/docs/broker-recovery-results.json +52 -0
- package/docs/broker-recovery.md +30 -0
- package/docs/db-operations.md +41 -0
- package/docs/implementation-roadmap.md +129 -0
- package/docs/operations/2026-10-28-database.json +34 -0
- package/docs/package-plan.md +250 -0
- package/docs/releasing.md +91 -0
- package/docs/testing.md +86 -0
- package/docs/usage.md +266 -0
- package/docs/validation.md +174 -0
- package/examples/README.md +124 -0
- package/examples/compose.yaml +26 -0
- package/examples/package.json +42 -0
- package/examples/prisma/schema.prisma +29 -0
- package/examples/prisma.config.ts +10 -0
- package/examples/scripts/prepare-db.cjs +45 -0
- package/examples/scripts/smoke.cjs +37 -0
- package/examples/scripts/with-brokers.cjs +249 -0
- package/examples/scripts/with-rabbitmq.cjs +131 -0
- package/examples/sql/application.sql +14 -0
- package/examples/sql/tenancy.sql +26 -0
- package/examples/src/broker-common.ts +79 -0
- package/examples/src/bullmq-ingress/main.ts +165 -0
- package/examples/src/common.ts +112 -0
- package/examples/src/idempotency-ingress/main.ts +211 -0
- package/examples/src/kafka-ingress/main.ts +209 -0
- package/examples/src/outbox-to-inbox/main.ts +198 -0
- package/examples/src/rabbitmq-ingress/main.ts +203 -0
- package/examples/src/sqs-ingress/main.ts +273 -0
- package/examples/src/tenancy/main.ts +210 -0
- package/examples/src/webhook-ingress/main.ts +172 -0
- package/examples/tsconfig.json +16 -0
- package/package.json +99 -4
- package/src/sql/create-inbox-table.sql +100 -0
- package/src/sql/migrate-v1-to-v2.sql +53 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import "reflect-metadata";
|
|
2
|
+
import { Module } from "@nestjs/common";
|
|
3
|
+
import { NestFactory } from "@nestjs/core";
|
|
4
|
+
import { PrismaPg } from "@prisma/adapter-pg";
|
|
5
|
+
import {
|
|
6
|
+
InboxModule,
|
|
7
|
+
InboxPermanentError,
|
|
8
|
+
type InboxHandler,
|
|
9
|
+
} from "@nestarc/inbox";
|
|
10
|
+
import { PrismaClient, type Prisma } from "./generated/prisma/client";
|
|
11
|
+
import { setTimeout } from "node:timers/promises";
|
|
12
|
+
|
|
13
|
+
export const tenantId = "example-tenant";
|
|
14
|
+
export type OrderPayload = { orderId: string };
|
|
15
|
+
|
|
16
|
+
export function exampleDatabaseUrl(): string {
|
|
17
|
+
const value = process.env.INBOX_EXAMPLE_DATABASE_URL;
|
|
18
|
+
if (!value)
|
|
19
|
+
throw new Error(
|
|
20
|
+
"Set INBOX_EXAMPLE_DATABASE_URL to the dedicated local fixture.",
|
|
21
|
+
);
|
|
22
|
+
const url = new URL(value);
|
|
23
|
+
const isolatedTest =
|
|
24
|
+
process.env.INBOX_TEST_DATABASE === "1" &&
|
|
25
|
+
process.env.INBOX_TEST_DATABASE_URL === value &&
|
|
26
|
+
process.env.INBOX_TEST_DATABASE_NAME === url.pathname.slice(1) &&
|
|
27
|
+
/^\/nestarc_inbox_test_[a-f0-9]{16}$/.test(url.pathname) &&
|
|
28
|
+
url.username === "inbox_test";
|
|
29
|
+
if (
|
|
30
|
+
!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) ||
|
|
31
|
+
(!/^\/inbox_examples(?:_[a-zA-Z0-9_]+)?$/.test(url.pathname) &&
|
|
32
|
+
!isolatedTest)
|
|
33
|
+
) {
|
|
34
|
+
throw new Error("Examples require a local inbox_examples database.");
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createPrisma(): PrismaClient {
|
|
40
|
+
return new PrismaClient({
|
|
41
|
+
adapter: new PrismaPg({ connectionString: exampleDatabaseUrl(), max: 12 }),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function projectionHandler(
|
|
46
|
+
consumer: string,
|
|
47
|
+
eventType: string,
|
|
48
|
+
): InboxHandler<OrderPayload, Prisma.TransactionClient> {
|
|
49
|
+
return {
|
|
50
|
+
consumer,
|
|
51
|
+
eventType,
|
|
52
|
+
schemaVersion: 1,
|
|
53
|
+
validate: (payload) =>
|
|
54
|
+
!!payload &&
|
|
55
|
+
typeof payload === "object" &&
|
|
56
|
+
!Array.isArray(payload) &&
|
|
57
|
+
typeof payload.orderId === "string" &&
|
|
58
|
+
payload.orderId.length > 0,
|
|
59
|
+
async handle(payload, ctx) {
|
|
60
|
+
if (ctx.scope.kind !== "tenant") throw new InboxPermanentError();
|
|
61
|
+
await ctx.tx.exampleProjection.create({
|
|
62
|
+
data: { tenantId: ctx.scope.id, orderId: payload.orderId },
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function startInbox(consumer: string, eventType: string) {
|
|
69
|
+
const prisma = createPrisma();
|
|
70
|
+
@Module({
|
|
71
|
+
imports: [
|
|
72
|
+
InboxModule.forRoot({
|
|
73
|
+
prisma,
|
|
74
|
+
handlers: [projectionHandler(consumer, eventType)],
|
|
75
|
+
worker: { enabled: true, pollIntervalMs: 25, concurrency: 2 },
|
|
76
|
+
}),
|
|
77
|
+
],
|
|
78
|
+
})
|
|
79
|
+
class AppModule {}
|
|
80
|
+
try {
|
|
81
|
+
const app = await NestFactory.createApplicationContext(AppModule, {
|
|
82
|
+
logger: ["error", "warn"],
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
app,
|
|
86
|
+
prisma,
|
|
87
|
+
async close() {
|
|
88
|
+
try {
|
|
89
|
+
await app.close();
|
|
90
|
+
} finally {
|
|
91
|
+
await prisma.$disconnect();
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
} catch (error) {
|
|
96
|
+
await prisma.$disconnect();
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function waitUntil(
|
|
102
|
+
check: () => Promise<boolean>,
|
|
103
|
+
label: string,
|
|
104
|
+
timeoutMs = 15_000,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
const deadline = Date.now() + timeoutMs;
|
|
107
|
+
while (Date.now() < deadline) {
|
|
108
|
+
if (await check()) return;
|
|
109
|
+
await setTimeout(25);
|
|
110
|
+
}
|
|
111
|
+
throw new Error(`Timed out waiting for ${label}`);
|
|
112
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import "reflect-metadata";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import type { AddressInfo } from "node:net";
|
|
6
|
+
import { PostgresStorage } from "@nestarc/idempotency/postgres";
|
|
7
|
+
import {
|
|
8
|
+
InboxConflictError,
|
|
9
|
+
InboxService,
|
|
10
|
+
InboxValidationError,
|
|
11
|
+
} from "@nestarc/inbox";
|
|
12
|
+
import { Pool } from "pg";
|
|
13
|
+
import { exampleDatabaseUrl, startInbox, tenantId, waitUntil } from "../common";
|
|
14
|
+
|
|
15
|
+
/** An HTTP response cache may expire; the producer event ID must survive that expiry. */
|
|
16
|
+
async function main() {
|
|
17
|
+
const fixture = await startInbox("example.idempotency.v1", "order.created");
|
|
18
|
+
const pool = new Pool({ connectionString: exampleDatabaseUrl(), max: 4 });
|
|
19
|
+
const tableName = `inbox_example_http_${randomUUID().replaceAll("-", "")}`;
|
|
20
|
+
const storage = new PostgresStorage({ pool, tableName });
|
|
21
|
+
const inbox = fixture.app.get(InboxService);
|
|
22
|
+
let acceptCalls = 0;
|
|
23
|
+
|
|
24
|
+
const server = createServer((request, response) => {
|
|
25
|
+
const send = (status: number, body: string, replayed = false) => {
|
|
26
|
+
response.writeHead(status, {
|
|
27
|
+
"content-type": "application/json",
|
|
28
|
+
...(replayed ? { "idempotency-replayed": "true" } : {}),
|
|
29
|
+
});
|
|
30
|
+
response.end(body);
|
|
31
|
+
};
|
|
32
|
+
void (async () => {
|
|
33
|
+
if (request.method !== "POST" || request.url !== "/events")
|
|
34
|
+
return send(404, '{"error":"not found"}');
|
|
35
|
+
const header = request.headers["idempotency-key"];
|
|
36
|
+
if (typeof header !== "string" || !/^[a-zA-Z0-9-]{1,128}$/.test(header))
|
|
37
|
+
return send(400, '{"error":"invalid idempotency key"}');
|
|
38
|
+
const chunks: Buffer[] = [];
|
|
39
|
+
let size = 0;
|
|
40
|
+
for await (const chunk of request) {
|
|
41
|
+
const bytes = Buffer.from(chunk);
|
|
42
|
+
size += bytes.length;
|
|
43
|
+
if (size > 4096) return send(413, '{"error":"body too large"}');
|
|
44
|
+
chunks.push(bytes);
|
|
45
|
+
}
|
|
46
|
+
let event: { id: string; orderId: string };
|
|
47
|
+
try {
|
|
48
|
+
const input: unknown = JSON.parse(
|
|
49
|
+
Buffer.concat(chunks).toString("utf8"),
|
|
50
|
+
);
|
|
51
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
52
|
+
throw new Error("invalid body");
|
|
53
|
+
const value = input as Record<string, unknown>;
|
|
54
|
+
if (
|
|
55
|
+
Object.keys(value).some((key) => !["id", "orderId"].includes(key)) ||
|
|
56
|
+
typeof value.id !== "string" ||
|
|
57
|
+
typeof value.orderId !== "string" ||
|
|
58
|
+
!/^[a-f0-9-]{36}$/.test(value.id) ||
|
|
59
|
+
!/^[a-f0-9-]{36}$/.test(value.orderId)
|
|
60
|
+
)
|
|
61
|
+
throw new Error("invalid event");
|
|
62
|
+
event = { id: value.id, orderId: value.orderId };
|
|
63
|
+
} catch {
|
|
64
|
+
return send(400, '{"error":"invalid event"}');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// This local fixture's authenticated account is fixed. A real route must authenticate
|
|
68
|
+
// before deriving tenantId and must scope its HTTP key by tenant and endpoint.
|
|
69
|
+
const key = `${tenantId}:POST:/events:${header}`;
|
|
70
|
+
const fingerprint = createHash("sha256")
|
|
71
|
+
.update(JSON.stringify(event))
|
|
72
|
+
.digest("hex");
|
|
73
|
+
const claim = await storage.create(key, fingerprint, 5);
|
|
74
|
+
if (!claim.acquired) {
|
|
75
|
+
const current = await storage.get(key);
|
|
76
|
+
if (!current) return send(503, '{"error":"retry acquisition"}');
|
|
77
|
+
if (current.fingerprint !== fingerprint)
|
|
78
|
+
return send(422, '{"error":"idempotency content conflict"}');
|
|
79
|
+
if (current.status !== "COMPLETED")
|
|
80
|
+
return send(409, '{"error":"request is processing"}');
|
|
81
|
+
return send(current.statusCode!, current.responseBody!, true);
|
|
82
|
+
}
|
|
83
|
+
const token = claim.token;
|
|
84
|
+
if (!token) return send(503, '{"error":"missing request ownership"}');
|
|
85
|
+
try {
|
|
86
|
+
acceptCalls++;
|
|
87
|
+
const receipt = await inbox.accept({
|
|
88
|
+
scope: { kind: "tenant", id: tenantId },
|
|
89
|
+
source: "example-http-client",
|
|
90
|
+
consumer: "example.idempotency.v1",
|
|
91
|
+
messageId: event.id,
|
|
92
|
+
eventType: "order.created",
|
|
93
|
+
schemaVersion: 1,
|
|
94
|
+
payload: { orderId: event.orderId },
|
|
95
|
+
});
|
|
96
|
+
const body = JSON.stringify({
|
|
97
|
+
inboxId: receipt.id,
|
|
98
|
+
kind: receipt.kind,
|
|
99
|
+
});
|
|
100
|
+
const completion = await storage.complete(
|
|
101
|
+
key,
|
|
102
|
+
token,
|
|
103
|
+
{ statusCode: 202, body },
|
|
104
|
+
1,
|
|
105
|
+
);
|
|
106
|
+
if (completion !== "ok")
|
|
107
|
+
return send(503, '{"error":"request ownership lost"}');
|
|
108
|
+
return send(202, body);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
// A retry uses the same producer event ID, even if the acceptance result was lost.
|
|
111
|
+
await storage.delete(key, token).catch(() => undefined);
|
|
112
|
+
if (error instanceof InboxConflictError)
|
|
113
|
+
return send(422, '{"error":"event identity content conflict"}');
|
|
114
|
+
if (error instanceof InboxValidationError)
|
|
115
|
+
return send(400, '{"error":"invalid inbox envelope"}');
|
|
116
|
+
return send(503, '{"error":"acceptance unavailable"}');
|
|
117
|
+
}
|
|
118
|
+
})().catch(() => send(503, '{"error":"storage unavailable"}'));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
// A dedicated fixture only. Deploy the package's SQL migration explicitly in production.
|
|
123
|
+
await PostgresStorage.createSchema(pool, tableName);
|
|
124
|
+
await new Promise<void>((resolve, reject) => {
|
|
125
|
+
server.once("error", reject);
|
|
126
|
+
server.listen(0, "127.0.0.1", () => {
|
|
127
|
+
server.removeListener("error", reject);
|
|
128
|
+
resolve();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
const endpoint = `http://127.0.0.1:${(server.address() as AddressInfo).port}/events`;
|
|
132
|
+
const idempotencyKey = randomUUID();
|
|
133
|
+
const event = { id: randomUUID(), orderId: randomUUID() };
|
|
134
|
+
const post = (body = event) =>
|
|
135
|
+
fetch(endpoint, {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: {
|
|
138
|
+
"content-type": "application/json",
|
|
139
|
+
"idempotency-key": idempotencyKey,
|
|
140
|
+
},
|
|
141
|
+
body: JSON.stringify(body),
|
|
142
|
+
signal: AbortSignal.timeout(5000),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const first = await post();
|
|
146
|
+
assert.equal(first.status, 202);
|
|
147
|
+
const firstBody = await first.text();
|
|
148
|
+
assert.equal(JSON.parse(firstBody).kind, "accepted");
|
|
149
|
+
const replay = await post();
|
|
150
|
+
assert.equal(replay.status, 202);
|
|
151
|
+
assert.equal(replay.headers.get("idempotency-replayed"), "true");
|
|
152
|
+
assert.equal(await replay.text(), firstBody);
|
|
153
|
+
assert.equal(acceptCalls, 1);
|
|
154
|
+
const conflict = await post({ ...event, orderId: randomUUID() });
|
|
155
|
+
assert.equal(conflict.status, 422);
|
|
156
|
+
await conflict.text();
|
|
157
|
+
assert.equal(acceptCalls, 1);
|
|
158
|
+
|
|
159
|
+
await waitUntil(
|
|
160
|
+
async () =>
|
|
161
|
+
(await storage.get(`${tenantId}:POST:/events:${idempotencyKey}`)) ===
|
|
162
|
+
null,
|
|
163
|
+
"actual HTTP response retention expiry",
|
|
164
|
+
5000,
|
|
165
|
+
);
|
|
166
|
+
// The HTTP record has expired, but Inbox still rejects event identity reuse.
|
|
167
|
+
const durableConflict = await post({ ...event, orderId: randomUUID() });
|
|
168
|
+
assert.equal(durableConflict.status, 422);
|
|
169
|
+
await durableConflict.text();
|
|
170
|
+
const afterExpiry = await post();
|
|
171
|
+
assert.equal(afterExpiry.status, 202);
|
|
172
|
+
const afterBody = (await afterExpiry.json()) as {
|
|
173
|
+
inboxId: string;
|
|
174
|
+
kind: string;
|
|
175
|
+
};
|
|
176
|
+
assert.equal(afterBody.kind, "duplicate");
|
|
177
|
+
assert.equal(afterBody.inboxId, JSON.parse(firstBody).inboxId);
|
|
178
|
+
assert.equal(acceptCalls, 3);
|
|
179
|
+
await waitUntil(
|
|
180
|
+
async () =>
|
|
181
|
+
(await fixture.prisma.exampleProjection.count({
|
|
182
|
+
where: { tenantId, orderId: event.orderId },
|
|
183
|
+
})) === 1,
|
|
184
|
+
"one durable business projection despite HTTP cache expiry",
|
|
185
|
+
);
|
|
186
|
+
console.log(
|
|
187
|
+
JSON.stringify({
|
|
188
|
+
responseReplay: true,
|
|
189
|
+
afterExpiry: "duplicate",
|
|
190
|
+
projections: 1,
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
} finally {
|
|
194
|
+
await new Promise<void>((resolve) => {
|
|
195
|
+
server.close(() => resolve());
|
|
196
|
+
server.closeAllConnections();
|
|
197
|
+
});
|
|
198
|
+
try {
|
|
199
|
+
await pool.query(`DROP TABLE IF EXISTS "${tableName}"`);
|
|
200
|
+
} finally {
|
|
201
|
+
await storage.close();
|
|
202
|
+
await pool.end();
|
|
203
|
+
await fixture.close();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
void main().catch((error: unknown) => {
|
|
209
|
+
console.error(error);
|
|
210
|
+
process.exitCode = 1;
|
|
211
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { setTimeout } from "node:timers/promises";
|
|
4
|
+
import { Kafka, logLevel } from "kafkajs";
|
|
5
|
+
import {
|
|
6
|
+
InboxConflictError,
|
|
7
|
+
InboxService,
|
|
8
|
+
InboxValidationError,
|
|
9
|
+
} from "@nestarc/inbox";
|
|
10
|
+
import { tenantId, waitUntil } from "../common";
|
|
11
|
+
import { parseBrokerEvent, startBrokerInbox } from "../broker-common";
|
|
12
|
+
|
|
13
|
+
async function main() {
|
|
14
|
+
const broker = process.env.INBOX_EXAMPLE_KAFKA_BROKER;
|
|
15
|
+
if (!broker || !/^127\.0\.0\.1:\d+$/.test(broker))
|
|
16
|
+
throw new Error(
|
|
17
|
+
"Set INBOX_EXAMPLE_KAFKA_BROKER to the local fixture host:port.",
|
|
18
|
+
);
|
|
19
|
+
const name = `inbox-example-${randomUUID()}`;
|
|
20
|
+
const topic = `${name}-orders`;
|
|
21
|
+
const deadTopic = `${name}-dead`;
|
|
22
|
+
const kafka = new Kafka({
|
|
23
|
+
clientId: name,
|
|
24
|
+
brokers: [broker],
|
|
25
|
+
logLevel: logLevel.ERROR,
|
|
26
|
+
connectionTimeout: 5_000,
|
|
27
|
+
requestTimeout: 10_000,
|
|
28
|
+
retry: { retries: 3, initialRetryTime: 100, maxRetryTime: 1_000 },
|
|
29
|
+
});
|
|
30
|
+
const admin = kafka.admin();
|
|
31
|
+
const producer = kafka.producer();
|
|
32
|
+
const consumer = kafka.consumer({ groupId: name, sessionTimeout: 30_000 });
|
|
33
|
+
const fixture = await startBrokerInbox("example.kafka.v1", "order.created");
|
|
34
|
+
let callbackError: unknown;
|
|
35
|
+
let accepted = 0;
|
|
36
|
+
let quarantined = 0;
|
|
37
|
+
let transportRetries = 0;
|
|
38
|
+
let injectAcceptanceFailure = true;
|
|
39
|
+
const createdTopics: string[] = [];
|
|
40
|
+
try {
|
|
41
|
+
await admin.connect();
|
|
42
|
+
for (const candidate of [topic, deadTopic]) {
|
|
43
|
+
await admin.createTopics({
|
|
44
|
+
topics: [{ topic: candidate, numPartitions: 1, replicationFactor: 1 }],
|
|
45
|
+
waitForLeaders: true,
|
|
46
|
+
});
|
|
47
|
+
createdTopics.push(candidate);
|
|
48
|
+
}
|
|
49
|
+
await producer.connect();
|
|
50
|
+
await consumer.connect();
|
|
51
|
+
await consumer.subscribe({ topics: [topic], fromBeginning: true });
|
|
52
|
+
const inbox = fixture.app.get(InboxService);
|
|
53
|
+
await consumer.run({
|
|
54
|
+
autoCommit: false,
|
|
55
|
+
// Sequential processing inside each partition prevents committing across a gap.
|
|
56
|
+
partitionsConsumedConcurrently: 1,
|
|
57
|
+
eachMessage: async ({
|
|
58
|
+
topic: currentTopic,
|
|
59
|
+
partition,
|
|
60
|
+
message,
|
|
61
|
+
heartbeat,
|
|
62
|
+
}) => {
|
|
63
|
+
try {
|
|
64
|
+
let received = false;
|
|
65
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
66
|
+
try {
|
|
67
|
+
if (!message.value || message.value.byteLength > 256 * 1024)
|
|
68
|
+
throw new InboxValidationError("Missing or oversized event.");
|
|
69
|
+
let parsed: unknown;
|
|
70
|
+
try {
|
|
71
|
+
parsed = JSON.parse(message.value.toString("utf8"));
|
|
72
|
+
} catch {
|
|
73
|
+
throw new InboxValidationError("Invalid JSON.");
|
|
74
|
+
}
|
|
75
|
+
const event = parseBrokerEvent(parsed);
|
|
76
|
+
// Fixture-only failure: demonstrate that this offset is not committed yet.
|
|
77
|
+
if (injectAcceptanceFailure) {
|
|
78
|
+
injectAcceptanceFailure = false;
|
|
79
|
+
throw new Error("Injected acceptance failure");
|
|
80
|
+
}
|
|
81
|
+
await inbox.accept({
|
|
82
|
+
scope: { kind: "tenant", id: tenantId },
|
|
83
|
+
source: "example-kafka-orders",
|
|
84
|
+
consumer: "example.kafka.v1",
|
|
85
|
+
messageId: event.eventId,
|
|
86
|
+
eventType: "order.created",
|
|
87
|
+
schemaVersion: 1,
|
|
88
|
+
payload: { orderId: event.orderId },
|
|
89
|
+
});
|
|
90
|
+
received = true;
|
|
91
|
+
accepted++;
|
|
92
|
+
break;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
const permanent =
|
|
95
|
+
error instanceof InboxValidationError ||
|
|
96
|
+
error instanceof InboxConflictError;
|
|
97
|
+
if (permanent || attempt === 3) {
|
|
98
|
+
// A durable DLQ publish must succeed before advancing the source offset.
|
|
99
|
+
// A crash between publish and commit can duplicate DLQ entries; preserve
|
|
100
|
+
// source coordinates so the DLQ consumer can deduplicate them.
|
|
101
|
+
await producer.send({
|
|
102
|
+
topic: deadTopic,
|
|
103
|
+
acks: -1,
|
|
104
|
+
messages: [
|
|
105
|
+
{
|
|
106
|
+
key: `${currentTopic}/${partition}/${message.offset}`,
|
|
107
|
+
value: message.value,
|
|
108
|
+
headers: {
|
|
109
|
+
sourceTopic: currentTopic,
|
|
110
|
+
sourcePartition: String(partition),
|
|
111
|
+
sourceOffset: message.offset,
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
});
|
|
116
|
+
quarantined++;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
transportRetries++;
|
|
120
|
+
await heartbeat();
|
|
121
|
+
await setTimeout(100 * attempt);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Both durable acceptance and durable quarantine allow transport progress.
|
|
125
|
+
// Handler execution is asynchronous; its retry schedule belongs to Inbox.
|
|
126
|
+
await consumer.commitOffsets([
|
|
127
|
+
{
|
|
128
|
+
topic: currentTopic,
|
|
129
|
+
partition,
|
|
130
|
+
offset: String(BigInt(message.offset) + 1n),
|
|
131
|
+
},
|
|
132
|
+
]);
|
|
133
|
+
if (received) await heartbeat();
|
|
134
|
+
} catch (error) {
|
|
135
|
+
callbackError = error;
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
const orderId = randomUUID();
|
|
141
|
+
const eventId = randomUUID();
|
|
142
|
+
await producer.send({
|
|
143
|
+
topic,
|
|
144
|
+
acks: -1,
|
|
145
|
+
messages: [
|
|
146
|
+
{ value: JSON.stringify({ eventId, orderId }) },
|
|
147
|
+
{ value: JSON.stringify({ eventId, orderId }) },
|
|
148
|
+
{ value: "invalid-json" },
|
|
149
|
+
],
|
|
150
|
+
});
|
|
151
|
+
await waitUntil(
|
|
152
|
+
async () => {
|
|
153
|
+
if (callbackError) throw callbackError;
|
|
154
|
+
const offsets = await admin.fetchOffsets({
|
|
155
|
+
groupId: name,
|
|
156
|
+
topics: [topic],
|
|
157
|
+
});
|
|
158
|
+
return (
|
|
159
|
+
accepted === 2 &&
|
|
160
|
+
quarantined === 1 &&
|
|
161
|
+
offsets[0]?.partitions[0]?.offset === "3" &&
|
|
162
|
+
(await fixture.prisma.exampleProjection.count({
|
|
163
|
+
where: { orderId, tenantId },
|
|
164
|
+
})) === 1
|
|
165
|
+
);
|
|
166
|
+
},
|
|
167
|
+
"Kafka durable acceptance, contiguous offset commit, and quarantine",
|
|
168
|
+
30_000,
|
|
169
|
+
);
|
|
170
|
+
assert.equal(transportRetries, 1);
|
|
171
|
+
assert.equal(fixture.retries(), 1);
|
|
172
|
+
const deadOffsets = await admin.fetchTopicOffsets(deadTopic);
|
|
173
|
+
assert.equal(deadOffsets[0]?.high, "1");
|
|
174
|
+
console.log(
|
|
175
|
+
JSON.stringify({
|
|
176
|
+
broker: "Kafka protocol / Redpanda fixture",
|
|
177
|
+
accepted,
|
|
178
|
+
quarantined,
|
|
179
|
+
transportRetries,
|
|
180
|
+
handlerRetries: fixture.retries(),
|
|
181
|
+
projections: 1,
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
} finally {
|
|
185
|
+
try {
|
|
186
|
+
await consumer.disconnect();
|
|
187
|
+
} finally {
|
|
188
|
+
try {
|
|
189
|
+
await producer.disconnect();
|
|
190
|
+
} finally {
|
|
191
|
+
try {
|
|
192
|
+
if (createdTopics.length)
|
|
193
|
+
await admin.deleteTopics({ topics: createdTopics });
|
|
194
|
+
} finally {
|
|
195
|
+
try {
|
|
196
|
+
await admin.disconnect();
|
|
197
|
+
} finally {
|
|
198
|
+
await fixture.close();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
void main().catch((error: unknown) => {
|
|
207
|
+
console.error(error);
|
|
208
|
+
process.exitCode = 1;
|
|
209
|
+
});
|