1use std::collections::HashSet;
13
14use super::properties::{MemoryPolicy, Ordering, PipelineKind};
15use super::types::{
16 FragmentId, FragmentKind, InputContract, OperatorKindSpec, PhysicalOperatorId, PhysicalPlan,
17 StateOwnership,
18};
19use linkrs_core::error::QueryError;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ValidationTier {
24 Structural,
26 Full,
28}
29
30#[derive(Debug, Default)]
32pub struct ValidationResult {
33 pub errors: Vec<String>,
34 pub warnings: Vec<String>,
35}
36
37impl ValidationResult {
38 pub fn is_ok(&self) -> bool {
39 self.errors.is_empty()
40 }
41
42 pub fn into_result(self) -> Result<(), QueryError> {
43 if self.errors.is_empty() {
44 Ok(())
45 } else {
46 Err(QueryError::execution(format!(
47 "PhysicalPlan validation failed ({} errors, {} warnings): [{}]",
48 self.errors.len(),
49 self.warnings.len(),
50 self.errors.join("; ")
51 )))
52 }
53 }
54
55 pub fn merge(&mut self, other: ValidationResult) {
56 self.errors.extend(other.errors);
57 self.warnings.extend(other.warnings);
58 }
59}
60
61pub struct PhysicalPlanValidator;
63
64impl PhysicalPlanValidator {
65 pub fn validate(plan: &PhysicalPlan) -> Result<(), QueryError> {
67 Self::validate_tier(plan, ValidationTier::Structural)?.into_result()
68 }
69
70 pub fn validate_tier(
72 plan: &PhysicalPlan,
73 tier: ValidationTier,
74 ) -> Result<ValidationResult, QueryError> {
75 let mut result = ValidationResult::default();
76
77 Self::check_operator_id_uniqueness(plan, &mut result);
79 Self::check_operator_references(plan, &mut result);
80 Self::check_fragment_connectivity(plan, &mut result);
81 Self::check_fragment_cycles(plan, &mut result);
82 Self::check_fragment_operator_belongs(plan, &mut result);
83 Self::check_operator_input_counts(plan, &mut result);
84 Self::check_output_layouts(plan, &mut result);
85 Self::check_property_consistency(plan, &mut result);
86 Self::check_memory_policy(plan, &mut result);
87 Self::check_root_output_contract(plan, &mut result);
88 Self::check_capability_set(plan, &mut result);
89 Self::check_output_layout_width(plan, &mut result);
90 Self::check_fragment_exchange_layout(plan, &mut result);
91 Self::check_input_contract_consistency(plan, &mut result);
92 Self::check_linear_fragment_contracts(plan, &mut result);
93 Self::check_state_ownership(plan, &mut result);
94 Self::check_fragment_parallelism(plan, &mut result);
95 Self::check_partition_parallelism(plan, &mut result);
96
97 Self::check_fragment_kind_matches(plan, &mut result);
99 Self::check_root_fragment_kind(plan, &mut result);
100 Self::check_unreferenced_operators(plan, &mut result);
101 Self::check_operator_spec_consistency(plan, &mut result);
102
103 if tier == ValidationTier::Full {
104 Self::check_parameter_schema(plan, &mut result);
106 }
107
108 Ok(result)
109 }
110
111 pub fn check_compatibility(
117 plan: &PhysicalPlan,
118 current_layout_version: Option<u64>,
119 ) -> Result<(), QueryError> {
120 if let Some(cached_version) = plan.compatibility.layout_version {
121 if let Some(current_version) = current_layout_version {
122 if cached_version != current_version {
123 return Err(QueryError::execution(format!(
124 "Plan layout version mismatch: cached={}, current={}",
125 cached_version, current_version
126 )));
127 }
128 }
129 }
130 Ok(())
131 }
132
133 fn check_operator_id_uniqueness(plan: &PhysicalPlan, result: &mut ValidationResult) {
137 let mut seen = HashSet::new();
138 for op in &plan.operators {
139 if !seen.insert(op.operator_id) {
140 result
141 .errors
142 .push(format!("Duplicate PhysicalOperatorId: {}", op.operator_id));
143 }
144 }
145 }
146
147 fn check_operator_references(plan: &PhysicalPlan, result: &mut ValidationResult) {
149 let arena_ids: HashSet<PhysicalOperatorId> =
150 plan.operators.iter().map(|op| op.operator_id).collect();
151
152 for fragment in plan.fragments.fragments() {
153 for &op_id in &fragment.operators {
154 if !arena_ids.contains(&op_id) {
155 result.errors.push(format!(
156 "Fragment {:?} references operator {:?} which is not in the arena",
157 fragment.id, op_id
158 ));
159 }
160 }
161 if !arena_ids.contains(&fragment.root_operator) {
162 result.errors.push(format!(
163 "Fragment {:?} root operator {:?} not in arena",
164 fragment.id, fragment.root_operator
165 ));
166 }
167 }
168 }
169
170 fn check_fragment_connectivity(plan: &PhysicalPlan, result: &mut ValidationResult) {
172 let fragment_ids: HashSet<FragmentId> =
173 plan.fragments.fragments().iter().map(|f| f.id).collect();
174
175 if !fragment_ids.contains(&plan.root_fragment) {
176 result.errors.push(format!(
177 "Root fragment {:?} not found in fragment graph",
178 plan.root_fragment
179 ));
180 }
181
182 for fragment in plan.fragments.fragments() {
183 if fragment.operators.is_empty() {
184 result
185 .warnings
186 .push(format!("Fragment {:?} has no operators", fragment.id));
187 }
188 for input_id in &fragment.inputs {
189 if !fragment_ids.contains(input_id) {
190 result.errors.push(format!(
191 "Fragment {:?} references missing input {:?}",
192 fragment.id, input_id
193 ));
194 }
195 }
196 }
197 }
198
199 fn check_fragment_cycles(plan: &PhysicalPlan, result: &mut ValidationResult) {
202 let fragment_ids: Vec<FragmentId> =
203 plan.fragments.fragments().iter().map(|f| f.id).collect();
204
205 let mut in_degree: std::collections::HashMap<FragmentId, usize> =
207 fragment_ids.iter().map(|&fid| (fid, 0)).collect();
208
209 for fragment in plan.fragments.fragments() {
210 for &input in &fragment.inputs {
211 *in_degree.get_mut(&input).unwrap_or(&mut 0) += 1;
212 }
213 }
214
215 let mut reachable = HashSet::new();
219 let mut stack = vec![plan.root_fragment];
220 while let Some(fid) = stack.pop() {
221 if !reachable.insert(fid) {
222 continue;
223 }
224 if let Some(frag) = plan.fragments.get(fid) {
225 for &input in &frag.inputs {
226 stack.push(input);
227 }
228 }
229 }
230
231 for &fid in &fragment_ids {
232 if !reachable.contains(&fid) {
233 result
234 .warnings
235 .push(format!("Fragment {:?} is not reachable from root", fid));
236 }
237 }
238 }
239
240 fn check_fragment_operator_belongs(plan: &PhysicalPlan, result: &mut ValidationResult) {
243 for fragment in plan.fragments.fragments() {
244 let op_set: HashSet<PhysicalOperatorId> = fragment.operators.iter().copied().collect();
245 if !op_set.contains(&fragment.root_operator) {
246 result.errors.push(format!(
247 "Fragment {:?} root operator {:?} not in its operator list",
248 fragment.id, fragment.root_operator
249 ));
250 }
251 }
252 }
253
254 fn check_operator_input_counts(plan: &PhysicalPlan, result: &mut ValidationResult) {
260 for fragment in plan.fragments.fragments() {
261 if fragment.operators.is_empty() {
262 continue;
263 }
264 let leaf_op_id = fragment.operators[0];
268 let leaf_op = &plan.operators[leaf_op_id.0];
269
270 let expected_children = match &leaf_op.spec {
271 OperatorKindSpec::Source(_) | OperatorKindSpec::Txn(_) => 0,
272 OperatorKindSpec::Unary(_)
273 | OperatorKindSpec::Blocking(_)
274 | OperatorKindSpec::Graph(_)
275 | OperatorKindSpec::RecursiveFragment(_)
276 | OperatorKindSpec::Sink(_)
277 | OperatorKindSpec::Ddl(_)
278 | OperatorKindSpec::Fulltext(_)
279 | OperatorKindSpec::Vector(_)
280 | OperatorKindSpec::Apply(
281 crate::executor::streaming::operators::spec::ApplySpec::CorrelatedApply {
282 ..
283 },
284 ) => 1,
285 OperatorKindSpec::Join(_)
286 | OperatorKindSpec::Set(_)
287 | OperatorKindSpec::Apply(
288 crate::executor::streaming::operators::spec::ApplySpec::Apply { .. },
289 )
290 | OperatorKindSpec::Apply(
291 crate::executor::streaming::operators::spec::ApplySpec::PatternApply { .. },
292 )
293 | OperatorKindSpec::Apply(
294 crate::executor::streaming::operators::spec::ApplySpec::RollUpApply { .. },
295 ) => 2,
296 OperatorKindSpec::Wco(wco) => 1 + wco.bound_names.len(),
297 OperatorKindSpec::Exchange(_) => {
298 continue;
299 }
300 };
301
302 if fragment.inputs.len() != expected_children && expected_children > 0 {
303 result.errors.push(format!(
304 "Fragment {:?} has {} input(s) but its leaf operator {:?} ({}) expects {} child(ren)",
305 fragment.id,
306 fragment.inputs.len(),
307 leaf_op.operator_id,
308 leaf_op.explain_name,
309 expected_children
310 ));
311 }
312 }
313 }
314
315 fn check_output_layouts(plan: &PhysicalPlan, result: &mut ValidationResult) {
317 for op in &plan.operators {
318 if op.output_layout.is_empty() {
319 let is_command = matches!(
320 &op.spec,
321 OperatorKindSpec::Ddl(_) | OperatorKindSpec::Txn(_)
322 );
323 let is_source = matches!(&op.spec, OperatorKindSpec::Source(s)
324 if matches!(s, super::super::operators::spec::SourceSpec::Start)
325 );
326 if !is_command && !is_source {
327 result.warnings.push(format!(
328 "Operator {:?} ({}) has empty output layout",
329 op.operator_id, op.explain_name
330 ));
331 }
332 }
333 }
334 }
335
336 fn check_property_consistency(plan: &PhysicalPlan, result: &mut ValidationResult) {
338 for op in &plan.operators {
339 match &op.spec {
340 OperatorKindSpec::Blocking(spec) => {
341 if matches!(
342 spec,
343 super::super::operators::spec::BlockingSpec::Sort { .. }
344 ) && matches!(op.properties.ordering, Ordering::None)
345 {
346 result.warnings.push(format!(
347 "Sort operator {:?} declares no output ordering",
348 op.operator_id
349 ));
350 }
351 if matches!(op.properties.pipeline_kind, PipelineKind::Streaming) {
353 result.warnings.push(format!(
354 "Blocking spec operator {:?} is marked as streaming",
355 op.operator_id
356 ));
357 }
358 }
359 OperatorKindSpec::Exchange(_) => {
360 if op.properties.pipeline_kind == PipelineKind::Streaming {
362 result.warnings.push(format!(
363 "Exchange operator {:?} is marked as streaming",
364 op.operator_id
365 ));
366 }
367 }
368 OperatorKindSpec::Source(super::super::operators::spec::SourceSpec::Start)
369 if op.properties.pipeline_kind == PipelineKind::Blocking =>
370 {
371 result.warnings.push(format!(
372 "Start source {:?} should be streaming",
373 op.operator_id
374 ));
375 }
376 _ => {}
377 }
378 }
379 }
380
381 fn check_memory_policy(plan: &PhysicalPlan, result: &mut ValidationResult) {
383 for op in &plan.operators {
384 if op.properties.pipeline_kind == PipelineKind::Blocking {
385 match &op.properties.memory_policy {
386 MemoryPolicy::None => {
387 result.warnings.push(format!(
388 "Blocking operator {:?} ({}) has no memory policy set \
389 (should be RequiresBudget or Spillable)",
390 op.operator_id, op.explain_name
391 ));
392 }
393 MemoryPolicy::RequiresBudget | MemoryPolicy::Spillable { .. } => {}
394 }
395 }
396 }
397 }
398
399 fn check_root_output_contract(plan: &PhysicalPlan, result: &mut ValidationResult) {
401 if plan.output.output_layout.is_empty() {
402 result
403 .warnings
404 .push("Plan root output contract has empty layout".to_string());
405 }
406
407 let root_frag = plan.fragments.get(plan.root_fragment);
409 if let Some(frag) = root_frag {
410 if frag.operators.is_empty() {
411 result.errors.push(format!(
412 "Root fragment {:?} has no operators",
413 plan.root_fragment
414 ));
415 }
416 }
417 }
418
419 fn check_capability_set(plan: &PhysicalPlan, result: &mut ValidationResult) {
421 if plan.required_capabilities.is_empty() {
422 result
423 .warnings
424 .push("Plan declares no required capabilities".to_string());
425 }
426 }
427
428 fn check_output_layout_width(plan: &PhysicalPlan, result: &mut ValidationResult) {
431 for op in &plan.operators {
432 if op.output_layout.is_empty() {
433 continue;
434 }
435 for slot in &op.output_layout.slots {
436 if slot.data_type.is_none() {
437 let is_command = matches!(
438 &op.spec,
439 OperatorKindSpec::Ddl(_) | OperatorKindSpec::Txn(_)
440 );
441 if !is_command {
442 result.warnings.push(format!(
443 "Operator {:?} ({}) output slot '{}' has no data type",
444 op.operator_id, op.explain_name, slot.name
445 ));
446 }
447 }
448 }
449 }
450 }
451
452 fn check_fragment_exchange_layout(plan: &PhysicalPlan, result: &mut ValidationResult) {
454 for fragment in plan.fragments.fragments() {
455 if fragment.kind == FragmentKind::Exchange && fragment.exchange_layout.is_none() {
456 result.warnings.push(format!(
457 "Exchange fragment {:?} has no exchange_layout",
458 fragment.id
459 ));
460 }
461 }
462 }
463
464 fn check_fragment_kind_matches(plan: &PhysicalPlan, result: &mut ValidationResult) {
471 for fragment in plan.fragments.fragments() {
472 let root_op = match plan.operator(fragment.root_operator) {
473 Some(op) => op,
474 None => continue,
475 };
476 let is_terminal = matches!(
477 &root_op.spec,
478 OperatorKindSpec::Sink(_) | OperatorKindSpec::Ddl(_) | OperatorKindSpec::Txn(_)
479 );
480 match fragment.kind {
481 super::types::FragmentKind::Source => {
482 if !matches!(&root_op.spec, OperatorKindSpec::Source(_)) {
483 result.warnings.push(format!(
484 "Fragment {:?} has kind Source but root operator is {:?}",
485 fragment.id, root_op.explain_name
486 ));
487 }
488 }
489 super::types::FragmentKind::Terminal => {
490 if !is_terminal {
491 result.warnings.push(format!(
492 "Fragment {:?} has kind Terminal but root operator is {:?}",
493 fragment.id, root_op.explain_name
494 ));
495 }
496 }
497 super::types::FragmentKind::Blocking => {
498 if !matches!(&root_op.spec, OperatorKindSpec::Blocking(_)) {
499 result.warnings.push(format!(
500 "Fragment {:?} has kind Blocking but root operator is {:?}",
501 fragment.id, root_op.explain_name
502 ));
503 }
504 }
505 super::types::FragmentKind::Exchange
506 if !matches!(&root_op.spec, OperatorKindSpec::Exchange(_)) =>
507 {
508 result.warnings.push(format!(
509 "Fragment {:?} has kind Exchange but root operator is {:?}",
510 fragment.id, root_op.explain_name
511 ));
512 }
513 _ => {}
514 }
515 }
516 }
517
518 fn check_root_fragment_kind(plan: &PhysicalPlan, result: &mut ValidationResult) {
520 if let Some(root_frag) = plan.fragments.get(plan.root_fragment) {
521 match root_frag.kind {
522 super::types::FragmentKind::Source | super::types::FragmentKind::Streaming => {
523 result.warnings.push(format!(
524 "Root fragment {:?} has kind {:?}, expected Terminal or Result",
525 root_frag.id, root_frag.kind
526 ));
527 }
528 _ => {}
529 }
530 }
531 }
532
533 fn check_unreferenced_operators(plan: &PhysicalPlan, result: &mut ValidationResult) {
535 let mut referenced: std::collections::HashSet<PhysicalOperatorId> =
536 std::collections::HashSet::new();
537 for fragment in plan.fragments.fragments() {
538 for &op_id in &fragment.operators {
539 referenced.insert(op_id);
540 }
541 }
542 for op in &plan.operators {
543 if !referenced.contains(&op.operator_id) {
544 result.warnings.push(format!(
545 "Operator {:?} ({}) exists in the arena but is not referenced by any fragment",
546 op.operator_id, op.explain_name
547 ));
548 }
549 }
550 }
551
552 fn check_input_contract_consistency(plan: &PhysicalPlan, result: &mut ValidationResult) {
554 for op in &plan.operators {
555 if let InputContract::NoInput = &op.input_contract {
557 for fragment in plan.fragments.fragments() {
558 if fragment.root_operator == op.operator_id && !fragment.inputs.is_empty() {
559 result.warnings.push(format!(
560 "Operator {:?} ({}) has NoInput contract but its fragment {:?} has {} inputs",
561 op.operator_id, op.explain_name, fragment.id, fragment.inputs.len()
562 ));
563 }
564 }
565 }
566 }
567 }
568
569 fn check_linear_fragment_contracts(plan: &PhysicalPlan, result: &mut ValidationResult) {
575 for fragment in plan.fragments.fragments() {
576 if fragment.operators.is_empty() {
577 result.errors.push(format!(
578 "Fragment {:?} has no operator and therefore no root",
579 fragment.id
580 ));
581 continue;
582 }
583 if fragment.root_operator
584 != *fragment.operators.last().unwrap_or(&fragment.root_operator)
585 {
586 result.errors.push(format!(
587 "Fragment {:?} root {:?} is not the final pipeline operator",
588 fragment.id, fragment.root_operator
589 ));
590 }
591
592 let external = fragment.inputs.clone();
593 for (index, operator_id) in fragment.operators.iter().enumerate() {
594 let Some(operator) = plan.operator(*operator_id) else {
595 continue;
596 };
597 let references: Vec<FragmentId> = match &operator.input_contract {
598 InputContract::NoInput => Vec::new(),
599 InputContract::UnaryInput(input) => vec![input.fragment],
600 InputContract::BinaryInputs { left, right } => {
601 vec![left.fragment, right.fragment]
602 }
603 InputContract::PartitionedInputs { members, .. } => {
604 members.iter().map(|member| member.fragment).collect()
605 }
606 InputContract::WcoInputs { probe, builds } => {
607 let mut references = Vec::with_capacity(builds.len() + 1);
608 references.push(probe.fragment);
609 references.extend(builds.iter().map(|build| build.fragment));
610 references
611 }
612 };
613
614 let expected: Vec<FragmentId> = if index == 0 {
615 external.clone()
616 } else {
617 vec![fragment.id]
618 };
619 if references != expected {
620 result.errors.push(format!(
621 "Operator {:?} in fragment {:?} has input ports {:?}, expected {:?}",
622 operator_id, fragment.id, references, expected
623 ));
624 }
625
626 let arity = match &operator.input_contract {
627 InputContract::NoInput => 0,
628 InputContract::UnaryInput(_) => 1,
629 InputContract::BinaryInputs { .. } => 2,
630 InputContract::PartitionedInputs { members, .. } => members.len(),
631 InputContract::WcoInputs { builds, .. } => 1 + builds.len(),
632 };
633 let expected_arity = match &operator.spec {
634 OperatorKindSpec::Source(_) => 0,
635 OperatorKindSpec::Join(_)
636 | OperatorKindSpec::Set(_)
637 | OperatorKindSpec::Apply(
638 crate::executor::streaming::operators::spec::ApplySpec::Apply { .. },
639 )
640 | OperatorKindSpec::Apply(
641 crate::executor::streaming::operators::spec::ApplySpec::PatternApply {
642 ..
643 },
644 )
645 | OperatorKindSpec::Apply(
646 crate::executor::streaming::operators::spec::ApplySpec::RollUpApply {
647 ..
648 },
649 ) => 2,
650 OperatorKindSpec::Wco(wco) => 1 + wco.bound_names.len(),
651 OperatorKindSpec::Exchange(_) => arity,
652 _ => 1,
653 };
654 if !matches!(operator.spec, OperatorKindSpec::Source(_)) && arity != expected_arity
655 {
656 result.errors.push(format!(
657 "Operator {:?} ({}) declares arity {}, expected {}",
658 operator_id, operator.explain_name, arity, expected_arity
659 ));
660 }
661
662 for input_fragment in &references {
663 if *input_fragment == fragment.id {
664 if index == 0 {
665 result.errors.push(format!(
666 "First operator {:?} in fragment {:?} self-references its input",
667 operator_id, fragment.id
668 ));
669 }
670 } else if plan.fragments.get(*input_fragment).is_none() {
671 result.errors.push(format!(
672 "Operator {:?} references missing producer fragment {:?}",
673 operator_id, input_fragment
674 ));
675 }
676 }
677 }
678 }
679 }
680
681 fn check_state_ownership(plan: &PhysicalPlan, result: &mut ValidationResult) {
687 for op in &plan.operators {
688 let is_blocking = matches!(&op.spec, OperatorKindSpec::Blocking(_));
689 let is_exchange = matches!(&op.spec, OperatorKindSpec::Exchange(_));
690
691 match op.state_ownership {
692 StateOwnership::TreeLocal => {
693 if is_blocking || is_exchange {
696 result.warnings.push(format!(
697 "Operator {:?} ({}) is blocking/exchange but has TreeLocal state ownership",
698 op.operator_id, op.explain_name
699 ));
700 }
701 }
702 StateOwnership::GlobalRuntime | StateOwnership::TaskLocal => {
703 }
705 }
706 }
707 }
708
709 fn check_operator_spec_consistency(plan: &PhysicalPlan, result: &mut ValidationResult) {
713 for op in &plan.operators {
714 if let OperatorKindSpec::Source(_) = &op.spec {
716 for fragment in plan.fragments.fragments() {
717 if fragment.root_operator == op.operator_id && !fragment.inputs.is_empty() {
718 result.warnings.push(format!(
720 "Source operator {:?} ({}) is root of fragment {:?} which has inputs",
721 op.operator_id, op.explain_name, fragment.id
722 ));
723 }
724 }
725 }
726 }
727 }
728
729 fn check_fragment_parallelism(plan: &PhysicalPlan, result: &mut ValidationResult) {
733 for op in &plan.operators {
734 let p = &op.properties.parallelism;
735 if p.min_workers > p.max_workers {
736 result.errors.push(format!(
737 "Operator {:?} ({}) has min_workers {} > max_workers {}",
738 op.operator_id, op.explain_name, p.min_workers, p.max_workers
739 ));
740 }
741 if p.min_workers < 1 {
742 result.errors.push(format!(
743 "Operator {:?} ({}) has min_workers {} < 1",
744 op.operator_id, op.explain_name, p.min_workers
745 ));
746 }
747 }
748 }
749
750 fn check_partition_parallelism(plan: &PhysicalPlan, result: &mut ValidationResult) {
753 for op in &plan.operators {
754 if let InputContract::PartitionedInputs { members, .. } = &op.input_contract {
755 if members.is_empty() {
756 result.warnings.push(format!(
757 "Operator {:?} ({}) has empty PartitionedInputs",
758 op.operator_id, op.explain_name
759 ));
760 continue;
761 }
762 let base_workers = members[0].properties.parallelism.max_workers;
763 for member in members {
764 if member.properties.parallelism.max_workers != base_workers {
765 result.warnings.push(format!(
766 "Operator {:?} ({}) has partition member {:?} with {} workers, expected {}",
767 op.operator_id,
768 op.explain_name,
769 member.fragment,
770 member.properties.parallelism.max_workers,
771 base_workers
772 ));
773 }
774 }
775 }
776 }
777 }
778
779 fn check_parameter_schema(plan: &PhysicalPlan, result: &mut ValidationResult) {
782 for param in &plan.parameter_schema.params {
783 if param.name.is_empty() {
784 result
785 .errors
786 .push("Parameter schema contains unnamed parameter".to_string());
787 }
788 if param.slot.0 >= plan.parameter_schema.params.len() {
789 result.warnings.push(format!(
790 "Parameter '{}' has out-of-range slot {:?}",
791 param.name, param.slot
792 ));
793 }
794 }
795 }
796}