Repository navigation
fix: let leaf expressions reach the scan past a RepartitionExec - #27152
cool9850311 wants to merge 1 commit into
Conversation
`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 <noreply@anthropic.com>
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(), | |
| )?, | |
| ))) |
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