1use arrow::datatypes::DataType;
11
12use super::{
13 join_strategy::{JoinStrategy, JoinStrategyChoice, JoinStrategyEvidence},
14 rejection::{Reason, Rejection, RejectionOwner},
15};
16use crate::{
17 error::{QueryError, QueryErrorCode, Result},
18 expr::{BinaryOp, Expr},
19 plan::{JoinNode, JoinSide, JoinType, NodeId, QueryPlan, estimated_output_bytes, estimated_output_rows},
20};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28#[non_exhaustive]
29pub struct KeyedHashJoinStrategyOptions {
30 pub(super) large_keyed_hash_build_row_threshold: u64,
31 pub(super) large_keyed_hash_build_bytes_threshold: u64,
32 pub(super) large_keyed_hash_probe_bytes_threshold: u64,
33 pub(super) small_reusable_hash_build_row_threshold: u64,
34 pub(super) small_reusable_hash_build_bytes_threshold: u64,
35 pub(super) small_one_shot_probe_bytes_threshold: u64,
36 pub(super) max_small_probe_left_one_shot_build_row_threshold: u64,
37 pub(super) max_small_probe_left_one_shot_build_bytes_threshold: u64,
38}
39
40impl KeyedHashJoinStrategyOptions {
41 pub(crate) const DEFAULT_LARGE_KEYED_HASH_BUILD_ROW_THRESHOLD: u64 = 64_000_000;
43 pub(crate) const DEFAULT_LARGE_KEYED_HASH_BUILD_BYTES_THRESHOLD: u64 = 8 * 1024 * 1024 * 1024;
45 pub(crate) const DEFAULT_LARGE_KEYED_HASH_PROBE_BYTES_THRESHOLD: u64 = 2 * 1024 * 1024 * 1024;
47 pub(crate) const DEFAULT_SMALL_REUSABLE_HASH_BUILD_ROW_THRESHOLD: u64 = 1_000_000;
49 pub(crate) const DEFAULT_SMALL_REUSABLE_HASH_BUILD_BYTES_THRESHOLD: u64 = 64 * 1024 * 1024;
51 pub(crate) const DEFAULT_SMALL_ONE_SHOT_PROBE_BYTES_THRESHOLD: u64 = 256 * 1024 * 1024;
53 pub(crate) const DEFAULT_MAX_SMALL_PROBE_LEFT_ONE_SHOT_BUILD_ROW_THRESHOLD: u64 = 128_000_000;
56 pub(crate) const DEFAULT_MAX_SMALL_PROBE_LEFT_ONE_SHOT_BUILD_BYTES_THRESHOLD: u64 = 2 * 1024 * 1024 * 1024;
59
60 pub fn large_keyed_hash_build_row_threshold(&self) -> u64 {
62 self.large_keyed_hash_build_row_threshold
63 }
64
65 pub fn large_keyed_hash_build_bytes_threshold(&self) -> u64 {
67 self.large_keyed_hash_build_bytes_threshold
68 }
69
70 pub fn large_keyed_hash_probe_bytes_threshold(&self) -> u64 {
72 self.large_keyed_hash_probe_bytes_threshold
73 }
74
75 pub fn small_reusable_hash_build_row_threshold(&self) -> u64 {
77 self.small_reusable_hash_build_row_threshold
78 }
79
80 pub fn small_reusable_hash_build_bytes_threshold(&self) -> u64 {
82 self.small_reusable_hash_build_bytes_threshold
83 }
84
85 pub fn small_one_shot_probe_bytes_threshold(&self) -> u64 {
87 self.small_one_shot_probe_bytes_threshold
88 }
89
90 pub fn max_small_probe_left_one_shot_build_row_threshold(&self) -> u64 {
92 self.max_small_probe_left_one_shot_build_row_threshold
93 }
94
95 pub fn max_small_probe_left_one_shot_build_bytes_threshold(&self) -> u64 {
97 self.max_small_probe_left_one_shot_build_bytes_threshold
98 }
99
100 pub fn validate(&self) -> Result<()> {
107 validate_keyed_hash_join_threshold_order(
108 "large_keyed_hash_build_row_threshold",
109 self.large_keyed_hash_build_row_threshold,
110 "small_reusable_hash_build_row_threshold",
111 self.small_reusable_hash_build_row_threshold,
112 )?;
113 validate_keyed_hash_join_threshold_order(
114 "large_keyed_hash_build_bytes_threshold",
115 self.large_keyed_hash_build_bytes_threshold,
116 "small_reusable_hash_build_bytes_threshold",
117 self.small_reusable_hash_build_bytes_threshold,
118 )?;
119 validate_keyed_hash_join_threshold_order(
120 "large_keyed_hash_probe_bytes_threshold",
121 self.large_keyed_hash_probe_bytes_threshold,
122 "small_one_shot_probe_bytes_threshold",
123 self.small_one_shot_probe_bytes_threshold,
124 )?;
125 Ok(())
126 }
127
128 pub fn with_large_keyed_hash_build_row_threshold(mut self, rows: u64) -> Self {
131 self.large_keyed_hash_build_row_threshold = rows;
132 self
133 }
134
135 pub fn with_large_keyed_hash_build_bytes_threshold(mut self, bytes: u64) -> Self {
138 self.large_keyed_hash_build_bytes_threshold = bytes;
139 self
140 }
141
142 #[cfg(test)]
143 pub fn with_large_keyed_hash_probe_bytes_threshold(mut self, bytes: u64) -> Self {
144 self.large_keyed_hash_probe_bytes_threshold = bytes;
145 self
146 }
147
148 pub fn with_small_reusable_hash_build_row_threshold(mut self, rows: u64) -> Self {
151 self.small_reusable_hash_build_row_threshold = rows;
152 self
153 }
154
155 pub fn with_small_reusable_hash_build_bytes_threshold(mut self, bytes: u64) -> Self {
158 self.small_reusable_hash_build_bytes_threshold = bytes;
159 self
160 }
161
162 #[cfg(test)]
163 pub fn with_small_one_shot_probe_bytes_threshold(mut self, bytes: u64) -> Self {
164 self.small_one_shot_probe_bytes_threshold = bytes;
165 self
166 }
167}
168
169fn validate_keyed_hash_join_threshold_order(
170 large_field: &'static str,
171 large_value: u64,
172 small_field: &'static str,
173 small_value: u64,
174) -> Result<()> {
175 if large_value != 0 && small_value != 0 && large_value < small_value {
176 return Err(QueryError::new(
177 QueryErrorCode::InvalidConfiguration,
178 "query_engine.keyed_hash_join_strategy_options.validate",
179 "keyed hash-join strategy threshold ordering is inconsistent",
180 )
181 .with_fact("large_field", large_field)
182 .with_fact("large_value", large_value)
183 .with_fact("small_field", small_field)
184 .with_fact("small_value", small_value)
185 .with_fact("reason", "large_threshold_below_small_threshold"));
186 }
187 Ok(())
188}
189
190impl Default for KeyedHashJoinStrategyOptions {
191 fn default() -> Self {
192 Self {
193 large_keyed_hash_build_row_threshold: Self::DEFAULT_LARGE_KEYED_HASH_BUILD_ROW_THRESHOLD,
194 large_keyed_hash_build_bytes_threshold: Self::DEFAULT_LARGE_KEYED_HASH_BUILD_BYTES_THRESHOLD,
195 large_keyed_hash_probe_bytes_threshold: Self::DEFAULT_LARGE_KEYED_HASH_PROBE_BYTES_THRESHOLD,
196 small_reusable_hash_build_row_threshold: Self::DEFAULT_SMALL_REUSABLE_HASH_BUILD_ROW_THRESHOLD,
197 small_reusable_hash_build_bytes_threshold: Self::DEFAULT_SMALL_REUSABLE_HASH_BUILD_BYTES_THRESHOLD,
198 small_one_shot_probe_bytes_threshold: Self::DEFAULT_SMALL_ONE_SHOT_PROBE_BYTES_THRESHOLD,
199 max_small_probe_left_one_shot_build_row_threshold:
200 Self::DEFAULT_MAX_SMALL_PROBE_LEFT_ONE_SHOT_BUILD_ROW_THRESHOLD,
201 max_small_probe_left_one_shot_build_bytes_threshold:
202 Self::DEFAULT_MAX_SMALL_PROBE_LEFT_ONE_SHOT_BUILD_BYTES_THRESHOLD,
203 }
204 }
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub struct KeyedHashJoinStrategyInputFacts {
217 #[doc(hidden)]
218 pub estimated_build_rows: Option<u64>,
219 #[doc(hidden)]
222 pub observed_build_rows: Option<u64>,
223 #[doc(hidden)]
224 pub build_side_bytes: Option<u64>,
225 #[doc(hidden)]
226 pub probe_side_bytes: Option<u64>,
227 #[doc(hidden)]
228 pub estimated_output_rows: Option<u64>,
229 #[doc(hidden)]
230 pub estimated_output_bytes: Option<u64>,
231 #[doc(hidden)]
235 pub build_key_unique: Option<bool>,
236 #[doc(hidden)]
238 pub probe_reuse_count: u64,
239 #[doc(hidden)]
242 pub reusable_prebuild_supported: bool,
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
249pub enum KeylessConditionalJoinKind {
250 Inner,
251 Left,
252 Full,
253 LeftSemi,
254 LeftAnti,
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
263pub enum JoinForm {
264 KeyedEqui,
267 KeyedEquiPostFilter,
270 KeyedFilteredSemiAnti,
273 KeyedResidual { left_match: bool },
276 KeylessConditional(KeylessConditionalJoinKind),
278 KeylessOuterCross,
280 ProjectedCross,
282}
283
284impl JoinForm {
285 pub fn strategies(self) -> &'static [JoinStrategy] {
287 match self {
288 Self::KeyedEqui => {
289 &[JoinStrategy::ReusableHashEqui, JoinStrategy::OneShotEqui, JoinStrategy::PartitionedHashEqui]
290 },
291 Self::KeyedEquiPostFilter => &[JoinStrategy::OneShotEqui],
292 Self::KeyedFilteredSemiAnti => &[JoinStrategy::FilteredSemiAnti],
293 Self::KeyedResidual { left_match: false } => {
294 &[JoinStrategy::MixedKeyedResidual, JoinStrategy::PartitionedMixedKeyedResidual]
295 },
296 Self::KeyedResidual { left_match: true } => &[JoinStrategy::MixedKeyedResidual],
297 Self::KeylessConditional(KeylessConditionalJoinKind::Inner) => {
298 &[JoinStrategy::KeylessConditional { join_type: KeylessConditionalJoinKind::Inner }]
299 },
300 Self::KeylessConditional(KeylessConditionalJoinKind::Left) => {
301 &[JoinStrategy::KeylessConditional { join_type: KeylessConditionalJoinKind::Left }]
302 },
303 Self::KeylessConditional(KeylessConditionalJoinKind::Full) => {
304 &[JoinStrategy::KeylessConditional { join_type: KeylessConditionalJoinKind::Full }]
305 },
306 Self::KeylessConditional(KeylessConditionalJoinKind::LeftSemi) => {
307 &[JoinStrategy::KeylessConditional { join_type: KeylessConditionalJoinKind::LeftSemi }]
308 },
309 Self::KeylessConditional(KeylessConditionalJoinKind::LeftAnti) => {
310 &[JoinStrategy::KeylessConditional { join_type: KeylessConditionalJoinKind::LeftAnti }]
311 },
312 Self::KeylessOuterCross => &[JoinStrategy::KeylessOuterCross],
313 Self::ProjectedCross => &[JoinStrategy::ProjectedCross],
314 }
315 }
316
317 pub fn builds_first(self) -> bool {
320 self == Self::KeyedFilteredSemiAnti
321 }
322}
323
324pub(crate) fn classify_join(node_id: NodeId, join: &JoinNode) -> std::result::Result<JoinForm, Rejection> {
327 let spec = &join.spec;
328 let rejection = |reason| {
329 Rejection::new(
330 RejectionOwner::Node(node_id),
331 reason,
332 [
333 ("node_id", node_id.to_string()),
334 ("join_type", spec.join_type.to_string()),
335 ("key_count", spec.keys.len().to_string()),
336 ("has_residual", spec.residual_predicate.is_some().to_string()),
337 ("build_side", spec.build_side.to_string()),
338 ],
339 )
340 };
341 if spec.output_projection.is_empty() {
342 return Err(rejection(Reason::UnsupportedJoinNodeCapability));
343 }
344 let build_right = spec.build_side == JoinSide::Right;
347 let form = match (spec.keys.is_empty(), &spec.residual_predicate) {
348 (false, None) => match spec.join_type {
349 JoinType::Inner | JoinType::Full => Some(JoinForm::KeyedEqui),
350 JoinType::Left => build_right.then_some(JoinForm::KeyedEqui),
351 JoinType::LeftSemi | JoinType::LeftAnti { .. } | JoinType::LeftMark { .. } => {
352 build_right.then_some(JoinForm::KeyedFilteredSemiAnti)
353 },
354 JoinType::Cross => None,
355 },
356 (false, Some(residual)) => {
357 let left_match = matches!(spec.join_type, JoinType::LeftSemi | JoinType::LeftAnti { .. }) && !build_right;
360 let mixed = !spec.join_type.is_null_aware()
361 && match spec.join_type {
362 JoinType::Inner | JoinType::Full | JoinType::LeftSemi | JoinType::LeftAnti { .. } => true,
363 JoinType::Left => build_right,
364 JoinType::Cross | JoinType::LeftMark { .. } => false,
365 };
366 match (mixed, expr_requires_post_join_filter(residual)) {
367 (false, _) => None,
368 (true, false) => Some(JoinForm::KeyedResidual { left_match }),
369 (true, true) if spec.join_type == JoinType::Inner => Some(JoinForm::KeyedEquiPostFilter),
370 (true, true) => return Err(rejection(Reason::PostJoinResidualFilterRequiresOneShotEquiJoin)),
371 }
372 },
373 (true, Some(_)) => match spec.join_type {
374 JoinType::Inner => Some(KeylessConditionalJoinKind::Inner),
375 JoinType::Left => build_right.then_some(KeylessConditionalJoinKind::Left),
376 JoinType::Full => Some(KeylessConditionalJoinKind::Full),
377 JoinType::LeftSemi => (!build_right).then_some(KeylessConditionalJoinKind::LeftSemi),
378 JoinType::LeftAnti { .. } => (!build_right).then_some(KeylessConditionalJoinKind::LeftAnti),
379 JoinType::Cross | JoinType::LeftMark { .. } => None,
380 }
381 .map(JoinForm::KeylessConditional),
382 (true, None) => match spec.join_type {
383 JoinType::Left => build_right.then_some(JoinForm::KeylessOuterCross),
384 JoinType::Full => Some(JoinForm::KeylessOuterCross),
385 JoinType::Cross => Some(JoinForm::ProjectedCross),
386 JoinType::Inner | JoinType::LeftSemi | JoinType::LeftAnti { .. } | JoinType::LeftMark { .. } => None,
387 },
388 };
389 form.ok_or_else(|| rejection(Reason::UnsupportedJoinNodeCapability))
390}
391
392pub(crate) fn choose_join_strategy(
398 form: JoinForm,
399 join: &JoinNode,
400 input: KeyedHashJoinStrategyInputFacts,
401 options: &KeyedHashJoinStrategyOptions,
402) -> JoinStrategyChoice {
403 match form {
404 JoinForm::KeyedEqui => keyed_equi_strategy_choice(join, input, options),
405 JoinForm::KeyedEquiPostFilter => JoinStrategyChoice::with_evidence(JoinStrategyEvidence::new(
406 join,
407 input,
408 JoinStrategy::OneShotEqui,
409 options,
410 Some("residual_requires_post_join_filter"),
411 Some("residual_requires_post_join_filter"),
412 )),
413 JoinForm::KeyedResidual { left_match: false } => {
414 partitioned_mixed_keyed_residual_evidence(join, input, options)
415 .map_or(JoinStrategyChoice::without_evidence(JoinStrategy::MixedKeyedResidual), |evidence| {
416 JoinStrategyChoice::with_evidence(evidence)
417 })
418 },
419 JoinForm::KeyedResidual { left_match: true } => {
420 JoinStrategyChoice::without_evidence(JoinStrategy::MixedKeyedResidual)
421 },
422 JoinForm::KeyedFilteredSemiAnti => JoinStrategyChoice::without_evidence(JoinStrategy::FilteredSemiAnti),
423 JoinForm::KeylessConditional(join_type) => {
424 JoinStrategyChoice::without_evidence(JoinStrategy::KeylessConditional { join_type })
425 },
426 JoinForm::KeylessOuterCross => JoinStrategyChoice::without_evidence(JoinStrategy::KeylessOuterCross),
427 JoinForm::ProjectedCross => JoinStrategyChoice::without_evidence(JoinStrategy::ProjectedCross),
428 }
429}
430
431pub fn keyed_hash_join_strategy_input_facts_for_plan(
435 plan: &QueryPlan,
436 node_id: NodeId,
437 join: &JoinNode,
438) -> KeyedHashJoinStrategyInputFacts {
439 let build_input = match join.spec.build_side {
440 JoinSide::Left => join.inputs[0],
441 JoinSide::Right => join.inputs[1],
442 };
443 KeyedHashJoinStrategyInputFacts {
444 estimated_build_rows: estimated_output_rows(plan, build_input),
445 observed_build_rows: None,
446 build_side_bytes: estimated_output_bytes(plan, build_input),
447 probe_side_bytes: estimated_output_bytes(plan, probe_input_for_join(join)),
448 estimated_output_rows: estimated_output_rows(plan, node_id),
449 estimated_output_bytes: estimated_output_bytes(plan, node_id),
450 build_key_unique: None,
451 probe_reuse_count: 1,
452 reusable_prebuild_supported: reusable_hash_prebuild_supported_for_plan(plan, join),
453 }
454}
455
456fn keyed_equi_strategy_choice(
457 join: &JoinNode,
458 input: KeyedHashJoinStrategyInputFacts,
459 options: &KeyedHashJoinStrategyOptions,
460) -> JoinStrategyChoice {
461 let large_build = keyed_hash_join_build_is_large(&input, options);
462 let small_build = !large_build
463 && (input.observed_build_rows.is_some_and(|rows| rows <= options.small_reusable_hash_build_row_threshold)
464 || input.estimated_build_rows.is_some_and(|rows| rows <= options.small_reusable_hash_build_row_threshold)
465 || input.build_side_bytes.is_some_and(|bytes| bytes <= options.small_reusable_hash_build_bytes_threshold));
466 let build_size_known =
467 input.observed_build_rows.is_some() || input.estimated_build_rows.is_some() || input.build_side_bytes.is_some();
468 let large_probe =
469 input.probe_side_bytes.is_some_and(|bytes| bytes >= options.large_keyed_hash_probe_bytes_threshold);
470 let small_probe = !large_probe
471 && input.probe_side_bytes.is_some_and(|bytes| bytes <= options.small_one_shot_probe_bytes_threshold);
472 let single_use_without_artifact = join.spec.build_artifact_id.is_none() && input.probe_reuse_count <= 1;
473 let one_shot_contained_left_join_build = input
474 .observed_build_rows
475 .or(input.estimated_build_rows)
476 .is_some_and(|rows| rows <= options.max_small_probe_left_one_shot_build_row_threshold)
477 && input
478 .build_side_bytes
479 .is_some_and(|bytes| bytes <= options.max_small_probe_left_one_shot_build_bytes_threshold);
480 let one_shot_contained_left_join_output = input.build_key_unique == Some(true);
481 let reusable_rejected_reason = if !input.reusable_prebuild_supported {
482 Some("build_key_prebuild_reservation_unsupported")
483 } else if large_build {
484 Some("build_side_too_large")
485 } else if large_probe && !small_build {
486 Some("probe_side_too_large")
487 } else if input.probe_side_bytes.is_none()
488 && input.build_side_bytes.is_some()
489 && join.spec.build_side == JoinSide::Left
490 && !small_build
491 {
492 Some("probe_side_unestimated")
493 } else if single_use_without_artifact && build_size_known && !small_build {
494 Some("single_use_build_not_small")
495 } else {
496 None
497 };
498 let evidence = |strategy, reusable_rejected, partitioned_rejected| {
499 JoinStrategyChoice::with_evidence(JoinStrategyEvidence::new(
500 join,
501 input,
502 strategy,
503 options,
504 reusable_rejected,
505 partitioned_rejected,
506 ))
507 };
508
509 if join.spec.join_type == JoinType::Left
514 && input.reusable_prebuild_supported
515 && single_use_without_artifact
516 && !small_build
517 && small_probe
518 && one_shot_contained_left_join_build
519 && one_shot_contained_left_join_output
520 {
521 return evidence(
522 JoinStrategy::OneShotEqui,
523 reusable_rejected_reason,
524 Some("small_preserved_probe_prefers_one_shot_left_join"),
525 );
526 }
527
528 let prefer_reusable = join.spec.build_artifact_id.is_some()
529 || input.probe_reuse_count > 1
530 || (reusable_rejected_reason.is_none() && (!build_size_known || small_build));
531 if prefer_reusable {
532 return evidence(JoinStrategy::ReusableHashEqui, None, None);
533 }
534 if small_build && small_probe {
535 return evidence(
536 JoinStrategy::OneShotEqui,
537 reusable_rejected_reason,
538 Some("small_non_reusable_join_prefers_one_shot"),
539 );
540 }
541 evidence(JoinStrategy::PartitionedHashEqui, reusable_rejected_reason, None)
542}
543
544fn partitioned_mixed_keyed_residual_evidence(
547 join: &JoinNode,
548 input: KeyedHashJoinStrategyInputFacts,
549 options: &KeyedHashJoinStrategyOptions,
550) -> Option<JoinStrategyEvidence> {
551 let large_build = keyed_hash_join_build_is_large(&input, options);
552 let large_probe =
553 input.probe_side_bytes.is_some_and(|bytes| bytes >= options.large_keyed_hash_probe_bytes_threshold);
554 let small_build = !large_build
555 && (input.observed_build_rows.is_some_and(|rows| rows <= options.small_reusable_hash_build_row_threshold)
556 || input.estimated_build_rows.is_some_and(|rows| rows <= options.small_reusable_hash_build_row_threshold)
557 || input.build_side_bytes.is_some_and(|bytes| bytes <= options.small_reusable_hash_build_bytes_threshold));
558 let build_size_known =
559 input.observed_build_rows.is_some() || input.estimated_build_rows.is_some() || input.build_side_bytes.is_some();
560 if !large_build && !large_probe && (!build_size_known || small_build) {
561 return None;
562 }
563 let reusable_rejected_reason = if !input.reusable_prebuild_supported {
564 "build_key_prebuild_reservation_unsupported"
565 } else if large_build {
566 "build_side_too_large"
567 } else if large_probe && !small_build {
568 "probe_side_too_large"
569 } else {
570 "keyed_residual_requires_partitioned_mixed_strategy"
571 };
572 Some(JoinStrategyEvidence::new(
573 join,
574 input,
575 JoinStrategy::PartitionedMixedKeyedResidual,
576 options,
577 Some(reusable_rejected_reason),
578 None,
579 ))
580}
581
582pub fn keyed_hash_join_build_is_large(
588 input: &KeyedHashJoinStrategyInputFacts,
589 options: &KeyedHashJoinStrategyOptions,
590) -> bool {
591 input.observed_build_rows.is_some_and(|rows| rows >= options.large_keyed_hash_build_row_threshold)
592 || input.estimated_build_rows.is_some_and(|rows| rows >= options.large_keyed_hash_build_row_threshold)
593 || input.build_side_bytes.is_some_and(|bytes| bytes >= options.large_keyed_hash_build_bytes_threshold)
594}
595
596pub fn keyed_hash_join_strategy_should_probe_left_one_shot_build_key_uniqueness(
600 form: JoinForm,
601 join: &JoinNode,
602 input: KeyedHashJoinStrategyInputFacts,
603 options: &KeyedHashJoinStrategyOptions,
604) -> bool {
605 if form != JoinForm::KeyedEqui || join.spec.join_type != JoinType::Left || input.build_key_unique.is_some() {
606 return false;
607 }
608 if keyed_equi_strategy_choice(join, input, options).strategy() == JoinStrategy::OneShotEqui {
609 return false;
610 }
611 keyed_equi_strategy_choice(join, KeyedHashJoinStrategyInputFacts { build_key_unique: Some(true), ..input }, options)
612 .strategy()
613 == JoinStrategy::OneShotEqui
614}
615
616pub(crate) fn join_probe_streams_chunks(join: &JoinNode, form: JoinForm, strategy: JoinStrategy) -> bool {
620 match form {
621 JoinForm::KeyedEqui | JoinForm::KeyedFilteredSemiAnti | JoinForm::KeylessOuterCross => {
622 match join.spec.join_type {
626 JoinType::Inner | JoinType::LeftSemi | JoinType::LeftAnti { .. } | JoinType::LeftMark { .. } => true,
627 JoinType::Left => join.spec.build_side == JoinSide::Right,
628 JoinType::Full | JoinType::Cross => false,
629 }
630 },
631 JoinForm::KeyedEquiPostFilter => strategy == JoinStrategy::OneShotEqui,
632 JoinForm::KeyedResidual { left_match } => left_match && strategy == JoinStrategy::MixedKeyedResidual,
633 JoinForm::KeylessConditional(_) | JoinForm::ProjectedCross => false,
634 }
635}
636
637fn expr_requires_post_join_filter(expr: &Expr) -> bool {
643 expr.any(|expr| {
644 matches!(
645 expr,
646 Expr::Case { .. }
647 | Expr::IsDistinctFrom { .. }
648 | Expr::IsNotDistinctFrom { .. }
649 | Expr::Binary { op: BinaryOp::Divide, output_type: Some(DataType::Decimal128(_, _)), .. }
650 )
651 })
652}
653
654fn probe_input_for_join(join: &JoinNode) -> NodeId {
655 match join.spec.build_side {
656 JoinSide::Left => join.inputs[1],
657 JoinSide::Right => join.inputs[0],
658 }
659}
660
661fn reusable_hash_prebuild_supported_for_plan(plan: &QueryPlan, join: &JoinNode) -> bool {
666 let build_input = match join.spec.build_side {
667 JoinSide::Left => join.inputs[0],
668 JoinSide::Right => join.inputs[1],
669 };
670 let Some(input) = plan.node(build_input) else {
671 return true;
672 };
673 let schema = input.output_schema();
674 join.spec.keys.iter().all(|key| {
675 let column = match join.spec.build_side {
676 JoinSide::Left => &key.left,
677 JoinSide::Right => &key.right,
678 };
679 schema
680 .field_with_name(column.name())
681 .map(|field| hash_join_prebuild_reservation_supported_type(field.data_type()))
682 .unwrap_or(true)
683 })
684}
685
686fn hash_join_prebuild_reservation_supported_type(data_type: &DataType) -> bool {
687 !matches!(
690 data_type,
691 DataType::List(_)
692 | DataType::LargeList(_)
693 | DataType::FixedSizeList(_, _)
694 | DataType::Struct(_)
695 | DataType::Map(_, _)
696 | DataType::Union(_, _)
697 )
698}
699
700#[cfg(test)]
701mod tests {
702 use super::*;
703 use crate::{
704 expr::ColumnRef,
705 plan::{JoinKey, JoinOutputColumn, JoinSpec},
706 };
707
708 type Options = KeyedHashJoinStrategyOptions;
709
710 fn single_use_join(join_type: JoinType) -> JoinNode {
711 JoinNode {
712 inputs: [NodeId::from_index_for_test(0), NodeId::from_index_for_test(1)],
713 spec: JoinSpec::new(
714 join_type,
715 vec![JoinKey::new(
716 ColumnRef::input(0, "join_key", "join_key"),
717 ColumnRef::input(1, "join_key", "join_key"),
718 )],
719 )
720 .with_output_projection(vec![
721 JoinOutputColumn::left(ColumnRef::input(0, "left_value", "left_value"), "left_value"),
722 JoinOutputColumn::right(ColumnRef::input(1, "right_value", "right_value"), "right_value"),
723 ]),
724 }
725 }
726
727 fn single_use_inner_join() -> JoinNode {
728 single_use_join(JoinType::Inner)
729 }
730
731 fn single_use_inner_join_with_residual(residual: Expr) -> JoinNode {
732 let mut join = single_use_inner_join();
733 join.spec = join.spec.with_residual_predicate(residual);
734 join
735 }
736
737 fn single_use_left_join() -> JoinNode {
738 single_use_join(JoinType::Left)
739 }
740
741 fn input_facts(
742 estimated_build_rows: Option<u64>,
743 observed_build_rows: Option<u64>,
744 probe_side_bytes: Option<u64>,
745 ) -> KeyedHashJoinStrategyInputFacts {
746 KeyedHashJoinStrategyInputFacts {
747 estimated_build_rows,
748 observed_build_rows,
749 build_side_bytes: Some(Options::DEFAULT_SMALL_REUSABLE_HASH_BUILD_BYTES_THRESHOLD / 2),
750 probe_side_bytes,
751 estimated_output_rows: None,
752 estimated_output_bytes: None,
753 build_key_unique: None,
754 probe_reuse_count: 1,
755 reusable_prebuild_supported: true,
756 }
757 }
758
759 fn form(join: &JoinNode) -> Option<JoinForm> {
760 classify_join(NodeId::from_index_for_test(2), join).ok()
761 }
762
763 fn choice(
764 join: &JoinNode,
765 input: KeyedHashJoinStrategyInputFacts,
766 options: &KeyedHashJoinStrategyOptions,
767 ) -> JoinStrategyChoice {
768 choose_join_strategy(form(join).expect("test join has an operator form"), join, input, options)
769 }
770
771 fn keyed_hash_join_strategy_decision(
772 join: &JoinNode,
773 input: KeyedHashJoinStrategyInputFacts,
774 ) -> JoinStrategyChoice {
775 choice(join, input, &Options::default())
776 }
777
778 fn selected_strategy(choice: JoinStrategyChoice) -> JoinStrategy {
779 choice.strategy()
780 }
781
782 #[test]
783 fn known_small_probe_single_use_inner_join_stays_reusable() {
784 let decision = keyed_hash_join_strategy_decision(
785 &single_use_inner_join(),
786 input_facts(Some(10), None, Some(Options::DEFAULT_SMALL_ONE_SHOT_PROBE_BYTES_THRESHOLD / 2)),
787 );
788
789 assert_eq!(selected_strategy(decision), JoinStrategy::ReusableHashEqui);
790 }
791
792 #[test]
793 fn supported_keyed_residual_inner_join_stays_on_mixed_strategy() {
794 let join = single_use_inner_join_with_residual(Expr::binary(
795 Expr::column("left_value"),
796 BinaryOp::Gt,
797 Expr::column("right_value"),
798 ));
799
800 assert_eq!(form(&join), Some(JoinForm::KeyedResidual { left_match: false }));
801 }
802
803 #[test]
804 fn null_aware_anti_join_executes_only_on_the_filtered_strategy() {
805 let mut join = single_use_join(JoinType::LeftAnti { null_aware: true });
806 join.spec = join.spec.with_output_projection(vec![JoinOutputColumn::left(
807 ColumnRef::input(0, "left_value", "left_value"),
808 "left_value",
809 )]);
810 assert_eq!(form(&join), Some(JoinForm::KeyedFilteredSemiAnti));
811
812 join.spec = join.spec.with_residual_predicate(Expr::binary(
813 Expr::column("left_value"),
814 BinaryOp::Gt,
815 Expr::column("right_value"),
816 ));
817 assert_eq!(form(&join), None);
818 }
819
820 #[test]
821 fn post_filter_only_keyed_residual_inner_join_uses_one_shot_filter_path() {
822 let join = single_use_inner_join_with_residual(Expr::is_distinct_from(
823 Expr::column("left_value"),
824 Expr::column("right_value"),
825 ));
826 let decision = keyed_hash_join_strategy_decision(
827 &join,
828 input_facts(Some(10), None, Some(Options::DEFAULT_SMALL_ONE_SHOT_PROBE_BYTES_THRESHOLD / 2)),
829 );
830
831 assert_eq!(form(&join), Some(JoinForm::KeyedEquiPostFilter));
832 assert_eq!(selected_strategy(decision), JoinStrategy::OneShotEqui);
833 }
834
835 #[test]
836 fn unknown_probe_medium_single_use_inner_join_stays_reusable() {
837 let decision = keyed_hash_join_strategy_decision(
838 &single_use_inner_join(),
839 input_facts(Some(Options::DEFAULT_SMALL_REUSABLE_HASH_BUILD_ROW_THRESHOLD / 2), None, None),
840 );
841
842 assert_eq!(selected_strategy(decision), JoinStrategy::ReusableHashEqui);
843 }
844
845 #[test]
846 fn unknown_probe_tiny_estimated_build_stays_reusable() {
847 let decision = keyed_hash_join_strategy_decision(&single_use_inner_join(), input_facts(Some(10), None, None));
848
849 assert_eq!(selected_strategy(decision), JoinStrategy::ReusableHashEqui);
850 }
851
852 #[test]
853 fn unknown_probe_tiny_observed_without_estimate_stays_reusable() {
854 let decision = keyed_hash_join_strategy_decision(&single_use_inner_join(), input_facts(None, Some(31), None));
855
856 assert_eq!(selected_strategy(decision), JoinStrategy::ReusableHashEqui);
857 }
858
859 #[test]
860 fn unknown_probe_medium_observed_without_estimate_stays_reusable() {
861 let decision = keyed_hash_join_strategy_decision(
862 &single_use_inner_join(),
863 input_facts(None, Some(Options::DEFAULT_SMALL_REUSABLE_HASH_BUILD_ROW_THRESHOLD / 2), None),
864 );
865
866 assert_eq!(selected_strategy(decision), JoinStrategy::ReusableHashEqui);
867 }
868
869 #[test]
870 fn keyed_hash_join_strategy_options_change_selection_and_reported_thresholds() {
871 let join = single_use_inner_join();
872 let input = KeyedHashJoinStrategyInputFacts {
873 estimated_build_rows: Some(10),
874 observed_build_rows: None,
875 build_side_bytes: Some(Options::DEFAULT_SMALL_REUSABLE_HASH_BUILD_BYTES_THRESHOLD * 2),
876 probe_side_bytes: Some(Options::DEFAULT_SMALL_ONE_SHOT_PROBE_BYTES_THRESHOLD / 2),
877 estimated_output_rows: None,
878 estimated_output_bytes: None,
879 build_key_unique: None,
880 probe_reuse_count: 1,
881 reusable_prebuild_supported: true,
882 };
883 let options = KeyedHashJoinStrategyOptions::default()
884 .with_small_reusable_hash_build_row_threshold(5)
885 .with_small_reusable_hash_build_bytes_threshold(1024);
886
887 assert_eq!(selected_strategy(keyed_hash_join_strategy_decision(&join, input)), JoinStrategy::ReusableHashEqui);
888 let decision = choice(&join, input, &options);
889 let facts = decision.evidence().expect("a keyed choice carries its evidence").facts();
890
891 assert_eq!(selected_strategy(decision), JoinStrategy::PartitionedHashEqui);
892 assert_eq!(facts.get("small_reusable_hash_build_row_threshold"), Some(&"5".to_string()));
893 assert_eq!(facts.get("small_reusable_hash_build_bytes_threshold"), Some(&"1024".to_string()));
894 }
895
896 #[test]
897 fn keyed_hash_join_strategy_options_accept_zero_threshold_overrides() {
898 KeyedHashJoinStrategyOptions::default().with_large_keyed_hash_build_row_threshold(0).validate().unwrap();
899 KeyedHashJoinStrategyOptions::default().with_large_keyed_hash_build_bytes_threshold(0).validate().unwrap();
900 KeyedHashJoinStrategyOptions::default().with_large_keyed_hash_probe_bytes_threshold(0).validate().unwrap();
901 KeyedHashJoinStrategyOptions::default().with_small_reusable_hash_build_row_threshold(0).validate().unwrap();
902 KeyedHashJoinStrategyOptions::default().with_small_reusable_hash_build_bytes_threshold(0).validate().unwrap();
903 KeyedHashJoinStrategyOptions::default().with_small_one_shot_probe_bytes_threshold(0).validate().unwrap();
904 }
905
906 #[test]
907 fn left_join_with_small_probe_unique_build_and_contained_large_build_prefers_one_shot() {
908 let decision = keyed_hash_join_strategy_decision(
909 &single_use_left_join(),
910 KeyedHashJoinStrategyInputFacts {
911 estimated_build_rows: Some(Options::DEFAULT_MAX_SMALL_PROBE_LEFT_ONE_SHOT_BUILD_ROW_THRESHOLD),
912 observed_build_rows: None,
913 build_side_bytes: Some(Options::DEFAULT_MAX_SMALL_PROBE_LEFT_ONE_SHOT_BUILD_BYTES_THRESHOLD),
914 probe_side_bytes: Some(Options::DEFAULT_SMALL_ONE_SHOT_PROBE_BYTES_THRESHOLD / 2),
915 estimated_output_rows: None,
916 estimated_output_bytes: None,
917 build_key_unique: Some(true),
918 probe_reuse_count: 1,
919 reusable_prebuild_supported: true,
920 },
921 );
922
923 assert_eq!(selected_strategy(decision), JoinStrategy::OneShotEqui);
924 }
925
926 #[test]
927 fn left_join_with_small_probe_and_small_build_stays_reusable() {
928 let decision = keyed_hash_join_strategy_decision(
929 &single_use_left_join(),
930 KeyedHashJoinStrategyInputFacts {
931 estimated_build_rows: Some(10),
932 observed_build_rows: None,
933 build_side_bytes: Some(1024),
934 probe_side_bytes: Some(Options::DEFAULT_SMALL_ONE_SHOT_PROBE_BYTES_THRESHOLD / 2),
935 estimated_output_rows: None,
936 estimated_output_bytes: None,
937 build_key_unique: Some(true),
938 probe_reuse_count: 1,
939 reusable_prebuild_supported: true,
940 },
941 );
942
943 assert_eq!(selected_strategy(decision), JoinStrategy::ReusableHashEqui);
944 }
945
946 #[test]
947 fn left_join_with_small_probe_and_uncontained_large_build_stays_partitioned() {
948 let decision = keyed_hash_join_strategy_decision(
949 &single_use_left_join(),
950 KeyedHashJoinStrategyInputFacts {
951 estimated_build_rows: Some(Options::DEFAULT_MAX_SMALL_PROBE_LEFT_ONE_SHOT_BUILD_ROW_THRESHOLD + 1),
952 observed_build_rows: None,
953 build_side_bytes: Some(Options::DEFAULT_MAX_SMALL_PROBE_LEFT_ONE_SHOT_BUILD_BYTES_THRESHOLD + 1),
954 probe_side_bytes: Some(Options::DEFAULT_SMALL_ONE_SHOT_PROBE_BYTES_THRESHOLD / 2),
955 estimated_output_rows: None,
956 estimated_output_bytes: None,
957 build_key_unique: Some(true),
958 probe_reuse_count: 1,
959 reusable_prebuild_supported: true,
960 },
961 );
962
963 assert_eq!(selected_strategy(decision), JoinStrategy::PartitionedHashEqui);
964 }
965
966 #[test]
967 fn left_join_with_small_probe_and_no_output_containment_evidence_stays_partitioned() {
968 let decision = keyed_hash_join_strategy_decision(
969 &single_use_left_join(),
970 KeyedHashJoinStrategyInputFacts {
971 estimated_build_rows: Some(Options::DEFAULT_MAX_SMALL_PROBE_LEFT_ONE_SHOT_BUILD_ROW_THRESHOLD),
972 observed_build_rows: None,
973 build_side_bytes: Some(Options::DEFAULT_MAX_SMALL_PROBE_LEFT_ONE_SHOT_BUILD_BYTES_THRESHOLD),
974 probe_side_bytes: Some(Options::DEFAULT_SMALL_ONE_SHOT_PROBE_BYTES_THRESHOLD / 2),
975 estimated_output_rows: None,
976 estimated_output_bytes: None,
977 build_key_unique: None,
978 probe_reuse_count: 1,
979 reusable_prebuild_supported: true,
980 },
981 );
982
983 assert_eq!(selected_strategy(decision), JoinStrategy::PartitionedHashEqui);
984 }
985
986 #[test]
987 fn left_join_with_small_probe_and_contained_build_requests_uniqueness_probe() {
988 assert!(keyed_hash_join_strategy_should_probe_left_one_shot_build_key_uniqueness(
989 JoinForm::KeyedEqui,
990 &single_use_left_join(),
991 KeyedHashJoinStrategyInputFacts {
992 estimated_build_rows: Some(Options::DEFAULT_MAX_SMALL_PROBE_LEFT_ONE_SHOT_BUILD_ROW_THRESHOLD),
993 observed_build_rows: None,
994 build_side_bytes: Some(Options::DEFAULT_MAX_SMALL_PROBE_LEFT_ONE_SHOT_BUILD_BYTES_THRESHOLD),
995 probe_side_bytes: Some(Options::DEFAULT_SMALL_ONE_SHOT_PROBE_BYTES_THRESHOLD / 2),
996 estimated_output_rows: None,
997 estimated_output_bytes: None,
998 build_key_unique: None,
999 probe_reuse_count: 1,
1000 reusable_prebuild_supported: true,
1001 },
1002 &Options::default(),
1003 ));
1004 }
1005
1006 #[test]
1007 fn left_join_with_small_probe_and_contained_output_estimate_without_exact_proof_stays_partitioned() {
1008 let decision = keyed_hash_join_strategy_decision(
1009 &single_use_left_join(),
1010 KeyedHashJoinStrategyInputFacts {
1011 estimated_build_rows: Some(Options::DEFAULT_MAX_SMALL_PROBE_LEFT_ONE_SHOT_BUILD_ROW_THRESHOLD),
1012 observed_build_rows: None,
1013 build_side_bytes: Some(Options::DEFAULT_MAX_SMALL_PROBE_LEFT_ONE_SHOT_BUILD_BYTES_THRESHOLD),
1014 probe_side_bytes: Some(Options::DEFAULT_SMALL_ONE_SHOT_PROBE_BYTES_THRESHOLD / 2),
1015 estimated_output_rows: Some(1),
1016 estimated_output_bytes: Some(1024),
1017 build_key_unique: None,
1018 probe_reuse_count: 1,
1019 reusable_prebuild_supported: true,
1020 },
1021 );
1022
1023 assert_eq!(selected_strategy(decision), JoinStrategy::PartitionedHashEqui);
1024 }
1025}