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}