@@ -19,6 +19,16 @@ const actionQueryFields = ['receiver', 'act', 'account'];
1919
2020const MAX_SCROLL_TIME_SEC = 120 ;
2121
22+ // --- Streaming history-replay hardening ---------------------------------------------------
23+ // A stream subscription with a past `start_from` scrolls `<chain>-<type>-*` (every index,
24+ // including the oldest cold-tier shards). Left unbounded it can walk the entire history and,
25+ // multiplied by reconnect storms, pin the cold tier. These guards bound a single replay's size
26+ // and the number of concurrent replays per API process.
27+ const DEFAULT_STREAM_SCROLL_LIMIT = 50_000 ; // per-request doc cap when stream_scroll_limit is unset
28+ const DEFAULT_MAX_CONCURRENT_REPLAYS = 4 ; // concurrent history replays per API process
29+ const UNBOUNDED_REPLAY_WARN_THRESHOLD = 100_000 ; // warn when an explicit -1 (unlimited) replay exceeds this
30+ let activeHistoryReplays = 0 ; // in-flight replay counter (per process)
31+
2232export function getTotalValue ( searchResponse : estypes . SearchResponse ) : number {
2333 if ( searchResponse . hits . total ) {
2434 if ( typeof searchResponse . hits . total === 'number' ) {
@@ -155,30 +165,16 @@ export async function streamPastCommon<T extends keyof StreamTypeMap>(
155165 } ) ;
156166 }
157167
158- const responseQueue : estypes . SearchResponse < any , any > [ ] = [ ] ;
159-
160- let counter = 0 ;
161- let total = 0 ;
162- let totalFiltered = 0 ;
163- let longScroll = false ;
164-
165- const esQuery = {
166- index : fastify . manager . chain + `-${ dataKind } -*` ,
167- scroll : `${ MAX_SCROLL_TIME_SEC } s` ,
168- size : fastify . manager . config . api . stream_scroll_batch || 500 ,
169- ...search_body
170- } ;
171-
172- // console.dir(esQuery, {depth: Infinity, colors: true});
173- // console.dir(onDemandFilters);
174-
175- const init_response : estypes . SearchResponse < any , any > = await fastify . elastic . search ( esQuery ) ;
176-
177- const totalHits = getTotalValue ( init_response ) ;
178-
179- const scrollLimit = fastify . manager . config . api . stream_scroll_limit ;
180- if ( scrollLimit && scrollLimit !== - 1 && totalHits > scrollLimit ) {
181- const errorMsg = `Requested ${ totalHits } ${ dataKind } s, limit is ${ scrollLimit } .` ;
168+ // Bound concurrent replays (per API process). A reconnect storm or many simultaneous
169+ // deep-`start_from` subscriptions would otherwise spawn parallel full-history scrolls that
170+ // all pound the oldest (cold-tier) shards at once. Apply backpressure instead.
171+ let maxConcurrent = fastify . manager . config . api . stream_max_concurrent_replays ?? DEFAULT_MAX_CONCURRENT_REPLAYS ;
172+ if ( ! Number . isFinite ( maxConcurrent ) || maxConcurrent < 1 ) {
173+ maxConcurrent = DEFAULT_MAX_CONCURRENT_REPLAYS ;
174+ }
175+ if ( activeHistoryReplays >= maxConcurrent ) {
176+ const errorMsg = `Server busy: ${ activeHistoryReplays } history replays already running (max ${ maxConcurrent } ). Please retry shortly.` ;
177+ hLog ( `[${ requestUUID } ] Rejected ${ dataKind } replay — ${ errorMsg } ` ) ;
182178 socket . emit ( 'message' , {
183179 reqUUID : requestUUID ,
184180 type : `${ dataKind } _trace` ,
@@ -188,133 +184,207 @@ export async function streamPastCommon<T extends keyof StreamTypeMap>(
188184 } ) ;
189185 return { status : false , error : errorMsg } ;
190186 }
187+ activeHistoryReplays ++ ;
191188
192- if ( totalHits > 10000 ) {
193- total = totalHits ;
194- longScroll = true ;
195- hLog ( `Attention! Long scroll (${ dataKind } s) is running!` ) ;
196- }
189+ // Tracked outside the try so the finally can always release the scroll context.
190+ let currentScrollId : estypes . ScrollId | undefined ;
191+ try {
192+ const responseQueue : estypes . SearchResponse < any , any > [ ] = [ ] ;
193+
194+ let counter = 0 ;
195+ let total = 0 ;
196+ let totalFiltered = 0 ;
197+ let longScroll = false ;
198+
199+ // Cap a single replay so it can't scroll the entire history. When unset, fall back to a
200+ // finite default; an explicit -1 opts into unbounded replay but is logged loudly so it
201+ // can't silently melt the cold tier.
202+ let scrollLimit = fastify . manager . config . api . stream_scroll_limit ;
203+ if ( scrollLimit === undefined || scrollLimit === null ) {
204+ scrollLimit = DEFAULT_STREAM_SCROLL_LIMIT ;
205+ }
206+ // ES only tracks total hits up to 10k by default — which would let a 60k-doc replay slip a
207+ // 50k cap and silently truncate at 10k. Track up to (cap + 1) so "over the limit" is
208+ // detectable; for unlimited (-1), track to just past the warn threshold.
209+ const trackTotalHits = scrollLimit === - 1
210+ ? UNBOUNDED_REPLAY_WARN_THRESHOLD + 1
211+ : scrollLimit + 1 ;
212+
213+ const esQuery = {
214+ index : fastify . manager . chain + `-${ dataKind } -*` ,
215+ scroll : `${ MAX_SCROLL_TIME_SEC } s` ,
216+ size : fastify . manager . config . api . stream_scroll_batch || 500 ,
217+ ...search_body ,
218+ track_total_hits : trackTotalHits
219+ } ;
197220
198- // emit the first block
199- if ( init_response . hits . hits . length > 0 ) {
200- emitTraceInit ( socket , requestUUID , init_response . hits . hits [ 0 ] . _source . block_num , totalHits ) ;
201- }
221+ const init_response : estypes . SearchResponse < any , any > = await fastify . elastic . search ( esQuery ) ;
222+ currentScrollId = init_response . _scroll_id ;
202223
203- responseQueue . push ( init_response ) ;
224+ const totalHits = getTotalValue ( init_response ) ;
204225
205- let lastTransmittedBlock = 0 ;
206- let pendingScrollId : estypes . ScrollId | undefined = '' ;
226+ // Reject an over-cap replay up front instead of scrolling the whole cold tier.
227+ if ( scrollLimit !== - 1 && totalHits > scrollLimit ) {
228+ const errorMsg = `Requested at least ${ totalHits } ${ dataKind } s, limit is ${ scrollLimit } . Narrow the range with start_from/read_until.` ;
229+ socket . emit ( 'message' , {
230+ reqUUID : requestUUID ,
231+ type : `${ dataKind } _trace` ,
232+ mode : 'history' ,
233+ messages : [ ] ,
234+ error : errorMsg
235+ } ) ;
236+ return { status : false , error : errorMsg } ;
237+ }
238+ if ( scrollLimit === - 1 && totalHits > UNBOUNDED_REPLAY_WARN_THRESHOLD ) {
239+ hLog ( `[WARN][${ requestUUID } ] Unbounded ${ dataKind } replay of ${ totalHits } + docs (api.stream_scroll_limit=-1) — set a finite limit to protect old/cold indices.` ) ;
240+ }
241+
242+ if ( totalHits > 10000 ) {
243+ total = totalHits ;
244+ longScroll = true ;
245+ hLog ( `Attention! Long scroll (${ dataKind } s) is running!` ) ;
246+ }
247+
248+ // emit the first block
249+ if ( init_response . hits . hits . length > 0 ) {
250+ emitTraceInit ( socket , requestUUID , init_response . hits . hits [ 0 ] . _source . block_num , totalHits ) ;
251+ }
207252
208- while ( responseQueue . length ) {
209- let filterCount = 0 ;
210- const rp = responseQueue . shift ( ) ;
253+ responseQueue . push ( init_response ) ;
211254
212- if ( rp ) {
255+ let lastTransmittedBlock = 0 ;
213256
214- pendingScrollId = rp . _scroll_id ;
215- const enqueuedMessages : any [ ] = [ ] ;
216- counter += rp . hits . hits . length ;
257+ while ( responseQueue . length ) {
258+ let filterCount = 0 ;
259+ const rp = responseQueue . shift ( ) ;
217260
218- for ( const doc of rp . hits . hits ) {
219- let allow = false ;
261+ if ( rp ) {
220262
221- if ( dataKind === 'action' ) {
222- mergeActionMeta ( doc . _source ) ;
223- } else if ( dataKind === 'delta' ) {
224- mergeDeltaMeta ( doc . _source ) ;
263+ // Empty page = scroll exhausted. This is the authoritative terminator: relying only
264+ // on `counter === total` can loop forever if the scroll ends early (docs deleted/
265+ // merged mid-scroll, or a capped total that counter never exactly reaches) — an
266+ // infinite empty-scroll loop would peg CPU and spam ES.
267+ if ( ! rp . hits ?. hits || rp . hits . hits . length === 0 ) {
268+ hLog ( `${ counter } past ${ dataKind } s streamed to ${ socket . id } (${ totalFiltered } filtered)` ) ;
269+ break ;
225270 }
226271
227- // const tRef = process.hrtime.bigint();
228- if ( onDemandFilters . length > 0 ) {
229- if ( data . filter_op === 'or' ) {
230- allow = onDemandFilters . some ( filter => {
231- return checkMetaFilter ( filter , doc . _source , dataKind ) ;
232- } ) ;
272+ currentScrollId = rp . _scroll_id ;
273+ const enqueuedMessages : any [ ] = [ ] ;
274+ counter += rp . hits . hits . length ;
275+
276+ for ( const doc of rp . hits . hits ) {
277+ let allow = false ;
278+
279+ if ( dataKind === 'action' ) {
280+ mergeActionMeta ( doc . _source ) ;
281+ } else if ( dataKind === 'delta' ) {
282+ mergeDeltaMeta ( doc . _source ) ;
283+ }
284+
285+ if ( onDemandFilters . length > 0 ) {
286+ if ( data . filter_op === 'or' ) {
287+ allow = onDemandFilters . some ( filter => {
288+ return checkMetaFilter ( filter , doc . _source , dataKind ) ;
289+ } ) ;
290+ } else {
291+ allow = onDemandFilters . every ( filter => {
292+ return checkMetaFilter ( filter , doc . _source , dataKind ) ;
293+ } ) ;
294+ }
233295 } else {
234- allow = onDemandFilters . every ( filter => {
235- // console.log(doc._source);
236- return checkMetaFilter ( filter , doc . _source , dataKind ) ;
237- } ) ;
296+ allow = true ;
238297 }
239- } else {
240- allow = true ;
241- }
242- // console.log('Filter time: ', Number(process.hrtime.bigint() - tRef) / 10e6, 'ms');
243298
244- if ( allow ) {
245- enqueuedMessages . push ( doc . _source ) ;
246- } else {
247- filterCount ++ ;
248- }
299+ if ( allow ) {
300+ enqueuedMessages . push ( doc . _source ) ;
301+ } else {
302+ filterCount ++ ;
303+ }
249304
250- // set the last block
251- if ( doc . _source . block_num > lastTransmittedBlock ) {
252- lastTransmittedBlock = doc . _source . block_num ;
305+ // set the last block
306+ if ( doc . _source . block_num > lastTransmittedBlock ) {
307+ lastTransmittedBlock = doc . _source . block_num ;
308+ }
253309 }
254- }
255310
256- totalFiltered += filterCount ;
257-
258- if ( socket . connected ) {
259- if ( enqueuedMessages . length > 0 ) {
260- try {
261-
262- // Wait for 120 s
263- const ackResponse = await socket
264- . timeout ( MAX_SCROLL_TIME_SEC * 1000 )
265- . emitWithAck ( 'message' , {
266- reqUUID : requestUUID ,
267- type : `${ dataKind } _trace` ,
268- mode : 'history' ,
269- messages : enqueuedMessages ,
270- filtered : filterCount
271- } ) ;
272-
273- if ( ackResponse . status !== true ) {
274- hLog ( `${ dataKind } _trace scroll TIMEOUT` ) ;
275- return { status : ackResponse . status , error : ackResponse . error } ;
311+ totalFiltered += filterCount ;
312+
313+ if ( socket . connected ) {
314+ if ( enqueuedMessages . length > 0 ) {
315+ try {
316+
317+ // Wait for 120 s
318+ const ackResponse = await socket
319+ . timeout ( MAX_SCROLL_TIME_SEC * 1000 )
320+ . emitWithAck ( 'message' , {
321+ reqUUID : requestUUID ,
322+ type : `${ dataKind } _trace` ,
323+ mode : 'history' ,
324+ messages : enqueuedMessages ,
325+ filtered : filterCount
326+ } ) ;
327+
328+ if ( ackResponse . status !== true ) {
329+ hLog ( `${ dataKind } _trace scroll TIMEOUT` ) ;
330+ return { status : ackResponse . status , error : ackResponse . error } ;
331+ }
332+ } catch ( e : any ) {
333+ hLog ( `${ dataKind } _trace scroll NACK` , e ) ;
334+ return { status : false , error : e } ;
276335 }
277- } catch ( e : any ) {
278- hLog ( `${ dataKind } _trace scroll NACK` , e ) ;
279- return { status : false , error : e } ;
280336 }
337+ } else {
338+ // Client gone — return a failure status so the caller stops immediately and
339+ // does NOT launch its follow-up "fill" replays (which would scroll the cold
340+ // tier for a socket that no longer exists). finally still clears the scroll.
341+ hLog ( `LOST CLIENT During ${ dataKind . toUpperCase ( ) } history replay!` ) ;
342+ return { status : false , error : 'client disconnected' , lastTransmittedBlock, counter} ;
281343 }
282- } else {
283- hLog ( `LOST CLIENT During ${ dataKind . toUpperCase ( ) } history replay!` ) ;
284- break ;
285- }
286344
287- if ( longScroll ) {
288- hLog ( `[${ requestUUID } ] Progress: ${ counter + totalFiltered } /${ total } ` ) ;
289- }
345+ if ( longScroll ) {
346+ hLog ( `[${ requestUUID } ] Progress: ${ counter + totalFiltered } /${ total } ` ) ;
347+ }
290348
291- if ( getTotalValue ( rp ) === counter ) {
292- hLog ( `${ counter } past ${ dataKind } s streamed to ${ socket . id } (${ totalFiltered } filtered)` ) ;
293- break ;
349+ if ( getTotalValue ( rp ) === counter ) {
350+ hLog ( `${ counter } past ${ dataKind } s streamed to ${ socket . id } (${ totalFiltered } filtered)` ) ;
351+ break ;
352+ }
353+
354+ const next_response = await fastify . elastic . scroll ( {
355+ scroll_id : rp . _scroll_id ?? "" ,
356+ scroll : `${ MAX_SCROLL_TIME_SEC } s`
357+ } ) ;
358+ currentScrollId = next_response . _scroll_id ;
359+ responseQueue . push ( next_response ) ;
294360 }
295361
296- const next_response = await fastify . elastic . scroll ( {
297- scroll_id : rp . _scroll_id ?? "" ,
298- scroll : `${ MAX_SCROLL_TIME_SEC } s`
299- } ) ;
300- responseQueue . push ( next_response ) ;
362+ // TODO: Apply dynamic delay for request throttling
363+ await new Promise ( resolve => setTimeout ( resolve , 200 ) ) ;
301364 }
302365
303- // TODO: Apply dynamic delay for request throttling
304- await new Promise ( resolve => setTimeout ( resolve , 200 ) ) ;
305- }
366+ if ( counter === 0 ) {
367+ // No data found yet, make sure the last transmitted block is reset
368+ lastTransmittedBlock = Number ( data . start_from ) - 1 ;
369+ if ( head && lastTransmittedBlock < 0 ) {
370+ lastTransmittedBlock = head + lastTransmittedBlock ;
371+ }
372+ }
306373
307- if ( counter === 0 ) {
308- // No data found yet, make sure the last transmitted block is reset
309- lastTransmittedBlock = Number ( data . start_from ) - 1 ;
310- if ( head && lastTransmittedBlock < 0 ) {
311- lastTransmittedBlock = head + lastTransmittedBlock ;
374+ return { status : true , lastTransmittedBlock, counter} ;
375+ } finally {
376+ activeHistoryReplays -- ;
377+ // Always release the scroll context — on success, early return, timeout/NACK, or
378+ // disconnect — so old/cold indices aren't pinned by orphaned scrolls (which hold file
379+ // handles and block segment merges until the keepalive expires).
380+ if ( currentScrollId ) {
381+ try {
382+ await fastify . elastic . clearScroll ( { scroll_id : currentScrollId } ) ;
383+ } catch ( e : any ) {
384+ hLog ( `[${ requestUUID } ] Failed to clear scroll context: ${ e ?. message ?? e } ` ) ;
385+ }
312386 }
313387 }
314-
315- // destroy scroll context
316- await fastify . elastic . clearScroll ( { scroll_id : pendingScrollId } ) ;
317- return { status : true , lastTransmittedBlock, counter} ;
318388}
319389
320390
0 commit comments