Skip to content

parquet: cache per-file ArrowReaderMetadata for wide-schema scans - #22830

Closed
adriangb wants to merge 2 commits into
apache:mainfrom
pydantic:adrian/wide-schema-arrow-metadata-cache
Closed

adriangb wants to merge 2 commits into
apache:mainfrom
pydantic:adrian/wide-schema-arrow-metadata-cache

Conversation

@adriangb

@adriangb adriangb commented Jun 8, 2026 •

Copy link
Copy Markdown
Contributor

⚠️ Green CI here does not mean this is mergeable

The second commit is a temporary [patch.crates-io] redirecting all 17 arrow-rs crates to a throwaway fork branch, because this PR uses parquet API that is not in any release yet (see Dependencies below). CI is therefore green against that fork, not against anything on crates.io. The patch must be reverted before merge. Please read the checkmarks as "the change works against the proposed API", nothing more.

Which issue does this PR close?

Rationale for this change

Building an ArrowReaderMetadata walks every leaf of the parquet schema to produce the arrow Schema and dremel field levels. For wide-schema files this O(N_columns) walk runs on every file open, once per query, even though the result is identical across queries for the same file.

For scale: at 1024 columns the footer decode is ~385 µs/file, of which ~147 µs (38%) is the schema walk this PR caches.

What changes are included in this PR?

Cache the built ArrowReaderMetadata on CachedParquetMetaData, which already lives in the file metadata cache:

  • arrow_reader_metadata() lazily builds the base metadata via parquet_to_arrow_schema_and_field_levels + from_field_levels and memoises it in a OnceLock; warm hits are a cheap Arc-bump clone.
  • coerced_arrow_reader_metadata() memoises a single post-coercion build, keyed by the supplied schema's Arc identity.
  • CachedParquetFileReader overrides AsyncFileReader::get_arrow_reader_metadata to serve both from cache.

Dependencies, and why the patch points where it does

This needs the parquet primitives in apache/arrow-rs#9882 (from_field_levels, parquet_to_arrow_schema_and_field_levels, the ArrowReaderOptions accessors, and AsyncFileReader::get_arrow_reader_metadata). That PR is open and unmerged, so the API is in no parquet release and this PR cannot compile against crates.io.

The temporary patch therefore points at a fork branch that is the parquet 59.2.0 release tag plus #9882's four commits. Deliberately not arrow-rs main: main carries post-release changes — notably arrow_schema::Metadata replacing HashMap<String, String>, plus a new arrow-cmp crate — that DataFusion has not absorbed, and which fail to compile in datafusion-common before parquet is even reached.

To merge: revert the TEMPORARY: commit once #9882 has merged and DataFusion has bumped to a parquet release containing it.

The arrow-rs-independent pieces of the #21968 investigation are split out separately: #22829 (memory_size caching, coercion early-return) and #21987 (O(1) parquet leaf lookup in statistics collection, which needs no arrow-rs change).

Are these changes tested?

Yes, now that it builds. Existing suites pass against the patched dependency: -p datafusion-datasource-parquet --lib (194), -p datafusion --test parquet_integration (218), -p datafusion --test core_integration (1064). ./ci/scripts/rust_clippy.sh and cargo fmt --check are clean.

Rebasing onto current main surfaced a genuine bug in the original commit, now fixed: get_arrow_reader_metadata fetched metadata without propagating the page-index policy from options, so a caller that asked to skip the page index got it fetched anyway and the cached entry was polluted with it. test_page_index_skipped_with_cached_reader_factory catches this. It matters more than it sounds on the target workload — at 1024 columns the page index is 424 KiB/file and ~37% of total metadata decode.

No new tests are added for the caching itself; that gap is worth closing before this leaves draft.

Are there any user-facing changes?

No public API changes — CachedParquetMetaData gains internal caching fields and methods.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Jun 8, 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-datasource-parquet v54.1.0 (current)
error: running cargo-doc on crate 'datafusion-datasource-parquet' failed with output:
-----
   Compiling proc-macro2 v1.0.107
   Compiling unicode-ident v1.0.24
   Compiling quote v1.0.47
   Compiling libc v0.2.189
    Checking cfg-if v1.0.4
    Checking memchr v2.8.3
   Compiling autocfg v1.5.1
   Compiling libm v0.2.16
   Compiling num-traits v0.2.19
   Compiling syn v2.0.119
   Compiling syn v3.0.3
   Compiling zerocopy v0.8.56
    Checking bytes v1.12.1
    Checking allocator-api2 v0.2.21
    Checking foldhash v0.2.0
    Checking itoa v1.0.18
    Checking equivalent v1.0.2
    Checking once_cell v1.21.4
   Compiling serde_core v1.0.229
   Compiling getrandom v0.3.4
    Checking hashbrown v0.17.1
   Compiling zmij v1.0.23
    Checking indexmap v2.14.0
   Compiling serde_json v1.0.151
    Checking num-integer v0.1.46
   Compiling version_check v0.9.5
    Checking siphasher v1.0.3
    Checking stable_deref_trait v1.2.1
    Checking iana-time-zone v0.1.65
   Compiling synstructure v0.13.2
    Checking chrono v0.4.45
    Checking phf_shared v0.12.1
   Compiling ahash v0.8.12
    Checking num-bigint v0.5.1
   Compiling chrono-tz v0.10.4
    Checking arrow-schema v59.2.0
   Compiling zerocopy-derive v0.8.56
   Compiling zerofrom-derive v0.1.7
   Compiling yoke-derive v0.8.2
   Compiling zerovec-derive v0.11.3
   Compiling jobserver v0.1.35
    Checking zerofrom v0.1.8
    Checking phf v0.12.1
    Checking yoke v0.8.3
   Compiling shlex v2.0.1
   Compiling find-msvc-tools v0.1.10
    Checking zerovec v0.11.6
   Compiling cc v1.4.2
   Compiling displaydoc v0.2.7
    Checking num-complex v0.4.6
    Checking tinystr v0.8.3
    Checking smallvec v1.15.2
   Compiling pkg-config v0.3.33
    Checking lexical-util v1.0.7
    Checking writeable v0.6.3
    Checking pin-project-lite v0.2.17
    Checking litemap v0.8.2
   Compiling zstd-sys v2.0.16+zstd.1.5.7
    Checking icu_locale_core v2.2.0
    Checking zerotrie v0.2.4
    Checking potential_utf v0.1.5
    Checking futures-core v0.3.33
    Checking utf8_iter v1.0.4
    Checking futures-sink v0.3.33
   Compiling icu_properties_data v2.2.0
   Compiling icu_normalizer_data v2.2.0
    Checking icu_provider v2.2.0
    Checking icu_collections v2.2.0
   Compiling semver v1.0.28
   Compiling rustc_version v0.4.1
    Checking half v2.7.1
    Checking futures-channel v0.3.33
    Checking arrow-buffer v59.2.0
    Checking lexical-write-integer v1.0.6
    Checking lexical-parse-integer v1.0.6
   Compiling futures-macro v0.3.33
    Checking arrow-data v59.2.0
    Checking futures-io v0.3.33
    Checking futures-task v0.3.33
    Checking bitflags v2.13.1
    Checking slab v0.4.12
   Compiling zstd-safe v7.2.4
    Checking arrow-array v59.2.0
   Compiling parking_lot_core v0.9.12
    Checking futures-util v0.3.33
    Checking lexical-parse-float v1.0.6
    Checking lexical-write-float v1.0.6
    Checking icu_normalizer v2.2.0
    Checking icu_properties v2.2.0
    Checking arrow-select v59.2.0
   Compiling flatbuffers v25.12.19
   Compiling tokio-macros v2.7.2
    Checking aho-corasick v1.1.5
    Checking ryu v1.0.23
    Checking unicode-width v0.2.2
    Checking base64 v0.23.1
    Checking regex-syntax v0.8.11
    Checking scopeguard v1.2.0
    Checking unicode-segmentation v1.13.3
    Checking comfy-table v7.2.2
    Checking lock_api v0.4.14
    Checking tokio v1.53.1
    Checking arrow-ord v59.2.0
    Checking idna_adapter v1.2.2
    Checking lexical-core v1.0.6
    Checking regex-automata v0.4.18
    Checking atoi v2.0.0
   Compiling thiserror v2.0.20
    Checking percent-encoding v2.3.2
   Compiling getrandom v0.4.3
    Checking either v1.17.0
    Checking twox-hash v2.1.3
    Checking alloc-no-stdlib v2.0.4
    Checking alloc-stdlib v0.2.4
    Checking lz4_flex v0.14.0
    Checking form_urlencoded v1.2.2
    Checking arrow-cast v59.2.0
    Checking regex v1.13.1
    Checking idna v1.1.0
    Checking futures-executor v0.3.33
   Compiling ring v0.17.14
   Compiling tracing-attributes v0.1.31
   Compiling thiserror-impl v2.0.20
    Checking tracing-core v0.1.36
    Checking csv-core v0.1.13
   Compiling snap v1.1.2
    Checking simdutf8 v0.1.5
    Checking same-file v1.0.6
    Checking walkdir v2.5.0
    Checking tracing v0.1.44
    Checking csv v1.4.0
    Checking futures v0.3.33
    Checking url v2.5.8
    Checking brotli-decompressor v5.0.3
    Checking itertools v0.14.0
    Checking parking_lot v0.12.5
   Compiling async-trait v0.1.92
    Checking http v1.5.0
    Checking getrandom v0.2.17
    Checking zlib-rs v0.6.7
    Checking untrusted v0.9.0
    Checking humantime v2.4.0
   Compiling anyhow v1.0.104
    Checking object_store v0.13.2
    Checking flate2 v1.1.9
    Checking brotli v8.0.4
    Checking arrow-csv v59.2.0
    Checking arrow-json v59.2.0
    Checking arrow-string v59.2.0
    Checking arrow-arith v59.2.0
    Checking arrow-row v59.2.0
   Compiling seq-macro v0.3.6
    Checking log v0.4.33
    Checking uuid v1.24.0
    Checking itertools v0.15.0
    Checking hex v0.4.3
   Compiling prost-derive v0.14.4
   Compiling pin-project-internal v1.1.13
    Checking pin-project v1.1.13
   Compiling rustix v1.1.4
   Compiling crossbeam-utils v0.8.22
    Checking prost v0.14.4
    Checking ppv-lite86 v0.2.21
    Checking rand_core v0.9.5
    Checking linux-raw-sys v0.12.1
    Checking datafusion-doc v54.1.0 (/home/runner/work/datafusion/datafusion/datafusion/doc)
    Checking rand_chacha v0.9.0
    Checking foldhash v0.1.5
    Checking fastrand v2.5.0
    Checking hashbrown v0.14.5
    Checking dashmap v6.2.1
    Checking hashbrown v0.15.5
    Checking tempfile v3.27.0
    Checking rand v0.9.5
    Checking tokio-util v0.7.19
    Checking fixedbitset v0.5.7
    Checking petgraph v0.8.3
   Compiling datafusion-macros v54.1.0 (/home/runner/work/datafusion/datafusion/datafusion/macros)
    Checking datafusion-common-runtime v54.1.0 (/home/runner/work/datafusion/datafusion/datafusion/common-runtime)
    Checking glob v0.3.4
    Checking zstd v0.13.3
    Checking arrow-ipc v59.2.0
    Checking arrow v59.2.0
    Checking parquet v59.2.0
    Checking datafusion-common v54.1.0 (/home/runner/work/datafusion/datafusion/datafusion/common)
    Checking datafusion-proto-common v54.1.0 (/home/runner/work/datafusion/datafusion/datafusion/proto-common)
    Checking datafusion-expr-common v54.1.0 (/home/runner/work/datafusion/datafusion/datafusion/expr-common)
    Checking datafusion-proto-models v54.1.0 (/home/runner/work/datafusion/datafusion/datafusion/proto-models)
    Checking datafusion-physical-expr-common v54.1.0 (/home/runner/work/datafusion/datafusion/datafusion/physical-expr-common)
    Checking datafusion-functions-window-common v54.1.0 (/home/runner/work/datafusion/datafusion/datafusion/functions-window-common)
    Checking datafusion-functions-aggregate-common v54.1.0 (/home/runner/work/datafusion/datafusion/datafusion/functions-aggregate-common)
    Checking datafusion-expr v54.1.0 (/home/runner/work/datafusion/datafusion/datafusion/expr)
    Checking datafusion-execution v54.1.0 (/home/runner/work/datafusion/datafusion/datafusion/execution)
    Checking datafusion-physical-expr v54.1.0 (/home/runner/work/datafusion/datafusion/datafusion/physical-expr)
    Checking datafusion-functions v54.1.0 (/home/runner/work/datafusion/datafusion/datafusion/functions)
    Checking datafusion-physical-plan v54.1.0 (/home/runner/work/datafusion/datafusion/datafusion/physical-plan)
    Checking datafusion-physical-expr-adapter v54.1.0 (/home/runner/work/datafusion/datafusion/datafusion/physical-expr-adapter)
    Checking datafusion-session v54.1.0 (/home/runner/work/datafusion/datafusion/datafusion/session)
    Checking datafusion-datasource v54.1.0 (/home/runner/work/datafusion/datafusion/datafusion/datasource)
    Checking datafusion-pruning v54.1.0 (/home/runner/work/datafusion/datafusion/datafusion/pruning)
 Documenting datafusion-datasource-parquet v54.1.0 (/home/runner/work/datafusion/datafusion/datafusion/datasource-parquet)
