Skip to content

Commit 559c8be

Browse files
committed
fix(lambda): only push referenced params into the merged batch
`LambdaExpr` compresses the body's column-index projection by enumerating every referenced `Column`/`LambdaVariable` index and packing them into a dense range. That compression is correct for outer captures, but it silently broke multi-parameter lambdas: a body like `(k, v) -> v` (where `k` is unused) would have its `LambdaVariable("v")` re-projected from index 1 to index 0 and then, at runtime, read the slot the higher-order function had filled with `k`. Per maintainer feedback on #22853, fix it without a breaking change to `LambdaExpr::try_new` / `expressions::lambda(...)`: * `LambdaExpr` now tracks `used_params: HashSet<String>` — the subset of its own declared parameters that the body actually references. The set is computed during a single walk of the body in `LambdaExpr::new`, with a shadow stack that ignores `LambdaVariable`s bound by nested lambdas. For `(k, v) -> func(col, (k, v2) -> k + v2 + v)` the inner `k` shadows the outer `k`, so only `v` flows up as used by the outer lambda. * `LambdaArgument` gets an `Option<HashSet<String>>` for used parameter names plus a non-breaking `new_with_used_params(...)` constructor. The existing `new(...)` calls it with `None`, which preserves the old "push every declared parameter" behavior. * `LambdaArgument::evaluate` (through `merge_captures_with_variables`) only evaluates and pushes the closures whose parameter name appears in `used_params`, preserving the original declaration order. Unused declared parameters therefore leave no slot in the merged batch, so the body's compressed indices line up directly with the columns the evaluator actually built. * `HigherOrderFunctionExpr::evaluate` calls `new_with_used_params` and forwards `lambda.used_params().clone()`, so all in-tree higher-order UDFs benefit automatically without any callsite change. No public API breakage: `LambdaExpr::try_new`, `expressions::lambda(...)` and `LambdaArgument::new` keep their existing signatures. Two new tests cover the unused-parameter case and the nested-lambda shadowing case; existing tests in `physical-expr` and `expr` continue to pass.
1 parent 46b508e commit 559c8be

3 files changed

Lines changed: 266 additions & 31 deletions

File tree

datafusion/expr/src/higher_order_function.rs

