1use crate::{Partitioning, PhysicalPlan};
4
5use super::{AqeRule, RuntimeStats, StreamingAqeGuard};
6
7pub const DEFAULT_MAX_BROADCAST_BYTES: u64 = 64 * 1024 * 1024;
16
17const DEMOTION_TARGET_PARTITION_BYTES: u64 = krishiv_common::partition::TARGET_BYTES_PER_PARTITION;
20
21const DEMOTION_MIN_BUCKETS: u64 = 2;
25const DEMOTION_MAX_BUCKETS: u64 = 64;
26
27pub struct BroadcastRuntimeRule {
55 max_broadcast_bytes: u64,
57}
58
59impl BroadcastRuntimeRule {
60 pub fn new(max_broadcast_bytes: u64) -> Self {
64 Self {
65 max_broadcast_bytes,
66 }
67 }
68
69 fn demotion_buckets(observed_bytes: u64) -> u32 {
72 observed_bytes
73 .div_ceil(DEMOTION_TARGET_PARTITION_BYTES)
74 .clamp(DEMOTION_MIN_BUCKETS, DEMOTION_MAX_BUCKETS) as u32
75 }
76}
77
78impl AqeRule for BroadcastRuntimeRule {
79 fn name(&self) -> &str {
80 "broadcast-runtime"
81 }
82
83 fn apply(&self, plan: &PhysicalPlan, stats: &[RuntimeStats]) -> Option<PhysicalPlan> {
84 if stats.is_empty() || StreamingAqeGuard::plan_is_streaming(plan) {
85 return None;
86 }
87
88 let observed_bytes: u64 = stats
92 .iter()
93 .map(|s| {
94 if s.serialized_bytes > 0 {
95 s.serialized_bytes
96 } else {
97 s.memory_bytes
98 }
99 })
100 .sum();
101 if observed_bytes == 0 {
102 return None;
103 }
104
105 let fits_broadcast = observed_bytes <= self.max_broadcast_bytes;
106
107 let mut changed = false;
110 for node in plan.nodes() {
111 match node.partitioning() {
112 Partitioning::Hash { .. } | Partitioning::RoundRobin { .. }
113 if fits_broadcast && node.broadcast_eligible() =>
114 {
115 changed = true;
116 }
117 Partitioning::Broadcast if !fits_broadcast => {
118 changed = true;
119 }
120 _ => {}
121 }
122 }
123
124 if !changed {
125 return None;
126 }
127
128 let mut plan = plan.clone();
130 for node in plan.nodes_mut() {
131 let eligible = node.broadcast_eligible();
132 let old = node.partitioning().clone();
133 match old {
134 Partitioning::Hash { .. } | Partitioning::RoundRobin { .. }
135 if fits_broadcast && eligible =>
136 {
137 node.set_partitioning(Partitioning::Broadcast);
138 }
139 Partitioning::Broadcast if !fits_broadcast => {
140 node.set_partitioning(Partitioning::RoundRobin {
141 buckets: Self::demotion_buckets(observed_bytes),
142 });
143 }
144 _ => {}
145 }
146 }
147
148 tracing::debug!(
149 rule = "broadcast-runtime",
150 observed_bytes,
151 promoted = fits_broadcast,
152 "BroadcastRuntimeRule applied"
153 );
154
155 Some(plan)
156 }
157}
158
159#[cfg(test)]
162mod tests {
163 use crate::optimizer::AqeOptimizer;
164 use crate::{ExecutionKind, Partitioning, PhysicalPlan, PlanNode};
165
166 use super::{AqeRule, BroadcastRuntimeRule, DEFAULT_MAX_BROADCAST_BYTES, RuntimeStats};
167
168 const ONE_MIB: u64 = 1024 * 1024;
169
170 fn hash_node(id: &str, eligible: bool) -> PlanNode {
171 PlanNode::new(id, "exchange", ExecutionKind::Batch)
172 .with_partitioning(Partitioning::Hash {
173 keys: vec!["k".into()],
174 buckets: 8,
175 })
176 .with_broadcast_eligible(eligible)
177 }
178
179 fn broadcast_node(id: &str) -> PlanNode {
180 PlanNode::new(id, "broadcast exchange", ExecutionKind::Batch)
181 .with_partitioning(Partitioning::Broadcast)
182 .with_broadcast_eligible(true)
183 }
184
185 fn plan_with(nodes: Vec<PlanNode>) -> PhysicalPlan {
186 let mut plan = PhysicalPlan::new("test", ExecutionKind::Batch);
187 for node in nodes {
188 plan = plan.with_node(node);
189 }
190 plan
191 }
192
193 fn stats_with_serialized(bytes: &[u64]) -> Vec<RuntimeStats> {
194 bytes
195 .iter()
196 .map(|&b| RuntimeStats {
197 serialized_bytes: b,
198 ..Default::default()
199 })
200 .collect()
201 }
202
203 #[test]
206 fn promotion_fires_for_small_eligible_hash_node() {
207 let plan = plan_with(vec![hash_node("xchg", true)]);
208 let stats = stats_with_serialized(&[10 * ONE_MIB]);
209 let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
210
211 let result = rule.apply(&plan, &stats).expect("promotion must fire");
212 let node = result.nodes().iter().find(|n| n.id() == "xchg").unwrap();
213 assert_eq!(node.partitioning(), &Partitioning::Broadcast);
214 }
215
216 #[test]
217 fn promotion_fires_for_small_eligible_round_robin_node() {
218 let plan = plan_with(vec![
219 PlanNode::new("rr", "exchange", ExecutionKind::Batch)
220 .with_partitioning(Partitioning::RoundRobin { buckets: 4 })
221 .with_broadcast_eligible(true),
222 ]);
223 let stats = stats_with_serialized(&[ONE_MIB]);
224 let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
225
226 let result = rule.apply(&plan, &stats).expect("promotion must fire");
227 assert_eq!(result.nodes()[0].partitioning(), &Partitioning::Broadcast);
228 }
229
230 #[test]
231 fn promotion_fires_at_exact_threshold() {
232 let plan = plan_with(vec![hash_node("xchg", true)]);
234 let stats = stats_with_serialized(&[DEFAULT_MAX_BROADCAST_BYTES]);
235 let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
236
237 let result = rule.apply(&plan, &stats).expect("boundary must promote");
238 assert_eq!(result.nodes()[0].partitioning(), &Partitioning::Broadcast);
239 }
240
241 #[test]
242 fn promotion_aggregates_stats_across_partitions() {
243 let plan = plan_with(vec![hash_node("xchg", true)]);
246 let stats = stats_with_serialized(&[40 * ONE_MIB, 40 * ONE_MIB]);
247 let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
248
249 assert!(
250 rule.apply(&plan, &stats).is_none(),
251 "summed size exceeds threshold → no promotion"
252 );
253 }
254
255 #[test]
256 fn promotion_prefers_serialized_bytes_over_memory_bytes() {
257 let plan = plan_with(vec![hash_node("xchg", true)]);
260 let stats = vec![RuntimeStats {
261 memory_bytes: 200 * ONE_MIB,
262 serialized_bytes: 10 * ONE_MIB,
263 ..Default::default()
264 }];
265 let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
266
267 let result = rule.apply(&plan, &stats).expect("promotion must fire");
268 assert_eq!(result.nodes()[0].partitioning(), &Partitioning::Broadcast);
269 }
270
271 #[test]
272 fn promotion_falls_back_to_memory_bytes_when_serialized_is_zero() {
273 let plan = plan_with(vec![hash_node("xchg", true)]);
274 let stats = vec![RuntimeStats {
275 memory_bytes: ONE_MIB,
276 serialized_bytes: 0,
277 ..Default::default()
278 }];
279 let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
280
281 let result = rule.apply(&plan, &stats).expect("promotion must fire");
282 assert_eq!(result.nodes()[0].partitioning(), &Partitioning::Broadcast);
283 }
284
285 #[test]
286 fn no_promotion_when_not_broadcast_eligible() {
287 let plan = plan_with(vec![hash_node("xchg", false)]);
288 let stats = stats_with_serialized(&[ONE_MIB]);
289 let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
290
291 assert!(
292 rule.apply(&plan, &stats).is_none(),
293 "ineligible node must not be promoted"
294 );
295 }
296
297 #[test]
298 fn no_promotion_above_threshold() {
299 let plan = plan_with(vec![hash_node("xchg", true)]);
300 let stats = stats_with_serialized(&[DEFAULT_MAX_BROADCAST_BYTES + 1]);
301 let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
302
303 assert!(
304 rule.apply(&plan, &stats).is_none(),
305 "observed size above threshold must not promote"
306 );
307 }
308
309 #[test]
310 fn no_promotion_for_unpartitioned_node() {
311 let plan = plan_with(vec![
312 PlanNode::new("scan", "scan", ExecutionKind::Batch).with_broadcast_eligible(true),
313 ]);
314 let stats = stats_with_serialized(&[ONE_MIB]);
315 let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
316
317 assert!(
318 rule.apply(&plan, &stats).is_none(),
319 "only Hash/RoundRobin nodes are promotion candidates"
320 );
321 }
322
323 #[test]
326 fn demotion_fires_when_broadcast_node_observed_too_large() {
327 let plan = plan_with(vec![broadcast_node("bcast")]);
328 let stats = stats_with_serialized(&[300 * ONE_MIB]);
330 let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
331
332 let result = rule.apply(&plan, &stats).expect("demotion must fire");
333 assert_eq!(
334 result.nodes()[0].partitioning(),
335 &Partitioning::RoundRobin { buckets: 3 }
336 );
337 }
338
339 #[test]
340 fn demotion_bucket_count_clamped_to_minimum_two() {
341 let plan = plan_with(vec![broadcast_node("bcast")]);
344 let stats = stats_with_serialized(&[65 * ONE_MIB]);
345 let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
346
347 let result = rule.apply(&plan, &stats).expect("demotion must fire");
348 assert_eq!(
349 result.nodes()[0].partitioning(),
350 &Partitioning::RoundRobin { buckets: 2 }
351 );
352 }
353
354 #[test]
355 fn demotion_bucket_count_clamped_to_maximum_sixty_four() {
356 let plan = plan_with(vec![broadcast_node("bcast")]);
358 let stats = stats_with_serialized(&[64 * 1024 * ONE_MIB]);
359 let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
360
361 let result = rule.apply(&plan, &stats).expect("demotion must fire");
362 assert_eq!(
363 result.nodes()[0].partitioning(),
364 &Partitioning::RoundRobin { buckets: 64 }
365 );
366 }
367
368 #[test]
369 fn no_demotion_when_broadcast_node_within_threshold() {
370 let plan = plan_with(vec![broadcast_node("bcast")]);
371 let stats = stats_with_serialized(&[ONE_MIB]);
372 let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
373
374 assert!(
375 rule.apply(&plan, &stats).is_none(),
376 "small broadcast node stays broadcast → no change → None"
377 );
378 }
379
380 #[test]
381 fn promotion_and_demotion_apply_together() {
382 let plan = plan_with(vec![hash_node("xchg", true), broadcast_node("bcast")]);
386 let stats = stats_with_serialized(&[200 * ONE_MIB]);
387 let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
388
389 let result = rule.apply(&plan, &stats).expect("demotion must fire");
390 let xchg = result.nodes().iter().find(|n| n.id() == "xchg").unwrap();
391 let bcast = result.nodes().iter().find(|n| n.id() == "bcast").unwrap();
392 assert!(
393 matches!(xchg.partitioning(), Partitioning::Hash { .. }),
394 "hash node above threshold must not be promoted"
395 );
396 assert_eq!(
397 bcast.partitioning(),
398 &Partitioning::RoundRobin { buckets: 2 },
399 "broadcast node above threshold must be demoted"
400 );
401 }
402
403 #[test]
406 fn returns_none_when_no_change() {
407 let plan = plan_with(vec![PlanNode::new("scan", "scan", ExecutionKind::Batch)]);
409 let stats = stats_with_serialized(&[ONE_MIB]);
410 let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
411
412 assert!(rule.apply(&plan, &stats).is_none());
413 }
414
415 #[test]
416 fn empty_stats_returns_none() {
417 let plan = plan_with(vec![hash_node("xchg", true)]);
418 let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
419
420 assert!(rule.apply(&plan, &[]).is_none());
421 }
422
423 #[test]
424 fn zero_observed_bytes_returns_none() {
425 let plan = plan_with(vec![hash_node("xchg", true)]);
426 let stats = vec![RuntimeStats::default()];
427 let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
428
429 assert!(rule.apply(&plan, &stats).is_none());
430 }
431
432 #[test]
433 fn rule_is_intrinsically_disabled_for_streaming() {
434 let mut plan = PhysicalPlan::new("stream", ExecutionKind::Streaming);
435 plan = plan.with_node(
436 PlanNode::new("xchg", "exchange", ExecutionKind::Streaming)
437 .with_partitioning(Partitioning::Hash {
438 keys: vec!["k".into()],
439 buckets: 8,
440 })
441 .with_broadcast_eligible(true),
442 );
443 let stats = stats_with_serialized(&[ONE_MIB]);
444 let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
445
446 assert!(rule.apply(&plan, &stats).is_none());
447 }
448
449 #[test]
450 fn streaming_guard_respected_via_aqe_optimizer() {
451 let mut aqe = AqeOptimizer::new();
452 aqe.add_guarded_rule(Box::new(BroadcastRuntimeRule::new(
453 DEFAULT_MAX_BROADCAST_BYTES,
454 )));
455
456 let plan = PhysicalPlan::new("stream", ExecutionKind::Streaming).with_node(
457 PlanNode::new("xchg", "exchange", ExecutionKind::Streaming)
458 .with_partitioning(Partitioning::Hash {
459 keys: vec!["k".into()],
460 buckets: 8,
461 })
462 .with_broadcast_eligible(true),
463 );
464 let stats = stats_with_serialized(&[ONE_MIB]);
465
466 let (result, applied) = aqe.apply(plan.clone(), &stats).expect("aqe");
467 assert_eq!(result, plan, "streaming plan must be untouched");
468 assert!(applied.is_empty(), "guarded rule must not fire");
469 }
470
471 #[test]
472 fn batch_plan_promoted_via_aqe_optimizer() {
473 let mut aqe = AqeOptimizer::new();
474 aqe.add_guarded_rule(Box::new(BroadcastRuntimeRule::new(
475 DEFAULT_MAX_BROADCAST_BYTES,
476 )));
477
478 let plan = plan_with(vec![hash_node("xchg", true)]);
479 let stats = stats_with_serialized(&[ONE_MIB]);
480
481 let (result, applied) = aqe.apply(plan, &stats).expect("aqe");
482 assert_eq!(applied, vec!["broadcast-runtime"]);
483 assert_eq!(result.nodes()[0].partitioning(), &Partitioning::Broadcast);
484 }
485
486 #[test]
487 fn rule_name_is_broadcast_runtime() {
488 let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
489 assert_eq!(rule.name(), "broadcast-runtime");
490 }
491}