jayzhan211 opened a new issue, #25296:
URL: https://github.com/apache/datafusion/issues/25296

   ## Describe the bug
   
   **Physical filter pushdown resolves columns by name, so when a schema 
contains two columns with the same name, a filter over the second one is pushed 
to the first.** With a TopK (`ORDER BY ... LIMIT n`) dynamic filter this 
returns wrong results under the default configuration.
   
   Trigger: two joined tables that share a column name. Below, `orders` and 
`payments` both have `amount`, so the lower join's output schema is `[amount, 
id, amount]`. A filter over `payments.amount` is looked up as `"amount"` in 
that schema, matches the *first* `amount`, which is `orders.amount`, and is 
pushed to the wrong table. If either column were renamed, the query would be 
correct.
   
   Query: the order with the smallest payment.
   
   ```sql
   SELECT o.amount, p.amount
   FROM orders o
   JOIN payments p ON o.id = p.id
   JOIN customers c ON p.id = c.id
   ORDER BY p.amount LIMIT 1;
   ```
   
   How the filter travels down the physical plan (the lower join is built on 
`payments` because `orders` is larger, and a projection restores the written 
column order):
   
   ```text
   SortExec TopK(fetch=1), ORDER BY amount@1          dynamic filter: amount@1 
< 30
      │                                               ("amount" at output index 
1 = p.amount, correct)
      ▼
   HashJoinExec (upper)  output [o.amount, p.amount]
      │   index 1 is on the right child, so the filter may go there.
      │   Position inside the child is resolved BY NAME: index_of("amount")
      ▼
   HashJoinExec (lower)  output [amount,  id,  amount]      <-- two columns 
named "amount"
                                  ▲             ▲
                                  │             └── p.amount   the column the 
filter is about
                                  └──────────────── o.amount   the column 
index_of("amount") returns:
                                                               same name, first 
match wins
      │
      ├── DataSourceExec payments      receives nothing        (should have 
received amount < 30)
      └── DataSourceExec orders        receives amount < 30    WRONG TABLE
   ```
   
   Why that returns the wrong row. The joined rows are (100, 30), (200, 20), 
(300, 10), and the answer should be (300, 10). `orders` streams through the 
lower join one row per batch (`batch_size = 1`, one row per row group), and 
after each batch the TopK publishes "only rows with `amount` below my current 
best can still matter":
   
   | step | `orders` scan | row reaching the TopK | TopK best so far | filter 
published by the TopK |
   |---|---|---|---|---|
   | 1 | reads order 1 (amount 100) | (100, 30) | 30 | `amount < 30` |
   | 2 | order 2 (amount 200): `200 < 30` is false, row group pruned | nothing 
| 30 | unchanged |
   | 3 | order 3 (amount 300): `300 < 30` is false, pruned | nothing | 30 | 
unchanged |
   | 4-6 | orders 4 to 6 pruned the same way | nothing | 30 | unchanged |
   
   The threshold 30 was computed from `payments.amount` (values 30, 20, 10). On 
that column `amount < 30` keeps payments 20 and 10, the ones that could still 
beat the current best. But it is applied to `orders.amount` (values 100, 200, 
..., 600), where no value is below 30. The parquet reader checks each remaining 
row group's min/max against the filter before decoding it, finds `200 < 30`, 
`300 < 30`, ... impossible, and skips them all. The rows (200, 20) and (300, 
10) never reach the TopK, which reports the only row it ever saw:
   
   ```text
                       expected     actual
   o.amount, p.amount  300, 10      100, 30
   ```
   
   Root cause: `FilterRemapper::try_remap` in 
`datafusion/physical-plan/src/filter_pushdown.rs` maps a parent filter into a 
child's schema with `child_schema.index_of(col.name())`. Arrow's `index_of` 
returns the *first* field with that name, so with duplicated names the lookup 
is ambiguous and silently picks the wrong column. 
`HashJoinExec::gather_filters_for_pushdown` reaches it through 
`ChildFilterDescription::from_child_with_allowed_indices`; the index check 
there only decides *whether* a column belongs to the child, the *position* 
inside the child is still looked up by name. Physical schemas are not required 
to have unique names, and a join of two tables that share a column name 
produces one every time.
   
   ## To Reproduce
   
   ```sql
   set datafusion.execution.target_partitions = 1;
   -- one row per batch, so the TopK filter is updated while the `orders` scan 
