Resolve or refuse a cursor without waiting on merged files - #63
Merged
Conversation
A cursor block inside the live buffer's range that the buffer does not know made the hub decline the source, and the file source it fell back to waited for the merged file holding that block number — twenty minutes on Ethereum — only to fail there too.
A one-block file is named after its block number, so scanning from the cursor's LIB up to it could match that ID suffix on a different block and call an unresolvable cursor resolvable.
This was referenced Aug 17, 2026
A cursor above head names a block that exists, on an instance that has not got there yet — a client reconnecting to a fleet member a few blocks behind. Give it five seconds, then report a retryable failure rather than a cursor to discard.
sduchesneau
force-pushed
the
fix/unresolvable-cursor-fast-fail
branch
from
August 17, 2026 12:29
97c2895 to
c931e9c
Compare
| } | ||
|
|
||
| found := false | ||
| err := g.forkedBlocksStore.Walk(context.Background(), fmt.Sprintf("%010d", blockNum), func(filename string) error { |
There was a problem hiding this comment.
minor, but should pass ctx here so dosent continue on cancel
UlysseCorbeil
approved these changes
Aug 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A stream resuming from a cursor whose block the live buffer should hold, but does not — a corrupted or forged cursor, or one carried over from a chain the process never saw — goes silent instead of failing.
ForkableHubdeclines such a cursor (forkable.blocksFromCursor→cannot find block with ID …), soJoiningSourcefalls back to the file source. That one can only start once the merged-blocks file holding the cursor's block number exists, which near chain head it does not: the stream then waits for the merger to write a whole bundle — measured at ~20 minutes on Ethereum with 100-block bundles — and the cursor is unresolvable from files when it gets there anyway.Measured against
firehose-core/devel/standard(dummy chain, 2 blocks/s), taking a live cursor off the stream and rewriting the last 4 hex of its block hash:developInvalidArgument: cannot resolve cursor from merged block filesInvalidArgument: cannot resolve cursor: block #5266 (…dead) sits inside the live range [4770, 5286], where neither the live buffer nor the forked blocks hold itWith the merger stopped so the bundle never lands,
developstays silent indefinitely.Change
Three commits, each a distinct decision.
41a2252— ask before falling back to files. Once the live source has declined a cursor,JoiningSourceasks whether anything can still produce that block:[LowestBlockNum, HeadNum]whose ID it does not know is on no chain it ever saw;ErrResolveCursor— whichstream.Runalready surfaces as an invalid argument, unchanged by this PR.Everything else is untouched: a cursor below the live range, a live buffer not yet ready, or a forked-blocks store that cannot be read all stay with the file source. The last one matters — an unreadable store is not evidence of absence, and treating an S3 hiccup as "that block never existed" would fail requests carrying perfectly good cursors.
The check is exported as
CheckCursorResolvableso callers resolving cursors outsideJoiningSourcecan make the same one — substreams' tier1CursorResolverhas the identical fallback and the identical hang (streamingfast/substreams#883).36ebf04— look the forked block up at its own height. One-block files are named after their block number, so the only file that can hold the cursor's block is the one atcursor.Block.Num(). Scanning[LIB.Num, Block.Num]was both wasted I/O and unsound: a truncated-ID suffix matching at any height in that window declared the cursor resolvable, sending it back to the file source to hang exactly as before.c931e9c— wait out a cursor above the live head, and make that one retryable. A cursor block above head says nothing about the block existing; it says this process has not reached it, which is what a client reconnecting to an instance a few blocks behind the one that served it looks like. Those are givenCursorHeadWaitTimeout(5s, exported) to arrive — polling the live head — and only then reported, asErrCursorAboveHeadrather than as a cursor no source can resolve.That one maps to a new
stream.ErrUnavailable, meant forcodes.Unavailable: we may simply be lagging while another instance already serves that block, and an invalid argument would have the client discard a cursor that is good.ErrResolveCursorkeeps itsErrInvalidArgmapping.Servers need to map the new type; without it the error falls to their default branch (
codes.Internalin firehose-core — still retryable, but the message is replaced). The matching mapping is streamingfast/firehose-core#212.Callers with their own resolution path distinguish the two: substreams reverts an unresolvable cursor to its LIB, but must not revert a sink that is merely ahead of a lagging instance.
Tests
TestJoiningSourceCheckCursorResolvable, 10 cases: block known to the live buffer, below the range, above head and reached while waiting, above head and never reached, reached but unknown there, buffer not ready, held by the forked blocks, held at another height (which is another block), forked-blocks store failing, and unknown to both.TestFileSourceFactory_HasForkedBlockagainst aMockStorewith real one-block filenames: the same ID suffix present at two heights answers only for its own.Note:
TestFileSource_Runis flaky ondevelopalready — it asserts a counter incremented from parallel preprocessor goroutines without synchronization (go test -count=50 -run TestFileSource_Runfails ondevelop, andgo test -racereports pre-existing races inTestEternalSource). Adding tests to the package shifts scheduling enough to surface it more often. Left alone here as unrelated.🤖 Generated with Claude Code