Skip to main content

radixdb_executor/
join_executor.rs

1// Copyright 2026 RadixDB Contributors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Modern streaming JOIN executor using Volcano-style operators.
16//!
17//! This module provides high-performance JOIN execution with:
18//! - **Hash Join**: Build smaller side, probe larger side with O(N+M) complexity
19//! - **Merge Join**: O(N+M) when inputs are pre-sorted on join keys
20//! - **Nested Loop**: O(N*M) fallback for non-equality joins or small tables
21//! - **Early Termination**: LIMIT stops execution immediately
22//! - **Residual Filters**: Non-equality conditions applied during streaming
23//!
24//! # Architecture
25//!
26//! ```text
27//! JoinRequest
28//!     │
29//!     ▼
30//! ┌─────────────────────────────────┐
31//! │ JoinExecutor::execute()         │
32//! │  1. Analyze join condition      │
33//! │  2. Select optimal algorithm    │
34//! │  3. Execute with streaming      │
35//! │  4. Early terminate at LIMIT    │
36//! └─────────────────────────────────┘
37//!     │
38//!     ▼
39//! JoinResult { rows, columns }
40//! ```
41//!
42//! # Design Decisions & Tradeoffs
43//!
44//! ## Hybrid Execution: Streaming vs Parallel
45//!
46//! This executor uses a **hybrid approach** that dynamically chooses between
47//! Volcano-style streaming and parallel bulk processing based on query characteristics:
48//!
49//! ### Streaming Volcano Path (default for small datasets or small LIMIT)
50//! - **When**: Build side < 10,000 rows OR LIMIT ≤ 1,000
51//! - **Benefits**:
52//!   - O(1) memory for probe side (streaming, not materialized)
53//!   - Early termination: LIMIT 10 stops after 10 rows
54//!   - Low latency to first row (important for interactive queries)
55//!   - Composable operators (Filter → Join → Project → Limit)
56//!
57//! ### Parallel Hash Join Path (for large analytical queries)
58//! - **When**: Build side ≥ 10,000 rows AND (no LIMIT or LIMIT > 1,000)
59//! - **Benefits**:
60//!   - Parallel hash build using a pre-admitted fixed-width atomic table
61//!   - Parallel probe with Rayon work-stealing
62//!   - 2-4x speedup on multi-core systems for large joins
63//!   - Atomic tracking for OUTER join unmatched rows
64//!
65//! ### Why Not Always Parallel?
66//! Parallel execution has overhead (task scheduling, synchronization). For:
67//! - Small datasets: overhead exceeds benefit
68//! - Small LIMIT: streaming stops early; parallel computes full result then truncates
69//!
70//! ## Merge Join Boundary
71//!
72//! The binary entry point currently receives materialized relation batches,
73//! but `MergeJoinOperator` itself consumes its certified ordered inputs one row
74//! at a time. Only matching duplicate-key groups are blocking state, and their
75//! owner is bounded before execution:
76//!
77//! ```text
78//! left ordered operator  ┐
79//!                        ├→ bounded streaming MergeJoinOperator
80//! right ordered operator ┘
81//! ```
82//!
83//! ## Bloom Filter Optimization
84//!
85//! Bloom filters can accelerate hash joins by filtering probe rows that
86//! definitely won't match before touching the hash table. This is particularly
87//! effective for:
88//! - High selectivity joins (few matches relative to probe size)
89//! - Multi-way joins (filter cascades through the plan)
90//!
91//! Query planning can build a runtime bloom filter and wrap the probe input in
92//! `BloomFilterOperator` before handing it to the streaming join executor.
93
94use crate::context::ExecutionContext;
95use crate::expression::{JoinFilter, RowFilter};
96use crate::hash_table::JoinHashState;
97use crate::operator::{ColumnInfo, MaterializedOperator, Operator, OrderingProperty, RowRef};
98use crate::operators::hash_join::{HashJoinOperator, JoinSide, JoinType};
99use crate::operators::merge_join::MergeJoinOperator;
100use crate::operators::nested_loop_join::NestedLoopJoinOperator;
101use crate::parallel::{
102    parallel_hash_join_cancellable, parallel_join_state_retained_bytes, ParallelConfig,
103    ParallelHashJoinOperator, DEFAULT_PARALLEL_JOIN_THRESHOLD,
104};
105use crate::result::OperatorExecutorResult;
106use crate::utils::{extract_join_keys_and_residual, JoinProjectionIndices, RetainedRowsBudget};
107use radixdb_core::value::NULL_VALUE;
108use radixdb_core::{CompactArc, CompactVec};
109use radixdb_core::{Result, Row, RowVec, Value};
110use radixdb_functions::global_registry;
111use radixdb_sql::ast::Expression;
112use radixdb_storage::instrumentation::{self, JoinExecutionKind, JoinExecutionRecord};
113use radixdb_storage::DeferredRow;
114
115/// Runtime join algorithm selection.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum RuntimeJoinAlgorithm {
118    /// Hash join: O(N + M) for unsorted equality inputs.
119    HashJoin,
120    /// Merge join: O(N + M) when inputs are ordered on their keys.
121    MergeJoin,
122    /// Nested loop for small inputs or conditions without equality keys.
123    NestedLoop,
124}
125
126/// Runtime join decision produced by the planner and consumed by physical JOIN.
127#[derive(Debug, Clone)]
128pub struct RuntimeJoinDecision {
129    /// Selected join algorithm.
130    pub algorithm: RuntimeJoinAlgorithm,
131    /// Whether physical execution should swap the two inputs.
132    pub swap_sides: bool,
133    /// Human-readable decision evidence.
134    pub explanation: String,
135}
136
137impl RuntimeJoinDecision {
138    /// Check if hash join was selected.
139    pub fn use_hash_join(&self) -> bool {
140        self.algorithm == RuntimeJoinAlgorithm::HashJoin
141    }
142
143    /// Check if merge join was selected.
144    pub fn use_merge_join(&self) -> bool {
145        self.algorithm == RuntimeJoinAlgorithm::MergeJoin
146    }
147
148    /// Check if nested loop was selected.
149    pub fn use_nested_loop(&self) -> bool {
150        self.algorithm == RuntimeJoinAlgorithm::NestedLoop
151    }
152}
153
154/// LIMIT threshold below which streaming execution is preferred over parallel.
155///
156/// When LIMIT is small (≤ this value), streaming Volcano-style execution benefits from
157/// early termination - we can stop after producing just N rows without processing
158/// the entire join. Parallel execution would compute the full join result before
159/// truncating, wasting work.
160///
161/// When LIMIT is large (> this value), the early termination benefit is minimal,
162/// so parallel execution's throughput advantage dominates.
163const STREAMING_LIMIT_THRESHOLD: u64 = 1000;
164
165/// Result of a streaming join execution.
166#[derive(Debug)]
167pub struct JoinResult {
168    /// The joined rows with synthetic row IDs.
169    pub rows: JoinRows,
170    /// Column names for the combined result.
171    pub columns: Vec<String>,
172}
173
174#[derive(Debug)]
175pub enum JoinRows {
176    Owned(RowVec),
177    Deferred(Vec<DeferredRow>),
178}
179
180impl JoinRows {
181    #[inline]
182    pub fn len(&self) -> usize {
183        match self {
184            Self::Owned(rows) => rows.len(),
185            Self::Deferred(rows) => rows.len(),
186        }
187    }
188
189    /// Return whether the physical JOIN produced no rows.
190    pub fn is_empty(&self) -> bool {
191        self.len() == 0
192    }
193
194    #[inline]
195    fn owned_len(&self) -> usize {
196        match self {
197            Self::Owned(rows) => rows.len(),
198            Self::Deferred(_) => 0,
199        }
200    }
201
202    pub fn into_owned(self) -> RowVec {
203        match self {
204            Self::Owned(rows) => rows,
205            Self::Deferred(rows) => {
206                let mut owned = RowVec::with_capacity(rows.len());
207                for (row_id, row) in rows.into_iter().enumerate() {
208                    owned.push((row_id as i64, row.into_owned()));
209                }
210                owned
211            }
212        }
213    }
214
215    pub fn into_deferred(self) -> Vec<DeferredRow> {
216        match self {
217            Self::Deferred(rows) => rows,
218            Self::Owned(mut rows) => rows
219                .drain_rows()
220                .map(DeferredRow::owned)
221                .collect::<Vec<_>>(),
222        }
223    }
224}
225
226/// Analysis of a join operation for algorithm selection.
227#[derive(Debug, Clone)]
228pub struct JoinAnalysis {
229    /// Left side key column indices for equality join.
230    pub left_key_indices: Vec<usize>,
231    /// Right side key column indices for equality join.
232    pub right_key_indices: Vec<usize>,
233    /// Non-equality conditions to apply after hash matching.
234    pub residual_conditions: Vec<Expression>,
235    /// The parsed join type.
236    pub join_type: JoinType,
237    /// Join type as string (for compatibility).
238    pub join_type_str: String,
239}
240
241/// Configuration for join execution.
242/// This combines the algorithm choice with execution-specific config.
243#[derive(Debug, Clone)]
244struct JoinConfig {
245    /// The algorithm to use.
246    algorithm: RuntimeJoinAlgorithm,
247    /// For hash joins: whether to build on the left side.
248    build_left: bool,
249}
250
251/// Physical ordering certificates for both sides of one JOIN edge.
252#[derive(Debug, Clone, Default)]
253pub struct JoinInputOrderings {
254    pub left: OrderingProperty,
255    pub right: OrderingProperty,
256}
257
258impl JoinInputOrderings {
259    pub fn new(left: OrderingProperty, right: OrderingProperty) -> Self {
260        Self { left, right }
261    }
262}
263
264struct MergeExecutionRequest<'a> {
265    left_rows: CompactArc<Vec<Row>>,
266    right_rows: CompactArc<Vec<Row>>,
267    columns: (&'a [String], &'a [String]),
268    analysis: &'a JoinAnalysis,
269    ordering: JoinInputOrderings,
270    limit: Option<u64>,
271    ctx: &'a ExecutionContext,
272}
273
274/// Request to execute a join operation.
275///
276/// Uses `CompactArc<Vec<Row>>` to enable zero-copy sharing with CTE results.
277/// When dropping, only decrements refcount (O(1)) instead of deallocating rows.
278/// The caller should pass Arc-wrapped data for CTE sources.
279pub struct JoinRequest<'a> {
280    /// Left side rows (Arc for zero-copy sharing with CTE results).
281    pub left_rows: CompactArc<Vec<Row>>,
282    /// Right side rows (Arc for zero-copy sharing with CTE results).
283    pub right_rows: CompactArc<Vec<Row>>,
284    /// Left side column names.
285    pub left_columns: &'a [String],
286    /// Right side column names.
287    pub right_columns: &'a [String],
288    /// Join condition (if any).
289    pub condition: Option<&'a Expression>,
290    /// Join type string (INNER, LEFT, RIGHT, FULL, CROSS).
291    pub join_type: &'a str,
292    /// LIMIT for early termination.
293    pub limit: Option<u64>,
294    /// Execution context for expression evaluation.
295    pub ctx: &'a ExecutionContext,
296    /// Optional algorithm decision from QueryPlanner.
297    /// When provided, the executor uses this instead of making its own decision.
298    pub algorithm_hint: Option<&'a RuntimeJoinDecision>,
299    /// Ordering certified by the physical producers of both inputs.
300    pub ordering: JoinInputOrderings,
301    /// Optional fused projection for the final joined row.
302    ///
303    /// Hash joins apply it only when no residual predicate still needs the full
304    /// logical row. Nested-loop joins apply it after evaluating their condition
305    /// against full left/right rows.
306    pub projection: Option<&'a JoinProjectionIndices>,
307}
308
309/// Request to execute a streaming hash join.
310///
311/// Unlike `JoinRequest`, this takes a streaming operator for the probe side,
312/// enabling true streaming without full materialization. This is optimal for
313/// LIMIT queries where early termination can stop the probe scan early.
314///
315/// # Memory Model
316///
317/// - **Build side**: Fully materialized (required for hash table construction)
318/// - **Probe side**: Streams row-by-row from the operator (O(1) memory)
319///
320/// # When to Use
321///
322/// Use `StreamingJoinRequest` when:
323/// - Query has LIMIT (early termination benefit)
324/// - Probe side is large (avoid full materialization)
325/// - Join algorithm is Hash Join
326pub struct StreamingJoinRequest<'a> {
327    /// Build side rows (Arc for zero-copy sharing with CTE results).
328    pub build_rows: CompactArc<Vec<Row>>,
329    /// Build side column names.
330    pub build_columns: &'a [String],
331    /// Probe side as streaming operator (NOT materialized).
332    pub probe_source: Box<dyn Operator>,
333    /// Probe side column names.
334    pub probe_columns: Vec<String>,
335    /// Join condition (if any).
336    pub condition: Option<&'a Expression>,
337    /// Join type string (INNER, LEFT, RIGHT, FULL, CROSS).
338    pub join_type: &'a str,
339    /// Whether build side is left (false = build is right).
340    pub build_is_left: bool,
341    /// LIMIT for early termination.
342    pub limit: Option<u64>,
343    /// Execution context for expression evaluation.
344    pub ctx: &'a ExecutionContext,
345    /// Pre-built hash table (if available). When provided, skips the hash table
346    /// build phase in HashJoinOperator, avoiding double iteration of build_rows.
347    pub pre_built_hash_state: Option<JoinHashState>,
348    /// Optional fused projection for the final joined row.
349    ///
350    /// This is applied inside HashJoinOperator only when the join condition has
351    /// no residual predicates that need the full logical left+right row.
352    pub projection: Option<&'a JoinProjectionIndices>,
353}
354
355pub type StreamingJoinResult = (
356    Box<dyn radixdb_storage::QueryResult>,
357    CompactArc<Vec<String>>,
358);
359
360struct PreparedStreamingHashJoin {
361    operator: HashJoinOperator,
362    columns: Vec<String>,
363    started: std::time::Instant,
364    build_row_count: u64,
365    build_is_left: bool,
366    left_width: u64,
367    right_width: u64,
368}
369
370/// Publishes the same edge-level counters whether the operator is collected by
371/// the legacy binary entry point or pulled lazily by another JOIN edge.
372struct ObservedStreamingHashJoin {
373    inner: HashJoinOperator,
374    started: std::time::Instant,
375    build_row_count: u64,
376    build_is_left: bool,
377    left_width: u64,
378    right_width: u64,
379    output_width: u64,
380    output_rows: u64,
381    recorded: bool,
382}
383
384/// Edge-level instrumentation wrapper for the bounded parallel pull operator.
385struct ObservedParallelHashJoin {
386    inner: ParallelHashJoinOperator,
387    started: std::time::Instant,
388    build_row_count: u64,
389    build_is_left: bool,
390    left_width: u64,
391    right_width: u64,
392    output_width: u64,
393    output_rows: u64,
394    recorded: bool,
395}
396
397impl ObservedParallelHashJoin {
398    fn publish(&mut self) {
399        if self.recorded {
400            return;
401        }
402        let probe_rows = self.inner.observed_probe_rows();
403        let (left_rows, right_rows) = if self.build_is_left {
404            (self.build_row_count, probe_rows)
405        } else {
406            (probe_rows, self.build_row_count)
407        };
408        instrumentation::record_join_execution(
409            JoinExecutionKind::HashParallel,
410            JoinExecutionRecord {
411                left_rows,
412                right_rows,
413                output_rows: self.output_rows,
414                left_width: self.left_width,
415                right_width: self.right_width,
416                output_width: self.output_width,
417                candidate_pairs: self.inner.observed_candidate_rows(),
418                wall_nanos: self.started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
419                ..JoinExecutionRecord::default()
420            },
421        );
422        self.recorded = true;
423    }
424}
425
426impl Operator for ObservedParallelHashJoin {
427    fn open(&mut self) -> Result<()> {
428        self.inner.open()
429    }
430
431    fn next(&mut self) -> Result<Option<RowRef>> {
432        let row = self.inner.next()?;
433        if row.is_some() {
434            self.output_rows = self.output_rows.saturating_add(1);
435        }
436        Ok(row)
437    }
438
439    fn close(&mut self) -> Result<()> {
440        let result = self.inner.close();
441        self.publish();
442        result
443    }
444
445    fn schema(&self) -> &[ColumnInfo] {
446        self.inner.schema()
447    }
448
449    fn estimated_rows(&self) -> Option<usize> {
450        self.inner.estimated_rows()
451    }
452
453    fn ordering(&self) -> OrderingProperty {
454        self.inner.ordering()
455    }
456
457    fn name(&self) -> &str {
458        self.inner.name()
459    }
460}
461
462impl ObservedStreamingHashJoin {
463    fn new(plan: PreparedStreamingHashJoin) -> (Self, Vec<String>) {
464        let columns = plan.columns;
465        let output_width = columns.len() as u64;
466        (
467            Self {
468                inner: plan.operator,
469                started: plan.started,
470                build_row_count: plan.build_row_count,
471                build_is_left: plan.build_is_left,
472                left_width: plan.left_width,
473                right_width: plan.right_width,
474                output_width,
475                output_rows: 0,
476                recorded: false,
477            },
478            columns,
479        )
480    }
481
482    fn publish(&mut self) {
483        if self.recorded {
484            return;
485        }
486        let probe_rows = self.inner.observed_probe_rows();
487        let execution_kind = if self.inner.used_scan_fallback() {
488            JoinExecutionKind::NestedLoop
489        } else {
490            JoinExecutionKind::HashStreaming
491        };
492        let (left_rows, right_rows) = if self.build_is_left {
493            (self.build_row_count, probe_rows)
494        } else {
495            (probe_rows, self.build_row_count)
496        };
497        instrumentation::record_join_execution(
498            execution_kind,
499            JoinExecutionRecord {
500                left_rows,
501                right_rows,
502                output_rows: self.output_rows,
503                left_width: self.left_width,
504                right_width: self.right_width,
505                output_width: self.output_width,
506                candidate_pairs: self.inner.observed_candidate_rows(),
507                wall_nanos: self.started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
508                ..JoinExecutionRecord::default()
509            },
510        );
511        self.recorded = true;
512    }
513}
514
515impl Operator for ObservedStreamingHashJoin {
516    fn open(&mut self) -> Result<()> {
517        self.inner.open()
518    }
519
520    fn next(&mut self) -> Result<Option<RowRef>> {
521        let row = self.inner.next()?;
522        if row.is_some() {
523            self.output_rows = self.output_rows.saturating_add(1);
524        }
525        Ok(row)
526    }
527
528    fn close(&mut self) -> Result<()> {
529        let result = self.inner.close();
530        self.publish();
531        result
532    }
533
534    fn schema(&self) -> &[ColumnInfo] {
535        self.inner.schema()
536    }
537
538    fn estimated_rows(&self) -> Option<usize> {
539        self.inner.estimated_rows()
540    }
541
542    fn ordering(&self) -> OrderingProperty {
543        self.inner.ordering()
544    }
545
546    fn name(&self) -> &str {
547        self.inner.name()
548    }
549}
550
551/// Modern streaming join executor.
552///
553/// Uses Volcano-style operators for efficient join execution with:
554/// - Streaming probe side (no full materialization)
555/// - Early termination for LIMIT
556/// - Residual filter application during iteration
557pub struct JoinExecutor {}
558
559/// Build-side width sampling is intentionally bounded. The inputs have already
560/// crossed their predicate/dependency-projection boundaries, so a small sample
561/// captures the physical row shape without adding another O(N) pass before the
562/// hash build.
563const HASH_BUILD_WIDTH_SAMPLE_ROWS: usize = 256;
564
565impl JoinExecutor {
566    /// Create a new join executor.
567    pub fn new() -> Self {
568        Self {}
569    }
570
571    /// Execute a join operation.
572    ///
573    /// This is the main entry point that:
574    /// 1. Analyzes the join condition
575    /// 2. Uses provided algorithm hint or selects optimal algorithm
576    /// 3. Executes with streaming
577    /// 4. Applies early termination
578    pub fn execute(&self, request: JoinRequest<'_>) -> Result<JoinResult> {
579        let started = std::time::Instant::now();
580        let left_rows = request.left_rows.len() as u64;
581        let right_rows = request.right_rows.len() as u64;
582        let left_width = request.left_columns.len() as u64;
583        let right_width = request.right_columns.len() as u64;
584        // Build combined column list
585        let mut all_columns = request.left_columns.to_vec();
586        all_columns.extend(request.right_columns.iter().cloned());
587
588        // Analyze the join (key extraction only - sort check is deferred)
589        let analysis = self.analyze(
590            request.left_columns,
591            request.right_columns,
592            request.condition,
593            request.join_type,
594        );
595        let merge_ordering_certified = request
596            .ordering
597            .left
598            .proves_ascending_nulls_last(&analysis.left_key_indices)
599            && request
600                .ordering
601                .right
602                .proves_ascending_nulls_last(&analysis.right_key_indices);
603
604        // Select algorithm: use provided hint from QueryPlanner if available,
605        // otherwise fall back to local heuristics
606        let mut config = if let Some(hint) = request.algorithm_hint {
607            self.convert_runtime_decision(hint, &analysis)
608        } else {
609            self.select_algorithm(
610                &analysis,
611                &request.left_rows,
612                &request.right_rows,
613                merge_ordering_certified,
614            )
615        };
616
617        // MergeJoinOperator consumes equality keys only. The hash operator owns
618        // complete ON match-state, including residual predicates, so an OUTER
619        // equality edge never needs the O(NxM) nested-loop fallback.
620        if config.algorithm == RuntimeJoinAlgorithm::MergeJoin
621            && (!analysis.residual_conditions.is_empty() || !merge_ordering_certified)
622        {
623            config.algorithm = RuntimeJoinAlgorithm::HashJoin;
624            config.build_left = match analysis.join_type {
625                JoinType::Left | JoinType::Full => false,
626                JoinType::Right => true,
627                _ => left_rows <= right_rows,
628            };
629        }
630        let mut merge_memory_reservation = None;
631        if config.algorithm == RuntimeJoinAlgorithm::MergeJoin {
632            let retained_bytes = MergeJoinOperator::matching_groups_retained_bytes(
633                &request.left_rows,
634                &request.right_rows,
635                &analysis.left_key_indices,
636                &analysis.right_key_indices,
637                RetainedRowsBudget::DEFAULT_MAX_ROWS,
638                request.ctx.join_hash_state_max_bytes(),
639            )?;
640            merge_memory_reservation =
641                retained_bytes.and_then(|bytes| request.ctx.reserve_join_memory(bytes));
642            if merge_memory_reservation.is_none() {
643                config.algorithm = RuntimeJoinAlgorithm::HashJoin;
644                config.build_left = match analysis.join_type {
645                    JoinType::Left | JoinType::Full => false,
646                    JoinType::Right => true,
647                    _ => left_rows <= right_rows,
648                };
649            }
650        }
651        if config.algorithm == RuntimeJoinAlgorithm::HashJoin {
652            config.build_left = Self::choose_hash_build_side(
653                &analysis.join_type,
654                &request.left_rows,
655                &request.right_rows,
656                config.build_left,
657            );
658        }
659
660        let applied_projection = match config.algorithm {
661            RuntimeJoinAlgorithm::HashJoin | RuntimeJoinAlgorithm::NestedLoop => request.projection,
662            RuntimeJoinAlgorithm::MergeJoin => None,
663        };
664
665        // Execute join based on algorithm (takes ownership of rows)
666        let (rows, execution_kind) = match config.algorithm {
667            RuntimeJoinAlgorithm::HashJoin => self.execute_hash_join(
668                request.left_rows,
669                request.right_rows,
670                &analysis,
671                request.left_columns,
672                request.right_columns,
673                config.build_left,
674                request.limit,
675                request.ctx,
676                applied_projection,
677            )?,
678            RuntimeJoinAlgorithm::MergeJoin => {
679                let _memory_reservation = merge_memory_reservation
680                    .take()
681                    .expect("admitted merge plan must retain its memory reservation");
682                self.execute_merge_join(MergeExecutionRequest {
683                    left_rows: request.left_rows,
684                    right_rows: request.right_rows,
685                    columns: (request.left_columns, request.right_columns),
686                    analysis: &analysis,
687                    ordering: request.ordering,
688                    limit: request.limit,
689                    ctx: request.ctx,
690                })
691                .map(|rows| (JoinRows::Owned(rows), JoinExecutionKind::Merge))?
692            }
693            RuntimeJoinAlgorithm::NestedLoop => self
694                .execute_nested_loop(
695                    request.left_rows,
696                    request.right_rows,
697                    request.condition,
698                    request.left_columns,
699                    request.right_columns,
700                    &analysis.join_type_str,
701                    request.limit,
702                    applied_projection,
703                    request.ctx,
704                )
705                .map(|rows| (rows, JoinExecutionKind::NestedLoop))?,
706        };
707
708        let columns = applied_projection
709            .map(|proj| proj.output_columns.clone())
710            .unwrap_or(all_columns);
711
712        instrumentation::record_join_rows_constructed(rows.owned_len() as u64);
713        instrumentation::record_join_execution(
714            execution_kind,
715            JoinExecutionRecord {
716                left_rows,
717                right_rows,
718                output_rows: rows.len() as u64,
719                left_width,
720                right_width,
721                output_width: columns.len() as u64,
722                candidate_pairs: if execution_kind == JoinExecutionKind::NestedLoop {
723                    left_rows.saturating_mul(right_rows)
724                } else {
725                    0
726                },
727                wall_nanos: started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
728                ..JoinExecutionRecord::default()
729            },
730        );
731
732        Ok(JoinResult { rows, columns })
733    }
734
735    /// Execute a streaming hash join where probe side streams from an operator.
736    ///
737    /// This is the optimized path for LIMIT queries:
738    /// - Build side is materialized (required for hash table)
739    /// - Probe side streams row-by-row (O(1) memory)
740    /// - Early termination stops probe scan immediately when LIMIT is reached
741    ///
742    /// # Performance
743    ///
744    /// For `SELECT ... JOIN ... LIMIT 10`:
745    /// - **Old path**: Materialize 10M + 10M rows, then return 10
746    /// - **This path**: Materialize 10M rows, stream until 10 matches
747    ///
748    /// Memory usage is halved, and early termination actually stops work.
749    pub fn execute_streaming(&self, request: StreamingJoinRequest<'_>) -> Result<JoinResult> {
750        let limit = request.limit;
751        let preserve_deferred = request.projection.is_some();
752        let ctx = request.ctx;
753        let plan = self.prepare_streaming_hash_join(request)?;
754        let (mut join_op, columns) = ObservedStreamingHashJoin::new(plan);
755
756        let rows =
757            self.execute_operator_with_filter(&mut join_op, limit, &[], preserve_deferred, ctx)?;
758        instrumentation::record_join_rows_constructed(rows.owned_len() as u64);
759
760        Ok(JoinResult { rows, columns })
761    }
762
763    /// Return a pull cursor over one hash edge without collecting its output.
764    /// The cursor itself enforces LIMIT and closes the whole child pipeline on
765    /// EOF, cancellation, error, early limit, or drop.
766    pub fn execute_streaming_result(
767        &self,
768        request: StreamingJoinRequest<'_>,
769    ) -> Result<StreamingJoinResult> {
770        let limit = request.limit;
771        let cancellation = request.ctx.cancellation_handle();
772
773        // A large equality edge uses the same pull boundary as the serial hash
774        // path, but advances one bounded probe batch in parallel. No complete
775        // result or materialized probe relation is created. Residual predicates
776        // and semi/anti joins remain on the serial operator until their complete
777        // match-state is represented by this parallel cursor as well.
778        let (left_columns, right_columns) = if request.build_is_left {
779            (
780                request.build_columns.to_vec(),
781                request.probe_columns.clone(),
782            )
783        } else {
784            (
785                request.probe_columns.clone(),
786                request.build_columns.to_vec(),
787            )
788        };
789        let analysis = self.analyze(
790            &left_columns,
791            &right_columns,
792            request.condition,
793            request.join_type,
794        );
795        let config = ParallelConfig::default();
796        #[cfg(any(test, feature = "test-failpoints"))]
797        let force_serial = radixdb_storage::test_failpoints::force_serial_execution();
798        #[cfg(not(any(test, feature = "test-failpoints")))]
799        let force_serial = false;
800        #[cfg(any(test, feature = "test-failpoints"))]
801        let threshold_allows = radixdb_storage::test_failpoints::force_parallel_execution()
802            || config.should_parallel_join(request.build_rows.len());
803        #[cfg(not(any(test, feature = "test-failpoints")))]
804        let threshold_allows = config.should_parallel_join(request.build_rows.len());
805        let parallel_eligible = !force_serial
806            && request.pre_built_hash_state.is_none()
807            && threshold_allows
808            && limit.is_none_or(|limit| limit > STREAMING_LIMIT_THRESHOLD)
809            && !analysis.left_key_indices.is_empty()
810            && analysis.residual_conditions.is_empty()
811            && matches!(
812                analysis.join_type,
813                JoinType::Inner | JoinType::Left | JoinType::Right | JoinType::Full
814            );
815        if parallel_eligible {
816            let track_build_matches = analysis
817                .join_type
818                .needs_unmatched_build(request.build_is_left);
819            let state_bytes =
820                parallel_join_state_retained_bytes(request.build_rows.len(), track_build_matches)
821                    .ok_or_else(|| {
822                    radixdb_core::Error::invalid_argument(
823                        "parallel join state exceeds its addressable memory range",
824                    )
825                })?;
826            if let Some(state_reservation) = request.ctx.reserve_join_memory(state_bytes) {
827                if let Some(batch_reservation) = request
828                    .ctx
829                    .reserve_join_memory(config.join_output_batch_bytes)
830                {
831                    let build_row_count = request.build_rows.len() as u64;
832                    #[cfg(any(test, feature = "test-failpoints"))]
833                    radixdb_storage::test_failpoints::record_execution_path(9);
834                    let mut all_columns = left_columns.clone();
835                    all_columns.extend(right_columns.iter().cloned());
836                    let columns = request
837                        .projection
838                        .map(|projection| projection.output_columns.clone())
839                        .unwrap_or(all_columns);
840                    let (build_key_indices, probe_key_indices) = if request.build_is_left {
841                        (
842                            analysis.left_key_indices.clone(),
843                            analysis.right_key_indices.clone(),
844                        )
845                    } else {
846                        (
847                            analysis.right_key_indices.clone(),
848                            analysis.left_key_indices.clone(),
849                        )
850                    };
851                    let operator = ParallelHashJoinOperator::new(
852                        request.probe_source,
853                        request.build_rows,
854                        build_key_indices,
855                        probe_key_indices,
856                        analysis.join_type,
857                        request.build_is_left,
858                        left_columns.len(),
859                        right_columns.len(),
860                        columns.clone(),
861                        request
862                            .projection
863                            .map(|projection| projection.columns.clone()),
864                        config,
865                        cancellation.clone(),
866                        state_bytes,
867                        state_reservation,
868                        batch_reservation,
869                    )?;
870                    let operator = ObservedParallelHashJoin {
871                        inner: operator,
872                        started: std::time::Instant::now(),
873                        build_row_count,
874                        build_is_left: request.build_is_left,
875                        left_width: left_columns.len() as u64,
876                        right_width: right_columns.len() as u64,
877                        output_width: columns.len() as u64,
878                        output_rows: 0,
879                        recorded: false,
880                    };
881                    let columns = CompactArc::new(columns);
882                    let result = OperatorExecutorResult::open(
883                        CompactArc::clone(&columns),
884                        Box::new(operator),
885                        cancellation,
886                        limit,
887                    )?;
888                    return Ok((Box::new(result), columns));
889                }
890            }
891        }
892
893        #[cfg(any(test, feature = "test-failpoints"))]
894        radixdb_storage::test_failpoints::record_execution_path(8);
895        let plan = self.prepare_streaming_hash_join(request)?;
896        let (join_op, columns) = ObservedStreamingHashJoin::new(plan);
897        let columns = CompactArc::new(columns);
898        let result = OperatorExecutorResult::open(
899            CompactArc::clone(&columns),
900            Box::new(join_op),
901            cancellation,
902            limit,
903        )?;
904        Ok((Box::new(result), columns))
905    }
906
907    fn prepare_streaming_hash_join(
908        &self,
909        request: StreamingJoinRequest<'_>,
910    ) -> Result<PreparedStreamingHashJoin> {
911        let started = std::time::Instant::now();
912        let build_row_count = request.build_rows.len() as u64;
913        // Build combined column list based on build side position
914        let (left_columns, right_columns) = if request.build_is_left {
915            (
916                request.build_columns.to_vec(),
917                request.probe_columns.clone(),
918            )
919        } else {
920            (
921                request.probe_columns.clone(),
922                request.build_columns.to_vec(),
923            )
924        };
925
926        let mut all_columns = left_columns.clone();
927        all_columns.extend(right_columns.iter().cloned());
928
929        // Analyze the join condition
930        let analysis = self.analyze(
931            &left_columns,
932            &right_columns,
933            request.condition,
934            request.join_type,
935        );
936
937        // Probe side is already a streaming operator
938        let probe_op = request.probe_source;
939
940        let build_side = if request.build_is_left {
941            JoinSide::Left
942        } else {
943            JoinSide::Right
944        };
945
946        let build_key_indices = if request.build_is_left {
947            &analysis.left_key_indices
948        } else {
949            &analysis.right_key_indices
950        };
951        let build_batch = request.build_rows;
952        let retain_hash_for_reuse = CompactArc::strong_count(&build_batch) > 1;
953        let hash_state = match request.pre_built_hash_state {
954            Some(state) => {
955                if !state.matches(&build_batch, build_key_indices) {
956                    return Err(radixdb_core::Error::internal(
957                        "pre-built join hash state does not match build rows and keys",
958                    ));
959                }
960                Some(state)
961            }
962            None if !build_key_indices.is_empty() => request.ctx.join_hash_state_for(
963                CompactArc::clone(&build_batch),
964                build_key_indices,
965                retain_hash_for_reuse,
966            ),
967            None => None,
968        };
969
970        // Equality build rows cross this boundary exactly once. The immutable
971        // state is built directly over their CompactArc and reused for every
972        // probe row; the former path moved all rows through a second Vec before
973        // it could construct the same table.
974        let mut join_op = if let Some(hash_state) = hash_state {
975            HashJoinOperator::with_prebuilt(
976                probe_op,
977                hash_state,
978                analysis.join_type,
979                analysis.left_key_indices.clone(),
980                analysis.right_key_indices.clone(),
981                request.build_is_left,
982                request.build_columns.len(),
983            )?
984        } else {
985            // Standard path: build hash table during open()
986            let build_schema: Vec<ColumnInfo> =
987                request.build_columns.iter().map(ColumnInfo::new).collect();
988            // Unwrap CompactArc if sole owner, otherwise clone (MaterializedOperator needs Vec<Row>)
989            let build_rows_vec =
990                CompactArc::try_unwrap(build_batch).unwrap_or_else(|arc| (*arc).clone());
991            let build_op = Box::new(MaterializedOperator::new(build_rows_vec, build_schema));
992
993            let (left_op, right_op): (Box<dyn Operator>, Box<dyn Operator>) =
994                if request.build_is_left {
995                    (build_op, probe_op)
996                } else {
997                    (probe_op, build_op)
998                };
999
1000            HashJoinOperator::new(
1001                left_op,
1002                right_op,
1003                analysis.join_type,
1004                analysis.left_key_indices.clone(),
1005                analysis.right_key_indices.clone(),
1006                build_side,
1007            )
1008            // The common request owner already declined reservation. Force the
1009            // operator's O(1) scan fallback instead of spending the full limit
1010            // a second time outside request accounting.
1011            .with_hash_state_max_bytes(0)
1012        };
1013
1014        let residual_filters = analysis
1015            .residual_conditions
1016            .iter()
1017            .map(|condition| {
1018                JoinFilter::new(condition, &left_columns, &right_columns, global_registry())
1019                    .map(|filter| filter.with_context(request.ctx))
1020            })
1021            .collect::<Result<Vec<_>>>()?;
1022        join_op = join_op.with_residual_filters(residual_filters);
1023
1024        if let Some(proj) = request.projection {
1025            let projected_schema: Vec<ColumnInfo> =
1026                proj.output_columns.iter().map(ColumnInfo::new).collect();
1027            join_op = join_op.with_projection(proj.columns.clone(), projected_schema);
1028        }
1029
1030        let columns = if let Some(proj) = request.projection {
1031            proj.output_columns.clone()
1032        } else {
1033            all_columns
1034        };
1035
1036        Ok(PreparedStreamingHashJoin {
1037            operator: join_op,
1038            columns,
1039            started,
1040            build_row_count,
1041            build_is_left: request.build_is_left,
1042            left_width: left_columns.len() as u64,
1043            right_width: right_columns.len() as u64,
1044        })
1045    }
1046
1047    /// Analyze join for algorithm selection and key extraction.
1048    ///
1049    /// Ordering is not discovered here. Merge eligibility comes from an
1050    /// explicit physical property supplied with the request.
1051    fn analyze(
1052        &self,
1053        left_columns: &[String],
1054        right_columns: &[String],
1055        condition: Option<&Expression>,
1056        join_type_str: &str,
1057    ) -> JoinAnalysis {
1058        let join_type = JoinType::parse(join_type_str);
1059
1060        // Extract equality keys and residual conditions
1061        let (left_key_indices, right_key_indices, residual_conditions) =
1062            if let Some(cond) = condition {
1063                extract_join_keys_and_residual(cond, left_columns, right_columns)
1064            } else {
1065                (Vec::new(), Vec::new(), Vec::new())
1066            };
1067
1068        JoinAnalysis {
1069            left_key_indices,
1070            right_key_indices,
1071            residual_conditions,
1072            join_type,
1073            join_type_str: join_type_str.to_uppercase(),
1074        }
1075    }
1076
1077    /// Select optimal join algorithm based on analysis and cardinalities.
1078    ///
1079    /// This is the fallback algorithm selection when QueryPlanner doesn't
1080    /// provide an algorithm hint. Materialized rows are never rescanned merely
1081    /// to discover sortedness.
1082    fn select_algorithm(
1083        &self,
1084        analysis: &JoinAnalysis,
1085        left_rows: &[Row],
1086        right_rows: &[Row],
1087        merge_ordering_certified: bool,
1088    ) -> JoinConfig {
1089        let has_equality_keys = !analysis.left_key_indices.is_empty();
1090
1091        // No equality keys -> must use nested loop
1092        if !has_equality_keys {
1093            return JoinConfig {
1094                algorithm: RuntimeJoinAlgorithm::NestedLoop,
1095                build_left: false,
1096            };
1097        }
1098
1099        // Merge is valid only when both physical producers certify the exact
1100        // leading key order required by this edge.
1101        if merge_ordering_certified {
1102            return JoinConfig {
1103                algorithm: RuntimeJoinAlgorithm::MergeJoin,
1104                build_left: false,
1105            };
1106        }
1107
1108        // Use hash join with build on smaller side
1109        // Exception: OUTER joins have restrictions on build side
1110        let join_type = &analysis.join_type_str;
1111        let build_left = if join_type.contains("LEFT") || join_type.contains("FULL") {
1112            // LEFT/FULL OUTER: must build on right (left rows must be preserved)
1113            false
1114        } else if join_type.contains("RIGHT") {
1115            // RIGHT OUTER: must build on left (right rows must be preserved)
1116            true
1117        } else {
1118            // INNER/CROSS: build on smaller side
1119            left_rows.len() <= right_rows.len()
1120        };
1121
1122        JoinConfig {
1123            algorithm: RuntimeJoinAlgorithm::HashJoin,
1124            build_left,
1125        }
1126    }
1127
1128    /// Convert a RuntimeJoinDecision from QueryPlanner to JoinConfig.
1129    ///
1130    /// This bridges the gap between the QueryPlanner's cost-based decisions and
1131    /// the executor's algorithm implementation.
1132    fn convert_runtime_decision(
1133        &self,
1134        decision: &RuntimeJoinDecision,
1135        analysis: &JoinAnalysis,
1136    ) -> JoinConfig {
1137        let build_left = match decision.algorithm {
1138            RuntimeJoinAlgorithm::HashJoin => {
1139                // Use swap_sides hint from QueryPlanner, but respect OUTER join constraints
1140                let join_type = &analysis.join_type_str;
1141                if join_type.contains("LEFT") || join_type.contains("FULL") {
1142                    // LEFT/FULL OUTER: must build on right (left rows must be preserved)
1143                    false
1144                } else if join_type.contains("RIGHT") {
1145                    // RIGHT OUTER: must build on left (right rows must be preserved)
1146                    true
1147                } else {
1148                    // INNER/CROSS: use QueryPlanner's decision based on cost analysis
1149                    // swap_sides=true means swap, so if left was smaller, build_left=true normally
1150                    // QueryPlanner computes swap_sides = right < left, so:
1151                    // - swap_sides=false means left <= right, build on left
1152                    // - swap_sides=true means right < left, build on right (inverted)
1153                    !decision.swap_sides
1154                }
1155            }
1156            _ => false, // build_left not used for merge/nested loop
1157        };
1158
1159        JoinConfig {
1160            algorithm: decision.algorithm,
1161            build_left,
1162        }
1163    }
1164
1165    /// Refine a planner row-count hint using the actual post-pushdown row
1166    /// shapes that the physical hash operator will retain.
1167    ///
1168    /// OUTER sides retain their established fail-closed orientation. INNER
1169    /// joins choose the smaller estimated resident build batch, so one wide
1170    /// row does not beat several narrow rows merely because its cardinality is
1171    /// smaller. Equal estimates preserve the planner choice.
1172    fn choose_hash_build_side(
1173        join_type: &JoinType,
1174        left_rows: &[Row],
1175        right_rows: &[Row],
1176        planner_build_left: bool,
1177    ) -> bool {
1178        match join_type {
1179            JoinType::Left | JoinType::Full => return false,
1180            JoinType::Right => return true,
1181            _ => {}
1182        }
1183
1184        let left_bytes = Self::estimate_post_pushdown_bytes(left_rows);
1185        let right_bytes = Self::estimate_post_pushdown_bytes(right_rows);
1186        match left_bytes.cmp(&right_bytes) {
1187            std::cmp::Ordering::Less => true,
1188            std::cmp::Ordering::Greater => false,
1189            std::cmp::Ordering::Equal => planner_build_left,
1190        }
1191    }
1192
1193    fn estimate_post_pushdown_bytes(rows: &[Row]) -> u128 {
1194        let sampled_rows = rows.len().min(HASH_BUILD_WIDTH_SAMPLE_ROWS);
1195        if sampled_rows == 0 {
1196            return 0;
1197        }
1198        let sampled_bytes = rows.iter().take(sampled_rows).fold(0_u128, |total, row| {
1199            total.saturating_add(RetainedRowsBudget::estimate_row_bytes(row) as u128)
1200        });
1201        sampled_bytes
1202            .saturating_mul(rows.len() as u128)
1203            .div_ceil(sampled_rows as u128)
1204    }
1205
1206    /// Execute hash join using streaming HashJoinOperator or parallel execution.
1207    ///
1208    /// Chooses between:
1209    /// - **Parallel hash join**: When build side exceeds threshold (10,000 rows) and
1210    ///   LIMIT is absent or large (> 1,000). Better for bulk analytics.
1211    /// - **Streaming Volcano**: When LIMIT is small (early termination benefit) or
1212    ///   data is below parallel threshold. Better for interactive queries.
1213    #[allow(clippy::too_many_arguments)]
1214    fn execute_hash_join(
1215        &self,
1216        left_rows: CompactArc<Vec<Row>>,
1217        right_rows: CompactArc<Vec<Row>>,
1218        analysis: &JoinAnalysis,
1219        left_columns: &[String],
1220        right_columns: &[String],
1221        build_left: bool,
1222        limit: Option<u64>,
1223        ctx: &ExecutionContext,
1224        projection: Option<&JoinProjectionIndices>,
1225    ) -> Result<(JoinRows, JoinExecutionKind)> {
1226        // Use schema for column counts (not row data - handles empty tables correctly)
1227        let left_col_count = left_columns.len();
1228        let right_col_count = right_columns.len();
1229
1230        // Build combined column list for residual filter compilation
1231        let mut all_columns = left_columns.to_vec();
1232        all_columns.extend(right_columns.iter().cloned());
1233
1234        // Decide between parallel and streaming execution
1235        // Parallel is beneficial when:
1236        // 1. Build side row count exceeds threshold (parallel overhead worthwhile)
1237        // 2. No small LIMIT (streaming benefits from early termination)
1238        let build_row_count = if build_left {
1239            left_rows.len()
1240        } else {
1241            right_rows.len()
1242        };
1243        #[cfg(any(test, feature = "test-failpoints"))]
1244        let force_serial = radixdb_storage::test_failpoints::force_serial_execution();
1245        #[cfg(not(any(test, feature = "test-failpoints")))]
1246        let force_serial = false;
1247        #[cfg(any(test, feature = "test-failpoints"))]
1248        let threshold_allows = radixdb_storage::test_failpoints::force_parallel_execution()
1249            || build_row_count >= DEFAULT_PARALLEL_JOIN_THRESHOLD;
1250        #[cfg(not(any(test, feature = "test-failpoints")))]
1251        let threshold_allows = build_row_count >= DEFAULT_PARALLEL_JOIN_THRESHOLD;
1252        let parallel_eligible = !force_serial
1253            && analysis.residual_conditions.is_empty()
1254            && threshold_allows
1255            && limit.is_none_or(|l| l > STREAMING_LIMIT_THRESHOLD);
1256        let parallel_memory_reservation = parallel_eligible
1257            .then(|| {
1258                parallel_join_state_retained_bytes(
1259                    build_row_count,
1260                    analysis.join_type.needs_unmatched_build(build_left),
1261                )
1262                .and_then(|bytes| ctx.reserve_join_memory(bytes))
1263            })
1264            .flatten();
1265
1266        if let Some(_memory_reservation) = parallel_memory_reservation {
1267            #[cfg(any(test, feature = "test-failpoints"))]
1268            radixdb_storage::test_failpoints::record_execution_path(9);
1269            // Parallel execution path
1270            self.execute_hash_join_parallel(
1271                left_rows,
1272                right_rows,
1273                analysis,
1274                left_col_count,
1275                right_col_count,
1276                &all_columns,
1277                build_left,
1278                limit,
1279                projection,
1280                ctx,
1281            )
1282            .map(|rows| (JoinRows::Owned(rows), JoinExecutionKind::HashParallel))
1283        } else {
1284            #[cfg(any(test, feature = "test-failpoints"))]
1285            radixdb_storage::test_failpoints::record_execution_path(8);
1286            // Shared immutable relations (notably repeated CTE references)
1287            // retain one request-local hash state across binary edges.
1288            let build_rows_reusable = if build_left {
1289                CompactArc::strong_count(&left_rows) > 1
1290            } else {
1291                CompactArc::strong_count(&right_rows) > 1
1292            };
1293            let prebuilt_hash_state = if build_left {
1294                ctx.join_hash_state_for(
1295                    CompactArc::clone(&left_rows),
1296                    &analysis.left_key_indices,
1297                    build_rows_reusable,
1298                )
1299            } else {
1300                ctx.join_hash_state_for(
1301                    CompactArc::clone(&right_rows),
1302                    &analysis.right_key_indices,
1303                    build_rows_reusable,
1304                )
1305            };
1306            // Streaming Volcano execution path
1307            self.execute_hash_join_streaming(
1308                left_rows,
1309                right_rows,
1310                analysis,
1311                left_columns,
1312                right_columns,
1313                build_left,
1314                limit,
1315                ctx,
1316                projection,
1317                prebuilt_hash_state,
1318            )
1319            .map(|(rows, fallback)| {
1320                let kind = if fallback {
1321                    JoinExecutionKind::NestedLoop
1322                } else {
1323                    JoinExecutionKind::HashStreaming
1324                };
1325                (rows, kind)
1326            })
1327        }
1328    }
1329
1330    /// Execute hash join using a fixed-width atomic table + Rayon.
1331    ///
1332    /// Uses parallel hash build and probe phases for bulk analytics workloads.
1333    /// Better for large datasets without small LIMIT constraints.
1334    #[allow(clippy::too_many_arguments)]
1335    fn execute_hash_join_parallel(
1336        &self,
1337        left_rows: CompactArc<Vec<Row>>,
1338        right_rows: CompactArc<Vec<Row>>,
1339        analysis: &JoinAnalysis,
1340        left_col_count: usize,
1341        right_col_count: usize,
1342        all_columns: &[String],
1343        build_left: bool,
1344        limit: Option<u64>,
1345        projection: Option<&JoinProjectionIndices>,
1346        ctx: &ExecutionContext,
1347    ) -> Result<RowVec> {
1348        let config = ParallelConfig::default();
1349
1350        // Determine probe and build sides - use Arc slices directly (zero-copy)
1351        let (probe_slice, build_slice, probe_key_indices, build_key_indices, swapped) =
1352            if build_left {
1353                // Build on left: probe is right, build is left
1354                (
1355                    right_rows.as_slice(),
1356                    left_rows.as_slice(),
1357                    &analysis.right_key_indices,
1358                    &analysis.left_key_indices,
1359                    true, // swapped: left is build, right is probe
1360                )
1361            } else {
1362                // Build on right (default): probe is left, build is right
1363                (
1364                    left_rows.as_slice(),
1365                    right_rows.as_slice(),
1366                    &analysis.left_key_indices,
1367                    &analysis.right_key_indices,
1368                    false, // not swapped: left is probe, right is build
1369                )
1370            };
1371
1372        let (probe_col_count, build_col_count) = if swapped {
1373            (right_col_count, left_col_count)
1374        } else {
1375            (left_col_count, right_col_count)
1376        };
1377
1378        // Execute parallel hash join
1379        let cancellation = ctx.cancellation_handle();
1380        let result = parallel_hash_join_cancellable(
1381            probe_slice,
1382            build_slice,
1383            probe_key_indices,
1384            build_key_indices,
1385            analysis.join_type,
1386            probe_col_count,
1387            build_col_count,
1388            swapped,
1389            projection.map(|projection| projection.columns.as_slice()),
1390            &config,
1391            &cancellation,
1392            ctx.join_hash_state_max_bytes(),
1393        )?;
1394
1395        // Wrap with synthetic row IDs for join results
1396        let mut rows: RowVec = result
1397            .rows
1398            .into_iter()
1399            .enumerate()
1400            .map(|(i, row)| (i as i64, row))
1401            .collect();
1402
1403        // Apply residual conditions FIRST (before LIMIT)
1404        // This ensures correct semantics: filter matching rows, then limit
1405        let is_inner = !analysis.join_type_str.contains("LEFT")
1406            && !analysis.join_type_str.contains("RIGHT")
1407            && !analysis.join_type_str.contains("FULL");
1408
1409        if !analysis.residual_conditions.is_empty() {
1410            if is_inner {
1411                // For INNER joins, simply filter rows
1412                for cond in &analysis.residual_conditions {
1413                    let filter = RowFilter::new(cond, all_columns)?.with_context(ctx);
1414                    filter.retain_checked(&mut rows)?;
1415                }
1416            } else {
1417                // For OUTER joins, need special NULL-padding handling
1418                rows = self.apply_residual_post_join(
1419                    rows,
1420                    &analysis.residual_conditions,
1421                    all_columns,
1422                    &analysis.join_type_str,
1423                    left_col_count,
1424                    right_col_count,
1425                    ctx,
1426                )?;
1427            }
1428        }
1429
1430        // Apply LIMIT after filtering (correct order)
1431        if let Some(max) = limit {
1432            rows.truncate(max as usize);
1433        }
1434
1435        Ok(rows)
1436    }
1437
1438    /// Execute hash join using streaming Volcano-style operators.
1439    ///
1440    /// Uses iterator-based execution for low latency to first row and
1441    /// early termination with LIMIT.
1442    #[allow(clippy::too_many_arguments)]
1443    fn execute_hash_join_streaming(
1444        &self,
1445        left_rows: CompactArc<Vec<Row>>,
1446        right_rows: CompactArc<Vec<Row>>,
1447        analysis: &JoinAnalysis,
1448        left_columns: &[String],
1449        right_columns: &[String],
1450        build_left: bool,
1451        limit: Option<u64>,
1452        ctx: &ExecutionContext,
1453        projection: Option<&JoinProjectionIndices>,
1454        prebuilt_hash_state: Option<JoinHashState>,
1455    ) -> Result<(JoinRows, bool)> {
1456        // Build schema for operators from column names
1457        let left_schema: Vec<ColumnInfo> = left_columns.iter().map(ColumnInfo::new).collect();
1458        let right_schema: Vec<ColumnInfo> = right_columns.iter().map(ColumnInfo::new).collect();
1459
1460        let mut join_op = if let Some(hash_state) = prebuilt_hash_state {
1461            let (probe_rows, probe_schema, build_col_count) = if build_left {
1462                (right_rows, right_schema, left_columns.len())
1463            } else {
1464                (left_rows, left_schema, right_columns.len())
1465            };
1466            HashJoinOperator::with_prebuilt(
1467                Box::new(MaterializedOperator::from_arc(probe_rows, probe_schema)),
1468                hash_state,
1469                analysis.join_type,
1470                analysis.left_key_indices.clone(),
1471                analysis.right_key_indices.clone(),
1472                build_left,
1473                build_col_count,
1474            )?
1475        } else {
1476            // Create input operators from Arc (unwraps if sole owner, clones if shared).
1477            let left_op = Box::new(MaterializedOperator::from_arc(left_rows, left_schema));
1478            let right_op = Box::new(MaterializedOperator::from_arc(right_rows, right_schema));
1479            let build_side = if build_left {
1480                JoinSide::Left
1481            } else {
1482                JoinSide::Right
1483            };
1484            HashJoinOperator::new(
1485                left_op,
1486                right_op,
1487                analysis.join_type,
1488                analysis.left_key_indices.clone(),
1489                analysis.right_key_indices.clone(),
1490                build_side,
1491            )
1492            // `prebuilt_hash_state == None` means the common request owner did
1493            // not admit another hash allocation. Preserve correctness through
1494            // the bounded scan fallback.
1495            .with_hash_state_max_bytes(0)
1496        };
1497
1498        let residual_filters = analysis
1499            .residual_conditions
1500            .iter()
1501            .map(|condition| {
1502                JoinFilter::new(condition, left_columns, right_columns, global_registry())
1503                    .map(|filter| filter.with_context(ctx))
1504            })
1505            .collect::<Result<Vec<_>>>()?;
1506        join_op = join_op.with_residual_filters(residual_filters);
1507
1508        if let Some(proj) = projection {
1509            let projected_schema: Vec<ColumnInfo> =
1510                proj.output_columns.iter().map(ColumnInfo::new).collect();
1511            join_op = join_op.with_projection(proj.columns.clone(), projected_schema);
1512        }
1513
1514        // Execute with Volcano model
1515        let rows =
1516            self.execute_operator_with_filter(&mut join_op, limit, &[], projection.is_some(), ctx)?;
1517        Ok((rows, join_op.used_scan_fallback()))
1518    }
1519
1520    /// Execute merge join for pre-sorted inputs using MergeJoinOperator.
1521    fn execute_merge_join(&self, request: MergeExecutionRequest<'_>) -> Result<RowVec> {
1522        let (left_columns, right_columns) = request.columns;
1523        // Build schema for operators
1524        let left_schema: Vec<ColumnInfo> = left_columns.iter().map(ColumnInfo::new).collect();
1525        let right_schema: Vec<ColumnInfo> = right_columns.iter().map(ColumnInfo::new).collect();
1526
1527        // Unwrap CompactArc if sole owner, otherwise clone (MaterializedOperator needs Vec<Row>)
1528        let left_vec =
1529            CompactArc::try_unwrap(request.left_rows).unwrap_or_else(|arc| (*arc).clone());
1530        let right_vec =
1531            CompactArc::try_unwrap(request.right_rows).unwrap_or_else(|arc| (*arc).clone());
1532
1533        // Create input operators - takes ownership, no clone
1534        let left_op = Box::new(
1535            MaterializedOperator::new(left_vec, left_schema).with_ordering(request.ordering.left),
1536        );
1537        let right_op = Box::new(
1538            MaterializedOperator::new(right_vec, right_schema)
1539                .with_ordering(request.ordering.right),
1540        );
1541
1542        // Create merge join operator
1543        let mut merge_op = MergeJoinOperator::new(
1544            left_op,
1545            right_op,
1546            request.analysis.join_type,
1547            request.analysis.left_key_indices.clone(),
1548            request.analysis.right_key_indices.clone(),
1549        )
1550        .with_group_budget(
1551            RetainedRowsBudget::DEFAULT_MAX_ROWS,
1552            request.ctx.join_hash_state_max_bytes(),
1553        );
1554
1555        // Execute with Volcano model (no residual filters for merge join currently)
1556        self.execute_operator_with_filter(&mut merge_op, request.limit, &[], false, request.ctx)
1557            .map(JoinRows::into_owned)
1558    }
1559
1560    /// Execute nested loop join using NestedLoopJoinOperator.
1561    #[allow(clippy::too_many_arguments)]
1562    fn execute_nested_loop(
1563        &self,
1564        left_rows: CompactArc<Vec<Row>>,
1565        right_rows: CompactArc<Vec<Row>>,
1566        condition: Option<&Expression>,
1567        left_columns: &[String],
1568        right_columns: &[String],
1569        join_type_str: &str,
1570        limit: Option<u64>,
1571        projection: Option<&JoinProjectionIndices>,
1572        ctx: &ExecutionContext,
1573    ) -> Result<JoinRows> {
1574        // Build schema for operators
1575        let left_schema: Vec<ColumnInfo> = left_columns.iter().map(ColumnInfo::new).collect();
1576        let right_schema: Vec<ColumnInfo> = right_columns.iter().map(ColumnInfo::new).collect();
1577
1578        // Unwrap CompactArc if sole owner, otherwise clone (MaterializedOperator needs Vec<Row>)
1579        let left_vec = CompactArc::try_unwrap(left_rows).unwrap_or_else(|arc| (*arc).clone());
1580        let right_vec = CompactArc::try_unwrap(right_rows).unwrap_or_else(|arc| (*arc).clone());
1581
1582        // Create input operators - takes ownership, no clone
1583        let left_op = Box::new(MaterializedOperator::new(left_vec, left_schema));
1584        let right_op = Box::new(MaterializedOperator::new(right_vec, right_schema));
1585
1586        // Convert join type string to enum
1587        let join_type = if join_type_str.contains("CROSS") {
1588            JoinType::Cross
1589        } else if join_type_str.contains("FULL") {
1590            JoinType::Full
1591        } else if join_type_str.contains("RIGHT") {
1592            JoinType::Right
1593        } else if join_type_str.contains("LEFT") {
1594            JoinType::Left
1595        } else {
1596            JoinType::Inner
1597        };
1598
1599        // Create nested loop join operator
1600        let mut nl_op =
1601            NestedLoopJoinOperator::new(left_op, right_op, join_type, condition.cloned());
1602        if let Some(proj) = projection {
1603            let projected_schema: Vec<ColumnInfo> =
1604                proj.output_columns.iter().map(ColumnInfo::new).collect();
1605            nl_op = nl_op.with_projection(proj.columns.clone(), projected_schema);
1606        }
1607
1608        // Execute with Volcano model
1609        self.execute_operator_with_filter(&mut nl_op, limit, &[], projection.is_some(), ctx)
1610    }
1611
1612    /// Execute operator with Volcano model and optional residual filter.
1613    fn execute_operator_with_filter(
1614        &self,
1615        op: &mut dyn Operator,
1616        limit: Option<u64>,
1617        residual_filters: &[RowFilter],
1618        preserve_deferred: bool,
1619        ctx: &ExecutionContext,
1620    ) -> Result<JoinRows> {
1621        ctx.check_cancelled()?;
1622        if let Err(error) = op.open() {
1623            let _ = op.close();
1624            return Err(error);
1625        }
1626        let execution_result = (|| {
1627            let max_rows = limit.map(|l| l as usize).unwrap_or(usize::MAX);
1628            let mut rows = RowVec::with_capacity(max_rows.min(1000));
1629            let mut deferred_rows =
1630                preserve_deferred.then(|| Vec::with_capacity(max_rows.min(1000)));
1631            let mut row_id = 0i64;
1632            let mut visited_rows = 0_usize;
1633            let has_filters = !residual_filters.is_empty();
1634
1635            loop {
1636                if visited_rows & 0xff == 0 {
1637                    ctx.check_cancelled()?;
1638                }
1639                let Some(row_ref) = op.next()? else {
1640                    break;
1641                };
1642                visited_rows = visited_rows.saturating_add(1);
1643                // Apply residual filters - specialized unrolling for common cases (1-4 filters)
1644                if has_filters {
1645                    let pass = match residual_filters.len() {
1646                        1 => residual_filters[0].matches_row_ref_checked(&row_ref)?,
1647                        2 => {
1648                            residual_filters[0].matches_row_ref_checked(&row_ref)?
1649                                && residual_filters[1].matches_row_ref_checked(&row_ref)?
1650                        }
1651                        3 => {
1652                            residual_filters[0].matches_row_ref_checked(&row_ref)?
1653                                && residual_filters[1].matches_row_ref_checked(&row_ref)?
1654                                && residual_filters[2].matches_row_ref_checked(&row_ref)?
1655                        }
1656                        4 => {
1657                            residual_filters[0].matches_row_ref_checked(&row_ref)?
1658                                && residual_filters[1].matches_row_ref_checked(&row_ref)?
1659                                && residual_filters[2].matches_row_ref_checked(&row_ref)?
1660                                && residual_filters[3].matches_row_ref_checked(&row_ref)?
1661                        }
1662                        _ => {
1663                            let mut all_pass = true;
1664                            for filter in residual_filters {
1665                                if !filter.matches_row_ref_checked(&row_ref)? {
1666                                    all_pass = false;
1667                                    break;
1668                                }
1669                            }
1670                            all_pass
1671                        }
1672                    };
1673                    if !pass {
1674                        continue;
1675                    }
1676                }
1677
1678                if let Some(deferred_rows) = deferred_rows.as_mut() {
1679                    deferred_rows.push(row_ref.into_deferred());
1680                    if deferred_rows.len() >= max_rows {
1681                        break;
1682                    }
1683                    continue;
1684                }
1685
1686                let row = row_ref.into_owned();
1687
1688                rows.push((row_id, row));
1689                row_id += 1;
1690
1691                // Early termination
1692                if rows.len() >= max_rows {
1693                    break;
1694                }
1695            }
1696
1697            Ok(deferred_rows.map_or(JoinRows::Owned(rows), JoinRows::Deferred))
1698        })();
1699        let close_result = op.close();
1700        match (execution_result, close_result) {
1701            (Ok(rows), Ok(())) => Ok(rows),
1702            (Err(error), _) | (Ok(_), Err(error)) => Err(error),
1703        }
1704    }
1705
1706    /// Apply residual conditions for OUTER joins.
1707    ///
1708    /// For OUTER joins, residual conditions need special handling:
1709    /// matched rows that fail residual should produce NULL-padded output.
1710    #[allow(clippy::too_many_arguments)]
1711    fn apply_residual_post_join(
1712        &self,
1713        mut rows: RowVec,
1714        residual: &[Expression],
1715        all_columns: &[String],
1716        join_type: &str,
1717        left_col_count: usize,
1718        right_col_count: usize,
1719        ctx: &ExecutionContext,
1720    ) -> Result<RowVec> {
1721        let is_left_outer = join_type.contains("LEFT");
1722        let is_right_outer = join_type.contains("RIGHT");
1723        let is_full_outer = join_type.contains("FULL");
1724
1725        for cond in residual {
1726            let filter = RowFilter::new(cond, all_columns)?.with_context(ctx);
1727
1728            if is_left_outer || is_right_outer || is_full_outer {
1729                // For OUTER joins, replace non-matching rows with NULL-padded versions
1730                let mut new_rows = RowVec::with_capacity(rows.len());
1731                for (row_id, row) in rows {
1732                    if filter.matches_checked(&row)? {
1733                        new_rows.push((row_id, row));
1734                    } else {
1735                        // Convert to NULL-padded row
1736                        if is_left_outer {
1737                            // Keep left, NULL right
1738                            let mut new_values: CompactVec<Value> =
1739                                CompactVec::with_capacity(left_col_count + right_col_count);
1740                            new_values.extend(row.iter().take(left_col_count).cloned());
1741                            new_values.extend(std::iter::repeat_n(NULL_VALUE, right_col_count));
1742                            new_rows.push((row_id, Row::from_compact_vec(new_values)));
1743                        } else if is_right_outer {
1744                            // NULL left, keep right
1745                            let mut new_values: CompactVec<Value> =
1746                                CompactVec::with_capacity(left_col_count + right_col_count);
1747                            new_values.extend(std::iter::repeat_n(NULL_VALUE, left_col_count));
1748                            new_values.extend(row.iter().skip(left_col_count).cloned());
1749                            new_rows.push((row_id, Row::from_compact_vec(new_values)));
1750                        } else {
1751                            // FULL OUTER - keep original for now
1752                            new_rows.push((row_id, row));
1753                        }
1754                    }
1755                }
1756                rows = new_rows;
1757            } else {
1758                // INNER join - just filter
1759                filter.retain_checked(&mut rows)?;
1760            }
1761        }
1762
1763        Ok(rows)
1764    }
1765}
1766
1767impl Default for JoinExecutor {
1768    fn default() -> Self {
1769        Self::new()
1770    }
1771}
1772
1773#[cfg(test)]
1774mod tests {
1775    use super::*;
1776    use crate::operator::{ColumnSource, MaterializedOperator};
1777    use std::sync::atomic::{AtomicUsize, Ordering};
1778    use std::sync::Arc;
1779
1780    struct CountingOperator {
1781        rows: Vec<Row>,
1782        schema: Vec<ColumnInfo>,
1783        next_row: usize,
1784        next_calls: Arc<AtomicUsize>,
1785        opened: bool,
1786    }
1787
1788    impl CountingOperator {
1789        fn new(rows: Vec<Row>, columns: &[String], next_calls: Arc<AtomicUsize>) -> Self {
1790            Self {
1791                rows,
1792                schema: columns.iter().map(ColumnInfo::new).collect(),
1793                next_row: 0,
1794                next_calls,
1795                opened: false,
1796            }
1797        }
1798    }
1799
1800    impl Operator for CountingOperator {
1801        fn open(&mut self) -> Result<()> {
1802            self.opened = true;
1803            Ok(())
1804        }
1805
1806        fn next(&mut self) -> Result<Option<RowRef>> {
1807            assert!(self.opened);
1808            self.next_calls.fetch_add(1, Ordering::Relaxed);
1809            let Some(row) = self.rows.get(self.next_row).cloned() else {
1810                return Ok(None);
1811            };
1812            self.next_row += 1;
1813            Ok(Some(RowRef::owned(row)))
1814        }
1815
1816        fn close(&mut self) -> Result<()> {
1817            self.opened = false;
1818            Ok(())
1819        }
1820
1821        fn schema(&self) -> &[ColumnInfo] {
1822            &self.schema
1823        }
1824
1825        fn estimated_rows(&self) -> Option<usize> {
1826            Some(self.rows.len())
1827        }
1828
1829        fn name(&self) -> &str {
1830            "Counting"
1831        }
1832    }
1833
1834    fn make_rows(data: Vec<Vec<i64>>) -> Vec<Row> {
1835        data.into_iter()
1836            .map(|vals| Row::from_values(vals.into_iter().map(Value::integer).collect()))
1837            .collect()
1838    }
1839
1840    #[test]
1841    fn streaming_result_does_not_collect_probe_before_downstream_pull() {
1842        use radixdb_sql::ast::{Identifier, InfixExpression};
1843        use radixdb_sql::token::{Position, Token, TokenType};
1844
1845        let executor = JoinExecutor::new();
1846        let ctx = ExecutionContext::new();
1847        let left_columns = vec!["a.id".to_string(), "a.value".to_string()];
1848        let right_columns = vec!["b.id".to_string(), "b.value".to_string()];
1849        let condition = Expression::Infix(InfixExpression::new(
1850            Token::new(TokenType::Operator, "=", Position::default()),
1851            Box::new(Expression::Identifier(Identifier::new(
1852                Token::new(TokenType::Identifier, "a.id", Position::default()),
1853                "a.id".to_string(),
1854            ))),
1855            "=".to_string(),
1856            Box::new(Expression::Identifier(Identifier::new(
1857                Token::new(TokenType::Identifier, "b.id", Position::default()),
1858                "b.id".to_string(),
1859            ))),
1860        ));
1861        let projection = JoinProjectionIndices {
1862            columns: vec![ColumnSource::Outer(1), ColumnSource::Inner(1)],
1863            output_columns: vec!["value".to_string(), "dictionary".to_string()],
1864        };
1865        let next_calls = Arc::new(AtomicUsize::new(0));
1866
1867        radixdb_storage::instrumentation::begin_join_execution_probe();
1868        let (mut result, columns) = executor
1869            .execute_streaming_result(StreamingJoinRequest {
1870                build_rows: CompactArc::new(make_rows(vec![vec![1, 100], vec![2, 200]])),
1871                build_columns: &right_columns,
1872                probe_source: Box::new(CountingOperator::new(
1873                    make_rows(vec![vec![1, 10], vec![2, 20]]),
1874                    &left_columns,
1875                    Arc::clone(&next_calls),
1876                )),
1877                probe_columns: left_columns,
1878                condition: Some(&condition),
1879                join_type: "INNER",
1880                build_is_left: false,
1881                limit: None,
1882                ctx: &ctx,
1883                pre_built_hash_state: None,
1884                projection: Some(&projection),
1885            })
1886            .unwrap();
1887
1888        assert_eq!(&*columns, &["value".to_string(), "dictionary".to_string()]);
1889        assert_eq!(next_calls.load(Ordering::Relaxed), 0);
1890        assert!(result.next());
1891        assert_eq!(next_calls.load(Ordering::Relaxed), 1);
1892        let row = result.take_deferred_row();
1893        assert!(row.is_deferred());
1894        assert_eq!(row.get(0), Some(&Value::integer(10)));
1895        assert_eq!(row.get(1), Some(&Value::integer(100)));
1896        drop(result);
1897        assert_eq!(ctx.retained_join_memory_bytes(), 0);
1898        let probe = radixdb_storage::instrumentation::end_join_execution_probe();
1899        assert_eq!(probe.hash_streaming_calls, 1);
1900        assert_eq!(probe.output_rows, 1);
1901    }
1902
1903    #[test]
1904    fn hash_build_side_uses_bounded_post_pushdown_bytes_not_only_row_count() {
1905        let wide_left = vec![Row::from_values(vec![
1906            Value::integer(1),
1907            Value::text("x".repeat(32 * 1024)),
1908        ])];
1909        let narrow_right = make_rows(vec![vec![1], vec![2], vec![3], vec![4]]);
1910
1911        assert!(!JoinExecutor::choose_hash_build_side(
1912            &JoinType::Inner,
1913            &wide_left,
1914            &narrow_right,
1915            true,
1916        ));
1917
1918        let narrow_left = make_rows(vec![vec![1], vec![2]]);
1919        let wide_right = vec![Row::from_values(vec![
1920            Value::integer(1),
1921            Value::text("y".repeat(32 * 1024)),
1922        ])];
1923        assert!(JoinExecutor::choose_hash_build_side(
1924            &JoinType::Inner,
1925            &narrow_left,
1926            &wide_right,
1927            false,
1928        ));
1929    }
1930
1931    #[test]
1932    fn hash_build_side_keeps_outer_orientation() {
1933        let wide = vec![Row::from_values(vec![Value::text("z".repeat(32 * 1024))])];
1934        let narrow = make_rows(vec![vec![1]]);
1935
1936        assert!(!JoinExecutor::choose_hash_build_side(
1937            &JoinType::Left,
1938            &wide,
1939            &narrow,
1940            true,
1941        ));
1942        assert!(JoinExecutor::choose_hash_build_side(
1943            &JoinType::Right,
1944            &narrow,
1945            &wide,
1946            false,
1947        ));
1948        assert!(!JoinExecutor::choose_hash_build_side(
1949            &JoinType::Full,
1950            &wide,
1951            &narrow,
1952            true,
1953        ));
1954    }
1955
1956    #[test]
1957    fn test_inner_join() {
1958        let executor = JoinExecutor::new();
1959        let ctx = ExecutionContext::new();
1960
1961        let left = make_rows(vec![vec![1, 10], vec![2, 20], vec![3, 30]]);
1962        let right = make_rows(vec![vec![1, 100], vec![3, 300]]);
1963
1964        let left_cols = vec!["a.id".to_string(), "a.val".to_string()];
1965        let right_cols = vec!["b.id".to_string(), "b.data".to_string()];
1966
1967        // Create equality condition: a.id = b.id
1968        use radixdb_sql::ast::{Identifier, InfixExpression};
1969        use radixdb_sql::token::{Position, Token, TokenType};
1970
1971        let cond = Expression::Infix(InfixExpression::new(
1972            Token::new(TokenType::Operator, "=", Position::default()),
1973            Box::new(Expression::Identifier(Identifier::new(
1974                Token::new(TokenType::Identifier, "a.id", Position::default()),
1975                "a.id".to_string(),
1976            ))),
1977            "=".to_string(),
1978            Box::new(Expression::Identifier(Identifier::new(
1979                Token::new(TokenType::Identifier, "b.id", Position::default()),
1980                "b.id".to_string(),
1981            ))),
1982        ));
1983
1984        let request = JoinRequest {
1985            left_rows: CompactArc::new(left),
1986            right_rows: CompactArc::new(right),
1987            left_columns: &left_cols,
1988            right_columns: &right_cols,
1989            condition: Some(&cond),
1990            join_type: "INNER",
1991            limit: None,
1992            ctx: &ctx,
1993            algorithm_hint: None,
1994            ordering: JoinInputOrderings::default(),
1995            projection: None,
1996        };
1997
1998        let before = radixdb_storage::instrumentation::snapshot();
1999        let result = executor.execute(request).unwrap();
2000        let after = radixdb_storage::instrumentation::snapshot();
2001
2002        assert_eq!(result.rows.len(), 2);
2003        assert_eq!(result.columns.len(), 4);
2004        assert!(
2005            after.join_rows_constructed >= before.join_rows_constructed.saturating_add(2),
2006            "materialized join rows must reach the engine instrumentation owner"
2007        );
2008        assert!(
2009            after.join_operator_calls >= before.join_operator_calls.saturating_add(1),
2010            "one physical JOIN execution must be published"
2011        );
2012        assert!(after.join_left_input_rows >= before.join_left_input_rows.saturating_add(3));
2013        assert!(after.join_right_input_rows >= before.join_right_input_rows.saturating_add(2));
2014        assert!(after.join_output_rows >= before.join_output_rows.saturating_add(2));
2015        assert!(after.join_max_output_width >= 4);
2016    }
2017
2018    #[test]
2019    fn merge_requires_physical_ordering_certificates_without_row_rescan() {
2020        use radixdb_sql::ast::{Identifier, InfixExpression};
2021        use radixdb_sql::token::{Position, Token, TokenType};
2022
2023        let executor = JoinExecutor::new();
2024        let ctx = ExecutionContext::new();
2025        let left = make_rows(vec![vec![1, 10], vec![2, 20], vec![3, 30]]);
2026        let right = make_rows(vec![vec![1, 100], vec![3, 300]]);
2027        let left_cols = vec!["a.id".to_string(), "a.val".to_string()];
2028        let right_cols = vec!["b.id".to_string(), "b.data".to_string()];
2029        let condition = Expression::Infix(InfixExpression::new(
2030            Token::new(TokenType::Operator, "=", Position::default()),
2031            Box::new(Expression::Identifier(Identifier::new(
2032                Token::new(TokenType::Identifier, "a.id", Position::default()),
2033                "a.id".to_string(),
2034            ))),
2035            "=".to_string(),
2036            Box::new(Expression::Identifier(Identifier::new(
2037                Token::new(TokenType::Identifier, "b.id", Position::default()),
2038                "b.id".to_string(),
2039            ))),
2040        ));
2041
2042        radixdb_storage::instrumentation::begin_join_execution_probe();
2043        let unknown_result = executor
2044            .execute(JoinRequest {
2045                left_rows: CompactArc::new(left.clone()),
2046                right_rows: CompactArc::new(right.clone()),
2047                left_columns: &left_cols,
2048                right_columns: &right_cols,
2049                condition: Some(&condition),
2050                join_type: "INNER",
2051                limit: None,
2052                ctx: &ctx,
2053                algorithm_hint: None,
2054                ordering: JoinInputOrderings::default(),
2055                projection: None,
2056            })
2057            .unwrap();
2058        let unknown_probe = radixdb_storage::instrumentation::end_join_execution_probe();
2059        assert_eq!(unknown_result.rows.len(), 2);
2060        assert_eq!(unknown_probe.merge_calls, 0);
2061        assert_eq!(unknown_probe.hash_streaming_calls, 1);
2062
2063        radixdb_storage::instrumentation::begin_join_execution_probe();
2064        let certified_result = executor
2065            .execute(JoinRequest {
2066                left_rows: CompactArc::new(left),
2067                right_rows: CompactArc::new(right),
2068                left_columns: &left_cols,
2069                right_columns: &right_cols,
2070                condition: Some(&condition),
2071                join_type: "INNER",
2072                limit: None,
2073                ctx: &ctx,
2074                algorithm_hint: None,
2075                ordering: JoinInputOrderings::new(
2076                    OrderingProperty::ascending_nulls_last(vec![0]),
2077                    OrderingProperty::ascending_nulls_last(vec![0]),
2078                ),
2079                projection: None,
2080            })
2081            .unwrap();
2082        let certified_probe = radixdb_storage::instrumentation::end_join_execution_probe();
2083        assert_eq!(certified_result.rows.len(), 2);
2084        assert_eq!(certified_probe.merge_calls, 1);
2085        assert_eq!(certified_probe.hash_streaming_calls, 0);
2086    }
2087
2088    #[test]
2089    fn merge_hint_cannot_bypass_missing_ordering_certificate() {
2090        use radixdb_sql::ast::{Identifier, InfixExpression};
2091        use radixdb_sql::token::{Position, Token, TokenType};
2092
2093        let executor = JoinExecutor::new();
2094        let ctx = ExecutionContext::new();
2095        let left_cols = vec!["a.id".to_string()];
2096        let right_cols = vec!["b.id".to_string()];
2097        let condition = Expression::Infix(InfixExpression::new(
2098            Token::new(TokenType::Operator, "=", Position::default()),
2099            Box::new(Expression::Identifier(Identifier::new(
2100                Token::new(TokenType::Identifier, "a.id", Position::default()),
2101                "a.id".to_string(),
2102            ))),
2103            "=".to_string(),
2104            Box::new(Expression::Identifier(Identifier::new(
2105                Token::new(TokenType::Identifier, "b.id", Position::default()),
2106                "b.id".to_string(),
2107            ))),
2108        ));
2109        let hint = RuntimeJoinDecision {
2110            algorithm: RuntimeJoinAlgorithm::MergeJoin,
2111            swap_sides: false,
2112            explanation: "test untrusted merge hint".to_string(),
2113        };
2114
2115        radixdb_storage::instrumentation::begin_join_execution_probe();
2116        executor
2117            .execute(JoinRequest {
2118                left_rows: CompactArc::new(make_rows(vec![vec![1], vec![2]])),
2119                right_rows: CompactArc::new(make_rows(vec![vec![1], vec![2]])),
2120                left_columns: &left_cols,
2121                right_columns: &right_cols,
2122                condition: Some(&condition),
2123                join_type: "INNER",
2124                limit: None,
2125                ctx: &ctx,
2126                algorithm_hint: Some(&hint),
2127                ordering: JoinInputOrderings::default(),
2128                projection: None,
2129            })
2130            .unwrap();
2131        let probe = radixdb_storage::instrumentation::end_join_execution_probe();
2132        assert_eq!(probe.merge_calls, 0);
2133        assert_eq!(probe.hash_streaming_calls, 1);
2134    }
2135
2136    #[test]
2137    fn oversized_merge_duplicate_group_falls_back_to_bounded_hash() {
2138        use radixdb_sql::ast::{Identifier, InfixExpression};
2139        use radixdb_sql::token::{Position, Token, TokenType};
2140
2141        let executor = JoinExecutor::new();
2142        let hash_budget = crate::hash_table::JoinHashTable::estimated_retained_bytes(10).unwrap();
2143        let ctx = crate::context::ExecutionContextBuilder::new()
2144            .join_hash_state_max_bytes(hash_budget)
2145            .build();
2146        let left_cols = vec!["a.id".to_string()];
2147        let right_cols = vec!["b.id".to_string()];
2148        let condition = Expression::Infix(InfixExpression::new(
2149            Token::new(TokenType::Operator, "=", Position::default()),
2150            Box::new(Expression::Identifier(Identifier::new(
2151                Token::new(TokenType::Identifier, "a.id", Position::default()),
2152                "a.id".to_string(),
2153            ))),
2154            "=".to_string(),
2155            Box::new(Expression::Identifier(Identifier::new(
2156                Token::new(TokenType::Identifier, "b.id", Position::default()),
2157                "b.id".to_string(),
2158            ))),
2159        ));
2160        let hint = RuntimeJoinDecision {
2161            algorithm: RuntimeJoinAlgorithm::MergeJoin,
2162            swap_sides: false,
2163            explanation: "test pathological duplicate group".to_string(),
2164        };
2165        let ordering = JoinInputOrderings::new(
2166            OrderingProperty::ascending_nulls_last(vec![0]),
2167            OrderingProperty::ascending_nulls_last(vec![0]),
2168        );
2169        let duplicates = || make_rows((0..10).map(|_| vec![1]).collect());
2170
2171        radixdb_storage::instrumentation::begin_join_execution_probe();
2172        let result = executor
2173            .execute(JoinRequest {
2174                left_rows: CompactArc::new(duplicates()),
2175                right_rows: CompactArc::new(duplicates()),
2176                left_columns: &left_cols,
2177                right_columns: &right_cols,
2178                condition: Some(&condition),
2179                join_type: "INNER",
2180                limit: None,
2181                ctx: &ctx,
2182                algorithm_hint: Some(&hint),
2183                ordering,
2184                projection: None,
2185            })
2186            .unwrap();
2187        let probe = radixdb_storage::instrumentation::end_join_execution_probe();
2188
2189        assert_eq!(result.rows.len(), 100);
2190        assert_eq!(probe.merge_calls, 0);
2191        assert_eq!(probe.hash_streaming_calls, 1);
2192    }
2193
2194    #[test]
2195    fn test_left_join() {
2196        let executor = JoinExecutor::new();
2197        let ctx = ExecutionContext::new();
2198
2199        let left = make_rows(vec![vec![1, 10], vec![2, 20], vec![3, 30]]);
2200        let right = make_rows(vec![vec![1, 100]]);
2201
2202        let left_cols = vec!["a.id".to_string(), "a.val".to_string()];
2203        let right_cols = vec!["b.id".to_string(), "b.data".to_string()];
2204
2205        use radixdb_sql::ast::{Identifier, InfixExpression};
2206        use radixdb_sql::token::{Position, Token, TokenType};
2207
2208        let cond = Expression::Infix(InfixExpression::new(
2209            Token::new(TokenType::Operator, "=", Position::default()),
2210            Box::new(Expression::Identifier(Identifier::new(
2211                Token::new(TokenType::Identifier, "a.id", Position::default()),
2212                "a.id".to_string(),
2213            ))),
2214            "=".to_string(),
2215            Box::new(Expression::Identifier(Identifier::new(
2216                Token::new(TokenType::Identifier, "b.id", Position::default()),
2217                "b.id".to_string(),
2218            ))),
2219        ));
2220
2221        let request = JoinRequest {
2222            left_rows: CompactArc::new(left),
2223            right_rows: CompactArc::new(right),
2224            left_columns: &left_cols,
2225            right_columns: &right_cols,
2226            condition: Some(&cond),
2227            join_type: "LEFT",
2228            limit: None,
2229            ctx: &ctx,
2230            algorithm_hint: None,
2231            ordering: JoinInputOrderings::default(),
2232            projection: None,
2233        };
2234
2235        let result = executor.execute(request).unwrap();
2236
2237        // All 3 left rows should be preserved
2238        assert_eq!(result.rows.len(), 3);
2239    }
2240
2241    #[test]
2242    fn test_early_termination() {
2243        let executor = JoinExecutor::new();
2244        let ctx = ExecutionContext::new();
2245
2246        let left = make_rows(vec![vec![1], vec![2], vec![3]]);
2247        let right = make_rows(vec![vec![1], vec![2], vec![3]]);
2248
2249        let left_cols = vec!["a.id".to_string()];
2250        let right_cols = vec!["b.id".to_string()];
2251
2252        use radixdb_sql::ast::{Identifier, InfixExpression};
2253        use radixdb_sql::token::{Position, Token, TokenType};
2254
2255        let cond = Expression::Infix(InfixExpression::new(
2256            Token::new(TokenType::Operator, "=", Position::default()),
2257            Box::new(Expression::Identifier(Identifier::new(
2258                Token::new(TokenType::Identifier, "a.id", Position::default()),
2259                "a.id".to_string(),
2260            ))),
2261            "=".to_string(),
2262            Box::new(Expression::Identifier(Identifier::new(
2263                Token::new(TokenType::Identifier, "b.id", Position::default()),
2264                "b.id".to_string(),
2265            ))),
2266        ));
2267
2268        let request = JoinRequest {
2269            left_rows: CompactArc::new(left),
2270            right_rows: CompactArc::new(right),
2271            left_columns: &left_cols,
2272            right_columns: &right_cols,
2273            condition: Some(&cond),
2274            join_type: "INNER",
2275            limit: Some(1), // Only need 1 row
2276            ctx: &ctx,
2277            algorithm_hint: None,
2278            ordering: JoinInputOrderings::default(),
2279            projection: None,
2280        };
2281
2282        let result = executor.execute(request).unwrap();
2283
2284        // Should stop after 1 row
2285        assert_eq!(result.rows.len(), 1);
2286    }
2287
2288    #[test]
2289    fn test_streaming_hash_join_applies_projection_boundary() {
2290        let executor = JoinExecutor::new();
2291        let ctx = ExecutionContext::new();
2292
2293        let left = make_rows(vec![
2294            vec![1, 10, 1000],
2295            vec![2, 20, 2000],
2296            vec![3, 30, 3000],
2297        ]);
2298        let right = make_rows(vec![vec![1, 100, 9000], vec![3, 300, 7000]]);
2299
2300        let left_cols = vec![
2301            "a.id".to_string(),
2302            "a.val".to_string(),
2303            "a.unused".to_string(),
2304        ];
2305        let right_cols = vec![
2306            "b.id".to_string(),
2307            "b.data".to_string(),
2308            "b.unused".to_string(),
2309        ];
2310
2311        use radixdb_sql::ast::{Identifier, InfixExpression};
2312        use radixdb_sql::token::{Position, Token, TokenType};
2313
2314        let cond = Expression::Infix(InfixExpression::new(
2315            Token::new(TokenType::Operator, "=", Position::default()),
2316            Box::new(Expression::Identifier(Identifier::new(
2317                Token::new(TokenType::Identifier, "a.id", Position::default()),
2318                "a.id".to_string(),
2319            ))),
2320            "=".to_string(),
2321            Box::new(Expression::Identifier(Identifier::new(
2322                Token::new(TokenType::Identifier, "b.id", Position::default()),
2323                "b.id".to_string(),
2324            ))),
2325        ));
2326
2327        let projection = JoinProjectionIndices {
2328            columns: vec![ColumnSource::Outer(1), ColumnSource::Inner(1)],
2329            output_columns: vec!["val".to_string(), "data".to_string()],
2330        };
2331
2332        let left_schema = left_cols.iter().map(ColumnInfo::new).collect();
2333        let request = StreamingJoinRequest {
2334            build_rows: CompactArc::new(right),
2335            build_columns: &right_cols,
2336            probe_source: Box::new(MaterializedOperator::new(left, left_schema)),
2337            probe_columns: left_cols.clone(),
2338            condition: Some(&cond),
2339            join_type: "INNER",
2340            build_is_left: false,
2341            limit: Some(10),
2342            ctx: &ctx,
2343            pre_built_hash_state: None,
2344            projection: Some(&projection),
2345        };
2346
2347        let result = executor.execute_streaming(request).unwrap();
2348
2349        assert_eq!(result.columns, vec!["val".to_string(), "data".to_string()]);
2350        let rows = result.rows.into_owned();
2351        assert_eq!(rows.len(), 2);
2352        assert!(rows.iter().all(|(_, row)| row.len() == 2));
2353        assert_eq!(rows[0].1.get(0), Some(&Value::integer(10)));
2354        assert_eq!(rows[0].1.get(1), Some(&Value::integer(100)));
2355        assert_eq!(rows[1].1.get(0), Some(&Value::integer(30)));
2356        assert_eq!(rows[1].1.get(1), Some(&Value::integer(300)));
2357    }
2358
2359    #[test]
2360    fn streaming_hash_state_keeps_empty_build_schema_for_left_join() {
2361        let executor = JoinExecutor::new();
2362        let ctx = ExecutionContext::new();
2363        let left = make_rows(vec![vec![1, 10], vec![2, 20]]);
2364        let right = CompactArc::new(Vec::new());
2365        let left_cols = vec!["a.id".to_string(), "a.value".to_string()];
2366        let right_cols = vec!["b.id".to_string(), "b.value".to_string()];
2367
2368        use radixdb_sql::ast::{Identifier, InfixExpression};
2369        use radixdb_sql::token::{Position, Token, TokenType};
2370        let condition = Expression::Infix(InfixExpression::new(
2371            Token::new(TokenType::Operator, "=", Position::default()),
2372            Box::new(Expression::Identifier(Identifier::new(
2373                Token::new(TokenType::Identifier, "a.id", Position::default()),
2374                "a.id".to_string(),
2375            ))),
2376            "=".to_string(),
2377            Box::new(Expression::Identifier(Identifier::new(
2378                Token::new(TokenType::Identifier, "b.id", Position::default()),
2379                "b.id".to_string(),
2380            ))),
2381        ));
2382        let left_schema = left_cols.iter().map(ColumnInfo::new).collect();
2383
2384        let result = executor
2385            .execute_streaming(StreamingJoinRequest {
2386                build_rows: right,
2387                build_columns: &right_cols,
2388                probe_source: Box::new(MaterializedOperator::new(left, left_schema)),
2389                probe_columns: left_cols,
2390                condition: Some(&condition),
2391                join_type: "LEFT",
2392                build_is_left: false,
2393                limit: None,
2394                ctx: &ctx,
2395                pre_built_hash_state: None,
2396                projection: None,
2397            })
2398            .unwrap();
2399
2400        assert_eq!(result.columns.len(), 4);
2401        let rows = result.rows.into_owned();
2402        assert_eq!(rows.len(), 2);
2403        assert!(rows.iter().all(|(_, row)| row.len() == 4));
2404        assert!(rows.iter().all(|(_, row)| row.get(2).unwrap().is_null()));
2405        assert!(rows.iter().all(|(_, row)| row.get(3).unwrap().is_null()));
2406    }
2407
2408    #[test]
2409    fn streaming_hash_state_rejects_different_physical_batch() {
2410        let executor = JoinExecutor::new();
2411        let ctx = ExecutionContext::new();
2412        let build_rows = CompactArc::new(make_rows(vec![vec![1], vec![2]]));
2413        let unrelated_rows = CompactArc::new((*build_rows).clone());
2414        let state = JoinHashState::build(build_rows, &[0]);
2415        let left_cols = vec!["a.id".to_string()];
2416        let right_cols = vec!["b.id".to_string()];
2417
2418        use radixdb_sql::ast::{Identifier, InfixExpression};
2419        use radixdb_sql::token::{Position, Token, TokenType};
2420        let condition = Expression::Infix(InfixExpression::new(
2421            Token::new(TokenType::Operator, "=", Position::default()),
2422            Box::new(Expression::Identifier(Identifier::new(
2423                Token::new(TokenType::Identifier, "a.id", Position::default()),
2424                "a.id".to_string(),
2425            ))),
2426            "=".to_string(),
2427            Box::new(Expression::Identifier(Identifier::new(
2428                Token::new(TokenType::Identifier, "b.id", Position::default()),
2429                "b.id".to_string(),
2430            ))),
2431        ));
2432        let left_schema = left_cols.iter().map(ColumnInfo::new).collect();
2433
2434        let error = executor
2435            .execute_streaming(StreamingJoinRequest {
2436                build_rows: unrelated_rows,
2437                build_columns: &right_cols,
2438                probe_source: Box::new(MaterializedOperator::new(
2439                    make_rows(vec![vec![1]]),
2440                    left_schema,
2441                )),
2442                probe_columns: left_cols,
2443                condition: Some(&condition),
2444                join_type: "INNER",
2445                build_is_left: false,
2446                limit: None,
2447                ctx: &ctx,
2448                pre_built_hash_state: Some(state),
2449                projection: None,
2450            })
2451            .unwrap_err();
2452
2453        assert!(error
2454            .to_string()
2455            .contains("does not match build rows and keys"));
2456    }
2457
2458    #[test]
2459    fn request_local_hash_state_is_built_once_for_one_shared_relation() {
2460        let executor = JoinExecutor::new();
2461        let ctx = ExecutionContext::new();
2462        let shared_build = CompactArc::new(make_rows(vec![vec![1, 100], vec![2, 200]]));
2463        let left_cols = vec!["a.id".to_string()];
2464        let right_cols = vec!["b.id".to_string(), "b.value".to_string()];
2465
2466        use radixdb_sql::ast::{Identifier, InfixExpression};
2467        use radixdb_sql::token::{Position, Token, TokenType};
2468        let condition = Expression::Infix(InfixExpression::new(
2469            Token::new(TokenType::Operator, "=", Position::default()),
2470            Box::new(Expression::Identifier(Identifier::new(
2471                Token::new(TokenType::Identifier, "a.id", Position::default()),
2472                "a.id".to_string(),
2473            ))),
2474            "=".to_string(),
2475            Box::new(Expression::Identifier(Identifier::new(
2476                Token::new(TokenType::Identifier, "b.id", Position::default()),
2477                "b.id".to_string(),
2478            ))),
2479        ));
2480
2481        radixdb_storage::instrumentation::begin_join_execution_probe();
2482        for _ in 0..2 {
2483            let result = executor
2484                .execute_streaming(StreamingJoinRequest {
2485                    build_rows: CompactArc::clone(&shared_build),
2486                    build_columns: &right_cols,
2487                    probe_source: Box::new(MaterializedOperator::new(
2488                        make_rows(vec![vec![1], vec![2]]),
2489                        left_cols.iter().map(ColumnInfo::new).collect(),
2490                    )),
2491                    probe_columns: left_cols.clone(),
2492                    condition: Some(&condition),
2493                    join_type: "INNER",
2494                    build_is_left: false,
2495                    limit: None,
2496                    ctx: &ctx,
2497                    pre_built_hash_state: None,
2498                    projection: None,
2499                })
2500                .unwrap();
2501            assert_eq!(result.rows.len(), 2);
2502        }
2503        let probe = radixdb_storage::instrumentation::end_join_execution_probe();
2504        assert_eq!(probe.hash_state_builds, 1);
2505        assert_eq!(probe.hash_state_reuses, 1);
2506    }
2507
2508    #[test]
2509    fn streaming_hash_budget_falls_back_without_changing_left_join_results() {
2510        let executor = JoinExecutor::new();
2511        let ctx = crate::context::ExecutionContextBuilder::new()
2512            .join_hash_state_max_bytes(0)
2513            .build();
2514        let left = make_rows(vec![vec![1, 10], vec![2, 20], vec![3, 30]]);
2515        let right = CompactArc::new(make_rows(vec![vec![1, 100], vec![3, 300]]));
2516        let left_cols = vec!["a.id".to_string(), "a.value".to_string()];
2517        let right_cols = vec!["b.id".to_string(), "b.value".to_string()];
2518
2519        use radixdb_sql::ast::{Identifier, InfixExpression};
2520        use radixdb_sql::token::{Position, Token, TokenType};
2521        let condition = Expression::Infix(InfixExpression::new(
2522            Token::new(TokenType::Operator, "=", Position::default()),
2523            Box::new(Expression::Identifier(Identifier::new(
2524                Token::new(TokenType::Identifier, "a.id", Position::default()),
2525                "a.id".to_string(),
2526            ))),
2527            "=".to_string(),
2528            Box::new(Expression::Identifier(Identifier::new(
2529                Token::new(TokenType::Identifier, "b.id", Position::default()),
2530                "b.id".to_string(),
2531            ))),
2532        ));
2533        let left_schema = left_cols.iter().map(ColumnInfo::new).collect();
2534
2535        radixdb_storage::instrumentation::begin_join_execution_probe();
2536        let result = executor
2537            .execute_streaming(StreamingJoinRequest {
2538                build_rows: right,
2539                build_columns: &right_cols,
2540                probe_source: Box::new(MaterializedOperator::new(left, left_schema)),
2541                probe_columns: left_cols,
2542                condition: Some(&condition),
2543                join_type: "LEFT",
2544                build_is_left: false,
2545                limit: None,
2546                ctx: &ctx,
2547                pre_built_hash_state: None,
2548                projection: None,
2549            })
2550            .unwrap();
2551        let probe = radixdb_storage::instrumentation::end_join_execution_probe();
2552
2553        let rows = result.rows.into_owned();
2554        assert_eq!(rows.len(), 3);
2555        assert!(rows
2556            .iter()
2557            .find(|(_, row)| row.get(0) == Some(&Value::integer(2)))
2558            .unwrap()
2559            .1
2560            .get(2)
2561            .unwrap()
2562            .is_null());
2563        assert_eq!(probe.hash_streaming_calls, 0);
2564        assert_eq!(probe.nested_loop_calls, 1);
2565        assert_eq!(probe.candidate_pairs, 6);
2566    }
2567
2568    #[test]
2569    fn test_materialized_hash_join_applies_projection_boundary() {
2570        let _path = radixdb_storage::test_failpoints::ExecutionPathControlGuard::install(
2571            radixdb_storage::test_failpoints::ExecutionPathMode::ForceParallel,
2572        );
2573        let executor = JoinExecutor::new();
2574        let ctx = ExecutionContext::new();
2575
2576        let left = make_rows(vec![
2577            vec![2, 20, 2000],
2578            vec![1, 10, 1000],
2579            vec![3, 30, 3000],
2580        ]);
2581        let right = make_rows(vec![vec![1, 100, 9000], vec![3, 300, 7000]]);
2582
2583        let left_cols = vec![
2584            "a.id".to_string(),
2585            "a.val".to_string(),
2586            "a.unused".to_string(),
2587        ];
2588        let right_cols = vec![
2589            "b.id".to_string(),
2590            "b.data".to_string(),
2591            "b.unused".to_string(),
2592        ];
2593
2594        use radixdb_sql::ast::{Identifier, InfixExpression};
2595        use radixdb_sql::token::{Position, Token, TokenType};
2596
2597        let cond = Expression::Infix(InfixExpression::new(
2598            Token::new(TokenType::Operator, "=", Position::default()),
2599            Box::new(Expression::Identifier(Identifier::new(
2600                Token::new(TokenType::Identifier, "a.id", Position::default()),
2601                "a.id".to_string(),
2602            ))),
2603            "=".to_string(),
2604            Box::new(Expression::Identifier(Identifier::new(
2605                Token::new(TokenType::Identifier, "b.id", Position::default()),
2606                "b.id".to_string(),
2607            ))),
2608        ));
2609
2610        let projection = JoinProjectionIndices {
2611            columns: vec![ColumnSource::Outer(1), ColumnSource::Inner(1)],
2612            output_columns: vec!["val".to_string(), "data".to_string()],
2613        };
2614        let decision = RuntimeJoinDecision {
2615            algorithm: RuntimeJoinAlgorithm::HashJoin,
2616            swap_sides: false,
2617            explanation: "test forces hash join".to_string(),
2618        };
2619
2620        let request = JoinRequest {
2621            left_rows: CompactArc::new(left),
2622            right_rows: CompactArc::new(right),
2623            left_columns: &left_cols,
2624            right_columns: &right_cols,
2625            condition: Some(&cond),
2626            join_type: "INNER",
2627            limit: None,
2628            ctx: &ctx,
2629            algorithm_hint: Some(&decision),
2630            ordering: JoinInputOrderings::default(),
2631            projection: Some(&projection),
2632        };
2633
2634        radixdb_storage::instrumentation::begin_join_execution_probe();
2635        let result = executor.execute(request).unwrap();
2636        let probe = radixdb_storage::instrumentation::end_join_execution_probe();
2637
2638        assert_eq!(result.columns, vec!["val".to_string(), "data".to_string()]);
2639        let rows = result.rows.into_owned();
2640        assert_eq!(rows.len(), 2);
2641        assert!(rows.iter().all(|(_, row)| row.len() == 2));
2642        assert_eq!(probe.hash_parallel_calls, 1);
2643        assert_eq!(probe.hash_streaming_calls, 0);
2644    }
2645
2646    #[test]
2647    fn test_materialized_hash_join_filters_residual_before_projecting_deferred_row() {
2648        let executor = JoinExecutor::new();
2649        let ctx = ExecutionContext::new();
2650
2651        let left = make_rows(vec![vec![1, 10, 1000], vec![2, 20, 2000]]);
2652        let right = make_rows(vec![vec![1, 100, 9000], vec![2, 15, 8000]]);
2653
2654        let left_cols = vec![
2655            "a.id".to_string(),
2656            "a.val".to_string(),
2657            "a.unused".to_string(),
2658        ];
2659        let right_cols = vec![
2660            "b.id".to_string(),
2661            "b.data".to_string(),
2662            "b.unused".to_string(),
2663        ];
2664
2665        use radixdb_sql::ast::{Identifier, InfixExpression};
2666        use radixdb_sql::token::{Position, Token, TokenType};
2667
2668        let eq = Expression::Infix(InfixExpression::new(
2669            Token::new(TokenType::Operator, "=", Position::default()),
2670            Box::new(Expression::Identifier(Identifier::new(
2671                Token::new(TokenType::Identifier, "a.id", Position::default()),
2672                "a.id".to_string(),
2673            ))),
2674            "=".to_string(),
2675            Box::new(Expression::Identifier(Identifier::new(
2676                Token::new(TokenType::Identifier, "b.id", Position::default()),
2677                "b.id".to_string(),
2678            ))),
2679        ));
2680        let residual = Expression::Infix(InfixExpression::new(
2681            Token::new(TokenType::Operator, "<", Position::default()),
2682            Box::new(Expression::Identifier(Identifier::new(
2683                Token::new(TokenType::Identifier, "a.val", Position::default()),
2684                "a.val".to_string(),
2685            ))),
2686            "<".to_string(),
2687            Box::new(Expression::Identifier(Identifier::new(
2688                Token::new(TokenType::Identifier, "b.data", Position::default()),
2689                "b.data".to_string(),
2690            ))),
2691        ));
2692        let cond = Expression::Infix(InfixExpression::new(
2693            Token::new(TokenType::Keyword, "AND", Position::default()),
2694            Box::new(eq),
2695            "AND".to_string(),
2696            Box::new(residual),
2697        ));
2698
2699        let projection = JoinProjectionIndices {
2700            columns: vec![ColumnSource::Outer(1), ColumnSource::Inner(1)],
2701            output_columns: vec!["val".to_string(), "data".to_string()],
2702        };
2703        let decision = RuntimeJoinDecision {
2704            algorithm: RuntimeJoinAlgorithm::HashJoin,
2705            swap_sides: false,
2706            explanation: "test forces hash join".to_string(),
2707        };
2708
2709        let request = JoinRequest {
2710            left_rows: CompactArc::new(left),
2711            right_rows: CompactArc::new(right),
2712            left_columns: &left_cols,
2713            right_columns: &right_cols,
2714            condition: Some(&cond),
2715            join_type: "INNER",
2716            limit: None,
2717            ctx: &ctx,
2718            algorithm_hint: Some(&decision),
2719            ordering: JoinInputOrderings::default(),
2720            projection: Some(&projection),
2721        };
2722
2723        let result = executor.execute(request).unwrap();
2724
2725        assert_eq!(result.columns, vec!["val".to_string(), "data".to_string()]);
2726        assert!(matches!(result.rows, JoinRows::Deferred(_)));
2727        let rows = result.rows.into_owned();
2728        assert_eq!(rows.len(), 1);
2729        assert_eq!(rows[0].1.len(), 2);
2730        assert_eq!(rows[0].1.get(0), Some(&Value::integer(10)));
2731        assert_eq!(rows[0].1.get(1), Some(&Value::integer(100)));
2732    }
2733
2734    #[test]
2735    fn inner_residual_merge_candidate_uses_hash_instead_of_quadratic_fallback() {
2736        let executor = JoinExecutor::new();
2737        let ctx = ExecutionContext::new();
2738        let left = make_rows(vec![vec![1, 10], vec![2, 20]]);
2739        let right = make_rows(vec![vec![1, 100], vec![2, 15]]);
2740        let left_cols = vec!["a.id".to_string(), "a.val".to_string()];
2741        let right_cols = vec!["b.id".to_string(), "b.data".to_string()];
2742
2743        use radixdb_sql::ast::{Identifier, InfixExpression};
2744        use radixdb_sql::token::{Position, Token, TokenType};
2745        let column = |name: &str| {
2746            Expression::Identifier(Identifier::new(
2747                Token::new(TokenType::Identifier, name, Position::default()),
2748                name.to_string(),
2749            ))
2750        };
2751        let infix = |left: Expression, op: &str, right: Expression| {
2752            Expression::Infix(InfixExpression::new(
2753                Token::new(TokenType::Operator, op, Position::default()),
2754                Box::new(left),
2755                op.to_string(),
2756                Box::new(right),
2757            ))
2758        };
2759        let condition = infix(
2760            infix(column("a.id"), "=", column("b.id")),
2761            "AND",
2762            infix(column("a.val"), "<", column("b.data")),
2763        );
2764        let decision = RuntimeJoinDecision {
2765            algorithm: RuntimeJoinAlgorithm::MergeJoin,
2766            swap_sides: false,
2767            explanation: "test sorted-input merge candidate".to_string(),
2768        };
2769
2770        radixdb_storage::instrumentation::begin_join_execution_probe();
2771        let result = executor
2772            .execute(JoinRequest {
2773                left_rows: CompactArc::new(left),
2774                right_rows: CompactArc::new(right),
2775                left_columns: &left_cols,
2776                right_columns: &right_cols,
2777                condition: Some(&condition),
2778                join_type: "INNER",
2779                limit: None,
2780                ctx: &ctx,
2781                algorithm_hint: Some(&decision),
2782                ordering: JoinInputOrderings::default(),
2783                projection: None,
2784            })
2785            .unwrap();
2786        let probe = radixdb_storage::instrumentation::end_join_execution_probe();
2787
2788        assert_eq!(result.rows.len(), 1);
2789        assert_eq!(probe.hash_streaming_calls, 1);
2790        assert_eq!(probe.nested_loop_calls, 0);
2791    }
2792
2793    #[test]
2794    fn test_materialized_nested_loop_applies_projection_boundary() {
2795        let executor = JoinExecutor::new();
2796        let ctx = ExecutionContext::new();
2797
2798        let left = make_rows(vec![vec![1, 10], vec![2, 20]]);
2799        let right = make_rows(vec![vec![100, 1000], vec![200, 2000]]);
2800
2801        let left_cols = vec!["a.id".to_string(), "a.val".to_string()];
2802        let right_cols = vec!["b.id".to_string(), "b.data".to_string()];
2803
2804        let projection = JoinProjectionIndices {
2805            columns: vec![ColumnSource::Inner(1), ColumnSource::Outer(1)],
2806            output_columns: vec!["data".to_string(), "val".to_string()],
2807        };
2808
2809        let request = JoinRequest {
2810            left_rows: CompactArc::new(left),
2811            right_rows: CompactArc::new(right),
2812            left_columns: &left_cols,
2813            right_columns: &right_cols,
2814            condition: None,
2815            join_type: "CROSS",
2816            limit: None,
2817            ctx: &ctx,
2818            algorithm_hint: None,
2819            ordering: JoinInputOrderings::default(),
2820            projection: Some(&projection),
2821        };
2822
2823        let result = executor.execute(request).unwrap();
2824
2825        assert_eq!(result.columns, vec!["data".to_string(), "val".to_string()]);
2826        let rows = result.rows.into_owned();
2827        assert_eq!(rows.len(), 4);
2828        assert!(rows.iter().all(|(_, row)| row.len() == 2));
2829        assert_eq!(rows[0].1.get(0), Some(&Value::integer(1000)));
2830        assert_eq!(rows[0].1.get(1), Some(&Value::integer(10)));
2831    }
2832
2833    #[test]
2834    fn test_cross_join() {
2835        let executor = JoinExecutor::new();
2836        let ctx = ExecutionContext::new();
2837
2838        let left = make_rows(vec![vec![1], vec![2]]);
2839        let right = make_rows(vec![vec![10], vec![20]]);
2840
2841        let left_cols = vec!["a.id".to_string()];
2842        let right_cols = vec!["b.val".to_string()];
2843
2844        let request = JoinRequest {
2845            left_rows: CompactArc::new(left),
2846            right_rows: CompactArc::new(right),
2847            left_columns: &left_cols,
2848            right_columns: &right_cols,
2849            condition: None,
2850            join_type: "CROSS",
2851            limit: None,
2852            ctx: &ctx,
2853            algorithm_hint: None,
2854            ordering: JoinInputOrderings::default(),
2855            projection: None,
2856        };
2857
2858        let result = executor.execute(request).unwrap();
2859
2860        // 2 x 2 = 4 rows
2861        assert_eq!(result.rows.len(), 4);
2862    }
2863}