Skip to content

feat(parquet): support page-level limit pruning - #25000

Open
peterxcli wants to merge 1 commit into
apache:mainfrom
peterxcli:feat/page-level-lmiit-pruning
Open

feat(parquet): support page-level limit pruning#25000
peterxcli wants to merge 1 commit into
apache:mainfrom
peterxcli:feat/page-level-lmiit-pruning

Conversation

@peterxcli

@peterxcli peterxcli commented Sep 6, 2026

Copy link
Copy Markdown
Member

Which issue does this PR close?

Rationale for this change

Row-group LIMIT pruning cannot skip partially matching row groups even when a few pages within them contain enough guaranteed matches. For example, with pages [0, 10, 0], [5, 6, 7], and [0, 8, 0], WHERE a >= 5 LIMIT 3 can read only the middle page.

This extends the row-group optimization introduced in #18868 to page ranges for scans that do not need to preserve order.

What changes are included in this PR?

  • Share the null-safe inverse-predicate construction between row-group and page pruning in a private Parquet module.
  • Use page statistics to prove fully matching row ranges, intersect them across all supported conjuncts and existing row selections, and combine them with fully matched row groups.
  • Rewrite the access plan only when the guaranteed rows satisfy the limit. Preserve existing full-match flags and retain the ordinary plan when proofs are unavailable or insufficient.
  • Disable the page-level LIMIT rewrite for order-preserving scans, including when the optimizer removes an already-satisfied sort.
  • Add and document the lazily registered limit_pruned_rows metric.
  • Fix a pre-existing all-features Clippy warning in the PostgreSQL test helper by borrowing its decimal argument.

What is the testing strategy for this PR?

The Parquet integration tests cover selecting a fully matched page, combining pages and row groups, NULLs, unsupported conjuncts, insufficient guaranteed rows, missing page indexes, and an eliminated ORDER BY sort. Unit tests cover existing row selections, full-match flags, and the opener's order-preservation gate.

The full-match assertion was verified to fail before its fix. Temporarily removing the page-level order guard also makes the SQL ordering regression fail with different row IDs.

Local validation:

  • 95 page-pruning integration tests passed.
  • 16 row-group filter unit tests passed, along with the access-plan and opener ordering regressions.
  • cargo fmt --all.
  • cargo clippy --all-targets --all-features -- -D warnings.
  • ./dev/rust_lint.sh.

Performance benchmarks are not yet available. The inverse-statistics pass adds work when it cannot prove enough matches, so both successful-pruning and no-benefit cases need measurement; the tests demonstrate pruning behavior, not a measured speedup.

Are there any user-facing changes?

Eligible unordered Parquet LIMIT queries can skip additional row ranges. EXPLAIN ANALYZE reports limit_pruned_rows when this optimization removes rows. No new configuration or public API is introduced.

@github-actions github-actions Bot added documentation Improvements or additions to documentation physical-expr Changes to the physical-expr crates core Core DataFusion crate sqllogictest SQL Logic Tests (.slt) datasource Changes to the datasource crate labels Sep 6, 2026
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.13408% with 21 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.68%. Comparing base (262936e) to head (67a5a6d).

Files with missing lines Patch % Lines
datafusion/datasource-parquet/src/page_filter.rs 92.69% 16 Missing and 3 partials ⚠️
datafusion/datasource-parquet/src/pruning.rs 93.93% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##             main   #25000    +/-   ##
========================================
  Coverage   81.67%   81.68%            
========================================
  Files        1126     1127     +1     
  Lines      414842   415125   +283     
  Branches   414842   415125   +283     
========================================
+ Hits       338841   339101   +260     
- Misses      56070    56085    +15     
- Partials    19931    19939     +8     

☔ 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.

@xudong963
xudong963 self-requested a review September 7, 2026 02:41

@jayzhan211 jayzhan211 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @peterxcli, here is a suggestion:

The inverted-predicate pass at page_filter.rs:384-414 never short-circuits, at either level.

Per row group. The forward loop 30 lines above breaks as soon as the running selection stops selecting anything:

if !selects_any {
    break;
}

The inverted loop has no equivalent. update_selection is an intersection, so once fully_matched_selection is empty it can never become non-empty again — but every remaining conjunct is still fully evaluated.

Per file. The loop keeps computing inverted selections for every remaining row group even after enough guaranteed rows exist to satisfy limit. limit_pruned_plan stops greedily at candidate_rows >= limit, but only after all of the work has already been paid for.

Each prune_pages_for_predicate call builds a StatisticsConverter, materializes page min/max/null-count arrays, evaluates the pruning expression and builds a RowSelection — that's the bulk of page-index pruning cost. So this roughly doubles page_index_eval_time for every WHERE … LIMIT parquet query (page index is on by default), scaling with row-group count even when the first row group already proves the limit, or when nothing in the file is ever fully matched.

The per-row-group half is a two-line fix and mirrors the existing pattern exactly. complete stays true, so an empty selection is stored and limit_pruned_plan skips the row group via its existing rows == 0 check — same outcome, less work:

                     fully_matched_selection = update_selection(
                         fully_matched_selection,
                         complement_selection(selection),
                     );
+
+                    // Intersection is monotone: once nothing is selected, no
+                    // later conjunct can add rows back.
+                    if !fully_matched_selection
+                        .as_ref()
+                        .is_some_and(|s| s.selects_any())
+                    {
+                        break;
+                    }
                 }

For the per-file half, track the guaranteed rows as you go and stop once the limit is covere shape limit_pruned_plan does, so the two agree:

let mut guaranteed_rows = 0usize;

// in the `is_fully_matched(row_group_index)` branch, before `continue`:
guaranteed_rows += selected_rows_in_access(
    &access_plan.inner()[row_group_index],
    &groups[row_group_index],
);

// gate the inverted block:
if guaranteed_rows < limit.unwrap_or(0)
    && access_plan.should_scan(row_group_index)
    && let Some(inverted_predicates) = &inverted_predicates
{
    ...
    // once `complete`, add the rows this row group contributes, using the
    // same `Scan` / `Selection` intersection `limit_pruned_plan` applies
}

Given the effort already spent avoiding exactly this class of work elsewhere in the opener page_index_load_skipped, the lazily-registered counters), it'd be good to have this inbefore merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Core DataFusion crate datasource Changes to the datasource crate documentation Improvements or additions to documentation physical-expr Changes to the physical-expr crates sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support page level limit pruning [EPIC] Support limit pruning

3 participants