still has row groups to read
   set datafusion.execution.batch_size = 1;
   
   CREATE TABLE orders_src(id INT, amount INT) AS VALUES (1, 100), (2, 200), 
(3, 300), (4, 400), (5, 500), (6, 600);
   CREATE TABLE payments_src(id INT, amount INT) AS VALUES (1, 30), (2, 20), 
(3, 10);
   CREATE TABLE customers_src(id INT) AS VALUES (1), (2), (3);
   
   COPY orders_src TO '/tmp/dup/orders.parquet' STORED AS PARQUET OPTIONS 
('format.max_row_group_size' '1');
   COPY payments_src TO '/tmp/dup/payments.parquet' STORED AS PARQUET;
   COPY customers_src TO '/tmp/dup/customers.parquet' STORED AS PARQUET;
   
   CREATE EXTERNAL TABLE orders(id INT, amount INT) STORED AS PARQUET LOCATION 
'/tmp/dup/orders.parquet';
   CREATE EXTERNAL TABLE payments(id INT, amount INT) STORED AS PARQUET 
LOCATION '/tmp/dup/payments.parquet';
   CREATE EXTERNAL TABLE customers(id INT) STORED AS PARQUET LOCATION 
'/tmp/dup/customers.parquet';
   
   SELECT o.amount, p.amount
   FROM orders o
   JOIN payments p ON o.id = p.id
   JOIN customers c ON p.id = c.id
   ORDER BY p.amount LIMIT 1;
   ```
   
   ```text
   +--------+--------+
   | amount | amount |
   +--------+--------+
   | 100    | 30     |
   +--------+--------+
   ```
   
   The same wrong row comes back with 
`datafusion.execution.parquet.pushdown_filters = true`. With 
`datafusion.optimizer.enable_topk_dynamic_filter_pushdown = false` the result 
is correct.
   
   ## Expected behavior
   
   ```text
   +--------+--------+
   | amount | amount |
   +--------+--------+
   | 300    | 10     |
   +--------+--------+
   ```
   
   ## Additional context
   
   `EXPLAIN` of the query:
   
   ```text
   SortExec: TopK(fetch=1), expr=[amount@1 ASC NULLS LAST], 
preserve_partitioning=[false]
   --HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(id@0, id@1)], 
projection=[amount@1, amount@3]
   ----DataSourceExec: file_groups={1 group: [[.../customers.parquet]]}, 
projection=[id], file_type=parquet
   ----HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(id@0, id@0)], 
projection=[amount@3, id@0, amount@1]
   ------DataSourceExec: file_groups={1 group: [[.../payments.parquet]]}, 
projection=[id, amount], file_type=parquet, predicate=DynamicFilter [ empty ], 
dynamic_rg_pruning=eligible
   ------DataSourceExec: file_groups={1 group: [[.../orders.parquet]]}, 
projection=[id, amount], file_type=parquet, predicate=DynamicFilter [ empty ] 
AND DynamicFilter [ empty ], dynamic_rg_pruning=eligible
   ```
   
   `EXPLAIN ANALYZE` shows the misplaced filter on the `orders` scan: 
`predicate=DynamicFilter [ ... ] AND DynamicFilter [ amount@1 < 30 ]`.
   
   Why the reproducer needs this shape: the name lookup only misfires when a 
join's child is itself a join (two same-named columns in one schema), and the 
wrongly targeted table must be the streaming probe side, so that the filter 
still has row groups left to prune when the TopK updates it. With the default 
batch size the lower join coalesces its output into one batch and the bug is 
masked; `batch_size = 1` and one row per row group make it deterministic.
   
   Found while reviewing #25255, which removes one caller of the name-based 
semi-join routing but does not change `try_remap`. Reproduced on `main` at 
a407990b4 and on that PR branch. The static-predicate path is less exposed only 
because the logical optimizer usually pushes static predicates to the scans 
before the physical pass.
   
   A fix probably needs the remap to carry positions rather than names: 
`HashJoinExec` already knows which parent output index maps to which child 
index (`column_indices`), so `from_child_with_allowed_indices` could take a 
`HashMap<usize, usize>` from parent index to child index instead of a 
`HashSet<usize>` plus a name lookup.
   


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