Phần 2 · Chương 2.3
Gửi hai lần
Bạn sẽ tạo ra: Idempotency keys, unique index một phần (DR-03) · khoảng 75 phút, bao gồm bài tập
Tài liệu gốc: SRS — Đặc tả yêu cầu phần mềm · SAD — Tài liệu kiến trúc phần mềm (tiếng Anh)
Chương 0.3 đã map journey mà phần này sinh ra để thắng, và khoảnh khắc tệ nhất của nó gói gọn trong một câu: Tuan gõ "B2, north ramp" rồi bấm send đúng lúc sóng mất. Lệnh send đang bay; không có ack nào quay về. Giờ client của anh đối mặt câu hỏi cổ nhất trong distributed systems — nó đã xảy ra chưa? — và cả hai cách đoán đều sai. Giả sử fail rồi resend: dispatcher có thể đọc message hai lần. Giả sử success rồi không làm gì: dispatcher có thể không bao giờ đọc nó. Journey map nói thẳng về mức độ nghiêm trọng: "Duplicate and phantom messages are the #1 user-visible defect in homegrown chat." Chương này làm cho câu hỏi ấy trở thành có đoán sai cũng an toàn — retry bao nhiêu lần tùy ý, database vẫn chỉ giữ đúng một bản.
Cửa sổ mười giây
Chạy lại cỗ máy của 2.2 trong điều kiện của journey 4. POST tới api;
transaction commit; seq 42 đã durable; 201 rời server — rồi chết ở cửa hầm.
Từ phía platform, send đã success. Từ phía Tuan, chỉ còn im lặng và một icon
đồng hồ (state sending trung thực của FR-SDK-05 — các chương SDK sở hữu
icon ấy, nhưng sự trung thực của nó bắt đầu ở đây, vì một state chỉ trung
thực nếu server khiến nó resolve được).
Chín mươi giây sau điện thoại bắt được wifi và client làm điều hợp lý duy nhất: nó send lần nữa. Hãy xem endpoint của 2.2 — đúng theo mọi thước đo của chương đó — xử lý retry này ra sao:
it("a retry WITHOUT a key duplicates the message — journey 4's failure, staged", async () => {
const channel = await repo.createChannel("idem-no-key", "public");
await repo.sendMessage(channel.id, { text: "B2, north ramp" });
// The ack was lost; the client cannot know. It retries:
await repo.sendMessage(channel.id, { text: "B2, north ramp" });
const rows = await repo.listMessagesRaw(channel.id);
// Two rows, seq 1 and 2, identical text. The dispatcher reads it twice.
expect(rows.filter((m) => m.text === "B2, north ramp")).toHaveLength(2);
});Test passes — đó chính là staging. Không có gì trong 2.2 sai; từng transaction làm đúng điều nó hứa. Defect nằm giữa các transaction, trong retry mà network ép phải có, và cẩn thận đến mấy bên trong một request cũng không thể nhìn thấy nó. Journey map đã đặt tên failure này trước cả khi ta viết endpoint: "the naive implementation retries without an idempotency key and the dispatcher receives 'B2, north ramp' three times — or worse, the client assumes failure when the server actually persisted it."
sequenceDiagram
participant T as client của Tuan
participant A as API service
participant P as PostgreSQL
Note over T,A: KHÔNG có idempotency key
T->>A: POST message "B2, north ramp"
A->>P: INSERT · COMMIT (seq 42)
A--xT: 201 — mất cùng tín hiệu
Note over T: không ack nào quay về —<br/>đã send chưa? client không thể biết
T->>A: retry: POST "B2, north ramp"
A->>P: INSERT · COMMIT (seq 43)
A-->>T: 201
Note over P: dispatcher giờ đọc nó hai lần —<br/>đúng failure của journey 4Key đi cùng message
Fix là một contract giữa client và server. Ở send time — trước khi bất cứ
thứ gì có thể fail, và đó là phần quan trọng (FR-SDK-06: key được "generated
at send time, before the failure") — client mint một idempotency key và gắn nó
vào. Key có nghĩa là: lần send logic này, dù nó physically arrive bao nhiêu
lần đi nữa. FR-MSG-04 nói phần của server: "A repeated key within 24 hours
shall return the original message with 201-equivalent semantics and shall
not create a duplicate."
2.1 đã cắm enforcement mechanism và bảo bạn chờ tới chương này. Nó đây, từ migration bạn đã apply — partial unique index mà chương schema gọi là "toàn bộ chương 2.3, được cắm trước từ bây giờ":
CREATE UNIQUE INDEX "messages_idem" ON "messages"
USING btree ("channel_id","idempotency_key")
WHERE "messages"."idempotency_key" IS NOT NULL; -- DR-03Partial, vì keys là optional (server-originated messages có thể không mang
key — index bỏ qua NULL thay vì coi chúng là bằng nhau). Unique theo
(channel_id, key), vì key namespace thuộc về channel. Và ở trong
database, đó là toàn bộ lập luận của box tiếp theo.
Optional đáng thêm một câu nữa, vì nó là policy, không phải loophole. Interactive clients — mọi thứ mà ngón tay con người có thể retry — luôn nên send một key, và SDK sẽ làm việc đó tự động. Nhưng backend-originated sends của FR-MSG-13 và system messages mà các phần sau giới thiệu có caller với delivery guarantees riêng; ép một synthetic key lên chúng chỉ là ceremony mà không thêm protection. Partial index encode policy chính xác: nếu bạn claim một key, nó bind tuyệt đối; nếu không, bạn đã opt out khỏi retry protection, và opt-out ấy hiện rõ trong chính code của bạn thay vì bị default ngầm.
flowchart TB
mem["application memory<br/>(một Set các key đã thấy)"]
memx["✗ chết khi restart<br/>✗ instance khác không thấy"]
db["storage layer<br/>partial unique index (DR-03)"]
dbok["✓ sống sót qua restart<br/>✓ một sự thật cho mọi instance<br/>✓ enforce cả với code quên check"]
mem --- memx
db --- dbok
note["constitution II: enforced at the storage layer<br/>(unique index), not in application memory"]
db ~~~ noteKey đi vào qua cùng boundary schema mà 2.2 đã dựng — một optional field, và pipe validate nó như mọi thứ khác:
import { z } from "zod";
// The send body (chapter 2.2). FR-MSG-01 fixes the limits: text up to
// 8,000 characters, metadata up to 4 KB of JSON — the length check lands
// with FR-EMJ-02's code-point counting in the emoji chapter; today the
// character bound is the honest approximation, recorded as such.
export const sendMessageBodySchema = z.strictObject({
text: z.string().min(1).max(8000),
metadata: z.record(z.string(), z.unknown()).optional(),
+ // Chapter 2.3 (FR-MSG-04): the client's idempotency key — minted at send
+ // time (FR-SDK-06), optional because server-originated messages may not
+ // carry one. The partial unique index (DR-03) ignores NULLs.
+ idempotency_key: z.string().uuid().optional(),
});
export type SendMessageBody = z.infer<typeof sendMessageBodySchema>;Retry xứng đáng nghe lại điều gì
Trước cơ chế, hãy chốt semantics, vì chúng không hiển nhiên như vẻ ngoài. Khi retry đụng index và không insert gì, client nên nhận gì? Một error ("409: duplicate") nghe nghiêm cẩn và sai hoàn toàn. Client không hề cư xử sai — nó đang làm đúng việc client phải làm sau một lost ack, và câu hỏi nó đặt ra không phải "tôi có được create một message không?" mà là "message của tôi đã vào chưa?" Câu trả lời trung thực là có — đây, seq 42, được create ở timestamp mà đường hầm đã nuốt mất. Đó là lý do FR-MSG-04 chỉ định "201-equivalent semantics": retry nhận message original, body không phân biệt được với câu trả lời nó lẽ ra đã nhận lần đầu, nên client code chỉ có đúng một success path để viết. Một error response ở đây sẽ ép mọi SDK và mọi customer integration coi "already sent" như failure mà họ phải special-case ngược lại thành success — hàng trăm team tự suy diễn lại, thường là tệ, một decision mà platform lẽ ra phải quyết một lần.
Service là nơi decision ấy thành code — nó map snake_case key trong request body sang argument của repository và bỏ internal flag trước khi bất cứ thứ gì chạm tới wire:
import { Injectable, NotFoundException } from "@nestjs/common";
import {
ChannelNotFoundError,
Repository,
type MessageRow,
} from "../db/repository";
import type { SendMessageBody } from "./messages.schema";
-// The thin layer between HTTP and the repository (chapter 2.2). It owns
+// The thin layer between HTTP and the repository (chapters 2.2 + 2.3). It
-// exactly one thing today: turning the layer's domain error into the
+// owns two things: turning the layer's domain error into the wire's 404,
-// wire's 404 — same non-answer a foreign tenant's id has received since
-// 2.1, now wearing an HTTP status. The filter from 1.4 shapes the body.
+// and translating the repository's `duplicate: true` into FR-MSG-04's
+// "201-equivalent semantics" — the retry returns the original message,
+// indistinguishable from a fresh send (the `duplicate` flag stays internal).
@Injectable()
export class MessagesService {
constructor(private readonly repo: Repository) {}
- async send(channelId: string, body: SendMessageBody): Promise<MessageRow> {
+ async send(
+ channelId: string,
+ body: SendMessageBody,
+ ): Promise<MessageRow> {
try {
- return await this.repo.sendMessage(channelId, body);
+ const result = await this.repo.sendMessage(channelId, {
+ text: body.text,
+ metadata: body.metadata,
+ ...(body.idempotency_key != null && {
+ idempotencyKey: body.idempotency_key,
+ }),
+ });
+ // The internal duplicate flag never reaches the wire: the client
+ // sees the same body whether this was the original send or the
+ // retry that recovered it (FR-MSG-04's 201-equivalent semantics).
+ return {
+ id: result.id,
+ channel_id: result.channel_id,
+ seq: result.seq,
+ text: result.text,
+ created_at: result.created_at,
+ };
} catch (error) {
if (error instanceof ChannelNotFoundError) {
// A CONSTANT message: echoing the id back would make the foreign-id
// answer differ from the missing-id answer, and "different" is
// itself a disclosure (FR-TEN-05).
throw new NotFoundException("channel not found");
}
throw error;
}
}
}Một clause trên write path
Write path từ 2.2 mọc thêm đúng clause mà §5.1 đã cho thấy, và phần amendment của repository đủ nhỏ để đọc trọn:
import { randomUUID } from "node:crypto";
import { and, asc, eq, sql } from "drizzle-orm";
import type { Db } from "./client";
import { channels, members, messages, users } from "./schema";
// The repository layer — the ONE place data access lives (ADR-04's single
// writer, constitution I). Two surfaces with a bright line between them:
//
// createEnvironment — the ADMIN surface. It creates tenants, so it is the
// only operation here that is not tenant-scoped. It also inserts a stub
// application row to satisfy environments' NOT NULL foreign key (recorded
// decision: the real application lifecycle belongs to Part 3).
//
// Repository — everything else. The constructor REQUIRES an
// environment_id; every query is scoped by it HERE, in one home — never
// at call sites. Cross-tenant reads return null/empty: no data, and no
// reveal that the foreign id even exists (FR-TEN-05).
//
// Drizzle is the query engine inside this layer (ADR-16): queries keep
// their SQL shape and gain end-to-end types. Where the builder falls short,
// a raw SQL island is permitted — inside the layer, never outside it.
//
// All primary keys are generated app-side (crypto.randomUUID) — the SAD's
// SQL declares no id defaults, and the migration adds none.
export interface Environment {
id: string;
kind: "development" | "production";
}
export async function createEnvironment(
db: Db,
{ name, kind = "development" }: { name: string; kind?: Environment["kind"] },
): Promise<Environment> {
const applicationId = randomUUID();
const environmentId = randomUUID();
// The admin surface writes through the same Db handle but carries no
// tenant scope — it is the operation that MINTS the scope.
await db.execute(
sql`INSERT INTO applications (id, name) VALUES (${applicationId}, ${name})`,
);
await db.execute(
sql`INSERT INTO environments (id, application_id, kind, signing_secret)
VALUES (${environmentId}, ${applicationId}, ${kind}, ${randomUUID()})`,
);
return { id: environmentId, kind };
}
export interface UserRow {
id: string;
external_id: string;
display_name: string | null;
}
export interface ChannelRow {
id: string;
external_id: string;
type: "public" | "private";
name: string | null;
}
export interface MessageRow {
id: string;
channel_id: string;
seq: number;
text: string | null;
created_at: string;
+ /** Chapter 2.3 (FR-MSG-04): true when a retry was recognised by the
+ * idempotency index and the ORIGINAL message was returned instead of
+ * a new insert. The service layer uses this to decide response shape. */
+ duplicate?: boolean;
}
/** Thrown when a channel id resolves to nothing IN THIS TENANT — which,
* from the caller's side, is indistinguishable from "does not exist"
* (FR-TEN-05: no data, and no reveal that the foreign id exists). The
* layer stays framework-free; the service turns this into the wire's
* 404 (constitution I's isolation, EIR-API-04's envelope). */
export class ChannelNotFoundError extends Error {
constructor(public readonly channelId: string) {
super(`channel not found: ${channelId}`);
this.name = "ChannelNotFoundError";
}
+}
+
+/** Timestamps cross the wire as RFC 3339 strings (constitution: UTC,
+ * millisecond precision) — the driver hands back a Date or a string
+ * depending on the column and the query shape. */
+function toIso(value: Date | string): string {
+ return value instanceof Date ? value.toISOString() : String(value);
}
export class Repository {
// Constructor parameter properties — the shorthand chapter 1.4 released
// for this service when ADR-15 spent erasableSyntaxOnly on decorator
// metadata. The guarantee still holds in the gateway and every package.
constructor(
private readonly db: Db,
private readonly environmentId: string,
) {}
async createUser(externalId: string, displayName?: string): Promise<UserRow> {
const id = randomUUID();
await this.db.insert(users).values({
id,
environmentId: this.environmentId,
externalId,
displayName: displayName ?? null,
});
return { id, external_id: externalId, display_name: displayName ?? null };
}
async getUserByExternalId(externalId: string): Promise<UserRow | null> {
const rows = await this.db
.select({
id: users.id,
external_id: users.externalId,
display_name: users.displayName,
})
.from(users)
.where(
and(
eq(users.environmentId, this.environmentId),
eq(users.externalId, externalId),
),
);
return rows[0] ?? null;
}
async createChannel(
externalId: string,
type: ChannelRow["type"],
name?: string,
): Promise<ChannelRow> {
const id = randomUUID();
await this.db.insert(channels).values({
id,
environmentId: this.environmentId,
externalId,
type,
name: name ?? null,
});
return { id, external_id: externalId, type, name: name ?? null };
}
async getChannelByExternalId(externalId: string): Promise<ChannelRow | null> {
const rows = await this.db
.select({
id: channels.id,
external_id: channels.externalId,
type: sql<ChannelRow["type"]>`${channels.type}`,
name: channels.name,
})
.from(channels)
.where(
and(
eq(channels.environmentId, this.environmentId),
eq(channels.externalId, externalId),
),
);
return rows[0] ?? null;
}
async listChannels(): Promise<ChannelRow[]> {
return this.db
.select({
id: channels.id,
external_id: channels.externalId,
type: sql<ChannelRow["type"]>`${channels.type}`,
name: channels.name,
})
.from(channels)
.where(eq(channels.environmentId, this.environmentId))
.orderBy(asc(channels.externalId));
}
/** Membership joins live in channel-land, so the tenant scope rides the
* channel: the double-scoped SELECT below is what makes a foreign channel
* id useless. INSERT ... SELECT is where the builder falls short — this
* is the layer's one raw SQL island, permitted by ADR-16 and kept inside
* the wall like everything else. */
async addMember(channelId: string, userId: string): Promise<boolean> {
const result = await this.db.execute(
sql`INSERT INTO members (channel_id, user_id)
SELECT c.id, u.id FROM channels c, users u
WHERE c.id = ${channelId} AND c.environment_id = ${this.environmentId}
AND u.id = ${userId} AND u.environment_id = ${this.environmentId}`,
);
return (result.rowCount ?? 0) > 0;
}
async listMembers(channelId: string): Promise<string[]> {
const rows = await this.db
.select({ user_id: members.userId })
.from(members)
.innerJoin(channels, eq(channels.id, members.channelId))
.where(
and(
eq(members.channelId, channelId),
eq(channels.environmentId, this.environmentId),
),
)
.orderBy(asc(members.joinedAt));
return rows.map((r) => r.user_id);
}
async channelsForUser(userId: string): Promise<string[]> {
const rows = await this.db
.select({ channel_id: members.channelId })
.from(members)
.innerJoin(users, eq(users.id, members.userId))
.where(
and(
eq(members.userId, userId),
eq(users.environmentId, this.environmentId),
),
);
return rows.map((r) => r.channel_id);
}
- /** The write path (chapter 2.2): sequence assignment under the channel
+ /** The write path (chapters 2.2 + 2.3): sequence assignment under the
- * row lock (ADR-03). The transaction IS the ordering guarantee: the
+ * channel row lock (ADR-03), with idempotency enforcement via the
+ * partial unique index (DR-03). The transaction IS the ordering
- * lock serialises assignment per channel, and the ack that matters
+ * guarantee: the lock serialises assignment per channel, and the ack
- * happens only after commit (FR-MSG-05).
+ * that matters happens only after commit (FR-MSG-05).
+ *
+ * When an idempotency key is present and conflicts with an existing
+ * message, the insert is skipped (ON CONFLICT DO NOTHING), the channel's
+ * sequence is left untouched, and the ORIGINAL message is returned with
+ * `duplicate: true` — FR-MSG-04's "201-equivalent semantics".
*/
async sendMessage(
channelId: string,
{
userId,
text,
metadata,
- }: { userId?: string; text: string; metadata?: unknown },
+ idempotencyKey,
+ }: {
+ userId?: string;
+ text: string;
+ metadata?: unknown;
+ idempotencyKey?: string;
+ },
): Promise<MessageRow> {
return this.db.transaction(async (tx) => {
const [channel] = await tx
.select({ id: channels.id, lastSequence: channels.lastSequence })
.from(channels)
.where(
and(
eq(channels.id, channelId),
eq(channels.environmentId, this.environmentId),
),
)
.for("update");
if (!channel) throw new ChannelNotFoundError(channelId);
const seq = channel.lastSequence + 1;
const id = randomUUID();
+
- await tx
- .update(channels)
- .set({ lastSequence: seq })
- .where(eq(channels.id, channel.id));
- await tx.insert(messages).values({
+ const insert = tx.insert(messages).values({
id,
channelId: channel.id,
sequence: seq,
userId: userId ?? null,
text,
metadata: metadata ?? {},
+ idempotencyKey: idempotencyKey ?? null,
});
+
+ // The conflict clause is attached ONLY when a key is present, and it
+ // names the partial index explicitly. A bare ON CONFLICT DO NOTHING
+ // would absorb every constraint on the table — including DR-01's
+ // UNIQUE (channel_id, sequence), whose loud failure is 2.2's safety
+ // net. A keyless send therefore carries no conflict clause at all.
+ const inserted = await (
+ idempotencyKey
+ ? insert.onConflictDoNothing({
+ target: [messages.channelId, messages.idempotencyKey],
+ where: sql`${messages.idempotencyKey} IS NOT NULL`,
+ })
+ : insert
+ ).returning({ id: messages.id, createdAt: messages.createdAt });
+
+ if (inserted.length === 0) {
+ // The key has been here before. Return the ORIGINAL message — the
+ // retry gets the same answer the lost ack carried (FR-MSG-04) —
+ // and leave last_sequence alone: a recognised duplicate wrote
+ // nothing, so it consumes nothing.
+ return {
+ ...(await this.getMessageByIdempotencyKey(
+ tx,
+ channel.id,
+ idempotencyKey!,
+ )),
+ duplicate: true,
+ };
+ }
+
+ // The sequence is spent only by a message that actually landed.
+ await tx
+ .update(channels)
+ .set({ lastSequence: seq })
+ .where(eq(channels.id, channel.id));
+
return {
id,
channel_id: channel.id,
seq,
text,
- created_at: new Date().toISOString(),
+ created_at: toIso(inserted[0]!.createdAt),
};
});
}
+
+ /** Fetch a message by its idempotency key within a channel — the
+ * recovery leg of 2.3's duplicate-recognised path. The channel join
+ * carries the tenant scope: every query in this layer answers only for
+ * its own environment, private helpers included (constitution I). */
+ private async getMessageByIdempotencyKey(
+ tx: Db,
+ channelId: string,
+ idempotencyKey: string,
+ ): Promise<MessageRow> {
+ const [row] = await tx
+ .select({
+ id: messages.id,
+ channel_id: messages.channelId,
+ seq: messages.sequence,
+ text: messages.text,
+ created_at: messages.createdAt,
+ })
+ .from(messages)
+ .innerJoin(channels, eq(channels.id, messages.channelId))
+ .where(
+ and(
+ eq(messages.channelId, channelId),
+ eq(messages.idempotencyKey, idempotencyKey),
+ eq(channels.environmentId, this.environmentId),
+ ),
+ );
+ // The row MUST exist: this method is only reached when the insert
+ // conflicted on the idempotency index, so the key is already there.
+ if (!row) {
+ throw new Error(
+ `idempotency key ${idempotencyKey} conflicted but its message is missing — index inconsistency`,
+ );
+ }
+ return { ...row, created_at: toIso(row.created_at) };
+ }
+
+ /** Every message in the channel, ordered by sequence — tenant-scoped
+ * like everything else here. DECISION (chapter 2.3): this exists for
+ * the idempotency suite's row counts; 2.4 replaces it with the real
+ * paginated read, and this method retires with that chapter. */
+ async listMessagesRaw(
+ channelId: string,
+ ): Promise<{ id: string; text: string | null; seq: number }[]> {
+ return this.db
+ .select({
+ id: messages.id,
+ text: messages.text,
+ seq: messages.sequence,
+ })
+ .from(messages)
+ .innerJoin(channels, eq(channels.id, messages.channelId))
+ .where(
+ and(
+ eq(messages.channelId, channelId),
+ eq(channels.environmentId, this.environmentId),
+ ),
+ )
+ .orderBy(asc(messages.sequence));
+ }
}Hai chi tiết trong diff ấy không phải trang trí, và cả hai đều được học theo cách khó — viết version hiển nhiên trước rồi nhìn nó làm gì.
Conflict clause được attach có điều kiện. Đọc lại ternary: khi không có
key, insert hoàn toàn không mang clause ON CONFLICT nào. Shape dễ bị cám
dỗ là .onConflictDoNothing(key ? {...} : undefined) — một call site, gọn.
Nhưng truyền undefined không có nghĩa là "không có clause"; nó emit một
bare ON CONFLICT DO NOTHING, và bare clause hấp thụ mọi constraint
trên table. Bao gồm UNIQUE (channel_id, sequence) của DR-01 — chính
constraint đã khiến race của 2.2 visible. Ta đã ship version đó, rồi ép một
sequence collision trên một keyless send để xem nó làm gì:
Error: idempotency key undefined conflicted but its message is missing …Message bị drop im lặng, và failure nổi lên thành một internal error về một key mà caller chưa từng gửi. Idempotency đã âm thầm tháo chốt safety net đúng một chương sau khi ta lắp nó. Vì vậy có guard — và vì vậy guard ấy có test riêng, nằm trong suite của 2.1 nơi sequence behavior sinh sống:
import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { sql } from "drizzle-orm";
import { createDb, createPool, DEFAULT_DATABASE_URL, type Db } from "./client";
import { migrate } from "./migrate";
import { createEnvironment, Repository, type Environment } from "./repository";
// The isolation suite: attack the repository with FOREIGN tenant ids and
// prove the leak inexpressible (FR-TEN-05, NFR-SEC-09, constitution I).
// Requires the compose Postgres — this file is *.itest.ts precisely so the
// Docker-free unit lane never collects it.
// Guardrail: integration tests run against the LOCAL compose stack only.
const url = new URL(process.env.DATABASE_URL ?? DEFAULT_DATABASE_URL);
if (!["localhost", "127.0.0.1"].includes(url.hostname)) {
throw new Error(
`integration tests refuse non-local databases (got host "${url.hostname}") — never point this suite at a shared database`,
);
}
const pool = createPool();
const db: Db = createDb(pool);
let envA: Environment;
let envB: Environment;
let repoA: Repository;
let repoB: Repository;
beforeAll(async () => {
await migrate(pool);
// Deterministic ground WITHOUT a truncate: this suite mints its own
// environments, and 2.1 proved no other environment's rows are visible
// through them. Isolation buys parallel-safe test files for free — see
// the note below.
envA = await createEnvironment(db, { name: "tenant-a" });
envB = await createEnvironment(db, { name: "tenant-b" });
repoA = new Repository(db, envA.id);
repoB = new Repository(db, envB.id);
});
afterAll(async () => {
await pool.end();
});
describe("tenant isolation is structural (FR-TEN-05)", () => {
it("a foreign external_id resolves to nothing — not even an existence hint", async () => {
await repoA.createUser("tuan", "Tuan");
expect(await repoA.getUserByExternalId("tuan")).not.toBeNull();
expect(await repoB.getUserByExternalId("tuan")).toBeNull();
});
it("channel reads and lists are scoped by construction", async () => {
await repoA.createChannel("support", "public", "Support");
expect(await repoB.getChannelByExternalId("support")).toBeNull();
expect(await repoB.listChannels()).toEqual([]);
expect((await repoA.listChannels()).map((c) => c.external_id)).toContain(
"support",
);
});
it("membership writes with foreign ids affect zero rows", async () => {
const user = await repoA.getUserByExternalId("tuan");
const channel = await repoA.getChannelByExternalId("support");
expect(await repoA.addMember(channel!.id, user!.id)).toBe(true);
// B holds A's REAL ids — and still cannot write or read through them.
expect(await repoB.addMember(channel!.id, user!.id)).toBe(false);
expect(await repoB.listMembers(channel!.id)).toEqual([]);
expect(await repoB.channelsForUser(user!.id)).toEqual([]);
expect(await repoA.listMembers(channel!.id)).toEqual([user!.id]);
});
it("uniqueness is per-tenant (DR-02): both tenants may own the same external_id", async () => {
await expect(
repoB.createUser("tuan", "A different Tuan"),
).resolves.toBeTruthy();
await expect(repoA.createUser("tuan", "Duplicate in A")).rejects.toThrow();
});
});
describe("sequence assignment is serialised per channel (ADR-03)", () => {
it("two concurrent sends never interleave", async () => {
const channel = await repoA.createChannel("ordering", "public");
const [a, b] = await Promise.all([
repoA.sendMessage(channel.id, { text: "first writer" }),
repoA.sendMessage(channel.id, { text: "second writer" }),
]);
// Two sends, two DISTINCT consecutive sequence numbers — always.
expect(new Set([a.seq, b.seq]).size).toBe(2);
expect(Math.abs(a.seq - b.seq)).toBe(1);
});
});
+
+describe("idempotency must not disarm DR-01 (chapter 2.3)", () => {
+ it("a keyless send still fails loudly on a sequence collision", async () => {
+ const channel = await repoA.createChannel("dr01-guard", "public");
+ await repoA.sendMessage(channel.id, { text: "first" });
+ // Rewind the counter so the next keyless send reuses seq 1. The
+ // conflict clause must NOT swallow this: DR-01's unique constraint is
+ // 2.2's safety net, and idempotency has no business disarming it.
+ await db.execute(
+ sql`UPDATE channels SET last_sequence = 0 WHERE id = ${channel.id}`,
+ );
+ await expect(
+ repoA.sendMessage(channel.id, { text: "collides" }),
+ ).rejects.toThrow();
+ // And nothing landed: the failed insert wrote no row.
+ const rows = await repoA.listMessagesRaw(channel.id);
+ expect(rows).toHaveLength(1);
+ });
+});Sequence chỉ được tiêu bởi một message thật sự landed. UPDATE channels
được chuyển xuống dưới insert. Trong draft đầu tiên nó chạy trước, nên một
recognised duplicate vẫn advance last_sequence — ba retry của một message để
counter ở 3 trong khi table chỉ có một row. FR-MSG-02 tolerate gaps, nên không
có gì broken; nhưng một retry không viết gì thì không nên consume gì, và một
counter trôi khỏi thực tế vô cớ là một lời nói dối nhỏ mà cuối cùng bạn sẽ
phải giải thích cho ai đó đang debug lúc 3 giờ sáng. Suite pin điều này: sau
ba keyed retries, message tiếp theo là seq 2, không phải seq 4.
Có một subtlety đáng nói thẳng: conflict leg vẫn chạy bên trong row-lock
transaction từ 2.2. Nghĩa là một retry thua race trước chính original của nó
(hai retries cùng in flight một lúc — chuyện đó xảy ra) vẫn serialise như mọi
cặp sends khác, và đúng một trong chúng insert. Lock order; index deduplicate;
không cái nào làm việc của cái kia. Service layer translate duplicate: true
thành "201-equivalent semantics" của FR-MSG-04 ở HTTP boundary: flag
duplicate bị strip, và MessageRow original được trả về — default status
của @Post() trong NestJS là 201, nên retry nhận 201 với đúng body mà
lost ack đã mang. Không special status code, không conditional header —
single success path của client cứ thế hoạt động.
Chạy thử
Dựng journey bằng tay — cùng channel, cùng text, cùng key, hai lần:
KEY=$(uuidgen)
curl -s -X POST localhost:4000/v1/channels/$CHANNEL/messages \
-H "X-Relay-Environment: $ENV" -H "content-type: application/json" \
-d "{\"text\":\"B2, north ramp\",\"idempotency_key\":\"$KEY\"}"
# → seq 42. Giờ là "retry" — request y hệt:
curl -s -X POST localhost:4000/v1/channels/$CHANNEL/messages \
-H "X-Relay-Environment: $ENV" -H "content-type: application/json" \
-d "{\"text\":\"B2, north ramp\",\"idempotency_key\":\"$KEY\"}"
# → seq 42 lần nữa — CÙNG message, recognised, không được tạo lạiNếu bạn muốn thấy chính xác database đã được bảo gì, hãy hỏi query builder thay vì đoán — inference clause là toàn bộ mánh ở đây:
on conflict ("channel_id","idempotency_key")
where "messages"."idempotency_key" IS NOT NULL
do nothingWHERE đó nằm ở vị trí inference, trước DO NOTHING. Đó là cách Postgres
biết bạn đang nói tới index nào, và là lý do clause match messages_idem —
partial index — thay vì không match gì cả.
Idempotency suite có năm case: staged duplicate, recognised retry, năm concurrent sends dưới một key, no-burn sequence check, và per-channel key namespace. Với DR-01 guard được thêm vào suite của 2.1, integration lane đứng ở mười bốn test trên ba file; Docker-free gate vẫn nguyên ở bốn mươi.
sequenceDiagram
participant T as client của Tuan
participant A as API service
participant P as PostgreSQL
Note over T,A: CÓ key k1, mint ở send time
T->>A: POST {text, idempotency_key: k1}
A->>P: INSERT ON CONFLICT (channel, key) DO NOTHING → row (seq 42)
A--xT: 201 — mất cùng tín hiệu
T->>A: retry: POST {text, idempotency_key: k1}
A->>P: INSERT ON CONFLICT DO NOTHING → zero rows
A->>P: SELECT original theo (channel, k1)
A-->>T: 201-equivalent {seq 42, duplicate recognised}
Note over P: mãi mãi một row (DR-03)Đến lượt bạn
Bài tập chính là build: đưa key đi qua schema, service, repository, và cả hai bộ test. Rồi tấn công guarantee:
- Chạy lại staged duplicate với key được attach và nhìn response thứ hai trả về seq original. Rồi chạy no-key version thêm một lần và cảm nhận failure im lặng đến mức nào — không error, không ở đâu, không bao giờ.
- Đổi conditional về lại
.onConflictDoNothing(key ? {…} : undefined), rồi chạy DR-01 guard trong suite của 2.1. Nhìn một sequence collision bị nuốt và nổi lại thành internal error về một key không ai gửi. Đặt ternary trở lại bên ngoài call. - Chuyển
UPDATE channelslên trên insert một lần nữa và chạy no-burn test. Ba retries, counter ở 3, một row — một gap bạn tự tạo ra chẳng vì gì. - Send cùng key tới hai channel khác nhau. Hai rows — đúng: namespace của key là channel (index của DR-03 nói vậy). Viết ra vì sao global key namespace sẽ sai với một multi-tenant system trước khi đọc câu trả lời trong deep dive.
Nếu mắc kẹt, tag giữ answer key: part2-ch3.
Những điều đọng lại
Nếu bạn không đọc gì khác trong chương này, hãy giữ lại những điều sau:
- Một lost ack khiến cả hai phỏng đoán của client đều sai — idempotency keys làm retry an toàn thay vì làm client thông minh hơn (FR-MSG-04).
- Key được mint trước failure (FR-SDK-06): key generate ở retry time chỉ là bug đội lốt.
- Enforcement sống ở storage layer (DR-03, constitution II): memory chết cùng process và bị chia theo instance; index sống sót qua cả hai và bind cả những code hay quên.
- Retry trả về original — 201-equivalent semantics nghĩa là câu trả lời bị mất được deliver lại, không phải được kiếm lại.
- Lock và index làm hai việc khác nhau: lock order các send; index collapse những lần lặp lại của một send. 2.2 + 2.3 cùng nhau là hai clause đầu của constitution II ở dạng executable.