Part 3 · Chapter 3.7
Commit and publish are two instants
You will produce: The resume duplicate closed: a high-water mark that outlives the buffer · about 60 minutes including the exercise
Chương 2.7 là chương mà loạt bài này gọi là cái bug chủ lực của nó. Nó dựng giao thức resume, phát biểu cuộc đua trùng-lặp/khoảng-trống bằng đúng bấy nhiêu chữ, đóng nó bằng một lập luận chồng-lấn-cộng-chống-trùng, rồi chứng minh lập luận ấy bằng ba bài test.
Nó không hề đóng được cuộc đua.
Một client kết nối lại có thể được giao cùng một message hai lần. Tính chất ấy là của FR-RTM-03 — không khoảng trống và không bản đúp — và suite cột mốc của chương 2.8 khẳng định nó ở mọi lần chạy. Nó đã sai suốt bảy chương — từ 2.8 tới chương retry-and-disable.
Chương này là bốn dòng logic và một cái nhìn dài về vì sao chúng đã thiếu.
Hai khoảnh khắc
Đây là đường gửi của gateway, thứ không đổi từ chương 2.6:
const committed = await api.sendMessage(connection.identity, { … });
… // the ack, then nineteen lines of comment
…
if (!committed.duplicate && committed.text !== null) {
await fanout?.publish({ id: committed.id, channel: committed.channel_id, seq: committed.seq, … });
}Hãy đọc hai cái await đó như hai khoảnh khắc chứ không phải một thao tác.
Sau cái thứ nhất, message nằm trong PostgreSQL với một sequence đã được gán. Nó bền vững. Bất kỳ câu query nào — kể cả lần backfill của một client đang resume — đều thấy được nó.
Sau cái thứ hai, tấm vải mới biết. Mọi gateway đã đăng ký sẽ giao nó.
Giữa hai cái đó thì message tồn tại và chưa được thông báo, và chẳng gì trong lập luận của chương 2.7 có một cái tên cho trạng thái đó.
sequenceDiagram
participant D as gateway của người gửi
participant A as api service
participant PG as PostgreSQL
participant R as Redis fabric
participant T as gateway của Tuấn (đang resume)
D->>A: gửi "still coming down"
A->>PG: commit · cấp seq 4
A-->>D: 201 · seq 4
Note over A,PG: BỀN VỮNG TỪ ĐÂY — mọi truy vấn backfill đều thấy seq 4
T->>A: backfill, cursor = 1
A->>PG: SELECT seq > 1
A-->>T: 2, 3, 4
Note over T: mark = 4 · flush · phase = live
D->>R: publish seq 4
Note over D,R: LOAN BÁO TỪ ĐÂY — và resume thì đã xong rồi
R->>T: seq 4
Note over T: chương 2.7: không còn gì để đối chiếu → GIAO HAI LẦNĐó là cả cái defect. Phần backfill của một kết nối đang resume trả về sequence 4, nên mốc cao nhất của nó thành 4. Nó xả bộ đệm, chuyển sang trực tiếp, rồi sau đó lệnh publish bị trễ mới tới — tại một kết nối không còn đệm nữa, giao một sequence mà client đã được trao chỉ một khoảnh khắc trước.
Ba trên bốn góc phần tư
resume.itest.ts có ba bài test khi chương này bắt đầu. Hãy vẽ chúng lên hai trục
— frame được publish khi nào, và sequence của nó có nằm ở hoặc dưới cái mốc không —
và hình dạng của chỗ bỏ sót hiện ra ngay.
flowchart TB
subgraph during["publish TRONG LÚC đang buffer"]
d1["seq <= mark<br/>test 1 · bị flushable chặn"]
d2["seq > mark<br/>test 2 · flush giao đi"]
end
subgraph after["publish SAU KHI đã live"]
a1["seq <= mark<br/>KHÔNG CÓ TEST — chỗ lỗi nằm"]
a2["seq > mark<br/>test 3 · giao live"]
end
note["ba test, bốn ô·<br/>ô trống chỉ cách test ngay trên nó<br/>đúng một con số"]
a1 -.-> note
style a1 fill:#7f1d1d,color:#fff,stroke:#dc2626Bài test thứ ba publish frame(44) sau khi lần resume đã xong rồi khẳng định rằng nó
tới nơi. Bài test còn thiếu thì publish frame(42) — một sequence mà phần backfill đã
gửi rồi — và khẳng định rằng nó không tới.
Cách nhau một con số. Đó không phải sự trùng hợp; đó là chuyện xảy ra khi một suite được viết ra từ một mô hình. Mô hình có ba trường hợp và suite có ba bài test, và chẳng ai vẽ cái ma trận.
Đây là ba bài test mà chương này thêm vào:
@@ -191,7 +191,123 @@ describe("resume across a real fabric", () => {
// the phase went back to normal 2.6 delivery.
await publishFromElsewhere(frame(44));
await settle(300);
expect(created(frames)).toEqual([42, 44]);
socket.close();
});
+
+ it("suppresses a frame the backfill already delivered, published after the resume", async () => {
+ // THE FOURTH QUADRANT, and the defect this chapter exists to close.
+ //
+ // The three tests above cover: published while buffering and in the backfill
+ // (deduplicated by `flushable`); published while buffering and NOT in the
+ // backfill (delivered by the flush); published after going live with a
+ // sequence ABOVE the mark (delivered). The cell they leave empty is this one —
+ // published after going live, with a sequence the backfill already sent.
+ //
+ // It is not a contrived case. A message is durable at one instant and
+ // announced at another: the gateway commits through the api and only then
+ // publishes to Redis. A backfill query landing between those two instants
+ // returns a message the fabric has not yet delivered, and the delivery arrives
+ // after the resume has finished — when chapter 2.7's dedup window has already
+ // closed, because `marks` was a local variable that `resume()` discarded.
+ //
+ // One number different from the test above it. That is the whole bug.
+ harness = await boot({
+ session: async () => ({
+ environment_id: "env-1",
+ user: "tuan",
+ channel_ids: [CHANNEL],
+ }),
+ backfill: async () => ({
+ [CHANNEL]: { messages: [frame(42)], truncated: false },
+ }),
+ sendMessage: async () => {
+ throw new Error("not used");
+ },
+ });
+ const socket = new WebSocket(
+ `${harness.url}?token=${await token()}&cursor=${CHANNEL}:41`,
+ );
+ const frames = record(socket);
+ await settle(400);
+ // The resume has completed. NOW the fabric catches up with a message the
+ // backfill already delivered — the publish that was still in flight while the
+ // backfill query ran.
+ await publishFromElsewhere(frame(42));
+ await settle(300);
+
+ expect(created(frames)).toEqual([42]);
+ socket.close();
+ });
+
+ it("still suppresses when two instances publish out of order", async () => {
+ // Sequences COMMIT in order under a channel row lock; they are PUBLISHED by
+ // whichever gateway instance handled each send, and those do not coordinate.
+ // A prompt publish of 43 can precede a stalled publish of 42.
+ //
+ // This is the case that made the spec's first design unsafe. It proposed
+ // retiring the mark once a higher sequence arrived — which would see the 43,
+ // drop the mark, and then deliver the 42 (research R3).
+ harness = await boot({
+ session: async () => ({
+ environment_id: "env-1",
+ user: "tuan",
+ channel_ids: [CHANNEL],
+ }),
+ backfill: async () => ({
+ [CHANNEL]: { messages: [frame(42)], truncated: false },
+ }),
+ sendMessage: async () => {
+ throw new Error("not used");
+ },
+ });
+ const socket = new WebSocket(
+ `${harness.url}?token=${await token()}&cursor=${CHANNEL}:41`,
+ );
+ const frames = record(socket);
+ await settle(400);
+ // 43 is ABOVE the mark and must be delivered. A rule that retired the mark on
+ // seeing it would then have nothing left to compare the delayed 42 against.
+ await publishFromElsewhere(frame(43));
+ await settle(150);
+ // 42 is the mark itself, arriving late from an instance that stalled between
+ // its api call and its publish. It must still be suppressed.
+ await publishFromElsewhere(frame(42));
+ await settle(300);
+
+ expect(created(frames)).toEqual([42, 43]);
+ socket.close();
+ });
+
+ it("suppresses nothing when the resume degraded", async () => {
+ // A degraded resume tells the client to page history for every channel, so the
+ // backfill it received is a fragment or nothing at all. A mark taken from it
+ // would suppress messages the client never got — turning this chapter's
+ // duplicate into a gap, which constitution II ranks worse.
+ harness = await boot({
+ session: async () => ({
+ environment_id: "env-1",
+ user: "tuan",
+ channel_ids: [CHANNEL],
+ }),
+ backfill: async () => {
+ throw new Error("backfill unavailable");
+ },
+ sendMessage: async () => {
+ throw new Error("not used");
+ },
+ });
+ const socket = new WebSocket(
+ `${harness.url}?token=${await token()}&cursor=${CHANNEL}:41`,
+ );
+ const frames = record(socket);
+ await settle(400);
+ // A sequence at or below the presented cursor. With no mark retained it must
+ // still arrive: the client was told to page history, not to expect silence.
+ await publishFromElsewhere(frame(41));
+ await settle(300);
+
+ expect(created(frames)).toEqual([41]);
+ socket.close();
+ });
});Chạy chúng với code của chương retry-and-disable thì hai bài đỏ:
$ pnpm --filter @relay/gateway test:integration src/resume.itest.ts
× suppresses a frame the backfill already delivered, published after the resume
× still suppresses when two instances publish out of order
Tests 2 failed | 4 passed (6)
AssertionError: expected [ 42, 42 ] to deeply equal [ 42 ][ 42, 42 ]. Trong bốn giây, mọi lần, với một api giả và một Redis thật.
Giữ lại cái mốc
Phần chống trùng đã tồn tại sẵn. flushable so các frame đã đệm với cái mốc
trong bước 4, và lời chú thích của nó giải thích chỗ lệch một rất chi tiết. Thứ còn thiếu
là chính phép so đó, muộn hơn một bước:
@@ -104,12 +104,57 @@ export function flushable(
buffer: Message[],
marks: Record<string, number>,
): Message[] {
return buffer.filter((frame) => frame.seq > (marks[frame.channel] ?? 0));
}
+/** Step 5's half, and the reason this chapter exists (FR-RTM-03).
+ *
+ * `flushable` answers this question for frames sitting in the buffer. This answers
+ * it for frames arriving AFTER the connection has gone live, which is the case
+ * chapter 2.7 did not have — its marks were a local variable that `resume()`
+ * discarded the moment it flushed.
+ *
+ * A message is durable at one instant and announced at another: the gateway
+ * commits through the api and only then publishes to the fabric. A backfill query
+ * landing between those two instants returns a message the fabric has not yet
+ * delivered, so the delivery arrives once the resume is over — and before this
+ * function there was nothing left to compare it against.
+ *
+ * `<=`, not `<`. The mark IS a sequence the backfill delivered rather than the one
+ * after it, which is the same off-by-one `flushable` documents one screen up. */
+export function suppressed(
+ marks: Record<string, number> | null,
+ frame: Message,
+): boolean {
+ if (marks === null) return false;
+ const mark = marks[frame.channel];
+ return mark !== undefined && frame.seq <= mark;
+}
+
+/** The marks a connection keeps, bounded to the channels it actually presented
+ * cursors for.
+ *
+ * `highWaterMarks` seeds from the cursors and then adds a key for every channel
+ * the backfill answered with, so on its own it bounds nothing. The api derives its
+ * response from the cursors it was given, so the two agree today — but a bound
+ * this service claims should not live in another service's response shape. This is
+ * `scopeCursors` applied one step later, and it is here rather than in `session.ts`
+ * so that a unit test can reach it.
+ *
+ * At most `MAX_RESUME_CHANNELS` entries, because that is what the resume contract
+ * already caps the cursor map at. */
+export function scopeMarks(
+ marks: Record<string, number>,
+ cursors: Record<string, number>,
+): Record<string, number> {
+ return Object.fromEntries(
+ Object.entries(marks).filter(([channelId]) => channelId in cursors),
+ );
+}
+
/** A promise that resolves false instead of hanging forever. */
export async function withDeadline(
work: Promise<unknown>,
ms: number,
): Promise<boolean> {
let timer: ReturnType<typeof setTimeout> | undefined;suppressed chính là câu hỏi của flushable đem hỏi một kết nối đang sống. scopeMarks
giới hạn thứ được giữ lại.
Cái mốc rồi cần một chỗ để sống, và đó là một field:
@@ -26,12 +26,30 @@ export interface Connection {
* field and nothing else — the resume machinery is invisible to it. */
phase: ResumePhase;
buffer: Message[];
/** Set when the buffer hit its ceiling. The frames are gone, so the
* client must be told to page history instead of trusting the stream. */
overflowed: boolean;
+ /** Per channel, the highest sequence this connection's backfill
+ * delivered — kept for the connection's life so that a frame the fabric
+ * announces AFTER the resume has finished can still be recognised as one the
+ * client already holds.
+ *
+ * Three states, and the middle one is not the same as the first:
+ *
+ * null a fresh connect, or a resume that degraded — suppress nothing
+ * {} a resume whose cursor set was empty after scoping
+ * { chan: 42 } suppress at or below 42 on that channel
+ *
+ * NEVER RETIRED while the connection lives. Retiring on a higher sequence looks
+ * like the natural way to bound this and hands the duplicate straight back:
+ * sequences commit in order under a channel row lock but are published by
+ * whichever gateway instance handled each send, so a prompt 43 can beat a stalled
+ * 42. Bounded instead by `MAX_RESUME_CHANNELS`, which already caps the cursors
+ * these are scoped to. */
+ marks: Record<string, number> | null;
}
export class Registry {
private readonly byId = new Map<string, Connection>();
add(connection: Connection): void {Và ba chỗ gọi — đặt nó khi thành công, xoá nó ở mọi lần suy giảm, tra tới nó trong lúc giao:
@@ -11,12 +11,14 @@ import type { Fanout } from "./fanout.js";
import { Registry, type Connection } from "./registry.js";
import {
MAX_BUFFERED_FRAMES,
SUBSCRIBE_DEADLINE_MS,
flushable,
highWaterMarks,
+ scopeMarks,
+ suppressed,
parseCursors,
scopeCursors,
withDeadline,
} from "./resume.js";
// One session per socket (chapter 2.5). The order of operations here is the
@@ -86,12 +88,19 @@ export function attachSessions({
connection.overflowed = true;
continue;
}
connection.buffer.push(message);
continue;
}
+ // A live connection is not necessarily a connection with
+ // nothing to remember: a frame at or below what its backfill already
+ // delivered is one it has, however long ago the resume finished. Before
+ // this, delivery consulted `phase` and nothing else, and the marks were
+ // discarded the moment the connection went live — which is precisely when
+ // the fabric could still be catching up.
+ if (suppressed(connection.marks, message)) continue;
send(connection.socket, { type: "message.created", payload: message });
}
}
fanout?.onDelivery(deliver);
// noServer: the upgrade is handled by hand so the token can be checked
// BEFORE the handshake completes. Letting ws own the upgrade would mean
@@ -153,12 +162,15 @@ export function attachSessions({
// questions at once, and a failure now closes the socket before it opens.
channelIds: new Set(channelIds),
missedPings: 0,
phase: presented === undefined ? "live" : "buffering",
buffer: [],
overflowed: false,
+ // A fresh connect suppresses nothing; a resume fills this in when it
+ // succeeds, and leaves it null when it degrades.
+ marks: null,
};
registry.add(connection);
// Subscriptions follow membership: the first local member of a channel
// makes this instance a subscriber, and the last one to leave releases
// it (reference-counted in the fabric).
@@ -255,12 +267,17 @@ export function attachSessions({
/** Everything that cannot promise completeness ends up here: the client
* is told resume did not happen and which channels to page instead. The
* frames held so far are dropped on purpose — they would be an arbitrary
* fragment of a stream the client is about to refetch in full. */
const degrade = (reason: string): void => {
connection.buffer = [];
+ // AND THE MARKS. A degraded resume tells the client to page history for
+ // every channel, so the backfill behind these marks is a fragment or
+ // nothing at all — suppressing on them would turn this chapter's duplicate
+ // into a gap, which constitution II ranks worse.
+ connection.marks = null;
connection.phase = "live";
ack(connection, {
cursor: cursors,
resume_ok: false,
truncated: [...connection.channelIds],
});
@@ -329,12 +346,16 @@ export function attachSessions({
return;
}
for (const message of flushable(connection.buffer, marks)) {
send(connection.socket, { type: "message.created", payload: message });
}
connection.buffer = [];
+ // KEPT, where chapter 2.7 discarded them. Scoped to the cursors this
+ // connection actually presented, so the bound is this service's rather than
+ // one inherited from the shape of the api's response.
+ connection.marks = scopeMarks(marks, cursors);
// Step 5.
connection.phase = "live";
logger.log("info", "resume.completed", {
connection_id: connection.id,
backfilled: Object.values(backfilled).reduce(
(n, page) => n + page.messages.length,flowchart LR
subgraph resume["resume, chương 2.7"]
s1["1 subscribe"]
s2["2 buffer"]
s3["3 backfill<br/>ghi lại mark H"]
s4["4 flush<br/>phát seq > H"]
end
s5["5 live"]
keep[["chương 3.7:<br/>GIỮ H trên Connection"]]
del{"deliver()"}
drop["seq <= H<br/>client đã có rồi"]
send["seq > H<br/>gửi"]
s1 --> s2 --> s3 --> s4 --> s5
s3 -.->|"H, thu hẹp theo<br/>các cursor client đưa"| keep
s5 --> del
keep --> del
del -- "suppressed" --> drop
del -- "còn lại" --> send
style keep fill:#064e3b,color:#fff,stroke:#059669Một trong các bản diff đó xoá một câu, và cái câu ấy mới là phần thú vị.
Connection.phase từng mang lời chú thích này:
Phần giao đọc field này và không gì khác — bộ máy resume thì vô hình với nó.
Đó chính là cái defect được viết ra như một nguyên tắc thiết kế, và nó đọc lên như một đức tính. Phần giao nên đơn giản; bộ máy resume nên được khoanh vùng. Rắc rối là chính sự khoanh vùng ấy đóng cửa sổ chống trùng, mà cái cửa sổ ấy phải mở lâu hơn lần resume một chút.
Vì sao cái mốc không bao giờ được cho nghỉ hưu
Giữ một số nguyên cho mỗi channel suốt đời một kết nối thì mời gọi một sự tối ưu hiển nhiên: bỏ nó đi ngay khi một sequence cao hơn tới, bởi chắc hẳn cửa sổ đã đóng khi đó rồi.
Nó chưa đóng, và lý do vẫn là cùng cái bài toán hai-khoảnh-khắc, cao hơn một tầng. Các sequence được gán dưới một khoá dòng channel ở chương 2.2, nên sequence 4 commit trước sequence 5. Chúng được publish bởi bất kỳ instance gateway nào xử lý mỗi lần gửi, và các instance đó thì không phối hợp. Instance A có thể commit 4 rồi khựng lại trước khi publish trong khi instance B commit 5 rồi publish ngay:
resume completes, mark = 4
seq 5 arrives → above the mark → deliver, and RETIRE
seq 4 arrives → no mark left → deliver ← the duplicate, againQuy tắc cho nghỉ hưu trao thẳng cái cửa sổ ấy lại. Bản đặc tả của chương này ban đầu đòi hỏi nó, và phần nghiên cứu đã lật đổ bản đặc tả.
Vẫn bị giới hạn mà chẳng cần nó. Các dấu được giới hạn theo đúng các cursor mà client đã trình ra,
và hợp đồng resume thì vốn đã từ chối nhiều hơn MAX_RESUME_CHANNELS cursor —
200 số nguyên cho mỗi kết nối, không đổi suốt đời nó.
Cùng một đường nối, bốn lần
Đây là chủ đề lặp đi lặp lại của Phần 3, và đường fan-out là trường hợp được dựng trước khi người đọc có khái niệm về nó.
| Chương | Đường nối | Câu trả lời |
|---|---|---|
| chương outbox | outbox | làm cho hai khoảnh khắc thành nguyên tử — một transaction, không khoảng trống |
| chương webhook dispatcher | việc giao webhook | post, rồi báo cáo; khách hàng hấp thụ bản trùng |
| chương retry-and-disable | bản ghi các lần thử | publish sau lệnh commit; bản ghi có thể bị mất |
| chương này | fan-out và resume | chồng lấn, rồi chống trùng ở bên đọc |
Bốn trường hợp của bền vững ở một khoảnh khắc, được thông báo ở một khoảnh khắc khác, và bốn câu trả lời đúng khác nhau. Câu trả lời tuỳ vào cái khoảng trống ấy tốn gì. Mất một bản ghi phân tích thì tốn một dashboard; mất một message thì tốn cả sản phẩm. Chương 2.6 dựng fan-out trước khi chương outbox gọi tên cái pattern, và đó chính là lý do cái này không bị ai nhận ra.
Nó được tìm ra thế nào, và chuyện đó tốn gì
Không phải bằng suy luận. Bằng một lần đỏ chập chờn trong phép đo nền của chương retry-and-disable, trong một cái lane đồng thời đang đỏ vì ba lý do chẳng liên quan — một suite xoá consumer broker của một suite khác, một consumer wildcard ăn mất event của một suite hàng xóm, và một khẳng định bảo mật đã thoái hoá thành "chẳng dòng log nào chứa chữ I".
Một lần đỏ e2e thi thoảng trong đám ấy thì trông như thêm một cái nữa cùng loại. Nó sống sót bởi một cái lane đỏ giấu các defect thật giữa những cái giả, và đó là lập luận thực dụng cho việc sửa các flake ngay cả khi chúng là của người khác.
Một số hiệu chương là một tham chiếu sẽ già đi
Chèn chương này vào đã dời quota đi một chỗ và isolation gauntlet đi một chỗ, và trong thứ tự mà cuốn sách này được viết lần đầu thì chuyện đó bỏ lại ba lời chú thích mắc cạn trong mã nguồn của nền tảng, mỗi cái trích cái số mà chủ đề của nó từng có. Một cái đã sai từ lần chèn trước đó.
Chẳng cái nào trong ba được sửa ở đây, bởi chẳng cái nào còn nói ra một con số nữa. Phần
này gọi tên chương theo chủ đề và lần viết lại làm chuyện đó đã chạy trước cả chương một, nên
tới lúc chương này tới thì chẳng còn gì để sửa. Thứ sống sót là cái lập luận,
và cái nhãn xuất xứ mang nó — thứ tới được services/api/src/db/schema.ts cùng
chương webhook, mười hai chương kể từ đây, bởi đó là chỗ file ấy đổi lần kế tiếp.
Sự phân biệt đáng giữ là giữa một dấu xuất xứ và một lời hứa về phía trước. "Chương này thêm field này" thì đúng mãi mãi. "Một chương sau sẽ dựng lớp vận chuyển cho quota" thì cũ đi ngay khoảnh khắc có gì đó được chèn vào trước nó, và nó sống bên trong một file được fence chính xác từng byte vào một chương đã xuất bản, nên sửa nó tốn một lần sửa đổi fence.
Và script đi bộ cũng mang theo cùng lời hứa ấy, trong một console.log mà một người đọc chạy
chứ không đọc. Nó không được trưng ra ở đây: scripts/webhook-walk.mjs thuộc về
các chương webhook, ở movement VI, và chương này chẳng có file nào tên đó để sửa.
Cùng lần sửa ấy rơi xuống ở đó, trên cùng lập luận.
Các suite giữ gì
Cái điều kiện và phần giới hạn phạm vi đều thuần tuý, nên bài test của chúng chẳng cần socket, chẳng cần broker và chẳng cần đồng hồ:
@@ -4,12 +4,14 @@ import type { Message } from "@relay/protocol";
import {
flushable,
highWaterMarks,
parseCursors,
scopeCursors,
+ scopeMarks,
+ suppressed,
withDeadline,
} from "./resume.js";
// The resume theorem, held still (chapter 2.7). Everything here is a pure
// function precisely so the ordering argument can be tested without a
// socket, a broker, or a database — session.test.ts then proves the
@@ -131,6 +133,88 @@ describe("withDeadline", () => {
// connection away.
expect(await withDeadline(Promise.reject(new Error("redis down")), 50)).toBe(
false,
);
});
});
+
+// `flushable` decides what a resuming connection may hand over from
+// its buffer; `suppressed` decides what a LIVE connection must still refuse. The
+// two comparisons are the same and the second one did not exist, which is the
+// whole of the defect.
+describe("suppressed", () => {
+ const at = (channel: string, seq: number): Message => ({
+ id: `id-${seq}`,
+ channel,
+ seq,
+ user: "dispatcher",
+ text: `m${seq}`,
+ created_at: "2026-08-19T00:00:00.000Z",
+ });
+
+ it("suppresses a frame at the mark", () => {
+ // `<=`, not `<`: the mark IS a sequence the backfill delivered.
+ expect(suppressed({ a: 42 }, at("a", 42))).toBe(true);
+ });
+
+ it("suppresses a frame below the mark", () => {
+ expect(suppressed({ a: 42 }, at("a", 7))).toBe(true);
+ });
+
+ it("delivers a frame above the mark", () => {
+ // The half that matters most: suppression must never become a gap.
+ expect(suppressed({ a: 42 }, at("a", 43))).toBe(false);
+ });
+
+ it("delivers on a channel with no mark", () => {
+ expect(suppressed({ a: 42 }, at("b", 1))).toBe(false);
+ });
+
+ it("suppresses nothing when the connection never resumed", () => {
+ expect(suppressed(null, at("a", 1))).toBe(false);
+ });
+
+ it("suppresses nothing when the mark set is empty", () => {
+ expect(suppressed({}, at("a", 1))).toBe(false);
+ });
+
+ it("keeps channels apart", () => {
+ // A mark on one channel must never silence another. Two connections' worth of
+ // confusion would look exactly like message loss.
+ const marks = { a: 42, b: 1 };
+ expect(suppressed(marks, at("a", 40))).toBe(true);
+ expect(suppressed(marks, at("b", 40))).toBe(false);
+ });
+});
+
+describe("scopeMarks", () => {
+ it("drops a channel the cursors never named", () => {
+ // `highWaterMarks` adds a key for every channel the BACKFILL answered with.
+ // The api derives its response from the cursors it was given, so this cannot
+ // happen today — and a bound this service claims should not depend on another
+ // service's response shape.
+ expect(scopeMarks({ a: 42, surprise: 9 }, { a: 41 })).toEqual({ a: 42 });
+ });
+
+ it("keeps every channel the cursors did name", () => {
+ expect(scopeMarks({ a: 42, b: 7 }, { a: 41, b: 1 })).toEqual({ a: 42, b: 7 });
+ });
+
+ it("never exceeds the cursor set, which the resume contract already caps", () => {
+ const cursors = Object.fromEntries(
+ Array.from({ length: 200 }, (_, i) => [`c${i}`, i]),
+ );
+ const marks = { ...cursors, extra: 1 };
+ expect(Object.keys(scopeMarks(marks, cursors))).toHaveLength(200);
+ });
+
+ it("keeps a cursor far above anything the channel holds", () => {
+ // A client-supplied cursor is checked only for being a non-negative integer.
+ // A nonsense value seeds a mark rather than being clamped, and that is the
+ // documented consequence rather than a defect: the client asserted it holds
+ // those sequences, and the backfill has always taken that at face value.
+ // The blast radius is one connection and reconnecting recovers it.
+ expect(scopeMarks({ a: 999_999_999 }, { a: 999_999_999 })).toEqual({
+ a: 999_999_999,
+ });
+ });
+});Và phần đấu dây — rằng deliver() có tra tới các dấu hay không — được giữ bằng một
tấm vải giả:
@@ -466,12 +466,79 @@ describe("the socket (chapter 2.5)", () => {
const seqs = created(frames);
expect(seqs).toEqual([42, 43]); // complete…
expect(new Set(seqs).size).toBe(seqs.length); // …and exactly once
socket.close();
});
+ it("a frame at the mark, arriving after the resume, is not delivered", async () => {
+ // The same property `resume.itest.ts` proves against a real broker, held here
+ // against a stubbed one so it fails fast and without Redis. This is the wiring
+ // rather than the predicate: that `deliver()` consults the marks at all.
+ const fanout = stubFanout();
+ harness = await boot(
+ stubApi({
+ backfill: async () => ({
+ [CHANNEL]: { messages: [frame(42)], truncated: false },
+ }),
+ }),
+ undefined,
+ fanout,
+ );
+ const socket = new WebSocket(
+ `${harness.url}?token=${await token()}&cursor=${CHANNEL}:41`,
+ );
+ const frames = record(socket);
+ await nextFrame(socket, "connection.ack");
+ await settle();
+ // The resume is over. The fabric catches up with what the backfill sent.
+ fanout.emit(frame(42));
+ await settle();
+ expect(created(frames)).toEqual([42]);
+ socket.close();
+ });
+
+ it("a frame above the mark, arriving after the resume, IS delivered", async () => {
+ // The half that stops a duplicate fix becoming a gap (FR-RTM-03 is one
+ // property, not two: no gap AND no double).
+ const fanout = stubFanout();
+ harness = await boot(
+ stubApi({
+ backfill: async () => ({
+ [CHANNEL]: { messages: [frame(42)], truncated: false },
+ }),
+ }),
+ undefined,
+ fanout,
+ );
+ const socket = new WebSocket(
+ `${harness.url}?token=${await token()}&cursor=${CHANNEL}:41`,
+ );
+ const frames = record(socket);
+ await nextFrame(socket, "connection.ack");
+ await settle();
+ fanout.emit(frame(43));
+ await settle();
+ expect(created(frames)).toEqual([42, 43]);
+ socket.close();
+ });
+
+ it("a connection that never resumed suppresses nothing", async () => {
+ // A fresh connect presents no cursor, so it holds no marks and behaves
+ // exactly as chapter 2.6 left it.
+ const fanout = stubFanout();
+ harness = await boot(stubApi({}), undefined, fanout);
+ const socket = new WebSocket(`${harness.url}?token=${await token()}`);
+ const frames = record(socket);
+ await nextFrame(socket, "connection.ack");
+ await settle();
+ fanout.emit(frame(1));
+ await settle();
+ expect(created(frames)).toEqual([1]);
+ socket.close();
+ });
+
it("stages the other interleaving: published during backfill, absent from it", async () => {
// Same window, the other order: seq 43 committed AFTER the backfill
// query's snapshot, so it exists ONLY in the buffer. This is the
// interleaving where backfill-then-subscribe loses the message.
const fanout = stubFanout();
harness = await boot(Ratchet dịch lên, và cố ý dừng lại trước 100:
@@ -75,14 +75,24 @@ export default defineConfig({
"services/api/src/db/repository.ts": {
branches: 85,
functions: 100,
lines: 98,
statements: 95,
},
+ // 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
+ // reached because a Promise executor runs synchronously and always assigns
+ // the timer before the `finally` can see it. Pinning 100 here would pin a
+ // number the file cannot reach without deleting a defensive check that
+ // costs nothing.
"services/gateway/src/resume.ts": {
- branches: 93,
+ branches: 95,
functions: 100,
lines: 100,
statements: 100,
},
"services/api/src/auth/user-token.ts": {
branches: 96,Loạt phá hoại
Năm đột biến, mỗi cái được áp lên code thật, mỗi cái được hoàn nguyên với file được kiểm là giống hệt từng byte:
=============== mutation 1: never keep the marks after a successful resume
RESULT: caught × suppresses a frame the backfill already delivered…
=============== mutation 2: use < instead of <= in the predicate
RESULT: caught × suppresses a frame the backfill already delivered…
=============== mutation 3: suppress on any channel rather than the frame's own
RESULT: caught × delivers on a channel with no mark
× keeps channels apart
=============== mutation 4: ignore the cursors when scoping the marks
RESULT: caught × drops a channel the cursors never named
=============== mutation 5: retire a mark once a higher sequence is delivered
RESULT: caught × still suppresses when two instances publish out of order
=============== files restored byte-identical?
YESHai trong năm cái đó không được viết đúng ngay lần đầu, và cả hai lần sửa chữa đều đáng giá hơn những lần xanh.
Đột biến thứ tư theo kế hoạch thì không thể đỏ được. Nó định là "giữ lại các
dấu qua một lần resume suy giảm". Đọc code trước khi chạy nó: mọi
return degrade(...) đều xảy ra trước khi các dấu từng được gán, nên trên mọi
đường suy giảm thì chúng vẫn là null và lệnh xoá bên trong degrade chẳng đổi gì.
Yêu cầu đó đúng một cách cấu trúc — các dấu chỉ được đặt trên đường thành công —
chứ không phải bởi cái dòng ấy có chạy. Dòng ấy ở lại như một guard; đột biến được
thay bằng một đột biến tấn công phần giới hạn phạm vi thay vào đó.
Đột biến thứ năm sống sót, và lỗi nằm ở bài test. Trường hợp sai thứ tự publish sequence 43 rồi 42 với một cái mốc là 43. Cả hai đều ở hoặc dưới nó, nên cái quy tắc cho nghỉ hưu mà đột biến thêm vào chẳng bao giờ nổ. Kịch bản cần một frame trên cái mốc trước — 43 được giao, cái mốc được cho nghỉ hưu, rồi cái 42 bị trễ mới được giao lần thứ hai. Giờ bài test dựng đúng cảnh đó.
Một bài test cưỡi trên một stream cứ lớn lên mãi
Cái lane tìm ra cái này ở lần chạy thứ 11 trên hai mươi, và đó là lý do duy nhất nó nằm trong chương này chứ không nằm trong sự cố của ai đó:
expected [ …(2756) ] to include '<uuid>'Bài test consumer-đuổi-kịp publish ba event, rồi rút cạn stream cho tới khi nó thấy
chúng. Nó gọi ENV() ba lần để làm thế — mà ENV tạo một uuid mới tinh ở mỗi
lần gọi, nên ba event đi tới ba subject khác nhau và chẳng bộ lọc nào phủ nổi cả
ba. Không có bộ lọc, durable bắt đầu từ đầu một stream giữ chừng mười ba
nghìn event từ các chương trước, còn vòng lặp rút thì có một ngân sách cố định 800
lượt để đi qua chúng.
2,756 event được rút, tồn đọng vẫn chưa sạch, khẳng định đỏ.
Giới hạn consumer vào một environment thì gỡ bỏ cuộc đua chứ không phải nâng ngân sách lên. Lần rút chẳng có gì để rút, và khẳng định thì nói về đúng ba event mà bài test đã publish. Bất biến mà nó được viết ra cho thì không bị đụng: stream giữ message bất kể có ai đang đọc hay không, và tồn đọng thì chờ.
@@ -424,29 +424,61 @@ describe("the consumer", () => {
it("invariant 9: a consumer stopped for N publishes receives all N on restart", async () => {
// What `limits` retention means: the stream holds messages whether or not
// anybody is reading. The backlog waits.
const durable = `itest-catchup-${Date.now()}`;
const seen: string[] = [];
- const runtime = runtimeFor(db, durable, async (e) => void seen.push(e.id));
+ // ONE environment for all three publishes, and the consumer filtered to it.
+ //
+ // This test used to call `ENV()` three times — and `ENV` mints a fresh uuid
+ // on every call, so the three events went to three different subjects and no
+ // filter could cover them. Without a filter the durable starts at the head of
+ // a stream holding ~13,000 events from earlier chapters, and the loop below
+ // had to drain all of them inside a fixed budget of 800 polls before the
+ // three under test were even reachable.
+ //
+ // Found on run 11 of the deduplication chapter's twenty post-fix lane runs: `expected
+ // [ …(2756) ] to include '<uuid>'`. 2,756 events drained and the backlog
+ // still not cleared. It is the same shape as the sweep and the drain in
+ // `deliveries.itest.ts` — a test riding a shared, growing resource with a
+ // fixed budget, which passes until the resource outgrows the budget.
+ //
+ // Scoped, the drain below has nothing to drain and the assertion is about
+ // exactly the three events it published. The invariant is unchanged: the
+ // stream holds messages whether or not anybody is reading, and the backlog
+ // waits.
+ const environmentId = ENV();
+ const runtime = runtimeFor(
+ db,
+ durable,
+ async (e) => void seen.push(e.id),
+ silent,
+ environmentId,
+ );
// Get to the head of the stream first, so "everything published while away"
- // is measurable rather than lost in twelve thousand older events.
+ // is measurable rather than lost among older events.
for (let i = 0; i < 800; i++) {
const { handled, duplicates } = await runtime.pollOnce();
if (handled + duplicates === 0) break;
}
await runtime.stop();
const published = [
- await publish(ENV()),
- await publish(ENV()),
- await publish(ENV()),
+ await publish(environmentId),
+ await publish(environmentId),
+ await publish(environmentId),
];
- const restarted = runtimeFor(db, durable, async (e) => void seen.push(e.id));
+ const restarted = runtimeFor(
+ db,
+ durable,
+ async (e) => void seen.push(e.id),
+ silent,
+ environmentId,
+ );
for (let i = 0; i < 100; i++) {
await restarted.pollOnce();
if (published.every((id) => seen.includes(id))) break;
}
await restarted.stop();