gene-bordegaray commented on code in PR #23854:
URL: https://github.com/apache/datafusion/pull/23854#discussion_r3718957052


##########
datafusion/physical-plan/src/joins/hash_join/exec.rs:
##########
@@ -891,16 +891,20 @@ impl HashJoinExec {
         // https://github.com/apache/datafusion/issues/20195
         if config.optimizer.preserve_file_partitions > 0
             && self.mode == PartitionMode::Partitioned
+            && !matches!(

Review Comment:
   I think this might read easier if we chcked if it is `Partitioning::Hash` 
instead, what do you think?
   
   Could we also update the comment explaining what we allow and what we do not 
as of now and the reasons why (partition / filter alignment)



##########
datafusion/core/tests/physical_optimizer/filter_pushdown.rs:
##########
@@ -1191,6 +1192,256 @@ async fn 
test_hashjoin_dynamic_filter_pushdown_partitioned() {
     );
 }
 
+#[tokio::test]
+async fn test_hashjoin_dynamic_filter_pushdown_range_partitioned() {
+    use datafusion_common::JoinType;
+    use datafusion_physical_plan::joins::{HashJoinExec, PartitionMode};
+
+    // Rough sketch of the Range-partitioned MRE we're trying to recreate. The
+    // test hand-wires identical Range repartitioning:
+    //
+    // EXPLAIN
+    // SELECT *
+    // FROM build
+    // JOIN probe
+    //   ON build.a = probe.a AND build.b = probe.b;
+    //
+    // 
+---------------+------------------------------------------------------------+
+    // | plan_type     | plan                                                  
     |
+    // 
+---------------+------------------------------------------------------------+
+    // | physical_plan | ┌───────────────────────────┐                         
     |
+    // |               | │        HashJoinExec       │                         
     |
+    // |               | │    --------------------   ├──────────────┐          
     |
+    // |               | │ on: (a = a), (b = b)      │              │          
     |
+    // |               | └─────────────┬─────────────┘              │          
     |
+    // |               | 
┌─────────────┴─────────────┐┌─────────────┴─────────────┐ |
+    // |               | │      RepartitionExec      ││      RepartitionExec   
   │ |
+    // |               | │    --------------------   ││    
--------------------   │ |
+    // |               | │ partition_count(in->out): ││ 
partition_count(in->out): │ |
+    // |               | │           1 -> 2          ││           1 -> 2       
   │ |
+    // |               | │                           ││                        
   │ |
+    // |               | │    partitioning_scheme:   ││    
partitioning_scheme:   │ |
+    // |               | │ Range([a ASC, b ASC], 2)  ││ Range([a ASC, b ASC], 
2)  │ |
+    // |               | │      split: (aa, bb)      ││      split: (aa, bb)   
   │ |
+    // |               | 
└─────────────┬─────────────┘└─────────────┬─────────────┘ |
+    // |               | 
┌─────────────┴─────────────┐┌─────────────┴─────────────┐ |
+    // |               | │ DataSourceExec (build)    ││ DataSourceExec (probe) 
   │ |
+    // |               | │    --------------------   ││    
--------------------   │ |
+    // |               | │ rows: (aa,ba), (ab,bb)    ││ rows: (aa,ba) ... 
(ad,bd) │ |
+    // |               | │                           ││ predicate: 
DynamicFilter  │ |
+    // |               | │                           ││ range CASE -> 
filter_0/1  │ |
+    // |               | 
└───────────────────────────┘└───────────────────────────┘ |
+    // |               |                                                       
     |
+    // 
+---------------+------------------------------------------------------------+
+
+    // Create build side with limited values
+    let build_batches = vec![

Review Comment:
   seems like this is boilerplate that is repreated for multiple tests would it 
e worth to extract / reduce some of this with a helper?



##########
datafusion/core/tests/physical_optimizer/filter_pushdown.rs:
##########


Review Comment:
   similarly this is repeated every test with the same plans checks, could we 
reduce this boilerplate without losing visibility as to what the test is doing



##########
datafusion/core/tests/physical_optimizer/filter_pushdown.rs:
##########
@@ -1191,6 +1192,260 @@ async fn 
test_hashjoin_dynamic_filter_pushdown_partitioned() {
     );
 }
 
+// Not portable to sqllogictest: this test pins `PartitionMode::Partitioned`
+// by hand-wiring matching Range repartitioning on both join sides, which the
+// SQL planner does not currently produce.
+#[tokio::test]

Review Comment:
   I see, ya it is using a csv backed table but we could either:
   
   - update it to be parquet backed and reuse them for your testing (I would 
prefer this is the code changes are small)
   - or could introduce a `ParquetSource` in the PR and add coverage there
   
   For range partitioning I would really prefer there to be coverage for all 
features in this file 🙇 .
   
   NOTE: I would still keep the existing tests you have, they are great



##########
datafusion/core/tests/physical_optimizer/filter_pushdown.rs:
##########
@@ -1191,6 +1192,256 @@ async fn 
test_hashjoin_dynamic_filter_pushdown_partitioned() {
     );
 }
 
