Leon-Weigel opened a new issue, #25244:
URL: https://github.com/apache/datafusion/issues/25244

   ## One-line summary
   
   With `datafusion.optimizer.enable_join_dynamic_filter_pushdown = true`, 
dynamic-filter
   pushdown remaps the filter's column **by name** into the probe-side child, 
so when a
   join key references the *second* of two output columns both named `id`, the 
filter is
   silently moved to the *first* `id` column and parquet row-group pruning then 
discards
   all rows. The same plan with the flag `false` returns the correct row.
   
   ## DataFusion version
   
   `datafusion 55.1.0` / `arrow 59.3.0` (default features). Also observed 
through a graph
   engine on `datafusion 54.0.0`; the flags/plan shape are unchanged.
   
   ## Reproducer
   
   Pure DataFusion — no third-party engine. Two one-row parquet files and a 
hand-built
   logical plan (built with the API rather than SQL because SQL cannot 
reference the
   second of two same-named columns). Save as a test/example with dev-deps
   `datafusion = "55.1"`, `arrow = "59"`, `parquet = "59"`, `tempfile`, `tokio`.
   
   ```rust
   use std::sync::Arc;
   
   use arrow::array::{ArrayRef, StringArray};
   use arrow::datatypes::{DataType, Field, Schema};
   use arrow::record_batch::RecordBatch;
   use datafusion::execution::context::SessionConfig;
   use datafusion::logical_expr::{Expr, JoinType, LogicalPlan, 
LogicalPlanBuilder};
   use datafusion::physical_optimizer::PhysicalOptimizerRule;
   use datafusion::prelude::*;
   use parquet::arrow::ArrowWriter;
   
   fn write_parquet(path: &std::path::Path, schema: Arc<Schema>, batch: 
&RecordBatch) {
       let file = std::fs::File::create(path).unwrap();
       let mut w = ArrowWriter::try_new(file, schema, None).unwrap();
       w.write(batch).unwrap();
       w.close().unwrap();
   }
   
   // ta: single row (id = "a1", ty = "x1")  -> id range [a1,a1] does NOT 
contain x1
   // tb: single row (id = "x1")
   fn make_tables() -> (tempfile::TempDir, String, String) {
       let dir = tempfile::tempdir().unwrap();
   
       let sa = Arc::new(Schema::new(vec![
           Field::new("id", DataType::Utf8, false),
           Field::new("ty", DataType::Utf8, false),
       ]));
       let b = RecordBatch::try_new(
           Arc::clone(&sa),
           vec![
               Arc::new(StringArray::from(vec!["a1"])) as ArrayRef,
               Arc::new(StringArray::from(vec!["x1"])) as ArrayRef,
           ],
       )
       .unwrap();
       let ta = dir.path().join("ta.parquet");
       write_parquet(&ta, sa, &b);
   
       let sb = Arc::new(Schema::new(vec![Field::new("id", DataType::Utf8, 
false)]));
       let b = RecordBatch::try_new(
           Arc::clone(&sb),
           vec![Arc::new(StringArray::from(vec!["x1"])) as ArrayRef],
       )
       .unwrap();
       let tb = dir.path().join("tb.parquet");
       write_parquet(&tb, sb, &b);
   
       (
           dir,
           ta.to_string_lossy().into_owned(),
           tb.to_string_lossy().into_owned(),
       )
   }
   
   async fn ctx(dynamic: bool, ta: &str, tb: &str) -> SessionContext {
       let mut config = SessionConfig::new();
       config.options_mut().execution.target_partitions = 1;
       config.options_mut().execution.collect_statistics = true;
       config.options_mut().execution.parquet.schema_force_view_types = false;
       config.options_mut().optimizer.join_reordering = false;
       config.options_mut().optimizer.enable_join_dynamic_filter_pushdown = 
dynamic;
       let ctx = SessionContext::new_with_config(config);
       ctx.register_parquet("ta", ta, 
ParquetReadOptions::default()).await.unwrap();
       ctx.register_parquet("tb", tb, 
ParquetReadOptions::default()).await.unwrap();
       ctx
   }
   
   fn c(rel: &str, name: &str) -> Expr {
       Expr::Column(datafusion::common::Column::new(Some(rel), name))
   }
   fn k(rel: &str, name: &str) -> datafusion::common::Column {
       datafusion::common::Column::new(Some(rel), name)
   }
   
   fn build_logical(scan_a: LogicalPlan, scan_b: LogicalPlan) -> LogicalPlan {
       let aliased = |scan: LogicalPlan, name: &str, exprs: Vec<Expr>| {
           LogicalPlanBuilder::from(scan)
               .alias(name).unwrap()
               .project(exprs).unwrap()
               .build().unwrap()
       };
       let a = aliased(scan_a, "a", vec![c("a", "id"), c("a", "ty")]);
       let b = aliased(scan_b.clone(), "b", vec![c("b", "id")]);
       let cc = aliased(scan_b.clone(), "c", vec![c("c", "id")]);
   
       // A = a JOIN b ON a.ty = b.id -> [a.id, a.ty, b.id]
       let a_join = LogicalPlanBuilder::from(a)
           .join_detailed(
               b, JoinType::Inner,
               (vec![k("a", "ty")], vec![k("b", "id")]),
               None, datafusion::common::NullEquality::NullEqualsNothing,
           ).unwrap().build().unwrap();
   
       // A2 = [a.id, b.id] -> TWO output columns both named `id`
       let a2 = LogicalPlanBuilder::from(a_join)
           .project(vec![c("a", "id"), c("b", "id")])
           .unwrap().build().unwrap();
   
       // J = A2 JOIN c ON b.id = c.id -> [a.id, b.id, c.id]
       let j = LogicalPlanBuilder::from(a2)
           .join_detailed(
               cc, JoinType::Inner,
               (vec![k("b", "id")], vec![k("c", "id")]),
               None, datafusion::common::NullEquality::NullEqualsNothing,
           ).unwrap().build().unwrap();
   
       // s = filter(tb, id = 'x1') -> [s.id]
       let s = LogicalPlanBuilder::from(scan_b)
           .alias("s").unwrap()
           .filter(c("s", "id").eq(lit("x1"))).unwrap()
           .project(vec![c("s", "id")]).unwrap()
           .build().unwrap();
   
       // Top = s JOIN J ON s.id = J.<b.id>  -- J's SECOND `id` (index 1)
       LogicalPlanBuilder::from(s)
           .join_detailed(
               j, JoinType::Inner,
               (vec![k("s", "id")], vec![k("b", "id")]),
               None, datafusion::common::NullEquality::NullEqualsNothing,
           ).unwrap().build().unwrap()
   }
   
   async fn run(dynamic: bool) -> (Arc<dyn 
datafusion::physical_plan::ExecutionPlan>, Vec<RecordBatch>) {
       let (_dir, ta, tb) = make_tables();
       let ctx = ctx(dynamic, &ta, &tb).await;
       let scan_a = ctx.table("ta").await.unwrap().logical_plan().clone();
       let scan_b = ctx.table("tb").await.unwrap().logical_plan().clone();
       let logical = build_logical(scan_a, scan_b);
   
       // NOTE: logical optimization is bypassed so the hand-built join tree is 
not
       // reordered; the physical optimizer (incl. dynamic-filter pushdown) 
still runs.
       let state = ctx.state();
       let mut phys = state.query_planner()
           .create_physical_plan(&logical, &state).await.unwrap();
       for rule in state.physical_optimizers() {
           phys = rule.optimize(phys, state.config_options()).unwrap();
       }
       let batches = datafusion::physical_plan::collect(
           Arc::clone(&phys), ctx.task_ctx(),
       ).await.unwrap();
       (phys, batches)
   }
   
   #[tokio::test]
   async fn dynamic_filter_must_not_change_the_answer() {
       let (_, on) = run(true).await;
       let (_, off) = run(false).await;
       let rows = |b: &[RecordBatch]| b.iter().map(|x| 
x.num_rows()).sum::<usize>();
       assert_eq!(rows(&on), rows(&off),
           "a physical optimizer flag changed the answer: ON={} OFF={}",
           rows(&on), rows(&off));
   }
   ```
   
   ## Expected output
   
   Both settings return the single matching row (`s.id = x1`, `a.id = a1`, 
`b.id = x1`,
   `c.id = x1`):
   
   ```
   ROWS(ON)  = 1
   ROWS(OFF) = 1
   ```
   
   ## Actual output (datafusion 55.1.0)
   
   ```
   --- flag ON ---
   ROWS(ON) = 0
   --- flag OFF ---
   ROWS(OFF) = 1
     ["x1", "a1", "x1", "x1"]
   ```
   
   ## Physical plans
   
   ### Flag ON
   
   A `DynamicFilter` has been pushed onto `ta.parquet` (which contains only
   `id = a1`); the filter's values come from `s.id = x1`, so parquet row-group 
