Part 3 · Chapter 3.20
When to stop trying
You will produce: Attempt records on an analytics stream, and auto-disable from two triggers · about 80 minutes including the exercise
Server của một khách hàng đã trả lời 500 cho mọi webhook suốt ba ngày.
Đây là thứ nền tảng của chương webhook dispatcher làm với chuyện đó. Mỗi event sinh ra một delivery, mỗi delivery được thử bảy lần trong hai tiếng rưỡi, mỗi lần thử đều hỏng, mỗi lần đều vào dead letter. Rồi event tiếp theo tới và toàn bộ chuyện đó lặp lại. Khách hàng không được báo. Endpoint vẫn bật. Không ai ở Relay biết, bởi chẳng đâu có gì đếm cả.
Nền tảng đó bỏ cuộc với một delivery. Nó không bao giờ bỏ cuộc với một endpoint.
Chương này sửa điều đó, và nó làm hai việc theo một thứ tự cụ thể: nó dựng bản ghi về chuyện đã xảy ra, rồi mới dựng quyết định dừng thử. Thứ tự đó không phải ngẫu nhiên. Tắt endpoint của một khách hàng trả tiền là chuyện ai đó phải giải thích về sau — với khách hàng, với bộ phận hỗ trợ, có thể với một luật sư — và một nền tảng ra tay như vậy mà không có bản ghi từng lần thử thì không giải thích nổi. Đó là lý do FR-WHK-07 bị hoãn khỏi chương webhook dispatcher chứ không được xuất bản: quyết định đã sẵn sàng còn bằng chứng thì chưa.
Bản ghi phải đến trước
Có một phiên bản của chương này ngắn bằng một nửa. Nó thêm hai cột, đếm số lần hỏng, tắt endpoint sau một tiếng, rồi dừng. Mọi thứ FR-WHK-07 yêu cầu, không hơn.
Hãy tưởng tượng cuộc trò chuyện hỗ trợ diễn ra sau đó. Một khách hàng viết vào: webhook
của chúng tôi ngừng ba ngày trước và chúng tôi không tắt gì cả. Nền tảng
có thể nói endpoint đang bị tắt. Nó có thể nói lý do một cách chung chung — nó đang
hỏng. Nó không thể nói chuyện hỏng bắt đầu khi nào, endpoint thực sự trả lời gì,
đã có bao nhiêu request, hay lần cuối là một 500 hay một
timeout. Mọi sự thật đó đều tồn tại vào đúng lúc chúng quan trọng và không cái nào được
ghi lại.
Nên bản ghi lần thử đến trước, và phần còn lại của chương tiêu nó.
Đường nối vốn đã mang sẵn nó
Đây là phần dễ chịu, và nó là phần dễ chịu duy nhất của chương này.
Chương webhook dispatcher đã định nghĩa hợp đồng nội bộ mà dispatcher dùng để báo cáo chuyện gì xảy ra khi nó post. Hãy xem thứ nó đã gửi sẵn:
export const internalDeliveryOutcomeRequestSchema = z.strictObject({
delivery_id: z.string().uuid(),
attempt: z.number().int().positive(),
status: z.number().int().optional(),
error: z.string().max(2000).optional(),
latency_ms: z.number().int().nonnegative(),
});latency_ms là bắt buộc, không âm, và được gửi trên từng lần thử một. Dispatcher
đã đo nó từ khi chương webhook dispatcher xuất bản. Và cả dispatch.controller.ts
lẫn recordAttemptOutcome đều không nhắc tới field đó ở đâu — nó được
schema xác thực rồi vứt xuống sàn.
Nên cái cột nghe có vẻ khó nhất của FR-WHK-06, khách hàng mất bao lâu để trả lời, chẳng tốn gì để có được. Dữ liệu đã tới suốt một chương; đây là thứ đầu tiên cần nó.
Điều đó đáng dừng lại một chút, bởi nó là một pattern chứ không phải một sự trùng hợp. Một hợp đồng thiết kế quanh thứ bên gửi biết thường mang nhiều hơn thứ người tiêu thụ đầu tiên cần, và phần dư là miễn phí vào ngày có thứ khác xuất hiện. Phương án khác — mở rộng đường nối bây giờ, triển khai lại hai service để thêm một số nguyên — là cái giá mà chương webhook dispatcher tránh được bằng cách viết ra mọi thứ dispatcher có.
Một stream thứ ba
Attempt event không đi trên stream EVENTS.
EVENTS mang các domain event của tenant, với thời hạn lưu bảy ngày định hình cho
những bên tiêu thụ không được bỏ lỡ cái nào. DELIVERIES mang công việc đã đến hạn.
Bản ghi lần thử chẳng thuộc loại nào. Chúng có khối lượng lớn, chúng được phép mất mát
vì một lý do mà cả mục sau nói về, và ingester của Phần 4 sẽ muốn
tiêu thụ chúng mà không phải tiêu thụ luôn mọi message event trong nền tảng.
Ba stream, ba thời hạn lưu, ba vị trí consumer. Ngữ pháp subject
mở rộng ngữ pháp của chương broker thay vì bịa ra một quy ước thứ hai, và nó sống trong
@relay/protocol bên cạnh hai cái kia vì lý do chương broker đã dời cái đầu tiên
tới đó: một consumer tự ghép subject filter của mình sẽ chẳng nhận được gì vào ngày
ngữ pháp thay đổi. Không lỗi, không cảnh báo, chỉ là một vị trí stream rỗng.
@@ -210,12 +210,72 @@ export const ALL_DELIVERIES_SUBJECT = `${DELIVERY_SUBJECT_PREFIX}.>`;
* filter change rather than a redesign. */
export function deliverySubjectFor(environmentId: string): string {
if (!environmentId) throw new Error("an environment id is required");
return `${DELIVERY_SUBJECT_PREFIX}.${environmentId}`;
}
+// ---------------------------------------------------------------------------
+// Analytical events (constitution III).
+//
+// The THIRD grammar in this file, and it is here for the reason the other two
+// are: a consumer that assembles its own subject filter receives nothing the day
+// the grammar changes — no error, no warning, just an empty stream position. Part
+// 4's ingester is that consumer, and it does not exist yet, which is exactly when
+// a shared definition is cheapest to establish.
+//
+// `analytics.{domain}.{action}.{environment_id}` extends the broker chapter's
+// `events.{domain}.{action}.{env}` rather than inventing a second convention.
+//
+// The stream is SEPARATE from `EVENTS` and `DELIVERIES`, and that is a decision
+// rather than tidiness (research R4). `EVENTS` carries tenant domain events whose
+// consumers must not miss one; `DELIVERIES` carries work. Attempt records are
+// neither: high volume, deliberately lossy (research R5), and a consumer that
+// wants them does not want every message event alongside.
+// ---------------------------------------------------------------------------
+
+export const ANALYTICS_STREAM = "ANALYTICS";
+export const ANALYTICS_SUBJECT_PREFIX = "analytics";
+export const ALL_ANALYTICS_SUBJECT = `${ANALYTICS_SUBJECT_PREFIX}.>`;
+
+/** A UUID, checked rather than trusted.
+ *
+ * The environment id becomes a SUBJECT TOKEN, and NATS subjects are
+ * dot-delimited: a value containing a dot silently creates a deeper subject than
+ * intended, and one containing `*` or `>` creates a wildcard. Neither fails at
+ * publish time. Both would put one tenant's attempt records where another
+ * tenant's filter can reach them, which is constitution I stated as a parsing
+ * problem. Every caller in this platform holds a uuid already, so refusing
+ * anything else costs nothing. */
+const UUID =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+export function analyticsSubjectFor(
+ domain: string,
+ action: string,
+ environmentId: string,
+): string {
+ if (!domain) throw new Error("a domain is required");
+ if (!action) throw new Error("an action is required");
+ if (!UUID.test(environmentId)) {
+ throw new Error("an environment id must be a uuid");
+ }
+ return [ANALYTICS_SUBJECT_PREFIX, domain, action, environmentId].join(".");
+}
+
+/** The one action this chapter publishes. Named so a consumer filters on a
+ * constant rather than on a string it typed. */
+export const WEBHOOK_ATTEMPT_ACTION = { domain: "webhook", action: "attempt" };
+
+export function webhookAttemptSubject(environmentId: string): string {
+ return analyticsSubjectFor(
+ WEBHOOK_ATTEMPT_ACTION.domain,
+ WEBHOOK_ATTEMPT_ACTION.action,
+ environmentId,
+ );
+}
+
// ---------------------------------------------------------------------------
// The dispatch contract (constitution IV).
//
// The dispatcher owns no database. "Only the API service writes to PostgreSQL…
// Other services obtain writes and backfill reads via the API service's internal
// endpoints." These are those endpoints, and they live here for the reasonCác bài test giữ ngữ pháp đó, kể cả ba giá trị nguy hiểm:
import { describe, expect, it } from "vitest";
import { ALL_EVENTS_SUBJECT, subjectFor } from "./internal.js";
// The subject grammar is ADR-02's, and a consumer that filters on a subject it
// assembled itself receives nothing the day the grammar drifts — silently. So
// the grammar is held here rather than trusted: the shape of a subject, the one
// abbreviation, the failure that must be loud, and the guarantee that whatever
// comes out is still reachable from the wildcard every consumer subscribes to.
/** `events.>` in NATS terms: everything below the prefix, at any depth. */
function matchesWildcard(subject: string, wildcard: string): boolean {
const prefix = wildcard.replace(/>$/, "");
return subject.startsWith(prefix) && subject.length > prefix.length;
}
describe("subjectFor builds ADR-02's `events.{domain}.{action}.{env}`", () => {
it("puts the environment last", () => {
expect(subjectFor("message.created", "env_123")).toBe(
"events.msg.created.env_123",
);
});
it("abbreviates `message` to `msg`, as ADR-02's own example does", () => {
const subject = subjectFor("message.created", "env_123");
expect(subject.split(".")[1]).toBe("msg");
});
it("passes a domain with no abbreviation through unchanged", () => {
expect(subjectFor("channel.created", "env_123")).toBe(
"events.channel.created.env_123",
);
});
it("throws on a missing part instead of producing `events..created.`", () => {
expect(() => subjectFor("", "env_123")).toThrow(/event type is required/);
expect(() => subjectFor("message.created", "")).toThrow(
/environment id is required/,
);
});
it("produces subjects the consumer's wildcard matches", () => {
for (const type of ["message.created", "channel.created"]) {
expect(matchesWildcard(subjectFor(type, "env_123"), ALL_EVENTS_SUBJECT)).toBe(
true,
);
}
});
});@@ -1,9 +1,15 @@
import { describe, expect, it } from "vitest";
-import { ALL_EVENTS_SUBJECT, subjectFor } from "./internal.js";
+import {
+ ALL_ANALYTICS_SUBJECT,
+ ALL_EVENTS_SUBJECT,
+ analyticsSubjectFor,
+ subjectFor,
+ webhookAttemptSubject,
+} from "./internal.js";
// The subject grammar is ADR-02's, and a consumer that filters on a subject it
// assembled itself receives nothing the day the grammar drifts — silently. So
// the grammar is held here rather than trusted: the shape of a subject, the one
// abbreviation, the failure that must be loud, and the guarantee that whatever
// comes out is still reachable from the wildcard every consumer subscribes to.
@@ -44,6 +50,65 @@ describe("subjectFor builds ADR-02's `events.{domain}.{action}.{env}`", () => {
expect(matchesWildcard(subjectFor(type, "env_123"), ALL_EVENTS_SUBJECT)).toBe(
true,
);
}
});
});
+
+// The analytics grammar. Held for the same reason as the one above
+// — Part 4's ingester will filter on it and does not exist yet — plus one this
+// grammar has and that one does not: the environment id becomes a SUBJECT TOKEN,
+// and a subject token is parsed by the broker rather than escaped by it.
+describe("analyticsSubjectFor builds `analytics.{domain}.{action}.{env}`", () => {
+ const ENV = "9f3c1e7a-0b2d-4c8e-9a1f-6d5b4c3a2e10";
+
+ it("puts the environment last, as the events grammar does", () => {
+ expect(analyticsSubjectFor("webhook", "attempt", ENV)).toBe(
+ `analytics.webhook.attempt.${ENV}`,
+ );
+ });
+
+ it("names the one action this chapter publishes", () => {
+ expect(webhookAttemptSubject(ENV)).toBe(analyticsSubjectFor("webhook", "attempt", ENV));
+ });
+
+ it("produces subjects the ingester's wildcard matches", () => {
+ expect(matchesWildcard(webhookAttemptSubject(ENV), ALL_ANALYTICS_SUBJECT)).toBe(
+ true,
+ );
+ });
+
+ it("does not collide with the events stream's wildcard", () => {
+ // Two streams, two prefixes. A subject reachable from both would mean the
+ // EVENTS consumers start receiving attempt records, which is the coupling
+ // research R4 separated the streams to avoid.
+ expect(matchesWildcard(webhookAttemptSubject(ENV), ALL_EVENTS_SUBJECT)).toBe(
+ false,
+ );
+ });
+
+ it("refuses an environment id that is not a uuid", () => {
+ // THIS IS THE TENANT-ISOLATION CASE, not input tidiness. A subject is
+ // dot-delimited and NATS reads `*` and `>` as wildcards, so a value carrying
+ // either would publish one tenant's attempt records where another tenant's
+ // filter can reach them — and nothing would fail at publish time.
+ for (const bad of [
+ "",
+ "env_123",
+ "not-a-uuid",
+ // The dangerous three: a dot creates a deeper subject than intended, and
+ // the two wildcards create a subscription rather than a destination.
+ "9f3c1e7a-0b2d-4c8e-9a1f-6d5b4c3a2e10.extra",
+ "*",
+ ">",
+ ]) {
+ expect(() => analyticsSubjectFor("webhook", "attempt", bad)).toThrow(
+ /environment id must be a uuid/,
+ );
+ }
+ });
+
+ it("throws on a missing domain or action rather than producing `analytics..`", () => {
+ expect(() => analyticsSubjectFor("", "attempt", ENV)).toThrow(/domain is required/);
+ expect(() => analyticsSubjectFor("webhook", "", ENV)).toThrow(/action is required/);
+ });
+});Bản thân stream được bảo đảm tồn tại theo đúng cách DELIVERIES làm — bằng cách truyền một
hàm ensure cho publisher mà chương outbox đã dựng. Tham số đó vốn đã
tồn tại, và đó là lý do chỗ này chỉ chín dòng chứ không phải một cơ chế mới:
@@ -4,12 +4,14 @@ import {
RetentionPolicy,
StorageType,
type JetStreamClient,
type NatsConnection,
} from "nats";
+import { ALL_ANALYTICS_SUBJECT, ANALYTICS_STREAM } from "@relay/protocol";
+
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
@@ -38,12 +40,19 @@ const SECOND_NS = 1_000_000_000;
* consumer outage is absorbed. Seven days is chosen over the floor for a reason
* the floor does not cover: an outage that starts on a Friday evening is not
* noticed until Monday. The floor protects a process crash; this protects a
* weekend. */
const MAX_AGE_NS = 7 * 24 * 60 * 60 * SECOND_NS;
+/** The analytics stream's own bound. The same seven days as
+ * `EVENTS`, for a different reason: there it absorbs a consumer outage without
+ * losing a tenant's events, here it absorbs an ingester outage without letting
+ * the stream become a database. Named separately so the two can diverge when
+ * Part 4 has an opinion, rather than one changing the other by accident. */
+const ANALYTICS_MAX_AGE_NS = 7 * 24 * 60 * 60 * SECOND_NS;
+
/** An unbounded stream is a full disk with extra steps. The bound turns that
* into a number an operator can watch — and with `discard: old`, hitting it
* loses the OLDEST events rather than refusing new publishes. Refusing
* publishes would take the write path down with the event spine, which is the
* inversion the outbox chapter's exists to prevent. */
const MAX_BYTES = 1024 * 1024 * 1024;
@@ -91,12 +100,56 @@ export async function ensureStream(nc: NatsConnection): Promise<void> {
});
return;
}
await jsm.streams.update(STREAM, { ...existing.config, ...mutable });
}
+/** The ANALYTICS stream (constitution III).
+ *
+ * A THIRD stream rather than a third use of `EVENTS`, and the reasons are all
+ * about the difference between an operational event and an analytical one
+ * (research R4): different volume, different retention, and a Part 4 ingester
+ * that should be able to consume attempt records without also consuming every
+ * message event in the platform.
+ *
+ * Reuses the `ensure` parameter the webhook dispatcher chapter added rather than introducing a
+ * second mechanism — the publisher already knows how to bring one stream into
+ * existence, and "which stream" has been a parameter since a delivery published
+ * to `deliveries.*` came back 503.
+ *
+ * SEVEN DAYS, `discard: old`, and no acknowledgement anywhere: nothing consumes
+ * this stream in this chapter. Seven days is long enough for an ingester to be
+ * down over a long weekend and short enough that the stream does not quietly
+ * become the analytical database it is supposed to feed. At the bound the oldest
+ * analytics is the least interesting, which is the one case where dropping the
+ * old data is the right answer — the opposite choice on `EVENTS` would lose a
+ * tenant's events, and there `discard: old` is a bound on a liability instead. */
+export async function ensureAnalyticsStream(nc: NatsConnection): Promise<void> {
+ const jsm = await nc.jetstreamManager();
+ const mutable = {
+ subjects: [ALL_ANALYTICS_SUBJECT],
+ max_age: ANALYTICS_MAX_AGE_NS,
+ max_bytes: MAX_BYTES,
+ discard: DiscardPolicy.Old,
+ num_replicas: replicaCount(),
+ };
+ const existing = await jsm.streams.info(ANALYTICS_STREAM).catch(() => null);
+ if (existing === null) {
+ await jsm.streams.add({
+ name: ANALYTICS_STREAM,
+ retention: RetentionPolicy.Limits,
+ storage: StorageType.File,
+ ...mutable,
+ });
+ return;
+ }
+ // Retention and storage are immutable on an existing stream — the broker chapter
+ // measured that (its research R1), and the lesson transfers unchanged.
+ await jsm.streams.update(ANALYTICS_STREAM, { ...existing.config, ...mutable });
+}
+
export function createJetStreamPublisher({
url = process.env.RELAY_NATS_URL ?? DEFAULT_NATS_URL,
ensure = ensureStream,
}: {
url?: string;
/** Which stream this publisher is responsible for bringing into existence.Lần publish được phép hỏng, và chương phải nói ra điều đó
FR-WHK-06 nói mọi lần thử giao đều phải được ghi lại.
Hiến pháp III nói các analytical event được phát ra bất đồng bộ và rằng "việc hỏng hay dồn ứ của đường ống phân tích KHÔNG ĐƯỢC ảnh hưởng tới việc giao message, tính sẵn sàng của API, hay việc giao webhook."
Hai điều đó không thể cùng được tối đa hoá, và xung đột này không phải chuyện kỹ thuật vặt. Để ghi lại mọi lần thử mà không mất mát, bản ghi phải chia sẻ một transaction với kết quả — chính outbox pattern mà chương outbox đã dựng, vốn tồn tại để làm cho "trạng thái đã đổi" và "event đã được phát" thành một sự thật nguyên tử. Làm vậy ở đây thì một consumer phân tích bị kẹt sẽ làm dồn ứ một bảng vận hành: outbox đầy lên, relay tụt lại, và một đường ống đo đạc chẳng ai theo dõi bắt đầu giữ row lock trên chính cái bảng quyết định webhook của một khách hàng có đi hay không.
Bảo đảm sự độc lập thì lần publish xảy ra ngoài transaction, nơi một cú crash giữa commit và publish làm mất bản ghi.
Sự độc lập thắng, và lý do là hai kiểu hỏng đó không so sánh được với nhau. Một bản ghi lần thử bị mất là một lỗ hổng trên một dashboard. Một transaction kết quả bị chặn là webhook của một khách hàng ngừng chạy vì một đường ống đo đạc đang ốm — điều mà hiến pháp III gọi thẳng là một thất bại thiết kế.
Vậy nên: chữ "mọi" của FR-WHK-06 là gần đúng, và đoạn văn này là chỗ chương nói ra điều đó chứ không phải một cước chú ba mục sau. Bản ghi lần thử là nỗ lực-tốt-nhất. Một dashboard dựng trên chúng thỉnh thoảng sẽ thiếu một lần thử, và thứ mà đánh đổi đó mua được là một đường giao không thể bị dừng bởi bất cứ thứ gì nằm sau nó.
flowchart LR
disp["dispatcher<br/>gửi, đo, báo cáo"]
subgraph api["api service — người DUY NHẤT ghi Postgres"]
outcome["/internal/dispatch/outcome"]
tx[["MỘT transaction:<br/>state của delivery · chuỗi hỏng<br/>· tắt · thông báo"]]
pub["publishAttempt<br/>SAU khi commit"]
end
pg[("PostgreSQL<br/>vận hành")]
js[("JetStream ANALYTICS<br/>analytics.webhook.attempt.env")]
p4["ingester của Phần 4<br/>CHƯA DỰNG"]
ch[("ClickHouse<br/>CHƯA DỰNG")]
disp -->|"status · latency · error"| outcome
outcome --> tx --> pg
tx -.->|"commit trước"| pub
pub -->|"nhiều nhất một lần<br/>hỏng thì ghi log rồi bỏ"| js
js -.-> p4 -.-> ch
pub -.->|"KHÔNG BAO GIỜ chặn"| outcomeĐây là toàn bộ publisher. Nó là một file nhỏ và toàn bộ chủ đề của nó là một quyết định:
import { webhookAttemptSubject } from "@relay/protocol";
import type { Logger } from "@relay/service-kit";
import type { Publisher } from "../outbox/publisher";
// The attempt record, on its way to the analytical path (FR-001,
// constitution III).
//
// This file is small and its whole subject is one decision, so the decision is
// written here rather than in the chapter alone: THE PUBLISH IS ALLOWED TO FAIL,
// and failing must cost the delivery nothing.
//
// Constitution III: "Analytical events are emitted asynchronously — never
// synchronously on the request path. Failure or backlog of the analytical
// pipeline MUST NOT affect message delivery, API availability, or webhook
// dispatch." FR-WHK-06 says every delivery attempt shall be recorded. Those two
// cannot both be maximised, and research R5 chose which one wins:
//
// * record every attempt without loss → the record shares a transaction with
// the outcome (the outbox chapter's), and then a stalled analytics consumer backs up
// an operational table;
// * guarantee independence → the publish happens after the commit, outside it,
// and a crash in that gap loses the record.
//
// Independence wins, because the two costs are not equal. A lost attempt record
// is a gap in a dashboard. A blocked outcome transaction is a customer's webhooks
// stopping because a metering pipeline is unwell — which constitution III names
// as a design failure in as many words.
//
// So "every attempt" is APPROXIMATE, and the chapter says so in the paragraph
// that introduces the feature rather than in a footnote.
/** The publisher that reaches the ANALYTICS stream, as a DI token.
*
* A SECOND publisher rather than a second use of the first. Each
* `createJetStreamPublisher` ensures exactly one stream, and the webhook dispatcher chapter already
* learned what happens when a publisher is pointed at a stream it did not create:
* every publish comes back 503, which is a confusing way to discover that
* JetStream does not create streams on demand. Declared here, beside the function
* that uses it, so the module wiring reads as configuration rather than as
* knowledge. */
export const ANALYTICS_PUBLISHER = Symbol("ANALYTICS_PUBLISHER");
/** What one attempt looked like. Assembled by the caller from the outcome it has
* just recorded, because the api is the only party holding all of it: the
* dispatcher has the status and the latency but not the environment, and nothing
* but the api knows what the outcome WAS. */
export interface AttemptRecord {
deliveryId: string;
endpointId: string;
environmentId: string;
eventId: string;
attempt: number;
/** Absent when nothing answered. A timeout has no status, and inventing one —
* 0, 599 — would make every dashboard built on this lie in the same direction. */
status?: number;
/** Present when there was no status. Already capped at 2000 characters by the
* seam's schema, so nothing is truncated here. */
error?: string;
latencyMs: number;
outcome: "delivered" | "rescheduled" | "dead_lettered";
attemptedAt: Date;
}
/** The wire shape, in `contracts/attempts.md`. Snake case because it leaves the
* platform: a consumer written in another language reads this, and Part 4's
* ingester is the first of them. */
interface AttemptEvent {
delivery_id: string;
endpoint_id: string;
environment_id: string;
event_id: string;
attempt: number;
attempted_at: string;
status?: number;
error?: string;
latency_ms: number;
outcome: string;
}
/** IDENTIFIERS, STATUSES AND DURATIONS ONLY (FR-004, NFR-SEC-06).
*
* The record is built by naming every field rather than by spreading the input,
* and that is the point. A spread would carry whatever a future caller happened
* to put on the record — a payload, a decrypted secret, a header — onto a stream
* with seven-day retention that nothing in this platform reads yet. An allow-list
* fails closed when somebody adds a field; a spread fails open. */
function shape(record: AttemptRecord): AttemptEvent {
return {
delivery_id: record.deliveryId,
endpoint_id: record.endpointId,
environment_id: record.environmentId,
event_id: record.eventId,
attempt: record.attempt,
attempted_at: record.attemptedAt.toISOString(),
// `exactOptionalPropertyTypes` is on, so these are spread in rather than
// assigned: an explicit `undefined` is not the same as an absent key, and the
// difference is the whole meaning of "nothing answered".
...(record.status !== undefined ? { status: record.status } : {}),
...(record.error !== undefined ? { error: record.error } : {}),
latency_ms: record.latencyMs,
outcome: record.outcome,
};
}
/** Publish one attempt record. Never throws.
*
* Call this AFTER the outcome transaction has committed, never inside it. Inside,
* a broker that is slow makes an operational transaction slow and holds a row
* lock while it waits, which is the coupling this whole design exists to avoid —
* and the sabotage battery moves this call inside the transaction to prove the
* suite would notice.
*
* The failure is swallowed HERE rather than at each call site, so no caller can
* accidentally turn an analytics outage into a delivery outage by forgetting a
* `catch`. A caller that wanted to know would have to ask, and none does. */
export async function publishAttempt(
publisher: Publisher,
logger: Logger,
record: AttemptRecord,
): Promise<void> {
try {
await publisher.publish({
subject: webhookAttemptSubject(record.environmentId),
// The broker's deduplication key, and it is `{delivery}:{attempt}` for the
// reason the webhook dispatcher chapter learned the hard way: the delivery id alone is stable
// across all seven attempts, and using it collapsed every retry into the
// first attempt's message. That bug cost the platform its entire retry
// schedule and was found by a walk rather than by a test. Here the same
// mistake would silently discard attempts 2 through 7 from every dashboard.
id: `${record.deliveryId}:${record.attempt}`,
payload: shape(record),
});
} catch (error) {
// One line, no payload, no secret — and no rethrow. The delivery already
// happened and the outcome is already committed; there is nothing this
// failure can usefully undo, and plenty it could break by trying.
logger.log("error", "analytics.attempt_publish_failed", {
delivery_id: record.deliveryId,
attempt: record.attempt,
error: String(error),
});
}
}Hai thứ trong đó gánh nhiều hơn kích cỡ của chúng.
Bản ghi được dựng bằng cách gọi tên từng field, không phải bằng cách spread input. Một lần spread sẽ mang theo bất cứ thứ gì một bên gọi tương lai tình cờ gắn vào — một payload, một bí mật đã giải mã, một header — lên một stream lưu bảy ngày mà chẳng gì trong nền tảng này hiện đang đọc. Một allow-list hỏng theo hướng đóng khi ai đó thêm một field. Một lần spread hỏng theo hướng mở, lặng lẽ, và cái hỏng đó là nội dung message của một khách hàng nằm trong một kho phân tích đã được hứa là sẽ không bao giờ giữ thứ đó.
Khoá chống trùng là {delivery}:{attempt}, và đó là bug của chương webhook dispatcher
tới trong code mới. Riêng delivery id thì ổn định qua cả bảy
lần thử; chương webhook dispatcher đã dùng nó làm msgID của broker cho lần publish delivery, và
JetStream gộp mọi lần thử lại thành message của lần thử đầu tiên. Lần publish
báo thành công, chẳng gì tới được dispatcher, và mọi webhook hỏng đều được
thử lại đúng không lần nào cho tới khi một lượt đi bộ đi tìm lần thử số 2 và thấy
chẳng gì được gửi cả. Cùng sai lầm đó ở đây sẽ lặng lẽ vứt bỏ lần thử thứ hai
tới thứ bảy khỏi mọi dashboard từng dựng trên stream này.
Các unit test giữ cả hai tính chất, và assertion thú vị là cái nói về các field thừa:
import { describe, expect, it } from "vitest";
import { createLogger, type Logger } from "@relay/service-kit";
import type { PublishedMessage, Publisher } from "../outbox/publisher";
import { publishAttempt, type AttemptRecord } from "./analytics";
// The attempt record's SHAPE and its SILENCE.
//
// Two claims are worth a unit test, and both are about what the payload is not.
// FR-004 says an attempt record carries identifiers, statuses and durations and
// nothing else; contract invariant 4 says a publish failure changes nothing for
// the caller. Neither needs a broker to check, and a test that needed one would be
// a test nobody runs while writing the code.
/** Captures what was handed to the broker, without being one. */
function recorder(): Publisher & { sent: PublishedMessage[] } {
const sent: PublishedMessage[] = [];
return {
sent,
async publish(message) {
sent.push(message);
},
async close() {},
};
}
function exploding(error = new Error("nats: no responders")): Publisher {
return {
async publish() {
throw error;
},
async close() {},
};
}
/** The sink receives one JSON STRING per line, so it is parsed back rather than
* inspected as an object — which also proves the fields survive serialisation,
* the only form anybody reads a log line in. */
function captured(): { logger: Logger; lines: Record<string, unknown>[] } {
const lines: Record<string, unknown>[] = [];
const logger = createLogger("analytics-test", (line) => {
lines.push(JSON.parse(String(line)) as Record<string, unknown>);
});
return { logger, lines };
}
const ENV = "9f3c1e7a-0b2d-4c8e-9a1f-6d5b4c3a2e10";
const base: AttemptRecord = {
deliveryId: "1c2d3e4f-0000-4000-8000-000000000001",
endpointId: "1c2d3e4f-0000-4000-8000-000000000002",
environmentId: ENV,
eventId: "1c2d3e4f-0000-4000-8000-000000000003",
attempt: 3,
status: 503,
latencyMs: 214,
outcome: "rescheduled",
attemptedAt: new Date("2026-08-18T09:14:22.481Z"),
};
const silent = createLogger("analytics-test", () => {});
describe("publishAttempt shapes the event contracts/attempts.md describes", () => {
it("carries the four identifiers, the attempt, the status, the latency and the outcome", async () => {
const publisher = recorder();
await publishAttempt(publisher, silent, base);
expect(publisher.sent).toHaveLength(1);
expect(publisher.sent[0]!.payload).toEqual({
delivery_id: base.deliveryId,
endpoint_id: base.endpointId,
environment_id: ENV,
event_id: base.eventId,
attempt: 3,
attempted_at: "2026-08-18T09:14:22.481Z",
status: 503,
latency_ms: 214,
outcome: "rescheduled",
});
});
it("puts the environment on the subject as well as in the payload", async () => {
// Invariant 2. The subject is how a future consumer filters by tenant, and a
// subject disagreeing with its payload would route one environment's records
// under another's filter.
const publisher = recorder();
await publishAttempt(publisher, silent, base);
const { subject, payload } = publisher.sent[0]!;
expect(subject).toBe(`analytics.webhook.attempt.${ENV}`);
expect(subject.endsWith((payload as { environment_id: string }).environment_id)).toBe(
true,
);
});
it("deduplicates on the delivery AND the attempt, not the delivery alone", async () => {
// The webhook dispatcher chapter's bug, in the one place it could recur. The delivery id is
// stable across all seven attempts, so a `msgID` of the delivery alone would
// let the broker collapse attempts 2 through 7 into the first — and every
// dashboard built on this stream would show one attempt per failing delivery
// for ever, with no error anywhere.
const publisher = recorder();
await publishAttempt(publisher, silent, { ...base, attempt: 1 });
await publishAttempt(publisher, silent, { ...base, attempt: 2 });
const ids = publisher.sent.map((m) => m.id);
expect(ids).toEqual([`${base.deliveryId}:1`, `${base.deliveryId}:2`]);
expect(new Set(ids).size).toBe(2);
});
it("omits `status` entirely when nothing answered, rather than sending a zero", async () => {
// A timeout has no status. Sending 0 or 599 would be a number a dashboard
// could average, and the average would be a fiction.
const publisher = recorder();
// Built by omission rather than by setting `status: undefined`, because
// `exactOptionalPropertyTypes` makes those two different things and only one of
// them is what a timeout looks like.
const timedOut: AttemptRecord = { ...base };
delete timedOut.status;
await publishAttempt(publisher, silent, {
...timedOut,
error: "timeout after 10000ms",
latencyMs: 10_000,
});
const payload = publisher.sent[0]!.payload as Record<string, unknown>;
expect("status" in payload).toBe(false);
expect(payload["error"]).toBe("timeout after 10000ms");
// The latency of a timeout IS the timeout, and it is still reported: "how
// long did we wait" is a real question even when "what did they say" has no
// answer.
expect(payload["latency_ms"]).toBe(10_000);
});
it("omits `error` when there was a status", async () => {
const publisher = recorder();
await publishAttempt(publisher, silent, base);
expect("error" in (publisher.sent[0]!.payload as object)).toBe(false);
});
it("carries no payload, secret, signature or header — whatever it is handed", async () => {
// FR-004 and SC-006, and the reason this asserts on EXTRA fields rather than
// on the ones it expects: the risk is not that a field goes missing, it is
// that one is added. `shape` names every key it copies, so a caller that
// decorates the record with a secret cannot leak it onto a stream with
// seven-day retention. A spread would have.
const publisher = recorder();
await publishAttempt(publisher, silent, {
...base,
// Everything a careless future caller might attach. None of it is on the
// interface, hence the cast — a compile error is the FIRST line of defence
// and this test is the second.
...({
payload: { text: "B2, north ramp" },
secret: "whsec_do_not_publish_this",
signature: "v1=deadbeef",
headers: { authorization: "Bearer rk_live_xxx" },
url: "https://customer.example/hook",
} as unknown as AttemptRecord),
});
const payload = publisher.sent[0]!.payload as Record<string, unknown>;
expect(Object.keys(payload).sort()).toEqual([
"attempt",
"attempted_at",
"delivery_id",
"endpoint_id",
"environment_id",
"event_id",
"latency_ms",
"outcome",
"status",
]);
const serialised = JSON.stringify(publisher.sent[0]);
for (const forbidden of [
"B2, north ramp",
"whsec_do_not_publish_this",
"v1=deadbeef",
"Bearer",
"customer.example",
]) {
expect(serialised).not.toContain(forbidden);
}
});
});
describe("publishAttempt swallows its own failure (contract invariant 4)", () => {
it("does not throw when the broker refuses", async () => {
// THE ONE THAT MATTERS. The caller has already committed an outcome, and the
// dispatcher is waiting for an answer. If this threw, an analytics outage
// would become a 500 on the dispatch seam, the dispatcher would not
// acknowledge, and the delivery would be posted to the customer again — a
// duplicate webhook caused by a metering pipeline. Constitution III names
// that inversion as a design failure.
const { logger, lines } = captured();
await expect(
publishAttempt(exploding(), logger, base),
).resolves.toBeUndefined();
expect(lines).toHaveLength(1);
expect(lines[0]!["msg"]).toBe("analytics.attempt_publish_failed");
expect(lines[0]!["delivery_id"]).toBe(base.deliveryId);
expect(lines[0]!["attempt"]).toBe(3);
});
it("logs the failure without the payload it was carrying", async () => {
const { logger, lines } = captured();
await publishAttempt(exploding(), logger, {
...base,
...({ payload: { text: "B2, north ramp" } } as unknown as AttemptRecord),
});
// A log line about a failed publish is the most tempting place in the
// codebase to dump the thing that failed to publish.
expect(JSON.stringify(lines)).not.toContain("B2, north ramp");
});
});Lần publish đi vào đâu, chính xác
Một dòng trong handler kết quả, và mọi thứ về nó nằm ở vị trí:
@@ -14,22 +14,27 @@ import {
type InternalDeliveryOutcomeRequest,
type InternalDeliveryOutcomeResponse,
type InternalExpandRequest,
type InternalExpandResponse,
} from "@relay/protocol";
+import type { Logger } from "@relay/service-kit";
+
import { Accepts, CredentialGuard } from "../auth/credential.guard";
import type { Db } from "../db/client";
import {
DeliveryNotFoundError,
deliveryMaterial,
expandEventToDeliveries,
recordAttemptOutcome,
replayDeadLetter,
} from "../db/repository";
import { ZodValidationPipe } from "../messages/zod-validation.pipe";
+import { LOGGER } from "../logger";
+import { ANALYTICS_PUBLISHER, publishAttempt } from "../webhooks/analytics";
+import type { Publisher } from "../outbox/publisher";
// The dispatcher's only road to state (constitution IV).
//
// "Only the API service writes to PostgreSQL… Other services obtain writes and
// backfill reads via the API service's internal endpoints." The dispatcher owns
// no database, so everything it needs is here — and that constraint is not a
@@ -42,13 +47,17 @@ import { ZodValidationPipe } from "../messages/zod-validation.pipe";
// either be useless to the dispatcher or would have to ignore the scope — and
// ignoring a tenant scope is the shape a cross-tenant hole takes.
@Controller("internal/dispatch")
@UseGuards(CredentialGuard)
@Accepts("platform")
export class DispatchController {
- constructor(@Inject("DB") private readonly db: Db) {}
+ constructor(
+ @Inject("DB") private readonly db: Db,
+ @Inject(ANALYTICS_PUBLISHER) private readonly analytics: Publisher,
+ @Inject(LOGGER) private readonly logger: Logger,
+ ) {}
/** One event becomes one delivery per matching endpoint — claimed, so it
* happens exactly once however often the broker redelivers (research R2). */
@Post("expand")
@HttpCode(200)
async expand(
@@ -97,13 +106,49 @@ export class DispatchController {
// a response at all.
const result = await recordAttemptOutcome(this.db, {
deliveryId: body.delivery_id,
attempt: body.attempt,
...(body.status !== undefined ? { status: body.status } : {}),
...(body.error !== undefined ? { error: body.error } : {}),
+ latencyMs: body.latency_ms,
});
+
+ // THE ATTEMPT RECORD, and everything about this call's POSITION is the
+ // decision (research R5, constitution III).
+ //
+ // AFTER the transaction, not inside it: `recordAttemptOutcome` has already
+ // returned, so its row locks are released and its work is durable. A publish
+ // inside would hold a lock on the delivery while waiting on a broker, and a
+ // slow analytics path would become a slow delivery path — the coupling
+ // constitution III exists to forbid. `publishAttempt` cannot throw, so this
+ // line cannot change the answer below it either.
+ //
+ // BEFORE the response rather than after it, because there is no "after":
+ // returning ends the request. The cost is that the dispatcher waits for one
+ // publish, which is why that publish has no retry and no timeout of its own
+ // beyond the client's.
+ //
+ // Only when something was RECORDED. A repeat report — the dispatcher
+ // crashed between reporting and acknowledging — changed no row, and
+ // publishing for it would put an attempt on the stream that never happened
+ // (contract invariant 1).
+ if (result.recorded) {
+ await publishAttempt(this.analytics, this.logger, {
+ deliveryId: body.delivery_id,
+ endpointId: result.endpointId,
+ environmentId: result.environmentId,
+ eventId: result.eventId,
+ attempt: result.attempt,
+ ...(body.status !== undefined ? { status: body.status } : {}),
+ ...(body.error !== undefined ? { error: body.error } : {}),
+ latencyMs: body.latency_ms,
+ outcome: result.outcome,
+ attemptedAt: new Date(),
+ });
+ }
+
return {
outcome: result.outcome,
...(result.nextAttemptAt
? { next_attempt_at: result.nextAttemptAt.toISOString() }
: {}),
};Sau transaction, nên không row lock nào bị giữ trong lúc hỏi một broker. Chỉ khi có gì đó thực sự được ghi lại, điều này cần một đoạn riêng.
recordAttemptOutcome đẳng xâm trên (delivery_id, attempt). Dispatcher
post, rồi báo cáo, rồi acknowledge — nên một cú crash ở khe cuối đó khiến một
báo cáo thứ hai thành chuyện bình thường chứ không phải ngoại lệ, và api trả lời nó bằng
quyết định nó đã ra lần đầu và không đổi dòng nào. Publish vào lần phát lại đó
sẽ đặt hai attempt event lên stream cho một lần thử. Chẳng gì trên
đường phân tích chống trùng, nên một dashboard sẽ hiện một lần thử lại chưa từng
xảy ra. Vì thế repository trả về việc nó có thật sự ghi gì không,
và lần publish treo vào đó.
Phần nối dây module là nơi publisher thứ hai xuất hiện. Mỗi
createJetStreamPublisher bảo đảm đúng một stream, nên đường phân tích nhận
cái của riêng nó chứ không mượn cái của deliveries:
@@ -1,11 +1,18 @@
import { Module, Scope } from "@nestjs/common";
import { MessagesModule } from "../messages/messages.module";
import { AuthModule } from "../auth/auth.module";
import { createDb, createPool, type Db } from "../db/client";
+import { LOGGER, apiLogger } from "../logger";
+import {
+ createJetStreamPublisher,
+ ensureAnalyticsStream,
+} from "../outbox/jetstream.publisher";
+import type { Publisher } from "../outbox/publisher";
+import { ANALYTICS_PUBLISHER } from "../webhooks/analytics";
import { BackfillController } from "./backfill.controller";
import { InternalController } from "./internal.controller";
import { DispatchController } from "./dispatch.controller";
import { MembershipsController } from "./memberships.controller";
import { SessionController } from "./session.controller";
@@ -34,9 +41,32 @@ import { SessionController } from "./session.controller";
providers: [
{
provide: "DB",
useFactory: (): Db => createDb(createPool()),
scope: Scope.DEFAULT,
},
+ // The attempt record's way onto the analytical path. Its own
+ // publisher, ensuring its own stream — see ANALYTICS_PUBLISHER's note.
+ //
+ // The connection is LAZY, as every broker client in this workspace is, and
+ // here that property is load-bearing rather than tidy: the api must accept
+ // outcome reports with the broker unreachable. If this connected eagerly, a
+ // dead broker would take the dispatch seam down with it, which is the exact
+ // inversion constitution III forbids.
+ {
+ provide: ANALYTICS_PUBLISHER,
+ useFactory: (): Publisher =>
+ createJetStreamPublisher({ ensure: ensureAnalyticsStream }),
+ scope: Scope.DEFAULT,
+ },
+ // `AppModule` provides this too, but a provider is visible to the module that
+ // declares it and to nothing it imports — so the controllers here would have
+ // nothing to inject. Same factory, so the service name in a log line stays
+ // `api` and the log stream does not sprout a second identity for one line.
+ {
+ provide: LOGGER,
+ useFactory: apiLogger,
+ scope: Scope.DEFAULT,
+ },
],
})
export class InternalModule {}Giờ tới quyết định — và một phép đo đã thay đổi nó
FR-WHK-07: một endpoint hỏng liên tục hơn một tiếng thì bị tắt.
Chỗ hiển nhiên để kiểm là nơi các lần hỏng vốn đã được ghi lại —
bên trong recordAttemptOutcome, trong cùng transaction, đúng cách chương broker đặt
phần giành và phần tác động cạnh nhau. Không vòng lặp mới, không trạng thái mới để poll, không
thứ mới để triển khai. Nó là thiết kế nhỏ hơn theo mọi thước đo có được trước khi
đo bất cứ thứ gì.
Vậy hãy đo nó. Lịch thử lại của chương webhook dispatcher là
RETRY_TIERS_MS = [0, 1s, 5s, 30s, 5min, 30min, 2h]. Với một delivery hỏng
mọi lần, các lần thử rơi vào đây:
attempt 1 at +0h00m00s (within the 1h window)
attempt 2 at +0h00m01s (within the 1h window)
attempt 3 at +0h00m06s (within the 1h window)
attempt 4 at +0h00m36s (within the 1h window)
attempt 5 at +0h05m36s (within the 1h window)
attempt 6 at +0h35m36s (within the 1h window)
attempt 7 at +2h35m36sgantt
title Một delivery hỏng, đo theo quy tắc một giờ
dateFormat X
axisFormat %H:%M
section Các lần thử
1 · +0s :milestone, 0, 0
2 · +1s :milestone, 1, 0
3 · +6s :milestone, 6, 0
4 · +36s :milestone, 36, 0
5 · +5m36s :milestone, 336, 0
6 · +35m36s :milestone, 2136, 0
7 · +2h35m36s :milestone, 9336, 0
section Quy tắc
một giờ trôi qua ở đây :crit, milestone, 3600, 0
section Khoảng trống
không có gì xảy ra ở mốc một giờ :active, 2136, 9336Sáu lần thử rơi vào trong tiếng đầu tiên. Lần thứ bảy rơi vào 2h35m36s, và nó là lần cuối — sau nó delivery vào dead letter.
Chẳng gì xảy ra ở mốc một tiếng. Một phép kiểm chỉ chạy khi một kết quả được ghi lại thì không thể kích hoạt ở ngưỡng, bởi không kết quả nào được ghi ở đâu gần đó. Với một delivery hỏng đơn lẻ, event tiếp theo sau 35m36s là ở 2h35m36s, nên endpoint bị tắt muộn chín mươi lăm phút.
Và rồi phần biến một vấn đề độ trễ thành một vấn đề đúng đắn: sau lần thử thứ bảy thì delivery chết. Nếu không event nào nữa tới cho environment đó, không kết quả nào được ghi lại nữa và endpoint không bao giờ bị tắt. Nó ngồi đó bật và hỏng mãi mãi — đúng cái trạng thái mà FR-WHK-07 tồn tại để chấm dứt.
Một environment bận rộn che hoàn toàn chuyện này. Event mới cứ tới, các lần thử cứ xảy ra, phép kiểm kích hoạt đủ gần mốc một tiếng để chẳng ai nhận ra. Cái endpoint hỏng lặng lẽ là cái ít lưu lượng: khách hàng lưu lượng thấp, cũng chính là khách hàng ít có khả năng đang theo dõi nhất.
Vậy thì hai cơ chế kích hoạt. Khi một kết quả được ghi lại, cái này bắt mọi endpoint vẫn còn nhận lần thử. Và một lượt quét, cái này bắt phần còn lại.
flowchart TB
out["một outcome được ghi<br/>recordAttemptOutcome"]
run{"hỏng?"}
clear["chuỗi hỏng bị xoá<br/>cả hai cột null"]
open["chuỗi hỏng mở ra hoặc dài thêm<br/>started_at · attempts + 1"]
check{"quá 1h VÀ >= 5 lần thử?"}
sweep["vòng lặp của relay<br/>một truy vấn mỗi lượt drain"]
find{"có endpoint nào có chuỗi hỏng<br/>đã vượt quá một giờ?"}
disable["tắt, MỘT LẦN<br/>UPDATE ... WHERE enabled = true"]
note["không dòng nào được cập nhật:<br/>ai đó đã tới trước"]
notify[("webhook_disable_notifications<br/>delivered_at NULL")]
out --> run
run -- "2xx" --> clear
run -- "bất kỳ thứ gì khác" --> open --> check
check -- không --> wait["không có gì để làm"]
check -- có --> disable
sweep --> find
find -- không --> wait
find -- có --> disable
disable -- "1 dòng" --> notify
disable -- "0 dòng" --> noteChuỗi hỏng là hai cột
Trạng thái mà auto-disable đọc không phải attempt stream. Nó là hai cột trên endpoint, và giữ chúng riêng biệt là thứ làm cho một lần tắt độc lập với sức khoẻ của một broker.
@@ -626,29 +626,74 @@ export const webhookEndpoints = pgTable(
eventTypes: jsonb("event_types").notNull(),
secretCiphertext: text("secret_ciphertext").notNull(),
// Non-null only during a rotation window: both secrets sign, so a recipient
// accepting either is correct throughout (contracts/webhooks.md §Rotation).
secretPreviousCiphertext: text("secret_previous_ciphertext"),
secretRotatedAt: timestamp("secret_rotated_at", { withTimezone: true }),
- // An owner can pause an endpoint. What disables one AUTOMATICALLY after
- // continuous failure is FR-WHK-07's, in the follow-on chapter — the column
- // exists now so that chapter adds a rule rather than a migration.
+ // An owner can pause an endpoint. This chapter is the follow-on the webhook
+ // dispatcher chapter named here, and the prediction held: automatic disablement added a rule and
+ // four columns, and did not have to change this one.
enabled: boolean("enabled").notNull().default(true),
+ // THE FAILURE RUN (FR-006). The current unbroken sequence of
+ // failures, and nothing more — history is the attempt event stream, not this.
+ //
+ // Two columns rather than a table, because a run is one row per endpoint BY
+ // DEFINITION: it is the *current* run, and there is only ever one. A table
+ // would need a "which row is current" rule, and that rule is the bug
+ // (research R2). Both null when the endpoint is healthy, and any delivered
+ // outcome sets them back to null.
+ failureRunStartedAt: timestamp("failure_run_started_at", {
+ withTimezone: true,
+ }),
+ failureRunAttempts: integer("failure_run_attempts"),
+ // WHO SWITCHED IT OFF, which `enabled` alone cannot say. Auto-disable sets
+ // `enabled = false` AND stamps these; a customer pausing their own endpoint
+ // sets `enabled = false` and leaves them null. That asymmetry IS FR-009: a
+ // customer can tell a platform disablement from their own by whether the
+ // platform left its fingerprints.
+ disabledAt: timestamp("disabled_at", { withTimezone: true }),
+ disabledReason: text("disabled_reason"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
// DELETION IS SOFT, and this column is why. The credentials chapter made the same
// choice for `api_keys.revokedAt` and gave the reason: "a deleted row loses
// the record of what once had access". Here the stakes are higher — a hard
// delete would have to cascade, and cascading would erase the customer's
// dead letters, which FR-WHK-04 says to retain for seven days. So a deleted
// endpoint stops receiving deliveries and stops appearing in listings, and
// the record of what it was survives its deletion.
deletedAt: timestamp("deleted_at", { withTimezone: true }),
},
- (t) => [index("webhook_endpoints_environment_idx").on(t.environmentId)],
+ (t) => [
+ index("webhook_endpoints_environment_idx").on(t.environmentId),
+ // The SWEEP's only query (research R1): enabled endpoints with
+ // an open failure run, so the one that has outrun the hour can be found
+ // without reading every endpoint in the platform. Partial, for the reason
+ // the outbox chapter's index and the webhook dispatcher chapter's delivery index are partial — a healthy
+ // endpoint has a null run and costs nothing to keep out of it.
+ index("webhook_endpoints_failure_run_idx")
+ .on(t.failureRunStartedAt)
+ .where(sql`${t.enabled} AND ${t.failureRunStartedAt} IS NOT NULL`),
+ // The two halves of a run travel together, and the database says so rather
+ // than four call sites remembering to. A run with a start and no count, or a
+ // count and no start, is not a state this platform has a meaning for — and
+ // `shouldDisable` would read the missing half as zero, which is the shape of
+ // a bug that disables nothing and looks like a policy decision.
+ check(
+ "webhook_endpoints_failure_run_check",
+ sql`(${t.failureRunStartedAt} IS NULL) = (${t.failureRunAttempts} IS NULL)`,
+ ),
+ // Same argument for the disable stamp. FR-009 rests on `disabled_at` being
+ // the platform's fingerprint, so a reason without a timestamp would make the
+ // one distinction a customer needs unreadable.
+ check(
+ "webhook_endpoints_disabled_check",
+ sql`(${t.disabledAt} IS NULL) = (${t.disabledReason} IS NULL)`,
+ ),
+ ],
);
// The retry schedule — and it is the outbox chapter's with one more column.
//
// DECISION (research R1, MEASURED): the obvious implementation is
// to let the broker hold the delay between attempts. It was measured against a
@@ -686,12 +731,40 @@ export const webhookDeliveries = pgTable(
.notNull()
.defaultNow(),
// Set when the relay publishes it; cleared when the next attempt is
// scheduled. The relay's claim, in the shape `outbox.published_at` has.
dispatchedAt: timestamp("dispatched_at", { withTimezone: true }),
state: text("state").notNull().default("pending"),
+ // WHAT THE ENDPOINT ACTUALLY SAID on the most recent attempt.
+ //
+ // The webhook dispatcher chapter recorded an attempt by MOVING the delivery — state, attempt,
+ // next_attempt_at — and threw the answer away, which was enough while the
+ // only reader was the retry schedule. Two things here need it back, and
+ // neither can get it from the attempt event: that publish is at-most-once by
+ // design (research R5).
+ //
+ // * the test event reports what the endpoint answered to a caller who is
+ // WAITING, and the attempt happens in the dispatcher's process (FR-016);
+ // * the sweep writes a disablement's last observed error, and the sweep
+ // fires precisely when no outcome is arriving — that is the whole of
+ // research R1. Without this it could only write null, and "disabled,
+ // cause unknown" is the notification a support engineer receives.
+ //
+ // `lastLatencyMs` is the third thing in this chapter to pick `latency_ms` up
+ // off the floor: it has crossed the internal seam on every attempt since the
+ // webhook dispatcher chapter and been discarded (research R6).
+ lastStatus: integer("last_status"),
+ lastError: text("last_error"),
+ lastLatencyMs: integer("last_latency_ms"),
+ // A TEST EVENT's delivery (FR-013). Three decisions branch on
+ // it — no retry schedule, no failure-run update, and delivery even to a
+ // disabled endpoint — which is why it is a column and not a `payload->>'type'`
+ // comparison against a customer-visible document. It also keeps the marker
+ // for the RECIPIENT (the envelope's `type` and `test`) separate from the
+ // marker for the PLATFORM, two audiences that happen to agree today.
+ synthetic: boolean("synthetic").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => [
// One event produces at most one delivery per endpoint. This is what makes
@@ -738,6 +811,66 @@ export const webhookDeadLetters = pgTable(
deadLetteredAt: timestamp("dead_lettered_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => [index("webhook_dead_letters_environment_idx").on(t.environmentId)],
);
+
+// DECISION (FR-011, research R7): one row per automatic
+// disablement — an OUTBOUND OBLIGATION the platform has not yet met.
+//
+// `deliveredAt` is the honest column, and it exists in this chapter solely in
+// order to be null. FR-WHK-07 asks for the endpoint to be disabled "and the
+// organisation notified by email", and this platform has no email transport of
+// any kind. A later chapter needs the same transport for quotas, so building one
+// here would mean building it for its second consumer first. (Named rather than
+// numbered: see the note on the dead-letter table above.)
+//
+// A schema that recorded only the disablement would let a future reader believe
+// the requirement was finished. This one says, in a column, which half is
+// missing.
+//
+// Why a table rather than more columns on the endpoint: the endpoint gains
+// `disabledAt` and `disabledReason` regardless, because FR-009 needs a customer
+// to tell a platform disablement from their own. The notification is a different
+// kind of thing — a record with a lifecycle, which is what `deliveredAt` makes
+// visible.
+export const webhookDisableNotifications = pgTable(
+ "webhook_disable_notifications",
+ {
+ id: uuid("id").primaryKey(),
+ environmentId: uuid("environment_id")
+ .notNull()
+ .references(() => environments.id),
+ // DENORMALISED on purpose. The joins are available —
+ // environments.application_id → applications.organisation_id — so storing it
+ // looks redundant. It is stored because this row records an obligation AS IT
+ // STOOD when the endpoint was disabled, and an application moving between
+ // organisations later must not silently retarget a notification that was
+ // already owed to somebody else.
+ organisationId: uuid("organisation_id")
+ .notNull()
+ .references(() => organisations.id),
+ endpointId: uuid("endpoint_id")
+ .notNull()
+ .references(() => webhookEndpoints.id),
+ disabledAt: timestamp("disabled_at", { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+ // The window that triggered it, copied rather than referenced: the endpoint's
+ // own run columns are cleared the moment a customer re-enables it, and a
+ // notification that lost its evidence on re-enablement would be unanswerable
+ // by the time anybody read it.
+ runStartedAt: timestamp("run_started_at", { withTimezone: true }).notNull(),
+ runAttempts: integer("run_attempts").notNull(),
+ lastStatus: integer("last_status"),
+ lastError: text("last_error"),
+ // NULL THROUGHOUT THIS CHAPTER. Set by whatever chapter builds a transport.
+ deliveredAt: timestamp("delivered_at", { withTimezone: true }),
+ },
+ (t) => [
+ // Nothing beyond the primary key and the tenant. Volume is one row per
+ // endpoint per outage, so an index for any other access pattern would be
+ // guessing at a query nobody has written.
+ index("webhook_disable_notifications_environment_idx").on(t.environmentId),
+ ],
+);Hai cột chứ không phải một bảng, bởi một chuỗi là một dòng cho mỗi endpoint theo định nghĩa: nó là chuỗi hiện tại, và chỉ bao giờ có một. Một cái bảng sẽ cần một quy tắc xem dòng nào là hiện tại, và quy tắc đó chính là cái bug.
Cả hai đều null khi khoẻ mạnh. Bất kỳ kết quả đã giao nào cũng xoá chúng, và đó là thứ làm cho chính sách rộng lượng với một khách hàng chập chờn — một endpoint thành công một lần mỗi tiếng thì không bao giờ bị tắt. Đó là một lựa chọn cố ý về việc cái hỏng nào tệ hơn. Một nền tảng tắt các endpoint thỉnh thoảng vẫn chạy thì tệ hơn một nền tảng cứ tiếp tục thử các endpoint không bao giờ chạy.
disabled_at là dấu vân tay. Riêng enabled: false không nói ai
đã làm điều đó. Một khách hàng tự tạm dừng endpoint của mình sẽ để disabled_at null;
nền tảng thì đóng dấu vào đó. Sự bất đối xứng đó là toàn bộ FR-WHK-07, và nó là khác biệt
giữa một ticket hỗ trợ bắt đầu bằng một câu hỏi và một ticket bắt đầu bằng một
câu trả lời.
Migration được review bằng tay, như ADR-16 đòi hỏi, và phần review được viết thẳng vào file chứ không phải làm rồi quên.
Nó là 0011 còn bộ sinh đề nghị 0007 — chương thứ ba liên tiếp nói điều này, và
lý do không đổi: drizzle-kit đánh số theo số snapshot của riêng nó,
các migration viết tay sau 0005 chẳng có snapshot nào, và migrate.ts đọc thư mục
rồi sắp theo tên file. Thứ mà sự lặp lại này đáng giá là hình dạng của độ lệch chứ không phải
phép tính: mọi file được sinh ra mà cuốn sách này thêm từ đây trở đi sẽ đề nghị một số thấp hơn
bốn đơn vị so với số nó phải lấy, cho tới khi ai đó hoặc sinh lại các snapshot hoặc
khai tử cái journal mà runner vốn đã phớt lờ.
-- The failure run, automatic disablement, and its notification.
--
-- NUMBERED 0011 AND GENERATED AS 0007, the same drift `0010_webhooks.sql` records one
-- migration earlier and `0009_message_edits.sql` two before that. `drizzle-kit` numbers
-- from its snapshot count and this directory's hand-written migrations outrun it;
-- `migrate.ts` reads the DIRECTORY and sorts by filename. The snapshot keeps its own
-- number because its lineage is right — 0006 plus this one table.
--
-- REVIEW DISPOSITION: drizzle-kit generated this from schema.ts and it was read
-- line by line before being applied (the ADR-16 workflow, and chapter 2.1's rule
-- after a generated migration was once applied unread). Nothing was rewritten;
-- two CHECK constraints were added to schema.ts and the file regenerated. Six
-- things were checked rather than assumed:
--
-- * EVERY NEW COLUMN ON webhook_endpoints IS NULLABLE WITH NO DEFAULT, and that
-- is the single most important line in this file. A default of `now()` on
-- failure_run_started_at would have opened a failure run for every endpoint
-- in the platform at deploy time, and an hour later the sweep would have
-- disabled all of them. Nullable-and-null means every existing endpoint is
-- healthy the moment this applies, which is true;
-- * `synthetic boolean DEFAULT false NOT NULL` is the one non-null addition,
-- and the backfill it implies is correct: every delivery that already exists
-- was a real event, not a test;
-- * webhook_disable_notifications carries environment_id NOT NULL with a
-- foreign key (constitution I). organisation_id is NOT NULL too and is
-- denormalised on purpose — the row records an obligation as it stood, and an
-- application moving between organisations must not retarget a notification
-- already owed to somebody else;
-- * all three foreign keys are ON DELETE NO ACTION, the choice the webhook
-- dispatcher chapter made and
-- for its reason: deletion is soft, and a cascade would erase records the
-- platform is required to keep;
-- * webhook_endpoints_failure_run_idx is PARTIAL, on failure_run_started_at
-- WHERE enabled AND the run is open. It covers the sweep's only query and
-- nothing else, so a healthy endpoint costs nothing to keep out of it — the
-- same shape as the outbox chapter's index and the webhook dispatcher chapter's delivery index;
-- * the two CHECK constraints make the run's halves and the disable stamp's
-- halves travel together. Neither was in the generated output, and neither is
-- decoration: `shouldDisable` reads a missing attempt count as zero, which
-- would disable nothing and look like a policy decision rather than a bug.
--
-- No statement here is destructive, and there is no down path (forward-only).
CREATE TABLE "webhook_disable_notifications" (
"id" uuid PRIMARY KEY NOT NULL,
"environment_id" uuid NOT NULL,
"organisation_id" uuid NOT NULL,
"endpoint_id" uuid NOT NULL,
"disabled_at" timestamp with time zone DEFAULT now() NOT NULL,
"run_started_at" timestamp with time zone NOT NULL,
"run_attempts" integer NOT NULL,
"last_status" integer,
"last_error" text,
"delivered_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "webhook_deliveries" ADD COLUMN "last_status" integer;--> statement-breakpoint
ALTER TABLE "webhook_deliveries" ADD COLUMN "last_error" text;--> statement-breakpoint
ALTER TABLE "webhook_deliveries" ADD COLUMN "last_latency_ms" integer;--> statement-breakpoint
ALTER TABLE "webhook_deliveries" ADD COLUMN "synthetic" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "webhook_endpoints" ADD COLUMN "failure_run_started_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "webhook_endpoints" ADD COLUMN "failure_run_attempts" integer;--> statement-breakpoint
ALTER TABLE "webhook_endpoints" ADD COLUMN "disabled_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "webhook_endpoints" ADD COLUMN "disabled_reason" text;--> statement-breakpoint
ALTER TABLE "webhook_disable_notifications" ADD CONSTRAINT "webhook_disable_notifications_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "webhook_disable_notifications" ADD CONSTRAINT "webhook_disable_notifications_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "webhook_disable_notifications" ADD CONSTRAINT "webhook_disable_notifications_endpoint_id_webhook_endpoints_id_fk" FOREIGN KEY ("endpoint_id") REFERENCES "public"."webhook_endpoints"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "webhook_disable_notifications_environment_idx" ON "webhook_disable_notifications" USING btree ("environment_id");--> statement-breakpoint
CREATE INDEX "webhook_endpoints_failure_run_idx" ON "webhook_endpoints" USING btree ("failure_run_started_at") WHERE "webhook_endpoints"."enabled" AND "webhook_endpoints"."failure_run_started_at" IS NOT NULL;--> statement-breakpoint
ALTER TABLE "webhook_endpoints" ADD CONSTRAINT "webhook_endpoints_failure_run_check" CHECK (("webhook_endpoints"."failure_run_started_at" IS NULL) = ("webhook_endpoints"."failure_run_attempts" IS NULL));--> statement-breakpoint
ALTER TABLE "webhook_endpoints" ADD CONSTRAINT "webhook_endpoints_disabled_check" CHECK (("webhook_endpoints"."disabled_at" IS NULL) = ("webhook_endpoints"."disabled_reason" IS NULL));Chính sách, không có gì khác trong đó
Phần số học sống trong file riêng của nó, không database, không đồng hồ, không broker:
// When to stop trying (FR-007).
//
// This file is the chapter's only arithmetic, and it is separated from both places
// that call it deliberately. One trigger runs inside a database transaction and
// the other runs inside a background loop; a policy living in either would be
// testable only with a database, a clock and a broker, and "should this endpoint be
// switched off" is a question that needs none of the three.
//
// The two numbers are FIXED, not configuration, and that is FR-007's own wording
// rather than an omission. An operator who can lower the floor to one has an
// operator who can disable a paying customer's endpoint on a single bad response —
// and the failure mode of configuration here is silent, since nobody notices a
// threshold that is too aggressive until the endpoints are already off.
/** ONE HOUR, from FR-WHK-07's "failing continuously for more than an hour". */
export const DISABLE_AFTER_MS = 60 * 60 * 1_000;
/** FIVE attempts, and the number comes from measurement rather than taste
* (research R3).
*
* The hour alone is not enough. The webhook dispatcher chapter's schedule reaches a two-hour gap
* between attempts, so ONE failure followed by silence satisfies "failing for
* more than an hour" with a single data point — and disabling on one bad response
* is what the floor exists to prevent.
*
* Five is the largest floor a single failing delivery can still clear inside the
* window. Computed against `RETRY_TIERS_MS = [0, 1s, 5s, 30s, 5min, 30min, 2h]`,
* measured from the first attempt:
*
* attempt 5 at +5m36s inside the hour
* attempt 6 at +35m36s inside the hour
* attempt 7 at +2h35m36s OUTSIDE it
*
* So a floor of 5 or 6 is reachable by one delivery and a floor of 7 is not: set
* it to 7 and a single-delivery run could never trigger, which would leave the
* quietest endpoints — the ones least likely to be watched — enabled and failing
* for ever. Five leaves a margin of one.
*
* Below 5 the floor stops doing its job: two or three failures are reachable in
* the first six seconds, and six seconds of trouble is a blip rather than an
* outage. */
export const DISABLE_MIN_ATTEMPTS = 5;
export interface FailureRun {
/** When the current unbroken run of failures began. Null when the endpoint is
* healthy — any success clears it. */
runStartedAt: Date | null;
/** How many failures the run contains. Null when healthy. */
runAttempts: number | null;
/** Passed in rather than read, so this function has no clock of its own. Every
* caller has one already: the on-outcome trigger is inside a transaction that
* knows `now()`, and the sweep is a loop that just woke up. */
now: Date;
}
/** Should this endpoint be switched off?
*
* Both conditions, never either: longer than an hour AND at least five failures.
* The run must also exist — a healthy endpoint is not a candidate, and the two
* nulls are how "healthy" is spelled. */
export function shouldDisable({
runStartedAt,
runAttempts,
now,
}: FailureRun): boolean {
if (runStartedAt === null || runAttempts === null) return false;
if (runAttempts < DISABLE_MIN_ATTEMPTS) return false;
return runWindowMs({ runStartedAt, now }) > DISABLE_AFTER_MS;
}
/** How long the run has been going, and never a negative number.
*
* The clamp is not defensive padding. A clock that moves backwards — an NTP
* correction, a container resumed from a snapshot, a replica whose time differs
* from the writer's — would otherwise produce a negative window, and a negative
* window compared against a positive threshold reads as "not yet", which is the
* safe direction only by accident. Clamping makes the answer "no time has passed"
* rather than "time has run backwards", which is a claim the rest of the code can
* reason about. Spec edge case, stated there before it was written here. */
export function runWindowMs({
runStartedAt,
now,
}: {
runStartedAt: Date;
now: Date;
}): number {
return Math.max(0, now.getTime() - runStartedAt.getTime());
}
/** The sentence a customer reads, and the reason FR-009 asks for one.
*
* "Disabled" on its own invites a support ticket. This says what the platform saw,
* over how long, and what the endpoint last answered — enough for the customer to
* recognise their own outage without being told to check a dashboard that does not
* exist yet. */
export function disableReason({
runAttempts,
windowMs,
lastStatus,
lastError,
}: {
runAttempts: number;
windowMs: number;
lastStatus: number | null;
lastError: string | null;
}): string {
const answered =
lastStatus !== null
? `last status ${lastStatus}`
: lastError !== null
? // Bounded, because this string goes in a column a customer reads and the
// error it quotes came from a machine we do not own. The seam already
// caps the error at 2000 characters; a reason is a sentence, not a log.
`no response (${lastError.slice(0, 200)})`
: "no response";
return `${runAttempts} consecutive failures over ${humanDuration(windowMs)}; ${answered}`;
}
/** `1h04m`. Minutes, because the threshold is an hour and seconds would be noise
* — and because a reason that said `3862000ms` would be a message written for the
* platform rather than for the person reading it. */
function humanDuration(ms: number): string {
const minutes = Math.floor(ms / 60_000);
const hours = Math.floor(minutes / 60);
return `${hours}h${String(minutes % 60).padStart(2, "0")}m`;
}Sự tách biệt này không phải để gọn. Đây là phần duy nhất của chương có một con số trong đó, và một hàm thuần là phiên bản duy nhất của nó có thể được test đầy đủ trong vài mili giây. Cả hai cơ chế kích hoạt đều gọi nó; không cái nào có thể bất đồng với cái kia về việc một tiếng nghĩa là gì.
Vì sao năm lần thử? Bởi riêng một tiếng thì chưa đủ. Lịch chạy tới một khoảng cách hai tiếng, nên một lần hỏng rồi im lặng cũng thoả mãn "hỏng hơn một tiếng" với đúng một điểm dữ liệu, và tắt endpoint của một khách hàng vì một câu trả lời tệ chính là thứ mà một cái sàn tồn tại để chặn.
Năm là cái sàn lớn nhất mà một delivery hỏng đơn lẻ vẫn vượt được trong cửa sổ — lần thử năm rơi vào +5m36s và lần sáu vào +35m36s, cả hai trong tiếng đó, nên có biên độ một. Đặt nó thành bảy thì một chuỗi một-delivery sẽ không bao giờ kích hoạt, bởi lần thử thứ bảy rơi ngoài cửa sổ: cái sàn sẽ trở thành lý do các endpoint ít lưu lượng vẫn bật, đó chính là cái bug mà lượt quét tồn tại để sửa, tái xuất qua cửa trước. Dưới năm thì nó thôi làm được việc của nó, bởi ba lần hỏng tới trong sáu giây và sáu giây trục trặc là một cơn nấc.
Các bài test suy lại điều đó từ bảng tier thật chứ không phát biểu lại, để một chương sau đổi lịch sẽ phát hiện ra rằng nó đã đổi cả cái sàn:
import { describe, expect, it } from "vitest";
import { RETRY_TIERS_MS } from "./schedule";
import {
DISABLE_AFTER_MS,
DISABLE_MIN_ATTEMPTS,
disableReason,
runWindowMs,
shouldDisable,
} from "./disable";
// The decision to switch off a paying customer's endpoint.
//
// Pinned at 100% branches in `vitest.coverage.config.mts`, because constitution VI
// names idempotency logic and this is the predicate the at-most-once disable rests
// on. It is also the only arithmetic in the chapter, and arithmetic is the kind of
// thing that is obviously right until somebody computes it.
const T0 = new Date("2026-08-18T08:00:00.000Z");
const after = (ms: number) => new Date(T0.getTime() + ms);
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
describe("shouldDisable requires an hour AND five failures", () => {
it("disables a run past the hour with five failures", () => {
expect(
shouldDisable({
runStartedAt: T0,
runAttempts: 5,
now: after(HOUR + MINUTE),
}),
).toBe(true);
});
it("does NOT disable four failures, however long the run", () => {
// The floor, doing its job. A whole day of a single failing delivery that
// only ever managed four attempts is not five failures.
expect(
shouldDisable({
runStartedAt: T0,
runAttempts: DISABLE_MIN_ATTEMPTS - 1,
now: after(24 * HOUR),
}),
).toBe(false);
});
it("does NOT disable five failures inside the hour", () => {
// The window, doing its job. The webhook dispatcher chapter's first five attempts all land
// within 5m36s, so without the hour a single blip would disable an endpoint
// six seconds into an outage that might already be over.
expect(
shouldDisable({
runStartedAt: T0,
runAttempts: 9,
now: after(HOUR - 1),
}),
).toBe(false);
});
it("does not disable at EXACTLY the hour — FR-007 says more than", () => {
// A boundary worth pinning rather than leaving to whichever comparison
// somebody typed. "More than an hour" is `>`, and one millisecond later is
// the first moment that is true.
const attempts = DISABLE_MIN_ATTEMPTS;
expect(
shouldDisable({ runStartedAt: T0, runAttempts: attempts, now: after(HOUR) }),
).toBe(false);
expect(
shouldDisable({
runStartedAt: T0,
runAttempts: attempts,
now: after(HOUR + 1),
}),
).toBe(true);
});
it("treats a healthy endpoint as no candidate at all", () => {
// Both nulls are how "healthy" is spelled, and each null is checked because a
// schema CHECK guarantees they agree but this function must not depend on the
// schema to be correct.
const now = after(48 * HOUR);
expect(shouldDisable({ runStartedAt: null, runAttempts: null, now })).toBe(false);
expect(shouldDisable({ runStartedAt: null, runAttempts: 99, now })).toBe(false);
expect(shouldDisable({ runStartedAt: T0, runAttempts: null, now })).toBe(false);
});
it("does not disable a run of zero length", () => {
// The first failure opens the run and is evaluated in the same transaction, so
// this case happens on every single failure. It must be cheap and it must be
// false.
expect(shouldDisable({ runStartedAt: T0, runAttempts: 1, now: T0 })).toBe(false);
});
it("yields no negative window when the clock moves backwards", () => {
// An NTP correction, a container resumed from a snapshot, a replica whose
// clock differs from the writer's. A negative window would compare as "not
// yet" — the safe direction, but only by accident, and this makes it a
// property rather than luck. Spec edge case.
const backwards = new Date(T0.getTime() - 5 * MINUTE);
expect(runWindowMs({ runStartedAt: T0, now: backwards })).toBe(0);
expect(
shouldDisable({ runStartedAt: T0, runAttempts: 99, now: backwards }),
).toBe(false);
});
});
describe("the floor is reachable by one failing delivery (research R3)", () => {
// THE MEASUREMENT THAT CHOSE THE NUMBER, re-derived here from the real tier
// table rather than restated. If a later chapter changes the schedule, this
// fails and the floor gets re-examined — which is the whole reason it is
// computed from `RETRY_TIERS_MS` instead of written down.
const attemptOffsets = RETRY_TIERS_MS.reduce<number[]>(
(acc, tier) => [...acc, (acc.at(-1) ?? 0) + tier],
[],
);
it("reaches the floor inside the hour, with a margin of one attempt", () => {
// Attempt N lands at offset N-1. The floor is cleared when the attempt at
// index DISABLE_MIN_ATTEMPTS - 1 is still inside the window.
const atFloor = attemptOffsets[DISABLE_MIN_ATTEMPTS - 1]!;
expect(atFloor).toBeLessThan(DISABLE_AFTER_MS);
// The margin: one more attempt also lands inside the hour.
const oneMore = attemptOffsets[DISABLE_MIN_ATTEMPTS]!;
expect(oneMore).toBeLessThan(DISABLE_AFTER_MS);
});
it("would be unreachable at a floor of seven, which is why it is not seven", () => {
// The last attempt falls at +2h35m36s. A floor equal to the attempt count
// would mean a single failing delivery never disables its endpoint, and the
// quiet endpoint stays broken for ever — the exact failure research R1 found.
const lastAttempt = attemptOffsets.at(-1)!;
expect(lastAttempt).toBeGreaterThan(DISABLE_AFTER_MS);
expect(attemptOffsets.length).toBeGreaterThan(DISABLE_MIN_ATTEMPTS);
});
it("is above the blip threshold: three failures arrive within seconds", () => {
const third = attemptOffsets[2]!;
expect(third).toBeLessThan(10_000);
expect(DISABLE_MIN_ATTEMPTS).toBeGreaterThan(3);
});
});
describe("disableReason says what happened", () => {
it("names the count, the window and the last status", () => {
expect(
disableReason({
runAttempts: 6,
windowMs: HOUR + 4 * MINUTE,
lastStatus: 503,
lastError: null,
}),
).toBe("6 consecutive failures over 1h04m; last status 503");
});
it("names the error when nothing answered", () => {
expect(
disableReason({
runAttempts: 5,
windowMs: 2 * HOUR,
lastStatus: null,
lastError: "timeout after 10000ms",
}),
).toBe("5 consecutive failures over 2h00m; no response (timeout after 10000ms)");
});
it("says `no response` when there is neither", () => {
// The sweep's case: it disables an endpoint whose failures stopped arriving,
// and the last delivery may predate the columns that would have recorded them.
expect(
disableReason({
runAttempts: 5,
windowMs: HOUR + MINUTE,
lastStatus: null,
lastError: null,
}),
).toBe("5 consecutive failures over 1h01m; no response");
});
it("bounds an enormous error rather than quoting all of it", () => {
// Spec edge case: "an attempt whose error message is enormous, or contains a
// customer's payload". This column is read by a person; the full error is
// already capped at 2000 characters by the seam, and a reason is a sentence.
const reason = disableReason({
runAttempts: 5,
windowMs: 2 * HOUR,
lastStatus: null,
lastError: "x".repeat(2000),
});
expect(reason.length).toBeLessThan(300);
expect(reason).toContain("x".repeat(200));
expect(reason).not.toContain("x".repeat(201));
});
});Tắt, nhiều nhất một lần
Cả hai cơ chế kích hoạt đều kết thúc ở cùng một câu lệnh, và câu lệnh đó là thứ thực thi FR-WHK-07 chứ không phải một phép kiểm ai đó phải nhớ:
@@ -19,21 +19,29 @@ import {
messages,
organisations,
outbox,
users,
webhookDeadLetters,
webhookDeliveries,
+ webhookDisableNotifications,
webhookEndpoints,
} from "./schema";
import {
membershipEvent,
messageCreatedEvent,
messageDeletedEvent,
messageUpdatedEvent,
} from "../outbox/event";
import { nextAttemptAt } from "../webhooks/schedule";
+import {
+ DISABLE_AFTER_MS,
+ DISABLE_MIN_ATTEMPTS,
+ disableReason,
+ runWindowMs,
+ shouldDisable,
+} from "../webhooks/disable";
import { activeSigningSecrets } from "../webhooks/secret";
import {
mintApiKey,
parseApiKeyCredential,
prefixMatchesKind,
secretMatches,
@@ -399,33 +407,262 @@ export type DeliveryOutcome = "delivered" | "rescheduled" | "dead_lettered";
* then acknowledges; a crash between the POST and the acknowledgement means the
* delivery is redelivered and reported again. Recognising the repeat and
* returning the same answer is what makes that redelivery harmless — the POST
* itself may duplicate, and the customer absorbs it on the event id, but the
* SCHEDULE must not advance twice for one attempt or the tiers would collapse.
*/
+/** Open, extend or clear an endpoint's failure run, and disable it if the run has
+ * gone on long enough (FR-006, FR-007).
+ *
+ * Runs INSIDE the transaction that records the outcome, and takes
+ * `SELECT … FOR UPDATE` on the endpoint row.
+ *
+ * WHAT THAT LOCK IS ACTUALLY FOR, corrected by the sabotage battery. The comment
+ * here used to say it was "the whole of FR-008's concurrency story" — that without
+ * it, two dispatcher instances reporting outcomes for the same endpoint at the same
+ * moment would both decide to disable and produce two notifications. Dropping the
+ * lock and running the whole suite produced 46 passes. The claim was wrong: the
+ * `enabled = true` predicate in `disableEndpoint`'s update is sufficient for
+ * at-most-once on its own, and the lock was being credited for the predicate's
+ * work.
+ *
+ * The lock protects the COUNTER. Under READ COMMITTED both transactions read
+ * `runAttempts = 4`, both compute 5, and the second UPDATE waits for the first and
+ * then overwrites it with 5. That is a lost update, and the run undercounts by one
+ * per collision. Nothing reports it, nothing fails, and the endpoint quietly needs
+ * an extra failure to reach FR-007's floor of five — a threshold harder to reach
+ * than the requirement says, which is the kind of defect that survives for years.
+ *
+ * The lock is per endpoint and held for one small update, so two customers never
+ * contend (research R2).
+ *
+ * Lock ORDER is delivery-then-endpoint, everywhere, without exception. The caller
+ * has already locked the delivery row; anything that took these two in the other
+ * order would deadlock against this under concurrency, and a deadlock found in
+ * production is a deadlock found by a customer.
+ *
+ * Returns what it did, so the caller can report it and the tests can assert on it
+ * without reading the row back. */
+async function applyFailureRun(
+ tx: Db,
+ input: {
+ endpointId: string;
+ /** Did this attempt fail? A success clears the run outright. */
+ failed: boolean;
+ status: number | null;
+ error: string | null;
+ },
+): Promise<{ disabled: boolean }> {
+ const [endpoint] = await tx
+ .select({
+ id: webhookEndpoints.id,
+ environmentId: webhookEndpoints.environmentId,
+ enabled: webhookEndpoints.enabled,
+ runStartedAt: webhookEndpoints.failureRunStartedAt,
+ runAttempts: webhookEndpoints.failureRunAttempts,
+ })
+ .from(webhookEndpoints)
+ .where(eq(webhookEndpoints.id, input.endpointId))
+ .for("update");
+
+ // The endpoint was deleted between the delivery being claimed and its outcome
+ // being reported. Nothing to track and nothing to disable; the delivery's own
+ // state has already been settled by the caller.
+ if (!endpoint) return { disabled: false };
+
+ if (!input.failed) {
+ // ANY SUCCESS CLEARS THE RUN (FR-006). This is why an endpoint that succeeds
+ // once an hour is never disabled, and it is deliberately generous: a platform
+ // that switches off endpoints which sometimes work is a worse failure than one
+ // that keeps trying. Written unconditionally rather than behind a "was there a
+ // run" check — the update is the same cost either way, and the check is one
+ // more thing to get wrong.
+ await tx
+ .update(webhookEndpoints)
+ .set({ failureRunStartedAt: null, failureRunAttempts: null })
+ .where(eq(webhookEndpoints.id, endpoint.id));
+ return { disabled: false };
+ }
+
+ // Read the clock ONCE, from the database, so the window and the timestamps it is
+ // compared against come from the same source. An api process whose clock differs
+ // from Postgres's would otherwise measure a window against somebody else's idea
+ // of now.
+ // Coerced, not trusted. A raw `execute` hands back whatever the driver made of
+ // the column, and for `timestamptz` that is a string here rather than a Date —
+ // which drizzle then refuses to write back, with `value.toISOString is not a
+ // function` from deep inside its timestamp mapper and no mention of this line.
+ const [clock] = (await tx.execute(sql`SELECT now() AS now`))
+ .rows as { now: string | Date }[];
+ const now = new Date(clock!.now);
+
+ const runStartedAt = endpoint.runStartedAt ?? now;
+ const runAttempts = (endpoint.runAttempts ?? 0) + 1;
+
+ await tx
+ .update(webhookEndpoints)
+ .set({ failureRunStartedAt: runStartedAt, failureRunAttempts: runAttempts })
+ .where(eq(webhookEndpoints.id, endpoint.id));
+
+ if (!shouldDisable({ runStartedAt, runAttempts, now })) {
+ return { disabled: false };
+ }
+
+ return disableEndpoint(tx, {
+ endpointId: endpoint.id,
+ environmentId: endpoint.environmentId,
+ runStartedAt,
+ runAttempts,
+ now,
+ status: input.status,
+ error: input.error,
+ });
+}
+
+/** Switch an endpoint off, once, and record the obligation to tell somebody
+ * (FR-007, FR-008, FR-011).
+ *
+ * AT MOST ONCE PER RUN, and it is the STATEMENT that enforces it rather than a
+ * check somebody has to remember to write: the update carries `enabled = true` in
+ * its predicate, so a second disable matches zero rows and the notification below
+ * is never reached. Both triggers call this, so both inherit the property — which
+ * is contract invariant 12, and the reason there can safely be two of them. */
+async function disableEndpoint(
+ tx: Db,
+ input: {
+ endpointId: string;
+ environmentId: string;
+ runStartedAt: Date;
+ runAttempts: number;
+ now: Date;
+ status: number | null;
+ error: string | null;
+ },
+): Promise<{ disabled: boolean }> {
+ const windowMs = runWindowMs({ runStartedAt: input.runStartedAt, now: input.now });
+ const reason = disableReason({
+ runAttempts: input.runAttempts,
+ windowMs,
+ lastStatus: input.status,
+ lastError: input.error,
+ });
+
+ const updated = (await tx.execute(sql`
+ UPDATE webhook_endpoints
+ SET enabled = false,
+ disabled_at = ${input.now},
+ disabled_reason = ${reason}
+ WHERE id = ${input.endpointId}
+ AND enabled = true
+ RETURNING id`)) as unknown as { rows: { id: string }[] };
+
+ // Zero rows means somebody else got there first — a concurrent outcome report,
+ // or the sweep, or a customer who happened to pause the endpoint themselves. In
+ // every case the answer is the same: this call disabled nothing, so it owes no
+ // notification.
+ if (updated.rows.length === 0) return { disabled: false };
+
+ // The organisation is resolved HERE, at write time, through the two hops that
+ // already exist: environments.application_id → applications.organisation_id. It
+ // is stored rather than joined for later because this row records an obligation
+ // AS IT STOOD, and an application moving between organisations afterwards must
+ // not silently retarget a notification already owed to somebody else.
+ const [owner] = await tx
+ .select({ organisationId: applications.organisationId })
+ .from(environments)
+ .innerJoin(applications, eq(environments.applicationId, applications.id))
+ .where(eq(environments.id, input.environmentId));
+
+ await tx.insert(webhookDisableNotifications).values({
+ id: randomUUID(),
+ environmentId: input.environmentId,
+ organisationId: owner!.organisationId,
+ endpointId: input.endpointId,
+ disabledAt: input.now,
+ runStartedAt: input.runStartedAt,
+ runAttempts: input.runAttempts,
+ lastStatus: input.status,
+ lastError: input.error,
+ // NOT SET, and that is the point. FR-WHK-07 asks for the organisation to be
+ // notified by email and this platform has no email; `delivered_at` exists in
+ // order to be null until a transport does.
+ });
+
+ return { disabled: true };
+}
+
+/** What one recorded outcome yields its caller.
+ *
+ * The four identifiers are here because the ATTEMPT EVENT needs them and the
+ * dispatcher does not hold them: it knows a delivery id, a status and a latency,
+ * and nothing about which environment or event that delivery belongs to. Reading
+ * them back out with a second query would be a second query for data this
+ * transaction already had in hand.
+ *
+ * `recorded` is the field the analytics publish is conditional on. See the
+ * idempotent-replay branch below. */
+export interface RecordedOutcome {
+ outcome: DeliveryOutcome;
+ nextAttemptAt: Date | null;
+ /** True when this call actually moved the delivery. False when it recognised a
+ * report it had already processed. */
+ recorded: boolean;
+ endpointId: string;
+ environmentId: string;
+ eventId: string;
+ attempt: number;
+ synthetic: boolean;
+}
+
+/** The identifiers, lifted off the locked row so both return paths agree. */
+function identity(delivery: {
+ endpointId: string;
+ environmentId: string;
+ eventId: string;
+ attempt: number;
+ synthetic: boolean;
+}): Pick<
+ RecordedOutcome,
+ "endpointId" | "environmentId" | "eventId" | "attempt" | "synthetic"
+> {
+ return {
+ endpointId: delivery.endpointId,
+ environmentId: delivery.environmentId,
+ eventId: delivery.eventId,
+ attempt: delivery.attempt,
+ synthetic: delivery.synthetic,
+ };
+}
+
export async function recordAttemptOutcome(
db: Db,
input: {
deliveryId: string;
attempt: number;
status?: number;
error?: string;
+ /** How long the customer took to answer. Carried across the internal seam on
+ * every attempt since the webhook dispatcher chapter and discarded until this one
+ * wanted it (research R6). Optional only so that callers written before it existed still compile;
+ * every real caller has it. */
+ latencyMs?: number;
},
-): Promise<{ outcome: DeliveryOutcome; nextAttemptAt: Date | null }> {
+): Promise<RecordedOutcome> {
return db.transaction(async (tx) => {
const [delivery] = await tx
.select({
id: webhookDeliveries.id,
environmentId: webhookDeliveries.environmentId,
endpointId: webhookDeliveries.endpointId,
eventId: webhookDeliveries.eventId,
payload: webhookDeliveries.payload,
attempt: webhookDeliveries.attempt,
state: webhookDeliveries.state,
nextAttemptAt: webhookDeliveries.nextAttemptAt,
dispatchedAt: webhookDeliveries.dispatchedAt,
+ synthetic: webhookDeliveries.synthetic,
})
.from(webhookDeliveries)
.where(eq(webhookDeliveries.id, input.deliveryId))
.for("update");
if (!delivery) throw new DeliveryNotFoundError(input.deliveryId);
@@ -440,58 +677,345 @@ export async function recordAttemptOutcome(
? ("delivered" as const)
: delivery.state === "dead"
? ("dead_lettered" as const)
: ("rescheduled" as const),
nextAttemptAt:
delivery.state === "pending" ? delivery.nextAttemptAt : null,
+ // NOT RECORDED. This branch changed no row, so nothing new happened and
+ // the caller must not publish an attempt event for it (contract invariant
+ // 1). The dispatcher posts, reports, then acknowledges, so a crash in the
+ // last gap makes a second report ordinary rather than exceptional — and
+ // nothing on the analytical path deduplicates, so publishing here would
+ // put a retry that never happened on a customer's dashboard.
+ recorded: false,
+ ...identity(delivery),
};
}
const succeeded =
input.status !== undefined && input.status >= 200 && input.status < 300;
+ // WHAT THE ENDPOINT SAID, kept on every recorded attempt whichever branch
+ // follows. Two things need it and neither can get it from the attempt event,
+ // whose publish is at-most-once by design: the test event answers a caller who
+ // is waiting (FR-016), and the sweep names a disablement's last error at a
+ // moment when no outcome is arriving (FR-009, research R1).
+ const lastOutcome = {
+ lastStatus: input.status ?? null,
+ lastError: input.error ?? null,
+ lastLatencyMs: input.latencyMs ?? null,
+ };
+
+ // THE FAILURE RUN, and a test event is exempt from it (contract invariant 13,
+ // research R8). A test event is a diagnostic rather than traffic: letting a
+ // failed test push an endpoint toward disablement would punish a customer for
+ // checking, and letting a successful one CLEAR the run would let a customer
+ // mask a real outage by testing until it passed. Both directions matter, which
+ // is why the exemption is on the call and not inside it.
+ if (!delivery.synthetic) {
+ await applyFailureRun(tx, {
+ endpointId: delivery.endpointId,
+ failed: !succeeded,
+ status: input.status ?? null,
+ error: input.error ?? null,
+ });
+ }
+
if (succeeded) {
await tx
.update(webhookDeliveries)
- .set({ state: "delivered", dispatchedAt: null })
+ .set({ state: "delivered", dispatchedAt: null, ...lastOutcome })
.where(eq(webhookDeliveries.id, delivery.id));
- return { outcome: "delivered" as const, nextAttemptAt: null };
+ return {
+ outcome: "delivered" as const,
+ nextAttemptAt: null,
+ recorded: true,
+ ...identity(delivery),
+ };
}
- const next = nextAttemptAt(delivery.attempt + 1);
+ // A TEST EVENT GETS ONE ATTEMPT AND NO SCHEDULE (research R8, FR-013). A
+ // caller is standing at their terminal waiting for the answer; a test that
+ // quietly retried for two hours would report a stale one, and a test event
+ // that kept coming back would be indistinguishable from real traffic to the
+ // customer trying to read their logs.
+ const next = delivery.synthetic ? null : nextAttemptAt(delivery.attempt + 1);
if (next) {
await tx
.update(webhookDeliveries)
.set({
attempt: delivery.attempt + 1,
nextAttemptAt: next,
// Cleared so the relay can pick it up again when it falls due.
dispatchedAt: null,
+ ...lastOutcome,
})
.where(eq(webhookDeliveries.id, delivery.id));
- return { outcome: "rescheduled" as const, nextAttemptAt: next };
+ return {
+ outcome: "rescheduled" as const,
+ nextAttemptAt: next,
+ recorded: true,
+ ...identity(delivery),
+ };
}
// Attempts exhausted. The dead letter and the state change commit together —
// a delivery marked dead with no dead letter behind it would be a failure
// with no record, which is exactly what FR-WHK-04's seven days are for.
+ // …unless it was a test. A dead letter is a customer-visible record retained
+ // for seven days and replayable (FR-WHK-04), and a test event is a diagnostic
+ // the customer asked for and already has the answer to. Writing one would put
+ // synthetic traffic in the store whose whole purpose is real traffic that
+ // failed to leave, and offer an operator a "replay" button that re-sends a
+ // test. The delivery is still marked dead, and the record of what happened is
+ // the response the caller received plus `last_status` on the row.
+ if (delivery.synthetic) {
+ await tx
+ .update(webhookDeliveries)
+ .set({ state: "dead", dispatchedAt: null, ...lastOutcome })
+ .where(eq(webhookDeliveries.id, delivery.id));
+ return {
+ outcome: "dead_lettered" as const,
+ nextAttemptAt: null,
+ recorded: true,
+ ...identity(delivery),
+ };
+ }
+
await tx.insert(webhookDeadLetters).values({
id: randomUUID(),
environmentId: delivery.environmentId,
endpointId: delivery.endpointId,
eventId: delivery.eventId,
payload: delivery.payload,
lastStatus: input.status ?? null,
lastError: input.error ?? null,
attempts: delivery.attempt,
});
await tx
.update(webhookDeliveries)
- .set({ state: "dead", dispatchedAt: null })
+ .set({ state: "dead", dispatchedAt: null, ...lastOutcome })
.where(eq(webhookDeliveries.id, delivery.id));
- return { outcome: "dead_lettered" as const, nextAttemptAt: null };
+ return {
+ outcome: "dead_lettered" as const,
+ nextAttemptAt: null,
+ recorded: true,
+ ...identity(delivery),
+ };
+ });
+}
+
+/** Create the one delivery a test event needs (FR-013, research R8).
+ *
+ * THREE DELIBERATE DEVIATIONS from `expandEventToDeliveries`, each with a reason,
+ * and they are the whole difference between a test event and a real one:
+ *
+ * * ONE ENDPOINT, named by the caller, rather than every endpoint whose
+ * subscription matches. A test is aimed. Fanning it out would send every other
+ * endpoint in the environment a surprise event they did not ask for.
+ * * DELIVERED EVEN WHEN DISABLED — `enabled` is not in the predicate below.
+ * Testing is how a customer establishes their endpoint is fixed BEFORE
+ * re-enabling it, and refusing here would make the disable-repair-re-enable
+ * loop unclosable, which is the whole point of FR-WHK-09.
+ * * NO CLAIM LEDGER. Expansion claims the event so a broker redelivery cannot
+ * double a customer's webhooks; nothing redelivers a test, because a person
+ * asked for it once over HTTP.
+ *
+ * Soft-deleted endpoints are still refused. A deleted endpoint is gone as far as
+ * the customer's own API is concerned, and delivering to one would be the platform
+ * reaching a url the customer believes it has forgotten.
+ *
+ * Everything else is ordinary: a real row, on the real schedule, delivered by the
+ * real dispatcher, signed by the real signing path. That is FR-014 — a test whose
+ * delivery worked differently would prove nothing about real deliveries. */
+export async function createTestDelivery(
+ db: Db,
+ input: { endpointId: string; environmentId: string },
+): Promise<{ deliveryId: string; eventId: string; payload: unknown } | null> {
+ const [endpoint] = await db
+ .select({ id: webhookEndpoints.id })
+ .from(webhookEndpoints)
+ .where(
+ and(
+ eq(webhookEndpoints.id, input.endpointId),
+ eq(webhookEndpoints.environmentId, input.environmentId),
+ isNull(webhookEndpoints.deletedAt),
+ ),
+ );
+ if (!endpoint) return null;
+
+ const eventId = randomUUID();
+ // MARKED TWICE, for two different readers (FR-015). A recipient switching on
+ // `type` and a recipient inspecting the body should each be able to tell this is
+ // synthetic without knowing about the other — and neither should have to know
+ // about `webhook_deliveries.synthetic`, which is the platform's own marker and
+ // not part of the contract.
+ const payload = {
+ id: eventId,
+ type: TEST_EVENT_TYPE,
+ environment_id: input.environmentId,
+ occurred_at: new Date().toISOString(),
+ test: true,
+ data: { message: "This is a test event from Relay." },
+ };
+
+ const deliveryId = randomUUID();
+ await db.insert(webhookDeliveries).values({
+ id: deliveryId,
+ environmentId: input.environmentId,
+ endpointId: endpoint.id,
+ eventId,
+ payload,
+ // Due immediately: a caller is waiting.
+ attempt: 1,
+ synthetic: true,
+ });
+
+ return { deliveryId, eventId, payload };
+}
+
+/** What a test event's envelope calls itself. Exported because the contract names
+ * it and a recipient may switch on it. */
+export const TEST_EVENT_TYPE = "webhook.test";
+
+/** What the endpoint answered, read back off the delivery the test created.
+ *
+ * The attempt happens in the DISPATCHER's process, so the route that is holding a
+ * customer's request cannot observe it directly — it waits for the row to move.
+ * `state` is the signal: `pending` means no outcome has been recorded yet. */
+export async function testDeliveryResult(
+ db: Db,
+ deliveryId: string,
+): Promise<{
+ settled: boolean;
+ delivered: boolean;
+ status: number | null;
+ error: string | null;
+ latencyMs: number | null;
+} | null> {
+ const [row] = await db
+ .select({
+ state: webhookDeliveries.state,
+ lastStatus: webhookDeliveries.lastStatus,
+ lastError: webhookDeliveries.lastError,
+ lastLatencyMs: webhookDeliveries.lastLatencyMs,
+ })
+ .from(webhookDeliveries)
+ .where(eq(webhookDeliveries.id, deliveryId));
+ if (!row) return null;
+
+ return {
+ settled: row.state !== "pending",
+ // A test event gets one attempt, so `delivered` and "the state is delivered"
+ // are the same fact. Reading the state rather than the status keeps that
+ // decision in one place — `recordAttemptOutcome` already decided what 2xx
+ // means, and a second opinion here is a second thing to get wrong.
+ delivered: row.state === "delivered",
+ status: row.lastStatus,
+ error: row.lastError,
+ latencyMs: row.lastLatencyMs,
+ };
+}
+
+/** Disable every endpoint whose failure run has outrun the hour
+ * (research R1, contract invariant 12).
+ *
+ * THE SECOND TRIGGER, and it is not belt-and-braces. The on-outcome check catches
+ * every endpoint that is still receiving attempts, and research R1 measured that
+ * this is not all of them. Against the webhook dispatcher chapter's tier table, one failing delivery
+ * attempts at +35m36s and then not again until +2h35m36s — so nothing happens AT
+ * the hour, and a check that only runs when an outcome is recorded fires
+ * ninety-five minutes late. Worse: if that last attempt dead-letters and no
+ * further events arrive for the environment, no outcome is ever recorded again and
+ * the endpoint is never disabled at all. It sits enabled and failing for ever,
+ * which is the state FR-WHK-07 exists to end.
+ *
+ * The endpoint that stays broken silently is the QUIET one — the low-traffic
+ * customer, who is also the customer least likely to be watching.
+ *
+ * Rides the delivery relay's existing loop rather than adding a scheduler: one
+ * more statement per drain, in a worker that is already awake and already holds a
+ * connection (constitution VII). Its per-endpoint work goes through the same
+ * `disableEndpoint` the on-outcome path uses, so the at-most-once rule is one rule
+ * and not two implementations of it.
+ *
+ * Returns how many it disabled, so the relay can log a number rather than a claim.
+ */
+export async function sweepDisabledEndpoints(
+ db: Db,
+ limit = 100,
+): Promise<number> {
+ // An INTERVAL built from the same constant the pure policy uses, so the sweep and
+ // `shouldDisable` can never disagree about how long an hour is. Milliseconds
+ // rather than a literal `'1 hour'`: one definition, in `disable.ts`.
+ const disableCutoff = sql`now() - make_interval(secs => ${DISABLE_AFTER_MS / 1000})`;
+
+ return db.transaction(async (tx) => {
+ // The candidates, and the LAST THING each endpoint heard. `disableReason` and
+ // the notification both want a status the sweep does not have in hand — it
+ // fires precisely when no outcome is arriving — so it is read off the
+ // endpoint's most recent attempted delivery. LEFT JOIN LATERAL, because an
+ // endpoint whose deliveries have all been pruned still deserves to be switched
+ // off; it just gets "no response" as its reason.
+ //
+ // `FOR UPDATE OF e SKIP LOCKED` is the outbox chapter's pattern and here it does two
+ // jobs: it serialises the sweep against a concurrent outcome report on the same
+ // endpoint, and it lets two api instances sweep at once without either waiting
+ // — whichever skips simply finds nothing to do, which is correct.
+ const candidates = (await tx.execute(sql`
+ SELECT e.id,
+ e.environment_id,
+ e.failure_run_started_at AS run_started_at,
+ e.failure_run_attempts AS run_attempts,
+ now() AS now,
+ last.last_status,
+ last.last_error
+ FROM webhook_endpoints e
+ LEFT JOIN LATERAL (
+ SELECT d.last_status, d.last_error
+ FROM webhook_deliveries d
+ WHERE d.endpoint_id = e.id
+ AND d.synthetic = false
+ AND d.last_latency_ms IS NOT NULL
+ ORDER BY d.next_attempt_at DESC, d.id DESC
+ LIMIT 1
+ ) last ON true
+ WHERE e.enabled = true
+ AND e.deleted_at IS NULL
+ AND e.failure_run_started_at IS NOT NULL
+ AND e.failure_run_attempts >= ${DISABLE_MIN_ATTEMPTS}
+ AND e.failure_run_started_at < ${disableCutoff}
+ ORDER BY e.failure_run_started_at
+ LIMIT ${limit}
+ FOR UPDATE OF e SKIP LOCKED`)) as unknown as {
+ rows: {
+ id: string;
+ environment_id: string;
+ run_started_at: string | Date;
+ run_attempts: number;
+ now: string | Date;
+ last_status: number | null;
+ last_error: string | null;
+ }[];
+ };
+
+ let disabled = 0;
+ for (const row of candidates.rows) {
+ const result = await disableEndpoint(tx, {
+ endpointId: row.id,
+ environmentId: row.environment_id,
+ // Same coercion, same reason as `applyFailureRun`'s clock read.
+ runStartedAt: new Date(row.run_started_at),
+ runAttempts: row.run_attempts,
+ now: new Date(row.now),
+ status: row.last_status,
+ error: row.last_error,
+ });
+ if (result.disabled) disabled++;
+ }
+ return disabled;
});
}
/** Everything the dispatcher needs to sign and post one delivery.
*
* Admin surface: one dispatcher serves every environment. The environment is
@@ -523,12 +1047,13 @@ export async function deliveryMaterial(
eventId: webhookDeliveries.eventId,
attempt: webhookDeliveries.attempt,
payload: webhookDeliveries.payload,
url: webhookEndpoints.url,
enabled: webhookEndpoints.enabled,
deletedAt: webhookEndpoints.deletedAt,
+ synthetic: webhookDeliveries.synthetic,
secretCiphertext: webhookEndpoints.secretCiphertext,
secretPreviousCiphertext: webhookEndpoints.secretPreviousCiphertext,
secretRotatedAt: webhookEndpoints.secretRotatedAt,
})
.from(webhookDeliveries)
.innerJoin(
@@ -538,13 +1063,25 @@ export async function deliveryMaterial(
.where(eq(webhookDeliveries.id, deliveryId));
if (!row) return null;
// An endpoint paused or removed after the delivery was scheduled gets nothing.
// The spec's edge case: events already in the retry schedule for a removed
// endpoint must not be delivered.
- if (!row.enabled || row.deletedAt) return null;
+ //
+ // A TEST EVENT IS THE EXCEPTION, and it is the only one (FR-013).
+ // Two requirements meet exactly here and pull opposite ways: invariant 9 says a
+ // disabled endpoint receives no attempts, and FR-013 says a customer may test a
+ // disabled endpoint — which is how they establish it is fixed BEFORE re-enabling
+ // it. `synthetic` is what tells them apart, and it is the reason that column is a
+ // column rather than a string comparison against a customer-visible payload.
+ //
+ // DELETED IS STILL DELETED. A soft-deleted endpoint is gone as far as the
+ // customer's own API is concerned, and delivering to one — test or not — would be
+ // the platform reaching a url the customer believes it has forgotten.
+ if (row.deletedAt) return null;
+ if (!row.enabled && !row.synthetic) return null;
return {
delivery_id: row.id,
endpoint_id: row.endpointId,
environment_id: row.environmentId,
event_id: row.eventId,
@@ -1180,12 +1717,23 @@ export interface WebhookEndpointRow {
id: string;
url: string;
event_types: string[];
enabled: boolean;
secret_rotated_at: string | null;
created_at: string;
+ /** FR-009. `enabled: false` with `disabled_at: null` means the
+ * customer paused it themselves; both set means the platform did. Without this
+ * pair a customer looking at a disabled endpoint has no way to tell whether they
+ * are looking at their own decision or ours, and the support conversation starts
+ * from zero. */
+ disabled_at: string | null;
+ disabled_reason: string | null;
+ /** The run as it stands, so a customer can see a disablement COMING rather than
+ * only after it lands. Both null when the endpoint is healthy. */
+ failure_run_started_at: string | null;
+ failure_run_attempts: number | null;
}
/** Raised when an outcome names a delivery that is not there. A caller error,
* not a platform one — the controller turns it into a 404. */
export class DeliveryNotFoundError extends Error {
constructor(id: string) {
@@ -1233,13 +1781,25 @@ export class Repository {
* `req.principal?.environmentId ?? "unknown"` reads the same id and carries a branch
* the guard makes unreachable — a request without a principal never reaches a
* controller — and an unreachable branch in a file pinned at 100% is a coverage
* failure with no fix but a comment. The ratchet found exactly that arm at 75%.
*
* The id, not a widened operation: the environment still comes from a verified
- * credential and never from a request body, which is constitution I's shape. */
+ *
+ * AND A SECOND CALLER WITH A DIFFERENT NEED, which is why this reads as an accessor
+ * rather than as one route's convenience. The test event wants an UNSCOPED operation:
+ * `createTestDelivery` ignores subscriptions and `enabled`, so it cannot be a method
+ * on this class at all — and it must still be told which environment is asking.
+ * Exposing the id rather than widening the operation keeps constitution I's shape
+ * either way, and the two reasons arrive from opposite directions: one wanted to stop
+ * reading an optional chain, the other cannot use a scoped method.
+ *
+ * The ledger deferred this accessor to the webhook chapter on the strength of THAT
+ * second reason — the comment it was attached to — and the membership chapter needed
+ * it five chapters earlier for the first. A deferral justified by a comment is a
+ * deferral justified by one caller's opinion of why the code exists. */
get environment(): string {
return this.environmentId;
}
// ---------------------------------------------------------------------
// Webhook endpoints. Scoped like everything else on this class:
@@ -1323,13 +1883,37 @@ export class Repository {
async setEndpointEnabled(
id: string,
enabled: boolean,
): Promise<WebhookEndpointRow | null> {
await this.db
.update(webhookEndpoints)
- .set({ enabled })
+ .set({
+ enabled,
+ // RE-ENABLING CLEARS THE RUN, all four columns, in this one statement
+ // (FR-017). The hour is measured from the NEXT failure, not
+ // resumed from the old one — otherwise a customer who fixed their server
+ // and switched it back on would be disabled again by the first failure
+ // after that, on the strength of an outage they had already repaired.
+ //
+ // `disabled_at` and `disabled_reason` go too, because FR-009 reads them as
+ // "the platform switched this off" and after a re-enable that is no longer
+ // true. A schema CHECK requires those two to agree, so they must move
+ // together whatever else happens here.
+ //
+ // DISABLING clears nothing. A customer pausing their own endpoint has said
+ // nothing about whether it is healthy, and throwing away the run would let
+ // a disable/enable cycle launder an hour of failures.
+ ...(enabled
+ ? {
+ failureRunStartedAt: null,
+ failureRunAttempts: null,
+ disabledAt: null,
+ disabledReason: null,
+ }
+ : {}),
+ })
.where(and(eq(webhookEndpoints.id, id), this.liveEndpoints));
return this.getEndpoint(id);
}
/** SOFT. A hard delete would have to cascade, and cascading would erase the
* customer's dead letters — which FR-WHK-04 says to retain for seven days.
@@ -1414,24 +1998,32 @@ export class Repository {
id: webhookEndpoints.id,
url: webhookEndpoints.url,
eventTypes: webhookEndpoints.eventTypes,
enabled: webhookEndpoints.enabled,
secretRotatedAt: webhookEndpoints.secretRotatedAt,
createdAt: webhookEndpoints.createdAt,
+ disabledAt: webhookEndpoints.disabledAt,
+ disabledReason: webhookEndpoints.disabledReason,
+ failureRunStartedAt: webhookEndpoints.failureRunStartedAt,
+ failureRunAttempts: webhookEndpoints.failureRunAttempts,
})
.from(webhookEndpoints)
.where(where);
// Never the ciphertext. A read path that can return it is one refactor away
// from a response that does.
return rows.map((r) => ({
id: r.id,
url: r.url,
event_types: r.eventTypes as string[],
enabled: r.enabled,
secret_rotated_at: r.secretRotatedAt?.toISOString() ?? null,
created_at: r.createdAt.toISOString(),
+ disabled_at: r.disabledAt?.toISOString() ?? null,
+ disabled_reason: r.disabledReason,
+ failure_run_started_at: r.failureRunStartedAt?.toISOString() ?? null,
+ failure_run_attempts: r.failureRunAttempts,
}));
}
async createUser(externalId: string, displayName?: string): Promise<UserRow> {
const id = randomUUID();
const inserted = await this.dbĐó là một diff lớn, nên đây là bốn thứ trong đó đáng kể.
WHERE … AND enabled = true. Một lần tắt thứ hai khớp không dòng nào và trả về
không gì, nên phần notification bên dưới không bao giờ tới được. Nhiều-nhất-một-lần là một tính chất
của SQL, không phải của đường code đã tới đó — và đó là thứ cho phép có hai
cơ chế kích hoạt một cách an toàn.
Tổ chức được phân giải vào lúc ghi, qua hai phép join vốn đã tồn tại, và được lưu lên notification. Lưu nó trông có vẻ thừa. Nó được lưu bởi dòng đó ghi lại một nghĩa vụ như nó vốn có lúc endpoint bị tắt, và một application chuyển giữa các tổ chức về sau không được lặng lẽ đổi đích của một notification vốn đã nợ một bên khác.
Lượt quét đọc status cuối từ delivery gần nhất của endpoint. Nó không có kết quả nào trong tay — nó kích hoạt đúng lúc không có kết quả nào tới — nên không có phép join lateral đó thì lý do duy nhất nó có thể ghi là "không rõ nguyên nhân", mà đó chính là notification mà một kỹ sư hỗ trợ thực sự nhận được.
Kết quả của một test event được miễn khỏi tất cả những điều đó, và đó là mục sau nữa.
Lượt quét đi nhờ một vòng lặp vốn đã tồn tại
Cơ chế kích hoạt thứ hai là thêm một câu lệnh mỗi lần drain, trong một worker vốn đã tỉnh, vốn đã sở hữu một database connection, và vốn đã chạy trong service duy nhất được phép ghi:
@@ -9,13 +9,17 @@ import {
RetentionPolicy,
StorageType,
type NatsConnection,
} from "nats";
import type { Db } from "../db/client";
-import { drainDueDeliveries, type DueDeliveryRow } from "../db/repository";
+import {
+ drainDueDeliveries,
+ sweepDisabledEndpoints,
+ type DueDeliveryRow,
+} from "../db/repository";
import type { Publisher } from "../outbox/publisher";
// The second relay (research R13).
//
// This is the moment the outbox chapter's stops being a thing that moves EVENTS
// and becomes a shape: `SELECT … FOR UPDATE SKIP LOCKED`, publish, mark, for any
@@ -81,26 +85,38 @@ const IDLE_INTERVAL_MS = 250;
export interface DeliveryRelay {
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 only tests exercise. */
drainOnce(): Promise<number>;
+ /** One sweep, same argument. Returns how many endpoints it disabled. */
+ sweepOnce(): Promise<number>;
}
export function createDeliveryRelay({
db,
publisher,
logger,
batchSize = BATCH_SIZE,
intervalMs = IDLE_INTERVAL_MS,
+ sweepEnabled = process.env["RELAY_DISABLE_SWEEP"] !== "off",
}: {
db: Db;
publisher: Publisher;
logger: Logger;
batchSize?: number;
intervalMs?: number;
+ /** `RELAY_DISABLE_SWEEP=off` turns the second trigger off, and it exists for one
+ * purpose: quickstart V6 asks a reader to watch a quiet endpoint stay enabled
+ * and failing with the sweep off, and then be disabled with it on. The first
+ * half is what an outcome-only check ships, and reading it is the only way the
+ * second half means anything.
+ *
+ * DEFAULT ON. A flag whose default disabled a requirement would be a
+ * requirement nobody had built. */
+ sweepEnabled?: boolean;
}): DeliveryRelay {
let running = false;
let loop: Promise<void> = Promise.resolve();
async function drainOnce(): Promise<number> {
return drainDueDeliveries(db, batchSize, async (row: DueDeliveryRow) => {
@@ -132,12 +148,41 @@ export function createDeliveryRelay({
attempt: row.attempt,
},
});
});
}
+ /** The auto-disable sweep, riding this loop (research R1).
+ *
+ * Here rather than in a scheduler of its own because this worker is already
+ * awake, already owns a database connection, and already runs in the one service
+ * permitted to write (constitution VII, constitution IV). A third background
+ * process would be a third thing to deploy, monitor and reason about, for one
+ * statement.
+ *
+ * Its failure is logged and dropped for the same reason the drain's is: an
+ * endpoint that should have been disabled and was not is a cost measured in one
+ * more failed delivery, while a relay that stops draining is a cost measured in
+ * every customer's webhooks. */
+ async function sweepOnce(): Promise<number> {
+ if (!sweepEnabled) return 0;
+ try {
+ const disabled = await sweepDisabledEndpoints(db);
+ if (disabled > 0) {
+ // A COUNT, and only when it is not zero. This runs several times a second
+ // when the platform is idle, and a line per pass would bury every other
+ // line in the service.
+ logger.log("info", "webhooks.endpoints_disabled", { count: disabled });
+ }
+ return disabled;
+ } catch (error) {
+ logger.log("error", "webhooks.disable_sweep_failed", { error: String(error) });
+ return 0;
+ }
+ }
+
async function run(): Promise<void> {
while (running) {
try {
const dispatched = await drainOnce();
if (dispatched > 0) {
// Counts, never payloads — and never a signing secret, which is why
@@ -148,12 +193,17 @@ export function createDeliveryRelay({
} catch (error) {
// A broker that is down is an expected state, not a crash. Rows stay
// pending and due, so the backlog drains when it returns — the same
// buffering the outbox chapter's relay promises for events.
logger.log("error", "deliveries.drain_failed", { error: String(error) });
}
+ // AFTER the drain and only when there was nothing due, so a backlog is never
+ // made to wait behind a housekeeping query. An endpoint that has been failing
+ // for an hour can wait another quarter of a second; a customer's webhook
+ // cannot.
+ await sweepOnce();
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
}
return {
start() {
@@ -163,8 +213,9 @@ export function createDeliveryRelay({
},
async stop() {
running = false;
await loop;
},
drainOnce,
+ sweepOnce,
};
}Không thứ mới để triển khai, không cron, không scheduler. Hiến pháp VII đòi phương án buồn tẻ và ở đây phương án buồn tẻ cũng là phương án duy nhất không thêm một thứ thứ ba để triển khai và theo dõi chỉ vì một câu query.
Lượt quét chạy sau lần drain và chỉ khi chẳng có gì đến hạn, nên một backlog không bao giờ phải chờ sau việc dọn dẹp. Một endpoint hỏng suốt một tiếng có thể chờ thêm một phần tư giây; webhook của một khách hàng thì không.
RELAY_DISABLE_SWEEP=off tồn tại vì một mục đích, và đó là mục đích dạy học:
nó cho bạn xem cái endpoint ít lưu lượng cứ hỏng mãi. Màn trình diễn đó nằm ở phần sau
của chương này, và nó là thứ duy nhất làm cho cơ chế kích hoạt thứ hai trông khác
một biện pháp thừa.
Cái notification chẳng ai nhận
Việc tắt ghi một dòng vào một bảng mới:
webhook_disable_notifications
environment_id · organisation_id · endpoint_id
disabled_at · run_started_at · run_attempts
last_status · last_error
delivered_at <-- always null in this chapterFR-WHK-07 yêu cầu endpoint bị tắt và tổ chức được báo qua email. Nền tảng này không có bất kỳ transport email nào. Không phải một cái cấu hình sai — là không có.
Nên yêu cầu mới hoàn thành một nửa, và delivered_at là cột nói ra
điều đó. Nó tồn tại trong chương này chỉ để mà null. Một schema chỉ ghi lại
việc tắt sẽ để một người đọc tương lai — kể cả bạn của tương lai — tin rằng
yêu cầu đã xong, và lần đầu tiên ai đó phát hiện ra không phải vậy
sẽ là khi một khách hàng hỏi vì sao chẳng ai báo họ.
Forward reference — undefined
Chương quota cần chính cái transport đó cho các cảnh báo của nó, và đó là lý do nó không được dựng ở đây: dựng nó bây giờ nghĩa là dựng nó cho người tiêu thụ thứ hai trước, và đoán mò xem người đầu tiên cần gì.
Why
Có một khác biệt thật giữa "chúng tôi đã không làm" và "chúng tôi đã làm phần phải bền, và để lại phần phải mang tính vận hành".
Nghĩa vụ là phần bền. Tổ chức nào được nợ một lời nhắn, về endpoint nào, cho cửa sổ nào, với bằng chứng gì — tất cả những thứ đó phải được ghi lại vào đúng khoảnh khắc tắt, bên trong cùng transaction, nếu không nó mất. Một transport có thể thêm về sau và sẽ thấy hàng đợi đang chờ nó.
Làm theo chiều ngược lại — gửi một email lúc tắt mà không có bản ghi nào — là phiên bản không thể sửa về sau.
Chứng minh nó chạy lại được
Giờ vòng lặp khép lại. Một khách hàng có endpoint bị tắt sửa server của họ. Họ làm gì tiếp theo?
Không có test event, họ bật lại và cầu may, và event thật đầu tiên là thí nghiệm. Nếu họ sai, endpoint tích thêm một tiếng hỏng nữa và bị tắt lần thứ hai — đó là cách một endpoint bị tắt hai lần trong một ngày và một khách hàng kết luận rằng nền tảng hỏng.
FR-WHK-09 là một event tổng hợp, gửi tới một endpoint theo yêu cầu, giao qua đường bình thường với ba điểm khác biệt:
@@ -1,11 +1,17 @@
-import { Injectable, NotFoundException } from "@nestjs/common";
+import { Inject, Injectable, NotFoundException } from "@nestjs/common";
+import type { Db } from "../db/client";
import { WEBHOOK_EVENT_TYPES } from "../outbox/event";
import { protocolError } from "../protocol-error";
-import { Repository, type WebhookEndpointRow } from "../db/repository";
+import {
+ createTestDelivery,
+ Repository,
+ testDeliveryResult,
+ type WebhookEndpointRow,
+} from "../db/repository";
import { encryptSecret, mintSigningSecret } from "./secret";
// The management surface's rules. Two of them are worth stating
// here rather than leaving to the controller, because both are the kind of thing
// that looks like validation and is actually a security boundary.
@@ -41,15 +47,37 @@ export interface CreateEndpointInput {
/** What a customer receives once and never again. */
export interface EndpointWithSecret extends WebhookEndpointRow {
secret: string;
}
+/** How long a caller waits for a test event to come back.
+ *
+ * The attempt happens in the dispatcher's process, so this route can only watch
+ * the row. Ten seconds is above the dispatcher's own attempt timeout, so a
+ * customer whose server hangs gets the honest answer — "no response" — rather than
+ * this bound firing first and reporting an inconclusive result for a conclusive
+ * failure. */
+const TEST_EVENT_TIMEOUT_MS = 10_000;
+const TEST_EVENT_POLL_MS = 100;
+
+/** What a test event answered, or why there is no answer yet. */
+export interface TestEventResult {
+ delivered: boolean;
+ status: number | null;
+ latency_ms: number | null;
+ error: string | null;
+ event_id: string;
+}
+
@Injectable()
export class WebhooksService {
- constructor(private readonly repo: Repository) {}
+ constructor(
+ private readonly repo: Repository,
+ @Inject("DB") private readonly db: Db,
+ ) {}
async create(input: CreateEndpointInput): Promise<EndpointWithSecret> {
this.assertDeliverableUrl(input.url);
this.assertEventTypes(input.event_types);
const existing = await this.repo.countEndpoints();
@@ -95,12 +123,65 @@ export class WebhooksService {
async setEnabled(id: string, enabled: boolean): Promise<WebhookEndpointRow> {
const row = await this.repo.setEndpointEnabled(id, enabled);
if (!row) throw new NotFoundException("no such webhook endpoint");
return row;
}
+ /** Send one synthetic event to one endpoint and report what it answered
+ * (FR-013…FR-016, research R8).
+ *
+ * The delivery is REAL: a row on the ordinary schedule, published by the
+ * ordinary relay, posted and signed by the ordinary dispatcher, its outcome
+ * recorded by the ordinary seam. This method creates it and then WATCHES,
+ * because the attempt happens in another process and constitution IV does not
+ * let that process write its own answer anywhere else.
+ *
+ * That is why FR-014 is met rather than approximated. A route that posted the
+ * request itself would need a second copy of the signing code, and a test signed
+ * by a second copy proves nothing about real deliveries — which is the one thing
+ * a test event exists to prove. */
+ async sendTestEvent(endpointId: string): Promise<TestEventResult> {
+ const created = await createTestDelivery(this.db, {
+ endpointId,
+ environmentId: this.repo.environment,
+ });
+ // The same 404 a missing endpoint gets, so a probe cannot tell "no such
+ // endpoint" from "not yours" (FR-TEN-05).
+ if (!created) throw new NotFoundException("no such webhook endpoint");
+
+ const deadline = Date.now() + TEST_EVENT_TIMEOUT_MS;
+ for (;;) {
+ const result = await testDeliveryResult(this.db, created.deliveryId);
+ if (result?.settled) {
+ return {
+ delivered: result.delivered,
+ status: result.status,
+ latency_ms: result.latencyMs,
+ error: result.error,
+ event_id: created.eventId,
+ };
+ }
+ if (Date.now() >= deadline) {
+ // NOT an HTTP error, and the distinction is the contract's. A non-2xx from
+ // the customer means the test succeeded in finding out; this means the
+ // platform did not find out, and the `error` says which. Answering 500
+ // would conflate "we could not run the test" with "your endpoint is
+ // unhealthy", and the second is what a customer would read.
+ return {
+ delivered: false,
+ status: null,
+ latency_ms: null,
+ error:
+ "no attempt was reported within 10s — is the dispatcher running?",
+ event_id: created.eventId,
+ };
+ }
+ await new Promise((resolve) => setTimeout(resolve, TEST_EVENT_POLL_MS));
+ }
+ }
+
async remove(id: string): Promise<void> {
const deleted = await this.repo.deleteEndpoint(id);
if (!deleted) throw new NotFoundException("no such webhook endpoint");
}
private assertDeliverableUrl(raw: string): void {Route báo lại thứ endpoint đã trả lời, và luôn trả lời 200 khi
bài test đã chạy:
@@ -64,12 +64,29 @@ export class WebhooksController {
@Post(":id/disable")
@HttpCode(200)
disable(@Param("id") id: string) {
return this.webhooks.setEnabled(id, false);
}
+ /** Send a synthetic event to this endpoint and report what it answered
+ * (FR-013, FR-016).
+ *
+ * ALWAYS 200 when the test ran, whatever the endpoint said. A non-2xx from the
+ * customer's server is reported as `delivered: false` with the status, because
+ * the TEST succeeded — it found out. Answering 502 here would conflate "we could
+ * not run the test" with "your endpoint is unhealthy", and a customer debugging
+ * at 3 a.m. would read the second.
+ *
+ * 404 for an endpoint in another environment, the same answer a missing one
+ * gets. */
+ @Post(":id/test")
+ @HttpCode(200)
+ test(@Param("id") id: string) {
+ return this.webhooks.sendTestEvent(id);
+ }
+
/** SOFT delete. The endpoint stops receiving deliveries and disappears from
* every read; the row survives because its dead letters must (FR-WHK-04). */
@Delete(":id")
@HttpCode(204)
async remove(@Param("id") id: string): Promise<void> {
await this.webhooks.remove(id);Why
Endpoint của một khách hàng trả lời 503 là một bài test thành công. Nó đã tìm ra.
Trả về 502 từ route này sẽ gộp "chúng tôi không chạy được bài test" với
"endpoint của bạn không khoẻ", và vào ba giờ sáng thì cái thứ hai mới là thứ
người ta đọc.
Trường hợp duy nhất mà error mô tả nền tảng chứ không phải khách hàng là
khi không lần thử nào được báo cáo — không có dispatcher nào chạy. Kể cả khi đó câu trả lời
vẫn là 200 với delivered: false, và phần lỗi nói bên nào đã hỏng.
Vẫn giao kể cả khi đã tắt. Đây là điểm khác biệt làm cho vòng lặp
khép lại được, và nó va với một bất biến từ đầu chương: một
endpoint đã tắt thì không nhận lần thử nào. Cả hai đều đúng, và chúng gặp nhau ở đúng
một điều kiện — cái điều kiện nơi dispatcher hỏi xin vật liệu để thực hiện một
lần thử. synthetic là thứ phân biệt, và đó là mục đích của cột đó. Một
endpoint đã xoá mềm vẫn bị từ chối: đã xoá nghĩa là biến mất khỏi API của chính khách hàng,
và giao tới một cái như vậy sẽ là nền tảng với tới một URL mà khách hàng
tin rằng nó đã quên.
Kết quả của nó không bao giờ chạm vào chuỗi hỏng. Một bài test hỏng không được đẩy một endpoint tiến gần tới việc bị tắt — điều đó sẽ trừng phạt một khách hàng vì đã đi kiểm tra. Một bài test thành công cũng không được xoá chuỗi, nếu không một khách hàng có thể che giấu một sự cố thật bằng cách cứ test cho tới khi nó pass. Cả hai chiều, và đó là lý do phần miễn trừ nằm ở chỗ gọi chứ không nằm bên trong.
Bật lại xoá cả bốn cột, nên một tiếng được đo từ lần hỏng tiếp theo. Không có điều đó, một khách hàng đã sửa server và bật nó lại sẽ bị tắt lại bởi lần hỏng đầu tiên sau đó, dựa trên một sự cố họ đã sửa xong.
Xem nó diễn ra
Lượt đi bộ từ chương webhook dispatcher mọc thêm một cờ. Khởi động một endpoint hỏng vĩnh viễn ở một terminal:
$ node scripts/hostile-endpoint.mjs --mode=fail --quiet --secret=hunter2
MARKER listening url=http://127.0.0.1:4555/hook mode=failvà ở một terminal khác, lái một event qua cả lịch rồi qua luôn mốc một tiếng:
@@ -5,15 +5,19 @@
//
// # in another
// node scripts/webhook-walk.mjs
// node scripts/webhook-walk.mjs --print-signing-material
// node scripts/webhook-walk.mjs --fast-forward # against --mode=fail
// node scripts/webhook-walk.mjs --send-only # leave it for the real dispatcher
+// node scripts/webhook-walk.mjs --fast-forward --watch-disable # this chapter
//
// --url=http://127.0.0.1:4555/hook where to point the endpoint
// --api-port=4141 the api this walk spawns for itself
+// --watch-disable this chapter: print the failure run as it
+// grows and the disablement when it lands.
+// Ages the run rather than waiting an hour.
// --secret=SECRET pin the signing secret instead of minting
// one, so the endpoint can be started with
// the same value and verify what arrives:
//
// node scripts/hostile-endpoint.mjs --secret=hunter2
// node scripts/webhook-walk.mjs --secret=hunter2 --print-signing-material
@@ -56,12 +60,15 @@ const { createLogger } = await import(
const { createDispatcher } = await import(
join(ROOT, "services", "dispatcher", "dist", "main.js")
);
const { SIGNATURE_SCHEME } = await import(
join(ROOT, "services", "dispatcher", "dist", "signature.js")
);
+const { DISABLE_AFTER_MS, DISABLE_MIN_ATTEMPTS } = await import(
+ join(API_DIST, "webhooks", "disable.js")
+);
const { RETRY_TIERS_MS, MAX_ATTEMPTS } = await import(
join(API_DIST, "webhooks", "schedule.js")
);
const { DeliverPolicy } = await import(
join(ROOT, "services", "dispatcher", "node_modules", "nats", "lib", "src", "mod.js")
);
@@ -74,12 +81,13 @@ const flag = (name) => process.argv.includes(`--${name}`);
const ENDPOINT_URL = arg("url", "http://127.0.0.1:4555/hook");
const API_PORT = Number(arg("api-port", "4141"));
const PRINT_MATERIAL = flag("print-signing-material");
const SEND_ONLY = flag("send-only");
const FAST_FORWARD = flag("fast-forward");
+const WATCH_DISABLE = flag("watch-disable");
const PINNED_SECRET = arg("secret", "");
const CREDENTIAL = "rk_svc_walk_0123456789abcdef0123456789abcd";
const show = (label, value) => console.log(`${label.padEnd(26)} ${value}`);
const rule = (title) => console.log(`\n=== ${title} ${"=".repeat(Math.max(0, 46 - title.length))}`);
@@ -290,12 +298,27 @@ const report = async () => {
};
await dispatcher.pollOnce();
console.log("");
await report();
+/** The endpoint's failure run, straight from the row auto-disable reads.
+ *
+ * Read with plain SQL because this script is a reader's tool and the columns are
+ * the point: `enabled` is what stops deliveries, and `disabled_at` is how a
+ * customer tells a platform disablement from their own (FR-009). */
+const runOf = async () => {
+ const { rows } = await pool.query(
+ `SELECT enabled, disabled_at, disabled_reason,
+ failure_run_started_at, failure_run_attempts
+ FROM webhook_endpoints WHERE id = $1`,
+ [endpoint.id],
+ );
+ return rows[0];
+};
+
// ---------------------------------------------------------------------------
if (FAST_FORWARD) {
rule("5. the whole schedule, without waiting for it");
console.log(
` tiers: ${RETRY_TIERS_MS.map((ms) => (ms === 0 ? "now" : `${ms / 1000}s`)).join(" → ")}`,
@@ -313,12 +336,13 @@ if (FAST_FORWARD) {
const until = Date.now() + timeoutMs;
while (!(await done()) && Date.now() < until) await dispatcher.pollOnce();
};
const stateOf = async () => (await repo.listDeliveriesForEvent(eventId))[0];
+
for (let i = 0; i < MAX_ATTEMPTS + 2; i++) {
const before = await stateOf();
if (!before || before.state !== "pending") break;
// Drag everything still pending into the past, then run the two loops the
// way they run in production.
@@ -341,12 +365,22 @@ if (FAST_FORWARD) {
show(
` attempt ${before.attempt}`,
after && after.attempt !== before.attempt
? `failed → rescheduled as attempt ${after.attempt}`
: `failed → ${after ? after.state : "gone"}`,
);
+
+ if (WATCH_DISABLE) {
+ const run = await runOf();
+ show(
+ " endpoint",
+ run.failure_run_started_at
+ ? `run open · ${run.failure_run_attempts} failures · enabled=${run.enabled}`
+ : `no run · enabled=${run.enabled}`,
+ );
+ }
}
console.log("");
await report();
const dead = (await repo.listDeadLetters()).filter((d) => d.event_id === eventId);
@@ -354,12 +388,81 @@ if (FAST_FORWARD) {
show("dead letters", dead.length);
for (const d of dead) {
show(` ${d.id.slice(0, 8)}`, `attempts=${d.attempts} last_status=${d.last_status ?? "none"}`);
}
}
+// ---------------------------------------------------------------------------
+if (WATCH_DISABLE) {
+ rule("6. when to stop trying");
+
+ console.log(" The run above is what auto-disable reads — two columns on the endpoint,");
+ console.log(" never the attempt stream. A backlogged analytics path cannot delay a");
+ console.log(" disablement, and a broker being unwell cannot block one.\n");
+ console.log(` The rule: longer than ${DISABLE_AFTER_MS / 60000} minutes AND at least`);
+ console.log(` ${DISABLE_MIN_ATTEMPTS} failures. Both, never either — the hour alone would let one`);
+ console.log(" failure followed by a two-hour retry gap disable an endpoint.\n");
+
+ const before = await runOf();
+ show("failures in the run", before.failure_run_attempts ?? 0);
+ show("enabled", before.enabled);
+
+ if (!before.failure_run_started_at) {
+ console.log("\n No open run — the endpoint answered 2xx at some point, which CLEARS it.");
+ console.log(" Point this at --mode=fail to watch a run survive long enough to matter.\n");
+ } else {
+ // AGED, not waited out. Same honesty as --fast-forward above: the clock moves,
+ // the logic does not. An hour and four minutes of real time would make this
+ // demonstration one nobody runs.
+ console.log(" aging the run past the hour (the clock moves, the rule does not)...\n");
+ await pool.query(
+ `UPDATE webhook_endpoints
+ SET failure_run_started_at = now() - interval '64 minutes'
+ WHERE id = $1`,
+ [endpoint.id],
+ );
+
+ // THE SWEEP, which is the trigger this endpoint needs. Its last attempt has
+ // already been made — the delivery dead-lettered above — so no further outcome
+ // will ever be reported and an on-outcome check would never fire again. That is
+ // research R1's quiet endpoint, and it is the reason there are two triggers.
+ const disabled = await relay.sweepOnce();
+ show("endpoints the sweep disabled", disabled);
+
+ const after = await runOf();
+ console.log("");
+ show("enabled", after.enabled);
+ show("disabled_at", after.disabled_at ? after.disabled_at.toISOString() : "null");
+ show("disabled_reason", after.disabled_reason ?? "null");
+
+ const { rows: notes } = await pool.query(
+ `SELECT run_attempts, last_status, last_error, delivered_at
+ FROM webhook_disable_notifications WHERE endpoint_id = $1`,
+ [endpoint.id],
+ );
+ console.log("");
+ show("notification rows", notes.length);
+ for (const n of notes) {
+ show(
+ ` run of ${n.run_attempts}`,
+ `last_status=${n.last_status ?? "none"} delivered_at=${n.delivered_at ?? "null"}`,
+ );
+ }
+ console.log("");
+ console.log(" `delivered_at` is null and stays null. FR-WHK-07 asks for the");
+ console.log(" organisation to be notified BY EMAIL, and this platform has no email");
+ console.log(" transport of any kind. The row is the obligation; the null is the");
+ console.log(" admission. A later chapter needs the same transport for quotas.\n");
+
+ // Running it again must change nothing. At most once per run, enforced by the
+ // `enabled = true` predicate in the update rather than by a check.
+ const second = await relay.sweepOnce();
+ show("a second sweep disables", second);
+ }
+}
+
rule("what to take from this");
console.log(`
The delivery row is the schedule. Not a message the broker is holding — that
was measured in research R1 and rejected, because a sleeping message keeps its
acknowledgement slot and dead endpoints would starve healthy ones.Chuỗi của endpoint được in bên cạnh mỗi lần thử, và lần tắt có một mục riêng:
$ node scripts/webhook-walk.mjs --secret=hunter2 --fast-forward --watch-disable
=== 5. the whole schedule, without waiting for it =
tiers: now → 1s → 5s → 30s → 300s → 1800s → 7200s
7 attempts, then the delivery is dead-lettered.
attempt 2 failed → rescheduled as attempt 3
endpoint run open · 2 failures · enabled=true
attempt 3 failed → rescheduled as attempt 4
endpoint run open · 3 failures · enabled=true
attempt 4 failed → rescheduled as attempt 5
endpoint run open · 4 failures · enabled=true
attempt 5 failed → rescheduled as attempt 6
endpoint run open · 5 failures · enabled=true
attempt 6 failed → rescheduled as attempt 7
endpoint run open · 6 failures · enabled=true
attempt 7 failed → dead
endpoint run open · 7 failures · enabled=true
delivery b9a68bee attempt=7 state=dead next=2026-08-18T14:52:26.767Z
dead letters 1
3e2acaf8 attempts=7 last_status=500Hãy dừng ở hai dòng cuối trước dead letter. Delivery là dead.
Chuỗi giữ bảy lần hỏng. Endpoint là enabled=true.
Đó là cái endpoint ít lưu lượng của research R1, tới được bằng cách chạy chứ không phải bằng số học. Lịch đã cạn, nên không kết quả nào nữa sẽ được báo cáo cho endpoint này. Một phép kiểm chỉ-theo-kết-quả đã chạy lần cuối cùng của nó.
Rồi lượt quét:
=== 6. when to stop trying ========================
The rule: longer than 60 minutes AND at least
5 failures. Both, never either.
failures in the run 7
enabled true
aging the run past the hour (the clock moves, the rule does not)...
endpoints the sweep disabled 1
enabled false
disabled_at 2026-08-18T14:52:29.830Z
disabled_reason 7 consecutive failures over 1h04m; last status 500
notification rows 1
run of 7 last_status=500 delivered_at=null
a second sweep disables 0Giờ chạy đúng thứ đó với RELAY_DISABLE_SWEEP=off, tức là phiên bản của
nền tảng này chỉ xuất bản cơ chế kích hoạt hiển nhiên:
$ RELAY_DISABLE_SWEEP=off node scripts/webhook-walk.mjs --secret=hunter2 --fast-forward --watch-disable
failures in the run 7
enabled true
aging the run past the hour (the clock moves, the rule does not)...
endpoints the sweep disabled 0
enabled true
disabled_at null
disabled_reason null
notification rows 0Bảy lần hỏng, chuỗi hơn một tiếng tuổi, delivery đã vào dead letter, và endpoint vẫn bật. Đọc hai bản ghi đó cạnh nhau là thứ duy nhất làm cho cơ chế kích hoạt thứ hai có ý nghĩa.
Trap
Cả hai cờ đều viết lại timestamp: một cái kéo next_attempt_at về quá khứ để
lịch có thể được xem trong vài giây thay vì hai tiếng rưỡi, cái kia
làm failure_run_started_at già đi 64 phút.
Không cái nào đổi một điều kiện. Quy tắc quyết định vẫn là cùng
shouldDisable mà các bài test chạy và production chạy, đọc cùng các cột.
Một màn trình diễn bỏ qua phần logic thay vì bỏ qua phần chờ sẽ là
một màn trình diễn chẳng chứng minh gì, và sự phân biệt đó đáng khăng khăng bởi
dựng ra loại kia một cách vô tình thì rất dễ.
Stream, và một event lấy ra từ nó
$ node scripts/stream-info.mjs ANALYTICS
stream ANALYTICS
messages 7
bytes 0.0 MiB
consumers 0
configuration
subjects ["analytics.>"]
retention limits (immutable once created)
storage file (immutable once created)
replicas 1
max_age 604800s
max_bytes 1.00 GiB
discard old (at the bound, drop the OLDEST)
duplicate_window 120s (the broker's dedupe, not ours)
consumersBảy lần thử, bảy event, không consumer nào. Con số cuối đó là trạng thái trung thực của FR-WHK-06 ở cuối chương này.
stream-info.mjs trước đây không nhận đối số — nó có "EVENTS" viết cứng
ở ba chỗ. Trỏ bước xác thực của chương này vào nó mà không sửa sẽ
in ra cấu hình của stream sai và pass, bởi mọi field hiện ra
ở đây đều tồn tại trên cả hai. Một bước xác thực không thể fail thì tệ hơn không có
bước nào.
@@ -2,41 +2,65 @@
// The chapter quotes the broker, not the config file — a
// configuration that was written is not the same as a configuration that was
// applied, and the difference is exactly what this chapter is about.
//
// docker compose up -d --wait nats
// RELAY_NATS_URL=nats://localhost:14222 node scripts/stream-info.mjs
+// RELAY_NATS_URL=nats://localhost:14222 node scripts/stream-info.mjs ANALYTICS
+//
+// The stream is an ARGUMENT as of the retry-and-disable chapter, defaulting to the one stream that
+// existed when this was written. It had `"EVENTS"` in three places, and the retry-and-disable chapter's
+// quickstart asks the reader to inspect `ANALYTICS` — which would have printed
+// the wrong stream's configuration and passed, since every field it shows exists
+// on both. A validation step that cannot fail is worse than no step.
import { connect } from "../services/api/node_modules/nats/lib/src/mod.js";
+const stream = process.argv[2] ?? "EVENTS";
const url = process.env.RELAY_NATS_URL ?? "nats://127.0.0.1:4222";
const nc = await connect({ servers: url });
const jsm = await nc.jetstreamManager();
const seconds = (ns) => (ns === 0 ? "unlimited" : `${ns / 1e9}s`);
const bytes = (n) => (n === -1 ? "unlimited" : `${(n / 1024 ** 3).toFixed(2)} GiB`);
const show = (label, value) => console.log(`${label.padEnd(20)} ${value}`);
-const info = await jsm.streams.info("EVENTS");
+const info = await jsm.streams.info(stream).catch(() => null);
+if (info === null) {
+ // Named loudly rather than crashed on. A stream that does not exist yet is the
+ // ordinary state before the api has started once, and "no such stream" is a
+ // more useful answer than a broker client's stack trace.
+ console.error(
+ `no stream named ${stream}. The api CREATES its streams on first publish —\n` +
+ "start it once, or check the name (EVENTS, DELIVERIES, ANALYTICS).",
+ );
+ await nc.drain();
+ process.exit(1);
+}
const c = info.config;
-console.log("stream EVENTS");
+console.log(`stream ${stream}`);
show(" messages", info.state.messages);
show(" bytes", `${(info.state.bytes / 1024 ** 2).toFixed(1)} MiB`);
show(" consumers", info.state.consumer_count);
console.log("configuration");
show(" subjects", JSON.stringify(c.subjects));
show(" retention", `${c.retention} (immutable once created)`);
show(" storage", `${c.storage} (immutable once created)`);
show(" replicas", c.num_replicas);
-show(" max_age", `${seconds(c.max_age)} (NFR-REL-08 floor: 86400s)`);
+show(
+ " max_age",
+ stream === "EVENTS"
+ ? `${seconds(c.max_age)} (NFR-REL-08 floor: 86400s)`
+ : seconds(c.max_age),
+);
show(" max_bytes", bytes(c.max_bytes));
show(" discard", `${c.discard} (at the bound, drop the OLDEST)`);
show(" duplicate_window", `${seconds(c.duplicate_window)} (the broker's dedupe, not ours)`);
console.log("consumers");
-for await (const ci of jsm.consumers.list("EVENTS")) {
+for await (const ci of jsm.consumers.list(stream)) {
show(
` ${ci.name}`,
`pending=${ci.num_pending} ack_pending=${ci.num_ack_pending} redelivered=${ci.num_redelivered} max_deliver=${ci.config.max_deliver ?? "-"}`,
);
}Đây là một event, đọc ra từ stream theo cách mà ingester của Phần 4 rồi sẽ đọc:
Trên subject analytics.webhook.attempt.b0fc170b-58dc-435d-b452-3f7df834e90f:
{
"delivery_id": "eb8fa3c9-606f-49a1-b872-b577efefe793",
"endpoint_id": "2678590a-addc-489c-9ef5-3f8a807ddb16",
"environment_id": "b0fc170b-58dc-435d-b452-3f7df834e90f",
"event_id": "9f0667a3-c049-459d-9291-c70cffb35871",
"attempt": 1,
"attempted_at": "2026-08-18T16:06:13.509Z",
"status": 500,
"latency_ms": 12,
"outcome": "rescheduled"
}Hãy đọc những field không có ở đó: không payload, không signing secret, không chữ ký, không URL, không header. Kích thước, định danh, status và thời lượng, và không gì khác.
outcome là field duy nhất mà dispatcher không thể cung cấp. Nó có
status và độ trễ; chỉ api mới biết một cái 500 nghĩa là "thử lại sau
năm phút" hay "đó là lần thứ bảy, hãy ghi dead letter".
Trap
Dispatcher có một kết quả thứ tư. Khi một endpoint đã bị tắt hoặc
xoá kể từ khi một delivery được lên lịch, deliver.ts ghi log delivery.skipped và
đi tiếp — và không báo cáo cái đó cho ai cả.
Nên không kết quả nào được ghi và không attempt event nào được publish, và điều đó đúng chứ không phải một lỗ hổng: một lần bỏ qua nghĩa là không request nào được thực hiện, và một bản ghi cho một lần thử chưa từng xảy ra sẽ đặt một dòng vào dashboard của một khách hàng mô tả một request mà server của họ chưa bao giờ nhận.
Bản nháp đầu tiên của hợp đồng chương này liệt kê skipped là một giá trị thứ tư.
Bất cứ ai viết một consumer dựa trên nó sẽ xuất bản một nhánh không bao giờ
chạy được.
Các suite giữ gì
Ba suite mới và một suite sẵn có lớn hơn nhiều. Bản ghi lần thử, trên một broker thật và một api thật:
import "reflect-metadata";
import { randomUUID } from "node:crypto";
import { Test } from "@nestjs/testing";
import type { INestApplication } from "@nestjs/common";
import { AckPolicy, connect, DeliverPolicy, type NatsConnection } from "nats";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { ALL_ANALYTICS_SUBJECT, ANALYTICS_STREAM } from "@relay/protocol";
import { AppModule } from "../app.module";
import { createDb, createPool, type Db } from "../db/client";
import { ensureAnalyticsStream } from "../outbox/jetstream.publisher";
import {
createEnvironment,
drainDueDeliveries,
expandEventToDeliveries,
recordAttemptOutcome,
Repository,
} from "../db/repository";
import { encryptSecret, mintSigningSecret } from "./secret";
import { MAX_ATTEMPTS } from "./schedule";
// The attempt record, against a real broker and a real api.
//
// Invariants 1, 2, 3 and 5 of contracts/attempts.md live here. Invariant 4 is the
// swallowed publish failure and is pure, so it lives in analytics.test.ts.
//
// This suite drives the INTERNAL ROUTE over HTTP rather than calling
// `publishAttempt` itself, and that is the whole point of it being an integration
// test. The claim under test is not "the publisher works" — the unit lane holds
// that — it is "recording an outcome puts exactly one event on the stream, and the
// publish happens outside the transaction that recorded it". Neither half is
// visible from inside the publisher.
//
// Every environment is minted here, and every assertion is scoped to a subject
// carrying that environment's id. The stream is global and this lane runs beside
// other suites, so an assertion that counted messages on `analytics.>` would be
// counting somebody else's work (the outbox chapter's finding 4, again).
const CREDENTIAL =
process.env["RELAY_INTERNAL_CREDENTIAL"] ??
"rk_svc_attempts_itest_0123456789abcdef0123";
/** A per-run consumer name. A durable IS a position in a shared stream, and
* THIS CHAPTER'S own baseline is the reason this is a per-SUITE prefix rather than
* a shared `itest-`: the api's consumer suite once swept every consumer whose name
* began with that, and deleted a live one belonging to another suite. */
const SUITE = "itest-attempts";
describe("the attempt record", () => {
let app: INestApplication;
let url: string;
let db: Db;
let nats: NatsConnection;
let env: { id: string };
let repo: Repository;
let durable: string;
const seedEndpoint = async (
scope: Repository,
eventTypes = ["message.created"],
) => {
const secret = mintSigningSecret();
return scope.createEndpoint({
url: `https://example.test/${randomUUID()}`,
eventTypes,
secretCiphertext: encryptSecret(secret),
});
};
/** The relay's claim, with the publish stubbed out.
*
* `drainDueDeliveries` takes the publish as a callback — the seam that makes
* the outbox chapter's broker-agnostic — so claiming a delivery without putting
* it on the DELIVERIES stream is a one-line stub rather than a mock. This suite
* is about the ANALYTICS stream, and a real delivery publish here would hand
* work to whatever dispatcher happens to be running beside it. */
const claimOnly = () => drainDueDeliveries(db, 50, async () => {});
/** Expand one event into deliveries and claim them, which is what leaves a row
* an outcome can be reported against. The relay's claim is included because an
* unclaimed delivery is not one the dispatcher would ever have posted. */
const deliveryFor = async (environmentId: string, scope: Repository) => {
const eventId = randomUUID();
await expandEventToDeliveries(db, {
eventId,
environmentId,
type: "message.created",
payload: { id: eventId, type: "message.created" },
});
await claimOnly();
const [row] = await scope.listDeliveriesForEvent(eventId);
expect(row).toBeDefined();
return { eventId, delivery: row! };
};
const report = (body: Record<string, unknown>) =>
fetch(`${url}/internal/dispatch/outcome`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${CREDENTIAL}`,
},
body: JSON.stringify(body),
});
/** Everything this suite has taken off the stream, filed by environment.
*
* A BUFFER rather than a filter, and the first version of this file got it
* wrong in a way worth recording: it acknowledged every message it read and
* returned only the ones matching the environment asked for. Reading
* environment A's records therefore consumed and discarded environment B's, and
* the two-tenant test failed looking for a record that had already been thrown
* away by the assertion before it. A shared position needs shared bookkeeping. */
const inbox = new Map<string, Record<string, unknown>[]>();
/** Take whatever is available right now, file it, acknowledge it. Records from
* other suites in this lane land here too and are acknowledged so they do not
* come back; they simply never match an environment any test asks about. */
const drain = async (): Promise<void> => {
const consumer = await nats.jetstream().consumers.get(ANALYTICS_STREAM, durable);
const messages = await consumer.fetch({ max_messages: 100, expires: 1_000 });
for await (const msg of messages) {
const payload = msg.json<Record<string, unknown>>();
msg.ack();
const key = String(payload["environment_id"]);
inbox.set(key, [...(inbox.get(key) ?? []), payload]);
}
};
/** Poll until this environment has at least `atLeast`, then answer with all of
* them.
*
* Polls rather than peeks: the publish happens after the outcome commits, so a
* single fetch races the broker — and a test that fetches once is testing
* something the platform never does. the webhook dispatcher chapter's suite learned this and said
* so in as many words. */
const collected = async (
environmentId: string,
atLeast = 1,
budgetMs = 15_000,
): Promise<Record<string, unknown>[]> => {
const deadline = Date.now() + budgetMs;
while (Date.now() < deadline) {
await drain();
if ((inbox.get(environmentId) ?? []).length >= atLeast) break;
}
return inbox.get(environmentId) ?? [];
};
/** Every environment this suite mints, so its leftovers can be settled.
*
* The relay's drain is GLOBAL and claims 50 due rows at a time, oldest first.
* Reports of failure reschedule their delivery, so a suite that reports failures
* and never delivers them leaves rows that are due again a second later and stay
* that way. Enough of them starve a later suite's own delivery out of the window
* — which is exactly how four `dispatcher.itest.ts` tests failed under the
* coverage lane, with nothing delivered and no error anywhere. */
const minted: string[] = [];
const mintEnvironment = async (name: string) => {
const created = await createEnvironment(db, { name });
minted.push(created.id);
return created;
};
beforeAll(async () => {
process.env["RELAY_INTERNAL_CREDENTIAL"] = CREDENTIAL;
db = createDb(createPool());
env = await createEnvironment(db, { name: "attempts-itest" });
minted.push(env.id);
repo = new Repository(db, env.id);
app = (
await Test.createTestingModule({ imports: [AppModule] }).compile()
).createNestApplication({ logger: false });
await app.listen(0);
url = await app.getUrl();
nats = await connect({
servers: process.env["RELAY_NATS_URL"] ?? "nats://localhost:4222",
});
// The stream is created by the API SERVICE, not here — one definition of a
// stream, as the webhook dispatcher chapter established for DELIVERIES. So the first outcome is
// reported before any consumer exists, which is also the honest ordering: the
// platform must not need a reader in order to write.
await seedEndpoint(repo);
const { delivery } = await deliveryFor(env.id, repo);
await report({
delivery_id: delivery.id,
attempt: 1,
status: 200,
latency_ms: 12,
});
const jsm = await nats.jetstreamManager();
durable = `${SUITE}-${randomUUID().slice(0, 8)}`;
await jsm.consumers.add(ANALYTICS_STREAM, {
durable_name: durable,
ack_policy: AckPolicy.Explicit,
// ALL, not New. The point of a seven-day retention is that a consumer
// arriving late still finds what it missed, and this consumer is arriving
// late on purpose — the record above was published before it existed.
deliver_policy: DeliverPolicy.All,
filter_subject: ALL_ANALYTICS_SUBJECT,
});
}, 60_000);
afterAll(async () => {
if (nats && !nats.isClosed()) {
const jsm = await nats.jetstreamManager();
// The durable goes with the run. The webhook dispatcher chapter's suite leaked two
// consumers per run onto a shared broker and the retry-and-disable chapter's baseline found ninety of
// them; this file does not add to that count.
await jsm.consumers.delete(ANALYTICS_STREAM, durable).catch(() => undefined);
// And the stream comes BACK, because the last test in this file deletes it.
// Leaving it deleted made every later suite in the lane log a swallowed
// publish failure — harmless by design, and still a whole lane of api
// instances quietly recording nothing. The api cannot restore it itself: its
// publisher ensures the stream when it opens a connection, and that
// connection is cached for the process's life.
//
// Restored by calling THE API'S OWN `ensureAnalyticsStream`, not by declaring
// the configuration again here. The first version of this teardown wrote its
// own `streams.add` and left out `max_bytes`, so the stream a reader then
// inspected with `stream-info.mjs` was the TEST's stream wearing the api's
// name — unbounded where the api bounds it at a gigabyte. Two definitions of
// one stream is a drift waiting for the day they disagree, and it took two
// hours to arrive.
await ensureAnalyticsStream(nats).catch(() => undefined);
await nats.drain();
}
if (minted.length > 0) {
const list = minted.map((id) => `'${id}'`).join(",");
await db.execute(
`UPDATE webhook_deliveries SET state = 'dead'
WHERE state = 'pending' AND environment_id IN (${list})`,
);
}
await app?.close();
}, 60_000);
it("invariant 1: the stream exists with the configuration the contract states", async () => {
// Created by the api's own publisher on first use — nothing in this file
// created it, and a test that created it would be asserting on its own work.
const info = await (await nats.jetstreamManager()).streams.info(ANALYTICS_STREAM);
expect(info.config.subjects).toEqual([ALL_ANALYTICS_SUBJECT]);
expect(info.config.max_age).toBe(7 * 24 * 60 * 60 * 1_000_000_000);
expect(info.config.discard).toBe("old");
expect(info.config.retention).toBe("limits");
});
it("invariants 1, 2, 3: a recorded outcome publishes one event carrying the four identifiers", async () => {
const scoped = await mintEnvironment("attempts-itest-one");
const scopedRepo = new Repository(db, scoped.id);
const endpoint = await seedEndpoint(scopedRepo);
const { eventId, delivery } = await deliveryFor(scoped.id, scopedRepo);
const response = await report({
delivery_id: delivery.id,
attempt: 1,
status: 200,
latency_ms: 143,
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ outcome: "delivered" });
const events = await collected(scoped.id);
expect(events).toHaveLength(1);
const [event] = events;
expect(event).toMatchObject({
delivery_id: delivery.id,
endpoint_id: endpoint.id,
environment_id: scoped.id,
event_id: eventId,
attempt: 1,
status: 200,
latency_ms: 143,
outcome: "delivered",
});
// FR-004: identifiers, statuses and durations. Nothing else, and asserted as
// an exact key set so an added field fails here rather than shipping.
expect(Object.keys(event!).sort()).toEqual([
"attempt",
"attempted_at",
"delivery_id",
"endpoint_id",
"environment_id",
"event_id",
"latency_ms",
"outcome",
"status",
]);
expect(typeof event!["attempted_at"]).toBe("string");
});
it("records a timeout with NO status rather than omitting the attempt", async () => {
// FR-001's hardest case. Nothing answered, so there is no status to report —
// and the attempt still happened, which is exactly what a customer asking
// "what did you do on my behalf" needs to see.
const scoped = await mintEnvironment("attempts-itest-timeout");
const scopedRepo = new Repository(db, scoped.id);
await seedEndpoint(scopedRepo);
const { delivery } = await deliveryFor(scoped.id, scopedRepo);
await report({
delivery_id: delivery.id,
attempt: 1,
error: "timeout after 10000ms",
latency_ms: 10_000,
});
const [event] = await collected(scoped.id);
expect(event).toBeDefined();
expect("status" in event!).toBe(false);
expect(event!["error"]).toBe("timeout after 10000ms");
expect(event!["latency_ms"]).toBe(10_000);
expect(event!["outcome"]).toBe("rescheduled");
});
it("publishes one event per attempt across a whole exhausted schedule", async () => {
// US1's third acceptance scenario, and the only place in the chapter that
// drives a delivery to exhaustion on the stream. Seven attempts, seven
// events, ascending, with the last one dead-lettered.
//
// The tiers are not waited out — `recordAttemptOutcome` is called directly
// for attempts 2..7 and the schedule's clock is irrelevant to what gets
// published. Waiting for the real 2h tier would make this test unrunnable,
// and the thing under test is the record, not the delay.
const scoped = await mintEnvironment("attempts-itest-full");
const scopedRepo = new Repository(db, scoped.id);
await seedEndpoint(scopedRepo);
const { delivery } = await deliveryFor(scoped.id, scopedRepo);
await report({
delivery_id: delivery.id,
attempt: 1,
status: 500,
latency_ms: 20,
});
// Attempts 2..7 go through the route too, so every one of them exercises the
// publish. Each needs the row to be due and claimed again first.
for (let attempt = 2; attempt <= MAX_ATTEMPTS; attempt++) {
await db.execute(
`UPDATE webhook_deliveries
SET next_attempt_at = now() - interval '1 second', dispatched_at = NULL
WHERE id = '${delivery.id}'`,
);
await claimOnly();
const response = await report({
delivery_id: delivery.id,
attempt,
status: 500,
latency_ms: 20,
});
expect(response.status).toBe(200);
}
const events = await collected(scoped.id, MAX_ATTEMPTS, 30_000);
expect(events).toHaveLength(MAX_ATTEMPTS);
expect(events.map((e) => e["attempt"])).toEqual([1, 2, 3, 4, 5, 6, 7]);
// Six reschedules and then the end of the road.
expect(events.slice(0, MAX_ATTEMPTS - 1).map((e) => e["outcome"])).toEqual(
Array(MAX_ATTEMPTS - 1).fill("rescheduled"),
);
expect(events.at(-1)!["outcome"]).toBe("dead_lettered");
}, 60_000);
it("invariant 1: a REPEATED report publishes nothing the second time", async () => {
// The dispatcher posts, reports, then acknowledges, so a crash in the last
// gap makes a second report ordinary. It changes no row — and nothing on the
// analytical path deduplicates, so a second event here would show a customer
// a retry that never happened.
const scoped = await mintEnvironment("attempts-itest-repeat");
const scopedRepo = new Repository(db, scoped.id);
await seedEndpoint(scopedRepo);
const { delivery } = await deliveryFor(scoped.id, scopedRepo);
const body = {
delivery_id: delivery.id,
attempt: 1,
status: 200,
latency_ms: 30,
};
const first = await report(body);
const second = await report(body);
// The dispatcher is told the same thing both times — that is what idempotent
// means here — so the repeat is invisible to it.
expect(await first.json()).toEqual(await second.json());
// Spend a real budget looking for a second event rather than checking once.
const events = await collected(scoped.id, 2, 5_000);
expect(events).toHaveLength(1);
}, 30_000);
it("FR-018: no attempt event crosses a tenant boundary", async () => {
// Two environments, one report each, and each one's subject carries only its
// own. The subject is the filter a future consumer will use, so a mismatch
// between subject and payload is the shape a cross-tenant leak would take
// here — nothing would error, and one customer's dashboard would show
// another's traffic.
const a = await mintEnvironment("attempts-itest-tenant-a");
const b = await mintEnvironment("attempts-itest-tenant-b");
const repoA = new Repository(db, a.id);
const repoB = new Repository(db, b.id);
await seedEndpoint(repoA);
await seedEndpoint(repoB);
const first = await deliveryFor(a.id, repoA);
const second = await deliveryFor(b.id, repoB);
await report({
delivery_id: first.delivery.id,
attempt: 1,
status: 200,
latency_ms: 10,
});
await report({
delivery_id: second.delivery.id,
attempt: 1,
status: 500,
latency_ms: 11,
});
const forA = await collected(a.id);
const forB = await collected(b.id);
expect(forA).toHaveLength(1);
expect(forB).toHaveLength(1);
expect(forA[0]!["delivery_id"]).toBe(first.delivery.id);
expect(forB[0]!["delivery_id"]).toBe(second.delivery.id);
// Neither environment's id appears in the other's record, in any field.
expect(JSON.stringify(forA)).not.toContain(b.id);
expect(JSON.stringify(forB)).not.toContain(a.id);
}, 30_000);
it("carries no payload, secret or signature, whatever the delivery held", async () => {
// SC-006 end to end. The unit test proves `shape` is an allow-list; this
// proves the thing actually on the stream contains none of a real delivery's
// sensitive parts, including the endpoint's url and the event body.
const scoped = await mintEnvironment("attempts-itest-secrets");
const scopedRepo = new Repository(db, scoped.id);
const secret = mintSigningSecret();
const endpoint = await scopedRepo.createEndpoint({
url: "https://customer.example/secret-path",
eventTypes: ["message.created"],
secretCiphertext: encryptSecret(secret),
});
const eventId = randomUUID();
await expandEventToDeliveries(db, {
eventId,
environmentId: scoped.id,
type: "message.created",
payload: {
id: eventId,
type: "message.created",
data: { text: "B2, north ramp" },
},
});
await claimOnly();
const [row] = await scopedRepo.listDeliveriesForEvent(eventId);
await report({
delivery_id: row!.id,
attempt: 1,
status: 200,
latency_ms: 9,
});
const serialised = JSON.stringify(await collected(scoped.id));
expect(serialised).toContain(endpoint.id);
for (const forbidden of [
secret,
"B2, north ramp",
"customer.example",
"secret-path",
]) {
expect(serialised).not.toContain(forbidden);
}
}, 30_000);
it("FR-009: a customer disabling their own endpoint leaves no platform fingerprint", async () => {
// The other half of the distinction the disable columns exist to draw, and it
// is testable before auto-disable exists: `setEndpointEnabled(false)` must
// leave `disabled_at` null.
const scoped = await mintEnvironment("attempts-itest-manual");
const scopedRepo = new Repository(db, scoped.id);
const endpoint = await seedEndpoint(scopedRepo);
await scopedRepo.setEndpointEnabled(endpoint.id, false);
const [row] = (
await db.execute(
`SELECT enabled, disabled_at, disabled_reason,
failure_run_started_at, failure_run_attempts
FROM webhook_endpoints WHERE id = '${endpoint.id}'`,
)
).rows as {
enabled: boolean;
disabled_at: string | null;
disabled_reason: string | null;
failure_run_started_at: string | null;
failure_run_attempts: number | null;
}[];
expect(row!.enabled).toBe(false);
expect(row!.disabled_at).toBeNull();
expect(row!.disabled_reason).toBeNull();
expect(row!.failure_run_started_at).toBeNull();
expect(row!.failure_run_attempts).toBeNull();
});
it("persists what the endpoint answered on the delivery row", async () => {
// The state the test event and the sweep both read (data-model.md). Chapter
// The webhook dispatcher chapter recorded an attempt by moving the delivery and
// discarded the answer;
// this is the column that stops the sweep having to write "cause unknown".
const scoped = await mintEnvironment("attempts-itest-last");
const scopedRepo = new Repository(db, scoped.id);
await seedEndpoint(scopedRepo);
const { delivery } = await deliveryFor(scoped.id, scopedRepo);
await recordAttemptOutcome(db, {
deliveryId: delivery.id,
attempt: 1,
status: 503,
latencyMs: 214,
});
const [row] = (
await db.execute(
`SELECT last_status, last_error, last_latency_ms
FROM webhook_deliveries WHERE id = '${delivery.id}'`,
)
).rows as {
last_status: number | null;
last_error: string | null;
last_latency_ms: number | null;
}[];
expect(row!.last_status).toBe(503);
expect(row!.last_latency_ms).toBe(214);
expect(row!.last_error).toBeNull();
});
it("invariant 5: an outcome is recorded and answered with the ANALYTICS stream deleted", async () => {
// THE CONSTITUTION III TEST, and the reason this chapter publishes after the
// commit instead of inside it. Deleting the stream is a sharper instrument
// than stopping the broker and needs no container restart: the connection is
// healthy, the publish is refused.
//
// If a delivery can fail because analytics is unwell, the design is wrong —
// not the test.
//
// LAST IN THE FILE ON PURPOSE. The api's publisher ensures its stream when it
// opens a connection, and that connection is cached for the process's life —
// so a stream deleted underneath it is NOT recreated, and every later publish
// from this app instance fails. That is a real limitation and it is recorded
// in the chapter rather than papered over here: an operator who deletes this
// stream loses attempt records until the api restarts. Nothing after this test
// may depend on the stream, so nothing is.
const scoped = await mintEnvironment("attempts-itest-nostream");
const scopedRepo = new Repository(db, scoped.id);
await seedEndpoint(scopedRepo);
const { delivery } = await deliveryFor(scoped.id, scopedRepo);
const jsm = await nats.jetstreamManager();
await jsm.streams.delete(ANALYTICS_STREAM);
const started = Date.now();
const response = await report({
delivery_id: delivery.id,
attempt: 1,
status: 200,
latency_ms: 55,
});
const elapsed = Date.now() - started;
// Unchanged answer, unchanged row. The delivery does not care.
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ outcome: "delivered" });
const [row] = await scopedRepo.listDeliveriesForEvent(delivery.event_id);
expect(row!.state).toBe("delivered");
// And it did not sit waiting on a broker that was never going to answer. The
// bound is generous — this asserts "not stalled", not a latency budget.
expect(elapsed).toBeLessThan(15_000);
}, 60_000);
});Chuỗi hỏng, việc tắt, lượt quét, và hai tính chất mà bộ sabotage đã chứng minh là chưa được test:
@@ -9,14 +9,18 @@ import {
drainDueDeliveries,
expandEventToDeliveries,
pendingDeliveryDepth,
recordAttemptOutcome,
replayDeadLetter,
Repository,
+ sweepDisabledEndpoints,
timesHandled,
} from "../db/repository";
+import { createLogger } from "@relay/service-kit";
+
+import { createDeliveryRelay } from "./delivery-relay";
import { MAX_ATTEMPTS, RETRY_TIERS_MS } from "./schedule";
import { encryptSecret, mintSigningSecret } from "./secret";
// The delivery schedule. Invariant 8 lives here; 9, 10 and 12
// join it as the tiers and the dead-letter path arrive.
//
@@ -823,6 +827,896 @@ describe("the outcome of an attempt, off the happy path", () => {
it("refuses to replay a dead letter that does not exist", async () => {
// `false`, not a throw: the controller turns it into the same 404 a foreign
// tenant gets, so a probe cannot tell "no such record" from "not yours".
expect(await replayDeadLetter(db, randomUUID())).toBe(false);
});
});
+
+// The failure run and automatic disablement (FR-006…FR-012).
+//
+// Invariants 6, 7, 8, 9, 10, 11 and 12 of contracts/webhooks.md live here. The
+// policy's arithmetic is pure and lives in `disable.test.ts`; what these need a
+// database for is the locking, the at-most-once rule and the sweep.
+//
+// THE HOUR IS MOVED, NOT WAITED OUT. `failure_run_started_at` is pushed into the
+// past with one statement, which is the same thing the walk script's
+// `--fast-forward` does for the retry schedule. Waiting sixty-one real minutes
+// would make these tests unrunnable, and the thing under test is the decision, not
+// the clock.
+describe("the failure run", () => {
+ let db: Db;
+ let env: { id: string };
+ let repo: Repository;
+
+ /** Every environment these tests mint, so their leftovers can be settled.
+ *
+ * THE RELAY'S DRAIN IS GLOBAL and takes 50 due rows at a time, oldest first.
+ * These tests report failures, so their deliveries are RESCHEDULED — they fall
+ * due again a second later and stay due for ever, because nothing here delivers
+ * them. Left behind, a few hundred of them sit at the front of that 50-row
+ * window and starve a later suite's own delivery out of it entirely.
+ *
+ * That is not hypothetical: it failed four tests in `dispatcher.itest.ts` under
+ * the coverage lane, which runs every suite in one process, and it failed them
+ * with `expected 0 to be greater than 0` — nothing delivered, no error anywhere.
+ * The suite that caused it passed.
+ *
+ * The retry-and-disable chapter's baseline drew the rule twice already: clean up what you created,
+ * and only what you created. */
+ const minted: string[] = [];
+ const mintEnvironment = async (name: string) => {
+ const created = await createEnvironment(db, { name });
+ minted.push(created.id);
+ return created;
+ };
+
+ const seedEndpoint = async (scope = repo) => {
+ const secret = mintSigningSecret();
+ return scope.createEndpoint({
+ url: `https://example.test/${randomUUID()}`,
+ eventTypes: ["message.created"],
+ secretCiphertext: encryptSecret(secret),
+ });
+ };
+
+ /** One claimed delivery for this endpoint, ready to have an outcome reported. */
+ const deliveryFor = async (environmentId: string, scope: Repository) => {
+ const eventId = randomUUID();
+ await expandEventToDeliveries(db, {
+ eventId,
+ environmentId,
+ type: "message.created",
+ payload: { id: eventId, type: "message.created" },
+ });
+ await drainDueDeliveries(db, 50, async () => {});
+ const rows = await scope.listDeliveriesForEvent(eventId);
+ return rows;
+ };
+
+ /** The endpoint's run and disable columns, read with plain SQL — the query
+ * engine lives in the repository layer and nowhere else (constitution I,
+ * ADR-16), and the lint rule that says so makes no exception for tests. */
+ const runOf = async (endpointId: string) => {
+ const { rows } = (await db.execute(
+ `SELECT enabled, disabled_at, disabled_reason,
+ failure_run_started_at, failure_run_attempts
+ FROM webhook_endpoints WHERE id = '${endpointId}'`,
+ )) as unknown as {
+ rows: {
+ enabled: boolean;
+ disabled_at: Date | null;
+ disabled_reason: string | null;
+ failure_run_started_at: Date | null;
+ failure_run_attempts: number | null;
+ }[];
+ };
+ return rows[0]!;
+ };
+
+ const notificationsFor = async (endpointId: string) => {
+ const { rows } = (await db.execute(
+ `SELECT environment_id, organisation_id, endpoint_id, run_attempts,
+ last_status, last_error, delivered_at
+ FROM webhook_disable_notifications WHERE endpoint_id = '${endpointId}'`,
+ )) as unknown as {
+ rows: {
+ environment_id: string;
+ organisation_id: string;
+ endpoint_id: string;
+ run_attempts: number;
+ last_status: number | null;
+ last_error: string | null;
+ delivered_at: Date | null;
+ }[];
+ };
+ return rows;
+ };
+
+ /** Move this endpoint's run start into the past. The equivalent of waiting. */
+ const ageRun = (endpointId: string, minutes: number) =>
+ db.execute(
+ `UPDATE webhook_endpoints
+ SET failure_run_started_at = now() - interval '${minutes} minutes'
+ WHERE id = '${endpointId}'`,
+ );
+
+ /** Report `count` failures against fresh deliveries to one endpoint. Fresh
+ * deliveries rather than one row retried, because the run counts FAILURES
+ * against an endpoint and must not care which delivery produced them. */
+ const failTimes = async (
+ environmentId: string,
+ scope: Repository,
+ endpointId: string,
+ count: number,
+ status: number | undefined = 500,
+ ) => {
+ for (let i = 0; i < count; i++) {
+ const rows = await deliveryFor(environmentId, scope);
+ const mine = rows.find((r) => r.endpoint_id === endpointId);
+ if (!mine) continue;
+ await recordAttemptOutcome(db, {
+ deliveryId: mine.id,
+ attempt: mine.attempt,
+ ...(status !== undefined ? { status } : { error: "connection refused" }),
+ latencyMs: 12,
+ });
+ }
+ };
+
+ beforeAll(async () => {
+ db = createDb(createPool());
+ env = await createEnvironment(db, { name: "disable-itest" });
+ minted.push(env.id);
+ repo = new Repository(db, env.id);
+ });
+
+ afterAll(async () => {
+ // Settle every delivery these environments left pending. `dead` rather than
+ // deleted: the rows are evidence a later reader of this database may want, and
+ // a state the relay does not look at costs nothing to keep.
+ if (minted.length === 0) return;
+ const list = minted.map((id) => `'${id}'`).join(",");
+ await db.execute(
+ `UPDATE webhook_deliveries SET state = 'dead'
+ WHERE state = 'pending' AND environment_id IN (${list})`,
+ );
+ }, 60_000);
+
+ it("invariant 6: a failure opens the run, and further failures extend it", async () => {
+ const scoped = await mintEnvironment("disable-itest-open");
+ const scopedRepo = new Repository(db, scoped.id);
+ const endpoint = await seedEndpoint(scopedRepo);
+
+ expect((await runOf(endpoint.id)).failure_run_started_at).toBeNull();
+
+ await failTimes(scoped.id, scopedRepo, endpoint.id, 1);
+ const opened = await runOf(endpoint.id);
+ expect(opened.failure_run_started_at).not.toBeNull();
+ expect(opened.failure_run_attempts).toBe(1);
+ // One failure is not a disablement, however long ago it was.
+ expect(opened.enabled).toBe(true);
+
+ await failTimes(scoped.id, scopedRepo, endpoint.id, 2);
+ const grown = await runOf(endpoint.id);
+ expect(grown.failure_run_attempts).toBe(3);
+ // The START does not move as the run grows — the window is measured from the
+ // first failure, and a start that crept forward would mean an endpoint failing
+ // steadily was never an hour old.
+ expect(grown.failure_run_started_at).toEqual(opened.failure_run_started_at);
+ }, 60_000);
+
+ it("invariant 7: any success clears the run", async () => {
+ const scoped = await mintEnvironment("disable-itest-clear");
+ const scopedRepo = new Repository(db, scoped.id);
+ const endpoint = await seedEndpoint(scopedRepo);
+
+ await failTimes(scoped.id, scopedRepo, endpoint.id, 3);
+ expect((await runOf(endpoint.id)).failure_run_attempts).toBe(3);
+
+ await failTimes(scoped.id, scopedRepo, endpoint.id, 1, 200);
+
+ const cleared = await runOf(endpoint.id);
+ expect(cleared.failure_run_started_at).toBeNull();
+ expect(cleared.failure_run_attempts).toBeNull();
+ }, 60_000);
+
+ it("SC-003: an endpoint that succeeds once an hour is never disabled", async () => {
+ // The generous case, and it is generous on purpose: a platform that switches
+ // off endpoints which sometimes work is a worse failure than one that keeps
+ // trying. Four failures over an aged window, then one success, repeated — the
+ // run never reaches both conditions at once because the success resets it.
+ const scoped = await mintEnvironment("disable-itest-flaky");
+ const scopedRepo = new Repository(db, scoped.id);
+ const endpoint = await seedEndpoint(scopedRepo);
+
+ for (let cycle = 0; cycle < 3; cycle++) {
+ await failTimes(scoped.id, scopedRepo, endpoint.id, 4);
+ // Age it well past the threshold: even so, four failures are below the floor.
+ await ageRun(endpoint.id, 120);
+ await failTimes(scoped.id, scopedRepo, endpoint.id, 1, 200);
+ expect((await runOf(endpoint.id)).failure_run_started_at).toBeNull();
+ }
+
+ const final = await runOf(endpoint.id);
+ expect(final.enabled).toBe(true);
+ expect(final.disabled_at).toBeNull();
+ }, 120_000);
+
+ it("invariants 6 and 11: an hour of failures past the floor disables it, once, with one notification", async () => {
+ const scoped = await mintEnvironment("disable-itest-disable");
+ const scopedRepo = new Repository(db, scoped.id);
+ const endpoint = await seedEndpoint(scopedRepo);
+
+ // Four failures, then age the run past the hour: still below the floor.
+ await failTimes(scoped.id, scopedRepo, endpoint.id, 4);
+ await ageRun(endpoint.id, 64);
+ expect((await runOf(endpoint.id)).enabled).toBe(true);
+
+ // The fifth crosses both conditions at once.
+ await failTimes(scoped.id, scopedRepo, endpoint.id, 1, 503);
+
+ const disabled = await runOf(endpoint.id);
+ expect(disabled.enabled).toBe(false);
+ expect(disabled.disabled_at).not.toBeNull();
+ // FR-009: the reason names the count, the window and what the endpoint said.
+ expect(disabled.disabled_reason).toMatch(
+ /^5 consecutive failures over 1h0\dm; last status 503$/,
+ );
+
+ const notifications = await notificationsFor(endpoint.id);
+ expect(notifications).toHaveLength(1);
+ expect(notifications[0]!.environment_id).toBe(scoped.id);
+ expect(notifications[0]!.run_attempts).toBe(5);
+ expect(notifications[0]!.last_status).toBe(503);
+ // THE HONEST COLUMN. FR-WHK-07 asks for the organisation to be notified by
+ // email; this platform has no email, and the null says so.
+ expect(notifications[0]!.delivered_at).toBeNull();
+ // The organisation was resolved at write time rather than left to a join.
+ expect(notifications[0]!.organisation_id).toBeTruthy();
+ }, 120_000);
+
+ it("invariant 8: further failures do not disable it again or notify again", async () => {
+ const scoped = await mintEnvironment("disable-itest-once");
+ const scopedRepo = new Repository(db, scoped.id);
+ const endpoint = await seedEndpoint(scopedRepo);
+
+ await failTimes(scoped.id, scopedRepo, endpoint.id, 4);
+ await ageRun(endpoint.id, 64);
+ await failTimes(scoped.id, scopedRepo, endpoint.id, 1, 503);
+ const first = await runOf(endpoint.id);
+ expect(first.enabled).toBe(false);
+
+ // More failures arrive — deliveries already on the schedule from before the
+ // disablement, which is exactly the edge case the spec names.
+ await failTimes(scoped.id, scopedRepo, endpoint.id, 3, 500);
+
+ expect(await notificationsFor(endpoint.id)).toHaveLength(1);
+ // And the disable timestamp is the FIRST one: a second disable would move it,
+ // which would quietly rewrite when the customer's outage began.
+ expect((await runOf(endpoint.id)).disabled_at).toEqual(first.disabled_at);
+ }, 120_000);
+
+ it("invariant 8 under concurrency: two overlapping reports disable once", async () => {
+ // THE REASON THE ENDPOINT ROW IS LOCKED. Two dispatcher instances can report
+ // outcomes for two deliveries to the same endpoint in the same moment. Without
+ // `FOR UPDATE` both read four, both write five, and both decide to disable —
+ // two disablements and two notifications for one outage.
+ const scoped = await mintEnvironment("disable-itest-race");
+ const scopedRepo = new Repository(db, scoped.id);
+ const endpoint = await seedEndpoint(scopedRepo);
+
+ await failTimes(scoped.id, scopedRepo, endpoint.id, 4);
+ await ageRun(endpoint.id, 64);
+
+ // Two deliveries, both due, both reported at once.
+ const first = (await deliveryFor(scoped.id, scopedRepo)).find(
+ (r) => r.endpoint_id === endpoint.id,
+ )!;
+ const second = (await deliveryFor(scoped.id, scopedRepo)).find(
+ (r) => r.endpoint_id === endpoint.id,
+ )!;
+
+ await Promise.all([
+ recordAttemptOutcome(db, {
+ deliveryId: first.id,
+ attempt: first.attempt,
+ status: 500,
+ latencyMs: 5,
+ }),
+ recordAttemptOutcome(db, {
+ deliveryId: second.id,
+ attempt: second.attempt,
+ status: 500,
+ latencyMs: 5,
+ }),
+ ]);
+
+ expect((await runOf(endpoint.id)).enabled).toBe(false);
+ // ONE. This is the assertion the lock exists for.
+ expect(await notificationsFor(endpoint.id)).toHaveLength(1);
+ }, 120_000);
+
+ it("invariant 9: a disabled endpoint receives no new deliveries", async () => {
+ const scoped = await mintEnvironment("disable-itest-nonew");
+ const scopedRepo = new Repository(db, scoped.id);
+ const endpoint = await seedEndpoint(scopedRepo);
+ const healthy = await seedEndpoint(scopedRepo);
+
+ await failTimes(scoped.id, scopedRepo, endpoint.id, 4);
+ await ageRun(endpoint.id, 64);
+ await failTimes(scoped.id, scopedRepo, endpoint.id, 1, 503);
+ expect((await runOf(endpoint.id)).enabled).toBe(false);
+
+ // A new event arrives for the environment. Expansion is where `enabled` is
+ // read, so the disabled endpoint simply is not among the rows produced.
+ const eventId = randomUUID();
+ await expandEventToDeliveries(db, {
+ eventId,
+ environmentId: scoped.id,
+ type: "message.created",
+ payload: { id: eventId, type: "message.created" },
+ });
+ const rows = await scopedRepo.listDeliveriesForEvent(eventId);
+
+ expect(rows.map((r) => r.endpoint_id)).toEqual([healthy.id]);
+ }, 120_000);
+
+ it("invariant 10: disabling one endpoint changes nothing for any other", async () => {
+ // FR-012 and SC-004. One endpoint in the same environment, one in another —
+ // neither loses its run state, its `enabled`, or its deliveries.
+ const scoped = await mintEnvironment("disable-itest-iso-a");
+ const other = await mintEnvironment("disable-itest-iso-b");
+ const scopedRepo = new Repository(db, scoped.id);
+ const otherRepo = new Repository(db, other.id);
+
+ const doomed = await seedEndpoint(scopedRepo);
+ const sibling = await seedEndpoint(scopedRepo);
+ const stranger = await seedEndpoint(otherRepo);
+
+ // The sibling and the stranger are mid-run when the disablement happens, so
+ // this asserts their state is not merely absent but UNCHANGED.
+ await failTimes(scoped.id, scopedRepo, sibling.id, 2);
+ await failTimes(other.id, otherRepo, stranger.id, 2);
+ const siblingBefore = await runOf(sibling.id);
+ const strangerBefore = await runOf(stranger.id);
+
+ await failTimes(scoped.id, scopedRepo, doomed.id, 4);
+ await ageRun(doomed.id, 64);
+ await failTimes(scoped.id, scopedRepo, doomed.id, 1, 503);
+ expect((await runOf(doomed.id)).enabled).toBe(false);
+
+ expect(await runOf(sibling.id)).toEqual(siblingBefore);
+ expect(await runOf(stranger.id)).toEqual(strangerBefore);
+ expect(await notificationsFor(sibling.id)).toHaveLength(0);
+ expect(await notificationsFor(stranger.id)).toHaveLength(0);
+ }, 180_000);
+
+ it("invariant 12: the SWEEP disables the quiet endpoint no outcome ever revisits", async () => {
+ // THE TEST RESEARCH R1 EXISTS FOR, and the one most likely to be dropped as
+ // redundant next to the on-outcome check above. It is not redundant: it is the
+ // only test that covers the customer the requirement is actually about.
+ //
+ // Five failures, then SILENCE. The endpoint has crossed the floor and the hour,
+ // and no further outcome will ever arrive — the delivery dead-lettered, or the
+ // environment simply went quiet. An outcome-only check never fires again and
+ // the endpoint stays enabled and failing for ever.
+ const scoped = await mintEnvironment("disable-itest-sweep");
+ const scopedRepo = new Repository(db, scoped.id);
+ const endpoint = await seedEndpoint(scopedRepo);
+
+ await failTimes(scoped.id, scopedRepo, endpoint.id, 5, 503);
+ await ageRun(endpoint.id, 64);
+
+ // Still enabled: nothing has happened since, which is the whole point.
+ expect((await runOf(endpoint.id)).enabled).toBe(true);
+
+ // A LIMIT BIG ENOUGH TO REACH THIS ENDPOINT. The sweep is global and takes the
+ // hundred oldest eligible endpoints; every earlier run of this suite leaves
+ // endpoints with an open failure run behind, and those are older than this
+ // one, so they fill the batch and this endpoint is never reached. The suite
+ // then fails on a shared database and passes on a fresh one.
+ //
+ // Found at the deduplication chapter's baseline, after 781 endpoints had
+ // accumulated an open run. Note which assertion caught it: `disabled >= 1`
+ // PASSED, because the sweep had just disabled a hundred endpoints belonging to
+ // nobody. Only the assertion about THIS endpoint could tell the difference.
+ //
+ // MEASURED IN THIS ORDER'S LANE AT THE MOMENT THE LIMIT WAS ADDED: 41 endpoints
+ // with an open run out of 723, against a default batch of 100. So the fault is
+ // NOT yet reachable here — the batch still has room — and the fix is arriving
+ // before its symptom rather than after it, which is the only reason this comment
+ // has to say which number it was written against. At 100 open runs it becomes
+ // the failure above, and nothing about the suite would change to announce it.
+ const disabled = await sweepDisabledEndpoints(db, 10_000);
+ expect(disabled).toBeGreaterThanOrEqual(1);
+
+ const after = await runOf(endpoint.id);
+ expect(after.enabled).toBe(false);
+ expect(after.disabled_at).not.toBeNull();
+ // The sweep has no outcome in hand, so it read the last status off the
+ // endpoint's most recent delivery. Without that it could only say "cause
+ // unknown", which is the notification a support engineer would receive.
+ expect(after.disabled_reason).toContain("last status 503");
+
+ const notifications = await notificationsFor(endpoint.id);
+ expect(notifications).toHaveLength(1);
+ expect(notifications[0]!.last_status).toBe(503);
+ expect(notifications[0]!.delivered_at).toBeNull();
+ }, 120_000);
+
+ it("invariant 12: the sweep is idempotent against invariant 8", async () => {
+ // Both triggers go through the same statement, so the sweep inherits the
+ // at-most-once rule rather than reimplementing it. Running it twice must not
+ // produce a second notification — and running it against an endpoint the
+ // on-outcome path already disabled must find nothing to do.
+ const scoped = await mintEnvironment("disable-itest-sweep2");
+ const scopedRepo = new Repository(db, scoped.id);
+ const endpoint = await seedEndpoint(scopedRepo);
+
+ await failTimes(scoped.id, scopedRepo, endpoint.id, 5, 503);
+ await ageRun(endpoint.id, 64);
+
+ await sweepDisabledEndpoints(db, 10_000);
+ await sweepDisabledEndpoints(db, 10_000);
+ await sweepDisabledEndpoints(db, 10_000);
+
+ expect(await notificationsFor(endpoint.id)).toHaveLength(1);
+ }, 120_000);
+
+ it("the sweep leaves a healthy endpoint and one still inside the hour alone", async () => {
+ // The sweep runs several times a second in a live service, so what it does NOT
+ // touch matters as much as what it does.
+ const scoped = await mintEnvironment("disable-itest-sweep3");
+ const scopedRepo = new Repository(db, scoped.id);
+ const healthy = await seedEndpoint(scopedRepo);
+ const recent = await seedEndpoint(scopedRepo);
+
+ await failTimes(scoped.id, scopedRepo, recent.id, 5, 500);
+ // Inside the hour: five failures, but the window has not elapsed.
+ await ageRun(recent.id, 30);
+
+ await sweepDisabledEndpoints(db, 10_000);
+
+ expect((await runOf(healthy.id)).enabled).toBe(true);
+ expect((await runOf(recent.id)).enabled).toBe(true);
+ expect(await notificationsFor(recent.id)).toHaveLength(0);
+ }, 120_000);
+
+ it("invariant 13: a test event's outcome never touches the run", async () => {
+ // Written here rather than with the test-event route, because the property is
+ // the repository's: a synthetic delivery's outcome must not open, extend or
+ // clear a run. A failed test must not push an endpoint toward disablement, and
+ // a successful one must not let a customer mask a real outage by testing until
+ // it passes.
+ const scoped = await mintEnvironment("disable-itest-synthetic");
+ const scopedRepo = new Repository(db, scoped.id);
+ const endpoint = await seedEndpoint(scopedRepo);
+
+ await failTimes(scoped.id, scopedRepo, endpoint.id, 3, 500);
+ const before = await runOf(endpoint.id);
+ expect(before.failure_run_attempts).toBe(3);
+
+ // A synthetic delivery that FAILS: the run must not grow.
+ const failing = (await deliveryFor(scoped.id, scopedRepo)).find(
+ (r) => r.endpoint_id === endpoint.id,
+ )!;
+ await db.execute(
+ `UPDATE webhook_deliveries SET synthetic = true WHERE id = '${failing.id}'`,
+ );
+ await recordAttemptOutcome(db, {
+ deliveryId: failing.id,
+ attempt: failing.attempt,
+ status: 500,
+ latencyMs: 7,
+ });
+ expect((await runOf(endpoint.id)).failure_run_attempts).toBe(3);
+
+ // And one that SUCCEEDS: the run must not clear.
+ const passing = (await deliveryFor(scoped.id, scopedRepo)).find(
+ (r) => r.endpoint_id === endpoint.id,
+ )!;
+ await db.execute(
+ `UPDATE webhook_deliveries SET synthetic = true WHERE id = '${passing.id}'`,
+ );
+ await recordAttemptOutcome(db, {
+ deliveryId: passing.id,
+ attempt: passing.attempt,
+ status: 200,
+ latencyMs: 7,
+ });
+ const after = await runOf(endpoint.id);
+ expect(after.failure_run_attempts).toBe(3);
+ expect(after.failure_run_started_at).toEqual(before.failure_run_started_at);
+ }, 120_000);
+
+ it("a failed test event writes no dead letter", async () => {
+ // A dead letter is customer-visible, retained for seven days and replayable.
+ // A test event is a diagnostic the customer already has the answer to, so
+ // putting one there would offer an operator a "replay" button that re-sends a
+ // test.
+ const scoped = await mintEnvironment("disable-itest-nodl");
+ const scopedRepo = new Repository(db, scoped.id);
+ const endpoint = await seedEndpoint(scopedRepo);
+
+ const rows = await deliveryFor(scoped.id, scopedRepo);
+ const delivery = rows.find((r) => r.endpoint_id === endpoint.id)!;
+ await db.execute(
+ `UPDATE webhook_deliveries SET synthetic = true WHERE id = '${delivery.id}'`,
+ );
+
+ // One attempt and no schedule, so the first failure is the last.
+ const result = await recordAttemptOutcome(db, {
+ deliveryId: delivery.id,
+ attempt: delivery.attempt,
+ status: 500,
+ latencyMs: 7,
+ });
+ expect(result.outcome).toBe("dead_lettered");
+
+ const mine = (await scopedRepo.listDeadLetters()).filter(
+ (d) => d.event_id === delivery.event_id,
+ );
+ expect(mine).toHaveLength(0);
+ }, 60_000);
+});
+
+// The sweep as the RELAY runs it (T033's reason for existing).
+//
+// Everything above calls `sweepDisabledEndpoints` directly, which leaves the
+// wrapper around it — the flag, the count log, the swallowed failure — measured by
+// nothing. Research R11 predicted exactly this: "the sweep runs in the relay loop
+// … easy to exercise in a way the instrument cannot see". the webhook dispatcher chapter ignored the
+// equivalent warning and found four red thresholds with the chapter otherwise
+// finished.
+describe("the sweep, through the relay that runs it", () => {
+ let db: Db;
+
+ /** A publisher that goes nowhere. The sweep does not publish, so a real one
+ * would only give this test a broker to be flaky about. */
+ const silentPublisher = {
+ async publish() {},
+ async close() {},
+ };
+
+ const captured = () => {
+ const lines: Record<string, unknown>[] = [];
+ return {
+ lines,
+ logger: createLogger("sweep-itest", (line) =>
+ lines.push(JSON.parse(String(line)) as Record<string, unknown>),
+ ),
+ };
+ };
+
+ /** Same bookkeeping as the describe above, for the same reason: these tests
+ * report failures, so the deliveries they create fall due again for ever. */
+ const minted: string[] = [];
+
+ beforeAll(() => {
+ db = createDb(createPool());
+ });
+
+ afterAll(async () => {
+ if (minted.length === 0) return;
+ const list = minted.map((id) => `'${id}'`).join(",");
+ await db.execute(
+ `UPDATE webhook_deliveries SET state = 'dead'
+ WHERE state = 'pending' AND environment_id IN (${list})`,
+ );
+ }, 60_000);
+
+ const doomedEndpoint = async () => {
+ const env = await createEnvironment(db, { name: `sweep-relay-${randomUUID().slice(0, 8)}` });
+ minted.push(env.id);
+ const repo = new Repository(db, env.id);
+ const secret = mintSigningSecret();
+ const endpoint = await repo.createEndpoint({
+ url: `https://example.test/${randomUUID()}`,
+ eventTypes: ["message.created"],
+ secretCiphertext: encryptSecret(secret),
+ });
+ // Five failures against fresh deliveries, then age the run past the hour.
+ for (let i = 0; i < 5; i++) {
+ const eventId = randomUUID();
+ await expandEventToDeliveries(db, {
+ eventId,
+ environmentId: env.id,
+ type: "message.created",
+ payload: { id: eventId, type: "message.created" },
+ });
+ const rows = await repo.listDeliveriesForEvent(eventId);
+ const mine = rows.find((r) => r.endpoint_id === endpoint.id)!;
+ await recordAttemptOutcome(db, {
+ deliveryId: mine.id,
+ attempt: mine.attempt,
+ status: 500,
+ latencyMs: 4,
+ });
+ }
+ await db.execute(
+ `UPDATE webhook_endpoints
+ SET failure_run_started_at = now() - interval '64 minutes'
+ WHERE id = '${endpoint.id}'`,
+ );
+ return endpoint;
+ };
+
+ const enabledOf = async (id: string) => {
+ const { rows } = (await db.execute(
+ `SELECT enabled FROM webhook_endpoints WHERE id = '${id}'`,
+ )) as unknown as { rows: { enabled: boolean }[] };
+ return rows[0]!.enabled;
+ };
+
+ it("disables through the relay and logs a count", async () => {
+ const endpoint = await doomedEndpoint();
+ const { logger, lines } = captured();
+ const relay = createDeliveryRelay({
+ db,
+ publisher: silentPublisher,
+ logger,
+ sweepEnabled: true,
+ });
+
+ const disabled = await relay.sweepOnce();
+
+ expect(disabled).toBeGreaterThanOrEqual(1);
+ expect(await enabledOf(endpoint.id)).toBe(false);
+ const logged = lines.filter((l) => l["msg"] === "webhooks.endpoints_disabled");
+ expect(logged).toHaveLength(1);
+ // A COUNT, never an endpoint id or a url — the log discipline every relay in
+ // this workspace keeps (NFR-SEC-06).
+ expect(logged[0]!["count"]).toBe(disabled);
+ expect(JSON.stringify(logged)).not.toContain(endpoint.id);
+ }, 120_000);
+
+ it("does nothing at all with RELAY_DISABLE_SWEEP off", async () => {
+ // Quickstart V6's first half. This is what an outcome-only check ships, and
+ // reading it is the only way the second half means anything.
+ const endpoint = await doomedEndpoint();
+ const { logger, lines } = captured();
+ const relay = createDeliveryRelay({
+ db,
+ publisher: silentPublisher,
+ logger,
+ sweepEnabled: false,
+ });
+
+ expect(await relay.sweepOnce()).toBe(0);
+ // Still enabled, still failing, hours past the threshold. That is the bug.
+ expect(await enabledOf(endpoint.id)).toBe(true);
+ expect(lines).toHaveLength(0);
+ }, 120_000);
+
+ it("logs and swallows a sweep failure rather than stopping the loop", async () => {
+ // An endpoint that should have been disabled and was not costs one more failed
+ // delivery. A relay that stopped draining costs every customer's webhooks. So
+ // the sweep's failure is logged and dropped, exactly as the drain's is.
+ const { logger, lines } = captured();
+ const relay = createDeliveryRelay({
+ // A db whose every query fails. Closing a real pool would be a slower way to
+ // say the same thing and would take the other tests' pool with it.
+ db: {
+ transaction: async () => {
+ throw new Error("connection terminated unexpectedly");
+ },
+ } as unknown as Db,
+ publisher: silentPublisher,
+ logger,
+ sweepEnabled: true,
+ });
+
+ expect(await relay.sweepOnce()).toBe(0);
+ const failed = lines.filter((l) => l["msg"] === "webhooks.disable_sweep_failed");
+ expect(failed).toHaveLength(1);
+ expect(String(failed[0]!["error"])).toContain("connection terminated");
+ });
+
+ it("logs nothing when there is nothing to disable", async () => {
+ // This runs several times a second in a live service. A line per pass would
+ // bury every other line the api writes.
+ const { logger, lines } = captured();
+ const relay = createDeliveryRelay({
+ db,
+ publisher: silentPublisher,
+ logger,
+ sweepEnabled: true,
+ });
+
+ // Whatever the shared database holds, a second sweep immediately after a first
+ // has nothing left that is both eligible and enabled.
+ await relay.sweepOnce();
+ lines.length = 0;
+ expect(await relay.sweepOnce()).toBe(0);
+ expect(lines).toHaveLength(0);
+ }, 120_000);
+});
+
+// What the LOCK protects, as opposed to what the predicate protects.
+//
+// These two tests exist because the sabotage battery contradicted a comment. The
+// claim was that `SELECT … FOR UPDATE` on the endpoint row is what stops a
+// concurrent pair of failures producing two disablements and two notifications.
+// Dropping the lock and running the whole suite produced 46 passes: the
+// `enabled = true` predicate in the disable statement is sufficient for that on its
+// own, and the lock was not the mechanism being credited.
+//
+// The lock's real job is the COUNTER. Under READ COMMITTED, two transactions both
+// read `failure_run_attempts = 4`, both compute 5, and the second UPDATE waits for
+// the first and then overwrites it with 5 — a lost update, and the run undercounts.
+// An undercount does not disable anything wrongly; it delays a disablement, which
+// is FR-007's floor being quietly harder to reach than it says.
+describe("the failure run under concurrency", () => {
+ let db: Db;
+ const minted: string[] = [];
+
+ beforeAll(() => {
+ db = createDb(createPool());
+ });
+
+ afterAll(async () => {
+ if (minted.length === 0) return;
+ const list = minted.map((id) => `'${id}'`).join(",");
+ await db.execute(
+ `UPDATE webhook_deliveries SET state = 'dead'
+ WHERE state = 'pending' AND environment_id IN (${list})`,
+ );
+ }, 60_000);
+
+ const twoPendingDeliveries = async () => {
+ const env = await createEnvironment(db, {
+ name: `run-concurrency-${randomUUID().slice(0, 8)}`,
+ });
+ minted.push(env.id);
+ const repo = new Repository(db, env.id);
+ const endpoint = await repo.createEndpoint({
+ url: `https://example.test/${randomUUID()}`,
+ eventTypes: ["message.created"],
+ secretCiphertext: encryptSecret(mintSigningSecret()),
+ });
+ const made: { id: string; attempt: number }[] = [];
+ for (let i = 0; i < 2; i++) {
+ const eventId = randomUUID();
+ await expandEventToDeliveries(db, {
+ eventId,
+ environmentId: env.id,
+ type: "message.created",
+ payload: { id: eventId, type: "message.created" },
+ });
+ const rows = await repo.listDeliveriesForEvent(eventId);
+ const mine = rows.find((r) => r.endpoint_id === endpoint.id)!;
+ made.push({ id: mine.id, attempt: mine.attempt });
+ }
+ return { endpoint, deliveries: made };
+ };
+
+ const attemptsOf = async (endpointId: string) => {
+ const { rows } = (await db.execute(
+ `SELECT failure_run_attempts FROM webhook_endpoints WHERE id = '${endpointId}'`,
+ )) as unknown as { rows: { failure_run_attempts: number | null }[] };
+ return rows[0]!.failure_run_attempts;
+ };
+
+ it("counts BOTH of two overlapping failures — the lost update the lock prevents", async () => {
+ const { endpoint, deliveries } = await twoPendingDeliveries();
+
+ await Promise.all(
+ deliveries.map((d) =>
+ recordAttemptOutcome(db, {
+ deliveryId: d.id,
+ attempt: d.attempt,
+ status: 500,
+ latencyMs: 3,
+ }),
+ ),
+ );
+
+ // TWO, not one. Without `FOR UPDATE` this reads 1: both transactions see a null
+ // run, both compute 1, and the second write overwrites the first. The endpoint
+ // then needs an extra failure to reach the floor, for ever, and nothing
+ // anywhere reports that the count is wrong.
+ expect(await attemptsOf(endpoint.id)).toBe(2);
+ }, 60_000);
+
+ it("still disables only once when the pair crosses the threshold together", async () => {
+ // The other half, and the one the battery showed is NOT the lock's doing: the
+ // `enabled = true` predicate in the disable statement means the second
+ // transaction updates zero rows and writes no notification, lock or no lock.
+ // Kept as a test because the property is required (invariant 8) whatever
+ // enforces it — and the battery is how we found out which mechanism does.
+ const { endpoint, deliveries } = await twoPendingDeliveries();
+ await db.execute(
+ `UPDATE webhook_endpoints
+ SET failure_run_started_at = now() - interval '64 minutes',
+ failure_run_attempts = 4
+ WHERE id = '${endpoint.id}'`,
+ );
+
+ await Promise.all(
+ deliveries.map((d) =>
+ recordAttemptOutcome(db, {
+ deliveryId: d.id,
+ attempt: d.attempt,
+ status: 503,
+ latencyMs: 3,
+ }),
+ ),
+ );
+
+ const { rows } = (await db.execute(
+ `SELECT count(*)::int AS n FROM webhook_disable_notifications
+ WHERE endpoint_id = '${endpoint.id}'`,
+ )) as unknown as { rows: { n: number }[] };
+ expect(rows[0]!.n).toBe(1);
+ }, 60_000);
+});
+
+// That the LOOP calls the sweep (T026).
+//
+// Also a finding of the sabotage battery: deleting the `await sweepOnce()` line
+// from the relay's `run()` loop broke nothing, because every test above calls
+// `sweepOnce` or `sweepDisabledEndpoints` directly. The sweep was tested and its
+// PLACE in the loop was not, which is the same shape as the webhook dispatcher chapter's vacuous
+// "terminated, not retried" assertion — the mechanism was covered and the wiring
+// was not.
+describe("the relay's loop sweeps without being asked", () => {
+ let db: Db;
+ const minted: string[] = [];
+
+ beforeAll(() => {
+ db = createDb(createPool());
+ });
+
+ afterAll(async () => {
+ if (minted.length === 0) return;
+ const list = minted.map((id) => `'${id}'`).join(",");
+ await db.execute(
+ `UPDATE webhook_deliveries SET state = 'dead'
+ WHERE state = 'pending' AND environment_id IN (${list})`,
+ );
+ }, 60_000);
+
+ it("disables an endpoint with nothing but start() and time", async () => {
+ const env = await createEnvironment(db, {
+ name: `loop-sweep-${randomUUID().slice(0, 8)}`,
+ });
+ minted.push(env.id);
+ const repo = new Repository(db, env.id);
+ const endpoint = await repo.createEndpoint({
+ url: `https://example.test/${randomUUID()}`,
+ eventTypes: ["message.created"],
+ secretCiphertext: encryptSecret(mintSigningSecret()),
+ });
+ await db.execute(
+ `UPDATE webhook_endpoints
+ SET failure_run_started_at = now() - interval '64 minutes',
+ failure_run_attempts = 6
+ WHERE id = '${endpoint.id}'`,
+ );
+
+ const relay = createDeliveryRelay({
+ db,
+ publisher: { async publish() {}, async close() {} },
+ logger: createLogger("loop-sweep-itest", () => {}),
+ // Fast, because this test waits on a real timer rather than pretending.
+ intervalMs: 50,
+ });
+
+ // START, and then nothing. No `sweepOnce`, no `drainOnce` — the loop is the
+ // only thing that can disable this endpoint, which is the point.
+ relay.start();
+ try {
+ const deadline = Date.now() + 20_000;
+ let enabled = true;
+ while (enabled && Date.now() < deadline) {
+ const { rows } = (await db.execute(
+ `SELECT enabled FROM webhook_endpoints WHERE id = '${endpoint.id}'`,
+ )) as unknown as { rows: { enabled: boolean }[] };
+ enabled = rows[0]!.enabled;
+ if (enabled) await new Promise((r) => setTimeout(r, 100));
+ }
+ expect(enabled).toBe(false);
+ } finally {
+ await relay.stop();
+ }
+ }, 60_000);
+});Và test event, với suite này đóng vai dispatcher để nó có thể xác minh chữ ký theo cách một khách hàng sẽ làm:
import "reflect-metadata";
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
import { createServer, type Server } from "node:http";
import { Test } from "@nestjs/testing";
import type { INestApplication } from "@nestjs/common";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { AppModule } from "../app.module";
import { createDb, createPool, type Db } from "../db/client";
import {
createApiKey,
createEnvironment,
recordAttemptOutcome,
Repository,
} from "../db/repository";
import { encryptSecret, mintSigningSecret } from "./secret";
// Proving an endpoint works again (FR-013…FR-017, research R8).
//
// THIS SUITE PLAYS THE DISPATCHER. `POST /test` creates a real delivery and then
// watches the row, because the attempt happens in another process — so something
// has to make that attempt, and importing the dispatcher's build into the api's
// test lane would make this suite fail whenever the packages happened to build in
// the other order.
//
// Playing it by hand is also the stronger test. The signature is verified with
// `node:crypto` and the documented recipe, nothing from the signing path, which is
// how a customer verifies and the only way to check FR-014's claim that a test
// event is signed exactly as a real one.
const CREDENTIAL =
process.env["RELAY_INTERNAL_CREDENTIAL"] ??
"rk_svc_testevent_itest_0123456789abcdef01";
interface Received {
body: string;
headers: Record<string, string>;
}
describe("the test event", () => {
let app: INestApplication;
let url: string;
let db: Db;
let server: Server;
let endpointUrl: string;
let received: Received[] = [];
let answerStatus = 200;
/** When true the endpoint accepts the request and never answers, so the
* dispatcher-side attempt below reports a timeout rather than a status. */
let answerNothing = false;
const minted: string[] = [];
const listen = () =>
new Promise<string>((resolve) => {
server = createServer((req, res) => {
let body = "";
req.on("data", (chunk) => (body += String(chunk)));
req.on("end", () => {
received.push({
body,
headers: Object.fromEntries(
Object.entries(req.headers).map(([k, v]) => [k, String(v)]),
),
});
if (answerNothing) return; // accepted and abandoned
res.writeHead(answerStatus, { "content-type": "text/plain" }).end("ok");
});
});
server.listen(0, "127.0.0.1", () => {
const address = server.address();
resolve(`http://127.0.0.1:${typeof address === "object" && address ? address.port : 0}/hook`);
});
});
const mintEnvironment = async (name: string) => {
const created = await createEnvironment(db, { name });
minted.push(created.id);
return created;
};
/** An endpoint pointed at this suite's server.
*
* Created through the REPOSITORY rather than the public route, because the
* public route refuses loopback addresses — the webhook dispatcher chapter's SSRF check, which is
* correct and which every local walk in this repository has to step around the
* same way. */
const seedEndpoint = async (repo: Repository, secret: string) =>
repo.createEndpoint({
url: endpointUrl,
eventTypes: ["message.created"],
secretCiphertext: encryptSecret(secret),
});
/** Everything a dispatcher does for one delivery: take the material, post it
* signed, report the outcome. Run concurrently with the request under test,
* because that request is holding a customer's connection open waiting for
* exactly this to happen.
*
* FINDS ITS OWN ROW rather than calling the relay's drain, and the first version
* did the opposite. `drainDueDeliveries` is GLOBAL — it claims the fifty oldest
* due deliveries in the platform regardless of who they belong to — so when
* another suite in this package drained at the wrong moment it claimed this
* suite's delivery, discarded it, and stamped `dispatched_at`. Nothing here ever
* saw it, the route waited out its ten seconds, and the test failed reporting
* `delivered: false` for an endpoint that had answered 200 the last time it was
* asked. It passed alone and failed in the lane, which is the shape of every
* other cross-suite fault this chapter has found.
*
* The claim is not what is under test. Selecting the row directly removes the
* race without weakening anything the assertions rest on. */
const actAsDispatcher = async (
environmentId: string,
budgetMs = 8_000,
): Promise<void> => {
const deadline = Date.now() + budgetMs;
while (Date.now() < deadline) {
const { rows } = (await db.execute(
`SELECT id, attempt FROM webhook_deliveries
WHERE environment_id = '${environmentId}'
AND state = 'pending'
ORDER BY created_at DESC
LIMIT 1`,
)) as unknown as { rows: { id: string; attempt: number }[] };
const deliveryId: string | null = rows[0]?.id ?? null;
if (deliveryId === null) {
await new Promise((r) => setTimeout(r, 50));
continue;
}
const material = await fetch(`${url}/internal/dispatch/material`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${CREDENTIAL}`,
},
body: JSON.stringify({ delivery_id: deliveryId }),
}).then((r) => r.json() as Promise<{
attempt: number;
secrets: string[];
payload: unknown;
}>);
// THE RAW BYTES, signed once and sent unchanged. Re-serialising between
// signing and sending is the single most common way a first integration
// fails, and it fails looking like the platform's bug.
const rawBody = JSON.stringify(material.payload);
const timestamp = String(Math.floor(Date.now() / 1000));
const signature = createHmac("sha256", material.secrets[0]!)
.update(`v1:${timestamp}:${rawBody}`)
.digest("hex");
const started = Date.now();
let status: number | undefined;
let error: string | undefined;
try {
const response = await fetch(endpointUrl, {
method: "POST",
headers: {
"content-type": "application/json",
"relay-webhook-timestamp": timestamp,
"relay-webhook-signature": `v1=${signature}`,
},
body: rawBody,
signal: AbortSignal.timeout(2_000),
});
status = response.status;
} catch (caught) {
error = String(caught);
}
await recordAttemptOutcome(db, {
deliveryId,
attempt: material.attempt,
...(status !== undefined ? { status } : {}),
...(error !== undefined ? { error } : {}),
latencyMs: Date.now() - started,
});
return;
}
};
const sendTest = (endpointId: string, credential: string) =>
fetch(`${url}/v1/webhooks/${endpointId}/test`, {
method: "POST",
headers: { authorization: `Bearer ${credential}` },
});
const endpointRow = async (id: string) => {
const { rows } = (await db.execute(
`SELECT enabled, disabled_at, disabled_reason,
failure_run_started_at, failure_run_attempts
FROM webhook_endpoints WHERE id = '${id}'`,
)) as unknown as {
rows: {
enabled: boolean;
disabled_at: Date | null;
disabled_reason: string | null;
failure_run_started_at: Date | null;
failure_run_attempts: number | null;
}[];
};
return rows[0]!;
};
beforeAll(async () => {
process.env["RELAY_INTERNAL_CREDENTIAL"] = CREDENTIAL;
db = createDb(createPool());
endpointUrl = await listen();
app = (
await Test.createTestingModule({ imports: [AppModule] }).compile()
).createNestApplication({ logger: false });
await app.listen(0);
url = await app.getUrl();
}, 60_000);
afterAll(async () => {
if (minted.length > 0) {
const list = minted.map((id) => `'${id}'`).join(",");
await db.execute(
`UPDATE webhook_deliveries SET state = 'dead'
WHERE state = 'pending' AND environment_id IN (${list})`,
);
}
server?.close();
await app?.close();
}, 60_000);
it("FR-014, FR-015: delivers a signed synthetic event and reports what it answered", async () => {
const env = await mintEnvironment("test-event-itest-ok");
const key = await createApiKey(db, { environmentId: env.id });
const repo = new Repository(db, env.id);
const secret = mintSigningSecret();
const endpoint = await seedEndpoint(repo, secret);
received = [];
answerStatus = 200;
answerNothing = false;
const [response] = await Promise.all([
sendTest(endpoint.id, key.credential),
actAsDispatcher(env.id),
]);
expect(response.status).toBe(200);
const body = (await response.json()) as Record<string, unknown>;
expect(body["delivered"]).toBe(true);
expect(body["status"]).toBe(200);
expect(typeof body["latency_ms"]).toBe("number");
expect(body["error"]).toBeNull();
expect(body["event_id"]).toBeTruthy();
// MARKED TWICE (FR-015). A recipient switching on the type and a recipient
// reading the body can each tell without knowing about the other.
expect(received).toHaveLength(1);
const envelope = JSON.parse(received[0]!.body) as Record<string, unknown>;
expect(envelope["type"]).toBe("webhook.test");
expect(envelope["test"]).toBe(true);
expect(envelope["id"]).toBe(body["event_id"]);
expect(envelope["environment_id"]).toBe(env.id);
// THE SIGNATURE, verified the way a customer verifies: `node:crypto`, the
// documented recipe, and the raw body as it arrived. Nothing from the signing
// path — a test that verified with our own code would prove only that the code
// agrees with itself.
const timestamp = received[0]!.headers["relay-webhook-timestamp"]!;
const offered = received[0]!.headers["relay-webhook-signature"]!
.split(",")
.map((part) => part.trim().replace(/^v1=/, ""));
const expected = createHmac("sha256", secret)
.update(`v1:${timestamp}:${received[0]!.body}`)
.digest("hex");
const matches = offered.some((candidate) => {
const a = Buffer.from(candidate, "hex");
const b = Buffer.from(expected, "hex");
return a.length === b.length && timingSafeEqual(a, b);
});
expect(matches).toBe(true);
}, 60_000);
it("FR-013: is delivered even when the endpoint is DISABLED", async () => {
// The case that makes the loop closable. Refusing here would mean a customer
// could only find out whether their repair worked by re-enabling and waiting
// for real traffic — which is how an endpoint gets disabled twice in a day.
const env = await mintEnvironment("test-event-itest-disabled");
const key = await createApiKey(db, { environmentId: env.id });
const repo = new Repository(db, env.id);
const endpoint = await seedEndpoint(repo, mintSigningSecret());
await repo.setEndpointEnabled(endpoint.id, false);
received = [];
answerStatus = 200;
answerNothing = false;
const [response] = await Promise.all([
sendTest(endpoint.id, key.credential),
actAsDispatcher(env.id),
]);
expect((await response.json())["delivered"]).toBe(true);
expect(received).toHaveLength(1);
// And testing does NOT re-enable it. Only the customer does that.
expect((await endpointRow(endpoint.id)).enabled).toBe(false);
}, 60_000);
it("FR-016: a non-2xx is `delivered: false` with the status, not an HTTP error", async () => {
const env = await mintEnvironment("test-event-itest-500");
const key = await createApiKey(db, { environmentId: env.id });
const repo = new Repository(db, env.id);
const endpoint = await seedEndpoint(repo, mintSigningSecret());
received = [];
answerStatus = 503;
answerNothing = false;
const [response] = await Promise.all([
sendTest(endpoint.id, key.credential),
actAsDispatcher(env.id),
]);
// The TEST succeeded — it found out. That is why this is a 200.
expect(response.status).toBe(200);
const body = (await response.json()) as Record<string, unknown>;
expect(body["delivered"]).toBe(false);
expect(body["status"]).toBe(503);
}, 60_000);
it("reports an endpoint that never answers as delivered: false with an error", async () => {
// Spec edge case: "a test event sent to an endpoint whose URL no longer
// resolves". Modelled as a server that accepts and abandons, which is the
// harder version — a refused connection fails fast, a hang costs the timeout.
const env = await mintEnvironment("test-event-itest-hang");
const key = await createApiKey(db, { environmentId: env.id });
const repo = new Repository(db, env.id);
const endpoint = await seedEndpoint(repo, mintSigningSecret());
received = [];
answerNothing = true;
const [response] = await Promise.all([
sendTest(endpoint.id, key.credential),
actAsDispatcher(env.id),
]);
answerNothing = false;
expect(response.status).toBe(200);
const body = (await response.json()) as Record<string, unknown>;
expect(body["delivered"]).toBe(false);
// No status, because nothing answered. Inventing one would make a customer
// debug a response their server never sent.
expect(body["status"]).toBeNull();
expect(String(body["error"])).toBeTruthy();
}, 60_000);
it("invariant 13: the outcome leaves the failure run exactly as it was", async () => {
// Both directions. A failed test must not push an endpoint toward
// disablement; a successful one must not clear a run and let a customer mask
// a real outage by testing until it passes.
const env = await mintEnvironment("test-event-itest-run");
const key = await createApiKey(db, { environmentId: env.id });
const repo = new Repository(db, env.id);
const endpoint = await seedEndpoint(repo, mintSigningSecret());
// Open a run with a real failure first.
await db.execute(
`UPDATE webhook_endpoints
SET failure_run_started_at = now() - interval '10 minutes',
failure_run_attempts = 3
WHERE id = '${endpoint.id}'`,
);
const before = await endpointRow(endpoint.id);
received = [];
answerStatus = 500;
answerNothing = false;
await Promise.all([sendTest(endpoint.id, key.credential), actAsDispatcher(env.id)]);
expect(await endpointRow(endpoint.id)).toEqual(before);
answerStatus = 200;
await Promise.all([sendTest(endpoint.id, key.credential), actAsDispatcher(env.id)]);
expect(await endpointRow(endpoint.id)).toEqual(before);
}, 90_000);
it("FR-017: re-enabling clears all four columns", async () => {
const env = await mintEnvironment("test-event-itest-reenable");
const key = await createApiKey(db, { environmentId: env.id });
const repo = new Repository(db, env.id);
const endpoint = await seedEndpoint(repo, mintSigningSecret());
// Disabled by the platform, with a run behind it.
await db.execute(
`UPDATE webhook_endpoints
SET enabled = false,
disabled_at = now(),
disabled_reason = '5 consecutive failures over 1h02m; last status 503',
failure_run_started_at = now() - interval '62 minutes',
failure_run_attempts = 5
WHERE id = '${endpoint.id}'`,
);
const response = await fetch(`${url}/v1/webhooks/${endpoint.id}/enable`, {
method: "POST",
headers: { authorization: `Bearer ${key.credential}` },
});
expect(response.status).toBe(200);
const after = await endpointRow(endpoint.id);
expect(after.enabled).toBe(true);
expect(after.disabled_at).toBeNull();
expect(after.disabled_reason).toBeNull();
expect(after.failure_run_started_at).toBeNull();
expect(after.failure_run_attempts).toBeNull();
// And the representation says so, which is what a customer actually reads.
const shown = (await response.json()) as Record<string, unknown>;
expect(shown["disabled_at"]).toBeNull();
expect(shown["failure_run_attempts"]).toBeNull();
}, 60_000);
it("FR-017: the hour is measured from the NEXT failure, not resumed", async () => {
// Without this, a customer who repaired their server and switched it back on
// would be disabled again by the first failure afterwards — on the strength of
// an outage they had already fixed.
const env = await mintEnvironment("test-event-itest-fresh");
const repo = new Repository(db, env.id);
const endpoint = await seedEndpoint(repo, mintSigningSecret());
await db.execute(
`UPDATE webhook_endpoints
SET enabled = false, disabled_at = now(),
disabled_reason = 'x',
failure_run_started_at = now() - interval '90 minutes',
failure_run_attempts = 9
WHERE id = '${endpoint.id}'`,
);
await repo.setEndpointEnabled(endpoint.id, true);
// One fresh failure, reported directly.
const eventId = randomUUID();
const { rows } = (await db.execute(
`INSERT INTO webhook_deliveries (id, environment_id, endpoint_id, event_id, payload)
VALUES ('${randomUUID()}', '${env.id}', '${endpoint.id}', '${eventId}', '{}')
RETURNING id`,
)) as unknown as { rows: { id: string }[] };
await recordAttemptOutcome(db, {
deliveryId: rows[0]!.id,
attempt: 1,
status: 500,
latencyMs: 3,
});
const after = await endpointRow(endpoint.id);
// A NEW run of one, not a resumed run of ten.
expect(after.failure_run_attempts).toBe(1);
expect(after.enabled).toBe(true);
// And its start is recent, not ninety minutes ago.
expect(Date.now() - new Date(after.failure_run_started_at!).getTime()).toBeLessThan(
60_000,
);
}, 60_000);
it("FR-TEN-05: a test against another environment's endpoint answers 404", async () => {
const mine = await mintEnvironment("test-event-itest-mine");
const theirs = await mintEnvironment("test-event-itest-theirs");
const myKey = await createApiKey(db, { environmentId: mine.id });
const theirEndpoint = await seedEndpoint(
new Repository(db, theirs.id),
mintSigningSecret(),
);
received = [];
const response = await sendTest(theirEndpoint.id, myKey.credential);
expect(response.status).toBe(404);
// The same answer a missing endpoint gets, so a probe cannot tell one from the
// other — and nothing was delivered.
const missing = await sendTest(randomUUID(), myKey.credential);
expect(missing.status).toBe(404);
expect(await response.json()).toEqual(await missing.json());
expect(received).toHaveLength(0);
}, 60_000);
it("answers honestly when nothing is there to make the attempt", async () => {
// No dispatcher, nobody playing one. The route must not hang for ever and must
// not claim the endpoint is unhealthy — it does not know that. This is the one
// case where `error` describes the PLATFORM rather than the customer.
const env = await mintEnvironment("test-event-itest-nodispatcher");
const key = await createApiKey(db, { environmentId: env.id });
const repo = new Repository(db, env.id);
const endpoint = await seedEndpoint(repo, mintSigningSecret());
received = [];
const response = await sendTest(endpoint.id, key.credential);
expect(response.status).toBe(200);
const body = (await response.json()) as Record<string, unknown>;
expect(body["delivered"]).toBe(false);
expect(body["status"]).toBeNull();
expect(String(body["error"])).toContain("dispatcher");
expect(received).toHaveLength(0);
}, 60_000);
});Why
POST /test tạo một delivery thật rồi theo dõi cái dòng đó, bởi
lần thử xảy ra ở một tiến trình khác. Phải có gì đó tạo ra nó.
Import bản build của dispatcher vào test lane của api sẽ làm suite này
fail bất cứ khi nào hai package tình cờ build theo thứ tự ngược lại. Viết ra
công việc của dispatcher bằng tay tốn ba mươi dòng và mua được bài test mạnh hơn:
chữ ký được xác minh bằng node:crypto và công thức đã ghi trong tài liệu, không dùng gì từ
đường ký. Một bài test xác minh bằng chính code ký của chúng ta sẽ chỉ chứng minh
rằng code đồng ý với chính nó.
Các mức ratchet coverage đã dịch chuyển, và chúng dịch lên:
@@ -172,12 +172,21 @@ export default defineConfig({
// attributable — and the answer was to write `webhooks/deliveries.itest.ts` rather
// than lower the pin. This order has already run the chapters that took this file
// to 92, so the same tests arrive against a HIGHER floor than they were written
// for. The pin stays where the later chapters put it; what the webhook tests buy
// here is that it does not move DOWN.
//
+ // AND THE DISABLEMENT CHAPTER RAISED THEM AGAIN IN THE PUBLISHED ORDER, 89 -> 90,
+ // which this order cannot do either: the pin is already 92 and the webhook
+ // chapter's battery measured 93.48. Published's account is worth keeping for its
+ // shape — measured mid-chapter with the failure run written and its tests not
+ // yet, that file read 96.46 statements and 88.80 branches, which is the instrument
+ // saying "you added five operations and tested none of them" in the only language
+ // it has. The tests were written rather than the pin lowered. Here they arrive
+ // against a floor five chapters of later work already raised.
+ //
// The dispatcher's two decision-bearing files. `expand.ts`
// decides whether a redelivered event produces a second set of webhooks
// — constitution VI names idempotency explicitly — and `deliver.ts`
// holds the post-then-report ordering that chooses a duplicate over a
// silent loss. Pinned here because they measured 0% and 87.5% when the
// service arrived, which is exactly what research R12 warned a new
@@ -205,12 +214,41 @@ export default defineConfig({
"services/dispatcher/src/deliver.ts": {
branches: 90,
functions: 100,
lines: 100,
statements: 100,
},
+
+ // The retry-and-disable chapter's two new files, pinned at 100 on every metric because both
+ // reached it and neither has an excuse not to.
+ //
+ // `disable.ts` is here because constitution VI NAMES this case: it is the
+ // predicate the at-most-once disablement rests on, so it is idempotency
+ // logic, and NFR-MNT-02 asks for 100% branch coverage of that. It is also
+ // pure — no database, no clock, no broker — which is precisely why it was
+ // separated from both triggers that call it. A file with nothing to mock has
+ // no reason to be partially tested.
+ //
+ // `analytics.ts` is here for a different reason: everything it does is
+ // decide what NOT to put on a stream. Its allow-list is the mechanism
+ // standing between a customer's payload and seven days of retention
+ // (FR-004, SC-006), and its `catch` is what stops an analytics outage
+ // becoming a delivery outage (contract invariant 4). Both are branches, and
+ // an unmeasured branch here fails silently in the direction nobody checks.
+ "services/api/src/webhooks/disable.ts": {
+ branches: 100,
+ functions: 100,
+ lines: 100,
+ statements: 100,
+ },
+ "services/api/src/webhooks/analytics.ts": {
+ branches: 100,
+ functions: 100,
+ lines: 100,
+ statements: 100,
+ },
// THE DEDUPLICATION CHAPTER RAISED THIS, 93 -> 95. The chapter added two pure functions
// to this file — the live-path suppression predicate and the scoping that
// bounds the marks — and both are fully covered.
//
// Not 100, and the missing branch is named rather than chased: it is
// `if (timer)` in chapter 2.7's `withDeadline`, whose falsy arm cannot beĐiều đó xảy ra theo cách khó, và đó là cách nó lẽ ra phải xảy ra. Đo
giữa chương khi chuỗi hỏng đã viết còn các bài test của nó thì chưa, repository.ts
đọc ra 96,46% statement và 88,80% branch — dưới cả hai ngưỡng. Cái
dụng cụ đang nói "bạn đã thêm năm thao tác và chưa test cái nào" bằng
thứ ngôn ngữ duy nhất nó có. Các bài test được viết; file giờ đọc ra 97,29 / 90,56 /
100 / 99,14, và đó là những con số mà ratchet được nâng lên.
disable.ts và analytics.ts được ghim ở 100 trên mọi chỉ số. Hiến pháp VI
gọi tên logic đẳng xâm một cách rõ ràng và disable.ts là cái điều kiện mà
việc tắt nhiều-nhất-một-lần dựa lên; analytics.ts là một file mà toàn bộ công việc của nó là
quyết định thứ gì không được đặt lên một stream.
Bộ sabotage
Bảy mutation, mỗi cái áp lên code thật, mỗi cái được hoàn nguyên sau đó với file được xác minh là giống hệt từng byte:
=============== mutation 1: clear the run on failure instead of on success
RESULT: caught
× invariant 6: a failure opens the run, and further failures extend it
× invariant 7: any success clears the run
× SC-003: an endpoint that succeeds once an hour is never disabled
=============== mutation 2: drop the enabled = true predicate
RESULT: caught
× invariant 8 under concurrency: two overlapping reports disable once
× still disables only once when the pair crosses the threshold together
=============== mutation 3: let a test event touch the failure run
RESULT: caught
× invariant 13: the outcome leaves the failure run exactly as it was
=============== mutation 4: publish the attempt inside the transaction
RESULT: caught
× invariant 5: an outcome is recorded and answered with the ANALYTICS stream deleted
=============== mutation 5: remove the sweep from the relay loop
RESULT: caught
× disables an endpoint with nothing but start() and time
=============== mutation 7: drop SELECT FOR UPDATE on the endpoint
RESULT: caught
× counts BOTH of two overlapping failures — the lost update the lock prevents
=============== mutation 6: dedupe on the delivery id alone
RESULT: caught
× deduplicates on the delivery AND the attempt, not the delivery aloneBảy trên bảy, và ba trong số đó không chạy được ngay lần đầu. Đó là phần đáng giữ.
Mutation 4 ban đầu là try { → {, để lại một catch lơ lửng và không
biên dịch được. Bộ sabotage ngoan ngoãn báo là đã bắt được, và nó bị bắt bởi
tsc — thứ chẳng nói gì về việc một bài test có giữ tính chất đó không. Một
mutation không biên dịch được thì không phải một mutation.
Mutation 5 và 7 biên dịch được, chạy được, và pass. Cả hai đều lộ ra khoảng trống thật: vị trí của lượt quét trong vòng lặp chưa được test, và cái row lock đang được ghi công cho việc mà điều kiện đang làm. Cả hai đều sinh ra một bài test mới, và một cái sinh ra một lời chỉnh sửa cho một comment đã tự tin sai.
Why
Không phải các bài test có pass không. Mà là gỡ một cơ chế đi có làm chúng fail không.
Đó là hai câu hỏi khác nhau và chỉ câu thứ hai nói cho bạn điều gì đó. Một suite mà mọi bài test đều pass và không mutation nào bị bắt là một suite chẳng giữ gì cả — và bạn không phân biệt được hai thứ đó bằng cách đọc các bài test hay đọc code, đó là lý do phép kiểm phải mang tính cơ học.
Checkpoint
Cả hai lane và cổng coverage:
$ pnpm test && pnpm test:integration && pnpm coverage
Tests 184 passed (184)
Tests 188 passed (188)
Tests 363 passed (363)Nền tảng giờ ghi lại chuyện xảy ra trên mọi lần thử giao, trên một stream dựng cho khối lượng phân tích và được cho phép mất mát một cách rõ ràng. Nó tắt một endpoint hỏng hơn một tiếng với ít nhất năm lần thử — từ hai cơ chế kích hoạt, bởi một trong hai chứng minh được là không bao giờ kích hoạt cho đúng những khách hàng mà chuyện này dành cho nhất. Nó ghi ra ai phải được báo và thừa nhận, trong một cột, rằng nó không có cách nào để báo họ. Và nó cho một khách hàng chứng minh bản sửa của họ chạy được trước khi bật lại bất cứ thứ gì.
Hai yêu cầu được giao một nửa một cách cố ý. Các lần thử được publish mà không truy vấn được: ingester của Phần 4 mới là thứ hoàn tất FR-WHK-06. Notification được ghi lại mà không được gửi: chương rate-limit dựng transport, cho cả quota nữa.
Checkpoint
services/api/src/isolation/targets.ts và gauntlet.itest.ts nhận thêm một route mỗi chiều:
POST /v1/webhooks/:id/test được gọi tên bởi phép suy dẫn trên bản build đã đăng ký nó —
lần thứ năm trong repository này — và nó là route duy nhất trong danh sách đó mà thiệt hại
sẽ rơi RA NGOÀI nền tảng. Một đòn tấn công xuyên tenant thành công vào nó khiến Relay POST tới
server của một khách hàng khác. Phân loại write, bị tấn công bên cạnh ba hành động anh em,
và đáng một câu chứ không phải một diff.
services/api/src/consumer/consumer.itest.ts đã thôi quét các consumer không phải của nó
tạo ra. Phần dọn dẹp của nó xoá theo tiền tố itest-, thứ chưa bao giờ là của suite này để nhận,
nên nó gỡ mất một durable đang sống thuộc về một suite khác giữa chừng — và nạn nhân báo
consumer deleted hoặc consumer not found vì những lý do chẳng gì trong file của nó giải thích được. Một
lượt quét theo tiền tố xoá cả những thứ nó không tạo ra; giờ tiền tố là của riêng suite này.
services/api/src/auth/credentials.itest.ts là cái thứ tư, và nó là cái đáng
đọc hai lần. Assertion rò rỉ credential của nó suy ra bí mật bằng cách tách credential
theo _ — ký tự nằm trong bảng chữ cái base64url, nên cái kim là mảnh sau dấu gạch dưới
CUỐI CÙNG. Đo trên 200.000 lần đúc: 26,9% credential cho ra một cái kim dài hai mươi
ký tự trở xuống, nên một dòng log làm rò ba mươi ký tự đầu của một bí mật vẫn pass;
1,57% cho ra một ký tự hoặc không ký tự nào, và not.toContain trên một ký tự đơn thì fail
với bất kỳ đống rơm nào.
Cả hai nửa đều được tìm ra hai lần, độc lập với nhau, và các bản sửa đồng ý với nhau. Thứ tự đã xuất bản chạm
nó ở đây, trên một lần đúc kết thúc bằng …_I — assertion đã trở thành "không dòng log nào chứa chữ
I", trên một error body viết "this route expects an API key". Thứ tự này chạm nó sớm hơn một
chương, trong bộ battery coverage, trên một lần đúc kết thúc bằng _0. Hai lần rút trúng cùng
1,57% đó, và cả hai đều tới cùng một bộ phân tích và cùng một ngưỡng.