+#[tokio::test]
+async fn test_hashjoin_dynamic_filter_pushdown_range_partitioned() {
+    use datafusion_common::JoinType;
+    use datafusion_physical_plan::joins::{HashJoinExec, PartitionMode};
+
+    // Rough sketch of the Range-partitioned MRE we're trying to recreate. The
+    // test hand-wires identical Range repartitioning:
+    //
+    // EXPLAIN
+    // SELECT *
+    // FROM build
+    // JOIN probe
+    //   ON build.a = probe.a AND build.b = probe.b;
+    //
+    // 
+---------------+------------------------------------------------------------+
+    // | plan_type     | plan                                                  
     |
+    // 
+---------------+------------------------------------------------------------+
+    // | physical_plan | ┌───────────────────────────┐                         
     |
+    // |               | │        HashJoinExec       │                         
     |
+    // |               | │    --------------------   ├──────────────┐          
     |
+    // |               | │ on: (a = a), (b = b)      │              │          
     |
+    // |               | └─────────────┬─────────────┘              │          
     |
+    // |               | 
┌─────────────┴─────────────┐┌─────────────┴─────────────┐ |
+    // |               | │      RepartitionExec      ││      RepartitionExec   
   │ |
+    // |               | │    --------------------   ││    
--------------------   │ |
+    // |               | │ partition_count(in->out): ││ 
partition_count(in->out): │ |
+    // |               | │           1 -> 2          ││           1 -> 2       
   │ |
+    // |               | │                           ││                        
   │ |
+    // |               | │    partitioning_scheme:   ││    
partitioning_scheme:   │ |
+    // |               | │ Range([a ASC, b ASC], 2)  ││ Range([a ASC, b ASC], 
2)  │ |
+    // |               | │      split: (aa, bb)      ││      split: (aa, bb)   
   │ |
+    // |               | 
└─────────────┬─────────────┘└─────────────┬─────────────┘ |
+    // |               | 
┌─────────────┴─────────────┐┌─────────────┴─────────────┐ |
+    // |               | │ DataSourceExec (build)    ││ DataSourceExec (probe) 
   │ |
+    // |               | │    --------------------   ││    
--------------------   │ |
+    // |               | │ rows: (aa,ba), (ab,bb)    ││ rows: (aa,ba) ... 
(ad,bd) │ |
+    // |               | │                           ││ predicate: 
DynamicFilter  │ |
+    // |               | │                           ││ range CASE -> 
filter_0/1  │ |
+    // |               | 
└───────────────────────────┘└───────────────────────────┘ |
+    // |               |                                                       
     |
+    // 
+---------------+------------------------------------------------------------+
+
+    // Create build side with limited values
+    let build_batches = vec![
+        record_batch!(
+            ("a", Utf8, ["aa", "ab"]),
+            ("b", Utf8, ["ba", "bb"]),
+            ("c", Float64, [1.0, 2.0]) // Extra column not used in join
+        )
+        .unwrap(),
+    ];
+    let build_side_schema = Arc::new(Schema::new(vec![
+        Field::new("a", DataType::Utf8, false),
+        Field::new("b", DataType::Utf8, false),
+        Field::new("c", DataType::Float64, false),
+    ]));
+    let build_scan = TestScanBuilder::new(Arc::clone(&build_side_schema))
+        .with_support(true)
+        .with_batches(build_batches)
+        .build();
+
+    // Create probe side with more values
+    let probe_batches = vec![
+        record_batch!(
+            ("a", Utf8, ["aa", "ab", "ac", "ad"]),
+            ("b", Utf8, ["ba", "bb", "bc", "bd"]),
+            ("e", Float64, [1.0, 2.0, 3.0, 4.0]) // Extra column not used in 
join
+        )
+        .unwrap(),
+    ];
+    let probe_side_schema = Arc::new(Schema::new(vec![
+        Field::new("a", DataType::Utf8, false),
+        Field::new("b", DataType::Utf8, false),
+        Field::new("e", DataType::Float64, false),
+    ]));
+    let probe_scan = TestScanBuilder::new(Arc::clone(&probe_side_schema))
+        .with_support(true)
+        .with_batches(probe_batches)
+        .build();
+
+    let split_points = vec![SplitPoint::new(vec![
+        ScalarValue::Utf8(Some("aa".to_string())),
+        ScalarValue::Utf8(Some("bb".to_string())),
+    ])];
+
+    // Build side: DataSource -> RepartitionExec (Range)
+    let build_range_ordering = LexOrdering::new(vec![
+        PhysicalSortExpr::new(
+            col("a", &build_side_schema).unwrap(),
+            SortOptions::default(),
+        ),
+        PhysicalSortExpr::new(
+            col("b", &build_side_schema).unwrap(),
+            SortOptions::default(),
+        ),
+    ])
+    .unwrap();
+    let build_repartition = Arc::new(
+        RepartitionExec::try_new(
+            build_scan,
+            Partitioning::Range(
+                RangePartitioning::try_new(build_range_ordering, 
split_points.clone())
+                    .unwrap(),
+            ),
+        )
+        .unwrap(),
+    );
+
+    // Probe side: DataSource -> RepartitionExec (Range)
+    let probe_range_ordering = LexOrdering::new(vec![
+        PhysicalSortExpr::new(
+            col("a", &probe_side_schema).unwrap(),
+            SortOptions::default(),
+        ),
+        PhysicalSortExpr::new(
+            col("b", &probe_side_schema).unwrap(),
+            SortOptions::default(),
+        ),
+    ])
+    .unwrap();
+    let probe_repartition = Arc::new(
+        RepartitionExec::try_new(
+            Arc::clone(&probe_scan),
+            Partitioning::Range(
+                RangePartitioning::try_new(probe_range_ordering, 
split_points).unwrap(),
+            ),
+        )
+        .unwrap(),
+    );
+
+    // Create HashJoinExec with partitioned inputs
+    let on = vec![
+        (
+            col("a", &build_side_schema).unwrap(),
+            col("a", &probe_side_schema).unwrap(),
+        ),
+        (
+            col("b", &build_side_schema).unwrap(),
+            col("b", &probe_side_schema).unwrap(),
+        ),
+    ];
+    let hash_join = Arc::new(
+        HashJoinExec::try_new(
+            build_repartition,
+            probe_repartition,
+            on,
+            None,
+            &JoinType::Inner,
+            None,
+            PartitionMode::Partitioned,
+            datafusion_common::NullEquality::NullEqualsNothing,
+            false,
+        )
+        .unwrap(),
+    );
+
+    // Top-level CoalescePartitionsExec
+    let cp = Arc::new(CoalescePartitionsExec::new(hash_join)) as Arc<dyn 
ExecutionPlan>;
+    // Add a sort for deterministic output
+    let plan = Arc::new(SortExec::new(
+        LexOrdering::new(vec![PhysicalSortExpr::new(
+            col("a", &probe_side_schema).unwrap(),
+            SortOptions::new(true, false), // descending, nulls_first
+        )])
+        .unwrap(),
+        cp,
+    )) as Arc<dyn ExecutionPlan>;
+
+    // expect the predicate to be pushed down into the probe side DataSource
+    insta::assert_snapshot!(
+        OptimizationTest::new(Arc::clone(&plan), 
FilterPushdown::new_post_optimization(), true),
+        @r"
+    OptimizationTest:
+      input:
+        - SortExec: expr=[a@0 DESC NULLS LAST], preserve_partitioning=[false]
+        -   CoalescePartitionsExec
+        -     HashJoinExec: mode=Partitioned, join_type=Inner, on=[(a@0, a@0), 
(b@1, b@1)]
+        -       RepartitionExec: partitioning=Range([a@0 ASC, b@1 ASC], [(aa, 
bb)], 2), input_partitions=1
+        -         DataSourceExec: file_groups={1 group: [[test.parquet]]}, 
projection=[a, b, c], file_type=test, pushdown_supported=true
+        -       RepartitionExec: partitioning=Range([a@0 ASC, b@1 ASC], [(aa, 
bb)], 2), input_partitions=1
+        -         DataSourceExec: file_groups={1 group: [[test.parquet]]}, 
projection=[a, b, e], file_type=test, pushdown_supported=true
+      output:
+        Ok:
+          - SortExec: expr=[a@0 DESC NULLS LAST], preserve_partitioning=[false]
+          -   CoalescePartitionsExec
+          -     HashJoinExec: mode=Partitioned, join_type=Inner, on=[(a@0, 
a@0), (b@1, b@1)]
+          -       RepartitionExec: partitioning=Range([a@0 ASC, b@1 ASC], 
[(aa, bb)], 2), input_partitions=1
+          -         DataSourceExec: file_groups={1 group: [[test.parquet]]}, 
projection=[a, b, c], file_type=test, pushdown_supported=true
+          -       RepartitionExec: partitioning=Range([a@0 ASC, b@1 ASC], 
[(aa, bb)], 2), input_partitions=1
+          -         DataSourceExec: file_groups={1 group: [[test.parquet]]}, 
projection=[a, b, e], file_type=test, pushdown_supported=true, 
predicate=DynamicFilter [ empty ]
+    "
+    );
+
+    // Actually apply the optimization to the plan and execute to see the 
filter in action
+    let mut config = ConfigOptions::default();
+    config.execution.parquet.pushdown_filters = true;
+    config.optimizer.enable_dynamic_filter_pushdown = true;
+    config.optimizer.preserve_file_partitions = 1;
+    let plan = FilterPushdown::new_post_optimization()
+        .optimize(plan, &config)
+        .unwrap();
+
+    let config = SessionConfig::from(config).with_batch_size(10);
+    let session_ctx = SessionContext::new_with_config(config);
+    session_ctx.register_object_store(
+        ObjectStoreUrl::parse("test://").unwrap().as_ref(),
+        Arc::new(InMemory::new()),
+    );
+    let state = session_ctx.state();
+    let task_ctx = state.task_ctx();
+    let batches = collect(Arc::clone(&plan), Arc::clone(&task_ctx))
+        .await
+        .unwrap();
+
+    // Now check what our filter looks like
+    insta::assert_snapshot!(
+        format!("{}", format_plan_for_test(&plan)),
+        @r"
+    - SortExec: expr=[a@0 DESC NULLS LAST], preserve_partitioning=[false]
+    -   CoalescePartitionsExec
+    -     HashJoinExec: mode=Partitioned, join_type=Inner, on=[(a@0, a@0), 
(b@1, b@1)]
+    -       RepartitionExec: partitioning=Range([a@0 ASC, b@1 ASC], [(aa, 
bb)], 2), input_partitions=1
+    -         DataSourceExec: file_groups={1 group: [[test.parquet]]}, 
projection=[a, b, c], file_type=test, pushdown_supported=true
+    -       RepartitionExec: partitioning=Range([a@0 ASC, b@1 ASC], [(aa, 
bb)], 2), input_partitions=1
+    -         DataSourceExec: file_groups={1 group: [[test.parquet]]}, 
projection=[a, b, e], file_type=test, pushdown_supported=true, 
predicate=DynamicFilter [ CASE WHEN a@0 IS NULL OR a@0 < aa OR a@0 = aa AND 
(b@1 IS NULL OR b@1 < bb) THEN a@0 >= aa AND a@0 <= aa AND b@1 >= ba AND b@1 <= 
ba AND struct(a@0, b@1) IN (SET) ([{c0:aa,c1:ba}]) ELSE a@0 >= ab AND a@0 <= ab 
AND b@1 >= bb AND b@1 <= bb AND struct(a@0, b@1) IN (SET) ([{c0:ab,c1:bb}]) END 
]

Review Comment:
   🎊 



##########
datafusion/physical-plan/src/joins/hash_join/shared_bounds.rs:
##########
@@ -656,38 +661,99 @@ impl SharedBuildAccumulator {
                     }
                 }
 
-                let filter_expr = if has_canceled_unknown {
-                    let mut when_then_branches = empty_partition_ids
-                        .into_iter()
-                        .map(|partition_id| {
-                            (
-                                lit(ScalarValue::UInt64(Some(partition_id as 
u64))),
-                                lit(false),
-                            )
-                        })
-                        .collect::<Vec<_>>();
-                    when_then_branches.extend(real_branches);
-
-                    if when_then_branches.is_empty() {
-                        lit(true)
-                    } else {
-                        Arc::new(CaseExpr::try_new(
-                            Some(modulo_expr),
-                            when_then_branches,
-                            Some(lit(true)),
-                        )?) as Arc<dyn PhysicalExpr>
-                    }
-                } else if real_branches.is_empty() {
+                let filter_expr = if has_canceled_unknown
+                    && real_partition_ids.is_empty()
+                    && empty_partition_ids.is_empty()
+                {
+                    lit(true)
+                } else if !has_canceled_unknown && 
real_partition_ids.is_empty() {
                     lit(false)
-                } else if real_branches.len() == 1
+                } else if !has_canceled_unknown
+                    && real_partition_ids.len() == 1
                     && empty_partition_ids.len() + 1 == num_partitions
                 {
-                    Arc::clone(&real_branches[0].1)
+                    Arc::clone(&partition_filters[real_partition_ids[0]])
+                } else if let Some(range_partitioning) = 
&self.probe_range_partitioning {
+                    // Range partitioning
+                    assert_eq!(
+                        partition_filters.len(),
+                        range_partitioning.partition_count()
+                    );
+                    assert_eq!(self.on_right.len(), 
range_partitioning.ordering().len());
+                    let sort_exprs = self
+                        .on_right
+                        .iter()
+                        .zip(range_partitioning.ordering())
+                        .map(|(expr, sort_expr)| {
+                            PhysicalSortExpr::new(Arc::clone(expr), 
sort_expr.options)
+                        })
+                        .collect::<Vec<_>>();
+                    let else_expr = partition_filters
+                        .pop()
+                        .expect("Range partitioning always has at least one 
partition");
+                    // CASE evaluates in order
+                    //
+                    // CASE
+                    //   WHEN key <range split[0] THEN F0
+                    //   WHEN key <range split[1] THEN F1
+                    //   ...
+                    //   ELSE Fn
+                    // END
+                    let when_then_expr = range_partitioning
+                        .split_points()
+                        .iter()
+                        .zip(partition_filters)
+                        .map(|(split_point, then_expr)| {
+                            let when_expr = build_lexicographic_filter(

Review Comment:
   we need to be careful here. I don't think that this is always producing the 
same ordering as `RangePartitioning`. Codex caught a nasty edge case.
   
   The repartitioned for range uses `compare_rows` which uses 
`ScalarValue::try_cmp`, `build_lexicographic_filter` uses a binary expression 
comparisions which do not have 1-1 semantics.
   
   An edge case is comparing floating point 0.0 values. If a split point was 
0.0 then the bounds of range partitioning would be:
   ```text
   partition 0: key < +0.0
   partition 1: +0.0 <= key
   ```
   
   `ScalarValue::try_cmp` will evaluate -0.0 to route to partition 0 since it 
thinks of -0.0 < +0.0 as true. The binary expression comparision will normalize 
these values to 0.0 and have -0.0 < +0.0 as false, thus would evaluate against 
the wrong filter.
   
   Can we create a 1-1 mirroring of the compare_rows sematntics and add a 
regression test here.
   
   Here is a pointer to the compare_rows I am talking about: 
https://github.com/peterxcli/datafusion/blob/18f9db399331ef1c2163c8d5a230b7169d073e50/datafusion/common/src/utils/mod.rs#L120
 
   
   and here is where it is used for range repartitioner: 
https://github.com/peterxcli/datafusion/blob/18f9db399331ef1c2163c8d5a230b7169d073e50/datafusion/physical-plan/src/repartition/mod.rs#L962



##########
datafusion/core/tests/physical_optimizer/filter_pushdown.rs:
##########
@@ -1191,6 +1192,256 @@ async fn 
test_hashjoin_dynamic_filter_pushdown_partitioned() {
     );
 }
 
+#[tokio::test]
+async fn test_hashjoin_dynamic_filter_pushdown_range_partitioned() {
+    use datafusion_common::JoinType;

Review Comment:
   nit: could we keep these at the module level?



##########
datafusion/physical-plan/src/joins/hash_join/exec.rs:
##########
@@ -7064,8 +7075,57 @@ mod tests {
             .enable_join_dynamic_filter_pushdown = true;
 
         let join = HashJoinExec::try_new(
-            left,
+            Arc::clone(&left) as Arc<dyn ExecutionPlan>,
             right,
+            on.clone(),
+            None,
+            &JoinType::Inner,
+            None,
+            PartitionMode::Partitioned,
+            NullEquality::NullEqualsNothing,
+            false,
+        )?;
+
+        
assert!(join.allow_join_dynamic_filter_pushdown(session_config.options()));

Review Comment:
   I would prefer to split this test into two separate postive and negative 
test cases so it is easier to understand the failure if it pops up 👍 



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to