laminar_sql/planner/streaming_optimizer/
mod.rs1use std::fmt::Debug;
8use std::sync::Arc;
9
10use datafusion::physical_optimizer::PhysicalOptimizerRule;
11use datafusion::physical_plan::aggregates::{AggregateExec, AggregateMode};
12use datafusion::physical_plan::execution_plan::Boundedness;
13use datafusion::physical_plan::sorts::sort::SortExec;
14use datafusion::physical_plan::{ExecutionPlan, ExecutionPlanProperties};
15use datafusion_common::config::ConfigOptions;
16use datafusion_common::DataFusionError;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum StreamingValidatorMode {
21 Reject,
23 Warn,
25 Off,
27}
28
29#[derive(Debug)]
31struct StreamingViolation {
32 operator: String,
33 reason: String,
34 plan_path: String,
35}
36
37#[derive(Debug)]
42pub struct StreamingPhysicalValidator {
43 mode: StreamingValidatorMode,
44}
45
46impl StreamingPhysicalValidator {
47 #[must_use]
49 pub fn new(mode: StreamingValidatorMode) -> Self {
50 Self { mode }
51 }
52}
53
54impl PhysicalOptimizerRule for StreamingPhysicalValidator {
55 fn optimize(
56 &self,
57 plan: Arc<dyn ExecutionPlan>,
58 _config: &ConfigOptions,
59 ) -> datafusion_common::Result<Arc<dyn ExecutionPlan>> {
60 if matches!(self.mode, StreamingValidatorMode::Off) {
61 return Ok(plan);
62 }
63
64 let violations = find_streaming_violations(&plan);
65 if violations.is_empty() {
66 return Ok(plan);
67 }
68
69 match self.mode {
70 StreamingValidatorMode::Reject => {
71 Err(DataFusionError::Plan(format_violations(&violations)))
72 }
73 StreamingValidatorMode::Warn => {
74 for v in &violations {
75 tracing::warn!(
76 operator = %v.operator,
77 path = %v.plan_path,
78 "Streaming plan violation: {}", v.reason
79 );
80 }
81 Ok(plan)
82 }
83 StreamingValidatorMode::Off => unreachable!(),
84 }
85 }
86
87 #[allow(clippy::unnecessary_literal_bound)]
88 fn name(&self) -> &str {
89 "streaming_physical_validator"
90 }
91
92 fn schema_check(&self) -> bool {
93 true
94 }
95}
96
97fn find_streaming_violations(plan: &Arc<dyn ExecutionPlan>) -> Vec<StreamingViolation> {
98 let mut violations = Vec::new();
99 walk_plan(plan, &mut violations, "");
100 violations
101}
102
103fn walk_plan(plan: &Arc<dyn ExecutionPlan>, violations: &mut Vec<StreamingViolation>, path: &str) {
104 let name = plan.name();
105 let current_path = if path.is_empty() {
106 name.to_string()
107 } else {
108 format!("{path} -> {name}")
109 };
110
111 if plan.as_any().downcast_ref::<SortExec>().is_some() && has_unbounded_child(plan) {
113 violations.push(StreamingViolation {
114 operator: name.to_string(),
115 reason: "Sort requires buffering all input; unbounded source will \
116 buffer forever. Remove ORDER BY or add a window."
117 .to_string(),
118 plan_path: current_path.clone(),
119 });
120 }
121
122 if let Some(agg) = plan.as_any().downcast_ref::<AggregateExec>() {
124 if matches!(
125 agg.mode(),
126 &AggregateMode::Final | &AggregateMode::FinalPartitioned
127 ) && has_unbounded_child(plan)
128 {
129 violations.push(StreamingViolation {
130 operator: name.to_string(),
131 reason: "Final aggregation on unbounded input will never emit \
132 results. Use a window function (TUMBLE/HOP/SESSION) or \
133 add an EMIT clause."
134 .to_string(),
135 plan_path: current_path.clone(),
136 });
137 }
138 }
139
140 for child in plan.children() {
141 walk_plan(child, violations, ¤t_path);
142 }
143}
144
145fn has_unbounded_child(plan: &Arc<dyn ExecutionPlan>) -> bool {
146 plan.children()
147 .iter()
148 .any(|c| matches!(c.boundedness(), Boundedness::Unbounded { .. }))
149}
150
151fn format_violations(violations: &[StreamingViolation]) -> String {
152 use std::fmt::Write;
153
154 let mut msg = String::from("Streaming plan validation failed:\n");
155 for (i, v) in violations.iter().enumerate() {
156 let _ = writeln!(
157 msg,
158 " {}. [{}] {} (at: {})",
159 i + 1,
160 v.operator,
161 v.reason,
162 v.plan_path
163 );
164 }
165 msg
166}
167
168#[cfg(test)]
169mod tests;