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

   ### Describe the bug
   
   An expression with `ExpressionPlacement::MoveTowardsLeafNodes` placement is 
supposed to be pushed down to the file scan, where the source can either supply 
it as a column or prune to it. That pushdown is lost whenever the expression is 
**wrapped in another expression** and a `RepartitionExec` has been inserted 
between the `ProjectionExec` and the `DataSourceExec`.
   
   All three functions that declare this placement are affected, in two symptom 
classes:
   
   | function | evaluable directly? | symptom when wrapped |
   |---|---|---|
   | `file_row_index()` | no | **execution error** |
   | `input_file_name()` | no | **execution error** |
   | `get_field()` (`s['a']`) | yes | **silently loses Parquet nested-column 
pruning** |
   
   ```
   Execution error: file_row_index() is source dependent and cannot be 
evaluated directly
   ```
   
   The bare call always works; wrapping it in *anything* breaks it:
   
   ```sql
   SELECT file_row_index()     FROM big;   -- ok
   SELECT file_row_index() + 1 FROM big;   -- Execution error
   ```
   
   #### Root cause
   
   The general machinery for this already exists: `ExtractLeafExpressions` / 
`PushDownLeafProjections` in 
`datafusion/optimizer/src/extract_leaf_expressions.rs` pulls 
`MoveTowardsLeafNodes` sub-expressions out into a `__datafusion_extracted_N` 
projection that the source can absorb. All three functions already declare the 
placement (e.g. `functions/src/core/file_row_index.rs:85`).
   
   But `extract_from_plan` (`extract_leaf_expressions.rs:259`) only extracts 
from an allowlist of node types, and **`Projection` is not on it**:
   
   ```rust
   // Only extract from plan types whose output schema is predictable after
   // expression rewriting. ...
   if !matches!(
       &plan,
       LogicalPlan::Aggregate(_)
           | LogicalPlan::Filter(_)
           | LogicalPlan::Sort(_)
           | LogicalPlan::Limit(_)
           | LogicalPlan::Join(_)
   ) {
       return Ok(Transformed::no(plan));
   }
   ```
   
   The same wrapped shape therefore works in a `WHERE` clause, where `Filter` 
**is** on the allowlist:
   
   ```sql
   SELECT column1 FROM big WHERE file_row_index() + 1 = 3;   -- ok
   ```
   ```
   Filter: __datafusion_extracted_1 + Int64(1) = Int64(3)
     Projection: file_row_index() AS __datafusion_extracted_1, big.column1
       TableScan: big projection=[column1]
   ```
   
   That plan contains a `RepartitionExec` too and still works, because by then 
the marker is an ordinary column.
   
   Measured, the same `marker + 1` shape in each node type:
   
   | node type | on allowlist? | result |
   |---|---|---|
   | `Filter` (`WHERE`) | yes | ok |
   | `Sort` (`ORDER BY`) | yes | ok |
   | `Aggregate` (`sum` / `GROUP BY` / `HAVING`) | yes | ok |
   | `Join` (`ON`) | yes | **error** — separate cause, see Additional context |
   | `Window` (`OVER`) | no | **error** |
   | **`Projection` (`SELECT` list)** | **no** | **error — this issue** |
   
   The module docs state the rationale for excluding `Projection`:
   
   > The split has no use there: the source absorbs the leaf expressions of the 
original projection just as well.
   
   That holds only while the projection actually reaches the source. 
`EnsureRequirements` then inserts a `RepartitionExec` when the estimated row 
count exceeds `batch_size` 
(`ensure_requirements/enforce_distribution.rs:1167`), 
`RepartitionExec::try_swapping_with_projection` refuses any projection that is 
not `all_columns` (`physical-plan/src/repartition/mod.rs:1949`), and nothing 
re-establishes the assumption. The bare call works because a bare marker 
projection is already in the shape the source absorbs.
   
   ### To Reproduce
   
   In `datafusion-cli`, stock configuration:
   
   ```sql
   COPY (SELECT value AS column1 FROM range(0, 20000))
   TO '/tmp/df_leaf_repro/big.parquet' STORED AS PARQUET;
   
   CREATE EXTERNAL TABLE big
   STORED AS PARQUET LOCATION '/tmp/df_leaf_repro/big.parquet';
   
   SELECT file_row_index() FROM big;        -- ok
   SELECT file_row_index() + 1 FROM big;    -- Execution error
   ```
   
   > [!IMPORTANT]
   > **A bare `LIMIT n` masks this bug.** It is pushed into the scan, so the 
row estimate drops to `n`, falls below `batch_size`, and no `RepartitionExec` 
is inserted. A regression test written with `LIMIT 3`, or against a table 
smaller than `batch_size`, **passes against broken code**.
   >
   > | query | result |
   > |---|---|
   > | `SELECT file_row_index() + 1 FROM big LIMIT 3` | **ok** — bug masked |
   > | `SELECT file_row_index() + 1 FROM big` | error |
   > | `SELECT file_row_index() + 1, column1 FROM big ORDER BY column1 LIMIT 3` 
| error |
   > | `SELECT file_row_index() + 1 FROM big OFFSET 5 LIMIT 3` | error |
   
   Expression shapes, all without `LIMIT`:
   
   | shape | result |
   |---|---|
   | `file_row_index()` | ok |
   | `file_row_index() AS r` | ok |
   | `file_row_index() + 1` | **error** |
   | `arrow_typeof(file_row_index())` | **error** |
   | `CAST(file_row_index() AS INT)` | **error** |
   | `SELECT r + 1 FROM (SELECT file_row_index() AS r FROM big)` | **error** — 
