Skip to content

feat(parquet): add bytes_processed scan-completion metric - #69

Closed
adriangb wants to merge 4 commits into
mainfrom
claude/query-progress-tracker-metrics-dyl2u1
Closed

feat(parquet): add bytes_processed scan-completion metric#69
adriangb wants to merge 4 commits into
mainfrom
claude/query-progress-tracker-metrics-dyl2u1

Conversation

@adriangb

Copy link
Copy Markdown
Member

Which issue does this PR close?

  • No issue filed yet. Happy to open one describing the problem before review if that is preferred.

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_scanned looks 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, so bytes_scanned / total file bytes understates progress by a factor that varies per query and is not knowable up front.
  • files_processed only 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_scanned counts only the first half.

What changes are included in this PR?

A new bytes_processed counter on ParquetFileMetrics, alongside bytes_scanned and sharing its metric type, category and per-file label:

bytes_scanned    — bytes fetched from the object store
bytes_processed  — bytes the scan is finished with, whether read or pruned

Its contract is one invariant:

Over the lifetime of a file — or, for a file split into byte ranges for parallelism, a range — bytes_processed advances by exactly effective_size(), monotonically.

so bytes_processed / total file bytes is a completion fraction that needs no statistics and no second metric to normalise against.

Credit lands a row group at a time:

event credit
file/range pruned before open (file statistics, dynamic filter) effective_size()
row groups the final access plan skips — range, statistics, bloom filter, limit, page index compressed_size(rg), at open
row group dropped mid-scan by a dynamic filter compressed_size(rg)
row group reached by the decoder compressed_size(rg)
file closed for any reason (finished, LIMIT, early stop, error) the remainder

Two details worth a reviewer's attention:

  • ByteProgress (in metrics.rs) clamps every credit to the bytes left in the range and credits the remainder on Drop. 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 — and Drop is what makes the invariant hold under LIMIT, EarlyStoppingStream and errors without instrumenting every exit path.
  • row_group_in_range is extracted out of RowGroupAccessPlanFilter::prune_by_range so 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 asserting bytes_scanned == 0), a byte-split file (each range credits exactly its own length, and the ranges sum to the file), a LIMIT that 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 fails row_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-parquet suite, the explain_analyze core 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 ANALYZE on a parquet scan gains a bytes_processed= entry, next to bytes_scanned. Four sqllogictest files are updated; the diff there is exactly eight added bytes_processed= fragments and nothing else — existing <slt:ignore> markers and pinned values are preserved.
  • A bullet in docs/source/user-guide/explain-usage.md documenting the metric next to bytes_scanned.
  • ParquetFileMetrics gains a public field. The struct is documented as subject to change and is normally built through ParquetFileMetrics::new, but external code constructing it with a struct literal would need updating — flagging in case this warrants the api change label.

🤖 Generated with Claude Code

https://claude.ai/code/session_017cmj1SUoV7Y9ADPbzLrzZ3


Generated by Claude Code

`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
@github-actions github-actions Bot added documentation Improvements or additions to documentation core sqllogictest datasource labels Aug 20, 2026
Comment thread datafusion/datasource-parquet/src/metrics.rs Outdated
Comment thread datafusion/datasource-parquet/src/opener/mod.rs
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

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
     Cloning apache/main
    Building datafusion v54.1.0 (current)
       Built [  51.511s] (current)
     Parsing datafusion v54.1.0 (current)
      Parsed [   0.029s] (current)
    Building datafusion v54.1.0 (baseline)
       Built [  48.230s] (baseline)
     Parsing datafusion v54.1.0 (baseline)
      Parsed [   0.030s] (baseline)
    Checking datafusion v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.726s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 102.708s] datafusion
    Building datafusion-datasource-parquet v54.1.0 (current)
       Built [  43.991s] (current)
     Parsing datafusion-datasource-parquet v54.1.0 (current)
      Parsed [   0.029s] (current)
    Building datafusion-datasource-parquet v54.1.0 (baseline)
       Built [  42.996s] (baseline)
     Parsing datafusion-datasource-parquet v54.1.0 (baseline)
      Parsed [   0.028s] (baseline)
    Checking datafusion-datasource-parquet v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.174s] 223 checks: 222 pass, 1 fail, 0 warn, 31 skip

--- failure constructible_struct_adds_field: struct exhaustively constructible through public API adds field ---

Description:
A pub struct that could be exhaustively constructed with a literal using only public API has a new pub field, breaking existing exhaustive literals.
        ref: https://doc.rust-lang.org/reference/expressions/struct-expr.html
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.50.0/src/lints/constructible_struct_adds_field.ron

Failed in:
  field ParquetFileMetrics.bytes_processed in /home/runner/work/datafusion/datafusion/datafusion/datasource-parquet/src/metrics.rs:82

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  88.155s] datafusion-datasource-parquet
    Building datafusion-sqllogictest v54.1.0 (current)
       Built [  83.290s] (current)
     Parsing datafusion-sqllogictest v54.1.0 (current)
      Parsed [   0.019s] (current)
    Building datafusion-sqllogictest v54.1.0 (baseline)
       Built [  80.167s] (baseline)
     Parsing datafusion-sqllogictest v54.1.0 (baseline)
      Parsed [   0.020s] (baseline)
    Checking datafusion-sqllogictest v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.089s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 165.813s] datafusion-sqllogictest

claude added 2 commits August 20, 2026 15:04
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
Comment thread datafusion/datasource-parquet/src/opener/mod.rs Outdated
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
adriangb requested a balanced review from Copilot and removed request for Copilot August 20, 2026 16:33
@adriangb adriangb closed this Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core datasource documentation Improvements or additions to documentation sqllogictest

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants