Skip to content

PiecewiseMergeJoin changes state before draining its output coalescer: the Left/Full unmatched pass repeats forever, queued Right/Full rows can be dropped #24297

Description

@buraksenn

Describe the bug

ClassicPWMJStream (Inner/Left/Right/Full range joins) buffers its output in Arrow's BatchCoalescer. One push_batch can complete several target-sized batches and retain a partial remainder. The state machine, however, treats "the producer is finished" as if it also meant "all output has been emitted", and transitions (or falls back into a producer) while batches are still queued. Three consequences, worst first:

1. The Left/Full unmatched pass restarts forever (reachable from SQL). The drain block in process_unmatched_buffered_batch (classic_join.rs#L326-L346) has no transition for the empty-queue case:

if !self.batch_process_state.continue_process {
    if let Some(batch) = /* next_completed_batch() */ {
        return Ok(StatefulStreamResult::Ready(Some(batch)));
    }
    /* finish_buffered_batch() */
    if let Some(batch) = /* next_completed_batch() */ {
        self.state = PiecewiseMergeJoinStreamState::Completed;
        return Ok(StatefulStreamResult::Ready(Some(batch)));
    }
}                          // <- both pops empty: no transition, falls through…
let buffered_data = /* … */ // …into the unmatched-row producer, which runs again

When the number of unmatched buffered rows is an exact multiple of batch_size, the flush leaves no partial remainder, so after the queue drains both pops return None, neither return fires, and control falls through into the producer. The visited bitmap still reports the same rows unmatched, so the same batches are pushed again — the stream emits duplicates forever and never terminates. Whether you hit it depends only on data cardinality vs. configured batch size.

2. Queued unmatched Right/Full rows can be silently dropped. At scan end, resolve_classic_join flushes all unmatched streamed rows in one push (classic_join.rs#L576-L583), which can queue ≥ 2 completed batches. process_stream_batch then pops one, transitions to FetchStreamBatch, and returns it (classic_join.rs#L291-L303). If the streamed input then ends, a Right/Inner join short-circuits straight to Completed (#L320-L323) — the remaining queued batches become unreachable and rows are missing from the result. The non-final-partition transition (#L230) drops leftovers the same way. Reachability caveat: this needs a streamed batch carrying ≥ 2×batch_size unmatched rows at the terminal transition; in the SQL plans I tried, stream batches arrive chunked to the session batch size, so I could only demonstrate it driving the operator directly — but it is the same missing invariant as bug 1.

3. A zero-row placeholder escapes as stream output. resolve_classic_join returns RecordBatch::new_empty purely as control flow (#L586-L587), and the scan-end path hands it to the consumer (#L306-L309) — an empty join yields one zero-row batch instead of no batches. Harmless to results, but it is StatefulStreamResult::Continue's job, expressed as data.

To Reproduce

Bug 1, via SQL (4 unmatched buffered rows = 2 × batch_size; append LIMIT 20 to see the duplicated rows instead of a non-terminating query):

set datafusion.optimizer.enable_piecewise_merge_join = true;
set datafusion.execution.batch_size = 2;
create table l(v int) as values (10), (20), (30), (40), (1);
create table r(v int) as values (5);
select * from l left join r on l.v <= r.v;  -- never terminates

Bug 2, at the operator level: build a PiecewiseMergeJoinExec (Right join, l.v >= r.v) over a buffered side [1, 2] and a streamed side [3, 5] with batch_size = 1. Neither streamed row matches, the flush queues two completed batches, and only the first null-extended row is emitted — collect returns 1 row where 2 are correct.

Expected behavior

A phase transitions only after its coalescer has been finalized and confirmed empty (finish_buffered_batch() + next_completed_batch() returning None): every queued batch is emitted exactly once, the unmatched pass runs once and the stream terminates, and an empty join yields no batches. One drain invariant at the two phase boundaries closes all three defects; matching, null handling, and the visited bitmap need no changes.

Additional context

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions