cool9850311 commented on code in PR #27152:
URL: https://github.com/apache/datafusion/pull/27152#discussion_r4504820774


##########
datafusion/physical-optimizer/src/projection_pushdown.rs:
##########
@@ -434,21 +451,234 @@ fn remap_filter_expr(
         } else if let Some(variable) = expr.downcast_ref::<LambdaVariable>()
             && old_input_len != new_input_len
         {
-            let offset = 
variable.index().checked_sub(old_input_len).ok_or_else(|| {
-                internal_datafusion_err!(
-                    "Lambda parameter {variable} precedes the {old_input_len} 
input columns"
-                )
-            })?;
-            return Ok(Transformed::yes(Arc::new(LambdaVariable::new(
-                new_input_len + offset,
-                Arc::clone(variable.field()),
-            )) as _));
+            let rebased = rebase_lambda_variable(variable, old_input_len, 
new_input_len)?;
+            return Ok(Transformed::yes(rebased));
         }
         Ok(Transformed::no(expr))
     })
     .data()
 }
 
+/// Shifts a lambda parameter by the change in input width, for the reason
+/// [`remap_filter_expr`] documents.
+fn rebase_lambda_variable(
+    variable: &LambdaVariable,
+    old_input_len: usize,
+    new_input_len: usize,
+) -> Result<Arc<dyn PhysicalExpr>> {
+    let offset = variable.index().checked_sub(old_input_len).ok_or_else(|| {
+        internal_datafusion_err!(
+            "Lambda parameter {variable} precedes the {old_input_len} input 
columns"
+        )
+    })?;
+    Ok(Arc::new(LambdaVariable::new(
+        new_input_len + offset,
+        Arc::clone(variable.field()),
+    )))
+}
+
+/// Prefix for the columns this rule adds when it splits a projection.
+const LEAF_COLUMN_PREFIX: &str = "__datafusion_leaf";
+
+/// Name of the column the split adds for the leaf expression at `index`.
+///
+/// Single source of truth: the lower projection's alias and the reference the
+/// outer projection is rewritten to must agree, or the rewrite silently points
+/// at nothing.
+fn leaf_column_name(index: usize) -> String {
+    format!("{LEAF_COLUMN_PREFIX}_{index}")
+}
+
+/// Splits a `ProjectionExec` that mixes leaf expressions with ones that must
+/// stay in place, moving the leaves into a projection the source absorbs.
+///
+/// `benefits_from_input_partitioning` is true as soon as any expression is
+/// `KeepInPlace`, which both inserts a `RepartitionExec` below the projection
+/// and stops that projection descending past it, so a `MoveTowardsLeafNodes`
+/// expression sharing it never reaches the source. Splitting leaves the
+/// computation above the repartition, still parallelised.
+fn try_split_leaf_projection(
+    plan: &Arc<dyn ExecutionPlan>,
+) -> Result<Option<Arc<dyn ExecutionPlan>>> {
+    let Some(projection) = plan.downcast_ref::<ProjectionExec>() else {
+        return Ok(None);
+    };
+    let Some(repartition) = 
projection.input().downcast_ref::<RepartitionExec>() else {
+        return Ok(None);
+    };
+    // Hash and Range partitioning reference columns by index, which would have
+    // to be remapped against the rewritten child schema. Round robin carries 
no
+    // column references, and is what `EnsureRequirements` inserts here.
+    if !matches!(repartition.partitioning(), Partitioning::RoundRobinBatch(_)) 
{
+        return Ok(None);
+    }
+
+    let leaves = collect_leaf_exprs(projection.expr())?;
+    if leaves.is_empty() {
+        return Ok(None);
+    }
+
+    // Pass through only the child columns the outer expressions still need. A
+    // column used *only* inside an extracted leaf must be dropped, otherwise 
the
+    // source keeps reading it: for `s['a'] + 1` that would read the whole 
struct
+    // alongside the extracted field, which is worse than not splitting at all.
+    let child = repartition.input();
+    let child_schema = child.schema();
+    let passthrough_indices = collect_outer_columns(projection.expr(), 
&leaves)?;
+    let mut lower_exprs = passthrough_indices
+        .iter()
+        .map(|&idx| {
+            let field = child_schema.field(idx);
+            ProjectionExpr {
+                expr: Arc::new(Column::new(field.name(), idx)),
+                alias: field.name().to_string(),
+            }
+        })
+        .collect::<Vec<_>>();
+    for (index, leaf) in leaves.iter().enumerate() {
+        lower_exprs.push(ProjectionExpr {
+            expr: Arc::clone(leaf),
+            alias: leaf_column_name(index),
+        });
+    }
+    let lower_projection = ProjectionExec::try_new(lower_exprs, 
Arc::clone(child))?;
+
+    // Only commit the split if the child really absorbs the leaf projection.
+    // Otherwise this would just add a projection that helps nobody.
+    let Some(new_child) = 
child.try_swapping_with_projection(&lower_projection)? else {

Review Comment:
   Done — your SLT block went in verbatim at the position you named, and the
   expected plan matched exactly, including `partition_sizes=[3]`.
   
   I measured how far "no test fails" reached, since it decides what the green
   suite was worth. With the check removed:
   
   | suite | result |
   |---|---|
   | sqllogictest, 546 files | `RC=0` |
   | physical-optimizer unit, 50 | `RC=0` |
   | `core_integration` `physical_optimizer`, 668 | `RC=0` |
   
   Nothing in 1264 tests noticed, including the four this PR had added — all of
   mine were positive tests, none checked that the split *doesn't* happen.
   
   Running the same check over the rest of the function found a second unpinned
   promise, so de-duplication has an `EXPLAIN` now too. Values can't show it 
(two
   calls to one marker agree either way) but the scan projection can:
   
   ```
   projection=[column1, CAST(__datafusion_file_row_index@1 AS Int64;N) as 
__datafusion_leaf_0]
   ```
   
   Without de-duplication that line gains a second `CAST(...) as 
__datafusion_leaf_1`,
   which is what the new test fails on.



##########
datafusion/physical-optimizer/src/projection_pushdown.rs:
##########
@@ -61,6 +66,18 @@ impl PhysicalOptimizerRule for ProjectionPushdown {
             })
             .map(|t| t.data)?;
 
+        // Push what can already descend, then split the projections that are
+        // stuck above a `RepartitionExec`, then clean up once more: the split
+        // can leave an outer projection that is now only column references.
+        let plan = plan.transform_down(remove_unnecessary_projections).data()?;
+        let plan = plan
+            .transform_down(|plan| {
+                Ok(match try_split_leaf_projection(&plan)? {
+                    Some(new_plan) => Transformed::yes(new_plan),
+                    None => Transformed::no(plan),
+                })
+            })
+            .data()?;
         plan.transform_down(remove_unnecessary_projections).data()

Review Comment:
   I went a step further than this and removed the walk entirely — please tell 
me
   if you'd rather have your version. What pushed me that way is that it seems 
not
   to do anything even when a split does fire.
   
   Instrumented to diff the plan string before and after it:
   
   | suite | splits fired | walk changed a plan |
   |---|---|---|
   | sqllogictest, 545 files | 19 | 0 |
   | unit | 0 | 0 |
   | `core_integration` | 0 | 0 |
   
   Both directions of the mutation are green too — never running it, and 
running it
   unconditionally — so no test observes it either way.
   
   <details><summary>Why it looks like it can't fire</summary>
   
   `remove_unnecessary_projections` has three paths for a projection
   (`projection.rs:1047`), and as far as I can tell the outer projection takes 
none:
   
   1. `is_projection_removable` wants alias == column name and
      `projection.schema() == input.schema()`. The split renames (`v` over
      `__datafusion_leaf_0`) and keeps the original output schema, for the 
metadata
      reason you raised on the other thread. Instrumented: both false in 20/20 
splits.
   2. `overrides_metadata` returns `Transformed::no`.
   3. `RepartitionExec::try_swapping_with_projection` declines either way. If 
every
      outer expression stayed a plain column, each original expression was 
exactly
      one leaf or exactly one passthrough, so `N >= P + L` and the narrowing 
guard
      refuses. If it does narrow, some expression consumed more than one leaf 
and so
      isn't a plain `Column`, and `all_columns` refuses.
   
   Measured per split (`N` = outer expressions, `P` = passthrough, `L` = 
leaves):
   
   | N | P | L | count | refused by |
   |---|---|---|---|---|
   | 1 | 0 | 1 | 2 | narrowing guard |
   | 2 | 1 | 1 | 8 | narrowing guard |
   | 2 | 1 | 2 | 1 | `all_columns` |
   | 3 | 1 | 1 | 3 | narrowing guard |
   | 3 | 1 | 2 | 4 | narrowing guard |
   | 3 | 1 | 3 | 1 | narrowing guard |
   | 4 | 2 | 2 | 1 | narrowing guard |
   
   18 of 20 on the first guard, 2 on the second — the narrowing case is a nested
   lambda whose single output expression consumes two leaves.
   
   Nothing else in the tree moves either: the split preserves the projection's
   schema, so ancestors see no change, and the lower projection is absorbed by 
the
   source.
   </details>



##########
datafusion/sqllogictest/test_files/file_row_index.slt:
##########
@@ -151,6 +151,145 @@ ORDER BY column1
 statement ok
 DROP TABLE parquet_two_files;
 
+# A leaf expression must still reach the scan when its projection also holds a
+# `KeepInPlace` one; see `try_split_leaf_projection` in
+# `datafusion/physical-optimizer/src/projection_pushdown.rs`. These need a
+# repartition, so `batch_size` is below the row count: do not raise it and do
+# not add a bare `LIMIT`, or the queries pass against broken code.
+
+statement ok
+SET datafusion.execution.batch_size = 2;
+
+# The marker wrapped in arithmetic.
+query II
+SELECT file_row_index() + 1, column1 FROM parquet_table ORDER BY column1
+----
+1 10
+2 20
+3 30
+4 40
+5 50
+
+# The marker is BARE here. An unrelated computed column in the same projection
+# is enough, because the flag is a property of the whole projection.
+query II
+SELECT file_row_index(), column1 + 1 FROM parquet_table ORDER BY column1
+----
+0 11
+1 21
+2 31
+3 41
+4 51
+
+# Not specific to arithmetic: any enclosing expression has the same effect.
+query TI
+SELECT arrow_typeof(file_row_index()), column1 FROM parquet_table ORDER BY 
column1
+----
+Int64 10
+Int64 20
+Int64 30
+Int64 40
+Int64 50
+
+# The sibling marker behaves the same way. Compared against a predicate rather
+# than printed, so the expectation does not depend on the absolute file path.
+query BI
+SELECT length(input_file_name()) > 0, column1 FROM parquet_table ORDER BY 
column1
+----
+true 10
+true 20
+true 30
+true 40
+true 50
+
+# Both markers in one projection, each inside an enclosing expression.
+query BI
+SELECT input_file_name() IS NOT NULL AND file_row_index() >= 0, column1
+FROM parquet_table ORDER BY column1
+----
+true 10
+true 20
+true 30
+true 40
+true 50
+
+# A bare `input_file_name()` beside a computed column: the same trigger as the
+# `file_row_index()` case above. Asserted through the plan rather than the
+# values, because the value is an absolute path.
+query TT
+EXPLAIN SELECT input_file_name(), column1 + 1 FROM parquet_table
+----
+logical_plan
+01)Projection: input_file_name(), CAST(parquet_table.column1 AS Int64) + 
Int64(1)
+02)--TableScan: parquet_table projection=[column1]
+physical_plan
+01)ProjectionExec: expr=[__datafusion_leaf_0@1 as input_file_name(), 
CAST(column1@0 AS Int64) + 1 as parquet_table.column1 + Int64(1)]
+02)--RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1
+03)----DataSourceExec: file_groups={1 group: 
[[WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch/file_row_index/parquet_table/data.parquet]]},
 projection=[column1, input_file_name() as __datafusion_leaf_0], 
file_type=parquet
+
+# The same marker twice. Both calls are one expression, so the split gives them

Review Comment:
   All three are in. They match your expected values and all three error against
   `main` with `is source dependent and cannot be evaluated directly` — TopK 
gives
   `4 51` / `3 41`, the lambda-body marker `[1, 2, 3] 100 alpha` …, CSV `true 
11`
   … `true 61`.
   
   One thing I couldn't reproduce: the dynamic filter reaching the scan in the 
TopK
   query. With `ORDER BY r DESC LIMIT 2`, where `r` is the rewritten marker:
   
   ```
   SortExec: TopK(fetch=2), expr=[r@0 DESC], preserve_partitioning=[true]
     ProjectionExec: expr=[__datafusion_leaf_0@1 as r, column1@0 + 1 as ...]
       RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1
         DataSourceExec: ..., projection=[column1, CAST(...) as 
__datafusion_leaf_0], file_type=parquet
   ```
   
   No predicate on the scan. Sorting on the computed sibling instead (`ORDER BY 
w`)
   does give `predicate=DynamicFilter` and `dynamic_rg_pruning=eligible` — my 
guess
   is `__datafusion_leaf_0` is a synthetic row-index column with no statistics 
to
   prune row groups with, but I haven't chased it.
   
   The test still seems worth keeping for the path it does cover: the sort key 
