Skip to content

feat: optional filters and adaptive conjunct reordering in FilterExec - #25683

Draft
adriangb wants to merge 4 commits into
apache:mainfrom
pydantic:filter-exec-adaptive
Draft

adriangb wants to merge 4 commits into
apache:mainfrom
pydantic:filter-exec-adaptive

Conversation

@adriangb

@adriangb adriangb commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

graph LR
  A1["#25673 Optional wrapper"]
  A2["#25681 producers mark filters"]
  A3["#25674 gate + config"]
  A4["#25682 Parquet consumer"]
  B1["#25683 FilterExec consumer"]
  C1["#25677 InList collapse"]
  C2["#25713 split join filter"]
  A1 --> A2
  A1 --> A3
  A3 --> A4
  A3 --> B1
  A2 --> C2
  A4 --> C2
  C1 --> C2
  classDef this fill:#f6e7d6,stroke:#b25e12,stroke-width:3px
  class B1 this
Loading

Rationale for this change

FilterExec is the second consumer of optional filters. It is also where the order of conjuncts matters most. This PR starts from the review of #22698:

#22698 review point This PR
One AND evaluator, not a second engine the result is a plain BinaryExpr AND chain
The cost model must match BinaryExpr pre-selection uses PRE_SELECTION_THRESHOLD (now pub, #[doc(hidden)])
State must not leak across executions statistics are tied to the TaskContext; reset_state clears them
Deterministic tests injectable Clock (ManualClock in tests)
No unsafe unwrap, no UInt32Array downcast removed
Smaller about 425 lines of logic, compared to 1621

What changes are included in this PR?

Part 1: optional conjuncts (datafusion.execution.optional_filter_mode, from #25674)

Mode FilterExec
always (default) the predicate is not split; today's behavior
pruning_only optional conjuncts are not evaluated (FilterExec cannot prune)
adaptive required conjuncts run as one BinaryExpr AND chain; each optional conjunct runs behind an OptionalFilterGate; the saving per removed row is optional_filter_min_saving_ns_per_row
  • Wrapped conjuncts do not feed equivalence classes or constants (Optional(a = b) is ignored by predicate analysis).
  • Metrics: optional_filter_rows_skipped, optional_filter_pauses, optional_filter_eval_time (registered only when a stream gates optional filters).

Part 2: adaptive conjunct reordering (datafusion.execution.adaptive_filter_reordering, default false)

graph LR
  W["8 warm-up batches:<br/>measure each conjunct"] --> R["rank by rows dropped per ns"]
  R --> D{"estimated cost ≥ 5% lower?"}
  D -- yes --> N["new BinaryExpr AND chain"]
  D -- no --> O["keep the original predicate"]
Loading

What is the testing strategy for this PR?

Part Tests
1 same results in all modes; a redundant optional conjunct pauses (exact pause and skip counts); a selective one never pauses; NOT(Optional(x)) is never skipped; wrapped equalities add no equivalences; statistics per context, reset by reset_state
2 9 scenario tests with a manual clock (strategy for each batch); an end-to-end test; adaptive_filter_reordering.slt

cargo test -p datafusion-physical-plan filter, the physical-plan and physical-expr lib tests, proto tests and the full sqllogictest suite pass.

Known gaps: in adaptive mode, optional conjuncts run after the required chain and are not ranked with it. Reordering decides once per stream, but a gate can change its decision, so ranking them together needs a gate-aware node in the AND chain.

Are there any user-facing changes?

One new config option (adaptive_filter_reordering, default off) and new metrics. The default behavior does not change.

🤖 Generated with Claude Code

…orrectness

Add `OptionalFilterPhysicalExpr`, a transparent wrapper that marks a filter
as optional: a consumer can skip it without changing the query result. A
consumer can skip it only when the wrapper is a direct conjunct of the root
AND chain of its predicate. In all other positions the wrapper is
transparent, because `evaluate()` always evaluates the inner expression.
`snapshot()` returns the inner expression, so pruning sees through it.

Also add:
- `split_optional`, `is_optional_filter`, `as_dynamic_filter` and
  `debug_assert_optional_on_root_chain` helpers in `physical_expr::utils`
- `PhysicalOptionalFilterNode` proto message (field 29 in
  `PhysicalExprNode`) with self-encoding `try_to_proto`/`try_from_proto`

No producer uses the wrapper yet, so there is no behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation physical-expr Changes to the physical-expr crates sqllogictest SQL Logic Tests (.slt) common Related to common crate proto Related to proto crate physical-plan Changes to the physical-plan crate labels Sep 24, 2026
@github-actions

github-actions Bot commented Sep 24, 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-common v55.1.0 (current)
       Built [  35.541s] (current)
     Parsing datafusion-common v55.1.0 (current)
      Parsed [   0.063s] (current)
    Building datafusion-common v55.1.0 (baseline)
       Built [  35.139s] (baseline)
     Parsing datafusion-common v55.1.0 (baseline)
      Parsed [   0.065s] (baseline)
    Checking datafusion-common v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.626s] 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 ExecutionOptions.optional_filter_mode in /home/runner/work/datafusion/datafusion/datafusion/common/src/config.rs:949
  field ExecutionOptions.optional_filter_max_pass_ratio in /home/runner/work/datafusion/datafusion/datafusion/common/src/config.rs:949
  field ExecutionOptions.optional_filter_min_saving_ns_per_row in /home/runner/work/datafusion/datafusion/datafusion/common/src/config.rs:949
  field ExecutionOptions.adaptive_filter_reordering in /home/runner/work/datafusion/datafusion/datafusion/common/src/config.rs:949

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  73.057s] datafusion-common
    Building datafusion-physical-expr v55.1.0 (current)
       Built [  30.399s] (current)
     Parsing datafusion-physical-expr v55.1.0 (current)
      Parsed [   0.054s] (current)
    Building datafusion-physical-expr v55.1.0 (baseline)
       Built [  30.857s] (baseline)
     Parsing datafusion-physical-expr v55.1.0 (baseline)
      Parsed [   0.050s] (baseline)
    Checking datafusion-physical-expr v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.333s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  62.644s] datafusion-physical-expr
    Building datafusion-physical-plan v55.1.0 (current)
       Built [  39.759s] (current)
     Parsing datafusion-physical-plan v55.1.0 (current)
      Parsed [   0.171s] (current)
    Building datafusion-physical-plan v55.1.0 (baseline)
       Built [  39.424s] (baseline)
     Parsing datafusion-physical-plan v55.1.0 (baseline)
      Parsed [   0.166s] (baseline)
    Checking datafusion-physical-plan v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.635s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  81.491s] datafusion-physical-plan
    Building datafusion-proto v55.1.0 (current)
       Built [  56.783s] (current)
     Parsing datafusion-proto v55.1.0 (current)
      Parsed [   0.017s] (current)
    Building datafusion-proto v55.1.0 (baseline)
       Built [  56.485s] (baseline)
     Parsing datafusion-proto v55.1.0 (baseline)
      Parsed [   0.019s] (baseline)
    Checking datafusion-proto v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.108s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 114.707s] datafusion-proto
    Building datafusion-proto-models v55.1.0 (current)
       Built [  25.715s] (current)
     Parsing datafusion-proto-models v55.1.0 (current)
      Parsed [   0.130s] (current)
    Building datafusion-proto-models v55.1.0 (baseline)
       Built [  25.773s] (baseline)
     Parsing datafusion-proto-models v55.1.0 (baseline)
      Parsed [   0.131s] (baseline)
    Checking datafusion-proto-models v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   1.850s] 223 checks: 222 pass, 1 fail, 0 warn, 31 skip

