Uh oh!
There was an error while loading. Please reload this page.
fix(lambda): only push referenced params into the merged batch - #24162
Conversation
Thank you for opening this pull request! Reviewer note: cargo-semver-checks reported the current version number is not SemVer-compatible with the changes in this pull request (compared against the base branch). Details |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@## main #24162 +/- ##
==========================================
+ Coverage 81.02% 81.03% +0.01%
==========================================
Files 1105 1107 +2 Lines 380669 385292 +4623 Branches 380669 385292 +4623 ==========================================
+ Hits 308446 312233 +3787 - Misses 53994 54641 +647 - Partials 18229 18418 +189 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
This is a nice PR, and thank you for fixing the bug!
In addition to the things I noted, I also directed an AI review and it came up with below. #1, #2, and #5 look important to me, but I haven't dug deeper yet.
Issues
1. No test covers the actual fix (blocking)
All five new tests assert LambdaExpr::used_params() — the metadata. Nothing exercises LambdaArgument::new → merge_captures_with_variables, which is where the bug lived and where the layout contract is enforced. The PR body says "added two new tests… for the unused-parameter case"; those tests would still pass if merge_captures_with_variables ignored used_param_indices entirely.
This is testable without a real multi-param HOF: in datafusion/expr/src/higher_order_function.rs build a LambdaArgument with params = [k, v], used_params = {"v"}, body = LambdaVariable::new(0, v_field), and call evaluate with two distinguishable closures. Assert you get v's values, not k's. Add a captures variant too, since the captures ++ params offset is the other half of the contract.
2. Name-based matching is redundant and now a silent-corruption hazard
LambdaArgument::new takes &HashSet<String> and re-derives positions by name — but the caller already has positional alignment: params is built at physical-expr/higher_order_function.rs:336 by zip(lambda.params(), lambda_params), so index i of paramsis index i of lambda.params(). The round-trip name → index buys nothing.
The cost is a new failure mode. LambdaVariable can be built directly (proto decode, lambda_variable.rs:189, tests) with a field name that doesn't match the declared param. Before this PR a mismatch was harmless; now it silently drops the param from used_params, the column never gets pushed, and the body reads the wrong slot — no error, wrong answers.
Suggest: cache used_param_indices: Vec<usize> on LambdaExpr, expose used_param_indices() -> &[usize], and have LambdaArgument::new take &[usize]. That removes the name coupling, removes per-batch string hashing (LambdaArgument::new runs per evaluate, i.e. per batch), makes LambdaExpr::clone cheaper, and avoids putting datafusion_common::HashSet (a hashbrown re-export) in the public API.
If you keep names, at least assert in LambdaExpr::try_new that every name in used_params is in params — currently an unmatched name is a silent no-op.
3. Missing test for the f_up pop
shadow_stack.pop() is never verified. Every shadowing test has a single nested lambda, so a missing pop would pass all five. Add: outer (k, v), body = f(g(arr, (k) -> k), k) — the second k sits after the nested lambda at the same level, so a leaked frame would wrongly mark outer k unused.
4. variables.first() row-count derivation
let row_count = match variables.first(){Some(first) => first()?.len(),None => 0,};Two things. The comment says evaluating a variable is "essentially free," but that's an assumption about HOF impls, not a guarantee — a closure that builds an index/range array is not free, and here it's built purely to be discarded. Passing the row count down from LambdaArgument::evaluate would be exact.
The None => 0 arm is unreachable in practice and untested; consider internal_err! instead of silently producing a 0-row batch, which would be very hard to debug if it ever fires.
5. Undocumented behavior change for HOF authors
Unused params' closures are no longer invoked at all. That's a genuine perf win (skips materializing e.g. an unused index array per batch), but it's an observable contract change for HigherOrderUDFImpl implementors who assumed every closure in args gets called. Worth a line in the evaluate doc and in the PR description.
6. Missing upgrade-guide entry (project convention)
The PR carries the auto detected api change label for the LambdaArgument::new signature break. docs/source/library-user-guide/upgrading/55.0.0.md is the active page and gets entries for exactly this. Add one, even if short — the "unlikely to be called externally" argument justifies making the break, not skipping the note. (Adopting #2 changes the new arg's type, so write the entry after settling that.)
Smaller things
- Doc duplication. The same three-paragraph explanation appears four times: the
used_param_indicesfield,LambdaArgument::new, theused_paramsfield, andLambdaExpr::used_params. Keep one canonical version (theCollectUsedVisitordoc is the best-written) and make the rest one-liners pointing at it. lambda.rs:408—[Self::used_params]inside a#[cfg(test)]doc comment;Selfdoesn't resolve there. Use plain backticks.lambda.rs:357—use super::LambdaExpr;afteruse std::sync::Arc;, separated from the other imports. Fold it into the block above.- Tests reach for
crate::expressions::BinaryExpranddatafusion_expr::Operatorby full path inline; import them alongsideColumn/LambdaVariablefor consistency with the existing test style. - The
.copied()+(new_idx, original)rename incolumn_index_mapis a real readability improvement — the old(projected, original)binding onenumerate()was actively misleading. Good unrelated cleanup.
Uh oh!
There was an error while loading. Please reload this page.
| if columns.is_empty() { | ||
| // Constant lambda body with no captures and no used parameters. We | ||
| // still need a row count for the merged batch, so evaluate one | ||
| // variable just to derive it. This is essentially free in the common | ||
| // case (the variables already exist as closures over arrays the | ||
| // caller computed up front). | ||
| let row_count = match variables.first() { | ||
| Some(first) => first()?.len(), | ||
| None => 0, | ||
| }; | ||
| return Ok(RecordBatch::try_new_with_options( | ||
| schema, | ||
| vec![], | ||
| &RecordBatchOptions::new().with_row_count(Some(row_count)), | ||
| )?); | ||
| } | ||
There was a problem hiding this comment.
Is this a separate issue that's caught and included here or was this introduced by the above changes?
There was a problem hiding this comment.
sort of introduced by our changes. Before, we pushed all params (used or not) into columns so the variable was never empty. Now we just push the used params only, so for lambdas that use 0 params (like (k,v) -> constant) columns is empty, so we need to derive the row count manually (otherwise RecordBatch::try_new(schema, columns) wont do it, like before)
| projected_body: Arc<dyn PhysicalExpr>, | ||
| projection: Vec<usize>, | ||
| /// Subset of `params` (by name) that the body actually references, | ||
| /// computed with nested-lambda shadow tracking. Empty when no parameter |
There was a problem hiding this comment.
TBH the term "nested-lambda shadow tracking" doesn't have obvious meaning to me. Is there an easy way to make the meaning more clear, or somewhere else in the code I should have looked to understand what it means?
There was a problem hiding this comment.
when a lambda has nested lambdas like (k, v) -> func(col, (k, v2) -> k + v2 + v), the innermost k "shadows"/"overrides" the outermost k bc even though they share a name, the innermost k is its own separate parameter, not a reference to the outer one. So when collecting which parameters a lambda's body references n a nested lambda, we need to take this possible name conflict into account, otherwise we'd wrongly count the outer k as used just because the name appears in the body of the nested lambda.
Its true its a bit unclear by just reading it in the param description, I moved the explanation to CollectUsedVisitor that actually handles the logic for "shadowed" params
timsaucer
commented
Aug 10, 2026
This is a case where the current lambda functions are all working because they're single variable, right? I am wondering if this is necessary to get into #22393 or if it's okay going in the next release. |
thanks for the review @timsaucer! I will take a look shortly.
Yep exactly, this came up while @Adam-Alani (the original author of the fix) was working on a PR to add |
LiaCastaneda
commented
Aug 11, 2026
it would be nice if its on the new release |
LiaCastaneda
commented
Aug 11, 2026
this is unlikely but not impossible, if it does differ it would be a coding bug, however its true its probably cleanest to just handle the indices instead of names |
LiaCastaneda
commented
Aug 11, 2026
@timsaucer I addressed most of the review points :) |
timsaucer
left a comment
There was a problem hiding this comment.
Looks great, thanks again! One minor comment that can be fixed with documentation. Also my agent suggests one additional test:
LLM generated evaluation
The layout contract is that in the un-projected body's index space, every capture index sorts before every own-param index, which sorts before every nested-param index. That's what makes column_index_map's dense rank for a used param equal n_captures + rank_among_used_params, which is what makes captures ++ used_params in LambdaArgument::new line up. It holds because the planner appends each lambda's params to the enclosing scope schema — but nothing states it, nothing checks it, and all seven tests hand-write indices chosen to be consistent with it. If the planner's numbering ever changed, every test here would still pass and results would be silently wrong.
At minimum, a comment on used_param_indices stating the invariant. Better: an end-to-end test, which is cheaper than it looks — MockHigherOrderUDF in datafusion/physical-expr/src/higher_order_function.rs already calls lambda.evaluate(...) from invoke_with_args. Widening it (or adding a two-param sibling) to return two lambda_parameters and two closures gives you planner → LambdaExpr → used_param_indices → LambdaArgument → merge_captures_with_variables in one test, with the indices produced by the real planner instead of by hand. That's the test that would actually have caught the original bug.
| ) -> Self { | ||
| let fields = match &captures { | ||
| let used_param_indices = used_param_indices.to_vec(); | ||
| let effective_params = used_param_indices.iter().map(|i| Arc::clone(¶ms[*i])); |
There was a problem hiding this comment.
There is a potential for a panic here if used_param_indices ever contains an index that is larger than the length of params. In practice, not a problem but it looks like this is a pub function so we at least need to document the invariant. Maybe even a debug_assert.
Uh oh!
There was an error while loading. Please reload this page.
…e#24162) (#166) * fix(lambda): only push referenced params into the merged batch (apache#24162) ## Which issue does this PR close? basically this PR apache#22853 + a few more tests ## Rationale for this change The current lambdas in DF only take a single parameter `(v -> ...)`, so nobody had noticed that `LambdaExpr` mishandles lambdas with more than one parameter. The bug surfaced while working on `transform_values` (apache#22689), which needs `(k, v) -> expr ` two parameters, one of which is very often unused (e.g. `(k, v) -> v * 2`, k never referenced). The bug is that when a higher order function with more than 1 param evaluates a lambda, it fills each parameter into a slot based on its declared position — for example for `(k, v) -> v` `k` always goes into slot 0, `v` always into slot 1. `LambdaExpr` separately scans the body and renumbers whatever it finds referenced into a dense `0..n` range, to avoid carrying around columns nothing uses (like `v` in this case). That renumbering is fine for outer captures, but applying it to the lambda's own parameters is wrong, because it changes where the body looks for a value without changing where the evaluator put it. ### Example: in `(k, v) -> v` `v` is declared second (slot 1), but since it's the only parameter the body references, the renumbering logic reassigns it to slot 0. The evaluator, unaware of this, writes `k`'s values into slot 0 and `v`'s into slot 1. So the body ends up reading slot 0 expecting `v` — and gets `k` instead. So the results end up being incorrect. ## What changes are included in this PR? - `LambdaExpr` now computes `used_params`: which is the subset of its own declared parameters that are actually referenced in the body. - `LambdaArgument::new` takes `used_params` and only pushes the referenced parameters in the body into the merged batch, in original declaration order — so the body's indices always line up with what's actually built. - `HigherOrderFunctionExpr::evaluate` forwards `lambda.used_params()` to `LambdaArgument::new` ## Are these changes tested? yes, added two new tests one for the unused-parameter case and nested-lambda for the shadowing case. ## Are there any user-facing changes? The only public api change is on `LambdaArgument::new ` which now requires a new argument: `used_params: &HashSet<String>`, however LambdaArgument::new is very unlikely to be called outside datafusion, see [this](apache#22853 (comment)) comment (cherry picked from commit 4e6acfe) * Adjust to API change
…e#24162) ## Which issue does this PR close? basically this PR apache#22853 + a few more tests ## Rationale for this change The current lambdas in DF only take a single parameter `(v -> ...)`, so nobody had noticed that `LambdaExpr` mishandles lambdas with more than one parameter. The bug surfaced while working on `transform_values` (apache#22689), which needs `(k, v) -> expr ` two parameters, one of which is very often unused (e.g. `(k, v) -> v * 2`, k never referenced). The bug is that when a higher order function with more than 1 param evaluates a lambda, it fills each parameter into a slot based on its declared position — for example for `(k, v) -> v` `k` always goes into slot 0, `v` always into slot 1. `LambdaExpr` separately scans the body and renumbers whatever it finds referenced into a dense `0..n` range, to avoid carrying around columns nothing uses (like `v` in this case). That renumbering is fine for outer captures, but applying it to the lambda's own parameters is wrong, because it changes where the body looks for a value without changing where the evaluator put it. ### Example: in `(k, v) -> v` `v` is declared second (slot 1), but since it's the only parameter the body references, the renumbering logic reassigns it to slot 0. The evaluator, unaware of this, writes `k`'s values into slot 0 and `v`'s into slot 1. So the body ends up reading slot 0 expecting `v` — and gets `k` instead. So the results end up being incorrect. ## What changes are included in this PR? - `LambdaExpr` now computes `used_params`: which is the subset of its own declared parameters that are actually referenced in the body. - `LambdaArgument::new` takes `used_params` and only pushes the referenced parameters in the body into the merged batch, in original declaration order — so the body's indices always line up with what's actually built. - `HigherOrderFunctionExpr::evaluate` forwards `lambda.used_params()` to `LambdaArgument::new` ## Are these changes tested? yes, added two new tests one for the unused-parameter case and nested-lambda for the shadowing case. ## Are there any user-facing changes? The only public api change is on `LambdaArgument::new ` which now requires a new argument: `used_params: &HashSet<String>`, however LambdaArgument::new is very unlikely to be called outside datafusion, see [this](apache#22853 (comment)) comment
…e#24162) (#166) * fix(lambda): only push referenced params into the merged batch (apache#24162) ## Which issue does this PR close? basically this PR apache#22853 + a few more tests ## Rationale for this change The current lambdas in DF only take a single parameter `(v -> ...)`, so nobody had noticed that `LambdaExpr` mishandles lambdas with more than one parameter. The bug surfaced while working on `transform_values` (apache#22689), which needs `(k, v) -> expr ` two parameters, one of which is very often unused (e.g. `(k, v) -> v * 2`, k never referenced). The bug is that when a higher order function with more than 1 param evaluates a lambda, it fills each parameter into a slot based on its declared position — for example for `(k, v) -> v` `k` always goes into slot 0, `v` always into slot 1. `LambdaExpr` separately scans the body and renumbers whatever it finds referenced into a dense `0..n` range, to avoid carrying around columns nothing uses (like `v` in this case). That renumbering is fine for outer captures, but applying it to the lambda's own parameters is wrong, because it changes where the body looks for a value without changing where the evaluator put it. ### Example: in `(k, v) -> v` `v` is declared second (slot 1), but since it's the only parameter the body references, the renumbering logic reassigns it to slot 0. The evaluator, unaware of this, writes `k`'s values into slot 0 and `v`'s into slot 1. So the body ends up reading slot 0 expecting `v` — and gets `k` instead. So the results end up being incorrect. ## What changes are included in this PR? - `LambdaExpr` now computes `used_params`: which is the subset of its own declared parameters that are actually referenced in the body. - `LambdaArgument::new` takes `used_params` and only pushes the referenced parameters in the body into the merged batch, in original declaration order — so the body's indices always line up with what's actually built. - `HigherOrderFunctionExpr::evaluate` forwards `lambda.used_params()` to `LambdaArgument::new` ## Are these changes tested? yes, added two new tests one for the unused-parameter case and nested-lambda for the shadowing case. ## Are there any user-facing changes? The only public api change is on `LambdaArgument::new ` which now requires a new argument: `used_params: &HashSet<String>`, however LambdaArgument::new is very unlikely to be called outside datafusion, see [this](apache#22853 (comment)) comment (cherry picked from commit 4e6acfe) * Adjust to API change
…Partition child (#169) * Revert "fix(lambda): only push referenced params into the merged batch (apache#24162) (#166)" This reverts commit 79de5e9. * fix: keep a CoalescePartitionsExec required by a SinglePartition child (apache#23948) - None filed; happy to open one if preferred. A valid query can be planned into a physical plan that `SanityCheckPlan` then rejects: ``` SanityCheckPlan caused by Error during planning: Plan: ["HashJoinExec: mode=CollectLeft, join_type=Left, on=[(id@0, id@0)], projection=[id@0]", " DataSourceExec: file_groups={4 groups: [...]}, projection=[id], file_type=parquet", " RepartitionExec: partitioning=RoundRobinBatch(8), input_partitions=1", " CoalescePartitionsExec", " ProjectionExec: expr=[first_value(t.id) ORDER BY [...]@1 as id]", " AggregateExec: mode=FinalPartitioned, gby=[id@0 as id], aggr=[first_value(t.id) ORDER BY [...]]", " RepartitionExec: partitioning=Hash([id@0], 8), input_partitions=4", " AggregateExec: mode=Partial, gby=[id@1 as id], aggr=[first_value(t.id) ORDER BY [...]]", " DataSourceExec: file_groups={4 groups: [...]}, projection=[ts, id], file_type=parquet"] does not satisfy distribution requirements: SinglePartition. Child-0 output partitioning: UnknownPartitioning(4) ``` The `HashJoinExec` is in `CollectLeft` mode, which requires `Distribution::SinglePartition` on its build (left) child, but child 0 is a bare 4-partition `DataSourceExec` with no `CoalescePartitionsExec` above it. Self-contained reproducer with `datafusion-cli` (the four `COPY` statements are what make the scan multi-partition): ```sql set datafusion.execution.target_partitions = 8; set datafusion.optimizer.repartition_file_scans = false; create table src (id int, ts int) as values (1, 10), (2, 20), (3, 30); copy (select * from src) to 'data/0.parquet' stored as parquet; copy (select * from src) to 'data/1.parquet' stored as parquet; copy (select * from src) to 'data/2.parquet' stored as parquet; copy (select * from src) to 'data/3.parquet' stored as parquet; create external table t stored as parquet location 'data/'; select a.id from t a left join (select distinct on (id) id, ts from t order by id, ts) f on a.id = f.id order by a.id; ``` Setting `datafusion.optimizer.repartition_sorts = false` makes it plan fine, which points at the sort-parallelization phase. `EnsureRequirements` does insert the coalesce for the `SinglePartition` requirement (`enforce_distribution.rs`, `Distribution::SinglePartition => add_merge_on_top(...)`). Its own phase 3a (`parallelize_sorts`) then takes it back out: `remove_bottleneck_in_subplan` removes a `CoalescePartitionsExec` found at `children[0]` positionally, without consulting the parent's distribution requirement for that child. That parent is reached because `update_coalesce_ctx_children` marks a node as connected when *any* child qualifies. It correctly excludes a `SinglePartition`-requiring child from *setting* the flag, but the join's other child (`UnspecifiedDistribution`, connected to a coalesce below) sets it, so the traversal descends into the join and rewrites child 0 anyway. Nothing re-enforces distribution afterwards, so `SanityCheckPlan` is the first thing to notice. Note the surviving `CoalescePartitionsExec` on the probe side in the plan above: it is what propagated the flag, and it is untouched because the `if` returns without recursing into child 1. The sibling helper on the phase 2b path already does consult the requirement (`update_child_to_remove_unnecessary_sort` / `remove_corresponding_sort_from_sub_plan` re-add a merge using the per-child `child_distribution(child_idx)`); only this path is missing it. The same failure shows up with a build child that is already hash-partitioned on the join key (`Child-0 output partitioning: Hash([k@0], 8)`), which is what a `JoinSelection` input swap leaves behind — a `CollectLeft` join reported as `join_type=Right` with an embedded projection. `remove_bottleneck_in_subplan` now checks the parent's per-child distribution requirement before removing a coalesce, both for `children[0]` and when recursing into the other children. The node `parallelize_sorts` is itself rewriting (the root of the call) is exempt, since the caller drops that node and rebuilds the sort cascade around the result — that is the rule's intended transformation, and gating it too would disable sort parallelization below a global sort. This is threaded through as an `is_root` flag on a private `_impl` function; the public entry point keeps its signature. Yes, at two levels: - An end-to-end sqllogictest in `datafusion/sqllogictest/test_files/joins.slt` reproducing it from SQL (the reproducer above, with the data written by `COPY` inside the test). On `main` it fails with exactly the distribution error above. - Two tests in `datafusion/core/tests/physical_optimizer/ensure_requirements.rs` covering both shapes of the build child (`UnknownPartitioning(n)` and `Hash([k], n)`), running the full `EnsureRequirements` rule and then `SanityCheckPlan` via the existing `optimize_and_sanity_check` helper, plus the idempotency check. `cargo test -p datafusion-physical-optimizer`, `cargo test -p datafusion --test core_integration -- physical_optimizer` (530 tests) and the full `sqllogictest` suite (498 files) pass. No API changes. Plans that were previously rejected by `SanityCheckPlan` now plan and execute; a coalesce that is genuinely required is retained where it was previously (incorrectly) removed. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * fix(lambda): only push referenced params into the merged batch (apache#24162) (#166) * fix(lambda): only push referenced params into the merged batch (apache#24162) ## Which issue does this PR close? basically this PR apache#22853 + a few more tests ## Rationale for this change The current lambdas in DF only take a single parameter `(v -> ...)`, so nobody had noticed that `LambdaExpr` mishandles lambdas with more than one parameter. The bug surfaced while working on `transform_values` (apache#22689), which needs `(k, v) -> expr ` two parameters, one of which is very often unused (e.g. `(k, v) -> v * 2`, k never referenced). The bug is that when a higher order function with more than 1 param evaluates a lambda, it fills each parameter into a slot based on its declared position — for example for `(k, v) -> v` `k` always goes into slot 0, `v` always into slot 1. `LambdaExpr` separately scans the body and renumbers whatever it finds referenced into a dense `0..n` range, to avoid carrying around columns nothing uses (like `v` in this case). That renumbering is fine for outer captures, but applying it to the lambda's own parameters is wrong, because it changes where the body looks for a value without changing where the evaluator put it. ### Example: in `(k, v) -> v` `v` is declared second (slot 1), but since it's the only parameter the body references, the renumbering logic reassigns it to slot 0. The evaluator, unaware of this, writes `k`'s values into slot 0 and `v`'s into slot 1. So the body ends up reading slot 0 expecting `v` — and gets `k` instead. So the results end up being incorrect. ## What changes are included in this PR? - `LambdaExpr` now computes `used_params`: which is the subset of its own declared parameters that are actually referenced in the body. - `LambdaArgument::new` takes `used_params` and only pushes the referenced parameters in the body into the merged batch, in original declaration order — so the body's indices always line up with what's actually built. - `HigherOrderFunctionExpr::evaluate` forwards `lambda.used_params()` to `LambdaArgument::new` ## Are these changes tested? yes, added two new tests one for the unused-parameter case and nested-lambda for the shadowing case. ## Are there any user-facing changes? The only public api change is on `LambdaArgument::new ` which now requires a new argument: `used_params: &HashSet<String>`, however LambdaArgument::new is very unlikely to be called outside datafusion, see [this](apache#22853 (comment)) comment (cherry picked from commit 4e6acfe) * Adjust to API change --------- Co-authored-by: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Lía Adriana <lia.castaneda@datadoghq.com>
Which issue does this PR close?
basically this PR #22853 + a few more tests
Rationale for this change
The current lambdas in DF only take a single parameter
(v -> ...), so nobody had noticed thatLambdaExprmishandles lambdas with more than one parameter. The bug surfaced while working ontransform_values(#22689), which needs(k, v) -> exprtwo parameters, one of which is very often unused (e.g.(k, v) -> v * 2, k never referenced).The bug is that when a higher order function with more than 1 param evaluates a lambda, it fills each parameter into a slot based on its declared position — for example for
(k, v) -> vkalways goes into slot 0,valways into slot 1.LambdaExprseparately scans the body and renumbers whatever it finds referenced into a dense0..nrange, to avoid carrying around columns nothing uses (likevin this case). That renumbering is fine for outer captures, but applying it to the lambda's own parameters is wrong, because it changes where the body looks for a value without changing where the evaluator put it.Example:
in
(k, v) -> vvis declared second (slot 1), but since it's the only parameter the body references, the renumbering logic reassigns it to slot 0. The evaluator, unaware of this, writesk's values into slot 0 andv's into slot 1. So the body ends up reading slot 0 expectingv— and getskinstead. So the results end up being incorrect.What changes are included in this PR?
LambdaExprnow computesused_params: which is the subset of its own declared parameters that are actually referenced in the body.LambdaArgument::newtakesused_paramsand only pushes the referenced parameters in the body into the merged batch, in original declaration order — so the body's indices always line up with what's actually built.HigherOrderFunctionExpr::evaluateforwardslambda.used_params()toLambdaArgument::newAre these changes tested?
yes, added two new tests one for the unused-parameter case and nested-lambda for the shadowing case.
Are there any user-facing changes?
The only public api change is on
LambdaArgument::newwhich now requires a new argument:used_params: &HashSet<String>, however LambdaArgument::new is very unlikely to be called outside datafusion, see this comment