Skip to content

Commit 13651ee

Browse files
authored
Merge pull request #178 from eosrio/harden/stream-replay
fix(api): harden stream history replay against unbounded cold-tier walks
2 parents 435d9cd + a620e73 commit 13651ee

3 files changed

Lines changed: 197 additions & 124 deletions

File tree

references/config.ref.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
"server_addr": "127.0.0.1",
77
"server_port": 7000,
88
"stream_port": 1234,
9-
"stream_scroll_limit": -1,
9+
"stream_scroll_limit": 50000,
1010
"stream_scroll_batch": 500,
11+
"stream_max_concurrent_replays": 4,
1112
"server_name": "127.0.0.1:7000",
1213
"provider_name": "Example Provider",
1314
"provider_url": "https://example.com",

src/api/helpers/functions.ts

Lines changed: 192 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ const actionQueryFields = ['receiver', 'act', 'account'];
1919

2020
const 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+
2232
export 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

src/interfaces/hyperionConfig.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,8 @@ interface ApiConfigs {
134134
enabled?: boolean;
135135
log_errors?: boolean;
136136
stream_scroll_batch?: number;
137-
stream_scroll_limit?: number;
137+
stream_scroll_limit?: number; // per-request doc cap for stream history replay; -1 = unlimited (default: 50000)
138+
stream_max_concurrent_replays?: number; // max concurrent stream history replays per API process (default: 4)
138139
pm2_scaling?: number;
139140

140141
// Node.js options
@@ -297,6 +298,7 @@ export const HyperionApiConfigSchema = z.object({
297298
log_errors: z.boolean().optional(),
298299
stream_scroll_batch: z.number().optional(),
299300
stream_scroll_limit: z.number().optional(),
301+
stream_max_concurrent_replays: z.number().optional(),
300302
pm2_scaling: z.number().optional(),
301303

302304
// Node.js options

0 commit comments

Comments
 (0)