Repository navigation
fix: let leaf expressions reach the scan past a RepartitionExec - #27152
Conversation
dd4a8ee to
e501896
Compare
jayzhan211
left a comment
There was a problem hiding this comment.
@cool9850311 thanks for tracking this down so thoroughly! The split fixes the reported errors and pruning, and scanned bytes drop from 178.2 KB to 67.6 KB in my s['a'] + 1 check. I left two inline comments on shapes that work on main but fail with this PR.
Two queries from the issue's tables still error with this PR, because a different node sits under the projection:
SELECT file_row_index() + 1 FROM big OFFSET 5 LIMIT 3; -- GlobalLimitExec
SELECT file_row_index() AS r, sum(column1) OVER (ORDER BY column1) AS w FROM big; -- BoundedWindowAggExecThese need different handling, so a follow-up is fine. Could you change Closes #27131 to Part of #27131 so the issue stays open for them?
| return Ok(Transformed::yes(Arc::new(remapped) as Arc<dyn PhysicalExpr>)); | ||
| } | ||
| } | ||
| Ok(Transformed::no(node)) |
There was a problem hiding this comment.
Lambda parameters are indexed after the input columns, so they go stale when the split widens the input. This query works on main but now fails with Field of physical LambdaVariable with index 0 doesn't match batch field:
SET datafusion.sql_parser.dialect = databricks;
SELECT array_transform(arr, x -> x + 1) AS a, s['value'] AS v, s['label'] AS l FROM t ORDER BY v;This fixes it, with child_schema.fields().len() passed in as a new old_input_len parameter and let new_input_len = first_leaf + leaves.len(); added next to first_leaf. Could you add the query above to the SLT, plus a nested lambda case?
| Ok(Transformed::no(node)) | |
| // Lambda parameters are appended after the input columns, so they move | |
| // when the split changes the input width. | |
| if let Some(variable) = node.downcast_ref::<LambdaVariable>() | |
| && old_input_len != new_input_len | |
| { | |
| let rebased = LambdaVariable::new( | |
| variable.index() - old_input_len + new_input_len, | |
| Arc::clone(variable.field()), | |
| ); | |
| return Ok(Transformed::yes(Arc::new(rebased) as Arc<dyn PhysicalExpr>)); | |
| } | |
| Ok(Transformed::no(node)) |
| Ok(Some(Arc::new(ProjectionExec::try_new( | ||
| new_exprs, | ||
| new_repartition, | ||
| )?))) |
There was a problem hiding this comment.
try_new drops metadata that the planner set through try_new_with_schema_metadata, so this select now fails with PhysicalOptimizer rule 'ProjectionPushdown' failed. Schema mismatch, while it works on main:
ctx.table("t").await?.select(vec![
get_field(col("s"), "value").alias_with_metadata("v", Some(metadata.into())),
(col("id") + lit(1i64)).alias("w"),
])?;Could you apply this and add that select as a test, with batch_size below the row count so the repartition appears, asserting the output field keeps its metadata?
| Ok(Some(Arc::new(ProjectionExec::try_new( | |
| new_exprs, | |
| new_repartition, | |
| )?))) | |
| Ok(Some(Arc::new( | |
| ProjectionExec::try_new_with_schema_metadata( | |
| new_exprs, | |
| new_repartition, | |
| projection.schema().as_ref(), | |
| )?, | |
| ))) |
e501896 to
b69dd03
Compare
jayzhan211
left a comment
There was a problem hiding this comment.
@cool9850311 thanks for the quick update! The lambda and metadata fixes both hold, including nested lambdas and a lambda whose input width doesn't change. One thing is left before this can merge: no test covers a source that refuses the split, see the inline comment on that check. The other comments are optional.
|
|
||
| // 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 { |
There was a problem hiding this comment.
No test fails if this check is removed. SELECT s['value'] AS v, id + 1 AS w on a memory table then gets an extra ProjectionExec that the source never absorbs, and every test in this PR still passes. The PR description promises this limit, so could you pin it here with an EXPLAIN in projection_pushdown.slt?
SLT test, placed before the batch_size = 8192 restore (passes on this PR, fails with the check removed)
# A source that does not absorb the projection keeps the plan unchanged: the
# split must not add a projection nobody can use.
statement ok
CREATE TABLE leaf_split_mem AS
SELECT column1 AS id, column2 AS s FROM VALUES
(1, {value: 100}), (2, {value: 200}), (3, {value: 150}),
(4, {value: 300}), (5, {value: 250});
query TT
EXPLAIN SELECT s['value'] AS v, id + 1 AS w FROM leaf_split_mem;
----
logical_plan
01)Projection: get_field(leaf_split_mem.s, Utf8("value")) AS v, leaf_split_mem.id + Int64(1) AS w
02)--TableScan: leaf_split_mem projection=[id, s]
physical_plan
01)ProjectionExec: expr=[get_field(s@1, value) as v, id@0 + 1 as w]
02)--RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1
03)----DataSourceExec: partitions=1, partition_sizes=[3]
statement ok
DROP TABLE leaf_split_mem;There was a problem hiding this 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.
| 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() |
There was a problem hiding this comment.
Optional cleanup: the last walk only matters when a split fired. Skipping it otherwise means a plan with no split gets the same single cleanup walk as on main. All 545 SLT files pass with this change.
| 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() | |
| let split = plan.transform_down(|plan| { | |
| Ok(match try_split_leaf_projection(&plan)? { | |
| Some(new_plan) => Transformed::yes(new_plan), | |
| None => Transformed::no(plan), | |
| }) | |
| })?; | |
| if split.transformed { | |
| split | |
| .data | |
| .transform_down(remove_unnecessary_projections) | |
| .data() | |
| } else { | |
| Ok(split.data) | |
| } |
There was a problem hiding this 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.
Why it looks like it can't fire
remove_unnecessary_projections has three paths for a projection
(projection.rs:1047), and as far as I can tell the outer projection takes none:
is_projection_removablewants alias == column name and
projection.schema() == input.schema(). The split renames (vover
__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.overrides_metadatareturnsTransformed::no.RepartitionExec::try_swapping_with_projectiondeclines either way. If every
outer expression stayed a plain column, each original expression was exactly
one leaf or exactly one passthrough, soN >= P + Land the narrowing guard
refuses. If it does narrow, some expression consumed more than one leaf and so
isn't a plainColumn, andall_columnsrefuses.
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.
| 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 |
There was a problem hiding this comment.
Optional: three shapes fail on main and work with this PR, but have no test yet: a TopK whose dynamic filter reaches the scan through the rewritten marker column, input_file_name() on a CSV source, and a marker inside a lambda body. The queries below all pass on this PR.
The three SLT tests and which file each goes in
In file_row_index.slt, inside the batch_size = 2 block:
query II
SELECT file_row_index() AS r, column1 + 1 FROM parquet_table ORDER BY r DESC LIMIT 2
----
4 51
3 41In projection_pushdown.slt, inside the databricks dialect block:
query ?IT
SELECT array_transform(arr, x -> x + file_row_index()) AS a, s['value'] AS v, s['label'] AS l
FROM leaf_split ORDER BY v;
----
[1, 2, 3] 100 alpha
[8] 150 gamma
[5, 6] 200 beta
[14] 250 epsilon
[10, 11, 12] 300 deltaIn input_file_name.slt, while csv_table exists:
statement ok
SET datafusion.execution.batch_size = 2;
query BI
SELECT length(input_file_name()) > 0, column1 + 1
FROM csv_table
ORDER BY 2
----
true 11
true 21
true 31
true 41
true 51
true 61
statement ok
SET datafusion.execution.batch_size = 8192;There was a problem hiding this 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.
| # 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. |
There was a problem hiding this comment.
Optional: "trap 1" is only defined in the PR description, so this comment won't make sense to someone reading the file.
| # 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. | |
| # Raising `batch_size` above the row count removes the repartition. This must | |
| # keep working, and it is also the control showing that `batch_size` is what | |
| # triggers the repartition. |
There was a problem hiding this 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.
`ProjectionExec::benefits_from_input_partitioning` is true as soon as any expression in the projection has `KeepInPlace` placement. That single flag does two opposing things: `EnsureRequirements` inserts a `RepartitionExec` beneath the projection, and `RepartitionExec::try_swapping_with_projection` then refuses to let the same projection descend. A `MoveTowardsLeafNodes` expression sharing that projection can therefore never reach the source. `file_row_index()` and `input_file_name()` are then evaluated directly and fail with "is source dependent and cannot be evaluated directly", while `get_field` silently reads the whole struct instead of pruning to the field. The leaf call does not have to be wrapped in anything: an unrelated computed column in the same projection is enough, because the flag is a property of the whole projection. `ProjectionPushdown` now splits such a projection. The leaf expressions move into a lower projection that the source absorbs, and the computation stays above the repartition where it is still parallelised, so the intent of both guards is kept rather than one traded for the other. The split is committed only when the child really absorbs the leaf projection, and only `RoundRobinBatch` repartitioning is handled, because hash and range partitioning reference columns by index and would need remapping. Only the columns the outer expressions still reference outside the extracted leaves are passed through, so `s['a'] + 1` no longer reads the whole struct alongside the extracted field. Regression tests cover both symptom classes. Two traps are documented in the test files because either one makes the tests pass against broken code: the table must exceed `batch_size` for a repartition to be inserted at all, and a bare `LIMIT n` is pushed into the scan and hides the bug again. Closes apache#27131 Co-Authored-By: Claude Opus 5 <[email protected]>
…ection
Two regressions in the projection split, both reported in review. Each query
below works on `main` and failed with the split.
Lambda parameters are indexed after the input columns, so widening the input
leaves them pointing at the wrong column:
SELECT array_transform(arr, x -> x + 1), s['value'], s['label'] FROM t;
`remap_filter_expr` in this file already rebases parameters for the join filter
pushdown, so the node-level shift moves into `rebase_lambda_variable` and both
callers use it rather than carrying two copies.
The symptom depends on where the stale index lands. Out of range it is dropped
and the query happens to be correct; in range it fails `LambdaVariable`'s field
check; in range with a matching name and type it reads the wrong column and
returns wrong values with no error. The last case is covered, since the field
check is a backstop rather than the guarantee.
`ProjectionExec::try_new` derives the output schema from the expressions alone
and drops the field metadata the planner attached through
`try_new_with_schema_metadata`, which the rule's own `schema_check` then
rejects:
PhysicalOptimizer rule 'ProjectionPushdown' failed. Schema mismatch.
The split now rebuilds the projection with that constructor, taking the
metadata from the projection it replaces.
Tests. The value queries live in `projection_pushdown.slt`; the fixture gains
`arr` and `tags`, appended after `s` so the existing `get_field(s@1, ...)`
assertions do not move. A nested lambda needs two leaf expressions: with one
the split is width-neutral, the parameter does not move, and the query passes
against unfixed code. The metadata test is a Rust test because no SQL syntax
attaches field metadata; it asserts on the physical plan's schema, not the
DataFrame's, and uses Parquet because only a source that absorbs the leaf
projection makes the split fire.
Part of apache#27131
Co-Authored-By: Claude Opus 5 <[email protected]>
…fixes Review follow-up. Nothing here changes what the split computes. The split is only committed when the child really absorbs the leaf projection, but no test held that: removing the check left all 545 SLT files, 50 physical-optimizer unit tests and 669 `core_integration` ones green. A memory table refuses the lower projection, because `all_alias_free_columns` rejects an aliased `get_field`, and that is also what any node that does not override `try_swapping_with_projection` does. An `EXPLAIN` on such a table now pins the plan; with the check removed it fails on the extra `ProjectionExec`. Collecting each distinct leaf once was likewise unpinned, and the values cannot show it: two calls to the same marker agree with each other whether they share a column or not. The duplicated-marker case gains an `EXPLAIN`, which fails with a second `__datafusion_leaf_1` in the scan projection if the de-duplication goes. Three shapes the split fixes had no test either. Each reaches a different path: a TopK sorts on a column the split produced and runs above the repartition with `preserve_partitioning=[true]`; a marker inside a lambda body has to be found through `LambdaExpr`; and CSV is a second scan implementation, while every other test here uses Parquet. All three error with `is source dependent and cannot be evaluated directly` without the split. The cleanup walk this PR had added after the split is removed rather than made conditional, because it cannot do anything. `remove_unnecessary_projections` has three paths for a projection: it is not removable, since the split renames and keeps the original output schema; `overrides_metadata` changes nothing; and `RepartitionExec` declines it either for not narrowing, when every expression stayed a plain column, or for holding a computed one, when an expression consumed more than one leaf and the projection did narrow. Nothing else in the tree moved, as the split preserves the projection's schema. Instrumenting the walk confirmed it changed no plan in any of the 19 splits the suite fires. Handling only `RoundRobinBatch` was unpinned the same way, and no SQL reaches that shape: the guard is hit 1326 times across the suite, every one of them round robin. The rule is a plain plan-to-plan function though, so the plan can be built by hand, which `split_declines_hash_repartition` does, after `ensure_requirements.rs` for the hash repartition and `test_utils.rs` for the scan. The scan has to be Parquet rather than `EmptyExec`: only a source that absorbs the leaf projection lets the split commit once the check is gone. Also drops a comment reference to a trap number that only exists in the pull request description, and rewrites the one on `collect_leaf_exprs` that used `s['a']['b']` as its example: the logical optimizer folds that into a single call, so the branch it describes never sees it. It now says what stopping at a leaf avoids instead. Part of apache#27131 Co-Authored-By: Claude Opus 5 <[email protected]>
b69dd03 to
4ee8c64
Compare
Which issue does this PR close?
file_row_index()/input_file_name()error andget_field()loses pruning when their projection also contains aKeepInPlaceexpression #27131.Rationale for this change
A
MoveTowardsLeafNodesexpression loses leaf pushdown as soon as it shares aprojection with a
KeepInPlaceexpression and aRepartitionExecis inserted.The two symptom classes are:
ProjectionExec::benefits_from_input_partitioningis true as soon as anyexpression in the projection is
KeepInPlace(the default placement). That oneflag does two opposing things:
EnsureRequirementsinserts aRepartitionExecbeneath the projection, andRepartitionExec::try_swapping_with_projectionthen refuses to let that sameprojection descend past it.
So the leaf expression can never reach the source. The leaf call does not have to
be wrapped in anything — an unrelated computed column in the same projection is
enough, because the flag is a property of the whole projection, not of one
expression.
get_fieldis the headline case: nested column pruning is itsdocumented purpose and it degrades silently.
The trigger needs a
RepartitionExec, so the table has to exceedbatch_size(
EnsureRequirementsusesbatch_sizeas a row-count threshold). That is whythis has not shown up in the existing small-fixture tests.
What changes are included in this PR?
ProjectionPushdownnow splits such a projection instead of giving up:parallelised.
Both guards keep their intent rather than one being traded for the other: the
scan only gains the cheap leaf expression, and the expensive computation is still
run in parallel.
Deliberate limits:
child.try_swapping_with_projectionactuallyreturns
Somefor the lower projection, i.e. when the source really doesabsorb it. Otherwise the plan is left untouched.
Partitioning::RoundRobinBatchis handled. Hash and range partitioningreference columns by index, so changing the schema below them would require
remapping their expressions; that is left out of this PR.
leaves are passed through, so
s['a'] + 1does not end up reading the wholestruct alongside the extracted field.
What is the testing strategy for this PR?
datafusion/sqllogictest/test_files/file_row_index.slt— value-basedregression tests for the error class: bare marker plus a computed sibling,
+ 1,arrow_typeof, two different markers mixed, a duplicated marker, andnon-regression for
Filter/Sort/Aggregate.datafusion/sqllogictest/test_files/projection_pushdown.slt— plan assertionsfor the
get_fieldclass. Value comparison can never catch that one: withoutpruning the results are still correct, only more data is read, so the test has
to assert the pruned
projection=[...]in the plan.datafusion/physical-optimizerunit tests: 50 passed.Two traps are called out in comments in both test files, because either one makes
the tests pass against unfixed code:
batch_size, or noRepartitionExecis inserted andthere is nothing to reproduce;
LIMIT nis pushed into the scan and hides the bug again.Verified by reverting the source file with
git checkout main -- <file>: the newtests then fail with 6 errors and 16 plan-diff lines.
Are there any user-facing changes?
No API changes. Queries that previously failed with "is source dependent and
cannot be evaluated directly" now succeed, and
get_fieldregains nested columnpruning when it shares a projection with other expressions.
One plan-shape note for #25456 (inlining projection columns), which touches
adjacent code: its test pins
file_row_index()used twice as not inlined. Whenthe split here fires, the two identical leaf calls are de-duplicated into a
single lower-projection column. Values are unaffected; only the plan shape
differs. Happy to rebase in whichever order is convenient.
🤖 Generated with Claude Code