pruning
   (relying on the wrong column) removes the only `ta` row and the query 
returns zero rows.
   
   ```
   HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(id@0, id@1)]
     FilterExec: id@0 = x1
       DataSourceExec: file_groups={1 group: [[.../tb.parquet]]}, 
projection=[id], file_type=parquet, predicate=id@0 = x1 AND id@0 = x1, ...
     HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(id@1, id@0)]
       HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(ty@1, id@0)], 
projection=[id@0, id@2]
         DataSourceExec: file_groups={1 group: [[.../ta.parquet]]}, 
projection=[id, ty], file_type=parquet, predicate=DynamicFilter [ empty ], 
dynamic_rg_pruning=eligible
         DataSourceExec: file_groups={1 group: [[.../tb.parquet]]}, 
projection=[id], file_type=parquet, predicate=DynamicFilter [ empty ], 
dynamic_rg_pruning=eligible
       DataSourceExec: file_groups={1 group: [[.../tb.parquet]]}, 
projection=[id], file_type=parquet, predicate=DynamicFilter [ empty ], 
dynamic_rg_pruning=eligible
   ```
   
   ### Flag OFF
   
   Identical plan with **no** `DynamicFilter` anywhere:
   
   ```
   HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(id@0, id@1)]
     FilterExec: id@0 = x1
       DataSourceExec: file_groups={1 group: [[.../tb.parquet]]}, 
projection=[id], file_type=parquet, predicate=id@0 = x1 AND id@0 = x1, ...
     HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(id@1, id@0)]
       HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(ty@1, id@0)], 
projection=[id@0, id@2]
         DataSourceExec: file_groups={1 group: [[.../ta.parquet]]}, 
projection=[id, ty], file_type=parquet
         DataSourceExec: file_groups={1 group: [[.../tb.parquet]]}, 
projection=[id], file_type=parquet
       DataSourceExec: file_groups={1 group: [[.../tb.parquet]]}, 
projection=[id], file_type=parquet
   ```
   
   ## Caveat on the reproducer's planning path
   
   The reproducer builds the join tree with `LogicalPlanBuilder` and bypasses 
