Building Relay

Phần 3 · Chương 3.3

Outbox

Bạn sẽ tạo ra: Transactional outbox + relay (ADR-06); crash-in-the-gap test · khoảng 90 phút, bao gồm bài tập

Tài liệu gốc: SAD — Tài liệu kiến trúc phần mềm (tiếng Anh)

Platform bây giờ biết ai đang hỏi (3.2) và họ thuộc tenant nào (3.1). Thứ nó chưa làm được là nói cho người khác biết rằng có chuyện vừa xảy ra.

Webhooks cần điều đó. Analytics cũng vậy, live dashboard cũng vậy, và — ít tha thứ nhất — metering, thứ FR-ANL-06 yêu cầu phải khớp với operational data trong sai số 0.1%. Tất cả chúng là consumers của một thứ platform chưa produce: một event cho mỗi state change.

Cách hiển nhiên để produce nó dài hai dòng, và sai. Không phải sai tinh vi, không phải sai ở scale — mà sai theo cách âm thầm làm mất data, vào một ngày đẹp trời, trên một máy đang healthy. Chương này bắt đầu bằng việc làm nó mất một event ngay trước mắt bạn.

Phiên bản hai dòng, và window bên trong nó

Đây là toàn bộ bug, viết theo cách bất kỳ ai cũng sẽ viết đầu tiên:

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

Không có gì trong hai dòng đó là cẩu thả. Chúng chạy đúng order — commit trước, nên không event nào mô tả một message đã rollback. Trong codebase thật sẽ có error handling quanh chúng. Chúng hoạt động.

Chúng hoạt động gần như luôn luôn, và đó 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
Dual-write problem. Giữa commit và publish có một window nơi message đã tồn tại còn event thì chưa — và process chết ở đó không để lại gì nói rằng từng có một event bị nợ.

Chạy walk của chương trong mode đó và kill nó trong gap. Đây không phải thought experiment: script print một marker giữa commit và publish, và parent process gửi SIGKILL ngay khi marker 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]

Bây giờ nhìn vào thứ nằm trong database:

naive  env 0e9d99cb: messages=1 outbox=0

Một message. Không event. Và không error ở đâu cả — không exception nào bị throw, không retry queue nào có entry, không log line nào nói có gì sai. Webhook của customer sẽ không bao giờ fire cho message đó. Meter sẽ lệch một, mãi mãi, và daily reconciliation của FR-ANL-06 sẽ raise alert mà không ai giải thích được vì không còn gì để giải thích.

Đó là thứ làm failure này khác với các failure Phần 2 đã xử lý. Một WebSocket frame bị drop có thể recover — chương 2.7 recover nó từ Postgres, vì Postgres vẫn biết. Ở đây Postgres cũng không biết. Evidence chết cùng process.

Cái giá của lựa chọn thứ tư

ADR-06 cân bốn designs, và đáng nhìn vào design bị reject vì lý do operational thay vì correctness, vì đó là kiểu rejection series này đang cố dạy.

Change-data-capture — tail write-ahead log của Postgres bằng thứ như Debezium rồi derive events từ đó — về mặt architecture là câu trả lời đúng nhất có sẵn. WAL đã là transactional event log; derive events từ nó nghĩa là event và state change không thể disagree, vì chúng là cùng bytes.

Nó vẫn bị reject. Debezium nghĩa là chạy Kafka Connect hoặc equivalent, mapping schema sang events trong configuration, và coupling vào một WAL format không phải public API. Đó là một operational subsystem lớn hơn vấn đề nó giải, lại do một người vận hành (driver D8).

Outbox khoảng năm mươi dòng: một INSERT bên trong transaction, và một loop làm SELECT … FOR UPDATE SKIP LOCKED, publish, rồi mark. Asymmetry đó — năm mươi dòng so với một subsystem — là toàn bộ argument, và ADR-06 gọi tên volume nơi argument ngừng đúng (~50k events/s, lúc đó team đã đủ lớn để chạy Debezium).

Table, được quote thay vì invent

Lần đầu trong Phần 3, schema không phải derivation. SAD §6.1 define thẳng table này, nên chương có thể quote nó:

