Skip to content

Commit 8fdf109

Browse files
carderneTrigger.dev RepoOps
authored andcommitted
fix(webapp): realtime subscriptions racing stream creation
Fix intermittent failures when subscribing to newly created realtime streams. Retry stream-specific not-found responses within the existing retry limits, preserve resume positions and cancellation, and avoid treating configuration or authentication errors as empty streams. Mono-RevId: b373bf50250ab8f79cb447841929c957158c62ef
1 parent 79aae3b commit 8fdf109

4 files changed

Lines changed: 136 additions & 64 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Fix intermittent subscription failures and missed records when reading newly created realtime streams.

‎apps/webapp/app/services/realtime/s2realtimeStreams.server.ts‎

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
344344

345345
/**
346346
* Non-blocking read of the records after `afterSeqNum`. Retries transient
347-
* failures (network errors, creation conflicts, 5xx, and reads S2 ends with an `error` event)
347+
* failures (network errors, stream creation races, 5xx, and reads S2 ends with an `error` event)
348348
* because the `.wait()` race check depends on this read to find a record
349349
* sent before the waitpoint registered.
350350
*/
@@ -356,9 +356,18 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
356356
try {
357357
return await this.#readRecordsOnce(s2Stream, afterSeqNum);
358358
} catch (error) {
359-
const status = (error as { status?: number }).status;
359+
const { status, streamNotFound } = error as {
360+
status?: number;
361+
streamNotFound?: boolean;
362+
};
363+
// A stream that is still absent after retries has no records yet.
364+
if (streamNotFound && attempt + 1 >= maxAttempts) return [];
360365
const isClientError =
361-
status !== undefined && status >= 400 && status < 500 && status !== 409;
366+
status !== undefined &&
367+
status >= 400 &&
368+
status < 500 &&
369+
status !== 409 &&
370+
!streamNotFound;
362371
if (isClientError || attempt + 1 >= maxAttempts) {
363372
this.logger.error("S2 readRecords failed", {
364373
stream: s2Stream,
@@ -401,13 +410,11 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
401410
);
402411

403412
if (!res.ok) {
404-
// Stream may not exist yet (no data sent)
405-
if (res.status === 404) {
406-
return [];
407-
}
413+
const streamNotFound = await isS2StreamNotFound(res);
408414
await cancelResponseBody(res);
409415
throw Object.assign(new Error(`S2 readRecords failed: ${res.status} ${res.statusText}`), {
410416
status: res.status,
417+
streamNotFound,
411418
});
412419
}
413420

@@ -808,7 +815,8 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
808815
if (opts.wait != null) qs.set("wait", String(opts.wait));
809816

810817
// Concurrent first readers/writers can race S2's automatic stream creation.
811-
// Retry only the conflict, before exposing any SSE bytes to the caller.
818+
// S2-lite can also return stream_not_found during this race. Other 404s
819+
// (missing basin, token or route) are terminal. Retry before exposing SSE bytes.
812820
const backoffsMs = [100, 200, 400];
813821
for (let attempt = 0; ; attempt++) {
814822
const res = await fetch(
@@ -826,9 +834,11 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
826834
);
827835

828836
if (!res.ok) {
837+
const streamNotFound = await isS2StreamNotFound(res);
829838
await cancelResponseBody(res);
839+
opts.signal?.throwIfAborted();
830840
const delay = backoffsMs[attempt];
831-
if (res.status === 409 && delay !== undefined) {
841+
if ((res.status === 409 || streamNotFound) && delay !== undefined) {
832842
await sleep(delay, undefined, { signal: opts.signal });
833843
continue;
834844
}
@@ -856,6 +866,21 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
856866
}
857867
}
858868

869+
async function isS2StreamNotFound(response: Response): Promise<boolean> {
870+
if (response.status !== 404) return false;
871+
try {
872+
const error: unknown = await response.json();
873+
return (
874+
typeof error === "object" &&
875+
error !== null &&
876+
"code" in error &&
877+
error.code === "stream_not_found"
878+
);
879+
} catch {
880+
return false;
881+
}
882+
}
883+
859884
async function cancelResponseBody(response: Response): Promise<void> {
860885
try {
861886
await response.body?.cancel();

‎apps/webapp/test/helpers/s2FaultProxy.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type Fault = {
1818
message: string;
1919
remaining: number;
2020
status?: number;
21+
code?: string;
2122
};
2223

2324
/**
@@ -77,8 +78,8 @@ export class S2FaultProxy {
7778
}
7879

7980
/** Reject before SSE headers, as when concurrent first reads race to create a stream. */
80-
rejectNextRead(match: string, status: number, times = 1) {
81-
this.faults.push({ match, status, remaining: times, afterBatches: 0, message: "" });
81+
rejectNextRead(match: string, status: number, times = 1, code?: string) {
82+
this.faults.push({ match, status, code, remaining: times, afterBatches: 0, message: "" });
8283
}
8384

8485
private takeFault(req: IncomingMessage): Fault | undefined {
@@ -112,7 +113,12 @@ export class S2FaultProxy {
112113

113114
const fault = this.takeFault(req);
114115
if (fault?.status) {
115-
res.writeHead(fault.status).end();
116+
if (fault.code) {
117+
res.writeHead(fault.status, { "Content-Type": "application/json" });
118+
res.end(JSON.stringify({ code: fault.code }));
119+
} else {
120+
res.writeHead(fault.status).end();
121+
}
116122
return;
117123
}
118124
const upstream = await fetch(`${target}${req.url}`, {

‎apps/webapp/test/s2StreamSubscription.e2e.test.ts‎

Lines changed: 87 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ import { createS2Container, type StartedS2Container } from "@internal/testcontai
55
import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server";
66
import { S2FaultProxy } from "./helpers/s2FaultProxy";
77

8+
const creationErrors = [
9+
{ status: 409, code: undefined },
10+
{ status: 404, code: "stream_not_found" },
11+
];
12+
13+
const terminalErrors = [
14+
{ status: 401, code: "authn" },
15+
{ status: 403, code: "permission_denied" },
16+
{ status: 404, code: "basin_not_found" },
17+
{ status: 404, code: "access_token_not_found" },
18+
{ status: 404, code: undefined },
19+
{ status: 404, code: "unknown" },
20+
];
21+
822
describe("S2 subscription handshake", () => {
923
let s2: StartedS2Container;
1024
let streams: S2RealtimeStreams;
@@ -37,50 +51,60 @@ describe("S2 subscription handshake", () => {
3751
);
3852
}
3953

40-
it("retries creation conflicts without losing the requested resume position", async () => {
41-
const session = randomUUID();
42-
await streams.appendPartToSessionStream("first", "p0", session, "out");
43-
await streams.appendPartToSessionStream("second", "p1", session, "out");
44-
proxy.rejectNextRead(session, 409, 2);
45-
46-
const response = await subscribe(session, undefined, "0");
47-
const body = await response.text();
48-
expect(response.status).toBe(200);
49-
expect(response.headers.get("X-Stream-Version")).toBe("v2");
50-
expect(body).toContain("second");
51-
expect(body).not.toContain("first");
52-
expect(proxy.injected.filter((path) => path.includes(session))).toHaveLength(2);
53-
});
54+
it.each(creationErrors)(
55+
"retries HTTP $status ($code) without losing the requested resume position",
56+
async ({ status, code }) => {
57+
const session = randomUUID();
58+
await streams.appendPartToSessionStream("first", "p0", session, "out");
59+
await streams.appendPartToSessionStream("second", "p1", session, "out");
60+
proxy.rejectNextRead(session, status, 2, code);
5461

55-
it("fails after four attempts if the conflict persists", async () => {
56-
const session = randomUUID();
57-
proxy.rejectNextRead(session, 409, 10);
58-
await expect(subscribe(session)).rejects.toThrow("S2 stream failed: 409");
59-
expect(proxy.injected.filter((path) => path.includes(session))).toHaveLength(4);
60-
});
62+
const response = await subscribe(session, undefined, "0");
63+
const body = await response.text();
64+
expect(response.status).toBe(200);
65+
expect(response.headers.get("X-Stream-Version")).toBe("v2");
66+
expect(body).toContain("second");
67+
expect(body).not.toContain("first");
68+
expect(proxy.injected.filter((path) => path.includes(session))).toHaveLength(2);
69+
}
70+
);
6171

62-
it("retries non-blocking read conflicts without losing the requested resume position", async () => {
63-
const session = randomUUID();
64-
await streams.appendPartToSessionStream("first", "p0", session, "out");
65-
await streams.appendPartToSessionStream("second", "p1", session, "out");
66-
proxy.rejectNextRead(session, 409, 2);
72+
it.each(creationErrors)(
73+
"fails after four attempts if HTTP $status ($code) persists",
74+
async ({ status, code }) => {
75+
const session = randomUUID();
76+
proxy.rejectNextRead(session, status, 10, code);
77+
await expect(subscribe(session)).rejects.toThrow(`S2 stream failed: ${status}`);
78+
expect(proxy.injected.filter((path) => path.includes(session))).toHaveLength(4);
79+
}
80+
);
6781

68-
const records = await streams.readSessionStreamRecords(session, "out", 0);
82+
it.each(creationErrors)(
83+
"retries non-blocking HTTP $status ($code) without losing the requested resume position",
84+
async ({ status, code }) => {
85+
const session = randomUUID();
86+
const channel = "updates";
87+
await streams.appendPartToSessionStream("first", "p0", session, "out", channel);
88+
await streams.appendPartToSessionStream("second", "p1", session, "out", channel);
89+
proxy.rejectNextRead(session, status, 2, code);
6990

70-
expect(records.map(({ data, id, seqNum }) => ({ data, id, seqNum }))).toEqual([
71-
{ data: "second", id: "p1", seqNum: 1 },
72-
]);
73-
expect(proxy.injected.filter((path) => path.includes(session))).toHaveLength(2);
74-
});
91+
const records = await streams.readSessionStreamRecords(session, "out", 0, channel);
92+
93+
expect(records.map(({ data, id, seqNum }) => ({ data, id, seqNum }))).toEqual([
94+
{ data: "second", id: "p1", seqNum: 1 },
95+
]);
96+
expect(proxy.injected.filter((path) => path.includes(session))).toHaveLength(2);
97+
}
98+
);
7599

76100
it.each([
77-
{ status: 409, attempts: 3 },
78-
{ status: 403, attempts: 1 },
101+
{ status: 409, attempts: 3, code: undefined },
102+
...terminalErrors.map((error) => ({ ...error, attempts: 1 })),
79103
])(
80-
"bounds non-blocking reads to $attempts attempts for HTTP $status",
81-
async ({ status, attempts }) => {
104+
"bounds non-blocking reads to $attempts attempts for HTTP $status ($code)",
105+
async ({ status, attempts, code }) => {
82106
const run = randomUUID();
83-
proxy.rejectNextRead(run, status, 10);
107+
proxy.rejectNextRead(run, status, 10, code);
84108

85109
await expect(streams.readRecords(run, "input")).rejects.toThrow(
86110
`S2 readRecords failed: ${status}`
@@ -89,6 +113,14 @@ describe("S2 subscription handshake", () => {
89113
}
90114
);
91115

116+
it("returns no records only after bounded retries for a missing stream", async () => {
117+
const session = randomUUID();
118+
proxy.rejectNextRead(session, 404, 10, "stream_not_found");
119+
120+
expect(await streams.readSessionStreamRecords(session, "in")).toEqual([]);
121+
expect(proxy.injected.filter((path) => path.includes(session))).toHaveLength(3);
122+
});
123+
92124
it("opens concurrent subscriptions to previously unwritten streams", async () => {
93125
for (let round = 0; round < 8; round++) {
94126
const session = randomUUID();
@@ -103,24 +135,27 @@ describe("S2 subscription handshake", () => {
103135
}
104136
});
105137

106-
it("does not retry other HTTP errors", async () => {
138+
it.each(terminalErrors)("does not retry HTTP $status ($code)", async ({ status, code }) => {
107139
const session = randomUUID();
108-
proxy.rejectNextRead(session, 403, 10);
109-
await expect(subscribe(session)).rejects.toThrow("S2 stream failed: 403");
140+
proxy.rejectNextRead(session, status, 10, code);
141+
await expect(subscribe(session)).rejects.toThrow(`S2 stream failed: ${status}`);
110142
expect(proxy.injected.filter((path) => path.includes(session))).toHaveLength(1);
111143
});
112144

113-
it("stops retrying when the subscriber disconnects during backoff", async () => {
114-
const session = randomUUID();
115-
const abort = new AbortController();
116-
proxy.rejectNextRead(session, 409, 10);
117-
const result = subscribe(session, abort.signal).catch((error: unknown) => error);
118-
await expect.poll(() => proxy.injected.some((path) => path.includes(session))).toBe(true);
119-
abort.abort();
120-
expect(await result).toMatchObject({ name: "AbortError" });
121-
const attempts = proxy.injected.filter((path) => path.includes(session)).length;
122-
// Prove no new request is sent after cancellation, across the first retry delay.
123-
await setTimeout(150);
124-
expect(proxy.injected.filter((path) => path.includes(session))).toHaveLength(attempts);
125-
});
145+
it.each(creationErrors)(
146+
"stops retrying HTTP $status ($code) when the subscriber disconnects during backoff",
147+
async ({ status, code }) => {
148+
const session = randomUUID();
149+
const abort = new AbortController();
150+
proxy.rejectNextRead(session, status, 10, code);
151+
const result = subscribe(session, abort.signal).catch((error: unknown) => error);
152+
await expect.poll(() => proxy.injected.some((path) => path.includes(session))).toBe(true);
153+
abort.abort();
154+
expect(await result).toMatchObject({ name: "AbortError" });
155+
const attempts = proxy.injected.filter((path) => path.includes(session)).length;
156+
// Prove no new request is sent after cancellation, across the first retry delay.
157+
await setTimeout(150);
158+
expect(proxy.injected.filter((path) => path.includes(session))).toHaveLength(attempts);
159+
}
160+
);
126161
});

0 commit comments

Comments
 (0)