@@ -314,6 +314,11 @@ pub enum PrecomputePlanError {
314314 InvalidSchema { schema_id : String } ,
315315 #[ error( "materialization {0} uses a summary family unsupported by the runtime schema" ) ]
316316 UnsupportedFamily ( u64 ) ,
317+ #[ error( "materialization {materialization} has no consistent Planner computation: {reason}" ) ]
318+ InvalidComputation {
319+ materialization : u64 ,
320+ reason : String ,
321+ } ,
317322 #[ error( "materialization {materialization} has an invalid window layout: {reason}" ) ]
318323 InvalidWindowLayout {
319324 materialization : u64 ,
@@ -466,7 +471,9 @@ impl PrecomputePlan {
466471
467472 /// The Planner SummaryAgg node that produces `output`, with the source
468473 /// expression feeding it when that input is a raw source. `None` when no
469- /// installed DAG produces the output with a SummaryAgg.
474+ /// installed DAG produces the output with a SummaryAgg. Every DAG that
475+ /// produces the output must agree on both, or the plan is rejected: the
476+ /// runtime routes, filters and catalogs the output once.
470477 #[ allow( clippy:: type_complexity) ]
471478 pub fn summary_producer (
472479 & self ,
@@ -479,6 +486,10 @@ impl PrecomputePlan {
479486 String ,
480487 > {
481488 use planner_types:: post_asap:: PostAsapOperatorPayload ;
489+ let mut selected: Option < (
490+ planner_types:: post_asap:: PostAsapDagNode ,
491+ Option < planner_types:: pre_asap:: QueryExpr > ,
492+ ) > = None ;
482493 for installed in self . executable_dags . values ( ) {
483494 let Some ( node) = installed. binding . nodes . iter ( ) . find_map ( |( node, binding) | {
484495 matches ! ( binding, crate :: executable_plan:: BackendNodeBinding :: Materialization { stored_output } if * stored_output == output) . then_some ( * node)
@@ -508,9 +519,39 @@ impl PrecomputePlan {
508519 } ) ,
509520 _ => None ,
510521 } ;
511- return Ok ( Some ( ( producer. clone ( ) , source) ) ) ;
522+ if let Some ( ( first, first_source) ) = & selected {
523+ // Shared outputs may be read by scans that project different
524+ // columns; what must agree is the update and its population.
525+ let population =
526+ |node : & planner_types:: post_asap:: PostAsapDagNode ,
527+ source : & Option < planner_types:: pre_asap:: QueryExpr > | {
528+ let PostAsapOperatorPayload :: SummaryAgg { family, .. } = & node. payload
529+ else {
530+ return None ;
531+ } ;
532+ source. as_ref ( ) . map ( |expression| {
533+ raw_time_series_input_contract (
534+ expression,
535+ matches ! ( family, SummaryFamilyType :: ExactAggregate ( ..) ) ,
536+ )
537+ . map ( |( metric, _, filter) | {
538+ ( metric, crate :: utils:: normalize_spatial_filter ( & filter) )
539+ } )
540+ } )
541+ } ;
542+ if first. payload != producer. payload
543+ || population ( first, first_source) != population ( producer, & source)
544+ {
545+ return Err ( format ! (
546+ "stored output {} is produced by DAGs that disagree on its computation" ,
547+ output. as_u64( )
548+ ) ) ;
549+ }
550+ } else {
551+ selected = Some ( ( producer. clone ( ) , source) ) ;
552+ }
512553 }
513- Ok ( None )
554+ Ok ( selected )
514555 }
515556
516557 /// Canonical population predicate of `config`'s input: the typed table
@@ -523,8 +564,16 @@ impl PrecomputePlan {
523564 if config. table_name . is_some ( ) || config. derived_input . is_some ( ) {
524565 return Ok ( table) ;
525566 }
526- let Some ( ( _, Some ( expression) ) ) = self . summary_producer ( config. stored_output_id ) ? else {
527- return Ok ( String :: new ( ) ) ;
567+ let expression = match self . summary_producer ( config. stored_output_id ) ? {
568+ Some ( ( _, Some ( expression) ) ) => expression,
569+ // A bound raw output whose input cannot be read must not be
570+ // treated as unfiltered.
571+ Some ( ( _, None ) ) => {
572+ return Err ( "raw output's Planner producer does not read a source scan" . into ( ) )
573+ }
574+ // Only plans without a Planner DAG for this output (imported or
575+ // fixture outputs) have no predicate to read.
576+ None => return Ok ( String :: new ( ) ) ,
528577 } ;
529578 let family = self . state_family ( config. stored_output_id ) ;
530579 let ( _, _, filter) = raw_time_series_input_contract (
@@ -1033,6 +1082,28 @@ impl PrecomputePlan {
10331082 schema. materialization . as_u64 ( ) ,
10341083 ) ) ;
10351084 }
1085+ let invalid_computation = |reason : String | PrecomputePlanError :: InvalidComputation {
1086+ materialization : schema. materialization . as_u64 ( ) ,
1087+ reason,
1088+ } ;
1089+ let raw =
1090+ materialization. table_name . is_none ( ) && materialization. derived_input . is_none ( ) ;
1091+ // Once a plan carries Planner DAGs, a raw output's input predicate
1092+ // exists only on its producer; an unbound output would silently
1093+ // read every series of its metric.
1094+ if raw
1095+ && !self . executable_dags . is_empty ( )
1096+ && self
1097+ . summary_producer ( schema. materialization )
1098+ . map_err ( invalid_computation) ?
1099+ . is_none ( )
1100+ {
1101+ return Err ( invalid_computation (
1102+ "raw output has no Planner producer" . into ( ) ,
1103+ ) ) ;
1104+ }
1105+ self . population_filter ( materialization)
1106+ . map_err ( invalid_computation) ?;
10361107 let source = materialization. table_name . as_ref ( ) . map_or_else (
10371108 || Source :: TimeSeries {
10381109 metric : materialization. metric . clone ( ) ,
0 commit comments