Repository navigation
fix: advance the cursor to the ledger actually covered - #198
Richard-tobi wants to merge 1 commit into
Conversation
|
@Richard-tobi Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
Miracle656
left a comment
There was a problem hiding this comment.
This is strong work — the diagnosis is right, the cursor-paging drain is the correct shape, and the four tests in src/__tests__/rpcPaging.test.ts all pass locally against your branch (57 passed across rpcPaging + fetchEventsSafe + indexerSources + multiNetworkIndexer; tsc --noEmit and tsc -p tsconfig.test.json both clean after npx prisma generate). I checked the drain logic in both directions and I could not make it commit a cursor past unindexed data on the pollOnce path: the maxLedger >= endLedger break at fetchRangePaged and the page-budget break can both leave the top ledger partially covered, but pollOnce re-reads fromLedger inclusively, so that ledger is picked up on the next poll. That is correct, and it is worth a comment saying so, because it is the load-bearing assumption of the whole design.
What I can't merge yet is that the invariant your own docstring states — "the highest ledger fully covered by them — never the raw network tip" — is not upheld on two sibling paths, and both of them lose ledgers permanently.
1. src/indexer/parallel.ts — pollParallel takes the max, not the min
const highestLedger = results.reduce(
(max, r) => Math.max(max, r.highestLedger),
fromLedger,
);
await setLastIndexedLedger(highestLedger, net);Before your change every worker returned the identical network tip, so Math.max was meaningless. Your change is what makes the per-worker values diverge: a partition whose pages hit EVENTS_PAGE_BUDGET now returns a genuinely lower highestLedger than a partition that drained cleanly. Math.max throws that signal away and commits the higher one, so the truncated partition's ledgers are skipped and never revisited — the exact failure #180 is about, just on the INGEST_WORKERS > 1 path. The committed cursor has to be the minimum across workers (seeded from toLedger, then floored at fromLedger so an empty window still makes progress). pollParallel also never clamps to toLedger, which the new pollOnce does.
2. src/rpc.ts — the bisect path still returns the raw tip
The single-ledger branch is untouched:
if (startLedger >= endLedger) {
const { events, latestLedger } = await _fetchFn(startLedger, contractIds, limit, network);
return { events, highestLedger: Math.max(startLedger, latestLedger) };and the catch returns Math.max(lower.highestLedger, upper.highestLedger) with no clamp. So one XDR decode error anywhere in the range makes fetchEventsSafe hand back the network tip. pollOnce's new Math.min(highestLedger, endLedger) saves the serial path — that defensive clamp was a good instinct — but pollParallel has no such clamp, so on the parallel path an XDR error jumps the cursor straight to the tip and everything between is gone. Clamping inside fetchEventsSafe (both the single-ledger return and the bisect return) is the fix, and it makes the function actually honour its docstring for every caller, including src/ingest/backfill.ts.
A test for each would be good: one pollParallel case where one worker is truncated and one is not, asserting the committed ledger is the truncated one; and one fetchEventsSafe case where the bisect fires, asserting highestLedger <= endLedger.
Smaller things, not blockers:
EVENTS_PAGE_BUDGET = 20withBATCH_SIZEdefaulting to 10 000 means a single ledger would need ~200k matching events to livelock the loop (maxLedger === fromLedger→ cursor does not advance). Not reachable in practice, but a one-line comment saying why the budget is safe would stop the next reader from having to work it out.fetchRangePagedtakes_fetchFnbut the XDR bisect only wraps the whole range, so a decode error on page 17 re-fetches pages 1–16. Fine for now; worth a note.
Thanks — this is the right fix and the paging drain is the hard part. Wire the same clamp through the parallel and bisect paths and I'll merge it.
|
@Miracle656 Both blockers are fixed and the branch is rebased, so it merges again. 1.
|
|
@Miracle656 Both blockers are on the head and I verified them independently at
Tests for each ( Verification in a clean worktree ( Ready for re-review — thanks for the detailed pointers on where the clamp had to move. |
fefe35b to
15edcb3
Compare
Overview
fetchEvents(src/rpc.ts) read a singlegetEventspage, returned the network tip (resp.latestLedger) as the highest ledger, and discardedresp.cursor— so a page truncated atlimitsilently dropped the rest of the range.fetchEventsSafe's happy path passed that tip straight through ashighestLedger, ignoring itsendLedgerargument, andpollOncecommitted it as the cursor. The cursor therefore jumped pasttarget = tip - TIP_LAG, defeating the propagation buffer that exists to avoid reading un-settled ledgers.This change drains a full page by following the RPC cursor and advances the cursor only to the ledger those pages actually covered, bounded by
endLedger.Related Issue
Miracle656/wraith#180 — Advance the cursor to the ledger actually covered, not the chain tip.
Changes
[MODIFY]
src/rpc.tsfetchEventsnow returns aFetchEventsPage(events,latestLedger,cursor,maxLedger) instead of{ events, latestLedger }.cursoris the RPC paging token;maxLedgeris the highestevent.ledgeractually seen in the page (0when empty). Existing fields are kept, so current destructuring callers are unaffected.fetchEventsaccepts an optionaloptions.cursorand builds the cursor-shaped request ({ cursor, limit, filters }) when continuing a page, and the range-shaped request ({ startLedger, limit, filters }) otherwise — matching the SDK's mutually exclusiveApi.GetEventsRequestunion.EVENTS_PAGE_BUDGET(20) and thefetchRangePagedhelper, which pages with the cursor while pages return full, drops events aboveendLedger, and reports whether a page-budget cut the range short.fetchEventsSafe's happy path now returnshighestLedger = min(last ledger fully covered, endLedger):latestLedgerwhen the range drained, otherwise the lastmaxLedgerobserved when the budget was hit. It never returns the raw tip. The XDR bisection/skip branches are unchanged.[MODIFY]
src/indexer.tspollOnce's third parameter is renamed fromlatestLedgertoendLedger(it is the poll target,tip - TIP_LAG), and the committed cursor is clamped withMath.min(highestLedger, endLedger)so no source can advance it past the requested window. The empty-batch and normal commit paths both persist the clamped cursor.[ADD]
src/__tests__/rpcPaging.test.tsJest tests with a mocked RPC
getEventson the cached server (the real request/response shaping still runs):limitevents followed by a second page proves the second page is now ingested (and that the follow-up request used the cursor);highestLedgernever exceeds the passedendLedgerand never equals the raw tip;endLedgerare dropped instead of ingesting un-settled ledgers;Verification Results
fetchEventsreturns the paging cursor and the maximumevent.ledgerseen, alongsidelatestLedgerFetchEventsPage { events, latestLedger, cursor, maxLedger }fetchRangePagedfollowscursor, bounded byEVENTS_PAGE_BUDGEThighestLedgerismin(last ledger fully covered, endLedger)— never the raw tipMath.min(truncated ? maxLedger : latestLedger, endLedger)limitevents proves the second page is lost onmainand ingested afterendLedgerpassed inCloses #180