Skip to content

Commit 9c13ddd

Browse files
committed
fix(chat): retain input sequence for append retries
1 parent a361659 commit 9c13ddd

5 files changed

Lines changed: 254 additions & 47 deletions

File tree

apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.s
1313
import { stripClientWebhookActionSource } from "~/services/realtime/sanitizeSessionInput.server";
1414
import {
1515
claimSessionStreamPart,
16+
commitSessionStreamPart,
1617
drainSessionStreamWaitpoints,
1718
releaseSessionStreamPart,
1819
} from "~/services/sessionStreamWaitpointCache.server";
@@ -147,22 +148,28 @@ const { action, loader } = createActionApiRoute(
147148
const partId = clientPartId ?? nanoid(7);
148149

149150
// Idempotency on client-supplied part ids: atomically claim the id before
150-
// appending. A concurrent or retried POST that loses the claim skips the
151-
// append (no duplicate record) but still falls through to the drain below,
152-
// so a retry whose first attempt died before waking the waitpoint can still
153-
// recover it. The claim is released on append failure so a genuine retry
154-
// can re-claim and proceed.
155-
const wonClaim = clientPartId
151+
// appending, then store the resulting sequence with the claim. A retry
152+
// returns that original sequence and still falls through to the drain, so
153+
// a first attempt that lost its HTTP response cannot lose turn correlation
154+
// or strand a waitpoint. The claim is released on append failure.
155+
const claim = clientPartId
156156
? await claimSessionStreamPart(
157157
authentication.environment.id,
158158
addressingKey,
159159
params.io,
160160
clientPartId
161161
)
162-
: true;
162+
: ({ status: "claimed", claimToken: undefined } as const);
163163

164-
let appendSeq: number | undefined;
165-
if (wonClaim) {
164+
if (claim.status === "pending") {
165+
return json(
166+
{ ok: false, error: "This append is still in progress, please retry." },
167+
{ status: 409, headers: { "Retry-After": "1" } }
168+
);
169+
}
170+
171+
let appendSeq = claim.status === "committed" ? claim.seq : undefined;
172+
if (claim.status === "claimed") {
166173
const [appendError, seq] = await tryCatch(
167174
realtimeStream.appendPartToSessionStream(part, partId, addressingKey, params.io)
168175
);
@@ -174,7 +181,8 @@ const { action, loader } = createActionApiRoute(
174181
authentication.environment.id,
175182
addressingKey,
176183
params.io,
177-
clientPartId
184+
clientPartId,
185+
claim.claimToken
178186
);
179187
}
180188
if (appendError instanceof ServiceValidationError) {
@@ -193,6 +201,17 @@ const { action, loader } = createActionApiRoute(
193201
{ status: 500 }
194202
);
195203
}
204+
205+
if (clientPartId && appendSeq !== undefined) {
206+
await commitSessionStreamPart(
207+
authentication.environment.id,
208+
addressingKey,
209+
params.io,
210+
clientPartId,
211+
claim.claimToken,
212+
appendSeq
213+
);
214+
}
196215
}
197216

198217
// Fire any run-scoped waitpoints registered against this channel. Best

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

Lines changed: 97 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { randomUUID } from "node:crypto";
12
import { Redis } from "ioredis";
23
import { defaultReconnectOnError } from "@internal/redis";
34
import { env } from "~/env.server";
@@ -146,15 +147,36 @@ export async function drainSessionStreamWaitpoints(
146147
* where `.wait()` completed the waitpoint from pre-arrived data.
147148
*/
148149
// "ssa" — session-stream-append. Idempotency claim for the append route:
149-
// when a caller supplies an `X-Part-Id`, the first request atomically claims
150-
// the key (SET NX) before appending; a concurrent or retried POST with the
151-
// same id fails the claim and skips the append, so it never produces a
152-
// duplicate record (or double-fires the waitpoint drain). The claim is
153-
// released if the append fails, so a retry of a genuinely failed append
154-
// still goes through. 5-minute window — covers retry storms, not a
155-
// permanent idempotency store.
150+
// when a caller supplies an `X-Part-Id`, the first request atomically stores a
151+
// tokened pending claim before appending, then replaces it with the committed
152+
// S2 sequence. A retry can therefore return the original sequence without
153+
// producing a duplicate record. The token makes commit/release compare-and-
154+
// set operations safe if a slow owner outlives the claim TTL. 5-minute window
155+
// — covers retry storms, not a permanent idempotency store.
156156
const APPEND_DEDUPE_PREFIX = "ssa:";
157157
const APPEND_DEDUPE_TTL_SECONDS = 5 * 60;
158+
const APPEND_DEDUPE_PENDING_PREFIX = "pending:";
159+
const APPEND_DEDUPE_SEQUENCE_PREFIX = "seq:";
160+
161+
const COMMIT_APPEND_DEDUPE_SCRIPT = `
162+
if redis.call("GET", KEYS[1]) ~= ARGV[1] then
163+
return 0
164+
end
165+
redis.call("SET", KEYS[1], ARGV[2], "EX", ARGV[3])
166+
return 1
167+
`;
168+
169+
const RELEASE_APPEND_DEDUPE_SCRIPT = `
170+
if redis.call("GET", KEYS[1]) ~= ARGV[1] then
171+
return 0
172+
end
173+
return redis.call("DEL", KEYS[1])
174+
`;
175+
176+
type SessionStreamPartClaim =
177+
| { status: "claimed"; claimToken: string | undefined }
178+
| { status: "committed"; seq: number }
179+
| { status: "pending" };
158180

159181
function buildAppendDedupeKey(
160182
environmentId: string,
@@ -171,40 +193,84 @@ function buildAppendDedupeKey(
171193
}
172194

173195
/**
174-
* Atomically claim a part id before appending. Returns true if this caller
175-
* won the claim (first to see this id) and should perform the append, false
176-
* if the id was already claimed (a concurrent or retried POST) and the append
177-
* should be skipped. Fails open (returns true) when Redis is unavailable —
178-
* appends degrade to at-least-once, never to dropped.
196+
* Atomically claim a part id before appending. Returns the caller's claim
197+
* token, the sequence from an already committed append, or pending when the
198+
* winning request has not committed yet. Fails open as an untracked claim
199+
* when Redis is unavailable — appends degrade to at-least-once, never to
200+
* dropped.
179201
*/
180202
export async function claimSessionStreamPart(
181203
environmentId: string,
182204
addressingKey: string,
183205
io: "out" | "in",
184206
partId: string
185-
): Promise<boolean> {
186-
if (!redis) return true;
207+
): Promise<SessionStreamPartClaim> {
208+
if (!redis) return { status: "claimed", claimToken: undefined };
187209

188210
try {
211+
const claimToken = randomUUID();
212+
const pendingValue = `${APPEND_DEDUPE_PENDING_PREFIX}${claimToken}`;
189213
// SET NX is the atomic claim: "OK" when set (we won), null when the key
190214
// already exists (someone else owns this id).
191-
const result = await redis.set(
215+
const key = buildAppendDedupeKey(environmentId, addressingKey, io, partId);
216+
const result = await redis.set(key, pendingValue, "EX", APPEND_DEDUPE_TTL_SECONDS, "NX");
217+
if (result === "OK") {
218+
return { status: "claimed", claimToken };
219+
}
220+
221+
const existing = await redis.get(key);
222+
if (existing?.startsWith(APPEND_DEDUPE_SEQUENCE_PREFIX)) {
223+
const seq = Number(existing.slice(APPEND_DEDUPE_SEQUENCE_PREFIX.length));
224+
if (Number.isSafeInteger(seq) && seq >= 0) {
225+
return { status: "committed", seq };
226+
}
227+
}
228+
229+
// Includes claims from an older deploy (literal "1"). They suppress a
230+
// duplicate append but cannot safely recover its sequence, so callers
231+
// must retry instead of returning a successful response without a cursor.
232+
return { status: "pending" };
233+
} catch (error) {
234+
logger.error("Failed to claim session stream append part", {
235+
environmentId,
236+
addressingKey,
237+
io,
238+
partId,
239+
error,
240+
});
241+
return { status: "claimed", claimToken: undefined };
242+
}
243+
}
244+
245+
/** Replace this caller's pending claim with the committed S2 sequence. */
246+
export async function commitSessionStreamPart(
247+
environmentId: string,
248+
addressingKey: string,
249+
io: "out" | "in",
250+
partId: string,
251+
claimToken: string | undefined,
252+
seq: number
253+
): Promise<void> {
254+
if (!redis || !claimToken) return;
255+
256+
try {
257+
await redis.eval(
258+
COMMIT_APPEND_DEDUPE_SCRIPT,
259+
1,
192260
buildAppendDedupeKey(environmentId, addressingKey, io, partId),
193-
"1",
194-
"EX",
195-
APPEND_DEDUPE_TTL_SECONDS,
196-
"NX"
261+
`${APPEND_DEDUPE_PENDING_PREFIX}${claimToken}`,
262+
`${APPEND_DEDUPE_SEQUENCE_PREFIX}${seq}`,
263+
String(APPEND_DEDUPE_TTL_SECONDS)
197264
);
198-
return result === "OK";
199265
} catch (error) {
200-
logger.error("Failed to claim session stream append part", {
266+
logger.error("Failed to commit session stream append part", {
201267
environmentId,
202268
addressingKey,
203269
io,
204270
partId,
271+
seq,
205272
error,
206273
});
207-
return true;
208274
}
209275
}
210276

@@ -213,12 +279,18 @@ export async function releaseSessionStreamPart(
213279
environmentId: string,
214280
addressingKey: string,
215281
io: "out" | "in",
216-
partId: string
282+
partId: string,
283+
claimToken: string | undefined
217284
): Promise<void> {
218-
if (!redis) return;
285+
if (!redis || !claimToken) return;
219286

220287
try {
221-
await redis.del(buildAppendDedupeKey(environmentId, addressingKey, io, partId));
288+
await redis.eval(
289+
RELEASE_APPEND_DEDUPE_SCRIPT,
290+
1,
291+
buildAppendDedupeKey(environmentId, addressingKey, io, partId),
292+
`${APPEND_DEDUPE_PENDING_PREFIX}${claimToken}`
293+
);
222294
} catch (error) {
223295
logger.error("Failed to release session stream append part", {
224296
environmentId,

apps/webapp/app/v3/webhookEngine.server.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { findOrCreateSession, findSessionByExternalId } from "~/services/realtim
1515
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
1616
import {
1717
claimSessionStreamPart,
18+
commitSessionStreamPart,
1819
drainSessionStreamWaitpoints,
1920
releaseSessionStreamPart,
2021
} from "~/services/sessionStreamWaitpointCache.server";
@@ -221,27 +222,38 @@ function createWebhookEngine() {
221222
};
222223
const part = JSON.stringify({ kind: "message", payload });
223224

224-
// deliveryId as the part id → a deliver-job retry re-claims the same id and skips a duplicate
225-
// append. The S2 record is durable, so a run that boots later still reads it.
226-
const wonClaim = await claimSessionStreamPart(
227-
environment.id,
228-
addressingKey,
229-
"in",
230-
deliveryId
231-
);
232-
if (wonClaim) {
233-
const [appendError] = await tryCatch(
225+
// deliveryId as the part id → a deliver-job retry reuses the committed claim and skips a
226+
// duplicate append. The S2 record is durable, so a run that boots later still reads it.
227+
const claim = await claimSessionStreamPart(environment.id, addressingKey, "in", deliveryId);
228+
if (claim.status === "claimed") {
229+
const [appendError, appendSeq] = await tryCatch(
234230
realtimeStream.appendPartToSessionStream(part, deliveryId, addressingKey, "in")
235231
);
236232
if (appendError) {
237233
// Nothing landed — release the claim so a retry re-appends the same id.
238-
await releaseSessionStreamPart(environment.id, addressingKey, "in", deliveryId);
234+
await releaseSessionStreamPart(
235+
environment.id,
236+
addressingKey,
237+
"in",
238+
deliveryId,
239+
claim.claimToken
240+
);
239241
// A ServiceValidationError (e.g. record too large) is terminal; anything else is transient.
240242
if (appendError instanceof ServiceValidationError) {
241243
return { success: false, error: appendError.message };
242244
}
243245
throw appendError;
244246
}
247+
if (typeof appendSeq === "number") {
248+
await commitSessionStreamPart(
249+
environment.id,
250+
addressingKey,
251+
"in",
252+
deliveryId,
253+
claim.claimToken,
254+
appendSeq
255+
);
256+
}
245257
}
246258

247259
// Wake any `.in` waitpoints the run registered (best-effort; the record is durable in S2).

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

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,38 @@ describe("session stream e2e", () => {
311311
expect(String(got?.chunk)).toContain("hello from client");
312312
});
313313

314-
it("E12 subscribe with an invalid token is rejected", async () => {
314+
it("E12 in/append retry returns the original seq without duplicating the record", async () => {
315+
const { addressingKey, token, baseUrl } = await setupSession();
316+
317+
const payload = JSON.stringify({ kind: "message", text: "lost response" });
318+
const partId = `retry-${randomBytes(6).toString("hex")}`;
319+
320+
// The first append commits. Model its response being lost by issuing the
321+
// same idempotent request again; keep the first seq only as a test oracle.
322+
const first = await appendInput({ baseUrl, addressingKey, token, partId, body: payload });
323+
expect(first.status).toBe(200);
324+
expect(first.json).toEqual({ ok: true, seq: expect.any(Number) });
325+
const firstSeq = (first.json as { seq: number }).seq;
326+
327+
const retry = await appendInput({ baseUrl, addressingKey, token, partId, body: payload });
328+
expect(retry.status).toBe(200);
329+
expect(retry.json).toEqual({ ok: true, seq: firstSeq });
330+
const retrySeq = (retry.json as { seq: number }).seq;
331+
332+
const { parts } = await collectSessionOut({
333+
baseUrl,
334+
addressingKey,
335+
token,
336+
io: "in",
337+
timeoutInSeconds: 1,
338+
maxMs: 5_000,
339+
});
340+
const dataParts = parts.filter((part) => part.chunk != null);
341+
expect(dataParts).toHaveLength(1);
342+
expect(Number(dataParts[0]!.id)).toBe(retrySeq);
343+
});
344+
345+
it("E13 subscribe with an invalid token is rejected", async () => {
315346
const { addressingKey, baseUrl } = await setupSession();
316347

317348
const { status } = await openChannelRaw({

0 commit comments

Comments
 (0)