Repository navigation
application of simple optimizer rule produces incorrect results (DF 49 regression) #17510
Copy link
Copy link
Closed
Closed
Copy link
Labels
bugSomething isn't workingSomething isn't workinghelp wantedExtra attention is neededExtra attention is needed
Description
Describe the bug
The following code produces an incorrect result on Datafusion 49.0.2, but correct behavior on 48.0.1. Both execution plans should produce the same row count, but in DF 49.0.2 one returns too few results. The behavior is independent of the order in which the original/optimized plans are executed. If the order of the executions is flipped, the second execution always returns the incorrect result.
use arrow_array::{RecordBatch, UInt32Array};
use arrow_schema::{DataType, Field, Schema};
use datafusion::datasource::memory::MemTable;
use datafusion::physical_optimizer::PhysicalOptimizerRule;
use datafusion::physical_plan::ExecutionPlan;
use datafusion::prelude::*;
use datafusion_common::Result as DFResult;
use std::sync::Arc;
#[derive(Debug)]
struct NoOpOptimizer;
impl PhysicalOptimizerRule for NoOpOptimizer {
fn optimize(
&self,
plan: Arc<dyn ExecutionPlan>,
_config: &datafusion_common::config::ConfigOptions,
) -> DFResult<Arc<dyn ExecutionPlan>> {
Ok(plan)
}
fn name(&self) -> &str {
"NoOpOptimizer"
}
fn schema_check(&self) -> bool {
true
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create simple test data
let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::UInt32, false)]));
let batch = RecordBatch::try_new(
schema.clone(),
vec![Arc::new(UInt32Array::from((0..20).collect::<Vec<u32>>()))],
)?;
let ctx = SessionContext::new();
let provider = MemTable::try_new(schema.clone(), vec![vec![batch]])?;
ctx.register_table("test", Arc::new(provider))?;
// Create a simple plan with a sort and a limit.
let df = ctx
.table("test")
.await?
.sort(vec![col("id").sort(true, true)])?
.limit(0, Some(10))?;
let logical_plan = df.into_optimized_plan()?;
let plan = ctx.state().create_physical_plan(&logical_plan).await?;
// Apply no-op optimizer
let optimizer = NoOpOptimizer;
let optimized_plan = optimizer.optimize(plan.clone(), &Default::default())?;
let task_ctx = ctx.task_ctx();
let original_stream = plan.execute(0, task_ctx.clone())?;
let original_batches = datafusion::physical_plan::common::collect(original_stream).await?;
let original_count: usize = original_batches.iter().map(|b| b.num_rows()).sum();
let optimized_stream = optimized_plan.execute(0, task_ctx)?;
let optimized_batches = datafusion::physical_plan::common::collect(optimized_stream).await?;
let optimized_count: usize = optimized_batches.iter().map(|b| b.num_rows()).sum();
// Result mismatch on DF 49
println!("Expected: 10 rows (limit in query)");
println!("Optimized plan: {} rows", optimized_count);
println!("Original plan: {} rows", original_count);
Ok(())
}Cargo.toml:
[package]
name = "datafusion-regression-test"
version = "0.1.0"
edition = "2021"
[workspace]
[dependencies]
arrow-array = "55"
arrow-schema = "55"
datafusion = "49.0.2"
datafusion-common = "49.0.2"
tokio = { version = "1", features = ["full"] }DF 49.0.2:
Expected: 10 rows (limit in query)
Optimized plan: 9 rows
Original plan: 10 rows
DF 48.01:
Expected: 10 rows (limit in query)
Optimized plan: 10 rows
Original plan: 10 rows
To Reproduce
No response
Expected behavior
No response
Additional context
No response
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
bugSomething isn't workingSomething isn't workinghelp wantedExtra attention is neededExtra attention is needed
Activity
[-]application simple optimizer rule produces incorrect results (DF 49 regression)[/-][+]application of simple optimizer rule produces incorrect results (DF 49 regression)[/+]wkalt commentedon Sep 10, 2025
reading more commits, I suspect this may be #16998 which is fixed by #17028. If so the fix is just to update our code to use reset_state. In that case this can be closed.
wkalt commentedon Sep 10, 2025
Calling reset_state between executions did not seem to address the issue, so I'll leave it open for comment.
Our usecase for doing this is testing optimizer rules, to make sure the result is the same before and after optimization. I have worked around this by changing the test to create two separate plans.
alamb commentedon Sep 11, 2025
@adriangb any ideas of what is going on here?
adriangb commentedon Sep 11, 2025
I don't think this has anything to do with dynamic filters.
I reduced the MRE to:
(I tested by adding this to the bottom of datafusion/core/tests/physical_optimizer/filter_pushdown/mod.rs)
If you comment back in the line
// let plan_2 = plan_2.reset_state().unwrap();then it passes.As you can see I set
datafusion.optimizer.enable_dynamic_filter_pushdown = falsewhich should disable dynamic filters, i.e. this is unrelated to that feature.Could it be that you are just observing the state of the
SortExecbeing re-used across runs? Where did you callreset_state()?alamb commentedon Sep 11, 2025
when I add this code to the original reproducer the test now passes.
Using @adriangb 's reproducer, when I uncommented out the line
// let plan_2 = plan_2.reset_state().unwrap();The test passes for me on DataFusion 49 and when I comment it back out then the test fails
Thus, I think the solution is to call
reset_state.The root cause seems to be that code doesn't really copy the plan so this example is running the same plan twice.
This line specifically:
planis anArc<dyn ExecutionPlan>so callingcloneon it just copies the pointerSimilarly, in @adriangb 's version
plan_1is anArc<dyn ExecutionPlan>so callingcloneon it just copies the pointerSo in effect what the reproducers are doing is calling
execute()on the same plan twice. Thus I would expect that callingreset_stateon the plan would be needed to reset any internal state, which is exactly the behavior we seealamb commentedon Sep 12, 2025
I think we can close this ticket as not a bug, is that ok @wkalt ?
wkalt commentedon Sep 13, 2025
@alamb sorry for the delay, yes that is fine. Closing.
alamb commentedon Sep 14, 2025
Thank you for the report and the reproducer -- I am glad we sorted this out