subquery is flattened |
   
   `input_file_name()` behaves identically: bare is ok, while 
`input_file_name() || 'x'`, `upper(input_file_name())` and `input_file_name() 
|| CAST(file_row_index() AS VARCHAR)` all error.
   
   #### `get_field()`: same cause, silent performance regression
   
   ```sql
   COPY (SELECT named_struct('a', value, 'b', value * 2, 'c', CAST(value AS 
VARCHAR)) AS s
         FROM range(0, 20000)) TO '/tmp/df_leaf_repro/st.parquet' STORED AS 
PARQUET;
   
   CREATE EXTERNAL TABLE t STORED AS PARQUET LOCATION 
'/tmp/df_leaf_repro/st.parquet';
   
   EXPLAIN SELECT s['a']     AS v FROM t;
   EXPLAIN SELECT s['a'] + 1 AS v FROM t;
   ```
   
   | query | `DataSourceExec` projection | reads |
   |---|---|---|
   | `SELECT s['a'] FROM t` | `projection=[get_field(s@0, a) as v]` | only the 
`a` leaf |
   | `SELECT s['a'] + 1 FROM t` | `projection=[s]` | **the entire struct** |
   
   No error, just substantially more I/O than necessary — and nested-column 
pruning is the headline feature of the extraction rule.
   
   #### Controls confirming the mechanism
   
   Each of these removes the `RepartitionExec` and restores correct behaviour, 
on the same table and data:
   
   | setting | `file_row_index() + 1` | `s['a'] + 1` scan projection |
   |---|---|---|
   | default | error | `projection=[s]` |
   | `SET datafusion.execution.batch_size = 30000` | ok | 
`projection=[get_field(s@0, a) + 1 as v]` |
   | `SET datafusion.execution.target_partitions = 1` | ok | 
`projection=[get_field(s@0, a) + 1 as v]` |
   
   Raising `batch_size` above the row count changes only the threshold at 
`enforce_distribution.rs:1167`, which is what promotes this from correlation to 
demonstrated cause.
   
   `datafusion.execution.parquet.pushdown_filters` makes no difference — the 
projection case fails with it both on and off.
   
   `EXPLAIN` on the failing query **succeeds**; the failure appears only once 
execution starts.
   
   ### Expected behavior
   
   A wrapped `MoveTowardsLeafNodes` expression should reach the scan just as 
the bare form does, so that:
   
   - `file_row_index() + 1` and `input_file_name() || 'x'` return results 
instead of erroring, and
   - `s['a'] + 1` prunes to the `a` leaf instead of reading the whole struct.
   
   Failing that, the two marker functions should at least be rejected at 
**plan** time with an actionable message rather than accepted, passed by 
`EXPLAIN`, and failed mid-execution.
   
   ### Additional context
   
   Suggested fix direction — extend extraction to projections whose expressions 
*contain* a `MoveTowardsLeafNodes` sub-expression, turning
   
   ```
   Projection: file_row_index() + 1 AS v, column1
   ```
   
   into
   
   ```
   Projection: __datafusion_extracted_1 + 1 AS v, column1
     Projection: file_row_index() AS __datafusion_extracted_1, column1
   ```
   
   The inner pure extraction projection is absorbable by the source (exactly 
the shape that works today) and the outer one references only columns, so no 
loosening of the `all_columns` guard in `RepartitionExec` is needed. Two 
constraints:
   
   - The allowlist comment excludes node types whose output schema depends on 
their expressions, and `Projection` is one of them. The split must keep the 
outer projection's alias list and output field names identical, changing only 
the inner sub-expression into a column reference.
   - Detection should key off `ExpressionPlacement::MoveTowardsLeafNodes` 
rather than per-function downcasts. #23638 added a hardcoded 
`try_downcast_func` chain for `FileRowIndexFunc`/`InputFileNameFunc`, and noted 
at the time: *"If we keep adding this sort of metadata functions, we might want 
a better API to detect them."* That API now exists and all three functions 
declare it, so keying off placement also covers metadata functions added later.
   
   Related: #23531 / #23638 are the same `"is source dependent and cannot be 
evaluated directly"` error from a *different* pushdown gap (`input_file_name()` 
inside a Parquet `RowFilter`), fixed by excluding those UDFs from filter 
pushdown. `file_row_index()` was added in #22604, `input_file_name()` in #22978.
   
   Two further gaps found while investigating, which I believe are **out of 
scope here** and need their own issues:
   
   1. **`Join` is on the allowlist but still fails.** Extraction routes each 
extracted sub-expression to an input by checking which input's schema contains 
all of the expression's column references. A marker call has *no* column 
references, so it cannot be routed. Pre-extracting it in a subquery works:
      ```sql
      SELECT count(*) FROM (SELECT file_row_index() AS r, column1 FROM big) a
      JOIN big b ON a.column1 = b.column1 AND a.r >= 0;   -- ok
      ```
   2. **`file_row_index()` does not work on CSV or JSON at all.** Even the bare 
form errors, independently of row count and `target_partitions` (verified on a 
3-row file and with `target_partitions = 1`); the only production caller of the 
rewrite is the Parquet source. `input_file_name()` bare does work on CSV. So 
`file_row_index()` is effectively Parquet-only, which the documentation does 
not state.
   
   Verified on `c120619de` (`48.0.0-rc2-4901-gc120619de`), `datafusion-cli` 
reporting `57.0.0-dev`, macOS arm64, default `target_partitions` (= core count).
   
   ### Willingness to contribute
   
   I plan to contribute a fix for this bug independently.
   


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