--- failure enum_variant_added: enum variant added on exhaustive enum ---

Description:
A publicly-visible enum without #[non_exhaustive] has a new variant.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#enum-variant-new
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.50.0/src/lints/enum_variant_added.ron

Failed in:
  variant ExprType:OptionalFilter in /home/runner/work/datafusion/datafusion/datafusion/proto-models/src/generated/prost.rs:1686
  variant ExprType:OptionalFilter in /home/runner/work/datafusion/datafusion/datafusion/proto-models/src/generated/prost.rs:1686

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  54.697s] datafusion-proto-models
    Building datafusion-pruning v55.1.0 (current)
       Built [  41.404s] (current)
     Parsing datafusion-pruning v55.1.0 (current)
      Parsed [   0.015s] (current)
    Building datafusion-pruning v55.1.0 (baseline)
       Built [  41.752s] (baseline)
     Parsing datafusion-pruning v55.1.0 (baseline)
      Parsed [   0.016s] (baseline)
    Checking datafusion-pruning v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.080s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  84.331s] datafusion-pruning
    Building datafusion-sqllogictest v55.1.0 (current)
       Built [ 103.321s] (current)
     Parsing datafusion-sqllogictest v55.1.0 (current)
      Parsed [   0.021s] (current)
    Building datafusion-sqllogictest v55.1.0 (baseline)
       Built [ 103.264s] (baseline)
     Parsing datafusion-sqllogictest v55.1.0 (baseline)
      Parsed [   0.023s] (baseline)
    Checking datafusion-sqllogictest v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.086s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 209.335s] datafusion-sqllogictest

@github-actions github-actions Bot added the auto detected api change Auto detected API change label Sep 24, 2026
@codecov-commenter

codecov-commenter commented Sep 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.26306% with 220 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.54%. Comparing base (95bb0a0) to head (b733afd).
⚠️ Report is 11 commits behind head on main.

