fix(core): keep a bounded stream alive until the finalized head reaches its range end - #143
Draft
abernatskiy wants to merge 3 commits into
Draft
fix(core): keep a bounded stream alive until the finalized head reaches its range end#143abernatskiy wants to merge 3 commits into
abernatskiy wants to merge 3 commits into
Conversation
…es its range end A bounded stream (toBlock set) ended as soon as every requested block was DELIVERED, even when the portal's reported finalized head was still below toBlock. Finalized-only targets (parquet) hold rows back until a batch reports them final — and after the last block no such batch ever came, so pipeTo resolved while the target silently discarded the unfinalized tail. A backfill whose END_BLOCK sits above the current finalized head therefore "succeeded" with its output ending short of END_BLOCK. The stream now ends only once the consumer has been shown a finalized head at or above the range end: - portal-client: after the last block of a bounded range, poll GET /finalized-head until it reaches toBlock (there is nothing to wait for on no-finality datasets, and nothing to do when the range is already final) and deliver the catch-up as one final empty batch. A bounded range the portal answers with no data is retried on the same cadence instead of being ended short, and a response that advanced nothing no longer re-requests in a hot loop. - portal-source: forward an otherwise-filtered empty batch when it advances the finalized head over the last delivered block, so the consumer can commit its tail. - targets: skip the user onData handler for batches built from zero source blocks (handlers never saw blockless batches before); cursor/offset persistence still runs and records the caught-up finalized head. The postgres sync insert becomes an upsert for the re-saved offset row. Co-Authored-By: Claude Fable 5 <[email protected]>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR changes the bounded-range streaming contract so a stream with toBlock set does not complete until the consumer has observed a finalized head >= toBlock, preventing finalized-only targets (e.g. parquet) from silently dropping an unfinalized tail.
Changes:
- Portal client now polls
GET /finalized-headafter delivering the last block of a bounded range and emits a final empty “finality catch-up” batch once finality reachestoBlock. - Portal source forwards only the otherwise-filtered empty batch that advances finality past the last delivered block, and targets skip user
onDatafor these blockless batches while still persisting cursor/finality. - Adds/updates tests across core, portal client/cache, and targets to validate finality catch-up behavior and bounded no-data retries.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/pipes/src/testing/test-portal.ts | Extends mock portal to serve GET /finalized-head for finality polling tests. |
| packages/pipes/src/portal-client/client.ts | Adds end-of-bounded-range finality polling + bounded no-data retry pacing and prevents hot loops. |
| packages/pipes/src/portal-client/client.test.ts | Updates batching/accounting tests to include the final empty finality catch-up batch and retry pacing. |
| packages/pipes/src/core/portal-source.ts | Forwards the finality catch-up empty batch to consumers while keeping other empty batches filtered. |
| packages/pipes/src/core/portal-source.test.ts | Updates expectations for the extra catch-up batch and lifecycle span counts. |
| packages/pipes/src/core/bounded-range-finality.test.ts | New tests covering bounded finality wait, fast paths, and bounded no-data retry. |
| packages/pipes/src/targets/parquet/parquet-target.ts | Skips user onData for blockless finality catch-up batches while still committing finality/cursor. |
| packages/pipes/src/targets/parquet/parquet-target.test.ts | Updates finalized-only parquet behavior to assert tail publication once finality catches up. |
| packages/pipes/src/targets/parquet/custom-engine.test.ts | Updates custom engine expectations to include tail rows after finality catch-up. |
| packages/pipes/src/targets/memory/memory-target.test.ts | Updates memory target expectations to include tail row once finality catch-up arrives. |
| packages/pipes/src/targets/drizzle/node-postgres/postgres-state.ts | Changes sync-table insert to an upsert to support catch-up re-saving the last offset row. |
| packages/pipes/src/targets/drizzle/node-postgres/drizzle-target.ts | Skips user onData for blockless catch-up batches but still persists cursor/finality. |
| packages/pipes/src/targets/drizzle/node-postgres/drizzle-target.test.ts | Updates snapshot to reflect finalized head catch-up and empty rollback chain. |
| packages/pipes/src/targets/clickhouse/clickouse-target.ts | Skips user onData for blockless catch-up batches while still saving cursor/finality. |
| packages/pipes/src/targets/clickhouse/clickhouse-target.test.ts | Updates snapshots to include the additional saved offset row from the catch-up batch. |
| packages/pipes/src/targets/bigquery/bigquery-target.ts | Skips user onData for blockless catch-up batches but still WAL-commits finality/cursor. |
| packages/pipes/src/portal-cache/node/portal-cache.test.ts | Updates cache snapshots to include the finality catch-up batch metadata. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ot empty JSON A JSON content-type with an empty body makes the client's res.json() throw instead of resolving to undefined. Covers the path with a test where the first poll finds no head and the stream keeps waiting. Co-Authored-By: Claude Fable 5 <[email protected]>
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.
Suppressed comments (1)
packages/pipes/src/portal-client/client.ts:307
getFinalizedHead()is awaited without any abort signal, so a bounded stream that is stopped/aborted while waiting for finality can hang until the HTTP request times out. The rest of the stream requests are wired tobuffer.signal(viaabort: buffer.signal), so this new polling path should be cancellable the same way (e.g., pass anAbortSignalintogetFinalizedHead, or otherwise ensure the underlying request is aborted whenbuffer.signalfires).
const head = await getFinalizedHead()
if (head != null && head.number > finalizedSeen) {
finalizedSeen = head.number
if (head.number >= toBlock) {
await buffer.put({
The GET /finalized-head poll ran without an abort signal, so a consumer stopping a bounded stream mid-wait left the request running until the HTTP timeout. It is now wired to the buffer's signal like every stream request. Co-Authored-By: Claude Fable 5 <[email protected]>
2 tasks
abernatskiy
marked this pull request as draft
August 7, 2026 17:55
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 bounded stream (
toBlockset) ends as soon as every requested block has been delivered — with no regard for whether the portal's reported finalized head has reachedtoBlock. Finalized-only targets (parquet) hold rows back until some batch reports them final; after the last block no such batch ever comes (empty batches are filtered out inportal-source), soawait pipeTo(...)resolves while the target silently discards the unfinalized tail.Concretely: a backfill whose
END_BLOCKsits above the current finalized head "succeeds" with its output files ending short ofEND_BLOCK. This is how an Avalanche transactions backfill shipped a range its completion marker claimed but its parquet files did not cover (~1.7k blocks short — the gap between the network data source's reported finalized head and the range end).MRE
packages/pipes/src/core/bounded-range-finality.test.ts, first test: one response delivers blocks 1..3 but reportsfinalized = 1— the shape a backfill sees whenEND_BLOCKis above the current finalized head. Onmainthe test fails with:i.e.
pipeToresolved and the last finalized head the target ever saw was 1 — a finalized-only target must discard blocks 2..3, with no error anywhere.Fix
The contract is now: a bounded stream ends only after the consumer has been shown a finalized head ≥ the range end — waiting for finality to catch up, not crashing, not ending short.
GET /finalized-headuntil it reachestoBlock, then deliver the catch-up as one final empty batch. No-finality datasets (no finalized head ever reported) and already-final ranges end exactly as before, without a single poll. A bounded range the portal answers with no data is retried on the same cadence instead of ending the range short, and a 200 that advanced nothing no longer re-requests in a hot loop.onDatahandler for batches built from zero source blocks (user handlers never saw blockless batches before; one indexeddata[0]and would crash). Finalization, cursor and offset persistence still run — recording the caught-up finalized head is the point of the batch. The postgres sync-table insert becomes an upsert, since the catch-up re-saves the last block's offset row with newer finality.The wait is bounded by finality itself: for a range ending above the chain's current finalized head it is minutes. For a range the portal can never finalize (a frozen dataset) the stream now visibly waits instead of silently completing with missing data — the failure mode this bug traded that for.
Tests
bounded-range-finality.test.ts— the MRE (fails onmain), plus no-poll fast-paths (already-final, no-finality) and the bounded no-data retry.GET /finalized-head(default: everything it served is final; scriptable per test).3–5 stay buffered and are never written, i.e. the incident behavior. It now asserts nothing is published above the finalized head while finality lags, and that the tail lands once finality arrives.🤖 Generated with Claude Code