Skip to main content

query_planning/capability/
join.rs

1//! Join operator forms and strategy choice.
2//!
3//! [`classify_join`] derives one [`JoinForm`] per join node from its spec;
4//! the form names the executors that can run the join. [`choose_join_strategy`]
5//! picks one of them from size facts and the thresholds in
6//! [`KeyedHashJoinStrategyOptions`]. Capability admission, the compiled join
7//! plan, the bounded executor's refinement, and the join-strategy metric rows
8//! read these two functions.
9
10use 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/// Threshold policy used by capability admission and runtime keyed-hash join
23/// strategy selection.
24///
25/// This is an expert Rust surface only. Keep server/session/user-facing settings
26/// out until the admission and runtime contracts have a reviewed tuning model.
27#[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    /// Past this build scale, reusable hash residency is risky enough to prefer partitioning.
42    pub(crate) const DEFAULT_LARGE_KEYED_HASH_BUILD_ROW_THRESHOLD: u64 = 64_000_000;
43    /// Byte-size large-build gate used alongside row evidence.
44    pub(crate) const DEFAULT_LARGE_KEYED_HASH_BUILD_BYTES_THRESHOLD: u64 = 8 * 1024 * 1024 * 1024;
45    /// Large probes can turn one reusable build into too much gather/output pressure.
46    pub(crate) const DEFAULT_LARGE_KEYED_HASH_PROBE_BYTES_THRESHOLD: u64 = 2 * 1024 * 1024 * 1024;
47    /// Builds below this row scale are cheap enough to prebuild even for one probe.
48    pub(crate) const DEFAULT_SMALL_REUSABLE_HASH_BUILD_ROW_THRESHOLD: u64 = 1_000_000;
49    /// Byte-size small-build gate used alongside row evidence.
50    pub(crate) const DEFAULT_SMALL_REUSABLE_HASH_BUILD_BYTES_THRESHOLD: u64 = 64 * 1024 * 1024;
51    /// Small probes avoid the scheduling overhead of the partitioned path.
52    pub(crate) const DEFAULT_SMALL_ONE_SHOT_PROBE_BYTES_THRESHOLD: u64 = 256 * 1024 * 1024;
53    /// Large-but-contained right builds can still be cheaper than partitioning
54    /// when a left join's preserved probe side is already small.
55    pub(crate) const DEFAULT_MAX_SMALL_PROBE_LEFT_ONE_SHOT_BUILD_ROW_THRESHOLD: u64 = 128_000_000;
56    /// Byte guard paired with
57    /// [`DEFAULT_MAX_SMALL_PROBE_LEFT_ONE_SHOT_BUILD_ROW_THRESHOLD`](Self::DEFAULT_MAX_SMALL_PROBE_LEFT_ONE_SHOT_BUILD_ROW_THRESHOLD).
58    pub(crate) const DEFAULT_MAX_SMALL_PROBE_LEFT_ONE_SHOT_BUILD_BYTES_THRESHOLD: u64 = 2 * 1024 * 1024 * 1024;
59
60    /// Build row threshold used to classify a keyed hash build as large.
61    pub fn large_keyed_hash_build_row_threshold(&self) -> u64 {
62        self.large_keyed_hash_build_row_threshold
63    }
64
65    /// Build byte threshold used alongside row evidence to classify a large build.
66    pub fn large_keyed_hash_build_bytes_threshold(&self) -> u64 {
67        self.large_keyed_hash_build_bytes_threshold
68    }
69
70    /// Probe byte threshold used when considering partitioned execution.
71    pub fn large_keyed_hash_probe_bytes_threshold(&self) -> u64 {
72        self.large_keyed_hash_probe_bytes_threshold
73    }
74
75    /// Build row bound for considering a reusable hash build even for one probe.
76    pub fn small_reusable_hash_build_row_threshold(&self) -> u64 {
77        self.small_reusable_hash_build_row_threshold
78    }
79
80    /// Build byte bound paired with the small reusable-build row threshold.
81    pub fn small_reusable_hash_build_bytes_threshold(&self) -> u64 {
82        self.small_reusable_hash_build_bytes_threshold
83    }
84
85    /// Probe byte bound under which a one-shot join can avoid partitioning.
86    pub fn small_one_shot_probe_bytes_threshold(&self) -> u64 {
87        self.small_one_shot_probe_bytes_threshold
88    }
89
90    /// Maximum build rows for the one-shot left-join path with a small probe.
91    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    /// Maximum build bytes for the one-shot left-join path with a small probe.
96    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    /// Validates internal consistency for keyed hash-join strategy thresholds.
101    ///
102    /// Zero remains accepted because this policy is still an expert Rust surface
103    /// and callers may use zero to disable narrow strategy branches. The checks
104    /// only reject threshold orderings that make "small" and "large" evidence
105    /// overlap contradictorily.
106    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    /// Sets the large-build row threshold. Call [`Self::validate`] to check
129    /// its ordering against the small reusable-build threshold.
130    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    /// Sets the large-build threshold in bytes. Call [`Self::validate`] to
136    /// check its ordering against the small reusable-build byte threshold.
137    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    /// Sets the small reusable-build row threshold. This setter does not
149    /// validate its ordering against the large-build threshold.
150    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    /// Sets the small reusable-build byte threshold. This setter does not
156    /// validate its ordering against the large-build byte threshold.
157    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/// Size evidence one keyed hash join is chosen from.
208///
209/// Planning fills the estimates and leaves `observed_build_rows` and
210/// `build_key_unique` empty; the bounded executor refines the same struct once
211/// the build side is materialized, so the choice can move from the planned
212/// strategy to the one the observed sizes justify. A `probe_reuse_count` above
213/// one, or a `build_artifact_id` on the join, means several probes read one
214/// build, and the reusable hash table is then kept whatever its size.
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub struct KeyedHashJoinStrategyInputFacts {
217    #[doc(hidden)]
218    pub estimated_build_rows: Option<u64>,
219    /// Rows counted after the build side materialized; `None` at planning
220    /// time.
221    #[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    /// Whether every build key value is distinct, once measured. Only a left
232    /// join reads it; see
233    /// [`keyed_hash_join_strategy_should_probe_left_one_shot_build_key_uniqueness`].
234    #[doc(hidden)]
235    pub build_key_unique: Option<bool>,
236    /// Probes that read this build. Planning reports one.
237    #[doc(hidden)]
238    pub probe_reuse_count: u64,
239    /// Whether every build key type supports the hash table pre-build
240    /// reservation; nested Arrow types do not.
241    #[doc(hidden)]
242    pub reusable_prebuild_supported: bool,
243}
244
245/// Join types the keyless conditional executor runs. A left join must build
246/// its right side and a semi or anti join its left; cross and mark joins have
247/// no keyless conditional form.
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
249pub enum KeylessConditionalJoinKind {
250    Inner,
251    Left,
252    Full,
253    LeftSemi,
254    LeftAnti,
255}
256
257/// The operator form of one join node, derived once from its spec.
258///
259/// Each form names the executors that can run the join
260/// ([`JoinForm::strategies`]); strategy choice picks among them from size
261/// facts and never widens the set.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
263pub enum JoinForm {
264    /// Equality keys without a residual: an inner join, a left join that
265    /// builds its right side, or a full join.
266    KeyedEqui,
267    /// An inner join with equality keys whose residual only a filter after
268    /// matching can evaluate.
269    KeyedEquiPostFilter,
270    /// Equality keys without a residual: a semi, anti, or mark join that
271    /// builds its right side and outputs only left columns.
272    KeyedFilteredSemiAnti,
273    /// Equality keys and a residual the mixed join evaluates while matching.
274    /// `left_match` marks a semi or anti join that builds its left side.
275    KeyedResidual { left_match: bool },
276    /// A residual without equality keys.
277    KeylessConditional(KeylessConditionalJoinKind),
278    /// A left (building its right side) or full join without keys or residual.
279    KeylessOuterCross,
280    /// A cross join without keys or residual.
281    ProjectedCross,
282}
283
284impl JoinForm {
285    /// Every strategy that can execute this form.
286    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    /// Whether the build runs ahead of other ready work. A filtered semi,
318    /// anti, or mark build is a key set the probe side waits on.
319    pub fn builds_first(self) -> bool {
320        self == Self::KeyedFilteredSemiAnti
321    }
322}
323
324/// Classifies a join node into the operator form that executes it, or
325/// rejects a join no native executor runs.
326pub(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    // Semi, anti, and mark joins output only left columns; node validation
345    // rejects any other projection, so the forms below need not check it.
346    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            // Null-aware anti semantics are implemented only by the filtered
358            // semi/anti executor, which takes no residual.
359            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
392/// Chooses the strategy that executes `join` in `form` from size facts.
393///
394/// The choice is always one of [`JoinForm::strategies`]. Missing size facts
395/// still yield an executable strategy: an unknown keyed build keeps the
396/// reusable hash table.
397pub(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
431/// Planning-time facts for `join`: row and byte estimates from the plan's size
432/// model, no observed rows, one probe, and no uniqueness measurement. The
433/// bounded executor fills the observed fields before its own choice.
434pub 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    // A left join preserves every probe row. When the probe is small and each
510    // probe row matches at most one build row (unique build key), the output is
511    // bounded by the probe even though the build is not small, and only then
512    // may a build above the small thresholds be joined in one shot.
513    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
544/// Evidence for partitioning a keyed residual join, when its build or probe
545/// side is large enough that the mixed join should run partition by partition.
546fn 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
582/// Whether the build side already exceeds the large-build thresholds.
583///
584/// Large-build evidence is monotone in observed rows and bytes: once a build
585/// is large under partial observation it stays large, so callers may commit to
586/// partition-wise consumption before the whole build side is materialized.
587pub 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
596/// Whether measuring build-key uniqueness could move a keyed left join from
597/// partitioning to a one-shot join: the choice is not one-shot without the
598/// measurement and would be with a unique build key.
599pub 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
616/// Whether a probe kernel of `strategy` pulls its probe input chunk by
617/// chunk, so a frame-stream probe input stays chunked and is not concatenated
618/// first.
619pub(crate) fn join_probe_streams_chunks(join: &JoinNode, form: JoinForm, strategy: JoinStrategy) -> bool {
620    match form {
621        JoinForm::KeyedEqui | JoinForm::KeyedFilteredSemiAnti | JoinForm::KeylessOuterCross => {
622            // A full join, and a left join that builds its left side, report
623            // unmatched build rows only after the whole probe has been seen,
624            // so they take the probe as one frame.
625            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
637/// Residual forms the cuDF mixed join cannot evaluate while matching: it
638/// evaluates the residual as an AST expression, which has no CASE, no
639/// null-aware comparison, and no Decimal128 division. Such a residual runs as a
640/// filter over the matched rows, which keeps SQL semantics only for an inner
641/// join; for any other join type the set of unmatched rows would change.
642fn 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
661/// Whether every build key column has a type the pre-build reservation
662/// supports. A build input or key column the plan cannot resolve is reported as
663/// supported: node validation owns that rejection, and a second one here would
664/// misname the cause.
665fn 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    // Flat strings use cuDF's data-dependent exact pre-build contract; only
688    // structural nested keys are rejected here.
689    !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}