Building Relay

Part 3 · Chapter 3.5

The outbox

You will produce: Transactional outbox + relay (ADR-06); the crash-in-the-gap test · about 90 minutes including the exercise

Source: SAD — Software Architecture Document

Nền tảng giờ nói được ai đang hỏi và họ thuộc tenant nào. Thứ nó chưa làm được là báo cho ai đó khác rằng có chuyện đã xảy ra.

Webhook cần điều đó. Phân tích cũng vậy, dashboard trực tiếp cũng vậy, và — khắt khe nhất — việc đo đếm, thứ mà FR-ANL-06 đòi phải khớp với dữ liệu vận hành tới mức 0.1%. Tất cả chúng đều là bên tiêu thụ của một thứ mà nền tảng chưa tạo ra: một event cho mỗi thay đổi trạng thái.

Cách hiển nhiên để tạo ra một cái thì dài hai dòng, và nó sai. Không phải sai một cách tinh vi, không phải sai-ở-quy-mô-lớn — mà sai theo kiểu đánh mất dữ liệu trong im lặng, vào một ngày đẹp trời, trên một cỗ máy khoẻ mạnh. Chương này mở đầu bằng cách làm nó đánh mất một event ngay trước mặt bạn.

Phiên bản hai dòng, và cái cửa sổ bên trong nó

Đây là cả cái bug, viết theo đúng cách mà ai cũng sẽ viết trước tiên:

the naive version (excerpt)
const message = await repo.sendMessage(channelId, body);   // commits
await publisher.publish(eventFor(message));                // then publishes

Chẳng có gì cẩu thả ở hai dòng đó. Chúng chạy đúng thứ tự — commit trước, nên chẳng event nào mô tả một message đã roll back. Trong bất kỳ codebase thật nào cũng có phần xử lý lỗi quanh chúng. Chúng chạy được.

Chúng chạy được gần như mọi lúc, và đó chính là vấn đề.

sequenceDiagram
    participant C as Caller
    participant A as API service
    participant PG as PostgreSQL
    participant B as Broker
    C->>A: POST /v1/channels/:id/messages
    A->>PG: BEGIN · insert message · COMMIT
    PG-->>A: committed
    Note over A,B: THE GAP — message đã tồn tại,<br/>event thì chưa, và chưa có gì<br/>báo là sai
    A--xB: publish event.msg.created
    Note over A,PG: process chết ở đây.<br/>Không error. Không retry. Không record rằng<br/>đã từng nợ một event
Bài toán ghi kép. Giữa lệnh commit và lệnh publish có một cửa sổ mà trong đó message tồn tại còn event thì không — và một tiến trình chết ở đó chẳng để lại gì để nói rằng đã từng có một event bị nợ.

Hãy chạy lần đi bộ của chương ở chế độ đó rồi giết nó ngay trong khoảng trống. Đây không phải một thí nghiệm tưởng tượng: script in ra một dấu hiệu giữa lệnh commit và lệnh publish, và một tiến trình cha gửi cho nó SIGKILL ngay khoảnh khắc dấu hiệu ấy xuất hiện.

$ naive, killed in the gap
environment                0e9d99cb-e8a2-46f5-9e53-3a2729e8ed38
mode                       naive
messages committed         1
outbox rows                0
MARKER kill-me-now
  [process killed with SIGKILL at the marker]

Giờ hãy nhìn xem trong database có gì:

naive  env 0e9d99cb: messages=1 outbox=0

Một message. Không event nào. Và chẳng có lỗi ở đâu cả — chẳng exception nào được ném, chẳng hàng đợi thử lại nào có một mục, chẳng dòng log nào nói rằng có gì đó sai. Webhook của khách hàng sẽ chẳng bao giờ bắn cho message đó. Bộ đo sẽ lệch một, mãi mãi, và lần đối soát hằng ngày của FR-ANL-06 sẽ dựng lên một cảnh báo mà chẳng ai giải thích nổi bởi chẳng còn gì để mà giải thích nó.

Đó là thứ làm cho kiểu thất bại này khác với những kiểu mà Phần 2 xử lý. Một frame WebSocket bị rơi thì phục hồi được — chương 2.7 phục hồi nó từ Postgres, bởi Postgres vẫn còn biết. Ở đây thì Postgres cũng chẳng biết. Bằng chứng đã chết cùng tiến trình.

Lựa chọn thứ tư tốn gì

ADR-06 cân nhắc bốn thiết kế, và đáng để xem cái bị bác bỏ vì lý do vận hành chứ không phải vì tính đúng đắn, bởi đó là loại bác bỏ mà loạt bài này đang cố dạy.

Change-data-capture — bám theo write-ahead log của Postgres bằng một thứ như Debezium rồi suy ra event từ đó — về mặt kiến trúc là câu trả lời đúng đắn nhất có sẵn. WAL vốn đã là một nhật ký event có tính transaction; suy ra event từ nó nghĩa là event và thay đổi trạng thái không thể nào bất đồng, bởi chúng là cùng những byte.

Nó vẫn bị bác bỏ. Debezium nghĩa là phải chạy Kafka Connect hoặc một thứ tương đương, ánh xạ schema thành event trong cấu hình, và ràng buộc vào một định dạng WAL vốn không phải một API công khai. Đó là một hệ thống con vận hành lớn hơn cả bài toán nó giải, do một người vận hành (động cơ D8).

Outbox thì khoảng năm mươi dòng: một INSERT bên trong một transaction, và một vòng lặp làm SELECT … FOR UPDATE SKIP LOCKED, publish, rồi đánh dấu. Sự bất đối xứng đó — năm mươi dòng so với một hệ thống con — chính là toàn bộ lập luận, và ADR-06 gọi tên cái mức lưu lượng mà ở đó nó thôi đứng vững (~50k event/s, tại điểm đó thì đội ngũ đã đủ lớn để vận hành Debezium).

Cái bảng, được trích chứ không được bịa

Lần đầu tiên trong Phần 3, schema không phải một phép suy dẫn. SAD §6.1 định nghĩa bảng này thẳng thừng, nên chương có thể trích nó:

services/api/src/db/schema.ts
@@ -1,8 +1,9 @@
 import { sql } from "drizzle-orm";
 import {
+  bigserial,
   bigint,
   check,
   index,
   integer,
   jsonb,
   pgTable,
@@ -17,15 +18,15 @@ import {
 // The TS twin of SAD §6.1 (ADR-16). The schema now exists twice — once as
 // the SAD's SQL truth, once here — and that drift risk is checked, not
 // assumed away: drizzle-kit GENERATES the migration SQL from these
 // definitions, and the generated SQL is reviewed against §6.1 before the
 // runner applies it. The four tenant-bearing tables reproduce §6.1
 // column-for-column, constraints and DR citations included. Deliberately
-// absent, with named arrivals: message_edits (edit chapter), outbox
-// (ADR-06's chapter), emoji/media tables (their parts), messages
-// partitioning (SAD growth note -> retention chapter).
+// absent, with named arrivals: message_edits (edit chapter), emoji/media
+// tables (their parts), messages partitioning (SAD growth note -> retention
+// chapter). The outbox arrives with the chapter of that name and is at the bottom of this file.
 
 // The tenancy hierarchy. Everything from here to `members`
 // below sits ABOVE the environment boundary: these rows say who owns a
 // platform account, and they are the only tables in this file without an
 // environment_id. Everything below the boundary carries one and is scoped by
 // the repository (constitution I).
@@ -278,6 +279,49 @@ export const members = pgTable(
   (t) => [
     primaryKey({ columns: [t.channelId, t.userId] }),
     // Hot-path index (SAD §6.3): the resume path's "which channels am I in".
     index("members_user_channel").on(t.userId, t.channelId),
   ],
 );
+
+// The outbox (ADR-06). For the first time in Part 3 this table is
+// QUOTED rather than derived: SAD §6.1 defines it column-for-column, so nothing
+// about its shape is a chapter invention.
+//
+// Three absences are deliberate and worth knowing about.
+//
+// No environment_id. Every other table below the tenant boundary carries one
+// (FR-TEN-06); this one does not, because an outbox row is not tenant data — it
+// is work the platform owes itself. The environment travels inside `subject`
+// and `payload`, so a consumer can filter, but nothing reads this table on a
+// tenant's behalf. Same family of exception as the credentials chapter's unscoped key lookup, and
+// recorded for the same reason.
+//
+// No status column. `published_at IS NULL` is the queue: a row is pending or it
+// is done, and there is no third state to get stuck in.
+//
+// No attempts or last_error. Retry accounting belongs to webhook delivery
+// (FR-WHK-03/06). This relay retries by not marking a row done.
+export const outbox = pgTable(
+  "outbox",
+  {
+    id: bigserial("id", { mode: "number" }).primaryKey(),
+    subject: text("subject").notNull(),
+    payload: jsonb("payload").notNull(),
+    createdAt: timestamp("created_at", { withTimezone: true })
+      .notNull()
+      .defaultNow(),
+    publishedAt: timestamp("published_at", { withTimezone: true }),
+  },
+  (t) => [
+    // DECISION: SAD §6.1 defines this table but no index for it.
+    // The relay's only query is "the oldest rows with published_at IS NULL",
+    // and without an index that degrades into a full scan over a table which is
+    // 99.9% published rows. The predicate is PARTIAL on purpose: the index
+    // covers only what the relay reads, so published rows cost nothing to keep
+    // and pruning stays optional rather than urgent (ADR-06 calls pruning
+    // trivial; it still needs a scheduler this platform does not have).
+    index("outbox_unpublished")
+      .on(t.createdAt)
+      .where(sql`${t.publishedAt} IS NULL`),
+  ],
+);

Ba sự vắng mặt đáng được gọi tên, bởi mỗi cái là một thứ bạn có thể trông đợi.

Không có cột trạng thái. published_at IS NULL chính là cái hàng đợi. Một dòng hoặc đang chờ hoặc đã xong; chẳng có trạng thái thứ ba nào để một dòng kẹt lại, và chẳng có enum nào để thêm một giá trị thứ tư vào năm sau.

Không có bộ đếm lần thử, không có lỗi cuối, không có bảng dead-letter. Đó là phần kế toán các lần thử lại, nó thuộc về việc giao webhook (FR-WHK-03, FR-WHK-06), và nó là của chương webhook dispatcher. Relay này thử lại bằng cách không đánh dấu một dòng là xong, thứ đó chẳng cần cột nào cả.

Thứ duy nhất mà §6.1 không định nghĩa là một index, nên phần đó là một quyết định của chương và nó nói thế trong schema. Nó là index bộ phận — nó chỉ phủ các dòng mà relay đọc — và đó là thứ làm cho việc giữ lại các dòng đã publish chẳng tốn gì.

services/api/migrations/0004_outbox.sql
-- The transactional outbox (ADR-06).
--
-- REVIEW DISPOSITION: drizzle-kit generated this file from schema.ts and it was
-- read line by line before being applied (the ADR-16 workflow). Nothing was
-- rewritten, and this time there is a stronger reason than "no existing rows":
-- SAD §6.1 DEFINES this table, so the generated SQL was compared against the
-- document rather than only against the TypeScript. It matches column for
-- column — id BIGSERIAL PRIMARY KEY, subject TEXT NOT NULL, payload JSONB NOT
-- NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), published_at TIMESTAMPTZ.
--
-- The INDEX is the one thing §6.1 does not define, and it is a chapter
-- derivation (see the DECISION in schema.ts). It is PARTIAL: it covers only
-- rows the relay actually reads — `published_at IS NULL` — so a table that is
-- almost entirely published rows costs almost nothing to index, and pruning
-- stays an option rather than an obligation.
--
-- Deliberately absent: any status enum, attempts counter or last_error column.
-- `published_at IS NULL` is the whole queue, and retry accounting belongs to
-- webhook delivery in the webhook dispatcher chapter.
 
CREATE TABLE "outbox" (
	"id" bigserial PRIMARY KEY NOT NULL,
	"subject" text NOT NULL,
	"payload" jsonb NOT NULL,
	"created_at" timestamp with time zone DEFAULT now() NOT NULL,
	"published_at" timestamp with time zone
);
--> statement-breakpoint
CREATE INDEX "outbox_unpublished" ON "outbox" USING btree ("created_at") WHERE "outbox"."published_at" IS NULL;

Event commit cùng với message

Đây là thay đổi sửa được cái bug. Nó là một INSERT, và toàn bộ sức mạnh của nó đến từ việc nó nằm ở phía nào của một lệnh COMMIT.

services/api/src/db/repository.ts
@@ -10,14 +10,16 @@ import {
   environments,
   humans,
   members,
   memberships,
   messages,
   organisations,
+  outbox,
   users,
 } from "./schema";
+import { messageCreatedEvent } from "../outbox/event";
 import {
   mintApiKey,
   parseApiKeyCredential,
   prefixMatchesKind,
   secretMatches,
   type EnvironmentKind,
@@ -215,12 +217,93 @@ export async function environmentSigningSecret(
     })
     .from(environments)
     .where(eq(environments.id, environmentId));
   return row ?? null;
 }
 