is a
   column the split produced, and the TopK runs above the repartition with
   `preserve_partitioning=[true]`. So I kept it and rewrote the comment to say 
that
   instead. I'd put your wording in without checking it first, which was my slip
   rather than anything wrong with the test you suggested.



##########
datafusion/sqllogictest/test_files/file_row_index.slt:
##########
@@ -151,6 +151,145 @@ ORDER BY column1
 statement ok
 DROP TABLE parquet_two_files;
 
+# A leaf expression must still reach the scan when its projection also holds a
+# `KeepInPlace` one; see `try_split_leaf_projection` in
+# `datafusion/physical-optimizer/src/projection_pushdown.rs`. These need a
+# repartition, so `batch_size` is below the row count: do not raise it and do
+# not add a bare `LIMIT`, or the queries pass against broken code.
+
+statement ok
+SET datafusion.execution.batch_size = 2;
+
+# The marker wrapped in arithmetic.
+query II
+SELECT file_row_index() + 1, column1 FROM parquet_table ORDER BY column1
+----
+1 10
+2 20
+3 30
+4 40
+5 50
+
+# The marker is BARE here. An unrelated computed column in the same projection
+# is enough, because the flag is a property of the whole projection.
+query II
+SELECT file_row_index(), column1 + 1 FROM parquet_table ORDER BY column1
+----
+0 11
+1 21
+2 31
+3 41
+4 51
+
+# Not specific to arithmetic: any enclosing expression has the same effect.
+query TI
+SELECT arrow_typeof(file_row_index()), column1 FROM parquet_table ORDER BY 
column1
+----
+Int64 10
+Int64 20
+Int64 30
+Int64 40
+Int64 50
+
+# The sibling marker behaves the same way. Compared against a predicate rather
+# than printed, so the expectation does not depend on the absolute file path.
+query BI
+SELECT length(input_file_name()) > 0, column1 FROM parquet_table ORDER BY 
column1
+----
+true 10
+true 20
+true 30
+true 40
+true 50
+
+# Both markers in one projection, each inside an enclosing expression.
+query BI
+SELECT input_file_name() IS NOT NULL AND file_row_index() >= 0, column1
+FROM parquet_table ORDER BY column1
+----
+true 10
+true 20
+true 30
+true 40
+true 50
+
+# A bare `input_file_name()` beside a computed column: the same trigger as the
+# `file_row_index()` case above. Asserted through the plan rather than the
+# values, because the value is an absolute path.
+query TT
+EXPLAIN SELECT input_file_name(), column1 + 1 FROM parquet_table
+----
+logical_plan
+01)Projection: input_file_name(), CAST(parquet_table.column1 AS Int64) + 
Int64(1)
+02)--TableScan: parquet_table projection=[column1]
+physical_plan
+01)ProjectionExec: expr=[__datafusion_leaf_0@1 as input_file_name(), 
CAST(column1@0 AS Int64) + 1 as parquet_table.column1 + Int64(1)]
+02)--RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1
+03)----DataSourceExec: file_groups={1 group: 
[[WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch/file_row_index/parquet_table/data.parquet]]},
 projection=[column1, input_file_name() as __datafusion_leaf_0], 
