diff --git a/control_plane/src/clickhouse.rs b/control_plane/src/clickhouse.rs index 5a3c92eb1..90a1d048e 100644 --- a/control_plane/src/clickhouse.rs +++ b/control_plane/src/clickhouse.rs @@ -1413,7 +1413,7 @@ mod tests { } #[tokio::test] - async fn compiles_summary_joined_with_exact_table_into_mixed_dag() { + async fn rejects_mixed_snapshots_and_preserves_local_sql_contracts() { let config = materialization( AggregationType::Sum, "value", @@ -1461,14 +1461,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, @@ -1479,6 +1475,16 @@ mod tests { cumulative: true, }], }; + let Err(error) = compile_clickhouse_workload(&request).await else { + panic!("local summary joined to an external table needs a common snapshot proof"); + }; + assert!( + error.to_string().contains("common snapshot proof"), + "{error}" + ); + request.queries[0].sql = + "SELECT sum(value) FROM telemetry WHERE timestamp_ms >= 0 AND timestamp_ms < 2000" + .into(); let publication = compile_clickhouse_workload(&request).await.unwrap(); // A simple installed aggregate publishes the same key as a refresh. let simple_sql = @@ -1682,31 +1688,11 @@ mod tests { > installed.document.nodes.len() ); let entry = publication.query_plan.entries.values().next().unwrap(); - // External SQL retains its literal time range until it can be bound. - assert!(!entry.canonical_query.starts_with("moving-window-v1:")); - assert!(entry + assert!(!entry .nodes .values() .any(|node| matches!(node, crate::query_plan::QueryPlanNode::ExternalExact { .. }))); - assert!(entry.nodes.values().any(|node| matches!( - node, - crate::query_plan::QueryPlanNode::ReadMaterialization { .. } - ))); - assert!(entry.nodes.values().any(|node| matches!( - node, - crate::query_plan::QueryPlanNode::RelationalJoin { .. } - ))); - assert!(entry.nodes.values().any(|node| { - let crate::query_plan::QueryPlanNode::Relational { operation, .. } = node else { - return false; - }; - matches!( - serde_json::from_value::( - operation.clone() - ), - Ok(planner_types::post_asap::ValueOperation::Project { .. }) - ) - })); + assert!(!entry.materialization_bindings().is_empty()); let original = request.queries[0].sql.clone(); let schema = request.tables.get_mut("telemetry").unwrap(); schema.columns[schema.time_index.unwrap()].name = "other_timestamp".into(); diff --git a/control_plane/src/physical/compiler.rs b/control_plane/src/physical/compiler.rs index c53d6fd52..32c131c78 100644 --- a/control_plane/src/physical/compiler.rs +++ b/control_plane/src/physical/compiler.rs @@ -5064,36 +5064,18 @@ pub(crate) mod tests { } #[test] - fn metricsql_counter_gate_preserves_an_independent_summary_sibling() { + fn metricsql_counter_gate_rejects_mixed_input_snapshots() { let mut workload = request("mixed", "max_over_time(m[1m]) + rate(m[1m])"); workload.allow_mixed_summary_and_exact_execution = true; let mut deployment = environment(10_000); deployment.target = PhysicalDeploymentTarget::BackendLocalRemoteWrite; deployment.target_collector_ids.clear(); - let plan = DeploymentPlanCompiler - .compile_metricsql(workload, deployment) - .unwrap(); - assert!(!plan.precompute_plan.materializations.is_empty()); - assert!(plan - .precompute_plan - .materializations - .iter() - .all(|m| !matches!( - m.aggregation_type, - asap_types::AggregationType::Increase | asap_types::AggregationType::Rate - ))); - let entry = plan.query_plan.entries.values().next().unwrap(); - assert!(!entry.materialization_bindings().is_empty()); + let Err(error) = DeploymentPlanCompiler.compile_metricsql(workload, deployment) else { + panic!("mixed local/external snapshots must fail deployment binding"); + }; assert!( - entry.nodes.values().any(|node| matches!( - node, - crate::query_plan::QueryPlanNode::ExternalExact { .. } - | crate::query_plan::QueryPlanNode::Logical { - operator: - crate::query_plan::residual::ResidualQueryOperator::ExactSubquery { .. }, - .. - } - )) + error.to_string().contains("common snapshot proof"), + "{error}" ); } diff --git a/control_plane/src/physical/maintained_population.rs b/control_plane/src/physical/maintained_population.rs index dd1f856ba..b5b7664e6 100644 --- a/control_plane/src/physical/maintained_population.rs +++ b/control_plane/src/physical/maintained_population.rs @@ -84,6 +84,7 @@ pub(super) fn operator( without: input.without, }, lookback_ms: input.lookback_ms, + history_retention_ms: request.query_retention_margin_ms, max_k: spec.max_k as u64, quantiles: spec.quantiles, max_bytes, diff --git a/control_plane/src/physical/workload_cost.rs b/control_plane/src/physical/workload_cost.rs index 963ebccac..bd6d2169b 100644 --- a/control_plane/src/physical/workload_cost.rs +++ b/control_plane/src/physical/workload_cost.rs @@ -1069,7 +1069,7 @@ mod tests { } #[test] - fn mixed_materialization_masks_price_state_and_prometheus_subquery_sources() { + fn materialization_masks_reject_mixed_snapshots_before_costing() { let mut snapshot = fixture(); let q = &mut snapshot.query_workload.repeating_queries.as_mut().unwrap()[0]; q.query = @@ -1082,18 +1082,30 @@ mod tests { assert_eq!( candidates.len(), 5, - "four legal candidate key sets plus native" + "four proposed candidate key sets plus native" ); let mut identities = BTreeSet::new(); + let mut rejected = 0; for candidate in &candidates[..4] { let enabled = candidate .enabled_materialization_keys .as_ref() .unwrap() .len(); - let plan = DeploymentPlanCompiler - .compile_promql(candidate.clone(), environment.clone()) - .unwrap(); + let result = + DeploymentPlanCompiler.compile_promql(candidate.clone(), environment.clone()); + if enabled == 1 { + let Err(error) = result else { + panic!("mixed snapshots must fail binding"); + }; + assert!( + error.to_string().contains("common snapshot proof"), + "{error}" + ); + rejected += 1; + continue; + } + let plan = result.unwrap(); assert!(identities.insert(plan.envelope.plan_id)); let cost = manifest(&plan, &candidate.queries).unwrap(); assert_eq!( @@ -1128,6 +1140,8 @@ mod tests { crate::query_plan::QueryPlanNode::ExactFallback { .. } )))); } + assert_eq!(rejected, 2); + assert_eq!(identities.len(), 2); assert!( !candidates .last() diff --git a/crates/asap_types/src/query_plan.rs b/crates/asap_types/src/query_plan.rs index 452943cca..45a1ec577 100644 --- a/crates/asap_types/src/query_plan.rs +++ b/crates/asap_types/src/query_plan.rs @@ -62,6 +62,13 @@ impl QueryPlan { } pub fn lookup(&self, promql: &str) -> Result<&QueryPlanEntry, QueryPlanError> { + // Installed entries already carry validated canonical identities. The + // common exact spelling requires no serving-time parser invocation. + if let Some(entry) = self.entries.get(promql).filter(|entry| { + entry.language == QueryLanguage::PromQl && entry.canonical_query == promql + }) { + return Ok(entry); + } let identity = canonical_promql(promql)?; self.lookup_canonical(QueryLanguage::PromQl, &identity) } @@ -375,8 +382,40 @@ impl QueryPlanEntry { topological_order(root, &self.nodes) } + /// The current external adapters provide evaluation time, not a snapshot + /// token compatible with local SDS or current-series revisions. + pub fn validate_snapshot_sources(&self) -> Result<(), QueryPlanError> { + use residual::ResidualQueryOperator; + let mut local = false; + let mut external = false; + for id in self.topological_order()? { + match &self.nodes[&id] { + QueryPlanNode::ReadMaterialization { .. } + | QueryPlanNode::Logical { + operator: ResidualQueryOperator::CurrentSeries { .. }, + .. + } => local = true, + QueryPlanNode::ExternalExact { .. } + | QueryPlanNode::Logical { + operator: + ResidualQueryOperator::ExactSubquery { .. } + | ResidualQueryOperator::CandidateExactSubquery { .. }, + .. + } => external = true, + _ => {} + } + } + if local && external { + return Err(QueryPlanError::UnsupportedNode( + "local state and external exact input have no common snapshot proof".into(), + )); + } + Ok(()) + } + /// Validate references, bindings, reachability, and cycles before activation. pub fn validate(&self, available: &BTreeSet) -> Result<(), QueryPlanError> { + self.validate_snapshot_sources()?; if !self.nodes.contains_key(&self.root) { return Err(QueryPlanError::Invalid(format!( "query `{}` has missing root {}", @@ -384,6 +423,7 @@ impl QueryPlanEntry { ))); } for (id, node) in &self.nodes { + validate_native_relation(*id, node)?; if let QueryPlanNode::PhysicalFragment { inputs, dag, @@ -853,3 +893,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::physical_planner::compile_node(&node, &inputs) + .map_err(|e| invalid(e.to_string()))?; + Ok(()) +} + +#[cfg(test)] +mod native_binding_tests { + use super::*; + use planner_types::{ + post_asap::{SummaryFamilyType, SummaryField, SummarySchema, ValueOperation}, + pre_asap::{DataType, Predicate, QueryExpr}, + }; + + // Unsupported expressions fail installation without evaluating any source. + #[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/crates/asap_types/src/query_plan/current_series.rs b/crates/asap_types/src/query_plan/current_series.rs index 4eb69cf18..c99498457 100644 --- a/crates/asap_types/src/query_plan/current_series.rs +++ b/crates/asap_types/src/query_plan/current_series.rs @@ -13,6 +13,9 @@ pub struct SeriesPopulation { pub grouping: Grouping, pub lookback_ms: u64, pub max_input_lag_ms: u64, + /// Admitted historical distance behind ingestion; zero retains current state only. + #[serde(default)] + pub history_retention_ms: u64, pub max_series: usize, pub max_bytes: u64, pub max_k: u64, @@ -28,6 +31,7 @@ impl SeriesPopulation { || self.lookback_ms > i64::MAX as u64 || self.max_input_lag_ms == 0 || self.max_input_lag_ms > self.lookback_ms + || self.history_retention_ms > i64::MAX as u64 || self.max_series == 0 || self.max_series > 100_000 || self.max_bytes == 0 @@ -67,6 +71,7 @@ mod tests { }, lookback_ms: 1_000, max_input_lag_ms: 1_000, + history_retention_ms: 0, max_series: 100, max_bytes: 1_000_000, max_k: 3, diff --git a/data_plane/examples/audit_clickhouse_fallback.rs b/data_plane/examples/audit_clickhouse_fallback.rs index 22eefcd16..02e2f085e 100644 --- a/data_plane/examples/audit_clickhouse_fallback.rs +++ b/data_plane/examples/audit_clickhouse_fallback.rs @@ -172,6 +172,14 @@ async fn main() { "clickhouse_exception_code": headers.get("x-clickhouse-exception-code").and_then(|v| v.to_str().ok()), "result": body, }), + ClickHouseAccelerationOutcome::Failed(error) => json!({ + "id": row.id, + "fallback_requested": false, + "exact_executed": false, + "exact_success": false, + "execution_error": error.to_string(), + "result": body, + }), ClickHouseAccelerationOutcome::Accelerated(_) => json!({ "id": row.id, "fallback_requested": false, diff --git a/data_plane/src/drivers/ingest/prometheus_remote_write.rs b/data_plane/src/drivers/ingest/prometheus_remote_write.rs index 3816c0dbc..4cd9b16dc 100644 --- a/data_plane/src/drivers/ingest/prometheus_remote_write.rs +++ b/data_plane/src/drivers/ingest/prometheus_remote_write.rs @@ -1003,6 +1003,7 @@ mod tests { snapshot_sha256: reference.snapshot_sha256, }; let active = RuntimePhysicalPlan { + readout_programs: Default::default(), envelope: envelope.clone(), summary_catalog: Some(catalog), precompute_plan: PrecomputePlan { diff --git a/data_plane/src/drivers/query/servers/http.rs b/data_plane/src/drivers/query/servers/http.rs index 1199e8867..5eed32a79 100644 --- a/data_plane/src/drivers/query/servers/http.rs +++ b/data_plane/src/drivers/query/servers/http.rs @@ -1109,6 +1109,11 @@ async fn try_answer_freshness_probe( /// gorilla engine's `plan_from_ast` does. fn parse_last_over_time_probe(query: &str) -> Option<(String, i64)> { use promql_parser::parser::{parse, Expr}; + // Every accepted probe contains this function token. Avoid constructing + // the PromQL grammar for unrelated installed queries on each HTTP request. + if !query.contains("last_over_time") { + return None; + } let expr = parse(query).ok()?; fn unwrap<'a>(expr: &'a Expr) -> &'a Expr { match expr { @@ -2509,6 +2514,7 @@ mod tests { .unwrap(); let active = crate::storage_engines::types::ActivePhysicalPlanHandle::new( crate::storage_engines::types::RuntimePhysicalPlan { + readout_programs: Default::default(), envelope: envelope.clone(), summary_catalog: Some(Arc::clone(&catalog)), precompute_plan: PrecomputePlan { @@ -5439,6 +5445,7 @@ pub fn validate_and_build_runtime_plan( None => default_routing, }; Ok(crate::storage_engines::types::RuntimePhysicalPlan { + readout_programs: Default::default(), envelope: envelope.clone(), summary_catalog: Some(Arc::new(request.summary_catalog)), precompute_plan: request.precompute_plan, diff --git a/data_plane/src/main.rs b/data_plane/src/main.rs index 7061350cd..d5b9e5d7a 100644 --- a/data_plane/src/main.rs +++ b/data_plane/src/main.rs @@ -1084,6 +1084,7 @@ async fn main() -> Result<()> { if active_physical_plan.active_snapshot().plan_id() == 0 { let current = active_physical_plan.active_snapshot(); active_physical_plan.swap(data_plane::storage_engines::types::RuntimePhysicalPlan { + readout_programs: Default::default(), envelope: current.envelope.clone(), summary_catalog: current.summary_catalog.clone(), precompute_plan: current.precompute_plan.clone(), diff --git a/data_plane/src/precompute_engine/maintenance_runtime.rs b/data_plane/src/precompute_engine/maintenance_runtime.rs index a48154c87..5eaaaa9a6 100644 --- a/data_plane/src/precompute_engine/maintenance_runtime.rs +++ b/data_plane/src/precompute_engine/maintenance_runtime.rs @@ -4196,6 +4196,7 @@ mod tests { }, )]); let active = RuntimePhysicalPlan { + readout_programs: Default::default(), envelope: bundle.precompute_plan.envelope.clone(), summary_catalog: Some(Arc::new(bundle.summary_catalog)), precompute_plan: bundle.precompute_plan, diff --git a/data_plane/src/precompute_engine/worker.rs b/data_plane/src/precompute_engine/worker.rs index d957c0bf4..dcb4127c7 100644 --- a/data_plane/src/precompute_engine/worker.rs +++ b/data_plane/src/precompute_engine/worker.rs @@ -4677,7 +4677,7 @@ mod dag_execution_tests { }; assert_eq!( group.exact_value_for(readout, &None, 0, 5000), - Some(answer), + Ok(Some(answer)), "{query}" ); } 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 e50ae3f05..2083b1bf1 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::summary_kernels::SumAccumulator; use async_trait::async_trait; use axum::{ body::Bytes, @@ -21,6 +22,7 @@ use super::{ }; use crate::storage_engines::sketch_db::index::SketchStore; +#[derive(Clone)] pub struct CatalogClickHouseAccelerator { pub store: Arc, active_physical_plan: Option, @@ -68,7 +70,15 @@ impl CatalogClickHouseAccelerator { start_ms: u64, end_ms: u64, request_context: &ClickHouseQueryRequest, - ) -> Result { + ) -> Result { + let miss = |detail: String| { + crate::query_engines::EngineError::capability_miss("clickhouse_input", detail) + }; + entry + .validate_snapshot_sources() + .map_err(|error| miss(error.to_string()))?; + let mut held = crate::query_engines::request::reserve(0)?; + let mut held_bytes = 0usize; let mut prepared = PreparedExternalLeaves::new(); let leaves = entry.nodes.iter().filter_map(|(id, node)| match node { asap_types::query_plan::QueryPlanNode::ExternalExact { request, inputs } @@ -83,12 +93,17 @@ impl CatalogClickHouseAccelerator { let backend = self .exact_backend .as_ref() - .ok_or_else(|| "ClickHouse exact subtree endpoint unavailable".to_owned())?; + .ok_or_else(|| miss("ClickHouse exact subtree endpoint unavailable".to_owned()))?; let schema = match &bound.output { asap_types::query_plan::ExternalExactOutput::Relation { schema } => { - serde_json::from_value(schema.clone()).map_err(|error| error.to_string())? + serde_json::from_value(schema.clone()) + .map_err(|error| miss(error.to_string()))? + } + _ => { + return Err(miss( + "ClickHouse exact subtree must produce a relation".into(), + )) } - _ => return Err("ClickHouse exact subtree must produce a relation".into()), }; let mut parameters = bound .parameters @@ -124,22 +139,36 @@ impl CatalogClickHouseAccelerator { parameters, headers: request_context.headers.clone(), }; - let response = backend - .execute(&request) - .await - .map_err(|error| error.to_string())?; + let response = crate::query_engines::request::wait(backend.execute(&request)) + .await? + .map_err(|error| match error { + super::fallback::ClickHouseFallbackError::Physical(error) => { + crate::query_engines::EngineError::Physical(error) + } + error => miss(error.to_string()), + })?; if !response.status.is_success() { - return Err(format!( + return Err(miss(format!( "ClickHouse external subtree returned HTTP {}", response.status - )); + ))); } + let decode_bytes = response + .body + .len() + .checked_mul(64) + .ok_or(asap_physical_operators::dag::Error::MemoryLimit)?; + let _decode = crate::query_engines::request::reserve(decode_bytes)?; let mut relation = super::relational_adapter::ClickHouseRelation::from_json_compact( &schema, &response.body, ) - .map_err(|error| error.to_string())?; + .map_err(|error| miss(error.to_string()))?; relation.coverage = Some((start_ms, end_ms)); + held_bytes = held_bytes + .checked_add(relation.retained_bytes()) + .ok_or(asap_physical_operators::dag::Error::MemoryLimit)?; + held.resize(held_bytes)?; prepared.insert(id, relation); } Ok(prepared) @@ -219,7 +248,11 @@ impl ClickHouseAccelerator for CatalogClickHouseAccelerator { outcome = self .execute_bound(request, &physical, entry, runtime_range) .await; - if matches!(outcome, ClickHouseAccelerationOutcome::Accelerated(_)) { + if matches!( + outcome, + ClickHouseAccelerationOutcome::Accelerated(_) + | ClickHouseAccelerationOutcome::Failed(_) + ) { return outcome; } } @@ -236,6 +269,37 @@ impl CatalogClickHouseAccelerator { entry: &asap_types::query_plan::QueryPlanEntry, runtime_range: Option<(u64, u64)>, ) -> ClickHouseAccelerationOutcome { + let engine = self.clone(); + let request = request.clone(); + let physical = physical.clone(); + let entry = entry.clone(); + match crate::query_engines::request::run(Default::default(), move |handle| { + Ok(handle.block_on(engine.execute_bound_inner( + &request, + &physical, + &entry, + runtime_range, + ))) + }) + .await + { + Ok(outcome) => outcome, + Err(error) => ClickHouseAccelerationOutcome::Failed(error), + } + } + + async fn execute_bound_inner( + &self, + request: &ClickHouseQueryRequest, + physical: &crate::storage_engines::types::RuntimePhysicalPlan, + entry: &asap_types::query_plan::QueryPlanEntry, + runtime_range: Option<(u64, u64)>, + ) -> ClickHouseAccelerationOutcome { + if let Err(error) = entry.validate_snapshot_sources() { + return ClickHouseAccelerationOutcome::Fallback( + ClickHouseAccelerationFallback::Planning(error.to_string()), + ); + } let format = match requested_format(request) { Ok(format) => format, Err(format) => { @@ -272,19 +336,100 @@ impl CatalogClickHouseAccelerator { range.start_ms = start_ms; range.end_ms = end_ms; } + let revisions = match self.store.revisions.read() { + Ok(runtime) => runtime.clone(), + Err(_) => { + return ClickHouseAccelerationOutcome::Failed( + crate::query_engines::EngineError::Physical( + asap_physical_operators::dag::Error::Invalid( + "revision installation poisoned".into(), + ), + ), + ) + } + }; + let pinned = if let Some(revisions) = revisions { + let bindings = entry.materialization_bindings(); + if bindings.is_empty() { + None + } else { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let required = bindings + .iter() + .map(|binding| binding.stored_output_reference.stored_output_id) + .collect(); + let ranges = bindings + .iter() + .map(|binding| { + ( + binding.stored_output_reference.stored_output_id, + range.start_ms, + range.end_ms, + ) + }) + .collect::>(); + let Some(generation) = physical.precompute_plan.summary_catalog.as_ref() else { + return ClickHouseAccelerationOutcome::Fallback( + ClickHouseAccelerationFallback::CatalogMiss, + ); + }; + match revisions.query_view(&required, now, &ranges, generation) { + Ok(view) => Some(view), + Err(error) => { + if error.is::() { + return ClickHouseAccelerationOutcome::Fallback( + ClickHouseAccelerationFallback::IncompleteCoverage, + ); + } + return ClickHouseAccelerationOutcome::Failed( + crate::query_engines::EngineError::Physical( + match error.downcast::() { + Ok(error) => *error, + Err(error) => asap_physical_operators::dag::Error::Invalid( + error.to_string(), + ), + }, + ), + ); + } + } + } + } else { + None + }; + let _snapshot = match crate::query_engines::request::reserve( + pinned + .as_ref() + .map_or(0, |view| view.approx_resident_bytes()), + ) { + Ok(guard) => guard, + Err(error) => return ClickHouseAccelerationOutcome::Failed(error.into()), + }; let prepared = match self .prepare_external_exact(entry, range.start_ms, range.end_ms, request) .await { Ok(prepared) => prepared, + Err(error @ crate::query_engines::EngineError::Physical(_)) => { + return ClickHouseAccelerationOutcome::Failed(error) + } Err(error) => { return ClickHouseAccelerationOutcome::Fallback( - ClickHouseAccelerationFallback::Execution(error), + ClickHouseAccelerationFallback::Execution(error.to_string()), ) } }; + let _inputs = match crate::query_engines::request::reserve( + prepared.values().map(|v| v.retained_bytes()).sum(), + ) { + Ok(guard) => guard, + Err(error) => return ClickHouseAccelerationOutcome::Failed(error.into()), + }; match execute_sql_dag_with_external( - self.store.as_ref(), + pinned.as_ref().unwrap_or(self.store.as_ref()), entry, catalog.as_ref(), &prepared, @@ -292,39 +437,63 @@ impl CatalogClickHouseAccelerator { range.end_ms, range.cumulative, ) { - ClickHouseDagOutcome::Accelerated(result) => match result.encode(format) { - Ok(body) => { - let mut headers = HeaderMap::new(); - headers.insert( - "content-type", - HeaderValue::from_static(match format { - ClickHouseFormat::Json | ClickHouseFormat::JsonEachRow => { - "application/json; charset=UTF-8" - } - ClickHouseFormat::TabSeparated => { - "text/tab-separated-values; charset=UTF-8" + ClickHouseDagOutcome::Failed(error) => { + ClickHouseAccelerationOutcome::Failed(error.into()) + } + ClickHouseDagOutcome::Accelerated(result) => { + let retained = result + .batches + .iter() + .map(|batch| batch.get_array_memory_size()) + .sum::(); + let _output = match retained + .checked_mul(16) + .ok_or(asap_physical_operators::dag::Error::MemoryLimit) + .and_then(crate::query_engines::request::reserve) + { + Ok(guard) => guard, + Err(error) => return ClickHouseAccelerationOutcome::Failed(error.into()), + }; + match result.encode(format) { + Ok(body) => { + let _response = match crate::query_engines::request::reserve(body.len()) { + Ok(guard) => guard, + Err(error) => { + return ClickHouseAccelerationOutcome::Failed(error.into()) } - }), - ); - let (execution, detail) = if entry.materialization_bindings().is_empty() { - ("exact_fallback", "external_dag") - } else if prepared.is_empty() { - ("warm", "asap") - } else { - ("hybrid", "hybrid") - }; - headers.insert("x-asap-execution", HeaderValue::from_static(execution)); - headers.insert("x-asap-execution-detail", HeaderValue::from_static(detail)); - ClickHouseAccelerationOutcome::Accelerated(ClickHouseRawResponse { - status: StatusCode::OK, - headers, - body: Bytes::from(body), - }) + }; + let mut headers = HeaderMap::new(); + headers.insert( + "content-type", + HeaderValue::from_static(match format { + ClickHouseFormat::Json | ClickHouseFormat::JsonEachRow => { + "application/json; charset=UTF-8" + } + ClickHouseFormat::TabSeparated => { + "text/tab-separated-values; charset=UTF-8" + } + }), + ); + let (execution, detail) = if entry.materialization_bindings().is_empty() { + ("exact_fallback", "external_dag") + } else if prepared.is_empty() { + ("warm", "asap") + } else { + ("hybrid", "hybrid") + }; + headers.insert("x-asap-execution", HeaderValue::from_static(execution)); + headers.insert("x-asap-execution-detail", HeaderValue::from_static(detail)); + ClickHouseAccelerationOutcome::Accelerated(ClickHouseRawResponse { + status: StatusCode::OK, + headers, + body: Bytes::from(body), + }) + } + Err(error) => ClickHouseAccelerationOutcome::Fallback( + ClickHouseAccelerationFallback::Execution(error.to_string()), + ), } - Err(error) => ClickHouseAccelerationOutcome::Fallback( - ClickHouseAccelerationFallback::Execution(error.to_string()), - ), - }, + } ClickHouseDagOutcome::Fallback(ClickHouseDagFallback::IncompleteCoverage { .. }) => ClickHouseAccelerationOutcome::Fallback( @@ -856,7 +1025,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 = @@ -1139,55 +1313,64 @@ mod tests { assert_eq!(accelerated_value, exact_value); } + // Rejecting mixed snapshots must preserve whole-query external execution. #[tokio::test] - async fn summary_and_external_exact_leaf_compose_in_one_query_dag() { + async fn external_only_sql_keeps_native_binding_and_result_encoding() { let (accelerator, request) = fixture(2_000).await; let physical = accelerator .active_physical_plan .as_ref() .unwrap() .active_snapshot(); - let entry = mixed_summary_external_entry( + let mut entry = mixed_summary_external_entry( physical.query_plan.entries.values().next().unwrap().clone(), ); + entry.root = QueryNodeId(6); + entry.nodes.retain(|id, _| *id == entry.root); let accelerator = accelerator.with_exact_backend(Arc::new(FixedExactSubtree)); - let prepared = accelerator - .prepare_external_exact(&entry, 0, 2_000, &request) + let ClickHouseAccelerationOutcome::Accelerated(response) = accelerator + .execute_bound(&request, &physical, &entry, None) .await - .unwrap(); - let ClickHouseDagOutcome::Accelerated(result) = execute_sql_dag_with_external( - accelerator.store.as_ref(), - &entry, - physical.summary_catalog.as_ref().unwrap(), - &prepared, - 0, - 2_000, - true, - ) else { - panic!("mixed summary/external DAG should execute") + else { + panic!("external-only SQL should remain executable"); }; assert_eq!( - String::from_utf8(result.encode(ClickHouseFormat::TabSeparated).unwrap()).unwrap(), - "1970-01-01T00:00:02\t0.5\n" + std::str::from_utf8(&response.body).unwrap(), + "1970-01-01T00:00:02\t10.0\n" ); } #[tokio::test] - async fn real_clickhouse_runtime_differential_uses_production_mixed_constructor() { - let Ok(base_url) = std::env::var("CLICKHOUSE_URL") else { - return; - }; - let (accelerator, mut request) = fixture(2_000).await; - if let Ok(user) = std::env::var("CLICKHOUSE_USER") { - request - .headers - .insert("x-clickhouse-user", user.parse().unwrap()); - } - if let Ok(password) = std::env::var("CLICKHOUSE_PASSWORD") { - request - .headers - .insert("x-clickhouse-key", password.parse().unwrap()); - } + async fn summary_and_external_exact_leaf_requires_snapshot_proof() { + let (accelerator, request) = fixture(2_000).await; + let physical = accelerator + .active_physical_plan + .as_ref() + .unwrap() + .active_snapshot(); + let entry = mixed_summary_external_entry( + physical.query_plan.entries.values().next().unwrap().clone(), + ); + assert!(entry + .validate_snapshot_sources() + .unwrap_err() + .to_string() + .contains("common snapshot proof")); + // Deliberately omit an external backend: rejection must precede I/O. + let error = accelerator + .prepare_external_exact(&entry, 0, 2_000, &request) + .await + .unwrap_err(); + assert!( + error.to_string().contains("common snapshot proof"), + "{error}" + ); + } + + // Even a malformed already-active plan fails before it can call an external source. + #[tokio::test] + async fn active_mixed_plan_is_rejected_before_external_execution() { + let (accelerator, request) = fixture(2_000).await; let physical = accelerator .active_physical_plan .as_ref() @@ -1199,27 +1382,16 @@ mod tests { query_plan.entries.insert(key, entry); let mut active = physical.as_ref().clone(); active.query_plan = Arc::new(query_plan); - let exact_backend = Arc::new(super::super::fallback::ClickHouseHttpFallback::new( - base_url, - "default".into(), - )); let accelerator = CatalogClickHouseAccelerator::with_active_physical_plan_and_exact_backend( accelerator.store.clone(), crate::storage_engines::types::ActivePhysicalPlanHandle::new(active), - exact_backend.clone(), + Arc::new(super::super::fallback::ClickHouseHttpFallback::new( + "http://127.0.0.1:1".into(), + "default".into(), + )), ); - let ClickHouseAccelerationOutcome::Accelerated(response) = - accelerator.execute(&request).await - else { - panic!("real ClickHouse mixed summary/external DAG should accelerate") - }; - let mut exact_request = request; - exact_request.method = Method::POST; - exact_request.sql = "SELECT formatDateTime(toDateTime(2), '%Y-%m-%dT%H:%i:%S') AS timestamp, toFloat64(5) / toFloat64(10) AS ratio FORMAT TabSeparated".into(); - exact_request.body = Bytes::from(exact_request.sql.clone()); - exact_request.parameters.clear(); - let exact = exact_backend.execute(&exact_request).await.unwrap(); - assert_eq!(exact.status, StatusCode::OK); - assert_eq!(response.body, exact.body); + assert!(matches!(accelerator.execute(&request).await, + ClickHouseAccelerationOutcome::Fallback(ClickHouseAccelerationFallback::Planning(reason)) + if reason.contains("common snapshot proof"))); } } 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 3563a60d3..27edd68ae 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,159 +26,363 @@ 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()); +#[cfg(test)] +thread_local! { + // Deterministically publish between branches without timing-dependent threads. + static AFTER_BRANCH: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +#[derive(Debug, thiserror::Error)] +enum SqlExecutionError { + #[error("{0}")] + Binding(String), + #[error(transparent)] + Physical(#[from] asap_physical_operators::dag::Error), +} +impl From for SqlExecutionError { + fn from(value: String) -> Self { + Self::Binding(value) + } +} +impl From<&str> for SqlExecutionError { + fn from(value: &str) -> Self { + Self::Binding(value.into()) + } +} +impl From + for SqlExecutionError +{ + fn from( + error: crate::query_engines::asap_query_engine::post_asap_readout::LoweringSkip, + ) -> Self { + use crate::query_engines::asap_query_engine::post_asap_readout::LoweringSkip; + match error { + LoweringSkip::Execution(error) => Self::Physical(error), + error => Self::Binding(format!("incomplete leaf coverage: {error:?}")), + } } - 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()); + 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 + ) + .into()); + } + } + if let Some(relation) = self.memo.get(&root) { + return Ok(relation.clone()); + } + 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; + if let Some(publish) = AFTER_BRANCH.with(|hook| hook.take()) { + publish(self.index); + } + } + + #[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; } - let asap_types::query_plan::ExternalExactOutput::Relation { schema } = &request.output - else { - return Err("ClickHouse external leaf must declare relation output".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 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()); + let op = dag::planner::compile_node( + &node, + &inputs + .iter() + .map(|(_, schema)| Arc::new(schema.clone())) + .collect::>(), + ) + .map_err(|e| e.to_string())?; + operations.insert( + id, + (inputs.iter().map(|(id, _)| id.0).collect::>(), op), + ); + pending.extend(inputs); + } + let compiled = + asap_physical_operators::physical_planner::CompiledPhysicalDag::from_operators( + sources + .iter() + .map(|id| { + ( + id.0, + asap_physical_operators::physical_planner::InputContract::bounded( + Arc::new(schemas[id].clone()), + ), + ) + }) + .collect(), + operations.into_iter().map(|(id, op)| (id.0, op)).collect(), + vec![root.0], + ) + .map_err(|e| e.to_string())?; + let mut resolved_inputs = BTreeMap::new(); + let context = crate::query_engines::request::context(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(), + })?; + 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); + } + } } - 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) + 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(SqlExecutionError::from)?; + 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())?; + resolved_inputs.insert( + id.0, + Box::new( + Operator::source(batch.schema().clone(), vec![batch]) + .map_err(|e| e.to_string())?, + ) as asap_physical_operators::physical_planner::Source<'_>, + ); } - 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()); - } - if !matches!(join_kind, planner_types::pre_asap::JoinKind::Inner) { - return Err("only inner relational joins are executable".into()); + let graph = compiled + .instantiate(resolved_inputs) + .map_err(|e| e.to_string())?; + let mut output = graph.execute(&[root.0], context)?.remove(0); + let mut batches = Vec::new(); + loop { + crate::query_engines::request::check()?; + match output.next().now_or_never() { + Some(Some(Ok(batch))) => batches.push(batch), + Some(Some(Err(error))) => return Err(error.into()), + Some(None) => break, + None => continue, } - 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, - ) - .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, - ) + native::relation(&batches, expected, coverage) + .map_err(|e| SqlExecutionError::Binding(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) - )); + ).into()); + } } + ClickHouseRelation::from_series_rows( + expected_schema, + outcome.series.clone(), + outcome.coverage, + ) + .map_err(|error| SqlExecutionError::Binding(error.to_string())) } - ClickHouseRelation::from_series_rows(expected_schema, outcome.series, outcome.coverage) - .map_err(|error| error.to_string()) + None => Err(format!("published DAG references missing node {}", root.0).into()), } - None => Err(format!("published DAG references missing node {}", root.0)), } } pub enum ClickHouseDagOutcome { Accelerated(ClickHouseQueryResult), Fallback(ClickHouseDagFallback), + Failed(asap_physical_operators::dag::Error), } fn complete_pane_coverage( @@ -202,6 +405,11 @@ pub fn execute_sql_dag_with_external( t1_ms: u64, is_cumulative: bool, ) -> ClickHouseDagOutcome { + if let Err(error) = entry.validate_snapshot_sources() { + return ClickHouseDagOutcome::Fallback(ClickHouseDagFallback::UnsupportedPlan( + error.to_string(), + )); + } let revision = index.summary_update_revision(); let result = execute_sql_dag_with_external_unfenced( index, @@ -212,6 +420,9 @@ pub fn execute_sql_dag_with_external( t1_ms, is_cumulative, ); + if matches!(result, ClickHouseDagOutcome::Failed(_)) { + return result; + } if !revision.matches(index.summary_update_revision()) { return ClickHouseDagOutcome::Fallback(ClickHouseDagFallback::UnsupportedPlan( "summary input changed during SQL DAG evaluation".into(), @@ -238,7 +449,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,18 +484,25 @@ 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") => { + Err(SqlExecutionError::Physical(error)) => return ClickHouseDagOutcome::Failed(error), + Err(SqlExecutionError::Binding(error)) + if error.contains("incomplete leaf coverage") => + { return ClickHouseDagOutcome::Fallback(ClickHouseDagFallback::IncompleteCoverage { requested: (t0_ms, t1_ms), observed: None, @@ -288,8 +510,8 @@ fn execute_sql_dag_with_external_unfenced( } Err(error) => { return ClickHouseDagOutcome::Fallback(ClickHouseDagFallback::UnsupportedPlan( - error, - )) + error.to_string(), + )); } }; let bindings = entry.materialization_bindings(); @@ -315,26 +537,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)); } @@ -342,6 +545,11 @@ fn execute_sql_dag_with_external_unfenced( match execute_query_plan_from_readout(index, entry, base_root, t0_ms, t1_ms, is_cumulative) { Ok(outcome) => outcome, + Err( + crate::query_engines::asap_query_engine::post_asap_readout::LoweringSkip::Execution( + error, + ), + ) => return ClickHouseDagOutcome::Failed(error), Err(error) => { let detail = format!("{error:?}"); if detail.contains("missing materialized pane") @@ -371,67 +579,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 +603,275 @@ fn validate_reachable(entry: &QueryPlanEntry, root: QueryNodeId) -> Result<(), S Ok(()) } +// Validate schemas and graph shape before touching deployment sources. #[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, + } + } + + // SQL's native runtime must preserve exhausted/cancelled errors at its boundary. + #[tokio::test] + async fn sql_native_resource_failure_is_terminal() { + for cancelled in [false, true] { + crate::query_engines::request::run( + asap_physical_operators::dag::Limits { + max_bytes: if cancelled { 4096 } else { 1 }, + ..Default::default() + }, + move |_| { + if cancelled { + crate::query_engines::request::context( + asap_physical_operators::dag::Scope::Query { + evaluation_time_ms: 1000, + revision: 0, + }, + )? + .cancel(); + } + let schema = relation_schema("x"); + let entry = external_entry(&schema); + let mut relation = ClickHouseRelation::from_json_compact( + &schema, + br#"{"meta":[{"name":"x","type":"Int64"}],"data":[[1]]}"#, + ) + .unwrap(); + relation.coverage = Some((0, 1000)); + let result = execute_sql_dag_with_external( + &SketchStore::new(), + &entry, + &SummaryCatalog::from_materializations(1, 1, &[]).unwrap(), + &[(QueryNodeId(0), relation)].into(), + 0, + 1000, + true, + ); + let ClickHouseDagOutcome::Failed(error) = result else { + panic!("native failure became fallback"); + }; + fn is_resource( + error: &asap_physical_operators::dag::Error, + cancelled: bool, + ) -> bool { + use asap_physical_operators::dag::Error; + match error { + Error::Cancelled => cancelled, + Error::MemoryLimit => !cancelled, + Error::AtNode { source, .. } => is_resource(source, cancelled), + _ => false, + } + } + assert!(is_resource(&error, cancelled), "{error:?}"); + Ok(()) + }, + ) + .await + .unwrap(); + } + } + + #[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.to_string().contains("query `shared-external` node 0")); + assert!(error.to_string().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 + ); + } + + /// A publication between query branches invalidates the entire result. + #[test] + fn query_wide_fence_rejects_publication_between_join_branches() { + use planner_types::pre_asap::{JoinKind, Predicate, QueryExpr, ScalarValue}; + let schema = relation_schema("x"); + let mut entry = external_entry(&schema); + entry + .nodes + .insert(QueryNodeId(2), entry.nodes[&QueryNodeId(0)].clone()); + let mut output = schema.clone(); + output.fields.push(schema.fields[0].clone()); + entry.nodes.insert( + QueryNodeId(1), + QueryPlanNode::RelationalJoin { + inputs: [QueryNodeId(0), QueryNodeId(2)], + join_kind: JoinKind::Cross, + pred: serde_json::to_value(Predicate::( + QueryExpr::Literal(ScalarValue::Boolean(true)).into(), + )) + .unwrap(), + left_schema: schema.clone(), + right_schema: schema.clone(), + output_schema: output, + pruning: None, + }, + ); + 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.clone()), + (QueryNodeId(2), relation), + ]); + let index = SketchStore::new(); + let catalog = SummaryCatalog::from_materializations(1, 1, &[]).unwrap(); + let run = + || execute_sql_dag_with_external(&index, &entry, &catalog, &prepared, 0, 1000, false); + assert!(matches!(run(), ClickHouseDagOutcome::Accelerated(_))); + AFTER_BRANCH.with(|hook| { + hook.set(Some(|index| { + use crate::storage_engines::sketch_db::index::{SketchEncoding, SketchSampleState}; + index.append_sample( + 1, + BTreeMap::new(), + (0, 1000), + SketchSampleState { + bytes: vec![1], + encoding: SketchEncoding::ProtoFull, + }, + ); + })) + }); + assert!( + matches!(run(), ClickHouseDagOutcome::Fallback(ClickHouseDagFallback::UnsupportedPlan(reason)) + if reason.contains("changed during SQL DAG")) + ); + assert!(AFTER_BRANCH.with(|hook| hook.get().is_none())); + } + #[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/fallback.rs b/data_plane/src/query_engines/asap_clickhouse_query_engine/fallback.rs index 8fbbfc213..f25f6e59b 100644 --- a/data_plane/src/query_engines/asap_clickhouse_query_engine/fallback.rs +++ b/data_plane/src/query_engines/asap_clickhouse_query_engine/fallback.rs @@ -15,6 +15,8 @@ pub struct ClickHouseRawResponse { #[derive(Debug, thiserror::Error)] pub enum ClickHouseFallbackError { + #[error(transparent)] + Physical(#[from] asap_physical_operators::dag::Error), #[error("ClickHouse request failed: {0}")] Request(String), #[error("ClickHouse returned an invalid response: {0}")] @@ -73,10 +75,22 @@ impl ClickHouseHttpFallback { headers.append(name, value); } } - let body = response - .bytes() - .await - .map_err(|e| ClickHouseFallbackError::Response(e.to_string()))?; + let mut response = response; + let mut body = Vec::new(); + while let Some(chunk) = crate::query_engines::request::wait(response.chunk()) + .await? + .map_err(|e| ClickHouseFallbackError::Response(e.to_string()))? + { + let size = body + .len() + .checked_add(chunk.len()) + .ok_or(asap_physical_operators::dag::Error::MemoryLimit)?; + // No parallel operator work runs while this source is being fetched. + // Check the whole buffered response against the remaining request budget. + let _check = crate::query_engines::request::reserve(size)?; + body.extend_from_slice(&chunk); + } + let body = Bytes::from(body); Ok(ClickHouseRawResponse { status, headers, 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 c0acadd8e..9b55f7ea0 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,13 +18,17 @@ 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 { + #[error(transparent)] + Physical(#[from] asap_physical_operators::dag::Error), #[error("unsupported SQL relational operation: {0}")] Unsupported(String), #[error("SQL column index {0} is outside a {1}-column row")] @@ -197,6 +203,30 @@ pub struct ClickHouseRelation { } impl ClickHouseRelation { + pub(crate) fn retained_bytes(&self) -> usize { + fn cell(value: &Cell) -> usize { + std::mem::size_of::() + + match value { + Cell::Utf8(value) => value.capacity(), + Cell::List(values) | Cell::Struct(values) => values.iter().map(cell).sum(), + Cell::Map(values) => values + .iter() + .map(|(key, value)| cell(key) + cell(value)) + .sum(), + _ => 0, + } + } + self.rows + .iter() + .map(|row| row.iter().map(cell).sum::() + 24) + .sum::() + + self + .fields + .iter() + .map(|(name, _, _)| name.capacity() + 128) + .sum::() + } + pub fn from_json_compact( schema: &SummarySchema, body: &[u8], @@ -371,134 +401,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 +507,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 +831,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 +1209,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 +1245,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 +1291,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 b68077dd1..49fc93018 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 000000000..1888e30c6 --- /dev/null +++ b/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter/native.rs @@ -0,0 +1,158 @@ +//! 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 = asap_physical_operators::physical_planner::compile_node( + &node, + &batches + .iter() + .map(|batch| batch.schema().clone()) + .collect::>(), + ) + .map_err(|error| ClickHouseRelationalError::Unsupported(error.to_string()))?; + let context = crate::query_engines::request::context(dag::Scope::Query { + evaluation_time_ms: coverage.map_or(0, |(_, end)| end as i64), + revision: 0, + }) + .map_err(ClickHouseRelationalError::Physical)?; + let batches = dag::batch_execution::evaluate_inputs(batches, operator, context) + .map_err(ClickHouseRelationalError::Physical)?; + 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_clickhouse_query_engine/server.rs b/data_plane/src/query_engines/asap_clickhouse_query_engine/server.rs index 967d314c2..3633abaa5 100644 --- a/data_plane/src/query_engines/asap_clickhouse_query_engine/server.rs +++ b/data_plane/src/query_engines/asap_clickhouse_query_engine/server.rs @@ -28,7 +28,7 @@ struct ServerState { /// /// Implementations own plan-catalog lookup, planning/binding, DAG execution, /// coverage validation, and ClickHouse result encoding. Every fail-closed -/// outcome is routed to the exact ClickHouse backend by this HTTP adapter. +/// availability miss may route to the exact backend; execution failures terminate. #[async_trait] pub trait ClickHouseAccelerator: Send + Sync { async fn execute(&self, request: &ClickHouseQueryRequest) -> ClickHouseAccelerationOutcome; @@ -69,6 +69,7 @@ impl ClickHouseAccelerationFallback { pub enum ClickHouseAccelerationOutcome { Accelerated(ClickHouseRawResponse), Fallback(ClickHouseAccelerationFallback), + Failed(crate::query_engines::EngineError), } struct DisabledAccelerator; @@ -187,6 +188,9 @@ async fn execute_or_fallback(state: &ServerState, request: &ClickHouseQueryReque .or_insert(axum::http::HeaderValue::from_static("asap")); response } + ClickHouseAccelerationOutcome::Failed(error) => { + (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response() + } ClickHouseAccelerationOutcome::Fallback(reason) => { tracing::info!( failure_stage = reason.stage(), @@ -236,6 +240,32 @@ mod tests { use std::sync::Mutex; use tower::ServiceExt; + // Resource exhaustion and cancellation must never launch an exact request. + #[tokio::test] + async fn physical_failure_does_not_call_exact_fallback() { + for failure in [ + asap_physical_operators::dag::Error::MemoryLimit, + asap_physical_operators::dag::Error::Cancelled, + ] { + let fallback = Arc::new(RecordingFallback::default()); + let accelerator = Arc::new(FixedAccelerator { + outcome: Mutex::new(Some(ClickHouseAccelerationOutcome::Failed(failure.into()))), + }); + let response = + ClickHouseHttpServer::router_with_accelerator(fallback.clone(), accelerator) + .oneshot( + axum::http::Request::builder() + .uri("/?query=SELECT%201") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(fallback.sql.lock().unwrap().is_empty()); + } + } + #[derive(Default)] struct RecordingFallback { sql: Mutex>, diff --git a/data_plane/src/query_engines/asap_query_engine/engine.rs b/data_plane/src/query_engines/asap_query_engine/engine.rs index 9eb19a4e7..b9ff3756a 100644 --- a/data_plane/src/query_engines/asap_query_engine/engine.rs +++ b/data_plane/src/query_engines/asap_query_engine/engine.rs @@ -4,6 +4,20 @@ use tracing::debug; use asap_types::query_requirements::QueryRequirements; use asap_types::KeyByLabelNames; +pub(super) fn finish_query( + result: Result, + unchanged: bool, +) -> Result { + let result = result?; + if !unchanged { + return Err(crate::query_engines::EngineError::capability_miss( + "installed_logical_dag", + "summary input changed during query DAG evaluation", + )); + } + Ok(result) +} + #[derive(Clone)] struct QueryReadinessRequirement { materializations: Vec, @@ -182,6 +196,7 @@ pub struct ASAPQueryEngine { metricsql_exact_subquery_endpoint: Option, query_forwarding_policy: crate::query_engines::QueryForwardingPolicy, exact_subquery_client: reqwest::Client, + execution_limits: asap_physical_operators::dag::Limits, } impl ASAPQueryEngine { @@ -228,17 +243,8 @@ impl ASAPQueryEngine { .map_err(|error| { crate::query_engines::EngineError::capability_miss("query_plan", error.to_string()) })?; - let pinned = self.pinned_for(&physical, planned, &[now_ms])?; - let engine = pinned.as_ref().unwrap_or(self); - let leaves = engine - .prepare_query_inputs(&physical, planned, &[now_ms]) - .await?; - let (mut result, mut stats) = - engine.execute_logical_entry(&physical, planned, &leaves, now_ms)?; - stats.remote_evaluations = leaves.values().map(|leaf| leaf.remote_evaluations).sum(); - stats.remote_rpcs = leaves.values().map(|leaf| leaf.remote_rpcs).sum(); - annotate_logical_execution(&mut result, &stats); - Ok(result) + self.execute_installed_instant(&physical, planned, now_ms) + .await } pub async fn execute_metricsql_range( @@ -270,6 +276,7 @@ impl ASAPQueryEngine { pub fn new(prometheus_scrape_interval: u64) -> Self { Self { prometheus_scrape_interval, + execution_limits: Default::default(), summary_store: None, active_physical_plan: None, exact_subquery_endpoint: None, @@ -299,18 +306,84 @@ impl ASAPQueryEngine { self.query_forwarding_policy = policy; self } + pub fn with_execution_limits(mut self, limits: asap_physical_operators::dag::Limits) -> Self { + self.execution_limits = limits; + self + } + + async fn execute_installed_instant( + &self, + physical: &crate::storage_engines::types::RuntimePhysicalPlan, + entry: &asap_types::query_plan::QueryPlanEntry, + at: u64, + ) -> Result + { + let engine = self.clone(); + let physical = physical.clone(); + let entry = entry.clone(); + crate::query_engines::request::run(self.execution_limits.clone(), move |handle| { + handle.block_on(async { + entry.validate_snapshot_sources().map_err(|e| { + crate::query_engines::EngineError::capability_miss("query_plan", e.to_string()) + })?; + let pinned = engine.pinned_for(&physical, &entry, &[at])?; + let _snapshot = crate::query_engines::request::reserve( + pinned + .as_ref() + .and_then(|engine| engine.summary_store.as_ref()) + .map_or(0, |index| index.approx_resident_bytes()), + )?; + let engine = pinned.as_ref().unwrap_or(&engine); + let leaves = engine + .prepare_query_inputs(&physical, &entry, &[at]) + .await?; + let _inputs = crate::query_engines::request::reserve( + super::logical_dag::prepared_bytes(&leaves), + )?; + let (mut result, mut stats) = + engine.execute_logical_entry(&physical, &entry, &leaves, at)?; + let _result = crate::query_engines::request::reserve(result.retained_bytes())?; + stats.remote_evaluations = + leaves.values().map(|leaf| leaf.remote_evaluations).sum(); + stats.remote_rpcs = leaves.values().map(|leaf| leaf.remote_rpcs).sum(); + annotate_logical_execution(&mut result, &stats); + Ok(result) + }) + }) + .await + } + async fn prepare_query_inputs( &self, physical: &crate::storage_engines::types::RuntimePhysicalPlan, entry: &asap_types::query_plan::QueryPlanEntry, times: &[u64], ) -> Result { + entry.validate_snapshot_sources().map_err(|e| { + crate::query_engines::EngineError::capability_miss("query_plan", e.to_string()) + })?; super::catalog_resolver::validate_entry( physical.summary_catalog.as_deref(), entry, physical.query_plan.plan_id, physical.query_plan.plan_version, )?; + // Native installed DAGs require no remote request preparation. Avoid + // constructing evaluation-grid maps for this common deployment path. + // Subqueries and raw scans still take the checked preparation path. + if entry.nodes.values().all(|node| match node { + asap_types::query_plan::QueryPlanNode::ExternalExact { .. } => false, + asap_types::query_plan::QueryPlanNode::Logical { operator, .. } => !matches!( + operator, + asap_types::query_plan::residual::ResidualQueryOperator::ExactSubquery { .. } + | asap_types::query_plan::residual::ResidualQueryOperator::CandidateExactSubquery { .. } + | asap_types::query_plan::residual::ResidualQueryOperator::Subquery { .. } + | asap_types::query_plan::residual::ResidualQueryOperator::Scan { .. } + ), + _ => true, + }) { + return Ok(super::logical_dag::PreparedLeaves::new()); + } // Candidate-filtered exact cuts have a data dependency: read the // installed membership subtree once, then use that vector to build the // Prometheus selector. Keeping the result as a prepared leaf also means @@ -459,32 +532,10 @@ impl ASAPQueryEngine { .with_label_keys_override(labels.into_keys().collect()) }).collect(), evaluation_ms)); } - let mut subtree = entry.clone(); - subtree.root = root; - let reachable = subtree.topological_order().map_err(|e| { - EngineError::capability_miss("installed_logical_dag", e.to_string()) + let subtree = physical.readout_program(entry, root).map_err(|error| { + EngineError::capability_miss("installed_logical_dag", error) })?; - subtree.nodes.retain(|id, _| reachable.contains(id)); - let bindings: Vec<_> = subtree - .materialization_bindings() - .into_iter() - .cloned() - .collect(); - let windows: std::collections::BTreeSet> = - bindings.iter().map(|b| b.readout_lookback_ms).collect(); - if windows.len() != 1 || windows.contains(&None) || windows.contains(&Some(0)) { - return Err(EngineError::capability_miss( - "installed_logical_dag", - "bound subtree requires one explicit positive window", - )); - } - subtree.instant.lookback_ms = windows - .first() - .copied() - .flatten() - .expect("explicit semantic lookback checked"); - subtree.instant.full_history = false; - subtree.instant.cumulative_readout = true; + let bindings = subtree.materialization_bindings(); let requirement = readiness_requirement(&subtree); let index = index.ok_or_else(|| { EngineError::capability_miss( @@ -497,12 +548,7 @@ impl ASAPQueryEngine { &subtree, evaluation_ms, ) - .map_err(|e| { - EngineError::capability_miss( - "installed_logical_dag", - format!("bound readout failed: {e:?}"), - ) - })?; + .map_err(EngineError::from)?; let active = self.active_physical_plan.as_ref().ok_or_else(|| { EngineError::capability_miss( "installed_logical_dag", @@ -568,17 +614,14 @@ impl ASAPQueryEngine { }, ); let current = index.map(|index| index.summary_update_revision()); - if match (revision, current) { - (Some(before), Some(after)) => !before.matches(after), - (None, None) => false, - _ => true, - } { - return Err(EngineError::capability_miss( - "installed_logical_dag", - "summary input changed during query DAG evaluation", - )); - } - result + finish_query( + result, + match (revision, current) { + (Some(before), Some(after)) => before.matches(after), + (None, None) => true, + _ => false, + }, + ) } async fn execute_logical_range( @@ -589,6 +632,24 @@ impl ASAPQueryEngine { end: u64, step: u64, ) -> Result + { + let engine = self.clone(); + let physical = physical.clone(); + let entry = entry.clone(); + crate::query_engines::request::run(self.execution_limits.clone(), move |handle| { + handle.block_on(engine.execute_logical_range_inner(&physical, &entry, start, end, step)) + }) + .await + } + + async fn execute_logical_range_inner( + &self, + physical: &crate::storage_engines::types::RuntimePhysicalPlan, + entry: &asap_types::query_plan::QueryPlanEntry, + start: u64, + end: u64, + step: u64, + ) -> Result { use crate::query_engines::{ query_result::{QueryResult, RangeVectorElement}, @@ -600,12 +661,27 @@ impl ASAPQueryEngine { "invalid range or more than 11000 evaluations", )); } + entry + .validate_snapshot_sources() + .map_err(|e| EngineError::capability_miss("query_plan", e.to_string()))?; + let _grid = + crate::query_engines::request::reserve(((end - start) / step + 1) as usize * 8)?; let times: Vec = (0..=(end - start) / step) .map(|n| start + n * step) .collect(); let pinned = self.pinned_for(physical, entry, ×)?; + let _snapshot = crate::query_engines::request::reserve( + pinned + .as_ref() + .and_then(|engine| engine.summary_store.as_ref()) + .map_or(0, |index| index.approx_resident_bytes()), + )?; let engine = pinned.as_ref().unwrap_or(self); let leaves = engine.prepare_query_inputs(physical, entry, ×).await?; + let _inputs = + crate::query_engines::request::reserve(super::logical_dag::prepared_bytes(&leaves))?; + let mut retained = crate::query_engines::request::reserve(0)?; + let mut retained_bytes = 0usize; let mut series = std::collections::BTreeMap::, RangeVectorElement>::new(); let mut total = super::logical_dag::ExecutionStats::default(); @@ -618,6 +694,7 @@ impl ASAPQueryEngine { total.remote_evaluations += stats.remote_evaluations; total.remote_rpcs += stats.remote_rpcs; total.remote_branch_evaluations += stats.remote_branch_evaluations; + let _step_result = crate::query_engines::request::reserve(result.retained_bytes())?; let QueryResult::Vector(result) = result else { return Err(EngineError::capability_miss( "installed_logical_dag", @@ -625,6 +702,11 @@ impl ASAPQueryEngine { )); }; for point in result.values { + // Reserve before retaining the next point and its series identity. + retained_bytes = retained_bytes + .checked_add(point.retained_bytes() + 128) + .ok_or(asap_physical_operators::dag::Error::MemoryLimit)?; + retained.resize(retained_bytes)?; let keys = point.label_keys_override.ok_or_else(|| { EngineError::capability_miss( "installed_logical_dag", @@ -788,17 +870,9 @@ impl ASAPQueryEngine { { if let Some(physical) = self.active_physical_plan_snapshot() { if let Ok(entry) = physical.query_plan.lookup(query) { - if entry.nodes.values().any(|node| { - matches!( - node, - asap_types::query_plan::QueryPlanNode::Logical { .. } - | asap_types::query_plan::QueryPlanNode::PhysicalFragment { .. } - ) - }) { - return self - .execute_logical_range(&physical, entry, start_ms, end_ms, step_ms) - .await; - } + return self + .execute_logical_range(&physical, entry, start_ms, end_ms, step_ms) + .await; } } let Some(idx) = self.summary_store.as_ref() else { @@ -881,6 +955,9 @@ impl ASAPQueryEngine { } Ok(result) }).map_err(|reason| { + if let super::post_asap_readout::LoweringSkip::Execution(error) = reason { + return crate::query_engines::EngineError::Physical(error); + } if let Some(req) = Self::requirements_from_query_str(query) { } crate::query_engines::EngineError::capability_miss( @@ -1043,26 +1120,9 @@ impl crate::query_engines::routing::query_engine_routing::QueryEngine for ASAPQu { if let Some(physical) = self.active_physical_plan_snapshot() { if let Ok(entry) = physical.query_plan.lookup(query) { - let pinned = self.pinned_for(&physical, entry, &[now_ms])?; - let engine = pinned.as_ref().unwrap_or(self); - let leaves = engine - .prepare_query_inputs(&physical, entry, &[now_ms]) - .await - .map_err(|error| { - tracing::warn!(query, error = %error, "installed query DAG preparation failed"); - error - })?; - let (mut result, mut stats) = engine - .execute_logical_entry(&physical, entry, &leaves, now_ms) - .map_err(|error| { - tracing::warn!(query, error = %error, "installed query DAG execution failed"); - error - })?; - stats.remote_evaluations = - leaves.values().map(|leaf| leaf.remote_evaluations).sum(); - stats.remote_rpcs = leaves.values().map(|leaf| leaf.remote_rpcs).sum(); - annotate_logical_execution(&mut result, &stats); - return Ok(result); + return self + .execute_installed_instant(&physical, entry, now_ms) + .await; } } // One authoritative warm path: ASAPPlanner post-ASAP DAG → @@ -1135,6 +1195,9 @@ impl crate::query_engines::routing::query_engine_routing::QueryEngine for ASAPQu } Ok((result, t0_ms)) }).map_err(|reason| { + if let super::post_asap_readout::LoweringSkip::Execution(error) = reason { + return crate::query_engines::EngineError::Physical(error); + } if let Some(req) = Self::requirements_from_query_str(query) { } crate::query_engines::EngineError::capability_miss( @@ -2762,6 +2825,15 @@ mod range_stitch_tests { matches!(result, Err(EngineError::CapabilityMiss { .. })), "a missing first pane must fail closed with a CapabilityMiss: {result:?}" ); + // Stored-only range execution reports the actual shared-runtime work. + let complete = engine + .execute_range_promql_modern(query, 60_000, 60_000, 30_000) + .await + .unwrap(); + assert!(complete + .warnings() + .iter() + .any(|w| w.starts_with("asap_logical_stats:raw=0,summary=1,"))); } } diff --git a/data_plane/src/query_engines/asap_query_engine/exact_subqueries.rs b/data_plane/src/query_engines/asap_query_engine/exact_subqueries.rs index 8e9fe425b..19b7dc7da 100644 --- a/data_plane/src/query_engines/asap_query_engine/exact_subqueries.rs +++ b/data_plane/src/query_engines/asap_query_engine/exact_subqueries.rs @@ -296,6 +296,11 @@ pub(super) async fn prepare_external( client: &reqwest::Client, mut prepared: PreparedLeaves, ) -> Result { + entry + .validate_snapshot_sources() + .map_err(|error| miss(error.to_string()))?; + let mut retained = + crate::query_engines::request::reserve(super::logical_dag::prepared_bytes(&prepared))?; // Equivalent exact cuts at the same time share one actual remote request. let mut remote_cache = HashMap::<(QueryLanguage, String, i64), Value>::new(); for ((id, at), leaf) in leaves(entry, times)? { @@ -416,16 +421,34 @@ pub(super) async fn prepare_external( .get(url) .query(&[("query", query.as_str()), ("time", time.as_str())]) }; - let response = request - .send() - .await + let response = crate::query_engines::request::wait(request.send()) + .await? .map_err(|e| miss(format!("exact request failed: {e}")))?; if !response.status().is_success() { return Err(miss(format!("exact endpoint HTTP {}", response.status()))); } - let body: serde_json::Value = response - .json() - .await + let mut response = response; + let mut bytes = Vec::new(); + let mut wire = crate::query_engines::request::reserve(0)?; + while let Some(chunk) = crate::query_engines::request::wait(response.chunk()) + .await? + .map_err(|e| miss(format!("invalid exact response: {e}")))? + { + let next = bytes + .len() + .checked_add(chunk.len()) + .ok_or(asap_physical_operators::dag::Error::MemoryLimit)?; + wire.resize(next)?; + bytes.extend_from_slice(&chunk); + } + // Include JSON decoding workspace as well as the retained wire body. + let _decode = crate::query_engines::request::reserve( + bytes + .len() + .checked_mul(64) + .ok_or(asap_physical_operators::dag::Error::MemoryLimit)?, + )?; + let body: serde_json::Value = serde_json::from_slice(&bytes) .map_err(|e| miss(format!("invalid exact response: {e}")))?; let value = parse_result(&body, at)?; remote_cache.insert(key, value.clone()); @@ -440,6 +463,13 @@ pub(super) async fn prepare_external( remote_rpcs: usize::from(!cached), }, ); + retained.resize( + super::logical_dag::prepared_bytes(&prepared) + + remote_cache + .iter() + .map(|((_, query, _), value)| query.capacity() + value.retained_bytes() + 128) + .sum::(), + )?; } Ok(prepared) } @@ -912,13 +942,12 @@ mod tests { } #[tokio::test] - async fn five_minute_error_ratio_combines_prometheus_cut_with_summary_store() { - use crate::query_engines::query_result::{InstantVectorElement, QueryResult}; + async fn five_minute_error_ratio_rejects_external_and_local_revision_mix() { use crate::storage_engines::sketch_db::{ data::AggKind, index::{Capability, SummarySeriesMetadata}, }; - use crate::storage_engines::types::{KeyByLabelValues, Measurement}; + use crate::storage_engines::types::Measurement; use asap_physical_operators::summary_kernels::IncreaseAccumulator; use asap_types::query_plan::{ residual::BinaryOperation, ExactReadout, MaterializationBinding, PhysicalGrouping, @@ -1034,58 +1063,17 @@ mod tests { let address = listener.local_addr().unwrap(); let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - let prepared = prepare( + let result = prepare( &entry, &[AT], Some(&format!("http://{address}")), &reqwest::Client::new(), ) - .await - .unwrap(); - let (result, stats) = - super::super::logical_dag::execute_installed(&entry, &prepared, AT, |root, at| { - assert_eq!(root, QueryNodeId(2)); - let mut summary = entry.clone(); - summary.root = root; - summary.nodes.retain(|id, _| matches!(id.0, 2 | 3)); - let (outcome, _) = super::super::post_asap_readout::execute_query_plan_instant( - &store, &summary, at, - ) - .map_err(|error| miss(format!("summary readout failed: {error:?}")))?; - let rows = outcome - .series - .into_iter() - .map(|(labels, samples)| { - let (keys, values): (Vec<_>, Vec<_>) = labels.into_iter().unzip(); - Ok(InstantVectorElement::new( - KeyByLabelValues::new_with_labels(values), - samples - .last() - .ok_or_else(|| miss("summary returned no point"))? - .1, - ) - .with_label_keys_override(keys)) - }) - .collect::, EngineError>>()?; - Ok(QueryResult::vector(rows, at)) - }) - .unwrap(); - let QueryResult::Vector(result) = result else { - panic!("instant vector expected") - }; - assert_eq!(result.timestamp, AT); - assert_eq!(result.values.len(), 1); - assert_eq!( - result.values[0].label_keys_override.as_deref(), - Some(&["job".into()][..]) + .await; + assert!( + matches!(result, Err(EngineError::CapabilityMiss { detail, .. }) if detail.contains("common snapshot proof")) ); - assert_eq!(result.values[0].labels.labels, vec!["user-service"]); - assert!((result.values[0].value - 0.1).abs() < 1e-12); - assert_eq!(stats.raw_scan_evaluations, 0); - assert_eq!(stats.remote_evaluations, 1); - assert_eq!(stats.remote_rpcs, 1); - assert_eq!(stats.summary_readout_evaluations, 1); - assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!(calls.load(Ordering::SeqCst), 0); server.abort(); } #[test] 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 b14fe9d17..de0880e54 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; @@ -20,6 +23,30 @@ pub(crate) enum Value { Vector(Vector), Matrix(Matrix, i64, i64), } +impl Value { + pub(crate) fn retained_bytes(&self) -> usize { + fn labels(labels: &Labels) -> usize { + labels + .iter() + .map(|(key, value)| key.capacity() + value.capacity() + 96) + .sum() + } + match self { + Self::Scalar(_) => 8, + Self::Vector(rows) => { + rows.capacity() * std::mem::size_of::<(Labels, f64)>() + + rows.iter().map(|(keys, _)| labels(keys)).sum::() + } + Self::Matrix(rows, ..) => { + rows.capacity() * std::mem::size_of::<(Labels, Vec<(i64, f64)>)>() + + rows + .iter() + .map(|(keys, points)| labels(keys) + points.capacity() * 16) + .sum::() + } + } + } +} #[derive(Debug, Default, Clone)] pub struct ExecutionStats { /// Kept in execution provenance for compatibility; deployed DAGs cannot @@ -84,6 +111,13 @@ pub(crate) struct PreparedLeaf { } pub(crate) type PreparedLeaves = BTreeMap<(QueryNodeId, i64), PreparedLeaf>; +pub(crate) fn prepared_bytes(leaves: &PreparedLeaves) -> usize { + leaves + .values() + .map(|leaf| leaf.value.retained_bytes() + 128) + .sum() +} + pub(crate) fn execute_installed( entry: &QueryPlanEntry, leaves: &PreparedLeaves, @@ -105,25 +139,99 @@ 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(), - context: asap_physical_operators::dag::RunContext::new( - asap_physical_operators::dag::Scope::Query { - evaluation_time_ms: i64::try_from(at) - .map_err(|_| miss("evaluation timestamp overflow"))?, - revision: 0, - }, - asap_physical_operators::dag::Limits::default(), - )?, + 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(physical::Error::Operator( + "installed DAG evaluation budget exceeded".into(), + ) + .into()); + } + let depth = depths[&(id, time)] + 1; + if depth > 128 { + return Err(physical::Error::Operator( + "installed DAG exceeds execution depth of 128".into(), + ) + .into()); + } + 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(EngineError::from)?; + } + let context = crate::query_engines::request::context(physical::Scope::Query { + evaluation_time_ms: at_signed, + revision: 0, + }) + .map_err(EngineError::from)?; + let mut output = graph + .execute(&[0], context) + .map_err(EngineError::from)? + .remove(0); + // I/O is prepared before this synchronous adapter; Pending is a cooperative yield. + let evaluated = loop { + crate::query_engines::request::check()?; + match output.next().now_or_never() { + Some(Some(Ok(value))) => break value.value().clone(), + Some(Some(Err(failure))) => { + return Err(error + .borrow_mut() + .take() + .unwrap_or(EngineError::Physical(failure))); + } + Some(None) => { + return Err(physical::Error::Operator("query DAG produced no result".into()).into()) + } + None => continue, + } }; - 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 @@ -150,25 +258,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, - context: asap_physical_operators::dag::RunContext, } -impl Result> Evaluator<'_, F> { - fn eval(&mut self, id: QueryNodeId, at: i64) -> Result { - if self.context.is_cancelled() { - return Err(asap_physical_operators::Error::Cancelled.into()); - } - 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; @@ -178,36 +284,42 @@ 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.memo.len() >= 200_000 { - return Err(asap_physical_operators::Error::Operator( - "installed DAG evaluation budget exceeded".into(), - ) - .into()); - } - if !self.active.insert((id, at)) { - return Err( - asap_physical_operators::Error::Invalid("cyclic installed DAG".into()).into(), - ); - } - let node = self - .entry - .nodes - .get(&id) - .ok_or_else(|| miss("missing installed node"))? - .clone(); - let value = match node { + let value = match node.clone() { + QueryPlanNode::Scalar { value } => Value::Scalar(native_scalar(value, context)?), + QueryPlanNode::Logical { + operator: ResidualQueryOperator::CurrentSeries { .. }, + .. + } => { + self.stats.summary_readout_evaluations += 1; + from_result((self.callback)( + id, + u64::try_from(at).map_err(|_| miss("negative current-series timestamp"))?, + )?)? + } + QueryPlanNode::Logical { operator, .. } => { + if matches!( + operator, + ResidualQueryOperator::Scan { .. } + | ResidualQueryOperator::ExactSubquery { .. } + | ResidualQueryOperator::CandidateExactSubquery { .. } + ) { + return Err(miss( + "installed Prometheus leaf was not prepared; backend raw execution is forbidden", + )); + } + self.logical(operator, inputs, dependencies, at, context)? + } QueryPlanNode::PhysicalFragment { - inputs, dag, row_input, pruning, + .. } => { let values = inputs .iter() - .map(|id| self.eval(*id, at).and_then(vector)) + .map(|value| vector((**value).clone())) .collect::, _>>()?; if let Some(contract) = &pruning { native_values::validate_pruning( @@ -216,7 +328,7 @@ impl Result> Evaluator<' row_input, contract, at, - self.context.clone(), + context.clone(), )?; if let Some(warning) = pruning_warning(Some(&contract.completeness)) { self.warnings.push(warning); @@ -227,61 +339,37 @@ impl Result> Evaluator<' values, row_input, at, - self.context.clone(), + context.clone(), )?) } - - QueryPlanNode::Scalar { value } => Value::Scalar(value), - QueryPlanNode::Logical { - operator: ResidualQueryOperator::CurrentSeries { .. }, - .. - } => { - self.stats.summary_readout_evaluations += 1; - from_result((self.callback)( - id, - u64::try_from(at).map_err(|_| miss("negative current-series timestamp"))?, - )?)? - } - QueryPlanNode::Logical { operator, inputs } => { - if matches!( - operator, - ResidualQueryOperator::Scan { .. } - | ResidualQueryOperator::ExactSubquery { .. } - | ResidualQueryOperator::CandidateExactSubquery { .. } - ) { - return Err(miss( - "installed Prometheus leaf was not prepared; backend raw execution is forbidden", - )); - } - self.logical(operator, &inputs, at)? - } QueryPlanNode::RelationalJoin { - inputs, join_kind: planner_types::pre_asap::JoinKind::Semi, pred, pruning, left_schema, right_schema, output_schema, + .. } => { - let values = vector(self.eval(inputs[0], at)?)?; - let candidates = vector(self.eval(inputs[1], at)?)?; - let predicate = serde_json::from_value(pred) - .map_err(|_| miss("invalid semi-join predicate"))?; - if let Some(warning) = pruning_warning(pruning.as_ref()) { - self.warnings.push(warning); - } - Value::Vector(native_values::relation( - values, - candidates, - predicate, + let [values, candidates] = inputs else { + return Err(miss("semi-join requires two inputs")); + }; + let selected = native_values::relation( + vector((**values).clone())?, + vector((**candidates).clone())?, + serde_json::from_value(pred) + .map_err(|error| physical::Error::Invalid(error.to_string()))?, std::sync::Arc::new(left_schema), std::sync::Arc::new(right_schema), std::sync::Arc::new(output_schema), - pruning, + pruning.clone(), at, - self.context.clone(), - )?) + context.clone(), + )?; + if let Some(warning) = pruning_warning(pruning.as_ref()) { + self.warnings.push(warning); + } + Value::Vector(selected) } _ => { self.stats.summary_readout_evaluations += 1; @@ -291,20 +379,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() + .map(|value| (**value).clone()) .ok_or_else(|| miss("missing logical input")) }; match operator { @@ -318,172 +406,122 @@ 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, - self.context.clone(), + context.clone(), )?)) } 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, - self.context.clone(), + context.clone(), )?)) } 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)) } @@ -491,6 +529,143 @@ 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::PhysicalFragment { inputs, .. } | 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()).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 pruning_warning(completeness: Option<&CandidateCompleteness>) -> Option { match completeness { None | Some(CandidateCompleteness::Certified { .. }) => None, @@ -510,6 +685,7 @@ fn semi_join( values: Vector, keys: &[(String, String)], completeness: Option<&CandidateCompleteness>, + context: &physical::RunContext, ) -> Result<(Vector, Option), EngineError> { use planner_types::{ post_asap::{SummaryFamilyType, SummaryField, SummarySchema}, @@ -547,13 +723,6 @@ fn semi_join( }) .collect(), ))); - let context = asap_physical_operators::dag::RunContext::new( - asap_physical_operators::dag::Scope::Query { - evaluation_time_ms: 0, - revision: 0, - }, - Default::default(), - )?; let rows = native_values::relation( values, candidates, @@ -563,48 +732,206 @@ fn semi_join( left, completeness.cloned(), 0, - context, + context.clone(), )?; Ok((rows, pruning_warning(completeness))) } -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(EngineError::from)?; + let batches = evaluate_source(source, context.clone()).map_err(EngineError::from)?; + match batches + .first() + .and_then(|b| b.rows().first()) + .and_then(|r| r.first()) + { + Some(Cell::Float64(value)) => Ok(*value), + _ => Err(miss("native scalar source returned invalid output")), } - 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(EngineError::from) +} +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(EngineError::from) +} +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(EngineError::from)?; + 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(EngineError::from)?; + 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(EngineError::from)?; + let rows = native_batch_rows(batch, vec![operator], context)?; + match rows.first().and_then(|row| row.first()) { + Some(Cell::Float64(value)) => Ok(Value::Scalar(*value)), + _ => Err(miss("native scalar conversion returned invalid output")), + } +} fn grouping_key(labels: &Labels, grouping: &Grouping) -> Labels { labels @@ -624,146 +951,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 { - let context = asap_physical_operators::dag::RunContext::new( - asap_physical_operators::dag::Scope::Query { - evaluation_time_ms: 0, - revision: 0, - }, - Default::default(), - ) - .unwrap(); - native_values::limit( - native_values::sort(values, grouping, true, context.clone()).unwrap(), - grouping, - k, - 0, - context, +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(EngineError::from)?; + let limit = Operator::limit(sort.schema(), k, 0, vec![1]).map_err(EngineError::from)?; + 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 { @@ -772,104 +1055,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(EngineError::from)?; + 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(EngineError::from)?; + 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(EngineError::from)?; + let operator = physical::operators::Operator::window(schema, intent, 1, 2, vec![0], window) + .map_err(EngineError::from)?; + native_vector_output(native_batch_rows(batch, vec![operator], context)?, 0, 1) +} + +#[cfg(test)] +fn test_native_context() -> physical::RunContext { + physical::RunContext::new( + physical::Scope::Query { + evaluation_time_ms: 0, + revision: 0, + }, + physical::Limits::default(), + ) + .unwrap() } #[cfg(test)] @@ -980,7 +1344,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); @@ -1004,7 +1370,9 @@ mod topk_tests { (labels(&[("series", "low")]), -1.0), (labels(&[("series", "high")]), 3.0), ], - ); + &test_native_context(), + ) + .unwrap(); let selected = topk_selection( 2, &Grouping { @@ -1012,7 +1380,9 @@ mod topk_tests { without: false, }, selected, - ); + &test_native_context(), + ) + .unwrap(); assert_eq!( selected .iter() @@ -1293,6 +1663,7 @@ mod topk_tests { vec![(Labels::new(), 1.0)], &[("value".into(), "value".into())], None, + &test_native_context(), ) .unwrap(); assert!(rows.is_empty(), "unequal numeric samples matched: {rows:?}"); @@ -1316,6 +1687,7 @@ mod topk_tests { ("zone".into(), "region".into()), ], None, + &test_native_context(), ) .unwrap(); assert_eq!(selected, vec![left[0].clone()]); @@ -1325,6 +1697,7 @@ mod topk_tests { vec![(Labels::new(), a)], &[("value".into(), "value".into())], None, + &test_native_context(), ) .unwrap(); assert_eq!(!rows.is_empty(), matched); @@ -1349,6 +1722,7 @@ mod topk_tests { Some(&CandidateCompleteness::Certified { guarantee: topk_membership_guarantee(), }), + &test_native_context(), ) .unwrap(); let selected = topk_selection( @@ -1358,7 +1732,9 @@ mod topk_tests { without: false, }, selected, - ); + &test_native_context(), + ) + .unwrap(); assert_eq!( selected .iter() @@ -1514,6 +1890,7 @@ mod topk_tests { exact.clone(), &[("pod".into(), "pod".into())], Some(&CandidateCompleteness::BestEffort { guarantee: None }), + &test_native_context(), ) .unwrap(); assert!(warning.unwrap().contains("approximate")); @@ -1528,7 +1905,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 621562ef6..e9c709ac9 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 @@ -261,6 +261,7 @@ pub(super) fn physical( let mut stream = streams.remove(0); let mut result = Vec::new(); loop { + crate::query_engines::request::check()?; match stream.next().now_or_never() { Some(Some(batch)) => { for row in batch?.rows() { 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 b9db4172f..e6c68b79b 100644 --- a/data_plane/src/query_engines/asap_query_engine/mod.rs +++ b/data_plane/src/query_engines/asap_query_engine/mod.rs @@ -6,9 +6,7 @@ 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; pub use crate::storage_engines::sketch_db::query as asap_tier; @@ -20,3 +18,6 @@ pub mod tests; #[cfg(test)] mod test_plan; + +#[cfg(test)] +mod request_tests; 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 178965e5e..000000000 --- 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 cb6c25023..023318b70 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 { @@ -15,7 +16,31 @@ pub enum LoweringSkip { InvalidQueryPlan(String), MaterializationNotReady(String), ExecuteFailed(String), + Execution(dag::Error), } +impl From for crate::query_engines::EngineError { + fn from(error: LoweringSkip) -> Self { + match error { + LoweringSkip::Execution(error) => Self::Physical(error), + other => Self::capability_miss("installed_query_dag", format!("{other:?}")), + } + } +} +fn execution_failure(error: dag::Error) -> LoweringSkip { + fn resource(error: &dag::Error) -> bool { + match error { + dag::Error::MemoryLimit | dag::Error::Cancelled => true, + dag::Error::AtNode { source, .. } => resource(source), + _ => false, + } + } + if resource(&error) { + LoweringSkip::Execution(error) + } else { + LoweringSkip::ExecuteFailed(error.to_string()) + } +} + use crate::query_engines::asap_query_engine::summary_executor::{ GroupState, QueryExecutionContext, SummaryExecutorError, SummaryValue, }; @@ -101,6 +126,8 @@ enum PhysicalQueryOutput { #[derive(Debug, thiserror::Error)] enum PhysicalNodeError { + #[error(transparent)] + Physical(#[from] dag::Error), #[error("materialization/store operation failed: {0:?}")] Store(SummaryExecutorError), #[error("node expected summary state input")] @@ -113,20 +140,27 @@ struct PhysicalQueryRuntime<'a> { language: control_plane::query_plan::QueryLanguage, catalog: Option>, context: QueryExecutionContext<'a>, + // All counter groups in a run use the same resolved evaluation range. + counter_parameters: std::cell::OnceCell>, } -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| match error { + crate::query_engines::EngineError::Physical(error) => { + PhysicalNodeError::Physical(error) + } + other => PhysicalNodeError::Fallback(other.to_string()), + }), QueryPlanNode::Binary { operator, .. } => { let [lhs, rhs] = inputs else { return Err(PhysicalNodeError::ExpectedState); @@ -137,7 +171,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 @@ -223,41 +257,55 @@ impl QueryNodeRuntime for PhysicalQueryRuntime<'_> { let [PhysicalQueryOutput::State { groups, .. }] = inputs else { return Err(PhysicalNodeError::ExpectedState); }; + let no_parameters = std::collections::HashMap::new(); + let parameters = if matches!( + readout, + control_plane::query_plan::ExactReadout::Rate + | control_plane::query_plan::ExactReadout::Increase + ) { + self.counter_parameters.get_or_init(|| { + std::collections::HashMap::from([ + ("range_start_ms".into(), self.context.t0_ms.to_string()), + ("range_end_ms".into(), self.context.t1_ms.to_string()), + ]) + }) + } else { + &no_parameters + }; let mut coverage = None; let values = groups .iter() - .map(|(key, state)| { - fold_coverage(&mut coverage, state.exact_coverage()); - state - .exact_value_for( - *readout, - &None, - self.context.t0_ms, - self.context.t1_ms, - ) - .map(|value| { - ( + .filter_map(|(key, state)| { + let state_coverage = state.exact_coverage(); + fold_coverage(&mut coverage, state_coverage); + let value = + match state.exact_value_with_parameters(*readout, &None, parameters) { + Ok(Some(value)) => value, + Ok(None) => return None, + Err(error) => return Some(Err(PhysicalNodeError::Fallback(error))), + }; + Some(Ok({ + ( + { + let mut labels = key.clone(); + if self.language + == control_plane::query_plan::QueryLanguage::MetricsQl + && !matches!( + readout, + control_plane::query_plan::ExactReadout::Max + | control_plane::query_plan::ExactReadout::Min + ) { - let mut labels = key.clone(); - if self.language - == control_plane::query_plan::QueryLanguage::MetricsQl - && !matches!( - readout, - control_plane::query_plan::ExactReadout::Max - | control_plane::query_plan::ExactReadout::Min - ) - { - labels.remove("__name__"); - } - labels - }, - SummaryValue::Points( - vec![(self.context.t1_ms as i64, value)], - state.exact_coverage(), - ), - ) - }) - .ok_or(PhysicalNodeError::ExpectedState) + labels.remove("__name__"); + } + labels + }, + SummaryValue::Points( + vec![(self.context.t1_ms as i64, value)], + state_coverage, + ), + ) + })) }) .collect::, _>>()?; Ok(PhysicalQueryOutput::Value(values, coverage)) @@ -481,17 +529,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 @@ -500,27 +573,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 = PhysicalNodeError::Physical; + let batch = Batch::try_new(schema.clone(), rows).map_err(error)?; + let operator = Operator::aggregate(schema, vec![0], vec![("value".into(), Reduction::Sum(1))]) + .map_err(error)?; + let output = dag::batch_execution::evaluate_batch(batch, vec![operator], context.clone()) + .map_err(error)?; + let mut result = Vec::new(); + for row in output.iter().flat_map(|batch| batch.rows()) { + let [Value::Int64(index), Value::Float64(sum)] = row.as_slice() else { + return Err(PhysicalNodeError::ExpectedState); + }; + let ((labels, time), (_, row_coverage)) = &groups[*index as usize]; + result.push(( + labels.clone(), + SummaryValue::Points(vec![(*time, *sum)], *row_coverage), + )); + } + Ok(PhysicalQueryOutput::Value(result, coverage)) } fn execute_physical_query_plan( @@ -533,6 +631,145 @@ 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()).collect::>(); + self.runtime + .execute_node(self.id, self.node, &values, &context) + .map_err(|error| match error { + PhysicalNodeError::Physical(error) => error, + other => dag::Error::Operator(format!("query node {}: {other}", 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, + )?; + crate::query_engines::request::drive(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(|value| value.value().clone()) + }, + )))? +} +fn execute_bound_query( + entry: &asap_types::query_plan::QueryPlanEntry, + root: QueryNodeId, + runtime: &PhysicalQueryRuntime<'_>, + revision: u64, +) -> Result { + let context = crate::query_engines::request::context(dag::Scope::Query { + evaluation_time_ms: i64::try_from(runtime.context.t1_ms) + .map_err(|_| dag::Error::Invalid("query time exceeds i64".into()))?, + revision, + })?; + Ok(execute_bound_queries(entry, &[root], runtime, context)?.remove(0)) +} + fn execute_physical_query_payload( index: &SketchStore, entry: &asap_types::query_plan::QueryPlanEntry, @@ -586,6 +823,7 @@ fn execute_physical_query_payload( let revision = index.summary_update_revision(); let result = (|| { let runtime = PhysicalQueryRuntime { + counter_parameters: Default::default(), language: entry.language, catalog: index.summary_catalog_snapshot(), context: QueryExecutionContext { @@ -596,38 +834,25 @@ 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(execution_failure)?; + readout_outcome(output, t1_ms) })(); - if !revision.matches(index.summary_update_revision()) { + finish_readout(result, revision.matches(index.summary_update_revision())) +} + +// A revision race must never replace a terminal physical execution error. +fn finish_readout( + result: Result, + revision_unchanged: bool, +) -> Result { + let outcome = result?; + if !revision_unchanged { return Err(LoweringSkip::ExecuteFailed( "summary input changed during query DAG evaluation".into(), )); } - result + Ok(outcome) } /// `SummaryValue::Points`/`TopK` -> `ASAPTierResult.series`'s row shape. @@ -671,6 +896,86 @@ 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 { + counter_parameters: Default::default(), + 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(execution_failure)?; + 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::*; @@ -678,6 +983,363 @@ mod tests { use asap_types::query_plan::{ExactReadout, PhysicalGrouping, QueryReadout}; use planner_types::pre_asap::ArithmeticOpKind; + // Real DAG resource failures survive both summary-readout and engine error adapters. + #[test] + fn summary_dag_resource_failures_never_become_capability_misses() { + use asap_types::query_plan::{ + FallbackPolicy, InstantExecution, QueryLanguage, QueryPlanEntry, + }; + let store = SketchStore::new(); + let runtime = PhysicalQueryRuntime { + counter_parameters: Default::default(), + language: QueryLanguage::PromQl, + catalog: None, + context: QueryExecutionContext { + index: &store, + t0_ms: 0, + t1_ms: 42, + is_cumulative: false, + allowed_materializations: None, + }, + }; + let entry = QueryPlanEntry { + language: QueryLanguage::PromQl, + query_id: "resource-test".into(), + canonical_query: "1".into(), + fixed_evaluation: None, + root: QueryNodeId(0), + nodes: [(QueryNodeId(0), QueryPlanNode::Scalar { value: 1. })].into(), + instant: InstantExecution { + lookback_ms: 0, + full_history: false, + cumulative_readout: false, + }, + fallback: FallbackPolicy::ExactBackend, + }; + for cancelled in [false, true] { + let context = dag::RunContext::new( + dag::Scope::Query { + evaluation_time_ms: 42, + revision: 1, + }, + dag::Limits { + max_bytes: if cancelled { 4096 } else { 1 }, + ..dag::Limits::default() + }, + ) + .unwrap(); + if cancelled { + context.cancel(); + } + let error = + match execute_bound_queries(&entry, &[entry.root], &runtime, context.clone()) { + Err(error) => error, + Ok(_) => panic!("resource-limited execution succeeded"), + }; + let engine: crate::query_engines::EngineError = execution_failure(error).into(); + assert!( + matches!(engine, crate::query_engines::EngineError::Physical(_)), + "{engine:?}" + ); + assert_eq!(context.retained_bytes(), 0); + } + } + + // Even when a writer invalidates the read fence, resource errors stay terminal. + #[test] + fn revision_change_cannot_mask_terminal_execution_failure() { + for error in [dag::Error::MemoryLimit, dag::Error::Cancelled] { + let result = finish_readout(Err(LoweringSkip::Execution(error)), false); + let engine: crate::query_engines::EngineError = result.err().unwrap().into(); + assert!( + matches!(engine, crate::query_engines::EngineError::Physical(_)), + "{engine:?}" + ); + } + } + + // A sparse counter is omitted without dropping other series in the vector. + #[test] + fn counter_readout_omits_only_series_with_fewer_than_two_samples() { + use crate::storage_engines::types::Measurement; + let idx = SketchStore::new(); + let runtime = PhysicalQueryRuntime { + counter_parameters: Default::default(), + language: control_plane::query_plan::QueryLanguage::PromQl, + catalog: None, + context: QueryExecutionContext { + index: &idx, + t0_ms: 0, + t1_ms: 60_000, + is_cumulative: true, + allowed_materializations: None, + }, + }; + let groups = [1, 2] + .into_iter() + .map(|samples| { + let mut state = asap_physical_operators::summary_kernels::IncreaseAccumulator::new( + Measurement::new(10.0), + 10_000, + Measurement::new(10.0), + 10_000, + ); + if samples == 2 { + state.update(Measurement::new(20.0), 20_000); + } + ( + BTreeMap::from([("instance".into(), samples.to_string())]), + GroupState::ExactAgg { + entries: vec![std::rc::Rc::new(BTreeMap::from([( + 60_000, + std::sync::Arc::new(state) + as std::sync::Arc, + )]))], + agg_type: asap_types::AggregationType::Rate, + }, + ) + }) + .collect(); + let context = dag::RunContext::new( + dag::Scope::Query { + evaluation_time_ms: 60_000, + revision: 0, + }, + dag::Limits::default(), + ) + .unwrap(); + let output = runtime + .execute_node( + QueryNodeId(0), + &QueryPlanNode::ExactReadout { + input: QueryNodeId(1), + readout: control_plane::query_plan::ExactReadout::Rate, + }, + &[&PhysicalQueryOutput::State { + groups, + item_labels: vec![], + }], + &context, + ) + .unwrap(); + let PhysicalQueryOutput::Value(rows, _) = output else { + panic!("value rows required") + }; + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].0["instance"], "2"); + } + + // Empty leading/trailing panes do not invalidate a bounded counter window. + #[test] + fn sparse_counter_panes_preserve_full_requested_range() { + use crate::storage_engines::types::Measurement; + let idx = SketchStore::new(); + let policy = asap_types::PolicyFingerprint(778); + idx.register(SummarySeriesMetadata { + storage_handle: 7, + metric_name: "requests_total".into(), + group_by_keys: std::collections::BTreeSet::new(), + capability: Some(Capability::ExactAgg(asap_types::AggregationType::Rate)), + agg_kind: AggKind::ExactAgg { + agg_type: asap_types::AggregationType::Rate, + parameters_canonical: String::new(), + spatial_filter_canonical: String::new(), + }, + accuracy: None, + first_seen_unix_ms: 0, + retired_at_ms: None, + expires_at_ms: None, + policy_fp: policy, + }); + for (start, time, value) in [(20_000, 25_000, 10.), (40_000, 45_000, 20.)] { + let state = asap_physical_operators::summary_kernels::IncreaseAccumulator::new( + Measurement::new(value), + time, + Measurement::new(value), + time, + ); + idx.append_precompute(7, BTreeMap::new(), (start, start + 10_000), Box::new(state)); + } + let binding = asap_types::query_plan::MaterializationBinding { + full_window_slide_ms: None, + item_labels: vec![], + materialization: policy.into(), + stored_output_reference: super::super::test_plan::bound_reference(&idx, policy.into()), + output_grouping: asap_types::query_plan::PhysicalGrouping::PerEntity, + window_ms: 10_000, + pane_origin_ms: Some(0), + readout_lookback_ms: Some(60_000), + }; + let context = QueryExecutionContext { + index: &idx, + t0_ms: 0, + t1_ms: 60_000, + is_cumulative: true, + allowed_materializations: None, + }; + let groups = context.read_bound_materialization(&binding).unwrap(); + assert_eq!(groups.len(), 1); + let result = groups[0] + .1 + .exact_value_for( + control_plane::query_plan::ExactReadout::Rate, + &None, + 0, + 60_000, + ) + .unwrap() + .unwrap(); + assert!((result - 11. / 24.).abs() < 1e-12); + let partial = QueryExecutionContext { + t0_ms: 25_000, + ..context + }; + assert!(partial.read_bound_materialization(&binding).is_err()); + } + + // Per-series reads preserve source order so stable ranking retains tied inputs. + #[test] + fn per_entity_read_preserves_source_order() { + let idx = SketchStore::new(); + let policy = asap_types::PolicyFingerprint(779); + for (sid, instance) in [(1, "z"), (2, "a")] { + idx.register(SummarySeriesMetadata { + storage_handle: sid, + metric_name: "m".into(), + group_by_keys: std::collections::BTreeSet::new(), + capability: Some(Capability::ExactAgg(asap_types::AggregationType::Sum)), + agg_kind: AggKind::ExactAgg { + agg_type: asap_types::AggregationType::Sum, + parameters_canonical: String::new(), + spatial_filter_canonical: String::new(), + }, + accuracy: None, + first_seen_unix_ms: 0, + retired_at_ms: None, + expires_at_ms: None, + policy_fp: policy, + }); + idx.append_precompute( + sid, + BTreeMap::from([("instance".into(), instance.into())]), + (0, 1_000), + Box::new(asap_physical_operators::summary_kernels::SumAccumulator::with_sum(1.)), + ); + } + let binding = asap_types::query_plan::MaterializationBinding { + full_window_slide_ms: None, + item_labels: vec![], + materialization: policy.into(), + stored_output_reference: super::super::test_plan::bound_reference(&idx, policy.into()), + output_grouping: asap_types::query_plan::PhysicalGrouping::PerEntity, + window_ms: 1_000, + pane_origin_ms: Some(0), + readout_lookback_ms: Some(1_000), + }; + let context = QueryExecutionContext { + index: &idx, + t0_ms: 0, + t1_ms: 1_000, + is_cumulative: true, + allowed_materializations: None, + }; + let groups = context.read_bound_materialization(&binding).unwrap(); + assert_eq!( + groups + .iter() + .map(|(labels, _)| labels["instance"].as_str()) + .collect::>(), + vec!["z", "a"] + ); + } + + // 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 { + counter_parameters: Default::default(), + 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![( @@ -1488,6 +2150,7 @@ mod tests { let value = outcome.series[0].1[0].1; assert!((value - 0.575).abs() < 1e-12, "reset-aware rate={value}"); let native_runtime = PhysicalQueryRuntime { + counter_parameters: Default::default(), language: control_plane::query_plan::QueryLanguage::MetricsQl, catalog: None, context: QueryExecutionContext { @@ -1503,7 +2166,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/request_tests.rs b/data_plane/src/query_engines/asap_query_engine/request_tests.rs new file mode 100644 index 000000000..c405f1c0f --- /dev/null +++ b/data_plane/src/query_engines/asap_query_engine/request_tests.rs @@ -0,0 +1,226 @@ +//! Installed query → real HTTP input → shared physical execution contracts. +use super::{engine::ASAPQueryEngine, test_plan}; +use crate::query_engines::{routing::query_engine_routing::QueryEngine, EngineError}; +use crate::storage_engines::sketch_db::index::SketchStore; +use asap_physical_operators::dag::{Error, Limits}; +use asap_types::query_plan::*; +use std::{collections::BTreeMap, sync::Arc}; + +fn external_entry() -> QueryPlanEntry { + QueryPlanEntry { + language: QueryLanguage::PromQl, + query_id: "m".into(), + canonical_query: "m".into(), + fixed_evaluation: None, + root: QueryNodeId(0), + nodes: BTreeMap::from([( + QueryNodeId(0), + QueryPlanNode::ExternalExact { + request: ExternalExactRequest { + language: QueryLanguage::PromQl, + expression: "m".into(), + output: ExternalExactOutput::InstantVector, + parameters: BTreeMap::new(), + start_parameter: None, + end_parameter: None, + input_contracts: vec![], + }, + inputs: vec![], + }, + )]), + instant: InstantExecution { + lookback_ms: 1000, + full_history: false, + cumulative_readout: true, + }, + fallback: FallbackPolicy::ExactBackend, + } +} + +// One step fits; many steps must not each acquire an independent memory limit. +#[tokio::test] +async fn installed_range_enforces_one_budget_and_keeps_external_only_queries() { + let app = axum::Router::new().route("/api/v1/query", axum::routing::get(|axum::extract::Query(params): axum::extract::Query>| async move { + axum::Json(serde_json::json!({"status":"success", "data":{"resultType":"vector", "result":[ + {"metric":{"job":"api"},"value":[params["time"].parse::().unwrap(),"2"]} + ]}})) + })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let index = Arc::new(SketchStore::new()); + let active = test_plan::install(&index, &[], vec![external_entry()]); + let engine = ASAPQueryEngine::new(1000) + .with_active_physical_plan(active) + .with_exact_subquery_endpoint(endpoint) + .with_execution_limits(Limits { + max_bytes: 12_000, + ..Limits::default() + }); + engine.execute_at("m", 1000).await.unwrap(); + let error = engine + .execute_range_promql_modern("m", 1000, 101_000, 1000) + .await + .unwrap_err(); + assert!( + matches!(error, EngineError::Physical(Error::MemoryLimit)), + "{error:?}" + ); + server.abort(); +} + +// Candidate membership and arithmetic branches cannot smuggle local state +// into an external request without a shared input snapshot contract. +#[test] +fn mixed_bound_outputs_rejected_before_any_source_is_read() { + let config = test_plan::materialization("m", "Sum", serde_json::json!({}), &[], 1000); + let local = test_plan::entry( + "sum_over_time(m[1s])", + &config, + PhysicalGrouping::PerEntity, + 1000, + QueryPlanNode::ExactReadout { + input: QueryNodeId(0), + readout: ExactReadout::Sum, + }, + ); + let mut entry = external_entry(); + entry + .nodes + .insert(QueryNodeId(1), local.nodes[&QueryNodeId(0)].clone()); + if let QueryPlanNode::ExternalExact { inputs, request } = + entry.nodes.get_mut(&QueryNodeId(0)).unwrap() + { + inputs.push(QueryNodeId(1)); + request + .input_contracts + .push(ExternalExactInput::CandidateMembership { + item_label: "job".into(), + }); + } + let error = entry + .validate(&[config.policy_fingerprint()].into_iter().collect()) + .unwrap_err(); + assert!( + error.to_string().contains("common snapshot proof"), + "{error}" + ); + entry.nodes.insert( + QueryNodeId(1), + QueryPlanNode::Logical { + operator: residual::ResidualQueryOperator::CurrentSeries { + population: current_series::SeriesPopulation { + metric: "m".into(), + matchers: vec![], + grouping: residual::Grouping { + labels: vec!["job".into()], + without: false, + }, + lookback_ms: 1000, + max_input_lag_ms: 1000, + history_retention_ms: 0, + max_series: 100, + max_bytes: 100_000, + max_k: 3, + quantiles: false, + }, + readout: current_series::SeriesReadout::Sum, + }, + inputs: vec![], + }, + ); + assert!(entry + .validate_snapshot_sources() + .unwrap_err() + .to_string() + .contains("common snapshot proof")); + // An unreachable local node cannot turn an external-only graph into a mixed graph. + if let QueryPlanNode::ExternalExact { inputs, request } = + entry.nodes.get_mut(&QueryNodeId(0)).unwrap() + { + inputs.clear(); + request.input_contracts.clear(); + } + entry.validate_snapshot_sources().unwrap(); +} + +// A concurrent publication cannot turn terminal execution failure into fallback. +#[test] +fn revision_race_preserves_execution_failure() { + for failure in [Error::MemoryLimit, Error::Cancelled] { + let result = super::engine::finish_query::<()>(Err(EngineError::Physical(failure)), false); + assert!( + matches!(result, Err(EngineError::Physical(_))), + "{result:?}" + ); + } +} + +// Local readouts fit individually; retained range output eventually exhausts +// the same budget used by subsequent native evaluations. +#[tokio::test] +async fn installed_local_range_accounts_for_accumulated_results() { + use crate::storage_engines::sketch_db::index::{AggKind, Capability, SummarySeriesMetadata}; + let config = test_plan::materialization("m", "Sum", serde_json::json!({}), &[], 1000); + let entry = test_plan::entry( + "sum_over_time(m[1s])", + &config, + PhysicalGrouping::PerEntity, + 1000, + QueryPlanNode::ExactReadout { + input: QueryNodeId(0), + readout: ExactReadout::Sum, + }, + ); + let index = Arc::new(SketchStore::new()); + index.register(SummarySeriesMetadata { + storage_handle: 1, + metric_name: "m".into(), + group_by_keys: Default::default(), + capability: Some(Capability::ExactAgg(asap_types::AggregationType::Sum)), + agg_kind: AggKind::ExactAgg { + agg_type: asap_types::AggregationType::Sum, + parameters_canonical: String::new(), + spatial_filter_canonical: String::new(), + }, + accuracy: None, + first_seen_unix_ms: 0, + retired_at_ms: None, + expires_at_ms: None, + policy_fp: config.policy_fingerprint(), + }); + let engine = + test_plan::engine(index.clone(), config, vec![1], entry).with_execution_limits(Limits { + max_bytes: 8192, + ..Limits::default() + }); + for pane in 0..100 { + index.append_precompute( + 1, + BTreeMap::new(), + (pane * 1000, (pane + 1) * 1000), + Box::new(asap_physical_operators::summary_kernels::SumAccumulator::with_sum(2.0)), + ); + } + engine + .execute_at("sum_over_time(m[1s])", 1000) + .await + .unwrap(); + let error = engine + .execute_range_promql_modern("sum_over_time(m[1s])", 1000, 100_000, 1000) + .await + .unwrap_err(); + fn resource(error: &Error) -> bool { + match error { + Error::MemoryLimit => true, + Error::AtNode { source, .. } => resource(source), + _ => false, + } + } + assert!( + matches!(error, EngineError::Physical(ref error) if resource(error)), + "{error:?}" + ); +} diff --git a/data_plane/src/query_engines/asap_query_engine/summary_exec.rs b/data_plane/src/query_engines/asap_query_engine/summary_exec.rs deleted file mode 100644 index 9c174d95c..000000000 --- a/data_plane/src/query_engines/asap_query_engine/summary_exec.rs +++ /dev/null @@ -1,776 +0,0 @@ -//! Backend execution model for ASAPPlanner's post-ASAP IR. -//! Adapted from ASAPPlanner's summary executor. -//! -//! [`SummaryExecutor`] supplies storage and readout operations; the tree walker -//! executes the selected summary plan. The concrete backend implementation is -//! `AsapSummaryExecutor` in the sibling `summary_executor` module. - -use std::collections::BTreeMap; - -use planner_types::pre_asap::{ColumnRef, QueryExpr, Reduction}; - -use planner_types::post_asap::{SketchQuery, SummaryExpr, SummaryFamilyType, SummaryNode}; - -/// Deployment-supplied backend for executing a [`SummaryNode`] tree -/// against materialized state. -pub trait SummaryExecutor { - /// Reference to one materialized summary instance (e.g. a sid). - type Handle: Clone; - /// Decoded, in-memory state for one instance (or an already-merged - /// group of them). - type State; - /// A query answer. - type Value; - /// Deployment error type. - type Error; - /// Opaque per-group identity for a `SummaryAgg`'s grouping — e.g. a - /// label-value map for a PromQL/time-series deployment. `Ord` so - /// `execute` can fold same-group handles/states deterministically; - /// `Default` for the single-group case (`Reduction::Reduce` with an - /// empty `by`, or a `Logical` leaf, which has no grouping concept at - /// all) — every outcome is always a per-group list, and the - /// single-group case is simply a list of one entry under - /// `GroupKey::default()`. - type GroupKey: Clone + Ord + Default; - - /// Resolve a `SummaryAgg` leaf to matching materialized-instance - /// handles, each tagged with the group it belongs to. Must only - /// return handles whose committed summary family is exactly - /// `family` (same `(kind, params)` pair, whichever family it is — - /// `ExactAggregate`/`Sketch`/`Sample`/`Wavelet`/`StatModel`; never - /// `Plain`, since a `SummaryAgg` always commits a real family — see - /// the field's own doc on `SummaryExpr::SummaryAgg`). - /// - /// `reduction` (the same [`Reduction`] the pre-ASAP `Aggregate` node - /// this was bound from carried) tells you which of two shapes to - /// produce — the two are **not** interchangeable (ASAPPlanner#163): - /// - `Reduction::Reduce(by)`: a genuine cross-series reduction. Tag - /// every returned handle with the `GroupKey` for its `by` values; - /// when `by` is empty there is exactly one group — every handle - /// should share one deployment-chosen `GroupKey` (e.g. - /// `GroupKey::default()`), and they will all be merged together. - /// - `Reduction::PerEntity`: there is no grouping concept for this - /// shape at all. Tag every returned handle with its own, distinct - /// `GroupKey` (its full entity/series identity) — handles must - /// never share a `GroupKey` here, since `execute` merges same-group - /// handles together and merging unrelated entities would silently - /// produce a wrong answer. - #[allow(clippy::type_complexity)] - fn find_candidates( - &self, - family: &SummaryFamilyType, - col: &ColumnRef, - reduction: &Reduction, - child: &SummaryNode, - ) -> Result, Self::Error>; - - /// Fetch/decode one handle's raw state. - fn fetch_state(&self, handle: &Self::Handle) -> Result; - - /// Merge two or more same-family, same-group states. - fn merge_states(&self, states: Vec) -> Result; - - /// Read a query out of one group's built state. - fn readout(&self, state: &Self::State, query: &SketchQuery) - -> Result; - - /// Handle a `SummaryExpr::Logical` node (nothing committed at the - /// post-ASAP level). Ungrouped by construction — `Logical` defers - /// entirely to the deployment, which decides for itself whether/how - /// the underlying `QueryExpr` groups. - fn logical(&self, expr: &QueryExpr) -> Result; -} - -/// Result of executing one [`SummaryNode`] — one summary state per group -/// (tagged with the shared family), or one final value per group. Always -/// a list, even when there's exactly one (ungrouped) group — see -/// [`SummaryExecutor::GroupKey`]. -pub enum ExecOutcome { - State(Vec<(E::GroupKey, E::State, SummaryFamilyType)>), - Value(Vec<(E::GroupKey, E::Value)>), -} - -/// Errors [`execute`] itself can raise, on top of the deployment's own -/// [`SummaryExecutor::Error`]. -#[derive(Debug)] -pub enum ExecError { - /// [`SummaryExecutor::find_candidates`] returned no handles. - NoCandidates, - /// A `SummaryMerge` child produced a `Value`, not a `State`. - MergeChildNotState, - /// `SummaryMerge` children disagreed on their summary family — checked - /// globally across every group from every child, not just within one - /// group, because family agreement is a planning-time property of the - /// `SummaryMerge` node itself (fixed before any group value is known), - /// not something that can validly vary per group. - MergeKindParamsMismatch, - /// A `SummaryMerge` had zero children. - EmptyMerge, - /// `SummaryJoin`/`SummarySubtract`/`SummaryDelete` — unreachable today. - NotYetSupported(&'static str), - /// The deployment's own error. - Executor(Inner), -} - -impl From for ExecError { - fn from(e: Inner) -> Self { - ExecError::Executor(e) - } -} - -/// Recursively execute `node` against `exec`. -pub fn execute( - node: &SummaryNode, - exec: &E, -) -> Result, ExecError> { - match &node.expr { - SummaryExpr::KeepPreAsap(qe) => Ok(ExecOutcome::Value(vec![( - E::GroupKey::default(), - exec.logical(qe)?, - )])), - - SummaryExpr::SummaryAgg { - child, - family, - input, - reduction, - .. - } => { - // This legacy adapter forwards one value column, not a complete - // keyed item/weight contract. Do not silently treat a unit-weight - // count as a sample-value sum or drop the weight of a keyed update. - let col = match (&input.item, &input.weight) { - (None, planner_types::post_asap::SummaryInputExpr::Column(col)) => col.clone(), - _ => { - return Err(ExecError::NotYetSupported( - "keyed or non-column summary update", - )) - } - }; - let tagged = exec.find_candidates(family, &col, reduction, child)?; - if tagged.is_empty() { - return Err(ExecError::NoCandidates); - } - // Group by `GroupKey` first (a `SummaryAgg` may see several - // handles for the same group, e.g. cross-host instances of - // the same metric/group-by value that need folding), then - // fold each group's handles independently — a group's state - // must never be combined with another group's. - let mut by_group: BTreeMap> = BTreeMap::new(); - for (key, handle) in tagged { - by_group.entry(key).or_default().push(handle); - } - let mut out = Vec::with_capacity(by_group.len()); - for (key, handles) in by_group { - let states = handles - .iter() - .map(|h| exec.fetch_state(h)) - .collect::, _>>()?; - let state = fold_states(states, exec)?; - out.push((key, state, family.clone())); - } - Ok(ExecOutcome::State(out)) - } - - SummaryExpr::SummaryEstimate { - summary_input, - query, - } => { - let groups = expect_state(execute(summary_input, exec)?)?; - let mut out = Vec::with_capacity(groups.len()); - for (key, state, _family) in groups { - out.push((key, exec.readout(&state, query)?)); - } - Ok(ExecOutcome::Value(out)) - } - - SummaryExpr::SummaryMerge { children, .. } => { - if children.is_empty() { - return Err(ExecError::EmptyMerge); - } - let mut agreed: Option = None; - // Collect every child's (group -> state) pairs first, so a - // group present in only some children still gets folded from - // whatever states exist for it (mirrors today's per-(group, - // window) ExactAgg merge: fold whatever's present, don't - // require every child to cover every group). - let mut by_group: BTreeMap> = BTreeMap::new(); - for child in children { - let groups = expect_state(execute(child, exec)?)?; - for (key, state, family) in groups { - match &agreed { - None => agreed = Some(family), - Some(f) if *f == family => {} - Some(_) => return Err(ExecError::MergeKindParamsMismatch), - } - by_group.entry(key).or_default().push(state); - } - } - // `children` non-empty => at least one child produced a - // `State` outcome (checked above) => that outcome had at - // least one group (a `SummaryAgg`/`SummaryMerge` never - // produces an empty group list — `SummaryAgg` errors via - // `NoCandidates` first, and `SummaryMerge` recursively bottoms - // out at a `SummaryAgg`) => `agreed` is always `Some` here. - let family = agreed.expect("checked non-empty above"); - let mut out = Vec::with_capacity(by_group.len()); - for (key, states) in by_group { - let merged = fold_states(states, exec)?; - out.push((key, merged, family.clone())); - } - Ok(ExecOutcome::State(out)) - } - - SummaryExpr::SummaryJoin { .. } => Err(ExecError::NotYetSupported("SummaryJoin")), - SummaryExpr::RelationalJoin { .. } => Err(ExecError::NotYetSupported("RelationalJoin")), - // semi-join is lowered to the deployed QueryPlan DAG, where both - // row inputs retain labels for intersection and exact reranking. This - // legacy generic adapter exposes opaque GroupKey values and cannot - // implement that contract without losing label identity. - SummaryExpr::BinaryOp { .. } => Err(ExecError::NotYetSupported("BinaryOp")), - SummaryExpr::ValueOperation { .. } => Err(ExecError::NotYetSupported("ValueOperation")), - SummaryExpr::SummarySubtract { .. } => Err(ExecError::NotYetSupported("SummarySubtract")), - SummaryExpr::SummaryDelete { .. } => Err(ExecError::NotYetSupported("SummaryDelete")), - } -} - -/// Fold one-or-more same-family, same-group states into one. -fn fold_states( - mut states: Vec, - exec: &E, -) -> Result> { - if states.len() == 1 { - Ok(states.pop().expect("len == 1")) - } else { - Ok(exec.merge_states(states)?) - } -} - -/// Unwrap an [`ExecOutcome::State`], erroring if it was actually a -/// `Value`. -#[allow(clippy::type_complexity)] -fn expect_state( - outcome: ExecOutcome, -) -> Result, ExecError> { - match outcome { - ExecOutcome::State(groups) => Ok(groups), - ExecOutcome::Value(_) => Err(ExecError::MergeChildNotState), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use planner_types::post_asap::{SummaryField, SummarySchema}; - use planner_types::pre_asap::{Column, DataType, Schema, Source}; - use std::cell::RefCell; - use std::collections::HashMap; - use std::rc::Rc; - - fn scan() -> QueryExpr { - QueryExpr::Scan { - source: Source::TimeSeries { metric: "m".into() }, - predicates: vec![], - schema: Schema::with_time_index( - vec![ - Column::new("ts", DataType::Timestamp, false), - Column::new("value", DataType::Float64, false), - ], - 0, - vec![], - ), - } - } - - fn lift(schema_fields: Vec<&str>) -> SummarySchema { - SummarySchema { - fields: schema_fields - .into_iter() - .map(|n| SummaryField { - name: n.into(), - dtype: SummaryFamilyType::Plain(DataType::Float64), - nullable: false, - }) - .collect(), - time_index: None, - } - } - - fn logical_node() -> Rc { - Rc::new(SummaryNode { - expr: SummaryExpr::KeepPreAsap(Rc::new(scan())), - schema: lift(vec!["ts", "value"]), - guarantee: None, - }) - } - - /// Builds a `SummaryAgg` with `Reduction::Reduce(vec![])` (an ordinary, - /// ungrouped cross-series reduction) — the shape every pre-#163 test in - /// this module exercises. Use [`agg_node_with_reduction`] to exercise - /// `Reduction` itself. - fn agg_node(family: SummaryFamilyType, child: Rc) -> Rc { - agg_node_with_reduction(family, child, Reduction::by(vec![])) - } - - fn agg_node_with_reduction( - family: SummaryFamilyType, - child: Rc, - reduction: Reduction, - ) -> Rc { - Rc::new(SummaryNode { - expr: SummaryExpr::SummaryAgg { - child, - family, - input: planner_types::post_asap::SummaryUpdate { - item: None, - weight: planner_types::post_asap::SummaryInputExpr::Column( - ColumnRef::SampleValue, - ), - weight_domain: Default::default(), - }, - reduction, - grouping: planner_types::post_asap::GroupingStrategy::default(), - }, - schema: lift(vec!["value"]), - guarantee: None, - }) - } - - fn estimate_node(summary_input: Rc, query: SketchQuery) -> Rc { - Rc::new(SummaryNode { - expr: SummaryExpr::SummaryEstimate { - summary_input, - query, - }, - schema: lift(vec!["value"]), - guarantee: None, - }) - } - - fn merge_node(children: Vec>) -> Rc { - Rc::new(SummaryNode { - expr: SummaryExpr::SummaryMerge { - children, - timing: planner_types::post_asap::ExecutionTiming::QueryTime, - }, - schema: lift(vec!["value"]), - guarantee: None, - }) - } - - /// Trivial in-memory executor for exercising `execute`'s tree-walking, - /// grouping, and merge-precondition logic — `State`/`Value` are just - /// `f64`s, `GroupKey` is `String` (empty string = the ungrouped - /// default), and `merge_states` is `sum`, so tests can assert on - /// concrete numbers without any real sketch-math dependency. - /// - /// `find_candidates` intentionally ignores `family`/`reduction`/`child` - /// and returns every registered handle tagged with its own registered - /// group — real deployments filter on those; these tests only - /// exercise `execute`'s own tree-walking and grouping/merge logic, not - /// a real candidate search. See - /// `reduction_is_passed_through_to_find_candidates_unmodified` below - /// for a test that *does* inspect `reduction`. - struct MockExecutor { - /// handle -> (group, value). - values: RefCell>, - next_handle: RefCell, - } - - impl MockExecutor { - fn new() -> Self { - Self { - values: RefCell::new(HashMap::new()), - next_handle: RefCell::new(0), - } - } - - /// Register one materialized instance under the default - /// (ungrouped) group, returning its handle. - fn register(&self, value: f64) -> u64 { - self.register_grouped("", value) - } - - /// Register one materialized instance under an explicit group. - fn register_grouped(&self, group: &str, value: f64) -> u64 { - let mut h = self.next_handle.borrow_mut(); - let handle = *h; - *h += 1; - self.values - .borrow_mut() - .insert(handle, (group.to_string(), value)); - handle - } - } - - impl SummaryExecutor for MockExecutor { - type Handle = u64; - type State = f64; - type Value = f64; - type Error = String; - type GroupKey = String; - - fn find_candidates( - &self, - _family: &SummaryFamilyType, - _col: &ColumnRef, - _reduction: &Reduction, - _child: &SummaryNode, - ) -> Result, Self::Error> { - Ok(self - .values - .borrow() - .iter() - .map(|(h, (g, _))| (g.clone(), *h)) - .collect()) - } - - fn fetch_state(&self, handle: &Self::Handle) -> Result { - self.values - .borrow() - .get(handle) - .map(|(_, v)| *v) - .ok_or_else(|| "unknown handle".to_string()) - } - - fn merge_states(&self, states: Vec) -> Result { - Ok(states.into_iter().sum()) - } - - fn readout( - &self, - state: &Self::State, - _query: &SketchQuery, - ) -> Result { - Ok(*state) - } - - fn logical(&self, _expr: &QueryExpr) -> Result { - Ok(-1.0) - } - } - - fn kll() -> SummaryFamilyType { - use planner_types::post_asap::{ - GroupingStrategy, SketchAlgorithm, SketchKind, SketchParams, - }; - SummaryFamilyType::Sketch( - SketchKind::new(SketchAlgorithm::Kll, SketchParams::Kll { k: 200 }), - GroupingStrategy::default(), - ) - } - - fn sum() -> SummaryFamilyType { - use planner_types::post_asap::{ExactKind, ExactParams}; - SummaryFamilyType::ExactAggregate(ExactKind::Sum, ExactParams::Sum) - } - - /// Extract the single (ungrouped) output's value, asserting there's - /// exactly one group -- the shape every pre-grouping test expects. - fn only(v: Vec<(String, T)>) -> T { - assert_eq!(v.len(), 1, "expected exactly one (ungrouped) output group"); - v.into_iter().next().unwrap().1 - } - - #[test] - fn single_agg_reads_out_directly() { - let exec = MockExecutor::new(); - exec.register(7.0); - let tree = estimate_node(agg_node(sum(), logical_node()), SketchQuery::Cardinality); - let ExecOutcome::Value(v) = execute(&tree, &exec).unwrap() else { - panic!("expected a value"); - }; - assert_eq!(only(v), 7.0); - } - - /// Legacy candidate lookup cannot discard keyed items or constant weights - /// when adapting the planner's typed update contract. - #[test] - fn unsupported_update_semantics_fail_before_candidate_lookup() { - use planner_types::post_asap::{SummaryInputExpr, SummaryUpdate}; - let exec = MockExecutor::new(); - exec.register(7.0); - for input in [ - SummaryUpdate { - item: Some(SummaryInputExpr::Column(ColumnRef::Named("key".into()))), - weight: SummaryInputExpr::Column(ColumnRef::SampleValue), - weight_domain: Default::default(), - }, - SummaryUpdate { - item: None, - weight: SummaryInputExpr::Constant(1.0), - weight_domain: Default::default(), - }, - ] { - let mut tree = agg_node(sum(), logical_node()); - let SummaryExpr::SummaryAgg { input: update, .. } = &mut Rc::make_mut(&mut tree).expr - else { - unreachable!() - }; - *update = input; - assert!(matches!( - execute(&tree, &exec), - Err(ExecError::NotYetSupported( - "keyed or non-column summary update" - )) - )); - } - } - - /// Binary summaries remain explicit unsupported execution until value and - /// label/time matching are implemented by the warm tier. - #[test] - fn binary_summary_is_not_executed_as_one_operand() { - let child = logical_node(); - let tree = SummaryNode { - expr: SummaryExpr::BinaryOp { - timing: planner_types::post_asap::ExecutionTiming::QueryTime, - lhs: child.clone(), - rhs: child.clone(), - operator: planner_types::post_asap::BinaryOperator { - checked_relative_division: false, - checked_finite_division: false, - kind: planner_types::pre_asap::BinaryOpKind::Arithmetic( - planner_types::pre_asap::ArithmeticOpKind::Div, - ), - vector_match: None, - }, - }, - schema: child.schema.clone(), - guarantee: None, - }; - assert!(matches!( - execute(&tree, &MockExecutor::new()), - Err(ExecError::NotYetSupported("BinaryOp")) - )); - } - - #[test] - fn multiple_candidates_for_one_agg_are_merged() { - let exec = MockExecutor::new(); - exec.register(3.0); - exec.register(4.0); - let tree = estimate_node(agg_node(sum(), logical_node()), SketchQuery::Cardinality); - let ExecOutcome::Value(v) = execute(&tree, &exec).unwrap() else { - panic!("expected a value"); - }; - assert_eq!(only(v), 7.0); // MockExecutor::merge_states sums - } - - #[test] - fn logical_leaf_defers_to_executor() { - let exec = MockExecutor::new(); - let tree = logical_node(); - let ExecOutcome::Value(v) = execute(&tree, &exec).unwrap() else { - panic!("expected a value"); - }; - assert_eq!(only(v), -1.0); - } - - #[test] - fn nested_summary_agg_recurses_through_both_levels() { - // quantile(0.9, sum by (job) (m)) shape: Kll wraps Sum. - let exec = MockExecutor::new(); - exec.register(10.0); - let inner = agg_node(sum(), logical_node()); - let outer = agg_node(kll(), inner); - let tree = estimate_node(outer, SketchQuery::Quantile { q: 0.9 }); - let ExecOutcome::Value(v) = execute(&tree, &exec).unwrap() else { - panic!("expected a value"); - }; - // MockExecutor::find_candidates ignores the tree shape and always - // returns every registered handle -- the point of this test is - // that `execute` actually reaches both levels (no panic/short - // circuit on the nested SummaryAgg), not the specific value. - assert_eq!(only(v), 10.0); - } - - #[test] - fn merge_of_two_aggs_with_matching_kind_params_sums() { - let exec = MockExecutor::new(); - let a = agg_node(sum(), logical_node()); - let b = agg_node(sum(), logical_node()); - // Each SummaryAgg independently pulls every registered handle from - // the shared MockExecutor (2 handles, same default group), so the - // merge sees two already-summed inputs. Register after building - // the tree so each `find_candidates` call sees the same two - // handles. - exec.register(1.0); - exec.register(2.0); - let merged = merge_node(vec![a, b]); - let tree = estimate_node(merged, SketchQuery::Cardinality); - let ExecOutcome::Value(v) = execute(&tree, &exec).unwrap() else { - panic!("expected a value"); - }; - // Each child sums to 3.0 (1.0 + 2.0); the outer merge sums the two - // children's results: 3.0 + 3.0. - assert_eq!(only(v), 6.0); - } - - #[test] - fn merge_of_merges_nests_arbitrarily_deep() { - let exec = MockExecutor::new(); - exec.register(5.0); - let leaf_a = agg_node(sum(), logical_node()); - let leaf_b = agg_node(sum(), logical_node()); - let inner_merge = merge_node(vec![leaf_a, leaf_b]); - let leaf_c = agg_node(sum(), logical_node()); - let outer_merge = merge_node(vec![inner_merge, leaf_c]); - let tree = estimate_node(outer_merge, SketchQuery::Cardinality); - assert!(execute(&tree, &exec).is_ok()); - } - - #[test] - fn merge_rejects_mismatched_kind_params() { - let exec = MockExecutor::new(); - exec.register(1.0); - let a = agg_node(sum(), logical_node()); - let b = agg_node(kll(), logical_node()); - let merged = merge_node(vec![a, b]); - let tree = estimate_node(merged, SketchQuery::Cardinality); - match execute(&tree, &exec) { - Err(ExecError::MergeKindParamsMismatch) => {} - other => panic!( - "expected MergeKindParamsMismatch, got a different outcome: {}", - other.is_ok() - ), - } - } - - #[test] - fn merge_rejects_a_value_producing_child() { - let exec = MockExecutor::new(); - exec.register(1.0); - let state_child = agg_node(sum(), logical_node()); - let value_child = estimate_node(logical_node(), SketchQuery::Cardinality); - let merged = merge_node(vec![state_child, value_child]); - let tree = estimate_node(merged, SketchQuery::Cardinality); - match execute(&tree, &exec) { - Err(ExecError::MergeChildNotState) => {} - other => panic!( - "expected MergeChildNotState, got a different outcome: {}", - other.is_ok() - ), - } - } - - #[test] - fn no_candidates_is_a_distinct_error() { - let exec = MockExecutor::new(); // nothing registered - let tree = estimate_node(agg_node(sum(), logical_node()), SketchQuery::Cardinality); - match execute(&tree, &exec) { - Err(ExecError::NoCandidates) => {} - other => panic!( - "expected NoCandidates, got a different outcome: {}", - other.is_ok() - ), - } - } - - #[test] - fn grouped_summary_agg_produces_one_series_per_group() { - // The gap ASAPPlanner#159 flagged: `quantile by (zone) (m)` must - // produce one output series per zone, not one series merging - // every zone's sketches together. - let exec = MockExecutor::new(); - exec.register_grouped("us-east", 3.0); - exec.register_grouped("us-west", 4.0); - let tree = estimate_node(agg_node(sum(), logical_node()), SketchQuery::Cardinality); - let ExecOutcome::Value(mut v) = execute(&tree, &exec).unwrap() else { - panic!("expected a value"); - }; - v.sort_by(|a, b| a.0.cmp(&b.0)); - assert_eq!( - v, - vec![("us-east".to_string(), 3.0), ("us-west".to_string(), 4.0)] - ); - } - - #[test] - fn grouped_summary_merge_folds_within_group_not_across() { - // Two children (e.g. edge + gateway tiers of the same SummaryAgg - // shape) each see both groups' handles; the merge must fold - // us-east's two values together and us-west's two values - // together, WITHOUT ever mixing a us-east state into us-west's - // output or vice versa. - let exec = MockExecutor::new(); - let a = agg_node(sum(), logical_node()); - let b = agg_node(sum(), logical_node()); - exec.register_grouped("us-east", 1.0); - exec.register_grouped("us-west", 10.0); - let merged = merge_node(vec![a, b]); - let tree = estimate_node(merged, SketchQuery::Cardinality); - let ExecOutcome::Value(mut v) = execute(&tree, &exec).unwrap() else { - panic!("expected a value"); - }; - v.sort_by(|x, y| x.0.cmp(&y.0)); - // us-east: both children see the one us-east handle (1.0) -> each - // child's us-east state is 1.0 -> merge sums the two children: - // 1.0 + 1.0 = 2.0. Same for us-west: 10.0 + 10.0 = 20.0. Neither - // group's value (2.0, 20.0) equals the cross-group sum (11.0) -- - // proves the merge didn't mix groups. - assert_eq!( - v, - vec![("us-east".to_string(), 2.0), ("us-west".to_string(), 20.0)] - ); - } - - /// A wiring test for issue ASAPPlanner#163: `execute` must pass a - /// `SummaryAgg`'s `reduction` through to `find_candidates` completely - /// unmodified — not re-derive it, not silently drop it in favor of a - /// bare `by` list. `MockExecutor` above ignores `reduction` entirely - /// (it isn't testing this), so this test uses its own executor that - /// records exactly what it received. - struct ReductionSpyExecutor { - seen: RefCell>, - } - - impl SummaryExecutor for ReductionSpyExecutor { - type Handle = u64; - type State = f64; - type Value = f64; - type Error = String; - type GroupKey = u32; - - fn find_candidates( - &self, - _family: &SummaryFamilyType, - _col: &ColumnRef, - reduction: &Reduction, - _child: &SummaryNode, - ) -> Result, Self::Error> { - self.seen.borrow_mut().push(reduction.clone()); - Ok(vec![(0, 0)]) - } - - fn fetch_state(&self, _handle: &Self::Handle) -> Result { - Ok(1.0) - } - - fn merge_states(&self, states: Vec) -> Result { - Ok(states.into_iter().sum()) - } - - fn readout( - &self, - state: &Self::State, - _query: &SketchQuery, - ) -> Result { - Ok(*state) - } - - fn logical(&self, _expr: &QueryExpr) -> Result { - Ok(-1.0) - } - } - - #[test] - fn reduction_is_passed_through_to_find_candidates_unmodified() { - let exec = ReductionSpyExecutor { - seen: RefCell::new(vec![]), - }; - let tree = agg_node_with_reduction(sum(), logical_node(), Reduction::PerEntity); - execute(&tree, &exec).unwrap(); - assert_eq!(exec.seen.borrow().as_slice(), &[Reduction::PerEntity]); - } -} 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 356a1a2f3..0511e1aad 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 @@ -1,95 +1,28 @@ -//! `data_plane`'s implementation of `crate::query_engines::asap_query_engine::summary_exec::SummaryExecutor` -//! — the serving-time interface that resolves an `SummaryNode` plan tree -//! against whatever is actually materialized right now. See -//! `data_plane/docs/l4node-plan-executor-design.md` for the surrounding -//! design. -//! -//! ## Scope -//! -//! Covers **quantile/cardinality queries** (DDSketch/Kll/Hll) **and the -//! Frequency family** — both the bare total (`count`/`sum` with no -//! specific item key) and a per-item point lookup (`count(cms_metric -//! {item="x"})`, `SketchQuery::PointCount{key: Named(_), value: Some(_)}` -//! — `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 from a `Filter` predicate) — for CMS/CountSketch/ -//! CMS-with-heap/CountSketch-with-heap, both cumulative (instant) and -//! per-window (matrix/range). All modes do real cross-sid merging via -//! `delta_apply::SummaryState`: reconstruct each candidate sid's own -//! state over the range (or per window), then merge same-window/ -//! same-range states *across* sids before reading out one answer per -//! group (or per group per window). -//! -//! `Self::Value` (`SummaryValue`) carries two shapes: `Points` (one -//! scalar per timestamp — everything but `TopK`) and `TopK` (one ranked -//! `(item, count)` list per timestamp). Both cumulative and per-window -//! readout merge cross-sid the same way regardless of which shape the -//! query needs — top-k merge reuses `SummaryState::topk_items` plus the -//! same `merge_same_family` pipeline (`asap_sketchlib`'s heap `merge` -//! already re-reconciles the heap against the merged matrix), no separate -//! merge logic. -//! -//! Not covered, and reported as an explicit `Unsupported` error rather -//! than silently mishandled: -//! - `SketchQuery::TopK` against a heap-less family (`Dd`/`Hll`/`Kll`/ -//! `Cms`/`CountSketch`) — a family limitation (no item universe to -//! rank), not an unimplemented-query limitation; see `topk_ranked`. -//! - `SketchQuery::PointCount` against a heap-less-*and*-quantile/ -//! cardinality family (`Dd`/`Hll`/`Kll` have no item universe at all) -//! — a family limitation, not an unimplemented-query limitation. -//! - A `PointCount` whose `key`/`value` combination isn't one of the two -//! expected shapes (`SampleValue` + `None`, or `Named`/`Qualified` + -//! `Some(_)`) — reported rather than silently guessed at. -//! -//! `find_candidates`/`fetch_state`/`merge_states` ALSO recognize -//! `AggKind::ExactAgg` sids for `ExactKind::{Sum, Increase}` (see -//! `exact_agg_kind_match`'s doc for why `Min`/`Max`/`Count`/`Rate` aren't -//! matched) — one sid is one aggregation, read out directly, with no -//! special-casing of exact-vs-approximate at the `find_candidates`/merge -//! level. But `readout`/`SketchQuery` NEVER see these: `asap_aware_mapping::replacement` -//! never wraps an `ExactAccumulator` implementation in a -//! `SummaryEstimate` (`estimate = false` in `bind_summary_agg`), so -//! `execute()` on such a tree returns `ExecOutcome::State` at the root -//! rather than calling `readout`. `GroupState::exact_value` is the -//! ExactAgg analog of `readout_cumulative` — a future caller reads the -//! final value out of `ExecOutcome::State`'s `GroupState` by calling it -//! directly, not through this trait. -//! -//! Not covered here either: stacking an outer statistic (avg/stddev/...) -//! on top of a sketch or exact-agg readout ("outer-agg-fold") — out of -//! scope by explicit design choice, not an oversight. - +//! Deployment adapters for resolving, decoding, and reading installed materializations. +use asap_physical_operators::summary_kernels::{MaxAccumulator, MinAccumulator}; use std::collections::{BTreeMap, BTreeSet}; use std::rc::Rc; use std::sync::Arc; -use crate::query_engines::asap_query_engine::summary_exec::SummaryExecutor; use planner_types::post_asap::{ - ExactKind, SketchAlgorithm, SketchParams, SketchQuery, SummaryExpr, SummaryFamilyType, - SummaryNode, + ExactKind, SketchAlgorithm, SketchParams, SketchQuery, SummaryFamilyType, }; -use planner_types::pre_asap::{ColumnId, ColumnRef, QueryExpr, Reduction, Source}; +use planner_types::pre_asap::{QueryExpr, Source}; use crate::storage_engines::sketch_db::data::{AggKind, SketchConfig, SketchTimeSeries}; 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::summary_kernels::increase::IncreaseAccumulator; -use asap_physical_operators::summary_kernels::max::MaxAccumulator; -use asap_physical_operators::summary_kernels::min::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 /// mutated after construction). This is what carries the time range and -/// cumulative-vs-per-window mode: `SummaryExecutor`'s trait methods take -/// no such parameters, and `ASAPQueryEngine` itself is called +/// cumulative-vs-per-window mode. `ASAPQueryEngine` itself is called /// concurrently (`Arc`), so threading the range through /// shared mutable state on the engine would be a race — a fresh, -/// stack-local context per call is the safe alternative. +/// each call receives its own stack-local context. pub struct QueryExecutionContext<'a> { pub index: &'a SketchStore, pub t0_ms: u64, @@ -105,55 +38,9 @@ pub struct QueryExecutionContext<'a> { pub allowed_materializations: Option>, } -/// One candidate sid, already carrying its `[t0, t1]` data and decode -/// parameters. `find_candidates` fetches this once per candidate (it -/// needs the sid's label values to build the group key anyway); folding -/// it into the handle means `fetch_state`/`readout` reuse it instead of -/// re-querying the same `(sid, t0, t1)` range a second time. `Rc` keeps -/// clones of the handle cheap (a refcount bump, not a re-fetch or a -/// re-clone of the sample bytes). -/// -/// `ExactAgg` mirrors `Sketch` structurally (one sid's already-decoded -/// `[t0, t1]` data, `Rc`-shared) rather than getting a special exact-vs- -/// approximate treatment — see this module's doc. Its payload is already -/// `Arc` per window (from -/// `SketchStore::query_exact_agg_range`, which decodes eagerly, unlike -/// the sketch path's lazy raw-bytes-until-readout), so there's no -/// decode-parameter analog of `DeltaSketchKind` to carry. -#[derive(Clone)] -pub enum SidHandle { - Sketch { - series: Rc, - kind: DeltaSketchKind, - }, - ExactAgg { - windows: Rc>>, - agg_type: AggregationType, - }, -} - -// Manual `Debug` -- `dyn AggregateCore` doesn't implement it, so -// `#[derive(Debug)]` can't reach through `ExactAgg`'s `windows` field. -impl std::fmt::Debug for SidHandle { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - SidHandle::Sketch { series, kind } => f - .debug_struct("SidHandle::Sketch") - .field("series", series) - .field("kind", kind) - .finish(), - SidHandle::ExactAgg { windows, agg_type } => f - .debug_struct("SidHandle::ExactAgg") - .field("window_count", &windows.len()) - .field("agg_type", agg_type) - .finish(), - } - } -} - /// One group's accumulated candidates. `Sketch` entries all share one /// `DeltaSketchKind`; `ExactAgg` entries all share one `AggregationType` -/// (both guaranteed by `find_candidates`'s exact-match contract) — a +/// (validated by the deployment binding) — a /// group is never a mix of the two (`merge_states` errors if it somehow /// were). #[derive(Clone)] @@ -186,19 +73,8 @@ impl std::fmt::Debug for GroupState { } impl GroupState { - /// Merge this group's ExactAgg accumulators — across every sid AND - /// every window in `[t0, t1]` (there's no per-window concept exposed - /// here; a future caller that needs one issues a narrower query) — - /// into one combined value, then read out the statistic `agg_type` - /// implies. The ExactAgg analog of `readout_cumulative`, but called - /// directly by a future caller reading `ExecOutcome::State` (see this - /// module's doc for why `readout()`/`SketchQuery` never see these). - /// - /// `None` for a `Sketch` state, a group with no windows in range, or - /// a merge/query failure. `AggregationType::Min`/`Max` (and any other - /// type `exact_agg_kind_match` doesn't match) can't reach a - /// `GroupState::ExactAgg` via `find_candidates` in the first place — - /// the fallback arm here is defensive, not a real path. + /// Finalize the group's exact state using its declared family. + /// Typed query execution uses `exact_value_for` to preserve readout errors. pub fn exact_value(&self, key: &Option) -> Option { let GroupState::ExactAgg { entries, agg_type } = self else { return None; @@ -212,18 +88,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. @@ -233,12 +104,25 @@ impl GroupState { key: &Option, range_start_ms: u64, range_end_ms: u64, - ) -> Option { + ) -> Result, String> { + let parameters = std::collections::HashMap::from([ + ("range_start_ms".to_string(), range_start_ms.to_string()), + ("range_end_ms".to_string(), range_end_ms.to_string()), + ]); + self.exact_value_with_parameters(readout, key, ¶meters) + } + + pub(crate) fn exact_value_with_parameters( + &self, + readout: asap_types::query_plan::ExactReadout, + key: &Option, + parameters: &std::collections::HashMap, + ) -> Result, String> { let GroupState::ExactAgg { entries, agg_type } = self else { - return None; + return Err("exact readout requires matching exact state".into()); }; if agg_type.planner_exact_family().as_ref() != Some(&readout.planner_family()) { - return None; + return Err("exact readout requires matching exact state".into()); } let stat = match readout { asap_types::query_plan::ExactReadout::Count => asap_types::Statistic::Count, @@ -249,76 +133,12 @@ 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_optional( + entries.iter().flat_map(|windows| windows.values().cloned()), + stat, + key, + parameters, + ) } /// Coverage analog of `exact_value` — folds `(min_window_end_ms, @@ -351,22 +171,12 @@ pub enum SummaryExecutorError { /// SummaryFamilyType)` — mirrors today's `CapabilityMiss` /// contract; the caller fails over to archive. NoCandidates, - /// Couldn't recover a metric name by walking the `SummaryAgg`'s - /// child subtree — an unsupported/CSE-`Ref`-shaped `QueryExpr` this - /// executor doesn't walk through. - NoMetricFound, - /// A requested `by` `ColumnId` doesn't resolve to a name against the - /// child's schema. - UnresolvedColumn(ColumnId), /// A candidate sid claims a `SummaryFamilyType` this executor doesn't /// implement cross-sid merge for, or the sid's on-disk /// `SketchConfig` didn't decode into a `DeltaSketchKind`. UnsupportedFamily, /// Decode/merge failure surfaced from `delta_apply`/`asap_sketchlib`. Decode(String), - /// A `SummaryExpr::KeepPreAsap` node — no maintained summary was selected. Same - /// meaning as today's "no candidate bound"; the caller fails over. - Logical, /// A query shape not covered by this executor — see the module doc. Unsupported(&'static str), } @@ -441,6 +251,15 @@ impl QueryExecutionContext<'_> { )); } + if self + .allowed_materializations + .as_ref() + .is_some_and(|allowed| !allowed.contains(&binding.materialization.fingerprint())) + { + return Err(SummaryExecutorError::Unsupported( + "materialization is not authorized by the installed query", + )); + } let catalog = self.index .summary_catalog_snapshot() @@ -525,6 +344,7 @@ impl QueryExecutionContext<'_> { sids.dedup(); let mut matched_metadata = 0usize; let mut by_group: BTreeMap, Vec> = BTreeMap::new(); + let mut source_order = BTreeMap::new(); for sid in sids.iter().copied() { if self @@ -588,6 +408,7 @@ impl QueryExecutionContext<'_> { project_group_key(keys, &series.series_label_values) } }; + source_order.entry(key.clone()).or_insert(sid); by_group.entry(key).or_default().push(GroupState::Sketch { entries: vec![Rc::new(series)], kind, @@ -602,15 +423,20 @@ impl QueryExecutionContext<'_> { _ )) ) { - // Counter pane statistics are sufficient for Prometheus - // extrapolatedRate only when no query boundary cuts a - // pane. A partial pane would require its first/last - // in-range raw sample, which this SDS intentionally - // does not retain. Fail closed to the exact subtree. + // Sparse counters may have empty edge panes. Every + // stored pane must still lie wholly within the requested + // range and on its declared grid; cutting a pane would + // require raw boundary samples that this state lacks. let coverage = self .index .exact_agg_coverage_bounds(sid, self.t0_ms, self.t1_ms); - if coverage != Some((self.t0_ms, self.t1_ms)) { + let Some((start, end)) = coverage else { + continue; + }; + if start < self.t0_ms + || end > self.t1_ms + || !binding.covers_range(start, end) + { return Err(SummaryExecutorError::Unsupported( "counter SDS requires full-pane query coverage", )); @@ -648,6 +474,7 @@ impl QueryExecutionContext<'_> { } else { Arc::new(MaxAccumulator::with_value(value)) }; + source_order.entry(key.clone()).or_insert(sid); by_group.entry(key).or_default().push(GroupState::ExactAgg { entries: vec![Rc::new(BTreeMap::from([( self.t1_ms as i64, @@ -685,6 +512,7 @@ impl QueryExecutionContext<'_> { PhysicalGrouping::PerEntity => labels, PhysicalGrouping::Reduce(keys) => project_group_key(keys, &labels), }; + source_order.entry(key.clone()).or_insert(sid); by_group.entry(key).or_default().push(GroupState::ExactAgg { entries: vec![Rc::new(windows)], agg_type, @@ -703,18 +531,21 @@ impl QueryExecutionContext<'_> { ); return Err(SummaryExecutorError::NoCandidates); } - let result = by_group + let mut result: Vec<_> = by_group .into_iter() - .map(|(key, states)| { - ::merge_states(self, states).map(|state| (key, state)) - }) - .collect(); + .map(|(key, states)| self.merge_states(states).map(|state| (key, state))) + .collect::>()?; + if binding.output_grouping == PhysicalGrouping::PerEntity { + // Stable native ranking retains the first source on equal values. + // Grouping by labels must not replace the source's series order. + result.sort_by_key(|(key, _)| source_order[key]); + } if !inventory_revision.matches(self.index.summary_update_revision()) { return Err(SummaryExecutorError::Unsupported( "summary input changed during read", )); } - result + Ok(result) } pub fn readout_bound( @@ -722,14 +553,14 @@ impl QueryExecutionContext<'_> { state: &GroupState, query: &SketchQuery, ) -> Result { - ::readout(self, state, query) + self.readout(state, query) } pub fn merge_bound_states( &self, states: Vec, ) -> Result { - ::merge_states(self, states) + self.merge_states(states) } } @@ -746,166 +577,8 @@ fn fold_coverage(coverage: &mut Option<(u64, u64)>, w_end: i64) { }); } -impl<'a> SummaryExecutor for QueryExecutionContext<'a> { - type Handle = SidHandle; - type State = GroupState; - type Value = SummaryValue; - type Error = SummaryExecutorError; - type GroupKey = BTreeMap; - - fn find_candidates( - &self, - family: &SummaryFamilyType, - _col: &ColumnRef, - reduction: &Reduction, - child: &SummaryNode, - ) -> Result, Self::Error> { - if matches!( - family, - SummaryFamilyType::Plain(_) - | SummaryFamilyType::Sample(..) - | SummaryFamilyType::Wavelet(..) - | SummaryFamilyType::StatModel(..) - ) { - return Ok(Vec::new()); - } - let metric = find_metric(child).ok_or(SummaryExecutorError::NoMetricFound)?; - - let by: &[ColumnId] = reduction.group_keys().map(|k| k.keys()).unwrap_or(&[]); - let mut by_names: Vec = Vec::with_capacity(by.len()); - for &col_id in by { - let name = child - .schema - .fields - .get(col_id) - .map(|f| f.name.clone()) - .ok_or(SummaryExecutorError::UnresolvedColumn(col_id))?; - by_names.push(name); - } - let required_keys: BTreeSet = by_names.iter().cloned().collect(); - - // Which family a matching sid resolved to, carrying just enough - // to build its `SidHandle` -- kept local to this function since - // nothing outside needs a candidate BEFORE it's turned into a - // handle. - enum Candidate { - Sketch(DeltaSketchKind), - ExactAgg(AggregationType), - } - - // A compiled QueryPlan resolves materializations before activation. - // Serving follows the direct fingerprint -> SID reverse index; it - // never scans metric registrations to discover a compatible family. - let candidate_sids = if let Some(allowed) = &self.allowed_materializations { - let mut sids: Vec<_> = allowed - .iter() - .flat_map(|fingerprint| self.index.series_ids_for_policy(*fingerprint)) - .collect(); - sids.sort_unstable(); - sids.dedup(); - sids - } else { - // Compatibility-only callers without a formal QueryPlan. - self.index.instances_matching(&metric, &required_keys) - }; - let mut out = Vec::new(); - for sid in candidate_sids { - let candidate = self - .index - .with_instance(sid, |m| { - if self - .allowed_materializations - .as_ref() - .is_some_and(|allowed| !allowed.contains(&m.policy_fp)) - { - return None; - } - match &m.agg_kind { - AggKind::Sketch { - algorithm: kind, - config, - .. - } => summary_family_matches_sketch(family, kind.clone(), config) - .then(|| to_delta_kind(kind.clone(), config)) - .flatten() - .map(Candidate::Sketch), - AggKind::ExactAgg { agg_type, .. } => { - summary_family_matches_exact(family, *agg_type) - .then_some(Candidate::ExactAgg(*agg_type)) - } - } - }) - .flatten(); - let Some(candidate) = candidate else { - continue; - }; - - match candidate { - Candidate::Sketch(candidate_kind) => { - // Fetching the series here (rather than just checking - // membership) is what lets `fetch_state`/`readout` skip a - // second identical `query_range` call later -- see - // `SidHandle`'s doc. The label values it carries are also - // the only place a group's actual values live (metadata - // only has the group-by KEY names, not values). - let series = self.index.query_range(sid, self.t0_ms, self.t1_ms); - let Some(series) = series.into_iter().next() else { - continue; - }; - let group_key = - resolve_group_key(reduction, &by_names, &series.series_label_values); - out.push(( - group_key, - SidHandle::Sketch { - series: Rc::new(series), - kind: candidate_kind, - }, - )); - } - Candidate::ExactAgg(agg_type) => { - // `query_exact_agg_range` decodes eagerly (returns - // `Arc` per window already merged - // in-memory + disk) -- no raw-bytes/delta-frame - // handling analogous to the sketch path is needed. - let series_list = self - .index - .query_exact_agg_range(sid, self.t0_ms, self.t1_ms); - let Some((label_values, windows)) = series_list.into_iter().next() else { - continue; - }; - let group_key = resolve_group_key(reduction, &by_names, &label_values); - out.push(( - group_key, - SidHandle::ExactAgg { - windows: Rc::new(windows), - agg_type, - }, - )); - } - } - } - // Empty is NOT an error here -- `crate::query_engines::asap_query_engine::summary_exec::execute()` - // itself checks `find_candidates`'s result for emptiness and - // raises the canonical `ExecError::NoCandidates`; erroring here - // too would just wrap that in `ExecError::Executor(..)` instead, - // losing the distinction callers match on. - Ok(out) - } - - fn fetch_state(&self, handle: &Self::Handle) -> Result { - Ok(match handle { - SidHandle::Sketch { series, kind } => GroupState::Sketch { - kind: *kind, - entries: vec![series.clone()], - }, - SidHandle::ExactAgg { windows, agg_type } => GroupState::ExactAgg { - agg_type: *agg_type, - entries: vec![windows.clone()], - }, - }) - } - - fn merge_states(&self, states: Vec) -> Result { +impl QueryExecutionContext<'_> { + fn merge_states(&self, states: Vec) -> Result { // The actual decode/merge math (`cumulative_summary_state`/ // `merge_same_family`) happens in `readout`, not here: it needs // to distinguish cumulative vs. per-window mode @@ -945,14 +618,9 @@ impl<'a> SummaryExecutor for QueryExecutionContext<'a> { fn readout( &self, - state: &Self::State, + state: &GroupState, query: &SketchQuery, - ) -> Result { - // `ExactAgg` states never reach here in practice -- see this - // module's doc (`asap_aware_mapping::replacement` never wraps an ExactAccumulator - // in a `SummaryEstimate`, so `execute()` stops at - // `ExecOutcome::State` before ever calling `readout`). Defensive, - // not a real path. + ) -> Result { let GroupState::Sketch { entries, kind } = state else { return Err(SummaryExecutorError::UnsupportedFamily); }; @@ -962,10 +630,6 @@ impl<'a> SummaryExecutor for QueryExecutionContext<'a> { readout_per_window(entries, *kind, query, self.t0_ms as i64) } } - - fn logical(&self, _expr: &QueryExpr) -> Result { - Err(SummaryExecutorError::Logical) - } } /// Fold a group's whole `[t0, t1]` into one merged state and read out one @@ -1089,97 +753,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 @@ -1307,25 +896,6 @@ fn project_group_key( .collect() } -/// Construct group keys from the explicit post-ASAP reduction, for both -/// sketches and exact accumulators. -/// -/// * `PerEntity` preserves the full sid label map and keeps entities distinct. -/// * `Reduce(by)` projects onto the requested keys. Empty keys produce `{}` -/// for every candidate, merging all candidates into one group. -/// -/// An empty column list alone cannot distinguish these two semantics. -fn resolve_group_key( - reduction: &Reduction, - by_names: &[String], - label_values: &BTreeMap, -) -> BTreeMap { - match reduction { - Reduction::PerEntity => label_values.clone(), - Reduction::Reduce(_) => project_group_key(by_names, label_values), - } -} - /// `SketchConfig` (data_plane's per-sid stored params) -> `DeltaSketchKind` /// (`delta_apply`'s decode/merge parameter carrier). fn to_delta_kind(kind: SketchAlgorithm, config: &SketchConfig) -> Option { @@ -1391,24 +961,6 @@ fn to_delta_kind(kind: SketchAlgorithm, config: &SketchConfig) -> Option Option { - match &node.expr { - SummaryExpr::KeepPreAsap(qe) => find_metric_in_query_expr(qe), - SummaryExpr::SummaryAgg { child, .. } => find_metric(child), - SummaryExpr::SummaryEstimate { summary_input, .. } => find_metric(summary_input), - SummaryExpr::SummaryMerge { children, .. } => children.first().and_then(|c| find_metric(c)), - _ => None, - } -} - /// Walk a canonical `QueryExpr` down to its first `Scan { /// source: Source::TimeSeries { metric }, .. }` to recover the target /// metric name. @@ -1446,12 +998,11 @@ pub(crate) fn find_metric_in_query_expr(qe: &QueryExpr) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::query_engines::asap_query_engine::summary_exec::{execute, ExecOutcome}; use crate::storage_engines::sketch_db::index::{ AccuracyBound, Capability, SketchSampleState, SketchStore, SummarySeriesMetadata, }; use planner_types::post_asap::{SummaryField, SummarySchema}; - use planner_types::pre_asap::{Column, DataType, Schema}; + use planner_types::pre_asap::{Column, ColumnRef, DataType, Schema}; use std::rc::Rc; #[test] @@ -1472,12 +1023,11 @@ mod tests { }; assert_eq!( state.exact_value_for(ExactReadout::Count, &Some(key.clone()), 0, 60_000), - Some(2.0) - ); - assert_eq!( - state.exact_value_for(ExactReadout::Sum, &Some(key), 0, 60_000), - None + Ok(Some(2.0)) ); + assert!(state + .exact_value_for(ExactReadout::Sum, &Some(key), 0, 60_000) + .is_err()); } #[test] @@ -1532,123 +1082,6 @@ mod tests { assert!(validate_binding_phase(&legacy, 67_000).is_err()); } - fn sketch_family( - algorithm: planner_types::post_asap::SketchAlgorithm, - params: planner_types::post_asap::SketchParams, - ) -> SummaryFamilyType { - SummaryFamilyType::Sketch( - planner_types::post_asap::SketchKind::new(algorithm, params), - planner_types::post_asap::GroupingStrategy::default(), - ) - } - - fn scan_node(metric: &str, group_by_field: Option<&str>) -> Rc { - let qe = QueryExpr::Scan { - source: Source::TimeSeries { - metric: metric.to_string(), - }, - predicates: vec![], - schema: Schema::with_time_index( - vec![ - Column::new("ts", DataType::Timestamp, false), - Column::new("value", DataType::Float64, false), - ], - 0, - vec![], - ), - }; - let mut fields = vec![SummaryField { - name: "value".into(), - dtype: SummaryFamilyType::Plain(DataType::Float64), - nullable: false, - }]; - if let Some(name) = group_by_field { - fields.push(SummaryField { - name: name.into(), - dtype: SummaryFamilyType::Plain(DataType::Utf8), - nullable: false, - }); - } - Rc::new(SummaryNode { - expr: SummaryExpr::KeepPreAsap(Rc::new(qe)), - schema: SummarySchema { - fields, - time_index: None, - }, - guarantee: None, - }) - } - - fn kll_agg_node(child: Rc, reduction: Reduction) -> Rc { - Rc::new(SummaryNode { - expr: SummaryExpr::SummaryAgg { - child, - family: sketch_family( - planner_types::post_asap::SketchAlgorithm::Kll, - planner_types::post_asap::SketchParams::Kll { k: 200 }, - ), - input: planner_types::post_asap::SummaryUpdate { - item: None, - weight: planner_types::post_asap::SummaryInputExpr::Column( - ColumnRef::SampleValue, - ), - weight_domain: Default::default(), - }, - reduction, - grouping: planner_types::post_asap::GroupingStrategy::default(), - }, - schema: SummarySchema { - fields: vec![], - time_index: None, - }, - guarantee: None, - }) - } - - fn hll_agg_node(child: Rc) -> Rc { - hll_agg_node_with(child, Reduction::by(vec![])) - } - - fn hll_agg_node_with(child: Rc, reduction: Reduction) -> Rc { - Rc::new(SummaryNode { - expr: SummaryExpr::SummaryAgg { - child, - family: sketch_family( - planner_types::post_asap::SketchAlgorithm::Hll, - planner_types::post_asap::SketchParams::Hll { precision: 10 }, - ), - input: planner_types::post_asap::SummaryUpdate { - item: None, - weight: planner_types::post_asap::SummaryInputExpr::Column( - ColumnRef::SampleValue, - ), - weight_domain: Default::default(), - }, - reduction, - grouping: planner_types::post_asap::GroupingStrategy::default(), - }, - schema: SummarySchema { - fields: vec![], - time_index: None, - }, - guarantee: None, - }) - } - - fn estimate_node(summary_input: Rc, query: SketchQuery) -> Rc { - Rc::new(SummaryNode { - expr: SummaryExpr::SummaryEstimate { - summary_input, - query, - }, - schema: SummarySchema { - fields: vec![], - time_index: None, - }, - guarantee: None, - }) - } - fn kll_meta(sid: u64, metric: &str, group_by: &[&str]) -> SummarySeriesMetadata { let cfg = SketchConfig::Kll { k: 200 }; SummarySeriesMetadata { @@ -1672,26 +1105,6 @@ mod tests { } } - fn hll_meta(sid: u64, metric: &str) -> SummarySeriesMetadata { - let cfg = SketchConfig::Hll { precision: 10 }; - SummarySeriesMetadata { - storage_handle: sid, - metric_name: metric.to_string(), - group_by_keys: BTreeSet::new(), - capability: Some(Capability::CardinalityApprox), - agg_kind: crate::storage_engines::sketch_db::index::AggKind::Sketch { - algorithm: SketchAlgorithm::Hll, - config: cfg.clone(), - spatial_filter_canonical: String::new(), - }, - accuracy: Some(AccuracyBound::from_config(&cfg)), - first_seen_unix_ms: 0, - retired_at_ms: None, - expires_at_ms: None, - policy_fp: asap_types::PolicyFingerprint::UNSET, - } - } - fn encode_kll_items_proto(k: u16, items: &[f64]) -> Vec { use asap_sketchlib::proto::sketchlib::{sketch_envelope, KllState, SketchEnvelope}; use prost::Message; @@ -1709,35 +1122,6 @@ mod tests { env.encode_to_vec() } - fn encode_hll_from_items(precision: u32, items: &[&str]) -> Vec { - use asap_sketchlib::{HllSketch, HllVariant, MessagePackCodec}; - let mut sk = HllSketch::new(HllVariant::Regular, precision); - for item in items { - sk.update(item.as_bytes()); - } - sk.to_msgpack().expect("encode HLL msgpack") - } - - fn cms_meta(sid: u64, metric: &str) -> SummarySeriesMetadata { - let cfg = SketchConfig::CountMin { rows: 4, cols: 256 }; - SummarySeriesMetadata { - storage_handle: sid, - metric_name: metric.to_string(), - group_by_keys: BTreeSet::new(), - capability: Some(Capability::FrequencyEstimate(Some(SketchAlgorithm::Cms))), - agg_kind: crate::storage_engines::sketch_db::index::AggKind::Sketch { - algorithm: SketchAlgorithm::Cms, - config: cfg.clone(), - spatial_filter_canonical: String::new(), - }, - accuracy: Some(AccuracyBound::from_config(&cfg)), - first_seen_unix_ms: 0, - retired_at_ms: None, - expires_at_ms: None, - policy_fp: asap_types::PolicyFingerprint::UNSET, - } - } - /// Encode a CMS msgpack frame whose row-0 total is `total_weight` /// (`update`s a single synthetic key `total_weight` times -- row 0's /// sum equals the number of insertions regardless of hashing, since @@ -1751,171 +1135,6 @@ mod tests { sk.to_msgpack().expect("encode CountMinSketch msgpack") } - /// Encode a CMS msgpack frame with one `update` of `weight` for a - /// single named `key` -- unlike `encode_cms_with_total`'s synthetic - /// "k", this lets a test control which key a `PointCount` query looks - /// up. - fn encode_cms_with_item(rows: usize, cols: usize, key: &str, weight: f64) -> Vec { - use asap_sketchlib::{CountMinSketch, MessagePackCodec}; - let mut sk = CountMinSketch::new(rows, cols); - sk.update(key, weight); - sk.to_msgpack().expect("encode CountMinSketch msgpack") - } - - fn cms_agg_node(child: Rc) -> Rc { - Rc::new(SummaryNode { - expr: SummaryExpr::SummaryAgg { - child, - family: sketch_family( - planner_types::post_asap::SketchAlgorithm::Cms, - planner_types::post_asap::SketchParams::Cms { - width: 256, - depth: 4, - }, - ), - input: planner_types::post_asap::SummaryUpdate { - item: None, - weight: planner_types::post_asap::SummaryInputExpr::Column( - ColumnRef::SampleValue, - ), - weight_domain: Default::default(), - }, - reduction: Reduction::by(vec![]), - grouping: planner_types::post_asap::GroupingStrategy::default(), - }, - schema: SummarySchema { - fields: vec![], - time_index: None, - }, - guarantee: None, - }) - } - - fn cms_with_heap_meta(sid: u64, metric: &str) -> SummarySeriesMetadata { - let cfg = SketchConfig::CountMin { rows: 4, cols: 256 }; - SummarySeriesMetadata { - storage_handle: sid, - metric_name: metric.to_string(), - group_by_keys: BTreeSet::new(), - capability: Some(Capability::FrequencyTopk(Some( - SketchAlgorithm::CmsWithHeap, - ))), - agg_kind: crate::storage_engines::sketch_db::index::AggKind::Sketch { - algorithm: SketchAlgorithm::CmsWithHeap, - config: cfg.clone(), - spatial_filter_canonical: String::new(), - }, - accuracy: Some(AccuracyBound::from_config(&cfg)), - first_seen_unix_ms: 0, - retired_at_ms: None, - expires_at_ms: None, - policy_fp: asap_types::PolicyFingerprint::UNSET, - } - } - - /// Encode a `CmsWithHeap` msgpack frame with one `update` per - /// `(key, weight)` pair. - fn encode_cms_with_heap_items( - rows: usize, - cols: usize, - heap_size: usize, - items: &[(&str, f64)], - ) -> Vec { - use asap_sketchlib::{CountMinSketchWithHeap, MessagePackCodec}; - let mut sk = CountMinSketchWithHeap::new(rows, cols, heap_size); - for (key, weight) in items { - sk.update(key, *weight); - } - sk.to_msgpack() - .expect("encode CountMinSketchWithHeap msgpack") - } - - fn cms_with_heap_agg_node(child: Rc) -> Rc { - Rc::new(SummaryNode { - expr: SummaryExpr::SummaryAgg { - child, - family: sketch_family( - planner_types::post_asap::SketchAlgorithm::CmsWithHeap, - planner_types::post_asap::SketchParams::CmsWithHeap { - width: 256, - depth: 4, - heap_size: 10, - }, - ), - input: planner_types::post_asap::SummaryUpdate { - item: None, - weight: planner_types::post_asap::SummaryInputExpr::Column( - ColumnRef::SampleValue, - ), - weight_domain: Default::default(), - }, - reduction: Reduction::by(vec![]), - grouping: planner_types::post_asap::GroupingStrategy::default(), - }, - schema: SummarySchema { - fields: vec![], - time_index: None, - }, - guarantee: None, - }) - } - - // ── ExactAgg (Sum) fixtures ──────────────────────────────────────── - - fn sum_exact_agg_meta(sid: u64, metric: &str, group_by: &[&str]) -> SummarySeriesMetadata { - SummarySeriesMetadata { - storage_handle: sid, - metric_name: metric.to_string(), - group_by_keys: group_by - .iter() - .map(|s| s.to_string()) - .collect::>(), - capability: Some(Capability::ExactAgg(asap_types::AggregationType::Sum)), - agg_kind: crate::storage_engines::sketch_db::index::AggKind::ExactAgg { - agg_type: asap_types::AggregationType::Sum, - parameters_canonical: String::new(), - spatial_filter_canonical: String::new(), - }, - accuracy: None, - first_seen_unix_ms: 0, - retired_at_ms: None, - expires_at_ms: None, - policy_fp: asap_types::PolicyFingerprint::UNSET, - } - } - - fn sum_agg_node(child: Rc, by: Vec) -> Rc { - Rc::new(SummaryNode { - expr: SummaryExpr::SummaryAgg { - child, - family: SummaryFamilyType::ExactAggregate( - planner_types::post_asap::ExactKind::Sum, - planner_types::post_asap::ExactParams::Sum, - ), - input: planner_types::post_asap::SummaryUpdate { - item: None, - weight: planner_types::post_asap::SummaryInputExpr::Column( - ColumnRef::SampleValue, - ), - weight_domain: Default::default(), - }, - // Sum is a genuine PromQL aggregation operator -- an empty - // `by` always means "reduce fully," never `PerEntity` (see - // `resolve_group_key`'s doc). - reduction: Reduction::by(by), - grouping: planner_types::post_asap::GroupingStrategy::default(), - }, - schema: SummarySchema { - fields: vec![], - time_index: None, - }, - guarantee: None, - }) - } - - const T0: u64 = 1_000_000; - const T1: u64 = 2_000_000; - // Neighboring overlapping complete windows are not additional answer panes. #[test] fn full_window_sketch_read_excludes_neighboring_windows() { @@ -1957,6 +1176,15 @@ mod tests { pane_origin_ms: Some(0), readout_lookback_ms: Some(60_000), }; + let unauthorized = QueryExecutionContext { + allowed_materializations: Some(BTreeSet::from([asap_types::PolicyFingerprint(999)])), + ..context + }; + assert!(unauthorized.read_bound_materialization(&binding).is_err()); + let context = QueryExecutionContext { + allowed_materializations: Some(BTreeSet::from([fp])), + ..unauthorized + }; let states = context.read_bound_materialization(&binding).unwrap(); let SummaryValue::Points(points, coverage) = context .readout_bound(&states[0].1, &SketchQuery::Quantile { q: 0.5 }) @@ -2056,56 +1284,6 @@ mod tests { assert!(context.read_bound_materialization(&unknown).is_err()); } - fn ctx(index: &SketchStore) -> QueryExecutionContext<'_> { - QueryExecutionContext { - index, - t0_ms: T0, - t1_ms: T1, - is_cumulative: true, - allowed_materializations: None, - } - } - - fn matrix_ctx(index: &SketchStore) -> QueryExecutionContext<'_> { - QueryExecutionContext { - index, - t0_ms: T0, - t1_ms: T1, - is_cumulative: false, - allowed_materializations: None, - } - } - - #[test] - fn query_plan_materialization_filter_excludes_stale_sid() { - let idx = SketchStore::new(); - let mut stale = kll_meta(1, "latency_ms", &[]); - stale.policy_fp = asap_types::PolicyFingerprint(10); - idx.register(stale); - let exec = QueryExecutionContext { - index: &idx, - t0_ms: T0, - t1_ms: T1, - is_cumulative: true, - allowed_materializations: Some( - [asap_types::PolicyFingerprint(20)].into_iter().collect(), - ), - }; - let family = sketch_family( - planner_types::post_asap::SketchAlgorithm::Kll, - planner_types::post_asap::SketchParams::Kll { k: 200 }, - ); - let result = exec - .find_candidates( - &family, - &ColumnRef::SampleValue, - &Reduction::PerEntity, - &scan_node("latency_ms", None), - ) - .expect("candidate lookup"); - assert!(result.is_empty()); - } - #[test] fn typed_dds_quantile_interpolates_without_changing_portable_rank_semantics() { let mut sketch = asap_sketchlib::DdSketch::new(0.01); @@ -2139,1252 +1317,4 @@ mod tests { ) .is_err()); } - - #[test] - fn single_kll_sid_quantile_readout() { - let idx = SketchStore::new(); - let sid = 1u64; - idx.register(kll_meta(sid, "latency_ms", &[])); - let items: Vec = (1..=100).map(|i| i as f64).collect(); - idx.append_sample( - sid, - BTreeMap::new(), - (T0, T0 + 1000), - SketchSampleState { - bytes: encode_kll_items_proto(200, &items), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::ProtoFull, - }, - ); - - let child = scan_node("latency_ms", None); - let tree = estimate_node( - kll_agg_node(child, Reduction::by(vec![])), - SketchQuery::Quantile { q: 0.5 }, - ); - - let exec = ctx(&idx); - let ExecOutcome::Value(v) = execute(&tree, &exec).expect("execute should succeed") else { - panic!("expected a value"); - }; - assert_eq!(v.len(), 1, "ungrouped query produces exactly one group"); - let (_group, value) = &v[0]; - let SummaryValue::Points(samples, coverage) = value else { - panic!("expected Points, got {value:?}"); - }; - assert_eq!( - *coverage, - Some((T0 + 1000, T0 + 1000)), - "cumulative readout's coverage must reflect the single window observed" - ); - let (_ts, median) = samples[0]; - // Median of 1..=100 is ~50. - assert!( - (45.0..=55.0).contains(&median), - "median {median} out of range" - ); - } - - #[test] - fn two_sids_same_group_actually_merge_not_just_first() { - // Two sids covering the SAME group must be MERGED into one - // combined answer, not silently duplicated or one-of-them-dropped. - let idx = SketchStore::new(); - idx.register(kll_meta(1, "latency_ms", &[])); - idx.register(kll_meta(2, "latency_ms", &[])); - // sid 1: values 1..=50 (median ~25); sid 2: values 51..=100 (median ~75). - // Merged, the combined median should be ~50 -- NOT ~25 (if merge - // silently dropped sid 2) and NOT ~75 (if it dropped sid 1). - let items1: Vec = (1..=50).map(|i| i as f64).collect(); - let items2: Vec = (51..=100).map(|i| i as f64).collect(); - idx.append_sample( - 1, - BTreeMap::new(), - (T0, T0 + 1000), - SketchSampleState { - bytes: encode_kll_items_proto(200, &items1), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::ProtoFull, - }, - ); - idx.append_sample( - 2, - BTreeMap::new(), - (T0, T0 + 1000), - SketchSampleState { - bytes: encode_kll_items_proto(200, &items2), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::ProtoFull, - }, - ); - - let child = scan_node("latency_ms", None); - let tree = estimate_node( - kll_agg_node(child, Reduction::by(vec![])), - SketchQuery::Quantile { q: 0.5 }, - ); - - let exec = ctx(&idx); - let ExecOutcome::Value(v) = execute(&tree, &exec).expect("execute should succeed") else { - panic!("expected a value"); - }; - assert_eq!(v.len(), 1); - let (_group, value) = &v[0]; - let SummaryValue::Points(samples, _coverage) = value else { - panic!("expected Points, got {value:?}"); - }; - let (_ts, median) = samples[0]; - assert!( - (40.0..=60.0).contains(&median), - "merged median {median} should be ~50 (both sids' data combined), \ - not ~25 or ~75 (one sid dropped)" - ); - } - - #[test] - fn two_sids_different_groups_produce_two_series_not_one_merged_blob() { - // `quantile by (zone) (...)` must produce one output series per - // zone, not one series merging both zones together. - let idx = SketchStore::new(); - idx.register(kll_meta(1, "latency_ms", &["zone"])); - idx.register(kll_meta(2, "latency_ms", &["zone"])); - let items1: Vec = (1..=50).map(|i| i as f64).collect(); - let items2: Vec = (51..=100).map(|i| i as f64).collect(); - let mut labels_east = BTreeMap::new(); - labels_east.insert("zone".to_string(), "us-east".to_string()); - let mut labels_west = BTreeMap::new(); - labels_west.insert("zone".to_string(), "us-west".to_string()); - idx.append_sample( - 1, - labels_east, - (T0, T0 + 1000), - SketchSampleState { - bytes: encode_kll_items_proto(200, &items1), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::ProtoFull, - }, - ); - idx.append_sample( - 2, - labels_west, - (T0, T0 + 1000), - SketchSampleState { - bytes: encode_kll_items_proto(200, &items2), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::ProtoFull, - }, - ); - - let child = scan_node("latency_ms", Some("zone")); - // "zone" is field index 1 in `scan_node`'s schema (0 = value). - let tree = estimate_node( - kll_agg_node(child, Reduction::by(vec![1])), - SketchQuery::Quantile { q: 0.5 }, - ); - - let exec = ctx(&idx); - let ExecOutcome::Value(mut v) = execute(&tree, &exec).expect("execute should succeed") - else { - panic!("expected a value"); - }; - assert_eq!( - v.len(), - 2, - "two zones must produce two output series, not one merged blob" - ); - v.sort_by(|a, b| a.0.get("zone").cmp(&b.0.get("zone"))); - let (east_group, east_value) = &v[0]; - let (west_group, west_value) = &v[1]; - assert_eq!(east_group.get("zone").map(String::as_str), Some("us-east")); - assert_eq!(west_group.get("zone").map(String::as_str), Some("us-west")); - let SummaryValue::Points(east_samples, _coverage) = east_value else { - panic!("expected Points, got {east_value:?}"); - }; - let SummaryValue::Points(west_samples, _coverage) = west_value else { - panic!("expected Points, got {west_value:?}"); - }; - let east_median = east_samples[0].1; - let west_median = west_samples[0].1; - assert!( - (20.0..=30.0).contains(&east_median), - "us-east median {east_median} should reflect only sid 1's data (~25)" - ); - assert!( - (70.0..=80.0).contains(&west_median), - "us-west median {west_median} should reflect only sid 2's data (~75)" - ); - } - - #[test] - fn bare_per_series_query_keeps_distinct_series_separate_even_with_no_by() { - // The `Reduction::PerEntity` half of the empty-`by` ambiguity - // (ASAPController#163/#164/#165), confirmed against a real e2e - // shadow-mode run for `quantile_over_time(m[r])` (a bare per-series - // range function -- no PromQL `by(...)`, no label selector, so - // there's no grouping concept for the query to express at all, - // NOT a request to merge everything). Two sids with DIFFERENT real - // labels ("zone") under a `PerEntity` query must still produce TWO - // separate output series -- naively projecting onto an empty `by` - // would collapse both sids' group keys to the SAME `{}` and - // silently merge two unrelated distributions into one wrong - // answer. See `genuine_full_reduction_merges_distinct_series_unlike_per_entity` - // for the opposite (`Reduce([])`) case, which MUST merge. - let idx = SketchStore::new(); - idx.register(kll_meta(1, "latency_ms", &["zone"])); - idx.register(kll_meta(2, "latency_ms", &["zone"])); - let items1: Vec = (1..=50).map(|i| i as f64).collect(); - let items2: Vec = (51..=100).map(|i| i as f64).collect(); - let mut labels_east = BTreeMap::new(); - labels_east.insert("zone".to_string(), "us-east".to_string()); - let mut labels_west = BTreeMap::new(); - labels_west.insert("zone".to_string(), "us-west".to_string()); - idx.append_sample( - 1, - labels_east, - (T0, T0 + 1000), - SketchSampleState { - bytes: encode_kll_items_proto(200, &items1), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::ProtoFull, - }, - ); - idx.append_sample( - 2, - labels_west, - (T0, T0 + 1000), - SketchSampleState { - bytes: encode_kll_items_proto(200, &items2), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::ProtoFull, - }, - ); - - // No `by` requested at all -- mirrors `quantile_over_time(0.99, - // latency_ms[r])` with no `by(...)`/label selector. - let child = scan_node("latency_ms", Some("zone")); - let tree = estimate_node( - kll_agg_node(child, Reduction::PerEntity), - SketchQuery::Quantile { q: 0.5 }, - ); - - let exec = ctx(&idx); - let ExecOutcome::Value(mut v) = execute(&tree, &exec).expect("execute should succeed") - else { - panic!("expected a value"); - }; - assert_eq!( - v.len(), - 2, - "two distinct series must stay separate even with no explicit `by` -- \ - got {v:?}" - ); - v.sort_by(|a, b| a.0.get("zone").cmp(&b.0.get("zone"))); - let (east_group, east_value) = &v[0]; - let (west_group, west_value) = &v[1]; - assert_eq!(east_group.get("zone").map(String::as_str), Some("us-east")); - assert_eq!(west_group.get("zone").map(String::as_str), Some("us-west")); - let SummaryValue::Points(east_samples, _coverage) = east_value else { - panic!("expected Points, got {east_value:?}"); - }; - let SummaryValue::Points(west_samples, _coverage) = west_value else { - panic!("expected Points, got {west_value:?}"); - }; - assert!( - (20.0..=30.0).contains(&east_samples[0].1), - "us-east median {} should reflect only sid 1's data (~25), not a merge with sid 2", - east_samples[0].1 - ); - assert!( - (70.0..=80.0).contains(&west_samples[0].1), - "us-west median {} should reflect only sid 2's data (~75), not a merge with sid 1", - west_samples[0].1 - ); - } - - #[test] - fn hll_cardinality_readout() { - let idx = SketchStore::new(); - let sid = 1u64; - idx.register(hll_meta(sid, "unique_users")); - let items: Vec<&str> = vec!["a", "b", "c", "d", "e"]; - idx.append_sample( - sid, - BTreeMap::new(), - (T0, T0 + 1000), - SketchSampleState { - bytes: encode_hll_from_items(10, &items), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::MsgpackFull, - }, - ); - - let child = scan_node("unique_users", None); - let tree = estimate_node(hll_agg_node(child), SketchQuery::Cardinality); - - let exec = ctx(&idx); - let ExecOutcome::Value(v) = execute(&tree, &exec).expect("execute should succeed") else { - panic!("expected a value"); - }; - let (_group, value) = &v[0]; - let SummaryValue::Points(samples, _coverage) = value else { - panic!("expected Points, got {value:?}"); - }; - let (_ts, card) = samples[0]; - assert!( - (3.0..=7.0).contains(&card), - "cardinality {card} should be ~5" - ); - } - - #[test] - fn genuine_full_reduction_merges_distinct_series_unlike_per_entity() { - // The other half of the empty-`by` ambiguity `Reduction` resolves - // (ASAPController#163/#164/#165): a bare per-series range function - // like `quantile_over_time(m[r])` (Reduction::PerEntity, see - // `bare_per_series_query_keeps_distinct_series_separate_even_with_no_by` - // above) must NOT merge distinct series, but a genuine - // cross-series reduction with zero grouping columns -- - // `count(hll_metric)`-shaped, Reduction::Reduce(GroupKeys::by([])) - // -- legitimately MUST merge them into one combined answer. Before - // `find_candidates` took `&Reduction` instead of `&[ColumnId]`, - // these two cases were indistinguishable from an empty `by` alone - // (the old `sketch_group_key` heuristic could only ever pick ONE - // of the two behaviors for every empty-`by` query). - // - // Two HLL sids with DIFFERENT real "zone" labels (same shape as - // the PerEntity test above) and DISJOINT item sets: under a - // genuine `Reduce([])`, they must merge into ONE group whose - // cardinality reflects BOTH sids' items combined (~10), not two - // separate ~5-item answers. - let idx = SketchStore::new(); - idx.register(hll_meta(1, "unique_users")); - idx.register(hll_meta(2, "unique_users")); - let items1: Vec<&str> = vec!["a", "b", "c", "d", "e"]; - let items2: Vec<&str> = vec!["f", "g", "h", "i", "j"]; - let mut labels_east = BTreeMap::new(); - labels_east.insert("zone".to_string(), "us-east".to_string()); - let mut labels_west = BTreeMap::new(); - labels_west.insert("zone".to_string(), "us-west".to_string()); - idx.append_sample( - 1, - labels_east, - (T0, T0 + 1000), - SketchSampleState { - bytes: encode_hll_from_items(10, &items1), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::MsgpackFull, - }, - ); - idx.append_sample( - 2, - labels_west, - (T0, T0 + 1000), - SketchSampleState { - bytes: encode_hll_from_items(10, &items2), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::MsgpackFull, - }, - ); - - let child = scan_node("unique_users", Some("zone")); - let tree = estimate_node( - hll_agg_node_with(child, Reduction::by(vec![])), - SketchQuery::Cardinality, - ); - - let exec = ctx(&idx); - let ExecOutcome::Value(v) = execute(&tree, &exec).expect("execute should succeed") else { - panic!("expected a value"); - }; - assert_eq!( - v.len(), - 1, - "a genuine full reduction must merge both sids into one group, got {v:?}" - ); - let (_group, value) = &v[0]; - let SummaryValue::Points(samples, _coverage) = value else { - panic!("expected Points, got {value:?}"); - }; - let (_ts, card) = samples[0]; - assert!( - (8.0..=12.0).contains(&card), - "merged cardinality {card} should be ~10 (both sids' disjoint item sets combined), \ - not ~5 (one sid dropped or kept separate)" - ); - } - - #[test] - fn single_cms_sid_total_readout() { - let idx = SketchStore::new(); - let sid = 1u64; - idx.register(cms_meta(sid, "requests_total")); - idx.append_sample( - sid, - BTreeMap::new(), - (T0, T0 + 1000), - SketchSampleState { - bytes: encode_cms_with_total(4, 256, 42), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::MsgpackFull, - }, - ); - - let child = scan_node("requests_total", None); - let tree = estimate_node( - cms_agg_node(child), - SketchQuery::PointCount { - key: ColumnRef::SampleValue, - value: None, - }, - ); - - let exec = ctx(&idx); - let ExecOutcome::Value(v) = execute(&tree, &exec).expect("execute should succeed") else { - panic!("expected a value"); - }; - let (_group, value) = &v[0]; - let SummaryValue::Points(samples, _coverage) = value else { - panic!("expected Points, got {value:?}"); - }; - let (_ts, total) = samples[0]; - assert_eq!( - total, 42.0, - "bare total must equal the number of insertions" - ); - } - - #[test] - fn two_cms_sids_same_group_totals_actually_merge() { - // Cross-sid merge for the Frequency family: two sids' totals must - // ADD (matrix merge then row-0 sum), not just report one of them. - let idx = SketchStore::new(); - idx.register(cms_meta(1, "requests_total")); - idx.register(cms_meta(2, "requests_total")); - idx.append_sample( - 1, - BTreeMap::new(), - (T0, T0 + 1000), - SketchSampleState { - bytes: encode_cms_with_total(4, 256, 30), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::MsgpackFull, - }, - ); - idx.append_sample( - 2, - BTreeMap::new(), - (T0, T0 + 1000), - SketchSampleState { - bytes: encode_cms_with_total(4, 256, 12), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::MsgpackFull, - }, - ); - - let child = scan_node("requests_total", None); - let tree = estimate_node( - cms_agg_node(child), - SketchQuery::PointCount { - key: ColumnRef::SampleValue, - value: None, - }, - ); - - let exec = ctx(&idx); - let ExecOutcome::Value(v) = execute(&tree, &exec).expect("execute should succeed") else { - panic!("expected a value"); - }; - let (_group, value) = &v[0]; - let SummaryValue::Points(samples, _coverage) = value else { - panic!("expected Points, got {value:?}"); - }; - let (_ts, total) = samples[0]; - assert_eq!( - total, 42.0, - "merged total must be the SUM of both sids (30 + 12)" - ); - } - - #[test] - fn single_cms_sid_named_key_point_estimate() { - let idx = SketchStore::new(); - let sid = 1u64; - idx.register(cms_meta(sid, "requests_by_route")); - idx.append_sample( - sid, - BTreeMap::new(), - (T0, T0 + 1000), - SketchSampleState { - bytes: encode_cms_with_item(4, 256, "checkout", 17.0), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::MsgpackFull, - }, - ); - - let child = scan_node("requests_by_route", None); - let tree = estimate_node( - cms_agg_node(child), - SketchQuery::PointCount { - key: ColumnRef::Named("item".to_string()), - value: Some("checkout".to_string()), - }, - ); - - let exec = ctx(&idx); - let ExecOutcome::Value(v) = execute(&tree, &exec).expect("execute should succeed") else { - panic!("expected a value"); - }; - let (_group, value) = &v[0]; - let SummaryValue::Points(samples, _coverage) = value else { - panic!("expected Points, got {value:?}"); - }; - let (_ts, estimate) = samples[0]; - assert_eq!( - estimate, 17.0, - "named-key point estimate must equal that key's own insertions" - ); - } - - #[test] - fn two_cms_sids_same_group_named_key_estimate_merges_cross_sid() { - // Cross-sid merge for a NAMED-KEY point lookup: the same key's - // weight contributed by two different sids must ADD (matrix merge - // then keyed estimate), not just report one sid's contribution -- - // the point-lookup analog of `two_cms_sids_same_group_totals_actually_merge`. - let idx = SketchStore::new(); - idx.register(cms_meta(1, "requests_by_route")); - idx.register(cms_meta(2, "requests_by_route")); - idx.append_sample( - 1, - BTreeMap::new(), - (T0, T0 + 1000), - SketchSampleState { - bytes: encode_cms_with_item(4, 256, "checkout", 30.0), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::MsgpackFull, - }, - ); - idx.append_sample( - 2, - BTreeMap::new(), - (T0, T0 + 1000), - SketchSampleState { - bytes: encode_cms_with_item(4, 256, "checkout", 12.0), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::MsgpackFull, - }, - ); - - let child = scan_node("requests_by_route", None); - let tree = estimate_node( - cms_agg_node(child), - SketchQuery::PointCount { - key: ColumnRef::Named("item".to_string()), - value: Some("checkout".to_string()), - }, - ); - - let exec = ctx(&idx); - let ExecOutcome::Value(v) = execute(&tree, &exec).expect("execute should succeed") else { - panic!("expected a value"); - }; - let (_group, value) = &v[0]; - let SummaryValue::Points(samples, _coverage) = value else { - panic!("expected Points, got {value:?}"); - }; - let (_ts, estimate) = samples[0]; - assert_eq!( - estimate, 42.0, - "merged named-key estimate must be the SUM of both sids (30 + 12)" - ); - } - - #[test] - fn topk_query_against_non_heap_sketch_is_unsupported() { - // A heap-less family (Cms/CountSketch/Dd/Hll/Kll) carries no item - // universe to rank -- TopK must still error for it. This is a - // FAMILY limitation (see `topk_ranked`), not "TopK is - // unimplemented" -- contrast with the heap-bearing TopK tests - // below, which succeed. - let idx = SketchStore::new(); - let sid = 1u64; - idx.register(cms_meta(sid, "requests_total")); - idx.append_sample( - sid, - BTreeMap::new(), - (T0, T0 + 1000), - SketchSampleState { - bytes: encode_cms_with_total(4, 256, 1), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::MsgpackFull, - }, - ); - let child = scan_node("requests_total", None); - let tree = estimate_node(cms_agg_node(child), SketchQuery::TopK { k: 5 }); - let exec = ctx(&idx); - match execute(&tree, &exec) { - Err(crate::query_engines::asap_query_engine::summary_exec::ExecError::Executor( - SummaryExecutorError::Unsupported(_), - )) => {} - other => panic!("expected Unsupported, got {}", other.is_ok()), - } - } - - #[test] - fn single_cms_with_heap_sid_topk_readout_sorted_and_capped() { - let idx = SketchStore::new(); - let sid = 1u64; - idx.register(cms_with_heap_meta(sid, "requests_by_route")); - idx.append_sample( - sid, - BTreeMap::new(), - (T0, T0 + 1000), - SketchSampleState { - bytes: encode_cms_with_heap_items( - 4, - 256, - 10, - &[ - ("a", 10.0), - ("b", 20.0), - ("c", 30.0), - ("d", 40.0), - ("e", 50.0), - ], - ), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::MsgpackFull, - }, - ); - - let child = scan_node("requests_by_route", None); - let tree = estimate_node(cms_with_heap_agg_node(child), SketchQuery::TopK { k: 3 }); - - let exec = ctx(&idx); - let ExecOutcome::Value(v) = execute(&tree, &exec).expect("execute should succeed") else { - panic!("expected a value"); - }; - let (_group, value) = &v[0]; - let SummaryValue::TopK(points, _coverage) = value else { - panic!("expected TopK, got {value:?}"); - }; - assert_eq!(points.len(), 1, "cumulative query produces one point"); - let (_ts, items) = &points[0]; - assert_eq!(items.len(), 3, "must be capped at k=3"); - let keys: Vec<&str> = items.iter().map(|(k, _)| k.as_str()).collect(); - assert_eq!( - keys, - vec!["e", "d", "c"], - "must be sorted descending by value (50, 40, 30), not heap-insertion order" - ); - } - - #[test] - fn two_cms_with_heap_sids_same_group_topk_merges_cross_sid() { - // Two sids in the same group with DISJOINT key sets -- the merged - // top-k must contain BOTH sids' keys, proving the readout actually - // merges cross-sid (via `merge_same_family`/`asap_sketchlib`'s heap - // reconciliation) rather than just reading out one sid's heap. - let idx = SketchStore::new(); - idx.register(cms_with_heap_meta(1, "requests_by_route")); - idx.register(cms_with_heap_meta(2, "requests_by_route")); - idx.append_sample( - 1, - BTreeMap::new(), - (T0, T0 + 1000), - SketchSampleState { - bytes: encode_cms_with_heap_items(4, 256, 10, &[("a", 30.0)]), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::MsgpackFull, - }, - ); - idx.append_sample( - 2, - BTreeMap::new(), - (T0, T0 + 1000), - SketchSampleState { - bytes: encode_cms_with_heap_items(4, 256, 10, &[("b", 40.0)]), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::MsgpackFull, - }, - ); - - let child = scan_node("requests_by_route", None); - let tree = estimate_node(cms_with_heap_agg_node(child), SketchQuery::TopK { k: 5 }); - - let exec = ctx(&idx); - let ExecOutcome::Value(v) = execute(&tree, &exec).expect("execute should succeed") else { - panic!("expected a value"); - }; - let (_group, value) = &v[0]; - let SummaryValue::TopK(points, _coverage) = value else { - panic!("expected TopK, got {value:?}"); - }; - let (_ts, items) = &points[0]; - let keys: std::collections::BTreeSet<&str> = - items.iter().map(|(k, _)| k.as_str()).collect(); - assert!( - keys.contains("a") && keys.contains("b"), - "merged top-k must contain both sids' keys, got {items:?}" - ); - } - - #[test] - fn per_window_matrix_topk_produces_per_window_ranked_lists() { - let idx = SketchStore::new(); - let sid = 1u64; - idx.register(cms_with_heap_meta(sid, "requests_by_route")); - let w1_end = T0 + 100_000; - let w2_end = T0 + 200_000; - idx.append_sample( - sid, - BTreeMap::new(), - (T0, w1_end), - SketchSampleState { - bytes: encode_cms_with_heap_items(4, 256, 10, &[("x", 100.0)]), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::MsgpackFull, - }, - ); - idx.append_sample( - sid, - BTreeMap::new(), - (w1_end, w2_end), - SketchSampleState { - bytes: encode_cms_with_heap_items(4, 256, 10, &[("y", 200.0)]), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::MsgpackFull, - }, - ); - - let child = scan_node("requests_by_route", None); - let tree = estimate_node(cms_with_heap_agg_node(child), SketchQuery::TopK { k: 2 }); - - let exec = matrix_ctx(&idx); - let ExecOutcome::Value(v) = execute(&tree, &exec).expect("execute should succeed") else { - panic!("expected a value"); - }; - let (_group, value) = &v[0]; - let SummaryValue::TopK(mut points, _coverage) = value.clone() else { - panic!("expected TopK, got {value:?}"); - }; - points.sort_by_key(|(ts, _)| *ts); - assert_eq!( - points.len(), - 2, - "two distinct windows must produce two independently-ranked points" - ); - assert_eq!(points[0].0, w1_end as i64); - assert_eq!(points[1].0, w2_end as i64); - assert_eq!(points[0].1[0].0, "x", "window 1's top key is x"); - assert_eq!(points[1].1[0].0, "y", "window 2's top key is y"); - } - - #[test] - fn no_matching_sid_is_no_candidates() { - let idx = SketchStore::new(); - let child = scan_node("nonexistent_metric", None); - let tree = estimate_node( - kll_agg_node(child, Reduction::by(vec![])), - SketchQuery::Quantile { q: 0.5 }, - ); - let exec = ctx(&idx); - match execute(&tree, &exec) { - Err(crate::query_engines::asap_query_engine::summary_exec::ExecError::NoCandidates) => { - } - other => panic!("expected NoCandidates, got {}", other.is_ok()), - } - } - - #[test] - fn mismatched_params_does_not_match() { - // sid is Kll{k: 200}; query wants Kll{k: 500} -- must NOT match, - // even though both are "Kll". - let idx = SketchStore::new(); - idx.register(kll_meta(1, "latency_ms", &[])); - idx.append_sample( - 1, - BTreeMap::new(), - (T0, T0 + 1000), - SketchSampleState { - bytes: encode_kll_items_proto(200, &[1.0, 2.0, 3.0]), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::ProtoFull, - }, - ); - let child = scan_node("latency_ms", None); - let mismatched = Rc::new(SummaryNode { - expr: SummaryExpr::SummaryAgg { - child, - family: sketch_family( - planner_types::post_asap::SketchAlgorithm::Kll, - planner_types::post_asap::SketchParams::Kll { k: 500 }, - ), - input: planner_types::post_asap::SummaryUpdate { - item: None, - weight: planner_types::post_asap::SummaryInputExpr::Column( - ColumnRef::SampleValue, - ), - weight_domain: Default::default(), - }, - reduction: Reduction::by(vec![]), - grouping: planner_types::post_asap::GroupingStrategy::default(), - }, - schema: SummarySchema { - fields: vec![], - time_index: None, - }, - guarantee: None, - }); - let tree = estimate_node(mismatched, SketchQuery::Quantile { q: 0.5 }); - let exec = ctx(&idx); - match execute(&tree, &exec) { - Err(crate::query_engines::asap_query_engine::summary_exec::ExecError::NoCandidates) => { - } - other => panic!( - "expected NoCandidates (param mismatch), got {}", - other.is_ok() - ), - } - } - - #[test] - fn per_window_matrix_produces_multiple_points_for_one_sid() { - let idx = SketchStore::new(); - let sid = 1u64; - idx.register(kll_meta(sid, "latency_ms", &[])); - let w1_end = T0 + 100_000; - let w2_end = T0 + 200_000; - let items_w1: Vec = (1..=50).map(|i| i as f64).collect(); - let items_w2: Vec = (901..=1000).map(|i| i as f64).collect(); - idx.append_sample( - sid, - BTreeMap::new(), - (T0, w1_end), - SketchSampleState { - bytes: encode_kll_items_proto(200, &items_w1), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::ProtoFull, - }, - ); - idx.append_sample( - sid, - BTreeMap::new(), - (w1_end, w2_end), - SketchSampleState { - bytes: encode_kll_items_proto(200, &items_w2), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::ProtoFull, - }, - ); - - let child = scan_node("latency_ms", None); - let tree = estimate_node( - kll_agg_node(child, Reduction::by(vec![])), - SketchQuery::Quantile { q: 0.5 }, - ); - - let exec = matrix_ctx(&idx); - let ExecOutcome::Value(v) = execute(&tree, &exec).expect("execute should succeed") else { - panic!("expected a value"); - }; - assert_eq!(v.len(), 1, "ungrouped query produces exactly one group"); - let (_group, value) = v.into_iter().next().unwrap(); - let SummaryValue::Points(mut samples, coverage) = value else { - panic!("expected Points"); - }; - assert_eq!( - coverage, - Some((w1_end, w2_end)), - "fully-covered group's coverage must bracket every window-end observed" - ); - samples.sort_by_key(|(ts, _)| *ts); - assert_eq!( - samples.len(), - 2, - "two distinct windows must produce two output points, not one collapsed answer" - ); - assert_eq!(samples[0].0, w1_end as i64); - assert_eq!(samples[1].0, w2_end as i64); - assert!( - (20.0..=30.0).contains(&samples[0].1), - "window 1 median {} should be ~25 (items 1..=50)", - samples[0].1 - ); - assert!( - (940.0..=960.0).contains(&samples[1].1), - "window 2 median {} should be ~950 (items 901..=1000)", - samples[1].1 - ); - } - - #[test] - fn per_window_matrix_merges_across_sids_per_window() { - // Two sids in the SAME group, both contributing a frame to the - // SAME window_end -- the per-window answer for that window must - // reflect BOTH sids merged, not just one (the cross-sid analog of - // `two_sids_same_group_actually_merge_not_just_first`, but for - // one window instead of the whole cumulative range). - let idx = SketchStore::new(); - idx.register(kll_meta(1, "latency_ms", &[])); - idx.register(kll_meta(2, "latency_ms", &[])); - let w_end = T0 + 100_000; - let items1: Vec = (1..=50).map(|i| i as f64).collect(); - let items2: Vec = (51..=100).map(|i| i as f64).collect(); - idx.append_sample( - 1, - BTreeMap::new(), - (T0, w_end), - SketchSampleState { - bytes: encode_kll_items_proto(200, &items1), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::ProtoFull, - }, - ); - idx.append_sample( - 2, - BTreeMap::new(), - (T0, w_end), - SketchSampleState { - bytes: encode_kll_items_proto(200, &items2), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::ProtoFull, - }, - ); - - let child = scan_node("latency_ms", None); - let tree = estimate_node( - kll_agg_node(child, Reduction::by(vec![])), - SketchQuery::Quantile { q: 0.5 }, - ); - - let exec = matrix_ctx(&idx); - let ExecOutcome::Value(v) = execute(&tree, &exec).expect("execute should succeed") else { - panic!("expected a value"); - }; - let (_group, value) = v.into_iter().next().unwrap(); - let SummaryValue::Points(samples, _coverage) = value else { - panic!("expected Points"); - }; - assert_eq!( - samples.len(), - 1, - "both sids share one window_end -> one output point" - ); - let (ts, median) = samples[0]; - assert_eq!(ts, w_end as i64); - assert!( - (40.0..=60.0).contains(&median), - "merged per-window median {median} should be ~50 (both sids' data combined)" - ); - } - - #[test] - fn per_window_matrix_drops_carry_in_base_before_t0() { - // `SketchStore::query_range` may splice in a carry-in Full ending - // BEFORE `t0_ms` to seed the delta-apply walk. That base must not - // surface as an output point. - let idx = SketchStore::new(); - let sid = 1u64; - idx.register(kll_meta(sid, "latency_ms", &[])); - let carry_in_end = T0 - 50_000; // before t0 - let in_range_end = T0 + 100_000; - idx.append_sample( - sid, - BTreeMap::new(), - (T0 - 100_000, carry_in_end), - SketchSampleState { - bytes: encode_kll_items_proto(200, &[1.0, 2.0, 3.0]), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::ProtoFull, - }, - ); - idx.append_sample( - sid, - BTreeMap::new(), - (carry_in_end, in_range_end), - SketchSampleState { - bytes: encode_kll_items_proto(200, &[10.0, 20.0, 30.0]), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::ProtoFull, - }, - ); - - let child = scan_node("latency_ms", None); - let tree = estimate_node( - kll_agg_node(child, Reduction::by(vec![])), - SketchQuery::Quantile { q: 0.5 }, - ); - - let exec = matrix_ctx(&idx); - let ExecOutcome::Value(v) = execute(&tree, &exec).expect("execute should succeed") else { - panic!("expected a value"); - }; - let (_group, value) = v.into_iter().next().unwrap(); - let SummaryValue::Points(samples, _coverage) = value else { - panic!("expected Points"); - }; - assert_eq!( - samples.len(), - 1, - "the carry-in-base window (ending before t0) must not appear in output" - ); - assert_eq!(samples[0].0, in_range_end as i64); - } - - #[test] - fn per_window_matrix_coverage_includes_carry_in_base_before_t0() { - // A GENUINE carry-in splice (unlike the test above, whose earlier - // window is Full-encoded and simply falls outside `query_range`'s - // overlap scan entirely): `SketchStore::query_range` splices in the - // most-recent Full snapshot ending before `t0_ms` when the - // earliest IN-WINDOW frame is a Delta, so the delta-apply walk has - // a rolling base to apply onto (see `query_range`'s doc). That - // splice must not surface as its own output point (existing - // behavior), but its window-end DOES extend this group's observed - // coverage further back than the first in-range point -- coverage - // is folded from RAW window-ends before the `w_end < t0_ms` output - // filter (see `SummaryValue`'s doc). - let idx = SketchStore::new(); - let sid = 1u64; - idx.register(cms_with_heap_meta(sid, "requests_by_route")); - - let carry_in_end = T0 - 50_000; // strictly before t0 -- Full base - let delta_window_end = T0 + 50_000; // in-range -- Delta, needs the base - - idx.append_sample( - sid, - BTreeMap::new(), - (T0 - 100_000, carry_in_end), - SketchSampleState { - bytes: encode_cms_with_heap_items(4, 256, 10, &[("a", 5.0)]), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::MsgpackFull, - }, - ); - // Real DELTA-HEAP wire shape (`(is_delta, (rows, cols, cells), - // heap, heap_size)`, see `delta_apply.rs`'s `encode_delta_heap` - // test helper) -- zero matrix cells (no change), heap replaces - // wholesale with `{b: 3.0}`. - #[derive(serde::Serialize)] - struct DeltaHeapFrame<'a>( - bool, - (u32, u32, &'a [(u32, u32, i64)]), - Vec<(String, f64)>, - u64, - ); - let cells: Vec<(u32, u32, i64)> = vec![]; - let delta_bytes = rmp_serde::to_vec(&DeltaHeapFrame( - true, - (4, 256, &cells), - vec![("b".to_string(), 3.0)], - 10, - )) - .expect("encode delta-heap frame"); - idx.append_sample( - sid, - BTreeMap::new(), - (carry_in_end, delta_window_end), - SketchSampleState { - bytes: delta_bytes, - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::MsgpackDelta, - }, - ); - - let child = scan_node("requests_by_route", None); - let tree = estimate_node(cms_with_heap_agg_node(child), SketchQuery::TopK { k: 5 }); - - let exec = matrix_ctx(&idx); - let ExecOutcome::Value(v) = execute(&tree, &exec).expect("execute should succeed") else { - panic!("expected a value"); - }; - let (_group, value) = v.into_iter().next().unwrap(); - let SummaryValue::TopK(points, coverage) = value else { - panic!("expected TopK, got {value:?}"); - }; - assert_eq!( - points.len(), - 1, - "the carry-in base must not appear as its own output point" - ); - assert_eq!(points[0].0, delta_window_end as i64); - assert_eq!( - coverage, - Some((carry_in_end, delta_window_end)), - "coverage must extend back to the carry-in base's window-end, even \ - though it never appears as an output point itself" - ); - } - - // ── ExactAgg (Sum) support ────────────────────────────────────────── - - #[test] - fn single_sum_exactagg_sid_exact_value_merges_windows() { - // A bare `SummaryAgg` over an ExactAgg(Sum) sid resolves to - // `ExecOutcome::State` directly (never `Value` -- see this - // module's doc), and `GroupState::exact_value` must fold every - // window in range into one combined sum. - let idx = SketchStore::new(); - let sid = 1u64; - idx.register(sum_exact_agg_meta(sid, "bytes_total", &[])); - idx.append_precompute( - sid, - BTreeMap::new(), - (T0, T0 + 1000), - Box::new(asap_physical_operators::summary_kernels::SumAccumulator::with_sum(10.0)), - ); - idx.append_precompute( - sid, - BTreeMap::new(), - (T0 + 1000, T0 + 2000), - Box::new(asap_physical_operators::summary_kernels::SumAccumulator::with_sum(15.0)), - ); - - let child = scan_node("bytes_total", None); - let tree = sum_agg_node(child, vec![]); - - let exec = ctx(&idx); - let ExecOutcome::State(groups) = execute(&tree, &exec).expect("execute should succeed") - else { - panic!("expected State, not Value -- ExactAgg never reaches readout"); - }; - assert_eq!( - groups.len(), - 1, - "ungrouped query produces exactly one group" - ); - let (_key, state, family) = &groups[0]; - assert_eq!( - *family, - SummaryFamilyType::ExactAggregate( - planner_types::post_asap::ExactKind::Sum, - planner_types::post_asap::ExactParams::Sum - ) - ); - assert_eq!( - state.exact_value(&None), - Some(25.0), - "exact_value must merge both windows' sums (10 + 15)" - ); - assert_eq!( - state.exact_coverage(), - Some((T0 + 1000, T0 + 2000)), - "exact_coverage must bracket both windows' end timestamps, mirroring \ - SummaryValue::coverage()'s sketch-family behavior" - ); - } - - #[test] - fn exact_coverage_is_none_for_a_sketch_state() { - // `exact_coverage` is the `ExactAgg`-only counterpart to - // `SummaryValue::coverage()` -- must not silently return something - // for a `Sketch` state. - let idx = SketchStore::new(); - let sid = 1u64; - idx.register(kll_meta(sid, "latency_ms", &[])); - idx.append_sample( - sid, - BTreeMap::new(), - (T0, T0 + 1000), - SketchSampleState { - bytes: encode_kll_items_proto(200, &[1.0, 2.0, 3.0]), - encoding: crate::storage_engines::sketch_db::index::SketchEncoding::ProtoFull, - }, - ); - let child = scan_node("latency_ms", None); - let tree = estimate_node( - kll_agg_node(child, Reduction::by(vec![])), - SketchQuery::Quantile { q: 0.5 }, - ); - let exec = ctx(&idx); - let ExecOutcome::Value(_) = execute(&tree, &exec).expect("execute should succeed") else { - panic!("expected a value"); - }; - // Build the Sketch GroupState directly via fetch_state to exercise - // exact_coverage's defensive None arm (readout() already proved - // this tree resolves to a real Sketch Value above). - let handles = exec - .find_candidates( - &sketch_family( - planner_types::post_asap::SketchAlgorithm::Kll, - planner_types::post_asap::SketchParams::Kll { k: 200 }, - ), - &ColumnRef::SampleValue, - &Reduction::by(vec![]), - &scan_node("latency_ms", None), - ) - .expect("find_candidates should succeed"); - let (_key, handle) = &handles[0]; - let state = exec - .fetch_state(handle) - .expect("fetch_state should succeed"); - assert_eq!( - state.exact_coverage(), - None, - "exact_coverage must be None for a Sketch state, not silently Some" - ); - } - - #[test] - fn two_sum_exactagg_sids_same_group_merge_cross_sid() { - // Cross-sid merge for ExactAgg: two sids in the same (ungrouped) - // group must ADD, not just report one of them -- the ExactAgg - // analog of `two_cms_sids_same_group_totals_actually_merge`. - let idx = SketchStore::new(); - idx.register(sum_exact_agg_meta(1, "bytes_total", &[])); - idx.register(sum_exact_agg_meta(2, "bytes_total", &[])); - idx.append_precompute( - 1, - BTreeMap::new(), - (T0, T0 + 1000), - Box::new(asap_physical_operators::summary_kernels::SumAccumulator::with_sum(30.0)), - ); - idx.append_precompute( - 2, - BTreeMap::new(), - (T0, T0 + 1000), - Box::new(asap_physical_operators::summary_kernels::SumAccumulator::with_sum(12.0)), - ); - - let child = scan_node("bytes_total", None); - let tree = sum_agg_node(child, vec![]); - - let exec = ctx(&idx); - let ExecOutcome::State(groups) = execute(&tree, &exec).expect("execute should succeed") - else { - panic!("expected State, not Value -- ExactAgg never reaches readout"); - }; - assert_eq!(groups.len(), 1); - let (_key, state, ..) = &groups[0]; - assert_eq!( - state.exact_value(&None), - Some(42.0), - "merged exact_value must be the SUM of both sids (30 + 12)" - ); - } - - #[test] - fn max_exactagg_sid_is_not_matched() { - // `ExactKind::Max` is deliberately NOT matched against ExactAgg - // sids by the legacy family-discovery path (see - // `summary_family_matches_exact`'s doc) -- must fail over as - // NoCandidates rather than widen discovery here. - let idx = SketchStore::new(); - let sid = 1u64; - let mut meta = sum_exact_agg_meta(sid, "latency_max_ms", &[]); - meta.agg_kind = crate::storage_engines::sketch_db::index::AggKind::ExactAgg { - agg_type: asap_types::AggregationType::Max, - parameters_canonical: String::new(), - spatial_filter_canonical: String::new(), - }; - meta.capability = Some(Capability::ExactAgg(asap_types::AggregationType::Max)); - idx.register(meta); - idx.append_precompute( - sid, - BTreeMap::new(), - (T0, T0 + 1000), - Box::new(asap_physical_operators::summary_kernels::MaxAccumulator::new()), - ); - - let child = scan_node("latency_max_ms", None); - let tree = Rc::new(SummaryNode { - expr: SummaryExpr::SummaryAgg { - child, - family: SummaryFamilyType::ExactAggregate( - planner_types::post_asap::ExactKind::Max, - planner_types::post_asap::ExactParams::Max, - ), - input: planner_types::post_asap::SummaryUpdate { - item: None, - weight: planner_types::post_asap::SummaryInputExpr::Column( - ColumnRef::SampleValue, - ), - weight_domain: Default::default(), - }, - reduction: Reduction::by(vec![]), - grouping: planner_types::post_asap::GroupingStrategy::default(), - }, - schema: SummarySchema { - fields: vec![], - time_index: None, - }, - guarantee: None, - }); - let exec = ctx(&idx); - match execute(&tree, &exec) { - Err(crate::query_engines::asap_query_engine::summary_exec::ExecError::NoCandidates) => { - } - other => panic!("expected NoCandidates, got {}", other.is_ok()), - } - } } diff --git a/data_plane/src/query_engines/canonical/mod.rs b/data_plane/src/query_engines/canonical/mod.rs deleted file mode 100644 index c170e5d0e..000000000 --- a/data_plane/src/query_engines/canonical/mod.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! Compatibility re-exports for the existing summary executor. -//! -//! Installed SQL serving executes QueryPlan nodes through its typed relational -//! adapter. These aliases remain for callers of the summary execution API. - -pub mod executor { - pub use crate::query_engines::asap_query_engine::summary_exec::{ - execute, ExecError, ExecOutcome, SummaryExecutor, - }; -} - -pub mod context { - pub use crate::query_engines::asap_query_engine::summary_executor::QueryExecutionContext; -} - -pub mod result { - pub use crate::query_engines::asap_query_engine::summary_executor::SummaryValue; -} - -pub mod sds_resolver { - pub use asap_types::sds::{DataDescriptorId, SummaryDescriptorId}; -} diff --git a/data_plane/src/query_engines/mod.rs b/data_plane/src/query_engines/mod.rs index 1672e1095..f586fc4be 100644 --- a/data_plane/src/query_engines/mod.rs +++ b/data_plane/src/query_engines/mod.rs @@ -16,7 +16,6 @@ pub mod asap_clickhouse_query_engine; pub mod asap_query_engine; -pub mod canonical; pub mod query_forwarding; pub mod query_result; pub mod routing; @@ -98,3 +97,5 @@ impl EngineError { } } } + +pub(crate) mod request; diff --git a/data_plane/src/query_engines/query_result.rs b/data_plane/src/query_engines/query_result.rs index 0d46b2525..e1e927f49 100644 --- a/data_plane/src/query_engines/query_result.rs +++ b/data_plane/src/query_engines/query_result.rs @@ -31,6 +31,29 @@ pub enum QueryResult { } impl QueryResult { + pub(crate) fn retained_bytes(&self) -> usize { + match self { + Self::Vector(rows) => rows + .values + .iter() + .map(InstantVectorElement::retained_bytes) + .sum(), + Self::Matrix(rows) => rows + .values + .iter() + .map(|row| { + std::mem::size_of::() + + string_bytes(&row.labels.labels) + + row + .label_keys_override + .as_ref() + .map_or(0, |keys| string_bytes(keys)) + + row.samples.capacity() * std::mem::size_of::() + }) + .sum(), + } + } + pub fn result_type(&self) -> QueryResultType { match self { QueryResult::Vector(_) => QueryResultType::InstantVector, @@ -174,7 +197,20 @@ pub struct InstantVectorElement { pub label_keys_override: Option>, } +fn string_bytes(values: &[String]) -> usize { + std::mem::size_of_val(values) + values.iter().map(String::capacity).sum::() +} + impl InstantVectorElement { + pub(crate) fn retained_bytes(&self) -> usize { + std::mem::size_of::() + + string_bytes(&self.labels.labels) + + self + .label_keys_override + .as_ref() + .map_or(0, |keys| string_bytes(keys)) + } + pub fn new(labels: KeyByLabelValues, value: f64) -> Self { Self { labels, diff --git a/data_plane/src/query_engines/request.rs b/data_plane/src/query_engines/request.rs new file mode 100644 index 000000000..c7ccc2929 --- /dev/null +++ b/data_plane/src/query_engines/request.rs @@ -0,0 +1,239 @@ +//! Own one native control for the lifetime of a request, including async inputs. +//! +//! Planner's native context is !Send. A blocking worker owns it while Tokio's +//! handle drives input I/O on that same thread. The thread-local is scoped to +//! this worker invocation; it is never installed on a Tokio scheduler thread. +use super::EngineError; +use asap_physical_operators::dag::{Error, Limits, Reservation, RunContext, Scope}; +use std::{ + cell::RefCell, + future::Future, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, +}; + +#[derive(Default)] +struct Cancellation { + cancelled: AtomicBool, + changed: tokio::sync::Notify, +} +struct CancelOnDrop(Arc); +impl Drop for CancelOnDrop { + fn drop(&mut self) { + self.0.cancelled.store(true, Ordering::Release); + self.0.changed.notify_one(); + } +} +struct ActiveRequest { + context: RunContext, + cancellation: Arc, +} +thread_local! { + static ACTIVE: RefCell> = const { RefCell::new(None) }; +} +struct ClearRequest; +impl Drop for ClearRequest { + fn drop(&mut self) { + ACTIVE.with(|slot| { + slot.borrow_mut().take(); + }); + } +} + +/// Dropping the caller future cancels remote reads and cooperative native work. +pub(crate) async fn run(limits: Limits, operation: F) -> Result +where + T: Send + 'static, + F: FnOnce(tokio::runtime::Handle) -> Result + Send + 'static, +{ + let cancellation = Arc::new(Cancellation::default()); + let _cancel = CancelOnDrop(cancellation.clone()); + let handle = tokio::runtime::Handle::current(); + tokio::task::spawn_blocking(move || { + let context = RunContext::new( + Scope::Query { + evaluation_time_ms: 0, + revision: 0, + }, + limits, + )?; + ACTIVE.with(|slot| { + assert!(slot.borrow().is_none(), "nested request executor"); + *slot.borrow_mut() = Some(ActiveRequest { + context, + cancellation, + }); + }); + let _clear = ClearRequest; + check()?; + operation(handle) + }) + .await + .map_err(|error| { + EngineError::Physical(Error::Operator(format!("query worker failed: {error}"))) + })? +} + +pub(crate) fn check() -> Result<(), Error> { + ACTIVE.with(|slot| { + if let Some(active) = slot.borrow().as_ref() { + if active.cancellation.cancelled.load(Ordering::Acquire) { + active.context.cancel(); + } + if active.context.is_cancelled() { + return Err(Error::Cancelled); + } + } + Ok(()) + }) +} + +pub(crate) fn context(scope: Scope) -> Result { + check()?; + ACTIVE.with(|slot| match slot.borrow().as_ref() { + Some(active) => { + let mut context = active.context.clone(); + context.scope = scope; + Ok(context) + } + None => RunContext::new(scope, Limits::default()), + }) +} + +pub(crate) fn reserve(bytes: usize) -> Result { + context(Scope::Query { + evaluation_time_ms: 0, + revision: 0, + })? + .reserve(bytes) +} + +/// Drive a local native future while bridging caller cancellation on each poll. +/// This must not enter another executor: native operators may synchronously run +/// a nested DAG while they are polled. +pub(crate) fn drive(future: impl Future) -> Result { + use futures::FutureExt; + let mut future = Box::pin(future); + loop { + check()?; + if let Some(result) = future.as_mut().now_or_never() { + check()?; + return Ok(result); + } + std::thread::yield_now(); + } +} + +pub(crate) async fn wait(future: impl Future) -> Result { + check()?; + let cancellation = ACTIVE.with(|slot| { + slot.borrow() + .as_ref() + .map(|active| active.cancellation.clone()) + }); + if let Some(cancellation) = cancellation { + tokio::select! { + result = future => { check()?; Ok(result) }, + _ = cancellation.changed.notified() => Err(Error::Cancelled), + } + } else { + Ok(future.await) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Evaluation scopes share accounting; independent HTTP requests do not. + #[tokio::test] + async fn range_scopes_share_retained_input_and_result_budget() { + let error = run( + Limits { + max_bytes: 1024, + ..Limits::default() + }, + |_| { + let first = context(Scope::Query { + evaluation_time_ms: 1, + revision: 7, + })?; + let _input = first.reserve(600)?; + let next = context(Scope::Query { + evaluation_time_ms: 2, + revision: 7, + })?; + let _result = next.reserve(500)?; + Ok(()) + }, + ) + .await + .unwrap_err(); + assert!(matches!(error, EngineError::Physical(Error::MemoryLimit))); + run( + Limits { + max_bytes: 1024, + ..Limits::default() + }, + |_| { + let _all = reserve(1024)?; + Ok(()) + }, + ) + .await + .unwrap(); + } + + // Cancellation must also stop a nested, cooperatively yielding native run. + #[tokio::test] + async fn dropping_request_cancels_native_polling() { + let (started, ready) = tokio::sync::oneshot::channel(); + let (finished, done) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(run(Limits::default(), move |_| { + let context = context(Scope::Query { + evaluation_time_ms: 1, + revision: 1, + })?; + started.send(()).unwrap(); + let result = drive(std::future::pending::<()>()); + finished + .send(matches!(result, Err(Error::Cancelled)) && context.is_cancelled()) + .unwrap(); + result.map_err(EngineError::from) + })); + ready.await.unwrap(); + task.abort(); + assert!( + tokio::time::timeout(std::time::Duration::from_secs(2), done) + .await + .unwrap() + .unwrap() + ); + } + + // Aborting the async caller wakes a remote read even when it never replies. + #[tokio::test] + async fn dropping_request_cancels_inflight_input_and_worker() { + let (started, ready) = tokio::sync::oneshot::channel(); + let (finished, done) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(run(Limits::default(), move |handle| { + handle.block_on(async { + started.send(()).unwrap(); + let result = wait(std::future::pending::<()>()).await; + let cancelled = matches!(result, Err(Error::Cancelled)); + finished.send(cancelled).unwrap(); + result.map_err(EngineError::from) + }) + })); + ready.await.unwrap(); + task.abort(); + assert!( + tokio::time::timeout(std::time::Duration::from_secs(2), done) + .await + .unwrap() + .unwrap() + ); + } +} diff --git a/data_plane/src/storage_engines/sketch_db/current_series.rs b/data_plane/src/storage_engines/sketch_db/current_series.rs index efd3c0127..9eb378300 100644 --- a/data_plane/src/storage_engines/sketch_db/current_series.rs +++ b/data_plane/src/storage_engines/sketch_db/current_series.rs @@ -32,17 +32,19 @@ impl Ord for Ranked { .then(self.labels.cmp(&other.labels)) } } +#[derive(Clone)] struct Member { timestamp: i64, value: Option, group: Labels, bytes: u64, } -#[derive(Default)] +#[derive(Default, Clone)] struct Group { ordered: BTreeSet, cached: Option<(Vec, Vector, f64, f64)>, } +#[derive(Clone)] struct Population { definition: SeriesPopulation, members: BTreeMap, @@ -275,8 +277,23 @@ pub struct CurrentSeriesStore { populations: BTreeMap, first: Option, watermark: Option, + // Completed population versions, bounded by the same declared state budget. + history: BTreeMap>, + history_bytes: BTreeMap, } impl CurrentSeriesStore { + pub(crate) fn approx_resident_bytes(&self) -> usize { + self.populations + .values() + .map(|population| population.bytes as usize + 1024) + .sum::() + + self + .history_bytes + .values() + .map(|bytes| *bytes as usize) + .sum::() + } + /// Called only after the complete Remote Write batch was admitted successfully. pub fn ingest(&mut self, plan: &QueryPlan, samples: &[CanonicalSample]) { let generation = (plan.plan_id, plan.plan_version); @@ -300,35 +317,80 @@ impl CurrentSeriesStore { if self.populations.is_empty() || samples.is_empty() { return; } - let mut timestamps: Vec<_> = samples.iter().map(|s| s.timestamp_ms).collect(); - timestamps.sort_unstable(); - timestamps.dedup(); + let mut batches = BTreeMap::>::new(); + for sample in samples { + batches.entry(sample.timestamp_ms).or_default().push(sample); + } + if self + .watermark + .is_some_and(|w| batches.keys().any(|t| *t <= w)) + { + // Late writes invalidate completed versions; never serve a stale revision. + self.history.clear(); + self.history_bytes.clear(); + } let max_lag = self .populations .values() .map(|p| p.definition.max_input_lag_ms) .min() .unwrap() as i64; - for timestamp in timestamps { + for (timestamp, batch) in batches { if self .watermark .is_some_and(|w| timestamp > w.saturating_add(max_lag)) { - // A gap cannot prove that every still-live series was observed. self.first = Some(timestamp); } self.first.get_or_insert(timestamp); self.watermark = Some(self.watermark.unwrap_or(timestamp).max(timestamp)); - } - let watermark = self.watermark.unwrap(); - for population in self.populations.values_mut() { - let cutoff = watermark.saturating_sub(population.definition.lookback_ms as i64); - population.expire(cutoff); - for sample in samples { - population.update(sample, cutoff); + let watermark = self.watermark.unwrap(); + for (key, population) in &mut self.populations { + let cutoff = watermark.saturating_sub(population.definition.lookback_ms as i64); + population.expire(cutoff); + for sample in &batch { + population.update(sample, cutoff); + } + let retention = population.definition.history_retention_ms; + if retention == 0 || timestamp < watermark { + continue; + } + let history = self.history.entry(key.clone()).or_default(); + let bytes = self.history_bytes.entry(key.clone()).or_default(); + let oldest = watermark.saturating_sub(retention as i64); + let checkpoint_bytes = population.bytes.saturating_add(1024); + // Include fixed per-version metadata even for an empty population. + while history.first_key_value().is_some_and(|(t, _)| *t < oldest) + || (!history.is_empty() + && bytes + .saturating_add(checkpoint_bytes) + .saturating_add(population.bytes) + > population.definition.max_bytes) + { + let (_, (_, expired)) = history.pop_first().unwrap(); + *bytes -= expired.bytes.saturating_add(1024); + } + if population.unavailable.is_none() + && bytes + .saturating_add(checkpoint_bytes) + .saturating_add(population.bytes) + <= population.definition.max_bytes + { + if let Some((_, previous)) = + history.insert(timestamp, (self.first.unwrap(), population.clone())) + { + *bytes -= previous.bytes.saturating_add(1024); + } + *bytes += checkpoint_bytes; + } else { + // A missing version must not let a historical read reuse an older value. + history.clear(); + *bytes = 0; + } } } } + pub fn read( &mut self, generation: (u64, u64), @@ -341,8 +403,31 @@ impl CurrentSeriesStore { } let at = i64::try_from(at).map_err(|_| "invalid evaluation timestamp")?; let watermark = self.watermark.ok_or("current-series state is cold")?; - if at < watermark { - return Err("current-series state cannot answer historical evaluations".into()); + if at < watermark + || (at == watermark + && self + .history + .get(&definition.key()) + .is_some_and(|h| h.contains_key(&at))) + { + if definition.history_retention_ms == 0 + || at < watermark.saturating_sub(definition.history_retention_ms as i64) + { + return Err("current-series state cannot answer historical evaluations outside declared retention".into()); + } + let (timestamp, (first, saved)) = self + .history + .get(&definition.key()) + .and_then(|h| h.range(..=at).next_back()) + .ok_or("current-series historical coverage is unavailable")?; + if at > timestamp.saturating_add(definition.max_input_lag_ms as i64) + || at.saturating_sub(definition.lookback_ms as i64) < *first + { + return Err("current-series historical lookback is not covered".into()); + } + let mut population = saved.clone(); + population.expire(at.saturating_sub(definition.lookback_ms as i64)); + return Ok(population.read(readout)); } if at > watermark.saturating_add(definition.max_input_lag_ms as i64) { return Err("current-series input is behind evaluation time".into()); @@ -395,6 +480,7 @@ mod tests { }, lookback_ms: 300_000, max_input_lag_ms: 60_000, + history_retention_ms: 0, max_series: 100, max_bytes: 1_000_000, max_k: 3, @@ -423,6 +509,88 @@ mod tests { .unwrap(); assert!(values.is_empty()); } + // Historical reads use the state at that timestamp, never the latest values. + #[test] + fn declared_history_retains_as_of_population_within_budget() { + let mut population = definition(); + population.lookback_ms = 1_000; + population.max_input_lag_ms = 1_000; + population.history_retention_ms = 2_000; + let plan = plan(&population); + let mut store = CurrentSeriesStore::default(); + store.ingest( + &plan, + &[ + sample("one", "api", 0, Some(1.)), + sample("one", "api", 1_000, Some(2.)), + sample("one", "api", 2_000, Some(3.)), + sample("one", "api", 3_000, Some(4.)), + ], + ); + assert_eq!( + store + .read((7, 1), &population, &SeriesReadout::Sum, 1_000) + .unwrap()[0] + .1, + 2. + ); + assert_eq!( + store + .read((7, 1), &population, &SeriesReadout::Sum, 3_000) + .unwrap()[0] + .1, + 4. + ); + assert!(store + .read((7, 1), &population, &SeriesReadout::Sum, 999) + .is_err()); + assert!( + store.history_bytes[&population.key()] + store.populations[&population.key()].bytes + <= population.max_bytes + ); + } + + // Resource exhaustion and late revisions cannot masquerade as historical coverage. + #[test] + fn retained_history_rejects_evicted_and_late_revisions() { + let mut population = definition(); + population.lookback_ms = 1_000; + population.max_input_lag_ms = 1_000; + population.history_retention_ms = 2_000; + let installed = plan(&population); + let mut store = CurrentSeriesStore::default(); + store.ingest( + &installed, + &[ + sample("one", "api", 0, Some(1.)), + sample("one", "api", 1_000, Some(2.)), + sample("one", "api", 2_000, Some(3.)), + ], + ); + store.ingest(&installed, &[sample("two", "api", 1_500, Some(20.))]); + assert!(store + .read((7, 1), &population, &SeriesReadout::Sum, 1_000) + .is_err()); + let mut bounded = population.clone(); + bounded.max_bytes = 2_500; + let mut store = CurrentSeriesStore::default(); + store.ingest( + &plan(&bounded), + &[ + sample("one", "api", 0, Some(1.)), + sample("one", "api", 1_000, Some(2.)), + sample("one", "api", 2_000, Some(3.)), + ], + ); + assert!(store + .read((7, 1), &bounded, &SeriesReadout::Sum, 1_000) + .is_err()); + assert!( + store.history_bytes[&bounded.key()] + store.populations[&bounded.key()].bytes + <= bounded.max_bytes + ); + } + fn plan(p: &SeriesPopulation) -> QueryPlan { let mut plan = QueryPlan::empty(); plan.plan_id = 7; 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 c3874750d..d831682f2 100644 --- a/data_plane/src/storage_engines/sketch_db/index/mod.rs +++ b/data_plane/src/storage_engines/sketch_db/index/mod.rs @@ -568,6 +568,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, @@ -2784,6 +2788,9 @@ impl SketchStore { // resident memory even though they are not part of the durable // payload/flush accounting. total += self.rollups.approx_bytes(); + if let Ok(current) = self.current_series.lock() { + total += current.approx_resident_bytes(); + } total } 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 8f5da9ef7..d208cb57d 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::summary_kernels::count_min_sketch_with_heap::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 05656df78..09869df2e 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,794 +1,10 @@ -//! 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::summary_kernels::univmon::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::summary_kernels::univmon::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::summary_kernels::univmon::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::summary_kernels::dd_sketch::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::NativeBatchV1 => { - return Err("native batch requires the physical batch reader".into()); - } - 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::NativeBatchV1 => { - return Err("native batch requires the physical batch reader".into()); - } - 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::summary_kernels::dd_sketch::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::summary_kernels::datasketches_kll::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::summary_kernels::hll_sketch::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(()) -} +//! Storage uses the Planner-owned state implementation. +pub use asap_physical_operators::stored_state::delta_apply::*; #[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; + use asap_physical_operators::stored_state::{SketchEncoding, SketchSampleState}; #[test] fn native_batches_are_not_legacy_sketch_frames() { @@ -800,508 +16,4 @@ mod tests { assert!(cumulative_summary_state(&samples, DeltaSketchKind::Kll { k: 200 }).is_err()); assert!(per_window_summary_states(&samples, DeltaSketchKind::Kll { k: 200 }).is_err()); } - - 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, - ..Default::default() - }; - 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::summary_kernels::hll_sketch::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::summary_kernels::dd_sketch::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::summary_kernels::datasketches_kll::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() - ), - } - } } diff --git a/data_plane/src/storage_engines/types/hot_reload_config.rs b/data_plane/src/storage_engines/types/hot_reload_config.rs index 567852c80..c3f0439fb 100644 --- a/data_plane/src/storage_engines/types/hot_reload_config.rs +++ b/data_plane/src/storage_engines/types/hot_reload_config.rs @@ -24,10 +24,92 @@ pub struct RuntimePhysicalPlan { pub transmission_plan: asap_types::producer_plan::TransmissionPlan, pub installed_precompute_plan: Arc, pub query_plan: Arc, + /// Immutable readout-boundary programs for this snapshot. This holds plan + /// metadata only; data, coverage and readiness are resolved on every run. + pub readout_programs: Arc, pub storage_routing: Arc, } +type ReadoutRoots = + BTreeMap>; + +#[derive(Debug, Default)] +struct ReadoutProgramRegistry { + query_plan: Option>, + entries: std::collections::HashMap>, +} + +#[derive(Debug, Default)] +pub struct PreparedReadoutPrograms { + registry: std::sync::Mutex, +} + impl RuntimePhysicalPlan { + /// Prepare an already-selected readout boundary once for this immutable + /// deployment snapshot. No rows, state, results or readiness are retained. + pub(crate) fn readout_program( + &self, + entry: &asap_types::query_plan::QueryPlanEntry, + root: asap_types::query_plan::QueryNodeId, + ) -> Result, String> { + let mut programs = self + .readout_programs + .registry + .lock() + .map_err(|_| "readout program registry poisoned".to_string())?; + // Cloning a snapshot and replacing its QueryPlan must not preserve + // bindings from the previous immutable plan, even with identical names. + if programs + .query_plan + .as_ref() + .is_none_or(|installed| !Arc::ptr_eq(installed, &self.query_plan)) + { + programs.entries.clear(); + programs.query_plan = Some(Arc::clone(&self.query_plan)); + } + if let Some(program) = programs + .entries + .get(&entry.language) + .and_then(|queries| queries.get(&entry.canonical_query)) + .and_then(|roots| roots.get(&root)) + { + return Ok(Arc::clone(program)); + } + let reachable = entry + .topological_order_from(root) + .map_err(|error| error.to_string())?; + let mut program = entry.clone(); + program.root = root; + program.nodes = reachable + .into_iter() + .map(|id| (id, entry.nodes[&id].clone())) + .collect(); + let windows: std::collections::BTreeSet<_> = program + .materialization_bindings() + .iter() + .map(|binding| binding.readout_lookback_ms) + .collect(); + if windows.len() != 1 || windows.contains(&None) || windows.contains(&Some(0)) { + return Err("bound subtree requires one explicit positive window".into()); + } + program.instant.lookback_ms = windows + .first() + .copied() + .flatten() + .expect("explicit semantic lookback checked"); + program.instant.full_history = false; + program.instant.cumulative_readout = true; + let program = Arc::new(program); + programs + .entries + .entry(entry.language) + .or_default() + .entry(entry.canonical_query.clone()) + .or_default() + .insert(root, Arc::clone(&program)); + Ok(program) + } + pub fn plan_id(&self) -> u64 { self.envelope.plan_id } @@ -625,6 +707,7 @@ mod tests { capability_snapshot_id: "test".into(), }; RuntimePhysicalPlan { + readout_programs: Default::default(), envelope: envelope.clone(), summary_catalog: None, precompute_plan: asap_types::precompute_plan::PrecomputePlan { @@ -677,6 +760,75 @@ mod tests { } } + #[test] + fn readout_programs_follow_replaced_query_plan_bindings() { + use asap_types::query_plan::*; + let entry = |id: u64| QueryPlanEntry { + language: asap_types::QueryLanguage::PromQl, + query_id: "sum_over_time(m[1m])".into(), + canonical_query: "sum_over_time(m[1m])".into(), + fixed_evaluation: None, + root: QueryNodeId(1), + nodes: BTreeMap::from([ + ( + QueryNodeId(0), + QueryPlanNode::ReadMaterialization { + binding: MaterializationBinding { + materialization: asap_types::PolicyFingerprint(id).into(), + stored_output_reference: + asap_types::sds::StoredOutputReference::for_output( + asap_types::PolicyFingerprint(id).into(), + ), + output_grouping: PhysicalGrouping::PerEntity, + item_labels: vec![], + window_ms: 5000, + pane_origin_ms: Some(0), + readout_lookback_ms: Some(60000), + full_window_slide_ms: None, + }, + }, + ), + ( + QueryNodeId(1), + QueryPlanNode::ExactReadout { + input: QueryNodeId(0), + readout: ExactReadout::Sum, + }, + ), + ]), + instant: InstantExecution { + lookback_ms: 60000, + full_history: false, + cumulative_readout: true, + }, + fallback: FallbackPolicy::ExactBackend, + }; + let mut original = physical_plan(1, 1, 0, None); + let first_entry = entry(1); + Arc::make_mut(&mut original.query_plan) + .entries + .insert(first_entry.canonical_query.clone(), first_entry.clone()); + let first = original + .readout_program(&first_entry, first_entry.root) + .unwrap(); + let mut successor = original.clone(); + let second_entry = entry(2); + Arc::make_mut(&mut successor.query_plan) + .entries + .insert(second_entry.canonical_query.clone(), second_entry.clone()); + let second = successor + .readout_program(&second_entry, second_entry.root) + .unwrap(); + assert_eq!( + first.materialization_bindings()[0].materialization, + asap_types::PolicyFingerprint(1).into() + ); + assert_eq!( + second.materialization_bindings()[0].materialization, + asap_types::PolicyFingerprint(2).into() + ); + } + fn dummy_agg(id: u64) -> PrecomputeMaterialization { PrecomputeMaterialization::new( AggregationType::Sum, diff --git a/data_plane/src/storage_engines/types/mod.rs b/data_plane/src/storage_engines/types/mod.rs index 9956f5b46..f126e0db2 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 3a179e8ba..e016a48f2 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/data_plane/tests/asapquery_compatibility_process_e2e.rs b/data_plane/tests/asapquery_compatibility_process_e2e.rs index da96d9365..e9cc124f1 100644 --- a/data_plane/tests/asapquery_compatibility_process_e2e.rs +++ b/data_plane/tests/asapquery_compatibility_process_e2e.rs @@ -645,47 +645,67 @@ async fn registered_temporal_topk(algorithm: planner_types::post_asap::SketchAlg query.accuracy_target.clone(), ) .unwrap(); - // Family ordering alone does not select a whole DAG. Price the desired - // legal heap candidate explicitly so this process test exercises its runtime. - struct HeapFixtureCost(planner_types::post_asap::SketchAlgorithm); - impl asap_aware_mapping::CostModel for HeapFixtureCost { + // This test installs collector heap state. Backend-only exact temporal + // readouts are legal in production but outside this fixture's input contract. + struct HeapFixtureModel( + control_plane::physical::post_asap::cost_model::ForcedFamilyCostModel, + planner_types::post_asap::SketchAlgorithm, + ); + impl asap_aware_mapping::CostModel for HeapFixtureModel { fn rank_candidates( &self, - _: &planner_types::pre_asap::AggIntent, + intent: &planner_types::pre_asap::AggIntent, candidates: &[planner_types::post_asap::SketchAlgorithm], ) -> Vec { - let mut ranked = candidates.to_vec(); - ranked.sort_by_key(|kind| kind != &self.0); - ranked + self.0.rank_candidates(intent, candidates) + } + fn size_params( + &self, + kind: planner_types::post_asap::SketchAlgorithm, + intent: &planner_types::pre_asap::AggIntent, + eps: f64, + delta: f64, + ) -> planner_types::post_asap::SketchParams { + self.0.size_params(kind, intent, eps, delta) } fn candidate_cost( &self, candidate: &asap_aware_mapping::ReplacementSubDAG, - _: &asap_aware_mapping::TargetSubDAG<'_>, + target: &asap_aware_mapping::TargetSubDAG<'_>, ) -> Option { - let heap = - if let asap_aware_mapping::Replacement::Summary(root) = &candidate.replacement { - planner_types::post_asap::compile_executable_dag(root) - .unwrap() - .nodes - .iter() - .any(|node| { - matches!(&node.payload, - planner_types::post_asap::ExecutableOperatorPayload::SummaryAgg { - family: SummaryFamilyType::Sketch(kind, _), .. - } if kind.algorithm() == &self.0) - }) - } else { - false - }; - Some(asap_aware_mapping::cost_model::Cost(if heap { - 1.0 - } else { - 1e12 - })) + self.0.candidate_cost(candidate, target) + } + fn summary_support_evidence( + &self, + summary: &planner_types::post_asap::SummaryNode, + ) -> Option { + if matches!( + summary.expr, + planner_types::post_asap::SummaryExpr::ValueOperation { + operation: planner_types::post_asap::ValueOperation::Limit { .. }, + .. + } + ) { + return Some(false); + } + let dag = planner_types::post_asap::compile_executable_dag(&std::rc::Rc::new( + summary.clone(), + )) + .ok()?; + if dag.nodes.iter().any(|node| matches!(&node.payload, + planner_types::post_asap::ExecutableOperatorPayload::SummaryAgg { family: SummaryFamilyType::Sketch(kind, _), .. } if kind.algorithm() != &self.1)) { + return Some(false); + } + self.0.summary_support_evidence(summary) } } - let model = HeapFixtureCost(algorithm.clone()); + let model = HeapFixtureModel( + control_plane::physical::post_asap::cost_model::ForcedFamilyCostModel::new( + query.accuracy_target.clone(), + algorithm.clone(), + ), + algorithm.clone(), + ); query.selected_plan_root = control_plane::planner_selection::select_query_with_models( &expr, &model, @@ -1577,12 +1597,12 @@ async fn collector_free_profile_serves_complete_matrix_and_falls_back_exactly() Some(1) ); assert_eq!( - topk_sum["data"]["result"][0]["metric"]["job"], "worker", - "TopK must preserve the selected series labels: {topk_sum}" + topk_sum["data"]["result"][0]["metric"], + serde_json::json!({"job": "worker"}) ); assert_eq!( - topk_count["data"]["result"][0]["metric"]["job"], "api", - "TopK must preserve the selected series labels: {topk_count}" + topk_count["data"]["result"][0]["metric"], + serde_json::json!({"job": "api"}) ); assert!((first_value(&sum, "value").expect("sum value") - 240.0).abs() < 1e-9); diff --git a/docs/01-getting-started/architecture.md b/docs/01-getting-started/architecture.md index 55e223bab..df3a62c70 100644 --- a/docs/01-getting-started/architecture.md +++ b/docs/01-getting-started/architecture.md @@ -113,5 +113,5 @@ Planner/ERP inputs through shared SDS descriptors, PrecomputePlan, physical instances and QueryPlan. Remote-write queue admission is not durable raw-data commit. Finite drain and typed producer-watermark completion are different protocols; the sample stream does not supply an authoritative watermark. -See [maintenance replay](../developer_docs/maintenance-replay.md) for the +See [maintenance replay](../developer_docs/precomputation-replay.md) for the current durable and continuous boundaries. diff --git a/docs/design_docs/README.md b/docs/design_docs/README.md index 25b1d501f..6570d0bec 100644 --- a/docs/design_docs/README.md +++ b/docs/design_docs/README.md @@ -9,10 +9,12 @@ integration. by the following three designs. - [Binding Planner Physical DAGs to deployment plans](asapplanner-integration.md) defines how backend source/state bindings and operational policy instantiate - Planner-provided maintenance and query computation. + Planner-provided precomputation and query computation. - [Summary definitions table and SDS](summary-catalog-sds-architecture.md) owns definition and instance identity, version-scoped state references, read eligibility and committed-state metadata. +- [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/physical-operators.md b/docs/design_docs/physical-operators.md index ade079abf..c403684da 100644 --- a/docs/design_docs/physical-operators.md +++ b/docs/design_docs/physical-operators.md @@ -14,7 +14,7 @@ be changed and tested together in Planner. The query and precompute integration PRs both use the independent ASAP DAG runtime. Deployment code binds input sources, storage, ingestion windows, publication and protocol outputs. Execution phase belongs to the physical node's data state, not the operator payload. -Computation has the same semantics at ingestion time and query time. +Computation has the same semantics during precomputation and query execution. Candidate pruning uses a general semi-join with explicit matching keys, followed by grouped Sort and grouped Limit. The completeness certificate belongs to the @@ -39,7 +39,7 @@ the backend can execute arbitrary raw-only installed plans. Planner PR #462 owns the library and depends on Planner #461, including its composed candidate-pruning API. Backend #774 consumes the pinned library; -#763 integrates ingestion DAG execution and #765 integrates query DAG execution. +#763 integrates precomputation DAG execution and #765 integrates query DAG execution. The remaining backend stack builds on those integrations. #728 checks candidate structure, #742 checks selection with synthetic costs, and #775 checks installed plans against data-plane results. Production evidence and performance validation @@ -55,6 +55,20 @@ instantiates the compiled DAG. `CompiledPhysicalDag::from_operators` supports adapters that already have a concrete physical fragment. Unsupported deployment input frontiers are reported to Planner as feasibility evidence, before selection. +## Deployment retention and execution evidence + +The backend `DeploymentPlanCompiler` binds declared historical query delay to +retention; it does not change a logical selector's lookback. Current-series +populations retain bounded versions only when the deployment requests historical +coverage. Their current and retained versions share the population memory budget. +Changed inputs require a new output revision; an older common snapshot remains +eligible while it satisfies freshness. Missing or evicted state fails explicitly. +Candidate costing includes version residency, copying and retirement. + +Every installed range evaluation uses the shared DAG execution path and reports +its actual local-summary and external-exact work. The differential and benefit +runners require successful local execution evidence as well as matching values. +Routing to the ASAP endpoint alone is insufficient. Selected Filter, Sort, Limit and semi-join fragments are compiled by Planner and persisted with typed input contracts. Complete query candidates include Planner readouts that turn accumulator state into values; internal shared and stored edges retain @@ -65,6 +79,7 @@ its labels from the candidate side. The native join performs the comparison. Physical execution errors retain their original cause. Memory exhaustion and cancellation terminate both instant and range requests; routing does not try a -second engine or exact fallback. Physical fragments in one query evaluation share -one run context. This does not yet account for every protocol-buffer allocation -or provide an HTTP-disconnect cancellation mechanism. +second engine or exact fallback. All range steps and nested physical executions share one request budget and +cancellation signal, while retaining separate execution state. Tracked inputs, +workspace and results count against that budget; estimates are not a hard RSS +limit. See [query execution contracts](query-dag-execution.md#6-request-consistency). diff --git a/docs/design_docs/query-dag-execution.md b/docs/design_docs/query-dag-execution.md new file mode 100644 index 000000000..37d7909a5 --- /dev/null +++ b/docs/design_docs/query-dag-execution.md @@ -0,0 +1,320 @@ +# Query DAG Execution + +## 1. Overview + +ASAP uses one physical-operator library and one DAG runtime for both +precomputation and query execution. + +```mermaid +flowchart LR + L["Logical Post-ASAP DAG"] -->|Candidate generation| M["Summary Maintenance Lifecycle"] + M -->|Physical Plan Compiler| P["Physical DAG candidates"] + P -->|Backend selection and Deployment Plan Compiler| D["Deployment Plan / DAG"] + D --> Pre["PrecomputePlan"] + D --> Query["QueryPlan"] + Pre --> Runtime["Shared physical operators and DAG runtime"] + Query --> Runtime +``` + +This follows [Planner #462's architecture](https://github.com/ProjectASAP/ASAPPlanner/blob/feat/shared-physical-operators/docs/design_docs/physical-planning-and-deployment.md): + +| Layer | Responsibility | +| --- | --- | +| Logical Post-ASAP DAG | Computation semantics | +| Summary Maintenance Lifecycle | Build, retention, reuse, and window strategy | +| Physical DAG(s) | Supported physical candidates, executable operators, and typed boundaries | +| Deployment Plan / DAG | Selected candidate, concrete bindings, and operational lifecycle | + +ASAPPlanner owns the first three layers. Backend selects a feasible physical +candidate using deployment statistics, resource limits and costs, then binds it +into `PrecomputePlan` and `QueryPlan`. It preserves the candidate's operators, +dependencies, sharing and materialization frontier. + +The lifecycle is a planning contract, not another computation IR. “Maintenance” +here names Planner's lifecycle concept; input reception is ingestion and execution +before a request is precomputation. Executing a bound Physical DAG introduces +neither an **Execution DAG** abstraction nor another planning layer. + +## 2. Ownership + +### ASAPPlanner + +ASAPPlanner owns the post-ASAP IR, logical and physical operator implementations, +`asap-physical-operators`, `physical_planner`, `CompiledPhysicalDag`, and the +independent DAG runtime. A change to an IR operation and its execution +implementation can therefore be reviewed in one Planner PR. + +The runtime owns generic execution behavior: + +- dependency scheduling; +- shared-producer execution; +- bounded buffering; +- cancellation; +- per-run reuse of intermediate outputs; +- operator workspace accounting. + +A producer with multiple consumers executes once per DAG run, while each +consumer independently receives its output. + +### Backend + +Backend owns deployment-specific concerns: + +- source binding and window selection; +- catalog and storage access; +- revision and coverage admission; +- summary publication; +- protocol conversion; +- query and precomputation bindings. + +It does not duplicate Planner operators or maintain a second DAG runtime. +JSON, Arrow, SQL, and PromQL are boundary representations; they do not determine +the internal representation of every ASAP summary. + +See the [shared library design](https://github.com/ProjectASAP/ASAPPlanner/blob/feat/shared-physical-operators/docs/design_docs/physical-planning-and-deployment.md) +for the broader architecture and DataFusion comparison. + +## 3. Compilation and execution + +```text +Planner: Logical Post-ASAP DAG + lifecycle candidate + ↓ Physical Plan Compiler + supported Physical DAG candidates + ↓ Backend selection and Deployment Plan Compiler + Deployment Plan / DAG (PrecomputePlan + QueryPlan) + ↓ execute bound Physical DAGs through shared runtime + stored outputs / query results +``` + +### 3.1 Compile + +ASAPPlanner compiles semantically legal computation and lifecycle candidates +into supported Physical DAG candidates with typed input/output boundaries. +Compilation does not require live storage readers. `CompiledPhysicalDag` is a +Rust implementation type for physical compilation, not a separate architectural +layer or an intermediate Execution DAG. + +### 3.2 Bind + +Backend checks candidate feasibility and costs, selects a physical candidate, +and binds its inputs in the Deployment Plan / DAG. At request time, those binding +rules resolve concrete eligible records and readers. Conceptually: + +```text +Summary-state input → eligible SummaryStore records +Vector input → labeled values and a PromQL window +Relation input → typed SQL/storage relation +``` + +Admission and binding validate types, state formats, grouping, windows, +revisions, and coverage. They do not repeat operator lowering. + +### 3.3 Execute + +The shared runtime executes the bound Physical DAG. Execution does not lower +operators again or make new placement decisions. + +```mermaid +flowchart LR + A["Input"] --> B["Shared producer"] + B --> C["Consumer A"] --> E["Root 1"] + B --> D["Consumer B"] --> F["Root 2"] +``` + +The shared producer executes once. Multiple roots share upstream computation, +cancellation, and the enclosing memory budget. + +## 4. Precomputation and query execution + +Ingestion means accepting input data. Precomputation means executing a selected +DAG before a query request, including work triggered by ingestion or scheduled +window processing. + +Operator type does not determine execution phase. The selected plan determines +whether an operation executes during precomputation or at query time. + +For example, partial precomputation can use: + +```text +Precomputation: raw data → BuildKLL → SummaryStore +Query: SummaryStore → ReadKLL → Quantile(0.99) → result +``` + +The same operator library executes both DAGs. + +| Mode | Pre-request work | Request-time work | +| --- | --- | --- | +| No precomputation | None | All required computation | +| Partial precomputation | Store intermediate values or states | Complete the remaining DAG | +| Full precomputation | Complete all data-dependent computation | Retrieve the prepared result | + +These definitions are independent of the algorithm. Reading stored KLL state +and computing its quantile at query time is partial precomputation. +Build, merge, and readout are reusable operators rather than phase-specific operators. + +## 5. Query execution + +An installed query brings together these contracts: + +```text +Installed query + ├── QueryPlanEntry: nodes, dependencies and root + ├── selected Query Physical DAG + ├── deployment bindings and source references + ├── window contract + └── catalog generation +``` + +This is an architectural view, not the serialized field layout of +`QueryPlanEntry`; some contracts live in its enclosing plan or bound runtime graph. + +At request time: + +```mermaid +flowchart LR + Request["Query request"] --> Entry["Installed query"] + Entry --> Validate["Admit sources and pin eligible snapshot"] + Validate --> Bind["Bind inputs within request budget"] + Bind --> Execute["Execute DAG"] + Execute --> Convert["Protocol conversion"] --> Result["Query result"] +``` + +SQL relation nodes and PromQL vector operations use the same Planner physical +DAG representation. SQL adapters bind relations and convert results at the +protocol boundary. PromQL adapters bind labeled vectors and windows. Common +arithmetic, aggregation, sorting, limiting, joins, and temporal computation +belong in the shared physical-operator library. + +## 6. Request consistency + +One installed query request owns one cancellation signal and one resource +budget. A request over continuous local inputs also pins one eligible input +snapshot. Range steps have separate DAG execution state but share these controls. + +```text +range request + ├─ pin eligible snapshot + ├─ prepare inputs ┐ + ├─ execute t1 → retain result ├─ shared budget + ├─ execute t2 → retain result │ and cancellation + └─ return accumulated result ┘ +``` + +Tracked memory includes pinned SDS/current-series state, prepared inputs, +operator workspace, and retained results. JSON decoding and SQL encoding use +conservative workspace estimates; accounting is not a hard process RSS limit. + +Resource exhaustion or cancellation terminates the request without exact +fallback, even if a revision changes concurrently. + +### Snapshot compatibility + +All local inputs used by one query must admit a common eligible revision in the +selected catalog generation. A newer partially published revision does not +invalidate an older complete revision that still satisfies freshness and coverage. + +External exact adapters expose evaluation time but not a source snapshot token. +The current admission rule therefore rejects a candidate combining local +SDS/current-series inputs with external exact inputs, including candidate pruning. +The rule is checked at installation and before source access. + +```text +local SDS @ r1 external exact @ unknown revision + \ / + \------ operation ---/ + │ + ▼ + rejected +``` + +Equal timestamps are insufficient to establish equal revisions. Whole-query +external exact execution remains a separate deployment choice; a resource or +cancellation failure in a local DAG cannot trigger that choice. + +## 7. Operator and plan acceptance + +A local DAG is accepted only if every reachable operation has a compatible +implementation: + +```text +operation + ├── implementation exists + ├── input/output types match + ├── parameters are supported + ├── grouping semantics are supported + └── required state format is available +``` + +Deployment additionally validates source bindings, stored-state formats, +grouping, windows, revisions, coverage, and approximation evidence where required. +An operator implementation establishes execution support, not deployment feasibility. + +Unsupported operations are rejected explicitly. Routing them to an external +source does not make them locally supported. The [Planner library contract](physical-operators.md) +owns operator support; Backend does not maintain a second operator matrix. + +## 8. Candidate pruning + +Candidate pruning is ordinary DAG composition: + +```mermaid +flowchart LR + Summary["Candidate summary"] --> Keys["Candidate keys"] + Keys --> Join["SemiJoin(keys)"] + Values["Authoritative values"] --> Join + Join --> Sort["Sort(score)"] --> Limit["Limit per group"] +``` + +This path needs no dedicated `MembershipFilter` or grouped TopK operator. +Its correctness rules are: + +- grouped Limit is distinct from global Limit; +- candidate completeness comes from the pruning certificate; +- exact scoring cannot recover a candidate omitted by pruning; +- missing authoritative values invalidate certified pruning; +- local candidate and scoring inputs must use the same pinned snapshot. + +Best-effort pruning remains explicitly approximate. + +## 9. Runtime and cancellation + +Planner's native context is not `Send`, so each request executes through one +dedicated worker invocation. The worker owns thread-local execution context for +that request. Async callers signal cancellation, which remote waits and native +polling observe. + +When execution finishes, the worker clears the context before the thread is +reused. Cancellation is cooperative at polling boundaries; it does not preempt +an individual synchronous kernel. + +## 10. Testing and acceptance + +| Layer | Required coverage | +| --- | --- | +| Planner library | Shared-producer diamonds, multiple consumers, bounded buffering/backpressure, cancellation, memory accounting, both execution phases, typed expressions, joins, grouped Sort/Limit, and window computation | +| Backend | Installed source bindings, revision and coverage admission, stored-state reconstruction, end-to-end query results, and rejection of unsupported plans | +| Request contracts | Shared budgets across range steps and nested execution, terminal resource/cancellation errors, snapshot pinning, and rejection of mixed local/external inputs before reads | + +Results are compared against exact computation or the declared approximation +guarantee. These acceptance requirements do not imply that every deployment or +operator combination has been tested. + +## 11. Scope + +This design establishes the common execution path: + +```text +Logical Post-ASAP DAG + Summary Maintenance Lifecycle + → Physical DAG candidates + → Deployment Plan / DAG + → execute bound Physical DAGs + → query/precomputation output +``` + +Local raw Scan and universal support for every Planner aggregate or extension +remain outside this migration. Fallback routing does not establish local support; +tests supplied with already-decoded batches do not prove raw-source integration. + +ASAPPlanner #462 owns `physical_planner`, native physical operators, and DAG +execution. Backend's `DeploymentPlanCompiler` owns state identities, window +contracts, source bindings, and deployment of the resulting DAG. diff --git a/docs/developer_docs/control-plane/physical-compiler.md b/docs/developer_docs/control-plane/physical-compiler.md index 3885be978..dc086a377 100644 --- a/docs/developer_docs/control-plane/physical-compiler.md +++ b/docs/developer_docs/control-plane/physical-compiler.md @@ -42,7 +42,7 @@ for every target collector. Legacy they are not a second semantic planner. ASAPPlanner owns semantics and physical candidate construction, including summary -families, maintenance lifecycles and physical boundaries. Backend evaluates +families, precomputation update lifecycles and physical boundaries. Backend evaluates candidate feasibility and complete workload-scoped costs, then selects and binds the deployment without changing its computation. Missing or stale implementation evidence makes the candidate unavailable; the @@ -117,7 +117,7 @@ Why this interface exists: it prevents adapters, protocols, and physical planning from each implementing their own query-to-summary mapping. Snapshot and HTTP adapters provide the same `WindowCostModel`. After logical -selection, one generator enumerates layouts allowed by each state's maintenance +selection, one generator enumerates layouts allowed by each state's precomputation requirements and deployment target, then derives or matches layout-specific costs. See [repeated window planning](../planning/repeated-dashboard-panes.md) for inputs, migration and cadence examples. @@ -226,9 +226,11 @@ 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, -so a shared node in a diamond DAG performs one store/operator execution. A -typed node failure follows the entry's explicit fallback route. +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. Only an +eligible availability failure follows the entry's explicit fallback route. +Resource exhaustion and cancellation terminate execution without fallback. ### What the compiler puts in CollectorPlan diff --git a/docs/developer_docs/maintenance-replay.md b/docs/developer_docs/maintenance-replay.md deleted file mode 100644 index e40d48e4e..000000000 --- a/docs/developer_docs/maintenance-replay.md +++ /dev/null @@ -1,117 +0,0 @@ -# Maintenance execution, replay and completion - -Use the [E2E physical-DAG walkthrough](../evaluation/e2e-physical-dag.md) for -commands, installed artifacts and the current acceptance boundary. The -production adapter now executes an explicit validated maintenance subDAG; the -older description of a merge-only adapter is obsolete. - -## Plans and runtime capability - -Planner emits the actual maintenance-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 -collector/backfill jobs or additive fragments. - -The finite implementation supports a bounded exact-input/readout/arithmetic to -summary-update subset: explicit Finalize over supported exact Sum/Count states, -strictly aligned arithmetic rows, and the installed supported outer family/update -expression. Query-time rows are not silently reinterpreted as maintenance rows. -The catalog contains window width, slide, layout and origin. Current automatic -execution requires matching full stored nonoverlapping windows; it does not -invent a pane merge or enable sliding merely because the layout DTO can express -it. - -The accepted finite multi-source path requires complete source definitions and -its admitted population scope. The wider canonical multi-group path is a -separate integration: all source and target identities must use the canonical -version, all expected groups/windows must be present, and a global reduction -must publish one transaction over the entire cohort. Per-group partial updates -are not equivalent. A real differing-group DDS quantile probe exposed a readout -interpolation mismatch; the new scope is not an accuracy success until its -selected-family process oracle passes. - -## Queue admission is not immutable completion - -`POST /api/v1/write` accepts Remote Write v1 scalar samples into bounded queues. -It returns `204` after validation, capacity reservation and in-memory dedup -bookkeeping. Accumulator mutation and summary persistence happen later. There -is no durable raw-sample WAL or recovered receiver dedup log. A stale marker is -excluded from numeric aggregation, not propagated as a lifecycle event. - -`POST /api/v1/precompute/drain` is a finite-input operation. It closes the -receiver, waits for admitted work and forces the required summary payloads to -become durable before sealing completion. Failed closure is not a successful -proof; retry must finish the same closure. The durable generation checkpoint -rejects new raw input after a same-generation restart. A newly installed -generation has separate admission/visibility rules and cannot reuse the old -derived result while its source population is still open. - -## Reserved durable publication - -The immutable output writer reuses the existing SID sidecar, part format, -manifest and flusher. Under the source/lifetime and mutation guards it reserves -one output window for a physical SID, recording input lineage and payload -fingerprint. It writes the part, publishes it through the manifest, and only -then advances the immutable frontier and clears the pending reservation. - -The latest completed receipt is retained per SID. An identical latest retry -requires matching input lineage, valid retained payload and live manifest -membership. A conflicting lineage cannot reuse equal output bytes. The consumer -looks up committed output or resumes a matching durable pending part **before** -recomputing a randomized sketch. This prevents restart from creating another -part or accepting a different random payload as the same publication. - -A pending reservation whose payload was never made durable may still require -retained source data. If that data is gone, recovery fails closed; at-most-once -publication is not a promise of unlimited replay availability. Expired/retired -lifetimes must not be resurrected. The immutable end frontier forbids late -mutation; it is not itself proof of gap-free coverage. Readers and schedulers -must still check concrete completed window coordinates. - -## Continuous producer barriers - -The existing shared `SummaryWatermarkBarrier` carries catalog generation, -producer ID, partition ID, producer epoch, monotonic sequence and watermark. -`MultiSourceCoordinator` uses an explicit source-partition specification and -persisted staging/checkpoints. Superseded epochs cannot satisfy readiness for a -new epoch. All required inputs and partitions must be complete for the output -window before a batch is ready. - -`OutputSink::advance_summary_watermark` is an internal Rust API. The ordinary -remote-write sample payload supplies neither this authority nor a closed -producer roster, and there is no public HTTP watermark endpoint. Worker maximum -sample time, an idle timeout, HTTP acceptance and a query coverage watermark are -not interchangeable with all-producer completion. A continuous integration must -bind the producer roster, order barriers after publication/durability, reject -late writes in a closed range, and preserve that proof across restart. - -The finite drain path must not be advertised as continuous operation. The next -sliding layer should first consume explicitly selected FullWindow states at the -exact origin/slide coordinates; overlapping pane composition requires its own -explicit operator, cost and coverage validation. - -## Earlier in-process receipt machinery - -The generic maintenance sink also has bounded in-process replay receipts for -ordered batches. Those receipts expire by the retained-state retry horizon and -pin a partially accepted batch until retry completes. They do not replace the -SID sidecar/manifest protocol above and must not be used as durable immutable -publication evidence. Their existence alone does not authorize arbitrary -maintenance DAGs, groups or producer completion claims. - -### Installed producer partition roster - -`ProducerContract.partition_ids` optionally declares the authoritative partitions -for that producer and materialization. The default empty set preserves legacy -serialization and permits no completion claims. It does not infer a roster from -received Remote Write series, worker count or observed timestamps. - -`PrecomputePlan::validate_watermark_scope(materialization, barrier)` checks an -existing `SummaryWatermarkBarrier` against the installed catalog generation, -plan envelope and producer/partition/materialization binding. It also applies -the existing barrier validation. This is a scope check only: a receiver still -needs authenticated producer identity, ordered publication, durable progress -and the existing coordinator's epoch/sequence rules before accepting closure. -There is no public HTTP barrier endpoint in this change. In particular, a -successful `/api/v1/write` response does not advance this authority. diff --git a/docs/developer_docs/precomputation-replay.md b/docs/developer_docs/precomputation-replay.md new file mode 100644 index 000000000..c3d7668b0 --- /dev/null +++ b/docs/developer_docs/precomputation-replay.md @@ -0,0 +1,44 @@ +# Precomputation publication and recovery + +Ingestion accepts source data; precomputation executes the installed Physical +DAG and publishes stored results. Planner defines computation and permitted +update behavior. Backend binds inputs, enforces correction and retention limits, +and persists results under the installed SDS identities. + +## Continuous local ingestion + +With `--remote-write-revision-dir`, Backend checkpoints accepted Remote Write +input and evaluates the selected precomputation DAG against a fixed input +revision. Late input within the correction horizon produces a replacement +revision, not an additive replay of the complete result. Out-of-range requests +are rejected before admission. + +Outputs can publish independently. Queries pin a common eligible snapshot across +all required outputs and range steps. If one branch has r2 and another is still +at r1, the query may read common r1 while it satisfies freshness. It must not mix +incompatible revisions. Same-version recovery restores input history and committed +outputs; a new plan version needs its own warm-up. + +See [continuous input and recovery](../design_docs/precompute-dag-execution.md#continuous-local-input-revision-snapshots) +for configuration, publication rules and acceptance cases. This local-input path +does not require an external producer/partition watermark protocol. + +## Finite input + +Without continuous revision configuration, `POST /api/v1/write` acknowledges +queue admission; it does not prove durable publication or input completion. +`POST /api/v1/precompute/drain` closes the finite receiver, waits for admitted +work and durably publishes the required outputs before sealing completion. +A failed drain must be retried; it cannot authorize derived output publication. +The recovered same-generation checkpoint rejects further input after closure. +Drain cannot seal a continuously revisable input. + +The finite output writer records input lineage and payload identity, writes a +part, commits its manifest entry, then advances completion. Recovery resumes a +matching durable pending publication before recomputing randomized state. +Conflicting lineage cannot reuse equal bytes. Missing retained inputs or durable +payload fail explicitly; in-memory retry receipts are not durable proof. + +See the [E2E walkthrough](../evaluation/e2e-physical-dag.md) for commands and +installed artifacts. External producer watermark coordination is a separate +input contract, not an inference from flush timers or observed timestamps. diff --git a/docs/developer_docs/query-engine/compiler-rule-boundary-review.md b/docs/developer_docs/query-engine/compiler-rule-boundary-review.md deleted file mode 100644 index 7c432093b..000000000 --- a/docs/developer_docs/query-engine/compiler-rule-boundary-review.md +++ /dev/null @@ -1,30 +0,0 @@ -# Backend compiler rule-boundary review - -Scope: ASAPQuery-backend main `877ea128`, plus PR #700 (`1dcf299f`) and its current correction. Reviewed implementation and callers, not documentation claims. Findings below are code-level boundary violations or coupling; no untested claim of production wrong answers. - -Rechecked after merging main `674b9573` (#699): the four implementation files containing these five findings are unchanged; the legacy callers remain. - -## Findings - -1. **[P1] Legacy binding rewrites Rate into Increase before Planner selection.** `control_plane/src/physical/post_asap/lower.rs:171-214` calls `rewrite_rate_to_increase` before `select_summary`. This erases Rate from the selected logical IR and relies on downstream knowledge to recover division by the window. The caller in `control_plane/src/main.rs:1135-1162` remains reachable through the legacy algebra/typed-stage path. Share a physical counter implementation while preserving the logical Rate node/readout; if a logical rate-to-increase/divide transformation is needed, Planner must emit both operations. This is distinct from the main physical compiler's `physical_materialization_family`, which only maps the stored family and retains the selected readout. - -2. **[P1] Legacy metric-name policy invents aggregation semantics.** `control_plane/src/physical/workload_planner.rs:23-38,126-143,180-282` maps `top_endpoint_qps` to TopK and constructs a synthetic per-endpoint Count aggregate. It also chooses Quantile/Cardinality/Frequency from names rather than solely from the canonical query. `main.rs:1161` and `main.rs:1958` still call this adapter. Planner checks the fabricated intent's legality, not whether it describes the submitted query. Restrict such examples to fixtures, or require an explicit typed workload intent; never choose a statistic from a metric name. - -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. - -## PR #700 correction - -The original backend `current_series.rs` parsed PromQL and independently assembled a maintained-state alternative. The correction moves recognition and workload sharing into Planner's `MaintainedPopulationStrategy`, adds typed maintenance/readout operators, removes `PlanningRequest.current_series`, and lowers the selected operators directly. Regression tests check that the installed Planner DAG contains the producer and that changing catalog text does not change physical operator binding. - -## Within the backend's responsibility - -- Complete workload quote comparison, placement, memory limits, input-lag bounds and runtime capability filtering. -- Choosing supported physical window implementations and subsets of retained materializations while preserving exact residual execution. -- Mapping Count/Sum or Rate/Increase to compatible physical storage while retaining the original logical readout. -- Explicit exact fallback for unsupported operators/accuracy/ERP evidence. A fallback may reduce acceleration coverage but is not itself a new logical rewrite rule. - -The other findings are review follow-ups, not silently bundled fixes. Retiring the legacy binding path and removing text-based residual reconstruction should be separate, focused changes with compatibility tests. diff --git a/docs/evaluation/e2e-physical-dag.md b/docs/evaluation/e2e-physical-dag.md index 0bcc13a67..8ea94b773 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 @@ -172,7 +172,7 @@ curl -fsS -X POST http://127.0.0.1:9091/api/v1/precompute/drain | jq . This is a finite-input closure operation, not a timer or an ordinary flush for a live Prometheus deployment. It closes that receiver; later writes in the closed generation are rejected. Durable completion and retry/recovery are distinct -from queue admission. See [maintenance and completion](../developer_docs/maintenance-replay.md). +from queue admission. See [precomputation and completion](../developer_docs/precomputation-replay.md). Prometheus `WriteRequest` has no authoritative event-time watermark or producer/partition roster. Worker maximum timestamps and inactivity therefore