feat(parquet): add bytes_processed scan-completion metric - #69
Closed
adriangb wants to merge 4 commits into
Closed
Conversation
`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
|
Thank you for opening this pull request! Reviewer note: cargo-semver-checks reported the current version number is not SemVer-compatible with the changes in this pull request (compared against the base branch). Details |
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
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
adriangb
requested
a balanced review from Copilot
and removed request for
Copilot
August 20, 2026 16:33
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.
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