+// ---------------------------------------------------------------------------
+// The outbox drain (ADR-06). Part of the ADMIN surface for the
+// same reason the credential lookup is: it runs on behalf of the platform
+// rather than of a tenant, and it is deliberately NOT scoped by environment —
+// one relay drains every environment's events, because an outbox row is work
+// the platform owes itself.
+//
+// The SQL lives here rather than in the relay module because the query engine
+// lives inside this layer and nowhere else (constitution I, ADR-16). The relay
+// supplies WHAT to do with a row; this supplies HOW rows are claimed and
+// retired.
+// ---------------------------------------------------------------------------
+
+export interface OutboxRow {
+  id: number;
+  subject: string;
+  payload: unknown;
+}
+
+/** Claim up to `limit` unpublished rows, hand each to `publish`, and mark the
+ * ones that succeeded — all inside ONE transaction.
+ *
+ * `FOR UPDATE SKIP LOCKED` is what makes a second relay safe: competing
+ * drainers skip each other's claimed rows instead of blocking on them, so
+ * horizontal scaling is a property of this query rather than of a coordination
+ * mechanism nobody wants to operate (ADR-06).
+ *
+ * PUBLISH THEN MARK, never the reverse. A crash between the two republishes on
+ * restart, which is at-least-once and is the accepted cost. Marking first would
+ * make it at-most-once and reintroduce exactly the loss this chapter removes
+ * (research R3).
+ *
+ * A publisher that throws aborts the batch: rows already published in this
+ * batch are marked, the failing row and everything after it stay pending, and
+ * the next pass tries again from there.
+ */
+export async function drainOutbox(
+  db: Db,
+  limit: number,
+  publish: (row: OutboxRow) => Promise<void>,
+): Promise<number> {
+  return db.transaction(async (tx) => {
+    const claimed = (await tx.execute(
+      sql`SELECT id, subject, payload
+            FROM outbox
+           WHERE published_at IS NULL
+           ORDER BY created_at, id
+           LIMIT ${limit}
+             FOR UPDATE SKIP LOCKED`,
+    )) as unknown as { rows: OutboxRow[] };
+
+    const published: number[] = [];
+    try {
+      for (const row of claimed.rows) {
+        await publish(row);
+        published.push(row.id);
+      }
+    } finally {
+      // In the `finally` on purpose: whatever went wrong with row N+1, rows 1..N
+      // really did reach the broker and must not be sent a second time by this
+      // instance's next pass.
+      if (published.length > 0) {
+        await tx.execute(
+          sql`UPDATE outbox SET published_at = now()
+               WHERE id = ANY(${sql.raw(`ARRAY[${published.join(",")}]::bigint[]`)})`,
+        );
+      }
+    }
+    return published.length;
+  });
+}
+
+/** How far behind the relay is. The single number worth alarming on later, and
+ * the one the chapter shows going up while the broker is down. */
+export async function outboxDepth(db: Db): Promise<number> {
+  const result = (await db.execute(
+    sql`SELECT count(*)::int AS pending FROM outbox WHERE published_at IS NULL`,
+  )) as unknown as { rows: { pending: number }[] };
+  return result.rows[0]?.pending ?? 0;
+}
+
 /** What a signup produced — or found. `created` answers "was an organisation
  * created on this call?", NOT "was the identity new": a known human who owned
  * nothing gets `created: true`, because one really was created for them. */
 export interface Provisioned {
   organisation: { id: string; name: string };
   application: { id: string; name: string };
@@ -596,17 +679,24 @@ export class Repository {
    * `duplicate: true` — FR-MSG-04's "201-equivalent semantics".
    */
   async sendMessage(
     channelId: string,
     {
       userId,
+      userExternalId,
       text,
       metadata,
       idempotencyKey,
     }: {
       userId?: string;
+      /** The sender as a CONSUMER will see them. Threaded from the
+       * caller rather than looked up here — the internal route already holds it
+       * (it is the token's subject), and an extra SELECT inside the write
+       * transaction is a cost every message would pay forever. Absent on the
+       * public REST route, where a key-authenticated send is unattributed. */
+      userExternalId?: string;
       text: string;
       metadata?: unknown;
       idempotencyKey?: string;
     },
   ): Promise<MessageRow> {
     return this.db.transaction(async (tx) => {
@@ -666,18 +756,52 @@ export class Repository {
       // The sequence is spent only by a message that actually landed.
       await tx
         .update(channels)
         .set({ lastSequence: seq })
         .where(eq(channels.id, channel.id));
 
+      const createdAt = toIso(inserted[0]!.createdAt);
+
+      // THE EVENT COMMITS WITH THE MESSAGE (ADR-06).
+      //
+      // This insert is inside the transaction that already guards the write, so
+      // the two share a fate: no message without its event, no event without
+      // its message. Publishing after the commit instead would leave a gap —
+      // crash in it and the message exists while the event never did, silently,
+      // with nothing to reconcile against.
+      //
+      // It sits on the INSERTED branch only. A recognised idempotent retry
+      // returned above without writing anything and must consume no event
+      // either, or a client retrying on a flaky link fires a second webhook for
+      // one message (FR-MSG-04, research R1).
+      //
+      // The envelope is built complete here and never touched again: the relay
+      // moves bytes, it does not author them (ADR-04).
+      const event = messageCreatedEvent({
+        eventId: randomUUID(),
+        environmentId: this.environmentId,
+        message: {
+          id,
+          channel_id: channel.id,
+          seq,
+          user: userExternalId ?? null,
+          text,
+          created_at: createdAt,
+        },
+      });
+      await tx.insert(outbox).values({
+        subject: event.subject,
+        payload: event.payload,
+      });
+
       return {
         id,
         channel_id: channel.id,
         seq,
         text,
-        created_at: toIso(inserted[0]!.createdAt),
+        created_at: createdAt,
       };
     });
   }
 
   /** Fetch a message by its idempotency key within a channel — the
    * recovery leg of 2.3's duplicate-recognised path. The channel join

Hai chi tiết trong đó quan trọng hơn vẻ ngoài của chúng.

Nó chỉ nằm trên nhánh đã chèn. Chương 2.3 dạy đường ghi cách nhận ra một lần thử lại: trình ra cùng một khoá đẳng xâm và bạn nhận lại chính message gốc, còn sequence thì không bị tiêu vì chẳng gì được ghi. Một event ghi trên nhánh đó sẽ nghĩa là một client thử lại trên một đường truyền chập chờn sẽ bắn hai webhook cho một message — đúng cái bản trùng mà 2.3 tồn tại để ngăn, được đưa trở lại ở tầng trên một bậc. Có một bài test cho nó, và nó là cái bất biến mà tôi sẽ giữ nếu chỉ được giữ một.

Cái phong bì được dựng đầy đủ, bên trong transaction. Relay chẳng thêm gì sau đó: không một dấu thời gian, không một id, không một field. Đó không phải sự gọn gàng — nó là thứ làm cho một event được publish lại giống hệt từng byte với lần thử đầu tiên, và đó là thứ làm cho việc chống trùng chạy được ngay từ đầu.

services/api/src/outbox/event.ts
// The event envelope. Built in ONE place, complete, inside the
// transaction that caused it — so the relay is a mover of bytes and never an
// author of them (ADR-04, research R7).
//
// Nothing in this file reads the clock or generates an id. Both arrive from the
// caller, which is what makes a republished event byte-identical to its first
// attempt: the deduplication key a consumer sees after a crash is the same key
// it would have seen without one.
 
/** A message as the PUBLIC api returns it. Consumers are customers: they get
 * external ids and the field names the REST surface uses. `user_id` does not
 * cross this boundary. */
export interface MessageCreatedData {
  id: string;
  channel_id: string;
  seq: number;
  user: string | null;
  text: string | null;
  created_at: string;
}
 
export interface OutboxEvent {
  /** UUID, generated in the transaction. The consumer's deduplication key. */
  id: string;
  /** FR-WHK-02's name for this event, spelled as that requirement spells it. */
  type: "message.created";
  environment_id: string;
  /** When the state change happened — the message's own timestamp, not the
   * moment this object was constructed. */
  occurred_at: string;
  data: MessageCreatedData;
}
 
export interface PendingEvent {
  subject: string;
  payload: OutboxEvent;
}
 
/** `events.msg.created.{environment_id}` — the shape SAD §6.1's own comment
 * gives. The full subject taxonomy for FR-WHK-02's other seven types, and any
 * per-environment sharding, belongs to the broker chapter. */
export function subjectFor(type: string, environmentId: string): string {
  const leaf = type.replace(/^message\./, "msg.");
  return `events.${leaf}.${environmentId}`;
}
 
export function messageCreatedEvent({
  eventId,
  environmentId,
  message,
}: {
  eventId: string;
  environmentId: string;
  message: MessageCreatedData;
}): PendingEvent {
  // Refused rather than defaulted. An event with no deduplication key looks
  // deliverable and cannot be deduplicated, which is worse than no event.
  if (!eventId) throw new Error("an event id is required");
  if (!environmentId) throw new Error("an environment id is required");
 
  return {
    subject: subjectFor("message.created", environmentId),
    payload: {
      id: eventId,
      type: "message.created",
      environment_id: environmentId,
      occurred_at: message.created_at,
      data: message,
    },
  };
}
sequenceDiagram
    participant A as API service
    participant PG as PostgreSQL
    participant R as Relay (trong api)
    participant B as Broker
    A->>PG: BEGIN
    A->>PG: insert message
    A->>PG: insert outbox row (same transaction)
    A->>PG: COMMIT
    Note over PG: message và event chung số phận —<br/>cả hai, hoặc không cái nào
    R->>PG: SELECT … WHERE published_at IS NULL<br/>FOR UPDATE SKIP LOCKED
    R->>B: publish
    B-->>R: ack
    R->>PG: UPDATE outbox SET published_at = now()
    Note over R,B: publish RỒI mark. Crash ở giữa<br/>sẽ republish — at-least-once,<br/>là chi phí được chấp nhận (ADR-06)
Cái outbox. Event commit cùng với message, nên hai thứ chia chung một số phận; một relay riêng dời các dòng sang broker rồi chỉ đánh dấu chúng sau khi broker đã acknowledge.

Giờ vẫn cú giết ấy, ở đúng chỗ ấy, với phiên bản này:

$ outbox, killed in the gap
environment                c0f83026-0e9b-4094-92e4-90ad5c7a7faf
mode                       outbox
messages committed         1
outbox rows waiting        1
MARKER kill-me-now
  [process killed with SIGKILL at the marker]
outbox env c0f83026: messages=1 outbox=1 unpublished=1

Event sống sót qua chính cái tiến trình định publish nó. Chẳng gì mất, chẳng gì cần một người vận hành, và relay chạy kế tiếp sẽ gửi nó.

Relay, và cái thứ tự thực sự quan trọng

Vòng lặp thì nhỏ hơn cả lập luận cho nó:

services/api/src/outbox/relay.ts
import type { Logger } from "@relay/service-kit";
 
import type { Db } from "../db/client";
import { drainOutbox } from "../db/repository";
import { publishPending, type Publisher } from "./publisher";
 
// The relay (ADR-06): the loop that moves committed events to the
// broker. It owns no state of its own — its entire progress is visible in the
// table it drains, which is what makes "promotable to its own deployment" true
// rather than aspirational.
//
// It is NOT on the request path. A write commits its event and returns; whether
// the broker is reachable is this loop's problem and nobody else's (research
// R9). That inversion is the whole point of an outbox.
 
/** Rows per pass. Small enough that a failing publisher costs one short
 * transaction rather than a long one; large enough that a backlog drains in
 * sensible steps. A batch is a transaction, and long transactions hold locks. */
const BATCH_SIZE = 100;
 
/** Wake-ups per second when idle. FR-ANL-04 allows 60 seconds for an event to
 * become queryable and this budget has two orders of magnitude of headroom, so
 * the interval is chosen for tidiness rather than for latency: a poll every
 * 200ms is invisible to Postgres and keeps the demonstration snappy. If this
 * ever needs to be lower, `LISTEN`/`NOTIFY` is the answer — and the poll still
 * has to exist underneath it as the correctness path (research R2). */
const IDLE_INTERVAL_MS = 200;
 
export interface Relay {
  /** Runs until `stop()`. Never rejects: a broker that is down is an expected
   * state, not a crash. */
  start(): void;
  stop(): Promise<void>;
  /** One pass, for tests and for the walk script — the same code path `start`
   * runs, so nothing is proven about a loop that only tests exercise. */
  drainOnce(): Promise<number>;
}
 
export function createRelay({
  db,
  publisher,
  logger,
  batchSize = BATCH_SIZE,
  intervalMs = IDLE_INTERVAL_MS,
}: {
  db: Db;
  publisher: Publisher;
  logger: Logger;
  batchSize?: number;
  intervalMs?: number;
}): Relay {
  let running = false;
  let loop: Promise<void> = Promise.resolve();
 
  async function drainOnce(): Promise<number> {
    return drainOutbox(db, batchSize, async (row) => {
      await publishPending(publisher, {
        subject: row.subject,
        payload: row.payload as { id: string },
      });
    });
  }
 
  async function run(): Promise<void> {
    while (running) {
      try {
        const published = await drainOnce();
        if (published > 0) {
          // Counts and durations, never payloads. A message body in a log line
          // is a tenant's data in an operator's terminal (NFR-SEC-06).
          logger.log("info", "outbox.published", { count: published });
          continue; // straight back for more; a backlog should not wait
        }
      } catch (error) {
        // The broker being unreachable lands here, and it is not an error the
        // relay can do anything about except try again. Rows stay pending,
        // which is exactly the buffering SAD §7 promises.
        logger.log("error", "outbox.drain_failed", { error: String(error) });
      }
      await new Promise((resolve) => setTimeout(resolve, intervalMs));
    }
  }
 
  return {
    start() {
      if (running) return;
      running = true;
      loop = run();
    },
    async stop() {
      running = false;
      await loop;
      await publisher.close();
    },
    drainOnce,
  };
}

FOR UPDATE SKIP LOCKED là dòng đáng nhìn. Hai instance api là bản triển khai thông thường — service là phi trạng thái và chạy nhiều hơn một lần — nên hai relay cùng rút một bảng là trường hợp bình thường, không phải trường hợp biên. SKIP LOCKED làm cho các bên rút cạnh tranh bước vòng qua các dòng mà bên kia đã giành thay vì xếp hàng phía sau chúng, nghĩa là việc mở rộng ngang là một tính chất của câu query này chứ không phải của một cuộc bầu leader mà chẳng ai muốn vận hành.

Dòng còn lại là thứ tự của hai bước cuối: publish, rồi đánh dấu. Làm ngược lại thì một cú crash ở giữa chúng lại đánh mất event, và đó chính là cả cái bug đội một cái mũ khác. Làm theo cách này nghĩa là một cú crash ở giữa chúng sẽ publish lại — dòng chưa bao giờ được đánh dấu, nên lượt kế tiếp gửi nó lần thứ hai.

Đó là ít-nhất-một-lần, và nó là vĩnh viễn. Cách diễn đạt của ADR-06 đáng được mượn: ôm lấy, chứ không giảm nhẹ.

Ít-nhất-một-lần đòi hỏi gì ở mọi người khác

Một bản trùng phải được hấp thụ ở đâu đó, và "đâu đó" chính là bên tiêu thụ. Mọi event đều mang một id sống sót qua việc publish lại, và một bên tiêu thụ lờ nó đi thì hỏng bất kể relay này cẩn thận tới đâu:

the event envelope (excerpt)
{
  "id": "8f14e45f-ceea-4f6a-9b2c-1d2e3f4a5b6c",
  "type": "message.created",
  "environment_id": "3f2a…",
  "occurred_at": "2026-08-08T13:31:09.229Z",
  "data": { "id": "…", "channel_id": "…", "seq": 1, "user": "tuan", "text": "…", "created_at": "…" }
}

Cái id đó được sinh ra trong transaction và được lưu trong dòng, nên một lần publish lại gửi cùng cái khoá ấy chứ không phải một cái mới tinh. Nó cũng được trao cho JetStream làm id chống trùng của message, thứ đó gộp trường hợp phổ biến lại bên trong cửa sổ chống trùng của broker — nhưng cửa sổ ấy là một sự tiện lợi, không phải cái bảo đảm. Bảo đảm chính là cái field.

Và giờ tới thứ dễ làm bạn đau nhất về sau: chương này không hứa thứ tự của các event. Hai relay publish đồng thời có thể xen kẽ. Một batch thất bại giữa chừng thì publish lại từ một điểm sớm hơn. Chẳng gì trong SRS đòi hỏi event có thứ tự, và xây một bảo đảm về thứ tự vào trong một lần rút SKIP LOCKED sẽ nghĩa là từ bỏ chính cái tính chất làm cho nó mở rộng được.

Thứ có thứ tự là các message bên trong một channel, theo data.seq, đúng như FR-MSG-03 và ADR-03 đã hứa từ chương 2.2. Một bên tiêu thụ cần thứ tự thì nên đọc cái field đó. Một bên tiêu thụ suy ra thứ tự từ thời điểm tới nơi thì rồi sẽ sai, và cái ngày nó sai sẽ chẳng trông giống một bug trong bên tiêu thụ.

Vì sao giờ có hai con đường

Chương 2.6 đã dựng sẵn một cách để báo cho các máy khác rằng một message đã xảy ra: Redis pub/sub, một subject cho mỗi channel. Hỏi vì sao chương này không đơn giản là dùng nó thì hợp lý.

flowchart TB
    write["Một message commit<br/>(một write path, ADR-04)"]
    subgraph live["LIVE delivery — chương 2.6"]
      redis["Redis pub/sub<br/>at-most-once, ADR-07"]
      socket["connected sockets<br/>(không ai nghe? không sao)"]
    end
    subgraph durable["DURABLE events — chương này"]
      ob["outbox row<br/>commit cùng message"]
      relay["relay drain nó"]
      js["JetStream<br/>at-least-once, ADR-06/02"]
    end
    write --> redis --> socket
    write --> ob --> relay --> js
    note["Hai paths vì chúng trả lời hai câu hỏi khác nhau.<br/>Một live frame bị drop có thể recover bằng resume (2.7).<br/>Một EVENT bị drop là webhook không bao giờ fire và<br/>meter âm thầm drift (FR-ANL-06)"]
    durable ~~~ note
Hai con đường giao, một cách có chủ đích. Các frame trực tiếp là nhiều-nhất-một-lần bởi một lần resume phục hồi được chúng; các event bền vững là ít-nhất-một-lần bởi chẳng gì khác phục hồi được.

Chúng trả lời hai câu hỏi khác nhau. Redis pub/sub là nhiều-nhất-một-lần theo thiết kế (ADR-07): nếu chẳng gateway nào đăng ký một channel vào đúng khoảnh khắc publish, thì frame biến mất, và thế cũng ổn — client kết nối lại, trình ra một cursor, và phần backfill của chương 2.7 trao cho nó mọi thứ nó bỏ lỡ, từ Postgres. Chi phí của một frame trực tiếp bị rơi thì bị giới hạn bởi một lần resume.

Một event thì chẳng có lần resume nào. Nếu một message.created chẳng bao giờ tới được webhook dispatcher, thì chẳng hệ thống khách hàng nào nhận ra một khoảng trống rồi đi xin nó; webhook đơn giản là chẳng bao giờ bắn còn bộ đo thì lặng lẽ sai. Nên con đường này là ít-nhất-một-lần, bền vững, và chậm hơn — vài giây chứ không phải vài mili giây, vẫn nằm sâu trong ngân sách 60 giây của FR-ANL-04.

Cùng một event, hai con đường, hai bảo đảm, bởi hậu quả của việc mất mát thì không giống nhau trên cả hai.

Broker không được phép quan trọng

Một xương sống event mà kéo cả đường ghi xuống theo thì tệ hơn không có xương sống event nào. Nên relay kết nối lười, api khởi động bất kể broker có với tới được hay không, và một lần publish thất bại thì để các dòng nguyên tại chỗ.

services/api/src/outbox/jetstream.publisher.ts
import {
  connect,
  type JetStreamClient,
  type NatsConnection,
} from "nats";
 
import type { Publisher, PublishedMessage } from "./publisher";
 
// The one adapter that knows what a broker is (ADR-02).
//
// Everything upstream of this file speaks in subjects and payloads; swapping
// JetStream for something else means writing another file like this one and
// changing nothing that produces events. That is the reversibility ADR-06
// claims for the outbox, expressed as a module boundary rather than a promise.
 
export const DEFAULT_NATS_URL = "nats://localhost:4222";
 
/** One stream over `events.>`, file-backed. This is the MINIMUM a publisher
 * needs in order to be provable — publishing into a broker with no stream is
 * fire-and-forget, and the chapter's claim would be false at the last hop.
 *
 * The real design of the subject space — FR-WHK-02's full event-type list,
 * per-environment sharding, retention, replicas — belongs to the broker chapter along
 * with every consumer. */
const STREAM = "EVENTS";
const SUBJECTS = ["events.>"];
 
export function createJetStreamPublisher({
  url = process.env.RELAY_NATS_URL ?? DEFAULT_NATS_URL,
}: { url?: string } = {}): Publisher {
  let connection: NatsConnection | null = null;
  let js: JetStreamClient | null = null;
 
  /** Connection is LAZY and re-attempted. The api must start and accept writes
   * with the broker unreachable — a service that refuses to boot without its
   * event spine has made the spine a dependency of the write path, which is the
   * opposite of what an outbox is for (research R9). */
  async function client(): Promise<JetStreamClient> {
    if (js && connection && !connection.isClosed()) return js;
    const nc = await connect({ servers: url });
    const jsm = await nc.jetstreamManager();
    // Created if absent, left alone if present: two api instances starting
    // together must not fight over it.
    const existing = await jsm.streams
      .info(STREAM)
      .then(() => true)
      .catch(() => false);
    if (!existing) {
      await jsm.streams.add({ name: STREAM, subjects: [...SUBJECTS] });
    }
    connection = nc;
    js = nc.jetstream();
    return js;
  }
 
  return {
    async publish({ subject, id, payload }: PublishedMessage): Promise<void> {
      const stream = await client();
      // `msgID` is the broker's deduplication key, and it is the ENVELOPE's id
      // — so a republish after a crash is recognisable as the same event rather
      // than as a second one. JetStream will collapse it inside its dedupe
      // window; consumers must still deduplicate for the general case, which is
      // why the id is in the payload too (ADR-06's system-wide discipline).
      await stream.publish(subject, new TextEncoder().encode(JSON.stringify(payload)), {
        msgID: id,
      });
    },
    async close(): Promise<void> {
      if (connection && !connection.isClosed()) {
        await connection.drain();
      }
      connection = null;
      js = null;
    },
  };
}

Bảng thất bại ở §7 của SAD vốn đã tuyên bố hành vi này — "broker chết, event tích lại trong Postgres, relay rút cạn khi phục hồi". Những tuyên bố như thế thì đáng được đem chạy:

$ docker compose stop nats
$ node scripts/dual-write-walk.mjs --mode=outbox --messages=3
messages committed         3
outbox rows waiting        3
$ psql -tAc "SELECT count(*) FROM outbox WHERE published_at IS NULL"
3
 
$ docker compose start nats
$ node scripts/dual-write-walk.mjs --mode=outbox --messages=1
events published           4
outbox rows waiting        0

Ba event chờ hết đợt sự cố; một cái thứ tư tới sau đó; cả bốn đều publish mà chẳng ai phải làm gì. Con số mà một người vận hành theo dõi chính là cái count(*) đó — độ sâu outbox — và nó là tín hiệu duy nhất đáng đặt cảnh báo khi chuyện này trở thành việc của ai đó phải vận hành.

Phép kiểm đã từ chối cái bảng này

Chương isolation harness đỏ trước cả khi những thứ này chạy:

the structural check, on the first table added after it (excerpt)
these tables have no path to an environment: outbox. Add environment_id, add a
foreign key to a table that has one, or add it to SPINE in db/catalogue.ts with
a reason.

Đó là harness đang chạy đúng, trên chương đầu tiên thêm một cái bảng kể từ khi nó được dựng. Câu trả lời là lựa chọn thứ ba, và lý do chính là cái mà lời chú thích trong schema đã đưa ra: một dòng outbox không phải bản ghi của một tenant, nó là công việc mà nền tảng tự nợ chính nó. Environment đi bên trong subject và payload, nên một bên tiêu thụ lọc được, và chẳng đường request nào join cái bảng này thay mặt ai cả.

services/api/src/db/catalogue.ts
@@ -49,12 +49,29 @@ const SPINE: ReadonlyArray<readonly [string, string]> = [
   ],
   ["memberships", "joins humans to organisations, above the environment level"],
   [
     "schema_migrations",
     "the migration ledger; it predates tenancy and belongs to the database",
   ],
+  // THE FIRST TABLE THIS CHECK HAS ACTUALLY REFUSED, and it refused it correctly.
+  //
+  // An outbox row is not a tenant's record — it is work the platform owes itself. The
+  // environment travels inside `subject` and `payload`, so a consumer can filter, but
+  // nothing reads this table on a tenant's behalf and no request path joins it.
+  //
+  // THAT ARGUMENT IS ABOUT READS, AND IT DOES NOT COVER RETENTION. The payload is a
+  // full copy of the message, `text` included, and the relay marks rows published
+  // rather than deleting them. So a message deleted from `messages` still has its words
+  // in here, and nothing on this platform removes them. That is a real gap, it is not a
+  // tenancy gap, and it is recorded here rather than argued away — the chapter that owns
+  // per-environment retention owns the fix.
+  [
+    "outbox",
+    "work the platform owes itself rather than a tenant's record; the environment " +
+      "travels in `subject` and `payload` and no read path joins it",
+  ],
 ];
 
 /** The spine, for anyone who needs to state it rather than derive it. */
 export const SPINE_TABLES: readonly string[] = SPINE.map(([t]) => t);
 
 export interface CatalogueRow extends Record<string, unknown> {

Nếu gauntlet được dựng ở cuối phần này thay vì trước nó, thì cái bảng này sẽ được phân loại chín chương sau khi nó được viết, bởi một người đang dựng lại lập luận từ schema. Làm ở đây thì tốn một mục danh sách và một đoạn văn.

Các suite không chạy cạnh nhau được

Chương trước tuần tự hoá các task của turbo, bởi ba trong chín suite api đỏ với một lỗi catalogue của Postgres. Cách sửa đó là cần thiết và nó không đủ:

the same error, from inside one package (excerpt)
duplicate key value violates unique constraint "pg_type_typname_nsp_index"
duplicate key value violates unique constraint "pg_class_relname_nsp_index"

Turbo giờ chạy một gói một lúc, nhưng vitest thì vẫn chạy các file song song bên trong một gói. Mọi suite ở đây đều áp dụng migration trước khi nó bắt đầu, nên vài suite cùng phát CREATE TYPE lên cùng một schema vào cùng một khoảnh khắc.

Chương trước thì không thể tìm ra nó. Với schema đã được áp dụng sẵn, mọi suite đều chẳng tìm ra việc gì để làm và cuộc đua chẳng có cửa sổ nào — chính migration của chương này mới mở ra một cái.

services/api/vitest.integration.config.mts
@@ -5,8 +5,20 @@ import { defineConfig } from "vitest/config";
 // config is what `pnpm --filter @relay/api test:integration` runs against
 // the compose Postgres. (.mts because this package compiles to CommonJS —
 // a .ts config would be loaded as CJS, which vitest refuses.)
 export default defineConfig({
   test: {
     include: ["src/**/*.itest.ts"],
+    // ONE FILE AT A TIME, BECAUSE THEY SHARE ONE DATABASE.
+    //
+    // Every suite here runs migrations before it starts. Vitest runs FILES in parallel
+    // by default, so several of them issue `CREATE TYPE` against the same schema at the
+    // same moment and Postgres answers `duplicate key value violates unique constraint
+    // "pg_type_typname_nsp_index"` — an error about its own catalogue, which reads like
+    // a driver fault and is not one.
+    //
+    // It only bites when a migration is PENDING. With the schema already applied every
+    // suite finds nothing to do and the race has no window, which is why serialising the
+    // turbo tasks was enough until this chapter added a table.
+    fileParallelism: false,
   },
 });
services/gateway/vitest.integration.config.mts
@@ -4,8 +4,20 @@ import { defineConfig } from "vitest/config";
 // established for the api: *.itest.ts is invisible to the Docker-free unit
 // include, and this config is what `pnpm --filter @relay/gateway
 // test:integration` runs against the compose Redis.
 export default defineConfig({
   test: {
     include: ["src/**/*.itest.ts"],
+    // ONE FILE AT A TIME, BECAUSE THEY SHARE ONE DATABASE.
+    //
+    // Every suite here runs migrations before it starts. Vitest runs FILES in parallel
+    // by default, so several of them issue `CREATE TYPE` against the same schema at the
+    // same moment and Postgres answers `duplicate key value violates unique constraint
+    // "pg_type_typname_nsp_index"` — an error about its own catalogue, which reads like
+    // a driver fault and is not one.
+    //
+    // It only bites when a migration is PENDING. With the schema already applied every
+    // suite finds nothing to do and the race has no window, which is why serialising the
+    // turbo tasks was enough until this chapter added a table.
+    fileParallelism: false,
   },
 });

Thứ chương này để lại cho sau, một cách có chủ đích

Phân loại subject, các stream, và mọi consumer — chương kế tiếp. Chương này tạo một stream trên events.> bởi một publisher cần một nơi nào đó để publish và một relay trỏ vào hư không thì chẳng chứng minh được gì. Bảy kiểu event còn lại của FR-WHK-02, việc chia mảnh theo environment, việc lưu giữ, và durable pull consumer đầu tiên là của chương kế, và thiết kế chúng ở đây sẽ là thiết kế chúng hai lần.

Giao webhook, ký, các tầng thử lại và dead-letter — movement VI. Lý do cái bảng này chẳng có cột attempts.

Việc tỉa. ADR-06 gọi việc xoá các dòng đã publish là chuyện vặt, và đúng thế — nhưng nó cần một bộ lập lịch mà nền tảng này chưa có, và bịa ra một cái để xoá các dòng hiện chẳng tốn gì (index là index bộ phận) thì sẽ là làm việc chỉ vì chính nó.

Công việc đối soát của FR-ANL-06. Câu hỏi "làm sao ai đó biết được nếu một event biến mất?" giờ đã có câu trả lời — so số message với số event, cả hai đều nằm trong Postgres — nhưng công việc hỏi nó hằng ngày thì thuộc về đường phân tích ở Phần 4.

Các bài test, và chúng giữ gì

Mười hai bất biến. Chín cái chạy với Postgres, hai cái thuần tuý, và một cái đi nhờ trên bài test crash:

$ pnpm --filter @relay/api test:integration src/outbox/outbox.itest.ts
✓ invariant 1: a committed message leaves exactly one outbox row
✓ invariant 2: a rolled-back write leaves no outbox row
✓ invariant 3: a recognised idempotent retry adds no second event
✓ invariant 4: both doors produce one event each, identical in shape
✓ invariant 7: the relay publishes pending rows, marks them, and does not republish
✓ invariant 8: two concurrent relays publish every row exactly once
✓ invariant 11: a relay log line carries counts, never payloads
✓ invariant 6: publish-after-commit LOSES the event when the process dies in the gap
✓ invariant 5: the outbox SURVIVES the same kill, and invariant 10's id survives with it
✓ invariant 9: the broker can be absent — writes succeed, events accumulate, the backlog drains
✓ a failing publisher leaves the row pending rather than losing it
Tests  11 passed (11)

Bất biến 6 là cái bất thường: một bài test mà việc của nó là chứng minh rằng phiên bản hỏng thì vẫn hỏng. Nếu lần đi bộ ngây thơ mà có bao giờ thôi đánh mất event của nó, thì lập luận của chương này đã lặng lẽ thôi còn đúng, và tôi thà biết chuyện đó từ một bài test đỏ còn hơn từ một người đọc.

services/api/src/outbox/outbox.itest.ts
import "reflect-metadata";
 
import { spawn } from "node:child_process";
import { join } from "node:path";
 
import { createLogger, type Logger } from "@relay/service-kit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
 
import { createDb, createPool, type Db } from "../db/client";
import {
  createEnvironment,
  outboxDepth,
  Repository,
} from "../db/repository";
import { createJetStreamPublisher } from "./jetstream.publisher";
import { createRelay } from "./relay";
import type { Publisher, PublishedMessage } from "./publisher";
 
// The outbox, against the real database. Invariants 1-4, 7-8 and
// 11 live here; the crash cases (5, 6, 10) and the broker outage (9) are added
// below, because both need a process to kill or a container to stop.
//
// The relay is driven by hand — `drainOnce()`, the same code path the loop runs
// — so these assertions are deterministic. The background loop is off in this
// lane (RELAY_OUTBOX_RELAY=off); a loop marking rows published mid-assertion
// would make every test here flaky for no teaching value.
 
const silent: Logger = createLogger("outbox-itest", () => {});
 
/** A destination that is not a broker. Same shape the port promises, so the
 * relay cannot tell the difference — which is invariant 12, exercised here as a
 * side effect of every other test. */
function recordingPublisher(): Publisher & {
  sent: PublishedMessage[];
  failNext: (times: number) => void;
} {
  const sent: PublishedMessage[] = [];
  let failures = 0;
  return {
    sent,
    failNext(times: number) {
      failures = times;
    },
    async publish(message) {
      if (failures > 0) {
        failures -= 1;
        throw new Error("broker unreachable");
      }
      sent.push(message);
    },
    async close() {},
  };
}
 
const unpublishedFor = async (db: Db, environmentId: string) => {
  const rows = (await db.execute(
    `SELECT id, subject, payload FROM outbox
      WHERE published_at IS NULL
        AND payload->>'environment_id' = '${environmentId}'
      ORDER BY id`,
  )) as unknown as { rows: { id: number; subject: string; payload: { id: string; data: { seq: number; user: string | null; text: string } } }[] };
  return rows.rows;
};
 
describe("the outbox", () => {
  let db: Db;
  let env: { id: string };
  let repo: Repository;
  let channelId: string;
  let tuan: { id: string };
 
  beforeAll(async () => {
    db = createDb(createPool());
    env = await createEnvironment(db, { name: `outbox-itest-${Date.now()}` });
    repo = new Repository(db, env.id);
    tuan = await repo.createUser("tuan", "Tuan");
    channelId = (await repo.createChannel("fleet", "public")).id;
    await repo.addMember(channelId, tuan.id);
  }, 60_000);
 
  afterAll(async () => {
    // Leave the table as we found it for this environment, so a rerun starts
    // from zero rather than from the last run's backlog.
    await db.execute(
      `DELETE FROM outbox WHERE payload->>'environment_id' = '${env.id}'`,
    );
  });
 
  it("invariant 1: a committed message leaves exactly one outbox row", async () => {
    const before = await unpublishedFor(db, env.id);
    const message = await repo.sendMessage(channelId, {
      text: "B2, north ramp",
      userId: tuan.id,
      userExternalId: "tuan",
    });
    const after = await unpublishedFor(db, env.id);
    expect(after.length).toBe(before.length + 1);
 
    const row = after.at(-1)!;
    expect(row.subject).toBe(`events.msg.created.${env.id}`);
    expect(row.payload.data.seq).toBe(message.seq);
    expect(row.payload.data.text).toBe("B2, north ramp");
    expect(row.payload.data.user).toBe("tuan");
  });
 
  it("invariant 2: a rolled-back write leaves no outbox row", async () => {
    const before = await unpublishedFor(db, env.id);
    // The transaction fails AFTER the message and its event are written: the
    // channel does not exist in this tenant, so sendMessage throws before it
    // commits. Event and state change share a fate.
    await expect(
      repo.sendMessage("00000000-0000-0000-0000-000000000000", {
        text: "never happened",
        userId: tuan.id,
        userExternalId: "tuan",
      }),
    ).rejects.toThrow();
    expect((await unpublishedFor(db, env.id)).length).toBe(before.length);
  });
 
  it("invariant 3: a recognised idempotent retry adds no second event", async () => {
    // 2.3's conflict path returns the ORIGINAL message and writes nothing. It
    // must consume no event either — otherwise a client retrying on a flaky
    // link fires two webhooks for one message (FR-MSG-04, research R1).
    const before = await unpublishedFor(db, env.id);
    const key = `retry-${Date.now()}`;
    const first = await repo.sendMessage(channelId, {
      text: "sent twice",
      userId: tuan.id,
      userExternalId: "tuan",
      idempotencyKey: key,
    });
    const afterFirst = await unpublishedFor(db, env.id);
    expect(afterFirst.length).toBe(before.length + 1);
 
    const retry = await repo.sendMessage(channelId, {
      text: "sent twice",
      userId: tuan.id,
      userExternalId: "tuan",
      idempotencyKey: key,
    });
    expect(retry.duplicate).toBe(true);
    expect(retry.seq).toBe(first.seq);
    expect((await unpublishedFor(db, env.id)).length).toBe(afterFirst.length);
  });
 
  it("invariant 4: both doors produce one event each, identical in shape", async () => {
    // The public REST route and the socket's internal route reach ONE write
    // path (ADR-04), so this is true by construction — and the test exists to
    // notice the day someone adds a second path.
    const before = await unpublishedFor(db, env.id);
    await repo.sendMessage(channelId, {
      text: "through the socket",
      userId: tuan.id,
      userExternalId: "tuan",
    });
    // The key-authenticated public send is unattributed (the credentials chapter's recorded bound),
    // which is a CONTENT difference, not a shape one.
    await repo.sendMessage(channelId, { text: "through REST" });
    const rows = (await unpublishedFor(db, env.id)).slice(before.length);
    expect(rows.length).toBe(2);
    const shapes = rows.map((r) => Object.keys(r.payload).sort().join(","));
    expect(shapes[0]).toBe(shapes[1]);
    expect(rows[0]!.payload.data.user).toBe("tuan");
    expect(rows[1]!.payload.data.user).toBeNull();
  });
 
  it("invariant 7: the relay publishes pending rows, marks them, and does not republish", async () => {
    const publisher = recordingPublisher();
    const relay = createRelay({ db, publisher, logger: silent });
 
    const pending = await unpublishedFor(db, env.id);
    expect(pending.length).toBeGreaterThan(0);
 
    const published = await drainUntilClear(relay, db, env.id);
    expect(published).toBeGreaterThanOrEqual(pending.length);
    expect((await unpublishedFor(db, env.id)).length).toBe(0);
 
    // Every event this environment produced reached the destination with its
    // own id as the deduplication key.
    const ids = publisher.sent.map((m) => m.id);
    expect(new Set(ids).size).toBe(ids.length);
 
    // A second pass has nothing of OURS to do — marked rows are done.
    //
    // Asserted per environment, not on the global count: `drainOnce()` returns
    // how many rows it moved across the whole table, and other suites in this
    // lane are writing events the entire time. The row-level property being
    // tested is "a marked row is never sent again", so that is what the
    // assertion says.
    const oursBefore = publisher.sent.filter((m) =>
      m.subject.endsWith(env.id),
    ).length;
    await relay.drainOnce();
    const oursAfter = publisher.sent.filter((m) =>
      m.subject.endsWith(env.id),
    ).length;
    expect(oursAfter).toBe(oursBefore);
    expect((await unpublishedFor(db, env.id)).length).toBe(0);
  });
 
  it("invariant 8: two concurrent relays publish every row exactly once", async () => {
    // `FOR UPDATE SKIP LOCKED` is the whole mechanism: competing drainers skip
    // each other's claimed rows rather than blocking on them. Two api instances
    // is the ORDINARY deployment, not an edge case.
    for (let i = 0; i < 20; i++) {
      await repo.sendMessage(channelId, {
        text: `concurrent ${i}`,
        userId: tuan.id,
        userExternalId: "tuan",
      });
    }
    const a = recordingPublisher();
    const b = recordingPublisher();
    const relayA = createRelay({ db, publisher: a, logger: silent, batchSize: 7 });
    const relayB = createRelay({ db, publisher: b, logger: silent, batchSize: 7 });
 
    // Run them at the same time, repeatedly, until the backlog is gone.
    for (let pass = 0; pass < 20; pass++) {
      if ((await outboxDepthFor(db, env.id)) === 0) break;
      await Promise.all([relayA.drainOnce(), relayB.drainOnce()]);
    }
 
    expect(await outboxDepthFor(db, env.id)).toBe(0);
 
    // UNIQUE AMONG THE ROWS THIS TEST WROTE. `drainOnce` is global and ordered
    // oldest-first, so these two relays publish the whole table's backlog on the way
    // to this environment's twenty — and a uniqueness claim over everything they
    // sent is a claim about data this test did not write. It is also false for
    // reasons that have nothing to do with exactly-once: `m.id` is the ENVELOPE id,
    // which an edit's `message.created` and `message.updated` deliberately share.
    //
    // It passed for years because it ran when the backlog happened to be empty. The
    // measurement that settled it: 700 published, 41 distinct — 513 of them another
    // package's fixture rows, whose payload carried no id at all.
    const mine = [...a.sent, ...b.sent].filter((m) => {
      const p = m.payload as { environment_id?: string; data?: { text?: string } };
      return p.environment_id === env.id && p.data?.text?.startsWith("concurrent ") === true;
    });
    const ids = mine.map((m) => m.id);
    // TWENTY, NOT "at least one": a relay that published nineteen and a loop that
    // exited early both satisfy uniqueness, and neither is exactly-once.
    expect(ids).toHaveLength(20);
    expect(new Set(ids).size).toBe(ids.length);
  });
 
  it("invariant 11: a relay log line carries counts, never payloads", async () => {
    // A message body in a log is a tenant's data in an operator's terminal
    // (NFR-SEC-06). The relay logs what it did, not what it moved.
    const lines: string[] = [];
    const noisy: Logger = createLogger("outbox-itest", (line) =>
      lines.push(typeof line === "string" ? line : JSON.stringify(line)),
    );
    await repo.sendMessage(channelId, {
      text: "a secret worth keeping out of logs",
      userId: tuan.id,
      userExternalId: "tuan",
    });
    const relay = createRelay({
      db,
      publisher: recordingPublisher(),
      logger: noisy,
    });
    await relay.drainOnce();
    // The relay's own logging happens in the loop, so drive one iteration of it
    // the way production does.
    relay.start();
    await new Promise((resolve) => setTimeout(resolve, 300));
    await relay.stop();
 
    const haystack = lines.join("\n");
    expect(haystack).not.toContain("a secret worth keeping out of logs");
    expect(haystack).not.toContain("north ramp");
  });
 
  it("invariant 6: publish-after-commit LOSES the event when the process dies in the gap (SC-003)", async () => {
    // The chapter's opening failure, reproduced. The naive walk commits its
    // messages and is killed before it publishes. Afterwards the messages are
    // there and there is NO durable record that an event was ever owed — no
    // row, no error, nothing to replay from. That silence is the whole problem:
    // a message was sent, no webhook will fire, and nothing anywhere knows.
    const environmentId = await killInTheGap("naive");
 
    const messages = (await db.execute(
      `SELECT count(*)::int AS n FROM messages m
         JOIN channels c ON c.id = m.channel_id
        WHERE c.environment_id = '${environmentId}'`,
    )) as unknown as { rows: { n: number }[] };
    expect(messages.rows[0]!.n).toBeGreaterThan(0);
 
    const owed = (await db.execute(
      `SELECT count(*)::int AS n FROM outbox
        WHERE payload->>'environment_id' = '${environmentId}'`,
    )) as unknown as { rows: { n: number }[] };
    expect(owed.rows[0]!.n).toBe(0);
  }, 60_000);
 
  it("invariant 5: the outbox SURVIVES the same kill, and invariant 10's id survives with it (SC-002)", async () => {
    // Same script, same signal, same moment. The difference is that the event
    // committed with the message, so it is still here — pending, addressed, and
    // carrying the deduplication key it was born with.
    const environmentId = await killInTheGap("outbox");
 
    const rows = (await db.execute(
      `SELECT id, subject, payload FROM outbox
        WHERE published_at IS NULL AND payload->>'environment_id' = '${environmentId}'
        ORDER BY id`,
    )) as unknown as {
      rows: { id: number; subject: string; payload: { id: string } }[];
    };
    expect(rows.rows.length).toBeGreaterThan(0);
    const survivor = rows.rows[0]!;
    expect(survivor.subject).toBe(`events.msg.created.${environmentId}`);
    expect(survivor.payload.id).toMatch(/^[0-9a-f-]{36}$/);
 
    // Recovery needs no operator: a relay started afterwards publishes it, with
    // the SAME id the row was written with — which is invariant 10's integration
    // half. A deduplication key that changed on retry would make every
    // consumer's dedupe useless.
    const publisher = recordingPublisher();
    const relay = createRelay({ db, publisher, logger: silent });
    expect(
      await drainUntilClear(relay, db, environmentId),
    ).toBeGreaterThan(0);
    expect(publisher.sent.map((m) => m.id)).toContain(survivor.payload.id);
 
    await db.execute(
      `DELETE FROM outbox WHERE payload->>'environment_id' = '${environmentId}'`,
    );
  }, 60_000);
 
  it("invariant 9: the broker can be absent — writes succeed, events accumulate, the backlog drains (SC-007)", async () => {
    // SAD §7 claims exactly this: "broker down, events accumulate in Postgres,
    // relay drains on recovery". The claim is tested here rather than quoted.
    //
    // "Down" is a publisher pointed at a port with nothing behind it, driving
    // the REAL JetStream client — so the failure path under test is the client's
    // own connect failure, not a fake's throw.
    const down = createJetStreamPublisher({ url: "nats://127.0.0.1:14999" });
    const downRelay = createRelay({ db, publisher: down, logger: silent });
 
    // Writes do not care. This is the inversion an outbox exists to create: the
    // event spine is not a dependency of the write path (research R9).
    for (let i = 0; i < 3; i++) {
      const message = await repo.sendMessage(channelId, {
        text: `while the broker is down ${i}`,
        userId: tuan.id,
        userExternalId: "tuan",
      });
      expect(message.seq).toBeGreaterThan(0);
    }
    const backlog = await outboxDepthFor(db, env.id);
    expect(backlog).toBeGreaterThanOrEqual(3);
 
    // The relay cannot publish, and says so by failing rather than by marking.
    await expect(downRelay.drainOnce()).rejects.toThrow();
    expect(await outboxDepthFor(db, env.id)).toBe(backlog);
    await down.close();
 
    // The broker returns. Nobody intervenes; the same loop drains what piled up.
    const up = createJetStreamPublisher({
      url: process.env.RELAY_NATS_URL ?? "nats://localhost:14222",
    });
    const upRelay = createRelay({ db, publisher: up, logger: silent });
    const drained = await drainUntilClear(upRelay, db, env.id);
    expect(drained).toBeGreaterThanOrEqual(backlog);
    expect(await outboxDepthFor(db, env.id)).toBe(0);
    await up.close();
 
    // And the whole-table depth an operator would watch is a real number.
    expect(await outboxDepth(db)).toBeGreaterThanOrEqual(0);
  }, 60_000);
 
  it("a failing publisher leaves the row pending rather than losing it", async () => {
    // The publish-then-mark ordering, tested from the failure side: a broker
    // that refuses must not advance the cursor.
    const publisher = recordingPublisher();
    await repo.sendMessage(channelId, {
      text: "the broker will refuse this",
      userId: tuan.id,
      userExternalId: "tuan",
    });
    const depthBefore = await outboxDepthFor(db, env.id);
    expect(depthBefore).toBeGreaterThan(0);
 
    publisher.failNext(1);
    const relay = createRelay({ db, publisher, logger: silent });
    await expect(relay.drainOnce()).rejects.toThrow(/unreachable/);
    expect(await outboxDepthFor(db, env.id)).toBe(depthBefore);
 
    // And it drains on the next attempt, with nothing lost.
    //
    // The lane is quiet enough for this to be deterministic: the e2e journey's
    // api children run with the relay off, precisely so that two test files do
    // not race over one table. Within this file, `drainOnce()` is driven by
    // hand and nothing else publishes.
    expect(
      await drainUntilClear(relay, db, env.id),
    ).toBeGreaterThanOrEqual(depthBefore);
    expect(await outboxDepthFor(db, env.id)).toBe(0);
  });
});
 
/** Drain until this environment's backlog is gone.
 *
 * One `drainOnce()` is not enough, and the reason is worth stating: the relay is
 * deliberately NOT tenant-scoped — one loop drains every environment's events,
 * because an outbox row is work the platform owes itself. So a batch can be
 * filled entirely by rows this suite did not write, and a test that assumes
 * otherwise passes alone and fails in a full lane. (It did exactly that here.)
 * Suites cannot isolate themselves by construction on this table the way 2.1's
 * per-suite environments let them everywhere else. */
async function drainUntilClear(
  relay: { drainOnce: () => Promise<number> },
  db: Db,
  environmentId: string,
  passes = 20,
): Promise<number> {
  let moved = 0;
  for (let i = 0; i < passes; i++) {
    if ((await outboxDepthFor(db, environmentId)) === 0) break;
    const drained = await relay.drainOnce();
    moved += drained;
    if (drained === 0) break;
  }
  return moved;
}
 
/** Run the walk in one mode and `SIGKILL` it the moment it says it is in the
 * gap between the commit and the publish.
 *
 * A real signal from the parent, not `process.exit()` from the child: a process
 * that exits cooperatively gets to flush, and flushing is the thing being
 * disproved. This is the difference between testing an error path and testing
 * durability (research R4). */
async function killInTheGap(mode: "naive" | "outbox"): Promise<string> {
  // `__dirname`, not `import.meta`: this service compiles to CommonJS under
  // NestJS (ADR-15), where the meta-property is a compile error.
  const script = join(__dirname, "..", "..", "..", "..", "scripts", "dual-write-walk.mjs");
  return new Promise((resolve, reject) => {
    // A 30-second pause the child never actually waits out: the parent kills it
    // the instant the marker appears. It was 3 seconds, and on a loaded lane the
    // signal occasionally arrived AFTER the pause elapsed — at which point the
    // child went on to drain the outbox, which is global, and published rows
    // other tests in this file were still asserting on. The window only needs to
    // exceed the parent's reaction time; making it long costs nothing because it
    // is never spent.
    const child = spawn("node", [script, `--mode=${mode}`, "--pause=30000"], {
      env: { ...process.env },
      stdio: ["ignore", "pipe", "pipe"],
    });
    let out = "";
    let killed = false;
    const timer = setTimeout(() => {
      child.kill("SIGKILL");
      reject(new Error(`no marker within 30s; output was:\n${out}`));
    }, 30_000);
    child.stdout.on("data", (chunk: Buffer) => {
      out += chunk.toString();
      if (!killed && out.includes("MARKER kill-me-now")) {
        killed = true;
        child.kill("SIGKILL");
      }
    });
    child.stderr.on("data", (chunk: Buffer) => (out += chunk.toString()));
    child.on("exit", () => {
      clearTimeout(timer);
      if (!killed) return reject(new Error(`child finished before the marker:\n${out}`));
      const env = /environment\s+(\S+)/.exec(out)?.[1];
      if (!env) return reject(new Error(`no environment in output:\n${out}`));
      resolve(env);
    });
  });
}
 
/** Depth for ONE environment. `outboxDepth` counts the whole table, which is
 * right for an operator and wrong for a test sharing a database with others. */
async function outboxDepthFor(db: Db, environmentId: string): Promise<number> {
  const result = (await db.execute(
    `SELECT count(*)::int AS pending FROM outbox
      WHERE published_at IS NULL
        AND payload->>'environment_id' = '${environmentId}'`,
  )) as unknown as { rows: { pending: number }[] };
  return result.rows[0]?.pending ?? 0;
}

Để kiểm xem suite có giữ được thứ gì không, hãy dời lệnh chèn outbox ra ngoài transaction rồi chạy lại:

× invariant 1: a committed message leaves exactly one outbox row
× invariant 3: a recognised idempotent retry adds no second event
× invariant 4: both doors produce one event each, identical in shape
× invariant 7: the relay publishes pending rows, marks them, and does not republish
× invariant 5: the outbox SURVIVES the same kill …
× invariant 9: the broker can be absent …
× a failing publisher leaves the row pending rather than losing it
7 failed

Bảy trên mười một. Một thay đổi một dòng về vị trí của một lệnh INSERT làm vỡ hầu hết những gì chương này tuyên bố — và đó là đúng mức độ nhạy cảm cho một suite canh gác một tính chất về độ bền.

Phần bán kính vụ nổ còn lại

Nhỏ, lần này. Đường event là thứ được thêm vào chứ không phải một đường nối nghỉ hưu, nên các file mà các chương trước đã fence thì đổi theo những cách vừa một màn hình:

services/api/src/outbox/publisher.ts
// The port. ADR-06's quieter payoff is that the outbox is "the
// abstraction seam that makes ADR-02 reversible": every event originates in a
// Postgres table with a subject and a payload, and *which broker* is a relay
// configuration detail. That sentence is only true if the seam exists in the
// code, so here it is — three fields, none of them broker-specific.
 
export interface PublishedMessage {
  /** Where it goes. */
  subject: string;
  /** Which event this is — the envelope's own id, used as the broker's
   * deduplication key. Not the outbox row's number: that is a cursor into this
   * database and means nothing outside it (research R7). */
  id: string;
  /** What a consumer receives, already complete. */
  payload: unknown;
}
 
export interface Publisher {
  /** Resolves only when the destination has ACCEPTED the message. A publisher
   * that resolves early turns at-least-once into at-most-once, because the
   * relay marks a row published on this promise (research R3). */
  publish(message: PublishedMessage): Promise<void>;
  close(): Promise<void>;
}
 
/** Publish one row's worth of event. Trivial by design: the interesting parts
 * are the transaction that wrote the row and the relay that decides when to
 * mark it, and neither of them should have to know a broker's vocabulary. */
export async function publishPending(
  publisher: Publisher,
  pending: { subject: string; payload: { id: string } },
): Promise<void> {
  await publisher.publish({
    subject: pending.subject,
    id: pending.payload.id,
    payload: pending.payload,
  });
}
services/api/src/outbox/outbox.module.ts
import { Inject, Injectable, Module, type OnModuleDestroy } from "@nestjs/common";
 
import { createLogger } from "@relay/service-kit";
 
import { createDb, createPool, type Db } from "../db/client";
import { createJetStreamPublisher } from "./jetstream.publisher";
import { createRelay, type Relay } from "./relay";
 
// The relay's home. It lives INSIDE the api service because
// ADR-06 put it there — "a small loop inside the API service initially,
// promotable to its own deployment if outbox depth alarms fire". Promoting it
// would mean moving this file and nothing else: the loop reads a table and
// writes to a broker, and shares no state with the request path.
 
export const OUTBOX_RELAY = "OUTBOX_RELAY";
 
/** The relay runs WITH the service. It is not something an operator switches
 * on — an event spine that only runs when someone remembers is not a spine.
 *
 * `RELAY_OUTBOX_RELAY=off` exists for the suites that want a quiet database:
 * most integration tests assert on rows, and a background loop marking them
 * published mid-assertion would make those tests flaky for no teaching value.
 * The outbox suite turns it off and drives `drainOnce()` itself, which is the
 * same code path the loop runs. */
export function relayEnabled(): boolean {
  return (process.env.RELAY_OUTBOX_RELAY ?? "on").toLowerCase() !== "off";
}
 
@Injectable()
export class OutboxRelayService implements OnModuleDestroy {
  constructor(@Inject(OUTBOX_RELAY) private readonly relay: Relay) {}
 
  start(): void {
    if (relayEnabled()) this.relay.start();
  }
 
  async onModuleDestroy(): Promise<void> {
    await this.relay.stop();
  }
}
 
@Module({
  providers: [
    {
      provide: OUTBOX_RELAY,
      useFactory: (): Relay =>
        createRelay({
          db: createDb(createPool()) as Db,
          publisher: createJetStreamPublisher(),
          logger: createLogger("outbox"),
        }),
    },
    OutboxRelayService,
  ],
  exports: [OUTBOX_RELAY, OutboxRelayService],
})
export class OutboxModule {}
services/api/src/app.module.ts
@@ -7,23 +7,30 @@ import { APP_FILTER } from "@nestjs/core";
 
 import { AuthModule } from "./auth/auth.module";
 import { AuthenticateMiddleware } from "./auth/authenticate.middleware";
 import { HealthController } from "./health.controller";
 import { InternalModule } from "./internal/internal.module";
 import { MessagesModule } from "./messages/messages.module";
+import { OutboxModule } from "./outbox/outbox.module";
 import { TenancyModule } from "./tenancy/tenancy.module";
 import { LOGGER, apiLogger } from "./logger";
 import { ProtocolErrorFilter } from "./protocol-error.filter";
 import { RequestContextMiddleware } from "./request-context.middleware";
 
 // The application described as a module graph — ADR-15's convention for the
 // wide surface Phases 2-4 will grow. Registering the error filter as a
 // provider (APP_FILTER) instead of wiring it in main.ts means every entry
 // point — including tests — gets the same error envelope for free.
 @Module({
-  imports: [AuthModule, MessagesModule, InternalModule, TenancyModule],
+  imports: [
+    AuthModule,
+    MessagesModule,
+    InternalModule,
+    TenancyModule,
+    OutboxModule,
+  ],
   controllers: [HealthController],
   providers: [
     { provide: LOGGER, useFactory: apiLogger },
     { provide: APP_FILTER, useClass: ProtocolErrorFilter },
     RequestContextMiddleware,
   ],
services/api/src/main.ts
@@ -1,31 +1,39 @@
 import "reflect-metadata";
 
 import { NestFactory } from "@nestjs/core";
 import { createLogger } from "@relay/service-kit";
 
 import { AppModule } from "./app.module";
+import { OutboxRelayService } from "./outbox/outbox.module";
 
 // Nest's own banner logger stays off: this workspace already decided what a
 // log line looks like (one JSON object, NFR-OBS-01), and the framework does
 // not get a second opinion.
 async function bootstrap(): Promise<void> {
   const app = await NestFactory.create(AppModule, { logger: false });
   const requested = Number(process.env.PORT ?? 4000);
   await app.listen(requested);
   // THE PORT IT GOT, NOT THE PORT IT ASKED FOR.
   //
-  // `PORT=0` asks the operating system for any free port, which is what a test
-  // spawning this service should do — a fixed port races whichever sibling suite also
-  // binds one, and a previous run's child still holding it makes a health check succeed
-  // against a service that has never heard of this run's data. Three unrelated-looking
-  // assertions, one fixture.
+  // `PORT=0` asks the operating system for any free port, which is what a test spawning
+  // this service should do — a fixed port races whichever sibling suite also binds one,
+  // and a previous run's child still holding it makes a health check succeed against a
+  // service that has never heard of this run's data. Three unrelated-looking assertions,
+  // one fixture.
   //
   // But a parent can only use the number if this process reports it, and logging
   // `requested` prints 0. So the bound address is read back and logged.
   const address = app.getHttpServer().address() as { port?: number } | string | null;
   const port =
     typeof address === "object" && address !== null ? (address.port ?? requested) : requested;
+  // The relay starts AFTER the server is listening, and starting it cannot fail: the
+  // publisher connects lazily, so an unreachable broker leaves events accumulating in
+  // Postgres instead of preventing the api from serving writes (research R9).
+  app.get(OutboxRelayService).start();
+  // Nest calls onModuleDestroy on shutdown hooks; without this the relay's loop would
+  // outlive the process's intent to stop.
+  app.enableShutdownHooks();
   createLogger("api").log("info", "listening", { port });
 }
 
 void bootstrap();
services/api/src/messages/messages.service.ts
@@ -26,22 +26,28 @@ import type { HistoryQuery, SendMessageBody } from "./messages.schema";
 export class MessagesService {
   constructor(private readonly repo: Repository) {}
 
   async send(
     channelId: string,
     body: SendMessageBody,
-    /** Chapter 2.6: who wrote it. Optional because the public REST route
-     * has no authenticated user yet (its own chapter, Part 3); the
-     * internal route always knows. */
+    /** Chapter 2.6: who wrote it. Optional because a key-authenticated public
+     * send is unattributed (the credentials chapter's recorded bound); the internal route always
+     * knows. */
     userId?: string,
+    /** The same person as a CONSUMER will see them. The event
+     * envelope carries external ids, and the internal route already holds this
+     * one — it is the token's subject — so threading it costs nothing where a
+     * lookup inside the write transaction would cost a query per message. */
+    userExternalId?: string,
   ): Promise<MessageRow> {
     try {
       return await this.repo.sendMessage(channelId, {
         text: body.text,
         metadata: body.metadata,
         ...(userId !== undefined && { userId }),
+        ...(userExternalId !== undefined && { userExternalId }),
         ...(body.idempotency_key != null && {
           idempotencyKey: body.idempotency_key,
         }),
       });
     } catch (error) {
       if (error instanceof ChannelNotFoundError) {
services/api/src/internal/internal.controller.ts
@@ -72,12 +72,15 @@ export class InternalController {
       },
       // Chapter 2.6: the sender is RESOLVED here and, until now, dropped
       // here — every socket-written row had user_id NULL. Fan-out cannot
       // build a message.created frame without a sender, so the write path
       // finally records the one it already had in its hand.
       user.id,
+      // The outbox chapter: and the external id travels too, because the event this
+      // write now emits is read by customers, who know users by that name.
+      userExternalId,
     );
     // `user` is echoed as the EXTERNAL id: internal uuids are ours, and
     // the frame this becomes is client-facing.
     return { ...message, user: userExternalId };
   }
 }
services/api/package.json
@@ -17,12 +17,13 @@
     "@nestjs/core": "^11.1.28",
     "@nestjs/platform-express": "^11.1.28",
     "@relay/protocol": "workspace:*",
     "@relay/service-kit": "workspace:*",
     "drizzle-orm": "^0.45.2",
     "jose": "^6.2.7",
+    "nats": "^2.29.3",
     "pg": "^8.22.0",
     "reflect-metadata": "^0.2.2",
     "rxjs": "^7.8.2",
     "zod": "^4.4.3"
   },
   "devDependencies": {
services/gateway/src/session.itest.ts
@@ -112,13 +112,16 @@ async function startApi(): Promise<ApiUnderTest> {
   const key = await seeder.createApiKey(db, {
     environmentId: environment.id,
   });
 
   const port = Number(process.env.RELAY_SESSION_ITEST_API_PORT ?? 4123);
   const child: ChildProcess = spawn("node", [join(dist, "main.js")], {
-    env: { ...process.env, PORT: String(port) },
+    // No outbox relay in this child. This suite is about the
+    // socket's credentials; a background loop draining a table that chapter
+    // The outbox chapter's suite is asserting on turns two unrelated test files into a race.
+    env: { ...process.env, PORT: String(port), RELAY_OUTBOX_RELAY: "off" },
     stdio: ["ignore", "pipe", "pipe"],
   });
   const url = `http://127.0.0.1:${port}`;
   await waitForHealth(`${url}/healthz`);
 
   return {
services/gateway/package.json
@@ -14,10 +14,11 @@
     "@relay/service-kit": "workspace:*",
     "ioredis": "^6.0.0",
     "jose": "^6.2.7",
     "ws": "^8.21.1"
   },
   "devDependencies": {
+    "@relay/api": "workspace:*",
     "@types/ws": "^8.18.1",
     "tsx": "^4.23.1"
   }
 }
packages/e2e/src/harness.ts
@@ -327,13 +327,24 @@ export async function boot({ gateways = 2 } = {}): Promise<System> {
     ...process.env,
     ...forwarded(
       "DATABASE_URL",
       "RELAY_POSTGRES_PORT",
       "RELAY_REDIS_URL",
       "RELAY_REDIS_PORT",
+      // The api's relay needs the broker's address. Forwarded,
+      // never composed here — a harness that invents a URL becomes a second
+      // source of truth, which is exactly how this suite first failed.
+      "RELAY_NATS_URL",
+      "RELAY_NATS_PORT",
     ),
+    // The api children run WITHOUT the outbox relay. This journey
+    // asserts message delivery, and a background loop draining the outbox while
+    // the outbox chapter's own suite asserts on that same table is a race between two test
+    // files, not a property of the system. The relay has its own suite, which
+    // drives it explicitly.
+    RELAY_OUTBOX_RELAY: "off",
   };
 
   const apiPort = Number(process.env.RELAY_E2E_API_PORT ?? 4100);
   children.push(
     capture(
       "api",
turbo.json
@@ -15,19 +15,22 @@
     },
     "test": {
       "dependsOn": ["^build"],
       "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/compose.yaml"]
     },
     "test:integration": {
-      "dependsOn": ["^build"],
+      "dependsOn": ["^build", "build"],
       "cache": false,
       "env": [
         "DATABASE_URL",
         "RELAY_POSTGRES_PORT",
         "RELAY_REDIS_URL",
         "RELAY_REDIS_PORT",
+        "RELAY_NATS_URL",
+        "RELAY_NATS_PORT",
+        "RELAY_OUTBOX_RELAY",
         "RELAY_E2E_API_PORT"
       ]
     },
     "//#lint:root": {
       "inputs": [
         "**/*.{ts,mts,cts,mjs,js}",
scripts/dual-write-walk.mjs
// The outbox chapter walk: the dual-write problem, and the fix, run side by side.
//
//   node scripts/dual-write-walk.mjs --mode=naive
//   node scripts/dual-write-walk.mjs --mode=outbox
//   node scripts/dual-write-walk.mjs --mode=outbox --messages=200
//
// Both modes write a message and then publish an event. They differ in ONE
// respect — when the event becomes durable — and that difference is the whole
// chapter.
//
// The script prints `MARKER kill-me-now` between the commit and the publish and
// then waits, so a parent process can `SIGKILL` it exactly in the gap. That is
// how outbox.itest.ts turns "the process died at the worst moment" into a
// repeatable test rather than a story. Run by hand with nobody killing it, the
// script simply carries on and reports what happened.
//
// DECISION: the naive publish-after-commit path lives HERE and in
// no service. It is a teaching artifact, like split-brain.mjs — fenced, so it
// cannot quietly stop compiling, and outside services/ so that nobody copying
// the repository ships the bug (research R5).
import { createDb, createPool } from "../services/api/dist/db/client.js";
import {
  createEnvironment,
  Repository,
  drainOutbox,
} from "../services/api/dist/db/repository.js";
import { messageCreatedEvent } from "../services/api/dist/outbox/event.js";
import { createJetStreamPublisher } from "../services/api/dist/outbox/jetstream.publisher.js";
 
const arg = (name, fallback) => {
  const hit = process.argv.find((a) => a.startsWith(`--${name}=`));
  return hit ? hit.slice(name.length + 3) : fallback;
};
 
const MODE = arg("mode", "outbox");
const COUNT = Number(arg("messages", "1"));
const PAUSE_MS = Number(arg("pause", "400"));
 
if (MODE !== "naive" && MODE !== "outbox") {
  console.error(`unknown --mode=${MODE} (expected naive or outbox)`);
  process.exit(2);
}
 
const db = createDb(createPool());
const env = await createEnvironment(db, { name: `dual-write-${Date.now()}` });
const repo = new Repository(db, env.id);
const tuan = await repo.createUser("tuan", "Tuan");
const channel = await repo.createChannel("fleet", "public");
await repo.addMember(channel.id, tuan.id);
 
const show = (label, value) => console.log(`${label.padEnd(26)} ${value}`);
show("environment", env.id);
show("mode", MODE);
 
const pending = async () => {
  const rows = await db.execute(
    `SELECT count(*)::int AS n FROM outbox
      WHERE published_at IS NULL AND payload->>'environment_id' = '${env.id}'`,
  );
  return rows.rows[0].n;
};
 
if (MODE === "naive") {
  // ── the way you would write it first ────────────────────────────────────
  // Commit the message. Then publish the event. Nothing wrong with either
  // step; everything wrong with the gap between them.
  for (let i = 0; i < COUNT; i++) {
    const id = crypto.randomUUID();
    const seq = i + 1;
    await db.execute(
      `INSERT INTO messages (id, channel_id, sequence, user_id, text, metadata)
       VALUES ('${id}', '${channel.id}', ${seq}, '${tuan.id}', 'B2, north ramp', '{}')`,
    );
    await db.execute(
      `UPDATE channels SET last_sequence = ${seq} WHERE id = '${channel.id}'`,
    );
  }
  show("messages committed", COUNT);
  show("outbox rows", await pending());
 
  console.log("MARKER kill-me-now");
  await new Promise((r) => setTimeout(r, PAUSE_MS));
 
  // A process that dies above this line has committed messages and owes
  // events that no longer exist anywhere. Nothing errored. Nothing to replay.
  const publisher = createJetStreamPublisher();
  for (let i = 0; i < COUNT; i++) {
    const event = messageCreatedEvent({
      eventId: crypto.randomUUID(),
      environmentId: env.id,
      message: {
        id: crypto.randomUUID(),
        channel_id: channel.id,
        seq: i + 1,
        user: "tuan",
        text: "B2, north ramp",
        created_at: new Date().toISOString(),
      },
    });
    await publisher.publish({
      subject: event.subject,
      id: event.payload.id,
      payload: event.payload,
    });
  }
  await publisher.close();
  show("events published", COUNT);
  show("durable record of them", "none — the publish WAS the record");
} else {
  // ── the way it survives ─────────────────────────────────────────────────
  // The event row commits with the message. The publish is somebody else's
  // problem, later, from a table.
  for (let i = 0; i < COUNT; i++) {
    await repo.sendMessage(channel.id, {
      text: "B2, north ramp",
      userId: tuan.id,
      userExternalId: "tuan",
    });
  }
  show("messages committed", COUNT);
  show("outbox rows waiting", await pending());
 
  console.log("MARKER kill-me-now");
  await new Promise((r) => setTimeout(r, PAUSE_MS));
 
  // A process that dies above this line has lost nothing: the rows are in
  // Postgres and the next relay to run will publish them.
  const publisher = createJetStreamPublisher();
  let published = 0;
  for (;;) {
    const moved = await drainOutbox(db, 100, async (row) => {
      await publisher.publish({
        subject: row.subject,
        id: row.payload.id,
        payload: row.payload,
      });
    });
    published += moved;
    if (moved === 0) break;
  }
  await publisher.close();
  show("events published", published);
  show("outbox rows waiting", await pending());
}
 
process.exit(0);

Và một lần sửa-tiến, đó là loại thứ khác mà một chương nợ bạn. Suite đăng ký của chương tenancy khẳng định rằng một lần cung cấp thất bại để nguyên con số đếm organisation toàn cục. Chuyện đó xanh suốt hai chương rồi đỏ ở đây với expected 884 to be 883, bởi các bài test crash của chương này spawn các tiến trình con tự cung cấp tenant của riêng chúng trong lúc nó chạy. Con số đếm chưa bao giờ là bằng chứng — khẳng định nằm hai dòng bên dưới nó, rằng chẳng organisation nào tên doomed org sống sót, thì luôn là bằng chứng. Con số đếm toàn cục biến mất:

services/api/src/tenancy/signup.itest.ts
@@ -157,12 +157,17 @@ describe("signup", () => {
     // This read `SELECT count(*) FROM organisations` before and after and asserted the
     // two matched. That is a GLOBAL claim about a LOCAL operation: any other suite that
     // provisions a tenant between the two reads breaks it, and the failure —
     // `expected 452 to be 451` — says nothing about a neighbour. The isolation
     // fixtures seed two tenants and did exactly that.
     //
+    // THE OUTBOX CHAPTER WOULD HAVE FORCED THE SAME FIX. Its crash tests spawn child
+    // processes that provision their own tenants, which moves the same count from the
+    // same direction. Two unrelated chapters arriving at one defect is how you know the
+    // assertion was wrong rather than unlucky.
+    //
     // What invariant 1 actually claims is that the failed transaction left NOTHING
     // behind. That is a question about one organisation, and the test above already
     // asks its questions that way.
     await expect(
       provisionOrganisation(db, {
         provider: "not-a-provider",

Thay đổi trong turbo.json đó đáng một câu, bởi nó là một bug mà chương này tìm ra chứ không phải một bug nó gây ra. Hai suite giờ spawn một tiến trình con từ services/api/dist — các case socket của gateway từ chương credentials và bài test crash của chương này — còn nest build thì xoá thư mục output của nó trước khi build lại. Task tích hợp phụ thuộc vào ^build (các bản build của phụ thuộc) mà không phụ thuộc vào build (của chính nó), nên bản build của api có thể chạy trong lúc các bài test của api đang đọc dist, và tiến trình con chết với ERR_MODULE_NOT_FOUND. Nó đã âm ỉ từ chương credentials và chỉ nổi lên khi một suite thứ hai cần đúng các file đó.