@@ -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
104107const PART_RECOVERY_CLOCK_SKEW_MS = 10_000 ;
105108const PART_RECOVERY_PAGE_SIZE = 1_000 ;
106- const PART_RECOVERY_MAX_PAGES = 32 ;
107109
108110export 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 [ ] > {
0 commit comments