services/api/src/db/schema.ts
@@ -1,5 +1,6 @@
 import { sql } from "drizzle-orm";
 import {
+  bigserial,
   bigint,
   check,
   index,
@@ -20,9 +21,9 @@ import {
 // 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 arrived in 3.3 and is at the bottom of this file.
 
 // The tenancy hierarchy (chapter 3.1). Everything from here to `members`
 // below sits ABOVE the environment boundary: these rows say who owns a
@@ -281,3 +282,46 @@ export const members = pgTable(
     index("members_user_channel").on(t.userId, t.channelId),
   ],
 );
+
+// The outbox (chapter 3.3, 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 3.2'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, chapter 3.5). 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 (chapter 3.3): 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, vì mỗi cái là thứ bạn có thể expect.

Không status column. published_at IS NULL chính là queue. Một row hoặc pending hoặc done; không có state thứ ba để row mắc kẹt, và không có enum để năm sau thêm value thứ tư.

Không attempts counter, không last error, không dead-letter table. Đó là retry accounting, nó thuộc về webhook delivery (FR-WHK-03, FR-WHK-06), và là của chương 3.5. Relay này retry bằng cách không mark row là done, việc đó không cần column nào cả.

Thứ duy nhất §6.1 không define là index, nên phần đó là chapter decision và nói rõ trong schema. Nó là partial — chỉ cover rows relay đọc — đó là điều làm việc giữ published rows lại gần như không tốn gì.

services/api/migrations/0004_outbox.sql
-- Chapter 3.3 — 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 chapter 3.5.
 
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 message

Đây là change fix 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 COMMIT.

services/api/src/db/repository.ts
@@ -13,8 +13,10 @@ import {
   memberships,
   messages,
   organisations,
+  outbox,
   users,
 } from "./schema";
+import { messageCreatedEvent } from "../outbox/event";
 import {
   mintApiKey,
   parseApiKeyCredential,
@@ -218,6 +220,87 @@ export async function environmentSigningSecret(
   return row ?? null;
 }
 
+// ---------------------------------------------------------------------------
+// The outbox drain (chapter 3.3, 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. */
@@ -599,11 +682,18 @@ export class Repository {
     channelId: string,
     {
       userId,
+      userExternalId,
       text,
       metadata,
       idempotencyKey,
     }: {
       userId?: string;
+      /** Chapter 3.3: 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;
@@ -669,12 +759,46 @@ export class Repository {
         .set({ lastSequence: seq })
         .where(eq(channels.id, channel.id));
 
+      const createdAt = toIso(inserted[0]!.createdAt);
+
+      // THE EVENT COMMITS WITH THE MESSAGE (chapter 3.3, 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,
       };
     });
   }

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

Nó chỉ nằm trên inserted branch. Chương 2.3 dạy write path nhận ra retry: present cùng idempotency key và bạn nhận lại original message, còn sequence không bị consume vì không có gì được viết. Event được viết trên branch đó nghĩa là client retry trên flaky link fire hai webhooks cho một message — đúng loại duplicate mà 2.3 tồn tại để ngăn, được reintroduce lên một layer. Có test cho việc đó, và đó là invariant tôi sẽ giữ nếu chỉ được giữ một cái.

Envelope được build complete, bên trong transaction. Relay không thêm gì sau đó: không timestamp, không id, không field. Đó không phải tidiness — đó là thứ làm republished event byte-identical với attempt đầu tiên, tức là thứ khiến deduplication hoạt động được.

services/api/src/outbox/event.ts
// The event envelope (chapter 3.3). 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 chapter 3.4. */
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)
Outbox. Event commit cùng message, nên hai thứ chung số phận; một relay riêng move rows tới broker và chỉ mark chúng sau khi broker acknowledge.

Bây giờ cùng cú kill đó, ở cùng chỗ đó, 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 process lẽ ra sẽ publish nó. Không gì mất, không cần operator, và relay tiếp theo chạy sẽ gửi nó.

Relay, và ordering quan trọng

Loop nhỏ hơn argument dành 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 (chapter 3.3, 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 cần nhìn. Hai api instances là deployment bình thường — service stateless và chạy hơn một bản — nên hai relays drain một table là normal case, không phải edge case. SKIP LOCKED khiến các competing drainers bước vòng qua rows mà nhau đã claim thay vì queue phía sau, nghĩa là horizontal scaling là property của query này chứ không phải của một leader election không ai muốn vận hành.

Dòng còn lại là order của hai bước cuối: publish, rồi mark. Làm ngược lại và crash giữa hai bước sẽ lại làm mất event, tức cùng bug đội một cái mũ khác. Làm theo cách này nghĩa là crash ở giữa sẽ republish — row chưa từng được mark, nên pass tiếp theo gửi nó lần thứ hai.

Đó là at-least-once, và nó là vĩnh viễn. Cách ADR-06 diễn đạt đáng được dùng: embraced, không phải mitigated.

At-least-once yêu cầu gì từ những người khác

Duplicate phải được absorb ở đâu đó, và "đâu đó" là consumer. Mọi event mang một id sống sót qua republish, và consumer ignore nó thì broken bất kể relay này cẩn thận thế nào:

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": "…" }
}

Id đó được generate trong transaction và stored trong row, nên republish gửi cùng key thay vì một key mới. Nó cũng được đưa cho JetStream làm deduplication id của message, thứ collapse common case bên trong dedupe window của broker — nhưng window đó là convenience, không phải guarantee. Guarantee là field.

Và đây là thứ có khả năng làm bạn đau sau này nhất: chương này không promise event ordering. Hai relays publish concurrently có thể interleave. Một batch fail giữa chừng republish từ một điểm sớm hơn. Không có gì trong SRS yêu cầu ordered events, và build ordering guarantee vào một SKIP LOCKED drain nghĩa là từ bỏ property giúp nó scale.

Thứ ordered là messages trong một channel, theo data.seq, đúng như FR-MSG-03 và ADR-03 đã promise từ chương 2.2. Consumer cần order nên đọc field đó. Consumer infer order từ arrival cuối cùng sẽ sai, và ngày nó sai sẽ không trông giống bug trong consumer.

Vì sao bây giờ có hai paths

Chương 2.6 đã build một cách để nói với máy khác rằng một message đã xảy ra: Redis pub/sub, một subject per channel. Hỏi vì sao chương này không dùng luôn nó là hoàn toàn 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 delivery paths, một cách cố ý. Live frames là at-most-once vì resume có thể recover chúng; durable events là at-least-once vì không còn gì khác recover được.

Chúng trả lời hai câu hỏi khác nhau. Redis pub/sub là at-most-once by design (ADR-07): nếu không gateway nào subscribed vào channel tại khoảnh khắc publish, frame biến mất, và điều đó ổn — client reconnect, present cursor, và backfill của chương 2.7 đưa cho nó mọi thứ đã miss từ Postgres. Chi phí của một live frame bị drop bị bound bởi resume.

Event không có resume. Nếu một message.created không bao giờ reach webhook dispatcher, không customer system nào notice gap rồi hỏi xin nó; webhook đơn giản là không bao giờ fire và meter âm thầm sai. Vì vậy path này là at-least-once, durable, và chậm hơn — seconds thay vì milliseconds, vẫn nằm thoải mái trong budget 60 giây của FR-ANL-04.

Cùng event, hai paths, hai guarantees, vì hậu quả của loss ở hai bên không như nhau.

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

Một event spine kéo write path chết cùng nó còn tệ hơn không có event spine. Vì vậy relay connect lazily, api start dù broker có reachable hay không, và publish failure để rows nguyên chỗ cũ.

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 (chapter 3.3, 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 chapter 3.4 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;
    },
  };
}

Failure matrix của SAD §7 đã claim behavior này — "broker down, events accumulate in Postgres, relay drains on recovery". Những claim như vậy đáng được 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 events đợi qua outage; event thứ tư đến sau outage; cả bốn publish mà không ai làm gì. Con số operator theo dõi là count(*) đó — outbox depth — và nó là signal duy nhất đáng alarm khi việc vận hành thứ này trở thành job của ai đó.

Những gì chương này cố ý để lại sau

Subject taxonomy, streams, và mọi consumer (3.4). Chương này tạo một stream trên events.> vì publisher cần chỗ để publish và relay trỏ vào hư không không chứng minh được gì. Bảy event types còn lại của FR-WHK-02, per-environment sharding, retention, và durable pull consumer đầu tiên là của chương sau, và design chúng ở đây nghĩa là design chúng hai lần.

Webhook delivery, signing, retry tiers và dead-lettering (3.5). Lý do table này không có column attempts.

Pruning. ADR-06 gọi việc delete published rows là trivial, và đúng vậy — nhưng nó cần scheduler mà platform này chưa có, và invent một scheduler để delete rows hiện gần như không tốn gì (index là partial) sẽ là làm việc chỉ để có việc.

Reconciliation job của FR-ANL-06. Câu hỏi "làm sao ai đó biết nếu event bị missing?" bây giờ có câu trả lời — compare message counts với event counts, cả hai đều ở trong Postgres — nhưng job hỏi câu đó hằng ngày thuộc về analytics path trong Phần 4.

Các tests, và thứ chúng giữ

Mười hai invariants. Chín cái chạy against Postgres, hai cái pure, và một cái dựa trên crash test:

$ 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)

