Skip to main content

krishiv_sql/
spillable_join.rs

1//! Per-join selection of a spillable algorithm under a memory cap.
2//!
3//! # The failure this fixes
4//!
5//! TPC-H q18 on a 4500 MiB executor:
6//!
7//! ```text
8//! Resources exhausted: Failed to allocate additional 1015.5 KB for
9//! HashJoinInput[0] with 732.4 MB already allocated for this reservation -
10//! 205.0 KB remain available
11//! ```
12//!
13//! Not a leak, not a mis-sized budget: the pool refused correctly. DataFusion's
14//! hash join holds its entire build side in memory with no spill path, so when
15//! the pool is exhausted the operator has nowhere to put the overflow and the
16//! query fails. Sort-merge join spills.
17//!
18//! # Why per-join, and why this exists as a rule instead of a config bit
19//!
20//! The first attempt (8f72a340, reverted) set
21//! `datafusion.optimizer.prefer_hash_join = false` for the whole session
22//! whenever a cgroup limit existed. Measured on the cluster, q2 — ten stages of
23//! joins whose build sides all fit comfortably — went from 189 s to past a
24//! 2400 s timeout. Sorting both sides of every join to rescue the one join
25//! that overflows is a catastrophic trade.
26//!
27//! So the decision is made where the information is: at each hash join, from
28//! that join's *estimated build size* against the *per-task share* of the
29//! query pool. Three deliberately conservative gates, each a direct lesson
30//! from the q2 regression:
31//!
32//! 1. **No cap, no change.** An embedded engine on 23 GB keeps hash joins.
33//! 2. **Unknown statistics keep hash join.** A missing estimate is not
34//!    evidence of a big build side, and guessing "big" re-creates the blanket
35//!    regression. The cost of guessing "small" wrongly is the status quo —
36//!    q18 fails as it does today — while the cost of guessing "big" wrongly
37//!    is a q2-shaped timeout on healthy queries.
38//! 3. **The join mode must be convertible.** `Partitioned` inputs are already
39//!    hashed on the join keys — the distribution sort-merge needs — so the
40//!    conversion adds per-partition sorts, not exchanges. `CollectLeft`
41//!    converts too *when the plan has a single partition*, where sorting alone
42//!    satisfies sort-merge. Anything else keeps hash join.
43//!
44//!    This bullet used to read "CollectLeft build sides are small by
45//!    construction". They are not: `CollectLeft` is picked from an estimate
46//!    and buffers the whole build side. Worse, a task engine plans with
47//!    `target_partitions = cores / slots`, which is **1** on a 3-core, 3-slot
48//!    executor — so *every* join was CollectLeft and the rule converted
49//!    nothing at all while five SF100 queries died on it.
50//!
51//! The sorts are inserted explicitly (with partitioning preserved) rather than
52//! left to `EnforceSorting`, because appended optimizer rules run *after* the
53//! enforcement passes — a requirement declared here would never be satisfied.
54//!
55//! # Which spillable algorithm
56//!
57//! Sort-merge is not the only way to make a join spill, and it is the worse
58//! one: it sorts *both* inputs in full even when nearly all the data would have
59//! fitted, which is what cost q2 6.3x. [`crate::grace_hash_join`] partitions
60//! both sides by key and joins bucket by bucket instead — no sorting, and the
61//! buckets that fit never reach the disk.
62//!
63//! So when `grace` is set the rule tries that first and keeps sort-merge as the
64//! fallback for shapes it refuses. It is **off by default**: sort-merge is what
65//! the SF100 sweeps have actually been measured against, and a newer operator
66//! earns the default by beating it on the cluster.
67
68use datafusion::common::config::ConfigOptions;
69use datafusion::common::stats::Precision;
70use datafusion::common::tree_node::{Transformed, TreeNode};
71use datafusion::error::Result;
72use datafusion::physical_expr::{LexOrdering, PhysicalSortExpr};
73use datafusion::physical_optimizer::PhysicalOptimizerRule;
74use datafusion::physical_plan::joins::utils::JoinFilter;
75use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode, SortMergeJoinExec};
76use datafusion::physical_plan::repartition::RepartitionExec;
77use datafusion::physical_plan::sorts::sort::SortExec;
78use datafusion::physical_plan::{ExecutionPlan, ExecutionPlanProperties, Partitioning};
79use std::sync::Arc;
80
81/// Environment override for the build-size threshold, in bytes.
82pub const SPILL_JOIN_BUILD_BYTES_ENV: &str = "KRISHIV_SPILL_JOIN_BUILD_BYTES";
83
84/// Share of the per-task memory allowance above which an estimated build side
85/// is treated as "will not fit as a hash table".
86///
87/// A hash table costs more than the raw bytes it holds (buckets, hashes,
88/// padding), and the build side is not the task's only consumer, so the
89/// threshold sits well below 1.0. Below it, hash join stays — it is the right
90/// algorithm when it fits.
91const BUILD_FRACTION_OF_TASK_SHARE: f64 = 0.5;
92
93/// Bytes assumed for a column whose type carries no fixed width.
94///
95/// Varlen columns (`Utf8`, `Binary`, and their `View`/`Large` forms) have no
96/// Build-side bytes derived from a row count when `total_byte_size` is absent.
97///
98/// Returns `None` when the row count is absent too — the one case where the
99/// planner genuinely knows nothing and guessing would be a coin flip.
100fn estimated_build_bytes_from_rows(
101    stats: &datafusion::common::Statistics,
102    build_schema: &arrow::datatypes::Schema,
103) -> Option<u64> {
104    let rows = match stats.num_rows {
105        Precision::Exact(rows) | Precision::Inexact(rows) => rows,
106        Precision::Absent => return None,
107    };
108    // One reading of row width, shared with the broadcast rule — see
109    // `crate::join_estimates`. Two hand-rolled widths is how the two rules came
110    // to disagree about row *counts*, and there is no reason to repeat it.
111    let row_width = crate::join_estimates::estimated_row_width(build_schema);
112    u64::try_from(rows.saturating_mul(row_width)).ok()
113}
114
115/// This join's estimated build-side bytes, or `None` when the planner knows
116/// neither a byte size nor a row count for it.
117///
118/// `total_byte_size` absent does not mean "size unknown" — DataFusion often has
119/// a row count when it has no byte size (a shuffle read, a filter over a scan
120/// with row stats). Deriving bytes from rows uses information the planner
121/// already holds instead of surrendering at the first absent field, which is
122/// how q9/SF100 kept a hash join whose build side then took 797.5 MB of a
123/// 797.6 MB pool.
124///
125/// Still conservative: with the row count *also* absent this returns `None` and
126/// the caller keeps the hash join, because guessing "big" for every join is the
127/// session-wide switch that timed q2 out.
128///
129/// # The one estimate that is treated as unbounded
130///
131/// An estimate of **zero bytes and zero rows** on a join that is being asked to
132/// build a hash table is not a measurement — it is the estimator giving up, and
133/// this rule must not read it as "fits comfortably".
134///
135/// TPC-H q21 at SF100 is the case. Its `NOT EXISTS` becomes a `LeftAnti`
136/// self-join over `lineitem`, which DataFusion estimates as
137/// `outer_rows - semi_estimate` = `593462145 - 593462145` = 0
138/// (`joins/utils.rs`). The real intermediate is tens of millions of rows. That
139/// single zero poisoned **two** independent decisions: the broadcast choice
140/// (see `distributed_plan::broadcast_build_estimate_is_empty`) and this one. With
141/// the broadcast side fixed so the join is hash-partitioned, each task then had
142/// to build its own share as a hash table — and q21 died with
143/// `Resources exhausted: HashJoinInput[4] with 806.0 MB already allocated` out
144/// of a 2.6 GB pool, having previously merely been slow.
145///
146/// So a degenerate zero reports `u64::MAX`: assume it does not fit and pick the
147/// spillable algorithm. The cost of being wrong is a spillable join over an
148/// empty relation, which is free; the cost of trusting it is a failed query.
149fn build_bytes_estimate(hash_join: &HashJoinExec) -> Option<u64> {
150    // One shared reading of the statistics; see `crate::join_estimates`.
151    //
152    // An explicit zero is a *claim* that the relation is empty, and it is the
153    // claim this function refuses to believe. `Absent` is different — an honest
154    // "I do not know" — and keeps the existing policy of leaving the hash join
155    // alone, which is what stops this rule from re-creating the q2 timeout.
156    let estimate = crate::join_estimates::BuildSideEstimate::of(hash_join.left());
157    if estimate.is_unknown() {
158        return None;
159    }
160    if estimate.any_claims_empty() {
161        return Some(DEGENERATE_BUILD_BYTES);
162    }
163    // An error computing statistics is not evidence of a large build side, and
164    // this rule is an optimisation: declining is always a valid answer.
165    let stats = hash_join.left().partition_statistics(None).ok()?;
166    match stats.total_byte_size {
167        Precision::Exact(bytes) | Precision::Inexact(bytes) => u64::try_from(bytes).ok(),
168        Precision::Absent => {
169            estimated_build_bytes_from_rows(&stats, &hash_join.left().schema())
170        }
171    }
172}
173
174/// Reorder a join filter so that every left-side column precedes every
175/// right-side one.
176///
177/// # The bug this works around
178///
179/// DataFusion's sort-merge join builds the filter's intermediate batch as
180/// **all left columns followed by all right columns**
181/// (`joins/sort_merge_join/filter.rs::get_filter_columns`, reached from
182/// `materializing_stream.rs`):
183///
184/// ```text
185/// filter_columns.extend(left_columns);   // every Left entry, in order
186/// filter_columns.extend(right_columns);  // then every Right entry
187/// ```
188///
189/// But `JoinFilter::schema()` is ordered by `column_indices` **as given**, and
190/// `HashJoinExec` builds the batch in that same given order. So a filter whose
191/// `column_indices` name a right-side column before a left-side one is correct
192/// under hash join and wrong under sort-merge: the batch's columns no longer
193/// line up with the schema, and Arrow refuses it.
194///
195/// That is TPC-H q17 and q19 at SF100, verbatim:
196///
197/// ```text
198/// q17: expected Decimal128(15, 2) but found Decimal128(30, 15) at column index 0
199/// q19: expected Decimal128(15, 2) but found Utf8View        at column index 0
200/// ```
201///
202/// Both filters name `l_quantity` (right) first, so column 0 received the left
203/// side's first column instead — the `0.2 * avg(l_quantity)` expression in q17,
204/// `p_brand` in q19. Neither query is doing anything unusual; any filter that
205/// mentions the probe side first hits it.
206///
207/// (DataFusion's own *other* sort-merge path, `bitwise_stream.rs`'s
208/// `evaluate_filter_for_inner_row`, iterates `column_indices` in order and is
209/// correct. The two paths disagree with each other, which is what makes this a
210/// DataFusion bug rather than a contract we were misreading.)
211///
212/// # The fix
213///
214/// Permute `column_indices` and the intermediate schema into the order
215/// sort-merge is going to materialise anyway, and rewrite the filter
216/// expression's column indices to match. The filter then means exactly what it
217/// meant before, expressed in the layout the operator actually builds.
218///
219/// Returns `None` when the filter cannot be normalised (a `JoinSide::None`
220/// entry, or an expression column outside the intermediate schema), in which
221/// case the caller keeps the hash join — declining is always safe.
222fn left_first_filter(filter: &JoinFilter) -> Option<JoinFilter> {
223    use datafusion::common::JoinSide;
224    use datafusion::physical_expr::expressions::Column;
225
226    let indices = filter.column_indices();
227    let mut order: Vec<usize> = Vec::with_capacity(indices.len());
228    order.extend(
229        indices
230            .iter()
231            .enumerate()
232            .filter(|(_, ci)| ci.side == JoinSide::Left)
233            .map(|(at, _)| at),
234    );
235    order.extend(
236        indices
237            .iter()
238            .enumerate()
239            .filter(|(_, ci)| ci.side == JoinSide::Right)
240            .map(|(at, _)| at),
241    );
242    // A side we do not understand (`JoinSide::None`) would be dropped by the
243    // partition above; refuse rather than silently lose a filter column.
244    if order.len() != indices.len() {
245        return None;
246    }
247    // Already left-first: hand back the filter untouched so the common case
248    // allocates nothing and stays byte-identical.
249    if order.iter().enumerate().all(|(to, from)| to == *from) {
250        return Some(filter.clone());
251    }
252
253    let mut moved_to = vec![0usize; indices.len()];
254    for (to, &from) in order.iter().enumerate() {
255        *moved_to.get_mut(from)? = to;
256    }
257
258    let fields = filter.schema().fields();
259    let mut permuted = Vec::with_capacity(order.len());
260    for &from in &order {
261        permuted.push(fields.get(from)?.as_ref().clone());
262    }
263    let schema = Arc::new(arrow::datatypes::Schema::new(permuted));
264    let column_indices: Vec<_> = order
265        .iter()
266        .map(|&from| indices.get(from).cloned())
267        .collect::<Option<Vec<_>>>()?;
268
269    // The filter expression addresses the intermediate schema positionally, so
270    // permuting that schema means re-pointing every column in the expression.
271    type Expr = Arc<dyn datafusion::physical_expr::PhysicalExpr>;
272    let original: Expr = Arc::clone(filter.expression());
273    let expression = original
274        .transform(|node: Expr| {
275            // `PhysicalExpr: Any` — upcast to downcast, as elsewhere in this file.
276            let any = node.as_ref() as &dyn std::any::Any;
277            let Some(column) = any.downcast_ref::<Column>() else {
278                return Ok(Transformed::no(node));
279            };
280            let Some(&to) = moved_to.get(column.index()) else {
281                return Err(datafusion::error::DataFusionError::Internal(format!(
282                    "join filter column {} is outside its {}-column intermediate schema",
283                    column.index(),
284                    moved_to.len()
285                )));
286            };
287            Ok(Transformed::yes(Arc::new(Column::new(column.name(), to)) as Expr))
288        })
289        .ok()?
290        .data;
291
292    Some(JoinFilter::new(expression, column_indices, schema))
293}
294
295/// What [`build_bytes_estimate`] reports when the planner's estimate is
296/// degenerate — an explicit claim of zero rows and zero bytes on a relation
297/// that is being asked to build a hash table.
298///
299/// **A sentinel, not a size.** It means "the estimator gave up; do not read
300/// this as small". Anywhere it is treated as a number it will dominate, which
301/// is the point when choosing whether to convert *this* join, and a bug when
302/// summing what a *plan* costs — see [`JoinFacts::budget_bytes`].
303const DEGENERATE_BUILD_BYTES: u64 = u64::MAX;
304
305/// What the budget needs to know about one hash join in the plan.
306#[derive(Debug, Clone, Copy, PartialEq, Eq)]
307struct JoinFacts {
308    /// Estimated build-side bytes, or `None` when the planner knows neither a
309    /// byte size nor a row count.
310    bytes: Option<u64>,
311    /// Whether [`SpillableJoinSelection::convert`] can convert this join's
312    /// *mode* at all. A join it will refuse holds its build side whatever the
313    /// budget decides, so pretending otherwise mis-spends the budget.
314    convertible: bool,
315}
316
317impl JoinFacts {
318    /// Bytes this join is certain to hold if left alone. Unknown counts as 0 —
319    /// the same assumption the per-join gate makes when it keeps a join whose
320    /// size it cannot estimate.
321    fn retained_bytes(self) -> u64 {
322        self.bytes.unwrap_or(0)
323    }
324
325    /// What this join costs the **plan's** budget.
326    ///
327    /// [`DEGENERATE_BUILD_BYTES`] is a sentinel, not a measurement, and the two
328    /// must not be added together. Charged as an assumption — the same share an
329    /// unmeasurable join gets from [`unknown_build_pressure`] — because that is
330    /// honestly what is known about it.
331    ///
332    /// Deliberately NOT used for the candidate ordering or the fit test below:
333    /// there the sentinel still means "assume it does not fit", so a degenerate
334    /// join stays first in line to convert. Costing the plan and choosing what
335    /// to convert are different questions and this join answers them
336    /// differently.
337    fn budget_bytes(self, assumed_share: u64) -> u64 {
338        if self.bytes == Some(DEGENERATE_BUILD_BYTES) {
339            assumed_share
340        } else {
341            self.retained_bytes()
342        }
343    }
344
345    /// Whether the budget is free to choose for this join.
346    ///
347    /// Only joins that are both convertible and measurable are candidates: an
348    /// unknown size keeps its hash join at the per-join gate regardless.
349    fn is_candidate(self) -> bool {
350        self.convertible && self.bytes.is_some()
351    }
352}
353
354/// Memory to assume for the joins whose build side cannot be estimated.
355///
356/// # What this does, and what it deliberately cannot do
357///
358/// [`JoinFacts::retained_bytes`] reports **zero** for an unmeasurable join, so
359/// [`SpillableJoinSelection::conversion_decisions`] valued such joins at
360/// nothing when deciding whether aggregate pressure existed. This charges each
361/// one `threshold / joins` instead — a refusal to claim the join is free.
362///
363/// **It only bites on a plan that MIXES measurable and unmeasurable joins**,
364/// where it makes the measurable ones convert sooner. Two boundaries make that
365/// exact, and both are intentional:
366///
367/// * With **every** join unmeasurable the term sums to `threshold` (n shares of
368///   `threshold / n`), so `total <= threshold` short-circuits and nothing
369///   converts. That is not a bug to route around: gate 2 keeps an unmeasurable
370///   join's hash join *unconditionally*, and [`JoinFacts::is_candidate`]
371///   requires `bytes.is_some()`, so there is no decision left for a budget to
372///   make. An all-unknown plan is beyond this rule's reach by construction.
373/// * A plan with no unmeasurable joins gets zero, so it behaves exactly as
374///   before.
375///
376/// # Correction: this was written for q21, and q21 was not this
377///
378/// This function was added believing q21's SF100 failure —
379///
380/// ```text
381/// Resources exhausted: Failed to allocate additional 310.2 MB for
382/// HashJoinInput[0] with 0.0 B already allocated for this reservation -
383/// 87.9 MB remain available for the total memory pool: fair(pool_size: 2.6 GB)
384/// ```
385///
386/// — was siblings each valued at zero exhausting the pool. It was not. **Every
387/// join in every plan was unmeasurable**, because declaring a primary key had
388/// silently disabled the table's statistics (fixed in `register_parquet_table`;
389/// see its docs). Measured across coordinator and all three executors:
390/// `unmeasurable == hash_joins` in **all 414 passes**, **zero** conversions.
391/// q21 was therefore the all-unknown boundary above, which this term cannot
392/// help — and it duly did not. Restoring statistics fixed q21 and q17.
393///
394/// It is kept because the mixed case is real and reachable (a shuffle read
395/// whose upstream estimate is absent alongside measurable scans), and because
396/// valuing an unmeasurable join at zero is indefensible on its own terms. But
397/// it has **never been observed to change a decision on the SF100 corpus**, and
398/// nobody should cite it as the reason a query stopped failing.
399///
400/// # Why an even split, and why this does not re-create the q2 regression
401///
402/// With no byte size and no row count there is genuinely nothing to measure,
403/// so any figure is an assumption; the only question is which assumption is
404/// defensible. Zero asserts the join is free, which is the assumption that
405/// just failed. Treating it as unbounded would convert every join in sight —
406/// that is the session-wide `prefer_hash_join = false` switch that took q2
407/// from 189 s past a 2400 s timeout.
408///
409/// The neutral assumption between them is that a shared pool divides evenly
410/// among the operators holding it: each unmeasurable join is charged
411/// `threshold / joins`. It is not a claim about the join's real size, it is a
412/// refusal to claim the join is free.
413///
414/// **Queries whose joins are all measurable are untouched**: this returns 0 for
415/// them, `total` is unchanged, and a plan with no aggregate pressure still
416/// short-circuits to the per-join gate exactly as before. The change can only
417/// bite where an unmeasurable join exists *and* the pool is under pressure —
418/// which is the case it was missing.
419fn unknown_build_pressure(facts: &[JoinFacts], threshold: u64) -> u64 {
420    let unknown = facts.iter().filter(|f| f.bytes.is_none()).count();
421    if unknown == 0 || facts.is_empty() {
422        return 0;
423    }
424    let share = threshold / facts.len() as u64;
425    share.saturating_mul(unknown as u64)
426}
427
428/// Facts for every hash join in `plan`, **in `transform_up` order**.
429///
430/// Post-order (children before parent, children left to right) is exactly the
431/// order `TreeNode::transform_up` visits nodes, which is what lets the caller
432/// pair the Nth fact with the Nth join it is asked to rewrite. `ExecutionPlan`
433/// offers no node identity and `transform_up` rebuilds parents as their
434/// children change — so pointers are useless here, but position is stable.
435fn collect_join_facts(
436    plan: &Arc<dyn ExecutionPlan>,
437    target_partitions: usize,
438    rescue_degenerate_broadcast: bool,
439    out: &mut Vec<JoinFacts>,
440) {
441    for child in plan.children() {
442        collect_join_facts(child, target_partitions, rescue_degenerate_broadcast, out);
443    }
444    let any = plan.as_ref() as &dyn std::any::Any;
445    if let Some(hash_join) = any.downcast_ref::<HashJoinExec>() {
446        out.push(JoinFacts {
447            bytes: build_bytes_estimate(hash_join),
448            convertible: convertible_mode(hash_join, target_partitions, rescue_degenerate_broadcast)
449                .is_some(),
450        });
451    }
452}
453
454/// How a convertible join reaches sort-merge.
455#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456enum Conversion {
457    /// The sides already share the distribution sort-merge needs: sort in
458    /// place. `preserve_partitioning` is false only for the single-partition
459    /// `CollectLeft` case, where there is no distribution to preserve.
460    InPlace { preserve_partitioning: bool },
461    /// A `CollectLeft` join whose sides have *different* partition counts.
462    /// Hash-partition both on the join keys first — which is precisely the plan
463    /// DataFusion would have produced as `Partitioned` had the build-side
464    /// estimate not claimed to be tiny.
465    Repartition { partitions: usize },
466}
467
468/// Whether this join's mode can become sort-merge, and how.
469///
470/// Split out of `convert` so the budget can ask the question without doing the
471/// work — counting a join the rule will refuse is how the budget ends up
472/// tightening against memory that never gets freed.
473///
474/// # Why `CollectLeft` over an already-split probe side is convertible
475///
476/// It did not used to be, and TPC-H q21 at SF100 embedded died of it every
477/// time. `CollectLeft` is chosen from an *estimate*; q21's `NOT EXISTS` becomes
478/// a `LeftAnti` self-join over `lineitem` that DataFusion estimates at
479/// `outer_rows - semi_estimate` = `593462145 - 593462145` = **0 rows**, which
480/// is comfortably under any broadcast threshold. The real build side is
481/// 2003 MB, and it was buffered whole into a 2.3 GB pool:
482///
483/// ```text
484/// Resources exhausted: Failed to allocate additional 1151.2 KB for
485/// HashJoinInput with 2003.0 MB already allocated
486/// ```
487///
488/// The distributed planner already refuses to broadcast on a degenerate
489/// estimate (`distributed_plan::broadcast_build_estimate_is_empty`), but the
490/// embedded path never runs the distributed planner, so nothing caught it —
491/// and this rule, which *did* size the build side as unbounded, then had no
492/// mode it was allowed to rewrite. The join was un-spillable by construction.
493///
494/// Hash-partitioning both sides on the equijoin keys is the standard remedy
495/// and is what `PartitionMode::Partitioned` means; matching rows still land in
496/// the same partition, so it is correct for every join type here, including
497/// the `LeftAnti` that q21 needs.
498///
499/// # Why only for a *degenerate* estimate
500///
501/// The first version of this rescued any oversized `CollectLeft`, and that is
502/// a plan change on the coordinator too — which runs this same rule with more
503/// than one target partition. Every ordinary broadcast join in a distributed
504/// plan gained a pair of exchanges, and each exchange is a stage boundary:
505/// q21 at SF100 went from **11 stages / 758 s** to **13 stages / 1061 s**.
506///
507/// The degenerate estimate is the whole reason this case is unrescuable, so it
508/// is the whole condition. A broadcast join with an honest size is one the
509/// budget can reason about and the distributed planner can already refuse
510/// (`distributed_plan::broadcast_build_estimate_is_empty`); it does not need,
511/// and must not get, an exchange it never asked for.
512fn convertible_mode(
513    hash_join: &HashJoinExec,
514    target_partitions: usize,
515    rescue_degenerate_broadcast: bool,
516) -> Option<Conversion> {
517    let single_partition = hash_join.left().output_partitioning().partition_count() == 1
518        && hash_join.right().output_partitioning().partition_count() == 1;
519    match hash_join.partition_mode() {
520        PartitionMode::Partitioned => Some(Conversion::InPlace {
521            preserve_partitioning: true,
522        }),
523        PartitionMode::CollectLeft if single_partition => Some(Conversion::InPlace {
524            preserve_partitioning: false,
525        }),
526        // Only when the estimator *gave up*. Repartitioning needs equijoin keys
527        // to hash on and more than one partition to be worth planning, but the
528        // binding condition is the degenerate estimate — see below.
529        PartitionMode::CollectLeft
530            if rescue_degenerate_broadcast
531                && target_partitions > 1
532                && !hash_join.on().is_empty()
533                && crate::join_estimates::BuildSideEstimate::of(hash_join.left())
534                    .any_claims_empty() =>
535        {
536            Some(Conversion::Repartition {
537                partitions: target_partitions,
538            })
539        }
540        _ => None,
541    }
542}
543
544/// Convert hash joins whose estimated build side cannot fit the per-task
545/// memory share into sort-merge joins, which can spill.
546#[derive(Debug)]
547pub struct SpillableJoinSelection {
548    /// Build-size threshold in bytes; `None` disables the rule entirely
549    /// (no memory cap → nothing to protect against).
550    threshold_bytes: Option<u64>,
551    /// Send oversized joins to [`crate::grace_hash_join`] instead of sort-merge.
552    ///
553    /// A field rather than an environment read at the point of use: the choice
554    /// is then visible in the rule's own state, and a test can exercise both
555    /// paths without mutating process-wide environment that every other test in
556    /// the binary shares.
557    grace: bool,
558    /// Allow the degenerate-broadcast rescue in `convertible_mode` to
559    /// hash-partition a join's inputs.
560    ///
561    /// Off for the coordinator. The rescue is a *distribution* change, and in a
562    /// plan that is about to be cut into stages every exchange becomes a stage
563    /// boundary: q21 at SF100 went from 11 stages / 758 s to 13 / 1061 s when
564    /// this fired during distributed planning. The coordinator does not need it
565    /// either — q21 completes distributed; it is the embedded path, which has
566    /// no stages to add and no other guard, that cannot survive without it.
567    rescue_degenerate_broadcast: bool,
568}
569
570impl SpillableJoinSelection {
571    /// Derive the threshold from the process's capacity decision, honouring
572    /// [`SPILL_JOIN_BUILD_BYTES_ENV`].
573    pub fn from_capacity() -> Self {
574        let threshold_bytes = std::env::var(SPILL_JOIN_BUILD_BYTES_ENV)
575            .ok()
576            .and_then(|v| v.trim().parse::<u64>().ok())
577            .filter(|n| *n > 0)
578            .or_else(|| {
579                // Deliberately the per-slot share, in every process. Giving an
580                // embedded query the whole pool to size joins against was
581                // measured faster and wrong — see
582                // `executor_capacity::declare_single_query_process`. The flag
583                // below controls the broadcast rescue and nothing else.
584                let share = krishiv_common::executor_capacity::ExecutorCapacity::detect_cached()
585                    .min_task_memory_share_bytes()?;
586                #[expect(
587                    clippy::cast_precision_loss,
588                    clippy::cast_possible_truncation,
589                    clippy::cast_sign_loss,
590                    reason = "byte counts are far below f64's exact-integer range"
591                )]
592                Some((share as f64 * BUILD_FRACTION_OF_TASK_SHARE) as u64)
593            });
594        Self {
595            threshold_bytes,
596            // NOT grace-aware. `from_capacity` is reached from the coordinator's
597            // planning context (`spill_join_build_bytes: None`), and a grace
598            // join in a stage plan cannot be encoded, which collapses the whole
599            // query to a single task. Grace is opted into explicitly by
600            // `for_local_execution`, which only the post-decode executor path
601            // calls.
602            grace: false,
603            // Only a one-shot CLI process rescues a degenerate broadcast join.
604            // A coordinator installs this same rule in its planning session, and
605            // there an added exchange is an added stage boundary: scoping by
606            // call site does not work, because both reach `from_capacity`.
607            rescue_degenerate_broadcast:
608                krishiv_common::executor_capacity::is_single_query_process(),
609        }
610    }
611
612    /// Forbid the degenerate-broadcast rescue — for a plan that is about to be
613    /// cut into stages, where an added exchange is an added stage boundary.
614    #[must_use]
615    pub fn without_broadcast_rescue(self) -> Self {
616        Self {
617            rescue_degenerate_broadcast: false,
618            ..self
619        }
620    }
621
622    /// Same threshold as [`Self::from_capacity`], but allowed to choose the
623    /// grace hash join.
624    ///
625    /// Only for plans that are already decoded and will not be serialized —
626    /// see `distributed_plan::apply_local_spill_strategy`.
627    #[must_use]
628    pub fn for_local_execution() -> Self {
629        Self {
630            grace: crate::grace_hash_join::enabled(),
631            ..Self::from_capacity()
632        }
633    }
634
635    /// Allow grace **only** in a process that never encodes a stage plan.
636    ///
637    /// `with_krishiv_optimizer_rules` is shared by two callers with opposite
638    /// requirements: the coordinator's staging planner, whose output must
639    /// survive `datafusion-proto` (a `GraceHashJoinExec` there fails to encode
640    /// and the scheduler's response is to run the whole query as a SINGLE
641    /// TASK), and the one-shot CLI, whose plans never leave the process.
642    ///
643    /// `is_single_query_process()` separates them exactly: stage building
644    /// happens only in `build_stages_for_parquet_tables`, reached solely from
645    /// `krishiv-scheduler`'s `distributed_batch`, i.e. the coordinator daemon,
646    /// which never declares itself single-query. It is also the same predicate
647    /// that gates `rescue_degenerate_broadcast`, which is the point: the rescue
648    /// exists to make a degenerate broadcast spillable, and grace is the better
649    /// way to spill one. Enabling them apart is what left the rescue handing
650    /// every join to sort-merge while grace sat unreachable.
651    ///
652    /// This does **not** turn grace on — `grace_hash_join::enabled()` still
653    /// defaults off. It stops `KRISHIV_GRACE_HASH_JOIN` from being silently
654    /// inert in the tier that has the rescue.
655    #[must_use]
656    pub fn with_grace_where_plans_are_never_encoded(self) -> Self {
657        self.with_grace_gated(
658            krishiv_common::executor_capacity::is_single_query_process(),
659            crate::grace_hash_join::enabled(),
660        )
661    }
662
663    /// The gate above with both inputs passed in.
664    ///
665    /// Split out purely so the *closed* direction can be tested with the flag
666    /// **on** — the only version of that test worth having. Reading the real
667    /// inputs would make it assert nothing: grace is false when the flag is
668    /// unset, so a passing test could not tell a shut gate from an absent
669    /// flag. The env cannot be set in the test either (`forbid(unsafe_code)`),
670    /// and `declare_single_query_process()` is a latch with no reset.
671    ///
672    /// Only ever *enables*: a caller that already chose grace
673    /// (`for_local_execution`) keeps it.
674    #[must_use]
675    fn with_grace_gated(self, single_query_process: bool, flag: bool) -> Self {
676        if single_query_process && flag {
677            Self { grace: true, ..self }
678        } else {
679            self
680        }
681    }
682
683    /// The per-join threshold to actually apply, once the **total** unspillable
684    /// build footprint of the plan is taken into account.
685    ///
686    /// `threshold` describes how much build memory a task can afford. It was
687    /// being asked of each join *individually*, which is the wrong question:
688    /// a hash join build side cannot spill, every join in a fragment holds its
689    /// build side at once, and they all draw on one pool. TPC-H q10 at SF100
690    /// planned **8 hash joins and converted 1** — the other 7 each sat under
691    /// 250 MB and together exhausted a 2.6 GB pool, after which the next join
692    /// was refused 877 bytes and the query died.
693    ///
694    /// So the budget applies to the sum. The **smallest** joins are retained as
695    /// hash joins and everything from the first join that breaks the budget
696    /// upward converts — the question is "which joins can we afford to leave
697    /// un-spillable", and the cheapest ones are the ones worth keeping.
698    ///
699    /// (This paragraph said "largest joins convert first" for one revision after
700    /// the code stopped doing that. The first implementation walked descending
701    /// and returned the largest join's size — routinely *above* the configured
702    /// threshold, so the "budget" loosened the rule instead of tightening it.
703    /// The walk was fixed to ascending; the prose was not, and described an
704    /// algorithm that no longer existed.)
705    ///
706    /// The decision is **per join**, keyed by position in `transform_up` order.
707    ///
708    /// An earlier version returned a single tightened threshold instead, which
709    /// kept the existing mechanism but could not express two things:
710    ///
711    /// - **Equal-sized joins became all-or-nothing.** No one threshold can
712    ///   retain two of three joins of identical size, so a plan whose joins tie
713    ///   converted all of them once the sum broke the budget — over-converting
714    ///   to the slower sort-merge plan. Ties are not exotic: sibling joins over
715    ///   similarly-sized shuffle inputs estimate identically.
716    /// - **The budget counted joins `convert` would refuse.** A join whose mode
717    ///   is unconvertible holds its build side regardless, so the threshold
718    ///   tightened against memory that was never going to be freed, converting
719    ///   smaller joins while the real consumer stayed.
720    ///
721    /// Both were written off as needing node identity that `ExecutionPlan` does
722    /// not offer. It does not offer *pointer* identity — `transform_up` rebuilds
723    /// parents as their children change — but post-order **position** is stable,
724    /// and that is all this needs.
725    ///
726    /// Unconvertible and unmeasurable joins are charged to the budget first,
727    /// since they are retained no matter what. The remainder is spent on the
728    /// candidates smallest-first: the question is "which joins can we afford to
729    /// leave un-spillable", and the cheapest ones are the ones worth keeping.
730    ///
731    /// A strict generalisation: with no aggregate pressure every join is
732    /// retained here and the per-join gate decides as it always did, so a plan
733    /// that was fine before behaves identically — the q2 regression risk is
734    /// unchanged.
735    fn conversion_decisions(facts: &[JoinFacts], threshold: u64) -> Vec<bool> {
736        // The share an *unknown* build side is assumed to hold. Shared with
737        // `unknown_build_pressure`, and used for the degenerate sentinel too —
738        // see `JoinFacts::budget_bytes` for why one sentinel must not be
739        // allowed to saturate a whole plan's arithmetic.
740        let assumed_share = if facts.is_empty() {
741            0
742        } else {
743            threshold / facts.len() as u64
744        };
745        let measured = facts
746            .iter()
747            .map(|f| f.budget_bytes(assumed_share))
748            .fold(0u64, u64::saturating_add);
749        let unknown_pressure = unknown_build_pressure(facts, threshold);
750        let total = measured.saturating_add(unknown_pressure);
751        // A degenerate estimate IS aggregate pressure — that is the whole
752        // meaning of the sentinel — so it must not be able to short-circuit its
753        // way out. Charging it an assumed share (above) fixed the budget it was
754        // saturating, but with a single degenerate join that share is the whole
755        // threshold and `total <= threshold` held by equality, retaining exactly
756        // the join the sentinel exists to convert. Both halves are needed.
757        let degenerate = facts
758            .iter()
759            .any(|f| f.bytes == Some(DEGENERATE_BUILD_BYTES));
760        // No aggregate pressure: let the per-join gate decide, as before.
761        if !degenerate && total <= threshold {
762            return vec![false; facts.len()];
763        }
764
765        // Joins the rule cannot convert are retained whatever we decide, so
766        // their bytes come off the top rather than pretending they are
767        // available to spend. An unmeasurable join is exactly that kind of
768        // join — the per-join gate always keeps it — so its assumed share is
769        // charged here too.
770        let unavoidable = facts
771            .iter()
772            .filter(|f| !f.is_candidate())
773            .map(|f| f.budget_bytes(assumed_share))
774            .fold(0u64, u64::saturating_add)
775            .saturating_add(unknown_pressure);
776        let mut budget = threshold.saturating_sub(unavoidable);
777
778        let mut candidates: Vec<(usize, u64)> = facts
779            .iter()
780            .enumerate()
781            .filter(|(_, f)| f.is_candidate())
782            .map(|(at, f)| (at, f.retained_bytes()))
783            .collect();
784        // Smallest first, and `sort_by_key` is stable, so equal sizes are
785        // retained in plan order — deterministic rather than arbitrary.
786        candidates.sort_by_key(|(_, bytes)| *bytes);
787
788        let mut convert = vec![false; facts.len()];
789        for (at, bytes) in candidates {
790            if bytes <= budget {
791                budget -= bytes;
792            } else if let Some(slot) = convert.get_mut(at) {
793                *slot = true;
794            }
795        }
796        convert
797    }
798
799    /// Explicit threshold, for tests. Keeps the sort-merge conversion.
800    #[must_use]
801    pub fn with_threshold(threshold_bytes: Option<u64>) -> Self {
802        Self {
803            threshold_bytes,
804            grace: false,
805            // Enabled, unlike `from_capacity`: a test binary is not a
806            // single-query CLI process, and gating on that here would make
807            // every rescue test silently vacuous. Tests that need it off call
808            // `without_broadcast_rescue`.
809            rescue_degenerate_broadcast: true,
810        }
811    }
812
813    /// Explicit threshold and algorithm, for tests.
814    #[must_use]
815    pub fn with_threshold_and_grace(threshold_bytes: Option<u64>, grace: bool) -> Self {
816        Self {
817            threshold_bytes,
818            grace,
819            rescue_degenerate_broadcast: true,
820        }
821    }
822
823
824    /// Replace `hash_join` with the spilling grace hash join.
825    ///
826    /// No projection to restore and no sorts to insert: the operator keeps the
827    /// original join whole and joins it a bucket at a time, so its type, filter,
828    /// null equality and built-in projection come along unchanged. That is the
829    /// whole reason to prefer it — `reapply_projection` exists only because
830    /// `SortMergeJoinExec` drops the projection, and getting those indices wrong
831    /// is what broke live q7/q8/q9.
832    /// `build_input`/`probe_input` are the sides *after* `conversion` has been
833    /// applied, not the join's original children. That distinction is the whole
834    /// fix: `GraceHashJoinExec::try_new` rejects exactly one thing — sides with
835    /// different partition counts — and a degenerate broadcast is defined by
836    /// having them. Offered the raw children, grace declined every rescued join
837    /// and the rule then hash-partitioned both sides itself for sort-merge,
838    /// producing the alignment grace had just been refused for lacking. The
839    /// better algorithm was unreachable on the one shape this rescue exists for.
840    ///
841    /// # No configuration reaches this combination today
842    ///
843    /// Measured on SF100 q21, 2026-08-04: the rescue fires (three
844    /// `Repartition`s, five conversions) and grace is never *consulted* —
845    /// `self.grace` is false. `for_local_execution` is the only constructor
846    /// that enables grace and it is reached only from
847    /// `distributed_plan::apply_local_spill_strategy`, i.e. the post-decode
848    /// **executor**, where `is_single_query_process()` is false and so the
849    /// rescue is off. The embedded CLI is the mirror image: rescue on, grace
850    /// hard-off, because its session comes from `from_capacity` /
851    /// `with_threshold`.
852    ///
853    /// So this repairs a latent contradiction rather than a live regression.
854    /// It becomes load-bearing the moment grace is allowed anywhere the rescue
855    /// runs — the obvious candidate being the embedded session, which plans
856    /// locally, never encodes, and is exactly where a degenerate broadcast
857    /// exhausted the pool in the first place.
858    fn grace_join(
859        &self,
860        hash_join: &HashJoinExec,
861        conversion: Conversion,
862        build_input: &Arc<dyn ExecutionPlan>,
863        probe_input: &Arc<dyn ExecutionPlan>,
864        build_bytes: u64,
865        threshold: u64,
866    ) -> Result<Arc<dyn ExecutionPlan>> {
867        // `builder()` clones the node; `reset_state()` drops the original's
868        // collected build side and dynamic filter so the copy starts clean.
869        let mut builder = hash_join.builder().reset_state().with_new_children(vec![
870            Arc::clone(build_input),
871            Arc::clone(probe_input),
872        ])?;
873        // Both sides are now hash-partitioned on the join keys, so the mode has
874        // to say so. `CollectLeft` over partitioned children is not merely
875        // mislabelled: grace runs `left().execute(partition)` for each output
876        // partition, which on a one-partition build side would be out of range.
877        if matches!(conversion, Conversion::Repartition { .. }) {
878            builder = builder
879                .with_partition_mode(PartitionMode::Partitioned)
880                .recompute_properties();
881        }
882        let template = Arc::new(builder.build()?);
883        let mode = *template.partition_mode();
884        // Bucket for what ONE TASK builds, not for the whole relation.
885        //
886        // `build_bytes` describes every partition of the build side, but
887        // `threshold` is a per-task memory share, and a task executes exactly
888        // one partition of a `Partitioned` join. Feeding the whole-relation
889        // figure to a per-task budget over-partitions by the partition count.
890        //
891        // Measured on the SF100 cluster 2026-08-08. q21's LeftSemi build side
892        // estimates 14.2 GB across 18 partitions — 790 MB per task, which wants
893        // ~7 buckets against a 250 MB share. It asked for **114**, and the
894        // LeftAnti for 76. Each bucket is its own spill file and its own hash
895        // join pass, so stage 3's median task went from 180 s under sort-merge
896        // to **500 s** under grace: the operator that is supposed to be the
897        // better trade lost 2.8x, on bookkeeping rather than on the join.
898        let per_task_build_bytes = match mode {
899            PartitionMode::Partitioned => {
900                let partitions = template.left().output_partitioning().partition_count().max(1);
901                build_bytes / partitions as u64
902            }
903            // `CollectLeft` buffers the entire build side in every task, so the
904            // whole-relation figure is the right one there.
905            _ => build_bytes,
906        };
907        let buckets = crate::grace_hash_join::bucket_count(per_task_build_bytes, threshold);
908        let budget = usize::try_from(threshold).unwrap_or(usize::MAX);
909        let grace = crate::grace_hash_join::GraceHashJoinExec::try_new(template, buckets, budget)?;
910        tracing::info!(
911            build_bytes,
912            per_task_build_bytes,
913            threshold,
914            buckets,
915            ?mode,
916            join_type = ?hash_join.join_type(),
917            "hash join build side exceeds per-task memory share; using grace hash join"
918        );
919        Ok(Arc::new(grace))
920    }
921
922    /// Restore `hash_join`'s built-in projection on top of `converted`.
923    ///
924    /// A no-op when the join carried none, which is the common case.
925    fn reapply_projection(
926        converted: Arc<dyn ExecutionPlan>,
927        hash_join: &HashJoinExec,
928    ) -> Result<Arc<dyn ExecutionPlan>> {
929        use datafusion::physical_expr::expressions::Column;
930        use datafusion::physical_plan::projection::ProjectionExec;
931
932        let Some(projection) = hash_join.projection.as_ref() else {
933            return Ok(converted);
934        };
935        let schema = converted.schema();
936        let mut exprs: Vec<(Arc<dyn datafusion::physical_expr::PhysicalExpr>, String)> =
937            Vec::with_capacity(projection.len());
938        for &index in projection.iter() {
939            let Some(field) = schema.fields().get(index) else {
940                // The projection does not address this plan's schema after all.
941                // Refusing here is safe: the caller keeps the hash join.
942                return datafusion::error::Result::Err(
943                    datafusion::error::DataFusionError::Plan(format!(
944                        "spillable-join: projection index {index} is outside the \
945                         converted join's {} columns",
946                        schema.fields().len()
947                    )),
948                );
949            };
950            exprs.push((
951                Arc::new(Column::new(field.name(), index)),
952                field.name().clone(),
953            ));
954        }
955        Ok(Arc::new(ProjectionExec::try_new(exprs, converted)?))
956    }
957
958    /// Whether this hash join should become a sort-merge join, and if so, the
959    /// converted node.
960    fn convert(
961        &self,
962        hash_join: &HashJoinExec,
963        threshold: u64,
964        target_partitions: usize,
965    ) -> Result<Option<Arc<dyn ExecutionPlan>>> {
966        // Gate 3: the join mode must be convertible to sort-merge.
967        // `CollectLeft` is convertible exactly when the plan has one partition
968        // — which, on this engine, is the *common* case rather than an edge
969        // case. A task engine is built with
970        // `target_partitions = cores / slots`, and a 3-core executor running 3
971        // slots gets **1**. DataFusion never emits `Partitioned` at one
972        // partition, so every join in every fragment was `CollectLeft`, and
973        // this gate turned all of them away: the rule converted zero joins in
974        // three hours across three executors while five SF100 queries died on
975        // build sides it was written to rescue.
976        //
977        // The original premise — "CollectLeft build sides are small by
978        // construction" — is false. `CollectLeft` is chosen from an *estimate*
979        // and buffers the whole build side, so a wrong estimate makes it the
980        // worst mode to be in, not the safest. q9 and q10 each took the entire
981        // 797 MB pool this way.
982        //
983        // Sort-merge needs its inputs sorted on the join keys and co-located.
984        // With one partition, "sorted" is the whole requirement — there is no
985        // distribution to preserve — so the conversion is *simpler* here than
986        // in the partitioned case, not riskier.
987        let Some(conversion) =
988            convertible_mode(hash_join, target_partitions, self.rescue_degenerate_broadcast)
989        else {
990            tracing::debug!(
991                mode = ?hash_join.partition_mode(),
992                threshold,
993                "spillable-join: join mode is not convertible"
994            );
995            return Ok(None);
996        };
997        // Gate 2: the build side must be *known* to be large. Absent statistics
998        // keep hash join — guessing "big" is how the reverted session-wide
999        // switch timed out q2.
1000        //
1001        // Logged, because a rule that silently declines is indistinguishable
1002        // from a rule that was never installed. Three SF100 queries died on
1003        // un-spillable hash joins while this rule sat registered and converted
1004        // nothing, and the logs could not say which gate turned each one away.
1005        let Some(build_bytes) = build_bytes_estimate(hash_join) else {
1006            tracing::debug!(
1007                threshold,
1008                "spillable-join: build-side size and row count both unknown, \
1009                 keeping hash join"
1010            );
1011            return Ok(None);
1012        };
1013        // No `build_bytes <= threshold` test here any more: whether this join
1014        // can be afforded is a whole-plan question, decided once in
1015        // `conversion_decisions` and passed in by the caller. Asking it again
1016        // per join is what let seven joins each sit under the threshold and
1017        // together exhaust the pool.
1018
1019        // Sort both sides on the join keys. Partition preservation follows the
1020        // mode decided above: keep it for `Partitioned` (so no exchange is
1021        // re-planned), drop it for single-partition `CollectLeft` (where there
1022        // is nothing to preserve).
1023        let on = hash_join.on();
1024
1025        // A degenerate broadcast join is given the distribution it should have
1026        // had before anything is sorted — see `convertible_mode`. The sorts
1027        // below then run per partition, exactly as in the `Partitioned` case.
1028        //
1029        // This runs *before* the choice of algorithm, not just before the sorts:
1030        // both candidates want these inputs, and computing them here is what
1031        // lets grace be judged on the sides the rule actually produces. It used
1032        // to be judged on the raw children and declined every rescued join.
1033        let (build_input, probe_input, preserve_partitioning) = match conversion {
1034            Conversion::InPlace {
1035                preserve_partitioning,
1036            } => (
1037                Arc::clone(hash_join.left()),
1038                Arc::clone(hash_join.right()),
1039                preserve_partitioning,
1040            ),
1041            Conversion::Repartition { partitions } => {
1042                let build_keys: Vec<_> = on.iter().map(|(l, _)| Arc::clone(l)).collect();
1043                let probe_keys: Vec<_> = on.iter().map(|(_, r)| Arc::clone(r)).collect();
1044                let build = RepartitionExec::try_new(
1045                    Arc::clone(hash_join.left()),
1046                    Partitioning::Hash(build_keys, partitions),
1047                )?;
1048                let probe = RepartitionExec::try_new(
1049                    Arc::clone(hash_join.right()),
1050                    Partitioning::Hash(probe_keys, partitions),
1051                )?;
1052                tracing::info!(
1053                    partitions,
1054                    build_bytes,
1055                    threshold,
1056                    join_type = ?hash_join.join_type(),
1057                    "spillable-join: broadcast build side is too large to buffer; \
1058                     hash-partitioning both sides so it can spill"
1059                );
1060                (
1061                    Arc::new(build) as Arc<dyn ExecutionPlan>,
1062                    Arc::new(probe) as Arc<dyn ExecutionPlan>,
1063                    true,
1064                )
1065            }
1066        };
1067
1068        // The build side is too big. Two ways to make it spill:
1069        //
1070        //   grace hash join — partition both sides by key and join bucket by
1071        //     bucket, each bucket an ordinary in-memory hash join. Nothing is
1072        //     sorted, and the buckets that would have fitted never touch disk.
1073        //   sort-merge — sort *both* sides in full, always. Correct, spillable,
1074        //     and the reason q2 went from 208 s to 1317 s.
1075        //
1076        // Grace is tried first; sort-merge remains the fallback for the shapes
1077        // it refuses. Off by default — see `grace_hash_join::enabled`.
1078        //
1079        // # "Grace is strictly the better trade" — measured, and it is not
1080        //
1081        // That claim stood here unmeasured for weeks. TPC-H q21 at SF100,
1082        // 3 nodes, three interleaved A/B pairs on one image, 2026-08-08:
1083        //
1084        // | buckets | q21 wall (median of 3) | vs sort-merge |
1085        // |---|---|---|
1086        // | sort-merge | **632.8 s** | — |
1087        // | grace, 32 (the floor) | 1075.9 s | **1.70x slower** |
1088        // | grace, 114 (before the per-task bucket fix) | 2257.3 s | 3.43x slower |
1089        // | grace, 8 (`KRISHIV_GRACE_HASH_JOIN_BUCKETS`) | — | **OOM** |
1090        //
1091        // Arm A's spread across the three passes was 4%, so 1.70x is a result
1092        // and not drift. Fewer buckets is not the answer either: at 8 the query
1093        // dies with `Failed to allocate additional 39.4 MB for HashJoinInput
1094        // with 2.6 GB already allocated`, which is what `DEFAULT_BUCKETS = 32`
1095        // is protecting against.
1096        //
1097        // So on this shape — two stacked self-joins over `lineitem` on
1098        // `l_orderkey`, ~1.2 GB of shuffle read per task — sort-merge wins at
1099        // every bucket count that survives. The sorts amortise; per-bucket
1100        // spill files do not, on nodes whose disk is shared with MinIO.
1101        //
1102        // This is one query on one cluster, so it is not a reason to delete
1103        // grace. It IS a reason not to reach for it as an obvious win, and the
1104        // flag stays off by default.
1105        if self.grace {
1106            match self.grace_join(
1107                hash_join,
1108                conversion,
1109                &build_input,
1110                &probe_input,
1111                build_bytes,
1112                threshold,
1113            ) {
1114                // Returned as-is: grace keeps the join whole, projection
1115                // included, so `reapply_projection` here would project twice.
1116                Ok(converted) => return Ok(Some(converted)),
1117                // At info, not debug. A declining rule is indistinguishable from
1118                // an absent one, and this decline sends the query to the
1119                // operator that cost q2 1109 s — on an executor at
1120                // `RUST_LOG=info` the old `debug!` left no trace whatsoever.
1121                Err(error) => tracing::info!(
1122                    %error,
1123                    build_bytes,
1124                    "spillable-join: grace hash join declined; trying sort-merge"
1125                ),
1126            }
1127        }
1128
1129        let left_keys: Vec<PhysicalSortExpr> = on
1130            .iter()
1131            .map(|(l, _)| PhysicalSortExpr::new_default(Arc::clone(l)))
1132            .collect();
1133        let right_keys: Vec<PhysicalSortExpr> = on
1134            .iter()
1135            .map(|(_, r)| PhysicalSortExpr::new_default(Arc::clone(r)))
1136            .collect();
1137        let (Some(left_ordering), Some(right_ordering)) = (
1138            LexOrdering::new(left_keys),
1139            LexOrdering::new(right_keys),
1140        ) else {
1141            return Ok(None);
1142        };
1143        let sort_options = left_ordering
1144            .iter()
1145            .map(|sort_expr| sort_expr.options)
1146            .collect();
1147
1148        let sorted_left =
1149            sort_unless_already_sorted(build_input, left_ordering, preserve_partitioning);
1150        let sorted_right =
1151            sort_unless_already_sorted(probe_input, right_ordering, preserve_partitioning);
1152
1153        // Sort-merge materialises the filter's columns left-side-first
1154        // regardless of the order `column_indices` declares, so a filter that
1155        // names a right-side column first has to be permuted into that layout
1156        // or the batch will not match its own schema. See `left_first_filter` —
1157        // this is q17 and q19 at SF100.
1158        let filter = match hash_join.filter() {
1159            Some(filter) => match left_first_filter(filter) {
1160                Some(normalised) => Some(normalised),
1161                None => {
1162                    tracing::debug!(
1163                        "spillable-join: join filter cannot be reordered for sort-merge, \
1164                         keeping hash join"
1165                    );
1166                    return Ok(None);
1167                }
1168            },
1169            None => None,
1170        };
1171
1172        // Let SortMergeJoinExec's own validation decide whether this join
1173        // shape (type, filter) is supported; on refusal, keep the hash join
1174        // rather than fail the query.
1175        match SortMergeJoinExec::try_new(
1176            sorted_left,
1177            sorted_right,
1178            on.to_vec(),
1179            filter,
1180            *hash_join.join_type(),
1181            sort_options,
1182            hash_join.null_equality(),
1183        ) {
1184            Ok(smj) => {
1185                // `HashJoinExec` has a built-in projection; `SortMergeJoinExec`
1186                // does not (there is a TODO to that effect in DataFusion's
1187                // source). Converting a projected join therefore silently
1188                // widens the output back to the full left++right schema, and
1189                // the *parent* join's positional `on` columns then point at the
1190                // wrong fields — live q7/q8/q9 failed with
1191                // `Missing on the right: Column { name: "o_custkey", index: 3 }`.
1192                //
1193                // Reproduce the projection explicitly. The join's projection
1194                // indices address the same full join schema `SortMergeJoinExec`
1195                // produces (DataFusion validates them against it with
1196                // `can_project(&join_schema, ..)`), so selecting those indices
1197                // off the converted join yields the identical output columns,
1198                // order and names.
1199                let converted = Self::reapply_projection(Arc::new(smj), hash_join)?;
1200                tracing::info!(
1201                    build_bytes,
1202                    threshold,
1203                    mode = ?hash_join.partition_mode(),
1204                    join_type = ?hash_join.join_type(),
1205                    projected = hash_join.contains_projection(),
1206                    "hash join build side exceeds per-task memory share; using sort-merge join"
1207                );
1208                Ok(Some(converted))
1209            }
1210            Err(error) => {
1211                tracing::debug!(%error, "sort-merge conversion declined; keeping hash join");
1212                Ok(None)
1213            }
1214        }
1215    }
1216}
1217
1218/// Sort `input` on `ordering` — unless it is already sorted that way.
1219///
1220/// # Why this is not premature cleverness
1221///
1222/// This rule runs *after* `EnforceSorting`, so every `SortExec` it inserts is
1223/// final: nothing downstream ever revisits the plan to notice that one of them
1224/// is redundant. And converting a join to sort-merge makes its output ordered,
1225/// which is exactly the input a *stacked* join is then handed.
1226///
1227/// TPC-H q21 at SF100 is that shape verbatim. Its `NOT EXISTS` and `EXISTS`
1228/// become two joins on the same key, one feeding the other, and both convert:
1229///
1230/// ```text
1231/// SortMergeJoinExec LeftAnti  on l_orderkey
1232///   SortExec [l_orderkey]                    <- re-sorts an already-sorted input
1233///     SortMergeJoinExec LeftSemi  on l_orderkey
1234///       SortExec [l_orderkey] ...            <- genuine
1235///       SortExec [l_orderkey] ...            <- genuine
1236///   SortExec [l_orderkey] ...                <- genuine
1237/// ```
1238///
1239/// `SortMergeJoinExec::maintains_input_order` is `[true, false]` for every
1240/// `Left*` join type, so DataFusion already reports the LeftSemi's output as
1241/// ordered on `l_orderkey`. The middle sort therefore re-sorted several GB per
1242/// task — spilling, because the whole reason the join converted is that its
1243/// build side does not fit — to reach an order it was already in. Measured:
1244/// that one stage is 73% of q21's task time.
1245///
1246/// The check is DataFusion's own `ordering_satisfy`, not a hand-rolled
1247/// comparison, so an input sorted on a *superset* prefix or on an equivalent
1248/// column also counts.
1249///
1250/// # Partitioning is part of the contract
1251///
1252/// A `SortExec` with `preserve_partitioning(false)` outputs ONE partition
1253/// whatever it was given, so skipping it would silently change the plan's
1254/// partitioning. Dropping it is only safe when the input already has the
1255/// partition count the sort would have produced.
1256fn sort_unless_already_sorted(
1257    input: Arc<dyn ExecutionPlan>,
1258    ordering: LexOrdering,
1259    preserve_partitioning: bool,
1260) -> Arc<dyn ExecutionPlan> {
1261    let partitions_match =
1262        preserve_partitioning || input.output_partitioning().partition_count() == 1;
1263    if partitions_match
1264        && input
1265            .equivalence_properties()
1266            .ordering_satisfy(ordering.clone())
1267            .unwrap_or(false)
1268    {
1269        tracing::debug!(
1270            ordering = %ordering,
1271            "spillable-join: input already sorted on the join keys; skipping the sort"
1272        );
1273        return input;
1274    }
1275    Arc::new(SortExec::new(ordering, input).with_preserve_partitioning(preserve_partitioning))
1276}
1277
1278impl PhysicalOptimizerRule for SpillableJoinSelection {
1279    fn name(&self) -> &str {
1280        "spillable_join_selection"
1281    }
1282
1283    fn schema_check(&self) -> bool {
1284        // The conversion preserves the join's output schema exactly; sorts add
1285        // no columns.
1286        true
1287    }
1288
1289    fn optimize(
1290        &self,
1291        plan: Arc<dyn ExecutionPlan>,
1292        config: &ConfigOptions,
1293    ) -> Result<Arc<dyn ExecutionPlan>> {
1294        // Gate 1: no cap, no change.
1295        let Some(configured) = self.threshold_bytes else {
1296            tracing::debug!("spillable-join: no memory cap configured, rule inactive");
1297            return Ok(plan);
1298        };
1299        // Needed to re-partition a degenerate broadcast join — see
1300        // `convertible_mode`. Read from the session rather than from capacity:
1301        // it is the number of partitions this plan is actually being built for.
1302        let target_partitions = config.execution.target_partitions.max(1);
1303        // The budget is on the SUM of un-converted build sides, not on each one
1304        // separately — see `conversion_decisions`. Decisions are indexed by
1305        // position in `transform_up` order, which `collect_join_facts` mirrors.
1306        let mut facts = Vec::new();
1307        collect_join_facts(
1308            &plan,
1309            target_partitions,
1310            self.rescue_degenerate_broadcast,
1311            &mut facts,
1312        );
1313        let decisions = Self::conversion_decisions(&facts, configured);
1314        let threshold = configured;
1315        let mut at = 0usize;
1316        let mut seen = 0usize;
1317        let mut converted = 0usize;
1318        let mut declined_on_error = 0usize;
1319        let out = plan
1320            .transform_up(|node| {
1321                // `ExecutionPlan: Any` — upcast to downcast (DF 54 has no `as_any`).
1322                let any = node.as_ref() as &dyn std::any::Any;
1323                let Some(hash_join) = any.downcast_ref::<HashJoinExec>() else {
1324                    return Ok(Transformed::no(node));
1325                };
1326                let index = at;
1327                at += 1;
1328                seen += 1;
1329                // Not chosen by the budget: leave it a hash join.
1330                //
1331                // `unwrap_or(false)` rather than a panic: if the two traversals
1332                // ever disagreed about how many joins exist, converting nothing
1333                // is the safe answer — this rule must never be the reason a
1334                // query fails.
1335                if !decisions.get(index).copied().unwrap_or(false) {
1336                    return Ok(Transformed::no(node));
1337                }
1338                // A rule that rewrites plans for *memory* reasons must never be
1339                // the reason a query fails. Live q7/q8/q9 turned an internal
1340                // refusal ("the left or right side of the join does not have
1341                // all columns on `on`") into a failed fragment, trading an
1342                // out-of-memory error for a planning error — strictly worse,
1343                // because the un-converted plan at least had a chance of
1344                // fitting. Declining is always available; erroring is not.
1345                match self.convert(hash_join, threshold, target_partitions) {
1346                    Ok(Some(plan)) => {
1347                        converted += 1;
1348                        Ok(Transformed::yes(plan))
1349                    }
1350                    Ok(None) => Ok(Transformed::no(node)),
1351                    Err(error) => {
1352                        declined_on_error += 1;
1353                        tracing::warn!(
1354                            %error,
1355                            mode = ?hash_join.partition_mode(),
1356                            join_type = ?hash_join.join_type(),
1357                            "spillable-join: conversion errored; keeping hash join"
1358                        );
1359                        Ok(Transformed::no(node))
1360                    }
1361                }
1362            })
1363            .map(|t| t.data)?;
1364        // One line that distinguishes "no hash joins in this plan", "joins seen
1365        // and left alone", and "rule not installed" — three states that were
1366        // previously identical from outside, which is what made three SF100
1367        // failures take a live investigation to attribute.
1368        if seen > 0 {
1369            // At info, not debug: executors run RUST_LOG=info, and the
1370            // debug-level version of this line was invisible in the only
1371            // environment that had the bug it was added to diagnose.
1372            tracing::info!(
1373                hash_joins = seen,
1374                converted,
1375                declined_on_error,
1376                configured_threshold = configured,
1377                chosen_by_budget = decisions.iter().filter(|d| **d).count(),
1378                unconvertible = facts.iter().filter(|f| !f.convertible).count(),
1379                unmeasurable = facts.iter().filter(|f| f.bytes.is_none()).count(),
1380                "spillable-join: pass complete"
1381            );
1382        }
1383        Ok(out)
1384    }
1385}
1386
1387#[cfg(test)]
1388#[allow(clippy::unwrap_used, clippy::expect_used)]
1389mod tests {
1390    use super::*;
1391    use datafusion::prelude::{SessionConfig, SessionContext};
1392
1393    /// A session whose joins plan as `PartitionMode::Partitioned` even at test
1394    /// sizes — the mode the rule targets. Without forcing the thresholds down,
1395    /// DataFusion plans tiny joins as CollectLeft and the rule (correctly)
1396    /// declines, which makes the conversion test pass vacuously.
1397    fn partitioned_join_ctx() -> SessionContext {
1398        let mut config = SessionConfig::new().with_target_partitions(4);
1399        config.options_mut().optimizer.hash_join_single_partition_threshold = 0;
1400        config.options_mut().optimizer.hash_join_single_partition_threshold_rows = 0;
1401        SessionContext::new_with_config(config)
1402    }
1403
1404    async fn joined_plan(ctx: &SessionContext) -> Arc<dyn ExecutionPlan> {
1405        ctx.sql("CREATE TABLE big AS SELECT v % 1000 AS k, v AS payload FROM (VALUES (1)) t(x), UNNEST(range(0, 20000)) AS u(v)")
1406            .await.unwrap().collect().await.unwrap();
1407        ctx.sql("CREATE TABLE small AS SELECT v AS k FROM (VALUES (1)) t(x), UNNEST(range(0, 100)) AS u(v)")
1408            .await.unwrap().collect().await.unwrap();
1409        ctx.sql("SELECT b.k, count(*) FROM big b JOIN small s ON b.k = s.k GROUP BY b.k")
1410            .await.unwrap().create_physical_plan().await.unwrap()
1411    }
1412
1413    fn contains(plan: &Arc<dyn ExecutionPlan>, name: &str) -> bool {
1414        datafusion::physical_plan::displayable(plan.as_ref())
1415            .indent(true)
1416            .to_string()
1417            .contains(name)
1418    }
1419
1420    /// With a threshold below every build side, partitioned hash joins become
1421    /// sort-merge joins — the conversion mechanics work end to end, and the
1422    /// converted plan still executes to the same answer.
1423    #[tokio::test]
1424    async fn an_oversized_build_side_converts_and_still_answers_correctly() {
1425        let ctx = partitioned_join_ctx();
1426        let plan = joined_plan(&ctx).await;
1427        assert!(contains(&plan, "HashJoinExec"), "precondition: hash join planned");
1428        assert!(
1429            contains(&plan, "mode=Partitioned"),
1430            "precondition: the join must be Partitioned or the rule correctly declines:\n{}",
1431            datafusion::physical_plan::displayable(plan.as_ref()).indent(true)
1432        );
1433
1434        let rule = SpillableJoinSelection::with_threshold(Some(1));
1435        let optimized = rule.optimize(Arc::clone(&plan), &ConfigOptions::default()).unwrap();
1436        assert!(
1437            contains(&optimized, "SortMergeJoin"),
1438            "an over-threshold build side must convert:\n{}",
1439            datafusion::physical_plan::displayable(optimized.as_ref()).indent(true)
1440        );
1441
1442        // A converted plan that returns different rows would be worse than the
1443        // failure it prevents. Baseline is planned afresh: the optimized tree
1444        // shares untransformed Arc subtrees with `plan`, and RepartitionExec
1445        // panics ("partition not used yet") if one instance is executed twice.
1446        let baseline_plan = ctx
1447            .sql("SELECT b.k, count(*) FROM big b JOIN small s ON b.k = s.k GROUP BY b.k")
1448            .await.unwrap().create_physical_plan().await.unwrap();
1449        let baseline =
1450            datafusion::physical_plan::collect(baseline_plan, ctx.task_ctx()).await.unwrap();
1451        let converted =
1452            datafusion::physical_plan::collect(optimized, ctx.task_ctx()).await.unwrap();
1453        let count = |bs: &[arrow::record_batch::RecordBatch]| -> usize {
1454            bs.iter().map(|b| b.num_rows()).sum()
1455        };
1456        assert_eq!(count(&baseline), count(&converted));
1457    }
1458
1459    /// Grace over a genuinely partitioned join, with rows, answers correctly.
1460    ///
1461    /// `grace_tests` runs everything at one partition, where `execute(0)` is the
1462    /// only call there is. Grace actually runs `left().execute(partition)` and
1463    /// `right().execute(partition)` for *each* output partition and joins them
1464    /// pairwise, which is only sound because both sides are hash-partitioned on
1465    /// the join keys. Nothing tested that pairing carried the right rows, and
1466    /// the rescue path now sends production traffic through it.
1467    #[tokio::test]
1468    async fn grace_over_partitioned_inputs_answers_correctly() {
1469        let ctx = partitioned_join_ctx();
1470        let plan = joined_plan(&ctx).await;
1471        assert!(
1472            contains(&plan, "mode=Partitioned"),
1473            "precondition: the join must be Partitioned or this tests nothing:\n{}",
1474            datafusion::physical_plan::displayable(plan.as_ref()).indent(true)
1475        );
1476
1477        let optimized = SpillableJoinSelection::with_threshold_and_grace(Some(1), true)
1478            .optimize(Arc::clone(&plan), &ConfigOptions::default())
1479            .unwrap();
1480        assert!(
1481            contains(&optimized, "GraceHashJoin"),
1482            "grace must be what ran, or the answer proves nothing about it:\n{}",
1483            datafusion::physical_plan::displayable(optimized.as_ref()).indent(true)
1484        );
1485
1486        // Planned afresh — the optimized tree shares untransformed Arc subtrees
1487        // with `plan`, and RepartitionExec panics if one instance runs twice.
1488        let baseline_plan = ctx
1489            .sql("SELECT b.k, count(*) FROM big b JOIN small s ON b.k = s.k GROUP BY b.k")
1490            .await.unwrap().create_physical_plan().await.unwrap();
1491        let baseline =
1492            datafusion::physical_plan::collect(baseline_plan, ctx.task_ctx()).await.unwrap();
1493        let converted =
1494            datafusion::physical_plan::collect(optimized, ctx.task_ctx()).await.unwrap();
1495
1496        // Values, not row counts: a mispaired partition would drop some groups
1497        // and keep the total plausible. `k` is the group key, so sorting the
1498        // rendered pairs makes the comparison order-independent.
1499        let cells = |bs: &[arrow::record_batch::RecordBatch]| -> Vec<String> {
1500            let mut out = Vec::new();
1501            for b in bs {
1502                for row in 0..b.num_rows() {
1503                    let cols: Vec<String> = (0..b.num_columns())
1504                        .map(|c| {
1505                            arrow::util::display::array_value_to_string(b.column(c), row).unwrap()
1506                        })
1507                        .collect();
1508                    out.push(cols.join("|"));
1509                }
1510            }
1511            out.sort();
1512            out
1513        };
1514        let expected = cells(&baseline);
1515        assert_eq!(expected.len(), 100, "fixture should produce one group per key");
1516        assert_eq!(cells(&converted), expected, "grace changed the answer");
1517    }
1518
1519    /// A build side comfortably under the threshold keeps its hash join. This
1520    /// is the q2 protection — the reverted session-wide switch failed exactly
1521    /// this property.
1522    #[tokio::test]
1523    async fn a_small_build_side_keeps_its_hash_join() {
1524        let ctx = partitioned_join_ctx();
1525        let plan = joined_plan(&ctx).await;
1526        let rule = SpillableJoinSelection::with_threshold(Some(u64::MAX));
1527        let optimized = rule.optimize(Arc::clone(&plan), &ConfigOptions::default()).unwrap();
1528        assert!(contains(&optimized, "HashJoinExec"), "under-threshold joins stay hash");
1529        assert!(!contains(&optimized, "SortMergeJoin"));
1530    }
1531
1532    /// Two joins on the same key, stacked: the upper one must not re-sort the
1533    /// lower one's already-sorted output.
1534    ///
1535    /// This is TPC-H q21's shape — `EXISTS` and `NOT EXISTS` over the same
1536    /// table on the same key — and on the SF100 cluster that one stage is
1537    /// **64% of the whole query's task time**. Three of its four sorts are
1538    /// genuine; the fourth re-sorted the semi-join's output into the order the
1539    /// semi-join had already produced it in.
1540    ///
1541    /// Counting sorts is the assertion because it is the thing that regressed:
1542    /// a version that only checked "the answer is right" passed against the
1543    /// redundant sort, which is correct and merely slow.
1544    #[tokio::test]
1545    async fn a_stacked_join_on_the_same_key_does_not_re_sort() {
1546        let ctx = partitioned_join_ctx();
1547        ctx.sql("CREATE TABLE l1 AS SELECT v % 1000 AS k FROM (VALUES (1)) t(x), UNNEST(range(0, 20000)) AS u(v)")
1548            .await.unwrap().collect().await.unwrap();
1549        ctx.sql("CREATE TABLE l2 AS SELECT v % 700 AS k FROM (VALUES (1)) t(x), UNNEST(range(0, 20000)) AS u(v)")
1550            .await.unwrap().collect().await.unwrap();
1551        ctx.sql("CREATE TABLE l3 AS SELECT v % 300 AS k FROM (VALUES (1)) t(x), UNNEST(range(0, 20000)) AS u(v)")
1552            .await.unwrap().collect().await.unwrap();
1553        let sql = "SELECT l1.k FROM l1 \
1554                   WHERE EXISTS (SELECT 1 FROM l2 WHERE l2.k = l1.k) \
1555                     AND NOT EXISTS (SELECT 1 FROM l3 WHERE l3.k = l1.k)";
1556        let plan = ctx.sql(sql).await.unwrap().create_physical_plan().await.unwrap();
1557
1558        let joins = |plan: &Arc<dyn ExecutionPlan>| -> usize {
1559            datafusion::physical_plan::displayable(plan.as_ref())
1560                .indent(true)
1561                .to_string()
1562                .matches("HashJoinExec")
1563                .count()
1564        };
1565        assert_eq!(
1566            joins(&plan),
1567            2,
1568            "precondition: both subqueries must plan as hash joins:\n{}",
1569            datafusion::physical_plan::displayable(plan.as_ref()).indent(true)
1570        );
1571        assert!(
1572            contains(&plan, "mode=Partitioned"),
1573            "precondition: partitioned, or the rule declines and this tests nothing:\n{}",
1574            datafusion::physical_plan::displayable(plan.as_ref()).indent(true)
1575        );
1576
1577        let optimized = SpillableJoinSelection::with_threshold(Some(1))
1578            .optimize(Arc::clone(&plan), &ConfigOptions::default())
1579            .unwrap();
1580        let rendered = datafusion::physical_plan::displayable(optimized.as_ref())
1581            .indent(true)
1582            .to_string();
1583        assert_eq!(
1584            rendered.matches("SortMergeJoin").count(),
1585            2,
1586            "precondition: both joins convert, or there is no stacking to test:\n{rendered}"
1587        );
1588        // Four inputs feed two joins; one of them — the lower join's output —
1589        // arrives sorted. Three sorts, not four.
1590        assert_eq!(
1591            rendered.matches("SortExec").count(),
1592            3,
1593            "the upper join must reuse the lower join's ordering:\n{rendered}"
1594        );
1595
1596        // Skipping a sort must not change what comes out. Planned afresh: the
1597        // optimized tree shares untransformed Arc subtrees with `plan`.
1598        let baseline_plan = ctx.sql(sql).await.unwrap().create_physical_plan().await.unwrap();
1599        let baseline =
1600            datafusion::physical_plan::collect(baseline_plan, ctx.task_ctx()).await.unwrap();
1601        let converted =
1602            datafusion::physical_plan::collect(optimized, ctx.task_ctx()).await.unwrap();
1603        let rows = |bs: &[arrow::record_batch::RecordBatch]| -> usize {
1604            bs.iter().map(|b| b.num_rows()).sum()
1605        };
1606        assert!(rows(&baseline) > 0, "fixture must produce rows");
1607        assert_eq!(rows(&converted), rows(&baseline), "skipping the sort changed the answer");
1608    }
1609
1610    /// No memory cap means no threshold means no change — the embedded engine
1611    /// on a big machine must keep the fast path untouched.
1612    #[tokio::test]
1613    async fn no_cap_leaves_the_plan_alone() {
1614        let ctx = partitioned_join_ctx();
1615        let plan = joined_plan(&ctx).await;
1616        let rule = SpillableJoinSelection::with_threshold(None);
1617        let optimized = rule.optimize(Arc::clone(&plan), &ConfigOptions::default()).unwrap();
1618        assert!(contains(&optimized, "HashJoinExec"));
1619        assert!(!contains(&optimized, "SortMergeJoin"));
1620    }
1621}
1622
1623#[cfg(test)]
1624#[allow(clippy::unwrap_used, clippy::expect_used)]
1625mod row_count_fallback_tests {
1626    use super::*;
1627    use arrow::datatypes::{DataType, Field, Schema};
1628    use datafusion::common::{ColumnStatistics, Statistics};
1629
1630    fn schema(fields: Vec<Field>) -> Schema {
1631        Schema::new(fields)
1632    }
1633
1634    fn stats_with(num_rows: Precision<usize>, columns: usize) -> Statistics {
1635        Statistics {
1636            num_rows,
1637            total_byte_size: Precision::Absent,
1638            column_statistics: vec![ColumnStatistics::new_unknown(); columns],
1639        }
1640    }
1641
1642    #[test]
1643    fn absent_rows_and_bytes_yields_no_estimate() {
1644        // The one case where the planner truly knows nothing: keep hash join
1645        // rather than guess. This is the guard against re-creating the
1646        // session-wide switch that timed q2 out.
1647        let s = schema(vec![Field::new("k", DataType::Int64, false)]);
1648        assert_eq!(
1649            estimated_build_bytes_from_rows(&stats_with(Precision::Absent, 1), &s),
1650            None
1651        );
1652    }
1653
1654    #[test]
1655    fn a_row_count_gives_an_estimate_when_byte_size_is_absent() {
1656        // The q9 case: rows known, bytes not. 1M rows x one 8-byte column.
1657        let s = schema(vec![Field::new("k", DataType::Int64, false)]);
1658        assert_eq!(
1659            estimated_build_bytes_from_rows(&stats_with(Precision::Exact(1_000_000), 1), &s),
1660            Some(8_000_000)
1661        );
1662    }
1663
1664    #[test]
1665    fn inexact_row_counts_count_too() {
1666        // Post-filter estimates are Inexact; refusing them would leave the
1667        // fallback inert on exactly the plans that need it.
1668        let s = schema(vec![Field::new("k", DataType::Int64, false)]);
1669        assert_eq!(
1670            estimated_build_bytes_from_rows(&stats_with(Precision::Inexact(1_000), 1), &s),
1671            Some(8_000)
1672        );
1673    }
1674
1675    #[test]
1676    fn varlen_columns_get_a_modest_assumed_width() {
1677        // Utf8 has no fixed width. The estimate must still produce something,
1678        // and must not be wild: one Int64 + one Utf8 = 8 + 32 per row.
1679        let s = schema(vec![
1680            Field::new("k", DataType::Int64, false),
1681            Field::new("name", DataType::Utf8, false),
1682        ]);
1683        let want = 100 * (8 + crate::join_estimates::ASSUMED_VARLEN_COLUMN_BYTES as u64);
1684        assert_eq!(
1685            estimated_build_bytes_from_rows(&stats_with(Precision::Exact(100), 2), &s),
1686            Some(want)
1687        );
1688    }
1689
1690    #[test]
1691    fn a_zero_row_build_side_estimates_zero_not_unknown() {
1692        // Zero rows must not be conflated with "unknown": an empty build side
1693        // is the strongest possible reason to keep the hash join, and a `None`
1694        // here would read as "no information" instead.
1695        let s = schema(vec![Field::new("k", DataType::Int64, false)]);
1696        assert_eq!(
1697            estimated_build_bytes_from_rows(&stats_with(Precision::Exact(0), 1), &s),
1698            Some(0)
1699        );
1700    }
1701
1702    #[test]
1703    fn a_huge_row_count_does_not_overflow_into_a_small_estimate() {
1704        // Saturating arithmetic: an absurd row count must stay absurd rather
1705        // than wrap around to something that looks like it fits.
1706        let s = schema(vec![Field::new("k", DataType::Int64, false)]);
1707        let est = estimated_build_bytes_from_rows(&stats_with(Precision::Exact(usize::MAX), 1), &s)
1708            .expect("a known row count always yields an estimate");
1709        assert!(est > u64::from(u32::MAX), "estimate collapsed to {est}");
1710    }
1711}
1712
1713#[cfg(test)]
1714#[allow(clippy::unwrap_used, clippy::expect_used)]
1715mod collect_left_tests {
1716    use super::*;
1717    use datafusion::physical_plan::displayable;
1718    use datafusion::prelude::{SessionConfig, SessionContext};
1719
1720    /// A session that plans exactly the way a task engine does on a saturated
1721    /// executor: one target partition, because `cores / slots` is 1. This is
1722    /// the configuration in which every join is `CollectLeft` — the shape the
1723    /// rule used to skip entirely.
1724    fn single_partition_ctx() -> SessionContext {
1725        SessionContext::new_with_config(SessionConfig::new().with_target_partitions(1))
1726    }
1727
1728    fn shows(plan: &Arc<dyn ExecutionPlan>, name: &str) -> bool {
1729        displayable(plan.as_ref()).indent(true).to_string().contains(name)
1730    }
1731
1732    async fn one_partition_join_plan(ctx: &SessionContext) -> Arc<dyn ExecutionPlan> {
1733        ctx.sql("CREATE TABLE l(k INT, v INT) AS VALUES (1, 10), (2, 20), (3, 30)")
1734            .await
1735            .unwrap()
1736            .collect()
1737            .await
1738            .unwrap();
1739        ctx.sql("CREATE TABLE r(k INT, w INT) AS VALUES (1, 100), (2, 200)")
1740            .await
1741            .unwrap()
1742            .collect()
1743            .await
1744            .unwrap();
1745        ctx.sql("SELECT l.v, r.w FROM l JOIN r ON l.k = r.k")
1746            .await
1747            .unwrap()
1748            .create_physical_plan()
1749            .await
1750            .unwrap()
1751    }
1752
1753    #[tokio::test]
1754    async fn a_single_partition_plan_really_does_produce_collect_left() {
1755        // Pins the premise the fix rests on. If DataFusion ever stops choosing
1756        // CollectLeft at one partition, the tests below stop testing anything
1757        // and this one says so first.
1758        let ctx = single_partition_ctx();
1759        let plan = one_partition_join_plan(&ctx).await;
1760        assert!(
1761            shows(&plan, "CollectLeft"),
1762            "expected CollectLeft at target_partitions=1, got:\n{}",
1763            displayable(plan.as_ref()).indent(true)
1764        );
1765    }
1766
1767    #[tokio::test]
1768    async fn a_large_collect_left_join_becomes_sort_merge() {
1769        // The regression that mattered: with a threshold below the build side,
1770        // the rule must now convert. Before this fix it returned the plan
1771        // untouched no matter how large the build side was.
1772        let ctx = single_partition_ctx();
1773        let plan = one_partition_join_plan(&ctx).await;
1774        let rule = SpillableJoinSelection::with_threshold(Some(1));
1775        let out = rule.optimize(plan, ctx.copied_config().options()).unwrap();
1776        assert!(
1777            shows(&out, "SortMergeJoin"),
1778            "CollectLeft join was not converted:\n{}",
1779            displayable(out.as_ref()).indent(true)
1780        );
1781    }
1782
1783    #[tokio::test]
1784    async fn a_small_collect_left_join_is_left_alone() {
1785        // Hash join is the right algorithm when it fits; the fix must not
1786        // convert everything just because it now *can*.
1787        let ctx = single_partition_ctx();
1788        let plan = one_partition_join_plan(&ctx).await;
1789        let rule = SpillableJoinSelection::with_threshold(Some(64 * 1024 * 1024));
1790        let out = rule.optimize(plan, ctx.copied_config().options()).unwrap();
1791        assert!(shows(&out, "HashJoin"), "small join should stay a hash join");
1792        assert!(!shows(&out, "SortMergeJoin"));
1793    }
1794
1795    #[tokio::test]
1796    async fn the_converted_plan_returns_the_same_rows() {
1797        // A spillable plan that answers differently is not a fix. Compare the
1798        // converted plan's output against the hash-join plan's.
1799        use datafusion::physical_plan::collect;
1800        let ctx = single_partition_ctx();
1801        let plan = one_partition_join_plan(&ctx).await;
1802        let task_ctx = ctx.task_ctx();
1803
1804        let hash_rows = collect(Arc::clone(&plan), Arc::clone(&task_ctx)).await.unwrap();
1805        let converted = SpillableJoinSelection::with_threshold(Some(1))
1806            .optimize(plan, ctx.copied_config().options())
1807            .unwrap();
1808        assert!(shows(&converted, "SortMergeJoin"));
1809        let smj_rows = collect(converted, task_ctx).await.unwrap();
1810
1811        let total = |b: &[arrow::array::RecordBatch]| -> usize {
1812            b.iter().map(arrow::array::RecordBatch::num_rows).sum()
1813        };
1814        assert_eq!(total(&hash_rows), total(&smj_rows), "row count changed");
1815        assert_eq!(total(&smj_rows), 2, "expected the two matching keys");
1816    }
1817}
1818
1819/// The shape that killed TPC-H q21 embedded: a broadcast join whose probe side
1820/// is *already* split across partitions, so the old `convertible_mode` refused
1821/// it and the oversized build side had nowhere to spill to.
1822#[cfg(test)]
1823#[allow(clippy::unwrap_used, clippy::expect_used)]
1824mod degenerate_broadcast_tests {
1825    use super::*;
1826    use datafusion::physical_plan::{collect, displayable};
1827    use datafusion::prelude::{SessionConfig, SessionContext};
1828
1829    fn shows(plan: &Arc<dyn ExecutionPlan>, name: &str) -> bool {
1830        displayable(plan.as_ref()).indent(true).to_string().contains(name)
1831    }
1832
1833    /// Multi-partition, which is what an embedded session uses: an engine built
1834    /// with `target_partitions = available_parallelism()`, not the task
1835    /// engine's 1.
1836    fn multi_partition_ctx() -> SessionContext {
1837        SessionContext::new_with_config(SessionConfig::new().with_target_partitions(4))
1838    }
1839
1840    /// The q21 shape: a `CollectLeft` join whose **probe side is already
1841    /// split**. A tiny build side keeps DataFusion's broadcast choice, and a
1842    /// probe registered with two partitions keeps the split — the combination
1843    /// the old `convertible_mode` refused, leaving the build side nowhere to
1844    /// spill to.
1845    ///
1846    /// Registered as a real multi-partition table rather than assembled by
1847    /// hand: a `HashJoinExec` built directly over a `RepartitionExec` is not a
1848    /// plan DataFusion would emit, and executing it panics partitions that
1849    /// nothing polls. The bug is about a plan the planner really produces.
1850    /// `degenerate` chooses whether the build side's estimate *claims to be
1851    /// empty* — the q21 condition, and the only one this rescue fires on.
1852    async fn broadcast_join_over_split_probe(
1853        ctx: &SessionContext,
1854        degenerate: bool,
1855    ) -> Arc<dyn ExecutionPlan> {
1856        use arrow::array::Int32Array;
1857        use arrow::datatypes::{DataType, Field, Schema};
1858        use arrow::record_batch::RecordBatch;
1859        use datafusion::datasource::MemTable;
1860
1861        let build_schema = Arc::new(Schema::new(vec![
1862            Field::new("k", DataType::Int32, false),
1863            Field::new("v", DataType::Int32, false),
1864        ]));
1865        if degenerate {
1866            // Zero rows, so `BuildSideEstimate::any_claims_empty` holds — which
1867            // is what q21's LeftAnti produces for a relation that is really
1868            // 2 GB. The shape is the point; the size cannot be reproduced here.
1869            let empty = MemTable::try_new(Arc::clone(&build_schema), vec![vec![]]).unwrap();
1870            ctx.register_table("l", Arc::new(empty)).unwrap();
1871        } else {
1872            let rows = RecordBatch::try_new(
1873                Arc::clone(&build_schema),
1874                vec![
1875                    Arc::new(Int32Array::from(vec![1, 2, 3])),
1876                    Arc::new(Int32Array::from(vec![10, 20, 30])),
1877                ],
1878            )
1879            .unwrap();
1880            let table = MemTable::try_new(Arc::clone(&build_schema), vec![vec![rows]]).unwrap();
1881            ctx.register_table("l", Arc::new(table)).unwrap();
1882        }
1883
1884        let schema = Arc::new(Schema::new(vec![
1885            Field::new("k", DataType::Int32, false),
1886            Field::new("w", DataType::Int32, false),
1887        ]));
1888        let partition = |k: i32, w: i32| {
1889            vec![
1890                RecordBatch::try_new(
1891                    Arc::clone(&schema),
1892                    vec![
1893                        Arc::new(Int32Array::from(vec![k])),
1894                        Arc::new(Int32Array::from(vec![w])),
1895                    ],
1896                )
1897                .unwrap(),
1898            ]
1899        };
1900        // Two partitions, so the probe side arrives already split.
1901        let split = MemTable::try_new(
1902            Arc::clone(&schema),
1903            vec![partition(1, 100), partition(2, 200)],
1904        )
1905        .unwrap();
1906        ctx.register_table("r", Arc::new(split)).unwrap();
1907
1908        ctx.sql("SELECT l.v, r.w FROM l JOIN r ON l.k = r.k")
1909            .await
1910            .unwrap()
1911            .create_physical_plan()
1912            .await
1913            .unwrap()
1914    }
1915
1916    #[tokio::test]
1917    async fn the_premise_holds_a_collect_left_join_over_a_split_probe() {
1918        // If either half of this stops being true the tests below stop testing
1919        // anything, so assert the premise separately and first.
1920        let ctx = multi_partition_ctx();
1921        let plan = broadcast_join_over_split_probe(&ctx, true).await;
1922        assert!(shows(&plan, "CollectLeft"), "fixture is not a broadcast join");
1923        assert_eq!(
1924            plan.children()[0].output_partitioning().partition_count(),
1925            1,
1926            "build side should be un-split"
1927        );
1928        assert!(
1929            plan.children()[1].output_partitioning().partition_count() > 1,
1930            "probe side must be split — that is the case the old rule refused"
1931        );
1932    }
1933
1934    #[tokio::test]
1935    async fn an_oversized_broadcast_join_is_repartitioned_so_it_can_spill() {
1936        // q21 at SF100: `HashJoinInput` reached 2003.0 MB in a 2.3 GB pool
1937        // because this join could be neither buffered nor converted. It must now
1938        // convert, which means hash-partitioning both sides first.
1939        let ctx = multi_partition_ctx();
1940        let plan = broadcast_join_over_split_probe(&ctx, true).await;
1941        let out = SpillableJoinSelection::with_threshold(Some(1))
1942            .optimize(plan, ctx.copied_config().options())
1943            .unwrap();
1944        assert!(
1945            shows(&out, "SortMergeJoin"),
1946            "broadcast join over a split probe side was left un-spillable:\n{}",
1947            displayable(out.as_ref()).indent(true)
1948        );
1949        assert!(
1950            shows(&out, "RepartitionExec"),
1951            "sort-merge needs both sides hash-partitioned on the join keys:\n{}",
1952            displayable(out.as_ref()).indent(true)
1953        );
1954    }
1955
1956    /// The rescued join must be offered to grace, not handed straight to
1957    /// sort-merge.
1958    ///
1959    /// `GraceHashJoinExec::try_new`'s only rejection is a partition-count
1960    /// mismatch, and a degenerate broadcast is *defined* by having one — a
1961    /// 1-partition build side against a split probe. `convert` tried grace on
1962    /// those raw inputs, watched it decline, and then hash-partitioned both
1963    /// sides itself twenty lines further down. So on the one shape this rescue
1964    /// exists for, the better algorithm was unreachable by construction: grace
1965    /// was judged on inputs the rule was already about to replace.
1966    ///
1967    /// This is q21 at SF100 — five oversized joins, five sort-merges, and the
1968    /// sort-merge fallback is the operator that took q2 from 208 s to 1317 s.
1969    /// The decline was logged at `debug!`, so on an executor running
1970    /// `RUST_LOG=info` it left no trace at all.
1971    #[tokio::test]
1972    async fn a_rescued_broadcast_join_is_offered_to_grace() {
1973        let ctx = multi_partition_ctx();
1974        let plan = broadcast_join_over_split_probe(&ctx, true).await;
1975        let out = SpillableJoinSelection::with_threshold_and_grace(Some(1), true)
1976            .optimize(plan, ctx.copied_config().options())
1977            .unwrap();
1978
1979        assert!(
1980            shows(&out, "GraceHashJoin"),
1981            "grace declined a join whose sides this rule then repartitioned itself:\n{}",
1982            displayable(out.as_ref()).indent(true)
1983        );
1984        assert!(
1985            !shows(&out, "SortMergeJoin"),
1986            "grace applies here, so sort-merge should not have been reached:\n{}",
1987            displayable(out.as_ref()).indent(true)
1988        );
1989    }
1990
1991    /// The rescued grace plan executes, across every partition.
1992    ///
1993    /// Not a row-carrying test — the trigger is a degenerate estimate, and the
1994    /// only relation whose estimate honestly claims empty is an empty one, so
1995    /// this asserts 0 == 0 on the data. What it does exercise is the specific
1996    /// hazard of the new path: grace calls `left().execute(i)` and
1997    /// `right().execute(i)` per output partition, directly against the
1998    /// `RepartitionExec`s this rule inserts, and a `RepartitionExec` whose
1999    /// partitions are not all polled panics rather than under-counting. Rows
2000    /// through grace on partitioned inputs are covered by
2001    /// `tests::grace_over_partitioned_inputs_answers_correctly`.
2002    #[tokio::test]
2003    async fn the_rescued_grace_plan_executes_on_every_partition() {
2004        let ctx = multi_partition_ctx();
2005        let plan = broadcast_join_over_split_probe(&ctx, true).await;
2006        let task_ctx = ctx.task_ctx();
2007        let before = all_rows(Arc::clone(&plan), Arc::clone(&task_ctx)).await;
2008        let converted = SpillableJoinSelection::with_threshold_and_grace(Some(1), true)
2009            .optimize(plan, ctx.copied_config().options())
2010            .unwrap();
2011        assert!(shows(&converted, "GraceHashJoin"), "precondition: grace must have applied");
2012        let after = all_rows(converted, task_ctx).await;
2013
2014        assert_eq!(before, after, "row count changed across the re-plan");
2015        assert_eq!(after, 0, "an empty build side joins to nothing");
2016    }
2017
2018    /// Rows from every partition. Both the fixture and the converted plan have
2019    /// four output partitions, and `collect` drives only partition 0 — which
2020    /// does not merely under-count, it panics `RepartitionExec` for the
2021    /// partitions nothing ever polled.
2022    async fn all_rows(
2023        plan: Arc<dyn ExecutionPlan>,
2024        task_ctx: Arc<datafusion::execution::TaskContext>,
2025    ) -> usize {
2026        use datafusion::physical_plan::coalesce_partitions::CoalescePartitionsExec;
2027        let merged = Arc::new(CoalescePartitionsExec::new(plan));
2028        collect(merged, task_ctx)
2029            .await
2030            .unwrap()
2031            .iter()
2032            .map(arrow::array::RecordBatch::num_rows)
2033            .sum()
2034    }
2035
2036    #[tokio::test]
2037    async fn repartitioning_does_not_change_the_answer() {
2038        // Re-planning a join's distribution is only a fix if the rows survive it.
2039        let ctx = multi_partition_ctx();
2040        let plan = broadcast_join_over_split_probe(&ctx, true).await;
2041        let task_ctx = ctx.task_ctx();
2042        let before = all_rows(Arc::clone(&plan), Arc::clone(&task_ctx)).await;
2043        let converted = SpillableJoinSelection::with_threshold(Some(1))
2044            .optimize(plan, ctx.copied_config().options())
2045            .unwrap();
2046        let after = all_rows(converted, task_ctx).await;
2047
2048        // The build side really is empty here — a degenerate estimate is the
2049        // trigger, and the only relation whose estimate honestly claims empty
2050        // is an empty one. So this checks that the re-planned distribution
2051        // *executes* and agrees, not that it carries rows; rows through the
2052        // conversion are covered by `collect_left_tests` and `projection_tests`.
2053        assert_eq!(before, after, "row count changed across the re-plan");
2054        assert_eq!(after, 0, "an empty build side joins to nothing");
2055    }
2056
2057    #[tokio::test]
2058    async fn a_small_broadcast_join_keeps_its_hash_join() {
2059        // The repartition is a rescue, not a policy: a build side that fits must
2060        // still be broadcast, or every small dimension join pays for an exchange.
2061        let ctx = multi_partition_ctx();
2062        let plan = broadcast_join_over_split_probe(&ctx, false).await;
2063        let out = SpillableJoinSelection::with_threshold(Some(1 << 30))
2064            .optimize(Arc::clone(&plan), ctx.copied_config().options())
2065            .unwrap();
2066        assert!(
2067            !shows(&out, "SortMergeJoin"),
2068            "a build side well under the threshold was converted anyway:\n{}",
2069            displayable(out.as_ref()).indent(true)
2070        );
2071    }
2072
2073    /// The regression this rescue caused on its first outing, pinned so it
2074    /// cannot come back.
2075    ///
2076    /// The coordinator runs this same rule with more than one target partition.
2077    /// Rescuing *every* oversized broadcast join therefore re-planned ordinary
2078    /// distributed joins, and each added exchange is a stage boundary: TPC-H
2079    /// q21 at SF100 went from 11 stages / 758 s to 13 stages / 1061 s.
2080    ///
2081    /// A broadcast join whose estimate is honest must be left exactly as it is,
2082    /// however far over the threshold it sits — the budget can reason about it,
2083    /// and the distributed planner can already refuse it.
2084    #[tokio::test]
2085    async fn an_honest_broadcast_join_is_never_repartitioned() {
2086        let ctx = multi_partition_ctx();
2087        let plan = broadcast_join_over_split_probe(&ctx, false).await;
2088        assert!(shows(&plan, "CollectLeft"), "premise: an honest broadcast join");
2089        // Threshold 1 byte: every join is "oversized". Only the degenerate
2090        // estimate may buy an exchange, so this must still change nothing.
2091        let out = SpillableJoinSelection::with_threshold(Some(1))
2092            .optimize(plan, ctx.copied_config().options())
2093            .unwrap();
2094        assert!(
2095            !shows(&out, "RepartitionExec"),
2096            "an honestly-sized broadcast join gained an exchange — this is the \
2097             q21 distributed regression (11 stages -> 13):\n{}",
2098            displayable(out.as_ref()).indent(true)
2099        );
2100        assert!(
2101            shows(&out, "CollectLeft"),
2102            "the join should have been left alone entirely:\n{}",
2103            displayable(out.as_ref()).indent(true)
2104        );
2105    }
2106
2107    /// The coordinator opts out entirely, and must get the plan back untouched
2108    /// even for the degenerate shape the rescue exists to handle.
2109    ///
2110    /// Narrowing the rescue to degenerate estimates was *not* enough on its
2111    /// own: q21's degenerate `LeftAnti` is present in the distributed plan too,
2112    /// so the coordinator kept re-planning it and q21 stayed at 13 stages
2113    /// (888 s) instead of the baseline's 11 (758 s). What separates the two is
2114    /// not the estimate but who is planning — a plan about to be cut into
2115    /// stages cannot afford an exchange, and does not need one.
2116    #[tokio::test]
2117    async fn the_coordinator_never_repartitions_even_a_degenerate_broadcast() {
2118        let ctx = multi_partition_ctx();
2119        let plan = broadcast_join_over_split_probe(&ctx, true).await;
2120        let out = SpillableJoinSelection::with_threshold(Some(1))
2121            .without_broadcast_rescue()
2122            .optimize(plan, ctx.copied_config().options())
2123            .unwrap();
2124        assert!(
2125            !shows(&out, "RepartitionExec"),
2126            "a plan bound for stage-cutting gained an exchange:\n{}",
2127            displayable(out.as_ref()).indent(true)
2128        );
2129    }
2130
2131    /// The executor task engine plans at `target_partitions = cores / slots`,
2132    /// which is 1 on a saturated 3-core executor. Its broadcast joins have a
2133    /// single-partition probe side and must keep taking the simpler in-place
2134    /// conversion — this rescue must not plant an exchange there.
2135    #[tokio::test]
2136    async fn a_single_partition_broadcast_join_gains_no_exchange() {
2137        let ctx = SessionContext::new_with_config(SessionConfig::new().with_target_partitions(1));
2138        ctx.sql("CREATE TABLE l(k INT, v INT) AS VALUES (1, 10), (2, 20), (3, 30)")
2139            .await
2140            .unwrap()
2141            .collect()
2142            .await
2143            .unwrap();
2144        ctx.sql("CREATE TABLE r(k INT, w INT) AS VALUES (1, 100), (2, 200)")
2145            .await
2146            .unwrap()
2147            .collect()
2148            .await
2149            .unwrap();
2150        let plan = ctx
2151            .sql("SELECT l.v, r.w FROM l JOIN r ON l.k = r.k")
2152            .await
2153            .unwrap()
2154            .create_physical_plan()
2155            .await
2156            .unwrap();
2157        assert!(shows(&plan, "CollectLeft"), "premise: one partition broadcasts");
2158        let out = SpillableJoinSelection::with_threshold(Some(1))
2159            .optimize(plan, ctx.copied_config().options())
2160            .unwrap();
2161        assert!(shows(&out, "SortMergeJoin"));
2162        assert!(
2163            !shows(&out, "RepartitionExec"),
2164            "a one-partition plan gained an exchange it cannot use:\n{}",
2165            displayable(out.as_ref()).indent(true)
2166        );
2167    }
2168}
2169
2170#[cfg(test)]
2171#[allow(clippy::unwrap_used, clippy::expect_used)]
2172mod never_fails_the_query_tests {
2173    use super::*;
2174    use datafusion::physical_plan::displayable;
2175    use datafusion::prelude::{SessionConfig, SessionContext};
2176
2177    /// A plan whose joins the rule will want to convert.
2178    async fn joined_plan(ctx: &SessionContext) -> Arc<dyn ExecutionPlan> {
2179        ctx.sql("CREATE TABLE a(k INT, v INT) AS VALUES (1, 1), (2, 2)")
2180            .await
2181            .unwrap()
2182            .collect()
2183            .await
2184            .unwrap();
2185        ctx.sql("CREATE TABLE b(k INT, w INT) AS VALUES (1, 9)")
2186            .await
2187            .unwrap()
2188            .collect()
2189            .await
2190            .unwrap();
2191        ctx.sql("CREATE TABLE c(k INT, z INT) AS VALUES (1, 5)")
2192            .await
2193            .unwrap()
2194            .collect()
2195            .await
2196            .unwrap();
2197        // Two stacked joins: `transform_up` converts the inner one first, so
2198        // the outer one is asked about a child the rule already rewrote — the
2199        // shape that produced the live failure.
2200        ctx.sql("SELECT a.v, b.w, c.z FROM a JOIN b ON a.k = b.k JOIN c ON a.k = c.k")
2201            .await
2202            .unwrap()
2203            .create_physical_plan()
2204            .await
2205            .unwrap()
2206    }
2207
2208    #[tokio::test]
2209    async fn stacked_joins_never_make_the_rule_return_an_error() {
2210        // The live regression: q7/q8/q9 stopped failing with "Resources
2211        // exhausted" and started failing with
2212        // `spillable_join_selection / Error during planning: The left or right
2213        // side of the join does not have all columns on "on"`. Trading an
2214        // out-of-memory error for a planning error is strictly worse — the
2215        // un-converted plan at least had a chance of fitting. This rule is an
2216        // optimisation and must always be able to decline.
2217        let ctx = SessionContext::new_with_config(SessionConfig::new().with_target_partitions(1));
2218        let plan = joined_plan(&ctx).await;
2219        let out = SpillableJoinSelection::with_threshold(Some(1))
2220            .optimize(plan, ctx.copied_config().options());
2221        assert!(
2222            out.is_ok(),
2223            "the rule must never fail a plan; got {:?}",
2224            out.err()
2225        );
2226    }
2227
2228    #[tokio::test]
2229    async fn a_plan_the_rule_declines_is_returned_unchanged_and_still_runs() {
2230        use datafusion::physical_plan::collect;
2231        let ctx = SessionContext::new_with_config(SessionConfig::new().with_target_partitions(1));
2232        let plan = joined_plan(&ctx).await;
2233        let before = displayable(plan.as_ref()).indent(true).to_string();
2234        let task_ctx = ctx.task_ctx();
2235
2236        // Threshold far above anything here: every join declines.
2237        let out = SpillableJoinSelection::with_threshold(Some(1 << 40))
2238            .optimize(Arc::clone(&plan), ctx.copied_config().options())
2239            .unwrap();
2240        assert_eq!(
2241            before,
2242            displayable(out.as_ref()).indent(true).to_string(),
2243            "declining must leave the plan untouched"
2244        );
2245        let rows = collect(out, task_ctx).await.unwrap();
2246        let total: usize = rows.iter().map(arrow::array::RecordBatch::num_rows).sum();
2247        assert_eq!(total, 1, "the declined plan must still produce the join result");
2248    }
2249}
2250
2251#[cfg(test)]
2252#[allow(clippy::unwrap_used, clippy::expect_used)]
2253mod budget_tests {
2254    use super::*;
2255
2256    /// Facts as the rule actually sees them.
2257    ///
2258    /// Arrow rounds buffer allocations, so a source built to "look like" 200
2259    /// bytes reports ~296. Asserting on nominal sizes tested the allocator;
2260    /// these tests measure first and assert the *invariant*.
2261    fn facts(plan: &Arc<dyn ExecutionPlan>) -> Vec<JoinFacts> {
2262        let mut out = Vec::new();
2263        collect_join_facts(plan, 1, true, &mut out);
2264        out
2265    }
2266
2267    fn sizes_of(facts: &[JoinFacts]) -> Vec<u64> {
2268        facts.iter().map(|f| f.retained_bytes()).collect()
2269    }
2270
2271    /// Bytes left un-converted under `decisions`.
2272    fn retained(facts: &[JoinFacts], decisions: &[bool]) -> u64 {
2273        facts
2274            .iter()
2275            .zip(decisions)
2276            .filter(|(_, convert)| !**convert)
2277            .map(|(f, _)| f.retained_bytes())
2278            .fold(0, u64::saturating_add)
2279    }
2280
2281    /// The threshold is a budget on the SUM, not a per-join allowance.
2282    ///
2283    /// q10's SF100 shape: several joins that each fit comfortably and together
2284    /// do not. Under the old per-join rule every one of these is under the
2285    /// budget, nothing converts, and the pool is exhausted at run time.
2286    #[test]
2287    fn joins_that_each_fit_but_together_do_not_are_converted() {
2288        let plan = plan_with_build_sizes(&[200; 8]);
2289        let facts = facts(&plan);
2290        let sizes = sizes_of(&facts);
2291        let largest = *sizes.iter().max().expect("fixture has joins");
2292        let total: u64 = sizes.iter().copied().fold(0, u64::saturating_add);
2293
2294        // A budget every join fits under individually, that the sum exceeds —
2295        // exactly the state q10 was in.
2296        let budget = largest;
2297        assert!(
2298            total > budget,
2299            "fixture must create aggregate pressure: total {total} vs budget {budget}"
2300        );
2301
2302        let decisions = SpillableJoinSelection::conversion_decisions(&facts, budget);
2303        assert!(
2304            retained(&facts, &decisions) <= budget,
2305            "the un-converted sum {} exceeds the {budget} budget (sizes {sizes:?})",
2306            retained(&facts, &decisions)
2307        );
2308    }
2309
2310    /// No aggregate pressure → nothing is chosen for conversion, so a plan that
2311    /// behaved acceptably before behaves identically. This bounds the q2
2312    /// regression risk: converting more joins than necessary is what made q2 6x
2313    /// slower, and this only acts under real pressure.
2314    #[test]
2315    fn without_pressure_nothing_is_chosen() {
2316        let plan = plan_with_build_sizes(&[50, 60]);
2317        let facts = facts(&plan);
2318        let total: u64 = sizes_of(&facts).iter().copied().fold(0, u64::saturating_add);
2319        let decisions = SpillableJoinSelection::conversion_decisions(&facts, total + 1);
2320        assert!(
2321            decisions.iter().all(|convert| !convert),
2322            "a total that fits the budget must convert nothing: {decisions:?}"
2323        );
2324    }
2325
2326    /// One oversized join still converts on its own, as before.
2327    #[test]
2328    fn a_single_oversized_join_still_converts() {
2329        let plan = plan_with_build_sizes(&[900]);
2330        let facts = facts(&plan);
2331        let largest = *sizes_of(&facts).iter().max().expect("fixture has a join");
2332        let decisions = SpillableJoinSelection::conversion_decisions(&facts, largest - 1);
2333        assert_eq!(decisions, vec![true], "an over-budget join must convert");
2334    }
2335
2336    /// **q21 at SF100.** Joins whose build side cannot be estimated used to sum
2337    /// to zero, so the aggregate check concluded there was no pressure and
2338    /// converted nothing — then the pool was exhausted at run time by the very
2339    /// joins it had valued at nothing.
2340    ///
2341    /// The measured failure: a join asking for its FIRST 310.2 MB found 87.9 MB
2342    /// left of a 2.6 GB pool, its siblings already holding the rest.
2343    #[test]
2344    fn unmeasurable_joins_still_create_aggregate_pressure() {
2345        // Four joins whose size the planner cannot estimate at all.
2346        let facts = vec![
2347            JoinFacts { bytes: None, convertible: true },
2348            JoinFacts { bytes: None, convertible: true },
2349            JoinFacts { bytes: None, convertible: true },
2350            JoinFacts { bytes: Some(900), convertible: true },
2351        ];
2352        let threshold = 1000;
2353        // Before the fix `total` was 900, which fits 1000, so this returned all
2354        // false and the query died. The unknowns are now charged a share each.
2355        let decisions = SpillableJoinSelection::conversion_decisions(&facts, threshold);
2356        assert!(
2357            decisions.iter().any(|convert| *convert),
2358            "unmeasurable joins must count as pressure, or the budget is blind \
2359             to exactly the joins it exists to bound: {decisions:?}"
2360        );
2361    }
2362
2363    /// The other half of the tension, and the one that must not regress:
2364    /// **a plan whose joins are all measurable is completely untouched.**
2365    ///
2366    /// Guessing "big" for every join is the session-wide `prefer_hash_join =
2367    /// false` switch that took q2 from 189 s past a 2400 s timeout. The new
2368    /// pressure term returns zero when nothing is unmeasurable, so such a plan
2369    /// still short-circuits to the per-join gate exactly as before.
2370    #[test]
2371    fn measurable_plans_are_unaffected_by_the_unknown_pressure_term() {
2372        let facts = vec![
2373            JoinFacts { bytes: Some(50), convertible: true },
2374            JoinFacts { bytes: Some(60), convertible: true },
2375        ];
2376        assert_eq!(
2377            super::unknown_build_pressure(&facts, 1000),
2378            0,
2379            "no unknowns means no assumed pressure"
2380        );
2381        let decisions = SpillableJoinSelection::conversion_decisions(&facts, 1000);
2382        assert!(
2383            decisions.iter().all(|convert| !convert),
2384            "a fully measurable plan under budget must convert nothing: {decisions:?}"
2385        );
2386    }
2387
2388    /// The assumed share is per join, not per plan: one unknown among many
2389    /// small joins is not enough to force conversion on its own.
2390    #[test]
2391    fn a_single_unknown_among_many_does_not_force_conversion() {
2392        let mut facts: Vec<JoinFacts> = (0..9)
2393            .map(|_| JoinFacts { bytes: Some(1), convertible: true })
2394            .collect();
2395        facts.push(JoinFacts { bytes: None, convertible: true });
2396        // 10 joins, threshold 1000 -> one unknown is charged 100; 9 + 100 fits.
2397        let decisions = SpillableJoinSelection::conversion_decisions(&facts, 1000);
2398        assert!(
2399            decisions.iter().all(|convert| !convert),
2400            "one unknown among ten small joins is not aggregate pressure: {decisions:?}"
2401        );
2402    }
2403
2404    /// The boundary the pressure term cannot cross, pinned so nobody "fixes"
2405    /// it into converting joins the rest of the rule will refuse anyway.
2406    ///
2407    /// With every join unmeasurable the term sums to exactly `threshold`
2408    /// (n shares of `threshold / n`), `total <= threshold` short-circuits, and
2409    /// no join is chosen. That is correct: gate 2 keeps an unmeasurable join's
2410    /// hash join unconditionally and `is_candidate` requires `bytes.is_some()`,
2411    /// so a "convert" decision here would be silently ignored downstream.
2412    ///
2413    /// This is the shape TPC-H q21 was in when this term was written *for* it —
2414    /// all 414 spillable-join passes at SF100 reported
2415    /// `unmeasurable == hash_joins` because a declared primary key had disabled
2416    /// the tables' statistics. The term could not have helped, and did not. The
2417    /// fix belonged at the statistics layer.
2418    #[test]
2419    fn an_all_unknown_plan_is_beyond_the_budgets_reach() {
2420        let facts: Vec<JoinFacts> = (0..4)
2421            .map(|_| JoinFacts { bytes: None, convertible: true })
2422            .collect();
2423        assert_eq!(
2424            super::unknown_build_pressure(&facts, 1000),
2425            1000,
2426            "four unknowns each charged threshold/4 sum to the whole threshold"
2427        );
2428        let decisions = SpillableJoinSelection::conversion_decisions(&facts, 1000);
2429        assert!(
2430            decisions.iter().all(|convert| !convert),
2431            "an all-unknown plan has no candidate to convert: {decisions:?}"
2432        );
2433    }
2434
2435    /// A plan with no hash joins must decide nothing, and must not panic on the
2436    /// empty path.
2437    #[test]
2438    fn a_plan_without_joins_is_left_alone() {
2439        let plan = plan_with_build_sizes(&[]);
2440        let facts = facts(&plan);
2441        assert!(facts.is_empty());
2442        assert!(SpillableJoinSelection::conversion_decisions(&facts, 250).is_empty());
2443    }
2444
2445    /// Equal-sized joins are no longer all-or-nothing.
2446    ///
2447    /// This is the regression test for a real limitation that was documented
2448    /// and left in place for one revision: a single tightened threshold cannot
2449    /// separate joins of identical size, so three equal joins under a budget
2450    /// that fits two converted **all three** — over-converting to the slower
2451    /// sort-merge plan. Ties are not exotic; sibling joins over similarly-sized
2452    /// shuffle inputs estimate identically.
2453    ///
2454    /// Deciding per join by post-order position fixes it: exactly the one join
2455    /// that does not fit converts.
2456    #[test]
2457    fn equal_sized_joins_are_decided_individually() {
2458        let plan = plan_with_build_sizes(&[100, 100, 100]);
2459        let facts = facts(&plan);
2460        let sizes = sizes_of(&facts);
2461        let one = sizes.first().copied().expect("fixture has joins");
2462        // Room for exactly two of the three.
2463        let budget = one * 2;
2464
2465        let decisions = SpillableJoinSelection::conversion_decisions(&facts, budget);
2466        assert_eq!(
2467            decisions.iter().filter(|convert| **convert).count(),
2468            1,
2469            "exactly one of three equal joins should convert, not all of them: \
2470             {decisions:?} (sizes {sizes:?}, budget {budget})"
2471        );
2472        assert!(
2473            retained(&facts, &decisions) <= budget,
2474            "the retained set must still fit the budget"
2475        );
2476    }
2477
2478    /// A join the rule cannot convert must not be counted as spendable.
2479    ///
2480    /// Its build side is held whatever the budget decides, so charging it to
2481    /// the budget first is the difference between converting the joins that
2482    /// will actually free memory and converting smaller ones while the real
2483    /// consumer stays put.
2484    #[test]
2485    fn unconvertible_joins_are_charged_to_the_budget_first() {
2486        let big_unconvertible = JoinFacts { bytes: Some(100), convertible: false };
2487        let small_candidate = JoinFacts { bytes: Some(30), convertible: true };
2488        let facts = [big_unconvertible, small_candidate];
2489
2490        // 100 is already spent by the join that cannot convert, so the 30-byte
2491        // candidate does not fit in the remaining 20 and must convert.
2492        let decisions = SpillableJoinSelection::conversion_decisions(&facts, 120);
2493        assert_eq!(
2494            decisions,
2495            vec![false, true],
2496            "the unconvertible join stays (it must), and the candidate converts \
2497             because the budget it draws on is what is left after it"
2498        );
2499    }
2500
2501    /// A join whose size is unknown keeps its hash join at the per-join gate, so
2502    /// it is not a candidate and cannot be chosen for conversion.
2503    #[test]
2504    fn unmeasurable_joins_are_never_chosen() {
2505        let facts = [
2506            JoinFacts { bytes: None, convertible: true },
2507            JoinFacts { bytes: Some(500), convertible: true },
2508        ];
2509        let decisions = SpillableJoinSelection::conversion_decisions(&facts, 10);
2510        assert_eq!(decisions, vec![false, true]);
2511    }
2512
2513    /// Build a plan whose hash joins report the given build-side byte sizes.
2514    ///
2515    /// `effective_threshold` reads estimates through `partition_statistics`, so
2516    /// the sizes have to come from real statistics rather than being injected.
2517    /// `MemoryExec` over batches of a known width gives that.
2518    pub(super) fn plan_with_build_sizes(sizes: &[u64]) -> Arc<dyn ExecutionPlan> {
2519        // Built directly rather than planned from SQL: the point is to control
2520        // the build estimates exactly, and a planner is free to reorder joins.
2521        let mut plan: Arc<dyn ExecutionPlan> = sized_source(1);
2522        for size in sizes {
2523            plan = Arc::new(
2524                HashJoinExec::try_new(
2525                    sized_source(*size),
2526                    Arc::clone(&plan),
2527                    vec![(
2528                        Arc::new(datafusion::physical_expr::expressions::Column::new("k", 0)),
2529                        Arc::new(datafusion::physical_expr::expressions::Column::new("k", 0)),
2530                    )],
2531                    None,
2532                    &datafusion::common::JoinType::Inner,
2533                    None,
2534                    PartitionMode::CollectLeft,
2535                    datafusion::common::NullEquality::NullEqualsNothing,
2536                    false,
2537                )
2538                .expect("hash join"),
2539            );
2540        }
2541        plan
2542    }
2543
2544    /// A single-column source whose statistics report `bytes` total.
2545    pub(super) fn sized_source(bytes: u64) -> Arc<dyn ExecutionPlan> {
2546        use arrow::array::Int32Array;
2547        use arrow::datatypes::{DataType, Field, Schema};
2548        use arrow::record_batch::RecordBatch;
2549
2550        let schema = Arc::new(Schema::new(vec![Field::new("k", DataType::Int32, false)]));
2551        // 4 bytes per Int32 row, so `bytes / 4` rows reports ~`bytes`.
2552        let rows = usize::try_from(bytes / 4).unwrap_or(1).max(1);
2553        let batch = RecordBatch::try_new(
2554            Arc::clone(&schema),
2555            vec![Arc::new(Int32Array::from(vec![0; rows]))],
2556        )
2557        .expect("batch");
2558        datafusion::datasource::memory::MemorySourceConfig::try_new_exec(
2559            &[vec![batch]],
2560            schema,
2561            None,
2562        )
2563        .expect("memory exec")
2564    }
2565}
2566
2567#[cfg(test)]
2568#[allow(clippy::unwrap_used, clippy::expect_used)]
2569mod projection_tests {
2570    use super::*;
2571    use datafusion::physical_plan::{collect, displayable};
2572    use datafusion::prelude::{SessionConfig, SessionContext};
2573
2574    /// Two stacked joins where the inner one projects a subset of its columns.
2575    /// This is q10's shape: `customer JOIN orders JOIN lineitem`, where the
2576    /// middle join carries a projection and the outer join's `on` addresses
2577    /// its output positionally.
2578    async fn stacked_projected_plan(ctx: &SessionContext) -> Arc<dyn ExecutionPlan> {
2579        for ddl in [
2580            "CREATE TABLE c(c_custkey INT, c_name VARCHAR) AS VALUES (1, 'a'), (2, 'b')",
2581            "CREATE TABLE o(o_orderkey INT, o_custkey INT, o_total INT) AS VALUES (10, 1, 5)",
2582            "CREATE TABLE l(l_orderkey INT, l_qty INT) AS VALUES (10, 3)",
2583        ] {
2584            ctx.sql(ddl).await.unwrap().collect().await.unwrap();
2585        }
2586        ctx.sql(
2587            "SELECT c.c_name, l.l_qty \
2588             FROM c JOIN o ON c.c_custkey = o.o_custkey \
2589                    JOIN l ON o.o_orderkey = l.l_orderkey",
2590        )
2591        .await
2592        .unwrap()
2593        .create_physical_plan()
2594        .await
2595        .unwrap()
2596    }
2597
2598    /// Number of `SortMergeJoinExec` nodes anywhere in `plan`.
2599    ///
2600    /// The precondition every test below depends on. `convert` has six ways to
2601    /// decline (mode, absent statistics, build side under threshold, no sort
2602    /// keys, `SortMergeJoinExec::try_new` refusing, projection out of range),
2603    /// and every one of them returns the plan **unchanged** — which passes an
2604    /// assertion that the output still matches the input. Without this count, a
2605    /// rule that silently stopped converting would leave the whole module
2606    /// green.
2607    fn sort_merge_join_count(plan: &Arc<dyn ExecutionPlan>) -> usize {
2608        // `ExecutionPlan: Any` — upcast to downcast (DF 54 has no `as_any`).
2609        let any = plan.as_ref() as &dyn std::any::Any;
2610        let here = usize::from(any.downcast_ref::<SortMergeJoinExec>().is_some());
2611        here + plan
2612            .children()
2613            .iter()
2614            .map(|c| sort_merge_join_count(c))
2615            .sum::<usize>()
2616    }
2617
2618    /// Whether any `HashJoinExec` in `plan` carries a built-in projection —
2619    /// the condition `reapply_projection` exists for.
2620    fn has_projected_hash_join(plan: &Arc<dyn ExecutionPlan>) -> bool {
2621        let any = plan.as_ref() as &dyn std::any::Any;
2622        any.downcast_ref::<HashJoinExec>()
2623            .is_some_and(HashJoinExec::contains_projection)
2624            || plan.children().iter().any(|c| has_projected_hash_join(c))
2625    }
2626
2627    /// Every cell, row-sorted — not a row count.
2628    fn cells(batches: &[arrow::array::RecordBatch]) -> Vec<String> {
2629        let mut rows: Vec<String> = batches
2630            .iter()
2631            .flat_map(|b| {
2632                (0..b.num_rows()).map(move |r| {
2633                    (0..b.num_columns())
2634                        .map(|c| {
2635                            arrow::util::display::array_value_to_string(b.column(c), r)
2636                                .expect("cell")
2637                        })
2638                        .collect::<Vec<_>>()
2639                        .join("|")
2640                })
2641            })
2642            .collect();
2643        rows.sort();
2644        rows
2645    }
2646
2647    #[tokio::test]
2648    async fn converting_a_projected_join_keeps_the_output_columns() {
2649        // The live failure: converting a join that carries a projection widened
2650        // its output back to the full left++right schema, so the parent join's
2651        // positional `on` broke with
2652        // `Missing on the right: Column { name: "o_custkey", index: 3 }`.
2653        let ctx = SessionContext::new_with_config(SessionConfig::new().with_target_partitions(1));
2654        let plan = stacked_projected_plan(&ctx).await;
2655        let before_schema = plan.schema();
2656        assert!(
2657            has_projected_hash_join(&plan),
2658            "fixture must build a hash join carrying a projection, or this tests nothing:\n{}",
2659            displayable(plan.as_ref()).indent(true)
2660        );
2661
2662        let out = SpillableJoinSelection::with_threshold(Some(1))
2663            .optimize(Arc::clone(&plan), ctx.copied_config().options())
2664            .expect("the rule must not fail the plan");
2665
2666        assert!(
2667            sort_merge_join_count(&out) > 0,
2668            "the rule declined, so the conversion under test never ran:\n{}",
2669            displayable(out.as_ref()).indent(true)
2670        );
2671        assert_eq!(
2672            out.schema(),
2673            before_schema,
2674            "conversion changed the plan's output schema:\n{}",
2675            displayable(out.as_ref()).indent(true)
2676        );
2677    }
2678
2679    /// The values, not the shape.
2680    ///
2681    /// `reapply_projection` re-indexes the join's projection against the
2682    /// *converted* join's schema and takes each output column's name from that
2683    /// schema too. If those indices ever addressed different columns, the names
2684    /// and types would still line up — they are read from the same place the
2685    /// data is — so a schema comparison, a row count and a column count would
2686    /// all agree while every value was wrong. Only comparing cells catches it.
2687    #[tokio::test]
2688    async fn the_converted_projected_plan_returns_the_same_values() {
2689        let ctx = SessionContext::new_with_config(SessionConfig::new().with_target_partitions(1));
2690        let plan = stacked_projected_plan(&ctx).await;
2691        let task_ctx = ctx.task_ctx();
2692        assert!(has_projected_hash_join(&plan), "fixture must project");
2693
2694        let before = collect(Arc::clone(&plan), Arc::clone(&task_ctx)).await.unwrap();
2695        let out = SpillableJoinSelection::with_threshold(Some(1))
2696            .optimize(plan, ctx.copied_config().options())
2697            .unwrap();
2698        assert!(
2699            sort_merge_join_count(&out) > 0,
2700            "the rule declined, so the conversion under test never ran"
2701        );
2702        let after = collect(out, task_ctx).await.unwrap();
2703
2704        assert_eq!(cells(&before), cells(&after), "converted plan changed the data");
2705        assert_eq!(cells(&after), vec![String::from("a|3")], "expected the single matching row");
2706    }
2707}
2708
2709#[cfg(test)]
2710#[allow(clippy::unwrap_used, clippy::expect_used)]
2711mod grace_tests {
2712    use super::*;
2713    use crate::grace_hash_join::GraceHashJoinExec;
2714    use datafusion::physical_plan::{collect, displayable};
2715    use datafusion::prelude::{SessionConfig, SessionContext};
2716
2717    async fn joined_plan(ctx: &SessionContext) -> Arc<dyn ExecutionPlan> {
2718        ctx.sql("CREATE TABLE l(k INT, v INT) AS VALUES (1, 10), (2, 20), (3, 30)")
2719            .await.unwrap().collect().await.unwrap();
2720        ctx.sql("CREATE TABLE r(k INT, w INT) AS VALUES (1, 100), (2, 200), (2, 201)")
2721            .await.unwrap().collect().await.unwrap();
2722        ctx.sql("SELECT l.v, r.w FROM l JOIN r ON l.k = r.k")
2723            .await.unwrap().create_physical_plan().await.unwrap()
2724    }
2725
2726    fn grace_joins(plan: &Arc<dyn ExecutionPlan>) -> usize {
2727        // `ExecutionPlan: Any` — upcast to downcast (DF 54 has no `as_any`).
2728        let any = plan.as_ref() as &dyn std::any::Any;
2729        usize::from(any.downcast_ref::<GraceHashJoinExec>().is_some())
2730            + plan.children().iter().map(|c| grace_joins(c)).sum::<usize>()
2731    }
2732
2733    /// A partitioned grace join buckets for what ONE TASK builds.
2734    ///
2735    /// `build_bytes` covers every partition; `threshold` is a per-task share.
2736    /// Feeding one to the other over-partitions by the partition count, and
2737    /// each extra bucket is its own spill file and its own hash-join pass.
2738    ///
2739    /// Live on SF100 2026-08-08: q21's LeftSemi asked for **114** buckets on a
2740    /// build side that is 790 MB per task against a 250 MB share — about 7 are
2741    /// wanted. Stage 3's median task went 180 s (sort-merge) to 500 s (grace),
2742    /// losing 2.8x on bookkeeping rather than on the join.
2743    ///
2744    /// Asserts the bucket count, because that is the number that was wrong;
2745    /// every existing grace test asserts only that grace was chosen.
2746    #[tokio::test]
2747    async fn a_partitioned_grace_join_buckets_per_task_not_per_relation() {
2748        let mut config = SessionConfig::new().with_target_partitions(4);
2749        config.options_mut().optimizer.hash_join_single_partition_threshold = 0;
2750        config.options_mut().optimizer.hash_join_single_partition_threshold_rows = 0;
2751        let ctx = SessionContext::new_with_config(config);
2752        ctx.sql("CREATE TABLE big AS SELECT v % 1000 AS k, v AS payload FROM (VALUES (1)) t(x), UNNEST(range(0, 20000)) AS u(v)")
2753            .await.unwrap().collect().await.unwrap();
2754        ctx.sql("CREATE TABLE small AS SELECT v AS k FROM (VALUES (1)) t(x), UNNEST(range(0, 100)) AS u(v)")
2755            .await.unwrap().collect().await.unwrap();
2756        let plan = ctx
2757            .sql("SELECT b.k, count(*) FROM big b JOIN small s ON b.k = s.k GROUP BY b.k")
2758            .await.unwrap().create_physical_plan().await.unwrap();
2759        assert!(
2760            displayable(plan.as_ref()).indent(true).to_string().contains("mode=Partitioned"),
2761            "precondition: a partitioned join, or per-task and per-relation agree"
2762        );
2763
2764        // Threshold 1 byte, so the bucket count is driven entirely by the build
2765        // size and the difference between the two readings is maximal.
2766        let out = SpillableJoinSelection::with_threshold_and_grace(Some(1), true)
2767            .optimize(plan, ctx.copied_config().options())
2768            .unwrap();
2769
2770        fn grace_of(plan: &Arc<dyn ExecutionPlan>) -> Option<&GraceHashJoinExec> {
2771            let any = plan.as_ref() as &dyn std::any::Any;
2772            any.downcast_ref::<GraceHashJoinExec>()
2773                .or_else(|| plan.children().iter().find_map(|c| grace_of(c)))
2774        }
2775        let grace = grace_of(&out).expect("grace join");
2776        let partitions = grace.children()[0].output_partitioning().partition_count();
2777        assert!(partitions > 1, "precondition: more than one partition to divide by");
2778
2779        let whole_relation =
2780            crate::grace_hash_join::bucket_count(build_bytes_of(&out).unwrap_or(0), 1);
2781        let per_task = crate::grace_hash_join::bucket_count(
2782            build_bytes_of(&out).unwrap_or(0) / partitions as u64,
2783            1,
2784        );
2785        // The fixture only proves something if the two readings differ.
2786        if whole_relation != per_task {
2787            assert_eq!(
2788                grace.buckets(),
2789                per_task,
2790                "grace bucketed for the whole relation ({whole_relation}) instead of \
2791                 for one task ({per_task}) across {partitions} partitions"
2792            );
2793        }
2794    }
2795
2796    /// The build-side estimate the rule saw, read back off the converted plan.
2797    fn build_bytes_of(plan: &Arc<dyn ExecutionPlan>) -> Option<u64> {
2798        fn walk(plan: &Arc<dyn ExecutionPlan>) -> Option<u64> {
2799            let any = plan.as_ref() as &dyn std::any::Any;
2800            if let Some(grace) = any.downcast_ref::<GraceHashJoinExec>() {
2801                let build = &grace.children()[0];
2802                let stats = build.partition_statistics(None).ok()?;
2803                return match stats.total_byte_size {
2804                    Precision::Exact(b) | Precision::Inexact(b) => u64::try_from(b).ok(),
2805                    Precision::Absent => {
2806                        estimated_build_bytes_from_rows(&stats, &build.schema())
2807                    }
2808                };
2809            }
2810            plan.children().iter().find_map(|c| walk(c))
2811        }
2812        walk(plan)
2813    }
2814
2815    /// With the flag on, an oversized build side becomes a grace hash join
2816    /// rather than a sort-merge join.
2817    #[tokio::test]
2818    async fn an_oversized_join_becomes_a_grace_hash_join_when_enabled() {
2819        let ctx = SessionContext::new_with_config(SessionConfig::new().with_target_partitions(1));
2820        let plan = joined_plan(&ctx).await;
2821        let out = SpillableJoinSelection::with_threshold_and_grace(Some(1), true)
2822            .optimize(plan, ctx.copied_config().options())
2823            .unwrap();
2824        assert_eq!(
2825            grace_joins(&out),
2826            1,
2827            "expected a grace hash join:\n{}",
2828            displayable(out.as_ref()).indent(true)
2829        );
2830        assert!(
2831            !displayable(out.as_ref()).indent(true).to_string().contains("SortMergeJoin"),
2832            "grace should have been preferred over sort-merge"
2833        );
2834    }
2835
2836    /// The flag defaults off, so today's deployed behaviour is untouched: the
2837    /// same plan still converts to sort-merge.
2838    #[tokio::test]
2839    async fn with_the_flag_off_the_sort_merge_conversion_is_unchanged() {
2840        let ctx = SessionContext::new_with_config(SessionConfig::new().with_target_partitions(1));
2841        let plan = joined_plan(&ctx).await;
2842        let out = SpillableJoinSelection::with_threshold(Some(1))
2843            .optimize(plan, ctx.copied_config().options())
2844            .unwrap();
2845        assert_eq!(grace_joins(&out), 0, "the flag is off; no grace join should appear");
2846        assert!(
2847            displayable(out.as_ref()).indent(true).to_string().contains("SortMergeJoin"),
2848            "the sort-merge path must still work"
2849        );
2850    }
2851
2852    /// The substituted plan answers identically. A join that spills but returns
2853    /// different rows is worse than the failure it prevents.
2854    #[tokio::test]
2855    async fn the_grace_plan_returns_the_same_rows() {
2856        let ctx = SessionContext::new_with_config(SessionConfig::new().with_target_partitions(1));
2857        let plan = joined_plan(&ctx).await;
2858        let task_ctx = ctx.task_ctx();
2859        let baseline = collect(Arc::clone(&plan), Arc::clone(&task_ctx)).await.unwrap();
2860
2861        let out = SpillableJoinSelection::with_threshold_and_grace(Some(1), true)
2862            .optimize(plan, ctx.copied_config().options())
2863            .unwrap();
2864        assert_eq!(grace_joins(&out), 1, "the rule declined; this proved nothing");
2865        let converted = collect(out, task_ctx).await.unwrap();
2866
2867        let cells = |bs: &[arrow::array::RecordBatch]| -> Vec<String> {
2868            let mut rows: Vec<String> = bs
2869                .iter()
2870                .flat_map(|b| {
2871                    (0..b.num_rows()).map(move |r| {
2872                        (0..b.num_columns())
2873                            .map(|c| {
2874                                arrow::util::display::array_value_to_string(b.column(c), r)
2875                                    .expect("cell")
2876                            })
2877                            .collect::<Vec<_>>()
2878                            .join("|")
2879                    })
2880                })
2881                .collect();
2882            rows.sort();
2883            rows
2884        };
2885        assert_eq!(cells(&baseline), cells(&converted));
2886        assert_eq!(cells(&converted), vec!["10|100", "20|200", "20|201"]);
2887    }
2888
2889    /// A shape the grace join refuses must fall back, not fail the query. The
2890    /// rule's contract is that it can always decline.
2891    #[tokio::test]
2892    async fn a_refused_shape_falls_back_instead_of_failing() {
2893        // Two partitions on one side and one on the other is the broadcast
2894        // shape `GraceHashJoinExec::try_new` rejects.
2895        let ctx = SessionContext::new_with_config(SessionConfig::new().with_target_partitions(4));
2896        let plan = joined_plan(&ctx).await;
2897        let out = SpillableJoinSelection::with_threshold_and_grace(Some(1), true)
2898            .optimize(Arc::clone(&plan), ctx.copied_config().options());
2899        assert!(out.is_ok(), "a refusal must never fail the plan: {:?}", out.err());
2900    }
2901}
2902
2903#[cfg(test)]
2904#[allow(clippy::unwrap_used, clippy::expect_used)]
2905mod join_filter_order_tests {
2906    use super::*;
2907    use arrow::datatypes::{DataType, Field, Schema};
2908    use datafusion::common::JoinSide;
2909    use datafusion::physical_expr::expressions::{BinaryExpr, Column};
2910    use datafusion::physical_plan::joins::utils::ColumnIndex;
2911
2912    /// A filter naming the right side first — the shape q17 and q19 produce.
2913    /// Intermediate schema is `[q: Decimal (right), b: Utf8 (left)]`.
2914    fn right_first() -> JoinFilter {
2915        let schema = Arc::new(Schema::new(vec![
2916            Field::new("q", DataType::Decimal128(15, 2), true),
2917            Field::new("b", DataType::Utf8, true),
2918        ]));
2919        let expression = Arc::new(BinaryExpr::new(
2920            Arc::new(Column::new("q", 0)),
2921            datafusion::logical_expr::Operator::Lt,
2922            Arc::new(Column::new("b", 1)),
2923        ));
2924        JoinFilter::new(
2925            expression,
2926            vec![
2927                ColumnIndex { index: 0, side: JoinSide::Right },
2928                ColumnIndex { index: 0, side: JoinSide::Left },
2929            ],
2930            schema,
2931        )
2932    }
2933
2934    /// Sort-merge materialises `[all left] ++ [all right]`, so the normalised
2935    /// filter must declare exactly that order.
2936    #[test]
2937    fn a_right_first_filter_is_reordered_to_left_first() {
2938        let out = left_first_filter(&right_first()).expect("normalisable");
2939        assert_eq!(
2940            out.column_indices()
2941                .iter()
2942                .map(|c| c.side)
2943                .collect::<Vec<_>>(),
2944            vec![JoinSide::Left, JoinSide::Right],
2945        );
2946        assert_eq!(
2947            out.schema()
2948                .fields()
2949                .iter()
2950                .map(|f| f.name().clone())
2951                .collect::<Vec<_>>(),
2952            vec!["b".to_string(), "q".to_string()],
2953            "the intermediate schema must follow the new column order"
2954        );
2955    }
2956
2957    /// Reordering the schema without re-pointing the expression would leave a
2958    /// filter that reads the wrong columns — the same class of silent wrong
2959    /// answer, just moved. `q` was at 0 and must now be at 1.
2960    #[test]
2961    fn the_expression_is_repointed_at_the_new_positions() {
2962        let out = left_first_filter(&right_first()).expect("normalisable");
2963        let rendered = format!("{}", out.expression());
2964        assert!(
2965            rendered.contains("q@1") && rendered.contains("b@0"),
2966            "expression still points at the old positions: {rendered}"
2967        );
2968    }
2969
2970    /// A filter already in left-first order is returned unchanged, so the
2971    /// common case costs nothing and cannot be perturbed.
2972    #[test]
2973    fn an_already_left_first_filter_is_untouched() {
2974        let schema = Arc::new(Schema::new(vec![
2975            Field::new("b", DataType::Utf8, true),
2976            Field::new("q", DataType::Decimal128(15, 2), true),
2977        ]));
2978        let expression = Arc::new(BinaryExpr::new(
2979            Arc::new(Column::new("b", 0)),
2980            datafusion::logical_expr::Operator::Lt,
2981            Arc::new(Column::new("q", 1)),
2982        ));
2983        let filter = JoinFilter::new(
2984            expression,
2985            vec![
2986                ColumnIndex { index: 0, side: JoinSide::Left },
2987                ColumnIndex { index: 0, side: JoinSide::Right },
2988            ],
2989            schema,
2990        );
2991        let out = left_first_filter(&filter).expect("normalisable");
2992        assert_eq!(format!("{}", out.expression()), format!("{}", filter.expression()));
2993        assert_eq!(out.column_indices(), filter.column_indices());
2994    }
2995
2996    /// Interleaved sides keep their relative order within each side — that is
2997    /// what `get_filter_columns` produces, and anything else would mis-map.
2998    #[test]
2999    fn relative_order_within_each_side_is_preserved() {
3000        let schema = Arc::new(Schema::new(vec![
3001            Field::new("r0", DataType::Int32, true),
3002            Field::new("l0", DataType::Int32, true),
3003            Field::new("r1", DataType::Int32, true),
3004            Field::new("l1", DataType::Int32, true),
3005        ]));
3006        let filter = JoinFilter::new(
3007            Arc::new(Column::new("l1", 3)),
3008            vec![
3009                ColumnIndex { index: 7, side: JoinSide::Right },
3010                ColumnIndex { index: 5, side: JoinSide::Left },
3011                ColumnIndex { index: 9, side: JoinSide::Right },
3012                ColumnIndex { index: 6, side: JoinSide::Left },
3013            ],
3014            schema,
3015        );
3016        let out = left_first_filter(&filter).expect("normalisable");
3017        assert_eq!(
3018            out.column_indices()
3019                .iter()
3020                .map(|c| (c.side, c.index))
3021                .collect::<Vec<_>>(),
3022            vec![
3023                (JoinSide::Left, 5),
3024                (JoinSide::Left, 6),
3025                (JoinSide::Right, 7),
3026                (JoinSide::Right, 9),
3027            ],
3028        );
3029        // l1 was the 4th column (index 3) and is now the 2nd (index 1).
3030        assert_eq!(format!("{}", out.expression()), "l1@1");
3031    }
3032}
3033
3034#[cfg(test)]
3035#[allow(clippy::unwrap_used, clippy::expect_used)]
3036mod encodability_tests {
3037    use super::*;
3038    use crate::grace_hash_join::GraceHashJoinExec;
3039    use datafusion::prelude::SessionContext;
3040
3041    fn grace_joins(plan: &Arc<dyn ExecutionPlan>) -> usize {
3042        let any = plan.as_ref() as &dyn std::any::Any;
3043        usize::from(any.downcast_ref::<GraceHashJoinExec>().is_some())
3044            + plan.children().iter().map(|c| grace_joins(c)).sum::<usize>()
3045    }
3046
3047    /// The staging planner must never produce a grace hash join.
3048    ///
3049    /// `GraceHashJoinExec` is a Krishiv node and `datafusion-proto` cannot
3050    /// serialize it. A stage plan containing one fails to encode, and the
3051    /// scheduler's response to an unencodable stage plan is to run the whole
3052    /// query as a SINGLE TASK — so the flag read as a memory fix while silently
3053    /// un-distributing q10 and q21 on the cluster:
3054    ///
3055    /// ```text
3056    /// stage plan cannot be encoded and decoded; running this query as a
3057    /// SINGLE TASK ... Unsupported plan and extension codec failed
3058    /// ```
3059    ///
3060    /// Grace belongs on the executor, after decode
3061    /// (`distributed_plan::apply_local_spill_strategy`). This pins the
3062    /// separation: whatever the environment says, the path that plans stages
3063    /// stays encodable.
3064    /// The gate that lets the CLI have grace must not open for the coordinator.
3065    ///
3066    /// `with_grace_where_plans_are_never_encoded` is applied by
3067    /// `with_krishiv_optimizer_rules_with_join_threshold`, which the staging
3068    /// planner also calls — so the *only* thing standing between a grace join
3069    /// and an unencodable stage plan is `is_single_query_process()`. This pins
3070    /// the closed direction, with the environment variable deliberately set:
3071    /// a reader should not have to trust that the env is unset to believe the
3072    /// coordinator is safe.
3073    ///
3074    /// Both directions are asserted **with the flag on**, which is what makes
3075    /// the closed case mean anything: read from the real environment, grace is
3076    /// false when the flag is unset, so the test would pass against a gate that
3077    /// was wired backwards.
3078    #[test]
3079    fn grace_opens_only_where_plans_are_never_encoded() {
3080        let coordinator = SpillableJoinSelection::with_threshold(Some(1))
3081            .with_grace_gated(false, true);
3082        assert!(
3083            !coordinator.grace,
3084            "the flag opened grace on a process whose plans get encoded — a \
3085             grace join in a stage plan runs the whole query as a SINGLE TASK"
3086        );
3087
3088        let one_shot_cli = SpillableJoinSelection::with_threshold(Some(1))
3089            .with_grace_gated(true, true);
3090        assert!(
3091            one_shot_cli.grace,
3092            "grace stayed shut in a process that never encodes a plan, which \
3093             is the whole point of the gate"
3094        );
3095
3096        // And the flag still governs: a single-query process without it opted
3097        // in gets today's behaviour, not grace by default.
3098        let flag_off = SpillableJoinSelection::with_threshold(Some(1))
3099            .with_grace_gated(true, false);
3100        assert!(!flag_off.grace, "the gate turned grace on by itself");
3101    }
3102
3103    #[tokio::test]
3104    async fn the_staging_planner_never_emits_an_unencodable_grace_join() {
3105        // Exactly how `planning_session_context_with_options` builds its rules.
3106        let rule = SpillableJoinSelection::with_threshold(Some(1));
3107        let ctx = SessionContext::new_with_config(
3108            datafusion::prelude::SessionConfig::new().with_target_partitions(1),
3109        );
3110        for ddl in [
3111            "CREATE TABLE l(k INT, v INT) AS VALUES (1, 10), (2, 20)",
3112            "CREATE TABLE r(k INT, w INT) AS VALUES (1, 100), (2, 200)",
3113        ] {
3114            ctx.sql(ddl).await.unwrap().collect().await.unwrap();
3115        }
3116        let plan = ctx
3117            .sql("SELECT l.v, r.w FROM l JOIN r ON l.k = r.k")
3118            .await
3119            .unwrap()
3120            .create_physical_plan()
3121            .await
3122            .unwrap();
3123
3124        let out = rule.optimize(plan, ctx.copied_config().options()).unwrap();
3125        assert_eq!(
3126            grace_joins(&out),
3127            0,
3128            "the staging planner produced a grace join, which cannot be encoded:\n{}",
3129            datafusion::physical_plan::displayable(out.as_ref()).indent(true)
3130        );
3131        // And it did convert *something*, so this is not passing because the
3132        // rule declined everything.
3133        assert!(
3134            datafusion::physical_plan::displayable(out.as_ref())
3135                .indent(true)
3136                .to_string()
3137                .contains("SortMergeJoin"),
3138            "the rule declined entirely, so encodability was never at stake"
3139        );
3140    }
3141}
3142
3143#[cfg(test)]
3144#[allow(clippy::unwrap_used, clippy::expect_used)]
3145mod budget_never_loosens_tests {
3146    use super::*;
3147
3148    use super::budget_tests::plan_with_build_sizes;
3149
3150    fn facts_of(plan: &Arc<dyn ExecutionPlan>) -> Vec<JoinFacts> {
3151        let mut out = Vec::new();
3152        collect_join_facts(plan, 1, true, &mut out);
3153        out
3154    }
3155
3156    fn retained(facts: &[JoinFacts], decisions: &[bool]) -> u64 {
3157        facts
3158            .iter()
3159            .zip(decisions)
3160            .filter(|(_, convert)| !**convert)
3161            .map(|(f, _)| f.retained_bytes())
3162            .fold(0, u64::saturating_add)
3163    }
3164
3165    /// The budget may only ever convert MORE, never fewer.
3166    ///
3167    /// The first implementation returned the largest join's size as a new
3168    /// threshold — routinely far above the configured one — so it converted
3169    /// only the single biggest join where the plain per-join rule would have
3170    /// converted every join over budget. Live on TPC-H q10:
3171    ///
3172    /// ```text
3173    /// threshold=974064839  configured_threshold=250000000  budget_tightened=false
3174    /// ```
3175    ///
3176    /// The budget exists to convert more under aggregate pressure; loosening
3177    /// inverted it, and is why that fix never moved q10 or q11. Now that the
3178    /// decision is per join there is no threshold to invert, but the property
3179    /// it was protecting still has to hold.
3180    #[test]
3181    fn everything_over_the_configured_threshold_still_converts() {
3182        for configured in [1_u64, 1_000, 250_000_000] {
3183            let plan = plan_with_build_sizes(&[900, 400, 300]);
3184            let facts = facts_of(&plan);
3185            let decisions = SpillableJoinSelection::conversion_decisions(&facts, configured);
3186            let converted = decisions.iter().filter(|convert| **convert).count();
3187            let would_have = facts
3188                .iter()
3189                .filter(|f| f.retained_bytes() > configured)
3190                .count();
3191            assert!(
3192                converted >= would_have,
3193                "the budget converted {converted} joins where the plain threshold \
3194                 would have converted {would_have} (configured {configured})"
3195            );
3196        }
3197    }
3198
3199    /// The point of the budget: what is LEFT as hash joins must fit it.
3200    ///
3201    /// This is the property q10 needed and never had. Several joins that each
3202    /// sit under the threshold, whose sum does not — the retained set has to
3203    /// come in under budget, or the pool is exhausted at run time exactly as
3204    /// before.
3205    #[test]
3206    fn the_joins_left_as_hash_joins_fit_the_budget() {
3207        let plan = plan_with_build_sizes(&[200; 8]);
3208        let facts = facts_of(&plan);
3209        let total: u64 = facts
3210            .iter()
3211            .map(|f| f.retained_bytes())
3212            .fold(0, u64::saturating_add);
3213        let largest = facts
3214            .iter()
3215            .map(|f| f.retained_bytes())
3216            .max()
3217            .expect("fixture has joins");
3218        // A budget every join clears individually, that the sum does not.
3219        let budget = largest * 2;
3220        assert!(total > budget, "fixture must create aggregate pressure");
3221
3222        let decisions = SpillableJoinSelection::conversion_decisions(&facts, budget);
3223        assert!(
3224            retained(&facts, &decisions) <= budget,
3225            "un-converted joins sum to {}, over the {budget} budget",
3226            retained(&facts, &decisions)
3227        );
3228    }
3229}
3230
3231#[cfg(test)]
3232#[allow(clippy::unwrap_used, clippy::expect_used)]
3233mod degenerate_sentinel_budget_tests {
3234    use super::*;
3235
3236    fn fact(bytes: Option<u64>, convertible: bool) -> JoinFacts {
3237        JoinFacts { bytes, convertible }
3238    }
3239
3240    /// One degenerate estimate must not make the configured threshold
3241    /// meaningless for every other join in the plan.
3242    ///
3243    /// `DEGENERATE_BUILD_BYTES` is `u64::MAX`, and the budget summed it. A
3244    /// degenerate join that the rule cannot convert therefore saturated
3245    /// `unavoidable`, `budget` became `threshold - u64::MAX` = **0**, and every
3246    /// candidate converted to sort-merge no matter how much build memory the
3247    /// operator said a task had.
3248    ///
3249    /// Measured live on the SF100 cluster 2026-08-07: raising
3250    /// `KRISHIV_SPILL_JOIN_BUILD_BYTES` from 250 MB to 900 TB changed q21's
3251    /// plan by exactly nothing — `configured_threshold: 900000000000000`,
3252    /// `hash_joins: 5, converted: 3`, the same three joins. The knob was
3253    /// inoperative for any plan containing a degenerate estimate, which is
3254    /// every plan with a self anti-join.
3255    #[test]
3256    fn a_degenerate_estimate_does_not_zero_the_budget_for_everyone_else() {
3257        let facts = vec![
3258            // Unconvertible and degenerate — the q21 shape.
3259            fact(Some(DEGENERATE_BUILD_BYTES), false),
3260            fact(Some(100), true),
3261            fact(Some(200), true),
3262        ];
3263        // Comfortably fits the two measurable joins plus an assumed share for
3264        // the third.
3265        let decisions = SpillableJoinSelection::conversion_decisions(&facts, 10_000);
3266        assert_eq!(
3267            decisions,
3268            vec![false, false, false],
3269            "a threshold that fits the measurable joins must retain them"
3270        );
3271    }
3272
3273    /// ...and the sentinel must still do its job.
3274    ///
3275    /// The correction above is only safe if a degenerate join remains the
3276    /// FIRST thing to convert under real pressure. It is the join whose size
3277    /// nothing can bound, and leaving it as an un-spillable hash join is what
3278    /// killed q21 with `HashJoinInput[4] with 806.0 MB already allocated`.
3279    #[test]
3280    fn under_pressure_the_degenerate_join_is_the_one_that_converts() {
3281        let facts = vec![
3282            fact(Some(DEGENERATE_BUILD_BYTES), true),
3283            fact(Some(200), true),
3284        ];
3285        let decisions = SpillableJoinSelection::conversion_decisions(&facts, 250);
3286        assert_eq!(
3287            decisions,
3288            vec![true, false],
3289            "the unbounded join converts and the measurable one that fits is kept"
3290        );
3291    }
3292
3293    /// The budget still binds when the measurable joins genuinely do not fit.
3294    #[test]
3295    fn a_degenerate_join_does_not_buy_the_others_a_free_pass() {
3296        let facts = vec![
3297            fact(Some(DEGENERATE_BUILD_BYTES), false),
3298            fact(Some(900), true),
3299            fact(Some(800), true),
3300        ];
3301        let decisions = SpillableJoinSelection::conversion_decisions(&facts, 1_000);
3302        assert!(
3303            decisions[1] || decisions[2],
3304            "900 + 800 cannot both be retained under a 1000 budget: {decisions:?}"
3305        );
3306    }
3307}