Skip to content

Commit 99e770e

Browse files
committed
fix: don't duplicate volatile expressions when pushing projection into file scan (#23395)
## Which issue does this PR close? - Closes #23220. ## Rationale for this change The reported bug showed that when volatile (non-deterministic) expressions are referenced multiple times in an outer function the query results can be incorrect. To produce correct results, a volatile expression (e.g. `random()`, `uuid()`) aliased once in a subquery and referenced multiple times must be evaluated once and reused. Since 52.0.0 this evaluation pattern for volatile functions has been broken; the physical projection-pushdown rule merges the outer projection into the file `DataSourceExec`, inlining the aliased volatile expression at each reference site. Instead of being evaluated once, the volatile expression is evaluated N times, so the references diverge: ```sql SELECT s.r AS x, s.r AS y FROM (SELECT random() AS r FROM t) AS s; -- x != y on >= 52.0.0 ``` This was correct in 51.0.0 and regressed in 52.0.0/53.0.0. It reproduces on file scans (Parquet/CSV) but not in-memory tables, and was surfaced downstream in Ibis. Worth noting, #10337 appears to report the same class of bug but a different cause (I'll try to look into that issue soon as well). ## What changes are included in this PR? `FileScanConfig::try_swapping_with_projection` now declines to merge a projection into the file source when the merge would inline a volatile expression that the incoming projection references. The check reuses the existing `is_volatile()` utility from `datafusion_physical_expr_common` — the same volatility gate the physical `ProjectionPushdown` and `FilterPushdown` rules already apply — so file-source projection merging is now consistent with them. Deterministic expressions still merge freely. The guard blocks when a volatile inner expression is referenced at least once (not only more than once), because a single outer expression can itself duplicate the value (e.g.`r + r`). ## Are these changes tested? Yes: - Unit tests in `file_scan_config.rs`: - volatile referenced ≥1× → blocked; - deterministic computed and column-only → allowed; - unreferenced volatile → allowed; - single-expression self-reference (`r + r`) → blocked; - volatile nested in arithmetic → blocked; - empty → allowed. - A regression test in `projection_pushdown.slt` asserting `x = y` for the aliased-`random()` pattern over a Parquet scan. - Full `datafusion-sqllogictest` suite passes. ## Are there any user-facing changes? No API changes! Query results for the affected pattern are corrected (a volatile expression aliased in a subquery is no longer duplicated by projection pushdown into a file scan). Physical plans for such queries now retain a `ProjectionExec` above the scan rather than inlining the volatile expression into the `DataSourceExec` projection.
1 parent 7fd1b0d commit 99e770e

3 files changed

Lines changed: 261 additions & 1 deletion

File tree

datafusion/datasource/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ zstd = { workspace = true, optional = true }
7474

7575
[dev-dependencies]
7676
criterion = { workspace = true }
77+
datafusion-functions = { workspace = true }
7778
insta = { workspace = true }
7879
tempfile = { workspace = true }
7980

datafusion/datasource/src/file_scan_config/mod.rs

Lines changed: 230 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ use datafusion_physical_expr::projection::ProjectionExprs;
4444
use datafusion_physical_expr::utils::reassign_expr_columns;
4545
use datafusion_physical_expr::{EquivalenceProperties, Partitioning, split_conjunction};
4646
use datafusion_physical_expr_adapter::PhysicalExprAdapterFactory;
47-
use datafusion_physical_expr_common::physical_expr::PhysicalExpr;
47+
use datafusion_physical_expr_common::physical_expr::{PhysicalExpr, is_volatile};
4848
use datafusion_physical_expr_common::sort_expr::{LexOrdering, PhysicalSortExpr};
4949
use datafusion_physical_plan::SortOrderPushdownResult;
5050
use datafusion_physical_plan::coop::cooperative;
@@ -577,6 +577,51 @@ impl From<FileScanConfig> for FileScanConfigBuilder {
577577
}
578578
}
579579

