1use crate::{NodeOp, Partitioning, PhysicalPlan};
39
40use super::{AqeRule, RuntimeStats, StreamingAqeGuard};
41
42pub const DEFAULT_SALT_FACTOR: u32 = 4;
44
45pub const DEFAULT_SKEW_THRESHOLD: f64 = 2.0;
47
48pub const MIN_PARTITIONS_FOR_SKEW: usize = 4;
50
51#[non_exhaustive]
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct SkewAdvice {
56 pub hot_partitions: Vec<usize>,
58 pub factor: u32,
60 pub join_keys: Vec<String>,
62}
63
64pub struct SkewJoinRule {
72 threshold: f64,
74 factor: u32,
76 max_factor: u32,
78}
79
80impl SkewJoinRule {
81 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 pub fn with_default_factor(threshold: f64) -> Self {
92 Self::new(threshold, DEFAULT_SALT_FACTOR)
93 }
94
95 #[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 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 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 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 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 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 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 pub fn is_empty(&self) -> bool {
241 self.hot_partitions.is_empty()
242 }
243
244 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 #[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 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 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 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 assert!(rule.detect_hot_partitions(&stats).is_empty());
323 }
324
325 #[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 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 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 #[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 #[test]
435 fn adaptive_salting_scales_factor_with_skew_severity() {
436 let rule = SkewJoinRule::new(2.0, 4).with_adaptive_salty(16);
437 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 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); 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}