error[E0432]: unresolved import `parquet::arrow::parquet_to_arrow_schema_and_field_levels`
  --> /home/runner/work/datafusion/datafusion/datafusion/datasource-parquet/src/metadata.rs:47:5
   |
47 |     parquet_to_arrow_schema_and_field_levels,
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ no `parquet_to_arrow_schema_and_field_levels` in `arrow`
   |
help: a similar name exists in the module
   |
47 -     parquet_to_arrow_schema_and_field_levels,
47 +     parquet_to_arrow_field_levels,
   |

error[E0407]: method `get_arrow_reader_metadata` is not a member of trait `AsyncFileReader`
   --> /home/runner/work/datafusion/datafusion/datafusion/datasource-parquet/src/reader.rs:302:5
    |
302 | /     fn get_arrow_reader_metadata<'a>(
303 | |         &'a mut self,
304 | |         options: ArrowReaderOptions,
305 | |     ) -> BoxFuture<'a, parquet::errors::Result<ArrowReaderMetadata>> {
...   |
398 | |         .boxed()
399 | |     }
    | |_____^ not a member of trait `AsyncFileReader`

Some errors have detailed explanations: E0407, E0432.
For more information about an error, try `rustc --explain E0407`.
error: could not document `datafusion-datasource-parquet`

-----

error: failed to build rustdoc for crate datafusion-datasource-parquet v54.1.0
note: this is usually due to a compilation error in the crate,
      and is unlikely to be a bug in cargo-semver-checks
note: the following command can be used to reproduce the error:
      cargo new --lib example &&
          cd example &&
          echo '[workspace]' >> Cargo.toml &&
          cargo add --path /home/runner/work/datafusion/datafusion/datafusion/datasource-parquet --features parquet_encryption,proto &&
          cargo check &&
          cargo doc

error: aborting due to failure to build rustdoc for crate datafusion-datasource-parquet v54.1.0

@github-actions github-actions Bot added the auto detected api change Auto detected API change label Jun 8, 2026
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

Thank you for your contribution. Unfortunately, this pull request is stale because it has been open 60 days with no activity. Please remove the stale label or comment or this will be closed in 7 days.

@github-actions github-actions Bot added the Stale PR has not had any activity for some time label Aug 8, 2026
adriangb and others added 2 commits August 9, 2026 08:42
Building an `ArrowReaderMetadata` walks every leaf of the parquet schema
to produce the arrow `Schema` + dremel field levels. For wide-schema
files this O(N_columns) walk runs on every file open, once per query,
even though the result is identical across queries for the same file.

Cache it on `CachedParquetMetaData` (which already lives in the file
metadata cache):

- `arrow_reader_metadata()` lazily builds the base
  `ArrowReaderMetadata` via `parquet_to_arrow_schema_and_field_levels` +
  `ArrowReaderMetadata::from_field_levels` and memoises it in a
  `OnceLock`; warm hits are a cheap `Arc`-bump clone.
- `coerced_arrow_reader_metadata()` memoises a single post-coercion
  build keyed by the supplied schema's `Arc` identity (the common case:
  every file coerces to the same table schema).
- `CachedParquetFileReader` overrides
  `AsyncFileReader::get_arrow_reader_metadata` to serve both from cache,
  falling back to a fetch + `try_new` on miss.

Depends on the arrow-rs primitives in apache/arrow-rs#9882
(`from_field_levels`, `parquet_to_arrow_schema_and_field_levels`,
`ArrowReaderOptions` accessors, `AsyncFileReader::get_arrow_reader_metadata`).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This PR uses parquet API that only exists on apache/arrow-rs#9882, which
has not merged and so is in no parquet release. Without a patch the
workspace does not compile and CI cannot say anything useful about the
change.

The patch points at a throwaway branch that is the parquet 59.2.0 release
tag plus apache#9882's four commits — deliberately not arrow-rs `main`, which
has post-release changes (notably `arrow_schema::Metadata` replacing
`HashMap<String, String>`) that DataFusion has not absorbed and which
break `datafusion-common` before parquet is even reached.

Revert this commit once apache#9882 has merged and DataFusion has bumped to a
parquet release containing it. Green CI on this PR means "the change
works against that API", not "this is mergeable".

Co-Authored-By: Claude Opus 5 <[email protected]>
@adriangb
adriangb force-pushed the adrian/wide-schema-arrow-metadata-cache branch from 277613a to 0bf8ca6 Compare August 9, 2026 12:42
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 66.10169% with 40 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.05%. Comparing base (918013e) to head (0bf8ca6).

Files with missing lines Patch % Lines
datafusion/datasource-parquet/src/metadata.rs 55.55% 20 Missing ⚠️
datafusion/datasource-parquet/src/reader.rs 72.60% 16 Missing and 4 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #22830      +/-   ##
==========================================
- Coverage   81.06%   81.05%   -0.02%     
==========================================
  Files        1106     1106              
  Lines      381891   381987      +96     
  Branches   381891   381987      +96     
==========================================
+ Hits       309578   309612      +34     
- Misses      54034    54090      +56     
- Partials    18279    18285       +6     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@adriangb

adriangb commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

run benchmark wide_schema

@adriangbot

Copy link
Copy Markdown

🤖 Benchmark running (GKE) | trigger
Instance: c4a-highmem-16 (12 vCPU / 65 GiB) | Linux bench-c5231693037-1507-266gl 6.12.85+ #1 SMP Wed Jun 17 20:31:55 UTC 2026 aarch64 GNU/Linux

CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
Socket(s):                               -
Cluster(s):                              1
Stepping:                                r0p1
BogoMIPS:                                2000.00
Flags:                                   fp asimd evtstrm aes pmull sha1 sha2 crc32 atomics fphp asimdhp cpuid asimdrdm jscvt fcma lrcpc dcpop sha3 sm3 sm4 asimddp sha512 sve asimdfhm dit uscat ilrcpc flagm sb paca pacg dcpodp sve2 sveaes svepmull svebitperm svesha3 svesm4 flagm2 frint svei8mm svebf16 i8mm bf16 dgh rng bti
L1d cache:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
Vulnerability Gather data sampling:      Not affected
Vulnerability Indirect target selection: Not affected
Vulnerability Itlb multihit:             Not affected
Vulnerability L1tf:                      Not affected
Vulnerability Mds:                       Not affected
Vulnerability Meltdown:                  Not affected
Vulnerability Mmio stale data:           Not affected
Vulnerability Reg file data sampling:    Not affected
Vulnerability Retbleed:                  Not affected
Vulnerability Spec rstack overflow:      Not affected
Vulnerability Spec store bypass:         Mitigation; Speculative Store Bypass disabled via prctl
Vulnerability Spectre v1:                Mitigation; __user pointer sanitization
Vulnerability Spectre v2:                Mitigation; CSV2, BHB
Vulnerability Srbds:                     Not affected
Vulnerability Tsa:                       Not affected
Vulnerability Tsx async abort:           Not affected
Vulnerability Vmscape:                   Not affected

Comparing adrian/wide-schema-arrow-metadata-cache (0bf8ca6) to 918013e (merge-base) diff

Run configuration
run benchmark wide_schema

Results will be posted here when complete


File an issue against this benchmark runner

@github-actions github-actions Bot removed the Stale PR has not had any activity for some time label Aug 10, 2026
@github-actions

Copy link
Copy Markdown

Thank you for your contribution. Unfortunately, this pull request is stale because it has been open 60 days with no activity. Please remove the stale label or comment or this will be closed in 7 days.

