Repository navigation
Conversation
…oned-join filters
This was referenced Oct 31, 2026
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.
Builds on #8; review that first.
What and why
When DataFusion plans a partitioned hash join, which is the usual plan for large and distributed joins, the dynamic filter it pushes into the probe-side scan is a routing
CASEexpression. Until now, Iceberg scans got nothing out of it:CASE;CASEagainst statistics either.So the probe side of a partitioned join read every row group of every file. With this change, both paths prune with these filters. Query results are unchanged.
The CASE shape
A partitioned hash join builds one filter per build partition and routes each probe row to its partition's filter:
Each
Fiholds that partition's bounds (key >= min AND key <= max) and its membership test (an IN list, or a hash lookup for large builds). With range partitioning the shape isCASE range_partition(keys) WHEN i THEN Fi ... ELSE Fn END. Joins that need probe-side NULLs wrap the CASE in... OR key IS NULL. When only one build partition has rows, DataFusion skips the CASE and pushes that partition's filter directly.In a distributed query, the coordinator merges the partitioned-join filters reported by every producer task with OR, so workers receive an OR of per-task CASE expressions.
Why the OR of the branches is safe
A CASE evaluates, for each row, exactly one of its THEN branches or its ELSE. If the CASE is true for a row, the branch that row took is true for it, so the OR of all THEN branches and the ELSE is true too. That holds whatever the base and WHEN expressions are, even when their values can't be listed or evaluated against statistics.
The OR can match rows the CASE doesn't, because a row is checked against every partition's filter instead of only its own. It never misses a row the CASE keeps. A missing ELSE yields NULL, which never passes a filter, so it is left out.
Conversion rules
Every converted subtree is tracked as exact or approximate. A predicate is approximate when it may match more rows than the expression; it never matches fewer.
ELSE truegives always-true, so there is nothing to prune;falsebranches drop out of the OR.NOT (... OR <approximate>)therefore still doesn't convert.How both pruning paths use it
iceberg/src/common/case_as_or.rsholds the shared rewrite:case_branches_disjunctionbuilds the OR of a CASE's branches.rewrite_case_as_orapplies it to every CASE reachable from the root through AND and OR, including CASEs nested in the branches. AND and OR only ever widen when an operand widens, so the result still matches every row the original keeps. A CASE anywhere else, for example under NOT, is left alone.Row groups and rows.
physical_expr_to_predicatetreats a CASE ascase_branches_disjunction(case)marked approximate. The runtime predicate provider from #6 publishes that predicate to the Iceberg reader as before.Whole files.
PartitionFilePrunerused to hand the dynamic filter to DataFusion'sFilePruner, which takes its own snapshot of the filter and can't accept a rewritten expression. Now it:rewrite_case_as_or;PruningPredicatefrom the result;The built predicate is cached and rebuilt only when the filters' generation changes, which reproduces the change tracking
FilePrunerdid internally. A comment in the code explains this.Both paths keep the existing behavior otherwise: same single flag,
iceberg.dynamic_filter_pruning_enabled; nothing waits for a filter; only dynamic filters are used; any error keeps the data; and theFilterExecabove the scan still applies every filter.Reused from DataFusion
DataFusion 55 has no simplification or pruning support for CASE, so the CASE-to-OR rewrite is new. Everything else is DataFusion's:
PruningPredicateBuilderandPruningPredicatefor file-level pruning;PrunableStatisticsto present a file's statistics;snapshot_physical_exprandsnapshot_generationto read the dynamic filters and detect changes;CaseExpr's accessors to read the branches.Tests
Converter unit tests, each showing the predicate produced:
ELSE false;ELSE true, which gives always-true;... OR key IS NULLwrapping;NOTof an approximate OR or of a CASE, which doesn't convert.A unit test for the file pruner uses one pruner throughout. It checks a file, updates the dynamic filter to a CASE, and shows the decision change. It then shows that an unchanged generation reuses the cached predicate, and that a later update changes the decision again.
Integration tests on the 7-day taxi table (one file per day), with
target_partitions = 4and bothhash_join_single_partition_thresholdsettings at 0 so the join is partitioned. The plan is asserted to use a partitioned join. The build side selects two days that hash to different build partitions, so the filter is a routing CASE. Each test runs with pruning off and on and checks that the results are identical (50000 trips).The data-file counts include the build side's 2 files. The unpartitioned case isolates the row-group path: no file can be skipped by partition value, so the drop in rows comes only from row-group pruning.
A 2-worker distributed run of the partitioned join with pruning on returns the correct result.
Each new integration test was checked against a broken implementation:
Checks run:
cargo fmt --all --checkcargo clippy -p datafusion-distributed-iceberg -p datafusion-distributed-iceberg-test-tools --all-targets: no warningscargo test -p datafusion-distributed-iceberg: all passcargo test --features integration --test dynamic_filtering: 18/18 with--test-threads=1. Run in parallel, the pre-existing flakyaggregates::tests::remote_dynamic_filtersfails intermittently.