the LOGICAL optimizer, so the
   hand-built tree is not reordered; the physical optimizer, including 
dynamic-filter pushdown, runs normally.
   That is deliberate: plain SQL cannot reference the *second* of two 
same-named output columns, so SQL cannot
   express the join key that triggers this. The same misroute occurs through 
the ordinary end-to-end path in the
   graph engine described at the bottom, where the duplicate `id` names arise 
naturally, so the bypass is a way to
   make the case small rather than a way to make it happen.
   
   ## Probable root cause
   
   `datafusion-physical-plan/src/filter_pushdown.rs`, 
`FilterRemapper::try_remap`
   (~lines 365-385). When routing a pushed-down filter into a child it 
validates the
   column position against `allowed_indices` but then **remaps by name**:
   
   ```rust
   if self.allowed_indices.contains(&col.index())
       && let Ok(new_index) = self.child_schema.index_of(col.name())
   {
       Ok(Transformed::yes(Arc::new(Column::new(col.name(), new_index))))
   }
   ```
   
   `Schema::index_of(name)` returns the *first* field with that name. The join 
key created
   by `HashJoinExec::gather_filters_for_pushdown` is
   `Column { name: "id", index: 1 }` (the second `id` of `A2`). Pushing it into 
`A2`/`A`
   resolves the name `id` to index 0 (`a.id`) instead of index 1 (`b.id`), so 
the dynamic
   filter is attached to the wrong parquet scan. `dynamic_rg_pruning` then 
evaluates
   `x1` against `ta.id` min/max `[a1,a1]` and prunes the row group, returning 
no rows.
   The `allowed_indices` guard added for same-named join sides does not help 
because it
   only gates *which child* is eligible; the actual index selection is still 
name-based.
   
   A positional remap (e.g. carry the parent-output index → child-input index 
mapping from
   the join's `column_indices`/`projection`) would fix it.
   
   ## Same shape in a graph engine (context, not required to reproduce)
   
   The originating incident was a Cypher multi-hop expansion whose endpoint 
(`w.id`) was a
   join-output column equated to a separately bound node (`mid.id`). There 
every output
   column is named `id`, and the same mechanism pushed the dynamic filter from 
the top
   join down to the *base* `test.parquet` scan:
   
   ```
   HashJoinExec: on=[(id@0, id@1)], projection=[id@1, id@3]
     FilterExec: id@0 = x1                              <- spec WHERE id='x1'
     HashJoinExec: on=[(end_id@2, id@0)], projection=[id@0, id@1, id@3]
       ...
         DataSourceExec: test.parquet, projection=[id], predicate=DynamicFilter 
[ empty ], dynamic_rg_pruning=eligible
   ```
   
   `ROWS(ON) = []` vs `ROWS(OFF) = [("t2","g1")]`.
   


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