From 215fc79c8def6d64ef8a6efc661e80cc91277284 Mon Sep 17 00:00:00 2001 From: zz_y Date: Tue, 22 Sep 2026 19:50:36 +0000 Subject: [PATCH 01/26] refactor: execute query relation subgraphs with memoization --- .../asap_clickhouse_query_engine/execution.rs | 366 ++++++++++++------ .../asap_query_engine/physical_dag.rs | 64 ++- 2 files changed, 313 insertions(+), 117 deletions(-) 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 65b43e03..3b300fed 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 @@ -50,127 +50,183 @@ fn apply_relational_operation( .map_err(|error| error.to_string()) } -fn execute_relation_subtree( - index: &SketchStore, - entry: &QueryPlanEntry, - root: QueryNodeId, - expected_schema: &planner_types::post_asap::SummarySchema, - prepared: &PreparedExternalLeaves, +struct RelationDagExecutor<'a> { + index: &'a SketchStore, + entry: &'a QueryPlanEntry, + prepared: &'a PreparedExternalLeaves, t0_ms: u64, t1_ms: u64, is_cumulative: bool, -) -> Result { - match entry.nodes.get(&root) { - Some(QueryPlanNode::ExternalExact { request, .. }) => { - if request.language != asap_types::QueryLanguage::ClickHouseSql { - return Err("ClickHouse DAG contains an external leaf for another language".into()); - } - let asap_types::query_plan::ExternalExactOutput::Relation { schema } = &request.output - else { - return Err("ClickHouse external leaf must declare relation output".into()); - }; - let declared: planner_types::post_asap::SummarySchema = - serde_json::from_value(schema.clone()).map_err(|error| error.to_string())?; - if &declared != expected_schema { - return Err("external exact leaf schema differs from its parent edge".into()); + memo: BTreeMap, + schemas: BTreeMap, + active: BTreeSet, + #[cfg(test)] + evaluations: BTreeMap, +} + +impl RelationDagExecutor<'_> { + fn execute( + &mut self, + root: QueryNodeId, + expected_schema: &planner_types::post_asap::SummarySchema, + ) -> Result { + if let Some(schema) = self.schemas.get(&root) { + if schema != expected_schema { + return Err(format!( + "query `{}` node {} is consumed with inconsistent relation schemas", + self.entry.query_id, root.0 + )); } - prepared - .get(&root) - .cloned() - .ok_or_else(|| "published external exact leaf was not prepared".into()) } - Some(QueryPlanNode::Relational { - input, - operation, - input_schema, - output_schema, - }) => { - let input = execute_relation_subtree( - index, - entry, - *input, - input_schema, - prepared, - t0_ms, - t1_ms, - is_cumulative, - )?; - apply_relational_operation(operation.clone(), output_schema, input) + if let Some(relation) = self.memo.get(&root) { + return Ok(relation.clone()); + } + if !self.active.insert(root) { + return Err(format!( + "query `{}` contains a cycle at relation node {}", + self.entry.query_id, root.0 + )); } - Some(QueryPlanNode::RelationalJoin { - inputs, - join_kind, - pred, - left_schema, - right_schema, - output_schema, - }) => { - if !matches!(join_kind, planner_types::pre_asap::JoinKind::Inner) { - return Err("only inner relational joins are executable".into()); + self.schemas.insert(root, expected_schema.clone()); + let result = self.execute_uncached(root, expected_schema); + self.active.remove(&root); + let relation = result.map_err(|error| { + format!( + "query `{}` relation node {} failed: {error}", + self.entry.query_id, root.0 + ) + })?; + self.record_evaluation(root); + self.memo.insert(root, relation.clone()); + Ok(relation) + } + + #[cfg(test)] + fn record_evaluation(&mut self, id: QueryNodeId) { + *self.evaluations.entry(id).or_default() += 1; + } + + #[cfg(not(test))] + fn record_evaluation(&mut self, _id: QueryNodeId) {} + + fn execute_uncached( + &mut self, + root: QueryNodeId, + expected_schema: &planner_types::post_asap::SummarySchema, + ) -> 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()) } - let left = execute_relation_subtree( - index, - entry, - inputs[0], + Some(QueryPlanNode::Relational { + input, + operation, + input_schema, + output_schema, + }) => { + if output_schema != expected_schema { + return Err("relational node output schema differs from its parent edge".into()); + } + let input = self.execute(*input, input_schema)?; + apply_relational_operation(operation.clone(), output_schema, input) + } + Some(QueryPlanNode::RelationalJoin { + inputs, + join_kind, + pred, 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, + output_schema, + }) => { + if output_schema != expected_schema { + return Err("join output schema differs from its parent edge".into()); + } + if !matches!(join_kind, planner_types::pre_asap::JoinKind::Inner) { + return Err("only inner relational joins are executable".into()); + } + let left = self.execute(inputs[0], left_schema)?; + let right = self.execute(inputs[1], right_schema)?; + 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(_) => { + let outcome = execute_query_plan_from_readout( + self.index, + self.entry, + root, + self.t0_ms, + self.t1_ms, + self.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, - ) + 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 = execute_query_plan_from_readout( + self.index, + self.entry, + leaf_id, + self.t0_ms, + self.t1_ms, + self.is_cumulative, + ) + .map_err(|error| format!("incomplete leaf coverage: {error:?}"))?; + if !binding.covers_range(self.t0_ms, self.t1_ms) + || !complete_pane_coverage( + leaf_outcome.coverage, + (self.t0_ms, self.t1_ms), + binding.window_ms, + ) + { + return Err(format!( + "incomplete leaf coverage: requested ({}, {}), observed {:?}, pane {} origin {}", + self.t0_ms, + self.t1_ms, leaf_outcome.coverage, binding.window_ms, binding.pane_origin_ms.unwrap_or(0) )); + } } - } - ClickHouseRelation::from_series_rows(expected_schema, outcome.series, outcome.coverage) + ClickHouseRelation::from_series_rows( + expected_schema, + outcome.series, + outcome.coverage, + ) .map_err(|error| error.to_string()) + } + None => Err(format!("published DAG references missing node {}", root.0)), } - None => Err(format!("published DAG references missing node {}", root.0)), } } pub enum ClickHouseDagOutcome { @@ -265,16 +321,21 @@ 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(), + active: BTreeSet::new(), + #[cfg(test)] + evaluations: BTreeMap::new(), + }) + .execute(entry.root, &root_schema) + { Ok(relation) => relation, Err(error) if error.contains("incomplete leaf coverage") => { return ClickHouseDagOutcome::Fallback(ClickHouseDagFallback::IncompleteCoverage { @@ -453,7 +514,94 @@ fn validate_reachable(entry: &QueryPlanEntry, root: QueryNodeId) -> Result<(), S #[cfg(test)] mod tests { - use super::complete_pane_coverage; + use super::*; + use asap_types::query_plan::{ + ExternalExactOutput, ExternalExactRequest, FallbackPolicy, InstantExecution, QueryLanguage, + }; + use planner_types::{ + post_asap::{SummaryFamilyType, SummaryField, SummarySchema}, + pre_asap::DataType, + }; + + fn relation_schema(name: &str) -> SummarySchema { + SummarySchema { + fields: vec![SummaryField { + name: name.into(), + dtype: SummaryFamilyType::Plain(DataType::Int64), + nullable: false, + }], + time_index: None, + } + } + + fn external_entry(schema: &SummarySchema) -> QueryPlanEntry { + QueryPlanEntry { + language: QueryLanguage::ClickHouseSql, + query_id: "shared-external".into(), + canonical_query: "SELECT x".into(), + fixed_evaluation: None, + root: QueryNodeId(0), + nodes: BTreeMap::from([( + QueryNodeId(0), + QueryPlanNode::ExternalExact { + request: ExternalExactRequest { + language: QueryLanguage::ClickHouseSql, + expression: "SELECT 1 AS x".into(), + output: ExternalExactOutput::Relation { + schema: serde_json::to_value(schema).unwrap(), + }, + parameters: BTreeMap::new(), + start_parameter: None, + end_parameter: None, + input_contracts: Vec::new(), + }, + inputs: Vec::new(), + }, + )]), + instant: InstantExecution { + lookback_ms: 0, + full_history: false, + cumulative_readout: false, + }, + fallback: FallbackPolicy::Reject, + } + } + + #[test] + fn relation_dag_memoizes_a_shared_node_and_enforces_one_edge_schema() { + let schema = relation_schema("x"); + let entry = external_entry(&schema); + let relation = ClickHouseRelation::from_json_compact( + &schema, + br#"{"meta":[{"name":"x","type":"Int64"}],"data":[[1]]}"#, + ) + .unwrap(); + let prepared = BTreeMap::from([(QueryNodeId(0), relation)]); + let index = SketchStore::new(); + let mut executor = RelationDagExecutor { + index: &index, + entry: &entry, + prepared: &prepared, + t0_ms: 0, + t1_ms: 1, + is_cumulative: false, + memo: BTreeMap::new(), + schemas: BTreeMap::new(), + active: BTreeSet::new(), + evaluations: BTreeMap::new(), + }; + + executor.execute(QueryNodeId(0), &schema).unwrap(); + executor.execute(QueryNodeId(0), &schema).unwrap(); + assert_eq!(executor.evaluations[&QueryNodeId(0)], 1); + + let error = executor + .execute(QueryNodeId(0), &relation_schema("different")) + .unwrap_err(); + assert!(error.contains("query `shared-external` node 0")); + assert!(error.contains("inconsistent relation schemas")); + } + #[test] fn exact_accumulator_window_end_coverage_includes_its_pane_start() { assert!(complete_pane_coverage( 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 index 178965e5..dfbbda6a 100644 --- a/data_plane/src/query_engines/asap_query_engine/physical_dag.rs +++ b/data_plane/src/query_engines/asap_query_engine/physical_dag.rs @@ -41,8 +41,12 @@ pub trait AsyncQueryNodeRuntime { pub enum DagExecutionError { #[error("invalid physical query graph: {0}")] InvalidGraph(String), - #[error("query node {node_id} failed")] - Node { node_id: u64, source: E }, + #[error("query `{query_id}` node {node_id} failed")] + Node { + query_id: String, + node_id: u64, + source: E, + }, } /// Execute each reachable node exactly once. A diamond-shaped DAG therefore @@ -59,9 +63,9 @@ pub fn execute_from( root: QueryNodeId, runtime: &R, ) -> Result> { - let order = entry - .topological_order_from(root) - .map_err(|error| DagExecutionError::InvalidGraph(error.to_string()))?; + let order = entry.topological_order_from(root).map_err(|error| { + DagExecutionError::InvalidGraph(format!("query `{}`: {error}", entry.query_id)) + })?; let mut outputs = BTreeMap::::new(); for id in order { let node = entry @@ -84,6 +88,7 @@ pub fn execute_from( runtime .execute_node(id, node, &inputs) .map_err(|source| DagExecutionError::Node { + query_id: entry.query_id.clone(), node_id: id.0, source, })?; @@ -107,9 +112,9 @@ pub async fn execute_from_async( root: QueryNodeId, runtime: &R, ) -> Result> { - let order = entry - .topological_order_from(root) - .map_err(|error| DagExecutionError::InvalidGraph(error.to_string()))?; + let order = entry.topological_order_from(root).map_err(|error| { + DagExecutionError::InvalidGraph(format!("query `{}`: {error}", entry.query_id)) + })?; let mut outputs = BTreeMap::::new(); for id in order { let node = entry @@ -132,6 +137,7 @@ pub async fn execute_from_async( .execute_node(id, node, &inputs) .await .map_err(|source| DagExecutionError::Node { + query_id: entry.query_id.clone(), node_id: id.0, source, })?; @@ -153,6 +159,22 @@ mod tests { struct CountingRuntime(RefCell>); + struct FailingRuntime; + + impl QueryNodeRuntime for FailingRuntime { + type Output = usize; + type Error = &'static str; + + fn execute_node( + &self, + _id: QueryNodeId, + _node: &QueryPlanNode, + _inputs: &[usize], + ) -> Result { + Err("broken read") + } + } + impl QueryNodeRuntime for CountingRuntime { type Output = usize; type Error = std::convert::Infallible; @@ -223,6 +245,32 @@ mod tests { assert!(runtime.0.borrow().values().all(|count| *count == 1)); } + #[test] + fn node_failure_identifies_the_installed_query_and_node() { + let entry = QueryPlanEntry { + language: asap_types::query_plan::QueryLanguage::PromQl, + query_id: "latency-p50".into(), + canonical_query: "latency".into(), + fixed_evaluation: None, + root: QueryNodeId(7), + nodes: BTreeMap::from([( + QueryNodeId(7), + QueryPlanNode::ExactFallback { + reason: "fixture".into(), + }, + )]), + instant: InstantExecution { + lookback_ms: 0, + full_history: false, + cumulative_readout: false, + }, + fallback: FallbackPolicy::Reject, + }; + + let error = execute(&entry, &FailingRuntime).unwrap_err().to_string(); + assert!(error.contains("query `latency-p50` node 7 failed")); + } + struct AsyncCountingRuntime(tokio::sync::Mutex>); #[async_trait::async_trait] From e2df4a5ac9bd564d48a88d3db077db4601eadf30 Mon Sep 17 00:00:00 2001 From: zz_y Date: Tue, 22 Sep 2026 19:50:43 +0000 Subject: [PATCH 02/26] docs: explain installed QueryPlan DAG execution --- docs/design_docs/README.md | 2 + docs/design_docs/query-dag-execution.md | 178 ++++++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 docs/design_docs/query-dag-execution.md diff --git a/docs/design_docs/README.md b/docs/design_docs/README.md index 7a98c71a..42b1aeb7 100644 --- a/docs/design_docs/README.md +++ b/docs/design_docs/README.md @@ -12,6 +12,8 @@ notes and migration gates distinguish implemented behavior from proposed changes - [Summary definitions table and SDS](summary-catalog-sds-architecture.md) owns definition and instance identity, version-scoped state references, readiness and state lifecycle semantics. +- [QueryPlan DAG execution](query-dag-execution.md) explains how the query + engine evaluates installed query sub-DAGs and reads bound stored summaries. - [Architecture migration delivery plan](asapplanner-migration-plan.md) defines common-library extraction, removal of ASAPCollector dependencies, the two-plan rollout, and backend acceptance/retirement gates. diff --git a/docs/design_docs/query-dag-execution.md b/docs/design_docs/query-dag-execution.md new file mode 100644 index 00000000..2806a00c --- /dev/null +++ b/docs/design_docs/query-dag-execution.md @@ -0,0 +1,178 @@ +# QueryPlan DAG execution + +Audience: backend designers and developers. + +This document describes how the backend executes an installed `QueryPlan`. +The [plan split](asapplanner-integration.md) defines why query-time work is +separate from maintenance, and the +[SDS contract](summary-catalog-sds-architecture.md) defines the stored records +read by the plan. + +## Runtime contract + +The physical compiler publishes one `QueryPlanEntry` for each installed query. +An entry contains a root, typed nodes, dependency IDs, evaluation-time rules, +and an explicit fallback policy. The query engine executes the nodes reachable +from that root. It does not parse the incoming expression into another runtime +program and does not execute `selected_dags`; those documents retain Planner +semantic provenance for validation and debugging. + +The request first resolves a canonical query identity in the active, immutable +physical-plan snapshot. The Summary Catalog, PrecomputePlan and QueryPlan in +that snapshot have the same plan ID and version. A lookup miss is a capability +miss and follows the installed routing policy. + +```mermaid +flowchart LR + Request[Canonical query request] --> Lookup[Lookup QueryPlanEntry] + Lookup --> Root[Start at entry.root] + Root --> Walk[Find reachable sub-DAG] + Walk --> Inputs[Evaluate dependencies] + Inputs --> Node[Execute node adapter] + Node --> Memo[Memoize node output] + Memo --> Result[Adapt root output] + Store[SummaryStore] -->|StoredOutputReference| Inputs + Exact[External exact engine] -->|declared exact leaf| Inputs +``` + +Installation rejects missing inputs, cycles, unreachable nodes, invalid output +bindings, unsupported provenance versions, and a reader whose window contract +or `StoredOutputReference` differs from its PrecomputePlan writer. Runtime +errors retain the query ID and node ID. + +## Execution layers + +V1 uses one plan and three value adapters rather than three semantic programs. + +| Adapter | Nodes and values | Scheduling | +| --- | --- | --- | +| Stored-summary adapter | `ReadMaterialization`, state merge, exact/sketch readout, scalar arithmetic and reduction | Reachable nodes run in topological order. Each node runs once for the requested root. | +| PromQL/MetricsQL residual adapter | Logical aggregation, binary, temporal, subquery, candidate reranking and prepared exact leaves | Demand evaluation memoized by `(node_id, evaluation_time)`. The time key is required because a subquery evaluates one dependency at several timestamps. | +| ClickHouse relation adapter | External relations, filters, projections and joins around stored-summary sub-DAGs | Demand evaluation memoized by `node_id`. Each edge validates its declared relation schema. Stored-summary sub-DAGs delegate to the topological adapter. | + +All adapters start from a `QueryPlanEntry` node. The language adapters only +represent different runtime value types. They cannot select a replacement +definition or reconstruct an operator from the request text. + +## StoredSummary reads + +A `ReadMaterialization` carries the exact `StoredOutputReference` published for +its writer. V1 derives its output ID from the definition ID because the current +store index is definition keyed. The read proceeds as follows: + +1. Validate the output reference and its definition identity. +2. Use the active catalog generation and definition to select only visible + stored summaries. A previous generation is visible only after an explicit + compatible-definition decision during activation. +3. Reject unpublished input, a request outside the bound pane/full-window + phase, missing required panes, or an unavailable population. +4. Check the installed catalog descriptor against the requested readout family. +5. Decode payloads using their stored descriptor and merge only compatible + states according to the plan's grouping contract. + +The query engine performs an ID lookup in the SummaryStore index. It does not +search the catalog at serving time for an alternative summary. Instance +metadata and payload are one logical `StoredSummary`; the physical +`SummaryStateReference` used by the storage engine is not a QueryPlan edge. + +Readiness is conservative. Until the store represents completed empty panes, +a missing additive pane is not assumed to be zero. A coverage or format miss +therefore triggers the entry's installed fallback behavior instead of returning +a partial accelerated answer. + +## Dependency ordering and reuse + +Each evaluation derives only the sub-DAG reachable from the requested root. +Dependencies complete before their consumer. Output memoization makes a diamond +graph execute its shared node once within that evaluation. + +PromQL subqueries are the exception to a plain node-only memo key. The same +node has a different result at each evaluation timestamp, so their memo key is +`(node_id, evaluation_time)`. Repeated access at the same timestamp reuses the +value. Prepared external leaves use the same identity and are issued before +local residual evaluation so network I/O does not hide inside a synchronous +operator. + +Memoization is request local. It is discarded after the root result is adapted; +the SummaryStore is the cross-request reuse boundary. A summary revision fence +prevents a response from combining payloads changed during one evaluation. + +## Exact work and fallback + +An `ExternalExact` node is an ordinary typed DAG leaf. Its expression, output +shape, parameters and input contracts are installed with the plan. The engine +may prepare it through Prometheus, MetricsQL or ClickHouse only when forwarding +is enabled. Candidate-dependent leaves first evaluate their installed candidate +sub-DAG and pass the resulting membership set to the exact request. + +Fallback is not an implicit parser retry inside the executor. An +`ExactFallback` node fails deliberately, and the entry's `FallbackPolicy` +determines whether the router may call the exact backend. Invalid graphs, +incompatible stored state and unsupported nodes fail closed with a scoped +reason. + +## Shared KLL example + +Two installed query entries can read one precomputed output while keeping +different query roots: + +```mermaid +flowchart LR + subgraph P[PrecomputePlan] + Samples[Latency samples] --> KLL[Build KLL by service] + KLL --> Write[Write output latency-kll] + end + Write --> Store[(StoredSummary records)] + subgraph Q50[QueryPlanEntry p50] + Read50[Read latency-kll] --> P50[Quantile 0.50] + end + subgraph Q99[QueryPlanEntry p99] + Read99[Read latency-kll] --> P99[Quantile 0.99] + end + Store --> Read50 + Store --> Read99 +``` + +`Read50`, `Read99` and `Write` carry the same `StoredOutputReference`. Each +request reads the ready population/window records and executes only its own +readout sub-DAG. No query rebuilds the KLL and no serving-time catalog search +chooses a different summary. + +Within one entry, two parents may also share a read or relational node. The +request-local memo returns its existing value to the second parent. Across the +p50 and p99 requests, payload reuse comes from SummaryStore rather than a +cross-request executor cache. + +## Concurrency, cancellation and limits + +Requests execute concurrently and own separate memo maps and intermediate +values. The active physical plan and committed stored summaries are shared +through immutable snapshots or synchronized store indexes. No mutable execution +context is shared between requests. + +V1 evaluates ready nodes sequentially inside one request. Independent requests +still run concurrently. Parallel execution of independent nodes is unnecessary +for correctness and remains future work. External I/O uses the request client's +timeout; cancellation drops the request-local evaluation and its prepared +values. Logical subqueries enforce depth and evaluation budgets to bound memory +and work. + +Large relation intermediates and a unified resource budget across all three +adapters remain follow-up work. The current implementation also does not cache +root results across requests, add a distributed query scheduler, or reuse stored +payloads across plan versions without the activation-time compatibility check. + +## End-to-end sequence + +1. The control plane installs and activates one coherent physical plan. +2. The serving endpoint canonicalizes the request only to find its installed + entry; it does not compile a new execution graph. +3. The engine validates the entry against the active catalog generation. +4. Declared external leaves are prepared when allowed. +5. The appropriate value adapter evaluates the reachable sub-DAG with + request-local memoization. +6. `ReadMaterialization` nodes resolve their bound ready stored summaries. +7. Node failures carry query/node context and follow the installed fallback + policy. +8. A revision fence confirms that stored input did not change during execution. +9. The root value is adapted to the Prometheus/MetricsQL or ClickHouse response. From f9bb469cdc49a04df785f4d5b851464fb6062a6d Mon Sep 17 00:00:00 2001 From: zz_y Date: Tue, 22 Sep 2026 20:10:20 +0000 Subject: [PATCH 03/26] chore: sync backend with ASAPPlanner main --- .github/workflows/sync-asapplanner-main.yml | 55 +++++++++++++++++++++ Cargo.lock | 35 +++++-------- Cargo.toml | 8 +-- control_plane/src/planner_selection.rs | 6 +-- scripts/sync-asapplanner-main.sh | 35 +++++++++++++ 5 files changed, 110 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/sync-asapplanner-main.yml create mode 100755 scripts/sync-asapplanner-main.sh diff --git a/.github/workflows/sync-asapplanner-main.yml b/.github/workflows/sync-asapplanner-main.yml new file mode 100644 index 00000000..cfadf644 --- /dev/null +++ b/.github/workflows/sync-asapplanner-main.yml @@ -0,0 +1,55 @@ +name: Sync ASAPPlanner main + +on: + schedule: + - cron: "17 6 * * *" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: sync-asapplanner-main + cancel-in-progress: true + +jobs: + update: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout backend + uses: actions/checkout@v4 + + - name: Install Rust components + run: rustup component add rustfmt + + - name: Configure private Cargo dependencies + env: + ASAP_CI_REPO_TOKEN: ${{ secrets.ASAP_CI_REPO_TOKEN }} + run: git config --global url."https://x-access-token:${ASAP_CI_REPO_TOKEN}@github.com/".insteadOf "https://github.com/" + + - name: Update Planner revision and lockfile + env: + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + run: ./scripts/sync-asapplanner-main.sh + + - name: Check synchronized workspace + env: + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + run: | + cargo fmt -p control_plane -p data_plane -p asap_types -p asap_otel_proto -- --check + cargo check --workspace + + - name: Create or update synchronization PR + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.ASAP_CI_REPO_TOKEN }} + branch: automation/sync-asapplanner-main + delete-branch: true + commit-message: "chore: sync ASAPPlanner main" + title: "chore: sync ASAPPlanner main" + body: | + Updates all ASAPPlanner workspace dependencies to the current `main` commit and regenerates `Cargo.lock`. + + The synchronization workflow verified formatting and `cargo check --workspace` before opening this PR. diff --git a/Cargo.lock b/Cargo.lock index 1d49df2e..334e5461 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -364,7 +364,7 @@ dependencies = [ [[package]] name = "asap-aware-mapping" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=851493116d42674d09cf9646dde59532003025e4#851493116d42674d09cf9646dde59532003025e4" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=2ec3fc80caa922c8e1f33aa05f60b7d787e73257#2ec3fc80caa922c8e1f33aa05f60b7d787e73257" dependencies = [ "asap-types", "asap_sketchlib 0.3.0 (git+https://github.com/ProjectASAP/asap_sketchlib)", @@ -376,7 +376,7 @@ dependencies = [ [[package]] name = "asap-frontend-promql" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=851493116d42674d09cf9646dde59532003025e4#851493116d42674d09cf9646dde59532003025e4" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=2ec3fc80caa922c8e1f33aa05f60b7d787e73257#2ec3fc80caa922c8e1f33aa05f60b7d787e73257" dependencies = [ "asap-types", "promql-parser 0.10.0 (git+https://github.com/ProjectASAP/promql-parser?rev=9fede7eecca923c9882fe256484d00d37f8706cb)", @@ -385,7 +385,7 @@ dependencies = [ [[package]] name = "asap-frontend-sql" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=851493116d42674d09cf9646dde59532003025e4#851493116d42674d09cf9646dde59532003025e4" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=2ec3fc80caa922c8e1f33aa05f60b7d787e73257#2ec3fc80caa922c8e1f33aa05f60b7d787e73257" dependencies = [ "asap-sql-function-catalog", "asap-types", @@ -396,12 +396,12 @@ dependencies = [ [[package]] name = "asap-sql-function-catalog" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=851493116d42674d09cf9646dde59532003025e4#851493116d42674d09cf9646dde59532003025e4" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=2ec3fc80caa922c8e1f33aa05f60b7d787e73257#2ec3fc80caa922c8e1f33aa05f60b7d787e73257" [[package]] name = "asap-types" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=851493116d42674d09cf9646dde59532003025e4#851493116d42674d09cf9646dde59532003025e4" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=2ec3fc80caa922c8e1f33aa05f60b7d787e73257#2ec3fc80caa922c8e1f33aa05f60b7d787e73257" dependencies = [ "serde", "serde_json", @@ -1656,7 +1656,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2273,7 +2273,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2611,7 +2611,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3229,7 +3229,7 @@ dependencies = [ "once_cell", "socket2 0.5.10", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3492,7 +3492,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3505,7 +3505,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3950,7 +3950,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4725,7 +4725,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4802,15 +4802,6 @@ dependencies = [ "windows-targets", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" diff --git a/Cargo.toml b/Cargo.toml index 98e4db58..f196cbcf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,10 +15,10 @@ version = "0.1.0" [workspace.dependencies] # Keep Planner frontends, selection, and IR on the same immutable revision. # Alias upstream asap-types because this workspace also defines asap_types. -planner-types = { package = "asap-types", git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "851493116d42674d09cf9646dde59532003025e4" } -asap-aware-mapping = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "851493116d42674d09cf9646dde59532003025e4" } -asap-frontend-promql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "851493116d42674d09cf9646dde59532003025e4" } -asap-frontend-sql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "851493116d42674d09cf9646dde59532003025e4" } +planner-types = { package = "asap-types", git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "2ec3fc80caa922c8e1f33aa05f60b7d787e73257" } +asap-aware-mapping = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "2ec3fc80caa922c8e1f33aa05f60b7d787e73257" } +asap-frontend-promql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "2ec3fc80caa922c8e1f33aa05f60b7d787e73257" } +asap-frontend-sql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "2ec3fc80caa922c8e1f33aa05f60b7d787e73257" } # Shared external deps (used by 2+ crates) serde = { version = "1.0", features = ["derive"] } diff --git a/control_plane/src/planner_selection.rs b/control_plane/src/planner_selection.rs index dfc9e578..0906c97d 100644 --- a/control_plane/src/planner_selection.rs +++ b/control_plane/src/planner_selection.rs @@ -399,7 +399,7 @@ fn select_workload_impl( let selection = space.global_selection(cost_model); if let Some(trace) = trace.as_deref_mut() { let groups = space.cost_sorted(cost_model).iter().enumerate().map(|(index, group)| { - let chosen = selection.groups().find(|selected| Rc::ptr_eq(selected.target, group.target)) + let chosen = selection.target_selections().find(|selected| Rc::ptr_eq(selected.target, group.target)) .and_then(|selected| selected.chosen); let candidates = group.candidates.iter().zip(&group.costs).enumerate() .map(|(rank, (candidate, cost))| serde_json::json!({ @@ -418,7 +418,7 @@ fn select_workload_impl( "estimated_cost_status": if cost.is_finite() { "available" } else { "not_reported_by_cost_model" }, "selected": chosen.is_some_and(|chosen| std::ptr::eq(chosen, *candidate)), })).collect::>(); - let rejected = space.groups().find(|memo| Rc::ptr_eq(&memo.target, group.target)) + let rejected = space.target_subdag_candidates().find(|memo| Rc::ptr_eq(&memo.target, group.target)) .into_iter().flat_map(|memo| &memo.rejected).map(|candidate| serde_json::json!({ "status": "rejected", "strategy": candidate.strategy, "description": candidate.description, "reason": candidate.error.to_string() @@ -434,7 +434,7 @@ fn select_workload_impl( .iter() .map(|(id, root)| { selection - .materialize(root) + .assemble_selected_dag(root) .map_err(|error| SelectionError::Workload(error.to_string()))? .map(|node| (*id, node)) .ok_or_else(|| SelectionError::Workload(format!("missing query root {id}"))) diff --git a/scripts/sync-asapplanner-main.sh b/scripts/sync-asapplanner-main.sh new file mode 100755 index 00000000..2c2a2d97 --- /dev/null +++ b/scripts/sync-asapplanner-main.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +planner_url="https://github.com/ProjectASAP/ASAPPlanner" +planner_rev="$(git ls-remote "$planner_url" refs/heads/main | awk 'NR == 1 { print $1 }')" +if [[ ! "$planner_rev" =~ ^[0-9a-f]{40}$ ]]; then + echo "could not resolve ASAPPlanner main to a commit" >&2 + exit 1 +fi + +python3 - "$planner_rev" <<'PY' +import pathlib +import re +import sys + +path = pathlib.Path("Cargo.toml") +text = path.read_text() +revision = sys.argv[1] +pattern = re.compile( + r'(git = "https://github.com/ProjectASAP/ASAPPlanner", rev = ")[0-9a-f]{40}("\s*})' +) +updated, replacements = pattern.subn(rf"\g<1>{revision}\2", text) +if replacements != 4: + raise SystemExit(f"expected four ASAPPlanner dependencies, found {replacements}") +path.write_text(updated) +PY + +cargo update \ + -p asap-types@0.1.0 \ + -p asap-aware-mapping \ + -p asap-frontend-promql \ + -p asap-frontend-sql \ + --precise "$planner_rev" + +echo "synchronized ASAPPlanner dependencies to $planner_rev" From 7435dc5e8c0f9ab376ca1d7134ecf6fb3dc942f9 Mon Sep 17 00:00:00 2001 From: zz_y Date: Tue, 22 Sep 2026 20:10:20 +0000 Subject: [PATCH 04/26] feat: cover post-ASAP query operators exhaustively --- .../src/physical/executable_binding.rs | 46 +++++++- .../asap_clickhouse_query_engine/execution.rs | 5 +- .../relational_adapter.rs | 104 +++++++++++++++++- docs/design_docs/query-dag-execution.md | 28 +++++ 4 files changed, 172 insertions(+), 11 deletions(-) diff --git a/control_plane/src/physical/executable_binding.rs b/control_plane/src/physical/executable_binding.rs index 18723595..a675742a 100644 --- a/control_plane/src/physical/executable_binding.rs +++ b/control_plane/src/physical/executable_binding.rs @@ -2,6 +2,47 @@ pub use asap_types::executable_plan::*; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum OperatorExecution { + Maintenance, + Query, +} + +/// Keep the backend's ownership decision exhaustive over Planner's physical IR. +/// Adding a payload variant upstream must therefore choose an executor here. +fn operator_execution( + node: &planner_types::post_asap::ExecutableDagNode, +) -> Result { + use planner_types::post_asap::{ExecutableOperatorPayload as Payload, ExecutionTiming}; + + let declared = match &node.payload { + Payload::Binary { timing, .. } | Payload::Value { timing, .. } => *timing, + Payload::CandidateTopK { .. } | Payload::SummaryEstimate { .. } => { + ExecutionTiming::ReadTime + } + Payload::SummaryAgg { .. } + | Payload::SummaryJoin { .. } + | Payload::SummarySubtract + | Payload::SummaryDelete { .. } + | Payload::SummaryMerge => ExecutionTiming::MaintenanceTime, + // These operators can be placed on either side of the stored-state + // boundary. Planner's validated output state is authoritative. + Payload::Fallback { .. } | Payload::RelationalJoin { .. } => node.output_state.timing, + }; + if declared != node.output_state.timing { + return Err(format!( + "post-ASAP node {:?} has operator timing {} but output state {}", + node.id, + declared.as_str(), + node.output_state + )); + } + Ok(match declared { + ExecutionTiming::MaintenanceTime => OperatorExecution::Maintenance, + ExecutionTiming::ReadTime => OperatorExecution::Query, + }) +} + /// Assign backend phases to a selected semantic DAG without changing its nodes. pub fn install_selected_dag( query_id: String, @@ -15,12 +56,11 @@ pub fn install_selected_dag( let mut nodes = std::collections::BTreeMap::new(); let mut precompute_sinks = Vec::new(); for node in &dag.nodes { + let execution = operator_execution(node)?; let binding = if let Some(summary_definition) = materialization(node.id) { precompute_sinks.push(node.id); BackendNodeBinding::Materialization { summary_definition } - } else if node.output_state.timing - == planner_types::post_asap::ExecutionTiming::MaintenanceTime - { + } else if execution == OperatorExecution::Maintenance { BackendNodeBinding::MaintenanceInput } else { query_node(node.id).map_or(BackendNodeBinding::QueryInput, |query_node| { 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 3b300fed..57e7edc4 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 @@ -159,15 +159,12 @@ impl RelationDagExecutor<'_> { if output_schema != expected_schema { return Err("join output schema differs from its parent edge".into()); } - if !matches!(join_kind, planner_types::pre_asap::JoinKind::Inner) { - return Err("only inner relational joins are executable".into()); - } let left = self.execute(inputs[0], left_schema)?; let right = self.execute(inputs[1], right_schema)?; let pred = serde_json::from_value(pred.clone()).map_err(|error| error.to_string())?; ClickHouseRelationalAdapter - .apply_inner_equi_join(&pred, output_schema, left, right) + .apply_join(join_kind, &pred, output_schema, left, right) .map_err(|error| error.to_string()) } Some(_) => { 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 c47b7b48..e42503d5 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 @@ -371,8 +371,9 @@ 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, @@ -390,12 +391,49 @@ impl ClickHouseRelationalAdapter { fields.extend(right.fields.clone()); let schema = scalar_schema(&fields); let mut rows = Vec::new(); + let mut right_matched = vec![false; right.rows.len()]; for left_row in &left.rows { - for right_row in &right.rows { + let mut left_matched = false; + for (right_index, right_row) in right.rows.iter().enumerate() { 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)) { + let matches = matches!(kind, planner_types::pre_asap::JoinKind::Cross) + || matches!(eval(&pred.0, &joined, &schema)?, Cell::Bool(true)); + if matches { + left_matched = true; + right_matched[right_index] = true; + match kind { + planner_types::pre_asap::JoinKind::Semi => { + rows.push(left_row.clone()); + break; + } + planner_types::pre_asap::JoinKind::Anti => break, + _ => rows.push(joined), + } + } + } + if !left_matched { + match kind { + planner_types::pre_asap::JoinKind::Left + | planner_types::pre_asap::JoinKind::Full => { + let mut joined = left_row.clone(); + joined.resize(left_row.len() + right.fields.len(), Cell::Null); + rows.push(joined); + } + planner_types::pre_asap::JoinKind::Anti => rows.push(left_row.clone()), + _ => {} + } + } + } + if matches!( + kind, + planner_types::pre_asap::JoinKind::Right | planner_types::pre_asap::JoinKind::Full + ) { + for (matched, right_row) in right_matched.into_iter().zip(&right.rows) { + if !matched { + let mut joined = vec![Cell::Null; left.fields.len()]; + joined.extend(right_row.iter().cloned()); rows.push(joined); } } @@ -1672,7 +1710,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 @@ -1712,4 +1756,56 @@ 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 joined_schema = schema(&[("left", DataType::Int64), ("right", DataType::Int64)]); + 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/docs/design_docs/query-dag-execution.md b/docs/design_docs/query-dag-execution.md index 2806a00c..20cf814a 100644 --- a/docs/design_docs/query-dag-execution.md +++ b/docs/design_docs/query-dag-execution.md @@ -54,6 +54,34 @@ All adapters start from a `QueryPlanEntry` node. The language adapters only represent different runtime value types. They cannot select a replacement definition or reconstruct an operator from the request text. +## Planner physical-operator coverage + +The backend pins one ASAPPlanner `main` commit and treats its exported +`ExecutableOperatorPayload` enum as the exhaustive physical-operator contract. +Execution follows the `ExecutionDataState` assigned by Planner; a query engine +must not replay a maintenance operator while serving a request. + +| Planner payload | Planner phase | Backend execution | +| --- | --- | --- | +| `Fallback` | Maintenance or query, from its validated edge state | Precompute input adapter, prepared `ExternalExact` leaf, or the entry's explicit whole-query fallback policy | +| `Binary` | Maintenance or query, from `timing` | Maintenance runtime for `MaintenanceTime`; scalar/vector query operator for `ReadTime` | +| `CandidateTopK` | Query | Candidate membership plus authoritative exact values, followed by grouped reranking | +| `Value` | Maintenance or query, from `timing` | Maintenance population/update adapter, or query adapters for population readout, exact aggregate/finalization, projection, filter, sort and limit | +| `RelationalJoin` | Maintenance or query rows, from its validated edge state | Precompute row adapter or ClickHouse relation adapter for inner, left, right, full, cross, semi and anti joins | +| `SummaryAgg` | Maintenance | Precompute DAG operator ending at a stored-output boundary | +| `SummaryJoin` | Maintenance | Precompute DAG operator | +| `SummarySubtract` | Maintenance | Precompute DAG operator | +| `SummaryDelete` | Maintenance | Precompute DAG operator | +| `SummaryEstimate` | Query | Bound sketch readout | +| `SummaryMerge` | Maintenance state | Precompute DAG operator; the QueryPlan state-merge node remains a physical read adapter for previously stored panes | + +The compiler either binds every query-phase node to a `QueryPlanNode`, absorbs +an explicit boundary such as exact-accumulator finalization into its typed +readout, or emits an exact node with a declared fallback policy. Unknown +extensions and invalid phase crossings fail during compilation or installation. +The match sites and coverage tests are exhaustive so a new Planner enum variant +causes a backend compile failure until its phase and runtime adapter are chosen. + ## StoredSummary reads A `ReadMaterialization` carries the exact `StoredOutputReference` published for From aca434996bd4c3b907a1ac5e49809f49adc983ef Mon Sep 17 00:00:00 2001 From: zz_y Date: Tue, 22 Sep 2026 20:12:12 +0000 Subject: [PATCH 05/26] fix: leave Planner evidence upgrade to stacked PR --- Cargo.lock | 35 ++++++++++++++++---------- Cargo.toml | 8 +++--- control_plane/src/planner_selection.rs | 6 ++--- 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 334e5461..1d49df2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -364,7 +364,7 @@ dependencies = [ [[package]] name = "asap-aware-mapping" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=2ec3fc80caa922c8e1f33aa05f60b7d787e73257#2ec3fc80caa922c8e1f33aa05f60b7d787e73257" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=851493116d42674d09cf9646dde59532003025e4#851493116d42674d09cf9646dde59532003025e4" dependencies = [ "asap-types", "asap_sketchlib 0.3.0 (git+https://github.com/ProjectASAP/asap_sketchlib)", @@ -376,7 +376,7 @@ dependencies = [ [[package]] name = "asap-frontend-promql" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=2ec3fc80caa922c8e1f33aa05f60b7d787e73257#2ec3fc80caa922c8e1f33aa05f60b7d787e73257" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=851493116d42674d09cf9646dde59532003025e4#851493116d42674d09cf9646dde59532003025e4" dependencies = [ "asap-types", "promql-parser 0.10.0 (git+https://github.com/ProjectASAP/promql-parser?rev=9fede7eecca923c9882fe256484d00d37f8706cb)", @@ -385,7 +385,7 @@ dependencies = [ [[package]] name = "asap-frontend-sql" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=2ec3fc80caa922c8e1f33aa05f60b7d787e73257#2ec3fc80caa922c8e1f33aa05f60b7d787e73257" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=851493116d42674d09cf9646dde59532003025e4#851493116d42674d09cf9646dde59532003025e4" dependencies = [ "asap-sql-function-catalog", "asap-types", @@ -396,12 +396,12 @@ dependencies = [ [[package]] name = "asap-sql-function-catalog" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=2ec3fc80caa922c8e1f33aa05f60b7d787e73257#2ec3fc80caa922c8e1f33aa05f60b7d787e73257" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=851493116d42674d09cf9646dde59532003025e4#851493116d42674d09cf9646dde59532003025e4" [[package]] name = "asap-types" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=2ec3fc80caa922c8e1f33aa05f60b7d787e73257#2ec3fc80caa922c8e1f33aa05f60b7d787e73257" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=851493116d42674d09cf9646dde59532003025e4#851493116d42674d09cf9646dde59532003025e4" dependencies = [ "serde", "serde_json", @@ -1656,7 +1656,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2273,7 +2273,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2611,7 +2611,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3229,7 +3229,7 @@ dependencies = [ "once_cell", "socket2 0.5.10", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3492,7 +3492,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3505,7 +3505,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3950,7 +3950,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4725,7 +4725,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4802,6 +4802,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" diff --git a/Cargo.toml b/Cargo.toml index f196cbcf..98e4db58 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,10 +15,10 @@ version = "0.1.0" [workspace.dependencies] # Keep Planner frontends, selection, and IR on the same immutable revision. # Alias upstream asap-types because this workspace also defines asap_types. -planner-types = { package = "asap-types", git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "2ec3fc80caa922c8e1f33aa05f60b7d787e73257" } -asap-aware-mapping = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "2ec3fc80caa922c8e1f33aa05f60b7d787e73257" } -asap-frontend-promql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "2ec3fc80caa922c8e1f33aa05f60b7d787e73257" } -asap-frontend-sql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "2ec3fc80caa922c8e1f33aa05f60b7d787e73257" } +planner-types = { package = "asap-types", git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "851493116d42674d09cf9646dde59532003025e4" } +asap-aware-mapping = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "851493116d42674d09cf9646dde59532003025e4" } +asap-frontend-promql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "851493116d42674d09cf9646dde59532003025e4" } +asap-frontend-sql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "851493116d42674d09cf9646dde59532003025e4" } # Shared external deps (used by 2+ crates) serde = { version = "1.0", features = ["derive"] } diff --git a/control_plane/src/planner_selection.rs b/control_plane/src/planner_selection.rs index 0906c97d..dfc9e578 100644 --- a/control_plane/src/planner_selection.rs +++ b/control_plane/src/planner_selection.rs @@ -399,7 +399,7 @@ fn select_workload_impl( let selection = space.global_selection(cost_model); if let Some(trace) = trace.as_deref_mut() { let groups = space.cost_sorted(cost_model).iter().enumerate().map(|(index, group)| { - let chosen = selection.target_selections().find(|selected| Rc::ptr_eq(selected.target, group.target)) + let chosen = selection.groups().find(|selected| Rc::ptr_eq(selected.target, group.target)) .and_then(|selected| selected.chosen); let candidates = group.candidates.iter().zip(&group.costs).enumerate() .map(|(rank, (candidate, cost))| serde_json::json!({ @@ -418,7 +418,7 @@ fn select_workload_impl( "estimated_cost_status": if cost.is_finite() { "available" } else { "not_reported_by_cost_model" }, "selected": chosen.is_some_and(|chosen| std::ptr::eq(chosen, *candidate)), })).collect::>(); - let rejected = space.target_subdag_candidates().find(|memo| Rc::ptr_eq(&memo.target, group.target)) + let rejected = space.groups().find(|memo| Rc::ptr_eq(&memo.target, group.target)) .into_iter().flat_map(|memo| &memo.rejected).map(|candidate| serde_json::json!({ "status": "rejected", "strategy": candidate.strategy, "description": candidate.description, "reason": candidate.error.to_string() @@ -434,7 +434,7 @@ fn select_workload_impl( .iter() .map(|(id, root)| { selection - .assemble_selected_dag(root) + .materialize(root) .map_err(|error| SelectionError::Workload(error.to_string()))? .map(|node| (*id, node)) .ok_or_else(|| SelectionError::Workload(format!("missing query root {id}"))) From e1fa604e2ee03e31082cdd38cd99750e8462f7a0 Mon Sep 17 00:00:00 2001 From: zz_y Date: Tue, 22 Sep 2026 20:26:18 +0000 Subject: [PATCH 06/26] fix: preserve incompatible Planner sync PRs --- .github/workflows/sync-asapplanner-main.yml | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/.github/workflows/sync-asapplanner-main.yml b/.github/workflows/sync-asapplanner-main.yml index cfadf644..29ffd235 100644 --- a/.github/workflows/sync-asapplanner-main.yml +++ b/.github/workflows/sync-asapplanner-main.yml @@ -21,9 +21,6 @@ jobs: - name: Checkout backend uses: actions/checkout@v4 - - name: Install Rust components - run: rustup component add rustfmt - - name: Configure private Cargo dependencies env: ASAP_CI_REPO_TOKEN: ${{ secrets.ASAP_CI_REPO_TOKEN }} @@ -34,13 +31,6 @@ jobs: CARGO_NET_GIT_FETCH_WITH_CLI: "true" run: ./scripts/sync-asapplanner-main.sh - - name: Check synchronized workspace - env: - CARGO_NET_GIT_FETCH_WITH_CLI: "true" - run: | - cargo fmt -p control_plane -p data_plane -p asap_types -p asap_otel_proto -- --check - cargo check --workspace - - name: Create or update synchronization PR uses: peter-evans/create-pull-request@v7 with: @@ -52,4 +42,4 @@ jobs: body: | Updates all ASAPPlanner workspace dependencies to the current `main` commit and regenerates `Cargo.lock`. - The synchronization workflow verified formatting and `cargo check --workspace` before opening this PR. + MVP CI validates formatting, compilation, linting and tests. A semantic Planner change stays visible as a failing synchronization PR until the backend adaptation is added. From 1edde799727529f45137b5845179718a0a471bc7 Mon Sep 17 00:00:00 2001 From: zz_y Date: Wed, 23 Sep 2026 12:39:26 +0000 Subject: [PATCH 07/26] refactor: share physical operator kernels across ASAP deployments --- Cargo.lock | 24 ++ Cargo.toml | 2 + control_plane/Cargo.toml | 1 + .../src/physical/executable_binding.rs | 10 + crates/asap-physical-operators/Cargo.toml | 28 ++ crates/asap-physical-operators/README.md | 45 +++ .../count_min_sketch_accumulator.rs | 18 +- .../count_min_sketch_with_heap_accumulator.rs | 4 +- .../accumulators}/count_sketch_accumulator.rs | 24 +- .../count_sketch_with_heap_accumulator.rs | 6 +- .../datasketches_kll_accumulator.rs | 16 +- .../accumulators}/dd_sketch_accumulator.rs | 14 +- .../src/accumulators}/exact_accumulator.rs | 21 +- .../accumulators}/hll_sketch_accumulator.rs | 12 +- .../accumulators}/hydra_kll_accumulator.rs | 9 +- .../src/accumulators}/increase_accumulator.rs | 4 +- .../src/accumulators}/keyed_counter_state.rs | 8 +- .../src/accumulators}/keyed_max_state.rs | 4 +- .../src/accumulators}/keyed_min_state.rs | 4 +- .../keyed_sum_count_accumulator.rs | 4 +- .../src/accumulators}/max_accumulator.rs | 4 +- .../src/accumulators}/min_accumulator.rs | 4 +- .../src/accumulators}/mod.rs | 0 .../sketch_envelope_accumulator.rs | 8 +- .../src/accumulators}/sum_accumulator.rs | 4 +- .../src/accumulators}/univmon_accumulator.rs | 4 +- .../src}/arithmetic.rs | 4 +- .../asap-physical-operators/src/capability.rs | 113 +++++++ .../asap-physical-operators/src/factory.rs | 24 +- .../src}/key_by_label_values.rs | 0 crates/asap-physical-operators/src/lib.rs | 19 ++ .../src}/measurement.rs | 0 .../asap-physical-operators/src/query_dag.rs | 3 +- .../asap-physical-operators/src}/traits.rs | 2 +- .../tests/deployment.rs | 65 +++++ data_plane/Cargo.toml | 3 +- data_plane/benches/sketch_db.rs | 2 +- data_plane/examples/univmon_erp_artifact.rs | 4 +- data_plane/src/drivers/ingest/otel.rs | 24 +- data_plane/src/lib.rs | 2 +- .../src/precompute_engine/ingest_handler.rs | 4 +- .../precompute_engine/maintenance_runtime.rs | 11 +- data_plane/src/precompute_engine/mod.rs | 2 - .../src/precompute_engine/output_sink.rs | 2 +- data_plane/src/precompute_engine/raw_dag.rs | 2 +- data_plane/src/precompute_engine/worker.rs | 22 +- .../accelerator.rs | 6 +- .../query_engines/asap_query_engine/engine.rs | 8 +- .../asap_query_engine/exact_subqueries.rs | 2 +- .../asap_query_engine/live_serve.rs | 2 +- .../asap_query_engine/logical_dag.rs | 2 +- .../query_engines/asap_query_engine/mod.rs | 1 - .../asap_query_engine/post_asap_readout.rs | 20 +- .../asap_query_engine/summary_executor.rs | 22 +- .../sketch_db/backfill/processor.rs | 4 +- .../sketch_db/backfill/window_builder.rs | 10 +- .../sketch_db/index/maintenance.rs | 4 +- .../storage_engines/sketch_db/index/mod.rs | 26 +- .../sketch_db/lifecycle/eviction.rs | 2 +- .../sketch_db/query/decoders.rs | 2 +- .../sketch_db/query/delta_apply.rs | 20 +- data_plane/src/storage_engines/types/mod.rs | 8 +- data_plane/src/tests/accumulator_fixture.rs | 276 ++++++++++++++++++ data_plane/src/tests/mod.rs | 2 + data_plane/src/tests/trait_design_tests.rs | 2 +- data_plane/src/utils/mod.rs | 1 - data_plane/tests/edge_sketch_codec.rs | 4 +- .../tests/support/univmon_erp_process.rs | 2 +- docs/design_docs/query-dag-execution.md | 29 ++ 69 files changed, 814 insertions(+), 231 deletions(-) create mode 100644 crates/asap-physical-operators/Cargo.toml create mode 100644 crates/asap-physical-operators/README.md rename {data_plane/src/precompute_engine/operators => crates/asap-physical-operators/src/accumulators}/count_min_sketch_accumulator.rs (98%) rename {data_plane/src/precompute_engine/operators => crates/asap-physical-operators/src/accumulators}/count_min_sketch_with_heap_accumulator.rs (99%) rename {data_plane/src/precompute_engine/operators => crates/asap-physical-operators/src/accumulators}/count_sketch_accumulator.rs (95%) rename {data_plane/src/precompute_engine/operators => crates/asap-physical-operators/src/accumulators}/count_sketch_with_heap_accumulator.rs (98%) rename {data_plane/src/precompute_engine/operators => crates/asap-physical-operators/src/accumulators}/datasketches_kll_accumulator.rs (98%) rename {data_plane/src/precompute_engine/operators => crates/asap-physical-operators/src/accumulators}/dd_sketch_accumulator.rs (97%) rename {data_plane/src/precompute_engine/operators => crates/asap-physical-operators/src/accumulators}/exact_accumulator.rs (96%) rename {data_plane/src/precompute_engine/operators => crates/asap-physical-operators/src/accumulators}/hll_sketch_accumulator.rs (98%) rename {data_plane/src/precompute_engine/operators => crates/asap-physical-operators/src/accumulators}/hydra_kll_accumulator.rs (95%) rename {data_plane/src/precompute_engine/operators => crates/asap-physical-operators/src/accumulators}/increase_accumulator.rs (99%) rename {data_plane/src/precompute_engine/operators => crates/asap-physical-operators/src/accumulators}/keyed_counter_state.rs (98%) rename {data_plane/src/precompute_engine/operators => crates/asap-physical-operators/src/accumulators}/keyed_max_state.rs (98%) rename {data_plane/src/precompute_engine/operators => crates/asap-physical-operators/src/accumulators}/keyed_min_state.rs (98%) rename {data_plane/src/precompute_engine/operators => crates/asap-physical-operators/src/accumulators}/keyed_sum_count_accumulator.rs (99%) rename {data_plane/src/precompute_engine/operators => crates/asap-physical-operators/src/accumulators}/max_accumulator.rs (98%) rename {data_plane/src/precompute_engine/operators => crates/asap-physical-operators/src/accumulators}/min_accumulator.rs (98%) rename {data_plane/src/precompute_engine/operators => crates/asap-physical-operators/src/accumulators}/mod.rs (100%) rename {data_plane/src/precompute_engine/operators => crates/asap-physical-operators/src/accumulators}/sketch_envelope_accumulator.rs (94%) rename {data_plane/src/precompute_engine/operators => crates/asap-physical-operators/src/accumulators}/sum_accumulator.rs (99%) rename {data_plane/src/precompute_engine/operators => crates/asap-physical-operators/src/accumulators}/univmon_accumulator.rs (98%) rename {data_plane/src/utils => crates/asap-physical-operators/src}/arithmetic.rs (81%) create mode 100644 crates/asap-physical-operators/src/capability.rs rename data_plane/src/precompute_engine/accumulator_factory.rs => crates/asap-physical-operators/src/factory.rs (98%) rename {data_plane/src/storage_engines/types => crates/asap-physical-operators/src}/key_by_label_values.rs (100%) create mode 100644 crates/asap-physical-operators/src/lib.rs rename {data_plane/src/storage_engines/types => crates/asap-physical-operators/src}/measurement.rs (100%) rename data_plane/src/query_engines/asap_query_engine/physical_dag.rs => crates/asap-physical-operators/src/query_dag.rs (99%) rename {data_plane/src/storage_engines/types => crates/asap-physical-operators/src}/traits.rs (99%) create mode 100644 crates/asap-physical-operators/tests/deployment.rs create mode 100644 data_plane/src/tests/accumulator_fixture.rs diff --git a/Cargo.lock b/Cargo.lock index d4dfd33f..2ad92579 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -393,6 +393,28 @@ dependencies = [ "serde_json", ] +[[package]] +name = "asap-physical-operators" +version = "0.1.0" +dependencies = [ + "asap-types", + "asap_sketch_codec", + "asap_sketchlib 0.3.0 (git+https://github.com/ProjectASAP/asap_sketchlib?branch=main)", + "asap_types", + "async-trait", + "base64 0.21.7", + "bincode", + "hex", + "prost", + "rmp-serde", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tracing", + "xxhash-rust", +] + [[package]] name = "asap-sql-function-catalog" version = "0.1.0" @@ -945,6 +967,7 @@ dependencies = [ "asap-aware-mapping", "asap-frontend-promql", "asap-frontend-sql", + "asap-physical-operators", "asap-types", "asap_types", "axum", @@ -1155,6 +1178,7 @@ dependencies = [ "arrow", "asap-aware-mapping", "asap-frontend-promql", + "asap-physical-operators", "asap-types", "asap_otel_proto", "asap_sketch_codec", diff --git a/Cargo.toml b/Cargo.toml index 5bce5921..c8bfac1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/asap_otel_proto", "crates/asap_types", "crates/asap_sketch_codec", + "crates/asap-physical-operators", "data_plane", "control_plane", ] @@ -38,6 +39,7 @@ arc-swap = "1.7" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } # Internal crates +asap-physical-operators = { path = "crates/asap-physical-operators" } asap_types = { path = "crates/asap_types" } asap_otel_proto = { path = "crates/asap_otel_proto" } indexmap = { version = "2.0", features = ["serde"] } diff --git a/control_plane/Cargo.toml b/control_plane/Cargo.toml index ac83339b..510d3aa9 100644 --- a/control_plane/Cargo.toml +++ b/control_plane/Cargo.toml @@ -12,6 +12,7 @@ name = "control_plane" path = "src/main.rs" [dependencies] +asap-physical-operators.workspace = true tokio = { version = "1", features = ["full"] } axum = { version = "0.7", features = ["ws"] } futures-util = "0.3" diff --git a/control_plane/src/physical/executable_binding.rs b/control_plane/src/physical/executable_binding.rs index a675742a..8cbfcf8d 100644 --- a/control_plane/src/physical/executable_binding.rs +++ b/control_plane/src/physical/executable_binding.rs @@ -56,6 +56,16 @@ pub fn install_selected_dag( let mut nodes = std::collections::BTreeMap::new(); let mut precompute_sinks = Vec::new(); for node in &dag.nodes { + if let planner_types::post_asap::ExecutableOperatorPayload::SummaryAgg { + family, + input, + grouping, + .. + } = &node.payload + { + asap_physical_operators::capability::validate_summary_kernel(family, input, grouping) + .map_err(|reason| format!("post-ASAP node {:?}: {reason}", node.id))?; + } let execution = operator_execution(node)?; let binding = if let Some(summary_definition) = materialization(node.id) { precompute_sinks.push(node.id); diff --git a/crates/asap-physical-operators/Cargo.toml b/crates/asap-physical-operators/Cargo.toml new file mode 100644 index 00000000..503db515 --- /dev/null +++ b/crates/asap-physical-operators/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "asap-physical-operators" +version.workspace = true +edition.workspace = true + +[dependencies] +asap_types.workspace = true +planner-types.workspace = true +asap_sketch_codec = { path = "../asap_sketch_codec" } +asap_sketchlib = { git = "https://github.com/ProjectASAP/asap_sketchlib", branch = "main" } +serde.workspace = true +serde_json.workspace = true +tracing.workspace = true +thiserror.workspace = true +async-trait = "0.1" +base64 = "0.21" +bincode = "1.3" +rmp-serde = "1.3" +prost = "0.13" +xxhash-rust = { version = "0.8", features = ["xxh32", "xxh64"] } + +[features] +default = [] +extra_debugging = [] + +[dev-dependencies] +tokio.workspace = true +hex = "0.4" diff --git a/crates/asap-physical-operators/README.md b/crates/asap-physical-operators/README.md new file mode 100644 index 00000000..c913833a --- /dev/null +++ b/crates/asap-physical-operators/README.md @@ -0,0 +1,45 @@ +# ASAP physical operators + +Shared Rust kernels for maintenance-time and query-time execution. Used by +ASAPQuery-backend; other deployments can depend on the `asap-physical-operators` +package in this Git repository. No backend server, storage implementation, +background worker, Arrow or DataFusion version is required. + +The public `planner` export names the exact Planner types used by the library. +`capability::validate_summary_kernel` checks family, parameters, update layout +and grouping without allocating state. `factory::create_planner_accumulator` +constructs that same kernel. A compiler should validate before accepting the +operator; a caller then supplies evaluated updates, merges compatible states, +and invokes the state's typed readout. Neither operation requires persistence. + +```rust +use asap_physical_operators::{factory::create_planner_accumulator, Statistic}; +use asap_physical_operators::planner::{ + post_asap::{ExactKind, ExactParams, SummaryFamilyType, SummaryUpdate}, + pre_asap::ColumnRef, +}; + +let family = SummaryFamilyType::ExactAggregate(ExactKind::Sum, ExactParams::Sum); +let mut operator = create_planner_accumulator( + &family, + &SummaryUpdate::column(ColumnRef::SampleValue), + &Default::default(), +).unwrap(); +operator.validate_single_input(3.0).unwrap(); +operator.update_single(3.0, 1000); +let state = operator.into_accumulator(); +assert_eq!(state.query_statistic(Statistic::Sum, &None, &Default::default()).unwrap(), 3.0); +``` + +The crate contains exact and sketch accumulators, common state traits, the +Planner-family factory, Float64 arithmetic and synchronous/asynchronous +QueryPlan DAG traversal. Unsupported kernel families are errors. It does not +infer new plans, choose a fallback engine, promise arbitrary SQL/PromQL support, +or change the execution phase encoded by Planner. Kernel availability does not +certify an accuracy guarantee; Planner and the deployment must still validate +the requested accuracy and evidence scope. + +Deployment adapters supply storage, source rows, time/population scope, expression +evaluation, I/O and output representation. Planner's current maintenance-only +summary placement and the backend's missing local raw Scan remain separate +integration limitations; exporting these kernels does not silently bypass them. diff --git a/data_plane/src/precompute_engine/operators/count_min_sketch_accumulator.rs b/crates/asap-physical-operators/src/accumulators/count_min_sketch_accumulator.rs similarity index 98% rename from data_plane/src/precompute_engine/operators/count_min_sketch_accumulator.rs rename to crates/asap-physical-operators/src/accumulators/count_min_sketch_accumulator.rs index b3307fdf..a1fc7e69 100644 --- a/data_plane/src/precompute_engine/operators/count_min_sketch_accumulator.rs +++ b/crates/asap-physical-operators/src/accumulators/count_min_sketch_accumulator.rs @@ -1,5 +1,5 @@ -use crate::precompute_engine::operators::dd_sketch_accumulator::normalize_sample_p; -use crate::storage_engines::types::{ +use crate::accumulators::dd_sketch_accumulator::normalize_sample_p; +use crate::{ AggregateCore, AggregationType, KeyByLabelValues, MergeableAccumulator, MultipleSubpopulationAggregate, SerializableToSink, }; @@ -213,7 +213,7 @@ impl CountMinSketchAccumulator { &mut self, buffer: &[u8], ) -> Result<(), Box> { - use asap_otel_proto::sketchlib::v1::CountMinDelta as PbDelta; + use asap_sketchlib::proto::sketchlib::CountMinDelta as PbDelta; use prost::Message; let pb = PbDelta::decode(buffer).map_err(|e| format!("decode CountMinDelta: {e}"))?; @@ -300,7 +300,7 @@ impl CountMinSketchAccumulator { /// Merge multiple accumulators efficiently without cloning all of them. pub fn merge_multiple( - accumulators: &[Box], + accumulators: &[Box], ) -> Result> { if accumulators.is_empty() { return Err("No accumulators to merge".into()); @@ -379,8 +379,7 @@ pub(crate) const MAX_SKETCH_CELLS: usize = 8 * 1024 * 1024; /// slices overflow / alias the 64-bit word and the matrix-cell /// layout is no longer the one the producer hashed into — the sketch /// is internally degenerate. This mirrors sketchlib's own -/// `MatrixFastHash::assert_compatible` budget (`rows * (mask_bits + -/// 1) <= 64`); we check the column-index bits alone so realistic +/// `MatrixFastHash::assert_compatible` budget (`rows * (mask_bits + 1) <= 64`); we check the column-index bits alone so realistic /// configs (5x2048, 5x4096, 5x2000) — for which the sign bits share /// the top of the word without affecting the cell layout — still /// pass. @@ -515,7 +514,7 @@ impl AggregateCore for CountMinSketchAccumulator { key: &Option, query_kwargs: &std::collections::HashMap, ) -> Result> { - use crate::storage_engines::types::MultipleSubpopulationAggregate; + use crate::MultipleSubpopulationAggregate; use asap_types::Statistic; // Key-provided path: route to MultipleSubpopulationAggregate::query @@ -811,7 +810,7 @@ mod tests { let boxed_accs: Vec> = vec![Box::new(cms1), Box::new(cms2)]; assert!(CountMinSketchAccumulator::merge_multiple(&boxed_accs).is_err()); - use crate::precompute_engine::operators::sum_accumulator::SumAccumulator; + use crate::accumulators::sum_accumulator::SumAccumulator; let cms = CountMinSketchAccumulator::new(2, 3); let sum = SumAccumulator::new(); let mixed_accs: Vec> = vec![Box::new(cms), Box::new(sum)]; @@ -971,7 +970,7 @@ mod tests { #[test] fn test_apply_proto_delta_bytes_round_trip() { - use asap_otel_proto::sketchlib::v1::CountMinDelta as PbDelta; + use asap_sketchlib::proto::sketchlib::CountMinDelta as PbDelta; use prost::Message; let mut acc = CountMinSketchAccumulator { @@ -990,6 +989,7 @@ mod tests { d_counts: vec![10, 100], l1: vec![], l2: vec![], + ..Default::default() } .encode_to_vec(); diff --git a/data_plane/src/precompute_engine/operators/count_min_sketch_with_heap_accumulator.rs b/crates/asap-physical-operators/src/accumulators/count_min_sketch_with_heap_accumulator.rs similarity index 99% rename from data_plane/src/precompute_engine/operators/count_min_sketch_with_heap_accumulator.rs rename to crates/asap-physical-operators/src/accumulators/count_min_sketch_with_heap_accumulator.rs index 3eea0afd..259ab9d2 100644 --- a/data_plane/src/precompute_engine/operators/count_min_sketch_with_heap_accumulator.rs +++ b/crates/asap-physical-operators/src/accumulators/count_min_sketch_with_heap_accumulator.rs @@ -1,4 +1,4 @@ -use crate::storage_engines::types::{ +use crate::{ AggregateCore, AggregationType, KeyByLabelValues, MergeableAccumulator, MultipleSubpopulationAggregate, SerializableToSink, }; @@ -390,7 +390,7 @@ impl AggregateCore for CountMinSketchWithHeapAccumulator { key: &Option, query_kwargs: &std::collections::HashMap, ) -> Result> { - use crate::storage_engines::types::MultipleSubpopulationAggregate; + use crate::MultipleSubpopulationAggregate; let key_val = key .as_ref() .ok_or("Key required for CountMinSketchWithHeapAccumulator")?; diff --git a/data_plane/src/precompute_engine/operators/count_sketch_accumulator.rs b/crates/asap-physical-operators/src/accumulators/count_sketch_accumulator.rs similarity index 95% rename from data_plane/src/precompute_engine/operators/count_sketch_accumulator.rs rename to crates/asap-physical-operators/src/accumulators/count_sketch_accumulator.rs index 9a353a33..c12eda0c 100644 --- a/data_plane/src/precompute_engine/operators/count_sketch_accumulator.rs +++ b/crates/asap-physical-operators/src/accumulators/count_sketch_accumulator.rs @@ -5,7 +5,7 @@ //! estimator so query and ingest use the same hash specification. Top-k //! requires the separate heap-bearing accumulator. -use crate::storage_engines::types::{ +use crate::{ AggregateCore, AggregationType, KeyByLabelValues, MergeableAccumulator, MultipleSubpopulationAggregate, SerializableToSink, }; @@ -96,7 +96,7 @@ impl CountSketchAccumulator { // ingest caller skips the data point) instead of building a // degenerate or huge matrix. Shares the CMS validator since the // CountSketch matrix uses the same packed-hash column layout. - crate::precompute_engine::operators::count_min_sketch_accumulator::validate_sketch_dims( + crate::accumulators::count_min_sketch_accumulator::validate_sketch_dims( "CountSketchState", rows, cols, @@ -161,7 +161,7 @@ impl CountSketchAccumulator { &mut self, buffer: &[u8], ) -> Result<(), Box> { - use asap_otel_proto::sketchlib::v1::CountSketchDelta as PbDelta; + use asap_sketchlib::proto::sketchlib::CountSketchDelta as PbDelta; use prost::Message; let pb = PbDelta::decode(buffer).map_err(|e| format!("decode CountSketchDelta: {e}"))?; @@ -183,17 +183,8 @@ impl CountSketchAccumulator { .zip(pb.d_counts.iter()) .map(|((r, c), dc)| (*r, *c, *dc)) .collect(); - // Proto-schema-divergence-tracker: the Go-side - // `CountSketchDelta` proto carries an `hh_keys` field - // (heavy-hitter candidate keys forwarded by the upstream - // Space-Saving tracker). The Rust wire-format struct now - // models it (`asap_sketchlib::CountSketchDelta::hh_keys`), - // but the vendored Rust proto bindings in - // `asap_otel_proto::sketchlib::v1` haven't been regenerated - // against the latest `.proto` yet, so no `hh_keys` arrive on - // the wire from Go producers. Sending an empty `hh_keys` - // disables the TopK rebuild path; it'll start firing once the - // proto-schema sync PR lands. + // This is the heap-less matrix kernel; ranked membership is handled + // by the explicit heap-bearing operator, not inferred from delta keys. let delta = CountSketchDelta { rows: pb.rows, cols: pb.cols, @@ -563,7 +554,7 @@ mod tests { #[test] fn test_aggregate_core_merge_wrong_type_rejects() { - use crate::precompute_engine::operators::count_min_sketch_accumulator::CountMinSketchAccumulator; + use crate::accumulators::count_min_sketch_accumulator::CountMinSketchAccumulator; let cs = CountSketchAccumulator::new(2, 3); let cms = CountMinSketchAccumulator::new(2, 3); let result = cs.merge_with(&cms); @@ -592,7 +583,7 @@ mod tests { #[test] fn test_apply_proto_delta_bytes_round_trip() { - use asap_otel_proto::sketchlib::v1::CountSketchDelta as PbDelta; + use asap_sketchlib::proto::sketchlib::CountSketchDelta as PbDelta; use prost::Message; let mut acc = CountSketchAccumulator { @@ -609,6 +600,7 @@ mod tests { cell_cols: vec![0, 2], d_counts: vec![10, -6], l2: vec![], + ..Default::default() } .encode_to_vec(); diff --git a/data_plane/src/precompute_engine/operators/count_sketch_with_heap_accumulator.rs b/crates/asap-physical-operators/src/accumulators/count_sketch_with_heap_accumulator.rs similarity index 98% rename from data_plane/src/precompute_engine/operators/count_sketch_with_heap_accumulator.rs rename to crates/asap-physical-operators/src/accumulators/count_sketch_with_heap_accumulator.rs index 7c8de44a..6b8c1b24 100644 --- a/data_plane/src/precompute_engine/operators/count_sketch_with_heap_accumulator.rs +++ b/crates/asap-physical-operators/src/accumulators/count_sketch_with_heap_accumulator.rs @@ -12,7 +12,7 @@ //! for `SketchAlgorithm::CountSketchWithHeap` sids -- the same conflation bug //! already fixed on the read side, now closed on the write side too. -use crate::storage_engines::types::{ +use crate::{ AggregateCore, AggregationType, KeyByLabelValues, MergeableAccumulator, MultipleSubpopulationAggregate, SerializableToSink, }; @@ -285,7 +285,7 @@ impl AggregateCore for CountSketchWithHeapAccumulator { key: &Option, query_kwargs: &std::collections::HashMap, ) -> Result> { - use crate::storage_engines::types::MultipleSubpopulationAggregate; + use crate::MultipleSubpopulationAggregate; let key_val = key .as_ref() .ok_or("Key required for CountSketchWithHeapAccumulator")?; @@ -561,7 +561,7 @@ mod tests { /// min-over-rows divergence at the sketch-math level). #[test] fn test_rejects_merge_with_cms_family_accumulator() { - use crate::precompute_engine::operators::count_min_sketch_with_heap_accumulator::CountMinSketchWithHeapAccumulator; + use crate::accumulators::count_min_sketch_with_heap_accumulator::CountMinSketchWithHeapAccumulator; let cs = CountSketchWithHeapAccumulator::new(4, 64, 10); let cms = CountMinSketchWithHeapAccumulator::new(4, 64, 10); diff --git a/data_plane/src/precompute_engine/operators/datasketches_kll_accumulator.rs b/crates/asap-physical-operators/src/accumulators/datasketches_kll_accumulator.rs similarity index 98% rename from data_plane/src/precompute_engine/operators/datasketches_kll_accumulator.rs rename to crates/asap-physical-operators/src/accumulators/datasketches_kll_accumulator.rs index 2874f510..7a29e84b 100644 --- a/data_plane/src/precompute_engine/operators/datasketches_kll_accumulator.rs +++ b/crates/asap-physical-operators/src/accumulators/datasketches_kll_accumulator.rs @@ -1,4 +1,4 @@ -use crate::storage_engines::types::{ +use crate::{ AggregateCore, AggregationType, AuxStats, MergeableAccumulator, SerializableToSink, SingleSubpopulationAggregate, }; @@ -139,7 +139,7 @@ impl DatasketchesKLLAccumulator { /// Merge multiple accumulators efficiently without cloning all of them. pub fn merge_multiple( - accumulators: &[Box], + accumulators: &[Box], ) -> Result> { if accumulators.is_empty() { return Err("No accumulators to merge".into()); @@ -299,7 +299,7 @@ impl AggregateCore for DatasketchesKLLAccumulator { _key: &Option, query_kwargs: &std::collections::HashMap, ) -> Result> { - use crate::storage_engines::types::SingleSubpopulationAggregate; + use crate::SingleSubpopulationAggregate; self.query(statistic, Some(query_kwargs)) } } @@ -359,10 +359,10 @@ impl MergeableAccumulator for DatasketchesKLLAccumul #[cfg(test)] mod tests { use super::*; + use prost::Message; fn encode_state(state: asap_sketchlib::proto::sketchlib::KllState) -> Vec { use asap_sketchlib::proto::sketchlib::{sketch_envelope, SketchEnvelope}; - use prost::Message; SketchEnvelope { sketch_state: Some(sketch_envelope::SketchState::Kll(state)), ..Default::default() @@ -537,7 +537,7 @@ mod tests { let boxed_accs: Vec> = vec![Box::new(kll1), Box::new(kll2)]; assert!(DatasketchesKLLAccumulator::merge_multiple(&boxed_accs).is_err()); - use crate::precompute_engine::operators::sum_accumulator::SumAccumulator; + use crate::accumulators::sum_accumulator::SumAccumulator; let kll = DatasketchesKLLAccumulator::new(200); let sum = SumAccumulator::new(); let mixed_accs: Vec> = vec![Box::new(kll), Box::new(sum)]; @@ -552,7 +552,6 @@ mod tests { // match the ground truth (sorted items) within KLL's own // rank-error bound for k=200. use asap_sketchlib::proto::sketchlib::KllState; - use prost::Message; let items: Vec = (0..64).map(|i| i as f64).collect(); let state = KllState { @@ -593,7 +592,6 @@ mod tests { #[test] fn compacted_wire_state_preserves_count_and_quantiles() { use asap_sketchlib::{proto::sketchlib::KllState, sketches::KLL}; - use prost::Message; let mut source = KLL::::init_kll_with_seed(32, 123); for i in 0..1000 { source.update(&(((i * 7919 + 17) % 1009) as f64 / 1009.0)); @@ -624,7 +622,6 @@ mod tests { // wrapped in a `SketchEnvelope{kll: ...}` via sketchlib-go's // `SerializePortableFO` + `proto.Marshal`. use asap_sketchlib::proto::sketchlib::{sketch_envelope, KllState, SketchEnvelope}; - use prost::Message; let items: Vec = (0..64).map(|i| i as f64).collect(); let state = KllState { @@ -652,7 +649,6 @@ mod tests { #[test] fn test_from_sketchlib_proto_bytes_envelope_wrong_sketch_type() { use asap_sketchlib::proto::sketchlib::{sketch_envelope, CountMinState, SketchEnvelope}; - use prost::Message; let env = SketchEnvelope { sketch_state: Some(sketch_envelope::SketchState::CountMin( @@ -669,7 +665,6 @@ mod tests { #[test] fn test_from_sketchlib_proto_bytes_rejects_small_k() { use asap_sketchlib::proto::sketchlib::KllState; - use prost::Message; let state = KllState { k: 4, // < minimum of 8 m: 2, @@ -690,7 +685,6 @@ mod tests { #[test] fn test_from_sketchlib_proto_bytes_rejects_inconsistent_levels() { use asap_sketchlib::proto::sketchlib::KllState; - use prost::Message; // num_levels=1 but levels array has 3 entries instead of 2 let state = KllState { k: 200, diff --git a/data_plane/src/precompute_engine/operators/dd_sketch_accumulator.rs b/crates/asap-physical-operators/src/accumulators/dd_sketch_accumulator.rs similarity index 97% rename from data_plane/src/precompute_engine/operators/dd_sketch_accumulator.rs rename to crates/asap-physical-operators/src/accumulators/dd_sketch_accumulator.rs index 0f63348b..9aa1132e 100644 --- a/data_plane/src/precompute_engine/operators/dd_sketch_accumulator.rs +++ b/crates/asap-physical-operators/src/accumulators/dd_sketch_accumulator.rs @@ -14,9 +14,7 @@ //! served by controller-provisioned exact aggregations — `query_statistic` //! returns the unavailable-statistic error for them. -use crate::storage_engines::types::{ - AggregateCore, AggregationType, KeyByLabelValues, SerializableToSink, -}; +use crate::{AggregateCore, AggregationType, KeyByLabelValues, SerializableToSink}; use asap_sketchlib::{DdSketch, DdSketchDelta, MessagePackCodec}; use serde_json::Value; use std::collections::HashMap; @@ -119,12 +117,12 @@ impl DDSketchAccumulator { /// Called against an accumulator that already carries the base /// sketch state; the caller is the per-series snapshot cache in /// the ingest path. Bytes are the - /// `asap_otel_proto::sketchlib::v1::DdSketchDelta` message. + /// `asap_sketchlib::proto::sketchlib::DdSketchDelta` message. pub fn apply_proto_delta_bytes( &mut self, buffer: &[u8], ) -> Result<(), Box> { - use asap_otel_proto::sketchlib::v1::DdSketchDelta as PbDelta; + use asap_sketchlib::proto::sketchlib::DdSketchDelta as PbDelta; use prost::Message; let pb = PbDelta::decode(buffer).map_err(|e| format!("decode DDSketchDelta: {e}"))?; @@ -400,7 +398,7 @@ mod tests { #[test] fn test_aggregate_core_merge_wrong_type_rejects() { - use crate::precompute_engine::operators::count_sketch_accumulator::CountSketchAccumulator; + use crate::accumulators::count_sketch_accumulator::CountSketchAccumulator; let dd = DDSketchAccumulator::new(0.01); let cs = CountSketchAccumulator::new(2, 3); assert!(dd.merge_with(&cs).is_err()); @@ -426,7 +424,7 @@ mod tests { #[test] fn test_apply_proto_delta_bytes_round_trip() { - use asap_otel_proto::sketchlib::v1::{DdSketchBucketDelta, DdSketchDelta as PbDelta}; + use asap_sketchlib::proto::sketchlib::{DdSketchBucketDelta, DdSketchDelta as PbDelta}; use prost::Message; let mut acc = DDSketchAccumulator::new(0.01); @@ -457,7 +455,7 @@ mod tests { /// A valid protobuf with an inadmissible span must not acknowledge a dropped update. #[test] fn test_apply_proto_delta_rejects_span_without_mutating_state() { - use asap_otel_proto::sketchlib::v1::{DdSketchBucketDelta, DdSketchDelta as PbDelta}; + use asap_sketchlib::proto::sketchlib::{DdSketchBucketDelta, DdSketchDelta as PbDelta}; use prost::Message; let mut acc = DDSketchAccumulator::new(0.01); acc.inner = DdSketch::from_raw(0.01, vec![1, 2, 3], 0); diff --git a/data_plane/src/precompute_engine/operators/exact_accumulator.rs b/crates/asap-physical-operators/src/accumulators/exact_accumulator.rs similarity index 96% rename from data_plane/src/precompute_engine/operators/exact_accumulator.rs rename to crates/asap-physical-operators/src/accumulators/exact_accumulator.rs index b7fcead1..5d23acd8 100644 --- a/data_plane/src/precompute_engine/operators/exact_accumulator.rs +++ b/crates/asap-physical-operators/src/accumulators/exact_accumulator.rs @@ -1,6 +1,6 @@ //! Exact summary state identified by Planner family, independent of keyed layout. use super::increase_accumulator::IncreaseAccumulator; -use crate::storage_engines::types::{ +use crate::{ AggregateCore, AggregationType, AuxStats, KeyByLabelValues, Measurement, SerializableToSink, }; use asap_types::Statistic; @@ -132,16 +132,15 @@ fn merge_scalar(left: &ScalarState, right: &ScalarState) -> Result { ScalarState::Max(a.iter().chain(b).copied().reduce(f64::max)) } - (ScalarState::Counter(a), ScalarState::Counter(b)) => { - ScalarState::Counter(match (a, b) { - (Some(a), Some(b)) => Some( - >::merge_accumulators(vec![a.clone(), b.clone()])?, - ), - (a, b) => a.clone().or_else(|| b.clone()), - }) - } + (ScalarState::Counter(a), ScalarState::Counter(b)) => ScalarState::Counter(match (a, b) { + (Some(a), Some(b)) => Some( + >::merge_accumulators(vec![ + a.clone(), + b.clone(), + ])?, + ), + (a, b) => a.clone().or_else(|| b.clone()), + }), _ => return Err("exact scalar state families differ".into()), }) } diff --git a/data_plane/src/precompute_engine/operators/hll_sketch_accumulator.rs b/crates/asap-physical-operators/src/accumulators/hll_sketch_accumulator.rs similarity index 98% rename from data_plane/src/precompute_engine/operators/hll_sketch_accumulator.rs rename to crates/asap-physical-operators/src/accumulators/hll_sketch_accumulator.rs index b4c3b4d0..254eee38 100644 --- a/data_plane/src/precompute_engine/operators/hll_sketch_accumulator.rs +++ b/crates/asap-physical-operators/src/accumulators/hll_sketch_accumulator.rs @@ -11,10 +11,8 @@ //! registers + variant + HIP accumulators losslessly, so the merge + //! store round-trip works end-to-end without that richer query surface. -use crate::precompute_engine::operators::dd_sketch_accumulator::normalize_sample_p; -use crate::storage_engines::types::{ - AggregateCore, AggregationType, KeyByLabelValues, SerializableToSink, -}; +use crate::accumulators::dd_sketch_accumulator::normalize_sample_p; +use crate::{AggregateCore, AggregationType, KeyByLabelValues, SerializableToSink}; use asap_sketchlib::{HllSketch, HllVariant, MessagePackCodec}; use serde_json::Value; use std::collections::HashMap; @@ -232,7 +230,7 @@ impl HllSketchAccumulator { /// Called against an accumulator that already carries the base /// sketch state; the caller is the per-series snapshot cache in /// the ingest path. Bytes are the - /// `asap_otel_proto::sketchlib::v1::HllDelta` message. + /// `asap_sketchlib::proto::sketchlib::HllDelta` message. pub fn apply_proto_delta_bytes( &mut self, buffer: &[u8], @@ -569,7 +567,7 @@ mod tests { #[test] fn test_aggregate_core_merge_wrong_type_rejects() { - use crate::precompute_engine::operators::count_sketch_accumulator::CountSketchAccumulator; + use crate::accumulators::count_sketch_accumulator::CountSketchAccumulator; let hll = HllSketchAccumulator::new(HllVariant::Regular, 2); let cs = CountSketchAccumulator::new(2, 3); assert!(hll.merge_with(&cs).is_err()); @@ -601,7 +599,7 @@ mod tests { #[test] fn test_apply_proto_delta_bytes_round_trip() { - use asap_otel_proto::sketchlib::v1::HllDelta as PbDelta; + use asap_sketchlib::proto::sketchlib::HllDelta as PbDelta; use prost::Message; let mut acc = HllSketchAccumulator::new(HllVariant::Regular, 2); diff --git a/data_plane/src/precompute_engine/operators/hydra_kll_accumulator.rs b/crates/asap-physical-operators/src/accumulators/hydra_kll_accumulator.rs similarity index 95% rename from data_plane/src/precompute_engine/operators/hydra_kll_accumulator.rs rename to crates/asap-physical-operators/src/accumulators/hydra_kll_accumulator.rs index c3793584..a5cc64dc 100644 --- a/data_plane/src/precompute_engine/operators/hydra_kll_accumulator.rs +++ b/crates/asap-physical-operators/src/accumulators/hydra_kll_accumulator.rs @@ -1,9 +1,6 @@ use crate::{ - storage_engines::types::{ - AggregateCore, AggregationType, MergeableAccumulator, MultipleSubpopulationAggregate, - SerializableToSink, - }, - KeyByLabelValues, + AggregateCore, AggregationType, KeyByLabelValues, MergeableAccumulator, + MultipleSubpopulationAggregate, SerializableToSink, }; use asap_sketchlib::{HydraKllSketch, MessagePackCodec}; use base64::{engine::general_purpose, Engine as _}; @@ -127,7 +124,7 @@ impl AggregateCore for HydraKllSketchAccumulator { key: &Option, query_kwargs: &std::collections::HashMap, ) -> Result> { - use crate::storage_engines::types::MultipleSubpopulationAggregate; + use crate::MultipleSubpopulationAggregate; let key_val = key .as_ref() .ok_or("Key required for HydraKllSketchAccumulator")?; diff --git a/data_plane/src/precompute_engine/operators/increase_accumulator.rs b/crates/asap-physical-operators/src/accumulators/increase_accumulator.rs similarity index 99% rename from data_plane/src/precompute_engine/operators/increase_accumulator.rs rename to crates/asap-physical-operators/src/accumulators/increase_accumulator.rs index 421feaa4..7407800b 100644 --- a/data_plane/src/precompute_engine/operators/increase_accumulator.rs +++ b/crates/asap-physical-operators/src/accumulators/increase_accumulator.rs @@ -1,4 +1,4 @@ -use crate::storage_engines::types::{ +use crate::{ AggregateCore, AggregationType, Measurement, MergeableAccumulator, SerializableToSink, SingleSubpopulationAggregate, }; @@ -391,7 +391,7 @@ impl AggregateCore for IncreaseAccumulator { _key: &Option, query_kwargs: &std::collections::HashMap, ) -> Result> { - use crate::storage_engines::types::SingleSubpopulationAggregate; + use crate::SingleSubpopulationAggregate; self.query( statistic, (!query_kwargs.is_empty()).then_some(query_kwargs), diff --git a/data_plane/src/precompute_engine/operators/keyed_counter_state.rs b/crates/asap-physical-operators/src/accumulators/keyed_counter_state.rs similarity index 98% rename from data_plane/src/precompute_engine/operators/keyed_counter_state.rs rename to crates/asap-physical-operators/src/accumulators/keyed_counter_state.rs index b94d2fab..1d4cf1c7 100644 --- a/data_plane/src/precompute_engine/operators/keyed_counter_state.rs +++ b/crates/asap-physical-operators/src/accumulators/keyed_counter_state.rs @@ -1,5 +1,5 @@ -use crate::precompute_engine::operators::IncreaseAccumulator; -use crate::storage_engines::types::{ +use crate::accumulators::IncreaseAccumulator; +use crate::{ AggregateCore, AggregationType, KeyByLabelValues, MergeableAccumulator, MultipleSubpopulationAggregate, SerializableToSink, SingleSubpopulationAggregate, }; @@ -209,7 +209,7 @@ impl AggregateCore for KeyedCounterState { key: &Option, query_kwargs: &std::collections::HashMap, ) -> Result> { - use crate::storage_engines::types::MultipleSubpopulationAggregate; + use crate::MultipleSubpopulationAggregate; let key_val = key.as_ref().ok_or("Key required for KeyedCounterState")?; self.query(statistic, key_val, Some(query_kwargs)) } @@ -263,7 +263,7 @@ impl MergeableAccumulator for KeyedCounterState { #[cfg(test)] mod tests { use super::*; - use crate::storage_engines::types::Measurement; + use crate::Measurement; fn create_test_increase_accumulator(start_val: f64, end_val: f64) -> IncreaseAccumulator { IncreaseAccumulator::new( diff --git a/data_plane/src/precompute_engine/operators/keyed_max_state.rs b/crates/asap-physical-operators/src/accumulators/keyed_max_state.rs similarity index 98% rename from data_plane/src/precompute_engine/operators/keyed_max_state.rs rename to crates/asap-physical-operators/src/accumulators/keyed_max_state.rs index 4309c04a..30a4a666 100644 --- a/data_plane/src/precompute_engine/operators/keyed_max_state.rs +++ b/crates/asap-physical-operators/src/accumulators/keyed_max_state.rs @@ -1,4 +1,4 @@ -use crate::storage_engines::types::{ +use crate::{ AggregateCore, AggregationType, KeyByLabelValues, MergeableAccumulator, MultipleSubpopulationAggregate, SerializableToSink, }; @@ -211,7 +211,7 @@ impl AggregateCore for KeyedMaxState { key: &Option, query_kwargs: &std::collections::HashMap, ) -> Result> { - use crate::storage_engines::types::MultipleSubpopulationAggregate; + use crate::MultipleSubpopulationAggregate; let key_val = key.as_ref().ok_or("Key required for KeyedMaxState")?; self.query(statistic, key_val, Some(query_kwargs)) } diff --git a/data_plane/src/precompute_engine/operators/keyed_min_state.rs b/crates/asap-physical-operators/src/accumulators/keyed_min_state.rs similarity index 98% rename from data_plane/src/precompute_engine/operators/keyed_min_state.rs rename to crates/asap-physical-operators/src/accumulators/keyed_min_state.rs index 5be698f5..f6bbf2be 100644 --- a/data_plane/src/precompute_engine/operators/keyed_min_state.rs +++ b/crates/asap-physical-operators/src/accumulators/keyed_min_state.rs @@ -1,4 +1,4 @@ -use crate::storage_engines::types::{ +use crate::{ AggregateCore, AggregationType, KeyByLabelValues, MergeableAccumulator, MultipleSubpopulationAggregate, SerializableToSink, }; @@ -211,7 +211,7 @@ impl AggregateCore for KeyedMinState { key: &Option, query_kwargs: &std::collections::HashMap, ) -> Result> { - use crate::storage_engines::types::MultipleSubpopulationAggregate; + use crate::MultipleSubpopulationAggregate; let key_val = key.as_ref().ok_or("Key required for KeyedMinState")?; self.query(statistic, key_val, Some(query_kwargs)) } diff --git a/data_plane/src/precompute_engine/operators/keyed_sum_count_accumulator.rs b/crates/asap-physical-operators/src/accumulators/keyed_sum_count_accumulator.rs similarity index 99% rename from data_plane/src/precompute_engine/operators/keyed_sum_count_accumulator.rs rename to crates/asap-physical-operators/src/accumulators/keyed_sum_count_accumulator.rs index c39d5583..486b3fee 100644 --- a/data_plane/src/precompute_engine/operators/keyed_sum_count_accumulator.rs +++ b/crates/asap-physical-operators/src/accumulators/keyed_sum_count_accumulator.rs @@ -1,4 +1,4 @@ -use crate::storage_engines::types::{ +use crate::{ AggregateCore, AggregationType, KeyByLabelValues, MergeableAccumulator, MultipleSubpopulationAggregate, SerializableToSink, }; @@ -324,7 +324,7 @@ impl AggregateCore for KeyedSumCountAccumulator { key: &Option, query_kwargs: &std::collections::HashMap, ) -> Result> { - use crate::storage_engines::types::MultipleSubpopulationAggregate; + use crate::MultipleSubpopulationAggregate; let key_val = key .as_ref() .ok_or("Key required for KeyedSumCountAccumulator")?; diff --git a/data_plane/src/precompute_engine/operators/max_accumulator.rs b/crates/asap-physical-operators/src/accumulators/max_accumulator.rs similarity index 98% rename from data_plane/src/precompute_engine/operators/max_accumulator.rs rename to crates/asap-physical-operators/src/accumulators/max_accumulator.rs index 733c2028..235b5fc9 100644 --- a/data_plane/src/precompute_engine/operators/max_accumulator.rs +++ b/crates/asap-physical-operators/src/accumulators/max_accumulator.rs @@ -1,4 +1,4 @@ -use crate::storage_engines::types::{ +use crate::{ AggregateCore, AggregationType, AuxStats, MergeableAccumulator, SerializableToSink, SingleSubpopulationAggregate, }; @@ -149,7 +149,7 @@ impl AggregateCore for MaxAccumulator { _key: &Option, _query_kwargs: &std::collections::HashMap, ) -> Result> { - use crate::storage_engines::types::SingleSubpopulationAggregate; + use crate::SingleSubpopulationAggregate; self.query(statistic, None) } } diff --git a/data_plane/src/precompute_engine/operators/min_accumulator.rs b/crates/asap-physical-operators/src/accumulators/min_accumulator.rs similarity index 98% rename from data_plane/src/precompute_engine/operators/min_accumulator.rs rename to crates/asap-physical-operators/src/accumulators/min_accumulator.rs index e69cda83..fff2fa0e 100644 --- a/data_plane/src/precompute_engine/operators/min_accumulator.rs +++ b/crates/asap-physical-operators/src/accumulators/min_accumulator.rs @@ -1,4 +1,4 @@ -use crate::storage_engines::types::{ +use crate::{ AggregateCore, AggregationType, AuxStats, MergeableAccumulator, SerializableToSink, SingleSubpopulationAggregate, }; @@ -152,7 +152,7 @@ impl AggregateCore for MinAccumulator { _key: &Option, _query_kwargs: &std::collections::HashMap, ) -> Result> { - use crate::storage_engines::types::SingleSubpopulationAggregate; + use crate::SingleSubpopulationAggregate; self.query(statistic, None) } } diff --git a/data_plane/src/precompute_engine/operators/mod.rs b/crates/asap-physical-operators/src/accumulators/mod.rs similarity index 100% rename from data_plane/src/precompute_engine/operators/mod.rs rename to crates/asap-physical-operators/src/accumulators/mod.rs diff --git a/data_plane/src/precompute_engine/operators/sketch_envelope_accumulator.rs b/crates/asap-physical-operators/src/accumulators/sketch_envelope_accumulator.rs similarity index 94% rename from data_plane/src/precompute_engine/operators/sketch_envelope_accumulator.rs rename to crates/asap-physical-operators/src/accumulators/sketch_envelope_accumulator.rs index 08956e0a..930dbe6d 100644 --- a/data_plane/src/precompute_engine/operators/sketch_envelope_accumulator.rs +++ b/crates/asap-physical-operators/src/accumulators/sketch_envelope_accumulator.rs @@ -5,7 +5,7 @@ //! (via `SketchEnvelope::decode`) only when merge or query operations need //! the inner sketch type. -use crate::storage_engines::types::{AggregateCore, KeyByLabelValues, SerializableToSink}; +use crate::{AggregateCore, KeyByLabelValues, SerializableToSink}; use asap_sketchlib::proto::sketchlib::{sketch_envelope, SketchEnvelope}; use prost::Message; use serde_json::Value; @@ -135,7 +135,7 @@ impl AggregateCore for SketchEnvelopeAccumulator { } } -impl crate::storage_engines::types::MultipleSubpopulationAggregate for SketchEnvelopeAccumulator { +impl crate::MultipleSubpopulationAggregate for SketchEnvelopeAccumulator { fn query( &self, _statistic: Statistic, @@ -148,9 +148,7 @@ impl crate::storage_engines::types::MultipleSubpopulationAggregate for SketchEnv ) } - fn clone_boxed( - &self, - ) -> Box { + fn clone_boxed(&self) -> Box { Box::new(self.clone()) } } diff --git a/data_plane/src/precompute_engine/operators/sum_accumulator.rs b/crates/asap-physical-operators/src/accumulators/sum_accumulator.rs similarity index 99% rename from data_plane/src/precompute_engine/operators/sum_accumulator.rs rename to crates/asap-physical-operators/src/accumulators/sum_accumulator.rs index d5ff3b02..c5293911 100644 --- a/data_plane/src/precompute_engine/operators/sum_accumulator.rs +++ b/crates/asap-physical-operators/src/accumulators/sum_accumulator.rs @@ -1,4 +1,4 @@ -use crate::storage_engines::types::{ +use crate::{ AggregateCore, AggregationType, AuxStats, MergeableAccumulator, SerializableToSink, SingleSubpopulationAggregate, }; @@ -180,7 +180,7 @@ impl AggregateCore for SumAccumulator { _key: &Option, _query_kwargs: &std::collections::HashMap, ) -> Result> { - use crate::storage_engines::types::SingleSubpopulationAggregate; + use crate::SingleSubpopulationAggregate; self.query(statistic, None) } } diff --git a/data_plane/src/precompute_engine/operators/univmon_accumulator.rs b/crates/asap-physical-operators/src/accumulators/univmon_accumulator.rs similarity index 98% rename from data_plane/src/precompute_engine/operators/univmon_accumulator.rs rename to crates/asap-physical-operators/src/accumulators/univmon_accumulator.rs index b216f8f5..2d18895e 100644 --- a/data_plane/src/precompute_engine/operators/univmon_accumulator.rs +++ b/crates/asap-physical-operators/src/accumulators/univmon_accumulator.rs @@ -1,8 +1,6 @@ //! One frequency state shared by count, distinct, L2 and entropy readouts. -use crate::storage_engines::types::{ - AggregateCore, AuxStats, KeyByLabelValues, SerializableToSink, -}; +use crate::{AggregateCore, AuxStats, KeyByLabelValues, SerializableToSink}; use asap_sketchlib::{DataInput, UnivMon}; use asap_types::{AggregationType, Statistic}; use serde_json::Value; diff --git a/data_plane/src/utils/arithmetic.rs b/crates/asap-physical-operators/src/arithmetic.rs similarity index 81% rename from data_plane/src/utils/arithmetic.rs rename to crates/asap-physical-operators/src/arithmetic.rs index 94aba683..bfc50694 100644 --- a/data_plane/src/utils/arithmetic.rs +++ b/crates/asap-physical-operators/src/arithmetic.rs @@ -1,7 +1,7 @@ -//! Float64 arithmetic shared by data-plane execution engines. +//! Float64 arithmetic shared by ASAP execution engines. //! Preserve IEEE non-finite results; callers own their output policies. -pub(crate) fn evaluate_float64_arithmetic( +pub fn evaluate_float64_arithmetic( operator: &planner_types::pre_asap::ArithmeticOpKind, left: f64, right: f64, diff --git a/crates/asap-physical-operators/src/capability.rs b/crates/asap-physical-operators/src/capability.rs new file mode 100644 index 00000000..42e0deed --- /dev/null +++ b/crates/asap-physical-operators/src/capability.rs @@ -0,0 +1,113 @@ +//! Allocation-free checks for the concrete summary kernels in this crate. +use planner_types::post_asap::{ + ExactKind, ExactParams, GroupingStrategy, SketchAlgorithm, SketchParams, SummaryFamilyType, + SummaryUpdate, +}; + +/// Check the same contract used by `create_planner_accumulator` before a plan +/// is accepted. Execution timing is deliberately not a kernel property. +pub fn validate_summary_kernel( + family: &SummaryFamilyType, + input: &SummaryUpdate, + grouping: &GroupingStrategy, +) -> Result<(), String> { + if grouping != &GroupingStrategy::PerSubpopulationInstance { + return Err("shared summary grouping has no registered kernel".into()); + } + let keyed = match family { + SummaryFamilyType::ExactAggregate(kind, params) => { + use ExactKind as K; + use ExactParams as P; + if !matches!( + (kind, params), + (K::Sum, P::Sum) + | (K::Count, P::Count) + | (K::Min, P::Min) + | (K::Max, P::Max) + | (K::Rate, P::Rate) + | (K::Increase, P::Increase) + ) { + return Err(format!("unsupported exact kernel {family:?}")); + } + input.item.is_some() + } + SummaryFamilyType::Sketch(kind, layout) => { + if layout != grouping { + return Err("Planner family and operator grouping disagree".into()); + } + use SketchAlgorithm as A; + use SketchParams as P; + match (kind.algorithm(), kind.params()) { + (A::Kll, P::Kll { k }) if (8..=u16::MAX as u32).contains(k) => false, + (A::DDSketch, P::DDSketch { alpha }) + if alpha.is_finite() && *alpha > 0.0 && *alpha < 1.0 => + { + false + } + (A::Hll, P::Hll { precision }) if (4..=18).contains(precision) => false, + (A::Cms, P::Cms { width, depth }) + | (A::CountSketch, P::CountSketch { width, depth }) + if valid_matrix(*width, *depth) => + { + true + } + ( + A::CmsWithHeap, + P::CmsWithHeap { + width, + depth, + heap_size, + }, + ) + | ( + A::CountSketchWithHeap, + P::CountSketchWithHeap { + width, + depth, + heap_size, + }, + ) if valid_matrix(*width, *depth) && *heap_size > 0 => true, + ( + A::UnivMon, + P::UnivMon { + heap_size, + sketch_rows, + sketch_cols, + layers, + }, + ) if *heap_size > 0 + && *sketch_cols > 0 + && (1..=20).contains(sketch_rows) + && (1..=64).contains(layers) + && (*sketch_rows as usize) + .checked_mul(*sketch_cols as usize) + .and_then(|n| n.checked_mul(*layers as usize)) + .is_some() => + { + false + } + _ => { + return Err(format!( + "unsupported kernel or invalid parameters: {kind:?}" + )) + } + } + } + _ => return Err(format!("unsupported summary kernel {family:?}")), + }; + if keyed != input.item.is_some() + && !asap_types::accumulator_spec::is_unit_sample_frequency(input) + { + return Err("Planner item expression does not match kernel layout".into()); + } + Ok(()) +} + +fn valid_matrix(width: u32, depth: u32) -> bool { + crate::accumulators::count_min_sketch_accumulator::validate_sketch_dims( + "Planner kernel", + depth as usize, + width as usize, + ) + .is_ok() +} diff --git a/data_plane/src/precompute_engine/accumulator_factory.rs b/crates/asap-physical-operators/src/factory.rs similarity index 98% rename from data_plane/src/precompute_engine/accumulator_factory.rs rename to crates/asap-physical-operators/src/factory.rs index 9f94c8c9..fe6bed87 100644 --- a/data_plane/src/precompute_engine/accumulator_factory.rs +++ b/crates/asap-physical-operators/src/factory.rs @@ -1,17 +1,18 @@ -use crate::precompute_engine::operators::{ +use crate::accumulators::{ CountMinSketchAccumulator, CountMinSketchWithHeapAccumulator, CountSketchAccumulator, CountSketchWithHeapAccumulator, DDSketchAccumulator, DatasketchesKLLAccumulator, HydraKllSketchAccumulator, IncreaseAccumulator, KeyedCounterState, KeyedMaxState, KeyedMinState, KeyedSumCountAccumulator, MaxAccumulator, MinAccumulator, SumAccumulator, }; -use crate::storage_engines::types::{ - AggregateCore, AggregationType, KeyByLabelValues, Measurement, -}; +#[cfg(test)] +use crate::AggregationType; +use crate::{AggregateCore, KeyByLabelValues, Measurement}; +#[cfg(test)] use asap_types::aggregation_config::PrecomputeMaterialization; // Production dispatch consumes Planner SummaryAgg payloads directly. The // config adapter below is compiled only for isolated historical kernel tests. -use super::operators::hll_sketch_accumulator::HllSketchAccumulator; -use super::operators::univmon_accumulator::UnivMonAccumulator; +use crate::accumulators::hll_sketch_accumulator::HllSketchAccumulator; +use crate::accumulators::univmon_accumulator::UnivMonAccumulator; #[cfg(test)] use asap_types::accumulator_spec::cms_params; use planner_types::post_asap::{ExactKind, SketchAlgorithm, SketchParams, SummaryFamilyType}; @@ -43,7 +44,7 @@ macro_rules! impl_clone_accumulator_methods { }; } -/// Trait for feeding samples into accumulators in the precompute engine. +/// Shared update interface for query-time and maintenance-time accumulation. /// /// This provides a uniform interface over all accumulator types so that the /// worker loop doesn't need to know which concrete type it's dealing with. @@ -1493,7 +1494,7 @@ mod tests { let acc = updater.snapshot_accumulator(); let kll = acc .as_any() - .downcast_ref::() + .downcast_ref::() .expect("should be KLL"); assert_eq!(kll.inner.k, 50, "k should be 50 from capital-K param"); } @@ -1824,13 +1825,14 @@ pub fn create_planner_accumulator( input: &planner_types::post_asap::SummaryUpdate, grouping: &planner_types::post_asap::GroupingStrategy, ) -> Result, String> { + crate::capability::validate_summary_kernel(family, input, grouping)?; use planner_types::post_asap::GroupingStrategy; if grouping != &GroupingStrategy::PerSubpopulationInstance { return Err("shared summary grouping requires a supported Planner Hydra kernel".into()); } if matches!(family, SummaryFamilyType::ExactAggregate(..)) { return Ok(Box::new(PlannerExactUpdater { - acc: super::operators::exact_accumulator::ExactAccumulator::new( + acc: crate::accumulators::exact_accumulator::ExactAccumulator::new( family.clone(), input.item.is_some(), )?, @@ -1928,7 +1930,7 @@ pub fn create_planner_accumulator( } struct PlannerExactUpdater { - acc: super::operators::exact_accumulator::ExactAccumulator, + acc: crate::accumulators::exact_accumulator::ExactAccumulator, } impl AccumulatorUpdater for PlannerExactUpdater { fn update_single(&mut self, value: f64, timestamp: i64) { @@ -1939,7 +1941,7 @@ impl AccumulatorUpdater for PlannerExactUpdater { } impl_clone_accumulator_methods!(acc); fn reset(&mut self) { - self.acc = super::operators::exact_accumulator::ExactAccumulator::new( + self.acc = crate::accumulators::exact_accumulator::ExactAccumulator::new( self.acc.family().clone(), self.acc.is_keyed(), ) diff --git a/data_plane/src/storage_engines/types/key_by_label_values.rs b/crates/asap-physical-operators/src/key_by_label_values.rs similarity index 100% rename from data_plane/src/storage_engines/types/key_by_label_values.rs rename to crates/asap-physical-operators/src/key_by_label_values.rs diff --git a/crates/asap-physical-operators/src/lib.rs b/crates/asap-physical-operators/src/lib.rs new file mode 100644 index 00000000..5d1858d1 --- /dev/null +++ b/crates/asap-physical-operators/src/lib.rs @@ -0,0 +1,19 @@ +#![doc = include_str!("../README.md")] + +pub mod accumulators; +pub mod key_by_label_values; +pub mod measurement; +pub mod traits; + +pub use asap_types::{AggregationType, Statistic}; +pub use key_by_label_values::KeyByLabelValues; +pub use measurement::Measurement; +pub use traits::*; + +pub mod arithmetic; +pub mod capability; +pub mod factory; +pub mod query_dag; + +/// The exact Planner contract used by these kernels. +pub use planner_types as planner; diff --git a/data_plane/src/storage_engines/types/measurement.rs b/crates/asap-physical-operators/src/measurement.rs similarity index 100% rename from data_plane/src/storage_engines/types/measurement.rs rename to crates/asap-physical-operators/src/measurement.rs diff --git a/data_plane/src/query_engines/asap_query_engine/physical_dag.rs b/crates/asap-physical-operators/src/query_dag.rs similarity index 99% rename from data_plane/src/query_engines/asap_query_engine/physical_dag.rs rename to crates/asap-physical-operators/src/query_dag.rs index dfbbda6a..88833525 100644 --- a/data_plane/src/query_engines/asap_query_engine/physical_dag.rs +++ b/crates/asap-physical-operators/src/query_dag.rs @@ -1,7 +1,7 @@ //! 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 +//! definitions live in `asap_types`; store and operator semantics are //! supplied by a runtime adapter. use std::collections::BTreeMap; @@ -99,7 +99,6 @@ pub fn execute_from( }) } -#[cfg(test)] pub async fn execute_async( entry: &QueryPlanEntry, runtime: &R, diff --git a/data_plane/src/storage_engines/types/traits.rs b/crates/asap-physical-operators/src/traits.rs similarity index 99% rename from data_plane/src/storage_engines/types/traits.rs rename to crates/asap-physical-operators/src/traits.rs index 97f2c96d..2b99da78 100644 --- a/data_plane/src/storage_engines/types/traits.rs +++ b/crates/asap-physical-operators/src/traits.rs @@ -1,4 +1,4 @@ -use crate::storage_engines::types::KeyByLabelValues; +use crate::KeyByLabelValues; use std::collections::HashMap; use asap_types::AggregationType; diff --git a/crates/asap-physical-operators/tests/deployment.rs b/crates/asap-physical-operators/tests/deployment.rs new file mode 100644 index 00000000..980a5c68 --- /dev/null +++ b/crates/asap-physical-operators/tests/deployment.rs @@ -0,0 +1,65 @@ +//! Exercise the public library without a backend server, store, or scheduler. +use asap_physical_operators::planner::{ + post_asap::{ + GroupingStrategy, SketchAlgorithm, SketchKind, SketchParams, SummaryFamilyType, + SummaryUpdate, + }, + pre_asap::ColumnRef, +}; +use asap_physical_operators::{factory::create_planner_accumulator, AggregateCore, Statistic}; +use std::collections::HashMap; + +fn family(k: u32) -> SummaryFamilyType { + SummaryFamilyType::Sketch( + SketchKind::new(SketchAlgorithm::Kll, SketchParams::Kll { k }), + GroupingStrategy::PerSubpopulationInstance, + ) +} +fn build(values: &[f64]) -> Box { + let mut operator = create_planner_accumulator( + &family(512), + &SummaryUpdate::column(ColumnRef::SampleValue), + &Default::default(), + ) + .unwrap(); + for (at, value) in values.iter().enumerate() { + operator.validate_single_input(*value).unwrap(); + operator.update_single(*value, at as i64); + } + operator.into_accumulator() +} +fn read(state: &dyn AggregateCore) -> f64 { + state + .query_statistic( + Statistic::Quantile, + &None, + &HashMap::from([("quantile".into(), "0.5".into())]), + ) + .unwrap() +} + +// The same kernels work when every build is query-time, when only a prefix +// was precomputed, and when all state was precomputed before the readout. +#[test] +fn raw_partial_and_fully_precomputed_use_the_same_kernels() { + let raw: Vec = (0..128).map(f64::from).collect(); + let raw_only = build(&raw); + let stored_prefix = build(&raw[..64]); + let query_time_suffix = build(&raw[64..]); + let partial = stored_prefix.merge_with(&*query_time_suffix).unwrap(); + let stored_complete = build(&raw); + assert_eq!(read(&*raw_only), read(&*partial)); + assert_eq!(read(&*partial), read(&*stored_complete)); + assert!((read(&*raw_only) - 64.0).abs() <= 1.0); +} + +// A compiler must reject invalid physical parameters before starting execution. +#[test] +fn invalid_kll_parameters_are_rejected_at_binding() { + let result = create_planner_accumulator( + &family(0), + &SummaryUpdate::column(ColumnRef::SampleValue), + &Default::default(), + ); + assert!(result.is_err()); +} diff --git a/data_plane/Cargo.toml b/data_plane/Cargo.toml index 7482c521..5e9c27b2 100644 --- a/data_plane/Cargo.toml +++ b/data_plane/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true [dependencies] # Internal crates (workspace) asap_types.workspace = true +asap-physical-operators.workspace = true asap_sketch_codec = { path = "../crates/asap_sketch_codec" } # Phase 9: the control plane is now an in-process library inside the # backend binary. Wiring up the in-process OpAMP server + capability-map @@ -103,4 +104,4 @@ default = [] # Enable lock profiling instrumentation lock_profiling = [] # Enable extra debugging output -extra_debugging = [] +extra_debugging = ["asap-physical-operators/extra_debugging"] diff --git a/data_plane/benches/sketch_db.rs b/data_plane/benches/sketch_db.rs index f57b2994..cc005bda 100644 --- a/data_plane/benches/sketch_db.rs +++ b/data_plane/benches/sketch_db.rs @@ -33,7 +33,7 @@ use asap_sketchlib::DdSketch; use asap_sketchlib::{HllSketch, HllVariant}; use prost::Message; -use data_plane::precompute_engine::operators::SumAccumulator; +use asap_physical_operators::accumulators::SumAccumulator; use data_plane::storage_engines::sketch_db::data::{ AccuracyBound, AggKind, AggregationType, Capability, SketchAlgorithm, SketchConfig, SketchEncoding, diff --git a/data_plane/examples/univmon_erp_artifact.rs b/data_plane/examples/univmon_erp_artifact.rs index aa37ec13..10910306 100644 --- a/data_plane/examples/univmon_erp_artifact.rs +++ b/data_plane/examples/univmon_erp_artifact.rs @@ -1,7 +1,7 @@ //! Measure readout-specific ERP evidence from finite JSONL evaluation data. //! This offline tool retains samples; the production backend does not. -use data_plane::precompute_engine::operators::hll_sketch_accumulator::HllSketchAccumulator; -use data_plane::precompute_engine::operators::univmon_accumulator::UnivMonAccumulator; +use asap_physical_operators::accumulators::hll_sketch_accumulator::HllSketchAccumulator; +use asap_physical_operators::accumulators::univmon_accumulator::UnivMonAccumulator; use data_plane::storage_engines::types::{AggregateCore, SerializableToSink}; use serde_json::{json, Value}; use std::collections::{BTreeMap, HashMap}; diff --git a/data_plane/src/drivers/ingest/otel.rs b/data_plane/src/drivers/ingest/otel.rs index 9dedf5c4..bd12e770 100644 --- a/data_plane/src/drivers/ingest/otel.rs +++ b/data_plane/src/drivers/ingest/otel.rs @@ -24,7 +24,6 @@ use std::collections::HashMap; use std::io::Read; -use crate::precompute_engine::operators::sketch_envelope_accumulator::SketchEnvelopeAccumulator; use crate::precompute_engine::series_router::WorkerMessage; use crate::precompute_engine::IngestState; use crate::query_engines::routing::FreshnessProbeCache; @@ -35,6 +34,7 @@ use asap_otel_proto::tonic::collector::metrics::v1::{ }; use asap_otel_proto::tonic::common::v1::any_value::Value as AnyValueVariant; use asap_otel_proto::tonic::metrics::v1::number_data_point::Value as NumberValue; +use asap_physical_operators::accumulators::sketch_envelope_accumulator::SketchEnvelopeAccumulator; use asap_sketchlib::proto::sketchlib::{sketch_envelope, SketchEnvelope}; use asap_sketchlib::MessagePackCodec; use axum::{body::Bytes, extract::State, routing::post, Json, Router}; @@ -2095,7 +2095,7 @@ fn dp_carries_heap(dp: &ModifiedOtlpSketchDp) -> bool { .unwrap_or(false) } ENCODING_MSGPACK_DELTA => { - use crate::precompute_engine::operators::CountMinSketchWithHeapAccumulator; + use asap_physical_operators::accumulators::CountMinSketchWithHeapAccumulator; CountMinSketchWithHeapAccumulator::from_msgpack_heap_delta_bytes(&dp.sketch) .map(|acc| !acc.inner.topk_heap_items().is_empty()) .unwrap_or(false) @@ -2523,7 +2523,7 @@ fn decode_modified_otlp_sketch_bytes( encoding: i32, bytes: &[u8], ) -> Result, Box> { - use crate::precompute_engine::operators::{ + use asap_physical_operators::accumulators::{ CountMinSketchAccumulator, CountSketchAccumulator, DDSketchAccumulator, DatasketchesKLLAccumulator, HllSketchAccumulator, }; @@ -2594,7 +2594,7 @@ fn decode_modified_otlp_sketch_bytes( use asap_sketchlib::CountSketchWithHeap; if let Ok(heap) = CountSketchWithHeap::from_msgpack(bytes) { if !heap.topk_heap_items().is_empty() { - use crate::precompute_engine::operators::CountSketchWithHeapAccumulator; + use asap_physical_operators::accumulators::CountSketchWithHeapAccumulator; return Ok(Box::new( CountSketchWithHeapAccumulator::from_msgpack_with_heap_bytes(bytes)?, )); @@ -2659,11 +2659,11 @@ fn empty_accumulator_for_delta_bootstrap( config: &crate::storage_engines::sketch_db::index::SketchConfig, encoding: i32, ) -> Option> { - use crate::precompute_engine::operators::{ + use crate::storage_engines::sketch_db::index::SketchConfig; + use asap_physical_operators::accumulators::{ CountMinSketchAccumulator, CountSketchAccumulator, CountSketchWithHeapAccumulator, HllSketchAccumulator, }; - use crate::storage_engines::sketch_db::index::SketchConfig; match (algorithm, config) { (SketchAlgorithm::Hll, SketchConfig::Hll { precision }) => { @@ -2733,7 +2733,7 @@ pub(crate) fn apply_modified_otlp_delta_bytes( existing: &mut Box, bytes: &[u8], ) -> Result<(), Box> { - use crate::precompute_engine::operators::{ + use asap_physical_operators::accumulators::{ CountMinSketchAccumulator, CountSketchAccumulator, CountSketchWithHeapAccumulator, DDSketchAccumulator, HllSketchAccumulator, }; @@ -2979,7 +2979,7 @@ fn otlp_to_metric_points_and_sketches(request: &ExportMetricsServiceRequest) -> // ExactAgg(Sum) path as a plain delta Sum — the backend sums // the per-window/per-shard partials for the same sid. for dp in &sa.data_points { - let value = match crate::precompute_engine::operators::sum_accumulator::SumAccumulator::from_sum_bytes(&dp.sketch) { + let value = match asap_physical_operators::accumulators::sum_accumulator::SumAccumulator::from_sum_bytes(&dp.sketch) { Ok(acc) => acc.sum, Err(e) => { debug!("asap_edge: SumAgg data point decode failed (skipping): {e}"); @@ -3419,8 +3419,8 @@ mod policy_fp_lookup_tests { #[cfg(test)] mod dispatcher_tests { use super::*; - use crate::precompute_engine::operators::{DDSketchAccumulator, HllSketchAccumulator}; use crate::storage_engines::types::AggregateCore; + use asap_physical_operators::accumulators::{DDSketchAccumulator, HllSketchAccumulator}; use asap_sketchlib::DdSketch; use asap_sketchlib::HllVariant; @@ -3772,8 +3772,8 @@ mod sid_resolution_tests { /// directly observable on the bucket counts. #[tokio::test] async fn delta_apply_rotates_per_series_base_at_window_boundary() { - use crate::precompute_engine::operators::DDSketchAccumulator; use asap_otel_proto::sketchlib::v1::{DdSketchBucketDelta, DdSketchDelta as PbDelta}; + use asap_physical_operators::accumulators::DDSketchAccumulator; use asap_sketchlib::proto::sketchlib::{sketch_envelope, DdSketchState, SketchEnvelope}; use prost::Message; @@ -4104,8 +4104,8 @@ mod sid_resolution_tests { /// recover after a backend restart. #[tokio::test] async fn leading_cms_delta_bootstraps_onto_empty_base() { - use crate::precompute_engine::operators::CountMinSketchAccumulator; use asap_otel_proto::sketchlib::v1::CountMinDelta as PbDelta; + use asap_physical_operators::accumulators::CountMinSketchAccumulator; use prost::Message; let (state, drain) = make_state().await; @@ -4190,8 +4190,8 @@ mod sid_resolution_tests { /// the register-max updates. #[tokio::test] async fn leading_hll_delta_bootstraps_onto_empty_base() { - use crate::precompute_engine::operators::HllSketchAccumulator; use asap_otel_proto::sketchlib::v1::HllDelta as PbDelta; + use asap_physical_operators::accumulators::HllSketchAccumulator; use prost::Message; let (state, drain) = make_state().await; diff --git a/data_plane/src/lib.rs b/data_plane/src/lib.rs index 2cf07fa5..116429c2 100644 --- a/data_plane/src/lib.rs +++ b/data_plane/src/lib.rs @@ -42,7 +42,7 @@ pub use storage_engines::types::{ SerializableToSink, SingleSubpopulationAggregate, }; -pub use precompute_engine::operators::{ +pub use asap_physical_operators::accumulators::{ IncreaseAccumulator, KeyedSumCountAccumulator, MaxAccumulator, MinAccumulator, SumAccumulator, }; diff --git a/data_plane/src/precompute_engine/ingest_handler.rs b/data_plane/src/precompute_engine/ingest_handler.rs index 4426d29c..6d59810e 100644 --- a/data_plane/src/precompute_engine/ingest_handler.rs +++ b/data_plane/src/precompute_engine/ingest_handler.rs @@ -366,8 +366,8 @@ mod tests { #[tokio::test] async fn delta_path_reconstitutes_cumulative_state() { use crate::drivers::ingest::otel::apply_modified_otlp_delta_bytes; - use crate::precompute_engine::operators::DDSketchAccumulator; use asap_otel_proto::sketchlib::v1::{DdSketchBucketDelta, DdSketchDelta as PbDelta}; + use asap_physical_operators::accumulators::DDSketchAccumulator; use asap_sketchlib::DdSketch; use planner_types::post_asap::SketchAlgorithm; use prost::Message; @@ -472,7 +472,7 @@ mod tests { /// survive; a stale entry from far in the past must be swept. #[tokio::test] async fn stale_snapshot_entry_is_evicted_by_sweep() { - use crate::precompute_engine::operators::SumAccumulator; + use asap_physical_operators::accumulators::SumAccumulator; let (state, drain) = setup_state(7, "evict_metric").await; diff --git a/data_plane/src/precompute_engine/maintenance_runtime.rs b/data_plane/src/precompute_engine/maintenance_runtime.rs index f77a0870..3b846265 100644 --- a/data_plane/src/precompute_engine/maintenance_runtime.rs +++ b/data_plane/src/precompute_engine/maintenance_runtime.rs @@ -258,7 +258,7 @@ impl PrecomputeOperatorRegistry for OperatorAdapter<'_> { "keyed maintenance updates require explicit row identity routing".into(), ); } - let mut updater = super::accumulator_factory::create_planner_accumulator( + let mut updater = asap_physical_operators::factory::create_planner_accumulator( family, input, grouping, )?; if updater.is_keyed() { @@ -471,8 +471,9 @@ fn evaluate_aligned_binary( let right = right_rows .get(×tamp) .ok_or("maintenance binary requires matching timestamp sets")?; - let value = - crate::utils::arithmetic::evaluate_float64_arithmetic(arithmetic, left, *right); + let value = asap_physical_operators::arithmetic::evaluate_float64_arithmetic( + arithmetic, left, *right, + ); if !value.is_finite() { return Err("maintenance binary produced a non-finite update".into()); } @@ -2128,7 +2129,7 @@ pub(crate) fn affected_materializations( #[cfg(test)] mod tests { use super::*; - use crate::precompute_engine::operators::SumAccumulator; + use asap_physical_operators::accumulators::SumAccumulator; use planner_types::post_asap::{ EdgeRole, ExecutableDag, ExecutableDagEdge, GroupingEdgeCompatibility, SummarySchema, WindowEdgeCompatibility, @@ -2163,7 +2164,7 @@ mod tests { fn cohort_lineage_is_order_independent_and_binds_every_input() { use crate::storage_engines::sketch_db::index::FrozenExactWindows; let make = |sid, id, value| { - let mut state = crate::precompute_engine::operators::SumAccumulator::new(); + let mut state = asap_physical_operators::accumulators::SumAccumulator::new(); state.update(value); FrozenExactWindows { stored_output_reference: asap_types::sds::StoredOutputReference::for_definition( diff --git a/data_plane/src/precompute_engine/mod.rs b/data_plane/src/precompute_engine/mod.rs index 80e87caf..726801a2 100644 --- a/data_plane/src/precompute_engine/mod.rs +++ b/data_plane/src/precompute_engine/mod.rs @@ -1,4 +1,3 @@ -pub mod accumulator_factory; pub mod config; pub mod coordination_checkpoint; mod engine; @@ -9,7 +8,6 @@ pub mod ingest_handler; pub mod maintenance_runtime; pub(crate) mod metrics; pub mod multisource_coordinator; -pub mod operators; pub mod output_sink; pub mod raw_dag; pub mod series_buffer; diff --git a/data_plane/src/precompute_engine/output_sink.rs b/data_plane/src/precompute_engine/output_sink.rs index 268a6a37..33782c92 100644 --- a/data_plane/src/precompute_engine/output_sink.rs +++ b/data_plane/src/precompute_engine/output_sink.rs @@ -323,9 +323,9 @@ impl OutputSink for NoopOutputSink { #[cfg(test)] mod tests { use super::*; - use crate::precompute_engine::operators::{DDSketchAccumulator, SumAccumulator}; use crate::storage_engines::sketch_db::index::{AggKind, SeriesLookup}; use crate::storage_engines::types::{InstalledPrecomputePlan, KeyByLabelValues}; + use asap_physical_operators::accumulators::{DDSketchAccumulator, SumAccumulator}; use asap_types::aggregation_config::PrecomputeMaterialization; use asap_types::enums::WindowKind; use asap_types::AggregationType; diff --git a/data_plane/src/precompute_engine/raw_dag.rs b/data_plane/src/precompute_engine/raw_dag.rs index ee76c8b4..a1884e32 100644 --- a/data_plane/src/precompute_engine/raw_dag.rs +++ b/data_plane/src/precompute_engine/raw_dag.rs @@ -1,6 +1,6 @@ //! Bind raw ingestion to a selected Planner producer and its raw dependency edge. -use super::accumulator_factory::{create_planner_accumulator, AccumulatorUpdater}; use crate::storage_engines::types::KeyByLabelValues; +use asap_physical_operators::factory::{create_planner_accumulator, AccumulatorUpdater}; use asap_types::{executable_plan::BackendNodeBinding, PrecomputeMaterialization}; use planner_types::post_asap::{ EdgeRole, ExecutableOperatorPayload, GroupingStrategy, PostAsapNodeId, SummaryFamilyType, diff --git a/data_plane/src/precompute_engine/worker.rs b/data_plane/src/precompute_engine/worker.rs index 9f41025d..c23b9fd5 100644 --- a/data_plane/src/precompute_engine/worker.rs +++ b/data_plane/src/precompute_engine/worker.rs @@ -1,16 +1,16 @@ -#[cfg(test)] -use crate::precompute_engine::accumulator_factory::create_fixture_accumulator; -use crate::precompute_engine::accumulator_factory::AccumulatorUpdater; use crate::precompute_engine::config::LateDataPolicy; use crate::precompute_engine::group_key::GroupKey; use crate::precompute_engine::metrics::record_late_input; -use crate::precompute_engine::operators::sum_accumulator::SumAccumulator; use crate::precompute_engine::output_sink::OutputSink; use crate::precompute_engine::series_router::WorkerMessage; use crate::precompute_engine::window_manager::WindowManager; use crate::storage_engines::types::{ AggregateCore, InstalledPrecomputePlanHandle, KeyByLabelValues, PrecomputedOutput, }; +#[cfg(test)] +use crate::tests::accumulator_fixture::create_fixture_accumulator; +use asap_physical_operators::accumulators::sum_accumulator::SumAccumulator; +use asap_physical_operators::factory::AccumulatorUpdater; use asap_types::aggregation_config::PrecomputeMaterialization; use asap_types::PolicyFingerprint; use asap_types::SampleUpdateRule; @@ -1943,11 +1943,11 @@ mod tests { // ----------------------------------------------------------------------- use crate::precompute_engine::config::LateDataPolicy; - use crate::precompute_engine::operators::datasketches_kll_accumulator::DatasketchesKLLAccumulator; - use crate::precompute_engine::operators::keyed_sum_count_accumulator::KeyedSumCountAccumulator; - use crate::precompute_engine::operators::sum_accumulator::SumAccumulator; use crate::precompute_engine::output_sink::CapturingOutputSink; use crate::storage_engines::types::InstalledPrecomputePlan; + use asap_physical_operators::accumulators::datasketches_kll_accumulator::DatasketchesKLLAccumulator; + use asap_physical_operators::accumulators::keyed_sum_count_accumulator::KeyedSumCountAccumulator; + use asap_physical_operators::accumulators::sum_accumulator::SumAccumulator; use asap_sketchlib::KllSketch; use asap_types::enums::WindowKind; use asap_types::AggregationType; @@ -3241,7 +3241,7 @@ mod tests { // OTLP ingest dispatch builds via `decode_modified_otlp_sketch_bytes`. // ----------------------------------------------------------------------- - use crate::precompute_engine::operators::DDSketchAccumulator; + use asap_physical_operators::accumulators::DDSketchAccumulator; use asap_sketchlib::DdSketch; /// Build a fresh DDSketch holding `vals` so each test has a real, @@ -4006,7 +4006,7 @@ mod tests { // A pooled Sum is correct only for an explicit cross-entity reduction. #[test] fn pooled_sum_does_not_preserve_per_entity_output_rows() { - use crate::precompute_engine::operators::SumAccumulator; + use asap_physical_operators::accumulators::SumAccumulator; let config = make_agg_config( 1, "gauge", @@ -4060,7 +4060,7 @@ mod tests { // The physical compiler rejects raw counter producers until series state is preserved. #[test] fn pooled_counter_samples_lose_independent_same_timestamp_reset() { - use crate::precompute_engine::operators::IncreaseAccumulator; + use asap_physical_operators::accumulators::IncreaseAccumulator; let config = make_agg_config( 1, "requests_total", @@ -4352,9 +4352,9 @@ mod tests { #[cfg(test)] mod dag_execution_tests { use super::*; - use crate::precompute_engine::operators::exact_accumulator::ExactAccumulator; use crate::precompute_engine::output_sink::CapturingOutputSink; use crate::storage_engines::types::InstalledPrecomputePlan; + use asap_physical_operators::accumulators::exact_accumulator::ExactAccumulator; use asap_types::query_plan::ExactReadout; fn plan(query: &str) -> control_plane::physical::compiler::CompiledPhysicalPlan { 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 a49a39d0..09225868 100644 --- a/data_plane/src/query_engines/asap_clickhouse_query_engine/accelerator.rs +++ b/data_plane/src/query_engines/asap_clickhouse_query_engine/accelerator.rs @@ -1,5 +1,6 @@ //! Catalog-backed ClickHouse acceleration boundary. +use asap_physical_operators::accumulators::SumAccumulator; use async_trait::async_trait; use axum::{ body::Bytes, @@ -361,10 +362,7 @@ mod tests { } } - use crate::{ - precompute_engine::operators::SumAccumulator, - storage_engines::sketch_db::index::{AggKind, Capability, SummarySeriesMetadata}, - }; + use crate::storage_engines::sketch_db::index::{AggKind, Capability, SummarySeriesMetadata}; use asap_types::query_plan::{ ClickHousePlanningContext, ExactReadout, ExternalExactOutput, ExternalExactRequest, FallbackPolicy, FixedEvaluationRange, InstantExecution, MaterializationBinding, 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 be8be79e..a30816e8 100644 --- a/data_plane/src/query_engines/asap_query_engine/engine.rs +++ b/data_plane/src/query_engines/asap_query_engine/engine.rs @@ -1394,11 +1394,11 @@ mod sketch_query_tests { #[cfg(test)] mod aux_pushdown_tests { use super::*; - use crate::precompute_engine::operators::{ + use crate::storage_engines::types::AggregationType; + use asap_physical_operators::accumulators::{ max_accumulator::MaxAccumulator, min_accumulator::MinAccumulator, sum_accumulator::SumAccumulator, }; - use crate::storage_engines::types::AggregationType; use asap_types::Statistic; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; @@ -1613,9 +1613,9 @@ mod asap_tier_classify_tests { /// results instead of a CapabilityMiss. #[tokio::test] async fn execute_sum_by_zone_dispatches_to_exact_agg_reducer() { - use crate::precompute_engine::operators::sum_accumulator::SumAccumulator; use crate::query_engines::query_result::QueryResult; use crate::storage_engines::sketch_db::data::AggregationType; + use asap_physical_operators::accumulators::sum_accumulator::SumAccumulator; let idx = Arc::new(SketchStore::new()); // Mirror the acceptance-test setup: four ExactAgg(Sum) sids, one @@ -2267,9 +2267,9 @@ mod asap_tier_classify_tests { /// `OuterFn::Plain` instant sums. #[tokio::test] async fn execute_instant_sum_accumulates_all_windows_not_last() { - use crate::precompute_engine::operators::sum_accumulator::SumAccumulator; use crate::query_engines::query_result::QueryResult; use crate::storage_engines::sketch_db::data::AggregationType; + use asap_physical_operators::accumulators::sum_accumulator::SumAccumulator; let idx = Arc::new(SketchStore::new()); let now_ms = 600_000_u64; 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 66a5cab7..95e9006c 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 @@ -880,13 +880,13 @@ mod tests { #[tokio::test] async fn five_minute_error_ratio_combines_prometheus_cut_with_summary_store() { - use crate::precompute_engine::operators::IncreaseAccumulator; use crate::query_engines::query_result::{InstantVectorElement, QueryResult}; use crate::storage_engines::sketch_db::{ data::AggKind, index::{Capability, SummarySeriesMetadata}, }; use crate::storage_engines::types::{KeyByLabelValues, Measurement}; + use asap_physical_operators::accumulators::IncreaseAccumulator; use asap_types::query_plan::{ residual::BinaryOperation, ExactReadout, MaterializationBinding, PhysicalGrouping, }; diff --git a/data_plane/src/query_engines/asap_query_engine/live_serve.rs b/data_plane/src/query_engines/asap_query_engine/live_serve.rs index 798138cb..ee6919ca 100644 --- a/data_plane/src/query_engines/asap_query_engine/live_serve.rs +++ b/data_plane/src/query_engines/asap_query_engine/live_serve.rs @@ -170,7 +170,7 @@ mod tests { 9, BTreeMap::new(), (start, end), - Box::new(crate::precompute_engine::operators::SumAccumulator::with_sum(value)), + Box::new(asap_physical_operators::accumulators::SumAccumulator::with_sum(value)), ); } let entry = asap_types::query_plan::QueryPlanEntry { 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 4b77a5a8..29ef637f 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 @@ -790,7 +790,7 @@ mod topk_tests { // An overflowing sum cannot implement average, but zero/subnormal averages remain valid. #[test] fn finite_division_guards_temporal_average_without_rejecting_zero() { - let mut sum = crate::precompute_engine::operators::sum_accumulator::SumAccumulator::new(); + let mut sum = asap_physical_operators::accumulators::sum_accumulator::SumAccumulator::new(); sum.update(1e308); sum.update(1e308); assert!(binary( diff --git a/data_plane/src/query_engines/asap_query_engine/mod.rs b/data_plane/src/query_engines/asap_query_engine/mod.rs index b9db4172..2d8b05b5 100644 --- a/data_plane/src/query_engines/asap_query_engine/mod.rs +++ b/data_plane/src/query_engines/asap_query_engine/mod.rs @@ -6,7 +6,6 @@ pub mod engine; mod exact_subqueries; pub mod live_serve; pub mod logical_dag; -pub mod physical_dag; pub mod post_asap_readout; pub mod summary_exec; pub mod summary_executor; diff --git a/data_plane/src/query_engines/asap_query_engine/post_asap_readout.rs b/data_plane/src/query_engines/asap_query_engine/post_asap_readout.rs index b2331b40..f6265a74 100644 --- a/data_plane/src/query_engines/asap_query_engine/post_asap_readout.rs +++ b/data_plane/src/query_engines/asap_query_engine/post_asap_readout.rs @@ -2,11 +2,11 @@ use std::collections::BTreeMap; -use crate::utils::arithmetic::evaluate_float64_arithmetic as arithmetic; +use asap_physical_operators::arithmetic::evaluate_float64_arithmetic as arithmetic; use asap_types::query_plan::{QueryNodeId, QueryPlanNode}; -use crate::query_engines::asap_query_engine::physical_dag::{self, QueryNodeRuntime}; +use asap_physical_operators::query_dag::{self, QueryNodeRuntime}; /// A planned warm query cannot be served; callers may route to an exact backend. #[derive(Debug)] pub enum LoweringSkip { @@ -554,7 +554,7 @@ fn execute_physical_query_payload( allowed_materializations: None, }, }; - let output = physical_dag::execute_from(entry, root, &runtime) + let output = query_dag::execute_from(entry, root, &runtime) .map_err(|error| LoweringSkip::ExecuteFailed(format!("{error:?}")))?; match output { PhysicalQueryOutput::Scalar(_) => Err(LoweringSkip::ExecuteFailed( @@ -1050,7 +1050,7 @@ mod tests { 1, BTreeMap::new(), (1_000, 2_000), - Box::new(crate::precompute_engine::operators::SumAccumulator::with_sum(42.0)), + Box::new(asap_physical_operators::accumulators::SumAccumulator::with_sum(42.0)), ); let config = test_plan::materialization("bytes_total", "Sum", serde_json::json!({}), &[], 1000); @@ -1146,7 +1146,9 @@ mod tests { BTreeMap::new(), bounds, Box::new( - crate::precompute_engine::operators::SumAccumulator::with_sum(sum), + asap_physical_operators::accumulators::SumAccumulator::with_sum( + sum, + ), ), ); } @@ -1214,7 +1216,7 @@ mod tests { BTreeMap::new(), (pane * 60_000, (pane + 1) * 60_000), Box::new( - crate::precompute_engine::operators::SumAccumulator::with_sum( + asap_physical_operators::accumulators::SumAccumulator::with_sum( (pane + 1) as f64, ), ), @@ -1289,7 +1291,7 @@ mod tests { BTreeMap::new(), (pane * 10_000, (pane + 1) * 10_000), Box::new( - crate::precompute_engine::operators::SumAccumulator::with_sum( + asap_physical_operators::accumulators::SumAccumulator::with_sum( (pane + 1) as f64, ), ), @@ -1356,7 +1358,7 @@ mod tests { 7, BTreeMap::new(), (pane * 10_000, (pane + 1) * 10_000), - Box::new(crate::precompute_engine::operators::SumAccumulator::with_sum(1.0)), + Box::new(asap_physical_operators::accumulators::SumAccumulator::with_sum(1.0)), ); } assert!( @@ -1386,7 +1388,7 @@ mod tests { policy_fp: policy, }); use crate::storage_engines::types::Measurement; - let mut accumulator = crate::precompute_engine::operators::IncreaseAccumulator::new( + let mut accumulator = asap_physical_operators::accumulators::IncreaseAccumulator::new( Measurement::new(10.0), 10_000, Measurement::new(10.0), 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 9bf4607c..555e31bc 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 @@ -70,9 +70,6 @@ use planner_types::post_asap::{ }; use planner_types::pre_asap::{ColumnId, ColumnRef, QueryExpr, Reduction, Source}; -use crate::precompute_engine::operators::increase_accumulator::IncreaseAccumulator; -use crate::precompute_engine::operators::max_accumulator::MaxAccumulator; -use crate::precompute_engine::operators::min_accumulator::MinAccumulator; 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::{ @@ -81,6 +78,9 @@ use crate::storage_engines::sketch_db::query::delta_apply::{ use crate::storage_engines::types::{ AggregateCore, AggregationType, KeyByLabelValues, MergeableAccumulator, }; +use asap_physical_operators::accumulators::increase_accumulator::IncreaseAccumulator; +use asap_physical_operators::accumulators::max_accumulator::MaxAccumulator; +use asap_physical_operators::accumulators::min_accumulator::MinAccumulator; /// Per-query, per-call execution context — constructed fresh for each /// incoming query (never shared across concurrent queries, never @@ -251,7 +251,7 @@ impl GroupState { let planner_state = entries.iter().flat_map(|w| w.values()).any(|a| { a.as_any() - .is::() + .is::() }); // Temporal exact summaries are the hot path for long-window // dashboards. Merge their concrete, fixed-size states in one batch @@ -1441,7 +1441,7 @@ mod tests { #[test] fn keyed_count_state_follows_planner_family_and_query_readout() { - use crate::precompute_engine::operators::KeyedSumCountAccumulator; + use asap_physical_operators::accumulators::KeyedSumCountAccumulator; use asap_types::query_plan::ExactReadout; let key = KeyByLabelValues::new_with_labels(vec!["web".to_string()]); @@ -1958,9 +1958,9 @@ mod tests { /// One installed frequency summary merges panes before all four readouts. #[test] fn bound_univmon_merges_panes_for_four_readouts() { - use crate::precompute_engine::operators::univmon_accumulator::UnivMonAccumulator; use crate::storage_engines::sketch_db::index::SketchEncoding; use crate::storage_engines::types::SerializableToSink; + use asap_physical_operators::accumulators::univmon_accumulator::UnivMonAccumulator; use asap_types::query_plan::{MaterializationBinding, PhysicalGrouping}; let index = SketchStore::new(); let fp = asap_types::PolicyFingerprint(701); @@ -3190,13 +3190,13 @@ mod tests { sid, BTreeMap::new(), (T0, T0 + 1000), - Box::new(crate::precompute_engine::operators::SumAccumulator::with_sum(10.0)), + Box::new(asap_physical_operators::accumulators::SumAccumulator::with_sum(10.0)), ); idx.append_precompute( sid, BTreeMap::new(), (T0 + 1000, T0 + 2000), - Box::new(crate::precompute_engine::operators::SumAccumulator::with_sum(15.0)), + Box::new(asap_physical_operators::accumulators::SumAccumulator::with_sum(15.0)), ); let child = scan_node("bytes_total", None); @@ -3296,13 +3296,13 @@ mod tests { 1, BTreeMap::new(), (T0, T0 + 1000), - Box::new(crate::precompute_engine::operators::SumAccumulator::with_sum(30.0)), + Box::new(asap_physical_operators::accumulators::SumAccumulator::with_sum(30.0)), ); idx.append_precompute( 2, BTreeMap::new(), (T0, T0 + 1000), - Box::new(crate::precompute_engine::operators::SumAccumulator::with_sum(12.0)), + Box::new(asap_physical_operators::accumulators::SumAccumulator::with_sum(12.0)), ); let child = scan_node("bytes_total", None); @@ -3342,7 +3342,7 @@ mod tests { sid, BTreeMap::new(), (T0, T0 + 1000), - Box::new(crate::precompute_engine::operators::MaxAccumulator::new()), + Box::new(asap_physical_operators::accumulators::MaxAccumulator::new()), ); let child = scan_node("latency_max_ms", None); diff --git a/data_plane/src/storage_engines/sketch_db/backfill/processor.rs b/data_plane/src/storage_engines/sketch_db/backfill/processor.rs index 3bd65673..c680649c 100644 --- a/data_plane/src/storage_engines/sketch_db/backfill/processor.rs +++ b/data_plane/src/storage_engines/sketch_db/backfill/processor.rs @@ -608,7 +608,7 @@ mod tests { /// when given the same ordered samples. #[test] fn backfill_builds_bit_identical_sum_accumulator_to_live() { - use crate::precompute_engine::accumulator_factory::create_fixture_accumulator; + use crate::tests::accumulator_fixture::create_fixture_accumulator; let cfg = sum_config(1, "m", vec![]); @@ -980,7 +980,7 @@ mod tests { cfg.policy_fingerprint(), ); let acc = - crate::precompute_engine::operators::sum_accumulator::SumAccumulator::with_sum(1.0); + asap_physical_operators::accumulators::sum_accumulator::SumAccumulator::with_sum(1.0); let live_sid = store .ingest_precompute_for_agg_config( |_metric, attrs, _kind| { diff --git a/data_plane/src/storage_engines/sketch_db/backfill/window_builder.rs b/data_plane/src/storage_engines/sketch_db/backfill/window_builder.rs index 6bf028c9..430c59aa 100644 --- a/data_plane/src/storage_engines/sketch_db/backfill/window_builder.rs +++ b/data_plane/src/storage_engines/sketch_db/backfill/window_builder.rs @@ -4,15 +4,15 @@ //! It shares the pure accumulator factory and update primitives with live ingest //! so both paths use the same sketch semantics. -#[cfg(test)] -use crate::precompute_engine::accumulator_factory::{ - create_fixture_accumulator, AccumulatorUpdater, -}; #[cfg(test)] use crate::precompute_engine::worker::apply_sample; use crate::storage_engines::sketch_db::backfill::raw_sample_reader::RawSample; use crate::storage_engines::types::AggregateCore; #[cfg(test)] +use crate::tests::accumulator_fixture::create_fixture_accumulator; +#[cfg(test)] +use asap_physical_operators::factory::AccumulatorUpdater; +#[cfg(test)] use asap_types::aggregation_config::PrecomputeMaterialization; /// Construct the accumulator for one `(agg_id, window)` pair by @@ -86,7 +86,7 @@ mod tests { // Replay must preserve each series and rank by the selected update mode. #[test] fn backfilled_topk_preserves_series_and_weight_mode() { - use crate::precompute_engine::operators::{ + use asap_physical_operators::accumulators::{ CountMinSketchWithHeapAccumulator, CountSketchWithHeapAccumulator, }; for kind in [ diff --git a/data_plane/src/storage_engines/sketch_db/index/maintenance.rs b/data_plane/src/storage_engines/sketch_db/index/maintenance.rs index bc7ce173..d9214899 100644 --- a/data_plane/src/storage_engines/sketch_db/index/maintenance.rs +++ b/data_plane/src/storage_engines/sketch_db/index/maintenance.rs @@ -767,8 +767,8 @@ impl SketchStore { #[cfg(test)] mod tests { use super::*; - use crate::precompute_engine::operators::SumAccumulator; use crate::storage_engines::types::PrecomputedOutput; + use asap_physical_operators::accumulators::SumAccumulator; use asap_types::traits::SerializableToSink; #[test] @@ -1275,8 +1275,8 @@ mod tests { )]) ); let assert_complete_output = |store: &SketchStore| { - use crate::precompute_engine::operators::DDSketchAccumulator; use crate::storage_engines::sketch_db::data::SketchEncoding; + use asap_physical_operators::accumulators::DDSketchAccumulator; let rows = store.query_range(target_sid, 0, 60_000); assert_eq!(rows.len(), 1); assert!(rows[0].series_label_values.is_empty()); 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 a436a787..f1a00352 100644 --- a/data_plane/src/storage_engines/sketch_db/index/mod.rs +++ b/data_plane/src/storage_engines/sketch_db/index/mod.rs @@ -92,13 +92,13 @@ fn reconstruct_exact_agg( type_name: &str, bytes: &[u8], ) -> Option> { - use crate::precompute_engine::operators::{ + use crate::storage_engines::types::AggregateCore; + use asap_physical_operators::accumulators::{ IncreaseAccumulator, KeyedCounterState, KeyedSumCountAccumulator, MaxAccumulator, MinAccumulator, SumAccumulator, }; - use crate::storage_engines::types::AggregateCore; match type_name { - "PlannerExactAccumulatorV1" => crate::precompute_engine::operators::exact_accumulator::ExactAccumulator::deserialize_from_bytes(bytes).ok().map(|a|Box::new(a) as Box), + "PlannerExactAccumulatorV1" => asap_physical_operators::accumulators::exact_accumulator::ExactAccumulator::deserialize_from_bytes(bytes).ok().map(|a|Box::new(a) as Box), "SumAccumulator" => SumAccumulator::deserialize_from_bytes(bytes) .ok() .map(|a| Box::new(a) as Box), @@ -1689,12 +1689,12 @@ impl SketchStore { // string that had to agree with it. let rollup_value = payload .as_any() - .downcast_ref::() + .downcast_ref::() .map(|acc| (RollupReduction::Min, acc.value)) .or_else(|| { payload .as_any() - .downcast_ref::() + .downcast_ref::() .map(|acc| (RollupReduction::Max, acc.value)) }); let store = self @@ -4532,7 +4532,7 @@ mod tests { #[test] fn precompute_payload_round_trips_through_storage() { - use crate::precompute_engine::operators::SumAccumulator; + use asap_physical_operators::accumulators::SumAccumulator; let idx = SketchStore::new(); let cfg = SketchConfig::DDSketch { @@ -4573,7 +4573,7 @@ mod tests { #[test] fn query_precomputes_by_agg_returns_data_grouped_by_label_values() { - use crate::precompute_engine::operators::SumAccumulator; + use asap_physical_operators::accumulators::SumAccumulator; let idx = SketchStore::new(); let cfg = SketchConfig::DDSketch { @@ -4727,7 +4727,7 @@ mod tests { assert!(sketch.as_sketch().is_some()); assert!(sketch.as_exact_agg().is_none()); - use crate::precompute_engine::operators::SumAccumulator; + use asap_physical_operators::accumulators::SumAccumulator; let exact_agg = AggPayload::ExactAgg(Arc::new(SumAccumulator::with_sum(1.0))); assert!(exact_agg.as_sketch().is_none()); assert!(exact_agg.as_exact_agg().is_some()); @@ -5451,7 +5451,7 @@ mod tests { 850, BTreeMap::new(), (0, 30_000), - Box::new(crate::precompute_engine::operators::SumAccumulator::new()) + Box::new(asap_physical_operators::accumulators::SumAccumulator::new()) )); // A flusher that captured metadata before completion cannot reopen it. writer.upsert_all(&[stale_record]).unwrap(); @@ -5883,7 +5883,7 @@ mod tests { lv_zone("z0"), (s, s + 30_000), Box::new( - crate::precompute_engine::operators::SumAccumulator::with_sum( + asap_physical_operators::accumulators::SumAccumulator::with_sum( (i + 1) as f64, ), ), @@ -6102,7 +6102,7 @@ mod tests { lv_zone("z0"), (s, s + 30_000), Box::new( - crate::precompute_engine::operators::SumAccumulator::with_sum((i + 1) as f64), + asap_physical_operators::accumulators::SumAccumulator::with_sum((i + 1) as f64), ), ); } @@ -6166,7 +6166,7 @@ mod tests { lv_zone("z0"), (s, s + 30_000), Box::new({ - let mut acc = crate::precompute_engine::operators::SumAccumulator::new(); + let mut acc = asap_physical_operators::accumulators::SumAccumulator::new(); acc.update((i + 1) as f64); acc.update(10.0); acc @@ -6359,8 +6359,8 @@ mod tests { // Flush and reopen must preserve Planner family rather than reconstructing Rate as Increase. #[test] fn planner_exact_families_survive_disk_eviction_and_restart() { - use crate::precompute_engine::operators::exact_accumulator::ExactAccumulator; use crate::storage_engines::types::{AggregateCore, AggregationType}; + use asap_physical_operators::accumulators::exact_accumulator::ExactAccumulator; let kinds = [ AggregationType::Sum, AggregationType::Count, diff --git a/data_plane/src/storage_engines/sketch_db/lifecycle/eviction.rs b/data_plane/src/storage_engines/sketch_db/lifecycle/eviction.rs index d0124896..ee3e7470 100644 --- a/data_plane/src/storage_engines/sketch_db/lifecycle/eviction.rs +++ b/data_plane/src/storage_engines/sketch_db/lifecycle/eviction.rs @@ -193,8 +193,8 @@ pub fn warn_if_retention_inverted( #[cfg(test)] mod tests { use super::*; - use crate::precompute_engine::operators::SumAccumulator; use crate::storage_engines::types::{AggregationType, InstalledPrecomputePlan}; + use asap_physical_operators::accumulators::SumAccumulator; use asap_types::aggregation_config::PrecomputeMaterialization; use asap_types::enums::WindowKind; use asap_types::KeyByLabelNames; 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 f1698c2f..3d254f2c 100644 --- a/data_plane/src/storage_engines/sketch_db/query/decoders.rs +++ b/data_plane/src/storage_engines/sketch_db/query/decoders.rs @@ -24,7 +24,7 @@ use asap_sketchlib::CountSketchWithHeap; use asap_sketchlib::CsHeapItem; use asap_sketchlib::MessagePackCodec; -use crate::precompute_engine::operators::count_min_sketch_with_heap_accumulator::CountMinSketchWithHeapAccumulator; +use asap_physical_operators::accumulators::count_min_sketch_with_heap_accumulator::CountMinSketchWithHeapAccumulator; /// Decode a `CountMinSketch` from the modified-OTLP wire bytes. /// MSGPACK path round-trips `CountMinSketch::deserialize_msgpack`; 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 894b15ee..d36b6955 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 @@ -115,7 +115,7 @@ impl DeltaSketchKind { sketch_cols, layers, } => SummaryState::UnivMon( - crate::precompute_engine::operators::univmon_accumulator::UnivMonAccumulator::new( + asap_physical_operators::accumulators::univmon_accumulator::UnivMonAccumulator::new( *heap_size as usize, *sketch_rows as usize, *sketch_cols as usize, @@ -167,7 +167,7 @@ fn decode_full( }, SketchEncoding::MsgpackFull, ) => { - let state = crate::precompute_engine::operators::univmon_accumulator::UnivMonAccumulator::from_bytes(bytes) + let state = asap_physical_operators::accumulators::univmon_accumulator::UnivMonAccumulator::from_bytes(bytes) .map_err(|e| e.to_string())?; if state.dimensions() != ( @@ -244,7 +244,7 @@ fn decode_full( /// folded across a window (or several) via delta application, or merged /// in from another sid's own reconstruction. pub enum SummaryState { - UnivMon(crate::precompute_engine::operators::univmon_accumulator::UnivMonAccumulator), + UnivMon(asap_physical_operators::accumulators::univmon_accumulator::UnivMonAccumulator), Dd(DdSketch), Hll(HllSketch), Kll(KllSketch), @@ -321,7 +321,7 @@ impl SummaryState { } // Shape (2): bucket-delta proto → additive apply via the // SAME decoder the ingest delta path uses. - use crate::precompute_engine::operators::dd_sketch_accumulator::DDSketchAccumulator; + use asap_physical_operators::accumulators::dd_sketch_accumulator::DDSketchAccumulator; let mut acc = DDSketchAccumulator { inner: std::mem::replace(sk, DdSketch::new(sk.alpha)), sample_p: 1.0, @@ -740,21 +740,21 @@ pub fn per_window_summary_states( // --------------------------------------------------------------------------- fn dd_from_proto(buffer: &[u8]) -> Result { - use crate::precompute_engine::operators::dd_sketch_accumulator::DDSketchAccumulator; + use asap_physical_operators::accumulators::dd_sketch_accumulator::DDSketchAccumulator; DDSketchAccumulator::from_sketchlib_proto_bytes(buffer) .map(|acc| acc.inner) .map_err(|e| e.to_string()) } fn kll_from_proto(buffer: &[u8]) -> Result { - use crate::precompute_engine::operators::datasketches_kll_accumulator::DatasketchesKLLAccumulator; + use asap_physical_operators::accumulators::datasketches_kll_accumulator::DatasketchesKLLAccumulator; DatasketchesKLLAccumulator::from_sketchlib_proto_bytes(buffer) .map(|acc| acc.inner) .map_err(|e| e.to_string()) } fn hll_from_proto(buffer: &[u8]) -> Result { - use crate::precompute_engine::operators::hll_sketch_accumulator::HllSketchAccumulator; + use asap_physical_operators::accumulators::hll_sketch_accumulator::HllSketchAccumulator; HllSketchAccumulator::from_sketchlib_proto_bytes(buffer) .map(|acc| acc.inner) .map_err(|e| e.to_string()) @@ -910,7 +910,7 @@ mod tests { 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 crate::precompute_engine::operators::hll_sketch_accumulator::HllSketchAccumulator; + use asap_physical_operators::accumulators::hll_sketch_accumulator::HllSketchAccumulator; let mut sk = HllSketch::new(HllVariant::Regular, 12); for i in 0..500u64 { sk.update(format!("item-{i}").as_bytes()); @@ -929,7 +929,7 @@ mod tests { #[test] fn dd_from_proto_matches_accumulator_decoder() { - use crate::precompute_engine::operators::dd_sketch_accumulator::DDSketchAccumulator; + use asap_physical_operators::accumulators::dd_sketch_accumulator::DDSketchAccumulator; let mut sk = DdSketch::new(0.01); for v in [1.0, 2.0, 5.0, 5.0, 9.0, 42.0] { sk.update(v); @@ -946,7 +946,7 @@ mod tests { #[test] fn kll_from_proto_matches_accumulator_decoder() { - use crate::precompute_engine::operators::datasketches_kll_accumulator::DatasketchesKLLAccumulator; + use asap_physical_operators::accumulators::datasketches_kll_accumulator::DatasketchesKLLAccumulator; let items: Vec = (0..200).map(|i| i as f64).collect(); let bytes = encode_kll(256, &items); let via_delta = kll_from_proto(&bytes).expect("delta_apply kll decode"); diff --git a/data_plane/src/storage_engines/types/mod.rs b/data_plane/src/storage_engines/types/mod.rs index dc8e7015..f126e0db 100644 --- a/data_plane/src/storage_engines/types/mod.rs +++ b/data_plane/src/storage_engines/types/mod.rs @@ -9,20 +9,14 @@ pub mod enums; pub mod hot_reload_config; pub mod installed_precompute_plan; -pub mod key_by_label_values; -pub mod measurement; pub mod precomputed_output; pub mod storage_backend; -pub mod traits; pub use enums::*; pub use hot_reload_config::*; pub use installed_precompute_plan::*; -pub use key_by_label_values::*; -pub use measurement::*; pub use precomputed_output::*; pub use storage_backend::*; -pub use traits::*; // Cross-module re-export of asap_types data types so callers can // write `crate::storage_engines::types::PrecomputeMaterialization` instead of @@ -35,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/accumulator_fixture.rs b/data_plane/src/tests/accumulator_fixture.rs new file mode 100644 index 00000000..8c7eaac5 --- /dev/null +++ b/data_plane/src/tests/accumulator_fixture.rs @@ -0,0 +1,276 @@ +//! Config fixtures for backend integration tests; production binds Planner payloads. +use asap_physical_operators::factory::*; +use asap_physical_operators::{AggregateCore, AggregationType}; +use asap_types::{accumulator_spec::cms_params, PrecomputeMaterialization}; +use planner_types::post_asap::{ExactKind, SketchAlgorithm, SketchParams, SummaryFamilyType}; +#[cfg(test)] +/// Return `true` if `config` produces a keyed (MultipleSubpopulation) updater, +/// without allocating an updater object. +/// +/// **Contract:** this must agree with every concrete `AccumulatorUpdater::is_keyed()` +/// implementation. When a new accumulator type is added, update both here and +/// in the corresponding struct. +pub fn config_is_keyed(config: &PrecomputeMaterialization) -> bool { + config + .accumulator_spec() + .expect("valid fixture") + .grouping + .is_some() +} + +/// Top-k ranking quantity, selected by `weight_mode` or its alias `topk_weight`. +/// +/// * `value` / `sum`: sum values per key (default). +/// * `count` / `frequency` / `freq`: count occurrences per key. +#[cfg(test)] +fn topk_weight_param(config: &PrecomputeMaterialization) -> TopkWeight { + match config.sample_update_rule() { + asap_types::SampleUpdateRule::Count => TopkWeight::Count, + asap_types::SampleUpdateRule::Value { .. } + | asap_types::SampleUpdateRule::CounterDelta { .. } => TopkWeight::Value, + } +} + +#[cfg(test)] +fn topk_weight_scale_param(config: &PrecomputeMaterialization) -> f64 { + match config.sample_update_rule() { + asap_types::SampleUpdateRule::Value { scale } => scale, + asap_types::SampleUpdateRule::CounterDelta { scale } => scale, + asap_types::SampleUpdateRule::Count => 1.0, + } +} + +// --------------------------------------------------------------------------- +// Factory function +// --------------------------------------------------------------------------- + +/// Read the KLL `k` out of `SketchParams::Kll`. `accumulator_spec()` +/// always builds a `SketchKind` whose `SketchAlgorithm::Kll` is paired with +/// `SketchParams::Kll`, so the +/// other arm is unreachable from a `spec` this module builds itself. +#[cfg(test)] +fn kll_k(params: &SketchParams) -> u16 { + match params { + // Lossless: `accumulator_spec()` only ever stores a value that + // already fit in `u16` (via `kll_k_param`'s own `u16::try_from` + // fallback) widened to `u32`. + SketchParams::Kll { k } => *k as u16, + other => unreachable!( + "accumulator_spec() paired SketchAlgorithm::Kll with non-Kll params: {other:?}" + ), + } +} + +/// Read `(rows = depth, columns = width)` out of `SketchParams::Cms` or `::CountSketch` +/// — same shape, different variant per bare-sketch identity. +fn cms_dims(params: &SketchParams) -> (usize, usize) { + match params { + SketchParams::Cms { width, depth } | SketchParams::CountSketch { width, depth } => { + (*depth as usize, *width as usize) + } + other => unreachable!( + "accumulator_spec() paired SketchAlgorithm::Cms/CountSketch with unexpected params: {other:?}" + ), + } +} + +/// Read `(rows = depth, columns = width, heap_size)` out of `SketchParams::CmsWithHeap` +/// or `::CountSketchWithHeap`. +fn cms_heap_dims(params: &SketchParams) -> (usize, usize, usize) { + match params { + SketchParams::CmsWithHeap { + width, + depth, + heap_size, + } + | SketchParams::CountSketchWithHeap { + width, + depth, + heap_size, + } => (*depth as usize, *width as usize, *heap_size as usize), + other => unreachable!( + "accumulator_spec() paired a WithHeap SketchAlgorithm with unexpected params: {other:?}" + ), + } +} + +/// Read the DDSketch relative-accuracy `alpha` out of `SketchParams::DDSketch`. +#[cfg(test)] +fn ddsketch_alpha(params: &SketchParams) -> f64 { + match params { + SketchParams::DDSketch { alpha } => *alpha, + other => unreachable!( + "accumulator_spec() paired SketchAlgorithm::DDSketch with non-DDSketch params: {other:?}" + ), + } +} + +/// Construct isolated payload fixtures for kernel/storage unit tests. +/// Production execution requires a validated Planner DAG program. +#[cfg(test)] +pub fn create_fixture_accumulator( + config: &PrecomputeMaterialization, +) -> Box { + let spec = config + .accumulator_spec() + .expect("invalid isolated kernel fixture"); + + let keyed = spec.grouping.is_some(); + + match (&spec.family, keyed) { + (SummaryFamilyType::ExactAggregate(ExactKind::Sum | ExactKind::Count, _), false) => { + Box::new(SumAccumulatorUpdater::new()) + } + (SummaryFamilyType::ExactAggregate(ExactKind::Sum, _), true) => { + Box::new(KeyedSumCountAccumulatorUpdater::for_family(ExactKind::Sum)) + } + (SummaryFamilyType::ExactAggregate(ExactKind::Count, _), true) => Box::new( + KeyedSumCountAccumulatorUpdater::for_family(ExactKind::Count), + ), + + // Direction comes off the family itself now. It used to be read + // back out of `aggregation_sub_type` because Planner had one + // `MinMax` accumulator for both directions, which meant a config + // whose sub_type was lost or misspelled silently built the wrong + // extremum. + (SummaryFamilyType::ExactAggregate(ExactKind::Min, _), false) => { + Box::new(MinAccumulatorUpdater::new()) + } + (SummaryFamilyType::ExactAggregate(ExactKind::Min, _), true) => { + Box::new(KeyedMinStateUpdater::new()) + } + (SummaryFamilyType::ExactAggregate(ExactKind::Max, _), false) => { + Box::new(MaxAccumulatorUpdater::new()) + } + (SummaryFamilyType::ExactAggregate(ExactKind::Max, _), true) => { + Box::new(KeyedMaxStateUpdater::new()) + } + + (SummaryFamilyType::ExactAggregate(ExactKind::Increase | ExactKind::Rate, _), false) => { + Box::new(IncreaseAccumulatorUpdater::new()) + } + (SummaryFamilyType::ExactAggregate(ExactKind::Increase | ExactKind::Rate, _), true) => { + Box::new(KeyedCounterStateUpdater::new()) + } + + (SummaryFamilyType::Sketch(kind, _), false) + if kind.algorithm() == &SketchAlgorithm::Kll => + { + Box::new(KllAccumulatorUpdater::new(kll_k(kind.params()))) + } + // HydraKLL: `k` comes off the typed params like the unkeyed case, + // but the `(row, col)` tiling grid has no `SketchParams::Kll` + // field to live in (see `asap_types::accumulator_spec`'s module + // doc) — read it the same way bare CMS does, via `cms_params`. + (SummaryFamilyType::Sketch(kind, _), true) if kind.algorithm() == &SketchAlgorithm::Kll => { + let (row_num, col_num) = cms_params(config); + Box::new(HydraKllAccumulatorUpdater::new( + row_num, + col_num, + kll_k(kind.params()), + )) + } + + // Bare CMS: point-frequency only, min-of-rows estimator. `keyed=false` + // can't actually arise here today (no `AggregationType` resolves to + // bare Cms unkeyed — see accumulator_spec.rs), matched anyway as a + // safe default. + (SummaryFamilyType::Sketch(kind, _), _) if kind.algorithm() == &SketchAlgorithm::Cms => { + let (row_num, col_num) = cms_dims(kind.params()); + Box::new(CmsAccumulatorUpdater::new(row_num, col_num)) + } + + // CountSketch uses the median-of-signed-rows estimator. + (SummaryFamilyType::Sketch(kind, _), _) + if kind.algorithm() == &SketchAlgorithm::CountSketch => + { + let (row_num, col_num) = cms_dims(kind.params()); + Box::new(CountSketchAccumulatorUpdater::new(row_num, col_num)) + } + + // Heap-bearing top-k variant (raw-input ingest path): route to the + // real `CmsHeapAccumulatorUpdater` so the per-policy top-k heap is + // BUILT (heap-less CMS could not answer `topk(...)` — recall 0). + // Keyed by the configured group-by `aggregated_labels` (e.g. `host`), + // ranked by Σ value per key by default (`weight_mode: value`), or Σ + // count for genuine frequency-top-k (`weight_mode: count`). The OTLP + // modified-sketch path builds the heap agent-side and uses + // `SketchEnvelope` ingest, not this raw arm. + (SummaryFamilyType::Sketch(kind, _), _) + if kind.algorithm() == &SketchAlgorithm::CmsWithHeap => + { + let (row_num, col_num, heap_size) = cms_heap_dims(kind.params()); + Box::new(CmsHeapAccumulatorUpdater::with_weight_scale( + row_num, + col_num, + heap_size, + topk_weight_param(config), + topk_weight_scale_param(config), + )) + } + + // Heap-bearing CountSketch retains CountSketch estimation semantics. + (SummaryFamilyType::Sketch(kind, _), _) + if kind.algorithm() == &SketchAlgorithm::CountSketchWithHeap => + { + let (row_num, col_num, heap_size) = cms_heap_dims(kind.params()); + Box::new(CountSketchWithHeapAccumulatorUpdater::with_weight_scale( + row_num, + col_num, + heap_size, + topk_weight_param(config), + topk_weight_scale_param(config), + )) + } + + (SummaryFamilyType::Sketch(kind, _), _) + if kind.algorithm() == &SketchAlgorithm::DDSketch => + { + Box::new(DDSketchAccumulatorUpdater::new(ddsketch_alpha( + kind.params(), + ))) + } + + (SummaryFamilyType::Sketch(kind, _), false) + if kind.algorithm() == &SketchAlgorithm::UnivMon => + { + let SketchParams::UnivMon { + heap_size, + sketch_rows, + sketch_cols, + layers, + } = kind.params() + else { + unreachable!("validated UnivMon family parameters") + }; + asap_physical_operators::factory::create_planner_accumulator( + &spec.family, + &planner_types::post_asap::SummaryUpdate::column( + planner_types::pre_asap::ColumnRef::SampleValue, + ), + &Default::default(), + ) + .unwrap() + } + + (SummaryFamilyType::Sketch(kind, _), false) + if kind.algorithm() == &SketchAlgorithm::Hll => + { + let SketchParams::Hll { precision } = kind.params() else { + unreachable!("validated HLL family parameters") + }; + asap_physical_operators::factory::create_planner_accumulator( + &spec.family, + &planner_types::post_asap::SummaryUpdate::column( + planner_types::pre_asap::ColumnRef::SampleValue, + ), + &Default::default(), + ) + .unwrap() + } + + (other_family, keyed) => { + panic!("unsupported isolated kernel fixture {other_family:?}, keyed={keyed}") + } + } +} diff --git a/data_plane/src/tests/mod.rs b/data_plane/src/tests/mod.rs index a6d76926..e016a48f 100644 --- a/data_plane/src/tests/mod.rs +++ b/data_plane/src/tests/mod.rs @@ -5,3 +5,5 @@ pub mod trait_design_tests; #[cfg(test)] pub mod test_utilities; + +pub mod accumulator_fixture; diff --git a/data_plane/src/tests/trait_design_tests.rs b/data_plane/src/tests/trait_design_tests.rs index a10cd3d1..b5b80a76 100644 --- a/data_plane/src/tests/trait_design_tests.rs +++ b/data_plane/src/tests/trait_design_tests.rs @@ -1,8 +1,8 @@ -use crate::precompute_engine::operators::{KeyedSumCountAccumulator, SumAccumulator}; #[cfg(test)] use crate::storage_engines::types::{ KeyByLabelValues, MultipleSubpopulationAggregate, SingleSubpopulationAggregate, }; +use asap_physical_operators::accumulators::{KeyedSumCountAccumulator, SumAccumulator}; use asap_types::Statistic; #[test] diff --git a/data_plane/src/utils/mod.rs b/data_plane/src/utils/mod.rs index ec6e20c6..18e84ff2 100644 --- a/data_plane/src/utils/mod.rs +++ b/data_plane/src/utils/mod.rs @@ -1,4 +1,3 @@ -pub(crate) mod arithmetic; pub mod http; pub use http::*; diff --git a/data_plane/tests/edge_sketch_codec.rs b/data_plane/tests/edge_sketch_codec.rs index 4f94d9fe..0055124e 100644 --- a/data_plane/tests/edge_sketch_codec.rs +++ b/data_plane/tests/edge_sketch_codec.rs @@ -35,7 +35,7 @@ fn ddsketch_bare_state_is_rejected_and_envelope_supports_query_readout() { let bare = prost::Message::encode_to_vec(&state); assert!(asap_sketch_codec::reconstruct_ddsketch(&bare).is_err()); let (decoded, _) = asap_sketch_codec::reconstruct_ddsketch(&envelope).unwrap(); - let accumulator = data_plane::precompute_engine::operators::DDSketchAccumulator { + let accumulator = asap_physical_operators::accumulators::DDSketchAccumulator { inner: decoded, sample_p: 1.0, }; @@ -62,7 +62,7 @@ fn kll_envelope_keeps_level_layout_for_backend_readout() { assert_eq!(state.k, 200); assert_eq!(state.items.len(), 50); let snapshot_bytes = bytes; - let accumulator = data_plane::precompute_engine::operators::DatasketchesKLLAccumulator::from_sketchlib_proto_bytes(&snapshot_bytes).unwrap(); + let accumulator = asap_physical_operators::accumulators::DatasketchesKLLAccumulator::from_sketchlib_proto_bytes(&snapshot_bytes).unwrap(); assert!(accumulator.get_quantile(0.5).is_finite()); } diff --git a/data_plane/tests/support/univmon_erp_process.rs b/data_plane/tests/support/univmon_erp_process.rs index 28bb7b01..f9eecb4e 100644 --- a/data_plane/tests/support/univmon_erp_process.rs +++ b/data_plane/tests/support/univmon_erp_process.rs @@ -1,6 +1,6 @@ use super::*; +use asap_physical_operators::accumulators::univmon_accumulator::UnivMonAccumulator; use control_plane::physical::erp::ErpShapeObserver; -use data_plane::precompute_engine::operators::univmon_accumulator::UnivMonAccumulator; use data_plane::storage_engines::types::{AggregateCore, SerializableToSink}; fn values(offset: usize) -> Vec { diff --git a/docs/design_docs/query-dag-execution.md b/docs/design_docs/query-dag-execution.md index 20cf814a..a5ae11df 100644 --- a/docs/design_docs/query-dag-execution.md +++ b/docs/design_docs/query-dag-execution.md @@ -54,6 +54,35 @@ All adapters start from a `QueryPlanEntry` node. The language adapters only represent different runtime value types. They cannot select a replacement definition or reconstruct an operator from the request text. +## Shared physical operator library + +`crates/asap-physical-operators` owns the concrete accumulator kernels, typed +state/update traits, Planner-family factory, scalar arithmetic, and installed +QueryPlan DAG traversal. Both maintenance and query execution import this crate +directly; the old data-plane operator/factory modules are removed. A deployment +such as asap-fusion can depend on the library without importing `data_plane` or +`control_plane`, and without taking a dependency on this backend's Arrow version. + +The compiler calls the library's allocation-free `validate_summary_kernel` +when binding a `SummaryAgg`. Runtime construction uses the same validation. +Unsupported family/layout combinations and invalid parameters are rejected +before the accumulator runs. This is a summary-kernel capability check, not a +claim that every Planner payload has a complete local implementation. + +Kernels do not own execution placement. Their state can be constructed during +maintenance or during a query, and the same merge/readout implementation handles +raw-only, partially precomputed and fully precomputed inputs. The independent +library integration test exercises these three boundaries with KLL. This test +checks operator reuse; it does not claim that the backend's currently forbidden +raw Scan has become an installed query source. + +Storage reads, population/window selection, expression-to-update evaluation, +transport, language result adaptation and scheduling policy remain deployment +responsibilities. In particular, Planner's current maintenance-only summary +placement still limits which query-time summary DAGs can be exported. Completing +that contract requires Planner placement support and backend raw-source binding; +classifying an enum variant is not proof of local executability. + ## Planner physical-operator coverage The backend pins one ASAPPlanner `main` commit and treats its exported From 1a3af01f96942a8fc7e084312a62defafc501074 Mon Sep 17 00:00:00 2001 From: zz_y Date: Wed, 23 Sep 2026 12:42:02 +0000 Subject: [PATCH 08/26] test: include shared physical kernels in the contracts suite --- scripts/e2e.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 2600999f..19878aa6 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -81,6 +81,10 @@ contracts() { say "contracts: shared policy and routing types" rust_test asap_types + CURRENT_STAGE="contracts/asap-physical-operators" + say "contracts: shared physical kernels and deployment-independent execution" + rust_test asap-physical-operators + CURRENT_STAGE="contracts/asap_otel_proto" say "contracts: modified OTLP and monitor protobuf compatibility" rust_test asap_otel_proto --tests From dccbe1827ded2e2b58dd7d5e69bbe8419980808d Mon Sep 17 00:00:00 2001 From: zz_y Date: Wed, 23 Sep 2026 12:45:07 +0000 Subject: [PATCH 09/26] fix: validate native kernel dimensions independently of packed codecs --- .../asap-physical-operators/src/capability.rs | 14 +++++---- .../tests/deployment.rs | 31 +++++++++++++++++++ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/crates/asap-physical-operators/src/capability.rs b/crates/asap-physical-operators/src/capability.rs index 42e0deed..fdd2fee7 100644 --- a/crates/asap-physical-operators/src/capability.rs +++ b/crates/asap-physical-operators/src/capability.rs @@ -104,10 +104,12 @@ pub fn validate_summary_kernel( } fn valid_matrix(width: u32, depth: u32) -> bool { - crate::accumulators::count_min_sketch_accumulator::validate_sketch_dims( - "Planner kernel", - depth as usize, - width as usize, - ) - .is_ok() + // Construction uses the kernel's native row hashing. Packed-wire decoder + // limits describe a different representation and must not reject it here. + width > 0 + && depth > 0 + && (width as usize) + .checked_mul(depth as usize) + .and_then(|n| n.checked_mul(std::mem::size_of::())) + .is_some() } diff --git a/crates/asap-physical-operators/tests/deployment.rs b/crates/asap-physical-operators/tests/deployment.rs index 980a5c68..ffdd3aea 100644 --- a/crates/asap-physical-operators/tests/deployment.rs +++ b/crates/asap-physical-operators/tests/deployment.rs @@ -63,3 +63,34 @@ fn invalid_kll_parameters_are_rejected_at_binding() { ); assert!(result.is_err()); } + +// Native CountSketch supports the confidence-sized depth used by the backend; +// a packed-wire column-bit budget must not be imposed on this constructor. +#[test] +fn native_count_sketch_dimensions_are_not_packed_wire_dimensions() { + use asap_physical_operators::planner::post_asap::SummaryInputExpr; + use asap_physical_operators::KeyByLabelValues; + let family = SummaryFamilyType::Sketch( + SketchKind::new( + SketchAlgorithm::CountSketchWithHeap, + SketchParams::CountSketchWithHeap { + width: 1200, + depth: 55, + heap_size: 3, + }, + ), + Default::default(), + ); + let mut update = SummaryUpdate::column(ColumnRef::SampleValue); + update.item = Some(SummaryInputExpr::Column(ColumnRef::Named("host".into()))); + let mut operator = create_planner_accumulator(&family, &update, &Default::default()).unwrap(); + let key = KeyByLabelValues::new_with_labels(vec!["a".into()]); + operator.update_keyed(&key, 7.0, 1000); + let state = operator.into_accumulator(); + assert_eq!( + state + .query_statistic(Statistic::Sum, &Some(key), &Default::default()) + .unwrap(), + 7.0 + ); +} From c550822a9c571169eb37a0ec9d7c74a51c3e7728 Mon Sep 17 00:00:00 2001 From: zz_y Date: Wed, 23 Sep 2026 13:33:18 +0000 Subject: [PATCH 10/26] docs: specify physical operator coverage and execution gaps --- docs/design_docs/query-dag-execution.md | 178 ++++++++++++++++++++---- 1 file changed, 152 insertions(+), 26 deletions(-) diff --git a/docs/design_docs/query-dag-execution.md b/docs/design_docs/query-dag-execution.md index a5ae11df..a623e01a 100644 --- a/docs/design_docs/query-dag-execution.md +++ b/docs/design_docs/query-dag-execution.md @@ -83,33 +83,159 @@ placement still limits which query-time summary DAGs can be exported. Completing that contract requires Planner placement support and backend raw-source binding; classifying an enum variant is not proof of local executability. -## Planner physical-operator coverage - -The backend pins one ASAPPlanner `main` commit and treats its exported -`ExecutableOperatorPayload` enum as the exhaustive physical-operator contract. -Execution follows the `ExecutionDataState` assigned by Planner; a query engine -must not replay a maintenance operator while serving a request. - -| Planner payload | Planner phase | Backend execution | +## Physical operator coverage and acceptance contract + +Coverage is recorded against backend `dccbe182` and pinned Planner +`cd7e9e0f710816d49190dabd6c789359067a208f`, not an unspecified Planner main. +**This PR does not yet meet the universal local-execution contract.** +An exhaustive phase match proves ownership only. A reusable kernel proves an +algorithm implementation exists; neither proves that a concrete installed plan +can obtain its inputs and execute every node locally. + +The target contract is: compilation accepts a local query plan only if every +reachable operator has a concrete implementation for its parameters, input and +output schemas/states, grouping, time scope and placement. Raw-only, partial +precomputation and full precomputation must obey the same contract. An explicitly +external plan may remain useful, but is not evidence of local coverage. + +### All Planner executable payloads + +“Backend” below describes implemented paths and their restrictions, not a promise +that every instance of the payload is accepted. Shared kernels do not include +backend storage, relational adapters or arbitrary expression evaluation. + +| Payload | Current placement | Backend implementation / limitation | Shared library | +| --- | --- | --- | --- | +| `Fallback` | From validated edge state | Selected ingestion source boundary, prepared external exact subtree, or explicit whole-query fallback. No general local raw-query executor. | No source/SQL/PromQL executor | +| `Binary` | Maintenance or read, from timing | Maintenance arithmetic requires immutable completed, aligned row inputs; query scalar/vector arithmetic uses the relevant value adapter. Not arbitrary row/vector coercion. | Float64 Add/Sub/Mul/Div/Mod/Pow/Atan2; alignment and vector semantics remain backend-local | +| `CandidateTopK` | Read | Membership sidecar plus authoritative exact values and grouped reranking. Exact values may require an external leaf; candidate membership alone is not a complete exact answer. | No candidate/vector adapter | +| `Value` | From timing | Operation-specific subset; see next table. | Exact accumulator kernels only; no general Value dispatcher | +| `RelationalJoin` | From row edge state | Query ClickHouse relation adapter implements inner/left/right/full/cross/semi/anti joins within supported predicate/schema/value semantics. No generic maintenance join implementation. This is not a claim of all ClickHouse settings/NULL semantics. | Not extracted | +| `SummaryAgg` | Maintenance | Raw-ingestion specialization and restricted maintenance row-to-state aggregation. Factory validates family/layout/parameters. Maintenance DAG path needs a typed update evaluator, immutable inputs and installed materialization; it does not support arbitrary item expressions or output populations. No installed query-time builder. | Construction/update kernels for families below; placement-neutral | +| `SummaryJoin` | Maintenance | Ownership classified; no dispatch implementation in maintenance runtime. | No registered SummaryJoin kernel | +| `SummarySubtract` | Maintenance | Ownership classified; unsupported by maintenance runtime. | No registered SummarySubtract kernel | +| `SummaryDelete` | Maintenance | Ownership classified; no dispatch implementation in maintenance runtime. | No registered SummaryDelete kernel | +| `SummaryEstimate` | Read | Typed sketch readout over compatible stored states, with family, window and population restrictions. | Underlying sketch query kernels; store/readout adapter remains backend-local | +| `SummaryMerge` | Maintenance in Planner | Maintenance state merge is implemented. Separately, installed QueryPlan SummaryMerge merges compatible stored states at query time. That read adapter does not make Planner summary construction query-placeable. | Accumulator merge implementations; no universal cross-family merge | + +### Every ValueOperation + +| Operation | Implemented path | Limits / missing coverage | +| --- | --- | --- | +| `MaintainPopulation` | Specialized remote-write current-series maintenance | Not a general table-row update executor; not shared-library functionality | +| `ReadPopulation` | Compiled current-series readout with installed identity/capacity | Specialized maintained population, not arbitrary raw Scan | +| `Exact(Aggregate)` | Relation adapter and supported logical aggregate lowering | Relation measures Count/Sum/Avg/Min/Max; numeric Sum/Avg/Min/Max require non-null Int64/Float64 and finite valid values. Per-entity reduction and grouping-without unsupported there. No universal AggIntent implementation | +| `FinalizeExactAccumulator` | Typed exact readout; maintenance finalization of immutable completed windows | Supported exact families below; not an arbitrary state conversion | +| `Project` | Query relation adapter | Supported expression/value subset; no general maintenance implementation | +| `Filter` | Query relation predicate adapter | Supported expression/value subset; no general maintenance implementation | +| `Sort` | Query relation adapter; supported logical sorting | Relation partitioned sorting, NaN or unsupported sort-key types rejected | +| `Limit` | Query relation adapter with offset; specialized logical lowering | Not a generic maintenance operator | +| `Extension` | No general executor | Unsupported value operations can lower to explicit ExactFallback; this is not local support | + +### Summary-family and readout coverage + +The Planner-family factory accepts only `PerSubpopulationInstance` grouping and +matching family/parameter variants. The presence of a low-level accumulator does +not automatically register a Planner binding. Supported kernels expose update, +compatible-state merge and family-specific query operations; decoding, window +coverage and readout compatibility still require the backend adapter. + +| Family / algorithm | Factory admission | Intended readout and restrictions | +| --- | --- | --- | +| Exact Sum, Count, Min, Max | Supported matching ExactParams | Corresponding exact readout; keyed/scalar update shape must match | +| Exact Increase, Rate | Supported matching ExactParams | Counter/time-aware readout; not plain scalar sum/division semantics | +| Exact IRate | Unsupported | No matching factory/readout binding | +| KLL | k in 8..65535 | Quantile | +| DDSketch | finite 0 < alpha < 1 | Quantile; backend continuous-percentile adapter uses interpolated readout | +| HLL | precision 4..18, local Regular HLL implementation | Cardinality; kernel availability does not supply an accuracy/failure-probability proof | +| CMS, CountSketch | Positive width/depth, checked allocation size | PointCount: supported key/value shape or sample-total readout; no heap TopK | +| CMSWithHeap, CountSketchWithHeap | Same matrix checks plus positive heap size | PointCount and ranked heap TopK; approximate membership is not guaranteed complete exact TopK | +| UnivMon | Positive heap/columns, rows 1..20, layers 1..64, checked dimensions | Cardinality, FrequencyL2, FrequencyEntropy and sample-total PointCount; no general keyed readout | +| KMV, Theta | Unsupported | Planner algorithm existence is not runtime support | +| Plain, Sample, Wavelet, StatModel | Not summary-factory kernels | Plain rows may be relation values, not a SummaryAgg accumulator | +| Shared grouping / Hydra KLL | Not admitted by current Planner-family factory | Low-level Hydra code exists; no installed shared-grouping coverage claim | + +All six SketchQuery variants are accounted for: Quantile, Cardinality, +PointCount, TopK, FrequencyL2 and FrequencyEntropy. They are family-specific, +not a Cartesian product with every sketch. PointCount requires the supported +SampleValue/None or named/qualified-key/Some(value) shape; heapless sketches +cannot enumerate TopK. Native matrix construction checks are distinct from +packed-wire decoder limits. The SummaryAgg capability check does not validate +all subsequent readout combinations or certify approximation guarantees. + +### Installed QueryPlan and residual coverage + +| Installed node(s) | Local execution status | +| --- | --- | +| Scalar, Binary, ReduceSum | Implemented scalar/grouped value paths | +| ReadMaterialization | Bound catalog/store read; requires available compatible population/windows | +| SummaryEstimate, ExactReadout, SummaryMerge | Implemented for supported typed states/readouts; not raw-source construction | +| CandidateTopK | Backend reranking adapter; exact input must actually be available | +| Relational, RelationalJoin | Backend ClickHouse value adapter subset described above; not in shared library | +| Logical | Residual operator subset listed below | +| ExternalExact | Declared external computation, possibly dependent on candidates; not local coverage | +| ExactFallback | Deliberate failure handed to installed fallback policy; not an implementation | + +Residual operator inventory: + +- `CurrentSeries`: local read of an installed maintained population. +- `ExactSubquery`, `CandidateExactSubquery`: prepared external exact results. +- `Scan`: explicitly rejected in deployed plans (`local raw Scan is forbidden`). +- `UnaryNegate`, `VectorToScalar`: implemented typed scalar/vector operations. +- `Aggregate`: Sum, Max, Min, Avg, Count; `TopKSelection`: grouped value ranking. +- `Binary`: Add/Sub/Mul/Div/CheckedDiv/FiniteDiv/Mod/Pow and + Equal/NotEqual/Less/LessEqual/Greater/GreaterEqual; typed matching and domain + restrictions apply, not arbitrary PromQL binary syntax. +- `Temporal`: Rate, Increase, Avg, Max, Min, Sum, Count over supported inputs. +- `Sort`, `HistogramQuantile`, `Subquery`: implemented residual paths; subqueries + require bounded time grids and memoization by node and evaluation time. + +The shared synchronous/asynchronous DAG walker schedules and memoizes nodes. It +requires a runtime adapter; it is not an implementation of the entire inventory. +Relational and PromQL adapters remain in data_plane, so asap-fusion cannot yet +obtain a complete query engine by importing the shared crate alone. + +### Precomputation boundary: present status and required acceptance + +| Plan placement | Present evidence | Remaining requirement | | --- | --- | --- | -| `Fallback` | Maintenance or query, from its validated edge state | Precompute input adapter, prepared `ExternalExact` leaf, or the entry's explicit whole-query fallback policy | -| `Binary` | Maintenance or query, from `timing` | Maintenance runtime for `MaintenanceTime`; scalar/vector query operator for `ReadTime` | -| `CandidateTopK` | Query | Candidate membership plus authoritative exact values, followed by grouped reranking | -| `Value` | Maintenance or query, from `timing` | Maintenance population/update adapter, or query adapters for population readout, exact aggregate/finalization, projection, filter, sort and limit | -| `RelationalJoin` | Maintenance or query rows, from its validated edge state | Precompute row adapter or ClickHouse relation adapter for inner, left, right, full, cross, semi and anti joins | -| `SummaryAgg` | Maintenance | Precompute DAG operator ending at a stored-output boundary | -| `SummaryJoin` | Maintenance | Precompute DAG operator | -| `SummarySubtract` | Maintenance | Precompute DAG operator | -| `SummaryDelete` | Maintenance | Precompute DAG operator | -| `SummaryEstimate` | Query | Bound sketch readout | -| `SummaryMerge` | Maintenance state | Precompute DAG operator; the QueryPlan state-merge node remains a physical read adapter for previously stored panes | - -The compiler either binds every query-phase node to a `QueryPlanNode`, absorbs -an explicit boundary such as exact-accumulator finalization into its typed -readout, or emits an exact node with a declared fallback policy. Unknown -extensions and invalid phase crossings fail during compilation or installation. -The match sites and coverage tests are exhaustive so a new Planner enum variant -causes a backend compile failure until its phase and runtime adapter are chosen. +| Raw only | Independent KLL consumer constructs and queries state directly | Backend local raw source plus query-time summary construction/lowering; currently not supported as a general installed query plan | +| Partially precomputed | KLL consumer merges prebuilt prefix with query-built suffix; backend can combine supported stored and residual nodes | General stored-state + raw-suffix query DAG, typed update evaluation, compatible scope/merge checks and process acceptance | +| Fully precomputed | Backend stored read/merge/readout paths and process tests | Valid only for supported family/schema/window/operator combinations; storage readiness remains a runtime requirement | + +To complete the requested contract, Planner must express valid query-time summary +placement; the backend must bind local raw inputs and query-time builders; and +installation must check every reachable operator against concrete adapter +capabilities, including expressions, family/readout combinations and edge states. +SummaryJoin/Subtract/Delete require actual defined implementations or explicit +compile-time rejection in local plans. External execution must be declared as a +different deployment capability, not silently counted as local support. + +Acceptance must execute the same supported query under all three placements with +external forwarding disabled, check results against the raw exact computation +(and declared approximation guarantees where applicable), and reject unsupported +operators/parameters at installation. Include grouped/temporal, empty/missing +window, mixed-state compatibility and query-time summary cases. No percentage +coverage or universal executability is claimed until these tests exist and pass. + +### Evidence and verification limits + +Source map (paths relative to repository root): + +- `control_plane/src/physical/executable_binding.rs`: phase ownership and SummaryAgg admission; ownership is not whole-graph capability validation. +- `control_plane/src/query_plan.rs`, `control_plane/src/physical/maintained_population.rs`: lowering and specialized population handling. +- `crates/asap-physical-operators/src/capability.rs`, `factory.rs`, `query_dag.rs`: shared admission, construction and adapter-driven scheduling. +- `data_plane/src/precompute_engine/raw_dag.rs`, `maintenance_runtime.rs`: raw specialization, implemented maintenance dispatch and unsupported branches. +- `data_plane/src/query_engines/asap_query_engine/{post_asap_readout,logical_dag,summary_executor,exact_subqueries}.rs`: query adapters and raw/external boundaries. +- `data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter.rs` and its `aggregate.rs`: relation subset and rejection conditions. +- `crates/asap_types/src/query_plan.rs` and `query_plan/residual.rs`: complete installed node/operator inventory. + +Shared-library tests cover kernels, a KLL three-boundary consumer, invalid KLL +parameters and native CountSketch dimensions. Backend tests cover supported DAG, +maintenance, readout and relation paths. Passing these suites is not a proof that +every Planner payload or parameter combination is locally executable. Inherited +level-1 grouped-Sum/quantile-ratio failures and #759's strict local-execution gate +remain unresolved; external exact success does not satisfy that gate. ## StoredSummary reads From 935ffe0fe1f0d2628de3b6885affdebc3897f83c Mon Sep 17 00:00:00 2001 From: zz_y Date: Wed, 23 Sep 2026 13:54:49 +0000 Subject: [PATCH 11/26] docs: clarify Planner execution phase terminology --- docs/design_docs/query-dag-execution.md | 33 ++++++++++++++++--------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/docs/design_docs/query-dag-execution.md b/docs/design_docs/query-dag-execution.md index a623e01a..b63c9eb7 100644 --- a/docs/design_docs/query-dag-execution.md +++ b/docs/design_docs/query-dag-execution.md @@ -104,19 +104,28 @@ external plan may remain useful, but is not evidence of local coverage. that every instance of the payload is accepted. Shared kernels do not include backend storage, relational adapters or arbitrary expression evaluation. -| Payload | Current placement | Backend implementation / limitation | Shared library | +| Payload | Current Planner execution phase(当前 Planner 执行阶段) | Backend implementation / limitation | Shared library | | --- | --- | --- | --- | -| `Fallback` | From validated edge state | Selected ingestion source boundary, prepared external exact subtree, or explicit whole-query fallback. No general local raw-query executor. | No source/SQL/PromQL executor | -| `Binary` | Maintenance or read, from timing | Maintenance arithmetic requires immutable completed, aligned row inputs; query scalar/vector arithmetic uses the relevant value adapter. Not arbitrary row/vector coercion. | Float64 Add/Sub/Mul/Div/Mod/Pow/Atan2; alignment and vector semantics remain backend-local | -| `CandidateTopK` | Read | Membership sidecar plus authoritative exact values and grouped reranking. Exact values may require an external leaf; candidate membership alone is not a complete exact answer. | No candidate/vector adapter | -| `Value` | From timing | Operation-specific subset; see next table. | Exact accumulator kernels only; no general Value dispatcher | -| `RelationalJoin` | From row edge state | Query ClickHouse relation adapter implements inner/left/right/full/cross/semi/anti joins within supported predicate/schema/value semantics. No generic maintenance join implementation. This is not a claim of all ClickHouse settings/NULL semantics. | Not extracted | -| `SummaryAgg` | Maintenance | Raw-ingestion specialization and restricted maintenance row-to-state aggregation. Factory validates family/layout/parameters. Maintenance DAG path needs a typed update evaluator, immutable inputs and installed materialization; it does not support arbitrary item expressions or output populations. No installed query-time builder. | Construction/update kernels for families below; placement-neutral | -| `SummaryJoin` | Maintenance | Ownership classified; no dispatch implementation in maintenance runtime. | No registered SummaryJoin kernel | -| `SummarySubtract` | Maintenance | Ownership classified; unsupported by maintenance runtime. | No registered SummarySubtract kernel | -| `SummaryDelete` | Maintenance | Ownership classified; no dispatch implementation in maintenance runtime. | No registered SummaryDelete kernel | -| `SummaryEstimate` | Read | Typed sketch readout over compatible stored states, with family, window and population restrictions. | Underlying sketch query kernels; store/readout adapter remains backend-local | -| `SummaryMerge` | Maintenance in Planner | Maintenance state merge is implemented. Separately, installed QueryPlan SummaryMerge merges compatible stored states at query time. That read adapter does not make Planner summary construction query-placeable. | Accumulator merge implementations; no universal cross-family merge | +| `Fallback` | Depends on the neighbor nodes in the DAG (validated edge states) | Selected ingestion source boundary, prepared external exact subtree, or explicit whole-query fallback. No general local raw-query executor. | No source/SQL/PromQL executor | +| `Binary` | Ingestion time or read/query time (explicit `timing`, validated against DAG edges) | Maintenance arithmetic requires immutable completed, aligned row inputs; query scalar/vector arithmetic uses the relevant value adapter. Not arbitrary row/vector coercion. | Float64 Add/Sub/Mul/Div/Mod/Pow/Atan2; alignment and vector semantics remain backend-local | +| `CandidateTopK` | Read/query time | Membership sidecar plus authoritative exact values and grouped reranking. Exact values may require an external leaf; candidate membership alone is not a complete exact answer. | No candidate/vector adapter | +| `Value` | Ingestion time or read/query time (explicit `timing`, validated against DAG edges) | Operation-specific subset; see next table. | Exact accumulator kernels only; no general Value dispatcher | +| `RelationalJoin` | Depends on the neighbor nodes in the DAG (validated row edge states) | Query ClickHouse relation adapter implements inner/left/right/full/cross/semi/anti joins within supported predicate/schema/value semantics. No generic maintenance join implementation. This is not a claim of all ClickHouse settings/NULL semantics. | Not extracted | +| `SummaryAgg` | Ingestion time | Raw-ingestion specialization and restricted maintenance row-to-state aggregation. Factory validates family/layout/parameters. Maintenance DAG path needs a typed update evaluator, immutable inputs and installed materialization; it does not support arbitrary item expressions or output populations. No installed query-time builder. | Construction/update kernels for families below; placement-neutral | +| `SummaryJoin` | Ingestion time | Ownership classified; no dispatch implementation in maintenance runtime. | No registered SummaryJoin kernel | +| `SummarySubtract` | Ingestion time | Ownership classified; unsupported by maintenance runtime. | No registered SummarySubtract kernel | +| `SummaryDelete` | Ingestion time | Ownership classified; no dispatch implementation in maintenance runtime. | No registered SummaryDelete kernel | +| `SummaryEstimate` | Read/query time | Typed sketch readout over compatible stored states, with family, window and population restrictions. | Underlying sketch query kernels; store/readout adapter remains backend-local | +| `SummaryMerge` | Ingestion time in Planner | Maintenance state merge is implemented. Separately, installed QueryPlan SummaryMerge merges compatible stored states at query time. That read adapter does not make Planner summary construction query-placeable. | Accumulator merge implementations; no universal cross-family merge | + +In this column, **ingestion time** includes ingestion-triggered background +maintenance; it does not require execution inline with each incoming sample. +The current API still names this phase `MaintenanceTime`. **Read/query time** +means execution while serving a query. “Depends on the neighbor nodes in the +DAG” refers to validated input/output edge states, not an unconstrained runtime +choice. `Binary` and `Value` carry explicit `timing`, so their phase is not +inferred solely from neighboring nodes. These labels describe the current +Planner contract, not whether a backend implementation exists. ### Every ValueOperation From f8fbaaa968b11eaa9ecfa46d6a02338771f45158 Mon Sep 17 00:00:00 2001 From: zz_y Date: Wed, 23 Sep 2026 14:13:19 +0000 Subject: [PATCH 12/26] refactor!: compose membership filtering and TopK with ingestion and query phases --- Cargo.lock | 10 +- Cargo.toml | 8 +- .../examples/calibration_candidates.rs | 10 +- .../examples/offline_planner_replay.rs | 4 +- control_plane/src/emit/mod.rs | 4 +- control_plane/src/physical/compiler.rs | 49 +++--- .../src/physical/executable_binding.rs | 15 +- control_plane/src/physical/plan_dot.rs | 2 +- .../src/physical/post_asap/cost_model.rs | 4 +- control_plane/src/physical/post_asap/tests.rs | 4 +- control_plane/src/query_plan.rs | 73 ++------- control_plane/src/query_plan/residual.rs | 56 +++++-- control_plane/tests/offline_evidence.rs | 2 +- crates/asap-physical-operators/README.md | 9 ++ crates/asap-physical-operators/src/lib.rs | 2 + crates/asap-physical-operators/src/rows.rs | 88 +++++++++++ crates/asap_types/src/derived_input.rs | 2 +- crates/asap_types/src/executable_plan.rs | 15 +- crates/asap_types/src/precompute_plan.rs | 12 +- crates/asap_types/src/query_plan.rs | 24 +-- .../precompute_engine/maintenance_runtime.rs | 43 +++--- .../src/precompute_engine/subdag_scheduler.rs | 23 ++- .../query_engines/asap_query_engine/engine.rs | 2 +- .../asap_query_engine/exact_subqueries.rs | 30 ++-- .../asap_query_engine/logical_dag.rs | 141 ++++++++---------- .../asap_query_engine/post_asap_readout.rs | 2 +- .../asap_query_engine/summary_exec.rs | 13 +- .../asap_query_engine/summary_executor.rs | 2 +- docs/design_docs/query-dag-execution.md | 81 ++++++---- .../compiler-rule-boundary-review.md | 1 - tools/o11y-execution/calibrate_runtime.py | 42 +++--- .../o11y-execution/test_calibrate_runtime.py | 42 +++--- 32 files changed, 469 insertions(+), 346 deletions(-) create mode 100644 crates/asap-physical-operators/src/rows.rs diff --git a/Cargo.lock b/Cargo.lock index 2ad92579..a2893858 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -364,7 +364,7 @@ dependencies = [ [[package]] name = "asap-aware-mapping" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=cd7e9e0f710816d49190dabd6c789359067a208f#cd7e9e0f710816d49190dabd6c789359067a208f" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=09129074c1894f313b98764dd0400ecd73334a2d#09129074c1894f313b98764dd0400ecd73334a2d" dependencies = [ "asap-types", "asap_sketchlib 0.3.0 (git+https://github.com/ProjectASAP/asap_sketchlib)", @@ -376,7 +376,7 @@ dependencies = [ [[package]] name = "asap-frontend-promql" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=cd7e9e0f710816d49190dabd6c789359067a208f#cd7e9e0f710816d49190dabd6c789359067a208f" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=09129074c1894f313b98764dd0400ecd73334a2d#09129074c1894f313b98764dd0400ecd73334a2d" dependencies = [ "asap-types", "promql-parser 0.10.0 (git+https://github.com/ProjectASAP/promql-parser?rev=9fede7eecca923c9882fe256484d00d37f8706cb)", @@ -385,7 +385,7 @@ dependencies = [ [[package]] name = "asap-frontend-sql" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=cd7e9e0f710816d49190dabd6c789359067a208f#cd7e9e0f710816d49190dabd6c789359067a208f" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=09129074c1894f313b98764dd0400ecd73334a2d#09129074c1894f313b98764dd0400ecd73334a2d" dependencies = [ "asap-sql-function-catalog", "asap-types", @@ -418,12 +418,12 @@ dependencies = [ [[package]] name = "asap-sql-function-catalog" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=cd7e9e0f710816d49190dabd6c789359067a208f#cd7e9e0f710816d49190dabd6c789359067a208f" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=09129074c1894f313b98764dd0400ecd73334a2d#09129074c1894f313b98764dd0400ecd73334a2d" [[package]] name = "asap-types" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=cd7e9e0f710816d49190dabd6c789359067a208f#cd7e9e0f710816d49190dabd6c789359067a208f" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=09129074c1894f313b98764dd0400ecd73334a2d#09129074c1894f313b98764dd0400ecd73334a2d" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index c8bfac1c..852c7a47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,10 +16,10 @@ version = "0.1.0" [workspace.dependencies] # Keep Planner frontends, selection, and IR on the same immutable revision. # Alias upstream asap-types because this workspace also defines asap_types. -planner-types = { package = "asap-types", git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "cd7e9e0f710816d49190dabd6c789359067a208f" } -asap-aware-mapping = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "cd7e9e0f710816d49190dabd6c789359067a208f" } -asap-frontend-promql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "cd7e9e0f710816d49190dabd6c789359067a208f" } -asap-frontend-sql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "cd7e9e0f710816d49190dabd6c789359067a208f" } +planner-types = { package = "asap-types", git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "09129074c1894f313b98764dd0400ecd73334a2d" } +asap-aware-mapping = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "09129074c1894f313b98764dd0400ecd73334a2d" } +asap-frontend-promql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "09129074c1894f313b98764dd0400ecd73334a2d" } +asap-frontend-sql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "09129074c1894f313b98764dd0400ecd73334a2d" } # Shared external deps (used by 2+ crates) serde = { version = "1.0", features = ["derive"] } diff --git a/control_plane/examples/calibration_candidates.rs b/control_plane/examples/calibration_candidates.rs index f2894c4f..d83c50ad 100644 --- a/control_plane/examples/calibration_candidates.rs +++ b/control_plane/examples/calibration_candidates.rs @@ -37,16 +37,14 @@ fn planner_forest(queries: &[control_plane::physical::compiler::QueryCompilation vec![lhs, rhs], json!({"operator_debug":format!("{operator:?}"),"timing_debug":format!("{timing:?}")}), ), - SummaryExpr::CandidateTopK { + SummaryExpr::MembershipFilter { candidates, values, - k, - grouping, completeness, } => ( - "CandidateTopK", + "MembershipFilter", vec![candidates, values], - json!({"k":k,"grouping_debug":format!("{grouping:?}"),"completeness_debug":format!("{completeness:?}")}), + json!({"completeness_debug":format!("{completeness:?}")}), ), SummaryExpr::ValueOperation { child, @@ -105,7 +103,7 @@ fn planner_forest(queries: &[control_plane::physical::compiler::QueryCompilation vec![summary_input], json!({"query_debug":format!("{query:?}")}), ), - SummaryExpr::SummaryMerge { children } => { + SummaryExpr::SummaryMerge { children, .. } => { ("SummaryMerge", children.iter().collect(), json!({})) } }; diff --git a/control_plane/examples/offline_planner_replay.rs b/control_plane/examples/offline_planner_replay.rs index 7a703ce1..f87f8ab8 100644 --- a/control_plane/examples/offline_planner_replay.rs +++ b/control_plane/examples/offline_planner_replay.rs @@ -59,7 +59,7 @@ fn inspect( inspect(summary_input, model, seen, states, raw) } SummaryExpr::ValueOperation { child, .. } => inspect(child, model, seen, states, raw), - SummaryExpr::SummaryMerge { children } => { + SummaryExpr::SummaryMerge { children, .. } => { for child in children { inspect(child, model, seen, states, raw); } @@ -82,7 +82,7 @@ fn inspect( inspect(lhs, model, seen, states, raw); inspect(rhs, model, seen, states, raw); } - SummaryExpr::CandidateTopK { + SummaryExpr::MembershipFilter { candidates, values, .. } => { inspect(candidates, model, seen, states, raw); diff --git a/control_plane/src/emit/mod.rs b/control_plane/src/emit/mod.rs index d9d13f75..f1bd209c 100644 --- a/control_plane/src/emit/mod.rs +++ b/control_plane/src/emit/mod.rs @@ -54,9 +54,9 @@ fn extract_from_node(node: &Rc) -> Option { // `ExactAgg` case. SummaryExpr::SummaryAgg { .. } => None, SummaryExpr::SummaryEstimate { summary_input, .. } => extract_from_node(summary_input), - SummaryExpr::SummaryMerge { children } => children.iter().find_map(extract_from_node), + SummaryExpr::SummaryMerge { children, .. } => children.iter().find_map(extract_from_node), SummaryExpr::ValueOperation { child, .. } => extract_from_node(child), - SummaryExpr::CandidateTopK { + SummaryExpr::MembershipFilter { candidates, values, .. } => extract_from_node(candidates).or_else(|| extract_from_node(values)), // Not surfaced by any `Bind*` path yet (gated on rules that diff --git a/control_plane/src/physical/compiler.rs b/control_plane/src/physical/compiler.rs index 4130e711..7c11c3e9 100644 --- a/control_plane/src/physical/compiler.rs +++ b/control_plane/src/physical/compiler.rs @@ -807,7 +807,7 @@ fn has_unsafe_raw_entity_leaf( SummaryExpr::SummaryEstimate { summary_input, .. } => { has_unsafe_raw_entity_leaf(summary_input, selected, false) } - SummaryExpr::SummaryMerge { children } => children + SummaryExpr::SummaryMerge { children, .. } => children .iter() .any(|child| has_unsafe_raw_entity_leaf(child, selected, false)), _ => false, @@ -2056,7 +2056,7 @@ fn summary_agg_metric(node: &SummaryNode) -> Option { } } SummaryExpr::SummaryAgg { child, .. } => walk(child, metrics), - SummaryExpr::CandidateTopK { + SummaryExpr::MembershipFilter { candidates, values, .. } => { walk(candidates, metrics); @@ -2064,7 +2064,7 @@ fn summary_agg_metric(node: &SummaryNode) -> Option { } SummaryExpr::ValueOperation { child, .. } => walk(child, metrics), SummaryExpr::SummaryEstimate { summary_input, .. } => walk(summary_input, metrics), - SummaryExpr::SummaryMerge { children } => { + SummaryExpr::SummaryMerge { children, .. } => { for child in children { walk(child, metrics); } @@ -2321,7 +2321,7 @@ fn requires_exact_erp_fallback( child: summary_input, .. } => walk(summary_input, out), - SummaryExpr::SummaryMerge { children } => { + SummaryExpr::SummaryMerge { children, .. } => { children.iter().for_each(|child| walk(child, out)) } SummaryExpr::SummaryJoin { outer, inner, .. } => { @@ -2338,7 +2338,7 @@ fn requires_exact_erp_fallback( walk(left, out); walk(right, out); } - SummaryExpr::CandidateTopK { + SummaryExpr::MembershipFilter { candidates, values, .. } => { walk(candidates, out); @@ -3206,7 +3206,7 @@ fn immutable_materialization_sources(node: &SummaryNode) -> Option Option @@ -3535,7 +3535,7 @@ fn collect_selected_materializations( } } match &node.expr { - SummaryExpr::CandidateTopK { + SummaryExpr::MembershipFilter { candidates, values, .. } => { walk(candidates, readout, composable, grouping.clone(), selected)?; @@ -3589,7 +3589,7 @@ fn collect_selected_materializations( grouping.clone(), selected, )?, - SummaryExpr::SummaryMerge { children } => { + SummaryExpr::SummaryMerge { children, .. } => { for child in children { walk(child, readout, composable, grouping.clone(), selected)?; } @@ -4441,10 +4441,17 @@ pub(crate) mod tests { .compile_promql(request, environment(10_000)) .unwrap(); let entry = plan.query_plan.entries.values().next().unwrap(); - let crate::query_plan::QueryPlanNode::CandidateTopK { inputs, .. } = - &entry.nodes[&entry.root] + let crate::query_plan::QueryPlanNode::Logical { + operator: asap_types::query_plan::residual::ResidualQueryOperator::TopKSelection { .. }, + inputs, + } = &entry.nodes[&entry.root] else { - panic!("Planner weighted TopK must lower to CandidateTopK: {entry:#?}"); + panic!("expected ordinary TopK root") + }; + let crate::query_plan::QueryPlanNode::MembershipFilter { inputs, .. } = + &entry.nodes[&inputs[0]] + else { + panic!("Planner weighted TopK must lower to MembershipFilter: {entry:#?}"); }; assert!(matches!( entry.nodes[&inputs[0]], @@ -4540,7 +4547,14 @@ pub(crate) mod tests { asap_types::AggregationType::CountMinSketchWithHeap ); let entry = plan.query_plan.lookup(query).unwrap(); - let QueryPlanNode::CandidateTopK { inputs, .. } = &entry.nodes[&entry.root] else { + let QueryPlanNode::Logical { + operator: ResidualQueryOperator::TopKSelection { .. }, + inputs, + } = &entry.nodes[&entry.root] + else { + panic!("expected ordinary TopK root") + }; + let QueryPlanNode::MembershipFilter { inputs, .. } = &entry.nodes[&inputs[0]] else { panic!("expected candidate TopK: {entry:#?}"); }; assert!(matches!( @@ -4581,7 +4595,7 @@ pub(crate) mod tests { .nodes .iter() .all(|node| node.output_state.timing - == planner_types::post_asap::ExecutionTiming::MaintenanceTime)); + == planner_types::post_asap::ExecutionTiming::IngestionTime)); assert_eq!(installed.binding.query_plan_sink, entry.root); let mut mismatched = plan.to_publication_artifact().unwrap(); let projected = mismatched @@ -5766,7 +5780,7 @@ pub(crate) mod tests { }; request.queries[0].selected_plan_root = Rc::new(SummaryNode { expr: SummaryExpr::BinaryOp { - timing: planner_types::post_asap::ExecutionTiming::ReadTime, + timing: planner_types::post_asap::ExecutionTiming::QueryTime, lhs: selected.clone(), rhs: selected.clone(), operator: planner_types::post_asap::BinaryOperator { @@ -5967,7 +5981,7 @@ pub(crate) mod tests { let right = right.queries[0].selected_plan_root.clone(); let right = Rc::new(SummaryNode { expr: SummaryExpr::ValueOperation { - timing: planner_types::post_asap::ExecutionTiming::ReadTime, + timing: planner_types::post_asap::ExecutionTiming::QueryTime, operation: planner_types::post_asap::ValueOperation::FinalizeExactAccumulator, child: right.clone(), }, @@ -5976,7 +5990,7 @@ pub(crate) mod tests { }); request.queries[0].selected_plan_root = Rc::new(SummaryNode { expr: SummaryExpr::BinaryOp { - timing: planner_types::post_asap::ExecutionTiming::ReadTime, + timing: planner_types::post_asap::ExecutionTiming::QueryTime, lhs: left.clone(), rhs: right, operator: planner_types::post_asap::BinaryOperator { @@ -7605,6 +7619,7 @@ pub(crate) mod tests { }; let merge = Rc::new(SummaryNode { expr: SummaryExpr::SummaryMerge { + timing: planner_types::post_asap::ExecutionTiming::QueryTime, children: vec![left.clone(), right.clone()], }, schema: left.schema.clone(), diff --git a/control_plane/src/physical/executable_binding.rs b/control_plane/src/physical/executable_binding.rs index 8cbfcf8d..41f12ab2 100644 --- a/control_plane/src/physical/executable_binding.rs +++ b/control_plane/src/physical/executable_binding.rs @@ -16,15 +16,16 @@ fn operator_execution( use planner_types::post_asap::{ExecutableOperatorPayload as Payload, ExecutionTiming}; let declared = match &node.payload { - Payload::Binary { timing, .. } | Payload::Value { timing, .. } => *timing, - Payload::CandidateTopK { .. } | Payload::SummaryEstimate { .. } => { - ExecutionTiming::ReadTime + Payload::Binary { timing, .. } + | Payload::Value { timing, .. } + | Payload::SummaryMerge { timing } => *timing, + Payload::MembershipFilter { .. } | Payload::SummaryEstimate { .. } => { + ExecutionTiming::QueryTime } Payload::SummaryAgg { .. } | Payload::SummaryJoin { .. } | Payload::SummarySubtract - | Payload::SummaryDelete { .. } - | Payload::SummaryMerge => ExecutionTiming::MaintenanceTime, + | Payload::SummaryDelete { .. } => ExecutionTiming::IngestionTime, // These operators can be placed on either side of the stored-state // boundary. Planner's validated output state is authoritative. Payload::Fallback { .. } | Payload::RelationalJoin { .. } => node.output_state.timing, @@ -38,8 +39,8 @@ fn operator_execution( )); } Ok(match declared { - ExecutionTiming::MaintenanceTime => OperatorExecution::Maintenance, - ExecutionTiming::ReadTime => OperatorExecution::Query, + ExecutionTiming::IngestionTime => OperatorExecution::Maintenance, + ExecutionTiming::QueryTime => OperatorExecution::Query, }) } diff --git a/control_plane/src/physical/plan_dot.rs b/control_plane/src/physical/plan_dot.rs index 67bad1b7..19d60241 100644 --- a/control_plane/src/physical/plan_dot.rs +++ b/control_plane/src/physical/plan_dot.rs @@ -154,7 +154,7 @@ fn query_node_label(node: &QueryPlanNode) -> String { QueryPlanNode::SummaryEstimate { query, .. } => format!("SummaryEstimate\n{query:?}"), QueryPlanNode::ExactReadout { readout, .. } => format!("ExactReadout\n{readout:?}"), QueryPlanNode::SummaryMerge { .. } => "SummaryMerge".into(), - QueryPlanNode::CandidateTopK { k, .. } => format!("CandidateTopK\nk={k}"), + QueryPlanNode::MembershipFilter { .. } => "MembershipFilter".into(), QueryPlanNode::ExternalExact { .. } => "ExternalExact".into(), QueryPlanNode::ExactFallback { reason } => format!("ExactFallback\n{reason}"), } diff --git a/control_plane/src/physical/post_asap/cost_model.rs b/control_plane/src/physical/post_asap/cost_model.rs index 571c054c..5fa6c75d 100644 --- a/control_plane/src/physical/post_asap/cost_model.rs +++ b/control_plane/src/physical/post_asap/cost_model.rs @@ -521,8 +521,8 @@ impl CostModel for ControlPlaneCostModel { fn value_operation_capabilities(&self) -> ValueOperationCapabilities { ValueOperationCapabilities { - read_time: true, - maintenance_time: false, + query_time: true, + ingestion_time: false, } } diff --git a/control_plane/src/physical/post_asap/tests.rs b/control_plane/src/physical/post_asap/tests.rs index 7ecdcb24..8341c828 100644 --- a/control_plane/src/physical/post_asap/tests.rs +++ b/control_plane/src/physical/post_asap/tests.rs @@ -112,11 +112,11 @@ fn node_is_archive(node: &Rc) -> bool { SummaryExpr::SummaryAgg { child, .. } => node_is_archive(child), SummaryExpr::ValueOperation { child, .. } => node_is_archive(child), SummaryExpr::SummaryEstimate { summary_input, .. } => node_is_archive(summary_input), - SummaryExpr::SummaryMerge { children } => children.iter().any(node_is_archive), + SummaryExpr::SummaryMerge { children, .. } => children.iter().any(node_is_archive), SummaryExpr::SummaryJoin { outer, inner, .. } => { node_is_archive(outer) || node_is_archive(inner) } - SummaryExpr::CandidateTopK { + SummaryExpr::MembershipFilter { candidates, values, .. } => node_is_archive(candidates) || node_is_archive(values), SummaryExpr::SummarySubtract { left, right } diff --git a/control_plane/src/query_plan.rs b/control_plane/src/query_plan.rs index 19c12c68..40a7e8b3 100644 --- a/control_plane/src/query_plan.rs +++ b/control_plane/src/query_plan.rs @@ -179,7 +179,7 @@ where *input = remap[input]; } } - QueryPlanNode::CandidateTopK { inputs, .. } + QueryPlanNode::MembershipFilter { inputs, .. } | QueryPlanNode::Binary { inputs, .. } | QueryPlanNode::RelationalJoin { inputs, .. } => { for input in inputs { @@ -308,7 +308,7 @@ where .. }, ), - timing: planner_types::post_asap::ExecutionTiming::ReadTime, + timing: planner_types::post_asap::ExecutionTiming::QueryTime, } if measures.len() == 1 => { use planner_types::pre_asap::AggIntent; let operation = match &measures[0] { @@ -368,12 +368,12 @@ where SummaryExpr::ValueOperation { child: sort, operation: planner_types::post_asap::ValueOperation::Limit { n, offset: 0 }, - timing: planner_types::post_asap::ExecutionTiming::ReadTime, + timing: planner_types::post_asap::ExecutionTiming::QueryTime, } => { let SummaryExpr::ValueOperation { child, operation: planner_types::post_asap::ValueOperation::Sort { keys, partition_by }, - timing: planner_types::post_asap::ExecutionTiming::ReadTime, + timing: planner_types::post_asap::ExecutionTiming::QueryTime, } = &sort.expr else { return Err(QueryPlanError::Invalid( @@ -434,7 +434,7 @@ where SummaryExpr::ValueOperation { child, operation: planner_types::post_asap::ValueOperation::Sort { keys, .. }, - timing: planner_types::post_asap::ExecutionTiming::ReadTime, + timing: planner_types::post_asap::ExecutionTiming::QueryTime, } if keys.len() == 1 => QueryPlanNode::Logical { operator: residual::ResidualQueryOperator::Sort { descending: !keys[0].ascending, @@ -444,43 +444,14 @@ where SummaryExpr::ValueOperation { .. } => QueryPlanNode::ExactFallback { reason: "unsupported post-ASAP value operation".into(), }, - SummaryExpr::CandidateTopK { + SummaryExpr::MembershipFilter { candidates, values, - k, - grouping, completeness, } => { - let labels = grouping - .keys() - .iter() - .map(|&column| { - values - .schema - .fields - .get(column) - .map(|field| field.name.clone()) - .ok_or_else(|| { - QueryPlanError::Invalid( - "unresolved CandidateTopK grouping column".into(), - ) - }) - }) - .collect::, _>>()?; let candidate_input = self.lower(candidates)?; let value_input = if let Some(original) = &self.logical_source { - let parsed = promql_parser::parser::parse(original) - .map_err(|error| QueryPlanError::Invalid(error.to_string()))?; - let promql_parser::parser::Expr::Aggregate(aggregate) = parsed else { - return Err(QueryPlanError::Invalid( - "CandidateTopK requires a top-level PromQL aggregate".into(), - )); - }; - if aggregate.op.to_string() != "topk" { - return Err(QueryPlanError::Invalid( - "CandidateTopK requires a topk source expression".into(), - )); - } + let exact_expression = residual::selected_native_expression(original, values)?; fn item_label(node: &SummaryNode) -> Option { match &node.expr { SummaryExpr::SummaryEstimate { summary_input, .. } => { @@ -500,7 +471,7 @@ where } let item_label = item_label(candidates).ok_or_else(|| { QueryPlanError::Invalid( - "CandidateTopK membership has no named item label".into(), + "MembershipFilter membership has no named item label".into(), ) })?; let value_id = QueryNodeId(self.next_id); @@ -510,7 +481,7 @@ where QueryPlanNode::ExternalExact { request: ExternalExactRequest { language: QueryLanguage::PromQl, - expression: aggregate.expr.to_string(), + expression: exact_expression.to_string(), output: ExternalExactOutput::InstantVector, parameters: BTreeMap::new(), start_parameter: None, @@ -526,15 +497,8 @@ where } else { self.lower(values)? }; - QueryPlanNode::CandidateTopK { + QueryPlanNode::MembershipFilter { inputs: [candidate_input, value_input], - k: u64::try_from(*k).map_err(|_| { - QueryPlanError::Invalid("CandidateTopK k exceeds u64".into()) - })?, - grouping: residual::Grouping { - labels, - without: grouping.is_without(), - }, completeness: completeness.clone(), } } @@ -542,7 +506,7 @@ where lhs, rhs, operator, - timing: planner_types::post_asap::ExecutionTiming::ReadTime, + timing: planner_types::post_asap::ExecutionTiming::QueryTime, } if self.logical_source.is_some() || operator.checked_relative_division || operator.checked_finite_division => @@ -624,7 +588,7 @@ where lhs, rhs, operator, - timing: planner_types::post_asap::ExecutionTiming::ReadTime, + timing: planner_types::post_asap::ExecutionTiming::QueryTime, } if exact_value_executable(node) => { let planner_types::pre_asap::BinaryOpKind::Arithmetic(operator) = &operator.kind else { @@ -782,7 +746,7 @@ where input: self.lower(summary_input)?, query: query.clone().into(), }, - SummaryExpr::SummaryMerge { children } => { + SummaryExpr::SummaryMerge { children, .. } => { if children.is_empty() { QueryPlanNode::ExactFallback { reason: "empty summary_merge".into(), @@ -881,7 +845,7 @@ pub(crate) fn exact_value_executable(node: &SummaryNode) -> bool { lhs, rhs, operator, - timing: planner_types::post_asap::ExecutionTiming::ReadTime, + timing: planner_types::post_asap::ExecutionTiming::QueryTime, } => { matches!( operator.kind, @@ -1393,7 +1357,7 @@ mod tests { } #[test] - fn candidate_topk_rejects_invalid_completeness_contract() { + fn membership_filter_rejects_invalid_completeness_contract() { let leaf = QueryPlanNode::ExactFallback { reason: "prepared".into(), }; @@ -1408,13 +1372,8 @@ mod tests { (QueryNodeId(1), leaf), ( QueryNodeId(2), - QueryPlanNode::CandidateTopK { + QueryPlanNode::MembershipFilter { inputs: [QueryNodeId(0), QueryNodeId(1)], - k: 2, - grouping: residual::Grouping { - labels: vec![], - without: false, - }, completeness: CandidateCompleteness::Certified { guarantee: planner_types::post_asap::ResultGuarantee { metric: planner_types::post_asap::ErrorMetric::Frequency, diff --git a/control_plane/src/query_plan/residual.rs b/control_plane/src/query_plan/residual.rs index 3727e774..5722d50c 100644 --- a/control_plane/src/query_plan/residual.rs +++ b/control_plane/src/query_plan/residual.rs @@ -442,11 +442,34 @@ pub(crate) fn selected_residual_nodes( original: &str, selected: &planner_types::post_asap::SummaryNode, ) -> Result<(QueryNodeId, BTreeMap), QueryPlanError> { + let expression = selected_native_expression(original, selected)?; + let mut lower = Lower { + nodes: BTreeMap::new(), + seen: BTreeMap::new(), + }; + let root = lower.lower(&expression)?; + Ok((root, lower.nodes)) +} + +/// Resolve the selected exact subtree to a verified native expression before +/// binding an external input. Never substitute the top-level query's child. +pub(super) fn selected_native_expression( + original: &str, + selected: &planner_types::post_asap::SummaryNode, +) -> Result { if !selected.guarantee.as_ref().is_some_and(|g| g.is_exact()) { return Err(invalid( "native residual substitution requires an exact selected value", )); } + let selected = match &selected.expr { + planner_types::post_asap::SummaryExpr::ValueOperation { + child, + operation: planner_types::post_asap::ValueOperation::FinalizeExactAccumulator, + .. + } => child.as_ref(), + _ => selected, + }; fn visit<'a>(expr: &'a Expr, output: &mut Vec<&'a Expr>) { output.push(expr); match expr { @@ -485,7 +508,7 @@ pub(crate) fn selected_residual_nodes( rhs: right, .. } - | SummaryExpr::CandidateTopK { + | SummaryExpr::MembershipFilter { candidates: left, values: right, .. @@ -500,7 +523,7 @@ pub(crate) fn selected_residual_nodes( selected_horizons(left, out); selected_horizons(right, out); } - SummaryExpr::SummaryMerge { children } => { + SummaryExpr::SummaryMerge { children, .. } => { for child in children { selected_horizons(child, out); } @@ -530,12 +553,7 @@ pub(crate) fn selected_residual_nodes( let candidates = SketchAlgorithmStrategy::new(&asap_aware_mapping::DefaultCostModel) .replacements(&TargetSubDAG::new(&root)); if candidates.iter().any(|candidate| matches!(&candidate.replacement, Replacement::Summary(node) if node.as_ref() == selected)) { - let mut lower = Lower { - nodes: BTreeMap::new(), - seen: BTreeMap::new(), - }; - let root = lower.lower(expression)?; - let candidate = (root, lower.nodes); + let candidate = expression.clone(); if matched .as_ref() .is_some_and(|previous| previous != &candidate) @@ -575,6 +593,24 @@ pub(super) fn selected_aggregate_operator( mod hybrid_tests { use super::*; use crate::query_plan::{MaterializationBinding, PhysicalGrouping}; + #[test] + fn external_binding_rejects_an_unrelated_selected_exact_subtree() { + let exact = crate::query_parser::parse_query_expr_with_interval( + "sum_over_time(other_metric[5m])", + planner_types::types::AccuracyTarget::Exact, + 1_000, + ) + .unwrap(); + let selected = crate::planner_selection::plan_test_query(&exact).unwrap(); + assert!(selected_native_expression("topk(2, sum_over_time(m[5m]))", &selected).is_err()); + assert_eq!( + selected_native_expression("sum_over_time(other_metric[5m])", &selected) + .unwrap() + .to_string(), + "sum_over_time(other_metric[5m])" + ); + } + #[test] fn selected_summary_and_filtered_residual_share_installed_binary() { // Both filtered and unfiltered leaves bind independently. @@ -1086,7 +1122,7 @@ pub fn eligible_materialization_keys( visit(original, left, keys)?; visit(original, right, keys)?; } - SummaryExpr::CandidateTopK { + SummaryExpr::MembershipFilter { candidates, values, .. } => { visit(original, candidates, keys)?; @@ -1098,7 +1134,7 @@ pub fn eligible_materialization_keys( | SummaryExpr::SummaryDelete { summary_input, .. } => { visit(original, summary_input, keys)? } - SummaryExpr::SummaryMerge { children } => { + SummaryExpr::SummaryMerge { children, .. } => { for child in children { visit(original, child, keys)?; } diff --git a/control_plane/tests/offline_evidence.rs b/control_plane/tests/offline_evidence.rs index 5e334d26..a07a9a16 100644 --- a/control_plane/tests/offline_evidence.rs +++ b/control_plane/tests/offline_evidence.rs @@ -351,7 +351,7 @@ fn binary_summary_has_explicit_warm_tier_fallback() { let child = bound(&model()); let root = std::rc::Rc::new(SummaryNode { expr: SummaryExpr::BinaryOp { - timing: planner_types::post_asap::ExecutionTiming::ReadTime, + timing: planner_types::post_asap::ExecutionTiming::QueryTime, lhs: child.clone(), rhs: child.clone(), operator: BinaryOperator { diff --git a/crates/asap-physical-operators/README.md b/crates/asap-physical-operators/README.md index c913833a..d68b7b69 100644 --- a/crates/asap-physical-operators/README.md +++ b/crates/asap-physical-operators/README.md @@ -43,3 +43,12 @@ Deployment adapters supply storage, source rows, time/population scope, expressi evaluation, I/O and output representation. Planner's current maintenance-only summary placement and the backend's missing local raw Scan remain separate integration limitations; exporting these kernels does not silently bypass them. + +## Composable row operators + +`rows::membership_filter` performs a value-preserving semijoin and reports +missing membership keys. `rows::grouped_topk` independently ranks rows within +groups, retaining input order for ties and placing NaN after numeric values. +Neither kernel knows about sketches, storage, execution phase or external +queries. The deployment enforces the pruning certificate; filtering and ranking +remain separate operations in the installed graph. diff --git a/crates/asap-physical-operators/src/lib.rs b/crates/asap-physical-operators/src/lib.rs index 5d1858d1..4df43833 100644 --- a/crates/asap-physical-operators/src/lib.rs +++ b/crates/asap-physical-operators/src/lib.rs @@ -17,3 +17,5 @@ pub mod query_dag; /// The exact Planner contract used by these kernels. pub use planner_types as planner; + +pub mod rows; diff --git a/crates/asap-physical-operators/src/rows.rs b/crates/asap-physical-operators/src/rows.rs new file mode 100644 index 00000000..869ac0c3 --- /dev/null +++ b/crates/asap-physical-operators/src/rows.rs @@ -0,0 +1,88 @@ +//! Composable row operators, independent of storage, query language and sketches. +use std::collections::{BTreeMap, BTreeSet}; + +/// A semijoin preserves value-row order and multiplicity; duplicate membership +/// keys never multiply rows. Missing membership keys are reported separately so +/// the deployment can enforce the pruning proof attached to its plan. +pub fn membership_filter( + members: impl IntoIterator, + values: Vec, + identity: impl Fn(&T) -> K, +) -> (Vec, BTreeSet) { + let members: BTreeSet = members.into_iter().collect(); + let mut missing = members.clone(); + let rows = values + .into_iter() + .filter(|row| { + let key = identity(row); + missing.remove(&key); + members.contains(&key) + }) + .collect(); + (rows, missing) +} + +/// Stable descending TopK per group. NaN sorts after numeric values; ties keep +/// input order. This operator does not know how its input was filtered or built. +pub fn grouped_topk( + values: Vec, + k: usize, + group_key: impl Fn(&T) -> K, + score: impl Fn(&T) -> f64, +) -> Vec { + let mut groups: BTreeMap> = BTreeMap::new(); + for row in values { + groups.entry(group_key(&row)).or_default().push(row); + } + groups + .into_values() + .flat_map(|mut rows| { + rows.sort_by(|a, b| { + let (a, b) = (score(a), score(b)); + match (a.is_nan(), b.is_nan()) { + (true, true) => std::cmp::Ordering::Equal, + (true, false) => std::cmp::Ordering::Greater, + (false, true) => std::cmp::Ordering::Less, + (false, false) => b.total_cmp(&a), + } + }); + rows.truncate(k); + rows + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn semijoin_preserves_values_order_and_duplicates_without_ranking() { + let (rows, missing) = membership_filter( + ["b", "c", "b", "missing"], + vec![("a", 100.), ("b", 2.), ("c", 9.), ("b", 3.)], + |row| row.0, + ); + assert_eq!(rows, vec![("b", 2.), ("c", 9.), ("b", 3.)]); + assert_eq!(missing, BTreeSet::from(["missing"])); + assert_eq!( + grouped_topk(rows, 2, |_| (), |r| r.1), + vec![("c", 9.), ("b", 3.)] + ); + } + + #[test] + fn grouped_ranking_preserves_ties_and_places_nan_last() { + let rows = vec![("x", 0, f64::NAN), ("x", 1, 2.), ("y", 2, 8.), ("x", 3, 2.)]; + let ranked = grouped_topk(rows, 2, |r| r.0, |r| r.2); + assert_eq!(ranked, vec![("x", 1, 2.), ("x", 3, 2.), ("y", 2, 8.)]); + assert!(grouped_topk(vec![1], 0, |_| (), |r| *r as f64).is_empty()); + } + + #[test] + fn empty_membership_removes_all_rows() { + let (rows, missing) = membership_filter([], vec![1, 2], |r| *r); + assert!(rows.is_empty()); + assert!(missing.is_empty()); + } +} diff --git a/crates/asap_types/src/derived_input.rs b/crates/asap_types/src/derived_input.rs index d53dd080..75906330 100644 --- a/crates/asap_types/src/derived_input.rs +++ b/crates/asap_types/src/derived_input.rs @@ -215,7 +215,7 @@ mod tests { use planner_types::post_asap::{ EdgeRole, ExecutionDataState, GroupingEdgeCompatibility, WindowEdgeCompatibility, }; - let state = ExecutionDataState::MAINTENANCE_SUMMARY; + let state = ExecutionDataState::INGESTION_SUMMARY; OwnedPostAsapDag { schema_version: crate::executable_plan::OWNED_POST_ASAP_DAG_SCHEMA_VERSION, query_id: "query-a".into(), diff --git a/crates/asap_types/src/executable_plan.rs b/crates/asap_types/src/executable_plan.rs index fe4c095b..7d771b93 100644 --- a/crates/asap_types/src/executable_plan.rs +++ b/crates/asap_types/src/executable_plan.rs @@ -20,8 +20,8 @@ use serde::{Deserialize, Serialize}; #[serde(transparent)] pub struct QueryNodeId(pub u64); -pub const OWNED_POST_ASAP_DAG_SCHEMA_VERSION: u32 = 2; -pub const MAINTENANCE_DAG_SCHEMA_VERSION: u32 = 3; +pub const OWNED_POST_ASAP_DAG_SCHEMA_VERSION: u32 = 3; +pub const MAINTENANCE_DAG_SCHEMA_VERSION: u32 = 4; /// Versioned, language-neutral Planner DAG persisted with an installed plan. /// Plan lifecycle belongs to the enclosing `PrecomputePlan`; this document @@ -293,7 +293,7 @@ impl BackendExecutableBinding { for node in &dag.nodes { match (node.output_state.timing, self.node(node.id)) { ( - ExecutionTiming::MaintenanceTime, + ExecutionTiming::IngestionTime, Some( BackendNodeBinding::MaintenanceInput | BackendNodeBinding::Materialization { .. }, @@ -331,11 +331,10 @@ impl BackendExecutableBinding { } for node in &dag.nodes { match (node.output_state.timing, self.node(node.id).unwrap()) { - (ExecutionTiming::ReadTime, BackendNodeBinding::Query { .. }) - | (ExecutionTiming::ReadTime, BackendNodeBinding::QueryInput) - | (ExecutionTiming::MaintenanceTime, BackendNodeBinding::MaintenanceInput) - | (ExecutionTiming::MaintenanceTime, BackendNodeBinding::Materialization { .. }) => { - } + (ExecutionTiming::QueryTime, BackendNodeBinding::Query { .. }) + | (ExecutionTiming::QueryTime, BackendNodeBinding::QueryInput) + | (ExecutionTiming::IngestionTime, BackendNodeBinding::MaintenanceInput) + | (ExecutionTiming::IngestionTime, BackendNodeBinding::Materialization { .. }) => {} _ => { return Err(format!( "backend placement disagrees with node {} mode", diff --git a/crates/asap_types/src/precompute_plan.rs b/crates/asap_types/src/precompute_plan.rs index 47f30192..12d9f421 100644 --- a/crates/asap_types/src/precompute_plan.rs +++ b/crates/asap_types/src/precompute_plan.rs @@ -626,19 +626,19 @@ impl PrecomputePlan { ExecutableOperatorPayload as Payload, ExecutionTiming, ValueOperation, }; if node.output_state - != planner_types::post_asap::ExecutionDataState::MAINTENANCE_ROWS + != planner_types::post_asap::ExecutionDataState::INGESTION_ROWS { return Err(invalid()); } match &node.payload { Payload::Value { operation: ValueOperation::FinalizeExactAccumulator, - timing: ExecutionTiming::MaintenanceTime, + timing: ExecutionTiming::IngestionTime, } if children.len() == 1 && frontiers.contains_key(&children[0].producer) => {} Payload::Binary { operator, - timing: ExecutionTiming::MaintenanceTime, + timing: ExecutionTiming::IngestionTime, } if children.len() == 2 && children .iter() @@ -1056,7 +1056,7 @@ mod source_window_cohort_tests { reduction: Reduction::by(vec![]), grouping: Default::default(), }, - output_state: ExecutionDataState::MAINTENANCE_SUMMARY, + output_state: ExecutionDataState::INGESTION_SUMMARY, output_schema: SummarySchema { fields: vec![], time_index: None, @@ -1077,7 +1077,9 @@ mod source_window_cohort_tests { assert!(validate_maintenance_reduction(&config, &node).is_err()); config.partitioning = None; assert!(validate_maintenance_reduction(&config, &node).is_err()); - node.payload = ExecutableOperatorPayload::SummaryMerge; + node.payload = ExecutableOperatorPayload::SummaryMerge { + timing: planner_types::post_asap::ExecutionTiming::IngestionTime, + }; assert!(validate_maintenance_reduction(&config, &node).is_err()); } diff --git a/crates/asap_types/src/query_plan.rs b/crates/asap_types/src/query_plan.rs index f7071c7b..fc73f58c 100644 --- a/crates/asap_types/src/query_plan.rs +++ b/crates/asap_types/src/query_plan.rs @@ -391,15 +391,7 @@ impl QueryPlanEntry { )); } } - if let QueryPlanNode::CandidateTopK { - k, completeness, .. - } = node - { - if *k == 0 { - return Err(QueryPlanError::Invalid( - "CandidateTopK requires k > 0".into(), - )); - } + if let QueryPlanNode::MembershipFilter { completeness, .. } = node { if matches!( completeness, CandidateCompleteness::Certified { guarantee } @@ -409,7 +401,7 @@ impl QueryPlanEntry { || guarantee.failure_probability.evaluate().is_none() ) { return Err(QueryPlanError::Invalid( - "invalid CandidateTopK completeness certificate".into(), + "invalid MembershipFilter completeness certificate".into(), )); } } @@ -613,13 +605,11 @@ pub enum QueryPlanNode { SummaryMerge { inputs: Vec, }, - /// Use an approximate heap only as a membership sidecar, then rerank the - /// matching exact counter readouts. `inputs[0]` is candidate membership; - /// `inputs[1]` is the authoritative exact value vector. - CandidateTopK { + /// Semijoin value rows against membership identities, preserving their values + /// and order. Inputs are membership and authoritative values respectively. + /// Ranking, grouping and limiting are separate downstream operators. + MembershipFilter { inputs: [QueryNodeId; 2], - k: u64, - grouping: residual::Grouping, completeness: CandidateCompleteness, }, /// An exact subtree evaluated outside ASAP. Its results enter the query DAG @@ -647,7 +637,7 @@ impl QueryPlanNode { Self::SummaryMerge { inputs } | Self::Logical { inputs, .. } | Self::ExternalExact { inputs, .. } => inputs, - Self::CandidateTopK { inputs, .. } => inputs, + Self::MembershipFilter { inputs, .. } => inputs, } } } diff --git a/data_plane/src/precompute_engine/maintenance_runtime.rs b/data_plane/src/precompute_engine/maintenance_runtime.rs index 3b846265..30a0a391 100644 --- a/data_plane/src/precompute_engine/maintenance_runtime.rs +++ b/data_plane/src/precompute_engine/maintenance_runtime.rs @@ -169,14 +169,16 @@ impl PrecomputeOperatorRegistry for OperatorAdapter<'_> { inputs: &[Arc], ) -> Result { match &node.payload { - ExecutableOperatorPayload::SummaryMerge => merge_inputs(inputs), + ExecutableOperatorPayload::SummaryMerge { + timing: planner_types::post_asap::ExecutionTiming::IngestionTime, + } => merge_inputs(inputs), ExecutableOperatorPayload::Binary { operator, - timing: planner_types::post_asap::ExecutionTiming::MaintenanceTime, + timing: planner_types::post_asap::ExecutionTiming::IngestionTime, } => { if !self.inputs.frozen_inputs().is_some() || node.output_state - != planner_types::post_asap::ExecutionDataState::MAINTENANCE_ROWS + != planner_types::post_asap::ExecutionDataState::INGESTION_ROWS { return Err("maintenance binary requires immutable completed row inputs".into()); } @@ -185,7 +187,7 @@ impl PrecomputeOperatorRegistry for OperatorAdapter<'_> { ExecutableOperatorPayload::Value { operation: planner_types::post_asap::ValueOperation::FinalizeExactAccumulator, - timing: planner_types::post_asap::ExecutionTiming::MaintenanceTime, + timing: planner_types::post_asap::ExecutionTiming::IngestionTime, } => { if !self.inputs.frozen_inputs().is_some() { return Err( @@ -2147,8 +2149,7 @@ mod tests { .nodes .iter() .filter(|node| { - node.output_state.timing - == planner_types::post_asap::ExecutionTiming::MaintenanceTime + node.output_state.timing == planner_types::post_asap::ExecutionTiming::IngestionTime }) .map(|node| node.id) .collect::>(); @@ -2241,8 +2242,10 @@ mod tests { fn node(id: u32) -> ExecutableDagNode { ExecutableDagNode { id: PostAsapNodeId(id), - payload: ExecutableOperatorPayload::SummaryMerge, - output_state: planner_types::post_asap::ExecutionDataState::MAINTENANCE_SUMMARY, + payload: ExecutableOperatorPayload::SummaryMerge { + timing: planner_types::post_asap::ExecutionTiming::IngestionTime, + }, + output_state: planner_types::post_asap::ExecutionDataState::INGESTION_SUMMARY, output_schema: SummarySchema { fields: vec![], time_index: None, @@ -2260,7 +2263,7 @@ mod tests { fields: vec![], time_index: None, }, - data_state: planner_types::post_asap::ExecutionDataState::MAINTENANCE_SUMMARY, + data_state: planner_types::post_asap::ExecutionDataState::INGESTION_SUMMARY, grouping: GroupingEdgeCompatibility::Identical, window: WindowEdgeCompatibility::NotApplicable, } @@ -2442,7 +2445,7 @@ mod tests { let mut read = node(2); read.payload = ExecutableOperatorPayload::Value { operation: planner_types::post_asap::ValueOperation::FinalizeExactAccumulator, - timing: planner_types::post_asap::ExecutionTiming::MaintenanceTime, + timing: planner_types::post_asap::ExecutionTiming::IngestionTime, }; read.output_schema.fields = vec![SummaryField { name: "value".into(), @@ -2487,14 +2490,14 @@ mod tests { dtype: SummaryFamilyType::ExactAggregate(ExactKind::Sum, ExactParams::Sum), nullable: false, }]; - read.output_state = planner_types::post_asap::ExecutionDataState::MAINTENANCE_ROWS; + read.output_state = planner_types::post_asap::ExecutionDataState::INGESTION_ROWS; aggregate.output_schema.fields = vec![SummaryField { name: "state".into(), dtype: configs[1].accumulator_spec().unwrap().family, nullable: false, }]; let mut query = node(4); - query.output_state = planner_types::post_asap::ExecutionDataState::READ_ROWS; + query.output_state = planner_types::post_asap::ExecutionDataState::QUERY_ROWS; let mut first_edge = edge(1, 2); first_edge.intermediate_schema = source_node.output_schema.clone(); let mut second_edge = edge(2, 3); @@ -2905,7 +2908,9 @@ mod tests { second_node.id = PostAsapNodeId(5); let mut merge = second_node.clone(); merge.id = PostAsapNodeId(6); - merge.payload = ExecutableOperatorPayload::SummaryMerge; + merge.payload = ExecutableOperatorPayload::SummaryMerge { + timing: planner_types::post_asap::ExecutionTiming::IngestionTime, + }; dag.nodes.extend([second_node, merge]); let original = dag .edges @@ -3393,9 +3398,9 @@ mod tests { }; operation.payload = ExecutableOperatorPayload::Binary { operator: operator.clone(), - timing: planner_types::post_asap::ExecutionTiming::MaintenanceTime, + timing: planner_types::post_asap::ExecutionTiming::IngestionTime, }; - operation.output_state = planner_types::post_asap::ExecutionDataState::MAINTENANCE_ROWS; + operation.output_state = planner_types::post_asap::ExecutionDataState::INGESTION_ROWS; assert!(frozen .execute(&operation, &[left.clone(), right.clone()]) .is_ok()); @@ -3412,7 +3417,7 @@ mod tests { .is_err()); operation.payload = ExecutableOperatorPayload::Binary { operator: operator.clone(), - timing: planner_types::post_asap::ExecutionTiming::ReadTime, + timing: planner_types::post_asap::ExecutionTiming::QueryTime, }; assert!(frozen .execute(&operation, &[left.clone(), right.clone()]) @@ -3868,7 +3873,7 @@ mod tests { .policy_fingerprint() .into(); let mut query = node(2); - query.output_state = planner_types::post_asap::ExecutionDataState::READ_ROWS; + query.output_state = planner_types::post_asap::ExecutionDataState::QUERY_ROWS; let dag = ExecutableDag { nodes: vec![node(0), node(1), query], edges: vec![edge(0, 1), edge(1, 2)], @@ -4008,7 +4013,7 @@ mod tests { // source 0 is shared by both branches; root therefore contains two // copies of its value while node 0 itself is evaluated once. let mut query = node(4); - query.output_state = planner_types::post_asap::ExecutionDataState::READ_ROWS; + query.output_state = planner_types::post_asap::ExecutionDataState::QUERY_ROWS; let dag = ExecutableDag { nodes: (0..4).map(node).chain([query]).collect(), edges: vec![edge(0, 1), edge(0, 2), edge(1, 3), edge(2, 3), edge(3, 4)], @@ -4080,7 +4085,7 @@ mod tests { let mut unsupported = node(1); unsupported.payload = ExecutableOperatorPayload::SummarySubtract; let mut query = node(2); - query.output_state = planner_types::post_asap::ExecutionDataState::READ_ROWS; + query.output_state = planner_types::post_asap::ExecutionDataState::QUERY_ROWS; let dag = ExecutableDag { nodes: vec![node(0), unsupported, query], edges: vec![edge(0, 1), edge(1, 2)], diff --git a/data_plane/src/precompute_engine/subdag_scheduler.rs b/data_plane/src/precompute_engine/subdag_scheduler.rs index 53d45d23..857309cb 100644 --- a/data_plane/src/precompute_engine/subdag_scheduler.rs +++ b/data_plane/src/precompute_engine/subdag_scheduler.rs @@ -180,7 +180,7 @@ where let node = nodes .get(&id) .ok_or_else(|| ScheduleError::Invalid(format!("missing node {id}")))?; - if node.output_state == ExecutionDataState::READ_ROWS { + if node.output_state == ExecutionDataState::QUERY_ROWS { return Err(ScheduleError::Invalid(format!( "query-time node {id} in precompute dependency path" ))); @@ -268,8 +268,7 @@ mod tests { .nodes .iter() .filter(|node| { - node.output_state.timing - == planner_types::post_asap::ExecutionTiming::MaintenanceTime + node.output_state.timing == planner_types::post_asap::ExecutionTiming::IngestionTime }) .map(|node| node.id) .collect::>(); @@ -285,7 +284,7 @@ mod tests { ExecutableDagNode { id: PostAsapNodeId(id), payload: ExecutableOperatorPayload::SummarySubtract, - output_state: ExecutionDataState::MAINTENANCE_SUMMARY, + output_state: ExecutionDataState::INGESTION_SUMMARY, output_schema: SummarySchema { fields: Vec::new(), time_index: None, @@ -303,7 +302,7 @@ mod tests { fields: Vec::new(), time_index: None, }, - data_state: ExecutionDataState::MAINTENANCE_SUMMARY, + data_state: ExecutionDataState::INGESTION_SUMMARY, grouping: GroupingEdgeCompatibility::Identical, window: WindowEdgeCompatibility::NotApplicable, } @@ -361,7 +360,7 @@ mod tests { #[test] fn stored_sinks_share_one_evaluation() { let mut query = node(4); - query.output_state = ExecutionDataState::READ_ROWS; + query.output_state = ExecutionDataState::QUERY_ROWS; let dag = ExecutableDag { nodes: vec![node(0), node(1), node(2), node(3), query], edges: vec![edge(0, 1), edge(1, 2), edge(1, 3)], @@ -408,7 +407,7 @@ mod tests { } let mut binary = node(3); binary.payload = ExecutableOperatorPayload::Binary { - timing: planner_types::post_asap::ExecutionTiming::MaintenanceTime, + timing: planner_types::post_asap::ExecutionTiming::IngestionTime, operator: BinaryOperator { checked_relative_division: false, checked_finite_division: false, @@ -423,7 +422,7 @@ mod tests { let mut dag = ExecutableDag { nodes: vec![node(0), node(1), node(2), binary, { let mut query = node(4); - query.output_state = ExecutionDataState::READ_ROWS; + query.output_state = ExecutionDataState::QUERY_ROWS; query }], edges: vec![right, left], @@ -459,7 +458,7 @@ mod tests { .map(node) .chain([{ let mut query = node(4); - query.output_state = ExecutionDataState::READ_ROWS; + query.output_state = ExecutionDataState::QUERY_ROWS; query }]) .collect(), @@ -504,9 +503,9 @@ mod tests { } } let mut raw = node(0); - raw.output_state = ExecutionDataState::READ_ROWS; + raw.output_state = ExecutionDataState::QUERY_ROWS; let mut query = node(4); - query.output_state = ExecutionDataState::READ_ROWS; + query.output_state = ExecutionDataState::QUERY_ROWS; let dag = ExecutableDag { nodes: vec![raw, node(1), node(2), node(3), query], edges: vec![edge(0, 1), edge(1, 2), edge(1, 3), edge(2, 3), edge(3, 4)], @@ -532,7 +531,7 @@ mod tests { #[test] fn rejects_query_node_in_precompute_path_and_mismatched_lineage_key() { let mut query_child = node(0); - query_child.output_state = ExecutionDataState::READ_ROWS; + query_child.output_state = ExecutionDataState::QUERY_ROWS; let dag = ExecutableDag { nodes: vec![query_child, node(1)], edges: vec![edge(0, 1)], 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 a30816e8..3978183d 100644 --- a/data_plane/src/query_engines/asap_query_engine/engine.rs +++ b/data_plane/src/query_engines/asap_query_engine/engine.rs @@ -286,7 +286,7 @@ impl ASAPQueryEngine { // 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 - // CandidateTopK reuses the same membership readout during composition. + // MembershipFilter reuses the same membership readout during composition. let dependencies = super::exact_subqueries::external_dependencies(entry, times)?; let mut prepared = super::logical_dag::PreparedLeaves::new(); let unique_inputs = dependencies 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 95e9006c..636573bb 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 @@ -85,9 +85,9 @@ fn leaves( } _ => pending.extend(inputs.iter().map(|input| (*input, at))), }, - // CandidateTopK is a typed composition node rather than a Logical + // MembershipFilter is a typed composition node rather than a Logical // wrapper, but its value input can still be a Prometheus leaf. - QueryPlanNode::CandidateTopK { inputs, .. } => { + QueryPlanNode::MembershipFilter { inputs, .. } => { pending.extend(inputs.iter().map(|input| (*input, at))); } QueryPlanNode::ExternalExact { request, inputs } => { @@ -650,22 +650,30 @@ mod tests { } #[tokio::test] - async fn candidate_exact_is_discovered_and_prepared_behind_candidate_topk_root() { - use asap_types::query_plan::{residual::Grouping, CandidateCompleteness}; + async fn candidate_exact_is_discovered_and_prepared_behind_membership_filter_root() { + use asap_types::query_plan::CandidateCompleteness; let mut entry = candidate_entry("sum by (job) (rate(m[5m]))"); entry.nodes.insert( QueryNodeId(2), - QueryPlanNode::CandidateTopK { + QueryPlanNode::MembershipFilter { inputs: [QueryNodeId(1), QueryNodeId(0)], - k: 2, - grouping: Grouping { - labels: vec![], - without: false, - }, completeness: CandidateCompleteness::BestEffort { guarantee: None }, }, ); - entry.root = QueryNodeId(2); + entry.nodes.insert( + QueryNodeId(3), + QueryPlanNode::Logical { + operator: ResidualQueryOperator::TopKSelection { + k: 2, + grouping: asap_types::query_plan::residual::Grouping { + labels: vec![], + without: false, + }, + }, + inputs: vec![QueryNodeId(2)], + }, + ); + entry.root = QueryNodeId(3); let dependencies = external_dependencies(&entry, &[1_000]).unwrap(); assert_eq!(dependencies, vec![(QueryNodeId(0), QueryNodeId(1), 1_000)]); let prepared = prepare_external( 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 29ef637f..86b29c8f 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 @@ -205,16 +205,13 @@ impl Result> Evaluator<' } self.logical(operator, &inputs, at)? } - QueryPlanNode::CandidateTopK { + QueryPlanNode::MembershipFilter { inputs, - k, - grouping, completeness, } => { let candidates = vector(self.eval(inputs[0], at)?)?; let values = vector(self.eval(inputs[1], at)?)?; - let (selected, warning) = - candidate_topk(k, &grouping, candidates, values, &completeness)?; + let (selected, warning) = membership_filter(candidates, values, &completeness)?; if let Some(warning) = warning { self.warnings.push(warning); } @@ -423,9 +420,7 @@ impl Result> Evaluator<' } } -fn candidate_topk( - k: u64, - grouping: &Grouping, +fn membership_filter( candidates: Vector, values: Vector, completeness: &CandidateCompleteness, @@ -435,30 +430,22 @@ fn candidate_topk( labels.remove("__name__"); labels }; - let candidate_ids: BTreeSet<_> = candidates - .iter() - .map(|(labels, _)| identity(labels)) - .collect(); - let value_ids: BTreeSet<_> = values.iter().map(|(labels, _)| identity(labels)).collect(); - let dangling = candidate_ids - .iter() - .any(|candidate| !value_ids.contains(candidate)); - if dangling && matches!(completeness, CandidateCompleteness::Certified { .. }) { - return Err(miss("certified TopK candidate has no exact counter value")); + let (selected, missing) = asap_physical_operators::rows::membership_filter( + candidates.iter().map(|(labels, _)| identity(labels)), + values, + |(labels, _)| identity(labels), + ); + if !missing.is_empty() && matches!(completeness, CandidateCompleteness::Certified { .. }) { + return Err(miss("certified membership key has no authoritative value")); } - let matched = values - .into_iter() - .filter(|(labels, _)| candidate_ids.contains(&identity(labels))) - .collect(); - let selected = topk_selection(k, grouping, matched); let warning = match completeness { CandidateCompleteness::Certified { .. } => None, CandidateCompleteness::BestEffort { guarantee } => Some(match guarantee { Some(guarantee) => format!( - "ASAP TopK candidate membership is approximate: {:?}", + "ASAP membership pruning is approximate: {:?}", guarantee.metric ), - None => "ASAP TopK candidate membership is approximate and uncertified".into(), + None => "ASAP membership pruning is approximate and uncertified".into(), }), }; Ok((selected, warning)) @@ -520,30 +507,12 @@ 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. fn topk_selection(k: u64, grouping: &Grouping, values: Vector) -> Vector { - if k == 0 { - return Vec::new(); - } - let mut groups: BTreeMap = BTreeMap::new(); - for (labels, value) in values { - groups - .entry(grouping_key(&labels, grouping)) - .or_default() - .push((labels, value)); - } - let limit = usize::try_from(k).unwrap_or(usize::MAX); - groups - .into_values() - .flat_map(|mut group| { - group.sort_by(|a, b| match (a.1.is_nan(), b.1.is_nan()) { - (true, true) => std::cmp::Ordering::Equal, - (true, false) => std::cmp::Ordering::Greater, - (false, true) => std::cmp::Ordering::Less, - (false, false) => b.1.total_cmp(&a.1), - }); - group.truncate(limit); - group - }) - .collect() + asap_physical_operators::rows::grouped_topk( + values, + usize::try_from(k).unwrap_or(usize::MAX), + |(labels, _)| grouping_key(labels, grouping), + |(_, value)| *value, + ) } fn binary( @@ -908,6 +877,14 @@ mod topk_tests { (labels(&[("series", "high")]), 3.0), ], ); + let selected = topk_selection( + 2, + &Grouping { + labels: vec![], + without: false, + }, + selected, + ); assert_eq!( selected .iter() @@ -1177,12 +1154,7 @@ mod topk_tests { (labels(&[("pod", "b")]), 8.0), (labels(&[("pod", "c")]), 9.0), ]; - let (selected, warning) = candidate_topk( - 2, - &Grouping { - labels: vec![], - without: false, - }, + let (selected, warning) = membership_filter( candidates, exact, &CandidateCompleteness::Certified { @@ -1190,6 +1162,14 @@ mod topk_tests { }, ) .unwrap(); + let selected = topk_selection( + 2, + &Grouping { + labels: vec![], + without: false, + }, + selected, + ); assert_eq!( selected .iter() @@ -1204,7 +1184,8 @@ mod topk_tests { fn installed_candidate_sidecar_reads_both_summary_inputs() { let candidate_id = QueryNodeId(0); let value_id = QueryNodeId(1); - let root = QueryNodeId(2); + let filter = QueryNodeId(2); + let root = QueryNodeId(3); let entry = QueryPlanEntry { language: asap_types::query_plan::QueryLanguage::PromQl, query_id: "candidate-topk".into(), @@ -1225,19 +1206,27 @@ mod topk_tests { }, ), ( - root, - QueryPlanNode::CandidateTopK { + filter, + QueryPlanNode::MembershipFilter { inputs: [candidate_id, value_id], - k: 1, - grouping: Grouping { - labels: vec![], - without: false, - }, completeness: CandidateCompleteness::Certified { guarantee: topk_membership_guarantee(), }, }, ), + ( + root, + QueryPlanNode::Logical { + operator: ResidualQueryOperator::TopKSelection { + k: 1, + grouping: Grouping { + labels: vec![], + without: false, + }, + }, + inputs: vec![filter], + }, + ), ]), instant: InstantExecution { lookback_ms: 300_000, @@ -1251,7 +1240,10 @@ mod topk_tests { ( (candidate_id, at), PreparedLeaf { - value: Value::Vector(vec![(labels(&[("pod", "b")]), 100.0)]), + value: Value::Vector(vec![ + (labels(&[("pod", "b")]), 100.0), + (labels(&[("pod", "c")]), 1.0), + ]), remote: false, remote_evaluations: 0, remote_rpcs: 0, @@ -1263,6 +1255,7 @@ mod topk_tests { value: Value::Vector(vec![ (labels(&[("pod", "a")]), 2.0), (labels(&[("pod", "b")]), 1.0), + (labels(&[("pod", "c")]), 3.0), ]), remote: false, remote_evaluations: 0, @@ -1278,8 +1271,8 @@ mod topk_tests { panic!("vector expected") }; assert_eq!(result.values.len(), 1); - assert_eq!(result.values[0].value, 1.0, "exact value is authoritative"); - assert_eq!(result.values[0].labels.labels, vec!["b"]); + assert_eq!(result.values[0].value, 3.0, "exact value is authoritative"); + assert_eq!(result.values[0].labels.labels, vec!["c"]); assert_eq!(stats.summary_readout_evaluations, 2); assert!(result.warnings.is_empty()); } @@ -1288,30 +1281,20 @@ mod topk_tests { fn uncertified_candidate_sidecar_warns_or_falls_back_explicitly() { let candidates = vec![(labels(&[("pod", "a")]), 1.0)]; let exact = vec![(labels(&[("pod", "a")]), 2.0)]; - let (_, warning) = candidate_topk( - 1, - &Grouping { - labels: vec![], - without: false, - }, + let (_, warning) = membership_filter( candidates.clone(), exact.clone(), &CandidateCompleteness::BestEffort { guarantee: None }, ) .unwrap(); assert!(warning.unwrap().contains("approximate")); - // Exact queries never lower an uncertified CandidateTopK. The Planner + // Exact queries never lower an uncertified MembershipFilter. The Planner // emits its ordinary exact fallback instead; this runtime node is only // valid for certified or explicitly approximate plans. let certified = CandidateCompleteness::Certified { guarantee: topk_membership_guarantee(), }; - assert!(candidate_topk( - 1, - &Grouping { - labels: vec![], - without: false - }, + assert!(membership_filter( vec![(labels(&[("pod", "missing")]), 1.0)], exact, &certified, 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 f6265a74..4b40899f 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 @@ -305,7 +305,7 @@ impl QueryNodeRuntime for PhysicalQueryRuntime<'_> { }) } QueryPlanNode::Logical { .. } - | QueryPlanNode::CandidateTopK { .. } + | QueryPlanNode::MembershipFilter { .. } | QueryPlanNode::Relational { .. } | QueryPlanNode::ExternalExact { .. } | QueryPlanNode::RelationalJoin { .. } => Err(PhysicalNodeError::Fallback( 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 index fb75ae6b..02c2adac 100644 --- a/data_plane/src/query_engines/asap_query_engine/summary_exec.rs +++ b/data_plane/src/query_engines/asap_query_engine/summary_exec.rs @@ -184,7 +184,7 @@ pub fn execute( Ok(ExecOutcome::Value(out)) } - SummaryExpr::SummaryMerge { children } => { + SummaryExpr::SummaryMerge { children, .. } => { if children.is_empty() { return Err(ExecError::EmptyMerge); } @@ -223,11 +223,11 @@ pub fn execute( SummaryExpr::SummaryJoin { .. } => Err(ExecError::NotYetSupported("SummaryJoin")), SummaryExpr::RelationalJoin { .. } => Err(ExecError::NotYetSupported("RelationalJoin")), - // CandidateTopK is lowered to the deployed QueryPlan DAG, where both + // MembershipFilter 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::CandidateTopK { .. } => Err(ExecError::NotYetSupported("CandidateTopK")), + SummaryExpr::MembershipFilter { .. } => Err(ExecError::NotYetSupported("MembershipFilter")), SummaryExpr::BinaryOp { .. } => Err(ExecError::NotYetSupported("BinaryOp")), SummaryExpr::ValueOperation { .. } => Err(ExecError::NotYetSupported("ValueOperation")), SummaryExpr::SummarySubtract { .. } => Err(ExecError::NotYetSupported("SummarySubtract")), @@ -350,7 +350,10 @@ mod tests { fn merge_node(children: Vec>) -> Rc { Rc::new(SummaryNode { - expr: SummaryExpr::SummaryMerge { children }, + expr: SummaryExpr::SummaryMerge { + children, + timing: planner_types::post_asap::ExecutionTiming::QueryTime, + }, schema: lift(vec!["value"]), guarantee: None, }) @@ -522,7 +525,7 @@ mod tests { let child = logical_node(); let tree = SummaryNode { expr: SummaryExpr::BinaryOp { - timing: planner_types::post_asap::ExecutionTiming::ReadTime, + timing: planner_types::post_asap::ExecutionTiming::QueryTime, lhs: child.clone(), rhs: child.clone(), operator: planner_types::post_asap::BinaryOperator { 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 555e31bc..e145db16 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 @@ -1389,7 +1389,7 @@ fn find_metric(node: &SummaryNode) -> Option { 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)), + SummaryExpr::SummaryMerge { children, .. } => children.first().and_then(|c| find_metric(c)), _ => None, } } diff --git a/docs/design_docs/query-dag-execution.md b/docs/design_docs/query-dag-execution.md index b63c9eb7..31eb3642 100644 --- a/docs/design_docs/query-dag-execution.md +++ b/docs/design_docs/query-dag-execution.md @@ -57,7 +57,7 @@ definition or reconstruct an operator from the request text. ## Shared physical operator library `crates/asap-physical-operators` owns the concrete accumulator kernels, typed -state/update traits, Planner-family factory, scalar arithmetic, and installed +state/update traits, Planner-family factory, scalar arithmetic, row membership filtering, grouped TopK, and installed QueryPlan DAG traversal. Both maintenance and query execution import this crate directly; the old data-plane operator/factory modules are removed. A deployment such as asap-fusion can depend on the library without importing `data_plane` or @@ -78,15 +78,14 @@ raw Scan has become an installed query source. Storage reads, population/window selection, expression-to-update evaluation, transport, language result adaptation and scheduling policy remain deployment -responsibilities. In particular, Planner's current maintenance-only summary -placement still limits which query-time summary DAGs can be exported. Completing +responsibilities. In particular, Planner's current restrictions on summary construction +still limit which query-time summary DAGs can be exported. Completing that contract requires Planner placement support and backend raw-source binding; classifying an enum variant is not proof of local executability. ## Physical operator coverage and acceptance contract -Coverage is recorded against backend `dccbe182` and pinned Planner -`cd7e9e0f710816d49190dabd6c789359067a208f`, not an unspecified Planner main. +Coverage describes this PR and the immutable Planner revision in `Cargo.toml`. **This PR does not yet meet the universal local-execution contract.** An exhaustive phase match proves ownership only. A reusable kernel proves an algorithm implementation exists; neither proves that a concrete installed plan @@ -104,28 +103,56 @@ external plan may remain useful, but is not evidence of local coverage. that every instance of the payload is accepted. Shared kernels do not include backend storage, relational adapters or arbitrary expression evaluation. -| Payload | Current Planner execution phase(当前 Planner 执行阶段) | Backend implementation / limitation | Shared library | +| Payload | Execution phase | Backend implementation / limitation | Shared library | | --- | --- | --- | --- | -| `Fallback` | Depends on the neighbor nodes in the DAG (validated edge states) | Selected ingestion source boundary, prepared external exact subtree, or explicit whole-query fallback. No general local raw-query executor. | No source/SQL/PromQL executor | -| `Binary` | Ingestion time or read/query time (explicit `timing`, validated against DAG edges) | Maintenance arithmetic requires immutable completed, aligned row inputs; query scalar/vector arithmetic uses the relevant value adapter. Not arbitrary row/vector coercion. | Float64 Add/Sub/Mul/Div/Mod/Pow/Atan2; alignment and vector semantics remain backend-local | -| `CandidateTopK` | Read/query time | Membership sidecar plus authoritative exact values and grouped reranking. Exact values may require an external leaf; candidate membership alone is not a complete exact answer. | No candidate/vector adapter | -| `Value` | Ingestion time or read/query time (explicit `timing`, validated against DAG edges) | Operation-specific subset; see next table. | Exact accumulator kernels only; no general Value dispatcher | -| `RelationalJoin` | Depends on the neighbor nodes in the DAG (validated row edge states) | Query ClickHouse relation adapter implements inner/left/right/full/cross/semi/anti joins within supported predicate/schema/value semantics. No generic maintenance join implementation. This is not a claim of all ClickHouse settings/NULL semantics. | Not extracted | -| `SummaryAgg` | Ingestion time | Raw-ingestion specialization and restricted maintenance row-to-state aggregation. Factory validates family/layout/parameters. Maintenance DAG path needs a typed update evaluator, immutable inputs and installed materialization; it does not support arbitrary item expressions or output populations. No installed query-time builder. | Construction/update kernels for families below; placement-neutral | -| `SummaryJoin` | Ingestion time | Ownership classified; no dispatch implementation in maintenance runtime. | No registered SummaryJoin kernel | -| `SummarySubtract` | Ingestion time | Ownership classified; unsupported by maintenance runtime. | No registered SummarySubtract kernel | -| `SummaryDelete` | Ingestion time | Ownership classified; no dispatch implementation in maintenance runtime. | No registered SummaryDelete kernel | -| `SummaryEstimate` | Read/query time | Typed sketch readout over compatible stored states, with family, window and population restrictions. | Underlying sketch query kernels; store/readout adapter remains backend-local | -| `SummaryMerge` | Ingestion time in Planner | Maintenance state merge is implemented. Separately, installed QueryPlan SummaryMerge merges compatible stored states at query time. That read adapter does not make Planner summary construction query-placeable. | Accumulator merge implementations; no universal cross-family merge | - -In this column, **ingestion time** includes ingestion-triggered background -maintenance; it does not require execution inline with each incoming sample. -The current API still names this phase `MaintenanceTime`. **Read/query time** -means execution while serving a query. “Depends on the neighbor nodes in the -DAG” refers to validated input/output edge states, not an unconstrained runtime -choice. `Binary` and `Value` carry explicit `timing`, so their phase is not -inferred solely from neighboring nodes. These labels describe the current -Planner contract, not whether a backend implementation exists. +| `Fallback` | Ingestion time or query time | Selected ingestion source boundary, prepared external exact subtree, or explicit whole-query fallback. No general local raw-query executor. | No source/SQL/PromQL executor | +| `Binary` | Ingestion time or query time | Maintenance arithmetic requires immutable completed, aligned row inputs; query scalar/vector arithmetic uses the relevant value adapter. Not arbitrary row/vector coercion. | Float64 Add/Sub/Mul/Div/Mod/Pow/Atan2; alignment and vector semantics remain backend-local | +| `MembershipFilter` | Ingestion time or query time | Query vector semijoin is implemented, with pruning completeness checked separately from ranking. No ingestion adapter yet. | Generic row membership filter; ordinary grouped TopK is a separate kernel | +| `Value` | Ingestion time or query time | Operation-specific subset; see next table. | Exact accumulator kernels only; no general Value dispatcher | +| `RelationalJoin` | Ingestion time or query time | Query ClickHouse relation adapter implements inner/left/right/full/cross/semi/anti joins within supported predicate/schema/value semantics. No generic maintenance join implementation. This is not a claim of all ClickHouse settings/NULL semantics. | Not extracted | +| `SummaryAgg` | Ingestion time or query time | Raw-ingestion specialization and restricted maintenance row-to-state aggregation. Factory validates family/layout/parameters. Maintenance DAG path needs a typed update evaluator, immutable inputs and installed materialization; it does not support arbitrary item expressions or output populations. No installed query-time builder. | Construction/update kernels for families below; placement-neutral | +| `SummaryJoin` | Ingestion time or query time | Ownership classified; no dispatch implementation in maintenance runtime. | No registered SummaryJoin kernel | +| `SummarySubtract` | Ingestion time or query time | Ownership classified; unsupported by maintenance runtime. | No registered SummarySubtract kernel | +| `SummaryDelete` | Ingestion time or query time | Ownership classified; no dispatch implementation in maintenance runtime. | No registered SummaryDelete kernel | +| `SummaryEstimate` | Ingestion time or query time | Typed sketch readout over compatible stored states, with family, window and population restrictions. | Underlying sketch query kernels; store/readout adapter remains backend-local | +| `SummaryMerge` | Ingestion time or query time | Planner and backend support both phases. Query merges can consume stored ingestion results and query-produced states; ingestion merges cannot depend on future query results. | Compatible-state merge kernels; no universal cross-family merge | + +A physical operator defines **what computation happens**. The plan decides +**when it happens: ingestion time or query time**. This rule applies to every +physical computation operator. Ingestion time includes background processing +of arriving data; it need not run inline with each sample. The phase column +states the design contract. The implementation column records current gaps; +it must not turn those gaps into permanent restrictions on an operator. + +The phase API uses `IngestionTime` and `QueryTime`, serialized as +`ingestion_time` and `query_time`. There are no aliases for the former names. + +### Candidate pruning is a composed subgraph + +The fused candidate-ranking operator is removed from Planner and QueryPlan. +The graph contains independently executable operations: + +1. Read membership keys from a summary. +2. Obtain authoritative values, optionally pushing the membership restriction + into an explicitly bound external request. +3. Apply `MembershipFilter`, a semijoin that preserves value-row order and + multiplicity. Membership scores never replace authoritative values. +4. Apply the ordinary grouped TopK operator. + +`MembershipFilter` has no k, grouping or ranking behavior. The shared library +provides separate `rows::membership_filter` and `rows::grouped_topk` kernels, +which other deployments can compose. A missing authoritative value fails a +certified membership plan; best-effort pruning remains explicitly approximate. +The pruning certificate stays on the filter. Exact reranking does not prove +that omitted keys could not have won. Planner still rejects uncertified pruning +for an exact request. + +External expression binding verifies the selected exact subtree against its +native expression; it does not substitute the original top-level TopK child. +Planner exports and costs filtering and ranking separately. Executable DAG wire +version 3 requires updated consumers; no fused-operator compatibility path is +retained. Backend owned-DAG schema version 3 and ingestion-DAG schema version 4 +reject incompatible installed documents. ### Every ValueOperation @@ -179,7 +206,7 @@ all subsequent readout combinations or certify approximation guarantees. | Scalar, Binary, ReduceSum | Implemented scalar/grouped value paths | | ReadMaterialization | Bound catalog/store read; requires available compatible population/windows | | SummaryEstimate, ExactReadout, SummaryMerge | Implemented for supported typed states/readouts; not raw-source construction | -| CandidateTopK | Backend reranking adapter; exact input must actually be available | +| MembershipFilter | Value-preserving membership semijoin; ordinary Logical TopKSelection ranks its output | | Relational, RelationalJoin | Backend ClickHouse value adapter subset described above; not in shared library | | Logical | Residual operator subset listed below | | ExternalExact | Declared external computation, possibly dependent on candidates; not local coverage | diff --git a/docs/developer_docs/query-engine/compiler-rule-boundary-review.md b/docs/developer_docs/query-engine/compiler-rule-boundary-review.md index 7c432093..bd00622a 100644 --- a/docs/developer_docs/query-engine/compiler-rule-boundary-review.md +++ b/docs/developer_docs/query-engine/compiler-rule-boundary-review.md @@ -12,7 +12,6 @@ Rechecked after merging main `674b9573` (#699): the four implementation files co 3. **[P2] Native residual lowering reruns Planner to rediscover the selected operator from text.** `control_plane/src/query_plan/logical.rs:537-620` parses original subexpressions and calls `select_summary_default` to construct an equality witness; `selected_aggregate_operator` then extracts the operation from that text. It fails closed on missing/ambiguous witnesses, which protects against blindly choosing an unrelated expression, but a valid selected DAG can become unlowerable when selection policy or representation changes. Carry sufficient explicit readout/operator information in Planner IR and lower that directly. The observed cross-series quantile failure in the old implementation was “native residual substitution requires an exact selected value”. -4. **[P2] CandidateTopK can bypass its selected values child.** `control_plane/src/query_plan.rs:515-603` uses the original top-level PromQL aggregate's input as an `ExternalExact` request when `logical_source` is present, instead of lowering `values`. It retains Planner k/group/completeness and derives membership identity from the selected candidate, but does not establish equivalence between the generated expression and the selected values DAG. A future Planner rewrite of that child would not be faithfully reflected. Lower the selected exact child or carry a verified native-fragment binding in the input contract. 5. **[P2] Legacy binding owns an unconditional TimeRange/Aggregate swap.** `control_plane/src/physical/post_asap/lower.rs:120-141` turns `TimeRange(Aggregate(X))` into `Aggregate(TimeRange(X))` for any aggregate at that position. This is a logical tree rewrite, with no local legality condition for the measure or grouping. Move any required canonicalization and its legality proof to Planner/frontend; the backend should consume the canonical shape. Reachable through the same legacy binding entry as finding 1; no production failure was reproduced in this audit. diff --git a/tools/o11y-execution/calibrate_runtime.py b/tools/o11y-execution/calibrate_runtime.py index 5587dca6..7b263db7 100644 --- a/tools/o11y-execution/calibrate_runtime.py +++ b/tools/o11y-execution/calibrate_runtime.py @@ -212,7 +212,7 @@ def launch(name, command): query_phase = phase(folder, "query-" + qid, before, after, time.perf_counter_ns() - start) raw = folder / f"queries-{qid}.json" runner.write_json(raw, records) - validate_candidate_topk_execution(artifact, records) + validate_membership_filter_execution(artifact, records) routes = {record["execution"] for record in records} correct = all(record["comparison"]["equal"] and record["exact"]["http_status"] == 200 for record in records) row["queries"][qid] = {"cpu_ns": query_phase["cpu_ns"], "evaluations": len(records), @@ -267,20 +267,20 @@ def launch(name, command): -def _candidate_topk_inputs(nodes, root): +def _membership_filter_inputs(nodes, root): bindings, visiting, visited = set(), set(), set() def visit(node_id): node_id = str(node_id) if node_id in visiting: - raise ValueError("CandidateTopK input DAG contains a cycle") + raise ValueError("MembershipFilter input DAG contains a cycle") if node_id in visited: return if node_id not in nodes: - raise ValueError(f"CandidateTopK input DAG references missing node {node_id}") + raise ValueError(f"MembershipFilter input DAG references missing node {node_id}") visiting.add(node_id) node = nodes[node_id] if node.get("op") == "exact_fallback": - raise ValueError("CandidateTopK input contains ExactFallback") + raise ValueError("MembershipFilter input contains ExactFallback") if node.get("op") == "read_materialization": bindings.add(str(node["binding"]["materialization"])) children = [str(value) for value in node.get("inputs", [])] @@ -294,21 +294,21 @@ def visit(node_id): return bindings -def validate_candidate_topk_artifact(artifact): - """Reject CandidateTopK plans whose membership sidecar is not locally installed.""" +def validate_membership_filter_artifact(artifact): + """Reject MembershipFilter plans whose membership sidecar is not locally installed.""" request = artifact.get("install_request", {}) schemas = {str(row["materialization"]): row for row in request.get("precompute_plan", {}).get("schemas", [])} modes = set() for entry in request.get("query_plan", {}).get("entries", {}).values(): nodes = entry.get("nodes", {}) for node in nodes.values(): - if node.get("op") != "candidate_top_k": + if node.get("op") != "membership_filter": continue inputs = node.get("inputs", []) if len(inputs) != 2: - raise ValueError("CandidateTopK requires membership and exact-value inputs") - membership_bindings = _candidate_topk_inputs(nodes, inputs[0]) - value_bindings = _candidate_topk_inputs(nodes, inputs[1]) + raise ValueError("MembershipFilter requires membership and exact-value inputs") + membership_bindings = _membership_filter_inputs(nodes, inputs[0]) + value_bindings = _membership_filter_inputs(nodes, inputs[1]) heap_bindings = [] for materialization in membership_bindings: schema = schemas.get(materialization) @@ -316,7 +316,7 @@ def validate_candidate_topk_artifact(artifact): if "CmsWithHeap" in family or "CountSketchWithHeap" in family: heap_bindings.append(materialization) if not heap_bindings: - raise ValueError("CandidateTopK membership input has no installed heap materialization") + raise ValueError("MembershipFilter membership input has no installed heap materialization") value_node = nodes.get(str(inputs[1]), {}) operator = value_node.get("operator", {}) if value_node.get("op") == "logical" else {} if operator.get("kind") == "candidate_exact_subquery": @@ -339,21 +339,21 @@ def validate_candidate_topk_artifact(artifact): and any(kind in json.dumps((schemas.get(mid) or {}).get("family", {})).lower() for kind in ("counter", "rate", "increase")) for mid in value_bindings): - raise ValueError("CandidateTopK value input has no installed ExactCounter materialization") + raise ValueError("MembershipFilter value input has no installed ExactCounter materialization") return modes -def validate_candidate_topk_execution(artifact, records): - modes = validate_candidate_topk_artifact(artifact) +def validate_membership_filter_execution(artifact, records): + modes = validate_membership_filter_artifact(artifact) if not modes: return if len(modes) != 1: - raise ValueError("mixed CandidateTopK execution contracts are not calibratable together") + raise ValueError("mixed MembershipFilter execution contracts are not calibratable together") mode = next(iter(modes)) for record in records: provenance = record.get("execution_provenance", {}) if provenance.get("raw_scan_evaluations", 0) not in (0, None): - raise ValueError("CandidateTopK execution used a forbidden local raw scan") + raise ValueError("MembershipFilter execution used a forbidden local raw scan") if mode == "candidate_filtered_exact": if record.get("execution") != "hybrid" or provenance.get("detail") != "hybrid": raise ValueError("candidate-filtered TopK did not report hybrid execution") @@ -364,12 +364,12 @@ def validate_candidate_topk_execution(artifact, records): raise ValueError(f"candidate-filtered TopK has invalid provenance: {key}") else: if record.get("execution") != "warm" or provenance.get("detail") not in (None, "asap"): - raise ValueError("local CandidateTopK execution was not warm") + raise ValueError("local MembershipFilter execution was not warm") for key in ("exact_subquery_rpcs", "exact_subquery_evaluations", "exact_branch_evaluations"): if provenance.get(key, 0) != 0: - raise ValueError(f"CandidateTopK execution used exact path: {key}") + raise ValueError(f"MembershipFilter execution used exact path: {key}") if provenance.get("summary_readout_evaluations", 0) < 2: - raise ValueError("CandidateTopK execution did not read both summary branches") + raise ValueError("MembershipFilter execution did not read both summary branches") def main(): @@ -413,7 +413,7 @@ def main(): candidates = candidate_document["candidates"] for candidate in candidates: if "manifest" in candidate and "install_request" in candidate: - validate_candidate_topk_artifact(candidate) + validate_membership_filter_artifact(candidate) for index, candidate in enumerate(candidates): if "manifest" not in candidate or "install_request" not in candidate: continue diff --git a/tools/o11y-execution/test_calibrate_runtime.py b/tools/o11y-execution/test_calibrate_runtime.py index 74a0931d..891829f3 100644 --- a/tools/o11y-execution/test_calibrate_runtime.py +++ b/tools/o11y-execution/test_calibrate_runtime.py @@ -1,14 +1,14 @@ -"""Fail-closed validation for calibrated CandidateTopK artifacts.""" +"""Fail-closed validation for calibrated MembershipFilter artifacts.""" import unittest -from calibrate_runtime import validate_candidate_topk_artifact, validate_candidate_topk_execution +from calibrate_runtime import validate_membership_filter_artifact, validate_membership_filter_execution -class CandidateTopKArtifactTests(unittest.TestCase): +class MembershipFilterArtifactTests(unittest.TestCase): def artifact(self, membership): return {"install_request": { "query_plan": {"entries": {"q": {"nodes": { - "0": {"op": "candidate_top_k", "inputs": [1, 3]}, + "0": {"op": "membership_filter", "inputs": [1, 3]}, "1": {"op": "summary_estimate", "input": 2}, "2": membership, "3": {"op": "exact_readout", "input": 4}, @@ -35,20 +35,20 @@ def candidate_filtered_artifact(self): def test_rejects_exact_membership_fallback(self): with self.assertRaisesRegex(ValueError, "contains ExactFallback"): - validate_candidate_topk_artifact(self.artifact({"op": "exact_fallback", "reason": "unsupported"})) + validate_membership_filter_artifact(self.artifact({"op": "exact_fallback", "reason": "unsupported"})) def test_rejects_uninstalled_heap_membership(self): artifact = self.artifact({"op": "read_materialization", "binding": {"materialization": 9}}) with self.assertRaisesRegex(ValueError, "no installed heap"): - validate_candidate_topk_artifact(artifact) + validate_membership_filter_artifact(artifact) def test_accepts_heap_membership_and_exact_values(self): - validate_candidate_topk_artifact( + validate_membership_filter_artifact( self.artifact({"op": "read_materialization", "binding": {"materialization": 7}}) ) - def test_ignores_plans_without_candidate_topk(self): - validate_candidate_topk_artifact({"install_request": { + def test_ignores_plans_without_membership_filter(self): + validate_membership_filter_artifact({"install_request": { "query_plan": {"entries": {"q": {"nodes": {"0": {"op": "exact_fallback"}}}}}, "precompute_plan": {"schemas": []}, }}) @@ -57,56 +57,56 @@ def test_rejects_exact_value_fallback(self): artifact = self.artifact({"op": "read_materialization", "binding": {"materialization": 7}}) artifact["install_request"]["query_plan"]["entries"]["q"]["nodes"]["3"] = {"op": "exact_fallback"} with self.assertRaisesRegex(ValueError, "contains ExactFallback"): - validate_candidate_topk_artifact(artifact) + validate_membership_filter_artifact(artifact) def test_rejects_missing_or_cyclic_input_nodes(self): artifact = self.artifact({"op": "summary_estimate", "input": 99}) with self.assertRaisesRegex(ValueError, "missing node 99"): - validate_candidate_topk_artifact(artifact) + validate_membership_filter_artifact(artifact) artifact = self.artifact({"op": "summary_estimate", "input": 2}) with self.assertRaisesRegex(ValueError, "contains a cycle"): - validate_candidate_topk_artifact(artifact) + validate_membership_filter_artifact(artifact) def test_requires_two_local_summary_readouts_at_runtime(self): artifact = self.artifact({"op": "read_materialization", "binding": {"materialization": 7}}) provenance = {"summary_readout_evaluations": 2, "exact_subquery_rpcs": 0, "exact_subquery_evaluations": 0, "exact_branch_evaluations": 0} - validate_candidate_topk_execution(artifact, [{"execution": "warm", "execution_provenance": provenance}]) + validate_membership_filter_execution(artifact, [{"execution": "warm", "execution_provenance": provenance}]) with self.assertRaisesRegex(ValueError, "both summary branches"): - validate_candidate_topk_execution(artifact, [{"execution": "warm", "execution_provenance": { + validate_membership_filter_execution(artifact, [{"execution": "warm", "execution_provenance": { **provenance, "summary_readout_evaluations": 1}}]) with self.assertRaisesRegex(ValueError, "used exact path"): - validate_candidate_topk_execution(artifact, [{"execution": "warm", "execution_provenance": { + validate_membership_filter_execution(artifact, [{"execution": "warm", "execution_provenance": { **provenance, "exact_subquery_rpcs": 1}}]) def test_accepts_one_heap_and_candidate_filtered_external_exact(self): - validate_candidate_topk_artifact(self.candidate_filtered_artifact()) + validate_membership_filter_artifact(self.candidate_filtered_artifact()) def test_candidate_filtered_contract_rejects_local_exact_state_or_unshared_input(self): artifact = self.candidate_filtered_artifact() artifact["install_request"]["precompute_plan"]["schemas"].append( {"materialization": 8, "family": {"family": "exact", "kind": "increase"}}) with self.assertRaisesRegex(ValueError, "must not install"): - validate_candidate_topk_artifact(artifact) + validate_membership_filter_artifact(artifact) artifact = self.candidate_filtered_artifact() artifact["install_request"]["query_plan"]["entries"]["q"]["nodes"]["3"]["inputs"] = [2] with self.assertRaisesRegex(ValueError, "shared membership"): - validate_candidate_topk_artifact(artifact) + validate_membership_filter_artifact(artifact) def test_candidate_filtered_execution_requires_hybrid_one_rpc_and_one_summary_read(self): artifact = self.candidate_filtered_artifact() provenance = {"detail": "hybrid", "raw_scan_evaluations": 0, "summary_readout_evaluations": 1, "exact_subquery_rpcs": 1, "exact_subquery_evaluations": 1, "exact_branch_evaluations": 1} - validate_candidate_topk_execution( + validate_membership_filter_execution( artifact, [{"execution": "hybrid", "execution_provenance": provenance}]) for key in ("summary_readout_evaluations", "exact_subquery_rpcs", "exact_subquery_evaluations", "exact_branch_evaluations"): with self.subTest(key=key), self.assertRaisesRegex(ValueError, "invalid provenance"): - validate_candidate_topk_execution(artifact, [{"execution": "hybrid", + validate_membership_filter_execution(artifact, [{"execution": "hybrid", "execution_provenance": {**provenance, key: 0}}]) with self.assertRaisesRegex(ValueError, "hybrid execution"): - validate_candidate_topk_execution( + validate_membership_filter_execution( artifact, [{"execution": "hybrid", "execution_provenance": { **provenance, "detail": "external_exact"}}]) From e0bb3d33597fd67217fdf894125face98f84bf02 Mon Sep 17 00:00:00 2001 From: zz_y Date: Wed, 23 Sep 2026 14:13:45 +0000 Subject: [PATCH 13/26] test: specify ingestion phase in derived merge fixtures --- crates/asap_types/src/derived_input.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/asap_types/src/derived_input.rs b/crates/asap_types/src/derived_input.rs index 75906330..e4596b7c 100644 --- a/crates/asap_types/src/derived_input.rs +++ b/crates/asap_types/src/derived_input.rs @@ -224,7 +224,7 @@ mod tests { .into_iter() .map(|id| OwnedPostAsapNode { id: PostAsapNodeId(id), - payload: serde_json::json!({"kind":"summary_merge"}), + payload: serde_json::json!({"kind":"summary_merge", "timing":"ingestion_time"}), output_state: state, output_schema: serde_json::json!({"fields":[],"time_index":null}), guarantee: None, From cfee4df57ae060c553b2d47539fdef0ab6f3d453 Mon Sep 17 00:00:00 2001 From: zz_y Date: Wed, 23 Sep 2026 14:15:19 +0000 Subject: [PATCH 14/26] fix: exclude query-produced states from ingestion scheduling --- data_plane/src/precompute_engine/subdag_scheduler.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/data_plane/src/precompute_engine/subdag_scheduler.rs b/data_plane/src/precompute_engine/subdag_scheduler.rs index 857309cb..ba5e8868 100644 --- a/data_plane/src/precompute_engine/subdag_scheduler.rs +++ b/data_plane/src/precompute_engine/subdag_scheduler.rs @@ -180,7 +180,7 @@ where let node = nodes .get(&id) .ok_or_else(|| ScheduleError::Invalid(format!("missing node {id}")))?; - if node.output_state == ExecutionDataState::QUERY_ROWS { + if node.output_state.timing == planner_types::post_asap::ExecutionTiming::QueryTime { return Err(ScheduleError::Invalid(format!( "query-time node {id} in precompute dependency path" ))); @@ -564,6 +564,13 @@ mod tests { execute_precompute_sink(&dag, &invalid_path_binding, PostAsapNodeId(1), key(1), ®istry, &sink), Err(ScheduleError::Invalid(message)) if message.contains("query-owned node") )); + let mut summary_dag = dag.clone(); + summary_dag.nodes[0].output_state.primitive = + planner_types::post_asap::DataPrimitive::SummaryState; + assert!(matches!( + execute_precompute_sink(&summary_dag, &invalid_path_binding, PostAsapNodeId(1), key(1), ®istry, &sink), + Err(ScheduleError::Invalid(message)) if message.contains("query-owned node") + )); assert!(matches!( execute_precompute_sink(&dag, &invalid_path_binding, PostAsapNodeId(1), key(0), ®istry, &sink), Err(ScheduleError::Invalid(message)) if message.contains("does not match") From ab6a999e315df270a1419b10cee84c009a083d99 Mon Sep 17 00:00:00 2001 From: zz_y Date: Wed, 23 Sep 2026 14:17:24 +0000 Subject: [PATCH 15/26] docs: explain remaining query-time computations in plain language --- docs/design_docs/query-dag-execution.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/design_docs/query-dag-execution.md b/docs/design_docs/query-dag-execution.md index 31eb3642..a5310d5e 100644 --- a/docs/design_docs/query-dag-execution.md +++ b/docs/design_docs/query-dag-execution.md @@ -47,7 +47,7 @@ V1 uses one plan and three value adapters rather than three semantic programs. | Adapter | Nodes and values | Scheduling | | --- | --- | --- | | Stored-summary adapter | `ReadMaterialization`, state merge, exact/sketch readout, scalar arithmetic and reduction | Reachable nodes run in topological order. Each node runs once for the requested root. | -| PromQL/MetricsQL residual adapter | Logical aggregation, binary, temporal, subquery, candidate reranking and prepared exact leaves | Demand evaluation memoized by `(node_id, evaluation_time)`. The time key is required because a subquery evaluates one dependency at several timestamps. | +| PromQL/MetricsQL query-time adapter | Logical aggregation, binary, temporal, subquery, candidate reranking and prepared exact leaves | Demand evaluation memoized by `(node_id, evaluation_time)`. The time key is required because a subquery evaluates one dependency at several timestamps. | | ClickHouse relation adapter | External relations, filters, projections and joins around stored-summary sub-DAGs | Demand evaluation memoized by `node_id`. Each edge validates its declared relation schema. Stored-summary sub-DAGs delegate to the topological adapter. | All adapters start from a `QueryPlanEntry` node. The language adapters only @@ -199,7 +199,12 @@ cannot enumerate TopK. Native matrix construction checks are distinct from packed-wire decoder limits. The SummaryAgg capability check does not validate all subsequent readout combinations or certify approximation guarantees. -### Installed QueryPlan and residual coverage +### Installed QueryPlan and query-time operation coverage + +These operations finish the query after its inputs have been obtained. For +example, stored per-series rates can feed a sum by job and then TopK. The code +calls these operations "residual" because they are the work remaining after +precomputation; that term does not mean unsupported work or external fallback. | Installed node(s) | Local execution status | | --- | --- | @@ -208,11 +213,11 @@ all subsequent readout combinations or certify approximation guarantees. | SummaryEstimate, ExactReadout, SummaryMerge | Implemented for supported typed states/readouts; not raw-source construction | | MembershipFilter | Value-preserving membership semijoin; ordinary Logical TopKSelection ranks its output | | Relational, RelationalJoin | Backend ClickHouse value adapter subset described above; not in shared library | -| Logical | Residual operator subset listed below | +| Logical | Query-time operation subset listed below | | ExternalExact | Declared external computation, possibly dependent on candidates; not local coverage | | ExactFallback | Deliberate failure handed to installed fallback policy; not an implementation | -Residual operator inventory: +Query-time operation inventory (`ResidualQueryOperator` in the code): - `CurrentSeries`: local read of an installed maintained population. - `ExactSubquery`, `CandidateExactSubquery`: prepared external exact results. @@ -223,7 +228,7 @@ Residual operator inventory: Equal/NotEqual/Less/LessEqual/Greater/GreaterEqual; typed matching and domain restrictions apply, not arbitrary PromQL binary syntax. - `Temporal`: Rate, Increase, Avg, Max, Min, Sum, Count over supported inputs. -- `Sort`, `HistogramQuantile`, `Subquery`: implemented residual paths; subqueries +- `Sort`, `HistogramQuantile`, `Subquery`: implemented query-time paths; subqueries require bounded time grids and memoization by node and evaluation time. The shared synchronous/asynchronous DAG walker schedules and memoizes nodes. It @@ -236,7 +241,7 @@ obtain a complete query engine by importing the shared crate alone. | Plan placement | Present evidence | Remaining requirement | | --- | --- | --- | | Raw only | Independent KLL consumer constructs and queries state directly | Backend local raw source plus query-time summary construction/lowering; currently not supported as a general installed query plan | -| Partially precomputed | KLL consumer merges prebuilt prefix with query-built suffix; backend can combine supported stored and residual nodes | General stored-state + raw-suffix query DAG, typed update evaluation, compatible scope/merge checks and process acceptance | +| Partially precomputed | KLL consumer merges prebuilt prefix with query-built suffix; backend can combine supported stored and query-time computation nodes | General stored-state + raw-suffix query DAG, typed update evaluation, compatible scope/merge checks and process acceptance | | Fully precomputed | Backend stored read/merge/readout paths and process tests | Valid only for supported family/schema/window/operator combinations; storage readiness remains a runtime requirement | To complete the requested contract, Planner must express valid query-time summary @@ -309,7 +314,7 @@ PromQL subqueries are the exception to a plain node-only memo key. The same node has a different result at each evaluation timestamp, so their memo key is `(node_id, evaluation_time)`. Repeated access at the same timestamp reuses the value. Prepared external leaves use the same identity and are issued before -local residual evaluation so network I/O does not hide inside a synchronous +local query-time evaluation so network I/O does not hide inside a synchronous operator. Memoization is request local. It is discarded after the root result is adapted; @@ -373,7 +378,7 @@ V1 evaluates ready nodes sequentially inside one request. Independent requests still run concurrently. Parallel execution of independent nodes is unnecessary for correctness and remains future work. External I/O uses the request client's timeout; cancellation drops the request-local evaluation and its prepared -values. Logical subqueries enforce depth and evaluation budgets to bound memory +values. Query-time subqueries enforce depth and evaluation budgets to bound memory and work. Large relation intermediates and a unified resource budget across all three From 22a6908ab973ddb94ca776070cabb18fcabf741b Mon Sep 17 00:00:00 2001 From: zz_y Date: Wed, 23 Sep 2026 15:18:57 +0000 Subject: [PATCH 16/26] docs: consolidate physical operation coverage and defer raw scan --- docs/design_docs/query-dag-execution.md | 145 ++++++++++-------------- 1 file changed, 60 insertions(+), 85 deletions(-) diff --git a/docs/design_docs/query-dag-execution.md b/docs/design_docs/query-dag-execution.md index a5310d5e..7685da71 100644 --- a/docs/design_docs/query-dag-execution.md +++ b/docs/design_docs/query-dag-execution.md @@ -97,53 +97,78 @@ output schemas/states, grouping, time scope and placement. Raw-only, partial precomputation and full precomputation must obey the same contract. An explicitly external plan may remain useful, but is not evidence of local coverage. -### All Planner executable payloads - -“Backend” below describes implemented paths and their restrictions, not a promise -that every instance of the payload is accepted. Shared kernels do not include -backend storage, relational adapters or arbitrary expression evaluation. - -| Payload | Execution phase | Backend implementation / limitation | Shared library | -| --- | --- | --- | --- | -| `Fallback` | Ingestion time or query time | Selected ingestion source boundary, prepared external exact subtree, or explicit whole-query fallback. No general local raw-query executor. | No source/SQL/PromQL executor | -| `Binary` | Ingestion time or query time | Maintenance arithmetic requires immutable completed, aligned row inputs; query scalar/vector arithmetic uses the relevant value adapter. Not arbitrary row/vector coercion. | Float64 Add/Sub/Mul/Div/Mod/Pow/Atan2; alignment and vector semantics remain backend-local | -| `MembershipFilter` | Ingestion time or query time | Query vector semijoin is implemented, with pruning completeness checked separately from ranking. No ingestion adapter yet. | Generic row membership filter; ordinary grouped TopK is a separate kernel | -| `Value` | Ingestion time or query time | Operation-specific subset; see next table. | Exact accumulator kernels only; no general Value dispatcher | -| `RelationalJoin` | Ingestion time or query time | Query ClickHouse relation adapter implements inner/left/right/full/cross/semi/anti joins within supported predicate/schema/value semantics. No generic maintenance join implementation. This is not a claim of all ClickHouse settings/NULL semantics. | Not extracted | -| `SummaryAgg` | Ingestion time or query time | Raw-ingestion specialization and restricted maintenance row-to-state aggregation. Factory validates family/layout/parameters. Maintenance DAG path needs a typed update evaluator, immutable inputs and installed materialization; it does not support arbitrary item expressions or output populations. No installed query-time builder. | Construction/update kernels for families below; placement-neutral | -| `SummaryJoin` | Ingestion time or query time | Ownership classified; no dispatch implementation in maintenance runtime. | No registered SummaryJoin kernel | -| `SummarySubtract` | Ingestion time or query time | Ownership classified; unsupported by maintenance runtime. | No registered SummarySubtract kernel | -| `SummaryDelete` | Ingestion time or query time | Ownership classified; no dispatch implementation in maintenance runtime. | No registered SummaryDelete kernel | -| `SummaryEstimate` | Ingestion time or query time | Typed sketch readout over compatible stored states, with family, window and population restrictions. | Underlying sketch query kernels; store/readout adapter remains backend-local | -| `SummaryMerge` | Ingestion time or query time | Planner and backend support both phases. Query merges can consume stored ingestion results and query-produced states; ingestion merges cannot depend on future query results. | Compatible-state merge kernels; no universal cross-family merge | - -A physical operator defines **what computation happens**. The plan decides -**when it happens: ingestion time or query time**. This rule applies to every -physical computation operator. Ingestion time includes background processing -of arriving data; it need not run inline with each sample. The phase column -states the design contract. The implementation column records current gaps; -it must not turn those gaps into permanent restrictions on an operator. +### Physical operation coverage + +A physical operation defines **what computation happens**. The plan chooses +**ingestion time or query time**. This applies to every computation operation; +ingestion time includes background processing of arriving data. Current adapter +gaps are implementation work, not permanent restrictions on execution time. + +The table lists each operation once, regardless of whether the code represents +it inside `ValueOperation`, `Logical`, or another wrapper. “Backend integration” +means an implemented path for the stated subset, not universal support. + +| Physical operation | Purpose | Shared library implementation | Backend integration | Missing coverage | +| --- | --- | --- | --- | --- | +| Raw Scan | Read raw input rows | None | Rejected in installed local query plans | Local raw source; deferred from this PR | +| Read materialization | Load previously computed state | State decoding kernels; no storage adapter | Catalog/store binding for compatible populations and windows | General raw input access; unavailable or incompatible state cannot be read | +| Maintain population | Update the current-series population | None | Specialized remote-write ingestion path | General table-row updates and shared implementation | +| Read population / CurrentSeries | Read current values from a maintained population | None | Installed population identity/capacity and current-series readout | Arbitrary raw-table reading | +| Scalar | Produce a scalar value | No separate scalar-source executor | Typed scalar path | General expression evaluation | +| Binary | Combine or compare two inputs | Float64 Add/Sub/Mul/Div/Mod/Pow/Atan2 kernels | Query arithmetic, CheckedDiv/FiniteDiv and comparisons; ingestion arithmetic on immutable completed, aligned rows | General coercion, arbitrary PromQL matching and unsupported value domains | +| Unary negate | Negate a value | No separate adapter | Typed query scalar/vector path | General ingestion adapter | +| Vector to scalar | Convert a vector to a scalar | No separate adapter | Typed query path | General ingestion adapter | +| Exact aggregate | Compute Count/Sum/Avg/Min/Max, including ReduceSum | Exact accumulator kernels | Relation and query aggregate adapters | No universal aggregate implementation; relation numeric measures require non-null Int64/Float64 and finite valid values; relation per-entity reduction and grouping-without unsupported | +| Finalize exact accumulator / ExactReadout | Obtain an exact result from typed state | Exact-family readout kernels | Typed query readout and ingestion finalization of immutable completed windows | Arbitrary state conversion and unsupported exact families | +| Project | Select or calculate output columns | No general expression executor | Query relation adapter | Unsupported expressions/types and general ingestion adapter | +| Filter | Keep rows satisfying a predicate | No general predicate executor | Query relation adapter | Unsupported predicates/types and general ingestion adapter | +| Relational join, including semi-join | Match rows by a predicate; semi-join retains matching left rows | Row membership kernel; general semi-join replacement pending; other joins remain backend-local | Relation adapter supports inner/left/right/full/cross/semi/anti joins within its predicate/schema subset; vector candidate pruning currently uses a dedicated membership adapter; general semi-join replacement pending | General ingestion join adapter and unrestricted SQL/NULL semantics | +| Sort | Order input rows or values | No general sorting adapter | Query relation and logical sorting | Relation partitioned sorting, NaN and unsupported key types; general ingestion adapter | +| Limit | Keep a bounded slice of input | No separate adapter | Query relation offset/limit and specialized logical lowering | General ingestion adapter; does not rank or match candidate keys | +| Grouped TopK | Rank values and select the best k per group | Grouped TopK kernel | Query TopK selection after authoritative values are obtained | General ingestion adapter; ranking does not prove candidate completeness | +| SummaryAgg | Construct summary state from input | Supported-family construction/update kernels | Raw-ingestion specialization and restricted row-to-state ingestion aggregation | Installed query-time builder; arbitrary item expressions/output populations; typed update evaluation required | +| SummaryMerge | Combine compatible summary states | Compatible-state merge kernels | Ingestion and query state merge | Universal cross-family merge is not supported | +| SummaryEstimate | Query a summary for an approximate result | Family-specific sketch query kernels | Typed stored-state readout | Unsupported family/readout combinations; window/population compatibility and accuracy evidence remain required | +| SummaryJoin | Combine summary inputs using summary join semantics | No registered kernel | No runtime dispatch | Concrete kernel and adapters | +| SummarySubtract | Subtract summary state | No registered kernel | Unsupported in ingestion runtime | Concrete kernel and adapters | +| SummaryDelete | Remove contributions from summary state | No registered kernel | No runtime dispatch | Concrete kernel and adapters | +| Temporal computation | Compute Rate/Increase/Avg/Max/Min/Sum/Count over time | Relevant exact accumulator kernels, not a complete temporal adapter | Query paths over supported inputs | General input/state combinations and ingestion adapter | +| Histogram quantile | Calculate a quantile from histogram buckets | No separate histogram adapter | Query path | General ingestion adapter | +| Subquery | Evaluate an expression over a time grid | DAG memoization support, not the subquery executor | Query path with bounded grids and memoization by node/evaluation time | Unbounded grids and general ingestion adapter | +| Extension | Execute an additional value operation | No general executor | Unsupported operations may route to explicit fallback | A concrete local implementation for each admitted extension | + +External computation (`ExternalExact`, `ExactSubquery`, +`CandidateExactSubquery`) and fallback are routing choices, not local physical +operation implementations. They do not fill any missing coverage in this table. +The shared DAG walker schedules and memoizes nodes but still needs backend +adapters; importing the library alone does not provide a complete query engine. + +**Deferred raw-data support:** this PR does not implement local raw Scan or +claim complete local execution when only raw data is stored. External fallback +and the independent raw-input kernel tests do not satisfy that capability. The phase API uses `IngestionTime` and `QueryTime`, serialized as -`ingestion_time` and `query_time`. There are no aliases for the former names. +`ingestion_time` and `query_time`, without aliases for former names. ### Candidate pruning is a composed subgraph The fused candidate-ranking operator is removed from Planner and QueryPlan. -The graph contains independently executable operations: +The target graph uses a general semi-join in place of the current dedicated +`MembershipFilter` adapter. That code change is pending separately from this +documentation update. The graph contains these operations: 1. Read membership keys from a summary. 2. Obtain authoritative values, optionally pushing the membership restriction into an explicitly bound external request. -3. Apply `MembershipFilter`, a semijoin that preserves value-row order and +3. Apply a general semi-join with explicit matching keys that preserves value-row order and multiplicity. Membership scores never replace authoritative values. 4. Apply the ordinary grouped TopK operator. -`MembershipFilter` has no k, grouping or ranking behavior. The shared library -provides separate `rows::membership_filter` and `rows::grouped_topk` kernels, -which other deployments can compose. A missing authoritative value fails a +The semi-join has no k, grouping or ranking behavior. The shared library currently provides `rows::membership_filter` and +`rows::grouped_topk`; the pending change replaces the former with a general +`rows::semi_join` kernel that other deployments can compose with ranking. A missing authoritative value fails a certified membership plan; best-effort pruning remains explicitly approximate. -The pruning certificate stays on the filter. Exact reranking does not prove +The pruning certificate stays on the semi-join. Exact reranking does not prove that omitted keys could not have won. Planner still rejects uncertified pruning for an exact request. @@ -154,20 +179,6 @@ version 3 requires updated consumers; no fused-operator compatibility path is retained. Backend owned-DAG schema version 3 and ingestion-DAG schema version 4 reject incompatible installed documents. -### Every ValueOperation - -| Operation | Implemented path | Limits / missing coverage | -| --- | --- | --- | -| `MaintainPopulation` | Specialized remote-write current-series maintenance | Not a general table-row update executor; not shared-library functionality | -| `ReadPopulation` | Compiled current-series readout with installed identity/capacity | Specialized maintained population, not arbitrary raw Scan | -| `Exact(Aggregate)` | Relation adapter and supported logical aggregate lowering | Relation measures Count/Sum/Avg/Min/Max; numeric Sum/Avg/Min/Max require non-null Int64/Float64 and finite valid values. Per-entity reduction and grouping-without unsupported there. No universal AggIntent implementation | -| `FinalizeExactAccumulator` | Typed exact readout; maintenance finalization of immutable completed windows | Supported exact families below; not an arbitrary state conversion | -| `Project` | Query relation adapter | Supported expression/value subset; no general maintenance implementation | -| `Filter` | Query relation predicate adapter | Supported expression/value subset; no general maintenance implementation | -| `Sort` | Query relation adapter; supported logical sorting | Relation partitioned sorting, NaN or unsupported sort-key types rejected | -| `Limit` | Query relation adapter with offset; specialized logical lowering | Not a generic maintenance operator | -| `Extension` | No general executor | Unsupported value operations can lower to explicit ExactFallback; this is not local support | - ### Summary-family and readout coverage The Planner-family factory accepts only `PerSubpopulationInstance` grouping and @@ -199,43 +210,6 @@ cannot enumerate TopK. Native matrix construction checks are distinct from packed-wire decoder limits. The SummaryAgg capability check does not validate all subsequent readout combinations or certify approximation guarantees. -### Installed QueryPlan and query-time operation coverage - -These operations finish the query after its inputs have been obtained. For -example, stored per-series rates can feed a sum by job and then TopK. The code -calls these operations "residual" because they are the work remaining after -precomputation; that term does not mean unsupported work or external fallback. - -| Installed node(s) | Local execution status | -| --- | --- | -| Scalar, Binary, ReduceSum | Implemented scalar/grouped value paths | -| ReadMaterialization | Bound catalog/store read; requires available compatible population/windows | -| SummaryEstimate, ExactReadout, SummaryMerge | Implemented for supported typed states/readouts; not raw-source construction | -| MembershipFilter | Value-preserving membership semijoin; ordinary Logical TopKSelection ranks its output | -| Relational, RelationalJoin | Backend ClickHouse value adapter subset described above; not in shared library | -| Logical | Query-time operation subset listed below | -| ExternalExact | Declared external computation, possibly dependent on candidates; not local coverage | -| ExactFallback | Deliberate failure handed to installed fallback policy; not an implementation | - -Query-time operation inventory (`ResidualQueryOperator` in the code): - -- `CurrentSeries`: local read of an installed maintained population. -- `ExactSubquery`, `CandidateExactSubquery`: prepared external exact results. -- `Scan`: explicitly rejected in deployed plans (`local raw Scan is forbidden`). -- `UnaryNegate`, `VectorToScalar`: implemented typed scalar/vector operations. -- `Aggregate`: Sum, Max, Min, Avg, Count; `TopKSelection`: grouped value ranking. -- `Binary`: Add/Sub/Mul/Div/CheckedDiv/FiniteDiv/Mod/Pow and - Equal/NotEqual/Less/LessEqual/Greater/GreaterEqual; typed matching and domain - restrictions apply, not arbitrary PromQL binary syntax. -- `Temporal`: Rate, Increase, Avg, Max, Min, Sum, Count over supported inputs. -- `Sort`, `HistogramQuantile`, `Subquery`: implemented query-time paths; subqueries - require bounded time grids and memoization by node and evaluation time. - -The shared synchronous/asynchronous DAG walker schedules and memoizes nodes. It -requires a runtime adapter; it is not an implementation of the entire inventory. -Relational and PromQL adapters remain in data_plane, so asap-fusion cannot yet -obtain a complete query engine by importing the shared crate alone. - ### Precomputation boundary: present status and required acceptance | Plan placement | Present evidence | Remaining requirement | @@ -244,7 +218,8 @@ obtain a complete query engine by importing the shared crate alone. | Partially precomputed | KLL consumer merges prebuilt prefix with query-built suffix; backend can combine supported stored and query-time computation nodes | General stored-state + raw-suffix query DAG, typed update evaluation, compatible scope/merge checks and process acceptance | | Fully precomputed | Backend stored read/merge/readout paths and process tests | Valid only for supported family/schema/window/operator combinations; storage readiness remains a runtime requirement | -To complete the requested contract, Planner must express valid query-time summary +The remaining work below describes the target contract, not a requirement to +implement local raw Scan in this PR. Planner must express valid query-time summary placement; the backend must bind local raw inputs and query-time builders; and installation must check every reachable operator against concrete adapter capabilities, including expressions, family/readout combinations and edge states. From 309bca0a4036164a7df2df56653b406a8531325c Mon Sep 17 00:00:00 2001 From: zz_y Date: Wed, 23 Sep 2026 15:25:21 +0000 Subject: [PATCH 17/26] docs: define precomputation modes independently of KLL --- docs/design_docs/query-dag-execution.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/design_docs/query-dag-execution.md b/docs/design_docs/query-dag-execution.md index 7685da71..e38bc0bc 100644 --- a/docs/design_docs/query-dag-execution.md +++ b/docs/design_docs/query-dag-execution.md @@ -212,11 +212,18 @@ all subsequent readout combinations or certify approximation guarantees. ### Precomputation boundary: present status and required acceptance -| Plan placement | Present evidence | Remaining requirement | +| Precomputation mode | Definition | Current support / remaining gaps | | --- | --- | --- | -| Raw only | Independent KLL consumer constructs and queries state directly | Backend local raw source plus query-time summary construction/lowering; currently not supported as a general installed query plan | -| Partially precomputed | KLL consumer merges prebuilt prefix with query-built suffix; backend can combine supported stored and query-time computation nodes | General stored-state + raw-suffix query DAG, typed update evaluation, compatible scope/merge checks and process acceptance | -| Fully precomputed | Backend stored read/merge/readout paths and process tests | Valid only for supported family/schema/window/operator combinations; storage readiness remains a runtime requirement | +| No precomputation | The query starts from raw data and performs all required computation at query time. | General local raw input and query-time summary construction are not supported in installed plans; deferred from this PR. | +| Partial precomputation | The query reuses previously computed results or states and performs the remaining computation at query time. Inputs may combine stored states, stored values, and raw data. | Supported stored-state and query-time operations can be combined. General plans requiring local raw input or query-time summary construction remain incomplete. | +| Full precomputation | All data-dependent computation needed for the query result has been performed before the query arrives. Query execution retrieves the prepared result and formats the response. | Supported only where the prepared result matches the requested query and time scope and is available. Reading stored summaries followed by merging, estimation, aggregation or ranking is partial precomputation. | + +These definitions are independent of any particular algorithm. The KLL consumer +tests are examples of constructing, merging, and querying state across different +precomputation boundaries. They demonstrate reusable kernel behavior, not +complete backend support for all three modes. In particular, a test that queries +a prebuilt KLL state still performs estimation at query time; it does not +demonstrate full precomputation of the query result. The remaining work below describes the target contract, not a requirement to implement local raw Scan in this PR. Planner must express valid query-time summary From 0050c533911d62b33e808a13df188bb97bbbcbfa Mon Sep 17 00:00:00 2001 From: zz_y Date: Wed, 23 Sep 2026 15:26:46 +0000 Subject: [PATCH 18/26] docs: align query execution diagram with physical operation terminology --- docs/design_docs/query-dag-execution.md | 32 ++++++++++++++++++------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/docs/design_docs/query-dag-execution.md b/docs/design_docs/query-dag-execution.md index e38bc0bc..685c6581 100644 --- a/docs/design_docs/query-dag-execution.md +++ b/docs/design_docs/query-dag-execution.md @@ -24,17 +24,31 @@ miss and follows the installed routing policy. ```mermaid flowchart LR - Request[Canonical query request] --> Lookup[Lookup QueryPlanEntry] - Lookup --> Root[Start at entry.root] - Root --> Walk[Find reachable sub-DAG] - Walk --> Inputs[Evaluate dependencies] - Inputs --> Node[Execute node adapter] - Node --> Memo[Memoize node output] - Memo --> Result[Adapt root output] - Store[SummaryStore] -->|StoredOutputReference| Inputs - Exact[External exact engine] -->|declared exact leaf| Inputs + Request[Query request] --> Lookup[Find installed query physical plan] + Lookup --> Root[Identify query result operation] + Root --> Walk[Find required physical operations] + Walk --> Inputs[Obtain operation inputs] + Inputs --> Execute[Execute physical operation] + Execute --> Memo[Cache result within this request] + Memo --> Result[Return query result] + Store[Stored materializations] --> Read[Read materialization] + Read --> Inputs + External[Declared external computation] --> Inputs ``` +The installed query physical plan is represented by `QueryPlanEntry`; its `root` +identifies the operation producing the query result. Required input operations +execute before their consumers. The execution adapter invokes the implementation +of each physical operation, and request-local caching avoids repeated evaluation +of shared dependencies. Operations evaluated at multiple query times are cached +separately for each evaluation time. + +“Read materialization” is the operation listed in the coverage table. It obtains +stored state from `SummaryStore` through a `StoredOutputReference`. Declared +external computation supplies an explicitly bound input; it is not a local +physical operation implementation. + + Installation rejects missing inputs, cycles, unreachable nodes, invalid output bindings, unsupported provenance versions, and a reader whose window contract or `StoredOutputReference` differs from its PrecomputePlan writer. Runtime From f0084ade27900fa21eb455b07b579c5cbe59c696 Mon Sep 17 00:00:00 2001 From: zz_y Date: Wed, 23 Sep 2026 15:29:16 +0000 Subject: [PATCH 19/26] docs: clarify current-series state operations --- docs/design_docs/query-dag-execution.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/design_docs/query-dag-execution.md b/docs/design_docs/query-dag-execution.md index 685c6581..50fa3a75 100644 --- a/docs/design_docs/query-dag-execution.md +++ b/docs/design_docs/query-dag-execution.md @@ -126,8 +126,8 @@ means an implemented path for the stated subset, not universal support. | --- | --- | --- | --- | --- | | Raw Scan | Read raw input rows | None | Rejected in installed local query plans | Local raw source; deferred from this PR | | Read materialization | Load previously computed state | State decoding kernels; no storage adapter | Catalog/store binding for compatible populations and windows | General raw input access; unavailable or incompatible state cannot be read | -| Maintain population | Update the current-series population | None | Specialized remote-write ingestion path | General table-row updates and shared implementation | -| Read population / CurrentSeries | Read current values from a maintained population | None | Installed population identity/capacity and current-series readout | Arbitrary raw-table reading | +| Maintain current-series state | Update the maintained values and timestamps for incoming time series. | None | Specialized remote-write ingestion path | General table-row updates and shared implementation | +| Read current-series state | Read values from the maintained time-series state for query execution. | None | Current-series readout using the installed state identity and capacity | Arbitrary raw-table reading | | Scalar | Produce a scalar value | No separate scalar-source executor | Typed scalar path | General expression evaluation | | Binary | Combine or compare two inputs | Float64 Add/Sub/Mul/Div/Mod/Pow/Atan2 kernels | Query arithmetic, CheckedDiv/FiniteDiv and comparisons; ingestion arithmetic on immutable completed, aligned rows | General coercion, arbitrary PromQL matching and unsupported value domains | | Unary negate | Negate a value | No separate adapter | Typed query scalar/vector path | General ingestion adapter | @@ -151,6 +151,11 @@ means an implemented path for the stated subset, not universal support. | Subquery | Evaluate an expression over a time grid | DAG memoization support, not the subquery executor | Query path with bounded grids and memoization by node/evaluation time | Unbounded grids and general ingestion adapter | | Extension | Execute an additional value operation | No general executor | Unsupported operations may route to explicit fallback | A concrete local implementation for each admitted extension | +The current-series operations maintain and read a set of time series and their +current values; they do not provide a general raw-table scan or full historical +read. Their code names are `MaintainPopulation` for updates and `ReadPopulation` +/ `CurrentSeries` for reads. + External computation (`ExternalExact`, `ExactSubquery`, `CandidateExactSubquery`) and fallback are routing choices, not local physical operation implementations. They do not fill any missing coverage in this table. From 5aded532969bff7363220a0deb3f4961291bedff Mon Sep 17 00:00:00 2001 From: zz_y Date: Wed, 23 Sep 2026 16:00:56 +0000 Subject: [PATCH 20/26] docs: define independent DAG runtime shared by both engines --- crates/asap-physical-operators/README.md | 10 +- docs/design_docs/query-dag-execution.md | 239 ++++++++++++------ .../control-plane/physical-compiler.md | 2 +- docs/developer_docs/maintenance-replay.md | 2 +- docs/evaluation/e2e-physical-dag.md | 2 +- 5 files changed, 180 insertions(+), 75 deletions(-) diff --git a/crates/asap-physical-operators/README.md b/crates/asap-physical-operators/README.md index d68b7b69..d5e05882 100644 --- a/crates/asap-physical-operators/README.md +++ b/crates/asap-physical-operators/README.md @@ -1,6 +1,6 @@ # ASAP physical operators -Shared Rust kernels for maintenance-time and query-time execution. Used by +Shared Rust kernels for ingestion time and query time execution. Used by ASAPQuery-backend; other deployments can depend on the `asap-physical-operators` package in this Git repository. No backend server, storage implementation, background worker, Arrow or DataFusion version is required. @@ -52,3 +52,11 @@ groups, retaining input order for ties and placing NaN after numeric values. Neither kernel knows about sketches, storage, execution phase or external queries. The deployment enforces the pruning certificate; filtering and ranking remain separate operations in the installed graph. + +## Shared DAG execution design + +The target is an independently implemented ASAP DAG runtime and physical +operator library used by both the precompute engine and the query engine. +DataFusion is a design reference, not the execution framework. The current kernels and backend-driven traversal are not yet +that implementation. See the [design document](../../docs/design_docs/query-dag-execution.md) +for operator responsibilities, engine integration, and shared-dependency semantics. diff --git a/docs/design_docs/query-dag-execution.md b/docs/design_docs/query-dag-execution.md index 50fa3a75..d1bbbd39 100644 --- a/docs/design_docs/query-dag-execution.md +++ b/docs/design_docs/query-dag-execution.md @@ -1,10 +1,10 @@ # QueryPlan DAG execution -Audience: backend designers and developers. +Audience: system designers and deployment implementers. This document describes how the backend executes an installed `QueryPlan`. The [plan split](asapplanner-integration.md) defines why query-time work is -separate from maintenance, and the +separate from ingestion-time work, and the [SDS contract](summary-catalog-sds-architecture.md) defines the stored records read by the plan. @@ -54,53 +54,161 @@ bindings, unsupported provenance versions, and a reader whose window contract or `StoredOutputReference` differs from its PrecomputePlan writer. Runtime errors retain the query ID and node ID. -## Execution layers +## Shared DAG execution -V1 uses one plan and three value adapters rather than three semantic programs. +**Decision: ASAP will independently implement the shared DAG runtime and +physical operators. Both the precompute engine and the query engine will use +this library. DataFusion is a design reference, not the execution framework.** -| Adapter | Nodes and values | Scheduling | -| --- | --- | --- | -| Stored-summary adapter | `ReadMaterialization`, state merge, exact/sketch readout, scalar arithmetic and reduction | Reachable nodes run in topological order. Each node runs once for the requested root. | -| PromQL/MetricsQL query-time adapter | Logical aggregation, binary, temporal, subquery, candidate reranking and prepared exact leaves | Demand evaluation memoized by `(node_id, evaluation_time)`. The time key is required because a subquery evaluates one dependency at several timestamps. | -| ClickHouse relation adapter | External relations, filters, projections and joins around stored-summary sub-DAGs | Demand evaluation memoized by `node_id`. Each edge validates its declared relation schema. Stored-summary sub-DAGs delegate to the topological adapter. | +The shared library executes a physical operator DAG for both the precompute +engine and the query engine. It is designed around that execution contract, +not around the current backend's function or module boundaries. The existing +accumulator library and backend-driven query traversal do not yet provide this +architecture. + +```mermaid +flowchart TB + Planner[Post-ASAP physical plan] --> Bind[Bind and validate physical operators] + Bind --> DAG[Executable physical operator DAG] + Precompute[Precompute engine] -->|Ingestion inputs and windows| Run[Shared DAG execution library] + Query[Query engine] -->|Query inputs and evaluation time| Run + DAG --> Run + Run --> States[States and materialized results] + Run --> Results[Query results] +``` -All adapters start from a `QueryPlanEntry` node. The language adapters only -represent different runtime value types. They cannot select a replacement -definition or reconstruct an operator from the request text. +The compiler binds each operation to a concrete implementation and checks its +input and output types before accepting the plan. Both engines execute the +result through the same library. Neither engine supplies a second interpretation +of Filter, Project, Aggregate, SummaryMerge, Sort, or Limit. + +An operation defines its computation, typed inputs and outputs, and requirements +such as input ordering and grouping. An execution instance owns the changing +state for one run. Keeping the plan separate from running state allows the same +plan to serve concurrent queries and ingestion windows without sharing mutable +accumulators accidentally. + +Operations consume and produce batches incrementally where their semantics +allow it. Filter and Project can emit results as batches arrive. Sorting a +complete group must wait until that group's input is complete. Grouped Limit +counts across batches, not separately within each batch. For ingestion, the +engine supplies window completion; for a query, the engine supplies the +requested input range. These requirements do not make an operator exclusive to +one execution phase. + +### Engine responsibilities + +| Component | Responsibility | +| --- | --- | +| Shared DAG execution library | Physical operator implementations, typed expressions, state construction/merge/readout, dependency execution, shared intermediate results, cancellation, and execution resource accounting | +| Precompute engine | Connect ingestion sources, assign data to the intended windows, supply completion signals, and persist or restore operator state and materialized results | +| Query engine | Bind request parameters and evaluation times, connect stored or declared external inputs, invoke the shared DAG, and format results | +| Deployment integration | Supply source/sink implementations, storage, scheduling resources, and durability policy | + +The library does not depend on either engine. Another deployment such as +asap-fusion can bind its own sources and sinks to the same physical operators. +Every computation operator may run at ingestion time or query time; the plan +chooses when, and the engine supplies the appropriate inputs and execution scope. + +### Shared dependencies + +A DAG can have several consumers of the same operation. The execution library +must represent that shared identity explicitly. Reusing a plan object alone does +not establish that its computation runs once. + +Within a query, **request-local caching of intermediate results** prevents +repeated evaluation of a shared dependency. Time-dependent results are separated +by evaluation time. During ingestion, sharing is scoped to the same execution +and input window. Results from distinct requests or windows are never mixed. + +For streaming output, the runtime delivers the same produced batches to each +consumer. Buffering is bounded and participates in the execution memory budget; +a slow consumer cannot cause unlimited retention. Cancelling one consumer does +not stop a producer still needed by another. Cancelling the whole execution +releases its streams, intermediate results, and tasks. ## Shared physical operator library -`crates/asap-physical-operators` owns the concrete accumulator kernels, typed -state/update traits, Planner-family factory, scalar arithmetic, row membership filtering, grouped TopK, and installed -QueryPlan DAG traversal. Both maintenance and query execution import this crate -directly; the old data-plane operator/factory modules are removed. A deployment -such as asap-fusion can depend on the library without importing `data_plane` or -`control_plane`, and without taking a dependency on this backend's Arrow version. - -The compiler calls the library's allocation-free `validate_summary_kernel` -when binding a `SummaryAgg`. Runtime construction uses the same validation. -Unsupported family/layout combinations and invalid parameters are rejected -before the accumulator runs. This is a summary-kernel capability check, not a -claim that every Planner payload has a complete local implementation. - -Kernels do not own execution placement. Their state can be constructed during -maintenance or during a query, and the same merge/readout implementation handles -raw-only, partially precomputed and fully precomputed inputs. The independent -library integration test exercises these three boundaries with KLL. This test -checks operator reuse; it does not claim that the backend's currently forbidden -raw Scan has become an installed query source. - -Storage reads, population/window selection, expression-to-update evaluation, -transport, language result adaptation and scheduling policy remain deployment -responsibilities. In particular, Planner's current restrictions on summary construction -still limit which query-time summary DAGs can be exported. Completing -that contract requires Planner placement support and backend raw-source binding; -classifying an enum variant is not proof of local executability. +The library's unit of composition is an executable physical operator. Each +operator exposes its input dependencies, output schema, execution requirements, +and a way to start execution. Filter, Project, Aggregate, Join, Sort, Limit, +and summary operations participate in this same contract. + +Typed scalar expressions are separate from operations over batches. A literal, +negation, or comparison can be evaluated inside Project or Filter without +inventing a separate DAG node for every expression. Where Planner represents a +standalone scalar-producing operation, it uses the same expression semantics. +All accepted value types and nullability rules follow Planner's contract. + +Existing mathematical algorithms may supply internal kernels, but their current +backend wrappers do not define the new operator API. Moving helper functions +into a crate is not sufficient: both engines must instantiate and execute the +shared operators through the shared DAG runtime. Removed backend-specific +execution paths must not survive as compatibility branches. + +## DataFusion reuse vs. independent implementation + +The alternatives are to build on DataFusion's execution framework and general +operators, adding ASAP-specific operators, or to implement ASAP's own DAG runtime +and physical operators. This design selects independent implementation. + +| Concern | Reuse DataFusion | Independent ASAP implementation | +| --- | --- | --- | +| General computations | Reuse existing expressions, projection, filtering, joins, sorting, and aggregation where their semantics match Planner | Implement and test the supported operations and expression semantics against Planner's contract | +| Execution model | Adopt its physical-plan interfaces and batch streams; integrate ASAP-specific execution requirements | Define node identity, typed edges, execution instances, and multi-consumer behavior as the library's core contract | +| Shared dependencies | Shared references to a plan object do not by themselves guarantee shared execution; additional coordination is needed | One producer execution per node, input partition, and evaluation scope, with explicit result delivery to all consumers | +| Summary state | Supply custom accumulators/operators and integrate the required state lifecycle | Treat summary construction, updates, merge, readout, snapshots, and restoration as native operator capabilities | +| Ingestion and query execution | Adapt both engines to DataFusion while adding ASAP's window and persistence behavior | Use the same runtime and operators in both engines; engines supply their inputs, execution scope, and persistence integration | +| Resource management | Reuse framework facilities where applicable, while accounting for ASAP-specific state and sharing | Implement bounded buffering, memory accounting, backpressure, cancellation, and cleanup | +| Dependencies and maintenance | Accept DataFusion/Arrow interface and version constraints | Own the execution API and its maintenance; accept greater implementation and verification work | + +DataFusion's +[ExecutionPlan interface](https://docs.rs/datafusion/latest/datafusion/physical_plan/trait.ExecutionPlan.html) +represents input dependencies through shared plan references and starts execution +by returning a batch stream. This permits shared references in the plan +representation; it does not establish a general execute-once guarantee for a +producer with multiple consumers. ASAP's decision is therefore not based on a +claim that DataFusion cannot represent a shared node. It is based on making +shared execution and the summary-state lifecycle explicit parts of ASAP's own +runtime contract. + +ASAP needs both a finite query execution and ingestion execution over successive +windows. The same summary producer may feed several computations or sinks. +The runtime must coordinate those consumers without duplicating updates, +mixing evaluation times, or cancelling work still needed elsewhere. Persisted +state also requires explicit snapshot and restoration semantics. DataFusion's +[Accumulator interface](https://docs.rs/datafusion/latest/datafusion/logical_expr/trait.Accumulator.html) +provides update, merge, and result operations, but its intermediate-state export +can consume state; that interface alone is not an ingestion checkpoint protocol. + +Independent implementation gives ASAP direct control over these behaviors and +keeps the shared library usable by other deployments. The cost is substantial: +ASAP must implement and test the general operators, type and null semantics, +stream lifecycle, and resource controls rather than assume a framework supplies +them. The coverage table must continue to report incomplete implementations. + +DataFusion remains a reference for separating immutable operator definitions +from execution state, batch-stream processing, typed +[physical expressions](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html), +and operator input/output requirements. Existing sketch algorithms and suitable +low-level libraries may be reused internally. This does not authorize retaining +the current backend executor as a second execution path. Choosing a batch memory +format is separate from choosing the DAG runtime; independence does not require +reimplementing every buffer or mathematical primitive. + +Acceptance of the new runtime must include a shared producer with two consumers, +consumers progressing at different rates, cancellation of one consumer, failure +propagation, and isolation between query times and ingestion windows. Stateful +operators must also survive snapshot and restoration without applying a committed +input twice. The execute-once guarantee within one execution does not by itself +prove correct recovery after a restart. These tests complement operator result +checks and must run through both engines' integration with the shared library. ## Physical operator coverage and acceptance contract Coverage describes this PR and the immutable Planner revision in `Cargo.toml`. -**This PR does not yet meet the universal local-execution contract.** +**The current code does not yet implement the shared DAG architecture or meet +the universal local-execution contract.** An exhaustive phase match proves ownership only. A reusable kernel proves an algorithm implementation exists; neither proves that a concrete installed plan can obtain its inputs and execute every node locally. @@ -139,7 +247,6 @@ means an implemented path for the stated subset, not universal support. | Relational join, including semi-join | Match rows by a predicate; semi-join retains matching left rows | Row membership kernel; general semi-join replacement pending; other joins remain backend-local | Relation adapter supports inner/left/right/full/cross/semi/anti joins within its predicate/schema subset; vector candidate pruning currently uses a dedicated membership adapter; general semi-join replacement pending | General ingestion join adapter and unrestricted SQL/NULL semantics | | Sort | Order input rows or values | No general sorting adapter | Query relation and logical sorting | Relation partitioned sorting, NaN and unsupported key types; general ingestion adapter | | Limit | Keep a bounded slice of input | No separate adapter | Query relation offset/limit and specialized logical lowering | General ingestion adapter; does not rank or match candidate keys | -| Grouped TopK | Rank values and select the best k per group | Grouped TopK kernel | Query TopK selection after authoritative values are obtained | General ingestion adapter; ranking does not prove candidate completeness | | SummaryAgg | Construct summary state from input | Supported-family construction/update kernels | Raw-ingestion specialization and restricted row-to-state ingestion aggregation | Installed query-time builder; arbitrary item expressions/output populations; typed update evaluation required | | SummaryMerge | Combine compatible summary states | Compatible-state merge kernels | Ingestion and query state merge | Universal cross-family merge is not supported | | SummaryEstimate | Query a summary for an approximate result | Family-specific sketch query kernels | Typed stored-state readout | Unsupported family/readout combinations; window/population compatibility and accuracy evidence remain required | @@ -148,9 +255,15 @@ means an implemented path for the stated subset, not universal support. | SummaryDelete | Remove contributions from summary state | No registered kernel | No runtime dispatch | Concrete kernel and adapters | | Temporal computation | Compute Rate/Increase/Avg/Max/Min/Sum/Count over time | Relevant exact accumulator kernels, not a complete temporal adapter | Query paths over supported inputs | General input/state combinations and ingestion adapter | | Histogram quantile | Calculate a quantile from histogram buckets | No separate histogram adapter | Query path | General ingestion adapter | -| Subquery | Evaluate an expression over a time grid | DAG memoization support, not the subquery executor | Query path with bounded grids and memoization by node/evaluation time | Unbounded grids and general ingestion adapter | +| Subquery | Evaluate an expression over a time grid | Request-local caching of intermediate results; full shared DAG runtime pending | Query path with bounded grids and request-local caching by operation and evaluation time | Unbounded grids and general ingestion adapter | | Extension | Execute an additional value operation | No general executor | Unsupported operations may route to explicit fallback | A concrete local implementation for each admitted extension | +Grouped TopK is represented in the target plan as Sort followed by Limit within +each group. The current dedicated TopK plan node must be replaced, and Limit +needs an explicit grouping contract. A global Limit is not equivalent. An +optimized kernel may execute the composition without changing its meaning. +Candidate completeness remains a condition on pruning, not on ranking. + The current-series operations maintain and read a set of time series and their current values; they do not provide a general raw-table scan or full historical read. Their code names are `MaintainPopulation` for updates and `ReadPopulation` @@ -159,16 +272,13 @@ read. Their code names are `MaintainPopulation` for updates and `ReadPopulation` External computation (`ExternalExact`, `ExactSubquery`, `CandidateExactSubquery`) and fallback are routing choices, not local physical operation implementations. They do not fill any missing coverage in this table. -The shared DAG walker schedules and memoizes nodes but still needs backend +The shared DAG walker schedules operations and caches intermediate results within each request but still needs backend adapters; importing the library alone does not provide a complete query engine. **Deferred raw-data support:** this PR does not implement local raw Scan or claim complete local execution when only raw data is stored. External fallback and the independent raw-input kernel tests do not satisfy that capability. -The phase API uses `IngestionTime` and `QueryTime`, serialized as -`ingestion_time` and `query_time`, without aliases for former names. - ### Candidate pruning is a composed subgraph The fused candidate-ranking operator is removed from Planner and QueryPlan. @@ -181,7 +291,7 @@ documentation update. The graph contains these operations: into an explicitly bound external request. 3. Apply a general semi-join with explicit matching keys that preserves value-row order and multiplicity. Membership scores never replace authoritative values. -4. Apply the ordinary grouped TopK operator. +4. Sort authoritative values and apply Limit independently within each group. The semi-join has no k, grouping or ranking behavior. The shared library currently provides `rows::membership_filter` and `rows::grouped_topk`; the pending change replaces the former with a general @@ -193,10 +303,8 @@ for an exact request. External expression binding verifies the selected exact subtree against its native expression; it does not substitute the original top-level TopK child. -Planner exports and costs filtering and ranking separately. Executable DAG wire -version 3 requires updated consumers; no fused-operator compatibility path is -retained. Backend owned-DAG schema version 3 and ingestion-DAG schema version 4 -reject incompatible installed documents. +Planner represents and costs filtering and ranking separately. Incompatible +installed plans are rejected; removed operators have no compatibility path. ### Summary-family and readout coverage @@ -262,16 +370,6 @@ coverage or universal executability is claimed until these tests exist and pass. ### Evidence and verification limits -Source map (paths relative to repository root): - -- `control_plane/src/physical/executable_binding.rs`: phase ownership and SummaryAgg admission; ownership is not whole-graph capability validation. -- `control_plane/src/query_plan.rs`, `control_plane/src/physical/maintained_population.rs`: lowering and specialized population handling. -- `crates/asap-physical-operators/src/capability.rs`, `factory.rs`, `query_dag.rs`: shared admission, construction and adapter-driven scheduling. -- `data_plane/src/precompute_engine/raw_dag.rs`, `maintenance_runtime.rs`: raw specialization, implemented maintenance dispatch and unsupported branches. -- `data_plane/src/query_engines/asap_query_engine/{post_asap_readout,logical_dag,summary_executor,exact_subqueries}.rs`: query adapters and raw/external boundaries. -- `data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter.rs` and its `aggregate.rs`: relation subset and rejection conditions. -- `crates/asap_types/src/query_plan.rs` and `query_plan/residual.rs`: complete installed node/operator inventory. - Shared-library tests cover kernels, a KLL three-boundary consumer, invalid KLL parameters and native CountSketch dimensions. Backend tests cover supported DAG, maintenance, readout and relation paths. Passing these suites is not a proof that @@ -308,17 +406,17 @@ a partial accelerated answer. ## Dependency ordering and reuse Each evaluation derives only the sub-DAG reachable from the requested root. -Dependencies complete before their consumer. Output memoization makes a diamond +Dependencies complete before their consumer. Request-local caching of intermediate results makes a diamond graph execute its shared node once within that evaluation. -PromQL subqueries are the exception to a plain node-only memo key. The same -node has a different result at each evaluation timestamp, so their memo key is +PromQL subqueries are the exception to a cache key containing only the operation identity. The same +node has a different result at each evaluation timestamp, so their cache key is `(node_id, evaluation_time)`. Repeated access at the same timestamp reuses the value. Prepared external leaves use the same identity and are issued before local query-time evaluation so network I/O does not hide inside a synchronous operator. -Memoization is request local. It is discarded after the root result is adapted; +The cache is request local. It is discarded after the root result is adapted; the SummaryStore is the cross-request reuse boundary. A summary revision fence prevents a response from combining payloads changed during one evaluation. @@ -364,26 +462,25 @@ readout sub-DAG. No query rebuilds the KLL and no serving-time catalog search chooses a different summary. Within one entry, two parents may also share a read or relational node. The -request-local memo returns its existing value to the second parent. Across the +request-local cache returns its existing value to the second parent. Across the p50 and p99 requests, payload reuse comes from SummaryStore rather than a cross-request executor cache. ## Concurrency, cancellation and limits -Requests execute concurrently and own separate memo maps and intermediate +Requests execute concurrently and own separate result caches and intermediate values. The active physical plan and committed stored summaries are shared through immutable snapshots or synchronized store indexes. No mutable execution context is shared between requests. -V1 evaluates ready nodes sequentially inside one request. Independent requests +The current backend evaluates ready nodes sequentially inside one request. Independent requests still run concurrently. Parallel execution of independent nodes is unnecessary for correctness and remains future work. External I/O uses the request client's timeout; cancellation drops the request-local evaluation and its prepared values. Query-time subqueries enforce depth and evaluation budgets to bound memory and work. -Large relation intermediates and a unified resource budget across all three -adapters remain follow-up work. The current implementation also does not cache +Large relation intermediates and a unified execution resource budget remain follow-up work. The current implementation also does not cache root results across requests, add a distributed query scheduler, or reuse stored payloads across plan versions without the activation-time compatibility check. @@ -394,8 +491,8 @@ payloads across plan versions without the activation-time compatibility check. entry; it does not compile a new execution graph. 3. The engine validates the entry against the active catalog generation. 4. Declared external leaves are prepared when allowed. -5. The appropriate value adapter evaluates the reachable sub-DAG with - request-local memoization. +5. The target shared runtime executes the reachable physical operator DAG with + request-local caching of intermediate results. 6. `ReadMaterialization` nodes resolve their bound ready stored summaries. 7. Node failures carry query/node context and follow the installed fallback policy. diff --git a/docs/developer_docs/control-plane/physical-compiler.md b/docs/developer_docs/control-plane/physical-compiler.md index 29540073..656ec007 100644 --- a/docs/developer_docs/control-plane/physical-compiler.md +++ b/docs/developer_docs/control-plane/physical-compiler.md @@ -205,7 +205,7 @@ 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, +Execution uses the validated topological order and caches every operation result within the request, so a shared node in a diamond DAG performs one store/operator execution. A typed node failure follows the entry's explicit fallback route. diff --git a/docs/developer_docs/maintenance-replay.md b/docs/developer_docs/maintenance-replay.md index e40d48e4..2cf3dcfe 100644 --- a/docs/developer_docs/maintenance-replay.md +++ b/docs/developer_docs/maintenance-replay.md @@ -7,7 +7,7 @@ older description of a merge-only adapter is obsolete. ## Plans and runtime capability -Planner emits the actual maintenance-time operations. The control plane binds +Planner emits the actual ingestion-time operations. The control plane binds source and derived `SummaryDefinitionId`s into the existing owned executable DAG, then validates the complete PrecomputePlan against SummaryCatalog. Raw inputs update raw materializations. Derived configurations must not receive raw diff --git a/docs/evaluation/e2e-physical-dag.md b/docs/evaluation/e2e-physical-dag.md index a9cbfe10..0666cefb 100644 --- a/docs/evaluation/e2e-physical-dag.md +++ b/docs/evaluation/e2e-physical-dag.md @@ -157,7 +157,7 @@ jq '.precompute_plan | {materializations, executable_dags}' \ target/physical-dag-inspection/selected.json ``` -Look for explicit maintenance-time operations and frontier bindings. Raw +Look for explicit ingestion-time operations and frontier bindings. Raw materializations receive samples; a derived materialization consumes its bound immutable input program and must not receive raw backfill/collector jobs. The runtime supports only its validated operator, schema, grouping and window From 1591b288d8a4e08c2eb6c7fdeaea7490ecaa4913 Mon Sep 17 00:00:00 2001 From: zz_y Date: Wed, 23 Sep 2026 17:13:49 +0000 Subject: [PATCH 21/26] Implement independent shared physical DAG runtime and native operators --- Cargo.lock | 3 +- crates/asap-physical-operators/Cargo.toml | 3 +- crates/asap-physical-operators/README.md | 126 +- crates/asap-physical-operators/src/dag/mod.rs | 515 ++++++++ .../src/dag/operators.rs | 1170 +++++++++++++++++ .../src/dag/planner.rs | 479 +++++++ .../asap-physical-operators/src/dag/tests.rs | 260 ++++ .../asap-physical-operators/src/dag/values.rs | 318 +++++ crates/asap-physical-operators/src/lib.rs | 3 +- .../asap-physical-operators/src/query_dag.rs | 345 ----- .../tests/physical_dag.rs | 663 ++++++++++ .../precompute_engine/maintenance_runtime.rs | 28 + .../src/precompute_engine/subdag_scheduler.rs | 210 ++- .../asap_query_engine/logical_dag.rs | 425 +++++- .../asap_query_engine/post_asap_readout.rs | 141 +- .../storage_engines/sketch_db/index/mod.rs | 4 + docs/design_docs/query-dag-execution.md | 123 +- .../control-plane/physical-compiler.md | 3 +- 18 files changed, 4238 insertions(+), 581 deletions(-) create mode 100644 crates/asap-physical-operators/src/dag/mod.rs create mode 100644 crates/asap-physical-operators/src/dag/operators.rs create mode 100644 crates/asap-physical-operators/src/dag/planner.rs create mode 100644 crates/asap-physical-operators/src/dag/tests.rs create mode 100644 crates/asap-physical-operators/src/dag/values.rs delete mode 100644 crates/asap-physical-operators/src/query_dag.rs create mode 100644 crates/asap-physical-operators/tests/physical_dag.rs diff --git a/Cargo.lock b/Cargo.lock index a2893858..60416ee9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -401,16 +401,15 @@ dependencies = [ "asap_sketch_codec", "asap_sketchlib 0.3.0 (git+https://github.com/ProjectASAP/asap_sketchlib?branch=main)", "asap_types", - "async-trait", "base64 0.21.7", "bincode", + "futures", "hex", "prost", "rmp-serde", "serde", "serde_json", "thiserror 1.0.69", - "tokio", "tracing", "xxhash-rust", ] diff --git a/crates/asap-physical-operators/Cargo.toml b/crates/asap-physical-operators/Cargo.toml index 503db515..60127171 100644 --- a/crates/asap-physical-operators/Cargo.toml +++ b/crates/asap-physical-operators/Cargo.toml @@ -4,6 +4,7 @@ version.workspace = true edition.workspace = true [dependencies] +futures = "0.3" asap_types.workspace = true planner-types.workspace = true asap_sketch_codec = { path = "../asap_sketch_codec" } @@ -12,7 +13,6 @@ serde.workspace = true serde_json.workspace = true tracing.workspace = true thiserror.workspace = true -async-trait = "0.1" base64 = "0.21" bincode = "1.3" rmp-serde = "1.3" @@ -24,5 +24,4 @@ default = [] extra_debugging = [] [dev-dependencies] -tokio.workspace = true hex = "0.4" diff --git a/crates/asap-physical-operators/README.md b/crates/asap-physical-operators/README.md index d5e05882..e21e6c68 100644 --- a/crates/asap-physical-operators/README.md +++ b/crates/asap-physical-operators/README.md @@ -1,62 +1,80 @@ # ASAP physical operators -Shared Rust kernels for ingestion time and query time execution. Used by -ASAPQuery-backend; other deployments can depend on the `asap-physical-operators` -package in this Git repository. No backend server, storage implementation, -background worker, Arrow or DataFusion version is required. - -The public `planner` export names the exact Planner types used by the library. -`capability::validate_summary_kernel` checks family, parameters, update layout -and grouping without allocating state. `factory::create_planner_accumulator` -constructs that same kernel. A compiler should validate before accepting the -operator; a caller then supplies evaluated updates, merges compatible states, -and invokes the state's typed readout. Neither operation requires persistence. +An independent Rust physical operator DAG runtime shared by ingestion time and +query time execution. The library requires neither backend engine, a server, +a storage implementation, Arrow nor DataFusion. DataFusion informed the design; +it is not the execution framework. + +`dag::PhysicalDag` binds typed operator inputs to node IDs. Each execution starts +one producer per reachable node, shares output batches among its consumers, and +bounds buffering. Dropping one consumer does not cancel other consumers. A +`RunContext` carries query or ingestion scope, cancellation and byte accounting. +Executions use the caller's worker and worker-local streams, with no internal +thread pool. Poll multiple root streams concurrently when they share inputs. + +`dag::operators::Operator` implements native batch sources, scalar values, +projection, filtering, grouped exact aggregation, semi-join, grouped Sort and +Limit, vector-to-scalar conversion, Union, and summary construction/merge/readout. +Sort followed by Limit implements grouped ranking; no dedicated TopK physical +operator is needed. Summary construction updates state batch by batch. End of +input means the supplied query range or ingestion window is complete. ```rust -use asap_physical_operators::{factory::create_planner_accumulator, Statistic}; -use asap_physical_operators::planner::{ - post_asap::{ExactKind, ExactParams, SummaryFamilyType, SummaryUpdate}, - pre_asap::ColumnRef, +use asap_physical_operators::dag::{ + operators::{Expression, Operator}, + values::Value, + Limits, PhysicalDag, RunContext, Scope, }; +use asap_physical_operators::planner::pre_asap::DataType; +use futures::{executor::block_on, StreamExt}; -let family = SummaryFamilyType::ExactAggregate(ExactKind::Sum, ExactParams::Sum); -let mut operator = create_planner_accumulator( - &family, - &SummaryUpdate::column(ColumnRef::SampleValue), - &Default::default(), -).unwrap(); -operator.validate_single_input(3.0).unwrap(); -operator.update_single(3.0, 1000); -let state = operator.into_accumulator(); -assert_eq!(state.query_statistic(Statistic::Sum, &None, &Default::default()).unwrap(), 3.0); +let source = Operator::scalar(Value::Int64(7), DataType::Int64)?; +let negate = Operator::project(source.schema(), vec![ + ("value".into(), Expression::Negate(Box::new(Expression::Column(0)))), +])?; +let mut plan = PhysicalDag::default(); +plan.add(0, vec![], source)?; +plan.add(1, vec![0], negate)?; +let run = RunContext::new( + Scope::Query { evaluation_time_ms: 1000, revision: 1 }, + Limits::default(), +)?; +let mut output = plan.execute(&[1], run)?.remove(0); +let batch = block_on(output.next()).unwrap()?; +assert!(matches!(batch.rows()[0][0], Value::Int64(-7))); +# Ok::<(), asap_physical_operators::dag::Error>(()) ``` -The crate contains exact and sketch accumulators, common state traits, the -Planner-family factory, Float64 arithmetic and synchronous/asynchronous -QueryPlan DAG traversal. Unsupported kernel families are errors. It does not -infer new plans, choose a fallback engine, promise arbitrary SQL/PromQL support, -or change the execution phase encoded by Planner. Kernel availability does not -certify an accuracy guarantee; Planner and the deployment must still validate -the requested accuracy and evidence scope. - -Deployment adapters supply storage, source rows, time/population scope, expression -evaluation, I/O and output representation. Planner's current maintenance-only -summary placement and the backend's missing local raw Scan remain separate -integration limitations; exporting these kernels does not silently bypass them. - -## Composable row operators - -`rows::membership_filter` performs a value-preserving semijoin and reports -missing membership keys. `rows::grouped_topk` independently ranks rows within -groups, retaining input order for ties and placing NaN after numeric values. -Neither kernel knows about sketches, storage, execution phase or external -queries. The deployment enforces the pruning certificate; filtering and ranking -remain separate operations in the installed graph. - -## Shared DAG execution design - -The target is an independently implemented ASAP DAG runtime and physical -operator library used by both the precompute engine and the query engine. -DataFusion is a design reference, not the execution framework. The current kernels and backend-driven traversal are not yet -that implementation. See the [design document](../../docs/design_docs/query-dag-execution.md) -for operator responsibilities, engine integration, and shared-dependency semantics. +`dag::planner::bind` accepts a post-ASAP DAG and explicit source bindings for +installed ingestion/storage frontiers. It rejects unsupported operations and +schema mismatches before starting a source. Implement `PhysicalOperator` for a +deployment source, including asynchronous I/O; computation operators remain in +the library. The public `planner` export identifies the exact Planner types used +by the crate. The native binder currently supports a subset of those types and +operations; it does not interpret an unknown node as external fallback. + +Plain values preserve Planner scalar/collection types and nullability. Numeric +arithmetic uses matching Int64 or Float64 inputs; integer overflow is an error. +Boolean predicates use three-valued logic. Native summary states currently cover +exact Sum/Count/Min/Max/Rate/Increase, KLL, DDSketch and HLL. Binding checks family, +parameters and readout compatibility; source batches also validate state payloads. +Existing accumulator algorithms are reused as kernels behind these operators. + +Both backend ingestion DAG execution and installed query DAG execution use this +runtime. Some installed value/storage adapters still provide backend-specific +computation; they have not all been replaced by native batch bindings. Local raw +Scan remains deferred. See the [design and coverage table](../../docs/design_docs/query-dag-execution.md) +for the distinction between native operator support and backend integration. + +The default limits are eight buffered batches per producer and 64 MiB of estimated +retained execution data. Callers can set both through `Limits`. Accounting includes +consumer-held outputs and reserved operator state, but is not a hard RSS cap or an +allocator hook. Source-owned data and temporary allocation peaks are excluded. +Blocking operators have no spill support. Plan depth is limited to 128. No execution +state is shared between runs, and no implicit fallback or legacy traversal API is +provided. + +Run `cargo test -p asap-physical-operators --locked` for the independent library +acceptance tests, including shared producers, backpressure, cancellation, grouping, +state restoration and raw/partial/fully precomputed DAG examples. These examples +supply in-memory batches; they do not establish backend local raw-Scan support. diff --git a/crates/asap-physical-operators/src/dag/mod.rs b/crates/asap-physical-operators/src/dag/mod.rs new file mode 100644 index 00000000..b5a2e7b7 --- /dev/null +++ b/crates/asap-physical-operators/src/dag/mod.rs @@ -0,0 +1,515 @@ +//! Independent operator DAG execution. No backend plan or engine types are used. +//! +//! Each run creates one stream per reachable node. Consumers subscribe to that +//! stream independently; retained outputs are released after the last consumer. +use futures::{stream::LocalBoxStream, Stream}; +use std::{ + cell::{Cell, RefCell}, + collections::{BTreeMap, BTreeSet, VecDeque}, + fmt::Debug, + pin::Pin, + rc::Rc, + sync::Arc, + task::{Context, Poll, Waker}, +}; + +pub type NodeId = u64; +pub type OutputStream<'a, V> = LocalBoxStream<'a, Result>; +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum Error { + #[error("invalid DAG: {0}")] + Invalid(String), + #[error("operator failed: {0}")] + Operator(String), + #[error("node {node} ({operation}) failed: {source}")] + AtNode { + node: NodeId, + operation: String, + source: Box, + }, + #[error("execution memory limit exceeded")] + MemoryLimit, + #[error("execution cancelled")] + Cancelled, +} + +/// Scope is part of an execution instance, never mutable state in a reusable plan. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Scope { + Ingestion { + window_start_ms: i64, + window_end_ms: i64, + revision: u64, + }, + Query { + evaluation_time_ms: i64, + revision: u64, + }, +} +#[derive(Clone, Debug)] +pub struct Limits { + pub max_buffered_batches: usize, + pub max_bytes: usize, +} +impl Default for Limits { + fn default() -> Self { + Self { + max_buffered_batches: 8, + max_bytes: 64 * 1024 * 1024, + } + } +} +struct Control { + cancelled: Cell, + bytes: Cell, + peak: Cell, + limits: Limits, + waiters: RefCell>, +} +#[derive(Clone)] +pub struct RunContext { + pub scope: Scope, + control: Rc, +} +impl RunContext { + pub fn new(scope: Scope, limits: Limits) -> Result { + if limits.max_buffered_batches == 0 || limits.max_bytes == 0 { + return Err(Error::Invalid("execution limits must be positive".into())); + } + if matches!(&scope, Scope::Ingestion { window_start_ms, window_end_ms, .. } if window_start_ms > window_end_ms) + { + return Err(Error::Invalid("inverted ingestion window".into())); + } + Ok(Self { + scope, + control: Rc::new(Control { + cancelled: Cell::new(false), + bytes: Cell::new(0), + peak: Cell::new(0), + limits, + waiters: RefCell::new(Vec::new()), + }), + }) + } + pub fn cancel(&self) { + self.control.cancelled.set(true); + for waiter in self.control.waiters.borrow_mut().drain(..) { + waiter.wake(); + } + } + pub fn is_cancelled(&self) -> bool { + self.control.cancelled.get() + } + pub fn retained_bytes(&self) -> usize { + self.control.bytes.get() + } + pub fn peak_bytes(&self) -> usize { + self.control.peak.get() + } + pub fn reserve(&self, bytes: usize) -> Result { + let total = self + .control + .bytes + .get() + .checked_add(bytes) + .ok_or(Error::MemoryLimit)?; + if total > self.control.limits.max_bytes { + return Err(Error::MemoryLimit); + } + self.control.bytes.set(total); + self.control.peak.set(self.control.peak.get().max(total)); + Ok(Reservation { + bytes, + control: Rc::clone(&self.control), + }) + } + fn register(&self, waker: &Waker) { + let mut waiters = self.control.waiters.borrow_mut(); + if !waiters.iter().any(|old| old.will_wake(waker)) { + waiters.push(waker.clone()); + } + } +} +pub struct Reservation { + bytes: usize, + control: Rc, +} +impl Reservation { + /// Adjust an operator-owned allocation without accumulating bookkeeping entries. + pub fn resize(&mut self, bytes: usize) -> Result<(), Error> { + let total = self + .control + .bytes + .get() + .checked_sub(self.bytes) + .and_then(|total| total.checked_add(bytes)) + .ok_or(Error::MemoryLimit)?; + if total > self.control.limits.max_bytes { + return Err(Error::MemoryLimit); + } + self.control.bytes.set(total); + self.control.peak.set(self.control.peak.get().max(total)); + self.bytes = bytes; + Ok(()) + } +} +impl Drop for Reservation { + fn drop(&mut self) { + self.control + .bytes + .set(self.control.bytes.get().saturating_sub(self.bytes)); + } +} + +/// An output owns its memory reservation even after it leaves the DAG's queue. +pub struct SharedValue { + value: Arc, + _reservation: Rc, +} +impl Clone for SharedValue { + fn clone(&self) -> Self { + Self { + value: Arc::clone(&self.value), + _reservation: Rc::clone(&self._reservation), + } + } +} +impl std::ops::Deref for SharedValue { + type Target = V; + fn deref(&self) -> &V { + &self.value + } +} +impl SharedValue { + pub fn value(&self) -> &V { + &self.value + } +} + +/// Operators own computation. The runtime provides already-connected inputs; +/// an operator must not recursively execute another plan node itself. +pub trait PhysicalOperator { + fn name(&self) -> &str; + fn input_schemas(&self) -> Vec; + fn output_schema(&self) -> S; + fn start<'a>( + &'a self, + inputs: Vec>, + context: RunContext, + ) -> Result, Error>; + fn output_bytes(&self, value: &V) -> usize; +} +struct Node<'a, V, S> { + inputs: Vec, + operator: Box + 'a>, +} +pub struct PhysicalDag<'a, V, S> { + nodes: BTreeMap>, +} +impl Default for PhysicalDag<'_, V, S> { + fn default() -> Self { + Self { + nodes: BTreeMap::new(), + } + } +} +impl<'a, V: 'a, S: Clone + PartialEq + Debug + 'a> PhysicalDag<'a, V, S> { + pub fn add( + &mut self, + id: NodeId, + inputs: Vec, + operator: impl PhysicalOperator + 'a, + ) -> Result<(), Error> { + self.add_boxed(id, inputs, Box::new(operator)) + } + pub fn add_boxed( + &mut self, + id: NodeId, + inputs: Vec, + operator: Box + 'a>, + ) -> Result<(), Error> { + if self.nodes.contains_key(&id) { + return Err(Error::Invalid(format!("duplicate node {id}"))); + } + self.nodes.insert(id, Node { inputs, operator }); + Ok(()) + } + pub fn validate(&self, roots: &[NodeId]) -> Result<(), Error> { + fn visit( + dag: &PhysicalDag<'_, V, S>, + id: NodeId, + active: &mut BTreeSet, + done: &mut BTreeMap, + ) -> Result { + if let Some(depth) = done.get(&id) { + return Ok(*depth); + } + if active.len() >= 128 { + return Err(Error::Invalid( + "DAG exceeds the supported execution depth of 128".into(), + )); + } + if !active.insert(id) { + return Err(Error::Invalid(format!("cycle at node {id}"))); + } + let node = dag + .nodes + .get(&id) + .ok_or_else(|| Error::Invalid(format!("missing node {id}")))?; + let expected = node.operator.input_schemas(); + if expected.len() != node.inputs.len() { + return Err(Error::Invalid(format!("node {id} input arity mismatch"))); + } + let mut depth = 1; + for (input, schema) in node.inputs.iter().zip(expected) { + depth = depth.max(1 + visit(dag, *input, active, done)?); + let actual = dag.nodes[input].operator.output_schema(); + if actual != schema { + return Err(Error::Invalid(format!( + "node {id} input {input} schema mismatch: {actual:?} vs {schema:?}" + ))); + } + } + if depth > 128 { + return Err(Error::Invalid( + "DAG exceeds the supported execution depth of 128".into(), + )); + } + active.remove(&id); + done.insert(id, depth); + Ok(depth) + } + if roots.is_empty() { + return Err(Error::Invalid("execution needs a root".into())); + } + let mut done = BTreeMap::new(); + for &root in roots { + visit(self, root, &mut BTreeSet::new(), &mut done)?; + } + Ok(()) + } + pub fn execute<'r>( + &'r self, + roots: &[NodeId], + context: RunContext, + ) -> Result>, Error> + where + 'a: 'r, + { + if context.is_cancelled() { + return Err(Error::Cancelled); + } + self.validate(roots)?; + fn build<'r, V: 'r, S: 'r>( + dag: &'r PhysicalDag<'_, V, S>, + id: NodeId, + context: &RunContext, + states: &mut BTreeMap>>>, + ) -> Result>>, Error> { + if let Some(state) = states.get(&id) { + return Ok(Rc::clone(state)); + } + let node = &dag.nodes[&id]; + let mut inputs = Vec::new(); + for &child in &node.inputs { + inputs.push(Input::subscribe(build(dag, child, context, states)?)); + } + let stream = node + .operator + .start(inputs, context.clone()) + .map_err(|source| Error::AtNode { + node: id, + operation: node.operator.name().into(), + source: Box::new(source), + })?; + let op = node.operator.as_ref(); + let state = Rc::new(RefCell::new(Producer { + stream: Some(stream), + node: id, + operation: node.operator.name().into(), + size: Box::new(move |value| op.output_bytes(value)), + context: context.clone(), + queue: VecDeque::new(), + base: 0, + next_reader: 0, + batches_polled: 0, + readers: BTreeMap::new(), + waiters: BTreeMap::new(), + finished: false, + failure: None, + })); + states.insert(id, Rc::clone(&state)); + Ok(state) + } + let mut states = BTreeMap::new(); + roots + .iter() + .map(|&id| build(self, id, &context, &mut states).map(Input::subscribe)) + .collect() + } +} +struct Producer<'a, V> { + node: NodeId, + operation: String, + stream: Option>, + size: Box usize + 'a>, + context: RunContext, + queue: VecDeque>, + base: u64, + next_reader: u64, + batches_polled: usize, + readers: BTreeMap, + waiters: BTreeMap, + finished: bool, + failure: Option, +} +impl Producer<'_, V> { + fn trim(&mut self) { + let minimum = self + .readers + .values() + .copied() + .min() + .unwrap_or(self.base + self.queue.len() as u64); + while self.base < minimum { + self.queue.pop_front(); + self.base += 1; + } + for (_, waker) in std::mem::take(&mut self.waiters) { + waker.wake(); + } + if self.readers.is_empty() { + self.stream = None; + self.queue.clear(); + } + } +} +pub struct Input<'a, V> { + producer: Rc>>, + reader: u64, + done: bool, +} +impl<'a, V> Input<'a, V> { + fn subscribe(producer: Rc>>) -> Self { + let reader = { + let mut state = producer.borrow_mut(); + let id = state.next_reader; + state.next_reader += 1; + let base = state.base; + state.readers.insert(id, base); + id + }; + Self { + producer, + reader, + done: false, + } + } +} +impl Drop for Input<'_, V> { + fn drop(&mut self) { + let mut state = self.producer.borrow_mut(); + state.readers.remove(&self.reader); + state.waiters.remove(&self.reader); + state.trim(); + } +} +impl Stream for Input<'_, V> { + type Item = Result, Error>; + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + if this.done { + return Poll::Ready(None); + } + let mut state = this.producer.borrow_mut(); + state.context.register(cx.waker()); + if state.context.is_cancelled() { + state.failure = Some(Error::Cancelled); + state.finished = true; + state.stream = None; + state.queue.clear(); + } + let position = state.readers[&this.reader]; + let index = (position - state.base) as usize; + if let Some(value) = state.queue.get(index).cloned() { + state.readers.insert(this.reader, position + 1); + state.trim(); + return Poll::Ready(Some(Ok(value))); + } + if state.finished { + this.done = true; + state.readers.remove(&this.reader); + let failure = state.failure.clone(); + state.trim(); + return Poll::Ready(failure.map(Err)); + } + state.waiters.insert(this.reader, cx.waker().clone()); + if state.queue.len() >= state.context.control.limits.max_buffered_batches { + return Poll::Pending; + } + // Always-ready sources must still give cancellation and other roots a turn. + if state.batches_polled >= 32 { + state.batches_polled = 0; + cx.waker().wake_by_ref(); + return Poll::Pending; + } + let polled = state + .stream + .as_mut() + .expect("unfinished producer") + .as_mut() + .poll_next(cx); + if matches!(&polled, Poll::Ready(Some(Ok(_)))) { + state.batches_polled += 1; + } + match polled { + Poll::Pending => Poll::Pending, + Poll::Ready(Some(Ok(value))) => match state.context.reserve((state.size)(&value)) { + Ok(reservation) => { + let value = SharedValue { + value: Arc::new(value), + _reservation: Rc::new(reservation), + }; + state.queue.push_back(value.clone()); + state.readers.insert(this.reader, position + 1); + state.trim(); + Poll::Ready(Some(Ok(value))) + } + Err(error) => { + state.failure = Some(error.clone()); + state.finished = true; + state.stream = None; + this.done = true; + state.readers.remove(&this.reader); + state.trim(); + Poll::Ready(Some(Err(error))) + } + }, + Poll::Ready(result) => { + let error = result.and_then(Result::err).map(|source| match source { + Error::AtNode { .. } | Error::Cancelled | Error::MemoryLimit => source, + source => Error::AtNode { + node: state.node, + operation: state.operation.clone(), + source: Box::new(source), + }, + }); + state.failure = error.clone(); + state.finished = true; + state.stream = None; + this.done = true; + state.readers.remove(&this.reader); + state.trim(); + Poll::Ready(error.map(Err)) + } + } + } +} + +pub mod operators; +pub mod values; + +#[cfg(test)] +mod tests; + +pub mod planner; diff --git a/crates/asap-physical-operators/src/dag/operators.rs b/crates/asap-physical-operators/src/dag/operators.rs new file mode 100644 index 00000000..eeb1a922 --- /dev/null +++ b/crates/asap-physical-operators/src/dag/operators.rs @@ -0,0 +1,1170 @@ +//! Native DAG operators. Engines bind sources; computation lives here. +use super::{ + values::{group_key, Batch, Schema, Value}, + Error, Input, OutputStream, PhysicalOperator, Reservation, RunContext, +}; +use futures::StreamExt; +use planner_types::{ + post_asap::{SummaryFamilyType, SummaryField, SummarySchema, SummaryUpdate}, + pre_asap::{ArithmeticOpKind, ColumnRef, DataType}, +}; +use std::{collections::BTreeMap, sync::Arc}; + +fn invalid(message: &str) -> Error { + Error::Invalid(message.into()) +} +fn field(schema: &Schema, column: usize) -> Result<&SummaryField, Error> { + schema + .fields + .get(column) + .ok_or_else(|| invalid("column out of range")) +} +fn plain(schema: &Schema, column: usize) -> Result<(&DataType, bool), Error> { + let f = field(schema, column)?; + let SummaryFamilyType::Plain(dtype) = &f.dtype else { + return Err(invalid("plain value required")); + }; + Ok((dtype, f.nullable)) +} +fn schema(fields: Vec) -> Schema { + Arc::new(SummarySchema { + fields, + time_index: None, + }) +} +fn result_field(name: &str, dtype: DataType, nullable: bool) -> SummaryField { + SummaryField { + name: name.into(), + dtype: SummaryFamilyType::Plain(dtype), + nullable, + } +} + +#[derive(Clone, Debug)] +pub enum Expression { + Column(usize), + Literal { + value: Value, + dtype: DataType, + }, + Negate(Box), + Arithmetic { + op: ArithmeticOpKind, + left: Box, + right: Box, + }, + Equal(Box, Box), + Less(Box, Box), + And(Box, Box), + Or(Box, Box), + Not(Box), + IsNull(Box), +} +impl Expression { + fn dtype(&self, input: &Schema) -> Result<(DataType, bool), Error> { + use Expression::*; + match self { + Column(i) => { + let (t, n) = plain(input, *i)?; + Ok((t.clone(), n)) + } + Literal { value, dtype } => { + if value.matches(dtype, true) { + Ok((dtype.clone(), matches!(value, Value::Null))) + } else { + Err(invalid("literal type mismatch")) + } + } + Negate(v) => { + let (t, n) = v.dtype(input)?; + if matches!(t, DataType::Int64 | DataType::Float64) { + Ok((t, n)) + } else { + Err(invalid("numeric negation required")) + } + } + Arithmetic { op, left, right } => { + let (a, n) = left.dtype(input)?; + let (b, m) = right.dtype(input)?; + if a == b + && matches!(a, DataType::Int64 | DataType::Float64) + && !(a == DataType::Int64 && *op == ArithmeticOpKind::Atan2) + { + Ok((a, n || m)) + } else { + Err(invalid("arithmetic requires matching numeric types")) + } + } + Equal(a, b) | Less(a, b) => { + let (a, n) = a.dtype(input)?; + let (b, m) = b.dtype(input)?; + if a == b && ordered(&a) { + Ok((DataType::Bool, n || m)) + } else { + Err(invalid("comparison requires matching ordered types")) + } + } + And(a, b) | Or(a, b) => { + let (a, n) = a.dtype(input)?; + let (b, m) = b.dtype(input)?; + if a == DataType::Bool && b == DataType::Bool { + Ok((DataType::Bool, n || m)) + } else { + Err(invalid("boolean operands required")) + } + } + Not(v) => { + let (t, n) = v.dtype(input)?; + if t == DataType::Bool { + Ok((t, n)) + } else { + Err(invalid("boolean operand required")) + } + } + IsNull(v) => { + v.dtype(input)?; + Ok((DataType::Bool, false)) + } + } + } + fn evaluate(&self, row: &[Value]) -> Result { + use Expression::*; + Ok(match self { + Column(i) => row[*i].clone(), + Literal { value, .. } => value.clone(), + Negate(v) => match v.evaluate(row)? { + Value::Int64(v) => Value::Int64( + v.checked_neg() + .ok_or_else(|| invalid("integer negation overflow"))?, + ), + Value::Float64(v) => Value::Float64(-v), + Value::Null => Value::Null, + _ => return Err(invalid("numeric negation required")), + }, + Arithmetic { op, left, right } => { + numeric(op, left.evaluate(row)?, right.evaluate(row)?)? + } + Equal(a, b) | Less(a, b) => { + let (a, b) = (a.evaluate(row)?, b.evaluate(row)?); + if matches!(a, Value::Null) || matches!(b, Value::Null) { + Value::Null + } else if matches!((&a,&b),(Value::Float64(a),Value::Float64(b)) if a.is_nan() || b.is_nan()) + { + Value::Bool(false) + } else { + let c = a.compare(&b)?; + Value::Bool(if matches!(self, Equal(..)) { + c.is_eq() + } else { + c.is_lt() + }) + } + } + And(a, b) | Or(a, b) => { + let (a, b) = (a.evaluate(row)?, b.evaluate(row)?); + match (a, b, matches!(self, And(..))) { + (Value::Bool(false), _, true) | (_, Value::Bool(false), true) => { + Value::Bool(false) + } + (Value::Bool(true), _, false) | (_, Value::Bool(true), false) => { + Value::Bool(true) + } + (Value::Null, _, _) | (_, Value::Null, _) => Value::Null, + (Value::Bool(a), Value::Bool(b), true) => Value::Bool(a && b), + (Value::Bool(a), Value::Bool(b), false) => Value::Bool(a || b), + _ => return Err(invalid("boolean operands required")), + } + } + Not(v) => match v.evaluate(row)? { + Value::Bool(v) => Value::Bool(!v), + Value::Null => Value::Null, + _ => return Err(invalid("boolean operand required")), + }, + IsNull(v) => Value::Bool(matches!(v.evaluate(row)?, Value::Null)), + }) + } +} +fn ordered(dtype: &DataType) -> bool { + matches!( + dtype, + DataType::Int64 + | DataType::Float64 + | DataType::Utf8 + | DataType::Bool + | DataType::Timestamp + | DataType::Date + ) +} +fn numeric(op: &ArithmeticOpKind, a: Value, b: Value) -> Result { + use ArithmeticOpKind::*; + Ok(match (a, b) { + (Value::Null, _) | (_, Value::Null) => Value::Null, + (Value::Float64(a), Value::Float64(b)) => { + Value::Float64(crate::arithmetic::evaluate_float64_arithmetic(op, a, b)) + } + (Value::Int64(a), Value::Int64(b)) => Value::Int64( + match op { + Add => a.checked_add(b), + Sub => a.checked_sub(b), + Mul => a.checked_mul(b), + Div => a.checked_div(b), + Mod => a.checked_rem(b), + Pow => u32::try_from(b).ok().and_then(|b| a.checked_pow(b)), + Atan2 => None, + } + .ok_or_else(|| invalid("invalid integer arithmetic or overflow"))?, + ), + _ => return Err(invalid("arithmetic type mismatch")), + }) +} +#[derive(Clone, Debug)] +pub struct SortKey { + pub column: usize, + pub descending: bool, + pub nulls_first: bool, +} +#[derive(Clone, Debug)] +pub enum Reduction { + Count, + Sum(usize), + Avg(usize), + Min(usize), + Max(usize), +} +#[derive(Clone)] +enum Kind { + Source(Vec), + Union, + VectorToScalar { + column: usize, + }, + Project(Vec), + Filter(Expression), + Limit { + n: u64, + offset: u64, + groups: Vec, + }, + Sort { + keys: Vec, + groups: Vec, + }, + Aggregate { + groups: Vec, + measures: Vec, + }, + SemiJoin { + keys: Vec<(usize, usize)>, + }, + SummaryBuild { + family: SummaryFamilyType, + value: usize, + time: Option, + groups: Vec, + }, + SummaryMerge { + state: usize, + groups: Vec, + }, + Readout { + state: usize, + statistic: crate::Statistic, + parameters: std::collections::HashMap, + }, +} +/// A bound operation has a fully checked input/output contract before execution. +#[derive(Clone)] +pub struct Operator { + kind: Kind, + inputs: Vec, + output: Schema, +} +impl Operator { + pub fn source(output: Schema, batches: Vec) -> Result { + super::values::validate_schema(&output)?; + if batches.iter().any(|b| b.schema() != &output) { + return Err(invalid("source schema mismatch")); + } + Ok(Self { + kind: Kind::Source(batches), + inputs: vec![], + output, + }) + } + /// Union polls every input fairly, including branches sharing a producer. + pub fn union(input: Schema, arity: usize) -> Result { + if arity == 0 { + return Err(invalid("union needs at least one input")); + } + Ok(Self { + kind: Kind::Union, + inputs: vec![input.clone(); arity], + output: input, + }) + } + pub fn scalar(value: Value, dtype: DataType) -> Result { + let schema = schema(vec![result_field( + "value", + dtype, + matches!(value, Value::Null), + )]); + Self::source( + schema.clone(), + vec![Batch::try_new(schema, vec![vec![value]])?], + ) + } + /// PromQL scalar conversion: zero or multiple elements produce NaN. + pub fn vector_to_scalar(input: Schema, column: usize) -> Result { + if plain(&input, column)? != (&DataType::Float64, false) { + return Err(invalid("scalar conversion requires non-null Float64")); + } + Ok(Self { + kind: Kind::VectorToScalar { column }, + inputs: vec![input], + output: schema(vec![result_field("value", DataType::Float64, false)]), + }) + } + pub fn project(input: Schema, columns: Vec<(String, Expression)>) -> Result { + let fields = columns + .iter() + .map(|(name, e)| { + let (t, n) = e.dtype(&input)?; + Ok(result_field(name, t, n)) + }) + .collect::>()?; + Ok(Self { + kind: Kind::Project(columns.into_iter().map(|(_, e)| e).collect()), + inputs: vec![input], + output: schema(fields), + }) + } + pub fn filter(input: Schema, predicate: Expression) -> Result { + if predicate.dtype(&input)?.0 != DataType::Bool { + return Err(invalid("filter predicate must be boolean")); + } + Ok(Self { + kind: Kind::Filter(predicate), + inputs: vec![input.clone()], + output: input, + }) + } + pub fn limit(input: Schema, n: u64, offset: u64, groups: Vec) -> Result { + validate_groups(&input, &groups)?; + Ok(Self { + kind: Kind::Limit { n, offset, groups }, + inputs: vec![input.clone()], + output: input, + }) + } + pub fn sort(input: Schema, keys: Vec, groups: Vec) -> Result { + validate_groups(&input, &groups)?; + for key in &keys { + if !ordered(plain(&input, key.column)?.0) { + return Err(invalid("unsupported sort type")); + } + } + Ok(Self { + kind: Kind::Sort { keys, groups }, + inputs: vec![input.clone()], + output: input, + }) + } + pub fn aggregate( + input: Schema, + groups: Vec, + measures: Vec<(String, Reduction)>, + ) -> Result { + validate_groups(&input, &groups)?; + let mut fields = groups + .iter() + .map(|&i| input.fields[i].clone()) + .collect::>(); + for (name, reduction) in &measures { + let (t, n) = match reduction { + Reduction::Count => (DataType::Int64, false), + Reduction::Sum(i) | Reduction::Avg(i) => { + let (t, _) = plain(&input, *i)?; + if !matches!(t, DataType::Int64 | DataType::Float64) { + return Err(invalid("numeric aggregate input required")); + } + ( + if matches!(reduction, Reduction::Avg(_)) { + DataType::Float64 + } else { + t.clone() + }, + false, + ) + } + Reduction::Min(i) | Reduction::Max(i) => { + let (t, _) = plain(&input, *i)?; + if !ordered(t) { + return Err(invalid("ordered aggregate input required")); + } + (t.clone(), true) + } + }; + fields.push(result_field(name, t, n)); + } + Ok(Self { + kind: Kind::Aggregate { + groups, + measures: measures.into_iter().map(|(_, r)| r).collect(), + }, + inputs: vec![input], + output: schema(fields), + }) + } + pub fn semi_join( + left: Schema, + right: Schema, + keys: Vec<(usize, usize)>, + ) -> Result { + if keys.is_empty() { + return Err(invalid("semi-join needs matching keys")); + } + for &(l, r) in &keys { + if plain(&left, l)?.0 != plain(&right, r)?.0 { + return Err(invalid("join key types differ")); + } + } + Ok(Self { + kind: Kind::SemiJoin { keys }, + inputs: vec![left.clone(), right], + output: left, + }) + } + pub fn summary_build( + input: Schema, + family: SummaryFamilyType, + value: usize, + time: Option, + groups: Vec, + ) -> Result { + super::values::validate_family(&family)?; + validate_groups(&input, &groups)?; + if plain(&input, value)? != (&DataType::Float64, false) { + return Err(invalid("summary numeric update requires non-null Float64")); + } + if let Some(time) = time { + if plain(&input, time)? != (&DataType::Timestamp, false) { + return Err(invalid("summary time column must be a timestamp")); + } + } + if time.is_none() + && matches!( + family, + SummaryFamilyType::ExactAggregate( + planner_types::post_asap::ExactKind::Rate + | planner_types::post_asap::ExactKind::Increase, + _ + ) + ) + { + return Err(invalid("counter summary requires a timestamp column")); + } + crate::capability::validate_summary_kernel( + &family, + &SummaryUpdate::column(ColumnRef::SampleValue), + &Default::default(), + ) + .map_err(Error::Invalid)?; + let mut fields = groups + .iter() + .map(|&i| input.fields[i].clone()) + .collect::>(); + fields.push(SummaryField { + name: "state".into(), + dtype: family.clone(), + nullable: false, + }); + Ok(Self { + kind: Kind::SummaryBuild { + family, + value, + time, + groups, + }, + inputs: vec![input], + output: schema(fields), + }) + } + pub fn summary_merge(input: Schema, state: usize, groups: Vec) -> Result { + validate_groups(&input, &groups)?; + super::values::validate_family(&field(&input, state)?.dtype)?; + if matches!(field(&input, state)?.dtype, SummaryFamilyType::Plain(_)) { + return Err(invalid("summary state required")); + } + let mut fields = groups + .iter() + .map(|&i| input.fields[i].clone()) + .collect::>(); + fields.push(input.fields[state].clone()); + Ok(Self { + kind: Kind::SummaryMerge { state, groups }, + inputs: vec![input], + output: schema(fields), + }) + } + pub fn readout( + input: Schema, + state: usize, + statistic: crate::Statistic, + parameters: std::collections::HashMap, + ) -> Result { + super::values::validate_family(&field(&input, state)?.dtype)?; + if matches!(field(&input, state)?.dtype, SummaryFamilyType::Plain(_)) { + return Err(invalid("summary state required")); + } + validate_readout(&field(&input, state)?.dtype, statistic, ¶meters)?; + let mut fields = input.fields.clone(); + let result_type = if matches!( + fields[state].dtype, + SummaryFamilyType::ExactAggregate(planner_types::post_asap::ExactKind::Count, _) + ) { + DataType::Int64 + } else { + DataType::Float64 + }; + fields[state] = result_field("value", result_type, false); + Ok(Self { + kind: Kind::Readout { + state, + statistic, + parameters, + }, + inputs: vec![input], + output: schema(fields), + }) + } + pub(crate) fn with_output_schema(mut self, output: Schema) -> Result { + if self.output.fields.len() != output.fields.len() + || self + .output + .fields + .iter() + .zip(&output.fields) + .any(|(actual, declared)| { + actual.dtype != declared.dtype || (actual.nullable && !declared.nullable) + }) + { + return Err(invalid("native output type differs from Planner output")); + } + if output.time_index.is_some_and(|i| { + i >= output.fields.len() + || output.fields[i].dtype != SummaryFamilyType::Plain(DataType::Timestamp) + }) { + return Err(invalid("invalid output time column")); + } + self.output = output; + Ok(self) + } + pub fn schema(&self) -> Schema { + self.output.clone() + } +} +fn validate_groups(input: &Schema, groups: &[usize]) -> Result<(), Error> { + for &i in groups { + plain(input, i)?; + } + if groups + .iter() + .collect::>() + .len() + != groups.len() + { + return Err(invalid("duplicate group columns")); + } + Ok(()) +} +async fn collect_rows( + mut input: Input<'_, Batch>, + context: &RunContext, +) -> Result<(Vec>, Vec), Error> { + let mut rows = Vec::new(); + let mut reservations = Vec::new(); + while let Some(batch) = input.next().await { + let batch = batch?; + reservations.push(context.reserve(batch.bytes())?); + rows.extend(batch.rows().iter().cloned()); + } + Ok((rows, reservations)) +} +impl PhysicalOperator for Operator { + fn name(&self) -> &str { + match self.kind { + Kind::Source(_) => "Source", + Kind::Union => "Union", + Kind::VectorToScalar { .. } => "VectorToScalar", + Kind::Project(_) => "Project", + Kind::Filter(_) => "Filter", + Kind::Limit { .. } => "Limit", + Kind::Sort { .. } => "Sort", + Kind::Aggregate { .. } => "Aggregate", + Kind::SemiJoin { .. } => "SemiJoin", + Kind::SummaryBuild { .. } => "SummaryAgg", + Kind::SummaryMerge { .. } => "SummaryMerge", + Kind::Readout { .. } => "SummaryReadout", + } + } + fn input_schemas(&self) -> Vec { + self.inputs.clone() + } + fn output_schema(&self) -> Schema { + self.output.clone() + } + fn output_bytes(&self, value: &Batch) -> usize { + value.bytes() + } + fn start<'a>( + &'a self, + mut inputs: Vec>, + context: RunContext, + ) -> Result, Error> { + let output = self.output.clone(); + if let Kind::Source(batches) = &self.kind { + return Ok(futures::stream::iter(batches.iter().cloned().map(Ok)).boxed_local()); + } + if matches!(self.kind, Kind::Union) { + return Ok(futures::stream::select_all(inputs) + .map(|batch| batch.map(|batch| batch.value().clone())) + .boxed_local()); + } + if let Kind::SemiJoin { keys } = &self.kind { + let right = inputs.pop().ok_or_else(|| invalid("right input missing"))?; + let left = inputs.pop().ok_or_else(|| invalid("left input missing"))?; + return Ok(futures::stream::once(async move { + // Poll both branches together: either may depend on a common producer. + let ((left, _left_memory), (right, _right_memory)) = futures::try_join!( + collect_rows(left, &context), + collect_rows(right, &context) + )?; + let right_cols = keys.iter().map(|(_, r)| *r).collect::>(); + let left_cols = keys.iter().map(|(l, _)| *l).collect::>(); + let members = right + .iter() + .filter(|row| right_cols.iter().all(|&i| !matches!(row[i], Value::Null))) + .map(|r| group_key(r, &right_cols)) + .collect::, _>>()?; + let rows = left + .into_iter() + .filter_map(|r| match group_key(&r, &left_cols) { + Ok(k) + if left_cols.iter().all(|&i| !matches!(r[i], Value::Null)) + && members.contains(&k) => + { + Some(Ok(r)) + } + Ok(_) => None, + Err(e) => Some(Err(e)), + }) + .collect::, _>>()?; + Batch::try_new(output, rows) + }) + .boxed_local()); + } + let input = inputs.pop().ok_or_else(|| invalid("input missing"))?; + match &self.kind { + Kind::VectorToScalar { column } => Ok(futures::stream::once(async move { + let mut input = input; + let mut value = f64::NAN; + let mut count = 0usize; + while let Some(batch) = input.next().await { + for row in batch?.rows() { + count = count.saturating_add(1); + if let Value::Float64(v) = row[*column] { + value = v; + } + } + } + Batch::try_new( + output, + vec![vec![Value::Float64(if count == 1 { + value + } else { + f64::NAN + })]], + ) + }) + .boxed_local()), + Kind::Project(expressions) => Ok(input + .map(move |batch| { + let batch = batch?; + let rows = batch + .rows() + .iter() + .map(|r| { + expressions + .iter() + .map(|e| e.evaluate(r)) + .collect::, _>>() + }) + .collect::, _>>()?; + Batch::try_new(output.clone(), rows) + }) + .boxed_local()), + Kind::Filter(predicate) => Ok(input + .map(move |batch| { + let batch = batch?; + let mut rows = Vec::new(); + for row in batch.rows() { + if matches!(predicate.evaluate(row)?, Value::Bool(true)) { + rows.push(row.clone()); + } + } + Batch::try_new(output.clone(), rows) + }) + .boxed_local()), + Kind::Limit { n, offset, groups } => { + let counts = BTreeMap::>, u64>::new(); + Ok(futures::stream::try_unfold( + (input, counts, Vec::::new(), false), + move |(mut input, mut counts, mut memory, done)| { + let output = output.clone(); + let context = context.clone(); + async move { + if done || *n == 0 { + return Ok(None); + } + let Some(batch) = input.next().await else { + return Ok(None); + }; + let batch = batch?; + let mut rows = Vec::new(); + for row in batch.rows() { + let key = group_key(row, groups)?; + if !counts.contains_key(&key) { + memory.push( + context.reserve( + key.iter() + .map(|part| { + part.len() + std::mem::size_of::>() + }) + .sum::() + + 64, + )?, + ); + } + let count = counts.entry(key).or_default(); + if *count >= *offset && count.saturating_sub(*offset) < *n { + rows.push(row.clone()); + } + *count = count.saturating_add(1); + } + let done = groups.is_empty() + && counts + .get(&vec![]) + .is_some_and(|count| count.saturating_sub(*offset) >= *n); + Ok(Some(( + Batch::try_new(output, rows)?, + (input, counts, memory, done), + ))) + } + }, + ) + .boxed_local()) + } + Kind::SummaryBuild { + family, + value, + time, + groups, + } => Ok(futures::stream::once(async move { + Batch::try_new( + output, + build_summary(input, family, *value, *time, groups, &context).await?, + ) + }) + .boxed_local()), + Kind::Readout { + state, + statistic, + parameters, + } => Ok(input + .map(move |batch| { + let batch = batch?; + let mut rows = batch.rows().to_vec(); + for row in &mut rows { + let Value::Summary { state: summary, .. } = &row[*state] else { + return Err(invalid("summary value required")); + }; + row[*state] = if output.fields[*state].dtype + == SummaryFamilyType::Plain(DataType::Int64) + { + let count = summary.aux_stats().count.ok_or_else(|| { + Error::Operator("exact count state lacks an integer count".into()) + })?; + Value::Int64( + i64::try_from(count).map_err(|_| { + Error::Operator("exact count exceeds Int64".into()) + })?, + ) + } else { + Value::Float64( + summary + .query_statistic(*statistic, &None, parameters) + .map_err(|e| Error::Operator(e.to_string()))?, + ) + }; + } + Batch::try_new(output.clone(), rows) + }) + .boxed_local()), + _ => Ok(futures::stream::once(async move { + let (rows, _memory) = collect_rows(input, &context).await?; + let result = match &self.kind { + Kind::Sort { keys, groups } => { + let mut grouped = BTreeMap::>, Vec>>::new(); + for row in rows { + grouped + .entry(group_key(&row, groups)?) + .or_default() + .push(row); + } + let mut result = Vec::new(); + for mut rows in grouped.into_values() { + rows.sort_by(|a, b| compare_rows(a, b, keys)); + result.extend(rows); + } + result + } + Kind::Aggregate { groups, measures } => { + reduce(rows, groups, measures, &self.inputs[0])? + } + Kind::SummaryMerge { state, groups } => merge_summary(rows, *state, groups)?, + _ => return Err(invalid("unexpected blocking operation")), + }; + Batch::try_new(output, result) + }) + .boxed_local()), + } + } +} +fn compare_rows(a: &[Value], b: &[Value], keys: &[SortKey]) -> std::cmp::Ordering { + use std::cmp::Ordering::*; + for key in keys { + let (a, b) = (&a[key.column], &b[key.column]); + let order = match (a, b) { + (Value::Null, Value::Null) => Equal, + (Value::Null, _) => { + if key.nulls_first { + Less + } else { + Greater + } + } + (_, Value::Null) => { + if key.nulls_first { + Greater + } else { + Less + } + } + (Value::Float64(a), Value::Float64(b)) if a.is_nan() || b.is_nan() => { + match (a.is_nan(), b.is_nan()) { + (true, true) => Equal, + (true, false) => Greater, + _ => Less, + } + } + _ => { + let order = a.compare(b).expect("bound ordered types"); + if key.descending { + order.reverse() + } else { + order + } + } + }; + if order != Equal { + return order; + } + } + Equal +} +fn reduce( + rows: Vec>, + groups: &[usize], + measures: &[Reduction], + input: &Schema, +) -> Result>, Error> { + let mut grouped = BTreeMap::>, Vec>>::new(); + if rows.is_empty() && groups.is_empty() { + grouped.insert(vec![], vec![]); + } + for row in rows { + grouped + .entry(group_key(&row, groups)?) + .or_default() + .push(row); + } + grouped + .into_values() + .map(|rows| { + let mut result = groups + .iter() + .map(|&i| rows[0][i].clone()) + .collect::>(); + for measure in measures { + result.push(reduce_one(&rows, measure, input)?); + } + Ok(result) + }) + .collect() +} +fn reduce_one(rows: &[Vec], measure: &Reduction, input: &Schema) -> Result { + let column = match measure { + Reduction::Count => { + return Ok(Value::Int64( + i64::try_from(rows.len()).map_err(|_| invalid("count overflow"))?, + )) + } + Reduction::Sum(i) | Reduction::Avg(i) | Reduction::Min(i) | Reduction::Max(i) => *i, + }; + let values = rows + .iter() + .map(|r| &r[column]) + .filter(|v| !matches!(v, Value::Null)) + .collect::>(); + if matches!(measure, Reduction::Min(_) | Reduction::Max(_)) { + if plain(input, column)?.0 == &DataType::Float64 { + // Match exact-state kernels: ignore NaN when a numeric value exists. + let mut best: Option = None; + for value in values { + let Value::Float64(value) = value else { + return Err(invalid("floating aggregate value required")); + }; + best = Some(best.map_or(*value, |old| { + if matches!(measure, Reduction::Min(_)) { + old.min(*value) + } else { + old.max(*value) + } + })); + } + return Ok(best.map(Value::Float64).unwrap_or(Value::Null)); + } + let mut best: Option<&Value> = None; + for value in values { + if best + .map(|b| value.compare(b)) + .transpose()? + .is_none_or(|order| { + if matches!(measure, Reduction::Min(_)) { + order.is_lt() + } else { + order.is_gt() + } + }) + { + best = Some(value); + } + } + return Ok(best.cloned().unwrap_or(Value::Null)); + } + let count = values.len(); + let dtype = plain(input, column)?.0; + if dtype == &DataType::Int64 { + let sum = values.into_iter().try_fold(0i128, |sum, v| { + let Value::Int64(v) = v else { + return Err(invalid("integer aggregate value required")); + }; + sum.checked_add(i128::from(*v)) + .ok_or_else(|| invalid("integer aggregate overflow")) + })?; + return if matches!(measure, Reduction::Avg(_)) { + Ok(Value::Float64(sum as f64 / count as f64)) + } else { + Ok(Value::Int64( + i64::try_from(sum).map_err(|_| invalid("integer sum overflow"))?, + )) + }; + } + let sum = values + .into_iter() + .map(|v| { + if let Value::Float64(v) = v { + *v + } else { + unreachable!() + } + }) + .sum::(); + Ok(Value::Float64(if matches!(measure, Reduction::Avg(_)) { + sum / count as f64 + } else { + sum + })) +} +async fn build_summary( + mut input: Input<'_, Batch>, + family: &SummaryFamilyType, + value: usize, + time: Option, + groups: &[usize], + context: &RunContext, +) -> Result>, Error> { + type State = ( + Vec, + Box, + Reservation, + usize, + Option, + ); + let create = |labels: Vec, key_bytes: usize| -> Result { + let updater = crate::factory::create_planner_accumulator( + family, + &SummaryUpdate::column(ColumnRef::SampleValue), + &Default::default(), + ) + .map_err(Error::Operator)?; + let overhead = labels.iter().map(Value::bytes).sum::() + key_bytes + 64; + let memory = context.reserve(updater.memory_usage_bytes() + overhead)?; + Ok((labels, updater, memory, overhead, None)) + }; + let mut states = BTreeMap::>, State>::new(); + if groups.is_empty() { + states.insert(vec![], create(vec![], 0)?); + } + let ordered_time = matches!( + family, + SummaryFamilyType::ExactAggregate( + planner_types::post_asap::ExactKind::Rate + | planner_types::post_asap::ExactKind::Increase, + _ + ) + ); + while let Some(batch) = input.next().await { + let batch = batch?; + for row in batch.rows() { + let key = group_key(row, groups)?; + if !states.contains_key(&key) { + let labels = groups.iter().map(|&i| row[i].clone()).collect(); + let state = create( + labels, + key.iter() + .map(|v| v.len() + std::mem::size_of::>()) + .sum(), + )?; + states.insert(key.clone(), state); + } + let (_, updater, memory, overhead, previous) = + states.get_mut(&key).expect("inserted group"); + let Value::Float64(value) = row[value] else { + return Err(invalid("summary update type")); + }; + let timestamp = if let Some(time) = time { + let Value::Timestamp(time) = row[time] else { + return Err(invalid("summary time type")); + }; + time + } else { + 0 + }; + if ordered_time && previous.is_some_and(|prior| timestamp <= prior) { + return Err(Error::Operator( + "counter samples must have strictly increasing timestamps within each group" + .into(), + )); + } + updater + .validate_single_input(value) + .map_err(Error::Operator)?; + updater.update_single(value, timestamp); + *previous = Some(timestamp); + memory.resize(updater.memory_usage_bytes() + *overhead)?; + } + } + Ok(states + .into_values() + .map(|(mut labels, updater, _memory, _, _)| { + labels.push(Value::Summary { + family: family.clone(), + state: Arc::from(updater.into_accumulator()), + }); + labels + }) + .collect()) +} + +fn merge_summary( + rows: Vec>, + state_column: usize, + groups: &[usize], +) -> Result>, Error> { + type GroupState = (Vec, SummaryFamilyType, Arc); + let mut states: BTreeMap>, GroupState> = BTreeMap::new(); + for row in rows { + let Value::Summary { family, state } = &row[state_column] else { + return Err(invalid("summary state required")); + }; + let key = group_key(&row, groups)?; + if let Some((_, expected, existing)) = states.get_mut(&key) { + if expected != family { + return Err(invalid("incompatible summary family")); + } + *existing = Arc::from( + existing + .merge_with(state.as_ref()) + .map_err(|e| Error::Operator(e.to_string()))?, + ); + } else { + states.insert( + key, + ( + groups.iter().map(|&i| row[i].clone()).collect(), + family.clone(), + state.clone(), + ), + ); + } + } + Ok(states + .into_values() + .map(|(mut keys, family, state)| { + keys.push(Value::Summary { family, state }); + keys + }) + .collect()) +} + +fn validate_readout( + family: &SummaryFamilyType, + statistic: crate::Statistic, + parameters: &std::collections::HashMap, +) -> Result<(), Error> { + use crate::Statistic as S; + use planner_types::post_asap::{ExactKind as E, SketchAlgorithm as A}; + let supported = match family { + SummaryFamilyType::ExactAggregate(kind, _) => matches!( + (kind, statistic), + (E::Sum, S::Sum) + | (E::Count, S::Count) + | (E::Min, S::Min) + | (E::Max, S::Max) + | (E::Rate, S::Rate) + | (E::Increase, S::Increase) + ), + SummaryFamilyType::Sketch(kind, _) => match kind.algorithm() { + A::Kll => statistic == S::Quantile, + A::DDSketch => matches!(statistic, S::Quantile | S::Count), + A::Hll => matches!(statistic, S::Cardinality | S::Count), + _ => false, + }, + _ => false, + }; + if !supported { + return Err(invalid( + "readout is not implemented for this summary family", + )); + } + if statistic == S::Quantile + && !parameters + .get("quantile") + .and_then(|s| s.parse::().ok()) + .is_some_and(|q| (0.0..=1.0).contains(&q)) + { + return Err(invalid("quantile readout requires quantile in [0,1]")); + } + Ok(()) +} diff --git a/crates/asap-physical-operators/src/dag/planner.rs b/crates/asap-physical-operators/src/dag/planner.rs new file mode 100644 index 00000000..1952f179 --- /dev/null +++ b/crates/asap-physical-operators/src/dag/planner.rs @@ -0,0 +1,479 @@ +//! Bind a post-ASAP DAG to native operators. Sources are explicit execution +//! frontiers supplied by the deployment; unsupported computation is an error. +use super::{ + operators::{Expression, Operator, Reduction, SortKey}, + values::{Batch, Schema, Value}, + Error, NodeId, PhysicalDag, PhysicalOperator, +}; +use planner_types::{ + post_asap::{ + ExactOperation, ExecutableDag, ExecutableDagNode, ExecutableOperatorPayload as Payload, + SketchQuery, SummaryFamilyType, SummaryInputExpr, ValueOperation, + }, + pre_asap::{ + AggIntent, ColumnRef, CompareOpKind, DataType, GroupKeys, QueryExpr, + Reduction as PlannerReduction, ScalarValue, + }, +}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::Arc, +}; +fn invalid(message: impl Into) -> Error { + Error::Invalid(message.into()) +} + +/// Source nodes cut the DAG at an installed storage/ingestion frontier. The +/// binding must have exactly the declared schema and no upstream dependencies. +/// A deployment must authorize these frontiers before calling this function. +pub type Source<'a> = Box + 'a>; + +pub fn bind<'a>( + dag: &ExecutableDag, + mut sources: BTreeMap>, + roots: &[NodeId], +) -> Result, Error> { + preflight_depth(dag)?; + dag.validate().map_err(|e| invalid(e.to_string()))?; + let nodes = dag + .nodes + .iter() + .map(|node| (u64::from(node.id.0), node)) + .collect::>(); + let mut dependencies = BTreeMap::>::new(); + for edge in &dag.edges { + dependencies + .entry(u64::from(edge.consumer.0)) + .or_default() + .push(u64::from(edge.producer.0)); + } + if sources.keys().any(|id| !nodes.contains_key(id)) { + return Err(invalid("source binding names an unknown node")); + } + let mut ordered = Vec::new(); + let mut seen = BTreeSet::new(); + let mut pending = roots.iter().map(|&id| (id, false)).collect::>(); + while let Some((id, expanded)) = pending.pop() { + if expanded { + ordered.push(id); + continue; + } + if !seen.insert(id) { + continue; + } + if !nodes.contains_key(&id) { + return Err(invalid(format!("missing root {id}"))); + } + pending.push((id, true)); + if !sources.contains_key(&id) { + for &input in dependencies.get(&id).into_iter().flatten() { + pending.push((input, false)); + } + } + } + let mut graph = PhysicalDag::default(); + let mut auxiliary = u64::MAX; + for id in ordered { + let node = nodes[&id]; + let output = Arc::new(node.output_schema.clone()); + super::values::validate_schema(&output)?; + let (operator, inputs) = if let Some(source) = sources.remove(&id) { + if !source.input_schemas().is_empty() || source.output_schema() != output { + return Err(invalid("frontier is not a source with the declared schema")); + } + ( + Box::new(CheckedSource { source, output }) as Source<'a>, + vec![], + ) + } else { + let mut inputs = dependencies.get(&id).cloned().unwrap_or_default(); + let mut schemas = inputs + .iter() + .map(|id| Arc::new(nodes[id].output_schema.clone())) + .collect::>(); + if matches!(node.payload, Payload::SummaryMerge { .. }) && inputs.len() > 1 { + if schemas.iter().any(|s| s != &schemas[0]) { + return Err(invalid("summary merge inputs have different schemas")); + } + graph.add( + auxiliary, + inputs, + Operator::union(schemas[0].clone(), schemas.len())?, + )?; + inputs = vec![auxiliary]; + auxiliary -= 1; + schemas.truncate(1); + } + let operator = bind_operation(node, &schemas) + .map_err(|error| invalid(format!("node {id}: {error}")))? + .with_output_schema(output)?; + (Box::new(operator) as Source<'a>, inputs) + }; + graph.add_boxed(id, inputs, operator)?; + } + graph.validate(roots)?; + Ok(graph) +} + +fn bind_operation(node: &ExecutableDagNode, inputs: &[Schema]) -> Result { + let [input] = inputs else { + return Err(invalid( + "native Planner binding currently requires a unary operation or an explicit source", + )); + }; + match &node.payload { + Payload::Value { operation, .. } => match operation { + ValueOperation::Project { cols, .. } => Operator::project( + input.clone(), + cols.iter() + .enumerate() + .map(|(i, col)| { + Ok(( + node.output_schema + .fields + .get(i) + .ok_or_else(|| invalid("projection width mismatch"))? + .name + .clone(), + expression(&col.expr)?, + )) + }) + .collect::>()?, + ), + ValueOperation::Filter { pred } => { + Operator::filter(input.clone(), expression(&pred.0)?) + } + ValueOperation::Sort { keys, partition_by } => Operator::sort( + input.clone(), + keys.iter() + .map(|key| { + let QueryExpr::Column(column) = key.expr else { + return Err(invalid( + "sort expression must be projected before sorting", + )); + }; + Ok(SortKey { + column, + descending: !key.ascending, + nulls_first: key.nulls_first, + }) + }) + .collect::>()?, + groups(input, partition_by)?, + ), + ValueOperation::Limit { n, offset } => { + Operator::limit(input.clone(), *n as u64, *offset as u64, vec![]) + } + ValueOperation::Exact(ExactOperation::Aggregate { + reduction, + measures, + output_names, + having: None, + }) => { + if measures.len() != output_names.len() { + return Err(invalid("aggregate output names differ from measures")); + } + let PlannerReduction::Reduce(keys) = reduction else { + return Err(invalid( + "per-entity aggregate requires an explicit entity binding", + )); + }; + let measures = measures + .iter() + .zip(output_names) + .map(|(m, name)| { + let column = |col: Option| { + col.map(Ok) + .unwrap_or_else(|| named_column(input, &ColumnRef::SampleValue)) + }; + let m = match m { + AggIntent::Count { .. } => Reduction::Count, + AggIntent::Sum { col } => Reduction::Sum(column(*col)?), + AggIntent::Avg { col } => Reduction::Avg(column(*col)?), + AggIntent::Min { col } => Reduction::Min(column(*col)?), + AggIntent::Max { col } => Reduction::Max(column(*col)?), + _ => { + return Err(invalid( + "aggregate intent has no native implementation", + )) + } + }; + Ok((name.clone(), m)) + }) + .collect::>()?; + Operator::aggregate(input.clone(), groups(input, keys)?, measures) + } + ValueOperation::FinalizeExactAccumulator => { + let state = summary_column(input)?; + use crate::Statistic as S; + use planner_types::post_asap::ExactKind as E; + let statistic = match &input.fields[state].dtype { + SummaryFamilyType::ExactAggregate(kind, _) => match kind { + E::Sum => S::Sum, + E::Count => S::Count, + E::Min => S::Min, + E::Max => S::Max, + E::Rate => S::Rate, + E::Increase => S::Increase, + _ => return Err(invalid("exact family readout is unsupported")), + }, + _ => return Err(invalid("exact finalization requires exact state")), + }; + Operator::readout(input.clone(), state, statistic, Default::default()) + } + _ => Err(invalid("value operation has no native implementation")), + }, + Payload::SummaryAgg { + family, + input: update, + reduction, + grouping, + } => { + if update.item.is_some() { + return Err(invalid("keyed summary update binding is not implemented")); + } + crate::capability::validate_summary_kernel(family, update, grouping) + .map_err(Error::Invalid)?; + let SummaryInputExpr::Column(column) = &update.weight else { + return Err(invalid( + "summary update expression must be projected to a column", + )); + }; + let PlannerReduction::Reduce(keys) = reduction else { + return Err(invalid( + "summary construction requires explicit grouping columns", + )); + }; + Operator::summary_build( + input.clone(), + family.clone(), + named_column(input, column)?, + input.time_index, + groups(input, keys)?, + ) + } + Payload::SummaryMerge { .. } => { + let state = summary_column(input)?; + Operator::summary_merge( + input.clone(), + state, + (0..input.fields.len()) + .filter(|&i| i != state && Some(i) != input.time_index) + .collect(), + ) + } + Payload::SummaryEstimate { query } => { + let mut params = std::collections::HashMap::new(); + let statistic = match query { + SketchQuery::Quantile { q } => { + params.insert("quantile".into(), q.to_string()); + crate::Statistic::Quantile + } + SketchQuery::Cardinality => crate::Statistic::Cardinality, + SketchQuery::PointCount { value: None, .. } => crate::Statistic::Count, + _ => return Err(invalid("summary readout is not implemented")), + }; + Operator::readout(input.clone(), summary_column(input)?, statistic, params) + } + _ => Err(invalid( + "physical operation has no native binding; no fallback is installed", + )), + } +} +fn summary_column(input: &Schema) -> Result { + let columns = input + .fields + .iter() + .enumerate() + .filter(|(_, f)| !matches!(f.dtype, SummaryFamilyType::Plain(_))) + .map(|(i, _)| i) + .collect::>(); + match columns.as_slice() { + [column] => Ok(*column), + _ => Err(invalid("one summary state column required")), + } +} +fn named_column(input: &Schema, column: &ColumnRef) -> Result { + let name = match column { + ColumnRef::Named(name) => name.as_str(), + ColumnRef::SampleValue => "value", + _ => { + return Err(invalid( + "summary update requires an unambiguous bound column", + )) + } + }; + let matches = input + .fields + .iter() + .enumerate() + .filter(|(_, field)| field.name == name) + .map(|(i, _)| i) + .collect::>(); + match matches.as_slice() { + [column] => Ok(*column), + _ => Err(invalid("summary update column missing or ambiguous")), + } +} +fn groups(input: &Schema, groups: &GroupKeys) -> Result, Error> { + if groups.is_without() { + return Err(invalid("grouping without requires resolved label columns")); + } + if groups.keys().iter().any(|&i| i >= input.fields.len()) { + return Err(invalid("grouping column out of range")); + } + Ok(groups.keys().to_vec()) +} +fn expression(expr: &QueryExpr) -> Result { + let bind = |e: &QueryExpr| expression(e).map(Box::new); + Ok(match expr { + QueryExpr::Column(i) => Expression::Column(*i), + QueryExpr::Literal(value) => { + let (value, dtype) = match value { + ScalarValue::Int64(v) => (Value::Int64(*v), DataType::Int64), + ScalarValue::Float64(v) => (Value::Float64(*v), DataType::Float64), + ScalarValue::Utf8(v) => (Value::Utf8(v.as_str().into()), DataType::Utf8), + ScalarValue::Boolean(v) => (Value::Bool(*v), DataType::Bool), + ScalarValue::Null => (Value::Null, DataType::Null), + ScalarValue::Interval { + months, + days, + nanos, + } => ( + Value::Interval { + months: *months, + days: *days, + nanos: *nanos, + }, + DataType::Interval, + ), + }; + Expression::Literal { value, dtype } + } + QueryExpr::Arithmetic { op, left, right } => Expression::Arithmetic { + op: op.clone(), + left: bind(left)?, + right: bind(right)?, + }, + QueryExpr::Compare { + left, + op: CompareOpKind::Eq, + right, + } => Expression::Equal(bind(left)?, bind(right)?), + QueryExpr::Compare { + left, + op: CompareOpKind::Lt, + right, + } => Expression::Less(bind(left)?, bind(right)?), + QueryExpr::Not(v) => Expression::Not(bind(v)?), + QueryExpr::IsNull(v) => Expression::IsNull(bind(v)?), + QueryExpr::IsNotNull(v) => Expression::Not(Box::new(Expression::IsNull(bind(v)?))), + QueryExpr::BoolAnd(items) | QueryExpr::BoolOr(items) => { + let and = matches!(expr, QueryExpr::BoolAnd(_)); + let mut result = Expression::Literal { + value: Value::Bool(and), + dtype: DataType::Bool, + }; + for item in items { + result = if and { + Expression::And(Box::new(result), bind(item)?) + } else { + Expression::Or(Box::new(result), bind(item)?) + }; + } + result + } + _ => return Err(invalid("expression has no native implementation")), + }) +} + +// Source adapters may perform I/O, but their actual batches must honor the +// schema accepted by the binder before a downstream expression sees a row. +struct CheckedSource<'a> { + source: Source<'a>, + output: Schema, +} +impl PhysicalOperator for CheckedSource<'_> { + fn name(&self) -> &str { + self.source.name() + } + fn input_schemas(&self) -> Vec { + vec![] + } + fn output_schema(&self) -> Schema { + self.output.clone() + } + fn output_bytes(&self, batch: &Batch) -> usize { + self.source.output_bytes(batch) + } + fn start<'a>( + &'a self, + inputs: Vec>, + context: super::RunContext, + ) -> Result, Error> { + use futures::StreamExt; + Ok(self + .source + .start(inputs, context)? + .map(|batch| { + let batch = batch?; + if batch.schema() != &self.output { + return Err(invalid("source batch differs from its bound schema")); + } + Ok(batch) + }) + .boxed_local()) + } +} + +// Bound recursion before invoking the upstream recursive provenance validator. +fn preflight_depth(dag: &ExecutableDag) -> Result<(), Error> { + let mut remaining = dag + .nodes + .iter() + .map(|node| (node.id, 0usize)) + .collect::>(); + if remaining.len() != dag.nodes.len() { + return Err(invalid("duplicate Planner node")); + } + let mut consumers = BTreeMap::<_, Vec<_>>::new(); + for edge in &dag.edges { + if !remaining.contains_key(&edge.producer) { + return Err(invalid("missing Planner edge producer")); + } + *remaining + .get_mut(&edge.consumer) + .ok_or_else(|| invalid("missing Planner edge consumer"))? += 1; + consumers + .entry(edge.producer) + .or_default() + .push(edge.consumer); + } + let mut ready = remaining + .iter() + .filter(|(_, n)| **n == 0) + .map(|(id, _)| *id) + .collect::>(); + let mut depths = BTreeMap::new(); + let mut visited = 0; + while let Some(id) = ready.pop_front() { + visited += 1; + let depth = *depths.get(&id).unwrap_or(&1usize); + if depth > 128 { + return Err(invalid("DAG exceeds the supported execution depth of 128")); + } + for &consumer in consumers.get(&id).into_iter().flatten() { + let next = depths.entry(consumer).or_insert(1); + *next = (*next).max(depth + 1); + let count = remaining.get_mut(&consumer).expect("validated endpoint"); + *count -= 1; + if *count == 0 { + ready.push_back(consumer); + } + } + } + if visited != dag.nodes.len() { + return Err(invalid("Planner DAG contains a cycle")); + } + Ok(()) +} diff --git a/crates/asap-physical-operators/src/dag/tests.rs b/crates/asap-physical-operators/src/dag/tests.rs new file mode 100644 index 00000000..e051fa31 --- /dev/null +++ b/crates/asap-physical-operators/src/dag/tests.rs @@ -0,0 +1,260 @@ +use super::*; +use futures::{executor::block_on, stream, StreamExt}; + +struct Source { + starts: Rc>, + polls: Rc>, + fail: bool, + end: u64, +} +impl PhysicalOperator for Source { + fn name(&self) -> &str { + "CountingSource" + } + fn input_schemas(&self) -> Vec<()> { + vec![] + } + fn output_schema(&self) {} + fn output_bytes(&self, _: &u64) -> usize { + 8 + } + fn start<'a>( + &'a self, + _: Vec>, + _: RunContext, + ) -> Result, Error> { + self.starts.set(self.starts.get() + 1); + Ok(stream::iter(0..self.end) + .map(move |n| { + self.polls.set(self.polls.get() + 1); + if self.fail && n == 1 { + Err(Error::Operator("source failure".into())) + } else { + Ok(n) + } + }) + .boxed_local()) + } +} +struct Identity; +impl PhysicalOperator for Identity { + fn name(&self) -> &str { + "Identity" + } + fn input_schemas(&self) -> Vec<()> { + vec![()] + } + fn output_schema(&self) {} + fn output_bytes(&self, _: &u64) -> usize { + 8 + } + fn start<'a>( + &'a self, + mut inputs: Vec>, + _: RunContext, + ) -> Result, Error> { + Ok(inputs + .remove(0) + .map(|value| value.map(|v| *v)) + .boxed_local()) + } +} +fn context() -> RunContext { + RunContext::new( + Scope::Query { + evaluation_time_ms: 100, + revision: 1, + }, + Limits { + max_buffered_batches: 1, + max_bytes: 1024, + }, + ) + .unwrap() +} +fn source(fail: bool) -> (Source, Rc>, Rc>) { + let starts = Rc::new(Cell::new(0)); + let polls = Rc::new(Cell::new(0)); + ( + Source { + starts: starts.clone(), + polls: polls.clone(), + fail, + end: 4, + }, + starts, + polls, + ) +} + +// A shared producer runs once, and the slow reader bounds producer progress. +#[test] +fn shared_source_backpressure_and_reader_drop() { + let (source, starts, polls) = source(false); + let mut dag = PhysicalDag::default(); + dag.add(0, vec![], source).unwrap(); + let context = context(); + let mut readers = dag.execute(&[0, 0], context.clone()).unwrap(); + let mut slow = readers.pop().unwrap(); + let mut fast = readers.pop().unwrap(); + assert_eq!(starts.get(), 1); + let first = block_on(fast.next()).unwrap().unwrap(); + assert_eq!(*first, 0); + let mut cx = Context::from_waker(futures::task::noop_waker_ref()); + assert!(Pin::new(&mut fast).poll_next(&mut cx).is_pending()); + assert_eq!(polls.get(), 1); + let same = block_on(slow.next()).unwrap().unwrap(); + assert!(Arc::ptr_eq(&first.value, &same.value)); + drop(same); + drop(first); + assert_eq!(context.retained_bytes(), 0); + assert_eq!(*block_on(fast.next()).unwrap().unwrap(), 1); + drop(slow); + assert_eq!(*block_on(fast.next()).unwrap().unwrap(), 2); + assert_eq!(*block_on(fast.next()).unwrap().unwrap(), 3); + assert!(block_on(fast.next()).is_none()); + assert_eq!(polls.get(), 4); + drop(fast); + assert_eq!(context.retained_bytes(), 0); +} + +// Independent branches consume a common node concurrently without duplicate work. +#[test] +fn diamond_and_run_isolation() { + let (source, starts, polls) = source(false); + let mut dag = PhysicalDag::default(); + dag.add(0, vec![], source).unwrap(); + dag.add(1, vec![0], Identity).unwrap(); + dag.add(2, vec![0], Identity).unwrap(); + for _ in 0..2 { + let mut outputs = dag.execute(&[1, 2], context()).unwrap(); + let a = outputs.pop().unwrap(); + let b = outputs.pop().unwrap(); + let (a, b) = + block_on(async { futures::join!(a.collect::>(), b.collect::>()) }); + assert_eq!( + a.iter().map(|v| **v.as_ref().unwrap()).collect::>(), + vec![0, 1, 2, 3] + ); + assert_eq!( + b.iter().map(|v| **v.as_ref().unwrap()).collect::>(), + vec![0, 1, 2, 3] + ); + } + assert_eq!(starts.get(), 2); + assert_eq!(polls.get(), 8); +} + +// Failure reaches every subscriber; cancellation stops further producer work. +#[test] +fn broadcast_error_and_cancel() { + let (source, _, polls) = source(true); + let mut dag = PhysicalDag::default(); + dag.add(0, vec![], source).unwrap(); + let mut outputs = dag.execute(&[0, 0], context()).unwrap(); + let a = outputs.pop().unwrap(); + let b = outputs.pop().unwrap(); + let (a, b) = block_on(async { futures::join!(a.collect::>(), b.collect::>()) }); + for values in [a, b] { + assert_eq!(values.len(), 2); + assert!(matches!(values[1], Err(Error::AtNode { node: 0, .. }))); + } + assert_eq!(polls.get(), 2); + let run = context(); + let mut output = dag.execute(&[0], run.clone()).unwrap().remove(0); + run.cancel(); + assert!(matches!( + block_on(output.next()), + Some(Err(Error::Cancelled)) + )); + assert!(block_on(output.next()).is_none()); + assert_eq!(polls.get(), 2); +} + +// Retaining a consumer output retains its budget lease after queue eviction. +#[test] +fn retained_outputs_count_against_budget() { + let (source, _, _) = source(false); + let mut dag = PhysicalDag::default(); + dag.add(0, vec![], source).unwrap(); + let run = RunContext::new( + Scope::Query { + evaluation_time_ms: 0, + revision: 0, + }, + Limits { + max_buffered_batches: 1, + max_bytes: 8, + }, + ) + .unwrap(); + let mut input = dag.execute(&[0], run.clone()).unwrap().remove(0); + let held = block_on(input.next()).unwrap().unwrap(); + assert_eq!(run.retained_bytes(), 8); + assert!(matches!( + block_on(input.next()), + Some(Err(Error::MemoryLimit)) + )); + drop(input); + assert_eq!(run.retained_bytes(), 8); + drop(held); + assert_eq!(run.retained_bytes(), 0); +} + +// Invalid graphs fail before even starting a source. +#[test] +fn invalid_graphs_do_not_start_sources() { + let (source, starts, _) = source(false); + let mut dag = PhysicalDag::default(); + dag.add(0, vec![], source).unwrap(); + dag.add(1, vec![2], Identity).unwrap(); + dag.add(2, vec![1], Identity).unwrap(); + assert!(dag.execute(&[0, 1], context()).is_err()); + assert_eq!(starts.get(), 0); + let mut missing = PhysicalDag::default(); + missing.add(1, vec![9], Identity).unwrap(); + assert!(missing.validate(&[1]).is_err()); + let mut arity = PhysicalDag::default(); + arity.add(1, vec![], Identity).unwrap(); + assert!(arity.validate(&[1]).is_err()); +} + +// An always-ready source must yield so cancellation can be polled on this worker. +#[test] +fn ready_sources_cooperate_with_cancellation() { + let (mut source, _, polls) = source(false); + source.end = 10_000; + let mut dag = PhysicalDag::default(); + dag.add(0, vec![], source).unwrap(); + let context = context(); + let mut input = dag.execute(&[0], context.clone()).unwrap().remove(0); + block_on(async { + let drain = async { + while let Some(result) = input.next().await { + if let Err(error) = result { + assert_eq!(error, Error::Cancelled); + return; + } + } + panic!("source completed without yielding"); + }; + let cancel = async { + context.cancel(); + }; + futures::join!(drain, cancel); + }); + assert_eq!(polls.get(), 32); + assert_eq!(context.retained_bytes(), 0); +} + +// Cached shorter paths must not hide an over-deep path through shared nodes. +#[test] +fn depth_limit_covers_shared_paths() { + let (source, _, _) = source(false); + let mut dag = PhysicalDag::default(); + dag.add(0, vec![], source).unwrap(); + for id in 1..129 { + dag.add(id, vec![id - 1], Identity).unwrap(); + } + assert!(dag.validate(&(0..129).collect::>()).is_err()); +} diff --git a/crates/asap-physical-operators/src/dag/values.rs b/crates/asap-physical-operators/src/dag/values.rs new file mode 100644 index 00000000..5b4e043f --- /dev/null +++ b/crates/asap-physical-operators/src/dag/values.rs @@ -0,0 +1,318 @@ +//! Runtime values preserve Planner schemas; summary states are typed values too. +use super::Error; +use crate::AggregateCore; +use planner_types::{ + post_asap::{SummaryFamilyType, SummarySchema}, + pre_asap::DataType, +}; +use std::{cmp::Ordering, sync::Arc}; +pub type Schema = Arc; +#[derive(Clone)] +pub enum Value { + Null, + Bool(bool), + Int64(i64), + Float64(f64), + Utf8(Arc), + Timestamp(i64), + Date(i32), + Interval { + months: i32, + days: i32, + nanos: i64, + }, + List(Arc<[Value]>), + Struct(Arc<[Value]>), + Map(Arc<[(Value, Value)]>), + Summary { + family: SummaryFamilyType, + state: Arc, + }, +} +impl std::fmt::Debug for Value { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Summary { family, .. } => f.debug_tuple("Summary").field(family).finish(), + _ => write!(f, "{:?}", self.key()), + } + } +} +impl Value { + pub fn bytes(&self) -> usize { + std::mem::size_of::() + + match self { + Self::Utf8(s) => s.len(), + Self::List(v) | Self::Struct(v) => v.iter().map(Self::bytes).sum(), + Self::Map(v) => v.iter().map(|(k, v)| k.bytes() + v.bytes()).sum(), + Self::Summary { state, .. } => state.approx_memory_bytes(), + _ => 0, + } + } + pub fn matches(&self, dtype: &DataType, nullable: bool) -> bool { + if matches!(self, Self::Null) { + return nullable || matches!(dtype, DataType::Null); + } + match (self, dtype) { + (Self::Bool(_), DataType::Bool) + | (Self::Int64(_), DataType::Int64) + | (Self::Float64(_), DataType::Float64) + | (Self::Utf8(_), DataType::Utf8) + | (Self::Timestamp(_), DataType::Timestamp) + | (Self::Date(_), DataType::Date) + | (Self::Interval { .. }, DataType::Interval) => true, + (Self::List(v), DataType::List { element }) => v + .iter() + .all(|v| v.matches(&element.dtype, element.nullable)), + (Self::Struct(v), DataType::Struct { fields }) => { + v.len() == fields.len() + && v.iter() + .zip(fields) + .all(|(v, f)| v.matches(&f.dtype, f.nullable)) + } + ( + Self::Map(v), + DataType::Map { + key, + value, + value_nullable, + }, + ) => v + .iter() + .all(|(k, v)| k.matches(key, false) && v.matches(value, *value_nullable)), + _ => false, + } + } + /// Stable typed equality key. Zero signs and NaN payloads form one group. + pub fn key(&self) -> Result, Error> { + let mut out = Vec::new(); + macro_rules! number { + ($tag:expr,$v:expr) => {{ + out.push($tag); + out.extend_from_slice(&$v.to_le_bytes()); + }}; + } + match self { + Self::Null => out.push(0), + Self::Bool(v) => out.extend([1, *v as u8]), + Self::Int64(v) => number!(2, v), + Self::Float64(v) => { + let bits = if *v == 0. { + 0 + } else if v.is_nan() { + f64::NAN.to_bits() + } else { + v.to_bits() + }; + number!(3, bits); + } + Self::Utf8(v) => { + out.push(4); + out.extend(v.as_bytes()); + } + Self::Timestamp(v) => number!(5, v), + Self::Date(v) => number!(6, v), + Self::Interval { + months, + days, + nanos, + } => { + number!(7, months); + number!(8, days); + number!(9, nanos); + } + Self::List(v) | Self::Struct(v) => { + out.push(if matches!(self, Self::List(_)) { + 10 + } else { + 11 + }); + for v in v.iter() { + let key = v.key()?; + out.extend((key.len() as u64).to_le_bytes()); + out.extend(key); + } + } + Self::Map(v) => { + out.push(12); + for (k, v) in v.iter() { + for value in [k, v] { + let key = value.key()?; + out.extend((key.len() as u64).to_le_bytes()); + out.extend(key); + } + } + } + Self::Summary { .. } => { + return Err(Error::Invalid( + "summary states cannot be grouping keys".into(), + )) + } + } + Ok(out) + } + pub fn compare(&self, other: &Self) -> Result { + Ok(match (self, other) { + (Self::Null, Self::Null) => Ordering::Equal, + (Self::Int64(a), Self::Int64(b)) | (Self::Timestamp(a), Self::Timestamp(b)) => a.cmp(b), + (Self::Float64(a), Self::Float64(b)) => { + if a == b { + Ordering::Equal + } else { + a.total_cmp(b) + } + } + (Self::Utf8(a), Self::Utf8(b)) => a.cmp(b), + (Self::Bool(a), Self::Bool(b)) => a.cmp(b), + (Self::Date(a), Self::Date(b)) => a.cmp(b), + _ => { + return Err(Error::Operator( + "values do not have a supported common ordering".into(), + )) + } + }) + } +} +#[derive(Clone, Debug)] +pub struct Batch { + schema: Schema, + rows: Vec>, +} +impl Batch { + pub fn try_new(schema: Schema, rows: Vec>) -> Result { + validate_schema(&schema)?; + for row in &rows { + if row.len() != schema.fields.len() { + return Err(Error::Invalid( + "row width differs from Planner schema".into(), + )); + } + for (value, field) in row.iter().zip(&schema.fields) { + let matches = match (&field.dtype, value) { + (SummaryFamilyType::Plain(dtype), value) => { + value.matches(dtype, field.nullable) + } + (expected, Value::Summary { family, state }) => { + expected == family && validate_state(family, state.as_ref()).is_ok() + } + _ => false, + }; + if !matches { + return Err(Error::Invalid(format!( + "value differs from type of {}", + field.name + ))); + } + } + } + Ok(Self { schema, rows }) + } + pub fn schema(&self) -> &Schema { + &self.schema + } + pub fn rows(&self) -> &[Vec] { + &self.rows + } + pub fn bytes(&self) -> usize { + std::mem::size_of::() + + self + .rows + .iter() + .flat_map(|r| r.iter()) + .map(Value::bytes) + .sum::() + } +} +pub(crate) fn group_key(row: &[Value], columns: &[usize]) -> Result>, Error> { + columns + .iter() + .map(|&i| { + row.get(i) + .ok_or_else(|| Error::Invalid("group column out of range".into()))? + .key() + }) + .collect() +} + +pub(crate) fn validate_family(family: &SummaryFamilyType) -> Result<(), Error> { + use planner_types::post_asap::SketchAlgorithm as A; + match family { + SummaryFamilyType::ExactAggregate(..) => {} + SummaryFamilyType::Sketch(kind, _) + if matches!(kind.algorithm(), A::Kll | A::DDSketch | A::Hll) => {} + _ => { + return Err(Error::Invalid( + "summary family has no native DAG state implementation".into(), + )) + } + } + crate::capability::validate_summary_kernel( + family, + &planner_types::post_asap::SummaryUpdate::column( + planner_types::pre_asap::ColumnRef::SampleValue, + ), + &Default::default(), + ) + .map_err(Error::Invalid) +} +fn validate_state(family: &SummaryFamilyType, state: &dyn AggregateCore) -> Result<(), Error> { + use crate::accumulators::{ + datasketches_kll_accumulator::DatasketchesKLLAccumulator, + dd_sketch_accumulator::DDSketchAccumulator, exact_accumulator::ExactAccumulator, + hll_sketch_accumulator::HllSketchAccumulator, + }; + use planner_types::post_asap::SketchParams; + validate_family(family)?; + let valid = match family { + SummaryFamilyType::ExactAggregate(..) => state + .as_any() + .downcast_ref::() + .is_some_and(|s| s.family() == family && !s.is_keyed()), + SummaryFamilyType::Sketch(kind, _) => match kind.params() { + SketchParams::Kll { k } => state + .as_any() + .downcast_ref::() + .is_some_and(|s| u32::from(s.inner.k()) == *k), + SketchParams::DDSketch { alpha } => state + .as_any() + .downcast_ref::() + .is_some_and(|s| s.inner.alpha == *alpha && s.sample_p == 1.0), + SketchParams::Hll { precision } => state + .as_any() + .downcast_ref::() + .is_some_and(|s| s.inner.precision == u32::from(*precision) && s.sample_p == 1.0), + _ => false, + }, + _ => false, + }; + if valid { + Ok(()) + } else { + Err(Error::Invalid( + "state payload differs from declared family, parameters or population layout".into(), + )) + } +} + +pub(crate) fn validate_schema(schema: &Schema) -> Result<(), Error> { + if schema.time_index.is_some_and(|index| { + schema + .fields + .get(index) + .is_none_or(|field| field.dtype != SummaryFamilyType::Plain(DataType::Timestamp)) + }) { + return Err(Error::Invalid( + "time index must name a Timestamp column".into(), + )); + } + for field in &schema.fields { + if !matches!(field.dtype, SummaryFamilyType::Plain(_)) { + validate_family(&field.dtype)?; + if field.nullable { + return Err(Error::Invalid( + "nullable summary states are not supported".into(), + )); + } + } + } + Ok(()) +} diff --git a/crates/asap-physical-operators/src/lib.rs b/crates/asap-physical-operators/src/lib.rs index 4df43833..266a516f 100644 --- a/crates/asap-physical-operators/src/lib.rs +++ b/crates/asap-physical-operators/src/lib.rs @@ -13,9 +13,10 @@ pub use traits::*; pub mod arithmetic; pub mod capability; pub mod factory; -pub mod query_dag; /// The exact Planner contract used by these kernels. pub use planner_types as planner; pub mod rows; + +pub mod dag; diff --git a/crates/asap-physical-operators/src/query_dag.rs b/crates/asap-physical-operators/src/query_dag.rs deleted file mode 100644 index 88833525..00000000 --- a/crates/asap-physical-operators/src/query_dag.rs +++ /dev/null @@ -1,345 +0,0 @@ -//! Graph traversal for an installed physical QueryPlan. -//! -//! This module owns dependency ordering and memoization only. Physical node -//! definitions live in `asap_types`; 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 `{query_id}` node {node_id} failed")] - Node { - query_id: String, - 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(format!("query `{}`: {error}", entry.query_id)) - })?; - 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 { - query_id: entry.query_id.clone(), - node_id: id.0, - source, - })?; - outputs.insert(id, output); - } - outputs.remove(&root).ok_or_else(|| { - DagExecutionError::InvalidGraph(format!("root {} produced no output", root.0)) - }) -} - -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(format!("query `{}`: {error}", entry.query_id)) - })?; - 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 { - query_id: entry.query_id.clone(), - 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>); - - struct FailingRuntime; - - impl QueryNodeRuntime for FailingRuntime { - type Output = usize; - type Error = &'static str; - - fn execute_node( - &self, - _id: QueryNodeId, - _node: &QueryPlanNode, - _inputs: &[usize], - ) -> Result { - Err("broken read") - } - } - - 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)); - } - - #[test] - fn node_failure_identifies_the_installed_query_and_node() { - let entry = QueryPlanEntry { - language: asap_types::query_plan::QueryLanguage::PromQl, - query_id: "latency-p50".into(), - canonical_query: "latency".into(), - fixed_evaluation: None, - root: QueryNodeId(7), - nodes: BTreeMap::from([( - QueryNodeId(7), - QueryPlanNode::ExactFallback { - reason: "fixture".into(), - }, - )]), - instant: InstantExecution { - lookback_ms: 0, - full_history: false, - cumulative_readout: false, - }, - fallback: FallbackPolicy::Reject, - }; - - let error = execute(&entry, &FailingRuntime).unwrap_err().to_string(); - assert!(error.contains("query `latency-p50` node 7 failed")); - } - - 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/crates/asap-physical-operators/tests/physical_dag.rs b/crates/asap-physical-operators/tests/physical_dag.rs new file mode 100644 index 00000000..a46cd2d0 --- /dev/null +++ b/crates/asap-physical-operators/tests/physical_dag.rs @@ -0,0 +1,663 @@ +//! Acceptance tests use the library directly, without either backend engine. +use asap_physical_operators::{ + dag::{ + operators::{Expression, Operator, Reduction, SortKey}, + values::{Batch, Schema, Value}, + Limits, PhysicalDag, RunContext, Scope, + }, + Statistic, +}; +use futures::{executor::block_on, StreamExt}; +use planner_types::{ + post_asap::{ExactKind, ExactParams, SummaryFamilyType, SummaryField, SummarySchema}, + pre_asap::DataType, +}; +use std::sync::Arc; +fn schema(fields: &[(&str, DataType, bool)]) -> Schema { + Arc::new(SummarySchema { + fields: fields + .iter() + .map(|(name, dtype, nullable)| SummaryField { + name: (*name).into(), + dtype: SummaryFamilyType::Plain(dtype.clone()), + nullable: *nullable, + }) + .collect(), + time_index: None, + }) +} +fn run(dag: &PhysicalDag<'_, Batch, Schema>, root: u64, scope: Scope) -> Vec> { + let context = RunContext::new( + scope, + Limits { + max_buffered_batches: 1, + ..Limits::default() + }, + ) + .unwrap(); + block_on(async { + let mut stream = dag.execute(&[root], context.clone()).unwrap().remove(0); + let mut rows = vec![]; + while let Some(batch) = stream.next().await { + rows.extend(batch.unwrap().rows().iter().cloned()); + } + assert_eq!(context.retained_bytes(), 0); + rows + }) +} +fn query() -> Scope { + Scope::Query { + evaluation_time_ms: 1000, + revision: 2, + } +} +fn floats(rows: &[Vec], column: usize) -> Vec { + rows.iter() + .map(|r| { + if let Value::Float64(v) = r[column] { + v + } else { + panic!("not Float64") + } + }) + .collect() +} + +// Sort followed by partitioned Limit implements ranking independently per group. +#[test] +fn grouped_sort_limit_across_batches() { + let schema = schema(&[ + ("group", DataType::Int64, false), + ("score", DataType::Float64, false), + ]); + let batches = [ + vec![(1, 1.), (2, 4.), (1, 9.)], + vec![(2, 8.), (1, 5.), (2, 2.)], + ] + .into_iter() + .map(|rows| { + Batch::try_new( + schema.clone(), + rows.into_iter() + .map(|(g, v)| vec![Value::Int64(g), Value::Float64(v)]) + .collect(), + ) + .unwrap() + }) + .collect(); + let mut dag = PhysicalDag::default(); + dag.add( + 0, + vec![], + Operator::source(schema.clone(), batches).unwrap(), + ) + .unwrap(); + dag.add( + 1, + vec![0], + Operator::sort( + schema.clone(), + vec![SortKey { + column: 1, + descending: true, + nulls_first: false, + }], + vec![0], + ) + .unwrap(), + ) + .unwrap(); + dag.add(2, vec![1], Operator::limit(schema, 1, 1, vec![0]).unwrap()) + .unwrap(); + assert_eq!(floats(&run(&dag, 2, query()), 1), vec![5., 4.]); +} + +// The same computation runs in either engine scope with fresh per-run state. +#[test] +fn summary_construction_merge_and_readout_at_both_phases() { + let schema = schema(&[("v", DataType::Float64, false)]); + let batches = (1..=20) + .map(|v| Batch::try_new(schema.clone(), vec![vec![Value::Float64(v as f64)]]).unwrap()) + .collect(); + let family = SummaryFamilyType::ExactAggregate(ExactKind::Sum, ExactParams::Sum); + let build = Operator::summary_build(schema.clone(), family, 0, None, vec![]).unwrap(); + let state = build.schema(); + let mut dag = PhysicalDag::default(); + dag.add(0, vec![], Operator::source(schema, batches).unwrap()) + .unwrap(); + dag.add(1, vec![0], build).unwrap(); + dag.add(2, vec![1, 1], Operator::union(state.clone(), 2).unwrap()) + .unwrap(); + dag.add( + 3, + vec![2], + Operator::summary_merge(state.clone(), 0, vec![]).unwrap(), + ) + .unwrap(); + dag.add( + 4, + vec![3], + Operator::readout(state, 0, Statistic::Sum, Default::default()).unwrap(), + ) + .unwrap(); + for scope in [ + query(), + Scope::Ingestion { + window_start_ms: 0, + window_end_ms: 1000, + revision: 2, + }, + ] { + assert_eq!(floats(&run(&dag, 4, scope), 0), vec![420.]); + } +} + +// A semi-join can consume two branches of one producer with a one-batch buffer. +#[test] +fn diamond_semijoin_preserves_left_values_and_multiplicity() { + let schema = schema(&[("key", DataType::Int64, false)]); + let batches = [1, 2, 2, 3] + .into_iter() + .map(|v| Batch::try_new(schema.clone(), vec![vec![Value::Int64(v)]]).unwrap()) + .collect(); + let filter = Operator::filter( + schema.clone(), + Expression::Equal( + Box::new(Expression::Column(0)), + Box::new(Expression::Literal { + value: Value::Int64(2), + dtype: DataType::Int64, + }), + ), + ) + .unwrap(); + let mut dag = PhysicalDag::default(); + dag.add( + 0, + vec![], + Operator::source(schema.clone(), batches).unwrap(), + ) + .unwrap(); + dag.add(1, vec![0], filter).unwrap(); + dag.add( + 2, + vec![0, 1], + Operator::semi_join(schema.clone(), schema, vec![(0, 0)]).unwrap(), + ) + .unwrap(); + let rows = run(&dag, 2, query()); + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|r| matches!(r[0], Value::Int64(2)))); +} + +// Integer aggregation must not silently lose precision through Float64. +#[test] +fn exact_integer_and_empty_extrema() { + let schema = schema(&[("v", DataType::Int64, false)]); + let aggregate = Operator::aggregate( + schema.clone(), + vec![], + vec![("sum".into(), Reduction::Sum(0))], + ) + .unwrap(); + let mut dag = PhysicalDag::default(); + let value = 9_007_199_254_740_993; + dag.add( + 0, + vec![], + Operator::source( + schema.clone(), + vec![Batch::try_new( + schema.clone(), + vec![vec![Value::Int64(value)], vec![Value::Int64(2)]], + ) + .unwrap()], + ) + .unwrap(), + ) + .unwrap(); + dag.add(1, vec![0], aggregate).unwrap(); + assert!(matches!(run(&dag,1,query())[0][0],Value::Int64(v) if v==value+2)); + let mut empty = PhysicalDag::default(); + empty + .add(0, vec![], Operator::source(schema.clone(), vec![]).unwrap()) + .unwrap(); + empty + .add( + 1, + vec![0], + Operator::aggregate(schema, vec![], vec![("min".into(), Reduction::Min(0))]).unwrap(), + ) + .unwrap(); + assert!(matches!(run(&empty, 1, query())[0][0], Value::Null)); +} + +// Plain value operators are library implementations, including NaN comparison. +#[test] +fn scalar_negation_and_vector_conversion() { + let scalar = Operator::scalar(Value::Float64(7.), DataType::Float64).unwrap(); + let project = Operator::project( + scalar.schema(), + vec![( + "v".into(), + Expression::Negate(Box::new(Expression::Column(0))), + )], + ) + .unwrap(); + let convert = Operator::vector_to_scalar(project.schema(), 0).unwrap(); + let mut dag = PhysicalDag::default(); + dag.add(0, vec![], scalar).unwrap(); + dag.add(1, vec![0], project).unwrap(); + dag.add(2, vec![1], convert).unwrap(); + assert_eq!(floats(&run(&dag, 2, query()), 0), vec![-7.]); + let scalar = Operator::scalar(Value::Float64(f64::NAN), DataType::Float64).unwrap(); + let predicate = Expression::Equal( + Box::new(Expression::Column(0)), + Box::new(Expression::Column(0)), + ); + let filter = Operator::filter(scalar.schema(), predicate).unwrap(); + let mut dag = PhysicalDag::default(); + dag.add(0, vec![], scalar).unwrap(); + dag.add(1, vec![0], filter).unwrap(); + assert!(run(&dag, 1, query()).is_empty()); +} + +// Invalid operations fail at binding rather than becoming external fallbacks. +#[test] +fn binding_rejects_unsupported_operations() { + let schema = schema(&[("v", DataType::Float64, false)]); + assert!(Operator::summary_build( + schema.clone(), + SummaryFamilyType::ExactAggregate(ExactKind::Rate, ExactParams::Rate), + 0, + None, + vec![] + ) + .is_err()); + let sum = Operator::summary_build( + schema.clone(), + SummaryFamilyType::ExactAggregate(ExactKind::Sum, ExactParams::Sum), + 0, + None, + vec![], + ) + .unwrap(); + assert!(Operator::readout(sum.schema(), 0, Statistic::Quantile, Default::default()).is_err()); + assert!(Operator::filter(schema, Expression::Column(0)).is_err()); +} + +// KLL is one family example: precomputation changes input sources, not operators. +#[test] +fn kll_raw_partial_and_precomputed_are_native_dags() { + use planner_types::post_asap::{GroupingStrategy, SketchAlgorithm, SketchKind, SketchParams}; + let input = schema(&[("value", DataType::Float64, false)]); + let family = SummaryFamilyType::Sketch( + SketchKind::new(SketchAlgorithm::Kll, SketchParams::Kll { k: 512 }), + GroupingStrategy::PerSubpopulationInstance, + ); + let build = Operator::summary_build(input.clone(), family, 0, None, vec![]).unwrap(); + let state = build.schema(); + let build_range = |start: u32, end: u32| { + let mut dag = PhysicalDag::default(); + let batch = Batch::try_new( + input.clone(), + (start..end) + .map(|v| vec![Value::Float64(f64::from(v))]) + .collect(), + ) + .unwrap(); + dag.add( + 0, + vec![], + Operator::source(input.clone(), vec![batch]).unwrap(), + ) + .unwrap(); + dag.add(1, vec![0], build.clone()).unwrap(); + run( + &dag, + 1, + Scope::Ingestion { + window_start_ms: 0, + window_end_ms: 1000, + revision: 1, + }, + ) + }; + let prefix = build_range(0, 64); + let complete = build_range(0, 128); + let query_plan = |stored: Option>>, raw_start: Option| { + let mut dag = PhysicalDag::default(); + let mut states = vec![]; + if let Some(rows) = stored { + dag.add( + 0, + vec![], + Operator::source( + state.clone(), + vec![Batch::try_new(state.clone(), rows).unwrap()], + ) + .unwrap(), + ) + .unwrap(); + states.push(0); + } + if let Some(start) = raw_start { + dag.add( + 1, + vec![], + Operator::source( + input.clone(), + vec![Batch::try_new( + input.clone(), + (start..128) + .map(|v| vec![Value::Float64(f64::from(v))]) + .collect(), + ) + .unwrap()], + ) + .unwrap(), + ) + .unwrap(); + dag.add(2, vec![1], build.clone()).unwrap(); + states.push(2); + } + dag.add( + 3, + states.clone(), + Operator::union(state.clone(), states.len()).unwrap(), + ) + .unwrap(); + dag.add( + 4, + vec![3], + Operator::summary_merge(state.clone(), 0, vec![]).unwrap(), + ) + .unwrap(); + dag.add( + 5, + vec![4], + Operator::readout( + state.clone(), + 0, + Statistic::Quantile, + std::collections::HashMap::from([("quantile".into(), "0.5".into())]), + ) + .unwrap(), + ) + .unwrap(); + floats(&run(&dag, 5, query()), 0)[0] + }; + let raw = query_plan(None, Some(0)); + let partial = query_plan(Some(prefix), Some(64)); + let full = query_plan(Some(complete), None); + assert_eq!(raw, partial); + assert_eq!(partial, full); + assert!((raw - 64.).abs() <= 1.); +} + +// Restored state must retain its family; a mislabeled state is rejected. +#[test] +fn restored_exact_state_and_family_validation() { + use asap_physical_operators::{ + accumulators::exact_accumulator::ExactAccumulator, SerializableToSink, + }; + let family = SummaryFamilyType::ExactAggregate(ExactKind::Sum, ExactParams::Sum); + let mut acc = ExactAccumulator::new(family.clone(), false).unwrap(); + acc.update(None, 7., 0); + let acc = ExactAccumulator::deserialize_from_bytes(&acc.serialize_to_bytes()).unwrap(); + let schema = Arc::new(SummarySchema { + fields: vec![SummaryField { + name: "state".into(), + dtype: family.clone(), + nullable: false, + }], + time_index: None, + }); + let value = Value::Summary { + family: family.clone(), + state: Arc::new(acc), + }; + let mut dag = PhysicalDag::default(); + dag.add( + 0, + vec![], + Operator::source( + schema.clone(), + vec![Batch::try_new(schema.clone(), vec![vec![value]]).unwrap()], + ) + .unwrap(), + ) + .unwrap(); + dag.add( + 1, + vec![0], + Operator::readout(schema.clone(), 0, Statistic::Sum, Default::default()).unwrap(), + ) + .unwrap(); + assert_eq!(floats(&run(&dag, 1, query()), 0), vec![7.]); + let wrong = ExactAccumulator::new( + SummaryFamilyType::ExactAggregate(ExactKind::Max, ExactParams::Max), + false, + ) + .unwrap(); + assert!(Batch::try_new( + schema, + vec![vec![Value::Summary { + family, + state: Arc::new(wrong) + }]] + ) + .is_err()); +} + +// Planner binding rejects unknown computation instead of accepting a fallback. +#[test] +fn bind_post_asap_before_execution() { + use asap_physical_operators::dag::planner::bind; + use planner_types::{ + post_asap::{ + EdgeRole, ExecutableDag, ExecutableDagEdge, ExecutableDagNode, + ExecutableOperatorPayload, ExecutionDataState, ExecutionTiming, + GroupingEdgeCompatibility, PostAsapNodeId, ValueOperation, WindowEdgeCompatibility, + }, + pre_asap::{ArithmeticOpKind, ProjectItem, QueryExpr, ScalarValue}, + }; + use std::{collections::BTreeMap, rc::Rc}; + let schema = schema(&[("value", DataType::Float64, false)]); + let node = |id, payload| ExecutableDagNode { + id: PostAsapNodeId(id), + payload, + output_state: ExecutionDataState::QUERY_ROWS, + output_schema: (*schema).clone(), + guarantee: None, + }; + let mut dag = ExecutableDag { + nodes: vec![ + node( + 0, + ExecutableOperatorPayload::Fallback { + expression: QueryExpr::promql_scalar(1.), + }, + ), + node( + 1, + ExecutableOperatorPayload::Value { + timing: ExecutionTiming::QueryTime, + operation: ValueOperation::Project { + cols: vec![ProjectItem { + alias: None, + expr: QueryExpr::Arithmetic { + op: ArithmeticOpKind::Add, + left: Rc::new(QueryExpr::Column(0)), + right: Rc::new(QueryExpr::Literal(ScalarValue::Float64(2.))), + }, + }], + qualifier: None, + }, + }, + ), + ], + edges: vec![ExecutableDagEdge { + producer: PostAsapNodeId(0), + consumer: PostAsapNodeId(1), + role: EdgeRole::Input, + intermediate_schema: (*schema).clone(), + data_state: ExecutionDataState::QUERY_ROWS, + grouping: GroupingEdgeCompatibility::NotApplicable, + window: WindowEdgeCompatibility::NotApplicable, + }], + root: PostAsapNodeId(1), + }; + let sources = || -> BTreeMap> { + BTreeMap::from([( + 0, + Box::new( + Operator::source( + schema.clone(), + vec![Batch::try_new(schema.clone(), vec![vec![Value::Float64(1.)]]).unwrap()], + ) + .unwrap(), + ) as asap_physical_operators::dag::planner::Source<'static>, + )]) + }; + let native = bind(&dag, sources(), &[1]).unwrap(); + assert_eq!(floats(&run(&native, 1, query()), 0), vec![3.]); + assert!(bind(&dag, BTreeMap::new(), &[1]).is_err()); + dag.nodes[1].payload = ExecutableOperatorPayload::Value { + timing: ExecutionTiming::QueryTime, + operation: ValueOperation::Extension { + name: "unknown".into(), + }, + }; + assert!(bind(&dag, sources(), &[1]).is_err()); +} + +// A completed empty population has an exact zero count, with integer output. +#[test] +fn empty_exact_count_is_an_integer_state_readout() { + let input = schema(&[("value", DataType::Float64, false)]); + let build = Operator::summary_build( + input.clone(), + SummaryFamilyType::ExactAggregate(ExactKind::Count, ExactParams::Count), + 0, + None, + vec![], + ) + .unwrap(); + let read = Operator::readout(build.schema(), 0, Statistic::Count, Default::default()).unwrap(); + let mut dag = PhysicalDag::default(); + dag.add(0, vec![], Operator::source(input, vec![]).unwrap()) + .unwrap(); + dag.add(1, vec![0], build).unwrap(); + dag.add(2, vec![1], read).unwrap(); + assert!(matches!(run(&dag, 2, query())[0][0], Value::Int64(0))); +} + +// A deployment source cannot pass a different row shape to bound expressions. +#[test] +fn source_batches_must_match_the_bound_schema() { + use asap_physical_operators::dag::{self, PhysicalOperator}; + use planner_types::{ + post_asap::{ + ExecutableDag, ExecutableDagNode, ExecutableOperatorPayload, ExecutionDataState, + PostAsapNodeId, + }, + pre_asap::QueryExpr, + }; + use std::{cell::Cell, collections::BTreeMap, rc::Rc}; + struct WrongSource { + schema: Schema, + starts: Rc>, + } + impl PhysicalOperator for WrongSource { + fn name(&self) -> &str { + "ExternalSource" + } + fn input_schemas(&self) -> Vec { + vec![] + } + fn output_schema(&self) -> Schema { + self.schema.clone() + } + fn output_bytes(&self, value: &Batch) -> usize { + value.bytes() + } + fn start<'a>( + &'a self, + _: Vec>, + _: RunContext, + ) -> Result, dag::Error> { + self.starts.set(self.starts.get() + 1); + Ok( + futures::stream::once(async { Batch::try_new(schema(&[]), vec![vec![]]) }) + .boxed_local(), + ) + } + } + let expected = schema(&[("value", DataType::Float64, false)]); + let starts = Rc::new(Cell::new(0)); + let plan = ExecutableDag { + nodes: vec![ExecutableDagNode { + id: PostAsapNodeId(0), + payload: ExecutableOperatorPayload::Fallback { + expression: QueryExpr::promql_scalar(1.), + }, + output_state: ExecutionDataState::QUERY_ROWS, + output_schema: (*expected).clone(), + guarantee: None, + }], + edges: vec![], + root: PostAsapNodeId(0), + }; + let source = Box::new(WrongSource { + schema: expected, + starts: starts.clone(), + }) as dag::planner::Source<'static>; + let native = dag::planner::bind(&plan, BTreeMap::from([(0, source)]), &[0]).unwrap(); + assert_eq!(starts.get(), 0); + let context = RunContext::new(query(), Limits::default()).unwrap(); + let mut output = native.execute(&[0], context).unwrap().remove(0); + assert!(matches!( + block_on(output.next()), + Some(Err(dag::Error::AtNode { node: 0, .. })) + )); + assert_eq!(starts.get(), 1); +} + +// Float extrema have the same NaN behavior as the exact summary kernels. +#[test] +fn extrema_preserve_numeric_values_in_the_presence_of_nan() { + let input = schema(&[("v", DataType::Float64, false)]); + let mut dag = PhysicalDag::default(); + dag.add( + 0, + vec![], + Operator::source( + input.clone(), + vec![Batch::try_new( + input.clone(), + vec![vec![Value::Float64(-f64::NAN)], vec![Value::Float64(5.)]], + ) + .unwrap()], + ) + .unwrap(), + ) + .unwrap(); + dag.add( + 1, + vec![0], + Operator::aggregate( + input, + vec![], + vec![ + ("min".into(), Reduction::Min(0)), + ("max".into(), Reduction::Max(0)), + ], + ) + .unwrap(), + ) + .unwrap(); + let rows = run(&dag, 1, query()); + assert_eq!(floats(&rows, 0), vec![5.]); + assert_eq!(floats(&rows, 1), vec![5.]); +} diff --git a/data_plane/src/precompute_engine/maintenance_runtime.rs b/data_plane/src/precompute_engine/maintenance_runtime.rs index 30a0a391..65e6ad0f 100644 --- a/data_plane/src/precompute_engine/maintenance_runtime.rs +++ b/data_plane/src/precompute_engine/maintenance_runtime.rs @@ -163,6 +163,34 @@ impl PrecomputeOperatorRegistry for OperatorAdapter<'_> { } } + fn output_bytes(&self, value: &MaintenanceValue) -> usize { + fn labels(group: &Population) -> usize { + group.iter().map(|(k, v)| k.len() + v.len()).sum() + } + match value { + MaintenanceValue::Summary { state, .. } => state.approx_memory_bytes(), + MaintenanceValue::SummaryWindows { states, .. } => states + .iter() + .map(|(group, windows)| { + labels(group) + + windows + .iter() + .map(|(_, state)| 8 + state.approx_memory_bytes()) + .sum::() + }) + .sum(), + MaintenanceValue::Rows { values, name, .. } => { + name.len() + + values + .iter() + .map(|(group, rows)| { + labels(group) + rows.len() * std::mem::size_of::<(i64, f64)>() + }) + .sum::() + } + } + } + fn execute( &self, node: &ExecutableDagNode, diff --git a/data_plane/src/precompute_engine/subdag_scheduler.rs b/data_plane/src/precompute_engine/subdag_scheduler.rs index ba5e8868..a35c25cc 100644 --- a/data_plane/src/precompute_engine/subdag_scheduler.rs +++ b/data_plane/src/precompute_engine/subdag_scheduler.rs @@ -1,8 +1,11 @@ +use asap_physical_operators::dag as execution; use asap_types::executable_plan::{BackendExecutableBinding, BackendNodeBinding}; +use futures::StreamExt; use planner_types::post_asap::PostAsapNodeId; use planner_types::post_asap::{ EdgeRole, ExecutableDag, ExecutableDagNode, ExecutableOperatorPayload, ExecutionDataState, }; +use std::{cell::RefCell, rc::Rc}; use std::{ collections::{BTreeMap, BTreeSet}, sync::Arc, @@ -28,6 +31,9 @@ pub trait PrecomputeOperatorRegistry { fn materialized_input(&self, _node: &ExecutableDagNode) -> Result, Self::Error> { Ok(None) } + fn output_bytes(&self, _value: &V) -> usize { + std::mem::size_of::().max(1) + } fn execute(&self, node: &ExecutableDagNode, inputs: &[Arc]) -> Result; } @@ -158,26 +164,25 @@ where }; inputs.insert(consumer, ordered); } - let mut active = BTreeSet::new(); - let mut values = BTreeMap::>::new(); - fn visit( - id: u32, - nodes: &BTreeMap, - inputs: &BTreeMap>, - active: &mut BTreeSet, - values: &mut BTreeMap>, - registry: &R, - ) -> Result<(), ScheduleError> - where - R: PrecomputeOperatorRegistry, - { - if values.contains_key(&id) { - return Ok(()); + if outputs.is_empty() { + return Ok(Vec::new()); + } + let error = Rc::new(RefCell::new(None)); + let mut sources = BTreeMap::new(); + for (node, key) in outputs { + if let Some(value) = sink.get(key).map_err(ScheduleError::Sink)? { + sources.insert(node.0, value); } - if !active.insert(id) { - return Err(ScheduleError::Invalid("precompute subDAG cycle".into())); + } + let committed = sources.keys().copied().collect::>(); + let mut graph = execution::PhysicalDag::default(); + let mut pending = outputs.iter().map(|(id, _)| id.0).collect::>(); + let mut added = BTreeSet::new(); + while let Some(id) = pending.pop() { + if !added.insert(id) { + continue; } - let node = nodes + let node = *nodes .get(&id) .ok_or_else(|| ScheduleError::Invalid(format!("missing node {id}")))?; if node.output_state.timing == planner_types::post_asap::ExecutionTiming::QueryTime { @@ -185,43 +190,144 @@ where "query-time node {id} in precompute dependency path" ))); } - if let Some(value) = registry - .materialized_input(node) - .map_err(ScheduleError::Operator)? - { - values.insert(id, Arc::new(value)); - active.remove(&id); - return Ok(()); - } - let child_ids = inputs.get(&id).cloned().unwrap_or_default(); - for child in &child_ids { - visit(*child, nodes, inputs, active, values, registry)?; - } - let child_values = child_ids + let source = if let Some(value) = sources.remove(&id) { + Some(value) + } else { + registry + .materialized_input(node) + .map_err(ScheduleError::Operator)? + .map(Arc::new) + }; + let children = if source.is_some() { + vec![] + } else { + inputs.get(&id).cloned().unwrap_or_default() + }; + let schemas = children .iter() - .map(|child| Arc::clone(&values[child])) - .collect::>(); - let value = registry - .execute(node, &child_values) - .map_err(ScheduleError::Operator)?; - values.insert(id, Arc::new(value)); - active.remove(&id); - Ok(()) + .map(|child| { + nodes + .get(child) + .map(|n| n.output_schema.clone()) + .ok_or_else(|| ScheduleError::Invalid(format!("missing node {child}"))) + }) + .collect::, _>>()?; + pending.extend(children.iter().copied()); + graph + .add( + u64::from(id), + children.into_iter().map(u64::from).collect(), + IngestionOperator { + node, + registry, + source, + schemas, + error: error.clone(), + }, + ) + .map_err(|e| ScheduleError::Invalid(e.to_string()))?; } - let mut results = Vec::with_capacity(outputs.len()); - for (node, key) in outputs { - if let Some(committed) = sink.get(key).map_err(ScheduleError::Sink)? { - values.insert(node.0, Arc::clone(&committed)); - results.push(committed); - continue; - } - visit(node.0, &nodes, &inputs, &mut active, &mut values, registry)?; - let value = sink - .commit_if_absent(key.clone(), Arc::clone(&values[&node.0])) - .map_err(ScheduleError::Sink)?; - results.push(value); + let key = &outputs[0].1; + let context = execution::RunContext::new( + execution::Scope::Ingestion { + window_start_ms: key.window_start_ms, + window_end_ms: key.window_end_ms, + revision: key.plan_version, + }, + execution::Limits::default(), + ) + .map_err(|e| ScheduleError::Invalid(e.to_string()))?; + let roots = outputs + .iter() + .map(|(id, _)| u64::from(id.0)) + .collect::>(); + let streams = graph + .execute(&roots, context) + .map_err(|e| ScheduleError::Invalid(e.to_string()))?; + futures::executor::block_on(futures::future::try_join_all( + streams + .into_iter() + .zip(outputs) + .map(|(mut stream, (node, key))| { + let error = &error; + let committed = &committed; + async move { + let result = stream.next().await.ok_or_else(|| { + ScheduleError::Invalid("ingestion root produced no value".into()) + })?; + let value = match result { + Ok(value) => Arc::clone(value.value()), + Err(failure) => { + return Err(match error.borrow_mut().take() { + Some(error) => ScheduleError::Operator(error), + None => ScheduleError::Invalid(failure.to_string()), + }) + } + }; + if committed.contains(&node.0) { + Ok(value) + } else { + sink.commit_if_absent(key.clone(), value) + .map_err(ScheduleError::Sink) + } + } + }), + )) +} + +struct IngestionOperator<'a, V, R: PrecomputeOperatorRegistry> { + node: &'a ExecutableDagNode, + registry: &'a R, + source: Option>, + schemas: Vec, + error: Rc>>, +} +impl> + execution::PhysicalOperator, planner_types::post_asap::SummarySchema> + for IngestionOperator<'_, V, R> +{ + fn name(&self) -> &str { + "InstalledIngestionOperator" + } + fn input_schemas(&self) -> Vec { + self.schemas.clone() + } + fn output_schema(&self) -> planner_types::post_asap::SummarySchema { + self.node.output_schema.clone() + } + fn output_bytes(&self, value: &Arc) -> usize { + self.registry.output_bytes(value) + } + fn start<'a>( + &'a self, + inputs: Vec>>, + _: execution::RunContext, + ) -> Result>, execution::Error> { + Ok(futures::stream::once(async move { + if let Some(source) = &self.source { + return Ok(Arc::clone(source)); + } + let inputs = + futures::future::try_join_all(inputs.into_iter().map(|mut input| async move { + input.next().await.ok_or_else(|| { + execution::Error::Operator("ingestion input produced no value".into()) + })? + })) + .await?; + let values = inputs + .iter() + .map(|value| Arc::clone(value.value())) + .collect::>(); + self.registry + .execute(self.node, &values) + .map(Arc::new) + .map_err(|e| { + *self.error.borrow_mut() = Some(e); + execution::Error::Operator(format!("ingestion node {} failed", self.node.id.0)) + }) + }) + .boxed_local()) } - Ok(results) } #[cfg(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 86b29c8f..d20efdc3 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 @@ -4,10 +4,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; @@ -104,17 +107,91 @@ fn execute_values( where F: FnMut(QueryNodeId, u64) -> Result, { - let mut evaluator = Evaluator { + let at_signed = i64::try_from(at).map_err(|_| miss("evaluation timestamp overflow"))?; + let runtime = RefCell::new(ValueRuntime { entry, leaves, callback, stats: ExecutionStats::default(), - memo: BTreeMap::new(), - active: BTreeSet::new(), - warnings: Vec::new(), + warnings: vec![], + }); + let error = RefCell::new(None); + let mut graph = physical::PhysicalDag::default(); + let mut identities = BTreeMap::from([((entry.root, at_signed), 0u64)]); + let mut pending = vec![(entry.root, at_signed)]; + let mut depths = BTreeMap::from([((entry.root, at_signed), 1usize)]); + while let Some((id, time)) = pending.pop() { + let node = entry + .nodes + .get(&id) + .ok_or_else(|| miss("missing installed node"))?; + let dependencies = if leaves.contains_key(&(id, time)) { + vec![] + } else { + expanded_inputs(node, time)? + }; + let mut input_ids = Vec::new(); + for dependency in &dependencies { + if let Some(id) = identities.get(dependency) { + runtime.borrow_mut().stats.memo_hits += 1; + input_ids.push(*id); + } else { + if identities.len() >= 200_000 { + return Err(miss("installed DAG evaluation budget exceeded")); + } + let depth = depths[&(id, time)] + 1; + if depth > 128 { + return Err(miss("installed DAG exceeds execution depth of 128")); + } + depths.insert(*dependency, depth); + let id = identities.len() as u64; + identities.insert(*dependency, id); + pending.push(*dependency); + input_ids.push(id); + } + } + graph + .add( + identities[&(id, time)], + input_ids, + BoundValueOperator { + id, + time, + node, + dependencies, + runtime: &runtime, + error: &error, + }, + ) + .map_err(|error| miss(error.to_string()))?; + } + let context = physical::RunContext::new( + physical::Scope::Query { + evaluation_time_ms: at_signed, + revision: 0, + }, + physical::Limits::default(), + ) + .map_err(|error| miss(error.to_string()))?; + let mut output = graph + .execute(&[0], context) + .map_err(|error| miss(error.to_string()))? + .remove(0); + // These adapters have synchronous callbacks and prepared I/O leaves. A + // single poll avoids nesting a blocking futures executor inside a readout. + let evaluated = match output.next().now_or_never().flatten() { + Some(Ok(value)) => value.value().clone(), + Some(Err(failure)) => { + return Err(error + .borrow_mut() + .take() + .unwrap_or_else(|| miss(failure.to_string()))) + } + None => return Err(miss("synchronous query adapter did not produce a result")), }; - let at_signed = i64::try_from(at).map_err(|_| miss("evaluation timestamp overflow"))?; - let evaluated = evaluator.eval(entry.root, at_signed)?; + drop(output); + drop(graph); + let mut evaluator = runtime.into_inner(); if matches!(evaluated, Value::Scalar(_)) { // QueryResult currently models vectors/matrices only. Preserve a scalar // root's HTTP type by routing it to native, while scalar intermediates @@ -141,21 +218,22 @@ where Ok((output, evaluator.stats)) } -struct Evaluator<'a, F> { +struct ValueRuntime<'a, F> { entry: &'a QueryPlanEntry, leaves: &'a PreparedLeaves, stats: ExecutionStats, callback: F, - memo: BTreeMap<(QueryNodeId, i64), Value>, - active: BTreeSet<(QueryNodeId, i64)>, warnings: Vec, } -impl Result> Evaluator<'_, F> { - fn eval(&mut self, id: QueryNodeId, at: i64) -> Result { - if let Some(value) = self.memo.get(&(id, at)) { - self.stats.memo_hits += 1; - return Ok(value.clone()); - } +impl Result> ValueRuntime<'_, F> { + fn execute_node( + &mut self, + id: QueryNodeId, + at: i64, + node: &QueryPlanNode, + inputs: &[Value], + dependencies: &[(QueryNodeId, i64)], + ) -> Result { if let Some(leaf) = self.leaves.get(&(id, at)) { if leaf.remote { self.stats.remote_branch_evaluations += 1; @@ -165,22 +243,9 @@ impl Result> Evaluator<' self.stats.summary_readout_evaluations += 1; } let value = leaf.value.clone(); - self.memo.insert((id, at), value.clone()); return Ok(value); } - if self.active.len() >= 256 || !self.active.insert((id, at)) { - return Err(miss("cyclic or excessively deep installed DAG")); - } - if self.memo.len() >= 200_000 { - return Err(miss("installed DAG evaluation budget exceeded")); - } - let node = self - .entry - .nodes - .get(&id) - .ok_or_else(|| miss("missing installed node"))? - .clone(); - let value = match node { + let value = match node.clone() { QueryPlanNode::Scalar { value } => Value::Scalar(value), QueryPlanNode::Logical { operator: ResidualQueryOperator::CurrentSeries { .. }, @@ -192,7 +257,7 @@ impl Result> Evaluator<' u64::try_from(at).map_err(|_| miss("negative current-series timestamp"))?, )?)? } - QueryPlanNode::Logical { operator, inputs } => { + QueryPlanNode::Logical { operator, .. } => { if matches!( operator, ResidualQueryOperator::Scan { .. } @@ -203,14 +268,14 @@ impl Result> Evaluator<' "installed Prometheus leaf was not prepared; backend raw execution is forbidden", )); } - self.logical(operator, &inputs, at)? + self.logical(operator, inputs, dependencies, at)? } - QueryPlanNode::MembershipFilter { - inputs, - completeness, - } => { - let candidates = vector(self.eval(inputs[0], at)?)?; - let values = vector(self.eval(inputs[1], at)?)?; + QueryPlanNode::MembershipFilter { completeness, .. } => { + let [candidates, values] = inputs else { + return Err(miss("membership operator requires two inputs")); + }; + let candidates = vector(candidates.clone())?; + let values = vector(values.clone())?; let (selected, warning) = membership_filter(candidates, values, &completeness)?; if let Some(warning) = warning { self.warnings.push(warning); @@ -225,20 +290,19 @@ 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, ) -> Result { let input = |index: usize| { inputs .get(index) - .copied() + .cloned() .ok_or_else(|| miss("missing logical input")) }; match operator { @@ -252,7 +316,7 @@ impl Result> Evaluator<' ResidualQueryOperator::Scan { .. } => { Err(miss("local raw Scan is forbidden in deployed plans")) } - ResidualQueryOperator::UnaryNegate => match self.eval(input(0)?, at)? { + ResidualQueryOperator::UnaryNegate => match input(0)? { Value::Scalar(value) => Ok(Value::Scalar(-value)), Value::Vector(values) => Ok(Value::Vector( values @@ -263,7 +327,7 @@ impl Result> Evaluator<' _ => Err(miss("cannot negate range vector")), }, ResidualQueryOperator::VectorToScalar => { - let values = vector(self.eval(input(0)?, at)?)?; + let values = vector(input(0)?)?; Ok(Value::Scalar(if values.len() == 1 { values[0].1 } else { @@ -274,23 +338,23 @@ impl Result> Evaluator<' operation, grouping, } => { - let values = vector(self.eval(input(0)?, at)?)?; + let values = vector(input(0)?)?; Ok(Value::Vector(aggregate(operation, &grouping, values))) } ResidualQueryOperator::TopKSelection { k, grouping } => { - let values = vector(self.eval(input(0)?, at)?)?; + let values = vector(input(0)?)?; Ok(Value::Vector(topk_selection(k, &grouping, values))) } ResidualQueryOperator::Binary { operation, return_bool, } => { - let left = self.eval(input(0)?, at)?; - let right = self.eval(input(1)?, at)?; + let left = input(0)?; + let right = input(1)?; binary(operation, return_bool, left, right) } 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")); }; Ok(Value::Vector( @@ -351,7 +415,7 @@ impl Result> Evaluator<' )) } ResidualQueryOperator::Sort { descending } => { - let mut values = vector(self.eval(input(0)?, at)?)?; + let mut values = vector(input(0)?)?; values.sort_by(|a, b| { if a.1.is_nan() && b.1.is_nan() { std::cmp::Ordering::Equal @@ -368,11 +432,11 @@ impl Result> Evaluator<' Ok(Value::Vector(values)) } 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)); } @@ -389,30 +453,15 @@ impl Result> Evaluator<' 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)) } @@ -420,6 +469,134 @@ impl Result> Evaluator<' } } +fn subquery_grid( + at: i64, + range_ms: u64, + step_ms: u64, + offset_ms: i64, +) -> Result<(i64, i64, Vec), EngineError> { + let end = at + .checked_sub(offset_ms) + .ok_or_else(|| miss("offset overflow"))?; + let range = i64::try_from(range_ms).map_err(|_| miss("range overflow"))?; + let step = i64::try_from(step_ms).map_err(|_| miss("step overflow"))?; + if step <= 0 || range / step > 100_000 { + return Err(miss("invalid or excessive subquery steps")); + } + let start = end + .checked_sub(range) + .ok_or_else(|| miss("range overflow"))?; + let mut time = start + .div_euclid(step) + .checked_add(1) + .and_then(|n| n.checked_mul(step)) + .ok_or_else(|| miss("subquery grid overflow"))?; + let mut times = Vec::new(); + while time <= end { + times.push(time); + time = time + .checked_add(step) + .ok_or_else(|| miss("subquery time overflow"))?; + } + Ok((start, end, times)) +} +fn expanded_inputs(node: &QueryPlanNode, at: i64) -> Result, EngineError> { + match node { + QueryPlanNode::Logical { + operator: + ResidualQueryOperator::Scan { .. } + | ResidualQueryOperator::ExactSubquery { .. } + | ResidualQueryOperator::CandidateExactSubquery { .. }, + .. + } => Err(miss( + "installed leaf was not prepared; local raw execution is forbidden", + )), + QueryPlanNode::Logical { + operator: + ResidualQueryOperator::Subquery { + range_ms, + step_ms, + offset_ms, + }, + inputs, + } => { + let [input] = inputs.as_slice() else { + return Err(miss("subquery requires one input")); + }; + let (_, _, times) = subquery_grid(at, *range_ms, *step_ms, *offset_ms)?; + Ok(times.into_iter().map(|time| (*input, time)).collect()) + } + QueryPlanNode::Logical { + operator: ResidualQueryOperator::CurrentSeries { .. }, + .. + } => Ok(vec![]), + QueryPlanNode::Logical { inputs, .. } => Ok(inputs.iter().map(|&id| (id, at)).collect()), + QueryPlanNode::MembershipFilter { 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>, + _: physical::RunContext, + ) -> Result, physical::Error> { + Ok(futures::stream::once(async move { + let values = + futures::future::try_join_all(inputs.into_iter().map(|mut input| async move { + input.next().await.ok_or_else(|| { + physical::Error::Operator("query input produced no value".into()) + })? + })) + .await?; + let values = values.iter().map(|v| v.value().clone()).collect::>(); + self.runtime + .borrow_mut() + .execute_node(self.id, self.time, self.node, &values, &self.dependencies) + .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 membership_filter( candidates: Vector, values: Vector, @@ -1302,3 +1479,105 @@ mod topk_tests { .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]); + } + + // 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/post_asap_readout.rs b/data_plane/src/query_engines/asap_query_engine/post_asap_readout.rs index 4b40899f..50839238 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 asap_physical_operators::query_dag::{self, QueryNodeRuntime}; +use asap_physical_operators::dag::{self, PhysicalOperator}; +use futures::StreamExt; /// A planned warm query cannot be served; callers may route to an exact backend. #[derive(Debug)] pub enum LoweringSkip { @@ -115,16 +116,13 @@ struct PhysicalQueryRuntime<'a> { context: QueryExecutionContext<'a>, } -impl QueryNodeRuntime for PhysicalQueryRuntime<'_> { - type Output = PhysicalQueryOutput; - type Error = PhysicalNodeError; - +impl PhysicalQueryRuntime<'_> { fn execute_node( &self, _id: QueryNodeId, node: &QueryPlanNode, - inputs: &[Self::Output], - ) -> Result { + inputs: &[PhysicalQueryOutput], + ) -> Result { match node { QueryPlanNode::Scalar { value } => Ok(PhysicalQueryOutput::Scalar(*value)), QueryPlanNode::Binary { operator, .. } => { @@ -533,6 +531,129 @@ 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>, + _: dag::RunContext, + ) -> Result, dag::Error> { + Ok(futures::stream::once(async move { + let values = + futures::future::try_join_all(inputs.into_iter().map(|mut input| async move { + input.next().await.ok_or_else(|| { + dag::Error::Operator("query input produced no value".into()) + })? + })) + .await?; + let values = values + .iter() + .map(|value| value.value().clone()) + .collect::>(); + self.runtime + .execute_node(self.id, self.node, &values) + .map_err(|e| dag::Error::Operator(format!("query node {}: {e}", self.id.0))) + }) + .boxed_local()) + } +} +fn execute_bound_query( + entry: &asap_types::query_plan::QueryPlanEntry, + root: QueryNodeId, + runtime: &PhysicalQueryRuntime<'_>, + revision: u64, +) -> Result { + let order = 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 context = dag::RunContext::new( + dag::Scope::Query { + evaluation_time_ms: i64::try_from(runtime.context.t1_ms) + .map_err(|_| dag::Error::Invalid("query time exceeds i64".into()))?, + revision, + }, + dag::Limits::default(), + )?; + let mut root = graph.execute(&[root.0], context)?.remove(0); + futures::executor::block_on(async { + root.next() + .await + .ok_or_else(|| dag::Error::Operator("query root produced no value".into()))? + .map(|output| output.value().clone()) + }) +} + fn execute_physical_query_payload( index: &SketchStore, entry: &asap_types::query_plan::QueryPlanEntry, @@ -554,8 +675,10 @@ fn execute_physical_query_payload( allowed_materializations: None, }, }; - let output = query_dag::execute_from(entry, root, &runtime) - .map_err(|error| LoweringSkip::ExecuteFailed(format!("{error:?}")))?; + let output = execute_bound_query(entry, root, &runtime, revision.mutation_sequence()) + .map_err(|error| { + LoweringSkip::ExecuteFailed(format!("query {}: {error}", entry.query_id)) + })?; match output { PhysicalQueryOutput::Scalar(_) => Err(LoweringSkip::ExecuteFailed( "scalar-only query is not a warm vector result".into(), 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 f1a00352..8a8572f1 100644 --- a/data_plane/src/storage_engines/sketch_db/index/mod.rs +++ b/data_plane/src/storage_engines/sketch_db/index/mod.rs @@ -566,6 +566,10 @@ pub(crate) struct SummaryReadRevision { in_flight: usize, } impl SummaryReadRevision { + pub(crate) fn mutation_sequence(self) -> u64 { + self.mutation + } + fn capture( admission: u64, mutation: &std::sync::atomic::AtomicU64, diff --git a/docs/design_docs/query-dag-execution.md b/docs/design_docs/query-dag-execution.md index d1bbbd39..2fb39857 100644 --- a/docs/design_docs/query-dag-execution.md +++ b/docs/design_docs/query-dag-execution.md @@ -56,15 +56,16 @@ errors retain the query ID and node ID. ## Shared DAG execution -**Decision: ASAP will independently implement the shared DAG runtime and -physical operators. Both the precompute engine and the query engine will use -this library. DataFusion is a design reference, not the execution framework.** +**Decision: ASAP owns an independently implemented shared DAG runtime and +physical operators. Both engines use the same runtime. DataFusion is a design +reference, not the execution framework.** The shared library executes a physical operator DAG for both the precompute engine and the query engine. It is designed around that execution contract, -not around the current backend's function or module boundaries. The existing -accumulator library and backend-driven query traversal do not yet provide this -architecture. +not around the backend's function or module boundaries. The independent runtime +and native batch operators are implemented. Both installed query execution and +ingestion execution use this runtime; their existing storage and value adapters +are still being replaced by native batch operator bindings. ```mermaid flowchart TB @@ -77,10 +78,11 @@ flowchart TB Run --> Results[Query results] ``` -The compiler binds each operation to a concrete implementation and checks its -input and output types before accepting the plan. Both engines execute the -result through the same library. Neither engine supplies a second interpretation -of Filter, Project, Aggregate, SummaryMerge, Sort, or Limit. +The intended boundary is that the compiler binds each operation to a concrete +implementation and checks its input and output types before accepting the plan. +Both engines execute the result through the same library. Computation semantics +belong in that library; replacing the remaining backend adapters with native +operator bindings is still required to complete this boundary. An operation defines its computation, typed inputs and outputs, and requirements such as input ordering and grouping. An execution instance owns the changing @@ -124,8 +126,33 @@ and input window. Results from distinct requests or windows are never mixed. For streaming output, the runtime delivers the same produced batches to each consumer. Buffering is bounded and participates in the execution memory budget; a slow consumer cannot cause unlimited retention. Cancelling one consumer does -not stop a producer still needed by another. Cancelling the whole execution -releases its streams, intermediate results, and tasks. +not stop a producer still needed by another. Cancelling the whole execution wakes consumers; the next poll or dropping the +execution releases producer streams and queued results. Already delivered values +remain owned and accounted for until their consumers release them. + +### Implemented execution boundaries + +An execution runs on the caller's worker; the library does not create a thread +pool. Its streams may await deployment I/O, and every consumer of a shared +producer must be polled concurrently. It currently uses worker-local execution +state, so a running execution cannot migrate between threads. Separate runs have +separate producers, buffers and accumulators. + +Each producer has a configurable batch-buffer limit. Outputs and native blocking +state use a configurable estimated byte budget, including values retained by a +consumer after queue eviction. This is execution accounting, not a hard process +RSS limit: source-owned input, temporary allocation peaks and allocator overhead +are outside the guarantee. Sort, exact aggregation and semi-join currently +materialize their inputs and fail when the budget is exceeded; they do not spill. +Summary construction updates its accumulators incrementally. Plan depth is +limited to 128 to bound recursive stream polling. + +A native post-ASAP binding rejects unknown operations, unsupported expressions, +invalid parameters and incompatible schemas before starting sources. Deployments +explicitly bind storage or ingestion frontiers; those bindings do not authorize +a local raw Scan. The native binder covers a subset of Planner, and the installed +backend binder remains separate while its value adapters are migrated. Neither +binder may count external execution as native operator coverage. ## Shared physical operator library @@ -207,8 +234,8 @@ checks and must run through both engines' integration with the shared library. ## Physical operator coverage and acceptance contract Coverage describes this PR and the immutable Planner revision in `Cargo.toml`. -**The current code does not yet implement the shared DAG architecture or meet -the universal local-execution contract.** +**The shared DAG runtime and the native operator subset below are implemented. +The backend does not yet meet the universal local-execution contract.** An exhaustive phase match proves ownership only. A reusable kernel proves an algorithm implementation exists; neither proves that a concrete installed plan can obtain its inputs and execute every node locally. @@ -236,31 +263,33 @@ means an implemented path for the stated subset, not universal support. | Read materialization | Load previously computed state | State decoding kernels; no storage adapter | Catalog/store binding for compatible populations and windows | General raw input access; unavailable or incompatible state cannot be read | | Maintain current-series state | Update the maintained values and timestamps for incoming time series. | None | Specialized remote-write ingestion path | General table-row updates and shared implementation | | Read current-series state | Read values from the maintained time-series state for query execution. | None | Current-series readout using the installed state identity and capacity | Arbitrary raw-table reading | -| Scalar | Produce a scalar value | No separate scalar-source executor | Typed scalar path | General expression evaluation | -| Binary | Combine or compare two inputs | Float64 Add/Sub/Mul/Div/Mod/Pow/Atan2 kernels | Query arithmetic, CheckedDiv/FiniteDiv and comparisons; ingestion arithmetic on immutable completed, aligned rows | General coercion, arbitrary PromQL matching and unsupported value domains | -| Unary negate | Negate a value | No separate adapter | Typed query scalar/vector path | General ingestion adapter | -| Vector to scalar | Convert a vector to a scalar | No separate adapter | Typed query path | General ingestion adapter | -| Exact aggregate | Compute Count/Sum/Avg/Min/Max, including ReduceSum | Exact accumulator kernels | Relation and query aggregate adapters | No universal aggregate implementation; relation numeric measures require non-null Int64/Float64 and finite valid values; relation per-entity reduction and grouping-without unsupported | -| Finalize exact accumulator / ExactReadout | Obtain an exact result from typed state | Exact-family readout kernels | Typed query readout and ingestion finalization of immutable completed windows | Arbitrary state conversion and unsupported exact families | -| Project | Select or calculate output columns | No general expression executor | Query relation adapter | Unsupported expressions/types and general ingestion adapter | -| Filter | Keep rows satisfying a predicate | No general predicate executor | Query relation adapter | Unsupported predicates/types and general ingestion adapter | -| Relational join, including semi-join | Match rows by a predicate; semi-join retains matching left rows | Row membership kernel; general semi-join replacement pending; other joins remain backend-local | Relation adapter supports inner/left/right/full/cross/semi/anti joins within its predicate/schema subset; vector candidate pruning currently uses a dedicated membership adapter; general semi-join replacement pending | General ingestion join adapter and unrestricted SQL/NULL semantics | -| Sort | Order input rows or values | No general sorting adapter | Query relation and logical sorting | Relation partitioned sorting, NaN and unsupported key types; general ingestion adapter | -| Limit | Keep a bounded slice of input | No separate adapter | Query relation offset/limit and specialized logical lowering | General ingestion adapter; does not rank or match candidate keys | -| SummaryAgg | Construct summary state from input | Supported-family construction/update kernels | Raw-ingestion specialization and restricted row-to-state ingestion aggregation | Installed query-time builder; arbitrary item expressions/output populations; typed update evaluation required | -| SummaryMerge | Combine compatible summary states | Compatible-state merge kernels | Ingestion and query state merge | Universal cross-family merge is not supported | -| SummaryEstimate | Query a summary for an approximate result | Family-specific sketch query kernels | Typed stored-state readout | Unsupported family/readout combinations; window/population compatibility and accuracy evidence remain required | +| Scalar | Produce a scalar value | Typed native source, including nullable values | Installed scalar adapter on the shared runtime | Bind installed scalar nodes directly to the native batch source | +| Binary | Combine or compare two inputs | Native matching-type Int64/Float64 arithmetic expressions, checked integer arithmetic, comparisons and boolean expressions; Float64 arithmetic kernels | Query arithmetic, CheckedDiv/FiniteDiv and comparisons; ingestion arithmetic on immutable completed, aligned rows | General coercion, arbitrary PromQL matching and unsupported value domains | +| Unary negate | Negate a value | Native Int64/Float64 expression with null propagation and checked integer overflow | Typed query scalar/vector adapter | Connect installed value paths to the native expression binding | +| Vector to scalar | Convert a vector to a scalar | Native Float64 batch operator; zero or multiple rows produce NaN | Typed query adapter | Connect installed value paths to the native batch operator | +| Exact aggregate | Compute Count/Sum/Avg/Min/Max, including ReduceSum | Native grouped batches: checked Int64 Sum, Float64 Sum/Avg, Int64 Count, ordered Min/Max, nullable inputs; exact state kernels | Relation and query adapters | Connect installed adapters to native batches; no universal AggIntent, per-entity or unresolved grouping-without binding; blocking execution has no spill | +| Finalize exact accumulator / ExactReadout | Obtain an exact result from typed state | Native validated readout for the six supported exact families; Int64 Count and Float64 numeric results | Typed query readout and ingestion finalization | Native batch integration; arbitrary state conversion and other exact families | +| Project | Select or calculate output columns | Native typed expressions and batch projection; Planner plain scalar and collection values are preserved | Query relation adapter | Complete expression vocabulary and installed native batch binding | +| Filter | Keep rows satisfying a predicate | Native batch predicate evaluation; three-valued boolean logic, equality/less-than, null checks | Query relation adapter | Other predicates, coercions and installed native batch binding | +| Relational join, including semi-join | Match rows by a predicate; semi-join retains matching left rows | Native batch semi-join with explicit matching columns; value order and left multiplicity preserved; other joins remain backend-local | Relation adapter supports inner/left/right/full/cross/semi/anti joins within its predicate/schema subset; vector candidate pruning currently uses a dedicated membership adapter; general semi-join replacement pending | General ingestion join adapter and unrestricted SQL/NULL semantics | +| Sort | Order input rows or values | Native stable grouped sort with null placement; NaN follows numeric values | Query relation and logical adapters | Installed native batch binding; unsupported key types and spill-to-disk | +| Limit | Keep a bounded slice of input | Native offset/limit per group across batches; global Limit stops consuming after its slice | Query relation offset/limit and specialized logical lowering | Planner grouped-Limit transport and installed native batch binding; Limit does not rank or match candidate keys | +| Union | Combine input streams with the same schema | Native stream union; polls all inputs | Native Planner binding uses it for multi-input summary merge | General installed batch binding | +| SummaryAgg | Construct summary state from input | Native incremental grouped builder for exact Sum/Count/Min/Max/Rate/Increase, KLL, DDSketch and HLL; non-null Float64 updates, timestamped counters | Ingestion specialization and restricted row-to-state adapter | Installed native builder; Int64 updates, keyed updates and other families in the native batch interface | +| SummaryMerge | Combine compatible summary states | Native grouped state merge; multiple input streams compose through Union; family and parameters checked | Ingestion and query adapters | Installed native batch binding; cross-family conversion is not a merge | +| SummaryEstimate | Query a summary for an approximate result | Native KLL quantile, DDSketch quantile/count and HLL cardinality/count; parameters checked before execution | Typed stored-state readout | Other family/readout combinations in native batches; window/population compatibility and accuracy evidence remain required | | SummaryJoin | Combine summary inputs using summary join semantics | No registered kernel | No runtime dispatch | Concrete kernel and adapters | | SummarySubtract | Subtract summary state | No registered kernel | Unsupported in ingestion runtime | Concrete kernel and adapters | | SummaryDelete | Remove contributions from summary state | No registered kernel | No runtime dispatch | Concrete kernel and adapters | | Temporal computation | Compute Rate/Increase/Avg/Max/Min/Sum/Count over time | Relevant exact accumulator kernels, not a complete temporal adapter | Query paths over supported inputs | General input/state combinations and ingestion adapter | | Histogram quantile | Calculate a quantile from histogram buckets | No separate histogram adapter | Query path | General ingestion adapter | -| Subquery | Evaluate an expression over a time grid | Request-local caching of intermediate results; full shared DAG runtime pending | Query path with bounded grids and request-local caching by operation and evaluation time | Unbounded grids and general ingestion adapter | +| Subquery | Evaluate an expression over a time grid | Shared DAG execution and request-local caching of intermediate results | Backend expands each operation and evaluation time into a node in the shared runtime; bounded grids and explicit source frontiers | Shared time-grid construction and general ingestion adapter | | Extension | Execute an additional value operation | No general executor | Unsupported operations may route to explicit fallback | A concrete local implementation for each admitted extension | Grouped TopK is represented in the target plan as Sort followed by Limit within -each group. The current dedicated TopK plan node must be replaced, and Limit -needs an explicit grouping contract. A global Limit is not equivalent. An +each group. The native library supports this composition. The installed dedicated TopK +plan node must still be replaced, and Planner Limit needs an explicit grouping +contract. A global Limit is not equivalent. An optimized kernel may execute the composition without changing its meaning. Candidate completeness remains a condition on pruning, not on ranking. @@ -272,19 +301,21 @@ read. Their code names are `MaintainPopulation` for updates and `ReadPopulation` External computation (`ExternalExact`, `ExactSubquery`, `CandidateExactSubquery`) and fallback are routing choices, not local physical operation implementations. They do not fill any missing coverage in this table. -The shared DAG walker schedules operations and caches intermediate results within each request but still needs backend -adapters; importing the library alone does not provide a complete query engine. +The shared runtime owns dependency execution, bounded batch delivery and +request-local caching of intermediate results. Installed-plan adapters still +provide some computation semantics; the table identifies these migration gaps. +Importing the library does not provide backend sources or a complete query engine. **Deferred raw-data support:** this PR does not implement local raw Scan or claim complete local execution when only raw data is stored. External fallback -and the independent raw-input kernel tests do not satisfy that capability. +and independent DAG tests with supplied batches do not satisfy that capability. ### Candidate pruning is a composed subgraph The fused candidate-ranking operator is removed from Planner and QueryPlan. The target graph uses a general semi-join in place of the current dedicated `MembershipFilter` adapter. That code change is pending separately from this -documentation update. The graph contains these operations: +runtime implementation. The graph contains these operations: 1. Read membership keys from a summary. 2. Obtain authoritative values, optionally pushing the membership restriction @@ -293,9 +324,10 @@ documentation update. The graph contains these operations: multiplicity. Membership scores never replace authoritative values. 4. Sort authoritative values and apply Limit independently within each group. -The semi-join has no k, grouping or ranking behavior. The shared library currently provides `rows::membership_filter` and -`rows::grouped_topk`; the pending change replaces the former with a general -`rows::semi_join` kernel that other deployments can compose with ranking. A missing authoritative value fails a +The semi-join has no k, grouping or ranking behavior. The native DAG library +implements semi-join, grouped Sort and grouped Limit as composable operators. +Installed vector adapters still use specialized membership and ranking kernels; +replacing their plan representation and bindings remains separate work. A missing authoritative value fails a certified membership plan; best-effort pruning remains explicitly approximate. The pruning certificate stays on the semi-join. Exact reranking does not prove that omitted keys could not have won. Planner still rejects uncertified pruning @@ -308,6 +340,10 @@ installed plans are rejected; removed operators have no compatibility path. ### Summary-family and readout coverage +The native batch interface currently admits exact Sum/Count/Min/Max/Rate/Increase, +KLL, DDSketch and HLL states. The broader low-level factory inventory below does +not imply native DAG bindings for every listed family. + The Planner-family factory accepts only `PerSubpopulationInstance` grouping and matching family/parameter variants. The presence of a low-level accumulator does not automatically register a Planner binding. Supported kernels expose update, @@ -341,13 +377,13 @@ all subsequent readout combinations or certify approximation guarantees. | Precomputation mode | Definition | Current support / remaining gaps | | --- | --- | --- | -| No precomputation | The query starts from raw data and performs all required computation at query time. | General local raw input and query-time summary construction are not supported in installed plans; deferred from this PR. | +| No precomputation | The query starts from raw data and performs all required computation at query time. | Native DAGs can construct and query summaries from supplied batches. Installed plans still need native builder bindings; the local raw source is deferred from this PR. | | Partial precomputation | The query reuses previously computed results or states and performs the remaining computation at query time. Inputs may combine stored states, stored values, and raw data. | Supported stored-state and query-time operations can be combined. General plans requiring local raw input or query-time summary construction remain incomplete. | | Full precomputation | All data-dependent computation needed for the query result has been performed before the query arrives. Query execution retrieves the prepared result and formats the response. | Supported only where the prepared result matches the requested query and time scope and is available. Reading stored summaries followed by merging, estimation, aggregation or ranking is partial precomputation. | These definitions are independent of any particular algorithm. The KLL consumer tests are examples of constructing, merging, and querying state across different -precomputation boundaries. They demonstrate reusable kernel behavior, not +precomputation boundaries. They execute native operator DAGs as well as reusable kernels, not complete backend support for all three modes. In particular, a test that queries a prebuilt KLL state still performs estimation at query time; it does not demonstrate full precomputation of the query result. @@ -370,8 +406,11 @@ coverage or universal executability is claimed until these tests exist and pass. ### Evidence and verification limits -Shared-library tests cover kernels, a KLL three-boundary consumer, invalid KLL -parameters and native CountSketch dimensions. Backend tests cover supported DAG, +Shared-library tests cover native DAGs, shared producers, backpressure, cancellation, +resource accounting, isolated runs, typed expressions, grouped Sort/Limit, semi-join, +summary construction/merge/readout at both phases, source schema validation and +unsupported binding rejection. KLL examples cover all three state-input boundaries. +Kernel tests additionally cover invalid KLL parameters and native CountSketch dimensions. Backend tests cover supported DAG, maintenance, readout and relation paths. Passing these suites is not a proof that every Planner payload or parameter combination is locally executable. Inherited level-1 grouped-Sum/quantile-ratio failures and #759's strict local-execution gate diff --git a/docs/developer_docs/control-plane/physical-compiler.md b/docs/developer_docs/control-plane/physical-compiler.md index 656ec007..786b0641 100644 --- a/docs/developer_docs/control-plane/physical-compiler.md +++ b/docs/developer_docs/control-plane/physical-compiler.md @@ -205,7 +205,8 @@ for a query that cannot be executed end to end. Graph traversal is separate from node definitions and store semantics. Activation validates roots, edges, bindings, reachability, and cycles. -Execution uses the validated topological order and caches every operation result within the request, +The shared physical DAG runtime creates one producer per reachable node and +shares its outputs with bounded buffering within the request, so a shared node in a diamond DAG performs one store/operator execution. A typed node failure follows the entry's explicit fallback route. From 17de4a5188a02547c182f81b2dcbff7f83bf541e Mon Sep 17 00:00:00 2001 From: zz_y Date: Wed, 23 Sep 2026 19:08:26 +0000 Subject: [PATCH 22/26] Bind ingestion and query value computation to native DAG operators --- .../src/dag/batch_execution.rs | 131 +++++++ crates/asap-physical-operators/src/dag/mod.rs | 2 + .../asap-physical-operators/src/dag/values.rs | 17 +- .../precompute_engine/maintenance_runtime.rs | 323 +++++++++++++--- .../src/precompute_engine/subdag_scheduler.rs | 16 +- .../asap_query_engine/logical_dag.rs | 364 ++++++++++++++---- 6 files changed, 701 insertions(+), 152 deletions(-) create mode 100644 crates/asap-physical-operators/src/dag/batch_execution.rs diff --git a/crates/asap-physical-operators/src/dag/batch_execution.rs b/crates/asap-physical-operators/src/dag/batch_execution.rs new file mode 100644 index 00000000..9ae78d74 --- /dev/null +++ b/crates/asap-physical-operators/src/dag/batch_execution.rs @@ -0,0 +1,131 @@ +//! Execute a bounded in-memory batch through native operators. This is also the +//! bridge for deployments whose boundary values are not yet streaming batches. +use super::{operators::Operator, values::Batch, Error, PhysicalDag, RunContext}; +use futures::{FutureExt, StreamExt}; + +/// Every input is already in memory; the chain contains native operators only. +/// This deliberately does not enter a nested executor when called from a DAG +/// adapter. I/O belongs to source operators in the surrounding execution. +pub fn evaluate_batch( + input: Batch, + operators: Vec, + context: RunContext, +) -> Result, Error> { + let mut graph = PhysicalDag::default(); + graph.add( + 0, + vec![], + Operator::source(input.schema().clone(), vec![input])?, + )?; + let mut root = 0; + for operator in operators { + graph.add(root + 1, vec![root], operator)?; + root += 1; + } + evaluate_graph(graph, root, context) +} + +/// Evaluate a native in-memory source, including scalar sources, in the caller's scope. +pub fn evaluate_source(source: Operator, context: RunContext) -> Result, Error> { + let mut graph = PhysicalDag::default(); + graph.add(0, vec![], source)?; + evaluate_graph(graph, 0, context) +} + +fn evaluate_graph( + graph: PhysicalDag<'_, Batch, super::values::Schema>, + root: super::NodeId, + context: RunContext, +) -> Result, Error> { + let mut output = graph.execute(&[root], context)?.remove(0); + let mut batches = Vec::new(); + loop { + match output.next().now_or_never() { + Some(Some(Ok(batch))) => batches.push(batch.value().clone()), + Some(Some(Err(error))) => return Err(error), + Some(None) => return Ok(batches), + None => { + return Err(Error::Operator( + "in-memory native batch chain unexpectedly awaited I/O".into(), + )) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dag::{operators::Expression, values::Value, Limits, Scope}; + use planner_types::{ + post_asap::{SummaryFamilyType, SummaryField, SummarySchema}, + pre_asap::DataType, + }; + use std::sync::Arc; + + // Engine adapters can run the identical native chain from an outer executor. + #[test] + fn same_native_chain_inside_query_and_ingestion_execution() { + let schema = Arc::new(SummarySchema { + fields: vec![SummaryField { + name: "value".into(), + dtype: SummaryFamilyType::Plain(DataType::Float64), + nullable: false, + }], + time_index: None, + }); + for scope in [ + Scope::Query { + evaluation_time_ms: 20, + revision: 1, + }, + Scope::Ingestion { + window_start_ms: 10, + window_end_ms: 20, + revision: 1, + }, + ] { + let batch = Batch::try_new(schema.clone(), vec![vec![Value::Float64(7.)]]).unwrap(); + let negate = Operator::project( + schema.clone(), + vec![( + "value".into(), + Expression::Negate(Box::new(Expression::Column(0))), + )], + ) + .unwrap(); + let context = RunContext::new(scope, Limits::default()).unwrap(); + let result = futures::executor::block_on(async { + evaluate_batch(batch, vec![negate], context.clone()) + }) + .unwrap(); + assert!(matches!(result[0].rows()[0][0], Value::Float64(-7.))); + let source = Operator::scalar(Value::Float64(9.), DataType::Float64).unwrap(); + let scalar = evaluate_source(source, context).unwrap(); + assert!(matches!(scalar[0].rows()[0][0], Value::Float64(9.))); + } + } + + // A cancelled surrounding execution also prevents its native computation. + #[test] + fn cancellation_is_not_bypassed_by_in_memory_execution() { + let schema = Arc::new(SummarySchema { + fields: vec![], + time_index: None, + }); + let batch = Batch::try_new(schema, vec![vec![]]).unwrap(); + let context = RunContext::new( + Scope::Query { + evaluation_time_ms: 0, + revision: 0, + }, + Limits::default(), + ) + .unwrap(); + context.cancel(); + assert!(matches!( + evaluate_batch(batch, vec![], context), + Err(Error::Cancelled) + )); + } +} diff --git a/crates/asap-physical-operators/src/dag/mod.rs b/crates/asap-physical-operators/src/dag/mod.rs index b5a2e7b7..1c9ee9e8 100644 --- a/crates/asap-physical-operators/src/dag/mod.rs +++ b/crates/asap-physical-operators/src/dag/mod.rs @@ -513,3 +513,5 @@ pub mod values; mod tests; pub mod planner; + +pub mod batch_execution; diff --git a/crates/asap-physical-operators/src/dag/values.rs b/crates/asap-physical-operators/src/dag/values.rs index 5b4e043f..82b87314 100644 --- a/crates/asap-physical-operators/src/dag/values.rs +++ b/crates/asap-physical-operators/src/dag/values.rs @@ -263,10 +263,19 @@ fn validate_state(family: &SummaryFamilyType, state: &dyn AggregateCore) -> Resu use planner_types::post_asap::SketchParams; validate_family(family)?; let valid = match family { - SummaryFamilyType::ExactAggregate(..) => state - .as_any() - .downcast_ref::() - .is_some_and(|s| s.family() == family && !s.is_keyed()), + SummaryFamilyType::ExactAggregate(..) => { + state + .as_any() + .downcast_ref::() + .is_some_and(|s| s.family() == family && !s.is_keyed()) + || (matches!( + family, + SummaryFamilyType::ExactAggregate( + planner_types::post_asap::ExactKind::Sum, + planner_types::post_asap::ExactParams::Sum + ) + ) && state.as_any().is::()) + } SummaryFamilyType::Sketch(kind, _) => match kind.params() { SketchParams::Kll { k } => state .as_any() diff --git a/data_plane/src/precompute_engine/maintenance_runtime.rs b/data_plane/src/precompute_engine/maintenance_runtime.rs index 65e6ad0f..3d8a7074 100644 --- a/data_plane/src/precompute_engine/maintenance_runtime.rs +++ b/data_plane/src/precompute_engine/maintenance_runtime.rs @@ -8,6 +8,7 @@ use super::subdag_scheduler::{ use crate::storage_engines::types::{ AggregateCore, InstalledPrecomputePlanHandle, PrecomputedOutput, }; +use asap_physical_operators::dag::RunContext; use asap_types::executable_plan::{BackendExecutableBinding, BackendNodeBinding}; use planner_types::post_asap::{ExecutableDagNode, ExecutableOperatorPayload, PostAsapNodeId}; use sha2::{Digest, Sha256}; @@ -195,11 +196,12 @@ impl PrecomputeOperatorRegistry for OperatorAdapter<'_> { &self, node: &ExecutableDagNode, inputs: &[Arc], + context: RunContext, ) -> Result { match &node.payload { ExecutableOperatorPayload::SummaryMerge { timing: planner_types::post_asap::ExecutionTiming::IngestionTime, - } => merge_inputs(inputs), + } => merge_inputs(inputs, &context), ExecutableOperatorPayload::Binary { operator, timing: planner_types::post_asap::ExecutionTiming::IngestionTime, @@ -210,7 +212,7 @@ impl PrecomputeOperatorRegistry for OperatorAdapter<'_> { { return Err("maintenance binary requires immutable completed row inputs".into()); } - evaluate_aligned_binary(node, operator, inputs) + evaluate_aligned_binary(node, operator, inputs, &context) } ExecutableOperatorPayload::Value { @@ -223,7 +225,7 @@ impl PrecomputeOperatorRegistry for OperatorAdapter<'_> { .into(), ); } - finalize_exact(node, inputs) + finalize_exact(node, inputs, &context) } ExecutableOperatorPayload::SummaryAgg { family, @@ -288,12 +290,7 @@ impl PrecomputeOperatorRegistry for OperatorAdapter<'_> { "keyed maintenance updates require explicit row identity routing".into(), ); } - let mut updater = asap_physical_operators::factory::create_planner_accumulator( - family, input, grouping, - )?; - if updater.is_keyed() { - return Err("keyed maintenance accumulator requires an item expression".into()); - } + let _ = grouping; let output_groups = values .keys() .map(|group| { @@ -323,11 +320,39 @@ impl PrecomputeOperatorRegistry for OperatorAdapter<'_> { "maintenance sink requires one explicitly reduced output population".into(), ); } - for (timestamp_ms, value) in values.values().flatten() { - let weight = evaluate_weight(&input.weight, *value, name)?; - updater.validate_single_input(weight)?; - updater.update_single(weight, *timestamp_ms); - } + use asap_physical_operators::dag::{operators::Operator, values::Value}; + let schema = native_schema(vec![ + ( + "value", + planner_types::post_asap::SummaryFamilyType::Plain( + planner_types::pre_asap::DataType::Float64, + ), + ), + ( + "time", + planner_types::post_asap::SummaryFamilyType::Plain( + planner_types::pre_asap::DataType::Timestamp, + ), + ), + ]); + let rows = values + .values() + .flatten() + .map(|(time, value)| { + Ok(vec![ + Value::Float64(evaluate_weight(&input.weight, *value, name)?), + Value::Timestamp(*time), + ]) + }) + .collect::, String>>()?; + let builder = + Operator::summary_build(schema.clone(), family.clone(), 0, Some(1), vec![]) + .map_err(|e| e.to_string())?; + let mut result = native_rows(schema, rows, vec![builder], &context)?; + let Some(Value::Summary { state, .. }) = result.pop().and_then(|mut row| row.pop()) + else { + return Err("native summary builder did not return state".into()); + }; let timestamp = values .values() .flatten() @@ -337,7 +362,7 @@ impl PrecomputeOperatorRegistry for OperatorAdapter<'_> { Ok(MaintenanceValue::SummaryWindows { states: BTreeMap::from([( output_groups.into_iter().next().unwrap(), - vec![(timestamp, Arc::from(updater.into_accumulator()))].into(), + vec![(timestamp, state)].into(), )]), family: family.clone(), }) @@ -349,6 +374,92 @@ impl PrecomputeOperatorRegistry for OperatorAdapter<'_> { } } +// These functions translate deployment values into native batches. Window and +// catalog checks stay here; the library owns all computation on batch values. +fn native_schema( + fields: Vec<(&str, planner_types::post_asap::SummaryFamilyType)>, +) -> asap_physical_operators::dag::values::Schema { + Arc::new(planner_types::post_asap::SummarySchema { + fields: fields + .into_iter() + .map(|(name, dtype)| planner_types::post_asap::SummaryField { + name: name.into(), + dtype, + nullable: false, + }) + .collect(), + time_index: None, + }) +} +fn native_rows( + schema: asap_physical_operators::dag::values::Schema, + rows: Vec>, + operators: Vec, + context: &RunContext, +) -> Result>, String> { + use asap_physical_operators::dag::{batch_execution::evaluate_batch, values::Batch}; + let input = Batch::try_new(schema, rows).map_err(|e| e.to_string())?; + Ok(evaluate_batch(input, operators, context.clone()) + .map_err(|e| e.to_string())? + .into_iter() + .flat_map(|b| b.rows().to_vec()) + .collect()) +} +fn native_arithmetic( + op: &planner_types::pre_asap::ArithmeticOpKind, + inputs: Vec<(i64, f64, f64)>, + context: &RunContext, +) -> Result, String> { + use asap_physical_operators::dag::{ + operators::{Expression, Operator}, + values::Value, + }; + use planner_types::{post_asap::SummaryFamilyType, pre_asap::DataType}; + let schema = native_schema(vec![ + ("time", SummaryFamilyType::Plain(DataType::Timestamp)), + ("left", SummaryFamilyType::Plain(DataType::Float64)), + ("right", SummaryFamilyType::Plain(DataType::Float64)), + ]); + let project = Operator::project( + schema.clone(), + vec![ + ("time".into(), Expression::Column(0)), + ( + "value".into(), + Expression::Arithmetic { + op: op.clone(), + left: Box::new(Expression::Column(1)), + right: Box::new(Expression::Column(2)), + }, + ), + ], + ) + .map_err(|e| e.to_string())?; + let rows = native_rows( + schema, + inputs + .into_iter() + .map(|(time, left, right)| { + vec![ + Value::Timestamp(time), + Value::Float64(left), + Value::Float64(right), + ] + }) + .collect(), + vec![project], + context, + )?; + rows.into_iter() + .map(|row| match row.as_slice() { + [Value::Timestamp(time), Value::Float64(value)] if value.is_finite() => { + Ok((*time, *value)) + } + _ => Err("maintenance binary produced a non-finite update".into()), + }) + .collect() +} + fn validate_maintenance_grouping( target: &asap_types::PrecomputeMaterialization, source: &asap_types::PrecomputeMaterialization, @@ -441,6 +552,7 @@ fn evaluate_aligned_binary( node: &ExecutableDagNode, operator: &planner_types::post_asap::BinaryOperator, inputs: &[Arc], + context: &RunContext, ) -> Result { use planner_types::pre_asap::BinaryOpKind; let BinaryOpKind::Arithmetic(arithmetic) = &operator.kind else { @@ -501,14 +613,9 @@ fn evaluate_aligned_binary( let right = right_rows .get(×tamp) .ok_or("maintenance binary requires matching timestamp sets")?; - let value = asap_physical_operators::arithmetic::evaluate_float64_arithmetic( - arithmetic, left, *right, - ); - if !value.is_finite() { - return Err("maintenance binary produced a non-finite update".into()); - } - joined.push((timestamp, value)); + joined.push((timestamp, left, *right)); } + let joined = native_arithmetic(arithmetic, joined, context)?; values.insert(group.clone(), joined); } Ok(MaintenanceValue::Rows { @@ -521,6 +628,7 @@ fn evaluate_aligned_binary( fn finalize_exact( node: &ExecutableDagNode, inputs: &[Arc], + context: &RunContext, ) -> Result { use planner_types::post_asap::{ExactKind, SummaryFamilyType}; let [input] = inputs else { @@ -567,12 +675,39 @@ fn finalize_exact( let field = maintenance_float64_column(node)?; let mut values = PopulationRows::new(); for (group, states) in groups { + use asap_physical_operators::dag::{operators::Operator, values::Value}; + let schema = native_schema(vec![("state", family.clone())]); + let readout = Operator::readout(schema.clone(), 0, statistic, Default::default()) + .map_err(|e| e.to_string())?; + let rows = native_rows( + schema, + states + .iter() + .map(|(_, state)| { + vec![Value::Summary { + family: family.clone(), + state: Arc::clone(state), + }] + }) + .collect(), + vec![readout], + context, + )?; let rows = states .into_iter() - .map(|(timestamp, state)| { - let value = state - .query_statistic(statistic, &None, &std::collections::HashMap::new()) - .map_err(|error| error.to_string())?; + .zip(rows) + .map(|((timestamp, _), row)| { + let value = + match row.first() { + Some(Value::Float64(value)) => *value, + Some(Value::Int64(value)) if value.unsigned_abs() <= (1u64 << 53) => { + *value as f64 + } + _ => return Err( + "exact readout cannot be represented by the installed Float64 schema" + .to_string(), + ), + }; if !value.is_finite() { return Err("exact maintenance finalization produced a non-finite value".into()); } @@ -589,7 +724,10 @@ fn finalize_exact( }) } -fn merge_inputs(inputs: &[Arc]) -> Result { +fn merge_inputs( + inputs: &[Arc], + context: &RunContext, +) -> Result { let mut grouped = BTreeMap::, Option)>::new(); let mut expected_groups = None; let mut family = None; @@ -646,19 +784,36 @@ fn merge_inputs(inputs: &[Arc]) -> Result().map(|state| state.family().clone()) + }).or_else(|| first.as_any().is::().then_some( + planner_types::post_asap::SummaryFamilyType::ExactAggregate(planner_types::post_asap::ExactKind::Sum, planner_types::post_asap::ExactParams::Sum) + )).ok_or("summary merge requires a registered state family")?; + use asap_physical_operators::dag::{operators::Operator, values::Value}; + let schema = native_schema(vec![("state", state_family.clone())]); + let rows = std::iter::once(*first) + .chain(rest.iter().copied()) + .map(|state| { + vec![Value::Summary { + family: state_family.clone(), + state: Arc::clone(state), + }] + }) + .collect(); + let merge = + Operator::summary_merge(schema.clone(), 0, vec![]).map_err(|e| e.to_string())?; + let mut output = native_rows(schema, rows, vec![merge], context)?; + let Some(Value::Summary { state: merged, .. }) = output.pop().and_then(|mut row| row.pop()) + else { + return Err("native summary merge did not return state".into()); + }; let Some(timestamp) = timestamp else { return Ok(MaintenanceValue::Summary { - state: Arc::from(merged), + state: merged, family, }); }; - result.insert(group, vec![(timestamp, Arc::from(merged))].into()); + result.insert(group, vec![(timestamp, merged)].into()); } Ok(MaintenanceValue::SummaryWindows { states: result, @@ -2158,6 +2313,19 @@ pub(crate) fn affected_materializations( #[cfg(test)] mod tests { + fn test_context() -> asap_physical_operators::dag::RunContext { + use asap_physical_operators::dag::{Limits, RunContext, Scope}; + RunContext::new( + Scope::Ingestion { + window_start_ms: 0, + window_end_ms: 10_000, + revision: 1, + }, + Limits::default(), + ) + .unwrap() + } + use super::*; use asap_physical_operators::accumulators::SumAccumulator; use planner_types::post_asap::{ @@ -2364,7 +2532,11 @@ mod tests { .unwrap() .is_none()); let merged = adapter - .execute(&node(3), &[Arc::new(first), Arc::new(second)]) + .execute( + &node(3), + &[Arc::new(first), Arc::new(second)], + test_context(), + ) .unwrap(); assert_eq!( merged @@ -2487,7 +2659,7 @@ mod tests { ExactParams::Sum, )), }); - let row = adapter.execute(&read, &[source]).unwrap(); + let row = adapter.execute(&read, &[source], test_context()).unwrap(); let mut aggregate = node(3); aggregate.payload = ExecutableOperatorPayload::SummaryAgg { family: target_family, @@ -2499,7 +2671,9 @@ mod tests { reduction: Reduction::by(vec![]), grouping: GroupingStrategy::default(), }; - let result = adapter.execute(&aggregate, &[Arc::new(row)]).unwrap(); + let result = adapter + .execute(&aggregate, &[Arc::new(row)], test_context()) + .unwrap(); let mut kwargs = std::collections::HashMap::new(); kwargs.insert("quantile".into(), "0.5".into()); assert_eq!( @@ -3367,9 +3541,13 @@ mod tests { let left = rows(vec![(2_000, 7.0), (1_000, 5.0)]); let right = rows(vec![(1_000, 2.0), (2_000, 3.0)]); // Arrival order cannot exchange windows, and subtraction retains edge order. - let MaintenanceValue::Rows { values, .. } = - evaluate_aligned_binary(&operation, &operator, &[left.clone(), right.clone()]).unwrap() - else { + let MaintenanceValue::Rows { values, .. } = evaluate_aligned_binary( + &operation, + &operator, + &[left.clone(), right.clone()], + &test_context(), + ) + .unwrap() else { panic!("expected rows") }; assert_eq!(values[&BTreeMap::new()], vec![(1_000, 3.0), (2_000, 4.0)]); @@ -3396,6 +3574,7 @@ mod tests { &operation, &operator, &[grouped_left.clone(), grouped_right], + &test_context(), ) .unwrap() else { panic!("expected grouped rows") @@ -3410,7 +3589,8 @@ mod tests { &[ grouped_left, grouped(BTreeMap::from([(a, vec![(1_000, 2.0)])])) - ] + ], + &test_context() ) .is_err()); let binding = BackendExecutableBinding { @@ -3430,7 +3610,7 @@ mod tests { }; operation.output_state = planner_types::post_asap::ExecutionDataState::INGESTION_ROWS; assert!(frozen - .execute(&operation, &[left.clone(), right.clone()]) + .execute(&operation, &[left.clone(), right.clone()], test_context()) .is_ok()); let live = OperatorAdapter { binding: &binding, @@ -3441,14 +3621,14 @@ mod tests { configs: &[], }; assert!(live - .execute(&operation, &[left.clone(), right.clone()]) + .execute(&operation, &[left.clone(), right.clone()], test_context()) .is_err()); operation.payload = ExecutableOperatorPayload::Binary { operator: operator.clone(), timing: planner_types::post_asap::ExecutionTiming::QueryTime, }; assert!(frozen - .execute(&operation, &[left.clone(), right.clone()]) + .execute(&operation, &[left.clone(), right.clone()], test_context()) .is_err()); for invalid in [ @@ -3464,19 +3644,27 @@ mod tests { }), Arc::new(MaintenanceValue::summary(sum(2.0))), ] { - assert!( - evaluate_aligned_binary(&operation, &operator, &[left.clone(), invalid]).is_err() - ); + assert!(evaluate_aligned_binary( + &operation, + &operator, + &[left.clone(), invalid], + &test_context() + ) + .is_err()); } operator.kind = BinaryOpKind::Arithmetic(ArithmeticOpKind::Div); assert!(evaluate_aligned_binary( &operation, &operator, - &[left.clone(), rows(vec![(1_000, 0.0), (2_000, 3.0)])] + &[left.clone(), rows(vec![(1_000, 0.0), (2_000, 3.0)])], + &test_context() ) .is_err()); operation.output_schema.fields[1].dtype = SummaryFamilyType::Plain(DataType::Int64); - assert!(evaluate_aligned_binary(&operation, &operator, &[left, right]).is_err()); + assert!( + evaluate_aligned_binary(&operation, &operator, &[left, right], &test_context()) + .is_err() + ); } #[test] @@ -3497,7 +3685,7 @@ mod tests { )]), family, }); - assert!(merge_inputs(&[inputs.clone(), different_group]).is_err()); + assert!(merge_inputs(&[inputs.clone(), different_group], &test_context()).is_err()); let mut read = node(2); read.output_schema.fields = vec![SummaryField { name: "value".into(), @@ -3505,15 +3693,16 @@ mod tests { nullable: false, }]; let MaintenanceValue::Rows { values, .. } = - finalize_exact(&read, &[inputs.clone()]).unwrap() + finalize_exact(&read, &[inputs.clone()], &test_context()).unwrap() else { panic!("expected finalized rows") }; assert_eq!(values[&BTreeMap::new()], vec![(1_000, 2.0), (2_000, 7.0)]); // Merge is a semantic DAG operation, not an implicit batch optimization. // Finalizing after it emits exactly one value instead of two updates. - let merged = Arc::new(merge_inputs(&[inputs]).unwrap()); - let MaintenanceValue::Rows { values, .. } = finalize_exact(&read, &[merged]).unwrap() + let merged = Arc::new(merge_inputs(&[inputs], &test_context()).unwrap()); + let MaintenanceValue::Rows { values, .. } = + finalize_exact(&read, &[merged], &test_context()).unwrap() else { panic!("expected finalized row") }; @@ -3528,7 +3717,7 @@ mod tests { )), }); assert!( - matches!(finalize_exact(&read, &[integer_state]), Err(error) if error.contains("Float64")) + matches!(finalize_exact(&read, &[integer_state], &test_context()), Err(error) if error.contains("Float64")) ); } @@ -3555,7 +3744,7 @@ mod tests { ]; read.output_schema.time_index = Some(0); let MaintenanceValue::Rows { values, name, .. } = - finalize_exact(&read, &[Arc::clone(&input)]).unwrap() + finalize_exact(&read, &[Arc::clone(&input)], &test_context()).unwrap() else { panic!("expected typed rows") }; @@ -3583,7 +3772,7 @@ mod tests { copy.output_schema.fields[1].name = "ts".into(); malformed.push(copy); for malformed in malformed { - assert!(finalize_exact(&malformed, &[Arc::clone(&input)]).is_err()); + assert!(finalize_exact(&malformed, &[Arc::clone(&input)], &test_context()).is_err()); } let untimed = Arc::new(MaintenanceValue::Summary { state: sum(10.0), @@ -3592,7 +3781,7 @@ mod tests { ExactParams::Sum, )), }); - assert!(finalize_exact(&read, &[untimed]).is_err()); + assert!(finalize_exact(&read, &[untimed], &test_context()).is_err()); } #[test] @@ -3623,7 +3812,11 @@ mod tests { reduction: Reduction::by(vec![]), grouping: GroupingStrategy::default(), }; - let error = adapter.execute(&aggregate, &[Arc::new(MaintenanceValue::summary(sum(7.0)))]); + let error = adapter.execute( + &aggregate, + &[Arc::new(MaintenanceValue::summary(sum(7.0)))], + test_context(), + ); assert!(matches!(error, Err(reason) if reason.contains("typed update evaluator"))); } @@ -3684,8 +3877,10 @@ mod tests { name: "value".into(), timestamped: true, }; - assert!(matches!(adapter.execute(&aggregate, &[Arc::new(rows)]), - Err(error) if error.contains("one explicitly reduced output population"))); + assert!( + matches!(adapter.execute(&aggregate, &[Arc::new(rows)], test_context()), + Err(error) if error.contains("one explicitly reduced output population")) + ); } #[test] @@ -3750,8 +3945,10 @@ mod tests { name: "value".into(), timestamped: true, }; - assert!(matches!(adapter.execute(&aggregate, &[Arc::new(rows)]), - Err(error) if error.contains("positive representable domain"))); + assert!( + matches!(adapter.execute(&aggregate, &[Arc::new(rows)], test_context()), + Err(error) if error.contains("positive representable domain")) + ); } } diff --git a/data_plane/src/precompute_engine/subdag_scheduler.rs b/data_plane/src/precompute_engine/subdag_scheduler.rs index a35c25cc..831e6d2f 100644 --- a/data_plane/src/precompute_engine/subdag_scheduler.rs +++ b/data_plane/src/precompute_engine/subdag_scheduler.rs @@ -34,7 +34,12 @@ pub trait PrecomputeOperatorRegistry { fn output_bytes(&self, _value: &V) -> usize { std::mem::size_of::().max(1) } - fn execute(&self, node: &ExecutableDagNode, inputs: &[Arc]) -> Result; + fn execute( + &self, + node: &ExecutableDagNode, + inputs: &[Arc], + context: execution::RunContext, + ) -> Result; } /// Atomic persistence boundary. Implementations must return the already @@ -301,7 +306,7 @@ impl> fn start<'a>( &'a self, inputs: Vec>>, - _: execution::RunContext, + context: execution::RunContext, ) -> Result>, execution::Error> { Ok(futures::stream::once(async move { if let Some(source) = &self.source { @@ -319,7 +324,7 @@ impl> .map(|value| Arc::clone(value.value())) .collect::>(); self.registry - .execute(self.node, &values) + .execute(self.node, &values, context) .map(Arc::new) .map_err(|e| { *self.error.borrow_mut() = Some(e); @@ -424,6 +429,7 @@ mod tests { &self, node: &ExecutableDagNode, inputs: &[Arc], + _context: execution::RunContext, ) -> Result { *self.0.lock().unwrap().entry(node.id.0).or_default() += 1; Ok(node.id.0 + inputs.iter().map(|v| **v).sum::()) @@ -502,6 +508,7 @@ mod tests { &self, node: &ExecutableDagNode, inputs: &[Arc], + _context: execution::RunContext, ) -> Result { match node.id.0 { 0 => Ok(10), @@ -599,13 +606,14 @@ mod tests { &self, node: &ExecutableDagNode, inputs: &[Arc], + context: execution::RunContext, ) -> Result { assert_ne!( node.id, PostAsapNodeId(0), "absorbed source subtree must not execute" ); - self.0.execute(node, inputs) + self.0.execute(node, inputs, context) } } let mut raw = node(0); 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 d20efdc3..2e1bff1c 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 @@ -233,6 +233,7 @@ impl Result> ValueRuntim node: &QueryPlanNode, inputs: &[Value], dependencies: &[(QueryNodeId, i64)], + context: &physical::RunContext, ) -> Result { if let Some(leaf) = self.leaves.get(&(id, at)) { if leaf.remote { @@ -246,7 +247,7 @@ impl Result> ValueRuntim return Ok(value); } let value = match node.clone() { - QueryPlanNode::Scalar { value } => Value::Scalar(value), + QueryPlanNode::Scalar { value } => Value::Scalar(native_scalar(value, context)?), QueryPlanNode::Logical { operator: ResidualQueryOperator::CurrentSeries { .. }, .. @@ -268,7 +269,7 @@ impl Result> ValueRuntim "installed Prometheus leaf was not prepared; backend raw execution is forbidden", )); } - self.logical(operator, inputs, dependencies, at)? + self.logical(operator, inputs, dependencies, at, context)? } QueryPlanNode::MembershipFilter { completeness, .. } => { let [candidates, values] = inputs else { @@ -298,6 +299,7 @@ impl Result> ValueRuntim inputs: &[Value], dependencies: &[(QueryNodeId, i64)], at: i64, + context: &physical::RunContext, ) -> Result { let input = |index: usize| { inputs @@ -316,34 +318,22 @@ impl Result> ValueRuntim ResidualQueryOperator::Scan { .. } => { Err(miss("local raw Scan is forbidden in deployed plans")) } - ResidualQueryOperator::UnaryNegate => match input(0)? { - 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(input(0)?)?; - 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(input(0)?)?; - Ok(Value::Vector(aggregate(operation, &grouping, values))) + Ok(Value::Vector(aggregate( + operation, &grouping, values, context, + )?)) } ResidualQueryOperator::TopKSelection { k, grouping } => { let values = vector(input(0)?)?; - Ok(Value::Vector(topk_selection(k, &grouping, values))) + Ok(Value::Vector(topk_selection( + k, &grouping, values, context, + )?)) } ResidualQueryOperator::Binary { operation, @@ -414,23 +404,11 @@ impl Result> ValueRuntim .collect(), )) } - ResidualQueryOperator::Sort { descending } => { - let mut values = vector(input(0)?)?; - values.sort_by(|a, b| { - if a.1.is_nan() && b.1.is_nan() { - std::cmp::Ordering::Equal - } else if a.1.is_nan() { - std::cmp::Ordering::Greater - } else if b.1.is_nan() { - std::cmp::Ordering::Less - } else if descending { - b.1.total_cmp(&a.1) - } else { - a.1.total_cmp(&b.1) - } - }); - Ok(Value::Vector(values)) - } + ResidualQueryOperator::Sort { descending } => Ok(Value::Vector(sort_values( + vector(input(0)?)?, + descending, + context, + )?)), ResidualQueryOperator::HistogramQuantile => { let Value::Scalar(quantile) = input(0)? else { return Err(miss("quantile requires scalar")); @@ -571,7 +549,7 @@ impl Result> fn start<'a>( &'a self, inputs: Vec>, - _: physical::RunContext, + context: physical::RunContext, ) -> Result, physical::Error> { Ok(futures::stream::once(async move { let values = @@ -584,7 +562,14 @@ impl Result> let values = values.iter().map(|v| v.value().clone()).collect::>(); self.runtime .borrow_mut() - .execute_node(self.id, self.time, self.node, &values, &self.dependencies) + .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!( @@ -628,43 +613,222 @@ fn membership_filter( Ok((selected, warning)) } -fn aggregate(operation: Aggregation, grouping: &Grouping, values: Vector) -> Vector { - let mut groups: BTreeMap> = BTreeMap::new(); - for (labels, value) in values { - let key = labels - .into_iter() - .filter(|(key, _)| { - if grouping.without { - key != "__name__" && !grouping.labels.contains(key) - } else { - grouping.labels.contains(key) - } - }) - .collect(); - groups.entry(key).or_default().push(value); +fn native_scalar(value: f64, context: &physical::RunContext) -> Result { + use physical::{batch_execution::evaluate_source, operators::Operator, values::Value as Cell}; + let source = Operator::scalar( + Cell::Float64(value), + planner_types::pre_asap::DataType::Float64, + ) + .map_err(|e| miss(e.to_string()))?; + let batches = evaluate_source(source, context.clone()).map_err(|e| miss(e.to_string()))?; + match batches + .first() + .and_then(|b| b.rows().first()) + .and_then(|r| r.first()) + { + Some(Cell::Float64(value)) => Ok(*value), + _ => Err(miss("native scalar source returned invalid output")), } - groups +} + +fn native_labels(labels: &Labels) -> physical::values::Value { + physical::values::Value::Map( + labels + .iter() + .map(|(k, v)| { + ( + physical::values::Value::Utf8(k.as_str().into()), + physical::values::Value::Utf8(v.as_str().into()), + ) + }) + .collect::>() + .into(), + ) +} +fn native_vector_batch( + values: Vector, + grouping: &Grouping, +) -> Result { + use physical::values::{Batch, Value as Cell}; + use planner_types::{ + post_asap::{SummaryFamilyType, SummaryField, SummarySchema}, + pre_asap::DataType, + }; + let label_type = DataType::Map { + key: Box::new(DataType::Utf8), + value: Box::new(DataType::Utf8), + value_nullable: false, + }; + let schema = std::sync::Arc::new(SummarySchema { + fields: vec![ + ("labels", label_type.clone()), + ("group", label_type), + ("value", DataType::Float64), + ] .into_iter() - .map(|(labels, values)| { - let value = match operation { - Aggregation::Sum => values.iter().sum(), - Aggregation::Avg => values.iter().sum::() / values.len() as f64, - Aggregation::Count => values.len() as f64, - Aggregation::Max => { - values - .into_iter() - .fold(f64::NAN, |a, b| if a.is_nan() || b > a { b } else { a }) - } - Aggregation::Min => { - values - .into_iter() - .fold(f64::NAN, |a, b| if a.is_nan() || b < a { b } else { a }) + .map(|(name, dtype)| SummaryField { + name: name.into(), + dtype: SummaryFamilyType::Plain(dtype), + nullable: false, + }) + .collect(), + time_index: None, + }); + let rows = values + .into_iter() + .map(|(labels, value)| { + vec![ + native_labels(&labels), + native_labels(&grouping_key(&labels, grouping)), + Cell::Float64(value), + ] + }) + .collect(); + Batch::try_new(schema, rows).map_err(|e| miss(e.to_string())) +} +fn native_batch_rows( + batch: physical::values::Batch, + ops: Vec, + context: &physical::RunContext, +) -> Result>, EngineError> { + physical::batch_execution::evaluate_batch(batch, ops, context.clone()) + .map(|batches| { + batches + .into_iter() + .flat_map(|batch| batch.rows().to_vec()) + .collect() + }) + .map_err(|e| miss(e.to_string())) +} +fn native_vector_output( + rows: Vec>, + label_column: usize, + value_column: usize, +) -> Result { + use physical::values::Value as Cell; + rows.into_iter() + .map(|row| { + let Some(Cell::Map(entries)) = row.get(label_column) else { + return Err(miss("native operator returned invalid labels")); + }; + let labels = entries + .iter() + .map(|(k, v)| match (k, v) { + (Cell::Utf8(k), Cell::Utf8(v)) => Ok((k.to_string(), v.to_string())), + _ => Err(miss("native label map is not Utf8")), + }) + .collect::>()?; + let value = match row.get(value_column) { + Some(Cell::Float64(value)) => *value, + Some(Cell::Int64(value)) if value.unsigned_abs() <= (1u64 << 53) => *value as f64, + _ => { + return Err(miss( + "native result is not representable in the query Float64 protocol", + )) } }; - (labels, value) + Ok((labels, value)) }) .collect() } +fn aggregate( + operation: Aggregation, + grouping: &Grouping, + values: Vector, + context: &physical::RunContext, +) -> Result { + use physical::operators::{Operator, Reduction}; + let batch = native_vector_batch(values, grouping)?; + let reduction = match operation { + Aggregation::Sum => Reduction::Sum(2), + Aggregation::Avg => Reduction::Avg(2), + Aggregation::Count => Reduction::Count, + Aggregation::Max => Reduction::Max(2), + Aggregation::Min => Reduction::Min(2), + }; + let operator = Operator::aggregate( + batch.schema().clone(), + vec![1], + vec![("value".into(), reduction)], + ) + .map_err(|e| miss(e.to_string()))?; + native_vector_output(native_batch_rows(batch, vec![operator], context)?, 0, 1) +} +fn sort_values( + values: Vector, + descending: bool, + context: &physical::RunContext, +) -> Result { + use physical::operators::{Operator, SortKey}; + let batch = native_vector_batch( + values, + &Grouping { + labels: vec![], + without: false, + }, + )?; + let operator = Operator::sort( + batch.schema().clone(), + vec![SortKey { + column: 2, + descending, + nulls_first: false, + }], + vec![], + ) + .map_err(|e| miss(e.to_string()))?; + native_vector_output(native_batch_rows(batch, vec![operator], context)?, 0, 2) +} +fn negate(value: Value, context: &physical::RunContext) -> Result { + use physical::operators::{Expression, Operator}; + let scalar = matches!(value, Value::Scalar(_)); + let values = match value { + Value::Scalar(v) => vec![(Labels::new(), v)], + Value::Vector(v) => v, + _ => return Err(miss("cannot negate range vector")), + }; + let batch = native_vector_batch( + values, + &Grouping { + labels: vec![], + without: false, + }, + )?; + let operator = Operator::project( + batch.schema().clone(), + vec![ + ("labels".into(), Expression::Column(0)), + ( + "value".into(), + Expression::Negate(Box::new(Expression::Column(2))), + ), + ], + ) + .map_err(|e| miss(e.to_string()))?; + let result = native_vector_output(native_batch_rows(batch, vec![operator], context)?, 0, 1)?; + Ok(if scalar { + Value::Scalar(result[0].1) + } else { + Value::Vector(result) + }) +} +fn vector_to_scalar(values: Vector, context: &physical::RunContext) -> Result { + use physical::{operators::Operator, values::Value as Cell}; + let batch = native_vector_batch( + values, + &Grouping { + labels: vec![], + without: false, + }, + )?; + let operator = + Operator::vector_to_scalar(batch.schema().clone(), 2).map_err(|e| miss(e.to_string()))?; + let rows = native_batch_rows(batch, vec![operator], context)?; + match rows.first().and_then(|row| row.first()) { + Some(Cell::Float64(value)) => Ok(Value::Scalar(*value)), + _ => Err(miss("native scalar conversion returned invalid output")), + } +} fn grouping_key(labels: &Labels, grouping: &Grouping) -> Labels { labels @@ -683,13 +847,31 @@ fn grouping_key(labels: &Labels, grouping: &Grouping) -> Labels { /// Select by the child sample value while retaining every selected series' /// labels. NaN ranks below every numeric value, matching Prometheus' TOPK heap. /// Stable sorting also leaves equal-valued series in the child's order. -fn topk_selection(k: u64, grouping: &Grouping, values: Vector) -> Vector { - asap_physical_operators::rows::grouped_topk( - values, - usize::try_from(k).unwrap_or(usize::MAX), - |(labels, _)| grouping_key(labels, grouping), - |(_, value)| *value, +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], ) + .map_err(|e| miss(e.to_string()))?; + let limit = Operator::limit(sort.schema(), k, 0, vec![1]).map_err(|e| miss(e.to_string()))?; + let mut output = + native_vector_output(native_batch_rows(batch, vec![sort, limit], context)?, 0, 2)?; + // The HTTP adapter preserves canonical label-group presentation; native Sort + // already determined score order within each group. + output.sort_by_key(|(labels, _)| grouping_key(labels, grouping)); + Ok(output) } fn binary( @@ -921,6 +1103,18 @@ fn bucket_quantile(q: f64, mut b: Vec<(f64, f64)>) -> f64 { start + (end - start) * (rank - base) / (upper - base) } +#[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)] mod topk_tests { use super::*; @@ -1029,7 +1223,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); @@ -1053,7 +1249,9 @@ mod topk_tests { (labels(&[("series", "low")]), -1.0), (labels(&[("series", "high")]), 3.0), ], - ); + &test_native_context(), + ) + .unwrap(); let selected = topk_selection( 2, &Grouping { @@ -1061,7 +1259,9 @@ mod topk_tests { without: false, }, selected, - ); + &test_native_context(), + ) + .unwrap(); assert_eq!( selected .iter() @@ -1346,7 +1546,9 @@ mod topk_tests { without: false, }, selected, - ); + &test_native_context(), + ) + .unwrap(); assert_eq!( selected .iter() From 3a0f588d063833fda69fdf907141fa28ec41f2dd Mon Sep 17 00:00:00 2001 From: zz_y Date: Wed, 23 Sep 2026 19:13:38 +0000 Subject: [PATCH 23/26] Use native scalar sources and document engine operator bindings --- control_plane/Cargo.toml | 1 - .../src/dag/batch_execution.rs | 28 ++++++++++++++--- data_plane/src/drivers/query/servers/http.rs | 2 +- .../precompute_engine/maintenance_runtime.rs | 24 ++++++++++++++ .../asap_query_engine/logical_dag.rs | 27 +++++++++++++++- .../asap_query_engine/post_asap_readout.rs | 11 ++++--- docs/design_docs/query-dag-execution.md | 31 ++++++++++++------- 7 files changed, 100 insertions(+), 24 deletions(-) diff --git a/control_plane/Cargo.toml b/control_plane/Cargo.toml index 2f40eb4f..63b86609 100644 --- a/control_plane/Cargo.toml +++ b/control_plane/Cargo.toml @@ -12,7 +12,6 @@ name = "control_plane" path = "src/main.rs" [dependencies] -asap-physical-operators.workspace = true tokio = { version = "1", features = ["full"] } axum = { version = "0.7", features = ["ws"] } futures-util = "0.3" diff --git a/crates/asap-physical-operators/src/dag/batch_execution.rs b/crates/asap-physical-operators/src/dag/batch_execution.rs index 9ae78d74..cc43e7d4 100644 --- a/crates/asap-physical-operators/src/dag/batch_execution.rs +++ b/crates/asap-physical-operators/src/dag/batch_execution.rs @@ -44,11 +44,9 @@ fn evaluate_graph( Some(Some(Ok(batch))) => batches.push(batch.value().clone()), Some(Some(Err(error))) => return Err(error), Some(None) => return Ok(batches), - None => { - return Err(Error::Operator( - "in-memory native batch chain unexpectedly awaited I/O".into(), - )) - } + // Native operators have no I/O sources here. Pending is the + // shared runtime's cooperative yield after a batch quantum. + None => continue, } } } @@ -106,6 +104,26 @@ mod tests { } } + // Native sources may cross the runtime's cooperative batch quantum. + #[test] + fn in_memory_source_drives_cooperative_yields() { + let schema = Arc::new(SummarySchema { + fields: vec![], + time_index: None, + }); + let batch = Batch::try_new(schema.clone(), vec![vec![]]).unwrap(); + let source = Operator::source(schema, vec![batch; 65]).unwrap(); + let context = RunContext::new( + Scope::Query { + evaluation_time_ms: 0, + revision: 0, + }, + Limits::default(), + ) + .unwrap(); + assert_eq!(evaluate_source(source, context).unwrap().len(), 65); + } + // A cancelled surrounding execution also prevents its native computation. #[test] fn cancellation_is_not_bypassed_by_in_memory_execution() { diff --git a/data_plane/src/drivers/query/servers/http.rs b/data_plane/src/drivers/query/servers/http.rs index 1ad20b3d..e4b31355 100644 --- a/data_plane/src/drivers/query/servers/http.rs +++ b/data_plane/src/drivers/query/servers/http.rs @@ -6582,7 +6582,7 @@ mod catalog_install_tests { binding.window_ms += 1; let error = install(request).unwrap_err(); assert!( - error.contains("query physical pane duration differs"), + error.contains("query") && error.contains("pane") && error.contains("differs"), "{error}" ); } diff --git a/data_plane/src/precompute_engine/maintenance_runtime.rs b/data_plane/src/precompute_engine/maintenance_runtime.rs index 3d8a7074..e7beb24a 100644 --- a/data_plane/src/precompute_engine/maintenance_runtime.rs +++ b/data_plane/src/precompute_engine/maintenance_runtime.rs @@ -2435,6 +2435,30 @@ mod tests { .is_err()); } + // Ingestion adapters must execute native operators in the parent's scope. + #[test] + fn native_merge_uses_the_parent_budget_and_cancellation() { + let input = Arc::new(MaintenanceValue::summary(Arc::new( + SumAccumulator::with_sum(3.), + ))); + let context = test_context(); + let output = merge_inputs(&[Arc::clone(&input)], &context).unwrap(); + assert_eq!( + output + .state() + .unwrap() + .query_statistic(asap_types::Statistic::Sum, &None, &Default::default()) + .unwrap(), + 3. + ); + assert!(context.peak_bytes() > 0); + context.cancel(); + assert!(merge_inputs(&[input], &context) + .err() + .unwrap() + .contains("cancelled")); + } + fn node(id: u32) -> ExecutableDagNode { ExecutableDagNode { id: PostAsapNodeId(id), 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 2e1bff1c..3d3ec6bf 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 @@ -613,7 +613,10 @@ fn membership_filter( Ok((selected, warning)) } -fn native_scalar(value: f64, context: &physical::RunContext) -> Result { +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), @@ -1773,6 +1776,28 @@ mod shared_runtime_tests { 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() { 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 50839238..b276b58b 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 @@ -122,9 +122,12 @@ impl PhysicalQueryRuntime<'_> { _id: QueryNodeId, node: &QueryPlanNode, inputs: &[PhysicalQueryOutput], + context: &dag::RunContext, ) -> Result { match node { - QueryPlanNode::Scalar { value } => Ok(PhysicalQueryOutput::Scalar(*value)), + QueryPlanNode::Scalar { value } => super::logical_dag::native_scalar(*value, context) + .map(PhysicalQueryOutput::Scalar) + .map_err(|error| PhysicalNodeError::Fallback(error.to_string())), QueryPlanNode::Binary { operator, .. } => { let [lhs, rhs] = inputs else { return Err(PhysicalNodeError::ExpectedState); @@ -598,7 +601,7 @@ impl PhysicalOperator for BoundQueryOperator<'_, '_> { fn start<'a>( &'a self, inputs: Vec>, - _: dag::RunContext, + context: dag::RunContext, ) -> Result, dag::Error> { Ok(futures::stream::once(async move { let values = @@ -613,7 +616,7 @@ impl PhysicalOperator for BoundQueryOperator<'_, '_> { .map(|value| value.value().clone()) .collect::>(); self.runtime - .execute_node(self.id, self.node, &values) + .execute_node(self.id, self.node, &values, &context) .map_err(|e| dag::Error::Operator(format!("query node {}: {e}", self.id.0))) }) .boxed_local()) @@ -1582,7 +1585,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/docs/design_docs/query-dag-execution.md b/docs/design_docs/query-dag-execution.md index 2fb39857..f2e93060 100644 --- a/docs/design_docs/query-dag-execution.md +++ b/docs/design_docs/query-dag-execution.md @@ -154,6 +154,13 @@ a local raw Scan. The native binder covers a subset of Planner, and the installe backend binder remains separate while its value adapters are migrated. Neither binder may count external execution as native operator coverage. +The shared-library foundation is #770. #763 integrates precompute execution; +#765 integrates query execution. The [library design](physical-operators.md) +defines the common boundary and compares DataFusion reuse with independent +implementation. A storage adapter converts deployed values to native batches; +it does not reimplement the operation. Native batch chains use the surrounding +run's resource and cancellation context. + ## Shared physical operator library The library's unit of composition is an executable physical operator. Each @@ -263,20 +270,20 @@ means an implemented path for the stated subset, not universal support. | Read materialization | Load previously computed state | State decoding kernels; no storage adapter | Catalog/store binding for compatible populations and windows | General raw input access; unavailable or incompatible state cannot be read | | Maintain current-series state | Update the maintained values and timestamps for incoming time series. | None | Specialized remote-write ingestion path | General table-row updates and shared implementation | | Read current-series state | Read values from the maintained time-series state for query execution. | None | Current-series readout using the installed state identity and capacity | Arbitrary raw-table reading | -| Scalar | Produce a scalar value | Typed native source, including nullable values | Installed scalar adapter on the shared runtime | Bind installed scalar nodes directly to the native batch source | +| Scalar | Produce a scalar value | Typed native source, including nullable values | Both installed query evaluators use the native scalar source | General ingestion literal binding | | Binary | Combine or compare two inputs | Native matching-type Int64/Float64 arithmetic expressions, checked integer arithmetic, comparisons and boolean expressions; Float64 arithmetic kernels | Query arithmetic, CheckedDiv/FiniteDiv and comparisons; ingestion arithmetic on immutable completed, aligned rows | General coercion, arbitrary PromQL matching and unsupported value domains | -| Unary negate | Negate a value | Native Int64/Float64 expression with null propagation and checked integer overflow | Typed query scalar/vector adapter | Connect installed value paths to the native expression binding | -| Vector to scalar | Convert a vector to a scalar | Native Float64 batch operator; zero or multiple rows produce NaN | Typed query adapter | Connect installed value paths to the native batch operator | -| Exact aggregate | Compute Count/Sum/Avg/Min/Max, including ReduceSum | Native grouped batches: checked Int64 Sum, Float64 Sum/Avg, Int64 Count, ordered Min/Max, nullable inputs; exact state kernels | Relation and query adapters | Connect installed adapters to native batches; no universal AggIntent, per-entity or unresolved grouping-without binding; blocking execution has no spill | -| Finalize exact accumulator / ExactReadout | Obtain an exact result from typed state | Native validated readout for the six supported exact families; Int64 Count and Float64 numeric results | Typed query readout and ingestion finalization | Native batch integration; arbitrary state conversion and other exact families | +| Unary negate | Negate a value | Native Int64/Float64 expression with null propagation and checked integer overflow | Query scalar/vector values use native Project with Negate | General ingestion expression binding | +| Vector to scalar | Convert a vector to a scalar | Native Float64 batch operator; zero or multiple rows produce NaN | Query value adapter uses the native batch operator | General ingestion value binding | +| Exact aggregate | Compute Count/Sum/Avg/Min/Max, including ReduceSum | Native grouped batches: checked Int64 Sum, Float64 Sum/Avg, Int64 Count, ordered Min/Max, nullable inputs; exact state kernels | Query grouped aggregation uses the native batch operator; relation adapter remains separate | Native relation binding; no universal AggIntent, per-entity or unresolved grouping-without binding; blocking execution has no spill | +| Finalize exact accumulator / ExactReadout | Obtain an exact result from typed state | Native validated readout for the six supported exact families; Int64 Count and Float64 numeric results | Native ingestion finalization and typed stored-state query readout | Native query batch binding; arbitrary state conversion and other exact families | | Project | Select or calculate output columns | Native typed expressions and batch projection; Planner plain scalar and collection values are preserved | Query relation adapter | Complete expression vocabulary and installed native batch binding | | Filter | Keep rows satisfying a predicate | Native batch predicate evaluation; three-valued boolean logic, equality/less-than, null checks | Query relation adapter | Other predicates, coercions and installed native batch binding | | Relational join, including semi-join | Match rows by a predicate; semi-join retains matching left rows | Native batch semi-join with explicit matching columns; value order and left multiplicity preserved; other joins remain backend-local | Relation adapter supports inner/left/right/full/cross/semi/anti joins within its predicate/schema subset; vector candidate pruning currently uses a dedicated membership adapter; general semi-join replacement pending | General ingestion join adapter and unrestricted SQL/NULL semantics | -| Sort | Order input rows or values | Native stable grouped sort with null placement; NaN follows numeric values | Query relation and logical adapters | Installed native batch binding; unsupported key types and spill-to-disk | -| Limit | Keep a bounded slice of input | Native offset/limit per group across batches; global Limit stops consuming after its slice | Query relation offset/limit and specialized logical lowering | Planner grouped-Limit transport and installed native batch binding; Limit does not rank or match candidate keys | +| Sort | Order input rows or values | Native stable grouped sort with null placement; NaN follows numeric values | Query value sorting uses native Sort; relation adapter remains separate | Native relation binding; unsupported key types and spill-to-disk | +| Limit | Keep a bounded slice of input | Native offset/limit per group across batches; global Limit stops consuming after its slice | Query grouped ranking uses native Sort followed by grouped Limit; relation adapter remains separate | Planner grouped-Limit transport and native relation binding; Limit does not rank or match candidate keys | | Union | Combine input streams with the same schema | Native stream union; polls all inputs | Native Planner binding uses it for multi-input summary merge | General installed batch binding | -| SummaryAgg | Construct summary state from input | Native incremental grouped builder for exact Sum/Count/Min/Max/Rate/Increase, KLL, DDSketch and HLL; non-null Float64 updates, timestamped counters | Ingestion specialization and restricted row-to-state adapter | Installed native builder; Int64 updates, keyed updates and other families in the native batch interface | -| SummaryMerge | Combine compatible summary states | Native grouped state merge; multiple input streams compose through Union; family and parameters checked | Ingestion and query adapters | Installed native batch binding; cross-family conversion is not a merge | +| SummaryAgg | Construct summary state from input | Native incremental grouped builder for exact Sum/Count/Min/Max/Rate/Increase, KLL, DDSketch and HLL; non-null Float64 updates, timestamped counters | Completed-window ingestion DAG uses the native builder; raw ingestion uses shared per-window updaters | Query builder binding; Int64 updates, keyed updates and other families in the native batch interface | +| SummaryMerge | Combine compatible summary states | Native grouped state merge; multiple input streams compose through Union; family and parameters checked | Ingestion DAG uses native state merge; stored-query adapter remains separate | Native query batch binding; cross-family conversion is not a merge | | SummaryEstimate | Query a summary for an approximate result | Native KLL quantile, DDSketch quantile/count and HLL cardinality/count; parameters checked before execution | Typed stored-state readout | Other family/readout combinations in native batches; window/population compatibility and accuracy evidence remain required | | SummaryJoin | Combine summary inputs using summary join semantics | No registered kernel | No runtime dispatch | Concrete kernel and adapters | | SummarySubtract | Subtract summary state | No registered kernel | Unsupported in ingestion runtime | Concrete kernel and adapters | @@ -287,8 +294,8 @@ means an implemented path for the stated subset, not universal support. | Extension | Execute an additional value operation | No general executor | Unsupported operations may route to explicit fallback | A concrete local implementation for each admitted extension | Grouped TopK is represented in the target plan as Sort followed by Limit within -each group. The native library supports this composition. The installed dedicated TopK -plan node must still be replaced, and Planner Limit needs an explicit grouping +each group. The native library supports this composition. The query adapter already executes this composition. The installed TopK +plan representation must still be replaced, and Planner Limit needs an explicit grouping contract. A global Limit is not equivalent. An optimized kernel may execute the composition without changing its meaning. Candidate completeness remains a condition on pruning, not on ranking. @@ -326,7 +333,7 @@ runtime implementation. The graph contains these operations: The semi-join has no k, grouping or ranking behavior. The native DAG library implements semi-join, grouped Sort and grouped Limit as composable operators. -Installed vector adapters still use specialized membership and ranking kernels; +Installed vector adapters still use a dedicated membership binding and TopK plan representation; replacing their plan representation and bindings remains separate work. A missing authoritative value fails a certified membership plan; best-effort pruning remains explicitly approximate. The pruning certificate stays on the semi-join. Exact reranking does not prove From dc0b617a616f3e612a82d14e3e003b591cbf8ea9 Mon Sep 17 00:00:00 2001 From: zz_y Date: Wed, 23 Sep 2026 23:07:26 +0000 Subject: [PATCH 24/26] refactor: execute relation and temporal computations in Planner library --- Cargo.lock | 14 +- Cargo.toml | 12 +- control_plane/src/clickhouse.rs | 12 +- crates/asap_types/src/query_plan.rs | 97 ++ .../precompute_engine/maintenance_runtime.rs | 10 +- .../accelerator.rs | 7 +- .../asap_clickhouse_query_engine/execution.rs | 449 ++++-- .../relational_adapter.rs | 647 +-------- .../relational_adapter/aggregate.rs | 219 +-- .../relational_adapter/native.rs | 161 +++ .../asap_query_engine/logical_dag.rs | 505 ++++--- .../asap_query_engine/post_asap_readout.rs | 84 +- .../asap_query_engine/summary_executor.rs | 203 +-- .../src/storage_engines/sketch_db/data/mod.rs | 15 +- .../sketch_db/query/decoders.rs | 384 +---- .../sketch_db/query/delta_apply.rs | 1288 +---------------- docs/design_docs/query-dag-execution.md | 644 ++------- 17 files changed, 1172 insertions(+), 3579 deletions(-) create mode 100644 data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter/native.rs diff --git a/Cargo.lock b/Cargo.lock index 3a765ca5..fb1c6ad9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -364,7 +364,7 @@ dependencies = [ [[package]] name = "asap-aware-mapping" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=14fceed73f0ea35b5d5b275e4920f35a34233a9c#14fceed73f0ea35b5d5b275e4920f35a34233a9c" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=93a0fa2b60219c624fe74a0218ff4a76cd97ea78#93a0fa2b60219c624fe74a0218ff4a76cd97ea78" dependencies = [ "asap-types", "asap_sketchlib 0.3.0 (git+https://github.com/ProjectASAP/asap_sketchlib)", @@ -376,7 +376,7 @@ dependencies = [ [[package]] name = "asap-frontend-promql" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=14fceed73f0ea35b5d5b275e4920f35a34233a9c#14fceed73f0ea35b5d5b275e4920f35a34233a9c" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=93a0fa2b60219c624fe74a0218ff4a76cd97ea78#93a0fa2b60219c624fe74a0218ff4a76cd97ea78" dependencies = [ "asap-types", "promql-parser 0.10.0 (git+https://github.com/ProjectASAP/promql-parser?rev=9fede7eecca923c9882fe256484d00d37f8706cb)", @@ -385,7 +385,7 @@ dependencies = [ [[package]] name = "asap-frontend-sql" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=14fceed73f0ea35b5d5b275e4920f35a34233a9c#14fceed73f0ea35b5d5b275e4920f35a34233a9c" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=93a0fa2b60219c624fe74a0218ff4a76cd97ea78#93a0fa2b60219c624fe74a0218ff4a76cd97ea78" dependencies = [ "asap-sql-function-catalog", "asap-types", @@ -396,7 +396,7 @@ dependencies = [ [[package]] name = "asap-physical-operators" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=14fceed73f0ea35b5d5b275e4920f35a34233a9c#14fceed73f0ea35b5d5b275e4920f35a34233a9c" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=93a0fa2b60219c624fe74a0218ff4a76cd97ea78#93a0fa2b60219c624fe74a0218ff4a76cd97ea78" dependencies = [ "asap-types", "asap_sketch_codec", @@ -416,12 +416,12 @@ dependencies = [ [[package]] name = "asap-sql-function-catalog" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=14fceed73f0ea35b5d5b275e4920f35a34233a9c#14fceed73f0ea35b5d5b275e4920f35a34233a9c" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=93a0fa2b60219c624fe74a0218ff4a76cd97ea78#93a0fa2b60219c624fe74a0218ff4a76cd97ea78" [[package]] name = "asap-types" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=14fceed73f0ea35b5d5b275e4920f35a34233a9c#14fceed73f0ea35b5d5b275e4920f35a34233a9c" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=93a0fa2b60219c624fe74a0218ff4a76cd97ea78#93a0fa2b60219c624fe74a0218ff4a76cd97ea78" dependencies = [ "serde", "serde_json", @@ -442,7 +442,7 @@ dependencies = [ [[package]] name = "asap_sketch_codec" version = "0.1.0" -source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=14fceed73f0ea35b5d5b275e4920f35a34233a9c#14fceed73f0ea35b5d5b275e4920f35a34233a9c" +source = "git+https://github.com/ProjectASAP/ASAPPlanner?rev=93a0fa2b60219c624fe74a0218ff4a76cd97ea78#93a0fa2b60219c624fe74a0218ff4a76cd97ea78" dependencies = [ "asap_sketchlib 0.3.0 (git+https://github.com/ProjectASAP/asap_sketchlib?rev=026cd18c7b8c23ae6c46d4d683151ba562b8cd3a)", "prost", diff --git a/Cargo.toml b/Cargo.toml index a4fd489c..1eebd9cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,10 +14,10 @@ version = "0.1.0" [workspace.dependencies] # Keep Planner frontends, selection, and IR on the same immutable revision. # Alias upstream asap-types because this workspace also defines asap_types. -planner-types = { package = "asap-types", git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "14fceed73f0ea35b5d5b275e4920f35a34233a9c" } -asap-aware-mapping = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "14fceed73f0ea35b5d5b275e4920f35a34233a9c" } -asap-frontend-promql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "14fceed73f0ea35b5d5b275e4920f35a34233a9c" } -asap-frontend-sql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "14fceed73f0ea35b5d5b275e4920f35a34233a9c" } +planner-types = { package = "asap-types", git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "93a0fa2b60219c624fe74a0218ff4a76cd97ea78" } +asap-aware-mapping = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "93a0fa2b60219c624fe74a0218ff4a76cd97ea78" } +asap-frontend-promql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "93a0fa2b60219c624fe74a0218ff4a76cd97ea78" } +asap-frontend-sql = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "93a0fa2b60219c624fe74a0218ff4a76cd97ea78" } # Shared external deps (used by 2+ crates) serde = { version = "1.0", features = ["derive"] } @@ -37,8 +37,8 @@ arc-swap = "1.7" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } # Internal crates -asap-physical-operators = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "14fceed73f0ea35b5d5b275e4920f35a34233a9c" } -asap_sketch_codec = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "14fceed73f0ea35b5d5b275e4920f35a34233a9c" } +asap-physical-operators = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "93a0fa2b60219c624fe74a0218ff4a76cd97ea78" } +asap_sketch_codec = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "93a0fa2b60219c624fe74a0218ff4a76cd97ea78" } asap_types = { path = "crates/asap_types" } asap_otel_proto = { path = "crates/asap_otel_proto" } indexmap = { version = "2.0", features = ["serde"] } diff --git a/control_plane/src/clickhouse.rs b/control_plane/src/clickhouse.rs index 5b804fd6..54b19327 100644 --- a/control_plane/src/clickhouse.rs +++ b/control_plane/src/clickhouse.rs @@ -1453,14 +1453,10 @@ mod tests { ("telemetry".into(), timestamped("timestamp_ms", "value")), ( "divisors".into(), - Schema::with_time_index( - vec![ - Column::new("timestamp", DataType::Int64, false), - Column::new("divisor", DataType::Float64, false), - ], - 0, - vec![], - ), + Schema::new(vec![ + Column::new("timestamp", DataType::Int64, false), + Column::new("divisor", DataType::Float64, false), + ]), ), ]), accuracy: AccuracyTarget::Exact, diff --git a/crates/asap_types/src/query_plan.rs b/crates/asap_types/src/query_plan.rs index 7282719b..51204ce0 100644 --- a/crates/asap_types/src/query_plan.rs +++ b/crates/asap_types/src/query_plan.rs @@ -366,6 +366,7 @@ impl QueryPlanEntry { ))); } for (id, node) in &self.nodes { + validate_native_relation(*id, node)?; if let QueryPlanNode::Logical { operator, inputs } = node { operator.validate(inputs.len())?; } @@ -746,3 +747,99 @@ mod contract_tests { assert_send_sync::(); } } + +/// Bind portable relation semantics before an installed plan can access its sources. +fn validate_native_relation(id: QueryNodeId, node: &QueryPlanNode) -> Result<(), QueryPlanError> { + use planner_types::post_asap::{ + ExecutableDagNode, ExecutableOperatorPayload as Payload, ExecutionDataState, PostAsapNodeId, + }; + use std::sync::Arc; + let invalid = |error: String| QueryPlanError::Invalid(format!("query node {}: {error}", id.0)); + let (payload, inputs, output) = match node { + QueryPlanNode::Relational { + operation, + input_schema, + output_schema, + .. + } => ( + Payload::Value { + operation: serde_json::from_value(operation.clone()) + .map_err(|e| invalid(e.to_string()))?, + }, + vec![Arc::new(input_schema.clone())], + output_schema, + ), + QueryPlanNode::RelationalJoin { + join_kind, + pred, + left_schema, + right_schema, + output_schema, + pruning, + .. + } => ( + Payload::RelationalJoin { + join_kind: join_kind.clone(), + pred: serde_json::from_value(pred.clone()).map_err(|e| invalid(e.to_string()))?, + pruning: serde_json::from_value( + serde_json::to_value(pruning).map_err(|e| invalid(e.to_string()))?, + ) + .map_err(|e| invalid(e.to_string()))?, + }, + vec![ + Arc::new(left_schema.clone()), + Arc::new(right_schema.clone()), + ], + output_schema, + ), + _ => return Ok(()), + }; + let node = ExecutableDagNode { + id: PostAsapNodeId(0), + payload, + output_state: ExecutionDataState::QUERY_ROWS, + output_schema: output.clone(), + guarantee: None, + }; + asap_physical_operators::dag::planner::bind_node(&node, &inputs) + .map_err(|e| invalid(e.to_string()))?; + Ok(()) +} + +#[cfg(test)] +mod native_binding_tests { + use super::*; + use planner_types::{ + post_asap::{SummaryFamilyType, SummaryField, SummarySchema, ValueOperation}, + pre_asap::{DataType, Predicate, QueryExpr}, + }; + + // Unsupported expressions fail installation without evaluating any source. + #[test] + fn rejects_unimplemented_relation_predicate_before_execution() { + let schema = SummarySchema { + fields: vec![SummaryField { + name: "value".into(), + dtype: SummaryFamilyType::Plain(DataType::Float64), + nullable: false, + }], + time_index: None, + }; + let node = QueryPlanNode::Relational { + input: QueryNodeId(0), + operation: serde_json::to_value(ValueOperation::Filter { + pred: Predicate( + QueryExpr::FunctionCall { + name: "unimplemented_predicate".into(), + args: vec![], + } + .into(), + ), + }) + .unwrap(), + input_schema: schema.clone(), + output_schema: schema, + }; + assert!(validate_native_relation(QueryNodeId(1), &node).is_err()); + } +} diff --git a/data_plane/src/precompute_engine/maintenance_runtime.rs b/data_plane/src/precompute_engine/maintenance_runtime.rs index 69bfb8f8..59aa5ba6 100644 --- a/data_plane/src/precompute_engine/maintenance_runtime.rs +++ b/data_plane/src/precompute_engine/maintenance_runtime.rs @@ -403,7 +403,7 @@ fn native_rows( .collect()) } fn native_arithmetic( - op: &planner_types::pre_asap::ArithmeticOpKind, + op: &planner_types::post_asap::BinaryOperator, inputs: Vec<(i64, f64, f64)>, context: &RunContext, ) -> Result, String> { @@ -423,8 +423,8 @@ fn native_arithmetic( ("time".into(), Expression::Column(0)), ( "value".into(), - Expression::Arithmetic { - op: op.clone(), + Expression::Binary { + operator: op.clone(), left: Box::new(Expression::Column(1)), right: Box::new(Expression::Column(2)), }, @@ -552,7 +552,7 @@ fn evaluate_aligned_binary( context: &RunContext, ) -> Result { use planner_types::pre_asap::BinaryOpKind; - let BinaryOpKind::Arithmetic(arithmetic) = &operator.kind else { + let BinaryOpKind::Arithmetic(_) = &operator.kind else { return Err("maintenance binary currently requires arithmetic".into()); }; if operator.vector_match.is_some() { @@ -612,7 +612,7 @@ fn evaluate_aligned_binary( .ok_or("maintenance binary requires matching timestamp sets")?; joined.push((timestamp, left, *right)); } - let joined = native_arithmetic(arithmetic, joined, context)?; + let joined = native_arithmetic(operator, joined, context)?; values.insert(group.clone(), joined); } Ok(MaintenanceValue::Rows { 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 52bd5263..cc4a77ae 100644 --- a/data_plane/src/query_engines/asap_clickhouse_query_engine/accelerator.rs +++ b/data_plane/src/query_engines/asap_clickhouse_query_engine/accelerator.rs @@ -857,7 +857,12 @@ mod tests { "SELECT sum(value) FROM requests WHERE timestamp >= 2000 AND timestamp < 3000".into(); let uncovered = accelerator.execute(&request).await; assert!( - matches!(&uncovered, ClickHouseAccelerationOutcome::Fallback(ClickHouseAccelerationFallback::Execution(detail)) if detail.contains("NoCandidates")), + matches!( + &uncovered, + ClickHouseAccelerationOutcome::Fallback( + ClickHouseAccelerationFallback::IncompleteCoverage + ) + ), "{uncovered:?}" ); request.sql = diff --git a/data_plane/src/query_engines/asap_clickhouse_query_engine/execution.rs b/data_plane/src/query_engines/asap_clickhouse_query_engine/execution.rs index 98a6927e..fc30357d 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,29 +26,6 @@ pub enum ClickHouseDagFallback { ResultEncoding(String), } -fn apply_relational_operation( - operation: serde_json::Value, - output_schema: &planner_types::post_asap::SummarySchema, - relation: ClickHouseRelation, -) -> Result { - let adapter = ClickHouseRelationalAdapter; - if let Some(filter) = operation.get("Filter") { - let predicate = filter - .get("pred") - .cloned() - .ok_or_else(|| "published Filter lacks pred".to_owned()) - .and_then(|value| serde_json::from_value(value).map_err(|error| error.to_string()))?; - return adapter - .apply_filter(&predicate, relation) - .map_err(|error| error.to_string()); - } - let operation: ValueOperation = - serde_json::from_value(operation).map_err(|error| error.to_string())?; - adapter - .apply_operation(&operation, output_schema, relation) - .map_err(|error| error.to_string()) -} - struct RelationDagExecutor<'a> { index: &'a SketchStore, entry: &'a QueryPlanEntry, @@ -59,7 +35,6 @@ struct RelationDagExecutor<'a> { is_cumulative: bool, memo: BTreeMap, schemas: BTreeMap, - active: BTreeSet, #[cfg(test)] evaluations: BTreeMap, } @@ -81,21 +56,8 @@ impl RelationDagExecutor<'_> { if let Some(relation) = self.memo.get(&root) { return Ok(relation.clone()); } - if !self.active.insert(root) { - return Err(format!( - "query `{}` contains a cycle at relation node {}", - self.entry.query_id, root.0 - )); - } self.schemas.insert(root, expected_schema.clone()); - let result = self.execute_uncached(root, expected_schema); - self.active.remove(&root); - let relation = result.map_err(|error| { - format!( - "query `{}` relation node {} failed: {error}", - self.entry.query_id, root.0 - ) - })?; + let relation = self.execute_graph(root, expected_schema)?; self.record_evaluation(root); self.memo.insert(root, relation.clone()); Ok(relation) @@ -109,7 +71,190 @@ impl RelationDagExecutor<'_> { #[cfg(not(test))] fn record_evaluation(&mut self, _id: QueryNodeId) {} - fn execute_uncached( + 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; + } + schemas.insert(id, expected.clone()); + let (payload, inputs) = match self.entry.nodes.get(&id) { + Some(QueryPlanNode::Relational { + input, + operation, + input_schema, + output_schema, + }) => { + if output_schema != &expected { + return Err("relational output schema mismatch".into()); + } + let operation = + serde_json::from_value(operation.clone()).map_err(|e| e.to_string())?; + ( + Payload::Value { operation }, + vec![(*input, input_schema.clone())], + ) + } + Some(QueryPlanNode::RelationalJoin { + inputs, + join_kind, + pred, + left_schema, + right_schema, + output_schema, + pruning, + }) => { + if output_schema != &expected { + return Err("join output schema mismatch".into()); + } + if pruning.is_some() { + return Err( + "candidate pruning is not bound for this relation source".into() + ); + } + ( + Payload::RelationalJoin { + join_kind: join_kind.clone(), + pred: serde_json::from_value(pred.clone()) + .map_err(|e| e.to_string())?, + pruning: None, + }, + vec![ + (inputs[0], left_schema.clone()), + (inputs[1], right_schema.clone()), + ], + ) + } + Some(_) => { + sources.push(id); + continue; + } + None => return Err("missing relation node".into()), + }; + let node = ExecutableDagNode { + id: PostAsapNodeId( + u32::try_from(id.0).map_err(|_| "relation node ID exceeds Planner range")?, + ), + payload, + output_state: ExecutionDataState::QUERY_ROWS, + output_schema: expected, + guarantee: None, + }; + let op = dag::planner::bind_node( + &node, + &inputs + .iter() + .map(|(_, schema)| Arc::new(schema.clone())) + .collect::>(), + ) + .map_err(|e| e.to_string())?; + operations.insert( + id, + (inputs.iter().map(|(id, _)| id.0).collect::>(), op), + ); + pending.extend(inputs); + } + let mut graph = dag::PhysicalDag::default(); + for (id, (inputs, op)) in operations { + graph.add(id.0, inputs, op).map_err(|e| e.to_string())?; + } + // Empty placeholders validate DAG shape before deployment source access. + let mut validation = dag::PhysicalDag::default(); + for (id, schema) in &schemas { + let inputs = if sources.contains(id) { + vec![] + } else { + self.entry.nodes[id] + .inputs() + .iter() + .map(|id| id.0) + .collect() + }; + validation + .add( + id.0, + inputs.clone(), + SchemaCheck { + inputs: inputs + .iter() + .map(|id| Arc::new(schemas[&QueryNodeId(*id)].clone())) + .collect(), + output: Arc::new(schema.clone()), + }, + ) + .map_err(|e| e.to_string())?; + } + validation.validate(&[root.0]).map_err(|e| e.to_string())?; + let mut coverage = None; + let mut first = true; + for id in sources { + let relation = self.execute_source(id, &schemas[&id])?; + coverage = if first { + first = false; + relation.coverage + } else { + match (coverage, relation.coverage) { + (Some((a, b)), Some((c, d))) if a.max(c) <= b.min(d) => { + Some((a.max(c), b.min(d))) + } + _ => None, + } + }; + let batch = native::batch(&relation, &schemas[&id]).map_err(|e| e.to_string())?; + graph + .add( + id.0, + vec![], + Operator::source(batch.schema().clone(), vec![batch]) + .map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + } + let context = dag::RunContext::new( + dag::Scope::Query { + evaluation_time_ms: i64::try_from(self.t1_ms) + .map_err(|_| "evaluation time overflow")?, + revision: self.index.summary_update_revision().mutation_sequence(), + }, + dag::Limits::default(), + ) + .map_err(|e| e.to_string())?; + let mut output = graph + .execute(&[root.0], context) + .map_err(|e| e.to_string())? + .remove(0); + let mut batches = Vec::new(); + loop { + match output.next().now_or_never() { + Some(Some(Ok(batch))) => batches.push(batch), + Some(Some(Err(error))) => return Err(error.to_string()), + Some(None) => break, + None => continue, + } + } + native::relation(&batches, expected, coverage).map_err(|e| e.to_string()) + } + + fn execute_source( &mut self, root: QueryNodeId, expected_schema: &planner_types::post_asap::SummarySchema, @@ -136,39 +281,6 @@ impl RelationDagExecutor<'_> { .cloned() .ok_or_else(|| "published external exact leaf was not prepared".into()) } - Some(QueryPlanNode::Relational { - input, - operation, - input_schema, - output_schema, - }) => { - if output_schema != expected_schema { - return Err("relational node output schema differs from its parent edge".into()); - } - let input = self.execute(*input, input_schema)?; - apply_relational_operation(operation.clone(), output_schema, input) - } - Some(QueryPlanNode::RelationalJoin { - inputs, - join_kind, - pred, - left_schema, - right_schema, - output_schema, - pruning, - }) => { - if pruning.is_some() { return Err("candidate pruning is not bound for this relation source".into()); } - if output_schema != expected_schema { - return Err("join output schema differs from its parent edge".into()); - } - let left = self.execute(inputs[0], left_schema)?; - let right = self.execute(inputs[1], right_schema)?; - let pred = - serde_json::from_value(pred.clone()).map_err(|error| error.to_string())?; - ClickHouseRelationalAdapter - .apply_join(join_kind, &pred, output_schema, left, right) - .map_err(|error| error.to_string()) - } Some(_) => { let outcome = execute_query_plan_from_readout( self.index, @@ -289,7 +401,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 { .. } + ) ) }) }); @@ -329,7 +445,6 @@ fn execute_sql_dag_with_external_unfenced( is_cumulative, memo: BTreeMap::new(), schemas: BTreeMap::new(), - active: BTreeSet::new(), #[cfg(test)] evaluations: BTreeMap::new(), }) @@ -345,7 +460,7 @@ fn execute_sql_dag_with_external_unfenced( Err(error) => { return ClickHouseDagOutcome::Fallback(ClickHouseDagFallback::UnsupportedPlan( error, - )) + )); } }; let bindings = entry.materialization_bindings(); @@ -371,26 +486,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)); } @@ -427,67 +523,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())) @@ -511,6 +547,45 @@ fn validate_reachable(entry: &QueryPlanEntry, root: QueryNodeId) -> Result<(), S Ok(()) } +// Validate schemas and graph shape before touching deployment sources. +struct SchemaCheck { + inputs: Vec, + output: asap_physical_operators::dag::values::Schema, +} +impl + asap_physical_operators::dag::PhysicalOperator< + asap_physical_operators::dag::values::Batch, + asap_physical_operators::dag::values::Schema, + > for SchemaCheck +{ + fn name(&self) -> &str { + "SchemaCheck" + } + fn input_schemas(&self) -> Vec { + self.inputs.clone() + } + fn output_schema(&self) -> asap_physical_operators::dag::values::Schema { + self.output.clone() + } + fn output_bytes(&self, batch: &asap_physical_operators::dag::values::Batch) -> usize { + batch.bytes() + } + fn start<'a>( + &'a self, + _: Vec< + asap_physical_operators::dag::Input<'a, asap_physical_operators::dag::values::Batch>, + >, + _: asap_physical_operators::dag::RunContext, + ) -> Result< + asap_physical_operators::dag::OutputStream<'a, asap_physical_operators::dag::values::Batch>, + asap_physical_operators::dag::Error, + > { + Err(asap_physical_operators::dag::Error::Invalid( + "validation-only source".into(), + )) + } +} + #[cfg(test)] mod tests { use super::*; @@ -586,7 +661,6 @@ mod tests { is_cumulative: false, memo: BTreeMap::new(), schemas: BTreeMap::new(), - active: BTreeSet::new(), evaluations: BTreeMap::new(), }; @@ -601,6 +675,63 @@ mod tests { assert!(error.contains("inconsistent relation schemas")); } + // A SQL diamond binds one source to both join inputs in the shared DAG. + #[test] + fn relation_dag_executes_a_shared_source_join() { + use planner_types::pre_asap::{JoinKind, Predicate, QueryExpr, ScalarValue}; + let schema = relation_schema("x"); + let mut entry = external_entry(&schema); + let mut output = schema.clone(); + output.fields.push(schema.fields[0].clone()); + entry.nodes.insert( + QueryNodeId(1), + QueryPlanNode::RelationalJoin { + inputs: [QueryNodeId(0), QueryNodeId(0)], + join_kind: JoinKind::Cross, + pred: serde_json::to_value(Predicate::( + QueryExpr::Literal(ScalarValue::Boolean(true)).into(), + )) + .unwrap(), + left_schema: schema.clone(), + right_schema: schema.clone(), + output_schema: output.clone(), + pruning: None, + }, + ); + entry.root = QueryNodeId(1); + let relation = ClickHouseRelation::from_json_compact( + &schema, + br#"{"meta":[{"name":"x","type":"Int64"}],"data":[[1],[2]]}"#, + ) + .unwrap(); + let prepared = BTreeMap::from([(QueryNodeId(0), relation)]); + let index = SketchStore::new(); + let mut executor = RelationDagExecutor { + index: &index, + entry: &entry, + prepared: &prepared, + t0_ms: 0, + t1_ms: 1, + is_cumulative: false, + memo: BTreeMap::new(), + schemas: BTreeMap::new(), + evaluations: BTreeMap::new(), + }; + let result = executor + .execute(entry.root, &output) + .unwrap() + .into_result() + .unwrap(); + assert_eq!( + result + .batches + .iter() + .map(|batch| batch.num_rows()) + .sum::(), + 4 + ); + } + #[test] fn exact_accumulator_window_end_coverage_includes_its_pane_start() { assert!(complete_pane_coverage( diff --git a/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter.rs b/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter.rs index 1c71a2cd..a38ec246 100644 --- a/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter.rs +++ b/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter.rs @@ -1,9 +1,11 @@ //! ClickHouse row semantics for planner-owned relational wrappers. +#[cfg(test)] mod aggregate; mod collection; +pub(super) mod native; -use std::{cmp::Ordering, collections::BTreeMap, sync::Arc}; +use std::{collections::BTreeMap, sync::Arc}; use arrow::{ array::{ @@ -16,10 +18,12 @@ use arrow::{ use chrono::{DateTime, NaiveDateTime, TimeZone}; use planner_types::{ post_asap::{SummaryFamilyType, SummarySchema, ValueOperation}, - pre_asap::{ArithmeticOpKind, CompareOpKind, DataType, QueryExpr, ScalarValue, SortKey}, + pre_asap::DataType, }; use super::clickhouse_result_adapter::ClickHouseQueryResult; +#[cfg(test)] +use planner_types::pre_asap::{ArithmeticOpKind, CompareOpKind, QueryExpr, ScalarValue, SortKey}; #[derive(Debug, thiserror::Error, PartialEq)] pub enum ClickHouseRelationalError { @@ -379,164 +383,43 @@ impl ClickHouseRelationalAdapter { 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(); - let mut right_matched = vec![false; right.rows.len()]; - for left_row in &left.rows { - let mut left_matched = false; - for (right_index, right_row) in right.rows.iter().enumerate() { - let mut joined = Vec::with_capacity(left_row.len() + right_row.len()); - joined.extend(left_row.iter().cloned()); - joined.extend(right_row.iter().cloned()); - let matches = matches!(kind, planner_types::pre_asap::JoinKind::Cross) - || matches!(eval(&pred.0, &joined, &schema)?, Cell::Bool(true)); - if matches { - left_matched = true; - right_matched[right_index] = true; - match kind { - planner_types::pre_asap::JoinKind::Semi => { - rows.push(left_row.clone()); - break; - } - planner_types::pre_asap::JoinKind::Anti => break, - _ => rows.push(joined), - } - } - } - if !left_matched { - match kind { - planner_types::pre_asap::JoinKind::Left - | planner_types::pre_asap::JoinKind::Full => { - let mut joined = left_row.clone(); - joined.resize(left_row.len() + right.fields.len(), Cell::Null); - rows.push(joined); - } - planner_types::pre_asap::JoinKind::Anti => rows.push(left_row.clone()), - _ => {} - } - } - } - if matches!( - kind, - planner_types::pre_asap::JoinKind::Right | planner_types::pre_asap::JoinKind::Full - ) { - for (matched, right_row) in right_matched.into_iter().zip(&right.rows) { - if !matched { - let mut joined = vec![Cell::Null; left.fields.len()]; - joined.extend(right_row.iter().cloned()); - 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], + ) } } @@ -598,420 +481,54 @@ fn row_from_value( .collect() } -fn scalar_schema(fields: &[(String, DataType, bool)]) -> planner_types::pre_asap::Schema { - planner_types::pre_asap::Schema::new( - fields - .iter() - .map(|(name, dtype, nullable)| { - planner_types::pre_asap::Column::new(name.clone(), dtype.clone(), *nullable) - }) - .collect(), - ) -} - +#[cfg(test)] fn eval( expr: &QueryExpr, row: &[Cell], schema: &planner_types::pre_asap::Schema, ) -> Result { - match expr { - QueryExpr::Column(index) => { - row.get(*index) - .cloned() - .ok_or(ClickHouseRelationalError::ColumnOutOfRange( - *index, - row.len(), - )) - } - QueryExpr::Literal(value) => Ok(match value { - ScalarValue::Interval { .. } => { - return Err(ClickHouseRelationalError::Unsupported( - "interval literal".into(), - )) - } - ScalarValue::Int64(value) => Cell::Int64(*value), - ScalarValue::Float64(value) => Cell::Float64(*value), - ScalarValue::Utf8(value) => Cell::Utf8(value.clone()), - ScalarValue::Boolean(value) => Cell::Bool(*value), - ScalarValue::Null => Cell::Null, - }), - QueryExpr::Compare { left, op, right } => { - let left = eval(left, row, schema)?; - let right = eval(right, row, schema)?; - compare(op, left, right) - } - QueryExpr::Arithmetic { op, left, right } => { - arithmetic(op, eval(left, row, schema)?, eval(right, row, schema)?) - } - QueryExpr::FunctionCall { name, args } => { - use planner_types::pre_asap::scalar_signature::MapScalarFunction; - if name.eq_ignore_ascii_case("asap_struct_field") { - expr.scalar_type(schema) - .map_err(|error| ClickHouseRelationalError::Invalid(error.to_string()))?; - let DataType::Struct { fields } = args[0] - .scalar_type(schema) - .map_err(|error| ClickHouseRelationalError::Invalid(error.to_string()))? - .0 - else { - unreachable!() - }; - let offset = match &args[1] { - QueryExpr::Literal(ScalarValue::Int64(index)) => { - usize::try_from(index - 1).ok() - } - QueryExpr::Literal(ScalarValue::Utf8(name)) => { - fields.iter().position(|field| &field.name == name) - } - _ => None, - } - .ok_or_else(|| { - ClickHouseRelationalError::Invalid("struct field selector".into()) - })?; - let Cell::Struct(values) = eval(&args[0], row, schema)? else { - return Err(ClickHouseRelationalError::Invalid( - "struct field input".into(), - )); - }; - return values.get(offset).cloned().ok_or_else(|| { - ClickHouseRelationalError::Invalid("struct field value".into()) - }); - } - if name.eq_ignore_ascii_case("asap_element_access") { - let (output_type, _) = expr - .scalar_type(schema) - .map_err(|error| ClickHouseRelationalError::Invalid(error.to_string()))?; - if let DataType::List { element } = args[0] - .scalar_type(schema) - .map_err(|error| ClickHouseRelationalError::Invalid(error.to_string()))? - .0 - { - let Cell::List(values) = eval(&args[0], row, schema)? else { - return Err(ClickHouseRelationalError::Invalid( - "array access input".into(), - )); - }; - let index = match eval(&args[1], row, schema)? { - Cell::Null => return Ok(Cell::Null), - Cell::Int64(index) => index, - _ => { - return Err(ClickHouseRelationalError::Invalid( - "array access index".into(), - )) - } - }; - let offset = if index > 0 { - usize::try_from(index - 1).ok() - } else if index < 0 { - usize::try_from(index.unsigned_abs()) - .ok() - .and_then(|distance| values.len().checked_sub(distance)) - } else { - None - }; - return match offset.and_then(|offset| values.get(offset)) { - Some(value) => Ok(value.clone()), - None => default_collection_element(&output_type, element.nullable), - }; - } - } - let function = (if name.eq_ignore_ascii_case("asap_element_access") { - Some(MapScalarFunction::Access) - } else { - MapScalarFunction::from_name(name) - }) - .ok_or_else(|| { - ClickHouseRelationalError::Unsupported(format!("scalar function {name}")) - })?; - expr.scalar_type(schema) - .map_err(|error| ClickHouseRelationalError::Invalid(error.to_string()))?; - let values = args - .iter() - .map(|arg| eval(arg, row, schema)) - .collect::, _>>()?; - match function { - MapScalarFunction::Construct => { - let mut values = values.into_iter(); - let mut entries = Vec::new(); - while let Some(key) = values.next() { - if !matches!(key, Cell::Int64(_) | Cell::Utf8(_) | Cell::Bool(_)) { - return Err(ClickHouseRelationalError::Unsupported( - "map key value type".into(), - )); - } - entries.push(( - key, - values.next().ok_or_else(|| { - ClickHouseRelationalError::Invalid("odd map argument count".into()) - })?, - )); - } - Ok(Cell::Map(entries)) - } - MapScalarFunction::Concat => { - let mut entries = Vec::new(); - for value in values { - let Cell::Map(mut next) = value else { - return Err(ClickHouseRelationalError::Invalid( - "map concat argument".into(), - )); - }; - entries.append(&mut next); - } - Ok(Cell::Map(entries)) - } - MapScalarFunction::Access => { - let [Cell::Map(entries), key] = values.as_slice() else { - return Err(ClickHouseRelationalError::Invalid( - "map access arguments".into(), - )); - }; - if matches!(key, Cell::Null) { - return Ok(Cell::Null); - } - if !matches!(key, Cell::Int64(_) | Cell::Utf8(_) | Cell::Bool(_)) { - return Err(ClickHouseRelationalError::Unsupported( - "map lookup key type".into(), - )); - } - if let Some((_, value)) = entries.iter().find(|(candidate, _)| candidate == key) - { - return Ok(value.clone()); - } - let ( - DataType::Map { - value, - value_nullable, - .. - }, - _, - ) = args[0] - .scalar_type(schema) - .map_err(|error| ClickHouseRelationalError::Invalid(error.to_string()))? - else { - unreachable!() - }; - default_collection_element(&value, value_nullable) - } - } - } - other => Err(ClickHouseRelationalError::Unsupported(format!( - "scalar expression {other:?}" - ))), - } -} - -fn default_collection_element( - dtype: &DataType, - nullable: bool, -) -> Result { - if nullable { - return Ok(Cell::Null); - } - Ok(match dtype { - DataType::Interval | DataType::Date => { - return Err(ClickHouseRelationalError::Unsupported( - "temporal value transport".into(), - )) - } - DataType::Null => Cell::Null, - DataType::Int64 => Cell::Int64(0), - DataType::Float64 => Cell::Float64(0.0), - DataType::Utf8 => Cell::Utf8(String::new()), - DataType::Bool => Cell::Bool(false), - DataType::Map { .. } => Cell::Map(Vec::new()), - DataType::List { .. } => Cell::List(Arc::from([])), - DataType::Struct { fields } => Cell::Struct( - fields - .iter() - .map(|field| default_collection_element(&field.dtype, field.nullable)) - .collect::, _>>()? - .into(), - ), - _ => { - return Err(ClickHouseRelationalError::Unsupported( - "collection missing-element default type".into(), - )) - } - }) -} - -fn compare(op: &CompareOpKind, left: Cell, right: Cell) -> Result { - if matches!(left, Cell::Null) || matches!(right, Cell::Null) { - return Ok(Cell::Null); - } - let ordering = cell_cmp(&left, &right).ok_or_else(|| { - ClickHouseRelationalError::Invalid("comparison of incompatible values".into()) - })?; - let value = match op { - CompareOpKind::Eq => ordering == Ordering::Equal, - CompareOpKind::Ne => ordering != Ordering::Equal, - CompareOpKind::Lt => ordering == Ordering::Less, - CompareOpKind::Le => ordering != Ordering::Greater, - CompareOpKind::Gt => ordering == Ordering::Greater, - CompareOpKind::Ge => ordering != Ordering::Less, - _ => { - return Err(ClickHouseRelationalError::Unsupported(format!( - "comparison {op:?}" - ))) - } + let relation = ClickHouseRelation { + fields: schema + .columns + .iter() + .map(|c| (c.name.clone(), c.dtype.clone(), c.nullable)) + .collect(), + rows: vec![], + coverage: None, }; - Ok(Cell::Bool(value)) + let compiled = asap_physical_operators::dag::expressions::CompiledExpression::compile( + expr, + &Arc::new(native::schema(&relation)), + ) + .map_err(|error| ClickHouseRelationalError::Unsupported(error.to_string()))?; + let value = compiled + .evaluate(&row.iter().map(native::value).collect::>()) + .map_err(|error| ClickHouseRelationalError::Invalid(error.to_string()))?; + native::cell(&value) } - +#[cfg(test)] fn arithmetic( op: &ArithmeticOpKind, left: Cell, right: Cell, ) -> Result { - if matches!(left, Cell::Null) || matches!(right, Cell::Null) { - return Ok(Cell::Null); - } - if let (Cell::Int64(left), Cell::Int64(right)) = (&left, &right) { - let integer = match op { - ArithmeticOpKind::Add => Some(left.checked_add(*right)), - ArithmeticOpKind::Sub => Some(left.checked_sub(*right)), - ArithmeticOpKind::Mul => Some(left.checked_mul(*right)), - ArithmeticOpKind::Mod => Some(left.checked_rem(*right)), - _ => None, - }; - if let Some(value) = integer { - return value.map(Cell::Int64).ok_or_else(|| { - ClickHouseRelationalError::Invalid( - "integer arithmetic overflow or zero divisor".into(), - ) - }); - } - } - let (left, right) = match (left, right) { - (Cell::Int64(left), Cell::Int64(right)) => (left as f64, right as f64), - (Cell::Int64(left), Cell::Float64(right)) => (left as f64, right), - (Cell::Float64(left), Cell::Int64(right)) => (left, right as f64), - (Cell::Float64(left), Cell::Float64(right)) => (left, right), - _ => { - return Err(ClickHouseRelationalError::Invalid( - "arithmetic on non-numeric values".into(), - )) - } + let dtype = |value: &Cell| match value { + Cell::Int64(_) => DataType::Int64, + _ => DataType::Float64, }; - let value = match op { - ArithmeticOpKind::Add => left + right, - ArithmeticOpKind::Sub => left - right, - ArithmeticOpKind::Mul => left * right, - ArithmeticOpKind::Div if right != 0.0 => left / right, - ArithmeticOpKind::Mod if right != 0.0 => left % right, - ArithmeticOpKind::Pow => left.powf(right), - _ => { - return Err(ClickHouseRelationalError::Unsupported(format!( - "arithmetic {op:?}" - ))) - } - }; - Ok(Cell::Float64(value)) -} - -fn compare_sort_keys( - left: &[Cell], - right: &[Cell], - keys: &[SortKey], - schema: &planner_types::pre_asap::Schema, -) -> Ordering { - for key in keys { - let Ok(left) = eval(&key.expr, left, schema) else { - return Ordering::Equal; - }; - let Ok(right) = eval(&key.expr, right, schema) else { - return Ordering::Equal; - }; - let (ordering, order_depends_on_direction) = match (&left, &right) { - (Cell::Null, Cell::Null) => (Ordering::Equal, false), - (Cell::Null, _) => { - if key.nulls_first { - (Ordering::Less, false) - } else { - (Ordering::Greater, false) - } - } - (_, Cell::Null) => { - if key.nulls_first { - (Ordering::Greater, false) - } else { - (Ordering::Less, false) - } - } - _ => (cell_cmp(&left, &right).unwrap_or(Ordering::Equal), true), - }; - let ordering = if key.ascending || !order_depends_on_direction { - ordering - } else { - ordering.reverse() - }; - if ordering != Ordering::Equal { - return ordering; - } - } - Ordering::Equal -} - -fn contains_nan(value: &Cell) -> bool { - match value { - Cell::Float64(value) => value.is_nan(), - Cell::List(values) | Cell::Struct(values) => values.iter().any(contains_nan), - Cell::Map(entries) => entries - .iter() - .any(|(key, value)| contains_nan(key) || contains_nan(value)), - _ => false, - } -} - -fn integer_float_cmp(integer: i64, float: f64) -> Option { - if float.is_nan() { - return None; - } - // These bounds are powers of two, exactly representable as Float64. - if float >= 9_223_372_036_854_775_808.0 { - return Some(Ordering::Less); - } - if float < -9_223_372_036_854_775_808.0 { - return Some(Ordering::Greater); - } - let integral = float as i64; - match integer.cmp(&integral) { - Ordering::Equal => 0.0_f64.partial_cmp(&float.fract()), - other => Some(other), - } -} - -fn cell_cmp(left: &Cell, right: &Cell) -> Option { - match (left, right) { - (Cell::Int64(left), Cell::Int64(right)) => Some(left.cmp(right)), - (Cell::Float64(left), Cell::Float64(right)) => left.partial_cmp(right), - (Cell::Int64(left), Cell::Float64(right)) => integer_float_cmp(*left, *right), - (Cell::Float64(left), Cell::Int64(right)) => { - integer_float_cmp(*right, *left).map(Ordering::reverse) - } - (Cell::Utf8(left), Cell::Utf8(right)) => Some(left.cmp(right)), - (Cell::Bool(left), Cell::Bool(right)) => Some(left.cmp(right)), - (Cell::Timestamp(left), Cell::Timestamp(right)) => Some(left.cmp(right)), - (Cell::Map(left), Cell::Map(right)) => { - for ((left_key, left_value), (right_key, right_value)) in left.iter().zip(right) { - let order = cell_cmp(left_key, right_key)?; - if order != Ordering::Equal { - return Some(order); - } - let order = match (left_value, right_value) { - (Cell::Null, Cell::Null) => Ordering::Equal, - (Cell::Null, _) => Ordering::Greater, - (_, Cell::Null) => Ordering::Less, - _ => cell_cmp(left_value, right_value)?, - }; - if order != Ordering::Equal { - return Some(order); - } - } - Some(left.len().cmp(&right.len())) - } - _ => None, - } + let schema = planner_types::pre_asap::Schema::new(vec![ + planner_types::pre_asap::Column::new("left", dtype(&left), false), + planner_types::pre_asap::Column::new("right", dtype(&right), false), + ]); + eval( + &QueryExpr::Arithmetic { + op: op.clone(), + left: std::rc::Rc::new(QueryExpr::Column(0)), + right: std::rc::Rc::new(QueryExpr::Column(1)), + }, + &[left, right], + &schema, + ) } fn arrow_type(dtype: &DataType) -> ArrowDataType { @@ -1288,30 +805,6 @@ mod scalar_contract_tests { .is_err()); } - #[test] - fn mixed_comparison_preserves_integer_precision_and_boundaries() { - assert_eq!( - integer_float_cmp(9_007_199_254_740_993, 9_007_199_254_740_992.0), - Some(Ordering::Greater) - ); - assert_eq!( - integer_float_cmp(i64::MAX, 9_223_372_036_854_775_808.0), - Some(Ordering::Less) - ); - assert_eq!( - integer_float_cmp(i64::MIN, -9_223_372_036_854_775_808.0), - Some(Ordering::Equal) - ); - assert_eq!(integer_float_cmp(-1, -1.5), Some(Ordering::Greater)); - assert_eq!(integer_float_cmp(1, 1.5), Some(Ordering::Less)); - assert_eq!(integer_float_cmp(0, f64::INFINITY), Some(Ordering::Less)); - assert_eq!( - integer_float_cmp(0, f64::NEG_INFINITY), - Some(Ordering::Greater) - ); - assert_eq!(integer_float_cmp(0, f64::NAN), None); - } - #[test] fn integer_modulo_never_rounds_through_float() { assert_eq!( @@ -1690,7 +1183,10 @@ mod tests { fn unsupported_scalar_expression_fails_closed() { let row = vec![Cell::Float64(1.0)]; let error = eval( - &QueryExpr::BoolAnd(vec![]), + &QueryExpr::FunctionCall { + name: "unsupported_function".into(), + args: vec![], + }, &row, &planner_types::pre_asap::Schema::new(vec![]), ) @@ -1784,7 +1280,10 @@ mod tests { fields: fields_from_schema(&side_schema), coverage: Some((0, 10)), }; - let joined_schema = schema(&[("left", DataType::Int64), ("right", DataType::Int64)]); + 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, diff --git a/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter/aggregate.rs b/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter/aggregate.rs index b68077dd..49fc9301 100644 --- a/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter/aggregate.rs +++ b/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter/aggregate.rs @@ -1,178 +1,63 @@ -//! Typed query-time reductions over relational rows, independent of summary storage. +//! Integration tests bind relational reductions to Planner-owned operators. use super::*; use planner_types::pre_asap::{AggIntent, Predicate, Reduction}; - -fn unsupported(detail: &str) -> ClickHouseRelationalError { - ClickHouseRelationalError::Unsupported(detail.into()) -} - -fn group_cmp(left: &[Cell], right: &[Cell], keys: &[usize]) -> Ordering { - for key in keys { - let order = match (&left[*key], &right[*key]) { - (Cell::Null, Cell::Null) => Ordering::Equal, - (Cell::Null, _) => Ordering::Less, - (_, Cell::Null) => Ordering::Greater, - (left, right) => cell_cmp(left, right).expect("validated grouping cells"), - }; - if order != Ordering::Equal { - return order; - } - } - Ordering::Equal +fn apply( + reduction: &Reduction, + measures: &[AggIntent], + having: Option<&Predicate>, + output: &SummarySchema, + input: ClickHouseRelation, +) -> Result { + let groups = reduction.group_keys().map_or(0, |keys| keys.keys().len()); + ClickHouseRelationalAdapter.apply_operation( + &ValueOperation::Exact(planner_types::post_asap::ExactOperation::Aggregate { + reduction: reduction.clone(), + measures: measures.to_vec(), + having: having.cloned(), + output_names: output + .fields + .iter() + .skip(groups) + .map(|field| field.name.clone()) + .collect(), + }), + output, + input, + ) } - fn measure( intent: &AggIntent, rows: &[Vec], fields: &[(String, DataType, bool)], ) -> Result { - if matches!(intent, AggIntent::Count { .. }) { - return i64::try_from(rows.len()) - .map(Cell::Int64) - .map_err(|_| unsupported("row count exceeds Int64")); - } - let column = match intent { - AggIntent::Sum { col } - | AggIntent::Avg { col } - | AggIntent::Min { col } - | AggIntent::Max { col } => { - col.ok_or_else(|| unsupported("aggregate lacks input column"))? - } - _ => return Err(unsupported("relational aggregate intent")), - }; - let (_, dtype, nullable) = fields - .get(column) - .ok_or_else(|| unsupported("aggregate input column is out of range"))?; - // Nullable aggregate empty/default semantics need an explicit SQL policy. - if *nullable || !matches!(dtype, DataType::Int64 | DataType::Float64) { - return Err(unsupported("nullable or nonnumeric aggregate input")); - } - let values = rows.iter().map(|row| &row[column]).collect::>(); - if values.iter().any(|value| { - !matches!(value, Cell::Int64(_)) - && !matches!(value, Cell::Float64(value) if value.is_finite()) - }) { - return Err(unsupported("nonfinite or invalid aggregate value")); - } + let mut output = intent.output_column(&planner_types::pre_asap::Column::new( + fields[0].0.clone(), + fields[0].1.clone(), + fields[0].2, + )); if matches!(intent, AggIntent::Min { .. } | AggIntent::Max { .. }) { - let maximum = matches!(intent, AggIntent::Max { .. }); - return values - .into_iter() - .reduce(|left, right| { - let order = cell_cmp(left, right).expect("validated numeric aggregate"); - if (maximum && order.is_lt()) || (!maximum && order.is_gt()) { - right - } else { - left - } - }) - .cloned() - .ok_or_else(|| unsupported("empty min/max SQL default")); - } - if matches!(intent, AggIntent::Sum { .. }) && *dtype == DataType::Int64 { - let mut sum = 0i64; - for value in values { - let Cell::Int64(value) = value else { - return Err(unsupported("integer aggregate received noninteger")); - }; - sum = sum - .checked_add(*value) - .ok_or_else(|| unsupported("integer sum overflow"))?; - } - return Ok(Cell::Int64(sum)); - } - if matches!(intent, AggIntent::Avg { .. }) && *dtype == DataType::Int64 { - let mut sum = 0i128; - for value in values { - let Cell::Int64(value) = value else { - return Err(unsupported("integer average received noninteger")); - }; - sum = sum - .checked_add(i128::from(*value)) - .ok_or_else(|| unsupported("integer average sum overflow"))?; - } - let average = sum as f64 / rows.len() as f64; - return if average.is_finite() { - Ok(Cell::Float64(average)) - } else { - Err(unsupported("empty integer average")) - }; - } - let mut sum = 0.0; - for value in values { - sum += match value { - Cell::Int64(value) => *value as f64, - Cell::Float64(value) => *value, - _ => unreachable!("validated numeric aggregate"), - }; - } - if matches!(intent, AggIntent::Avg { .. }) { - sum /= rows.len() as f64; - } - if !sum.is_finite() { - return Err(unsupported("nonfinite aggregate output")); - } - Ok(Cell::Float64(sum)) -} - -pub(super) fn apply( - reduction: &Reduction, - measures: &[AggIntent], - having: Option<&Predicate>, - output: &SummarySchema, - mut input: ClickHouseRelation, -) -> Result { - let Reduction::Reduce(keys) = reduction else { - return Err(unsupported("per-entity relational reduction")); + output.nullable = true; + } + let output = SummarySchema { + fields: vec![planner_types::post_asap::SummaryField { + name: output.name, + dtype: SummaryFamilyType::Plain(output.dtype), + nullable: output.nullable, + }], + time_index: None, }; - if keys.is_without() { - return Err(unsupported("relational grouping without")); - } - let keys = keys.keys(); - for row in &input.rows { - for key in keys { - let value = row - .get(*key) - .ok_or_else(|| unsupported("group column out of range"))?; - if !matches!(value, Cell::Null) && cell_cmp(value, value).is_none() { - return Err(unsupported("unordered grouping value")); - } - } - } - input - .rows - .sort_by(|left, right| group_cmp(left, right, keys)); - let mut rows = Vec::new(); - let mut start = 0; - while start < input.rows.len() || (start == 0 && input.rows.is_empty() && keys.is_empty()) { - let mut end = (start + 1).min(input.rows.len()); - while end < input.rows.len() - && group_cmp(&input.rows[start], &input.rows[end], keys) == Ordering::Equal - { - end += 1; - } - let mut row = keys - .iter() - .map(|key| input.rows[start][*key].clone()) - .collect::>(); - for intent in measures { - row.push(measure(intent, &input.rows[start..end], &input.fields)?); - } - rows.push(row); - if end == start { - break; - } - start = end; - } - input.rows = rows; - input.fields = fields_from_schema(output); - if input.rows.iter().any(|row| row.len() != input.fields.len()) { - return Err(unsupported("aggregate output width mismatch")); - } - if let Some(predicate) = having { - input = ClickHouseRelationalAdapter.apply_filter(predicate, input)?; - } - Ok(input) + let result = apply( + &Reduction::by(vec![]), + &[intent.clone()], + None, + &output, + ClickHouseRelation { + rows: rows.to_vec(), + fields: fields.to_vec(), + coverage: None, + }, + )?; + Ok(result.rows[0][0].clone()) } #[cfg(test)] @@ -268,7 +153,7 @@ mod tests { } #[test] - fn numeric_reductions_and_nullable_rejection_are_explicit() { + fn numeric_reductions_preserve_nullable_planner_inputs() { let fields = vec![("value".into(), DataType::Float64, false)]; let rows = vec![vec![Cell::Float64(2.0)], vec![Cell::Float64(8.0)]]; for (intent, expected) in [ @@ -286,6 +171,6 @@ mod tests { assert_eq!(measure(&intent, &rows, &fields).unwrap(), expected); } let nullable = vec![("value".into(), DataType::Float64, true)]; - assert!(measure(&AggIntent::Sum { col: Some(0) }, &rows, &nullable).is_err()); + assert!(measure(&AggIntent::Sum { col: Some(0) }, &rows, &nullable).is_ok()); } } diff --git a/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter/native.rs b/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter/native.rs new file mode 100644 index 00000000..6ba366b9 --- /dev/null +++ b/data_plane/src/query_engines/asap_clickhouse_query_engine/relational_adapter/native.rs @@ -0,0 +1,161 @@ +//! JSON/Arrow transport stays in the deployment; Planner executes relation values. +use super::{fields_from_schema, Cell, ClickHouseRelation, ClickHouseRelationalError}; +use asap_physical_operators::dag::{ + self, + values::{Batch, Value}, +}; +use planner_types::post_asap::{ + ExecutableDagNode, ExecutableOperatorPayload, ExecutionDataState, PostAsapNodeId, + SummaryFamilyType, SummaryField, SummarySchema, +}; +use std::sync::Arc; +fn error(error: impl std::fmt::Display) -> ClickHouseRelationalError { + ClickHouseRelationalError::Invalid(error.to_string()) +} +pub(crate) fn schema(input: &ClickHouseRelation) -> SummarySchema { + SummarySchema { + fields: input + .fields + .iter() + .map(|(name, dtype, nullable)| SummaryField { + name: name.clone(), + dtype: SummaryFamilyType::Plain(dtype.clone()), + nullable: *nullable, + }) + .collect(), + time_index: None, + } +} +pub(super) fn value(cell: &Cell) -> Value { + match cell { + Cell::Null => Value::Null, + Cell::Bool(v) => Value::Bool(*v), + Cell::Int64(v) => Value::Int64(*v), + Cell::Float64(v) => Value::Float64(*v), + Cell::Utf8(v) => Value::Utf8(v.clone().into()), + Cell::Timestamp(v) => Value::Timestamp(*v), + Cell::List(v) => Value::List(v.iter().map(value).collect::>().into()), + Cell::Struct(v) => Value::Struct(v.iter().map(value).collect::>().into()), + Cell::Map(v) => Value::Map( + v.iter() + .map(|(k, v)| (value(k), value(v))) + .collect::>() + .into(), + ), + } +} +pub(super) fn cell(value: &Value) -> Result { + Ok(match value { + Value::Null => Cell::Null, + Value::Bool(v) => Cell::Bool(*v), + Value::Int64(v) => Cell::Int64(*v), + Value::Float64(v) => Cell::Float64(*v), + Value::Utf8(v) => Cell::Utf8(v.to_string()), + Value::Timestamp(v) => Cell::Timestamp(*v), + Value::List(v) => Cell::List(v.iter().map(cell).collect::, _>>()?.into()), + Value::Struct(v) => Cell::Struct(v.iter().map(cell).collect::, _>>()?.into()), + Value::Map(v) => Cell::Map( + v.iter() + .map(|(k, v)| Ok((cell(k)?, cell(v)?))) + .collect::, ClickHouseRelationalError>>()?, + ), + _ => return Err(error("native value has no ClickHouse result transport")), + }) +} +pub(crate) fn execute( + payload: ExecutableOperatorPayload, + output: &SummarySchema, + inputs: Vec, +) -> Result { + let coverage = inputs + .iter() + .map(|input| input.coverage) + .reduce(|left, right| match (left, right) { + (Some((a, b)), Some((c, d))) if a.max(c) <= b.min(d) => Some((a.max(c), b.min(d))), + _ => None, + }) + .flatten(); + let batches = inputs + .iter() + .map(|input| { + Batch::try_new( + Arc::new(schema(input)), + input + .rows + .iter() + .map(|row| row.iter().map(value).collect()) + .collect(), + ) + }) + .collect::, _>>() + .map_err(error)?; + let node = ExecutableDagNode { + id: PostAsapNodeId(0), + payload, + output_state: ExecutionDataState::QUERY_ROWS, + output_schema: output.clone(), + guarantee: None, + }; + let operator = dag::planner::bind_node( + &node, + &batches + .iter() + .map(|batch| batch.schema().clone()) + .collect::>(), + ) + .map_err(|error| ClickHouseRelationalError::Unsupported(error.to_string()))?; + let context = dag::RunContext::new( + dag::Scope::Query { + evaluation_time_ms: coverage.map_or(0, |(_, end)| end as i64), + revision: 0, + }, + dag::Limits::default(), + ) + .map_err(error)?; + let batches = + dag::batch_execution::evaluate_inputs(batches, operator, context).map_err(error)?; + let rows = batches + .iter() + .flat_map(|batch| batch.rows()) + .map(|row| row.iter().map(cell).collect()) + .collect::, _>>()?; + Ok(ClickHouseRelation { + rows, + fields: fields_from_schema(output), + coverage, + }) +} + +pub(crate) fn batch( + input: &ClickHouseRelation, + expected: &SummarySchema, +) -> Result { + if schema(input).fields != expected.fields { + return Err(dag::Error::Invalid( + "source relation differs from its declared schema".into(), + )); + } + Batch::try_new( + Arc::new(expected.clone()), + input + .rows + .iter() + .map(|row| row.iter().map(value).collect()) + .collect(), + ) +} +pub(crate) fn relation( + batches: &[dag::SharedValue], + output: &SummarySchema, + coverage: Option<(u64, u64)>, +) -> Result { + Ok(ClickHouseRelation { + fields: fields_from_schema(output), + rows: batches + .iter() + .flat_map(|batch| batch.rows()) + .map(|row| row.iter().map(cell).collect()) + .collect::>()?, + coverage, + }) +} diff --git a/data_plane/src/query_engines/asap_query_engine/logical_dag.rs b/data_plane/src/query_engines/asap_query_engine/logical_dag.rs index 28d6f5b4..42cbec9b 100644 --- a/data_plane/src/query_engines/asap_query_engine/logical_dag.rs +++ b/data_plane/src/query_engines/asap_query_engine/logical_dag.rs @@ -281,7 +281,9 @@ impl Result> ValueRuntim right_schema, .. } => { - let [values,candidates] = inputs else { return Err(miss("semi-join requires two inputs")); }; + let [values, candidates] = inputs else { + return Err(miss("semi-join requires two inputs")); + }; let values = vector(values.clone())?; let candidates = vector(candidates.clone())?; let predicate = serde_json::from_value(pred) @@ -300,7 +302,8 @@ impl Result> ValueRuntim ) }) .collect::>(); - let (selected, warning) = semi_join(candidates, values, &keys, pruning.as_ref(), context)?; + let (selected, warning) = + semi_join(candidates, values, &keys, pruning.as_ref(), context)?; if let Some(warning) = warning { self.warnings.push(warning); } @@ -368,69 +371,36 @@ impl Result> ValueRuntim } => { let left = input(0)?; let right = input(1)?; - binary(operation, return_bool, left, right) + binary_in_context(operation, return_bool, left, right, context) } ResidualQueryOperator::Temporal { operation } => { let Value::Matrix(values, start, end) = input(0)? else { return Err(miss("temporal operator requires range vector")); }; + let preserve_name = self.entry.language + == control_plane::query_plan::QueryLanguage::MetricsQl + && matches!( + operation, + TemporalOperation::Min | TemporalOperation::Max | TemporalOperation::Avg + ); + let result = native_temporal(values, operation, start, end, context)?; Ok(Value::Vector( - 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, @@ -450,12 +420,24 @@ impl Result> ValueRuntim 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, @@ -898,127 +880,75 @@ fn topk_selection( 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 { @@ -1027,104 +957,173 @@ fn binary( } } let mut seen = BTreeSet::new(); - let mut out = Vec::new(); for (labels, value) in left { let key = no_name(labels.clone()); if !seen.insert(key.clone()) { return Err(miss("duplicate vector matching labels")); } if let Some(right) = rhs.get(&key) { - if let Some(v) = combine(value, *right) { - out.push((if arithmetic || boolean { key } else { labels }, v)); - } + pairs.push((labels, value, *right)); } } - out } _ => return Err(miss("binary matrix unsupported")), - }; - Ok(Value::Vector(vector(Value::Vector(values))?)) -} - -fn rate(points: &[(i64, f64)], start: i64, end: i64) -> Option { - if points.len() < 2 { - return None; - } - let (first_t, first) = points[0]; - let (last_t, last) = *points.last()?; - let span = (last_t - first_t) as f64 / 1000.; - if span <= 0. { - return None; - } - let mut delta = last - first; - for pair in points.windows(2) { - if pair[1].1 < pair[0].1 { - delta += pair[0].1; - } } - let average = span / (points.len() - 1) as f64; - let mut to_start = (first_t - start) as f64 / 1000.; - let mut to_end = (end - last_t) as f64 / 1000.; - if to_start >= average * 1.1 { - to_start = average / 2.; - } - // Apply the zero bound after the sparse-window half-interval cap. - if delta > 0. && first >= 0. { - to_start = to_start.min(span * first / delta); + let schema = std::sync::Arc::new(SummarySchema { + fields: ["left", "right"] + .into_iter() + .map(|name| SummaryField { + name: name.into(), + dtype: SummaryFamilyType::Plain(DataType::Float64), + nullable: false, + }) + .collect(), + time_index: None, + }); + let batch = Batch::try_new( + schema.clone(), + pairs + .iter() + .map(|(_, a, b)| vec![Cell::Float64(*a), Cell::Float64(*b)]) + .collect(), + ) + .map_err(|e| miss(e.to_string()))?; + let operator = Operator::project( + schema, + vec![( + "value".into(), + Expression::Binary { + operator: BinaryOperator { + kind, + vector_match: None, + checked_relative_division: operation == BinaryOperation::CheckedDiv, + checked_finite_division: operation == BinaryOperation::FiniteDiv, + }, + left: Box::new(Expression::Column(0)), + right: Box::new(Expression::Column(1)), + }, + )], + ) + .map_err(|e| miss(e.to_string()))?; + let rows = native_batch_rows(batch, vec![operator], context)?; + let mut output = Vec::new(); + for ((labels, a, b), row) in pairs.into_iter().zip(rows) { + let value = match row.first() { + Some(Cell::Float64(value)) => *value, + Some(Cell::Bool(value)) if boolean => { + if *value { + 1. + } else { + 0. + } + } + Some(Cell::Bool(true)) => { + if scalar_left { + b + } else { + a + } + } + Some(Cell::Bool(false)) => continue, + _ => return Err(miss("native binary result schema mismatch")), + }; + output.push(( + if arithmetic || boolean { + no_name(labels) + } else { + labels + }, + value, + )); } - if to_end >= average * 1.1 { - to_end = average / 2.; + if scalar_output { + return Ok(Value::Scalar( + output + .first() + .ok_or_else(|| miss("missing scalar result"))? + .1, + )); } - Some(delta * (span + to_start + to_end) / span / ((end - start) as f64 / 1000.)) + Ok(Value::Vector(vector(Value::Vector(output))?)) } -fn bucket_quantile(q: f64, mut b: Vec<(f64, f64)>) -> f64 { - if q.is_nan() { - return f64::NAN; - } - if q < 0. { - return f64::NEG_INFINITY; - } - if q > 1. { - return f64::INFINITY; - } - b.retain(|p| !p.0.is_nan()); - b.sort_by(|a, b| a.0.total_cmp(&b.0)); - let mut buckets: Vec<(f64, f64)> = Vec::new(); - for p in b { - if let Some(last) = buckets.last_mut() { - if last.0 == p.0 { - last.1 += p.1; - continue; - } - } - buckets.push(p); - } - if buckets.len() < 2 || buckets.last().unwrap().0 != f64::INFINITY { - return f64::NAN; - } - let mut prev = buckets[0].1; - for p in buckets.iter_mut().skip(1) { - if p.1 < prev || (p.1 - prev).abs() <= 1e-12 * (p.1.abs() + prev.abs()) { - p.1 = prev; - } - prev = p.1; - } - let count = buckets.last().unwrap().1; - if count == 0. { - return f64::NAN; - } - let rank = q * count; - let idx = buckets[..buckets.len() - 1].partition_point(|p| p.1 < rank); - if idx == buckets.len() - 1 { - return buckets[idx - 1].0; - } - if idx == 0 && buckets[0].0 <= 0. { - return buckets[0].0; - } - let (start, base) = if idx == 0 { (0., 0.) } else { buckets[idx - 1] }; - let (end, upper) = buckets[idx]; - start + (end - start) * (rank - base) / (upper - base) +fn native_temporal( + values: Matrix, + operation: TemporalOperation, + start: i64, + end: i64, + context: &physical::RunContext, +) -> Result { + use planner_types::pre_asap::AggIntent; + let intent = match operation { + TemporalOperation::Rate => AggIntent::Rate, + TemporalOperation::Increase => AggIntent::Increase, + TemporalOperation::Sum => AggIntent::Sum { col: None }, + TemporalOperation::Avg => AggIntent::Avg { col: None }, + TemporalOperation::Min => AggIntent::Min { col: None }, + TemporalOperation::Max => AggIntent::Max { col: None }, + TemporalOperation::Count => AggIntent::Count { + accuracy: planner_types::types::AccuracyTarget::Exact, + }, + }; + let rows = values + .into_iter() + .flat_map(|(labels, points)| { + points.into_iter().map(move |(time, value)| { + vec![ + native_labels(&labels), + physical::values::Value::Timestamp(time), + physical::values::Value::Float64(value), + ] + }) + }) + .collect(); + native_window(rows, intent, Some((start, end)), context) +} +fn native_window( + rows: Vec>, + intent: planner_types::pre_asap::AggIntent, + window: Option<(i64, i64)>, + context: &physical::RunContext, +) -> Result { + use planner_types::{ + post_asap::{SummaryFamilyType, SummaryField, SummarySchema}, + pre_asap::DataType, + }; + let schema = std::sync::Arc::new(SummarySchema { + fields: vec![ + ( + "labels", + DataType::Map { + key: Box::new(DataType::Utf8), + value: Box::new(DataType::Utf8), + value_nullable: false, + }, + ), + ( + "coordinate", + if window.is_some() { + DataType::Timestamp + } else { + DataType::Float64 + }, + ), + ("value", DataType::Float64), + ] + .into_iter() + .map(|(name, dtype)| SummaryField { + name: name.into(), + dtype: SummaryFamilyType::Plain(dtype), + nullable: false, + }) + .collect(), + time_index: None, + }); + let batch = + physical::values::Batch::try_new(schema.clone(), rows).map_err(|e| miss(e.to_string()))?; + let operator = physical::operators::Operator::window(schema, intent, 1, 2, vec![0], window) + .map_err(|e| miss(e.to_string()))?; + native_vector_output(native_batch_rows(batch, vec![operator], context)?, 0, 1) } #[cfg(test)] 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 6d91d9b6..0a3c5465 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 @@ -138,7 +138,7 @@ impl 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 @@ -481,17 +481,42 @@ fn intersect_coverage(left: Option<(u64, u64)>, right: Option<(u64, u64)>) -> Op (result.0 <= result.1).then_some(result) } +#[cfg(test)] fn reduce_sum_values( grouping: &asap_types::query_plan::PhysicalGrouping, values: &[(BTreeMap, SummaryValue)], coverage: Option<(u64, u64)>, ) -> Result { + let context = dag::RunContext::new( + dag::Scope::Query { + evaluation_time_ms: 0, + revision: 0, + }, + dag::Limits::default(), + ) + .unwrap(); + reduce_sum_values_in_context(grouping, values, coverage, &context) +} +fn reduce_sum_values_in_context( + grouping: &asap_types::query_plan::PhysicalGrouping, + values: &[(BTreeMap, SummaryValue)], + coverage: Option<(u64, u64)>, + context: &dag::RunContext, +) -> Result { + use dag::{ + operators::{Operator, Reduction}, + values::{Batch, Value}, + }; + use planner_types::{ + post_asap::{SummaryFamilyType, SummaryField, SummarySchema}, + pre_asap::DataType, + }; let asap_types::query_plan::PhysicalGrouping::Reduce(keys) = grouping else { return Ok(PhysicalQueryOutput::Value(values.to_vec(), coverage)); }; let mut groups = BTreeMap::new(); for (labels, value) in values { - let SummaryValue::Points(points, coverage) = value else { + let SummaryValue::Points(points, row_coverage) = value else { return Err(PhysicalNodeError::ExpectedState); }; let labels = labels @@ -500,27 +525,52 @@ fn reduce_sum_values( .map(|(k, v)| (k.clone(), v.clone())) .collect::>(); for (timestamp, value) in points { - groups + let group = groups .entry((labels.clone(), *timestamp)) - .and_modify(|(sum, cover): &mut (f64, Option<(u64, u64)>)| { - *sum += value; - *cover = intersect_coverage(*cover, *coverage); - }) - .or_insert((*value, *coverage)); + .or_insert_with(|| (Vec::new(), *row_coverage)); + group.0.push(*value); + group.1 = intersect_coverage(group.1, *row_coverage); } } - Ok(PhysicalQueryOutput::Value( - groups + let groups = groups.into_iter().collect::>(); + let schema = std::sync::Arc::new(SummarySchema { + fields: vec![("group", DataType::Int64), ("value", DataType::Float64)] .into_iter() - .map(|((labels, timestamp), (sum, coverage))| { - ( - labels, - SummaryValue::Points(vec![(timestamp, sum)], coverage), - ) + .map(|(name, dtype)| SummaryField { + name: name.into(), + dtype: SummaryFamilyType::Plain(dtype), + nullable: false, }) .collect(), - coverage, - )) + time_index: None, + }); + let rows = groups + .iter() + .enumerate() + .flat_map(|(index, (_, (values, _)))| { + values + .iter() + .map(move |value| vec![Value::Int64(index as i64), Value::Float64(*value)]) + }) + .collect(); + let error = |error: dag::Error| PhysicalNodeError::Fallback(error.to_string()); + let batch = Batch::try_new(schema.clone(), rows).map_err(error)?; + let operator = Operator::aggregate(schema, vec![0], vec![("value".into(), Reduction::Sum(1))]) + .map_err(error)?; + let output = dag::batch_execution::evaluate_batch(batch, vec![operator], context.clone()) + .map_err(error)?; + let mut result = Vec::new(); + for row in output.iter().flat_map(|batch| batch.rows()) { + let [Value::Int64(index), Value::Float64(sum)] = row.as_slice() else { + return Err(PhysicalNodeError::ExpectedState); + }; + let ((labels, time), (_, row_coverage)) = &groups[*index as usize]; + result.push(( + labels.clone(), + SummaryValue::Points(vec![(*time, *sum)], *row_coverage), + )); + } + Ok(PhysicalQueryOutput::Value(result, coverage)) } fn execute_physical_query_plan( diff --git a/data_plane/src/query_engines/asap_query_engine/summary_executor.rs b/data_plane/src/query_engines/asap_query_engine/summary_executor.rs index e145db16..4a1a5916 100644 --- a/data_plane/src/query_engines/asap_query_engine/summary_executor.rs +++ b/data_plane/src/query_engines/asap_query_engine/summary_executor.rs @@ -59,6 +59,7 @@ //! on top of a sketch or exact-agg readout ("outer-agg-fold") — out of //! scope by explicit design choice, not an oversight. +use asap_physical_operators::accumulators::{MaxAccumulator, MinAccumulator}; use std::collections::{BTreeMap, BTreeSet}; use std::rc::Rc; use std::sync::Arc; @@ -75,12 +76,7 @@ use crate::storage_engines::sketch_db::index::{SketchSampleState, SketchStore}; use crate::storage_engines::sketch_db::query::delta_apply::{ cumulative_summary_state, per_window_summary_states, DeltaSketchKind, SummaryState, }; -use crate::storage_engines::types::{ - AggregateCore, AggregationType, KeyByLabelValues, MergeableAccumulator, -}; -use asap_physical_operators::accumulators::increase_accumulator::IncreaseAccumulator; -use asap_physical_operators::accumulators::max_accumulator::MaxAccumulator; -use asap_physical_operators::accumulators::min_accumulator::MinAccumulator; +use crate::storage_engines::types::{AggregateCore, AggregationType, KeyByLabelValues}; /// Per-query, per-call execution context — constructed fresh for each /// incoming query (never shared across concurrent queries, never @@ -212,18 +208,13 @@ impl GroupState { SummaryFamilyType::ExactAggregate(ExactKind::Rate, _) => asap_types::Statistic::Rate, _ => return None, }; - let mut merged: Option> = None; - for windows in entries { - for acc in windows.values() { - merged = Some(match merged.take() { - None => acc.clone_boxed_core(), - Some(current) => current.merge_with(acc.as_ref()).ok()?, - }); - } - } - merged? - .query_statistic(stat, key, &std::collections::HashMap::new()) - .ok() + asap_physical_operators::stored_state::readout::exact_readout( + entries.iter().flat_map(|windows| windows.values().cloned()), + stat, + key, + &std::collections::HashMap::new(), + ) + .ok() } /// Finalize the Planner-declared exact family with its matching readout. @@ -249,76 +240,17 @@ impl GroupState { asap_types::query_plan::ExactReadout::Max => asap_types::Statistic::Max, }; - let planner_state = entries.iter().flat_map(|w| w.values()).any(|a| { - a.as_any() - .is::() - }); - // Temporal exact summaries are the hot path for long-window - // dashboards. Merge their concrete, fixed-size states in one batch - // instead of allocating a boxed trait object for every pane. - if !planner_state - && matches!( - readout, - asap_types::query_plan::ExactReadout::Increase - | asap_types::query_plan::ExactReadout::Rate - ) - { - let accumulators = entries - .iter() - .flat_map(|windows| windows.values()) - .map(|acc| acc.as_any().downcast_ref::().cloned()) - .collect::>>()?; - let merged = >::merge_accumulators(accumulators) - .ok()?; - let query_kwargs = std::collections::HashMap::from([ - ("range_start_ms".to_string(), range_start_ms.to_string()), - ("range_end_ms".to_string(), range_end_ms.to_string()), - ]); - return merged.query_statistic(stat, key, &query_kwargs).ok(); - } - if !planner_state && readout == asap_types::query_plan::ExactReadout::Min { - return entries - .iter() - .flat_map(|windows| windows.values()) - .map(|acc| { - acc.as_any() - .downcast_ref::() - .map(|a| a.value) - }) - .collect::>>()? - .into_iter() - .reduce(f64::min); - } - if !planner_state && readout == asap_types::query_plan::ExactReadout::Max { - return entries - .iter() - .flat_map(|windows| windows.values()) - .map(|acc| { - acc.as_any() - .downcast_ref::() - .map(|a| a.value) - }) - .collect::>>()? - .into_iter() - .reduce(f64::max); - } - let mut merged: Option> = None; - for windows in entries { - for acc in windows.values() { - merged = Some(match merged.take() { - None => acc.clone_boxed_core(), - Some(m) => m.merge_with(acc.as_ref()).ok()?, - }); - } - } let query_kwargs = std::collections::HashMap::from([ ("range_start_ms".to_string(), range_start_ms.to_string()), ("range_end_ms".to_string(), range_end_ms.to_string()), ]); - let merged = merged?; - merged.query_statistic(stat, key, &query_kwargs).ok() + asap_physical_operators::stored_state::readout::exact_readout( + entries.iter().flat_map(|windows| windows.values().cloned()), + stat, + key, + &query_kwargs, + ) + .ok() } /// Coverage analog of `exact_value` — folds `(min_window_end_ms, @@ -1074,97 +1006,22 @@ fn readout_per_window( /// Read one scalar out of a merged `SummaryState` for the requested /// `SketchQuery` -- shared by both the cumulative and per-window readout /// paths. -fn sketch_query_value(rs: &SummaryState, query: &SketchQuery) -> Result { - if let SummaryState::UnivMon(state) = rs { - use crate::storage_engines::types::AggregateCore; - let statistic = match query { - SketchQuery::Cardinality => asap_types::Statistic::Cardinality, - SketchQuery::FrequencyL2 => asap_types::Statistic::FrequencyL2, - SketchQuery::FrequencyEntropy => asap_types::Statistic::FrequencyEntropy, - SketchQuery::PointCount { - key: ColumnRef::SampleValue, - value: None, - } => asap_types::Statistic::Count, - _ => { - return Err(SummaryExecutorError::Unsupported( - "unsupported UnivMon readout", - )) - } - }; - return state - .query_statistic(statistic, &None, &Default::default()) - .map_err(|_| SummaryExecutorError::Unsupported("UnivMon readout failed")); - } - match query { - SketchQuery::FrequencyL2 | SketchQuery::FrequencyEntropy => Err( - SummaryExecutorError::Unsupported("frequency moment readout requires UnivMon"), - ), - SketchQuery::Quantile { q } => match rs { - // Typed PromQL/continuous-percentile readout uses interpolation; - // portable DDS `quantile` deliberately retains lower-rank parity. - SummaryState::Dd(sketch) => { - sketch - .quantile_interpolated(*q) - .ok_or(SummaryExecutorError::Unsupported( - "DDS interpolated quantile is unavailable", - )) - } - _ => Ok(rs.quantile(*q)), +fn sketch_query_value( + state: &SummaryState, + query: &SketchQuery, +) -> Result { + asap_physical_operators::stored_state::readout::sketch_query_value(state, query).map_err( + |asap_physical_operators::stored_state::readout::Error::Unsupported(reason)| { + SummaryExecutorError::Unsupported(reason) }, - SketchQuery::Cardinality => Ok(rs.cardinality()), - // `key: ColumnRef::SampleValue, value: None` means "no specific - // item" -- the bare bucket total. `key: Named(_), value: Some(v)` - // is a per-item point lookup (e.g. `count(cms_metric{item="x"})`) - // -- `value` is where the filter's actual value lives (see - // `planner_types::post_asap::SketchQuery::PointCount`'s doc for why `readout` - // can't resolve it itself). Any other combination (e.g. a `Named` - // key with no value, or `SampleValue` with a value) is a shape - // this executor doesn't expect to see and reports rather than - // silently misreading. - SketchQuery::PointCount { - key: ColumnRef::SampleValue, - value: None, - } => Ok(rs.total()), - SketchQuery::PointCount { - key: ColumnRef::Named(_) | ColumnRef::Qualified { .. }, - value: Some(v), - } => rs.estimate(v).ok_or(SummaryExecutorError::Unsupported( - "PointCount by key requires a Frequency-family sketch (Cms/CountSketch/..WithHeap)", - )), - SketchQuery::PointCount { .. } => Err(SummaryExecutorError::Unsupported( - "unrecognized PointCount shape (key/value combination not expected)", - )), - // Both readout callers branch on `TopK` before ever calling this - // function (see `readout_cumulative`/`readout_per_window`), so - // this arm is unreachable in practice; kept for match - // exhaustiveness (`SketchQuery` has no `#[non_exhaustive]`) and to - // fail loudly rather than panic if that invariant is ever broken. - SketchQuery::TopK { .. } => Err(SummaryExecutorError::Unsupported( - "TopK must be read out via topk_ranked, not sketch_query_value", - )), - } + ) } - -/// Rank a merged `SummaryState`'s top-k heap items descending by value and -/// cap at the requested `k`. The sort is load-bearing, not defensive -/// polish: `SummaryState::topk_items` reads back a bounded min-heap's -/// backing array as-is (`HHHeap::heap()`, asap_sketchlib) -- it does NOT -/// actually guarantee order despite its own doc wording. Errors for a -/// heap-less family (`Dd`/`Hll`/`Kll`/`Cms`/`CountSketch` -- no item -/// universe to rank), not for an empty heap (a heap-bearing family that -/// simply never received any updates yields `Ok(vec![])`, not an error). -fn topk_ranked(rs: &SummaryState, k: usize) -> Result, SummaryExecutorError> { - let mut items = rs.topk_items().ok_or(SummaryExecutorError::Unsupported( - "TopK requires a heap-bearing family (CmsWithHeap/CountSketchWithHeap) -- \ - this state's family carries no item universe to rank", - ))?; - items.sort_by(|a, b| { - b.1.partial_cmp(&a.1) - .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| a.0.cmp(&b.0)) // deterministic tie-break for equal counts - }); - items.truncate(k); - Ok(items) +fn topk_ranked(state: &SummaryState, k: usize) -> Result, SummaryExecutorError> { + asap_physical_operators::stored_state::readout::topk_ranked(state, k).map_err( + |asap_physical_operators::stored_state::readout::Error::Unsupported(reason)| { + SummaryExecutorError::Unsupported(reason) + }, + ) } /// Exact canonical `SketchKind` match against a sid's own diff --git a/data_plane/src/storage_engines/sketch_db/data/mod.rs b/data_plane/src/storage_engines/sketch_db/data/mod.rs index e696fe52..475fc6b1 100644 --- a/data_plane/src/storage_engines/sketch_db/data/mod.rs +++ b/data_plane/src/storage_engines/sketch_db/data/mod.rs @@ -472,20 +472,7 @@ impl AccuracyBound { /// Per-sample sketch state. Stored as the payload column inside the /// per-sid `SidStoreData` columnar storage. -#[derive(Debug, Clone)] -pub struct SketchSampleState { - pub bytes: Vec, - /// Wire-encoding hint from the OTLP DataPoint's `encoding` field. - pub encoding: SketchEncoding, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SketchEncoding { - ProtoFull, - ProtoDelta, - MsgpackFull, - MsgpackDelta, -} +pub use asap_physical_operators::stored_state::{SketchEncoding, SketchSampleState}; /// One materialized series row returned by the query path. Resolved /// from the per-sid intern table at read time. diff --git a/data_plane/src/storage_engines/sketch_db/query/decoders.rs b/data_plane/src/storage_engines/sketch_db/query/decoders.rs index 3d254f2c..d208cb57 100644 --- a/data_plane/src/storage_engines/sketch_db/query/decoders.rs +++ b/data_plane/src/storage_engines/sketch_db/query/decoders.rs @@ -1,382 +1,2 @@ -//! Per-sketch-kind decoder helpers — out-of-line wrappers around -//! `asap_sketchlib` deserialize / proto-decode paths. -//! -//! Lifted from the inline closures in [`crate::storage_engines::sketch_db::query::sketch_reducer`] -//! once the reducer started decoding CMS / CountSketch / CMS-with-heap -//! payloads in addition to DDSketch / KLL / HLL. The CMS / CountSketch -//! / CMS-with-heap decoders mirror -//! `precompute_operators::{count_min_sketch, count_sketch, -//! count_min_sketch_with_heap}_accumulator.rs` bit-for-bit so the -//! ASAP-tier reducer's output matches what the precompute (ingest-side) -//! accumulator would have produced from the same bytes. -//! -//! Each entry point returns the typed sketchlib struct on success or a -//! plain `String` error; the reducer wraps the error into a -//! `ASAPTierError::DeserializeFailure` so the engine router falls over -//! to archive cleanly. - -use asap_sketchlib::CountMinSketch; -use asap_sketchlib::CountMinSketchDelta; -use asap_sketchlib::CountMinSketchWithHeap; -use asap_sketchlib::CountSketch; -use asap_sketchlib::CountSketchDelta; -use asap_sketchlib::CountSketchWithHeap; -use asap_sketchlib::CsHeapItem; -use asap_sketchlib::MessagePackCodec; - -use asap_physical_operators::accumulators::count_min_sketch_with_heap_accumulator::CountMinSketchWithHeapAccumulator; - -/// Decode a `CountMinSketch` from the modified-OTLP wire bytes. -/// MSGPACK path round-trips `CountMinSketch::deserialize_msgpack`; -/// PROTO path decodes a `SketchEnvelope{count_min: CountMinState}` -/// (or bare `CountMinState`) and re-projects to a flat matrix. Mirrors -/// `precompute_operators::count_min_sketch_accumulator::from_sketchlib_proto_bytes`. -pub fn decode_cms_from_proto(buffer: &[u8]) -> Result { - use asap_sketchlib::proto::sketchlib::{ - sketch_envelope, CountMinState, CounterType, SketchEnvelope, - }; - use prost::Message; - - let state = match SketchEnvelope::decode(buffer) { - Ok(env) => match env.sketch_state { - Some(sketch_envelope::SketchState::CountMin(st)) => st, - Some(_) => return Err("SketchEnvelope contains non-CountMin sketch".to_string()), - None => { - CountMinState::decode(buffer).map_err(|e| format!("decode CountMinState: {e}"))? - } - }, - Err(_) => { - CountMinState::decode(buffer).map_err(|e| format!("decode CountMinState: {e}"))? - } - }; - let rows = state.rows as usize; - let cols = state.cols as usize; - if rows == 0 || cols == 0 { - return Err(format!( - "CountMinState has zero dims (rows={rows}, cols={cols})" - )); - } - let expected_len = rows * cols; - let counter_type = CounterType::try_from(state.counter_type) - .map_err(|_| format!("CountMinState unknown counter_type {}", state.counter_type))?; - let flat: Vec = match counter_type { - CounterType::Int32 | CounterType::Int64 => { - if state.counts_int.len() != expected_len { - return Err(format!( - "CountMinState counts_int has {} entries, expected {}", - state.counts_int.len(), - expected_len - )); - } - state.counts_int.iter().map(|&v| v as f64).collect() - } - CounterType::Float64 => { - if state.counts_float.len() != expected_len { - return Err(format!( - "CountMinState counts_float has {} entries, expected {}", - state.counts_float.len(), - expected_len - )); - } - state.counts_float.clone() - } - other => { - return Err(format!( - "CountMinState counter_type {other:?} not yet supported in reducer" - )); - } - }; - let mut matrix = Vec::with_capacity(rows); - for r in 0..rows { - let start = r * cols; - matrix.push(flat[start..start + cols].to_vec()); - } - Ok(CountMinSketch::from_legacy_matrix(matrix, rows, cols)) -} - -/// Decode a `CountMinSketch` from msgpack bytes (sketch-core wire -/// format). Mirrors -/// `CountMinSketchAccumulator::from_msgpack_bytes`. -pub fn decode_cms_from_msgpack(buffer: &[u8]) -> Result { - CountMinSketch::from_msgpack(buffer) - .map_err(|e| format!("deserialize CountMinSketch msgpack: {e}")) -} - -/// Decode a `CountSketch` from the modified-OTLP proto wire bytes. -/// Mirrors -/// `precompute_operators::count_sketch_accumulator::from_sketchlib_proto_bytes`. -pub fn decode_cs_from_proto(buffer: &[u8]) -> Result { - use asap_sketchlib::proto::sketchlib::{ - sketch_envelope, CountSketchState, CounterType, SketchEnvelope, - }; - use prost::Message; - - let state = match SketchEnvelope::decode(buffer) { - Ok(env) => match env.sketch_state { - Some(sketch_envelope::SketchState::CountSketch(st)) => st, - Some(_) => return Err("SketchEnvelope contains non-CountSketch sketch".to_string()), - None => CountSketchState::decode(buffer) - .map_err(|e| format!("decode CountSketchState: {e}"))?, - }, - Err(_) => { - CountSketchState::decode(buffer).map_err(|e| format!("decode CountSketchState: {e}"))? - } - }; - let rows = state.rows as usize; - let cols = state.cols as usize; - if rows == 0 || cols == 0 { - return Err(format!( - "CountSketchState has zero dims (rows={rows}, cols={cols})" - )); - } - let expected_len = rows * cols; - let counter_type = CounterType::try_from(state.counter_type).map_err(|_| { - format!( - "CountSketchState unknown counter_type {}", - state.counter_type - ) - })?; - let flat: Vec = match counter_type { - CounterType::Int32 | CounterType::Int64 => { - if state.counts_int.len() != expected_len { - return Err(format!( - "CountSketchState counts_int has {} entries, expected {}", - state.counts_int.len(), - expected_len - )); - } - state.counts_int.iter().map(|&v| v as f64).collect() - } - CounterType::Float64 => { - if state.counts_float.len() != expected_len { - return Err(format!( - "CountSketchState counts_float has {} entries, expected {}", - state.counts_float.len(), - expected_len - )); - } - state.counts_float.clone() - } - other => { - return Err(format!( - "CountSketchState counter_type {other:?} not yet supported in reducer" - )); - } - }; - let mut matrix = Vec::with_capacity(rows); - for r in 0..rows { - let start = r * cols; - matrix.push(flat[start..start + cols].to_vec()); - } - Ok(CountSketch::from_legacy_matrix(matrix, rows, cols)) -} - -/// Decode a `CountSketch` from msgpack bytes (sketch-core wire format). -pub fn decode_cs_from_msgpack(buffer: &[u8]) -> Result { - CountSketch::from_msgpack(buffer).map_err(|e| format!("deserialize CountSketch msgpack: {e}")) -} - -/// Decode a `CountMinSketchWithHeap` from msgpack bytes — the OTLP -/// `CountMinSketch` wire bytes when the gateway/precompute layer -/// marked the sid as CmsWithHeap (heap embedded in the -/// `CountMinSketchWithHeapSerialized` outer wrapper). Delegates to -/// `asap_sketchlib::CountMinSketchWithHeap::deserialize_msgpack`. -pub fn decode_cms_with_heap_from_msgpack(buffer: &[u8]) -> Result { - CountMinSketchWithHeap::from_msgpack(buffer) - .map_err(|e| format!("deserialize CountMinSketchWithHeap msgpack: {e}")) -} - -/// Decode a `CountSketchWithHeap` (median-estimator, Count Sketch family) -/// from msgpack bytes. Distinct wire type from `CountMinSketchWithHeap` -/// (min-estimator, Count-Min family) even though both are heap-bearing -/// frequency sketches — see `asap_sketchlib::CountSketchWithHeap`. -/// Delegates to `asap_sketchlib::CountSketchWithHeap::from_msgpack`. -pub fn decode_cs_with_heap_from_msgpack(buffer: &[u8]) -> Result { - CountSketchWithHeap::from_msgpack(buffer) - .map_err(|e| format!("deserialize CountSketchWithHeap msgpack: {e}")) -} - -// --------------------------------------------------------------------------- -// Delta decoders. Under the per-window-reset (PWR) contract -// (`asap-precompute-go/window.go`: a delta is that window's own state -// applied onto a freshly-reset per-series sketch), each stored *Delta -// frame reconstructs into the FULL window state when applied onto an -// EMPTY base of the frame's declared dimensions. The reducer's -// `FrequencyEstimate` / `FrequencyTopk` paths are per-window evaluations, -// so "empty + apply(this window's delta)" yields exactly the window's -// matrix/heap — no cross-window stitching needed (mirrors how the ingest -// accumulators reset_to_empty per window before applying). -// -// The proto path reuses the PUBLIC `asap_sketchlib::{CountSketch, -// CountMinSketch}::apply_delta`; the proto `*Delta` message is decoded via -// `asap_sketchlib::proto::sketchlib::{CountSketchDelta, CountMinDelta}`, -// exactly as `precompute_operators::{count_sketch, -// count_min_sketch}_accumulator::apply_proto_delta_bytes` does. -// --------------------------------------------------------------------------- - -/// Decode a `CountMinSketch` PROTO_DELTA frame into a FULL sketch by -/// applying the sparse cell delta onto an empty base of the frame's -/// declared dimensions. Mirrors -/// `precompute_operators::count_min_sketch_accumulator::apply_proto_delta_bytes`. -pub fn decode_cms_from_proto_delta(buffer: &[u8]) -> Result { - use asap_sketchlib::proto::sketchlib::CountMinDelta as PbDelta; - use prost::Message; - - let pb = PbDelta::decode(buffer).map_err(|e| format!("decode CountMinDelta: {e}"))?; - if pb.cell_rows.len() != pb.cell_cols.len() || pb.cell_rows.len() != pb.d_counts.len() { - return Err(format!( - "CountMinDelta packed-array length mismatch: cell_rows={}, cell_cols={}, d_counts={}", - pb.cell_rows.len(), - pb.cell_cols.len(), - pb.d_counts.len() - )); - } - let rows = pb.rows as usize; - let cols = pb.cols as usize; - if rows == 0 || cols == 0 { - return Err(format!( - "CountMinDelta has zero dims (rows={rows}, cols={cols})" - )); - } - let cells = pb - .cell_rows - .iter() - .zip(pb.cell_cols.iter()) - .zip(pb.d_counts.iter()) - .map(|((r, c), dc)| (*r, *c, *dc)) - .collect(); - // hh_keys is parsed off the wire by the precompute accumulator but - // intentionally dropped (the vendored Go proto bindings don't yet - // populate it); match that to keep behavior identical. - let delta = CountMinSketchDelta { - rows: pb.rows, - cols: pb.cols, - cells, - l1: pb.l1, - l2: pb.l2, - hh_keys: Vec::new(), - }; - let mut cms = CountMinSketch::from_legacy_matrix(vec![vec![0.0; cols]; rows], rows, cols); - cms.apply_delta(&delta) - .map_err(|e| format!("apply CountMinDelta onto empty base: {e}"))?; - Ok(cms) -} - -/// Decode a `CountSketch` PROTO_DELTA frame into a FULL sketch by applying -/// the sparse cell delta onto an empty base of the frame's declared -/// dimensions. Mirrors -/// `precompute_operators::count_sketch_accumulator::apply_proto_delta_bytes`. -pub fn decode_cs_from_proto_delta(buffer: &[u8]) -> Result { - use asap_sketchlib::proto::sketchlib::CountSketchDelta as PbDelta; - use prost::Message; - - let pb = PbDelta::decode(buffer).map_err(|e| format!("decode CountSketchDelta: {e}"))?; - if pb.cell_rows.len() != pb.cell_cols.len() || pb.cell_rows.len() != pb.d_counts.len() { - return Err(format!( - "CountSketchDelta packed-array length mismatch: cell_rows={}, cell_cols={}, d_counts={}", - pb.cell_rows.len(), - pb.cell_cols.len(), - pb.d_counts.len() - )); - } - let rows = pb.rows as usize; - let cols = pb.cols as usize; - if rows == 0 || cols == 0 { - return Err(format!( - "CountSketchDelta has zero dims (rows={rows}, cols={cols})" - )); - } - let cells = pb - .cell_rows - .iter() - .zip(pb.cell_cols.iter()) - .zip(pb.d_counts.iter()) - .map(|((r, c), dc)| (*r, *c, *dc)) - .collect(); - let delta = CountSketchDelta { - rows: pb.rows, - cols: pb.cols, - cells, - l2: pb.l2, - hh_keys: Vec::new(), - }; - let mut cs = CountSketch::from_legacy_matrix(vec![vec![0.0; cols]; rows], rows, cols); - cs.apply_delta(&delta) - .map_err(|e| format!("apply CountSketchDelta onto empty base: {e}"))?; - Ok(cs) -} - -/// Decode a heap-bearing CountSketch MSGPACK_DELTA frame into a FULL -/// `CountMinSketchWithHeap` by applying the sparse matrix delta + full -/// heap onto an empty base of the frame's declared dimensions. This -/// REUSES the ingest-side delta-heap apply logic -/// (`CountMinSketchWithHeapAccumulator::from_msgpack_heap_delta_bytes` → -/// `apply_msgpack_heap_delta_bytes`), which decodes the frame generically -/// with `rmp_serde` — no `asap_sketchlib` delta API is added. -pub fn decode_cms_with_heap_from_msgpack_delta( - buffer: &[u8], -) -> Result { - let acc = CountMinSketchWithHeapAccumulator::from_msgpack_heap_delta_bytes(buffer) - .map_err(|e| format!("reconstruct CountMinSketchWithHeap from delta: {e}"))?; - Ok(acc.inner) -} - -/// Decode a heap-bearing CountSketch (median-estimator) MSGPACK_DELTA frame -/// into a FULL `asap_sketchlib::CountSketchWithHeap` by applying the sparse -/// matrix delta + full heap onto an empty base of the frame's declared -/// dimensions. Same DELTA-HEAP wire shape as the CmsWithHeap delta frame -/// (see `HeapDeltaWire`/`MatrixDeltaWire` in -/// `count_min_sketch_with_heap_accumulator.rs`), decoded here directly -/// with `rmp_serde` since there is no CountSketchWithHeap ingest -/// accumulator to delegate to. No `asap_sketchlib` delta API needed — the -/// public `from_legacy_matrix` rebuilds both the matrix and heap. -pub fn decode_cs_with_heap_from_msgpack_delta( - buffer: &[u8], -) -> Result { - #[derive(serde::Deserialize)] - struct HeapDeltaWire { - is_delta: bool, - matrix_delta: MatrixDeltaWire, - topk_heap: Vec<(String, f64)>, - heap_size: u64, - } - #[derive(serde::Deserialize)] - struct MatrixDeltaWire { - rows: u32, - cols: u32, - cells: Vec<(u32, u32, i64)>, - } - - let wire: HeapDeltaWire = rmp_serde::from_slice(buffer) - .map_err(|e| format!("decode CountSketchWithHeap delta msgpack: {e}"))?; - if !wire.is_delta { - return Err("CountSketchWithHeap delta frame has is_delta=false".to_string()); - } - let rows = wire.matrix_delta.rows as usize; - let cols = wire.matrix_delta.cols as usize; - if rows == 0 || cols == 0 { - return Err(format!( - "CountSketchWithHeap delta frame has zero dims (rows={rows}, cols={cols})" - )); - } - let mut matrix = vec![vec![0.0; cols]; rows]; - for (r, c, dc) in &wire.matrix_delta.cells { - let (r, c) = (*r as usize, *c as usize); - if r >= rows || c >= cols { - continue; - } - matrix[r][c] += *dc as f64; - } - let heap: Vec = wire - .topk_heap - .into_iter() - .map(|(key, value)| CsHeapItem { key, value }) - .collect(); - Ok(CountSketchWithHeap::from_legacy_matrix( - matrix, - heap, - rows, - cols, - wire.heap_size as usize, - )) -} +//! Storage uses the Planner-owned state implementation. +pub use asap_physical_operators::stored_state::decoders::*; diff --git a/data_plane/src/storage_engines/sketch_db/query/delta_apply.rs b/data_plane/src/storage_engines/sketch_db/query/delta_apply.rs index d36b6955..cd374a41 100644 --- a/data_plane/src/storage_engines/sketch_db/query/delta_apply.rs +++ b/data_plane/src/storage_engines/sketch_db/query/delta_apply.rs @@ -1,1286 +1,2 @@ -//! Per-window delta stitching for the ASAP-tier sketch reducer. -//! -//! The reducer walks a sid's per-window samples in time order. When a -//! window's payload is a `Full` encoding (PROTO / MSGPACK), it -//! initializes a rolling "current state" sketch. Subsequent `Delta` -//! encodings merge into that rolling state — either via -//! `asap_sketchlib::HllSketch::apply_delta` for HLL register deltas, -//! or via `Sketch::merge(decoded_delta)` for DD / KLL where the wire -//! format ships a sparse-but-mergeable sketch fragment. -//! -//! Two reducer modes, picked by the PromQL function name in -//! [`crate::storage_engines::sketch_db::query::sketch_reducer`]: -//! -//! * **per-window** (`quantile`, `histogram_quantile`, -//! `cardinality_estimate`): emit one scalar per window. A `Full` -//! resets the rolling state; a `Delta` merges then emits from the -//! merged state. Window-end timestamps come from the index. -//! * **cumulative** (`quantile_over_time`, `count_distinct_over_time`): -//! walk the full `[t0, t1]` range, accumulating Full + all subsequent -//! Deltas into a single rolling state. Emit one scalar at the last -//! window's end_ms (the rolled-up answer covers the whole window). -//! -//! ## Corner case: leading delta -//! -//! If the first sample in a query window is a `Delta`, the base -//! snapshot from the previous (out-of-range) window isn't available -//! to the reducer. The leading delta is dropped (logged via the -//! `DeserializeFailure { reason: "leading delta without base" }` path -//! when no Full follows in the same window) and we wait for the next -//! `Full`. For cumulative mode this means the cumulative answer -//! starts at the first Full in the range, not at `t0`. - -use asap_sketchlib::CountMinSketch; -use asap_sketchlib::CountMinSketchWithHeap; -use asap_sketchlib::CountSketch; -use asap_sketchlib::CountSketchWithHeap; -use asap_sketchlib::DdSketch; -use asap_sketchlib::HllSketch; -use asap_sketchlib::HllVariant; -use asap_sketchlib::KllSketch; -use asap_sketchlib::MessagePackCodec; - -use crate::storage_engines::sketch_db::index::{SketchEncoding, SketchSampleState}; -use crate::storage_engines::sketch_db::query::decoders::{ - decode_cms_from_msgpack, decode_cms_from_proto, decode_cms_from_proto_delta, - decode_cms_with_heap_from_msgpack, decode_cms_with_heap_from_msgpack_delta, - decode_cs_from_msgpack, decode_cs_from_proto, decode_cs_from_proto_delta, - decode_cs_with_heap_from_msgpack, decode_cs_with_heap_from_msgpack_delta, -}; - -/// Which sketch family a candidate is, and the parameters needed to -/// *bootstrap an empty state* — required by the per-window-reset (PWR) -/// delta model where a window's FIRST frame is a delta-from-empty (no -/// carry-in Full). Most families' deltas embed their own params in the -/// wire fragment (decoded independently, then merged in — see -/// `SummaryState::apply_delta_bytes`); HLL register deltas and DD's -/// bucket-index deltas are applied onto a pre-sized structure instead, -/// so those two need the params known up front to allocate it. -#[derive(Debug, Clone, Copy)] -pub enum DeltaSketchKind { - UnivMon { - heap_size: u32, - sketch_rows: u32, - sketch_cols: u32, - layers: u8, - }, - DDSketch { - alpha: f64, - }, - Hll { - precision: u32, - }, - Kll { - k: u32, - }, - Cms { - rows: usize, - cols: usize, - }, - CountSketch { - rows: usize, - cols: usize, - }, - /// `CmsWithHeap` wraps `asap_sketchlib::CountMinSketchWithHeap` - /// (min-over-rows estimator) and `CountSketchWithHeap` wraps the - /// distinct `asap_sketchlib::CountSketchWithHeap` (median-of-signed-rows - /// estimator) -- different algorithms that happen to share a storage - /// shape. Kept as two variants (not one shared `Heap`) so - /// `merge_same_family` rejects merging one into the other the same - /// way it already rejects e.g. merging a `Cms` into a `Kll`; now the - /// type system enforces it too, since the two variants hold different - /// Rust types. - CmsWithHeap { - rows: usize, - cols: usize, - heap_size: usize, - }, - CountSketchWithHeap { - rows: usize, - cols: usize, - heap_size: usize, - }, -} - -impl DeltaSketchKind { - /// Construct an EMPTY state for this kind, used to seed a new window - /// when its first frame is a delta-from-empty (PWR). A delta applied - /// onto this empty base reconstructs exactly that window's state - /// (delta-from-empty ⊕ empty = window state). - fn bootstrap_empty(&self) -> SummaryState { - match self { - Self::UnivMon { - heap_size, - sketch_rows, - sketch_cols, - layers, - } => SummaryState::UnivMon( - asap_physical_operators::accumulators::univmon_accumulator::UnivMonAccumulator::new( - *heap_size as usize, - *sketch_rows as usize, - *sketch_cols as usize, - *layers as usize, - ) - .expect("validated UnivMon catalog dimensions"), - ), - DeltaSketchKind::DDSketch { alpha } => SummaryState::Dd(DdSketch::new(*alpha)), - DeltaSketchKind::Kll { k } => SummaryState::Kll(KllSketch::new(*k as u16)), - DeltaSketchKind::Hll { precision } => { - SummaryState::Hll(HllSketch::new(HllVariant::Regular, *precision)) - } - DeltaSketchKind::Cms { rows, cols } => { - SummaryState::Cms(CountMinSketch::new(*rows, *cols)) - } - DeltaSketchKind::CountSketch { rows, cols } => { - SummaryState::CountSketch(CountSketch::new(*rows, *cols)) - } - DeltaSketchKind::CmsWithHeap { - rows, - cols, - heap_size, - } => SummaryState::CmsWithHeap(CountMinSketchWithHeap::new(*rows, *cols, *heap_size)), - DeltaSketchKind::CountSketchWithHeap { - rows, - cols, - heap_size, - } => SummaryState::CountSketchWithHeap(CountSketchWithHeap::new( - *rows, *cols, *heap_size, - )), - } - } -} - -/// Try to decode a "full" sketch from the bytes (used by both -/// per-window and cumulative modes when the encoding is `*Full`). -fn decode_full( - kind: &DeltaSketchKind, - bytes: &[u8], - encoding: SketchEncoding, -) -> Result { - match (kind, encoding) { - ( - DeltaSketchKind::UnivMon { - heap_size, - sketch_rows, - sketch_cols, - layers, - }, - SketchEncoding::MsgpackFull, - ) => { - let state = asap_physical_operators::accumulators::univmon_accumulator::UnivMonAccumulator::from_bytes(bytes) - .map_err(|e| e.to_string())?; - if state.dimensions() - != ( - *heap_size as usize, - *sketch_rows as usize, - *sketch_cols as usize, - *layers as usize, - ) - { - return Err("UnivMon payload dimensions differ from installed catalog".into()); - } - Ok(SummaryState::UnivMon(state)) - } - (DeltaSketchKind::DDSketch { .. }, SketchEncoding::ProtoFull) => { - let sk = dd_from_proto(bytes)?; - Ok(SummaryState::Dd(sk)) - } - (DeltaSketchKind::DDSketch { .. }, SketchEncoding::MsgpackFull) => { - let sk = DdSketch::from_msgpack(bytes) - .map_err(|e| format!("deserialize DDSketch msgpack: {e}"))?; - Ok(SummaryState::Dd(sk)) - } - (DeltaSketchKind::Hll { .. }, SketchEncoding::ProtoFull) => { - let sk = hll_from_proto(bytes)?; - Ok(SummaryState::Hll(sk)) - } - (DeltaSketchKind::Hll { .. }, SketchEncoding::MsgpackFull) => { - let sk = HllSketch::from_msgpack(bytes) - .map_err(|e| format!("deserialize HllSketch msgpack: {e}"))?; - Ok(SummaryState::Hll(sk)) - } - (DeltaSketchKind::Kll { .. }, SketchEncoding::ProtoFull) => { - let sk = kll_from_proto(bytes)?; - Ok(SummaryState::Kll(sk)) - } - (DeltaSketchKind::Kll { .. }, SketchEncoding::MsgpackFull) => { - let sk = KllSketch::from_msgpack(bytes) - .map_err(|e| format!("deserialize KllSketch msgpack: {e}"))?; - Ok(SummaryState::Kll(sk)) - } - (DeltaSketchKind::Cms { .. }, SketchEncoding::ProtoFull) => { - Ok(SummaryState::Cms(decode_cms_from_proto(bytes)?)) - } - (DeltaSketchKind::Cms { .. }, SketchEncoding::MsgpackFull) => { - Ok(SummaryState::Cms(decode_cms_from_msgpack(bytes)?)) - } - (DeltaSketchKind::CountSketch { .. }, SketchEncoding::ProtoFull) => { - Ok(SummaryState::CountSketch(decode_cs_from_proto(bytes)?)) - } - (DeltaSketchKind::CountSketch { .. }, SketchEncoding::MsgpackFull) => { - Ok(SummaryState::CountSketch(decode_cs_from_msgpack(bytes)?)) - } - // The heap-bearing wire format is msgpack-only in this - // deployment; `decode_cms_with_heap_from_msgpack` is the same - // "Full" decoder the reducer's existing per-frame dispatch falls - // through to for any non-MsgpackDelta encoding. - ( - DeltaSketchKind::CmsWithHeap { .. }, - SketchEncoding::ProtoFull | SketchEncoding::MsgpackFull, - ) => Ok(SummaryState::CmsWithHeap( - decode_cms_with_heap_from_msgpack(bytes)?, - )), - ( - DeltaSketchKind::CountSketchWithHeap { .. }, - SketchEncoding::ProtoFull | SketchEncoding::MsgpackFull, - ) => Ok(SummaryState::CountSketchWithHeap( - decode_cs_with_heap_from_msgpack(bytes)?, - )), - (_, e) => Err(format!("decode_full called with non-Full encoding {e:?}")), - } -} - -/// The reconstructed state one candidate sid contributes — either -/// folded across a window (or several) via delta application, or merged -/// in from another sid's own reconstruction. -pub enum SummaryState { - UnivMon(asap_physical_operators::accumulators::univmon_accumulator::UnivMonAccumulator), - Dd(DdSketch), - Hll(HllSketch), - Kll(KllSketch), - Cms(CountMinSketch), - CountSketch(CountSketch), - /// See `DeltaSketchKind::CmsWithHeap`/`CountSketchWithHeap` for why - /// these are two variants holding two different sketchlib types. - CmsWithHeap(CountMinSketchWithHeap), - CountSketchWithHeap(CountSketchWithHeap), -} - -impl SummaryState { - /// Apply a delta-encoded payload from a window sample. For DD / KLL, - /// the delta is interpreted as a "mergeable fragment" decoded - /// through the same full-state decoder and merged into the - /// rolling state. For HLL, the wire delta is a sparse register - /// update applied via the sketch's `apply_delta`. - /// - /// On encoding mismatch (e.g. trying to apply an HllDelta to a - /// DDSketch rolling state) returns Err. - pub fn apply_delta_bytes( - &mut self, - bytes: &[u8], - encoding: SketchEncoding, - ) -> Result<(), String> { - if !matches!( - encoding, - SketchEncoding::ProtoDelta | SketchEncoding::MsgpackDelta - ) { - return Err(format!( - "apply_delta_bytes called with non-Delta encoding {encoding:?}" - )); - } - match self { - SummaryState::UnivMon(_) => Err("UnivMon requires full pane snapshots".into()), - SummaryState::Dd(sk) => { - match encoding { - // PROTO_DELTA: dispatch on the payload SHAPE, mirroring the - // supported DDSketch frame decoder, which tries the - // full-envelope decode first, then falls back to - // the bucket-delta proto. Two wire shapes can arrive on the - // ProtoDelta channel: - // - // 1. `SketchEnvelope{DdSketchState}` — a full-state - // fragment, mergeable via `DdSketch::merge`. (The edge - // sends this when `compute_delta_against` hits the - // empty-current / undecodable-prior fallback and ships - // a full snapshot tagged as a delta.) - // 2. `DDSketchDelta { buckets: [{index, d_count}] }` — a - // bucket-index delta proto, applied additively. This is - // the COMMON delta_transmission frame the edge emits - // under per-window-reset (`compute_delta(&empty)`). - // - // Before this fix the reducer decoded ONLY shape (1) via - // `decode_full`. A real shape-(2) frame failed with a wire- - // type mismatch on field 1 (delta field 1 = repeated - // submessage; state field 1 = `double alpha`) → the whole - // `quantile_over_time` returned `No result` for every - // delta_transmission DDSketch stream. We wrap the rolling - // `DdSketch` in a transient accumulator so the bucket-delta - // apply lands on `sk` in place. - SketchEncoding::ProtoDelta => { - // Shape (1): full envelope fragment → merge. Try this - // first (cheap decode attempt; a bucket-delta proto - // fails it on the field-1 wire-type mismatch). - if let Ok(SummaryState::Dd(other)) = decode_full( - &DeltaSketchKind::DDSketch { alpha: 0.0 }, - bytes, - SketchEncoding::ProtoFull, - ) { - sk.merge(&other) - .map_err(|e| format!("merge DDSketch delta envelope: {e}"))?; - return Ok(()); - } - // Shape (2): bucket-delta proto → additive apply via the - // SAME decoder the ingest delta path uses. - use asap_physical_operators::accumulators::dd_sketch_accumulator::DDSketchAccumulator; - let mut acc = DDSketchAccumulator { - inner: std::mem::replace(sk, DdSketch::new(sk.alpha)), - sample_p: 1.0, - }; - let res = acc.apply_proto_delta_bytes(bytes); - *sk = acc.inner; - res.map_err(|e| format!("apply DDSketch proto bucket-delta: {e}"))?; - Ok(()) - } - // MSGPACK_DELTA: a serialized full-sketch fragment, mergeable - // via the full-state decoder. Kept for completeness — the - // edge wires PROTO_DELTA for DDSketch today. - SketchEncoding::MsgpackDelta => { - let other = match decode_full( - &DeltaSketchKind::DDSketch { alpha: 0.0 }, - bytes, - SketchEncoding::MsgpackFull, - ) { - Ok(SummaryState::Dd(s)) => s, - Ok(_) => { - return Err( - "decode_full(DDSketch) returned non-DDSketch state".to_string() - ) - } - Err(e) => return Err(e), - }; - sk.merge(&other) - .map_err(|e| format!("merge DDSketch delta: {e}"))?; - Ok(()) - } - _ => unreachable!(), - } - } - SummaryState::Hll(sk) => { - // HLL has a true sparse register delta in the proto - // wire format. Use the same path the precompute - // accumulator uses (`apply_proto_delta_bytes`-style). - if encoding == SketchEncoding::ProtoDelta { - apply_hll_proto_delta(sk, bytes) - } else { - // MsgpackDelta for HLL isn't a sparse encoding; - // it's a serialized HllSketch fragment, mergeable - // via `HllSketch::merge`. - let other = HllSketch::from_msgpack(bytes) - .map_err(|e| format!("deserialize HllSketch (delta-as-msgpack): {e}"))?; - sk.merge(&other) - .map_err(|e| format!("merge HLL delta: {e}"))?; - Ok(()) - } - } - SummaryState::Kll(sk) => { - let full_enc = match encoding { - SketchEncoding::ProtoDelta => SketchEncoding::ProtoFull, - SketchEncoding::MsgpackDelta => SketchEncoding::MsgpackFull, - _ => unreachable!(), - }; - let other = match decode_full(&DeltaSketchKind::Kll { k: 0 }, bytes, full_enc) { - Ok(SummaryState::Kll(s)) => s, - Ok(_) => return Err("decode_full(Kll) returned non-Kll state".to_string()), - Err(e) => return Err(e), - }; - sk.merge(&other) - .map_err(|e| format!("merge KLL delta: {e}"))?; - Ok(()) - } - // CMS/CountSketch/Heap have no true sparse in-place delta - // (unlike DD's bucket-index proto or HLL's register proto, - // above) — every delta frame already decodes into a - // complete, standalone state on its own (the PWR wire - // contract resets to empty at the source), so applying one - // is always "decode independently, then merge". - SummaryState::Cms(sk) => { - if encoding != SketchEncoding::ProtoDelta { - return Err( - "CountMin (heap-less) MSGPACK_DELTA is not a valid producer encoding \ - (msgpack-delta is the heap-bearing form)" - .to_string(), - ); - } - let other = decode_cms_from_proto_delta(bytes)?; - sk.merge(&other) - .map_err(|e| format!("merge CountMinSketch delta: {e}")) - } - SummaryState::CountSketch(sk) => { - if encoding != SketchEncoding::ProtoDelta { - return Err( - "CountSketch (heap-less) MSGPACK_DELTA is not a valid producer encoding \ - (msgpack-delta is the heap-bearing form)" - .to_string(), - ); - } - let other = decode_cs_from_proto_delta(bytes)?; - sk.merge(&other) - .map_err(|e| format!("merge CountSketch delta: {e}")) - } - SummaryState::CmsWithHeap(sk) => { - // Matches the existing per-frame reducer dispatch: only - // MsgpackDelta gets true delta treatment; ProtoDelta (not - // produced for this family in this deployment) falls - // through to the full-msgpack decoder, same as `decode_full`. - let other = if encoding == SketchEncoding::MsgpackDelta { - decode_cms_with_heap_from_msgpack_delta(bytes)? - } else { - decode_cms_with_heap_from_msgpack(bytes)? - }; - sk.merge(&other) - .map_err(|e| format!("merge CmsWithHeap delta: {e}")) - } - SummaryState::CountSketchWithHeap(sk) => { - let other = if encoding == SketchEncoding::MsgpackDelta { - decode_cs_with_heap_from_msgpack_delta(bytes)? - } else { - decode_cs_with_heap_from_msgpack(bytes)? - }; - sk.merge(&other) - .map_err(|e| format!("merge CountSketchWithHeap delta: {e}")) - } - } - } - - pub fn quantile(&self, q: f64) -> f64 { - match self { - SummaryState::Dd(sk) => sk.quantile(q).unwrap_or(0.0), - SummaryState::Kll(sk) => sk.quantile(q), - _ => 0.0, - } - } - - pub fn cardinality(&self) -> f64 { - match self { - SummaryState::Hll(sk) => sk.estimate(), - _ => 0.0, - } - } - - /// The bucket TOTAL — sum of row 0 of the underlying matrix. What a - /// bare `count_over_time`/`sum by (item) (rate(...))`-shaped query - /// (no specific item key) reads out. `0.0` for non-Frequency-family - /// states. - pub fn total(&self) -> f64 { - let matrix = match self { - SummaryState::Cms(c) => c.sketch(), - SummaryState::CountSketch(c) => c.sketch().clone(), - SummaryState::CmsWithHeap(h) => h.sketch_matrix(), - SummaryState::CountSketchWithHeap(h) => h.sketch_matrix(), - _ => return 0.0, - }; - matrix - .first() - .map(|row| row.iter().copied().sum::()) - .unwrap_or(0.0) - } - - /// Per-key point estimate — `count(metric{item="x"})`-shaped queries. - /// Unlike [`Self::topk_items`], no heap is needed: all four Frequency - /// variants (heap-bearing or not) already carry a keyed `estimate` - /// over their matrix. `None` for the quantile/cardinality states, - /// which have no item universe at all. - pub fn estimate(&self, key: &str) -> Option { - match self { - SummaryState::Cms(c) => Some(c.estimate(key)), - SummaryState::CountSketch(c) => Some(c.estimate(key)), - SummaryState::CmsWithHeap(h) => Some(h.estimate(key)), - SummaryState::CountSketchWithHeap(h) => Some(h.estimate(key)), - _ => None, - } - } - - /// Top-k `(key, value)` pairs from the heap, descending by value. - /// `None` for anything other than a heap-bearing state — the - /// heap-less Frequency states (`Cms`/`CountSketch`) carry no item - /// universe to enumerate, and the quantile/cardinality states have - /// no heap at all. - pub fn topk_items(&self) -> Option> { - match self { - SummaryState::CmsWithHeap(h) => Some( - h.topk_heap_items() - .into_iter() - .map(|item| (item.key, item.value)) - .collect(), - ), - SummaryState::CountSketchWithHeap(h) => Some( - h.topk_heap_items() - .into_iter() - .map(|item| (item.key, item.value)) - .collect(), - ), - _ => None, - } - } - - /// Merge `other` into `self` in place — both must be the same sketch - /// family. Used to combine several sids' reconstructed states - /// (`cumulative_summary_state`/`per_window_summary_states`) into one - /// cross-sid answer. `CmsWithHeap`/`CountSketchWithHeap` fall through - /// to the catch-all mismatch arm below like any other mixed pair — - /// and since the two variants now hold distinct sketchlib types - /// (`CountMinSketchWithHeap` vs `CountSketchWithHeap`), there is no - /// arm that could accidentally match them together — see their doc - /// on `DeltaSketchKind`. - pub fn merge_same_family(&mut self, other: &SummaryState) -> Result<(), String> { - match (self, other) { - (SummaryState::UnivMon(a), SummaryState::UnivMon(b)) => { - a.merge_in_place(b).map_err(|e| e.to_string()) - } - (SummaryState::Dd(a), SummaryState::Dd(b)) => { - a.merge(b).map_err(|e| format!("merge DDSketch: {e}")) - } - (SummaryState::Hll(a), SummaryState::Hll(b)) => { - a.merge(b).map_err(|e| format!("merge HLL: {e}")) - } - (SummaryState::Kll(a), SummaryState::Kll(b)) => { - a.merge(b).map_err(|e| format!("merge KLL: {e}")) - } - (SummaryState::Cms(a), SummaryState::Cms(b)) => { - a.merge(b).map_err(|e| format!("merge CountMinSketch: {e}")) - } - (SummaryState::CountSketch(a), SummaryState::CountSketch(b)) => { - a.merge(b).map_err(|e| format!("merge CountSketch: {e}")) - } - (SummaryState::CmsWithHeap(a), SummaryState::CmsWithHeap(b)) => { - a.merge(b).map_err(|e| format!("merge CmsWithHeap: {e}")) - } - (SummaryState::CountSketchWithHeap(a), SummaryState::CountSketchWithHeap(b)) => a - .merge(b) - .map_err(|e| format!("merge CountSketchWithHeap: {e}")), - (a, _) => Err(format!( - "SummaryState family mismatch in merge_same_family (self is {})", - a.family_name() - )), - } - } - - /// Diagnostic family name for error messages — not used for dispatch. - fn family_name(&self) -> &'static str { - match self { - SummaryState::UnivMon(_) => "UnivMon", - SummaryState::Dd(_) => "DDSketch", - SummaryState::Hll(_) => "Hll", - SummaryState::Kll(_) => "Kll", - SummaryState::Cms(_) => "Cms", - SummaryState::CountSketch(_) => "CountSketch", - SummaryState::CmsWithHeap(_) => "CmsWithHeap", - SummaryState::CountSketchWithHeap(_) => "CountSketchWithHeap", - } - } -} - -/// Fold every in-range window's frames for ONE series into a single -/// merged `SummaryState` (cumulative over `[t0, t1]`), returning `None` -/// if no Full frame ever landed (every sample was a leading delta). The -/// per-sid building block for a cross-sid answer: reconstruct each -/// candidate sid's state this way, then merge them (`merge_same_family`) -/// before reading out a quantile/cardinality over the combined data. -pub fn cumulative_summary_state( - samples: &[(i64, &SketchSampleState)], - kind: DeltaSketchKind, -) -> Result, String> { - let mut rolling: Option = None; - for (_window_end, state) in samples { - match state.encoding { - SketchEncoding::ProtoFull | SketchEncoding::MsgpackFull => { - let new_state = decode_full(&kind, &state.bytes, state.encoding)?; - rolling = Some(match rolling.take() { - None => new_state, - Some(mut prev) => { - prev.merge_same_family(&new_state)?; - prev - } - }); - } - SketchEncoding::ProtoDelta | SketchEncoding::MsgpackDelta => { - if rolling.is_none() { - rolling = Some(kind.bootstrap_empty()); - } - if let Some(rs) = rolling.as_mut() { - rs.apply_delta_bytes(&state.bytes, state.encoding)?; - } - } - } - } - Ok(rolling) -} - -#[cfg(test)] -/// Walk a sorted-by-window-end slice of samples in time order and -/// produce ONE per-window scalar `(window_end_ms, scalar)`. -/// -/// ## Per-window-reset (PWR) delta model -/// -/// The edge emits frames grouped by window (all frames of one window -/// share the same `window_end` key; the key changes across windows). -/// The edge RESETS its snapshot base at each window boundary, so each -/// window's state is built *from empty*: -/// -/// * Within a window, frames accumulate to the window total. The first -/// frame may be a `Full` (window 1, or a periodic re-snapshot) or a -/// `Delta`-from-empty (windows 2+ under PWR); subsequent frames are -/// `Delta` INCREMENTS applied onto the window's running base. -/// * Across windows, the base MUST reset — a new `window_end` discards -/// the previous window's rolling state and starts from empty. Never -/// carry one window's state into the next (that would inflate via -/// cross-window accumulation). -/// -/// Concretely this fixes two bugs in the old "single rolling Option that -/// only ever resets on a Full" walk: -/// 1. A query range whose Full lives only in window 1 (or out of -/// range) left windows 2+ as deltas with `rolling=None`, all -/// skipped → empty result. -/// 2. A window 2+ delta applied onto window 1's leftover rolling state -/// → cross-window inflation. -/// -/// For a `Delta` that is the window's FIRST frame (the PWR delta-from- -/// empty case), we bootstrap an EMPTY rolling state of `kind` and apply -/// the delta onto it (delta-from-empty ⊕ empty = that window's state). -/// -/// The delta-OFF path (exactly one `Full` per window) still produces one -/// correct value per window: the window opens with a Full, has no -/// further frames, and emits that Full's scalar. -/// -/// `eval` reads a scalar from the rolling state (`quantile(q)` / -/// `cardinality()`). `skipped` counts frames that could not contribute -/// (a delta we genuinely couldn't bootstrap from — should be rare). -/// -/// Returns `Ok(per_window_samples, skipped)`. -pub fn per_window_evaluate( - samples: &[(i64, &SketchSampleState)], - kind: DeltaSketchKind, - eval: E, -) -> Result<(Vec<(i64, f64)>, usize), String> -where - E: Fn(&SummaryState) -> f64, -{ - let (states, skipped) = per_window_summary_states(samples, kind)?; - Ok(( - states.into_iter().map(|(w, rs)| (w, eval(&rs))).collect(), - skipped, - )) -} - -/// Walk a sorted-by-window-end slice of samples in time order and -/// reconstruct ONE sid's per-window `SummaryState` (same per-window-reset -/// walk as [`per_window_evaluate`], generalized to return the -/// reconstructed state itself instead of an already-evaluated scalar). -/// The per-sid building block for cross-sid per-window merging (unlike -/// [`cumulative_summary_state`], which folds a whole `[t0, t1]` range -/// into one answer, this keeps each window separate so a caller can -/// merge same-window states across several sids before evaluating -- -/// needed for a matrix/range-query answer, where each output point is -/// itself a cross-sid merge for that one window). -/// -/// Returns `Ok((per_window_states, skipped))`. -pub fn per_window_summary_states( - samples: &[(i64, &SketchSampleState)], - kind: DeltaSketchKind, -) -> Result<(Vec<(i64, SummaryState)>, usize), String> { - let mut out: Vec<(i64, SummaryState)> = Vec::new(); - let mut skipped = 0usize; - - // Rolling state for the CURRENT window only. Reset to None whenever - // `window_end` changes (a new window establishes its own base from - // empty). `cur_end` tracks which window `rolling` belongs to. - let mut rolling: Option = None; - let mut cur_end: Option = None; - - for (window_end, state) in samples { - // Window boundary: flush the previous window's final accumulated - // state, then reset the base so this window starts from empty. - if cur_end != Some(*window_end) { - if let (Some(prev_end), Some(rs)) = (cur_end, rolling.take()) { - out.push((prev_end, rs)); - } - cur_end = Some(*window_end); - } - - match state.encoding { - SketchEncoding::ProtoFull | SketchEncoding::MsgpackFull => { - // A Full (re)sets this window's base. - rolling = Some(decode_full(&kind, &state.bytes, state.encoding)?); - } - SketchEncoding::ProtoDelta | SketchEncoding::MsgpackDelta => { - // Apply onto this window's running base. If this is the - // window's first frame (PWR delta-from-empty), bootstrap - // an empty base and apply onto it. - if rolling.is_none() { - rolling = Some(kind.bootstrap_empty()); - } - match rolling.as_mut() { - Some(rs) => rs.apply_delta_bytes(&state.bytes, state.encoding)?, - None => skipped += 1, - } - } - } - } - - // Flush the final window. - if let (Some(prev_end), Some(rs)) = (cur_end, rolling.take()) { - out.push((prev_end, rs)); - } - - Ok((out, skipped)) -} - -// --------------------------------------------------------------------------- -// Proto-envelope decoders — P2-4: ONE decoder per family. -// -// These delegate to the precompute-side accumulators' -// `from_sketchlib_proto_bytes`, which are the single source of truth for -// the modified-OTLP proto wire format (envelope unwrapping, alpha/k/ -// precision validation, and — critically for HLL — SPARSE -// `registers_sparse` expansion). Folding the warm read path onto the -// same decoder the ingest path uses means the sparse-register fix (and -// any future format change) can never drift between the two copies again -// — the bug class P2-3 / P2-4 closed. We extract the accumulator's -// public `inner` sketch for the rolling-state merge. -// --------------------------------------------------------------------------- - -fn dd_from_proto(buffer: &[u8]) -> Result { - use asap_physical_operators::accumulators::dd_sketch_accumulator::DDSketchAccumulator; - DDSketchAccumulator::from_sketchlib_proto_bytes(buffer) - .map(|acc| acc.inner) - .map_err(|e| e.to_string()) -} - -fn kll_from_proto(buffer: &[u8]) -> Result { - use asap_physical_operators::accumulators::datasketches_kll_accumulator::DatasketchesKLLAccumulator; - DatasketchesKLLAccumulator::from_sketchlib_proto_bytes(buffer) - .map(|acc| acc.inner) - .map_err(|e| e.to_string()) -} - -fn hll_from_proto(buffer: &[u8]) -> Result { - use asap_physical_operators::accumulators::hll_sketch_accumulator::HllSketchAccumulator; - HllSketchAccumulator::from_sketchlib_proto_bytes(buffer) - .map(|acc| acc.inner) - .map_err(|e| e.to_string()) -} - -/// Apply a proto-encoded `HllDelta` frame onto the HLL register vector — the -/// delta is a varint-packed (index_delta, value) blob; decode + apply -/// (register-wise max) via the shared sketch library so the unpacking stays a -/// single source of truth. -fn apply_hll_proto_delta(sk: &mut HllSketch, buffer: &[u8]) -> Result<(), String> { - sk.apply_delta_bytes(buffer) - .map_err(|e| format!("apply HLLDelta: {e}"))?; - Ok(()) -} - -#[cfg(test)] -mod tests { - //! P2-3 / P2-4 regression tests for the consolidated single-decoder - //! path. These exercise the family proto decoders that now delegate - //! to the precompute accumulators (the single source of truth), so a - //! divergence between the warm read path and the ingest path — - //! notably the SPARSE-register HLL handling the deleted dead decoder - //! got wrong — fails the build. - use super::*; - use asap_sketchlib::HllVariant; - - fn encode_dd(sk: &DdSketch) -> Vec { - use asap_sketchlib::proto::sketchlib::{sketch_envelope, DdSketchState, SketchEnvelope}; - use prost::Message; - let state = DdSketchState { - alpha: sk.alpha, - store_counts: sk.store_counts.clone(), - store_offset: sk.store_offset, - }; - SketchEnvelope { - sketch_state: Some(sketch_envelope::SketchState::Ddsketch(state)), - ..Default::default() - } - .encode_to_vec() - } - - fn encode_kll(k: u16, items: &[f64]) -> Vec { - use asap_sketchlib::proto::sketchlib::{sketch_envelope, KllState, SketchEnvelope}; - use prost::Message; - let state = KllState { - k: k as u32, - items: items.to_vec(), - levels: vec![], - num_levels: 0, - ..Default::default() - }; - SketchEnvelope { - sketch_state: Some(sketch_envelope::SketchState::Kll(state)), - ..Default::default() - } - .encode_to_vec() - } - - fn encode_hll_dense(sk: &HllSketch) -> Vec { - use asap_sketchlib::proto::sketchlib::{ - sketch_envelope, HllVariant as ProtoVariant, HyperLogLogState, SketchEnvelope, - }; - use prost::Message; - let state = HyperLogLogState { - variant: ProtoVariant::Regular as i32, - precision: sk.precision, - registers: sk.registers.clone(), - hip_kxq0: sk.hip_kxq0, - hip_kxq1: sk.hip_kxq1, - hip_est: sk.hip_est, - registers_sparse: None, - }; - SketchEnvelope { - sketch_state: Some(sketch_envelope::SketchState::Hll(state)), - ..Default::default() - } - .encode_to_vec() - } - - /// Build a SPARSE HLL proto frame: dense `registers` left empty, - /// `registers_sparse.packed` = varint (index_delta, value) pairs. - /// This is exactly the wire form a low-cardinality producer emits - /// (sketchlib-go below its dense/sparse crossover) — the frame the - /// DELETED `HllSketch_from_sketchlib_proto_bytes` hard-rejected with - /// "registers has 0 bytes". - fn encode_hll_sparse(precision: u32, nonzero: &[(u64, u8)]) -> Vec { - use asap_sketchlib::proto::sketchlib::{ - sketch_envelope, HllSparseRegisters, HllVariant as ProtoVariant, HyperLogLogState, - SketchEnvelope, - }; - use prost::Message; - // Varint-pack (index_delta, value), ascending index order. - let mut packed: Vec = Vec::new(); - let mut prev: u64 = 0; - let mut put_uvarint = |buf: &mut Vec, mut v: u64| loop { - let b = (v & 0x7f) as u8; - v >>= 7; - if v != 0 { - buf.push(b | 0x80); - } else { - buf.push(b); - break; - } - }; - let mut sorted = nonzero.to_vec(); - sorted.sort_by_key(|(i, _)| *i); - for (idx, val) in &sorted { - put_uvarint(&mut packed, idx - prev); - put_uvarint(&mut packed, *val as u64); - prev = *idx; - } - let state = HyperLogLogState { - variant: ProtoVariant::Regular as i32, - precision, - registers: Vec::new(), // dense field empty → sparse path - hip_kxq0: 0.0, - hip_kxq1: 0.0, - hip_est: 0.0, - // `num_registers` is informational — the decoder expands - // against `expected_len` from precision, not this field. - registers_sparse: Some(HllSparseRegisters { - num_registers: 1u32 << precision, - packed, - }), - }; - SketchEnvelope { - sketch_state: Some(sketch_envelope::SketchState::Hll(state)), - ..Default::default() - } - .encode_to_vec() - } - - #[test] - fn hll_from_proto_accepts_sparse_frame() { - // The consolidated decoder must accept the sparse wire form (the - // deleted dead decoder rejected it). Build a sparse frame setting - // a handful of registers, decode it, and confirm those register - // slots came back set in the dense array. - let precision = 12u32; - let nonzero = [(3u64, 5u8), (100, 2), (4000, 7)]; - let bytes = encode_hll_sparse(precision, &nonzero); - let sk = hll_from_proto(&bytes).expect("sparse HLL frame must decode (P2-3 regression)"); - assert_eq!(sk.registers.len(), 1usize << precision); - for (idx, val) in nonzero { - assert_eq!( - sk.registers[idx as usize], val, - "sparse register {idx} expanded to wrong value" - ); - } - } - - #[test] - fn hll_from_proto_matches_accumulator_decoder() { - // P2-4: the warm read path and the ingest accumulator must decode - // the SAME bytes to the SAME sketch (one source of truth). - use asap_physical_operators::accumulators::hll_sketch_accumulator::HllSketchAccumulator; - let mut sk = HllSketch::new(HllVariant::Regular, 12); - for i in 0..500u64 { - sk.update(format!("item-{i}").as_bytes()); - } - let bytes = encode_hll_dense(&sk); - let via_delta = hll_from_proto(&bytes).expect("delta_apply hll decode"); - let via_acc = HllSketchAccumulator::from_sketchlib_proto_bytes(&bytes) - .expect("accumulator hll decode") - .inner; - assert_eq!( - via_delta.registers, via_acc.registers, - "delta_apply and accumulator must produce identical HLL registers" - ); - assert!((via_delta.estimate() - via_acc.estimate()).abs() < 1e-9); - } - - #[test] - fn dd_from_proto_matches_accumulator_decoder() { - use asap_physical_operators::accumulators::dd_sketch_accumulator::DDSketchAccumulator; - let mut sk = DdSketch::new(0.01); - for v in [1.0, 2.0, 5.0, 5.0, 9.0, 42.0] { - sk.update(v); - } - let bytes = encode_dd(&sk); - let via_delta = dd_from_proto(&bytes).expect("delta_apply dd decode"); - let via_acc = DDSketchAccumulator::from_sketchlib_proto_bytes(&bytes) - .expect("accumulator dd decode") - .inner; - // Same quantile answers from the same bytes through both paths. - assert_eq!(via_delta.quantile(0.5), via_acc.quantile(0.5)); - assert_eq!(via_delta.quantile(0.99), via_acc.quantile(0.99)); - } - - #[test] - fn kll_from_proto_matches_accumulator_decoder() { - use asap_physical_operators::accumulators::datasketches_kll_accumulator::DatasketchesKLLAccumulator; - let items: Vec = (0..200).map(|i| i as f64).collect(); - let bytes = encode_kll(256, &items); - let via_delta = kll_from_proto(&bytes).expect("delta_apply kll decode"); - let via_acc = DatasketchesKLLAccumulator::from_sketchlib_proto_bytes(&bytes) - .expect("accumulator kll decode") - .inner; - assert_eq!(via_delta.quantile(0.5), via_acc.quantile(0.5)); - } - - // ----------------------------------------------------------------- - // Per-window-reset (PWR) delta-apply regression tests. - // - // The edge resets its snapshot base at every window boundary, so a - // window's first frame is either a Full (window 1 / re-snapshot) or - // a Delta-from-empty (windows 2+). The query-side walk must: - // * reset the rolling base when `window_end` changes, - // * bootstrap an empty base for a window's leading Delta, - // * emit ONE value per window (the window's final accumulated - // state), never per-frame and never cross-window-accumulated. - // ----------------------------------------------------------------- - - fn full(bytes: Vec) -> SketchSampleState { - SketchSampleState { - bytes, - encoding: SketchEncoding::ProtoFull, - } - } - fn delta(bytes: Vec) -> SketchSampleState { - SketchSampleState { - bytes, - encoding: SketchEncoding::ProtoDelta, - } - } - - fn dd_over(alpha: f64, vals: &[f64]) -> DdSketch { - let mut sk = DdSketch::new(alpha); - for &v in vals { - sk.update(v); - } - sk - } - - /// PWR across 3 windows: window 1 is `[Full]`, windows 2 & 3 are - /// `[Delta-from-empty]` (NO Full carry-in). Each window must - /// reconstruct its OWN distribution's median — not empty (the old - /// "skip delta with no base" bug) and not cross-window-inflated. - #[test] - fn pwr_ddsketch_three_windows_delta_from_empty() { - let alpha = 0.01; - let w1 = dd_over(alpha, &[1.0, 2.0, 3.0, 4.0, 5.0]); - let w2 = dd_over(alpha, &[10.0, 20.0, 30.0, 40.0, 50.0]); - let w3 = dd_over(alpha, &[100.0, 200.0, 300.0, 400.0, 500.0]); - - // window 1 ships a Full; windows 2+ ship a delta-from-empty. - let s1 = full(encode_dd(&w1)); - let s2 = delta(encode_dd(&w2)); - let s3 = delta(encode_dd(&w3)); - let samples = vec![(1000_i64, &s1), (2000, &s2), (3000, &s3)]; - - let kind = DeltaSketchKind::DDSketch { alpha }; - let (out, skipped) = - per_window_evaluate(&samples, kind, |rs| rs.quantile(0.5)).expect("pwr eval"); - assert_eq!(skipped, 0, "PWR must not skip delta-from-empty frames"); - assert_eq!(out.len(), 3, "one value per window"); - - // Each window's median ≈ that window's own distribution median, - // independent of the others (no carry-in inflation). - let truth = [ - w1.quantile(0.5).unwrap(), - w2.quantile(0.5).unwrap(), - w3.quantile(0.5).unwrap(), - ]; - for (i, (w_end, est)) in out.iter().enumerate() { - assert_eq!(*w_end, (i as i64 + 1) * 1000); - let rel = (est - truth[i]).abs() / truth[i].max(1e-9); - assert!( - rel < 0.05, - "window {i}: est={est} truth={} rel={rel}", - truth[i] - ); - } - // Cross-window-inflation guard: window 2's median must NOT have - // absorbed window 1 (would pull it well below 30). - assert!( - out[1].1 > 20.0, - "window 2 median {} suggests cross-window accumulation", - out[1].1 - ); - } - - /// Sub-window producer: a SINGLE window carries multiple frames - /// `[Full, Delta, Delta]`, where each later delta is an increment - /// since the previous emit in that window. The walk must COLLAPSE - /// them to ONE value = the window's running total, not emit three. - #[test] - fn pwr_ddsketch_subwindow_frames_collapse_to_window_total() { - let alpha = 0.01; - // Three sub-window increments that together cover 1..=15. - let a = dd_over(alpha, &[1.0, 2.0, 3.0, 4.0, 5.0]); - let b = dd_over(alpha, &[6.0, 7.0, 8.0, 9.0, 10.0]); - let c = dd_over(alpha, &[11.0, 12.0, 13.0, 14.0, 15.0]); - let s_a = full(encode_dd(&a)); - let s_b = delta(encode_dd(&b)); - let s_c = delta(encode_dd(&c)); - // All three share the same window_end (one window, sub-window frames). - let samples = vec![(5000_i64, &s_a), (5000, &s_b), (5000, &s_c)]; - - let kind = DeltaSketchKind::DDSketch { alpha }; - let (out, skipped) = - per_window_evaluate(&samples, kind, |rs| rs.quantile(0.5)).expect("subwindow eval"); - assert_eq!(skipped, 0); - assert_eq!(out.len(), 1, "sub-window frames collapse to ONE value"); - assert_eq!(out[0].0, 5000); - - let truth = dd_over(alpha, &(1..=15).map(|v| v as f64).collect::>()) - .quantile(0.5) - .unwrap(); - let rel = (out[0].1 - truth).abs() / truth.max(1e-9); - assert!(rel < 0.05, "window total est={} truth={truth}", out[0].1); - } - - /// Same sub-window collapse, but the window's FIRST frame is a - /// Delta-from-empty (PWR window 2+ with sub-window frames): - /// `[Delta-from-empty, Delta, Delta]`. - #[test] - fn pwr_ddsketch_subwindow_first_frame_delta_from_empty() { - let alpha = 0.01; - let a = dd_over(alpha, &[1.0, 2.0, 3.0, 4.0, 5.0]); - let b = dd_over(alpha, &[6.0, 7.0, 8.0, 9.0, 10.0]); - let c = dd_over(alpha, &[11.0, 12.0, 13.0, 14.0, 15.0]); - let s_a = delta(encode_dd(&a)); // first frame is delta-from-empty - let s_b = delta(encode_dd(&b)); - let s_c = delta(encode_dd(&c)); - let samples = vec![(9000_i64, &s_a), (9000, &s_b), (9000, &s_c)]; - - let kind = DeltaSketchKind::DDSketch { alpha }; - let (out, skipped) = - per_window_evaluate(&samples, kind, |rs| rs.quantile(0.5)).expect("eval"); - assert_eq!(skipped, 0); - assert_eq!(out.len(), 1); - let truth = dd_over(alpha, &(1..=15).map(|v| v as f64).collect::>()) - .quantile(0.5) - .unwrap(); - let rel = (out[0].1 - truth).abs() / truth.max(1e-9); - assert!(rel < 0.05, "est={} truth={truth}", out[0].1); - } - - /// PWR for HLL across 3 windows, each a Delta-from-empty (sparse - /// register delta). Bootstrapping an EMPTY HLL of the right precision - /// is required (register deltas index into a pre-sized array). Each - /// window's cardinality must reflect its OWN item set. - #[test] - fn pwr_hll_three_windows_delta_from_empty() { - let precision = 12u32; - // Build per-window HLLs, then encode each as a register-delta - // against an EMPTY sketch (= that window's full register state, - // the PWR delta-from-empty wire form). - let empty = HllSketch::new(HllVariant::Regular, precision); - let mut frames = Vec::new(); - let truths = [200usize, 800, 1500]; - for (w, &n) in truths.iter().enumerate() { - let mut sk = HllSketch::new(HllVariant::Regular, precision); - let base = (w as u64) * 100_000; // disjoint item sets per window - for i in 0..n as u64 { - sk.update(format!("u-{}", base + i).as_bytes()); - } - let bytes = sk.compute_delta(&empty, 0); - frames.push((((w as u64) + 1) * 1000, delta(bytes))); - } - let samples: Vec<(i64, &SketchSampleState)> = - frames.iter().map(|(t, s)| (*t as i64, s)).collect(); - - let kind = DeltaSketchKind::Hll { precision }; - let (out, skipped) = - per_window_evaluate(&samples, kind, |rs| rs.cardinality()).expect("hll pwr eval"); - assert_eq!(skipped, 0, "HLL delta-from-empty must bootstrap, not skip"); - assert_eq!(out.len(), 3); - for (i, (_w_end, est)) in out.iter().enumerate() { - let n = truths[i] as f64; - let rel = (est - n).abs() / n; - assert!( - rel < 0.15, - "window {i}: HLL est={est} truth={n} rel={rel} (each window independent)" - ); - } - } - - /// `CmsWithHeap` (min-over-rows estimator, `CountMinSketchWithHeap`) - /// and `CountSketchWithHeap` (median-of-signed-rows estimator, the - /// distinct `CountSketchWithHeap` type) are different sketch - /// algorithms that merely happen to share a storage shape — merging - /// one into the other must be rejected as a family mismatch, the - /// same as merging a `Cms` into a `Kll` would be. Since the two - /// `SummaryState` variants now hold genuinely different Rust types, - /// this is also enforced at compile time — there is no arm in - /// `merge_same_family` that type-checks a mixed pair together. - #[test] - fn cms_with_heap_and_count_sketch_with_heap_are_not_the_same_family() { - use asap_sketchlib::{CountMinSketchWithHeap, CountSketchWithHeap, MessagePackCodec}; - - let mut cms_heap = CountMinSketchWithHeap::new(4, 256, 10); - cms_heap.update("a", 1.0); - let mut cs_heap = CountSketchWithHeap::new(4, 256, 10); - cs_heap.update("b", 1.0); - - let mut a = SummaryState::CmsWithHeap( - CountMinSketchWithHeap::from_msgpack(&cms_heap.to_msgpack().unwrap()).unwrap(), - ); - let b = SummaryState::CountSketchWithHeap( - CountSketchWithHeap::from_msgpack(&cs_heap.to_msgpack().unwrap()).unwrap(), - ); - - match a.merge_same_family(&b) { - Err(msg) => assert!( - msg.contains("family mismatch"), - "expected a family-mismatch error, got: {msg}" - ), - Ok(()) => panic!( - "CmsWithHeap must not merge with CountSketchWithHeap -- \ - different algorithms sharing only a storage shape" - ), - } - } - - fn encode_delta_heap( - rows: u32, - cols: u32, - cells: &[(u32, u32, i64)], - heap: &[(&str, f64)], - heap_size: u64, - ) -> Vec { - #[derive(serde::Serialize)] - struct W<'a>( - bool, - (u32, u32, &'a [(u32, u32, i64)]), - Vec<(String, f64)>, - u64, - ); - let heap_owned: Vec<(String, f64)> = - heap.iter().map(|(k, v)| (k.to_string(), *v)).collect(); - let w = W(true, (rows, cols, cells), heap_owned, heap_size); - rmp_serde::to_vec(&w).expect("encode delta-heap") - } - - /// `SummaryState::CountSketchWithHeap` must decode both FULL and - /// DELTA-HEAP msgpack frames through the genuine - /// `asap_sketchlib::CountSketchWithHeap` (median-of-signed-rows - /// estimator) rather than the CMS-family `CountMinSketchWithHeap` - /// (min-over-rows estimator) it used to alias — the bug this split - /// fixed. Built via real `update()` calls (not a hand-crafted matrix) - /// so the sign-hashed row semantics are genuinely exercised, then - /// checks both decode paths reproduce the same matrix and the same - /// `estimate()` as the in-memory sketch they were encoded from. - #[test] - fn count_sketch_with_heap_full_and_delta_decode_via_new_asap_sketchlib_type() { - use asap_sketchlib::{CountSketchWithHeap, MessagePackCodec}; - - let mut built = CountSketchWithHeap::new(4, 64, 10); - for _ in 0..50 { - built.update("k", 1.0); - } - let expected_matrix = built.sketch_matrix(); - let expected_estimate = built.estimate("k"); - - // FULL path. - let full_bytes = built.to_msgpack().expect("encode full CountSketchWithHeap"); - let full_state = decode_full( - &DeltaSketchKind::CountSketchWithHeap { - rows: 4, - cols: 64, - heap_size: 10, - }, - &full_bytes, - SketchEncoding::MsgpackFull, - ) - .expect("decode_full CountSketchWithHeap"); - match full_state { - SummaryState::CountSketchWithHeap(inner) => { - assert_eq!(inner.sketch_matrix(), expected_matrix); - assert_eq!(inner.estimate("k"), expected_estimate); - } - other => panic!( - "expected CountSketchWithHeap state, got {}", - other.family_name() - ), - } - - // DELTA-HEAP path: same cells + heap against an empty base (PWR - // contract), encoded the way the Go producer does. - let cells: Vec<(u32, u32, i64)> = expected_matrix - .iter() - .enumerate() - .flat_map(|(r, row)| { - row.iter().enumerate().filter_map(move |(c, v)| { - if *v != 0.0 { - Some((r as u32, c as u32, *v as i64)) - } else { - None - } - }) - }) - .collect(); - let heap_pairs: Vec<(String, f64)> = built - .topk_heap_items() - .into_iter() - .map(|item| (item.key, item.value)) - .collect(); - assert!(!heap_pairs.is_empty(), "expected \"k\" in the top-k heap"); - let heap_refs: Vec<(&str, f64)> = - heap_pairs.iter().map(|(k, v)| (k.as_str(), *v)).collect(); - let delta_bytes = encode_delta_heap(4, 64, &cells, &heap_refs, 10); - - let mut rolling = DeltaSketchKind::CountSketchWithHeap { - rows: 4, - cols: 64, - heap_size: 10, - } - .bootstrap_empty(); - rolling - .apply_delta_bytes(&delta_bytes, SketchEncoding::MsgpackDelta) - .expect("apply CountSketchWithHeap delta"); - match rolling { - SummaryState::CountSketchWithHeap(inner) => { - assert_eq!( - inner.sketch_matrix(), - expected_matrix, - "delta path must reconstruct the identical matrix" - ); - assert_eq!(inner.estimate("k"), expected_estimate); - } - other => panic!( - "expected CountSketchWithHeap state, got {}", - other.family_name() - ), - } - } -} +//! Storage uses the Planner-owned state implementation. +pub use asap_physical_operators::stored_state::delta_apply::*; diff --git a/docs/design_docs/query-dag-execution.md b/docs/design_docs/query-dag-execution.md index f2e93060..cb48f67a 100644 --- a/docs/design_docs/query-dag-execution.md +++ b/docs/design_docs/query-dag-execution.md @@ -1,546 +1,136 @@ -# QueryPlan DAG execution +# Shared physical DAG execution -Audience: system designers and deployment implementers. +## Decision and ownership -This document describes how the backend executes an installed `QueryPlan`. -The [plan split](asapplanner-integration.md) defines why query-time work is -separate from ingestion-time work, and the -[SDS contract](summary-catalog-sds-architecture.md) defines the stored records -read by the plan. +ASAPPlanner owns `asap-physical-operators`, its independent DAG runtime and the +post-ASAP IR that defines its computation contract. Precompute engine and query +engine consume the same library. Changes to an IR operation and its execution +implementation can be reviewed in one Planner PR. -## Runtime contract +The backend owns source binding, window selection, catalog and storage access, +publication, and protocol conversion. It does not maintain copies of the shared +runtime or generic computation algorithms. JSON and Arrow conversion at the SQL +boundary does not make Arrow the internal representation of every summary. -The physical compiler publishes one `QueryPlanEntry` for each installed query. -An entry contains a root, typed nodes, dependency IDs, evaluation-time rules, -and an explicit fallback policy. The query engine executes the nodes reachable -from that root. It does not parse the incoming expression into another runtime -program and does not execute `selected_dags`; those documents retain Planner -semantic provenance for validation and debugging. +The [shared library design](https://github.com/ProjectASAP/ASAPPlanner/blob/feat/shared-physical-operators/docs/design_docs/physical-operators.md) +contains the architecture and DataFusion comparison. DataFusion provides mature +Arrow operators; ASAP's independent runtime directly owns shared producers and +custom summary state formats. Those states need not fit Arrow RecordBatch. -The request first resolves a canonical query identity in the active, immutable -physical-plan snapshot. The Summary Catalog, PrecomputePlan and QueryPlan in -that snapshot have the same plan ID and version. A lookup miss is a capability -miss and follows the installed routing policy. - -```mermaid -flowchart LR - Request[Query request] --> Lookup[Find installed query physical plan] - Lookup --> Root[Identify query result operation] - Root --> Walk[Find required physical operations] - Walk --> Inputs[Obtain operation inputs] - Inputs --> Execute[Execute physical operation] - Execute --> Memo[Cache result within this request] - Memo --> Result[Return query result] - Store[Stored materializations] --> Read[Read materialization] - Read --> Inputs - External[Declared external computation] --> Inputs -``` - -The installed query physical plan is represented by `QueryPlanEntry`; its `root` -identifies the operation producing the query result. Required input operations -execute before their consumers. The execution adapter invokes the implementation -of each physical operation, and request-local caching avoids repeated evaluation -of shared dependencies. Operations evaluated at multiple query times are cached -separately for each evaluation time. - -“Read materialization” is the operation listed in the coverage table. It obtains -stored state from `SummaryStore` through a `StoredOutputReference`. Declared -external computation supplies an explicitly bound input; it is not a local -physical operation implementation. +## Execution model +A plan is a DAG of typed operations. A producer can have multiple consumers and +executes once per run. Each consumer sees its output independently. The runtime +owns dependency scheduling, bounded buffering, cancellation and request-local +caching of intermediate results. Separate query evaluation times and ingestion +windows have separate execution state. -Installation rejects missing inputs, cycles, unreachable nodes, invalid output -bindings, unsupported provenance versions, and a reader whose window contract -or `StoredOutputReference` differs from its PrecomputePlan writer. Runtime -errors retain the query ID and node ID. - -## Shared DAG execution - -**Decision: ASAP owns an independently implemented shared DAG runtime and -physical operators. Both engines use the same runtime. DataFusion is a design -reference, not the execution framework.** - -The shared library executes a physical operator DAG for both the precompute -engine and the query engine. It is designed around that execution contract, -not around the backend's function or module boundaries. The independent runtime -and native batch operators are implemented. Both installed query execution and -ingestion execution use this runtime; their existing storage and value adapters -are still being replaced by native batch operator bindings. +Every computation operation can execute at ingestion time or query time. +The phase belongs to the plan, not the operator kind. Ingestion time includes +background work triggered by incoming data. Sources and publication remain +engine responsibilities; phase assignment alone does not provide a missing +source or implementation. ```mermaid -flowchart TB - Planner[Post-ASAP physical plan] --> Bind[Bind and validate physical operators] - Bind --> DAG[Executable physical operator DAG] - Precompute[Precompute engine] -->|Ingestion inputs and windows| Run[Shared DAG execution library] - Query[Query engine] -->|Query inputs and evaluation time| Run - DAG --> Run - Run --> States[States and materialized results] - Run --> Results[Query results] +flowchart LR + Request[Query request] --> Entry[Installed QueryPlanEntry] + Entry --> Bind[Bind shared physical DAG] + Bind --> Run[Execute dependencies and physical operators] + Run --> Result[Convert root output to query response] + Store[SummaryStore] -->|StoredOutputReference| Bind + Exact[Declared external exact source] --> Bind + Planner[post-ASAP physical DAG contract] --> Bind ``` -The intended boundary is that the compiler binds each operation to a concrete -implementation and checks its input and output types before accepting the plan. -Both engines execute the result through the same library. Computation semantics -belong in that library; replacing the remaining backend adapters with native -operator bindings is still required to complete this boundary. - -An operation defines its computation, typed inputs and outputs, and requirements -such as input ordering and grouping. An execution instance owns the changing -state for one run. Keeping the plan separate from running state allows the same -plan to serve concurrent queries and ingestion windows without sharing mutable -accumulators accidentally. - -Operations consume and produce batches incrementally where their semantics -allow it. Filter and Project can emit results as batches arrive. Sorting a -complete group must wait until that group's input is complete. Grouped Limit -counts across batches, not separately within each batch. For ingestion, the -engine supplies window completion; for a query, the engine supplies the -requested input range. These requirements do not make an operator exclusive to -one execution phase. - -### Engine responsibilities - -| Component | Responsibility | -| --- | --- | -| Shared DAG execution library | Physical operator implementations, typed expressions, state construction/merge/readout, dependency execution, shared intermediate results, cancellation, and execution resource accounting | -| Precompute engine | Connect ingestion sources, assign data to the intended windows, supply completion signals, and persist or restore operator state and materialized results | -| Query engine | Bind request parameters and evaluation times, connect stored or declared external inputs, invoke the shared DAG, and format results | -| Deployment integration | Supply source/sink implementations, storage, scheduling resources, and durability policy | - -The library does not depend on either engine. Another deployment such as -asap-fusion can bind its own sources and sinks to the same physical operators. -Every computation operator may run at ingestion time or query time; the plan -chooses when, and the engine supplies the appropriate inputs and execution scope. - -### Shared dependencies - -A DAG can have several consumers of the same operation. The execution library -must represent that shared identity explicitly. Reusing a plan object alone does -not establish that its computation runs once. - -Within a query, **request-local caching of intermediate results** prevents -repeated evaluation of a shared dependency. Time-dependent results are separated -by evaluation time. During ingestion, sharing is scoped to the same execution -and input window. Results from distinct requests or windows are never mixed. - -For streaming output, the runtime delivers the same produced batches to each -consumer. Buffering is bounded and participates in the execution memory budget; -a slow consumer cannot cause unlimited retention. Cancelling one consumer does -not stop a producer still needed by another. Cancelling the whole execution wakes consumers; the next poll or dropping the -execution releases producer streams and queued results. Already delivered values -remain owned and accounted for until their consumers release them. - -### Implemented execution boundaries - -An execution runs on the caller's worker; the library does not create a thread -pool. Its streams may await deployment I/O, and every consumer of a shared -producer must be polled concurrently. It currently uses worker-local execution -state, so a running execution cannot migrate between threads. Separate runs have -separate producers, buffers and accumulators. - -Each producer has a configurable batch-buffer limit. Outputs and native blocking -state use a configurable estimated byte budget, including values retained by a -consumer after queue eviction. This is execution accounting, not a hard process -RSS limit: source-owned input, temporary allocation peaks and allocator overhead -are outside the guarantee. Sort, exact aggregation and semi-join currently -materialize their inputs and fail when the budget is exceeded; they do not spill. -Summary construction updates its accumulators incrementally. Plan depth is -limited to 128 to bound recursive stream polling. - -A native post-ASAP binding rejects unknown operations, unsupported expressions, -invalid parameters and incompatible schemas before starting sources. Deployments -explicitly bind storage or ingestion frontiers; those bindings do not authorize -a local raw Scan. The native binder covers a subset of Planner, and the installed -backend binder remains separate while its value adapters are migrated. Neither -binder may count external execution as native operator coverage. - -The shared-library foundation is #770. #763 integrates precompute execution; -#765 integrates query execution. The [library design](physical-operators.md) -defines the common boundary and compares DataFusion reuse with independent -implementation. A storage adapter converts deployed values to native batches; -it does not reimplement the operation. Native batch chains use the surrounding -run's resource and cancellation context. - -## Shared physical operator library - -The library's unit of composition is an executable physical operator. Each -operator exposes its input dependencies, output schema, execution requirements, -and a way to start execution. Filter, Project, Aggregate, Join, Sort, Limit, -and summary operations participate in this same contract. - -Typed scalar expressions are separate from operations over batches. A literal, -negation, or comparison can be evaluated inside Project or Filter without -inventing a separate DAG node for every expression. Where Planner represents a -standalone scalar-producing operation, it uses the same expression semantics. -All accepted value types and nullability rules follow Planner's contract. - -Existing mathematical algorithms may supply internal kernels, but their current -backend wrappers do not define the new operator API. Moving helper functions -into a crate is not sufficient: both engines must instantiate and execute the -shared operators through the shared DAG runtime. Removed backend-specific -execution paths must not survive as compatibility branches. - -## DataFusion reuse vs. independent implementation - -The alternatives are to build on DataFusion's execution framework and general -operators, adding ASAP-specific operators, or to implement ASAP's own DAG runtime -and physical operators. This design selects independent implementation. - -| Concern | Reuse DataFusion | Independent ASAP implementation | -| --- | --- | --- | -| General computations | Reuse existing expressions, projection, filtering, joins, sorting, and aggregation where their semantics match Planner | Implement and test the supported operations and expression semantics against Planner's contract | -| Execution model | Adopt its physical-plan interfaces and batch streams; integrate ASAP-specific execution requirements | Define node identity, typed edges, execution instances, and multi-consumer behavior as the library's core contract | -| Shared dependencies | Shared references to a plan object do not by themselves guarantee shared execution; additional coordination is needed | One producer execution per node, input partition, and evaluation scope, with explicit result delivery to all consumers | -| Summary state | Supply custom accumulators/operators and integrate the required state lifecycle | Treat summary construction, updates, merge, readout, snapshots, and restoration as native operator capabilities | -| Ingestion and query execution | Adapt both engines to DataFusion while adding ASAP's window and persistence behavior | Use the same runtime and operators in both engines; engines supply their inputs, execution scope, and persistence integration | -| Resource management | Reuse framework facilities where applicable, while accounting for ASAP-specific state and sharing | Implement bounded buffering, memory accounting, backpressure, cancellation, and cleanup | -| Dependencies and maintenance | Accept DataFusion/Arrow interface and version constraints | Own the execution API and its maintenance; accept greater implementation and verification work | - -DataFusion's -[ExecutionPlan interface](https://docs.rs/datafusion/latest/datafusion/physical_plan/trait.ExecutionPlan.html) -represents input dependencies through shared plan references and starts execution -by returning a batch stream. This permits shared references in the plan -representation; it does not establish a general execute-once guarantee for a -producer with multiple consumers. ASAP's decision is therefore not based on a -claim that DataFusion cannot represent a shared node. It is based on making -shared execution and the summary-state lifecycle explicit parts of ASAP's own -runtime contract. - -ASAP needs both a finite query execution and ingestion execution over successive -windows. The same summary producer may feed several computations or sinks. -The runtime must coordinate those consumers without duplicating updates, -mixing evaluation times, or cancelling work still needed elsewhere. Persisted -state also requires explicit snapshot and restoration semantics. DataFusion's -[Accumulator interface](https://docs.rs/datafusion/latest/datafusion/logical_expr/trait.Accumulator.html) -provides update, merge, and result operations, but its intermediate-state export -can consume state; that interface alone is not an ingestion checkpoint protocol. - -Independent implementation gives ASAP direct control over these behaviors and -keeps the shared library usable by other deployments. The cost is substantial: -ASAP must implement and test the general operators, type and null semantics, -stream lifecycle, and resource controls rather than assume a framework supplies -them. The coverage table must continue to report incomplete implementations. - -DataFusion remains a reference for separating immutable operator definitions -from execution state, batch-stream processing, typed -[physical expressions](https://docs.rs/datafusion/latest/datafusion/physical_expr/trait.PhysicalExpr.html), -and operator input/output requirements. Existing sketch algorithms and suitable -low-level libraries may be reused internally. This does not authorize retaining -the current backend executor as a second execution path. Choosing a batch memory -format is separate from choosing the DAG runtime; independence does not require -reimplementing every buffer or mathematical primitive. - -Acceptance of the new runtime must include a shared producer with two consumers, -consumers progressing at different rates, cancellation of one consumer, failure -propagation, and isolation between query times and ingestion windows. Stateful -operators must also survive snapshot and restoration without applying a committed -input twice. The execute-once guarantee within one execution does not by itself -prove correct recovery after a restart. These tests complement operator result -checks and must run through both engines' integration with the shared library. +The installed plan carries deployment bindings and source references. Planner +operations retain Planner types, parameters, expressions and grouping semantics. +SQL relation nodes bind through the shared Planner binder and execute in one +shared DAG. PromQL bindings convert labeled vectors and windows to native +batches; common arithmetic, aggregation, sorting, limiting and temporal +computation execute in the library. Metric-name presentation and protocol +matching remain deployment bindings. ## Physical operator coverage and acceptance contract -Coverage describes this PR and the immutable Planner revision in `Cargo.toml`. -**The shared DAG runtime and the native operator subset below are implemented. -The backend does not yet meet the universal local-execution contract.** -An exhaustive phase match proves ownership only. A reusable kernel proves an -algorithm implementation exists; neither proves that a concrete installed plan -can obtain its inputs and execute every node locally. - -The target contract is: compilation accepts a local query plan only if every -reachable operator has a concrete implementation for its parameters, input and -output schemas/states, grouping, time scope and placement. Raw-only, partial -precomputation and full precomputation must obey the same contract. An explicitly -external plan may remain useful, but is not evidence of local coverage. - -### Physical operation coverage +An accepted local plan must have an implementation for every reachable +operation, including its types, parameters, grouping and state requirements. +Relation binding checks the same native implementations at installation and +execution. Unsupported operations must be rejected explicitly. Declaring an +external source is a separate deployment choice, not evidence of local support. -A physical operation defines **what computation happens**. The plan chooses -**ingestion time or query time**. This applies to every computation operation; -ingestion time includes background processing of arriving data. Current adapter -gaps are implementation work, not permanent restrictions on execution time. - -The table lists each operation once, regardless of whether the code represents -it inside `ValueOperation`, `Logical`, or another wrapper. “Backend integration” -means an implemented path for the stated subset, not universal support. +This table lists each operation once. Shared computation can be used in either +phase; the backend column identifies the available deployment bindings. | Physical operation | Purpose | Shared library implementation | Backend integration | Missing coverage | | --- | --- | --- | --- | --- | -| Raw Scan | Read raw input rows | None | Rejected in installed local query plans | Local raw source; deferred from this PR | -| Read materialization | Load previously computed state | State decoding kernels; no storage adapter | Catalog/store binding for compatible populations and windows | General raw input access; unavailable or incompatible state cannot be read | -| Maintain current-series state | Update the maintained values and timestamps for incoming time series. | None | Specialized remote-write ingestion path | General table-row updates and shared implementation | -| Read current-series state | Read values from the maintained time-series state for query execution. | None | Current-series readout using the installed state identity and capacity | Arbitrary raw-table reading | -| Scalar | Produce a scalar value | Typed native source, including nullable values | Both installed query evaluators use the native scalar source | General ingestion literal binding | -| Binary | Combine or compare two inputs | Native matching-type Int64/Float64 arithmetic expressions, checked integer arithmetic, comparisons and boolean expressions; Float64 arithmetic kernels | Query arithmetic, CheckedDiv/FiniteDiv and comparisons; ingestion arithmetic on immutable completed, aligned rows | General coercion, arbitrary PromQL matching and unsupported value domains | -| Unary negate | Negate a value | Native Int64/Float64 expression with null propagation and checked integer overflow | Query scalar/vector values use native Project with Negate | General ingestion expression binding | -| Vector to scalar | Convert a vector to a scalar | Native Float64 batch operator; zero or multiple rows produce NaN | Query value adapter uses the native batch operator | General ingestion value binding | -| Exact aggregate | Compute Count/Sum/Avg/Min/Max, including ReduceSum | Native grouped batches: checked Int64 Sum, Float64 Sum/Avg, Int64 Count, ordered Min/Max, nullable inputs; exact state kernels | Query grouped aggregation uses the native batch operator; relation adapter remains separate | Native relation binding; no universal AggIntent, per-entity or unresolved grouping-without binding; blocking execution has no spill | -| Finalize exact accumulator / ExactReadout | Obtain an exact result from typed state | Native validated readout for the six supported exact families; Int64 Count and Float64 numeric results | Native ingestion finalization and typed stored-state query readout | Native query batch binding; arbitrary state conversion and other exact families | -| Project | Select or calculate output columns | Native typed expressions and batch projection; Planner plain scalar and collection values are preserved | Query relation adapter | Complete expression vocabulary and installed native batch binding | -| Filter | Keep rows satisfying a predicate | Native batch predicate evaluation; three-valued boolean logic, equality/less-than, null checks | Query relation adapter | Other predicates, coercions and installed native batch binding | -| Relational join, including semi-join | Match rows by a predicate; semi-join retains matching left rows | Native batch semi-join with explicit matching columns; value order and left multiplicity preserved; other joins remain backend-local | Relation adapter supports inner/left/right/full/cross/semi/anti joins within its predicate/schema subset; vector candidate pruning currently uses a dedicated membership adapter; general semi-join replacement pending | General ingestion join adapter and unrestricted SQL/NULL semantics | -| Sort | Order input rows or values | Native stable grouped sort with null placement; NaN follows numeric values | Query value sorting uses native Sort; relation adapter remains separate | Native relation binding; unsupported key types and spill-to-disk | -| Limit | Keep a bounded slice of input | Native offset/limit per group across batches; global Limit stops consuming after its slice | Query grouped ranking uses native Sort followed by grouped Limit; relation adapter remains separate | Planner grouped-Limit transport and native relation binding; Limit does not rank or match candidate keys | -| Union | Combine input streams with the same schema | Native stream union; polls all inputs | Native Planner binding uses it for multi-input summary merge | General installed batch binding | -| SummaryAgg | Construct summary state from input | Native incremental grouped builder for exact Sum/Count/Min/Max/Rate/Increase, KLL, DDSketch and HLL; non-null Float64 updates, timestamped counters | Completed-window ingestion DAG uses the native builder; raw ingestion uses shared per-window updaters | Query builder binding; Int64 updates, keyed updates and other families in the native batch interface | -| SummaryMerge | Combine compatible summary states | Native grouped state merge; multiple input streams compose through Union; family and parameters checked | Ingestion DAG uses native state merge; stored-query adapter remains separate | Native query batch binding; cross-family conversion is not a merge | -| SummaryEstimate | Query a summary for an approximate result | Native KLL quantile, DDSketch quantile/count and HLL cardinality/count; parameters checked before execution | Typed stored-state readout | Other family/readout combinations in native batches; window/population compatibility and accuracy evidence remain required | -| SummaryJoin | Combine summary inputs using summary join semantics | No registered kernel | No runtime dispatch | Concrete kernel and adapters | -| SummarySubtract | Subtract summary state | No registered kernel | Unsupported in ingestion runtime | Concrete kernel and adapters | -| SummaryDelete | Remove contributions from summary state | No registered kernel | No runtime dispatch | Concrete kernel and adapters | -| Temporal computation | Compute Rate/Increase/Avg/Max/Min/Sum/Count over time | Relevant exact accumulator kernels, not a complete temporal adapter | Query paths over supported inputs | General input/state combinations and ingestion adapter | -| Histogram quantile | Calculate a quantile from histogram buckets | No separate histogram adapter | Query path | General ingestion adapter | -| Subquery | Evaluate an expression over a time grid | Shared DAG execution and request-local caching of intermediate results | Backend expands each operation and evaluation time into a node in the shared runtime; bounded grids and explicit source frontiers | Shared time-grid construction and general ingestion adapter | -| Extension | Execute an additional value operation | No general executor | Unsupported operations may route to explicit fallback | A concrete local implementation for each admitted extension | - -Grouped TopK is represented in the target plan as Sort followed by Limit within -each group. The native library supports this composition. The query adapter already executes this composition. The installed TopK -plan representation must still be replaced, and Planner Limit needs an explicit grouping -contract. A global Limit is not equivalent. An -optimized kernel may execute the composition without changing its meaning. -Candidate completeness remains a condition on pruning, not on ranking. - -The current-series operations maintain and read a set of time series and their -current values; they do not provide a general raw-table scan or full historical -read. Their code names are `MaintainPopulation` for updates and `ReadPopulation` -/ `CurrentSeries` for reads. - -External computation (`ExternalExact`, `ExactSubquery`, -`CandidateExactSubquery`) and fallback are routing choices, not local physical -operation implementations. They do not fill any missing coverage in this table. -The shared runtime owns dependency execution, bounded batch delivery and -request-local caching of intermediate results. Installed-plan adapters still -provide some computation semantics; the table identifies these migration gaps. -Importing the library does not provide backend sources or a complete query engine. - -**Deferred raw-data support:** this PR does not implement local raw Scan or -claim complete local execution when only raw data is stored. External fallback -and independent DAG tests with supplied batches do not satisfy that capability. - -### Candidate pruning is a composed subgraph - -The fused candidate-ranking operator is removed from Planner and QueryPlan. -The target graph uses a general semi-join in place of the current dedicated -`MembershipFilter` adapter. That code change is pending separately from this -runtime implementation. The graph contains these operations: - -1. Read membership keys from a summary. -2. Obtain authoritative values, optionally pushing the membership restriction - into an explicitly bound external request. -3. Apply a general semi-join with explicit matching keys that preserves value-row order and - multiplicity. Membership scores never replace authoritative values. -4. Sort authoritative values and apply Limit independently within each group. - -The semi-join has no k, grouping or ranking behavior. The native DAG library -implements semi-join, grouped Sort and grouped Limit as composable operators. -Installed vector adapters still use a dedicated membership binding and TopK plan representation; -replacing their plan representation and bindings remains separate work. A missing authoritative value fails a -certified membership plan; best-effort pruning remains explicitly approximate. -The pruning certificate stays on the semi-join. Exact reranking does not prove -that omitted keys could not have won. Planner still rejects uncertified pruning -for an exact request. - -External expression binding verifies the selected exact subtree against its -native expression; it does not substitute the original top-level TopK child. -Planner represents and costs filtering and ranking separately. Incompatible -installed plans are rejected; removed operators have no compatibility path. - -### Summary-family and readout coverage - -The native batch interface currently admits exact Sum/Count/Min/Max/Rate/Increase, -KLL, DDSketch and HLL states. The broader low-level factory inventory below does -not imply native DAG bindings for every listed family. - -The Planner-family factory accepts only `PerSubpopulationInstance` grouping and -matching family/parameter variants. The presence of a low-level accumulator does -not automatically register a Planner binding. Supported kernels expose update, -compatible-state merge and family-specific query operations; decoding, window -coverage and readout compatibility still require the backend adapter. - -| Family / algorithm | Factory admission | Intended readout and restrictions | -| --- | --- | --- | -| Exact Sum, Count, Min, Max | Supported matching ExactParams | Corresponding exact readout; keyed/scalar update shape must match | -| Exact Increase, Rate | Supported matching ExactParams | Counter/time-aware readout; not plain scalar sum/division semantics | -| Exact IRate | Unsupported | No matching factory/readout binding | -| KLL | k in 8..65535 | Quantile | -| DDSketch | finite 0 < alpha < 1 | Quantile; backend continuous-percentile adapter uses interpolated readout | -| HLL | precision 4..18, local Regular HLL implementation | Cardinality; kernel availability does not supply an accuracy/failure-probability proof | -| CMS, CountSketch | Positive width/depth, checked allocation size | PointCount: supported key/value shape or sample-total readout; no heap TopK | -| CMSWithHeap, CountSketchWithHeap | Same matrix checks plus positive heap size | PointCount and ranked heap TopK; approximate membership is not guaranteed complete exact TopK | -| UnivMon | Positive heap/columns, rows 1..20, layers 1..64, checked dimensions | Cardinality, FrequencyL2, FrequencyEntropy and sample-total PointCount; no general keyed readout | -| KMV, Theta | Unsupported | Planner algorithm existence is not runtime support | -| Plain, Sample, Wavelet, StatModel | Not summary-factory kernels | Plain rows may be relation values, not a SummaryAgg accumulator | -| Shared grouping / Hydra KLL | Not admitted by current Planner-family factory | Low-level Hydra code exists; no installed shared-grouping coverage claim | - -All six SketchQuery variants are accounted for: Quantile, Cardinality, -PointCount, TopK, FrequencyL2 and FrequencyEntropy. They are family-specific, -not a Cartesian product with every sketch. PointCount requires the supported -SampleValue/None or named/qualified-key/Some(value) shape; heapless sketches -cannot enumerate TopK. Native matrix construction checks are distinct from -packed-wire decoder limits. The SummaryAgg capability check does not validate -all subsequent readout combinations or certify approximation guarantees. - -### Precomputation boundary: present status and required acceptance - -| Precomputation mode | Definition | Current support / remaining gaps | +| Raw Scan | Read raw input rows | Engines can supply typed batch sources | Local raw Scan is rejected | Local raw source is explicitly deferred | +| Read materialization | Load previously computed state | Shared decoding and delta reconstruction | Catalog/store references, population and window checks | Missing or incompatible state is a runtime error | +| Maintain current-series state | Update values and timestamps of maintained time series | No general table update operation | Remote-write storage path | General row updates; this is not a raw-table executor | +| Read current-series state | Read maintained current values | Typed source interface | Installed identity/capacity and time scope | Arbitrary raw-table reading | +| Scalar | Produce a scalar | Typed source, including nullable values | Query scalar source | General ingestion literal binding | +| Binary | Combine or compare values | Planner numeric/comparison kinds, typed expressions and checked-division domains | Query native Project; ingestion uses shared arithmetic kernels | Arbitrary vector matching and unsupported domains | +| Unary negate | Negate a value | Int64/Float64 expression, null propagation and checked integer overflow | Query native Project | General ingestion expression binding | +| Vector to scalar | Convert an instant vector to a scalar | Native Float64 operator; zero or multiple rows yield NaN | Query native batch binding | General ingestion value binding | +| Exact aggregate | Compute Count/Sum/Avg/Min/Max, including ReduceSum | Native grouped aggregation; checked Int64 Sum, Float64 Sum/Avg, Int64 Count, ordered Min/Max and nullable inputs | Query vector and SQL relation bindings use shared computation | Other aggregate intents, unresolved grouping-without and per-entity relation lowering; no spill | +| Finalize exact accumulator / ExactReadout | Obtain an exact result from state | Shared state merge/finalization and typed batch readout | Ingestion finalization and stored-query readout use shared kernels | Arbitrary state conversion and unsupported exact families | +| Project | Select or compute output columns | Planner scalar expressions and native projection | SQL native DAG; vector expression binding | Unsupported expression functions/types | +| Filter | Keep rows satisfying a predicate | Planner predicates, three-valued boolean logic and native filtering | SQL native DAG | Unsupported predicates/coercions | +| Relational join, including semi-join | Match rows; semi-join retains matching left rows | Inner/left/right/full/cross/semi/anti joins; typed predicates and nullable outer outputs | SQL native DAG; candidate vectors use general semi-join with explicit keys | General ingestion source binding; SQL candidate-pruning source binding | +| Sort | Order rows or values | Stable grouped sort with null placement | Query vector and SQL native Sort | Sort expressions must be projected first; unsupported key types; no spill | +| Limit | Keep a bounded slice per group | Grouped offset/limit across batches | Planner grouped Limit; query vector and SQL native binding | Limit does not rank or match candidate keys | +| Union | Combine compatible input streams | Fair polling of native input streams | Multi-input summary merge | General installed batch-source binding | +| SummaryAgg | Construct summary state | Native builders for exact Sum/Count/Min/Max/Rate/Increase, KLL, DDSketch and HLL; other admitted families have shared update kernels | Ingestion DAG and per-window update paths | General query builder binding; native Int64/keyed updates and other batch families | +| SummaryMerge | Combine compatible summary states | Native grouped merge and shared stored-state merge kernels | Ingestion native DAG; query selects panes and invokes shared state kernels | Cross-family conversion is not a merge; not every stored encoding has a native batch binding | +| SummaryEstimate | Read an approximate result | Shared Planner SketchQuery dispatch; native KLL/DDSketch/HLL batch readout | Stored-state query readout uses shared kernels | Unsupported family/readout combinations; accuracy evidence remains required | +| SummaryJoin | Combine summaries using summary join semantics | No registered implementation | Rejected | Concrete semantics and implementation | +| SummarySubtract | Subtract summary state | No registered implementation | Rejected | Concrete semantics and implementation | +| SummaryDelete | Remove state contributions | No registered implementation | Rejected | Concrete semantics and implementation | +| Temporal computation | Compute Rate/Increase/Sum/Avg/Min/Max/Count over a window | Native window operator using Planner intents; reset-aware rates, Int64 Count | Query range vectors bind to native batches | Other temporal intents; general ingestion window binding | +| Histogram quantile | Interpolate a quantile from histogram buckets | Native window operator using Planner HistogramQuantile intent | Query bucket input binds to native batches | General ingestion histogram source binding | +| Subquery | Evaluate an expression over a time grid | Shared DAG execution and request-local caching of intermediate results | Backend binds a bounded grid of operation/evaluation-time nodes | General ingestion time-grid binding | +| Extension | Execute an additional operation | No universal executor | Unsupported extensions are rejected | A registered implementation for each admitted extension | + +Stored-state codecs and readout kernels are library capabilities; they do not +imply that every family has a native batch builder. Parameter, schema, population +and window compatibility remain mandatory. Availability of an approximate +kernel does not prove its accuracy guarantee. + +## Candidate pruning and grouped ranking + +Candidate pruning is a composed subgraph: + +1. Read candidate keys from a summary. +2. Obtain authoritative values from a declared source. +3. Apply a general semi-join on explicit matching keys. +4. Sort by score and apply Limit independently within each group. + +There is no dedicated MembershipFilter or grouped TopK physical operator. +A global Limit is not a grouped Limit. Candidate completeness belongs to the +pruning certificate; exact scoring and sorting cannot prove that an omitted key +would not have won. Missing authoritative values fail certified pruning. +Best-effort pruning remains explicitly approximate. + +## Precomputation boundary + +| Precomputation mode | Definition | Backend acceptance | | --- | --- | --- | -| No precomputation | The query starts from raw data and performs all required computation at query time. | Native DAGs can construct and query summaries from supplied batches. Installed plans still need native builder bindings; the local raw source is deferred from this PR. | -| Partial precomputation | The query reuses previously computed results or states and performs the remaining computation at query time. Inputs may combine stored states, stored values, and raw data. | Supported stored-state and query-time operations can be combined. General plans requiring local raw input or query-time summary construction remain incomplete. | -| Full precomputation | All data-dependent computation needed for the query result has been performed before the query arrives. Query execution retrieves the prepared result and formats the response. | Supported only where the prepared result matches the requested query and time scope and is available. Reading stored summaries followed by merging, estimation, aggregation or ranking is partial precomputation. | - -These definitions are independent of any particular algorithm. The KLL consumer -tests are examples of constructing, merging, and querying state across different -precomputation boundaries. They execute native operator DAGs as well as reusable kernels, not -complete backend support for all three modes. In particular, a test that queries -a prebuilt KLL state still performs estimation at query time; it does not -demonstrate full precomputation of the query result. - -The remaining work below describes the target contract, not a requirement to -implement local raw Scan in this PR. Planner must express valid query-time summary -placement; the backend must bind local raw inputs and query-time builders; and -installation must check every reachable operator against concrete adapter -capabilities, including expressions, family/readout combinations and edge states. -SummaryJoin/Subtract/Delete require actual defined implementations or explicit -compile-time rejection in local plans. External execution must be declared as a -different deployment capability, not silently counted as local support. - -Acceptance must execute the same supported query under all three placements with -external forwarding disabled, check results against the raw exact computation -(and declared approximation guarantees where applicable), and reject unsupported -operators/parameters at installation. Include grouped/temporal, empty/missing -window, mixed-state compatibility and query-time summary cases. No percentage -coverage or universal executability is claimed until these tests exist and pass. - -### Evidence and verification limits - -Shared-library tests cover native DAGs, shared producers, backpressure, cancellation, -resource accounting, isolated runs, typed expressions, grouped Sort/Limit, semi-join, -summary construction/merge/readout at both phases, source schema validation and -unsupported binding rejection. KLL examples cover all three state-input boundaries. -Kernel tests additionally cover invalid KLL parameters and native CountSketch dimensions. Backend tests cover supported DAG, -maintenance, readout and relation paths. Passing these suites is not a proof that -every Planner payload or parameter combination is locally executable. Inherited -level-1 grouped-Sum/quantile-ratio failures and #759's strict local-execution gate -remain unresolved; external exact success does not satisfy that gate. - -## StoredSummary reads - -A `ReadMaterialization` carries the exact `StoredOutputReference` published for -its writer. V1 derives its output ID from the definition ID because the current -store index is definition keyed. The read proceeds as follows: - -1. Validate the output reference and its definition identity. -2. Use the active catalog generation and definition to select only visible - stored summaries. A previous generation is visible only after an explicit - compatible-definition decision during activation. -3. Reject unpublished input, a request outside the bound pane/full-window - phase, missing required panes, or an unavailable population. -4. Check the installed catalog descriptor against the requested readout family. -5. Decode payloads using their stored descriptor and merge only compatible - states according to the plan's grouping contract. - -The query engine performs an ID lookup in the SummaryStore index. It does not -search the catalog at serving time for an alternative summary. Instance -metadata and payload are one logical `StoredSummary`; the physical -`SummaryStateReference` used by the storage engine is not a QueryPlan edge. - -Readiness is conservative. Until the store represents completed empty panes, -a missing additive pane is not assumed to be zero. A coverage or format miss -therefore triggers the entry's installed fallback behavior instead of returning -a partial accelerated answer. - -## Dependency ordering and reuse - -Each evaluation derives only the sub-DAG reachable from the requested root. -Dependencies complete before their consumer. Request-local caching of intermediate results makes a diamond -graph execute its shared node once within that evaluation. - -PromQL subqueries are the exception to a cache key containing only the operation identity. The same -node has a different result at each evaluation timestamp, so their cache key is -`(node_id, evaluation_time)`. Repeated access at the same timestamp reuses the -value. Prepared external leaves use the same identity and are issued before -local query-time evaluation so network I/O does not hide inside a synchronous -operator. - -The cache is request local. It is discarded after the root result is adapted; -the SummaryStore is the cross-request reuse boundary. A summary revision fence -prevents a response from combining payloads changed during one evaluation. - -## Exact work and fallback - -An `ExternalExact` node is an ordinary typed DAG leaf. Its expression, output -shape, parameters and input contracts are installed with the plan. The engine -may prepare it through Prometheus, MetricsQL or ClickHouse only when forwarding -is enabled. Candidate-dependent leaves first evaluate their installed candidate -sub-DAG and pass the resulting membership set to the exact request. - -Fallback is not an implicit parser retry inside the executor. An -`ExactFallback` node fails deliberately, and the entry's `FallbackPolicy` -determines whether the router may call the exact backend. Invalid graphs, -incompatible stored state and unsupported nodes fail closed with a scoped -reason. - -## Shared KLL example - -Two installed query entries can read one precomputed output while keeping -different query roots: - -```mermaid -flowchart LR - subgraph P[PrecomputePlan] - Samples[Latency samples] --> KLL[Build KLL by service] - KLL --> Write[Write output latency-kll] - end - Write --> Store[(StoredSummary records)] - subgraph Q50[QueryPlanEntry p50] - Read50[Read latency-kll] --> P50[Quantile 0.50] - end - subgraph Q99[QueryPlanEntry p99] - Read99[Read latency-kll] --> P99[Quantile 0.99] - end - Store --> Read50 - Store --> Read99 -``` - -`Read50`, `Read99` and `Write` carry the same `StoredOutputReference`. Each -request reads the ready population/window records and executes only its own -readout sub-DAG. No query rebuilds the KLL and no serving-time catalog search -chooses a different summary. - -Within one entry, two parents may also share a read or relational node. The -request-local cache returns its existing value to the second parent. Across the -p50 and p99 requests, payload reuse comes from SummaryStore rather than a -cross-request executor cache. - -## Concurrency, cancellation and limits - -Requests execute concurrently and own separate result caches and intermediate -values. The active physical plan and committed stored summaries are shared -through immutable snapshots or synchronized store indexes. No mutable execution -context is shared between requests. - -The current backend evaluates ready nodes sequentially inside one request. Independent requests -still run concurrently. Parallel execution of independent nodes is unnecessary -for correctness and remains future work. External I/O uses the request client's -timeout; cancellation drops the request-local evaluation and its prepared -values. Query-time subqueries enforce depth and evaluation budgets to bound memory -and work. - -Large relation intermediates and a unified execution resource budget remain follow-up work. The current implementation also does not cache -root results across requests, add a distributed query scheduler, or reuse stored -payloads across plan versions without the activation-time compatibility check. - -## End-to-end sequence - -1. The control plane installs and activates one coherent physical plan. -2. The serving endpoint canonicalizes the request only to find its installed - entry; it does not compile a new execution graph. -3. The engine validates the entry against the active catalog generation. -4. Declared external leaves are prepared when allowed. -5. The target shared runtime executes the reachable physical operator DAG with - request-local caching of intermediate results. -6. `ReadMaterialization` nodes resolve their bound ready stored summaries. -7. Node failures carry query/node context and follow the installed fallback - policy. -8. A revision fence confirms that stored input did not change during execution. -9. The root value is adapted to the Prometheus/MetricsQL or ClickHouse response. +| No precomputation | Start from raw data and perform all required computation at query time | Deferred until local raw sources and query-time summary construction are bound | +| Partial precomputation | Reuse stored results or states and compute the remaining query work at query time | Supported stored-state/value DAGs; plans requiring local raw input remain deferred | +| Full precomputation | All data-dependent computation of the query result is completed before the request | Retrieve a prepared result matching the requested query and time scope | + +These definitions are algorithm-independent. KLL tests are examples, not the +definition of any mode. Reading a prebuilt KLL state and estimating its quantile +at query time is partial precomputation of the result. + +## Acceptance + +Library tests exercise shared-producer diamonds, multiple consumers, +backpressure, cancellation, memory accounting, both execution phases, typed +expressions, joins, grouped Sort/Limit and window computations. Backend tests +exercise installed source bindings, stored-state reconstruction, query results, +and rejection of unsupported plans. Tests compare computations against exact +results or declared approximation guarantees as appropriate. + +Local raw Scan and a universal implementation of every Planner aggregate or +extension are not part of this migration. They must not be presented as complete +through fallback routing or by tests supplied with already decoded raw batches. From 08f475b8aac15dc6613d82544cc6f6a4e145ae8e Mon Sep 17 00:00:00 2001 From: zz_y Date: Wed, 23 Sep 2026 23:18:59 +0000 Subject: [PATCH 25/26] fix: share storage frontier execution within query DAG scope --- .../asap_clickhouse_query_engine/execution.rs | 72 +++--- .../asap_query_engine/post_asap_readout.rs | 236 +++++++++++++++--- docs/design_docs/query-dag-execution.md | 3 +- 3 files changed, 244 insertions(+), 67 deletions(-) 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 fc30357d..0cb2451c 100644 --- a/data_plane/src/query_engines/asap_clickhouse_query_engine/execution.rs +++ b/data_plane/src/query_engines/asap_clickhouse_query_engine/execution.rs @@ -204,10 +204,43 @@ impl RelationDagExecutor<'_> { .map_err(|e| e.to_string())?; } validation.validate(&[root.0]).map_err(|e| e.to_string())?; + let context = dag::RunContext::new( + dag::Scope::Query { + evaluation_time_ms: i64::try_from(self.t1_ms) + .map_err(|_| "evaluation time overflow")?, + revision: self.index.summary_update_revision().mutation_sequence(), + }, + dag::Limits::default(), + ) + .map_err(|e| e.to_string())?; + let mut storage_roots = BTreeSet::new(); + for source in &sources { + if !matches!( + self.entry.nodes[source], + QueryPlanNode::ExternalExact { .. } + ) { + storage_roots.insert(*source); + for id in self + .entry + .topological_order_from(*source) + .map_err(|e| e.to_string())? + { + if matches!( + self.entry.nodes[&id], + QueryPlanNode::ReadMaterialization { .. } + ) { + storage_roots.insert(id); + } + } + } + } + let stored = crate::query_engines::asap_query_engine::post_asap_readout::execute_query_plan_readouts( + self.index,self.entry,&storage_roots.into_iter().collect::>(),self.t0_ms,self.t1_ms,self.is_cumulative,context.clone(), + ).map_err(|e|format!("incomplete leaf coverage: {e:?}"))?; let mut coverage = None; let mut first = true; for id in sources { - let relation = self.execute_source(id, &schemas[&id])?; + let relation = self.execute_source(id, &schemas[&id], &stored)?; coverage = if first { first = false; relation.coverage @@ -229,15 +262,6 @@ impl RelationDagExecutor<'_> { ) .map_err(|e| e.to_string())?; } - let context = dag::RunContext::new( - dag::Scope::Query { - evaluation_time_ms: i64::try_from(self.t1_ms) - .map_err(|_| "evaluation time overflow")?, - revision: self.index.summary_update_revision().mutation_sequence(), - }, - dag::Limits::default(), - ) - .map_err(|e| e.to_string())?; let mut output = graph .execute(&[root.0], context) .map_err(|e| e.to_string())? @@ -258,6 +282,10 @@ impl RelationDagExecutor<'_> { &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, .. }) => { @@ -282,15 +310,7 @@ impl RelationDagExecutor<'_> { .ok_or_else(|| "published external exact leaf was not prepared".into()) } Some(_) => { - let outcome = execute_query_plan_from_readout( - self.index, - self.entry, - root, - self.t0_ms, - self.t1_ms, - self.is_cumulative, - ) - .map_err(|error| format!("incomplete leaf coverage: {error:?}"))?; + let outcome = stored.get(&root).ok_or("missing bound storage frontier")?; let reachable = self .entry .topological_order_from(root) @@ -305,15 +325,9 @@ impl RelationDagExecutor<'_> { _ => None, }) { - let leaf_outcome = execute_query_plan_from_readout( - self.index, - self.entry, - leaf_id, - self.t0_ms, - self.t1_ms, - self.is_cumulative, - ) - .map_err(|error| format!("incomplete leaf coverage: {error:?}"))?; + 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, @@ -331,7 +345,7 @@ impl RelationDagExecutor<'_> { } ClickHouseRelation::from_series_rows( expected_schema, - outcome.series, + outcome.series.clone(), outcome.coverage, ) .map_err(|error| error.to_string()) 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 0a3c5465..037c18eb 100644 --- a/data_plane/src/query_engines/asap_query_engine/post_asap_readout.rs +++ b/data_plane/src/query_engines/asap_query_engine/post_asap_readout.rs @@ -671,15 +671,20 @@ impl PhysicalOperator for BoundQueryOperator<'_, '_> { .boxed_local()) } } -fn execute_bound_query( +fn execute_bound_queries( entry: &asap_types::query_plan::QueryPlanEntry, - root: QueryNodeId, + roots: &[QueryNodeId], runtime: &PhysicalQueryRuntime<'_>, - revision: u64, -) -> Result { - let order = entry - .topological_order_from(root) - .map_err(|e| dag::Error::Invalid(e.to_string()))?; + 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]; @@ -689,6 +694,26 @@ fn execute_bound_query( BoundQueryOperator { id, node, runtime }, )?; } + let outputs = graph.execute( + &roots.iter().map(|root| root.0).collect::>(), + context, + )?; + futures::executor::block_on(futures::future::try_join_all(outputs.into_iter().map( + |mut output| async move { + output + .next() + .await + .ok_or_else(|| dag::Error::Operator("query root produced no value".into()))? + .map(|output| output.value().clone()) + }, + ))) +} +fn execute_bound_query( + entry: &asap_types::query_plan::QueryPlanEntry, + root: QueryNodeId, + runtime: &PhysicalQueryRuntime<'_>, + revision: u64, +) -> Result { let context = dag::RunContext::new( dag::Scope::Query { evaluation_time_ms: i64::try_from(runtime.context.t1_ms) @@ -697,13 +722,7 @@ fn execute_bound_query( }, dag::Limits::default(), )?; - let mut root = graph.execute(&[root.0], context)?.remove(0); - futures::executor::block_on(async { - root.next() - .await - .ok_or_else(|| dag::Error::Operator("query root produced no value".into()))? - .map(|output| output.value().clone()) - }) + Ok(execute_bound_queries(entry, &[root], runtime, context)?.remove(0)) } fn execute_physical_query_payload( @@ -731,29 +750,7 @@ fn execute_physical_query_payload( .map_err(|error| { LoweringSkip::ExecuteFailed(format!("query {}: {error}", entry.query_id)) })?; - 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 }) - } - } + readout_outcome(output, t1_ms) })(); if !revision.matches(index.summary_update_revision()) { return Err(LoweringSkip::ExecuteFailed( @@ -804,6 +801,85 @@ pub(crate) fn fold_coverage(coverage: &mut Option<(u64, u64)>, next: Option<(u64 }); } +fn readout_outcome( + output: PhysicalQueryOutput, + t1_ms: u64, +) -> Result { + match output { + PhysicalQueryOutput::Scalar(_) => Err(LoweringSkip::ExecuteFailed( + "scalar-only query is not a warm vector result".into(), + )), + PhysicalQueryOutput::Value(values, coverage) => { + let mut series = Vec::new(); + for (group_key, value) in &values { + series.extend(summary_value_to_series(group_key, value)); + } + Ok(PostAsapReadoutOutcome { series, coverage }) + } + PhysicalQueryOutput::State { groups, .. } => { + let mut coverage = None; + let mut series = Vec::new(); + for (group_key, state) in &groups { + if let GroupState::Sketch { entries, .. } = state { + for entry in entries { + for time in entry.samples.keys() { + let time = (*time).max(0) as u64; + fold_coverage(&mut coverage, Some((time, time))); + } + } + } else { + fold_coverage(&mut coverage, state.exact_coverage()); + } + if let Some(value) = state.exact_value(&None) { + series.push((group_key.clone(), vec![(t1_ms as i64, value)])); + } + } + Ok(PostAsapReadoutOutcome { series, coverage }) + } + } +} + +/// Evaluate all storage frontiers in one run so common dependencies are shared. +pub(crate) fn execute_query_plan_readouts( + index: &SketchStore, + entry: &asap_types::query_plan::QueryPlanEntry, + roots: &[QueryNodeId], + t0_ms: u64, + t1_ms: u64, + is_cumulative: bool, + context: dag::RunContext, +) -> Result, LoweringSkip> { + if roots.is_empty() { + return Ok(BTreeMap::new()); + } + let revision = index.summary_update_revision(); + let runtime = PhysicalQueryRuntime { + language: entry.language, + catalog: index.summary_catalog_snapshot(), + context: QueryExecutionContext { + index, + t0_ms, + t1_ms, + is_cumulative, + allowed_materializations: None, + }, + }; + let output = execute_bound_queries(entry, roots, &runtime, context) + .map_err(|e| LoweringSkip::ExecuteFailed(e.to_string()))?; + let result = roots + .iter() + .copied() + .zip(output) + .map(|(id, value)| readout_outcome(value, t1_ms).map(|value| (id, value))) + .collect(); + if !revision.matches(index.summary_update_revision()) { + return Err(LoweringSkip::ExecuteFailed( + "summary input changed during query DAG evaluation".into(), + )); + } + result +} + #[cfg(test)] mod tests { use super::*; @@ -811,6 +887,92 @@ mod tests { use asap_types::query_plan::{ExactReadout, PhysicalGrouping, QueryReadout}; use planner_types::pre_asap::ArithmeticOpKind; + // A stored sketch frontier exposes pane coverage without treating it as exact state. + #[test] + fn sketch_frontier_retains_coverage_for_relation_binding() { + use crate::storage_engines::sketch_db::{ + data::SketchTimeSeries, query::delta_apply::DeltaSketchKind, + }; + let state = GroupState::Sketch { + kind: DeltaSketchKind::Hll { precision: 10 }, + entries: vec![std::rc::Rc::new(SketchTimeSeries { + sid: 1, + series_label_values: BTreeMap::new(), + samples: BTreeMap::from([(1000, vec![]), (2000, vec![])]), + })], + }; + let outcome = readout_outcome( + PhysicalQueryOutput::State { + groups: vec![(BTreeMap::new(), state)], + item_labels: vec![], + }, + 2000, + ) + .unwrap(); + assert_eq!(outcome.coverage, Some((1000, 2000))); + assert!(outcome.series.is_empty()); + } + + // Multiple storage frontiers share one run and inherit parent cancellation. + #[test] + fn multi_root_readout_uses_one_parent_context() { + let entry = asap_types::query_plan::QueryPlanEntry { + language: asap_types::query_plan::QueryLanguage::PromQl, + query_id: "shared".into(), + canonical_query: "1+1".into(), + fixed_evaluation: None, + root: QueryNodeId(1), + nodes: BTreeMap::from([ + (QueryNodeId(0), QueryPlanNode::Scalar { value: 1. }), + ( + QueryNodeId(1), + QueryPlanNode::Binary { + operator: ArithmeticOpKind::Add, + inputs: [QueryNodeId(0), QueryNodeId(0)], + }, + ), + ]), + instant: asap_types::query_plan::InstantExecution { + lookback_ms: 0, + full_history: false, + cumulative_readout: false, + }, + fallback: asap_types::query_plan::FallbackPolicy::Reject, + }; + let index = SketchStore::new(); + let runtime = PhysicalQueryRuntime { + language: entry.language, + catalog: None, + context: QueryExecutionContext { + index: &index, + t0_ms: 0, + t1_ms: 1, + is_cumulative: false, + allowed_materializations: None, + }, + }; + let context = dag::RunContext::new( + dag::Scope::Query { + evaluation_time_ms: 1, + revision: 1, + }, + dag::Limits::default(), + ) + .unwrap(); + let outputs = execute_bound_queries( + &entry, + &[QueryNodeId(1), QueryNodeId(0)], + &runtime, + context.clone(), + ) + .unwrap(); + assert!( + matches!(outputs.as_slice(),[PhysicalQueryOutput::Scalar(a),PhysicalQueryOutput::Scalar(b)] if *a==2. && *b==1.) + ); + context.cancel(); + assert!(execute_bound_queries(&entry, &[QueryNodeId(1)], &runtime, context).is_err()); + } + fn exact_points(metric: &str, service: &str, value: f64) -> PhysicalQueryOutput { PhysicalQueryOutput::Value( vec![( diff --git a/docs/design_docs/query-dag-execution.md b/docs/design_docs/query-dag-execution.md index cb48f67a..2183aea6 100644 --- a/docs/design_docs/query-dag-execution.md +++ b/docs/design_docs/query-dag-execution.md @@ -45,7 +45,8 @@ flowchart LR The installed plan carries deployment bindings and source references. Planner operations retain Planner types, parameters, expressions and grouping semantics. SQL relation nodes bind through the shared Planner binder and execute in one -shared DAG. PromQL bindings convert labeled vectors and windows to native +shared DAG. Their storage frontiers execute together as multiple roots, sharing +upstream work and the enclosing cancellation and memory budget. PromQL bindings convert labeled vectors and windows to native batches; common arithmetic, aggregation, sorting, limiting and temporal computation execute in the library. Metric-name presentation and protocol matching remain deployment bindings. From bba134e3619cd651255fc01cf70e300559c33edd Mon Sep 17 00:00:00 2001 From: zz_y Date: Wed, 23 Sep 2026 23:38:10 +0000 Subject: [PATCH 26/26] fix: accept Planner integer score columns in query sorting --- control_plane/src/query_plan.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/control_plane/src/query_plan.rs b/control_plane/src/query_plan.rs index 0a1eb1a5..a35de2b5 100644 --- a/control_plane/src/query_plan.rs +++ b/control_plane/src/query_plan.rs @@ -386,6 +386,7 @@ where child.schema.fields.get(column).map(|field| &field.dtype), Some(SummaryFamilyType::Plain( planner_types::pre_asap::DataType::Float64 + | planner_types::pre_asap::DataType::Int64 )) | Some(SummaryFamilyType::ExactAggregate(..)) ) { return Err(QueryPlanError::Invalid(