From 33fad08f629d5c8147aaed4446cb0854d34763e3 Mon Sep 17 00:00:00 2001 From: zzylol Date: Tue, 29 Sep 2026 02:19:15 +0000 Subject: [PATCH 01/19] docs: specify physical candidate handoff and migration acceptance --- docs/design_docs/README.md | 2 + .../design_docs/physical-candidate-handoff.md | 66 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 docs/design_docs/physical-candidate-handoff.md diff --git a/docs/design_docs/README.md b/docs/design_docs/README.md index 6570d0bec..6100142d8 100644 --- a/docs/design_docs/README.md +++ b/docs/design_docs/README.md @@ -13,6 +13,8 @@ integration. - [Summary definitions table and SDS](summary-catalog-sds-architecture.md) owns definition and instance identity, version-scoped state references, read eligibility and committed-state metadata. +- [Physical candidate handoff](physical-candidate-handoff.md) defines removal of + Backend operator lowering while preserving deployment and SDS contracts. - [QueryPlan DAG execution](query-dag-execution.md) explains how the query engine evaluates installed query sub-DAGs and reads bound stored summaries. - [Architecture migration delivery plan](asapplanner-migration-plan.md) defines diff --git a/docs/design_docs/physical-candidate-handoff.md b/docs/design_docs/physical-candidate-handoff.md new file mode 100644 index 000000000..ea2c9f79a --- /dev/null +++ b/docs/design_docs/physical-candidate-handoff.md @@ -0,0 +1,66 @@ +# Bind Planner physical candidates without Backend operator lowering + +Status: target design. The current Backend still performs the lowering described below. + +## Problem + +Backend shares Planner's operators and runtime, but it still reconstructs parts +of the computation. `QueryPlanNode` includes logical and relational operations; +control-plane query lowering creates those nodes; SQL and PromQL serving paths +compile some of them into physical fragments. This duplicates responsibility even +though execution eventually uses the shared library. + +## Required boundary + +```text +Logical Post-ASAP DAG + Summary Maintenance Lifecycle + → Planner Physical Plan Compiler + → supported Physical DAG candidates + → Backend feasibility, workload costs and selection + → Deployment Plan / DAG: PrecomputePlan + QueryPlan + → bind concrete inputs and execute the selected Physical DAGs +``` + +`CompiledPhysicalDag` is an implementation type, not another architectural layer. +Backend must preserve candidate operators, dependencies, sharing, roots and +materialization boundaries. Runtime input binding may select eligible records; +it must not select algorithms, rebuild operators, or move work between phases. + +## Implementation changes + +| Component | Change | +| --- | --- | +| Planner handoff | Consume complete executable physical candidates and their typed boundaries; report missing Planner implementations explicitly | +| Deployment compiler | Price feasible candidates, select one, and install source/SDS bindings without logical lowering | +| Installed query | Store the selected physical computation and deployment metadata, removing the parallel Backend computation node representation | +| SQL execution | Remove request-time `ExecutableDagNode`, `compile_node` and physical graph reconstruction | +| PromQL execution | Move computation currently lowered by `residual.rs` and `logical_dag/native_values.rs` to Planner; retain protocol and input conversion | +| Precomputation | Bind the candidate's precompute graph and stored outputs without reclassifying operators by kind | +| Persistence | Update the installed-plan schema; reject incompatible stored plans explicitly rather than retaining a legacy execution path | + +SDS semantic identity, deployed-output identity, same-version recovery, query-wide +revision pinning, freshness, correction horizons, request resource budgets and +terminal cancellation/resource errors remain deployment contracts. + +A gap in Planner support must be fixed and tested in Planner before deleting the +corresponding Backend path. A missing connector is deployment infeasibility; +losing an operator's semantics in an adapter is not. + +## Acceptance + +1. Structural tests compare each selected candidate with its installed graph: + operators, parameters, typed edges, shared producers, roots and frontiers remain + unchanged. Include a shared-producer query ensemble. +2. Synthetic-cost tests reverse candidate selection without changing a candidate's + computation or introducing unpriced work. +3. Deployment E2E tests compile, install, ingest, publish and query each supported + candidate, checking exact results or admitted approximation guarantees. +4. Recovery tests preserve SDS identity and common-revision reads; a new plan + version requires its own warm-up. +5. Request tests retain cancellation and memory exhaustion as terminal errors, + including nested DAGs, range evaluation and concurrent revision publication. +6. Production serving modules do not import semantic lowering or compile nodes. + Decoder validation and binding to typed sources remain permitted. + +This work is complete only when the installed production path uses the handoff, +not when a new API or isolated test executes a physical fragment successfully. From 8d089c145fe0476a6e854ff54c1bc986ec79edc7 Mon Sep 17 00:00:00 2001 From: zzylol Date: Tue, 29 Sep 2026 02:44:26 +0000 Subject: [PATCH 02/19] refactor: install compiled SQL relation graphs before query execution --- control_plane/src/physical/plan_dot.rs | 3 +- control_plane/src/query_plan.rs | 154 +++++++--- .../src/query_plan/clickhouse_exact.rs | 39 +-- crates/asap_types/src/query_plan.rs | 32 ++- .../accelerator.rs | 98 ++++--- .../asap_clickhouse_query_engine/execution.rs | 264 ++++++++---------- .../relational_adapter.rs | 7 +- .../relational_adapter/native.rs | 11 +- .../asap_query_engine/post_asap_readout.rs | 1 + 9 files changed, 350 insertions(+), 259 deletions(-) diff --git a/control_plane/src/physical/plan_dot.rs b/control_plane/src/physical/plan_dot.rs index 22dc9ed99..c0e7f9d5f 100644 --- a/control_plane/src/physical/plan_dot.rs +++ b/control_plane/src/physical/plan_dot.rs @@ -139,7 +139,8 @@ fn escape(value: &str) -> String { fn query_node_label(node: &QueryPlanNode) -> String { match node { - QueryPlanNode::PhysicalFragment { dag, .. } => { + QueryPlanNode::PhysicalRelation { dag, .. } + | QueryPlanNode::PhysicalFragment { dag, .. } => { asap_physical_operators::physical_planner::CompiledPhysicalDag::decode(dag) .map(|plan| { format!( diff --git a/control_plane/src/query_plan.rs b/control_plane/src/query_plan.rs index 04a7afd85..d0900cb62 100644 --- a/control_plane/src/query_plan.rs +++ b/control_plane/src/query_plan.rs @@ -79,7 +79,7 @@ where preserve_relational: true, lowered: Some(&mut lowered), }; - let root = compiler.lower(root)?; + let root = compiler.lower_relation(root)?; Ok(QueryPlanEntry { language: QueryLanguage::ClickHouseSql, query_id, @@ -229,6 +229,97 @@ where &SummaryFamilyType, ) -> Result, { + fn lower_relation(&mut self, root: &Rc) -> Result { + use asap_physical_operators::physical_planner::{compile, InputContract}; + use planner_types::post_asap::{ + compile_executable_dag_with_node_ids, ExecutableOperatorPayload as Payload, + }; + let compilation = compile_executable_dag_with_node_ids(root) + .map_err(|e| QueryPlanError::Invalid(e.to_string()))?; + let mut pending = vec![compilation.dag.root]; + let mut visited = std::collections::BTreeSet::new(); + let mut contracts = BTreeMap::new(); + let mut bindings = BTreeMap::new(); + let mut computed = Vec::new(); + while let Some(id) = pending.pop() { + if !visited.insert(id) { + continue; + } + let node = compilation + .dag + .nodes + .iter() + .find(|n| n.id == id) + .ok_or_else(|| QueryPlanError::Invalid("missing Planner physical node".into()))?; + let relation = matches!( + &node.payload, + Payload::RelationalJoin { .. } + | Payload::Value { + operation: planner_types::post_asap::ValueOperation::Project { .. } + | planner_types::post_asap::ValueOperation::Filter { .. } + | planner_types::post_asap::ValueOperation::Sort { .. } + | planner_types::post_asap::ValueOperation::Limit { .. } + | planner_types::post_asap::ValueOperation::Exact( + planner_types::post_asap::ExactOperation::Aggregate { .. } + ) + } + ); + if relation { + computed.push(id); + pending.extend( + compilation + .dag + .edges + .iter() + .filter(|e| e.consumer == id) + .map(|e| e.producer), + ); + } else { + let semantic = compilation.node_ids.summary_node(id).ok_or_else(|| { + QueryPlanError::Invalid("missing Planner source identity".into()) + })?; + let source = self.lower(semantic)?; + bindings.insert(u64::from(id.0), source); + contracts.insert( + u64::from(id.0), + InputContract::bounded(std::sync::Arc::new(node.output_schema.clone())), + ); + } + } + if computed.is_empty() { + return self.lower(root); + } + let physical = compile( + &compilation.dag, + contracts, + &[u64::from(compilation.dag.root.0)], + ) + .map_err(|e| QueryPlanError::Invalid(e.to_string()))?; + let id = QueryNodeId(self.next_id); + self.next_id += 1; + self.nodes.insert( + id, + QueryPlanNode::PhysicalRelation { + inputs: physical + .input_contracts() + .map(|(id, _)| bindings[&id]) + .collect(), + dag: physical + .encode() + .map_err(|e| QueryPlanError::Invalid(e.to_string()))?, + }, + ); + for node in computed { + if let Some(semantic) = compilation.node_ids.summary_node(node) { + self.seen.insert(Rc::as_ptr(semantic) as usize, id); + if let Some(lowered) = &mut self.lowered { + lowered(semantic, id); + } + } + } + Ok(id) + } + fn graft( &mut self, id: QueryNodeId, @@ -248,7 +339,8 @@ where } for (local, mut physical) in nodes { match &mut physical { - QueryPlanNode::PhysicalFragment { inputs, .. } + QueryPlanNode::PhysicalRelation { inputs, .. } + | QueryPlanNode::PhysicalFragment { inputs, .. } | QueryPlanNode::Logical { inputs, .. } | QueryPlanNode::SummaryMerge { inputs } | QueryPlanNode::ExternalExact { inputs, .. } => { @@ -328,49 +420,23 @@ where } let physical = match &node.expr { - SummaryExpr::RelationalJoin { - left, - right, - kind, - pred, - pruning, - } if self.preserve_relational => QueryPlanNode::RelationalJoin { - inputs: [self.lower(left)?, self.lower(right)?], - join_kind: kind.clone(), - pruning: pruning.clone(), - pred: serde_json::to_value(pred).map_err(|error| { - QueryPlanError::Invalid(format!( - "cannot serialize relational join predicate: {error}" - )) - })?, - left_schema: left.schema.clone(), - right_schema: right.schema.clone(), - output_schema: node.schema.clone(), - }, - SummaryExpr::ValueOperation { - child, operation, .. - } if self.preserve_relational - && matches!( - operation, - planner_types::post_asap::ValueOperation::Project { .. } - | planner_types::post_asap::ValueOperation::Filter { .. } - | planner_types::post_asap::ValueOperation::Sort { .. } - | planner_types::post_asap::ValueOperation::Limit { .. } - | planner_types::post_asap::ValueOperation::Exact( - planner_types::post_asap::ExactOperation::Aggregate { .. } - ) - ) => + SummaryExpr::RelationalJoin { .. } if self.preserve_relational => { + return self.lower_relation(node); + } + SummaryExpr::ValueOperation { operation, .. } + if self.preserve_relational + && matches!( + operation, + planner_types::post_asap::ValueOperation::Project { .. } + | planner_types::post_asap::ValueOperation::Filter { .. } + | planner_types::post_asap::ValueOperation::Sort { .. } + | planner_types::post_asap::ValueOperation::Limit { .. } + | planner_types::post_asap::ValueOperation::Exact( + planner_types::post_asap::ExactOperation::Aggregate { .. } + ) + ) => { - QueryPlanNode::Relational { - input: self.lower(child)?, - operation: serde_json::to_value(operation).map_err(|error| { - QueryPlanError::UnsupportedNode(format!( - "cannot serialize relational operation: {error}" - )) - })?, - input_schema: child.schema.clone(), - output_schema: node.schema.clone(), - } + return self.lower_relation(node); } SummaryExpr::ValueOperation { child, diff --git a/control_plane/src/query_plan/clickhouse_exact.rs b/control_plane/src/query_plan/clickhouse_exact.rs index bcd31fb85..3b8451d1a 100644 --- a/control_plane/src/query_plan/clickhouse_exact.rs +++ b/control_plane/src/query_plan/clickhouse_exact.rs @@ -346,24 +346,29 @@ mod original_tests { ) .unwrap(); assert!( - !entry - .nodes - .values() - .any(|node| matches!(node, QueryPlanNode::Logical { .. })), - "SQL must not acquire PromQL operators" + !entry.nodes.values().any(|node| matches!( + node, + QueryPlanNode::Logical { .. } + | QueryPlanNode::Relational { .. } + | QueryPlanNode::RelationalJoin { .. } + )), + "SQL installation must not persist logical or uncompiled relational operators" + ); + let QueryPlanNode::PhysicalRelation { dag, .. } = &entry.nodes[&entry.root] else { + panic!("SQL computation must be installed as a complete physical DAG"); + }; + let physical = + asap_physical_operators::physical_planner::CompiledPhysicalDag::decode(dag) + .unwrap(); + assert_eq!(physical.roots().len(), 1); + assert!(!physical.input_contracts().collect::>().is_empty()); + let encoded: serde_json::Value = serde_json::from_slice(dag).unwrap(); + assert!( + encoded["nodes"].as_object().unwrap().keys().any(|id| { + physical.operator_name(id.parse().unwrap()) == Some("Aggregate") + }), + "native aggregate must remain inside the compiled physical DAG" ); - assert!(entry.nodes.values().any(|node| match node { - QueryPlanNode::Relational { operation, .. } => matches!( - serde_json::from_value::( - operation.clone() - ) - .unwrap(), - planner_types::post_asap::ValueOperation::Exact( - planner_types::post_asap::ExactOperation::Aggregate { .. } - ) - ), - _ => false, - })); } } } diff --git a/crates/asap_types/src/query_plan.rs b/crates/asap_types/src/query_plan.rs index 8cea4e1a5..699ec35d5 100644 --- a/crates/asap_types/src/query_plan.rs +++ b/crates/asap_types/src/query_plan.rs @@ -423,7 +423,31 @@ impl QueryPlanEntry { ))); } for (id, node) in &self.nodes { + if self.language == QueryLanguage::ClickHouseSql + && matches!( + node, + QueryPlanNode::Relational { .. } | QueryPlanNode::RelationalJoin { .. } + ) + { + return Err(QueryPlanError::Invalid("SQL plan contains uncompiled relation semantics; recompile the deployment plan".into())); + } validate_native_relation(*id, node)?; + if let QueryPlanNode::PhysicalRelation { inputs, dag } = node { + if self.language != QueryLanguage::ClickHouseSql { + return Err(QueryPlanError::Invalid( + "physical relation requires a SQL result binding".into(), + )); + } + let compiled = + asap_physical_operators::physical_planner::CompiledPhysicalDag::decode(dag) + .map_err(|e| QueryPlanError::Invalid(e.to_string()))?; + if compiled.roots().len() != 1 || compiled.input_contracts().count() != inputs.len() + { + return Err(QueryPlanError::Invalid( + "physical relation boundary arity mismatch".into(), + )); + } + } if let QueryPlanNode::PhysicalFragment { inputs, dag, @@ -708,6 +732,11 @@ pub struct PruningInputContract { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] pub enum QueryPlanNode { + /// Complete Planner-compiled relation computation; inputs follow its typed slots. + PhysicalRelation { + inputs: Vec, + dag: Vec, + }, /// Planner-compiled computation. Input order follows the physical input contracts. PhysicalFragment { inputs: Vec, @@ -784,7 +813,8 @@ impl QueryPlanNode { | Self::Relational { input, .. } | Self::SummaryEstimate { input, .. } | Self::ExactReadout { input, .. } => std::slice::from_ref(input), - Self::PhysicalFragment { inputs, .. } + Self::PhysicalRelation { inputs, .. } + | Self::PhysicalFragment { inputs, .. } | Self::SummaryMerge { inputs } | Self::Logical { inputs, .. } | Self::ExternalExact { inputs, .. } => inputs, diff --git a/data_plane/src/query_engines/asap_clickhouse_query_engine/accelerator.rs b/data_plane/src/query_engines/asap_clickhouse_query_engine/accelerator.rs index d1c2e7cd8..81d2c939d 100644 --- a/data_plane/src/query_engines/asap_clickhouse_query_engine/accelerator.rs +++ b/data_plane/src/query_engines/asap_clickhouse_query_engine/accelerator.rs @@ -683,6 +683,47 @@ mod tests { } } + fn physical_relation_chain( + source: QueryNodeId, + schema: SummarySchema, + operations: Vec<(ValueOperation, SummarySchema)>, + ) -> QueryPlanNode { + use asap_physical_operators::physical_planner::{ + compile_node, CompiledPhysicalDag, InputContract, + }; + use planner_types::post_asap::{ + ExecutableDagNode, ExecutableOperatorPayload, ExecutionDataState, PostAsapNodeId, + }; + let input = std::sync::Arc::new(schema); + let mut previous_schema = input.clone(); + let mut previous = 0; + let mut nodes = BTreeMap::new(); + for (index, (operation, output_schema)) in operations.into_iter().enumerate() { + let id = index as u64 + 1; + let node = ExecutableDagNode { + id: PostAsapNodeId(id as u32), + payload: ExecutableOperatorPayload::Value { operation }, + output_state: ExecutionDataState::QUERY_ROWS, + output_schema: output_schema.clone(), + guarantee: None, + }; + let operator = compile_node(&node, &[previous_schema]).unwrap(); + nodes.insert(id, (vec![previous], operator)); + previous = id; + previous_schema = std::sync::Arc::new(output_schema); + } + let physical = CompiledPhysicalDag::from_operators( + [(0, InputContract::bounded(input))].into(), + nodes, + vec![previous], + ) + .unwrap(); + QueryPlanNode::PhysicalRelation { + inputs: vec![source], + dag: physical.encode().unwrap(), + } + } + async fn fixture(end_ms: u64) -> (CatalogClickHouseAccelerator, ClickHouseQueryRequest) { fixture_with_store(end_ms, Arc::new(SketchStore::new()), true).await } @@ -737,9 +778,6 @@ mod tests { ("bucket", DataType::Timestamp), ("score", DataType::Float64), ]); - let filter = QueryNodeId(2); - let project = QueryNodeId(3); - let sort = QueryNodeId(4); let root = QueryNodeId(5); let executable = QueryPlanEntry { language: QueryLanguage::ClickHouseSql, @@ -774,24 +812,13 @@ mod tests { readout: ExactReadout::Sum, }, ), - ( - filter, - QueryPlanNode::Relational { - input: readout, - operation: serde_json::json!({"Filter": {"pred": Predicate(Rc::new(QueryExpr::Compare { + (root, physical_relation_chain(readout, input_schema.clone(), vec![ + (serde_json::from_value(serde_json::json!({"Filter": {"pred": Predicate(Rc::new(QueryExpr::Compare { left: Rc::new(QueryExpr::Column(1)), op: CompareOpKind::Gt, right: Rc::new(QueryExpr::Literal(ScalarValue::Float64(1.0))), - }))}}), - input_schema: input_schema.clone(), - output_schema: input_schema.clone(), - }, - ), - ( - project, - QueryPlanNode::Relational { - input: filter, - operation: serde_json::to_value(ValueOperation::Project { + }))}})).unwrap(), input_schema.clone()), + (serde_json::from_value(serde_json::to_value(ValueOperation::Project { cols: vec![ ProjectItem { alias: Some("bucket".into()), @@ -810,16 +837,8 @@ mod tests { ], qualifier: None, }) - .unwrap(), - input_schema: input_schema, - output_schema: projected_schema.clone(), - }, - ), - ( - sort, - QueryPlanNode::Relational { - input: project, - operation: serde_json::to_value(ValueOperation::Sort { + .unwrap()).unwrap(), projected_schema.clone()), + (serde_json::from_value(serde_json::to_value(ValueOperation::Sort { keys: vec![SortKey { expr: QueryExpr::Column(1), ascending: false, @@ -827,21 +846,10 @@ mod tests { }], partition_by: GroupKeys::none(), }) - .unwrap(), - input_schema: projected_schema.clone(), - output_schema: projected_schema.clone(), - }, - ), - ( - root, - QueryPlanNode::Relational { - input: sort, - operation: serde_json::to_value(ValueOperation::Limit { n: 1, offset: 0, partition_by: planner_types::pre_asap::GroupKeys::none() }) - .unwrap(), - input_schema: projected_schema.clone(), - output_schema: projected_schema, - }, - ), + .unwrap()).unwrap(), projected_schema.clone()), + (serde_json::from_value(serde_json::to_value(ValueOperation::Limit { n: 1, offset: 0, partition_by: planner_types::pre_asap::GroupKeys::none() }) + .unwrap()).unwrap(), projected_schema), + ])), ] .into_iter() .collect(), @@ -1125,7 +1133,7 @@ mod tests { // A publication made before time templates keeps its fixed lookup semantics. #[tokio::test] - async fn legacy_bounded_sql_plan_still_executes() { + async fn fixed_window_physical_plan_executes_without_a_template() { let (accelerator, mut request) = fixture_with_sql(1_000, Arc::new(SketchStore::new()), true, true).await; let active = accelerator.active_physical_plan.as_ref().unwrap(); @@ -1156,7 +1164,7 @@ mod tests { let ClickHouseAccelerationOutcome::Accelerated(response) = accelerator.execute(&request).await else { - panic!("old fixed identity must remain executable"); + panic!("fixed-window physical plan must execute without a template"); }; assert_eq!(response.body, "1970-01-01T00:00:01\t20.0\n"); request.sql = diff --git a/data_plane/src/query_engines/asap_clickhouse_query_engine/execution.rs b/data_plane/src/query_engines/asap_clickhouse_query_engine/execution.rs index 27edd68ae..127f73f61 100644 --- a/data_plane/src/query_engines/asap_clickhouse_query_engine/execution.rs +++ b/data_plane/src/query_engines/asap_clickhouse_query_engine/execution.rs @@ -119,118 +119,48 @@ impl RelationDagExecutor<'_> { ) -> Result { use super::relational_adapter::native; use asap_physical_operators::dag::{self, operators::Operator}; + use asap_physical_operators::physical_planner::{CompiledPhysicalDag, InputContract}; use futures::{FutureExt, StreamExt}; - use planner_types::post_asap::{ - ExecutableDagNode, ExecutableOperatorPayload as Payload, ExecutionDataState, - PostAsapNodeId, SummarySchema, - }; use std::sync::Arc; - let mut pending = vec![(root, expected.clone())]; - let mut schemas = BTreeMap::::new(); - let mut operations = BTreeMap::new(); - let mut sources = Vec::new(); - // Bind the entire computation before reading any storage source. - while let Some((id, expected)) = pending.pop() { - if let Some(previous) = schemas.get(&id) { - if previous != &expected { - return Err("inconsistent relation schemas".into()); - } - continue; - } - schemas.insert(id, expected.clone()); - let (payload, inputs) = match self.entry.nodes.get(&id) { - Some(QueryPlanNode::Relational { - input, - operation, - input_schema, - output_schema, - }) => { - if output_schema != &expected { - return Err("relational output schema mismatch".into()); - } - let operation = - serde_json::from_value(operation.clone()).map_err(|e| e.to_string())?; - ( - Payload::Value { operation }, - vec![(*input, input_schema.clone())], - ) - } - Some(QueryPlanNode::RelationalJoin { - inputs, - join_kind, - pred, - left_schema, - right_schema, - output_schema, - pruning, - }) => { - if output_schema != &expected { - return Err("join output schema mismatch".into()); - } - if pruning.is_some() { - return Err( - "candidate pruning is not bound for this relation source".into() - ); - } - ( - Payload::RelationalJoin { - join_kind: join_kind.clone(), - pred: serde_json::from_value(pred.clone()) - .map_err(|e| e.to_string())?, - pruning: None, - }, - vec![ - (inputs[0], left_schema.clone()), - (inputs[1], right_schema.clone()), - ], - ) + let (compiled, source_bindings) = match self.entry.nodes.get(&root) { + Some(QueryPlanNode::PhysicalRelation { inputs, dag }) => { + let compiled = CompiledPhysicalDag::decode(dag)?; + if compiled.roots().len() != 1 || compiled.input_contracts().count() != inputs.len() + { + return Err("physical relation boundary arity mismatch".into()); } - Some(_) => { - sources.push(id); - continue; + if compiled + .output_contract(compiled.roots()[0])? + .schema + .as_ref() + != expected + { + return Err("physical relation output schema mismatch".into()); } - None => return Err("missing relation node".into()), - }; - let node = ExecutableDagNode { - id: PostAsapNodeId( - u32::try_from(id.0).map_err(|_| "relation node ID exceeds Planner range")?, - ), - payload, - output_state: ExecutionDataState::QUERY_ROWS, - output_schema: expected, - guarantee: None, - }; - let op = dag::planner::compile_node( - &node, - &inputs - .iter() - .map(|(_, schema)| Arc::new(schema.clone())) - .collect::>(), - ) - .map_err(|e| e.to_string())?; - operations.insert( - id, - (inputs.iter().map(|(id, _)| id.0).collect::>(), op), - ); - pending.extend(inputs); - } - let compiled = - asap_physical_operators::physical_planner::CompiledPhysicalDag::from_operators( - sources - .iter() - .map(|id| { - ( - id.0, - asap_physical_operators::physical_planner::InputContract::bounded( - Arc::new(schemas[id].clone()), - ), - ) - }) - .collect(), - operations.into_iter().map(|(id, op)| (id.0, op)).collect(), - vec![root.0], - ) - .map_err(|e| e.to_string())?; + let bindings = compiled + .input_contracts() + .zip(inputs) + .map(|((slot, contract), id)| (slot, *id, contract.schema.clone())) + .collect::>(); + (compiled, bindings) + } + Some(QueryPlanNode::ExternalExact { .. }) => { + let schema = Arc::new(expected.clone()); + ( + CompiledPhysicalDag::from_operators( + [(root.0, InputContract::bounded(schema.clone()))].into(), + BTreeMap::new(), + vec![root.0], + )?, + vec![(root.0, root, schema)], + ) + } + _ => return Err("SQL execution requires a Planner-compiled physical relation".into()), + }; + let sources = source_bindings + .iter() + .map(|(_, id, _)| *id) + .collect::>(); let mut resolved_inputs = BTreeMap::new(); let context = crate::query_engines::request::context(dag::Scope::Query { evaluation_time_ms: i64::try_from(self.t1_ms) @@ -263,8 +193,8 @@ impl RelationDagExecutor<'_> { ).map_err(SqlExecutionError::from)?; let mut coverage = None; let mut first = true; - for id in sources { - let relation = self.execute_source(id, &schemas[&id], &stored)?; + for (slot, id, schema) in source_bindings { + let relation = self.execute_source(id, &schema, &stored)?; coverage = if first { first = false; relation.coverage @@ -276,9 +206,9 @@ impl RelationDagExecutor<'_> { _ => None, } }; - let batch = native::batch(&relation, &schemas[&id]).map_err(|e| e.to_string())?; + let batch = native::batch(&relation, &schema).map_err(|e| e.to_string())?; resolved_inputs.insert( - id.0, + slot, Box::new( Operator::source(batch.schema().clone(), vec![batch]) .map_err(|e| e.to_string())?, @@ -288,7 +218,7 @@ impl RelationDagExecutor<'_> { let graph = compiled .instantiate(resolved_inputs) .map_err(|e| e.to_string())?; - let mut output = graph.execute(&[root.0], context)?.remove(0); + let mut output = graph.execute(compiled.roots(), context)?.remove(0); let mut batches = Vec::new(); loop { crate::query_engines::request::check()?; @@ -449,18 +379,26 @@ fn execute_sql_dag_with_external_unfenced( ids.iter().any(|id| { matches!( entry.nodes.get(id), - Some( - QueryPlanNode::Relational { .. } - | QueryPlanNode::RelationalJoin { .. } - | QueryPlanNode::ExternalExact { .. } - ) + Some(QueryPlanNode::PhysicalRelation { .. } | QueryPlanNode::ExternalExact { .. }) ) }) }); if has_relational_join { let root_schema = match entry.nodes.get(&entry.root) { - Some(QueryPlanNode::Relational { output_schema, .. }) - | Some(QueryPlanNode::RelationalJoin { output_schema, .. }) => output_schema.clone(), + Some(QueryPlanNode::PhysicalRelation { dag, .. }) => { + match asap_physical_operators::physical_planner::CompiledPhysicalDag::decode(dag) + .and_then(|compiled| { + let root = compiled.roots().first().ok_or_else(|| { + asap_physical_operators::dag::Error::Invalid( + "physical relation has no root".into(), + ) + })?; + compiled.output_contract(*root) + }) { + Ok(contract) => contract.schema.as_ref().clone(), + Err(error) => return ClickHouseDagOutcome::Failed(error), + } + } Some(QueryPlanNode::ExternalExact { request, .. }) => { let asap_types::query_plan::ExternalExactOutput::Relation { schema } = &request.output @@ -753,30 +691,76 @@ mod tests { assert!(error.to_string().contains("inconsistent relation schemas")); } + fn physical_cross_join( + inputs: [QueryNodeId; 2], + schema: &planner_types::post_asap::SummarySchema, + output: &planner_types::post_asap::SummarySchema, + ) -> QueryPlanNode { + use asap_physical_operators::physical_planner::{ + compile_node, CompiledPhysicalDag, InputContract, + }; + use planner_types::post_asap::{ + ExecutableDagNode, ExecutableOperatorPayload, ExecutionDataState, PostAsapNodeId, + }; + use planner_types::pre_asap::{JoinKind, Predicate, QueryExpr, ScalarValue}; + let schema = std::sync::Arc::new(schema.clone()); + let op = compile_node( + &ExecutableDagNode { + id: PostAsapNodeId(100), + payload: ExecutableOperatorPayload::RelationalJoin { + join_kind: JoinKind::Cross, + pred: Predicate(QueryExpr::Literal(ScalarValue::Boolean(true)).into()), + pruning: None, + }, + output_state: ExecutionDataState::QUERY_ROWS, + output_schema: output.clone(), + guarantee: None, + }, + &[schema.clone(), schema.clone()], + ) + .unwrap(); + let physical = CompiledPhysicalDag::from_operators( + inputs + .iter() + .map(|id| (id.0, InputContract::bounded(schema.clone()))) + .collect(), + [(100, (inputs.iter().map(|id| id.0).collect(), op))].into(), + vec![100], + ) + .unwrap(); + QueryPlanNode::PhysicalRelation { + inputs: physical + .input_contracts() + .map(|(id, _)| QueryNodeId(id)) + .collect(), + dag: physical.encode().unwrap(), + } + } + // A SQL diamond binds one source to both join inputs in the shared DAG. #[test] fn relation_dag_executes_a_shared_source_join() { - use planner_types::pre_asap::{JoinKind, Predicate, QueryExpr, ScalarValue}; let schema = relation_schema("x"); let mut entry = external_entry(&schema); let mut output = schema.clone(); output.fields.push(schema.fields[0].clone()); entry.nodes.insert( QueryNodeId(1), - QueryPlanNode::RelationalJoin { - inputs: [QueryNodeId(0), QueryNodeId(0)], - join_kind: JoinKind::Cross, - pred: serde_json::to_value(Predicate::( - QueryExpr::Literal(ScalarValue::Boolean(true)).into(), - )) - .unwrap(), - left_schema: schema.clone(), - right_schema: schema.clone(), - output_schema: output.clone(), - pruning: None, - }, + physical_cross_join([QueryNodeId(0), QueryNodeId(0)], &schema, &output), ); entry.root = QueryNodeId(1); + entry.validate(&BTreeSet::new()).unwrap(); + let entry: QueryPlanEntry = + serde_json::from_slice(&serde_json::to_vec(&entry).unwrap()).unwrap(); + let QueryPlanNode::PhysicalRelation { inputs, .. } = &entry.nodes[&entry.root] else { + unreachable!() + }; + assert_eq!( + inputs, + &[QueryNodeId(0)], + "both join edges share one physical input slot" + ); + let relation = ClickHouseRelation::from_json_compact( &schema, br#"{"meta":[{"name":"x","type":"Int64"}],"data":[[1],[2]]}"#, @@ -813,7 +797,6 @@ mod tests { /// A publication between query branches invalidates the entire result. #[test] fn query_wide_fence_rejects_publication_between_join_branches() { - use planner_types::pre_asap::{JoinKind, Predicate, QueryExpr, ScalarValue}; let schema = relation_schema("x"); let mut entry = external_entry(&schema); entry @@ -823,18 +806,7 @@ mod tests { output.fields.push(schema.fields[0].clone()); entry.nodes.insert( QueryNodeId(1), - QueryPlanNode::RelationalJoin { - inputs: [QueryNodeId(0), QueryNodeId(2)], - join_kind: JoinKind::Cross, - pred: serde_json::to_value(Predicate::( - QueryExpr::Literal(ScalarValue::Boolean(true)).into(), - )) - .unwrap(), - left_schema: schema.clone(), - right_schema: schema.clone(), - output_schema: output, - pruning: None, - }, + physical_cross_join([QueryNodeId(0), QueryNodeId(2)], &schema, &output), ); entry.root = QueryNodeId(1); let relation = ClickHouseRelation::from_json_compact( diff --git a/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter.rs b/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter.rs index 9b55f7ea0..493c196aa 100644 --- a/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter.rs +++ b/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter.rs @@ -17,7 +17,7 @@ use arrow::{ }; use chrono::{DateTime, NaiveDateTime, TimeZone}; use planner_types::{ - post_asap::{SummaryFamilyType, SummarySchema, ValueOperation}, + post_asap::{SummaryFamilyType, SummarySchema}, pre_asap::DataType, }; @@ -398,8 +398,13 @@ fn clickhouse_type_matches(actual: Option<&str>, expected: &DataType, nullable: } } +#[cfg(test)] +use planner_types::post_asap::ValueOperation; + +#[cfg(test)] pub struct ClickHouseRelationalAdapter; +#[cfg(test)] impl ClickHouseRelationalAdapter { pub fn apply_join( &self, diff --git a/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter/native.rs b/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter/native.rs index 1888e30c6..e63833564 100644 --- a/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter/native.rs +++ b/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter/native.rs @@ -4,10 +4,7 @@ use asap_physical_operators::dag::{ self, values::{Batch, Value}, }; -use planner_types::post_asap::{ - ExecutableDagNode, ExecutableOperatorPayload, ExecutionDataState, PostAsapNodeId, - SummaryFamilyType, SummaryField, SummarySchema, -}; +use planner_types::post_asap::{SummaryFamilyType, SummaryField, SummarySchema}; use std::sync::Arc; fn error(error: impl std::fmt::Display) -> ClickHouseRelationalError { ClickHouseRelationalError::Invalid(error.to_string()) @@ -62,6 +59,12 @@ pub(super) fn cell(value: &Value) -> Result { _ => return Err(error("native value has no ClickHouse result transport")), }) } +#[cfg(test)] +use planner_types::post_asap::{ + ExecutableDagNode, ExecutableOperatorPayload, ExecutionDataState, PostAsapNodeId, +}; + +#[cfg(test)] pub(crate) fn execute( payload: ExecutableOperatorPayload, output: &SummarySchema, diff --git a/data_plane/src/query_engines/asap_query_engine/post_asap_readout.rs b/data_plane/src/query_engines/asap_query_engine/post_asap_readout.rs index 023318b70..0b33dbb80 100644 --- a/data_plane/src/query_engines/asap_query_engine/post_asap_readout.rs +++ b/data_plane/src/query_engines/asap_query_engine/post_asap_readout.rs @@ -355,6 +355,7 @@ impl PhysicalQueryRuntime<'_> { QueryPlanNode::Logical { .. } | QueryPlanNode::Relational { .. } | QueryPlanNode::ExternalExact { .. } + | QueryPlanNode::PhysicalRelation { .. } | QueryPlanNode::PhysicalFragment { .. } | QueryPlanNode::RelationalJoin { .. } => Err(PhysicalNodeError::Fallback( "logical node requires installed logical runtime".into(), From ae362bd315b0b2014dff762889d046c12b9736d4 Mon Sep 17 00:00:00 2001 From: zzylol Date: Tue, 29 Sep 2026 03:14:50 +0000 Subject: [PATCH 03/19] Execute native TopK candidates over bound stored outputs Adapt native candidate installation and persistence from ceba1e56 on the test stack. Preserve SQL compiled graphs and request revision pinning from main. Share request memory and cancellation contexts, and preserve native storage resource errors instead of treating them as capability misses. --- control_plane/Cargo.toml | 2 +- control_plane/src/main.rs | 74 +- control_plane/src/physical/compiler.rs | 1314 ++++++++++++++--- .../src/physical/compiler/windows.rs | 17 +- .../src/physical/executable_binding.rs | 1 + .../src/physical/maintained_population.rs | 142 +- control_plane/src/physical/plan_dot.rs | 47 + control_plane/src/physical/workload_cost.rs | 355 ++++- control_plane/src/planner_selection.rs | 46 +- control_plane/src/query_plan.rs | 12 +- control_plane/src/query_plan/query_time.rs | 7 +- control_plane/tests/discovery_snapshot.rs | 2 +- control_plane/tests/native_rate_topk.rs | 278 ++++ control_plane/tests/native_snapshot_topk.rs | 135 ++ crates/asap_types/src/executable_plan.rs | 64 +- crates/asap_types/src/precompute_plan.rs | 101 +- .../asap_types/src/precompute_plan/catalog.rs | 2 +- crates/asap_types/src/query_plan.rs | 47 +- .../src/query_plan/current_series.rs | 10 +- crates/asap_types/src/query_plan/native.rs | 327 ++++ .../precompute_engine/maintenance_runtime.rs | 48 +- data_plane/src/precompute_engine/mod.rs | 1 + .../precompute_engine/native_precompute.rs | 134 ++ .../src/precompute_engine/partitioning.rs | 20 +- .../accelerator.rs | 6 +- .../asap_clickhouse_query_engine/execution.rs | 1 + .../query_engines/asap_query_engine/engine.rs | 55 +- .../asap_query_engine/exact_subqueries.rs | 4 +- .../asap_query_engine/live_serve.rs | 1 + .../asap_query_engine/logical_dag.rs | 14 +- .../logical_dag/native_values.rs | 308 +++- .../asap_query_engine/post_asap_readout.rs | 10 +- .../asap_query_engine/request_tests.rs | 1 + .../asap_query_engine/test_plan.rs | 1 + data_plane/src/query_engines/request.rs | 7 +- .../sketch_db/current_series.rs | 10 + .../sketch_db/index/maintenance.rs | 45 +- .../storage_engines/sketch_db/index/mod.rs | 2 + .../storage_engines/sketch_db/index/native.rs | 702 +++++++++ .../sketch_db/persistence/cache.rs | 4 +- .../sketch_db/persistence/part.rs | 122 +- .../types/hot_reload_config.rs | 4 + data_plane/tests/support/physical_fixture.rs | 1 + 43 files changed, 4131 insertions(+), 353 deletions(-) create mode 100644 control_plane/tests/native_rate_topk.rs create mode 100644 control_plane/tests/native_snapshot_topk.rs create mode 100644 crates/asap_types/src/query_plan/native.rs create mode 100644 data_plane/src/precompute_engine/native_precompute.rs create mode 100644 data_plane/src/storage_engines/sketch_db/index/native.rs diff --git a/control_plane/Cargo.toml b/control_plane/Cargo.toml index 63b866090..4004000bc 100644 --- a/control_plane/Cargo.toml +++ b/control_plane/Cargo.toml @@ -15,7 +15,7 @@ path = "src/main.rs" tokio = { version = "1", features = ["full"] } axum = { version = "0.7", features = ["ws"] } futures-util = "0.3" -serde = { version = "1", features = ["derive"] } +serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" sha2 = "0.10" serde_yaml = "0.9" diff --git a/control_plane/src/main.rs b/control_plane/src/main.rs index fae7acbb2..74a185f00 100644 --- a/control_plane/src/main.rs +++ b/control_plane/src/main.rs @@ -204,8 +204,12 @@ struct CompileAndPublishPhysicalPlanRequest { target_collector_ids: Vec, capability_snapshot_id: String, #[serde(default)] + data_snapshot_id: Option, + #[serde(default)] evidence: HashMap, #[serde(default)] + accuracy_evidence: HashMap, + #[serde(default)] exact_composition_costs: HashMap>, #[serde(default)] @@ -378,7 +382,7 @@ async fn compile_and_publish_physical_plan( Json(CompileAndPublishPhysicalPlanResponse { cost_comparison: bundle.cost_comparison, - planner_selection_trace: bundle.planner_selection_trace, + planner_selection_trace: bundle.planner_selection_trace.as_ref().clone(), plan_id: bundle.envelope.plan_id, plan_version: bundle.envelope.plan_version, status: "active", @@ -589,7 +593,7 @@ fn compile_physical_plan_request( legacy_query_source: planner_types::pre_asap::Source::TimeSeries { metric: query.metric, }, - query_lookback_seconds: query.window_secs, + query_lookback_ms: query.window_secs.saturating_mul(1_000), group_by_labels: query.group_by, accuracy_target: query.accuracy, summary_lifecycle_inputs: query.lifecycle, @@ -598,29 +602,69 @@ fn compile_physical_plan_request( }); } - let planner_selection_trace = match physical::compiler::select_logical_roots_with_trace( - &mut queries, - canonical_roots.clone(), - &request.evidence, - &request.exact_composition_costs, - request.erp.as_ref(), - ) { - Ok(trace) => trace, - Err(error) => return Err((StatusCode::UNPROCESSABLE_ENTITY, error.to_string().into())), - }; + let scoped_snapshot_id = request.data_snapshot_id.as_deref().or_else(|| { + request + .workload_cost_evidence + .as_ref() + .map(|evidence| evidence.data_snapshot_id.as_str()) + }); + if request.data_snapshot_id.as_ref().is_some_and(|id| { + request + .workload_cost_evidence + .as_ref() + .is_some_and(|evidence| evidence.data_snapshot_id != *id) + }) { + return Err(( + StatusCode::UNPROCESSABLE_ENTITY, + "accuracy evidence data snapshot differs from workload cost evidence".into(), + )); + } + for (query_id, evidence) in &request.accuracy_evidence { + let Some(query) = queries.iter().find(|query| &query.query_id == query_id) else { + return Err(( + StatusCode::UNPROCESSABLE_ENTITY, + format!("accuracy evidence names unknown query {query_id}").into(), + )); + }; + evidence + .validate( + query_id, + &query.query_string, + &request.data_workload, + scoped_snapshot_id, + now, + request.max_evidence_age_ms, + ) + .map_err(|error| (StatusCode::UNPROCESSABLE_ENTITY, error.to_string().into()))?; + } + let planner_selection_trace = + match physical::compiler::select_logical_roots_with_scoped_evidence_and_trace( + &mut queries, + canonical_roots.clone(), + &request.evidence, + &request.accuracy_evidence, + &request.exact_composition_costs, + request.erp.as_ref(), + now, + ) { + Ok(trace) => trace, + Err(error) => return Err((StatusCode::UNPROCESSABLE_ENTITY, error.to_string().into())), + }; for (query, model) in queries.iter_mut().zip(window_models) { physical::compiler::prepare_window_implementations(query, &model, request.target, 0) .map_err(|error| (StatusCode::UNPROCESSABLE_ENTITY, error.to_string().into()))?; } let compilation_request = physical::compiler::PhysicalCompilationRequest { - planner_selection_trace, + planner_candidate_forests: Vec::new(), + planner_selection_trace: planner_selection_trace.into(), query_workload: Some(query_workload), data_workload: Some(request.data_workload), canonical_roots, queries, allow_mixed_summary_and_exact_execution: request.target == physical::compiler::PhysicalDeploymentTarget::BackendLocalRemoteWrite, + require_backend_local_execution: false, enabled_materialization_keys: None, topk_membership_evidence_by_query_id: request.evidence, exact_composition_costs: request.exact_composition_costs, @@ -646,7 +690,7 @@ fn compile_physical_plan_request( compilation_request.clone(), ) .map_err(|error| (StatusCode::UNPROCESSABLE_ENTITY, error.to_string().into()))?; - let planner_selection_trace = compilation_request.planner_selection_trace.clone(); + let planner_selection_trace = compilation_request.planner_selection_trace.as_ref().clone(); let (manifests, candidate_evaluations) = physical::workload_cost::compile_candidates_for_pricing( candidates.clone(), @@ -929,7 +973,7 @@ mod api_tests { "target": "backend_local_remote_write", "queries": [{ "query_id": query.query_id, "query_string": query.query_string, - "metric": metric, "window_secs": query.query_lookback_seconds, "accuracy": query.accuracy_target, + "metric": metric, "window_secs": query.query_lookback_ms / 1_000, "accuracy": query.accuracy_target, "lifecycle": query.summary_lifecycle_inputs, "evaluation_phase_ms": 0, "window_cost_model": { "implementation_id": "test", "cost": query.window_realization_candidates[0].cost } }], "dataset_identity": snapshot.environment.dataset_identity, diff --git a/control_plane/src/physical/compiler.rs b/control_plane/src/physical/compiler.rs index d90199c2a..d2ebaf864 100644 --- a/control_plane/src/physical/compiler.rs +++ b/control_plane/src/physical/compiler.rs @@ -71,7 +71,7 @@ pub struct QueryCompilationInput { /// materialization sources are derived from each post-ASAP SummaryAgg; /// it also remains part of the cost manifest workload identity. pub legacy_query_source: Source, - pub query_lookback_seconds: u64, + pub query_lookback_ms: u64, /// Label names are deployment metadata because Planner's canonical IR /// currently carries positional column IDs at this boundary. pub group_by_labels: Vec, @@ -154,10 +154,15 @@ pub struct SummaryLifecyclePlanningInputs { #[derive(Debug, Clone, Default)] pub struct PhysicalCompilationRequest { - /// Diagnostic projections of the original Planner search; never consumed by selection. - pub planner_selection_trace: Vec, + /// Complete Planner candidates retained until deployment admission/pricing. + /// Empty for callers that explicitly supply one already-selected forest. + pub planner_candidate_forests: Vec>, + /// Immutable search diagnostics shared across candidates; never consumed by selection. + pub planner_selection_trace: std::sync::Arc>, /// Enable a composable DAG with SummaryStore materializations and Prometheus exact subtrees. pub allow_mixed_summary_and_exact_execution: bool, + /// Deployment feasibility: external exact dependencies cannot be bound. + pub require_backend_local_execution: bool, /// Enabled optional candidate keys: None enables all eligible keys; an /// explicitly empty set enables none. These are not catalog definition IDs. pub enabled_materialization_keys: Option>, @@ -165,7 +170,7 @@ pub struct PhysicalCompilationRequest { pub query_workload: Option, /// Source evidence supplied independently from query demand. pub data_workload: Option, - /// Workload-lowered roots retained across physical alternative enumeration. + /// Workload-lowered roots retained across physical candidate enumeration. pub canonical_roots: Vec>, pub queries: Vec, pub topk_membership_evidence_by_query_id: HashMap, @@ -196,6 +201,135 @@ pub struct TopKMembershipEvidence { pub source: String, } +/// A proof for one exact Planner operand, including the enforced source +/// domain rather than an observed sample minimum or maximum. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct QuantileOperandDomainEvidence { + pub operand: Value, + pub lower: f64, + pub upper: f64, + pub max_samples: u64, + pub contract: String, +} + +/// Optional accuracy facts bound to one registered query and data snapshot. +/// Missing fields stay unknown to Planner. The data workload and snapshot +/// identity prevent reusing a proof for a different source population. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ScopedAccuracyEvidence { + pub query_string: String, + pub data_snapshot_id: String, + pub data_workload: DataWorkload, + pub source: String, + pub observed_at_unix_ms: u64, + pub valid_for_ms: u64, + #[serde(default)] + pub quantile_operand_domains: Vec, + #[serde(default)] + pub values_non_negative: Option, + #[serde(default)] + pub input_row_count: Option, + #[serde(default)] + pub hydra_shared_grid_collision_bound: Option, + #[serde(default)] + pub hydra_shared_grid_failure_probability: Option, + /// Enforced upper bound across all partition/item identities for this query + /// snapshot. An observed cardinality estimate is not this contract. + #[serde(default)] + pub topk_max_distinct_items: Option, + #[serde(default)] + pub topk_selected_lower_bound: Option, + #[serde(default)] + pub topk_excluded_upper_bound: Option, + #[serde(default)] + pub topk_interval_failure_probability: Option, +} + +impl ScopedAccuracyEvidence { + pub fn validate( + &self, + query_id: &str, + query_string: &str, + data: &DataWorkload, + snapshot_id: Option<&str>, + now_ms: u64, + max_age_ms: u64, + ) -> Result<(), CompileError> { + let valid_scope = self.query_string == query_string + && &self.data_workload == data + && snapshot_id.is_some_and(|id| id == self.data_snapshot_id) + && !self.data_snapshot_id.trim().is_empty() + && !self.source.trim().is_empty(); + let valid_time = self.observed_at_unix_ms <= now_ms + && self.valid_for_ms > 0 + && now_ms - self.observed_at_unix_ms <= self.valid_for_ms.min(max_age_ms); + let topk_bounds = ( + self.topk_selected_lower_bound, + self.topk_excluded_upper_bound, + self.topk_interval_failure_probability, + ); + let valid_topk = match topk_bounds { + (None, None, None) => true, + (Some(lower), Some(upper), Some(failure)) => { + lower.is_finite() + && upper.is_finite() + && lower > upper + && (0.0..=1.0).contains(&failure) + } + _ => false, + }; + let valid_stats = valid_topk + && self + .topk_max_distinct_items + .is_none_or(|n| n > 0 && n <= (1_u64 << 53)) + && self.input_row_count != Some(0) + && self + .hydra_shared_grid_collision_bound + .is_none_or(|v| v.is_finite() && v >= 0.0) + && self + .hydra_shared_grid_failure_probability + .is_none_or(|v| (0.0..=1.0).contains(&v)) + && self + .quantile_operand_domains + .iter() + .enumerate() + .all(|(index, domain)| { + domain.lower.is_finite() + && domain.upper.is_finite() + && domain.lower <= domain.upper + && (1..=(1u64 << 53)).contains(&domain.max_samples) + && !domain.contract.trim().is_empty() + && !self.quantile_operand_domains[..index] + .iter() + .any(|earlier| earlier.operand == domain.operand) + }); + if valid_scope && valid_time && valid_stats { + return Ok(()); + } + let reason = if self.query_string != query_string { + "accuracy evidence belongs to a different query" + } else if &self.data_workload != data { + "accuracy evidence belongs to a different data workload" + } else if !snapshot_id.is_some_and(|id| id == self.data_snapshot_id) + || self.data_snapshot_id.trim().is_empty() + { + "accuracy evidence belongs to a different or unspecified data snapshot" + } else if self.source.trim().is_empty() { + "accuracy evidence has no provenance source" + } else if !valid_time { + "accuracy evidence is expired, future-dated, or has no validity window" + } else { + "accuracy evidence contains invalid or ambiguous bounds" + }; + Err(CompileError::InvalidEvidence { + query_id: query_id.into(), + reason: reason.into(), + }) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct PhysicalDeploymentContext { @@ -241,7 +375,6 @@ pub struct BackendLocalPlanningInput { /// Must equal [`WORKLOAD_SNAPSHOT_VERSION`]. #[serde(rename = "snapshot_version")] pub schema_version: u32, - /// May be absent during candidate discovery, never during deployment. #[serde(default, skip_serializing_if = "Option::is_none")] pub workload_cost_evidence: Option, #[serde(deserialize_with = "deserialize_snapshot_query_workload")] @@ -255,6 +388,9 @@ pub struct BackendLocalPlanningInput { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct BackendLocalPhysicalInputs { + /// Constrain selection to local execution when no exact upstream is available. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub require_backend_local_execution: bool, pub lifecycle_costs: LifecycleUnitCosts, pub evidence_observed_at_unix_ms: u64, pub evidence_valid_for_ms: u64, @@ -279,6 +415,12 @@ pub struct BackendLocalPhysicalInputs { /// before workload selection so one query cannot borrow another's evidence. #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub topk_evidence: HashMap, + /// Data generation used by scoped accuracy certificates during candidate + /// discovery, before complete workload quotes are available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data_snapshot_id: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub accuracy_evidence: HashMap, /// Measured exact/summary composition profiles keyed by registered /// PromQL. Missing rows keep the corresponding Planner site opaque. #[serde(default, skip_serializing_if = "HashMap::is_empty")] @@ -480,7 +622,7 @@ pub struct CompiledPhysicalPlan { /// Lifecycle component only, not a complete physical-plan comparison. pub lifecycle_estimates: Vec, pub cost_comparison: Option, - pub planner_selection_trace: Vec, + pub planner_selection_trace: std::sync::Arc>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -499,7 +641,7 @@ pub struct MaterializationLifecycleEstimate { pub enum CompileError { #[error("invalid backend-local workload snapshot: {0}")] Snapshot(String), - #[error("no feasible completely costed alternative: {0}")] + #[error("no feasible completely costed candidate: {0}")] Candidates(serde_json::Value), #[error("planner revision mismatch: request={request}, compiler={compiler}")] PlannerRevision { @@ -518,24 +660,79 @@ pub enum CompileError { QueryPlan(#[from] crate::query_plan::QueryPlanError), } -struct QueryEvidence<'a>(Option<&'a TopKMembershipEvidence>); +struct QueryEvidence<'a> { + topk: Option<&'a TopKMembershipEvidence>, + scoped: Option<&'a ScopedAccuracyEvidence>, + now_ms: u64, +} impl AccuracyEvidenceProvider for QueryEvidence<'_> { + fn topk_max_distinct_items(&self, _: &QueryExpr) -> Option { + self.scoped + .and_then(|evidence| evidence.topk_max_distinct_items) + } + fn quantile_input_domain( + &self, + operand: &QueryExpr, + ) -> Option { + let operand = serde_json::to_value(operand).ok()?; + let domain = self + .scoped? + .quantile_operand_domains + .iter() + .find(|entry| entry.operand == operand)?; + Some(asap_aware_mapping::accuracy::QuantileInputDomain { + lower: domain.lower, + upper: domain.upper, + max_samples: domain.max_samples, + contract: domain.contract.clone(), + }) + } + fn propagation_stats( &self, op: &CompositionOperator, _family: &SummaryFamilyType, _query: Option<&SketchQuery>, ) -> PropagationStats { - match (op, self.0) { - (CompositionOperator::TopKSelection, Some(e)) => PropagationStats { - topk_selected_lower_bound: Some(e.selected_lower_bound), - topk_excluded_upper_bound: Some(e.excluded_upper_bound), - topk_interval_failure_probability: Some(e.interval_failure_probability), + let mut stats = self + .scoped + .map_or_else(PropagationStats::default, |evidence| PropagationStats { + values_non_negative: evidence.values_non_negative, + input_row_count: evidence.input_row_count.or_else(|| { + evidence + .data_workload + .input_cardinality + .value_at(self.now_ms) + .copied() + }), + data_distribution: evidence + .data_workload + .distribution + .value_at(self.now_ms) + .cloned(), + hydra_shared_grid_collision_bound: evidence.hydra_shared_grid_collision_bound, + hydra_shared_grid_failure_probability: evidence + .hydra_shared_grid_failure_probability, ..Default::default() - }, - _ => PropagationStats::default(), + }); + if matches!(op, CompositionOperator::TopKSelection) { + if let Some(evidence) = self + .scoped + .filter(|e| e.topk_selected_lower_bound.is_some()) + { + stats.topk_selected_lower_bound = evidence.topk_selected_lower_bound; + stats.topk_excluded_upper_bound = evidence.topk_excluded_upper_bound; + stats.topk_interval_failure_probability = + evidence.topk_interval_failure_probability; + } else if let Some(evidence) = self.topk { + stats.topk_selected_lower_bound = Some(evidence.selected_lower_bound); + stats.topk_excluded_upper_bound = Some(evidence.excluded_upper_bound); + stats.topk_interval_failure_probability = + Some(evidence.interval_failure_probability); + } } + stats } } @@ -595,6 +792,29 @@ impl BackendLocalPlanningInput { } let workload = self.query_workload; let mut data_workload = self.data_workload.clone(); + let scoped_snapshot_id = self + .physical_inputs + .data_snapshot_id + .as_deref() + .or_else(|| { + self.workload_cost_evidence + .as_ref() + .map(|evidence| evidence.data_snapshot_id.as_str()) + }); + if self + .physical_inputs + .data_snapshot_id + .as_ref() + .is_some_and(|id| { + self.workload_cost_evidence + .as_ref() + .is_some_and(|evidence| evidence.data_snapshot_id != *id) + }) + { + return Err(CompileError::Snapshot( + "accuracy evidence data snapshot differs from workload cost evidence".into(), + )); + } if data_workload.data_ingestion_interval.value.is_none() && self.physical_inputs.scrape_interval_ms > 0 { @@ -640,6 +860,7 @@ impl BackendLocalPlanningInput { let mut queries = Vec::with_capacity(entries.len()); let mut canonical_roots = Vec::with_capacity(entries.len()); let mut topk_evidence_by_id = HashMap::new(); + let mut scoped_evidence_by_id = HashMap::new(); for (index, (entry, parsed)) in entries.into_iter().zip(canonical_queries).enumerate() { let evaluation_interval_ms = match entry.recurrence { QueryRecurrence::Repeated(RepeatedDemand::FixedIntervalAt { @@ -651,14 +872,9 @@ impl BackendLocalPlanningInput { ))) } }; - if self.physical_inputs.scrape_interval_ms == 0 - || !self - .physical_inputs - .scrape_interval_ms - .is_multiple_of(1_000) - { + if self.physical_inputs.scrape_interval_ms == 0 { return Err(CompileError::Snapshot( - "scrape_interval_ms must be a positive whole number of seconds".into(), + "scrape_interval_ms must be positive".into(), )); } let accuracy = entry.requirements.accuracy.target(); @@ -686,6 +902,17 @@ impl BackendLocalPlanningInput { if let Some(evidence) = self.physical_inputs.topk_evidence.get(&query_string) { topk_evidence_by_id.insert(query_id.clone(), evidence.clone()); } + if let Some(evidence) = self.physical_inputs.accuracy_evidence.get(&query_string) { + evidence.validate( + &query_id, + &query_string, + &data_workload, + scoped_snapshot_id, + self.environment.observed_at_unix_ms, + self.environment.max_evidence_age_ms, + )?; + scoped_evidence_by_id.insert(query_id.clone(), evidence.clone()); + } queries.push(QueryCompilationInput { query_id, query_string: query_string.clone(), @@ -693,7 +920,7 @@ impl BackendLocalPlanningInput { legacy_query_source: Source::TimeSeries { metric: source_hint, }, - query_lookback_seconds: lookback_ms / 1_000, + query_lookback_ms: lookback_ms, group_by_labels: metadata.group_by_labels, accuracy_target: accuracy, summary_lifecycle_inputs: lifecycle, @@ -701,6 +928,16 @@ impl BackendLocalPlanningInput { materialization_runtime_policy: RuntimeRulePolicy::default(), }); } + if self + .physical_inputs + .accuracy_evidence + .keys() + .any(|query| !queries.iter().any(|entry| &entry.query_string == query)) + { + return Err(CompileError::Snapshot( + "accuracy evidence names a query absent from this workload".into(), + )); + } let mut exact_costs_by_id = HashMap::new(); for (index, entry) in workload.entries().enumerate() { if let Some(rows) = self @@ -722,13 +959,95 @@ impl BackendLocalPlanningInput { exact_costs_by_id.insert(format!("compat-query-{index}"), rows.clone()); } } - let planner_selection_trace = select_logical_roots_with_trace( + let mut candidate_roots = Vec::new(); + let mut planner_selection_trace = logical_roots_and_candidates( &mut queries, canonical_roots.clone(), &topk_evidence_by_id, + &scoped_evidence_by_id, &exact_costs_by_id, self.physical_inputs.erp.as_ref(), + self.environment.observed_at_unix_ms, + Some(&mut candidate_roots), )?; + // Resolve physical row identity before asking Planner for snapshot heap + // candidates. Keep canonical query semantics and exact candidates intact. + for (index, (query, root)) in queries.iter().zip(&canonical_roots).enumerate() { + let Ok(typed) = + asap_physical_operators::physical_planner::promql_rows::with_series_identity(root) + else { + continue; + }; + let evidence = QueryEvidence { + topk: topk_evidence_by_id.get(&query.query_id), + scoped: scoped_evidence_by_id.get(&query.query_id), + now_ms: self.environment.observed_at_unix_ms, + }; + let strategy = + asap_aware_mapping::SketchAlgorithmStrategy::new_with_planning_inputs_and_evidence( + &asap_aware_mapping::cost_model::DefaultCostModel, + &asap_aware_mapping::accuracy::DefaultAccuracyModel, + &asap_aware_mapping::accuracy::EqualSplitAllocator, + &evidence, + ); + let typed = Rc::new(typed); + let mut proposed = + strategy.current_series_topk_candidates(&typed, &query.accuracy_target); + let direct = asap_aware_mapping::ReplacementStrategy::propose( + &strategy, + &asap_aware_mapping::TargetSubDAG::new(&typed), + ); + proposed + .candidates + .extend(strategy.fixed_window_rate_candidates(&typed).candidates); + proposed.candidates.extend( + strategy + .query_time_rate_aggregation_candidates(&typed) + .candidates, + ); + proposed.candidates.extend(direct.candidates); + proposed.rejected.extend(direct.rejected); + for candidate in proposed.candidates { + let asap_aware_mapping::Replacement::Summary(root) = candidate.replacement else { + continue; + }; + let root = asap_aware_mapping::replacement::finalize_query_candidate(root, &typed) + .map_err(|error| CompileError::Snapshot(error.to_string()))?; + let compiled = asap_physical_operators::physical_planner::promql_rows::compile_current_series_readout(&root) + .or_else(|_| asap_physical_operators::physical_planner::promql_rows::compile_rate_ranking(&root).map(|(_, program)| program)); + if let Ok(physical) = asap_physical_operators::physical_planner::promql_rows::compile_fixed_window_rate_aggregation(&root) { + planner_selection_trace.push(serde_json::json!({ + "stage":"planner.physical_candidate", "query_id":query.query_id, + "logical_root_id":crate::planner_selection::explained_root_id(&root, &query.accuracy_target), + "rationale":candidate.rationale, "physical_candidate":serde_json::from_slice::(&physical.encode().map_err(|e| CompileError::Snapshot(e.to_string()))?).map_err(|e| CompileError::Snapshot(e.to_string()))?, + "guarantee":root.guarantee, + })); + candidate_roots.push(vec![(index, root)]); + continue; + } + match compiled { + Ok(program) => { + planner_selection_trace.push(serde_json::json!({ + "stage": "planner.physical_candidate", "query_id": query.query_id, + "logical_root_id": crate::planner_selection::explained_root_id(&root, &query.accuracy_target), + "rationale": candidate.rationale, "physical_dag": serde_json::from_slice::(&program.encode().map_err(|error| CompileError::Snapshot(error.to_string()))?).map_err(|error| CompileError::Snapshot(error.to_string()))?, + "guarantee": root.guarantee, + })); + candidate_roots.push(vec![(index, root)]); + } + Err(error) => planner_selection_trace.push(serde_json::json!({ + "stage": "planner.physical_candidate", "query_id": query.query_id, + "status": "unsupported", "reason": error.to_string(), + })), + } + } + for rejected in proposed.rejected { + planner_selection_trace.push(serde_json::json!({ + "stage": "planner.physical_candidate", "query_id": query.query_id, + "status": "rejected", "reason": rejected.error.to_string(), + })); + } + } for query in &mut queries { prepare_window_implementations( query, @@ -737,11 +1056,43 @@ impl BackendLocalPlanningInput { self.physical_inputs.query_retention_margin_ms, )?; } - // Composable lowering defers unsafe leaves to query time individually; retain Planner siblings. + let mut planner_candidate_forests = Vec::new(); + for roots in candidate_roots { + let mut forest = queries.clone(); + let changed: BTreeSet<_> = roots.iter().map(|(index, _)| *index).collect(); + for (index, root) in roots { + forest[index].selected_plan_root = root; + } + // Unchanged roots already have prepared windows in `queries`. + let preparation = changed.into_iter().try_for_each(|index| { + prepare_window_implementations( + &mut forest[index], + &self.physical_inputs.window_cost_model, + self.environment.target, + self.physical_inputs.query_retention_margin_ms, + ) + }); + if let Err(error) = preparation { + planner_selection_trace.push(serde_json::json!({ + "stage": "deployment.window_feasibility", + "status": "rejected", + "logical_root_ids": forest.iter().map(|query| crate::planner_selection::explained_root_id( + &query.selected_plan_root, &query.accuracy_target)).collect::>(), + "reason": error.to_string(), + })); + continue; + } + planner_candidate_forests.push(forest); + } + // Composable lowering residualizes unsafe leaves individually; retain Planner siblings. Ok(( PhysicalCompilationRequest { - planner_selection_trace, + planner_candidate_forests, + planner_selection_trace: planner_selection_trace.into(), allow_mixed_summary_and_exact_execution: true, + require_backend_local_execution: self + .physical_inputs + .require_backend_local_execution, enabled_materialization_keys: None, query_workload: Some(workload), data_workload: Some(data_workload), @@ -1064,6 +1415,7 @@ impl DeploymentPlanCompiler { for (id, root) in planner_types::post_asap::share_common_summary_subtrees(roots) { request.queries[id].selected_plan_root = root; } + let population_operators = super::maintained_population::operators(&request)?; let mut compiled_materializations = Vec::with_capacity(request.queries.len()); let mut collector_materializations = Vec::with_capacity(request.queries.len()); let mut plan_materializations = Vec::with_capacity(request.queries.len()); @@ -1100,15 +1452,22 @@ impl DeploymentPlanCompiler { validate_evidence(&query.query_id, e, &environment)?; } let node = query.selected_plan_root.clone(); - reject_uncertified_readouts(&query.query_id, &node)?; - let selected = collect_selected_materializations( - &node, - request.allow_mixed_summary_and_exact_execution, - ) - .map_err(|reason| CompileError::Query { - query_id: query.query_id.clone(), - reason, - })?; + reject_uncertified_readouts(&query.query_id, &node, &query.accuracy_target)?; + let selected = if super::maintained_population::supported_node(&node) { + Vec::new() + } else { + collect_selected_materializations( + &node, + request.allow_mixed_summary_and_exact_execution, + ) + .map_err(|reason| CompileError::Query { + query_id: query.query_id.clone(), + reason, + })? + }; + if !selected.is_empty() { + reject_uncertified_readouts(&query.query_id, &node, &query.accuracy_target)?; + } let selected = selected .into_iter() .filter(|state| { @@ -1156,16 +1515,14 @@ impl DeploymentPlanCompiler { .flatten() }); // Masks enumerate counter/max choices only. Other selected - // summaries remain required by this physical alternative. + // summaries remain required by this physical candidate. key.is_none_or(|key| policy.contains(&key)) }) }) .collect::>(); // An exact native fallback has no maintained state and must not // depend on evidence for unused window/state implementations. - if selected.is_empty() - && super::maintained_population::operator(&request, query)?.is_none() - { + if selected.is_empty() && population_operators[query_index].is_none() { continue; } let executable = planner_types::post_asap::compile_executable_dag_with_node_ids( @@ -1214,7 +1571,7 @@ impl DeploymentPlanCompiler { for state in &selected { if matches!(&state.node.expr, SummaryExpr::SummaryAgg { reduction: planner_types::pre_asap::Reduction::Reduce(keys), .. - } if keys.is_empty()) + } if keys.is_empty()) || asap_physical_operators::physical_planner::promql_rows::compile_fixed_window_rate_aggregation(&query.selected_plan_root).is_ok() { if let Some(sources) = immutable_materialization_sources(&state.node) { canonical_nodes.insert(Rc::as_ptr(&state.node) as usize); @@ -1225,10 +1582,13 @@ impl DeploymentPlanCompiler { } } let cohort_nodes = windows::cohort_nodes(&selected); + let native_cohort = asap_physical_operators::physical_planner::promql_rows::compile_fixed_window_rate_aggregation(&query.selected_plan_root).is_ok(); for (ordinal, selected) in selected.into_iter().enumerate() { let mut branch_query = query.clone(); - branch_query.query_lookback_seconds = - selected.window_secs.unwrap_or(query.query_lookback_seconds); + branch_query.query_lookback_ms = selected + .window_secs + .map(|seconds| seconds.saturating_mul(1_000)) + .unwrap_or(query.query_lookback_ms); branch_query.group_by_labels = selected .group_by .clone() @@ -1236,9 +1596,20 @@ impl DeploymentPlanCompiler { branch_query .window_realization_candidates .retain(|candidate| { - candidate.window_secs == branch_query.query_lookback_seconds + candidate.window_secs.saturating_mul(1_000) + == branch_query.query_lookback_ms && if cohort_nodes.contains(&(Rc::as_ptr(&selected.node) as usize)) { - windows::is_full_cohort(candidate) + if native_cohort { + windows::is_complete_window(candidate) + && candidate.slide_secs.saturating_mul(1000) + == u64::from( + query + .summary_lifecycle_inputs + .evaluation_interval_ms, + ) + } else { + windows::is_full_cohort(candidate) + } } else { !candidate.cohort_only } @@ -1323,7 +1694,7 @@ impl DeploymentPlanCompiler { let cadence = u64::from( query.summary_lifecycle_inputs.evaluation_interval_ms, ); - let window = query.query_lookback_seconds.saturating_mul(1_000); + let window = query.query_lookback_ms; a % cadence == b % cadence && a % window == b % window } _ => index == query_index, @@ -1454,7 +1825,9 @@ impl DeploymentPlanCompiler { query_id: query.query_id.clone(), reason: error.to_string(), })?; - if runtime_materialization.window_size != runtime_materialization.slide_interval + if !native_cohort + && runtime_materialization.window_size + != runtime_materialization.slide_interval { return Err(CompileError::Query { query_id: query.query_id.clone(), reason: "immutable scalar composition runtime requires nonoverlapping windows".into() }); @@ -1745,7 +2118,7 @@ impl DeploymentPlanCompiler { let window_ms = materialization.window_size.saturating_mul(1_000); if materialization_family != *node_family || window_ms == 0 - || source_window.unwrap_or(query.query_lookback_seconds).saturating_mul(1_000) + || source_window.map(|seconds| seconds.saturating_mul(1_000)).unwrap_or(query.query_lookback_ms) % window_ms != 0 { return Err(crate::query_plan::QueryPlanError::Invalid(format!( @@ -1768,7 +2141,7 @@ impl DeploymentPlanCompiler { }) }; let instant = InstantExecution { - lookback_ms: query.query_lookback_seconds.saturating_mul(1_000), + lookback_ms: query.query_lookback_ms, full_history: false, cumulative_readout: true, }; @@ -1789,15 +2162,122 @@ impl DeploymentPlanCompiler { } else { false }; - let mut entry = if let Some(operator) = - super::maintained_population::operator(&request, query)? - { + let native_rate = + asap_physical_operators::physical_planner::promql_rows::compile_rate_ranking( + &query.selected_plan_root, + ) + .ok(); + let native_rate = native_rate.or_else(|| { + let physical = asap_physical_operators::physical_planner::promql_rows::compile_fixed_window_rate_aggregation(&query.selected_plan_root).ok()?; + fn heap(node: &Rc) -> Option> { + match &node.expr { + SummaryExpr::SummaryAgg { family: SummaryFamilyType::Sketch(..) | SummaryFamilyType::ExactAggregate(planner_types::post_asap::ExactKind::Sum, _), .. } => Some(node.clone()), + SummaryExpr::ValueOperation { child, .. } => heap(child), + SummaryExpr::SummaryEstimate { summary_input, .. } => heap(summary_input), + _ => None, + } + } + Some((heap(&query.selected_plan_root)?, physical.query)) + }); + let mut entry = if let Some((source, program)) = native_rate { + let native_state_binding = if let SummaryExpr::SummaryAgg { + family: + SummaryFamilyType::Sketch(..) + | SummaryFamilyType::ExactAggregate(planner_types::post_asap::ExactKind::Sum, _), + .. + } = &source.expr + { + let SummaryExpr::SummaryAgg { family, .. } = &source.expr else { + unreachable!() + }; + Some(binding(&source, family)?) + } else { + None + }; + let mut entry = if let Some(binding) = native_state_binding { + let root = crate::query_plan::QueryNodeId(0); + if let Some(id) = executable_dags[query_index] + .as_ref() + .and_then(|compiled| compiled.node_ids.node_id(&source)) + { + query_node_bindings.insert((query_index, id), root); + } + crate::query_plan::QueryPlanEntry { + physical_dag: None, + language: crate::query_plan::QueryLanguage::PromQl, + query_id: query.query_id.clone(), + canonical_query: canonical.clone(), + fixed_evaluation: None, + root, + nodes: BTreeMap::from([( + root, + crate::query_plan::QueryPlanNode::ReadMaterialization { binding }, + )]), + instant, + fallback: FallbackPolicy::ExactBackend, + } + } else { + crate::query_plan::compile_bound_composable_mapped( + query.query_id.clone(), + canonical.clone(), + &source, + instant, + FallbackPolicy::ExactBackend, + binding, + |node, query_node| { + if let Some(id) = executable_dags[query_index] + .as_ref() + .and_then(|compiled| compiled.node_ids.node_id(node)) + { + query_node_bindings.insert((query_index, id), query_node); + } + }, + )? + }; + let root = crate::query_plan::QueryNodeId( + entry.nodes.keys().map(|id| id.0).max().unwrap_or(0) + 1, + ); + let sources = program + .input_contracts() + .map(|(id, _)| id) + .collect::>(); + if sources.len() != 1 { + return Err(CompileError::Snapshot( + "Rate physical candidate requires one source binding".into(), + )); + } + entry.nodes.insert( + root, + crate::query_plan::QueryPlanNode::Physical { + inputs: vec![entry.root], + source_nodes: sources, + max_bytes: request + .retained_summary_memory_budget_bytes + .unwrap_or(64 * 1024 * 1024), + }, + ); + entry.root = root; + entry.physical_dag = Some( + serde_json::from_slice( + &program + .encode() + .map_err(|e| CompileError::Snapshot(e.to_string()))?, + ) + .map_err(|e| CompileError::Snapshot(e.to_string()))?, + ); + entry.recover_vector_physical_dag()?; + if let Some(compiled) = &executable_dags[query_index] { + query_node_bindings.insert((query_index, compiled.dag.root), root); + } + Ok(entry) + } else if let Some(operator) = population_operators[query_index].clone() { let root = crate::query_plan::QueryNodeId(0); let compiled = executable_dags[query_index] .as_ref() .expect("compiled Planner DAG"); query_node_bindings.insert((query_index, compiled.dag.root), root); Ok(crate::query_plan::QueryPlanEntry { + physical_dag: None, language: crate::query_plan::QueryLanguage::PromQl, query_id: query.query_id.clone(), canonical_query: canonical.clone(), @@ -1829,6 +2309,7 @@ impl DeploymentPlanCompiler { // Keep native semantics instead of lowering an unbound summary. let root = crate::query_plan::QueryNodeId(0); Ok(crate::query_plan::QueryPlanEntry { + physical_dag: None, language: crate::query_plan::QueryLanguage::PromQl, query_id: query.query_id.clone(), canonical_query: canonical.clone(), @@ -1888,6 +2369,10 @@ impl DeploymentPlanCompiler { if frontend == QueryFrontend::MetricsQl { entry.language = crate::query_plan::QueryLanguage::MetricsQl; } + super::maintained_population::install_native_topk( + &mut entry, + &query.selected_plan_root, + )?; let catalog_key = QueryPlan::catalog_key(entry.language, &canonical); if query_entries.insert(catalog_key, entry).is_some() { return Err(CompileError::Query { @@ -1913,7 +2398,7 @@ impl DeploymentPlanCompiler { .find(|entry| entry.query_id == query_id) .expect("compiled query entry exists") .root; - let installed = super::executable_binding::install_selected_dag( + let mut installed = super::executable_binding::install_selected_dag( query_id.clone(), &compiled.dag, query_plan_sink, @@ -1929,11 +2414,35 @@ impl DeploymentPlanCompiler { query_id: query_id.clone(), reason, })?; + if let Ok(physical) = asap_physical_operators::physical_planner::promql_rows::compile_fixed_window_rate_aggregation(&request.queries[query_index].selected_plan_root) { + let program = physical.precompute.ok_or_else(|| CompileError::Snapshot("fixed-window candidate has no maintenance program".into()))?; + let sink = PostAsapNodeId(u32::try_from(program.roots()[0]).map_err(|_| CompileError::Snapshot("maintenance sink overflow".into()))?); + installed.native_programs.insert(sink, serde_json::from_slice(&program.encode().map_err(|e| CompileError::Snapshot(e.to_string()))?).map_err(|e| CompileError::Snapshot(e.to_string()))?); + } query_plan .selected_dags .insert(query_id.clone(), installed.document.clone()); installed_dags.insert(query_id, installed); } + let maintenance_lookbacks = materializations + .iter() + .filter_map(|target| { + target + .derived_input + .as_ref() + .map(|input| (input, target.stored_window_ms())) + }) + .flat_map(|(input, window)| input.inputs.iter().map(move |source| (*source, window))) + .fold( + BTreeMap::::new(), + |mut windows, (source, window)| { + windows + .entry(source) + .and_modify(|old| *old = (*old).max(window)) + .or_insert(window); + windows + }, + ); for materialization in &mut materializations { let fingerprint = materialization.policy_fingerprint(); let max_lookback_ms = query_plan @@ -1942,6 +2451,7 @@ impl DeploymentPlanCompiler { .flat_map(QueryPlanEntry::materialization_bindings) .filter(|binding| binding.materialization.fingerprint() == fingerprint) .filter_map(|binding| binding.readout_lookback_ms) + .chain(maintenance_lookbacks.get(&fingerprint.into()).copied()) .max(); if let Some(lookback_ms) = max_lookback_ms { materialization.num_aggregates_to_retain = Some(retained_state_count( @@ -2075,6 +2585,20 @@ impl DeploymentPlanCompiler { reason: error.to_string(), })?; } + if request.require_backend_local_execution { + for entry in query_plan.entries.values() { + if entry.nodes.values().any(|node| matches!(node, + crate::query_plan::QueryPlanNode::ExactFallback { .. } + | crate::query_plan::QueryPlanNode::Logical { + operator: crate::query_plan::query_time::QueryTimeOperator::ExactSubquery { .. } + | crate::query_plan::query_time::QueryTimeOperator::CandidateExactSubquery { .. }, .. })) { + return Err(CompileError::Query { + query_id: entry.query_id.clone(), + reason: "external execution is unavailable in this deployment".into(), + }); + } + } + } query_plan.bind_catalog(&summary_catalog)?; let storage_routing = crate::emit::backend_wire::storage_routing_document( crate::emit::backend_wire::DEFAULT_TENANT, @@ -2150,7 +2674,7 @@ pub fn select_logical_roots_for_queries( select_logical_roots_with_error_resource_profiles(queries, roots, evidence, exact_costs, None) } -fn observed_population_matches_root( +pub(crate) fn observed_population_matches_root( policy: &super::erp::ErpPlanningInput, root: &QueryExpr, ) -> bool { @@ -2213,6 +2737,48 @@ pub fn select_logical_roots_with_trace( evidence: &HashMap, exact_costs: &HashMap>, erp: Option<&super::erp::ErpPlanningInput>, +) -> Result, CompileError> { + select_logical_roots_with_scoped_evidence_and_trace( + queries, + roots, + evidence, + &HashMap::new(), + exact_costs, + erp, + 0, + ) +} + +pub fn select_logical_roots_with_scoped_evidence_and_trace( + queries: &mut [QueryCompilationInput], + roots: Vec>, + evidence: &HashMap, + scoped_evidence: &HashMap, + exact_costs: &HashMap>, + erp: Option<&super::erp::ErpPlanningInput>, + now_ms: u64, +) -> Result, CompileError> { + logical_roots_and_candidates( + queries, + roots, + evidence, + scoped_evidence, + exact_costs, + erp, + now_ms, + None, + ) +} + +fn logical_roots_and_candidates( + queries: &mut [QueryCompilationInput], + roots: Vec>, + evidence: &HashMap, + scoped_evidence: &HashMap, + exact_costs: &HashMap>, + erp: Option<&super::erp::ErpPlanningInput>, + now_ms: u64, + mut candidates_out: Option<&mut Vec)>>>, ) -> Result, CompileError> { let mut traces = Vec::new(); if roots.len() != queries.len() { @@ -2226,6 +2792,7 @@ pub fn select_logical_roots_with_trace( for (index, root) in roots.into_iter().enumerate() { let accuracy = &queries[index].accuracy_target; let certificate_scope = (evidence.contains_key(&queries[index].query_id) + || scoped_evidence.contains_key(&queries[index].query_id) || exact_costs.contains_key(&queries[index].query_id)) .then(|| queries[index].query_id.clone()); if let Some((_, _, roots)) = cohorts @@ -2279,6 +2846,7 @@ pub fn select_logical_roots_with_trace( model = model.with_erp(erp.clone()); } let certificate = scope.as_ref().and_then(|id| evidence.get(id)); + let scoped_certificate = scope.as_ref().and_then(|id| scoped_evidence.get(id)); let accuracy_model = super::erp::ErpAccuracyModel { policy: erp, max_error: match accuracy { @@ -2286,15 +2854,56 @@ pub fn select_logical_roots_with_trace( AccuracyTarget::Exact => 0.0, }, }; + let mut candidate_assembly_rejections = Vec::new(); + if let Some(output) = candidates_out.as_deref_mut() { + let inventory = crate::planner_selection::enumerate_workload_candidates( + roots.clone(), + accuracy.clone(), + &model, + &QueryEvidence { + topk: certificate, + scoped: scoped_certificate, + now_ms, + }, + &accuracy_model, + ) + .map_err(|error| CompileError::Snapshot(error.to_string()))?; + candidate_assembly_rejections = inventory.rejected_assemblies; + let candidates = inventory.candidates; + // Retain every root's candidates without multiplying independent + // cohorts. Deployment evaluates each root substitution in the + // preferred workload context; this is not exhaustive joint search. + output.extend(candidates); + } let (selected, mut trace) = crate::planner_selection::select_workload_with_accuracy_model_and_trace( roots, accuracy, &model, - &QueryEvidence(certificate), + &QueryEvidence { + topk: certificate, + scoped: scoped_certificate, + now_ms, + }, &accuracy_model, ) .map_err(|error| CompileError::Snapshot(error.to_string()))?; + trace["candidate_assembly_rejections"] = serde_json::json!(candidate_assembly_rejections); + if candidates_out.is_some() { + trace["computation_search_scope"] = serde_json::json!({ + "inventory": "all_root_candidates", + "deployment_evaluation": "single_root_substitutions_in_preferred_workload", + "joint_workload_search_exhaustive": false, + }); + } + if let Some(evidence) = scoped_certificate { + trace["accuracy_evidence_scope"] = serde_json::json!({ + "query_id": scope, + "source": evidence.source, + "data_snapshot_id": evidence.data_snapshot_id, + "observed_at_unix_ms": evidence.observed_at_unix_ms, + }); + } trace["deployment_overrides"] = serde_json::json!([]); let selected_indices = selected.iter().map(|(index, _)| *index).collect::>(); for (index, node) in selected { @@ -2474,7 +3083,11 @@ pub fn select_post_asap( expr, &CollectorFixtureModel(model), &DefaultAccuracyModel, - &QueryEvidence(evidence), + &QueryEvidence { + topk: evidence, + scoped: None, + now_ms: 0, + }, ) } @@ -2634,10 +3247,9 @@ pub(super) fn derived_window_cost( /// example, `a[1m] offset 1h` selects `(t - 61m, t - 60m]`. fn query_history_window_ms(expr: &QueryExpr, scrape_interval_ms: u64) -> Result { fn duration_ms(duration: std::time::Duration) -> Result { - if duration.subsec_nanos() != 0 { + if !duration.subsec_nanos().is_multiple_of(1_000_000) { return Err(CompileError::Snapshot( - "PromQL ranges must be a whole number of seconds in the backend-local profile" - .into(), + "PromQL ranges must have millisecond precision".into(), )); } u64::try_from(duration.as_millis()) @@ -2666,17 +3278,10 @@ fn query_history_window_ms(expr: &QueryExpr, scrape_interval_ms: u64) -> Result< duration_ms(*range)?, visit(child, scrape_interval_ms, true)?, ), - QueryExpr::TimeShift { shift, child } => { - if !shift.offset_ms.unsigned_abs().is_multiple_of(1_000) { - return Err(CompileError::Snapshot( - "PromQL offsets must be a whole number of seconds in the backend-local profile".into(), - )); - } - add( - shift.offset_ms.max(0) as u64, - visit(child, scrape_interval_ms, in_range)?, - ) - } + QueryExpr::TimeShift { shift, child } => add( + shift.offset_ms.max(0) as u64, + visit(child, scrape_interval_ms, in_range)?, + ), QueryExpr::PromqlScalarBridge(child) | QueryExpr::PromqlVectorFromScalar(child) | QueryExpr::PromqlScalarFromVector(child) @@ -2822,7 +3427,7 @@ pub(super) fn validate_window_implementations( && evidence.cpu_cost >= 0.0 && evidence.weighted_cost.is_finite() && evidence.weighted_cost >= 0.0 - && candidate.window_secs == query.query_lookback_seconds + && candidate.window_secs.saturating_mul(1_000) == query.query_lookback_ms && candidate .layout .validate(candidate.window_secs, candidate.slide_secs) @@ -2885,8 +3490,8 @@ fn retained_state_bytes(materialization: &asap_types::PrecomputeMaterialization) ) } -pub(super) fn estimated_state_bytes( - aggregation: &asap_types::AggregationType, +pub(crate) fn estimated_state_bytes( + aggregation_type: &asap_types::AggregationType, parameters: &HashMap, ) -> u128 { use asap_types::AggregationType as A; @@ -2897,7 +3502,7 @@ pub(super) fn estimated_state_bytes( .find_map(|name| parameters.get(*name).and_then(Value::as_u64)) .unwrap_or(fallback) as u128 }; - match aggregation { + match aggregation_type { A::CountMinSketch | A::CountSketch => { parameter(&["width", "w", "col_num", "col"], 1) * parameter(&["depth", "d", "row_num", "row"], 1) @@ -2929,7 +3534,7 @@ pub(super) fn estimated_state_bytes( } } -fn retained_partition_count( +pub(crate) fn retained_partition_count( materialization: &asap_types::PrecomputeMaterialization, input_cardinality: Option, ) -> u128 { @@ -2944,6 +3549,11 @@ fn retained_partition_count( A::Increase | A::Rate | A::Min | A::Max ) || !materialization.grouping_labels.names().is_empty() + || (materialization.derived_input.is_some() + && matches!( + materialization.aggregation_type, + A::CountMinSketchWithHeap | A::CountSketchWithHeap | A::Sum + )) { u128::from(input_cardinality.unwrap_or(1).max(1)) } else { @@ -3034,8 +3644,8 @@ fn select_lifecycle( raw_materialization_input_contract(node) .ok() .and_then(|(_, window, _)| window) - .unwrap_or(query.query_lookback_seconds) - .saturating_mul(1_000), + .map(|seconds| seconds.saturating_mul(1_000)) + .unwrap_or(query.query_lookback_ms), )), as_of: None, }, @@ -3124,12 +3734,12 @@ fn select_lifecycle( lifecycle_cost: plan.deployments[0] .alternatives .iter() - .find(|alternative| { - alternative.rejection.is_none() - && alternative.summary_maintenance_lifecycle + .find(|candidate| { + candidate.rejection.is_none() + && candidate.summary_maintenance_lifecycle == guarantee.summary_maintenance_lifecycle }) - .and_then(|alternative| alternative.total_cost) + .and_then(|candidate| candidate.total_cost) .ok_or_else(|| CompileError::Lifecycle { query_id: query.query_id.clone(), reason: "missing selected lifecycle cost".into(), @@ -3262,7 +3872,7 @@ fn immutable_materialization_sources(node: &SummaryNode) -> Option Option) -> Result<(), String> { Ok(()) } -fn reject_uncertified_readouts(query_id: &str, root: &Rc) -> Result<(), CompileError> { +fn reject_uncertified_readouts( + query_id: &str, + root: &Rc, + accuracy: &AccuracyTarget, +) -> Result<(), CompileError> { let dag = planner_types::post_asap::compile_executable_dag(root).map_err(|error| { CompileError::Query { query_id: query_id.into(), @@ -3372,6 +4017,25 @@ fn reject_uncertified_readouts(query_id: &str, root: &Rc) -> Result }); } } + // Leaf guarantees do not certify a composition (for example, division of + // two approximate quantiles). Admission checks the complete query result. + if dag.nodes.iter().any(|node| { + matches!( + node.payload, + planner_types::post_asap::ExecutableOperatorPayload::SummaryEstimate { .. } + ) + }) && root.guarantee.as_ref().is_none_or(|guarantee| { + !asap_aware_mapping::accuracy::AccuracyModel::satisfies( + &asap_aware_mapping::DefaultAccuracyModel, + guarantee, + accuracy, + ) + }) { + return Err(CompileError::Query { + query_id: query_id.into(), + reason: "selected query result has no certified accuracy guarantee satisfying the requested accuracy".into(), + }); + } Ok(()) } @@ -3385,7 +4049,9 @@ fn physical_aggregation( aggregation_id, metric_name: selected.metric.clone(), family: selected.family.clone(), - window_secs: selected.window_secs.unwrap_or(query.query_lookback_seconds), + window_secs: selected + .window_secs + .unwrap_or(query.query_lookback_ms.div_ceil(1_000)), spatial_filter: selected.spatial_filter.clone(), grouping: selected .group_by @@ -3423,6 +4089,22 @@ fn scoped_materialization( let SummaryExpr::SummaryAgg { reduction, .. } = &node.expr else { anyhow::bail!("materialization lacks SummaryAgg partition contract"); }; + if immutable_materialization_sources(node).is_some_and(|sources| { + sources.iter().any(|source| { + matches!( + &source.expr, + SummaryExpr::SummaryAgg { + family: SummaryFamilyType::ExactAggregate( + planner_types::post_asap::ExactKind::Rate, + _ + ), + .. + } + ) + }) + }) { + config.grouping_labels = asap_types::KeyByLabelNames { labels: Vec::new() }.into(); + } config.partitioning = Some(match reduction { planner_types::pre_asap::Reduction::PerEntity => { asap_types::sds::PopulationPartitioning::PerEntity @@ -3575,6 +4257,7 @@ fn collect_selected_materializations( node: &Rc, readout: Option<&SketchQuery>, composable: bool, + native_maintenance: bool, inherited_grouping: Option>, selected: &mut Vec, ) -> Result<(), String> { @@ -3608,9 +4291,26 @@ fn collect_selected_materializations( } else { None }; - if let Some(source) = immutable_materialization_sources(node) { + // A nested legacy Sum is a query reduction unless Planner supplied a + // complete native maintenance graph for this selected root. + let immutable_sources = if !native_maintenance + && matches!( + &node.expr, + SummaryExpr::SummaryAgg { + family: SummaryFamilyType::ExactAggregate( + planner_types::post_asap::ExactKind::Sum, + _ + ), + .. + } + ) { + None + } else { + immutable_materialization_sources(node) + }; + if let Some(source) = &immutable_sources { for source in source { - walk(&source, None, composable, None, selected)?; + walk(source, None, composable, native_maintenance, None, selected)?; } } match &node.expr { @@ -3621,24 +4321,73 @@ fn collect_selected_materializations( pruning: Some(_), .. } => { - walk(candidates, readout, composable, grouping.clone(), selected)?; + walk( + candidates, + readout, + composable, + native_maintenance, + grouping.clone(), + selected, + )?; // Explicit external authoritative values do not need duplicate local state. if !composable { - walk(values, readout, composable, grouping.clone(), selected)?; + walk( + values, + readout, + composable, + native_maintenance, + grouping.clone(), + selected, + )?; } } SummaryExpr::ValueOperation { child, .. } => { - walk(child, readout, composable, grouping.clone(), selected)?; + walk( + child, + readout, + composable, + native_maintenance, + grouping.clone(), + selected, + )?; } SummaryExpr::RelationalJoin { left, right, .. } => { - walk(left, readout, composable, grouping.clone(), selected)?; - walk(right, readout, composable, grouping.clone(), selected)?; + walk( + left, + readout, + composable, + native_maintenance, + grouping.clone(), + selected, + )?; + walk( + right, + readout, + composable, + native_maintenance, + grouping.clone(), + selected, + )?; } SummaryExpr::BinaryOp { lhs, rhs, .. } if composable || crate::query_plan::exact_value_executable(node) => { - walk(lhs, readout, composable, grouping.clone(), selected)?; - walk(rhs, readout, composable, grouping.clone(), selected)?; + walk( + lhs, + readout, + composable, + native_maintenance, + grouping.clone(), + selected, + )?; + walk( + rhs, + readout, + composable, + native_maintenance, + grouping.clone(), + selected, + )?; } SummaryExpr::SummaryAgg { child, @@ -3646,15 +4395,23 @@ fn collect_selected_materializations( SummaryFamilyType::ExactAggregate(planner_types::post_asap::ExactKind::Sum, _), .. } if !matches!(child.expr, SummaryExpr::KeepPreAsap(_)) + && immutable_sources.is_none() && ((composable && crate::query_plan::exact_accumulator_value_source(child).is_some()) || crate::query_plan::exact_value_executable(node)) => { - walk(child, readout, composable, grouping.clone(), selected)?; + walk( + child, + readout, + composable, + native_maintenance, + grouping.clone(), + selected, + )?; } SummaryExpr::SummaryAgg { child, .. } if !matches!(child.expr, SummaryExpr::KeepPreAsap(_)) - && immutable_materialization_sources(node).is_none() => {} + && immutable_sources.is_none() => {} SummaryExpr::SummaryAgg { family: SummaryFamilyType::ExactAggregate(planner_types::post_asap::ExactKind::Count, _), @@ -3667,12 +4424,20 @@ fn collect_selected_materializations( summary_input, Some(query), composable, + native_maintenance, grouping.clone(), selected, )?, SummaryExpr::SummaryMerge { children, .. } => { for child in children { - walk(child, readout, composable, grouping.clone(), selected)?; + walk( + child, + readout, + composable, + native_maintenance, + grouping.clone(), + selected, + )?; } } SummaryExpr::SummaryAgg { @@ -3769,7 +4534,18 @@ fn collect_selected_materializations( } let mut selected = Vec::new(); - walk(node, None, composable, None, &mut selected)?; + let physical_source = + asap_physical_operators::physical_planner::promql_rows::compile_rate_ranking(node) + .ok() + .map(|(source, _)| source); + walk( + physical_source.as_ref().unwrap_or(node), + None, + composable, + asap_physical_operators::physical_planner::promql_rows::compile_fixed_window_rate_aggregation(node).is_ok(), + None, + &mut selected, + )?; if composable { selected .retain(|state| !has_unsafe_raw_entity_leaf(node, &[Rc::clone(&state.node)], false)); @@ -3780,7 +4556,7 @@ fn collect_selected_materializations( Ok(selected) } -pub(super) fn sketch_params_json(params: &planner_types::post_asap::SketchParams) -> Value { +pub(crate) fn sketch_params_json(params: &planner_types::post_asap::SketchParams) -> Value { use planner_types::post_asap::SketchParams as P; match params { P::UnivMon { @@ -3879,6 +4655,7 @@ pub(crate) mod tests { "quantile by (job) (0.99, a)", "topk by (job) (1, a)", "topk by (job) (5, a)", + "count by (job) (a)", ]; snapshot.query_workload.repeating_queries = Some( queries @@ -4455,9 +5232,10 @@ pub(crate) mod tests { ); } - // Capability normalization precedes candidate enumeration, avoiding duplicate exact quotes. + // A larger computation inventory must retain both maintained and native + // execution; its cardinality is not the correctness contract. #[test] - fn counter_only_snapshot_has_distinct_local_and_native_cost_alternatives() { + fn counter_only_snapshot_has_distinct_local_and_native_cost_candidates() { let mut snapshot: BackendLocalPlanningInput = serde_json::from_str(include_str!( "../../../docs/examples/asapquery-planning-snapshot.json" )) @@ -4465,13 +5243,28 @@ pub(crate) mod tests { let entry = &mut snapshot.query_workload.repeating_queries.as_mut().unwrap()[0]; entry.query = Query("rate(m[1m])".into()); entry.requirements.accuracy = AccuracyRequirement::Explicit(AccuracyTarget::Exact); - let (request, _) = snapshot.into_physical_compilation_request().unwrap(); - assert_eq!( + let (request, environment) = snapshot.into_physical_compilation_request().unwrap(); + let candidates = super::super::workload_cost::enumerate_exact_and_materialized_candidates(request) - .unwrap() - .len(), - 3 + .unwrap(); + assert_eq!( + candidates + .iter() + .filter(|candidate| !candidate.allow_mixed_summary_and_exact_execution) + .count(), + 1 ); + let plans = candidates + .into_iter() + .map(|candidate| DeploymentPlanCompiler.compile_promql(candidate, environment.clone())) + .collect::, _>>() + .unwrap(); + assert!(plans + .iter() + .any(|plan| !plan.precompute_plan.materializations.is_empty())); + assert!(plans + .iter() + .any(|plan| plan.precompute_plan.materializations.is_empty())); } // Count and value rankings must configure different state update contracts. @@ -4750,9 +5543,11 @@ pub(crate) mod tests { evidence_by_query.insert(query_id.to_string(), evidence); } Ok(PhysicalCompilationRequest { - planner_selection_trace: Vec::new(), + planner_candidate_forests: Vec::new(), + planner_selection_trace: Default::default(), canonical_roots: Vec::new(), allow_mixed_summary_and_exact_execution: false, + require_backend_local_execution: false, enabled_materialization_keys: None, query_workload: None, data_workload: None, @@ -4761,7 +5556,7 @@ pub(crate) mod tests { query_string: promql.into(), selected_plan_root: post_asap, legacy_query_source: Source::TimeSeries { metric: "m".into() }, - query_lookback_seconds: 60, + query_lookback_ms: 60_000, group_by_labels: vec![], accuracy_target: accuracy, summary_lifecycle_inputs: lifecycle, @@ -5014,6 +5809,183 @@ pub(crate) mod tests { assert!(result.unwrap().precompute_plan.materializations.is_empty()); } + /// Accuracy facts must belong to the same query, workload, snapshot and + /// evidence window before they enter Planner. + #[test] + fn scoped_accuracy_evidence_rejects_wrong_or_stale_facts() { + let snapshot: BackendLocalPlanningInput = serde_json::from_str(include_str!( + "../../../docs/examples/asapquery-compatibility-demo-snapshot.json" + )) + .unwrap(); + let evidence = ScopedAccuracyEvidence { + query_string: "quantile_over_time(0.9,m[1m])".into(), + data_snapshot_id: "snapshot-a".into(), + data_workload: snapshot.data_workload.clone(), + source: "enforced-source-contract".into(), + observed_at_unix_ms: 9_000, + valid_for_ms: 2_000, + quantile_operand_domains: vec![], + values_non_negative: Some(true), + input_row_count: Some(10), + hydra_shared_grid_collision_bound: None, + hydra_shared_grid_failure_probability: None, + topk_max_distinct_items: None, + topk_selected_lower_bound: None, + topk_excluded_upper_bound: None, + topk_interval_failure_probability: None, + }; + let validate = |evidence: &ScopedAccuracyEvidence, query: &str, snapshot_id: &str, now| { + evidence.validate( + "q", + query, + &snapshot.data_workload, + Some(snapshot_id), + now, + 2_000, + ) + }; + assert!(validate(&evidence, &evidence.query_string, "snapshot-a", 10_000).is_ok()); + assert!(validate(&evidence, "another query", "snapshot-a", 10_000).is_err()); + assert!(validate(&evidence, &evidence.query_string, "snapshot-b", 10_000).is_err()); + assert!(validate(&evidence, &evidence.query_string, "snapshot-a", 12_000).is_err()); + let mut invalid = evidence.clone(); + invalid.hydra_shared_grid_failure_probability = Some(1.5); + assert!(validate(&invalid, &invalid.query_string, "snapshot-a", 10_000).is_err()); + let mut partial_topk = evidence.clone(); + partial_topk.topk_selected_lower_bound = Some(10.0); + assert!(validate( + &partial_topk, + &partial_topk.query_string, + "snapshot-a", + 10_000 + ) + .is_err()); + let mut valid_topk = partial_topk; + valid_topk.topk_excluded_upper_bound = Some(8.0); + valid_topk.topk_interval_failure_probability = Some(0.01); + assert!(validate(&valid_topk, &valid_topk.query_string, "snapshot-a", 10_000).is_ok()); + let stats = QueryEvidence { + topk: None, + scoped: Some(&valid_topk), + now_ms: 10_000, + } + .propagation_stats( + &CompositionOperator::TopKSelection, + &SummaryFamilyType::ExactAggregate( + planner_types::post_asap::ExactKind::Count, + planner_types::post_asap::ExactParams::Count, + ), + None, + ); + assert_eq!(stats.topk_selected_lower_bound, Some(10.0)); + assert_eq!(stats.topk_excluded_upper_bound, Some(8.0)); + } + + /// Quantile domain proof applies only to the operand named by its AST, + /// even when both operands read the same metric. + #[test] + fn scoped_quantile_domain_matches_one_exact_operand() { + let snapshot: BackendLocalPlanningInput = serde_json::from_str(include_str!( + "../../../docs/examples/asapquery-compatibility-demo-snapshot.json" + )) + .unwrap(); + let accuracy = AccuracyTarget::EpsilonDelta { + epsilon: 0.05, + delta: 0.01, + }; + let root = crate::query_parser::parse_query_expr_canonical( + "quantile_over_time(0.9,m[5m]) / quantile_over_time(0.5,m[5m])", + accuracy.clone(), + ) + .unwrap(); + let QueryExpr::BinaryOp { lhs, rhs, .. } = &root else { + panic!("expected ratio expression") + }; + let scoped = ScopedAccuracyEvidence { + query_string: "quantile_over_time(0.9,m[5m]) / quantile_over_time(0.5,m[5m])".into(), + data_snapshot_id: "snapshot-a".into(), + data_workload: snapshot.data_workload, + source: "enforced-source-contract".into(), + observed_at_unix_ms: 9_000, + valid_for_ms: 2_000, + quantile_operand_domains: vec![QuantileOperandDomainEvidence { + operand: serde_json::to_value(lhs.as_ref()).unwrap(), + lower: 1.0, + upper: 100.0, + max_samples: 10_000, + contract: "source-schema-v1".into(), + }], + values_non_negative: None, + input_row_count: None, + hydra_shared_grid_collision_bound: None, + hydra_shared_grid_failure_probability: None, + topk_max_distinct_items: None, + topk_selected_lower_bound: None, + topk_excluded_upper_bound: None, + topk_interval_failure_probability: None, + }; + let provider = QueryEvidence { + topk: None, + scoped: Some(&scoped), + now_ms: 10_000, + }; + assert!(provider.quantile_input_domain(lhs).is_some()); + assert!(provider.quantile_input_domain(rhs).is_none()); + let inspect = |provider: &dyn AccuracyEvidenceProvider| { + let (_, trace) = + crate::planner_selection::select_workload_with_accuracy_model_and_trace( + vec![(0, Rc::new(root.clone()))], + accuracy.clone(), + &ControlPlaneCostModel::new(accuracy.clone()), + provider, + &DefaultAccuracyModel, + ) + .unwrap(); + trace + }; + let unknown = inspect(&provider); + assert!(unknown["groups"].as_array().unwrap().iter().any(|group| { + group["candidates"] + .as_array() + .unwrap() + .iter() + .any(|candidate| { + candidate["accuracy_status"] == "unknown" && candidate["selected"] == false + }) + })); + let mut complete = scoped.clone(); + complete + .quantile_operand_domains + .push(QuantileOperandDomainEvidence { + operand: serde_json::to_value(rhs.as_ref()).unwrap(), + lower: 1.0, + upper: 100.0, + max_samples: 10_000, + contract: "source-schema-v1".into(), + }); + let complete_provider = QueryEvidence { + topk: None, + scoped: Some(&complete), + now_ms: 10_000, + }; + assert!(complete_provider.quantile_input_domain(rhs).is_some()); + let complete_trace = inspect(&complete_provider); + assert!(complete_trace["groups"] + .as_array() + .unwrap() + .iter() + .any(|group| { + group["candidates"] + .as_array() + .unwrap() + .iter() + .any(|candidate| { + candidate["accuracy_status"] == "known" + && candidate["replacement_kind"] == "summary" + }) + })); + } + #[test] fn snapshot_metricsql_entry_uses_the_shared_serving_language_contract() { let snapshot: BackendLocalPlanningInput = serde_json::from_str(include_str!( @@ -5959,49 +6931,26 @@ pub(crate) mod tests { interval: RepetitionInterval(45_000), evaluation_phase: planner_types::workload::TimestampMs(0), }; - let mut right = snapshot.clone(); - right.query_workload.repeating_queries.as_mut().unwrap()[0].query = - Query("sum_over_time(n[1m])".into()); - let (mut request, env) = snapshot.into_physical_compilation_request().unwrap(); - let (right, _) = right.into_physical_compilation_request().unwrap(); - let left = request.queries[0].selected_plan_root.clone(); - let right = right.queries[0].selected_plan_root.clone(); - request.queries[0].selected_plan_root = Rc::new(SummaryNode { - expr: SummaryExpr::BinaryOp { - timing: planner_types::post_asap::ExecutionTiming::QueryTime, - lhs: left.clone(), - rhs: right, - operator: planner_types::post_asap::BinaryOperator { - checked_relative_division: false, - checked_finite_division: false, - kind: planner_types::pre_asap::BinaryOpKind::Arithmetic( - planner_types::pre_asap::ArithmeticOpKind::Add, - ), - vector_match: None, - }, - }, - schema: left.schema.clone(), - guarantee: None, - }); - let text = "quantile(0.9, sum_over_time(m[1m])) + sum_over_time(n[1m])"; - request.queries[0].query_string = text.into(); - request + // Keep both outputs independent: composing a quantile with a sum would + // require an additional accuracy proof unrelated to cadence selection. + let mut raw_entry = entry.clone(); + raw_entry.query = Query("sum_over_time(n[1m])".into()); + snapshot .query_workload - .as_mut() - .unwrap() .repeating_queries .as_mut() - .unwrap()[0] - .query = Query(text.into()); - prepare_window_implementations(&mut request.queries[0], &model, env.target, 0).unwrap(); - request.queries[0] - .window_realization_candidates - .retain(|candidate| { + .unwrap() + .push(raw_entry); + let (mut request, env) = snapshot.into_physical_compilation_request().unwrap(); + for query in &mut request.queries { + prepare_window_implementations(query, &model, env.target, 0).unwrap(); + query.window_realization_candidates.retain(|candidate| { matches!( candidate.layout, asap_types::WindowMaterializationLayout::Pane { .. } ) }); + } let plan = DeploymentPlanCompiler.compile_promql(request, env).unwrap(); assert!(plan .precompute_plan @@ -6179,7 +7128,8 @@ pub(crate) mod tests { .unwrap() .0 .queries[0] - .query_lookback_seconds + .query_lookback_ms + / 1_000 } fn set_data_ingestion_interval( @@ -6204,7 +7154,7 @@ pub(crate) mod tests { set_data_ingestion_interval(&mut snapshot, Some(1_000)); let (request, _) = snapshot.into_physical_compilation_request().unwrap(); - assert_eq!(request.queries[0].query_lookback_seconds, 1); + assert_eq!(request.queries[0].query_lookback_ms, 1_000); } // Snapshot lowering must use the environment clock for cadence freshness. @@ -6244,7 +7194,7 @@ pub(crate) mod tests { request.data_workload.unwrap().data_ingestion_interval.value, Some(DurationMs(5_000)) ); - assert_eq!(request.queries[0].query_lookback_seconds, 5); + assert_eq!(request.queries[0].query_lookback_ms, 5_000); } // An explicitly invalid interval must not be replaced by the migration value. @@ -6279,31 +7229,50 @@ pub(crate) mod tests { assert_eq!(derived_query_window_secs("sum_over_time(a[1s])"), 1); } - // The seconds-based backend must fail explicitly instead of shrinking history. + /// Fractional ranges, subqueries and offsets preserve their exact history. #[test] - fn derived_history_rejects_fractional_seconds() { - for query in [ - "sum_over_time(a[1500ms])", - "sum_over_time(a[500ms])", - "sum_over_time(a[1500ms] offset 500ms)", - "sum_over_time(a[1s]) + sum(sum_over_time(b[500ms]))", - "avg_over_time((sum(a))[1500ms:])", - "sum(a offset 500ms)", + fn derived_history_preserves_milliseconds() { + for (query, expected) in [ + ("sum_over_time(a[1500ms])", 1500), + ("sum_over_time(a[500ms])", 500), + ("sum_over_time(a[1500ms] offset 500ms)", 2000), + ("sum_over_time(a[1s]) + sum(sum_over_time(b[500ms]))", 1000), + ("avg_over_time((sum(a))[1500ms:])", 6500), + ("sum(a offset 500ms)", 5500), ] { let mut snapshot = planning_snapshot(); snapshot.query_workload.repeating_queries.as_mut().unwrap()[0].query = Query(query.into()); - let error = snapshot - .into_physical_compilation_request() - .expect_err(query) - .to_string(); - assert!( - error.contains("whole number of seconds"), - "{query}: {error}" - ); + let (request, _) = snapshot.into_physical_compilation_request().expect(query); + assert_eq!(request.queries[0].query_lookback_ms, expected, "{query}"); } } + /// A real 100 ms source stays at 100 ms through lowering and query publication. + #[test] + fn hundred_millisecond_source_compiles_without_rounding() { + let mut snapshot = planning_snapshot(); + snapshot.query_workload.repeating_queries.as_mut().unwrap()[0].query = + Query("sum(data)".into()); + snapshot.physical_inputs.scrape_interval_ms = 100; + set_data_ingestion_interval(&mut snapshot, Some(100)); + let (request, environment) = snapshot.into_physical_compilation_request().unwrap(); + assert_eq!(request.queries[0].query_lookback_ms, 100); + let plan = DeploymentPlanCompiler + .compile_promql(request, environment) + .unwrap(); + assert_eq!( + plan.query_plan + .entries + .values() + .next() + .unwrap() + .instant + .lookback_ms, + 100 + ); + } + #[test] fn snapshot_rejects_manual_lookback() { let mut wire = serde_json::to_value(planning_snapshot()).unwrap(); @@ -6425,9 +7394,9 @@ pub(crate) mod tests { } // A tumbling shape pairs only with `Pane` in the validator's - // framework/layout table, so there is no alternative to price against it. + // framework/layout table, so there is no candidate to price against it. #[test] - fn tumbling_shapes_have_no_layout_alternative_to_rank() { + fn tumbling_shapes_have_no_layout_candidate_to_rank() { let cost = planning_snapshot().physical_inputs.window_cost_model.cost; let expr = crate::query_parser::parse_query_expr_canonical( "quantile_over_time(0.5, data[5m])", @@ -6462,7 +7431,7 @@ pub(crate) mod tests { (60_000, 90_000), ] { let mut query = request.queries[0].clone(); - query.query_lookback_seconds = lookback_ms / 1_000; + query.query_lookback_ms = lookback_ms; let expr = crate::query_parser::parse_query_expr_canonical( &format!("quantile_over_time(0.5, data[{}s])", lookback_ms / 1_000), AccuracyTarget::Exact, @@ -6862,7 +7831,8 @@ pub(crate) mod tests { request = super::super::workload_cost::enumerate_exact_and_materialized_candidates(request) .unwrap() - .pop() + .into_iter() + .find(|candidate| !candidate.allow_mixed_summary_and_exact_execution) .unwrap(); let bundle = DeploymentPlanCompiler .compile_promql(request, environment) @@ -7333,6 +8303,7 @@ pub(crate) mod tests { query_workload, data_workload, physical_inputs: BackendLocalPhysicalInputs { + require_backend_local_execution: false, lifecycle_costs: template.summary_lifecycle_inputs.costs, evidence_observed_at_unix_ms: 9_500, evidence_valid_for_ms: 60_000, @@ -7346,6 +8317,8 @@ pub(crate) mod tests { query_retention_margin_ms: 0, retained_summary_memory_budget_bytes: DEFAULT_RETAINED_SUMMARY_MEMORY_BUDGET_BYTES, topk_evidence: HashMap::new(), + data_snapshot_id: None, + accuracy_evidence: HashMap::new(), exact_composition_costs: HashMap::new(), erp: None, }, @@ -7468,7 +8441,7 @@ pub(crate) mod tests { } #[test] - fn checked_in_per_entity_snapshot_preserves_native_alternative() { + fn checked_in_per_entity_snapshot_preserves_native_candidate() { let source = include_str!("../../../docs/examples/asapquery-planning-snapshot.json"); let snapshot: BackendLocalPlanningInput = serde_json::from_str(source).expect("strict canonical workload fixture"); @@ -7535,8 +8508,9 @@ pub(crate) mod tests { let native = crate::physical::workload_cost::enumerate_exact_and_materialized_candidates(request) .unwrap() - .pop() - .unwrap(); + .into_iter() + .find(|candidate| !candidate.allow_mixed_summary_and_exact_execution) + .expect("native candidate retained"); let plan = DeploymentPlanCompiler .compile_promql(native, environment) .expect("native demo compiles"); @@ -7782,9 +8756,9 @@ pub(crate) mod tests { let mut second = request("q40", "sum(sum_over_time(m[40s]))") .queries .remove(0); - workload.queries[0].query_lookback_seconds = 20; + workload.queries[0].query_lookback_ms = 20_000; workload.queries[0].window_realization_candidates[0].window_secs = 20; - second.query_lookback_seconds = 40; + second.query_lookback_ms = 40_000; second.summary_lifecycle_inputs.evaluation_interval_ms = 20_000; second.window_realization_candidates[0].window_secs = 40; workload.queries.push(second); diff --git a/control_plane/src/physical/compiler/windows.rs b/control_plane/src/physical/compiler/windows.rs index 072ee7c2e..fcf2987ae 100644 --- a/control_plane/src/physical/compiler/windows.rs +++ b/control_plane/src/physical/compiler/windows.rs @@ -28,6 +28,10 @@ pub(super) fn is_full_cohort(candidate: &WindowRealizationCandidate) -> bool { } } +pub(super) fn is_complete_window(candidate: &WindowRealizationCandidate) -> bool { + matches!(candidate.layout, WindowMaterializationLayout::FullWindow) || is_full_cohort(candidate) +} + pub(super) fn cohort_nodes(states: &[SelectedMaterialization]) -> BTreeSet { let mut nodes = BTreeSet::new(); for state in states { @@ -153,11 +157,14 @@ pub fn prepare_window_implementations( reason, })?; let cohorts = cohort_nodes(&states); + let native_cohort = asap_physical_operators::physical_planner::promql_rows::compile_fixed_window_rate_aggregation(&query.selected_plan_root).is_ok(); let requirements = states .iter() .map(|state| { ( - state.window_secs.unwrap_or(query.query_lookback_seconds), + state + .window_secs + .unwrap_or(query.query_lookback_ms.div_ceil(1_000)), cohorts.contains(&(Rc::as_ptr(&state.node) as usize)), ) }) @@ -169,7 +176,7 @@ pub fn prepare_window_implementations( &model, &query.summary_lifecycle_inputs, window, - cohort, + cohort && !native_cohort, target, staleness_margin_ms, ) @@ -194,7 +201,11 @@ pub fn prepare_window_implementations( } let applicable = requirements.iter().any(|&(window, cohort)| { quote.window_secs == window - && if cohort { + && if cohort && native_cohort { + is_complete_window(quote) + && quote.slide_secs.saturating_mul(1000) + == u64::from(query.summary_lifecycle_inputs.evaluation_interval_ms) + } else if cohort { is_full_cohort(quote) } else { quote.slide_secs.saturating_mul(1_000) diff --git a/control_plane/src/physical/executable_binding.rs b/control_plane/src/physical/executable_binding.rs index 93056ad3a..9c09bf26b 100644 --- a/control_plane/src/physical/executable_binding.rs +++ b/control_plane/src/physical/executable_binding.rs @@ -55,6 +55,7 @@ pub fn install_selected_dag( } precompute_sinks.sort(); let installed = InstalledPostAsapDag { + native_programs: std::collections::BTreeMap::new(), document: OwnedPostAsapDag::from_executable(query_id, dag)?, binding: BackendExecutableBinding { nodes, diff --git a/control_plane/src/physical/maintained_population.rs b/control_plane/src/physical/maintained_population.rs index abe0d16f7..e4cbd518a 100644 --- a/control_plane/src/physical/maintained_population.rs +++ b/control_plane/src/physical/maintained_population.rs @@ -1,5 +1,7 @@ //! Lower typed population operators according to executor membership capabilities. -use super::compiler::{CompileError, PhysicalCompilationRequest, QueryCompilationInput}; +#[cfg(test)] +use super::compiler::QueryCompilationInput; +use super::compiler::{CompileError, PhysicalCompilationRequest}; use asap_types::query_plan::{ current_series::{SeriesPopulation, SeriesReadout}, query_time::{Grouping, LabelMatch, LabelMatcher, QueryTimeOperator}, @@ -8,23 +10,50 @@ use planner_types::post_asap::{ maintained_population::*, SummaryExpr, SummaryNode, ValueOperation, }; -fn selected(node: &SummaryNode) -> Option<(&MaintainedPopulation, &PopulationReadout)> { - let SummaryExpr::ValueOperation { +fn selected(node: &SummaryNode) -> Option<(MaintainedPopulation, PopulationReadout)> { + if let SummaryExpr::ValueOperation { child, operation: ValueOperation::ReadPopulation { readout }, .. } = &node.expr - else { - return None; - }; + { + if let SummaryExpr::ValueOperation { + operation: ValueOperation::MaintainPopulation { population }, + .. + } = &child.expr + { + return Some((population.clone(), readout.clone())); + } + } + // The source remains a maintained population when Planner places a heap, + // projection and ranking above it. Backend binds that source only. let SummaryExpr::ValueOperation { - operation: ValueOperation::MaintainPopulation { population }, + operation: ValueOperation::Limit { n, offset: 0, .. }, .. - } = &child.expr + } = &node.expr else { return None; }; - Some((population, readout)) + let dag = + planner_types::post_asap::compile_executable_dag(&std::rc::Rc::new(node.clone())).ok()?; + let populations = dag + .nodes + .iter() + .filter_map(|node| match &node.payload { + planner_types::post_asap::ExecutableOperatorPayload::Value { + operation: ValueOperation::MaintainPopulation { population }, + } => Some(population), + _ => None, + }) + .collect::>(); + let [population] = populations.as_slice() else { + return None; + }; + asap_physical_operators::physical_planner::promql_rows::compile_current_series_readout( + &std::rc::Rc::new(node.clone()), + ) + .ok()?; + Some(((*population).clone(), PopulationReadout::TopK { k: *n })) } pub(super) fn supported_node(node: &SummaryNode) -> bool { @@ -40,22 +69,21 @@ pub(super) fn supported(request: &PhysicalCompilationRequest) -> bool { .any(|q| supported_node(&q.selected_plan_root)) } -pub(super) fn operator( +/// Resolve population bindings once per candidate. Scanning every workload +/// root for each consumer recompiles the same native ranking graphs quadratically. +pub(super) fn operators( request: &PhysicalCompilationRequest, - query: &QueryCompilationInput, -) -> Result, CompileError> { - let Some((spec, readout)) = selected(&query.selected_plan_root) else { - return Ok(None); - }; - let PopulationInput::CurrentSeries(input) = &spec.input else { - return Err(CompileError::Query { query_id: query.query_id.clone(), reason: "maintained table-row populations require a row-update executor; remote-write current-series state is incompatible".into() }); - }; - let populations: std::collections::BTreeSet<_> = request +) -> Result>, CompileError> { + let selected = request .queries .iter() - .filter_map(|q| { - selected(&q.selected_plan_root) - .map(|(p, _)| serde_json::to_string(p).expect("typed population serializes")) + .map(|query| selected(&query.selected_plan_root)) + .collect::>(); + let populations: std::collections::BTreeSet<_> = selected + .iter() + .flatten() + .map(|(population, _)| { + serde_json::to_string(population).expect("typed population serializes") }) .collect(); let max_bytes = request @@ -63,6 +91,11 @@ pub(super) fn operator( .unwrap_or(64 * 1024 * 1024) .min(1_073_741_824) / populations.len().max(1) as u64; + selected.into_iter().zip(&request.queries).map(|(selected, query)| { + let Some((spec, readout)) = selected else { return Ok(None); }; + let PopulationInput::CurrentSeries(input) = &spec.input else { + return Err(CompileError::Query { query_id: query.query_id.clone(), reason: "maintained table-row populations require a row-update executor; remote-write current-series state is incompatible".into() }); + }; let population = SeriesPopulation { metric: input.metric.clone(), matchers: input @@ -97,7 +130,7 @@ pub(super) fn operator( .min(input.lookback_ms), }; population.validate()?; - let readout = match readout { + let readout = match &readout { PopulationReadout::Quantile { q } => SeriesReadout::Quantile { q: *q }, PopulationReadout::TopK { k } => SeriesReadout::TopK { k: *k as u64 }, PopulationReadout::Sum => SeriesReadout::Sum, @@ -108,4 +141,67 @@ pub(super) fn operator( population, readout, })) + }).collect() +} + +#[cfg(test)] +pub(super) fn operator( + request: &PhysicalCompilationRequest, + query: &QueryCompilationInput, +) -> Result, CompileError> { + let index = request + .queries + .iter() + .position(|q| q.query_id == query.query_id) + .expect("query belongs to the compilation request"); + Ok(operators(request)?.remove(index)) +} + +/// The maintained population is a deployment source; ranking is compiled by +/// Planner before this candidate is priced or installed. +pub(super) fn install_native_topk( + entry: &mut asap_types::query_plan::QueryPlanEntry, + selected: &std::rc::Rc, +) -> Result<(), CompileError> { + use asap_types::query_plan::QueryPlanNode; + let Some(QueryPlanNode::Logical { + operator: + QueryTimeOperator::CurrentSeries { + population, + readout: SeriesReadout::TopK { .. }, + }, + .. + }) = entry.nodes.get(&entry.root) + else { + return Ok(()); + }; + if population.grouping.without { + return Ok(()); + } + let compiled = + asap_physical_operators::physical_planner::promql_rows::compile_current_series_readout( + selected, + ) + .map_err(|error| CompileError::Query { + query_id: entry.query_id.clone(), + reason: error.to_string(), + })?; + let encoded = compiled.encode().map_err(|error| CompileError::Query { + query_id: entry.query_id.clone(), + reason: error.to_string(), + })?; + entry.physical_dag = Some( + serde_json::from_slice(&encoded) + .map_err(|error| CompileError::Snapshot(error.to_string()))?, + ); + let Some(QueryPlanNode::Logical { + operator: QueryTimeOperator::CurrentSeries { readout, .. }, + .. + }) = entry.nodes.get_mut(&entry.root) + else { + unreachable!() + }; + *readout = SeriesReadout::Snapshot; + entry.recover_population_physical_dag()?; + Ok(()) } diff --git a/control_plane/src/physical/plan_dot.rs b/control_plane/src/physical/plan_dot.rs index c0e7f9d5f..1965c761c 100644 --- a/control_plane/src/physical/plan_dot.rs +++ b/control_plane/src/physical/plan_dot.rs @@ -66,6 +66,14 @@ pub fn render(plan: &CompiledPhysicalPlan) -> String { edge.role )); } + for (sink, program) in &installed.native_programs { + render_native( + &mut dot, + &format!("maintenance_{dag_index}_{}", sink.0), + "Planner maintenance operators", + program, + ); + } dot.push_str(" }\n"); } dot.push_str(" }\n"); @@ -104,12 +112,48 @@ pub fn render(plan: &CompiledPhysicalPlan) -> String { )); } } + if let Some(program) = &entry.physical_dag { + render_native( + &mut dot, + &format!("native_query_{query_index}"), + "Planner query operators", + program, + ); + } dot.push_str(" }\n"); } dot.push_str("}\n"); dot } +fn render_native(dot: &mut String, prefix: &str, label: &str, program: &serde_json::Value) { + let Some(nodes) = program["nodes"].as_object() else { + return; + }; + dot.push_str(&format!( + " subgraph cluster_{prefix} {{\n label=\"{}\";\n", + escape(label) + )); + for (id, node) in nodes { + let operator = node.get("Operator"); + let label = operator + .map(|op| op["operator"]["kind"].to_string()) + .unwrap_or_else(|| "Bound physical input".into()); + emit_node( + dot, + &format!("{prefix}_{id}"), + &format!("#{id}\n{label}"), + "", + ); + if let Some(inputs) = operator.and_then(|op| op["inputs"].as_array()) { + for input in inputs { + dot.push_str(&format!(" {prefix}_{input} -> {prefix}_{id};\n")); + } + } + } + dot.push_str(" }\n"); +} + fn materialization_node(id: u64) -> String { format!("materialization_{id:016x}") } @@ -139,6 +183,9 @@ fn escape(value: &str) -> String { fn query_node_label(node: &QueryPlanNode) -> String { match node { + QueryPlanNode::Physical { source_nodes, .. } => { + format!("Planner Physical DAG\nbound sources {source_nodes:?}") + } QueryPlanNode::PhysicalRelation { dag, .. } | QueryPlanNode::PhysicalFragment { dag, .. } => { asap_physical_operators::physical_planner::CompiledPhysicalDag::decode(dag) diff --git a/control_plane/src/physical/workload_cost.rs b/control_plane/src/physical/workload_cost.rs index 045882c94..74dc1d5ba 100644 --- a/control_plane/src/physical/workload_cost.rs +++ b/control_plane/src/physical/workload_cost.rs @@ -38,6 +38,7 @@ pub struct CostComponentDemand { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct WorkloadCostManifest { + pub dataset_identity: planner_types::post_asap::LogicalDatasetIdentity, pub plan_id: u64, pub plan_version: u64, pub planner_revision: String, @@ -108,7 +109,7 @@ pub struct MaterializationSearchCoverage { pub struct CandidatePlanSelectionReport { #[serde(default)] #[serde(rename = "logical_selection")] - pub planner_selection_trace: Vec, + pub planner_selection_trace: std::sync::Arc>, #[serde(default)] pub materialization_search_coverage: Option, pub data_snapshot_id: String, @@ -211,6 +212,28 @@ pub fn manifest( } } } + for installed in plan.precompute_plan.executable_dags.values() { + for (sink, program) in &installed.native_programs { + let Some(asap_types::executable_plan::BackendNodeBinding::Materialization { + stored_output, + }) = installed.binding.node(*sink) + else { + return Err(invalid("native maintenance sink is unbound")); + }; + let config = plan + .precompute_plan + .materializations + .iter() + .find(|m| m.policy_fingerprint() == stored_output.fingerprint()) + .ok_or_else(|| invalid("native maintenance config is absent"))?; + add( + format!("maintenance:{}", stored_output.0), + json!({"physical_program":program,"stored_output":stored_output,"window_ms":config.stored_window_ms(),"interval_ms":config.slide_interval.saturating_mul(1000)}), + "horizon", + 1.0, + ); + } + } for rule in &plan.transmission_plan.rules { add( format!("transport:{}:{}", rule.producer_id, rule.materialization.0), @@ -305,7 +328,7 @@ pub fn manifest( for node_id in entry.topological_order()? { add( format!("query:{}:{}", entry.query_id, node_id.0), - json!({"node": entry.nodes[&node_id], "query": entry.canonical_query, "instant": entry.instant}), + json!({"node": entry.nodes[&node_id], "query": entry.canonical_query, "instant": entry.instant, "physical_dag": entry.physical_dag}), "query_evaluation", evaluations, ); @@ -318,6 +341,12 @@ pub fn manifest( ); } Ok(WorkloadCostManifest { + dataset_identity: plan + .precompute_plan + .ingest + .dataset_identity + .clone() + .ok_or_else(|| CompileError::Snapshot("cost manifest lacks dataset identity".into()))?, plan_id: plan.envelope.plan_id, plan_version: plan.envelope.plan_version, planner_revision: plan.envelope.planner_revision.clone(), @@ -485,7 +514,7 @@ fn candidate_description(candidate: &PhysicalCompilationRequest) -> CandidatePla CandidatePlanEvaluation { candidate_id: complete.then(|| { crate::planner_selection::explain_identity( - "alternative", + "candidate", &( &logical_root_ids, candidate.allow_mixed_summary_and_exact_execution, @@ -554,6 +583,12 @@ fn compile_candidate_for_pricing( }) .collect::>(); placement.sort(); + let native_programs = plan + .query_plan + .entries + .values() + .map(|entry| (&entry.query_id, &entry.physical_dag)) + .collect::>(); description.physical_candidate_id = description .candidate_id @@ -566,6 +601,7 @@ fn compile_candidate_for_pricing( bindings, placement, &plan.precompute_plan.ingest, + native_programs, ), ) }); @@ -637,9 +673,9 @@ fn select_candidates( frontend: super::compiler::QueryFrontend, ) -> Result { evidence.validate(&env)?; - if candidates.is_empty() || candidates.len() > 64 { + if candidates.is_empty() || candidates.len() > 4096 { return Err(invalid( - "candidate inventory must contain 1..=64 candidates", + "candidate inventory must contain 1..=4096 candidates", )); } let candidate_key_sets: BTreeSet<_> = candidates @@ -662,13 +698,7 @@ fn select_candidates( let planner_selection_trace = candidates[0].planner_selection_trace.clone(); let mut comparison_workload = None; let mut candidate_evaluations = Vec::new(); - let mut best_index = 0; - let mut best: Option<( - Cost, - CompiledPhysicalPlan, - WorkloadCostManifest, - BTreeMap, - )> = None; + let mut priced_candidates = Vec::new(); for candidate in candidates { let (plan, manifest, mut description) = match compile_candidate_for_pricing(candidate, env.clone(), frontend) { @@ -695,10 +725,13 @@ fn select_candidates( description.status = CandidateEvaluationStatus::Unselected; description.total_cost = Some(cost.0); candidate_evaluations.push(description); - if best.as_ref().is_none_or(|(previous, ..)| cost < *previous) { - best_index = candidate_evaluations.len() - 1; - best = Some((cost, plan, manifest, components)); - } + priced_candidates.push(Ok(( + cost, + plan, + manifest, + components, + candidate_evaluations.len() - 1, + ))); } Err((status, reason)) => { description.status = status; @@ -707,13 +740,27 @@ fn select_candidates( } } } - let (_, mut plan, selected_manifest, component_costs) = best.ok_or_else(|| { + let selected = asap_physical_operators::physical_planner::select_candidate( + priced_candidates, + |(cost, _, manifest, _, _)| { + Ok(Some( + asap_physical_operators::physical_planner::CandidateCost { + workload_scope: serde_json::to_string(&manifest.workload).map_err(|error| { + asap_physical_operators::Error::Invalid(error.to_string()) + })?, + horizon_seconds: manifest.horizon_seconds, + total_cost: cost.0, + }, + )) + }, + ) + .map_err(|error| { CompileError::Candidates( json!({"status": "all_infeasible", "logical_selection": planner_selection_trace, - "candidates": candidate_evaluations}), + "candidates": candidate_evaluations, "selection_error": error.to_string()}), ) })?; - // Exactly the winner retained by the existing strict-less-than selector. + let (_, mut plan, selected_manifest, component_costs, best_index) = selected.candidate; candidate_evaluations[best_index].status = CandidateEvaluationStatus::Selected; plan.cost_comparison = Some(CandidatePlanSelectionReport { planner_selection_trace, @@ -731,21 +778,55 @@ fn select_candidates( /// The current executor exposes continuously maintained state and the native /// exact backend. Additional Planner-produced forests can use `select_lowest_cost_candidate` directly. pub fn enumerate_exact_and_materialized_candidates( - request: PhysicalCompilationRequest, + mut request: PhysicalCompilationRequest, ) -> Result, CompileError> { - let already_selected = super::maintained_population::supported(&request); - let mut candidates = materialization_candidates(request)?; - if already_selected { - return Ok(candidates); + let forests = std::mem::take(&mut request.planner_candidate_forests); + if forests.is_empty() { + return enumerate_frontier_candidates(request); + } + // Keep the existing deterministic ordering for equal-cost candidates; + // every discovered forest still reaches deployment admission and pricing. + let mut candidates: Vec = Vec::new(); + for forest in std::iter::once(request.queries.clone()).chain(forests) { + let mut candidate = request.clone(); + candidate.queries = forest; + for candidate in enumerate_frontier_candidates(candidate)? { + if !candidates.iter().any(|existing| { + existing.allow_mixed_summary_and_exact_execution + == candidate.allow_mixed_summary_and_exact_execution + && existing.enabled_materialization_keys + == candidate.enabled_materialization_keys + && existing + .queries + .iter() + .zip(&candidate.queries) + .all(|(a, b)| a.selected_plan_root == b.selected_plan_root) + }) { + candidates.push(candidate); + } + if candidates.len() > 4096 { + return Err(invalid( + "deployment candidate inventory exceeds budget; no partial inventory selected", + )); + } + } } + Ok(candidates) +} + +fn enumerate_frontier_candidates( + mut request: PhysicalCompilationRequest, +) -> Result, CompileError> { + request.planner_candidate_forests.clear(); + let mut candidates = materialization_candidates(request)?; let roots: Vec<_> = candidates .last() - .expect("exact alternative") + .expect("exact candidate") .queries .iter() .map(|q| match &q.selected_plan_root.expr { planner_types::post_asap::SummaryExpr::KeepPreAsap(root) => std::rc::Rc::clone(root), - _ => unreachable!("native alternative retains canonical roots"), + _ => unreachable!("native candidate retains canonical roots"), }) .collect(); let strategy = @@ -760,21 +841,29 @@ pub fn enumerate_exact_and_materialized_candidates( .collect(); if maintained_roots.iter().any(Option::is_some) { // Current-series rules are compatible with window summaries in other - // workload roots. Preserve each priced temporal alternative and mask. + // workload roots. Preserve each priced temporal candidate and mask. let maintained: Vec<_> = candidates .iter() - .map(|alternative| { - let mut candidate = alternative.clone(); + .filter_map(|candidate| { + let mut candidate = candidate.clone(); + let mut changed = false; for (query, selected) in candidate.queries.iter_mut().zip(&maintained_roots) { if let Some(selected) = selected { - query.selected_plan_root = std::rc::Rc::clone(selected); + if !super::maintained_population::supported_node(&query.selected_plan_root) + { + query.selected_plan_root = std::rc::Rc::clone(selected); + changed = true; + } } } + if !changed { + return None; + } if maintained_roots.iter().all(Option::is_some) { candidate.allow_mixed_summary_and_exact_execution = false; candidate.enabled_materialization_keys = None; } - candidate + Some(candidate) }) .collect(); for candidate in maintained { @@ -836,7 +925,7 @@ fn materialization_candidates( &query.selected_plan_root, ) { Ok(found) => keys.extend(found), - // A failed local projection must not make the native alternative + // A failed local projection must not make the native candidate // disappear. Compile/select_lowest_cost_candidate retains its concrete unavailability. Err(_) => return Ok(vec![request, exact]), } @@ -868,6 +957,138 @@ mod tests { use super::super::compiler::BackendLocalPlanningInput; use super::*; + /// Deployment pricing must see candidate sketch families, not only an + /// upstream winner chosen before runtime resource evidence is applied. + #[test] + fn planner_quantile_inventory_reaches_backend_before_family_selection() { + let mut input = fixture(); + let queries = input.query_workload.repeating_queries.as_mut().unwrap(); + queries.truncate(1); + queries[0].query = planner_types::workload::Query("quantile_over_time(0.9,m[1m])".into()); + queries[0].requirements.accuracy = planner_types::workload::AccuracyRequirement::Explicit( + crate::types::AccuracyTarget::Epsilon(0.05), + ); + let (request, _) = input.into_physical_compilation_request().unwrap(); + let candidates = enumerate_exact_and_materialized_candidates(request).unwrap(); + let roots = candidates + .iter() + .flat_map(|c| &c.queries) + .map(|q| format!("{:?}", q.selected_plan_root)) + .collect::>(); + assert!( + roots.iter().any(|r| r.contains("Kll")), + "KLL disappeared before backend pricing" + ); + assert!( + roots.iter().any(|r| r.contains("DDSketch")), + "DDSketch disappeared before backend pricing" + ); + } + + /// A deployment without external execution rejects native candidates before pricing. + #[test] + fn local_only_deployment_rejects_external_candidate_before_pricing() { + let mut value = serde_json::to_value(fixture()).unwrap(); + value["implementation"]["require_backend_local_execution"] = json!(true); + let input: BackendLocalPlanningInput = serde_json::from_value(value).unwrap(); + let (request, environment) = input.clone().into_physical_compilation_request().unwrap(); + let candidates = enumerate_exact_and_materialized_candidates(request).unwrap(); + let native = candidates + .iter() + .find(|candidate| { + candidate.queries.iter().all(|query| { + matches!( + query.selected_plan_root.expr, + planner_types::post_asap::SummaryExpr::KeepPreAsap(_) + ) + }) + }) + .expect("native candidate remains inspectable") + .clone(); + let error = DeploymentPlanCompiler + .compile_promql(native, environment) + .unwrap_err(); + assert!( + error + .to_string() + .contains("external execution is unavailable"), + "{error}" + ); + let selected = with_unit_quotes(input).compile_promql().unwrap(); + assert!(selected.query_plan.entries.values().all(|entry| entry + .nodes + .values() + .all(|node| !matches!(node, crate::query_plan::QueryPlanNode::ExactFallback { .. })))); + let report = selected.cost_comparison.unwrap(); + assert!(report + .candidate_evaluations + .iter() + .any(|candidate| candidate + .unavailable_reason + .as_ref() + .is_some_and(|reason| reason.contains("external execution is unavailable")) + && candidate.total_cost.is_none())); + } + + /// Selecting one population must not suppress candidates for other roots. + #[test] + fn existing_population_does_not_suppress_other_population_candidates() { + let mut input = fixture(); + let queries = input.query_workload.repeating_queries.as_mut().unwrap(); + let template = queries[0].clone(); + *queries = ["quantile by(job)(0.9,m)", "count by(job)(m)"] + .into_iter() + .map(|q| { + let mut entry = template.clone(); + entry.query = planner_types::workload::Query(q.into()); + entry + }) + .collect(); + let (mut request, _) = input.into_physical_compilation_request().unwrap(); + let strategy = asap_aware_mapping::maintained_population::MaintainedPopulationStrategy::new( + &request.canonical_roots, + ); + request.queries[0].selected_plan_root = + strategy.candidate(&request.canonical_roots[0]).unwrap(); + request.queries[1].selected_plan_root = + crate::planner_selection::keep_pre_asap(&request.canonical_roots[1]).unwrap(); + let candidates = enumerate_exact_and_materialized_candidates(request).unwrap(); + assert!(candidates + .iter() + .any(|candidate| candidate.queries.iter().all(|query| { + super::super::maintained_population::supported_node(&query.selected_plan_root) + }))); + } + + /// Instant counts select current membership, never accumulated observations. + #[test] + fn local_grouped_count_has_a_bindable_candidate() { + let mut input = fixture(); + input.workload_cost_evidence = None; + input.physical_inputs.require_backend_local_execution = true; + input.data_workload.data_ingestion_interval.value = + Some(planner_types::workload::DurationMs(60_000)); + input.physical_inputs.scrape_interval_ms = 60_000; + let queries = input.query_workload.repeating_queries.as_mut().unwrap(); + queries.truncate(1); + queries[0].query = planner_types::workload::Query("count by(job)(m)".into()); + let plan = with_unit_quotes(input).compile_promql().unwrap(); + assert!(plan + .query_plan + .entries + .values() + .all(|entry| entry.nodes.values().any(|node| matches!( + node, + crate::query_plan::QueryPlanNode::Logical { + operator: crate::query_plan::residual::ResidualQueryOperator::CurrentSeries { + readout: asap_types::query_plan::current_series::SeriesReadout::Count, + .. + }, + .. + } + )))); + } + fn fixture() -> BackendLocalPlanningInput { let mut snapshot: BackendLocalPlanningInput = serde_json::from_str(include_str!( "../../../docs/examples/asapquery-planning-snapshot.json" @@ -952,7 +1173,7 @@ mod tests { let (mut request, env) = fixture().into_physical_compilation_request().unwrap(); request.allow_mixed_summary_and_exact_execution = false; request.queries[0].window_realization_candidates.clear(); - let candidates = enumerate_exact_and_materialized_candidates(request).unwrap(); + let candidates = enumerate_frontier_candidates(request).unwrap(); let (manifests, explanations) = compile_candidates_for_pricing( candidates, env, @@ -1078,7 +1299,7 @@ mod tests { crate::types::AccuracyTarget::Exact, ); let (request, environment) = snapshot.into_physical_compilation_request().unwrap(); - let candidates = enumerate_exact_and_materialized_candidates(request).unwrap(); + let candidates = enumerate_frontier_candidates(request).unwrap(); assert_eq!( candidates.len(), 5, @@ -1156,7 +1377,7 @@ mod tests { WorkloadCostEvidence, ) { let (request, env) = fixture().into_physical_compilation_request().unwrap(); - let candidates = enumerate_exact_and_materialized_candidates(request).unwrap(); + let candidates = enumerate_frontier_candidates(request).unwrap(); let quotes = candidates .iter() .map(|candidate| { @@ -1188,9 +1409,48 @@ mod tests { (candidates, env, evidence) } + /// Quote every bindable candidate at unit cost, leaving admission to decide. + fn with_unit_quotes(mut input: BackendLocalPlanningInput) -> BackendLocalPlanningInput { + let (request, env) = input.clone().into_physical_compilation_request().unwrap(); + let quotes = enumerate_exact_and_materialized_candidates(request) + .unwrap() + .into_iter() + .filter_map(|candidate| { + let plan = DeploymentPlanCompiler + .compile_promql(candidate.clone(), env.clone()) + .ok()?; + let manifest = manifest(&plan, &candidate.queries).unwrap(); + let unit_costs = manifest + .components + .keys() + .map(|id| (id.clone(), 1.0)) + .collect(); + Some(WorkloadQuote { + manifest, + executable: true, + unit_costs, + }) + }) + .collect(); + input.workload_cost_evidence = Some(WorkloadCostEvidence { + backend_revision: crate::physical::compiler::BACKEND_REVISION.into(), + planner_revision: crate::physical::compiler::PLANNER_REVISION.into(), + data_snapshot_id: input + .physical_inputs + .data_snapshot_id + .clone() + .unwrap_or_else(|| "fixture-data-v1".into()), + model_version: "test-only-unit-costs".into(), + observed_at_unix_ms: env.observed_at_unix_ms, + valid_for_ms: env.max_evidence_age_ms, + quotes, + }); + input + } + // Retained local input is priced once per metric, separate from the native service. #[test] - fn counter_materialization_manifest_prices_owned_state_and_distinct_native_alternative() { + fn counter_materialization_manifest_prices_owned_state_and_distinct_native_candidate() { use planner_types::workload::{AccuracyRequirement, Query}; let mut snapshot = fixture(); let entry = &mut snapshot.query_workload.repeating_queries.as_mut().unwrap()[0]; @@ -1245,19 +1505,14 @@ mod tests { ), ("sum_over_time(m[1m]) + count_over_time(m[1m])", vec!["m"]), ] { - let (mut request, env) = fixture().into_physical_compilation_request().unwrap(); - request.queries[0].query_string = query.into(); - request - .query_workload - .as_mut() - .unwrap() - .repeating_queries - .as_mut() - .unwrap()[0] - .query = planner_types::workload::Query(query.into()); + let mut input = fixture(); + input.query_workload.repeating_queries.as_mut().unwrap()[0].query = + planner_types::workload::Query(query.into()); + let (request, env) = input.into_physical_compilation_request().unwrap(); let exact = enumerate_exact_and_materialized_candidates(request) .unwrap() - .pop() + .into_iter() + .find(|candidate| !candidate.allow_mixed_summary_and_exact_execution) .unwrap(); let plan = DeploymentPlanCompiler .compile_promql(exact.clone(), env.clone()) @@ -1373,7 +1628,9 @@ mod tests { assert!(plan.cost_comparison.unwrap().candidate_evaluations[0] .unavailable_reason .is_some()); - evidence.quotes[1].executable = false; + for quote in &mut evidence.quotes[1..] { + quote.executable = false; + } assert!(select_lowest_cost_candidate(candidates.clone(), env.clone(), &evidence).is_err()); let (_, _, mut evidence) = quoted(); evidence @@ -1469,7 +1726,7 @@ mod tests { } #[test] - fn exact_alternative_does_not_require_unused_state_implementation_evidence() { + fn exact_candidate_does_not_require_unused_state_implementation_evidence() { let (candidates, env, evidence) = quoted(); let mut exact = candidates[1].clone(); assert_eq!( diff --git a/control_plane/src/planner_selection.rs b/control_plane/src/planner_selection.rs index 81ead342b..9e73361db 100644 --- a/control_plane/src/planner_selection.rs +++ b/control_plane/src/planner_selection.rs @@ -1,8 +1,8 @@ -//! Deployment-owned selection at the latest ASAPPlanner boundary. +//! Supplies deployment capabilities and cost/accuracy evidence to ASAPPlanner. //! -//! ASAPPlanner enumerates a ranked candidate space and deliberately does not -//! commit to one deployment plan. The backend owns that decision because it -//! also owns placement, runtime capabilities, and the physical wire contract. +//! Planner constructs, evaluates, and selects computation candidates. This +//! adapter registers the supported strategies and retains selection evidence; +//! DeploymentPlanCompiler binds the resulting computation and lifecycle. use std::rc::Rc; @@ -331,6 +331,44 @@ pub fn select_workload_with_accuracy_model_and_trace( Ok((selected, trace)) } +/// Preserve Planner candidates for deployment admission and pricing. This +/// does not select a winner or interpret an absent runtime quote as illegality. +/// Each returned forest has one root; independent roots remain factored rather +/// than materializing the workload's Cartesian product. +pub fn enumerate_workload_candidates( + roots: Vec<(usize, Rc)>, + accuracy: AccuracyTarget, + cost_model: &ControlPlaneCostModel, + evidence: &dyn AccuracyEvidenceProvider, + accuracy_model: &dyn AccuracyModel, +) -> Result, SelectionError> { + let strategies = replacement_strategies(cost_model, evidence, accuracy_model); + let space = asap_aware_mapping::search_workload_with_targets( + roots + .into_iter() + .map(|(id, root)| (id, root, Some(accuracy.clone()))) + .collect(), + &strategies, + accuracy_model, + ); + let mut inventory = asap_aware_mapping::replacement::CandidateDagInventory { + candidates: Vec::new(), + rejected_assemblies: Vec::new(), + }; + for (id, _) in &space.roots { + let root = space + .enumerate_candidate_dags_for_root(id, 65_536) + .map_err(|error| SelectionError::Workload(error.to_string()))?; + inventory.candidates.extend(root.candidates); + inventory.rejected_assemblies.extend( + root.rejected_assemblies + .into_iter() + .map(|reason| format!("query {id}: {reason}")), + ); + } + Ok(inventory) +} + /// The [`ReplacementStrategy`] set this deployment registers, carrying its own /// cost and accuracy models. This is deliberately not Planner's /// `default_strategies_with`: two of the strategies that list would give us are diff --git a/control_plane/src/query_plan.rs b/control_plane/src/query_plan.rs index d0900cb62..2d06a3cb6 100644 --- a/control_plane/src/query_plan.rs +++ b/control_plane/src/query_plan.rs @@ -41,6 +41,7 @@ where }; let root = compiler.lower(root)?; Ok(QueryPlanEntry { + physical_dag: None, language: QueryLanguage::PromQl, query_id, canonical_query, @@ -81,6 +82,7 @@ where }; let root = compiler.lower_relation(root)?; Ok(QueryPlanEntry { + physical_dag: None, language: QueryLanguage::ClickHouseSql, query_id, canonical_query, @@ -123,6 +125,7 @@ where }; let root = compiler.lower(root)?; let mut entry = QueryPlanEntry { + physical_dag: None, language: QueryLanguage::PromQl, query_id, canonical_query, @@ -339,7 +342,8 @@ where } for (local, mut physical) in nodes { match &mut physical { - QueryPlanNode::PhysicalRelation { inputs, .. } + QueryPlanNode::Physical { inputs, .. } + | QueryPlanNode::PhysicalRelation { inputs, .. } | QueryPlanNode::PhysicalFragment { inputs, .. } | QueryPlanNode::Logical { inputs, .. } | QueryPlanNode::SummaryMerge { inputs } @@ -1061,6 +1065,7 @@ mod catalog_binding_tests { config.pane_origin_ms = Some(0); let catalog = SummaryCatalog::from_materializations(7, 2, &[config.clone()]).unwrap(); let entry = QueryPlanEntry { + physical_dag: None, language: crate::query_plan::QueryLanguage::PromQl, query_id: "q".into(), canonical_query: "sum_over_time(m[1m])".into(), @@ -1316,6 +1321,7 @@ mod tests { #[test] fn language_tag_preserves_query_entry_serde() { let entry = QueryPlanEntry { + physical_dag: None, language: crate::query_plan::QueryLanguage::PromQl, query_id: "q".into(), canonical_query: canonical_promql("up").unwrap(), @@ -1343,6 +1349,7 @@ mod tests { #[test] fn language_catalog_keys_keep_equal_query_text_distinct() { let base = QueryPlanEntry { + physical_dag: None, language: QueryLanguage::PromQl, query_id: "prom".into(), canonical_query: "shared".into(), @@ -1438,6 +1445,7 @@ mod tests { ) .unwrap(); let entry = QueryPlanEntry { + physical_dag: None, language: QueryLanguage::PromQl, query_id: "q".into(), canonical_query: "topk(1, m)".into(), @@ -1489,6 +1497,7 @@ mod tests { }, ); let entry = QueryPlanEntry { + physical_dag: None, language: crate::query_plan::QueryLanguage::PromQl, query_id: "q".into(), canonical_query: "up".into(), @@ -1515,6 +1524,7 @@ mod tests { reason: "prepared".into(), }; let entry = QueryPlanEntry { + physical_dag: None, language: crate::query_plan::QueryLanguage::PromQl, query_id: "q".into(), canonical_query: "topk(2, rate(m[5m]))".into(), diff --git a/control_plane/src/query_plan/query_time.rs b/control_plane/src/query_plan/query_time.rs index 7f1afc89e..192618634 100644 --- a/control_plane/src/query_plan/query_time.rs +++ b/control_plane/src/query_plan/query_time.rs @@ -302,6 +302,7 @@ pub fn compile_logical( }; let root = lower.lower(&expr)?; let entry = QueryPlanEntry { + physical_dag: None, language: super::QueryLanguage::PromQl, query_id, canonical_query, @@ -517,7 +518,7 @@ pub(super) fn query_time_nodes( } } Err(invalid( - "Planner fragment does not match any original query subtree", + "Planner logical fragment does not match any original query subtree", )) } @@ -546,7 +547,7 @@ pub(super) fn binary_operator( }); } if operator.vector_match.is_some() { - return Err(invalid("explicit fragment vector matching unsupported")); + return Err(invalid("explicit logical vector matching unsupported")); } let operation = match operator.kind.to_string().as_str() { "+" => BinaryOperation::Add, @@ -596,7 +597,7 @@ pub(super) fn selected_native_expression( ) -> Result { if !selected.guarantee.as_ref().is_some_and(|g| g.is_exact()) { return Err(invalid( - "native fragment substitution requires an exact selected value", + "native subtree substitution requires an exact selected value", )); } let selected = match &selected.expr { diff --git a/control_plane/tests/discovery_snapshot.rs b/control_plane/tests/discovery_snapshot.rs index c6e4a69b0..ecedfd3fa 100644 --- a/control_plane/tests/discovery_snapshot.rs +++ b/control_plane/tests/discovery_snapshot.rs @@ -55,7 +55,7 @@ fn discovered_snapshot_plans_with_observed_cadence_and_promql_history() { .into_physical_compilation_request() .unwrap(); assert_eq!(request.scrape_interval_ms, Some(60_000)); - assert_eq!(request.queries[0].query_lookback_seconds, 3660); + assert_eq!(request.queries[0].query_lookback_ms, 3_660_000); let roundtrip: BackendLocalPlanningInput = serde_json::from_value(serde_json::to_value(&snapshot).unwrap()).unwrap(); assert_eq!(snapshot, roundtrip); diff --git a/control_plane/tests/native_rate_topk.rs b/control_plane/tests/native_rate_topk.rs new file mode 100644 index 000000000..0ac493186 --- /dev/null +++ b/control_plane/tests/native_rate_topk.rs @@ -0,0 +1,278 @@ +//! Planner owns Rate ranking; Backend binds durable state and prices candidates. +use control_plane::physical::{ + compiler::{BackendLocalPlanningInput, DeploymentPlanCompiler}, + workload_cost::enumerate_exact_and_materialized_candidates, +}; +use serde_json::{json, Value}; + +fn fixture(certified: bool) -> BackendLocalPlanningInput { + let mut wire: Value = serde_json::from_str(include_str!( + "../../docs/examples/asapquery-compatibility-demo-snapshot.json" + )) + .unwrap(); + let query = "topk by (job) (1, rate(requests_total[1m]))"; + let mut entry = wire["query_workload"]["repeating_queries"][3].clone(); + entry["query"] = query.into(); + entry["requirements"]["accuracy"] = + json!({"explicit":{"EpsilonDelta":{"epsilon":0.1,"delta":0.1}}}); + wire["query_workload"]["repeating_queries"] = json!([entry]); + wire["implementation"]["topk_evidence"] = json!({}); + wire["implementation"]["data_snapshot_id"] = "snapshot-topk-test".into(); + if certified { + wire["implementation"]["accuracy_evidence"] = json!({query: { + "query_string": query, "data_snapshot_id":"snapshot-topk-test", + "data_workload": wire["data_workload"], "source":"enforced-fixture-contract", + "observed_at_unix_ms":9500, "valid_for_ms":60000, + "topk_max_distinct_items":1000, + "topk_selected_lower_bound":101.0, "topk_excluded_upper_bound":100.0, + "topk_interval_failure_probability":0.001 + }}); + } + serde_json::from_value(wire).unwrap() +} + +// An admitted Rate heap reads durable counter state; it must not become an +// external whole-query fallback or accumulate counter samples as heap weights. +#[test] +fn rate_heap_candidates_bind_durable_counter_windows() { + let (request, environment) = fixture(true).into_physical_compilation_request().unwrap(); + let mut families = std::collections::BTreeSet::new(); + for candidate in enumerate_exact_and_materialized_candidates(request).unwrap() { + let Ok(plan) = DeploymentPlanCompiler.compile_promql(candidate, environment.clone()) else { + continue; + }; + if plan + .precompute_plan + .executable_dags + .values() + .any(|dag| !dag.native_programs.is_empty()) + { + continue; + } + let entry = plan.query_plan.entries.values().next().unwrap(); + let Some(program) = &entry.physical_dag else { + continue; + }; + for family in ["CmsWithHeap", "CountSketchWithHeap"] { + if program.to_string().contains(family) { + assert_eq!(entry.instant.lookback_ms, 60_000); + assert!(entry.nodes.values().any(|node| matches!( + node, + asap_types::query_plan::QueryPlanNode::ExactReadout { + readout: asap_types::query_plan::ExactReadout::Rate, + .. + } + ))); + assert_eq!(entry.materialization_bindings().len(), 1); + assert_eq!( + entry.materialization_bindings()[0].readout_lookback_ms, + Some(60_000) + ); + assert!(!plan.precompute_plan.materializations.is_empty()); + let mut restored: asap_types::query_plan::QueryPlanEntry = + serde_json::from_value(serde_json::to_value(entry).unwrap()).unwrap(); + restored.recover_vector_physical_dag().unwrap(); + restored.physical_dag = None; + assert!(restored.recover_vector_physical_dag().is_err()); + let mut drifted = entry.clone(); + if let asap_types::query_plan::QueryPlanNode::Physical { source_nodes, .. } = + drifted.nodes.get_mut(&drifted.root).unwrap() + { + source_nodes[0] += 100; + } + assert!(drifted.recover_vector_physical_dag().is_err()); + let mut wrong_window = entry.clone(); + wrong_window.instant.lookback_ms = 5_000; + assert!(wrong_window.recover_vector_physical_dag().is_err()); + families.insert(family); + } + } + } + assert_eq!( + families, + std::collections::BTreeSet::from(["CmsWithHeap", "CountSketchWithHeap"]) + ); +} + +// Scoped evidence is required even when a physical heap can be compiled. +#[test] +fn missing_rate_heap_proof_leaves_exact_candidate_available() { + let (request, environment) = fixture(false).into_physical_compilation_request().unwrap(); + let mut exact = false; + for candidate in enumerate_exact_and_materialized_candidates(request).unwrap() { + if let Ok(plan) = DeploymentPlanCompiler.compile_promql(candidate, environment.clone()) { + for entry in plan.query_plan.entries.values() { + if let Some(program) = &entry.physical_dag { + assert!(!program.to_string().contains("WithHeap")); + exact = true; + } + } + } + } + assert!(exact); +} + +// Provider costs may choose exact ranking, CMS or CountSketch over the same +// counter population; no family is selected before deployment costs exist. +#[test] +fn rate_heap_costs_can_select_each_compiled_candidate() { + use control_plane::physical::{ + compiler::{BACKEND_REVISION, PLANNER_REVISION}, + workload_cost::{manifest, WorkloadCostEvidence, WorkloadQuote}, + }; + for preferred in ["exact", "CmsWithHeap", "CountSketchWithHeap"] { + let mut input = fixture(true); + let (request, environment) = input.clone().into_physical_compilation_request().unwrap(); + let quotes = enumerate_exact_and_materialized_candidates(request) + .unwrap() + .into_iter() + .filter_map(|candidate| { + let plan = DeploymentPlanCompiler + .compile_promql(candidate.clone(), environment.clone()) + .ok()?; + let entry = plan.query_plan.entries.values().next().unwrap(); + let program = entry + .physical_dag + .as_ref() + .map(ToString::to_string) + .unwrap_or_default(); + let preferred_plan = entry.physical_vector_binding().is_some() + && !plan + .precompute_plan + .executable_dags + .values() + .any(|dag| !dag.native_programs.is_empty()) + && if preferred == "exact" { + !program.contains("WithHeap") + } else { + program.contains(preferred) + }; + let manifest = manifest(&plan, &candidate.queries).unwrap(); + Some(WorkloadQuote { + unit_costs: manifest + .components + .keys() + .map(|key| (key.clone(), if preferred_plan { 1.0 } else { 1e12 })) + .collect(), + manifest, + executable: true, + }) + }) + .collect(); + input.workload_cost_evidence = Some(WorkloadCostEvidence { + backend_revision: BACKEND_REVISION.into(), + planner_revision: PLANNER_REVISION.into(), + data_snapshot_id: "snapshot-topk-test".into(), + model_version: "controlled-rate-ranking-cost".into(), + observed_at_unix_ms: 10000, + valid_for_ms: 60000, + quotes, + }); + let plan = input.compile_promql().unwrap(); + let entry = plan.query_plan.entries.values().next().unwrap(); + assert!(entry.physical_vector_binding().is_some()); + let program = entry.physical_dag.as_ref().unwrap().to_string(); + if preferred == "exact" { + assert!(!program.contains("WithHeap")); + } else { + assert!(program.contains(preferred)); + } + } +} + +// Fixed-window candidates retain a native maintenance graph and read its heap +// state directly; removing that graph must make recovered installation invalid. +#[test] +fn fixed_window_rate_heap_candidates_install_both_physical_graphs() { + let mut input = fixture(true); + let mut wire = serde_json::to_value(&input).unwrap(); + wire["query_workload"]["repeating_queries"][0]["demand"]["fixed_interval_at"]["interval"] = + 60_000.into(); + wire["query_workload"]["repeating_queries"][0]["demand"]["fixed_interval_at"] + ["evaluation_phase"] = 0.into(); + input = serde_json::from_value(wire).unwrap(); + let (request, environment) = input.into_physical_compilation_request().unwrap(); + let mut families = std::collections::BTreeSet::new(); + let mut errors = Vec::new(); + for candidate in enumerate_exact_and_materialized_candidates(request).unwrap() { + let plan = match DeploymentPlanCompiler.compile_promql(candidate, environment.clone()) { + Ok(plan) => plan, + Err(error) => { + errors.push(error.to_string()); + continue; + } + }; + for installed in plan.precompute_plan.executable_dags.values() { + for sink in installed.native_programs.keys() { + let program = installed.native_program(*sink).unwrap().unwrap(); + let encoded = String::from_utf8(program.encode().unwrap()).unwrap(); + for family in ["CmsWithHeap", "CountSketchWithHeap"] { + if encoded.contains(family) { + families.insert(family); + } + } + assert!(encoded.contains("Rate")); + let entry = plan.query_plan.entries.values().next().unwrap(); + let query = entry.recover_vector_physical_dag().unwrap(); + assert!(!String::from_utf8(query.encode().unwrap()) + .unwrap() + .contains("KeyedSummaryBuild")); + let mut broken = plan.precompute_plan.clone(); + for installed in broken.executable_dags.values_mut() { + installed.native_programs.clear(); + } + assert!(broken.validate().is_err()); + } + } + } + assert_eq!( + families, + std::collections::BTreeSet::from(["CmsWithHeap", "CountSketchWithHeap"]), + "{errors:#?}" + ); +} + +// Rate must precede grouped Sum in both placements; deployment chooses ownership. +#[test] +fn grouped_rate_has_native_query_and_maintenance_candidates() { + let mut wire = serde_json::to_value(fixture(false)).unwrap(); + let entry = &mut wire["query_workload"]["repeating_queries"][0]; + entry["query"] = "sum by(job)(rate(requests_total[1m]))".into(); + entry["requirements"]["accuracy"] = json!({"explicit":"Exact"}); + entry["demand"]["fixed_interval_at"]["interval"] = 5_000.into(); + entry["demand"]["fixed_interval_at"]["evaluation_phase"] = 0.into(); + wire["implementation"]["accuracy_evidence"] = json!({}); + let input: BackendLocalPlanningInput = serde_json::from_value(wire).unwrap(); + let (request, environment) = input.into_physical_compilation_request().unwrap(); + let mut placements = std::collections::BTreeSet::new(); + let mut errors = Vec::new(); + for candidate in enumerate_exact_and_materialized_candidates(request).unwrap() { + let plan = match DeploymentPlanCompiler.compile_promql(candidate, environment.clone()) { + Ok(plan) => plan, + Err(error) => { + errors.push(error.to_string()); + continue; + } + }; + let entry = plan.query_plan.entries.values().next().unwrap(); + let Some(program) = &entry.physical_dag else { + continue; + }; + if entry.physical_vector_binding().is_none() { + continue; + } + entry.recover_vector_physical_dag().unwrap(); + let stored = plan + .precompute_plan + .executable_dags + .values() + .any(|dag| !dag.native_programs.is_empty()); + assert_eq!(program.to_string().contains("SummaryBuild"), !stored); + placements.insert(stored); + } + assert_eq!( + placements, + std::collections::BTreeSet::from([false, true]), + "{errors:#?}" + ); +} diff --git a/control_plane/tests/native_snapshot_topk.rs b/control_plane/tests/native_snapshot_topk.rs new file mode 100644 index 000000000..c4a03c7b1 --- /dev/null +++ b/control_plane/tests/native_snapshot_topk.rs @@ -0,0 +1,135 @@ +//! Planner owns the snapshot heap program; Backend admits and prices it. +use control_plane::physical::{ + compiler::{ + BackendLocalPlanningInput, DeploymentPlanCompiler, BACKEND_REVISION, PLANNER_REVISION, + }, + workload_cost::{ + enumerate_exact_and_materialized_candidates, manifest, WorkloadCostEvidence, WorkloadQuote, + }, +}; +use serde_json::{json, Value}; + +fn fixture(certified: bool) -> BackendLocalPlanningInput { + let mut wire: Value = serde_json::from_str(include_str!( + "../../docs/examples/asapquery-compatibility-demo-snapshot.json" + )) + .unwrap(); + let query = "topk by (job) (1, spatial_value)"; + let mut entry = wire["query_workload"]["repeating_queries"][3].clone(); + entry["query"] = query.into(); + entry["requirements"]["accuracy"] = + json!({"explicit":{"EpsilonDelta":{"epsilon":0.1,"delta":0.1}}}); + wire["query_workload"]["repeating_queries"] = json!([entry]); + wire["implementation"]["topk_evidence"] = json!({}); + wire["implementation"]["data_snapshot_id"] = "snapshot-topk-test".into(); + if certified { + wire["implementation"]["accuracy_evidence"] = json!({query: { + "query_string": query, "data_snapshot_id":"snapshot-topk-test", + "data_workload": wire["data_workload"], "source":"enforced-fixture-contract", + "observed_at_unix_ms":9500, "valid_for_ms":60000, + "topk_max_distinct_items":1000, + "topk_selected_lower_bound":101.0, "topk_excluded_upper_bound":100.0, + "topk_interval_failure_probability":0.001 + }}); + } + serde_json::from_value(wire).unwrap() +} + +fn heap(plan: &control_plane::physical::compiler::CompiledPhysicalPlan) -> bool { + plan.query_plan.entries.values().any(|entry| { + entry + .physical_dag + .as_ref() + .is_some_and(|program| program.to_string().contains("CountSketchWithHeap")) + }) +} + +// Same physical inventory, different deployment quotes: selection must reverse. +#[test] +fn snapshot_heap_and_exact_candidates_reach_deployment_cost_selection() { + for prefer_heap in [false, true] { + let mut input = fixture(true); + let (request, environment) = input.clone().into_physical_compilation_request().unwrap(); + assert!(request + .planner_selection_trace + .iter() + .any(|event| event["stage"] == "planner.physical_candidate" + && event.get("physical_dag").is_some())); + let candidates = enumerate_exact_and_materialized_candidates(request).unwrap(); + let mut saw_heap = false; + let mut saw_exact = false; + let quotes = candidates + .into_iter() + .filter_map(|candidate| { + let plan = DeploymentPlanCompiler + .compile_promql(candidate.clone(), environment.clone()) + .ok()?; + let is_heap = heap(&plan); + let local = plan + .query_plan + .entries + .values() + .all(|entry| entry.population_snapshot().is_some()); + saw_heap |= is_heap; + saw_exact |= local && !is_heap; + if is_heap { + assert!(plan.precompute_plan.materializations.is_empty()); + let entry = plan.query_plan.entries.values().next().unwrap(); + let restored: asap_types::query_plan::QueryPlanEntry = + serde_json::from_value(serde_json::to_value(entry).unwrap()).unwrap(); + restored.recover_population_physical_dag().unwrap(); + } + let manifest = manifest(&plan, &candidate.queries).unwrap(); + Some(WorkloadQuote { + unit_costs: manifest + .components + .keys() + .map(|key| { + ( + key.clone(), + if local && is_heap == prefer_heap { + 1.0 + } else { + 1e12 + }, + ) + }) + .collect(), + manifest, + executable: true, + }) + }) + .collect(); + assert!( + saw_heap, + "certified signed heap candidate never reached pricing" + ); + assert!(saw_exact, "exact population candidate disappeared"); + input.workload_cost_evidence = Some(WorkloadCostEvidence { + backend_revision: BACKEND_REVISION.into(), + planner_revision: PLANNER_REVISION.into(), + data_snapshot_id: "snapshot-topk-test".into(), + model_version: "fixture-cost-reversal".into(), + observed_at_unix_ms: 10000, + valid_for_ms: 60000, + quotes, + }); + assert_eq!(heap(&input.compile_promql().unwrap()), prefer_heap); + } +} + +// A priced physical graph is not an accuracy proof. Missing population/gap +// evidence must leave the exact candidate available and the heap uninstalled. +#[test] +fn unknown_snapshot_heap_guarantee_cannot_be_installed() { + let (request, environment) = fixture(false).into_physical_compilation_request().unwrap(); + assert!(request + .planner_selection_trace + .iter() + .any(|event| event["stage"] == "planner.physical_candidate")); + for candidate in enumerate_exact_and_materialized_candidates(request).unwrap() { + if let Ok(plan) = DeploymentPlanCompiler.compile_promql(candidate, environment.clone()) { + assert!(!heap(&plan)); + } + } +} diff --git a/crates/asap_types/src/executable_plan.rs b/crates/asap_types/src/executable_plan.rs index aff1ced0d..8e2980ab5 100644 --- a/crates/asap_types/src/executable_plan.rs +++ b/crates/asap_types/src/executable_plan.rs @@ -178,6 +178,10 @@ impl OwnedPostAsapDag { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct InstalledPostAsapDag { + /// Planner-compiled bounded programs keyed by persisted output node. + /// Deployment bindings below identify their stored input/output instances. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub native_programs: BTreeMap, pub document: OwnedPostAsapDag, pub binding: BackendExecutableBinding, } @@ -190,7 +194,64 @@ impl InstalledPostAsapDag { if self.document.schema_version != PRECOMPUTE_DAG_SCHEMA_VERSION { return Err("unsupported maintenance DAG document version".into()); } - self.binding.validate_precompute(&self.document.decode()?) + self.binding + .validate_precompute(&self.document.decode()?)?; + for sink in self.native_programs.keys() { + self.native_program(*sink)?; + } + Ok(()) + } + + /// Recovery validates the physical producer's typed storage boundaries; + /// it never lowers the semantic provenance document again. + pub fn native_program( + &self, + sink: PostAsapNodeId, + ) -> Result, String> + { + let Some(value) = self.native_programs.get(&sink) else { + return Ok(None); + }; + let program = asap_physical_operators::physical_planner::CompiledPhysicalDag::decode( + &serde_json::to_vec(value).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + if program.roots() != [u64::from(sink.0)] || !self.binding.precompute_sinks.contains(&sink) + { + return Err("native maintenance program differs from its installed sink".into()); + } + let dag = self.document.decode()?; + for (id, contract) in program.input_contracts() { + let id = PostAsapNodeId(u32::try_from(id).map_err(|_| "physical source id overflow")?); + if id == sink + || !matches!( + self.binding.node(id), + Some(BackendNodeBinding::Materialization { .. }) + ) + || dag + .nodes + .iter() + .find(|n| n.id == id) + .is_none_or(|n| n.output_schema != *contract.schema) + { + return Err( + "native maintenance source differs from installed state boundary".into(), + ); + } + } + let output = program + .output_contract(u64::from(sink.0)) + .map_err(|e| e.to_string())?; + if program.input_contracts().count() == 0 + || dag + .nodes + .iter() + .find(|n| n.id == sink) + .is_none_or(|n| n.output_schema != *output.schema) + { + return Err("native maintenance output differs from semantic schema".into()); + } + Ok(Some(program)) } /// Project the selected semantic DAG onto the maintenance ancestors of its @@ -379,6 +440,7 @@ mod tests { #[test] fn installed_maintenance_dag_rejects_complete_dag_version() { let installed = InstalledPostAsapDag { + native_programs: std::collections::BTreeMap::new(), document: OwnedPostAsapDag { schema_version: OWNED_POST_ASAP_DAG_SCHEMA_VERSION, query_id: "q".into(), diff --git a/crates/asap_types/src/precompute_plan.rs b/crates/asap_types/src/precompute_plan.rs index 05227b679..7a6d77c62 100644 --- a/crates/asap_types/src/precompute_plan.rs +++ b/crates/asap_types/src/precompute_plan.rs @@ -153,6 +153,8 @@ pub struct IngestContract { pub enum StateEncoding { SketchlibProtobufV1, SketchCoreMsgpackV1, + /// Backend-produced typed physical output, distinct from legacy sketch frames. + NativeBatchV1, ExactAccumulatorV1, /// Persisted backend state with explicit Planner family and population layout. PlannerExactAccumulatorV1, @@ -569,15 +571,6 @@ impl PrecomputePlan { return Err(invalid()); } validated_source_window_cohort(config, &sources)?; - if sources - .iter() - .any(|source| !matches!(source.aggregation_type, crate::AggregationType::Sum)) - { - return Err(invalid()); - } - if config.window_size != config.slide_interval { - return Err(invalid()); - } let mut matched = false; for installed in self.executable_dags.values() { let dag = installed @@ -596,8 +589,30 @@ impl PrecomputePlan { .iter() .find(|node| node.id == *sink) .ok_or_else(invalid)?; - validate_maintenance_reduction(config, target_node) + let native = installed + .native_program(*sink) .map_err(PrecomputePlanError::CatalogContract)?; + if sources.iter().any(|source| { + !matches!(source.aggregation_type, crate::AggregationType::Sum) + && !(native.is_some() + && matches!(source.aggregation_type, crate::AggregationType::Rate)) + }) { + return Err(invalid()); + } + if native.is_none() { + if config.window_size != config.slide_interval { + return Err(invalid()); + } + validate_maintenance_reduction(config, target_node) + .map_err(PrecomputePlanError::CatalogContract)?; + } else if !matches!( + config.aggregation_type, + crate::AggregationType::CountMinSketchWithHeap + | crate::AggregationType::CountSketchWithHeap + | crate::AggregationType::Sum + ) { + return Err(invalid()); + } let inputs: Vec<_> = dag .edges .iter() @@ -877,7 +892,7 @@ impl PrecomputePlan { crate::WindowKind::Session => None, } || schema.window.pane_origin_ms != materialization.pane_origin_ms - || schema.encodings != state_encodings(&accumulator.family) + || !state_encodings_match(&accumulator.family, &schema.encodings) { return Err(PrecomputePlanError::InvalidSchema { schema_id: schema.schema_id.clone(), @@ -925,8 +940,22 @@ pub(crate) fn state_schema_id(fingerprint: crate::PolicyFingerprint) -> String { format!("{}:summary-state:v1:{}", BACKEND_COMPAT, fingerprint.0) } +// Persisted schemas may declare a subset of formats. Adding a decoder must +// not invalidate existing installed plans that use only older supported codecs. +pub(crate) fn state_encodings_match( + family: &SummaryFamilyType, + encodings: &[StateEncoding], +) -> bool { + let supported = state_encodings(family); + !encodings.is_empty() + && encodings.iter().collect::>().len() == encodings.len() + && encodings + .iter() + .all(|encoding| supported.contains(encoding)) +} + pub(crate) fn state_encodings(family: &SummaryFamilyType) -> Vec { - match family { + let mut encodings = match family { SummaryFamilyType::ExactAggregate( planner_types::post_asap::ExactKind::Increase | planner_types::post_asap::ExactKind::Rate, @@ -954,7 +983,25 @@ pub(crate) fn state_encodings(family: &SummaryFamilyType) -> Vec StateEncoding::SketchCoreMsgpackV1, ], _ => Vec::new(), + }; + if matches!( + family, + SummaryFamilyType::ExactAggregate( + planner_types::post_asap::ExactKind::Sum + | planner_types::post_asap::ExactKind::Count + | planner_types::post_asap::ExactKind::Min + | planner_types::post_asap::ExactKind::Max + | planner_types::post_asap::ExactKind::Rate + | planner_types::post_asap::ExactKind::Increase, + _ + ) + ) || matches!(family, SummaryFamilyType::Sketch(kind, _) if matches!(kind.algorithm(), + SketchAlgorithm::Kll | SketchAlgorithm::DDSketch | SketchAlgorithm::Hll | + SketchAlgorithm::CmsWithHeap | SketchAlgorithm::CountSketchWithHeap)) + { + encodings.push(StateEncoding::NativeBatchV1); } + encodings } #[cfg(test)] @@ -972,6 +1019,36 @@ mod source_window_cohort_tests { })) .unwrap() } + // New native-format support must not invalidate persisted older codec subsets. + #[test] + fn older_encoding_subsets_remain_valid_and_unknown_codecs_fail() { + use planner_types::post_asap::{SketchKind, SketchParams}; + let family = SummaryFamilyType::Sketch( + SketchKind::new(SketchAlgorithm::Kll, SketchParams::Kll { k: 200 }), + Default::default(), + ); + assert!(state_encodings_match( + &family, + &[ + StateEncoding::SketchlibProtobufV1, + StateEncoding::SketchCoreMsgpackV1 + ] + )); + assert!(state_encodings_match( + &family, + &[StateEncoding::NativeBatchV1] + )); + assert!(!state_encodings_match(&family, &[])); + assert!(!state_encodings_match( + &family, + &[StateEncoding::ExactAccumulatorV1] + )); + assert!(!state_encodings_match( + &family, + &[StateEncoding::NativeBatchV1, StateEncoding::NativeBatchV1] + )); + } + #[test] fn producer_roster_roundtrip_and_watermark_scope() { let envelope = PlanEnvelope { diff --git a/crates/asap_types/src/precompute_plan/catalog.rs b/crates/asap_types/src/precompute_plan/catalog.rs index bad95e7e5..ad086c2c0 100644 --- a/crates/asap_types/src/precompute_plan/catalog.rs +++ b/crates/asap_types/src/precompute_plan/catalog.rs @@ -206,7 +206,7 @@ impl PrecomputePlan { schema_id: schema.schema_id.clone(), }); } - if schema.encodings != state_encodings(&family) { + if !state_encodings_match(&family, &schema.encodings) { return Err(invalid("encoding does not match state family")); } if config.num_aggregates_to_retain == Some(0) { diff --git a/crates/asap_types/src/query_plan.rs b/crates/asap_types/src/query_plan.rs index 699ec35d5..cc3b0b3a3 100644 --- a/crates/asap_types/src/query_plan.rs +++ b/crates/asap_types/src/query_plan.rs @@ -6,7 +6,8 @@ //! searching for compatible materializations. pub mod current_series; -pub mod query_time; +mod native; +pub mod residual; use std::collections::{BTreeMap, BTreeSet}; @@ -295,6 +296,9 @@ pub use crate::executable_plan::QueryNodeId; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct QueryPlanEntry { + /// Planner-selected native computation, persisted before activation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub physical_dag: Option, #[serde(default)] pub language: QueryLanguage, pub query_id: String, @@ -422,6 +426,15 @@ impl QueryPlanEntry { self.query_id, self.root.0 ))); } + if self.physical_vector_binding().is_some() { + self.recover_vector_physical_dag()?; + } else if self.population_snapshot().is_some() { + self.recover_population_physical_dag()?; + } else if self.physical_dag.is_some() { + return Err(QueryPlanError::Invalid( + "physical program has no deployment input bindings".into(), + )); + } for (id, node) in &self.nodes { if self.language == QueryLanguage::ClickHouseSql && matches!( @@ -431,7 +444,7 @@ impl QueryPlanEntry { { return Err(QueryPlanError::Invalid("SQL plan contains uncompiled relation semantics; recompile the deployment plan".into())); } - validate_native_relation(*id, node)?; + if let QueryPlanNode::PhysicalRelation { inputs, dag } = node { if self.language != QueryLanguage::ClickHouseSql { return Err(QueryPlanError::Invalid( @@ -441,6 +454,26 @@ impl QueryPlanEntry { let compiled = asap_physical_operators::physical_planner::CompiledPhysicalDag::decode(dag) .map_err(|e| QueryPlanError::Invalid(e.to_string()))?; + for ((_, contract), input) in compiled.input_contracts().zip(inputs) { + if let Some(QueryPlanNode::ExternalExact { request, .. }) = + self.nodes.get(input) + { + let ExternalExactOutput::Relation { schema } = &request.output else { + return Err(QueryPlanError::Invalid( + "physical relation requires a relational external input".into(), + )); + }; + let schema: planner_types::post_asap::SummarySchema = + serde_json::from_value(schema.clone()) + .map_err(|e| QueryPlanError::Invalid(e.to_string()))?; + if &schema != contract.schema.as_ref() { + return Err(QueryPlanError::Invalid( + "physical relation input differs from its bound source schema" + .into(), + )); + } + } + } if compiled.roots().len() != 1 || compiled.input_contracts().count() != inputs.len() { return Err(QueryPlanError::Invalid( @@ -732,6 +765,12 @@ pub struct PruningInputContract { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] pub enum QueryPlanNode { + /// Typed deployment inputs for the Planner-provided vector computation. + Physical { + inputs: Vec, + source_nodes: Vec, + max_bytes: u64, + }, /// Complete Planner-compiled relation computation; inputs follow its typed slots. PhysicalRelation { inputs: Vec, @@ -813,7 +852,8 @@ impl QueryPlanNode { | Self::Relational { input, .. } | Self::SummaryEstimate { input, .. } | Self::ExactReadout { input, .. } => std::slice::from_ref(input), - Self::PhysicalRelation { inputs, .. } + Self::Physical { inputs, .. } + | Self::PhysicalRelation { inputs, .. } | Self::PhysicalFragment { inputs, .. } | Self::SummaryMerge { inputs } | Self::Logical { inputs, .. } @@ -925,6 +965,7 @@ mod contract_tests { } /// Bind portable relation semantics before an installed plan can access its sources. +#[cfg(test)] fn validate_native_relation(id: QueryNodeId, node: &QueryPlanNode) -> Result<(), QueryPlanError> { use planner_types::post_asap::{ ExecutableDagNode, ExecutableOperatorPayload as Payload, ExecutionDataState, PostAsapNodeId, diff --git a/crates/asap_types/src/query_plan/current_series.rs b/crates/asap_types/src/query_plan/current_series.rs index b109756b6..55fb01f67 100644 --- a/crates/asap_types/src/query_plan/current_series.rs +++ b/crates/asap_types/src/query_plan/current_series.rs @@ -49,8 +49,14 @@ impl SeriesPopulation { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum SeriesReadout { - Quantile { q: f64 }, - TopK { k: u64 }, + /// All eligible members for a Planner-compiled physical readout. + Snapshot, + Quantile { + q: f64, + }, + TopK { + k: u64, + }, Sum, Count, Average, diff --git a/crates/asap_types/src/query_plan/native.rs b/crates/asap_types/src/query_plan/native.rs new file mode 100644 index 000000000..67e25598c --- /dev/null +++ b/crates/asap_types/src/query_plan/native.rs @@ -0,0 +1,327 @@ +//! Retained physical programs for SQL relations and PromQL vectors. +use super::*; +use asap_physical_operators::physical_planner::CompiledPhysicalDag; +use planner_types::post_asap::SummarySchema; + +impl QueryPlanEntry { + pub fn population_snapshot(&self) -> Option<¤t_series::SeriesPopulation> { + if self.nodes.len() != 1 { + return None; + } + match self.nodes.get(&self.root) { + Some(QueryPlanNode::Logical { + operator: + residual::ResidualQueryOperator::CurrentSeries { + population, + readout: current_series::SeriesReadout::Snapshot, + }, + inputs, + }) if inputs.is_empty() => Some(population), + _ => None, + } + } + + /// Recover an installed population readout; the source binds the complete + /// maintained vector, while the physical program owns ranking and limiting. + pub fn recover_population_physical_dag(&self) -> Result { + let invalid = |message: &str| QueryPlanError::Invalid(message.into()); + let population = self + .population_snapshot() + .ok_or_else(|| invalid("missing population source binding"))?; + population.validate()?; + let value = self + .physical_dag + .as_ref() + .ok_or_else(|| invalid("missing installed population physical DAG"))?; + let dag = CompiledPhysicalDag::decode( + &serde_json::to_vec(value) + .map_err(|error| QueryPlanError::Invalid(error.to_string()))?, + ) + .map_err(|error| QueryPlanError::Invalid(error.to_string()))?; + let inputs = dag.input_contracts().collect::>(); + let [(_, input)] = inputs.as_slice() else { + return Err(invalid("population physical DAG requires one source")); + }; + let [root] = dag.roots() else { + return Err(invalid("population physical DAG requires one root")); + }; + let output = dag + .output_contract(*root) + .map_err(|error| QueryPlanError::Invalid(error.to_string()))?; + if input.schema != output.schema { + return Err(invalid( + "population ranking must preserve complete source rows", + )); + } + use planner_types::{post_asap::SummaryFamilyType, pre_asap::DataType}; + let fields = &input.schema.fields; + let column = |name: &str, dtype: DataType| { + fields.iter().any(|field| { + field.name == name + && field.dtype == SummaryFamilyType::Plain(dtype.clone()) + && !field.nullable + }) + }; + if !column( + asap_physical_operators::physical_planner::promql_rows::SERIES_IDENTITY_COLUMN, + DataType::Utf8, + ) || !column("value", DataType::Float64) + || input.schema.time_index.is_none_or(|index| { + fields[index].dtype != SummaryFamilyType::Plain(DataType::Timestamp) + }) + || population.grouping.without + || population.grouping.labels.iter().any(|label| { + !fields.iter().any(|field| { + &field.name == label && field.dtype == SummaryFamilyType::Plain(DataType::Utf8) + }) + }) + { + return Err(invalid( + "population physical source loses identity, value, timestamp or grouping", + )); + } + Ok(dag) + } +} + +impl QueryPlanEntry { + pub fn physical_vector_binding(&self) -> Option<(&[QueryNodeId], &[u64], u64)> { + match self.nodes.get(&self.root) { + Some(QueryPlanNode::Physical { + inputs, + source_nodes, + max_bytes, + }) => Some((inputs, source_nodes, *max_bytes)), + _ => None, + } + } + + /// Validate bound counter vectors or stored aggregate batches against the + /// retained physical program; recovery never lowers logical operators. + pub fn recover_vector_physical_dag(&self) -> Result { + let invalid = |message: &str| QueryPlanError::Invalid(message.into()); + let (inputs, source_nodes, max_bytes) = self + .physical_vector_binding() + .ok_or_else(|| invalid("missing physical vector binding"))?; + if max_bytes == 0 + || usize::try_from(max_bytes).is_err() + || inputs.is_empty() + || inputs.len() != source_nodes.len() + || source_nodes.iter().copied().collect::>().len() != source_nodes.len() + { + return Err(invalid("invalid physical vector source mapping or budget")); + } + for input in inputs { + if let Some(QueryPlanNode::ReadMaterialization { binding }) = self.nodes.get(input) { + if binding.readout_lookback_ms != Some(self.instant.lookback_ms) + || binding.window_ms != self.instant.lookback_ms + || binding.window_ms == 0 + || !matches!(&binding.output_grouping, PhysicalGrouping::Reduce(labels) if labels.is_empty()) + { + return Err(invalid(&format!("stored native batch requires one complete bound window: {binding:?}, query lookback {}", self.instant.lookback_ms))); + } + continue; + } + let Some(QueryPlanNode::ExactReadout { + input: state, + readout: ExactReadout::Rate, + }) = self.nodes.get(input) + else { + return Err(invalid( + "physical vector requires an exact per-series Rate readout", + )); + }; + let Some(QueryPlanNode::ReadMaterialization { binding }) = self.nodes.get(state) else { + return Err(invalid( + "physical Rate input requires its installed stored output", + )); + }; + if binding + .readout_lookback_ms + .is_none_or(|window| window == 0 || window != self.instant.lookback_ms) + || !matches!(&binding.output_grouping, PhysicalGrouping::PerEntity) + { + return Err(invalid( + "physical Rate input must preserve every series and its window", + )); + } + } + let value = self + .physical_dag + .as_ref() + .ok_or_else(|| invalid("missing installed vector physical DAG"))?; + let dag = CompiledPhysicalDag::decode( + &serde_json::to_vec(value).map_err(|e| QueryPlanError::Invalid(e.to_string()))?, + ) + .map_err(|e| QueryPlanError::Invalid(e.to_string()))?; + if dag + .input_contracts() + .map(|(id, _)| id) + .collect::>() + != source_nodes.iter().copied().collect() + || dag.roots().len() != 1 + { + return Err(invalid( + "physical vector program differs from installed source mapping", + )); + } + use planner_types::{post_asap::SummaryFamilyType, pre_asap::DataType}; + let vector_schema = |schema: &SummarySchema| { + [ + ( + asap_physical_operators::physical_planner::promql_rows::SERIES_IDENTITY_COLUMN, + DataType::Utf8, + ), + ("value", DataType::Float64), + ] + .into_iter() + .all(|(name, dtype)| { + schema.fields.iter().any(|f| { + f.name == name + && f.dtype == SummaryFamilyType::Plain(dtype.clone()) + && !f.nullable + }) + }) && schema.time_index.is_some_and(|i| { + schema + .fields + .get(i) + .is_some_and(|f| f.dtype == SummaryFamilyType::Plain(DataType::Timestamp)) + }) + }; + if dag + .input_contracts() + .any(|(id, input)| { + let position = source_nodes.iter().position(|source| *source == id).unwrap(); + if matches!(self.nodes.get(&inputs[position]), Some(QueryPlanNode::ReadMaterialization { .. })) { + let summaries = input.schema.fields.iter().filter(|field| !matches!(field.dtype, SummaryFamilyType::Plain(_))).collect::>(); + !matches!(summaries.as_slice(), [field] if match &field.dtype { + SummaryFamilyType::Sketch(kind, _) => matches!(kind.algorithm(), planner_types::post_asap::SketchAlgorithm::CmsWithHeap | planner_types::post_asap::SketchAlgorithm::CountSketchWithHeap), + SummaryFamilyType::ExactAggregate(planner_types::post_asap::ExactKind::Sum, _) => true, + _ => false, + }) + } else { !vector_schema(&input.schema) } + }) + || { + let output = dag.output_contract(dag.roots()[0]).map_err(|e| QueryPlanError::Invalid(e.to_string()))?; + output.schema.fields.iter().filter(|field| field.dtype == SummaryFamilyType::Plain(DataType::Float64)).count() != 1 + || output.schema.fields.iter().any(|field| !matches!(field.dtype, SummaryFamilyType::Plain(DataType::Utf8 | DataType::Float64 | DataType::Timestamp))) + } + { + return Err(invalid(&format!( + "physical program has incompatible input or result schema: inputs {:?}; output {:?}", + dag.input_contracts().map(|(id, contract)| (id, &contract.schema)).collect::>(), + dag.output_contract(dag.roots()[0]).map_err(|error| QueryPlanError::Invalid(error.to_string()))?.schema, + ))); + } + Ok(dag) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use planner_types::{ + post_asap::{SummaryFamilyType, SummaryField}, + pre_asap::DataType, + }; + + fn installed() -> QueryPlanEntry { + let schema = SummarySchema { + fields: vec![SummaryField { + name: "value".into(), + dtype: SummaryFamilyType::Plain(DataType::Float64), + nullable: false, + }], + time_index: None, + }; + let mut entry = QueryPlanEntry { + physical_dag: None, + language: QueryLanguage::ClickHouseSql, + query_id: "native".into(), + canonical_query: "SELECT value".into(), + fixed_evaluation: None, + root: QueryNodeId(0), + nodes: BTreeMap::from([( + QueryNodeId(0), + QueryPlanNode::ExternalExact { + request: ExternalExactRequest { + language: QueryLanguage::ClickHouseSql, + expression: "SELECT value".into(), + output: ExternalExactOutput::Relation { + schema: serde_json::to_value(&schema).unwrap(), + }, + parameters: BTreeMap::new(), + start_parameter: None, + end_parameter: None, + input_contracts: vec![], + }, + inputs: vec![], + }, + )]), + instant: InstantExecution { + lookback_ms: 0, + full_history: false, + cumulative_readout: false, + }, + fallback: FallbackPolicy::Reject, + }; + let physical = CompiledPhysicalDag::from_operators( + [( + 0, + asap_physical_operators::physical_planner::InputContract::bounded( + std::sync::Arc::new(schema), + ), + )] + .into(), + BTreeMap::new(), + vec![0], + ) + .unwrap(); + entry.root = QueryNodeId(1); + entry.nodes.insert( + entry.root, + QueryPlanNode::PhysicalRelation { + inputs: vec![QueryNodeId(0)], + dag: physical.encode().unwrap(), + }, + ); + entry + } + + // Restart keeps the physical program; unknown formats and stale bindings fail activation. + #[test] + fn recovery_validates_physical_version_roots_and_input_schema() { + let entry = installed(); + let encoded = serde_json::to_vec(&entry).unwrap(); + let restored: QueryPlanEntry = serde_json::from_slice(&encoded).unwrap(); + restored.validate(&BTreeSet::new()).unwrap(); + let mut corrupt = restored.clone(); + if let QueryPlanNode::PhysicalRelation { dag, .. } = + corrupt.nodes.get_mut(&corrupt.root).unwrap() + { + let mut encoded: serde_json::Value = serde_json::from_slice(dag).unwrap(); + encoded["version"] = serde_json::json!(99); + *dag = serde_json::to_vec(&encoded).unwrap(); + } + assert!(corrupt.validate(&BTreeSet::new()).is_err()); + let mut corrupt = restored.clone(); + corrupt.root = QueryNodeId(99); + assert!(corrupt.validate(&BTreeSet::new()).is_err()); + let mut corrupt = restored.clone(); + if let QueryPlanNode::ExternalExact { request, .. } = + corrupt.nodes.get_mut(&QueryNodeId(0)).unwrap() + { + if let ExternalExactOutput::Relation { schema } = &mut request.output { + schema["fields"][0]["name"] = serde_json::json!("different"); + } + } + assert!(corrupt.validate(&BTreeSet::new()).is_err()); + let mut missing = restored; + if let QueryPlanNode::PhysicalRelation { dag, .. } = + missing.nodes.get_mut(&missing.root).unwrap() + { + dag.clear(); + } + assert!(missing.validate(&BTreeSet::new()).is_err()); + } +} diff --git a/data_plane/src/precompute_engine/maintenance_runtime.rs b/data_plane/src/precompute_engine/maintenance_runtime.rs index 3ce4596de..4e9c01af6 100644 --- a/data_plane/src/precompute_engine/maintenance_runtime.rs +++ b/data_plane/src/precompute_engine/maintenance_runtime.rs @@ -1464,15 +1464,17 @@ fn execute_finite_complete_populations( .collect::, _>>()?; asap_types::precompute_plan::validated_source_window_cohort(config, &sources) .map_err(|error| error.to_string())?; + let native_program = installed.native_program(sink)?; if std::iter::once(config) .chain(sources.iter().copied()) .any(|config| { config.population_key_encoding != asap_types::PopulationKeyEncoding::CanonicalLabelsV1 - || config.slide_interval.checked_mul(1000) != Some(config.stored_window_ms()) + || (native_program.is_none() + && config.slide_interval.checked_mul(1000) != Some(config.stored_window_ms())) }) { return Err( - "complete population execution requires canonical nonoverlapping full windows".into(), + "complete population execution requires canonical windows supported by the bound program".into(), ); } let dag = installed.document.decode()?; @@ -1481,8 +1483,11 @@ fn execute_finite_complete_populations( .iter() .find(|node| node.id == sink) .ok_or("complete target node is absent")?; - asap_types::precompute_plan::validate_maintenance_reduction(config, target_node)?; - if !matches!(&target_node.payload, ExecutableOperatorPayload::SummaryAgg { + if native_program.is_none() { + asap_types::precompute_plan::validate_maintenance_reduction(config, target_node)?; + } + if native_program.is_none() + && !matches!(&target_node.payload, ExecutableOperatorPayload::SummaryAgg { reduction: planner_types::pre_asap::Reduction::Reduce(keys), .. } if keys.is_empty()) { @@ -1503,11 +1508,16 @@ fn execute_finite_complete_populations( } for (start, end) in windows { let width = source.stored_window_ms(); + let stride = source + .slide_interval + .checked_mul(1000) + .ok_or("source cadence overflow")?; if width == 0 + || stride == 0 || end.checked_sub(*start) != Some(width) || *end > i64::MAX as u64 || (*start as i128 - source.pane_origin_ms.unwrap_or(0) as i128) - .rem_euclid(width as i128) + .rem_euclid(stride as i128) != 0 { return Err( @@ -1532,6 +1542,26 @@ fn execute_finite_complete_populations( for window in common_windows.unwrap_or_default() { let cohort = store.read_complete_raw_maintenance_cohort(generation, &derived.inputs, window)?; + if let Some(program) = &native_program { + let max_bytes = asap_physical_operators::runtime::Limits::default().max_bytes; + let batch = super::native_precompute::execute( + installed, + program, + cohort.inputs(), + window, + max_bytes, + )?; + let mut output = crate::storage_engines::types::PrecomputedOutput::new( + window.0, + window.1, + None, + target.fingerprint(), + ); + output.population_labels = Some(Population::new()); + output.catalog_generation = Some(Arc::clone(generation)); + store.publish_native_summary_output(resolver, config, &output, batch, max_bytes)?; + continue; + } let (dag, key) = prepare_frozen_maintenance_sink( installed, &plan.materializations, @@ -2838,6 +2868,7 @@ mod tests { }, ); let installed = InstalledPostAsapDag { + native_programs: std::collections::BTreeMap::new(), document, binding: durable_binding, }; @@ -3222,7 +3253,11 @@ mod tests { binding .nodes .insert(PostAsapNodeId(6), BackendNodeBinding::MaintenanceInput); - let installed = InstalledPostAsapDag { document, binding }; + let installed = InstalledPostAsapDag { + native_programs: BTreeMap::new(), + document, + binding, + }; let configs = [first, second, target]; let catalog = Arc::new( asap_types::summary_catalog::SummaryCatalog::from_materializations(2, 1, &configs) @@ -4185,6 +4220,7 @@ mod tests { bundle.precompute_plan.executable_dags = BTreeMap::from([( "retry".into(), InstalledPostAsapDag { + native_programs: BTreeMap::new(), document: { let mut document = OwnedPostAsapDag::from_executable("retry".into(), &dag).unwrap(); diff --git a/data_plane/src/precompute_engine/mod.rs b/data_plane/src/precompute_engine/mod.rs index 2a684d470..80d5e29c4 100644 --- a/data_plane/src/precompute_engine/mod.rs +++ b/data_plane/src/precompute_engine/mod.rs @@ -6,6 +6,7 @@ pub mod group_key; pub mod ingest_handler; pub mod maintenance_runtime; pub(crate) mod metrics; +mod native_precompute; pub mod output_sink; pub mod raw_dag; pub mod series_router; diff --git a/data_plane/src/precompute_engine/native_precompute.rs b/data_plane/src/precompute_engine/native_precompute.rs new file mode 100644 index 000000000..b08825f23 --- /dev/null +++ b/data_plane/src/precompute_engine/native_precompute.rs @@ -0,0 +1,134 @@ +//! Bind a complete durable counter cohort to a Planner-owned precompute graph. +use std::{collections::BTreeMap, sync::Arc}; + +use asap_physical_operators::{ + operators::Operator, + physical_planner::{CompiledPhysicalDag, Source}, + runtime::{Limits, RunContext, Scope}, + values::{Batch, Value}, +}; +use asap_types::executable_plan::{BackendNodeBinding, InstalledPostAsapDag}; +use futures::{executor::block_on, StreamExt}; +use planner_types::{ + post_asap::{PostAsapNodeId, SummaryFamilyType}, + pre_asap::DataType, +}; + +pub(super) fn execute( + installed: &InstalledPostAsapDag, + program: &CompiledPhysicalDag, + inputs: &[crate::storage_engines::sketch_db::index::FrozenExactWindows], + window: (u64, u64), + max_bytes: usize, +) -> Result { + let mut sources = BTreeMap::new(); + let mut input_bytes = 0usize; + for (id, contract) in program.input_contracts() { + let node = PostAsapNodeId(u32::try_from(id).map_err(|_| "native source id overflow")?); + let Some(BackendNodeBinding::Materialization { stored_output }) = + installed.binding.node(node) + else { + return Err("native precompute input has no stored binding".into()); + }; + let mut rows = Vec::new(); + for input in inputs + .iter() + .filter(|input| input.stored_output_reference.stored_output_id == *stored_output) + { + let state = input + .windows + .get(&window) + .ok_or("native precompute requires an exact complete counter window")?; + let row = contract + .schema + .fields + .iter() + .map(|field| match &field.dtype { + SummaryFamilyType::ExactAggregate( + planner_types::post_asap::ExactKind::Rate, + _, + ) => Ok(Value::Summary { + family: field.dtype.clone(), + state: Arc::clone(state), + }), + SummaryFamilyType::Plain(DataType::Timestamp) => Ok(Value::Timestamp( + i64::try_from(window.1).map_err(|_| "native window overflow")?, + )), + SummaryFamilyType::Plain(DataType::Utf8) + if field.name == "$promql_series_identity" => + { + Ok(Value::Utf8( + serde_json::to_string(&input.group) + .map_err(|e| e.to_string())? + .into(), + )) + } + SummaryFamilyType::Plain(DataType::Utf8) => Ok(Value::Utf8( + input + .group + .get(&field.name) + .cloned() + .unwrap_or_default() + .into(), + )), + _ => Err("unsupported native precompute stored input field".to_string()), + }) + .collect::, String>>()?; + rows.push(row); + } + let batch = Batch::try_new(contract.schema.clone(), rows).map_err(|e| e.to_string())?; + input_bytes = input_bytes + .checked_add(batch.bytes()) + .ok_or("native input size overflow")?; + if input_bytes > max_bytes { + return Err("native precompute input exceeds run budget".into()); + } + sources.insert( + id, + Box::new( + Operator::source(contract.schema.clone(), vec![batch]) + .map_err(|e| e.to_string())?, + ) as Source<'_>, + ); + } + let graph = program.instantiate(sources).map_err(|e| e.to_string())?; + let context = RunContext::new( + Scope::Ingestion { + window_start_ms: i64::try_from(window.0).map_err(|_| "native window overflow")?, + window_end_ms: i64::try_from(window.1).map_err(|_| "native window overflow")?, + revision: 0, + }, + Limits { + max_bytes, + ..Limits::default() + }, + ) + .map_err(|e| e.to_string())?; + // Source buffers and retained publication output share the operator budget. + let _inputs = context.reserve(input_bytes).map_err(|e| e.to_string())?; + let mut retained = context.reserve(0).map_err(|e| e.to_string())?; + let schema = program + .output_contract(program.roots()[0]) + .map_err(|e| e.to_string())? + .schema; + let mut stream = graph + .execute(program.roots(), context) + .map_err(|e| e.to_string())? + .remove(0); + block_on(async { + let mut rows = Vec::new(); + let mut bytes = 0usize; + while let Some(batch) = stream.next().await { + let batch = batch.map_err(|e| e.to_string())?; + bytes = bytes + .checked_add(batch.bytes()) + .ok_or("native output size overflow")?; + if bytes > max_bytes { + return Err("native precompute output exceeds publication budget".into()); + } + retained.resize(bytes).map_err(|e| e.to_string())?; + rows.extend(batch.rows().iter().cloned()); + } + Batch::try_new(schema, rows).map_err(|e| e.to_string()) + }) +} diff --git a/data_plane/src/precompute_engine/partitioning.rs b/data_plane/src/precompute_engine/partitioning.rs index 09ef174fd..fe29a12a7 100644 --- a/data_plane/src/precompute_engine/partitioning.rs +++ b/data_plane/src/precompute_engine/partitioning.rs @@ -68,15 +68,15 @@ impl DagPartitioning { Self::Labels(names) => { let pairs = names .iter() + // Missing and empty PromQL grouping labels denote the same + // group. Ownership must colocate them without adding a label. .map(|name| { - population - .get(name) - .map(|value| (name.as_str(), value.as_str())) - .ok_or_else(|| { - format!("DAG partition label {name} is absent from population") - }) + ( + name.as_str(), + population.get(name).map(String::as_str).unwrap_or(""), + ) }) - .collect::, _>>()?; + .collect::>(); let key = super::group_key::GroupKey::new(pairs); Ok(xxh64(key.canonical_bytes(), 0) as usize % workers) } @@ -99,7 +99,11 @@ mod tests { series.insert("instance".into(), instance.into()); assert_eq!(rule.owner(&series, 4).unwrap(), expected); } - assert!(rule.owner(&BTreeMap::new(), 4).is_err()); + assert_eq!( + rule.owner(&BTreeMap::new(), 4).unwrap(), + rule.owner(&BTreeMap::from([("service".into(), "".into())]), 4) + .unwrap() + ); assert!(rule.owner(&group, 0).is_err()); let owners = (0..64) .map(|i| { diff --git a/data_plane/src/query_engines/asap_clickhouse_query_engine/accelerator.rs b/data_plane/src/query_engines/asap_clickhouse_query_engine/accelerator.rs index 81d2c939d..b193f7ad7 100644 --- a/data_plane/src/query_engines/asap_clickhouse_query_engine/accelerator.rs +++ b/data_plane/src/query_engines/asap_clickhouse_query_engine/accelerator.rs @@ -664,6 +664,7 @@ mod tests { }, ); entry.root = project; + entry } @@ -780,6 +781,7 @@ mod tests { ]); let root = QueryNodeId(5); let executable = QueryPlanEntry { + physical_dag: None, language: QueryLanguage::ClickHouseSql, query_id: "SELECT value FROM samples".into(), canonical_query: "SELECT value FROM samples".into(), @@ -883,7 +885,8 @@ mod tests { } else { BTreeMap::new() }; - let entry = QueryPlanEntry { + let mut entry = QueryPlanEntry { + physical_dag: None, query_id: sql.clone(), canonical_query: canonical_sql.clone(), language: QueryLanguage::ClickHouseSql, @@ -897,6 +900,7 @@ mod tests { instant: executable.instant, fallback: executable.fallback, }; + let query_plan = QueryPlan { plan_id: 41, plan_version: 1, diff --git a/data_plane/src/query_engines/asap_clickhouse_query_engine/execution.rs b/data_plane/src/query_engines/asap_clickhouse_query_engine/execution.rs index 127f73f61..6dcbe9fab 100644 --- a/data_plane/src/query_engines/asap_clickhouse_query_engine/execution.rs +++ b/data_plane/src/query_engines/asap_clickhouse_query_engine/execution.rs @@ -566,6 +566,7 @@ mod tests { fn external_entry(schema: &SummarySchema) -> QueryPlanEntry { QueryPlanEntry { + physical_dag: None, language: QueryLanguage::ClickHouseSql, query_id: "shared-external".into(), canonical_query: "SELECT x".into(), diff --git a/data_plane/src/query_engines/asap_query_engine/engine.rs b/data_plane/src/query_engines/asap_query_engine/engine.rs index 9a72bfcd2..8811aa0a2 100644 --- a/data_plane/src/query_engines/asap_query_engine/engine.rs +++ b/data_plane/src/query_engines/asap_query_engine/engine.rs @@ -119,6 +119,7 @@ mod forwarding_policy_tests { let query = "sum(rate(m[5m]))"; let canonical = asap_types::query_plan::canonical_promql(query).unwrap(); let entry = QueryPlanEntry { + physical_dag: None, language: QueryLanguage::PromQl, query_id: canonical.clone(), canonical_query: canonical, @@ -311,6 +312,23 @@ impl ASAPQueryEngine { self } + fn request_limits( + &self, + entry: &asap_types::query_plan::QueryPlanEntry, + ) -> asap_physical_operators::dag::Limits { + let mut limits = self.execution_limits.clone(); + if let Some(bytes) = entry + .physical_vector_binding() + .map(|(_, _, bytes)| bytes) + .or_else(|| entry.population_snapshot().map(|p| p.max_bytes)) + { + limits.max_bytes = limits + .max_bytes + .min(usize::try_from(bytes).unwrap_or(usize::MAX)); + } + limits + } + async fn execute_installed_instant( &self, physical: &crate::storage_engines::types::RuntimePhysicalPlan, @@ -321,7 +339,7 @@ impl ASAPQueryEngine { let engine = self.clone(); let physical = physical.clone(); let entry = entry.clone(); - crate::query_engines::request::run(self.execution_limits.clone(), move |handle| { + crate::query_engines::request::run(self.request_limits(&entry), move |handle| { handle.block_on(async { entry.validate_snapshot_sources().map_err(|e| { crate::query_engines::EngineError::capability_miss("query_plan", e.to_string()) @@ -495,11 +513,29 @@ impl ASAPQueryEngine { .flatten(); let index = pinned.as_ref().or(self.summary_store.as_deref()); let revision = index.map(|index| index.summary_update_revision()); - let result = super::logical_dag::execute_installed( - entry, - leaves, - at, - |root, evaluation_ms| { + let native_stored = entry + .physical_vector_binding() + .is_some_and(|(inputs, _, _)| { + inputs.iter().all(|id| { + matches!( + entry.nodes.get(id), + Some(asap_types::query_plan::QueryPlanNode::ReadMaterialization { .. }) + ) + }) + }); + let result = if native_stored { + let store = index.ok_or_else(|| { + EngineError::capability_miss("native_stored", "summary store unavailable") + })?; + super::logical_dag::native_values::execute_stored( + entry, + physical.query_plan.plan_id, + physical.query_plan.plan_version, + store, + at, + ) + } else { + super::logical_dag::execute_installed(entry, leaves, at, |root, evaluation_ms| { if let Some(asap_types::query_plan::QueryPlanNode::Logical { operator: asap_types::query_plan::query_time::QueryTimeOperator::CurrentSeries { @@ -611,8 +647,8 @@ impl ASAPQueryEngine { evaluation_ms, false, )) - }, - ); + }) + }; let current = index.map(|index| index.summary_update_revision()); finish_query( result, @@ -636,7 +672,7 @@ impl ASAPQueryEngine { let engine = self.clone(); let physical = physical.clone(); let entry = entry.clone(); - crate::query_engines::request::run(self.execution_limits.clone(), move |handle| { + crate::query_engines::request::run(self.request_limits(&entry), move |handle| { handle.block_on(engine.execute_logical_range_inner(&physical, &entry, start, end, step)) }) .await @@ -2855,6 +2891,7 @@ mod range_stitch_tests { plan.query_plan.entries.insert( asap_types::query_plan::QueryPlan::catalog_key(QueryLanguage::MetricsQl, &identity), QueryPlanEntry { + physical_dag: None, language: QueryLanguage::MetricsQl, query_id: "vm-scalar".into(), canonical_query: identity.clone(), diff --git a/data_plane/src/query_engines/asap_query_engine/exact_subqueries.rs b/data_plane/src/query_engines/asap_query_engine/exact_subqueries.rs index 1b5fef65d..82b1ad2a9 100644 --- a/data_plane/src/query_engines/asap_query_engine/exact_subqueries.rs +++ b/data_plane/src/query_engines/asap_query_engine/exact_subqueries.rs @@ -87,7 +87,8 @@ fn leaves( }, // A join is a typed composition node rather than a Logical // wrapper, but its value input can still be a Prometheus leaf. - QueryPlanNode::PhysicalFragment { inputs, .. } => { + QueryPlanNode::PhysicalFragment { inputs, .. } + | QueryPlanNode::Physical { inputs, .. } => { pending.extend(inputs.iter().map(|input| (*input, at))); } QueryPlanNode::RelationalJoin { inputs, .. } => { @@ -477,6 +478,7 @@ mod tests { use asap_types::query_plan::{FallbackPolicy, InstantExecution}; fn entry(nodes: BTreeMap) -> QueryPlanEntry { QueryPlanEntry { + physical_dag: None, language: asap_types::query_plan::QueryLanguage::PromQl, query_id: "remote-cut".into(), canonical_query: "a / b".into(), diff --git a/data_plane/src/query_engines/asap_query_engine/live_serve.rs b/data_plane/src/query_engines/asap_query_engine/live_serve.rs index 8c9e26466..fdf481272 100644 --- a/data_plane/src/query_engines/asap_query_engine/live_serve.rs +++ b/data_plane/src/query_engines/asap_query_engine/live_serve.rs @@ -174,6 +174,7 @@ mod tests { ); } let entry = asap_types::query_plan::QueryPlanEntry { + physical_dag: None, language: asap_types::query_plan::QueryLanguage::PromQl, query_id: "q-sum".into(), canonical_query: "sum_over_time(bytes[1s])".into(), diff --git a/data_plane/src/query_engines/asap_query_engine/logical_dag.rs b/data_plane/src/query_engines/asap_query_engine/logical_dag.rs index 103580df0..19a325d0b 100644 --- a/data_plane/src/query_engines/asap_query_engine/logical_dag.rs +++ b/data_plane/src/query_engines/asap_query_engine/logical_dag.rs @@ -1,5 +1,5 @@ //! Executes the installed typed logical DAG. No serving-time PromQL parsing. -mod native_values; +pub(super) mod native_values; use crate::query_engines::{ query_result::{InstantVectorElement, QueryResult}, EngineError, @@ -127,6 +127,14 @@ pub(crate) fn execute_installed( where F: FnMut(QueryNodeId, u64) -> Result, { + if entry.population_snapshot().is_some() || entry.physical_vector_binding().is_some() { + if !leaves.is_empty() { + return Err(miss( + "population physical input must use its installed source binding", + )); + } + return native_values::execute_vectors(entry, at, callback); + } execute_values(entry, leaves, at, callback) } @@ -1475,6 +1483,7 @@ mod topk_tests { TemporalOperation::Rate, ] { let entry = QueryPlanEntry { + physical_dag: None, language, query_id: "labels".into(), canonical_query: "test".into(), @@ -1552,6 +1561,7 @@ mod topk_tests { let summary = QueryNodeId(0); let root = QueryNodeId(1); let entry = QueryPlanEntry { + physical_dag: None, language: asap_types::query_plan::QueryLanguage::PromQl, query_id: "summary-rate-topk".into(), canonical_query: "topk(2, rate(requests_total[5m]))".into(), @@ -1752,6 +1762,7 @@ mod topk_tests { let filter = QueryNodeId(2); let root = QueryNodeId(3); let entry = QueryPlanEntry { + physical_dag: None, language: asap_types::query_plan::QueryLanguage::PromQl, query_id: "candidate-topk".into(), canonical_query: "topk(1, rate(requests_total[5m]))".into(), @@ -1918,6 +1929,7 @@ mod shared_runtime_tests { fn entry() -> QueryPlanEntry { QueryPlanEntry { + physical_dag: None, language: QueryLanguage::PromQl, query_id: "shared-grid".into(), canonical_query: "shared-grid".into(), diff --git a/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs b/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs index e9c709ac9..c8e7a5f02 100644 --- a/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs +++ b/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs @@ -1,4 +1,4 @@ -//! Bind protocol vectors to native batch operators; computation stays in Planner. +//! Bind deployment inputs to retained native programs and decode PromQL results. use super::{grouping_key, miss, EngineError, Grouping, Labels, Vector}; use asap_physical_operators::dag::{ self, batch_execution, @@ -513,6 +513,23 @@ mod tests { } } + // The complete selected-candidate adapter must not classify a byte budget as capability. + #[test] + fn selected_candidate_input_budget_is_a_terminal_error() { + let plan = CompiledPhysicalDag::decode(&sorted()).unwrap(); + let error = execute_batches(&plan, 1, 1, 42, |_, schema| { + Ok(Batch::try_new( + schema.clone(), + vec![vec![Value::Float64(1.)]], + )?) + }) + .expect_err("input must exceed the byte budget"); + assert!( + matches!(error, EngineError::Physical(Error::MemoryLimit)), + "{error}" + ); + } + // Count-like values are bound as integers only when the protocol sample is exact. #[test] fn integer_input_binding_preserves_type_and_rejects_rounding() { @@ -567,3 +584,292 @@ mod tests { ); } } + +pub(super) fn execute_vectors( + entry: &asap_types::query_plan::QueryPlanEntry, + at: u64, + mut callback: F, +) -> Result< + ( + crate::query_engines::query_result::QueryResult, + super::ExecutionStats, + ), + EngineError, +> +where + F: FnMut( + asap_types::query_plan::QueryNodeId, + u64, + ) -> Result, +{ + use asap_physical_operators::physical_planner::promql_rows::series_row; + use std::collections::BTreeMap; + let (program, bindings, max_bytes) = + if let Some((inputs, source_nodes, budget)) = entry.physical_vector_binding() { + ( + entry + .recover_vector_physical_dag() + .map_err(|e| miss(e.to_string()))?, + source_nodes + .iter() + .copied() + .zip(inputs.iter().copied()) + .collect::>(), + budget, + ) + } else { + let program = entry + .recover_population_physical_dag() + .map_err(|e| miss(e.to_string()))?; + let source = program.input_contracts().next().unwrap().0; + ( + program, + BTreeMap::from([(source, entry.root)]), + entry.population_snapshot().unwrap().max_bytes, + ) + }; + execute_batches( + &program, + max_bytes, + bindings.len(), + at, + |input_id, schema| { + let values = super::vector(super::from_result(callback(bindings[&input_id], at)?)?)?; + let rows = values + .into_iter() + .map(|(labels, value)| { + series_row( + schema, + &labels, + i64::try_from(at).map_err(|_| miss("evaluation timestamp overflow"))?, + value, + ) + .map_err(|e| miss(e.to_string())) + }) + .collect::, _>>()?; + Batch::try_new(schema.clone(), rows).map_err(EngineError::from) + }, + ) +} + +pub(in crate::query_engines::asap_query_engine) fn execute_stored( + entry: &asap_types::query_plan::QueryPlanEntry, + plan_id: u64, + plan_version: u64, + store: &crate::storage_engines::sketch_db::index::SketchStore, + at: u64, +) -> Result< + ( + crate::query_engines::query_result::QueryResult, + super::ExecutionStats, + ), + EngineError, +> { + let (inputs, sources, max_bytes) = entry + .physical_vector_binding() + .ok_or_else(|| miss("missing native stored binding"))?; + let program = entry + .recover_vector_physical_dag() + .map_err(|e| miss(e.to_string()))?; + execute_batches(&program, max_bytes, inputs.len(), at, |id, schema| { + let index = sources + .iter() + .position(|source| *source == id) + .ok_or_else(|| miss("native source is unbound"))?; + let Some(asap_types::query_plan::QueryPlanNode::ReadMaterialization { binding }) = + entry.nodes.get(&inputs[index]) + else { + return Err(miss("native stored source has no deployed summary binding")); + }; + let end = i64::try_from(at).map_err(|_| miss("native timestamp overflow"))?; + let start = at + .checked_sub(binding.window_ms) + .ok_or_else(|| miss("native window underflow"))?; + let address = asap_types::sds::StoredSummaryKey { + plan_id, + plan_version, + stored_output_id: binding.stored_output_reference.stored_output_id, + population: std::collections::BTreeMap::new(), + window: asap_types::sds::HalfOpenTimeRange { + start_ms: start as i64, + end_ms: end, + }, + }; + store + .read_bound_native_summary( + &address, + &binding.stored_output_reference, + schema.clone(), + max_bytes as usize, + ) + .map_err(|error| match error { + crate::storage_engines::sketch_db::index::NativeReadError::Unavailable(message) => { + miss(message) + } + crate::storage_engines::sketch_db::index::NativeReadError::Physical(error) => { + EngineError::from(error) + } + }) + }) +} + +fn execute_batches( + program: &asap_physical_operators::physical_planner::CompiledPhysicalDag, + max_bytes: u64, + input_count: usize, + at: u64, + mut input_batch: impl FnMut(u64, &Schema) -> Result, +) -> Result< + ( + crate::query_engines::query_result::QueryResult, + super::ExecutionStats, + ), + EngineError, +> { + use crate::{ + query_engines::query_result::{InstantVectorElement, QueryResult}, + storage_engines::types::KeyByLabelValues, + }; + use asap_physical_operators::physical_planner::{ + promql_rows::{decode_series_identity, SERIES_IDENTITY_COLUMN}, + Source, + }; + use futures::StreamExt; + use std::collections::BTreeMap; + let at_signed = i64::try_from(at).map_err(|_| miss("evaluation timestamp overflow"))?; + let context = crate::query_engines::request::context_with_limits( + dag::Scope::Query { + evaluation_time_ms: at_signed, + revision: 0, + }, + dag::Limits { + max_bytes: max_bytes as usize, + ..dag::Limits::default() + }, + ) + .map_err(EngineError::from)?; + let mut sources = BTreeMap::new(); + let mut prepared = context.reserve(0)?; + let mut input_bytes = 0usize; + for (input_id, input) in program.input_contracts() { + crate::query_engines::request::check()?; + let batch = input_batch(input_id, &input.schema)?; + input_bytes = input_bytes + .checked_add(batch.bytes()) + .ok_or(asap_physical_operators::Error::MemoryLimit)?; + if input_bytes > max_bytes as usize { + return Err(asap_physical_operators::Error::MemoryLimit.into()); + } + prepared.resize(input_bytes)?; + let source = + Operator::source(input.schema.clone(), vec![batch]).map_err(EngineError::from)?; + sources.insert(input_id, Box::new(source) as Source<'_>); + } + let graph = program.instantiate(sources).map_err(EngineError::from)?; + let mut retained = context.reserve(0)?; + let mut result_bytes = 0usize; + let mut stream = graph + .execute(program.roots(), context) + .map_err(EngineError::from)? + .remove(0); + let values = crate::query_engines::request::drive(async { + let mut values = Vec::new(); + while let Some(batch) = stream.next().await { + let batch = batch.map_err(EngineError::from)?; + let identity = batch + .schema() + .fields + .iter() + .position(|field| field.name == SERIES_IDENTITY_COLUMN); + let value = batch + .schema() + .fields + .iter() + .position(|field| field.dtype == SummaryFamilyType::Plain(DataType::Float64)) + .ok_or_else(|| miss("physical output loses sample value"))?; + for row in batch.rows() { + let Value::Float64(sample) = &row[value] else { + return Err(miss("invalid physical result value")); + }; + let labels = if let Some(identity) = identity { + let Value::Utf8(encoded) = &row[identity] else { + return Err(miss("invalid physical series identity")); + }; + decode_series_identity(encoded).map_err(EngineError::from)? + } else { + batch + .schema() + .fields + .iter() + .zip(row) + .filter_map(|(field, value)| match value { + Value::Utf8(label) if !label.is_empty() => { + Some((field.name.clone(), label.to_string())) + } + _ => None, + }) + .collect() + }; + let point = InstantVectorElement::new( + KeyByLabelValues::new_with_labels(labels.values().cloned().collect()), + *sample, + ) + .with_label_keys_override(labels.into_keys().collect()); + result_bytes = result_bytes + .checked_add(point.retained_bytes()) + .ok_or(dag::Error::MemoryLimit)?; + retained.resize(result_bytes)?; + values.push(point); + } + } + Ok(values) + })??; + Ok(( + QueryResult::vector(values, at), + super::ExecutionStats { + summary_readout_evaluations: input_count, + ..Default::default() + }, + )) +} + +#[cfg(test)] +mod request_contract_tests { + use super::*; + + // A native candidate must use the enclosing request budget, even if its + // deployment binding permits a larger standalone execution. + #[tokio::test] + async fn native_candidate_cannot_escape_request_memory_limit() { + let result = crate::query_engines::request::run( + dag::Limits { + max_bytes: 1, + ..dag::Limits::default() + }, + |_| { + let schema = schema(&[("value", DataType::Float64)]); + let program = + asap_physical_operators::physical_planner::CompiledPhysicalDag::from_operators( + [( + 0, + asap_physical_operators::physical_planner::InputContract::bounded( + schema.clone(), + ), + )] + .into(), + Default::default(), + vec![0], + )?; + execute_batches(&program, 64 * 1024, 1, 0, |_, _| { + Batch::try_new(schema.clone(), vec![vec![Value::Float64(1.0)]]) + .map_err(EngineError::from) + }) + }, + ) + .await; + assert!(matches!( + result, + Err(EngineError::Physical(dag::Error::MemoryLimit)) + )); + } +} diff --git a/data_plane/src/query_engines/asap_query_engine/post_asap_readout.rs b/data_plane/src/query_engines/asap_query_engine/post_asap_readout.rs index 0b33dbb80..01866e310 100644 --- a/data_plane/src/query_engines/asap_query_engine/post_asap_readout.rs +++ b/data_plane/src/query_engines/asap_query_engine/post_asap_readout.rs @@ -352,11 +352,12 @@ impl PhysicalQueryRuntime<'_> { item_labels: merged_item_labels.unwrap_or_default(), }) } - QueryPlanNode::Logical { .. } + QueryPlanNode::PhysicalFragment { .. } + | QueryPlanNode::Physical { .. } + | QueryPlanNode::Logical { .. } | QueryPlanNode::Relational { .. } | QueryPlanNode::ExternalExact { .. } | QueryPlanNode::PhysicalRelation { .. } - | QueryPlanNode::PhysicalFragment { .. } | QueryPlanNode::RelationalJoin { .. } => Err(PhysicalNodeError::Fallback( "logical node requires installed logical runtime".into(), )), @@ -1004,6 +1005,7 @@ mod tests { }, }; let entry = QueryPlanEntry { + physical_dag: None, language: QueryLanguage::PromQl, query_id: "resource-test".into(), canonical_query: "1".into(), @@ -1284,6 +1286,7 @@ mod tests { #[test] fn multi_root_readout_uses_one_parent_context() { let entry = asap_types::query_plan::QueryPlanEntry { + physical_dag: None, language: asap_types::query_plan::QueryLanguage::PromQl, query_id: "shared".into(), canonical_query: "1+1".into(), @@ -1568,6 +1571,7 @@ mod tests { .unwrap(); idx.register(metadata); let mut entry = QueryPlanEntry { + physical_dag: None, language: QueryLanguage::MetricsQl, query_id: "quantile".into(), canonical_query: "quantile_over_time(0.9, latency_ms[1s])".into(), @@ -2006,6 +2010,7 @@ mod tests { } let entry = asap_types::query_plan::QueryPlanEntry { + physical_dag: None, language: asap_types::query_plan::QueryLanguage::PromQl, query_id: "q-rate".into(), canonical_query: "rate(requests_total[1m])".into(), @@ -2107,6 +2112,7 @@ mod tests { idx.append_precompute(7, BTreeMap::new(), (0, 60_000), Box::new(accumulator)); let entry = asap_types::query_plan::QueryPlanEntry { + physical_dag: None, language: asap_types::query_plan::QueryLanguage::PromQl, query_id: "q-rate".into(), canonical_query: "rate(requests_total[1m])".into(), diff --git a/data_plane/src/query_engines/asap_query_engine/request_tests.rs b/data_plane/src/query_engines/asap_query_engine/request_tests.rs index f70712abf..265e3b8cb 100644 --- a/data_plane/src/query_engines/asap_query_engine/request_tests.rs +++ b/data_plane/src/query_engines/asap_query_engine/request_tests.rs @@ -8,6 +8,7 @@ use std::{collections::BTreeMap, sync::Arc}; fn external_entry() -> QueryPlanEntry { QueryPlanEntry { + physical_dag: None, language: QueryLanguage::PromQl, query_id: "m".into(), canonical_query: "m".into(), diff --git a/data_plane/src/query_engines/asap_query_engine/test_plan.rs b/data_plane/src/query_engines/asap_query_engine/test_plan.rs index ef0a54c52..7da038b36 100644 --- a/data_plane/src/query_engines/asap_query_engine/test_plan.rs +++ b/data_plane/src/query_engines/asap_query_engine/test_plan.rs @@ -44,6 +44,7 @@ pub(super) fn entry( ) -> QueryPlanEntry { let canonical = canonical_promql(query).unwrap(); QueryPlanEntry { + physical_dag: None, language: QueryLanguage::PromQl, query_id: canonical.clone(), canonical_query: canonical, diff --git a/data_plane/src/query_engines/request.rs b/data_plane/src/query_engines/request.rs index c7ccc2929..9ed97e24a 100644 --- a/data_plane/src/query_engines/request.rs +++ b/data_plane/src/query_engines/request.rs @@ -91,6 +91,11 @@ pub(crate) fn check() -> Result<(), Error> { } pub(crate) fn context(scope: Scope) -> Result { + context_with_limits(scope, Limits::default()) +} + +/// A request always shares its active control; standalone callers supply their limit. +pub(crate) fn context_with_limits(scope: Scope, limits: Limits) -> Result { check()?; ACTIVE.with(|slot| match slot.borrow().as_ref() { Some(active) => { @@ -98,7 +103,7 @@ pub(crate) fn context(scope: Scope) -> Result { context.scope = scope; Ok(context) } - None => RunContext::new(scope, Limits::default()), + None => RunContext::new(scope, limits), }) } diff --git a/data_plane/src/storage_engines/sketch_db/current_series.rs b/data_plane/src/storage_engines/sketch_db/current_series.rs index e72ebe171..f9bd10728 100644 --- a/data_plane/src/storage_engines/sketch_db/current_series.rs +++ b/data_plane/src/storage_engines/sketch_db/current_series.rs @@ -194,6 +194,14 @@ impl Population { } } fn read(&mut self, readout: &SeriesReadout) -> Vector { + if matches!(readout, SeriesReadout::Snapshot) { + return self + .groups + .values() + .flat_map(|group| group.ordered.iter()) + .map(|member| (member.labels.clone(), member.value)) + .collect(); + } let mut result = vec![]; for (labels, group) in &mut self.groups { if group.cached.is_none() { @@ -221,6 +229,7 @@ impl Population { } let (values, top, sum, average) = group.cached.as_ref().unwrap(); match readout { + SeriesReadout::Snapshot => unreachable!("snapshot returned above"), SeriesReadout::Quantile { q } => { // `values` is only populated for a quantile-carrying population. // `QueryTimeOperator::validate` rejects the mismatched pairing at @@ -598,6 +607,7 @@ mod tests { plan.entries.insert( "test".into(), QueryPlanEntry { + physical_dag: None, language: QueryLanguage::PromQl, query_id: "test".into(), canonical_query: "quantile by (job) (0.5, a)".into(), diff --git a/data_plane/src/storage_engines/sketch_db/index/maintenance.rs b/data_plane/src/storage_engines/sketch_db/index/maintenance.rs index 3224b7248..a7c2333a5 100644 --- a/data_plane/src/storage_engines/sketch_db/index/maintenance.rs +++ b/data_plane/src/storage_engines/sketch_db/index/maintenance.rs @@ -276,6 +276,28 @@ impl SketchStore { expected_windows: &BTreeSet<(u64, u64)>, group: &BTreeMap, ) -> Result { + self.read_frozen_windows_with( + sid, + definition, + generation, + expected_windows, + group, + |name, _, bytes| { + reconstruct_exact_agg(name, bytes) + .ok_or_else(|| "immutable input accumulator cannot be decoded".to_string()) + }, + ) + } + + pub(super) fn read_frozen_windows_with + From<&'static str>>( + &self, + sid: u64, + definition: StoredOutputId, + generation: &Arc, + expected_windows: &BTreeSet<(u64, u64)>, + group: &BTreeMap, + mut decode: impl FnMut(&str, u8, &[u8]) -> Result, E>, + ) -> Result { let start_ms = expected_windows .iter() .map(|window| window.0) @@ -364,8 +386,10 @@ impl SketchStore { .part_cache .get_or_load(part.part_id) .map_err(|e| e.to_string())?; - for record in reader.index_records() { - if record.agg_id != sid || record.start_ts < start_ms || record.end_ts > end_ms { + for record in reader.window_records(sid, start_ms, end_ms) { + // A bound full-window read selects its exact coordinates; other + // overlapping snapshots do not contribute to this computation. + if !expected_windows.contains(&(record.start_ts, record.end_ts)) { continue; } let entry = reader.load_entry(&record).map_err(|e| e.to_string())?; @@ -377,8 +401,11 @@ impl SketchStore { if population != *group { continue; } - let state = reconstruct_exact_agg(&entry.sketch_type_name, &entry.sketch_bytes) - .ok_or("immutable input accumulator cannot be decoded")?; + let state = decode( + &entry.sketch_type_name, + entry.encoding_tag, + &entry.sketch_bytes, + )?; if windows .insert((record.start_ts, record.end_ts), Arc::from(state)) .is_some() @@ -720,9 +747,13 @@ impl SketchStore { labels: labels.into_values().collect(), }), sketch_type_name: state.type_name().to_string(), - encoding_tag: match binding.metadata.agg_kind { - AggKind::Sketch { .. } => encoding_to_tag(SketchEncoding::MsgpackFull), - AggKind::ExactAgg { .. } => 0, + encoding_tag: if state.type_name() == "NativePhysicalOutputV1" { + encoding_to_tag(SketchEncoding::NativeBatchV1) + } else { + match binding.metadata.agg_kind { + AggKind::Sketch { .. } => encoding_to_tag(SketchEncoding::MsgpackFull), + AggKind::ExactAgg { .. } => 0, + } }, sketch_bytes: bytes, }], diff --git a/data_plane/src/storage_engines/sketch_db/index/mod.rs b/data_plane/src/storage_engines/sketch_db/index/mod.rs index d831682f2..984eb1fe6 100644 --- a/data_plane/src/storage_engines/sketch_db/index/mod.rs +++ b/data_plane/src/storage_engines/sketch_db/index/mod.rs @@ -3869,7 +3869,9 @@ pub use SummarySeriesMetadata as SketchInstanceMetadata; // alongside the store that uses it. mod admission; mod maintenance; +mod native; pub(crate) use maintenance::{CompleteRawMaintenanceCohort, FrozenExactWindows}; +pub use native::NativeReadError; pub mod epoch_columnar; // `persistence` moved up to `sketch_db::persistence`. Re-exported here diff --git a/data_plane/src/storage_engines/sketch_db/index/native.rs b/data_plane/src/storage_engines/sketch_db/index/native.rs new file mode 100644 index 000000000..9bff8eef8 --- /dev/null +++ b/data_plane/src/storage_engines/sketch_db/index/native.rs @@ -0,0 +1,702 @@ +//! Bound native summary snapshots use the existing immutable publication and +//! recovery path. Window snapshots are not additive hot-state updates. +use super::*; +use crate::drivers::ingest::series_resolver::SeriesIdResolver; +use asap_physical_operators::{ + stored_state::native::{decode_batch, encode_batch}, + values::{Batch, Schema, Value}, + AggregateCore, SerializableToSink, +}; + +const NATIVE_OUTPUT_TYPE: &str = "NativePhysicalOutputV1"; +const NATIVE_OUTPUT_TAG: u8 = persistence::part::encoding_tag::NATIVE_BATCH_V1; + +/// Preserve execution failures across the storage boundary; missing state remains +/// distinguishable from a terminal resource failure. +#[derive(Debug, thiserror::Error)] +pub enum NativeReadError { + #[error("{0}")] + Unavailable(String), + #[error("{0}")] + Physical(#[from] asap_physical_operators::Error), +} +impl From for NativeReadError { + fn from(message: String) -> Self { + Self::Unavailable(message) + } +} +impl From<&'static str> for NativeReadError { + fn from(message: &'static str) -> Self { + Self::Unavailable(message.into()) + } +} + +#[derive(Clone)] +struct NativeSummaryOutput { + batch: Batch, + bytes: Vec, + kind: AggregationType, +} +impl NativeSummaryOutput { + fn new(batch: Batch, max_bytes: usize) -> Result { + let families = batch + .schema() + .fields + .iter() + .filter_map(|field| { + (!matches!( + field.dtype, + planner_types::post_asap::SummaryFamilyType::Plain(_) + )) + .then_some(&field.dtype) + }) + .collect::>(); + let [family] = families.as_slice() else { + return Err("native stored batch requires one summary column".into()); + }; + use planner_types::post_asap::{SketchAlgorithm, SummaryFamilyType}; + let schema_kind = match family { + SummaryFamilyType::Sketch(sketch, _) => match sketch.algorithm() { + SketchAlgorithm::CmsWithHeap => Some(AggregationType::CountMinSketchWithHeap), + SketchAlgorithm::CountSketchWithHeap => Some(AggregationType::CountSketchWithHeap), + _ => None, + }, + SummaryFamilyType::ExactAggregate(planner_types::post_asap::ExactKind::Sum, _) => { + Some(AggregationType::Sum) + } + _ => None, + }; + let mut kind = schema_kind; + for row in batch.rows() { + let states = row + .iter() + .filter_map(|value| match value { + Value::Summary { state, .. } => Some(state), + _ => None, + }) + .collect::>(); + let [state] = states.as_slice() else { + return Err("native stored row requires one summary state".into()); + }; + let row_kind = state.get_accumulator_type(); + if kind.is_some_and(|kind| kind != row_kind) { + return Err("native stored rows have different summary families".into()); + } + kind = Some(row_kind); + } + let kind = kind.ok_or("empty native batch has no supported summary family")?; + let bytes = encode_batch(&batch).map_err(|error| error.to_string())?; + if bytes.len() > max_bytes || batch.bytes() > max_bytes { + return Err("native summary exceeds publication/read budget".into()); + } + Ok(Self { batch, bytes, kind }) + } + fn validate_group(&self, group: &BTreeMap) -> Result<(), String> { + for (key, value) in group { + let column = self + .batch + .schema() + .fields + .iter() + .position(|field| &field.name == key) + .ok_or("native output is missing its stored group key")?; + if self + .batch + .rows() + .iter() + .any(|row| !matches!(&row[column], Value::Utf8(actual) if actual.as_ref() == value)) + { + return Err("native output group differs from stored address".into()); + } + } + Ok(()) + } +} +impl SerializableToSink for NativeSummaryOutput { + fn serialize_to_bytes(&self) -> Vec { + self.bytes.clone() + } + fn serialize_to_json(&self) -> serde_json::Value { + serde_json::json!({"format": NATIVE_OUTPUT_TYPE, "bytes": self.bytes}) + } +} +impl AggregateCore for NativeSummaryOutput { + fn clone_boxed_core(&self) -> Box { + Box::new(self.clone()) + } + fn type_name(&self) -> &'static str { + NATIVE_OUTPUT_TYPE + } + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + fn get_accumulator_type(&self) -> AggregationType { + self.kind + } + fn get_keys(&self) -> Option> { + None + } + fn approx_memory_bytes(&self) -> usize { + self.bytes.len() + self.batch.bytes() + } + fn merge_with( + &self, + _: &dyn AggregateCore, + ) -> Result, Box> { + Err("native output snapshots require an explicit physical merge operator".into()) + } + fn query_statistic( + &self, + _: asap_types::Statistic, + _: &Option, + _: &HashMap, + ) -> Result> { + Err("native output readout requires the installed physical DAG".into()) + } +} + +impl SketchStore { + /// Publish a finalized native result only after the complete raw input + /// cohort is durable. Existing publication fences prevent duplicate commits. + pub fn publish_native_summary_output( + &self, + resolver: &SeriesIdResolver, + config: &asap_types::PrecomputeMaterialization, + output: &crate::storage_engines::types::PrecomputedOutput, + batch: Batch, + max_bytes: usize, + ) -> Result { + use sha2::{Digest, Sha256}; + let generation = output + .catalog_generation + .as_ref() + .ok_or("native output requires a catalog generation")?; + let program = config + .derived_input + .as_ref() + .ok_or("native output requires installed input lineage")?; + let window = (output.start_timestamp, output.end_timestamp); + let cohort = + self.read_complete_raw_maintenance_cohort(generation, &program.inputs, window)?; + let (population_key, group) = build_attrs_fp_and_label_map(config, output)?; + let state = NativeSummaryOutput::new(batch, max_bytes)?; + let family = config.accumulator_spec().map_err(|e| e.to_string())?.family; + if state + .batch + .schema() + .fields + .iter() + .filter(|field| { + !matches!( + field.dtype, + planner_types::post_asap::SummaryFamilyType::Plain(_) + ) + }) + .any(|field| field.dtype != family) + { + return Err("native output schema differs from installed definition".into()); + } + for value in state.batch.rows().iter().flatten() { + if let Value::Summary { family: actual, .. } = value { + if actual != &family { + return Err("native output family differs from installed definition".into()); + } + } + } + state.validate_group(&group)?; + let sid = self.resolve_output_storage_handle( + resolver, + config.policy_fingerprint().into(), + &population_key, + Some(generation), + )?; + let mut digest = Sha256::new(); + digest.update(serde_json::to_vec(&(program, window)).map_err(|e| e.to_string())?); + for input in cohort.inputs() { + digest.update( + serde_json::to_vec(&(&input.stored_output_reference, &input.group)) + .map_err(|e| e.to_string())?, + ); + for (window, state) in &input.windows { + digest.update(serde_json::to_vec(window).map_err(|e| e.to_string())?); + let bytes = state.serialize_to_bytes(); + digest.update((bytes.len() as u64).to_le_bytes()); + digest.update(bytes); + } + } + self.publish_complete_raw_maintenance_output( + sid, + config, + output, + &state, + &cohort, + digest.finalize().into(), + ) + } + + /// Resolve the installed output/group prefix without minting a new handle, + /// then read exactly the requested immutable window and validate its format. + pub fn read_native_summary_output( + &self, + resolver: &SeriesIdResolver, + address: &asap_types::sds::StoredSummaryKey, + reference: &StoredOutputReference, + expected_schema: Schema, + max_bytes: usize, + ) -> Result { + address.validate().map_err(|e| e.to_string())?; + let (catalog, generation) = self + .descriptors + .authoritative_snapshot() + .ok_or("native read requires an installed catalog")?; + if (address.plan_id, address.plan_version) != (generation.plan_id, generation.plan_version) + || address.stored_output_id != reference.stored_output_id + || catalog + .output_reference(address.stored_output_id) + .map_err(|e| e.to_string())? + != *reference + { + return Err("native read differs from its installed output reference".into()); + } + let identity = &catalog.outputs[&address.stored_output_id]; + let data = &catalog.data_descriptors[&identity.data_descriptor_id]; + let pairs: Vec<_> = address + .population + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())) + .collect(); + let population_key = crate::drivers::ingest::population_attrs_fingerprint( + data.population_key_encoding, + &pairs, + )?; + let identity = serde_json::to_string(&(address.plan_id, address.plan_version, reference)) + .map_err(|e| e.to_string())?; + let sid = resolver + .lookup("stored-output", &population_key, &identity) + .ok_or("native stored output/group is unavailable")?; + self.read_native_summary_handle(sid, address, reference, expected_schema, max_bytes) + } + + /// A complete native batch is stored atomically under one global address. + /// Logical grouping remains in the batch; reads never allocate a resolver ID. + pub fn read_bound_native_summary( + &self, + address: &asap_types::sds::StoredSummaryKey, + reference: &StoredOutputReference, + expected_schema: Schema, + max_bytes: usize, + ) -> Result { + if !address.population.is_empty() { + return Err("native batch binding requires the complete stored output".into()); + } + let handles = self.storage_handles_for_output(reference); + let [sid] = handles.as_slice() else { + return Err("native stored output is absent or ambiguous".into()); + }; + self.read_native_summary_handle(*sid, address, reference, expected_schema, max_bytes) + } + + fn read_native_summary_handle( + &self, + sid: u64, + address: &asap_types::sds::StoredSummaryKey, + reference: &StoredOutputReference, + expected_schema: Schema, + max_bytes: usize, + ) -> Result { + address.validate().map_err(|e| e.to_string())?; + let generation = self + .active_catalog_generation() + .ok_or("native read has no active generation")?; + if (address.plan_id, address.plan_version) != (generation.plan_id, generation.plan_version) + || address.stored_output_id != reference.stored_output_id + || self.stored_output_for_handle(sid).as_ref() != Some(reference) + { + return Err("native read differs from its installed output binding".into()); + } + let window = ( + u64::try_from(address.window.start_ms).map_err(|_| "negative native window")?, + u64::try_from(address.window.end_ms).map_err(|_| "negative native window")?, + ); + let frozen = self.read_frozen_windows_with( + sid, + address.stored_output_id, + &generation, + &BTreeSet::from([window]), + &address.population, + |name, tag, bytes| { + if name != NATIVE_OUTPUT_TYPE || tag != NATIVE_OUTPUT_TAG { + return Err("stored record is not a native physical output".into()); + } + if bytes.len() > max_bytes { + return Err(NativeReadError::Physical( + asap_physical_operators::Error::MemoryLimit, + )); + } + let batch = decode_batch(bytes, expected_schema.clone(), max_bytes)?; + if batch.bytes() > max_bytes { + return Err(NativeReadError::Physical( + asap_physical_operators::Error::MemoryLimit, + )); + } + let output = NativeSummaryOutput::new(batch, max_bytes)?; + output.validate_group(&address.population)?; + Ok(Box::new(output) as Box) + }, + )?; + let state = frozen.windows[&window] + .as_any() + .downcast_ref::() + .ok_or("native output decoder mismatch")?; + Ok(state.batch.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage_engines::types::PrecomputedOutput; + use asap_physical_operators::{ + dag::{operators::Operator, Limits, RunContext, Scope}, + physical_planner::{CompiledPhysicalDag, InputContract, Source}, + summary_kernels::SumAccumulator, + }; + use futures::{executor::block_on, StreamExt}; + use planner_types::{ + post_asap::{SummaryFamilyType, SummaryField, SummarySchema}, + pre_asap::DataType, + }; + + fn run(input: Batch, operator: Operator) -> Batch { + let schema = input.schema().clone(); + let plan = CompiledPhysicalDag::from_operators( + BTreeMap::from([(0, InputContract::bounded(schema.clone()))]), + BTreeMap::from([(1, (vec![0], operator))]), + vec![1], + ) + .unwrap(); + let plan = CompiledPhysicalDag::decode(&plan.encode().unwrap()).unwrap(); + let graph = plan + .instantiate(BTreeMap::from([( + 0, + Box::new(Operator::source(schema, vec![input]).unwrap()) as Source<'_>, + )])) + .unwrap(); + let context = RunContext::new( + Scope::Query { + evaluation_time_ms: 60_000, + revision: 0, + }, + Limits::default(), + ) + .unwrap(); + let stream = graph.execute(&[1], context).unwrap().remove(0); + let output = block_on(stream.collect::>()); + assert_eq!(output.len(), 1); + (*output.into_iter().next().unwrap().unwrap()).clone() + } + + // Group columns in the payload must agree with the bound record address. + #[test] + fn native_summary_payload_cannot_be_relabelled_as_another_group() { + let family = SummaryFamilyType::ExactAggregate( + planner_types::post_asap::ExactKind::Sum, + planner_types::post_asap::ExactParams::Sum, + ); + let schema = Arc::new(SummarySchema { + fields: vec![ + SummaryField { + name: "job".into(), + dtype: SummaryFamilyType::Plain(DataType::Utf8), + nullable: false, + }, + SummaryField { + name: "state".into(), + dtype: family.clone(), + nullable: false, + }, + ], + time_index: None, + }); + let batch = Batch::try_new( + schema, + vec![vec![ + Value::Utf8("api".into()), + Value::Summary { + family, + state: Arc::new(SumAccumulator::new()), + }, + ]], + ) + .unwrap(); + let output = NativeSummaryOutput::new(batch, 1 << 20).unwrap(); + output + .validate_group(&BTreeMap::from([("job".into(), "api".into())])) + .unwrap(); + assert!(output + .validate_group(&BTreeMap::from([("job".into(), "worker".into())])) + .is_err()); + assert!(output + .validate_group(&BTreeMap::from([("unknown".into(), "api".into())])) + .is_err()); + } + + // Real durable source panes -> native build -> immutable publication -> bound + // lookup -> native readout, repeated after both store and resolver restart. + #[test] + fn native_summary_publication_and_bound_recovery_preserve_identity_and_format() { + let mut fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../../../docs/examples/asapquery-compatibility-demo-snapshot.json" + )) + .unwrap(); + let mut query = fixture["query_workload"]["repeating_queries"][3].clone(); + query["query"] = "quantile(1.0, sum_over_time(immutable_value[1m]))".into(); + query["demand"]["fixed_interval_at"]["interval"] = 60_000.into(); + query["demand"]["fixed_interval_at"]["evaluation_phase"] = 0.into(); + fixture["query_workload"]["repeating_queries"] = serde_json::json!([query]); + let snapshot = serde_json::from_value(fixture).unwrap(); + let plan = crate::tests::test_utilities::planning::quoted_snapshot(snapshot, false) + .compile_promql() + .unwrap(); + let source = plan + .precompute_plan + .materializations + .iter() + .find(|c| c.derived_input.is_none()) + .unwrap(); + let target = plan + .precompute_plan + .materializations + .iter() + .find(|c| c.derived_input.is_some()) + .unwrap(); + let directory = tempfile::tempdir().unwrap(); + let resolver_path = directory.path().join("resolver.jsonl"); + let resolver = SeriesIdResolver::open(resolver_path.clone()).unwrap(); + let persistence_config = || { + let mut config = persistence::config::SketchStorePersistenceConfig::with_memory_limit( + 1 << 24, + directory.path().join("store"), + ); + config.delete_older_than_ms = None; + config.hot_window_ms = None; + config + }; + let store = Arc::new(SketchStore::new()); + store + .install_summary_catalog(Arc::new(plan.summary_catalog.clone())) + .unwrap(); + let generation = store.active_catalog_generation().unwrap(); + let mut persistence = store.start_persistence(persistence_config()).unwrap(); + for (instance, value) in [("a", 0.125), ("b", 0.25)] { + let group = BTreeMap::from([("instance".to_string(), instance.to_string())]); + let coordinate = asap_types::sds::SummaryInstanceCoordinates { + stored_output_id: source.policy_fingerprint().into(), + time_range: HalfOpenTimeRange { + start_ms: 0, + end_ms: 60_000, + }, + group_values: group.clone(), + }; + let revision = store + .admit_summary_updates(&generation, BTreeSet::from([coordinate.clone()])) + .unwrap(); + let mut output = PrecomputedOutput::new(0, 60_000, None, source.policy_fingerprint()); + output.population_labels = Some(group); + output.catalog_generation = Some(generation.clone()); + let mut state = SumAccumulator::new(); + state.update(value); + store + .publish_admitted_summary_update( + &generation, + &coordinate, + revision, + revision, + 120_000, + |writer| writer.ingest_precompute_with_series_id(700, source, &output, &state), + ) + .unwrap(); + } + let deadline = crate::tests::test_utilities::timing::deadline(Duration::from_secs(5)); + while !store.seal_finite_summary_input(&generation).unwrap() { + assert!(std::time::Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(5)); + } + let cohort = store + .read_complete_raw_maintenance_cohort( + &generation, + &target.derived_input.as_ref().unwrap().inputs, + (0, 60_000), + ) + .unwrap(); + let values = cohort + .inputs() + .iter() + .flat_map(|input| input.windows.values()) + .map(|state| { + vec![Value::Float64( + state + .query_statistic(asap_types::Statistic::Sum, &None, &HashMap::new()) + .unwrap(), + )] + }) + .collect(); + let raw_schema = Arc::new(SummarySchema { + fields: vec![SummaryField { + name: "value".into(), + dtype: SummaryFamilyType::Plain(DataType::Float64), + nullable: false, + }], + time_index: None, + }); + let input = Batch::try_new(raw_schema.clone(), values).unwrap(); + let build = Operator::summary_build( + raw_schema, + target.accumulator_spec().unwrap().family, + 0, + None, + vec![], + ) + .unwrap(); + let output_batch = run(input, build); + let expected_schema = output_batch.schema().clone(); + let mut output = PrecomputedOutput::new(0, 60_000, None, target.policy_fingerprint()); + output.catalog_generation = Some(generation.clone()); + let before = persistence.manifest.live_parts().len(); + assert!(store + .publish_native_summary_output( + &resolver, + target, + &output, + output_batch.clone(), + 1 << 20 + ) + .unwrap()); + store + .publish_native_summary_output(&resolver, target, &output, output_batch, 1 << 20) + .unwrap(); + assert_eq!(persistence.manifest.live_parts().len(), before + 1); + let reference = plan + .summary_catalog + .output_reference(target.policy_fingerprint().into()) + .unwrap(); + let address = asap_types::sds::StoredSummaryKey { + plan_id: generation.plan_id, + plan_version: generation.plan_version, + stored_output_id: reference.stored_output_id, + population: BTreeMap::new(), + window: HalfOpenTimeRange { + start_ms: 0, + end_ms: 60_000, + }, + }; + let check = |store: &SketchStore, resolver: &SeriesIdResolver| { + let batch = store + .read_native_summary_output( + resolver, + &address, + &reference, + expected_schema.clone(), + 1 << 20, + ) + .unwrap(); + let direct = store + .read_bound_native_summary(&address, &reference, expected_schema.clone(), 1 << 20) + .unwrap(); + assert_eq!( + encode_batch(&direct).unwrap(), + encode_batch(&batch).unwrap() + ); + let handles = store.storage_handles_for_output(&reference); + assert_eq!(handles.len(), 1); + let frames = store.query_range(handles[0], 0, 60_000); + assert_eq!( + frames[0].samples[&60_000][0].encoding, + SketchEncoding::NativeBatchV1 + ); + let count = resolver.len(); + let mut other_group = address.clone(); + other_group + .population + .insert("instance".into(), "unknown".into()); + assert!(store + .read_native_summary_output( + resolver, + &other_group, + &reference, + expected_schema.clone(), + 1 << 20 + ) + .is_err()); + assert_eq!( + resolver.len(), + count, + "bound reads must not allocate outputs" + ); + let bytes = encode_batch(&batch).unwrap(); + let readout = Operator::readout( + batch.schema().clone(), + 0, + asap_types::Statistic::Quantile, + HashMap::from([("quantile".into(), "1.0".into())]), + ) + .unwrap(); + let values = run(batch, readout); + assert!(matches!(values.rows()[0][0], Value::Float64(v) if (v - 0.25).abs() < 0.01)); + let mut wrong = reference.clone(); + wrong.definition_id = plan + .summary_catalog + .output_reference(source.policy_fingerprint().into()) + .unwrap() + .definition_id; + assert!(store + .read_native_summary_output( + resolver, + &address, + &wrong, + expected_schema.clone(), + 1 << 20 + ) + .is_err()); + let mut missing = address.clone(); + missing.window.end_ms = 120_000; + assert!(store + .read_native_summary_output( + resolver, + &missing, + &reference, + expected_schema.clone(), + 1 << 20 + ) + .is_err()); + assert!(matches!( + store.read_native_summary_output( + resolver, + &address, + &reference, + expected_schema.clone(), + 1 + ), + Err(NativeReadError::Physical( + asap_physical_operators::Error::MemoryLimit + )) + )); + bytes + }; + let bytes = check(&store, &resolver); + persistence.shutdown(); + drop(store); + drop(resolver); + let recovered = Arc::new(SketchStore::new()); + recovered + .install_summary_catalog(Arc::new(plan.summary_catalog.clone())) + .unwrap(); + let mut persistence = recovered.start_persistence(persistence_config()).unwrap(); + let resolver = SeriesIdResolver::open(resolver_path).unwrap(); + assert_eq!(check(&recovered, &resolver), bytes); + persistence.shutdown(); + } +} diff --git a/data_plane/src/storage_engines/sketch_db/persistence/cache.rs b/data_plane/src/storage_engines/sketch_db/persistence/cache.rs index 0c235a451..ceda43c88 100644 --- a/data_plane/src/storage_engines/sketch_db/persistence/cache.rs +++ b/data_plane/src/storage_engines/sketch_db/persistence/cache.rs @@ -34,9 +34,9 @@ impl PartCache { Some( Cache::builder() .weigher(|_k: &PartId, v: &LoadedPart| -> u32 { - // Weight = sum of mmap'd bytes. Moka's weigher + // Include mmap bytes and the in-memory window index. The weigher // returns u32, so clamp large parts. - let len = v.meta.data_len + v.meta.index_len; + let len = v.resident_bytes(); len.min(u32::MAX as u64) as u32 }) .max_capacity(byte_budget) diff --git a/data_plane/src/storage_engines/sketch_db/persistence/part.rs b/data_plane/src/storage_engines/sketch_db/persistence/part.rs index c760668cb..6eae5f043 100644 --- a/data_plane/src/storage_engines/sketch_db/persistence/part.rs +++ b/data_plane/src/storage_engines/sketch_db/persistence/part.rs @@ -430,13 +430,15 @@ pub struct IndexRecord { pub data_offset: u64, } -/// mmap-backed reader for a single part. Cheap to construct (three -/// mmaps + one header parse), safe to share across threads via `Arc`. +/// mmap-backed reader with a sorted output/window index built once on open. +/// Safe to share across threads via `Arc`. pub struct PartReader { pub meta: PartMeta, pub part_dir: PathBuf, data_mmap: Arc, index_mmap: Arc, + // Older parts preserve flush order, so keep a separate sorted lookup. + window_index: Vec, } impl std::fmt::Debug for PartReader { @@ -479,12 +481,25 @@ impl PartReader { ))); } - Ok(Self { + let mut reader = Self { meta, part_dir: part_dir.to_path_buf(), data_mmap: Arc::new(data_mmap), index_mmap: Arc::new(index_mmap), - }) + window_index: Vec::new(), + }; + let mut positions: Vec<_> = (0..reader.meta.num_entries as usize).collect(); + positions.sort_unstable_by_key(|&position| { + let record = reader.index_record(position); + ( + record.agg_id, + record.start_ts, + record.end_ts, + record.data_offset, + ) + }); + reader.window_index = positions; + Ok(reader) } /// Read and verify just the meta.bin header (cheap — 64 bytes). @@ -537,28 +552,51 @@ impl PartReader { }) } - /// Return all index records. Small (32 B × num_entries) — cheap to - /// materialize. - pub fn index_records(&self) -> Vec { - let n = self.meta.num_entries as usize; - let mut out = Vec::with_capacity(n); - for i in 0..n { - let off = i * INDEX_ENTRY_SIZE; - let agg_id = u64::from_le_bytes(self.index_mmap[off..off + 8].try_into().unwrap()); - let start_ts = - u64::from_le_bytes(self.index_mmap[off + 8..off + 16].try_into().unwrap()); - let end_ts = - u64::from_le_bytes(self.index_mmap[off + 16..off + 24].try_into().unwrap()); - let data_offset = - u64::from_le_bytes(self.index_mmap[off + 24..off + 32].try_into().unwrap()); - out.push(IndexRecord { - agg_id, - start_ts, - end_ts, - data_offset, - }); + fn index_record(&self, position: usize) -> IndexRecord { + let off = position * INDEX_ENTRY_SIZE; + let read = + |offset| u64::from_le_bytes(self.index_mmap[offset..offset + 8].try_into().unwrap()); + IndexRecord { + agg_id: read(off), + start_ts: read(off + 8), + end_ts: read(off + 16), + data_offset: read(off + 24), } - out + } + + /// Preserve disk order for callers that need to inspect the entire part. + pub fn index_records(&self) -> Vec { + (0..self.meta.num_entries as usize) + .map(|position| self.index_record(position)) + .collect() + } + + /// Find records contained in the requested window under one stored-output + /// prefix. Duplicate windows remain visible so callers can reject ambiguity. + pub fn window_records( + &self, + agg_id: u64, + start_ts: u64, + end_ts: u64, + ) -> impl Iterator + '_ { + let first = self.window_index.partition_point(|&position| { + let record = self.index_record(position); + (record.agg_id, record.start_ts) < (agg_id, start_ts) + }); + self.window_index[first..] + .iter() + .map(|&position| self.index_record(position)) + .take_while(move |record| record.agg_id == agg_id && record.start_ts <= end_ts) + .filter(move |record| record.end_ts <= end_ts) + } + + pub fn resident_bytes(&self) -> u64 { + (self.data_mmap.len() as u64) + .saturating_add(self.index_mmap.len() as u64) + .saturating_add( + (self.window_index.capacity() as u64) + .saturating_mul(std::mem::size_of::() as u64), + ) } /// Resolve a single index record into a [`SnapshotEntry`] by reading @@ -658,6 +696,40 @@ mod tests { } } + // Old parts can be unsorted; lookup must isolate the prefix/range while + // preserving duplicate records for the immutable reader's ambiguity check. + #[test] + fn window_lookup_handles_unsorted_parts_duplicates_and_recovery() { + let tmp = TempDir::new().unwrap(); + let part_dir = tmp.path().join("lookup"); + let mut snapshots = Vec::new(); + for id in [99, 42, 1] { + let mut snapshot = make_snapshot(); + snapshot.agg_id = id; + snapshot.entries.reverse(); + if id == 42 { + snapshot.entries.push(snapshot.entries[1].clone()); + } + snapshots.push(snapshot); + } + PartWriter::write_part(&part_dir, 1, &snapshots).unwrap(); + for _ in 0..2 { + let reader = PartReader::open(&part_dir).unwrap(); + let records = reader.window_records(42, 1_000, 1_500).collect::>(); + assert_eq!(records.len(), 2); + assert!(records.iter().all(|record| record.agg_id == 42 + && record.start_ts == 1_000 + && record.end_ts == 1_500)); + assert_ne!(records[0].data_offset, records[1].data_offset); + assert_eq!(reader.window_records(42, 1_500, 2_000).count(), 1); + assert_eq!(reader.window_records(43, 0, u64::MAX).count(), 0); + assert_eq!(reader.window_records(42, 1_001, 1_999).count(), 0); + assert_eq!(reader.window_records(42, 2_000, 1_000).count(), 0); + assert_eq!(reader.index_records()[0].agg_id, 99); + assert!(reader.resident_bytes() > reader.meta.data_len + reader.meta.index_len); + } + } + #[test] fn part_round_trip_writes_and_reads_back() { let tmp = TempDir::new().unwrap(); diff --git a/data_plane/src/storage_engines/types/hot_reload_config.rs b/data_plane/src/storage_engines/types/hot_reload_config.rs index 47d0ddb29..b624d8164 100644 --- a/data_plane/src/storage_engines/types/hot_reload_config.rs +++ b/data_plane/src/storage_engines/types/hot_reload_config.rs @@ -80,6 +80,9 @@ impl RuntimePhysicalPlan { .map_err(|error| error.to_string())?; let mut program = entry.clone(); program.root = root; + // A bound readout is an input to the physical graph, not a second + // invocation of that graph. Keep only its storage/readout contract. + program.physical_dag = None; program.nodes = reachable .into_iter() .map(|id| (id, entry.nodes[&id].clone())) @@ -761,6 +764,7 @@ mod tests { fn readout_programs_follow_replaced_query_plan_bindings() { use asap_types::query_plan::*; let entry = |id: u64| QueryPlanEntry { + physical_dag: None, language: asap_types::QueryLanguage::PromQl, query_id: "sum_over_time(m[1m])".into(), canonical_query: "sum_over_time(m[1m])".into(), diff --git a/data_plane/tests/support/physical_fixture.rs b/data_plane/tests/support/physical_fixture.rs index 1b9b03823..e86a100e9 100644 --- a/data_plane/tests/support/physical_fixture.rs +++ b/data_plane/tests/support/physical_fixture.rs @@ -147,6 +147,7 @@ pub fn artifact_from_materializations( query_plan.entries.insert( canonical.clone(), QueryPlanEntry { + physical_dag: None, language: asap_types::query_plan::QueryLanguage::PromQl, query_id: canonical.clone(), canonical_query: canonical, From 93ad0fe6fe8158deb01634b14ba54acb1ec3fce1 Mon Sep 17 00:00:00 2001 From: zzylol Date: Tue, 29 Sep 2026 12:45:47 +0000 Subject: [PATCH 04/19] Retain selected native candidates and execute revisioned query ensembles --- control_plane/src/main.rs | 1 + control_plane/src/physical/compiler.rs | 136 +++++++-- .../src/physical/maintained_population.rs | 14 +- control_plane/src/physical/workload_cost.rs | 5 + control_plane/tests/native_rate_topk.rs | 48 ++++ .../precompute_engine/maintenance_runtime.rs | 25 +- .../precompute_engine/native_precompute.rs | 41 ++- data_plane/src/precompute_engine/revisions.rs | 50 +++- .../storage_engines/sketch_db/index/mod.rs | 33 ++- .../storage_engines/sketch_db/index/native.rs | 31 +++ .../asapquery_compatibility_process_e2e.rs | 10 +- .../tests/support/issue_701_702_process.rs | 12 +- .../tests/support/native_revision_process.rs | 257 ++++++++++++++++++ 13 files changed, 596 insertions(+), 67 deletions(-) create mode 100644 data_plane/tests/support/native_revision_process.rs diff --git a/control_plane/src/main.rs b/control_plane/src/main.rs index 74a185f00..ef5ac2254 100644 --- a/control_plane/src/main.rs +++ b/control_plane/src/main.rs @@ -590,6 +590,7 @@ fn compile_physical_plan_request( query_id: query.query_id, query_string: query.query_string, selected_plan_root: post_asap, + physical_candidate: None, legacy_query_source: planner_types::pre_asap::Source::TimeSeries { metric: query.metric, }, diff --git a/control_plane/src/physical/compiler.rs b/control_plane/src/physical/compiler.rs index d2ebaf864..55253d424 100644 --- a/control_plane/src/physical/compiler.rs +++ b/control_plane/src/physical/compiler.rs @@ -67,6 +67,9 @@ pub struct QueryCompilationInput { /// Planner-selected post-ASAP DAG. The physical compiler must not /// re-select a summary family from pre-ASAP input. pub selected_plan_root: Rc, + /// Encoded Planner PhysicalCandidate retained before deployment pricing. + /// Installation decodes this candidate; it must not compile another graph. + pub physical_candidate: Option>, /// Legacy catalog source retained for request compatibility. Physical /// materialization sources are derived from each post-ASAP SummaryAgg; /// it also remains part of the cost manifest workload identity. @@ -88,6 +91,46 @@ pub struct QueryCompilationInput { pub materialization_runtime_policy: RuntimeRulePolicy, } +impl QueryCompilationInput { + /// Physical planning precedes deployment feasibility and cost selection. + pub(crate) fn retain_physical_candidate(&mut self) -> Result<(), CompileError> { + use asap_physical_operators::physical_planner::{promql_rows, PhysicalCandidate}; + let candidate = + promql_rows::compile_fixed_window_rate_aggregation(&self.selected_plan_root) + .or_else(|_| { + promql_rows::compile_current_series_readout(&self.selected_plan_root) + .or_else(|_| { + promql_rows::compile_rate_ranking(&self.selected_plan_root) + .map(|(_, dag)| dag) + }) + .map(|query| PhysicalCandidate { + precompute: None, + query, + materialized_outputs: BTreeMap::new(), + }) + }) + .ok(); + self.physical_candidate = candidate + .map(|candidate| candidate.encode()) + .transpose() + .map_err(|e| CompileError::Snapshot(e.to_string()))?; + Ok(()) + } + + fn retained_physical( + &self, + ) -> Result, CompileError> + { + self.physical_candidate + .as_ref() + .map(|bytes| { + asap_physical_operators::physical_planner::PhysicalCandidate::decode(bytes) + .map_err(|e| CompileError::Snapshot(e.to_string())) + }) + .transpose() + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct WindowRealizationCostQuote { @@ -917,6 +960,7 @@ impl BackendLocalPlanningInput { query_id, query_string: query_string.clone(), selected_plan_root: post_asap, + physical_candidate: None, legacy_query_source: Source::TimeSeries { metric: source_hint, }, @@ -1084,6 +1128,12 @@ impl BackendLocalPlanningInput { } planner_candidate_forests.push(forest); } + for query in queries + .iter_mut() + .chain(planner_candidate_forests.iter_mut().flatten()) + { + query.retain_physical_candidate()?; + } // Composable lowering residualizes unsafe leaves individually; retain Planner siblings. Ok(( PhysicalCompilationRequest { @@ -1571,7 +1621,10 @@ impl DeploymentPlanCompiler { for state in &selected { if matches!(&state.node.expr, SummaryExpr::SummaryAgg { reduction: planner_types::pre_asap::Reduction::Reduce(keys), .. - } if keys.is_empty()) || asap_physical_operators::physical_planner::promql_rows::compile_fixed_window_rate_aggregation(&query.selected_plan_root).is_ok() + } if keys.is_empty()) + || query + .retained_physical()? + .is_some_and(|candidate| candidate.precompute.is_some()) { if let Some(sources) = immutable_materialization_sources(&state.node) { canonical_nodes.insert(Rc::as_ptr(&state.node) as usize); @@ -1582,7 +1635,9 @@ impl DeploymentPlanCompiler { } } let cohort_nodes = windows::cohort_nodes(&selected); - let native_cohort = asap_physical_operators::physical_planner::promql_rows::compile_fixed_window_rate_aggregation(&query.selected_plan_root).is_ok(); + let native_cohort = query + .retained_physical()? + .is_some_and(|candidate| candidate.precompute.is_some()); for (ordinal, selected) in selected.into_iter().enumerate() { let mut branch_query = query.clone(); branch_query.query_lookback_ms = selected @@ -2162,23 +2217,39 @@ impl DeploymentPlanCompiler { } else { false }; - let native_rate = - asap_physical_operators::physical_planner::promql_rows::compile_rate_ranking( - &query.selected_plan_root, - ) - .ok(); - let native_rate = native_rate.or_else(|| { - let physical = asap_physical_operators::physical_planner::promql_rows::compile_fixed_window_rate_aggregation(&query.selected_plan_root).ok()?; - fn heap(node: &Rc) -> Option> { - match &node.expr { - SummaryExpr::SummaryAgg { family: SummaryFamilyType::Sketch(..) | SummaryFamilyType::ExactAggregate(planner_types::post_asap::ExactKind::Sum, _), .. } => Some(node.clone()), - SummaryExpr::ValueOperation { child, .. } => heap(child), - SummaryExpr::SummaryEstimate { summary_input, .. } => heap(summary_input), - _ => None, - } - } - Some((heap(&query.selected_plan_root)?, physical.query)) - }); + let native_rate = if population_operators[query_index].is_none() { + query + .retained_physical()? + .map(|physical| { + let program = physical.query; + let sources = program + .input_contracts() + .map(|(id, _)| id) + .collect::>(); + let [source] = sources.as_slice() else { + return Err(CompileError::Snapshot( + "native candidate requires one bound input".into(), + )); + }; + let source = executable_dags[query_index] + .as_ref() + .and_then(|compiled| { + u32::try_from(*source).ok().and_then(|id| { + compiled.node_ids.summary_node(PostAsapNodeId(id)) + }) + }) + .cloned() + .ok_or_else(|| { + CompileError::Snapshot( + "selected physical input has no semantic binding".into(), + ) + })?; + Ok((source, program)) + }) + .transpose()? + } else { + None + }; let mut entry = if let Some((source, program)) = native_rate { let native_state_binding = if let SummaryExpr::SummaryAgg { family: @@ -2371,7 +2442,10 @@ impl DeploymentPlanCompiler { } super::maintained_population::install_native_topk( &mut entry, - &query.selected_plan_root, + query + .retained_physical()? + .as_ref() + .map(|candidate| &candidate.query), )?; let catalog_key = QueryPlan::catalog_key(entry.language, &canonical); if query_entries.insert(catalog_key, entry).is_some() { @@ -2414,10 +2488,23 @@ impl DeploymentPlanCompiler { query_id: query_id.clone(), reason, })?; - if let Ok(physical) = asap_physical_operators::physical_planner::promql_rows::compile_fixed_window_rate_aggregation(&request.queries[query_index].selected_plan_root) { - let program = physical.precompute.ok_or_else(|| CompileError::Snapshot("fixed-window candidate has no maintenance program".into()))?; - let sink = PostAsapNodeId(u32::try_from(program.roots()[0]).map_err(|_| CompileError::Snapshot("maintenance sink overflow".into()))?); - installed.native_programs.insert(sink, serde_json::from_slice(&program.encode().map_err(|e| CompileError::Snapshot(e.to_string()))?).map_err(|e| CompileError::Snapshot(e.to_string()))?); + if let Some(program) = request.queries[query_index] + .retained_physical()? + .and_then(|physical| physical.precompute) + { + let sink = PostAsapNodeId( + u32::try_from(program.roots()[0]) + .map_err(|_| CompileError::Snapshot("precompute sink overflow".into()))?, + ); + installed.native_programs.insert( + sink, + serde_json::from_slice( + &program + .encode() + .map_err(|e| CompileError::Snapshot(e.to_string()))?, + ) + .map_err(|e| CompileError::Snapshot(e.to_string()))?, + ); } query_plan .selected_dags @@ -5555,6 +5642,7 @@ pub(crate) mod tests { query_id: query_id.into(), query_string: promql.into(), selected_plan_root: post_asap, + physical_candidate: None, legacy_query_source: Source::TimeSeries { metric: "m".into() }, query_lookback_ms: 60_000, group_by_labels: vec![], diff --git a/control_plane/src/physical/maintained_population.rs b/control_plane/src/physical/maintained_population.rs index e4cbd518a..e5bc35eab 100644 --- a/control_plane/src/physical/maintained_population.rs +++ b/control_plane/src/physical/maintained_population.rs @@ -161,7 +161,7 @@ pub(super) fn operator( /// Planner before this candidate is priced or installed. pub(super) fn install_native_topk( entry: &mut asap_types::query_plan::QueryPlanEntry, - selected: &std::rc::Rc, + compiled: Option<&asap_physical_operators::physical_planner::CompiledPhysicalDag>, ) -> Result<(), CompileError> { use asap_types::query_plan::QueryPlanNode; let Some(QueryPlanNode::Logical { @@ -178,14 +178,10 @@ pub(super) fn install_native_topk( if population.grouping.without { return Ok(()); } - let compiled = - asap_physical_operators::physical_planner::promql_rows::compile_current_series_readout( - selected, - ) - .map_err(|error| CompileError::Query { - query_id: entry.query_id.clone(), - reason: error.to_string(), - })?; + let compiled = compiled.ok_or_else(|| CompileError::Query { + query_id: entry.query_id.clone(), + reason: "selected TopK candidate has no retained physical DAG".into(), + })?; let encoded = compiled.encode().map_err(|error| CompileError::Query { query_id: entry.query_id.clone(), reason: error.to_string(), diff --git a/control_plane/src/physical/workload_cost.rs b/control_plane/src/physical/workload_cost.rs index 74dc1d5ba..049fd272e 100644 --- a/control_plane/src/physical/workload_cost.rs +++ b/control_plane/src/physical/workload_cost.rs @@ -882,6 +882,11 @@ fn enumerate_frontier_candidates( } } } + for candidate in &mut candidates { + for query in &mut candidate.queries { + query.retain_physical_candidate()?; + } + } Ok(candidates) } diff --git a/control_plane/tests/native_rate_topk.rs b/control_plane/tests/native_rate_topk.rs index 0ac493186..e8cc2de57 100644 --- a/control_plane/tests/native_rate_topk.rs +++ b/control_plane/tests/native_rate_topk.rs @@ -276,3 +276,51 @@ fn grouped_rate_has_native_query_and_maintenance_candidates() { "{errors:#?}" ); } + +// Deployment must install the exact retained Planner graphs, including roots, +// typed inputs and materialization frontiers, rather than compiling replacements. +#[test] +fn selected_native_graphs_survive_deployment_unchanged() { + use asap_physical_operators::physical_planner::PhysicalCandidate; + let (request, environment) = fixture(true).into_physical_compilation_request().unwrap(); + let mut checked = 0; + let mut checked_precompute = 0; + for candidate in enumerate_exact_and_materialized_candidates(request).unwrap() { + let expected = candidate + .queries + .iter() + .filter_map(|query| { + query.physical_candidate.as_ref().map(|bytes| { + ( + query.query_id.clone(), + PhysicalCandidate::decode(bytes).unwrap(), + ) + }) + }) + .collect::>(); + let Ok(plan) = DeploymentPlanCompiler.compile_promql(candidate, environment.clone()) else { + continue; + }; + for entry in plan.query_plan.entries.values() { + let Some(expected) = expected.get(&entry.query_id) else { + continue; + }; + let actual = entry + .physical_dag + .as_ref() + .expect("retained candidate must be installed"); + let encoded: Value = serde_json::from_slice(&expected.query.encode().unwrap()).unwrap(); + assert_eq!(*actual, encoded); + if let Some(precompute) = &expected.precompute { + let encoded: Value = serde_json::from_slice(&precompute.encode().unwrap()).unwrap(); + assert!(plan.precompute_plan.executable_dags[&entry.query_id] + .native_programs + .values() + .any(|actual| actual == &encoded)); + checked_precompute += 1; + } + checked += 1; + } + } + assert!(checked > 0 && checked_precompute > 0); +} diff --git a/data_plane/src/precompute_engine/maintenance_runtime.rs b/data_plane/src/precompute_engine/maintenance_runtime.rs index 4e9c01af6..374e36fc2 100644 --- a/data_plane/src/precompute_engine/maintenance_runtime.rs +++ b/data_plane/src/precompute_engine/maintenance_runtime.rs @@ -1550,7 +1550,9 @@ fn execute_finite_complete_populations( cohort.inputs(), window, max_bytes, - )?; + 0, + ) + .map_err(|e| e.to_string())?; let mut output = crate::storage_engines::types::PrecomputedOutput::new( window.0, window.1, @@ -4556,6 +4558,27 @@ pub(crate) fn execute_revision_outputs( { continue; } + if let Some(program) = installed.native_program(*sink)? { + let batch = super::native_precompute::execute( + installed, &program, &selected, window, limit, revision, + )?; + let payload = + asap_physical_operators::stored_state::native::encode_batch(&batch)?; + if payload.len() > limit { + return Err(asap_physical_operators::Error::MemoryLimit.into()); + } + result.get_mut(target).unwrap().push(RevisionRecord { + reference: plan + .installed_precompute_plan + .stored_output_reference(*target) + .ok_or("native revision output has no installed binding")?, + group: BTreeMap::new(), + start_ms: window.0, + end_ms: window.1, + payload, + }); + continue; + } let (_, mut key) = prepare_frozen_maintenance_sink( installed, &plan.precompute_plan.materializations, diff --git a/data_plane/src/precompute_engine/native_precompute.rs b/data_plane/src/precompute_engine/native_precompute.rs index b08825f23..b79989ead 100644 --- a/data_plane/src/precompute_engine/native_precompute.rs +++ b/data_plane/src/precompute_engine/native_precompute.rs @@ -20,7 +20,8 @@ pub(super) fn execute( inputs: &[crate::storage_engines::sketch_db::index::FrozenExactWindows], window: (u64, u64), max_bytes: usize, -) -> Result { + revision: u64, +) -> Result> { let mut sources = BTreeMap::new(); let mut input_bytes = 0usize; for (id, contract) in program.input_contracts() { @@ -76,59 +77,49 @@ pub(super) fn execute( .collect::, String>>()?; rows.push(row); } - let batch = Batch::try_new(contract.schema.clone(), rows).map_err(|e| e.to_string())?; + let batch = Batch::try_new(contract.schema.clone(), rows)?; input_bytes = input_bytes .checked_add(batch.bytes()) .ok_or("native input size overflow")?; if input_bytes > max_bytes { - return Err("native precompute input exceeds run budget".into()); + return Err(asap_physical_operators::Error::MemoryLimit.into()); } sources.insert( id, - Box::new( - Operator::source(contract.schema.clone(), vec![batch]) - .map_err(|e| e.to_string())?, - ) as Source<'_>, + Box::new(Operator::source(contract.schema.clone(), vec![batch])?) as Source<'_>, ); } - let graph = program.instantiate(sources).map_err(|e| e.to_string())?; + let graph = program.instantiate(sources)?; let context = RunContext::new( Scope::Ingestion { window_start_ms: i64::try_from(window.0).map_err(|_| "native window overflow")?, window_end_ms: i64::try_from(window.1).map_err(|_| "native window overflow")?, - revision: 0, + revision, }, Limits { max_bytes, ..Limits::default() }, - ) - .map_err(|e| e.to_string())?; + )?; // Source buffers and retained publication output share the operator budget. - let _inputs = context.reserve(input_bytes).map_err(|e| e.to_string())?; - let mut retained = context.reserve(0).map_err(|e| e.to_string())?; - let schema = program - .output_contract(program.roots()[0]) - .map_err(|e| e.to_string())? - .schema; - let mut stream = graph - .execute(program.roots(), context) - .map_err(|e| e.to_string())? - .remove(0); + let _inputs = context.reserve(input_bytes)?; + let mut retained = context.reserve(0)?; + let schema = program.output_contract(program.roots()[0])?.schema; + let mut stream = graph.execute(program.roots(), context)?.remove(0); block_on(async { let mut rows = Vec::new(); let mut bytes = 0usize; while let Some(batch) = stream.next().await { - let batch = batch.map_err(|e| e.to_string())?; + let batch = batch?; bytes = bytes .checked_add(batch.bytes()) .ok_or("native output size overflow")?; if bytes > max_bytes { - return Err("native precompute output exceeds publication budget".into()); + return Err(asap_physical_operators::Error::MemoryLimit.into()); } - retained.resize(bytes).map_err(|e| e.to_string())?; + retained.resize(bytes)?; rows.extend(batch.rows().iter().cloned()); } - Batch::try_new(schema, rows).map_err(|e| e.to_string()) + Batch::try_new(schema, rows).map_err(Into::into) }) } diff --git a/data_plane/src/precompute_engine/revisions.rs b/data_plane/src/precompute_engine/revisions.rs index 02a624622..383f642da 100644 --- a/data_plane/src/precompute_engine/revisions.rs +++ b/data_plane/src/precompute_engine/revisions.rs @@ -473,6 +473,50 @@ pub(crate) fn decode_state( } } +/// Decode the complete persisted output schema selected by Planner. A native +/// batch may carry many groups; it is not a scalar accumulator record. +pub(crate) fn decode_native_record( + plan: &RuntimePhysicalPlan, + record: &RevisionRecord, + max_bytes: usize, +) -> Result, RevisionError> { + use asap_types::executable_plan::BackendNodeBinding; + let mut expected = None; + for installed in plan.precompute_plan.executable_dags.values() { + for sink in installed.native_programs.keys() { + if !matches!(installed.binding.node(*sink), Some(BackendNodeBinding::Materialization { stored_output }) if *stored_output == record.reference.stored_output_id) + { + continue; + } + let program = installed + .native_program(*sink)? + .ok_or("native output program missing")?; + let schema = program.output_contract(u64::from(sink.0))?.schema; + if expected + .as_ref() + .is_some_and(|previous| previous != &schema) + { + return Err("shared native output has conflicting schemas".into()); + } + expected = Some(schema); + } + } + let Some(schema) = expected else { + return Ok(None); + }; + if !record.group.is_empty() { + return Err("native revision must contain the whole grouped output".into()); + } + if record.payload.len() > max_bytes { + return Err(asap_physical_operators::Error::MemoryLimit.into()); + } + let batch = native::decode_batch(&record.payload, schema, max_bytes)?; + if batch.bytes() > max_bytes { + return Err(asap_physical_operators::Error::MemoryLimit.into()); + } + Ok(Some(batch)) +} + /// A single local owner serializes input revisions for the installed plan. /// Opening is lazy so activation/recovery does not depend on receiving new data. pub struct RevisionRuntime { @@ -579,7 +623,11 @@ impl RevisionRuntime { .into(), ); } - decode_state(record, config)?; + if decode_native_record(&plan, record, self.policy.max_checkpoint_bytes)? + .is_none() + { + decode_state(record, config)?; + } } } } diff --git a/data_plane/src/storage_engines/sketch_db/index/mod.rs b/data_plane/src/storage_engines/sketch_db/index/mod.rs index 984eb1fe6..3c0ed9c27 100644 --- a/data_plane/src/storage_engines/sketch_db/index/mod.rs +++ b/data_plane/src/storage_engines/sketch_db/index/mod.rs @@ -613,6 +613,9 @@ impl Drop for StateMutation<'_> { #[derive(Default)] pub struct SketchStore { + /// Only populated in an immutable, query-wide revision view. + native_revision_batches: + Option>, pub(crate) revisions: RwLock>>, pub current_series: std::sync::Mutex, /// Held through each state append; completion takes the exclusive guard. @@ -2750,7 +2753,9 @@ impl SketchStore { /// surface; it is O(N sids) and meant for the 30 s diagnostic tick, not /// the hot path. pub fn approx_resident_bytes(&self) -> usize { - let mut total = 0usize; + let mut total = self.native_revision_batches.as_ref().map_or(0, |batches| { + batches.values().map(|batch| batch.bytes()).sum::() + }); // 1. SeriesId bindings, compatibility metadata, and shared SDS descriptors. if let Ok(insts) = self.instances.read() { @@ -3843,7 +3848,9 @@ impl crate::storage_engines::sketch_db::index::persistence::EpochSource for Sket // over. The hot-window seal (`seal_aged_epochs`) handles the // common case; this keeps the memory-pressure backstop honest for // a burst that outruns the hot window. - let mut total = 0usize; + let mut total = self.native_revision_batches.as_ref().map_or(0, |batches| { + batches.values().map(|batch| batch.bytes()).sum() + }); for entry in self.series.iter() { let Ok(data) = entry.value().read() else { continue; @@ -6835,7 +6842,8 @@ impl SketchStore { pinned: &crate::precompute_engine::revisions::PinnedRevision, ) -> Result { use crate::storage_engines::types::{KeyByLabelValues, PrecomputedOutput}; - let view = Self::new(); + let mut view = Self::new(); + view.native_revision_batches = Some(BTreeMap::new()); view.install_precompute_plan( plan.summary_catalog .clone() @@ -6858,6 +6866,25 @@ impl SketchStore { if expected != record.reference { return Err("revision view semantic binding mismatch".into()); } + if let Some(batch) = + crate::precompute_engine::revisions::decode_native_record(plan, record, usize::MAX)? + { + let key = ( + definition, + i64::try_from(record.start_ms)?, + i64::try_from(record.end_ms)?, + ); + if view + .native_revision_batches + .as_mut() + .unwrap() + .insert(key, batch) + .is_some() + { + return Err("duplicate native record in pinned revision".into()); + } + continue; + } let config = plan .precompute_plan .materializations diff --git a/data_plane/src/storage_engines/sketch_db/index/native.rs b/data_plane/src/storage_engines/sketch_db/index/native.rs index 9bff8eef8..397bb3e63 100644 --- a/data_plane/src/storage_engines/sketch_db/index/native.rs +++ b/data_plane/src/storage_engines/sketch_db/index/native.rs @@ -292,6 +292,37 @@ impl SketchStore { if !address.population.is_empty() { return Err("native batch binding requires the complete stored output".into()); } + if let Some(batches) = &self.native_revision_batches { + address.validate().map_err(|e| e.to_string())?; + let generation = self + .active_catalog_generation() + .ok_or("native revision has no generation")?; + if (address.plan_id, address.plan_version) + != (generation.plan_id, generation.plan_version) + || address.stored_output_id != reference.stored_output_id + || self + .descriptors + .stored_output_reference(address.stored_output_id) + .as_ref() + != Some(reference) + { + return Err("native revision differs from installed binding".into()); + } + let batch = batches + .get(&( + address.stored_output_id, + address.window.start_ms, + address.window.end_ms, + )) + .ok_or("native output is absent from pinned revision")?; + if batch.schema() != &expected_schema { + return Err("native revision schema differs from installed contract".into()); + } + if batch.bytes() > max_bytes { + return Err(asap_physical_operators::Error::MemoryLimit.into()); + } + return Ok(batch.clone()); + } let handles = self.storage_handles_for_output(reference); let [sid] = handles.as_slice() else { return Err("native stored output is absent or ambiguous".into()); diff --git a/data_plane/tests/asapquery_compatibility_process_e2e.rs b/data_plane/tests/asapquery_compatibility_process_e2e.rs index b18ad599a..1c22861f2 100644 --- a/data_plane/tests/asapquery_compatibility_process_e2e.rs +++ b/data_plane/tests/asapquery_compatibility_process_e2e.rs @@ -27,6 +27,8 @@ mod distinct_planning_process; mod durable_summary_process; #[path = "support/immutable_maintenance_process.rs"] mod immutable_maintenance_process; +#[path = "support/native_revision_process.rs"] +mod native_revision_process; #[path = "support/revisable_maintenance_process.rs"] mod revisable_maintenance_process; @@ -212,7 +214,13 @@ fn unused_port() -> u16 { } async fn wait_until_ready(client: &reqwest::Client, url: &str, child: &mut Child) { - for _ in 0..120 { + let scale = std::env::var("ASAP_TEST_TIMEOUT_SCALE") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(1) + .max(1); + let deadline = tokio::time::Instant::now() + Duration::from_secs(30 * scale); + while tokio::time::Instant::now() < deadline { if let Some(status) = child.try_wait().expect("inspect backend process") { panic!("backend exited before readiness: {status}"); } diff --git a/data_plane/tests/support/issue_701_702_process.rs b/data_plane/tests/support/issue_701_702_process.rs index 533b0d4c4..c739cc228 100644 --- a/data_plane/tests/support/issue_701_702_process.rs +++ b/data_plane/tests/support/issue_701_702_process.rs @@ -346,10 +346,15 @@ fn issue_701_702_uncertified_ratios_require_exact_fallback() { let candidates = workload_cost::enumerate_exact_and_materialized_candidates(request).unwrap(); assert!(!candidates.is_empty()); + let mut exact_count = 0; for candidate in candidates { - let plan = DeploymentPlanCompiler - .compile_promql(candidate, environment.clone()) - .unwrap(); + let plan = match DeploymentPlanCompiler.compile_promql(candidate, environment.clone()) { + Ok(plan) => plan, + Err(control_plane::physical::compiler::CompileError::Query { reason, .. }) + if reason == "selected query result has no certified accuracy guarantee satisfying the requested accuracy" => continue, + Err(error) => panic!("unexpected candidate rejection for {query}: {error}"), + }; + exact_count += 1; assert!(plan.precompute_plan.materializations.is_empty(), "{query}"); assert!( plan.query_plan.entries.values().all(|entry| matches!( @@ -359,6 +364,7 @@ fn issue_701_702_uncertified_ratios_require_exact_fallback() { "{query}" ); } + assert!(exact_count > 0, "no executable exact candidate for {query}"); } } diff --git a/data_plane/tests/support/native_revision_process.rs b/data_plane/tests/support/native_revision_process.rs new file mode 100644 index 000000000..4bbd5ab8b --- /dev/null +++ b/data_plane/tests/support/native_revision_process.rs @@ -0,0 +1,257 @@ +use super::*; + +// One deployed query ensemble reads shared counters. Adding a late series must +// revise both the persisted grouped sum and the query-time ranking, including +// after restart, without mixing snapshots or falling back to an external engine. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn native_rate_ensemble_revises_and_recovers() { + run_native_ensemble(None).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn native_cms_rate_ensemble_revises_and_recovers() { + run_native_ensemble(Some("CmsWithHeap")).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn native_count_sketch_rate_ensemble_revises_and_recovers() { + run_native_ensemble(Some("CountSketchWithHeap")).await; +} + +async fn run_native_ensemble(family: Option<&str>) { + use control_plane::physical::{ + compiler::{ + BackendLocalPlanningInput, DeploymentPlanCompiler, BACKEND_REVISION, PLANNER_REVISION, + }, + workload_cost::{ + enumerate_exact_and_materialized_candidates, manifest, WorkloadCostEvidence, + WorkloadQuote, + }, + }; + let queries = [ + "sum by (job) (rate(native_revision_counter[1m]))", + "topk by (job) (1, rate(native_revision_counter[1m]))", + ]; + let mut fixture: Value = serde_json::from_str(include_str!( + "../../../docs/examples/asapquery-compatibility-demo-snapshot.json" + )) + .unwrap(); + let template = fixture["query_workload"]["repeating_queries"][3].clone(); + fixture["query_workload"]["repeating_queries"] = Value::Array( + queries + .iter() + .map(|query| { + let mut entry = template.clone(); + entry["query"] = (*query).into(); + entry["demand"]["fixed_interval_at"]["interval"] = 60_000.into(); + entry["requirements"]["accuracy"] = serde_json::json!({"explicit":"Exact"}); + entry + }) + .collect(), + ); + fixture["implementation"]["data_snapshot_id"] = "native-revision-ensemble".into(); + if family.is_some() { + fixture["query_workload"]["repeating_queries"][1]["requirements"]["accuracy"] = + serde_json::json!({"explicit":{"EpsilonDelta":{"epsilon":0.1,"delta":0.1}}}); + fixture["implementation"]["accuracy_evidence"] = serde_json::json!({queries[1]: { + "query_string": queries[1], "data_snapshot_id":"native-revision-ensemble", + "data_workload": fixture["data_workload"], "source":"enforced-fixture-contract", + "observed_at_unix_ms":9500, "valid_for_ms":60000, + "topk_max_distinct_items":1000, + "topk_selected_lower_bound":3.9, "topk_excluded_upper_bound":1.1, + "topk_interval_failure_probability":0.001 + }}); + } + let mut snapshot: BackendLocalPlanningInput = serde_json::from_value(fixture).unwrap(); + let (request, environment) = snapshot + .clone() + .into_physical_compilation_request() + .unwrap(); + let preferred = |plan: &control_plane::physical::compiler::CompiledPhysicalPlan| { + plan.precompute_plan.executable_dags.values().any(|dag| { + dag.native_programs + .values() + .any(|program| family.is_none_or(|family| program.to_string().contains(family))) + }) && plan.query_plan.entries.values().all(|entry| { + !entry.nodes.values().any(|node| { + matches!( + node, + asap_types::query_plan::QueryPlanNode::ExactFallback { .. } + | asap_types::query_plan::QueryPlanNode::ExternalExact { .. } + ) + }) + }) + }; + let mut eligible = 0; + let quotes = enumerate_exact_and_materialized_candidates(request) + .unwrap() + .into_iter() + .filter_map(|candidate| { + let plan = DeploymentPlanCompiler + .compile_promql(candidate.clone(), environment.clone()) + .ok()?; + let select = preferred(&plan); + if select { + eligible += 1; + } + let manifest = manifest(&plan, &candidate.queries).unwrap(); + Some(WorkloadQuote { + unit_costs: manifest + .components + .keys() + .map(|key| (key.clone(), if select { 1.0 } else { 1e12 })) + .collect(), + manifest, + executable: true, + }) + }) + .collect(); + assert!( + eligible > 0, + "ensemble has no feasible native precompute candidate" + ); + snapshot.workload_cost_evidence = Some(WorkloadCostEvidence { + backend_revision: BACKEND_REVISION.into(), + planner_revision: PLANNER_REVISION.into(), + data_snapshot_id: "native-revision-ensemble".into(), + model_version: "native-ensemble-fixture-cost".into(), + observed_at_unix_ms: 10000, + valid_for_ms: 60000, + quotes, + }); + let plan = snapshot.compile_promql().unwrap(); + assert!( + preferred(&plan), + "synthetic costs must select the native ensemble" + ); + assert_eq!(plan.query_plan.entries.len(), 2); + let installed = data_plane::drivers::query::servers::http::PhysicalPlanInstallRequest { + summary_catalog: plan.summary_catalog, + collector_plans: plan.collector_plans, + precompute_plan: plan.precompute_plan, + transmission_plan: plan.transmission_plan, + query_plan: plan.query_plan, + storage_routing: None, + adaptation_evidence: vec![], + }; + let directory = tempfile::tempdir().unwrap(); + let artifact = directory.path().join("plan.json"); + std::fs::write(&artifact, serde_json::to_vec(&installed).unwrap()).unwrap(); + let spawn = |port: u16| { + ChildGuard( + Command::new(env!("CARGO_BIN_EXE_data_plane")) + .arg("--physical-plan") + .arg(&artifact) + .arg("--http-port") + .arg(port.to_string()) + .arg("--output-dir") + .arg(directory.path()) + .arg("--enable-remote-write") + .arg("--remote-write-revision-dir") + .arg(directory.path().join("revisions")) + .args([ + "--remote-write-correction-horizon-ms", + "300000", + "--remote-write-revision-freshness-ms", + "60000", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(), + ) + }; + let client = reqwest::Client::new(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + let start = (now / 60_000 - 1) * 60_000; + let end = start + 60_000; + let request = |instance: &str, last: f64| WriteRequest { + timeseries: vec![series_with_labels( + "native_revision_counter", + &[("instance", instance), ("job", "worker")], + &[(start + 1000, 100.), (end - 1000, last)], + )], + }; + let port = unused_port(); + let mut child = spawn(port); + let backend = format!("http://127.0.0.1:{port}"); + wait_until_ready(&client, &format!("{backend}/api/v1/health"), &mut child.0).await; + let (first, second, expected, winner) = if family.is_some() { + (332., 158., [5., 4.], "a") + } else { + (158., 216., [3., 2.], "b") + }; + assert_eq!( + remote_write(&client, &backend, &request("a", first)).await, + 204 + ); + assert_ensemble( + &client, + &backend, + &queries, + end, + if family.is_some() { [4., 4.] } else { [1., 1.] }, + "a", + ) + .await; + assert_eq!( + remote_write(&client, &backend, &request("b", second)).await, + 204 + ); + assert_ensemble(&client, &backend, &queries, end, expected, winner).await; + // Replay is idempotent; the newly admitted series must not be counted twice. + assert_eq!( + remote_write(&client, &backend, &request("b", second)).await, + 204 + ); + assert_ensemble(&client, &backend, &queries, end, expected, winner).await; + drop(child); + let port = unused_port(); + let mut child = spawn(port); + let backend = format!("http://127.0.0.1:{port}"); + wait_until_ready(&client, &format!("{backend}/api/v1/health"), &mut child.0).await; + assert_ensemble(&client, &backend, &queries, end, expected, winner).await; +} + +async fn assert_ensemble( + client: &reqwest::Client, + backend: &str, + queries: &[&str; 2], + end: i64, + expected: [f64; 2], + winner: &str, +) { + for (index, query) in queries.iter().enumerate() { + let response = client + .get(format!("{backend}/api/v1/query")) + .query(&[ + ("query", query.to_string()), + ("time", (end / 1000).to_string()), + ]) + .send() + .await + .unwrap(); + let status = response.status(); + let body: Value = response.json().await.unwrap(); + assert!(status.is_success() && is_warm(&body), "{query}: {body}"); + assert_eq!( + body["data"]["result"].as_array().unwrap().len(), + 1, + "{body}" + ); + let point = &body["data"]["result"][0]; + let value: f64 = point["value"][1].as_str().unwrap().parse().unwrap(); + assert!( + (value - expected[index]).abs() < 1e-9, + "{query}: expected {}, got {body}", + expected[index] + ); + assert_eq!(point["metric"]["job"], "worker"); + if index == 1 { + assert_eq!(point["metric"]["instance"], winner); + } + } +} From 83f7a2925850c3360cb39ee7bdcf30c9b0d9f52f Mon Sep 17 00:00:00 2001 From: zzylol Date: Tue, 29 Sep 2026 12:55:45 +0000 Subject: [PATCH 05/19] Remove uncompiled relational plan formats and execute native aggregates --- control_plane/src/physical/plan_dot.rs | 6 +- control_plane/src/query_plan.rs | 132 +++++------ .../src/query_plan/clickhouse_exact.rs | 10 +- crates/asap_types/src/query_plan.rs | 213 +++++------------- .../accelerator.rs | 146 ++++++++---- .../asap_query_engine/exact_subqueries.rs | 30 +-- .../asap_query_engine/logical_dag.rs | 82 ++++--- .../logical_dag/native_values.rs | 105 ++++++++- .../asap_query_engine/post_asap_readout.rs | 4 +- .../tests/clickhouse_differential_e2e.rs | 23 +- 10 files changed, 389 insertions(+), 362 deletions(-) diff --git a/control_plane/src/physical/plan_dot.rs b/control_plane/src/physical/plan_dot.rs index 1965c761c..9eb57d8f8 100644 --- a/control_plane/src/physical/plan_dot.rs +++ b/control_plane/src/physical/plan_dot.rs @@ -197,11 +197,7 @@ fn query_node_label(node: &QueryPlanNode) -> String { }) .unwrap_or_else(|_| "Invalid physical DAG".into()) } - QueryPlanNode::RelationalJoin { .. } => "RelationalJoin".into(), - QueryPlanNode::Relational { .. } => "Relational".into(), - QueryPlanNode::Logical { operator, .. } => { - format!("Logical\n{}", query_time_label(operator)) - } + QueryPlanNode::Logical { operator, .. } => format!("Logical\n{}", query_time_label(operator)), QueryPlanNode::Scalar { value } => format!("Scalar\n{value}"), QueryPlanNode::Binary { operator, .. } => format!("Binary\n{operator:?}"), QueryPlanNode::ReduceSum { .. } => "ReduceSum".into(), diff --git a/control_plane/src/query_plan.rs b/control_plane/src/query_plan.rs index 2d06a3cb6..319d36803 100644 --- a/control_plane/src/query_plan.rs +++ b/control_plane/src/query_plan.rs @@ -177,9 +177,8 @@ fn compile_native_fragment( let physical = compile(&dag, contracts, &[u64::from(dag.root.0)]).map_err(|e| invalid(e.to_string()))?; let row_input = physical - .input_contracts() - .position(|(id, _)| bindings[&id] == query_inputs[0]) - .unwrap(); + .row_source(physical.roots()[0]) + .and_then(|source| physical.input_contracts().position(|(id, _)| id == source)); let pruning = if let SummaryExpr::RelationalJoin { left, right, @@ -352,16 +351,14 @@ where *input = remap[input]; } } - QueryPlanNode::Binary { inputs, .. } - | QueryPlanNode::RelationalJoin { inputs, .. } => { + QueryPlanNode::Binary { inputs, .. } => { for input in inputs { *input = remap[input]; } } QueryPlanNode::SummaryEstimate { input, .. } | QueryPlanNode::ExactReadout { input, .. } - | QueryPlanNode::ReduceSum { input, .. } - | QueryPlanNode::Relational { input, .. } => *input = remap[input], + | QueryPlanNode::ReduceSum { input, .. } => *input = remap[input], QueryPlanNode::Scalar { .. } | QueryPlanNode::ReadMaterialization { .. } | QueryPlanNode::ExactFallback { .. } => {} @@ -447,60 +444,13 @@ where operation: planner_types::post_asap::ValueOperation::Exact( planner_types::post_asap::ExactOperation::Aggregate { - reduction, - measures, - having: None, - .. + having: None, .. }, ), timing: planner_types::post_asap::ExecutionTiming::QueryTime, - } if measures.len() == 1 => { - use planner_types::pre_asap::AggIntent; - let operation = match &measures[0] { - AggIntent::Sum { .. } => Some(query_time::Aggregation::Sum), - AggIntent::Count { .. } => Some(query_time::Aggregation::Count), - AggIntent::Min { .. } => Some(query_time::Aggregation::Min), - AggIntent::Max { .. } => Some(query_time::Aggregation::Max), - AggIntent::Avg { .. } => Some(query_time::Aggregation::Avg), - _ => { - return Err(QueryPlanError::Invalid( - "unsupported exact value aggregation".into(), - )) - } - }; - let keys = reduction.group_keys().ok_or_else(|| { - QueryPlanError::Invalid( - "per-entity exact value aggregation has no grouping".into(), - ) - })?; - let labels = keys - .keys() - .iter() - .map(|&column| { - child - .schema - .fields - .get(column) - .map(|field| field.name.clone()) - .ok_or_else(|| { - QueryPlanError::Invalid( - "unresolved exact aggregation column".into(), - ) - }) - }) - .collect::, _>>()?; - let grouping = query_time::Grouping { - labels, - without: keys.is_without(), - }; - let operator = query_time::QueryTimeOperator::Aggregate { - operation: operation.expect("aggregate operation"), - grouping, - }; - QueryPlanNode::Logical { - operator, - inputs: vec![self.lower(child)?], - } + } => { + let input = self.lower(child)?; + compile_native_fragment(node, &[input])? } SummaryExpr::ValueOperation { child, @@ -1463,7 +1413,7 @@ mod tests { QueryPlanNode::PhysicalFragment { inputs: vec![QueryNodeId(0)], dag: compiled.encode().unwrap(), - row_input: 0, + row_input: Some(0), pruning: None, }, ), @@ -1544,10 +1494,14 @@ mod tests { }], time_index: None, }; - QueryPlanNode::RelationalJoin { - inputs: [QueryNodeId(1), QueryNodeId(0)], - join_kind: planner_types::pre_asap::JoinKind::Semi, - pred: serde_json::to_value(planner_types::pre_asap::Predicate( + { + let schemas = vec![ + std::sync::Arc::new(schema.clone()), + std::sync::Arc::new(schema.clone()), + ]; + let node = planner_types::post_asap::ExecutableDagNode { + id: planner_types::post_asap::PostAsapNodeId(2), + payload: planner_types::post_asap::ExecutableOperatorPayload::RelationalJoin { join_kind: planner_types::pre_asap::JoinKind::Semi, pred: serde_json::from_value(serde_json::to_value(planner_types::pre_asap::Predicate( std::rc::Rc::new(planner_types::pre_asap::QueryExpr::Compare { left: std::rc::Rc::new(planner_types::pre_asap::QueryExpr::Column( 0, @@ -1558,23 +1512,43 @@ mod tests { ), }), )) - .unwrap(), - pruning: Some(CandidateCompleteness::Certified { - guarantee: planner_types::post_asap::ResultGuarantee { - metric: planner_types::post_asap::ErrorMetric::Frequency, - bound: planner_types::post_asap::BoundExpr::Unknown { - statistic: "membership margin".into(), - }, - failure_probability: - planner_types::post_asap::ProbabilityExpr::Unknown { - statistic: "membership confidence".into(), + .unwrap()).unwrap(), pruning: None }, + output_state: planner_types::post_asap::ExecutionDataState::QUERY_ROWS, + output_schema: schema, guarantee: None, + }; + let operator = asap_physical_operators::physical_planner::compile_node( + &node, &schemas, + ) + .unwrap(); + let compiled = asap_physical_operators::physical_planner::CompiledPhysicalDag::from_operators( + schemas.into_iter().enumerate().map(|(id, schema)| (id as u64, asap_physical_operators::physical_planner::InputContract::bounded(schema))).collect(), + [(2, ((0..2).collect(), operator))].into(), vec![2], + ).unwrap(); + QueryPlanNode::PhysicalFragment { + inputs: [QueryNodeId(1), QueryNodeId(0)].to_vec(), + dag: compiled.encode().unwrap(), + row_input: Some(0), + pruning: (Some(CandidateCompleteness::Certified { + guarantee: planner_types::post_asap::ResultGuarantee { + metric: planner_types::post_asap::ErrorMetric::Frequency, + bound: planner_types::post_asap::BoundExpr::Unknown { + statistic: "membership margin".into(), }, - provenance: vec![], - }, - }), - left_schema: schema.clone(), - right_schema: schema.clone(), - output_schema: schema, + failure_probability: + planner_types::post_asap::ProbabilityExpr::Unknown { + statistic: "membership confidence".into(), + }, + provenance: vec![], + }, + })) + .map(|completeness| { + asap_types::query_plan::PruningInputContract { + candidate_input: 1, + keys: vec![(0, 0)], + completeness, + } + }), + } } }), ]), diff --git a/control_plane/src/query_plan/clickhouse_exact.rs b/control_plane/src/query_plan/clickhouse_exact.rs index 3b8451d1a..6d0538c0b 100644 --- a/control_plane/src/query_plan/clickhouse_exact.rs +++ b/control_plane/src/query_plan/clickhouse_exact.rs @@ -346,12 +346,10 @@ mod original_tests { ) .unwrap(); assert!( - !entry.nodes.values().any(|node| matches!( - node, - QueryPlanNode::Logical { .. } - | QueryPlanNode::Relational { .. } - | QueryPlanNode::RelationalJoin { .. } - )), + !entry + .nodes + .values() + .any(|node| matches!(node, QueryPlanNode::Logical { .. })), "SQL installation must not persist logical or uncompiled relational operators" ); let QueryPlanNode::PhysicalRelation { dag, .. } = &entry.nodes[&entry.root] else { diff --git a/crates/asap_types/src/query_plan.rs b/crates/asap_types/src/query_plan.rs index cc3b0b3a3..278aa9391 100644 --- a/crates/asap_types/src/query_plan.rs +++ b/crates/asap_types/src/query_plan.rs @@ -436,15 +436,6 @@ impl QueryPlanEntry { )); } for (id, node) in &self.nodes { - if self.language == QueryLanguage::ClickHouseSql - && matches!( - node, - QueryPlanNode::Relational { .. } | QueryPlanNode::RelationalJoin { .. } - ) - { - return Err(QueryPlanError::Invalid("SQL plan contains uncompiled relation semantics; recompile the deployment plan".into())); - } - if let QueryPlanNode::PhysicalRelation { inputs, dag } = node { if self.language != QueryLanguage::ClickHouseSql { return Err(QueryPlanError::Invalid( @@ -516,15 +507,54 @@ impl QueryPlanEntry { )); } } - let source = compiled.input_contracts().nth(*row_input).map(|(id, _)| id); - if compiled.roots().len() != 1 - || source.is_none() - || compiled.row_source(compiled.roots()[0]) != source - { + if compiled.roots().len() != 1 { return Err(QueryPlanError::Invalid( - "physical vector output must preserve its bound input rows".into(), + "physical vector requires one root".into(), )); } + if let Some(row_input) = row_input { + let source = compiled.input_contracts().nth(*row_input).map(|(id, _)| id); + if source.is_none() || compiled.row_source(compiled.roots()[0]) != source { + return Err(QueryPlanError::Invalid( + "physical vector output must preserve its bound input rows".into(), + )); + } + } else { + let output = compiled + .output_contract(compiled.roots()[0]) + .map_err(|e| QueryPlanError::Invalid(e.to_string()))?; + let numeric = output + .schema + .fields + .iter() + .filter(|field| { + matches!( + field.dtype, + planner_types::post_asap::SummaryFamilyType::Plain( + planner_types::pre_asap::DataType::Float64 + | planner_types::pre_asap::DataType::Int64 + ) + ) + }) + .count(); + if numeric != 1 + || output.schema.fields.iter().any(|field| { + !matches!( + field.dtype, + planner_types::post_asap::SummaryFamilyType::Plain( + planner_types::pre_asap::DataType::Float64 + | planner_types::pre_asap::DataType::Int64 + | planner_types::pre_asap::DataType::Utf8 + | planner_types::pre_asap::DataType::Timestamp + ) + ) + }) + { + return Err(QueryPlanError::Invalid( + "physical output cannot bind to a PromQL vector".into(), + )); + } + } if let Some(pruning) = pruning { if matches!(&pruning.completeness, CandidateCompleteness::Certified { guarantee } if guarantee.metric != planner_types::post_asap::ErrorMetric::TopKMembership || guarantee.bound.evaluate().is_none() || guarantee.failure_probability.evaluate().is_none()) @@ -535,7 +565,9 @@ impl QueryPlanEntry { } let contracts = compiled.input_contracts().collect::>(); let left = contracts - .get(*row_input) + .get(row_input.ok_or_else(|| { + QueryPlanError::Invalid("pruning requires preserved input rows".into()) + })?) .ok_or_else(|| QueryPlanError::Invalid("invalid row input".into()))? .1; let right = contracts @@ -551,7 +583,7 @@ impl QueryPlanEntry { } if compiled.input_contracts().count() != inputs.len() || compiled.roots().len() != 1 - || *row_input >= inputs.len() + || row_input.is_some_and(|index| index >= inputs.len()) { return Err(QueryPlanError::Invalid( "physical input/root binding mismatch".into(), @@ -584,30 +616,7 @@ impl QueryPlanEntry { )); } } - if let QueryPlanNode::RelationalJoin { - pruning: Some(completeness), - join_kind, - .. - } = node - { - if *join_kind != planner_types::pre_asap::JoinKind::Semi { - return Err(QueryPlanError::Invalid( - "pruning evidence requires a semi-join".into(), - )); - } - if matches!( - completeness, - CandidateCompleteness::Certified { guarantee } - if guarantee.metric - != planner_types::post_asap::ErrorMetric::TopKMembership - || guarantee.bound.evaluate().is_none() - || guarantee.failure_probability.evaluate().is_none() - ) { - return Err(QueryPlanError::Invalid( - "invalid semi-join pruning certificate".into(), - )); - } - } + for input in node.inputs() { if !self.nodes.contains_key(input) { return Err(QueryPlanError::Invalid(format!( @@ -780,27 +789,10 @@ pub enum QueryPlanNode { PhysicalFragment { inputs: Vec, dag: Vec, - row_input: usize, + row_input: Option, pruning: Option, }, - RelationalJoin { - inputs: [QueryNodeId; 2], - join_kind: planner_types::pre_asap::JoinKind, - pruning: Option, - pred: serde_json::Value, - left_schema: planner_types::post_asap::SummarySchema, - right_schema: planner_types::post_asap::SummarySchema, - output_schema: planner_types::post_asap::SummarySchema, - }, - Relational { - input: QueryNodeId, - /// Serialized planner-owned operation. Keeping the wire form here makes - /// the published catalog Send + Sync even though the planner AST uses Rc. - operation: serde_json::Value, - input_schema: planner_types::post_asap::SummarySchema, - output_schema: planner_types::post_asap::SummarySchema, - }, Logical { operator: query_time::QueryTimeOperator, inputs: Vec, @@ -847,9 +839,8 @@ impl QueryPlanNode { Self::Scalar { .. } | Self::ReadMaterialization { .. } | Self::ExactFallback { .. } => { &[] } - Self::Binary { inputs, .. } | Self::RelationalJoin { inputs, .. } => inputs, + Self::Binary { inputs, .. } => inputs, Self::ReduceSum { input, .. } - | Self::Relational { input, .. } | Self::SummaryEstimate { input, .. } | Self::ExactReadout { input, .. } => std::slice::from_ref(input), Self::Physical { inputs, .. } @@ -964,99 +955,15 @@ mod contract_tests { } } -/// Bind portable relation semantics before an installed plan can access its sources. #[cfg(test)] -fn validate_native_relation(id: QueryNodeId, node: &QueryPlanNode) -> Result<(), QueryPlanError> { - use planner_types::post_asap::{ - ExecutableDagNode, ExecutableOperatorPayload as Payload, ExecutionDataState, PostAsapNodeId, - }; - use std::sync::Arc; - let invalid = |error: String| QueryPlanError::Invalid(format!("query node {}: {error}", id.0)); - let (payload, inputs, output) = match node { - QueryPlanNode::Relational { - operation, - input_schema, - output_schema, - .. - } => ( - Payload::Value { - operation: serde_json::from_value(operation.clone()) - .map_err(|e| invalid(e.to_string()))?, - }, - vec![Arc::new(input_schema.clone())], - output_schema, - ), - QueryPlanNode::RelationalJoin { - join_kind, - pred, - left_schema, - right_schema, - output_schema, - pruning, - .. - } => ( - Payload::RelationalJoin { - join_kind: join_kind.clone(), - pred: serde_json::from_value(pred.clone()).map_err(|e| invalid(e.to_string()))?, - pruning: serde_json::from_value( - serde_json::to_value(pruning).map_err(|e| invalid(e.to_string()))?, - ) - .map_err(|e| invalid(e.to_string()))?, - }, - vec![ - Arc::new(left_schema.clone()), - Arc::new(right_schema.clone()), - ], - output_schema, - ), - _ => return Ok(()), - }; - let node = ExecutableDagNode { - id: PostAsapNodeId(0), - payload, - output_state: ExecutionDataState::QUERY_ROWS, - output_schema: output.clone(), - guarantee: None, - }; - asap_physical_operators::physical_planner::compile_node(&node, &inputs) - .map_err(|e| invalid(e.to_string()))?; - Ok(()) -} - -#[cfg(test)] -mod native_binding_tests { - use super::*; - use planner_types::{ - post_asap::{SummaryFamilyType, SummaryField, SummarySchema, ValueOperation}, - pre_asap::{DataType, Predicate, QueryExpr}, - }; - - // Unsupported expressions fail installation without evaluating any source. +mod retired_plan_tests { #[test] - fn rejects_unimplemented_relation_predicate_before_execution() { - let schema = SummarySchema { - fields: vec![SummaryField { - name: "value".into(), - dtype: SummaryFamilyType::Plain(DataType::Float64), - nullable: false, - }], - time_index: None, - }; - let node = QueryPlanNode::Relational { - input: QueryNodeId(0), - operation: serde_json::to_value(ValueOperation::Filter { - pred: Predicate( - QueryExpr::FunctionCall { - name: "unimplemented_predicate".into(), - args: vec![], - } - .into(), - ), - }) - .unwrap(), - input_schema: schema.clone(), - output_schema: schema, - }; - assert!(validate_native_relation(QueryNodeId(1), &node).is_err()); + fn uncompiled_relation_variants_are_not_accepted() { + for kind in ["relational", "relational_join"] { + let error = + serde_json::from_value::(serde_json::json!({"op":kind})) + .unwrap_err(); + assert!(error.to_string().contains("unknown variant"), "{error}"); + } } } diff --git a/data_plane/src/query_engines/asap_clickhouse_query_engine/accelerator.rs b/data_plane/src/query_engines/asap_clickhouse_query_engine/accelerator.rs index b193f7ad7..8f547ce16 100644 --- a/data_plane/src/query_engines/asap_clickhouse_query_engine/accelerator.rs +++ b/data_plane/src/query_engines/asap_clickhouse_query_engine/accelerator.rs @@ -620,49 +620,111 @@ mod tests { }, inputs: vec![], }); - entry.nodes.insert( - join, - QueryPlanNode::RelationalJoin { - inputs: [left, external], - join_kind: planner_types::pre_asap::JoinKind::Inner, - pruning: None, - pred: serde_json::to_value(Predicate(Rc::new(QueryExpr::Compare { - left: Rc::new(QueryExpr::Column(0)), - op: CompareOpKind::Eq, - right: Rc::new(QueryExpr::Column(2)), - }))) - .unwrap(), - left_schema, - right_schema, + entry.nodes.insert(join, { + let schemas = vec![ + std::sync::Arc::new(left_schema), + std::sync::Arc::new(right_schema), + ]; + let node = planner_types::post_asap::ExecutableDagNode { + id: planner_types::post_asap::PostAsapNodeId(2), + payload: planner_types::post_asap::ExecutableOperatorPayload::RelationalJoin { + join_kind: planner_types::pre_asap::JoinKind::Inner, + pred: serde_json::from_value( + serde_json::to_value(Predicate(Rc::new(QueryExpr::Compare { + left: Rc::new(QueryExpr::Column(0)), + op: CompareOpKind::Eq, + right: Rc::new(QueryExpr::Column(2)), + }))) + .unwrap(), + ) + .unwrap(), + pruning: None, + }, + output_state: planner_types::post_asap::ExecutionDataState::QUERY_ROWS, output_schema: joined_schema.clone(), - }, - ); - entry.nodes.insert( - project, - QueryPlanNode::Relational { - input: join, - operation: serde_json::to_value(ValueOperation::Project { - cols: vec![ - ProjectItem { - alias: Some("timestamp".into()), - expr: QueryExpr::Column(0), - }, - ProjectItem { - alias: Some("ratio".into()), - expr: QueryExpr::Arithmetic { - op: ArithmeticOpKind::Div, - left: Rc::new(QueryExpr::Column(1)), - right: Rc::new(QueryExpr::Column(3)), - }, - }, - ], - qualifier: None, - }) - .unwrap(), - input_schema: joined_schema, - output_schema, - }, - ); + guarantee: None, + }; + let operator = + asap_physical_operators::physical_planner::compile_node(&node, &schemas).unwrap(); + let compiled = + asap_physical_operators::physical_planner::CompiledPhysicalDag::from_operators( + schemas + .into_iter() + .enumerate() + .map(|(id, schema)| { + ( + id as u64, + asap_physical_operators::physical_planner::InputContract::bounded( + schema, + ), + ) + }) + .collect(), + [(2, ((0..2).collect(), operator))].into(), + vec![2], + ) + .unwrap(); + QueryPlanNode::PhysicalRelation { + inputs: [left, external].to_vec(), + dag: compiled.encode().unwrap(), + } + }); + entry.nodes.insert(project, { + let schemas = vec![std::sync::Arc::new(joined_schema)]; + let node = planner_types::post_asap::ExecutableDagNode { + id: planner_types::post_asap::PostAsapNodeId(2), + payload: planner_types::post_asap::ExecutableOperatorPayload::Value { + operation: serde_json::from_value( + serde_json::to_value(ValueOperation::Project { + cols: vec![ + ProjectItem { + alias: Some("timestamp".into()), + expr: QueryExpr::Column(0), + }, + ProjectItem { + alias: Some("ratio".into()), + expr: QueryExpr::Arithmetic { + op: ArithmeticOpKind::Div, + left: Rc::new(QueryExpr::Column(1)), + right: Rc::new(QueryExpr::Column(3)), + }, + }, + ], + qualifier: None, + }) + .unwrap(), + ) + .unwrap(), + }, + output_state: planner_types::post_asap::ExecutionDataState::QUERY_ROWS, + output_schema: output_schema, + guarantee: None, + }; + let operator = + asap_physical_operators::physical_planner::compile_node(&node, &schemas).unwrap(); + let compiled = + asap_physical_operators::physical_planner::CompiledPhysicalDag::from_operators( + schemas + .into_iter() + .enumerate() + .map(|(id, schema)| { + ( + id as u64, + asap_physical_operators::physical_planner::InputContract::bounded( + schema, + ), + ) + }) + .collect(), + [(2, ((0..1).collect(), operator))].into(), + vec![2], + ) + .unwrap(); + QueryPlanNode::PhysicalRelation { + inputs: vec![join], + dag: compiled.encode().unwrap(), + } + }); entry.root = project; entry diff --git a/data_plane/src/query_engines/asap_query_engine/exact_subqueries.rs b/data_plane/src/query_engines/asap_query_engine/exact_subqueries.rs index 82b1ad2a9..9d8dd5188 100644 --- a/data_plane/src/query_engines/asap_query_engine/exact_subqueries.rs +++ b/data_plane/src/query_engines/asap_query_engine/exact_subqueries.rs @@ -91,9 +91,7 @@ fn leaves( | QueryPlanNode::Physical { inputs, .. } => { pending.extend(inputs.iter().map(|input| (*input, at))); } - QueryPlanNode::RelationalJoin { inputs, .. } => { - pending.extend(inputs.iter().map(|input| (*input, at))); - } + QueryPlanNode::ExternalExact { request, inputs } => { pending.extend(inputs.iter().map(|input| (*input, at))); result.insert((id, at), ExactLeaf::External(request.clone())); @@ -696,22 +694,28 @@ mod tests { }], time_index: None, }; - QueryPlanNode::RelationalJoin { - inputs: [QueryNodeId(0), QueryNodeId(1)], - join_kind: planner_types::pre_asap::JoinKind::Semi, - pred: serde_json::to_value(planner_types::pre_asap::Predicate(std::rc::Rc::new( + { + let schemas = vec![std::sync::Arc::new(schema.clone()), std::sync::Arc::new(schema.clone())]; + let node = planner_types::post_asap::ExecutableDagNode { + id: planner_types::post_asap::PostAsapNodeId(2), + payload: planner_types::post_asap::ExecutableOperatorPayload::RelationalJoin { join_kind: planner_types::pre_asap::JoinKind::Semi, pred: serde_json::from_value(serde_json::to_value(planner_types::pre_asap::Predicate(std::rc::Rc::new( planner_types::pre_asap::QueryExpr::Compare { left: std::rc::Rc::new(planner_types::pre_asap::QueryExpr::Column(0)), op: planner_types::pre_asap::CompareOpKind::Eq, right: std::rc::Rc::new(planner_types::pre_asap::QueryExpr::Column(1)), }, ))) - .unwrap(), - pruning: Some(CandidateCompleteness::BestEffort { guarantee: None }), - left_schema: schema.clone(), - right_schema: schema.clone(), - output_schema: schema, - } + .unwrap()).unwrap(), pruning: None }, + output_state: planner_types::post_asap::ExecutionDataState::QUERY_ROWS, + output_schema: schema, guarantee: None, + }; + let operator = asap_physical_operators::physical_planner::compile_node(&node, &schemas).unwrap(); + let compiled = asap_physical_operators::physical_planner::CompiledPhysicalDag::from_operators( + schemas.into_iter().enumerate().map(|(id, schema)| (id as u64, asap_physical_operators::physical_planner::InputContract::bounded(schema))).collect(), + [(2, ((0..2).collect(), operator))].into(), vec![2], + ).unwrap(); + QueryPlanNode::PhysicalFragment { inputs: [QueryNodeId(0), QueryNodeId(1)].to_vec(), dag: compiled.encode().unwrap(), row_input: Some(0), pruning: (Some(CandidateCompleteness::BestEffort { guarantee: None })).map(|completeness| asap_types::query_plan::PruningInputContract { candidate_input: 1, keys: vec![(0,0)], completeness }) } + } }); entry.nodes.insert( QueryNodeId(3), diff --git a/data_plane/src/query_engines/asap_query_engine/logical_dag.rs b/data_plane/src/query_engines/asap_query_engine/logical_dag.rs index 19a325d0b..edead745a 100644 --- a/data_plane/src/query_engines/asap_query_engine/logical_dag.rs +++ b/data_plane/src/query_engines/asap_query_engine/logical_dag.rs @@ -333,7 +333,7 @@ impl Result> ValueRuntim native_values::validate_pruning( &dag, &values, - row_input, + row_input.ok_or_else(|| miss("pruning requires preserved input rows"))?, contract, at, context.clone(), @@ -350,35 +350,7 @@ impl Result> ValueRuntim context.clone(), )?) } - QueryPlanNode::RelationalJoin { - join_kind: planner_types::pre_asap::JoinKind::Semi, - pred, - pruning, - left_schema, - right_schema, - output_schema, - .. - } => { - let [values, candidates] = inputs else { - return Err(miss("semi-join requires two inputs")); - }; - let selected = native_values::relation( - vector((**values).clone())?, - vector((**candidates).clone())?, - serde_json::from_value(pred) - .map_err(|error| physical::Error::Invalid(error.to_string()))?, - std::sync::Arc::new(left_schema), - std::sync::Arc::new(right_schema), - std::sync::Arc::new(output_schema), - pruning.clone(), - at, - context.clone(), - )?; - if let Some(warning) = pruning_warning(pruning.as_ref()) { - self.warnings.push(warning); - } - Value::Vector(selected) - } + _ => { self.stats.summary_readout_evaluations += 1; from_result((self.callback)( @@ -601,9 +573,7 @@ fn expanded_inputs(node: &QueryPlanNode, at: i64) -> Result { Ok(inputs.iter().map(|&id| (id, at)).collect()) } - QueryPlanNode::RelationalJoin { inputs, .. } => { - Ok(inputs.iter().map(|&id| (id, at)).collect()) - } + _ => Ok(vec![]), } } @@ -1792,10 +1762,14 @@ mod topk_tests { }], time_index: None, }; - QueryPlanNode::RelationalJoin { - inputs: [value_id, candidate_id], - join_kind: planner_types::pre_asap::JoinKind::Semi, - pred: serde_json::to_value(planner_types::pre_asap::Predicate( + { + let schemas = vec![ + std::sync::Arc::new(schema.clone()), + std::sync::Arc::new(schema.clone()), + ]; + let node = planner_types::post_asap::ExecutableDagNode { + id: planner_types::post_asap::PostAsapNodeId(2), + payload: planner_types::post_asap::ExecutableOperatorPayload::RelationalJoin { join_kind: planner_types::pre_asap::JoinKind::Semi, pred: serde_json::from_value(serde_json::to_value(planner_types::pre_asap::Predicate( std::rc::Rc::new(planner_types::pre_asap::QueryExpr::Compare { left: std::rc::Rc::new(planner_types::pre_asap::QueryExpr::Column( 0, @@ -1806,13 +1780,33 @@ mod topk_tests { ), }), )) - .unwrap(), - pruning: Some(CandidateCompleteness::Certified { - guarantee: topk_membership_guarantee(), - }), - left_schema: schema.clone(), - right_schema: schema.clone(), - output_schema: schema, + .unwrap()).unwrap(), pruning: None }, + output_state: planner_types::post_asap::ExecutionDataState::QUERY_ROWS, + output_schema: schema, guarantee: None, + }; + let operator = asap_physical_operators::physical_planner::compile_node( + &node, &schemas, + ) + .unwrap(); + let compiled = asap_physical_operators::physical_planner::CompiledPhysicalDag::from_operators( + schemas.into_iter().enumerate().map(|(id, schema)| (id as u64, asap_physical_operators::physical_planner::InputContract::bounded(schema))).collect(), + [(2, ((0..2).collect(), operator))].into(), vec![2], + ).unwrap(); + QueryPlanNode::PhysicalFragment { + inputs: [value_id, candidate_id].to_vec(), + dag: compiled.encode().unwrap(), + row_input: Some(0), + pruning: (Some(CandidateCompleteness::Certified { + guarantee: topk_membership_guarantee(), + })) + .map(|completeness| { + asap_types::query_plan::PruningInputContract { + candidate_input: 1, + keys: vec![(0, 0)], + completeness, + } + }), + } } }), ( diff --git a/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs b/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs index c8e7a5f02..d18248c63 100644 --- a/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs +++ b/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs @@ -101,6 +101,7 @@ pub(super) fn limit( output(values, result) } /// Relational boundary used by explicit row plans. Planner owns predicate lowering. +#[cfg(test)] pub(super) fn relation( values: Vector, candidates: Vector, @@ -156,7 +157,7 @@ pub(super) fn relation( if let Some(pruning) = pruning { validate_pruning(&encoded, &inputs, 0, &pruning, at, context.clone())?; } - physical(&encoded, inputs, 0, at, context) + physical(&encoded, inputs, Some(0), at, context) } // Equality keys canonicalize signed zero and NaNs; row transport must preserve their bits. @@ -174,7 +175,7 @@ fn identity_key(value: &Value) -> Result, asap_physical_operators::Error pub(super) fn physical( encoded: &[u8], inputs: Vec, - row_input: usize, + row_input: Option, at: i64, context: dag::RunContext, ) -> Result { @@ -183,7 +184,7 @@ pub(super) fn physical( use std::collections::{BTreeMap, VecDeque}; let compiled = CompiledPhysicalDag::decode(encoded)?; let contracts = compiled.input_contracts().collect::>(); - if contracts.len() != inputs.len() || row_input >= inputs.len() { + if contracts.len() != inputs.len() || row_input.is_some_and(|index| index >= inputs.len()) { return Err(asap_physical_operators::Error::Invalid( "physical input arity mismatch".into(), ) @@ -236,7 +237,7 @@ pub(super) fn physical( .collect::, EngineError>>() }) .collect::, _>>()?; - if position == row_input { + if Some(position) == row_input { for (row, original) in rows.iter().zip(values) { let key = row .iter() @@ -251,6 +252,7 @@ pub(super) fn physical( Box::new(Operator::source(contract.schema.clone(), vec![batch])?) as Source<'_>, ); } + let output_schema = compiled.output_contract(compiled.roots()[0])?.schema; let graph = compiled.instantiate(sources)?; let mut streams = graph.execute(compiled.roots(), context)?; if streams.len() != 1 { @@ -265,6 +267,31 @@ pub(super) fn physical( match stream.next().now_or_never() { Some(Some(batch)) => { for row in batch?.rows() { + if row_input.is_none() { + let mut labels = Labels::new(); + let mut sample = None; + for (field, cell) in output_schema.fields.iter().zip(row) { + match cell { + Value::Utf8(value) => { + labels.insert(field.name.clone(), value.to_string()); + } + Value::Float64(value) => sample = Some(*value), + Value::Int64(value) if value.unsigned_abs() <= (1u64 << 53) => { + sample = Some(*value as f64) + } + Value::Timestamp(_) | Value::Null => {} + _ => return Err(miss( + "native output cannot be represented by the PromQL protocol", + )), + } + } + result.push(( + labels, + sample + .ok_or_else(|| miss("native vector output has no numeric value"))?, + )); + continue; + } let key = row .iter() .map(identity_key) @@ -333,7 +360,7 @@ pub(super) fn validate_pruning( let matched = physical( &check.encode()?, vec![candidates.clone(), values.clone()], - 0, + Some(0), at, context, )?; @@ -493,7 +520,7 @@ mod tests { let error = physical( &sorted(), vec![vec![(Labels::new(), 2.), (Labels::new(), 1.)]], - 0, + Some(0), 42, run.clone(), ) @@ -552,7 +579,7 @@ mod tests { let result = physical( &compiled, vec![vec![(Labels::new(), sample)]], - 0, + Some(0), 42, context(4096), ); @@ -567,13 +594,75 @@ mod tests { } } + // Aggregation changes both rows and labels; decoding must use the declared + // output schema instead of looking up an unchanged input row. + #[test] + fn physical_aggregate_returns_grouped_values_and_labels() { + use asap_physical_operators::{ + operators::Reduction, + physical_planner::{CompiledPhysicalDag, InputContract}, + }; + let input = schema(&[("job", DataType::Utf8), ("value", DataType::Float64)]); + let aggregate = Operator::aggregate( + input.clone(), + vec![0], + vec![("value".into(), Reduction::Sum(1))], + ) + .unwrap(); + let program = CompiledPhysicalDag::from_operators( + [(0, InputContract::bounded(input))].into(), + [(1, (vec![0], aggregate))].into(), + vec![1], + ) + .unwrap(); + let values = vec![ + ( + Labels::from([ + ("job".into(), "api".into()), + ("instance".into(), "a".into()), + ]), + 1., + ), + ( + Labels::from([ + ("job".into(), "api".into()), + ("instance".into(), "b".into()), + ]), + 2., + ), + ( + Labels::from([ + ("job".into(), "worker".into()), + ("instance".into(), "a".into()), + ]), + 4., + ), + ]; + let mut output = physical( + &program.encode().unwrap(), + vec![values], + None, + 42, + context(1 << 20), + ) + .unwrap(); + output.sort_by(|a, b| a.0.cmp(&b.0)); + assert_eq!( + output, + vec![ + (Labels::from([("job".into(), "api".into())]), 3.), + (Labels::from([("job".into(), "worker".into())]), 4.), + ] + ); + } + // Transport identity preserves the exact value selected by native total-order sorting. #[test] fn native_sort_preserves_signed_zero_bits() { let rows = physical( &sorted(), vec![vec![(Labels::new(), 0.), (Labels::new(), -0.)]], - 0, + Some(0), 42, context(4096), ) diff --git a/data_plane/src/query_engines/asap_query_engine/post_asap_readout.rs b/data_plane/src/query_engines/asap_query_engine/post_asap_readout.rs index 01866e310..dbabe845c 100644 --- a/data_plane/src/query_engines/asap_query_engine/post_asap_readout.rs +++ b/data_plane/src/query_engines/asap_query_engine/post_asap_readout.rs @@ -355,10 +355,8 @@ impl PhysicalQueryRuntime<'_> { QueryPlanNode::PhysicalFragment { .. } | QueryPlanNode::Physical { .. } | QueryPlanNode::Logical { .. } - | QueryPlanNode::Relational { .. } | QueryPlanNode::ExternalExact { .. } - | QueryPlanNode::PhysicalRelation { .. } - | QueryPlanNode::RelationalJoin { .. } => Err(PhysicalNodeError::Fallback( + | QueryPlanNode::PhysicalRelation { .. } => Err(PhysicalNodeError::Fallback( "logical node requires installed logical runtime".into(), )), QueryPlanNode::ExactFallback { reason } => { diff --git a/data_plane/tests/clickhouse_differential_e2e.rs b/data_plane/tests/clickhouse_differential_e2e.rs index b9a6523d4..00ecd0bdf 100644 --- a/data_plane/tests/clickhouse_differential_e2e.rs +++ b/data_plane/tests/clickhouse_differential_e2e.rs @@ -634,10 +634,7 @@ async fn moving_windows_match_clickhouse_after_automatic_publication() { #[tokio::test] async fn collection_sql_executes_local_elements_after_typed_exact_leaf() { use asap_types::query_plan::QueryPlanNode; - use planner_types::{ - post_asap::ValueOperation, - pre_asap::{Column, DataType, QueryExpr, Schema}, - }; + use planner_types::pre_asap::{Column, DataType, Schema}; let Ok(clickhouse_url) = std::env::var("CLICKHOUSE_URL") else { eprintln!("skipping collection process E2E because CLICKHOUSE_URL is unset"); return; @@ -711,11 +708,19 @@ async fn collection_sql_executes_local_elements_after_typed_exact_leaf() { .values() .any(|node| matches!(node, QueryPlanNode::ExternalExact { .. }))); for function in ["asap_element_access", "asap_struct_field"] { - assert!(entry.nodes.values().any(|node| { - let QueryPlanNode::Relational { operation, .. } = node else { return false; }; - let ValueOperation::Project { cols, .. } = serde_json::from_value(operation.clone()).unwrap() else { return false; }; - cols.iter().any(|column| matches!(&column.expr, QueryExpr::FunctionCall { name, .. } if name == function)) - }), "Planner-selected DAG must preserve local {function} evaluation"); + assert!( + entry.nodes.values().any(|node| { + let QueryPlanNode::PhysicalRelation { dag, .. } = node else { + return false; + }; + let graph = + asap_physical_operators::physical_planner::CompiledPhysicalDag::decode(dag) + .unwrap(); + assert_eq!(graph.roots().len(), 1); + String::from_utf8(dag.clone()).unwrap().contains(function) + }), + "Planner physical DAG must preserve local {function} evaluation" + ); } eprintln!( "collection Planner selection: {}", From 4f5ce157086819659044522e0926b768538efbd5 Mon Sep 17 00:00:00 2001 From: zzylol Date: Tue, 29 Sep 2026 13:07:17 +0000 Subject: [PATCH 06/19] fix: validate physical row identity and preserve missing grouping labels --- crates/asap_types/src/query_plan.rs | 77 +++++++++++++++++++ .../logical_dag/native_values.rs | 6 +- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/crates/asap_types/src/query_plan.rs b/crates/asap_types/src/query_plan.rs index 278aa9391..1f21d903d 100644 --- a/crates/asap_types/src/query_plan.rs +++ b/crates/asap_types/src/query_plan.rs @@ -520,6 +520,12 @@ impl QueryPlanEntry { )); } } else { + if compiled.row_source(compiled.roots()[0]).is_some() { + return Err(QueryPlanError::Invalid( + "row-preserving physical output requires its input identity binding" + .into(), + )); + } let output = compiled .output_contract(compiled.roots()[0]) .map_err(|e| QueryPlanError::Invalid(e.to_string()))?; @@ -957,6 +963,77 @@ mod contract_tests { #[cfg(test)] mod retired_plan_tests { + // Row-preserving operators cannot opt out of the original vector identity. + #[test] + fn row_preserving_graph_requires_its_identity_binding() { + use super::*; + use asap_physical_operators::{ + operators::Operator, + physical_planner::{CompiledPhysicalDag, InputContract}, + }; + use planner_types::{ + post_asap::{SummaryFamilyType, SummaryField, SummarySchema}, + pre_asap::DataType, + }; + let schema = std::sync::Arc::new(SummarySchema { + fields: vec![SummaryField { + name: "value".into(), + dtype: SummaryFamilyType::Plain(DataType::Float64), + nullable: false, + }], + time_index: None, + }); + let program = CompiledPhysicalDag::from_operators( + [(0, InputContract::bounded(schema.clone()))].into(), + [(1, (vec![0], Operator::limit(schema, 1, 0, vec![]).unwrap()))].into(), + vec![1], + ) + .unwrap(); + let mut entry = QueryPlanEntry { + language: QueryLanguage::PromQl, + query_id: "identity".into(), + canonical_query: "m".into(), + fixed_evaluation: None, + physical_dag: None, + root: QueryNodeId(1), + nodes: [ + ( + QueryNodeId(0), + QueryPlanNode::ExactFallback { + reason: "bound vector".into(), + }, + ), + ( + QueryNodeId(1), + QueryPlanNode::PhysicalFragment { + inputs: vec![QueryNodeId(0)], + dag: program.encode().unwrap(), + row_input: None, + pruning: None, + }, + ), + ] + .into(), + instant: InstantExecution { + lookback_ms: 0, + full_history: false, + cumulative_readout: false, + }, + fallback: FallbackPolicy::Reject, + }; + assert!(entry + .validate(&BTreeSet::new()) + .unwrap_err() + .to_string() + .contains("identity binding")); + if let QueryPlanNode::PhysicalFragment { row_input, .. } = + entry.nodes.get_mut(&QueryNodeId(1)).unwrap() + { + *row_input = Some(0); + } + entry.validate(&BTreeSet::new()).unwrap(); + } + #[test] fn uncompiled_relation_variants_are_not_accepted() { for kind in ["relational", "relational_join"] { diff --git a/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs b/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs index d18248c63..c9dcfa8bb 100644 --- a/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs +++ b/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs @@ -273,7 +273,9 @@ pub(super) fn physical( for (field, cell) in output_schema.fields.iter().zip(row) { match cell { Value::Utf8(value) => { - labels.insert(field.name.clone(), value.to_string()); + if !value.is_empty() { + labels.insert(field.name.clone(), value.to_string()); + } } Value::Float64(value) => sample = Some(*value), Value::Int64(value) if value.unsigned_abs() <= (1u64 << 53) => { @@ -616,6 +618,7 @@ mod tests { ) .unwrap(); let values = vec![ + (Labels::from([("instance".into(), "c".into())]), 5.), ( Labels::from([ ("job".into(), "api".into()), @@ -650,6 +653,7 @@ mod tests { assert_eq!( output, vec![ + (Labels::new(), 5.), (Labels::from([("job".into(), "api".into())]), 3.), (Labels::from([("job".into(), "worker".into())]), 4.), ] From 1a3efb6430fecdec838c6d3df063bc52ac0df6b5 Mon Sep 17 00:00:00 2001 From: zzylol Date: Tue, 29 Sep 2026 13:30:40 +0000 Subject: [PATCH 07/19] refactor: install Planner-compiled scalar and vector computation --- Cargo.lock | 14 +- Cargo.toml | 12 +- control_plane/src/physical/compiler.rs | 55 +++-- control_plane/src/query_plan.rs | 3 +- .../src/query_plan/physical_values.rs | 194 ++++++++++++++++++ control_plane/src/query_plan/query_time.rs | 6 +- crates/asap_types/src/query_plan.rs | 153 ++++++++------ .../asap_query_engine/logical_dag.rs | 4 + .../logical_dag/native_values.rs | 174 ++++++++++++++++ 9 files changed, 504 insertions(+), 111 deletions(-) create mode 100644 control_plane/src/query_plan/physical_values.rs diff --git a/Cargo.lock b/Cargo.lock index 471b047ab..5e5216d43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -364,7 +364,7 @@ dependencies = [ [[package]] name = "asap-aware-mapping" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=4b0839ce1733aab8231eb90944c876063a4551f9#4b0839ce1733aab8231eb90944c876063a4551f9" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3aa0f7be90f69974430b3fe3ccf98c5487ad06c2#3aa0f7be90f69974430b3fe3ccf98c5487ad06c2" dependencies = [ "asap-types", "asap_sketchlib 0.3.0 (git+https://github.com/ProjectASAP/asap_sketchlib)", @@ -376,7 +376,7 @@ dependencies = [ [[package]] name = "asap-frontend-promql" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=4b0839ce1733aab8231eb90944c876063a4551f9#4b0839ce1733aab8231eb90944c876063a4551f9" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3aa0f7be90f69974430b3fe3ccf98c5487ad06c2#3aa0f7be90f69974430b3fe3ccf98c5487ad06c2" dependencies = [ "asap-types", "promql-parser 0.10.0 (git+https://github.com/ProjectASAP/promql-parser?rev=9fede7eecca923c9882fe256484d00d37f8706cb)", @@ -385,7 +385,7 @@ dependencies = [ [[package]] name = "asap-frontend-sql" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=4b0839ce1733aab8231eb90944c876063a4551f9#4b0839ce1733aab8231eb90944c876063a4551f9" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3aa0f7be90f69974430b3fe3ccf98c5487ad06c2#3aa0f7be90f69974430b3fe3ccf98c5487ad06c2" dependencies = [ "asap-sql-function-catalog", "asap-types", @@ -396,7 +396,7 @@ dependencies = [ [[package]] name = "asap-physical-operators" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=4b0839ce1733aab8231eb90944c876063a4551f9#4b0839ce1733aab8231eb90944c876063a4551f9" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3aa0f7be90f69974430b3fe3ccf98c5487ad06c2#3aa0f7be90f69974430b3fe3ccf98c5487ad06c2" dependencies = [ "asap-types", "asap_sketch_codec", @@ -416,12 +416,12 @@ dependencies = [ [[package]] name = "asap-sql-function-catalog" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=4b0839ce1733aab8231eb90944c876063a4551f9#4b0839ce1733aab8231eb90944c876063a4551f9" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3aa0f7be90f69974430b3fe3ccf98c5487ad06c2#3aa0f7be90f69974430b3fe3ccf98c5487ad06c2" [[package]] name = "asap-types" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=4b0839ce1733aab8231eb90944c876063a4551f9#4b0839ce1733aab8231eb90944c876063a4551f9" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3aa0f7be90f69974430b3fe3ccf98c5487ad06c2#3aa0f7be90f69974430b3fe3ccf98c5487ad06c2" dependencies = [ "serde", "serde_json", @@ -443,7 +443,7 @@ dependencies = [ [[package]] name = "asap_sketch_codec" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=4b0839ce1733aab8231eb90944c876063a4551f9#4b0839ce1733aab8231eb90944c876063a4551f9" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3aa0f7be90f69974430b3fe3ccf98c5487ad06c2#3aa0f7be90f69974430b3fe3ccf98c5487ad06c2" dependencies = [ "asap_sketchlib 0.3.0 (git+https://github.com/ProjectASAP/asap_sketchlib?rev=5f03ccbd798ed5fec62bdd839bcb331123cab369)", "prost", diff --git a/Cargo.toml b/Cargo.toml index 9040dbf29..3008ee9ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,10 +14,10 @@ version = "0.1.0" [workspace.dependencies] # Keep Planner frontends, selection, and IR on the same immutable revision. # Alias upstream asap-types because this workspace also defines asap_types. -planner-types = { package = "asap-types", git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "4b0839ce1733aab8231eb90944c876063a4551f9" } -asap-aware-mapping = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "4b0839ce1733aab8231eb90944c876063a4551f9" } -asap-frontend-promql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "4b0839ce1733aab8231eb90944c876063a4551f9" } -asap-frontend-sql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "4b0839ce1733aab8231eb90944c876063a4551f9" } +planner-types = { package = "asap-types", git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3aa0f7be90f69974430b3fe3ccf98c5487ad06c2" } +asap-aware-mapping = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3aa0f7be90f69974430b3fe3ccf98c5487ad06c2" } +asap-frontend-promql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3aa0f7be90f69974430b3fe3ccf98c5487ad06c2" } +asap-frontend-sql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3aa0f7be90f69974430b3fe3ccf98c5487ad06c2" } # Shared external deps (used by 2+ crates) serde = { version = "1.0", features = ["derive"] } @@ -37,8 +37,8 @@ arc-swap = "1.7" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } # Internal crates -asap-physical-operators = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "4b0839ce1733aab8231eb90944c876063a4551f9" } -asap_sketch_codec = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "4b0839ce1733aab8231eb90944c876063a4551f9" } +asap-physical-operators = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3aa0f7be90f69974430b3fe3ccf98c5487ad06c2" } +asap_sketch_codec = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3aa0f7be90f69974430b3fe3ccf98c5487ad06c2" } asap_types = { path = "crates/asap_types" } asap_otel_proto = { path = "crates/asap_otel_proto" } indexmap = { version = "2.0", features = ["serde"] } diff --git a/control_plane/src/physical/compiler.rs b/control_plane/src/physical/compiler.rs index 55253d424..8752f2eac 100644 --- a/control_plane/src/physical/compiler.rs +++ b/control_plane/src/physical/compiler.rs @@ -2447,6 +2447,7 @@ impl DeploymentPlanCompiler { .as_ref() .map(|candidate| &candidate.query), )?; + crate::query_plan::physical_values::compile(&mut entry)?; let catalog_key = QueryPlan::catalog_key(entry.language, &canonical); if query_entries.insert(catalog_key, entry).is_some() { return Err(CompileError::Query { @@ -4887,24 +4888,18 @@ pub(crate) mod tests { let plan = DeploymentPlanCompiler .compile_promql(request, environment) .unwrap(); - assert!( - plan.query_plan - .entries - .values() - .flat_map(|e| e.nodes.values()) - .any(|node| matches!( + assert!(plan + .query_plan + .entries + .values() + .flat_map(|entry| entry.nodes.values()) + .flat_map( + |node| crate::query_plan::physical_values::operator_parameters( node, - crate::query_plan::QueryPlanNode::Logical { - operator: asap_types::query_plan::query_time::QueryTimeOperator::Binary { - operation: - asap_types::query_plan::query_time::BinaryOperation::FiniteDiv, - .. - }, - .. - } - )), - "{plan:#?}" - ); + "VectorBinary" + ) + ) + .any(|parameters| parameters["operator"]["checked_finite_division"] == true)); } // The Planner's minimum state lowers without reconstructing direction from text. @@ -5430,13 +5425,12 @@ pub(crate) mod tests { .unwrap(); let entry = plan.query_plan.lookup(query).unwrap(); use crate::query_plan::{query_time::QueryTimeOperator, QueryPlanNode}; - assert!(matches!( + let limits = crate::query_plan::physical_values::operator_parameters( &entry.nodes[&entry.root], - QueryPlanNode::Logical { - operator: QueryTimeOperator::Limit { n: 2, .. }, - .. - } - )); + "Limit", + ); + assert_eq!(limits.len(), 1); + assert_eq!(limits[0]["n"], 2); assert_eq!( entry .nodes @@ -6757,13 +6751,14 @@ pub(crate) mod tests { .query_plan .entries .values() - .any(|entry| entry.nodes.values().any(|node| matches!( - node, - crate::query_plan::QueryPlanNode::Logical { - operator: crate::query_plan::query_time::QueryTimeOperator::Binary { .. }, - .. - } - )))); + .flat_map(|entry| entry.nodes.values()) + .any( + |node| !crate::query_plan::physical_values::operator_parameters( + node, + "VectorBinary" + ) + .is_empty() + )); } #[test] diff --git a/control_plane/src/query_plan.rs b/control_plane/src/query_plan.rs index 319d36803..4917df29b 100644 --- a/control_plane/src/query_plan.rs +++ b/control_plane/src/query_plan.rs @@ -2,7 +2,8 @@ //! Serving consumes asap_types::query_plan; compilation stays in this component. mod clickhouse_exact; -pub mod query_time; +pub mod physical_values; +pub mod residual; pub use asap_types::query_plan::*; #[cfg(test)] diff --git a/control_plane/src/query_plan/physical_values.rs b/control_plane/src/query_plan/physical_values.rs new file mode 100644 index 000000000..1cde268b3 --- /dev/null +++ b/control_plane/src/query_plan/physical_values.rs @@ -0,0 +1,194 @@ +//! Retain Planner-compiled scalar/vector fragments before publishing an installed plan. +use super::*; +use asap_physical_operators::physical_planner::{promql_values as physical, CompiledPhysicalDag}; +use planner_types::{ + post_asap::BinaryOperator, + pre_asap::{AggIntent, ArithmeticOpKind, BinaryOpKind, ColumnRef, CompareOpKind, GroupKeys}, +}; +use residual::{Aggregation, BinaryOperation, Grouping, ResidualQueryOperator as Operation}; + +fn invalid(error: impl std::fmt::Display) -> QueryPlanError { + QueryPlanError::Invalid(error.to_string()) +} +fn grouping(value: &Grouping) -> GroupKeys { + let keys = value.labels.iter().cloned().map(ColumnRef::Named).collect(); + if value.without { + GroupKeys::without(keys) + } else { + GroupKeys::by(keys) + } +} +fn binary(operation: BinaryOperation) -> BinaryOperator { + use ArithmeticOpKind as A; + use BinaryOperation as O; + use CompareOpKind as C; + let kind = match operation { + O::Add => BinaryOpKind::Arithmetic(A::Add), + O::Sub => BinaryOpKind::Arithmetic(A::Sub), + O::Mul => BinaryOpKind::Arithmetic(A::Mul), + O::Div | O::CheckedDiv | O::FiniteDiv => BinaryOpKind::Arithmetic(A::Div), + O::Mod => BinaryOpKind::Arithmetic(A::Mod), + O::Pow => BinaryOpKind::Arithmetic(A::Pow), + O::Equal => BinaryOpKind::Compare(C::Eq), + O::NotEqual => BinaryOpKind::Compare(C::Ne), + O::Less => BinaryOpKind::Compare(C::Lt), + O::LessEqual => BinaryOpKind::Compare(C::Le), + O::Greater => BinaryOpKind::Compare(C::Gt), + O::GreaterEqual => BinaryOpKind::Compare(C::Ge), + }; + BinaryOperator { + kind, + vector_match: None, + checked_relative_division: operation == O::CheckedDiv, + checked_finite_division: operation == O::FiniteDiv, + } +} + +pub fn compile(entry: &mut QueryPlanEntry) -> Result<(), QueryPlanError> { + if entry.language == QueryLanguage::ClickHouseSql || entry.physical_dag.is_some() { + return Ok(()); + } + let mut scalars = BTreeMap::new(); + for id in entry.topological_order()? { + let node = &entry.nodes[&id]; + let inputs = node.inputs().to_vec(); + let scalar_input = |position: usize| { + inputs + .get(position) + .and_then(|id| scalars.get(id)) + .copied() + .unwrap_or(false) + }; + let mut scalar = false; + let compiled = match node { + QueryPlanNode::Scalar { .. } => { + scalar = true; + None + } + QueryPlanNode::Logical { + operator: + Operation::ExactSubquery { query } | Operation::CandidateExactSubquery { query, .. }, + .. + } => { + scalar = promql_parser::parser::parse(query) + .map_err(invalid)? + .value_type() + == promql_parser::parser::value::ValueType::Scalar; + None + } + QueryPlanNode::Logical { operator, .. } => match operator { + Operation::Binary { + operation, + return_bool, + } => { + scalar = scalar_input(0) && scalar_input(1); + Some( + physical::compile_binary( + &binary(*operation), + *return_bool, + scalar_input(0), + scalar_input(1), + ) + .map_err(invalid)?, + ) + } + Operation::UnaryNegate => { + scalar = scalar_input(0); + Some(physical::compile_negate(scalar).map_err(invalid)?) + } + Operation::VectorToScalar => { + scalar = true; + Some(physical::compile_vector_to_scalar().map_err(invalid)?) + } + Operation::Aggregate { + operation, + grouping: groups, + } => { + let intent = match operation { + Aggregation::Sum => AggIntent::Sum { col: None }, + Aggregation::Avg => AggIntent::Avg { col: None }, + Aggregation::Min => AggIntent::Min { col: None }, + Aggregation::Max => AggIntent::Max { col: None }, + Aggregation::Count => AggIntent::Count { + accuracy: planner_types::types::AccuracyTarget::Exact, + }, + }; + Some(physical::compile_aggregate(&intent, &grouping(groups)).map_err(invalid)?) + } + Operation::Sort { + descending, + grouping: groups, + } => Some(physical::compile_sort(*descending, &grouping(groups)).map_err(invalid)?), + Operation::Limit { + n, + offset, + grouping: groups, + } => { + Some(physical::compile_limit(*n, *offset, &grouping(groups)).map_err(invalid)?) + } + _ => None, + }, + QueryPlanNode::Binary { operator, .. } => { + scalar = scalar_input(0) && scalar_input(1); + Some( + physical::compile_binary( + &BinaryOperator { + kind: BinaryOpKind::Arithmetic(operator.clone()), + vector_match: None, + checked_relative_division: false, + checked_finite_division: false, + }, + false, + scalar_input(0), + scalar_input(1), + ) + .map_err(invalid)?, + ) + } + QueryPlanNode::PhysicalFragment { dag, .. } => { + let compiled = CompiledPhysicalDag::decode(dag).map_err(invalid)?; + scalar = compiled + .output_contract(compiled.roots()[0]) + .map_err(invalid)? + .schema + == physical::scalar_schema(); + None + } + _ => None, + }; + scalars.insert(id, scalar); + if let Some(compiled) = compiled { + entry.nodes.insert( + id, + QueryPlanNode::PhysicalFragment { + inputs, + dag: compiled.encode().map_err(invalid)?, + row_input: None, + pruning: None, + }, + ); + } + } + Ok(()) +} + +#[cfg(test)] +pub(crate) fn operator_parameters(node: &QueryPlanNode, kind: &str) -> Vec { + let QueryPlanNode::PhysicalFragment { dag, .. } = node else { + return vec![]; + }; + CompiledPhysicalDag::decode(dag).unwrap(); + let document: serde_json::Value = serde_json::from_slice(dag).unwrap(); + document["nodes"] + .as_object() + .unwrap() + .values() + .filter_map(|node| { + node.get("Operator")? + .get("operator")? + .get("kind")? + .get(kind) + .cloned() + }) + .collect() +} diff --git a/control_plane/src/query_plan/query_time.rs b/control_plane/src/query_plan/query_time.rs index 192618634..705871aad 100644 --- a/control_plane/src/query_plan/query_time.rs +++ b/control_plane/src/query_plan/query_time.rs @@ -871,7 +871,11 @@ mod planner_workload_tests { let plan = asap_physical_operators::physical_planner::CompiledPhysicalDag::decode(dag) .unwrap(); - assert_eq!(plan.operator_name(plan.roots()[0]), Some("Limit")); + assert!( + plan.operator_name(plan.roots()[0]) == Some("Limit") + || super::super::physical_values::operator_parameters(node, "Limit").len() + == 1 + ); } QueryPlanNode::Logical { operator: QueryTimeOperator::Limit { .. }, diff --git a/crates/asap_types/src/query_plan.rs b/crates/asap_types/src/query_plan.rs index 1f21d903d..3dba93681 100644 --- a/crates/asap_types/src/query_plan.rs +++ b/crates/asap_types/src/query_plan.rs @@ -482,83 +482,104 @@ impl QueryPlanEntry { let compiled = asap_physical_operators::physical_planner::CompiledPhysicalDag::decode(dag) .map_err(|e| QueryPlanError::Invalid(e.to_string()))?; - for (_, contract) in compiled.input_contracts() { - let mut samples = 0; - for (index, field) in contract.schema.fields.iter().enumerate() { - use planner_types::{post_asap::SummaryFamilyType, pre_asap::DataType}; - match &field.dtype { - SummaryFamilyType::Plain(DataType::Float64 | DataType::Int64) => { - samples += 1 - } - SummaryFamilyType::Plain(DataType::Utf8) => {} - SummaryFamilyType::Plain(DataType::Timestamp) - if contract.schema.time_index == Some(index) => {} - _ => { - return Err(QueryPlanError::Invalid(format!( - "PromQL input binding cannot supply field {}", - field.name - ))) + use asap_physical_operators::physical_planner::promql_values; + let value_schema = |schema: &asap_physical_operators::values::Schema| { + schema == &promql_values::scalar_schema() + || schema == &promql_values::vector_schema() + }; + let canonical_values = compiled + .input_contracts() + .all(|(_, input)| value_schema(&input.schema)) + && compiled.roots().len() == 1 + && value_schema( + &compiled + .output_contract(compiled.roots()[0]) + .map_err(|e| QueryPlanError::Invalid(e.to_string()))? + .schema, + ); + if canonical_values { + if row_input.is_some() || pruning.is_some() { + return Err(QueryPlanError::Invalid("complete label-map computation cannot carry an external row-identity adapter".into())); + } + } else { + for (_, contract) in compiled.input_contracts() { + let mut samples = 0; + for (index, field) in contract.schema.fields.iter().enumerate() { + use planner_types::{post_asap::SummaryFamilyType, pre_asap::DataType}; + match &field.dtype { + SummaryFamilyType::Plain(DataType::Float64 | DataType::Int64) => { + samples += 1 + } + SummaryFamilyType::Plain(DataType::Utf8) => {} + SummaryFamilyType::Plain(DataType::Timestamp) + if contract.schema.time_index == Some(index) => {} + _ => { + return Err(QueryPlanError::Invalid(format!( + "PromQL input binding cannot supply field {}", + field.name + ))) + } } } + if samples > 1 { + return Err(QueryPlanError::Invalid( + "PromQL vector input has only one numeric sample per row".into(), + )); + } } - if samples > 1 { - return Err(QueryPlanError::Invalid( - "PromQL vector input has only one numeric sample per row".into(), - )); - } - } - if compiled.roots().len() != 1 { - return Err(QueryPlanError::Invalid( - "physical vector requires one root".into(), - )); - } - if let Some(row_input) = row_input { - let source = compiled.input_contracts().nth(*row_input).map(|(id, _)| id); - if source.is_none() || compiled.row_source(compiled.roots()[0]) != source { + if compiled.roots().len() != 1 { return Err(QueryPlanError::Invalid( - "physical vector output must preserve its bound input rows".into(), + "physical vector requires one root".into(), )); } - } else { - if compiled.row_source(compiled.roots()[0]).is_some() { - return Err(QueryPlanError::Invalid( + if let Some(row_input) = row_input { + let source = compiled.input_contracts().nth(*row_input).map(|(id, _)| id); + if source.is_none() || compiled.row_source(compiled.roots()[0]) != source { + return Err(QueryPlanError::Invalid( + "physical vector output must preserve its bound input rows".into(), + )); + } + } else { + if compiled.row_source(compiled.roots()[0]).is_some() { + return Err(QueryPlanError::Invalid( "row-preserving physical output requires its input identity binding" .into(), )); - } - let output = compiled - .output_contract(compiled.roots()[0]) - .map_err(|e| QueryPlanError::Invalid(e.to_string()))?; - let numeric = output - .schema - .fields - .iter() - .filter(|field| { - matches!( - field.dtype, - planner_types::post_asap::SummaryFamilyType::Plain( - planner_types::pre_asap::DataType::Float64 - | planner_types::pre_asap::DataType::Int64 + } + let output = compiled + .output_contract(compiled.roots()[0]) + .map_err(|e| QueryPlanError::Invalid(e.to_string()))?; + let numeric = output + .schema + .fields + .iter() + .filter(|field| { + matches!( + field.dtype, + planner_types::post_asap::SummaryFamilyType::Plain( + planner_types::pre_asap::DataType::Float64 + | planner_types::pre_asap::DataType::Int64 + ) ) - ) - }) - .count(); - if numeric != 1 - || output.schema.fields.iter().any(|field| { - !matches!( - field.dtype, - planner_types::post_asap::SummaryFamilyType::Plain( - planner_types::pre_asap::DataType::Float64 - | planner_types::pre_asap::DataType::Int64 - | planner_types::pre_asap::DataType::Utf8 - | planner_types::pre_asap::DataType::Timestamp + }) + .count(); + if numeric != 1 + || output.schema.fields.iter().any(|field| { + !matches!( + field.dtype, + planner_types::post_asap::SummaryFamilyType::Plain( + planner_types::pre_asap::DataType::Float64 + | planner_types::pre_asap::DataType::Int64 + | planner_types::pre_asap::DataType::Utf8 + | planner_types::pre_asap::DataType::Timestamp + ) ) - ) - }) - { - return Err(QueryPlanError::Invalid( - "physical output cannot bind to a PromQL vector".into(), - )); + }) + { + return Err(QueryPlanError::Invalid( + "physical output cannot bind to a PromQL vector".into(), + )); + } } } if let Some(pruning) = pruning { diff --git a/data_plane/src/query_engines/asap_query_engine/logical_dag.rs b/data_plane/src/query_engines/asap_query_engine/logical_dag.rs index edead745a..4a36f5038 100644 --- a/data_plane/src/query_engines/asap_query_engine/logical_dag.rs +++ b/data_plane/src/query_engines/asap_query_engine/logical_dag.rs @@ -325,6 +325,10 @@ impl Result> ValueRuntim pruning, .. } => { + if let Some(value) = native_values::complete_values(&dag, inputs, context.clone())? + { + return Ok(value); + } let values = inputs .iter() .map(|value| vector((**value).clone())) diff --git a/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs b/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs index c9dcfa8bb..a70b256ef 100644 --- a/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs +++ b/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs @@ -10,6 +10,88 @@ use planner_types::{ pre_asap::DataType, }; use std::sync::Arc; + +/// Bind protocol values without choosing matching, grouping or arithmetic behavior. +pub(super) fn complete_values( + encoded: &[u8], + inputs: &[&super::Value], + context: dag::RunContext, +) -> Result, EngineError> { + use asap_physical_operators::physical_planner::{promql_values, CompiledPhysicalDag, Source}; + use futures::StreamExt; + let program = CompiledPhysicalDag::decode(encoded)?; + let scalar = promql_values::scalar_schema(); + let vector = promql_values::vector_schema(); + let compatible = |schema: &Schema| schema == &scalar || schema == &vector; + if program.roots().len() != 1 + || !program + .input_contracts() + .all(|(_, input)| compatible(&input.schema)) + || !compatible(&program.output_contract(program.roots()[0])?.schema) + { + return Ok(None); + } + if program.input_contracts().count() != inputs.len() { + return Err(miss("physical value input arity mismatch")); + } + let mut sources = std::collections::BTreeMap::new(); + let mut retained_inputs = context.reserve(0)?; + let mut input_bytes = 0usize; + for ((id, contract), input) in program.input_contracts().zip(inputs) { + let rows = match input { + super::Value::Scalar(value) if contract.schema == scalar => { + vec![vec![Value::Float64(*value)]] + } + super::Value::Vector(values) if contract.schema == vector => values + .iter() + .map(|(labels, value)| vec![super::native_labels(labels), Value::Float64(*value)]) + .collect(), + _ => { + return Err(miss( + "protocol input differs from compiled scalar/vector contract", + )) + } + }; + let batch = Batch::try_new(contract.schema.clone(), rows)?; + input_bytes = input_bytes + .checked_add(batch.bytes()) + .ok_or(dag::Error::MemoryLimit)?; + retained_inputs.resize(input_bytes)?; + sources.insert( + id, + Box::new(Operator::source(contract.schema.clone(), vec![batch])?) as Source<'_>, + ); + } + let output_schema = program.output_contract(program.roots()[0])?.schema; + let graph = program.instantiate(sources)?; + let mut retained = context.reserve(0)?; + let mut stream = graph.execute(program.roots(), context)?.remove(0); + let rows = crate::query_engines::request::drive(async { + let mut rows = Vec::new(); + let mut bytes = 0usize; + while let Some(batch) = stream.next().await { + let batch = batch?; + bytes = bytes + .checked_add(batch.bytes()) + .ok_or(dag::Error::MemoryLimit)?; + retained.resize(bytes)?; + rows.extend(batch.rows().iter().cloned()); + } + Ok::<_, EngineError>(rows) + })??; + if output_schema == scalar { + let [row] = rows.as_slice() else { + return Err(miss("physical scalar output must have exactly one row")); + }; + let [Value::Float64(value)] = row.as_slice() else { + return Err(miss("invalid scalar output")); + }; + Ok(Some(super::Value::Scalar(*value))) + } else { + super::native_vector_output(rows, 0, 1).map(|values| Some(super::Value::Vector(values))) + } +} + fn schema(fields: &[(&str, DataType)]) -> Schema { Arc::new(SummarySchema { fields: fields @@ -380,6 +462,98 @@ mod tests { Error, }; + // An installed computation binds complete label maps; ranking and division are native. + #[test] + fn compiled_vector_composition_preserves_grouping_and_runs_independently() { + use super::super::{ + execute_installed, PreparedLeaf, PreparedLeaves, Value as ProtocolValue, + }; + use asap_types::query_plan::{ + residual::ResidualQueryOperator, FallbackPolicy, InstantExecution, QueryPlanNode, + }; + let query = "topk by (job) (1, sum without(instance) (left_metric) / sum without(instance) (right_metric))"; + let mut entry = control_plane::query_plan::residual::compile_logical( + "compiled-values".into(), + query.into(), + InstantExecution { + lookback_ms: 300_000, + full_history: false, + cumulative_readout: false, + }, + FallbackPolicy::Reject, + ) + .unwrap(); + control_plane::query_plan::physical_values::compile(&mut entry).unwrap(); + entry.validate(&Default::default()).unwrap(); + assert!(entry.nodes.values().all(|node| matches!( + node, + QueryPlanNode::PhysicalFragment { .. } + | QueryPlanNode::Logical { + operator: ResidualQueryOperator::Scan { .. }, + .. + } + ))); + for scale in [1., 2.] { + let mut leaves = PreparedLeaves::new(); + for (id, node) in &entry.nodes { + if let QueryPlanNode::Logical { + operator: ResidualQueryOperator::Scan { metric, .. }, + .. + } = node + { + let left = metric.as_deref() == Some("left_metric"); + let values = [ + ("api", "a", if left { 4. * scale } else { 2. }), + ("api", "b", if left { 2. * scale } else { 1. }), + ("worker", "c", if left { 8. * scale } else { 2. }), + ]; + leaves.insert( + (*id, 1000), + PreparedLeaf { + value: ProtocolValue::Vector( + values + .into_iter() + .map(|(job, instance, value)| { + ( + Labels::from([ + ("__name__".into(), metric.clone().unwrap()), + ("job".into(), job.into()), + ("instance".into(), instance.into()), + ]), + value, + ) + }) + .collect(), + ), + remote: true, + remote_evaluations: 1, + remote_rpcs: 1, + }, + ); + } + } + let (result, _) = execute_installed(&entry, &leaves, 1000, |_, _| { + panic!("all inputs were bound") + }) + .unwrap(); + let crate::query_engines::query_result::QueryResult::Vector(result) = result else { + panic!("expected vector") + }; + let actual = result + .values + .into_iter() + .map(|point| (point.labels.labels[0].clone(), point.value)) + .collect::>(); + assert_eq!( + actual, + std::collections::BTreeMap::from([ + ("api".into(), 2. * scale), + ("worker".into(), 4. * scale) + ]) + ); + } + } + // An available label is bound by Backend; Planner evaluates its predicate. #[test] fn planner_filter_compiles_and_executes_bound_labels() { From c049d6e074c5fce1e5d0b390c1bbfff307da75cd Mon Sep 17 00:00:00 2001 From: zzylol Date: Tue, 29 Sep 2026 13:59:45 +0000 Subject: [PATCH 08/19] refactor: execute connected native value graphs without runtime lowering --- Cargo.lock | 14 +- Cargo.toml | 12 +- control_plane/src/physical/compiler.rs | 9 +- .../src/query_plan/physical_values.rs | 157 +++++++++- crates/asap_types/src/query_plan.rs | 30 +- .../asap_query_engine/exact_subqueries.rs | 3 +- .../asap_query_engine/logical_dag.rs | 286 +++++------------- .../logical_dag/native_values.rs | 183 ++--------- 8 files changed, 293 insertions(+), 401 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5e5216d43..395404970 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -364,7 +364,7 @@ dependencies = [ [[package]] name = "asap-aware-mapping" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3aa0f7be90f69974430b3fe3ccf98c5487ad06c2#3aa0f7be90f69974430b3fe3ccf98c5487ad06c2" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=0db0e404eeafcb8d2bf3473a26a74a402800fb34#0db0e404eeafcb8d2bf3473a26a74a402800fb34" dependencies = [ "asap-types", "asap_sketchlib 0.3.0 (git+https://github.com/ProjectASAP/asap_sketchlib)", @@ -376,7 +376,7 @@ dependencies = [ [[package]] name = "asap-frontend-promql" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3aa0f7be90f69974430b3fe3ccf98c5487ad06c2#3aa0f7be90f69974430b3fe3ccf98c5487ad06c2" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=0db0e404eeafcb8d2bf3473a26a74a402800fb34#0db0e404eeafcb8d2bf3473a26a74a402800fb34" dependencies = [ "asap-types", "promql-parser 0.10.0 (git+https://github.com/ProjectASAP/promql-parser?rev=9fede7eecca923c9882fe256484d00d37f8706cb)", @@ -385,7 +385,7 @@ dependencies = [ [[package]] name = "asap-frontend-sql" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3aa0f7be90f69974430b3fe3ccf98c5487ad06c2#3aa0f7be90f69974430b3fe3ccf98c5487ad06c2" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=0db0e404eeafcb8d2bf3473a26a74a402800fb34#0db0e404eeafcb8d2bf3473a26a74a402800fb34" dependencies = [ "asap-sql-function-catalog", "asap-types", @@ -396,7 +396,7 @@ dependencies = [ [[package]] name = "asap-physical-operators" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3aa0f7be90f69974430b3fe3ccf98c5487ad06c2#3aa0f7be90f69974430b3fe3ccf98c5487ad06c2" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=0db0e404eeafcb8d2bf3473a26a74a402800fb34#0db0e404eeafcb8d2bf3473a26a74a402800fb34" dependencies = [ "asap-types", "asap_sketch_codec", @@ -416,12 +416,12 @@ dependencies = [ [[package]] name = "asap-sql-function-catalog" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3aa0f7be90f69974430b3fe3ccf98c5487ad06c2#3aa0f7be90f69974430b3fe3ccf98c5487ad06c2" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=0db0e404eeafcb8d2bf3473a26a74a402800fb34#0db0e404eeafcb8d2bf3473a26a74a402800fb34" [[package]] name = "asap-types" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3aa0f7be90f69974430b3fe3ccf98c5487ad06c2#3aa0f7be90f69974430b3fe3ccf98c5487ad06c2" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=0db0e404eeafcb8d2bf3473a26a74a402800fb34#0db0e404eeafcb8d2bf3473a26a74a402800fb34" dependencies = [ "serde", "serde_json", @@ -443,7 +443,7 @@ dependencies = [ [[package]] name = "asap_sketch_codec" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3aa0f7be90f69974430b3fe3ccf98c5487ad06c2#3aa0f7be90f69974430b3fe3ccf98c5487ad06c2" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=0db0e404eeafcb8d2bf3473a26a74a402800fb34#0db0e404eeafcb8d2bf3473a26a74a402800fb34" dependencies = [ "asap_sketchlib 0.3.0 (git+https://github.com/ProjectASAP/asap_sketchlib?rev=5f03ccbd798ed5fec62bdd839bcb331123cab369)", "prost", diff --git a/Cargo.toml b/Cargo.toml index 3008ee9ad..fafd0f696 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,10 +14,10 @@ version = "0.1.0" [workspace.dependencies] # Keep Planner frontends, selection, and IR on the same immutable revision. # Alias upstream asap-types because this workspace also defines asap_types. -planner-types = { package = "asap-types", git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3aa0f7be90f69974430b3fe3ccf98c5487ad06c2" } -asap-aware-mapping = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3aa0f7be90f69974430b3fe3ccf98c5487ad06c2" } -asap-frontend-promql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3aa0f7be90f69974430b3fe3ccf98c5487ad06c2" } -asap-frontend-sql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3aa0f7be90f69974430b3fe3ccf98c5487ad06c2" } +planner-types = { package = "asap-types", git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "0db0e404eeafcb8d2bf3473a26a74a402800fb34" } +asap-aware-mapping = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "0db0e404eeafcb8d2bf3473a26a74a402800fb34" } +asap-frontend-promql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "0db0e404eeafcb8d2bf3473a26a74a402800fb34" } +asap-frontend-sql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "0db0e404eeafcb8d2bf3473a26a74a402800fb34" } # Shared external deps (used by 2+ crates) serde = { version = "1.0", features = ["derive"] } @@ -37,8 +37,8 @@ arc-swap = "1.7" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } # Internal crates -asap-physical-operators = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3aa0f7be90f69974430b3fe3ccf98c5487ad06c2" } -asap_sketch_codec = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3aa0f7be90f69974430b3fe3ccf98c5487ad06c2" } +asap-physical-operators = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "0db0e404eeafcb8d2bf3473a26a74a402800fb34" } +asap_sketch_codec = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "0db0e404eeafcb8d2bf3473a26a74a402800fb34" } asap_types = { path = "crates/asap_types" } asap_otel_proto = { path = "crates/asap_otel_proto" } indexmap = { version = "2.0", features = ["serde"] } diff --git a/control_plane/src/physical/compiler.rs b/control_plane/src/physical/compiler.rs index 8752f2eac..3293699c8 100644 --- a/control_plane/src/physical/compiler.rs +++ b/control_plane/src/physical/compiler.rs @@ -2447,7 +2447,14 @@ impl DeploymentPlanCompiler { .as_ref() .map(|candidate| &candidate.query), )?; - crate::query_plan::physical_values::compile(&mut entry)?; + let linked = crate::query_plan::physical_values::compile(&mut entry)?; + for ((index, _), node) in &mut query_node_bindings { + if *index == query_index { + if let Some(root) = linked.get(node) { + *node = *root; + } + } + } let catalog_key = QueryPlan::catalog_key(entry.language, &canonical); if query_entries.insert(catalog_key, entry).is_some() { return Err(CompileError::Query { diff --git a/control_plane/src/query_plan/physical_values.rs b/control_plane/src/query_plan/physical_values.rs index 1cde268b3..2dfd78b75 100644 --- a/control_plane/src/query_plan/physical_values.rs +++ b/control_plane/src/query_plan/physical_values.rs @@ -44,9 +44,11 @@ fn binary(operation: BinaryOperation) -> BinaryOperator { } } -pub fn compile(entry: &mut QueryPlanEntry) -> Result<(), QueryPlanError> { +pub fn compile( + entry: &mut QueryPlanEntry, +) -> Result, QueryPlanError> { if entry.language == QueryLanguage::ClickHouseSql || entry.physical_dag.is_some() { - return Ok(()); + return Ok(BTreeMap::new()); } let mut scalars = BTreeMap::new(); for id in entry.topological_order()? { @@ -63,7 +65,10 @@ pub fn compile(entry: &mut QueryPlanEntry) -> Result<(), QueryPlanError> { let compiled = match node { QueryPlanNode::Scalar { .. } => { scalar = true; - None + let QueryPlanNode::Scalar { value } = node else { + unreachable!() + }; + Some(physical::compile_scalar(*value).map_err(invalid)?) } QueryPlanNode::Logical { operator: @@ -126,6 +131,26 @@ pub fn compile(entry: &mut QueryPlanEntry) -> Result<(), QueryPlanError> { } => { Some(physical::compile_limit(*n, *offset, &grouping(groups)).map_err(invalid)?) } + Operation::Temporal { operation } => { + use residual::TemporalOperation as T; + let intent = match operation { + T::Rate => AggIntent::Rate, + T::Increase => AggIntent::Increase, + T::Sum => AggIntent::Sum { col: None }, + T::Avg => AggIntent::Avg { col: None }, + T::Min => AggIntent::Min { col: None }, + T::Max => AggIntent::Max { col: None }, + T::Count => AggIntent::Count { + accuracy: planner_types::types::AccuracyTarget::Exact, + }, + }; + let preserve = entry.language == QueryLanguage::MetricsQl + && matches!(operation, T::Min | T::Max | T::Avg); + Some(physical::compile_temporal(&intent, preserve).map_err(invalid)?) + } + Operation::HistogramQuantile => { + Some(physical::compile_histogram_quantile().map_err(invalid)?) + } _ => None, }, QueryPlanNode::Binary { operator, .. } => { @@ -169,7 +194,131 @@ pub fn compile(entry: &mut QueryPlanEntry) -> Result<(), QueryPlanError> { ); } } - Ok(()) + combine(entry) +} + +fn combine( + entry: &mut QueryPlanEntry, +) -> Result, QueryPlanError> { + use asap_physical_operators::physical_planner::InputContract; + use std::collections::BTreeSet; + let supported = |schema: &asap_physical_operators::values::Schema| { + schema == &physical::scalar_schema() + || schema == &physical::vector_schema() + || schema == &physical::matrix_schema() + }; + let mut programs = BTreeMap::new(); + for (&id, node) in &entry.nodes { + if let QueryPlanNode::PhysicalFragment { + dag, + row_input: None, + pruning: None, + .. + } = node + { + let graph = CompiledPhysicalDag::decode(dag).map_err(invalid)?; + if graph.roots().len() == 1 + && graph + .input_contracts() + .all(|(_, input)| supported(&input.schema)) + && supported( + &graph + .output_contract(graph.roots()[0]) + .map_err(invalid)? + .schema, + ) + { + programs.insert(id, graph); + } + } + } + let mut roots = BTreeSet::new(); + if programs.contains_key(&entry.root) { + roots.insert(entry.root); + } + for (id, node) in &entry.nodes { + if !programs.contains_key(id) { + roots.extend( + node.inputs() + .iter() + .filter(|id| programs.contains_key(id)) + .copied(), + ); + } + } + let reachable = |root: QueryNodeId, boundaries: &BTreeSet| { + let mut pending = vec![root]; + let mut seen = BTreeSet::new(); + while let Some(id) = pending.pop() { + if !programs.contains_key(&id) + || (id != root && boundaries.contains(&id)) + || !seen.insert(id) + { + continue; + } + pending.extend(entry.nodes[&id].inputs()); + } + seen + }; + // A producer consumed across an I/O boundary remains one separately scheduled + // physical output, rather than being duplicated into both downstream graphs. + let mut owners = BTreeMap::::new(); + for &root in &roots { + for id in reachable(root, &BTreeSet::new()) { + *owners.entry(id).or_default() += 1; + } + } + roots.extend( + owners + .into_iter() + .filter_map(|(id, count)| (count > 1).then_some(id)), + ); + let mut replacements = BTreeMap::new(); + let mut remap = BTreeMap::new(); + for &root in &roots { + let members = reachable(root, &roots); + let mut sources = BTreeMap::::new(); + let mut fragments = BTreeMap::new(); + for &id in &members { + let graph = &programs[&id]; + let inputs = entry.nodes[&id].inputs(); + for ((_, contract), input) in graph.input_contracts().zip(inputs) { + if !members.contains(input) + && sources + .insert(input.0, contract.clone()) + .is_some_and(|previous| previous.schema != contract.schema) + { + return Err(invalid("shared physical input has inconsistent schemas")); + } + } + fragments.insert( + id.0, + (inputs.iter().map(|id| id.0).collect(), graph.clone()), + ); + if id != root { + remap.insert(id, root); + } + } + let graph = + CompiledPhysicalDag::compose(sources, fragments, vec![root.0]).map_err(invalid)?; + replacements.insert( + root, + QueryPlanNode::PhysicalFragment { + inputs: graph + .input_contracts() + .map(|(id, _)| QueryNodeId(id)) + .collect(), + dag: graph.encode().map_err(invalid)?, + row_input: None, + pruning: None, + }, + ); + } + for id in remap.keys() { + entry.nodes.remove(id); + } + entry.nodes.extend(replacements); + Ok(remap) } #[cfg(test)] diff --git a/crates/asap_types/src/query_plan.rs b/crates/asap_types/src/query_plan.rs index 3dba93681..be9f3049d 100644 --- a/crates/asap_types/src/query_plan.rs +++ b/crates/asap_types/src/query_plan.rs @@ -486,6 +486,7 @@ impl QueryPlanEntry { let value_schema = |schema: &asap_physical_operators::values::Schema| { schema == &promql_values::scalar_schema() || schema == &promql_values::vector_schema() + || schema == &promql_values::matrix_schema() }; let canonical_values = compiled .input_contracts() @@ -601,12 +602,29 @@ impl QueryPlanEntry { .get(pruning.candidate_input) .ok_or_else(|| QueryPlanError::Invalid("invalid candidate input".into()))? .1; - asap_physical_operators::operators::Operator::semi_join( - left.schema.clone(), - right.schema.clone(), - pruning.keys.clone(), - ) - .map_err(|e| QueryPlanError::Invalid(e.to_string()))?; + if matches!( + pruning.completeness, + CandidateCompleteness::Certified { .. } + ) && compiled.certified_pruning_keys(compiled.roots()[0]) + != Some(pruning.keys.as_slice()) + { + return Err(QueryPlanError::Invalid( + "certified pruning binding requires native coverage validation".into(), + )); + } + for &(l, r) in &pruning.keys { + if left + .schema + .fields + .get(l) + .zip(right.schema.fields.get(r)) + .is_none_or(|(l, r)| l.dtype != r.dtype) + { + return Err(QueryPlanError::Invalid( + "invalid pruning key types".into(), + )); + } + } } if compiled.input_contracts().count() != inputs.len() || compiled.roots().len() != 1 diff --git a/data_plane/src/query_engines/asap_query_engine/exact_subqueries.rs b/data_plane/src/query_engines/asap_query_engine/exact_subqueries.rs index 9d8dd5188..8d10ba8bb 100644 --- a/data_plane/src/query_engines/asap_query_engine/exact_subqueries.rs +++ b/data_plane/src/query_engines/asap_query_engine/exact_subqueries.rs @@ -854,7 +854,7 @@ mod tests { let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); - let entry = entry(BTreeMap::from([ + let mut entry = entry(BTreeMap::from([ ( QueryNodeId(0), QueryPlanNode::Logical { @@ -877,6 +877,7 @@ mod tests { }, ), ])); + control_plane::query_plan::physical_values::compile(&mut entry).unwrap(); let leaves = prepare( &entry, &[1000], diff --git a/data_plane/src/query_engines/asap_query_engine/logical_dag.rs b/data_plane/src/query_engines/asap_query_engine/logical_dag.rs index 4a36f5038..d774d9b09 100644 --- a/data_plane/src/query_engines/asap_query_engine/logical_dag.rs +++ b/data_plane/src/query_engines/asap_query_engine/logical_dag.rs @@ -6,9 +6,9 @@ use crate::query_engines::{ }; use crate::storage_engines::types::KeyByLabelValues; use asap_physical_operators::dag as physical; -use asap_types::query_plan::query_time::{ - Aggregation, BinaryOperation, Grouping, QueryTimeOperator, TemporalOperation, -}; +use asap_types::query_plan::query_time::QueryTimeOperator; +#[cfg(test)] +use asap_types::query_plan::query_time::{Aggregation, BinaryOperation, Grouping, TemporalOperation}; use asap_types::query_plan::{CandidateCompleteness, QueryNodeId, QueryPlanEntry, QueryPlanNode}; use futures::{FutureExt, StreamExt}; use std::cell::RefCell; @@ -61,6 +61,7 @@ pub struct ExecutionStats { fn miss(detail: impl Into) -> EngineError { EngineError::capability_miss("installed_logical_dag", detail) } +#[cfg(test)] fn no_name(mut labels: Labels) -> Labels { labels.remove("__name__"); labels @@ -334,14 +335,6 @@ impl Result> ValueRuntim .map(|value| vector((**value).clone())) .collect::, _>>()?; if let Some(contract) = &pruning { - native_values::validate_pruning( - &dag, - &values, - row_input.ok_or_else(|| miss("pruning requires preserved input rows"))?, - contract, - at, - context.clone(), - )?; if let Some(warning) = pruning_warning(Some(&contract.completeness)) { self.warnings.push(warning); } @@ -373,12 +366,6 @@ impl Result> ValueRuntim at: i64, context: &physical::RunContext, ) -> Result { - let input = |index: usize| { - inputs - .get(index) - .map(|value| (**value).clone()) - .ok_or_else(|| miss("missing logical input")) - }; match operator { QueryTimeOperator::ExactSubquery { .. } | QueryTimeOperator::CandidateExactSubquery { .. } => { @@ -390,108 +377,17 @@ impl Result> ValueRuntim QueryTimeOperator::Scan { .. } => { Err(miss("local raw Scan is forbidden in deployed plans")) } - QueryTimeOperator::UnaryNegate => negate(input(0)?, context), - QueryTimeOperator::VectorToScalar => vector_to_scalar(vector(input(0)?)?, context), - QueryTimeOperator::Aggregate { - operation, - grouping, - } => { - let values = vector(input(0)?)?; - Ok(Value::Vector(aggregate( - operation, &grouping, values, context, - )?)) - } - QueryTimeOperator::Limit { - n, - offset, - grouping, - } => { - let values = vector(input(0)?)?; - Ok(Value::Vector(native_values::limit( - values, - &grouping, - n, - offset, - context.clone(), - )?)) - } - QueryTimeOperator::Binary { - operation, - return_bool, - } => { - let left = input(0)?; - let right = input(1)?; - binary_in_context(operation, return_bool, left, right, context) - } - QueryTimeOperator::Temporal { operation } => { - let Value::Matrix(values, start, end) = input(0)? else { - return Err(miss("temporal operator requires range vector")); - }; - let preserve_name = self.entry.language - == control_plane::query_plan::QueryLanguage::MetricsQl - && matches!( - operation, - TemporalOperation::Min | TemporalOperation::Max | TemporalOperation::Avg - ); - let result = native_temporal(values, operation, start, end, context)?; - Ok(Value::Vector( - result - .into_iter() - .map(|(labels, value)| { - ( - if preserve_name { - labels - } else { - no_name(labels) - }, - value, - ) - }) - .collect(), - )) - } - - QueryTimeOperator::Sort { - descending, - grouping, - } => { - let values = vector(input(0)?)?; - Ok(Value::Vector(native_values::sort( - values, - &grouping, - descending, - context.clone(), - )?)) - } - QueryTimeOperator::HistogramQuantile => { - let Value::Scalar(quantile) = input(0)? else { - return Err(miss("quantile requires scalar")); - }; - let mut groups: BTreeMap> = BTreeMap::new(); - for (mut labels, value) in vector(input(1)?)? { - if let Some(le) = labels.remove("le").and_then(|s| s.parse::().ok()) { - groups.entry(no_name(labels)).or_default().push((le, value)); - } - } - let rows = groups - .into_iter() - .flat_map(|(labels, buckets)| { - buckets.into_iter().map(move |(bound, count)| { - vec![ - native_labels(&labels), - physical::values::Value::Float64(bound), - physical::values::Value::Float64(count), - ] - }) - }) - .collect(); - Ok(Value::Vector(native_window( - rows, - planner_types::pre_asap::AggIntent::HistogramQuantile { q: quantile }, - None, - context, - )?)) - } + QueryTimeOperator::UnaryNegate + | QueryTimeOperator::VectorToScalar + | QueryTimeOperator::Aggregate { .. } + | QueryTimeOperator::Limit { .. } + | QueryTimeOperator::Binary { .. } + | QueryTimeOperator::Temporal { .. } + | QueryTimeOperator::Sort { .. } + | QueryTimeOperator::HistogramQuantile => Err(physical::Error::Invalid( + "installed computation must contain Planner physical operators".into(), + ) + .into()), QueryTimeOperator::Subquery { range_ms, step_ms, @@ -754,6 +650,7 @@ fn native_labels(labels: &Labels) -> physical::values::Value { .into(), ) } +#[cfg(test)] fn native_vector_batch( values: Vector, grouping: &Grouping, @@ -795,6 +692,7 @@ fn native_vector_batch( .collect(); Batch::try_new(schema, rows).map_err(EngineError::from) } +#[cfg(test)] fn native_batch_rows( batch: physical::values::Batch, ops: Vec, @@ -840,6 +738,7 @@ fn native_vector_output( }) .collect() } +#[cfg(test)] fn aggregate( operation: Aggregation, grouping: &Grouping, @@ -864,6 +763,7 @@ fn aggregate( native_vector_output(native_batch_rows(batch, vec![operator], context)?, 0, 1) } +#[cfg(test)] fn negate(value: Value, context: &physical::RunContext) -> Result { use physical::operators::{Expression, Operator}; let scalar = matches!(value, Value::Scalar(_)); @@ -897,24 +797,7 @@ fn negate(value: Value, context: &physical::RunContext) -> Result Result { - use physical::{operators::Operator, values::Value as Cell}; - let batch = native_vector_batch( - values, - &Grouping { - labels: vec![], - without: false, - }, - )?; - let operator = - Operator::vector_to_scalar(batch.schema().clone(), 2).map_err(EngineError::from)?; - let rows = native_batch_rows(batch, vec![operator], context)?; - match rows.first().and_then(|row| row.first()) { - Some(Cell::Float64(value)) => Ok(Value::Scalar(*value)), - _ => Err(miss("native scalar conversion returned invalid output")), - } -} - +#[cfg(test)] fn grouping_key(labels: &Labels, grouping: &Grouping) -> Labels { labels .iter() @@ -969,6 +852,7 @@ fn binary( ) -> Result { binary_in_context(operation, boolean, left, right, &test_native_context()) } +#[cfg(test)] fn binary_in_context( operation: BinaryOperation, boolean: bool, @@ -1127,83 +1011,21 @@ fn binary_in_context( Ok(Value::Vector(vector(Value::Vector(output))?)) } -fn native_temporal( - values: Matrix, - operation: TemporalOperation, - start: i64, - end: i64, - context: &physical::RunContext, -) -> Result { - use planner_types::pre_asap::AggIntent; - let intent = match operation { - TemporalOperation::Rate => AggIntent::Rate, - TemporalOperation::Increase => AggIntent::Increase, - TemporalOperation::Sum => AggIntent::Sum { col: None }, - TemporalOperation::Avg => AggIntent::Avg { col: None }, - TemporalOperation::Min => AggIntent::Min { col: None }, - TemporalOperation::Max => AggIntent::Max { col: None }, - TemporalOperation::Count => AggIntent::Count { - accuracy: planner_types::types::AccuracyTarget::Exact, +#[cfg(test)] +fn test_state_binding() -> QueryPlanNode { + let output = asap_types::sds::StoredOutputId(99); + QueryPlanNode::ReadMaterialization { + binding: asap_types::query_plan::MaterializationBinding { + stored_output_reference: asap_types::sds::StoredOutputReference::for_output(output), + materialization: output, + output_grouping: asap_types::query_plan::PhysicalGrouping::PerEntity, + item_labels: vec![], + window_ms: 1000, + pane_origin_ms: Some(0), + readout_lookback_ms: Some(300_000), + full_window_slide_ms: None, }, - }; - let rows = values - .into_iter() - .flat_map(|(labels, points)| { - points.into_iter().map(move |(time, value)| { - vec![ - native_labels(&labels), - physical::values::Value::Timestamp(time), - physical::values::Value::Float64(value), - ] - }) - }) - .collect(); - native_window(rows, intent, Some((start, end)), context) -} -fn native_window( - rows: Vec>, - intent: planner_types::pre_asap::AggIntent, - window: Option<(i64, i64)>, - context: &physical::RunContext, -) -> Result { - use planner_types::{ - post_asap::{SummaryFamilyType, SummaryField, SummarySchema}, - pre_asap::DataType, - }; - let schema = std::sync::Arc::new(SummarySchema { - fields: vec![ - ( - "labels", - DataType::Map { - key: Box::new(DataType::Utf8), - value: Box::new(DataType::Utf8), - value_nullable: false, - }, - ), - ( - "coordinate", - if window.is_some() { - DataType::Timestamp - } else { - DataType::Float64 - }, - ), - ("value", DataType::Float64), - ] - .into_iter() - .map(|(name, dtype)| SummaryField { - name: name.into(), - dtype: SummaryFamilyType::Plain(dtype), - nullable: false, - }) - .collect(), - time_index: None, - }); - let batch = - physical::values::Batch::try_new(schema.clone(), rows).map_err(EngineError::from)?; - let operator = physical::operators::Operator::window(schema, intent, 1, 2, vec![0], window) - .map_err(EngineError::from)?; - native_vector_output(native_batch_rows(batch, vec![operator], context)?, 0, 1) + } } #[cfg(test)] @@ -1422,6 +1244,8 @@ mod topk_tests { )] .into_iter() .collect(); + let mut entry = entry.clone(); + control_plane::query_plan::physical_values::compile(&mut entry).unwrap(); let (result, stats) = execute_installed(&entry, &leaves, at, |_, _| { panic!("summary callback must not run for an exact-child topk") }) @@ -1504,6 +1328,8 @@ mod topk_tests { remote_rpcs: 1, }, )]); + let mut entry = entry.clone(); + control_plane::query_plan::physical_values::compile(&mut entry).unwrap(); let (result, _) = execute_installed(&entry, &leaves, 1000, |_, _| { panic!("external child supplied") }) @@ -1542,6 +1368,7 @@ mod topk_tests { fixed_evaluation: None, root, nodes: BTreeMap::from([ + (QueryNodeId(99), test_state_binding()), ( summary, QueryPlanNode::ExactReadout { @@ -1584,6 +1411,8 @@ mod topk_tests { }, fallback: FallbackPolicy::ExactBackend, }; + let mut entry = entry.clone(); + control_plane::query_plan::physical_values::compile(&mut entry).unwrap(); let (result, stats) = execute_installed(&entry, &BTreeMap::new(), 300_000, |id, at| { assert_eq!(id, summary); assert_eq!(at, 300_000); @@ -1876,6 +1705,8 @@ mod topk_tests { }, ), ]); + let mut entry = entry.clone(); + control_plane::query_plan::physical_values::compile(&mut entry).unwrap(); let (result, stats) = execute_installed(&entry, &leaves, at as u64, |_, _| { panic!("both inputs are prepared") }) @@ -1933,9 +1764,9 @@ mod shared_runtime_tests { canonical_query: "shared-grid".into(), fixed_evaluation: None, root: QueryNodeId(3), - // The callback owns the absorbed summary dependencies. Only its - // declared readout boundary participates in this value graph. + // The callback binds a readout boundary backed by the declared stored source. nodes: BTreeMap::from([ + (QueryNodeId(99), test_state_binding()), ( QueryNodeId(0), QueryPlanNode::ExactReadout { @@ -1986,7 +1817,25 @@ mod shared_runtime_tests { // A shared time-grid node runs once per query; distinct times and runs stay isolated. #[test] fn shared_subquery_scopes_do_not_duplicate_or_leak_values() { - let entry = entry(); + let mut entry = entry(); + control_plane::query_plan::physical_values::compile(&mut entry).unwrap(); + let QueryPlanNode::PhysicalFragment { dag, .. } = &entry.nodes[&entry.root] else { + panic!("compiled root required") + }; + let graph: serde_json::Value = serde_json::from_slice(dag).unwrap(); + let shared = graph["nodes"] + .as_object() + .unwrap() + .values() + .find_map(|node| { + let operator = node.get("Operator")?; + operator["operator"]["kind"] + .get("VectorBinary") + .map(|_| operator["inputs"].as_array().unwrap()) + }) + .unwrap(); + assert_eq!(shared.len(), 2); + assert_eq!(shared[0], shared[1]); let mut calls = Vec::new(); for (at, expected) in [(3000, 10.), (4000, 14.)] { let (result, stats) = execute_installed(&entry, &BTreeMap::new(), at, |id, time| { @@ -2007,7 +1856,6 @@ mod shared_runtime_tests { }; assert_eq!(result.values[0].value, expected); assert_eq!(stats.summary_readout_evaluations, 2); - assert!(stats.memo_hits >= 1); } assert_eq!(calls, vec![2000, 3000, 3000, 4000]); } @@ -2037,7 +1885,9 @@ mod shared_runtime_tests { // Source failures keep their routing classification across the shared runtime. #[test] fn source_error_classification_survives_execution() { - let error = execute_installed(&entry(), &BTreeMap::new(), 3000, |_, _| { + let mut entry = entry(); + control_plane::query_plan::physical_values::compile(&mut entry).unwrap(); + let error = execute_installed(&entry, &BTreeMap::new(), 3000, |_, _| { Err(EngineError::capability_miss("source", "failed")) }) .unwrap_err(); diff --git a/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs b/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs index a70b256ef..c2ab39dcb 100644 --- a/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs +++ b/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs @@ -1,14 +1,14 @@ //! Bind deployment inputs to retained native programs and decode PromQL results. -use super::{grouping_key, miss, EngineError, Grouping, Labels, Vector}; +use super::{miss, EngineError, Labels, Vector}; use asap_physical_operators::dag::{ - self, batch_execution, - operators::{Operator, SortKey}, + self, + operators::Operator, values::{Batch, Schema, Value}, }; -use planner_types::{ - post_asap::{SummaryFamilyType, SummaryField, SummarySchema}, - pre_asap::DataType, -}; +#[cfg(test)] +use planner_types::post_asap::{SummaryField, SummarySchema}; +use planner_types::{post_asap::SummaryFamilyType, pre_asap::DataType}; +#[cfg(test)] use std::sync::Arc; /// Bind protocol values without choosing matching, grouping or arithmetic behavior. @@ -22,7 +22,8 @@ pub(super) fn complete_values( let program = CompiledPhysicalDag::decode(encoded)?; let scalar = promql_values::scalar_schema(); let vector = promql_values::vector_schema(); - let compatible = |schema: &Schema| schema == &scalar || schema == &vector; + let matrix = promql_values::matrix_schema(); + let compatible = |schema: &Schema| schema == &scalar || schema == &vector || schema == &matrix; if program.roots().len() != 1 || !program .input_contracts() @@ -46,6 +47,20 @@ pub(super) fn complete_values( .iter() .map(|(labels, value)| vec![super::native_labels(labels), Value::Float64(*value)]) .collect(), + super::Value::Matrix(values, start, end) if contract.schema == matrix => values + .iter() + .flat_map(|(labels, points)| { + points.iter().map(move |(time, value)| { + vec![ + super::native_labels(labels), + Value::Timestamp(*time), + Value::Float64(*value), + Value::Timestamp(*start), + Value::Timestamp(*end), + ] + }) + }) + .collect(), _ => { return Err(miss( "protocol input differs from compiled scalar/vector contract", @@ -92,6 +107,7 @@ pub(super) fn complete_values( } } +#[cfg(test)] fn schema(fields: &[(&str, DataType)]) -> Schema { Arc::new(SummarySchema { fields: fields @@ -105,84 +121,6 @@ fn schema(fields: &[(&str, DataType)]) -> Schema { time_index: None, }) } -fn key(value: &T) -> Value { - Value::Utf8( - serde_json::to_string(value) - .expect("string keys serialize") - .into(), - ) -} -fn ranked_batch(values: &Vector, grouping: &Grouping) -> Result { - let schema = schema(&[ - ("index", DataType::Int64), - ("group", DataType::Utf8), - ("value", DataType::Float64), - ]); - Batch::try_new( - schema, - values - .iter() - .enumerate() - .map(|(i, (labels, v))| { - vec![ - Value::Int64(i as i64), - key(&grouping_key(labels, grouping)), - Value::Float64(*v), - ] - }) - .collect(), - ) - .map_err(EngineError::from) -} -fn output(values: Vector, batches: Vec>) -> Result { - batches - .iter() - .flat_map(|b| b.rows()) - .map(|row| match row.first() { - Some(Value::Int64(index)) => values - .get(*index as usize) - .cloned() - .ok_or_else(|| miss("native result index outside input")), - _ => Err(miss("native result has no row identity")), - }) - .collect() -} -pub(super) fn sort( - values: Vector, - grouping: &Grouping, - descending: bool, - context: dag::RunContext, -) -> Result { - let batch = ranked_batch(&values, grouping)?; - let op = Operator::sort( - batch.schema().clone(), - vec![SortKey { - column: 2, - descending, - nulls_first: false, - }], - vec![1], - ) - .map_err(EngineError::from)?; - let result = - batch_execution::evaluate_batch(batch, vec![op], context).map_err(EngineError::from)?; - output(values, result) -} -pub(super) fn limit( - values: Vector, - grouping: &Grouping, - n: u64, - offset: u64, - context: dag::RunContext, -) -> Result { - let batch = ranked_batch(&values, grouping)?; - let op = - Operator::limit(batch.schema().clone(), n, offset, vec![1]).map_err(EngineError::from)?; - let result = - batch_execution::evaluate_batch(batch, vec![op], context).map_err(EngineError::from)?; - output(values, result) -} -/// Relational boundary used by explicit row plans. Planner owns predicate lowering. #[cfg(test)] pub(super) fn relation( values: Vector, @@ -201,18 +139,6 @@ pub(super) fn relation( use planner_types::post_asap::{ ExecutableDagNode, ExecutableOperatorPayload, ExecutionDataState, PostAsapNodeId, }; - let pruning = completeness - .as_ref() - .map(|completeness| { - Ok::<_, EngineError>(asap_types::query_plan::PruningInputContract { - candidate_input: 1, - keys: asap_physical_operators::physical_planner::equijoin_keys( - &predicate, &left, &right, - )?, - completeness: completeness.clone(), - }) - }) - .transpose()?; let node = ExecutableDagNode { id: PostAsapNodeId(2), output_state: ExecutionDataState::QUERY_ROWS, @@ -236,9 +162,6 @@ pub(super) fn relation( )?; let encoded = compiled.encode()?; let inputs = vec![values, candidates]; - if let Some(pruning) = pruning { - validate_pruning(&encoded, &inputs, 0, &pruning, at, context.clone())?; - } physical(&encoded, inputs, Some(0), at, context) } @@ -397,66 +320,10 @@ pub(super) fn physical( } } -pub(super) fn validate_pruning( - encoded: &[u8], - inputs: &[Vector], - row_input: usize, - contract: &asap_types::query_plan::PruningInputContract, - at: i64, - context: dag::RunContext, -) -> Result<(), EngineError> { - use asap_physical_operators::physical_planner::{CompiledPhysicalDag, InputContract}; - use planner_types::post_asap::CandidateCompleteness; - if !matches!( - contract.completeness, - CandidateCompleteness::Certified { .. } - ) { - return Ok(()); - } - let compiled = CompiledPhysicalDag::decode(encoded)?; - let schemas = compiled - .input_contracts() - .map(|(_, c)| c.schema.clone()) - .collect::>(); - let candidates = inputs - .get(contract.candidate_input) - .ok_or_else(|| miss("missing candidate input"))?; - let values = inputs - .get(row_input) - .ok_or_else(|| miss("missing authoritative input"))?; - let coverage = Operator::semi_join( - schemas[contract.candidate_input].clone(), - schemas[row_input].clone(), - contract.keys.iter().map(|&(l, r)| (r, l)).collect(), - )?; - let check = CompiledPhysicalDag::from_operators( - [ - ( - 0, - InputContract::bounded(schemas[contract.candidate_input].clone()), - ), - (1, InputContract::bounded(schemas[row_input].clone())), - ] - .into(), - [(2, (vec![0, 1], coverage))].into(), - vec![2], - )?; - let matched = physical( - &check.encode()?, - vec![candidates.clone(), values.clone()], - Some(0), - at, - context, - )?; - if matched.len() != candidates.len() { - return Err(miss("certified pruning key has no authoritative value")); - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; + use asap_physical_operators::operators::SortKey; use asap_physical_operators::{ physical_planner::{CompiledPhysicalDag, InputContract}, Error, From 372bc392cbcf925c54224168c30bd7d082ee6185 Mon Sep 17 00:00:00 2001 From: zzylol Date: Tue, 29 Sep 2026 14:24:18 +0000 Subject: [PATCH 09/19] refactor: execute retained native graphs for immutable precomputation --- Cargo.lock | 14 +- Cargo.toml | 12 +- control_plane/src/physical/compiler.rs | 8 + .../src/physical/executable_binding.rs | 41 ++++ control_plane/src/physical/workload_cost.rs | 18 +- crates/asap_types/src/executable_plan.rs | 24 ++- crates/asap_types/src/precompute_plan.rs | 7 +- .../precompute_engine/maintenance_runtime.rs | 203 +++++++----------- .../precompute_engine/native_precompute.rs | 88 +++++++- data_plane/src/precompute_engine/revisions.rs | 4 + .../tests/support/issue_701_702_process.rs | 36 ++-- 11 files changed, 279 insertions(+), 176 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 395404970..4e71d1d97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -364,7 +364,7 @@ dependencies = [ [[package]] name = "asap-aware-mapping" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=0db0e404eeafcb8d2bf3473a26a74a402800fb34#0db0e404eeafcb8d2bf3473a26a74a402800fb34" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=33510c2a1a387a5ec1459a36eb6221c8be88eee5#33510c2a1a387a5ec1459a36eb6221c8be88eee5" dependencies = [ "asap-types", "asap_sketchlib 0.3.0 (git+https://github.com/ProjectASAP/asap_sketchlib)", @@ -376,7 +376,7 @@ dependencies = [ [[package]] name = "asap-frontend-promql" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=0db0e404eeafcb8d2bf3473a26a74a402800fb34#0db0e404eeafcb8d2bf3473a26a74a402800fb34" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=33510c2a1a387a5ec1459a36eb6221c8be88eee5#33510c2a1a387a5ec1459a36eb6221c8be88eee5" dependencies = [ "asap-types", "promql-parser 0.10.0 (git+https://github.com/ProjectASAP/promql-parser?rev=9fede7eecca923c9882fe256484d00d37f8706cb)", @@ -385,7 +385,7 @@ dependencies = [ [[package]] name = "asap-frontend-sql" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=0db0e404eeafcb8d2bf3473a26a74a402800fb34#0db0e404eeafcb8d2bf3473a26a74a402800fb34" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=33510c2a1a387a5ec1459a36eb6221c8be88eee5#33510c2a1a387a5ec1459a36eb6221c8be88eee5" dependencies = [ "asap-sql-function-catalog", "asap-types", @@ -396,7 +396,7 @@ dependencies = [ [[package]] name = "asap-physical-operators" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=0db0e404eeafcb8d2bf3473a26a74a402800fb34#0db0e404eeafcb8d2bf3473a26a74a402800fb34" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=33510c2a1a387a5ec1459a36eb6221c8be88eee5#33510c2a1a387a5ec1459a36eb6221c8be88eee5" dependencies = [ "asap-types", "asap_sketch_codec", @@ -416,12 +416,12 @@ dependencies = [ [[package]] name = "asap-sql-function-catalog" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=0db0e404eeafcb8d2bf3473a26a74a402800fb34#0db0e404eeafcb8d2bf3473a26a74a402800fb34" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=33510c2a1a387a5ec1459a36eb6221c8be88eee5#33510c2a1a387a5ec1459a36eb6221c8be88eee5" [[package]] name = "asap-types" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=0db0e404eeafcb8d2bf3473a26a74a402800fb34#0db0e404eeafcb8d2bf3473a26a74a402800fb34" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=33510c2a1a387a5ec1459a36eb6221c8be88eee5#33510c2a1a387a5ec1459a36eb6221c8be88eee5" dependencies = [ "serde", "serde_json", @@ -443,7 +443,7 @@ dependencies = [ [[package]] name = "asap_sketch_codec" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=0db0e404eeafcb8d2bf3473a26a74a402800fb34#0db0e404eeafcb8d2bf3473a26a74a402800fb34" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=33510c2a1a387a5ec1459a36eb6221c8be88eee5#33510c2a1a387a5ec1459a36eb6221c8be88eee5" dependencies = [ "asap_sketchlib 0.3.0 (git+https://github.com/ProjectASAP/asap_sketchlib?rev=5f03ccbd798ed5fec62bdd839bcb331123cab369)", "prost", diff --git a/Cargo.toml b/Cargo.toml index fafd0f696..72c398a78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,10 +14,10 @@ version = "0.1.0" [workspace.dependencies] # Keep Planner frontends, selection, and IR on the same immutable revision. # Alias upstream asap-types because this workspace also defines asap_types. -planner-types = { package = "asap-types", git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "0db0e404eeafcb8d2bf3473a26a74a402800fb34" } -asap-aware-mapping = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "0db0e404eeafcb8d2bf3473a26a74a402800fb34" } -asap-frontend-promql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "0db0e404eeafcb8d2bf3473a26a74a402800fb34" } -asap-frontend-sql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "0db0e404eeafcb8d2bf3473a26a74a402800fb34" } +planner-types = { package = "asap-types", git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "33510c2a1a387a5ec1459a36eb6221c8be88eee5" } +asap-aware-mapping = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "33510c2a1a387a5ec1459a36eb6221c8be88eee5" } +asap-frontend-promql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "33510c2a1a387a5ec1459a36eb6221c8be88eee5" } +asap-frontend-sql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "33510c2a1a387a5ec1459a36eb6221c8be88eee5" } # Shared external deps (used by 2+ crates) serde = { version = "1.0", features = ["derive"] } @@ -37,8 +37,8 @@ arc-swap = "1.7" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } # Internal crates -asap-physical-operators = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "0db0e404eeafcb8d2bf3473a26a74a402800fb34" } -asap_sketch_codec = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "0db0e404eeafcb8d2bf3473a26a74a402800fb34" } +asap-physical-operators = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "33510c2a1a387a5ec1459a36eb6221c8be88eee5" } +asap_sketch_codec = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "33510c2a1a387a5ec1459a36eb6221c8be88eee5" } asap_types = { path = "crates/asap_types" } asap_otel_proto = { path = "crates/asap_otel_proto" } indexmap = { version = "2.0", features = ["serde"] } diff --git a/control_plane/src/physical/compiler.rs b/control_plane/src/physical/compiler.rs index 3293699c8..05778961e 100644 --- a/control_plane/src/physical/compiler.rs +++ b/control_plane/src/physical/compiler.rs @@ -2514,6 +2514,14 @@ impl DeploymentPlanCompiler { .map_err(|e| CompileError::Snapshot(e.to_string()))?, ); } + super::executable_binding::compile_precompute_programs( + &mut installed, + &materializations, + ) + .map_err(|reason| CompileError::Query { + query_id: query_id.clone(), + reason, + })?; query_plan .selected_dags .insert(query_id.clone(), installed.document.clone()); diff --git a/control_plane/src/physical/executable_binding.rs b/control_plane/src/physical/executable_binding.rs index 9c09bf26b..6a92c9ef5 100644 --- a/control_plane/src/physical/executable_binding.rs +++ b/control_plane/src/physical/executable_binding.rs @@ -90,3 +90,44 @@ pub fn validate_query_plan( } Ok(()) } + +/// Retain Planner's complete state-to-state graph before candidate pricing. +pub(super) fn compile_precompute_programs( + installed: &mut asap_types::executable_plan::InstalledPostAsapDag, + configs: &[asap_types::PrecomputeMaterialization], +) -> Result<(), String> { + use asap_types::executable_plan::BackendNodeBinding; + let dag = installed.document.decode()?; + for sink in &installed.binding.precompute_sinks { + if installed.native_programs.contains_key(sink) { + continue; + } + let Some(BackendNodeBinding::Materialization { stored_output }) = + installed.binding.node(*sink) + else { + continue; + }; + let config = configs + .iter() + .find(|c| c.policy_fingerprint() == stored_output.fingerprint()) + .ok_or("precompute output configuration missing")?; + let Some(derived) = &config.derived_input else { + continue; + }; + let frontiers = installed.binding.nodes.iter().filter_map(|(id, binding)| { + matches!(binding, BackendNodeBinding::Materialization { stored_output } if derived.inputs.contains(stored_output)).then_some(u64::from(id.0)) + }).collect::>(); + let program = asap_physical_operators::physical_planner::precompute::compile( + &dag, + &frontiers, + &[u64::from(sink.0)], + ) + .map_err(|e| e.to_string())?; + installed.native_programs.insert( + *sink, + serde_json::from_slice(&program.encode().map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?, + ); + } + Ok(()) +} diff --git a/control_plane/src/physical/workload_cost.rs b/control_plane/src/physical/workload_cost.rs index 049fd272e..61e815c49 100644 --- a/control_plane/src/physical/workload_cost.rs +++ b/control_plane/src/physical/workload_cost.rs @@ -799,8 +799,11 @@ pub fn enumerate_exact_and_materialized_candidates( && existing .queries .iter() - .zip(&candidate.queries) - .all(|(a, b)| a.selected_plan_root == b.selected_plan_root) + .map(|query| &query.selected_plan_root) + .eq(candidate + .queries + .iter() + .map(|query| &query.selected_plan_root)) }) { candidates.push(candidate); } @@ -875,8 +878,11 @@ fn enumerate_frontier_candidates( && existing .queries .iter() - .zip(&candidate.queries) - .all(|(a, b)| a.selected_plan_root == b.selected_plan_root) + .map(|query| &query.selected_plan_root) + .eq(candidate + .queries + .iter() + .map(|query| &query.selected_plan_root)) }) { candidates.push(candidate); } @@ -913,8 +919,8 @@ fn materialization_candidates( && request .queries .iter() - .zip(&exact.queries) - .all(|(a, b)| a.selected_plan_root == b.selected_plan_root) + .map(|query| &query.selected_plan_root) + .eq(exact.queries.iter().map(|query| &query.selected_plan_root)) { Ok(vec![request]) } else { diff --git a/crates/asap_types/src/executable_plan.rs b/crates/asap_types/src/executable_plan.rs index 8e2980ab5..b48aeccdc 100644 --- a/crates/asap_types/src/executable_plan.rs +++ b/crates/asap_types/src/executable_plan.rs @@ -228,11 +228,13 @@ impl InstalledPostAsapDag { self.binding.node(id), Some(BackendNodeBinding::Materialization { .. }) ) - || dag - .nodes - .iter() - .find(|n| n.id == id) - .is_none_or(|n| n.output_schema != *contract.schema) + || dag.nodes.iter().find(|n| n.id == id).is_none_or(|n| { + n.output_schema != *contract.schema + && asap_physical_operators::physical_planner::precompute::source_schema( + &n.output_schema, + ) + .map_or(true, |schema| schema != contract.schema) + }) { return Err( "native maintenance source differs from installed state boundary".into(), @@ -243,11 +245,13 @@ impl InstalledPostAsapDag { .output_contract(u64::from(sink.0)) .map_err(|e| e.to_string())?; if program.input_contracts().count() == 0 - || dag - .nodes - .iter() - .find(|n| n.id == sink) - .is_none_or(|n| n.output_schema != *output.schema) + || dag.nodes.iter().find(|n| n.id == sink).is_none_or(|n| { + n.output_schema != *output.schema + && asap_physical_operators::physical_planner::precompute::source_schema( + &n.output_schema, + ) + .map_or(true, |schema| schema != output.schema) + }) { return Err("native maintenance output differs from semantic schema".into()); } diff --git a/crates/asap_types/src/precompute_plan.rs b/crates/asap_types/src/precompute_plan.rs index 7a6d77c62..b6b943ce0 100644 --- a/crates/asap_types/src/precompute_plan.rs +++ b/crates/asap_types/src/precompute_plan.rs @@ -591,7 +591,12 @@ impl PrecomputePlan { .ok_or_else(invalid)?; let native = installed .native_program(*sink) - .map_err(PrecomputePlanError::CatalogContract)?; + .map_err(PrecomputePlanError::CatalogContract)? + .ok_or_else(invalid)?; + let native = native.output_contract(u64::from(sink.0)) + .map_err(|e| PrecomputePlanError::CatalogContract(e.to_string())) + .map(|contract| !asap_physical_operators::physical_planner::precompute::is_population_schema(&contract.schema))? + .then_some(native); if sources.iter().any(|source| { !matches!(source.aggregation_type, crate::AggregationType::Sum) && !(native.is_some() diff --git a/data_plane/src/precompute_engine/maintenance_runtime.rs b/data_plane/src/precompute_engine/maintenance_runtime.rs index 374e36fc2..0f75b2ad3 100644 --- a/data_plane/src/precompute_engine/maintenance_runtime.rs +++ b/data_plane/src/precompute_engine/maintenance_runtime.rs @@ -96,7 +96,6 @@ enum MaintenanceInputs<'a> { }, Frozen(&'a [crate::storage_engines::sketch_db::index::FrozenExactWindows]), Complete(&'a crate::storage_engines::sketch_db::index::CompleteRawMaintenanceCohort), - Captured(&'a [crate::storage_engines::sketch_db::index::FrozenExactWindows]), } impl MaintenanceInputs<'_> { @@ -105,7 +104,7 @@ impl MaintenanceInputs<'_> { ) -> Option<&[crate::storage_engines::sketch_db::index::FrozenExactWindows]> { match self { Self::Live { .. } => None, - Self::Frozen(inputs) | Self::Captured(inputs) => Some(inputs), + Self::Frozen(inputs) => Some(inputs), Self::Complete(cohort) => Some(cohort.inputs()), } } @@ -177,9 +176,6 @@ impl PrecomputeOperatorRegistry for OperatorAdapter<'_> { MaintenanceInputs::Frozen(inputs) => { frozen_population_value(inputs, definition, family, false) } - MaintenanceInputs::Captured(inputs) => { - frozen_population_value(inputs, definition, family, true) - } MaintenanceInputs::Complete(cohort) => { frozen_population_value(cohort.inputs(), definition, family, true) } @@ -996,29 +992,31 @@ fn execute_prepared_frozen_sink( dag: &planner_types::post_asap::ExecutableDag, key: MaterializationCommitKey, ) -> Result<(SummaryState, Population), MaintenanceError> { - let adapter = OperatorAdapter { - binding: &installed.binding, + let _ = (configs, dag); + let program = installed + .native_program(sink)? + .ok_or("installed precompute sink lacks its Planner physical graph")?; + let inputs = inputs + .frozen_inputs() + .ok_or("compiled precompute requires immutable inputs")?; + let batch = super::native_precompute::execute( + installed, + &program, inputs, - configs, - }; - let value = execute_precompute_sink( - dag, - &installed.binding, - sink, - key.clone(), - &adapter, - &CommitRegistry::default(), - maintenance_context(key.window_start_ms, key.window_end_ms, key.plan_version)?, - ) - .map_err(schedule_error)?; - let group = match value.as_ref() { - MaintenanceValue::SummaryWindows { states, .. } if states.len() == 1 => { - states.keys().next().unwrap().clone() - } - MaintenanceValue::Summary { .. } => Population::new(), - _ => return Err("maintenance sink has multiple or missing output populations".into()), - }; - Ok((Arc::clone(value.state()?), group)) + ( + u64::try_from(key.window_start_ms)?, + u64::try_from(key.window_end_ms)?, + ), + asap_physical_operators::runtime::Limits::default().max_bytes, + key.plan_version, + )?; + let mut populations = + super::native_precompute::population_states(&batch, u64::try_from(key.window_end_ms)?)?; + if populations.len() != 1 { + return Err("precompute sink must explicitly reduce to one output population".into()); + } + let (group, state) = populations.pop().unwrap(); + Ok((state, group)) } #[cfg(test)] @@ -1464,7 +1462,15 @@ fn execute_finite_complete_populations( .collect::, _>>()?; asap_types::precompute_plan::validated_source_window_cohort(config, &sources) .map_err(|error| error.to_string())?; - let native_program = installed.native_program(sink)?; + let native_program = installed.native_program(sink)?.filter(|program| { + program + .output_contract(u64::from(sink.0)) + .is_ok_and(|contract| { + !asap_physical_operators::physical_planner::precompute::is_population_schema( + &contract.schema, + ) + }) + }); if std::iter::once(config) .chain(sources.iter().copied()) .any(|config| { @@ -2869,8 +2875,14 @@ mod tests { stored_output: durable_configs[1].policy_fingerprint().into(), }, ); + let program = + asap_physical_operators::physical_planner::precompute::compile(&dag, &[1], &[3]) + .unwrap(); let installed = InstalledPostAsapDag { - native_programs: std::collections::BTreeMap::new(), + native_programs: BTreeMap::from([( + PostAsapNodeId(3), + serde_json::from_slice(&program.encode().unwrap()).unwrap(), + )]), document, binding: durable_binding, }; @@ -3255,8 +3267,17 @@ mod tests { binding .nodes .insert(PostAsapNodeId(6), BackendNodeBinding::MaintenanceInput); + let program = asap_physical_operators::physical_planner::precompute::compile( + &document.decode().unwrap(), + &[1, 5], + &[3], + ) + .unwrap(); let installed = InstalledPostAsapDag { - native_programs: BTreeMap::new(), + native_programs: BTreeMap::from([( + PostAsapNodeId(3), + serde_json::from_slice(&program.encode().unwrap()).unwrap(), + )]), document, binding, }; @@ -4483,8 +4504,6 @@ pub(crate) fn execute_revision_outputs( .as_ref() .ok_or("revision generation missing")?; for installed in plan.precompute_plan.executable_dags.values() { - let mut windows: BTreeMap<(u64, u64), Vec<(PostAsapNodeId, MaterializationCommitKey)>> = - BTreeMap::new(); let mut result: BTreeMap> = BTreeMap::new(); for sink in &installed.binding.precompute_sinks { @@ -4562,6 +4581,29 @@ pub(crate) fn execute_revision_outputs( let batch = super::native_precompute::execute( installed, &program, &selected, window, limit, revision, )?; + if asap_physical_operators::physical_planner::precompute::is_population_schema( + batch.schema(), + ) { + for (group, state) in + super::native_precompute::population_states(&batch, window.1)? + { + let payload = encode_state(state, config.accumulator_spec()?.family)?; + if payload.len() > limit { + return Err(asap_physical_operators::Error::MemoryLimit.into()); + } + result.get_mut(target).unwrap().push(RevisionRecord { + reference: plan + .installed_precompute_plan + .stored_output_reference(*target) + .ok_or("precompute output binding missing")?, + group, + start_ms: window.0, + end_ms: window.1, + payload, + }); + } + continue; + } let payload = asap_physical_operators::stored_state::native::encode_batch(&batch)?; if payload.len() > limit { @@ -4579,102 +4621,7 @@ pub(crate) fn execute_revision_outputs( }); continue; } - let (_, mut key) = prepare_frozen_maintenance_sink( - installed, - &plan.precompute_plan.materializations, - *sink, - &selected, - window, - )?; - // The captured input revision, rather than a sink-specific digest, - // identifies the common evaluation frontier for shared producers. - key.input_lineage = revision.to_be_bytes().to_vec(); - windows.entry(window).or_default().push((*sink, key)); - } - } - let dag = installed.document.decode()?; - for (window, sinks) in windows { - let inputs: Vec<_> = raw - .iter() - .filter_map(|input| { - let windows: BTreeMap<_, _> = input - .windows - .iter() - .filter(|((s, e), _)| *s >= window.0 && *e <= window.1) - .map(|(w, s)| (*w, Arc::clone(s))) - .collect(); - (!windows.is_empty()).then(|| { - crate::storage_engines::sketch_db::index::FrozenExactWindows { - stored_output_reference: input.stored_output_reference.clone(), - storage_handle: input.storage_handle, - definition: input.definition, - generation: Arc::clone(&input.generation), - group: input.group.clone(), - windows, - singleton_population_complete: true, - } - }) - }) - .collect(); - let adapter = OperatorAdapter { - binding: &installed.binding, - inputs: MaintenanceInputs::Captured(&inputs), - configs: &plan.precompute_plan.materializations, - }; - let context = RunContext::new( - asap_physical_operators::dag::Scope::Ingestion { - window_start_ms: window.0 as i64, - window_end_ms: window.1 as i64, - revision, - }, - asap_physical_operators::dag::Limits { - max_bytes: limit, - ..Default::default() - }, - )?; - let values = execute_precompute_sinks( - &dag, - &installed.binding, - &sinks, - &adapter, - &CommitRegistry::default(), - context, - ) - .map_err(schedule_error)?; - for ((_, key), value) in sinks.iter().zip(values) { - let group = match value.as_ref() { - MaintenanceValue::SummaryWindows { states, .. } if states.len() == 1 => { - states.keys().next().unwrap().clone() - } - MaintenanceValue::Summary { .. } => BTreeMap::new(), - _ => { - return Err( - "revision sink must explicitly reduce its output population".into() - ) - } - }; - let config = plan - .precompute_plan - .materializations - .iter() - .find(|c| c.policy_fingerprint() == key.stored_output.fingerprint()) - .ok_or("revision output configuration missing")?; - result - .get_mut(&key.stored_output) - .unwrap() - .push(RevisionRecord { - reference: plan - .installed_precompute_plan - .stored_output_reference(key.stored_output) - .ok_or("revision output binding missing")?, - group, - start_ms: window.0, - end_ms: window.1, - payload: encode_state( - Arc::clone(value.state()?), - config.accumulator_spec()?.family, - )?, - }); + return Err("installed revision producer lacks its Planner physical graph".into()); } } for (output, records) in result { diff --git a/data_plane/src/precompute_engine/native_precompute.rs b/data_plane/src/precompute_engine/native_precompute.rs index b79989ead..52fe5bec4 100644 --- a/data_plane/src/precompute_engine/native_precompute.rs +++ b/data_plane/src/precompute_engine/native_precompute.rs @@ -32,10 +32,51 @@ pub(super) fn execute( return Err("native precompute input has no stored binding".into()); }; let mut rows = Vec::new(); - for input in inputs + let bound_inputs = inputs .iter() - .filter(|input| input.stored_output_reference.stored_output_id == *stored_output) - { + .filter(|input| input.definition == *stored_output) + .collect::>(); + if bound_inputs.is_empty() { + return Err("native precompute input has no eligible stored population".into()); + } + for input in bound_inputs { + if input.stored_output_reference.stored_output_id != *stored_output + || input.windows.is_empty() + { + return Err( + "native precompute input differs from its stored-output binding".into(), + ); + } + if asap_physical_operators::physical_planner::precompute::is_population_schema( + &contract.schema, + ) { + let family = contract.schema.fields[2].dtype.clone(); + for (&(start, end), state) in &input.windows { + if start < window.0 || end > window.1 || start >= end { + return Err("native precompute pane is outside its bound window".into()); + } + rows.push(vec![ + Value::Map( + input + .group + .iter() + .map(|(k, v)| { + (Value::Utf8(k.clone().into()), Value::Utf8(v.clone().into())) + }) + .collect::>() + .into(), + ), + Value::Timestamp( + i64::try_from(end).map_err(|_| "native pane timestamp overflow")?, + ), + Value::Summary { + family: family.clone(), + state: Arc::clone(state), + }, + ]); + } + continue; + } let state = input .windows .get(&window) @@ -123,3 +164,44 @@ pub(super) fn execute( Batch::try_new(schema, rows).map_err(Into::into) }) } + +/// Decode output identities without regrouping or evaluating their states. +pub(super) fn population_states( + batch: &Batch, + window_end: u64, +) -> Result< + Vec<( + BTreeMap, + Arc, + )>, + Box, +> { + if !asap_physical_operators::physical_planner::precompute::is_population_schema(batch.schema()) + { + return Err("precompute output is not a population state batch".into()); + } + let mut result = BTreeMap::new(); + for row in batch.rows() { + let [Value::Map(labels), Value::Timestamp(end), Value::Summary { state, .. }] = + row.as_slice() + else { + return Err("invalid population output row".into()); + }; + if u64::try_from(*end).ok() != Some(window_end) { + return Err("precompute output window differs from publication".into()); + } + let mut group = BTreeMap::new(); + for (key, value) in labels.iter() { + let (Value::Utf8(key), Value::Utf8(value)) = (key, value) else { + return Err("invalid population labels".into()); + }; + if group.insert(key.to_string(), value.to_string()).is_some() { + return Err("duplicate population label".into()); + } + } + if result.insert(group, Arc::clone(state)).is_some() { + return Err("repeated precompute output population".into()); + } + } + Ok(result.into_iter().collect()) +} diff --git a/data_plane/src/precompute_engine/revisions.rs b/data_plane/src/precompute_engine/revisions.rs index 383f642da..8aca75d3d 100644 --- a/data_plane/src/precompute_engine/revisions.rs +++ b/data_plane/src/precompute_engine/revisions.rs @@ -492,6 +492,10 @@ pub(crate) fn decode_native_record( .native_program(*sink)? .ok_or("native output program missing")?; let schema = program.output_contract(u64::from(sink.0))?.schema; + if asap_physical_operators::physical_planner::precompute::is_population_schema(&schema) + { + continue; + } if expected .as_ref() .is_some_and(|previous| previous != &schema) diff --git a/data_plane/tests/support/issue_701_702_process.rs b/data_plane/tests/support/issue_701_702_process.rs index c739cc228..0c555bcaf 100644 --- a/data_plane/tests/support/issue_701_702_process.rs +++ b/data_plane/tests/support/issue_701_702_process.rs @@ -398,21 +398,27 @@ async fn temporal_average_overflow_falls_back_after_state_is_warm() { .into(); let snapshot = quote_snapshot_for_test(serde_json::from_value(fixture).unwrap()); let plan = snapshot.clone().compile_promql().unwrap(); - assert!(plan - .query_plan - .entries - .values() - .flat_map(|entry| entry.nodes.values()) - .any(|node| matches!( - node, - control_plane::query_plan::QueryPlanNode::Logical { - operator: control_plane::query_plan::query_time::QueryTimeOperator::Binary { - operation: control_plane::query_plan::query_time::BinaryOperation::FiniteDiv, - .. - }, - .. - } - ))); + assert!( + plan.query_plan + .entries + .values() + .flat_map(|entry| entry.nodes.values()) + .any(|node| { + let control_plane::query_plan::QueryPlanNode::PhysicalFragment { dag, .. } = node + else { + return false; + }; + asap_physical_operators::physical_planner::CompiledPhysicalDag::decode(dag) + .unwrap(); + let document: serde_json::Value = serde_json::from_slice(dag).unwrap(); + document["nodes"].as_object().unwrap().values().any(|node| { + node["Operator"]["operator"]["kind"]["VectorBinary"]["operator"] + ["checked_finite_division"] + == true + }) + }), + "average must retain its native finite-division contract" + ); let output = tempfile::tempdir().unwrap(); let path = output.path().join("snapshot.json"); std::fs::write(&path, serde_json::to_vec(&snapshot).unwrap()).unwrap(); From cdd23e380ff216bf3434e799632783202bed377f Mon Sep 17 00:00:00 2001 From: zzylol Date: Tue, 29 Sep 2026 14:43:12 +0000 Subject: [PATCH 10/19] refactor: remove backend precompute interpreter and scheduler --- Cargo.lock | 14 +- Cargo.toml | 12 +- .../precompute_engine/maintenance_runtime.rs | 1727 +---------------- data_plane/src/precompute_engine/mod.rs | 1 - docs/design_docs/precompute-dag-execution.md | 77 +- 5 files changed, 113 insertions(+), 1718 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4e71d1d97..0926271e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -364,7 +364,7 @@ dependencies = [ [[package]] name = "asap-aware-mapping" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=33510c2a1a387a5ec1459a36eb6221c8be88eee5#33510c2a1a387a5ec1459a36eb6221c8be88eee5" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=2280f4e6a550af1b5bc56a414ec88a5603d9fab4#2280f4e6a550af1b5bc56a414ec88a5603d9fab4" dependencies = [ "asap-types", "asap_sketchlib 0.3.0 (git+https://github.com/ProjectASAP/asap_sketchlib)", @@ -376,7 +376,7 @@ dependencies = [ [[package]] name = "asap-frontend-promql" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=33510c2a1a387a5ec1459a36eb6221c8be88eee5#33510c2a1a387a5ec1459a36eb6221c8be88eee5" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=2280f4e6a550af1b5bc56a414ec88a5603d9fab4#2280f4e6a550af1b5bc56a414ec88a5603d9fab4" dependencies = [ "asap-types", "promql-parser 0.10.0 (git+https://github.com/ProjectASAP/promql-parser?rev=9fede7eecca923c9882fe256484d00d37f8706cb)", @@ -385,7 +385,7 @@ dependencies = [ [[package]] name = "asap-frontend-sql" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=33510c2a1a387a5ec1459a36eb6221c8be88eee5#33510c2a1a387a5ec1459a36eb6221c8be88eee5" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=2280f4e6a550af1b5bc56a414ec88a5603d9fab4#2280f4e6a550af1b5bc56a414ec88a5603d9fab4" dependencies = [ "asap-sql-function-catalog", "asap-types", @@ -396,7 +396,7 @@ dependencies = [ [[package]] name = "asap-physical-operators" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=33510c2a1a387a5ec1459a36eb6221c8be88eee5#33510c2a1a387a5ec1459a36eb6221c8be88eee5" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=2280f4e6a550af1b5bc56a414ec88a5603d9fab4#2280f4e6a550af1b5bc56a414ec88a5603d9fab4" dependencies = [ "asap-types", "asap_sketch_codec", @@ -416,12 +416,12 @@ dependencies = [ [[package]] name = "asap-sql-function-catalog" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=33510c2a1a387a5ec1459a36eb6221c8be88eee5#33510c2a1a387a5ec1459a36eb6221c8be88eee5" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=2280f4e6a550af1b5bc56a414ec88a5603d9fab4#2280f4e6a550af1b5bc56a414ec88a5603d9fab4" [[package]] name = "asap-types" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=33510c2a1a387a5ec1459a36eb6221c8be88eee5#33510c2a1a387a5ec1459a36eb6221c8be88eee5" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=2280f4e6a550af1b5bc56a414ec88a5603d9fab4#2280f4e6a550af1b5bc56a414ec88a5603d9fab4" dependencies = [ "serde", "serde_json", @@ -443,7 +443,7 @@ dependencies = [ [[package]] name = "asap_sketch_codec" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=33510c2a1a387a5ec1459a36eb6221c8be88eee5#33510c2a1a387a5ec1459a36eb6221c8be88eee5" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=2280f4e6a550af1b5bc56a414ec88a5603d9fab4#2280f4e6a550af1b5bc56a414ec88a5603d9fab4" dependencies = [ "asap_sketchlib 0.3.0 (git+https://github.com/ProjectASAP/asap_sketchlib?rev=5f03ccbd798ed5fec62bdd839bcb331123cab369)", "prost", diff --git a/Cargo.toml b/Cargo.toml index 72c398a78..62778513e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,10 +14,10 @@ version = "0.1.0" [workspace.dependencies] # Keep Planner frontends, selection, and IR on the same immutable revision. # Alias upstream asap-types because this workspace also defines asap_types. -planner-types = { package = "asap-types", git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "33510c2a1a387a5ec1459a36eb6221c8be88eee5" } -asap-aware-mapping = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "33510c2a1a387a5ec1459a36eb6221c8be88eee5" } -asap-frontend-promql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "33510c2a1a387a5ec1459a36eb6221c8be88eee5" } -asap-frontend-sql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "33510c2a1a387a5ec1459a36eb6221c8be88eee5" } +planner-types = { package = "asap-types", git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "2280f4e6a550af1b5bc56a414ec88a5603d9fab4" } +asap-aware-mapping = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "2280f4e6a550af1b5bc56a414ec88a5603d9fab4" } +asap-frontend-promql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "2280f4e6a550af1b5bc56a414ec88a5603d9fab4" } +asap-frontend-sql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "2280f4e6a550af1b5bc56a414ec88a5603d9fab4" } # Shared external deps (used by 2+ crates) serde = { version = "1.0", features = ["derive"] } @@ -37,8 +37,8 @@ arc-swap = "1.7" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } # Internal crates -asap-physical-operators = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "33510c2a1a387a5ec1459a36eb6221c8be88eee5" } -asap_sketch_codec = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "33510c2a1a387a5ec1459a36eb6221c8be88eee5" } +asap-physical-operators = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "2280f4e6a550af1b5bc56a414ec88a5603d9fab4" } +asap_sketch_codec = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "2280f4e6a550af1b5bc56a414ec88a5603d9fab4" } asap_types = { path = "crates/asap_types" } asap_otel_proto = { path = "crates/asap_otel_proto" } indexmap = { version = "2.0", features = ["serde"] } diff --git a/data_plane/src/precompute_engine/maintenance_runtime.rs b/data_plane/src/precompute_engine/maintenance_runtime.rs index 0f75b2ad3..45e7eb701 100644 --- a/data_plane/src/precompute_engine/maintenance_runtime.rs +++ b/data_plane/src/precompute_engine/maintenance_runtime.rs @@ -1,14 +1,9 @@ -//! Production adapter from installed post-ASAP maintenance DAGs to summary state. +//! Bind immutable inputs to retained physical graphs and publish their stored outputs. use super::output_sink::OutputSink; -use super::subdag_scheduler::{ - execute_precompute_sink, execute_precompute_sinks, IdempotentCommitSink, - MaterializationCommitKey, PrecomputeOperatorRegistry, ScheduleError, -}; use crate::storage_engines::types::{ AggregateCore, InstalledPrecomputePlanHandle, PrecomputedOutput, }; -use asap_physical_operators::dag::RunContext; use asap_types::executable_plan::{BackendExecutableBinding, BackendNodeBinding}; use planner_types::post_asap::{ExecutableDagNode, ExecutableOperatorPayload, PostAsapNodeId}; use sha2::{Digest, Sha256}; @@ -19,458 +14,37 @@ type MaintenanceError = Box; type SummaryState = Arc; type Population = BTreeMap; -type PopulationStates = BTreeMap>; -type PopulationRows = BTreeMap>; - -fn maintenance_context( - start: i64, - end: i64, - revision: u64, -) -> Result { - RunContext::new( - asap_physical_operators::dag::Scope::Ingestion { - window_start_ms: start, - window_end_ms: end, - revision, - }, - asap_physical_operators::dag::Limits::default(), - ) - .map_err(Into::into) -} -#[derive(Clone)] -enum MaintenanceValue { - Summary { - state: SummaryState, - family: Option, - }, - // A collection is retained until the DAG explicitly reduces it. Evaluating - // the whole DAG once per source pane would change nested reductions. - SummaryWindows { - states: PopulationStates, - family: planner_types::post_asap::SummaryFamilyType, - }, - Rows { - values: PopulationRows, - name: String, - timestamped: bool, - }, +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +struct PublicationKey { + plan_id: u64, + plan_version: u64, + stored_output: asap_types::sds::StoredOutputId, + window_start_ms: i64, + window_end_ms: i64, + input_lineage: Vec, } -impl MaintenanceValue { - fn summary(state: SummaryState) -> Self { - Self::Summary { - state, - family: None, - } - } - - fn state(&self) -> Result<&SummaryState, MaintenanceError> { - match self { - Self::Summary { state, .. } => Ok(state), - Self::SummaryWindows { states, .. } - if states.len() == 1 - && states - .values() - .next() - .is_some_and(|windows| windows.len() == 1) => - { - Ok(&states.values().next().unwrap()[0].1) - } - Self::Rows { .. } | Self::SummaryWindows { .. } => { - Err("maintenance sink requires an explicit reduction to one summary state".into()) - } - } - } -} type PendingOutput = ( - Option<(MaterializationCommitKey, u64)>, + Option<(PublicationKey, u64)>, PrecomputedOutput, Box, ); enum MaintenanceInputs<'a> { - Live { - definition: asap_types::sds::StoredOutputId, - state: SummaryState, - }, Frozen(&'a [crate::storage_engines::sketch_db::index::FrozenExactWindows]), Complete(&'a crate::storage_engines::sketch_db::index::CompleteRawMaintenanceCohort), } impl MaintenanceInputs<'_> { - fn frozen_inputs( - &self, - ) -> Option<&[crate::storage_engines::sketch_db::index::FrozenExactWindows]> { + fn frozen_inputs(&self) -> &[crate::storage_engines::sketch_db::index::FrozenExactWindows] { match self { - Self::Live { .. } => None, - Self::Frozen(inputs) => Some(inputs), - Self::Complete(cohort) => Some(cohort.inputs()), - } - } -} - -fn frozen_population_value( - inputs: &[crate::storage_engines::sketch_db::index::FrozenExactWindows], - definition: asap_types::sds::StoredOutputId, - family: Option, - complete: bool, -) -> Result, MaintenanceError> { - let mut states = PopulationStates::new(); - for input in inputs.iter().filter(|input| input.definition == definition) { - if !complete && !states.is_empty() { - return Err("maintenance frontier requires explicit population routing".into()); - } - let windows = input - .windows - .iter() - .map(|((_, end), state)| (*end as i64, Arc::clone(state))) - .collect::>() - .into(); - if states.insert(input.group.clone(), windows).is_some() { - return Err("maintenance frontier repeats a logical population".into()); - } - } - if states.is_empty() { - return Ok(None); - } - Ok(Some(MaintenanceValue::SummaryWindows { - states, - family: family.ok_or("immutable source lacks a summary schema")?, - })) -} - -struct OperatorAdapter<'a> { - binding: &'a BackendExecutableBinding, - inputs: MaintenanceInputs<'a>, - configs: &'a [asap_types::aggregation_config::PrecomputeMaterialization], -} - -impl PrecomputeOperatorRegistry for OperatorAdapter<'_> { - type Error = MaintenanceError; - - fn materialized_input( - &self, - node: &ExecutableDagNode, - ) -> Result, MaintenanceError> { - let definition = match self.binding.node(node.id) { - Some(BackendNodeBinding::Materialization { stored_output }) => *stored_output, - _ => return Ok(None), - }; - let family = node.output_schema.fields.iter().find_map(|field| { - (!matches!( - field.dtype, - planner_types::post_asap::SummaryFamilyType::Plain(_) - )) - .then(|| field.dtype.clone()) - }); - match &self.inputs { - MaintenanceInputs::Live { - definition: source, - state, - } if definition == *source => Ok(Some(MaintenanceValue::Summary { - state: Arc::clone(state), - family, - })), - MaintenanceInputs::Live { .. } => Ok(None), - MaintenanceInputs::Frozen(inputs) => { - frozen_population_value(inputs, definition, family, false) - } - MaintenanceInputs::Complete(cohort) => { - frozen_population_value(cohort.inputs(), definition, family, true) - } - } - } - - fn output_bytes(&self, value: &MaintenanceValue) -> usize { - fn labels(group: &Population) -> usize { - group.iter().map(|(k, v)| k.len() + v.len()).sum() - } - match value { - MaintenanceValue::Summary { state, .. } => state.approx_memory_bytes(), - MaintenanceValue::SummaryWindows { states, .. } => states - .iter() - .map(|(group, windows)| { - labels(group) - + windows - .iter() - .map(|(_, state)| 8 + state.approx_memory_bytes()) - .sum::() - }) - .sum(), - MaintenanceValue::Rows { values, name, .. } => { - name.len() - + values - .iter() - .map(|(group, rows)| { - labels(group) + rows.len() * std::mem::size_of::<(i64, f64)>() - }) - .sum::() - } - } - } - - fn execute( - &self, - node: &ExecutableDagNode, - inputs: &[Arc], - context: RunContext, - ) -> Result { - if node.output_state.timing != planner_types::post_asap::ExecutionTiming::IngestionTime { - return Err("ingestion executor received a query-time node".into()); - } - match &node.payload { - ExecutableOperatorPayload::SummaryMerge => merge_inputs(inputs, &context), - ExecutableOperatorPayload::Binary { operator } => { - if !self.inputs.frozen_inputs().is_some() - || node.output_state - != planner_types::post_asap::ExecutionDataState::INGESTION_ROWS - { - return Err("maintenance binary requires immutable completed row inputs".into()); - } - evaluate_aligned_binary(node, operator, inputs, &context) - } - - ExecutableOperatorPayload::Value { - operation: planner_types::post_asap::ValueOperation::FinalizeExactAccumulator, - } => { - if !self.inputs.frozen_inputs().is_some() { - return Err( - "maintenance finalization requires immutable completed input windows" - .into(), - ); - } - finalize_exact(node, inputs, &context) - } - ExecutableOperatorPayload::SummaryAgg { - family, - input, - grouping, - .. - } => { - let [value] = inputs else { - return Err("maintenance SummaryAgg requires exactly one row input".into()); - }; - let MaintenanceValue::Rows { values, name, .. } = value.as_ref() else { - return Err("maintenance SummaryAgg requires a typed update evaluator; finalize summary state before applying an update".into()); - }; - if !self.inputs.frozen_inputs().is_some() { - return Err( - "maintenance aggregation requires immutable completed input windows".into(), - ); - } - let target = match self.binding.node(node.id) { - Some(BackendNodeBinding::Materialization { stored_output }) => stored_output, - _ => { - return Err( - "maintenance SummaryAgg lacks installed materialization binding".into(), - ) - } - }; - let config = self - .configs - .iter() - .find(|config| config.policy_fingerprint() == target.fingerprint()) - .ok_or("maintenance SummaryAgg lacks installed accumulator configuration")?; - asap_types::precompute_plan::validate_maintenance_reduction(config, node)?; - let sources = self - .inputs - .frozen_inputs() - .ok_or("maintenance aggregation requires frozen sources")?; - for source in sources { - let source_config = self - .configs - .iter() - .find(|config| { - config.policy_fingerprint() == source.definition.fingerprint() - }) - .ok_or("maintenance input lacks installed source configuration")?; - validate_maintenance_grouping( - config, - source_config, - node, - source.singleton_population_complete - || matches!(&self.inputs, MaintenanceInputs::Complete(_)), - )?; - } - if config.accumulator_spec()?.family != *family { - return Err( - "maintenance SummaryAgg family differs from installed configuration".into(), - ); - } - if input.item.is_some() { - return Err( - "keyed maintenance updates require explicit row identity routing".into(), - ); - } - let _ = grouping; - let output_groups = values - .keys() - .map(|group| { - if config.partitioning - == Some(asap_types::sds::PopulationPartitioning::PerEntity) - { - return Ok(group.clone()); - } - config - .grouping_labels - .names() - .into_iter() - .map(|key| { - group - .get(&key) - .cloned() - .map(|value| (key.to_string(), value)) - .ok_or_else(|| { - "maintenance output grouping key is absent".to_string() - }) - }) - .collect::>() - }) - .collect::, String>>()?; - if output_groups.len() != 1 { - return Err( - "maintenance sink requires one explicitly reduced output population".into(), - ); - } - use asap_physical_operators::dag::{operators::Operator, values::Value}; - let schema = native_schema(vec![ - ( - "value", - planner_types::post_asap::SummaryFamilyType::Plain( - planner_types::pre_asap::DataType::Float64, - ), - ), - ( - "time", - planner_types::post_asap::SummaryFamilyType::Plain( - planner_types::pre_asap::DataType::Timestamp, - ), - ), - ]); - let rows = values - .values() - .flatten() - .map(|(time, value)| { - Ok(vec![ - Value::Float64(evaluate_weight(&input.weight, *value, name)?), - Value::Timestamp(*time), - ]) - }) - .collect::, MaintenanceError>>()?; - let builder = - Operator::summary_build(schema.clone(), family.clone(), 0, Some(1), vec![])?; - let mut result = native_rows(schema, rows, vec![builder], &context)?; - let Some(Value::Summary { state, .. }) = result.pop().and_then(|mut row| row.pop()) - else { - return Err("native summary builder did not return state".into()); - }; - let timestamp = values - .values() - .flatten() - .map(|(timestamp, _)| *timestamp) - .max() - .ok_or("maintenance aggregation has no input rows")?; - Ok(MaintenanceValue::SummaryWindows { - states: BTreeMap::from([( - output_groups.into_iter().next().unwrap(), - vec![(timestamp, state)].into(), - )]), - family: family.clone(), - }) - } - payload => Err(format!( - "maintenance operator {payload:?} has no summary-state implementation" - ) - .into()), + Self::Frozen(inputs) => inputs, + Self::Complete(cohort) => cohort.inputs(), } } } -// These functions translate deployment values into native batches. Window and -// catalog checks stay here; the library owns all computation on batch values. -fn native_schema( - fields: Vec<(&str, planner_types::post_asap::SummaryFamilyType)>, -) -> asap_physical_operators::dag::values::Schema { - Arc::new(planner_types::post_asap::SummarySchema { - fields: fields - .into_iter() - .map(|(name, dtype)| planner_types::post_asap::SummaryField { - name: name.into(), - dtype, - nullable: false, - }) - .collect(), - time_index: None, - }) -} -fn native_rows( - schema: asap_physical_operators::dag::values::Schema, - rows: Vec>, - operators: Vec, - context: &RunContext, -) -> Result>, MaintenanceError> { - use asap_physical_operators::dag::{batch_execution::evaluate_batch, values::Batch}; - let input = Batch::try_new(schema, rows)?; - Ok(evaluate_batch(input, operators, context.clone())? - .into_iter() - .flat_map(|b| b.rows().to_vec()) - .collect()) -} -fn native_arithmetic( - op: &planner_types::post_asap::BinaryOperator, - inputs: Vec<(i64, f64, f64)>, - context: &RunContext, -) -> Result, MaintenanceError> { - use asap_physical_operators::dag::{ - operators::{Expression, Operator}, - values::Value, - }; - use planner_types::{post_asap::SummaryFamilyType, pre_asap::DataType}; - let schema = native_schema(vec![ - ("time", SummaryFamilyType::Plain(DataType::Timestamp)), - ("left", SummaryFamilyType::Plain(DataType::Float64)), - ("right", SummaryFamilyType::Plain(DataType::Float64)), - ]); - let project = Operator::project( - schema.clone(), - vec![ - ("time".into(), Expression::Column(0)), - ( - "value".into(), - Expression::Binary { - operator: op.clone(), - left: Box::new(Expression::Column(1)), - right: Box::new(Expression::Column(2)), - }, - ), - ], - )?; - let rows = native_rows( - schema, - inputs - .into_iter() - .map(|(time, left, right)| { - vec![ - Value::Timestamp(time), - Value::Float64(left), - Value::Float64(right), - ] - }) - .collect(), - vec![project], - context, - )?; - rows.into_iter() - .map(|row| match row.as_slice() { - [Value::Timestamp(time), Value::Float64(value)] if value.is_finite() => { - Ok((*time, *value)) - } - _ => Err("maintenance binary produced a non-finite update".into()), - }) - .collect() -} - fn validate_maintenance_grouping( target: &asap_types::PrecomputeMaterialization, source: &asap_types::PrecomputeMaterialization, @@ -496,340 +70,6 @@ fn validate_maintenance_grouping( Err("maintenance population reduction requires a complete singleton source or synchronized grouping".into()) } -fn evaluate_weight( - expression: &planner_types::post_asap::SummaryInputExpr, - value: f64, - name: &str, -) -> Result { - use planner_types::{post_asap::SummaryInputExpr, pre_asap::ColumnRef}; - let weight = match expression { - SummaryInputExpr::Constant(value) => *value, - SummaryInputExpr::Column(ColumnRef::SampleValue) => value, - SummaryInputExpr::Column(ColumnRef::Named(column)) if column == name => value, - _ => { - return Err("maintenance update does not resolve against the supplied typed row".into()) - } - }; - if !weight.is_finite() { - return Err("maintenance update weight is not finite".into()); - } - Ok(weight) -} - -// Rows carry only a value and its window timestamp. Reject any schema that -// would require silently dropping another column or manufacturing a timestamp. -fn maintenance_float64_column( - node: &ExecutableDagNode, -) -> Result<&planner_types::post_asap::SummaryField, MaintenanceError> { - use planner_types::post_asap::SummaryFamilyType; - let fields = &node.output_schema.fields; - let field = match node.output_schema.time_index { - None if fields.len() == 1 => &fields[0], - Some(time_index) if fields.len() == 2 && time_index < 2 => { - let timestamp = &fields[time_index]; - let value = &fields[1 - time_index]; - if timestamp.nullable - || timestamp.name == value.name - || !matches!( - timestamp.dtype, - SummaryFamilyType::Plain(planner_types::pre_asap::DataType::Timestamp) - ) - { - return Err("finalization timestamp column differs from its typed schema".into()); - } - value - } - _ => { - return Err( - "exact maintenance finalization requires one value and optional declared timestamp" - .into(), - ) - } - }; - if field.nullable - || !matches!( - field.dtype, - SummaryFamilyType::Plain(planner_types::pre_asap::DataType::Float64) - ) - { - return Err( - "exact maintenance finalization currently requires a Float64 output column".into(), - ); - } - Ok(field) -} - -fn evaluate_aligned_binary( - node: &ExecutableDagNode, - operator: &planner_types::post_asap::BinaryOperator, - inputs: &[Arc], - context: &RunContext, -) -> Result { - use planner_types::pre_asap::BinaryOpKind; - let BinaryOpKind::Arithmetic(_) = &operator.kind else { - return Err("maintenance binary currently requires arithmetic".into()); - }; - if operator.vector_match.is_some() { - return Err( - "maintenance binary requires explicit population routing for vector matching".into(), - ); - } - let name = maintenance_float64_column(node)?.name.clone(); - if node.output_schema.time_index.is_none() { - return Err("maintenance binary requires declared window timestamps".into()); - } - let [left, right] = inputs else { - return Err("maintenance binary requires two row inputs".into()); - }; - let ( - MaintenanceValue::Rows { - values: left, - timestamped: true, - .. - }, - MaintenanceValue::Rows { - values: right, - timestamped: true, - .. - }, - ) = (left.as_ref(), right.as_ref()) - else { - return Err("maintenance binary requires finalized row inputs".into()); - }; - if left.is_empty() || left.keys().ne(right.keys()) { - return Err("maintenance binary requires matching nonempty population sets".into()); - } - let mut values = PopulationRows::new(); - for (group, left) in left { - let right = &right[group]; - if left.is_empty() || left.len() != right.len() { - return Err("maintenance binary requires matching nonempty timestamp sets".into()); - } - // Canonical timestamp maps accept arrival-order differences, but never - // collapse duplicate updates or pair unrelated source windows by position. - let mut left_rows = BTreeMap::new(); - let mut right_rows = BTreeMap::new(); - for (rows, index) in [(left, &mut left_rows), (right, &mut right_rows)] { - for &(timestamp, value) in rows { - if !value.is_finite() || index.insert(timestamp, value).is_some() { - return Err( - "maintenance binary input has duplicate timestamps or non-finite values" - .into(), - ); - } - } - } - let mut joined = Vec::with_capacity(left.len()); - for (timestamp, left) in left_rows { - let right = right_rows - .get(×tamp) - .ok_or("maintenance binary requires matching timestamp sets")?; - joined.push((timestamp, left, *right)); - } - let joined = native_arithmetic(operator, joined, context)?; - values.insert(group.clone(), joined); - } - Ok(MaintenanceValue::Rows { - values, - name, - timestamped: true, - }) -} - -fn finalize_exact( - node: &ExecutableDagNode, - inputs: &[Arc], - context: &RunContext, -) -> Result { - use planner_types::post_asap::{ExactKind, SummaryFamilyType}; - let [input] = inputs else { - return Err("exact maintenance finalization requires one summary input".into()); - }; - let (groups, family) = match input.as_ref() { - MaintenanceValue::Summary { - state, - family: Some(family), - } => { - if node.output_schema.time_index.is_some() { - return Err("timestamped finalization requires source window timestamps".into()); - } - (vec![(Population::new(), vec![(0, state)])], family) - } - MaintenanceValue::SummaryWindows { states, family } => ( - states - .iter() - .map(|(group, windows)| { - ( - group.clone(), - windows.iter().map(|(time, state)| (*time, state)).collect(), - ) - }) - .collect(), - family, - ), - _ => { - return Err("exact maintenance finalization requires a typed exact accumulator".into()) - } - }; - let SummaryFamilyType::ExactAggregate(kind, _) = family else { - return Err("exact maintenance finalization requires a typed exact accumulator".into()); - }; - let statistic = match kind { - ExactKind::Sum => asap_types::Statistic::Sum, - ExactKind::Count => asap_types::Statistic::Count, - _ => { - return Err( - "exact maintenance readout requires explicit operator/time semantics".into(), - ) - } - }; - let field = maintenance_float64_column(node)?; - let mut values = PopulationRows::new(); - for (group, states) in groups { - use asap_physical_operators::dag::{operators::Operator, values::Value}; - let schema = native_schema(vec![("state", family.clone())]); - let readout = Operator::readout(schema.clone(), 0, statistic, Default::default())?; - let rows = native_rows( - schema, - states - .iter() - .map(|(_, state)| { - vec![Value::Summary { - family: family.clone(), - state: Arc::clone(state), - }] - }) - .collect(), - vec![readout], - context, - )?; - let rows = states - .into_iter() - .zip(rows) - .map(|((timestamp, _), row)| { - let value = - match row.first() { - Some(Value::Float64(value)) => *value, - Some(Value::Int64(value)) if value.unsigned_abs() <= (1u64 << 53) => { - *value as f64 - } - _ => return Err( - "exact readout cannot be represented by the installed Float64 schema" - .to_string(), - ), - }; - if !value.is_finite() { - return Err("exact maintenance finalization produced a non-finite value".into()); - } - Ok((timestamp, value)) - }) - .collect::, String>>()?; - values.insert(group, rows); - } - Ok(MaintenanceValue::Rows { - values, - name: field.name.clone(), - timestamped: node.output_schema.time_index.is_some() - && matches!(input.as_ref(), MaintenanceValue::SummaryWindows { .. }), - }) -} - -fn merge_inputs( - inputs: &[Arc], - context: &RunContext, -) -> Result { - let mut grouped = BTreeMap::, Option)>::new(); - let mut expected_groups = None; - let mut family = None; - for input in inputs { - let (groups, input_family) = match input.as_ref() { - MaintenanceValue::Summary { state, family } => ( - vec![(Population::new(), vec![(None, state)])], - family.as_ref(), - ), - MaintenanceValue::SummaryWindows { states, family } => ( - states - .iter() - .map(|(group, windows)| { - ( - group.clone(), - windows - .iter() - .map(|(time, state)| (Some(*time), state)) - .collect(), - ) - }) - .collect(), - Some(family), - ), - MaintenanceValue::Rows { .. } => return Err("summary merge cannot consume rows".into()), - }; - let keys: BTreeSet<_> = groups.iter().map(|(group, _)| group.clone()).collect(); - if expected_groups - .as_ref() - .is_some_and(|expected| expected != &keys) - { - return Err("summary merge cannot collapse different populations".into()); - } - expected_groups = Some(keys); - for (group, windows) in groups { - let (states, end) = grouped.entry(group).or_default(); - for (timestamp, state) in windows { - states.push(state); - *end = (*end).max(timestamp); - } - } - if let Some(input_family) = input_family { - if family.as_ref().is_some_and(|family| family != input_family) { - return Err("summary merge input families differ".into()); - } - family = Some(input_family.clone()); - } - } - if grouped.is_empty() { - return Err("summary maintenance node has no input state".into()); - } - let mut result = PopulationStates::new(); - for (group, (states, timestamp)) in grouped { - let Some((first, rest)) = states.split_first() else { - return Err("summary maintenance population has no input state".into()); - }; - let state_family = family.clone().or_else(|| { - first.as_any().downcast_ref::().map(|state| state.family().clone()) - }).or_else(|| first.as_any().is::().then_some( - planner_types::post_asap::SummaryFamilyType::ExactAggregate(planner_types::post_asap::ExactKind::Sum, planner_types::post_asap::ExactParams::Sum) - )).ok_or("summary merge requires a registered state family")?; - use asap_physical_operators::dag::{operators::Operator, values::Value}; - let schema = native_schema(vec![("state", state_family.clone())]); - let rows = std::iter::once(*first) - .chain(rest.iter().copied()) - .map(|state| { - vec![Value::Summary { - family: state_family.clone(), - state: Arc::clone(state), - }] - }) - .collect(); - let merge = Operator::summary_merge(schema.clone(), 0, vec![])?; - let mut output = native_rows(schema, rows, vec![merge], context)?; - let Some(Value::Summary { state: merged, .. }) = output.pop().and_then(|mut row| row.pop()) - else { - return Err("native summary merge did not return state".into()); - }; - let Some(timestamp) = timestamp else { - return Ok(MaintenanceValue::Summary { - state: merged, - family, - }); - }; - result.insert(group, vec![(timestamp, merged)].into()); - } - Ok(MaintenanceValue::SummaryWindows { - states: result, - family: family.ok_or("merged immutable state lacks a family")?, - }) -} - fn frozen_cohort_lineage( inputs: &[crate::storage_engines::sketch_db::index::FrozenExactWindows], expected: &asap_types::derived_input::DerivedInputIdentity, @@ -902,13 +142,7 @@ fn prepare_frozen_maintenance_sink( sink: PostAsapNodeId, inputs: &[crate::storage_engines::sketch_db::index::FrozenExactWindows], output_window: (u64, u64), -) -> Result< - ( - planner_types::post_asap::ExecutableDag, - MaterializationCommitKey, - ), - MaintenanceError, -> { +) -> Result<(planner_types::post_asap::ExecutableDag, PublicationKey), MaintenanceError> { installed.validate()?; let target = match installed.binding.node(sink) { Some(BackendNodeBinding::Materialization { stored_output }) => *stored_output, @@ -971,7 +205,7 @@ fn prepare_frozen_maintenance_sink( .first() .ok_or("immutable input cohort is empty")? .generation; - let key = MaterializationCommitKey { + let key = PublicationKey { plan_id: generation.plan_id, plan_version: generation.plan_version, stored_output: target, @@ -990,15 +224,13 @@ fn execute_prepared_frozen_sink( sink: PostAsapNodeId, inputs: MaintenanceInputs<'_>, dag: &planner_types::post_asap::ExecutableDag, - key: MaterializationCommitKey, + key: PublicationKey, ) -> Result<(SummaryState, Population), MaintenanceError> { let _ = (configs, dag); let program = installed .native_program(sink)? .ok_or("installed precompute sink lacks its Planner physical graph")?; - let inputs = inputs - .frozen_inputs() - .ok_or("compiled precompute requires immutable inputs")?; + let inputs = inputs.frozen_inputs(); let batch = super::native_precompute::execute( installed, &program, @@ -1779,22 +1011,22 @@ pub(crate) fn execute_finite_maintenance( } struct CommittedState { - value: Option>, + value: Option, published: bool, } #[derive(Default)] struct CommitRegistryState { generation: Option<(u64, u64)>, - entries: BTreeMap, + entries: BTreeMap, frontiers: BTreeMap, pending_batch: Option<[u8; 32]>, batch_has_published: bool, - admitted_keys: BTreeSet, + admitted_keys: BTreeSet, } impl CommitRegistryState { - fn validate_key(&self, key: &MaterializationCommitKey) -> Result<(), String> { + fn validate_key(&self, key: &PublicationKey) -> Result<(), String> { if self .generation .is_some_and(|generation| generation != (key.plan_id, key.plan_version)) @@ -1878,7 +1110,7 @@ impl CommitRegistry { fn complete_batch( &self, digest: [u8; 32], - completed: &[(MaterializationCommitKey, u64)], + completed: &[(PublicationKey, u64)], ) -> Result<(), String> { let mut state = self.0.lock().map_err(|_| "commit registry poisoned")?; if state.pending_batch != Some(digest) { @@ -1914,7 +1146,7 @@ impl CommitRegistry { Ok(()) } - fn pin_admitted(&self, key: &MaterializationCommitKey) -> Result<(), String> { + fn pin_admitted(&self, key: &PublicationKey) -> Result<(), String> { let mut state = self.0.lock().map_err(|_| "commit registry poisoned")?; if state.pending_batch.is_none() || state.generation != Some((key.plan_id, key.plan_version)) @@ -1925,14 +1157,14 @@ impl CommitRegistry { Ok(()) } - fn is_published(&self, key: &MaterializationCommitKey) -> Result { + fn is_published(&self, key: &PublicationKey) -> Result { let state = self.0.lock().map_err(|_| "commit registry poisoned")?; state.validate_key(key)?; Ok(state.entries.get(key).is_some_and(|entry| entry.published)) } fn publish( &self, - key: &MaterializationCommitKey, + key: &PublicationKey, emit: impl FnOnce() -> Result<(), Box>, ) -> Result<(), Box> { // Serialize acknowledgement with publication so a concurrent replay @@ -1955,13 +1187,8 @@ impl CommitRegistry { } } -impl IdempotentCommitSink for CommitRegistry { - type Error = String; - - fn get( - &self, - key: &MaterializationCommitKey, - ) -> Result>, Self::Error> { +impl CommitRegistry { + fn get(&self, key: &PublicationKey) -> Result, String> { let state = self.0.lock().map_err(|_| "commit registry poisoned")?; state.validate_key(key)?; Ok(state @@ -1972,9 +1199,9 @@ impl IdempotentCommitSink for CommitRegistry { fn commit_if_absent( &self, - key: MaterializationCommitKey, - value: Arc, - ) -> Result, Self::Error> { + key: PublicationKey, + value: SummaryState, + ) -> Result { let mut commits = self.0.lock().map_err(|_| "commit registry poisoned")?; commits.validate_key(&key)?; let committed = commits @@ -2053,14 +1280,6 @@ impl PrecomputeDagSink { if source_nodes.is_empty() { continue; } - let adapter = OperatorAdapter { - binding: &installed.binding, - inputs: MaintenanceInputs::Live { - definition: source_definition, - state: Arc::clone(&source), - }, - configs: &plan.precompute_plan.materializations, - }; let mut selected_outputs = Vec::new(); let mut horizons = Vec::new(); for sink_node in &installed.binding.precompute_sinks { @@ -2112,7 +1331,7 @@ impl PrecomputeDagSink { Some(BackendNodeBinding::Materialization { stored_output }) => *stored_output, _ => return Err("precompute sink lacks materialization binding".into()), }; - let key = MaterializationCommitKey { + let key = PublicationKey { plan_id: plan.plan_id(), plan_version: plan.plan_version(), stored_output: target, @@ -2146,19 +1365,19 @@ impl PrecomputeDagSink { selected_outputs.push((*sink_node, key)); horizons.push(horizon_ms); } - let values = execute_precompute_sinks( - &dag, - &installed.binding, - &selected_outputs, - &adapter, - &self.commits, - maintenance_context( - output.start_timestamp as i64, - output.end_timestamp as i64, - plan.plan_version(), - )?, - ) - .map_err(schedule_error)?; + let values = selected_outputs + .iter() + .map(|(sink, key)| { + if !source_nodes.contains(sink) { + return Err( + "derived outputs require an installed immutable precompute graph" + .to_string(), + ); + } + self.commits + .commit_if_absent(key.clone(), Arc::clone(&source)) + }) + .collect::, _>>()?; for (((_, key), horizon_ms), value) in selected_outputs.into_iter().zip(horizons).zip(values) { @@ -2169,7 +1388,7 @@ impl PrecomputeDagSink { derived.push(( Some((key, horizon_ms)), target_output, - value.state()?.clone_boxed_core(), + value.clone_boxed_core(), )); } } @@ -2181,14 +1400,6 @@ impl PrecomputeDagSink { } } -fn schedule_error(error: ScheduleError) -> MaintenanceError { - match error { - ScheduleError::Invalid(e) | ScheduleError::Sink(e) => e.into(), - ScheduleError::Operator(e) => e, - ScheduleError::Execution(e) => Box::new(e), - } -} - fn depends_on_any( dag: &planner_types::post_asap::ExecutableDag, sink: PostAsapNodeId, @@ -2372,18 +1583,6 @@ pub(crate) fn affected_materializations( #[cfg(test)] mod tests { - fn test_context() -> asap_physical_operators::dag::RunContext { - use asap_physical_operators::dag::{Limits, RunContext, Scope}; - RunContext::new( - Scope::Ingestion { - window_start_ms: 0, - window_end_ms: 10_000, - revision: 1, - }, - Limits::default(), - ) - .unwrap() - } use super::*; use asap_physical_operators::summary_kernels::SumAccumulator; @@ -2492,40 +1691,6 @@ mod tests { } // Ingestion adapters must execute native operators in the parent's scope. - #[test] - fn native_merge_uses_the_parent_budget_and_cancellation() { - let input = Arc::new(MaintenanceValue::summary(Arc::new( - SumAccumulator::with_sum(3.), - ))); - let context = test_context(); - let output = merge_inputs(&[Arc::clone(&input)], &context).unwrap(); - assert_eq!( - output - .state() - .unwrap() - .query_statistic(asap_types::Statistic::Sum, &None, &Default::default()) - .unwrap(), - 3. - ); - assert!(context.peak_bytes() > 0); - context.cancel(); - let error = merge_inputs(&[input], &context).err().unwrap(); - let error = error - .downcast_ref::() - .expect("native cancellation must retain its physical error type"); - fn cause( - error: &asap_physical_operators::dag::Error, - ) -> &asap_physical_operators::dag::Error { - match error { - asap_physical_operators::dag::Error::AtNode { source, .. } => cause(source), - error => error, - } - } - assert_eq!( - cause(error), - &asap_physical_operators::dag::Error::Cancelled - ); - } fn node(id: u32) -> ExecutableDagNode { ExecutableDagNode { @@ -2562,98 +1727,10 @@ mod tests { } #[test] - fn frozen_adapter_resolves_each_materialized_frontier_without_aliasing() { - use planner_types::post_asap::{ExactKind, ExactParams, SummaryFamilyType, SummaryField}; - let make = |id| crate::storage_engines::sketch_db::index::FrozenExactWindows { - stored_output_reference: asap_types::sds::StoredOutputReference::for_output( - definition(id), - ), - storage_handle: id, - definition: definition(id), - generation: Arc::new(asap_types::sds::CatalogGeneration { - schema_version: 2, - plan_id: 1, - plan_version: 1, - snapshot_sha256: "0".repeat(64), - }), - group: BTreeMap::new(), - windows: BTreeMap::from([((0, 1000), sum(id as f64))]), - singleton_population_complete: false, - }; - let inputs = [make(1), make(2)]; - let binding = BackendExecutableBinding { - nodes: [(1, definition(1)), (2, definition(2)), (3, definition(3))] - .into_iter() - .map(|(id, stored_output)| { - ( - PostAsapNodeId(id), - BackendNodeBinding::Materialization { stored_output }, - ) - }) - .collect(), - query_sink: PostAsapNodeId(3), - query_plan_sink: asap_types::query_plan::QueryNodeId(3), - precompute_sinks: vec![PostAsapNodeId(3)], - }; - let adapter = OperatorAdapter { - binding: &binding, - inputs: MaintenanceInputs::Frozen(&inputs), - configs: &[], - }; - let source_node = |id| { - let mut source = node(id); - source.output_schema.fields = vec![SummaryField { - name: "state".into(), - dtype: SummaryFamilyType::ExactAggregate(ExactKind::Sum, ExactParams::Sum), - nullable: false, - }]; - source - }; - let first = adapter - .materialized_input(&source_node(1)) - .unwrap() - .unwrap(); - let second = adapter - .materialized_input(&source_node(2)) - .unwrap() - .unwrap(); - assert!(adapter - .materialized_input(&source_node(3)) - .unwrap() - .is_none()); - let merged = adapter - .execute( - &node(3), - &[Arc::new(first), Arc::new(second)], - test_context(), - ) - .unwrap(); - assert_eq!( - merged - .state() - .unwrap() - .query_statistic( - asap_types::Statistic::Sum, - &None, - &std::collections::HashMap::new() - ) - .unwrap(), - 3.0 - ); - let ambiguous = [make(1), make(1)]; - let adapter = OperatorAdapter { - binding: &binding, - inputs: MaintenanceInputs::Frozen(&ambiguous), - configs: &[], - }; - assert!(adapter.materialized_input(&source_node(1)).is_err()); - } - - #[test] - fn finalized_summary_update_builds_a_different_installed_family() { - use planner_types::post_asap::{ - ExactKind, ExactParams, GroupingStrategy, SummaryFamilyType, SummaryField, - SummaryInputExpr, SummaryUpdate, + fn finalized_summary_update_builds_a_different_installed_family() { + use planner_types::post_asap::{ + ExactKind, ExactParams, GroupingStrategy, SummaryFamilyType, SummaryField, + SummaryInputExpr, SummaryUpdate, }; use planner_types::pre_asap::{DataType, Reduction}; let mut snapshot: serde_json::Value = serde_json::from_str(include_str!( @@ -2709,29 +1786,6 @@ mod tests { query_plan_sink: control_plane::query_plan::QueryNodeId(3), precompute_sinks: vec![PostAsapNodeId(3)], }; - let frozen_inputs = [ - crate::storage_engines::sketch_db::index::FrozenExactWindows { - stored_output_reference: asap_types::sds::StoredOutputReference::for_output( - source_definition, - ), - storage_handle: 1, - definition: source_definition, - generation: Arc::new(asap_types::sds::CatalogGeneration { - schema_version: 2, - plan_id: 1, - plan_version: 1, - snapshot_sha256: "0".repeat(64), - }), - group: BTreeMap::new(), - windows: BTreeMap::from([((0, 1000), sum(7.0))]), - singleton_population_complete: false, - }, - ]; - let adapter = OperatorAdapter { - binding: &binding, - inputs: MaintenanceInputs::Frozen(&frozen_inputs), - configs: &configs, - }; let mut read = node(2); read.payload = ExecutableOperatorPayload::Value { operation: planner_types::post_asap::ValueOperation::FinalizeExactAccumulator, @@ -2741,14 +1795,6 @@ mod tests { dtype: SummaryFamilyType::Plain(DataType::Float64), nullable: false, }]; - let source = Arc::new(MaintenanceValue::Summary { - state: sum(7.0), - family: Some(SummaryFamilyType::ExactAggregate( - ExactKind::Sum, - ExactParams::Sum, - )), - }); - let row = adapter.execute(&read, &[source], test_context()).unwrap(); let mut aggregate = node(3); aggregate.payload = ExecutableOperatorPayload::SummaryAgg { family: target_family, @@ -2760,21 +1806,7 @@ mod tests { reduction: Reduction::by(vec![]), grouping: GroupingStrategy::default(), }; - let result = adapter - .execute(&aggregate, &[Arc::new(row)], test_context()) - .unwrap(); - let mut kwargs = std::collections::HashMap::new(); - kwargs.insert("quantile".into(), "0.5".into()); - assert_eq!( - result - .state() - .unwrap() - .query_statistic(asap_types::Statistic::Quantile, &None, &kwargs) - .unwrap(), - 3.0 - ); - // Exercise the same registry through the production topological - // scheduler, including the precomputed source frontier and commit. + let kwargs = std::collections::HashMap::from([("quantile".into(), "0.5".into())]); let mut source_node = node(1); source_node.output_schema.fields = vec![SummaryField { name: "state".into(), @@ -2820,36 +1852,6 @@ mod tests { scheduled_binding.query_sink = PostAsapNodeId(4); scheduled_binding.query_plan_sink = control_plane::query_plan::QueryNodeId(4); let (dag, scheduled_binding) = maintenance_only(dag, scheduled_binding); - let scheduled_adapter = OperatorAdapter { - binding: &scheduled_binding, - ..adapter - }; - let key = MaterializationCommitKey { - plan_id: 1, - plan_version: 1, - stored_output: target, - window_start_ms: 0, - window_end_ms: 1000, - input_lineage: vec![1], - }; - let committed = execute_precompute_sink( - &dag, - &scheduled_binding, - PostAsapNodeId(3), - key, - &scheduled_adapter, - &CommitRegistry::default(), - test_context(), - ) - .unwrap(); - assert_eq!( - committed - .state() - .unwrap() - .query_statistic(asap_types::Statistic::Quantile, &None, &kwargs) - .unwrap(), - 3.0 - ); // The real store provides the completion proof. Execute, persist, // restart, and retry the same installed subDAG without additive append. use crate::storage_engines::sketch_db::index::{ @@ -3166,12 +2168,6 @@ mod tests { exercise_two_source_completed_sink(&dag, &configs, &scheduled_binding, false, false); exercise_two_source_completed_sink(&dag, &configs, &scheduled_binding, true, true); exercise_two_source_completed_sink(&dag, &configs, &scheduled_binding, false, true); - assert!(evaluate_weight( - &SummaryInputExpr::Column(planner_types::pre_asap::ColumnRef::Named("missing".into())), - 7.0, - "value" - ) - .is_err()); } fn exercise_two_source_completed_sink( @@ -3615,458 +2611,11 @@ mod tests { persistence.shutdown(); } - #[test] - fn maintenance_binary_aligns_windows_and_rejects_incomplete_or_ambiguous_rows() { - use planner_types::post_asap::{BinaryOperator, SummaryFamilyType, SummaryField}; - use planner_types::pre_asap::{ArithmeticOpKind, BinaryOpKind, DataType}; - let mut operation = node(10); - operation.output_schema.fields = vec![ - SummaryField { - name: "ts".into(), - dtype: SummaryFamilyType::Plain(DataType::Timestamp), - nullable: false, - }, - SummaryField { - name: "value".into(), - dtype: SummaryFamilyType::Plain(DataType::Float64), - nullable: false, - }, - ]; - operation.output_schema.time_index = Some(0); - let mut operator = BinaryOperator { - checked_relative_division: false, - checked_finite_division: false, - kind: BinaryOpKind::Arithmetic(ArithmeticOpKind::Sub), - vector_match: None, - }; - let rows = |values: Vec<(i64, f64)>| { - Arc::new(MaintenanceValue::Rows { - values: BTreeMap::from([(BTreeMap::new(), values)]), - name: "value".into(), - timestamped: true, - }) - }; - let left = rows(vec![(2_000, 7.0), (1_000, 5.0)]); - let right = rows(vec![(1_000, 2.0), (2_000, 3.0)]); - // Arrival order cannot exchange windows, and subtraction retains edge order. - let MaintenanceValue::Rows { values, .. } = evaluate_aligned_binary( - &operation, - &operator, - &[left.clone(), right.clone()], - &test_context(), - ) - .unwrap() else { - panic!("expected rows") - }; - assert_eq!(values[&BTreeMap::new()], vec![(1_000, 3.0), (2_000, 4.0)]); - // Equal timestamps in different populations are separate rows, never - // added together before the DAG explicitly reduces those populations. - let a = BTreeMap::from([("instance".to_string(), "a".to_string())]); - let b = BTreeMap::from([("instance".to_string(), "b".to_string())]); - let grouped = |values| { - Arc::new(MaintenanceValue::Rows { - values, - name: "value".into(), - timestamped: true, - }) - }; - let grouped_left = grouped(BTreeMap::from([ - (a.clone(), vec![(1_000, 5.0)]), - (b.clone(), vec![(1_000, 9.0)]), - ])); - let grouped_right = grouped(BTreeMap::from([ - (a.clone(), vec![(1_000, 2.0)]), - (b.clone(), vec![(1_000, 4.0)]), - ])); - let MaintenanceValue::Rows { values, .. } = evaluate_aligned_binary( - &operation, - &operator, - &[grouped_left.clone(), grouped_right], - &test_context(), - ) - .unwrap() else { - panic!("expected grouped rows") - }; - assert_eq!( - values, - BTreeMap::from([(a.clone(), vec![(1_000, 3.0)]), (b, vec![(1_000, 5.0)]),]) - ); - assert!(evaluate_aligned_binary( - &operation, - &operator, - &[ - grouped_left, - grouped(BTreeMap::from([(a, vec![(1_000, 2.0)])])) - ], - &test_context() - ) - .is_err()); - let binding = BackendExecutableBinding { - nodes: BTreeMap::new(), - query_sink: PostAsapNodeId(10), - query_plan_sink: asap_types::query_plan::QueryNodeId(10), - precompute_sinks: vec![], - }; - let frozen = OperatorAdapter { - binding: &binding, - inputs: MaintenanceInputs::Frozen(&[]), - configs: &[], - }; - operation.payload = ExecutableOperatorPayload::Binary { - operator: operator.clone(), - }; - operation.output_state = planner_types::post_asap::ExecutionDataState::INGESTION_ROWS; - assert!(frozen - .execute(&operation, &[left.clone(), right.clone()], test_context()) - .is_ok()); - let live = OperatorAdapter { - binding: &binding, - inputs: MaintenanceInputs::Live { - definition: definition(1), - state: sum(1.0), - }, - configs: &[], - }; - assert!(live - .execute(&operation, &[left.clone(), right.clone()], test_context()) - .is_err()); - operation.payload = ExecutableOperatorPayload::Binary { - operator: operator.clone(), - }; - operation.output_state = planner_types::post_asap::ExecutionDataState::QUERY_ROWS; - assert!(frozen - .execute(&operation, &[left.clone(), right.clone()], test_context()) - .is_err()); - operation.output_state = planner_types::post_asap::ExecutionDataState::INGESTION_ROWS; - - for invalid in [ - rows(vec![]), - rows(vec![(1_000, 2.0)]), - rows(vec![(1_000, 2.0), (3_000, 3.0)]), - rows(vec![(1_000, 2.0), (1_000, 3.0)]), - rows(vec![(1_000, f64::NAN), (2_000, 3.0)]), - Arc::new(MaintenanceValue::Rows { - values: BTreeMap::from([(BTreeMap::new(), vec![(1_000, 2.0), (2_000, 3.0)])]), - name: "value".into(), - timestamped: false, - }), - Arc::new(MaintenanceValue::summary(sum(2.0))), - ] { - assert!(evaluate_aligned_binary( - &operation, - &operator, - &[left.clone(), invalid], - &test_context() - ) - .is_err()); - } - operator.kind = BinaryOpKind::Arithmetic(ArithmeticOpKind::Div); - assert!(evaluate_aligned_binary( - &operation, - &operator, - &[left.clone(), rows(vec![(1_000, 0.0), (2_000, 3.0)])], - &test_context() - ) - .is_err()); - operation.output_schema.fields[1].dtype = SummaryFamilyType::Plain(DataType::Int64); - assert!( - evaluate_aligned_binary(&operation, &operator, &[left, right], &test_context()) - .is_err() - ); - } - - #[test] - fn finalization_preserves_windows_until_an_explicit_merge() { - use planner_types::post_asap::{ExactKind, ExactParams, SummaryFamilyType, SummaryField}; - let family = SummaryFamilyType::ExactAggregate(ExactKind::Sum, ExactParams::Sum); - let inputs = Arc::new(MaintenanceValue::SummaryWindows { - states: BTreeMap::from([( - BTreeMap::new(), - vec![(1_000, sum(2.0)), (2_000, sum(7.0))].into(), - )]), - family: family.clone(), - }); - let different_group = Arc::new(MaintenanceValue::SummaryWindows { - states: BTreeMap::from([( - BTreeMap::from([("instance".into(), "other".into())]), - vec![(1_000, sum(3.0))].into(), - )]), - family, - }); - assert!(merge_inputs(&[inputs.clone(), different_group], &test_context()).is_err()); - let mut read = node(2); - read.output_schema.fields = vec![SummaryField { - name: "value".into(), - dtype: SummaryFamilyType::Plain(planner_types::pre_asap::DataType::Float64), - nullable: false, - }]; - let MaintenanceValue::Rows { values, .. } = - finalize_exact(&read, &[inputs.clone()], &test_context()).unwrap() - else { - panic!("expected finalized rows") - }; - assert_eq!(values[&BTreeMap::new()], vec![(1_000, 2.0), (2_000, 7.0)]); - // Merge is a semantic DAG operation, not an implicit batch optimization. - // Finalizing after it emits exactly one value instead of two updates. - let merged = Arc::new(merge_inputs(&[inputs], &test_context()).unwrap()); - let MaintenanceValue::Rows { values, .. } = - finalize_exact(&read, &[merged], &test_context()).unwrap() - else { - panic!("expected finalized row") - }; - assert_eq!(values[&BTreeMap::new()], vec![(2_000, 9.0)]); - read.output_schema.fields[0].dtype = - SummaryFamilyType::Plain(planner_types::pre_asap::DataType::Int64); - let integer_state = Arc::new(MaintenanceValue::Summary { - state: sum(9.0), - family: Some(SummaryFamilyType::ExactAggregate( - ExactKind::Sum, - ExactParams::Sum, - )), - }); - assert!( - matches!(finalize_exact(&read, &[integer_state], &test_context()), Err(error) if error.to_string().contains("Float64")) - ); - } - - #[test] - fn finalization_preserves_declared_timestamp_and_rejects_ambiguous_columns() { - use planner_types::post_asap::{ExactKind, ExactParams, SummaryFamilyType, SummaryField}; - use planner_types::pre_asap::DataType; - let input = Arc::new(MaintenanceValue::SummaryWindows { - states: BTreeMap::from([(BTreeMap::new(), vec![(60_000, sum(10.0))].into())]), - family: SummaryFamilyType::ExactAggregate(ExactKind::Sum, ExactParams::Sum), - }); - let mut read = node(2); - read.output_schema.fields = vec![ - SummaryField { - name: "ts".into(), - dtype: SummaryFamilyType::Plain(DataType::Timestamp), - nullable: false, - }, - SummaryField { - name: "value".into(), - dtype: SummaryFamilyType::Plain(DataType::Float64), - nullable: false, - }, - ]; - read.output_schema.time_index = Some(0); - let MaintenanceValue::Rows { values, name, .. } = - finalize_exact(&read, &[Arc::clone(&input)], &test_context()).unwrap() - else { - panic!("expected typed rows") - }; - assert_eq!(values[&BTreeMap::new()], vec![(60_000, 10.0)]); - assert_eq!(name, "value"); - let mut malformed = Vec::new(); - let mut copy = read.clone(); - copy.output_schema.time_index = None; - malformed.push(copy); - let mut copy = read.clone(); - copy.output_schema.time_index = Some(2); - malformed.push(copy); - let mut copy = read.clone(); - copy.output_schema.time_index = Some(1); - malformed.push(copy); - let mut copy = read.clone(); - copy.output_schema - .fields - .push(copy.output_schema.fields[1].clone()); - malformed.push(copy); - let mut copy = read.clone(); - copy.output_schema.fields[1].nullable = true; - malformed.push(copy); - let mut copy = read.clone(); - copy.output_schema.fields[1].name = "ts".into(); - malformed.push(copy); - for malformed in malformed { - assert!(finalize_exact(&malformed, &[Arc::clone(&input)], &test_context()).is_err()); - } - let untimed = Arc::new(MaintenanceValue::Summary { - state: sum(10.0), - family: Some(SummaryFamilyType::ExactAggregate( - ExactKind::Sum, - ExactParams::Sum, - )), - }); - assert!(finalize_exact(&read, &[untimed], &test_context()).is_err()); - } - - #[test] - fn summary_aggregation_does_not_silently_reuse_input_family() { - use planner_types::post_asap::{ - ExactKind, ExactParams, GroupingStrategy, SummaryFamilyType, SummaryUpdate, - }; - use planner_types::pre_asap::{ColumnRef, Reduction}; - - let binding = BackendExecutableBinding { - nodes: BTreeMap::new(), - query_sink: PostAsapNodeId(2), - query_plan_sink: asap_types::query_plan::QueryNodeId(2), - precompute_sinks: vec![PostAsapNodeId(1)], - }; - let adapter = OperatorAdapter { - binding: &binding, - inputs: MaintenanceInputs::Live { - definition: definition(1), - state: sum(7.0), - }, - configs: &[], - }; - let mut aggregate = node(1); - aggregate.payload = ExecutableOperatorPayload::SummaryAgg { - family: SummaryFamilyType::ExactAggregate(ExactKind::Count, ExactParams::Count), - input: SummaryUpdate::column(ColumnRef::SampleValue), - reduction: Reduction::by(vec![]), - grouping: GroupingStrategy::default(), - }; - let error = adapter.execute( - &aggregate, - &[Arc::new(MaintenanceValue::summary(sum(7.0)))], - test_context(), - ); - assert!( - matches!(error, Err(reason) if reason.to_string().contains("typed update evaluator")) - ); - } - - #[test] - fn summary_update_rejects_multiple_output_populations_before_updating() { - use planner_types::post_asap::{GroupingStrategy, SummaryUpdate}; - use planner_types::pre_asap::{ColumnRef, Reduction}; - let snapshot: control_plane::physical::compiler::BackendLocalPlanningInput = - serde_json::from_str(include_str!( - "../../../docs/examples/asapquery-planning-snapshot.json" - )) - .unwrap(); - let mut config = crate::tests::test_utilities::planning::quoted_snapshot(snapshot, false) - .compile_promql() - .unwrap() - .precompute_plan - .materializations[0] - .clone(); - config.aggregation_type = asap_types::AggregationType::Sum; - config.aggregation_sub_type = "sum".into(); - config.grouping_labels = ["instance".to_string()].into_iter().collect(); - config.partitioning = Some(asap_types::sds::PopulationPartitioning::PerEntity); - let family = config.accumulator_spec().unwrap().family; - let binding = BackendExecutableBinding { - nodes: BTreeMap::from([( - PostAsapNodeId(1), - BackendNodeBinding::Materialization { - stored_output: config.policy_fingerprint().into(), - }, - )]), - query_sink: PostAsapNodeId(1), - query_plan_sink: asap_types::query_plan::QueryNodeId(1), - precompute_sinks: vec![PostAsapNodeId(1)], - }; - let configs = [config]; - let adapter = OperatorAdapter { - binding: &binding, - inputs: MaintenanceInputs::Frozen(&[]), - configs: &configs, - }; - let mut aggregate = node(1); - aggregate.payload = ExecutableOperatorPayload::SummaryAgg { - family, - input: SummaryUpdate::column(ColumnRef::SampleValue), - reduction: Reduction::PerEntity, - grouping: GroupingStrategy::default(), - }; - let rows = MaintenanceValue::Rows { - values: ["a", "b"] - .into_iter() - .map(|name| { - ( - BTreeMap::from([("instance".into(), name.into())]), - vec![(1_000, 5.0)], - ) - }) - .collect(), - name: "value".into(), - timestamped: true, - }; - assert!( - matches!(adapter.execute(&aggregate, &[Arc::new(rows)], test_context()), - Err(error) if error.to_string().contains("one explicitly reduced output population")) - ); - } - - #[test] - fn dds_maintenance_rejects_nonpositive_population_before_returning_summary() { - use planner_types::post_asap::{GroupingStrategy, SummaryUpdate}; - use planner_types::pre_asap::{ColumnRef, Reduction}; - let snapshot: control_plane::physical::compiler::BackendLocalPlanningInput = - serde_json::from_str(include_str!( - "../../../docs/examples/asapquery-planning-snapshot.json" - )) - .unwrap(); - let mut config = crate::tests::test_utilities::planning::quoted_snapshot(snapshot, false) - .compile_promql() - .unwrap() - .precompute_plan - .materializations[0] - .clone(); - config.aggregation_type = asap_types::AggregationType::DDSketch; - config.parameters.clear(); - config - .parameters - .insert("relative_accuracy".into(), "0.01".into()); - config.aggregation_sub_type.clear(); - config.grouping_labels = std::iter::empty::().collect(); - config.partitioning = Some(asap_types::sds::PopulationPartitioning::Grouped); - let family = config.accumulator_spec().unwrap().family; - let binding = BackendExecutableBinding { - nodes: BTreeMap::from([( - PostAsapNodeId(1), - BackendNodeBinding::Materialization { - stored_output: config.policy_fingerprint().into(), - }, - )]), - query_sink: PostAsapNodeId(1), - query_plan_sink: asap_types::query_plan::QueryNodeId(1), - precompute_sinks: vec![PostAsapNodeId(1)], - }; - let configs = [config]; - let adapter = OperatorAdapter { - binding: &binding, - inputs: MaintenanceInputs::Frozen(&[]), - configs: &configs, - }; - let mut aggregate = node(1); - aggregate.payload = ExecutableOperatorPayload::SummaryAgg { - family, - input: SummaryUpdate::column(ColumnRef::SampleValue), - reduction: Reduction::by(vec![]), - grouping: GroupingStrategy::default(), - }; - for rejected in [-20.0, 0.0, f64::MAX] { - let rows = MaintenanceValue::Rows { - values: [("a", 20.0), ("b", rejected)] - .into_iter() - .map(|(group, value)| { - ( - BTreeMap::from([("instance".into(), group.into())]), - vec![(1000, value)], - ) - }) - .collect(), - name: "value".into(), - timestamped: true, - }; - assert!( - matches!(adapter.execute(&aggregate, &[Arc::new(rows)], test_context()), - Err(error) if error.to_string().contains("positive representable domain")) - ); - } - } - #[test] fn admitted_slow_worker_can_publish_behind_another_workers_replay_floor() { let commits = CommitRegistry::default(); commits.0.lock().unwrap().generation = Some((7, 1)); - let key = |end| MaterializationCommitKey { + let key = |end| PublicationKey { plan_id: 7, plan_version: 1, stored_output: definition(2), @@ -4076,18 +2625,14 @@ mod tests { }; let fast = key(1000); commits.begin_batch([1; 32]).unwrap(); - commits - .commit_if_absent(fast.clone(), Arc::new(MaintenanceValue::summary(sum(2.0)))) - .unwrap(); + commits.commit_if_absent(fast.clone(), sum(2.0)).unwrap(); commits.publish(&fast, || Ok(())).unwrap(); commits.complete_batch([1; 32], &[(fast, 30)]).unwrap(); let slow = key(10); assert!(commits.is_published(&slow).is_err()); commits.begin_batch([2; 32]).unwrap(); commits.pin_admitted(&slow).unwrap(); - commits - .commit_if_absent(slow.clone(), Arc::new(MaintenanceValue::summary(sum(3.0)))) - .unwrap(); + commits.commit_if_absent(slow.clone(), sum(3.0)).unwrap(); commits.publish(&slow, || Ok(())).unwrap(); commits .complete_batch([2; 32], &[(slow.clone(), 30)]) @@ -4101,7 +2646,7 @@ mod tests { #[test] fn maintenance_receipts_are_bounded_and_expired_retries_fail_closed() { let commits = CommitRegistry::default(); - let key = |end| MaterializationCommitKey { + let key = |end| PublicationKey { plan_id: 7, plan_version: 1, stored_output: definition(2), @@ -4112,9 +2657,7 @@ mod tests { for end in (10..=1_000).step_by(10) { let key = key(end); commits.begin_batch([0; 32]).unwrap(); - commits - .commit_if_absent(key.clone(), Arc::new(MaintenanceValue::summary(sum(2.0)))) - .unwrap(); + commits.commit_if_absent(key.clone(), sum(2.0)).unwrap(); commits.publish(&key, || Ok(())).unwrap(); commits.complete_batch([0; 32], &[(key, 30)]).unwrap(); let state = commits.0.lock().unwrap(); @@ -4129,9 +2672,7 @@ mod tests { .is_err()); assert!(commits.is_published(&key(980)).unwrap()); commits.0.lock().unwrap().generation = Some((7, 2)); - assert!(commits - .commit_if_absent(key(1_000), Arc::new(MaintenanceValue::summary(sum(2.0)))) - .is_err()); + assert!(commits.commit_if_absent(key(1_000), sum(2.0)).is_err()); } // Local node IDs are reused in separate query DAGs. Receipts must be scoped @@ -4140,7 +2681,7 @@ mod tests { fn different_materializations_have_independent_publication_receipts() { let commits = CommitRegistry::default(); for target in [2, 3] { - let key = MaterializationCommitKey { + let key = PublicationKey { plan_id: 7, plan_version: 1, stored_output: definition(target), @@ -4149,9 +2690,7 @@ mod tests { input_lineage: vec![0; 32], }; assert!(!commits.is_published(&key).unwrap()); - commits - .commit_if_absent(key.clone(), Arc::new(MaintenanceValue::summary(sum(2.0)))) - .unwrap(); + commits.commit_if_absent(key.clone(), sum(2.0)).unwrap(); commits.publish(&key, || Ok(())).unwrap(); } assert_eq!(commits.0.lock().unwrap().entries.len(), 2); @@ -4219,7 +2758,7 @@ mod tests { ( PostAsapNodeId(0), BackendNodeBinding::Materialization { - stored_output: definition(1), + stored_output: target_definition, }, ), ( @@ -4287,7 +2826,7 @@ mod tests { i * step, (i + 1) * step, None, - asap_types::PolicyFingerprint(1), + target_definition.fingerprint(), ), sum(2.0).clone_boxed_core(), ) @@ -4298,7 +2837,7 @@ mod tests { let oversized = (0..65_537) .map(|_| { ( - PrecomputedOutput::new(0, step, None, asap_types::PolicyFingerprint(1)), + PrecomputedOutput::new(0, step, None, target_definition.fingerprint()), sum(2.0).clone_boxed_core(), ) }) @@ -4318,7 +2857,7 @@ mod tests { 999_000, 1_000_000, None, - asap_types::PolicyFingerprint(1), + target_definition.fingerprint(), ), sum(3.0).clone_boxed_core() )]) @@ -4344,148 +2883,6 @@ mod tests { ); } } - - #[test] - fn shared_summary_node_executes_once_and_summary_over_summary_merges() { - // source 0 is shared by both branches; root therefore contains two - // copies of its value while node 0 itself is evaluated once. - let mut query = node(4); - query.output_state = planner_types::post_asap::ExecutionDataState::QUERY_ROWS; - let dag = ExecutableDag { - nodes: (0..4).map(node).chain([query]).collect(), - edges: vec![edge(0, 1), edge(0, 2), edge(1, 3), edge(2, 3), edge(3, 4)], - root: PostAsapNodeId(4), - }; - let binding = BackendExecutableBinding { - nodes: (0..4) - .map(|id| { - ( - PostAsapNodeId(id), - BackendNodeBinding::Materialization { - stored_output: definition(if id == 0 { 1 } else { id as u64 + 1 }), - }, - ) - }) - .chain([( - PostAsapNodeId(4), - BackendNodeBinding::Query { - query_node: asap_types::query_plan::QueryNodeId(9), - }, - )]) - .collect(), - query_sink: PostAsapNodeId(4), - query_plan_sink: asap_types::query_plan::QueryNodeId(9), - precompute_sinks: vec![PostAsapNodeId(3)], - }; - let (dag, binding) = maintenance_only(dag, binding); - let source = sum(2.0); - let adapter = OperatorAdapter { - binding: &binding, - inputs: MaintenanceInputs::Live { - definition: definition(1), - state: source, - }, - configs: &[], - }; - let commits = CommitRegistry::default(); - let key = MaterializationCommitKey { - plan_id: 7, - plan_version: 2, - stored_output: definition(4), - window_start_ms: 0, - window_end_ms: 10, - input_lineage: b"batch:1".to_vec(), - }; - let result = execute_precompute_sink( - &dag, - &binding, - PostAsapNodeId(3), - key.clone(), - &adapter, - &commits, - test_context(), - ) - .unwrap(); - assert_eq!(result.state().unwrap().aux_stats().sum, Some(4.0)); - commits.publish(&key, || Ok(())).unwrap(); - assert!( - commits.get(&key).unwrap().is_none(), - "accepted payload must not remain in the retry registry" - ); - assert!(commits.is_published(&key).unwrap()); - commits - .publish(&key, || panic!("accepted lineage must not publish twice")) - .unwrap(); - } - - #[test] - fn unsupported_maintenance_operator_propagates_failure_without_commit() { - let mut unsupported = node(1); - unsupported.payload = ExecutableOperatorPayload::SummarySubtract; - let mut query = node(2); - query.output_state = planner_types::post_asap::ExecutionDataState::QUERY_ROWS; - let dag = ExecutableDag { - nodes: vec![node(0), unsupported, query], - edges: vec![edge(0, 1), edge(1, 2)], - root: PostAsapNodeId(2), - }; - let binding = BackendExecutableBinding { - nodes: BTreeMap::from([ - ( - PostAsapNodeId(0), - BackendNodeBinding::Materialization { - stored_output: definition(1), - }, - ), - ( - PostAsapNodeId(1), - BackendNodeBinding::Materialization { - stored_output: definition(2), - }, - ), - ( - PostAsapNodeId(2), - BackendNodeBinding::Query { - query_node: asap_types::query_plan::QueryNodeId(9), - }, - ), - ]), - query_sink: PostAsapNodeId(2), - query_plan_sink: asap_types::query_plan::QueryNodeId(9), - precompute_sinks: vec![PostAsapNodeId(1)], - }; - let (dag, binding) = maintenance_only(dag, binding); - let adapter = OperatorAdapter { - binding: &binding, - inputs: MaintenanceInputs::Live { - definition: definition(1), - state: sum(2.0), - }, - configs: &[], - }; - let commits = CommitRegistry::default(); - let key = MaterializationCommitKey { - plan_id: 7, - plan_version: 2, - stored_output: definition(2), - window_start_ms: 0, - window_end_ms: 10, - input_lineage: b"batch:1".to_vec(), - }; - assert!(matches!( - execute_precompute_sink( - &dag, - &binding, - PostAsapNodeId(1), - key.clone(), - &adapter, - &commits, - test_context() - ), - Err(ScheduleError::Operator(_)) - )); - assert!(commits.get(&key).unwrap().is_none()); - } } /// Captured local input provides fixed population membership for this revision. diff --git a/data_plane/src/precompute_engine/mod.rs b/data_plane/src/precompute_engine/mod.rs index 80d5e29c4..d0eb18daf 100644 --- a/data_plane/src/precompute_engine/mod.rs +++ b/data_plane/src/precompute_engine/mod.rs @@ -10,7 +10,6 @@ mod native_precompute; pub mod output_sink; pub mod raw_dag; pub mod series_router; -pub mod subdag_scheduler; pub mod window_manager; pub mod worker; diff --git a/docs/design_docs/precompute-dag-execution.md b/docs/design_docs/precompute-dag-execution.md index f7c1610e7..612636da1 100644 --- a/docs/design_docs/precompute-dag-execution.md +++ b/docs/design_docs/precompute-dag-execution.md @@ -2,6 +2,9 @@ Audience: backend designers and developers. +Status: target design. The [physical handoff](physical-candidate-handoff.md) +tracks the remaining migration from Backend computation representations. + This document specifies the precompute engine design: how it receives a Planner-provided physical candidate selected by Backend, runs its DAGs over data partitions, and publishes the @@ -33,7 +36,8 @@ Once the required output is ready, query latency excludes its construction work. “Compute once” refers to a particular output, population and window: subsequent windows still require construction or updates under the selected schedule. -The physical compiler splits the selected DAG at its stored outputs: +Planner compiles candidate Physical DAGs with explicit stored-output boundaries. +Backend selects a feasible candidate and binds those boundaries: ```mermaid flowchart LR @@ -56,9 +60,8 @@ the compiler adds concrete source and storage bindings. A worker runs the **whole precompute subgraph for its assigned data partition**. For this example, one worker handles `service=api` and another can handle -`service=worker`. Both execute the same graph. Each worker runs its nodes -sequentially in dependency order; workers process independent partitions -concurrently. A worker is an execution task, not necessarily a dedicated OS +`service=worker`. Both execute the same graph. The shared Planner runtime executes dependencies within a DAG run; Backend +workers may process independent partitions concurrently. A worker is an execution task, not necessarily a dedicated OS thread. Only outputs selected for persistence become stored summaries. Intermediate @@ -68,9 +71,11 @@ can read the same output. ## 2. Inputs and outputs -The physical compiler receives the selected post-ASAP DAG and the selected -deployment guarantee and schedule/retention. It emits one coherent plan version -containing definition rows, a PrecomputePlan, and matching QueryPlans. +The Backend deployment compiler receives Planner Physical DAG candidates and +their lifecycle requirements. It selects a feasible candidate using workload +costs and binds concrete inputs and stored outputs. The installed plan version +contains definition rows, a PrecomputePlan and matching QueryPlans. Backend +does not lower Post-ASAP operations or choose unpriced computation at installation. | Input to the precompute engine | Purpose | | --- | --- | @@ -99,9 +104,9 @@ Installation performs four steps: 1. Validate the graph and operator input/output types, including ordered edge roles for operators with multiple inputs. Validate source, definition and stored-output bindings together with the matching QueryPlans. -2. Bind supported runtime operators and compile a dependency execution order. - Preserve Planner families and parameters: Rate and Increase remain distinct, - and grouping does not create a separate backend family. +2. Decode and validate the retained Planner Physical DAG. Preserve its operators, + parameters, dependency edges, shared producers and roots. Bind typed input + contracts without recompiling operators or deriving another execution graph. 3. Derive a partitioning rule that keeps all required dependencies and reductions local to a worker, as described below. Check that execution can satisfy the selected deployment guarantee and schedule/retention. @@ -114,14 +119,14 @@ requirements fail installation before the plan becomes active. | Object | Lifetime and contents | | --- | --- | | `PrecomputePlan` | Compiler-supplied computation and bindings for one plan version. | -| `InstalledPrecomputePlan` | Backend-derived execution order, immutable operator programs, bindings, routing indexes and validated partitioning rule. Shared by the router and workers. | +| `InstalledPrecomputePlan` | Retained Planner Physical DAGs, deployment bindings, routing indexes and validated partitioning rule. Shared by the router and workers. | | Worker execution state | Mutable node state, input ordering buffers, windows and intermediate results for that worker's assigned partitions. | `InstalledPrecomputePlan` is internal runtime data, not another serialized configuration or independently installable plan. All execution lookup tables come from the validated DAG and its bindings. There is no separately maintained -aggregation list. Operator fusion is permitted only when it preserves the DAG's -semantics and physical-to-semantic provenance. +aggregation list. Physical optimization belongs to Planner; Backend does not +fuse, split or substitute operators after candidate pricing. ## 4. Route data so each worker can execute the whole subgraph @@ -183,9 +188,9 @@ arrows denote shared immutable program access; solid arrows denote work or data. ```mermaid flowchart TB - D[Selected Planner DAG] --> C[Physical compiler: split at stored outputs] - C --> P[PrecomputePlan: Read -> Group -> KLL -> Write] - C --> QP[QueryPlans: Read stored KLL -> Estimate] + D[Planner Physical DAG candidate] --> C[Backend: price, select and bind] + C --> P[PrecomputePlan: retained precompute DAG + bindings] + C --> QP[QueryPlans: retained query DAG + bindings] P --> IP[InstalledPrecomputePlan: shared immutable program] T[Scheduled window and source partitions] --> R[Route by service] @@ -237,18 +242,16 @@ Installed precompute DAGs execute through its `PhysicalDag` runtime. The backend storage frontiers, declared edge order, window completeness and durable commit keys. It does not own a second dependency walker. -For completed-window DAGs, SummaryAgg uses the native summary builder, -SummaryMerge uses the native state merge, finalization uses native typed readout, -and Binary lowers aligned rows to native Project using the Planner binary contract, including checked division. Batch conversion -preserves the installed population and timestamp bindings. Native calls receive -the surrounding execution context, so they share its memory budget and -cancellation. The scheduler accepts the caller's run context rather than creating -an independent budget. Physical cancellation and memory errors retain their -error types through maintenance execution. A failed worker stops processing -input and publishing outputs, and closes admission; the failing drain reports -the original failure and shutdown -does not flush more state. Recovery requires restarting the failed execution. -Query execution uses the same library's operations. +Completed-window execution binds immutable population/window state to the +retained graph. Native summary build, merge, finalization and aligned binary +operators execute inside the shared runtime. Backend converts stored input and +output representations without interpreting operator payloads. + +One run context supplies cancellation and a memory budget to sources, operators +and retained outputs. Resource exhaustion and cancellation retain their error +types. A failed worker closes admission and stops publishing; drain reports the +original failure and shutdown does not flush additional state. Recovery requires +restarting the failed execution. Raw ingestion retains per-window accumulator state through shared-library updaters; worker routing and window completion remain backend responsibilities. @@ -256,17 +259,13 @@ Storage publication occurs only after successful DAG execution. It is separate from the library's request-local caching of intermediate results. ```text -execute(partition, evaluation_window, bound_inputs): - check input identities and required completeness - create one intermediate-result map for this evaluation - for node in installed dependency order: - if this evaluation does not require node: continue - obtain inputs in their declared edge-role order - if a required input is incomplete: keep dependent work pending - otherwise: - execute node using this partition's node state - retain its result for every downstream consumer - publish completed selected outputs with their stored-output bindings +execute(partition, evaluation_window, input_revision): + pin eligible input records for every required boundary + validate identities, coverage and revision compatibility + bind typed sources to the retained Planner Physical DAG + execute all selected roots in one shared-runtime run + validate output contracts + publish successful outputs through their installed stored-output bindings ``` A shared upstream node is evaluated once for the same partition, window and From ce5d8d2318ae86a7478c00cdfbeaf8444a61aa99 Mon Sep 17 00:00:00 2001 From: zzylol Date: Tue, 29 Sep 2026 15:04:00 +0000 Subject: [PATCH 11/19] refactor: execute retained query value graphs and reject stale nodes --- Cargo.lock | 14 +- Cargo.toml | 12 +- .../src/query_plan/physical_values.rs | 101 ++++ crates/asap_types/src/query_plan.rs | 59 +++ .../precompute_engine/maintenance_runtime.rs | 2 +- .../query_engines/asap_query_engine/engine.rs | 8 +- .../asap_query_engine/logical_dag.rs | 41 +- .../logical_dag/native_values.rs | 2 +- .../asap_query_engine/post_asap_readout.rs | 435 ++++++++++-------- 9 files changed, 442 insertions(+), 232 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0926271e8..cfe039d1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -364,7 +364,7 @@ dependencies = [ [[package]] name = "asap-aware-mapping" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=2280f4e6a550af1b5bc56a414ec88a5603d9fab4#2280f4e6a550af1b5bc56a414ec88a5603d9fab4" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3e05ef648dfe4fba834d4f37beff1920ac8e0411#3e05ef648dfe4fba834d4f37beff1920ac8e0411" dependencies = [ "asap-types", "asap_sketchlib 0.3.0 (git+https://github.com/ProjectASAP/asap_sketchlib)", @@ -376,7 +376,7 @@ dependencies = [ [[package]] name = "asap-frontend-promql" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=2280f4e6a550af1b5bc56a414ec88a5603d9fab4#2280f4e6a550af1b5bc56a414ec88a5603d9fab4" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3e05ef648dfe4fba834d4f37beff1920ac8e0411#3e05ef648dfe4fba834d4f37beff1920ac8e0411" dependencies = [ "asap-types", "promql-parser 0.10.0 (git+https://github.com/ProjectASAP/promql-parser?rev=9fede7eecca923c9882fe256484d00d37f8706cb)", @@ -385,7 +385,7 @@ dependencies = [ [[package]] name = "asap-frontend-sql" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=2280f4e6a550af1b5bc56a414ec88a5603d9fab4#2280f4e6a550af1b5bc56a414ec88a5603d9fab4" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3e05ef648dfe4fba834d4f37beff1920ac8e0411#3e05ef648dfe4fba834d4f37beff1920ac8e0411" dependencies = [ "asap-sql-function-catalog", "asap-types", @@ -396,7 +396,7 @@ dependencies = [ [[package]] name = "asap-physical-operators" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=2280f4e6a550af1b5bc56a414ec88a5603d9fab4#2280f4e6a550af1b5bc56a414ec88a5603d9fab4" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3e05ef648dfe4fba834d4f37beff1920ac8e0411#3e05ef648dfe4fba834d4f37beff1920ac8e0411" dependencies = [ "asap-types", "asap_sketch_codec", @@ -416,12 +416,12 @@ dependencies = [ [[package]] name = "asap-sql-function-catalog" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=2280f4e6a550af1b5bc56a414ec88a5603d9fab4#2280f4e6a550af1b5bc56a414ec88a5603d9fab4" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3e05ef648dfe4fba834d4f37beff1920ac8e0411#3e05ef648dfe4fba834d4f37beff1920ac8e0411" [[package]] name = "asap-types" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=2280f4e6a550af1b5bc56a414ec88a5603d9fab4#2280f4e6a550af1b5bc56a414ec88a5603d9fab4" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3e05ef648dfe4fba834d4f37beff1920ac8e0411#3e05ef648dfe4fba834d4f37beff1920ac8e0411" dependencies = [ "serde", "serde_json", @@ -443,7 +443,7 @@ dependencies = [ [[package]] name = "asap_sketch_codec" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=2280f4e6a550af1b5bc56a414ec88a5603d9fab4#2280f4e6a550af1b5bc56a414ec88a5603d9fab4" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3e05ef648dfe4fba834d4f37beff1920ac8e0411#3e05ef648dfe4fba834d4f37beff1920ac8e0411" dependencies = [ "asap_sketchlib 0.3.0 (git+https://github.com/ProjectASAP/asap_sketchlib?rev=5f03ccbd798ed5fec62bdd839bcb331123cab369)", "prost", diff --git a/Cargo.toml b/Cargo.toml index 62778513e..55898c31d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,10 +14,10 @@ version = "0.1.0" [workspace.dependencies] # Keep Planner frontends, selection, and IR on the same immutable revision. # Alias upstream asap-types because this workspace also defines asap_types. -planner-types = { package = "asap-types", git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "2280f4e6a550af1b5bc56a414ec88a5603d9fab4" } -asap-aware-mapping = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "2280f4e6a550af1b5bc56a414ec88a5603d9fab4" } -asap-frontend-promql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "2280f4e6a550af1b5bc56a414ec88a5603d9fab4" } -asap-frontend-sql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "2280f4e6a550af1b5bc56a414ec88a5603d9fab4" } +planner-types = { package = "asap-types", git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3e05ef648dfe4fba834d4f37beff1920ac8e0411" } +asap-aware-mapping = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3e05ef648dfe4fba834d4f37beff1920ac8e0411" } +asap-frontend-promql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3e05ef648dfe4fba834d4f37beff1920ac8e0411" } +asap-frontend-sql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3e05ef648dfe4fba834d4f37beff1920ac8e0411" } # Shared external deps (used by 2+ crates) serde = { version = "1.0", features = ["derive"] } @@ -37,8 +37,8 @@ arc-swap = "1.7" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } # Internal crates -asap-physical-operators = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "2280f4e6a550af1b5bc56a414ec88a5603d9fab4" } -asap_sketch_codec = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "2280f4e6a550af1b5bc56a414ec88a5603d9fab4" } +asap-physical-operators = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3e05ef648dfe4fba834d4f37beff1920ac8e0411" } +asap_sketch_codec = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3e05ef648dfe4fba834d4f37beff1920ac8e0411" } asap_types = { path = "crates/asap_types" } asap_otel_proto = { path = "crates/asap_otel_proto" } indexmap = { version = "2.0", features = ["serde"] } diff --git a/control_plane/src/query_plan/physical_values.rs b/control_plane/src/query_plan/physical_values.rs index 2dfd78b75..86f869680 100644 --- a/control_plane/src/query_plan/physical_values.rs +++ b/control_plane/src/query_plan/physical_values.rs @@ -153,6 +153,45 @@ pub fn compile( } _ => None, }, + QueryPlanNode::ReduceSum { + grouping: groups, .. + } => Some(match groups { + PhysicalGrouping::Reduce(labels) => physical::compile_aggregate( + &AggIntent::Sum { col: None }, + &GroupKeys::by(labels.iter().cloned().map(ColumnRef::Named).collect()), + ) + .map_err(invalid)?, + PhysicalGrouping::PerEntity => CompiledPhysicalDag::from_operators( + BTreeMap::from([( + 0, + asap_physical_operators::physical_planner::InputContract::bounded( + physical::vector_schema(), + ), + )]), + BTreeMap::from([( + 1, + ( + vec![0], + asap_physical_operators::operators::Operator::project( + physical::vector_schema(), + vec![ + ( + "labels".into(), + asap_physical_operators::expressions::Expression::Column(0), + ), + ( + "value".into(), + asap_physical_operators::expressions::Expression::Column(1), + ), + ], + ) + .map_err(invalid)?, + ), + )]), + vec![1], + ) + .map_err(invalid)?, + }), QueryPlanNode::Binary { operator, .. } => { scalar = scalar_input(0) && scalar_input(1); Some( @@ -341,3 +380,65 @@ pub(crate) fn operator_parameters(node: &QueryPlanNode, kind: &str) -> Vec Result> ValueRuntim return Ok(value); } let value = match node.clone() { - QueryPlanNode::Scalar { value } => Value::Scalar(native_scalar(value, context)?), + QueryPlanNode::Scalar { .. } + | QueryPlanNode::Binary { .. } + | QueryPlanNode::ReduceSum { .. } => { + return Err(physical::Error::Invalid( + "installed computation requires a retained Planner physical graph".into(), + ) + .into()); + } QueryPlanNode::Logical { operator: QueryTimeOperator::CurrentSeries { .. }, .. @@ -615,27 +622,6 @@ fn semi_join( Ok((rows, pruning_warning(completeness))) } -pub(super) fn native_scalar( - value: f64, - context: &physical::RunContext, -) -> Result { - use physical::{batch_execution::evaluate_source, operators::Operator, values::Value as Cell}; - let source = Operator::scalar( - Cell::Float64(value), - planner_types::pre_asap::DataType::Float64, - ) - .map_err(EngineError::from)?; - let batches = evaluate_source(source, context.clone()).map_err(EngineError::from)?; - match batches - .first() - .and_then(|b| b.rows().first()) - .and_then(|r| r.first()) - { - Some(Cell::Float64(value)) => Ok(*value), - _ => Err(miss("native scalar source returned invalid output")), - } -} - fn native_labels(labels: &Labels) -> physical::values::Value { physical::values::Value::Map( labels @@ -1864,7 +1850,14 @@ mod shared_runtime_tests { #[test] fn native_scalar_and_aggregation_share_parent_resource_control() { let context = test_native_context(); - assert_eq!(native_scalar(7., &context).unwrap(), 7.); + let graph = asap_physical_operators::physical_planner::promql_values::compile_scalar(7.) + .unwrap() + .encode() + .unwrap(); + assert!(matches!( + native_values::complete_values(&graph, &[], context.clone()).unwrap(), + Some(Value::Scalar(7.)) + )); let output = aggregate( Aggregation::Sum, &Grouping { @@ -1878,7 +1871,7 @@ mod shared_runtime_tests { assert_eq!(output, vec![(Labels::new(), 7.)]); assert!(context.peak_bytes() > 0); context.cancel(); - assert!(native_scalar(7., &context).is_err()); + assert!(native_values::complete_values(&graph, &[], context.clone()).is_err()); assert!(negate(Value::Scalar(1.), &context).is_err()); } diff --git a/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs b/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs index c2ab39dcb..c22e47aa4 100644 --- a/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs +++ b/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs @@ -12,7 +12,7 @@ use planner_types::{post_asap::SummaryFamilyType, pre_asap::DataType}; use std::sync::Arc; /// Bind protocol values without choosing matching, grouping or arithmetic behavior. -pub(super) fn complete_values( +pub(in crate::query_engines::asap_query_engine) fn complete_values( encoded: &[u8], inputs: &[&super::Value], context: dag::RunContext, diff --git a/data_plane/src/query_engines/asap_query_engine/post_asap_readout.rs b/data_plane/src/query_engines/asap_query_engine/post_asap_readout.rs index dbabe845c..5bf9304a5 100644 --- a/data_plane/src/query_engines/asap_query_engine/post_asap_readout.rs +++ b/data_plane/src/query_engines/asap_query_engine/post_asap_readout.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; +#[cfg(test)] use asap_physical_operators::arithmetic::evaluate_float64_arithmetic as arithmetic; use asap_types::query_plan::{QueryNodeId, QueryPlanNode}; @@ -153,26 +154,19 @@ impl PhysicalQueryRuntime<'_> { context: &dag::RunContext, ) -> Result { match node { - QueryPlanNode::Scalar { value } => super::logical_dag::native_scalar(*value, context) - .map(PhysicalQueryOutput::Scalar) - .map_err(|error| match error { - crate::query_engines::EngineError::Physical(error) => { - PhysicalNodeError::Physical(error) - } - other => PhysicalNodeError::Fallback(other.to_string()), - }), - QueryPlanNode::Binary { operator, .. } => { - let [lhs, rhs] = inputs else { - return Err(PhysicalNodeError::ExpectedState); - }; - binary_values(operator, lhs, rhs) - } - QueryPlanNode::ReduceSum { grouping, .. } => { - let [PhysicalQueryOutput::Value(values, coverage)] = inputs else { - return Err(PhysicalNodeError::ExpectedState); - }; - reduce_sum_values_in_context(grouping, values, *coverage, context) + QueryPlanNode::Scalar { .. } + | QueryPlanNode::Binary { .. } + | QueryPlanNode::ReduceSum { .. } => { + Err(PhysicalNodeError::Physical(dag::Error::Invalid( + "installed value computation requires a retained Planner physical graph".into(), + ))) } + QueryPlanNode::PhysicalFragment { + dag, + row_input: None, + pruning: None, + .. + } => execute_value_fragment(dag, inputs, context), QueryPlanNode::ReadMaterialization { binding } => { let mut groups = self .context @@ -416,111 +410,122 @@ fn expand_item_readout( Ok((expand_item_rows(group_key, value, item_labels)?, coverage)) } -fn binary_values( - operator: &planner_types::pre_asap::ArithmeticOpKind, - lhs: &PhysicalQueryOutput, - rhs: &PhysicalQueryOutput, +// Binding preserves timestamps and coverage; all value computation is in the +// installed native program. No operator is constructed in this serving path. +fn execute_value_fragment( + encoded: &[u8], + inputs: &[&PhysicalQueryOutput], + context: &dag::RunContext, ) -> Result { - type Labels = BTreeMap; - type Samples = BTreeMap<(Labels, i64), (f64, Option<(u64, u64)>)>; - fn flatten(values: &[(Labels, SummaryValue)]) -> Result { - let mut result = BTreeMap::new(); - for (labels, value) in values { - let SummaryValue::Points(points, coverage) = value else { - return Err(PhysicalNodeError::ExpectedState); - }; - let mut labels = labels.clone(); - labels.remove("__name__"); - for (timestamp, value) in points { - if result - .insert((labels.clone(), *timestamp), (*value, *coverage)) - .is_some() - { - return Err(PhysicalNodeError::Fallback( - "ambiguous default vector matching".into(), - )); + use super::logical_dag::{native_values, Value}; + let mut times = std::collections::BTreeSet::new(); + let mut time_memory = context.reserve(0)?; + let mut output_memory = context.reserve(0)?; + let mut output_bytes = 0usize; + let mut coverage = None; + let mut first_coverage = true; + for input in inputs { + match input { + PhysicalQueryOutput::Scalar(_) => {} + PhysicalQueryOutput::Value(values, input_coverage) => { + coverage = if first_coverage { + *input_coverage + } else { + intersect_coverage(coverage, *input_coverage) + }; + first_coverage = false; + for (_, value) in values { + let SummaryValue::Points(points, _) = value else { + return Err(PhysicalNodeError::ExpectedState); + }; + for (time, _) in points { + if context.is_cancelled() { + return Err(dag::Error::Cancelled.into()); + } + times.insert(*time); + time_memory + .resize(times.len().checked_mul(32).ok_or(dag::Error::MemoryLimit)?)?; + } } } + PhysicalQueryOutput::State { .. } => return Err(PhysicalNodeError::ExpectedState), } - Ok(result) } - fn points(values: Samples) -> PhysicalQueryOutput { - PhysicalQueryOutput::Value( - values - .into_iter() - .map(|((labels, timestamp), (value, coverage))| { - ( - labels, - SummaryValue::Points(vec![(timestamp, value)], coverage), - ) - }) - .collect(), - None, - ) + if times.is_empty() { + times.insert(match context.scope { + dag::Scope::Query { + evaluation_time_ms, .. + } => evaluation_time_ms, + dag::Scope::Ingestion { window_end_ms, .. } => window_end_ms, + }); } - match (lhs, rhs) { - (PhysicalQueryOutput::Scalar(a), PhysicalQueryOutput::Scalar(b)) => { - Ok(PhysicalQueryOutput::Scalar(arithmetic(operator, *a, *b))) + let mut result = Vec::new(); + for time in × { + if context.is_cancelled() { + return Err(dag::Error::Cancelled.into()); } - (PhysicalQueryOutput::Value(values, coverage), PhysicalQueryOutput::Scalar(scalar)) => { - Ok({ - let mut output = points( - flatten(values)? - .into_iter() - .map(|(key, (value, coverage))| { - (key, (arithmetic(operator, value, *scalar), coverage)) + let values = inputs + .iter() + .map(|input| match input { + PhysicalQueryOutput::Scalar(value) => Value::Scalar(*value), + PhysicalQueryOutput::Value(values, _) => Value::Vector( + values + .iter() + .flat_map(|(labels, value)| { + let SummaryValue::Points(points, _) = value else { + unreachable!() + }; + points + .iter() + .filter(|(at, _)| at == time) + .map(|(_, value)| (labels.clone(), *value)) }) .collect(), - ); - if let PhysicalQueryOutput::Value(_, out_coverage) = &mut output { - *out_coverage = *coverage; - } - output + ), + PhysicalQueryOutput::State { .. } => unreachable!(), }) - } - (PhysicalQueryOutput::Scalar(scalar), PhysicalQueryOutput::Value(values, coverage)) => { - Ok({ - let mut output = points( - flatten(values)? - .into_iter() - .map(|(key, (value, coverage))| { - (key, (arithmetic(operator, *scalar, value), coverage)) - }) - .collect(), - ); - if let PhysicalQueryOutput::Value(_, out_coverage) = &mut output { - *out_coverage = *coverage; + .collect::>(); + let input_bytes = values.iter().try_fold(0usize, |bytes, value| { + bytes + .checked_add(value.retained_bytes()) + .ok_or(dag::Error::MemoryLimit) + })?; + let _input_memory = context.reserve(input_bytes)?; + let output = native_values::complete_values( + encoded, + &values.iter().collect::>(), + context.clone(), + ) + .map_err(|error| match error { + crate::query_engines::EngineError::Physical(error) => { + PhysicalNodeError::Physical(error) + } + other => PhysicalNodeError::Fallback(other.to_string()), + })? + .ok_or(PhysicalNodeError::ExpectedState)?; + match output { + Value::Scalar(value) if times.len() == 1 => { + return Ok(PhysicalQueryOutput::Scalar(value)) + } + Value::Vector(values) => { + for (labels, value) in values { + let bytes = labels.iter().try_fold(64usize, |bytes, (key, value)| { + bytes + .checked_add(key.len()) + .and_then(|n| n.checked_add(value.len())) + .ok_or(dag::Error::MemoryLimit) + })?; + output_bytes = output_bytes + .checked_add(bytes) + .ok_or(dag::Error::MemoryLimit)?; + output_memory.resize(output_bytes)?; + result.push((labels, SummaryValue::Points(vec![(*time, value)], coverage))); } - output - }) - } - ( - PhysicalQueryOutput::Value(left, left_coverage), - PhysicalQueryOutput::Value(right, right_coverage), - ) => { - let right = flatten(right)?; - let mut output = points( - flatten(left)? - .into_iter() - .filter_map(|(key, (left, coverage))| { - let (right, right_coverage) = right.get(&key)?; - Some(( - key, - ( - arithmetic(operator, left, *right), - intersect_coverage(coverage, *right_coverage), - ), - )) - }) - .collect(), - ); - if let PhysicalQueryOutput::Value(_, coverage) = &mut output { - *coverage = intersect_coverage(*left_coverage, *right_coverage); } - Ok(output) + _ => return Err(PhysicalNodeError::ExpectedState), } - _ => Err(PhysicalNodeError::ExpectedState), } + Ok(PhysicalQueryOutput::Value(result, coverage)) } fn intersect_coverage(left: Option<(u64, u64)>, right: Option<(u64, u64)>) -> Option<(u64, u64)> { @@ -530,95 +535,56 @@ fn intersect_coverage(left: Option<(u64, u64)>, right: Option<(u64, u64)>) -> Op } #[cfg(test)] -fn reduce_sum_values( - grouping: &asap_types::query_plan::PhysicalGrouping, - values: &[(BTreeMap, SummaryValue)], - coverage: Option<(u64, u64)>, +fn binary_values( + operation: &planner_types::pre_asap::ArithmeticOpKind, + lhs: &PhysicalQueryOutput, + rhs: &PhysicalQueryOutput, ) -> Result { - let context = dag::RunContext::new( + let graph = asap_physical_operators::physical_planner::promql_values::compile_binary( + &planner_types::post_asap::BinaryOperator { + kind: planner_types::pre_asap::BinaryOpKind::Arithmetic(operation.clone()), + vector_match: None, + checked_relative_division: false, + checked_finite_division: false, + }, + false, + matches!(lhs, PhysicalQueryOutput::Scalar(_)), + matches!(rhs, PhysicalQueryOutput::Scalar(_)), + )?; + execute_value_fragment(&graph.encode()?, &[lhs, rhs], &test_value_context()) +} + +#[cfg(test)] +fn test_value_context() -> dag::RunContext { + dag::RunContext::new( dag::Scope::Query { - evaluation_time_ms: 0, + evaluation_time_ms: 2000, revision: 0, }, dag::Limits::default(), ) - .unwrap(); - reduce_sum_values_in_context(grouping, values, coverage, &context) + .unwrap() } -fn reduce_sum_values_in_context( + +#[cfg(test)] +fn reduce_sum_values( grouping: &asap_types::query_plan::PhysicalGrouping, values: &[(BTreeMap, SummaryValue)], coverage: Option<(u64, u64)>, - context: &dag::RunContext, ) -> Result { - use dag::{ - operators::{Operator, Reduction}, - values::{Batch, Value}, - }; - use planner_types::{ - post_asap::{SummaryFamilyType, SummaryField, SummarySchema}, - pre_asap::DataType, - }; - let asap_types::query_plan::PhysicalGrouping::Reduce(keys) = grouping else { - return Ok(PhysicalQueryOutput::Value(values.to_vec(), coverage)); + use planner_types::pre_asap::{AggIntent, ColumnRef, GroupKeys}; + let asap_types::query_plan::PhysicalGrouping::Reduce(labels) = grouping else { + panic!("reduce fixture required") }; - let mut groups = BTreeMap::new(); - for (labels, value) in values { - let SummaryValue::Points(points, row_coverage) = value else { - return Err(PhysicalNodeError::ExpectedState); - }; - let labels = labels - .iter() - .filter(|(name, _)| keys.contains(name)) - .map(|(k, v)| (k.clone(), v.clone())) - .collect::>(); - for (timestamp, value) in points { - let group = groups - .entry((labels.clone(), *timestamp)) - .or_insert_with(|| (Vec::new(), *row_coverage)); - group.0.push(*value); - group.1 = intersect_coverage(group.1, *row_coverage); - } - } - let groups = groups.into_iter().collect::>(); - let schema = std::sync::Arc::new(SummarySchema { - fields: vec![("group", DataType::Int64), ("value", DataType::Float64)] - .into_iter() - .map(|(name, dtype)| SummaryField { - name: name.into(), - dtype: SummaryFamilyType::Plain(dtype), - nullable: false, - }) - .collect(), - time_index: None, - }); - let rows = groups - .iter() - .enumerate() - .flat_map(|(index, (_, (values, _)))| { - values - .iter() - .map(move |value| vec![Value::Int64(index as i64), Value::Float64(*value)]) - }) - .collect(); - let error = PhysicalNodeError::Physical; - let batch = Batch::try_new(schema.clone(), rows).map_err(error)?; - let operator = Operator::aggregate(schema, vec![0], vec![("value".into(), Reduction::Sum(1))]) - .map_err(error)?; - let output = dag::batch_execution::evaluate_batch(batch, vec![operator], context.clone()) - .map_err(error)?; - let mut result = Vec::new(); - for row in output.iter().flat_map(|batch| batch.rows()) { - let [Value::Int64(index), Value::Float64(sum)] = row.as_slice() else { - return Err(PhysicalNodeError::ExpectedState); - }; - let ((labels, time), (_, row_coverage)) = &groups[*index as usize]; - result.push(( - labels.clone(), - SummaryValue::Points(vec![(*time, *sum)], *row_coverage), - )); - } - Ok(PhysicalQueryOutput::Value(result, coverage)) + let graph = asap_physical_operators::physical_planner::promql_values::compile_aggregate( + &AggIntent::Sum { col: None }, + &GroupKeys::by(labels.iter().cloned().map(ColumnRef::Named).collect()), + )?; + execute_value_fragment( + &graph.encode()?, + &[&PhysicalQueryOutput::Value(values.to_vec(), coverage)], + &test_value_context(), + ) } fn execute_physical_query_plan( @@ -983,6 +949,52 @@ mod tests { use asap_types::query_plan::{ExactReadout, PhysicalGrouping, QueryReadout}; use planner_types::pre_asap::ArithmeticOpKind; + fn native_scalar_node(value: f64) -> QueryPlanNode { + QueryPlanNode::PhysicalFragment { + inputs: vec![], + dag: asap_physical_operators::physical_planner::promql_values::compile_scalar(value) + .unwrap() + .encode() + .unwrap(), + row_input: None, + pruning: None, + } + } + + // Old computation nodes must fail explicitly rather than construct operators during a request. + #[test] + fn uncompiled_value_nodes_are_rejected() { + let store = SketchStore::new(); + let runtime = PhysicalQueryRuntime { + counter_parameters: Default::default(), + language: asap_types::query_plan::QueryLanguage::PromQl, + catalog: None, + context: QueryExecutionContext { + index: &store, + t0_ms: 0, + t1_ms: 2000, + is_cumulative: true, + allowed_materializations: None, + }, + }; + for node in [ + QueryPlanNode::Scalar { value: 1. }, + QueryPlanNode::Binary { + inputs: [QueryNodeId(0), QueryNodeId(0)], + operator: ArithmeticOpKind::Add, + }, + QueryPlanNode::ReduceSum { + input: QueryNodeId(0), + grouping: PhysicalGrouping::Reduce(vec![]), + }, + ] { + assert!( + matches!(runtime.execute_node(QueryNodeId(1), &node, &[], &test_value_context()), + Err(PhysicalNodeError::Physical(dag::Error::Invalid(message))) if message.contains("retained Planner physical graph")) + ); + } + } + // Real DAG resource failures survive both summary-readout and engine error adapters. #[test] fn summary_dag_resource_failures_never_become_capability_misses() { @@ -1009,7 +1021,7 @@ mod tests { canonical_query: "1".into(), fixed_evaluation: None, root: QueryNodeId(0), - nodes: [(QueryNodeId(0), QueryPlanNode::Scalar { value: 1. })].into(), + nodes: [(QueryNodeId(0), native_scalar_node(1.))].into(), instant: InstantExecution { lookback_ms: 0, full_history: false, @@ -1291,12 +1303,18 @@ mod tests { fixed_evaluation: None, root: QueryNodeId(1), nodes: BTreeMap::from([ - (QueryNodeId(0), QueryPlanNode::Scalar { value: 1. }), + (QueryNodeId(0), native_scalar_node(1.)), ( QueryNodeId(1), - QueryPlanNode::Binary { - operator: ArithmeticOpKind::Add, - inputs: [QueryNodeId(0), QueryNodeId(0)], + QueryPlanNode::PhysicalFragment { + inputs: vec![QueryNodeId(0), QueryNodeId(0)], + dag: asap_physical_operators::physical_planner::promql_values::compile_binary( + &planner_types::post_asap::BinaryOperator { + kind: planner_types::pre_asap::BinaryOpKind::Arithmetic(ArithmeticOpKind::Add), + vector_match: None, checked_relative_division: false, checked_finite_division: false, + }, false, true, true, + ).unwrap().encode().unwrap(), + row_input: None, pruning: None, }, ), ]), @@ -1419,6 +1437,39 @@ mod tests { assert_eq!(intersect_coverage(Some((1000, 2000)), None), None); } + // Stored points at different evaluation times are separate native invocations, + // with one parent resource budget and no cross-time label matching. + #[test] + fn retained_value_graph_preserves_time_alignment() { + let points = |values| { + PhysicalQueryOutput::Value( + vec![( + BTreeMap::from([("service".into(), "api".into())]), + SummaryValue::Points(values, Some((1000, 2000))), + )], + Some((1000, 2000)), + ) + }; + let left = points(vec![(1000, 2.), (2000, 6.)]); + let right = points(vec![(1000, 1.), (2000, 3.)]); + let PhysicalQueryOutput::Value(values, coverage) = + binary_values(&ArithmeticOpKind::Div, &left, &right).unwrap() + else { + panic!("vector required") + }; + let actual = values + .iter() + .flat_map(|(_, value)| { + let SummaryValue::Points(points, _) = value else { + panic!("points required") + }; + points.clone() + }) + .collect::>(); + assert_eq!(actual, vec![(1000, 2.), (2000, 2.)]); + assert_eq!(coverage, Some((1000, 2000))); + } + // Rollup adds partial counts rather than counting the number of series. #[test] fn exact_rollup_adds_uneven_observation_counts() { From 9ba7c31279af9e63ba8e5afab7d1f10b252adc80 Mon Sep 17 00:00:00 2001 From: zzylol Date: Tue, 29 Sep 2026 15:37:46 +0000 Subject: [PATCH 12/19] fix: preserve shared precompute graphs across stored outputs --- control_plane/src/physical/compiler.rs | 113 ++++++++ .../src/physical/executable_binding.rs | 34 ++- crates/asap_types/src/executable_plan.rs | 140 ++++++++-- crates/asap_types/src/precompute_plan.rs | 27 ++ .../precompute_engine/maintenance_runtime.rs | 241 +++++++++--------- .../precompute_engine/native_precompute.rs | 159 ++++++++++-- docs/design_docs/precompute-dag-execution.md | 8 + 7 files changed, 561 insertions(+), 161 deletions(-) diff --git a/control_plane/src/physical/compiler.rs b/control_plane/src/physical/compiler.rs index 05778961e..685c47dcc 100644 --- a/control_plane/src/physical/compiler.rs +++ b/control_plane/src/physical/compiler.rs @@ -7088,6 +7088,119 @@ pub(crate) mod tests { assert!(full.weighted_cost >= lifecycle.horizon_seconds * 11.0); } + // One selected semantic diamond must survive physical compilation; outputs + // with a different deployment window cannot share the same execution run. + #[test] + fn precompute_compilation_preserves_shared_roots_and_window_boundaries() { + use asap_types::executable_plan::*; + use planner_types::post_asap::*; + let bundle = DeploymentPlanCompiler + .compile_promql(request("q", "sum_over_time(m[1m])"), environment(10_000)) + .unwrap(); + let mut source = bundle.precompute_plan.materializations[0].clone(); + source.stored_output_id = Some(asap_types::sds::StoredOutputId(1)); + source.population_key_encoding = asap_types::PopulationKeyEncoding::CanonicalLabelsV1; + let mut configs = vec![source.clone()]; + for id in [3, 4] { + let mut output = source.clone(); + output.stored_output_id = Some(asap_types::sds::StoredOutputId(id)); + output.derived_input = Some(asap_types::derived_input::DerivedInputIdentity { + inputs: BTreeSet::from([asap_types::sds::StoredOutputId(1)]), + program_sha256: "0".repeat(64), + }); + configs.push(output); + } + let schema = SummarySchema { + fields: vec![SummaryField { + name: "state".into(), + dtype: SummaryFamilyType::ExactAggregate(ExactKind::Sum, ExactParams::Sum), + nullable: false, + }], + time_index: None, + }; + let dag = ExecutableDag { + nodes: (1..=5) + .map(|id| ExecutableDagNode { + id: PostAsapNodeId(id), + payload: ExecutableOperatorPayload::SummaryMerge, + output_state: ExecutionDataState::INGESTION_SUMMARY, + output_schema: schema.clone(), + guarantee: None, + }) + .collect(), + edges: [(1, 2), (2, 3), (2, 4), (3, 5), (4, 5)] + .into_iter() + .map(|(a, b)| ExecutableDagEdge { + producer: PostAsapNodeId(a), + consumer: PostAsapNodeId(b), + role: EdgeRole::Input, + intermediate_schema: schema.clone(), + data_state: ExecutionDataState::INGESTION_SUMMARY, + grouping: GroupingEdgeCompatibility::Identical, + window: WindowEdgeCompatibility::NotApplicable, + }) + .collect(), + root: PostAsapNodeId(5), + }; + let mut installed = InstalledPostAsapDag { + document: OwnedPostAsapDag::from_executable("shared".into(), &dag).unwrap(), + native_programs: BTreeMap::new(), + binding: BackendExecutableBinding { + nodes: (1..=5) + .map(|id| { + ( + PostAsapNodeId(id), + if id == 2 || id == 5 { + BackendNodeBinding::MaintenanceInput + } else { + BackendNodeBinding::Materialization { + stored_output: asap_types::sds::StoredOutputId(id as u64), + } + }, + ) + }) + .collect(), + precompute_sinks: vec![PostAsapNodeId(3), PostAsapNodeId(4)], + query_sink: PostAsapNodeId(5), + query_plan_sink: QueryNodeId(5), + }, + }; + super::super::executable_binding::compile_precompute_programs(&mut installed, &configs) + .unwrap(); + assert_eq!( + installed + .native_program(PostAsapNodeId(3)) + .unwrap() + .unwrap() + .roots(), + &[3, 4] + ); + assert_eq!( + installed.native_programs[&PostAsapNodeId(3)], + installed.native_programs[&PostAsapNodeId(4)] + ); + configs[2].pane_origin_ms = Some(configs[1].pane_origin_ms.unwrap_or(0) + 1_000); + installed.native_programs.clear(); + super::super::executable_binding::compile_precompute_programs(&mut installed, &configs) + .unwrap(); + assert_eq!( + installed + .native_program(PostAsapNodeId(3)) + .unwrap() + .unwrap() + .roots(), + &[3] + ); + assert_eq!( + installed + .native_program(PostAsapNodeId(4)) + .unwrap() + .unwrap() + .roots(), + &[4] + ); + } + // Temporal SUM readouts share raw state only for identical source populations. #[test] fn derived_window_regression_shared_sum_panes() { diff --git a/control_plane/src/physical/executable_binding.rs b/control_plane/src/physical/executable_binding.rs index 6a92c9ef5..3742a2ea0 100644 --- a/control_plane/src/physical/executable_binding.rs +++ b/control_plane/src/physical/executable_binding.rs @@ -98,6 +98,7 @@ pub(super) fn compile_precompute_programs( ) -> Result<(), String> { use asap_types::executable_plan::BackendNodeBinding; let dag = installed.document.decode()?; + let mut groups = std::collections::BTreeMap::<_, Vec>::new(); for sink in &installed.binding.precompute_sinks { if installed.native_programs.contains_key(sink) { continue; @@ -117,17 +118,36 @@ pub(super) fn compile_precompute_programs( let frontiers = installed.binding.nodes.iter().filter_map(|(id, binding)| { matches!(binding, BackendNodeBinding::Materialization { stored_output } if derived.inputs.contains(stored_output)).then_some(u64::from(id.0)) }).collect::>(); + // Only co-schedule outputs with identical immutable input and window + // contracts. Legacy population bindings are kept as individual runs. + let separate = config.population_key_encoding.is_legacy().then_some(sink.0); + groups + .entry(( + frontiers, + config.stored_window_ms(), + config.slide_interval, + config.pane_origin_ms, + separate, + )) + .or_default() + .push(u64::from(sink.0)); + } + for ((frontiers, _, _, _, _), roots) in groups { let program = asap_physical_operators::physical_planner::precompute::compile( - &dag, - &frontiers, - &[u64::from(sink.0)], + &dag, &frontiers, &roots, ) .map_err(|e| e.to_string())?; - installed.native_programs.insert( - *sink, + let encoded: serde_json::Value = serde_json::from_slice(&program.encode().map_err(|e| e.to_string())?) - .map_err(|e| e.to_string())?, - ); + .map_err(|e| e.to_string())?; + for root in roots { + installed.native_programs.insert( + planner_types::post_asap::PostAsapNodeId( + u32::try_from(root).map_err(|_| "physical output id overflow")?, + ), + encoded.clone(), + ); + } } Ok(()) } diff --git a/crates/asap_types/src/executable_plan.rs b/crates/asap_types/src/executable_plan.rs index b48aeccdc..00ec3bc39 100644 --- a/crates/asap_types/src/executable_plan.rs +++ b/crates/asap_types/src/executable_plan.rs @@ -216,14 +216,28 @@ impl InstalledPostAsapDag { &serde_json::to_vec(value).map_err(|e| e.to_string())?, ) .map_err(|e| e.to_string())?; - if program.roots() != [u64::from(sink.0)] || !self.binding.precompute_sinks.contains(&sink) - { - return Err("native maintenance program differs from its installed sink".into()); + if !program.roots().contains(&u64::from(sink.0)) { + return Err("native precompute program omits its installed output".into()); + } + for root in program.roots() { + let root = + PostAsapNodeId(u32::try_from(*root).map_err(|_| "physical output id overflow")?); + if !self.binding.precompute_sinks.contains(&root) + || !matches!( + self.binding.node(root), + Some(BackendNodeBinding::Materialization { .. }) + ) + || self.native_programs.get(&root) != Some(value) + { + return Err( + "native precompute program differs across its installed outputs".into(), + ); + } } let dag = self.document.decode()?; for (id, contract) in program.input_contracts() { let id = PostAsapNodeId(u32::try_from(id).map_err(|_| "physical source id overflow")?); - if id == sink + if program.roots().contains(&u64::from(id.0)) || !matches!( self.binding.node(id), Some(BackendNodeBinding::Materialization { .. }) @@ -241,19 +255,25 @@ impl InstalledPostAsapDag { ); } } - let output = program - .output_contract(u64::from(sink.0)) - .map_err(|e| e.to_string())?; - if program.input_contracts().count() == 0 - || dag.nodes.iter().find(|n| n.id == sink).is_none_or(|n| { - n.output_schema != *output.schema - && asap_physical_operators::physical_planner::precompute::source_schema( - &n.output_schema, - ) - .map_or(true, |schema| schema != output.schema) - }) - { - return Err("native maintenance output differs from semantic schema".into()); + if program.input_contracts().count() == 0 { + return Err("native precompute program has no bound inputs".into()); + } + for root in program.roots() { + let output = program.output_contract(*root).map_err(|e| e.to_string())?; + if dag + .nodes + .iter() + .find(|n| u64::from(n.id.0) == *root) + .is_none_or(|n| { + n.output_schema != *output.schema + && asap_physical_operators::physical_planner::precompute::source_schema( + &n.output_schema, + ) + .map_or(true, |schema| schema != output.schema) + }) + { + return Err("native precompute output differs from semantic schema".into()); + } } Ok(Some(program)) } @@ -441,6 +461,92 @@ mod tests { assert_eq!(serde_json::to_value(QueryNodeId(9)).unwrap(), 9); } + // Recovery must retain every output of a shared producer graph and reject + // a missing or replaced output binding, rather than discard another root. + #[test] + fn shared_precompute_graph_recovers_all_output_bindings() { + use planner_types::post_asap::*; + let schema = SummarySchema { + fields: vec![SummaryField { + name: "state".into(), + dtype: SummaryFamilyType::ExactAggregate(ExactKind::Sum, ExactParams::Sum), + nullable: false, + }], + time_index: None, + }; + let dag = ExecutableDag { + nodes: (1..=5) + .map(|id| ExecutableDagNode { + id: PostAsapNodeId(id), + payload: ExecutableOperatorPayload::SummaryMerge, + output_state: ExecutionDataState::INGESTION_SUMMARY, + output_schema: schema.clone(), + guarantee: None, + }) + .collect(), + edges: [(1, 2), (2, 3), (2, 4), (3, 5), (4, 5)] + .into_iter() + .map(|(a, b)| ExecutableDagEdge { + producer: PostAsapNodeId(a), + consumer: PostAsapNodeId(b), + role: EdgeRole::Input, + intermediate_schema: schema.clone(), + data_state: ExecutionDataState::INGESTION_SUMMARY, + grouping: GroupingEdgeCompatibility::Identical, + window: WindowEdgeCompatibility::NotApplicable, + }) + .collect(), + root: PostAsapNodeId(5), + }; + let program = + asap_physical_operators::physical_planner::precompute::compile(&dag, &[1], &[3, 4]) + .unwrap(); + let encoded: serde_json::Value = + serde_json::from_slice(&program.encode().unwrap()).unwrap(); + let mut document = OwnedPostAsapDag::from_executable("shared".into(), &dag).unwrap(); + document.schema_version = MAINTENANCE_DAG_SCHEMA_VERSION; + let mut installed = InstalledPostAsapDag { + document, + native_programs: BTreeMap::from([ + (PostAsapNodeId(3), encoded.clone()), + (PostAsapNodeId(4), encoded), + ]), + binding: BackendExecutableBinding { + nodes: (1..=5) + .map(|id| { + ( + PostAsapNodeId(id), + if id == 2 || id == 5 { + BackendNodeBinding::MaintenanceInput + } else { + BackendNodeBinding::Materialization { + stored_output: crate::policy_fingerprint::PolicyFingerprint( + id as u64, + ) + .into(), + } + }, + ) + }) + .collect(), + precompute_sinks: vec![PostAsapNodeId(3), PostAsapNodeId(4)], + query_sink: PostAsapNodeId(3), + query_plan_sink: QueryNodeId(3), + }, + }; + installed.validate().unwrap(); + assert_eq!( + installed + .native_program(PostAsapNodeId(4)) + .unwrap() + .unwrap() + .roots(), + &[3, 4] + ); + installed.native_programs.remove(&PostAsapNodeId(4)); + assert!(installed.validate().is_err()); + } + #[test] fn installed_maintenance_dag_rejects_complete_dag_version() { let installed = InstalledPostAsapDag { diff --git a/crates/asap_types/src/precompute_plan.rs b/crates/asap_types/src/precompute_plan.rs index b6b943ce0..455f1bf47 100644 --- a/crates/asap_types/src/precompute_plan.rs +++ b/crates/asap_types/src/precompute_plan.rs @@ -593,6 +593,33 @@ impl PrecomputePlan { .native_program(*sink) .map_err(PrecomputePlanError::CatalogContract)? .ok_or_else(invalid)?; + for root in native.roots() { + let root = planner_types::post_asap::PostAsapNodeId( + u32::try_from(*root).map_err(|_| invalid())?, + ); + let Some(crate::executable_plan::BackendNodeBinding::Materialization { + stored_output, + }) = installed.binding.node(root) + else { + return Err(invalid()); + }; + let other = self + .materializations + .iter() + .find(|c| c.policy_fingerprint() == stored_output.fingerprint()) + .ok_or_else(invalid)?; + if other.stored_window_ms() != config.stored_window_ms() + || other.slide_interval != config.slide_interval + || other.pane_origin_ms != config.pane_origin_ms + || other.population_key_encoding != config.population_key_encoding + || other.derived_input.as_ref().map(|d| &d.inputs) + != Some(&derived.inputs) + || (native.roots().len() > 1 + && config.population_key_encoding.is_legacy()) + { + return Err(invalid()); + } + } let native = native.output_contract(u64::from(sink.0)) .map_err(|e| PrecomputePlanError::CatalogContract(e.to_string())) .map(|contract| !asap_physical_operators::physical_planner::precompute::is_population_schema(&contract.schema))? diff --git a/data_plane/src/precompute_engine/maintenance_runtime.rs b/data_plane/src/precompute_engine/maintenance_runtime.rs index 640a7d343..237958f1f 100644 --- a/data_plane/src/precompute_engine/maintenance_runtime.rs +++ b/data_plane/src/precompute_engine/maintenance_runtime.rs @@ -241,7 +241,9 @@ fn execute_prepared_frozen_sink( ), asap_physical_operators::runtime::Limits::default().max_bytes, key.plan_version, - )?; + )? + .remove(&u64::from(sink.0)) + .ok_or("native precompute output missing")?; let mut populations = super::native_precompute::population_states(&batch, u64::try_from(key.window_end_ms)?)?; if populations.len() != 1 { @@ -658,9 +660,7 @@ fn execute_finite_source_cohort( Ok(()) } -/// Evaluate every raw population before one global immutable publication. -/// Canonical source identities are mandatory; legacy plans retain their -/// existing singleton path and cannot silently reinterpret old resolver keys. +/// Evaluate a retained graph once per complete input window and publish all roots. fn execute_finite_complete_populations( store: &crate::storage_engines::sketch_db::index::SketchStore, resolver: &crate::drivers::ingest::series_resolver::SeriesIdResolver, @@ -694,42 +694,14 @@ fn execute_finite_complete_populations( .collect::, _>>()?; asap_types::precompute_plan::validated_source_window_cohort(config, &sources) .map_err(|error| error.to_string())?; - let native_program = installed.native_program(sink)?.filter(|program| { - program - .output_contract(u64::from(sink.0)) - .is_ok_and(|contract| { - !asap_physical_operators::physical_planner::precompute::is_population_schema( - &contract.schema, - ) - }) - }); + let program = installed + .native_program(sink)? + .ok_or("installed precompute output lacks its Planner physical graph")?; if std::iter::once(config) .chain(sources.iter().copied()) - .any(|config| { - config.population_key_encoding != asap_types::PopulationKeyEncoding::CanonicalLabelsV1 - || (native_program.is_none() - && config.slide_interval.checked_mul(1000) != Some(config.stored_window_ms())) - }) + .any(|c| c.population_key_encoding != asap_types::PopulationKeyEncoding::CanonicalLabelsV1) { - return Err( - "complete population execution requires canonical windows supported by the bound program".into(), - ); - } - let dag = installed.document.decode()?; - let target_node = dag - .nodes - .iter() - .find(|node| node.id == sink) - .ok_or("complete target node is absent")?; - if native_program.is_none() { - asap_types::precompute_plan::validate_maintenance_reduction(config, target_node)?; - } - if native_program.is_none() - && !matches!(&target_node.payload, ExecutableOperatorPayload::SummaryAgg { - reduction: planner_types::pre_asap::Reduction::Reduce(keys), .. - } if keys.is_empty()) - { - return Err("complete population execution currently requires one global reduction".into()); + return Err("complete population execution requires canonical population bindings".into()); } let mut common_windows: Option> = None; let mut common_groups: Option> = None; @@ -780,87 +752,81 @@ fn execute_finite_complete_populations( for window in common_windows.unwrap_or_default() { let cohort = store.read_complete_raw_maintenance_cohort(generation, &derived.inputs, window)?; - if let Some(program) = &native_program { - let max_bytes = asap_physical_operators::runtime::Limits::default().max_bytes; - let batch = super::native_precompute::execute( - installed, - program, - cohort.inputs(), - window, - max_bytes, - 0, - ) - .map_err(|e| e.to_string())?; - let mut output = crate::storage_engines::types::PrecomputedOutput::new( - window.0, - window.1, - None, - target.fingerprint(), - ); - output.population_labels = Some(Population::new()); - output.catalog_generation = Some(Arc::clone(generation)); - store.publish_native_summary_output(resolver, config, &output, batch, max_bytes)?; - continue; - } - let (dag, key) = prepare_frozen_maintenance_sink( + let max_bytes = asap_physical_operators::runtime::Limits::default().max_bytes; + let outputs = super::native_precompute::execute( installed, - &plan.materializations, - sink, + &program, cohort.inputs(), window, + max_bytes, + 0, )?; - let digest: [u8; 32] = key - .input_lineage - .as_slice() - .try_into() - .map_err(|_| "complete maintenance digest is invalid")?; - let output_group = Population::new(); - let attrs = crate::drivers::ingest::population_attrs_fingerprint( - config.population_key_encoding, - &[], - )?; - let target_sid = - store.resolve_output_storage_handle(resolver, target, &attrs, Some(generation))?; - if store - .completed_maintenance_coordinates(target, generation)? - .get(&target_sid) - .and_then(|groups| groups.get(&output_group)) - .is_some_and(|windows| windows.contains(&window)) - { - continue; - } - if store - .recover_complete_raw_maintenance_output(target_sid, config, &cohort, digest, window)? - { - continue; - } - let (state, computed_group) = execute_prepared_frozen_sink( - installed, - &plan.materializations, - sink, - MaintenanceInputs::Complete(&cohort), - &dag, - key, - )?; - if computed_group != output_group { - return Err("computed complete population differs from the bound global output".into()); + for (root, batch) in outputs { + let root = PostAsapNodeId(u32::try_from(root)?); + let Some(BackendNodeBinding::Materialization { + stored_output: target, + }) = installed.binding.node(root) + else { + return Err("physical output lacks its storage binding".into()); + }; + let config = plan + .materializations + .iter() + .find(|c| c.policy_fingerprint() == target.fingerprint()) + .ok_or("physical output configuration missing")?; + let mut output = PrecomputedOutput::new(window.0, window.1, None, target.fingerprint()); + output.catalog_generation = Some(Arc::clone(generation)); + if !asap_physical_operators::physical_planner::precompute::is_population_schema( + batch.schema(), + ) { + output.population_labels = Some(Population::new()); + store.publish_native_summary_output(resolver, config, &output, batch, max_bytes)?; + continue; + } + let derived = config + .derived_input + .as_ref() + .ok_or("physical output input identity missing")?; + let digest = frozen_cohort_lineage(cohort.inputs(), derived)?; + for (group, state) in super::native_precompute::population_states(&batch, window.1)? { + let pairs = group + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect::>(); + let attrs = crate::drivers::ingest::population_attrs_fingerprint( + config.population_key_encoding, + &pairs, + )?; + let target_sid = store.resolve_output_storage_handle( + resolver, + *target, + &attrs, + Some(generation), + )?; + if store + .completed_maintenance_coordinates(*target, generation)? + .get(&target_sid) + .and_then(|groups| groups.get(&group)) + .is_some_and(|windows| windows.contains(&window)) + { + continue; + } + if store.recover_complete_raw_maintenance_output( + target_sid, config, &cohort, digest, window, + )? { + continue; + } + output.population_labels = Some(group); + store.publish_complete_raw_maintenance_output( + target_sid, + config, + &output, + state.as_ref(), + &cohort, + digest, + )?; + } } - let mut output = crate::storage_engines::types::PrecomputedOutput::new( - window.0, - window.1, - Some(crate::storage_engines::types::KeyByLabelValues { labels: Vec::new() }), - target.fingerprint(), - ); - output.population_labels = Some(computed_group); - output.catalog_generation = Some(Arc::clone(generation)); - store.publish_complete_raw_maintenance_output( - target_sid, - config, - &output, - state.as_ref(), - &cohort, - digest, - )?; } Ok(()) } @@ -883,7 +849,11 @@ pub(crate) fn execute_finite_maintenance( return Err("finite maintenance catalog generation changed".into()); } for installed in plan.executable_dags.values() { + let mut executed = BTreeSet::new(); for sink in &installed.binding.precompute_sinks { + if executed.contains(sink) { + continue; + } let Some(BackendNodeBinding::Materialization { stored_output: target, }) = installed.binding.node(*sink) @@ -909,6 +879,11 @@ pub(crate) fn execute_finite_maintenance( *sink, &active_generation, )?; + if let Some(program) = installed.native_program(*sink)? { + for root in program.roots() { + executed.insert(PostAsapNodeId(u32::try_from(*root)?)); + } + } continue; } if derived.inputs.len() > 1 { @@ -2903,7 +2878,11 @@ pub(crate) fn execute_revision_outputs( for installed in plan.precompute_plan.executable_dags.values() { let mut result: BTreeMap> = BTreeMap::new(); + let mut executed = BTreeSet::new(); for sink in &installed.binding.precompute_sinks { + if executed.contains(sink) { + continue; + } let Some(BackendNodeBinding::Materialization { stored_output: target, }) = installed.binding.node(*sink) @@ -2919,7 +2898,19 @@ pub(crate) fn execute_revision_outputs( let Some(derived) = &config.derived_input else { continue; }; - result.entry(*target).or_default(); + let program = installed + .native_program(*sink)? + .ok_or("installed revision producer lacks its Planner physical graph")?; + for root in program.roots() { + let root = PostAsapNodeId(u32::try_from(*root)?); + executed.insert(root); + let Some(BackendNodeBinding::Materialization { stored_output }) = + installed.binding.node(root) + else { + return Err("physical output storage binding missing".into()); + }; + result.entry(*stored_output).or_default(); + } let inputs: Vec<_> = raw .iter() .filter(|r| derived.inputs.contains(&r.definition)) @@ -2974,10 +2965,23 @@ pub(crate) fn execute_revision_outputs( { continue; } - if let Some(program) = installed.native_program(*sink)? { - let batch = super::native_precompute::execute( - installed, &program, &selected, window, limit, revision, - )?; + let outputs = super::native_precompute::execute( + installed, &program, &selected, window, limit, revision, + )?; + for (root, batch) in outputs { + let root = PostAsapNodeId(u32::try_from(root)?); + let Some(BackendNodeBinding::Materialization { + stored_output: target, + }) = installed.binding.node(root) + else { + return Err("physical output storage binding missing".into()); + }; + let config = plan + .precompute_plan + .materializations + .iter() + .find(|c| c.policy_fingerprint() == target.fingerprint()) + .ok_or("physical output configuration missing")?; if asap_physical_operators::physical_planner::precompute::is_population_schema( batch.schema(), ) { @@ -3018,7 +3022,6 @@ pub(crate) fn execute_revision_outputs( }); continue; } - return Err("installed revision producer lacks its Planner physical graph".into()); } } for (output, records) in result { diff --git a/data_plane/src/precompute_engine/native_precompute.rs b/data_plane/src/precompute_engine/native_precompute.rs index 52fe5bec4..2cfbfb7dc 100644 --- a/data_plane/src/precompute_engine/native_precompute.rs +++ b/data_plane/src/precompute_engine/native_precompute.rs @@ -21,7 +21,7 @@ pub(super) fn execute( window: (u64, u64), max_bytes: usize, revision: u64, -) -> Result> { +) -> Result, Box> { let mut sources = BTreeMap::new(); let mut input_bytes = 0usize; for (id, contract) in program.input_contracts() { @@ -144,24 +144,38 @@ pub(super) fn execute( )?; // Source buffers and retained publication output share the operator budget. let _inputs = context.reserve(input_bytes)?; - let mut retained = context.reserve(0)?; - let schema = program.output_contract(program.roots()[0])?.schema; - let mut stream = graph.execute(program.roots(), context)?.remove(0); + // Drain all roots together: a bounded shared producer can otherwise block + // while the first root waits for an unpolled sibling to consume its queue. + let streams = graph.execute(program.roots(), context.clone())?; block_on(async { - let mut rows = Vec::new(); - let mut bytes = 0usize; - while let Some(batch) = stream.next().await { - let batch = batch?; - bytes = bytes - .checked_add(batch.bytes()) - .ok_or("native output size overflow")?; - if bytes > max_bytes { - return Err(asap_physical_operators::Error::MemoryLimit.into()); - } - retained.resize(bytes)?; - rows.extend(batch.rows().iter().cloned()); - } - Batch::try_new(schema, rows).map_err(Into::into) + let outputs = + futures::future::try_join_all(program.roots().iter().copied().zip(streams).map( + |(root, mut stream)| { + let context = context.clone(); + async move { + let schema = program.output_contract(root)?.schema; + let mut retained = context.reserve(0)?; + let mut rows = Vec::new(); + let mut bytes = 0usize; + while let Some(batch) = stream.next().await { + let batch = batch?; + bytes = bytes + .checked_add(batch.bytes()) + .ok_or(asap_physical_operators::Error::MemoryLimit)?; + retained.resize(bytes)?; + rows.extend(batch.rows().iter().cloned()); + } + let batch = Batch::try_new(schema, rows)?; + Ok::<_, asap_physical_operators::Error>((root, batch, retained)) + } + }, + )) + .await?; + // Keep every root's reservation until all roots have finished. + Ok(outputs + .into_iter() + .map(|(id, batch, _)| (id, batch)) + .collect()) }) } @@ -205,3 +219,112 @@ pub(super) fn population_states( } Ok(result.into_iter().collect()) } + +#[cfg(test)] +mod tests { + use super::*; + use asap_physical_operators::{physical_planner::InputContract, plan::PhysicalOperator}; + use asap_types::executable_plan::{BackendExecutableBinding, OwnedPostAsapDag}; + + // Both persisted roots must receive the same producer result, while a later + // revision gets fresh state. A failed run must return no partial outputs. + #[test] + fn shared_outputs_use_one_producer_and_isolate_revisions() { + use planner_types::post_asap::{ExactKind, ExactParams}; + let family = SummaryFamilyType::ExactAggregate(ExactKind::Sum, ExactParams::Sum); + let schema = + asap_physical_operators::physical_planner::precompute::population_schema(family); + let merge = Operator::summary_merge(schema.clone(), 2, vec![0]).unwrap(); + let output = merge.output_schema(); + let limit = || Operator::limit(output.clone(), 1, 0, vec![]).unwrap(); + let program = CompiledPhysicalDag::from_operators( + BTreeMap::from([(1, InputContract::bounded(schema))]), + BTreeMap::from([ + (2, (vec![1], merge)), + (3, (vec![2], limit())), + (4, (vec![2], limit())), + ]), + vec![3, 4], + ) + .unwrap(); + let program = CompiledPhysicalDag::decode(&program.encode().unwrap()).unwrap(); + let definition = asap_types::sds::StoredOutputId(1); + let installed = InstalledPostAsapDag { + document: OwnedPostAsapDag { + schema_version: asap_types::executable_plan::MAINTENANCE_DAG_SCHEMA_VERSION, + query_id: "shared".into(), + nodes: vec![], + edges: vec![], + root: PostAsapNodeId(4), + }, + binding: BackendExecutableBinding { + nodes: BTreeMap::from([( + PostAsapNodeId(1), + BackendNodeBinding::Materialization { + stored_output: definition, + }, + )]), + query_sink: PostAsapNodeId(4), + query_plan_sink: asap_types::executable_plan::QueryNodeId(4), + precompute_sinks: vec![PostAsapNodeId(3), PostAsapNodeId(4)], + }, + native_programs: BTreeMap::new(), + }; + let generation = Arc::new(asap_types::sds::CatalogGeneration { + schema_version: 6, + plan_id: 1, + plan_version: 1, + snapshot_sha256: "0".repeat(64), + }); + let state = |value| { + let mut sum = asap_physical_operators::summary_kernels::SumAccumulator::new(); + sum.update(value); + Arc::new(sum) as Arc + }; + for (revision, value) in [(1, 2.0), (2, 7.0)] { + let inputs = [ + crate::storage_engines::sketch_db::index::FrozenExactWindows { + stored_output_reference: asap_types::sds::StoredOutputReference { + stored_output_id: definition, + definition_id: serde_json::from_value(serde_json::json!(format!( + "sds-v1:{}", + "0".repeat(64) + ))) + .unwrap(), + }, + storage_handle: 1, + definition, + generation: generation.clone(), + group: BTreeMap::new(), + windows: BTreeMap::from([ + ((0, 1000), state(value)), + ((1000, 2000), state(3.0)), + ]), + singleton_population_complete: true, + }, + ]; + let outputs = + execute(&installed, &program, &inputs, (0, 2000), 1 << 20, revision).unwrap(); + assert_eq!(outputs.keys().copied().collect::>(), vec![3, 4]); + let state_at = |root| match &outputs[&root].rows()[0][1] { + Value::Summary { state, .. } => state, + _ => panic!("expected retained summary state"), + }; + assert!( + Arc::ptr_eq(state_at(3), state_at(4)), + "shared merge must execute once, not once per output" + ); + assert_eq!( + state_at(3) + .query_statistic(asap_types::Statistic::Sum, &None, &Default::default(),) + .unwrap(), + value + 3.0 + ); + let error = execute(&installed, &program, &inputs, (0, 2000), 1, revision).unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(asap_physical_operators::Error::MemoryLimit) + )); + } + } +} diff --git a/docs/design_docs/precompute-dag-execution.md b/docs/design_docs/precompute-dag-execution.md index 612636da1..e207320da 100644 --- a/docs/design_docs/precompute-dag-execution.md +++ b/docs/design_docs/precompute-dag-execution.md @@ -113,6 +113,14 @@ Installation performs four steps: 4. Build node and source-routing indexes, register the validated definition rows, and activate the coherent plan version for routing and execution. +A retained graph may have several stored-output roots. Outputs with the same +input frontiers, window extent, cadence and phase execute together against one +immutable input snapshot. The runtime consumes every root concurrently under +one memory budget, so a shared producer executes once. Both finite-input +publication and continuous revision publication use these results. Outputs +with different window contracts require separate runs. Recovery validates all +roots and their bindings; it must not silently select only the first root. + Unsupported operators, incompatible bindings, cycles and unsatisfied deployment requirements fail installation before the plan becomes active. From 9280048e900e531a46bae777ceb76215d3fb6f62 Mon Sep 17 00:00:00 2001 From: zzylol Date: Tue, 29 Sep 2026 15:56:59 +0000 Subject: [PATCH 13/19] fix: publish sliding counter revisions at the selected cadence --- .../drivers/ingest/prometheus_remote_write.rs | 25 +++++++ .../precompute_engine/maintenance_runtime.rs | 66 ++++++++++++++----- .../tests/support/native_revision_process.rs | 33 ++++++++-- docs/design_docs/precompute-dag-execution.md | 5 ++ 4 files changed, 108 insertions(+), 21 deletions(-) diff --git a/data_plane/src/drivers/ingest/prometheus_remote_write.rs b/data_plane/src/drivers/ingest/prometheus_remote_write.rs index 6f2c2ba2a..3f05d9b2d 100644 --- a/data_plane/src/drivers/ingest/prometheus_remote_write.rs +++ b/data_plane/src/drivers/ingest/prometheus_remote_write.rs @@ -1871,6 +1871,31 @@ impl PrometheusRemoteWriteReceiver { if bytes > runtime.policy.max_checkpoint_bytes { return Err(Box::new(asap_physical_operators::Error::MemoryLimit)); } } } + // This captured revision contains every accepted local input. + // Empty counter panes between observed panes are therefore known + // empty for this revision, not missing input. Preserve the native + // zero-sample state so counter readout can merge sparse histories; + // a later correction rebuilds these panes in a new revision. + if matches!(&program.family, planner_types::post_asap::SummaryFamilyType::ExactAggregate( + planner_types::post_asap::ExactKind::Rate | planner_types::post_asap::ExactKind::Increase, _ + )) && !matches!(config.window_layout, asap_types::WindowMaterializationLayout::FullWindow) { + let bounds = windows.keys().next().zip(windows.keys().next_back()) + .map(|(first, last)| (first.0, last.0)); + if let Some((mut start, last)) = bounds { + let width = config.stored_window_ms(); + if width == 0 { return Err("counter pane width is zero".into()); } + while start < last { + let end = start.checked_add(width).ok_or("counter pane timestamp overflow")?; + if let std::collections::btree_map::Entry::Vacant(entry) = windows.entry((start, end)) { + entry.insert(program.updater()?); + if windows.values().map(|u| u.memory_usage_bytes()).sum::() > runtime.policy.max_checkpoint_bytes { + return Err(Box::new(asap_physical_operators::Error::MemoryLimit)); + } + } + start = end; + } + } + } let reference = plan.installed_precompute_plan.stored_output_reference(policy_fp.into()).ok_or("revision raw binding missing")?; let group = group_key.as_population_labels(); let states: BTreeMap<_, Arc> = windows.into_iter().map(|(w,updater)|(w,Arc::from(updater.into_accumulator()))).collect(); diff --git a/data_plane/src/precompute_engine/maintenance_runtime.rs b/data_plane/src/precompute_engine/maintenance_runtime.rs index 237958f1f..25f5a49ca 100644 --- a/data_plane/src/precompute_engine/maintenance_runtime.rs +++ b/data_plane/src/precompute_engine/maintenance_runtime.rs @@ -1566,6 +1566,28 @@ mod tests { WindowEdgeCompatibility, }; + // A sixty-second window updated every ten seconds must publish every + // scheduled overlap, including after a correction and with a phase offset. + #[test] + fn revised_sliding_windows_follow_cadence_not_extent() { + for origin in [0, 5_000] { + let starts = [0, 10_000, 20_000, 60_000].map(|t| t + origin); + assert_eq!( + revision_windows(60_000, 10_000, origin as i64, starts.into_iter()).unwrap(), + starts + .into_iter() + .map(|start| (start, start + 60_000)) + .collect() + ); + assert!( + revision_windows(60_000, 10_000, origin as i64, [origin + 1].into_iter()) + .unwrap() + .is_empty() + ); + } + assert!(revision_windows(60_000, 0, 0, [0].into_iter()).is_err()); + } + fn definition(value: u64) -> asap_types::sds::StoredOutputId { asap_types::PolicyFingerprint(value).into() } @@ -2860,6 +2882,24 @@ mod tests { } } +fn revision_windows( + width: u64, + cadence: u64, + origin: i64, + starts: impl Iterator, +) -> Result, MaintenanceError> { + if width == 0 || cadence == 0 { + return Err("revision output has zero window extent or cadence".into()); + } + Ok(starts + .filter_map(|start| { + ((start as i128 - origin as i128).rem_euclid(cadence as i128) == 0) + .then(|| start.checked_add(width).map(|end| (start, end))) + .flatten() + }) + .collect()) +} + /// Captured local input provides fixed population membership for this revision. /// Each retained producer graph executes against the frozen inputs for its output window. pub(crate) fn execute_revision_outputs( @@ -2915,21 +2955,17 @@ pub(crate) fn execute_revision_outputs( .iter() .filter(|r| derived.inputs.contains(&r.definition)) .collect(); - let width = config.stored_window_ms(); - if width == 0 { - return Err("revision output has zero window extent".into()); - } - let possible: BTreeSet<_> = inputs - .iter() - .flat_map(|i| i.windows.keys()) - .filter_map(|(start, _)| { - ((*start as i128 - config.pane_origin_ms.unwrap_or(0) as i128) - .rem_euclid(width as i128) - == 0) - .then(|| start.checked_add(width).map(|end| (*start, end))) - .flatten() - }) - .collect(); + let possible = revision_windows( + config.stored_window_ms(), + config + .slide_interval + .checked_mul(1000) + .ok_or("revision cadence overflow")?, + config.pane_origin_ms.unwrap_or(0), + inputs + .iter() + .flat_map(|input| input.windows.keys().map(|(start, _)| *start)), + )?; for window in possible { let selected: Vec<_> = inputs .iter() diff --git a/data_plane/tests/support/native_revision_process.rs b/data_plane/tests/support/native_revision_process.rs index 4bbd5ab8b..32e7eab0e 100644 --- a/data_plane/tests/support/native_revision_process.rs +++ b/data_plane/tests/support/native_revision_process.rs @@ -5,20 +5,27 @@ use super::*; // after restart, without mixing snapshots or falling back to an external engine. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn native_rate_ensemble_revises_and_recovers() { - run_native_ensemble(None).await; + run_native_ensemble(None, 60_000, 0).await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn native_cms_rate_ensemble_revises_and_recovers() { - run_native_ensemble(Some("CmsWithHeap")).await; + run_native_ensemble(Some("CmsWithHeap"), 60_000, 0).await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn native_count_sketch_rate_ensemble_revises_and_recovers() { - run_native_ensemble(Some("CountSketchWithHeap")).await; + run_native_ensemble(Some("CountSketchWithHeap"), 60_000, 0).await; } -async fn run_native_ensemble(family: Option<&str>) { +// Overlapping sixty-second windows must publish at the selected ten-second +// cadence, including a late series and same-version restart. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sliding_native_rate_ensemble_revises_and_recovers() { + run_native_ensemble(None, 10_000, 5_000).await; +} + +async fn run_native_ensemble(family: Option<&str>, cadence_ms: u64, phase_ms: u64) { use control_plane::physical::{ compiler::{ BackendLocalPlanningInput, DeploymentPlanCompiler, BACKEND_REVISION, PLANNER_REVISION, @@ -43,7 +50,8 @@ async fn run_native_ensemble(family: Option<&str>) { .map(|query| { let mut entry = template.clone(); entry["query"] = (*query).into(); - entry["demand"]["fixed_interval_at"]["interval"] = 60_000.into(); + entry["demand"]["fixed_interval_at"]["interval"] = cadence_ms.into(); + entry["demand"]["fixed_interval_at"]["evaluation_phase"] = phase_ms.into(); entry["requirements"]["accuracy"] = serde_json::json!({"explicit":"Exact"}); entry }) @@ -125,6 +133,14 @@ async fn run_native_ensemble(family: Option<&str>) { "synthetic costs must select the native ensemble" ); assert_eq!(plan.query_plan.entries.len(), 2); + assert!( + plan.precompute_plan.materializations.iter().any(|config| { + config.derived_input.is_some() + && config.stored_window_ms() == 60_000 + && config.slide_interval * 1_000 == cadence_ms + }), + "the fixture must actually deploy the selected overlapping windows" + ); let installed = data_plane::drivers::query::servers::http::PhysicalPlanInstallRequest { summary_catalog: plan.summary_catalog, collector_plans: plan.collector_plans, @@ -166,7 +182,12 @@ async fn run_native_ensemble(family: Option<&str>) { .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_millis() as i64; - let start = (now / 60_000 - 1) * 60_000; + let start = if cadence_ms == 60_000 { + (now / 60_000 - 1) * 60_000 + } else { + // An eligible cadence boundary that is not a window-extent boundary. + (now / 60_000 - 2) * 60_000 + phase_ms as i64 + cadence_ms as i64 + }; let end = start + 60_000; let request = |instance: &str, last: f64| WriteRequest { timeseries: vec![series_with_labels( diff --git a/docs/design_docs/precompute-dag-execution.md b/docs/design_docs/precompute-dag-execution.md index e207320da..9bd7f6050 100644 --- a/docs/design_docs/precompute-dag-execution.md +++ b/docs/design_docs/precompute-dag-execution.md @@ -121,6 +121,11 @@ publication and continuous revision publication use these results. Outputs with different window contracts require separate runs. Recovery validates all roots and their bindings; it must not silently select only the first root. +Window extent and publication cadence are independent. A 60-second window +published every 10 seconds includes the ranges 0–60, 10–70 and 20–80 seconds. +Continuous revisions recompute each affected window on that cadence, preserving +the selected phase; they must not publish only disjoint 60-second windows. + Unsupported operators, incompatible bindings, cycles and unsatisfied deployment requirements fail installation before the plan becomes active. From 8afaf036f1cefb2ae4a5b18f7d52399d63ca4b38 Mon Sep 17 00:00:00 2001 From: zzylol Date: Tue, 29 Sep 2026 16:17:58 +0000 Subject: [PATCH 14/19] test: retain summary state through shared physical projections --- Cargo.lock | 14 ++++++------ Cargo.toml | 12 +++++----- .../precompute_engine/native_precompute.rs | 22 ++++++++++++++++--- 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cfe039d1b..2f59186aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -364,7 +364,7 @@ dependencies = [ [[package]] name = "asap-aware-mapping" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3e05ef648dfe4fba834d4f37beff1920ac8e0411#3e05ef648dfe4fba834d4f37beff1920ac8e0411" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3bff87fe6ca816627232a66240e778a7016a60a3#3bff87fe6ca816627232a66240e778a7016a60a3" dependencies = [ "asap-types", "asap_sketchlib 0.3.0 (git+https://github.com/ProjectASAP/asap_sketchlib)", @@ -376,7 +376,7 @@ dependencies = [ [[package]] name = "asap-frontend-promql" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3e05ef648dfe4fba834d4f37beff1920ac8e0411#3e05ef648dfe4fba834d4f37beff1920ac8e0411" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3bff87fe6ca816627232a66240e778a7016a60a3#3bff87fe6ca816627232a66240e778a7016a60a3" dependencies = [ "asap-types", "promql-parser 0.10.0 (git+https://github.com/ProjectASAP/promql-parser?rev=9fede7eecca923c9882fe256484d00d37f8706cb)", @@ -385,7 +385,7 @@ dependencies = [ [[package]] name = "asap-frontend-sql" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3e05ef648dfe4fba834d4f37beff1920ac8e0411#3e05ef648dfe4fba834d4f37beff1920ac8e0411" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3bff87fe6ca816627232a66240e778a7016a60a3#3bff87fe6ca816627232a66240e778a7016a60a3" dependencies = [ "asap-sql-function-catalog", "asap-types", @@ -396,7 +396,7 @@ dependencies = [ [[package]] name = "asap-physical-operators" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3e05ef648dfe4fba834d4f37beff1920ac8e0411#3e05ef648dfe4fba834d4f37beff1920ac8e0411" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3bff87fe6ca816627232a66240e778a7016a60a3#3bff87fe6ca816627232a66240e778a7016a60a3" dependencies = [ "asap-types", "asap_sketch_codec", @@ -416,12 +416,12 @@ dependencies = [ [[package]] name = "asap-sql-function-catalog" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3e05ef648dfe4fba834d4f37beff1920ac8e0411#3e05ef648dfe4fba834d4f37beff1920ac8e0411" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3bff87fe6ca816627232a66240e778a7016a60a3#3bff87fe6ca816627232a66240e778a7016a60a3" [[package]] name = "asap-types" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3e05ef648dfe4fba834d4f37beff1920ac8e0411#3e05ef648dfe4fba834d4f37beff1920ac8e0411" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3bff87fe6ca816627232a66240e778a7016a60a3#3bff87fe6ca816627232a66240e778a7016a60a3" dependencies = [ "serde", "serde_json", @@ -443,7 +443,7 @@ dependencies = [ [[package]] name = "asap_sketch_codec" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3e05ef648dfe4fba834d4f37beff1920ac8e0411#3e05ef648dfe4fba834d4f37beff1920ac8e0411" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3bff87fe6ca816627232a66240e778a7016a60a3#3bff87fe6ca816627232a66240e778a7016a60a3" dependencies = [ "asap_sketchlib 0.3.0 (git+https://github.com/ProjectASAP/asap_sketchlib?rev=5f03ccbd798ed5fec62bdd839bcb331123cab369)", "prost", diff --git a/Cargo.toml b/Cargo.toml index 55898c31d..9d0700a66 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,10 +14,10 @@ version = "0.1.0" [workspace.dependencies] # Keep Planner frontends, selection, and IR on the same immutable revision. # Alias upstream asap-types because this workspace also defines asap_types. -planner-types = { package = "asap-types", git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3e05ef648dfe4fba834d4f37beff1920ac8e0411" } -asap-aware-mapping = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3e05ef648dfe4fba834d4f37beff1920ac8e0411" } -asap-frontend-promql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3e05ef648dfe4fba834d4f37beff1920ac8e0411" } -asap-frontend-sql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3e05ef648dfe4fba834d4f37beff1920ac8e0411" } +planner-types = { package = "asap-types", git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3bff87fe6ca816627232a66240e778a7016a60a3" } +asap-aware-mapping = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3bff87fe6ca816627232a66240e778a7016a60a3" } +asap-frontend-promql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3bff87fe6ca816627232a66240e778a7016a60a3" } +asap-frontend-sql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3bff87fe6ca816627232a66240e778a7016a60a3" } # Shared external deps (used by 2+ crates) serde = { version = "1.0", features = ["derive"] } @@ -37,8 +37,8 @@ arc-swap = "1.7" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } # Internal crates -asap-physical-operators = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3e05ef648dfe4fba834d4f37beff1920ac8e0411" } -asap_sketch_codec = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3e05ef648dfe4fba834d4f37beff1920ac8e0411" } +asap-physical-operators = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3bff87fe6ca816627232a66240e778a7016a60a3" } +asap_sketch_codec = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3bff87fe6ca816627232a66240e778a7016a60a3" } asap_types = { path = "crates/asap_types" } asap_otel_proto = { path = "crates/asap_otel_proto" } indexmap = { version = "2.0", features = ["serde"] } diff --git a/data_plane/src/precompute_engine/native_precompute.rs b/data_plane/src/precompute_engine/native_precompute.rs index 2cfbfb7dc..a00a0ca12 100644 --- a/data_plane/src/precompute_engine/native_precompute.rs +++ b/data_plane/src/precompute_engine/native_precompute.rs @@ -236,13 +236,29 @@ mod tests { asap_physical_operators::physical_planner::precompute::population_schema(family); let merge = Operator::summary_merge(schema.clone(), 2, vec![0]).unwrap(); let output = merge.output_schema(); - let limit = || Operator::limit(output.clone(), 1, 0, vec![]).unwrap(); + let project = || { + Operator::project( + output.clone(), + output + .fields + .iter() + .enumerate() + .map(|(index, field)| { + ( + field.name.clone(), + asap_physical_operators::expressions::Expression::Column(index), + ) + }) + .collect(), + ) + .unwrap() + }; let program = CompiledPhysicalDag::from_operators( BTreeMap::from([(1, InputContract::bounded(schema))]), BTreeMap::from([ (2, (vec![1], merge)), - (3, (vec![2], limit())), - (4, (vec![2], limit())), + (3, (vec![2], project())), + (4, (vec![2], project())), ]), vec![3, 4], ) From e8960b1ce69f9b0f752b8893a06af6283c28fd36 Mon Sep 17 00:00:00 2001 From: zzylol Date: Tue, 29 Sep 2026 18:48:34 +0000 Subject: [PATCH 15/19] refactor: adopt precompute/query_time vocabulary from #783 Rebasing onto #783 left code added by this branch on the old names (ResidualQueryOperator, query_plan::residual, MaintenanceDagSink, MAINTENANCE_DAG_SCHEMA_VERSION). Apply the same renames, and keep the backend subDAG scheduler deleted as this branch intends. Co-Authored-By: Claude Opus 5.5 --- control_plane/src/physical/plan_dot.rs | 4 +- control_plane/src/physical/workload_cost.rs | 2 +- control_plane/src/query_plan.rs | 2 +- .../src/query_plan/physical_values.rs | 4 +- crates/asap_types/src/executable_plan.rs | 5 +- crates/asap_types/src/query_plan.rs | 2 +- crates/asap_types/src/query_plan/native.rs | 2 +- .../precompute_engine/native_precompute.rs | 2 +- .../src/precompute_engine/subdag_scheduler.rs | 780 ------------------ .../asap_query_engine/logical_dag.rs | 4 +- .../logical_dag/native_values.rs | 8 +- 11 files changed, 19 insertions(+), 796 deletions(-) delete mode 100644 data_plane/src/precompute_engine/subdag_scheduler.rs diff --git a/control_plane/src/physical/plan_dot.rs b/control_plane/src/physical/plan_dot.rs index 9eb57d8f8..98bf25509 100644 --- a/control_plane/src/physical/plan_dot.rs +++ b/control_plane/src/physical/plan_dot.rs @@ -197,7 +197,9 @@ fn query_node_label(node: &QueryPlanNode) -> String { }) .unwrap_or_else(|_| "Invalid physical DAG".into()) } - QueryPlanNode::Logical { operator, .. } => format!("Logical\n{}", query_time_label(operator)), + QueryPlanNode::Logical { operator, .. } => { + format!("Logical\n{}", query_time_label(operator)) + } QueryPlanNode::Scalar { value } => format!("Scalar\n{value}"), QueryPlanNode::Binary { operator, .. } => format!("Binary\n{operator:?}"), QueryPlanNode::ReduceSum { .. } => "ReduceSum".into(), diff --git a/control_plane/src/physical/workload_cost.rs b/control_plane/src/physical/workload_cost.rs index 61e815c49..b1b8c4bcf 100644 --- a/control_plane/src/physical/workload_cost.rs +++ b/control_plane/src/physical/workload_cost.rs @@ -1091,7 +1091,7 @@ mod tests { .all(|entry| entry.nodes.values().any(|node| matches!( node, crate::query_plan::QueryPlanNode::Logical { - operator: crate::query_plan::residual::ResidualQueryOperator::CurrentSeries { + operator: crate::query_plan::query_time::QueryTimeOperator::CurrentSeries { readout: asap_types::query_plan::current_series::SeriesReadout::Count, .. }, diff --git a/control_plane/src/query_plan.rs b/control_plane/src/query_plan.rs index 4917df29b..b05464efc 100644 --- a/control_plane/src/query_plan.rs +++ b/control_plane/src/query_plan.rs @@ -3,7 +3,7 @@ mod clickhouse_exact; pub mod physical_values; -pub mod residual; +pub mod query_time; pub use asap_types::query_plan::*; #[cfg(test)] diff --git a/control_plane/src/query_plan/physical_values.rs b/control_plane/src/query_plan/physical_values.rs index 86f869680..f3855896c 100644 --- a/control_plane/src/query_plan/physical_values.rs +++ b/control_plane/src/query_plan/physical_values.rs @@ -5,7 +5,7 @@ use planner_types::{ post_asap::BinaryOperator, pre_asap::{AggIntent, ArithmeticOpKind, BinaryOpKind, ColumnRef, CompareOpKind, GroupKeys}, }; -use residual::{Aggregation, BinaryOperation, Grouping, ResidualQueryOperator as Operation}; +use query_time::{Aggregation, BinaryOperation, Grouping, QueryTimeOperator as Operation}; fn invalid(error: impl std::fmt::Display) -> QueryPlanError { QueryPlanError::Invalid(error.to_string()) @@ -132,7 +132,7 @@ pub fn compile( Some(physical::compile_limit(*n, *offset, &grouping(groups)).map_err(invalid)?) } Operation::Temporal { operation } => { - use residual::TemporalOperation as T; + use query_time::TemporalOperation as T; let intent = match operation { T::Rate => AggIntent::Rate, T::Increase => AggIntent::Increase, diff --git a/crates/asap_types/src/executable_plan.rs b/crates/asap_types/src/executable_plan.rs index 00ec3bc39..530d58d66 100644 --- a/crates/asap_types/src/executable_plan.rs +++ b/crates/asap_types/src/executable_plan.rs @@ -194,8 +194,7 @@ impl InstalledPostAsapDag { if self.document.schema_version != PRECOMPUTE_DAG_SCHEMA_VERSION { return Err("unsupported maintenance DAG document version".into()); } - self.binding - .validate_precompute(&self.document.decode()?)?; + self.binding.validate_precompute(&self.document.decode()?)?; for sink in self.native_programs.keys() { self.native_program(*sink)?; } @@ -504,7 +503,7 @@ mod tests { let encoded: serde_json::Value = serde_json::from_slice(&program.encode().unwrap()).unwrap(); let mut document = OwnedPostAsapDag::from_executable("shared".into(), &dag).unwrap(); - document.schema_version = MAINTENANCE_DAG_SCHEMA_VERSION; + document.schema_version = PRECOMPUTE_DAG_SCHEMA_VERSION; let mut installed = InstalledPostAsapDag { document, native_programs: BTreeMap::from([ diff --git a/crates/asap_types/src/query_plan.rs b/crates/asap_types/src/query_plan.rs index 86430ff61..35c4349eb 100644 --- a/crates/asap_types/src/query_plan.rs +++ b/crates/asap_types/src/query_plan.rs @@ -7,7 +7,7 @@ pub mod current_series; mod native; -pub mod residual; +pub mod query_time; use std::collections::{BTreeMap, BTreeSet}; diff --git a/crates/asap_types/src/query_plan/native.rs b/crates/asap_types/src/query_plan/native.rs index 67e25598c..70c81ca3f 100644 --- a/crates/asap_types/src/query_plan/native.rs +++ b/crates/asap_types/src/query_plan/native.rs @@ -11,7 +11,7 @@ impl QueryPlanEntry { match self.nodes.get(&self.root) { Some(QueryPlanNode::Logical { operator: - residual::ResidualQueryOperator::CurrentSeries { + query_time::QueryTimeOperator::CurrentSeries { population, readout: current_series::SeriesReadout::Snapshot, }, diff --git a/data_plane/src/precompute_engine/native_precompute.rs b/data_plane/src/precompute_engine/native_precompute.rs index a00a0ca12..a669e4042 100644 --- a/data_plane/src/precompute_engine/native_precompute.rs +++ b/data_plane/src/precompute_engine/native_precompute.rs @@ -267,7 +267,7 @@ mod tests { let definition = asap_types::sds::StoredOutputId(1); let installed = InstalledPostAsapDag { document: OwnedPostAsapDag { - schema_version: asap_types::executable_plan::MAINTENANCE_DAG_SCHEMA_VERSION, + schema_version: asap_types::executable_plan::PRECOMPUTE_DAG_SCHEMA_VERSION, query_id: "shared".into(), nodes: vec![], edges: vec![], diff --git a/data_plane/src/precompute_engine/subdag_scheduler.rs b/data_plane/src/precompute_engine/subdag_scheduler.rs deleted file mode 100644 index 6f0d6c40a..000000000 --- a/data_plane/src/precompute_engine/subdag_scheduler.rs +++ /dev/null @@ -1,780 +0,0 @@ -use asap_physical_operators::dag as execution; -use asap_types::executable_plan::{BackendExecutableBinding, BackendNodeBinding}; -use futures::StreamExt; -use planner_types::post_asap::PostAsapNodeId; -use planner_types::post_asap::{ - EdgeRole, ExecutableDag, ExecutableDagNode, ExecutableOperatorPayload, ExecutionDataState, -}; -use std::{cell::RefCell, rc::Rc}; -use std::{ - collections::{BTreeMap, BTreeSet}, - sync::Arc, -}; - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct MaterializationCommitKey { - pub plan_id: u64, - pub plan_version: u64, - pub stored_output: asap_types::sds::StoredOutputId, - pub window_start_ms: i64, - pub window_end_ms: i64, - /// Producer lineage identity, including source and immutable input payload. - /// Production uses a domain-separated SHA-256 digest to avoid retaining - /// another full copy of every source summary. - pub input_lineage: Vec, -} - -pub trait PrecomputeOperatorRegistry { - type Error; - /// A materialized input is an execution frontier: its absorbed semantic - /// dependencies have already run and must not be evaluated again. - fn materialized_input(&self, _node: &ExecutableDagNode) -> Result, Self::Error> { - Ok(None) - } - fn output_bytes(&self, _value: &V) -> usize { - std::mem::size_of::().max(1) - } - fn execute( - &self, - node: &ExecutableDagNode, - inputs: &[Arc], - context: execution::RunContext, - ) -> Result; -} - -/// Atomic persistence boundary. Implementations must return the already -/// committed value when the same lineage key is replayed after a retry or -/// restart, and must never publish two values for one key. -pub trait IdempotentCommitSink { - type Error; - fn get(&self, key: &MaterializationCommitKey) -> Result>, Self::Error>; - fn commit_if_absent( - &self, - key: MaterializationCommitKey, - value: Arc, - ) -> Result, Self::Error>; -} - -#[derive(Debug)] -pub enum ScheduleError { - Invalid(String), - Operator(OperatorError), - Sink(SinkError), - Execution(execution::Error), -} - -/// Execute one precompute sink and its transitive dependencies in topological -/// order. Intermediates use `Arc`, so a shared upstream node is computed once -/// without copying summary payloads. Only the sink is committed; upstream -/// materialization sinks are committed by their own lineage-keyed invocation. -pub fn execute_precompute_sink( - dag: &ExecutableDag, - binding: &BackendExecutableBinding, - sink_node: PostAsapNodeId, - key: MaterializationCommitKey, - registry: &R, - sink: &S, - context: execution::RunContext, -) -> Result, ScheduleError> -where - R: PrecomputeOperatorRegistry, - S: IdempotentCommitSink, -{ - let mut outputs = - execute_precompute_sinks(dag, binding, &[(sink_node, key)], registry, sink, context)?; - Ok(outputs.remove(0)) -} - -/// Evaluate all selected stored outputs with one dependency cache. Keys must -/// describe the same input revision and window; only their output identity may -/// differ. Validation finishes before executing or committing any output. -pub fn execute_precompute_sinks( - dag: &ExecutableDag, - binding: &BackendExecutableBinding, - outputs: &[(PostAsapNodeId, MaterializationCommitKey)], - registry: &R, - sink: &S, - context: execution::RunContext, -) -> Result>, ScheduleError> -where - R: PrecomputeOperatorRegistry, - S: IdempotentCommitSink, -{ - let mut unique = BTreeSet::new(); - for (node, key) in outputs { - if !unique.insert(node.0) - || !binding.precompute_sinks.contains(node) - || !matches!(binding.node(*node), Some(BackendNodeBinding::Materialization { stored_output }) if *stored_output == key.stored_output) - { - return Err(ScheduleError::Invalid( - "commit key does not match a unique stored output binding".into(), - )); - } - let first = &outputs[0].1; - if ( - key.plan_id, - key.plan_version, - key.window_start_ms, - key.window_end_ms, - &key.input_lineage, - ) != ( - first.plan_id, - first.plan_version, - first.window_start_ms, - first.window_end_ms, - &first.input_lineage, - ) { - return Err(ScheduleError::Invalid( - "stored outputs require one evaluation window and input revision".into(), - )); - } - } - binding - .validate_precompute(dag) - .map_err(ScheduleError::Invalid)?; - let nodes = dag - .nodes - .iter() - .map(|n| (n.id.0, n)) - .collect::>(); - let mut incoming = BTreeMap::>::new(); - for edge in &dag.edges { - incoming.entry(edge.consumer.0).or_default().push(edge); - } - for node in &dag.nodes { - if matches!(node.payload, ExecutableOperatorPayload::Binary { .. }) { - incoming.entry(node.id.0).or_default(); - } - } - let mut inputs = BTreeMap::>::new(); - for (consumer, edges) in incoming { - let ordered = if nodes - .get(&consumer) - .is_some_and(|node| matches!(node.payload, ExecutableOperatorPayload::Binary { .. })) - { - // Wire order is not operand order. Preserve noncommutative binary - // semantics even when a valid transport reorders its edge list. - let left = edges - .iter() - .filter(|edge| edge.role == EdgeRole::Left) - .collect::>(); - let right = edges - .iter() - .filter(|edge| edge.role == EdgeRole::Right) - .collect::>(); - if edges.len() != 2 || left.len() != 1 || right.len() != 1 { - return Err(ScheduleError::Invalid( - "binary maintenance input roles must be exactly Left and Right".into(), - )); - } - vec![left[0].producer.0, right[0].producer.0] - } else { - edges.iter().map(|edge| edge.producer.0).collect() - }; - inputs.insert(consumer, ordered); - } - if outputs.is_empty() { - return Ok(Vec::new()); - } - let error = Rc::new(RefCell::new(None)); - let mut sources = BTreeMap::new(); - for (node, key) in outputs { - if let Some(value) = sink.get(key).map_err(ScheduleError::Sink)? { - sources.insert(node.0, value); - } - } - let committed = sources.keys().copied().collect::>(); - let mut graph = execution::PhysicalDag::default(); - let mut pending = outputs.iter().map(|(id, _)| id.0).collect::>(); - let mut added = BTreeSet::new(); - while let Some(id) = pending.pop() { - if !added.insert(id) { - continue; - } - let node = *nodes - .get(&id) - .ok_or_else(|| ScheduleError::Invalid(format!("missing node {id}")))?; - if node.output_state.timing == planner_types::post_asap::ExecutionTiming::QueryTime { - return Err(ScheduleError::Invalid(format!( - "query-time node {id} in precompute dependency path" - ))); - } - let source = if let Some(value) = sources.remove(&id) { - Some(value) - } else { - registry - .materialized_input(node) - .map_err(ScheduleError::Operator)? - .map(Arc::new) - }; - let children = if source.is_some() { - vec![] - } else { - inputs.get(&id).cloned().unwrap_or_default() - }; - let schemas = children - .iter() - .map(|child| { - nodes - .get(child) - .map(|n| n.output_schema.clone()) - .ok_or_else(|| ScheduleError::Invalid(format!("missing node {child}"))) - }) - .collect::, _>>()?; - pending.extend(children.iter().copied()); - graph - .add( - u64::from(id), - children.into_iter().map(u64::from).collect(), - IngestionOperator { - node, - registry, - source, - schemas, - error: error.clone(), - }, - ) - .map_err(ScheduleError::Execution)?; - } - let roots = outputs - .iter() - .map(|(id, _)| u64::from(id.0)) - .collect::>(); - let streams = graph - .execute(&roots, context) - .map_err(ScheduleError::Execution)?; - futures::executor::block_on(futures::future::try_join_all( - streams - .into_iter() - .zip(outputs) - .map(|(mut stream, (node, key))| { - let error = &error; - let committed = &committed; - async move { - let result = stream.next().await.ok_or_else(|| { - ScheduleError::Invalid("ingestion root produced no value".into()) - })?; - let value = match result { - Ok(value) => Arc::clone(value.value()), - Err(failure) => { - return Err(match error.borrow_mut().take() { - Some(error) => ScheduleError::Operator(error), - None => ScheduleError::Execution(failure), - }) - } - }; - if committed.contains(&node.0) { - Ok(value) - } else { - sink.commit_if_absent(key.clone(), value) - .map_err(ScheduleError::Sink) - } - } - }), - )) -} - -struct IngestionOperator<'a, V, R: PrecomputeOperatorRegistry> { - node: &'a ExecutableDagNode, - registry: &'a R, - source: Option>, - schemas: Vec, - error: Rc>>, -} -impl> - execution::PhysicalOperator, planner_types::post_asap::SummarySchema> - for IngestionOperator<'_, V, R> -{ - fn name(&self) -> &str { - "InstalledIngestionOperator" - } - fn input_schemas(&self) -> Vec { - self.schemas.clone() - } - fn output_schema(&self) -> planner_types::post_asap::SummarySchema { - self.node.output_schema.clone() - } - fn output_bytes(&self, value: &Arc) -> usize { - self.registry.output_bytes(value) - } - fn start<'a>( - &'a self, - inputs: Vec>>, - context: execution::RunContext, - ) -> Result>, execution::Error> { - Ok(futures::stream::once(async move { - if let Some(source) = &self.source { - return Ok(Arc::clone(source)); - } - let inputs = - futures::future::try_join_all(inputs.into_iter().map(|mut input| async move { - input.next().await.ok_or_else(|| { - execution::Error::Operator("ingestion input produced no value".into()) - })? - })) - .await?; - let values = inputs - .iter() - .map(|value| Arc::clone(value.value())) - .collect::>(); - self.registry - .execute(self.node, &values, context) - .map(Arc::new) - .map_err(|e| { - *self.error.borrow_mut() = Some(e); - execution::Error::Operator(format!("ingestion node {} failed", self.node.id.0)) - }) - }) - .boxed_local()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use planner_types::post_asap::{EdgeRole, ExecutionDataState}; - use planner_types::post_asap::{ - ExecutableDagEdge, ExecutableOperatorPayload, GroupingEdgeCompatibility, SummarySchema, - WindowEdgeCompatibility, - }; - use std::sync::Mutex; - - fn test_context() -> execution::RunContext { - execution::RunContext::new( - execution::Scope::Ingestion { - window_start_ms: 10, - window_end_ms: 20, - revision: 1, - }, - execution::Limits::default(), - ) - .unwrap() - } - - // Caller cancellation or exhaustion must terminate without committing state. - #[test] - fn caller_control_terminates_before_publication() { - for cancelled in [true, false] { - let context = execution::RunContext::new( - execution::Scope::Ingestion { - window_start_ms: 10, - window_end_ms: 20, - revision: 1, - }, - execution::Limits { - max_bytes: 1, - ..Default::default() - }, - ) - .unwrap(); - if cancelled { - context.cancel(); - } - let dag = ExecutableDag { - nodes: (0..4).map(node).collect(), - edges: vec![edge(0, 1), edge(1, 3)], - root: PostAsapNodeId(3), - }; - let (dag, binding) = maintenance_only(dag, binding(), PostAsapNodeId(3)); - let registry = Registry::default(); - let sink = Sink::default(); - let result = execute_precompute_sink( - &dag, - &binding, - PostAsapNodeId(3), - key(3), - ®istry, - &sink, - context, - ); - fn cause(error: &execution::Error) -> &execution::Error { - match error { - execution::Error::AtNode { source, .. } => cause(source), - error => error, - } - } - let Err(ScheduleError::Execution(error)) = result else { - panic!("expected a typed execution error, got {result:?}"); - }; - assert_eq!( - cause(&error), - if cancelled { - &execution::Error::Cancelled - } else { - &execution::Error::MemoryLimit - } - ); - assert!(sink.0.lock().unwrap().is_empty()); - if cancelled { - assert!(registry.0.lock().unwrap().is_empty()); - } - } - } - - fn binding() -> BackendExecutableBinding { - BackendExecutableBinding { - nodes: (0..4) - .map(|id| { - ( - PostAsapNodeId(id), - BackendNodeBinding::Materialization { - stored_output: asap_types::PolicyFingerprint(u64::from(id) + 1).into(), - }, - ) - }) - .chain([( - PostAsapNodeId(4), - BackendNodeBinding::Query { - query_node: asap_types::query_plan::QueryNodeId(9), - }, - )]) - .collect(), - query_sink: PostAsapNodeId(4), - query_plan_sink: asap_types::query_plan::QueryNodeId(9), - precompute_sinks: vec![PostAsapNodeId(3)], - } - } - - fn maintenance_only( - mut dag: ExecutableDag, - mut binding: BackendExecutableBinding, - sink: PostAsapNodeId, - ) -> (ExecutableDag, BackendExecutableBinding) { - let retained = dag - .nodes - .iter() - .filter(|node| { - node.output_state.timing == planner_types::post_asap::ExecutionTiming::IngestionTime - }) - .map(|node| node.id) - .collect::>(); - dag.nodes.retain(|node| retained.contains(&node.id)); - dag.edges - .retain(|edge| retained.contains(&edge.producer) && retained.contains(&edge.consumer)); - binding.nodes.retain(|id, _| retained.contains(id)); - dag.root = sink; - (dag, binding) - } - - fn node(id: u32) -> ExecutableDagNode { - ExecutableDagNode { - id: PostAsapNodeId(id), - payload: ExecutableOperatorPayload::SummarySubtract, - output_state: ExecutionDataState::INGESTION_SUMMARY, - output_schema: SummarySchema { - fields: Vec::new(), - time_index: None, - }, - guarantee: None, - } - } - - fn edge(producer: u32, consumer: u32) -> ExecutableDagEdge { - ExecutableDagEdge { - producer: PostAsapNodeId(producer), - consumer: PostAsapNodeId(consumer), - role: EdgeRole::Input, - intermediate_schema: SummarySchema { - fields: Vec::new(), - time_index: None, - }, - data_state: ExecutionDataState::INGESTION_SUMMARY, - grouping: GroupingEdgeCompatibility::Identical, - window: WindowEdgeCompatibility::NotApplicable, - } - } - - #[derive(Default)] - struct Registry(Mutex>); - - impl PrecomputeOperatorRegistry for Registry { - type Error = String; - - fn execute( - &self, - node: &ExecutableDagNode, - inputs: &[Arc], - _context: execution::RunContext, - ) -> Result { - *self.0.lock().unwrap().entry(node.id.0).or_default() += 1; - Ok(node.id.0 + inputs.iter().map(|v| **v).sum::()) - } - } - - #[derive(Default)] - struct Sink(Mutex>>); - - impl IdempotentCommitSink for Sink { - type Error = String; - - fn get(&self, key: &MaterializationCommitKey) -> Result>, Self::Error> { - Ok(self.0.lock().unwrap().get(key).cloned()) - } - - fn commit_if_absent( - &self, - key: MaterializationCommitKey, - value: Arc, - ) -> Result, Self::Error> { - Ok(Arc::clone( - self.0.lock().unwrap().entry(key).or_insert(value), - )) - } - } - - fn key(node_id: u32) -> MaterializationCommitKey { - MaterializationCommitKey { - plan_id: 7, - plan_version: 1, - stored_output: asap_types::PolicyFingerprint(u64::from(node_id) + 1).into(), - window_start_ms: 10, - window_end_ms: 20, - input_lineage: b"checkpoint:3".to_vec(), - } - } - - // Two stored sinks in one evaluation reuse their shared upstream work. - #[test] - fn stored_sinks_share_one_evaluation() { - let mut query = node(4); - query.output_state = ExecutionDataState::QUERY_ROWS; - let dag = ExecutableDag { - nodes: vec![node(0), node(1), node(2), node(3), query], - edges: vec![edge(0, 1), edge(1, 2), edge(1, 3)], - root: PostAsapNodeId(4), - }; - let mut bindings = binding(); - bindings.precompute_sinks = vec![PostAsapNodeId(2), PostAsapNodeId(3)]; - let (dag, bindings) = maintenance_only(dag, bindings, PostAsapNodeId(3)); - let registry = Registry::default(); - let sink = Sink::default(); - execute_precompute_sinks( - &dag, - &bindings, - &[(PostAsapNodeId(2), key(2)), (PostAsapNodeId(3), key(3))], - ®istry, - &sink, - test_context(), - ) - .unwrap(); - assert_eq!( - *registry.0.lock().unwrap(), - BTreeMap::from([(0, 1), (1, 1), (2, 1), (3, 1)]) - ); - } - - #[test] - fn binary_operand_roles_survive_edge_reordering_and_reject_duplicates() { - use planner_types::post_asap::BinaryOperator; - use planner_types::pre_asap::{ArithmeticOpKind, BinaryOpKind}; - struct Subtract; - impl PrecomputeOperatorRegistry for Subtract { - type Error = String; - fn execute( - &self, - node: &ExecutableDagNode, - inputs: &[Arc], - _context: execution::RunContext, - ) -> Result { - match node.id.0 { - 0 => Ok(10), - 1 => Ok(3), - 3 => Ok(*inputs[0] - *inputs[1]), - _ => Err("unexpected node".into()), - } - } - } - let mut binary = node(3); - binary.payload = ExecutableOperatorPayload::Binary { - operator: BinaryOperator { - checked_relative_division: false, - checked_finite_division: false, - kind: BinaryOpKind::Arithmetic(ArithmeticOpKind::Sub), - vector_match: None, - }, - }; - let mut left = edge(0, 3); - left.role = EdgeRole::Left; - let mut right = edge(1, 3); - right.role = EdgeRole::Right; - let mut dag = ExecutableDag { - nodes: vec![node(0), node(1), node(2), binary, { - let mut query = node(4); - query.output_state = ExecutionDataState::QUERY_ROWS; - query - }], - edges: vec![right, left], - root: PostAsapNodeId(3), - }; - let execute = |dag: &ExecutableDag| { - let (dag, binding) = maintenance_only(dag.clone(), binding(), PostAsapNodeId(3)); - execute_precompute_sink( - &dag, - &binding, - PostAsapNodeId(3), - key(3), - &Subtract, - &Sink::default(), - test_context(), - ) - }; - assert_eq!(*execute(&dag).unwrap(), 7); - dag.edges.reverse(); - assert_eq!(*execute(&dag).unwrap(), 7); - dag.edges[1].role = EdgeRole::Left; - assert!(matches!(execute(&dag), Err(ScheduleError::Invalid(_)))); - dag.edges.pop(); - assert!(matches!(execute(&dag), Err(ScheduleError::Invalid(_)))); - dag.edges.clear(); - assert!(matches!(execute(&dag), Err(ScheduleError::Invalid(_)))); - } - - #[test] - fn shared_dependency_executes_once_and_replay_reads_committed_value() { - // 0 is shared by 1 and 2; 3 consumes both branches. - let dag = ExecutableDag { - nodes: (0..4) - .map(node) - .chain([{ - let mut query = node(4); - query.output_state = ExecutionDataState::QUERY_ROWS; - query - }]) - .collect(), - edges: vec![edge(0, 1), edge(0, 2), edge(1, 3), edge(2, 3), edge(3, 4)], - root: PostAsapNodeId(4), - }; - let registry = Registry::default(); - let sink = Sink::default(); - let (dag, binding) = maintenance_only(dag, binding(), PostAsapNodeId(3)); - let first = execute_precompute_sink( - &dag, - &binding, - PostAsapNodeId(3), - key(3), - ®istry, - &sink, - test_context(), - ) - .unwrap(); - assert_eq!(*first, 6); - assert_eq!(registry.0.lock().unwrap().values().sum::(), 4); - - let replay = execute_precompute_sink( - &dag, - &binding, - PostAsapNodeId(3), - key(3), - ®istry, - &sink, - test_context(), - ) - .unwrap(); - assert!(Arc::ptr_eq(&first, &replay)); - assert_eq!(registry.0.lock().unwrap().values().sum::(), 4); - } - - #[test] - fn supplied_materialization_cuts_absorbed_dependencies_and_is_shared() { - struct FrontierRegistry(Registry); - impl PrecomputeOperatorRegistry for FrontierRegistry { - type Error = String; - fn materialized_input(&self, node: &ExecutableDagNode) -> Result, String> { - Ok((node.id == PostAsapNodeId(1)).then_some(10)) - } - fn execute( - &self, - node: &ExecutableDagNode, - inputs: &[Arc], - context: execution::RunContext, - ) -> Result { - assert_ne!( - node.id, - PostAsapNodeId(0), - "absorbed source subtree must not execute" - ); - self.0.execute(node, inputs, context) - } - } - let mut raw = node(0); - raw.output_state = ExecutionDataState::QUERY_ROWS; - let mut query = node(4); - query.output_state = ExecutionDataState::QUERY_ROWS; - let dag = ExecutableDag { - nodes: vec![raw, node(1), node(2), node(3), query], - edges: vec![edge(0, 1), edge(1, 2), edge(1, 3), edge(2, 3), edge(3, 4)], - root: PostAsapNodeId(4), - }; - let mut bindings = binding(); - bindings - .nodes - .insert(PostAsapNodeId(0), BackendNodeBinding::QueryInput); - let (dag, bindings) = maintenance_only(dag, bindings, PostAsapNodeId(3)); - let registry = FrontierRegistry(Registry::default()); - let sink = Sink::default(); - let result = execute_precompute_sink( - &dag, - &bindings, - PostAsapNodeId(3), - key(3), - ®istry, - &sink, - test_context(), - ) - .unwrap(); - assert_eq!(*result, 25); - assert_eq!( - *registry.0 .0.lock().unwrap(), - BTreeMap::from([(2, 1), (3, 1)]) - ); - } - - #[test] - fn rejects_query_node_in_precompute_path_and_mismatched_lineage_key() { - let mut query_child = node(0); - query_child.output_state = ExecutionDataState::QUERY_ROWS; - let dag = ExecutableDag { - nodes: vec![query_child, node(1)], - edges: vec![edge(0, 1)], - root: PostAsapNodeId(0), - }; - let registry = Registry::default(); - let sink = Sink::default(); - let invalid_path_binding = BackendExecutableBinding { - nodes: [ - ( - PostAsapNodeId(0), - BackendNodeBinding::Query { - query_node: asap_types::query_plan::QueryNodeId(1), - }, - ), - ( - PostAsapNodeId(1), - BackendNodeBinding::Materialization { - stored_output: asap_types::PolicyFingerprint(2).into(), - }, - ), - ] - .into_iter() - .collect(), - query_sink: PostAsapNodeId(0), - query_plan_sink: asap_types::query_plan::QueryNodeId(1), - precompute_sinks: vec![PostAsapNodeId(1)], - }; - assert!(matches!( - execute_precompute_sink(&dag, &invalid_path_binding, PostAsapNodeId(1), key(1), ®istry, &sink, test_context()), - Err(ScheduleError::Invalid(message)) if message.contains("query-owned node") - )); - let mut summary_dag = dag.clone(); - summary_dag.nodes[0].output_state.primitive = - planner_types::post_asap::DataPrimitive::SummaryState; - assert!(matches!( - execute_precompute_sink(&summary_dag, &invalid_path_binding, PostAsapNodeId(1), key(1), ®istry, &sink, test_context()), - Err(ScheduleError::Invalid(message)) if message.contains("query-owned node") - )); - assert!(matches!( - execute_precompute_sink(&dag, &invalid_path_binding, PostAsapNodeId(1), key(0), ®istry, &sink, test_context()), - Err(ScheduleError::Invalid(message)) if message.contains("does not match") - )); - } -} diff --git a/data_plane/src/query_engines/asap_query_engine/logical_dag.rs b/data_plane/src/query_engines/asap_query_engine/logical_dag.rs index 84b5d0827..269efbbc8 100644 --- a/data_plane/src/query_engines/asap_query_engine/logical_dag.rs +++ b/data_plane/src/query_engines/asap_query_engine/logical_dag.rs @@ -8,7 +8,9 @@ use crate::storage_engines::types::KeyByLabelValues; use asap_physical_operators::dag as physical; use asap_types::query_plan::query_time::QueryTimeOperator; #[cfg(test)] -use asap_types::query_plan::query_time::{Aggregation, BinaryOperation, Grouping, TemporalOperation}; +use asap_types::query_plan::query_time::{ + Aggregation, BinaryOperation, Grouping, TemporalOperation, +}; use asap_types::query_plan::{CandidateCompleteness, QueryNodeId, QueryPlanEntry, QueryPlanNode}; use futures::{FutureExt, StreamExt}; use std::cell::RefCell; diff --git a/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs b/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs index c22e47aa4..e656b7bc0 100644 --- a/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs +++ b/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs @@ -336,10 +336,10 @@ mod tests { execute_installed, PreparedLeaf, PreparedLeaves, Value as ProtocolValue, }; use asap_types::query_plan::{ - residual::ResidualQueryOperator, FallbackPolicy, InstantExecution, QueryPlanNode, + query_time::QueryTimeOperator, FallbackPolicy, InstantExecution, QueryPlanNode, }; let query = "topk by (job) (1, sum without(instance) (left_metric) / sum without(instance) (right_metric))"; - let mut entry = control_plane::query_plan::residual::compile_logical( + let mut entry = control_plane::query_plan::query_time::compile_logical( "compiled-values".into(), query.into(), InstantExecution { @@ -356,7 +356,7 @@ mod tests { node, QueryPlanNode::PhysicalFragment { .. } | QueryPlanNode::Logical { - operator: ResidualQueryOperator::Scan { .. }, + operator: QueryTimeOperator::Scan { .. }, .. } ))); @@ -364,7 +364,7 @@ mod tests { let mut leaves = PreparedLeaves::new(); for (id, node) in &entry.nodes { if let QueryPlanNode::Logical { - operator: ResidualQueryOperator::Scan { metric, .. }, + operator: QueryTimeOperator::Scan { metric, .. }, .. } = node { From 57da03f07465947e5050984d6e110eefb5fefc77 Mon Sep 17 00:00:00 2001 From: zzylol Date: Tue, 29 Sep 2026 18:56:03 +0000 Subject: [PATCH 16/19] refactor: adopt Planner PostAsapDag names ASAPPlanner #470 renamed ExecutableDag to PostAsapDag, and #462 was rebased onto it. Pin Planner to the rebased #462 head (76fbbf1) and apply the rename, including OwnedPostAsapDag::from_post_asap_dag. No serialized field or tag changes. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 14 ++--- Cargo.toml | 12 ++-- control_plane/src/clickhouse.rs | 2 +- control_plane/src/physical/compiler.rs | 52 ++++++++--------- .../src/physical/executable_binding.rs | 8 +-- .../src/physical/maintained_population.rs | 4 +- .../src/physical/post_asap/cost_model.rs | 12 ++-- control_plane/src/planner_selection.rs | 4 +- control_plane/src/query_plan.rs | 57 ++++++++++++------- crates/asap_types/src/derived_input.rs | 8 +-- crates/asap_types/src/executable_plan.rs | 30 +++++----- crates/asap_types/src/precompute_plan.rs | 24 ++++---- .../precompute_engine/maintenance_runtime.rs | 52 ++++++++--------- data_plane/src/precompute_engine/raw_dag.rs | 6 +- data_plane/src/precompute_engine/worker.rs | 7 +-- .../accelerator.rs | 14 ++--- .../asap_clickhouse_query_engine/execution.rs | 6 +- .../relational_adapter.rs | 4 +- .../relational_adapter/native.rs | 6 +- .../asap_query_engine/exact_subqueries.rs | 4 +- .../asap_query_engine/logical_dag.rs | 49 ++++++++++------ .../logical_dag/native_values.rs | 6 +- .../asapquery_compatibility_process_e2e.rs | 9 ++- .../design_docs/physical-candidate-handoff.md | 2 +- 24 files changed, 210 insertions(+), 182 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2f59186aa..1523a0f4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -364,7 +364,7 @@ dependencies = [ [[package]] name = "asap-aware-mapping" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3bff87fe6ca816627232a66240e778a7016a60a3#3bff87fe6ca816627232a66240e778a7016a60a3" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=76fbbf16cc44b19f56a780bfdb47a95327e84711#76fbbf16cc44b19f56a780bfdb47a95327e84711" dependencies = [ "asap-types", "asap_sketchlib 0.3.0 (git+https://github.com/ProjectASAP/asap_sketchlib)", @@ -376,7 +376,7 @@ dependencies = [ [[package]] name = "asap-frontend-promql" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3bff87fe6ca816627232a66240e778a7016a60a3#3bff87fe6ca816627232a66240e778a7016a60a3" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=76fbbf16cc44b19f56a780bfdb47a95327e84711#76fbbf16cc44b19f56a780bfdb47a95327e84711" dependencies = [ "asap-types", "promql-parser 0.10.0 (git+https://github.com/ProjectASAP/promql-parser?rev=9fede7eecca923c9882fe256484d00d37f8706cb)", @@ -385,7 +385,7 @@ dependencies = [ [[package]] name = "asap-frontend-sql" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3bff87fe6ca816627232a66240e778a7016a60a3#3bff87fe6ca816627232a66240e778a7016a60a3" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=76fbbf16cc44b19f56a780bfdb47a95327e84711#76fbbf16cc44b19f56a780bfdb47a95327e84711" dependencies = [ "asap-sql-function-catalog", "asap-types", @@ -396,7 +396,7 @@ dependencies = [ [[package]] name = "asap-physical-operators" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3bff87fe6ca816627232a66240e778a7016a60a3#3bff87fe6ca816627232a66240e778a7016a60a3" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=76fbbf16cc44b19f56a780bfdb47a95327e84711#76fbbf16cc44b19f56a780bfdb47a95327e84711" dependencies = [ "asap-types", "asap_sketch_codec", @@ -416,12 +416,12 @@ dependencies = [ [[package]] name = "asap-sql-function-catalog" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3bff87fe6ca816627232a66240e778a7016a60a3#3bff87fe6ca816627232a66240e778a7016a60a3" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=76fbbf16cc44b19f56a780bfdb47a95327e84711#76fbbf16cc44b19f56a780bfdb47a95327e84711" [[package]] name = "asap-types" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3bff87fe6ca816627232a66240e778a7016a60a3#3bff87fe6ca816627232a66240e778a7016a60a3" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=76fbbf16cc44b19f56a780bfdb47a95327e84711#76fbbf16cc44b19f56a780bfdb47a95327e84711" dependencies = [ "serde", "serde_json", @@ -443,7 +443,7 @@ dependencies = [ [[package]] name = "asap_sketch_codec" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=3bff87fe6ca816627232a66240e778a7016a60a3#3bff87fe6ca816627232a66240e778a7016a60a3" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=76fbbf16cc44b19f56a780bfdb47a95327e84711#76fbbf16cc44b19f56a780bfdb47a95327e84711" dependencies = [ "asap_sketchlib 0.3.0 (git+https://github.com/ProjectASAP/asap_sketchlib?rev=5f03ccbd798ed5fec62bdd839bcb331123cab369)", "prost", diff --git a/Cargo.toml b/Cargo.toml index 9d0700a66..2be96caea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,10 +14,10 @@ version = "0.1.0" [workspace.dependencies] # Keep Planner frontends, selection, and IR on the same immutable revision. # Alias upstream asap-types because this workspace also defines asap_types. -planner-types = { package = "asap-types", git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3bff87fe6ca816627232a66240e778a7016a60a3" } -asap-aware-mapping = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3bff87fe6ca816627232a66240e778a7016a60a3" } -asap-frontend-promql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3bff87fe6ca816627232a66240e778a7016a60a3" } -asap-frontend-sql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3bff87fe6ca816627232a66240e778a7016a60a3" } +planner-types = { package = "asap-types", git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "76fbbf16cc44b19f56a780bfdb47a95327e84711" } +asap-aware-mapping = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "76fbbf16cc44b19f56a780bfdb47a95327e84711" } +asap-frontend-promql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "76fbbf16cc44b19f56a780bfdb47a95327e84711" } +asap-frontend-sql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "76fbbf16cc44b19f56a780bfdb47a95327e84711" } # Shared external deps (used by 2+ crates) serde = { version = "1.0", features = ["derive"] } @@ -37,8 +37,8 @@ arc-swap = "1.7" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } # Internal crates -asap-physical-operators = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3bff87fe6ca816627232a66240e778a7016a60a3" } -asap_sketch_codec = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "3bff87fe6ca816627232a66240e778a7016a60a3" } +asap-physical-operators = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "76fbbf16cc44b19f56a780bfdb47a95327e84711" } +asap_sketch_codec = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "76fbbf16cc44b19f56a780bfdb47a95327e84711" } asap_types = { path = "crates/asap_types" } asap_otel_proto = { path = "crates/asap_otel_proto" } indexmap = { version = "2.0", features = ["serde"] } diff --git a/control_plane/src/clickhouse.rs b/control_plane/src/clickhouse.rs index 6b5f8cd88..dc421e778 100644 --- a/control_plane/src/clickhouse.rs +++ b/control_plane/src/clickhouse.rs @@ -534,7 +534,7 @@ where "SQL did not produce a summary DAG".into(), )); }; - let semantic = planner_types::post_asap::compile_executable_dag_with_node_ids(&root) + let semantic = planner_types::post_asap::compile_post_asap_dag_with_node_ids(&root) .map_err(|error| ClickHousePlanningError::Lower(error.to_string()))?; let mut materialization_nodes = std::collections::BTreeMap::new(); let mut query_nodes = std::collections::BTreeMap::new(); diff --git a/control_plane/src/physical/compiler.rs b/control_plane/src/physical/compiler.rs index 685c47dcc..73f056da9 100644 --- a/control_plane/src/physical/compiler.rs +++ b/control_plane/src/physical/compiler.rs @@ -16,7 +16,7 @@ use asap_aware_mapping::{ SummaryMaintenanceLifecycleCostInputs, WorkloadDemand, }; use planner_types::post_asap::{ - CompositionOperator, EvaluationSchedule, ExecutableDagCompilation, OutputRepresentation, + CompositionOperator, EvaluationSchedule, OutputRepresentation, PostAsapDagCompilation, PostAsapNodeId, SketchAlgorithm, SketchParams, SketchQuery, SummaryExpr, SummaryFamilyType, SummaryMaintenanceLifecycle, SummaryMaintenanceMode, SummaryNode, SummaryWindowFramework, }; @@ -1475,7 +1475,7 @@ impl DeploymentPlanCompiler { // materializations, then consumed by QueryPlan lowering. The key is // the planner DAG node identity across the workload; serving never scans // downstream components to rediscover this decision. - let mut executable_dags = vec![None::; request.queries.len()]; + let mut executable_dags = vec![None::; request.queries.len()]; let mut node_bindings = BTreeMap::<(usize, PostAsapNodeId), asap_types::PolicyFingerprint>::new(); let mut query_node_bindings = @@ -1575,7 +1575,7 @@ impl DeploymentPlanCompiler { if selected.is_empty() && population_operators[query_index].is_none() { continue; } - let executable = planner_types::post_asap::compile_executable_dag_with_node_ids( + let executable = planner_types::post_asap::compile_post_asap_dag_with_node_ids( &query.selected_plan_root, ) .map_err(|error| CompileError::Query { @@ -1894,14 +1894,15 @@ impl DeploymentPlanCompiler { .node_ids .node_id(child) .expect("selected input node"); - let document = asap_types::executable_plan::OwnedPostAsapDag::from_executable( - query.query_id.clone(), - &compiled.dag, - ) - .map_err(|reason| CompileError::Query { - query_id: query.query_id.clone(), - reason, - })?; + let document = + asap_types::executable_plan::OwnedPostAsapDag::from_post_asap_dag( + query.query_id.clone(), + &compiled.dag, + ) + .map_err(|reason| CompileError::Query { + query_id: query.query_id.clone(), + reason, + })?; runtime_materialization.derived_input = Some( asap_types::derived_input::DerivedInputIdentity::from_dag( &document, input_node, &frontiers, @@ -4081,7 +4082,7 @@ struct SelectedMaterialization { } fn validate_executable_subdag(node: &Rc) -> Result<(), String> { - let executable = planner_types::post_asap::compile_executable_dag(node) + let executable = planner_types::post_asap::compile_post_asap_dag(node) .map_err(|error| format!("invalid executable subDAG: {error}"))?; if let Some(edge) = executable.edges.iter().find(|edge| { edge.grouping == planner_types::post_asap::GroupingEdgeCompatibility::Incompatible @@ -4099,7 +4100,7 @@ fn reject_uncertified_readouts( root: &Rc, accuracy: &AccuracyTarget, ) -> Result<(), CompileError> { - let dag = planner_types::post_asap::compile_executable_dag(root).map_err(|error| { + let dag = planner_types::post_asap::compile_post_asap_dag(root).map_err(|error| { CompileError::Query { query_id: query_id.into(), reason: format!("invalid executable subDAG: {error}"), @@ -4108,7 +4109,7 @@ fn reject_uncertified_readouts( for node in &dag.nodes { if matches!( node.payload, - planner_types::post_asap::ExecutableOperatorPayload::SummaryEstimate { .. } + planner_types::post_asap::PostAsapOperatorPayload::SummaryEstimate { .. } ) && node .guarantee .as_ref() @@ -4125,7 +4126,7 @@ fn reject_uncertified_readouts( if dag.nodes.iter().any(|node| { matches!( node.payload, - planner_types::post_asap::ExecutableOperatorPayload::SummaryEstimate { .. } + planner_types::post_asap::PostAsapOperatorPayload::SummaryEstimate { .. } ) }) && root.guarantee.as_ref().is_none_or(|guarantee| { !asap_aware_mapping::accuracy::AccuracyModel::satisfies( @@ -4883,7 +4884,7 @@ pub(crate) mod tests { std::slice::from_ref(&root), ); let candidate = rule.candidate(&root).unwrap(); - planner_types::post_asap::compile_executable_dag(&candidate).unwrap(); + planner_types::post_asap::compile_post_asap_dag(&candidate).unwrap(); assert!(!super::super::maintained_population::supported_node( &candidate )); @@ -5185,17 +5186,16 @@ pub(crate) mod tests { .find(|node| { matches!( node.payload, - planner_types::post_asap::ExecutableOperatorPayload::SummaryAgg { .. } + planner_types::post_asap::PostAsapOperatorPayload::SummaryAgg { .. } ) }) .unwrap(); - if let planner_types::post_asap::ExecutableOperatorPayload::SummaryAgg { - reduction, .. - } = &mut node.payload + if let planner_types::post_asap::PostAsapOperatorPayload::SummaryAgg { reduction, .. } = + &mut node.payload { *reduction = planner_types::pre_asap::Reduction::by(vec![]); } - installed.document = asap_types::executable_plan::OwnedPostAsapDag::from_executable( + installed.document = asap_types::executable_plan::OwnedPostAsapDag::from_post_asap_dag( installed.document.query_id.clone(), &dag, ) @@ -7118,11 +7118,11 @@ pub(crate) mod tests { }], time_index: None, }; - let dag = ExecutableDag { + let dag = PostAsapDag { nodes: (1..=5) - .map(|id| ExecutableDagNode { + .map(|id| PostAsapDagNode { id: PostAsapNodeId(id), - payload: ExecutableOperatorPayload::SummaryMerge, + payload: PostAsapOperatorPayload::SummaryMerge, output_state: ExecutionDataState::INGESTION_SUMMARY, output_schema: schema.clone(), guarantee: None, @@ -7130,7 +7130,7 @@ pub(crate) mod tests { .collect(), edges: [(1, 2), (2, 3), (2, 4), (3, 5), (4, 5)] .into_iter() - .map(|(a, b)| ExecutableDagEdge { + .map(|(a, b)| PostAsapDagEdge { producer: PostAsapNodeId(a), consumer: PostAsapNodeId(b), role: EdgeRole::Input, @@ -7143,7 +7143,7 @@ pub(crate) mod tests { root: PostAsapNodeId(5), }; let mut installed = InstalledPostAsapDag { - document: OwnedPostAsapDag::from_executable("shared".into(), &dag).unwrap(), + document: OwnedPostAsapDag::from_post_asap_dag("shared".into(), &dag).unwrap(), native_programs: BTreeMap::new(), binding: BackendExecutableBinding { nodes: (1..=5) diff --git a/control_plane/src/physical/executable_binding.rs b/control_plane/src/physical/executable_binding.rs index 3742a2ea0..7d6ad4724 100644 --- a/control_plane/src/physical/executable_binding.rs +++ b/control_plane/src/physical/executable_binding.rs @@ -10,7 +10,7 @@ enum OperatorExecution { /// Every physical operator uses its node's placement; payload kind does not /// restrict execution phase. Runtime capability is checked separately. -fn operator_execution(node: &planner_types::post_asap::ExecutableDagNode) -> OperatorExecution { +fn operator_execution(node: &planner_types::post_asap::PostAsapDagNode) -> OperatorExecution { match node.output_state.timing { planner_types::post_asap::ExecutionTiming::IngestionTime => OperatorExecution::Ingestion, planner_types::post_asap::ExecutionTiming::QueryTime => OperatorExecution::Query, @@ -20,7 +20,7 @@ fn operator_execution(node: &planner_types::post_asap::ExecutableDagNode) -> Ope /// Assign backend phases to a selected semantic DAG without changing its nodes. pub fn install_selected_dag( query_id: String, - dag: &planner_types::post_asap::ExecutableDag, + dag: &planner_types::post_asap::PostAsapDag, query_plan_sink: QueryNodeId, materialization: impl Fn( planner_types::post_asap::PostAsapNodeId, @@ -30,7 +30,7 @@ pub fn install_selected_dag( let mut nodes = std::collections::BTreeMap::new(); let mut precompute_sinks = Vec::new(); for node in &dag.nodes { - if let planner_types::post_asap::ExecutableOperatorPayload::SummaryAgg { + if let planner_types::post_asap::PostAsapOperatorPayload::SummaryAgg { family, input, grouping, @@ -56,7 +56,7 @@ pub fn install_selected_dag( precompute_sinks.sort(); let installed = InstalledPostAsapDag { native_programs: std::collections::BTreeMap::new(), - document: OwnedPostAsapDag::from_executable(query_id, dag)?, + document: OwnedPostAsapDag::from_post_asap_dag(query_id, dag)?, binding: BackendExecutableBinding { nodes, query_sink: dag.root, diff --git a/control_plane/src/physical/maintained_population.rs b/control_plane/src/physical/maintained_population.rs index e5bc35eab..7bfee745f 100644 --- a/control_plane/src/physical/maintained_population.rs +++ b/control_plane/src/physical/maintained_population.rs @@ -35,12 +35,12 @@ fn selected(node: &SummaryNode) -> Option<(MaintainedPopulation, PopulationReado return None; }; let dag = - planner_types::post_asap::compile_executable_dag(&std::rc::Rc::new(node.clone())).ok()?; + planner_types::post_asap::compile_post_asap_dag(&std::rc::Rc::new(node.clone())).ok()?; let populations = dag .nodes .iter() .filter_map(|node| match &node.payload { - planner_types::post_asap::ExecutableOperatorPayload::Value { + planner_types::post_asap::PostAsapOperatorPayload::Value { operation: ValueOperation::MaintainPopulation { population }, } => Some(population), _ => None, diff --git a/control_plane/src/physical/post_asap/cost_model.rs b/control_plane/src/physical/post_asap/cost_model.rs index 9610ed0a1..98ee99864 100644 --- a/control_plane/src/physical/post_asap/cost_model.rs +++ b/control_plane/src/physical/post_asap/cost_model.rs @@ -20,7 +20,7 @@ use asap_aware_mapping::{ SummaryMaintenanceLifecycleCostInputs, TargetSubDAG, ValueOperationCapabilities, }; use planner_types::post_asap::{ - ExecutableOperatorPayload, GroupingStrategy, SketchAlgorithm, SketchParams, SketchQuery, + GroupingStrategy, PostAsapOperatorPayload, SketchAlgorithm, SketchParams, SketchQuery, SummaryFamilyType, SummaryWindowFramework, }; use planner_types::pre_asap::expr_ir::ColumnRef; @@ -190,13 +190,13 @@ impl ControlPlaneCostModel { // Exact compositions have a separate measured rate model. return None; }; - let dag = planner_types::post_asap::compile_executable_dag(root).ok()?; + let dag = planner_types::post_asap::compile_post_asap_dag(root).ok()?; let mut value = 0.0; let mut states = 0; for node in &dag.nodes { let family = match &node.payload { - ExecutableOperatorPayload::SummaryAgg { family, .. } - | ExecutableOperatorPayload::SummaryJoin { family, .. } => family, + PostAsapOperatorPayload::SummaryAgg { family, .. } + | PostAsapOperatorPayload::SummaryJoin { family, .. } => family, _ => continue, }; states += 1; @@ -527,12 +527,12 @@ impl CostModel for ControlPlaneCostModel { ) -> Option { use planner_types::post_asap::{NonNegativeWeightProof, WeightDomain}; let dag = - planner_types::post_asap::compile_executable_dag(&std::rc::Rc::new(summary.clone())) + planner_types::post_asap::compile_post_asap_dag(&std::rc::Rc::new(summary.clone())) .ok()?; // Counter-weighted heaps now consume explicit rate values. The raw // ingestion adapter cannot bind that frontier as counter deltas. let requires_rate_values = dag.nodes.iter().any(|node| matches!(&node.payload, - ExecutableOperatorPayload::SummaryAgg { input, family: SummaryFamilyType::Sketch(kind, _), .. } + PostAsapOperatorPayload::SummaryAgg { input, family: SummaryFamilyType::Sketch(kind, _), .. } if matches!(kind.algorithm(), SketchAlgorithm::CmsWithHeap | SketchAlgorithm::CountSketchWithHeap) && matches!(input.weight_domain, WeightDomain::NonNegative { proof: NonNegativeWeightProof::ResetAwareCounterDerivative }))); requires_rate_values.then_some(false) diff --git a/control_plane/src/planner_selection.rs b/control_plane/src/planner_selection.rs index 9e73361db..8bcc79fdf 100644 --- a/control_plane/src/planner_selection.rs +++ b/control_plane/src/planner_selection.rs @@ -77,10 +77,10 @@ fn target_identity(target: &QueryExpr, accuracy: &AccuracyTarget) -> Option Option { // Canonical exporter owns operator payloads and edge semantics. Hash its // structure, not assigned node IDs or the incidental sharing of Rc values. - let dag = planner_types::post_asap::compile_executable_dag(&Rc::new(node.clone())).ok()?; + let dag = planner_types::post_asap::compile_post_asap_dag(&Rc::new(node.clone())).ok()?; lossless_json(&dag)?; fn visit( - dag: &planner_types::post_asap::ExecutableDag, + dag: &planner_types::post_asap::PostAsapDag, id: planner_types::post_asap::PostAsapNodeId, memo: &mut std::collections::HashMap, ) -> String { diff --git a/control_plane/src/query_plan.rs b/control_plane/src/query_plan.rs index b05464efc..e6aadf931 100644 --- a/control_plane/src/query_plan.rs +++ b/control_plane/src/query_plan.rs @@ -145,9 +145,9 @@ fn compile_native_fragment( query_inputs: &[QueryNodeId], ) -> Result { use asap_physical_operators::physical_planner::{compile, InputContract}; - use planner_types::post_asap::{compile_executable_dag, EdgeRole}; + use planner_types::post_asap::{compile_post_asap_dag, EdgeRole}; let invalid = |e: String| QueryPlanError::Invalid(e); - let dag = compile_executable_dag(root).map_err(|e| invalid(e.to_string()))?; + let dag = compile_post_asap_dag(root).map_err(|e| invalid(e.to_string()))?; let mut edges = dag .edges .iter() @@ -235,9 +235,9 @@ where fn lower_relation(&mut self, root: &Rc) -> Result { use asap_physical_operators::physical_planner::{compile, InputContract}; use planner_types::post_asap::{ - compile_executable_dag_with_node_ids, ExecutableOperatorPayload as Payload, + compile_post_asap_dag_with_node_ids, PostAsapOperatorPayload as Payload, }; - let compilation = compile_executable_dag_with_node_ids(root) + let compilation = compile_post_asap_dag_with_node_ids(root) .map_err(|e| QueryPlanError::Invalid(e.to_string()))?; let mut pending = vec![compilation.dag.root]; let mut visited = std::collections::BTreeSet::new(); @@ -1500,23 +1500,38 @@ mod tests { std::sync::Arc::new(schema.clone()), std::sync::Arc::new(schema.clone()), ]; - let node = planner_types::post_asap::ExecutableDagNode { - id: planner_types::post_asap::PostAsapNodeId(2), - payload: planner_types::post_asap::ExecutableOperatorPayload::RelationalJoin { join_kind: planner_types::pre_asap::JoinKind::Semi, pred: serde_json::from_value(serde_json::to_value(planner_types::pre_asap::Predicate( - std::rc::Rc::new(planner_types::pre_asap::QueryExpr::Compare { - left: std::rc::Rc::new(planner_types::pre_asap::QueryExpr::Column( - 0, - )), - op: planner_types::pre_asap::CompareOpKind::Eq, - right: std::rc::Rc::new( - planner_types::pre_asap::QueryExpr::Column(1), - ), - }), - )) - .unwrap()).unwrap(), pruning: None }, - output_state: planner_types::post_asap::ExecutionDataState::QUERY_ROWS, - output_schema: schema, guarantee: None, - }; + let node = planner_types::post_asap::PostAsapDagNode { + id: planner_types::post_asap::PostAsapNodeId(2), + payload: + planner_types::post_asap::PostAsapOperatorPayload::RelationalJoin { + join_kind: planner_types::pre_asap::JoinKind::Semi, + pred: serde_json::from_value( + serde_json::to_value(planner_types::pre_asap::Predicate( + std::rc::Rc::new( + planner_types::pre_asap::QueryExpr::Compare { + left: std::rc::Rc::new( + planner_types::pre_asap::QueryExpr::Column( + 0, + ), + ), + op: planner_types::pre_asap::CompareOpKind::Eq, + right: std::rc::Rc::new( + planner_types::pre_asap::QueryExpr::Column( + 1, + ), + ), + }, + ), + )) + .unwrap(), + ) + .unwrap(), + pruning: None, + }, + output_state: planner_types::post_asap::ExecutionDataState::QUERY_ROWS, + output_schema: schema, + guarantee: None, + }; let operator = asap_physical_operators::physical_planner::compile_node( &node, &schemas, ) diff --git a/crates/asap_types/src/derived_input.rs b/crates/asap_types/src/derived_input.rs index e7aba6906..ffd1edea0 100644 --- a/crates/asap_types/src/derived_input.rs +++ b/crates/asap_types/src/derived_input.rs @@ -52,7 +52,7 @@ impl DerivedInputIdentity { .filter_map(|node| { matches!( &node.payload, - planner_types::post_asap::ExecutableOperatorPayload::Fallback { + planner_types::post_asap::PostAsapOperatorPayload::Fallback { expression: planner_types::pre_asap::QueryExpr::Literal(_), } ) @@ -355,13 +355,13 @@ mod tests { #[test] fn literal_leaves_are_hashed_without_inventing_materialization_references() { use planner_types::{ - post_asap::ExecutableOperatorPayload, + post_asap::PostAsapOperatorPayload, pre_asap::{QueryExpr, ScalarValue}, }; let mut dag = program(1, 2); let mut literal = dag.nodes[0].clone(); literal.id = PostAsapNodeId(3); - literal.payload = serde_json::to_value(ExecutableOperatorPayload::Fallback { + literal.payload = serde_json::to_value(PostAsapOperatorPayload::Fallback { expression: QueryExpr::Literal(ScalarValue::Int64(2)), }) .unwrap(); @@ -373,7 +373,7 @@ mod tests { let frontiers = BTreeMap::from([(PostAsapNodeId(1), source)]); let first = DerivedInputIdentity::from_dag(&dag, dag.root, &frontiers).unwrap(); assert_eq!(first.inputs, BTreeSet::from([source])); - dag.nodes[2].payload = serde_json::to_value(ExecutableOperatorPayload::Fallback { + dag.nodes[2].payload = serde_json::to_value(PostAsapOperatorPayload::Fallback { expression: QueryExpr::Literal(ScalarValue::Int64(3)), }) .unwrap(); diff --git a/crates/asap_types/src/executable_plan.rs b/crates/asap_types/src/executable_plan.rs index 530d58d66..60220341c 100644 --- a/crates/asap_types/src/executable_plan.rs +++ b/crates/asap_types/src/executable_plan.rs @@ -10,8 +10,8 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::sds::StoredOutputId; use planner_types::post_asap::{ - EdgeRole, ExecutableDag, ExecutableDagEdge, ExecutableDagNode, ExecutionDataState, - ExecutionTiming, GroupingEdgeCompatibility, PostAsapNodeId, WindowEdgeCompatibility, + EdgeRole, ExecutionDataState, ExecutionTiming, GroupingEdgeCompatibility, PostAsapDag, + PostAsapDagEdge, PostAsapDagNode, PostAsapNodeId, WindowEdgeCompatibility, }; use serde::{Deserialize, Serialize}; @@ -62,7 +62,7 @@ pub struct OwnedPostAsapEdge { } impl OwnedPostAsapDag { - pub fn from_executable(query_id: String, dag: &ExecutableDag) -> Result { + pub fn from_post_asap_dag(query_id: String, dag: &PostAsapDag) -> Result { let nodes = dag .nodes .iter() @@ -107,7 +107,7 @@ impl OwnedPostAsapDag { }) } - pub fn decode(&self) -> Result { + pub fn decode(&self) -> Result { let node_ids = self .nodes .iter() @@ -134,9 +134,9 @@ impl OwnedPostAsapDag { .nodes .iter() .map(|node| { - let payload: planner_types::post_asap::ExecutableOperatorPayload = + let payload: planner_types::post_asap::PostAsapOperatorPayload = serde_json::from_value(node.payload.clone()).map_err(|e| e.to_string())?; - Ok(ExecutableDagNode { + Ok(PostAsapDagNode { id: node.id, payload, output_state: node.output_state, @@ -155,7 +155,7 @@ impl OwnedPostAsapDag { .edges .iter() .map(|edge| { - Ok(ExecutableDagEdge { + Ok(PostAsapDagEdge { producer: edge.producer, consumer: edge.consumer, role: edge.role, @@ -167,7 +167,7 @@ impl OwnedPostAsapDag { }) }) .collect::, String>>()?; - Ok(ExecutableDag { + Ok(PostAsapDag { nodes, edges, root: self.root, @@ -352,7 +352,7 @@ impl BackendExecutableBinding { self.nodes.get(&id) } - pub fn validate_precompute(&self, dag: &ExecutableDag) -> Result<(), String> { + pub fn validate_precompute(&self, dag: &PostAsapDag) -> Result<(), String> { let ids = dag .nodes .iter() @@ -401,7 +401,7 @@ impl BackendExecutableBinding { Ok(()) } - pub fn validate(&self, dag: &ExecutableDag) -> Result<(), String> { + pub fn validate(&self, dag: &PostAsapDag) -> Result<(), String> { let semantic = dag .nodes .iter() @@ -473,11 +473,11 @@ mod tests { }], time_index: None, }; - let dag = ExecutableDag { + let dag = PostAsapDag { nodes: (1..=5) - .map(|id| ExecutableDagNode { + .map(|id| PostAsapDagNode { id: PostAsapNodeId(id), - payload: ExecutableOperatorPayload::SummaryMerge, + payload: PostAsapOperatorPayload::SummaryMerge, output_state: ExecutionDataState::INGESTION_SUMMARY, output_schema: schema.clone(), guarantee: None, @@ -485,7 +485,7 @@ mod tests { .collect(), edges: [(1, 2), (2, 3), (2, 4), (3, 5), (4, 5)] .into_iter() - .map(|(a, b)| ExecutableDagEdge { + .map(|(a, b)| PostAsapDagEdge { producer: PostAsapNodeId(a), consumer: PostAsapNodeId(b), role: EdgeRole::Input, @@ -502,7 +502,7 @@ mod tests { .unwrap(); let encoded: serde_json::Value = serde_json::from_slice(&program.encode().unwrap()).unwrap(); - let mut document = OwnedPostAsapDag::from_executable("shared".into(), &dag).unwrap(); + let mut document = OwnedPostAsapDag::from_post_asap_dag("shared".into(), &dag).unwrap(); document.schema_version = PRECOMPUTE_DAG_SCHEMA_VERSION; let mut installed = InstalledPostAsapDag { document, diff --git a/crates/asap_types/src/precompute_plan.rs b/crates/asap_types/src/precompute_plan.rs index 455f1bf47..7aa59eb09 100644 --- a/crates/asap_types/src/precompute_plan.rs +++ b/crates/asap_types/src/precompute_plan.rs @@ -14,16 +14,16 @@ use thiserror::Error; /// and are not admitted by this bounded capability check. pub fn validate_maintenance_reduction( config: &crate::PrecomputeMaterialization, - node: &planner_types::post_asap::ExecutableDagNode, + node: &planner_types::post_asap::PostAsapDagNode, ) -> Result<(), String> { use crate::sds::PopulationPartitioning; - use planner_types::{post_asap::ExecutableOperatorPayload, pre_asap::Reduction}; + use planner_types::{post_asap::PostAsapOperatorPayload, pre_asap::Reduction}; match &node.payload { - ExecutableOperatorPayload::SummaryAgg { + PostAsapOperatorPayload::SummaryAgg { reduction: Reduction::PerEntity, .. } if config.partitioning == Some(PopulationPartitioning::PerEntity) => Ok(()), - ExecutableOperatorPayload::SummaryAgg { + PostAsapOperatorPayload::SummaryAgg { reduction: Reduction::Reduce(keys), .. } if keys.is_empty() @@ -692,7 +692,7 @@ impl PrecomputePlan { .filter(|edge| edge.consumer == id) .collect(); use planner_types::post_asap::{ - ExecutableOperatorPayload as Payload, ValueOperation, + PostAsapOperatorPayload as Payload, ValueOperation, }; if node.output_state != planner_types::post_asap::ExecutionDataState::INGESTION_ROWS @@ -773,7 +773,7 @@ impl PrecomputePlan { crate::AggregationType::HLL | crate::AggregationType::UnivMon ) { - if let planner_types::post_asap::ExecutableOperatorPayload::SummaryAgg { + if let planner_types::post_asap::PostAsapOperatorPayload::SummaryAgg { input, .. } = &node.payload @@ -796,7 +796,7 @@ impl PrecomputePlan { } } if let Some(partitioning) = config.partitioning { - if let planner_types::post_asap::ExecutableOperatorPayload::SummaryAgg { + if let planner_types::post_asap::PostAsapOperatorPayload::SummaryAgg { reduction, .. } = &node.payload @@ -1173,9 +1173,9 @@ mod source_window_cohort_tests { use planner_types::pre_asap::Reduction; let mut config = full_window(); config.partitioning = Some(crate::sds::PopulationPartitioning::Grouped); - let mut node = ExecutableDagNode { + let mut node = PostAsapDagNode { id: PostAsapNodeId(1), - payload: ExecutableOperatorPayload::SummaryAgg { + payload: PostAsapOperatorPayload::SummaryAgg { family: SummaryFamilyType::ExactAggregate(ExactKind::Sum, ExactParams::Sum), input: SummaryUpdate { item: None, @@ -1195,18 +1195,18 @@ mod source_window_cohort_tests { assert!(validate_maintenance_reduction(&config, &node).is_ok()); config.partitioning = Some(crate::sds::PopulationPartitioning::PerEntity); assert!(validate_maintenance_reduction(&config, &node).is_err()); - if let ExecutableOperatorPayload::SummaryAgg { reduction, .. } = &mut node.payload { + if let PostAsapOperatorPayload::SummaryAgg { reduction, .. } = &mut node.payload { *reduction = Reduction::PerEntity; } assert!(validate_maintenance_reduction(&config, &node).is_ok()); config.partitioning = Some(crate::sds::PopulationPartitioning::Grouped); - if let ExecutableOperatorPayload::SummaryAgg { reduction, .. } = &mut node.payload { + if let PostAsapOperatorPayload::SummaryAgg { reduction, .. } = &mut node.payload { *reduction = Reduction::by(vec![0]); } assert!(validate_maintenance_reduction(&config, &node).is_err()); config.partitioning = None; assert!(validate_maintenance_reduction(&config, &node).is_err()); - node.payload = ExecutableOperatorPayload::SummaryMerge; + node.payload = PostAsapOperatorPayload::SummaryMerge; assert!(validate_maintenance_reduction(&config, &node).is_err()); } diff --git a/data_plane/src/precompute_engine/maintenance_runtime.rs b/data_plane/src/precompute_engine/maintenance_runtime.rs index 25f5a49ca..9e6107e41 100644 --- a/data_plane/src/precompute_engine/maintenance_runtime.rs +++ b/data_plane/src/precompute_engine/maintenance_runtime.rs @@ -5,7 +5,7 @@ use crate::storage_engines::types::{ AggregateCore, InstalledPrecomputePlanHandle, PrecomputedOutput, }; use asap_types::executable_plan::{BackendExecutableBinding, BackendNodeBinding}; -use planner_types::post_asap::{ExecutableDagNode, ExecutableOperatorPayload, PostAsapNodeId}; +use planner_types::post_asap::{PostAsapDagNode, PostAsapNodeId, PostAsapOperatorPayload}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; use std::sync::{Arc, Mutex}; @@ -48,7 +48,7 @@ impl MaintenanceInputs<'_> { fn validate_maintenance_grouping( target: &asap_types::PrecomputeMaterialization, source: &asap_types::PrecomputeMaterialization, - node: &ExecutableDagNode, + node: &PostAsapDagNode, singleton_population_complete: bool, ) -> Result<(), MaintenanceError> { if target.grouping_labels == source.grouping_labels @@ -61,7 +61,7 @@ fn validate_maintenance_grouping( && target.partitioning == Some(asap_types::sds::PopulationPartitioning::Grouped) && source.partitioning == Some(asap_types::sds::PopulationPartitioning::PerEntity) && target.stored_window_ms() == source.stored_window_ms() - && matches!(&node.payload, ExecutableOperatorPayload::SummaryAgg { + && matches!(&node.payload, PostAsapOperatorPayload::SummaryAgg { reduction: planner_types::pre_asap::Reduction::Reduce(keys), .. } if keys.is_empty()) { @@ -142,7 +142,7 @@ fn prepare_frozen_maintenance_sink( sink: PostAsapNodeId, inputs: &[crate::storage_engines::sketch_db::index::FrozenExactWindows], output_window: (u64, u64), -) -> Result<(planner_types::post_asap::ExecutableDag, PublicationKey), MaintenanceError> { +) -> Result<(planner_types::post_asap::PostAsapDag, PublicationKey), MaintenanceError> { installed.validate()?; let target = match installed.binding.node(sink) { Some(BackendNodeBinding::Materialization { stored_output }) => *stored_output, @@ -223,7 +223,7 @@ fn execute_prepared_frozen_sink( configs: &[asap_types::PrecomputeMaterialization], sink: PostAsapNodeId, inputs: MaintenanceInputs<'_>, - dag: &planner_types::post_asap::ExecutableDag, + dag: &planner_types::post_asap::PostAsapDag, key: PublicationKey, ) -> Result<(SummaryState, Population), MaintenanceError> { let _ = (configs, dag); @@ -1376,7 +1376,7 @@ impl PrecomputeDagSink { } fn depends_on_any( - dag: &planner_types::post_asap::ExecutableDag, + dag: &planner_types::post_asap::PostAsapDag, sink: PostAsapNodeId, sources: &BTreeSet, ) -> bool { @@ -1384,14 +1384,14 @@ fn depends_on_any( } fn dependencies( - dag: &planner_types::post_asap::ExecutableDag, + dag: &planner_types::post_asap::PostAsapDag, sink: PostAsapNodeId, ) -> BTreeSet { dependencies_until(dag, sink, &BTreeSet::new()) } fn dependencies_until( - dag: &planner_types::post_asap::ExecutableDag, + dag: &planner_types::post_asap::PostAsapDag, sink: PostAsapNodeId, frontier: &BTreeSet, ) -> BTreeSet { @@ -1562,7 +1562,7 @@ mod tests { use super::*; use asap_physical_operators::summary_kernels::SumAccumulator; use planner_types::post_asap::{ - EdgeRole, ExecutableDag, ExecutableDagEdge, GroupingEdgeCompatibility, SummarySchema, + EdgeRole, GroupingEdgeCompatibility, PostAsapDag, PostAsapDagEdge, SummarySchema, WindowEdgeCompatibility, }; @@ -1593,9 +1593,9 @@ mod tests { } fn maintenance_only( - mut dag: ExecutableDag, + mut dag: PostAsapDag, mut binding: BackendExecutableBinding, - ) -> (ExecutableDag, BackendExecutableBinding) { + ) -> (PostAsapDag, BackendExecutableBinding) { let retained = dag .nodes .iter() @@ -1689,10 +1689,10 @@ mod tests { // Ingestion adapters must execute native operators in the parent's scope. - fn node(id: u32) -> ExecutableDagNode { - ExecutableDagNode { + fn node(id: u32) -> PostAsapDagNode { + PostAsapDagNode { id: PostAsapNodeId(id), - payload: ExecutableOperatorPayload::SummaryMerge, + payload: PostAsapOperatorPayload::SummaryMerge, output_state: planner_types::post_asap::ExecutionDataState::INGESTION_SUMMARY, output_schema: SummarySchema { fields: vec![], @@ -1702,8 +1702,8 @@ mod tests { } } - fn edge(producer: u32, consumer: u32) -> ExecutableDagEdge { - ExecutableDagEdge { + fn edge(producer: u32, consumer: u32) -> PostAsapDagEdge { + PostAsapDagEdge { producer: PostAsapNodeId(producer), consumer: PostAsapNodeId(consumer), role: EdgeRole::Input, @@ -1784,7 +1784,7 @@ mod tests { precompute_sinks: vec![PostAsapNodeId(3)], }; let mut read = node(2); - read.payload = ExecutableOperatorPayload::Value { + read.payload = PostAsapOperatorPayload::Value { operation: planner_types::post_asap::ValueOperation::FinalizeExactAccumulator, }; read.output_schema.fields = vec![SummaryField { @@ -1793,7 +1793,7 @@ mod tests { nullable: false, }]; let mut aggregate = node(3); - aggregate.payload = ExecutableOperatorPayload::SummaryAgg { + aggregate.payload = PostAsapOperatorPayload::SummaryAgg { family: target_family, input: SummaryUpdate { item: None, @@ -1825,7 +1825,7 @@ mod tests { second_edge.data_state = read.output_state; let mut query_edge = edge(3, 4); query_edge.intermediate_schema = aggregate.output_schema.clone(); - let dag = ExecutableDag { + let dag = PostAsapDag { nodes: vec![source_node, read, aggregate, query], edges: vec![first_edge, second_edge, query_edge], root: PostAsapNodeId(4), @@ -1856,7 +1856,7 @@ mod tests { }; use asap_types::executable_plan::{InstalledPostAsapDag, OwnedPostAsapDag}; let mut document = - OwnedPostAsapDag::from_executable("immutable-chain".into(), &dag).unwrap(); + OwnedPostAsapDag::from_post_asap_dag("immutable-chain".into(), &dag).unwrap(); let mut durable_configs = configs.to_vec(); durable_configs[1].derived_input = Some( asap_types::derived_input::DerivedInputIdentity::from_dag( @@ -2168,7 +2168,7 @@ mod tests { } fn exercise_two_source_completed_sink( - template: &ExecutableDag, + template: &PostAsapDag, configs: &[asap_types::PrecomputeMaterialization], binding: &BackendExecutableBinding, matching_windows: bool, @@ -2200,7 +2200,7 @@ mod tests { second_node.id = PostAsapNodeId(5); let mut merge = second_node.clone(); merge.id = PostAsapNodeId(6); - merge.payload = ExecutableOperatorPayload::SummaryMerge; + merge.payload = PostAsapOperatorPayload::SummaryMerge; dag.nodes.extend([second_node, merge]); let original = dag .edges @@ -2217,7 +2217,7 @@ mod tests { edge.consumer = PostAsapNodeId(consumer); dag.edges.push(edge); } - if let ExecutableOperatorPayload::SummaryAgg { input, .. } = &mut dag + if let PostAsapOperatorPayload::SummaryAgg { input, .. } = &mut dag .nodes .iter_mut() .find(|node| node.id == PostAsapNodeId(3)) @@ -2229,7 +2229,7 @@ mod tests { ); } let mut document = - OwnedPostAsapDag::from_executable("two-source-fixture".into(), &dag).unwrap(); + OwnedPostAsapDag::from_post_asap_dag("two-source-fixture".into(), &dag).unwrap(); let mut target = configs[1].clone(); if complete_groups { target.population_key_encoding = asap_types::PopulationKeyEncoding::CanonicalLabelsV1; @@ -2745,7 +2745,7 @@ mod tests { .into(); let mut query = node(2); query.output_state = planner_types::post_asap::ExecutionDataState::QUERY_ROWS; - let dag = ExecutableDag { + let dag = PostAsapDag { nodes: vec![node(0), node(1), query], edges: vec![edge(0, 1), edge(1, 2)], root: PostAsapNodeId(2), @@ -2782,7 +2782,7 @@ mod tests { native_programs: BTreeMap::new(), document: { let mut document = - OwnedPostAsapDag::from_executable("retry".into(), &dag).unwrap(); + OwnedPostAsapDag::from_post_asap_dag("retry".into(), &dag).unwrap(); document.schema_version = asap_types::executable_plan::PRECOMPUTE_DAG_SCHEMA_VERSION; document diff --git a/data_plane/src/precompute_engine/raw_dag.rs b/data_plane/src/precompute_engine/raw_dag.rs index fac1323a1..993716d09 100644 --- a/data_plane/src/precompute_engine/raw_dag.rs +++ b/data_plane/src/precompute_engine/raw_dag.rs @@ -3,7 +3,7 @@ use crate::storage_engines::types::KeyByLabelValues; use asap_physical_operators::factory::{create_planner_accumulator, AccumulatorUpdater}; use asap_types::{executable_plan::BackendNodeBinding, PrecomputeMaterialization}; use planner_types::post_asap::{ - EdgeRole, ExecutableOperatorPayload, GroupingStrategy, PostAsapNodeId, SummaryFamilyType, + EdgeRole, GroupingStrategy, PostAsapNodeId, PostAsapOperatorPayload, SummaryFamilyType, SummaryInputExpr, SummaryUpdate, }; use planner_types::pre_asap::{ColumnRef, QueryExpr, Source}; @@ -35,7 +35,7 @@ impl RawDagProgram { { continue; } - let ExecutableOperatorPayload::SummaryAgg { + let PostAsapOperatorPayload::SummaryAgg { family, input, grouping, @@ -61,7 +61,7 @@ impl RawDagProgram { .iter() .find(|n| n.id == edge.producer) .ok_or("missing raw DAG input")?; - let ExecutableOperatorPayload::Fallback { expression } = &source.payload else { + let PostAsapOperatorPayload::Fallback { expression } = &source.payload else { return Err("raw producer requires an executable source input; maintenance edges cannot be bypassed".into()); }; let scan = match expression { diff --git a/data_plane/src/precompute_engine/worker.rs b/data_plane/src/precompute_engine/worker.rs index 5d71f6645..f2da34982 100644 --- a/data_plane/src/precompute_engine/worker.rs +++ b/data_plane/src/precompute_engine/worker.rs @@ -4707,14 +4707,13 @@ mod dag_execution_tests { let installed = plan.executable_dags.values_mut().next().unwrap(); let mut dag = installed.document.decode().unwrap(); for node in &mut dag.nodes { - if let planner_types::post_asap::ExecutableOperatorPayload::SummaryAgg { - input, .. - } = &mut node.payload + if let planner_types::post_asap::PostAsapOperatorPayload::SummaryAgg { input, .. } = + &mut node.payload { input.weight = planner_types::post_asap::SummaryInputExpr::Constant(99.0); } } - installed.document = asap_types::executable_plan::OwnedPostAsapDag::from_executable( + installed.document = asap_types::executable_plan::OwnedPostAsapDag::from_post_asap_dag( installed.document.query_id.clone(), &dag, ) diff --git a/data_plane/src/query_engines/asap_clickhouse_query_engine/accelerator.rs b/data_plane/src/query_engines/asap_clickhouse_query_engine/accelerator.rs index 8f547ce16..709372075 100644 --- a/data_plane/src/query_engines/asap_clickhouse_query_engine/accelerator.rs +++ b/data_plane/src/query_engines/asap_clickhouse_query_engine/accelerator.rs @@ -625,9 +625,9 @@ mod tests { std::sync::Arc::new(left_schema), std::sync::Arc::new(right_schema), ]; - let node = planner_types::post_asap::ExecutableDagNode { + let node = planner_types::post_asap::PostAsapDagNode { id: planner_types::post_asap::PostAsapNodeId(2), - payload: planner_types::post_asap::ExecutableOperatorPayload::RelationalJoin { + payload: planner_types::post_asap::PostAsapOperatorPayload::RelationalJoin { join_kind: planner_types::pre_asap::JoinKind::Inner, pred: serde_json::from_value( serde_json::to_value(Predicate(Rc::new(QueryExpr::Compare { @@ -671,9 +671,9 @@ mod tests { }); entry.nodes.insert(project, { let schemas = vec![std::sync::Arc::new(joined_schema)]; - let node = planner_types::post_asap::ExecutableDagNode { + let node = planner_types::post_asap::PostAsapDagNode { id: planner_types::post_asap::PostAsapNodeId(2), - payload: planner_types::post_asap::ExecutableOperatorPayload::Value { + payload: planner_types::post_asap::PostAsapOperatorPayload::Value { operation: serde_json::from_value( serde_json::to_value(ValueOperation::Project { cols: vec![ @@ -755,7 +755,7 @@ mod tests { compile_node, CompiledPhysicalDag, InputContract, }; use planner_types::post_asap::{ - ExecutableDagNode, ExecutableOperatorPayload, ExecutionDataState, PostAsapNodeId, + ExecutionDataState, PostAsapDagNode, PostAsapNodeId, PostAsapOperatorPayload, }; let input = std::sync::Arc::new(schema); let mut previous_schema = input.clone(); @@ -763,9 +763,9 @@ mod tests { let mut nodes = BTreeMap::new(); for (index, (operation, output_schema)) in operations.into_iter().enumerate() { let id = index as u64 + 1; - let node = ExecutableDagNode { + let node = PostAsapDagNode { id: PostAsapNodeId(id as u32), - payload: ExecutableOperatorPayload::Value { operation }, + payload: PostAsapOperatorPayload::Value { operation }, output_state: ExecutionDataState::QUERY_ROWS, output_schema: output_schema.clone(), guarantee: None, diff --git a/data_plane/src/query_engines/asap_clickhouse_query_engine/execution.rs b/data_plane/src/query_engines/asap_clickhouse_query_engine/execution.rs index 6dcbe9fab..81afff3bf 100644 --- a/data_plane/src/query_engines/asap_clickhouse_query_engine/execution.rs +++ b/data_plane/src/query_engines/asap_clickhouse_query_engine/execution.rs @@ -701,14 +701,14 @@ mod tests { compile_node, CompiledPhysicalDag, InputContract, }; use planner_types::post_asap::{ - ExecutableDagNode, ExecutableOperatorPayload, ExecutionDataState, PostAsapNodeId, + ExecutionDataState, PostAsapDagNode, PostAsapNodeId, PostAsapOperatorPayload, }; use planner_types::pre_asap::{JoinKind, Predicate, QueryExpr, ScalarValue}; let schema = std::sync::Arc::new(schema.clone()); let op = compile_node( - &ExecutableDagNode { + &PostAsapDagNode { id: PostAsapNodeId(100), - payload: ExecutableOperatorPayload::RelationalJoin { + payload: PostAsapOperatorPayload::RelationalJoin { join_kind: JoinKind::Cross, pred: Predicate(QueryExpr::Literal(ScalarValue::Boolean(true)).into()), pruning: None, diff --git a/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter.rs b/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter.rs index 493c196aa..dd2e32d0a 100644 --- a/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter.rs +++ b/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter.rs @@ -415,7 +415,7 @@ impl ClickHouseRelationalAdapter { right: ClickHouseRelation, ) -> Result { native::execute( - planner_types::post_asap::ExecutableOperatorPayload::RelationalJoin { + planner_types::post_asap::PostAsapOperatorPayload::RelationalJoin { join_kind: kind.clone(), pred: pred.clone(), pruning: None, @@ -445,7 +445,7 @@ impl ClickHouseRelationalAdapter { input: ClickHouseRelation, ) -> Result { native::execute( - planner_types::post_asap::ExecutableOperatorPayload::Value { + planner_types::post_asap::PostAsapOperatorPayload::Value { operation: operation.clone(), }, output_schema, diff --git a/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter/native.rs b/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter/native.rs index e63833564..3604f74f5 100644 --- a/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter/native.rs +++ b/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter/native.rs @@ -61,12 +61,12 @@ pub(super) fn cell(value: &Value) -> Result { } #[cfg(test)] use planner_types::post_asap::{ - ExecutableDagNode, ExecutableOperatorPayload, ExecutionDataState, PostAsapNodeId, + ExecutionDataState, PostAsapDagNode, PostAsapNodeId, PostAsapOperatorPayload, }; #[cfg(test)] pub(crate) fn execute( - payload: ExecutableOperatorPayload, + payload: PostAsapOperatorPayload, output: &SummarySchema, inputs: Vec, ) -> Result { @@ -92,7 +92,7 @@ pub(crate) fn execute( }) .collect::, _>>() .map_err(error)?; - let node = ExecutableDagNode { + let node = PostAsapDagNode { id: PostAsapNodeId(0), payload, output_state: ExecutionDataState::QUERY_ROWS, diff --git a/data_plane/src/query_engines/asap_query_engine/exact_subqueries.rs b/data_plane/src/query_engines/asap_query_engine/exact_subqueries.rs index 8d10ba8bb..f583463c0 100644 --- a/data_plane/src/query_engines/asap_query_engine/exact_subqueries.rs +++ b/data_plane/src/query_engines/asap_query_engine/exact_subqueries.rs @@ -696,9 +696,9 @@ mod tests { }; { let schemas = vec![std::sync::Arc::new(schema.clone()), std::sync::Arc::new(schema.clone())]; - let node = planner_types::post_asap::ExecutableDagNode { + let node = planner_types::post_asap::PostAsapDagNode { id: planner_types::post_asap::PostAsapNodeId(2), - payload: planner_types::post_asap::ExecutableOperatorPayload::RelationalJoin { join_kind: planner_types::pre_asap::JoinKind::Semi, pred: serde_json::from_value(serde_json::to_value(planner_types::pre_asap::Predicate(std::rc::Rc::new( + payload: planner_types::post_asap::PostAsapOperatorPayload::RelationalJoin { join_kind: planner_types::pre_asap::JoinKind::Semi, pred: serde_json::from_value(serde_json::to_value(planner_types::pre_asap::Predicate(std::rc::Rc::new( planner_types::pre_asap::QueryExpr::Compare { left: std::rc::Rc::new(planner_types::pre_asap::QueryExpr::Column(0)), op: planner_types::pre_asap::CompareOpKind::Eq, diff --git a/data_plane/src/query_engines/asap_query_engine/logical_dag.rs b/data_plane/src/query_engines/asap_query_engine/logical_dag.rs index 269efbbc8..c81ad52a4 100644 --- a/data_plane/src/query_engines/asap_query_engine/logical_dag.rs +++ b/data_plane/src/query_engines/asap_query_engine/logical_dag.rs @@ -1588,23 +1588,38 @@ mod topk_tests { std::sync::Arc::new(schema.clone()), std::sync::Arc::new(schema.clone()), ]; - let node = planner_types::post_asap::ExecutableDagNode { - id: planner_types::post_asap::PostAsapNodeId(2), - payload: planner_types::post_asap::ExecutableOperatorPayload::RelationalJoin { join_kind: planner_types::pre_asap::JoinKind::Semi, pred: serde_json::from_value(serde_json::to_value(planner_types::pre_asap::Predicate( - std::rc::Rc::new(planner_types::pre_asap::QueryExpr::Compare { - left: std::rc::Rc::new(planner_types::pre_asap::QueryExpr::Column( - 0, - )), - op: planner_types::pre_asap::CompareOpKind::Eq, - right: std::rc::Rc::new( - planner_types::pre_asap::QueryExpr::Column(1), - ), - }), - )) - .unwrap()).unwrap(), pruning: None }, - output_state: planner_types::post_asap::ExecutionDataState::QUERY_ROWS, - output_schema: schema, guarantee: None, - }; + let node = planner_types::post_asap::PostAsapDagNode { + id: planner_types::post_asap::PostAsapNodeId(2), + payload: + planner_types::post_asap::PostAsapOperatorPayload::RelationalJoin { + join_kind: planner_types::pre_asap::JoinKind::Semi, + pred: serde_json::from_value( + serde_json::to_value(planner_types::pre_asap::Predicate( + std::rc::Rc::new( + planner_types::pre_asap::QueryExpr::Compare { + left: std::rc::Rc::new( + planner_types::pre_asap::QueryExpr::Column( + 0, + ), + ), + op: planner_types::pre_asap::CompareOpKind::Eq, + right: std::rc::Rc::new( + planner_types::pre_asap::QueryExpr::Column( + 1, + ), + ), + }, + ), + )) + .unwrap(), + ) + .unwrap(), + pruning: None, + }, + output_state: planner_types::post_asap::ExecutionDataState::QUERY_ROWS, + output_schema: schema, + guarantee: None, + }; let operator = asap_physical_operators::physical_planner::compile_node( &node, &schemas, ) diff --git a/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs b/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs index e656b7bc0..93e5359e1 100644 --- a/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs +++ b/data_plane/src/query_engines/asap_query_engine/logical_dag/native_values.rs @@ -137,14 +137,14 @@ pub(super) fn relation( compile_node, CompiledPhysicalDag, InputContract, }; use planner_types::post_asap::{ - ExecutableDagNode, ExecutableOperatorPayload, ExecutionDataState, PostAsapNodeId, + ExecutionDataState, PostAsapDagNode, PostAsapNodeId, PostAsapOperatorPayload, }; - let node = ExecutableDagNode { + let node = PostAsapDagNode { id: PostAsapNodeId(2), output_state: ExecutionDataState::QUERY_ROWS, output_schema: (*output_schema).clone(), guarantee: None, - payload: ExecutableOperatorPayload::RelationalJoin { + payload: PostAsapOperatorPayload::RelationalJoin { join_kind: planner_types::pre_asap::JoinKind::Semi, pred: predicate, pruning: completeness, diff --git a/data_plane/tests/asapquery_compatibility_process_e2e.rs b/data_plane/tests/asapquery_compatibility_process_e2e.rs index 1c22861f2..fa69c4ccf 100644 --- a/data_plane/tests/asapquery_compatibility_process_e2e.rs +++ b/data_plane/tests/asapquery_compatibility_process_e2e.rs @@ -696,12 +696,11 @@ async fn registered_temporal_topk(algorithm: planner_types::post_asap::SketchAlg ) { return Some(false); } - let dag = planner_types::post_asap::compile_executable_dag(&std::rc::Rc::new( - summary.clone(), - )) - .ok()?; + let dag = + planner_types::post_asap::compile_post_asap_dag(&std::rc::Rc::new(summary.clone())) + .ok()?; if dag.nodes.iter().any(|node| matches!(&node.payload, - planner_types::post_asap::ExecutableOperatorPayload::SummaryAgg { family: SummaryFamilyType::Sketch(kind, _), .. } if kind.algorithm() != &self.1)) { + planner_types::post_asap::PostAsapOperatorPayload::SummaryAgg { family: SummaryFamilyType::Sketch(kind, _), .. } if kind.algorithm() != &self.1)) { return Some(false); } self.0.summary_support_evidence(summary) diff --git a/docs/design_docs/physical-candidate-handoff.md b/docs/design_docs/physical-candidate-handoff.md index ea2c9f79a..f58b2ba5c 100644 --- a/docs/design_docs/physical-candidate-handoff.md +++ b/docs/design_docs/physical-candidate-handoff.md @@ -33,7 +33,7 @@ it must not select algorithms, rebuild operators, or move work between phases. | Planner handoff | Consume complete executable physical candidates and their typed boundaries; report missing Planner implementations explicitly | | Deployment compiler | Price feasible candidates, select one, and install source/SDS bindings without logical lowering | | Installed query | Store the selected physical computation and deployment metadata, removing the parallel Backend computation node representation | -| SQL execution | Remove request-time `ExecutableDagNode`, `compile_node` and physical graph reconstruction | +| SQL execution | Remove request-time `PostAsapDagNode`, `compile_node` and physical graph reconstruction | | PromQL execution | Move computation currently lowered by `residual.rs` and `logical_dag/native_values.rs` to Planner; retain protocol and input conversion | | Precomputation | Bind the candidate's precompute graph and stored outputs without reclassifying operators by kind | | Persistence | Update the installed-plan schema; reject incompatible stored plans explicitly rather than retaining a legacy execution path | From b719fb3be225d7286a87377eaeddfa359eed4ca7 Mon Sep 17 00:00:00 2001 From: zzylol Date: Tue, 29 Sep 2026 22:45:56 +0000 Subject: [PATCH 17/19] chore(compiler): assert lifecycle plan lists the selected state first select_lifecycle reads only deployments[0]. Planner collects SummaryAgg nodes in pre-order, so the first deployment is the node passed in; nested source states are priced by their own select_lifecycle calls, and the backend cost model assigns one window framework to every deployment. Co-Authored-By: Claude Opus 5.5 --- control_plane/src/physical/compiler.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/control_plane/src/physical/compiler.rs b/control_plane/src/physical/compiler.rs index 73f056da9..af7c34bba 100644 --- a/control_plane/src/physical/compiler.rs +++ b/control_plane/src/physical/compiler.rs @@ -3799,6 +3799,13 @@ fn select_lifecycle( query_id: query.query_id.clone(), reason: error.to_string(), })?; + // Planner lists the passed SummaryAgg first. Nested source states are + // selected and priced by their own calls, and the backend cost model gives + // every deployment the same window framework, so later entries add nothing. + debug_assert!(plan + .deployments + .first() + .is_none_or(|deployment| Rc::ptr_eq(&deployment.summary, &plan.root))); let guarantee = plan .deployments .first() From 6c49478c30e45c38cec4118b76d288c2ee5fb097 Mon Sep 17 00:00:00 2001 From: zzylol Date: Tue, 29 Sep 2026 22:58:47 +0000 Subject: [PATCH 18/19] perf(workload_cost): reuse per-root work across deployment candidates Planner forests clone query inputs, so the 160 candidates of a 44-query workload share their root Rcs. Candidate descriptions recomputed every root's explain identity per candidate, and enumeration re-encoded every root's retained physical candidate. Memoize both by root address while all candidates are borrowed; identities, plans and manifests are unchanged. Co-Authored-By: Claude Opus 5.5 --- control_plane/src/physical/workload_cost.rs | 157 ++++++++++++++++++-- 1 file changed, 141 insertions(+), 16 deletions(-) diff --git a/control_plane/src/physical/workload_cost.rs b/control_plane/src/physical/workload_cost.rs index b1b8c4bcf..c6ce7e028 100644 --- a/control_plane/src/physical/workload_cost.rs +++ b/control_plane/src/physical/workload_cost.rs @@ -11,9 +11,12 @@ pub use status::{CandidateEvaluationStatus, CandidateSearchScope}; #[cfg(test)] use super::compiler::DeploymentPlanCompiler; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::rc::Rc; +use crate::types::AccuracyTarget; use asap_aware_mapping::cost_model::Cost; +use planner_types::post_asap::SummaryNode; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -496,17 +499,41 @@ impl WorkloadCostEvidence { } } -fn candidate_description(candidate: &PhysicalCompilationRequest) -> CandidatePlanEvaluation { - let root_ids = candidate - .queries +/// Planner forests clone query inputs, so candidates share root `Rc`s. The +/// memo borrows every candidate, which keeps each keyed address alive. +fn candidate_descriptions( + candidates: &[PhysicalCompilationRequest], +) -> Vec { + let mut root_ids = HashMap::<*const SummaryNode, Vec<(&AccuracyTarget, Option)>>::new(); + candidates .iter() - .map(|query| { - crate::planner_selection::explained_root_id( - &query.selected_plan_root, - &query.accuracy_target, - ) + .map(|candidate| { + candidate_description(candidate, |query| { + let known = root_ids + .entry(Rc::as_ptr(&query.selected_plan_root)) + .or_default(); + if let Some((_, id)) = known + .iter() + .find(|(accuracy, _)| *accuracy == &query.accuracy_target) + { + return id.clone(); + } + let id = crate::planner_selection::explained_root_id( + &query.selected_plan_root, + &query.accuracy_target, + ); + known.push((&query.accuracy_target, id.clone())); + id + }) }) - .collect::>(); + .collect() +} + +fn candidate_description<'a>( + candidate: &'a PhysicalCompilationRequest, + root_id: impl FnMut(&'a QueryCompilationInput) -> Option, +) -> CandidatePlanEvaluation { + let root_ids = candidate.queries.iter().map(root_id).collect::>(); let complete = root_ids.iter().all(Option::is_some); let mut logical_root_ids = root_ids.into_iter().flatten().collect::>(); logical_root_ids.sort(); @@ -535,6 +562,7 @@ fn candidate_description(candidate: &PhysicalCompilationRequest) -> CandidatePla fn compile_candidate_for_pricing( candidate: PhysicalCompilationRequest, + mut description: CandidatePlanEvaluation, env: PhysicalDeploymentContext, frontend: super::compiler::QueryFrontend, ) -> Result< @@ -545,7 +573,6 @@ fn compile_candidate_for_pricing( ), Box, > { - let mut description = candidate_description(&candidate); let queries = candidate.queries.clone(); let compiled = super::realization::RealizationProvider::compile( &super::realization::ExistingRealizations, @@ -626,8 +653,9 @@ pub fn compile_candidates_for_pricing( ) -> (Vec, Vec) { let mut manifests = Vec::new(); let mut candidate_evaluations = Vec::new(); - for candidate in candidates { - match compile_candidate_for_pricing(candidate, env.clone(), frontend) { + let descriptions = candidate_descriptions(&candidates); + for (candidate, description) in candidates.into_iter().zip(descriptions) { + match compile_candidate_for_pricing(candidate, description, env.clone(), frontend) { Ok((_, manifest, description)) => { manifests.push(manifest); candidate_evaluations.push(description); @@ -699,9 +727,10 @@ fn select_candidates( let mut comparison_workload = None; let mut candidate_evaluations = Vec::new(); let mut priced_candidates = Vec::new(); - for candidate in candidates { + let descriptions = candidate_descriptions(&candidates); + for (candidate, description) in candidates.into_iter().zip(descriptions) { let (plan, manifest, mut description) = - match compile_candidate_for_pricing(candidate, env.clone(), frontend) { + match compile_candidate_for_pricing(candidate, description, env.clone(), frontend) { Ok(bound) => bound, Err(description) => { candidate_evaluations.push(*description); @@ -888,9 +917,18 @@ fn enumerate_frontier_candidates( } } } + // A retained physical candidate depends only on its root, and candidates + // share root `Rc`s. `candidates` keeps every keyed address alive. + let mut physical = HashMap::<*const SummaryNode, Option>>::new(); for candidate in &mut candidates { for query in &mut candidate.queries { - query.retain_physical_candidate()?; + let root = Rc::as_ptr(&query.selected_plan_root); + if let Some(bytes) = physical.get(&root) { + query.physical_candidate = bytes.clone(); + } else { + query.retain_physical_candidate()?; + physical.insert(root, query.physical_candidate.clone()); + } } } Ok(candidates) @@ -1219,6 +1257,93 @@ mod tests { assert!(!report["logical_selection"].as_array().unwrap().is_empty()); } + /// Memoized root identities and retained physical candidates select the + /// same plan, manifest and candidate identities as independent compiles. + #[test] + fn shared_root_memoization_preserves_selected_plan_and_manifest() { + use planner_types::workload::{AccuracyRequirement, Query}; + let mut input = fixture(); + let queries = input.query_workload.repeating_queries.as_mut().unwrap(); + let template = queries[0].clone(); + *queries = [ + ("sum(sum_over_time(m[1m]))", AccuracyTarget::Exact), + ( + "quantile_over_time(0.9, m[1m])", + AccuracyTarget::Epsilon(0.05), + ), + ("count_over_time(m[1m])", AccuracyTarget::Exact), + ] + .into_iter() + .map(|(query, accuracy)| { + let mut entry = template.clone(); + entry.query = Query(query.into()); + entry.requirements.accuracy = AccuracyRequirement::Explicit(accuracy); + entry + }) + .collect(); + let input = with_unit_quotes(input); + let evidence = input.workload_cost_evidence.clone().unwrap(); + let (request, env) = input.into_physical_compilation_request().unwrap(); + let candidates = enumerate_exact_and_materialized_candidates(request).unwrap(); + assert!(candidates.len() > 2); + assert!(candidates[1..].iter().any(|candidate| candidate + .queries + .iter() + .zip(&candidates[0].queries) + .any(|(a, b)| Rc::ptr_eq(&a.selected_plan_root, &b.selected_plan_root)))); + + let reference = candidates + .iter() + .map(|candidate| { + for query in &candidate.queries { + let mut fresh = query.clone(); + fresh.retain_physical_candidate().unwrap(); + assert_eq!(fresh.physical_candidate, query.physical_candidate); + } + let description = candidate_description(candidate, |query| { + crate::planner_selection::explained_root_id( + &query.selected_plan_root, + &query.accuracy_target, + ) + }); + let plan = DeploymentPlanCompiler + .compile_promql(candidate.clone(), env.clone()) + .ok(); + (description, plan) + }) + .collect::>(); + let selected = select_lowest_cost_candidate(candidates.clone(), env, &evidence).unwrap(); + let mut selected_json = serde_json::to_value(&selected).unwrap(); + selected_json["cost_comparison"] = Value::Null; + let report = selected.cost_comparison.unwrap(); + assert_eq!(report.candidate_evaluations.len(), reference.len()); + for (index, (actual, (expected, plan))) in report + .candidate_evaluations + .iter() + .zip(&reference) + .enumerate() + { + assert_eq!(actual.candidate_id, expected.candidate_id); + assert_eq!(actual.logical_root_ids, expected.logical_root_ids); + assert_eq!( + actual.identity_unavailable_reason, + expected.identity_unavailable_reason + ); + assert_eq!( + actual.plan_id, + plan.as_ref().map(|plan| plan.envelope.plan_id) + ); + if actual.status == CandidateEvaluationStatus::Selected { + let plan = plan.as_ref().unwrap(); + assert_eq!(selected_json, serde_json::to_value(plan).unwrap()); + assert_eq!( + report.selected_manifest, + manifest(plan, &candidates[index].queries).unwrap() + ); + } + } + } + // Explanation records the same minimum quote selected by the existing algorithm. #[test] fn explain_links_selected_roots_without_changing_cost_choice() { From 9efc2526314d44dfaf1cae7beacac840d9ce1ab9 Mon Sep 17 00:00:00 2001 From: zzylol Date: Tue, 29 Sep 2026 22:58:48 +0000 Subject: [PATCH 19/19] perf(asap_types): decode each installed DAG once per validation PrecomputePlan::validate and validate_catalog_contents decoded every installed DAG inside the per-materialization loop, O(materializations x DAGs) decodes. Decode lazily once per DAG; decode errors still surface only where a DAG is used. Co-Authored-By: Claude Opus 5.5 --- crates/asap_types/src/precompute_plan.rs | 16 +++++++++++----- .../asap_types/src/precompute_plan/catalog.rs | 17 +++++++++++++---- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/crates/asap_types/src/precompute_plan.rs b/crates/asap_types/src/precompute_plan.rs index 7aa59eb09..e9cb9f253 100644 --- a/crates/asap_types/src/precompute_plan.rs +++ b/crates/asap_types/src/precompute_plan.rs @@ -522,6 +522,12 @@ impl PrecomputePlan { .chain(input.inputs.iter().copied()) }) .collect(); + // Decode each DAG at most once; errors still surface only where used. + let dags = self + .executable_dags + .values() + .map(|installed| (installed, std::cell::OnceCell::new())) + .collect::>(); for config in &self.materializations { if !config.population_key_encoding.is_legacy() && (self.ingest.protocol != IngestProtocol::PrometheusRemoteWriteV1 @@ -572,11 +578,11 @@ impl PrecomputePlan { } validated_source_window_cohort(config, &sources)?; let mut matched = false; - for installed in self.executable_dags.values() { - let dag = installed - .document - .decode() - .map_err(PrecomputePlanError::CatalogContract)?; + for (installed, dag) in &dags { + let dag = dag + .get_or_init(|| installed.document.decode()) + .as_ref() + .map_err(|error| PrecomputePlanError::CatalogContract(error.clone()))?; for sink in &installed.binding.precompute_sinks { if !matches!(installed.binding.node(*sink), Some(crate::executable_plan::BackendNodeBinding::Materialization { stored_output }) diff --git a/crates/asap_types/src/precompute_plan/catalog.rs b/crates/asap_types/src/precompute_plan/catalog.rs index ad086c2c0..80bfbcaa3 100644 --- a/crates/asap_types/src/precompute_plan/catalog.rs +++ b/crates/asap_types/src/precompute_plan/catalog.rs @@ -55,6 +55,12 @@ impl PrecomputePlan { "stored output semantics differ from installed writer computation", )); } + // Decode each DAG at most once; errors still surface only where used. + let dags = self + .executable_dags + .values() + .map(|installed| (installed, std::cell::OnceCell::new())) + .collect::>(); for config in &self.materializations { if config.derived_input.is_some() && config.semantic_fragment.is_none() { return Err(invalid( @@ -63,16 +69,19 @@ impl PrecomputePlan { } if let Some(expected) = &config.semantic_fragment { let mut found = false; - for installed in self.executable_dags.values() { - let dag = installed.document.decode().map_err(invalid)?; + for (installed, dag) in &dags { + let dag = dag + .get_or_init(|| installed.document.decode()) + .as_ref() + .map_err(|error| invalid(error.clone()))?; for (id, binding) in &installed.binding.nodes { if matches!(binding, crate::executable_plan::BackendNodeBinding::Materialization { stored_output } if stored_output.fingerprint() == config.policy_fingerprint()) { found = true; let actual = match &self.ingest.dataset_identity { - Some(dataset) => crate::semantic_fragment::SemanticFragment::from_stored_output_in_dataset(&dag, *id, dataset.clone()), - None => crate::semantic_fragment::SemanticFragment::from_stored_output(&dag, *id), + Some(dataset) => crate::semantic_fragment::SemanticFragment::from_stored_output_in_dataset(dag, *id, dataset.clone()), + None => crate::semantic_fragment::SemanticFragment::from_stored_output(dag, *id), }.map_err(invalid)?; if &actual != expected { return Err(invalid(