Skip to main content

datafusion_physical_plan/joins/
nested_loop_join.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! [`NestedLoopJoinExec`]: joins without equijoin (equality predicates).
19
20use std::fmt::Formatter;
21use std::ops::{BitOr, ControlFlow};
22use std::sync::Arc;
23use std::sync::atomic::{AtomicUsize, Ordering};
24use std::task::Poll;
25
26use super::utils::{
27    asymmetric_join_output_partitioning, need_produce_result_in_final,
28    reorder_output_after_swap, swap_join_projection,
29};
30use crate::common::can_project;
31use crate::execution_plan::{EmissionType, boundedness_from_children};
32use crate::joins::SharedBitmapBuilder;
33use crate::joins::utils::{
34    BuildProbeJoinMetrics, ColumnIndex, JoinFilter, OnceAsync, OnceFut,
35    build_join_schema, check_join_is_valid, estimate_join_statistics,
36    need_produce_right_in_final,
37};
38use crate::metrics::{
39    Count, ExecutionPlanMetricsSet, MetricBuilder, MetricType, MetricsSet, RatioMetrics,
40};
41use crate::projection::{
42    EmbeddedProjection, JoinData, ProjectionExec, try_embed_projection,
43    try_pushdown_through_join_with_column_indices,
44};
45use crate::statistics::{ChildStats, StatisticsArgs};
46use crate::{
47    ChildrenPropertiesMode, DisplayAs, DisplayFormatType, Distribution, ExecutionPlan,
48    ExecutionPlanProperties, PlanProperties, RecordBatchStream, ReplaceChildrenOptions,
49    SendableRecordBatchStream, validate_child_count,
50};
51
52use arrow::array::{
53    Array, BooleanArray, BooleanBufferBuilder, RecordBatchOptions, UInt32Array,
54    UInt64Array, new_null_array,
55};
56use arrow::buffer::BooleanBuffer;
57use arrow::compute::{
58    BatchCoalescer, concat_batches, filter, filter_record_batch, not, take,
59};
60use arrow::datatypes::{Schema, SchemaRef};
61use arrow::record_batch::RecordBatch;
62use arrow_schema::DataType;
63use datafusion_common::cast::as_boolean_array;
64use datafusion_common::tree_node::TreeNodeRecursion;
65use datafusion_common::{
66    JoinSide, NullEquality, Result, ScalarValue, Statistics, arrow_err,
67    assert_eq_or_internal_err, internal_datafusion_err, internal_err, project_schema,
68    unwrap_or_internal_err,
69};
70use datafusion_execution::memory_pool::{MemoryConsumer, MemoryReservation};
71use datafusion_execution::{SpillFile, TaskContext};
72use datafusion_expr::JoinType;
73use datafusion_physical_expr::equivalence::{
74    ProjectionMapping, join_equivalence_properties,
75};
76
77use datafusion_physical_expr::projection::{ProjectionRef, combine_projections};
78use futures::{Stream, StreamExt, TryStreamExt};
79use log::debug;
80use parking_lot::Mutex;
81
82use crate::metrics::SpillMetrics;
83use crate::spill::replayable_spill_input::ReplayableStreamSource;
84use crate::spill::spill_manager::SpillManager;
85
86#[expect(rustdoc::private_intra_doc_links)]
87/// NestedLoopJoinExec is a build-probe join operator designed for joins that
88/// do not have equijoin keys in their `ON` clause.
89///
90/// # Execution Flow
91///
92/// ```text
93///                                                Incoming right batch
94///                Left Side Buffered Batches
95///                       ┌───────────┐              ┌───────────────┐
96///                       │ ┌───────┐ │              │               │
97///                       │ │       │ │              │               │
98///  Current Left Row ───▶│ ├───────├─┤──────────┐   │               │
99///                       │ │       │ │          │   └───────────────┘
100///                       │ │       │ │          │           │
101///                       │ │       │ │          │           │
102///                       │ └───────┘ │          │           │
103///                       │ ┌───────┐ │          │           │
104///                       │ │       │ │          │     ┌─────┘
105///                       │ │       │ │          │     │
106///                       │ │       │ │          │     │
107///                       │ │       │ │          │     │
108///                       │ │       │ │          │     │
109///                       │ └───────┘ │          ▼     ▼
110///                       │   ......  │  ┌──────────────────────┐
111///                       │           │  │X (Cartesian Product) │
112///                       │           │  └──────────┬───────────┘
113///                       └───────────┘             │
114///                                                 │
115///                                                 ▼
116///                                      ┌───────┬───────────────┐
117///                                      │       │               │
118///                                      │       │               │
119///                                      │       │               │
120///                                      └───────┴───────────────┘
121///                                        Intermediate Batch
122///                                  (For join predicate evaluation)
123/// ```
124///
125/// The execution follows a two-phase design:
126///
127/// ## 1. Buffering Left Input
128/// - The operator eagerly buffers all left-side input batches into memory,
129///   util a memory limit is reached.
130///   Currently, an out-of-memory error will be thrown if all the left-side input batches
131///   cannot fit into memory at once.
132///   In the future, it's possible to make this case finish execution. (see
133///   'Memory-limited Execution' section)
134/// - The rationale for buffering the left side is that scanning the right side
135///   can be expensive (e.g., decoding Parquet files), so buffering more left
136///   rows reduces the number of right-side scan passes required.
137///
138/// ## 2. Probing Right Input
139/// - Right-side input is streamed batch by batch.
140/// - For each right-side batch:
141///   - It evaluates the join filter against the full buffered left input.
142///     This results in a Cartesian product between the right batch and each
143///     left row -- with the join predicate/filter applied -- for each inner
144///     loop iteration.
145///   - Matched results are accumulated into an output buffer. (see more in
146///     `Output Buffering Strategy` section)
147/// - This process continues until all right-side input is consumed.
148///
149/// # Producing unmatched build-side data
150/// - For special join types like left/full joins, it's required to also output
151///   unmatched pairs. During execution, bitmaps are kept for both left and right
152///   sides of the input; they'll be handled by dedicated states in `NLJStream`.
153/// - The final output of the left side unmatched rows is handled by a single
154///   partition for simplicity, since it only counts a small portion of the
155///   execution time. (e.g. if probe side has 10k rows, the final output of
156///   unmatched build side only roughly counts for 1/10k of the total time)
157///
158/// # Output Buffering Strategy
159/// The operator uses an intermediate output buffer to accumulate results. Once
160/// the output threshold is reached (currently set to the same value as
161/// `batch_size` in the configuration), the results will be eagerly output.
162///
163/// # Extra Notes
164/// - The operator always considers the **left** side as the build (buffered) side.
165///   Therefore, the physical optimizer should assign the smaller input to the left.
166/// - The design try to minimize the intermediate data size to approximately
167///   1 batch, for better cache locality and memory efficiency.
168///
169/// # Memory-limited Execution
170/// When the memory budget is exceeded during left-side buffering, the operator
171/// falls back to a multi-pass strategy:
172/// 1. Buffer as many left rows as fit in memory (one "chunk")
173/// 2. On the first pass, the right side is both processed and spilled to disk
174/// 3. For each subsequent left chunk, the right side is re-read from the spill file
175///
176/// The fallback is triggered automatically when the initial in-memory load
177/// fails with `ResourcesExhausted` and disk spilling is available. Each
178/// output partition independently re-executes the left child and manages
179/// its own spill state.
180///
181/// All join types are supported. For RIGHT/FULL/RIGHT SEMI/RIGHT ANTI/
182/// RIGHT MARK joins, a global right-side bitmap (indexed by right batch
183/// sequence number) accumulates matches across all left chunks. After the
184/// last left chunk is processed, the right side is replayed one more time
185/// to emit unmatched right rows using the accumulated bitmap.
186///
187/// Tracking issue: <https://github.com/apache/datafusion/issues/15760>
188///
189/// # Clone / Shared State
190/// Note this structure includes a [`OnceAsync`] that is used to coordinate the
191/// loading of the left side with the processing in each output stream.
192/// Therefore it can not be [`Clone`]
193#[derive(Debug)]
194pub struct NestedLoopJoinExec {
195    /// left side
196    pub(crate) left: Arc<dyn ExecutionPlan>,
197    /// right side
198    pub(crate) right: Arc<dyn ExecutionPlan>,
199    /// Filters which are applied while finding matching rows
200    pub(crate) filter: Option<JoinFilter>,
201    /// How the join is performed
202    pub(crate) join_type: JoinType,
203    /// The full concatenated schema of left and right children should be distinct from
204    /// the output schema of the operator
205    join_schema: SchemaRef,
206    /// Future that consumes left input and buffers it in memory
207    ///
208    /// This structure is *shared* across all output streams.
209    ///
210    /// Each output stream waits on the `OnceAsync` to signal the completion of
211    /// the build(left) side data, and buffer them all for later joining.
212    build_side_data: OnceAsync<JoinLeftData>,
213    /// Shared left-side spill data for OOM fallback.
214    ///
215    /// When `build_side_data` fails with OOM, the first partition to
216    /// initiate fallback spills the entire left side to disk. Other
217    /// partitions share the same spill file via this `OnceAsync`,
218    /// avoiding redundant re-execution of the left child.
219    left_spill_data: Arc<OnceAsync<LeftSpillData>>,
220    /// Information of index and left / right placement of columns
221    column_indices: Vec<ColumnIndex>,
222    /// Projection to apply to the output of the join
223    projection: Option<ProjectionRef>,
224
225    /// Execution metrics
226    metrics: ExecutionPlanMetricsSet,
227    /// Cache holding plan properties like equivalences, output partitioning etc.
228    cache: Arc<PlanProperties>,
229}
230
231/// Helps to build [`NestedLoopJoinExec`].
232pub struct NestedLoopJoinExecBuilder {
233    left: Arc<dyn ExecutionPlan>,
234    right: Arc<dyn ExecutionPlan>,
235    join_type: JoinType,
236    filter: Option<JoinFilter>,
237    projection: Option<ProjectionRef>,
238}
239
240impl NestedLoopJoinExecBuilder {
241    /// Make a new [`NestedLoopJoinExecBuilder`].
242    pub fn new(
243        left: Arc<dyn ExecutionPlan>,
244        right: Arc<dyn ExecutionPlan>,
245        join_type: JoinType,
246    ) -> Self {
247        Self {
248            left,
249            right,
250            join_type,
251            filter: None,
252            projection: None,
253        }
254    }
255
256    /// Set projection from the vector.
257    pub fn with_projection(self, projection: Option<Vec<usize>>) -> Self {
258        self.with_projection_ref(projection.map(Into::into))
259    }
260
261    /// Set projection from the shared reference.
262    pub fn with_projection_ref(mut self, projection: Option<ProjectionRef>) -> Self {
263        self.projection = projection;
264        self
265    }
266
267    /// Set optional filter.
268    pub fn with_filter(mut self, filter: Option<JoinFilter>) -> Self {
269        self.filter = filter;
270        self
271    }
272
273    /// Build resulting execution plan.
274    pub fn build(self) -> Result<NestedLoopJoinExec> {
275        let Self {
276            left,
277            right,
278            join_type,
279            filter,
280            projection,
281        } = self;
282
283        let left_schema = left.schema();
284        let right_schema = right.schema();
285        check_join_is_valid(&left_schema, &right_schema, &[])?;
286        let (join_schema, column_indices) =
287            build_join_schema(&left_schema, &right_schema, &join_type);
288        let join_schema = Arc::new(join_schema);
289        let cache = NestedLoopJoinExec::compute_properties(
290            &left,
291            &right,
292            &join_schema,
293            join_type,
294            projection.as_deref(),
295        )?;
296        Ok(NestedLoopJoinExec {
297            left,
298            right,
299            filter,
300            join_type,
301            join_schema,
302            build_side_data: Default::default(),
303            left_spill_data: Arc::new(OnceAsync::default()),
304            column_indices,
305            projection,
306            metrics: Default::default(),
307            cache: Arc::new(cache),
308        })
309    }
310}
311
312impl From<&NestedLoopJoinExec> for NestedLoopJoinExecBuilder {
313    fn from(exec: &NestedLoopJoinExec) -> Self {
314        Self {
315            left: Arc::clone(exec.left()),
316            right: Arc::clone(exec.right()),
317            join_type: exec.join_type,
318            filter: exec.filter.clone(),
319            projection: exec.projection.clone(),
320        }
321    }
322}
323
324impl NestedLoopJoinExec {
325    /// Try to create a new [`NestedLoopJoinExec`]
326    pub fn try_new(
327        left: Arc<dyn ExecutionPlan>,
328        right: Arc<dyn ExecutionPlan>,
329        filter: Option<JoinFilter>,
330        join_type: &JoinType,
331        projection: Option<Vec<usize>>,
332    ) -> Result<Self> {
333        NestedLoopJoinExecBuilder::new(left, right, *join_type)
334            .with_projection(projection)
335            .with_filter(filter)
336            .build()
337    }
338
339    /// left side
340    pub fn left(&self) -> &Arc<dyn ExecutionPlan> {
341        &self.left
342    }
343
344    /// right side
345    pub fn right(&self) -> &Arc<dyn ExecutionPlan> {
346        &self.right
347    }
348
349    /// Filters applied before join output
350    pub fn filter(&self) -> Option<&JoinFilter> {
351        self.filter.as_ref()
352    }
353
354    /// How the join is performed
355    pub fn join_type(&self) -> &JoinType {
356        &self.join_type
357    }
358
359    pub fn projection(&self) -> &Option<ProjectionRef> {
360        &self.projection
361    }
362
363    /// This function creates the cache object that stores the plan properties such as schema, equivalence properties, ordering, partitioning, etc.
364    fn compute_properties(
365        left: &Arc<dyn ExecutionPlan>,
366        right: &Arc<dyn ExecutionPlan>,
367        schema: &SchemaRef,
368        join_type: JoinType,
369        projection: Option<&[usize]>,
370    ) -> Result<PlanProperties> {
371        // Calculate equivalence properties:
372        let mut eq_properties = join_equivalence_properties(
373            left.equivalence_properties().clone(),
374            right.equivalence_properties().clone(),
375            &join_type,
376            Arc::clone(schema),
377            &Self::maintains_input_order(join_type),
378            None,
379            // No on columns in nested loop join
380            &[],
381        )?;
382
383        let mut output_partitioning =
384            asymmetric_join_output_partitioning(left, right, &join_type)?;
385
386        let emission_type = if left.boundedness().is_unbounded() {
387            EmissionType::Final
388        } else if right.pipeline_behavior() == EmissionType::Incremental {
389            match join_type {
390                // If we only need to generate matched rows from the probe side,
391                // we can emit rows incrementally.
392                JoinType::Inner
393                | JoinType::LeftSemi
394                | JoinType::RightSemi
395                | JoinType::Right
396                | JoinType::RightAnti
397                | JoinType::RightMark => EmissionType::Incremental,
398                // If we need to generate unmatched rows from the *build side*,
399                // we need to emit them at the end.
400                JoinType::Left
401                | JoinType::LeftAnti
402                | JoinType::LeftMark
403                | JoinType::Full => EmissionType::Both,
404            }
405        } else {
406            right.pipeline_behavior()
407        };
408
409        if let Some(projection) = projection {
410            // construct a map from the input expressions to the output expression of the Projection
411            let projection_mapping = ProjectionMapping::from_indices(projection, schema)?;
412            let out_schema = project_schema(schema, Some(&projection))?;
413            output_partitioning =
414                output_partitioning.project(&projection_mapping, &eq_properties);
415            eq_properties = eq_properties.project(&projection_mapping, out_schema);
416        }
417
418        Ok(PlanProperties::new(
419            eq_properties,
420            output_partitioning,
421            emission_type,
422            boundedness_from_children([left, right]),
423        ))
424    }
425
426    /// This join implementation does not preserve the input order of either side.
427    fn maintains_input_order(_join_type: JoinType) -> Vec<bool> {
428        vec![false, false]
429    }
430
431    pub fn contains_projection(&self) -> bool {
432        self.projection.is_some()
433    }
434
435    pub fn with_projection(&self, projection: Option<Vec<usize>>) -> Result<Self> {
436        let projection = projection.map(Into::into);
437        // check if the projection is valid
438        can_project(&self.schema(), projection.as_deref())?;
439        let projection =
440            combine_projections(projection.as_ref(), self.projection.as_ref())?;
441        NestedLoopJoinExecBuilder::from(self)
442            .with_projection_ref(projection)
443            .build()
444    }
445
446    /// Returns a new `ExecutionPlan` that runs NestedLoopsJoins with the left
447    /// and right inputs swapped.
448    ///
449    /// # Notes:
450    ///
451    /// This function should be called BEFORE inserting any repartitioning
452    /// operators on the join's children. Check [`super::HashJoinExec::swap_inputs`]
453    /// for more details.
454    pub fn swap_inputs(&self) -> Result<Arc<dyn ExecutionPlan>> {
455        let left = self.left();
456        let right = self.right();
457        let new_join = NestedLoopJoinExec::try_new(
458            Arc::clone(right),
459            Arc::clone(left),
460            self.filter().map(JoinFilter::swap),
461            &self.join_type().swap(),
462            swap_join_projection(
463                left.schema().fields().len(),
464                right.schema().fields().len(),
465                self.projection.as_deref(),
466                self.join_type(),
467            ),
468        )?;
469
470        // For Semi/Anti joins, swap result will produce same output schema,
471        // no need to wrap them into additional projection
472        let plan: Arc<dyn ExecutionPlan> = if matches!(
473            self.join_type(),
474            JoinType::LeftSemi
475                | JoinType::RightSemi
476                | JoinType::LeftAnti
477                | JoinType::RightAnti
478                | JoinType::LeftMark
479                | JoinType::RightMark
480        ) || self.projection.is_some()
481        {
482            Arc::new(new_join)
483        } else {
484            reorder_output_after_swap(
485                Arc::new(new_join),
486                &self.left().schema(),
487                &self.right().schema(),
488            )?
489        };
490
491        Ok(plan)
492    }
493}
494
495impl DisplayAs for NestedLoopJoinExec {
496    fn fmt_as(&self, t: DisplayFormatType, f: &mut Formatter) -> std::fmt::Result {
497        match t {
498            DisplayFormatType::Default | DisplayFormatType::Verbose => {
499                let display_filter = self.filter.as_ref().map_or_else(
500                    || "".to_string(),
501                    |f| format!(", filter={}", f.expression()),
502                );
503                let display_projections = if self.contains_projection() {
504                    format!(
505                        ", projection=[{}]",
506                        self.projection
507                            .as_ref()
508                            .unwrap()
509                            .iter()
510                            .map(|index| format!(
511                                "{}@{}",
512                                self.join_schema.fields().get(*index).unwrap().name(),
513                                index
514                            ))
515                            .collect::<Vec<_>>()
516                            .join(", ")
517                    )
518                } else {
519                    "".to_string()
520                };
521                write!(
522                    f,
523                    "NestedLoopJoinExec: join_type={:?}{}{}",
524                    self.join_type, display_filter, display_projections
525                )
526            }
527            DisplayFormatType::TreeRender => {
528                if *self.join_type() != JoinType::Inner {
529                    writeln!(f, "join_type={:?}", self.join_type)
530                } else {
531                    Ok(())
532                }
533            }
534        }
535    }
536}
537
538impl ExecutionPlan for NestedLoopJoinExec {
539    fn name(&self) -> &'static str {
540        "NestedLoopJoinExec"
541    }
542
543    fn properties(&self) -> &Arc<PlanProperties> {
544        &self.cache
545    }
546
547    fn required_input_distribution(&self) -> Vec<Distribution> {
548        self.input_distribution_requirements().into_per_child()
549    }
550
551    fn input_distribution_requirements(&self) -> crate::InputDistributionRequirements {
552        crate::InputDistributionRequirements::new(vec![
553            Distribution::SinglePartition,
554            Distribution::UnspecifiedDistribution,
555        ])
556    }
557
558    fn maintains_input_order(&self) -> Vec<bool> {
559        Self::maintains_input_order(self.join_type)
560    }
561
562    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
563        vec![&self.left, &self.right]
564    }
565
566    fn apply_expressions(
567        &self,
568        f: &mut dyn FnMut(&Arc<dyn crate::PhysicalExpr>) -> Result<TreeNodeRecursion>,
569    ) -> Result<TreeNodeRecursion> {
570        // Apply to join filter expressions if present
571        crate::apply_expression_roots(
572            self.filter.iter().map(|filter| filter.expression()),
573            f,
574        )
575    }
576
577    fn replace_children(
578        self: Arc<Self>,
579        mut children: Vec<Arc<dyn ExecutionPlan>>,
580        options: ReplaceChildrenOptions,
581    ) -> Result<Arc<dyn ExecutionPlan>> {
582        validate_child_count!(self, children);
583        match options.children_properties {
584            ChildrenPropertiesMode::Keep => {
585                let left = children.swap_remove(0);
586                let right = children.swap_remove(0);
587                Ok(Arc::new(Self {
588                    left,
589                    right,
590                    metrics: ExecutionPlanMetricsSet::new(),
591                    build_side_data: Default::default(),
592                    left_spill_data: Arc::new(OnceAsync::default()),
593                    cache: Arc::clone(&self.cache),
594                    filter: self.filter.clone(),
595                    join_type: self.join_type,
596                    join_schema: Arc::clone(&self.join_schema),
597                    column_indices: self.column_indices.clone(),
598                    projection: self.projection.clone(),
599                }))
600            }
601            ChildrenPropertiesMode::Recompute => Ok(Arc::new(
602                NestedLoopJoinExecBuilder::new(
603                    Arc::clone(&children[0]),
604                    Arc::clone(&children[1]),
605                    self.join_type,
606                )
607                .with_filter(self.filter.clone())
608                .with_projection_ref(self.projection.clone())
609                .build()?,
610            )),
611        }
612    }
613
614    fn with_new_children(
615        self: Arc<Self>,
616        children: Vec<Arc<dyn ExecutionPlan>>,
617    ) -> Result<Arc<dyn ExecutionPlan>> {
618        self.replace_children(
619            children,
620            ReplaceChildrenOptions::new(ChildrenPropertiesMode::Recompute),
621        )
622    }
623
624    fn with_new_children_and_same_properties(
625        self: Arc<Self>,
626        children: Vec<Arc<dyn ExecutionPlan>>,
627    ) -> Result<Arc<dyn ExecutionPlan>> {
628        self.replace_children(
629            children,
630            ReplaceChildrenOptions::new(ChildrenPropertiesMode::Keep),
631        )
632    }
633
634    fn execute(
635        &self,
636        partition: usize,
637        context: Arc<TaskContext>,
638    ) -> Result<SendableRecordBatchStream> {
639        assert_eq_or_internal_err!(
640            self.left.output_partitioning().partition_count(),
641            1,
642            "Invalid NestedLoopJoinExec, the output partition count of the left child must be 1,\
643                 consider using CoalescePartitionsExec or the EnforceDistribution rule"
644        );
645
646        let metrics = NestedLoopJoinMetrics::new(&self.metrics, partition);
647        let batch_size = context.session_config().batch_size();
648
649        // update column indices to reflect the projection
650        let column_indices_after_projection = match self.projection.as_ref() {
651            Some(projection) => projection
652                .iter()
653                .map(|i| self.column_indices[*i].clone())
654                .collect(),
655            None => self.column_indices.clone(),
656        };
657
658        let right_partition_count = self.right().output_partitioning().partition_count();
659
660        // Always try to buffer all left data in memory via OnceFut.
661        // If that fails with OOM, the stream will fallback to memory-limited
662        // mode (if conditions allow).
663        let load_reservation =
664            MemoryConsumer::new(format!("NestedLoopJoinLoad[{partition}]"))
665                .register(context.memory_pool());
666
667        let build_side_data = self.build_side_data.try_once(|| {
668            let stream = self.left.execute(0, Arc::clone(&context))?;
669
670            Ok(collect_left_input(
671                stream,
672                metrics.join_metrics.clone(),
673                load_reservation,
674                need_produce_result_in_final(self.join_type),
675                right_partition_count,
676            ))
677        })?;
678
679        let probe_side_data = self.right.execute(partition, Arc::clone(&context))?;
680
681        // Determine if OOM fallback to memory-limited mode is possible.
682        // Conditions:
683        // 1. Disk manager supports temp files (needed for spilling).
684        // 2. FULL join with multiple right partitions is not yet supported
685        //    in the fallback path. FULL join needs to track BOTH left-side
686        //    matches (for unmatched left rows) AND right-side matches (for
687        //    unmatched right rows). The fallback path builds a per-partition
688        //    `JoinLeftData` with `probe_threads_counter == 1`, so each
689        //    partition emits unmatched left rows based only on its own
690        //    right-side matches, producing incorrect duplicate output for
691        //    left rows that match in another partition. Other join types
692        //    that need only one-sided final emission (LEFT, LEFT SEMI,
693        //    LEFT ANTI, LEFT MARK) have a similar latent issue in the
694        //    fallback path which predates this change; tracking is out of
695        //    scope for this PR.
696        let full_join_multi_partition =
697            matches!(self.join_type, JoinType::Full) && right_partition_count > 1;
698        let spill_state = if context.runtime_env().disk_manager.tmp_files_enabled()
699            && !full_join_multi_partition
700        {
701            SpillState::Pending {
702                left_plan: Arc::clone(&self.left),
703                task_context: Arc::clone(&context),
704                left_spill_data: Arc::clone(&self.left_spill_data),
705            }
706        } else {
707            SpillState::Disabled
708        };
709
710        Ok(Box::pin(NestedLoopJoinStream::new(
711            self.schema(),
712            self.filter.clone(),
713            self.join_type,
714            probe_side_data,
715            build_side_data,
716            column_indices_after_projection,
717            metrics,
718            batch_size,
719            spill_state,
720        )))
721    }
722
723    fn metrics(&self) -> Option<MetricsSet> {
724        Some(self.metrics.clone_inner())
725    }
726
727    fn child_stats_requests(&self, partition: Option<usize>) -> Vec<ChildStats> {
728        // Left side is always broadcast, so it always needs overall stats.
729        // Right side is partitioned, so it needs per-partition stats.
730        vec![ChildStats::At(None), ChildStats::At(partition)]
731    }
732
733    fn statistics_from_inputs(
734        &self,
735        input_stats: &[Arc<Statistics>],
736        _args: &StatisticsArgs,
737    ) -> Result<Arc<Statistics>> {
738        // NestedLoopJoinExec is designed for joins without equijoin keys in the
739        // ON clause (e.g., `t1 JOIN t2 ON (t1.v1 + t2.v1) % 2 = 0`). Any join
740        // predicates are stored in `self.filter`, but `estimate_join_statistics`
741        // currently doesn't support selectivity estimation for such arbitrary
742        // filter expressions. We pass an empty join column list, which means
743        // the cardinality estimation cannot use column statistics and returns
744        // unknown row counts.
745        let join_columns = Vec::new();
746
747        let left_stats = input_stats[0].as_ref().clone();
748        let right_stats = input_stats[1].as_ref().clone();
749
750        let stats = estimate_join_statistics(
751            left_stats,
752            right_stats,
753            &join_columns,
754            NullEquality::NullEqualsNothing,
755            &self.join_type,
756            &self.join_schema,
757        )?;
758
759        Ok(Arc::new(stats.project(self.projection.as_ref())))
760    }
761
762    /// Tries to push `projection` down through `nested_loop_join`. If possible, performs the
763    /// pushdown and returns a new [`NestedLoopJoinExec`] as the top plan which has projections
764    /// as its children. Otherwise, returns `None`.
765    fn try_swapping_with_projection(
766        &self,
767        projection: &ProjectionExec,
768    ) -> Result<Option<Arc<dyn ExecutionPlan>>> {
769        // TODO: currently if there is projection in NestedLoopJoinExec, we can't push down projection to left or right input. Maybe we can pushdown the mixed projection later.
770        if self.contains_projection() {
771            return Ok(None);
772        }
773
774        let schema = self.schema();
775        if let Some(JoinData {
776            projected_left_child,
777            projected_right_child,
778            join_filter,
779            ..
780        }) = try_pushdown_through_join_with_column_indices(
781            projection,
782            self.left(),
783            self.right(),
784            &[],
785            &schema,
786            self.filter(),
787            self.column_indices.as_slice(),
788        )? {
789            Ok(Some(Arc::new(NestedLoopJoinExec::try_new(
790                Arc::new(projected_left_child),
791                Arc::new(projected_right_child),
792                join_filter,
793                self.join_type(),
794                // Returned early if projection is not None
795                None,
796            )?)))
797        } else {
798            try_embed_projection(projection, self)
799        }
800    }
801    #[cfg(feature = "proto")]
802    fn try_to_proto(
803        &self,
804        ctx: &crate::proto::ExecutionPlanEncodeCtx<'_>,
805    ) -> Result<Option<datafusion_proto_models::protobuf::PhysicalPlanNode>> {
806        use datafusion_proto_models::protobuf;
807
808        let left = ctx.encode_child(self.left())?;
809        let right = ctx.encode_child(self.right())?;
810
811        let join_type = crate::joins::proto::join_type_to_proto(*self.join_type());
812
813        let filter = self
814            .filter()
815            .map(|f| crate::joins::proto::join_filter_to_proto(f, ctx))
816            .transpose()?;
817
818        Ok(Some(protobuf::PhysicalPlanNode {
819            physical_plan_type: Some(
820                protobuf::physical_plan_node::PhysicalPlanType::NestedLoopJoin(Box::new(
821                    protobuf::NestedLoopJoinExecNode {
822                        left: Some(Box::new(left)),
823                        right: Some(Box::new(right)),
824                        join_type: join_type.into(),
825                        filter,
826                        projection: match self.projection.as_ref() {
827                            None => Vec::new(),
828                            Some(v) if v.is_empty() => vec![u32::MAX],
829                            Some(v) => v.iter().map(|x| *x as u32).collect(),
830                        },
831                    },
832                )),
833            ),
834        }))
835    }
836}
837
838#[cfg(feature = "proto")]
839impl NestedLoopJoinExec {
840    pub fn try_from_proto(
841        node: &datafusion_proto_models::protobuf::PhysicalPlanNode,
842        ctx: &crate::proto::ExecutionPlanDecodeCtx<'_>,
843    ) -> Result<Arc<dyn ExecutionPlan>> {
844        use datafusion_proto_models::protobuf;
845
846        let join = crate::expect_plan_variant!(
847            node,
848            protobuf::physical_plan_node::PhysicalPlanType::NestedLoopJoin,
849            "NestedLoopJoinExec",
850        );
851
852        let left = ctx.decode_required_child(
853            join.left.as_deref(),
854            "NestedLoopJoinExec",
855            "left",
856        )?;
857        let right = ctx.decode_required_child(
858            join.right.as_deref(),
859            "NestedLoopJoinExec",
860            "right",
861        )?;
862
863        let join_type = crate::joins::proto::join_type_from_proto(
864            join.join_type,
865            "NestedLoopJoinExec",
866        )?;
867
868        let filter = join
869            .filter
870            .as_ref()
871            .map(|f| {
872                crate::joins::proto::join_filter_from_proto(f, ctx, "NestedLoopJoinExec")
873            })
874            .transpose()?;
875
876        let projection = match join.projection.as_slice() {
877            [] => None,
878            [u32::MAX] => Some(Vec::new()),
879            indices => Some(indices.iter().map(|i| *i as usize).collect()),
880        };
881
882        Ok(Arc::new(NestedLoopJoinExec::try_new(
883            left, right, filter, &join_type, projection,
884        )?))
885    }
886}
887
888impl EmbeddedProjection for NestedLoopJoinExec {
889    fn with_projection(&self, projection: Option<Vec<usize>>) -> Result<Self> {
890        self.with_projection(projection)
891    }
892}
893
894/// Left (build-side) data
895pub(crate) struct JoinLeftData {
896    /// Build-side data collected to single batch
897    batch: RecordBatch,
898    /// Shared bitmap builder for visited left indices
899    bitmap: SharedBitmapBuilder,
900    /// Counter of running probe-threads, potentially able to update `bitmap`
901    probe_threads_counter: AtomicUsize,
902    /// Memory reservation for tracking batch and bitmap
903    /// Cleared on `JoinLeftData` drop
904    /// reservation is cleared on Drop
905    #[expect(dead_code)]
906    reservation: MemoryReservation,
907}
908
909impl JoinLeftData {
910    pub(crate) fn new(
911        batch: RecordBatch,
912        bitmap: SharedBitmapBuilder,
913        probe_threads_counter: AtomicUsize,
914        reservation: MemoryReservation,
915    ) -> Self {
916        Self {
917            batch,
918            bitmap,
919            probe_threads_counter,
920            reservation,
921        }
922    }
923
924    pub(crate) fn batch(&self) -> &RecordBatch {
925        &self.batch
926    }
927
928    pub(crate) fn bitmap(&self) -> &SharedBitmapBuilder {
929        &self.bitmap
930    }
931
932    /// Decrements counter of running threads, and returns `true`
933    /// if caller is the last running thread
934    pub(crate) fn report_probe_completed(&self) -> bool {
935        self.probe_threads_counter.fetch_sub(1, Ordering::Relaxed) == 1
936    }
937}
938
939/// Asynchronously collect input into a single batch, and creates `JoinLeftData` from it
940async fn collect_left_input(
941    stream: SendableRecordBatchStream,
942    join_metrics: BuildProbeJoinMetrics,
943    reservation: MemoryReservation,
944    with_visited_left_side: bool,
945    probe_threads_count: usize,
946) -> Result<JoinLeftData> {
947    let schema = stream.schema();
948
949    // Load all batches and count the rows
950    let (batches, metrics, reservation) = stream
951        .try_fold(
952            (Vec::new(), join_metrics, reservation),
953            |(mut batches, metrics, reservation), batch| async {
954                let batch_size = batch.get_array_memory_size();
955                // Reserve memory for incoming batch
956                reservation.try_grow(batch_size)?;
957                // Update metrics
958                metrics.build_mem_used.add(batch_size);
959                metrics.build_input_batches.add(1);
960                metrics.build_input_rows.add(batch.num_rows());
961                // Push batch to output
962                batches.push(batch);
963                Ok((batches, metrics, reservation))
964            },
965        )
966        .await?;
967
968    let merged_batch = concat_batches(&schema, &batches)?;
969
970    // Reserve memory for visited_left_side bitmap if required by join type
971    let visited_left_side = if with_visited_left_side {
972        let n_rows = merged_batch.num_rows();
973        let buffer_size = n_rows.div_ceil(8);
974        reservation.try_grow(buffer_size)?;
975        metrics.build_mem_used.add(buffer_size);
976
977        let mut buffer = BooleanBufferBuilder::new(n_rows);
978        buffer.append_n(n_rows, false);
979        buffer
980    } else {
981        BooleanBufferBuilder::new(0)
982    };
983
984    Ok(JoinLeftData::new(
985        merged_batch,
986        Mutex::new(visited_left_side),
987        AtomicUsize::new(probe_threads_count),
988        reservation,
989    ))
990}
991
992/// States for join processing. See `poll_next()` comment for more details about
993/// state transitions.
994#[derive(Debug, Clone, Copy)]
995enum NLJState {
996    BufferingLeft,
997    FetchingRight,
998    ProbeRight,
999    EmitRightUnmatched,
1000    /// Entered exactly once per left chunk, when the probe (right) side is
1001    /// exhausted and probing for the current chunk is finished. This state
1002    /// owns the single [`JoinLeftData::report_probe_completed`] call that
1003    /// decrements the shared probe-threads counter, and records in
1004    /// `is_unmatched_left_emitter` whether this stream is the one responsible
1005    /// for emitting unmatched-left rows. Splitting this decision out of
1006    /// `EmitLeftUnmatched` makes "decrement exactly once" a structural
1007    /// property of the state graph, so the (re-enterable) emit state no longer
1008    /// has to guard against decrementing twice.
1009    ProbeEnd,
1010    EmitLeftUnmatched,
1011    /// Emit unmatched right rows using the global bitmap accumulated across
1012    /// all left chunks. Only used in memory-limited mode for join types that
1013    /// require tracking right-side matches in the final output (RIGHT, FULL,
1014    /// RIGHT SEMI, RIGHT ANTI, RIGHT MARK).
1015    EmitGlobalRightUnmatched,
1016    Done,
1017}
1018/// Shared data for the left-side spill fallback.
1019///
1020/// When the in-memory `OnceFut` path fails with OOM, the first partition
1021/// spills the entire left side to disk. This struct holds the spill file
1022/// reference so other partitions can read from the same file.
1023pub(crate) struct LeftSpillData {
1024    /// SpillManager used to read the spill file (has the left schema)
1025    spill_manager: SpillManager,
1026    /// The spill file containing all left-side batches
1027    spill_file: Arc<dyn SpillFile>,
1028    /// Left-side schema
1029    schema: SchemaRef,
1030}
1031
1032/// Tracks the state of the memory-limited spill fallback for NLJ.
1033///
1034/// The NLJ always starts with the standard OnceFut path. If the in-memory
1035/// load fails with OOM and conditions allow, the operator falls back to a
1036/// multi-pass strategy where left data is loaded in chunks and the right
1037/// side is spilled to disk.
1038pub(crate) enum SpillState {
1039    /// Fallback is not possible (e.g., join type requires global right bitmap,
1040    /// or disk manager is disabled). OOM errors will propagate as-is.
1041    Disabled,
1042
1043    /// Fallback is possible but not yet triggered. The operator is still
1044    /// attempting the standard OnceFut path. Holds the context needed to
1045    /// initiate fallback if OOM occurs.
1046    Pending {
1047        /// Left child plan for re-execution
1048        left_plan: Arc<dyn ExecutionPlan>,
1049        /// TaskContext for re-execution and SpillManager creation
1050        task_context: Arc<TaskContext>,
1051        /// Shared OnceAsync for left-side spill data. The first partition
1052        /// to initiate fallback spills the left side; others share the file.
1053        left_spill_data: Arc<OnceAsync<LeftSpillData>>,
1054    },
1055
1056    /// Fallback has been triggered. Left data is being loaded in chunks
1057    /// and the right side is spilled to disk for re-scanning.
1058    Active(Box<SpillStateActive>),
1059}
1060
1061/// State for active memory-limited spill execution.
1062/// Boxed inside [`SpillState::Active`] to reduce enum size.
1063pub(crate) struct SpillStateActive {
1064    /// Shared future for left-side spill data. All partitions wait on
1065    /// the same future — the first to poll triggers the actual spill.
1066    left_spill_fut: OnceFut<LeftSpillData>,
1067    /// Left input stream for incremental chunk reading (from spill file).
1068    /// None until `left_spill_fut` resolves.
1069    left_stream: Option<SendableRecordBatchStream>,
1070    /// Left-side schema (set once `left_spill_fut` resolves)
1071    left_schema: Option<SchemaRef>,
1072    /// Memory reservation for left-side buffering
1073    reservation: MemoryReservation,
1074    /// Accumulated left batches for the current chunk
1075    pending_batches: Vec<RecordBatch>,
1076    /// Right input that spills on the first pass and replays from spill later.
1077    right_input: ReplayableStreamSource,
1078    /// Per-batch accumulated right bitmaps across all left chunks.
1079    /// Index = right batch sequence number (0-based, non-empty batches only).
1080    /// Only populated when `should_track_unmatched_right` is true.
1081    global_right_bitmaps: Vec<BooleanBuffer>,
1082    /// Separate reservation for `global_right_bitmaps`. These buffers live
1083    /// for the full operator lifetime (not per-chunk), so they must be
1084    /// tracked separately from `reservation`, which gets `resize(0)`-ed
1085    /// between chunks.
1086    global_right_bitmaps_reservation: MemoryReservation,
1087    /// Current right batch sequence index within the current pass.
1088    right_batch_index: usize,
1089}
1090
1091impl SpillStateActive {
1092    /// Merge a per-pass right bitmap into the global accumulator at the
1093    /// given batch index, growing the dedicated reservation when seeing
1094    /// a batch index for the first time.
1095    ///
1096    /// On first encounter of `idx`, the bitmap is stored as-is and its
1097    /// size is reserved. On subsequent encounters (later left chunk
1098    /// passes over the same right batch), the existing entry is OR-merged
1099    /// with `values`. Because `bitor` produces a buffer of the same bit
1100    /// length, the reservation does not need to be adjusted on merge.
1101    fn merge_current_right_bitmap(&mut self, idx: usize, values: BooleanBuffer) {
1102        if idx >= self.global_right_bitmaps.len() {
1103            // First encounter of this right batch — account memory and store.
1104            // The bitmap has one bit per right row, so for very large right
1105            // inputs the accumulated size can be non-negligible (e.g.,
1106            // 1M rows ≈ 125 KB per batch).
1107            // Use infallible `grow` because we must accept the bitmap to
1108            // preserve correctness — the fallback path has no other recourse.
1109            let bytes = values.len().div_ceil(8);
1110            self.global_right_bitmaps_reservation.grow(bytes);
1111            self.global_right_bitmaps.push(values);
1112        } else {
1113            // Subsequent left chunk pass — OR merge. Same bit length, so
1114            // no reservation adjustment is needed.
1115            self.global_right_bitmaps[idx] =
1116                self.global_right_bitmaps[idx].bitor(&values);
1117        }
1118    }
1119}
1120
1121pub(crate) struct NestedLoopJoinStream {
1122    // ========================================================================
1123    // PROPERTIES:
1124    // Operator's properties that remain constant
1125    //
1126    // Note: The implementation uses the terms left/build-side table and
1127    // right/probe-side table interchangeably. Treating the left side as the
1128    // build side is a convention in DataFusion: the planner always tries to
1129    // swap the smaller table to the left side.
1130    // ========================================================================
1131    /// Output schema
1132    pub(crate) output_schema: Arc<Schema>,
1133    /// join filter
1134    pub(crate) join_filter: Option<JoinFilter>,
1135    /// type of the join
1136    pub(crate) join_type: JoinType,
1137    /// the probe-side(right) table data of the nested loop join
1138    /// `Option` is used because memory-limited path requires resetting it.
1139    pub(crate) right_data: Option<SendableRecordBatchStream>,
1140    /// the build-side table data of the nested loop join
1141    pub(crate) left_data: OnceFut<JoinLeftData>,
1142    /// Projection to construct the output schema from the left and right tables.
1143    /// Example:
1144    /// - output_schema: ['a', 'c']
1145    /// - left_schema: ['a', 'b']
1146    /// - right_schema: ['c']
1147    ///
1148    /// The column indices would be [(left, 0), (right, 0)] -- taking the left
1149    /// 0th column and right 0th column can construct the output schema.
1150    ///
1151    /// Note there are other columns ('b' in the example) still kept after
1152    /// projection pushdown; this is because they might be used to evaluate
1153    /// the join filter (e.g., `JOIN ON (b+c)>0`).
1154    pub(crate) column_indices: Vec<ColumnIndex>,
1155    /// Join execution metrics
1156    pub(crate) metrics: NestedLoopJoinMetrics,
1157
1158    /// `batch_size` from configuration
1159    batch_size: usize,
1160
1161    /// See comments in [`need_produce_right_in_final`] for more detail
1162    should_track_unmatched_right: bool,
1163
1164    // ========================================================================
1165    // STATE FLAGS/BUFFERS:
1166    // Fields that hold intermediate data/flags during execution
1167    // ========================================================================
1168    /// State Tracking
1169    state: NLJState,
1170    /// Output buffer holds the join result to output. It will emit eagerly when
1171    /// the threshold is reached.
1172    output_buffer: Box<BatchCoalescer>,
1173    /// See comments in [`NLJState::Done`] for its purpose
1174    handled_empty_output: bool,
1175
1176    // Buffer(left) side
1177    // -----------------
1178    /// The current buffered left data to join
1179    buffered_left_data: Option<Arc<JoinLeftData>>,
1180    /// Index into the left buffered batch. Used in `ProbeRight` state
1181    left_probe_idx: usize,
1182    /// Index into the left buffered batch. Used in `EmitLeftUnmatched` state
1183    left_emit_idx: usize,
1184    /// Should we go back to `BufferingLeft` state again after `EmitLeftUnmatched`
1185    /// state is over.
1186    left_exhausted: bool,
1187    /// If we can buffer all left data in one pass (false means memory-limited multi-pass)
1188    left_buffered_in_one_pass: bool,
1189
1190    // Probe(right) side
1191    // -----------------
1192    /// The current probe batch to process
1193    current_right_batch: Option<RecordBatch>,
1194    // For right join, keep track of matched rows in `current_right_batch`
1195    // Constructed when fetching each new incoming right batch in `FetchingRight` state.
1196    current_right_batch_matched: Option<BooleanArray>,
1197
1198    /// Memory-limited spill fallback state. See [`SpillState`] for details.
1199    spill_state: SpillState,
1200
1201    /// Whether this stream is the one responsible for emitting unmatched-left
1202    /// rows for the current left chunk. Set in the [`NLJState::ProbeEnd`] state,
1203    /// which is entered exactly once per chunk and owns the single
1204    /// [`JoinLeftData::report_probe_completed`] call: the stream that drives the
1205    /// shared probe-threads counter to zero (the last to finish probing) becomes
1206    /// the emitter. Because the decrement happens once in `ProbeEnd` rather than
1207    /// in the re-enterable `EmitLeftUnmatched` state, the counter can never be
1208    /// decremented twice, so it cannot reach zero before all partitions finish
1209    /// probing (which would otherwise let a partition emit spurious NULL-padded
1210    /// unmatched-left rows early).
1211    is_unmatched_left_emitter: bool,
1212}
1213
1214pub(crate) struct NestedLoopJoinMetrics {
1215    /// Join execution metrics
1216    pub(crate) join_metrics: BuildProbeJoinMetrics,
1217    /// Selectivity of the join: output_rows / (left_rows * right_rows)
1218    pub(crate) selectivity: RatioMetrics,
1219    /// Spill metrics for memory-limited execution
1220    pub(crate) spill_metrics: SpillMetrics,
1221}
1222
1223impl NestedLoopJoinMetrics {
1224    pub fn new(metrics: &ExecutionPlanMetricsSet, partition: usize) -> Self {
1225        Self {
1226            join_metrics: BuildProbeJoinMetrics::new(partition, metrics),
1227            selectivity: MetricBuilder::new(metrics)
1228                .with_type(MetricType::Summary)
1229                .ratio_metrics("selectivity", partition),
1230            spill_metrics: SpillMetrics::new(metrics, partition),
1231        }
1232    }
1233}
1234
1235impl Stream for NestedLoopJoinStream {
1236    type Item = Result<RecordBatch>;
1237
1238    /// See the comments [`NestedLoopJoinExec`] for high-level design ideas.
1239    ///
1240    /// # Implementation
1241    ///
1242    /// This function is the entry point of NLJ operator's state machine
1243    /// transitions. The rough state transition graph is as follow, for more
1244    /// details see the comment in each state's matching arm.
1245    ///
1246    /// ============================
1247    /// State transition graph:
1248    /// ============================
1249    ///
1250    /// (start) --> BufferingLeft
1251    /// ----------------------------
1252    /// BufferingLeft → FetchingRight
1253    ///
1254    /// FetchingRight → ProbeRight (if right batch available)
1255    /// FetchingRight → ProbeEnd (if right exhausted)
1256    ///
1257    /// ProbeRight → ProbeRight (next left row or after yielding output)
1258    /// ProbeRight → EmitRightUnmatched (for special join types like right join)
1259    /// ProbeRight → FetchingRight (done with the current right batch)
1260    ///
1261    /// EmitRightUnmatched → FetchingRight
1262    ///
1263    /// ProbeEnd → EmitLeftUnmatched (records whether this stream is the
1264    /// unmatched-left emitter, then always continues to EmitLeftUnmatched)
1265    ///
1266    /// EmitLeftUnmatched → EmitLeftUnmatched (only process 1 chunk for each
1267    /// iteration)
1268    /// EmitLeftUnmatched → Done (if finished)
1269    /// ----------------------------
1270    /// Done → (end)
1271    fn poll_next(
1272        mut self: std::pin::Pin<&mut Self>,
1273        cx: &mut std::task::Context<'_>,
1274    ) -> Poll<Option<Self::Item>> {
1275        loop {
1276            match self.state {
1277                // # NLJState transitions
1278                // --> FetchingRight
1279                // This state will prepare the left side batches, next state
1280                // `FetchingRight` is responsible for preparing a single probe
1281                // side batch, before start joining.
1282                NLJState::BufferingLeft => {
1283                    debug!("[NLJState] Entering: {:?}", self.state);
1284                    // inside `collect_left_input` (the routine to buffer build
1285                    // -side batches), related metrics except build time will be
1286                    // updated.
1287                    // stop on drop
1288                    let build_metric = self.metrics.join_metrics.build_time.clone();
1289                    let _build_timer = build_metric.timer();
1290
1291                    match self.handle_buffering_left(cx) {
1292                        ControlFlow::Continue(()) => continue,
1293                        ControlFlow::Break(poll) => return poll,
1294                    }
1295                }
1296
1297                // # NLJState transitions:
1298                // 1. --> ProbeRight
1299                //    Start processing the join for the newly fetched right
1300                //    batch.
1301                // 2. --> ProbeEnd: When the right side input is exhausted,
1302                //    probing for the current left chunk is finished.
1303                //
1304                // After fetching a new batch from the right side, it will
1305                // process all rows from the buffered left data:
1306                // ```text
1307                // for batch in right_side:
1308                //     for row in left_buffer:
1309                //         join(batch, row)
1310                // ```
1311                // Note: the implementation does this step incrementally,
1312                // instead of materializing all intermediate Cartesian products
1313                // at once in memory.
1314                //
1315                // So after the right side input is exhausted, the join phase
1316                // for the current buffered left data is finished. We go to the
1317                // `ProbeEnd` state, which records probe completion before the
1318                // `EmitLeftUnmatched` phase checks if there is any special
1319                // handling (e.g., in cases like left join).
1320                NLJState::FetchingRight => {
1321                    debug!("[NLJState] Entering: {:?}", self.state);
1322                    // stop on drop
1323                    let join_metric = self.metrics.join_metrics.join_time.clone();
1324                    let _join_timer = join_metric.timer();
1325
1326                    match self.handle_fetching_right(cx) {
1327                        ControlFlow::Continue(()) => continue,
1328                        ControlFlow::Break(poll) => return poll,
1329                    }
1330                }
1331
1332                // NLJState transitions:
1333                // 1. --> ProbeRight(1)
1334                //    If we have already buffered enough output to yield, it
1335                //    will first give back control to the parent state machine,
1336                //    then resume at the same place.
1337                // 2. --> ProbeRight(2)
1338                //    After probing one right batch, and evaluating the
1339                //    join filter on (left-row x right-batch), it will advance
1340                //    to the next left row, then re-enter the current state and
1341                //    continue joining.
1342                // 3. --> FetchRight
1343                //    After it has done with the current right batch (to join
1344                //    with all rows in the left buffer), it will go to
1345                //    FetchRight state to check what to do next.
1346                NLJState::ProbeRight => {
1347                    debug!("[NLJState] Entering: {:?}", self.state);
1348
1349                    // stop on drop
1350                    let join_metric = self.metrics.join_metrics.join_time.clone();
1351                    let _join_timer = join_metric.timer();
1352
1353                    match self.handle_probe_right() {
1354                        ControlFlow::Continue(()) => continue,
1355                        ControlFlow::Break(poll) => {
1356                            return self.metrics.join_metrics.baseline.record_poll(poll);
1357                        }
1358                    }
1359                }
1360
1361                // In the `current_right_batch_matched` bitmap, all trues mean
1362                // it has been output by the join. In this state we have to
1363                // output unmatched rows for current right batch (with null
1364                // padding for left relation)
1365                // Precondition: we have checked the join type so that it's
1366                // possible to output right unmatched (e.g. it's right join)
1367                NLJState::EmitRightUnmatched => {
1368                    debug!("[NLJState] Entering: {:?}", self.state);
1369
1370                    // stop on drop
1371                    let join_metric = self.metrics.join_metrics.join_time.clone();
1372                    let _join_timer = join_metric.timer();
1373
1374                    match self.handle_emit_right_unmatched() {
1375                        ControlFlow::Continue(()) => continue,
1376                        ControlFlow::Break(poll) => {
1377                            return self.metrics.join_metrics.baseline.record_poll(poll);
1378                        }
1379                    }
1380                }
1381
1382                // NLJState transitions:
1383                // 1. --> EmitLeftUnmatched
1384                //    Probing for the current left chunk is finished. Report
1385                //    probe completion exactly once (decrementing the shared
1386                //    probe-threads counter) and record whether this stream is
1387                //    the unmatched-left emitter, then always advance to
1388                //    `EmitLeftUnmatched`.
1389                NLJState::ProbeEnd => {
1390                    debug!("[NLJState] Entering: {:?}", self.state);
1391
1392                    // stop on drop
1393                    let join_metric = self.metrics.join_metrics.join_time.clone();
1394                    let _join_timer = join_metric.timer();
1395
1396                    match self.handle_probe_end() {
1397                        ControlFlow::Continue(()) => continue,
1398                        ControlFlow::Break(poll) => {
1399                            return self.metrics.join_metrics.baseline.record_poll(poll);
1400                        }
1401                    }
1402                }
1403
1404                // NLJState transitions:
1405                // 1. --> EmitLeftUnmatched(1)
1406                //    If we have already buffered enough output to yield, it
1407                //    will first give back control to the parent state machine,
1408                //    then resume at the same place.
1409                // 2. --> EmitLeftUnmatched(2)
1410                //    After processing some unmatched rows, it will re-enter
1411                //    the same state, to check if there are any more final
1412                //    results to output.
1413                // 3. --> Done
1414                //    It has processed all data, go to the final state and ready
1415                //    to exit.
1416                // 4. --> BufferingLeft (memory-limited mode only)
1417                //    When left data was loaded in chunks and more chunks remain,
1418                //    go back to BufferingLeft to load the next chunk.
1419                NLJState::EmitLeftUnmatched => {
1420                    debug!("[NLJState] Entering: {:?}", self.state);
1421
1422                    // stop on drop
1423                    let join_metric = self.metrics.join_metrics.join_time.clone();
1424                    let _join_timer = join_metric.timer();
1425
1426                    match self.handle_emit_left_unmatched() {
1427                        ControlFlow::Continue(()) => continue,
1428                        ControlFlow::Break(poll) => {
1429                            return self.metrics.join_metrics.baseline.record_poll(poll);
1430                        }
1431                    }
1432                }
1433
1434                // Replay all right batches from spill and emit unmatched
1435                // right rows using the global bitmap accumulated across all
1436                // left chunks. Only entered in memory-limited mode for join
1437                // types where `should_track_unmatched_right` is true
1438                // (RIGHT, FULL, RIGHT SEMI, RIGHT ANTI, RIGHT MARK).
1439                NLJState::EmitGlobalRightUnmatched => {
1440                    debug!("[NLJState] Entering: {:?}", self.state);
1441
1442                    let join_metric = self.metrics.join_metrics.join_time.clone();
1443                    let _join_timer = join_metric.timer();
1444
1445                    match self.handle_emit_global_right_unmatched(cx) {
1446                        ControlFlow::Continue(()) => continue,
1447                        ControlFlow::Break(poll) => {
1448                            return self.metrics.join_metrics.baseline.record_poll(poll);
1449                        }
1450                    }
1451                }
1452
1453                // The final state and the exit point
1454                NLJState::Done => {
1455                    debug!("[NLJState] Entering: {:?}", self.state);
1456
1457                    // stop on drop
1458                    let join_metric = self.metrics.join_metrics.join_time.clone();
1459                    let _join_timer = join_metric.timer();
1460                    // counting it in join timer due to there might be some
1461                    // final resout batches to output in this state
1462
1463                    let poll = self.handle_done();
1464                    return self.metrics.join_metrics.baseline.record_poll(poll);
1465                }
1466            }
1467        }
1468    }
1469}
1470
1471impl RecordBatchStream for NestedLoopJoinStream {
1472    fn schema(&self) -> SchemaRef {
1473        Arc::clone(&self.output_schema)
1474    }
1475}
1476
1477impl NestedLoopJoinStream {
1478    #[expect(clippy::too_many_arguments)]
1479    pub(crate) fn new(
1480        schema: Arc<Schema>,
1481        filter: Option<JoinFilter>,
1482        join_type: JoinType,
1483        right_data: SendableRecordBatchStream,
1484        left_data: OnceFut<JoinLeftData>,
1485        column_indices: Vec<ColumnIndex>,
1486        metrics: NestedLoopJoinMetrics,
1487        batch_size: usize,
1488        spill_state: SpillState,
1489    ) -> Self {
1490        Self {
1491            output_schema: Arc::clone(&schema),
1492            join_filter: filter,
1493            join_type,
1494            right_data: Some(right_data),
1495            column_indices,
1496            left_data,
1497            metrics,
1498            buffered_left_data: None,
1499            output_buffer: Box::new(BatchCoalescer::new(schema, batch_size)),
1500            batch_size,
1501            current_right_batch: None,
1502            current_right_batch_matched: None,
1503            state: NLJState::BufferingLeft,
1504            left_probe_idx: 0,
1505            left_emit_idx: 0,
1506            left_exhausted: false,
1507            left_buffered_in_one_pass: true,
1508            handled_empty_output: false,
1509            should_track_unmatched_right: need_produce_right_in_final(join_type),
1510            spill_state,
1511            is_unmatched_left_emitter: false,
1512        }
1513    }
1514
1515    /// Returns true if this stream is operating in memory-limited mode
1516    fn is_memory_limited(&self) -> bool {
1517        matches!(self.spill_state, SpillState::Active(_))
1518    }
1519
1520    /// Check if we can fall back to memory-limited mode on this error.
1521    fn can_fallback_to_spill(&self, error: &datafusion_common::DataFusionError) -> bool {
1522        matches!(self.spill_state, SpillState::Pending { .. })
1523            && matches!(
1524                error.find_root(),
1525                datafusion_common::DataFusionError::ResourcesExhausted(_)
1526            )
1527    }
1528
1529    /// Switch from the standard OnceFut path to memory-limited mode.
1530    ///
1531    /// Uses the shared `left_spill_data` OnceAsync so that only the first
1532    /// partition to reach this point re-executes the left child and spills
1533    /// it to disk. Other partitions share the same spill file.
1534    fn initiate_fallback(&mut self) -> Result<()> {
1535        // Take ownership of Pending state
1536        let (left_plan, context, left_spill_data) =
1537            match std::mem::replace(&mut self.spill_state, SpillState::Disabled) {
1538                SpillState::Pending {
1539                    left_plan,
1540                    task_context,
1541                    left_spill_data,
1542                } => (left_plan, task_context, left_spill_data),
1543                _ => {
1544                    return internal_err!(
1545                        "initiate_fallback called in non-Pending spill state"
1546                    );
1547                }
1548            };
1549
1550        // Use OnceAsync to ensure only the first partition spills the left
1551        // side. Other partitions will get the same OnceFut that resolves
1552        // to the shared spill file.
1553        let left_spill_fut = left_spill_data.try_once(|| {
1554            let plan = Arc::clone(&left_plan);
1555            let ctx = Arc::clone(&context);
1556            let spill_metrics = self.metrics.spill_metrics.clone();
1557            Ok(async move {
1558                let mut stream = plan.execute(0, Arc::clone(&ctx))?;
1559                let schema = stream.schema();
1560                let left_spill_manager = SpillManager::new(
1561                    ctx.runtime_env(),
1562                    spill_metrics,
1563                    Arc::clone(&schema),
1564                )
1565                .with_compression_type(ctx.session_config().spill_compression());
1566
1567                let result = left_spill_manager
1568                    .spill_record_batch_stream_and_return_max_batch_memory(
1569                        &mut stream,
1570                        "NestedLoopJoin left spill",
1571                    )
1572                    .await?;
1573
1574                match result {
1575                    Some((file, _max_batch_memory)) => Ok(LeftSpillData {
1576                        spill_manager: left_spill_manager,
1577                        spill_file: file,
1578                        schema,
1579                    }),
1580                    None => {
1581                        internal_err!("Left side produced no data to spill")
1582                    }
1583                }
1584            })
1585        })?;
1586
1587        // Create reservation with can_spill for fair memory allocation
1588        let reservation = MemoryConsumer::new("NestedLoopJoinLoad[fallback]".to_string())
1589            .with_can_spill(true)
1590            .register(context.memory_pool());
1591
1592        // Separate reservation for the global right bitmaps. These buffers
1593        // persist across all left chunks, whereas `reservation` is reset
1594        // between chunks via `resize(0)`.
1595        let global_right_bitmaps_reservation =
1596            MemoryConsumer::new("NestedLoopJoinGlobalRightBitmaps".to_string())
1597                .register(context.memory_pool());
1598
1599        // Create SpillManager for right-side spilling
1600        let right_schema = self
1601            .right_data
1602            .as_ref()
1603            .expect("right_data must be present before fallback")
1604            .schema();
1605        let right_data = self
1606            .right_data
1607            .take()
1608            .expect("right_data must be present before fallback");
1609        let right_spill_manager = SpillManager::new(
1610            context.runtime_env(),
1611            self.metrics.spill_metrics.clone(),
1612            right_schema,
1613        )
1614        .with_compression_type(context.session_config().spill_compression());
1615
1616        self.spill_state = SpillState::Active(Box::new(SpillStateActive {
1617            left_spill_fut,
1618            left_stream: None,
1619            left_schema: None,
1620            reservation,
1621            pending_batches: Vec::new(),
1622            right_input: ReplayableStreamSource::new(
1623                right_data,
1624                right_spill_manager,
1625                "NestedLoopJoin right spill",
1626            ),
1627            global_right_bitmaps: Vec::new(),
1628            global_right_bitmaps_reservation,
1629            right_batch_index: 0,
1630        }));
1631
1632        // State stays BufferingLeft — next poll will enter
1633        // handle_buffering_left_memory_limited via is_memory_limited() check
1634        self.state = NLJState::BufferingLeft;
1635
1636        Ok(())
1637    }
1638
1639    // ==== State handler functions ====
1640
1641    /// Handle BufferingLeft state - prepare left side batches.
1642    ///
1643    /// In standard mode, uses OnceFut to load all left data at once.
1644    /// In memory-limited mode, incrementally buffers left batches until the
1645    /// memory budget is reached or the left stream is exhausted.
1646    fn handle_buffering_left(
1647        &mut self,
1648        cx: &mut std::task::Context<'_>,
1649    ) -> ControlFlow<Poll<Option<Result<RecordBatch>>>> {
1650        if self.is_memory_limited() {
1651            self.handle_buffering_left_memory_limited(cx)
1652        } else {
1653            // Standard path: use OnceFut
1654            match self.left_data.get_shared(cx) {
1655                Poll::Ready(Ok(left_data)) => {
1656                    self.buffered_left_data = Some(left_data);
1657                    self.left_exhausted = true;
1658                    self.state = NLJState::FetchingRight;
1659                    ControlFlow::Continue(())
1660                }
1661                Poll::Ready(Err(e)) => {
1662                    if self.can_fallback_to_spill(&e) {
1663                        debug!(
1664                            "NestedLoopJoin: OnceFut failed with OOM, \
1665                             falling back to memory-limited mode"
1666                        );
1667                        match self.initiate_fallback() {
1668                            Ok(()) => ControlFlow::Continue(()),
1669                            Err(fallback_err) => {
1670                                ControlFlow::Break(Poll::Ready(Some(Err(fallback_err))))
1671                            }
1672                        }
1673                    } else {
1674                        ControlFlow::Break(Poll::Ready(Some(Err(e))))
1675                    }
1676                }
1677                Poll::Pending => ControlFlow::Break(Poll::Pending),
1678            }
1679        }
1680    }
1681
1682    /// Memory-limited path for handle_buffering_left.
1683    ///
1684    /// Incrementally polls the left stream and accumulates batches until:
1685    /// - Memory reservation fails (chunk is full, more data remains)
1686    /// - Left stream is exhausted (this is the last/only chunk)
1687    fn handle_buffering_left_memory_limited(
1688        &mut self,
1689        cx: &mut std::task::Context<'_>,
1690    ) -> ControlFlow<Poll<Option<Result<RecordBatch>>>> {
1691        let SpillState::Active(active) = &mut self.spill_state else {
1692            unreachable!(
1693                "handle_buffering_left_memory_limited called without Active spill state"
1694            );
1695        };
1696
1697        // On first entry (or after re-entry for a new chunk pass when
1698        // left_stream was consumed), wait for the shared left spill
1699        // future to resolve and then open a stream from the spill file.
1700        if active.left_stream.is_none() {
1701            match active.left_spill_fut.get_shared(cx) {
1702                Poll::Ready(Ok(spill_data)) => {
1703                    match spill_data
1704                        .spill_manager
1705                        .read_spill_as_stream(Arc::clone(&spill_data.spill_file), None)
1706                    {
1707                        Ok(stream) => {
1708                            active.left_schema = Some(Arc::clone(&spill_data.schema));
1709                            active.left_stream = Some(stream);
1710                        }
1711                        Err(e) => {
1712                            return ControlFlow::Break(Poll::Ready(Some(Err(e))));
1713                        }
1714                    }
1715                }
1716                Poll::Ready(Err(e)) => {
1717                    return ControlFlow::Break(Poll::Ready(Some(Err(e))));
1718                }
1719                Poll::Pending => {
1720                    return ControlFlow::Break(Poll::Pending);
1721                }
1722            }
1723        }
1724
1725        let left_stream = active
1726            .left_stream
1727            .as_mut()
1728            .expect("left_stream must be set after spill future resolves");
1729
1730        // Poll left stream for more batches.
1731        // Note: pending_batches may already contain a batch from the
1732        // previous chunk iteration (the batch that triggered the memory limit).
1733        loop {
1734            match left_stream.poll_next_unpin(cx) {
1735                Poll::Ready(Some(Ok(batch))) => {
1736                    if batch.num_rows() == 0 {
1737                        continue;
1738                    }
1739                    let batch_rows = batch.num_rows();
1740                    let batch_size = batch.get_array_memory_size();
1741                    let can_grow = active.reservation.try_grow(batch_size).is_ok();
1742
1743                    if !can_grow && !active.pending_batches.is_empty() {
1744                        // Memory limit reached and we already have data.
1745                        // Push this batch into pending (it's already in memory)
1746                        // and stop buffering for this chunk.
1747                        active.pending_batches.push(batch);
1748                        self.left_exhausted = false;
1749                        self.left_buffered_in_one_pass = false;
1750                        break;
1751                    } else if !can_grow {
1752                        // No pending batches yet — we must accept this batch
1753                        // to make progress, even if it exceeds the budget.
1754                        active.reservation.grow(batch_size);
1755                    }
1756
1757                    self.metrics.join_metrics.build_mem_used.add(batch_size);
1758                    self.metrics.join_metrics.build_input_batches.add(1);
1759                    self.metrics.join_metrics.build_input_rows.add(batch_rows);
1760                    active.pending_batches.push(batch);
1761                }
1762                Poll::Ready(Some(Err(e))) => {
1763                    return ControlFlow::Break(Poll::Ready(Some(Err(e))));
1764                }
1765                Poll::Ready(None) => {
1766                    // Left stream exhausted
1767                    self.left_exhausted = true;
1768                    break;
1769                }
1770                Poll::Pending => {
1771                    return ControlFlow::Break(Poll::Pending);
1772                }
1773            }
1774        }
1775
1776        // If the left stream is fully exhausted, release its resources so the
1777        // upstream pipeline can be torn down before we move on to probing.
1778        if self.left_exhausted {
1779            active.left_stream = None;
1780        }
1781
1782        if active.pending_batches.is_empty() {
1783            // No data at all — go directly to Done
1784            self.left_exhausted = true;
1785            self.state = NLJState::Done;
1786            return ControlFlow::Continue(());
1787        }
1788
1789        let merged_batch = match concat_batches(
1790            active
1791                .left_schema
1792                .as_ref()
1793                .expect("left_schema must be set"),
1794            &active.pending_batches,
1795        ) {
1796            Ok(batch) => batch,
1797            Err(e) => {
1798                return ControlFlow::Break(Poll::Ready(Some(Err(e.into()))));
1799            }
1800        };
1801        active.pending_batches.clear();
1802
1803        // Build visited bitmap if needed for this join type
1804        let with_visited = need_produce_result_in_final(self.join_type);
1805        let n_rows = merged_batch.num_rows();
1806        let visited_left_side = if with_visited {
1807            let buffer_size = n_rows.div_ceil(8);
1808            // Use infallible grow for bitmap — it's small
1809            active.reservation.grow(buffer_size);
1810            self.metrics.join_metrics.build_mem_used.add(buffer_size);
1811            let mut buffer = BooleanBufferBuilder::new(n_rows);
1812            buffer.append_n(n_rows, false);
1813            buffer
1814        } else {
1815            BooleanBufferBuilder::new(0)
1816        };
1817
1818        // Create an empty reservation for JoinLeftData's RAII field.
1819        // The actual memory tracking is managed by the Active state's reservation.
1820        let dummy_reservation = active.reservation.new_empty();
1821
1822        let left_data = JoinLeftData::new(
1823            merged_batch,
1824            Mutex::new(visited_left_side),
1825            // In memory-limited mode, only 1 probe thread per chunk
1826            AtomicUsize::new(1),
1827            dummy_reservation,
1828        );
1829
1830        self.buffered_left_data = Some(Arc::new(left_data));
1831
1832        active.right_batch_index = 0;
1833        match active.right_input.open_pass() {
1834            Ok(stream) => {
1835                self.right_data = Some(stream);
1836            }
1837            Err(e) => {
1838                return ControlFlow::Break(Poll::Ready(Some(Err(e))));
1839            }
1840        }
1841
1842        self.state = NLJState::FetchingRight;
1843        ControlFlow::Continue(())
1844    }
1845
1846    /// Handle FetchingRight state - fetch next right batch and prepare for processing.
1847    ///
1848    /// In memory-limited mode during the first pass, each right batch is also
1849    /// written to a spill file so it can be re-read on subsequent passes.
1850    fn handle_fetching_right(
1851        &mut self,
1852        cx: &mut std::task::Context<'_>,
1853    ) -> ControlFlow<Poll<Option<Result<RecordBatch>>>> {
1854        match self
1855            .right_data
1856            .as_mut()
1857            .expect("right_data must be present while fetching right")
1858            .poll_next_unpin(cx)
1859        {
1860            Poll::Ready(result) => match result {
1861                Some(Ok(right_batch)) => {
1862                    // Update metrics
1863                    let right_batch_rows = right_batch.num_rows();
1864                    self.metrics.join_metrics.input_rows.add(right_batch_rows);
1865                    self.metrics.join_metrics.input_batches.add(1);
1866
1867                    // Skip the empty batch
1868                    if right_batch_rows == 0 {
1869                        return ControlFlow::Continue(());
1870                    }
1871
1872                    self.current_right_batch = Some(right_batch);
1873
1874                    // Prepare right bitmap
1875                    if self.should_track_unmatched_right {
1876                        let zeroed_buf = BooleanBuffer::new_unset(right_batch_rows);
1877                        self.current_right_batch_matched =
1878                            Some(BooleanArray::new(zeroed_buf, None));
1879                    }
1880
1881                    self.left_probe_idx = 0;
1882                    self.state = NLJState::ProbeRight;
1883                    ControlFlow::Continue(())
1884                }
1885                Some(Err(e)) => ControlFlow::Break(Poll::Ready(Some(Err(e)))),
1886                None => {
1887                    // Right side exhausted: probing for the current left chunk
1888                    // is finished. `ProbeEnd` reports probe completion before
1889                    // emitting unmatched-left rows.
1890                    self.state = NLJState::ProbeEnd;
1891                    ControlFlow::Continue(())
1892                }
1893            },
1894            Poll::Pending => ControlFlow::Break(Poll::Pending),
1895        }
1896    }
1897
1898    /// Handle ProbeRight state - process current probe batch
1899    fn handle_probe_right(&mut self) -> ControlFlow<Poll<Option<Result<RecordBatch>>>> {
1900        // Return any completed batches first
1901        if let Some(poll) = self.maybe_flush_ready_batch() {
1902            return ControlFlow::Break(poll);
1903        }
1904
1905        // Process current probe state
1906        match self.process_probe_batch() {
1907            // State unchanged (ProbeRight)
1908            // Continue probing until we have done joining the
1909            // current right batch with all buffered left rows.
1910            Ok(true) => ControlFlow::Continue(()),
1911            // To next FetchRightState
1912            // We have finished joining
1913            // (cur_right_batch x buffered_left_batches)
1914            Ok(false) => {
1915                // Left exhausted, transition to FetchingRight
1916                self.left_probe_idx = 0;
1917
1918                // Selectivity Metric: Update total possibilities for the batch (left_rows * right_rows)
1919                // If memory-limited execution is implemented, this logic must be updated accordingly.
1920                if let (Ok(left_data), Some(right_batch)) =
1921                    (self.get_left_data(), self.current_right_batch.as_ref())
1922                {
1923                    let left_rows = left_data.batch().num_rows();
1924                    let right_rows = right_batch.num_rows();
1925                    self.metrics.selectivity.add_total(left_rows * right_rows);
1926                }
1927
1928                if self.should_track_unmatched_right {
1929                    debug_assert!(
1930                        self.current_right_batch_matched.is_some(),
1931                        "If it's required to track matched rows in the right input, the right bitmap must be present"
1932                    );
1933                    self.state = NLJState::EmitRightUnmatched;
1934                } else {
1935                    self.current_right_batch = None;
1936                    self.state = NLJState::FetchingRight;
1937                }
1938                ControlFlow::Continue(())
1939            }
1940            Err(e) => ControlFlow::Break(Poll::Ready(Some(Err(e)))),
1941        }
1942    }
1943
1944    /// Handle EmitRightUnmatched state - emit unmatched right rows.
1945    ///
1946    /// In memory-limited mode, instead of emitting unmatched right rows
1947    /// per-batch (which would be incorrect since more left chunks may
1948    /// match those rows), we merge the bitmap into the global accumulator
1949    /// and defer emission to `EmitGlobalRightUnmatched`.
1950    fn handle_emit_right_unmatched(
1951        &mut self,
1952    ) -> ControlFlow<Poll<Option<Result<RecordBatch>>>> {
1953        // In memory-limited mode, merge bitmap into global and move on
1954        if self.is_memory_limited() {
1955            debug_assert!(
1956                self.current_right_batch_matched.is_some(),
1957                "right bitmap must be present"
1958            );
1959            let bitmap = std::mem::take(&mut self.current_right_batch_matched)
1960                .expect("right bitmap should be available");
1961            let (values, _nulls) = bitmap.into_parts();
1962
1963            if let SpillState::Active(ref mut active) = self.spill_state {
1964                let idx = active.right_batch_index;
1965                active.merge_current_right_bitmap(idx, values);
1966                active.right_batch_index += 1;
1967            }
1968
1969            self.current_right_batch = None;
1970            self.state = NLJState::FetchingRight;
1971            return ControlFlow::Continue(());
1972        }
1973
1974        // Standard (single-pass) mode: emit unmatched right rows immediately
1975        // Return any completed batches first
1976        if let Some(poll) = self.maybe_flush_ready_batch() {
1977            return ControlFlow::Break(poll);
1978        }
1979
1980        debug_assert!(
1981            self.current_right_batch_matched.is_some()
1982                && self.current_right_batch.is_some(),
1983            "This state is yielding output for unmatched rows in the current right batch, so both the right batch and the bitmap must be present"
1984        );
1985        match self.process_right_unmatched() {
1986            Ok(Some(batch)) => match self.output_buffer.push_batch(batch) {
1987                Ok(()) => {
1988                    debug_assert!(self.current_right_batch.is_none());
1989                    self.state = NLJState::FetchingRight;
1990                    ControlFlow::Continue(())
1991                }
1992                Err(e) => ControlFlow::Break(Poll::Ready(Some(arrow_err!(e)))),
1993            },
1994            Ok(None) => {
1995                debug_assert!(self.current_right_batch.is_none());
1996                self.state = NLJState::FetchingRight;
1997                ControlFlow::Continue(())
1998            }
1999            Err(e) => ControlFlow::Break(Poll::Ready(Some(Err(e)))),
2000        }
2001    }
2002
2003    /// Handle ProbeEnd state - record probe completion for the current chunk.
2004    ///
2005    /// Entered exactly once per left chunk, when the right side is exhausted.
2006    /// This is the single place that decrements the shared probe-threads counter
2007    /// via [`JoinLeftData::report_probe_completed`]: the stream that drives the
2008    /// counter to zero (the last to finish probing) is the one responsible for
2009    /// emitting unmatched-left rows, recorded in `is_unmatched_left_emitter`.
2010    ///
2011    /// Owning the decrement here — rather than in the re-enterable
2012    /// `EmitLeftUnmatched` state — makes "decrement exactly once per stream" a
2013    /// structural property of the state graph, so the counter cannot reach zero
2014    /// before all partitions finish probing (which would let a partition emit
2015    /// spurious NULL-padded unmatched-left rows early).
2016    ///
2017    /// Always transitions to `EmitLeftUnmatched`.
2018    fn handle_probe_end(&mut self) -> ControlFlow<Poll<Option<Result<RecordBatch>>>> {
2019        // Decrement the shared counter exactly once for this stream/chunk. The
2020        // last stream to finish probing (the one that drives the counter to
2021        // zero) becomes the unmatched-left emitter.
2022        let is_emitter = match self.get_left_data() {
2023            Ok(left_data) => left_data.report_probe_completed(),
2024            Err(e) => return ControlFlow::Break(Poll::Ready(Some(Err(e)))),
2025        };
2026        self.is_unmatched_left_emitter = is_emitter;
2027        self.state = NLJState::EmitLeftUnmatched;
2028        ControlFlow::Continue(())
2029    }
2030
2031    /// Handle EmitLeftUnmatched state - emit unmatched left rows.
2032    ///
2033    /// In memory-limited mode, after processing all unmatched rows for the
2034    /// current left chunk, transitions back to `BufferingLeft` to load the
2035    /// next chunk (if the left stream is not yet exhausted).
2036    fn handle_emit_left_unmatched(
2037        &mut self,
2038    ) -> ControlFlow<Poll<Option<Result<RecordBatch>>>> {
2039        // Return any completed batches first
2040        if let Some(poll) = self.maybe_flush_ready_batch() {
2041            return ControlFlow::Break(poll);
2042        }
2043
2044        // Process current unmatched state
2045        match self.process_left_unmatched() {
2046            // State unchanged (EmitLeftUnmatched)
2047            // Continue processing until we have processed all unmatched rows
2048            Ok(true) => ControlFlow::Continue(()),
2049            // We have finished processing all unmatched rows for this chunk
2050            Ok(false) => match self.output_buffer.finish_buffered_batch() {
2051                Ok(()) => {
2052                    // Flush any completed batch before transitioning.
2053                    // This is critical for the memory-limited path: the
2054                    // ProbeRight results must be emitted before we discard
2055                    // the current chunk and load the next one.
2056                    if let Some(poll) = self.maybe_flush_ready_batch() {
2057                        return ControlFlow::Break(poll);
2058                    }
2059
2060                    if !self.left_exhausted && self.is_memory_limited() {
2061                        // More left data to process — free current chunk and
2062                        // go back to BufferingLeft for the next chunk
2063                        if let SpillState::Active(ref active) = self.spill_state {
2064                            active.reservation.resize(0);
2065                        }
2066                        self.buffered_left_data = None;
2067                        self.left_probe_idx = 0;
2068                        self.left_emit_idx = 0;
2069                        // Each memory-limited chunk gets a fresh per-chunk
2070                        // `JoinLeftData`/counter; `is_unmatched_left_emitter` is
2071                        // recomputed when `ProbeEnd` is re-entered for the next
2072                        // chunk, so it does not need to be reset here.
2073                        self.state = NLJState::BufferingLeft;
2074                    } else if self.is_memory_limited()
2075                        && self.should_track_unmatched_right
2076                    {
2077                        // All left chunks done — emit global right unmatched.
2078                        // Drop the exhausted right stream so that
2079                        // EmitGlobalRightUnmatched opens a fresh replay pass
2080                        // from the spill file. (process_left_unmatched_range
2081                        // already ran with right_data still set, so its
2082                        // schema access is not affected.)
2083                        self.right_data = None;
2084                        self.state = NLJState::EmitGlobalRightUnmatched;
2085                    } else {
2086                        self.state = NLJState::Done;
2087                    }
2088                    ControlFlow::Continue(())
2089                }
2090                Err(e) => ControlFlow::Break(Poll::Ready(Some(arrow_err!(e)))),
2091            },
2092            Err(e) => ControlFlow::Break(Poll::Ready(Some(Err(e)))),
2093        }
2094    }
2095
2096    /// Handle EmitGlobalRightUnmatched state.
2097    ///
2098    /// Replays all right batches from the spill file and emits unmatched
2099    /// right rows using the global bitmap accumulated across all left chunks.
2100    fn handle_emit_global_right_unmatched(
2101        &mut self,
2102        cx: &mut std::task::Context<'_>,
2103    ) -> ControlFlow<Poll<Option<Result<RecordBatch>>>> {
2104        // Flush any completed batches first
2105        if let Some(poll) = self.maybe_flush_ready_batch() {
2106            return ControlFlow::Break(poll);
2107        }
2108
2109        // On first entry, open a new replay pass on the right input
2110        if self.right_data.is_none() {
2111            let SpillState::Active(ref mut active) = self.spill_state else {
2112                unreachable!("EmitGlobalRightUnmatched without Active spill state");
2113            };
2114            active.right_batch_index = 0;
2115            match active.right_input.open_pass() {
2116                Ok(stream) => {
2117                    self.right_data = Some(stream);
2118                }
2119                Err(e) => {
2120                    return ControlFlow::Break(Poll::Ready(Some(Err(e))));
2121                }
2122            }
2123        }
2124
2125        // Poll the replay stream for the next right batch
2126        match self
2127            .right_data
2128            .as_mut()
2129            .expect("right_data must be present")
2130            .poll_next_unpin(cx)
2131        {
2132            Poll::Ready(Some(Ok(right_batch))) => {
2133                if right_batch.num_rows() == 0 {
2134                    return ControlFlow::Continue(());
2135                }
2136
2137                let SpillState::Active(ref mut active) = self.spill_state else {
2138                    unreachable!();
2139                };
2140                let idx = active.right_batch_index;
2141                active.right_batch_index += 1;
2142
2143                // Build BooleanArray from the global bitmap
2144                let bitmap = if idx < active.global_right_bitmaps.len() {
2145                    BooleanArray::new(active.global_right_bitmaps[idx].clone(), None)
2146                } else {
2147                    // Batch never seen — treat all rows as unmatched
2148                    BooleanArray::new(
2149                        BooleanBuffer::new_unset(right_batch.num_rows()),
2150                        None,
2151                    )
2152                };
2153
2154                let left_schema = Arc::clone(
2155                    active
2156                        .left_schema
2157                        .as_ref()
2158                        .expect("left_schema must be set"),
2159                );
2160
2161                match build_unmatched_batch(
2162                    &self.output_schema,
2163                    &right_batch,
2164                    bitmap,
2165                    &left_schema,
2166                    &self.column_indices,
2167                    self.join_type,
2168                    JoinSide::Right,
2169                ) {
2170                    Ok(Some(batch)) => match self.output_buffer.push_batch(batch) {
2171                        Ok(()) => ControlFlow::Continue(()),
2172                        Err(e) => ControlFlow::Break(Poll::Ready(Some(arrow_err!(e)))),
2173                    },
2174                    Ok(None) => ControlFlow::Continue(()),
2175                    Err(e) => ControlFlow::Break(Poll::Ready(Some(Err(e)))),
2176                }
2177            }
2178            Poll::Ready(Some(Err(e))) => ControlFlow::Break(Poll::Ready(Some(Err(e)))),
2179            Poll::Ready(None) => {
2180                // All right batches replayed
2181                match self.output_buffer.finish_buffered_batch() {
2182                    Ok(()) => {
2183                        self.state = NLJState::Done;
2184                        ControlFlow::Continue(())
2185                    }
2186                    Err(e) => ControlFlow::Break(Poll::Ready(Some(arrow_err!(e)))),
2187                }
2188            }
2189            Poll::Pending => ControlFlow::Break(Poll::Pending),
2190        }
2191    }
2192
2193    /// Handle Done state - final state processing
2194    fn handle_done(&mut self) -> Poll<Option<Result<RecordBatch>>> {
2195        // Return any remaining completed batches before final termination
2196        if let Some(poll) = self.maybe_flush_ready_batch() {
2197            return poll;
2198        }
2199
2200        // HACK for the doc test in https://github.com/apache/datafusion/blob/main/datafusion/core/src/dataframe/mod.rs#L1265
2201        // If this operator directly return `Poll::Ready(None)`
2202        // for empty result, the final result will become an empty
2203        // batch with empty schema, however the expected result
2204        // should be with the expected schema for this operator
2205        if !self.handled_empty_output {
2206            let zero_count = Count::new();
2207            if *self.metrics.join_metrics.baseline.output_rows() == zero_count {
2208                let empty_batch = RecordBatch::new_empty(Arc::clone(&self.output_schema));
2209                self.handled_empty_output = true;
2210                return Poll::Ready(Some(Ok(empty_batch)));
2211            }
2212        }
2213
2214        Poll::Ready(None)
2215    }
2216
2217    // ==== Core logic handling for each state ====
2218
2219    /// Returns bool to indicate should it continue probing
2220    /// true -> continue in the same ProbeRight state
2221    /// false -> It has done with the (buffered_left x cur_right_batch), go to
2222    /// next state (ProbeRight)
2223    fn process_probe_batch(&mut self) -> Result<bool> {
2224        let left_data = Arc::clone(self.get_left_data()?);
2225        let right_batch = self
2226            .current_right_batch
2227            .as_ref()
2228            .ok_or_else(|| internal_datafusion_err!("Right batch should be available"))?
2229            .clone();
2230
2231        // stop probing, the caller will go to the next state
2232        if self.left_probe_idx >= left_data.batch().num_rows() {
2233            return Ok(false);
2234        }
2235
2236        // ========
2237        // Join (l_row x right_batch)
2238        // and push the result into output_buffer
2239        // ========
2240
2241        // Special case:
2242        // When the right batch is very small, join with multiple left rows at once,
2243        //
2244        // The regular implementation is not efficient if the plan's right child is
2245        // very small (e.g. 1 row total), because inside the inner loop of NLJ, it's
2246        // handling one input right batch at once, if it's not large enough, the
2247        // overheads like filter evaluation can't be amortized through vectorization.
2248        debug_assert_ne!(
2249            right_batch.num_rows(),
2250            0,
2251            "When fetching the right batch, empty batches will be skipped"
2252        );
2253
2254        let l_row_cnt_ratio = self.batch_size / right_batch.num_rows();
2255        if l_row_cnt_ratio > 10 {
2256            // Calculate max left rows to handle at once. This operator tries to handle
2257            // up to `datafusion.execution.batch_size` rows at once in the intermediate
2258            // batch.
2259            let l_row_count = std::cmp::min(
2260                l_row_cnt_ratio,
2261                left_data.batch().num_rows() - self.left_probe_idx,
2262            );
2263
2264            debug_assert!(
2265                l_row_count != 0,
2266                "This function should only be entered when there are remaining left rows to process"
2267            );
2268            let joined_batch = self.process_left_range_join(
2269                &left_data,
2270                &right_batch,
2271                self.left_probe_idx,
2272                l_row_count,
2273            )?;
2274
2275            if let Some(batch) = joined_batch {
2276                self.output_buffer.push_batch(batch)?;
2277            }
2278
2279            self.left_probe_idx += l_row_count;
2280
2281            return Ok(true);
2282        }
2283
2284        let l_idx = self.left_probe_idx;
2285        let joined_batch =
2286            self.process_single_left_row_join(&left_data, &right_batch, l_idx)?;
2287
2288        if let Some(batch) = joined_batch {
2289            self.output_buffer.push_batch(batch)?;
2290        }
2291
2292        // ==== Prepare for the next iteration ====
2293
2294        // Advance left cursor
2295        self.left_probe_idx += 1;
2296
2297        // Return true to continue probing
2298        Ok(true)
2299    }
2300
2301    /// Process [l_start_index, l_start_index + l_count) JOIN right_batch
2302    /// Returns a RecordBatch containing the join results (None if empty)
2303    ///
2304    /// Side Effect: If the join type requires, left or right side matched bitmap
2305    /// will be set for matched indices.
2306    fn process_left_range_join(
2307        &mut self,
2308        left_data: &JoinLeftData,
2309        right_batch: &RecordBatch,
2310        l_start_index: usize,
2311        l_row_count: usize,
2312    ) -> Result<Option<RecordBatch>> {
2313        // Construct the Cartesian product between the specified range of left rows
2314        // and the entire right_batch. First, it calculates the index vectors, then
2315        // materializes the intermediate batch, and finally applies the join filter
2316        // to it.
2317        // -----------------------------------------------------------
2318        let right_rows = right_batch.num_rows();
2319        let total_rows = l_row_count * right_rows;
2320
2321        // Build index arrays for cartesian product: left_range X right_batch
2322        let left_indices: UInt32Array =
2323            UInt32Array::from_iter_values((0..l_row_count).flat_map(|i| {
2324                std::iter::repeat_n((l_start_index + i) as u32, right_rows)
2325            }));
2326        let right_indices: UInt32Array = UInt32Array::from_iter_values(
2327            (0..l_row_count).flat_map(|_| 0..right_rows as u32),
2328        );
2329
2330        debug_assert!(
2331            left_indices.len() == right_indices.len()
2332                && right_indices.len() == total_rows,
2333            "The length or cartesian product should be (left_size * right_size)",
2334        );
2335
2336        // Evaluate the join filter (if any) over an intermediate batch built
2337        // using the filter's own schema/column indices.
2338        let bitmap_combined = if let Some(filter) = &self.join_filter {
2339            // Build the intermediate batch for filter evaluation
2340            let intermediate_batch = if filter.schema.fields().is_empty() {
2341                // Constant predicate (e.g., TRUE/FALSE). Use an empty schema with row_count
2342                create_record_batch_with_empty_schema(
2343                    Arc::new((*filter.schema).clone()),
2344                    total_rows,
2345                )?
2346            } else {
2347                let mut filter_columns: Vec<Arc<dyn Array>> =
2348                    Vec::with_capacity(filter.column_indices().len());
2349                for column_index in filter.column_indices() {
2350                    let array = if column_index.side == JoinSide::Left {
2351                        let col = left_data.batch().column(column_index.index);
2352                        take(col.as_ref(), &left_indices, None)?
2353                    } else {
2354                        let col = right_batch.column(column_index.index);
2355                        take(col.as_ref(), &right_indices, None)?
2356                    };
2357                    filter_columns.push(array);
2358                }
2359
2360                RecordBatch::try_new(Arc::new((*filter.schema).clone()), filter_columns)?
2361            };
2362
2363            let filter_result = filter
2364                .expression()
2365                .evaluate(&intermediate_batch)?
2366                .into_array(intermediate_batch.num_rows())?;
2367            let filter_arr = as_boolean_array(&filter_result)?;
2368
2369            // Combine with null bitmap to get a unified mask
2370            boolean_mask_from_filter(filter_arr)
2371        } else {
2372            // No filter: all pairs match
2373            BooleanArray::from(vec![true; total_rows])
2374        };
2375
2376        // Update the global left or right bitmap for matched indices
2377        // -----------------------------------------------------------
2378
2379        // None means we don't have to update left bitmap for this join type
2380        let mut left_bitmap = if need_produce_result_in_final(self.join_type) {
2381            Some(left_data.bitmap().lock())
2382        } else {
2383            None
2384        };
2385
2386        // 'local' meaning: we want to collect 'is_matched' flag for the current
2387        // right batch, after it has joining all of the left buffer, here it's only
2388        // the partial result for joining given left range
2389        let mut local_right_bitmap = if self.should_track_unmatched_right {
2390            let mut current_right_batch_bitmap = BooleanBufferBuilder::new(right_rows);
2391            // Ensure builder has logical length so set_bit is in-bounds
2392            current_right_batch_bitmap.append_n(right_rows, false);
2393            Some(current_right_batch_bitmap)
2394        } else {
2395            None
2396        };
2397
2398        // Set the matched bit for left and right side bitmap
2399        for (i, is_matched) in bitmap_combined.iter().enumerate() {
2400            let is_matched = is_matched.ok_or_else(|| {
2401                internal_datafusion_err!("Must be Some after the previous combining step")
2402            })?;
2403
2404            let l_index = l_start_index + i / right_rows;
2405            let r_index = i % right_rows;
2406
2407            if let Some(bitmap) = left_bitmap.as_mut()
2408                && is_matched
2409            {
2410                // Map local index back to absolute left index within the batch
2411                bitmap.set_bit(l_index, true);
2412            }
2413
2414            if let Some(bitmap) = local_right_bitmap.as_mut()
2415                && is_matched
2416            {
2417                bitmap.set_bit(r_index, true);
2418            }
2419        }
2420
2421        // Apply the local right bitmap to the global bitmap
2422        if self.should_track_unmatched_right {
2423            // Remember to put it back after update
2424            let global_right_bitmap =
2425                std::mem::take(&mut self.current_right_batch_matched).ok_or_else(
2426                    || internal_datafusion_err!("right batch's bitmap should be present"),
2427                )?;
2428            let (buf, nulls) = global_right_bitmap.into_parts();
2429            debug_assert!(nulls.is_none());
2430
2431            let current_right_bitmap = local_right_bitmap
2432                .ok_or_else(|| {
2433                    internal_datafusion_err!(
2434                        "Should be Some if the current join type requires right bitmap"
2435                    )
2436                })?
2437                .finish();
2438            let updated_global_right_bitmap = buf.bitor(&current_right_bitmap);
2439
2440            self.current_right_batch_matched =
2441                Some(BooleanArray::new(updated_global_right_bitmap, None));
2442        }
2443
2444        // For the following join types: only bitmaps are updated; do not emit rows now
2445        if matches!(
2446            self.join_type,
2447            JoinType::LeftAnti
2448                | JoinType::LeftSemi
2449                | JoinType::LeftMark
2450                | JoinType::RightAnti
2451                | JoinType::RightMark
2452                | JoinType::RightSemi
2453        ) {
2454            return Ok(None);
2455        }
2456
2457        // Build the projected output batch (using output schema/column_indices),
2458        // then apply the bitmap filter to it.
2459        if self.output_schema.fields().is_empty() {
2460            // Empty projection: only row count matters
2461            let row_count = bitmap_combined.true_count();
2462            return Ok(Some(create_record_batch_with_empty_schema(
2463                Arc::clone(&self.output_schema),
2464                row_count,
2465            )?));
2466        }
2467
2468        let mut out_columns: Vec<Arc<dyn Array>> =
2469            Vec::with_capacity(self.output_schema.fields().len());
2470        for column_index in &self.column_indices {
2471            let array = if column_index.side == JoinSide::Left {
2472                let col = left_data.batch().column(column_index.index);
2473                take(col.as_ref(), &left_indices, None)?
2474            } else {
2475                let col = right_batch.column(column_index.index);
2476                take(col.as_ref(), &right_indices, None)?
2477            };
2478            out_columns.push(array);
2479        }
2480        let pre_filtered =
2481            RecordBatch::try_new(Arc::clone(&self.output_schema), out_columns)?;
2482        let filtered = filter_record_batch(&pre_filtered, &bitmap_combined)?;
2483        Ok(Some(filtered))
2484    }
2485
2486    /// Process a single left row join with the current right batch.
2487    /// Returns a RecordBatch containing the join results (None if empty)
2488    ///
2489    /// Side Effect: If the join type requires, left or right side matched bitmap
2490    /// will be set for matched indices.
2491    fn process_single_left_row_join(
2492        &mut self,
2493        left_data: &JoinLeftData,
2494        right_batch: &RecordBatch,
2495        l_index: usize,
2496    ) -> Result<Option<RecordBatch>> {
2497        let right_row_count = right_batch.num_rows();
2498        if right_row_count == 0 {
2499            return Ok(None);
2500        }
2501
2502        let cur_right_bitmap = if let Some(filter) = &self.join_filter {
2503            apply_filter_to_row_join_batch(
2504                left_data.batch(),
2505                l_index,
2506                right_batch,
2507                filter,
2508            )?
2509        } else {
2510            BooleanArray::from(vec![true; right_row_count])
2511        };
2512
2513        self.update_matched_bitmap(l_index, &cur_right_bitmap)?;
2514
2515        // For the following join types: here we only have to set the left/right
2516        // bitmap, and no need to output result
2517        if matches!(
2518            self.join_type,
2519            JoinType::LeftAnti
2520                | JoinType::LeftSemi
2521                | JoinType::LeftMark
2522                | JoinType::RightAnti
2523                | JoinType::RightMark
2524                | JoinType::RightSemi
2525        ) {
2526            return Ok(None);
2527        }
2528
2529        if !cur_right_bitmap.has_true() {
2530            // If none of the pairs has passed the join predicate/filter
2531            Ok(None)
2532        } else {
2533            // Use the optimized approach similar to build_intermediate_batch_for_single_left_row
2534            let join_batch = build_row_join_batch(
2535                &self.output_schema,
2536                left_data.batch(),
2537                l_index,
2538                right_batch,
2539                Some(cur_right_bitmap),
2540                &self.column_indices,
2541                JoinSide::Left,
2542            )?;
2543            Ok(join_batch)
2544        }
2545    }
2546
2547    /// Returns bool to indicate should it continue processing unmatched rows
2548    /// true -> continue in the same EmitLeftUnmatched state
2549    /// false -> next state (Done)
2550    fn process_left_unmatched(&mut self) -> Result<bool> {
2551        let left_data = self.get_left_data()?;
2552        let left_batch = left_data.batch();
2553
2554        // ========
2555        // Check early return conditions
2556        // ========
2557
2558        // Early return if join type can't have unmatched rows
2559        let join_type_no_produce_left = !need_produce_result_in_final(self.join_type);
2560        // Stop processing unmatched rows, the caller will go to the next state
2561        let finished = self.left_emit_idx >= left_batch.num_rows();
2562
2563        // `ProbeEnd` already recorded whether this stream emits unmatched-left
2564        // rows. Every probe partition passes through this state, but only the
2565        // one that finished probing last is the emitter, so this flag is false
2566        // for the others.
2567        if join_type_no_produce_left || !self.is_unmatched_left_emitter || finished {
2568            return Ok(false);
2569        }
2570
2571        // ========
2572        // Process unmatched rows and push the result into output_buffer
2573        // Each time, the number to process is up to batch size
2574        // ========
2575        let start_idx = self.left_emit_idx;
2576        let end_idx = std::cmp::min(start_idx + self.batch_size, left_batch.num_rows());
2577
2578        if let Some(batch) =
2579            self.process_left_unmatched_range(left_data, start_idx, end_idx)?
2580        {
2581            self.output_buffer.push_batch(batch)?;
2582        }
2583
2584        // ==== Prepare for the next iteration ====
2585        self.left_emit_idx = end_idx;
2586
2587        // Return true to continue processing unmatched rows
2588        Ok(true)
2589    }
2590
2591    /// Process unmatched rows from the left data within the specified range.
2592    /// Returns a RecordBatch containing the unmatched rows (None if empty).
2593    ///
2594    /// # Arguments
2595    /// * `left_data` - The left side data containing the batch and bitmap
2596    /// * `start_idx` - Start index (inclusive) of the range to process
2597    /// * `end_idx` - End index (exclusive) of the range to process
2598    ///
2599    /// # Safety
2600    /// The caller is responsible for ensuring that `start_idx` and `end_idx` are
2601    /// within valid bounds of the left batch. This function does not perform
2602    /// bounds checking.
2603    fn process_left_unmatched_range(
2604        &self,
2605        left_data: &JoinLeftData,
2606        start_idx: usize,
2607        end_idx: usize,
2608    ) -> Result<Option<RecordBatch>> {
2609        if start_idx == end_idx {
2610            return Ok(None);
2611        }
2612
2613        // Slice both left batch, and bitmap to range [start_idx, end_idx)
2614        // The range is bit index (not byte)
2615        let left_batch = left_data.batch();
2616        let left_batch_sliced = left_batch.slice(start_idx, end_idx - start_idx);
2617
2618        // Can this be more efficient?
2619        let mut bitmap_sliced = BooleanBufferBuilder::new(end_idx - start_idx);
2620        bitmap_sliced.append_n(end_idx - start_idx, false);
2621        let bitmap = left_data.bitmap().lock();
2622        for i in start_idx..end_idx {
2623            assert!(
2624                i - start_idx < bitmap_sliced.capacity(),
2625                "DBG: {start_idx}, {end_idx}"
2626            );
2627            bitmap_sliced.set_bit(i - start_idx, bitmap.get_bit(i));
2628        }
2629        let bitmap_sliced = BooleanArray::new(bitmap_sliced.finish(), None);
2630
2631        let right_schema = self
2632            .right_data
2633            .as_ref()
2634            .expect("right_data must be present when building unmatched batch")
2635            .schema();
2636        build_unmatched_batch(
2637            &self.output_schema,
2638            &left_batch_sliced,
2639            bitmap_sliced,
2640            &right_schema,
2641            &self.column_indices,
2642            self.join_type,
2643            JoinSide::Left,
2644        )
2645    }
2646
2647    /// Process unmatched rows from the current right batch and reset the bitmap.
2648    /// Returns a RecordBatch containing the unmatched right rows (None if empty).
2649    fn process_right_unmatched(&mut self) -> Result<Option<RecordBatch>> {
2650        // ==== Take current right batch and its bitmap ====
2651        let right_batch_bitmap: BooleanArray =
2652            std::mem::take(&mut self.current_right_batch_matched).ok_or_else(|| {
2653                internal_datafusion_err!("right bitmap should be available")
2654            })?;
2655
2656        let right_batch = self.current_right_batch.take();
2657        let cur_right_batch = unwrap_or_internal_err!(right_batch);
2658
2659        let left_data = self.get_left_data()?;
2660        let left_schema = left_data.batch().schema();
2661
2662        let res = build_unmatched_batch(
2663            &self.output_schema,
2664            &cur_right_batch,
2665            right_batch_bitmap,
2666            &left_schema,
2667            &self.column_indices,
2668            self.join_type,
2669            JoinSide::Right,
2670        );
2671
2672        // ==== Clean-up ====
2673        self.current_right_batch_matched = None;
2674
2675        res
2676    }
2677
2678    // ==== Utilities ====
2679
2680    /// Get the build-side data of the left input, errors if it's None
2681    fn get_left_data(&self) -> Result<&Arc<JoinLeftData>> {
2682        self.buffered_left_data
2683            .as_ref()
2684            .ok_or_else(|| internal_datafusion_err!("LeftData should be available"))
2685    }
2686
2687    /// Flush the `output_buffer` if there are batches ready to output
2688    /// None if no result batch ready.
2689    fn maybe_flush_ready_batch(&mut self) -> Option<Poll<Option<Result<RecordBatch>>>> {
2690        if self.output_buffer.has_completed_batch()
2691            && let Some(batch) = self.output_buffer.next_completed_batch()
2692        {
2693            // Update output rows for selectivity metric
2694            let output_rows = batch.num_rows();
2695            self.metrics.selectivity.add_part(output_rows);
2696
2697            return Some(Poll::Ready(Some(Ok(batch))));
2698        }
2699
2700        None
2701    }
2702
2703    /// After joining (l_index@left_buffer x current_right_batch), it will result
2704    /// in a bitmap (the same length as current_right_batch) as the join match
2705    /// result. Use this bitmap to update the global bitmap, for special join
2706    /// types like full joins.
2707    ///
2708    /// Example:
2709    /// After joining l_index=1 (1-indexed row in the left buffer), and the
2710    /// current right batch with 3 elements, this function will be called with
2711    /// arguments: l_index = 1, r_matched = [false, false, true]
2712    /// - If the join type is FullJoin, the 1-index in the left bitmap will be
2713    ///   set to true, and also the right bitmap will be bitwise-ORed with the
2714    ///   input r_matched bitmap.
2715    /// - For join types that don't require output unmatched rows, this
2716    ///   function can be a no-op. For inner joins, this function is a no-op; for left
2717    ///   joins, only the left bitmap may be updated.
2718    fn update_matched_bitmap(
2719        &mut self,
2720        l_index: usize,
2721        r_matched_bitmap: &BooleanArray,
2722    ) -> Result<()> {
2723        let left_data = self.get_left_data()?;
2724
2725        // 1. Maybe update the left bitmap
2726        if need_produce_result_in_final(self.join_type) && r_matched_bitmap.has_true() {
2727            let mut bitmap = left_data.bitmap().lock();
2728            bitmap.set_bit(l_index, true);
2729        }
2730
2731        // 2. Maybe update the right bitmap
2732        if self.should_track_unmatched_right {
2733            debug_assert!(self.current_right_batch_matched.is_some());
2734            // after bit-wise or, it will be put back
2735            let right_bitmap = std::mem::take(&mut self.current_right_batch_matched)
2736                .ok_or_else(|| {
2737                    internal_datafusion_err!("right batch's bitmap should be present")
2738                })?;
2739            let (buf, nulls) = right_bitmap.into_parts();
2740            debug_assert!(nulls.is_none());
2741            let updated_right_bitmap = buf.bitor(r_matched_bitmap.values());
2742
2743            self.current_right_batch_matched =
2744                Some(BooleanArray::new(updated_right_bitmap, None));
2745        }
2746
2747        Ok(())
2748    }
2749}
2750
2751// ==== Utilities ====
2752
2753/// Apply the join filter between:
2754/// (l_index th row in left buffer) x (right batch)
2755/// Returns a bitmap, with successfully joined indices set to true
2756fn apply_filter_to_row_join_batch(
2757    left_batch: &RecordBatch,
2758    l_index: usize,
2759    right_batch: &RecordBatch,
2760    filter: &JoinFilter,
2761) -> Result<BooleanArray> {
2762    debug_assert!(left_batch.num_rows() != 0 && right_batch.num_rows() != 0);
2763
2764    let intermediate_batch = if filter.schema.fields().is_empty() {
2765        // If filter is constant (e.g. literal `true`), empty batch can be used
2766        // in the later filter step.
2767        create_record_batch_with_empty_schema(
2768            Arc::new((*filter.schema).clone()),
2769            right_batch.num_rows(),
2770        )?
2771    } else {
2772        build_row_join_batch(
2773            &filter.schema,
2774            left_batch,
2775            l_index,
2776            right_batch,
2777            None,
2778            &filter.column_indices,
2779            JoinSide::Left,
2780        )?
2781        .ok_or_else(|| internal_datafusion_err!("This function assume input batch is not empty, so the intermediate batch can't be empty too"))?
2782    };
2783
2784    let filter_result = filter
2785        .expression()
2786        .evaluate(&intermediate_batch)?
2787        .into_array(intermediate_batch.num_rows())?;
2788    let filter_arr = as_boolean_array(&filter_result)?;
2789
2790    // Convert boolean array with potential nulls into a unified mask bitmap
2791    let bitmap_combined = boolean_mask_from_filter(filter_arr);
2792
2793    Ok(bitmap_combined)
2794}
2795
2796/// Convert a boolean filter array into a unified mask bitmap.
2797///
2798/// Caution: The filter result is NOT a bitmap; it contains true/false/null values.
2799/// For example, `1 < NULL` evaluates to NULL. Therefore, we must combine (AND)
2800/// the boolean array with its null bitmap to construct a unified bitmap.
2801#[inline]
2802fn boolean_mask_from_filter(filter_arr: &BooleanArray) -> BooleanArray {
2803    let (values, nulls) = filter_arr.clone().into_parts();
2804    match nulls {
2805        Some(nulls) => BooleanArray::new(nulls.inner() & &values, None),
2806        None => BooleanArray::new(values, None),
2807    }
2808}
2809
2810/// This function performs the following steps:
2811/// 1. Apply filter to probe-side batch
2812/// 2. Broadcast the left row (build_side_batch\[build_side_index\]) to the
2813///    filtered probe-side batch
2814/// 3. Concat them together according to `col_indices`, and return the result
2815///    (None if the result is empty)
2816///
2817/// Example:
2818/// build_side_batch:
2819/// a
2820/// ----
2821/// 1
2822/// 2
2823/// 3
2824///
2825/// # 0 index element in the build_side_batch (that is `1`) will be used
2826/// build_side_index: 0
2827///
2828/// probe_side_batch:
2829/// b
2830/// ----
2831/// 10
2832/// 20
2833/// 30
2834/// 40
2835///
2836/// # After applying it, only index 1 and 3 elements in probe_side_batch will be
2837/// # kept
2838/// probe_side_filter:
2839/// false
2840/// true
2841/// false
2842/// true
2843///
2844///
2845/// # Projections to the build/probe side batch, to construct the output batch
2846/// col_indices:
2847/// [(left, 0), (right, 0)]
2848///
2849/// build_side: left
2850///
2851/// ====
2852/// Result batch:
2853/// a b
2854/// ----
2855/// 1 20
2856/// 1 40
2857fn build_row_join_batch(
2858    output_schema: &Schema,
2859    build_side_batch: &RecordBatch,
2860    build_side_index: usize,
2861    probe_side_batch: &RecordBatch,
2862    probe_side_filter: Option<BooleanArray>,
2863    // See [`NLJStream`] struct's `column_indices` field for more detail
2864    col_indices: &[ColumnIndex],
2865    // If the build side is left or right, used to interpret the side information
2866    // in `col_indices`
2867    build_side: JoinSide,
2868) -> Result<Option<RecordBatch>> {
2869    debug_assert!(build_side != JoinSide::None);
2870
2871    // TODO(perf): since the output might be projection of right batch, this
2872    // filtering step is more efficient to be done inside the column_index loop
2873    let filtered_probe_batch = if let Some(filter) = probe_side_filter {
2874        &filter_record_batch(probe_side_batch, &filter)?
2875    } else {
2876        probe_side_batch
2877    };
2878
2879    if filtered_probe_batch.num_rows() == 0 {
2880        return Ok(None);
2881    }
2882
2883    // Edge case: downstream operator does not require any columns from this NLJ,
2884    // so allow an empty projection.
2885    // Example:
2886    //  SELECT DISTINCT 32 AS col2
2887    //  FROM tab0 AS cor0
2888    //  LEFT OUTER JOIN tab2 AS cor1
2889    //  ON ( NULL ) IS NULL;
2890    if output_schema.fields.is_empty() {
2891        return Ok(Some(create_record_batch_with_empty_schema(
2892            Arc::new(output_schema.clone()),
2893            filtered_probe_batch.num_rows(),
2894        )?));
2895    }
2896
2897    let mut columns: Vec<Arc<dyn Array>> =
2898        Vec::with_capacity(output_schema.fields().len());
2899
2900    for column_index in col_indices {
2901        let array = if column_index.side == build_side {
2902            // Broadcast the single build-side row to match the filtered
2903            // probe-side batch length
2904            let original_left_array = build_side_batch.column(column_index.index);
2905
2906            // Use `arrow::compute::take` directly for `List(Utf8View)` rather
2907            // than going through `ScalarValue::to_array_of_size()`, which
2908            // avoids some intermediate allocations.
2909            //
2910            // In other cases, `to_array_of_size()` is faster.
2911            match original_left_array.data_type() {
2912                DataType::List(field) | DataType::LargeList(field)
2913                    if field.data_type() == &DataType::Utf8View =>
2914                {
2915                    let indices_iter = std::iter::repeat_n(
2916                        build_side_index as u64,
2917                        filtered_probe_batch.num_rows(),
2918                    );
2919                    let indices_array = UInt64Array::from_iter_values(indices_iter);
2920                    take(original_left_array.as_ref(), &indices_array, None)?
2921                }
2922                _ => {
2923                    let scalar_value = ScalarValue::try_from_array(
2924                        original_left_array.as_ref(),
2925                        build_side_index,
2926                    )?;
2927                    scalar_value.to_array_of_size(filtered_probe_batch.num_rows())?
2928                }
2929            }
2930        } else {
2931            // Take the filtered probe-side column using compute::take
2932            Arc::clone(filtered_probe_batch.column(column_index.index))
2933        };
2934
2935        columns.push(array);
2936    }
2937
2938    Ok(Some(RecordBatch::try_new(
2939        Arc::new(output_schema.clone()),
2940        columns,
2941    )?))
2942}
2943
2944/// Special case for `PlaceHolderRowExec`
2945/// Minimal example:  SELECT 1 WHERE EXISTS (SELECT 1);
2946//
2947/// # Return
2948/// If Some, that's the result batch
2949/// If None, it's not for this special case. Continue execution.
2950fn build_unmatched_batch_empty_schema(
2951    output_schema: &SchemaRef,
2952    batch_bitmap: &BooleanArray,
2953    // For left/right/full joins, it needs to fill nulls for another side
2954    join_type: JoinType,
2955) -> Result<Option<RecordBatch>> {
2956    let result_size = match join_type {
2957        JoinType::Left
2958        | JoinType::Right
2959        | JoinType::Full
2960        | JoinType::LeftAnti
2961        | JoinType::RightAnti => batch_bitmap.false_count(),
2962        JoinType::LeftSemi | JoinType::RightSemi => batch_bitmap.true_count(),
2963        JoinType::LeftMark | JoinType::RightMark => batch_bitmap.len(),
2964        _ => unreachable!(),
2965    };
2966
2967    if output_schema.fields().is_empty() {
2968        Ok(Some(create_record_batch_with_empty_schema(
2969            Arc::clone(output_schema),
2970            result_size,
2971        )?))
2972    } else {
2973        Ok(None)
2974    }
2975}
2976
2977/// Creates an empty RecordBatch with a specific row count.
2978/// This is useful for cases where we need a batch with the correct schema and row count
2979/// but no actual data columns (e.g., for constant filters).
2980fn create_record_batch_with_empty_schema(
2981    schema: SchemaRef,
2982    row_count: usize,
2983) -> Result<RecordBatch> {
2984    let options = RecordBatchOptions::new()
2985        .with_match_field_names(true)
2986        .with_row_count(Some(row_count));
2987
2988    RecordBatch::try_new_with_options(schema, vec![], &options).map_err(|e| {
2989        internal_datafusion_err!("Failed to create empty record batch: {}", e)
2990    })
2991}
2992
2993/// # Example:
2994/// batch:
2995/// a
2996/// ----
2997/// 1
2998/// 2
2999/// 3
3000///
3001/// batch_bitmap:
3002/// ----
3003/// false
3004/// true
3005/// false
3006///
3007/// another_side_schema:
3008/// [(b, bool), (c, int32)]
3009///
3010/// join_type: JoinType::Left
3011///
3012/// col_indices: ...(please refer to the comment in `NLJStream::column_indices``)
3013///
3014/// batch_side: right
3015///
3016/// # Walkthrough:
3017///
3018/// This executor is performing a right join, and the currently processed right
3019/// batch is as above. After joining it with all buffered left rows, the joined
3020/// entries are marked by the `batch_bitmap`.
3021/// This method will keep the unmatched indices on the batch side (right), and pad
3022/// the left side with nulls. The result would be:
3023///
3024/// b          c           a
3025/// ------------------------
3026/// Null(bool) Null(Int32) 1
3027/// Null(bool) Null(Int32) 3
3028fn build_unmatched_batch(
3029    output_schema: &SchemaRef,
3030    batch: &RecordBatch,
3031    batch_bitmap: BooleanArray,
3032    // For left/right/full joins, it needs to fill nulls for another side
3033    another_side_schema: &SchemaRef,
3034    col_indices: &[ColumnIndex],
3035    join_type: JoinType,
3036    batch_side: JoinSide,
3037) -> Result<Option<RecordBatch>> {
3038    // Should not call it for inner joins
3039    debug_assert_ne!(join_type, JoinType::Inner);
3040    debug_assert_ne!(batch_side, JoinSide::None);
3041
3042    // Handle special case (see function comment)
3043    if let Some(batch) =
3044        build_unmatched_batch_empty_schema(output_schema, &batch_bitmap, join_type)?
3045    {
3046        return Ok(Some(batch));
3047    }
3048
3049    match join_type {
3050        JoinType::Full | JoinType::Right | JoinType::Left => {
3051            if join_type == JoinType::Right {
3052                debug_assert_eq!(batch_side, JoinSide::Right);
3053            }
3054            if join_type == JoinType::Left {
3055                debug_assert_eq!(batch_side, JoinSide::Left);
3056            }
3057
3058            // 1. Filter the batch with *flipped* bitmap
3059            // 2. Fill left side with nulls
3060            let flipped_bitmap = not(&batch_bitmap)?;
3061
3062            // create a record batch, with left_schema, of only one row of all nulls
3063            let left_null_columns: Vec<Arc<dyn Array>> = another_side_schema
3064                .fields()
3065                .iter()
3066                .map(|field| new_null_array(field.data_type(), 1))
3067                .collect();
3068
3069            // Hack: If the left schema is not nullable, the full join result
3070            // might contain null, this is only a temporary batch to construct
3071            // such full join result.
3072            let nullable_left_schema = Arc::new(Schema::new(
3073                another_side_schema
3074                    .fields()
3075                    .iter()
3076                    .map(|field| (**field).clone().with_nullable(true))
3077                    .collect::<Vec<_>>(),
3078            ));
3079            let left_null_batch = if nullable_left_schema.fields.is_empty() {
3080                // Left input can be an empty relation, in this case left relation
3081                // won't be used to construct the result batch (i.e. not in `col_indices`)
3082                create_record_batch_with_empty_schema(nullable_left_schema, 0)?
3083            } else {
3084                RecordBatch::try_new(nullable_left_schema, left_null_columns)?
3085            };
3086
3087            debug_assert_ne!(batch_side, JoinSide::None);
3088            let opposite_side = batch_side.negate();
3089
3090            build_row_join_batch(
3091                output_schema,
3092                &left_null_batch,
3093                0,
3094                batch,
3095                Some(flipped_bitmap),
3096                col_indices,
3097                opposite_side,
3098            )
3099        }
3100        JoinType::RightSemi
3101        | JoinType::RightAnti
3102        | JoinType::LeftSemi
3103        | JoinType::LeftAnti => {
3104            if matches!(join_type, JoinType::RightSemi | JoinType::RightAnti) {
3105                debug_assert_eq!(batch_side, JoinSide::Right);
3106            }
3107            if matches!(join_type, JoinType::LeftSemi | JoinType::LeftAnti) {
3108                debug_assert_eq!(batch_side, JoinSide::Left);
3109            }
3110
3111            let bitmap = if matches!(join_type, JoinType::LeftSemi | JoinType::RightSemi)
3112            {
3113                batch_bitmap.clone()
3114            } else {
3115                not(&batch_bitmap)?
3116            };
3117
3118            if !bitmap.has_true() {
3119                return Ok(None);
3120            }
3121
3122            let mut columns: Vec<Arc<dyn Array>> =
3123                Vec::with_capacity(output_schema.fields().len());
3124
3125            for column_index in col_indices {
3126                debug_assert!(column_index.side == batch_side);
3127
3128                let col = batch.column(column_index.index);
3129                let filtered_col = filter(col, &bitmap)?;
3130
3131                columns.push(filtered_col);
3132            }
3133
3134            Ok(Some(RecordBatch::try_new(
3135                Arc::clone(output_schema),
3136                columns,
3137            )?))
3138        }
3139        JoinType::RightMark | JoinType::LeftMark => {
3140            if join_type == JoinType::RightMark {
3141                debug_assert_eq!(batch_side, JoinSide::Right);
3142            }
3143            if join_type == JoinType::LeftMark {
3144                debug_assert_eq!(batch_side, JoinSide::Left);
3145            }
3146
3147            let mut columns: Vec<Arc<dyn Array>> =
3148                Vec::with_capacity(output_schema.fields().len());
3149
3150            // Hack to deal with the borrow checker
3151            let mut right_batch_bitmap_opt = Some(batch_bitmap);
3152
3153            for column_index in col_indices {
3154                if column_index.side == batch_side {
3155                    let col = batch.column(column_index.index);
3156
3157                    columns.push(Arc::clone(col));
3158                } else if column_index.side == JoinSide::None {
3159                    let right_batch_bitmap = std::mem::take(&mut right_batch_bitmap_opt);
3160                    match right_batch_bitmap {
3161                        Some(right_batch_bitmap) => {
3162                            columns.push(Arc::new(right_batch_bitmap))
3163                        }
3164                        None => unreachable!("Should only be one mark column"),
3165                    }
3166                } else {
3167                    return internal_err!(
3168                        "Not possible to have this join side for RightMark join"
3169                    );
3170                }
3171            }
3172
3173            Ok(Some(RecordBatch::try_new(
3174                Arc::clone(output_schema),
3175                columns,
3176            )?))
3177        }
3178        _ => internal_err!(
3179            "If batch is at right side, this function must be handling Full/Right/RightSemi/RightAnti/RightMark joins"
3180        ),
3181    }
3182}
3183
3184#[cfg(test)]
3185pub(crate) mod tests {
3186    use super::*;
3187    use crate::statistics::{StatisticsArgs, StatisticsContext};
3188    use crate::test::{TestMemoryExec, assert_join_metrics};
3189    use crate::{
3190        common, expressions::Column, repartition::RepartitionExec, test::build_table_i32,
3191    };
3192
3193    use arrow::compute::SortOptions;
3194    use arrow::datatypes::{DataType, Field};
3195    use datafusion_common::assert_contains;
3196    use datafusion_common::test_util::batches_to_sort_string;
3197    use datafusion_execution::runtime_env::RuntimeEnvBuilder;
3198    use datafusion_expr::Operator;
3199    use datafusion_physical_expr::expressions::{BinaryExpr, Literal};
3200    use datafusion_physical_expr::{Partitioning, PhysicalExpr};
3201    use datafusion_physical_expr_common::sort_expr::{LexOrdering, PhysicalSortExpr};
3202
3203    use insta::allow_duplicates;
3204    use insta::assert_snapshot;
3205    use rstest::rstest;
3206
3207    fn build_table(
3208        a: (&str, &Vec<i32>),
3209        b: (&str, &Vec<i32>),
3210        c: (&str, &Vec<i32>),
3211        batch_size: Option<usize>,
3212        sorted_column_names: Vec<&str>,
3213    ) -> Arc<dyn ExecutionPlan> {
3214        let batch = build_table_i32(a, b, c);
3215        let schema = batch.schema();
3216
3217        let batches = if let Some(batch_size) = batch_size {
3218            let num_batches = batch.num_rows().div_ceil(batch_size);
3219            (0..num_batches)
3220                .map(|i| {
3221                    let start = i * batch_size;
3222                    let remaining_rows = batch.num_rows() - start;
3223                    batch.slice(start, batch_size.min(remaining_rows))
3224                })
3225                .collect::<Vec<_>>()
3226        } else {
3227            vec![batch]
3228        };
3229
3230        let mut sort_info = vec![];
3231        for name in sorted_column_names {
3232            let index = schema.index_of(name).unwrap();
3233            let sort_expr = PhysicalSortExpr::new(
3234                Arc::new(Column::new(name, index)),
3235                SortOptions::new(false, false),
3236            );
3237            sort_info.push(sort_expr);
3238        }
3239        let mut source = TestMemoryExec::try_new(&[batches], schema, None).unwrap();
3240        if let Some(ordering) = LexOrdering::new(sort_info) {
3241            source = source.try_with_sort_information(vec![ordering]).unwrap();
3242        }
3243
3244        let source = Arc::new(source);
3245        Arc::new(TestMemoryExec::update_cache(&source))
3246    }
3247
3248    fn build_left_table() -> Arc<dyn ExecutionPlan> {
3249        build_table(
3250            ("a1", &vec![5, 9, 11]),
3251            ("b1", &vec![5, 8, 8]),
3252            ("c1", &vec![50, 90, 110]),
3253            None,
3254            Vec::new(),
3255        )
3256    }
3257
3258    fn build_right_table() -> Arc<dyn ExecutionPlan> {
3259        build_table(
3260            ("a2", &vec![12, 2, 10]),
3261            ("b2", &vec![10, 2, 10]),
3262            ("c2", &vec![40, 80, 100]),
3263            None,
3264            Vec::new(),
3265        )
3266    }
3267
3268    fn prepare_join_filter() -> JoinFilter {
3269        let column_indices = vec![
3270            ColumnIndex {
3271                index: 1,
3272                side: JoinSide::Left,
3273            },
3274            ColumnIndex {
3275                index: 1,
3276                side: JoinSide::Right,
3277            },
3278        ];
3279        let intermediate_schema = Schema::new(vec![
3280            Field::new("x", DataType::Int32, true),
3281            Field::new("x", DataType::Int32, true),
3282        ]);
3283        // left.b1!=8
3284        let left_filter = Arc::new(BinaryExpr::new(
3285            Arc::new(Column::new("x", 0)),
3286            Operator::NotEq,
3287            Arc::new(Literal::new(ScalarValue::Int32(Some(8)))),
3288        )) as Arc<dyn PhysicalExpr>;
3289        // right.b2!=10
3290        let right_filter = Arc::new(BinaryExpr::new(
3291            Arc::new(Column::new("x", 1)),
3292            Operator::NotEq,
3293            Arc::new(Literal::new(ScalarValue::Int32(Some(10)))),
3294        )) as Arc<dyn PhysicalExpr>;
3295        // filter = left.b1!=8 and right.b2!=10
3296        // after filter:
3297        // left table:
3298        // ("a1", &vec![5]),
3299        // ("b1", &vec![5]),
3300        // ("c1", &vec![50]),
3301        // right table:
3302        // ("a2", &vec![12, 2]),
3303        // ("b2", &vec![10, 2]),
3304        // ("c2", &vec![40, 80]),
3305        let filter_expression =
3306            Arc::new(BinaryExpr::new(left_filter, Operator::And, right_filter))
3307                as Arc<dyn PhysicalExpr>;
3308
3309        JoinFilter::new(
3310            filter_expression,
3311            column_indices,
3312            Arc::new(intermediate_schema),
3313        )
3314    }
3315
3316    pub(crate) async fn multi_partitioned_join_collect(
3317        left: Arc<dyn ExecutionPlan>,
3318        right: Arc<dyn ExecutionPlan>,
3319        join_type: &JoinType,
3320        join_filter: Option<JoinFilter>,
3321        context: Arc<TaskContext>,
3322    ) -> Result<(Vec<String>, Vec<RecordBatch>, MetricsSet)> {
3323        let partition_count = 4;
3324
3325        // Redistributing right input
3326        let right = Arc::new(RepartitionExec::try_new(
3327            right,
3328            Partitioning::RoundRobinBatch(partition_count),
3329        )?) as Arc<dyn ExecutionPlan>;
3330
3331        // Use the required distribution for nested loop join to test partition data
3332        let nested_loop_join =
3333            NestedLoopJoinExec::try_new(left, right, join_filter, join_type, None)?;
3334        let columns = columns(&nested_loop_join.schema());
3335        let mut batches = vec![];
3336        for i in 0..partition_count {
3337            let stream = nested_loop_join.execute(i, Arc::clone(&context))?;
3338            let more_batches = common::collect(stream).await?;
3339            batches.extend(
3340                more_batches
3341                    .into_iter()
3342                    .inspect(|b| {
3343                        assert!(b.num_rows() <= context.session_config().batch_size())
3344                    })
3345                    .filter(|b| b.num_rows() > 0)
3346                    .collect::<Vec<_>>(),
3347            );
3348        }
3349
3350        let metrics = nested_loop_join.metrics().unwrap();
3351
3352        Ok((columns, batches, metrics))
3353    }
3354
3355    fn new_task_ctx(batch_size: usize) -> Arc<TaskContext> {
3356        let base = TaskContext::default();
3357        // limit max size of intermediate batch used in nlj to 1
3358        let cfg = base.session_config().clone().with_batch_size(batch_size);
3359        Arc::new(base.with_session_config(cfg))
3360    }
3361
3362    #[rstest]
3363    #[tokio::test]
3364    async fn join_inner_with_filter(#[values(1, 2, 16)] batch_size: usize) -> Result<()> {
3365        let task_ctx = new_task_ctx(batch_size);
3366        dbg!(&batch_size);
3367        let left = build_left_table();
3368        let right = build_right_table();
3369        let filter = prepare_join_filter();
3370        let (columns, batches, metrics) = multi_partitioned_join_collect(
3371            left,
3372            right,
3373            &JoinType::Inner,
3374            Some(filter),
3375            task_ctx,
3376        )
3377        .await?;
3378
3379        assert_eq!(columns, vec!["a1", "b1", "c1", "a2", "b2", "c2"]);
3380        allow_duplicates!(assert_snapshot!(batches_to_sort_string(&batches), @r"
3381        +----+----+----+----+----+----+
3382        | a1 | b1 | c1 | a2 | b2 | c2 |
3383        +----+----+----+----+----+----+
3384        | 5  | 5  | 50 | 2  | 2  | 80 |
3385        +----+----+----+----+----+----+
3386        "));
3387
3388        assert_join_metrics!(metrics, 1);
3389
3390        Ok(())
3391    }
3392
3393    #[rstest]
3394    #[tokio::test]
3395    async fn join_left_with_filter(#[values(1, 2, 16)] batch_size: usize) -> Result<()> {
3396        let task_ctx = new_task_ctx(batch_size);
3397        let left = build_left_table();
3398        let right = build_right_table();
3399
3400        let filter = prepare_join_filter();
3401        let (columns, batches, metrics) = multi_partitioned_join_collect(
3402            left,
3403            right,
3404            &JoinType::Left,
3405            Some(filter),
3406            task_ctx,
3407        )
3408        .await?;
3409        assert_eq!(columns, vec!["a1", "b1", "c1", "a2", "b2", "c2"]);
3410        allow_duplicates!(assert_snapshot!(batches_to_sort_string(&batches), @r"
3411        +----+----+-----+----+----+----+
3412        | a1 | b1 | c1  | a2 | b2 | c2 |
3413        +----+----+-----+----+----+----+
3414        | 11 | 8  | 110 |    |    |    |
3415        | 5  | 5  | 50  | 2  | 2  | 80 |
3416        | 9  | 8  | 90  |    |    |    |
3417        +----+----+-----+----+----+----+
3418        "));
3419
3420        assert_join_metrics!(metrics, 3);
3421
3422        Ok(())
3423    }
3424
3425    #[rstest]
3426    #[tokio::test]
3427    async fn join_right_with_filter(#[values(1, 2, 16)] batch_size: usize) -> Result<()> {
3428        let task_ctx = new_task_ctx(batch_size);
3429        let left = build_left_table();
3430        let right = build_right_table();
3431
3432        let filter = prepare_join_filter();
3433        let (columns, batches, metrics) = multi_partitioned_join_collect(
3434            left,
3435            right,
3436            &JoinType::Right,
3437            Some(filter),
3438            task_ctx,
3439        )
3440        .await?;
3441        assert_eq!(columns, vec!["a1", "b1", "c1", "a2", "b2", "c2"]);
3442        allow_duplicates!(assert_snapshot!(batches_to_sort_string(&batches), @r"
3443        +----+----+----+----+----+-----+
3444        | a1 | b1 | c1 | a2 | b2 | c2  |
3445        +----+----+----+----+----+-----+
3446        |    |    |    | 10 | 10 | 100 |
3447        |    |    |    | 12 | 10 | 40  |
3448        | 5  | 5  | 50 | 2  | 2  | 80  |
3449        +----+----+----+----+----+-----+
3450        "));
3451
3452        assert_join_metrics!(metrics, 3);
3453
3454        Ok(())
3455    }
3456
3457    #[rstest]
3458    #[tokio::test]
3459    async fn join_full_with_filter(#[values(1, 2, 16)] batch_size: usize) -> Result<()> {
3460        let task_ctx = new_task_ctx(batch_size);
3461        let left = build_left_table();
3462        let right = build_right_table();
3463
3464        let filter = prepare_join_filter();
3465        let (columns, batches, metrics) = multi_partitioned_join_collect(
3466            left,
3467            right,
3468            &JoinType::Full,
3469            Some(filter),
3470            task_ctx,
3471        )
3472        .await?;
3473        assert_eq!(columns, vec!["a1", "b1", "c1", "a2", "b2", "c2"]);
3474        allow_duplicates!(assert_snapshot!(batches_to_sort_string(&batches), @r"
3475        +----+----+-----+----+----+-----+
3476        | a1 | b1 | c1  | a2 | b2 | c2  |
3477        +----+----+-----+----+----+-----+
3478        |    |    |     | 10 | 10 | 100 |
3479        |    |    |     | 12 | 10 | 40  |
3480        | 11 | 8  | 110 |    |    |     |
3481        | 5  | 5  | 50  | 2  | 2  | 80  |
3482        | 9  | 8  | 90  |    |    |     |
3483        +----+----+-----+----+----+-----+
3484        "));
3485
3486        assert_join_metrics!(metrics, 5);
3487
3488        Ok(())
3489    }
3490
3491    #[rstest]
3492    #[tokio::test]
3493    async fn join_left_semi_with_filter(
3494        #[values(1, 2, 16)] batch_size: usize,
3495    ) -> Result<()> {
3496        let task_ctx = new_task_ctx(batch_size);
3497        let left = build_left_table();
3498        let right = build_right_table();
3499
3500        let filter = prepare_join_filter();
3501        let (columns, batches, metrics) = multi_partitioned_join_collect(
3502            left,
3503            right,
3504            &JoinType::LeftSemi,
3505            Some(filter),
3506            task_ctx,
3507        )
3508        .await?;
3509        assert_eq!(columns, vec!["a1", "b1", "c1"]);
3510        allow_duplicates!(assert_snapshot!(batches_to_sort_string(&batches), @r"
3511        +----+----+----+
3512        | a1 | b1 | c1 |
3513        +----+----+----+
3514        | 5  | 5  | 50 |
3515        +----+----+----+
3516        "));
3517
3518        assert_join_metrics!(metrics, 1);
3519
3520        Ok(())
3521    }
3522
3523    #[rstest]
3524    #[tokio::test]
3525    async fn join_left_anti_with_filter(
3526        #[values(1, 2, 16)] batch_size: usize,
3527    ) -> Result<()> {
3528        let task_ctx = new_task_ctx(batch_size);
3529        let left = build_left_table();
3530        let right = build_right_table();
3531
3532        let filter = prepare_join_filter();
3533        let (columns, batches, metrics) = multi_partitioned_join_collect(
3534            left,
3535            right,
3536            &JoinType::LeftAnti,
3537            Some(filter),
3538            task_ctx,
3539        )
3540        .await?;
3541        assert_eq!(columns, vec!["a1", "b1", "c1"]);
3542        allow_duplicates!(assert_snapshot!(batches_to_sort_string(&batches), @r"
3543        +----+----+-----+
3544        | a1 | b1 | c1  |
3545        +----+----+-----+
3546        | 11 | 8  | 110 |
3547        | 9  | 8  | 90  |
3548        +----+----+-----+
3549        "));
3550
3551        assert_join_metrics!(metrics, 2);
3552
3553        Ok(())
3554    }
3555
3556    #[tokio::test]
3557    async fn join_has_correct_stats() -> Result<()> {
3558        let left = build_left_table();
3559        let right = build_right_table();
3560        let nested_loop_join = NestedLoopJoinExec::try_new(
3561            left,
3562            right,
3563            None,
3564            &JoinType::Left,
3565            Some(vec![1, 2]),
3566        )?;
3567        let stats = StatisticsContext::new()
3568            .compute(&nested_loop_join, &StatisticsArgs::new())?;
3569        assert_eq!(
3570            nested_loop_join.schema().fields().len(),
3571            stats.column_statistics.len(),
3572        );
3573        assert_eq!(2, stats.column_statistics.len());
3574        Ok(())
3575    }
3576
3577    #[rstest]
3578    #[tokio::test]
3579    async fn join_right_semi_with_filter(
3580        #[values(1, 2, 16)] batch_size: usize,
3581    ) -> Result<()> {
3582        let task_ctx = new_task_ctx(batch_size);
3583        let left = build_left_table();
3584        let right = build_right_table();
3585
3586        let filter = prepare_join_filter();
3587        let (columns, batches, metrics) = multi_partitioned_join_collect(
3588            left,
3589            right,
3590            &JoinType::RightSemi,
3591            Some(filter),
3592            task_ctx,
3593        )
3594        .await?;
3595        assert_eq!(columns, vec!["a2", "b2", "c2"]);
3596        allow_duplicates!(assert_snapshot!(batches_to_sort_string(&batches), @r"
3597        +----+----+----+
3598        | a2 | b2 | c2 |
3599        +----+----+----+
3600        | 2  | 2  | 80 |
3601        +----+----+----+
3602        "));
3603
3604        assert_join_metrics!(metrics, 1);
3605
3606        Ok(())
3607    }
3608
3609    #[rstest]
3610    #[tokio::test]
3611    async fn join_right_anti_with_filter(
3612        #[values(1, 2, 16)] batch_size: usize,
3613    ) -> Result<()> {
3614        let task_ctx = new_task_ctx(batch_size);
3615        let left = build_left_table();
3616        let right = build_right_table();
3617
3618        let filter = prepare_join_filter();
3619        let (columns, batches, metrics) = multi_partitioned_join_collect(
3620            left,
3621            right,
3622            &JoinType::RightAnti,
3623            Some(filter),
3624            task_ctx,
3625        )
3626        .await?;
3627        assert_eq!(columns, vec!["a2", "b2", "c2"]);
3628        allow_duplicates!(assert_snapshot!(batches_to_sort_string(&batches), @r"
3629        +----+----+-----+
3630        | a2 | b2 | c2  |
3631        +----+----+-----+
3632        | 10 | 10 | 100 |
3633        | 12 | 10 | 40  |
3634        +----+----+-----+
3635        "));
3636
3637        assert_join_metrics!(metrics, 2);
3638
3639        Ok(())
3640    }
3641
3642    #[rstest]
3643    #[tokio::test]
3644    async fn join_left_mark_with_filter(
3645        #[values(1, 2, 16)] batch_size: usize,
3646    ) -> Result<()> {
3647        let task_ctx = new_task_ctx(batch_size);
3648        let left = build_left_table();
3649        let right = build_right_table();
3650
3651        let filter = prepare_join_filter();
3652        let (columns, batches, metrics) = multi_partitioned_join_collect(
3653            left,
3654            right,
3655            &JoinType::LeftMark,
3656            Some(filter),
3657            task_ctx,
3658        )
3659        .await?;
3660        assert_eq!(columns, vec!["a1", "b1", "c1", "mark"]);
3661        allow_duplicates!(assert_snapshot!(batches_to_sort_string(&batches), @r"
3662        +----+----+-----+-------+
3663        | a1 | b1 | c1  | mark  |
3664        +----+----+-----+-------+
3665        | 11 | 8  | 110 | false |
3666        | 5  | 5  | 50  | true  |
3667        | 9  | 8  | 90  | false |
3668        +----+----+-----+-------+
3669        "));
3670
3671        assert_join_metrics!(metrics, 3);
3672
3673        Ok(())
3674    }
3675
3676    #[rstest]
3677    #[tokio::test]
3678    async fn join_right_mark_with_filter(
3679        #[values(1, 2, 16)] batch_size: usize,
3680    ) -> Result<()> {
3681        let task_ctx = new_task_ctx(batch_size);
3682        let left = build_left_table();
3683        let right = build_right_table();
3684
3685        let filter = prepare_join_filter();
3686        let (columns, batches, metrics) = multi_partitioned_join_collect(
3687            left,
3688            right,
3689            &JoinType::RightMark,
3690            Some(filter),
3691            task_ctx,
3692        )
3693        .await?;
3694        assert_eq!(columns, vec!["a2", "b2", "c2", "mark"]);
3695
3696        allow_duplicates!(assert_snapshot!(batches_to_sort_string(&batches), @r"
3697        +----+----+-----+-------+
3698        | a2 | b2 | c2  | mark  |
3699        +----+----+-----+-------+
3700        | 10 | 10 | 100 | false |
3701        | 12 | 10 | 40  | false |
3702        | 2  | 2  | 80  | true  |
3703        +----+----+-----+-------+
3704        "));
3705
3706        assert_join_metrics!(metrics, 3);
3707
3708        Ok(())
3709    }
3710
3711    #[tokio::test]
3712    async fn test_overallocation() -> Result<()> {
3713        let left = build_table(
3714            ("a1", &vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 0]),
3715            ("b1", &vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 0]),
3716            ("c1", &vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 0]),
3717            None,
3718            Vec::new(),
3719        );
3720        let right = build_table(
3721            ("a2", &vec![10, 11]),
3722            ("b2", &vec![12, 13]),
3723            ("c2", &vec![14, 15]),
3724            None,
3725            Vec::new(),
3726        );
3727        let filter = prepare_join_filter();
3728
3729        // Join types that support memory-limited fallback should succeed
3730        // even under tight memory limits (they spill to disk instead of OOM).
3731        let fallback_join_types = vec![
3732            JoinType::Inner,
3733            JoinType::Left,
3734            JoinType::LeftSemi,
3735            JoinType::LeftAnti,
3736            JoinType::LeftMark,
3737            JoinType::Right,
3738            JoinType::RightSemi,
3739            JoinType::RightAnti,
3740            JoinType::RightMark,
3741        ];
3742
3743        for join_type in &fallback_join_types {
3744            let runtime = RuntimeEnvBuilder::new()
3745                .with_memory_limit(100, 1.0)
3746                .build_arc()?;
3747            let task_ctx = TaskContext::default().with_runtime(runtime);
3748            let task_ctx = Arc::new(task_ctx);
3749
3750            // Should succeed via spill fallback, not OOM
3751            let _result = multi_partitioned_join_collect(
3752                Arc::clone(&left),
3753                Arc::clone(&right),
3754                join_type,
3755                Some(filter.clone()),
3756                task_ctx,
3757            )
3758            .await?;
3759        }
3760
3761        // FULL JOIN with multiple right partitions is intentionally not
3762        // supported in the fallback path yet (cross-partition left-bitmap
3763        // coordination is missing). It should still OOM under tight memory.
3764        let runtime = RuntimeEnvBuilder::new()
3765            .with_memory_limit(100, 1.0)
3766            .build_arc()?;
3767        let task_ctx = TaskContext::default().with_runtime(runtime);
3768        let task_ctx = Arc::new(task_ctx);
3769        let err = multi_partitioned_join_collect(
3770            Arc::clone(&left),
3771            Arc::clone(&right),
3772            &JoinType::Full,
3773            Some(filter.clone()),
3774            task_ctx,
3775        )
3776        .await
3777        .unwrap_err();
3778        assert_contains!(err.to_string(), "Resources exhausted");
3779
3780        Ok(())
3781    }
3782
3783    /// Returns the column names on the schema
3784    fn columns(schema: &Schema) -> Vec<String> {
3785        schema.fields().iter().map(|f| f.name().clone()).collect()
3786    }
3787
3788    // ========================================================================
3789    // Memory-limited execution tests
3790    // ========================================================================
3791
3792    /// Helper to run a NLJ using partition 0 and collect results + metrics.
3793    async fn join_collect(
3794        left: Arc<dyn ExecutionPlan>,
3795        right: Arc<dyn ExecutionPlan>,
3796        join_type: &JoinType,
3797        join_filter: Option<JoinFilter>,
3798        context: Arc<TaskContext>,
3799    ) -> Result<(Vec<String>, Vec<RecordBatch>, MetricsSet)> {
3800        let nested_loop_join =
3801            NestedLoopJoinExec::try_new(left, right, join_filter, join_type, None)?;
3802        let columns = columns(&nested_loop_join.schema());
3803        let stream = nested_loop_join.execute(0, context)?;
3804        let batches: Vec<RecordBatch> = common::collect(stream)
3805            .await?
3806            .into_iter()
3807            .filter(|b| b.num_rows() > 0)
3808            .collect();
3809        let metrics = nested_loop_join.metrics().unwrap();
3810        Ok((columns, batches, metrics))
3811    }
3812
3813    /// Create a TaskContext with tight memory limit and disk spilling enabled.
3814    fn task_ctx_with_memory_limit(
3815        memory_limit: usize,
3816        batch_size: usize,
3817    ) -> Result<Arc<TaskContext>> {
3818        let runtime = RuntimeEnvBuilder::new()
3819            .with_memory_limit(memory_limit, 1.0)
3820            .build_arc()?;
3821        let cfg = TaskContext::default()
3822            .session_config()
3823            .clone()
3824            .with_batch_size(batch_size);
3825        let task_ctx = TaskContext::default()
3826            .with_runtime(runtime)
3827            .with_session_config(cfg);
3828        Ok(Arc::new(task_ctx))
3829    }
3830
3831    #[tokio::test]
3832    async fn test_nlj_memory_limited_inner_join() -> Result<()> {
3833        // Use a very small memory limit to force OOM → fallback to spill.
3834        let task_ctx = task_ctx_with_memory_limit(50, 16)?;
3835        let left = build_left_table();
3836        let right = build_right_table();
3837        let filter = prepare_join_filter();
3838
3839        let (columns, batches, metrics) =
3840            join_collect(left, right, &JoinType::Inner, Some(filter), task_ctx).await?;
3841
3842        assert_eq!(columns, vec!["a1", "b1", "c1", "a2", "b2", "c2"]);
3843
3844        // Verify spill actually occurred (memory-limited path was taken)
3845        assert!(
3846            metrics.spill_count().unwrap_or(0) > 0,
3847            "Expected spilling to occur under tight memory limit"
3848        );
3849
3850        // Result should be identical to the non-memory-limited case
3851        allow_duplicates!(assert_snapshot!(batches_to_sort_string(&batches), @r"
3852        +----+----+----+----+----+----+
3853        | a1 | b1 | c1 | a2 | b2 | c2 |
3854        +----+----+----+----+----+----+
3855        | 5  | 5  | 50 | 2  | 2  | 80 |
3856        +----+----+----+----+----+----+
3857        "));
3858        Ok(())
3859    }
3860
3861    #[tokio::test]
3862    async fn test_nlj_memory_limited_left_join() -> Result<()> {
3863        let task_ctx = task_ctx_with_memory_limit(50, 16)?;
3864        let left = build_left_table();
3865        let right = build_right_table();
3866        let filter = prepare_join_filter();
3867
3868        let (columns, batches, metrics) =
3869            join_collect(left, right, &JoinType::Left, Some(filter), task_ctx).await?;
3870
3871        assert_eq!(columns, vec!["a1", "b1", "c1", "a2", "b2", "c2"]);
3872
3873        // Verify spill actually occurred
3874        assert!(
3875            metrics.spill_count().unwrap_or(0) > 0,
3876            "Expected spilling to occur under tight memory limit"
3877        );
3878
3879        allow_duplicates!(assert_snapshot!(batches_to_sort_string(&batches), @r"
3880        +----+----+-----+----+----+----+
3881        | a1 | b1 | c1  | a2 | b2 | c2 |
3882        +----+----+-----+----+----+----+
3883        | 11 | 8  | 110 |    |    |    |
3884        | 5  | 5  | 50  | 2  | 2  | 80 |
3885        | 9  | 8  | 90  |    |    |    |
3886        +----+----+-----+----+----+----+
3887        "));
3888        Ok(())
3889    }
3890
3891    #[tokio::test]
3892    async fn test_nlj_fits_in_memory_no_spill() -> Result<()> {
3893        // Use a large memory limit — everything fits, no spilling needed.
3894        let task_ctx = task_ctx_with_memory_limit(10_000_000, 16)?;
3895        let left = build_left_table();
3896        let right = build_right_table();
3897        let filter = prepare_join_filter();
3898
3899        let (columns, batches, metrics) =
3900            join_collect(left, right, &JoinType::Inner, Some(filter), task_ctx).await?;
3901
3902        assert_eq!(columns, vec!["a1", "b1", "c1", "a2", "b2", "c2"]);
3903
3904        // Verify no spilling occurred (standard OnceFut path was used)
3905        assert_eq!(
3906            metrics.spill_count().unwrap_or(0),
3907            0,
3908            "Expected no spilling with generous memory limit"
3909        );
3910
3911        allow_duplicates!(assert_snapshot!(batches_to_sort_string(&batches), @r"
3912        +----+----+----+----+----+----+
3913        | a1 | b1 | c1 | a2 | b2 | c2 |
3914        +----+----+----+----+----+----+
3915        | 5  | 5  | 50 | 2  | 2  | 80 |
3916        +----+----+----+----+----+----+
3917        "));
3918        Ok(())
3919    }
3920
3921    #[tokio::test]
3922    async fn test_nlj_memory_limited_empty_inputs() -> Result<()> {
3923        let task_ctx = task_ctx_with_memory_limit(50, 16)?;
3924
3925        // Empty left table
3926        let empty_left = build_table(
3927            ("a1", &vec![]),
3928            ("b1", &vec![]),
3929            ("c1", &vec![]),
3930            None,
3931            Vec::new(),
3932        );
3933        let right = build_right_table();
3934        let filter = prepare_join_filter();
3935
3936        let (_columns, batches, _metrics) =
3937            join_collect(empty_left, right, &JoinType::Inner, Some(filter), task_ctx)
3938                .await?;
3939        assert!(batches.is_empty() || batches.iter().all(|b| b.num_rows() == 0));
3940
3941        // Empty right table
3942        let task_ctx2 = task_ctx_with_memory_limit(50, 16)?;
3943        let left = build_left_table();
3944        let empty_right = build_table(
3945            ("a2", &vec![]),
3946            ("b2", &vec![]),
3947            ("c2", &vec![]),
3948            None,
3949            Vec::new(),
3950        );
3951        let filter2 = prepare_join_filter();
3952
3953        let (_columns, batches, _metrics) = join_collect(
3954            left,
3955            empty_right,
3956            &JoinType::Inner,
3957            Some(filter2),
3958            task_ctx2,
3959        )
3960        .await?;
3961        assert!(batches.is_empty() || batches.iter().all(|b| b.num_rows() == 0));
3962
3963        Ok(())
3964    }
3965
3966    #[tokio::test]
3967    async fn test_nlj_memory_limited_no_disk_falls_back_to_oom() -> Result<()> {
3968        // When disk is disabled, fallback is not possible and OOM should occur.
3969        use datafusion_execution::disk_manager::{DiskManagerBuilder, DiskManagerMode};
3970
3971        let runtime = RuntimeEnvBuilder::new()
3972            .with_memory_limit(100, 1.0)
3973            .with_disk_manager_builder(
3974                DiskManagerBuilder::default().with_mode(DiskManagerMode::Disabled),
3975            )
3976            .build_arc()?;
3977        let task_ctx = Arc::new(TaskContext::default().with_runtime(runtime));
3978
3979        let left = build_left_table();
3980        let right = build_right_table();
3981        let filter = prepare_join_filter();
3982
3983        let err = join_collect(left, right, &JoinType::Inner, Some(filter), task_ctx)
3984            .await
3985            .unwrap_err();
3986
3987        assert_contains!(err.to_string(), "Resources exhausted");
3988        Ok(())
3989    }
3990
3991    #[tokio::test]
3992    async fn test_nlj_memory_limited_right_join() -> Result<()> {
3993        let task_ctx = task_ctx_with_memory_limit(50, 16)?;
3994        let left = build_left_table();
3995        let right = build_right_table();
3996        let filter = prepare_join_filter();
3997
3998        let (columns, batches, metrics) =
3999            join_collect(left, right, &JoinType::Right, Some(filter), task_ctx).await?;
4000
4001        assert_eq!(columns, vec!["a1", "b1", "c1", "a2", "b2", "c2"]);
4002
4003        // Verify spill actually occurred
4004        assert!(
4005            metrics.spill_count().unwrap_or(0) > 0,
4006            "Expected spilling to occur under tight memory limit"
4007        );
4008
4009        // Right join: all right rows appear. Unmatched right rows get NULLs on left.
4010        allow_duplicates!(assert_snapshot!(batches_to_sort_string(&batches), @r"
4011        +----+----+----+----+----+-----+
4012        | a1 | b1 | c1 | a2 | b2 | c2  |
4013        +----+----+----+----+----+-----+
4014        |    |    |    | 10 | 10 | 100 |
4015        |    |    |    | 12 | 10 | 40  |
4016        | 5  | 5  | 50 | 2  | 2  | 80  |
4017        +----+----+----+----+----+-----+
4018        "));
4019        Ok(())
4020    }
4021
4022    #[tokio::test]
4023    async fn test_nlj_memory_limited_full_join() -> Result<()> {
4024        let task_ctx = task_ctx_with_memory_limit(50, 16)?;
4025        let left = build_left_table();
4026        let right = build_right_table();
4027        let filter = prepare_join_filter();
4028
4029        let (columns, batches, metrics) =
4030            join_collect(left, right, &JoinType::Full, Some(filter), task_ctx).await?;
4031
4032        assert_eq!(columns, vec!["a1", "b1", "c1", "a2", "b2", "c2"]);
4033
4034        // Verify spill actually occurred
4035        assert!(
4036            metrics.spill_count().unwrap_or(0) > 0,
4037            "Expected spilling to occur under tight memory limit"
4038        );
4039
4040        // Full join: unmatched from both sides appear with NULL padding.
4041        allow_duplicates!(assert_snapshot!(batches_to_sort_string(&batches), @r"
4042        +----+----+-----+----+----+-----+
4043        | a1 | b1 | c1  | a2 | b2 | c2  |
4044        +----+----+-----+----+----+-----+
4045        |    |    |     | 10 | 10 | 100 |
4046        |    |    |     | 12 | 10 | 40  |
4047        | 11 | 8  | 110 |    |    |     |
4048        | 5  | 5  | 50  | 2  | 2  | 80  |
4049        | 9  | 8  | 90  |    |    |     |
4050        +----+----+-----+----+----+-----+
4051        "));
4052        Ok(())
4053    }
4054
4055    #[tokio::test]
4056    async fn test_nlj_memory_limited_right_semi_join() -> Result<()> {
4057        let task_ctx = task_ctx_with_memory_limit(50, 16)?;
4058        let left = build_left_table();
4059        let right = build_right_table();
4060        let filter = prepare_join_filter();
4061
4062        let (columns, batches, metrics) =
4063            join_collect(left, right, &JoinType::RightSemi, Some(filter), task_ctx)
4064                .await?;
4065
4066        assert_eq!(columns, vec!["a2", "b2", "c2"]);
4067
4068        assert!(
4069            metrics.spill_count().unwrap_or(0) > 0,
4070            "Expected spilling to occur under tight memory limit"
4071        );
4072
4073        // Right semi: only right rows that matched at least one left row.
4074        allow_duplicates!(assert_snapshot!(batches_to_sort_string(&batches), @r"
4075        +----+----+----+
4076        | a2 | b2 | c2 |
4077        +----+----+----+
4078        | 2  | 2  | 80 |
4079        +----+----+----+
4080        "));
4081        Ok(())
4082    }
4083
4084    #[tokio::test]
4085    async fn test_nlj_memory_limited_right_anti_join() -> Result<()> {
4086        let task_ctx = task_ctx_with_memory_limit(50, 16)?;
4087        let left = build_left_table();
4088        let right = build_right_table();
4089        let filter = prepare_join_filter();
4090
4091        let (columns, batches, metrics) =
4092            join_collect(left, right, &JoinType::RightAnti, Some(filter), task_ctx)
4093                .await?;
4094
4095        assert_eq!(columns, vec!["a2", "b2", "c2"]);
4096
4097        assert!(
4098            metrics.spill_count().unwrap_or(0) > 0,
4099            "Expected spilling to occur under tight memory limit"
4100        );
4101
4102        // Right anti: right rows that did NOT match any left row.
4103        allow_duplicates!(assert_snapshot!(batches_to_sort_string(&batches), @r"
4104        +----+----+-----+
4105        | a2 | b2 | c2  |
4106        +----+----+-----+
4107        | 10 | 10 | 100 |
4108        | 12 | 10 | 40  |
4109        +----+----+-----+
4110        "));
4111        Ok(())
4112    }
4113
4114    #[tokio::test]
4115    async fn test_nlj_memory_limited_right_mark_join() -> Result<()> {
4116        let task_ctx = task_ctx_with_memory_limit(50, 16)?;
4117        let left = build_left_table();
4118        let right = build_right_table();
4119        let filter = prepare_join_filter();
4120
4121        let (columns, batches, metrics) =
4122            join_collect(left, right, &JoinType::RightMark, Some(filter), task_ctx)
4123                .await?;
4124
4125        assert_eq!(columns, vec!["a2", "b2", "c2", "mark"]);
4126
4127        assert!(
4128            metrics.spill_count().unwrap_or(0) > 0,
4129            "Expected spilling to occur under tight memory limit"
4130        );
4131
4132        // Right mark: all right rows with a bool column indicating match.
4133        allow_duplicates!(assert_snapshot!(batches_to_sort_string(&batches), @r"
4134        +----+----+-----+-------+
4135        | a2 | b2 | c2  | mark  |
4136        +----+----+-----+-------+
4137        | 10 | 10 | 100 | false |
4138        | 12 | 10 | 40  | false |
4139        | 2  | 2  | 80  | true  |
4140        +----+----+-----+-------+
4141        "));
4142        Ok(())
4143    }
4144}