Skip to content

POC: page-granular scan planning for the Parquet push decoder - #10555

Draft
adriangb wants to merge 2 commits into
apache:mainfrom
pydantic:claude/push-decoder-peek-59
Draft

POC: page-granular scan planning for the Parquet push decoder#10555
adriangb wants to merge 2 commits into
apache:mainfrom
pydantic:claude/push-decoder-peek-59

Conversation

@adriangb

@adriangb adriangb commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

ParquetPushDecoder reports what it needs one row group at a time: DecodeResult::NeedsData does not resolve until every projected byte of the row group is buffered. That is the right granularity when the caller wants a whole row group's reader, but for a caller scheduling its own I/O it means:

  • resident bytes are bounded by row-group size, not by anything the caller chooses;
  • decoding cannot start until the last byte of the row group lands, so time-to-first-batch is a whole row group of latency; and
  • there is no way to ask "what does the next batch need?", which is the question a scheduler actually has.

This adds plan_scan_ranges, which decomposes a scan into the individual pages it will read, in the order decoding needs them, each tagged with the span of selected rows it serves. A caller can then fetch only what the next batch needs, drop pages once its decode cursor passes them, and read ahead as far as its own byte budget allows.

The plan is demand, not schedule: it says which bytes the query will read and when they are first needed. Request sizing, readahead depth and whether to merge nearby ranges stay caller policy, because they depend on the storage medium rather than on the file. (ObjectStore::get_ranges already coalesces for stores that use the default implementation, which is one reason this deliberately does not.)

Units: why row tags rather than a fixed quantum

The obvious shape would be next_batch_ranges() or ranges_for(n_rows). Both bake in a unit the caller may not want. A fixed row count can imply an unbounded byte count — 8192 rows of 1MB values is 8GB — while a pure byte budget cannot express "enough to make progress".

So each planned range carries first_row / last_row in selected-row space instead. A caller budgeting in bytes takes pages until the budget fills; a caller thinking in rows takes pages until the tags cover its window; and last_row is what makes eviction possible at all.

One floor remains and is documented: ParquetRecordBatchReader decodes batch_size rows at a time, so no fetch plan can subdivide a batch. For schemas with very large values the lever is a smaller batch_size, or byte-based batch sizing, which is a separate gap.

What changes are included in this PR?

A single new module, parquet::arrow::push_decoder::scan_plan, exporting plan_scan_ranges, ScanPlan and PlannedRange. No existing code path is touched — nothing calls it inside the crate yet, so this is purely additive.

plan_scan_ranges(metadata, row_groups, projection, selection) returns None when the file has no offset index, since page locations are what make page-granular planning possible; callers fall back to row-group-granular fetching.

The natural follow-up, if this shape is acceptable, is to use it inside the decoder itself so NeedsData can resolve at batch granularity rather than row-group granularity, and to make RowFilter evaluation incremental (today predicates are evaluated eagerly over a whole row group inside build(), which is why the DataFusion side below falls back to row-group granularity whenever filter pushdown is on).

Are these changes tested?

Yes — six unit tests covering page ordering, projection, selection-driven page skipping (including selected-row-space tagging), row-group subsets, the no-offset-index fallback, and that planned bytes exactly cover the projected column chunks when nothing is skipped.

It is also exercised end to end by a DataFusion POC that schedules I/O against this plan: apache/datafusion#24086. Measured there against DataFusion main, with simulated object-store latency:

benchmark baseline with batch-granular scheduling
TPC-H SF10 635.1s 100.0s (22 of 22 queries faster)
TPC-H SF1 110.0s 65.0s (21 of 22 faster, 0 slower)
TPC-DS SF1 380.1s 380.1s, peak memory −18%
single 300MB row group 845ms, 700ms TTFB, 283MB peak 708ms, 112ms TTFB, 100MB peak

ClickBench is unchanged there — because the published hits_*.parquet files contain no page index at all (0 of 105 columns), so page-granular planning cannot run on them. Rewriting three of those files with write_page_index=True and changing nothing else makes the same queries 1.54x faster (36 queries: 35 faster, 0 slower, 1 neutral), which is a reasonable argument that the capability is worth having even where files do not use it yet.

Are there any user-facing changes?

New public API only (plan_scan_ranges, ScanPlan, PlannedRange); no behavior change to existing paths. Marked draft because the API shape is the thing worth discussing — in particular whether this belongs as a free function or as a method on the decoder that can also advance it.

🤖 Generated with Claude Code

The push decoder reports what it needs one row group at a time:
NeedsData does not resolve until every projected byte of the row group
is buffered. Callers that schedule their own I/O therefore must buffer a
whole row group before decoding starts, and have no way to ask what the
next batch needs.

Add `plan_scan_ranges`, which decomposes a scan (row groups x projection
x optional row selection) into the individual pages it will read, in the
order decoding needs them, each tagged with the span of selected rows it
serves. Callers can then fetch only what the next batch requires, drop
pages once the decode cursor passes them, and read ahead as far as their
own byte budget allows.

The plan is demand, not schedule: request sizing, readahead depth and
range coalescing stay caller policy, since they depend on the storage
medium rather than the file. Row tags rather than a fixed row or byte
quantum keep it usable by callers budgeting in either unit, which
matters for schemas with large values where a fixed row count implies an
unbounded byte count.

Returns None when the file has no offset index, so callers fall back to
row-group-granular fetching.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two CI failures on the new module:

* Rustdoc `-D warnings` rejected the doc example: it lived in the
  module-level docs of a private module, tripping
  `rustdoc::private_doc_tests`. The prose was invisible to users for the
  same reason, since only the re-exported items are public. Move the
  motivation, units discussion and example onto `plan_scan_ranges`, which
  is where a reader looking up the API will actually find them, and leave
  the module header as a pointer.

* Clippy `-D warnings` rejected `ArrowReaderOptions::with_page_index`,
  deprecated since 57.2.0. Use `with_page_index_policy` in the test and
  in the doc link that points callers at it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

parquet Changes to the parquet crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant