Skip to content

Commit 8aa8866

Browse files
committed
fix(chat): harden append sequence recovery
1 parent 16c6230 commit 8aa8866

3 files changed

Lines changed: 136 additions & 38 deletions

File tree

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

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,12 @@ type S2ReadRecord = {
100100
timestamp: number;
101101
headers?: Array<[string, string]>;
102102
};
103+
type S2TailResponse = {
104+
tail: { seq_num: number; timestamp: number };
105+
};
103106

104107
const PART_RECOVERY_CLOCK_SKEW_MS = 10_000;
105108
const PART_RECOVERY_PAGE_SIZE = 1_000;
106-
const PART_RECOVERY_MAX_PAGES = 32;
107109

108110
export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
109111
private readonly basin: string;
@@ -276,8 +278,8 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
276278

277279
/**
278280
* Find an append whose Redis claim was left pending after S2 accepted it.
279-
* Reads forward from the claim timestamp and stops at the first response's
280-
* tail snapshot, so an active stream cannot make recovery chase new data.
281+
* Snapshots the stream tail, then reads forward from the claim timestamp.
282+
* The fixed tail keeps an active stream from extending the recovery search.
281283
*/
282284
async findSessionStreamPartSequence(
283285
friendlyId: string,
@@ -286,10 +288,33 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
286288
claimedAt?: number
287289
): Promise<number | undefined> {
288290
const s2Stream = this.toSessionStreamName(friendlyId, io);
291+
const tailResponse = await fetch(
292+
`${this.baseUrl}/streams/${encodeURIComponent(s2Stream)}/records/tail`,
293+
{
294+
method: "GET",
295+
headers: {
296+
Authorization: `Bearer ${this.token}`,
297+
Accept: "application/json",
298+
"S2-Basin": this.basin,
299+
},
300+
}
301+
);
302+
303+
if (!tailResponse.ok) {
304+
if (tailResponse.status === 404) return undefined;
305+
const text = await tailResponse.text().catch(() => "");
306+
throw new Error(
307+
`S2 findSessionStreamPartSequence tail check failed: ${tailResponse.status} ${tailResponse.statusText} ${text}`
308+
);
309+
}
310+
311+
const { tail } = (await tailResponse.json()) as S2TailResponse;
312+
const snapshotTail = tail.seq_num;
313+
if (snapshotTail === 0) return undefined;
314+
289315
let nextSeq: number | undefined;
290-
let snapshotTail: number | undefined;
291316

292-
for (let page = 0; page < PART_RECOVERY_MAX_PAGES; page++) {
317+
while (true) {
293318
const qs = new URLSearchParams();
294319
if (nextSeq !== undefined) {
295320
qs.set("seq_num", String(nextSeq));
@@ -326,13 +351,11 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
326351

327352
const json = (await res.json()) as {
328353
records?: S2ReadRecord[];
329-
tail?: { seq_num: number };
330354
};
331355
const records = json.records ?? [];
332-
snapshotTail ??= json.tail?.seq_num;
333356

334357
for (const record of records) {
335-
if (snapshotTail !== undefined && record.seq_num >= snapshotTail) break;
358+
if (record.seq_num >= snapshotTail) break;
336359
if (record.headers?.[0]?.[0] === "") continue;
337360
try {
338361
const envelope = JSON.parse(record.body) as { id?: unknown };
@@ -345,12 +368,8 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
345368
const lastRecord = records.at(-1);
346369
if (!lastRecord) return undefined;
347370
nextSeq = lastRecord.seq_num + 1;
348-
if (snapshotTail !== undefined && nextSeq >= snapshotTail) return undefined;
371+
if (nextSeq >= snapshotTail) return undefined;
349372
}
350-
351-
// An incomplete search must never be treated as proof that the append is
352-
// absent; the caller will keep the claim pending and retry recovery.
353-
throw new Error(`S2 part recovery exceeded ${PART_RECOVERY_MAX_PAGES} pages`);
354373
}
355374

356375
async #readRecordsByName(s2Stream: string, afterSeqNum?: number): Promise<StreamRecord[]> {

apps/webapp/app/services/sessionStreamWaitpointCache.server.ts

Lines changed: 38 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -212,36 +212,50 @@ export async function claimSessionStreamPart(
212212
if (!redis) return { status: "claimed", claimValue: undefined };
213213

214214
try {
215-
const claimedAt = Date.now();
216-
const pendingValue = `${APPEND_DEDUPE_PENDING_PREFIX}${claimedAt}:${randomUUID()}`;
217-
// SET NX is the atomic claim: "OK" when set (we won), null when the key
218-
// already exists (someone else owns this id).
219215
const key = buildAppendDedupeKey(environmentId, addressingKey, io, partId);
220-
const result = await redis.set(key, pendingValue, "EX", APPEND_DEDUPE_TTL_SECONDS, "NX");
221-
if (result === "OK") {
222-
return { status: "claimed", claimValue: pendingValue };
223-
}
216+
for (let attempt = 0; attempt < 3; attempt++) {
217+
const claimedAt = Date.now();
218+
const pendingValue = `${APPEND_DEDUPE_PENDING_PREFIX}${claimedAt}:${randomUUID()}`;
219+
// SET NX is the atomic claim: "OK" when set (we won), null when the key
220+
// already exists (someone else owns this id).
221+
const result = await redis.set(key, pendingValue, "EX", APPEND_DEDUPE_TTL_SECONDS, "NX");
222+
if (result === "OK") {
223+
return { status: "claimed", claimValue: pendingValue };
224+
}
224225

225-
const existing = await redis.get(key);
226-
if (existing?.startsWith(APPEND_DEDUPE_SEQUENCE_PREFIX)) {
227-
const seq = Number(existing.slice(APPEND_DEDUPE_SEQUENCE_PREFIX.length));
228-
if (Number.isSafeInteger(seq) && seq >= 0) {
229-
return { status: "committed", seq };
226+
const existing = await redis.get(key);
227+
if (existing?.startsWith(APPEND_DEDUPE_SEQUENCE_PREFIX)) {
228+
const seq = Number(existing.slice(APPEND_DEDUPE_SEQUENCE_PREFIX.length));
229+
if (Number.isSafeInteger(seq) && seq >= 0) {
230+
return { status: "committed", seq };
231+
}
230232
}
231-
}
232233

233-
if (existing) {
234-
const pendingClaimedAt = parsePendingClaimedAt(existing);
235-
return {
236-
status: "pending",
237-
claimValue: existing,
238-
claimedAt: pendingClaimedAt,
239-
};
234+
if (existing) {
235+
let pendingClaimedAt = parsePendingClaimedAt(existing);
236+
if (pendingClaimedAt === undefined) {
237+
// Claims from the previous release stored only "1". Estimate their
238+
// creation time from the remaining TTL so recovery starts near the
239+
// original append during a rolling deploy.
240+
const ttlMs = await redis.pttl(key);
241+
if (ttlMs >= 0) {
242+
pendingClaimedAt = Date.now() - Math.max(0, APPEND_DEDUPE_TTL_SECONDS * 1000 - ttlMs);
243+
}
244+
}
245+
return {
246+
status: "pending",
247+
claimValue: existing,
248+
claimedAt: pendingClaimedAt,
249+
};
250+
}
251+
252+
// The key expired or was evicted between SET NX and GET. Retry the
253+
// reservation a small number of times without growing the call stack.
240254
}
241255

242-
// The key expired between SET NX and GET. Let this request retry the
243-
// claim instead of treating a vanished owner as pending.
244-
return claimSessionStreamPart(environmentId, addressingKey, io, partId);
256+
// Redis kept losing the reservation. Fail open like other cache errors so
257+
// this append degrades to at-least-once instead of failing the request.
258+
return { status: "claimed", claimValue: undefined };
245259
} catch (error) {
246260
logger.error("Failed to claim session stream append part", {
247261
environmentId,

apps/webapp/test/session-stream.e2e.test.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,72 @@ describe("session stream e2e", () => {
413413
}
414414
});
415415

416-
it("E14 subscribe with an invalid token is rejected", async () => {
416+
it("E14 in/append recovers a sequence from a legacy claim", async () => {
417+
const { addressingKey, environmentId, token, inProducer, baseUrl } = await setupSession();
418+
const redis = new Redis({ ...server.redis, keyPrefix: "tr:" });
419+
420+
try {
421+
const payload = JSON.stringify({ kind: "message", text: "rolling deploy retry" });
422+
const partId = `legacy-${randomBytes(6).toString("hex")}`;
423+
const claimKey = `ssa:${encodeURIComponent(environmentId)}:${encodeURIComponent(
424+
addressingKey
425+
)}:in:${encodeURIComponent(partId)}`;
426+
427+
// The previous release stored literal "1" for both pending and accepted
428+
// appends. Its remaining TTL supplies the recovery window after deploy.
429+
await redis.set(claimKey, "1", "EX", 5 * 60);
430+
const originalSeq = await inProducer.appendData(payload, partId);
431+
432+
const retry = await appendInput({ baseUrl, addressingKey, token, partId, body: payload });
433+
expect(retry.status).toBe(200);
434+
expect(retry.json).toEqual({ ok: true, seq: originalSeq });
435+
expect(await redis.get(claimKey)).toBe(`seq:${originalSeq}`);
436+
437+
const { parts } = await collectSessionOut({
438+
baseUrl,
439+
addressingKey,
440+
token,
441+
io: "in",
442+
timeoutInSeconds: 1,
443+
maxMs: 5_000,
444+
});
445+
expect(parts.filter((part) => part.chunk != null)).toHaveLength(1);
446+
} finally {
447+
await redis.quit();
448+
}
449+
});
450+
451+
it("E15 in/append recovery searches beyond 32 full read pages", async () => {
452+
const { addressingKey, environmentId, token, inProducer, baseUrl } = await setupSession();
453+
const redis = new Redis({ ...server.redis, keyPrefix: "tr:" });
454+
455+
try {
456+
const payload = JSON.stringify({ kind: "message", text: "after large records" });
457+
const partId = `deep-${randomBytes(6).toString("hex")}`;
458+
const claimKey = `ssa:${encodeURIComponent(environmentId)}:${encodeURIComponent(
459+
addressingKey
460+
)}:in:${encodeURIComponent(partId)}`;
461+
462+
await redis.set(claimKey, `pending:${Date.now()}:crashed-owner`, "EX", 5 * 60);
463+
464+
// Each record is over half the 1 MiB read budget, forcing one record per
465+
// page. The target therefore sits on page 33 of the recovery scan.
466+
const largeDecoy = "x".repeat(530_000);
467+
for (let index = 0; index < 32; index++) {
468+
await inProducer.appendData(largeDecoy, `decoy-${index}`);
469+
}
470+
const originalSeq = await inProducer.appendData(payload, partId);
471+
472+
const retry = await appendInput({ baseUrl, addressingKey, token, partId, body: payload });
473+
expect(retry.status).toBe(200);
474+
expect(retry.json).toEqual({ ok: true, seq: originalSeq });
475+
expect(await redis.get(claimKey)).toBe(`seq:${originalSeq}`);
476+
} finally {
477+
await redis.quit();
478+
}
479+
});
480+
481+
it("E16 subscribe with an invalid token is rejected", async () => {
417482
const { addressingKey, baseUrl } = await setupSession();
418483

419484
const { status } = await openChannelRaw({

0 commit comments

Comments
 (0)