1+ import { randomUUID } from "node:crypto" ;
12import { Redis } from "ioredis" ;
23import { defaultReconnectOnError } from "@internal/redis" ;
34import { 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.
156156const APPEND_DEDUPE_PREFIX = "ssa:" ;
157157const 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
159181function 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 */
180202export 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,
0 commit comments