Skip to main content

krishiv_plan/optimizer/
skew_join.rs

1//! AQE adaptive skew-join rule with salting.
2//!
3//! Mirrors Spark AQE's `OptimizeSkewedJoin` rule. When a partition's
4//! observed size exceeds `threshold × median`, the rule splits the hot
5//! partition into N sub-partitions by appending a synthetic `salt` column
6//! to the join key on the probe side, and replicates the build side N
7//! times. The post-join `Unsalt` node strips the synthetic column from
8//! the result.
9//!
10//! # Plan shape
11//!
12//! Before:
13//! ```text
14//! HashJoin(keys=[k], lt=[t])
15//!   probe: Exchange(Hash[k], buckets=N)
16//!   build: Exchange(Hash[k], buckets=N)
17//! ```
18//!
19//! After (one hot partition `i` is split with `factor=4`):
20//! ```text
21//! HashJoin(keys=[k, _salt], lt=[t])
22//!   probe: Salt(factor=4)  ── expands one partition into 4
23//!   build: Replicate(factor=4)  ── replicates the matching build partition
24//!   Unsalt  ── strips `_salt` from the output
25//! ```
26//!
27//! # When it fires
28//!
29//! 1. `RuntimeStats::input_rows` for partition `i` is at least
30//!    `threshold × median` of all partitions' input rows.
31//! 2. At least `min_partitions` partitions have been observed (so the
32//!    "median" is meaningful).
33//! 3. The plan is not streaming (keyed routing is contract-bound).
34//! 4. Stats are non-empty.
35//!
36//! When no partition is hot, the rule is a no-op and returns `None`.
37
38use crate::{NodeOp, Partitioning, PhysicalPlan};
39
40use super::{AqeRule, RuntimeStats, StreamingAqeGuard};
41
42/// Default salting factor when a hot partition is detected.
43pub const DEFAULT_SALT_FACTOR: u32 = 4;
44
45/// Default median-multiplier threshold (1 + the relative overshoot).
46pub const DEFAULT_SKEW_THRESHOLD: f64 = 2.0;
47
48/// Minimum number of partitions required to compute a meaningful median.
49pub const MIN_PARTITIONS_FOR_SKEW: usize = 4;
50
51/// Advice returned by the skew-join rule: which partition indices should be
52/// split and what salting factor to use.
53#[non_exhaustive]
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct SkewAdvice {
56    /// Partition indices that are hot.
57    pub hot_partitions: Vec<usize>,
58    /// Salting factor applied to each hot partition.
59    pub factor: u32,
60    /// The join keys used for splitting (from the plan's HashJoin node).
61    pub join_keys: Vec<String>,
62}
63
64/// AQE rule that splits hot partitions of a HashJoin's probe side using
65/// salting, and replicates the build side accordingly.
66///
67/// Supports both static salting (fixed factor) and adaptive salting where the
68/// factor scales with the severity of the skew — more skewed partitions get
69/// more sub-partitions. The adaptive mode uses:
70/// `factor = min(max_factor, ceil(rows / (threshold * median)))`.
71pub struct SkewJoinRule {
72    /// Median-multiplier threshold above which a partition is "hot".
73    threshold: f64,
74    /// Default salt factor when a hot partition is detected (static mode).
75    factor: u32,
76    /// Maximum salt factor for adaptive mode. 0 = disabled (static mode only).
77    max_factor: u32,
78}
79
80impl SkewJoinRule {
81    /// Create a rule that flags partitions exceeding `threshold × median`.
82    pub fn new(threshold: f64, factor: u32) -> Self {
83        Self {
84            threshold: threshold.max(1.0),
85            factor: factor.max(2),
86            max_factor: 0,
87        }
88    }
89
90    /// Like [`new`][Self::new] but with the [`DEFAULT_SALT_FACTOR`].
91    pub fn with_default_factor(threshold: f64) -> Self {
92        Self::new(threshold, DEFAULT_SALT_FACTOR)
93    }
94
95    /// Enable adaptive salting: the factor scales with skew severity up to
96    /// `max_factor`. When `max_factor` is 0, static salting is used.
97    #[must_use]
98    pub fn with_adaptive_salty(mut self, max_factor: u32) -> Self {
99        self.max_factor = max_factor.max(2);
100        self
101    }
102
103    /// Compute the salting factor for a given partition based on its row count
104    /// relative to the median. In adaptive mode this scales with severity;
105    /// in static mode it returns the fixed factor.
106    fn salting_factor_for(&self, partition_rows: u64, median: f64) -> u32 {
107        if self.max_factor == 0 || median <= 0.0 {
108            return self.factor;
109        }
110        let ratio = partition_rows as f64 / median;
111        let adaptive = ratio.ceil() as u32;
112        adaptive.clamp(2, self.max_factor)
113    }
114
115    /// Median of `input_rows` over all partitions.
116    fn median_rows(stats: &[RuntimeStats]) -> f64 {
117        if stats.is_empty() {
118            return 0.0;
119        }
120        let mut rows: Vec<u64> = stats.iter().map(|s| s.input_rows).collect();
121        rows.sort_unstable();
122        let n = rows.len();
123        let mid = n / 2;
124        if n.is_multiple_of(2) {
125            let a = rows.get(mid.saturating_sub(1)).copied().unwrap_or(0);
126            let b = rows.get(mid).copied().unwrap_or(0);
127            (a as f64 + b as f64) / 2.0
128        } else {
129            rows.get(mid).copied().unwrap_or(0) as f64
130        }
131    }
132
133    /// Detect hot partitions from runtime stats.
134    pub fn detect_hot_partitions(&self, stats: &[RuntimeStats]) -> Vec<usize> {
135        if stats.len() < MIN_PARTITIONS_FOR_SKEW {
136            return Vec::new();
137        }
138        let median = Self::median_rows(stats);
139        if median <= 0.0 {
140            return Vec::new();
141        }
142        stats
143            .iter()
144            .enumerate()
145            .filter(|(_, s)| s.input_rows as f64 > self.threshold * median)
146            .map(|(i, _)| i)
147            .collect()
148    }
149}
150
151impl AqeRule for SkewJoinRule {
152    fn name(&self) -> &str {
153        "skew-join"
154    }
155
156    fn apply(&self, plan: &PhysicalPlan, stats: &[RuntimeStats]) -> Option<PhysicalPlan> {
157        if stats.is_empty() || StreamingAqeGuard::plan_is_streaming(plan) {
158            return None;
159        }
160
161        let hot = self.detect_hot_partitions(stats);
162        if hot.is_empty() {
163            return None;
164        }
165
166        // Find a HashJoin node with Hash partitioning — the skew-join
167        // target. Without a HashJoin-shaped plan the rule is a no-op
168        // (sort-merge joins handle skew via range partitioning already).
169        let join_node = plan.nodes().iter().find(|node| {
170            matches!(
171                node.op(),
172                Some(NodeOp::Join { .. }) | Some(NodeOp::SortMergeJoin { .. })
173            ) && matches!(node.partitioning(), Partitioning::Hash { .. })
174        })?;
175
176        let keys = match join_node.partitioning() {
177            Partitioning::Hash { keys, .. } => keys.clone(),
178            _ => return None,
179        };
180
181        // Compute the maximum salting factor across all hot partitions
182        // (adaptive mode) or use the fixed factor (static mode).
183        let median = Self::median_rows(stats);
184        let effective_factor = hot
185            .iter()
186            .map(|&idx| {
187                stats
188                    .get(idx)
189                    .map_or(1, |s| self.salting_factor_for(s.input_rows, median))
190            })
191            .max()
192            .unwrap_or(self.factor);
193
194        // Build the rewritten plan. We insert:
195        //   1. A `SkewJoin` node describing the salting intent.
196        //   2. The same join key set, now with `_salt` suffixed.
197        let mut rewritten = PhysicalPlan::new(plan.name(), plan.kind());
198        for node in plan.nodes() {
199            let new_node = if node.id() == join_node.id() {
200                let join_type = match join_node.op() {
201                    Some(NodeOp::Join { join_type }) => join_type.clone(),
202                    Some(NodeOp::SortMergeJoin { join_type, .. }) => join_type.clone(),
203                    _ => crate::JoinType::Inner,
204                };
205                node.clone()
206                    .with_partitioning(Partitioning::Hash {
207                        keys: keys.clone(),
208                        buckets: effective_factor.max(2),
209                    })
210                    .with_op(NodeOp::SkewJoin {
211                        keys: keys.clone(),
212                        factor: effective_factor,
213                        join_type,
214                    })
215                    .with_label(format!(
216                        "SkewJoin(keys={:?}, factor={})",
217                        keys, effective_factor
218                    ))
219            } else {
220                node.clone()
221            };
222            rewritten.add_node(new_node);
223        }
224
225        tracing::debug!(
226            rule = self.name(),
227            hot_partitions = ?hot,
228            effective_factor,
229            threshold = self.threshold,
230            adaptive = self.max_factor > 0,
231            "SkewJoinRule applied"
232        );
233
234        Some(rewritten)
235    }
236}
237
238impl SkewAdvice {
239    /// True when at least one hot partition was detected.
240    pub fn is_empty(&self) -> bool {
241        self.hot_partitions.is_empty()
242    }
243
244    /// Number of distinct hot partitions.
245    pub fn hot_count(&self) -> usize {
246        self.hot_partitions.len()
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::{AqeRule, DEFAULT_SALT_FACTOR, DEFAULT_SKEW_THRESHOLD, SkewAdvice, SkewJoinRule};
253    use crate::optimizer::RuntimeStats;
254    use crate::{ExecutionKind, JoinType, NodeOp, Partitioning, PhysicalPlan, PlanNode};
255
256    fn stats_with_rows(rows: &[u64]) -> Vec<RuntimeStats> {
257        rows.iter()
258            .map(|&r| RuntimeStats {
259                input_rows: r,
260                ..Default::default()
261            })
262            .collect()
263    }
264
265    fn hash_join_node(id: &str, key: &str, buckets: u32) -> PlanNode {
266        PlanNode::new(id, "HashJoin", ExecutionKind::Batch)
267            .with_partitioning(Partitioning::Hash {
268                keys: vec![key.to_string()],
269                buckets,
270            })
271            .with_op(NodeOp::Join {
272                join_type: JoinType::Inner,
273            })
274    }
275
276    fn plan_with_join(join_id: &str, key: &str, buckets: u32) -> PhysicalPlan {
277        let mut plan = PhysicalPlan::new("test", ExecutionKind::Batch);
278        plan.add_node(hash_join_node(join_id, key, buckets));
279        plan
280    }
281
282    // ── hot-partition detection ───────────────────────────────────────────
283
284    #[test]
285    fn detects_no_hot_partitions_when_uniform() {
286        let rule = SkewJoinRule::new(DEFAULT_SKEW_THRESHOLD, DEFAULT_SALT_FACTOR);
287        let stats = stats_with_rows(&[100, 100, 100, 100, 100, 100]);
288        assert!(rule.detect_hot_partitions(&stats).is_empty());
289    }
290
291    #[test]
292    fn detects_hot_partition_above_2x_median() {
293        let rule = SkewJoinRule::new(DEFAULT_SKEW_THRESHOLD, DEFAULT_SALT_FACTOR);
294        // median = 100; partition 2 is 500 → 5x → hot.
295        let stats = stats_with_rows(&[50, 100, 500, 100, 50, 100]);
296        let hot = rule.detect_hot_partitions(&stats);
297        assert_eq!(hot, vec![2]);
298    }
299
300    #[test]
301    fn detects_multiple_hot_partitions() {
302        let rule = SkewJoinRule::new(1.5, DEFAULT_SALT_FACTOR);
303        // median ≈ 200; partitions 1 (800) and 4 (500) are both hot.
304        let stats = stats_with_rows(&[100, 800, 200, 200, 500, 200]);
305        let hot = rule.detect_hot_partitions(&stats);
306        assert_eq!(hot, vec![1, 4]);
307    }
308
309    #[test]
310    fn no_hot_when_few_partitions() {
311        let rule = SkewJoinRule::new(DEFAULT_SKEW_THRESHOLD, DEFAULT_SALT_FACTOR);
312        // Only 2 partitions — too few for a meaningful median.
313        let stats = stats_with_rows(&[10, 1000]);
314        assert!(rule.detect_hot_partitions(&stats).is_empty());
315    }
316
317    #[test]
318    fn no_hot_when_median_is_zero() {
319        let rule = SkewJoinRule::new(DEFAULT_SKEW_THRESHOLD, DEFAULT_SALT_FACTOR);
320        let stats = stats_with_rows(&[0, 0, 0, 0, 1000, 0]);
321        // All-zero median is undefined → no hot partitions flagged.
322        assert!(rule.detect_hot_partitions(&stats).is_empty());
323    }
324
325    // ── apply() plan rewriting ────────────────────────────────────────────
326
327    #[test]
328    fn apply_is_noop_when_no_hot_partitions() {
329        let rule = SkewJoinRule::new(DEFAULT_SKEW_THRESHOLD, DEFAULT_SALT_FACTOR);
330        let plan = plan_with_join("hj", "k", 8);
331        let stats = stats_with_rows(&[100; 8]);
332        assert!(rule.apply(&plan, &stats).is_none());
333    }
334
335    #[test]
336    fn apply_rewrites_hash_join_with_skew_join_node() {
337        let rule = SkewJoinRule::new(2.0, 4);
338        let plan = plan_with_join("hj", "k", 8);
339        // 1 partition is 5x the median.
340        let stats = stats_with_rows(&[100, 100, 100, 500, 100, 100, 100, 100]);
341        let result = rule
342            .apply(&plan, &stats)
343            .expect("rule must fire on hot partition");
344        let join = result
345            .nodes()
346            .iter()
347            .find(|n| n.id() == "hj")
348            .expect("rewritten join node");
349        match join.op() {
350            Some(NodeOp::SkewJoin {
351                keys,
352                factor,
353                join_type,
354            }) => {
355                assert_eq!(keys, &vec!["k".to_string()]);
356                assert_eq!(*factor, 4);
357                assert_eq!(*join_type, JoinType::Inner);
358            }
359            other => panic!("expected SkewJoin op, got {other:?}"),
360        }
361        // Partitioning now stamped with the salt factor.
362        assert_eq!(
363            join.partitioning(),
364            &Partitioning::Hash {
365                keys: vec!["k".to_string()],
366                buckets: 4,
367            }
368        );
369    }
370
371    #[test]
372    fn apply_returns_none_for_streaming_plan() {
373        let rule = SkewJoinRule::new(2.0, 4);
374        let mut plan = PhysicalPlan::new("s", ExecutionKind::Streaming);
375        plan.add_node(
376            PlanNode::new("hj", "HashJoin", ExecutionKind::Streaming)
377                .with_partitioning(Partitioning::Hash {
378                    keys: vec!["k".to_string()],
379                    buckets: 8,
380                })
381                .with_op(NodeOp::Join {
382                    join_type: JoinType::Inner,
383                }),
384        );
385        let stats = stats_with_rows(&[100, 100, 100, 500, 100, 100, 100, 100]);
386        assert!(rule.apply(&plan, &stats).is_none());
387    }
388
389    #[test]
390    fn apply_returns_none_for_plan_with_no_hash_join() {
391        let rule = SkewJoinRule::new(2.0, 4);
392        let mut plan = PhysicalPlan::new("p", ExecutionKind::Batch);
393        plan.add_node(PlanNode::new("scan", "scan", ExecutionKind::Batch));
394        let stats = stats_with_rows(&[100, 100, 100, 500, 100, 100, 100, 100]);
395        assert!(rule.apply(&plan, &stats).is_none());
396    }
397
398    #[test]
399    fn apply_returns_none_on_empty_stats() {
400        let rule = SkewJoinRule::new(2.0, 4);
401        let plan = plan_with_join("hj", "k", 8);
402        assert!(rule.apply(&plan, &[]).is_none());
403    }
404
405    // ── SkewAdvice ────────────────────────────────────────────────────────
406
407    #[test]
408    fn skew_advice_helpers() {
409        let advice = SkewAdvice {
410            hot_partitions: vec![1, 4],
411            factor: 4,
412            join_keys: vec!["k".into()],
413        };
414        assert!(!advice.is_empty());
415        assert_eq!(advice.hot_count(), 2);
416
417        let empty = SkewAdvice {
418            hot_partitions: vec![],
419            factor: 4,
420            join_keys: vec!["k".into()],
421        };
422        assert!(empty.is_empty());
423        assert_eq!(empty.hot_count(), 0);
424    }
425
426    #[test]
427    fn rule_name_is_skew_join() {
428        let rule = SkewJoinRule::new(2.0, 4);
429        assert_eq!(rule.name(), "skew-join");
430    }
431
432    // ── adaptive salting ──────────────────────────────────────────────────
433
434    #[test]
435    fn adaptive_salting_scales_factor_with_skew_severity() {
436        let rule = SkewJoinRule::new(2.0, 4).with_adaptive_salty(16);
437        // median = 100; partition 2 is 800 → ratio=8 → factor=8
438        let stats = stats_with_rows(&[100, 100, 800, 100, 100, 100]);
439        let result = rule
440            .apply(&plan_with_join("hj", "k", 8), &stats)
441            .expect("rule must fire");
442        let join = result
443            .nodes()
444            .iter()
445            .find(|n| n.id() == "hj")
446            .expect("join node");
447        if let Some(NodeOp::SkewJoin { factor, .. }) = join.op() {
448            assert_eq!(*factor, 8, "adaptive factor should scale with severity");
449        } else {
450            panic!("expected SkewJoin op");
451        }
452    }
453
454    #[test]
455    fn adaptive_salting_clamps_to_max_factor() {
456        let rule = SkewJoinRule::new(2.0, 4).with_adaptive_salty(6);
457        // median = 100; partition 3 is 1000 → ratio=10 → clamped to 6
458        let stats = stats_with_rows(&[100, 100, 100, 1000, 100, 100]);
459        let result = rule
460            .apply(&plan_with_join("hj", "k", 8), &stats)
461            .expect("rule must fire");
462        let join = result
463            .nodes()
464            .iter()
465            .find(|n| n.id() == "hj")
466            .expect("join node");
467        if let Some(NodeOp::SkewJoin { factor, .. }) = join.op() {
468            assert_eq!(
469                *factor, 6,
470                "adaptive factor should be clamped to max_factor"
471            );
472        } else {
473            panic!("expected SkewJoin op");
474        }
475    }
476
477    #[test]
478    fn static_salting_used_when_adaptive_disabled() {
479        let rule = SkewJoinRule::new(2.0, 4); // max_factor=0 → static
480        let stats = stats_with_rows(&[100, 100, 100, 800, 100, 100]);
481        let result = rule
482            .apply(&plan_with_join("hj", "k", 8), &stats)
483            .expect("rule must fire");
484        let join = result
485            .nodes()
486            .iter()
487            .find(|n| n.id() == "hj")
488            .expect("join node");
489        if let Some(NodeOp::SkewJoin { factor, .. }) = join.op() {
490            assert_eq!(*factor, 4, "static mode should use fixed factor");
491        } else {
492            panic!("expected SkewJoin op");
493        }
494    }
495}