feat(parquet): add bytes_processed scan-completion metric - #24524
feat(parquet): add bytes_processed scan-completion metric#24524adriangb wants to merge 5 commits into
bytes_processed scan-completion metric#24524Conversation
`EXPLAIN ANALYZE` reports how many row groups a scan pruned, but nothing reports how much of a scan is done. `bytes_scanned` looks like it should serve — its natural denominator, the size of the files in the plan, is known up front — but it counts only the bytes fetched, so pruning and projection pushdown leave it understating progress by a factor that varies per query. `bytes_processed` completes that numerator: it counts the bytes a scan is finished with, whether it read them or proved it did not need them. Over the lifetime of a file it advances by exactly that file's size — or, for a file split into byte ranges for parallelism, by the size of the range — so `bytes_processed / total file bytes` is a completion fraction a progress reporter can use directly. Credit lands a row group at a time. Row groups pruned while opening the file (by range, statistics, bloom filter, page index or limit) are credited before the first batch is decoded, which is the progress a scan makes that `files_processed` cannot show; row groups dropped mid-scan by a dynamic filter are credited as they are dropped; the rest are credited as the decoder reaches them. Whatever is left over is credited when the file closes, so a scan cut short by a `LIMIT`, an early stop or an error still ends on exactly the size of its range. Costs one atomic add per row group. Crediting a row group's bytes progressively as its rows decode is left to a follow-up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cmj1SUoV7Y9ADPbzLrzZ3
Two fixes from review of the `bytes_processed` metric. `EarlyStoppingStream` returned `None` once a dynamic filter proved the rest of a file irrelevant, but went on holding its inner stream. The decoder's drop is what credits the range's remaining bytes, so the file kept reading as partly unread after the scan had demonstrably finished with it — for however long the caller took to drop the wrapper. Hold the inner stream in an `Option` and release it when marking the stream done, which also frees the decoder's buffers at the point we stop reading rather than later. The same applies when the inner stream is simply exhausted. `ByteProgress` tracked its remaining budget as a `u64` while crediting a `Count`, which stores a `usize`. On a 32-bit target a credit above `usize::MAX` would decrement the budget in full while recording a saturated value, so the two could disagree and the metric would never reach the range size. Hold the budget at the counter's width instead, so an oversized range saturates once, at construction. `bytes_scanned` has the same ceiling; representing byte counters as `u64` would be a change to the core metric types, not to this scan. Both are covered by tests that fail without the corresponding fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cmj1SUoV7Y9ADPbzLrzZ3
saadtajwar
left a comment
There was a problem hiding this comment.
This is really nice! Just left a small code-styling suggestion
| let mut will_scan = vec![false; rg_metadata.len()]; | ||
| for entry in &rg_plan { | ||
| will_scan[entry.rg_index] = true; | ||
| } | ||
| let skipped_bytes: u64 = rg_metadata | ||
| .iter() | ||
| .enumerate() | ||
| .filter(|(rg_index, rg_meta)| { | ||
| !will_scan[*rg_index] | ||
| && prepared | ||
| .file_range | ||
| .as_ref() | ||
| .is_none_or(|range| row_group_in_range(rg_meta, range)) | ||
| }) | ||
| .map(|(_, rg_meta)| row_group_bytes(rg_meta)) | ||
| .sum(); |
There was a problem hiding this comment.
nit: I think we could make this a bit cleaner by collapsing 1519-1522, suggestion below:
let in_range_bytes: u64 = rg_metadata
.iter()
.filter(|rg_meta| {
prepared
.file_range
.as_ref()
.is_none_or(|range| row_group_in_range(rg_meta, range))
})
.map(row_group_bytes)
.sum();
let skipped_bytes: u64 = in_range_bytes.saturating_sub(rg_plan.iter().map(|e| e.bytes).sum());
Review suggestion: the row groups left in `rg_plan` are a subset of the ones this range owns, because the plan they came from had `prune_by_range` applied. Subtracting the planned bytes from the range's total therefore leaves exactly the skipped ones, with no need to build a lookup of which row groups the plan kept. Also pin the property the in-range filter exists for: with nothing pruned a split file's ranges must credit nothing at open, since every row group a range owns is one it will read and the rest belong to its sibling. The existing assertions could not catch a range crediting its sibling's row groups — the drop-time top-up brings the total back to the range size either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cmj1SUoV7Y9ADPbzLrzZ3
The progress guard was created alongside the decoder, so a file whose metadata load, filter preparation or bloom-filter load failed never credited its bytes: the guard that tops up the remainder on drop did not exist yet. Under `OnError::Skip` the scan carries on past that file, and `bytes_processed` is left permanently short of the plan's byte total — so a consumer dividing by it never reaches 100%. `files_processed` already counts a file that failed to open as processed, and its byte-granular counterpart should agree. Move the guard onto the state that travels through the whole open, so every early return drops it and credits the range. That also folds the file-level pruning path into the same mechanism: pruning before open now credits by dropping the guard rather than through a separate `add`, so there is one way bytes are accounted for rather than two. Covered by a test that opens a file which is not parquet at all, failing well before a stream exists; it fails if the guard is built alongside the decoder as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cmj1SUoV7Y9ADPbzLrzZ3
There was a problem hiding this comment.
Pull request overview
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Adds a new Parquet scan completion metric (bytes_processed) that advances monotonically to the file/range effective_size(), enabling reliable scan-progress reporting even with pruning and projection pushdown.
Changes:
- Introduces
ParquetFileMetrics::bytes_processedplusByteProgressto safely and monotonically account bytes (including on early stop / errors viaDrop). - Updates Parquet scan/open/decoder logic to credit processed bytes at open-time (for pruned RGs) and during decode (per RG), including split-range correctness via shared
row_group_in_range. - Updates EXPLAIN ANALYZE outputs, sqllogictest expectations, and user docs to include
bytes_processed.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/source/user-guide/explain-usage.md | Documents the new bytes_processed EXPLAIN metric and its interpretation. |
| datafusion/sqllogictest/test_files/*.slt | Updates expected EXPLAIN ANALYZE metric strings to include bytes_processed. |
| datafusion/datasource-parquet/src/row_group_filter.rs | Extracts row_group_in_range to unify range ownership logic for row groups. |
| datafusion/datasource-parquet/src/push_decoder.rs | Credits bytes_processed as row groups are pruned/decoded/advanced. |
| datafusion/datasource-parquet/src/opener/mod.rs | Creates and wires ByteProgress, credits open-time skipped bytes, and adds invariant tests. |
| datafusion/datasource-parquet/src/opener/early_stop.rs | Ensures inner stream is dropped immediately on early stop to complete byte accounting promptly. |
| datafusion/datasource-parquet/src/metrics.rs | Adds bytes_processed metric and ByteProgress guard implementation. |
| datafusion/core/tests/sql/explain_analyze.rs | Asserts EXPLAIN ANALYZE output contains bytes_processed=. |
Suppressed comments (1)
datafusion/datasource-parquet/src/metrics.rs:1
- Adding a new
pubfield to a public struct is a semver-breaking change for external users constructingParquetFileMetricswith a struct literal. IfParquetFileMetricsis intended to be publicly constructible, consider mitigating this by marking the struct#[non_exhaustive](if not already), or making fields private and exposing construction/access vianew()/accessors so future metric additions don’t break downstream builds.
// Licensed to the Apache Software Foundation (ASF) under one
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| /// The on-disk size of a row group, as credited to | ||
| /// [`ParquetFileMetrics::bytes_processed`]. | ||
| fn row_group_bytes(rg_meta: &RowGroupMetaData) -> u64 { | ||
| u64::try_from(rg_meta.compressed_size()).unwrap_or(0) |
| let in_range_bytes: u64 = rg_metadata | ||
| .iter() | ||
| .filter(|rg_meta| { | ||
| prepared | ||
| .file_range | ||
| .as_ref() | ||
| .is_none_or(|range| row_group_in_range(rg_meta, range)) | ||
| }) | ||
| .map(row_group_bytes) | ||
| .sum(); | ||
| let planned_bytes: u64 = rg_plan.iter().map(|entry| entry.bytes).sum(); |
| - `DataSourceExec` | ||
| - `output_rows=99997497`: A total 99.9M rows were produced | ||
| - `bytes_scanned=3703192723`: Of the 14GB file, 3.7GB were actually read (due to projection pushdown) | ||
| - `bytes_processed=14779976446`: All 14GB were accounted for: the 3.7GB read, plus the bytes of row groups that pruning ruled out. Comparing this against the total size of the files in the plan tells you how far along a scan is |
Adding a `pub` field to `ParquetFileMetrics` is a semver-breaking change for anyone constructing it with a struct literal, which cargo-semver-checks reports and reviewers have flagged. The field also turned out to earn nothing: once file-level pruning started crediting through the progress guard, the only thing left reading it was the guard's construction. Build the counter on demand instead, next to the other metrics in this file that are registered where they are used rather than held on the struct. The public API is unchanged, so the semver break goes away, and future metrics of this kind need not widen it either. `EXPLAIN ANALYZE` output is unaffected: the counter keeps its name, type, category and filename label, so it renders in the same position. The sqllogictest expectations are unchanged and still pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017cmj1SUoV7Y9ADPbzLrzZ3
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #24524 +/- ##
==========================================
+ Coverage 81.24% 81.32% +0.07%
==========================================
Files 1113 1117 +4
Lines 392744 396255 +3511
Branches 392744 396255 +3511
==========================================
+ Hits 319090 322251 +3161
- Misses 54900 55182 +282
- Partials 18754 18822 +68 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Which issue does this PR close?
Rationale for this change
Nothing today reports how far along a scan is. That is what a progress bar, a query-progress API, or a watchdog that cancels runaway queries needs, and both existing signals fail at it in opposite directions:
bytes_scannedlooks like the right metric, because its natural denominator — the size of the files in the plan — is known before the query runs. But it counts only the bytes actually fetched. A scan that prunes most of its row groups, or projects 3 of 50 columns, reports a small fraction of the file even once it has finished, sobytes_scanned / total file bytesunderstates progress by a factor that varies per query and is not knowable up front.files_processedonly moves when a whole file completes. With high scan parallelism it reads 0% through the entire first wave of files, however much work is in flight, and then jumps.The gap is not the unit, it is the numerator: a scan is done with a byte once it has either read it or proved it does not need it, and
bytes_scannedcounts only the first half.What changes are included in this PR?
A new
bytes_processedcounter onParquetFileMetrics, alongsidebytes_scannedand sharing its metric type, category and per-file label:Its contract is one invariant:
so
bytes_processed / total file bytesis a completion fraction that needs no statistics and no second metric to normalise against.Credit lands a row group at a time:
effective_size()compressed_size(rg), at opencompressed_size(rg)compressed_size(rg)LIMIT, early stop, error)Two details worth a reviewer's attention:
ByteProgress(inmetrics.rs) clamps every credit to the bytes left in the range and credits the remainder onDrop. The clamp absorbs the two inexactnesses of crediting by row group — a file is slightly larger than the sum of its row groups, and a row group is assigned to a byte range by its first page's offset — andDropis what makes the invariant hold underLIMIT,EarlyStoppingStreamand errors without instrumenting every exit path.row_group_in_rangeis extracted out ofRowGroupAccessPlanFilter::prune_by_rangeso the open-time skip pass decides which row groups a range owns by the same rule, instead of duplicating the offset logic. Without it, one range of a split file would credit its siblings' row groups against its own budget and jump straight to 100% at open.Folding every open-time pruning stage into a single pass over the final access plan is what keeps this free of double counting: range, statistics, bloom, limit and page-index pruning have all been applied by then, so there is no need to instrument five sites and reason about their overlap.
Cost is one atomic add per row group. Crediting a row group's bytes progressively as its rows decode — proportionally by rows resolved, trued up at the boundary — is deliberately left to a follow-up; it does not change this metric's contract.
Are these changes tested?
Yes. Six new tests in
opener::test::bytes_processed, all asserting the invariant, for: a plain scan, a scan pruning row groups, a file pruned before open by a dynamic filter (also assertingbytes_scanned == 0), a byte-split file (each range credits exactly its own length, and the ranges sum to the file), aLIMITthat ends the scan early, and one that steps through the stream batch by batch to pin that credit advances during the scan rather than all at once on close.The two carrying the real claims were mutation-checked: removing the mid-scan credit fails
credit_advances_while_the_scan_runs, and removing the open-time credit failsrow_group_pruning_is_credited_before_any_batch_is_read. The other four would also pass a trivial credit-everything-on-close implementation, so those two are the ones doing the work.Also run: the full
datafusion-datasource-parquetsuite, theexplain_analyzecore tests, the four affected sqllogictest files,cargo fmt,cargo clippy --all-targets --all-features -- -D warnings, and rustdoc with-D warnings.Are there any user-facing changes?
EXPLAIN ANALYZEon a parquet scan gains abytes_processed=entry, next tobytes_scanned. Four sqllogictest files are updated; the diff there is exactly eight addedbytes_processed=fragments and nothing else — existing<slt:ignore>markers and pinned values are preserved.docs/source/user-guide/explain-usage.mddocumenting the metric next tobytes_scanned.ParquetFileMetricsgains a public field. The struct is documented as subject to change and is normally built throughParquetFileMetrics::new, but external code constructing it with a struct literal would need updating — flagging in case this warrants theapi changelabel.🤖 Generated with Claude Code
https://claude.ai/code/session_017cmj1SUoV7Y9ADPbzLrzZ3
Generated by Claude Code