diff --git a/control_plane/src/clickhouse.rs b/control_plane/src/clickhouse.rs index 5b804fd6..54b19327 100644 --- a/control_plane/src/clickhouse.rs +++ b/control_plane/src/clickhouse.rs @@ -1453,14 +1453,10 @@ mod tests { ("telemetry".into(), timestamped("timestamp_ms", "value")), ( "divisors".into(), - Schema::with_time_index( - vec![ - Column::new("timestamp", DataType::Int64, false), - Column::new("divisor", DataType::Float64, false), - ], - 0, - vec![], - ), + Schema::new(vec![ + Column::new("timestamp", DataType::Int64, false), + Column::new("divisor", DataType::Float64, false), + ]), ), ]), accuracy: AccuracyTarget::Exact, diff --git a/control_plane/src/physical/executable_binding.rs b/control_plane/src/physical/executable_binding.rs index 41f12ab2..535b40a2 100644 --- a/control_plane/src/physical/executable_binding.rs +++ b/control_plane/src/physical/executable_binding.rs @@ -4,44 +4,17 @@ pub use asap_types::executable_plan::*; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum OperatorExecution { - Maintenance, + Ingestion, Query, } -/// Keep the backend's ownership decision exhaustive over Planner's physical IR. -/// Adding a payload variant upstream must therefore choose an executor here. -fn operator_execution( - node: &planner_types::post_asap::ExecutableDagNode, -) -> Result { - use planner_types::post_asap::{ExecutableOperatorPayload as Payload, ExecutionTiming}; - - let declared = match &node.payload { - Payload::Binary { timing, .. } - | Payload::Value { timing, .. } - | Payload::SummaryMerge { timing } => *timing, - Payload::MembershipFilter { .. } | Payload::SummaryEstimate { .. } => { - ExecutionTiming::QueryTime - } - Payload::SummaryAgg { .. } - | Payload::SummaryJoin { .. } - | Payload::SummarySubtract - | Payload::SummaryDelete { .. } => ExecutionTiming::IngestionTime, - // These operators can be placed on either side of the stored-state - // boundary. Planner's validated output state is authoritative. - Payload::Fallback { .. } | Payload::RelationalJoin { .. } => node.output_state.timing, - }; - if declared != node.output_state.timing { - return Err(format!( - "post-ASAP node {:?} has operator timing {} but output state {}", - node.id, - declared.as_str(), - node.output_state - )); +/// 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 { + match node.output_state.timing { + planner_types::post_asap::ExecutionTiming::IngestionTime => OperatorExecution::Ingestion, + planner_types::post_asap::ExecutionTiming::QueryTime => OperatorExecution::Query, } - Ok(match declared { - ExecutionTiming::IngestionTime => OperatorExecution::Maintenance, - ExecutionTiming::QueryTime => OperatorExecution::Query, - }) } /// Assign backend phases to a selected semantic DAG without changing its nodes. @@ -67,11 +40,11 @@ pub fn install_selected_dag( asap_physical_operators::capability::validate_summary_kernel(family, input, grouping) .map_err(|reason| format!("post-ASAP node {:?}: {reason}", node.id))?; } - let execution = operator_execution(node)?; + let execution = operator_execution(node); let binding = if let Some(summary_definition) = materialization(node.id) { precompute_sinks.push(node.id); BackendNodeBinding::Materialization { summary_definition } - } else if execution == OperatorExecution::Maintenance { + } else if execution == OperatorExecution::Ingestion { BackendNodeBinding::MaintenanceInput } else { query_node(node.id).map_or(BackendNodeBinding::QueryInput, |query_node| { diff --git a/control_plane/src/query_plan.rs b/control_plane/src/query_plan.rs index 0a1eb1a5..a35de2b5 100644 --- a/control_plane/src/query_plan.rs +++ b/control_plane/src/query_plan.rs @@ -386,6 +386,7 @@ where child.schema.fields.get(column).map(|field| &field.dtype), Some(SummaryFamilyType::Plain( planner_types::pre_asap::DataType::Float64 + | planner_types::pre_asap::DataType::Int64 )) | Some(SummaryFamilyType::ExactAggregate(..)) ) { return Err(QueryPlanError::Invalid( diff --git a/crates/asap_types/src/query_plan.rs b/crates/asap_types/src/query_plan.rs index 7282719b..51204ce0 100644 --- a/crates/asap_types/src/query_plan.rs +++ b/crates/asap_types/src/query_plan.rs @@ -366,6 +366,7 @@ impl QueryPlanEntry { ))); } for (id, node) in &self.nodes { + validate_native_relation(*id, node)?; if let QueryPlanNode::Logical { operator, inputs } = node { operator.validate(inputs.len())?; } @@ -746,3 +747,99 @@ mod contract_tests { assert_send_sync::(); } } + +/// Bind portable relation semantics before an installed plan can access its sources. +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::dag::planner::bind_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. + #[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()); + } +} diff --git a/data_plane/src/precompute_engine/worker.rs b/data_plane/src/precompute_engine/worker.rs index 522e6923..c23b9fd5 100644 --- a/data_plane/src/precompute_engine/worker.rs +++ b/data_plane/src/precompute_engine/worker.rs @@ -4624,9 +4624,9 @@ mod dag_execution_tests { .unwrap(); installed.document.schema_version = asap_types::executable_plan::MAINTENANCE_DAG_SCHEMA_VERSION; - let error = InstalledPrecomputePlan::from_precompute_plan(plan) + assert!(InstalledPrecomputePlan::from_precompute_plan(plan) .unwrap_err() - .to_string(); - assert!(error.contains("update"), "{error}"); + .to_string() + .contains("update")); } } 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 cf8c93cb..cc4a77ae 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 @@ -1,5 +1,6 @@ //! Catalog-backed ClickHouse acceleration boundary. +use asap_physical_operators::accumulators::SumAccumulator; use async_trait::async_trait; use axum::{ body::Bytes, @@ -856,7 +857,12 @@ mod tests { "SELECT sum(value) FROM requests WHERE timestamp >= 2000 AND timestamp < 3000".into(); let uncovered = accelerator.execute(&request).await; assert!( - matches!(&uncovered, ClickHouseAccelerationOutcome::Fallback(ClickHouseAccelerationFallback::Execution(detail)) if detail.contains("NoCandidates")), + matches!( + &uncovered, + ClickHouseAccelerationOutcome::Fallback( + ClickHouseAccelerationFallback::IncompleteCoverage + ) + ), "{uncovered:?}" ); 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 3563a60d..0cb2451c 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 @@ -1,7 +1,7 @@ //! SQL boundary around the shared, compiler-bound physical DAG executor. use super::{ clickhouse_result_adapter::{from_series_rows, ClickHouseQueryResult}, - relational_adapter::{ClickHouseRelation, ClickHouseRelationalAdapter}, + relational_adapter::ClickHouseRelation, }; use crate::{ query_engines::asap_query_engine::{ @@ -11,7 +11,6 @@ use crate::{ }; use asap_types::query_plan::{QueryNodeId, QueryPlanEntry, QueryPlanNode}; use asap_types::summary_catalog::SummaryCatalog; -use planner_types::post_asap::ValueOperation; use std::collections::{BTreeMap, BTreeSet}; pub type PreparedExternalLeaves = BTreeMap; @@ -27,154 +26,332 @@ pub enum ClickHouseDagFallback { ResultEncoding(String), } -fn apply_relational_operation( - operation: serde_json::Value, - output_schema: &planner_types::post_asap::SummarySchema, - relation: ClickHouseRelation, -) -> Result { - let adapter = ClickHouseRelationalAdapter; - if let Some(filter) = operation.get("Filter") { - let predicate = filter - .get("pred") - .cloned() - .ok_or_else(|| "published Filter lacks pred".to_owned()) - .and_then(|value| serde_json::from_value(value).map_err(|error| error.to_string()))?; - return adapter - .apply_filter(&predicate, relation) - .map_err(|error| error.to_string()); - } - let operation: ValueOperation = - serde_json::from_value(operation).map_err(|error| error.to_string())?; - adapter - .apply_operation(&operation, output_schema, relation) - .map_err(|error| error.to_string()) -} - -fn execute_relation_subtree( - index: &SketchStore, - entry: &QueryPlanEntry, - root: QueryNodeId, - expected_schema: &planner_types::post_asap::SummarySchema, - prepared: &PreparedExternalLeaves, +struct RelationDagExecutor<'a> { + index: &'a SketchStore, + entry: &'a QueryPlanEntry, + prepared: &'a PreparedExternalLeaves, t0_ms: u64, t1_ms: u64, is_cumulative: bool, -) -> Result { - match entry.nodes.get(&root) { - Some(QueryPlanNode::ExternalExact { request, .. }) => { - if request.language != asap_types::QueryLanguage::ClickHouseSql { - return Err("ClickHouse DAG contains an external leaf for another language".into()); - } - let asap_types::query_plan::ExternalExactOutput::Relation { schema } = &request.output - else { - return Err("ClickHouse external leaf must declare relation output".into()); - }; - let declared: planner_types::post_asap::SummarySchema = - serde_json::from_value(schema.clone()).map_err(|error| error.to_string())?; - if &declared != expected_schema { - return Err("external exact leaf schema differs from its parent edge".into()); + memo: BTreeMap, + schemas: BTreeMap, + #[cfg(test)] + evaluations: BTreeMap, +} + +impl RelationDagExecutor<'_> { + fn execute( + &mut self, + root: QueryNodeId, + expected_schema: &planner_types::post_asap::SummarySchema, + ) -> Result { + if let Some(schema) = self.schemas.get(&root) { + if schema != expected_schema { + return Err(format!( + "query `{}` node {} is consumed with inconsistent relation schemas", + self.entry.query_id, root.0 + )); } - prepared - .get(&root) - .cloned() - .ok_or_else(|| "published external exact leaf was not prepared".into()) } - Some(QueryPlanNode::Relational { - input, - operation, - input_schema, - output_schema, - }) => { - let input = execute_relation_subtree( - index, - entry, - *input, - input_schema, - prepared, - t0_ms, - t1_ms, - is_cumulative, - )?; - apply_relational_operation(operation.clone(), output_schema, input) + if let Some(relation) = self.memo.get(&root) { + return Ok(relation.clone()); } - Some(QueryPlanNode::RelationalJoin { - inputs, - join_kind, - pred, - left_schema, - right_schema, - output_schema, - pruning, - }) => { - if pruning.is_some() { - return Err("candidate pruning requires a certified semi-join binding".into()); + self.schemas.insert(root, expected_schema.clone()); + let relation = self.execute_graph(root, expected_schema)?; + self.record_evaluation(root); + self.memo.insert(root, relation.clone()); + Ok(relation) + } + + #[cfg(test)] + fn record_evaluation(&mut self, id: QueryNodeId) { + *self.evaluations.entry(id).or_default() += 1; + } + + #[cfg(not(test))] + fn record_evaluation(&mut self, _id: QueryNodeId) {} + + fn execute_graph( + &mut self, + root: QueryNodeId, + expected: &planner_types::post_asap::SummarySchema, + ) -> Result { + use super::relational_adapter::native; + use asap_physical_operators::dag::{self, operators::Operator}; + 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; } - if !matches!(join_kind, planner_types::pre_asap::JoinKind::Inner) { - return Err("only inner relational joins are executable".into()); + 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()), + ], + ) + } + Some(_) => { + sources.push(id); + continue; + } + 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::bind_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 mut graph = dag::PhysicalDag::default(); + for (id, (inputs, op)) in operations { + graph.add(id.0, inputs, op).map_err(|e| e.to_string())?; + } + // Empty placeholders validate DAG shape before deployment source access. + let mut validation = dag::PhysicalDag::default(); + for (id, schema) in &schemas { + let inputs = if sources.contains(id) { + vec![] + } else { + self.entry.nodes[id] + .inputs() + .iter() + .map(|id| id.0) + .collect() + }; + validation + .add( + id.0, + inputs.clone(), + SchemaCheck { + inputs: inputs + .iter() + .map(|id| Arc::new(schemas[&QueryNodeId(*id)].clone())) + .collect(), + output: Arc::new(schema.clone()), + }, + ) + .map_err(|e| e.to_string())?; + } + validation.validate(&[root.0]).map_err(|e| e.to_string())?; + let context = dag::RunContext::new( + dag::Scope::Query { + evaluation_time_ms: i64::try_from(self.t1_ms) + .map_err(|_| "evaluation time overflow")?, + revision: self.index.summary_update_revision().mutation_sequence(), + }, + dag::Limits::default(), + ) + .map_err(|e| e.to_string())?; + let mut storage_roots = BTreeSet::new(); + for source in &sources { + if !matches!( + self.entry.nodes[source], + QueryPlanNode::ExternalExact { .. } + ) { + storage_roots.insert(*source); + for id in self + .entry + .topological_order_from(*source) + .map_err(|e| e.to_string())? + { + if matches!( + self.entry.nodes[&id], + QueryPlanNode::ReadMaterialization { .. } + ) { + storage_roots.insert(id); + } + } } - let left = execute_relation_subtree( - index, - entry, - inputs[0], - left_schema, - prepared, - t0_ms, - t1_ms, - is_cumulative, - )?; - let right = execute_relation_subtree( - index, - entry, - inputs[1], - right_schema, - prepared, - t0_ms, - t1_ms, - is_cumulative, - )?; - let pred = serde_json::from_value(pred.clone()).map_err(|error| error.to_string())?; - ClickHouseRelationalAdapter - .apply_inner_equi_join(&pred, output_schema, left, right) - .map_err(|error| error.to_string()) } - Some(_) => { - validate_reachable(entry, root)?; - let outcome = - execute_query_plan_from_readout(index, entry, root, t0_ms, t1_ms, is_cumulative) - .map_err(|error| format!("incomplete leaf coverage: {error:?}"))?; - let reachable = entry - .topological_order_from(root) - .map_err(|error| format!("invalid leaf DAG: {error}"))?; - for (leaf_id, binding) in reachable.iter().filter_map(|id| match entry.nodes.get(id) { - Some(QueryPlanNode::ReadMaterialization { binding }) => Some((*id, binding)), - _ => None, - }) { - let leaf_outcome = execute_query_plan_from_readout( - index, - entry, - leaf_id, - t0_ms, - t1_ms, - is_cumulative, + let stored = crate::query_engines::asap_query_engine::post_asap_readout::execute_query_plan_readouts( + self.index,self.entry,&storage_roots.into_iter().collect::>(),self.t0_ms,self.t1_ms,self.is_cumulative,context.clone(), + ).map_err(|e|format!("incomplete leaf coverage: {e:?}"))?; + let mut coverage = None; + let mut first = true; + for id in sources { + let relation = self.execute_source(id, &schemas[&id], &stored)?; + coverage = if first { + first = false; + relation.coverage + } else { + match (coverage, relation.coverage) { + (Some((a, b)), Some((c, d))) if a.max(c) <= b.min(d) => { + Some((a.max(c), b.min(d))) + } + _ => None, + } + }; + let batch = native::batch(&relation, &schemas[&id]).map_err(|e| e.to_string())?; + graph + .add( + id.0, + vec![], + Operator::source(batch.schema().clone(), vec![batch]) + .map_err(|e| e.to_string())?, ) - .map_err(|error| format!("incomplete leaf coverage: {error:?}"))?; - if !binding.covers_range(t0_ms, t1_ms) - || !complete_pane_coverage( - leaf_outcome.coverage, - (t0_ms, t1_ms), - binding.window_ms, - ) + .map_err(|e| e.to_string())?; + } + let mut output = graph + .execute(&[root.0], context) + .map_err(|e| e.to_string())? + .remove(0); + let mut batches = Vec::new(); + loop { + match output.next().now_or_never() { + Some(Some(Ok(batch))) => batches.push(batch), + Some(Some(Err(error))) => return Err(error.to_string()), + Some(None) => break, + None => continue, + } + } + native::relation(&batches, expected, coverage).map_err(|e| e.to_string()) + } + + fn execute_source( + &mut self, + root: QueryNodeId, + expected_schema: &planner_types::post_asap::SummarySchema, + stored: &BTreeMap< + QueryNodeId, + crate::query_engines::asap_query_engine::post_asap_readout::PostAsapReadoutOutcome, + >, + ) -> Result { + match self.entry.nodes.get(&root) { + Some(QueryPlanNode::ExternalExact { request, .. }) => { + if request.language != asap_types::QueryLanguage::ClickHouseSql { + return Err( + "ClickHouse DAG contains an external leaf for another language".into(), + ); + } + let asap_types::query_plan::ExternalExactOutput::Relation { schema } = + &request.output + else { + return Err("ClickHouse external leaf must declare relation output".into()); + }; + let declared: planner_types::post_asap::SummarySchema = + serde_json::from_value(schema.clone()).map_err(|error| error.to_string())?; + if &declared != expected_schema { + return Err("external exact leaf schema differs from its parent edge".into()); + } + self.prepared + .get(&root) + .cloned() + .ok_or_else(|| "published external exact leaf was not prepared".into()) + } + Some(_) => { + let outcome = stored.get(&root).ok_or("missing bound storage frontier")?; + let reachable = self + .entry + .topological_order_from(root) + .map_err(|error| format!("invalid leaf DAG: {error}"))?; + for (leaf_id, binding) in + reachable + .iter() + .filter_map(|id| match self.entry.nodes.get(id) { + Some(QueryPlanNode::ReadMaterialization { binding }) => { + Some((*id, binding)) + } + _ => None, + }) { - return Err(format!( - "incomplete leaf coverage: requested ({t0_ms}, {t1_ms}), observed {:?}, pane {} origin {}", + let leaf_outcome = stored + .get(&leaf_id) + .ok_or("missing materialization coverage")?; + if !binding.covers_range(self.t0_ms, self.t1_ms) + || !complete_pane_coverage( + leaf_outcome.coverage, + (self.t0_ms, self.t1_ms), + binding.window_ms, + ) + { + return Err(format!( + "incomplete leaf coverage: requested ({}, {}), observed {:?}, pane {} origin {}", + self.t0_ms, + self.t1_ms, leaf_outcome.coverage, binding.window_ms, binding.pane_origin_ms.unwrap_or(0) )); + } } - } - ClickHouseRelation::from_series_rows(expected_schema, outcome.series, outcome.coverage) + ClickHouseRelation::from_series_rows( + expected_schema, + outcome.series.clone(), + outcome.coverage, + ) .map_err(|error| error.to_string()) + } + None => Err(format!("published DAG references missing node {}", root.0)), } - None => Err(format!("published DAG references missing node {}", root.0)), } } pub enum ClickHouseDagOutcome { @@ -238,7 +415,11 @@ fn execute_sql_dag_with_external_unfenced( ids.iter().any(|id| { matches!( entry.nodes.get(id), - Some(QueryPlanNode::RelationalJoin { .. } | QueryPlanNode::ExternalExact { .. }) + Some( + QueryPlanNode::Relational { .. } + | QueryPlanNode::RelationalJoin { .. } + | QueryPlanNode::ExternalExact { .. } + ) ) }) }); @@ -269,16 +450,20 @@ fn execute_sql_dag_with_external_unfenced( )) } }; - let relation = match execute_relation_subtree( + let relation = match (RelationDagExecutor { index, entry, - entry.root, - &root_schema, prepared, t0_ms, t1_ms, is_cumulative, - ) { + memo: BTreeMap::new(), + schemas: BTreeMap::new(), + #[cfg(test)] + evaluations: BTreeMap::new(), + }) + .execute(entry.root, &root_schema) + { Ok(relation) => relation, Err(error) if error.contains("incomplete leaf coverage") => { return ClickHouseDagOutcome::Fallback(ClickHouseDagFallback::IncompleteCoverage { @@ -289,7 +474,7 @@ fn execute_sql_dag_with_external_unfenced( Err(error) => { return ClickHouseDagOutcome::Fallback(ClickHouseDagFallback::UnsupportedPlan( error, - )) + )); } }; let bindings = entry.materialization_bindings(); @@ -315,26 +500,7 @@ fn execute_sql_dag_with_external_unfenced( )), }; } - let mut base_root = entry.root; - let mut relational = Vec::new(); - loop { - match entry.nodes.get(&base_root) { - Some(QueryPlanNode::Relational { - input, - operation, - input_schema, - output_schema, - }) => { - relational.push(( - operation.clone(), - input_schema.clone(), - output_schema.clone(), - )); - base_root = *input; - } - _ => break, - } - } + let base_root = entry.root; if let Err(detail) = validate_reachable(entry, base_root) { return ClickHouseDagOutcome::Fallback(ClickHouseDagFallback::UnsupportedPlan(detail)); } @@ -371,67 +537,7 @@ fn execute_sql_dag_with_external_unfenced( observed: outcome.coverage, }); } - if relational.is_empty() { - return match from_series_rows(outcome.series) { - Ok(result) => ClickHouseDagOutcome::Accelerated(result), - Err(error) => ClickHouseDagOutcome::Fallback(ClickHouseDagFallback::ResultEncoding( - error.to_string(), - )), - }; - } - let input_schema = &relational.last().expect("non-empty").1; - let mut relation = match ClickHouseRelation::from_series_rows( - input_schema, - outcome.series, - outcome.coverage, - ) { - Ok(relation) => relation, - Err(error) => { - return ClickHouseDagOutcome::Fallback(ClickHouseDagFallback::ResultEncoding( - error.to_string(), - )) - } - }; - let adapter = ClickHouseRelationalAdapter; - for (wire_operation, _, output_schema) in relational.into_iter().rev() { - if let Some(filter) = wire_operation.get("Filter") { - let predicate = filter - .get("pred") - .cloned() - .ok_or_else(|| "published Filter lacks pred".to_owned()) - .and_then(|value| serde_json::from_value(value).map_err(|error| error.to_string())); - relation = match predicate.and_then(|predicate| { - adapter - .apply_filter(&predicate, relation) - .map_err(|error| error.to_string()) - }) { - Ok(relation) => relation, - Err(error) => { - return ClickHouseDagOutcome::Fallback(ClickHouseDagFallback::UnsupportedPlan( - format!("invalid published Filter: {error}"), - )) - } - }; - continue; - } - let operation: ValueOperation = match serde_json::from_value(wire_operation) { - Ok(operation) => operation, - Err(error) => { - return ClickHouseDagOutcome::Fallback(ClickHouseDagFallback::UnsupportedPlan( - format!("invalid published relational operation: {error}"), - )) - } - }; - relation = match adapter.apply_operation(&operation, &output_schema, relation) { - Ok(relation) => relation, - Err(error) => { - return ClickHouseDagOutcome::Fallback(ClickHouseDagFallback::UnsupportedPlan( - error.to_string(), - )) - } - }; - } - match relation.into_result() { + match from_series_rows(outcome.series) { Ok(result) => ClickHouseDagOutcome::Accelerated(result), Err(error) => { ClickHouseDagOutcome::Fallback(ClickHouseDagFallback::ResultEncoding(error.to_string())) @@ -455,9 +561,191 @@ fn validate_reachable(entry: &QueryPlanEntry, root: QueryNodeId) -> Result<(), S Ok(()) } +// Validate schemas and graph shape before touching deployment sources. +struct SchemaCheck { + inputs: Vec, + output: asap_physical_operators::dag::values::Schema, +} +impl + asap_physical_operators::dag::PhysicalOperator< + asap_physical_operators::dag::values::Batch, + asap_physical_operators::dag::values::Schema, + > for SchemaCheck +{ + fn name(&self) -> &str { + "SchemaCheck" + } + fn input_schemas(&self) -> Vec { + self.inputs.clone() + } + fn output_schema(&self) -> asap_physical_operators::dag::values::Schema { + self.output.clone() + } + fn output_bytes(&self, batch: &asap_physical_operators::dag::values::Batch) -> usize { + batch.bytes() + } + fn start<'a>( + &'a self, + _: Vec< + asap_physical_operators::dag::Input<'a, asap_physical_operators::dag::values::Batch>, + >, + _: asap_physical_operators::dag::RunContext, + ) -> Result< + asap_physical_operators::dag::OutputStream<'a, asap_physical_operators::dag::values::Batch>, + asap_physical_operators::dag::Error, + > { + Err(asap_physical_operators::dag::Error::Invalid( + "validation-only source".into(), + )) + } +} + #[cfg(test)] mod tests { - use super::complete_pane_coverage; + use super::*; + use asap_types::query_plan::{ + ExternalExactOutput, ExternalExactRequest, FallbackPolicy, InstantExecution, QueryLanguage, + }; + use planner_types::{ + post_asap::{SummaryFamilyType, SummaryField, SummarySchema}, + pre_asap::DataType, + }; + + fn relation_schema(name: &str) -> SummarySchema { + SummarySchema { + fields: vec![SummaryField { + name: name.into(), + dtype: SummaryFamilyType::Plain(DataType::Int64), + nullable: false, + }], + time_index: None, + } + } + + fn external_entry(schema: &SummarySchema) -> QueryPlanEntry { + QueryPlanEntry { + language: QueryLanguage::ClickHouseSql, + query_id: "shared-external".into(), + canonical_query: "SELECT x".into(), + fixed_evaluation: None, + root: QueryNodeId(0), + nodes: BTreeMap::from([( + QueryNodeId(0), + QueryPlanNode::ExternalExact { + request: ExternalExactRequest { + language: QueryLanguage::ClickHouseSql, + expression: "SELECT 1 AS x".into(), + output: ExternalExactOutput::Relation { + schema: serde_json::to_value(schema).unwrap(), + }, + parameters: BTreeMap::new(), + start_parameter: None, + end_parameter: None, + input_contracts: Vec::new(), + }, + inputs: Vec::new(), + }, + )]), + instant: InstantExecution { + lookback_ms: 0, + full_history: false, + cumulative_readout: false, + }, + fallback: FallbackPolicy::Reject, + } + } + + #[test] + fn relation_dag_memoizes_a_shared_node_and_enforces_one_edge_schema() { + let schema = relation_schema("x"); + let entry = external_entry(&schema); + let relation = ClickHouseRelation::from_json_compact( + &schema, + br#"{"meta":[{"name":"x","type":"Int64"}],"data":[[1]]}"#, + ) + .unwrap(); + let prepared = BTreeMap::from([(QueryNodeId(0), relation)]); + let index = SketchStore::new(); + let mut executor = RelationDagExecutor { + index: &index, + entry: &entry, + prepared: &prepared, + t0_ms: 0, + t1_ms: 1, + is_cumulative: false, + memo: BTreeMap::new(), + schemas: BTreeMap::new(), + evaluations: BTreeMap::new(), + }; + + executor.execute(QueryNodeId(0), &schema).unwrap(); + executor.execute(QueryNodeId(0), &schema).unwrap(); + assert_eq!(executor.evaluations[&QueryNodeId(0)], 1); + + let error = executor + .execute(QueryNodeId(0), &relation_schema("different")) + .unwrap_err(); + assert!(error.contains("query `shared-external` node 0")); + assert!(error.contains("inconsistent relation schemas")); + } + + // 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, + }, + ); + entry.root = QueryNodeId(1); + let relation = ClickHouseRelation::from_json_compact( + &schema, + br#"{"meta":[{"name":"x","type":"Int64"}],"data":[[1],[2]]}"#, + ) + .unwrap(); + let prepared = BTreeMap::from([(QueryNodeId(0), relation)]); + let index = SketchStore::new(); + let mut executor = RelationDagExecutor { + index: &index, + entry: &entry, + prepared: &prepared, + t0_ms: 0, + t1_ms: 1, + is_cumulative: false, + memo: BTreeMap::new(), + schemas: BTreeMap::new(), + evaluations: BTreeMap::new(), + }; + let result = executor + .execute(entry.root, &output) + .unwrap() + .into_result() + .unwrap(); + assert_eq!( + result + .batches + .iter() + .map(|batch| batch.num_rows()) + .sum::(), + 4 + ); + } + #[test] fn exact_accumulator_window_end_coverage_includes_its_pane_start() { assert!(complete_pane_coverage( 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 c0acadd8..a38ec246 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 @@ -1,9 +1,11 @@ //! ClickHouse row semantics for planner-owned relational wrappers. +#[cfg(test)] mod aggregate; mod collection; +pub(super) mod native; -use std::{cmp::Ordering, collections::BTreeMap, sync::Arc}; +use std::{collections::BTreeMap, sync::Arc}; use arrow::{ array::{ @@ -16,10 +18,12 @@ use arrow::{ use chrono::{DateTime, NaiveDateTime, TimeZone}; use planner_types::{ post_asap::{SummaryFamilyType, SummarySchema, ValueOperation}, - pre_asap::{ArithmeticOpKind, CompareOpKind, DataType, QueryExpr, ScalarValue, SortKey}, + pre_asap::DataType, }; use super::clickhouse_result_adapter::ClickHouseQueryResult; +#[cfg(test)] +use planner_types::pre_asap::{ArithmeticOpKind, CompareOpKind, QueryExpr, ScalarValue, SortKey}; #[derive(Debug, thiserror::Error, PartialEq)] pub enum ClickHouseRelationalError { @@ -371,134 +375,51 @@ fn clickhouse_type_matches(actual: Option<&str>, expected: &DataType, nullable: pub struct ClickHouseRelationalAdapter; impl ClickHouseRelationalAdapter { - pub fn apply_inner_equi_join( + pub fn apply_join( &self, + kind: &planner_types::pre_asap::JoinKind, pred: &planner_types::pre_asap::Predicate, output_schema: &SummarySchema, left: ClickHouseRelation, right: ClickHouseRelation, ) -> Result { - let coverage = match (left.coverage, right.coverage) { - (Some((left_start, left_end)), Some((right_start, right_end))) => { - let start = left_start.max(right_start); - let end = left_end.min(right_end); - (start <= end).then_some((start, end)) - } - _ => None, - }; - let mut fields = left.fields.clone(); - fields.extend(right.fields.clone()); - let schema = scalar_schema(&fields); - let mut rows = Vec::new(); - for left_row in &left.rows { - for right_row in &right.rows { - let mut joined = Vec::with_capacity(left_row.len() + right_row.len()); - joined.extend(left_row.iter().cloned()); - joined.extend(right_row.iter().cloned()); - if matches!(eval(&pred.0, &joined, &schema)?, Cell::Bool(true)) { - rows.push(joined); - } - } - } - Ok(ClickHouseRelation { - rows, - fields: fields_from_schema(output_schema), - coverage, - }) + native::execute( + planner_types::post_asap::ExecutableOperatorPayload::RelationalJoin { + join_kind: kind.clone(), + pred: pred.clone(), + pruning: None, + }, + output_schema, + vec![left, right], + ) } pub fn apply_filter( &self, pred: &planner_types::pre_asap::Predicate, - mut input: ClickHouseRelation, + input: ClickHouseRelation, ) -> Result { - let schema = scalar_schema(&input.fields); - input.rows = input - .rows - .into_iter() - .filter_map(|row| match eval(&pred.0, &row, &schema) { - Ok(Cell::Bool(true)) => Some(Ok(row)), - Ok(_) => None, - Err(error) => Some(Err(error)), - }) - .collect::, _>>()?; - Ok(input) + let schema = native::schema(&input); + self.apply_operation( + &ValueOperation::Filter { pred: pred.clone() }, + &schema, + input, + ) } pub fn apply_operation( &self, operation: &ValueOperation, output_schema: &SummarySchema, - mut input: ClickHouseRelation, + input: ClickHouseRelation, ) -> Result { - let schema = scalar_schema(&input.fields); - match operation { - ValueOperation::Exact(planner_types::post_asap::ExactOperation::Aggregate { - reduction, - measures, - having, - .. - }) => { - return aggregate::apply( - reduction, - measures, - having.as_ref(), - output_schema, - input, - ); - } - ValueOperation::Project { cols, .. } => { - let mut rows = Vec::with_capacity(input.rows.len()); - for row in &input.rows { - rows.push( - cols.iter() - .map(|item| eval(&item.expr, row, &schema)) - .collect::, _>>()?, - ); - } - input.rows = rows; - input.fields = fields_from_schema(output_schema); - } - ValueOperation::Sort { keys, partition_by } => { - if partition_by.is_without() || !partition_by.is_empty() { - return Err(ClickHouseRelationalError::Unsupported( - "partitioned sort".into(), - )); - } - for row in &input.rows { - for key in keys { - let value = eval(&key.expr, row, &schema)?; - if contains_nan(&value) { - return Err(ClickHouseRelationalError::Unsupported( - "NaN sort key".into(), - )); - } - if !matches!(value, Cell::Null) && cell_cmp(&value, &value).is_none() { - return Err(ClickHouseRelationalError::Unsupported( - "unsupported sort key value type".into(), - )); - } - } - } - input - .rows - .sort_by(|left, right| compare_sort_keys(left, right, keys, &schema)); - } - ValueOperation::Limit { - n, - offset, - partition_by, - } => { - if !partition_by.keys().is_empty() || partition_by.is_without() { - return Err(ClickHouseRelationalError::Unsupported( - "partitioned relation Limit is not bound".into(), - )); - } - input.rows = input.rows.into_iter().skip(*offset).take(*n).collect(); - } - other => return Err(ClickHouseRelationalError::Unsupported(format!("{other:?}"))), - } - Ok(input) + native::execute( + planner_types::post_asap::ExecutableOperatorPayload::Value { + operation: operation.clone(), + }, + output_schema, + vec![input], + ) } } @@ -560,420 +481,54 @@ fn row_from_value( .collect() } -fn scalar_schema(fields: &[(String, DataType, bool)]) -> planner_types::pre_asap::Schema { - planner_types::pre_asap::Schema::new( - fields - .iter() - .map(|(name, dtype, nullable)| { - planner_types::pre_asap::Column::new(name.clone(), dtype.clone(), *nullable) - }) - .collect(), - ) -} - +#[cfg(test)] fn eval( expr: &QueryExpr, row: &[Cell], schema: &planner_types::pre_asap::Schema, ) -> Result { - match expr { - QueryExpr::Column(index) => { - row.get(*index) - .cloned() - .ok_or(ClickHouseRelationalError::ColumnOutOfRange( - *index, - row.len(), - )) - } - QueryExpr::Literal(value) => Ok(match value { - ScalarValue::Interval { .. } => { - return Err(ClickHouseRelationalError::Unsupported( - "interval literal".into(), - )) - } - ScalarValue::Int64(value) => Cell::Int64(*value), - ScalarValue::Float64(value) => Cell::Float64(*value), - ScalarValue::Utf8(value) => Cell::Utf8(value.clone()), - ScalarValue::Boolean(value) => Cell::Bool(*value), - ScalarValue::Null => Cell::Null, - }), - QueryExpr::Compare { left, op, right } => { - let left = eval(left, row, schema)?; - let right = eval(right, row, schema)?; - compare(op, left, right) - } - QueryExpr::Arithmetic { op, left, right } => { - arithmetic(op, eval(left, row, schema)?, eval(right, row, schema)?) - } - QueryExpr::FunctionCall { name, args } => { - use planner_types::pre_asap::scalar_signature::MapScalarFunction; - if name.eq_ignore_ascii_case("asap_struct_field") { - expr.scalar_type(schema) - .map_err(|error| ClickHouseRelationalError::Invalid(error.to_string()))?; - let DataType::Struct { fields } = args[0] - .scalar_type(schema) - .map_err(|error| ClickHouseRelationalError::Invalid(error.to_string()))? - .0 - else { - unreachable!() - }; - let offset = match &args[1] { - QueryExpr::Literal(ScalarValue::Int64(index)) => { - usize::try_from(index - 1).ok() - } - QueryExpr::Literal(ScalarValue::Utf8(name)) => { - fields.iter().position(|field| &field.name == name) - } - _ => None, - } - .ok_or_else(|| { - ClickHouseRelationalError::Invalid("struct field selector".into()) - })?; - let Cell::Struct(values) = eval(&args[0], row, schema)? else { - return Err(ClickHouseRelationalError::Invalid( - "struct field input".into(), - )); - }; - return values.get(offset).cloned().ok_or_else(|| { - ClickHouseRelationalError::Invalid("struct field value".into()) - }); - } - if name.eq_ignore_ascii_case("asap_element_access") { - let (output_type, _) = expr - .scalar_type(schema) - .map_err(|error| ClickHouseRelationalError::Invalid(error.to_string()))?; - if let DataType::List { element } = args[0] - .scalar_type(schema) - .map_err(|error| ClickHouseRelationalError::Invalid(error.to_string()))? - .0 - { - let Cell::List(values) = eval(&args[0], row, schema)? else { - return Err(ClickHouseRelationalError::Invalid( - "array access input".into(), - )); - }; - let index = match eval(&args[1], row, schema)? { - Cell::Null => return Ok(Cell::Null), - Cell::Int64(index) => index, - _ => { - return Err(ClickHouseRelationalError::Invalid( - "array access index".into(), - )) - } - }; - let offset = if index > 0 { - usize::try_from(index - 1).ok() - } else if index < 0 { - usize::try_from(index.unsigned_abs()) - .ok() - .and_then(|distance| values.len().checked_sub(distance)) - } else { - None - }; - return match offset.and_then(|offset| values.get(offset)) { - Some(value) => Ok(value.clone()), - None => default_collection_element(&output_type, element.nullable), - }; - } - } - let function = (if name.eq_ignore_ascii_case("asap_element_access") { - Some(MapScalarFunction::Access) - } else { - MapScalarFunction::from_name(name) - }) - .ok_or_else(|| { - ClickHouseRelationalError::Unsupported(format!("scalar function {name}")) - })?; - expr.scalar_type(schema) - .map_err(|error| ClickHouseRelationalError::Invalid(error.to_string()))?; - let values = args - .iter() - .map(|arg| eval(arg, row, schema)) - .collect::, _>>()?; - match function { - MapScalarFunction::Construct => { - let mut values = values.into_iter(); - let mut entries = Vec::new(); - while let Some(key) = values.next() { - if !matches!(key, Cell::Int64(_) | Cell::Utf8(_) | Cell::Bool(_)) { - return Err(ClickHouseRelationalError::Unsupported( - "map key value type".into(), - )); - } - entries.push(( - key, - values.next().ok_or_else(|| { - ClickHouseRelationalError::Invalid("odd map argument count".into()) - })?, - )); - } - Ok(Cell::Map(entries)) - } - MapScalarFunction::Concat => { - let mut entries = Vec::new(); - for value in values { - let Cell::Map(mut next) = value else { - return Err(ClickHouseRelationalError::Invalid( - "map concat argument".into(), - )); - }; - entries.append(&mut next); - } - Ok(Cell::Map(entries)) - } - MapScalarFunction::Access => { - let [Cell::Map(entries), key] = values.as_slice() else { - return Err(ClickHouseRelationalError::Invalid( - "map access arguments".into(), - )); - }; - if matches!(key, Cell::Null) { - return Ok(Cell::Null); - } - if !matches!(key, Cell::Int64(_) | Cell::Utf8(_) | Cell::Bool(_)) { - return Err(ClickHouseRelationalError::Unsupported( - "map lookup key type".into(), - )); - } - if let Some((_, value)) = entries.iter().find(|(candidate, _)| candidate == key) - { - return Ok(value.clone()); - } - let ( - DataType::Map { - value, - value_nullable, - .. - }, - _, - ) = args[0] - .scalar_type(schema) - .map_err(|error| ClickHouseRelationalError::Invalid(error.to_string()))? - else { - unreachable!() - }; - default_collection_element(&value, value_nullable) - } - } - } - other => Err(ClickHouseRelationalError::Unsupported(format!( - "scalar expression {other:?}" - ))), - } -} - -fn default_collection_element( - dtype: &DataType, - nullable: bool, -) -> Result { - if nullable { - return Ok(Cell::Null); - } - Ok(match dtype { - DataType::Interval | DataType::Date => { - return Err(ClickHouseRelationalError::Unsupported( - "temporal value transport".into(), - )) - } - DataType::Null => Cell::Null, - DataType::Int64 => Cell::Int64(0), - DataType::Float64 => Cell::Float64(0.0), - DataType::Utf8 => Cell::Utf8(String::new()), - DataType::Bool => Cell::Bool(false), - DataType::Map { .. } => Cell::Map(Vec::new()), - DataType::List { .. } => Cell::List(Arc::from([])), - DataType::Struct { fields } => Cell::Struct( - fields - .iter() - .map(|field| default_collection_element(&field.dtype, field.nullable)) - .collect::, _>>()? - .into(), - ), - _ => { - return Err(ClickHouseRelationalError::Unsupported( - "collection missing-element default type".into(), - )) - } - }) -} - -fn compare(op: &CompareOpKind, left: Cell, right: Cell) -> Result { - if matches!(left, Cell::Null) || matches!(right, Cell::Null) { - return Ok(Cell::Null); - } - let ordering = cell_cmp(&left, &right).ok_or_else(|| { - ClickHouseRelationalError::Invalid("comparison of incompatible values".into()) - })?; - let value = match op { - CompareOpKind::Eq => ordering == Ordering::Equal, - CompareOpKind::Ne => ordering != Ordering::Equal, - CompareOpKind::Lt => ordering == Ordering::Less, - CompareOpKind::Le => ordering != Ordering::Greater, - CompareOpKind::Gt => ordering == Ordering::Greater, - CompareOpKind::Ge => ordering != Ordering::Less, - _ => { - return Err(ClickHouseRelationalError::Unsupported(format!( - "comparison {op:?}" - ))) - } + let relation = ClickHouseRelation { + fields: schema + .columns + .iter() + .map(|c| (c.name.clone(), c.dtype.clone(), c.nullable)) + .collect(), + rows: vec![], + coverage: None, }; - Ok(Cell::Bool(value)) + let compiled = asap_physical_operators::dag::expressions::CompiledExpression::compile( + expr, + &Arc::new(native::schema(&relation)), + ) + .map_err(|error| ClickHouseRelationalError::Unsupported(error.to_string()))?; + let value = compiled + .evaluate(&row.iter().map(native::value).collect::>()) + .map_err(|error| ClickHouseRelationalError::Invalid(error.to_string()))?; + native::cell(&value) } - +#[cfg(test)] fn arithmetic( op: &ArithmeticOpKind, left: Cell, right: Cell, ) -> Result { - if matches!(left, Cell::Null) || matches!(right, Cell::Null) { - return Ok(Cell::Null); - } - if let (Cell::Int64(left), Cell::Int64(right)) = (&left, &right) { - let integer = match op { - ArithmeticOpKind::Add => Some(left.checked_add(*right)), - ArithmeticOpKind::Sub => Some(left.checked_sub(*right)), - ArithmeticOpKind::Mul => Some(left.checked_mul(*right)), - ArithmeticOpKind::Mod => Some(left.checked_rem(*right)), - _ => None, - }; - if let Some(value) = integer { - return value.map(Cell::Int64).ok_or_else(|| { - ClickHouseRelationalError::Invalid( - "integer arithmetic overflow or zero divisor".into(), - ) - }); - } - } - let (left, right) = match (left, right) { - (Cell::Int64(left), Cell::Int64(right)) => (left as f64, right as f64), - (Cell::Int64(left), Cell::Float64(right)) => (left as f64, right), - (Cell::Float64(left), Cell::Int64(right)) => (left, right as f64), - (Cell::Float64(left), Cell::Float64(right)) => (left, right), - _ => { - return Err(ClickHouseRelationalError::Invalid( - "arithmetic on non-numeric values".into(), - )) - } + let dtype = |value: &Cell| match value { + Cell::Int64(_) => DataType::Int64, + _ => DataType::Float64, }; - let value = match op { - ArithmeticOpKind::Add => left + right, - ArithmeticOpKind::Sub => left - right, - ArithmeticOpKind::Mul => left * right, - ArithmeticOpKind::Div if right != 0.0 => left / right, - ArithmeticOpKind::Mod if right != 0.0 => left % right, - ArithmeticOpKind::Pow => left.powf(right), - _ => { - return Err(ClickHouseRelationalError::Unsupported(format!( - "arithmetic {op:?}" - ))) - } - }; - Ok(Cell::Float64(value)) -} - -fn compare_sort_keys( - left: &[Cell], - right: &[Cell], - keys: &[SortKey], - schema: &planner_types::pre_asap::Schema, -) -> Ordering { - for key in keys { - let Ok(left) = eval(&key.expr, left, schema) else { - return Ordering::Equal; - }; - let Ok(right) = eval(&key.expr, right, schema) else { - return Ordering::Equal; - }; - let (ordering, order_depends_on_direction) = match (&left, &right) { - (Cell::Null, Cell::Null) => (Ordering::Equal, false), - (Cell::Null, _) => { - if key.nulls_first { - (Ordering::Less, false) - } else { - (Ordering::Greater, false) - } - } - (_, Cell::Null) => { - if key.nulls_first { - (Ordering::Greater, false) - } else { - (Ordering::Less, false) - } - } - _ => (cell_cmp(&left, &right).unwrap_or(Ordering::Equal), true), - }; - let ordering = if key.ascending || !order_depends_on_direction { - ordering - } else { - ordering.reverse() - }; - if ordering != Ordering::Equal { - return ordering; - } - } - Ordering::Equal -} - -fn contains_nan(value: &Cell) -> bool { - match value { - Cell::Float64(value) => value.is_nan(), - Cell::List(values) | Cell::Struct(values) => values.iter().any(contains_nan), - Cell::Map(entries) => entries - .iter() - .any(|(key, value)| contains_nan(key) || contains_nan(value)), - _ => false, - } -} - -fn integer_float_cmp(integer: i64, float: f64) -> Option { - if float.is_nan() { - return None; - } - // These bounds are powers of two, exactly representable as Float64. - if float >= 9_223_372_036_854_775_808.0 { - return Some(Ordering::Less); - } - if float < -9_223_372_036_854_775_808.0 { - return Some(Ordering::Greater); - } - let integral = float as i64; - match integer.cmp(&integral) { - Ordering::Equal => 0.0_f64.partial_cmp(&float.fract()), - other => Some(other), - } -} - -fn cell_cmp(left: &Cell, right: &Cell) -> Option { - match (left, right) { - (Cell::Int64(left), Cell::Int64(right)) => Some(left.cmp(right)), - (Cell::Float64(left), Cell::Float64(right)) => left.partial_cmp(right), - (Cell::Int64(left), Cell::Float64(right)) => integer_float_cmp(*left, *right), - (Cell::Float64(left), Cell::Int64(right)) => { - integer_float_cmp(*right, *left).map(Ordering::reverse) - } - (Cell::Utf8(left), Cell::Utf8(right)) => Some(left.cmp(right)), - (Cell::Bool(left), Cell::Bool(right)) => Some(left.cmp(right)), - (Cell::Timestamp(left), Cell::Timestamp(right)) => Some(left.cmp(right)), - (Cell::Map(left), Cell::Map(right)) => { - for ((left_key, left_value), (right_key, right_value)) in left.iter().zip(right) { - let order = cell_cmp(left_key, right_key)?; - if order != Ordering::Equal { - return Some(order); - } - let order = match (left_value, right_value) { - (Cell::Null, Cell::Null) => Ordering::Equal, - (Cell::Null, _) => Ordering::Greater, - (_, Cell::Null) => Ordering::Less, - _ => cell_cmp(left_value, right_value)?, - }; - if order != Ordering::Equal { - return Some(order); - } - } - Some(left.len().cmp(&right.len())) - } - _ => None, - } + let schema = planner_types::pre_asap::Schema::new(vec![ + planner_types::pre_asap::Column::new("left", dtype(&left), false), + planner_types::pre_asap::Column::new("right", dtype(&right), false), + ]); + eval( + &QueryExpr::Arithmetic { + op: op.clone(), + left: std::rc::Rc::new(QueryExpr::Column(0)), + right: std::rc::Rc::new(QueryExpr::Column(1)), + }, + &[left, right], + &schema, + ) } fn arrow_type(dtype: &DataType) -> ArrowDataType { @@ -1250,30 +805,6 @@ mod scalar_contract_tests { .is_err()); } - #[test] - fn mixed_comparison_preserves_integer_precision_and_boundaries() { - assert_eq!( - integer_float_cmp(9_007_199_254_740_993, 9_007_199_254_740_992.0), - Some(Ordering::Greater) - ); - assert_eq!( - integer_float_cmp(i64::MAX, 9_223_372_036_854_775_808.0), - Some(Ordering::Less) - ); - assert_eq!( - integer_float_cmp(i64::MIN, -9_223_372_036_854_775_808.0), - Some(Ordering::Equal) - ); - assert_eq!(integer_float_cmp(-1, -1.5), Some(Ordering::Greater)); - assert_eq!(integer_float_cmp(1, 1.5), Some(Ordering::Less)); - assert_eq!(integer_float_cmp(0, f64::INFINITY), Some(Ordering::Less)); - assert_eq!( - integer_float_cmp(0, f64::NEG_INFINITY), - Some(Ordering::Greater) - ); - assert_eq!(integer_float_cmp(0, f64::NAN), None); - } - #[test] fn integer_modulo_never_rounds_through_float() { assert_eq!( @@ -1652,7 +1183,10 @@ mod tests { fn unsupported_scalar_expression_fails_closed() { let row = vec![Cell::Float64(1.0)]; let error = eval( - &QueryExpr::BoolAnd(vec![]), + &QueryExpr::FunctionCall { + name: "unsupported_function".into(), + args: vec![], + }, &row, &planner_types::pre_asap::Schema::new(vec![]), ) @@ -1685,7 +1219,13 @@ mod tests { right: Rc::new(QueryExpr::Column(2)), })); let joined = ClickHouseRelationalAdapter - .apply_inner_equi_join(&pred, &joined_schema, left, right) + .apply_join( + &planner_types::pre_asap::JoinKind::Inner, + &pred, + &joined_schema, + left, + right, + ) .unwrap(); let output_schema = schema(&[("service", DataType::Utf8), ("ratio", DataType::Float64)]); let projected = ClickHouseRelationalAdapter @@ -1725,4 +1265,59 @@ mod tests { 0.2 ); } + + // Every standardized Planner join kind has concrete row semantics. + #[test] + fn executes_all_post_asap_relational_join_kinds() { + use planner_types::pre_asap::JoinKind; + + let side_schema = schema(&[("key", DataType::Int64)]); + let relation = |values: &[i64]| ClickHouseRelation { + rows: values + .iter() + .map(|value| vec![Cell::Int64(*value)]) + .collect(), + fields: fields_from_schema(&side_schema), + coverage: Some((0, 10)), + }; + let mut joined_schema = schema(&[("left", DataType::Int64), ("right", DataType::Int64)]); + for field in &mut joined_schema.fields { + field.nullable = true; + } + let pred = Predicate(Rc::new(QueryExpr::Compare { + left: Rc::new(QueryExpr::Column(0)), + op: CompareOpKind::Eq, + right: Rc::new(QueryExpr::Column(1)), + })); + let adapter = ClickHouseRelationalAdapter; + let execute = |kind, output_schema: &SummarySchema| { + adapter + .apply_join( + &kind, + &pred, + output_schema, + relation(&[1, 2]), + relation(&[2, 3]), + ) + .unwrap() + .rows + }; + + assert_eq!( + execute(JoinKind::Inner, &joined_schema), + vec![vec![Cell::Int64(2), Cell::Int64(2)]] + ); + assert_eq!(execute(JoinKind::Left, &joined_schema).len(), 2); + assert_eq!(execute(JoinKind::Right, &joined_schema).len(), 2); + assert_eq!(execute(JoinKind::Full, &joined_schema).len(), 3); + assert_eq!(execute(JoinKind::Cross, &joined_schema).len(), 4); + assert_eq!( + execute(JoinKind::Semi, &side_schema), + vec![vec![Cell::Int64(2)]] + ); + assert_eq!( + execute(JoinKind::Anti, &side_schema), + vec![vec![Cell::Int64(1)]] + ); + } } diff --git a/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter/aggregate.rs b/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter/aggregate.rs index b68077dd..49fc9301 100644 --- a/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter/aggregate.rs +++ b/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter/aggregate.rs @@ -1,178 +1,63 @@ -//! Typed query-time reductions over relational rows, independent of summary storage. +//! Integration tests bind relational reductions to Planner-owned operators. use super::*; use planner_types::pre_asap::{AggIntent, Predicate, Reduction}; - -fn unsupported(detail: &str) -> ClickHouseRelationalError { - ClickHouseRelationalError::Unsupported(detail.into()) -} - -fn group_cmp(left: &[Cell], right: &[Cell], keys: &[usize]) -> Ordering { - for key in keys { - let order = match (&left[*key], &right[*key]) { - (Cell::Null, Cell::Null) => Ordering::Equal, - (Cell::Null, _) => Ordering::Less, - (_, Cell::Null) => Ordering::Greater, - (left, right) => cell_cmp(left, right).expect("validated grouping cells"), - }; - if order != Ordering::Equal { - return order; - } - } - Ordering::Equal +fn apply( + reduction: &Reduction, + measures: &[AggIntent], + having: Option<&Predicate>, + output: &SummarySchema, + input: ClickHouseRelation, +) -> Result { + let groups = reduction.group_keys().map_or(0, |keys| keys.keys().len()); + ClickHouseRelationalAdapter.apply_operation( + &ValueOperation::Exact(planner_types::post_asap::ExactOperation::Aggregate { + reduction: reduction.clone(), + measures: measures.to_vec(), + having: having.cloned(), + output_names: output + .fields + .iter() + .skip(groups) + .map(|field| field.name.clone()) + .collect(), + }), + output, + input, + ) } - fn measure( intent: &AggIntent, rows: &[Vec], fields: &[(String, DataType, bool)], ) -> Result { - if matches!(intent, AggIntent::Count { .. }) { - return i64::try_from(rows.len()) - .map(Cell::Int64) - .map_err(|_| unsupported("row count exceeds Int64")); - } - let column = match intent { - AggIntent::Sum { col } - | AggIntent::Avg { col } - | AggIntent::Min { col } - | AggIntent::Max { col } => { - col.ok_or_else(|| unsupported("aggregate lacks input column"))? - } - _ => return Err(unsupported("relational aggregate intent")), - }; - let (_, dtype, nullable) = fields - .get(column) - .ok_or_else(|| unsupported("aggregate input column is out of range"))?; - // Nullable aggregate empty/default semantics need an explicit SQL policy. - if *nullable || !matches!(dtype, DataType::Int64 | DataType::Float64) { - return Err(unsupported("nullable or nonnumeric aggregate input")); - } - let values = rows.iter().map(|row| &row[column]).collect::>(); - if values.iter().any(|value| { - !matches!(value, Cell::Int64(_)) - && !matches!(value, Cell::Float64(value) if value.is_finite()) - }) { - return Err(unsupported("nonfinite or invalid aggregate value")); - } + let mut output = intent.output_column(&planner_types::pre_asap::Column::new( + fields[0].0.clone(), + fields[0].1.clone(), + fields[0].2, + )); if matches!(intent, AggIntent::Min { .. } | AggIntent::Max { .. }) { - let maximum = matches!(intent, AggIntent::Max { .. }); - return values - .into_iter() - .reduce(|left, right| { - let order = cell_cmp(left, right).expect("validated numeric aggregate"); - if (maximum && order.is_lt()) || (!maximum && order.is_gt()) { - right - } else { - left - } - }) - .cloned() - .ok_or_else(|| unsupported("empty min/max SQL default")); - } - if matches!(intent, AggIntent::Sum { .. }) && *dtype == DataType::Int64 { - let mut sum = 0i64; - for value in values { - let Cell::Int64(value) = value else { - return Err(unsupported("integer aggregate received noninteger")); - }; - sum = sum - .checked_add(*value) - .ok_or_else(|| unsupported("integer sum overflow"))?; - } - return Ok(Cell::Int64(sum)); - } - if matches!(intent, AggIntent::Avg { .. }) && *dtype == DataType::Int64 { - let mut sum = 0i128; - for value in values { - let Cell::Int64(value) = value else { - return Err(unsupported("integer average received noninteger")); - }; - sum = sum - .checked_add(i128::from(*value)) - .ok_or_else(|| unsupported("integer average sum overflow"))?; - } - let average = sum as f64 / rows.len() as f64; - return if average.is_finite() { - Ok(Cell::Float64(average)) - } else { - Err(unsupported("empty integer average")) - }; - } - let mut sum = 0.0; - for value in values { - sum += match value { - Cell::Int64(value) => *value as f64, - Cell::Float64(value) => *value, - _ => unreachable!("validated numeric aggregate"), - }; - } - if matches!(intent, AggIntent::Avg { .. }) { - sum /= rows.len() as f64; - } - if !sum.is_finite() { - return Err(unsupported("nonfinite aggregate output")); - } - Ok(Cell::Float64(sum)) -} - -pub(super) fn apply( - reduction: &Reduction, - measures: &[AggIntent], - having: Option<&Predicate>, - output: &SummarySchema, - mut input: ClickHouseRelation, -) -> Result { - let Reduction::Reduce(keys) = reduction else { - return Err(unsupported("per-entity relational reduction")); + output.nullable = true; + } + let output = SummarySchema { + fields: vec![planner_types::post_asap::SummaryField { + name: output.name, + dtype: SummaryFamilyType::Plain(output.dtype), + nullable: output.nullable, + }], + time_index: None, }; - if keys.is_without() { - return Err(unsupported("relational grouping without")); - } - let keys = keys.keys(); - for row in &input.rows { - for key in keys { - let value = row - .get(*key) - .ok_or_else(|| unsupported("group column out of range"))?; - if !matches!(value, Cell::Null) && cell_cmp(value, value).is_none() { - return Err(unsupported("unordered grouping value")); - } - } - } - input - .rows - .sort_by(|left, right| group_cmp(left, right, keys)); - let mut rows = Vec::new(); - let mut start = 0; - while start < input.rows.len() || (start == 0 && input.rows.is_empty() && keys.is_empty()) { - let mut end = (start + 1).min(input.rows.len()); - while end < input.rows.len() - && group_cmp(&input.rows[start], &input.rows[end], keys) == Ordering::Equal - { - end += 1; - } - let mut row = keys - .iter() - .map(|key| input.rows[start][*key].clone()) - .collect::>(); - for intent in measures { - row.push(measure(intent, &input.rows[start..end], &input.fields)?); - } - rows.push(row); - if end == start { - break; - } - start = end; - } - input.rows = rows; - input.fields = fields_from_schema(output); - if input.rows.iter().any(|row| row.len() != input.fields.len()) { - return Err(unsupported("aggregate output width mismatch")); - } - if let Some(predicate) = having { - input = ClickHouseRelationalAdapter.apply_filter(predicate, input)?; - } - Ok(input) + let result = apply( + &Reduction::by(vec![]), + &[intent.clone()], + None, + &output, + ClickHouseRelation { + rows: rows.to_vec(), + fields: fields.to_vec(), + coverage: None, + }, + )?; + Ok(result.rows[0][0].clone()) } #[cfg(test)] @@ -268,7 +153,7 @@ mod tests { } #[test] - fn numeric_reductions_and_nullable_rejection_are_explicit() { + fn numeric_reductions_preserve_nullable_planner_inputs() { let fields = vec![("value".into(), DataType::Float64, false)]; let rows = vec![vec![Cell::Float64(2.0)], vec![Cell::Float64(8.0)]]; for (intent, expected) in [ @@ -286,6 +171,6 @@ mod tests { assert_eq!(measure(&intent, &rows, &fields).unwrap(), expected); } let nullable = vec![("value".into(), DataType::Float64, true)]; - assert!(measure(&AggIntent::Sum { col: Some(0) }, &rows, &nullable).is_err()); + assert!(measure(&AggIntent::Sum { col: Some(0) }, &rows, &nullable).is_ok()); } } 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 new file mode 100644 index 00000000..6ba366b9 --- /dev/null +++ b/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter/native.rs @@ -0,0 +1,161 @@ +//! JSON/Arrow transport stays in the deployment; Planner executes relation values. +use super::{fields_from_schema, Cell, ClickHouseRelation, ClickHouseRelationalError}; +use asap_physical_operators::dag::{ + self, + values::{Batch, Value}, +}; +use planner_types::post_asap::{ + ExecutableDagNode, ExecutableOperatorPayload, ExecutionDataState, PostAsapNodeId, + SummaryFamilyType, SummaryField, SummarySchema, +}; +use std::sync::Arc; +fn error(error: impl std::fmt::Display) -> ClickHouseRelationalError { + ClickHouseRelationalError::Invalid(error.to_string()) +} +pub(crate) fn schema(input: &ClickHouseRelation) -> SummarySchema { + SummarySchema { + fields: input + .fields + .iter() + .map(|(name, dtype, nullable)| SummaryField { + name: name.clone(), + dtype: SummaryFamilyType::Plain(dtype.clone()), + nullable: *nullable, + }) + .collect(), + time_index: None, + } +} +pub(super) fn value(cell: &Cell) -> Value { + match cell { + Cell::Null => Value::Null, + Cell::Bool(v) => Value::Bool(*v), + Cell::Int64(v) => Value::Int64(*v), + Cell::Float64(v) => Value::Float64(*v), + Cell::Utf8(v) => Value::Utf8(v.clone().into()), + Cell::Timestamp(v) => Value::Timestamp(*v), + Cell::List(v) => Value::List(v.iter().map(value).collect::>().into()), + Cell::Struct(v) => Value::Struct(v.iter().map(value).collect::>().into()), + Cell::Map(v) => Value::Map( + v.iter() + .map(|(k, v)| (value(k), value(v))) + .collect::>() + .into(), + ), + } +} +pub(super) fn cell(value: &Value) -> Result { + Ok(match value { + Value::Null => Cell::Null, + Value::Bool(v) => Cell::Bool(*v), + Value::Int64(v) => Cell::Int64(*v), + Value::Float64(v) => Cell::Float64(*v), + Value::Utf8(v) => Cell::Utf8(v.to_string()), + Value::Timestamp(v) => Cell::Timestamp(*v), + Value::List(v) => Cell::List(v.iter().map(cell).collect::, _>>()?.into()), + Value::Struct(v) => Cell::Struct(v.iter().map(cell).collect::, _>>()?.into()), + Value::Map(v) => Cell::Map( + v.iter() + .map(|(k, v)| Ok((cell(k)?, cell(v)?))) + .collect::, ClickHouseRelationalError>>()?, + ), + _ => return Err(error("native value has no ClickHouse result transport")), + }) +} +pub(crate) fn execute( + payload: ExecutableOperatorPayload, + output: &SummarySchema, + inputs: Vec, +) -> Result { + let coverage = inputs + .iter() + .map(|input| input.coverage) + .reduce(|left, right| match (left, right) { + (Some((a, b)), Some((c, d))) if a.max(c) <= b.min(d) => Some((a.max(c), b.min(d))), + _ => None, + }) + .flatten(); + let batches = inputs + .iter() + .map(|input| { + Batch::try_new( + Arc::new(schema(input)), + input + .rows + .iter() + .map(|row| row.iter().map(value).collect()) + .collect(), + ) + }) + .collect::, _>>() + .map_err(error)?; + let node = ExecutableDagNode { + id: PostAsapNodeId(0), + payload, + output_state: ExecutionDataState::QUERY_ROWS, + output_schema: output.clone(), + guarantee: None, + }; + let operator = dag::planner::bind_node( + &node, + &batches + .iter() + .map(|batch| batch.schema().clone()) + .collect::>(), + ) + .map_err(|error| ClickHouseRelationalError::Unsupported(error.to_string()))?; + let context = dag::RunContext::new( + dag::Scope::Query { + evaluation_time_ms: coverage.map_or(0, |(_, end)| end as i64), + revision: 0, + }, + dag::Limits::default(), + ) + .map_err(error)?; + let batches = + dag::batch_execution::evaluate_inputs(batches, operator, context).map_err(error)?; + let rows = batches + .iter() + .flat_map(|batch| batch.rows()) + .map(|row| row.iter().map(cell).collect()) + .collect::, _>>()?; + Ok(ClickHouseRelation { + rows, + fields: fields_from_schema(output), + coverage, + }) +} + +pub(crate) fn batch( + input: &ClickHouseRelation, + expected: &SummarySchema, +) -> Result { + if schema(input).fields != expected.fields { + return Err(dag::Error::Invalid( + "source relation differs from its declared schema".into(), + )); + } + Batch::try_new( + Arc::new(expected.clone()), + input + .rows + .iter() + .map(|row| row.iter().map(value).collect()) + .collect(), + ) +} +pub(crate) fn relation( + batches: &[dag::SharedValue], + output: &SummarySchema, + coverage: Option<(u64, u64)>, +) -> Result { + Ok(ClickHouseRelation { + fields: fields_from_schema(output), + rows: batches + .iter() + .flat_map(|batch| batch.rows()) + .map(|row| row.iter().map(cell).collect()) + .collect::>()?, + coverage, + }) +} 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 2c1e7a81..42cbec9b 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 @@ -5,10 +5,13 @@ use crate::query_engines::{ EngineError, }; use crate::storage_engines::types::KeyByLabelValues; +use asap_physical_operators::dag as physical; use asap_types::query_plan::residual::{ Aggregation, BinaryOperation, Grouping, ResidualQueryOperator, TemporalOperation, }; use asap_types::query_plan::{CandidateCompleteness, QueryNodeId, QueryPlanEntry, QueryPlanNode}; +use futures::{FutureExt, StreamExt}; +use std::cell::RefCell; use std::collections::{BTreeMap, BTreeSet}; type Labels = BTreeMap; @@ -105,17 +108,91 @@ fn execute_values( where F: FnMut(QueryNodeId, u64) -> Result, { - let mut evaluator = Evaluator { + let at_signed = i64::try_from(at).map_err(|_| miss("evaluation timestamp overflow"))?; + let runtime = RefCell::new(ValueRuntime { entry, leaves, callback, stats: ExecutionStats::default(), - memo: BTreeMap::new(), - active: BTreeSet::new(), - warnings: Vec::new(), + warnings: vec![], + }); + let error = RefCell::new(None); + let mut graph = physical::PhysicalDag::default(); + let mut identities = BTreeMap::from([((entry.root, at_signed), 0u64)]); + let mut pending = vec![(entry.root, at_signed)]; + let mut depths = BTreeMap::from([((entry.root, at_signed), 1usize)]); + while let Some((id, time)) = pending.pop() { + let node = entry + .nodes + .get(&id) + .ok_or_else(|| miss("missing installed node"))?; + let dependencies = if leaves.contains_key(&(id, time)) { + vec![] + } else { + expanded_inputs(node, time)? + }; + let mut input_ids = Vec::new(); + for dependency in &dependencies { + if let Some(id) = identities.get(dependency) { + runtime.borrow_mut().stats.memo_hits += 1; + input_ids.push(*id); + } else { + if identities.len() >= 200_000 { + return Err(miss("installed DAG evaluation budget exceeded")); + } + let depth = depths[&(id, time)] + 1; + if depth > 128 { + return Err(miss("installed DAG exceeds execution depth of 128")); + } + depths.insert(*dependency, depth); + let id = identities.len() as u64; + identities.insert(*dependency, id); + pending.push(*dependency); + input_ids.push(id); + } + } + graph + .add( + identities[&(id, time)], + input_ids, + BoundValueOperator { + id, + time, + node, + dependencies, + runtime: &runtime, + error: &error, + }, + ) + .map_err(|error| miss(error.to_string()))?; + } + let context = physical::RunContext::new( + physical::Scope::Query { + evaluation_time_ms: at_signed, + revision: 0, + }, + physical::Limits::default(), + ) + .map_err(|error| miss(error.to_string()))?; + let mut output = graph + .execute(&[0], context) + .map_err(|error| miss(error.to_string()))? + .remove(0); + // These adapters have synchronous callbacks and prepared I/O leaves. A + // single poll avoids nesting a blocking futures executor inside a readout. + let evaluated = match output.next().now_or_never().flatten() { + Some(Ok(value)) => value.value().clone(), + Some(Err(failure)) => { + return Err(error + .borrow_mut() + .take() + .unwrap_or_else(|| miss(failure.to_string()))) + } + None => return Err(miss("synchronous query adapter did not produce a result")), }; - let at_signed = i64::try_from(at).map_err(|_| miss("evaluation timestamp overflow"))?; - let evaluated = evaluator.eval(entry.root, at_signed)?; + drop(output); + drop(graph); + let mut evaluator = runtime.into_inner(); if matches!(evaluated, Value::Scalar(_)) { // QueryResult currently models vectors/matrices only. Preserve a scalar // root's HTTP type by routing it to native, while scalar intermediates @@ -142,21 +219,23 @@ where Ok((output, evaluator.stats)) } -struct Evaluator<'a, F> { +struct ValueRuntime<'a, F> { entry: &'a QueryPlanEntry, leaves: &'a PreparedLeaves, stats: ExecutionStats, callback: F, - memo: BTreeMap<(QueryNodeId, i64), Value>, - active: BTreeSet<(QueryNodeId, i64)>, warnings: Vec, } -impl Result> Evaluator<'_, F> { - fn eval(&mut self, id: QueryNodeId, at: i64) -> Result { - if let Some(value) = self.memo.get(&(id, at)) { - self.stats.memo_hits += 1; - return Ok(value.clone()); - } +impl Result> ValueRuntime<'_, F> { + fn execute_node( + &mut self, + id: QueryNodeId, + at: i64, + node: &QueryPlanNode, + inputs: &[Value], + dependencies: &[(QueryNodeId, i64)], + context: &physical::RunContext, + ) -> Result { if let Some(leaf) = self.leaves.get(&(id, at)) { if leaf.remote { self.stats.remote_branch_evaluations += 1; @@ -166,23 +245,10 @@ impl Result> Evaluator<' self.stats.summary_readout_evaluations += 1; } let value = leaf.value.clone(); - self.memo.insert((id, at), value.clone()); return Ok(value); } - if self.active.len() >= 256 || !self.active.insert((id, at)) { - return Err(miss("cyclic or excessively deep installed DAG")); - } - if self.memo.len() >= 200_000 { - return Err(miss("installed DAG evaluation budget exceeded")); - } - let node = self - .entry - .nodes - .get(&id) - .ok_or_else(|| miss("missing installed node"))? - .clone(); - let value = match node { - QueryPlanNode::Scalar { value } => Value::Scalar(value), + let value = match node.clone() { + QueryPlanNode::Scalar { value } => Value::Scalar(native_scalar(value, context)?), QueryPlanNode::Logical { operator: ResidualQueryOperator::CurrentSeries { .. }, .. @@ -193,7 +259,7 @@ impl Result> Evaluator<' u64::try_from(at).map_err(|_| miss("negative current-series timestamp"))?, )?)? } - QueryPlanNode::Logical { operator, inputs } => { + QueryPlanNode::Logical { operator, .. } => { if matches!( operator, ResidualQueryOperator::Scan { .. } @@ -204,10 +270,10 @@ impl Result> Evaluator<' "installed Prometheus leaf was not prepared; backend raw execution is forbidden", )); } - self.logical(operator, &inputs, at)? + self.logical(operator, inputs, dependencies, at, context)? } QueryPlanNode::RelationalJoin { - inputs, + inputs: _, join_kind: planner_types::pre_asap::JoinKind::Semi, pred, pruning, @@ -215,8 +281,11 @@ impl Result> Evaluator<' right_schema, .. } => { - let values = vector(self.eval(inputs[0], at)?)?; - let candidates = vector(self.eval(inputs[1], at)?)?; + let [values, candidates] = inputs else { + return Err(miss("semi-join requires two inputs")); + }; + let values = vector(values.clone())?; + let candidates = vector(candidates.clone())?; let predicate = serde_json::from_value(pred) .map_err(|_| miss("invalid semi-join predicate"))?; let keys = asap_physical_operators::dag::planner::equijoin_keys( @@ -233,7 +302,8 @@ impl Result> Evaluator<' ) }) .collect::>(); - let (selected, warning) = semi_join(candidates, values, &keys, pruning.as_ref())?; + let (selected, warning) = + semi_join(candidates, values, &keys, pruning.as_ref(), context)?; if let Some(warning) = warning { self.warnings.push(warning); } @@ -247,20 +317,20 @@ impl Result> Evaluator<' )?)? } }; - self.active.remove(&(id, at)); - self.memo.insert((id, at), value.clone()); Ok(value) } fn logical( &mut self, operator: ResidualQueryOperator, - inputs: &[QueryNodeId], + inputs: &[Value], + dependencies: &[(QueryNodeId, i64)], at: i64, + context: &physical::RunContext, ) -> Result { let input = |index: usize| { inputs .get(index) - .copied() + .cloned() .ok_or_else(|| miss("missing logical input")) }; match operator { @@ -274,165 +344,115 @@ impl Result> Evaluator<' ResidualQueryOperator::Scan { .. } => { Err(miss("local raw Scan is forbidden in deployed plans")) } - ResidualQueryOperator::UnaryNegate => match self.eval(input(0)?, at)? { - Value::Scalar(value) => Ok(Value::Scalar(-value)), - Value::Vector(values) => Ok(Value::Vector( - values - .into_iter() - .map(|(labels, value)| (labels, -value)) - .collect(), - )), - _ => Err(miss("cannot negate range vector")), - }, - ResidualQueryOperator::VectorToScalar => { - let values = vector(self.eval(input(0)?, at)?)?; - Ok(Value::Scalar(if values.len() == 1 { - values[0].1 - } else { - f64::NAN - })) - } + ResidualQueryOperator::UnaryNegate => negate(input(0)?, context), + ResidualQueryOperator::VectorToScalar => vector_to_scalar(vector(input(0)?)?, context), ResidualQueryOperator::Aggregate { operation, grouping, } => { - let values = vector(self.eval(input(0)?, at)?)?; - Ok(Value::Vector(aggregate(operation, &grouping, values))) + let values = vector(input(0)?)?; + Ok(Value::Vector(aggregate( + operation, &grouping, values, context, + )?)) } ResidualQueryOperator::Limit { n, offset, grouping, } => { - let values = vector(self.eval(input(0)?, at)?)?; + let values = vector(input(0)?)?; Ok(Value::Vector(native_values::limit( - values, &grouping, n, offset, + values, &grouping, n, offset, context, )?)) } ResidualQueryOperator::Binary { operation, return_bool, } => { - let left = self.eval(input(0)?, at)?; - let right = self.eval(input(1)?, at)?; - binary(operation, return_bool, left, right) + let left = input(0)?; + let right = input(1)?; + binary_in_context(operation, return_bool, left, right, context) } ResidualQueryOperator::Temporal { operation } => { - let Value::Matrix(values, start, end) = self.eval(input(0)?, at)? else { + 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( - values + result .into_iter() - .filter_map(|(labels, points)| { - if points.is_empty() { - return None; - } - let value = match operation { - TemporalOperation::Rate => rate(&points, start, end), - TemporalOperation::Increase => rate(&points, start, end) - .map(|r| r * (end - start) as f64 / 1000.), - TemporalOperation::Sum => Some(points.iter().map(|p| p.1).sum()), - TemporalOperation::Avg => Some( - points.iter().map(|p| p.1).sum::() / points.len() as f64, - ), - TemporalOperation::Count => Some(points.len() as f64), - TemporalOperation::Max => { - Some(points.iter().fold(f64::NAN, |a, p| { - if a.is_nan() || p.1 > a { - p.1 - } else { - a - } - })) - } - TemporalOperation::Min => { - Some(points.iter().fold(f64::NAN, |a, p| { - if a.is_nan() || p.1 < a { - p.1 - } else { - a - } - })) - } - }; - value.map(|v| { - let preserve_name = self.entry.language - == control_plane::query_plan::QueryLanguage::MetricsQl - && matches!( - operation, - TemporalOperation::Min - | TemporalOperation::Max - | TemporalOperation::Avg - ); - ( - if preserve_name { - labels - } else { - no_name(labels) - }, - v, - ) - }) + .map(|(labels, value)| { + ( + if preserve_name { + labels + } else { + no_name(labels) + }, + value, + ) }) .collect(), )) } + ResidualQueryOperator::Sort { descending, grouping, } => { - let values = vector(self.eval(input(0)?, at)?)?; + let values = vector(input(0)?)?; Ok(Value::Vector(native_values::sort( - values, &grouping, descending, + values, &grouping, descending, context, )?)) } ResidualQueryOperator::HistogramQuantile => { - let Value::Scalar(quantile) = self.eval(input(0)?, at)? else { + 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(self.eval(input(1)?, at)?)? { + 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)); } } - Ok(Value::Vector( - groups - .into_iter() - .map(|(labels, buckets)| (labels, bucket_quantile(quantile, buckets))) - .collect(), - )) + 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, + )?)) } ResidualQueryOperator::Subquery { range_ms, step_ms, offset_ms, } => { - let end = at - .checked_sub(offset_ms) - .ok_or_else(|| miss("offset overflow"))?; - let range = i64::try_from(range_ms).map_err(|_| miss("range overflow"))?; - let step = i64::try_from(step_ms).map_err(|_| miss("step overflow"))?; - if step <= 0 || range / step > 100_000 { - return Err(miss("invalid or excessive subquery steps")); - } - let start = end - .checked_sub(range) - .ok_or_else(|| miss("range overflow"))?; - let mut t = start - .div_euclid(step) - .checked_add(1) - .and_then(|n| n.checked_mul(step)) - .ok_or_else(|| miss("subquery grid overflow"))?; + let (start, end, _) = subquery_grid(at, range_ms, step_ms, offset_ms)?; let mut values: BTreeMap> = BTreeMap::new(); - while t <= end { - for (labels, value) in vector(self.eval(input(0)?, t)?)? { - values.entry(labels).or_default().push((t, value)); + if inputs.len() != dependencies.len() { + return Err(miss("subquery grid input mismatch")); + } + for (value, (_, time)) in inputs.iter().zip(dependencies) { + for (labels, value) in vector(value.clone())? { + values.entry(labels).or_default().push((*time, value)); } - t = t - .checked_add(step) - .ok_or_else(|| miss("subquery time overflow"))?; } Ok(Value::Matrix(values.into_iter().collect(), start, end)) } @@ -440,11 +460,147 @@ impl Result> Evaluator<' } } +fn subquery_grid( + at: i64, + range_ms: u64, + step_ms: u64, + offset_ms: i64, +) -> Result<(i64, i64, Vec), EngineError> { + let end = at + .checked_sub(offset_ms) + .ok_or_else(|| miss("offset overflow"))?; + let range = i64::try_from(range_ms).map_err(|_| miss("range overflow"))?; + let step = i64::try_from(step_ms).map_err(|_| miss("step overflow"))?; + if step <= 0 || range / step > 100_000 { + return Err(miss("invalid or excessive subquery steps")); + } + let start = end + .checked_sub(range) + .ok_or_else(|| miss("range overflow"))?; + let mut time = start + .div_euclid(step) + .checked_add(1) + .and_then(|n| n.checked_mul(step)) + .ok_or_else(|| miss("subquery grid overflow"))?; + let mut times = Vec::new(); + while time <= end { + times.push(time); + time = time + .checked_add(step) + .ok_or_else(|| miss("subquery time overflow"))?; + } + Ok((start, end, times)) +} +fn expanded_inputs(node: &QueryPlanNode, at: i64) -> Result, EngineError> { + match node { + QueryPlanNode::Logical { + operator: + ResidualQueryOperator::Scan { .. } + | ResidualQueryOperator::ExactSubquery { .. } + | ResidualQueryOperator::CandidateExactSubquery { .. }, + .. + } => Err(miss( + "installed leaf was not prepared; local raw execution is forbidden", + )), + QueryPlanNode::Logical { + operator: + ResidualQueryOperator::Subquery { + range_ms, + step_ms, + offset_ms, + }, + inputs, + } => { + let [input] = inputs.as_slice() else { + return Err(miss("subquery requires one input")); + }; + let (_, _, times) = subquery_grid(at, *range_ms, *step_ms, *offset_ms)?; + Ok(times.into_iter().map(|time| (*input, time)).collect()) + } + QueryPlanNode::Logical { + operator: ResidualQueryOperator::CurrentSeries { .. }, + .. + } => Ok(vec![]), + QueryPlanNode::Logical { inputs, .. } => Ok(inputs.iter().map(|&id| (id, at)).collect()), + QueryPlanNode::RelationalJoin { inputs, .. } => { + Ok(inputs.iter().map(|&id| (id, at)).collect()) + } + _ => Ok(vec![]), + } +} +struct BoundValueOperator<'a, 'entry, F> { + id: QueryNodeId, + time: i64, + node: &'entry QueryPlanNode, + dependencies: Vec<(QueryNodeId, i64)>, + runtime: &'a RefCell>, + error: &'a RefCell>, +} +impl Result> + physical::PhysicalOperator for BoundValueOperator<'_, '_, F> +{ + fn name(&self) -> &str { + "InstalledValueOperator" + } + fn input_schemas(&self) -> Vec<()> { + vec![(); self.dependencies.len()] + } + fn output_schema(&self) {} + fn output_bytes(&self, value: &Value) -> usize { + fn labels(value: &Labels) -> usize { + value.iter().map(|(k, v)| k.len() + v.len()).sum() + } + match value { + Value::Scalar(_) => 8, + Value::Vector(values) => values.iter().map(|(key, _)| labels(key) + 8).sum(), + Value::Matrix(values, ..) => values + .iter() + .map(|(key, points)| labels(key) + points.len() * 16) + .sum(), + } + } + fn start<'a>( + &'a self, + inputs: Vec>, + context: physical::RunContext, + ) -> Result, physical::Error> { + Ok(futures::stream::once(async move { + let values = + futures::future::try_join_all(inputs.into_iter().map(|mut input| async move { + input.next().await.ok_or_else(|| { + physical::Error::Operator("query input produced no value".into()) + })? + })) + .await?; + let values = values.iter().map(|v| v.value().clone()).collect::>(); + self.runtime + .borrow_mut() + .execute_node( + self.id, + self.time, + self.node, + &values, + &self.dependencies, + &context, + ) + .map_err(|error| { + *self.error.borrow_mut() = Some(error); + physical::Error::Operator(format!( + "query node {} at {} failed", + self.id.0, self.time + )) + }) + }) + .boxed_local()) + } +} + fn semi_join( candidates: Vector, values: Vector, keys: &[(String, String)], completeness: Option<&CandidateCompleteness>, + context: &physical::RunContext, ) -> Result<(Vector, Option), EngineError> { let left_key = |labels: &Labels| { keys.iter() @@ -465,7 +621,7 @@ fn semi_join( .map(|(labels, _)| right_key(labels)) .filter(|key| !available.contains(key)) .collect::>(); - let selected = native_values::semi_join(values, &candidates, &left_key, &right_key)?; + let selected = native_values::semi_join(values, &candidates, &left_key, &right_key, context)?; if !missing.is_empty() && matches!(completeness, Some(CandidateCompleteness::Certified { .. })) { return Err(miss("certified pruning key has no authoritative value")); @@ -483,43 +639,201 @@ fn semi_join( Ok((selected, warning)) } -fn aggregate(operation: Aggregation, grouping: &Grouping, values: Vector) -> Vector { - let mut groups: BTreeMap> = BTreeMap::new(); - for (labels, value) in values { - let key = labels - .into_iter() - .filter(|(key, _)| { - if grouping.without { - key != "__name__" && !grouping.labels.contains(key) - } else { - grouping.labels.contains(key) - } - }) - .collect(); - groups.entry(key).or_default().push(value); +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(|e| miss(e.to_string()))?; + let batches = evaluate_source(source, context.clone()).map_err(|e| miss(e.to_string()))?; + 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")), } - groups +} + +fn native_labels(labels: &Labels) -> physical::values::Value { + physical::values::Value::Map( + labels + .iter() + .map(|(k, v)| { + ( + physical::values::Value::Utf8(k.as_str().into()), + physical::values::Value::Utf8(v.as_str().into()), + ) + }) + .collect::>() + .into(), + ) +} +fn native_vector_batch( + values: Vector, + grouping: &Grouping, +) -> Result { + use physical::values::{Batch, Value as Cell}; + use planner_types::{ + post_asap::{SummaryFamilyType, SummaryField, SummarySchema}, + pre_asap::DataType, + }; + let label_type = DataType::Map { + key: Box::new(DataType::Utf8), + value: Box::new(DataType::Utf8), + value_nullable: false, + }; + let schema = std::sync::Arc::new(SummarySchema { + fields: vec![ + ("labels", label_type.clone()), + ("group", label_type), + ("value", DataType::Float64), + ] .into_iter() - .map(|(labels, values)| { - let value = match operation { - Aggregation::Sum => values.iter().sum(), - Aggregation::Avg => values.iter().sum::() / values.len() as f64, - Aggregation::Count => values.len() as f64, - Aggregation::Max => { - values - .into_iter() - .fold(f64::NAN, |a, b| if a.is_nan() || b > a { b } else { a }) - } - Aggregation::Min => { - values - .into_iter() - .fold(f64::NAN, |a, b| if a.is_nan() || b < a { b } else { a }) + .map(|(name, dtype)| SummaryField { + name: name.into(), + dtype: SummaryFamilyType::Plain(dtype), + nullable: false, + }) + .collect(), + time_index: None, + }); + let rows = values + .into_iter() + .map(|(labels, value)| { + vec![ + native_labels(&labels), + native_labels(&grouping_key(&labels, grouping)), + Cell::Float64(value), + ] + }) + .collect(); + Batch::try_new(schema, rows).map_err(|e| miss(e.to_string())) +} +fn native_batch_rows( + batch: physical::values::Batch, + ops: Vec, + context: &physical::RunContext, +) -> Result>, EngineError> { + physical::batch_execution::evaluate_batch(batch, ops, context.clone()) + .map(|batches| { + batches + .into_iter() + .flat_map(|batch| batch.rows().to_vec()) + .collect() + }) + .map_err(|e| miss(e.to_string())) +} +fn native_vector_output( + rows: Vec>, + label_column: usize, + value_column: usize, +) -> Result { + use physical::values::Value as Cell; + rows.into_iter() + .map(|row| { + let Some(Cell::Map(entries)) = row.get(label_column) else { + return Err(miss("native operator returned invalid labels")); + }; + let labels = entries + .iter() + .map(|(k, v)| match (k, v) { + (Cell::Utf8(k), Cell::Utf8(v)) => Ok((k.to_string(), v.to_string())), + _ => Err(miss("native label map is not Utf8")), + }) + .collect::>()?; + let value = match row.get(value_column) { + Some(Cell::Float64(value)) => *value, + Some(Cell::Int64(value)) if value.unsigned_abs() <= (1u64 << 53) => *value as f64, + _ => { + return Err(miss( + "native result is not representable in the query Float64 protocol", + )) } }; - (labels, value) + Ok((labels, value)) }) .collect() } +fn aggregate( + operation: Aggregation, + grouping: &Grouping, + values: Vector, + context: &physical::RunContext, +) -> Result { + use physical::operators::{Operator, Reduction}; + let batch = native_vector_batch(values, grouping)?; + let reduction = match operation { + Aggregation::Sum => Reduction::Sum(2), + Aggregation::Avg => Reduction::Avg(2), + Aggregation::Count => Reduction::Count, + Aggregation::Max => Reduction::Max(2), + Aggregation::Min => Reduction::Min(2), + }; + let operator = Operator::aggregate( + batch.schema().clone(), + vec![1], + vec![("value".into(), reduction)], + ) + .map_err(|e| miss(e.to_string()))?; + native_vector_output(native_batch_rows(batch, vec![operator], context)?, 0, 1) +} + +fn negate(value: Value, context: &physical::RunContext) -> Result { + use physical::operators::{Expression, Operator}; + let scalar = matches!(value, Value::Scalar(_)); + let values = match value { + Value::Scalar(v) => vec![(Labels::new(), v)], + Value::Vector(v) => v, + _ => return Err(miss("cannot negate range vector")), + }; + let batch = native_vector_batch( + values, + &Grouping { + labels: vec![], + without: false, + }, + )?; + let operator = Operator::project( + batch.schema().clone(), + vec![ + ("labels".into(), Expression::Column(0)), + ( + "value".into(), + Expression::Negate(Box::new(Expression::Column(2))), + ), + ], + ) + .map_err(|e| miss(e.to_string()))?; + let result = native_vector_output(native_batch_rows(batch, vec![operator], context)?, 0, 1)?; + Ok(if scalar { + Value::Scalar(result[0].1) + } else { + Value::Vector(result) + }) +} +fn vector_to_scalar(values: Vector, context: &physical::RunContext) -> 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(|e| miss(e.to_string()))?; + 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")), + } +} fn grouping_key(labels: &Labels, grouping: &Grouping) -> Labels { labels @@ -539,137 +853,102 @@ fn grouping_key(labels: &Labels, grouping: &Grouping) -> Labels { /// labels. NaN ranks below every numeric value, matching Prometheus' TOPK heap. /// Stable sorting also leaves equal-valued series in the child's order. #[cfg(test)] -fn topk_selection(k: u64, grouping: &Grouping, values: Vector) -> Vector { - native_values::limit( - native_values::sort(values, grouping, true).unwrap(), - grouping, - k, - 0, +fn topk_selection( + k: u64, + grouping: &Grouping, + values: Vector, + context: &physical::RunContext, +) -> Result { + use physical::operators::{Operator, SortKey}; + let batch = native_vector_batch(values, grouping)?; + let sort = Operator::sort( + batch.schema().clone(), + vec![SortKey { + column: 2, + descending: true, + nulls_first: false, + }], + vec![1], ) - .unwrap() + .map_err(|e| miss(e.to_string()))?; + let limit = Operator::limit(sort.schema(), k, 0, vec![1]).map_err(|e| miss(e.to_string()))?; + let mut output = + native_vector_output(native_batch_rows(batch, vec![sort, limit], context)?, 0, 2)?; + // The HTTP adapter preserves canonical label-group presentation; native Sort + // already determined score order within each group. + output.sort_by_key(|(labels, _)| grouping_key(labels, grouping)); + Ok(output) } +#[cfg(test)] fn binary( operation: BinaryOperation, boolean: bool, left: Value, right: Value, ) -> Result { - if matches!( - operation, - BinaryOperation::CheckedDiv | BinaryOperation::FiniteDiv - ) { - let valid = |value: &Value, denominator: bool| match value { - Value::Scalar(v) => v.is_finite() && (!denominator || *v != 0.0), - Value::Vector(rows) => rows - .iter() - .all(|(_, v)| v.is_finite() && (!denominator || *v != 0.0)), - Value::Matrix(..) => false, - }; - if boolean || !valid(&left, false) || !valid(&right, true) { - return Err(miss( - "checked division requires finite operands and a nonzero divisor", - )); + binary_in_context(operation, boolean, left, right, &test_native_context()) +} +fn binary_in_context( + operation: BinaryOperation, + boolean: bool, + left: Value, + right: Value, + context: &physical::RunContext, +) -> Result { + use physical::{ + operators::{Expression, Operator}, + values::{Batch, Value as Cell}, + }; + use planner_types::{ + post_asap::{BinaryOperator, SummaryFamilyType, SummaryField, SummarySchema}, + pre_asap::{ArithmeticOpKind as A, BinaryOpKind, CompareOpKind as C, DataType}, + }; + let kind = match operation { + BinaryOperation::Add => BinaryOpKind::Arithmetic(A::Add), + BinaryOperation::Sub => BinaryOpKind::Arithmetic(A::Sub), + BinaryOperation::Mul => BinaryOpKind::Arithmetic(A::Mul), + BinaryOperation::Div | BinaryOperation::CheckedDiv | BinaryOperation::FiniteDiv => { + BinaryOpKind::Arithmetic(A::Div) } - let result = binary(BinaryOperation::Div, false, left, right)?; - let valid_result = |v: &f64| { - if operation == BinaryOperation::FiniteDiv { - v.is_finite() - } else { - v.is_normal() - } - }; - let normal = match &result { - Value::Scalar(v) => valid_result(v), - Value::Vector(rows) => rows.iter().all(|(_, v)| valid_result(v)), - Value::Matrix(..) => false, - }; - return if normal { - Ok(result) - } else { - Err(miss( - "checked division result is outside the declared floating-point domain", - )) - }; + BinaryOperation::Mod => BinaryOpKind::Arithmetic(A::Mod), + BinaryOperation::Pow => BinaryOpKind::Arithmetic(A::Pow), + BinaryOperation::Equal => BinaryOpKind::Compare(C::Eq), + BinaryOperation::NotEqual => BinaryOpKind::Compare(C::Ne), + BinaryOperation::Less => BinaryOpKind::Compare(C::Lt), + BinaryOperation::LessEqual => BinaryOpKind::Compare(C::Le), + BinaryOperation::Greater => BinaryOpKind::Compare(C::Gt), + BinaryOperation::GreaterEqual => BinaryOpKind::Compare(C::Ge), + }; + let arithmetic = matches!(kind, BinaryOpKind::Arithmetic(_)); + let scalar_output = matches!((&left, &right), (Value::Scalar(_), Value::Scalar(_))); + if scalar_output && !arithmetic && !boolean { + return Err(miss("scalar comparison requires bool")); } - let arithmetic = matches!( - operation, - BinaryOperation::Add - | BinaryOperation::Sub - | BinaryOperation::Mul - | BinaryOperation::Div - | BinaryOperation::Mod - | BinaryOperation::Pow - ); - let combine = |a: f64, b: f64| -> Option { - Some(match operation { - BinaryOperation::Add => a + b, - BinaryOperation::Sub => a - b, - BinaryOperation::Mul => a * b, - BinaryOperation::Div => a / b, - BinaryOperation::Mod => a % b, - BinaryOperation::Pow => a.powf(b), - _ => { - let pass = match operation { - BinaryOperation::Equal => a == b, - BinaryOperation::NotEqual => a != b, - BinaryOperation::Less => a < b, - BinaryOperation::LessEqual => a <= b, - BinaryOperation::Greater => a > b, - BinaryOperation::GreaterEqual => a >= b, - _ => unreachable!(), - }; - if boolean { - if pass { - 1. - } else { - 0. - } - } else if pass { - a - } else { - return None; - } + if boolean + && matches!( + operation, + BinaryOperation::CheckedDiv | BinaryOperation::FiniteDiv + ) + { + return Err(miss("checked division cannot return bool")); + } + // Matching and metric-name presentation are protocol bindings; all numeric + // computation and checked arithmetic execute in the shared operator. + let mut pairs = Vec::new(); + let scalar_left = matches!(left, Value::Scalar(_)); + match (left, right) { + (Value::Scalar(a), Value::Scalar(b)) => pairs.push((Labels::new(), a, b)), + (Value::Vector(values), Value::Scalar(b)) => { + for (labels, a) in vector(Value::Vector(values))? { + pairs.push((labels, a, b)); } - }) - }; - let values = match (left, right) { - (Value::Scalar(a), Value::Scalar(b)) => { - if !arithmetic && !boolean { - return Err(miss("scalar comparison requires bool")); + } + (Value::Scalar(a), Value::Vector(values)) => { + for (labels, b) in vector(Value::Vector(values))? { + pairs.push((labels, a, b)); } - return Ok(Value::Scalar(combine(a, b).unwrap_or(0.))); } - (Value::Vector(values), Value::Scalar(scalar)) => vector(Value::Vector(values))? - .into_iter() - .filter_map(|(labels, value)| { - combine(value, scalar).map(|v| { - ( - if arithmetic || boolean { - no_name(labels) - } else { - labels - }, - v, - ) - }) - }) - .collect(), - (Value::Scalar(scalar), Value::Vector(values)) => vector(Value::Vector(values))? - .into_iter() - .filter_map(|(labels, value)| { - combine(scalar, value).map(|v| { - ( - if arithmetic || boolean { - no_name(labels) - } else { - labels - }, - if arithmetic || boolean { v } else { value }, - ) - }) - }) - .collect(), (Value::Vector(left), Value::Vector(right)) => { let mut rhs = BTreeMap::new(); for (labels, value) in right { @@ -678,104 +957,185 @@ fn binary( } } let mut seen = BTreeSet::new(); - let mut out = Vec::new(); for (labels, value) in left { let key = no_name(labels.clone()); if !seen.insert(key.clone()) { return Err(miss("duplicate vector matching labels")); } if let Some(right) = rhs.get(&key) { - if let Some(v) = combine(value, *right) { - out.push((if arithmetic || boolean { key } else { labels }, v)); - } + pairs.push((labels, value, *right)); } } - out } _ => return Err(miss("binary matrix unsupported")), - }; - Ok(Value::Vector(vector(Value::Vector(values))?)) -} - -fn rate(points: &[(i64, f64)], start: i64, end: i64) -> Option { - if points.len() < 2 { - return None; - } - let (first_t, first) = points[0]; - let (last_t, last) = *points.last()?; - let span = (last_t - first_t) as f64 / 1000.; - if span <= 0. { - return None; } - let mut delta = last - first; - for pair in points.windows(2) { - if pair[1].1 < pair[0].1 { - delta += pair[0].1; - } - } - let average = span / (points.len() - 1) as f64; - let mut to_start = (first_t - start) as f64 / 1000.; - let mut to_end = (end - last_t) as f64 / 1000.; - if to_start >= average * 1.1 { - to_start = average / 2.; - } - // Apply the zero bound after the sparse-window half-interval cap. - if delta > 0. && first >= 0. { - to_start = to_start.min(span * first / delta); + let schema = std::sync::Arc::new(SummarySchema { + fields: ["left", "right"] + .into_iter() + .map(|name| SummaryField { + name: name.into(), + dtype: SummaryFamilyType::Plain(DataType::Float64), + nullable: false, + }) + .collect(), + time_index: None, + }); + let batch = Batch::try_new( + schema.clone(), + pairs + .iter() + .map(|(_, a, b)| vec![Cell::Float64(*a), Cell::Float64(*b)]) + .collect(), + ) + .map_err(|e| miss(e.to_string()))?; + let operator = Operator::project( + schema, + vec![( + "value".into(), + Expression::Binary { + operator: BinaryOperator { + kind, + vector_match: None, + checked_relative_division: operation == BinaryOperation::CheckedDiv, + checked_finite_division: operation == BinaryOperation::FiniteDiv, + }, + left: Box::new(Expression::Column(0)), + right: Box::new(Expression::Column(1)), + }, + )], + ) + .map_err(|e| miss(e.to_string()))?; + let rows = native_batch_rows(batch, vec![operator], context)?; + let mut output = Vec::new(); + for ((labels, a, b), row) in pairs.into_iter().zip(rows) { + let value = match row.first() { + Some(Cell::Float64(value)) => *value, + Some(Cell::Bool(value)) if boolean => { + if *value { + 1. + } else { + 0. + } + } + Some(Cell::Bool(true)) => { + if scalar_left { + b + } else { + a + } + } + Some(Cell::Bool(false)) => continue, + _ => return Err(miss("native binary result schema mismatch")), + }; + output.push(( + if arithmetic || boolean { + no_name(labels) + } else { + labels + }, + value, + )); } - if to_end >= average * 1.1 { - to_end = average / 2.; + if scalar_output { + return Ok(Value::Scalar( + output + .first() + .ok_or_else(|| miss("missing scalar result"))? + .1, + )); } - Some(delta * (span + to_start + to_end) / span / ((end - start) as f64 / 1000.)) + Ok(Value::Vector(vector(Value::Vector(output))?)) } -fn bucket_quantile(q: f64, mut b: Vec<(f64, f64)>) -> f64 { - if q.is_nan() { - return f64::NAN; - } - if q < 0. { - return f64::NEG_INFINITY; - } - if q > 1. { - return f64::INFINITY; - } - b.retain(|p| !p.0.is_nan()); - b.sort_by(|a, b| a.0.total_cmp(&b.0)); - let mut buckets: Vec<(f64, f64)> = Vec::new(); - for p in b { - if let Some(last) = buckets.last_mut() { - if last.0 == p.0 { - last.1 += p.1; - continue; - } - } - buckets.push(p); - } - if buckets.len() < 2 || buckets.last().unwrap().0 != f64::INFINITY { - return f64::NAN; - } - let mut prev = buckets[0].1; - for p in buckets.iter_mut().skip(1) { - if p.1 < prev || (p.1 - prev).abs() <= 1e-12 * (p.1.abs() + prev.abs()) { - p.1 = prev; - } - prev = p.1; - } - let count = buckets.last().unwrap().1; - if count == 0. { - return f64::NAN; - } - let rank = q * count; - let idx = buckets[..buckets.len() - 1].partition_point(|p| p.1 < rank); - if idx == buckets.len() - 1 { - return buckets[idx - 1].0; - } - if idx == 0 && buckets[0].0 <= 0. { - return buckets[0].0; - } - let (start, base) = if idx == 0 { (0., 0.) } else { buckets[idx - 1] }; - let (end, upper) = buckets[idx]; - start + (end - start) * (rank - base) / (upper - base) +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, + }, + }; + 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(|e| miss(e.to_string()))?; + let operator = physical::operators::Operator::window(schema, intent, 1, 2, vec![0], window) + .map_err(|e| miss(e.to_string()))?; + native_vector_output(native_batch_rows(batch, vec![operator], context)?, 0, 1) +} + +#[cfg(test)] +fn test_native_context() -> physical::RunContext { + physical::RunContext::new( + physical::Scope::Query { + evaluation_time_ms: 0, + revision: 0, + }, + physical::Limits::default(), + ) + .unwrap() } #[cfg(test)] @@ -886,7 +1246,9 @@ mod topk_tests { without: false, }, values, - ); + &test_native_context(), + ) + .unwrap(); assert_eq!(selected.len(), 2); assert_eq!(selected[0].0["pod"], "b"); assert_eq!(selected[0].1, 9.0); @@ -910,7 +1272,9 @@ mod topk_tests { (labels(&[("series", "low")]), -1.0), (labels(&[("series", "high")]), 3.0), ], - ); + &test_native_context(), + ) + .unwrap(); let selected = topk_selection( 2, &Grouping { @@ -918,7 +1282,9 @@ mod topk_tests { without: false, }, selected, - ); + &test_native_context(), + ) + .unwrap(); assert_eq!( selected .iter() @@ -1209,6 +1575,7 @@ mod topk_tests { Some(&CandidateCompleteness::Certified { guarantee: topk_membership_guarantee(), }), + &test_native_context(), ) .unwrap(); let selected = topk_selection( @@ -1218,7 +1585,9 @@ mod topk_tests { without: false, }, selected, - ); + &test_native_context(), + ) + .unwrap(); assert_eq!( selected .iter() @@ -1374,6 +1743,7 @@ mod topk_tests { exact.clone(), &[("pod".into(), "pod".into())], Some(&CandidateCompleteness::BestEffort { guarantee: None }), + &test_native_context(), ) .unwrap(); assert!(warning.unwrap().contains("approximate")); @@ -1388,7 +1758,132 @@ mod topk_tests { exact, &[("pod".into(), "pod".into())], Some(&certified), + &test_native_context(), ) .is_err()); } } + +#[cfg(test)] +mod shared_runtime_tests { + use super::*; + use asap_types::query_plan::{FallbackPolicy, InstantExecution, QueryLanguage}; + + fn entry() -> QueryPlanEntry { + QueryPlanEntry { + language: QueryLanguage::PromQl, + query_id: "shared-grid".into(), + 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. + nodes: BTreeMap::from([ + ( + QueryNodeId(0), + QueryPlanNode::ExactReadout { + input: QueryNodeId(99), + readout: asap_types::query_plan::ExactReadout::Sum, + }, + ), + ( + QueryNodeId(1), + QueryPlanNode::Logical { + operator: ResidualQueryOperator::Subquery { + range_ms: 2000, + step_ms: 1000, + offset_ms: 0, + }, + inputs: vec![QueryNodeId(0)], + }, + ), + ( + QueryNodeId(2), + QueryPlanNode::Logical { + operator: ResidualQueryOperator::Temporal { + operation: TemporalOperation::Sum, + }, + inputs: vec![QueryNodeId(1)], + }, + ), + ( + QueryNodeId(3), + QueryPlanNode::Logical { + operator: ResidualQueryOperator::Binary { + operation: BinaryOperation::Add, + return_bool: false, + }, + inputs: vec![QueryNodeId(2), QueryNodeId(2)], + }, + ), + ]), + instant: InstantExecution { + lookback_ms: 2000, + full_history: false, + cumulative_readout: false, + }, + fallback: FallbackPolicy::ExactBackend, + } + } + + // 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 calls = Vec::new(); + for (at, expected) in [(3000, 10.), (4000, 14.)] { + let (result, stats) = execute_installed(&entry, &BTreeMap::new(), at, |id, time| { + assert_eq!(id, QueryNodeId(0)); + calls.push(time); + Ok(QueryResult::vector( + vec![InstantVectorElement::new( + KeyByLabelValues::new_with_labels(vec!["a".into()]), + time as f64 / 1000., + ) + .with_label_keys_override(vec!["pod".into()])], + time, + )) + }) + .unwrap(); + let QueryResult::Vector(result) = result else { + panic!("vector required"); + }; + 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]); + } + + // Query adapters use native computation and its parent execution budget. + #[test] + fn native_scalar_and_aggregation_share_parent_resource_control() { + let context = test_native_context(); + assert_eq!(native_scalar(7., &context).unwrap(), 7.); + let output = aggregate( + Aggregation::Sum, + &Grouping { + labels: vec![], + without: false, + }, + vec![(Labels::new(), 2.), (Labels::new(), 5.)], + &context, + ) + .unwrap(); + assert_eq!(output, vec![(Labels::new(), 7.)]); + assert!(context.peak_bytes() > 0); + context.cancel(); + assert!(native_scalar(7., &context).is_err()); + assert!(negate(Value::Scalar(1.), &context).is_err()); + } + + // 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, |_, _| { + Err(EngineError::capability_miss("source", "failed")) + }) + .unwrap_err(); + assert!(matches!(error,EngineError::CapabilityMiss{engine_id,..} if engine_id=="source")); + } +} 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 951fec4c..cdc8663a 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 @@ -23,16 +23,7 @@ fn schema(fields: &[(&str, DataType)]) -> Schema { time_index: None, }) } -fn context() -> Result { - dag::RunContext::new( - dag::Scope::Query { - evaluation_time_ms: 0, - revision: 0, - }, - dag::Limits::default(), - ) - .map_err(|e| miss(e.to_string())) -} + fn key(value: &T) -> Value { Value::Utf8( serde_json::to_string(value) @@ -79,6 +70,7 @@ pub(super) fn sort( values: Vector, grouping: &Grouping, descending: bool, + context: &dag::RunContext, ) -> Result { let batch = ranked_batch(&values, grouping)?; let op = Operator::sort( @@ -91,7 +83,7 @@ pub(super) fn sort( vec![1], ) .map_err(|e| miss(e.to_string()))?; - let result = batch_execution::evaluate_batch(batch, vec![op], context()?) + let result = batch_execution::evaluate_batch(batch, vec![op], context.clone()) .map_err(|e| miss(e.to_string()))?; output(values, result) } @@ -100,11 +92,12 @@ pub(super) fn limit( 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(|e| miss(e.to_string()))?; - let result = batch_execution::evaluate_batch(batch, vec![op], context()?) + let result = batch_execution::evaluate_batch(batch, vec![op], context.clone()) .map_err(|e| miss(e.to_string()))?; output(values, result) } @@ -113,6 +106,7 @@ pub(super) fn semi_join( candidates: &Vector, left_key: &impl Fn(&Labels) -> Vec, right_key: &impl Fn(&Labels) -> Vec, + context: &dag::RunContext, ) -> Result { let schema = schema(&[("index", DataType::Int64), ("key", DataType::Utf8)]); let batch = |rows: &Vector, identity: &dyn Fn(&Labels) -> Vec| { @@ -130,7 +124,7 @@ pub(super) fn semi_join( let result = batch_execution::evaluate_inputs( vec![batch(&values, left_key)?, batch(candidates, right_key)?], op, - context()?, + context.clone(), ) .map_err(|e| miss(e.to_string()))?; output(values, result) diff --git a/data_plane/src/query_engines/asap_query_engine/mod.rs b/data_plane/src/query_engines/asap_query_engine/mod.rs index b9db4172..2d8b05b5 100644 --- a/data_plane/src/query_engines/asap_query_engine/mod.rs +++ b/data_plane/src/query_engines/asap_query_engine/mod.rs @@ -6,7 +6,6 @@ pub mod engine; mod exact_subqueries; pub mod live_serve; pub mod logical_dag; -pub mod physical_dag; pub mod post_asap_readout; pub mod summary_exec; pub mod summary_executor; diff --git a/data_plane/src/query_engines/asap_query_engine/physical_dag.rs b/data_plane/src/query_engines/asap_query_engine/physical_dag.rs deleted file mode 100644 index 178965e5..00000000 --- a/data_plane/src/query_engines/asap_query_engine/physical_dag.rs +++ /dev/null @@ -1,298 +0,0 @@ -//! Graph traversal for an installed physical QueryPlan. -//! -//! This module owns dependency ordering and memoization only. Physical node -//! definitions live in `control_plane`; store and operator semantics are -//! supplied by a runtime adapter. - -use std::collections::BTreeMap; - -use asap_types::query_plan::{QueryNodeId, QueryPlanEntry, QueryPlanNode}; -use thiserror::Error; - -pub trait QueryNodeRuntime { - type Output: Clone; - type Error; - - fn execute_node( - &self, - id: QueryNodeId, - node: &QueryPlanNode, - inputs: &[Self::Output], - ) -> Result; -} - -/// Async counterpart used when ordinary DAG leaves perform external exact -/// reads. Keeping I/O in the node adapter lets the graph scheduler preserve -/// the same dependency ordering and memoization as local summary nodes. -#[async_trait::async_trait] -pub trait AsyncQueryNodeRuntime { - type Output: Clone + Send; - type Error; - - async fn execute_node( - &self, - id: QueryNodeId, - node: &QueryPlanNode, - inputs: &[Self::Output], - ) -> Result; -} - -#[derive(Debug, Error)] -pub enum DagExecutionError { - #[error("invalid physical query graph: {0}")] - InvalidGraph(String), - #[error("query node {node_id} failed")] - Node { node_id: u64, source: E }, -} - -/// Execute each reachable node exactly once. A diamond-shaped DAG therefore -/// performs one store read for the shared leaf, not one read per parent path. -pub fn execute( - entry: &QueryPlanEntry, - runtime: &R, -) -> Result> { - execute_from(entry, entry.root, runtime) -} - -pub fn execute_from( - entry: &QueryPlanEntry, - root: QueryNodeId, - runtime: &R, -) -> Result> { - let order = entry - .topological_order_from(root) - .map_err(|error| DagExecutionError::InvalidGraph(error.to_string()))?; - let mut outputs = BTreeMap::::new(); - for id in order { - let node = entry - .nodes - .get(&id) - .ok_or_else(|| DagExecutionError::InvalidGraph(format!("missing node {}", id.0)))?; - let inputs = node - .inputs() - .iter() - .map(|input| { - outputs.get(input).cloned().ok_or_else(|| { - DagExecutionError::InvalidGraph(format!( - "node {} ran before input {}", - id.0, input.0 - )) - }) - }) - .collect::, _>>()?; - let output = - runtime - .execute_node(id, node, &inputs) - .map_err(|source| DagExecutionError::Node { - node_id: id.0, - source, - })?; - outputs.insert(id, output); - } - outputs.remove(&root).ok_or_else(|| { - DagExecutionError::InvalidGraph(format!("root {} produced no output", root.0)) - }) -} - -#[cfg(test)] -pub async fn execute_async( - entry: &QueryPlanEntry, - runtime: &R, -) -> Result> { - execute_from_async(entry, entry.root, runtime).await -} - -pub async fn execute_from_async( - entry: &QueryPlanEntry, - root: QueryNodeId, - runtime: &R, -) -> Result> { - let order = entry - .topological_order_from(root) - .map_err(|error| DagExecutionError::InvalidGraph(error.to_string()))?; - let mut outputs = BTreeMap::::new(); - for id in order { - let node = entry - .nodes - .get(&id) - .ok_or_else(|| DagExecutionError::InvalidGraph(format!("missing node {}", id.0)))?; - let inputs = node - .inputs() - .iter() - .map(|input| { - outputs.get(input).cloned().ok_or_else(|| { - DagExecutionError::InvalidGraph(format!( - "node {} ran before input {}", - id.0, input.0 - )) - }) - }) - .collect::, _>>()?; - let output = runtime - .execute_node(id, node, &inputs) - .await - .map_err(|source| DagExecutionError::Node { - node_id: id.0, - source, - })?; - outputs.insert(id, output); - } - outputs.remove(&root).ok_or_else(|| { - DagExecutionError::InvalidGraph(format!("root {} produced no output", root.0)) - }) -} - -#[cfg(test)] -mod tests { - use std::cell::RefCell; - use std::collections::BTreeMap; - - use asap_types::query_plan::{FallbackPolicy, InstantExecution, QueryReadout}; - - use super::*; - - struct CountingRuntime(RefCell>); - - impl QueryNodeRuntime for CountingRuntime { - type Output = usize; - type Error = std::convert::Infallible; - - fn execute_node( - &self, - id: QueryNodeId, - _node: &QueryPlanNode, - inputs: &[usize], - ) -> Result { - *self.0.borrow_mut().entry(id).or_default() += 1; - Ok(1 + inputs.iter().sum::()) - } - } - - #[test] - fn shared_node_is_executed_once() { - let shared = QueryNodeId(0); - let left = QueryNodeId(1); - let right = QueryNodeId(2); - let root = QueryNodeId(3); - let nodes = [ - ( - shared, - QueryPlanNode::ExactFallback { - reason: "leaf".into(), - }, - ), - ( - left, - QueryPlanNode::SummaryEstimate { - input: shared, - query: QueryReadout::Cardinality, - }, - ), - ( - right, - QueryPlanNode::SummaryEstimate { - input: shared, - query: QueryReadout::Cardinality, - }, - ), - ( - root, - QueryPlanNode::SummaryMerge { - inputs: vec![left, right], - }, - ), - ] - .into_iter() - .collect(); - let entry = QueryPlanEntry { - language: asap_types::query_plan::QueryLanguage::PromQl, - query_id: "q".into(), - canonical_query: "up".into(), - fixed_evaluation: None, - root, - nodes, - instant: InstantExecution { - lookback_ms: 0, - full_history: false, - cumulative_readout: false, - }, - fallback: FallbackPolicy::Reject, - }; - let runtime = CountingRuntime(RefCell::new(BTreeMap::new())); - assert_eq!(execute(&entry, &runtime).unwrap(), 5); - assert!(runtime.0.borrow().values().all(|count| *count == 1)); - } - - struct AsyncCountingRuntime(tokio::sync::Mutex>); - - #[async_trait::async_trait] - impl AsyncQueryNodeRuntime for AsyncCountingRuntime { - type Output = usize; - type Error = std::convert::Infallible; - - async fn execute_node( - &self, - id: QueryNodeId, - _node: &QueryPlanNode, - inputs: &[usize], - ) -> Result { - *self.0.lock().await.entry(id).or_default() += 1; - Ok(1 + inputs.iter().sum::()) - } - } - - #[tokio::test] - async fn async_runtime_preserves_shared_dependency_memoization() { - let shared = QueryNodeId(0); - let left = QueryNodeId(1); - let right = QueryNodeId(2); - let root = QueryNodeId(3); - let nodes = [ - ( - shared, - QueryPlanNode::ExactFallback { - reason: "leaf".into(), - }, - ), - ( - left, - QueryPlanNode::SummaryEstimate { - input: shared, - query: QueryReadout::Cardinality, - }, - ), - ( - right, - QueryPlanNode::SummaryEstimate { - input: shared, - query: QueryReadout::Cardinality, - }, - ), - ( - root, - QueryPlanNode::SummaryMerge { - inputs: vec![left, right], - }, - ), - ] - .into_iter() - .collect(); - let entry = QueryPlanEntry { - language: asap_types::query_plan::QueryLanguage::PromQl, - query_id: "q".into(), - canonical_query: "up".into(), - fixed_evaluation: None, - root, - nodes, - instant: InstantExecution { - lookback_ms: 0, - full_history: false, - cumulative_readout: false, - }, - fallback: FallbackPolicy::Reject, - }; - let runtime = AsyncCountingRuntime(tokio::sync::Mutex::new(BTreeMap::new())); - assert_eq!(execute_async(&entry, &runtime).await.unwrap(), 5); - assert!(runtime.0.lock().await.values().all(|count| *count == 1)); - } -} 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 47f608e9..037c18eb 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 @@ -6,7 +6,8 @@ use asap_physical_operators::arithmetic::evaluate_float64_arithmetic as arithmet use asap_types::query_plan::{QueryNodeId, QueryPlanNode}; -use crate::query_engines::asap_query_engine::physical_dag::{self, QueryNodeRuntime}; +use asap_physical_operators::dag::{self, PhysicalOperator}; +use futures::StreamExt; /// A planned warm query cannot be served; callers may route to an exact backend. #[derive(Debug)] pub enum LoweringSkip { @@ -115,18 +116,18 @@ struct PhysicalQueryRuntime<'a> { context: QueryExecutionContext<'a>, } -impl QueryNodeRuntime for PhysicalQueryRuntime<'_> { - type Output = PhysicalQueryOutput; - type Error = PhysicalNodeError; - +impl PhysicalQueryRuntime<'_> { fn execute_node( &self, _id: QueryNodeId, node: &QueryPlanNode, - inputs: &[Self::Output], - ) -> Result { + inputs: &[PhysicalQueryOutput], + context: &dag::RunContext, + ) -> Result { match node { - QueryPlanNode::Scalar { value } => Ok(PhysicalQueryOutput::Scalar(*value)), + QueryPlanNode::Scalar { value } => super::logical_dag::native_scalar(*value, context) + .map(PhysicalQueryOutput::Scalar) + .map_err(|error| PhysicalNodeError::Fallback(error.to_string())), QueryPlanNode::Binary { operator, .. } => { let [lhs, rhs] = inputs else { return Err(PhysicalNodeError::ExpectedState); @@ -137,7 +138,7 @@ impl QueryNodeRuntime for PhysicalQueryRuntime<'_> { let [PhysicalQueryOutput::Value(values, coverage)] = inputs else { return Err(PhysicalNodeError::ExpectedState); }; - reduce_sum_values(grouping, values, *coverage) + reduce_sum_values_in_context(grouping, values, *coverage, context) } QueryPlanNode::ReadMaterialization { binding } => { let mut groups = self @@ -480,17 +481,42 @@ fn intersect_coverage(left: Option<(u64, u64)>, right: Option<(u64, u64)>) -> Op (result.0 <= result.1).then_some(result) } +#[cfg(test)] fn reduce_sum_values( grouping: &asap_types::query_plan::PhysicalGrouping, values: &[(BTreeMap, SummaryValue)], coverage: Option<(u64, u64)>, ) -> Result { + let context = dag::RunContext::new( + dag::Scope::Query { + evaluation_time_ms: 0, + revision: 0, + }, + dag::Limits::default(), + ) + .unwrap(); + reduce_sum_values_in_context(grouping, values, coverage, &context) +} +fn reduce_sum_values_in_context( + 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)); }; let mut groups = BTreeMap::new(); for (labels, value) in values { - let SummaryValue::Points(points, coverage) = value else { + let SummaryValue::Points(points, row_coverage) = value else { return Err(PhysicalNodeError::ExpectedState); }; let labels = labels @@ -499,27 +525,52 @@ fn reduce_sum_values( .map(|(k, v)| (k.clone(), v.clone())) .collect::>(); for (timestamp, value) in points { - groups + let group = groups .entry((labels.clone(), *timestamp)) - .and_modify(|(sum, cover): &mut (f64, Option<(u64, u64)>)| { - *sum += value; - *cover = intersect_coverage(*cover, *coverage); - }) - .or_insert((*value, *coverage)); + .or_insert_with(|| (Vec::new(), *row_coverage)); + group.0.push(*value); + group.1 = intersect_coverage(group.1, *row_coverage); } } - Ok(PhysicalQueryOutput::Value( - groups + let groups = groups.into_iter().collect::>(); + let schema = std::sync::Arc::new(SummarySchema { + fields: vec![("group", DataType::Int64), ("value", DataType::Float64)] .into_iter() - .map(|((labels, timestamp), (sum, coverage))| { - ( - labels, - SummaryValue::Points(vec![(timestamp, sum)], coverage), - ) + .map(|(name, dtype)| SummaryField { + name: name.into(), + dtype: SummaryFamilyType::Plain(dtype), + nullable: false, }) .collect(), - coverage, - )) + 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 = |error: dag::Error| PhysicalNodeError::Fallback(error.to_string()); + 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)) } fn execute_physical_query_plan( @@ -532,6 +583,148 @@ fn execute_physical_query_plan( execute_physical_query_payload(index, entry, entry.root, t0_ms, t1_ms, is_cumulative) } +// Store adapters retain backend coverage/population metadata; dependency execution +// and shared-node lifetime are owned by the independent physical DAG runtime. +struct BoundQueryOperator<'a, 'store> { + id: QueryNodeId, + node: &'a QueryPlanNode, + runtime: &'a PhysicalQueryRuntime<'store>, +} +impl PhysicalOperator for BoundQueryOperator<'_, '_> { + fn name(&self) -> &str { + "InstalledQueryOperator" + } + fn input_schemas(&self) -> Vec<()> { + vec![(); self.node.inputs().len()] + } + fn output_schema(&self) {} + fn output_bytes(&self, value: &PhysicalQueryOutput) -> usize { + match value { + PhysicalQueryOutput::Scalar(_) => 8, + PhysicalQueryOutput::State { + groups, + item_labels, + } => { + groups + .iter() + .map(|(labels, state)| { + labels.iter().map(|(k, v)| k.len() + v.len()).sum::() + + match state { + GroupState::Sketch { entries, .. } => entries + .iter() + .flat_map(|entry| entry.samples.values()) + .flatten() + .map(|sample| { + sample.bytes.len() + std::mem::size_of_val(sample) + }) + .sum::(), + GroupState::ExactAgg { entries, .. } => entries + .iter() + .flat_map(|entry| entry.values()) + .map(|state| state.approx_memory_bytes() + 8) + .sum::(), + } + }) + .sum::() + + item_labels.iter().map(String::len).sum::() + } + PhysicalQueryOutput::Value(values, _) => values + .iter() + .map(|(labels, value)| { + labels.iter().map(|(k, v)| k.len() + v.len()).sum::() + + match value { + SummaryValue::Points(points, _) => { + points.len() * std::mem::size_of::<(i64, f64)>() + } + SummaryValue::TopK(points, _) => points + .iter() + .map(|(_, items)| { + 8 + items.iter().map(|(key, _)| key.len() + 8).sum::() + }) + .sum::(), + } + }) + .sum(), + } + } + fn start<'a>( + &'a self, + inputs: Vec>, + context: dag::RunContext, + ) -> Result, dag::Error> { + Ok(futures::stream::once(async move { + let values = + futures::future::try_join_all(inputs.into_iter().map(|mut input| async move { + input.next().await.ok_or_else(|| { + dag::Error::Operator("query input produced no value".into()) + })? + })) + .await?; + let values = values + .iter() + .map(|value| value.value().clone()) + .collect::>(); + self.runtime + .execute_node(self.id, self.node, &values, &context) + .map_err(|e| dag::Error::Operator(format!("query node {}: {e}", self.id.0))) + }) + .boxed_local()) + } +} +fn execute_bound_queries( + entry: &asap_types::query_plan::QueryPlanEntry, + roots: &[QueryNodeId], + runtime: &PhysicalQueryRuntime<'_>, + context: dag::RunContext, +) -> Result, dag::Error> { + let mut order = std::collections::BTreeSet::new(); + for root in roots { + order.extend( + entry + .topological_order_from(*root) + .map_err(|e| dag::Error::Invalid(e.to_string()))?, + ); + } + let mut graph = dag::PhysicalDag::default(); + for id in order { + let node = &entry.nodes[&id]; + graph.add( + id.0, + node.inputs().iter().map(|id| id.0).collect(), + BoundQueryOperator { id, node, runtime }, + )?; + } + let outputs = graph.execute( + &roots.iter().map(|root| root.0).collect::>(), + context, + )?; + futures::executor::block_on(futures::future::try_join_all(outputs.into_iter().map( + |mut output| async move { + output + .next() + .await + .ok_or_else(|| dag::Error::Operator("query root produced no value".into()))? + .map(|output| output.value().clone()) + }, + ))) +} +fn execute_bound_query( + entry: &asap_types::query_plan::QueryPlanEntry, + root: QueryNodeId, + runtime: &PhysicalQueryRuntime<'_>, + revision: u64, +) -> Result { + let context = dag::RunContext::new( + dag::Scope::Query { + evaluation_time_ms: i64::try_from(runtime.context.t1_ms) + .map_err(|_| dag::Error::Invalid("query time exceeds i64".into()))?, + revision, + }, + dag::Limits::default(), + )?; + Ok(execute_bound_queries(entry, &[root], runtime, context)?.remove(0)) +} + fn execute_physical_query_payload( index: &SketchStore, entry: &asap_types::query_plan::QueryPlanEntry, @@ -553,31 +746,11 @@ fn execute_physical_query_payload( allowed_materializations: None, }, }; - let output = physical_dag::execute_from(entry, root, &runtime) - .map_err(|error| LoweringSkip::ExecuteFailed(format!("{error:?}")))?; - match output { - PhysicalQueryOutput::Scalar(_) => Err(LoweringSkip::ExecuteFailed( - "scalar-only query is not a warm vector result".into(), - )), - PhysicalQueryOutput::Value(values, coverage) => { - let mut series = Vec::new(); - for (group_key, value) in &values { - series.extend(summary_value_to_series(group_key, value)); - } - Ok(PostAsapReadoutOutcome { series, coverage }) - } - PhysicalQueryOutput::State { groups, .. } => { - let mut coverage = None; - let mut series = Vec::new(); - for (group_key, state) in &groups { - fold_coverage(&mut coverage, state.exact_coverage()); - if let Some(value) = state.exact_value(&None) { - series.push((group_key.clone(), vec![(t1_ms as i64, value)])); - } - } - Ok(PostAsapReadoutOutcome { series, coverage }) - } - } + let output = execute_bound_query(entry, root, &runtime, revision.mutation_sequence()) + .map_err(|error| { + LoweringSkip::ExecuteFailed(format!("query {}: {error}", entry.query_id)) + })?; + readout_outcome(output, t1_ms) })(); if !revision.matches(index.summary_update_revision()) { return Err(LoweringSkip::ExecuteFailed( @@ -628,6 +801,85 @@ pub(crate) fn fold_coverage(coverage: &mut Option<(u64, u64)>, next: Option<(u64 }); } +fn readout_outcome( + output: PhysicalQueryOutput, + t1_ms: u64, +) -> Result { + match output { + PhysicalQueryOutput::Scalar(_) => Err(LoweringSkip::ExecuteFailed( + "scalar-only query is not a warm vector result".into(), + )), + PhysicalQueryOutput::Value(values, coverage) => { + let mut series = Vec::new(); + for (group_key, value) in &values { + series.extend(summary_value_to_series(group_key, value)); + } + Ok(PostAsapReadoutOutcome { series, coverage }) + } + PhysicalQueryOutput::State { groups, .. } => { + let mut coverage = None; + let mut series = Vec::new(); + for (group_key, state) in &groups { + if let GroupState::Sketch { entries, .. } = state { + for entry in entries { + for time in entry.samples.keys() { + let time = (*time).max(0) as u64; + fold_coverage(&mut coverage, Some((time, time))); + } + } + } else { + fold_coverage(&mut coverage, state.exact_coverage()); + } + if let Some(value) = state.exact_value(&None) { + series.push((group_key.clone(), vec![(t1_ms as i64, value)])); + } + } + Ok(PostAsapReadoutOutcome { series, coverage }) + } + } +} + +/// Evaluate all storage frontiers in one run so common dependencies are shared. +pub(crate) fn execute_query_plan_readouts( + index: &SketchStore, + entry: &asap_types::query_plan::QueryPlanEntry, + roots: &[QueryNodeId], + t0_ms: u64, + t1_ms: u64, + is_cumulative: bool, + context: dag::RunContext, +) -> Result, LoweringSkip> { + if roots.is_empty() { + return Ok(BTreeMap::new()); + } + let revision = index.summary_update_revision(); + let runtime = PhysicalQueryRuntime { + language: entry.language, + catalog: index.summary_catalog_snapshot(), + context: QueryExecutionContext { + index, + t0_ms, + t1_ms, + is_cumulative, + allowed_materializations: None, + }, + }; + let output = execute_bound_queries(entry, roots, &runtime, context) + .map_err(|e| LoweringSkip::ExecuteFailed(e.to_string()))?; + let result = roots + .iter() + .copied() + .zip(output) + .map(|(id, value)| readout_outcome(value, t1_ms).map(|value| (id, value))) + .collect(); + if !revision.matches(index.summary_update_revision()) { + return Err(LoweringSkip::ExecuteFailed( + "summary input changed during query DAG evaluation".into(), + )); + } + result +} + #[cfg(test)] mod tests { use super::*; @@ -635,6 +887,92 @@ mod tests { use asap_types::query_plan::{ExactReadout, PhysicalGrouping, QueryReadout}; use planner_types::pre_asap::ArithmeticOpKind; + // A stored sketch frontier exposes pane coverage without treating it as exact state. + #[test] + fn sketch_frontier_retains_coverage_for_relation_binding() { + use crate::storage_engines::sketch_db::{ + data::SketchTimeSeries, query::delta_apply::DeltaSketchKind, + }; + let state = GroupState::Sketch { + kind: DeltaSketchKind::Hll { precision: 10 }, + entries: vec![std::rc::Rc::new(SketchTimeSeries { + sid: 1, + series_label_values: BTreeMap::new(), + samples: BTreeMap::from([(1000, vec![]), (2000, vec![])]), + })], + }; + let outcome = readout_outcome( + PhysicalQueryOutput::State { + groups: vec![(BTreeMap::new(), state)], + item_labels: vec![], + }, + 2000, + ) + .unwrap(); + assert_eq!(outcome.coverage, Some((1000, 2000))); + assert!(outcome.series.is_empty()); + } + + // Multiple storage frontiers share one run and inherit parent cancellation. + #[test] + fn multi_root_readout_uses_one_parent_context() { + let entry = asap_types::query_plan::QueryPlanEntry { + language: asap_types::query_plan::QueryLanguage::PromQl, + query_id: "shared".into(), + canonical_query: "1+1".into(), + fixed_evaluation: None, + root: QueryNodeId(1), + nodes: BTreeMap::from([ + (QueryNodeId(0), QueryPlanNode::Scalar { value: 1. }), + ( + QueryNodeId(1), + QueryPlanNode::Binary { + operator: ArithmeticOpKind::Add, + inputs: [QueryNodeId(0), QueryNodeId(0)], + }, + ), + ]), + instant: asap_types::query_plan::InstantExecution { + lookback_ms: 0, + full_history: false, + cumulative_readout: false, + }, + fallback: asap_types::query_plan::FallbackPolicy::Reject, + }; + let index = SketchStore::new(); + let runtime = PhysicalQueryRuntime { + language: entry.language, + catalog: None, + context: QueryExecutionContext { + index: &index, + t0_ms: 0, + t1_ms: 1, + is_cumulative: false, + allowed_materializations: None, + }, + }; + let context = dag::RunContext::new( + dag::Scope::Query { + evaluation_time_ms: 1, + revision: 1, + }, + dag::Limits::default(), + ) + .unwrap(); + let outputs = execute_bound_queries( + &entry, + &[QueryNodeId(1), QueryNodeId(0)], + &runtime, + context.clone(), + ) + .unwrap(); + assert!( + matches!(outputs.as_slice(),[PhysicalQueryOutput::Scalar(a),PhysicalQueryOutput::Scalar(b)] if *a==2. && *b==1.) + ); + context.cancel(); + assert!(execute_bound_queries(&entry, &[QueryNodeId(1)], &runtime, context).is_err()); + } + fn exact_points(metric: &str, service: &str, value: f64) -> PhysicalQueryOutput { PhysicalQueryOutput::Value( vec![( @@ -1458,7 +1796,7 @@ mod tests { control_plane::query_plan::ExactReadout::Increase, ] { assert!(matches!(native_runtime.execute_node(QueryNodeId(0), - &QueryPlanNode::ExactReadout { input:QueryNodeId(1),readout }, &[]), + &QueryPlanNode::ExactReadout { input:QueryNodeId(1),readout }, &[], &dag::RunContext::new(dag::Scope::Query { evaluation_time_ms: 0, revision: 0 }, dag::Limits::default()).unwrap()), Err(PhysicalNodeError::Fallback(reason)) if reason.contains("native MetricsQL counter semantics require external exact execution"))); } diff --git a/data_plane/src/query_engines/asap_query_engine/summary_executor.rs b/data_plane/src/query_engines/asap_query_engine/summary_executor.rs index e145db16..4a1a5916 100644 --- a/data_plane/src/query_engines/asap_query_engine/summary_executor.rs +++ b/data_plane/src/query_engines/asap_query_engine/summary_executor.rs @@ -59,6 +59,7 @@ //! on top of a sketch or exact-agg readout ("outer-agg-fold") — out of //! scope by explicit design choice, not an oversight. +use asap_physical_operators::accumulators::{MaxAccumulator, MinAccumulator}; use std::collections::{BTreeMap, BTreeSet}; use std::rc::Rc; use std::sync::Arc; @@ -75,12 +76,7 @@ use crate::storage_engines::sketch_db::index::{SketchSampleState, SketchStore}; use crate::storage_engines::sketch_db::query::delta_apply::{ cumulative_summary_state, per_window_summary_states, DeltaSketchKind, SummaryState, }; -use crate::storage_engines::types::{ - AggregateCore, AggregationType, KeyByLabelValues, MergeableAccumulator, -}; -use asap_physical_operators::accumulators::increase_accumulator::IncreaseAccumulator; -use asap_physical_operators::accumulators::max_accumulator::MaxAccumulator; -use asap_physical_operators::accumulators::min_accumulator::MinAccumulator; +use crate::storage_engines::types::{AggregateCore, AggregationType, KeyByLabelValues}; /// Per-query, per-call execution context — constructed fresh for each /// incoming query (never shared across concurrent queries, never @@ -212,18 +208,13 @@ impl GroupState { SummaryFamilyType::ExactAggregate(ExactKind::Rate, _) => asap_types::Statistic::Rate, _ => return None, }; - let mut merged: Option> = None; - for windows in entries { - for acc in windows.values() { - merged = Some(match merged.take() { - None => acc.clone_boxed_core(), - Some(current) => current.merge_with(acc.as_ref()).ok()?, - }); - } - } - merged? - .query_statistic(stat, key, &std::collections::HashMap::new()) - .ok() + asap_physical_operators::stored_state::readout::exact_readout( + entries.iter().flat_map(|windows| windows.values().cloned()), + stat, + key, + &std::collections::HashMap::new(), + ) + .ok() } /// Finalize the Planner-declared exact family with its matching readout. @@ -249,76 +240,17 @@ impl GroupState { asap_types::query_plan::ExactReadout::Max => asap_types::Statistic::Max, }; - let planner_state = entries.iter().flat_map(|w| w.values()).any(|a| { - a.as_any() - .is::() - }); - // Temporal exact summaries are the hot path for long-window - // dashboards. Merge their concrete, fixed-size states in one batch - // instead of allocating a boxed trait object for every pane. - if !planner_state - && matches!( - readout, - asap_types::query_plan::ExactReadout::Increase - | asap_types::query_plan::ExactReadout::Rate - ) - { - let accumulators = entries - .iter() - .flat_map(|windows| windows.values()) - .map(|acc| acc.as_any().downcast_ref::().cloned()) - .collect::>>()?; - let merged = >::merge_accumulators(accumulators) - .ok()?; - let query_kwargs = std::collections::HashMap::from([ - ("range_start_ms".to_string(), range_start_ms.to_string()), - ("range_end_ms".to_string(), range_end_ms.to_string()), - ]); - return merged.query_statistic(stat, key, &query_kwargs).ok(); - } - if !planner_state && readout == asap_types::query_plan::ExactReadout::Min { - return entries - .iter() - .flat_map(|windows| windows.values()) - .map(|acc| { - acc.as_any() - .downcast_ref::() - .map(|a| a.value) - }) - .collect::>>()? - .into_iter() - .reduce(f64::min); - } - if !planner_state && readout == asap_types::query_plan::ExactReadout::Max { - return entries - .iter() - .flat_map(|windows| windows.values()) - .map(|acc| { - acc.as_any() - .downcast_ref::() - .map(|a| a.value) - }) - .collect::>>()? - .into_iter() - .reduce(f64::max); - } - let mut merged: Option> = None; - for windows in entries { - for acc in windows.values() { - merged = Some(match merged.take() { - None => acc.clone_boxed_core(), - Some(m) => m.merge_with(acc.as_ref()).ok()?, - }); - } - } let query_kwargs = std::collections::HashMap::from([ ("range_start_ms".to_string(), range_start_ms.to_string()), ("range_end_ms".to_string(), range_end_ms.to_string()), ]); - let merged = merged?; - merged.query_statistic(stat, key, &query_kwargs).ok() + asap_physical_operators::stored_state::readout::exact_readout( + entries.iter().flat_map(|windows| windows.values().cloned()), + stat, + key, + &query_kwargs, + ) + .ok() } /// Coverage analog of `exact_value` — folds `(min_window_end_ms, @@ -1074,97 +1006,22 @@ fn readout_per_window( /// Read one scalar out of a merged `SummaryState` for the requested /// `SketchQuery` -- shared by both the cumulative and per-window readout /// paths. -fn sketch_query_value(rs: &SummaryState, query: &SketchQuery) -> Result { - if let SummaryState::UnivMon(state) = rs { - use crate::storage_engines::types::AggregateCore; - let statistic = match query { - SketchQuery::Cardinality => asap_types::Statistic::Cardinality, - SketchQuery::FrequencyL2 => asap_types::Statistic::FrequencyL2, - SketchQuery::FrequencyEntropy => asap_types::Statistic::FrequencyEntropy, - SketchQuery::PointCount { - key: ColumnRef::SampleValue, - value: None, - } => asap_types::Statistic::Count, - _ => { - return Err(SummaryExecutorError::Unsupported( - "unsupported UnivMon readout", - )) - } - }; - return state - .query_statistic(statistic, &None, &Default::default()) - .map_err(|_| SummaryExecutorError::Unsupported("UnivMon readout failed")); - } - match query { - SketchQuery::FrequencyL2 | SketchQuery::FrequencyEntropy => Err( - SummaryExecutorError::Unsupported("frequency moment readout requires UnivMon"), - ), - SketchQuery::Quantile { q } => match rs { - // Typed PromQL/continuous-percentile readout uses interpolation; - // portable DDS `quantile` deliberately retains lower-rank parity. - SummaryState::Dd(sketch) => { - sketch - .quantile_interpolated(*q) - .ok_or(SummaryExecutorError::Unsupported( - "DDS interpolated quantile is unavailable", - )) - } - _ => Ok(rs.quantile(*q)), +fn sketch_query_value( + state: &SummaryState, + query: &SketchQuery, +) -> Result { + asap_physical_operators::stored_state::readout::sketch_query_value(state, query).map_err( + |asap_physical_operators::stored_state::readout::Error::Unsupported(reason)| { + SummaryExecutorError::Unsupported(reason) }, - SketchQuery::Cardinality => Ok(rs.cardinality()), - // `key: ColumnRef::SampleValue, value: None` means "no specific - // item" -- the bare bucket total. `key: Named(_), value: Some(v)` - // is a per-item point lookup (e.g. `count(cms_metric{item="x"})`) - // -- `value` is where the filter's actual value lives (see - // `planner_types::post_asap::SketchQuery::PointCount`'s doc for why `readout` - // can't resolve it itself). Any other combination (e.g. a `Named` - // key with no value, or `SampleValue` with a value) is a shape - // this executor doesn't expect to see and reports rather than - // silently misreading. - SketchQuery::PointCount { - key: ColumnRef::SampleValue, - value: None, - } => Ok(rs.total()), - SketchQuery::PointCount { - key: ColumnRef::Named(_) | ColumnRef::Qualified { .. }, - value: Some(v), - } => rs.estimate(v).ok_or(SummaryExecutorError::Unsupported( - "PointCount by key requires a Frequency-family sketch (Cms/CountSketch/..WithHeap)", - )), - SketchQuery::PointCount { .. } => Err(SummaryExecutorError::Unsupported( - "unrecognized PointCount shape (key/value combination not expected)", - )), - // Both readout callers branch on `TopK` before ever calling this - // function (see `readout_cumulative`/`readout_per_window`), so - // this arm is unreachable in practice; kept for match - // exhaustiveness (`SketchQuery` has no `#[non_exhaustive]`) and to - // fail loudly rather than panic if that invariant is ever broken. - SketchQuery::TopK { .. } => Err(SummaryExecutorError::Unsupported( - "TopK must be read out via topk_ranked, not sketch_query_value", - )), - } + ) } - -/// Rank a merged `SummaryState`'s top-k heap items descending by value and -/// cap at the requested `k`. The sort is load-bearing, not defensive -/// polish: `SummaryState::topk_items` reads back a bounded min-heap's -/// backing array as-is (`HHHeap::heap()`, asap_sketchlib) -- it does NOT -/// actually guarantee order despite its own doc wording. Errors for a -/// heap-less family (`Dd`/`Hll`/`Kll`/`Cms`/`CountSketch` -- no item -/// universe to rank), not for an empty heap (a heap-bearing family that -/// simply never received any updates yields `Ok(vec![])`, not an error). -fn topk_ranked(rs: &SummaryState, k: usize) -> Result, SummaryExecutorError> { - let mut items = rs.topk_items().ok_or(SummaryExecutorError::Unsupported( - "TopK requires a heap-bearing family (CmsWithHeap/CountSketchWithHeap) -- \ - this state's family carries no item universe to rank", - ))?; - items.sort_by(|a, b| { - b.1.partial_cmp(&a.1) - .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| a.0.cmp(&b.0)) // deterministic tie-break for equal counts - }); - items.truncate(k); - Ok(items) +fn topk_ranked(state: &SummaryState, k: usize) -> Result, SummaryExecutorError> { + asap_physical_operators::stored_state::readout::topk_ranked(state, k).map_err( + |asap_physical_operators::stored_state::readout::Error::Unsupported(reason)| { + SummaryExecutorError::Unsupported(reason) + }, + ) } /// Exact canonical `SketchKind` match against a sid's own diff --git a/data_plane/src/storage_engines/sketch_db/data/mod.rs b/data_plane/src/storage_engines/sketch_db/data/mod.rs index e696fe52..475fc6b1 100644 --- a/data_plane/src/storage_engines/sketch_db/data/mod.rs +++ b/data_plane/src/storage_engines/sketch_db/data/mod.rs @@ -472,20 +472,7 @@ impl AccuracyBound { /// Per-sample sketch state. Stored as the payload column inside the /// per-sid `SidStoreData` columnar storage. -#[derive(Debug, Clone)] -pub struct SketchSampleState { - pub bytes: Vec, - /// Wire-encoding hint from the OTLP DataPoint's `encoding` field. - pub encoding: SketchEncoding, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SketchEncoding { - ProtoFull, - ProtoDelta, - MsgpackFull, - MsgpackDelta, -} +pub use asap_physical_operators::stored_state::{SketchEncoding, SketchSampleState}; /// One materialized series row returned by the query path. Resolved /// from the per-sid intern table at read time. 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 132c6f4b..b05261d2 100644 --- a/data_plane/src/storage_engines/sketch_db/index/mod.rs +++ b/data_plane/src/storage_engines/sketch_db/index/mod.rs @@ -566,6 +566,10 @@ pub(crate) struct SummaryReadRevision { in_flight: usize, } impl SummaryReadRevision { + pub(crate) fn mutation_sequence(self) -> u64 { + self.mutation + } + fn capture( admission: u64, mutation: &std::sync::atomic::AtomicU64, diff --git a/data_plane/src/storage_engines/sketch_db/query/decoders.rs b/data_plane/src/storage_engines/sketch_db/query/decoders.rs index 3d254f2c..d208cb57 100644 --- a/data_plane/src/storage_engines/sketch_db/query/decoders.rs +++ b/data_plane/src/storage_engines/sketch_db/query/decoders.rs @@ -1,382 +1,2 @@ -//! Per-sketch-kind decoder helpers — out-of-line wrappers around -//! `asap_sketchlib` deserialize / proto-decode paths. -//! -//! Lifted from the inline closures in [`crate::storage_engines::sketch_db::query::sketch_reducer`] -//! once the reducer started decoding CMS / CountSketch / CMS-with-heap -//! payloads in addition to DDSketch / KLL / HLL. The CMS / CountSketch -//! / CMS-with-heap decoders mirror -//! `precompute_operators::{count_min_sketch, count_sketch, -//! count_min_sketch_with_heap}_accumulator.rs` bit-for-bit so the -//! ASAP-tier reducer's output matches what the precompute (ingest-side) -//! accumulator would have produced from the same bytes. -//! -//! Each entry point returns the typed sketchlib struct on success or a -//! plain `String` error; the reducer wraps the error into a -//! `ASAPTierError::DeserializeFailure` so the engine router falls over -//! to archive cleanly. - -use asap_sketchlib::CountMinSketch; -use asap_sketchlib::CountMinSketchDelta; -use asap_sketchlib::CountMinSketchWithHeap; -use asap_sketchlib::CountSketch; -use asap_sketchlib::CountSketchDelta; -use asap_sketchlib::CountSketchWithHeap; -use asap_sketchlib::CsHeapItem; -use asap_sketchlib::MessagePackCodec; - -use asap_physical_operators::accumulators::count_min_sketch_with_heap_accumulator::CountMinSketchWithHeapAccumulator; - -/// Decode a `CountMinSketch` from the modified-OTLP wire bytes. -/// MSGPACK path round-trips `CountMinSketch::deserialize_msgpack`; -/// PROTO path decodes a `SketchEnvelope{count_min: CountMinState}` -/// (or bare `CountMinState`) and re-projects to a flat matrix. Mirrors -/// `precompute_operators::count_min_sketch_accumulator::from_sketchlib_proto_bytes`. -pub fn decode_cms_from_proto(buffer: &[u8]) -> Result { - use asap_sketchlib::proto::sketchlib::{ - sketch_envelope, CountMinState, CounterType, SketchEnvelope, - }; - use prost::Message; - - let state = match SketchEnvelope::decode(buffer) { - Ok(env) => match env.sketch_state { - Some(sketch_envelope::SketchState::CountMin(st)) => st, - Some(_) => return Err("SketchEnvelope contains non-CountMin sketch".to_string()), - None => { - CountMinState::decode(buffer).map_err(|e| format!("decode CountMinState: {e}"))? - } - }, - Err(_) => { - CountMinState::decode(buffer).map_err(|e| format!("decode CountMinState: {e}"))? - } - }; - let rows = state.rows as usize; - let cols = state.cols as usize; - if rows == 0 || cols == 0 { - return Err(format!( - "CountMinState has zero dims (rows={rows}, cols={cols})" - )); - } - let expected_len = rows * cols; - let counter_type = CounterType::try_from(state.counter_type) - .map_err(|_| format!("CountMinState unknown counter_type {}", state.counter_type))?; - let flat: Vec = match counter_type { - CounterType::Int32 | CounterType::Int64 => { - if state.counts_int.len() != expected_len { - return Err(format!( - "CountMinState counts_int has {} entries, expected {}", - state.counts_int.len(), - expected_len - )); - } - state.counts_int.iter().map(|&v| v as f64).collect() - } - CounterType::Float64 => { - if state.counts_float.len() != expected_len { - return Err(format!( - "CountMinState counts_float has {} entries, expected {}", - state.counts_float.len(), - expected_len - )); - } - state.counts_float.clone() - } - other => { - return Err(format!( - "CountMinState counter_type {other:?} not yet supported in reducer" - )); - } - }; - let mut matrix = Vec::with_capacity(rows); - for r in 0..rows { - let start = r * cols; - matrix.push(flat[start..start + cols].to_vec()); - } - Ok(CountMinSketch::from_legacy_matrix(matrix, rows, cols)) -} - -/// Decode a `CountMinSketch` from msgpack bytes (sketch-core wire -/// format). Mirrors -/// `CountMinSketchAccumulator::from_msgpack_bytes`. -pub fn decode_cms_from_msgpack(buffer: &[u8]) -> Result { - CountMinSketch::from_msgpack(buffer) - .map_err(|e| format!("deserialize CountMinSketch msgpack: {e}")) -} - -/// Decode a `CountSketch` from the modified-OTLP proto wire bytes. -/// Mirrors -/// `precompute_operators::count_sketch_accumulator::from_sketchlib_proto_bytes`. -pub fn decode_cs_from_proto(buffer: &[u8]) -> Result { - use asap_sketchlib::proto::sketchlib::{ - sketch_envelope, CountSketchState, CounterType, SketchEnvelope, - }; - use prost::Message; - - let state = match SketchEnvelope::decode(buffer) { - Ok(env) => match env.sketch_state { - Some(sketch_envelope::SketchState::CountSketch(st)) => st, - Some(_) => return Err("SketchEnvelope contains non-CountSketch sketch".to_string()), - None => CountSketchState::decode(buffer) - .map_err(|e| format!("decode CountSketchState: {e}"))?, - }, - Err(_) => { - CountSketchState::decode(buffer).map_err(|e| format!("decode CountSketchState: {e}"))? - } - }; - let rows = state.rows as usize; - let cols = state.cols as usize; - if rows == 0 || cols == 0 { - return Err(format!( - "CountSketchState has zero dims (rows={rows}, cols={cols})" - )); - } - let expected_len = rows * cols; - let counter_type = CounterType::try_from(state.counter_type).map_err(|_| { - format!( - "CountSketchState unknown counter_type {}", - state.counter_type - ) - })?; - let flat: Vec = match counter_type { - CounterType::Int32 | CounterType::Int64 => { - if state.counts_int.len() != expected_len { - return Err(format!( - "CountSketchState counts_int has {} entries, expected {}", - state.counts_int.len(), - expected_len - )); - } - state.counts_int.iter().map(|&v| v as f64).collect() - } - CounterType::Float64 => { - if state.counts_float.len() != expected_len { - return Err(format!( - "CountSketchState counts_float has {} entries, expected {}", - state.counts_float.len(), - expected_len - )); - } - state.counts_float.clone() - } - other => { - return Err(format!( - "CountSketchState counter_type {other:?} not yet supported in reducer" - )); - } - }; - let mut matrix = Vec::with_capacity(rows); - for r in 0..rows { - let start = r * cols; - matrix.push(flat[start..start + cols].to_vec()); - } - Ok(CountSketch::from_legacy_matrix(matrix, rows, cols)) -} - -/// Decode a `CountSketch` from msgpack bytes (sketch-core wire format). -pub fn decode_cs_from_msgpack(buffer: &[u8]) -> Result { - CountSketch::from_msgpack(buffer).map_err(|e| format!("deserialize CountSketch msgpack: {e}")) -} - -/// Decode a `CountMinSketchWithHeap` from msgpack bytes — the OTLP -/// `CountMinSketch` wire bytes when the gateway/precompute layer -/// marked the sid as CmsWithHeap (heap embedded in the -/// `CountMinSketchWithHeapSerialized` outer wrapper). Delegates to -/// `asap_sketchlib::CountMinSketchWithHeap::deserialize_msgpack`. -pub fn decode_cms_with_heap_from_msgpack(buffer: &[u8]) -> Result { - CountMinSketchWithHeap::from_msgpack(buffer) - .map_err(|e| format!("deserialize CountMinSketchWithHeap msgpack: {e}")) -} - -/// Decode a `CountSketchWithHeap` (median-estimator, Count Sketch family) -/// from msgpack bytes. Distinct wire type from `CountMinSketchWithHeap` -/// (min-estimator, Count-Min family) even though both are heap-bearing -/// frequency sketches — see `asap_sketchlib::CountSketchWithHeap`. -/// Delegates to `asap_sketchlib::CountSketchWithHeap::from_msgpack`. -pub fn decode_cs_with_heap_from_msgpack(buffer: &[u8]) -> Result { - CountSketchWithHeap::from_msgpack(buffer) - .map_err(|e| format!("deserialize CountSketchWithHeap msgpack: {e}")) -} - -// --------------------------------------------------------------------------- -// Delta decoders. Under the per-window-reset (PWR) contract -// (`asap-precompute-go/window.go`: a delta is that window's own state -// applied onto a freshly-reset per-series sketch), each stored *Delta -// frame reconstructs into the FULL window state when applied onto an -// EMPTY base of the frame's declared dimensions. The reducer's -// `FrequencyEstimate` / `FrequencyTopk` paths are per-window evaluations, -// so "empty + apply(this window's delta)" yields exactly the window's -// matrix/heap — no cross-window stitching needed (mirrors how the ingest -// accumulators reset_to_empty per window before applying). -// -// The proto path reuses the PUBLIC `asap_sketchlib::{CountSketch, -// CountMinSketch}::apply_delta`; the proto `*Delta` message is decoded via -// `asap_sketchlib::proto::sketchlib::{CountSketchDelta, CountMinDelta}`, -// exactly as `precompute_operators::{count_sketch, -// count_min_sketch}_accumulator::apply_proto_delta_bytes` does. -// --------------------------------------------------------------------------- - -/// Decode a `CountMinSketch` PROTO_DELTA frame into a FULL sketch by -/// applying the sparse cell delta onto an empty base of the frame's -/// declared dimensions. Mirrors -/// `precompute_operators::count_min_sketch_accumulator::apply_proto_delta_bytes`. -pub fn decode_cms_from_proto_delta(buffer: &[u8]) -> Result { - use asap_sketchlib::proto::sketchlib::CountMinDelta as PbDelta; - use prost::Message; - - let pb = PbDelta::decode(buffer).map_err(|e| format!("decode CountMinDelta: {e}"))?; - if pb.cell_rows.len() != pb.cell_cols.len() || pb.cell_rows.len() != pb.d_counts.len() { - return Err(format!( - "CountMinDelta packed-array length mismatch: cell_rows={}, cell_cols={}, d_counts={}", - pb.cell_rows.len(), - pb.cell_cols.len(), - pb.d_counts.len() - )); - } - let rows = pb.rows as usize; - let cols = pb.cols as usize; - if rows == 0 || cols == 0 { - return Err(format!( - "CountMinDelta has zero dims (rows={rows}, cols={cols})" - )); - } - let cells = pb - .cell_rows - .iter() - .zip(pb.cell_cols.iter()) - .zip(pb.d_counts.iter()) - .map(|((r, c), dc)| (*r, *c, *dc)) - .collect(); - // hh_keys is parsed off the wire by the precompute accumulator but - // intentionally dropped (the vendored Go proto bindings don't yet - // populate it); match that to keep behavior identical. - let delta = CountMinSketchDelta { - rows: pb.rows, - cols: pb.cols, - cells, - l1: pb.l1, - l2: pb.l2, - hh_keys: Vec::new(), - }; - let mut cms = CountMinSketch::from_legacy_matrix(vec![vec![0.0; cols]; rows], rows, cols); - cms.apply_delta(&delta) - .map_err(|e| format!("apply CountMinDelta onto empty base: {e}"))?; - Ok(cms) -} - -/// Decode a `CountSketch` PROTO_DELTA frame into a FULL sketch by applying -/// the sparse cell delta onto an empty base of the frame's declared -/// dimensions. Mirrors -/// `precompute_operators::count_sketch_accumulator::apply_proto_delta_bytes`. -pub fn decode_cs_from_proto_delta(buffer: &[u8]) -> Result { - use asap_sketchlib::proto::sketchlib::CountSketchDelta as PbDelta; - use prost::Message; - - let pb = PbDelta::decode(buffer).map_err(|e| format!("decode CountSketchDelta: {e}"))?; - if pb.cell_rows.len() != pb.cell_cols.len() || pb.cell_rows.len() != pb.d_counts.len() { - return Err(format!( - "CountSketchDelta packed-array length mismatch: cell_rows={}, cell_cols={}, d_counts={}", - pb.cell_rows.len(), - pb.cell_cols.len(), - pb.d_counts.len() - )); - } - let rows = pb.rows as usize; - let cols = pb.cols as usize; - if rows == 0 || cols == 0 { - return Err(format!( - "CountSketchDelta has zero dims (rows={rows}, cols={cols})" - )); - } - let cells = pb - .cell_rows - .iter() - .zip(pb.cell_cols.iter()) - .zip(pb.d_counts.iter()) - .map(|((r, c), dc)| (*r, *c, *dc)) - .collect(); - let delta = CountSketchDelta { - rows: pb.rows, - cols: pb.cols, - cells, - l2: pb.l2, - hh_keys: Vec::new(), - }; - let mut cs = CountSketch::from_legacy_matrix(vec![vec![0.0; cols]; rows], rows, cols); - cs.apply_delta(&delta) - .map_err(|e| format!("apply CountSketchDelta onto empty base: {e}"))?; - Ok(cs) -} - -/// Decode a heap-bearing CountSketch MSGPACK_DELTA frame into a FULL -/// `CountMinSketchWithHeap` by applying the sparse matrix delta + full -/// heap onto an empty base of the frame's declared dimensions. This -/// REUSES the ingest-side delta-heap apply logic -/// (`CountMinSketchWithHeapAccumulator::from_msgpack_heap_delta_bytes` → -/// `apply_msgpack_heap_delta_bytes`), which decodes the frame generically -/// with `rmp_serde` — no `asap_sketchlib` delta API is added. -pub fn decode_cms_with_heap_from_msgpack_delta( - buffer: &[u8], -) -> Result { - let acc = CountMinSketchWithHeapAccumulator::from_msgpack_heap_delta_bytes(buffer) - .map_err(|e| format!("reconstruct CountMinSketchWithHeap from delta: {e}"))?; - Ok(acc.inner) -} - -/// Decode a heap-bearing CountSketch (median-estimator) MSGPACK_DELTA frame -/// into a FULL `asap_sketchlib::CountSketchWithHeap` by applying the sparse -/// matrix delta + full heap onto an empty base of the frame's declared -/// dimensions. Same DELTA-HEAP wire shape as the CmsWithHeap delta frame -/// (see `HeapDeltaWire`/`MatrixDeltaWire` in -/// `count_min_sketch_with_heap_accumulator.rs`), decoded here directly -/// with `rmp_serde` since there is no CountSketchWithHeap ingest -/// accumulator to delegate to. No `asap_sketchlib` delta API needed — the -/// public `from_legacy_matrix` rebuilds both the matrix and heap. -pub fn decode_cs_with_heap_from_msgpack_delta( - buffer: &[u8], -) -> Result { - #[derive(serde::Deserialize)] - struct HeapDeltaWire { - is_delta: bool, - matrix_delta: MatrixDeltaWire, - topk_heap: Vec<(String, f64)>, - heap_size: u64, - } - #[derive(serde::Deserialize)] - struct MatrixDeltaWire { - rows: u32, - cols: u32, - cells: Vec<(u32, u32, i64)>, - } - - let wire: HeapDeltaWire = rmp_serde::from_slice(buffer) - .map_err(|e| format!("decode CountSketchWithHeap delta msgpack: {e}"))?; - if !wire.is_delta { - return Err("CountSketchWithHeap delta frame has is_delta=false".to_string()); - } - let rows = wire.matrix_delta.rows as usize; - let cols = wire.matrix_delta.cols as usize; - if rows == 0 || cols == 0 { - return Err(format!( - "CountSketchWithHeap delta frame has zero dims (rows={rows}, cols={cols})" - )); - } - let mut matrix = vec![vec![0.0; cols]; rows]; - for (r, c, dc) in &wire.matrix_delta.cells { - let (r, c) = (*r as usize, *c as usize); - if r >= rows || c >= cols { - continue; - } - matrix[r][c] += *dc as f64; - } - let heap: Vec = wire - .topk_heap - .into_iter() - .map(|(key, value)| CsHeapItem { key, value }) - .collect(); - Ok(CountSketchWithHeap::from_legacy_matrix( - matrix, - heap, - rows, - cols, - wire.heap_size as usize, - )) -} +//! Storage uses the Planner-owned state implementation. +pub use asap_physical_operators::stored_state::decoders::*; diff --git a/data_plane/src/storage_engines/sketch_db/query/delta_apply.rs b/data_plane/src/storage_engines/sketch_db/query/delta_apply.rs index d36b6955..cd374a41 100644 --- a/data_plane/src/storage_engines/sketch_db/query/delta_apply.rs +++ b/data_plane/src/storage_engines/sketch_db/query/delta_apply.rs @@ -1,1286 +1,2 @@ -//! Per-window delta stitching for the ASAP-tier sketch reducer. -//! -//! The reducer walks a sid's per-window samples in time order. When a -//! window's payload is a `Full` encoding (PROTO / MSGPACK), it -//! initializes a rolling "current state" sketch. Subsequent `Delta` -//! encodings merge into that rolling state — either via -//! `asap_sketchlib::HllSketch::apply_delta` for HLL register deltas, -//! or via `Sketch::merge(decoded_delta)` for DD / KLL where the wire -//! format ships a sparse-but-mergeable sketch fragment. -//! -//! Two reducer modes, picked by the PromQL function name in -//! [`crate::storage_engines::sketch_db::query::sketch_reducer`]: -//! -//! * **per-window** (`quantile`, `histogram_quantile`, -//! `cardinality_estimate`): emit one scalar per window. A `Full` -//! resets the rolling state; a `Delta` merges then emits from the -//! merged state. Window-end timestamps come from the index. -//! * **cumulative** (`quantile_over_time`, `count_distinct_over_time`): -//! walk the full `[t0, t1]` range, accumulating Full + all subsequent -//! Deltas into a single rolling state. Emit one scalar at the last -//! window's end_ms (the rolled-up answer covers the whole window). -//! -//! ## Corner case: leading delta -//! -//! If the first sample in a query window is a `Delta`, the base -//! snapshot from the previous (out-of-range) window isn't available -//! to the reducer. The leading delta is dropped (logged via the -//! `DeserializeFailure { reason: "leading delta without base" }` path -//! when no Full follows in the same window) and we wait for the next -//! `Full`. For cumulative mode this means the cumulative answer -//! starts at the first Full in the range, not at `t0`. - -use asap_sketchlib::CountMinSketch; -use asap_sketchlib::CountMinSketchWithHeap; -use asap_sketchlib::CountSketch; -use asap_sketchlib::CountSketchWithHeap; -use asap_sketchlib::DdSketch; -use asap_sketchlib::HllSketch; -use asap_sketchlib::HllVariant; -use asap_sketchlib::KllSketch; -use asap_sketchlib::MessagePackCodec; - -use crate::storage_engines::sketch_db::index::{SketchEncoding, SketchSampleState}; -use crate::storage_engines::sketch_db::query::decoders::{ - decode_cms_from_msgpack, decode_cms_from_proto, decode_cms_from_proto_delta, - decode_cms_with_heap_from_msgpack, decode_cms_with_heap_from_msgpack_delta, - decode_cs_from_msgpack, decode_cs_from_proto, decode_cs_from_proto_delta, - decode_cs_with_heap_from_msgpack, decode_cs_with_heap_from_msgpack_delta, -}; - -/// Which sketch family a candidate is, and the parameters needed to -/// *bootstrap an empty state* — required by the per-window-reset (PWR) -/// delta model where a window's FIRST frame is a delta-from-empty (no -/// carry-in Full). Most families' deltas embed their own params in the -/// wire fragment (decoded independently, then merged in — see -/// `SummaryState::apply_delta_bytes`); HLL register deltas and DD's -/// bucket-index deltas are applied onto a pre-sized structure instead, -/// so those two need the params known up front to allocate it. -#[derive(Debug, Clone, Copy)] -pub enum DeltaSketchKind { - UnivMon { - heap_size: u32, - sketch_rows: u32, - sketch_cols: u32, - layers: u8, - }, - DDSketch { - alpha: f64, - }, - Hll { - precision: u32, - }, - Kll { - k: u32, - }, - Cms { - rows: usize, - cols: usize, - }, - CountSketch { - rows: usize, - cols: usize, - }, - /// `CmsWithHeap` wraps `asap_sketchlib::CountMinSketchWithHeap` - /// (min-over-rows estimator) and `CountSketchWithHeap` wraps the - /// distinct `asap_sketchlib::CountSketchWithHeap` (median-of-signed-rows - /// estimator) -- different algorithms that happen to share a storage - /// shape. Kept as two variants (not one shared `Heap`) so - /// `merge_same_family` rejects merging one into the other the same - /// way it already rejects e.g. merging a `Cms` into a `Kll`; now the - /// type system enforces it too, since the two variants hold different - /// Rust types. - CmsWithHeap { - rows: usize, - cols: usize, - heap_size: usize, - }, - CountSketchWithHeap { - rows: usize, - cols: usize, - heap_size: usize, - }, -} - -impl DeltaSketchKind { - /// Construct an EMPTY state for this kind, used to seed a new window - /// when its first frame is a delta-from-empty (PWR). A delta applied - /// onto this empty base reconstructs exactly that window's state - /// (delta-from-empty ⊕ empty = window state). - fn bootstrap_empty(&self) -> SummaryState { - match self { - Self::UnivMon { - heap_size, - sketch_rows, - sketch_cols, - layers, - } => SummaryState::UnivMon( - asap_physical_operators::accumulators::univmon_accumulator::UnivMonAccumulator::new( - *heap_size as usize, - *sketch_rows as usize, - *sketch_cols as usize, - *layers as usize, - ) - .expect("validated UnivMon catalog dimensions"), - ), - DeltaSketchKind::DDSketch { alpha } => SummaryState::Dd(DdSketch::new(*alpha)), - DeltaSketchKind::Kll { k } => SummaryState::Kll(KllSketch::new(*k as u16)), - DeltaSketchKind::Hll { precision } => { - SummaryState::Hll(HllSketch::new(HllVariant::Regular, *precision)) - } - DeltaSketchKind::Cms { rows, cols } => { - SummaryState::Cms(CountMinSketch::new(*rows, *cols)) - } - DeltaSketchKind::CountSketch { rows, cols } => { - SummaryState::CountSketch(CountSketch::new(*rows, *cols)) - } - DeltaSketchKind::CmsWithHeap { - rows, - cols, - heap_size, - } => SummaryState::CmsWithHeap(CountMinSketchWithHeap::new(*rows, *cols, *heap_size)), - DeltaSketchKind::CountSketchWithHeap { - rows, - cols, - heap_size, - } => SummaryState::CountSketchWithHeap(CountSketchWithHeap::new( - *rows, *cols, *heap_size, - )), - } - } -} - -/// Try to decode a "full" sketch from the bytes (used by both -/// per-window and cumulative modes when the encoding is `*Full`). -fn decode_full( - kind: &DeltaSketchKind, - bytes: &[u8], - encoding: SketchEncoding, -) -> Result { - match (kind, encoding) { - ( - DeltaSketchKind::UnivMon { - heap_size, - sketch_rows, - sketch_cols, - layers, - }, - SketchEncoding::MsgpackFull, - ) => { - let state = asap_physical_operators::accumulators::univmon_accumulator::UnivMonAccumulator::from_bytes(bytes) - .map_err(|e| e.to_string())?; - if state.dimensions() - != ( - *heap_size as usize, - *sketch_rows as usize, - *sketch_cols as usize, - *layers as usize, - ) - { - return Err("UnivMon payload dimensions differ from installed catalog".into()); - } - Ok(SummaryState::UnivMon(state)) - } - (DeltaSketchKind::DDSketch { .. }, SketchEncoding::ProtoFull) => { - let sk = dd_from_proto(bytes)?; - Ok(SummaryState::Dd(sk)) - } - (DeltaSketchKind::DDSketch { .. }, SketchEncoding::MsgpackFull) => { - let sk = DdSketch::from_msgpack(bytes) - .map_err(|e| format!("deserialize DDSketch msgpack: {e}"))?; - Ok(SummaryState::Dd(sk)) - } - (DeltaSketchKind::Hll { .. }, SketchEncoding::ProtoFull) => { - let sk = hll_from_proto(bytes)?; - Ok(SummaryState::Hll(sk)) - } - (DeltaSketchKind::Hll { .. }, SketchEncoding::MsgpackFull) => { - let sk = HllSketch::from_msgpack(bytes) - .map_err(|e| format!("deserialize HllSketch msgpack: {e}"))?; - Ok(SummaryState::Hll(sk)) - } - (DeltaSketchKind::Kll { .. }, SketchEncoding::ProtoFull) => { - let sk = kll_from_proto(bytes)?; - Ok(SummaryState::Kll(sk)) - } - (DeltaSketchKind::Kll { .. }, SketchEncoding::MsgpackFull) => { - let sk = KllSketch::from_msgpack(bytes) - .map_err(|e| format!("deserialize KllSketch msgpack: {e}"))?; - Ok(SummaryState::Kll(sk)) - } - (DeltaSketchKind::Cms { .. }, SketchEncoding::ProtoFull) => { - Ok(SummaryState::Cms(decode_cms_from_proto(bytes)?)) - } - (DeltaSketchKind::Cms { .. }, SketchEncoding::MsgpackFull) => { - Ok(SummaryState::Cms(decode_cms_from_msgpack(bytes)?)) - } - (DeltaSketchKind::CountSketch { .. }, SketchEncoding::ProtoFull) => { - Ok(SummaryState::CountSketch(decode_cs_from_proto(bytes)?)) - } - (DeltaSketchKind::CountSketch { .. }, SketchEncoding::MsgpackFull) => { - Ok(SummaryState::CountSketch(decode_cs_from_msgpack(bytes)?)) - } - // The heap-bearing wire format is msgpack-only in this - // deployment; `decode_cms_with_heap_from_msgpack` is the same - // "Full" decoder the reducer's existing per-frame dispatch falls - // through to for any non-MsgpackDelta encoding. - ( - DeltaSketchKind::CmsWithHeap { .. }, - SketchEncoding::ProtoFull | SketchEncoding::MsgpackFull, - ) => Ok(SummaryState::CmsWithHeap( - decode_cms_with_heap_from_msgpack(bytes)?, - )), - ( - DeltaSketchKind::CountSketchWithHeap { .. }, - SketchEncoding::ProtoFull | SketchEncoding::MsgpackFull, - ) => Ok(SummaryState::CountSketchWithHeap( - decode_cs_with_heap_from_msgpack(bytes)?, - )), - (_, e) => Err(format!("decode_full called with non-Full encoding {e:?}")), - } -} - -/// The reconstructed state one candidate sid contributes — either -/// folded across a window (or several) via delta application, or merged -/// in from another sid's own reconstruction. -pub enum SummaryState { - UnivMon(asap_physical_operators::accumulators::univmon_accumulator::UnivMonAccumulator), - Dd(DdSketch), - Hll(HllSketch), - Kll(KllSketch), - Cms(CountMinSketch), - CountSketch(CountSketch), - /// See `DeltaSketchKind::CmsWithHeap`/`CountSketchWithHeap` for why - /// these are two variants holding two different sketchlib types. - CmsWithHeap(CountMinSketchWithHeap), - CountSketchWithHeap(CountSketchWithHeap), -} - -impl SummaryState { - /// Apply a delta-encoded payload from a window sample. For DD / KLL, - /// the delta is interpreted as a "mergeable fragment" decoded - /// through the same full-state decoder and merged into the - /// rolling state. For HLL, the wire delta is a sparse register - /// update applied via the sketch's `apply_delta`. - /// - /// On encoding mismatch (e.g. trying to apply an HllDelta to a - /// DDSketch rolling state) returns Err. - pub fn apply_delta_bytes( - &mut self, - bytes: &[u8], - encoding: SketchEncoding, - ) -> Result<(), String> { - if !matches!( - encoding, - SketchEncoding::ProtoDelta | SketchEncoding::MsgpackDelta - ) { - return Err(format!( - "apply_delta_bytes called with non-Delta encoding {encoding:?}" - )); - } - match self { - SummaryState::UnivMon(_) => Err("UnivMon requires full pane snapshots".into()), - SummaryState::Dd(sk) => { - match encoding { - // PROTO_DELTA: dispatch on the payload SHAPE, mirroring the - // supported DDSketch frame decoder, which tries the - // full-envelope decode first, then falls back to - // the bucket-delta proto. Two wire shapes can arrive on the - // ProtoDelta channel: - // - // 1. `SketchEnvelope{DdSketchState}` — a full-state - // fragment, mergeable via `DdSketch::merge`. (The edge - // sends this when `compute_delta_against` hits the - // empty-current / undecodable-prior fallback and ships - // a full snapshot tagged as a delta.) - // 2. `DDSketchDelta { buckets: [{index, d_count}] }` — a - // bucket-index delta proto, applied additively. This is - // the COMMON delta_transmission frame the edge emits - // under per-window-reset (`compute_delta(&empty)`). - // - // Before this fix the reducer decoded ONLY shape (1) via - // `decode_full`. A real shape-(2) frame failed with a wire- - // type mismatch on field 1 (delta field 1 = repeated - // submessage; state field 1 = `double alpha`) → the whole - // `quantile_over_time` returned `No result` for every - // delta_transmission DDSketch stream. We wrap the rolling - // `DdSketch` in a transient accumulator so the bucket-delta - // apply lands on `sk` in place. - SketchEncoding::ProtoDelta => { - // Shape (1): full envelope fragment → merge. Try this - // first (cheap decode attempt; a bucket-delta proto - // fails it on the field-1 wire-type mismatch). - if let Ok(SummaryState::Dd(other)) = decode_full( - &DeltaSketchKind::DDSketch { alpha: 0.0 }, - bytes, - SketchEncoding::ProtoFull, - ) { - sk.merge(&other) - .map_err(|e| format!("merge DDSketch delta envelope: {e}"))?; - return Ok(()); - } - // Shape (2): bucket-delta proto → additive apply via the - // SAME decoder the ingest delta path uses. - use asap_physical_operators::accumulators::dd_sketch_accumulator::DDSketchAccumulator; - let mut acc = DDSketchAccumulator { - inner: std::mem::replace(sk, DdSketch::new(sk.alpha)), - sample_p: 1.0, - }; - let res = acc.apply_proto_delta_bytes(bytes); - *sk = acc.inner; - res.map_err(|e| format!("apply DDSketch proto bucket-delta: {e}"))?; - Ok(()) - } - // MSGPACK_DELTA: a serialized full-sketch fragment, mergeable - // via the full-state decoder. Kept for completeness — the - // edge wires PROTO_DELTA for DDSketch today. - SketchEncoding::MsgpackDelta => { - let other = match decode_full( - &DeltaSketchKind::DDSketch { alpha: 0.0 }, - bytes, - SketchEncoding::MsgpackFull, - ) { - Ok(SummaryState::Dd(s)) => s, - Ok(_) => { - return Err( - "decode_full(DDSketch) returned non-DDSketch state".to_string() - ) - } - Err(e) => return Err(e), - }; - sk.merge(&other) - .map_err(|e| format!("merge DDSketch delta: {e}"))?; - Ok(()) - } - _ => unreachable!(), - } - } - SummaryState::Hll(sk) => { - // HLL has a true sparse register delta in the proto - // wire format. Use the same path the precompute - // accumulator uses (`apply_proto_delta_bytes`-style). - if encoding == SketchEncoding::ProtoDelta { - apply_hll_proto_delta(sk, bytes) - } else { - // MsgpackDelta for HLL isn't a sparse encoding; - // it's a serialized HllSketch fragment, mergeable - // via `HllSketch::merge`. - let other = HllSketch::from_msgpack(bytes) - .map_err(|e| format!("deserialize HllSketch (delta-as-msgpack): {e}"))?; - sk.merge(&other) - .map_err(|e| format!("merge HLL delta: {e}"))?; - Ok(()) - } - } - SummaryState::Kll(sk) => { - let full_enc = match encoding { - SketchEncoding::ProtoDelta => SketchEncoding::ProtoFull, - SketchEncoding::MsgpackDelta => SketchEncoding::MsgpackFull, - _ => unreachable!(), - }; - let other = match decode_full(&DeltaSketchKind::Kll { k: 0 }, bytes, full_enc) { - Ok(SummaryState::Kll(s)) => s, - Ok(_) => return Err("decode_full(Kll) returned non-Kll state".to_string()), - Err(e) => return Err(e), - }; - sk.merge(&other) - .map_err(|e| format!("merge KLL delta: {e}"))?; - Ok(()) - } - // CMS/CountSketch/Heap have no true sparse in-place delta - // (unlike DD's bucket-index proto or HLL's register proto, - // above) — every delta frame already decodes into a - // complete, standalone state on its own (the PWR wire - // contract resets to empty at the source), so applying one - // is always "decode independently, then merge". - SummaryState::Cms(sk) => { - if encoding != SketchEncoding::ProtoDelta { - return Err( - "CountMin (heap-less) MSGPACK_DELTA is not a valid producer encoding \ - (msgpack-delta is the heap-bearing form)" - .to_string(), - ); - } - let other = decode_cms_from_proto_delta(bytes)?; - sk.merge(&other) - .map_err(|e| format!("merge CountMinSketch delta: {e}")) - } - SummaryState::CountSketch(sk) => { - if encoding != SketchEncoding::ProtoDelta { - return Err( - "CountSketch (heap-less) MSGPACK_DELTA is not a valid producer encoding \ - (msgpack-delta is the heap-bearing form)" - .to_string(), - ); - } - let other = decode_cs_from_proto_delta(bytes)?; - sk.merge(&other) - .map_err(|e| format!("merge CountSketch delta: {e}")) - } - SummaryState::CmsWithHeap(sk) => { - // Matches the existing per-frame reducer dispatch: only - // MsgpackDelta gets true delta treatment; ProtoDelta (not - // produced for this family in this deployment) falls - // through to the full-msgpack decoder, same as `decode_full`. - let other = if encoding == SketchEncoding::MsgpackDelta { - decode_cms_with_heap_from_msgpack_delta(bytes)? - } else { - decode_cms_with_heap_from_msgpack(bytes)? - }; - sk.merge(&other) - .map_err(|e| format!("merge CmsWithHeap delta: {e}")) - } - SummaryState::CountSketchWithHeap(sk) => { - let other = if encoding == SketchEncoding::MsgpackDelta { - decode_cs_with_heap_from_msgpack_delta(bytes)? - } else { - decode_cs_with_heap_from_msgpack(bytes)? - }; - sk.merge(&other) - .map_err(|e| format!("merge CountSketchWithHeap delta: {e}")) - } - } - } - - pub fn quantile(&self, q: f64) -> f64 { - match self { - SummaryState::Dd(sk) => sk.quantile(q).unwrap_or(0.0), - SummaryState::Kll(sk) => sk.quantile(q), - _ => 0.0, - } - } - - pub fn cardinality(&self) -> f64 { - match self { - SummaryState::Hll(sk) => sk.estimate(), - _ => 0.0, - } - } - - /// The bucket TOTAL — sum of row 0 of the underlying matrix. What a - /// bare `count_over_time`/`sum by (item) (rate(...))`-shaped query - /// (no specific item key) reads out. `0.0` for non-Frequency-family - /// states. - pub fn total(&self) -> f64 { - let matrix = match self { - SummaryState::Cms(c) => c.sketch(), - SummaryState::CountSketch(c) => c.sketch().clone(), - SummaryState::CmsWithHeap(h) => h.sketch_matrix(), - SummaryState::CountSketchWithHeap(h) => h.sketch_matrix(), - _ => return 0.0, - }; - matrix - .first() - .map(|row| row.iter().copied().sum::()) - .unwrap_or(0.0) - } - - /// Per-key point estimate — `count(metric{item="x"})`-shaped queries. - /// Unlike [`Self::topk_items`], no heap is needed: all four Frequency - /// variants (heap-bearing or not) already carry a keyed `estimate` - /// over their matrix. `None` for the quantile/cardinality states, - /// which have no item universe at all. - pub fn estimate(&self, key: &str) -> Option { - match self { - SummaryState::Cms(c) => Some(c.estimate(key)), - SummaryState::CountSketch(c) => Some(c.estimate(key)), - SummaryState::CmsWithHeap(h) => Some(h.estimate(key)), - SummaryState::CountSketchWithHeap(h) => Some(h.estimate(key)), - _ => None, - } - } - - /// Top-k `(key, value)` pairs from the heap, descending by value. - /// `None` for anything other than a heap-bearing state — the - /// heap-less Frequency states (`Cms`/`CountSketch`) carry no item - /// universe to enumerate, and the quantile/cardinality states have - /// no heap at all. - pub fn topk_items(&self) -> Option> { - match self { - SummaryState::CmsWithHeap(h) => Some( - h.topk_heap_items() - .into_iter() - .map(|item| (item.key, item.value)) - .collect(), - ), - SummaryState::CountSketchWithHeap(h) => Some( - h.topk_heap_items() - .into_iter() - .map(|item| (item.key, item.value)) - .collect(), - ), - _ => None, - } - } - - /// Merge `other` into `self` in place — both must be the same sketch - /// family. Used to combine several sids' reconstructed states - /// (`cumulative_summary_state`/`per_window_summary_states`) into one - /// cross-sid answer. `CmsWithHeap`/`CountSketchWithHeap` fall through - /// to the catch-all mismatch arm below like any other mixed pair — - /// and since the two variants now hold distinct sketchlib types - /// (`CountMinSketchWithHeap` vs `CountSketchWithHeap`), there is no - /// arm that could accidentally match them together — see their doc - /// on `DeltaSketchKind`. - pub fn merge_same_family(&mut self, other: &SummaryState) -> Result<(), String> { - match (self, other) { - (SummaryState::UnivMon(a), SummaryState::UnivMon(b)) => { - a.merge_in_place(b).map_err(|e| e.to_string()) - } - (SummaryState::Dd(a), SummaryState::Dd(b)) => { - a.merge(b).map_err(|e| format!("merge DDSketch: {e}")) - } - (SummaryState::Hll(a), SummaryState::Hll(b)) => { - a.merge(b).map_err(|e| format!("merge HLL: {e}")) - } - (SummaryState::Kll(a), SummaryState::Kll(b)) => { - a.merge(b).map_err(|e| format!("merge KLL: {e}")) - } - (SummaryState::Cms(a), SummaryState::Cms(b)) => { - a.merge(b).map_err(|e| format!("merge CountMinSketch: {e}")) - } - (SummaryState::CountSketch(a), SummaryState::CountSketch(b)) => { - a.merge(b).map_err(|e| format!("merge CountSketch: {e}")) - } - (SummaryState::CmsWithHeap(a), SummaryState::CmsWithHeap(b)) => { - a.merge(b).map_err(|e| format!("merge CmsWithHeap: {e}")) - } - (SummaryState::CountSketchWithHeap(a), SummaryState::CountSketchWithHeap(b)) => a - .merge(b) - .map_err(|e| format!("merge CountSketchWithHeap: {e}")), - (a, _) => Err(format!( - "SummaryState family mismatch in merge_same_family (self is {})", - a.family_name() - )), - } - } - - /// Diagnostic family name for error messages — not used for dispatch. - fn family_name(&self) -> &'static str { - match self { - SummaryState::UnivMon(_) => "UnivMon", - SummaryState::Dd(_) => "DDSketch", - SummaryState::Hll(_) => "Hll", - SummaryState::Kll(_) => "Kll", - SummaryState::Cms(_) => "Cms", - SummaryState::CountSketch(_) => "CountSketch", - SummaryState::CmsWithHeap(_) => "CmsWithHeap", - SummaryState::CountSketchWithHeap(_) => "CountSketchWithHeap", - } - } -} - -/// Fold every in-range window's frames for ONE series into a single -/// merged `SummaryState` (cumulative over `[t0, t1]`), returning `None` -/// if no Full frame ever landed (every sample was a leading delta). The -/// per-sid building block for a cross-sid answer: reconstruct each -/// candidate sid's state this way, then merge them (`merge_same_family`) -/// before reading out a quantile/cardinality over the combined data. -pub fn cumulative_summary_state( - samples: &[(i64, &SketchSampleState)], - kind: DeltaSketchKind, -) -> Result, String> { - let mut rolling: Option = None; - for (_window_end, state) in samples { - match state.encoding { - SketchEncoding::ProtoFull | SketchEncoding::MsgpackFull => { - let new_state = decode_full(&kind, &state.bytes, state.encoding)?; - rolling = Some(match rolling.take() { - None => new_state, - Some(mut prev) => { - prev.merge_same_family(&new_state)?; - prev - } - }); - } - SketchEncoding::ProtoDelta | SketchEncoding::MsgpackDelta => { - if rolling.is_none() { - rolling = Some(kind.bootstrap_empty()); - } - if let Some(rs) = rolling.as_mut() { - rs.apply_delta_bytes(&state.bytes, state.encoding)?; - } - } - } - } - Ok(rolling) -} - -#[cfg(test)] -/// Walk a sorted-by-window-end slice of samples in time order and -/// produce ONE per-window scalar `(window_end_ms, scalar)`. -/// -/// ## Per-window-reset (PWR) delta model -/// -/// The edge emits frames grouped by window (all frames of one window -/// share the same `window_end` key; the key changes across windows). -/// The edge RESETS its snapshot base at each window boundary, so each -/// window's state is built *from empty*: -/// -/// * Within a window, frames accumulate to the window total. The first -/// frame may be a `Full` (window 1, or a periodic re-snapshot) or a -/// `Delta`-from-empty (windows 2+ under PWR); subsequent frames are -/// `Delta` INCREMENTS applied onto the window's running base. -/// * Across windows, the base MUST reset — a new `window_end` discards -/// the previous window's rolling state and starts from empty. Never -/// carry one window's state into the next (that would inflate via -/// cross-window accumulation). -/// -/// Concretely this fixes two bugs in the old "single rolling Option that -/// only ever resets on a Full" walk: -/// 1. A query range whose Full lives only in window 1 (or out of -/// range) left windows 2+ as deltas with `rolling=None`, all -/// skipped → empty result. -/// 2. A window 2+ delta applied onto window 1's leftover rolling state -/// → cross-window inflation. -/// -/// For a `Delta` that is the window's FIRST frame (the PWR delta-from- -/// empty case), we bootstrap an EMPTY rolling state of `kind` and apply -/// the delta onto it (delta-from-empty ⊕ empty = that window's state). -/// -/// The delta-OFF path (exactly one `Full` per window) still produces one -/// correct value per window: the window opens with a Full, has no -/// further frames, and emits that Full's scalar. -/// -/// `eval` reads a scalar from the rolling state (`quantile(q)` / -/// `cardinality()`). `skipped` counts frames that could not contribute -/// (a delta we genuinely couldn't bootstrap from — should be rare). -/// -/// Returns `Ok(per_window_samples, skipped)`. -pub fn per_window_evaluate( - samples: &[(i64, &SketchSampleState)], - kind: DeltaSketchKind, - eval: E, -) -> Result<(Vec<(i64, f64)>, usize), String> -where - E: Fn(&SummaryState) -> f64, -{ - let (states, skipped) = per_window_summary_states(samples, kind)?; - Ok(( - states.into_iter().map(|(w, rs)| (w, eval(&rs))).collect(), - skipped, - )) -} - -/// Walk a sorted-by-window-end slice of samples in time order and -/// reconstruct ONE sid's per-window `SummaryState` (same per-window-reset -/// walk as [`per_window_evaluate`], generalized to return the -/// reconstructed state itself instead of an already-evaluated scalar). -/// The per-sid building block for cross-sid per-window merging (unlike -/// [`cumulative_summary_state`], which folds a whole `[t0, t1]` range -/// into one answer, this keeps each window separate so a caller can -/// merge same-window states across several sids before evaluating -- -/// needed for a matrix/range-query answer, where each output point is -/// itself a cross-sid merge for that one window). -/// -/// Returns `Ok((per_window_states, skipped))`. -pub fn per_window_summary_states( - samples: &[(i64, &SketchSampleState)], - kind: DeltaSketchKind, -) -> Result<(Vec<(i64, SummaryState)>, usize), String> { - let mut out: Vec<(i64, SummaryState)> = Vec::new(); - let mut skipped = 0usize; - - // Rolling state for the CURRENT window only. Reset to None whenever - // `window_end` changes (a new window establishes its own base from - // empty). `cur_end` tracks which window `rolling` belongs to. - let mut rolling: Option = None; - let mut cur_end: Option = None; - - for (window_end, state) in samples { - // Window boundary: flush the previous window's final accumulated - // state, then reset the base so this window starts from empty. - if cur_end != Some(*window_end) { - if let (Some(prev_end), Some(rs)) = (cur_end, rolling.take()) { - out.push((prev_end, rs)); - } - cur_end = Some(*window_end); - } - - match state.encoding { - SketchEncoding::ProtoFull | SketchEncoding::MsgpackFull => { - // A Full (re)sets this window's base. - rolling = Some(decode_full(&kind, &state.bytes, state.encoding)?); - } - SketchEncoding::ProtoDelta | SketchEncoding::MsgpackDelta => { - // Apply onto this window's running base. If this is the - // window's first frame (PWR delta-from-empty), bootstrap - // an empty base and apply onto it. - if rolling.is_none() { - rolling = Some(kind.bootstrap_empty()); - } - match rolling.as_mut() { - Some(rs) => rs.apply_delta_bytes(&state.bytes, state.encoding)?, - None => skipped += 1, - } - } - } - } - - // Flush the final window. - if let (Some(prev_end), Some(rs)) = (cur_end, rolling.take()) { - out.push((prev_end, rs)); - } - - Ok((out, skipped)) -} - -// --------------------------------------------------------------------------- -// Proto-envelope decoders — P2-4: ONE decoder per family. -// -// These delegate to the precompute-side accumulators' -// `from_sketchlib_proto_bytes`, which are the single source of truth for -// the modified-OTLP proto wire format (envelope unwrapping, alpha/k/ -// precision validation, and — critically for HLL — SPARSE -// `registers_sparse` expansion). Folding the warm read path onto the -// same decoder the ingest path uses means the sparse-register fix (and -// any future format change) can never drift between the two copies again -// — the bug class P2-3 / P2-4 closed. We extract the accumulator's -// public `inner` sketch for the rolling-state merge. -// --------------------------------------------------------------------------- - -fn dd_from_proto(buffer: &[u8]) -> Result { - use asap_physical_operators::accumulators::dd_sketch_accumulator::DDSketchAccumulator; - DDSketchAccumulator::from_sketchlib_proto_bytes(buffer) - .map(|acc| acc.inner) - .map_err(|e| e.to_string()) -} - -fn kll_from_proto(buffer: &[u8]) -> Result { - use asap_physical_operators::accumulators::datasketches_kll_accumulator::DatasketchesKLLAccumulator; - DatasketchesKLLAccumulator::from_sketchlib_proto_bytes(buffer) - .map(|acc| acc.inner) - .map_err(|e| e.to_string()) -} - -fn hll_from_proto(buffer: &[u8]) -> Result { - use asap_physical_operators::accumulators::hll_sketch_accumulator::HllSketchAccumulator; - HllSketchAccumulator::from_sketchlib_proto_bytes(buffer) - .map(|acc| acc.inner) - .map_err(|e| e.to_string()) -} - -/// Apply a proto-encoded `HllDelta` frame onto the HLL register vector — the -/// delta is a varint-packed (index_delta, value) blob; decode + apply -/// (register-wise max) via the shared sketch library so the unpacking stays a -/// single source of truth. -fn apply_hll_proto_delta(sk: &mut HllSketch, buffer: &[u8]) -> Result<(), String> { - sk.apply_delta_bytes(buffer) - .map_err(|e| format!("apply HLLDelta: {e}"))?; - Ok(()) -} - -#[cfg(test)] -mod tests { - //! P2-3 / P2-4 regression tests for the consolidated single-decoder - //! path. These exercise the family proto decoders that now delegate - //! to the precompute accumulators (the single source of truth), so a - //! divergence between the warm read path and the ingest path — - //! notably the SPARSE-register HLL handling the deleted dead decoder - //! got wrong — fails the build. - use super::*; - use asap_sketchlib::HllVariant; - - fn encode_dd(sk: &DdSketch) -> Vec { - use asap_sketchlib::proto::sketchlib::{sketch_envelope, DdSketchState, SketchEnvelope}; - use prost::Message; - let state = DdSketchState { - alpha: sk.alpha, - store_counts: sk.store_counts.clone(), - store_offset: sk.store_offset, - }; - SketchEnvelope { - sketch_state: Some(sketch_envelope::SketchState::Ddsketch(state)), - ..Default::default() - } - .encode_to_vec() - } - - fn encode_kll(k: u16, items: &[f64]) -> Vec { - use asap_sketchlib::proto::sketchlib::{sketch_envelope, KllState, SketchEnvelope}; - use prost::Message; - let state = KllState { - k: k as u32, - items: items.to_vec(), - levels: vec![], - num_levels: 0, - ..Default::default() - }; - SketchEnvelope { - sketch_state: Some(sketch_envelope::SketchState::Kll(state)), - ..Default::default() - } - .encode_to_vec() - } - - fn encode_hll_dense(sk: &HllSketch) -> Vec { - use asap_sketchlib::proto::sketchlib::{ - sketch_envelope, HllVariant as ProtoVariant, HyperLogLogState, SketchEnvelope, - }; - use prost::Message; - let state = HyperLogLogState { - variant: ProtoVariant::Regular as i32, - precision: sk.precision, - registers: sk.registers.clone(), - hip_kxq0: sk.hip_kxq0, - hip_kxq1: sk.hip_kxq1, - hip_est: sk.hip_est, - registers_sparse: None, - }; - SketchEnvelope { - sketch_state: Some(sketch_envelope::SketchState::Hll(state)), - ..Default::default() - } - .encode_to_vec() - } - - /// Build a SPARSE HLL proto frame: dense `registers` left empty, - /// `registers_sparse.packed` = varint (index_delta, value) pairs. - /// This is exactly the wire form a low-cardinality producer emits - /// (sketchlib-go below its dense/sparse crossover) — the frame the - /// DELETED `HllSketch_from_sketchlib_proto_bytes` hard-rejected with - /// "registers has 0 bytes". - fn encode_hll_sparse(precision: u32, nonzero: &[(u64, u8)]) -> Vec { - use asap_sketchlib::proto::sketchlib::{ - sketch_envelope, HllSparseRegisters, HllVariant as ProtoVariant, HyperLogLogState, - SketchEnvelope, - }; - use prost::Message; - // Varint-pack (index_delta, value), ascending index order. - let mut packed: Vec = Vec::new(); - let mut prev: u64 = 0; - let mut put_uvarint = |buf: &mut Vec, mut v: u64| loop { - let b = (v & 0x7f) as u8; - v >>= 7; - if v != 0 { - buf.push(b | 0x80); - } else { - buf.push(b); - break; - } - }; - let mut sorted = nonzero.to_vec(); - sorted.sort_by_key(|(i, _)| *i); - for (idx, val) in &sorted { - put_uvarint(&mut packed, idx - prev); - put_uvarint(&mut packed, *val as u64); - prev = *idx; - } - let state = HyperLogLogState { - variant: ProtoVariant::Regular as i32, - precision, - registers: Vec::new(), // dense field empty → sparse path - hip_kxq0: 0.0, - hip_kxq1: 0.0, - hip_est: 0.0, - // `num_registers` is informational — the decoder expands - // against `expected_len` from precision, not this field. - registers_sparse: Some(HllSparseRegisters { - num_registers: 1u32 << precision, - packed, - }), - }; - SketchEnvelope { - sketch_state: Some(sketch_envelope::SketchState::Hll(state)), - ..Default::default() - } - .encode_to_vec() - } - - #[test] - fn hll_from_proto_accepts_sparse_frame() { - // The consolidated decoder must accept the sparse wire form (the - // deleted dead decoder rejected it). Build a sparse frame setting - // a handful of registers, decode it, and confirm those register - // slots came back set in the dense array. - let precision = 12u32; - let nonzero = [(3u64, 5u8), (100, 2), (4000, 7)]; - let bytes = encode_hll_sparse(precision, &nonzero); - let sk = hll_from_proto(&bytes).expect("sparse HLL frame must decode (P2-3 regression)"); - assert_eq!(sk.registers.len(), 1usize << precision); - for (idx, val) in nonzero { - assert_eq!( - sk.registers[idx as usize], val, - "sparse register {idx} expanded to wrong value" - ); - } - } - - #[test] - fn hll_from_proto_matches_accumulator_decoder() { - // P2-4: the warm read path and the ingest accumulator must decode - // the SAME bytes to the SAME sketch (one source of truth). - use asap_physical_operators::accumulators::hll_sketch_accumulator::HllSketchAccumulator; - let mut sk = HllSketch::new(HllVariant::Regular, 12); - for i in 0..500u64 { - sk.update(format!("item-{i}").as_bytes()); - } - let bytes = encode_hll_dense(&sk); - let via_delta = hll_from_proto(&bytes).expect("delta_apply hll decode"); - let via_acc = HllSketchAccumulator::from_sketchlib_proto_bytes(&bytes) - .expect("accumulator hll decode") - .inner; - assert_eq!( - via_delta.registers, via_acc.registers, - "delta_apply and accumulator must produce identical HLL registers" - ); - assert!((via_delta.estimate() - via_acc.estimate()).abs() < 1e-9); - } - - #[test] - fn dd_from_proto_matches_accumulator_decoder() { - use asap_physical_operators::accumulators::dd_sketch_accumulator::DDSketchAccumulator; - let mut sk = DdSketch::new(0.01); - for v in [1.0, 2.0, 5.0, 5.0, 9.0, 42.0] { - sk.update(v); - } - let bytes = encode_dd(&sk); - let via_delta = dd_from_proto(&bytes).expect("delta_apply dd decode"); - let via_acc = DDSketchAccumulator::from_sketchlib_proto_bytes(&bytes) - .expect("accumulator dd decode") - .inner; - // Same quantile answers from the same bytes through both paths. - assert_eq!(via_delta.quantile(0.5), via_acc.quantile(0.5)); - assert_eq!(via_delta.quantile(0.99), via_acc.quantile(0.99)); - } - - #[test] - fn kll_from_proto_matches_accumulator_decoder() { - use asap_physical_operators::accumulators::datasketches_kll_accumulator::DatasketchesKLLAccumulator; - let items: Vec = (0..200).map(|i| i as f64).collect(); - let bytes = encode_kll(256, &items); - let via_delta = kll_from_proto(&bytes).expect("delta_apply kll decode"); - let via_acc = DatasketchesKLLAccumulator::from_sketchlib_proto_bytes(&bytes) - .expect("accumulator kll decode") - .inner; - assert_eq!(via_delta.quantile(0.5), via_acc.quantile(0.5)); - } - - // ----------------------------------------------------------------- - // Per-window-reset (PWR) delta-apply regression tests. - // - // The edge resets its snapshot base at every window boundary, so a - // window's first frame is either a Full (window 1 / re-snapshot) or - // a Delta-from-empty (windows 2+). The query-side walk must: - // * reset the rolling base when `window_end` changes, - // * bootstrap an empty base for a window's leading Delta, - // * emit ONE value per window (the window's final accumulated - // state), never per-frame and never cross-window-accumulated. - // ----------------------------------------------------------------- - - fn full(bytes: Vec) -> SketchSampleState { - SketchSampleState { - bytes, - encoding: SketchEncoding::ProtoFull, - } - } - fn delta(bytes: Vec) -> SketchSampleState { - SketchSampleState { - bytes, - encoding: SketchEncoding::ProtoDelta, - } - } - - fn dd_over(alpha: f64, vals: &[f64]) -> DdSketch { - let mut sk = DdSketch::new(alpha); - for &v in vals { - sk.update(v); - } - sk - } - - /// PWR across 3 windows: window 1 is `[Full]`, windows 2 & 3 are - /// `[Delta-from-empty]` (NO Full carry-in). Each window must - /// reconstruct its OWN distribution's median — not empty (the old - /// "skip delta with no base" bug) and not cross-window-inflated. - #[test] - fn pwr_ddsketch_three_windows_delta_from_empty() { - let alpha = 0.01; - let w1 = dd_over(alpha, &[1.0, 2.0, 3.0, 4.0, 5.0]); - let w2 = dd_over(alpha, &[10.0, 20.0, 30.0, 40.0, 50.0]); - let w3 = dd_over(alpha, &[100.0, 200.0, 300.0, 400.0, 500.0]); - - // window 1 ships a Full; windows 2+ ship a delta-from-empty. - let s1 = full(encode_dd(&w1)); - let s2 = delta(encode_dd(&w2)); - let s3 = delta(encode_dd(&w3)); - let samples = vec![(1000_i64, &s1), (2000, &s2), (3000, &s3)]; - - let kind = DeltaSketchKind::DDSketch { alpha }; - let (out, skipped) = - per_window_evaluate(&samples, kind, |rs| rs.quantile(0.5)).expect("pwr eval"); - assert_eq!(skipped, 0, "PWR must not skip delta-from-empty frames"); - assert_eq!(out.len(), 3, "one value per window"); - - // Each window's median ≈ that window's own distribution median, - // independent of the others (no carry-in inflation). - let truth = [ - w1.quantile(0.5).unwrap(), - w2.quantile(0.5).unwrap(), - w3.quantile(0.5).unwrap(), - ]; - for (i, (w_end, est)) in out.iter().enumerate() { - assert_eq!(*w_end, (i as i64 + 1) * 1000); - let rel = (est - truth[i]).abs() / truth[i].max(1e-9); - assert!( - rel < 0.05, - "window {i}: est={est} truth={} rel={rel}", - truth[i] - ); - } - // Cross-window-inflation guard: window 2's median must NOT have - // absorbed window 1 (would pull it well below 30). - assert!( - out[1].1 > 20.0, - "window 2 median {} suggests cross-window accumulation", - out[1].1 - ); - } - - /// Sub-window producer: a SINGLE window carries multiple frames - /// `[Full, Delta, Delta]`, where each later delta is an increment - /// since the previous emit in that window. The walk must COLLAPSE - /// them to ONE value = the window's running total, not emit three. - #[test] - fn pwr_ddsketch_subwindow_frames_collapse_to_window_total() { - let alpha = 0.01; - // Three sub-window increments that together cover 1..=15. - let a = dd_over(alpha, &[1.0, 2.0, 3.0, 4.0, 5.0]); - let b = dd_over(alpha, &[6.0, 7.0, 8.0, 9.0, 10.0]); - let c = dd_over(alpha, &[11.0, 12.0, 13.0, 14.0, 15.0]); - let s_a = full(encode_dd(&a)); - let s_b = delta(encode_dd(&b)); - let s_c = delta(encode_dd(&c)); - // All three share the same window_end (one window, sub-window frames). - let samples = vec![(5000_i64, &s_a), (5000, &s_b), (5000, &s_c)]; - - let kind = DeltaSketchKind::DDSketch { alpha }; - let (out, skipped) = - per_window_evaluate(&samples, kind, |rs| rs.quantile(0.5)).expect("subwindow eval"); - assert_eq!(skipped, 0); - assert_eq!(out.len(), 1, "sub-window frames collapse to ONE value"); - assert_eq!(out[0].0, 5000); - - let truth = dd_over(alpha, &(1..=15).map(|v| v as f64).collect::>()) - .quantile(0.5) - .unwrap(); - let rel = (out[0].1 - truth).abs() / truth.max(1e-9); - assert!(rel < 0.05, "window total est={} truth={truth}", out[0].1); - } - - /// Same sub-window collapse, but the window's FIRST frame is a - /// Delta-from-empty (PWR window 2+ with sub-window frames): - /// `[Delta-from-empty, Delta, Delta]`. - #[test] - fn pwr_ddsketch_subwindow_first_frame_delta_from_empty() { - let alpha = 0.01; - let a = dd_over(alpha, &[1.0, 2.0, 3.0, 4.0, 5.0]); - let b = dd_over(alpha, &[6.0, 7.0, 8.0, 9.0, 10.0]); - let c = dd_over(alpha, &[11.0, 12.0, 13.0, 14.0, 15.0]); - let s_a = delta(encode_dd(&a)); // first frame is delta-from-empty - let s_b = delta(encode_dd(&b)); - let s_c = delta(encode_dd(&c)); - let samples = vec![(9000_i64, &s_a), (9000, &s_b), (9000, &s_c)]; - - let kind = DeltaSketchKind::DDSketch { alpha }; - let (out, skipped) = - per_window_evaluate(&samples, kind, |rs| rs.quantile(0.5)).expect("eval"); - assert_eq!(skipped, 0); - assert_eq!(out.len(), 1); - let truth = dd_over(alpha, &(1..=15).map(|v| v as f64).collect::>()) - .quantile(0.5) - .unwrap(); - let rel = (out[0].1 - truth).abs() / truth.max(1e-9); - assert!(rel < 0.05, "est={} truth={truth}", out[0].1); - } - - /// PWR for HLL across 3 windows, each a Delta-from-empty (sparse - /// register delta). Bootstrapping an EMPTY HLL of the right precision - /// is required (register deltas index into a pre-sized array). Each - /// window's cardinality must reflect its OWN item set. - #[test] - fn pwr_hll_three_windows_delta_from_empty() { - let precision = 12u32; - // Build per-window HLLs, then encode each as a register-delta - // against an EMPTY sketch (= that window's full register state, - // the PWR delta-from-empty wire form). - let empty = HllSketch::new(HllVariant::Regular, precision); - let mut frames = Vec::new(); - let truths = [200usize, 800, 1500]; - for (w, &n) in truths.iter().enumerate() { - let mut sk = HllSketch::new(HllVariant::Regular, precision); - let base = (w as u64) * 100_000; // disjoint item sets per window - for i in 0..n as u64 { - sk.update(format!("u-{}", base + i).as_bytes()); - } - let bytes = sk.compute_delta(&empty, 0); - frames.push((((w as u64) + 1) * 1000, delta(bytes))); - } - let samples: Vec<(i64, &SketchSampleState)> = - frames.iter().map(|(t, s)| (*t as i64, s)).collect(); - - let kind = DeltaSketchKind::Hll { precision }; - let (out, skipped) = - per_window_evaluate(&samples, kind, |rs| rs.cardinality()).expect("hll pwr eval"); - assert_eq!(skipped, 0, "HLL delta-from-empty must bootstrap, not skip"); - assert_eq!(out.len(), 3); - for (i, (_w_end, est)) in out.iter().enumerate() { - let n = truths[i] as f64; - let rel = (est - n).abs() / n; - assert!( - rel < 0.15, - "window {i}: HLL est={est} truth={n} rel={rel} (each window independent)" - ); - } - } - - /// `CmsWithHeap` (min-over-rows estimator, `CountMinSketchWithHeap`) - /// and `CountSketchWithHeap` (median-of-signed-rows estimator, the - /// distinct `CountSketchWithHeap` type) are different sketch - /// algorithms that merely happen to share a storage shape — merging - /// one into the other must be rejected as a family mismatch, the - /// same as merging a `Cms` into a `Kll` would be. Since the two - /// `SummaryState` variants now hold genuinely different Rust types, - /// this is also enforced at compile time — there is no arm in - /// `merge_same_family` that type-checks a mixed pair together. - #[test] - fn cms_with_heap_and_count_sketch_with_heap_are_not_the_same_family() { - use asap_sketchlib::{CountMinSketchWithHeap, CountSketchWithHeap, MessagePackCodec}; - - let mut cms_heap = CountMinSketchWithHeap::new(4, 256, 10); - cms_heap.update("a", 1.0); - let mut cs_heap = CountSketchWithHeap::new(4, 256, 10); - cs_heap.update("b", 1.0); - - let mut a = SummaryState::CmsWithHeap( - CountMinSketchWithHeap::from_msgpack(&cms_heap.to_msgpack().unwrap()).unwrap(), - ); - let b = SummaryState::CountSketchWithHeap( - CountSketchWithHeap::from_msgpack(&cs_heap.to_msgpack().unwrap()).unwrap(), - ); - - match a.merge_same_family(&b) { - Err(msg) => assert!( - msg.contains("family mismatch"), - "expected a family-mismatch error, got: {msg}" - ), - Ok(()) => panic!( - "CmsWithHeap must not merge with CountSketchWithHeap -- \ - different algorithms sharing only a storage shape" - ), - } - } - - fn encode_delta_heap( - rows: u32, - cols: u32, - cells: &[(u32, u32, i64)], - heap: &[(&str, f64)], - heap_size: u64, - ) -> Vec { - #[derive(serde::Serialize)] - struct W<'a>( - bool, - (u32, u32, &'a [(u32, u32, i64)]), - Vec<(String, f64)>, - u64, - ); - let heap_owned: Vec<(String, f64)> = - heap.iter().map(|(k, v)| (k.to_string(), *v)).collect(); - let w = W(true, (rows, cols, cells), heap_owned, heap_size); - rmp_serde::to_vec(&w).expect("encode delta-heap") - } - - /// `SummaryState::CountSketchWithHeap` must decode both FULL and - /// DELTA-HEAP msgpack frames through the genuine - /// `asap_sketchlib::CountSketchWithHeap` (median-of-signed-rows - /// estimator) rather than the CMS-family `CountMinSketchWithHeap` - /// (min-over-rows estimator) it used to alias — the bug this split - /// fixed. Built via real `update()` calls (not a hand-crafted matrix) - /// so the sign-hashed row semantics are genuinely exercised, then - /// checks both decode paths reproduce the same matrix and the same - /// `estimate()` as the in-memory sketch they were encoded from. - #[test] - fn count_sketch_with_heap_full_and_delta_decode_via_new_asap_sketchlib_type() { - use asap_sketchlib::{CountSketchWithHeap, MessagePackCodec}; - - let mut built = CountSketchWithHeap::new(4, 64, 10); - for _ in 0..50 { - built.update("k", 1.0); - } - let expected_matrix = built.sketch_matrix(); - let expected_estimate = built.estimate("k"); - - // FULL path. - let full_bytes = built.to_msgpack().expect("encode full CountSketchWithHeap"); - let full_state = decode_full( - &DeltaSketchKind::CountSketchWithHeap { - rows: 4, - cols: 64, - heap_size: 10, - }, - &full_bytes, - SketchEncoding::MsgpackFull, - ) - .expect("decode_full CountSketchWithHeap"); - match full_state { - SummaryState::CountSketchWithHeap(inner) => { - assert_eq!(inner.sketch_matrix(), expected_matrix); - assert_eq!(inner.estimate("k"), expected_estimate); - } - other => panic!( - "expected CountSketchWithHeap state, got {}", - other.family_name() - ), - } - - // DELTA-HEAP path: same cells + heap against an empty base (PWR - // contract), encoded the way the Go producer does. - let cells: Vec<(u32, u32, i64)> = expected_matrix - .iter() - .enumerate() - .flat_map(|(r, row)| { - row.iter().enumerate().filter_map(move |(c, v)| { - if *v != 0.0 { - Some((r as u32, c as u32, *v as i64)) - } else { - None - } - }) - }) - .collect(); - let heap_pairs: Vec<(String, f64)> = built - .topk_heap_items() - .into_iter() - .map(|item| (item.key, item.value)) - .collect(); - assert!(!heap_pairs.is_empty(), "expected \"k\" in the top-k heap"); - let heap_refs: Vec<(&str, f64)> = - heap_pairs.iter().map(|(k, v)| (k.as_str(), *v)).collect(); - let delta_bytes = encode_delta_heap(4, 64, &cells, &heap_refs, 10); - - let mut rolling = DeltaSketchKind::CountSketchWithHeap { - rows: 4, - cols: 64, - heap_size: 10, - } - .bootstrap_empty(); - rolling - .apply_delta_bytes(&delta_bytes, SketchEncoding::MsgpackDelta) - .expect("apply CountSketchWithHeap delta"); - match rolling { - SummaryState::CountSketchWithHeap(inner) => { - assert_eq!( - inner.sketch_matrix(), - expected_matrix, - "delta path must reconstruct the identical matrix" - ); - assert_eq!(inner.estimate("k"), expected_estimate); - } - other => panic!( - "expected CountSketchWithHeap state, got {}", - other.family_name() - ), - } - } -} +//! Storage uses the Planner-owned state implementation. +pub use asap_physical_operators::stored_state::delta_apply::*; diff --git a/data_plane/src/storage_engines/types/mod.rs b/data_plane/src/storage_engines/types/mod.rs index 9956f5b4..f126e0db 100644 --- a/data_plane/src/storage_engines/types/mod.rs +++ b/data_plane/src/storage_engines/types/mod.rs @@ -12,9 +12,6 @@ pub mod installed_precompute_plan; pub mod precomputed_output; pub mod storage_backend; -pub use asap_physical_operators::key_by_label_values::*; -pub use asap_physical_operators::measurement::*; -pub use asap_physical_operators::traits::*; pub use enums::*; pub use hot_reload_config::*; pub use installed_precompute_plan::*; @@ -32,3 +29,5 @@ pub use crate::query_engines::routing::{ classify_query_shape, BackendStorageRouting, HotReloadBackendStorageRouting, QueryOperatorShape, RoutingTarget, }; + +pub use asap_physical_operators::{traits::*, KeyByLabelValues, Measurement}; diff --git a/data_plane/src/tests/mod.rs b/data_plane/src/tests/mod.rs index 3a179e8b..e016a48f 100644 --- a/data_plane/src/tests/mod.rs +++ b/data_plane/src/tests/mod.rs @@ -6,4 +6,4 @@ pub mod trait_design_tests; #[cfg(test)] pub mod test_utilities; -pub(crate) mod accumulator_fixture; +pub mod accumulator_fixture; diff --git a/docs/design_docs/README.md b/docs/design_docs/README.md index 4a24ab51..9695976a 100644 --- a/docs/design_docs/README.md +++ b/docs/design_docs/README.md @@ -12,6 +12,8 @@ notes and migration gates distinguish implemented behavior from proposed changes - [Summary definitions table and SDS](summary-catalog-sds-architecture.md) owns definition and instance identity, version-scoped state references, readiness and state lifecycle semantics. +- [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 common-library extraction, removal of ASAPCollector dependencies, the two-plan rollout, and backend acceptance/retirement gates. diff --git a/docs/design_docs/query-dag-execution.md b/docs/design_docs/query-dag-execution.md new file mode 100644 index 00000000..2183aea6 --- /dev/null +++ b/docs/design_docs/query-dag-execution.md @@ -0,0 +1,137 @@ +# Shared physical DAG execution + +## Decision and ownership + +ASAPPlanner owns `asap-physical-operators`, its independent DAG runtime and the +post-ASAP IR that defines its computation contract. Precompute engine and query +engine consume the same library. Changes to an IR operation and its execution +implementation can be reviewed in one Planner PR. + +The backend owns source binding, window selection, catalog and storage access, +publication, and protocol conversion. It does not maintain copies of the shared +runtime or generic computation algorithms. JSON and Arrow conversion at the SQL +boundary does not make Arrow the internal representation of every summary. + +The [shared library design](https://github.com/ProjectASAP/ASAPPlanner/blob/feat/shared-physical-operators/docs/design_docs/physical-operators.md) +contains the architecture and DataFusion comparison. DataFusion provides mature +Arrow operators; ASAP's independent runtime directly owns shared producers and +custom summary state formats. Those states need not fit Arrow RecordBatch. + +## Execution model + +A plan is a DAG of typed operations. A producer can have multiple consumers and +executes once per run. Each consumer sees its output independently. The runtime +owns dependency scheduling, bounded buffering, cancellation and request-local +caching of intermediate results. Separate query evaluation times and ingestion +windows have separate execution state. + +Every computation operation can execute at ingestion time or query time. +The phase belongs to the plan, not the operator kind. Ingestion time includes +background work triggered by incoming data. Sources and publication remain +engine responsibilities; phase assignment alone does not provide a missing +source or implementation. + +```mermaid +flowchart LR + Request[Query request] --> Entry[Installed QueryPlanEntry] + Entry --> Bind[Bind shared physical DAG] + Bind --> Run[Execute dependencies and physical operators] + Run --> Result[Convert root output to query response] + Store[SummaryStore] -->|StoredOutputReference| Bind + Exact[Declared external exact source] --> Bind + Planner[post-ASAP physical DAG contract] --> Bind +``` + +The installed plan carries deployment bindings and source references. Planner +operations retain Planner types, parameters, expressions and grouping semantics. +SQL relation nodes bind through the shared Planner binder and execute in one +shared DAG. Their storage frontiers execute together as multiple roots, sharing +upstream work and the enclosing cancellation and memory budget. PromQL bindings convert labeled vectors and windows to native +batches; common arithmetic, aggregation, sorting, limiting and temporal +computation execute in the library. Metric-name presentation and protocol +matching remain deployment bindings. + +## Physical operator coverage and acceptance contract + +An accepted local plan must have an implementation for every reachable +operation, including its types, parameters, grouping and state requirements. +Relation binding checks the same native implementations at installation and +execution. Unsupported operations must be rejected explicitly. Declaring an +external source is a separate deployment choice, not evidence of local support. + +This table lists each operation once. Shared computation can be used in either +phase; the backend column identifies the available deployment bindings. + +| Physical operation | Purpose | Shared library implementation | Backend integration | Missing coverage | +| --- | --- | --- | --- | --- | +| Raw Scan | Read raw input rows | Engines can supply typed batch sources | Local raw Scan is rejected | Local raw source is explicitly deferred | +| Read materialization | Load previously computed state | Shared decoding and delta reconstruction | Catalog/store references, population and window checks | Missing or incompatible state is a runtime error | +| Maintain current-series state | Update values and timestamps of maintained time series | No general table update operation | Remote-write storage path | General row updates; this is not a raw-table executor | +| Read current-series state | Read maintained current values | Typed source interface | Installed identity/capacity and time scope | Arbitrary raw-table reading | +| Scalar | Produce a scalar | Typed source, including nullable values | Query scalar source | General ingestion literal binding | +| Binary | Combine or compare values | Planner numeric/comparison kinds, typed expressions and checked-division domains | Query native Project; ingestion uses shared arithmetic kernels | Arbitrary vector matching and unsupported domains | +| Unary negate | Negate a value | Int64/Float64 expression, null propagation and checked integer overflow | Query native Project | General ingestion expression binding | +| Vector to scalar | Convert an instant vector to a scalar | Native Float64 operator; zero or multiple rows yield NaN | Query native batch binding | General ingestion value binding | +| Exact aggregate | Compute Count/Sum/Avg/Min/Max, including ReduceSum | Native grouped aggregation; checked Int64 Sum, Float64 Sum/Avg, Int64 Count, ordered Min/Max and nullable inputs | Query vector and SQL relation bindings use shared computation | Other aggregate intents, unresolved grouping-without and per-entity relation lowering; no spill | +| Finalize exact accumulator / ExactReadout | Obtain an exact result from state | Shared state merge/finalization and typed batch readout | Ingestion finalization and stored-query readout use shared kernels | Arbitrary state conversion and unsupported exact families | +| Project | Select or compute output columns | Planner scalar expressions and native projection | SQL native DAG; vector expression binding | Unsupported expression functions/types | +| Filter | Keep rows satisfying a predicate | Planner predicates, three-valued boolean logic and native filtering | SQL native DAG | Unsupported predicates/coercions | +| Relational join, including semi-join | Match rows; semi-join retains matching left rows | Inner/left/right/full/cross/semi/anti joins; typed predicates and nullable outer outputs | SQL native DAG; candidate vectors use general semi-join with explicit keys | General ingestion source binding; SQL candidate-pruning source binding | +| Sort | Order rows or values | Stable grouped sort with null placement | Query vector and SQL native Sort | Sort expressions must be projected first; unsupported key types; no spill | +| Limit | Keep a bounded slice per group | Grouped offset/limit across batches | Planner grouped Limit; query vector and SQL native binding | Limit does not rank or match candidate keys | +| Union | Combine compatible input streams | Fair polling of native input streams | Multi-input summary merge | General installed batch-source binding | +| SummaryAgg | Construct summary state | Native builders for exact Sum/Count/Min/Max/Rate/Increase, KLL, DDSketch and HLL; other admitted families have shared update kernels | Ingestion DAG and per-window update paths | General query builder binding; native Int64/keyed updates and other batch families | +| SummaryMerge | Combine compatible summary states | Native grouped merge and shared stored-state merge kernels | Ingestion native DAG; query selects panes and invokes shared state kernels | Cross-family conversion is not a merge; not every stored encoding has a native batch binding | +| SummaryEstimate | Read an approximate result | Shared Planner SketchQuery dispatch; native KLL/DDSketch/HLL batch readout | Stored-state query readout uses shared kernels | Unsupported family/readout combinations; accuracy evidence remains required | +| SummaryJoin | Combine summaries using summary join semantics | No registered implementation | Rejected | Concrete semantics and implementation | +| SummarySubtract | Subtract summary state | No registered implementation | Rejected | Concrete semantics and implementation | +| SummaryDelete | Remove state contributions | No registered implementation | Rejected | Concrete semantics and implementation | +| Temporal computation | Compute Rate/Increase/Sum/Avg/Min/Max/Count over a window | Native window operator using Planner intents; reset-aware rates, Int64 Count | Query range vectors bind to native batches | Other temporal intents; general ingestion window binding | +| Histogram quantile | Interpolate a quantile from histogram buckets | Native window operator using Planner HistogramQuantile intent | Query bucket input binds to native batches | General ingestion histogram source binding | +| Subquery | Evaluate an expression over a time grid | Shared DAG execution and request-local caching of intermediate results | Backend binds a bounded grid of operation/evaluation-time nodes | General ingestion time-grid binding | +| Extension | Execute an additional operation | No universal executor | Unsupported extensions are rejected | A registered implementation for each admitted extension | + +Stored-state codecs and readout kernels are library capabilities; they do not +imply that every family has a native batch builder. Parameter, schema, population +and window compatibility remain mandatory. Availability of an approximate +kernel does not prove its accuracy guarantee. + +## Candidate pruning and grouped ranking + +Candidate pruning is a composed subgraph: + +1. Read candidate keys from a summary. +2. Obtain authoritative values from a declared source. +3. Apply a general semi-join on explicit matching keys. +4. Sort by score and apply Limit independently within each group. + +There is no dedicated MembershipFilter or grouped TopK physical operator. +A global Limit is not a grouped Limit. Candidate completeness belongs to the +pruning certificate; exact scoring and sorting cannot prove that an omitted key +would not have won. Missing authoritative values fail certified pruning. +Best-effort pruning remains explicitly approximate. + +## Precomputation boundary + +| Precomputation mode | Definition | Backend acceptance | +| --- | --- | --- | +| No precomputation | Start from raw data and perform all required computation at query time | Deferred until local raw sources and query-time summary construction are bound | +| Partial precomputation | Reuse stored results or states and compute the remaining query work at query time | Supported stored-state/value DAGs; plans requiring local raw input remain deferred | +| Full precomputation | All data-dependent computation of the query result is completed before the request | Retrieve a prepared result matching the requested query and time scope | + +These definitions are algorithm-independent. KLL tests are examples, not the +definition of any mode. Reading a prebuilt KLL state and estimating its quantile +at query time is partial precomputation of the result. + +## Acceptance + +Library tests exercise shared-producer diamonds, multiple consumers, +backpressure, cancellation, memory accounting, both execution phases, typed +expressions, joins, grouped Sort/Limit and window computations. Backend tests +exercise installed source bindings, stored-state reconstruction, query results, +and rejection of unsupported plans. Tests compare computations against exact +results or declared approximation guarantees as appropriate. + +Local raw Scan and a universal implementation of every Planner aggregate or +extension are not part of this migration. They must not be presented as complete +through fallback routing or by tests supplied with already decoded raw batches. diff --git a/docs/developer_docs/control-plane/physical-compiler.md b/docs/developer_docs/control-plane/physical-compiler.md index 29540073..786b0641 100644 --- a/docs/developer_docs/control-plane/physical-compiler.md +++ b/docs/developer_docs/control-plane/physical-compiler.md @@ -205,7 +205,8 @@ for a query that cannot be executed end to end. Graph traversal is separate from node definitions and store semantics. Activation validates roots, edges, bindings, reachability, and cycles. -Execution uses the validated topological order and memoizes every node result, +The shared physical DAG runtime creates one producer per reachable node and +shares its outputs with bounded buffering within the request, so a shared node in a diamond DAG performs one store/operator execution. A typed node failure follows the entry's explicit fallback route. diff --git a/docs/developer_docs/maintenance-replay.md b/docs/developer_docs/maintenance-replay.md index e40d48e4..2cf3dcfe 100644 --- a/docs/developer_docs/maintenance-replay.md +++ b/docs/developer_docs/maintenance-replay.md @@ -7,7 +7,7 @@ older description of a merge-only adapter is obsolete. ## Plans and runtime capability -Planner emits the actual maintenance-time operations. The control plane binds +Planner emits the actual ingestion-time operations. The control plane binds source and derived `SummaryDefinitionId`s into the existing owned executable DAG, then validates the complete PrecomputePlan against SummaryCatalog. Raw inputs update raw materializations. Derived configurations must not receive raw diff --git a/docs/developer_docs/query-engine/compiler-rule-boundary-review.md b/docs/developer_docs/query-engine/compiler-rule-boundary-review.md index 7c432093..bd00622a 100644 --- a/docs/developer_docs/query-engine/compiler-rule-boundary-review.md +++ b/docs/developer_docs/query-engine/compiler-rule-boundary-review.md @@ -12,7 +12,6 @@ Rechecked after merging main `674b9573` (#699): the four implementation files co 3. **[P2] Native residual lowering reruns Planner to rediscover the selected operator from text.** `control_plane/src/query_plan/logical.rs:537-620` parses original subexpressions and calls `select_summary_default` to construct an equality witness; `selected_aggregate_operator` then extracts the operation from that text. It fails closed on missing/ambiguous witnesses, which protects against blindly choosing an unrelated expression, but a valid selected DAG can become unlowerable when selection policy or representation changes. Carry sufficient explicit readout/operator information in Planner IR and lower that directly. The observed cross-series quantile failure in the old implementation was “native residual substitution requires an exact selected value”. -4. **[P2] CandidateTopK can bypass its selected values child.** `control_plane/src/query_plan.rs:515-603` uses the original top-level PromQL aggregate's input as an `ExternalExact` request when `logical_source` is present, instead of lowering `values`. It retains Planner k/group/completeness and derives membership identity from the selected candidate, but does not establish equivalence between the generated expression and the selected values DAG. A future Planner rewrite of that child would not be faithfully reflected. Lower the selected exact child or carry a verified native-fragment binding in the input contract. 5. **[P2] Legacy binding owns an unconditional TimeRange/Aggregate swap.** `control_plane/src/physical/post_asap/lower.rs:120-141` turns `TimeRange(Aggregate(X))` into `Aggregate(TimeRange(X))` for any aggregate at that position. This is a logical tree rewrite, with no local legality condition for the measure or grouping. Move any required canonicalization and its legality proof to Planner/frontend; the backend should consume the canonical shape. Reachable through the same legacy binding entry as finding 1; no production failure was reproduced in this audit. diff --git a/docs/evaluation/e2e-physical-dag.md b/docs/evaluation/e2e-physical-dag.md index a9cbfe10..0666cefb 100644 --- a/docs/evaluation/e2e-physical-dag.md +++ b/docs/evaluation/e2e-physical-dag.md @@ -157,7 +157,7 @@ jq '.precompute_plan | {materializations, executable_dags}' \ target/physical-dag-inspection/selected.json ``` -Look for explicit maintenance-time operations and frontier bindings. Raw +Look for explicit ingestion-time operations and frontier bindings. Raw materializations receive samples; a derived materialization consumes its bound immutable input program and must not receive raw backfill/collector jobs. The runtime supports only its validated operator, schema, grouping and window