You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A three-way self-join on inequality predicates over a single 500,000-row Parquet file returns count = 0 in well under a second when the session has a bounded memory pool (FairSpillPool or GreedyMemoryPool, 256 MiB or 1 GiB). The correct answer is 500,000 × 499,999 × 499,999 ≈ 1.25 × 10¹⁷.
There is no error and no warning. With DataFusion's default, unbounded pool the same query runs and keeps
running, which is what it should do.
EXPLAIN ANALYZE shows the outer NestedLoopJoinExec buffering ~33 M build rows (≈252 MB, the pool's
size), switching to its spill fallback (spill_count=1) — and then spilling zero rows (spilled_rows=0, spilled_bytes=1096 B), reading zero rows from its probe side (input_rows=0; the probe-side DataSourceExec reports files_opened=0) and emitting zero rows. The query completes successfully with
an empty join.
To reproduce
DataFusion 55.1.0 (arrow/parquet 59.2), Rust 1.95. Reproduced on macOS arm64 in a debug build; first
seen on Linux arm64 (Docker Desktop) in a release build, through datafusion-postgres.
use std::sync::Arc;use arrow::array::Int64Array;use arrow::datatypes::{DataType,Field,Schema};use arrow::record_batch::RecordBatch;use datafusion::execution::disk_manager::{DiskManagerBuilder,DiskManagerMode};use datafusion::execution::memory_pool::FairSpillPool;use datafusion::execution::runtime_env::RuntimeEnvBuilder;use datafusion::prelude::*;use parquet::arrow::ArrowWriter;#[tokio::main]asyncfnmain() -> datafusion::error::Result<()>{// one Parquet file, one Int64 column, ids 1..=500_000let schema = Arc::new(Schema::new(vec![Field::new("id",DataType::Int64,false)]));let path = std::env::temp_dir().join("t.parquet");letmut w = ArrowWriter::try_new(std::fs::File::create(&path)?, schema.clone(),None)?;
w.write(&RecordBatch::try_new(schema,vec![Arc::new(Int64Array::from_iter_values(1..=500_000))])?)?;
w.close()?;// a bounded pool with a spill directorylet spill = std::env::temp_dir().join("spill");
std::fs::create_dir_all(&spill)?;let runtime = RuntimeEnvBuilder::new().with_memory_pool(Arc::new(FairSpillPool::new(256*1024*1024))).with_disk_manager_builder(DiskManagerBuilder::default().with_mode(DiskManagerMode::Directories(vec![spill])),).build_arc()?;let ctx = SessionContext::new_with_config_rt(SessionConfig::new(), runtime);
ctx.register_parquet("t", path.to_str().unwrap(),ParquetReadOptions::default()).await?;
ctx.sql("SELECT count(*) FROM t a, t b, t c WHERE a.id <> b.id AND b.id <> c.id").await?
.show().await?;Ok(())}
Expected behavior
Either the correct count (after a very long time), or a ResourcesExhausted error. Never a successful,
empty join.
The two-way form (t a, t b WHERE a.id <> b.id) over the same file does not come back early under the
same pool; it runs until cancelled. The inner join's output — tens of millions of rows — is what fills
the pool and sends the outer join into the fallback.
A guess, not a diagnosis: the path is NestedLoopJoinStream::initiate_fallback / handle_buffering_left_memory_limited (datafusion-physical-plan 55.1.0, src/joins/nested_loop_join.rs), which re-executes the left plan into a spill file
(spill_record_batch_stream_and_return_max_batch_memory). The metrics say what reaches that file is
empty and the probe side is never read.
In the soak the defect appeared through deltalake's scan and datafusion-postgres; tables rebuilt
into many small files ran into the statement timeout instead, most likely because the inner join did
not fill the pool before the clock ran out (not measured). The reproduction above has no Delta in it.
Downstream, Pumice now refuses such plans before execution (a NestedLoopJoinExec with both inputs
estimated above a bound), but that is a guard, not a fix: any bounded-pool deployment can get a silent
wrong answer from this shape.
Describe the bug
A three-way self-join on inequality predicates over a single 500,000-row Parquet file returns
count = 0in well under a second when the session has a bounded memory pool (FairSpillPoolorGreedyMemoryPool, 256 MiB or 1 GiB). The correct answer is 500,000 × 499,999 × 499,999 ≈ 1.25 × 10¹⁷.There is no error and no warning. With DataFusion's default, unbounded pool the same query runs and keeps
running, which is what it should do.
EXPLAIN ANALYZEshows the outerNestedLoopJoinExecbuffering ~33 M build rows (≈252 MB, the pool'ssize), switching to its spill fallback (
spill_count=1) — and then spilling zero rows (spilled_rows=0,spilled_bytes=1096 B), reading zero rows from its probe side (input_rows=0; the probe-sideDataSourceExecreportsfiles_opened=0) and emitting zero rows. The query completes successfully withan empty join.
To reproduce
DataFusion 55.1.0 (arrow/parquet 59.2), Rust 1.95. Reproduced on macOS arm64 in a debug build; first
seen on Linux arm64 (Docker Desktop) in a release build, through
datafusion-postgres.Expected behavior
Either the correct count (after a very long time), or a
ResourcesExhaustederror. Never a successful,empty join.
Actual behavior
in ~0.6 s (256 MiB pool) or ~2.5 s (1 GiB pool). Same result with
GreedyMemoryPool, and withtarget_partitions = 1.Plan (identical under both pools;
EXPLAIN ANALYZEmetrics of the 256 MiB run on the outer join):Additional context
Related, same fallback path, different symptoms: NestedLoopJoinExec spill fallback evaluates the left input twice #24661 (the left input is evaluated twice in the fallback), NestedLoopJoin buffers the build side into a single concat_batches allocation: 2x transient peak, invisible to the memory pool #24819 (the build side's transient peak is invisible to the pool), NestedLoopJoin coordinated fallback hangs surviving partitions when one is dropped unfinished #25003 (a hang when a partition is dropped). This report is about the fallback completing successfully with an empty result.
The two-way form (
t a, t b WHERE a.id <> b.id) over the same file does not come back early under thesame pool; it runs until cancelled. The inner join's output — tens of millions of rows — is what fills
the pool and sends the outer join into the fallback.
A guess, not a diagnosis: the path is
NestedLoopJoinStream::initiate_fallback/handle_buffering_left_memory_limited(datafusion-physical-plan55.1.0,src/joins/nested_loop_join.rs), which re-executes the left plan into a spill file(
spill_record_batch_stream_and_return_max_batch_memory). The metrics say what reaches that file isempty and the probe side is never read.
In the soak the defect appeared through
deltalake's scan anddatafusion-postgres; tables rebuiltinto many small files ran into the statement timeout instead, most likely because the inner join did
not fill the pool before the clock ran out (not measured). The reproduction above has no Delta in it.
Downstream, Pumice now refuses such plans before execution (a
NestedLoopJoinExecwith both inputsestimated above a bound), but that is a guard, not a fix: any bounded-pool deployment can get a silent
wrong answer from this shape.