Invariant 6 là cái bất thường: một test có job chứng minh phiên bản broken vẫn broken. Nếu naive walk một ngày nào đó ngừng làm mất event, argument của chương này đã âm thầm không còn đúng, và tôi muốn biết điều đó từ red test hơn là từ reader.

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 (chapter 3.3). 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 (3.2'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);
    const all = [...a.sent, ...b.sent].map((m) => m.id);
    expect(new Set(all).size).toBe(all.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;
}

Để check suite thật sự giữ được điều gì, move outbox insert 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 one-line change ở vị trí của một INSERT phá phần lớn claims của chương này — đó là mức sensitivity đúng cho một suite guard một durability property.

Phần còn lại của blast radius

Lần này nhỏ. Event path được thêm vào thay vì retire một seam, nên những files các chương trước đã fence thay đổi theo cách vừa một màn hình:

services/api/src/outbox/publisher.ts
// The port (chapter 3.3). 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 (chapter 3.3). 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
@@ -10,6 +10,7 @@ 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";
@@ -20,7 +21,13 @@ import { RequestContextMiddleware } from "./request-context.middleware";
 // 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 },
services/api/src/main.ts
@@ -4,6 +4,7 @@ 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
@@ -12,6 +13,14 @@ async function bootstrap(): Promise<void> {
   const app = await NestFactory.create(AppModule, { logger: false });
   const port = Number(process.env.PORT ?? 4000);
   await app.listen(port);
+  // 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
+  // (chapter 3.3, 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 });
 }
 
services/api/src/messages/messages.service.ts
@@ -29,16 +29,22 @@ export class MessagesService {
   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 (3.2's recorded bound); the internal route always
+     * knows. */
     userId?: string,
+    /** Chapter 3.3: 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,
         }),
services/api/src/internal/internal.controller.ts
@@ -75,6 +75,9 @@ export class InternalController {
       // build a message.created frame without a sender, so the write path
       // finally records the one it already had in its hand.
       user.id,
+      // Chapter 3.3: 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.
services/api/package.json
@@ -20,6 +20,7 @@
     "@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",
services/gateway/package.json
@@ -17,6 +17,7 @@
     "ws": "^8.21.1"
   },
   "devDependencies": {
+    "@relay/api": "workspace:*",
     "@types/ws": "^8.18.1",
     "tsx": "^4.23.1"
   }
packages/e2e/src/harness.ts
@@ -330,7 +330,18 @@ export async function boot({ gateways = 2 } = {}): Promise<System> {
       "RELAY_POSTGRES_PORT",
       "RELAY_REDIS_URL",
       "RELAY_REDIS_PORT",
+      // Chapter 3.3: 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",
     ),
+    // Chapter 3.3: the api children run WITHOUT the outbox relay. This journey
+    // asserts message delivery, and a background loop draining the outbox while
+    // 3.3'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);
turbo.json
@@ -18,13 +18,16 @@
       "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"
       ]
     },
scripts/dual-write-walk.mjs
// The chapter 3.3 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 (chapter 3.3): 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 fix-forward, kiểu nợ khác mà một chương phải trả cho bạn. Signup suite của chương 3.1 assert rằng failed provisioning để global organisation count không đổi. Nó pass hai chương rồi fail ở đây với expected 884 to be 883, vì crash tests của 3.3 spawn child processes tự provision tenants của chúng trong lúc nó chạy. Count chưa bao giờ là evidence — assertion ngay dưới đó hai dòng, rằng không organisation nào tên doomed org sống sót, mới luôn là thứ gánh trọng lượng. Global count biến mất:

services/api/src/tenancy/signup.itest.ts
@@ -152,9 +152,6 @@ describe("signup", () => {
   });
 
   it("writes the full set or nothing when provisioning fails (invariant 1)", async () => {
-    const before = await db.execute(
-      `SELECT count(*)::int AS n FROM organisations`,
-    );
     // Force a failure inside the transaction, after the organisation insert:
     // an organisation name that is fine and a provider value the CHECK
     // constraint refuses.
@@ -165,14 +162,14 @@ describe("signup", () => {
         organisationName: "doomed org",
       }),
     ).rejects.toThrow();
-    const after = await db.execute(
-      `SELECT count(*)::int AS n FROM organisations`,
-    );
-    // Nothing survived — no half-built tenant, which is the whole point of the
-    // single transaction.
-    expect((after.rows[0] as { n: number }).n).toBe(
-      (before.rows[0] as { n: number }).n,
-    );
+    // REVISED by chapter 3.3: this used to count ALL organisations before and
+    // after and assert the totals matched. That is a global assertion in a lane
+    // where other suites create tenants concurrently — it passed for two
+    // chapters and then failed with "expected 884 to be 883" the day 3.3's
+    // crash tests started spawning child processes that provision their own.
+    // The precise assertion below was always the one carrying the weight: the
+    // doomed organisation must not survive its transaction. A count of
+    // everything was never evidence about this rollback.
     const orphan = await db.execute(
       `SELECT count(*)::int AS n FROM organisations WHERE name = 'doomed org'`,
     );

Change trong turbo.json đáng có một câu, vì đó là bug chương này tìm thấy chứ không phải bug nó gây ra. Hai suites bây giờ spawn child process từ services/api/dist — các socket cases của gateway từ 3.2 và crash test của chương này — và nest build xóa output directory trước khi rebuild. Integration task depended on ^build (build của dependencies) nhưng không depends on build (build của chính nó), nên build của api có thể chạy trong khi tests của api đang đọc dist, và child chết với ERR_MODULE_NOT_FOUND. Nó đã latent từ 3.2 và chỉ surfaced khi suite thứ hai cần cùng các files đó.