Lines changed: 95 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ use crate::expr::{
2424
use crate::type_coercion::functions::value_fields_with_higher_order_udf;
2525
use crate::udf_eq::UdfEq;
2626
use crate::{ColumnarValue, Documentation, Expr, ExprSchemable};
27-
use arrow::array::{ArrayRef, RecordBatch};
27+
use arrow::array::{ArrayRef, RecordBatch, RecordBatchOptions};
2828
use arrow::datatypes::{DataType, FieldRef, Schema};
2929
use arrow_schema::SchemaRef;
3030
use datafusion_common::config::ConfigOptions;
@@ -239,6 +239,15 @@ pub struct LambdaArgument {
239239
/// For example, for `array_transform([2], v -> -v)`,
240240
/// this will be `vec![Field::new("v", DataType::Int32, true)]`
241241
params: Vec<FieldRef>,
242+
/// Indices into `params` of the parameters that are actually referenced
243+
/// by `body` (taking nested-lambda shadowing into account).
244+
///
245+
/// `None` means "no information, assume every declared parameter is used"
246+
/// — that is the backwards-compatible behavior of [`Self::new`]. When set,
247+
/// [`Self::evaluate`] skips evaluating and pushing the closures for the
248+
/// parameters not listed here, so unused declared parameters do not shift
249+
/// the columns the body's compressed indices expect.
250+
used_param_indices: Option<Vec<usize>>,
242251
/// The body of the lambda
243252
///
244253
/// For example, for `array_transform([2], v -> -v)`,
@@ -257,26 +266,64 @@ pub struct LambdaArgument {
257266
}
258267

259268
impl LambdaArgument {
269+
/// Build a [`LambdaArgument`] that treats every declared parameter as
270+
/// used. This is the backwards-compatible behavior. Prefer
271+
/// [`Self::new_with_used_params`] when the caller knows which subset of
272+
/// the lambda's parameters the body actually references — otherwise the
273+
/// merged batch will still contain columns for unused parameters.
260274
pub fn new(
261275
params: Vec<FieldRef>,
262276
body: Arc<dyn PhysicalExpr>,
263277
captures: Option<RecordBatch>,
264278
) -> Self {
279+
Self::new_with_used_params(params, body, captures, None)
280+
}
281+
282+
/// Build a [`LambdaArgument`] knowing which subset of `params` (by name)
283+
/// the lambda body actually references.
284+
///
285+
/// When `used_params` is `Some(set)`, [`Self::evaluate`] only evaluates
286+
/// and pushes the closures whose corresponding parameter name is in
287+
/// `set`, in the original declaration order of `params`. Unused declared
288+
/// parameters leave no slot in the merged batch, so the body's compressed
289+
/// column indices line up directly. When `used_params` is `None`,
290+
/// behavior is identical to [`Self::new`].
291+
pub fn new_with_used_params(
292+
params: Vec<FieldRef>,
293+
body: Arc<dyn PhysicalExpr>,
294+
captures: Option<RecordBatch>,
295+
used_params: Option<HashSet<String>>,
296+
) -> Self {
297+
let used_param_indices = used_params.map(|set| {
298+
params
299+
.iter()
300+
.enumerate()
301+
.filter(|(_, f)| set.contains(f.name()))
302+
.map(|(i, _)| i)
303+
.collect::<Vec<_>>()
304+
});
305+
306+
let effective_params: Vec<FieldRef> = match &used_param_indices {
307+
Some(indices) => indices.iter().map(|i| Arc::clone(&params[*i])).collect(),
308+
None => params.clone(),
309+
};
310+
265311
let fields = match &captures {
266312
Some(batch) => batch
267313
.schema_ref()
268314
.fields()
269315
.iter()
270316
.cloned()
271-
.chain(params.clone())
317+
.chain(effective_params.iter().cloned())
272318
.collect(),
273-
None => params.clone(),
319+
None => effective_params,
274320
};
275321

276322
let schema = Arc::new(Schema::new(fields));
277323

278324
Self {
279325
params,
326+
used_param_indices,
280327
body,
281328
schema,
282329
captures,
@@ -344,6 +391,7 @@ impl LambdaArgument {
344391
spread_captures.as_ref(),
345392
Arc::clone(&self.schema),
346393
&self.params,
394+
self.used_param_indices.as_deref(),
347395
args,
348396
)?;
349397

@@ -355,6 +403,7 @@ fn merge_captures_with_variables(
355403
captures: Option<&RecordBatch>,
356404
schema: SchemaRef,
357405
params: &[FieldRef],
406+
used_param_indices: Option<&[usize]>,
358407
variables: &[&dyn Fn() -> Result<ArrayRef>],
359408
) -> Result<RecordBatch> {
360409
if variables.len() < params.len() {
@@ -365,23 +414,56 @@ fn merge_captures_with_variables(
365414
);
366415
}
367416

417+
let push_param_arrays = |columns: &mut Vec<ArrayRef>| -> Result<()> {
418+
match used_param_indices {
419+
Some(indices) => {
420+
for &i in indices {
421+
columns.push(variables[i]()?);
422+
}
423+
}
424+
None => {
425+
for arg in &variables[..params.len()] {
426+
columns.push(arg()?);
427+
}
428+
}
429+
}
430+
Ok(())
431+
};
432+
368433
let columns = match captures {
369434
Some(captures) => {
370435
let mut columns = captures.columns().to_vec();
371-
372-
for arg in &variables[..params.len()] {
373-
columns.push(arg()?);
374-
}
375-
436+
push_param_arrays(&mut columns)?;
437+
columns
438+
}
439+
None => {
440+
let mut columns = Vec::with_capacity(
441+
used_param_indices
442+
.map(<[usize]>::len)
443+
.unwrap_or(params.len()),
444+
);
445+
push_param_arrays(&mut columns)?;
376446
columns
377447
}
378-
None => variables
379-
.iter()
380-
.take(params.len())
381-
.map(|arg| arg())
382-
.collect::<Result<_>>()?,
383448
};
384449

450+
if columns.is_empty() {
451+
// Constant lambda body with no captures and no used parameters. We
452+
// still need a row count for the merged batch, so evaluate one
453+
// variable just to derive it. This is essentially free in the common
454+
// case (the variables already exist as closures over arrays the
455+
// caller computed up front).
456+
let row_count = match variables.first() {
457+
Some(first) => first()?.len(),
458+
None => 0,
459+
};
460+
return Ok(RecordBatch::try_new_with_options(
461+
schema,
462+
vec![],
463+
&RecordBatchOptions::new().with_row_count(Some(row_count)),
464+
)?);
465+
}
466+
385467
Ok(RecordBatch::try_new(schema, columns)?)
386468
}
387469

0 commit comments

Comments
 (0)