580+
/// Returns `true` if merging `outer` into `inner` would duplicate a volatile
581+
/// expression; the caller should then decline the merge.
582+
///
583+
/// `inner` is the scan's current projection and `outer` the projection being
584+
/// pushed into it; merging substitutes each `inner` expression into every
585+
/// `outer` reference to it. If a volatile `inner` expression (e.g. `random()`,
586+
/// `uuid()`) is referenced more than once, that single value gets inlined at
587+
/// each site and re-evaluated independently, so references meant to share a
588+
/// "locked-in" value diverge. This is the volatility guard the physical
589+
/// `ProjectionPushdown` and `FilterPushdown` rules already apply (see
590+
/// `datafusion_physical_expr_common::physical_expr::is_volatile`).
591+
///
592+
/// References are counted with multiplicity by walking each `outer` expression
593+
/// (as `try_collapse_projection_chain` does), so a self-duplicating expression
594+
/// such as `r + r` counts as two references. A volatile expression referenced
595+
/// exactly once has nothing to duplicate and is left to merge.
596+
fn would_duplicate_volatile_exprs(
597+
inner: &ProjectionExprs,
598+
outer: &ProjectionExprs,
599+
) -> bool {
600+
use datafusion_common::tree_node::{TreeNode, TreeNodeRecursion};
601+
602+
let inner_exprs = inner.as_ref();
603+
604+
let mut ref_counts = vec![0usize; inner_exprs.len()];
605+
for proj_expr in outer.as_ref() {
606+
proj_expr
607+
.expr
608+
.apply(|e| {
609+
if let Some(col) = e.as_ref().downcast_ref::<Column>()
610+
&& let Some(count) = ref_counts.get_mut(col.index())
611+
{
612+
*count += 1;
613+
}
614+
Ok(TreeNodeRecursion::Continue)
615+
})
616+
.expect("infallible closure should not fail");
617+
}
618+
619+
ref_counts
620+
.iter()
621+
.enumerate()
622+
.any(|(idx, &count)| count > 1 && is_volatile(&inner_exprs[idx].expr))
623+
}
624+
580625
impl DataSource for FileScanConfig {
581626
fn open(
582627
&self,
@@ -859,6 +904,15 @@ impl DataSource for FileScanConfig {
859904
&self,
860905
projection: &ProjectionExprs,
861906
) -> Result<Option<Arc<dyn DataSource>>> {
907+
// Don't merge a projection into the scan if it would inline a volatile
908+
// expression that the outer projection references, which would turn a
909+
// single "locked-in" value (e.g. `random()` aliased in a subquery) into
910+
// multiple independent evaluations. See #23220.
911+
if let Some(inner) = self.file_source.projection()
912+
&& would_duplicate_volatile_exprs(inner, projection)
913+
{
914+
return Ok(None);
915+
}
862916
match self.file_source.try_pushdown_projection(projection)? {
863917
Some(new_source) => {
864918
let mut new_file_scan_config = self.clone();
@@ -3191,4 +3245,179 @@ mod tests {
31913245
);
31923246
Ok(())
31933247
}
3248+
3249+
/// Helper: build a `ProjectionExprs` from `(expr, alias)` pairs.
3250+
fn make_projection(pairs: Vec<(Arc<dyn PhysicalExpr>, &str)>) -> ProjectionExprs {
3251+
ProjectionExprs::new(
3252+
pairs
3253+
.into_iter()
3254+
.map(|(expr, alias)| ProjectionExpr::new(expr, alias)),
3255+
)
3256+
}
3257+
3258+
/// Helper: create a volatile (non-deterministic) function expression,
3259+
/// e.g. `random()`.
3260+
fn make_volatile_expr() -> Arc<dyn PhysicalExpr> {
3261+
use datafusion_common::config::ConfigOptions;
3262+
use datafusion_expr::ScalarUDF;
3263+
use datafusion_functions::math::random::RandomFunc;
3264+
use datafusion_physical_expr::ScalarFunctionExpr;
3265+
3266+
Arc::new(ScalarFunctionExpr::new(
3267+
"random",
3268+
Arc::new(ScalarUDF::from(RandomFunc::new())),
3269+
vec![],
3270+
Arc::new(Field::new("random", DataType::Float64, false)),
3271+
Arc::new(ConfigOptions::default()),
3272+
))
3273+
}
3274+
3275+
/// Column-only inner projections always merge safely, even when
3276+
/// the outer projection references them multiple times.
3277+
#[test]
3278+
fn test_would_duplicate_allows_column_only_inner() {
3279+
let col_a: Arc<dyn PhysicalExpr> = Arc::new(Column::new("a", 0));
3280+
let col_b: Arc<dyn PhysicalExpr> = Arc::new(Column::new("b", 1));
3281+
3282+
let inner =
3283+
make_projection(vec![(Arc::clone(&col_a), "a"), (Arc::clone(&col_b), "b")]);
3284+
3285+
// Outer references col 0 twice
3286+
let outer = make_projection(vec![
3287+
(Arc::new(Column::new("a", 0)), "x"),
3288+
(Arc::new(Column::new("a", 0)), "y"),
3289+
]);
3290+
3291+
assert!(!would_duplicate_volatile_exprs(&inner, &outer));
3292+
}
3293+
3294+
/// Deterministic computed expressions (arithmetic) referenced multiple
3295+
/// times are allowed to merge — only volatile expressions are protected.
3296+
#[test]
3297+
fn test_would_duplicate_allows_deterministic_computed_multi_ref() {
3298+
let col_a: Arc<dyn PhysicalExpr> = Arc::new(Column::new("a", 0));
3299+
let col_b: Arc<dyn PhysicalExpr> = Arc::new(Column::new("b", 1));
3300+
// Inner: [a + b, b] (index 0 is deterministic computed)
3301+
let inner = make_projection(vec![
3302+
(
3303+
Arc::new(BinaryExpr::new(
3304+
Arc::clone(&col_a),
3305+
Operator::Plus,
3306+
Arc::clone(&col_b),
3307+
)),
3308+
"sum",
3309+
),
3310+
(Arc::clone(&col_b), "b"),
3311+
]);
3312+
3313+
// Outer references index 0 twice
3314+
let outer = make_projection(vec![
3315+
(Arc::new(Column::new("sum", 0)), "x"),
3316+
(Arc::new(Column::new("sum", 0)), "y"),
3317+
]);
3318+
3319+
// Deterministic arithmetic → allow merge even though duplicated
3320+
assert!(!would_duplicate_volatile_exprs(&inner, &outer));
3321+
}
3322+
3323+
/// A volatile expression the outer projection does not reference is
3324+
/// safe to merge (it is projected away, not duplicated).
3325+
#[test]
3326+
fn test_would_duplicate_allows_unreferenced_volatile() {
3327+
let col_a: Arc<dyn PhysicalExpr> = Arc::new(Column::new("a", 0));
3328+
// Inner: [random(), a]
3329+
let inner =
3330+
make_projection(vec![(make_volatile_expr(), "r"), (Arc::clone(&col_a), "a")]);
3331+
3332+
// Outer references only index 1 (the column), not the volatile expr
3333+
let outer = make_projection(vec![(Arc::new(Column::new("a", 1)), "a")]);
3334+
3335+
assert!(!would_duplicate_volatile_exprs(&inner, &outer));
3336+
}
3337+
3338+
/// A volatile expression referenced multiple times must block merge:
3339+
/// this is the #23220 regression (`random()` aliased then referenced as
3340+
/// `x` and `y`).
3341+
#[test]
3342+
fn test_would_duplicate_blocks_multi_ref_volatile() {
3343+
// Inner: [random()]
3344+
let inner = make_projection(vec![(make_volatile_expr(), "r")]);
3345+
3346+
// Outer references index 0 twice
3347+
let outer = make_projection(vec![
3348+
(Arc::new(Column::new("r", 0)), "x"),
3349+
(Arc::new(Column::new("r", 0)), "y"),
3350+
]);
3351+
3352+
assert!(would_duplicate_volatile_exprs(&inner, &outer));
3353+
}
3354+
3355+
/// A volatile expression referenced exactly once has nothing to duplicate,
3356+
/// so the merge is allowed.
3357+
#[test]
3358+
fn test_would_duplicate_allows_single_ref_volatile() {
3359+
let col_a: Arc<dyn PhysicalExpr> = Arc::new(Column::new("a", 0));
3360+
// Inner: [random(), a]
3361+
let inner =
3362+
make_projection(vec![(make_volatile_expr(), "r"), (Arc::clone(&col_a), "a")]);
3363+
3364+
// Outer references the volatile expression exactly once
3365+
let outer = make_projection(vec![
3366+
(Arc::new(Column::new("r", 0)), "x"),
3367+
(Arc::new(Column::new("a", 1)), "a"),
3368+
]);
3369+
3370+
assert!(!would_duplicate_volatile_exprs(&inner, &outer));
3371+
}
3372+
3373+
/// References are counted with multiplicity, so a single outer expression
3374+
/// that duplicates the value (e.g. `r + r`) still blocks the merge.
3375+
#[test]
3376+
fn test_would_duplicate_blocks_single_expr_self_ref_volatile() {
3377+
// Inner: [random()]
3378+
let inner = make_projection(vec![(make_volatile_expr(), "r")]);
3379+
3380+
// Outer: [r + r] — one expression referencing `random()` twice
3381+
let outer = make_projection(vec![(
3382+
Arc::new(BinaryExpr::new(
3383+
Arc::new(Column::new("r", 0)),
3384+
Operator::Plus,
3385+
Arc::new(Column::new("r", 0)),
3386+
)),
3387+
"x",
3388+
)]);
3389+
3390+
assert!(would_duplicate_volatile_exprs(&inner, &outer));
3391+
}
3392+
3393+
/// A volatile expression buried inside a larger expression (e.g.
3394+
/// `random() + 1`) is still detected and blocks merge.
3395+
#[test]
3396+
fn test_would_duplicate_blocks_volatile_nested_in_arithmetic() {
3397+
// Inner: [random() + 1]
3398+
let inner = make_projection(vec![(
3399+
Arc::new(BinaryExpr::new(
3400+
make_volatile_expr(),
3401+
Operator::Plus,
3402+
Arc::new(Literal::new(ScalarValue::Float64(Some(1.0)))),
3403+
)),
3404+
"expr",
3405+
)]);
3406+
3407+
// Outer references index 0 twice
3408+
let outer = make_projection(vec![
3409+
(Arc::new(Column::new("expr", 0)), "x"),
3410+
(Arc::new(Column::new("expr", 0)), "y"),
3411+
]);
3412+
3413+
assert!(would_duplicate_volatile_exprs(&inner, &outer));
3414+
}
3415+
3416+
/// Empty projections should not block merging.
3417+
#[test]
3418+
fn test_would_duplicate_empty_projections() {
3419+
let inner = make_projection(vec![]);
3420+
let outer = make_projection(vec![]);
3421+
assert!(!would_duplicate_volatile_exprs(&inner, &outer));
3422+
}
31943423
}

datafusion/sqllogictest/test_files/projection_pushdown.slt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2074,3 +2074,33 @@ SELECT s, id FROM simple_struct WHERE s['value'] > 100 AND id < 4;
20742074
# reset it explicitly.
20752075
statement ok
20762076
SET datafusion.execution.target_partitions = 4;
2077+
2078+
#####################
2079+
# Section: volatile expressions are not duplicated by projection pushdown
2080+
#
2081+
# Regression test for #23220: a volatile expression (e.g. `random()`) aliased
2082+
# once in a subquery and referenced multiple times must be evaluated once and
2083+
# reused. Projection pushdown must not merge the outer projection into the file
2084+
# scan when doing so would inline and duplicate the volatile expression.
2085+
# Reproduces only against a file scan (not an in-memory table); if the volatile
2086+
# expression is duplicated, the two references diverge and `x = y` is false.
2087+
#####################
2088+
2089+
statement ok
2090+
COPY (SELECT 1 AS id UNION ALL SELECT 2 UNION ALL SELECT 3)
2091+
TO 'test_files/scratch/projection_pushdown/volatile.parquet'
2092+
STORED AS PARQUET;
2093+
2094+
statement ok
2095+
CREATE EXTERNAL TABLE volatile_scan STORED AS PARQUET
2096+
LOCATION 'test_files/scratch/projection_pushdown/volatile.parquet';
2097+
2098+
# The two references to the aliased `random()` value must be equal on every
2099+
# row: the expression is evaluated once and reused, not inlined twice.
2100+
query B rowsort
2101+
SELECT s.x = s.y
2102+
FROM (SELECT r AS x, r AS y FROM (SELECT random() AS r FROM volatile_scan) AS t) AS s;
2103+
----
2104+
true
2105+
true
2106+
true

0 commit comments

Comments
 (0)