file_type=parquet
+
+# The same marker twice. Both calls are one expression, so the split gives them
+# a single column and the marker is evaluated once per row, not twice. Both
+# outputs must still agree with each other.
+query III
+SELECT file_row_index(), file_row_index() + 1, column1
+FROM parquet_table ORDER BY column1
+----
+0 1 10
+1 2 20
+2 3 30
+3 4 40
+4 5 50
+
+# Shapes that already worked must keep working: the leaf rules extract from
+# `Filter`, `Sort` and `Aggregate`, so those never depended on this fix.
+query I
+SELECT column1 FROM parquet_table WHERE file_row_index() + 1 = 3
+----
+30
+
+query I
+SELECT column1 FROM parquet_table ORDER BY file_row_index() + 1
+----
+10
+20
+30
+40
+50
+
+query I
+SELECT sum(file_row_index() + 1) FROM parquet_table
+----
+15
+
+# A bare marker on its own is unaffected: no `KeepInPlace` expression shares
+# the projection, so nothing is inserted beneath it.
+query II
+SELECT file_row_index(), column1 FROM parquet_table ORDER BY column1
+----
+0 10
+1 20
+2 30
+3 40
+4 50
+
+# Raising `batch_size` above the row count removes the repartition. This must
+# keep working, and it is also the control that proves trap 1 above is real.

Review Comment:
   Applied verbatim — thanks, that one was mine to catch. "trap 1" was a number 
I
   made up in the PR description and never defined in the file.



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