use crate::{Partitioning, PhysicalPlan};
use super::{AqeRule, RuntimeStats, StreamingAqeGuard};
pub const DEFAULT_MAX_BROADCAST_BYTES: u64 = 64 * 1024 * 1024;
const DEMOTION_TARGET_PARTITION_BYTES: u64 = krishiv_common::partition::TARGET_BYTES_PER_PARTITION;
const DEMOTION_MIN_BUCKETS: u64 = 2;
const DEMOTION_MAX_BUCKETS: u64 = 64;
pub struct BroadcastRuntimeRule {
max_broadcast_bytes: u64,
}
impl BroadcastRuntimeRule {
pub fn new(max_broadcast_bytes: u64) -> Self {
Self {
max_broadcast_bytes,
}
}
fn demotion_buckets(observed_bytes: u64) -> u32 {
observed_bytes
.div_ceil(DEMOTION_TARGET_PARTITION_BYTES)
.clamp(DEMOTION_MIN_BUCKETS, DEMOTION_MAX_BUCKETS) as u32
}
}
impl AqeRule for BroadcastRuntimeRule {
fn name(&self) -> &str {
"broadcast-runtime"
}
fn apply(&self, plan: &PhysicalPlan, stats: &[RuntimeStats]) -> Option<PhysicalPlan> {
if stats.is_empty() || StreamingAqeGuard::plan_is_streaming(plan) {
return None;
}
let observed_bytes: u64 = stats
.iter()
.map(|s| {
if s.serialized_bytes > 0 {
s.serialized_bytes
} else {
s.memory_bytes
}
})
.sum();
if observed_bytes == 0 {
return None;
}
let fits_broadcast = observed_bytes <= self.max_broadcast_bytes;
let mut changed = false;
for node in plan.nodes() {
match node.partitioning() {
Partitioning::Hash { .. } | Partitioning::RoundRobin { .. }
if fits_broadcast && node.broadcast_eligible() =>
{
changed = true;
}
Partitioning::Broadcast if !fits_broadcast => {
changed = true;
}
_ => {}
}
}
if !changed {
return None;
}
let mut plan = plan.clone();
for node in plan.nodes_mut() {
let eligible = node.broadcast_eligible();
let old = node.partitioning().clone();
match old {
Partitioning::Hash { .. } | Partitioning::RoundRobin { .. }
if fits_broadcast && eligible =>
{
node.set_partitioning(Partitioning::Broadcast);
}
Partitioning::Broadcast if !fits_broadcast => {
node.set_partitioning(Partitioning::RoundRobin {
buckets: Self::demotion_buckets(observed_bytes),
});
}
_ => {}
}
}
tracing::debug!(
rule = "broadcast-runtime",
observed_bytes,
promoted = fits_broadcast,
"BroadcastRuntimeRule applied"
);
Some(plan)
}
}
#[cfg(test)]
mod tests {
use crate::optimizer::AqeOptimizer;
use crate::{ExecutionKind, Partitioning, PhysicalPlan, PlanNode};
use super::{AqeRule, BroadcastRuntimeRule, DEFAULT_MAX_BROADCAST_BYTES, RuntimeStats};
const ONE_MIB: u64 = 1024 * 1024;
fn hash_node(id: &str, eligible: bool) -> PlanNode {
PlanNode::new(id, "exchange", ExecutionKind::Batch)
.with_partitioning(Partitioning::Hash {
keys: vec!["k".into()],
buckets: 8,
})
.with_broadcast_eligible(eligible)
}
fn broadcast_node(id: &str) -> PlanNode {
PlanNode::new(id, "broadcast exchange", ExecutionKind::Batch)
.with_partitioning(Partitioning::Broadcast)
.with_broadcast_eligible(true)
}
fn plan_with(nodes: Vec<PlanNode>) -> PhysicalPlan {
let mut plan = PhysicalPlan::new("test", ExecutionKind::Batch);
for node in nodes {
plan = plan.with_node(node);
}
plan
}
fn stats_with_serialized(bytes: &[u64]) -> Vec<RuntimeStats> {
bytes
.iter()
.map(|&b| RuntimeStats {
serialized_bytes: b,
..Default::default()
})
.collect()
}
#[test]
fn promotion_fires_for_small_eligible_hash_node() {
let plan = plan_with(vec![hash_node("xchg", true)]);
let stats = stats_with_serialized(&[10 * ONE_MIB]);
let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
let result = rule.apply(&plan, &stats).expect("promotion must fire");
let node = result.nodes().iter().find(|n| n.id() == "xchg").unwrap();
assert_eq!(node.partitioning(), &Partitioning::Broadcast);
}
#[test]
fn promotion_fires_for_small_eligible_round_robin_node() {
let plan = plan_with(vec![
PlanNode::new("rr", "exchange", ExecutionKind::Batch)
.with_partitioning(Partitioning::RoundRobin { buckets: 4 })
.with_broadcast_eligible(true),
]);
let stats = stats_with_serialized(&[ONE_MIB]);
let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
let result = rule.apply(&plan, &stats).expect("promotion must fire");
assert_eq!(result.nodes()[0].partitioning(), &Partitioning::Broadcast);
}
#[test]
fn promotion_fires_at_exact_threshold() {
let plan = plan_with(vec![hash_node("xchg", true)]);
let stats = stats_with_serialized(&[DEFAULT_MAX_BROADCAST_BYTES]);
let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
let result = rule.apply(&plan, &stats).expect("boundary must promote");
assert_eq!(result.nodes()[0].partitioning(), &Partitioning::Broadcast);
}
#[test]
fn promotion_aggregates_stats_across_partitions() {
let plan = plan_with(vec![hash_node("xchg", true)]);
let stats = stats_with_serialized(&[40 * ONE_MIB, 40 * ONE_MIB]);
let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
assert!(
rule.apply(&plan, &stats).is_none(),
"summed size exceeds threshold → no promotion"
);
}
#[test]
fn promotion_prefers_serialized_bytes_over_memory_bytes() {
let plan = plan_with(vec![hash_node("xchg", true)]);
let stats = vec![RuntimeStats {
memory_bytes: 200 * ONE_MIB,
serialized_bytes: 10 * ONE_MIB,
..Default::default()
}];
let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
let result = rule.apply(&plan, &stats).expect("promotion must fire");
assert_eq!(result.nodes()[0].partitioning(), &Partitioning::Broadcast);
}
#[test]
fn promotion_falls_back_to_memory_bytes_when_serialized_is_zero() {
let plan = plan_with(vec![hash_node("xchg", true)]);
let stats = vec![RuntimeStats {
memory_bytes: ONE_MIB,
serialized_bytes: 0,
..Default::default()
}];
let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
let result = rule.apply(&plan, &stats).expect("promotion must fire");
assert_eq!(result.nodes()[0].partitioning(), &Partitioning::Broadcast);
}
#[test]
fn no_promotion_when_not_broadcast_eligible() {
let plan = plan_with(vec![hash_node("xchg", false)]);
let stats = stats_with_serialized(&[ONE_MIB]);
let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
assert!(
rule.apply(&plan, &stats).is_none(),
"ineligible node must not be promoted"
);
}
#[test]
fn no_promotion_above_threshold() {
let plan = plan_with(vec![hash_node("xchg", true)]);
let stats = stats_with_serialized(&[DEFAULT_MAX_BROADCAST_BYTES + 1]);
let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
assert!(
rule.apply(&plan, &stats).is_none(),
"observed size above threshold must not promote"
);
}
#[test]
fn no_promotion_for_unpartitioned_node() {
let plan = plan_with(vec![
PlanNode::new("scan", "scan", ExecutionKind::Batch).with_broadcast_eligible(true),
]);
let stats = stats_with_serialized(&[ONE_MIB]);
let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
assert!(
rule.apply(&plan, &stats).is_none(),
"only Hash/RoundRobin nodes are promotion candidates"
);
}
#[test]
fn demotion_fires_when_broadcast_node_observed_too_large() {
let plan = plan_with(vec![broadcast_node("bcast")]);
let stats = stats_with_serialized(&[300 * ONE_MIB]);
let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
let result = rule.apply(&plan, &stats).expect("demotion must fire");
assert_eq!(
result.nodes()[0].partitioning(),
&Partitioning::RoundRobin { buckets: 3 }
);
}
#[test]
fn demotion_bucket_count_clamped_to_minimum_two() {
let plan = plan_with(vec![broadcast_node("bcast")]);
let stats = stats_with_serialized(&[65 * ONE_MIB]);
let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
let result = rule.apply(&plan, &stats).expect("demotion must fire");
assert_eq!(
result.nodes()[0].partitioning(),
&Partitioning::RoundRobin { buckets: 2 }
);
}
#[test]
fn demotion_bucket_count_clamped_to_maximum_sixty_four() {
let plan = plan_with(vec![broadcast_node("bcast")]);
let stats = stats_with_serialized(&[64 * 1024 * ONE_MIB]);
let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
let result = rule.apply(&plan, &stats).expect("demotion must fire");
assert_eq!(
result.nodes()[0].partitioning(),
&Partitioning::RoundRobin { buckets: 64 }
);
}
#[test]
fn no_demotion_when_broadcast_node_within_threshold() {
let plan = plan_with(vec![broadcast_node("bcast")]);
let stats = stats_with_serialized(&[ONE_MIB]);
let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
assert!(
rule.apply(&plan, &stats).is_none(),
"small broadcast node stays broadcast → no change → None"
);
}
#[test]
fn promotion_and_demotion_apply_together() {
let plan = plan_with(vec![hash_node("xchg", true), broadcast_node("bcast")]);
let stats = stats_with_serialized(&[200 * ONE_MIB]);
let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
let result = rule.apply(&plan, &stats).expect("demotion must fire");
let xchg = result.nodes().iter().find(|n| n.id() == "xchg").unwrap();
let bcast = result.nodes().iter().find(|n| n.id() == "bcast").unwrap();
assert!(
matches!(xchg.partitioning(), Partitioning::Hash { .. }),
"hash node above threshold must not be promoted"
);
assert_eq!(
bcast.partitioning(),
&Partitioning::RoundRobin { buckets: 2 },
"broadcast node above threshold must be demoted"
);
}
#[test]
fn returns_none_when_no_change() {
let plan = plan_with(vec![PlanNode::new("scan", "scan", ExecutionKind::Batch)]);
let stats = stats_with_serialized(&[ONE_MIB]);
let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
assert!(rule.apply(&plan, &stats).is_none());
}
#[test]
fn empty_stats_returns_none() {
let plan = plan_with(vec![hash_node("xchg", true)]);
let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
assert!(rule.apply(&plan, &[]).is_none());
}
#[test]
fn zero_observed_bytes_returns_none() {
let plan = plan_with(vec![hash_node("xchg", true)]);
let stats = vec![RuntimeStats::default()];
let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
assert!(rule.apply(&plan, &stats).is_none());
}
#[test]
fn rule_is_intrinsically_disabled_for_streaming() {
let mut plan = PhysicalPlan::new("stream", ExecutionKind::Streaming);
plan = plan.with_node(
PlanNode::new("xchg", "exchange", ExecutionKind::Streaming)
.with_partitioning(Partitioning::Hash {
keys: vec!["k".into()],
buckets: 8,
})
.with_broadcast_eligible(true),
);
let stats = stats_with_serialized(&[ONE_MIB]);
let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
assert!(rule.apply(&plan, &stats).is_none());
}
#[test]
fn streaming_guard_respected_via_aqe_optimizer() {
let mut aqe = AqeOptimizer::new();
aqe.add_guarded_rule(Box::new(BroadcastRuntimeRule::new(
DEFAULT_MAX_BROADCAST_BYTES,
)));
let plan = PhysicalPlan::new("stream", ExecutionKind::Streaming).with_node(
PlanNode::new("xchg", "exchange", ExecutionKind::Streaming)
.with_partitioning(Partitioning::Hash {
keys: vec!["k".into()],
buckets: 8,
})
.with_broadcast_eligible(true),
);
let stats = stats_with_serialized(&[ONE_MIB]);
let (result, applied) = aqe.apply(plan.clone(), &stats).expect("aqe");
assert_eq!(result, plan, "streaming plan must be untouched");
assert!(applied.is_empty(), "guarded rule must not fire");
}
#[test]
fn batch_plan_promoted_via_aqe_optimizer() {
let mut aqe = AqeOptimizer::new();
aqe.add_guarded_rule(Box::new(BroadcastRuntimeRule::new(
DEFAULT_MAX_BROADCAST_BYTES,
)));
let plan = plan_with(vec![hash_node("xchg", true)]);
let stats = stats_with_serialized(&[ONE_MIB]);
let (result, applied) = aqe.apply(plan, &stats).expect("aqe");
assert_eq!(applied, vec!["broadcast-runtime"]);
assert_eq!(result.nodes()[0].partitioning(), &Partitioning::Broadcast);
}
#[test]
fn rule_name_is_broadcast_runtime() {
let rule = BroadcastRuntimeRule::new(DEFAULT_MAX_BROADCAST_BYTES);
assert_eq!(rule.name(), "broadcast-runtime");
}
}