@github-actions github-actions Bot added the Stale PR has not had any activity for some time label Oct 10, 2026
adriangb added a commit to pydantic/datafusion that referenced this pull request Oct 16, 2026
…on (apache#26355)

## Which issue does this PR close?

- Part of apache#21968.

This revives apache#21987 by @adriangb, which was closed while stale and never
reviewed. The two commits are his, rebased onto current `main`. The fix
was never carried into apache#22829 or apache#22830, and the quadratic lookup is
still on `main`.

## Rationale for this change

Planning a query against a wide-schema Parquet table takes time
quadratic in the number of columns, even when the query reads only one
or two of them.

`DFParquetMetadata::statistics_from_parquet_metadata` resolves every
field in the logical file schema to a Parquet leaf column. Both branches
resolve each field with an O(N) lookup:

- When the file has statistics, it calls `StatisticsConverter::try_new`.
That does a linear `Schema::column_with_name`, then calls
`parquet_column`, which does a linear `Fields::find` and a linear scan
for the first leaf under the matched root.
- When the file has no statistics, it calls `parquet_column` directly.

This happens once per field, so the cost is O(N²) per file, on every
query plan, for every file in the table.

## What changes are included in this PR?

- A new crate-private `LeafResolver`
(`datafusion/datasource-parquet/src/leaf_resolver.rs`). It builds two
maps once per file: Arrow field name to root index, and Parquet root
index to first leaf index. Both call sites use it in place of the
per-field linear lookups, which makes the statistics pass O(N) per file.
- `LeafResolver` gives the same results as `parquet_column`, including
its tie-breaks: the first Arrow field with a given name wins, and the
lowest leaf index for a root wins. When a leaf exists, it builds the
converter with `StatisticsConverter::from_column_index`, which yields
the same converter `try_new` would (same leaf index, same Arrow field,
same physical type).
- A field with no Parquet leaf (a nested type, or a name missing from
the file schema) falls back to `StatisticsConverter::try_new`. This
reproduces its exact error for an unknown column, and it is needed
because `from_column_index` cannot express "this column has no Parquet
leaf".
- The `parquet_metadata_statistics` benchmark now also covers 1024
columns, because the quadratic term only shows up past a few hundred
columns.

Rebasing onto `main` conflicted in `statistics_from_parquet_metadata`.
The resolution keeps every change made on `main` since apache#21987, including
the omitted-null-count handling and the untrusted min/max order checks,
and changes only how each field's converter is built.

**Known limitation:** schemas with many nested fields still take the
O(N) fallback for those fields. apache/arrow-rs#9882 proposes a
constructor that takes an `Option<usize>` leaf, which would remove the
fallback. Flat wide schemas never take it.

## What is the testing strategy for this PR?

- Seven unit tests in `leaf_resolver.rs`. Five of them check, for every
field name plus some absent names, that `LeafResolver::leaf_index`
returns exactly what `parquet_column` returns and that
`LeafResolver::converter` matches `StatisticsConverter::try_new` (same
leaf index, same Arrow field, same error text). The tie-break rules are
the risky part: getting either backwards would silently attribute one
column's statistics to another, and nothing in the existing suite covers
duplicate names. The other two check specific leaf indices, and that a
duplicated name uses the first field's type. The schemas cover:
- primitives, a nested struct, a nested list, and a duplicate field name
with a different type from the first
- a primitive Arrow field over a multi-leaf Parquet group (lowest leaf
wins)
  - a Parquet root with no leaves (an empty group)
  - more Arrow fields than Parquet roots
  - an empty schema
- No `.slt` test: this changes how columns are looked up, not the
statistics produced, so the unit tests compare directly against
`parquet_column` and `try_new`.
- Existing suites: `-p datafusion-datasource-parquet --lib`, `-p
datafusion --test parquet_integration`, and the `parquet_statistics`,
`listing_table_statistics`, `parquet_sorted_statistics` and
`parquet_nested_schema_pruning` sqllogictest files.

### Benchmarks

`wide_schema` (1024 vs 8 columns × 256 files), run locally on an AMD
Ryzen 9 9950X3D. These are Criterion medians, `main` (e732212) against
this branch:

| Query | main | this PR | change |
|---|---:|---:|---:|
| Q01 wide | 694.5 ms | 522.9 ms | −24.7% |
| Q02 wide | 528.0 ms | 350.3 ms | −33.7% |
| Q03 wide | 529.2 ms | 353.4 ms | −33.2% |
| Q04 wide | 617.2 ms | 445.5 ms | −27.8% |
| Q01–Q04 narrow | — | — | within ±0.5% |

For every wide query the confidence intervals do not overlap. The two
sides ran one after the other on the same machine, not interleaved.

## Are there any user-facing changes?

No. `LeafResolver` is crate-private and the statistics produced are
unchanged. Planning against wide-schema Parquet tables is faster.

---

This PR was prepared with AI assistance (Claude Code).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Adrian Garcia Badaracco <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
alamb pushed a commit to rxdrccq/datafusion that referenced this pull request Oct 16, 2026
…on (apache#26355)

## Which issue does this PR close?

- Part of apache#21968.

This revives apache#21987 by @adriangb, which was closed while stale and never
reviewed. The two commits are his, rebased onto current `main`. The fix
was never carried into apache#22829 or apache#22830, and the quadratic lookup is
still on `main`.

## Rationale for this change

Planning a query against a wide-schema Parquet table takes time
quadratic in the number of columns, even when the query reads only one
or two of them.

`DFParquetMetadata::statistics_from_parquet_metadata` resolves every
field in the logical file schema to a Parquet leaf column. Both branches
resolve each field with an O(N) lookup:

- When the file has statistics, it calls `StatisticsConverter::try_new`.
That does a linear `Schema::column_with_name`, then calls
`parquet_column`, which does a linear `Fields::find` and a linear scan
for the first leaf under the matched root.
- When the file has no statistics, it calls `parquet_column` directly.

This happens once per field, so the cost is O(N²) per file, on every
query plan, for every file in the table.

## What changes are included in this PR?

- A new crate-private `LeafResolver`
(`datafusion/datasource-parquet/src/leaf_resolver.rs`). It builds two
maps once per file: Arrow field name to root index, and Parquet root
index to first leaf index. Both call sites use it in place of the
per-field linear lookups, which makes the statistics pass O(N) per file.
- `LeafResolver` gives the same results as `parquet_column`, including
its tie-breaks: the first Arrow field with a given name wins, and the
lowest leaf index for a root wins. When a leaf exists, it builds the
converter with `StatisticsConverter::from_column_index`, which yields
the same converter `try_new` would (same leaf index, same Arrow field,
same physical type).
- A field with no Parquet leaf (a nested type, or a name missing from
the file schema) falls back to `StatisticsConverter::try_new`. This
reproduces its exact error for an unknown column, and it is needed
because `from_column_index` cannot express "this column has no Parquet
leaf".
- The `parquet_metadata_statistics` benchmark now also covers 1024
columns, because the quadratic term only shows up past a few hundred
columns.

Rebasing onto `main` conflicted in `statistics_from_parquet_metadata`.
The resolution keeps every change made on `main` since apache#21987, including
the omitted-null-count handling and the untrusted min/max order checks,
and changes only how each field's converter is built.

**Known limitation:** schemas with many nested fields still take the
O(N) fallback for those fields. apache/arrow-rs#9882 proposes a
constructor that takes an `Option<usize>` leaf, which would remove the
fallback. Flat wide schemas never take it.

## What is the testing strategy for this PR?

- Seven unit tests in `leaf_resolver.rs`. Five of them check, for every
field name plus some absent names, that `LeafResolver::leaf_index`
returns exactly what `parquet_column` returns and that
`LeafResolver::converter` matches `StatisticsConverter::try_new` (same
leaf index, same Arrow field, same error text). The tie-break rules are
the risky part: getting either backwards would silently attribute one
column's statistics to another, and nothing in the existing suite covers
duplicate names. The other two check specific leaf indices, and that a
duplicated name uses the first field's type. The schemas cover:
- primitives, a nested struct, a nested list, and a duplicate field name
with a different type from the first
- a primitive Arrow field over a multi-leaf Parquet group (lowest leaf
wins)
  - a Parquet root with no leaves (an empty group)
  - more Arrow fields than Parquet roots
  - an empty schema
- No `.slt` test: this changes how columns are looked up, not the
statistics produced, so the unit tests compare directly against
`parquet_column` and `try_new`.
- Existing suites: `-p datafusion-datasource-parquet --lib`, `-p
datafusion --test parquet_integration`, and the `parquet_statistics`,
`listing_table_statistics`, `parquet_sorted_statistics` and
`parquet_nested_schema_pruning` sqllogictest files.

### Benchmarks

`wide_schema` (1024 vs 8 columns × 256 files), run locally on an AMD
Ryzen 9 9950X3D. These are Criterion medians, `main` (e732212) against
this branch:

| Query | main | this PR | change |
|---|---:|---:|---:|
| Q01 wide | 694.5 ms | 522.9 ms | −24.7% |
| Q02 wide | 528.0 ms | 350.3 ms | −33.7% |
| Q03 wide | 529.2 ms | 353.4 ms | −33.2% |
| Q04 wide | 617.2 ms | 445.5 ms | −27.8% |
| Q01–Q04 narrow | — | — | within ±0.5% |

For every wide query the confidence intervals do not overlap. The two
sides ran one after the other on the same machine, not interleaved.

## Are there any user-facing changes?

No. `LeafResolver` is crate-private and the statistics produced are
unchanged. Planning against wide-schema Parquet tables is faster.

---

This PR was prepared with AI assistance (Claude Code).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Adrian Garcia Badaracco <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
@github-actions github-actions Bot closed this Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto detected api change Auto detected API change datasource Changes to the datasource crate Stale PR has not had any activity for some time

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants