Skip to main content

krishiv_sql/
grace_hash_join.rs

1//! A hash join that spills.
2//!
3//! # The gap this closes
4//!
5//! DataFusion 54's `HashJoinExec` holds its entire build side in memory and has
6//! no spill path. `joins/hash_join/exec.rs` carries a comment that reads as if
7//! it did:
8//!
9//! ```text
10//! // Decide if we spill or not
11//! let batch_size = get_record_batch_memory_size(&batch);
12//! state.reservation.try_grow(batch_size)?;
13//! ```
14//!
15//! There is no branch under that comment — the `?` propagates, and it is the
16//! SF100 failure verbatim:
17//!
18//! ```text
19//! Resources exhausted: Failed to allocate additional 877.0 B for
20//! HashJoinInput - 0.0 B remain available for the total memory pool
21//! ```
22//!
23//! [`crate::spillable_join`] works around this by rewriting oversized hash
24//! joins into sort-merge joins, which do spill. That trade is expensive:
25//! sort-merge sorts *both* inputs in full even when almost all of the data
26//! would have fitted in memory, and it cost q2 a 6.3x slowdown on the cluster.
27//!
28//! # What this does instead
29//!
30//! A classic **hybrid grace hash join**:
31//!
32//! 1. Buffer the build side while it fits a budget. If the whole build side
33//!    fits, join in memory and never touch the disk — identical work to
34//!    `HashJoinExec`, which is the right algorithm in that case.
35//! 2. On overflow, hash-partition *both* inputs into `buckets` spill files on
36//!    the join keys.
37//! 3. Join bucket by bucket: each bucket's build side is small enough to hold,
38//!    so each bucket is an ordinary in-memory hash join.
39//!
40//! Only the buckets that overflow pay for disk, and nothing is ever sorted.
41//!
42//! # Why the answer is the same
43//!
44//! Rows join only to rows with equal join keys, and equal keys hash equal, so
45//! co-partitioning both sides on the same expressions with the same seed puts
46//! every row and all of its potential matches in the *same* bucket. The union
47//! over buckets is therefore the whole join — including the unmatched rows an
48//! outer join must emit, because a row's bucket contains every row it could
49//! have matched, so "unmatched within the bucket" and "unmatched overall" are
50//! the same statement.
51//!
52//! This is exactly the property the broadcast-join split bug violated: *there*
53//! the build side was replicated and the probe side split, so a task could call
54//! a row unmatched that another task had matched. Here both sides are
55//! partitioned by the same key, which is the safe case.
56//!
57//! # Delegation, not reimplementation
58//!
59//! Each bucket is joined by a real `HashJoinExec`, built from the original join
60//! with its own builder, so join type, join filter, null equality,
61//! null-awareness and the built-in projection are DataFusion's semantics
62//! unchanged. This operator only decides *what data goes to which join*; it
63//! never reimplements what a join means.
64//!
65//! Likewise the node delegates `schema()`, `properties()` and the distribution
66//! requirements to the join it replaces, so substituting it cannot change
67//! anything a parent plan observes.
68//!
69//! # Known limits
70//!
71//! - **Skew.** One key larger than the budget lands in one bucket and that
72//!   bucket is still an in-memory join. Recursive re-partitioning would need a
73//!   second hash seed, which `BatchPartitioner` does not expose. The bucket
74//!   count is therefore chosen generously, and an over-budget bucket is warned
75//!   about by name in `join_bucket` — with its size and the budget it broke —
76//!   rather than being retried or passing silently.
77//! - **Disk.** `buckets` temporary files per side stay open while partitioning.
78
79use arrow::datatypes::SchemaRef;
80use arrow::record_batch::RecordBatch;
81use datafusion::common::{Result, Statistics};
82use datafusion::error::DataFusionError;
83use datafusion::execution::TaskContext;
84use datafusion::execution::disk_manager::RefCountedTempFile;
85use datafusion::execution::memory_pool::MemoryConsumer;
86use datafusion::physical_expr::PhysicalExpr;
87use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode};
88use datafusion::physical_plan::metrics::{ExecutionPlanMetricsSet, MetricsSet, SpillMetrics, Time};
89use datafusion::physical_plan::repartition::BatchPartitioner;
90use datafusion::physical_plan::spill::{SpillManager, get_record_batch_memory_size};
91use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
92use datafusion::physical_plan::{
93    DisplayAs, DisplayFormatType, Distribution, ExecutionPlan, PlanProperties,
94    SendableRecordBatchStream,
95};
96use futures::{StreamExt, TryStreamExt};
97use std::fmt;
98use std::sync::Arc;
99
100/// Turn the grace hash join on. Absent or not truthy, the engine keeps the
101/// sort-merge conversion.
102///
103/// Default-off deliberately: the sort-merge path is what the SF100 sweeps have
104/// been measured against, and a new join operator earns its place by beating it
105/// on the cluster, not by being newer.
106pub const GRACE_HASH_JOIN_ENV: &str = "KRISHIV_GRACE_HASH_JOIN";
107
108/// Override the number of hash buckets the build side is partitioned into.
109pub const GRACE_HASH_JOIN_BUCKETS_ENV: &str = "KRISHIV_GRACE_HASH_JOIN_BUCKETS";
110
111/// Buckets used when the estimate suggests nothing larger.
112///
113/// Generous on purpose. The cost of a bucket is one temporary file and one
114/// small in-memory join; the cost of too few buckets is a bucket that does not
115/// fit, which is the failure this operator exists to prevent.
116const DEFAULT_BUCKETS: usize = 32;
117
118/// Bounds on the bucket count, whatever the estimate or the environment says.
119const MIN_BUCKETS: usize = 2;
120const MAX_BUCKETS: usize = 256;
121
122/// Whether the grace hash join is enabled for this process.
123#[must_use]
124pub fn enabled() -> bool {
125    std::env::var(GRACE_HASH_JOIN_ENV).is_ok_and(|v| {
126        let v = v.trim().to_ascii_lowercase();
127        v == "1" || v == "true" || v == "yes" || v == "on"
128    })
129}
130
131/// Buckets to partition into for a build side estimated at `build_bytes`
132/// against a per-task `budget`.
133///
134/// Sized so a bucket is expected to land at about half the budget, which leaves
135/// room for the hash table's own overhead on top of the raw rows.
136#[must_use]
137pub fn bucket_count(build_bytes: u64, budget: u64) -> usize {
138    if let Some(override_buckets) = std::env::var(GRACE_HASH_JOIN_BUCKETS_ENV)
139        .ok()
140        .and_then(|v| v.trim().parse::<usize>().ok())
141        .filter(|n| *n > 0)
142    {
143        return override_buckets.clamp(MIN_BUCKETS, MAX_BUCKETS);
144    }
145    let target = (budget / 2).max(1);
146    let needed = usize::try_from(build_bytes.div_ceil(target)).unwrap_or(MAX_BUCKETS);
147    needed.max(DEFAULT_BUCKETS).clamp(MIN_BUCKETS, MAX_BUCKETS)
148}
149
150/// A hash join that partitions to disk when its build side does not fit.
151///
152/// Stands in for the [`HashJoinExec`] it wraps: same children, same join, same
153/// output schema and plan properties.
154#[derive(Debug)]
155pub struct GraceHashJoinExec {
156    /// The join being replaced.
157    ///
158    /// Never executed. It owns the children and the join specification, and
159    /// answers every plan-level question on this node's behalf, so that
160    /// substituting this operator cannot change what a parent plan sees.
161    template: Arc<HashJoinExec>,
162    /// Hash buckets to partition both sides into on overflow.
163    buckets: usize,
164    /// Build-side bytes to buffer before giving up on the in-memory path.
165    build_budget: usize,
166    metrics: ExecutionPlanMetricsSet,
167}
168
169impl GraceHashJoinExec {
170    /// Wrap `template`, partitioning into `buckets` when the build side exceeds
171    /// `build_budget` bytes.
172    ///
173    /// # Errors
174    ///
175    /// When the two sides do not have the same number of partitions.
176    ///
177    /// This operator joins side by side: output partition `p` reads partition
178    /// `p` of *both* children. That is what `PartitionMode::Partitioned` means,
179    /// and it is also true of a one-partition `CollectLeft`. It is **not** true
180    /// of a wide `CollectLeft`, where a one-partition build side is broadcast to
181    /// every probe partition — asking that left child for partition 1 would
182    /// either fail outright or, worse, silently read the wrong data.
183    ///
184    /// Refusing here rather than at execute time means an unsupported shape is
185    /// a planning-time decline that leaves the original join in place, not a
186    /// query that dies after doing work.
187    pub fn try_new(
188        template: Arc<HashJoinExec>,
189        buckets: usize,
190        build_budget: usize,
191    ) -> Result<Self> {
192        use datafusion::physical_plan::ExecutionPlanProperties;
193
194        let left = template.left().output_partitioning().partition_count();
195        let right = template.right().output_partitioning().partition_count();
196        if left != right {
197            return Err(DataFusionError::Plan(format!(
198                "grace hash join needs both sides partitioned alike, got {left} and {right} \
199                 (mode {:?})",
200                template.partition_mode()
201            )));
202        }
203        Ok(Self {
204            template,
205            buckets: buckets.clamp(MIN_BUCKETS, MAX_BUCKETS),
206            // A zero budget would send a build side of any size to disk,
207            // including an empty one, turning every join into a disk round trip.
208            build_budget: build_budget.max(1),
209            metrics: ExecutionPlanMetricsSet::new(),
210        })
211    }
212
213    /// The join this node stands in for.
214    #[must_use]
215    pub fn template(&self) -> &Arc<HashJoinExec> {
216        &self.template
217    }
218
219    /// Hash buckets used on overflow.
220    #[must_use]
221    pub fn buckets(&self) -> usize {
222        self.buckets
223    }
224
225    /// Build-side bytes buffered before partitioning to disk.
226    #[must_use]
227    pub fn build_budget(&self) -> usize {
228        self.build_budget
229    }
230}
231
232impl DisplayAs for GraceHashJoinExec {
233    fn fmt_as(&self, t: DisplayFormatType, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234        match t {
235            DisplayFormatType::Default | DisplayFormatType::TreeRender => write!(
236                f,
237                "GraceHashJoinExec: join_type={:?}, buckets={}",
238                self.template.join_type(),
239                self.buckets
240            ),
241            DisplayFormatType::Verbose => write!(
242                f,
243                "GraceHashJoinExec: join_type={:?}, buckets={}, build_budget={}, on={:?}",
244                self.template.join_type(),
245                self.buckets,
246                self.build_budget,
247                self.template.on()
248            ),
249        }
250    }
251}
252
253impl ExecutionPlan for GraceHashJoinExec {
254    fn name(&self) -> &str {
255        "GraceHashJoinExec"
256    }
257
258    fn properties(&self) -> &Arc<PlanProperties> {
259        // Delegated, not recomputed: this node must be indistinguishable from
260        // the join it replaces at plan level, or substituting it could change
261        // how a parent distributes or orders its input.
262        self.template.properties()
263    }
264
265    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
266        vec![self.template.left(), self.template.right()]
267    }
268
269    fn required_input_distribution(&self) -> Vec<Distribution> {
270        self.template.required_input_distribution()
271    }
272
273    fn maintains_input_order(&self) -> Vec<bool> {
274        // Partitioning to disk reorders rows within a side, and bucket order is
275        // not input order. Claiming otherwise would let a parent skip a sort it
276        // actually needs.
277        vec![false, false]
278    }
279
280    fn with_new_children(
281        self: Arc<Self>,
282        children: Vec<Arc<dyn ExecutionPlan>>,
283    ) -> Result<Arc<dyn ExecutionPlan>> {
284        let template = self
285            .template
286            .builder()
287            .reset_state()
288            .with_new_children(children)?
289            .build()?;
290        Ok(Arc::new(Self::try_new(
291            Arc::new(template),
292            self.buckets,
293            self.build_budget,
294        )?))
295    }
296
297    fn execute(
298        &self,
299        partition: usize,
300        context: Arc<TaskContext>,
301    ) -> Result<SendableRecordBatchStream> {
302        let schema = self.template.schema();
303        let template = Arc::clone(&self.template);
304        let metrics = self.metrics.clone();
305        let buckets = self.buckets;
306        let build_budget = self.build_budget;
307
308        // `join` is async because it has to read the build side before it can
309        // choose a path. Flattening a one-shot stream over it keeps `execute`
310        // synchronous, as the trait requires.
311        let started = futures::stream::once(async move {
312            join(template, buckets, build_budget, partition, context, metrics).await
313        })
314        .try_flatten();
315
316        Ok(Box::pin(RecordBatchStreamAdapter::new(schema, started)))
317    }
318
319    fn metrics(&self) -> Option<MetricsSet> {
320        Some(self.metrics.clone_inner())
321    }
322
323    fn partition_statistics(&self, partition: Option<usize>) -> Result<Arc<Statistics>> {
324        self.template.partition_statistics(partition)
325    }
326}
327
328/// Run the join for one output partition.
329async fn join(
330    template: Arc<HashJoinExec>,
331    buckets: usize,
332    build_budget: usize,
333    partition: usize,
334    context: Arc<TaskContext>,
335    metrics: ExecutionPlanMetricsSet,
336) -> Result<SendableRecordBatchStream> {
337    let output_schema = template.schema();
338    let build_schema = template.left().schema();
339    let probe_schema = template.right().schema();
340
341    let mut build_stream = template.left().execute(partition, Arc::clone(&context))?;
342
343    // Pass 1: buffer the build side while it fits.
344    //
345    // Reserved against the pool as a *spillable* consumer — which is the literal
346    // truth, and it matters: `FairSpillPool` caps spillable consumers at a share
347    // of the pool while letting unspillable ones take what remains, so declaring
348    // this correctly is what stops it from crowding out a neighbouring hash join
349    // that genuinely cannot spill.
350    let reservation = MemoryConsumer::new(format!("GraceHashJoinBuild[{partition}]"))
351        .with_can_spill(true)
352        .register(context.memory_pool());
353    let mut buffered: Vec<RecordBatch> = Vec::new();
354    let mut buffered_bytes: usize = 0;
355    let mut overflowed = false;
356
357    while let Some(batch) = build_stream.next().await {
358        let batch = batch?;
359        if batch.num_rows() == 0 {
360            continue;
361        }
362        let size = get_record_batch_memory_size(&batch);
363        // Either bound ends the in-memory path: our own budget, or the pool
364        // refusing. The pool refusing is not an error here — it is precisely the
365        // signal this operator exists to act on instead of propagate.
366        if buffered_bytes.saturating_add(size) > build_budget || reservation.try_grow(size).is_err()
367        {
368            overflowed = true;
369            buffered.push(batch);
370            break;
371        }
372        buffered_bytes += size;
373        buffered.push(batch);
374    }
375
376    if !overflowed {
377        tracing::debug!(
378            partition,
379            buffered_bytes,
380            batches = buffered.len(),
381            "grace-hash-join: build side fits, joining in memory"
382        );
383        // Hand the batches to the join and stop accounting for them here: the
384        // join registers its own reservation over the same rows, and holding
385        // both would double-count the build side against the pool.
386        drop(reservation);
387        let probe = template.right().execute(partition, Arc::clone(&context))?;
388        let build_exec = memory_source(vec![buffered], build_schema)?;
389        let probe_exec = Arc::new(OnceStreamExec::new(probe_schema, probe));
390        return bucket_join(&template, build_exec, probe_exec)?.execute(0, context);
391    }
392
393    tracing::info!(
394        partition,
395        buffered_bytes,
396        build_budget,
397        buckets,
398        "grace-hash-join: build side exceeds the budget, partitioning to disk"
399    );
400
401    // Pass 2: co-partition both sides on the join keys.
402    let build_keys: Vec<Arc<dyn PhysicalExpr>> =
403        template.on().iter().map(|(l, _)| Arc::clone(l)).collect();
404    let probe_keys: Vec<Arc<dyn PhysicalExpr>> =
405        template.on().iter().map(|(_, r)| Arc::clone(r)).collect();
406
407    let build_spills = SpillManager::new(
408        context.runtime_env(),
409        SpillMetrics::new(&metrics, partition),
410        Arc::clone(&build_schema),
411    );
412    let probe_spills = SpillManager::new(
413        context.runtime_env(),
414        SpillMetrics::new(&metrics, partition),
415        Arc::clone(&probe_schema),
416    );
417
418    let build_files = spill_by_bucket(
419        std::mem::take(&mut buffered),
420        build_stream,
421        build_keys,
422        buckets,
423        &build_spills,
424        "grace hash join build side",
425    )
426    .await?;
427    // Everything buffered has been written through to disk. Held until here
428    // rather than released earlier, so the reservation never understates what
429    // is actually resident.
430    drop(reservation);
431
432    let probe_stream = template.right().execute(partition, Arc::clone(&context))?;
433    let probe_files = spill_by_bucket(
434        Vec::new(),
435        probe_stream,
436        probe_keys,
437        buckets,
438        &probe_spills,
439        "grace hash join probe side",
440    )
441    .await?;
442
443    // Pass 3: one in-memory join per bucket, streamed in turn so that only one
444    // bucket's build side is resident at a time.
445    let pairs: Vec<(usize, Option<RefCountedTempFile>, Option<RefCountedTempFile>)> = build_files
446        .into_iter()
447        .zip(probe_files)
448        .enumerate()
449        .map(|(bucket, (build, probe))| (bucket, build, probe))
450        .collect();
451
452    let joined = futures::stream::iter(pairs)
453        .map(Ok::<_, DataFusionError>)
454        .and_then(move |(bucket, build_file, probe_file)| {
455            let template = Arc::clone(&template);
456            let context = Arc::clone(&context);
457            let build_spills = build_spills.clone();
458            let probe_spills = probe_spills.clone();
459            let build_schema = Arc::clone(&build_schema);
460            let probe_schema = Arc::clone(&probe_schema);
461            async move {
462                join_bucket(
463                    &template,
464                    bucket,
465                    build_file,
466                    probe_file,
467                    &build_spills,
468                    &probe_spills,
469                    build_schema,
470                    probe_schema,
471                    build_budget,
472                    &context,
473                )
474                .await
475            }
476        })
477        .try_flatten();
478
479    Ok(Box::pin(RecordBatchStreamAdapter::new(
480        output_schema,
481        joined,
482    )))
483}
484
485/// Join one bucket: its build side read into memory, its probe side streamed.
486#[expect(
487    clippy::too_many_arguments,
488    reason = "one bucket needs both sides' files, spill managers and schemas; \
489              bundling them into a struct would only move the list"
490)]
491async fn join_bucket(
492    template: &Arc<HashJoinExec>,
493    bucket: usize,
494    build_file: Option<RefCountedTempFile>,
495    probe_file: Option<RefCountedTempFile>,
496    build_spills: &SpillManager,
497    probe_spills: &SpillManager,
498    build_schema: SchemaRef,
499    probe_schema: SchemaRef,
500    build_budget: usize,
501    context: &Arc<TaskContext>,
502) -> Result<SendableRecordBatchStream> {
503    // Both sides empty means no row of either input hashed here: nothing to
504    // join, and nothing unmatched to report either.
505    if build_file.is_none() && probe_file.is_none() {
506        return Ok(Box::pin(RecordBatchStreamAdapter::new(
507            template.schema(),
508            futures::stream::empty(),
509        )));
510    }
511
512    let build: Vec<RecordBatch> = match build_file {
513        Some(file) => {
514            build_spills
515                .read_spill_as_stream(file, None)?
516                .try_collect()
517                .await?
518        }
519        // An empty build side is not a shortcut: a right or full outer join
520        // still has to emit this bucket's probe rows as unmatched.
521        None => Vec::new(),
522    };
523    let bucket_bytes: usize = build.iter().map(get_record_batch_memory_size).sum();
524
525    // Account for the bucket we just read back.
526    //
527    // This was unreserved, in the operator whose whole purpose is to stop
528    // unaccounted build sides from exhausting the pool. The rows are resident
529    // twice over: once in this `Vec` (held by `MemorySourceConfig` for the
530    // life of the join) and again in the hash table the inner `HashJoinExec`
531    // builds from it, which *is* reserved. So peak residency was about double
532    // the bucket with only half of it visible — and on a skewed bucket the
533    // invisible half is exactly what pushes the executor over.
534    //
535    // `can_spill(false)`: these rows have already been through the disk and
536    // there is nowhere further to put them. Saying so lets a `FairSpillPool`
537    // account for them honestly rather than counting on a spill that cannot
538    // happen.
539    let reservation = MemoryConsumer::new(format!("GraceHashJoinBucket[{bucket}]"))
540        .with_can_spill(false)
541        .register(context.memory_pool());
542    reservation.try_grow(bucket_bytes)?;
543
544    if bucket_bytes > build_budget {
545        // The skew limit named in this module's docs. It used to claim such a
546        // bucket "is logged" — it was not, because `build_budget` never
547        // reached here and every bucket logged the same line at debug. One
548        // key larger than the budget still lands in one bucket; the join will
549        // attempt it, and this is the warning that says why the pool is about
550        // to be under pressure.
551        tracing::warn!(
552            bucket,
553            bucket_bytes,
554            build_budget,
555            "grace-hash-join: bucket build side exceeds the per-task budget \
556             (key skew); joining it anyway, which may exhaust the pool"
557        );
558    } else {
559        tracing::debug!(bucket, bucket_bytes, "grace-hash-join: joining bucket");
560    }
561
562    let probe: SendableRecordBatchStream = match probe_file {
563        Some(file) => probe_spills.read_spill_as_stream(file, None)?,
564        None => Box::pin(RecordBatchStreamAdapter::new(
565            Arc::clone(&probe_schema),
566            futures::stream::empty(),
567        )),
568    };
569
570    let build_exec = memory_source(vec![build], build_schema)?;
571    let probe_exec = Arc::new(OnceStreamExec::new(probe_schema, probe));
572    let schema = template.schema();
573    let joined = bucket_join(template, build_exec, probe_exec)?.execute(0, Arc::clone(context))?;
574
575    // Carry the reservation with the stream so it is released when this
576    // bucket's output is finished or dropped — not when this function returns,
577    // which is before a single row has been read.
578    let guarded = futures::stream::unfold(
579        (joined, reservation),
580        |(mut stream, reservation)| async move {
581            stream
582                .next()
583                .await
584                .map(|batch| (batch, (stream, reservation)))
585        },
586    );
587    Ok(Box::pin(RecordBatchStreamAdapter::new(schema, guarded)))
588}
589
590/// A single-partition scan over in-memory batches.
591fn memory_source(
592    partitions: Vec<Vec<RecordBatch>>,
593    schema: SchemaRef,
594) -> Result<Arc<dyn ExecutionPlan>> {
595    let exec =
596        datafusion::datasource::memory::MemorySourceConfig::try_new_exec(&partitions, schema, None)?;
597    Ok(exec)
598}
599
600/// Build the per-bucket join from the original.
601///
602/// Everything that decides *what the join means* — type, filter, null equality,
603/// null-awareness, the built-in projection — is carried over by the builder.
604/// Only the children and the partition mode change, and the mode has to: each
605/// bucket is a single pair of one-partition inputs, which is what `CollectLeft`
606/// describes.
607fn bucket_join(
608    template: &Arc<HashJoinExec>,
609    build: Arc<dyn ExecutionPlan>,
610    probe: Arc<dyn ExecutionPlan>,
611) -> Result<Arc<dyn ExecutionPlan>> {
612    template
613        .builder()
614        // Without this the buckets would share the original's collected build
615        // side and dynamic filter — one bucket's data answering another's join.
616        .reset_state()
617        .with_new_children(vec![build, probe])?
618        .with_partition_mode(PartitionMode::CollectLeft)
619        .recompute_properties()
620        .build_exec()
621}
622
623/// Hash-partition `prefix` followed by the rest of `stream` into one spill file
624/// per bucket.
625///
626/// Returns one entry per bucket, `None` where no row hashed to it.
627async fn spill_by_bucket(
628    prefix: Vec<RecordBatch>,
629    stream: SendableRecordBatchStream,
630    keys: Vec<Arc<dyn PhysicalExpr>>,
631    buckets: usize,
632    spills: &SpillManager,
633    request: &str,
634) -> Result<Vec<Option<RefCountedTempFile>>> {
635    let mut partitioner = BatchPartitioner::new_hash_partitioner(keys, buckets, Time::new())?;
636    // The type of an in-progress spill file is not nameable outside DataFusion,
637    // so it is only ever inferred here.
638    let mut files = Vec::with_capacity(buckets);
639    for bucket in 0..buckets {
640        files.push(spills.create_in_progress_file(&format!("{request} bucket {bucket}"))?);
641    }
642
643    // The already-buffered batches and the rest of the stream are the same
644    // input; chaining them means one routing loop rather than two that could
645    // drift apart.
646    let mut all = futures::stream::iter(prefix.into_iter().map(Ok)).chain(stream);
647    while let Some(batch) = all.next().await {
648        let batch = batch?;
649        if batch.num_rows() == 0 {
650            continue;
651        }
652        partitioner.partition(batch, |bucket, part| {
653            if part.num_rows() == 0 {
654                return Ok(());
655            }
656            // The partitioner was built with `buckets` partitions and `files`
657            // has one entry per bucket, so this cannot miss — but a silent
658            // panic here would surface as a lost task with no explanation.
659            let file = files.get_mut(bucket).ok_or_else(|| {
660                DataFusionError::Internal(format!(
661                    "grace hash join routed a batch to bucket {bucket} of {buckets}"
662                ))
663            })?;
664            file.append_batch(&part)?;
665            Ok(())
666        })?;
667    }
668
669    let mut finished = Vec::with_capacity(buckets);
670    for mut file in files {
671        finished.push(file.finish()?);
672    }
673    Ok(finished)
674}
675
676/// An [`ExecutionPlan`] over one already-created stream.
677///
678/// The bucket joins need their probe side as a plan node, but the data is a
679/// spill-file stream that exists before the plan does. This adapts one to the
680/// other. Single partition, single use: `execute` hands the stream out and it is
681/// gone, which is exactly how a bucket consumes it.
682struct OnceStreamExec {
683    stream: std::sync::Mutex<Option<SendableRecordBatchStream>>,
684    properties: Arc<PlanProperties>,
685}
686
687impl fmt::Debug for OnceStreamExec {
688    // A record-batch stream is not `Debug`, and `ExecutionPlan` requires it of
689    // the node. Nothing about a consumed-once stream is worth printing anyway.
690    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
691        f.write_str("OnceStreamExec")
692    }
693}
694
695impl OnceStreamExec {
696    fn new(schema: SchemaRef, stream: SendableRecordBatchStream) -> Self {
697        use datafusion::physical_expr::EquivalenceProperties;
698        use datafusion::physical_plan::Partitioning;
699        use datafusion::physical_plan::execution_plan::{Boundedness, EmissionType};
700
701        let properties = Arc::new(PlanProperties::new(
702            EquivalenceProperties::new(schema),
703            Partitioning::UnknownPartitioning(1),
704            EmissionType::Incremental,
705            Boundedness::Bounded,
706        ));
707        Self {
708            stream: std::sync::Mutex::new(Some(stream)),
709            properties,
710        }
711    }
712}
713
714impl DisplayAs for OnceStreamExec {
715    fn fmt_as(&self, _t: DisplayFormatType, f: &mut fmt::Formatter<'_>) -> fmt::Result {
716        write!(f, "OnceStreamExec")
717    }
718}
719
720impl ExecutionPlan for OnceStreamExec {
721    fn name(&self) -> &str {
722        "OnceStreamExec"
723    }
724
725    fn properties(&self) -> &Arc<PlanProperties> {
726        &self.properties
727    }
728
729    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
730        vec![]
731    }
732
733    fn with_new_children(
734        self: Arc<Self>,
735        _children: Vec<Arc<dyn ExecutionPlan>>,
736    ) -> Result<Arc<dyn ExecutionPlan>> {
737        Ok(self)
738    }
739
740    fn execute(
741        &self,
742        partition: usize,
743        _context: Arc<TaskContext>,
744    ) -> Result<SendableRecordBatchStream> {
745        if partition != 0 {
746            return Err(DataFusionError::Internal(format!(
747                "OnceStreamExec has one partition, asked for {partition}"
748            )));
749        }
750        self.stream
751            .lock()
752            .map_err(|_| DataFusionError::Internal("OnceStreamExec mutex poisoned".into()))?
753            .take()
754            .ok_or_else(|| DataFusionError::Internal("OnceStreamExec was already executed".into()))
755    }
756}
757
758#[cfg(test)]
759#[allow(clippy::unwrap_used, clippy::expect_used)]
760mod tests {
761    use super::*;
762    use arrow::array::{Int32Array, StringArray};
763    use arrow::datatypes::{DataType, Field, Schema};
764    use datafusion::common::{JoinType, NullEquality};
765    use datafusion::physical_expr::expressions::Column;
766    use datafusion::physical_plan::collect;
767    use datafusion::prelude::SessionContext;
768
769    fn build_schema() -> SchemaRef {
770        Arc::new(Schema::new(vec![
771            Field::new("k", DataType::Int32, true),
772            Field::new("v", DataType::Utf8, true),
773        ]))
774    }
775
776    fn probe_schema() -> SchemaRef {
777        Arc::new(Schema::new(vec![
778            Field::new("k", DataType::Int32, true),
779            Field::new("w", DataType::Int32, true),
780        ]))
781    }
782
783    fn build_batch(keys: Vec<Option<i32>>, vals: Vec<Option<&str>>) -> RecordBatch {
784        RecordBatch::try_new(
785            build_schema(),
786            vec![
787                Arc::new(Int32Array::from(keys)),
788                Arc::new(StringArray::from(vals)),
789            ],
790        )
791        .expect("build batch")
792    }
793
794    fn probe_batch(keys: Vec<Option<i32>>, ws: Vec<Option<i32>>) -> RecordBatch {
795        RecordBatch::try_new(
796            probe_schema(),
797            vec![
798                Arc::new(Int32Array::from(keys)),
799                Arc::new(Int32Array::from(ws)),
800            ],
801        )
802        .expect("probe batch")
803    }
804
805    /// Build side across two batches: duplicate keys, and keys matching nothing.
806    fn build_rows() -> Vec<RecordBatch> {
807        vec![
808            build_batch(vec![Some(1), Some(1), Some(2)], vec![Some("a1"), Some("a2"), Some("b")]),
809            build_batch(vec![Some(3), Some(7)], vec![Some("c"), Some("g")]),
810        ]
811    }
812
813    /// Probe side: a duplicate key, and a key matching nothing.
814    fn probe_rows() -> Vec<RecordBatch> {
815        vec![
816            probe_batch(vec![Some(1), Some(2)], vec![Some(10), Some(20)]),
817            probe_batch(vec![Some(2), Some(4)], vec![Some(21), Some(40)]),
818        ]
819    }
820
821    fn source(schema: SchemaRef, batches: Vec<RecordBatch>) -> Arc<dyn ExecutionPlan> {
822        memory_source(vec![batches], schema).expect("memory source")
823    }
824
825    /// The hash join the grace operator stands in for.
826    fn hash_join(
827        join_type: JoinType,
828        null_equality: NullEquality,
829        projection: Option<Vec<usize>>,
830    ) -> Arc<HashJoinExec> {
831        Arc::new(
832            HashJoinExec::try_new(
833                source(build_schema(), build_rows()),
834                source(probe_schema(), probe_rows()),
835                vec![(
836                    Arc::new(Column::new("k", 0)),
837                    Arc::new(Column::new("k", 0)),
838                )],
839                None,
840                &join_type,
841                projection,
842                PartitionMode::CollectLeft,
843                null_equality,
844                false,
845            )
846            .expect("hash join"),
847        )
848    }
849
850    /// Every cell of every row, sorted. Bucket order is not input order, so only
851    /// a set comparison is meaningful — and only cells catch a plan that returns
852    /// the right shape with the wrong values.
853    async fn cells(plan: Arc<dyn ExecutionPlan>, ctx: &SessionContext) -> Vec<String> {
854        let batches = collect(plan, ctx.task_ctx()).await.expect("collect");
855        let mut rows: Vec<String> = batches
856            .iter()
857            .flat_map(|b| {
858                (0..b.num_rows()).map(move |r| {
859                    (0..b.num_columns())
860                        .map(|c| {
861                            arrow::util::display::array_value_to_string(b.column(c), r)
862                                .expect("cell")
863                        })
864                        .collect::<Vec<_>>()
865                        .join("|")
866                })
867            })
868            .collect();
869        rows.sort();
870        rows
871    }
872
873    /// Number of spill files the operator actually created.
874    ///
875    /// Every "grace mode" test asserts this is non-zero. Without it a test that
876    /// silently took the in-memory path would pass while proving nothing about
877    /// the partitioning path it claims to cover.
878    /// `MetricsSet::spill_count()`, not `sum_by_name("spill_count")` — the
879    /// latter matches only `Count`/`Time`/`Gauge` variants and returns `false`
880    /// for `SpillCount`, so it silently reports zero spills forever. It was the
881    /// first thing written here, and every grace-mode test "passed" against it.
882    fn spill_files(plan: &GraceHashJoinExec) -> usize {
883        plan.metrics().and_then(|m| m.spill_count()).unwrap_or(0)
884    }
885
886    /// Grace mode and the hash join it replaces must agree, for every join type.
887    ///
888    /// Outer joins are the reason this test enumerates: an unmatched row is only
889    /// unmatched if *no* row on the other side matched it, so a partitioning
890    /// that split a key across buckets would emit spurious unmatched rows. That
891    /// is exactly the bug that made a split broadcast `LeftAnti` join return
892    /// wrong answers, and it is silent — right shape, right types, wrong data.
893    #[tokio::test]
894    async fn every_join_type_agrees_with_the_hash_join_it_replaces() {
895        let ctx = SessionContext::new();
896        for join_type in [
897            JoinType::Inner,
898            JoinType::Left,
899            JoinType::Right,
900            JoinType::Full,
901            JoinType::LeftSemi,
902            JoinType::LeftAnti,
903            JoinType::RightSemi,
904            JoinType::RightAnti,
905        ] {
906            let expected = cells(hash_join(join_type, NullEquality::NullEqualsNothing, None), &ctx).await;
907
908            // Budget of 1 byte: the first batch overflows, so every run of this
909            // test takes the partitioning path.
910            let grace = Arc::new(
911                GraceHashJoinExec::try_new(
912                    hash_join(join_type, NullEquality::NullEqualsNothing, None),
913                    4,
914                    1,
915                )
916                .expect("grace join"),
917            );
918            let actual = cells(Arc::clone(&grace) as Arc<dyn ExecutionPlan>, &ctx).await;
919            assert!(
920                spill_files(&grace) > 0,
921                "{join_type:?} took the in-memory path, so this proved nothing"
922            );
923            assert_eq!(actual, expected, "{join_type:?} disagreed after partitioning");
924        }
925    }
926
927    /// An anchor: the inner join's rows written out by hand, so the comparison
928    /// above cannot pass by both sides being broken the same way.
929    #[tokio::test]
930    async fn the_inner_join_returns_the_rows_it_should() {
931        let ctx = SessionContext::new();
932        let grace = Arc::new(
933            GraceHashJoinExec::try_new(
934                hash_join(JoinType::Inner, NullEquality::NullEqualsNothing, None),
935                4,
936                1,
937            )
938            .expect("grace join"),
939        );
940        assert_eq!(
941            cells(grace, &ctx).await,
942            vec!["1|a1|1|10", "1|a2|1|10", "2|b|2|20", "2|b|2|21"],
943        );
944    }
945
946    /// A build side that fits must never touch the disk. This is the whole
947    /// reason to prefer this over the sort-merge conversion: the common case
948    /// has to stay exactly as fast as an ordinary hash join.
949    #[tokio::test]
950    async fn a_build_side_that_fits_stays_in_memory() {
951        let ctx = SessionContext::new();
952        let grace = Arc::new(
953            GraceHashJoinExec::try_new(
954                hash_join(JoinType::Inner, NullEquality::NullEqualsNothing, None),
955                4,
956                64 * 1024 * 1024,
957            )
958            .expect("grace join"),
959        );
960        let actual = cells(Arc::clone(&grace) as Arc<dyn ExecutionPlan>, &ctx).await;
961        assert_eq!(spill_files(&grace), 0, "a fitting build side spilled");
962        assert_eq!(
963            actual,
964            cells(hash_join(JoinType::Inner, NullEquality::NullEqualsNothing, None), &ctx).await
965        );
966    }
967
968    /// Null keys hash consistently, so they co-locate like any other key. Both
969    /// null-equality settings must survive the round trip — under
970    /// `NullEqualsNull` the nulls actually join, which only works if they landed
971    /// in the same bucket.
972    #[tokio::test]
973    async fn null_keys_survive_partitioning_under_both_null_equalities() {
974        let ctx = SessionContext::new();
975        for null_equality in [NullEquality::NullEqualsNothing, NullEquality::NullEqualsNull] {
976            let join = || {
977                Arc::new(
978                    HashJoinExec::try_new(
979                        source(
980                            build_schema(),
981                            vec![build_batch(
982                                vec![None, Some(1), None],
983                                vec![Some("n1"), Some("a"), Some("n2")],
984                            )],
985                        ),
986                        source(
987                            probe_schema(),
988                            vec![probe_batch(vec![None, Some(1)], vec![Some(99), Some(10)])],
989                        ),
990                        vec![(
991                            Arc::new(Column::new("k", 0)),
992                            Arc::new(Column::new("k", 0)),
993                        )],
994                        None,
995                        &JoinType::Full,
996                        None,
997                        PartitionMode::CollectLeft,
998                        null_equality,
999                        false,
1000                    )
1001                    .expect("hash join"),
1002                )
1003            };
1004            let expected = cells(join(), &ctx).await;
1005            let grace =
1006                Arc::new(GraceHashJoinExec::try_new(join(), 4, 1).expect("grace join"));
1007            let actual = cells(Arc::clone(&grace) as Arc<dyn ExecutionPlan>, &ctx).await;
1008            assert!(spill_files(&grace) > 0, "{null_equality:?} stayed in memory");
1009            assert_eq!(actual, expected, "{null_equality:?} disagreed");
1010        }
1011    }
1012
1013    /// Bucket reservations must be released, not leaked.
1014    ///
1015    /// Each bucket's build side is read back from disk into a `Vec` that the
1016    /// join then holds — memory that went entirely unaccounted until it was
1017    /// reserved, in the operator whose whole purpose is to keep build sides
1018    /// from exhausting the pool. Reserving it is only half the job: the
1019    /// reservation has to be dropped when the bucket's output is finished,
1020    /// which is long after `join_bucket` returns.
1021    ///
1022    /// Running the same plan repeatedly against one bounded pool is the cheap
1023    /// way to catch a leak: if a bucket's bytes were never given back, a later
1024    /// run would be refused.
1025    #[tokio::test]
1026    async fn bucket_reservations_are_released_after_each_run() {
1027        use datafusion::execution::memory_pool::GreedyMemoryPool;
1028        use datafusion::execution::runtime_env::RuntimeEnvBuilder;
1029
1030        // Small enough that unreleased buckets would accumulate into a refusal,
1031        // large enough that one honest run fits.
1032        let env = RuntimeEnvBuilder::new()
1033            .with_memory_pool(Arc::new(GreedyMemoryPool::new(4 * 1024 * 1024)))
1034            .build_arc()
1035            .expect("runtime env");
1036        let ctx = SessionContext::new_with_config_rt(Default::default(), env);
1037
1038        let mut previous: Option<Vec<String>> = None;
1039        for run in 0..4 {
1040            let grace = Arc::new(
1041                GraceHashJoinExec::try_new(
1042                    hash_join(JoinType::Inner, NullEquality::NullEqualsNothing, None),
1043                    4,
1044                    1,
1045                )
1046                .expect("grace join"),
1047            );
1048            assert!(
1049                spill_files(&grace) == 0,
1050                "fresh node should not report spills before running"
1051            );
1052            let rows = cells(Arc::clone(&grace) as Arc<dyn ExecutionPlan>, &ctx).await;
1053            assert!(!rows.is_empty(), "run {run} produced nothing");
1054            if let Some(first) = &previous {
1055                assert_eq!(&rows, first, "run {run} disagreed with the first run");
1056            }
1057            previous = Some(rows);
1058        }
1059    }
1060
1061    /// A join carrying a built-in projection keeps it.
1062    ///
1063    /// `SortMergeJoinExec` has no projection, which is why the sort-merge
1064    /// conversion had to rebuild one by hand and broke q7/q8/q9 when it got the
1065    /// indices wrong. Delegating to a real `HashJoinExec` per bucket means this
1066    /// operator inherits the projection instead of reconstructing it — this test
1067    /// pins that it actually does.
1068    #[tokio::test]
1069    async fn a_projected_join_keeps_its_projection() {
1070        let ctx = SessionContext::new();
1071        // Columns 1 (v) and 3 (w) of the k,v,k,w join schema.
1072        let projection = Some(vec![1, 3]);
1073        let expected = cells(
1074            hash_join(JoinType::Inner, NullEquality::NullEqualsNothing, projection.clone()),
1075            &ctx,
1076        )
1077        .await;
1078
1079        let grace = Arc::new(
1080            GraceHashJoinExec::try_new(
1081                hash_join(JoinType::Inner, NullEquality::NullEqualsNothing, projection),
1082                4,
1083                1,
1084            )
1085            .expect("grace join"),
1086        );
1087        assert_eq!(
1088            grace.schema().fields().len(),
1089            2,
1090            "the projection was lost from the output schema"
1091        );
1092        let actual = cells(Arc::clone(&grace) as Arc<dyn ExecutionPlan>, &ctx).await;
1093        assert!(spill_files(&grace) > 0, "took the in-memory path");
1094        assert_eq!(actual, expected);
1095    }
1096
1097    /// The node must look exactly like the join it replaces, or a parent plan
1098    /// could distribute or order its input differently around it.
1099    #[test]
1100    fn the_node_reports_the_same_schema_and_partitioning_as_its_template() {
1101        let template = hash_join(JoinType::Inner, NullEquality::NullEqualsNothing, None);
1102        let grace =
1103            GraceHashJoinExec::try_new(Arc::clone(&template), 4, 1).expect("grace join");
1104        assert_eq!(grace.schema(), template.schema());
1105        assert_eq!(
1106            format!("{:?}", grace.properties().partitioning),
1107            format!("{:?}", template.properties().partitioning),
1108        );
1109    }
1110
1111    /// A broadcast join whose sides have different partition counts is refused
1112    /// at construction, so the caller keeps the original join rather than
1113    /// discovering the mismatch mid-query.
1114    #[test]
1115    fn a_join_whose_sides_differ_in_partition_count_is_refused() {
1116        let template = Arc::new(
1117            HashJoinExec::try_new(
1118                // One build partition, two probe partitions.
1119                memory_source(vec![build_rows().clone()], build_schema()).unwrap(),
1120                memory_source(
1121                    vec![vec![probe_rows()[0].clone()], vec![probe_rows()[1].clone()]],
1122                    probe_schema(),
1123                )
1124                .unwrap(),
1125                vec![(
1126                    Arc::new(Column::new("k", 0)),
1127                    Arc::new(Column::new("k", 0)),
1128                )],
1129                None,
1130                &JoinType::Inner,
1131                None,
1132                PartitionMode::CollectLeft,
1133                NullEquality::NullEqualsNothing,
1134                false,
1135            )
1136            .expect("hash join"),
1137        );
1138        let refused = GraceHashJoinExec::try_new(template, 4, 1);
1139        assert!(
1140            refused.is_err(),
1141            "a broadcast join must be refused, not silently mis-executed"
1142        );
1143    }
1144
1145    /// An empty build side is not a shortcut: a right outer join still has to
1146    /// emit every probe row as unmatched.
1147    #[tokio::test]
1148    async fn an_empty_build_side_still_emits_unmatched_probe_rows() {
1149        let ctx = SessionContext::new();
1150        let join = || {
1151            Arc::new(
1152                HashJoinExec::try_new(
1153                    source(build_schema(), vec![]),
1154                    source(probe_schema(), probe_rows()),
1155                    vec![(
1156                        Arc::new(Column::new("k", 0)),
1157                        Arc::new(Column::new("k", 0)),
1158                    )],
1159                    None,
1160                    &JoinType::Right,
1161                    None,
1162                    PartitionMode::CollectLeft,
1163                    NullEquality::NullEqualsNothing,
1164                    false,
1165                )
1166                .expect("hash join"),
1167            )
1168        };
1169        let expected = cells(join(), &ctx).await;
1170        let grace = Arc::new(GraceHashJoinExec::try_new(join(), 4, 1).expect("grace join"));
1171        assert_eq!(cells(grace, &ctx).await, expected);
1172        assert_eq!(expected.len(), 4, "every probe row should be reported");
1173    }
1174
1175    /// More buckets than distinct keys means most buckets are empty; they must
1176    /// contribute nothing rather than erroring or emitting phantom rows.
1177    #[tokio::test]
1178    async fn far_more_buckets_than_keys_changes_nothing() {
1179        let ctx = SessionContext::new();
1180        let expected = cells(
1181            hash_join(JoinType::Full, NullEquality::NullEqualsNothing, None),
1182            &ctx,
1183        )
1184        .await;
1185        let grace = Arc::new(
1186            GraceHashJoinExec::try_new(
1187                hash_join(JoinType::Full, NullEquality::NullEqualsNothing, None),
1188                256,
1189                1,
1190            )
1191            .expect("grace join"),
1192        );
1193        assert_eq!(cells(grace, &ctx).await, expected);
1194    }
1195
1196    #[test]
1197    fn the_bucket_count_grows_with_the_build_side() {
1198        // Small build side: the floor applies.
1199        assert_eq!(bucket_count(1024, 1024 * 1024), DEFAULT_BUCKETS);
1200        // 10 GB against a 256 MB budget wants far more than the floor, and is
1201        // capped rather than allowed to open unbounded files.
1202        let big = bucket_count(10 * 1024 * 1024 * 1024, 256 * 1024 * 1024);
1203        assert!(big > DEFAULT_BUCKETS, "expected more than the floor, got {big}");
1204        assert!(big <= MAX_BUCKETS);
1205        // A zero budget must not divide by zero.
1206        assert!(bucket_count(1, 0) >= MIN_BUCKETS);
1207    }
1208}