Files with missing lines Patch % Lines
datafusion/proto-models/src/generated/pbjson.rs 0.00% 57 Missing ⚠️
datafusion/physical-plan/src/filter.rs 86.41% 8 Missing and 42 partials ⚠️
...afusion/physical-plan/src/filter/conjunct_order.rs 88.05% 44 Missing and 4 partials ⚠️
...n/physical-expr/src/expressions/optional_filter.rs 79.53% 17 Missing and 18 partials ⚠️
...tafusion/physical-expr/src/optional_filter_gate.rs 98.53% 7 Missing and 3 partials ⚠️
datafusion/common/src/config.rs 65.21% 7 Missing and 1 partial ⚠️
datafusion/physical-expr/src/utils/mod.rs 96.94% 1 Missing and 3 partials ⚠️
datafusion/physical-expr/src/filter_stats.rs 96.00% 3 Missing ⚠️
...al-expr/src/expressions/dynamic_filters/tracker.rs 97.64% 0 Missing and 2 partials ⚠️
datafusion/physical-expr/src/simplifier/mod.rs 88.88% 0 Missing and 2 partials ⚠️
... and 1 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #25683      +/-   ##
==========================================
+ Coverage   82.48%   82.54%   +0.05%     
==========================================
  Files        1140     1144       +4     
  Lines      437614   440482    +2868     
  Branches   437614   440482    +2868     
==========================================
+ Hits       360986   363591    +2605     
- Misses      54834    54970     +136     
- Partials    21794    21921     +127     

☔ 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 force-pushed the filter-exec-adaptive branch 2 times, most recently from 98e8970 to 50eff49 Compare September 24, 2026 06:11
adriangb and others added 2 commits September 24, 2026 10:24
Add a runtime gate that pauses optional filters (filters that are not
needed for correctness, such as hash join and TopK dynamic filters) when
they remove too few rows or when they cost more than they save. The gate
is a per-stream state machine (Evaluate / Paused with exponential
backoff) that restarts evaluation when the filter changes. The gate
finds the dynamic filters one time with `DynamicFilterTracking::classify`
and then polls their subscriptions, so a check does not walk the filter
tree. Gates of one plan site share lock-free pooled statistics (rows and
evaluation time), which seed the state of new gates.

At the end of each window of evaluated batches the gate pauses the
filter if its pass ratio is larger than `max_pass_ratio`, or if its
evaluation time is larger than the work that the removed rows save:
`(rows_in - rows_out) * saving_ns_per_row`. The saving for each row is
the configured minimum plus an optional value that the consumer measures
and updates (`MeasuredRowSaving`). The cost check has a margin (pause
above 1.1x the saving, resume below 0.9x) so that a filter does not
switch on and off when cost and saving are almost equal.

Add the `filter_stats` module with the shared measurement primitives: an
injectable monotonic `Clock` (`SystemClock`, and `ManualClock` for
deterministic tests) and `FilterCost` (rows in, rows out, time, and the
derived pass ratio, cost per row and rows removed per nanosecond).

Add a crate-private generation tag to `DynamicFilterTracker` (the sum of
the latest observed generations of the dynamic filters, including
complete ones), so the pooled statistics can tag verdicts by filter
version without calling `snapshot_generation`.

Add the `datafusion.execution.optional_filter_mode` (always | adaptive |
pruning_only, default always),
`datafusion.execution.optional_filter_max_pass_ratio` (default 0.8) and
`datafusion.execution.optional_filter_min_saving_ns_per_row` (default 20)
options. No operator uses the gate yet, so behavior does not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`FilterExec` now handles optional conjuncts (see `OptionalFilterPhysicalExpr`)
according to `datafusion.execution.optional_filter_mode`:

- `always` (default): the predicate is not split; today's behavior.
- `pruning_only`: optional conjuncts are not evaluated.
- `adaptive`: required conjuncts run as one ordinary `BinaryExpr` AND chain,
  and each optional conjunct runs behind its own `OptionalFilterGate`, so
  optional conjuncts that remove too few rows, or that cost more than they
  save, are paused. The gates measure the evaluation time; the saving for
  each removed row is `datafusion.execution.optional_filter_min_saving_ns_per_row`.

Pooled gate statistics are tied to the current `TaskContext`, so a new
execution of the same plan starts fresh, and `reset_state` clears them.
New metrics: `optional_filter_rows_skipped`, `optional_filter_pauses`,
`optional_filter_eval_time`.

Also add adaptive conjunct reordering behind
`datafusion.execution.adaptive_filter_reordering` (default `false`): each
stream measures its conjuncts over a warm-up, ranks them by rows dropped
per nanosecond, and adopts a new order (built as a plain `BinaryExpr` AND
chain) only if the estimated cost, using `BinaryExpr`'s pre-selection
rule, is at least 5% lower. The measurements use the shared `Clock` and
`FilterCost` of `datafusion_physical_expr::filter_stats`, so tests use a
`ManualClock`. New metric: `adaptive_reorders`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@adriangb
adriangb force-pushed the filter-exec-adaptive branch from 50eff49 to 8730312 Compare September 24, 2026 15:24
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 common Related to common crate documentation Improvements or additions to documentation physical-expr Changes to the physical-expr crates physical-plan Changes to the physical-plan crate proto Related to proto crate sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants