Repository navigation
fix: split a leaf projection stranded above a limit by a pushed-down OFFSET - #27395
cool9850311 wants to merge 4 commits into
Conversation
…OFFSET A source that supports skip pushdown wraps its scan in a `GlobalLimitExec` for the skip, and when the scan has more than one file group `EnsureRequirements` adds a `CoalescePartitionsExec` under that. Either way the nodes land between the scan and the leaf projection the logical rules placed on it. Neither lets that projection swap back down, so `LimitPushdown` leaves two projections adjacent and the next `ProjectionPushdown` merges them, hoisting the leaf expression back above the limit: `file_row_index()` then errors at execution and `get_field` reads the whole struct. `try_split_leaf_projection` already handles the same shape above a `RepartitionExec`. Generalise its host from one hardcoded node to a chain: `split_host_child` accepts a round-robin `RepartitionExec`, a `GlobalLimitExec` or a `CoalescePartitionsExec`, and `split_host_chain` walks as many as are stacked. A single `OFFSET` on a multi-file table already stacks two, so stopping at the first would leave that shape broken. Each arm evaluates no expression of its own, has one child, and passes its child's schema through. That last property is what lets the outer expressions keep their column indices across several hosts, and it holds by construction rather than by luck: none of the three `compute_properties` takes a schema, and each clones its input's equivalence properties, so a host's schema is its child's. A fourth arm that projects would have to re-establish that, and nothing here will catch it; the doc comment says so. Splitting rather than swapping the whole projection past the limit is what keeps the cost right: only the leaf half descends, so the rows the limit skips pay for a field access the source was going to make anyway and never for the outer expressions. Part of apache#27131 Co-Authored-By: Claude Opus 5 <[email protected]>
…imit Every `OFFSET` in the new sections sets a skip that differs from the fetch, never `OFFSET n LIMIT n`: the split rebuilds the limit, and with the two numbers equal every assertion would hold just as well if they were swapped. `file_row_index.slt` gets five value queries for the marker UDFs, all of which error before the fix, an `EXPLAIN` for the rewritten plan, and two controls that never depended on it: `OFFSET 0`, and `OFFSET` with no `LIMIT`, where the skip is not pushed into the scan at all. It also gets a json table: `ListingTable::supports_skip_pushdown` is unconditional and the `GlobalLimitExec` wrap only looks at `skip`, so the defect is not specific to parquet, and every other query in the file is. `file_row_index()` itself is parquet-only, so that section uses `input_file_name()`. `projection_pushdown.slt` gets two value queries and four `EXPLAIN`s covering a bare leaf, a leaf beside a computed sibling, a leaf nested inside `upper(...)`, and the no-leaf case. The value queries pass before the fix on purpose: they pin the rows, which is the one thing a plan assertion cannot show. A dropped or swapped `skip` turns the plan text red as well, so they are not the only cover for it. The no-leaf `EXPLAIN` is labelled a control rather than a guard, because the scan prunes to `[id]` whether or not the rule bails out early; the unit test is what pins the bail-out. A further value query puts the marker inside a lambda body under an `OFFSET`: the lambda queries above it have no limit and the limit queries have no lambda, so nothing covered both, and it is also what pins the parameter rebase. Both files get a section with two file groups, which is the shape that stacks a `CoalescePartitionsExec` under the limit. Those count the surviving rows instead of listing them, because which rows survive the skip depends on the order the partitions are merged in, and `count(r)` rather than `count(*)`, so `optimize_projections` cannot drop the marker and leave nothing to evaluate. Neither new section contains a `RepartitionExec`, so both exercise this path rather than the one apache#27152 fixed. Ten unit tests hand-build the tree, because plain SQL does not reach these shapes: the split committing with both of the limit's numbers intact, the chain descending past two hosts and past three with a distinct pair of numbers on each, a `CoalescePartitionsExec` host keeping its own `fetch`, a `fetch` of `None`, a projection with no leaf expression left alone, `LocalLimitExec` kept out of the host set, a source that refuses the lower projection, the three nodes that meet the host conditions but are left out of the allowlist appearing instead as the chain bottom, and a `FilterExec` bottom, which takes the lower projection without absorbing it so the scan narrows only on this rule's second scheduled run. Part of apache#27131 Co-Authored-By: Claude Opus 5 <[email protected]>
jayzhan211
left a comment
There was a problem hiding this comment.
@cool9850311 thanks for following up on the OFFSET row! Looks good to me. The three inline comments are optional.
Co-authored-by: Jay Zhan <[email protected]>
alamb
left a comment
There was a problem hiding this comment.
Thanks @cool9850311 and @jayzhan211 -- the code looks ok but I think the comments make this really hard to understand. Can you please cut them down (by reading all comments, and removing anything you don't understand yourself)
I also have some ideas on how to improve the tests
Thank you for the contribution, and sorry for the request, I just think we need to fight to keep DataFusion understandable
| #[tokio::test] | ||
| async fn split_descends_through_global_limit() -> Result<()> { | ||
| let (schema, scan) = limit_split_scan()?; | ||
| // What a source that pushes `OFFSET` down builds: the skip becomes a |
There was a problem hiding this comment.
I don't understand these comments -- they are trying to describe in english the plan shape
I think these tests would be much more readable if instead of trying to describe this in textual comments, you instead add a snapshot of the orignal and the optimized plan
Sort of like
assert_snapshot!(input_and_optimized_plan_text(projection)?,
@r"
--- Input Plan
...
--- OutputPlan
ProjectionExec: expr=[__datafusion_leaf_0@1 as v, id@0 + 1 as w]
GlobalLimitExec: skip=1, fetch=3
DataSourceExec: file_groups={1 group: [[x]]}, projection=[id, get_field(s@1, value) as __datafusion_leaf_0], file_type=parquet
"
More background on the rationale here: https://andrew.nerdnetworks.org/blog/2026/11/13/transcript-testing/
| // a plain column, and is then no narrower than the widened child, or it | ||
| // narrows and holds a computed one. `RepartitionExec` declines both, it | ||
| // is not removable because it renames, and nothing else moved. | ||
| // stuck above a chain of the nodes `split_host_child` accepts. The |
There was a problem hiding this comment.
Can you please update these comments to not refer to implementation details elsewere -- pelase try and rephrase in your own words
| /// changed the width would leave both against the wrong one. | ||
| fn split_host_child(node: &Arc<dyn ExecutionPlan>) -> Option<&Arc<dyn ExecutionPlan>> { | ||
| if let Some(repartition) = node.downcast_ref::<RepartitionExec>() { | ||
| // Hash and Range partitioning reference columns by index, which would |
There was a problem hiding this comment.
likewise, these comments is far too verbise -- please rewrite these in your own words rather than leaving this re-explanation of the code in
| /// expression sharing it never reaches the source. Splitting leaves the | ||
| /// computation above the repartition, still parallelised. | ||
| /// Two nodes strand a `MoveTowardsLeafNodes` expression above the source this | ||
| /// way, and neither lets the whole projection past: |
| @@ -314,6 +314,191 @@ SELECT file_row_index() + 1, column1 FROM parquet_table ORDER BY column1 | |||
| # `RESET` restores the global default rather than the harness value, which the | |||
| # harness rejects as leftover configuration. | |||
|
|
|||
| # A leaf expression must also reach the scan when the source pushes an `OFFSET` | |||
There was a problem hiding this comment.
can you please reword these comments to explain the case they are teseting, not details about the code that implements it?
Sorry for not paying attention on readability of comments and tests, will work on this. |
…ments down Addresses the review on the tests and on the comments. Tests: the ten split tests asserted only the optimized plan, through a local `optimized_plan_text` helper. They now use `OptimizationTest`, which this test directory already has (`pushdown_sort.rs` uses it) and which renders the input plan and the output side by side, so a reader sees what the rule changed without opening the test body. It also compares the plan's schema before and after whenever the rule's `schema_check` is true, which `ProjectionPushdown` sets, so each of the ten now carries a schema assertion as well. Two tests needed restructuring to fit: the pass-through-bottom case was a loop over two node types, which inline snapshots cannot be, and is now two sequential assertions; the filter case takes one snapshot per scheduled run of the rule. The same change removes the inline snapshot formatting that the earlier review round asked about. Comments: the first pass measured the surroundings but did not treat the measurement as a ceiling. The second pass asks of every block whether it can be removed rather than whether it can be shortened, keeping one only if a reader would otherwise misread what the code does, or would make a change that breaks something no test catches. What went from the rule: the narration of `optimize()`'s two passes, which the two lines say already; the `split_host_child` and `split_host_chain` doc comments, since the schema pass-through is enforced by `schema_check` and the multi-host chain is stated by `split_descends_through_limit_and_coalesce`; the allowlist note, which `split_declines_local_limit` now carries alone; the `SplitHostChain` type and `child` field docs; the termination guard's note, which claimed non-termination the suite does not demonstrate; and the paragraph on what strands a leaf expression, which is context for the pull request rather than for the function. What stayed is the five facts not inferable from the code: why only round-robin partitioning qualifies, why `CoalescePartitionsExec` is in the chain, that `hosts` runs outermost first, that a `Some` from `try_swapping_with_projection` does not mean the source absorbed the projection, and why the hosts' properties are recomputed. In the tests and the `.slt` files the helper docs and the sentences restating a neighbouring query went; the ones that keep a test from looking redundant, or that explain a deliberately weak assertion, stayed. The termination guard's comment was also the one the review asked to reword. Measuring it first showed the claim was wrong: removing the guard does not hang the suite, it changes one test's output, so the comment went rather than being reworded. Rule: 78 comment lines down to 12, hunk density 64.5% to 21.8%, whole-file 11.9% to 9.4% against a 10.4% baseline, and the longest block this branch adds down from 15 lines to 4 (the file's own maximum is 7). Tests 17.7% to 6.0%, projection_pushdown.slt 18.1% to 10.4%, file_row_index.slt 22.6% to 14.4%. cargo fmt --check clean, clippy clean on datafusion-physical-optimizer --all-targets and on the core_integration test target, 41 projection pushdown tests and 51 physical-optimizer unit tests passing, both touched .slt files green, and the full sqllogictest suite green before the comment pass. Co-Authored-By: Claude Opus 5 <[email protected]>
Which issue does this PR close?
Part of #27131. Closes the
OFFSETrow of that issue's table; the twoWindowshapes are left for follow-ups.
Rationale for this change
#27152 fixed the
RepartitionExecpath. TheOFFSETrow of the issue's owntable still fails, and for a different reason:
There is no
RepartitionExecin these plans, and none of the issue's threecontrols (
batch_sizeabove the row count,target_partitions = 1, a tablesmaller than
batch_size) changes the outcome. It reproduces on a 5-row table.Measured rule by rule:
directly on the
TableScan;GlobalLimitExecfor the skip (catalog-listing/src/table.rs:782), whichlands between the scan and that projection. Judging from the gate at
push_down_limit.rs:112(skip > 0 && scan.source.supports_skip_pushdown())this arrived with feat: Push down offset/skip to TableScan #25404, though I have not built that commit to confirm it;
EnsureRequirementsadds aCoalescePartitionsExecunder the limit, so there are two nodes in theway, not one;
GlobalLimitExechas notry_swapping_with_projection, so the firstProjectionPushdowncannot get the projection back down;LimitPushdownremoves the now-redundant outerskip=0limit, leaving thetwo projections adjacent, and the second
ProjectionPushdownmerges them —hoisting the leaf back above the limit, where
file_row_index()is evaluateddirectly and errors out, and
get_fieldreads the whole struct.Base plans (
EXPLAIN, indent format):The bare marker alone in its projection fails too, so this is not the issue's
"a
KeepInPlaceexpression shares the projection" condition.Cost, measured once by hand on the issue's own 20k-row struct table: a bare
s['a']with anOFFSET 5reads 178.2 KB on main and 67.6 KB withthis change (2.6x). That is a one-off reading, not an assertion -- what the
tests pin is the projection the scan is left with, which is the thing a plan
can show.
What changes are included in this PR?
try_split_leaf_projectionalready splits a projection stranded above aRepartitionExec, pushing only the leaf half down. This is the same shape, sothe change generalises the host from one hardcoded node to a chain of them:
split_host_childaccepts a round-robinRepartitionExec, aGlobalLimitExecor a
CoalescePartitionsExec, andsplit_host_chainwalks as many as arestacked. A single
OFFSETon a multi-file table already stacks two, so stoppingat the first would leave that shape broken.
Each arm evaluates no expression of its own, has one child, and passes its
child's schema through — the last property is what lets the outer expressions
keep their column indices across several hosts. It holds by construction, not
by luck: none of the three
compute_propertiestakes a schema, and each clonesits input's equivalence properties, so a host's schema is its child's. A fourth
arm that projects would have to rebase the outer expressions itself, and the
rule sets
schema_check, so every unit test compares the plan's schema beforeand after and would fail.
Splitting, rather than letting the whole projection swap past the limit, is what
keeps this honest: the rows the limit skips pay only for a field access the
source was going to make anyway, never for the outer expressions.
upper(...)stays above the limit; the scan reads two fields instead of thestruct. Every shape without a leaf expression is left byte-identical, including
OFFSET 0,OFFSETwith noLIMIT, a projection of only computed expressions,and a pure column projection.
LocalLimitExecis not a host: no shape in the test corpus strands a leafexpression above one, because without a skip the source absorbs the projection
before any limit is inserted. There is a test pinning that it stays out.
Are these changes tested?
Yes. Every
OFFSETin the new tests sets a skip that differs from the fetch:the split rebuilds the limit, and with the two numbers equal every assertion
would hold just as well if they were swapped.
The unit tests use
OptimizationTest, the harness this test directory alreadyhas, so each one shows the input plan beside the output and, because the rule
sets
schema_check, compares the plan's schema before and after.file_row_index.sltinput_file_name(),arrow_typeof(marker). All five error on mainEXPLAINs,file_row_index.sltOFFSET 0andOFFSETwith noLIMITas controls that never depended on the fixEXPLAINs,projection_pushdown.sltupper(...), and the no-leaf control.sltfilesGlobalLimitExecoverCoalescePartitionsExecshape. Counted rather than listed, because which rows survive the skip depends on partition merge order;count(r)notcount(*), so the marker is still evaluatedsplit_descends_through_global_limitsplit_descends_through_limit_and_coalescesplit_descends_through_global_limit_without_fetchfetch = None, which plain SQL does not reachsplit_declines_global_limit_without_leafsplit_declines_local_limitLocalLimitExecstays out of the host setsplit_declines_when_the_child_refuses_the_projectionsplit_keeps_the_coalesce_fetchCoalescePartitionsExechost keeps its ownfetchacross the rebuild. Thefetchin the.sltplans is added byLimitPushdownafter the split, so it does not cover thissplit_descends_through_three_hostssplit_commits_at_a_pass_through_bottomCooperativeExecandBufferExecare not hosts, but as the chain bottom they delegate the swap to their input, so the split commits and the scan narrows.CoalesceBatchesExecbehaves the same way, but it is deprecated in 52.0.0 and nothing constructs it, so it is not pinnedsplit_below_a_filter_narrows_on_the_second_runFilterExecbottom takes the lower projection by holding it over its own input, so the scan narrows only on this rule's second scheduled runbatch_size = 2pair,projection_pushdown.sltRepartitionExeclanding above the limit, so the split acts on the leaf projection underneath it. The one new query whose plan contains aRepartitionExecOFFSET,projection_pushdown.sltinput_file_name()underOFFSETon json,file_row_index.sltsupports_skip_pushdownis unconditional), and every other query in the file is parquetEvery new
.sltquery but one has noRepartitionExecin its plan, so theyexercise this path and not #27152's. The exception is the
batch_size = 2pairat the end of the
projection_pushdown.sltsection, which lowers the batch sizeon purpose to put a round-robin repartition above the limit and cover the two
together.
Teeth check — each mutation applied on its own against this branch's head,
then
cargo test -p datafusion --test core_integration projection_pushdown(41tests) and both touched
.sltfiles. Counts are of failing tests and failing.sltrecords.Seven of the unit tests expect a split to commit beneath a
GlobalLimitExec;the table calls them "the seven".
GlobalLimitExec.sltqueries: 9 of the 11 infile_row_index.slt, where only the two controls survive, and 7 of the 12 inprojection_pushdown.slt, where the value queries still return the right rows and only the plans changeCoalescePartitionsExecsplit_descends_through_limit_and_coalesceandsplit_keeps_the_coalesce_fetch, and the 4 multi-file plan assertionssplit_descends_through_three_hosts, and the same 4 queriesskipandfetchswapped on the rebuilt limit.sltqueries -- the widest of any mutation hereskipdropped.sltqueriesfetchdroppedsplit_descends_through_global_limit_without_fetchalready hasfetch = None, and no.sltquery notices, plan or valueleaves.is_empty()guardsplit_declines_global_limit_without_leafandsplit_below_a_filter_narrows_on_the_second_run, whose inner projection has no leaf of its own. No.sltqueryLocalLimitExecsplit_declines_local_limitonlysplit_declines_when_the_child_refuses_the_projectionand the memory-sourceEXPLAINalready inprojection_pushdown.sltChildrenPropertiesMode::Recompute->Keepsplit_keeps_field_metadataamong them, and 21projection_pushdown.sltrecords, including repartition plans that were already thereCoalescePartitionsExechost rebuilt without itsfetchsplit_keeps_the_coalesce_fetchonly -- nothing else noticesOne semantic note: a leaf expression that moves below the limit is evaluated on
the rows the
OFFSETskips. That is not new here — without a limit the logicalrules already push leaf expressions to the source — but a user function that
declares
MoveTowardsLeafNodesand errors on some rows will now error onskipped rows.
Are there any user-facing changes?
Queries that combined a leaf expression with
OFFSETand errored now returnrows; ones that silently read a whole struct column now prune to the fields they
use. No API change.
🤖 Generated with Claude Code