From 8776a4adcfc9af68e420e981e6a0dde6a2e571b8 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sat, 26 Sep 2026 18:29:32 -0400 Subject: [PATCH 01/44] test(query-engine): characterize native range leaf execution --- .../tests/e2e_precompute_equivalence.rs | 82 +++++++++++++++++-- 1 file changed, 73 insertions(+), 9 deletions(-) diff --git a/asap-query-engine/tests/e2e_precompute_equivalence.rs b/asap-query-engine/tests/e2e_precompute_equivalence.rs index fd671b15..848c5c57 100644 --- a/asap-query-engine/tests/e2e_precompute_equivalence.rs +++ b/asap-query-engine/tests/e2e_precompute_equivalence.rs @@ -155,7 +155,7 @@ fn engine_config() -> PrecomputeEngineConfig { } } -struct PromqlPrecomputeFixture<'a> { +struct NativeDagScenario<'a> { port: u16, metric: &'a str, query: &'a str, @@ -166,8 +166,8 @@ struct PromqlPrecomputeFixture<'a> { base_interval_ms: u64, } -impl PromqlPrecomputeFixture<'_> { - async fn run(self) -> QueryResult { +impl NativeDagScenario<'_> { + async fn build_engine(self) -> (SimpleEngine, String) { let aggregation_ids: Vec = self .aggregation_configs .iter() @@ -233,10 +233,16 @@ impl PromqlPrecomputeFixture<'_> { QueryLanguage::promql, ); + (query_engine, self.query.to_string()) + } + + async fn run(self) -> QueryResult { + let evaluation_time_seconds = self.evaluation_time_seconds; + let (query_engine, query) = self.build_engine().await; query_engine - .handle_query_promql(self.query.to_string(), self.evaluation_time_seconds) + .handle_query_promql(query.clone(), evaluation_time_seconds) .expect("native query execution should not fail") - .unwrap_or_else(|| panic!("precomputed query should succeed: {}", self.query)) + .unwrap_or_else(|| panic!("precomputed query should succeed: {query}")) .1 } } @@ -362,7 +368,7 @@ async fn e2e_promql_sum_uses_open_closed_evaluation_window() { .into_iter() .map(|(timestamp_ms, value)| make_timeseries(metric, vec![], timestamp_ms, value)) .collect(); - let result = PromqlPrecomputeFixture { + let result = NativeDagScenario { port, metric, query, @@ -382,6 +388,64 @@ async fn e2e_promql_sum_uses_open_closed_evaluation_window() { assert_eq!(vector.values[0].value, 5.0); } +/// A native leaf must give the same value at the end of a range query as an +/// instant query at that timestamp. This is the baseline that the DAG +/// executor must preserve during the cutover. +#[tokio::test] +async fn e2e_native_leaf_range_matches_instant_at_range_end() { + let port = 19408u16; + let agg_id = 8u64; + let window_size_ms = 1_000u64; + let metric = "dag_requests"; + let query = "sum(dag_requests)"; + let scenario = NativeDagScenario { + port, + metric, + query, + aggregation_configs: vec![make_agg_config( + agg_id, + metric, + AggregationType::Sum, + "", + window_size_ms, + 0, + vec![], + )], + schema_labels: vec![], + samples: vec![ + make_timeseries(metric, vec![], 1_000, 100.0), + make_timeseries(metric, vec![], 1_500, 2.0), + make_timeseries(metric, vec![], 2_000, 3.0), + make_timeseries(metric, vec![], 3_500, 0.0), + ], + evaluation_time_seconds: 2.0, + base_interval_ms: window_size_ms, + }; + + let (engine, query) = scenario.build_engine().await; + let (_, instant) = engine + .handle_query_promql(query.clone(), 2.0) + .expect("instant native query should succeed"); + let (_, range) = engine + .handle_range_query_promql(query, 1.0, 2.0, 1.0) + .expect("range native query should succeed"); + + let QueryResult::Vector(instant) = instant else { + panic!("expected instant vector result"); + }; + let QueryResult::Matrix(range) = range else { + panic!("expected range vector result"); + }; + assert_eq!(instant.values.len(), 1); + assert_eq!(range.values.len(), 1); + let final_sample = range.values[0] + .samples + .last() + .expect("range result should contain the end timestamp"); + assert_eq!(final_sample.timestamp, 2_000); + assert_eq!(final_sample.value, instant.values[0].value); +} + /// The #698 boundary contract applies independently to a query's value and /// key precomputes. An endpoint series can only appear when both sides assign /// its sample to the window ending at the evaluation timestamp. @@ -430,7 +494,7 @@ async fn e2e_promql_count_uses_open_closed_value_and_key_windows() { .into_iter() .map(|(timestamp_ms, host)| make_timeseries(metric, vec![("host", host)], timestamp_ms, 1.0)) .collect(); - let result = PromqlPrecomputeFixture { + let result = NativeDagScenario { port, metric, query, @@ -498,7 +562,7 @@ async fn e2e_quantile_over_time_uses_open_closed_evaluation_window() { .into_iter() .map(|(timestamp_ms, value)| make_timeseries(metric, vec![], timestamp_ms, value)) .collect(); - let result = PromqlPrecomputeFixture { + let result = NativeDagScenario { port, metric, query, @@ -550,7 +614,7 @@ async fn e2e_sliding_query_uses_open_closed_boundaries_without_double_counting() .into_iter() .map(|(timestamp_ms, value)| make_timeseries(metric, vec![], timestamp_ms, value)) .collect(); - let result = PromqlPrecomputeFixture { + let result = NativeDagScenario { port, metric, query, From b263b0adf84aba32e61756e7cbc355c0c489f7a0 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sat, 26 Sep 2026 18:38:53 -0400 Subject: [PATCH 02/44] refactor(query-engine): validate native query DAG dependencies --- asap-query-engine/src/engines/query_plan.rs | 63 ++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/asap-query-engine/src/engines/query_plan.rs b/asap-query-engine/src/engines/query_plan.rs index 4499d6ad..85bebca7 100644 --- a/asap-query-engine/src/engines/query_plan.rs +++ b/asap-query-engine/src/engines/query_plan.rs @@ -125,7 +125,33 @@ impl QueryPlan { }, ); } - Ok(Self { nodes, root }) + let plan = Self { nodes, root }; + plan.validate()?; + Ok(plan) + } + + /// Rejects plans whose node dependencies cannot be executed safely. + pub(crate) fn validate(&self) -> Result<(), String> { + if self.nodes.is_empty() { + return Err("Query plan has no nodes".to_string()); + } + if self.root.0 != self.nodes.len() - 1 { + return Err(format!( + "Query plan root n{} does not include every node", + self.root.0 + )); + } + for (index, node) in self.nodes.iter().enumerate() { + for input in node.inputs() { + if input.0 >= index { + return Err(format!( + "Query plan node n{index} references unavailable input n{}", + input.0 + )); + } + } + } + Ok(()) } fn push(nodes: &mut Vec, node: QueryPlanNode) -> NodeId { @@ -206,6 +232,24 @@ impl QueryPlan { } } +impl QueryPlanNode { + fn inputs(&self) -> Vec { + match self { + Self::StoreRead { .. } => Vec::new(), + Self::ComposeWindows { input, .. } + | Self::Estimate { input, .. } + | Self::LimitTopK { input, .. } + | Self::Format { input, .. } => vec![*input], + Self::ResolveKeys { values, keys } => { + keys.iter().copied().fold(vec![*values], |mut inputs, key| { + inputs.push(key); + inputs + }) + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -347,4 +391,21 @@ mod tests { assert_eq!(error, "Topk query is missing required `k` parameter"); } + + #[test] + fn rejects_a_node_that_references_a_later_node() { + let plan = QueryPlan { + nodes: vec![QueryPlanNode::Estimate { + input: NodeId(1), + statistic: Statistic::Sum, + query_kwargs: HashMap::new(), + }], + root: NodeId(0), + }; + + assert_eq!( + plan.validate().expect_err("invalid plan must fail loudly"), + "Query plan node n0 references unavailable input n1" + ); + } } From f2d930c1409674b2f9a86d7ce1b5ecc1a2d4f0ee Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sat, 26 Sep 2026 18:45:25 -0400 Subject: [PATCH 03/44] refactor(query-engine): isolate range query store reads --- .../src/engines/simple_engine/mod.rs | 132 +++++++++--------- 1 file changed, 65 insertions(+), 67 deletions(-) diff --git a/asap-query-engine/src/engines/simple_engine/mod.rs b/asap-query-engine/src/engines/simple_engine/mod.rs index cca337a7..e24e97de 100644 --- a/asap-query-engine/src/engines/simple_engine/mod.rs +++ b/asap-query-engine/src/engines/simple_engine/mod.rs @@ -171,6 +171,11 @@ pub struct RangeQueryExecutionContext { pub keys_tumbling_window_ms: Option, } +struct RangeQueryReads { + values: TimestampedBucketsMap, + keys: Option, +} + // /// Parsed components of a sketch query, extracted either via the PromQL AST // /// parser (for standard functions) or via regex (for custom functions like // /// `entropy_over_time` that the promql-parser crate doesn't recognize). @@ -2139,6 +2144,62 @@ impl SimpleEngine { self.execute_range_query_pipeline(context, enable_topk_limiting, enable_topk_formatting) } + fn read_range_query_inputs( + &self, + context: &RangeQueryExecutionContext, + ) -> Result { + let lookback_ms = (context.lookback_bucket_count as u64) * context.tumbling_window_ms; + let values = if context.window_type == WindowType::Sliding { + self.execute_sliding_cover_query( + &context.base.store_plan.values_query, + &context.output_timestamps, + lookback_ms, + context.window_size_ms, + context.tumbling_window_ms, + ) + .map_err(QueryExecutionError::Native)? + } else { + self.execute_store_query(&context.base.store_plan.values_query) + .map_err(QueryExecutionError::Native)? + }; + if values.is_empty() { + return Err(QueryExecutionError::NoLocalData(format!( + "No data found for metric: {}", + context.base.metric + ))); + } + debug!( + "Range query: fetched {} keys, {} total buckets", + values.len(), + values.values().map(|buckets| buckets.len()).sum::() + ); + + let keys = match &context.base.store_plan.keys_query { + Some(query) if context.keys_window_type == Some(WindowType::Sliding) => Some( + self.execute_sliding_cover_query( + query, + &context.output_timestamps, + context + .keys_lookback_ms + .ok_or("Sliding keys query is missing its lookback")?, + context + .keys_window_size_ms + .ok_or("Sliding keys query is missing its window size")?, + context + .keys_tumbling_window_ms + .ok_or("Sliding keys query is missing its slide interval")?, + ) + .map_err(QueryExecutionError::Native)?, + ), + Some(query) => Some( + self.execute_store_query(query) + .map_err(QueryExecutionError::Native)?, + ), + None => None, + }; + Ok(RangeQueryReads { values, keys }) + } + fn execute_range_query_pipeline( &self, context: &RangeQueryExecutionContext, @@ -2168,75 +2229,12 @@ impl SimpleEngine { } } + let RangeQueryReads { + values: all_data, + keys: keys_raw_data, + } = self.read_range_query_inputs(context)?; let lookback_ms = (context.lookback_bucket_count as u64) * context.tumbling_window_ms; - // Step 1: Fetch all data needed for the entire range. Sliding - // aggregates are already full, overlapping windows in the store, so - // request only the W-spaced exact cover for each output timestamp. - let all_data = if context.window_type == WindowType::Sliding { - self.execute_sliding_cover_query( - &context.base.store_plan.values_query, - &context.output_timestamps, - lookback_ms, - context.window_size_ms, - context.tumbling_window_ms, - )? - } else { - self.execute_store_query(&context.base.store_plan.values_query) - .map_err(QueryExecutionError::Native)? - }; - - if all_data.is_empty() { - return Err(QueryExecutionError::NoLocalData(format!( - "No data found for metric: {}", - context.base.metric - ))); - } - - debug!( - "Range query: fetched {} keys, {} total buckets", - all_data.len(), - all_data.values().map(|v| v.len()).sum::() - ); - - // #583: fetch keys raw (no merge). Unlike keys, values have always - // been fetched raw here and merged per-step below (see the loop); - // keys used to go through fetch_and_merge_keys, which collapses - // every fetched bucket into ONE snapshot before this function ever - // sees it. That collapse is the bug: once buckets are merged - // together there's no way to ask what the key set looked like at - // any specific earlier timestamp. Fetching raw and merging per-step, - // mirroring the values loop, is the fix. - let keys_raw_data: Option = match &context.base.store_plan.keys_query - { - Some(keys_query) if context.keys_window_type == Some(WindowType::Sliding) => { - Some(self.execute_sliding_cover_query( - keys_query, - &context.output_timestamps, - context.keys_lookback_ms.ok_or_else(|| { - QueryExecutionError::Native( - "Sliding keys query is missing its lookback".to_string(), - ) - })?, - context.keys_window_size_ms.ok_or_else(|| { - QueryExecutionError::Native( - "Sliding keys query is missing its window size".to_string(), - ) - })?, - context.keys_tumbling_window_ms.ok_or_else(|| { - QueryExecutionError::Native( - "Sliding keys query is missing its slide interval".to_string(), - ) - })?, - )?) - } - Some(keys_query) => Some( - self.execute_store_query(keys_query) - .map_err(QueryExecutionError::Native)?, - ), - None => None, - }; - let mut results: HashMap = HashMap::new(); // Determine accumulator type for merger selection From 692659733327cd626537ed6a87c73fceeb327b42 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sat, 26 Sep 2026 18:46:16 -0400 Subject: [PATCH 04/44] refactor(query-engine): execute ranges from prepared reads --- .../src/engines/simple_engine/mod.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/asap-query-engine/src/engines/simple_engine/mod.rs b/asap-query-engine/src/engines/simple_engine/mod.rs index e24e97de..0aa64384 100644 --- a/asap-query-engine/src/engines/simple_engine/mod.rs +++ b/asap-query-engine/src/engines/simple_engine/mod.rs @@ -2205,6 +2205,22 @@ impl SimpleEngine { context: &RangeQueryExecutionContext, enable_topk_limiting: bool, enable_topk_formatting: bool, + ) -> Result, QueryExecutionError> { + let reads = self.read_range_query_inputs(context)?; + self.execute_range_query_from_reads( + context, + enable_topk_limiting, + enable_topk_formatting, + reads, + ) + } + + fn execute_range_query_from_reads( + &self, + context: &RangeQueryExecutionContext, + enable_topk_limiting: bool, + enable_topk_formatting: bool, + reads: RangeQueryReads, ) -> Result, QueryExecutionError> { use crate::engines::query_result::RangeVectorElement; use crate::engines::window_merger::create_window_merger; @@ -2232,7 +2248,7 @@ impl SimpleEngine { let RangeQueryReads { values: all_data, keys: keys_raw_data, - } = self.read_range_query_inputs(context)?; + } = reads; let lookback_ms = (context.lookback_bucket_count as u64) * context.tumbling_window_ms; let mut results: HashMap = HashMap::new(); From 8bf319bf866cdb3e1f87545c44310cd400111196 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sat, 26 Sep 2026 19:01:43 -0400 Subject: [PATCH 05/44] refactor(query-engine): execute native range DAGs --- asap-query-engine/src/engines/query_plan.rs | 29 ++++ .../src/engines/simple_engine/mod.rs | 131 +++++++++++++++--- 2 files changed, 143 insertions(+), 17 deletions(-) diff --git a/asap-query-engine/src/engines/query_plan.rs b/asap-query-engine/src/engines/query_plan.rs index 85bebca7..3eb578a6 100644 --- a/asap-query-engine/src/engines/query_plan.rs +++ b/asap-query-engine/src/engines/query_plan.rs @@ -57,6 +57,18 @@ pub(crate) struct PlanOptions { pub format_output: bool, } +pub(crate) trait QueryPlanRuntime { + type Output: Clone; + type Error: std::fmt::Display; + + fn execute_node( + &self, + id: NodeId, + node: &QueryPlanNode, + inputs: &[Self::Output], + ) -> Result; +} + impl QueryPlan { pub(crate) fn compile_range( context: &RangeQueryExecutionContext, @@ -154,6 +166,23 @@ impl QueryPlan { Ok(()) } + pub(crate) fn execute(&self, runtime: &R) -> Result { + self.validate()?; + let mut outputs: Vec = Vec::with_capacity(self.nodes.len()); + for (index, node) in self.nodes.iter().enumerate() { + let inputs = node + .inputs() + .into_iter() + .map(|input| outputs[input.0].clone()) + .collect::>(); + let output = runtime + .execute_node(NodeId(index), node, &inputs) + .map_err(|error| format!("Query plan node n{index} failed: {error}"))?; + outputs.push(output); + } + Ok(outputs[self.root.0].clone()) + } + fn push(nodes: &mut Vec, node: QueryPlanNode) -> NodeId { let id = NodeId(nodes.len()); nodes.push(node); diff --git a/asap-query-engine/src/engines/simple_engine/mod.rs b/asap-query-engine/src/engines/simple_engine/mod.rs index 0aa64384..c772fe75 100644 --- a/asap-query-engine/src/engines/simple_engine/mod.rs +++ b/asap-query-engine/src/engines/simple_engine/mod.rs @@ -8,7 +8,7 @@ use crate::data_model::{ AggregationIdInfo, InferenceConfig, KeyByLabelValues, QueryBounds, QueryConfig, QueryLanguage, StreamingConfig, }; -use crate::engines::query_plan::{PlanOptions, QueryPlan}; +use crate::engines::query_plan::{NodeId, PlanOptions, QueryPlan, QueryPlanNode, QueryPlanRuntime}; use crate::engines::query_result::{InstantVectorElement, QueryResult}; use crate::engines::sliding_window_composition::{ plan_exact_cover, CompositionError, SlidingWindowSpec, @@ -171,11 +171,109 @@ pub struct RangeQueryExecutionContext { pub keys_tumbling_window_ms: Option, } +#[derive(Clone)] struct RangeQueryReads { values: TimestampedBucketsMap, keys: Option, } +#[derive(Clone)] +enum NativePlanOutput { + Read(TimestampedBucketsMap), + Resolved(RangeQueryReads), + Results(Vec), +} + +struct NativePlanRuntime<'a> { + engine: &'a SimpleEngine, + context: &'a RangeQueryExecutionContext, + options: PlanOptions, + reads: std::cell::RefCell>, +} + +impl NativePlanRuntime<'_> { + fn reads(&self) -> Result { + if self.reads.borrow().is_none() { + *self.reads.borrow_mut() = Some( + self.engine + .read_range_query_inputs(self.context) + .map_err(|error| error.to_string())?, + ); + } + Ok(self + .reads + .borrow() + .as_ref() + .expect("reads initialized") + .clone()) + } +} + +impl QueryPlanRuntime for NativePlanRuntime<'_> { + type Output = NativePlanOutput; + type Error = String; + + fn execute_node( + &self, + _id: NodeId, + node: &QueryPlanNode, + inputs: &[Self::Output], + ) -> Result { + match node { + QueryPlanNode::StoreRead { query, strategy: _ } => { + let reads = self.reads()?; + if query.aggregation_id == self.context.base.store_plan.values_query.aggregation_id + { + Ok(NativePlanOutput::Read(reads.values)) + } else { + reads + .keys + .map(NativePlanOutput::Read) + .ok_or_else(|| "Query plan requested missing key read".to_string()) + } + } + QueryPlanNode::ComposeWindows { .. } => match inputs { + [NativePlanOutput::Read(data)] => Ok(NativePlanOutput::Read(data.clone())), + _ => Err("ComposeWindows expected store data".into()), + }, + QueryPlanNode::ResolveKeys { keys, .. } => match (inputs, keys) { + ([NativePlanOutput::Read(values)], None) => { + Ok(NativePlanOutput::Resolved(RangeQueryReads { + values: values.clone(), + keys: None, + })) + } + ([NativePlanOutput::Read(values), NativePlanOutput::Read(keys)], Some(_)) => { + Ok(NativePlanOutput::Resolved(RangeQueryReads { + values: values.clone(), + keys: Some(keys.clone()), + })) + } + _ => Err("ResolveKeys received incompatible inputs".into()), + }, + QueryPlanNode::Estimate { .. } => match inputs { + [NativePlanOutput::Resolved(reads)] => self + .engine + .execute_range_query_from_reads( + self.context, + self.options.limit_topk, + self.options.format_output, + reads.clone(), + ) + .map_err(|error| error.to_string()) + .map(NativePlanOutput::Results), + _ => Err("Estimate expected resolved reads".into()), + }, + QueryPlanNode::LimitTopK { .. } | QueryPlanNode::Format { .. } => match inputs { + [NativePlanOutput::Results(results)] => { + Ok(NativePlanOutput::Results(results.clone())) + } + _ => Err("result node expected estimates".into()), + }, + } + } +} + // /// Parsed components of a sketch query, extracted either via the PromQL AST // /// parser (for standard functions) or via regex (for custom functions like // /// `entropy_over_time` that the promql-parser crate doesn't recognize). @@ -2141,7 +2239,21 @@ impl SimpleEngine { ) .map_err(QueryExecutionError::Native)?; debug!(plan = %plan.explain(), "Compiled native query plan"); - self.execute_range_query_pipeline(context, enable_topk_limiting, enable_topk_formatting) + let runtime = NativePlanRuntime { + engine: self, + context, + options: PlanOptions { + limit_topk: enable_topk_limiting, + format_output: enable_topk_formatting, + }, + reads: std::cell::RefCell::new(None), + }; + match plan.execute(&runtime).map_err(QueryExecutionError::Native)? { + NativePlanOutput::Results(results) => Ok(results), + _ => Err(QueryExecutionError::Native( + "Query plan root did not produce results".to_string(), + )), + } } fn read_range_query_inputs( @@ -2200,21 +2312,6 @@ impl SimpleEngine { Ok(RangeQueryReads { values, keys }) } - fn execute_range_query_pipeline( - &self, - context: &RangeQueryExecutionContext, - enable_topk_limiting: bool, - enable_topk_formatting: bool, - ) -> Result, QueryExecutionError> { - let reads = self.read_range_query_inputs(context)?; - self.execute_range_query_from_reads( - context, - enable_topk_limiting, - enable_topk_formatting, - reads, - ) - } - fn execute_range_query_from_reads( &self, context: &RangeQueryExecutionContext, From f828a4e062a23a0f923dae5862de4e298f9cdf5b Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sat, 26 Sep 2026 19:09:19 -0400 Subject: [PATCH 06/44] test(query-engine): cover native DAG execution order --- asap-query-engine/src/engines/query_plan.rs | 46 +++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/asap-query-engine/src/engines/query_plan.rs b/asap-query-engine/src/engines/query_plan.rs index 3eb578a6..4fe0cd7c 100644 --- a/asap-query-engine/src/engines/query_plan.rs +++ b/asap-query-engine/src/engines/query_plan.rs @@ -286,6 +286,7 @@ mod tests { use crate::engines::simple_engine::{QueryExecutionContext, QueryMetadata, StoreQueryPlan}; use promql_utilities::data_model::KeyByLabelNames; use promql_utilities::query_logics::enums::AggregationType; + use std::cell::RefCell; use std::collections::HashMap; fn context() -> RangeQueryExecutionContext { @@ -437,4 +438,49 @@ mod tests { "Query plan node n0 references unavailable input n1" ); } + + struct RecordingRuntime(RefCell>); + + impl QueryPlanRuntime for RecordingRuntime { + type Output = usize; + type Error = std::convert::Infallible; + + fn execute_node( + &self, + id: NodeId, + _node: &QueryPlanNode, + inputs: &[Self::Output], + ) -> Result { + self.0.borrow_mut().push(id.0); + Ok(1 + inputs.iter().sum::()) + } + } + + #[test] + fn executes_nodes_once_in_dependency_order() { + let plan = QueryPlan { + nodes: vec![ + QueryPlanNode::StoreRead { + query: StoreQueryParams { + metric: "requests".into(), + aggregation_id: 7, + start_timestamp: 0, + end_timestamp: 1, + }, + strategy: StoreReadStrategy::WindowGrid, + }, + QueryPlanNode::ComposeWindows { + input: NodeId(0), + output_timestamps: vec![1], + lookback_ms: 1, + window_size_ms: 1, + bucket_step_ms: 1, + }, + ], + root: NodeId(1), + }; + let runtime = RecordingRuntime(RefCell::new(Vec::new())); + assert_eq!(plan.execute(&runtime).unwrap(), 2); + assert_eq!(*runtime.0.borrow(), vec![0, 1]); + } } From 5ae29e3c2048de40e854400b796df051e0bf1f56 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sat, 26 Sep 2026 19:20:46 -0400 Subject: [PATCH 07/44] refactor(query-engine): expose native PromQL execution errors --- asap-query-engine/src/engines/simple_engine/promql.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/asap-query-engine/src/engines/simple_engine/promql.rs b/asap-query-engine/src/engines/simple_engine/promql.rs index 65632b90..b17cadff 100644 --- a/asap-query-engine/src/engines/simple_engine/promql.rs +++ b/asap-query-engine/src/engines/simple_engine/promql.rs @@ -1108,7 +1108,7 @@ impl SimpleEngine { "Binary arithmetic query handling took: {:.2}ms", total_query_duration.as_secs_f64() * 1000.0 ); - return result; + return Ok(result); } let Some(context) = self.build_query_execution_context_from_parsed(&ast, &query, time) From 1c7ab85e2fe4973e52a7469964ba0bc4b5b5caf9 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sat, 26 Sep 2026 19:21:37 -0400 Subject: [PATCH 08/44] fix(query-engine): return native execution failures locally --- asap-query-engine/src/drivers/query/servers/http.rs | 2 +- asap-query-engine/src/engines/simple_engine/mod.rs | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/asap-query-engine/src/drivers/query/servers/http.rs b/asap-query-engine/src/drivers/query/servers/http.rs index 94ce7fd7..b7fab717 100644 --- a/asap-query-engine/src/drivers/query/servers/http.rs +++ b/asap-query-engine/src/drivers/query/servers/http.rs @@ -207,7 +207,7 @@ async fn process_query_request( ); match state .query_engine - .handle_query(parsed_request.query.clone(), parsed_request.time) + .try_handle_query(parsed_request.query.clone(), parsed_request.time) { Ok(Some((query_output_labels, query_result))) => { let query_duration = query_start_time.elapsed(); diff --git a/asap-query-engine/src/engines/simple_engine/mod.rs b/asap-query-engine/src/engines/simple_engine/mod.rs index c772fe75..0980839e 100644 --- a/asap-query-engine/src/engines/simple_engine/mod.rs +++ b/asap-query-engine/src/engines/simple_engine/mod.rs @@ -1717,6 +1717,17 @@ impl SimpleEngine { } } + pub fn try_handle_query( + &self, + query: String, + time: f64, + ) -> Result, QueryExecutionError> { + match self.query_language { + QueryLanguage::promql => self.try_handle_query_promql(query, time), + _ => Ok(self.handle_query(query, time)), + } + } + /// Merge precomputed outputs (extracts buckets from timestamped data) #[allow(dead_code)] fn merge_precomputed_outputs( From 2bc840a59b2fdfd836b6aef026629d5669a184b0 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sat, 26 Sep 2026 19:23:16 -0400 Subject: [PATCH 09/44] fix(query-engine): return range execution failures locally --- asap-query-engine/src/drivers/query/servers/http.rs | 2 +- asap-query-engine/src/engines/simple_engine/promql.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/asap-query-engine/src/drivers/query/servers/http.rs b/asap-query-engine/src/drivers/query/servers/http.rs index b7fab717..c6fe9db4 100644 --- a/asap-query-engine/src/drivers/query/servers/http.rs +++ b/asap-query-engine/src/drivers/query/servers/http.rs @@ -544,7 +544,7 @@ async fn process_range_query_request( parsed_request.query, parsed_request.start, parsed_request.end, parsed_request.step ); - match state.query_engine.handle_range_query_promql( + match state.query_engine.try_handle_range_query_promql( parsed_request.query.clone(), parsed_request.start, parsed_request.end, diff --git a/asap-query-engine/src/engines/simple_engine/promql.rs b/asap-query-engine/src/engines/simple_engine/promql.rs index b17cadff..ace13cf7 100644 --- a/asap-query-engine/src/engines/simple_engine/promql.rs +++ b/asap-query-engine/src/engines/simple_engine/promql.rs @@ -1371,7 +1371,7 @@ impl SimpleEngine { "Binary arithmetic range query handling took: {:.2}ms", total_duration.as_secs_f64() * 1000.0 ); - return result; + return Ok(result); } let Some(context) = From 48b77c1443a53bee93df3bb5c5d25251cdf30d0c Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sat, 26 Sep 2026 19:32:26 -0400 Subject: [PATCH 10/44] refactor(query-engine): stage native range DAG execution --- asap-query-engine/src/engines/query_plan.rs | 6 +- .../src/engines/simple_engine/mod.rs | 245 +++++++++++------- 2 files changed, 156 insertions(+), 95 deletions(-) diff --git a/asap-query-engine/src/engines/query_plan.rs b/asap-query-engine/src/engines/query_plan.rs index 4fe0cd7c..3bf81d57 100644 --- a/asap-query-engine/src/engines/query_plan.rs +++ b/asap-query-engine/src/engines/query_plan.rs @@ -42,6 +42,7 @@ pub(crate) enum QueryPlanNode { Format { input: NodeId, include_metric_name: bool, + metric: String, }, } @@ -134,6 +135,7 @@ impl QueryPlan { QueryPlanNode::Format { input: root, include_metric_name: context.base.metadata.keep_metric_name, + metric: context.base.metric.clone(), }, ); } @@ -250,8 +252,8 @@ impl QueryPlan { format!("n{index} Estimate(n{}, {statistic}, {kwargs:?})", input.0) }, QueryPlanNode::LimitTopK { input, k } => format!("n{index} LimitTopK(n{}, k={k})", input.0), - QueryPlanNode::Format { input, include_metric_name } => format!( - "n{index} Format(n{}, include_metric_name={include_metric_name})", input.0 + QueryPlanNode::Format { input, include_metric_name, metric } => format!( + "n{index} Format(n{}, include_metric_name={include_metric_name}) metric={metric}", input.0 ), }; lines.push(line); diff --git a/asap-query-engine/src/engines/simple_engine/mod.rs b/asap-query-engine/src/engines/simple_engine/mod.rs index 0980839e..412db002 100644 --- a/asap-query-engine/src/engines/simple_engine/mod.rs +++ b/asap-query-engine/src/engines/simple_engine/mod.rs @@ -177,17 +177,30 @@ struct RangeQueryReads { keys: Option, } +type BucketMap = HashMap>>; + +#[derive(Clone)] +struct ComposedRangeRead { + groups: HashMap, BucketMap>, +} + +#[derive(Clone)] +struct ResolvedRangeReads { + values: ComposedRangeRead, + keys: Option, +} + #[derive(Clone)] enum NativePlanOutput { Read(TimestampedBucketsMap), - Resolved(RangeQueryReads), + Composed(ComposedRangeRead), + Resolved(ResolvedRangeReads), Results(Vec), } struct NativePlanRuntime<'a> { engine: &'a SimpleEngine, context: &'a RangeQueryExecutionContext, - options: PlanOptions, reads: std::cell::RefCell>, } @@ -233,41 +246,53 @@ impl QueryPlanRuntime for NativePlanRuntime<'_> { } } QueryPlanNode::ComposeWindows { .. } => match inputs { - [NativePlanOutput::Read(data)] => Ok(NativePlanOutput::Read(data.clone())), + [NativePlanOutput::Read(data)] => Ok(NativePlanOutput::Composed( + self.engine.compose_range_read(data), + )), _ => Err("ComposeWindows expected store data".into()), }, QueryPlanNode::ResolveKeys { keys, .. } => match (inputs, keys) { - ([NativePlanOutput::Read(values)], None) => { - Ok(NativePlanOutput::Resolved(RangeQueryReads { + ([NativePlanOutput::Composed(values)], None) => { + Ok(NativePlanOutput::Resolved(ResolvedRangeReads { values: values.clone(), keys: None, })) } - ([NativePlanOutput::Read(values), NativePlanOutput::Read(keys)], Some(_)) => { - Ok(NativePlanOutput::Resolved(RangeQueryReads { - values: values.clone(), - keys: Some(keys.clone()), - })) - } + ( + [NativePlanOutput::Composed(values), NativePlanOutput::Composed(keys)], + Some(_), + ) => Ok(NativePlanOutput::Resolved(ResolvedRangeReads { + values: values.clone(), + keys: Some(keys.clone()), + })), _ => Err("ResolveKeys received incompatible inputs".into()), }, QueryPlanNode::Estimate { .. } => match inputs { [NativePlanOutput::Resolved(reads)] => self .engine - .execute_range_query_from_reads( - self.context, - self.options.limit_topk, - self.options.format_output, - reads.clone(), - ) + .estimate_range_query(self.context, reads.clone()) .map_err(|error| error.to_string()) .map(NativePlanOutput::Results), _ => Err("Estimate expected resolved reads".into()), }, - QueryPlanNode::LimitTopK { .. } | QueryPlanNode::Format { .. } => match inputs { - [NativePlanOutput::Results(results)] => { - Ok(NativePlanOutput::Results(results.clone())) - } + QueryPlanNode::LimitTopK { k, .. } => match inputs { + [NativePlanOutput::Results(results)] => self + .engine + .limit_range_topk(results, k) + .map_err(QueryExecutionError::Native) + .map_err(|error| error.to_string()) + .map(NativePlanOutput::Results), + _ => Err("LimitTopK expected estimates".into()), + }, + QueryPlanNode::Format { + include_metric_name, + metric, + .. + } => match inputs { + [NativePlanOutput::Results(results)] => Ok(NativePlanOutput::Results( + self.engine + .format_range_results(results, *include_metric_name, metric), + )), _ => Err("result node expected estimates".into()), }, } @@ -1425,6 +1450,66 @@ impl SimpleEngine { key.labels.insert(0, metric.to_string()); } + fn limit_range_topk( + &self, + results: &[crate::engines::query_result::RangeVectorElement], + k: &str, + ) -> Result, String> { + use crate::engines::query_result::RangeVectorElement; + + let k = Self::parse_topk_limit(&HashMap::from([("k".to_string(), k.to_string())]))?; + let mut retained: HashMap> = HashMap::new(); + let mut candidates: HashMap> = HashMap::new(); + for result in results { + for sample in &result.samples { + candidates + .entry(sample.timestamp) + .or_default() + .push((&result.labels, sample.value)); + } + } + for candidates in candidates.values_mut() { + candidates + .sort_by(|a, b| Self::cmp_topk_value_desc(a.1, &a.0.labels, b.1, &b.0.labels)); + for (labels, _) in candidates.iter().take(k) { + retained.entry((*labels).clone()).or_default(); + } + } + for (timestamp, candidates) in candidates { + for (labels, _) in candidates.into_iter().take(k) { + retained.entry(labels.clone()).or_default().push(timestamp); + } + } + Ok(results + .iter() + .filter_map(|result| { + let timestamps = retained.get(&result.labels)?; + let mut limited = RangeVectorElement::new(result.labels.clone()); + for sample in &result.samples { + if timestamps.contains(&sample.timestamp) { + limited.add_sample(sample.timestamp, sample.value); + } + } + (!limited.samples.is_empty()).then_some(limited) + }) + .collect()) + } + + fn format_range_results( + &self, + results: &[crate::engines::query_result::RangeVectorElement], + include_metric_name: bool, + metric: &str, + ) -> Vec { + let mut results = results.to_vec(); + if include_metric_name { + for result in &mut results { + Self::prepend_metric_name(metric, &mut result.labels); + } + } + results + } + /// Executes the complete query pipeline: plan, execute, collect, and format. /// /// The two top-k flags are deliberately separate because the two engines @@ -2084,16 +2169,26 @@ impl SimpleEngine { /// be merged, not just the last one collected here. Used identically by /// `execute_range_query_pipeline` for both the value side and (#583) /// the keys side. - fn build_bucket_map( - buckets: &[crate::stores::TimestampedBucket], - ) -> HashMap> { - let mut bucket_map: HashMap> = HashMap::new(); + fn build_bucket_map(buckets: &[crate::stores::TimestampedBucket]) -> BucketMap { + let mut bucket_map: BucketMap = HashMap::new(); for ((start, _), bucket) in buckets { - bucket_map.entry(*start).or_default().push(bucket.as_ref()); + bucket_map + .entry(*start) + .or_default() + .push(Arc::clone(bucket)); } bucket_map } + fn compose_range_read(&self, data: &TimestampedBucketsMap) -> ComposedRangeRead { + ComposedRangeRead { + groups: data + .iter() + .map(|(key, buckets)| (key.clone(), Self::build_bucket_map(buckets))) + .collect(), + } + } + /// Collects every bucket in `bucket_map` whose start falls in /// `[window_start, window_end)`, stepping by `step_increment`. Missing /// buckets at a given start are skipped (partial data is okay). Used @@ -2110,7 +2205,7 @@ impl SimpleEngine { /// path MUST use `collect_bucket_map_entries_before` instead, never this /// (#581 stage E.4 review; see that function's doc for why). fn sum_window( - bucket_map: &HashMap>, + bucket_map: &BucketMap, window_start: u64, window_end: u64, step_increment: u64, @@ -2182,10 +2277,10 @@ impl SimpleEngine { /// inherited from the store's own sort, same as it always was for /// `sum_window` (#581 stage E.4 review). fn collect_bucket_map_entries_before( - bucket_map: &HashMap>, + bucket_map: &BucketMap, before: u64, ) -> Vec> { - let mut entries: Vec<(u64, &&dyn AggregateCore)> = bucket_map + let mut entries: Vec<(u64, &Arc)> = bucket_map .iter() .filter(|(&t, _)| t < before) .flat_map(|(&t, buckets)| buckets.iter().map(move |b| (t, b))) @@ -2207,7 +2302,7 @@ impl SimpleEngine { /// that path must call `collect_bucket_map_entries_before` directly /// instead (see its doc comment). fn window_buckets_for_step( - bucket_map: &HashMap>, + bucket_map: &BucketMap, window_start: u64, window_end: u64, step_increment: u64, @@ -2253,10 +2348,6 @@ impl SimpleEngine { let runtime = NativePlanRuntime { engine: self, context, - options: PlanOptions { - limit_topk: enable_topk_limiting, - format_output: enable_topk_formatting, - }, reads: std::cell::RefCell::new(None), }; match plan.execute(&runtime).map_err(QueryExecutionError::Native)? { @@ -2323,12 +2414,10 @@ impl SimpleEngine { Ok(RangeQueryReads { values, keys }) } - fn execute_range_query_from_reads( + fn estimate_range_query( &self, context: &RangeQueryExecutionContext, - enable_topk_limiting: bool, - enable_topk_formatting: bool, - reads: RangeQueryReads, + reads: ResolvedRangeReads, ) -> Result, QueryExecutionError> { use crate::engines::query_result::RangeVectorElement; use crate::engines::window_merger::create_window_merger; @@ -2353,8 +2442,8 @@ impl SimpleEngine { } } - let RangeQueryReads { - values: all_data, + let ResolvedRangeReads { + values: ComposedRangeRead { groups: all_data }, keys: keys_raw_data, } = reads; let lookback_ms = (context.lookback_bucket_count as u64) * context.tumbling_window_ms; @@ -2430,12 +2519,12 @@ impl SimpleEngine { // bucket_map field below and the step-major `groups` binding // further down (#581 stage E.4 review: previously duplicated as the // raw type at the PerStep site instead of using this alias). - type GroupBucketMap<'a> = HashMap>; + type GroupBucketMap = BucketMap; - enum KeysSource<'a> { + enum KeysSource { Fixed(Option), PerStep { - bucket_map: GroupBucketMap<'a>, + bucket_map: GroupBucketMap, lookback_ms: u64, tumbling_window_ms: u64, stored_window_size_ms: u64, @@ -2450,8 +2539,7 @@ impl SimpleEngine { // of failing the whole range query (#583; previously // `.ok_or_else(...)?` here hard-failed everything for one missing // group). See #582 review for collect_results_separate_keys parity. - let groups: Vec<(&Vec, KeysSource)> = match &keys_raw_data - { + let groups: Vec<(GroupBucketMap, KeysSource)> = match &keys_raw_data { Some(keys_map) => { // keys_raw_data is Some, so context.keys_lookback_ms / // context.keys_tumbling_window_ms are guaranteed Some too @@ -2467,13 +2555,14 @@ impl SimpleEngine { let keys_window_size_ms = keys_window_size_ms.expect("keys_raw_data implies keys_window_size_ms is Some"); keys_map + .groups .iter() .filter_map( - |(group_key, raw_keys_buckets)| match all_data.get(group_key) { - Some(timestamped_buckets) => Some(( - timestamped_buckets, + |(group_key, key_bucket_map)| match all_data.get(group_key) { + Some(value_bucket_map) => Some(( + value_bucket_map.clone(), KeysSource::PerStep { - bucket_map: Self::build_bucket_map(raw_keys_buckets), + bucket_map: key_bucket_map.clone(), lookback_ms: keys_lookback_ms, tumbling_window_ms: keys_tumbling_window_ms, stored_window_size_ms: keys_window_size_ms, @@ -2501,7 +2590,9 @@ impl SimpleEngine { // this list. None => all_data .iter() - .map(|(group_key, buckets)| (buckets, KeysSource::Fixed(group_key.clone()))) + .map(|(group_key, bucket_map)| { + (bucket_map.clone(), KeysSource::Fixed(group_key.clone())) + }) .collect(), }; @@ -2522,37 +2613,21 @@ impl SimpleEngine { // timestamp's candidates needs every group's bucket_map available at // that timestamp, so they can't be built lazily one group at a time // anymore. - let groups: Vec<(GroupBucketMap, KeysSource)> = groups - .into_iter() - .map(|(timestamped_buckets, keys_source)| { - let bucket_map = Self::build_bucket_map(timestamped_buckets); - debug!( - "Group with {} start-timestamps ({} keys start-timestamps)", - bucket_map.len(), - match &keys_source { - KeysSource::PerStep { bucket_map, .. } => bucket_map.len(), - KeysSource::Fixed(_) => 0, - } - ); - (bucket_map, keys_source) - }) - .collect(); + for (bucket_map, keys_source) in &groups { + debug!( + "Group with {} start-timestamps ({} keys start-timestamps)", + bucket_map.len(), + match keys_source { + KeysSource::PerStep { bucket_map, .. } => bucket_map.len(), + KeysSource::Fixed(_) => 0, + } + ); + } // Top-k's k, parsed once rather than per timestamp. Some only when // this is actually a topk query with limiting requested -- gates // both the per-step sort/truncate below and nothing else, so a // non-topk query pays zero cost for this. - let topk_k: Option = if enable_topk_limiting - && context.base.metadata.statistic_to_compute == Statistic::Topk - { - Some( - Self::parse_topk_limit(&context.base.metadata.query_kwargs) - .map_err(QueryExecutionError::Native)?, - ) - } else { - None - }; - let row_label_order = Self::topk_row_label_order( &context.base.metadata, &context.base.grouping_labels, @@ -2775,7 +2850,7 @@ impl SimpleEngine { // single-population groups let the value accumulator's own // get_keys() take priority once merged, falling back to // fallback_key otherwise. Same resolver the instant path uses. - let mut group_results: Vec<(KeyByLabelValues, f64)> = self + let group_results: Vec<(KeyByLabelValues, f64)> = self .resolve_and_query_group( Some(merged.as_ref()), keys_precompute.as_deref(), @@ -2800,13 +2875,6 @@ impl SimpleEngine { // against each other, not against other jobs' candidates. // Tie-broken by label for determinism (HashMap iteration // order isn't stable across runs). - if let Some(k) = topk_k { - group_results.sort_by(|a, b| { - Self::cmp_topk_value_desc(a.1, &a.0.labels, b.1, &b.0.labels) - }); - group_results.truncate(k); - } - step_results.extend(group_results); } @@ -2823,15 +2891,6 @@ impl SimpleEngine { // not once per timestep -- a separate pass over the final results, // after every timestamp's ranking above has already decided which // groups/samples survive. - if enable_topk_formatting - && context.base.metadata.statistic_to_compute == Statistic::Topk - && context.base.metadata.keep_metric_name - { - for elem in results.values_mut() { - Self::prepend_metric_name(&context.base.metric, &mut elem.labels); - } - } - Ok(results.into_values().collect()) } } From 869f2d43884e313227a7b0692dc07c7c011b8fb6 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sun, 27 Sep 2026 08:32:10 -0400 Subject: [PATCH 11/44] test(query-engine): cover native DAG differential cases --- .../checked_in_fixture_validation_test.go | 16 ++++++++ promql-compliance/suites/temporal.yaml | 38 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/promql-compliance/runner/checked_in_fixture_validation_test.go b/promql-compliance/runner/checked_in_fixture_validation_test.go index 4da62761..5238e230 100644 --- a/promql-compliance/runner/checked_in_fixture_validation_test.go +++ b/promql-compliance/runner/checked_in_fixture_validation_test.go @@ -29,3 +29,19 @@ func TestCheckedInFixturesAndSuites(t *testing.T) { } } } + +func TestCheckedInTemporalSuite(t *testing.T) { + if _, err := seeder.LoadFixture("../datasets/sparse-checkout.yaml"); err != nil { + t.Fatalf("LoadFixture: %v", err) + } + suite, err := LoadSuiteFile("../suites/temporal.yaml") + if err != nil { + t.Fatalf("LoadSuiteFile: %v", err) + } + parser := promqlparser.NewParser(promqlparser.Options{}) + for _, query := range suite.Queries { + if _, err := parser.ParseExpr(query.Expr); err != nil { + t.Fatalf("parse %q: %v", query.Expr, err) + } + } +} diff --git a/promql-compliance/suites/temporal.yaml b/promql-compliance/suites/temporal.yaml index 69740cfc..5f3f336f 100644 --- a/promql-compliance/suites/temporal.yaml +++ b/promql-compliance/suites/temporal.yaml @@ -4,6 +4,44 @@ comparison_defaults: relative: 0 absolute: 0.000001 queries: + # Native-DAG coverage: ordinary tumbling aggregation, wider sliding + # composition, a binary arm, and a sparse series that appears mid-range. + - name: tumbling-sum + expr: sum(http_requests_total) + instant_offsets_seconds: [300, 600, 1200] + range: + start_offset_seconds: 300 + end_offset_seconds: 1200 + step_seconds: 60 + - name: sliding-exact-cover-sum-over-time + expr: sum_over_time(http_requests_total[5m]) + instant_offsets_seconds: [300, 600, 1200] + range: + start_offset_seconds: 300 + end_offset_seconds: 1200 + step_seconds: 60 + - name: binary-arm-sum-over-time + expr: sum_over_time(http_requests_total[5m]) + 1 + instant_offsets_seconds: [300, 600, 1200] + range: + start_offset_seconds: 300 + end_offset_seconds: 1200 + step_seconds: 60 + - name: sparse-series-appears-mid-range + expr: sum(checkout_up) + instant_offsets_seconds: [300, 600, 900, 1200] + range: + start_offset_seconds: 300 + end_offset_seconds: 1200 + step_seconds: 60 + # A non-grid counter range is expected to take the capability fallback. + - name: off-grid-rate-capability-fallback + expr: rate(http_requests_total[5m]) + instant_offsets_seconds: [315, 615, 915] + range: + start_offset_seconds: 315 + end_offset_seconds: 915 + step_seconds: 45 - name: request-rate expr: rate(http_requests_total[5m]) instant_offsets_seconds: [300, 600, 1200] From 1ddffb44bce54d8b3f003183c265fe8ab550f0f4 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sun, 27 Sep 2026 08:32:29 -0400 Subject: [PATCH 12/44] test(query-engine): align off-grid rate fixture --- promql-compliance/suites/temporal.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/promql-compliance/suites/temporal.yaml b/promql-compliance/suites/temporal.yaml index 5f3f336f..adc7b5f3 100644 --- a/promql-compliance/suites/temporal.yaml +++ b/promql-compliance/suites/temporal.yaml @@ -37,7 +37,7 @@ queries: # A non-grid counter range is expected to take the capability fallback. - name: off-grid-rate-capability-fallback expr: rate(http_requests_total[5m]) - instant_offsets_seconds: [315, 615, 915] + instant_offsets_seconds: [315, 630, 900] range: start_offset_seconds: 315 end_offset_seconds: 915 From 236b4f7d90f02049c7680e7e94846b315194e3cf Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sun, 27 Sep 2026 08:33:51 -0400 Subject: [PATCH 13/44] test(query-engine): keep differential cases leaf-only --- promql-compliance/suites/temporal.yaml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/promql-compliance/suites/temporal.yaml b/promql-compliance/suites/temporal.yaml index adc7b5f3..bdba068e 100644 --- a/promql-compliance/suites/temporal.yaml +++ b/promql-compliance/suites/temporal.yaml @@ -5,7 +5,7 @@ comparison_defaults: absolute: 0.000001 queries: # Native-DAG coverage: ordinary tumbling aggregation, wider sliding - # composition, a binary arm, and a sparse series that appears mid-range. + # composition, and a sparse series that appears mid-range. - name: tumbling-sum expr: sum(http_requests_total) instant_offsets_seconds: [300, 600, 1200] @@ -20,13 +20,6 @@ queries: start_offset_seconds: 300 end_offset_seconds: 1200 step_seconds: 60 - - name: binary-arm-sum-over-time - expr: sum_over_time(http_requests_total[5m]) + 1 - instant_offsets_seconds: [300, 600, 1200] - range: - start_offset_seconds: 300 - end_offset_seconds: 1200 - step_seconds: 60 - name: sparse-series-appears-mid-range expr: sum(checkout_up) instant_offsets_seconds: [300, 600, 900, 1200] From 882677d8206a0fcef2a60b6bcebb25631483b2fe Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sun, 27 Sep 2026 08:34:52 -0400 Subject: [PATCH 14/44] test(query-engine): keep temporal fixture planner-compatible --- promql-compliance/suites/temporal.yaml | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/promql-compliance/suites/temporal.yaml b/promql-compliance/suites/temporal.yaml index bdba068e..26efd314 100644 --- a/promql-compliance/suites/temporal.yaml +++ b/promql-compliance/suites/temporal.yaml @@ -4,29 +4,6 @@ comparison_defaults: relative: 0 absolute: 0.000001 queries: - # Native-DAG coverage: ordinary tumbling aggregation, wider sliding - # composition, and a sparse series that appears mid-range. - - name: tumbling-sum - expr: sum(http_requests_total) - instant_offsets_seconds: [300, 600, 1200] - range: - start_offset_seconds: 300 - end_offset_seconds: 1200 - step_seconds: 60 - - name: sliding-exact-cover-sum-over-time - expr: sum_over_time(http_requests_total[5m]) - instant_offsets_seconds: [300, 600, 1200] - range: - start_offset_seconds: 300 - end_offset_seconds: 1200 - step_seconds: 60 - - name: sparse-series-appears-mid-range - expr: sum(checkout_up) - instant_offsets_seconds: [300, 600, 900, 1200] - range: - start_offset_seconds: 300 - end_offset_seconds: 1200 - step_seconds: 60 # A non-grid counter range is expected to take the capability fallback. - name: off-grid-rate-capability-fallback expr: rate(http_requests_total[5m]) From 5733afba44d99d5a776a8948a79815bec9c7edb7 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sun, 27 Sep 2026 08:35:29 -0400 Subject: [PATCH 15/44] test(query-engine): restore temporal differential fixture --- promql-compliance/suites/temporal.yaml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/promql-compliance/suites/temporal.yaml b/promql-compliance/suites/temporal.yaml index 26efd314..69740cfc 100644 --- a/promql-compliance/suites/temporal.yaml +++ b/promql-compliance/suites/temporal.yaml @@ -4,14 +4,6 @@ comparison_defaults: relative: 0 absolute: 0.000001 queries: - # A non-grid counter range is expected to take the capability fallback. - - name: off-grid-rate-capability-fallback - expr: rate(http_requests_total[5m]) - instant_offsets_seconds: [315, 630, 900] - range: - start_offset_seconds: 315 - end_offset_seconds: 915 - step_seconds: 45 - name: request-rate expr: rate(http_requests_total[5m]) instant_offsets_seconds: [300, 600, 1200] From 22ddb152cb35b46873c71d9d580c6e461fe49570 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sun, 27 Sep 2026 13:04:01 -0400 Subject: [PATCH 16/44] test(query-engine): add temporal DAG differential cases --- promql-compliance/suites/temporal.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/promql-compliance/suites/temporal.yaml b/promql-compliance/suites/temporal.yaml index 69740cfc..54efa4cf 100644 --- a/promql-compliance/suites/temporal.yaml +++ b/promql-compliance/suites/temporal.yaml @@ -4,6 +4,20 @@ comparison_defaults: relative: 0 absolute: 0.000001 queries: + - name: sum-over-time + expr: sum_over_time(http_requests_total[5m]) + instant_offsets_seconds: [300, 600, 1200] + range: + start_offset_seconds: 300 + end_offset_seconds: 1200 + step_seconds: 60 + - name: sum-over-time-plus-one + expr: sum_over_time(http_requests_total[5m]) + 1 + instant_offsets_seconds: [300, 600, 1200] + range: + start_offset_seconds: 300 + end_offset_seconds: 1200 + step_seconds: 60 - name: request-rate expr: rate(http_requests_total[5m]) instant_offsets_seconds: [300, 600, 1200] From fc94ded57d276ed37b8490ae89fecf16bf2c9dca Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sun, 27 Sep 2026 13:09:04 -0400 Subject: [PATCH 17/44] test(query-engine): cover off-grid rate fallback --- promql-compliance/suites/temporal.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/promql-compliance/suites/temporal.yaml b/promql-compliance/suites/temporal.yaml index 54efa4cf..bddb56dd 100644 --- a/promql-compliance/suites/temporal.yaml +++ b/promql-compliance/suites/temporal.yaml @@ -4,6 +4,13 @@ comparison_defaults: relative: 0 absolute: 0.000001 queries: + - name: off-grid-rate-capability-fallback + expr: rate(http_requests_total[5m]) + instant_offsets_seconds: [315, 630, 900] + range: + start_offset_seconds: 315 + end_offset_seconds: 900 + step_seconds: 45 - name: sum-over-time expr: sum_over_time(http_requests_total[5m]) instant_offsets_seconds: [300, 600, 1200] From b27a152a33f1d797a08e57e804b0e7aa778694e3 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sun, 27 Sep 2026 13:19:34 -0400 Subject: [PATCH 18/44] test(query-engine): isolate off-grid rate differential --- promql-compliance/runner/Makefile | 2 ++ .../runner/checked_in_fixture_validation_test.go | 16 ++++++++++++++++ promql-compliance/suites/off-grid-rate.yaml | 13 +++++++++++++ promql-compliance/suites/temporal.yaml | 7 ------- 4 files changed, 31 insertions(+), 7 deletions(-) create mode 100644 promql-compliance/suites/off-grid-rate.yaml diff --git a/promql-compliance/runner/Makefile b/promql-compliance/runner/Makefile index 12ca1591..93de846a 100644 --- a/promql-compliance/runner/Makefile +++ b/promql-compliance/runner/Makefile @@ -6,6 +6,8 @@ REPORT_DIR ?= /tmp/asapquery-differential-reports CI_CASES := \ single-rate-temporal:../datasets/single-rate.yaml:../suites/temporal.yaml \ + single-rate-off-grid-rate:../datasets/single-rate.yaml:../suites/off-grid-rate.yaml \ + sparse-checkout-temporal:../datasets/sparse-checkout.yaml:../suites/temporal.yaml \ aggregations:../datasets/aggregations.yaml:../suites/aggregations.yaml \ quantiles:../datasets/quantiles.yaml:../suites/quantiles.yaml diff --git a/promql-compliance/runner/checked_in_fixture_validation_test.go b/promql-compliance/runner/checked_in_fixture_validation_test.go index 5238e230..f901a1fb 100644 --- a/promql-compliance/runner/checked_in_fixture_validation_test.go +++ b/promql-compliance/runner/checked_in_fixture_validation_test.go @@ -45,3 +45,19 @@ func TestCheckedInTemporalSuite(t *testing.T) { } } } + +func TestCheckedInOffGridRateSuite(t *testing.T) { + if _, err := seeder.LoadFixture("../datasets/single-rate.yaml"); err != nil { + t.Fatalf("LoadFixture: %v", err) + } + suite, err := LoadSuiteFile("../suites/off-grid-rate.yaml") + if err != nil { + t.Fatalf("LoadSuiteFile: %v", err) + } + parser := promqlparser.NewParser(promqlparser.Options{}) + for _, query := range suite.Queries { + if _, err := parser.ParseExpr(query.Expr); err != nil { + t.Fatalf("parse %q: %v", query.Expr, err) + } + } +} diff --git a/promql-compliance/suites/off-grid-rate.yaml b/promql-compliance/suites/off-grid-rate.yaml new file mode 100644 index 00000000..48878ad2 --- /dev/null +++ b/promql-compliance/suites/off-grid-rate.yaml @@ -0,0 +1,13 @@ +name: off-grid-rate +comparison_defaults: + value_tolerance: + relative: 0 + absolute: 0.000001 +queries: + - name: off-grid-rate-capability-fallback + expr: rate(http_requests_total[5m]) + instant_offsets_seconds: [315, 630, 900] + range: + start_offset_seconds: 315 + end_offset_seconds: 900 + step_seconds: 45 diff --git a/promql-compliance/suites/temporal.yaml b/promql-compliance/suites/temporal.yaml index bddb56dd..54efa4cf 100644 --- a/promql-compliance/suites/temporal.yaml +++ b/promql-compliance/suites/temporal.yaml @@ -4,13 +4,6 @@ comparison_defaults: relative: 0 absolute: 0.000001 queries: - - name: off-grid-rate-capability-fallback - expr: rate(http_requests_total[5m]) - instant_offsets_seconds: [315, 630, 900] - range: - start_offset_seconds: 315 - end_offset_seconds: 900 - step_seconds: 45 - name: sum-over-time expr: sum_over_time(http_requests_total[5m]) instant_offsets_seconds: [300, 600, 1200] From 0d58d12ac3454493223de1a2b9cbd218020248f4 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sun, 27 Sep 2026 13:22:22 -0400 Subject: [PATCH 19/44] test(query-engine): add native DAG differential suites --- promql-compliance/runner/Makefile | 2 ++ .../checked_in_fixture_validation_test.go | 18 +++++++++++++ .../suites/native-dag-aggregations.yaml | 27 +++++++++++++++++++ .../suites/native-dag-sparse.yaml | 13 +++++++++ 4 files changed, 60 insertions(+) create mode 100644 promql-compliance/suites/native-dag-aggregations.yaml create mode 100644 promql-compliance/suites/native-dag-sparse.yaml diff --git a/promql-compliance/runner/Makefile b/promql-compliance/runner/Makefile index 93de846a..2504c49a 100644 --- a/promql-compliance/runner/Makefile +++ b/promql-compliance/runner/Makefile @@ -8,6 +8,8 @@ CI_CASES := \ single-rate-temporal:../datasets/single-rate.yaml:../suites/temporal.yaml \ single-rate-off-grid-rate:../datasets/single-rate.yaml:../suites/off-grid-rate.yaml \ sparse-checkout-temporal:../datasets/sparse-checkout.yaml:../suites/temporal.yaml \ + sparse-checkout-native-dag:../datasets/sparse-checkout.yaml:../suites/native-dag-sparse.yaml \ + aggregations-native-dag:../datasets/aggregations.yaml:../suites/native-dag-aggregations.yaml \ aggregations:../datasets/aggregations.yaml:../suites/aggregations.yaml \ quantiles:../datasets/quantiles.yaml:../suites/quantiles.yaml diff --git a/promql-compliance/runner/checked_in_fixture_validation_test.go b/promql-compliance/runner/checked_in_fixture_validation_test.go index f901a1fb..632572ef 100644 --- a/promql-compliance/runner/checked_in_fixture_validation_test.go +++ b/promql-compliance/runner/checked_in_fixture_validation_test.go @@ -61,3 +61,21 @@ func TestCheckedInOffGridRateSuite(t *testing.T) { } } } + +func TestCheckedInNativeDagSuites(t *testing.T) { + parser := promqlparser.NewParser(promqlparser.Options{}) + for _, path := range []string{ + "../suites/native-dag-aggregations.yaml", + "../suites/native-dag-sparse.yaml", + } { + suite, err := LoadSuiteFile(path) + if err != nil { + t.Fatalf("LoadSuiteFile(%q): %v", path, err) + } + for _, query := range suite.Queries { + if _, err := parser.ParseExpr(query.Expr); err != nil { + t.Fatalf("parse %q: %v", query.Expr, err) + } + } + } +} diff --git a/promql-compliance/suites/native-dag-aggregations.yaml b/promql-compliance/suites/native-dag-aggregations.yaml new file mode 100644 index 00000000..8937fdfe --- /dev/null +++ b/promql-compliance/suites/native-dag-aggregations.yaml @@ -0,0 +1,27 @@ +name: native-dag-aggregations +comparison_defaults: + value_tolerance: + relative: 0.01 + absolute: 0.000001 +queries: + - name: tumbling-sum + expr: sum(data) + instant_offsets_seconds: [300, 600, 900, 1200] + range: + start_offset_seconds: 300 + end_offset_seconds: 1200 + step_seconds: 60 + - name: keyed-count + expr: count(data) by (job) + instant_offsets_seconds: [300, 600, 900, 1200] + range: + start_offset_seconds: 300 + end_offset_seconds: 1200 + step_seconds: 60 + - name: self-keyed-topk + expr: topk(2, data) + instant_offsets_seconds: [300, 600, 900, 1200] + range: + start_offset_seconds: 300 + end_offset_seconds: 1200 + step_seconds: 60 diff --git a/promql-compliance/suites/native-dag-sparse.yaml b/promql-compliance/suites/native-dag-sparse.yaml new file mode 100644 index 00000000..7480f568 --- /dev/null +++ b/promql-compliance/suites/native-dag-sparse.yaml @@ -0,0 +1,13 @@ +name: native-dag-sparse +comparison_defaults: + value_tolerance: + relative: 0 + absolute: 0.000001 +queries: + - name: sparse-series-appears-mid-range + expr: sum(checkout_up) + instant_offsets_seconds: [300, 600, 900, 1200] + range: + start_offset_seconds: 300 + end_offset_seconds: 1200 + step_seconds: 60 From 8385920126c4f0981713b3022c8a15d569fd5132 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sun, 27 Sep 2026 17:16:35 -0400 Subject: [PATCH 20/44] test(query-engine): add legacy range execution selector --- asap-query-engine/Cargo.toml | 1 + .../src/engines/simple_engine/mod.rs | 69 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/asap-query-engine/Cargo.toml b/asap-query-engine/Cargo.toml index 73249126..ed413a18 100644 --- a/asap-query-engine/Cargo.toml +++ b/asap-query-engine/Cargo.toml @@ -97,3 +97,4 @@ jemalloc = ["dep:tikv-jemallocator"] lock_profiling = [] # Enable extra debugging output extra_debugging = [] +native_query_legacy_test_support = [] diff --git a/asap-query-engine/src/engines/simple_engine/mod.rs b/asap-query-engine/src/engines/simple_engine/mod.rs index 412db002..17516605 100644 --- a/asap-query-engine/src/engines/simple_engine/mod.rs +++ b/asap-query-engine/src/engines/simple_engine/mod.rs @@ -65,6 +65,13 @@ pub enum QueryExecutionError { Native(String), } +#[cfg(feature = "native_query_legacy_test_support")] +#[derive(Clone, Copy)] +pub enum NativeRangeExecutionMode { + Dag, + Legacy, +} + /// Parameters for a single store query #[derive(Debug, Clone)] pub struct StoreQueryParams { @@ -323,6 +330,8 @@ pub struct SimpleEngine { data_ingestion_interval_ms: u64, controller_patterns: Vec, query_language: QueryLanguage, + #[cfg(feature = "native_query_legacy_test_support")] + native_range_execution_mode: NativeRangeExecutionMode, } impl SimpleEngine { @@ -496,9 +505,20 @@ impl SimpleEngine { data_ingestion_interval_ms, controller_patterns, query_language, + #[cfg(feature = "native_query_legacy_test_support")] + native_range_execution_mode: NativeRangeExecutionMode::Dag, } } + #[cfg(feature = "native_query_legacy_test_support")] + pub fn with_native_range_execution_mode_for_test( + mut self, + mode: NativeRangeExecutionMode, + ) -> Self { + self.native_range_execution_mode = mode; + self + } + /// Replace the inference config at runtime. Called by the applier task after /// the planner fires. /// @@ -2336,6 +2356,17 @@ impl SimpleEngine { enable_topk_limiting: bool, enable_topk_formatting: bool, ) -> Result, QueryExecutionError> { + #[cfg(feature = "native_query_legacy_test_support")] + if matches!( + self.native_range_execution_mode, + NativeRangeExecutionMode::Legacy + ) { + return self.execute_legacy_range_query_pipeline( + context, + enable_topk_limiting, + enable_topk_formatting, + ); + } let plan = QueryPlan::compile_range( context, PlanOptions { @@ -2358,6 +2389,44 @@ impl SimpleEngine { } } + #[cfg(feature = "native_query_legacy_test_support")] + fn execute_legacy_range_query_pipeline( + &self, + context: &RangeQueryExecutionContext, + enable_topk_limiting: bool, + enable_topk_formatting: bool, + ) -> Result, QueryExecutionError> { + let reads = self.read_range_query_inputs(context)?; + let mut results = self.estimate_range_query( + context, + ResolvedRangeReads { + values: self.compose_range_read(&reads.values), + keys: reads + .keys + .as_ref() + .map(|keys| self.compose_range_read(keys)), + }, + )?; + if enable_topk_limiting && context.base.metadata.statistic_to_compute == Statistic::Topk { + let k = context + .base + .metadata + .query_kwargs + .get("k") + .ok_or_else(|| { + QueryExecutionError::Native("Topk query is missing required `k` parameter".into()) + })?; + results = self + .limit_range_topk(&results, k) + .map_err(QueryExecutionError::Native)?; + } + Ok(self.format_range_results( + &results, + enable_topk_formatting && context.base.metadata.keep_metric_name, + &context.base.metric, + )) + } + fn read_range_query_inputs( &self, context: &RangeQueryExecutionContext, From d01271687f6a1b61293c7cbd9778e366fdd2fe84 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sun, 27 Sep 2026 17:18:15 -0400 Subject: [PATCH 21/44] test(query-engine): compare legacy and DAG range execution --- asap-query-engine/src/engines/mod.rs | 2 + asap-query-engine/src/lib.rs | 2 + .../tests/e2e_precompute_equivalence.rs | 46 +++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/asap-query-engine/src/engines/mod.rs b/asap-query-engine/src/engines/mod.rs index a33d545f..de3aeedc 100644 --- a/asap-query-engine/src/engines/mod.rs +++ b/asap-query-engine/src/engines/mod.rs @@ -7,4 +7,6 @@ pub mod window_merger; pub use query_result::{InstantVector, QueryResult, RangeVector, RangeVectorElement, Sample}; pub use simple_engine::{QueryExecutionError, SimpleEngine}; +#[cfg(feature = "native_query_legacy_test_support")] +pub use simple_engine::NativeRangeExecutionMode; pub use window_merger::{create_window_merger, NaiveMerger, WindowMerger}; diff --git a/asap-query-engine/src/lib.rs b/asap-query-engine/src/lib.rs index b86d8148..274473c1 100644 --- a/asap-query-engine/src/lib.rs +++ b/asap-query-engine/src/lib.rs @@ -34,6 +34,8 @@ pub use precompute_operators::{ pub use stores::{SimpleMapStore, Store, StoreResult}; pub use engines::{InstantVector, QueryExecutionError, QueryResult, SimpleEngine}; +#[cfg(feature = "native_query_legacy_test_support")] +pub use engines::NativeRangeExecutionMode; pub use drivers::{HttpServer, HttpServerConfig, OtlpReceiver, OtlpReceiverConfig}; diff --git a/asap-query-engine/tests/e2e_precompute_equivalence.rs b/asap-query-engine/tests/e2e_precompute_equivalence.rs index 848c5c57..28464f19 100644 --- a/asap-query-engine/tests/e2e_precompute_equivalence.rs +++ b/asap-query-engine/tests/e2e_precompute_equivalence.rs @@ -23,6 +23,8 @@ use query_engine_rust::drivers::ingest::prometheus_remote_write::{ use query_engine_rust::precompute_engine::config::{LateDataPolicy, PrecomputeEngineConfig}; use query_engine_rust::precompute_engine::output_sink::CapturingOutputSink; use query_engine_rust::precompute_engine::{HttpIngestConfig, HttpIngestSource, PrecomputeEngine}; +#[cfg(feature = "native_query_legacy_test_support")] +use query_engine_rust::NativeRangeExecutionMode; use query_engine_rust::{QueryResult, SimpleEngine, SimpleMapStore, Store}; // ─── helpers ──────────────────────────────────────────────────────────────── @@ -155,6 +157,7 @@ fn engine_config() -> PrecomputeEngineConfig { } } +#[derive(Clone)] struct NativeDagScenario<'a> { port: u16, metric: &'a str, @@ -446,6 +449,49 @@ async fn e2e_native_leaf_range_matches_instant_at_range_end() { assert_eq!(final_sample.value, instant.values[0].value); } +#[cfg(feature = "native_query_legacy_test_support")] +#[tokio::test] +async fn e2e_native_dag_range_matches_legacy_range() { + let scenario = NativeDagScenario { + port: 19409, + metric: "dag_differential_requests", + query: "sum_over_time(dag_differential_requests[2s])", + aggregation_configs: vec![make_agg_config( + 9, + "dag_differential_requests", + AggregationType::Sum, + "", + 1_000, + 0, + vec![], + )], + schema_labels: vec![], + samples: vec![ + make_timeseries("dag_differential_requests", vec![], 1_000, 1.0), + make_timeseries("dag_differential_requests", vec![], 2_000, 2.0), + make_timeseries("dag_differential_requests", vec![], 3_000, 3.0), + make_timeseries("dag_differential_requests", vec![], 5_000, 0.0), + ], + evaluation_time_seconds: 3.0, + base_interval_ms: 1_000, + }; + let mut legacy_scenario = scenario.clone(); + legacy_scenario.port = 19410; + let (dag, query) = scenario.build_engine().await; + let (legacy, _) = legacy_scenario.build_engine().await; + let dag = dag + .try_handle_range_query_promql(query.clone(), 2.0, 3.0, 1.0) + .expect("DAG execution should not fail"); + let legacy = legacy + .with_native_range_execution_mode_for_test(NativeRangeExecutionMode::Legacy) + .try_handle_range_query_promql(query, 2.0, 3.0, 1.0) + .expect("legacy execution should not fail"); + assert_eq!( + serde_json::to_value(dag).unwrap(), + serde_json::to_value(legacy).unwrap() + ); +} + /// The #698 boundary contract applies independently to a query's value and /// key precomputes. An endpoint series can only appear when both sides assign /// its sample to the window ending at the evaluation timestamp. From 7ec25ccbb9923ac03cc37a19d45ab6f97a3e4f0b Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sun, 27 Sep 2026 17:20:12 -0400 Subject: [PATCH 22/44] test(query-engine): characterize sparse legacy DAG parity --- .../tests/e2e_precompute_equivalence.rs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/asap-query-engine/tests/e2e_precompute_equivalence.rs b/asap-query-engine/tests/e2e_precompute_equivalence.rs index 28464f19..d331e8ed 100644 --- a/asap-query-engine/tests/e2e_precompute_equivalence.rs +++ b/asap-query-engine/tests/e2e_precompute_equivalence.rs @@ -492,6 +492,50 @@ async fn e2e_native_dag_range_matches_legacy_range() { ); } +#[cfg(feature = "native_query_legacy_test_support")] +#[tokio::test] +async fn e2e_sparse_range_dag_matches_legacy_range() { + let scenario = NativeDagScenario { + port: 19411, + metric: "sparse_dag_differential", + query: "sum(sparse_dag_differential)", + aggregation_configs: vec![make_agg_config( + 10, + "sparse_dag_differential", + AggregationType::Sum, + "", + 1_000, + 0, + vec![], + )], + schema_labels: vec![], + samples: vec![ + make_timeseries("sparse_dag_differential", vec![], 1_000, 1.0), + make_timeseries("sparse_dag_differential", vec![], 2_000, 1.0), + make_timeseries("sparse_dag_differential", vec![], 8_000, 1.0), + make_timeseries("sparse_dag_differential", vec![], 9_000, 1.0), + make_timeseries("sparse_dag_differential", vec![], 12_000, 0.0), + ], + evaluation_time_seconds: 9.0, + base_interval_ms: 1_000, + }; + let mut legacy_scenario = scenario.clone(); + legacy_scenario.port = 19412; + let (dag, query) = scenario.build_engine().await; + let (legacy, _) = legacy_scenario.build_engine().await; + let dag = dag + .try_handle_range_query_promql(query.clone(), 1.0, 9.0, 1.0) + .expect("DAG execution should not fail"); + let legacy = legacy + .with_native_range_execution_mode_for_test(NativeRangeExecutionMode::Legacy) + .try_handle_range_query_promql(query, 1.0, 9.0, 1.0) + .expect("legacy execution should not fail"); + assert_eq!( + serde_json::to_value(dag).unwrap(), + serde_json::to_value(legacy).unwrap() + ); +} + /// The #698 boundary contract applies independently to a query's value and /// key precomputes. An endpoint series can only appear when both sides assign /// its sample to the window ending at the evaluation timestamp. From 933a2af70dd55b22f90690d93c372c10a628bc26 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sun, 27 Sep 2026 17:27:22 -0400 Subject: [PATCH 23/44] test(query-engine): characterize keyed legacy DAG parity --- .../tests/e2e_precompute_equivalence.rs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/asap-query-engine/tests/e2e_precompute_equivalence.rs b/asap-query-engine/tests/e2e_precompute_equivalence.rs index d331e8ed..5ea87111 100644 --- a/asap-query-engine/tests/e2e_precompute_equivalence.rs +++ b/asap-query-engine/tests/e2e_precompute_equivalence.rs @@ -536,6 +536,68 @@ async fn e2e_sparse_range_dag_matches_legacy_range() { ); } +#[cfg(feature = "native_query_legacy_test_support")] +#[tokio::test] +async fn e2e_keyed_count_range_dag_matches_legacy_range() { + let metric = "keyed_dag_differential"; + let mut values = make_agg_config_full( + 11, + metric, + AggregationType::CountMinSketch, + "count", + 1_000, + 0, + vec![], + vec!["host"], + ); + values.parameters.insert("depth".to_string(), json!(3_u64)); + values + .parameters + .insert("width".to_string(), json!(128_u64)); + let scenario = NativeDagScenario { + port: 19413, + metric, + query: "count(keyed_dag_differential) by (host)", + aggregation_configs: vec![ + values, + make_agg_config_full( + 12, + metric, + AggregationType::SetAggregator, + "", + 1_000, + 0, + vec![], + vec!["host"], + ), + ], + schema_labels: vec!["host".to_string()], + samples: vec![ + make_timeseries(metric, vec![("host", "a")], 1_000, 1.0), + make_timeseries(metric, vec![("host", "b")], 2_000, 1.0), + make_timeseries(metric, vec![("host", "a")], 3_000, 1.0), + make_timeseries(metric, vec![], 5_000, 0.0), + ], + evaluation_time_seconds: 3.0, + base_interval_ms: 1_000, + }; + let mut legacy_scenario = scenario.clone(); + legacy_scenario.port = 19414; + let (dag, query) = scenario.build_engine().await; + let (legacy, _) = legacy_scenario.build_engine().await; + let dag = dag + .try_handle_range_query_promql(query.clone(), 1.0, 3.0, 1.0) + .unwrap(); + let legacy = legacy + .with_native_range_execution_mode_for_test(NativeRangeExecutionMode::Legacy) + .try_handle_range_query_promql(query, 1.0, 3.0, 1.0) + .unwrap(); + assert_eq!( + serde_json::to_value(dag).unwrap(), + serde_json::to_value(legacy).unwrap() + ); +} + /// The #698 boundary contract applies independently to a query's value and /// key precomputes. An endpoint series can only appear when both sides assign /// its sample to the window ending at the evaluation timestamp. From 17f506d7e4ad268359d96db074cedb14d2e192de Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sun, 27 Sep 2026 18:45:53 -0400 Subject: [PATCH 24/44] test(query-engine): characterize topk legacy DAG parity --- .../tests/e2e_precompute_equivalence.rs | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/asap-query-engine/tests/e2e_precompute_equivalence.rs b/asap-query-engine/tests/e2e_precompute_equivalence.rs index 5ea87111..15e2cafa 100644 --- a/asap-query-engine/tests/e2e_precompute_equivalence.rs +++ b/asap-query-engine/tests/e2e_precompute_equivalence.rs @@ -192,7 +192,10 @@ impl NativeDagScenario<'_> { }))], ); tokio::spawn(async move { - let _ = engine.run().await; + engine + .run() + .await + .expect("precompute engine should keep running"); }); tokio::time::sleep(tokio::time::Duration::from_millis(300)).await; @@ -598,6 +601,59 @@ async fn e2e_keyed_count_range_dag_matches_legacy_range() { ); } +#[cfg(feature = "native_query_legacy_test_support")] +#[tokio::test] +async fn e2e_self_keyed_topk_dag_matches_legacy_range() { + let metric = "topk_dag_differential"; + let mut config = make_agg_config_full( + 13, + metric, + AggregationType::CountMinSketchWithHeap, + "count", + 1_000, + 0, + vec![], + vec!["host"], + ); + config.parameters.insert("depth".to_string(), json!(3_u64)); + config + .parameters + .insert("width".to_string(), json!(128_u64)); + config + .parameters + .insert("heapsize".to_string(), json!(16_u64)); + let scenario = NativeDagScenario { + port: 19415, + metric, + query: "topk(2, topk_dag_differential)", + aggregation_configs: vec![config], + schema_labels: vec!["host".to_string()], + samples: vec![ + make_timeseries(metric, vec![("host", "a")], 1_000, 1.0), + make_timeseries(metric, vec![("host", "b")], 1_000, 2.0), + make_timeseries(metric, vec![("host", "c")], 1_000, 3.0), + make_timeseries(metric, vec![], 3_000, 0.0), + ], + evaluation_time_seconds: 1.0, + base_interval_ms: 1_000, + }; + let mut legacy_scenario = scenario.clone(); + legacy_scenario.port = 19416; + let (dag, query) = scenario.build_engine().await; + let (legacy, _) = legacy_scenario.build_engine().await; + let dag = dag + .try_handle_range_query_promql(query.clone(), 1.0, 1.0, 1.0) + .unwrap(); + let legacy = legacy + .with_native_range_execution_mode_for_test(NativeRangeExecutionMode::Legacy) + .try_handle_range_query_promql(query, 1.0, 1.0, 1.0) + .unwrap(); + assert_eq!( + serde_json::to_value(dag).unwrap(), + serde_json::to_value(legacy).unwrap() + ); +} + /// The #698 boundary contract applies independently to a query's value and /// key precomputes. An endpoint series can only appear when both sides assign /// its sample to the window ending at the evaluation timestamp. From 36abea34cacfa37cc9b4b9360db44373393c0755 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sun, 27 Sep 2026 20:59:16 -0400 Subject: [PATCH 25/44] test(query-engine): cover malformed native plan errors --- .../src/engines/simple_engine/mod.rs | 8 +++++ .../tests/e2e_precompute_equivalence.rs | 35 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/asap-query-engine/src/engines/simple_engine/mod.rs b/asap-query-engine/src/engines/simple_engine/mod.rs index 17516605..8c6376bf 100644 --- a/asap-query-engine/src/engines/simple_engine/mod.rs +++ b/asap-query-engine/src/engines/simple_engine/mod.rs @@ -70,6 +70,7 @@ pub enum QueryExecutionError { pub enum NativeRangeExecutionMode { Dag, Legacy, + MalformedPlan, } /// Parameters for a single store query @@ -2367,6 +2368,13 @@ impl SimpleEngine { enable_topk_formatting, ); } + #[cfg(feature = "native_query_legacy_test_support")] + if matches!( + self.native_range_execution_mode, + NativeRangeExecutionMode::MalformedPlan + ) { + return Err("test-only malformed native query plan".to_string()); + } let plan = QueryPlan::compile_range( context, PlanOptions { diff --git a/asap-query-engine/tests/e2e_precompute_equivalence.rs b/asap-query-engine/tests/e2e_precompute_equivalence.rs index 15e2cafa..083f9650 100644 --- a/asap-query-engine/tests/e2e_precompute_equivalence.rs +++ b/asap-query-engine/tests/e2e_precompute_equivalence.rs @@ -654,6 +654,41 @@ async fn e2e_self_keyed_topk_dag_matches_legacy_range() { ); } +#[cfg(feature = "native_query_legacy_test_support")] +#[tokio::test] +async fn e2e_malformed_native_plan_returns_local_error() { + let metric = "dag_requests"; + let (engine, query) = NativeDagScenario { + port: 19417, + metric, + query: "sum(dag_requests)", + aggregation_configs: vec![make_agg_config( + 14, + metric, + AggregationType::Sum, + "", + 1_000, + 0, + vec![], + )], + schema_labels: vec![], + samples: vec![ + make_timeseries(metric, vec![], 1_000, 100.0), + make_timeseries(metric, vec![], 1_500, 2.0), + make_timeseries(metric, vec![], 2_000, 3.0), + make_timeseries(metric, vec![], 3_500, 0.0), + ], + evaluation_time_seconds: 2.0, + base_interval_ms: 1_000, + } + .build_engine() + .await; + assert!(engine + .with_native_range_execution_mode_for_test(NativeRangeExecutionMode::MalformedPlan) + .try_handle_range_query_promql(query, 1.0, 2.0, 1.0) + .is_err()); +} + /// The #698 boundary contract applies independently to a query's value and /// key precomputes. An endpoint series can only appear when both sides assign /// its sample to the window ending at the evaluation timestamp. From bce5cc203628e6a64bc3be496337a46da0eedb3a Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sun, 27 Sep 2026 21:00:15 -0400 Subject: [PATCH 26/44] test(query-engine): cover native store failures --- .../src/engines/simple_engine/mod.rs | 8 +++++ .../tests/e2e_precompute_equivalence.rs | 35 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/asap-query-engine/src/engines/simple_engine/mod.rs b/asap-query-engine/src/engines/simple_engine/mod.rs index 8c6376bf..58175103 100644 --- a/asap-query-engine/src/engines/simple_engine/mod.rs +++ b/asap-query-engine/src/engines/simple_engine/mod.rs @@ -71,6 +71,7 @@ pub enum NativeRangeExecutionMode { Dag, Legacy, MalformedPlan, + FailingStore, } /// Parameters for a single store query @@ -2375,6 +2376,13 @@ impl SimpleEngine { ) { return Err("test-only malformed native query plan".to_string()); } + #[cfg(feature = "native_query_legacy_test_support")] + if matches!( + self.native_range_execution_mode, + NativeRangeExecutionMode::FailingStore + ) { + return Err("test-only native store failure".to_string()); + } let plan = QueryPlan::compile_range( context, PlanOptions { diff --git a/asap-query-engine/tests/e2e_precompute_equivalence.rs b/asap-query-engine/tests/e2e_precompute_equivalence.rs index 083f9650..40897591 100644 --- a/asap-query-engine/tests/e2e_precompute_equivalence.rs +++ b/asap-query-engine/tests/e2e_precompute_equivalence.rs @@ -689,6 +689,41 @@ async fn e2e_malformed_native_plan_returns_local_error() { .is_err()); } +#[cfg(feature = "native_query_legacy_test_support")] +#[tokio::test] +async fn e2e_native_store_failure_returns_local_error() { + let metric = "store_failure_differential"; + let (engine, query) = NativeDagScenario { + port: 19418, + metric, + query: "sum(store_failure_differential)", + aggregation_configs: vec![make_agg_config( + 15, + metric, + AggregationType::Sum, + "", + 1_000, + 0, + vec![], + )], + schema_labels: vec![], + samples: vec![ + make_timeseries(metric, vec![], 1_000, 1.0), + make_timeseries(metric, vec![], 1_500, 2.0), + make_timeseries(metric, vec![], 2_000, 3.0), + make_timeseries(metric, vec![], 3_500, 0.0), + ], + evaluation_time_seconds: 2.0, + base_interval_ms: 1_000, + } + .build_engine() + .await; + assert!(engine + .with_native_range_execution_mode_for_test(NativeRangeExecutionMode::FailingStore) + .try_handle_range_query_promql(query, 1.0, 2.0, 1.0) + .is_err()); +} + /// The #698 boundary contract applies independently to a query's value and /// key precomputes. An endpoint series can only appear when both sides assign /// its sample to the window ending at the evaluation timestamp. From 77d6b2d4454e3e4844355637bc71072f8b1672a5 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sun, 27 Sep 2026 21:14:21 -0400 Subject: [PATCH 27/44] test(query-engine): characterize topk tie parity --- asap-query-engine/tests/e2e_precompute_equivalence.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/asap-query-engine/tests/e2e_precompute_equivalence.rs b/asap-query-engine/tests/e2e_precompute_equivalence.rs index 40897591..011c1a35 100644 --- a/asap-query-engine/tests/e2e_precompute_equivalence.rs +++ b/asap-query-engine/tests/e2e_precompute_equivalence.rs @@ -629,8 +629,8 @@ async fn e2e_self_keyed_topk_dag_matches_legacy_range() { aggregation_configs: vec![config], schema_labels: vec!["host".to_string()], samples: vec![ - make_timeseries(metric, vec![("host", "a")], 1_000, 1.0), - make_timeseries(metric, vec![("host", "b")], 1_000, 2.0), + make_timeseries(metric, vec![("host", "a")], 1_000, 3.0), + make_timeseries(metric, vec![("host", "b")], 1_000, 3.0), make_timeseries(metric, vec![("host", "c")], 1_000, 3.0), make_timeseries(metric, vec![], 3_000, 0.0), ], From a2364e712c18114abe56ffb7657c15f74382e7c6 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sun, 27 Sep 2026 21:20:51 -0400 Subject: [PATCH 28/44] test(query-engine): compare DeltaSet replay DAG execution --- .../src/tests/native_range_query_tests.rs | 129 +++++++++++------- 1 file changed, 77 insertions(+), 52 deletions(-) diff --git a/asap-query-engine/src/tests/native_range_query_tests.rs b/asap-query-engine/src/tests/native_range_query_tests.rs index 28d6734a..c291c27e 100644 --- a/asap-query-engine/src/tests/native_range_query_tests.rs +++ b/asap-query-engine/src/tests/native_range_query_tests.rs @@ -37,6 +37,8 @@ mod tests { use crate::stores::Store; use crate::tests::test_utilities::engine_factories::create_engine_multi_timestamp_with_window; use crate::AggregateCore; + #[cfg(feature = "native_query_legacy_test_support")] + use crate::NativeRangeExecutionMode; use promql_utilities::data_model::KeyByLabelNames; use std::collections::HashMap; use std::sync::Arc; @@ -284,6 +286,61 @@ mod tests { ) } + fn create_oscillating_delta_set_engine() -> SimpleEngine { + let value_data: TimeSeriesData = (1..=5) + .map(|i| { + ( + i * 1000, + None, + Box::new(CountMinSketchAccumulator::new(2, 3)) as Box, + ) + }) + .collect(); + + let mut keys_add = DeltaSetAggregatorAccumulator::new(); + keys_add.add_key(KeyByLabelValues { + labels: vec!["host-a".to_string(), "evt-1".to_string()], + }); + let mut keys_remove = DeltaSetAggregatorAccumulator::new(); + keys_remove.remove_key(KeyByLabelValues { + labels: vec!["host-a".to_string(), "evt-1".to_string()], + }); + let keys_data: TimeSeriesData = vec![ + ( + 1000, + None, + Box::new(keys_add.clone()) as Box, + ), + ( + 2000, + None, + Box::new(keys_remove.clone()) as Box, + ), + ( + 3000, + None, + Box::new(keys_add.clone()) as Box, + ), + ( + 4000, + None, + Box::new(keys_remove.clone()) as Box, + ), + (5000, None, Box::new(keys_add) as Box), + ]; + + create_range_engine_dual_input( + "event_frequency", + AggregationType::CountMinSketch, + AggregationType::DeltaSetAggregator, + vec![], + vec!["host", "event"], + value_data, + keys_data, + "count(event_frequency) by (host, event)", + ) + } + /// Same as `create_range_engine_dual_input`, but lets the value and key /// aggregations use different bucket widths — the value aggregation's /// `tumbling_window_ms` must not be assumed to also be the key @@ -1437,58 +1494,7 @@ mod tests { // delta is an add) and reuses it for every step — so it would wrongly // show host-a present at every step, including the two "removed" // windows (t=2000, t=4000). - let value_data: TimeSeriesData = (1..=5) - .map(|i| { - ( - i * 1000, - None, - Box::new(CountMinSketchAccumulator::new(2, 3)) as Box, - ) - }) - .collect(); - - let mut keys_add = DeltaSetAggregatorAccumulator::new(); - keys_add.add_key(KeyByLabelValues { - labels: vec!["host-a".to_string(), "evt-1".to_string()], - }); - let mut keys_remove = DeltaSetAggregatorAccumulator::new(); - keys_remove.remove_key(KeyByLabelValues { - labels: vec!["host-a".to_string(), "evt-1".to_string()], - }); - let keys_data: TimeSeriesData = vec![ - ( - 1000, - None, - Box::new(keys_add.clone()) as Box, - ), - ( - 2000, - None, - Box::new(keys_remove.clone()) as Box, - ), - ( - 3000, - None, - Box::new(keys_add.clone()) as Box, - ), - ( - 4000, - None, - Box::new(keys_remove.clone()) as Box, - ), - (5000, None, Box::new(keys_add) as Box), - ]; - - let engine = create_range_engine_dual_input( - "event_frequency", - AggregationType::CountMinSketch, - AggregationType::DeltaSetAggregator, - vec![], - vec!["host", "event"], - value_data, - keys_data, - "count(event_frequency) by (host, event)", - ); + let engine = create_oscillating_delta_set_engine(); let query = "count(event_frequency) by (host, event)"; let result = engine @@ -1518,6 +1524,25 @@ mod tests { ); } + #[cfg(feature = "native_query_legacy_test_support")] + #[test] + fn range_query_delta_set_replay_dag_matches_legacy() { + let query = "count(event_frequency) by (host, event)"; + let dag = create_oscillating_delta_set_engine() + .try_handle_range_query_promql(query.to_string(), 1.0, 5.0, 1.0) + .expect("DAG execution failed"); + let legacy = create_oscillating_delta_set_engine() + .with_native_range_execution_mode_for_test(NativeRangeExecutionMode::Legacy) + .try_handle_range_query_promql(query.to_string(), 1.0, 5.0, 1.0) + .expect("legacy execution failed"); + + assert_eq!( + serde_json::to_value(dag).expect("DAG result should serialize"), + serde_json::to_value(legacy).expect("legacy result should serialize"), + "DAG execution must preserve DeltaSet replay semantics" + ); + } + #[tokio::test(flavor = "multi_thread")] async fn range_query_binary_expr_arm_set_aggregator_earlier_key_not_silently_dropped() { // SetAggregator counterpart to From 98c974464a58bf96eb4b6c21b496b7ba8eb546a7 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sun, 27 Sep 2026 21:23:58 -0400 Subject: [PATCH 29/44] test(query-engine): cover local native execution failures --- .../src/engines/simple_engine/mod.rs | 14 --- .../src/engines/simple_engine/promql.rs | 21 ++++ .../src/tests/prometheus_forwarding_tests.rs | 107 ++++++++++++++++++ 3 files changed, 128 insertions(+), 14 deletions(-) diff --git a/asap-query-engine/src/engines/simple_engine/mod.rs b/asap-query-engine/src/engines/simple_engine/mod.rs index 58175103..db204a0f 100644 --- a/asap-query-engine/src/engines/simple_engine/mod.rs +++ b/asap-query-engine/src/engines/simple_engine/mod.rs @@ -2369,20 +2369,6 @@ impl SimpleEngine { enable_topk_formatting, ); } - #[cfg(feature = "native_query_legacy_test_support")] - if matches!( - self.native_range_execution_mode, - NativeRangeExecutionMode::MalformedPlan - ) { - return Err("test-only malformed native query plan".to_string()); - } - #[cfg(feature = "native_query_legacy_test_support")] - if matches!( - self.native_range_execution_mode, - NativeRangeExecutionMode::FailingStore - ) { - return Err("test-only native store failure".to_string()); - } let plan = QueryPlan::compile_range( context, PlanOptions { diff --git a/asap-query-engine/src/engines/simple_engine/promql.rs b/asap-query-engine/src/engines/simple_engine/promql.rs index ace13cf7..639d3664 100644 --- a/asap-query-engine/src/engines/simple_engine/promql.rs +++ b/asap-query-engine/src/engines/simple_engine/promql.rs @@ -3,6 +3,8 @@ //! Contains all PromQL-specific context building, pattern matching, binary arithmetic //! dispatch, range-query handling, and query dispatch. +#[cfg(feature = "native_query_legacy_test_support")] +use super::NativeRangeExecutionMode; use super::SimpleEngine; use super::{ QueryExecutionContext, QueryExecutionError, QueryMetadata, QueryTimestamps, @@ -1355,6 +1357,25 @@ impl SimpleEngine { query, start, end, step ); + #[cfg(feature = "native_query_legacy_test_support")] + if matches!( + self.native_range_execution_mode, + NativeRangeExecutionMode::MalformedPlan + ) { + return Err(QueryExecutionError::Native( + "test-only malformed native query plan".to_string(), + )); + } + #[cfg(feature = "native_query_legacy_test_support")] + if matches!( + self.native_range_execution_mode, + NativeRangeExecutionMode::FailingStore + ) { + return Err(QueryExecutionError::Native( + "test-only native store failure".to_string(), + )); + } + let ast = match promql_parser::parser::parse(&query) { Ok(ast) => ast, Err(e) => { diff --git a/asap-query-engine/src/tests/prometheus_forwarding_tests.rs b/asap-query-engine/src/tests/prometheus_forwarding_tests.rs index 03a7b4c7..70717fb9 100644 --- a/asap-query-engine/src/tests/prometheus_forwarding_tests.rs +++ b/asap-query-engine/src/tests/prometheus_forwarding_tests.rs @@ -4,8 +4,12 @@ use crate::drivers::query::adapters::AdapterConfig; use crate::drivers::query::servers::http::{HttpServer, HttpServerConfig}; use crate::engines::SimpleEngine; use crate::stores::simple_map_store::SimpleMapStore; +#[cfg(feature = "native_query_legacy_test_support")] +use crate::NativeRangeExecutionMode; use reqwest::Client; use serde_json::Value; +#[cfg(feature = "native_query_legacy_test_support")] +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use tokio::net::TcpListener; use tokio::time::{sleep, Duration}; @@ -100,6 +104,35 @@ async fn start_mock_prometheus_server() -> Result (u16, Arc) { + use axum::{response::Json, routing::get, Router}; + use serde_json::json; + + let requests = Arc::new(AtomicUsize::new(0)); + let request_counter = requests.clone(); + let app = Router::new().route( + "/api/v1/query_range", + get(move || { + let request_counter = request_counter.clone(); + async move { + request_counter.fetch_add(1, Ordering::SeqCst); + Json(json!({"status": "success", "data": {"resultType": "matrix", "result": []}})) + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("mock fallback should bind"); + let port = listener.local_addr().expect("mock fallback address").port(); + tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("mock fallback should run"); + }); + (port, requests) +} + async fn setup_test_server(prometheus_port: u16) -> (HttpServer, u16) { let config = HttpServerConfig { port: 0, // Use random port @@ -135,6 +168,44 @@ async fn setup_test_server(prometheus_port: u16) -> (HttpServer, u16) { (server, actual_port) } +#[cfg(feature = "native_query_legacy_test_support")] +async fn setup_test_server_with_native_range_mode( + prometheus_port: u16, + mode: NativeRangeExecutionMode, +) -> (HttpServer, u16) { + let config = HttpServerConfig { + port: 0, + handle_http_requests: true, + adapter_config: AdapterConfig::prometheus_promql( + format!("http://127.0.0.1:{prometheus_port}"), + true, + 30, + ), + }; + let inference_config = InferenceConfig::new(QueryLanguage::promql, CleanupPolicy::NoCleanup); + let streaming_config = Arc::new(StreamingConfig::default()); + let store = Arc::new(SimpleMapStore::new( + streaming_config.clone(), + CleanupPolicy::NoCleanup, + )); + let query_engine = Arc::new( + SimpleEngine::new( + store.clone(), + inference_config, + streaming_config, + 15000, + QueryLanguage::promql, + ) + .with_native_range_execution_mode_for_test(mode), + ); + let server = HttpServer::new(config, query_engine, store, None); + let port = server + .start_test_server() + .await + .expect("test query server should start"); + (server, port) +} + #[tokio::test] async fn test_prometheus_forwarding_instant_query() { // Start mock Prometheus server @@ -340,6 +411,42 @@ async fn test_prometheus_forwarding_range_query() { assert_eq!(result["values"][1][1], "43.0"); } +#[cfg(feature = "native_query_legacy_test_support")] +#[tokio::test] +async fn native_range_execution_error_is_local_and_does_not_fallback() { + for mode in [ + NativeRangeExecutionMode::MalformedPlan, + NativeRangeExecutionMode::FailingStore, + ] { + let (prometheus_port, fallback_requests) = start_counting_mock_prometheus_server().await; + let (_server, server_port) = + setup_test_server_with_native_range_mode(prometheus_port, mode).await; + + let response = Client::new() + .get(format!("http://127.0.0.1:{server_port}/api/v1/query_range")) + .query(&[ + ("query", "unsupported_metric"), + ("start", "1"), + ("end", "2"), + ("step", "1"), + ]) + .send() + .await + .expect("range request should complete"); + let body: Value = response + .json() + .await + .expect("error response should be JSON"); + + assert_eq!(body["status"], "error"); + assert_eq!( + fallback_requests.load(Ordering::SeqCst), + 0, + "native execution errors must not be forwarded to Prometheus" + ); + } +} + #[tokio::test] async fn test_range_query_forwarding_disabled() { let config = HttpServerConfig { From fc72694b414a19c09fa612649b1d242f16a90c12 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sun, 27 Sep 2026 22:02:31 -0400 Subject: [PATCH 30/44] docs(query-engine): record native DAG follow-up work --- docs/729-native-query-dag-todos.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 docs/729-native-query-dag-todos.md diff --git a/docs/729-native-query-dag-todos.md b/docs/729-native-query-dag-todos.md new file mode 100644 index 00000000..435497b2 --- /dev/null +++ b/docs/729-native-query-dag-todos.md @@ -0,0 +1,25 @@ +# Native query DAG: remaining work + +This is the completion checklist for the native range-query DAG cutover. + +## Required before removing the temporary comparison path + +- [ ] Re-run the Docker differential matrix from an isolated Compose lifecycle and record the result for every case. + The current runner uses the fixed Compose project name `asapquery-differential`; interrupted or overlapping runs can race `up`/`down` and leave a Prometheus instance that rejects a repeat fixture as out-of-order. Make each invocation isolated (or serialize it), use a unique base timestamp, and retain every JSON report. +- [ ] Classify every remaining Prometheus differential mismatch as either a pre-existing legacy parity gap or a DAG regression. The legacy-versus-DAG E2E matrix is the evidence for that classification. +- [ ] Add any missing public E2E characterization needed by a classified regression, then fix only DAG-caused regressions. + +## Public error API + +- [ ] Complete the agreed API migration: make `handle_query_promql` and `handle_range_query_promql` return `Result, QueryExecutionError>` rather than flattening native failures to `None`. + `try_handle_*` already supplies this contract and the HTTP server uses it. Migrate direct callers and their capability-miss assertions in one focused commit, then remove the compatibility wrappers. +- [ ] Keep the error taxonomy explicit: `Ok(None)` means unsupported and fallback is allowed; `Err(QueryExecutionError)` means an accepted native execution failed and fallback is forbidden. + +## Remove temporary cutover machinery + +- [ ] After the differential matrix is accepted, delete `native_query_legacy_test_support`, `NativeRangeExecutionMode`, the legacy executor, and legacy-versus-DAG-only tests. +- [ ] Retain DAG-only E2E behavior tests, malformed-plan graph validation, local HTTP error/no-fallback coverage, and Docker compliance suites. + +## Follow-up scope + +- [ ] Implement complete native-query DAG execution in #743: represent arithmetic, constants, label matching, timestamp alignment, and fallback decisions in the plan rather than only executing each native query arm through a DAG. From 87eb352aeddf5562403fe298d60cb18adefe8be8 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sun, 27 Sep 2026 22:02:37 -0400 Subject: [PATCH 31/44] fix(compliance): isolate differential compose runs --- promql-compliance/runner/Makefile | 15 ++++++++++++++- promql-compliance/runner/run.go | 2 ++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/promql-compliance/runner/Makefile b/promql-compliance/runner/Makefile index 2504c49a..bf28f5c1 100644 --- a/promql-compliance/runner/Makefile +++ b/promql-compliance/runner/Makefile @@ -3,6 +3,9 @@ DATASET ?= ../datasets/single-rate.yaml SUITE ?= ../suites/temporal.yaml REPORT_DIR ?= /tmp/asapquery-differential-reports +RUN_ID ?= $(shell date +%s%N) +COMPOSE_PROJECT ?= asapquery-differential-$(RUN_ID) +BASE_TIME_MS ?= $(shell date +%s%3N) CI_CASES := \ single-rate-temporal:../datasets/single-rate.yaml:../suites/temporal.yaml \ @@ -24,7 +27,9 @@ run: go run ./cmd/differential-runner \ --dataset $(DATASET) \ --suite $(SUITE) \ - --compose-file ../docker-compose.yml + --compose-file ../docker-compose.yml \ + --compose-project $(COMPOSE_PROJECT) \ + --base-time-ms $(BASE_TIME_MS) run-all: @$(MAKE) --no-print-directory run-cases CASES="$(CI_CASES) $(NON_CI_CASES)" @@ -36,16 +41,24 @@ run-cases: @set -eu; \ mkdir -p "$(REPORT_DIR)"; \ result=0; \ + run_id="$$(date +%s%N)"; \ + base_time_ms="$$(date +%s%3N)"; \ + case_index=0; \ for test_case in $(CASES); do \ name="$${test_case%%:*}"; \ rest="$${test_case#*:}"; \ dataset="$${rest%%:*}"; \ suite="$${rest#*:}"; \ + project="asapquery-differential-$$name-$$run_id"; \ + case_base_time_ms="$$((base_time_ms + case_index * 60000))"; \ + case_index="$$((case_index + 1))"; \ echo "Running $$name ($$dataset, $$suite)"; \ if ! go run ./cmd/differential-runner \ --dataset "$$dataset" \ --suite "$$suite" \ --compose-file ../docker-compose.yml \ + --compose-project "$$project" \ + --base-time-ms "$$case_base_time_ms" \ --output "$(REPORT_DIR)/$$name.json"; then \ echo "FAILED: $$name" >&2; \ result=1; \ diff --git a/promql-compliance/runner/run.go b/promql-compliance/runner/run.go index 3900b667..d4aebfbc 100644 --- a/promql-compliance/runner/run.go +++ b/promql-compliance/runner/run.go @@ -284,6 +284,8 @@ func (l *composeLifecycle) Start(ctx context.Context) error { command.Env = append(os.Environ(), l.env...) output, err := command.CombinedOutput() if err != nil { + l.started = true + l.Stop() return fmt.Errorf("start compose project %q: %w\n%s", l.project, err, output) } l.started = true From d10a1cfe57edbfc946e8dfbac9e7b11501b242fa Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sun, 27 Sep 2026 22:03:29 -0400 Subject: [PATCH 32/44] fix(compliance): seed differential data in the past --- promql-compliance/runner/Makefile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/promql-compliance/runner/Makefile b/promql-compliance/runner/Makefile index bf28f5c1..3369b73d 100644 --- a/promql-compliance/runner/Makefile +++ b/promql-compliance/runner/Makefile @@ -5,7 +5,7 @@ SUITE ?= ../suites/temporal.yaml REPORT_DIR ?= /tmp/asapquery-differential-reports RUN_ID ?= $(shell date +%s%N) COMPOSE_PROJECT ?= asapquery-differential-$(RUN_ID) -BASE_TIME_MS ?= $(shell date +%s%3N) +BASE_TIME_MS ?= $(shell now_ms=$$(date +%s%3N); echo $$((now_ms / 300000 * 300000 - 1800000))) CI_CASES := \ single-rate-temporal:../datasets/single-rate.yaml:../suites/temporal.yaml \ @@ -42,7 +42,8 @@ run-cases: mkdir -p "$(REPORT_DIR)"; \ result=0; \ run_id="$$(date +%s%N)"; \ - base_time_ms="$$(date +%s%3N)"; \ + now_ms="$$(date +%s%3N)"; \ + base_time_ms="$$((now_ms / 300000 * 300000 - 1800000))"; \ case_index=0; \ for test_case in $(CASES); do \ name="$${test_case%%:*}"; \ From 691945bc0500ab4918a0b8133c2535631f5e7546 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Mon, 28 Sep 2026 14:10:46 -0400 Subject: [PATCH 33/44] feat(query-engine): trace native DAG execution --- asap-query-engine/src/engines/query_plan.rs | 24 +++++++++++++++++++++ promql-compliance/docker-compose.yml | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/asap-query-engine/src/engines/query_plan.rs b/asap-query-engine/src/engines/query_plan.rs index 3bf81d57..411fcdfb 100644 --- a/asap-query-engine/src/engines/query_plan.rs +++ b/asap-query-engine/src/engines/query_plan.rs @@ -3,6 +3,7 @@ use crate::engines::simple_engine::{RangeQueryExecutionContext, StoreQueryParams}; use asap_types::enums::WindowType; use promql_utilities::query_logics::enums::Statistic; +use tracing::debug; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct NodeId(usize); @@ -177,11 +178,23 @@ impl QueryPlan { .into_iter() .map(|input| outputs[input.0].clone()) .collect::>(); + debug!( + node_id = index, + node_kind = node.kind(), + input_count = inputs.len(), + "Executing native query plan node" + ); let output = runtime .execute_node(NodeId(index), node, &inputs) .map_err(|error| format!("Query plan node n{index} failed: {error}"))?; + debug!( + node_id = index, + node_kind = node.kind(), + "Completed native query plan node" + ); outputs.push(output); } + debug!(root_node_id = self.root.0, "Completed native query plan"); Ok(outputs[self.root.0].clone()) } @@ -264,6 +277,17 @@ impl QueryPlan { } impl QueryPlanNode { + fn kind(&self) -> &'static str { + match self { + Self::StoreRead { .. } => "StoreRead", + Self::ComposeWindows { .. } => "ComposeWindows", + Self::ResolveKeys { .. } => "ResolveKeys", + Self::Estimate { .. } => "Estimate", + Self::LimitTopK { .. } => "LimitTopK", + Self::Format { .. } => "Format", + } + } + fn inputs(&self) -> Vec { match self { Self::StoreRead { .. } => Vec::new(), diff --git a/promql-compliance/docker-compose.yml b/promql-compliance/docker-compose.yml index 0a20620d..df3877e4 100644 --- a/promql-compliance/docker-compose.yml +++ b/promql-compliance/docker-compose.yml @@ -52,7 +52,7 @@ services: - "${ASAP_QUERY_PORT:-18088}:8088" - "${ASAP_INGEST_PORT:-19091}:9091" environment: - RUST_LOG: INFO + RUST_LOG: ${RUST_LOG:-INFO} RUST_BACKTRACE: "1" volumes: - differential-planner-output:/asap-planner-output:ro From 171d51688b5e54acef10bfb713852fa8b793df20 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Mon, 28 Sep 2026 22:08:18 -0400 Subject: [PATCH 34/44] fix(query-engine): preserve grouped quantile labels --- asap-query-engine/src/engines/query_plan.rs | 4 +- .../tests/e2e_precompute_equivalence.rs | 59 +++++++++++++++++++ .../suites/native-dag-aggregations.yaml | 8 +++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/asap-query-engine/src/engines/query_plan.rs b/asap-query-engine/src/engines/query_plan.rs index 411fcdfb..9c12c28e 100644 --- a/asap-query-engine/src/engines/query_plan.rs +++ b/asap-query-engine/src/engines/query_plan.rs @@ -135,7 +135,9 @@ impl QueryPlan { &mut nodes, QueryPlanNode::Format { input: root, - include_metric_name: context.base.metadata.keep_metric_name, + include_metric_name: context.base.metadata.statistic_to_compute + == Statistic::Topk + && context.base.metadata.keep_metric_name, metric: context.base.metric.clone(), }, ); diff --git a/asap-query-engine/tests/e2e_precompute_equivalence.rs b/asap-query-engine/tests/e2e_precompute_equivalence.rs index 011c1a35..2365be14 100644 --- a/asap-query-engine/tests/e2e_precompute_equivalence.rs +++ b/asap-query-engine/tests/e2e_precompute_equivalence.rs @@ -860,6 +860,65 @@ async fn e2e_quantile_over_time_uses_open_closed_evaluation_window() { assert_eq!(vector.values[0].value, 4.0); } +/// Regression: grouped quantiles retain their grouping labels. The native DAG +/// must not prepend the metric name; only PromQL topk has that output shape. +#[tokio::test] +async fn e2e_grouped_quantile_preserves_output_label_shape() { + let port = 19416u16; + let metric = "grouped_latency"; + let query = "quantile by (job) (0.99, grouped_latency)"; + let mut config = make_agg_config( + 16, + metric, + AggregationType::DatasketchesKLL, + "", + 1_000, + 0, + vec!["job"], + ); + config.parameters.insert("K".to_string(), json!(200_u64)); + let samples = [("frontend", 100.0), ("backend", 200.0)] + .into_iter() + .flat_map(|(job, value)| { + [ + make_timeseries(metric, vec![("job", job)], 1_500, value), + make_timeseries(metric, vec![("job", job)], 3_500, 0.0), + ] + }) + .collect(); + let (engine, query) = NativeDagScenario { + port, + metric, + query, + aggregation_configs: vec![config], + schema_labels: vec!["job".to_string()], + samples, + evaluation_time_seconds: 2.0, + base_interval_ms: 1_000, + } + .build_engine() + .await; + + let (output_labels, result) = engine + .try_handle_query_promql(query, 2.0) + .expect("grouped quantile should execute") + .expect("grouped quantile should match configured inference"); + assert_eq!(output_labels.labels, vec!["job"]); + let QueryResult::Vector(vector) = result else { + panic!("expected instant vector result"); + }; + let mut returned_labels: Vec<_> = vector + .values + .into_iter() + .map(|element| element.labels.labels) + .collect(); + returned_labels.sort(); + assert_eq!( + returned_labels, + vec![vec!["backend".to_string()], vec!["frontend".to_string()]] + ); +} + /// Sliding precomputes keep their existing exact-cover composition while /// samples on every slide boundary move to the pane ending at that boundary. /// The shared 6s boundary must be counted once, not once per stored window. diff --git a/promql-compliance/suites/native-dag-aggregations.yaml b/promql-compliance/suites/native-dag-aggregations.yaml index 8937fdfe..94d7557d 100644 --- a/promql-compliance/suites/native-dag-aggregations.yaml +++ b/promql-compliance/suites/native-dag-aggregations.yaml @@ -18,6 +18,14 @@ queries: start_offset_seconds: 300 end_offset_seconds: 1200 step_seconds: 60 + # Regression: native formatting must preserve the grouped label values. + - name: grouped-quantile-labels + expr: quantile by (job) (0.99, data) + instant_offsets_seconds: [300, 600, 900, 1200] + range: + start_offset_seconds: 300 + end_offset_seconds: 1200 + step_seconds: 60 - name: self-keyed-topk expr: topk(2, data) instant_offsets_seconds: [300, 600, 900, 1200] From 5bed5d4193cf5b9121ba9814e58bfa2ba1f2553d Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Thu, 1 Oct 2026 09:11:51 -0400 Subject: [PATCH 35/44] refactor(query-engine): preserve typed DAG execution errors --- .../src/drivers/query/servers/http.rs | 4 +- asap-query-engine/src/engines/mod.rs | 2 +- asap-query-engine/src/engines/query_plan.rs | 30 ++++- .../src/engines/simple_engine/mod.rs | 112 +++++++++--------- .../src/engines/simple_engine/promql.rs | 4 +- asap-query-engine/src/lib.rs | 2 +- .../src/tests/native_range_query_tests.rs | 4 +- .../tests/e2e_precompute_equivalence.rs | 64 +++++----- 8 files changed, 127 insertions(+), 95 deletions(-) diff --git a/asap-query-engine/src/drivers/query/servers/http.rs b/asap-query-engine/src/drivers/query/servers/http.rs index c6fe9db4..94ce7fd7 100644 --- a/asap-query-engine/src/drivers/query/servers/http.rs +++ b/asap-query-engine/src/drivers/query/servers/http.rs @@ -207,7 +207,7 @@ async fn process_query_request( ); match state .query_engine - .try_handle_query(parsed_request.query.clone(), parsed_request.time) + .handle_query(parsed_request.query.clone(), parsed_request.time) { Ok(Some((query_output_labels, query_result))) => { let query_duration = query_start_time.elapsed(); @@ -544,7 +544,7 @@ async fn process_range_query_request( parsed_request.query, parsed_request.start, parsed_request.end, parsed_request.step ); - match state.query_engine.try_handle_range_query_promql( + match state.query_engine.handle_range_query_promql( parsed_request.query.clone(), parsed_request.start, parsed_request.end, diff --git a/asap-query-engine/src/engines/mod.rs b/asap-query-engine/src/engines/mod.rs index de3aeedc..27898c14 100644 --- a/asap-query-engine/src/engines/mod.rs +++ b/asap-query-engine/src/engines/mod.rs @@ -6,7 +6,7 @@ pub(crate) mod sliding_window_composition; pub mod window_merger; pub use query_result::{InstantVector, QueryResult, RangeVector, RangeVectorElement, Sample}; -pub use simple_engine::{QueryExecutionError, SimpleEngine}; #[cfg(feature = "native_query_legacy_test_support")] pub use simple_engine::NativeRangeExecutionMode; +pub use simple_engine::{QueryExecutionError, SimpleEngine}; pub use window_merger::{create_window_merger, NaiveMerger, WindowMerger}; diff --git a/asap-query-engine/src/engines/query_plan.rs b/asap-query-engine/src/engines/query_plan.rs index 9c12c28e..f68e5d8b 100644 --- a/asap-query-engine/src/engines/query_plan.rs +++ b/asap-query-engine/src/engines/query_plan.rs @@ -71,6 +71,23 @@ pub(crate) trait QueryPlanRuntime { ) -> Result; } +#[derive(Debug)] +pub(crate) enum QueryPlanExecutionError { + InvalidPlan(String), + Node { id: NodeId, source: E }, +} + +impl std::fmt::Display for QueryPlanExecutionError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidPlan(error) => write!(formatter, "invalid query plan: {error}"), + Self::Node { id, source } => { + write!(formatter, "Query plan node n{} failed: {source}", id.0) + } + } + } +} + impl QueryPlan { pub(crate) fn compile_range( context: &RangeQueryExecutionContext, @@ -171,8 +188,12 @@ impl QueryPlan { Ok(()) } - pub(crate) fn execute(&self, runtime: &R) -> Result { - self.validate()?; + pub(crate) fn execute( + &self, + runtime: &R, + ) -> Result> { + self.validate() + .map_err(QueryPlanExecutionError::InvalidPlan)?; let mut outputs: Vec = Vec::with_capacity(self.nodes.len()); for (index, node) in self.nodes.iter().enumerate() { let inputs = node @@ -188,7 +209,10 @@ impl QueryPlan { ); let output = runtime .execute_node(NodeId(index), node, &inputs) - .map_err(|error| format!("Query plan node n{index} failed: {error}"))?; + .map_err(|source| QueryPlanExecutionError::Node { + id: NodeId(index), + source, + })?; debug!( node_id = index, node_kind = node.kind(), diff --git a/asap-query-engine/src/engines/simple_engine/mod.rs b/asap-query-engine/src/engines/simple_engine/mod.rs index db204a0f..aa42adce 100644 --- a/asap-query-engine/src/engines/simple_engine/mod.rs +++ b/asap-query-engine/src/engines/simple_engine/mod.rs @@ -8,7 +8,9 @@ use crate::data_model::{ AggregationIdInfo, InferenceConfig, KeyByLabelValues, QueryBounds, QueryConfig, QueryLanguage, StreamingConfig, }; -use crate::engines::query_plan::{NodeId, PlanOptions, QueryPlan, QueryPlanNode, QueryPlanRuntime}; +use crate::engines::query_plan::{ + NodeId, PlanOptions, QueryPlan, QueryPlanExecutionError, QueryPlanNode, QueryPlanRuntime, +}; use crate::engines::query_result::{InstantVectorElement, QueryResult}; use crate::engines::sliding_window_composition::{ plan_exact_cover, CompositionError, SlidingWindowSpec, @@ -214,13 +216,9 @@ struct NativePlanRuntime<'a> { } impl NativePlanRuntime<'_> { - fn reads(&self) -> Result { + fn reads(&self) -> Result { if self.reads.borrow().is_none() { - *self.reads.borrow_mut() = Some( - self.engine - .read_range_query_inputs(self.context) - .map_err(|error| error.to_string())?, - ); + *self.reads.borrow_mut() = Some(self.engine.read_range_query_inputs(self.context)?); } Ok(self .reads @@ -233,7 +231,7 @@ impl NativePlanRuntime<'_> { impl QueryPlanRuntime for NativePlanRuntime<'_> { type Output = NativePlanOutput; - type Error = String; + type Error = QueryExecutionError; fn execute_node( &self, @@ -248,17 +246,20 @@ impl QueryPlanRuntime for NativePlanRuntime<'_> { { Ok(NativePlanOutput::Read(reads.values)) } else { - reads - .keys - .map(NativePlanOutput::Read) - .ok_or_else(|| "Query plan requested missing key read".to_string()) + reads.keys.map(NativePlanOutput::Read).ok_or_else(|| { + QueryExecutionError::Native( + "Query plan requested missing key read".to_string(), + ) + }) } } QueryPlanNode::ComposeWindows { .. } => match inputs { [NativePlanOutput::Read(data)] => Ok(NativePlanOutput::Composed( self.engine.compose_range_read(data), )), - _ => Err("ComposeWindows expected store data".into()), + _ => Err(QueryExecutionError::Native( + "ComposeWindows expected store data".into(), + )), }, QueryPlanNode::ResolveKeys { keys, .. } => match (inputs, keys) { ([NativePlanOutput::Composed(values)], None) => { @@ -274,24 +275,28 @@ impl QueryPlanRuntime for NativePlanRuntime<'_> { values: values.clone(), keys: Some(keys.clone()), })), - _ => Err("ResolveKeys received incompatible inputs".into()), + _ => Err(QueryExecutionError::Native( + "ResolveKeys received incompatible inputs".into(), + )), }, QueryPlanNode::Estimate { .. } => match inputs { [NativePlanOutput::Resolved(reads)] => self .engine .estimate_range_query(self.context, reads.clone()) - .map_err(|error| error.to_string()) .map(NativePlanOutput::Results), - _ => Err("Estimate expected resolved reads".into()), + _ => Err(QueryExecutionError::Native( + "Estimate expected resolved reads".into(), + )), }, QueryPlanNode::LimitTopK { k, .. } => match inputs { [NativePlanOutput::Results(results)] => self .engine .limit_range_topk(results, k) .map_err(QueryExecutionError::Native) - .map_err(|error| error.to_string()) .map(NativePlanOutput::Results), - _ => Err("LimitTopK expected estimates".into()), + _ => Err(QueryExecutionError::Native( + "LimitTopK expected estimates".into(), + )), }, QueryPlanNode::Format { include_metric_name, @@ -302,7 +307,9 @@ impl QueryPlanRuntime for NativePlanRuntime<'_> { self.engine .format_range_results(results, *include_metric_name, metric), )), - _ => Err("result node expected estimates".into()), + _ => Err(QueryExecutionError::Native( + "result node expected estimates".into(), + )), }, } } @@ -1824,17 +1831,6 @@ impl SimpleEngine { } } - pub fn try_handle_query( - &self, - query: String, - time: f64, - ) -> Result, QueryExecutionError> { - match self.query_language { - QueryLanguage::promql => self.try_handle_query_promql(query, time), - _ => Ok(self.handle_query(query, time)), - } - } - /// Merge precomputed outputs (extracts buckets from timestamped data) #[allow(dead_code)] fn merge_precomputed_outputs( @@ -2383,7 +2379,10 @@ impl SimpleEngine { context, reads: std::cell::RefCell::new(None), }; - match plan.execute(&runtime).map_err(QueryExecutionError::Native)? { + match plan.execute(&runtime).map_err(|error| match error { + QueryPlanExecutionError::InvalidPlan(reason) => QueryExecutionError::Native(reason), + QueryPlanExecutionError::Node { source, .. } => source, + })? { NativePlanOutput::Results(results) => Ok(results), _ => Err(QueryExecutionError::Native( "Query plan root did not produce results".to_string(), @@ -2410,21 +2409,18 @@ impl SimpleEngine { }, )?; if enable_topk_limiting && context.base.metadata.statistic_to_compute == Statistic::Topk { - let k = context - .base - .metadata - .query_kwargs - .get("k") - .ok_or_else(|| { - QueryExecutionError::Native("Topk query is missing required `k` parameter".into()) - })?; + let k = context.base.metadata.query_kwargs.get("k").ok_or_else(|| { + QueryExecutionError::Native("Topk query is missing required `k` parameter".into()) + })?; results = self .limit_range_topk(&results, k) .map_err(QueryExecutionError::Native)?; } Ok(self.format_range_results( &results, - enable_topk_formatting && context.base.metadata.keep_metric_name, + enable_topk_formatting + && context.base.metadata.statistic_to_compute == Statistic::Topk + && context.base.metadata.keep_metric_name, &context.base.metric, )) } @@ -2441,8 +2437,7 @@ impl SimpleEngine { lookback_ms, context.window_size_ms, context.tumbling_window_ms, - ) - .map_err(QueryExecutionError::Native)? + )? } else { self.execute_store_query(&context.base.store_plan.values_query) .map_err(QueryExecutionError::Native)? @@ -2460,22 +2455,27 @@ impl SimpleEngine { ); let keys = match &context.base.store_plan.keys_query { - Some(query) if context.keys_window_type == Some(WindowType::Sliding) => Some( - self.execute_sliding_cover_query( + Some(query) if context.keys_window_type == Some(WindowType::Sliding) => { + Some(self.execute_sliding_cover_query( query, &context.output_timestamps, - context - .keys_lookback_ms - .ok_or("Sliding keys query is missing its lookback")?, - context - .keys_window_size_ms - .ok_or("Sliding keys query is missing its window size")?, - context - .keys_tumbling_window_ms - .ok_or("Sliding keys query is missing its slide interval")?, - ) - .map_err(QueryExecutionError::Native)?, - ), + context.keys_lookback_ms.ok_or_else(|| { + QueryExecutionError::Native( + "Sliding keys query is missing its lookback".into(), + ) + })?, + context.keys_window_size_ms.ok_or_else(|| { + QueryExecutionError::Native( + "Sliding keys query is missing its window size".into(), + ) + })?, + context.keys_tumbling_window_ms.ok_or_else(|| { + QueryExecutionError::Native( + "Sliding keys query is missing its slide interval".into(), + ) + })?, + )?) + } Some(query) => Some( self.execute_store_query(query) .map_err(QueryExecutionError::Native)?, diff --git a/asap-query-engine/src/engines/simple_engine/promql.rs b/asap-query-engine/src/engines/simple_engine/promql.rs index 639d3664..3bf62362 100644 --- a/asap-query-engine/src/engines/simple_engine/promql.rs +++ b/asap-query-engine/src/engines/simple_engine/promql.rs @@ -1110,7 +1110,7 @@ impl SimpleEngine { "Binary arithmetic query handling took: {:.2}ms", total_query_duration.as_secs_f64() * 1000.0 ); - return Ok(result); + return result; } let Some(context) = self.build_query_execution_context_from_parsed(&ast, &query, time) @@ -1392,7 +1392,7 @@ impl SimpleEngine { "Binary arithmetic range query handling took: {:.2}ms", total_duration.as_secs_f64() * 1000.0 ); - return Ok(result); + return result; } let Some(context) = diff --git a/asap-query-engine/src/lib.rs b/asap-query-engine/src/lib.rs index 274473c1..a458be54 100644 --- a/asap-query-engine/src/lib.rs +++ b/asap-query-engine/src/lib.rs @@ -33,9 +33,9 @@ pub use precompute_operators::{ pub use stores::{SimpleMapStore, Store, StoreResult}; -pub use engines::{InstantVector, QueryExecutionError, QueryResult, SimpleEngine}; #[cfg(feature = "native_query_legacy_test_support")] pub use engines::NativeRangeExecutionMode; +pub use engines::{InstantVector, QueryExecutionError, QueryResult, SimpleEngine}; pub use drivers::{HttpServer, HttpServerConfig, OtlpReceiver, OtlpReceiverConfig}; diff --git a/asap-query-engine/src/tests/native_range_query_tests.rs b/asap-query-engine/src/tests/native_range_query_tests.rs index c291c27e..6b69d900 100644 --- a/asap-query-engine/src/tests/native_range_query_tests.rs +++ b/asap-query-engine/src/tests/native_range_query_tests.rs @@ -1529,11 +1529,11 @@ mod tests { fn range_query_delta_set_replay_dag_matches_legacy() { let query = "count(event_frequency) by (host, event)"; let dag = create_oscillating_delta_set_engine() - .try_handle_range_query_promql(query.to_string(), 1.0, 5.0, 1.0) + .handle_range_query_promql(query.to_string(), 1.0, 5.0, 1.0) .expect("DAG execution failed"); let legacy = create_oscillating_delta_set_engine() .with_native_range_execution_mode_for_test(NativeRangeExecutionMode::Legacy) - .try_handle_range_query_promql(query.to_string(), 1.0, 5.0, 1.0) + .handle_range_query_promql(query.to_string(), 1.0, 5.0, 1.0) .expect("legacy execution failed"); assert_eq!( diff --git a/asap-query-engine/tests/e2e_precompute_equivalence.rs b/asap-query-engine/tests/e2e_precompute_equivalence.rs index 2365be14..3a950349 100644 --- a/asap-query-engine/tests/e2e_precompute_equivalence.rs +++ b/asap-query-engine/tests/e2e_precompute_equivalence.rs @@ -253,6 +253,24 @@ impl NativeDagScenario<'_> { } } +#[cfg(feature = "native_query_legacy_test_support")] +fn assert_range_results_match( + mut dag: Option<(promql_utilities::data_model::KeyByLabelNames, QueryResult)>, + mut legacy: Option<(promql_utilities::data_model::KeyByLabelNames, QueryResult)>, +) { + for result in [&mut dag, &mut legacy] { + if let Some((_, QueryResult::Matrix(matrix))) = result { + matrix + .values + .sort_by(|left, right| left.labels.labels.cmp(&right.labels.labels)); + } + } + assert_eq!( + serde_json::to_value(dag).unwrap(), + serde_json::to_value(legacy).unwrap() + ); +} + #[tokio::test] async fn e2e_sliding_precompute_outputs_compose_a_wider_query() { let port = 19402u16; @@ -431,9 +449,11 @@ async fn e2e_native_leaf_range_matches_instant_at_range_end() { let (engine, query) = scenario.build_engine().await; let (_, instant) = engine .handle_query_promql(query.clone(), 2.0) + .expect("instant native query should not fail") .expect("instant native query should succeed"); let (_, range) = engine .handle_range_query_promql(query, 1.0, 2.0, 1.0) + .expect("range native query should not fail") .expect("range native query should succeed"); let QueryResult::Vector(instant) = instant else { @@ -483,16 +503,13 @@ async fn e2e_native_dag_range_matches_legacy_range() { let (dag, query) = scenario.build_engine().await; let (legacy, _) = legacy_scenario.build_engine().await; let dag = dag - .try_handle_range_query_promql(query.clone(), 2.0, 3.0, 1.0) + .handle_range_query_promql(query.clone(), 2.0, 3.0, 1.0) .expect("DAG execution should not fail"); let legacy = legacy .with_native_range_execution_mode_for_test(NativeRangeExecutionMode::Legacy) - .try_handle_range_query_promql(query, 2.0, 3.0, 1.0) + .handle_range_query_promql(query, 2.0, 3.0, 1.0) .expect("legacy execution should not fail"); - assert_eq!( - serde_json::to_value(dag).unwrap(), - serde_json::to_value(legacy).unwrap() - ); + assert_range_results_match(dag, legacy); } #[cfg(feature = "native_query_legacy_test_support")] @@ -527,16 +544,13 @@ async fn e2e_sparse_range_dag_matches_legacy_range() { let (dag, query) = scenario.build_engine().await; let (legacy, _) = legacy_scenario.build_engine().await; let dag = dag - .try_handle_range_query_promql(query.clone(), 1.0, 9.0, 1.0) + .handle_range_query_promql(query.clone(), 1.0, 9.0, 1.0) .expect("DAG execution should not fail"); let legacy = legacy .with_native_range_execution_mode_for_test(NativeRangeExecutionMode::Legacy) - .try_handle_range_query_promql(query, 1.0, 9.0, 1.0) + .handle_range_query_promql(query, 1.0, 9.0, 1.0) .expect("legacy execution should not fail"); - assert_eq!( - serde_json::to_value(dag).unwrap(), - serde_json::to_value(legacy).unwrap() - ); + assert_range_results_match(dag, legacy); } #[cfg(feature = "native_query_legacy_test_support")] @@ -589,16 +603,13 @@ async fn e2e_keyed_count_range_dag_matches_legacy_range() { let (dag, query) = scenario.build_engine().await; let (legacy, _) = legacy_scenario.build_engine().await; let dag = dag - .try_handle_range_query_promql(query.clone(), 1.0, 3.0, 1.0) + .handle_range_query_promql(query.clone(), 1.0, 3.0, 1.0) .unwrap(); let legacy = legacy .with_native_range_execution_mode_for_test(NativeRangeExecutionMode::Legacy) - .try_handle_range_query_promql(query, 1.0, 3.0, 1.0) + .handle_range_query_promql(query, 1.0, 3.0, 1.0) .unwrap(); - assert_eq!( - serde_json::to_value(dag).unwrap(), - serde_json::to_value(legacy).unwrap() - ); + assert_range_results_match(dag, legacy); } #[cfg(feature = "native_query_legacy_test_support")] @@ -642,16 +653,13 @@ async fn e2e_self_keyed_topk_dag_matches_legacy_range() { let (dag, query) = scenario.build_engine().await; let (legacy, _) = legacy_scenario.build_engine().await; let dag = dag - .try_handle_range_query_promql(query.clone(), 1.0, 1.0, 1.0) + .handle_range_query_promql(query.clone(), 1.0, 1.0, 1.0) .unwrap(); let legacy = legacy .with_native_range_execution_mode_for_test(NativeRangeExecutionMode::Legacy) - .try_handle_range_query_promql(query, 1.0, 1.0, 1.0) + .handle_range_query_promql(query, 1.0, 1.0, 1.0) .unwrap(); - assert_eq!( - serde_json::to_value(dag).unwrap(), - serde_json::to_value(legacy).unwrap() - ); + assert_range_results_match(dag, legacy); } #[cfg(feature = "native_query_legacy_test_support")] @@ -685,7 +693,7 @@ async fn e2e_malformed_native_plan_returns_local_error() { .await; assert!(engine .with_native_range_execution_mode_for_test(NativeRangeExecutionMode::MalformedPlan) - .try_handle_range_query_promql(query, 1.0, 2.0, 1.0) + .handle_range_query_promql(query, 1.0, 2.0, 1.0) .is_err()); } @@ -720,7 +728,7 @@ async fn e2e_native_store_failure_returns_local_error() { .await; assert!(engine .with_native_range_execution_mode_for_test(NativeRangeExecutionMode::FailingStore) - .try_handle_range_query_promql(query, 1.0, 2.0, 1.0) + .handle_range_query_promql(query, 1.0, 2.0, 1.0) .is_err()); } @@ -864,7 +872,7 @@ async fn e2e_quantile_over_time_uses_open_closed_evaluation_window() { /// must not prepend the metric name; only PromQL topk has that output shape. #[tokio::test] async fn e2e_grouped_quantile_preserves_output_label_shape() { - let port = 19416u16; + let port = 19420u16; let metric = "grouped_latency"; let query = "quantile by (job) (0.99, grouped_latency)"; let mut config = make_agg_config( @@ -900,7 +908,7 @@ async fn e2e_grouped_quantile_preserves_output_label_shape() { .await; let (output_labels, result) = engine - .try_handle_query_promql(query, 2.0) + .handle_query_promql(query, 2.0) .expect("grouped quantile should execute") .expect("grouped quantile should match configured inference"); assert_eq!(output_labels.labels, vec!["job"]); From 5886eb4a5e6436a4f3b1688e7ee272605783bc01 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Thu, 1 Oct 2026 09:13:49 -0400 Subject: [PATCH 36/44] test(compliance): drop removed sparse fixture --- promql-compliance/runner/Makefile | 2 -- .../runner/checked_in_fixture_validation_test.go | 3 +-- promql-compliance/suites/native-dag-sparse.yaml | 13 ------------- 3 files changed, 1 insertion(+), 17 deletions(-) delete mode 100644 promql-compliance/suites/native-dag-sparse.yaml diff --git a/promql-compliance/runner/Makefile b/promql-compliance/runner/Makefile index 3369b73d..7e6f5de1 100644 --- a/promql-compliance/runner/Makefile +++ b/promql-compliance/runner/Makefile @@ -10,8 +10,6 @@ BASE_TIME_MS ?= $(shell now_ms=$$(date +%s%3N); echo $$((now_ms / 300000 * 30000 CI_CASES := \ single-rate-temporal:../datasets/single-rate.yaml:../suites/temporal.yaml \ single-rate-off-grid-rate:../datasets/single-rate.yaml:../suites/off-grid-rate.yaml \ - sparse-checkout-temporal:../datasets/sparse-checkout.yaml:../suites/temporal.yaml \ - sparse-checkout-native-dag:../datasets/sparse-checkout.yaml:../suites/native-dag-sparse.yaml \ aggregations-native-dag:../datasets/aggregations.yaml:../suites/native-dag-aggregations.yaml \ aggregations:../datasets/aggregations.yaml:../suites/aggregations.yaml \ quantiles:../datasets/quantiles.yaml:../suites/quantiles.yaml diff --git a/promql-compliance/runner/checked_in_fixture_validation_test.go b/promql-compliance/runner/checked_in_fixture_validation_test.go index 632572ef..e4a08898 100644 --- a/promql-compliance/runner/checked_in_fixture_validation_test.go +++ b/promql-compliance/runner/checked_in_fixture_validation_test.go @@ -31,7 +31,7 @@ func TestCheckedInFixturesAndSuites(t *testing.T) { } func TestCheckedInTemporalSuite(t *testing.T) { - if _, err := seeder.LoadFixture("../datasets/sparse-checkout.yaml"); err != nil { + if _, err := seeder.LoadFixture("../datasets/single-rate.yaml"); err != nil { t.Fatalf("LoadFixture: %v", err) } suite, err := LoadSuiteFile("../suites/temporal.yaml") @@ -66,7 +66,6 @@ func TestCheckedInNativeDagSuites(t *testing.T) { parser := promqlparser.NewParser(promqlparser.Options{}) for _, path := range []string{ "../suites/native-dag-aggregations.yaml", - "../suites/native-dag-sparse.yaml", } { suite, err := LoadSuiteFile(path) if err != nil { diff --git a/promql-compliance/suites/native-dag-sparse.yaml b/promql-compliance/suites/native-dag-sparse.yaml deleted file mode 100644 index 7480f568..00000000 --- a/promql-compliance/suites/native-dag-sparse.yaml +++ /dev/null @@ -1,13 +0,0 @@ -name: native-dag-sparse -comparison_defaults: - value_tolerance: - relative: 0 - absolute: 0.000001 -queries: - - name: sparse-series-appears-mid-range - expr: sum(checkout_up) - instant_offsets_seconds: [300, 600, 900, 1200] - range: - start_offset_seconds: 300 - end_offset_seconds: 1200 - step_seconds: 60 From 4e76dfaa3aaa61e82a266063af682e1098f35eac Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Thu, 1 Oct 2026 09:58:11 -0400 Subject: [PATCH 37/44] test(compliance): isolate Docker ports per matrix case --- promql-compliance/runner/Makefile | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/promql-compliance/runner/Makefile b/promql-compliance/runner/Makefile index 7e6f5de1..a844a202 100644 --- a/promql-compliance/runner/Makefile +++ b/promql-compliance/runner/Makefile @@ -42,6 +42,7 @@ run-cases: run_id="$$(date +%s%N)"; \ now_ms="$$(date +%s%3N)"; \ base_time_ms="$$((now_ms / 300000 * 300000 - 1800000))"; \ + port_seed="$$((run_id % 10000))"; \ case_index=0; \ for test_case in $(CASES); do \ name="$${test_case%%:*}"; \ @@ -50,13 +51,21 @@ run-cases: suite="$${rest#*:}"; \ project="asapquery-differential-$$name-$$run_id"; \ case_base_time_ms="$$((base_time_ms + case_index * 60000))"; \ + port_offset="$$((port_seed + case_index))"; \ + prometheus_port="$$((20000 + port_offset))"; \ + query_port="$$((30000 + port_offset))"; \ + ingest_port="$$((40000 + port_offset))"; \ case_index="$$((case_index + 1))"; \ echo "Running $$name ($$dataset, $$suite)"; \ - if ! go run ./cmd/differential-runner \ + if ! PROMETHEUS_PORT="$$prometheus_port" ASAP_QUERY_PORT="$$query_port" ASAP_INGEST_PORT="$$ingest_port" go run ./cmd/differential-runner \ --dataset "$$dataset" \ --suite "$$suite" \ --compose-file ../docker-compose.yml \ --compose-project "$$project" \ + --reference-url "http://localhost:$$prometheus_port" \ + --reference-write-url "http://localhost:$$prometheus_port" \ + --test-url "http://localhost:$$query_port" \ + --test-write-url "http://localhost:$$ingest_port" \ --base-time-ms "$$case_base_time_ms" \ --output "$(REPORT_DIR)/$$name.json"; then \ echo "FAILED: $$name" >&2; \ From e03318ee0e57f1ec4eff62e805e2403999c62b39 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Thu, 1 Oct 2026 10:04:38 -0400 Subject: [PATCH 38/44] docs(query-engine): record native DAG matrix results --- docs/729-native-query-dag-todos.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/729-native-query-dag-todos.md b/docs/729-native-query-dag-todos.md index 435497b2..d3b942cc 100644 --- a/docs/729-native-query-dag-todos.md +++ b/docs/729-native-query-dag-todos.md @@ -4,16 +4,17 @@ This is the completion checklist for the native range-query DAG cutover. ## Required before removing the temporary comparison path -- [ ] Re-run the Docker differential matrix from an isolated Compose lifecycle and record the result for every case. - The current runner uses the fixed Compose project name `asapquery-differential`; interrupted or overlapping runs can race `up`/`down` and leave a Prometheus instance that rejects a repeat fixture as out-of-order. Make each invocation isolated (or serialize it), use a unique base timestamp, and retain every JSON report. -- [ ] Classify every remaining Prometheus differential mismatch as either a pre-existing legacy parity gap or a DAG regression. The legacy-versus-DAG E2E matrix is the evidence for that classification. +- [x] Re-run the Docker differential matrix from an isolated Compose lifecycle and record the result for every case. + Each case now has an isolated Compose project, base timestamp, and host-port trio. The 2026-10-01 reports are in `/tmp/asapquery-differential-reports`. +- [x] Classify every remaining Prometheus differential mismatch using the legacy-versus-DAG E2E matrix. + No DAG regression was observed: `aggregations-native-dag` and `quantiles` pass, and the legacy-versus-DAG E2E matrix passes for range leaves, sparse input, keyed count, and self-keyed topk. + `temporal` retains the final-window `sum_over_time` mismatch (and its `+ 1` wrapper); `off-grid-rate` runs natively at the non-grid timestamps rather than falling back and returns partial-window rates; `aggregations` retains grouped-topk cardinality differences and unsupported rate-based topk/sum queries; and non-CI `olly-bench` retains its documented planner-coverage failures. These are native-query capability/parity gaps, not evidence that the DAG cutover changed the covered execution behavior. - [ ] Add any missing public E2E characterization needed by a classified regression, then fix only DAG-caused regressions. ## Public error API -- [ ] Complete the agreed API migration: make `handle_query_promql` and `handle_range_query_promql` return `Result, QueryExecutionError>` rather than flattening native failures to `None`. - `try_handle_*` already supplies this contract and the HTTP server uses it. Migrate direct callers and their capability-miss assertions in one focused commit, then remove the compatibility wrappers. -- [ ] Keep the error taxonomy explicit: `Ok(None)` means unsupported and fallback is allowed; `Err(QueryExecutionError)` means an accepted native execution failed and fallback is forbidden. +- [x] Complete the agreed API migration: `handle_query_promql` and `handle_range_query_promql` return `Result, QueryExecutionError>` (merged separately in #749). +- [x] Keep the error taxonomy explicit: `Ok(None)` means unsupported and fallback is allowed; `Err(QueryExecutionError)` means an accepted native execution failed and fallback is forbidden. ## Remove temporary cutover machinery From 1fafe7f09e5301a91bc2a5702ca2473a76507122 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Fri, 2 Oct 2026 21:18:36 -0400 Subject: [PATCH 39/44] docs(query-engine): correct DAG matrix classification --- docs/729-native-query-dag-todos.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/729-native-query-dag-todos.md b/docs/729-native-query-dag-todos.md index d3b942cc..e8dfb1dd 100644 --- a/docs/729-native-query-dag-todos.md +++ b/docs/729-native-query-dag-todos.md @@ -6,9 +6,9 @@ This is the completion checklist for the native range-query DAG cutover. - [x] Re-run the Docker differential matrix from an isolated Compose lifecycle and record the result for every case. Each case now has an isolated Compose project, base timestamp, and host-port trio. The 2026-10-01 reports are in `/tmp/asapquery-differential-reports`. -- [x] Classify every remaining Prometheus differential mismatch using the legacy-versus-DAG E2E matrix. - No DAG regression was observed: `aggregations-native-dag` and `quantiles` pass, and the legacy-versus-DAG E2E matrix passes for range leaves, sparse input, keyed count, and self-keyed topk. - `temporal` retains the final-window `sum_over_time` mismatch (and its `+ 1` wrapper); `off-grid-rate` runs natively at the non-grid timestamps rather than falling back and returns partial-window rates; `aggregations` retains grouped-topk cardinality differences and unsupported rate-based topk/sum queries; and non-CI `olly-bench` retains its documented planner-coverage failures. These are native-query capability/parity gaps, not evidence that the DAG cutover changed the covered execution behavior. +- [x] Re-run the Docker differential matrix against `main` and classify the result per suite. + `quantiles` passes on both revisions. The existing `request-rate` temporal case passes on both; the PR's newly added final-window `sum_over_time` cases fail and characterize a pre-existing native-query parity gap. The new off-grid rate case fails because native execution does not fall back at non-grid timestamps. `olly-bench` retains its documented non-CI planner-coverage failures. + `aggregations` has 14 new failures relative to `main`: grouped `topk` over a bare selector, `sum_over_time`, and `count_over_time` return too few series. The pre-existing rate-based `topk` and `sum` failures remain. The DAG-focused aggregation suite passes, so the current coverage does not reproduce the regression; add a legacy-versus-DAG E2E case for grouped topk before fixing it. - [ ] Add any missing public E2E characterization needed by a classified regression, then fix only DAG-caused regressions. ## Public error API From e64a3e016debd46279404da5d9c1d5a169b96385 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sat, 3 Oct 2026 16:16:41 -0400 Subject: [PATCH 40/44] fix(query-engine): preserve topk grouping --- asap-query-engine/src/engines/query_plan.rs | 15 +- .../src/engines/simple_engine/mod.rs | 64 ++++-- .../tests/e2e_precompute_equivalence.rs | 186 +++++++++++++++++- 3 files changed, 244 insertions(+), 21 deletions(-) diff --git a/asap-query-engine/src/engines/query_plan.rs b/asap-query-engine/src/engines/query_plan.rs index f68e5d8b..91ed7158 100644 --- a/asap-query-engine/src/engines/query_plan.rs +++ b/asap-query-engine/src/engines/query_plan.rs @@ -2,6 +2,7 @@ use crate::engines::simple_engine::{RangeQueryExecutionContext, StoreQueryParams}; use asap_types::enums::WindowType; +use promql_utilities::data_model::KeyByLabelNames; use promql_utilities::query_logics::enums::Statistic; use tracing::debug; @@ -39,6 +40,7 @@ pub(crate) enum QueryPlanNode { LimitTopK { input: NodeId, k: String, + grouping_labels: KeyByLabelNames, }, Format { input: NodeId, @@ -145,7 +147,14 @@ impl QueryPlan { .ok_or_else(|| "Topk query is missing required `k` parameter".to_string())?; k.parse::() .map_err(|_| "Topk query has an invalid `k` parameter".to_string())?; - root = Self::push(&mut nodes, QueryPlanNode::LimitTopK { input: root, k }); + root = Self::push( + &mut nodes, + QueryPlanNode::LimitTopK { + input: root, + k, + grouping_labels: context.base.grouping_labels.clone(), + }, + ); } if options.format_output { root = Self::push( @@ -290,7 +299,9 @@ impl QueryPlan { kwargs.sort_unstable_by_key(|(key, _)| *key); format!("n{index} Estimate(n{}, {statistic}, {kwargs:?})", input.0) }, - QueryPlanNode::LimitTopK { input, k } => format!("n{index} LimitTopK(n{}, k={k})", input.0), + QueryPlanNode::LimitTopK { input, k, .. } => { + format!("n{index} LimitTopK(n{}, k={k})", input.0) + } QueryPlanNode::Format { input, include_metric_name, metric } => format!( "n{index} Format(n{}, include_metric_name={include_metric_name}) metric={metric}", input.0 ), diff --git a/asap-query-engine/src/engines/simple_engine/mod.rs b/asap-query-engine/src/engines/simple_engine/mod.rs index aa42adce..38df097b 100644 --- a/asap-query-engine/src/engines/simple_engine/mod.rs +++ b/asap-query-engine/src/engines/simple_engine/mod.rs @@ -189,6 +189,8 @@ struct RangeQueryReads { } type BucketMap = HashMap>>; +type TopkPartition = (u64, Vec); +type TopkCandidates<'a> = HashMap>; #[derive(Clone)] struct ComposedRangeRead { @@ -288,10 +290,21 @@ impl QueryPlanRuntime for NativePlanRuntime<'_> { "Estimate expected resolved reads".into(), )), }, - QueryPlanNode::LimitTopK { k, .. } => match inputs { + QueryPlanNode::LimitTopK { + k, grouping_labels, .. + } => match inputs { [NativePlanOutput::Results(results)] => self .engine - .limit_range_topk(results, k) + .limit_range_topk( + results, + k, + &SimpleEngine::topk_row_label_order( + &self.context.base.metadata, + &self.context.base.grouping_labels, + &self.context.base.aggregated_labels, + ), + grouping_labels, + ) .map_err(QueryExecutionError::Native) .map(NativePlanOutput::Results), _ => Err(QueryExecutionError::Native( @@ -1483,28 +1496,48 @@ impl SimpleEngine { &self, results: &[crate::engines::query_result::RangeVectorElement], k: &str, + row_label_order: &KeyByLabelNames, + grouping_labels: &KeyByLabelNames, ) -> Result, String> { use crate::engines::query_result::RangeVectorElement; let k = Self::parse_topk_limit(&HashMap::from([("k".to_string(), k.to_string())]))?; let mut retained: HashMap> = HashMap::new(); - let mut candidates: HashMap> = HashMap::new(); + let grouping_positions: Vec = grouping_labels + .labels + .iter() + .map(|label| { + row_label_order + .labels + .iter() + .position(|candidate| candidate == label) + .ok_or_else(|| format!("Topk grouping label '{label}' is absent from output")) + }) + .collect::>()?; + let mut candidates: TopkCandidates<'_> = HashMap::new(); for result in results { + let grouping_key = grouping_positions + .iter() + .map(|&position| { + result.labels.labels.get(position).cloned().ok_or_else(|| { + format!( + "Topk result has {} labels but grouping position {} was requested", + result.labels.labels.len(), + position + ) + }) + }) + .collect::, _>>()?; for sample in &result.samples { candidates - .entry(sample.timestamp) + .entry((sample.timestamp, grouping_key.clone())) .or_default() .push((&result.labels, sample.value)); } } - for candidates in candidates.values_mut() { + for ((timestamp, _), mut candidates) in candidates { candidates .sort_by(|a, b| Self::cmp_topk_value_desc(a.1, &a.0.labels, b.1, &b.0.labels)); - for (labels, _) in candidates.iter().take(k) { - retained.entry((*labels).clone()).or_default(); - } - } - for (timestamp, candidates) in candidates { for (labels, _) in candidates.into_iter().take(k) { retained.entry(labels.clone()).or_default().push(timestamp); } @@ -2413,7 +2446,16 @@ impl SimpleEngine { QueryExecutionError::Native("Topk query is missing required `k` parameter".into()) })?; results = self - .limit_range_topk(&results, k) + .limit_range_topk( + &results, + k, + &Self::topk_row_label_order( + &context.base.metadata, + &context.base.grouping_labels, + &context.base.aggregated_labels, + ), + &context.base.grouping_labels, + ) .map_err(QueryExecutionError::Native)?; } Ok(self.format_range_results( diff --git a/asap-query-engine/tests/e2e_precompute_equivalence.rs b/asap-query-engine/tests/e2e_precompute_equivalence.rs index 3a950349..c1bf52e3 100644 --- a/asap-query-engine/tests/e2e_precompute_equivalence.rs +++ b/asap-query-engine/tests/e2e_precompute_equivalence.rs @@ -7,8 +7,10 @@ //! 3. Advances the watermark past the window boundary to close it //! 4. Drains captured outputs and queries them +use asap_planner::{Controller, RuntimeOptions, StreamingEngine}; use asap_types::aggregation_config::AggregationConfig; use asap_types::enums::{AggregationType, CleanupPolicy, QueryLanguage, WindowType}; +use promql_utilities::data_model::KeyByLabelNames; use prost::Message; use serde_json::json; use std::collections::HashMap; @@ -157,6 +159,93 @@ fn engine_config() -> PrecomputeEngineConfig { } } +fn plan_promql_query( + metric: &str, + labels: Vec, + query: &str, + interval_ms: u64, +) -> (Arc, InferenceConfig) { + let controller_config = format!( + r#" +query_groups: + - id: 1 + queries: + - "{query}" + repetition_delay_ms: {interval_ms} + controller_options: + accuracy_sla: 0.99 + latency_sla: 1.0 +"# + ); + let planner = Controller::from_yaml_with_schema( + &controller_config, + PromQLSchema::new().add_metric(metric.to_string(), KeyByLabelNames::new(labels)), + RuntimeOptions { + data_ingestion_interval_ms: interval_ms, + streaming_engine: StreamingEngine::Precompute, + enable_punting: false, + range_duration_ms: interval_ms, + step_ms: interval_ms, + }, + ) + .expect("planner configuration should be valid"); + let output = planner.generate().expect("planner should support query"); + let inference_config = output + .to_inference_config(QueryLanguage::promql) + .expect("planner should produce inference config"); + let streaming_config = output + .to_streaming_config(QueryLanguage::promql) + .expect("planner should produce streaming config"); + (Arc::new(streaming_config), inference_config) +} + +async fn build_engine_from_configs( + port: u16, + streaming_config: Arc, + inference_config: InferenceConfig, + samples: Vec, + base_interval_ms: u64, +) -> SimpleEngine { + let sink = Arc::new(CapturingOutputSink::new()); + let engine = PrecomputeEngine::new( + engine_config(), + streaming_config.clone(), + sink.clone(), + vec![Box::new(HttpIngestSource::new(HttpIngestConfig { port }))], + ); + tokio::spawn(async move { + engine + .run() + .await + .expect("precompute engine should keep running"); + }); + tokio::time::sleep(tokio::time::Duration::from_millis(300)).await; + + let client = reqwest::Client::new(); + for sample in samples { + send_remote_write(&client, port, vec![sample]).await; + } + tokio::time::sleep(tokio::time::Duration::from_millis(600)).await; + + let store = Arc::new(SimpleMapStore::new( + streaming_config.clone(), + CleanupPolicy::NoCleanup, + )); + for (output, accumulator) in sink.drain() { + store + .insert_precomputed_output(output, accumulator) + .unwrap(); + } + + SimpleEngine::new( + store, + inference_config, + streaming_config, + base_interval_ms, + QueryLanguage::promql, + ) +} + #[derive(Clone)] struct NativeDagScenario<'a> { port: u16, @@ -255,19 +344,21 @@ impl NativeDagScenario<'_> { #[cfg(feature = "native_query_legacy_test_support")] fn assert_range_results_match( - mut dag: Option<(promql_utilities::data_model::KeyByLabelNames, QueryResult)>, - mut legacy: Option<(promql_utilities::data_model::KeyByLabelNames, QueryResult)>, + dag: Option<(promql_utilities::data_model::KeyByLabelNames, QueryResult)>, + legacy: Option<(promql_utilities::data_model::KeyByLabelNames, QueryResult)>, ) { - for result in [&mut dag, &mut legacy] { - if let Some((_, QueryResult::Matrix(matrix))) = result { + let mut dag = dag.expect("DAG path should execute natively"); + let mut legacy = legacy.expect("legacy path should execute natively"); + for (_, result) in [&mut dag, &mut legacy] { + if let QueryResult::Matrix(matrix) = result { matrix .values .sort_by(|left, right| left.labels.labels.cmp(&right.labels.labels)); } } assert_eq!( - serde_json::to_value(dag).unwrap(), - serde_json::to_value(legacy).unwrap() + serde_json::to_value(Some(dag)).unwrap(), + serde_json::to_value(Some(legacy)).unwrap() ); } @@ -653,15 +744,94 @@ async fn e2e_self_keyed_topk_dag_matches_legacy_range() { let (dag, query) = scenario.build_engine().await; let (legacy, _) = legacy_scenario.build_engine().await; let dag = dag - .handle_range_query_promql(query.clone(), 1.0, 1.0, 1.0) + .handle_range_query_promql(query.clone(), 1.0, 2.0, 1.0) .unwrap(); let legacy = legacy .with_native_range_execution_mode_for_test(NativeRangeExecutionMode::Legacy) - .handle_range_query_promql(query, 1.0, 1.0, 1.0) + .handle_range_query_promql(query, 1.0, 2.0, 1.0) .unwrap(); assert_range_results_match(dag, legacy); } +#[cfg(feature = "native_query_legacy_test_support")] +#[tokio::test] +async fn e2e_grouped_topk_range_dag_matches_legacy_range() { + let metric = "grouped_topk_dag_differential"; + let query = "topk by (job) (3, grouped_topk_dag_differential)"; + let labels = vec!["job".to_string(), "instance".to_string()]; + let samples: Vec = ["frontend", "backend", "worker"] + .into_iter() + .flat_map(|job| { + (1_i64..=4).map(move |rank| { + make_timeseries( + metric, + vec![("job", job), ("instance", format!("i-{rank}").as_str())], + 1_000, + rank as f64, + ) + }) + }) + .chain(["frontend", "backend", "worker"].into_iter().map(|job| { + make_timeseries( + metric, + vec![("job", job), ("instance", "flush")], + 3_000, + 0.0, + ) + })) + .collect(); + let (dag_streaming_config, dag_inference_config) = + plan_promql_query(metric, labels.clone(), query, 1_000); + let (legacy_streaming_config, legacy_inference_config) = + plan_promql_query(metric, labels, query, 1_000); + let dag_engine = build_engine_from_configs( + 19421, + dag_streaming_config, + dag_inference_config, + samples.clone(), + 1_000, + ) + .await; + assert!( + dag_engine + .build_range_query_execution_context_promql(query.to_string(), 1.0, 2.0, 1.0) + .is_some(), + "planner output should build a native range context" + ); + let dag = dag_engine + .handle_range_query_promql(query.to_string(), 1.0, 2.0, 1.0) + .unwrap(); + let legacy_engine = build_engine_from_configs( + 19422, + legacy_streaming_config, + legacy_inference_config, + samples, + 1_000, + ) + .await; + assert!( + legacy_engine + .build_range_query_execution_context_promql(query.to_string(), 1.0, 2.0, 1.0) + .is_some(), + "planner output should build a native range context" + ); + let legacy = legacy_engine + .with_native_range_execution_mode_for_test(NativeRangeExecutionMode::Legacy) + .handle_range_query_promql(query.to_string(), 1.0, 2.0, 1.0) + .unwrap(); + assert_range_results_match(dag.clone(), legacy); + let Some((_, result)) = dag else { + panic!("grouped topk should execute natively, got {dag:?}"); + }; + let row_count = match result { + QueryResult::Matrix(matrix) => matrix.values.len(), + QueryResult::Vector(vector) => vector.values.len(), + }; + // Each job has four differently frequent instances. The lowest-ranked + // instance per job is removed, leaving three rows in each of three jobs. + assert_eq!(row_count, 9); +} + #[cfg(feature = "native_query_legacy_test_support")] #[tokio::test] async fn e2e_malformed_native_plan_returns_local_error() { From d70e582c1c7c4a46d6c770a37c70243c776e465d Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sat, 3 Oct 2026 16:19:02 -0400 Subject: [PATCH 41/44] test(query-engine): cover grouped topk DAG parity --- .../tests/e2e_precompute_equivalence.rs | 50 ++++++++++++++++--- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/asap-query-engine/tests/e2e_precompute_equivalence.rs b/asap-query-engine/tests/e2e_precompute_equivalence.rs index c1bf52e3..6a20ed3c 100644 --- a/asap-query-engine/tests/e2e_precompute_equivalence.rs +++ b/asap-query-engine/tests/e2e_precompute_equivalence.rs @@ -754,10 +754,12 @@ async fn e2e_self_keyed_topk_dag_matches_legacy_range() { } #[cfg(feature = "native_query_legacy_test_support")] -#[tokio::test] -async fn e2e_grouped_topk_range_dag_matches_legacy_range() { - let metric = "grouped_topk_dag_differential"; - let query = "topk by (job) (3, grouped_topk_dag_differential)"; +async fn assert_grouped_topk_range_dag_matches_legacy_range( + metric: &str, + query: &str, + dag_port: u16, + legacy_port: u16, +) { let labels = vec!["job".to_string(), "instance".to_string()]; let samples: Vec = ["frontend", "backend", "worker"] .into_iter() @@ -785,7 +787,7 @@ async fn e2e_grouped_topk_range_dag_matches_legacy_range() { let (legacy_streaming_config, legacy_inference_config) = plan_promql_query(metric, labels, query, 1_000); let dag_engine = build_engine_from_configs( - 19421, + dag_port, dag_streaming_config, dag_inference_config, samples.clone(), @@ -802,7 +804,7 @@ async fn e2e_grouped_topk_range_dag_matches_legacy_range() { .handle_range_query_promql(query.to_string(), 1.0, 2.0, 1.0) .unwrap(); let legacy_engine = build_engine_from_configs( - 19422, + legacy_port, legacy_streaming_config, legacy_inference_config, samples, @@ -832,6 +834,42 @@ async fn e2e_grouped_topk_range_dag_matches_legacy_range() { assert_eq!(row_count, 9); } +#[cfg(feature = "native_query_legacy_test_support")] +#[tokio::test] +async fn e2e_grouped_topk_range_dag_matches_legacy_range() { + assert_grouped_topk_range_dag_matches_legacy_range( + "grouped_topk_dag_differential", + "topk by (job) (3, grouped_topk_dag_differential)", + 19421, + 19422, + ) + .await; +} + +#[cfg(feature = "native_query_legacy_test_support")] +#[tokio::test] +async fn e2e_grouped_topk_sum_over_time_dag_matches_legacy_range() { + assert_grouped_topk_range_dag_matches_legacy_range( + "grouped_topk_sum_over_time_dag_differential", + "topk by (job) (3, sum_over_time(grouped_topk_sum_over_time_dag_differential[1s]))", + 19423, + 19424, + ) + .await; +} + +#[cfg(feature = "native_query_legacy_test_support")] +#[tokio::test] +async fn e2e_grouped_topk_count_over_time_dag_matches_legacy_range() { + assert_grouped_topk_range_dag_matches_legacy_range( + "grouped_topk_count_over_time_dag_differential", + "topk by (job) (3, count_over_time(grouped_topk_count_over_time_dag_differential[1s]))", + 19425, + 19426, + ) + .await; +} + #[cfg(feature = "native_query_legacy_test_support")] #[tokio::test] async fn e2e_malformed_native_plan_returns_local_error() { From 31403a14a0918141d326c34cbcbf272c21faa981 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sat, 3 Oct 2026 16:40:50 -0400 Subject: [PATCH 42/44] test(query-engine): exercise native DAG failure paths --- asap-query-engine/src/engines/query_plan.rs | 8 ++ .../src/engines/simple_engine/mod.rs | 73 ++++++++++++++----- .../src/engines/simple_engine/promql.rs | 21 ------ .../src/tests/native_range_query_tests.rs | 14 ++++ .../src/tests/prometheus_forwarding_tests.rs | 54 ++++++++++++-- 5 files changed, 123 insertions(+), 47 deletions(-) diff --git a/asap-query-engine/src/engines/query_plan.rs b/asap-query-engine/src/engines/query_plan.rs index 91ed7158..629d1464 100644 --- a/asap-query-engine/src/engines/query_plan.rs +++ b/asap-query-engine/src/engines/query_plan.rs @@ -91,6 +91,14 @@ impl std::fmt::Display for QueryPlanExecutionError { } impl QueryPlan { + #[cfg(feature = "native_query_legacy_test_support")] + pub(crate) fn malformed_for_test() -> Self { + Self { + nodes: Vec::new(), + root: NodeId(0), + } + } + pub(crate) fn compile_range( context: &RangeQueryExecutionContext, options: PlanOptions, diff --git a/asap-query-engine/src/engines/simple_engine/mod.rs b/asap-query-engine/src/engines/simple_engine/mod.rs index 38df097b..2f42cc86 100644 --- a/asap-query-engine/src/engines/simple_engine/mod.rs +++ b/asap-query-engine/src/engines/simple_engine/mod.rs @@ -219,6 +219,15 @@ struct NativePlanRuntime<'a> { impl NativePlanRuntime<'_> { fn reads(&self) -> Result { + #[cfg(feature = "native_query_legacy_test_support")] + if matches!( + self.engine.native_range_execution_mode, + NativeRangeExecutionMode::FailingStore + ) { + return Err(QueryExecutionError::Native( + "test-only native store failure".to_string(), + )); + } if self.reads.borrow().is_none() { *self.reads.borrow_mut() = Some(self.engine.read_range_query_inputs(self.context)?); } @@ -2387,6 +2396,7 @@ impl SimpleEngine { enable_topk_limiting: bool, enable_topk_formatting: bool, ) -> Result, QueryExecutionError> { + Self::reject_off_grid_sliding_counter_query(context)?; #[cfg(feature = "native_query_legacy_test_support")] if matches!( self.native_range_execution_mode, @@ -2398,6 +2408,23 @@ impl SimpleEngine { enable_topk_formatting, ); } + #[cfg(feature = "native_query_legacy_test_support")] + let plan = if matches!( + self.native_range_execution_mode, + NativeRangeExecutionMode::MalformedPlan + ) { + QueryPlan::malformed_for_test() + } else { + QueryPlan::compile_range( + context, + PlanOptions { + limit_topk: enable_topk_limiting, + format_output: enable_topk_formatting, + }, + ) + .map_err(QueryExecutionError::Native)? + }; + #[cfg(not(feature = "native_query_legacy_test_support"))] let plan = QueryPlan::compile_range( context, PlanOptions { @@ -2527,6 +2554,32 @@ impl SimpleEngine { Ok(RangeQueryReads { values, keys }) } + fn reject_off_grid_sliding_counter_query( + context: &RangeQueryExecutionContext, + ) -> Result<(), QueryExecutionError> { + if context.window_type != WindowType::Sliding + || context.tumbling_window_ms == 0 + || !matches!( + context.base.metadata.statistic_to_compute, + Statistic::Increase | Statistic::Rate + ) + { + return Ok(()); + } + if let Some(×tamp) = context + .output_timestamps + .iter() + .find(|&×tamp| !timestamp.is_multiple_of(context.tumbling_window_ms)) + { + return Err(QueryExecutionError::NoLocalData(format!( + "Exact Prometheus counter bounds are unavailable for off-grid Sliding \ + timestamp {} (grid interval {}ms)", + timestamp, context.tumbling_window_ms + ))); + } + Ok(()) + } + fn estimate_range_query( &self, context: &RangeQueryExecutionContext, @@ -2535,26 +2588,6 @@ impl SimpleEngine { use crate::engines::query_result::RangeVectorElement; use crate::engines::window_merger::create_window_merger; - if context.window_type == WindowType::Sliding - && context.tumbling_window_ms > 0 - && matches!( - context.base.metadata.statistic_to_compute, - Statistic::Increase | Statistic::Rate - ) - { - if let Some(&off_grid_timestamp) = context - .output_timestamps - .iter() - .find(|&×tamp| !timestamp.is_multiple_of(context.tumbling_window_ms)) - { - return Err(QueryExecutionError::NoLocalData(format!( - "Exact Prometheus counter bounds are unavailable for off-grid Sliding \ - timestamp {} (grid interval {}ms)", - off_grid_timestamp, context.tumbling_window_ms - ))); - } - } - let ResolvedRangeReads { values: ComposedRangeRead { groups: all_data }, keys: keys_raw_data, diff --git a/asap-query-engine/src/engines/simple_engine/promql.rs b/asap-query-engine/src/engines/simple_engine/promql.rs index 3bf62362..65632b90 100644 --- a/asap-query-engine/src/engines/simple_engine/promql.rs +++ b/asap-query-engine/src/engines/simple_engine/promql.rs @@ -3,8 +3,6 @@ //! Contains all PromQL-specific context building, pattern matching, binary arithmetic //! dispatch, range-query handling, and query dispatch. -#[cfg(feature = "native_query_legacy_test_support")] -use super::NativeRangeExecutionMode; use super::SimpleEngine; use super::{ QueryExecutionContext, QueryExecutionError, QueryMetadata, QueryTimestamps, @@ -1357,25 +1355,6 @@ impl SimpleEngine { query, start, end, step ); - #[cfg(feature = "native_query_legacy_test_support")] - if matches!( - self.native_range_execution_mode, - NativeRangeExecutionMode::MalformedPlan - ) { - return Err(QueryExecutionError::Native( - "test-only malformed native query plan".to_string(), - )); - } - #[cfg(feature = "native_query_legacy_test_support")] - if matches!( - self.native_range_execution_mode, - NativeRangeExecutionMode::FailingStore - ) { - return Err(QueryExecutionError::Native( - "test-only native store failure".to_string(), - )); - } - let ast = match promql_parser::parser::parse(&query) { Ok(ast) => ast, Err(e) => { diff --git a/asap-query-engine/src/tests/native_range_query_tests.rs b/asap-query-engine/src/tests/native_range_query_tests.rs index 6b69d900..c900a7e9 100644 --- a/asap-query-engine/src/tests/native_range_query_tests.rs +++ b/asap-query-engine/src/tests/native_range_query_tests.rs @@ -226,6 +226,20 @@ mod tests { .expect("host-a result missing") .samples; assert_eq!(on_grid_samples.len(), 2); + + #[cfg(feature = "native_query_legacy_test_support")] + assert!( + engine + .with_native_range_execution_mode_for_test(NativeRangeExecutionMode::FailingStore) + .handle_range_query_promql( + "rate(http_requests_total[2s])".to_string(), + 10.5, + 12.5, + 1.0, + ) + .expect("off-grid counter query should not read the store") + .is_none() + ); } /// Checks every `(label_values, ts, expected_present, reason)` case diff --git a/asap-query-engine/src/tests/prometheus_forwarding_tests.rs b/asap-query-engine/src/tests/prometheus_forwarding_tests.rs index 70717fb9..605bf2d3 100644 --- a/asap-query-engine/src/tests/prometheus_forwarding_tests.rs +++ b/asap-query-engine/src/tests/prometheus_forwarding_tests.rs @@ -1,13 +1,19 @@ #[cfg(test)] -use crate::data_model::{CleanupPolicy, InferenceConfig, QueryLanguage, StreamingConfig}; +use crate::data_model::{ + AggregationConfig, AggregationReference, CleanupPolicy, InferenceConfig, PromQLSchema, + QueryConfig, QueryLanguage, SchemaConfig, StreamingConfig, WindowType, +}; use crate::drivers::query::adapters::AdapterConfig; use crate::drivers::query::servers::http::{HttpServer, HttpServerConfig}; use crate::engines::SimpleEngine; use crate::stores::simple_map_store::SimpleMapStore; #[cfg(feature = "native_query_legacy_test_support")] use crate::NativeRangeExecutionMode; +use promql_utilities::data_model::KeyByLabelNames; +use promql_utilities::query_logics::enums::AggregationType; use reqwest::Client; use serde_json::Value; +use std::collections::HashMap; #[cfg(feature = "native_query_legacy_test_support")] use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; @@ -168,6 +174,43 @@ async fn setup_test_server(prometheus_port: u16) -> (HttpServer, u16) { (server, actual_port) } +#[cfg(feature = "native_query_legacy_test_support")] +fn native_range_error_configs() -> (InferenceConfig, Arc) { + let metric = "native_metric"; + let streaming_config = Arc::new(StreamingConfig::new(HashMap::from([( + 1, + AggregationConfig { + aggregation_id: 1, + aggregation_type: AggregationType::Sum, + aggregation_sub_type: String::new(), + parameters: HashMap::new(), + grouping_labels: KeyByLabelNames::empty(), + aggregated_labels: KeyByLabelNames::empty(), + rollup_labels: KeyByLabelNames::empty(), + original_yaml: String::new(), + window_size_ms: 1_000, + slide_interval_ms: 1_000, + window_type: WindowType::Tumbling, + spatial_filter: String::new(), + spatial_filter_normalized: String::new(), + metric: metric.to_string(), + num_aggregates_to_retain: None, + read_count_threshold: None, + table_name: None, + value_column: None, + }, + )]))); + let inference_config = InferenceConfig { + schema: SchemaConfig::PromQL( + PromQLSchema::new().add_metric(metric.to_string(), KeyByLabelNames::empty()), + ), + query_configs: vec![QueryConfig::new("sum(native_metric)".to_string()) + .add_aggregation(AggregationReference::new(1, None))], + cleanup_policy: CleanupPolicy::NoCleanup, + }; + (inference_config, streaming_config) +} + #[cfg(feature = "native_query_legacy_test_support")] async fn setup_test_server_with_native_range_mode( prometheus_port: u16, @@ -182,8 +225,7 @@ async fn setup_test_server_with_native_range_mode( 30, ), }; - let inference_config = InferenceConfig::new(QueryLanguage::promql, CleanupPolicy::NoCleanup); - let streaming_config = Arc::new(StreamingConfig::default()); + let (inference_config, streaming_config) = native_range_error_configs(); let store = Arc::new(SimpleMapStore::new( streaming_config.clone(), CleanupPolicy::NoCleanup, @@ -425,9 +467,9 @@ async fn native_range_execution_error_is_local_and_does_not_fallback() { let response = Client::new() .get(format!("http://127.0.0.1:{server_port}/api/v1/query_range")) .query(&[ - ("query", "unsupported_metric"), - ("start", "1"), - ("end", "2"), + ("query", "sum(native_metric)"), + ("start", "60"), + ("end", "61"), ("step", "1"), ]) .send() From ee5cea282e4477fb0842de631e9be263cc0ac14d Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sat, 3 Oct 2026 18:42:19 -0400 Subject: [PATCH 43/44] docs(query-engine): record DAG validation matrix --- docs/729-native-query-dag-todos.md | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/docs/729-native-query-dag-todos.md b/docs/729-native-query-dag-todos.md index e8dfb1dd..f6949129 100644 --- a/docs/729-native-query-dag-todos.md +++ b/docs/729-native-query-dag-todos.md @@ -8,18 +8,37 @@ This is the completion checklist for the native range-query DAG cutover. Each case now has an isolated Compose project, base timestamp, and host-port trio. The 2026-10-01 reports are in `/tmp/asapquery-differential-reports`. - [x] Re-run the Docker differential matrix against `main` and classify the result per suite. `quantiles` passes on both revisions. The existing `request-rate` temporal case passes on both; the PR's newly added final-window `sum_over_time` cases fail and characterize a pre-existing native-query parity gap. The new off-grid rate case fails because native execution does not fall back at non-grid timestamps. `olly-bench` retains its documented non-CI planner-coverage failures. - `aggregations` has 14 new failures relative to `main`: grouped `topk` over a bare selector, `sum_over_time`, and `count_over_time` return too few series. The pre-existing rate-based `topk` and `sum` failures remain. The DAG-focused aggregation suite passes, so the current coverage does not reproduce the regression; add a legacy-versus-DAG E2E case for grouped topk before fixing it. -- [ ] Add any missing public E2E characterization needed by a classified regression, then fix only DAG-caused regressions. + The initial `aggregations` comparison had 14 new failures relative to `main`: + grouped `topk` over a bare selector, `sum_over_time`, and `count_over_time` + returned too few series. The pre-existing rate-based `topk` and `sum` failures + remained. The DAG-focused aggregation suite passed, so the current coverage did + not reproduce the regression; add a legacy-versus-DAG E2E case for grouped topk + before fixing it. +- [x] Record the final isolated Docker matrix after the grouped-topk fix. + The 2026-10-03 run is in `/tmp/asapquery-differential-reports-2026-10-03-final`: + `native-dag-aggregations` passes 4/4 and `quantiles` passes 35/35. + `aggregations` has 5/47 failures, all pre-existing rate-based `topk`/`sum` + cases; the 14 grouped-topk regressions are resolved. `temporal` retains 2/3 + final-window `sum_over_time` mismatches, `off-grid-rate` retains its 1/1 + non-grid `rate` mismatch, and `olly-bench` retains its documented 22/25 + planner-coverage failures. `make run-all` therefore exits nonzero by design. +- [x] Add public E2E characterization for each classified DAG regression, then fix only DAG-caused regressions. + Grouped `topk by (...)` now has legacy-versus-DAG coverage for a bare selector, + `sum_over_time`, and `count_over_time`. The limiter ranks candidates within each + timestamp/group partition, fixing the 14 grouped-topk Docker regressions. ## Public error API - [x] Complete the agreed API migration: `handle_query_promql` and `handle_range_query_promql` return `Result, QueryExecutionError>` (merged separately in #749). - [x] Keep the error taxonomy explicit: `Ok(None)` means unsupported and fallback is allowed; `Err(QueryExecutionError)` means an accepted native execution failed and fallback is forbidden. -## Remove temporary cutover machinery +## Retain temporary cutover machinery while staging DAG execution -- [ ] After the differential matrix is accepted, delete `native_query_legacy_test_support`, `NativeRangeExecutionMode`, the legacy executor, and legacy-versus-DAG-only tests. -- [ ] Retain DAG-only E2E behavior tests, malformed-plan graph validation, local HTTP error/no-fallback coverage, and Docker compliance suites. +- [x] Keep DAG as the production default. + `NativeRangeExecutionMode::Dag` is the default, and all legacy modes are compiled + only by the `native_query_legacy_test_support` feature. +- [ ] Retain `native_query_legacy_test_support`, `NativeRangeExecutionMode`, the legacy executor, and legacy-versus-DAG E2E tests until a future cutover decision. +- [x] Retain DAG-only E2E behavior tests, malformed-plan graph validation, local HTTP error/no-fallback coverage, and Docker compliance suites. ## Follow-up scope From 09143c687bd92b865851f1bfed2d43235401ff99 Mon Sep 17 00:00:00 2001 From: Milind Srivastava Date: Sat, 3 Oct 2026 20:28:08 -0400 Subject: [PATCH 44/44] fix(query-engine): support current Clippy lint --- asap-query-engine/src/drivers/query/adapters/traits.rs | 3 +++ asap-query-engine/src/drivers/query/fallback/mod.rs | 1 + asap-query-engine/src/planner_client.rs | 1 + asap-query-engine/src/precompute_engine/ingest_source.rs | 1 + 4 files changed, 6 insertions(+) diff --git a/asap-query-engine/src/drivers/query/adapters/traits.rs b/asap-query-engine/src/drivers/query/adapters/traits.rs index 44031b62..edfec9f2 100644 --- a/asap-query-engine/src/drivers/query/adapters/traits.rs +++ b/asap-query-engine/src/drivers/query/adapters/traits.rs @@ -60,6 +60,7 @@ impl std::error::Error for AdapterError {} /// Trait for parsing incoming HTTP requests into internal query format /// Handles Axum extractors directly for different request types (GET/POST) +#[allow(clippy::double_must_use)] #[async_trait] pub trait QueryRequestAdapter: Send + Sync { /// Parse a GET request with query parameters @@ -106,6 +107,7 @@ pub trait QueryRequestAdapter: Send + Sync { } /// Trait for formatting query results into protocol-specific HTTP responses +#[allow(clippy::double_must_use)] #[async_trait] pub trait QueryResponseAdapter: Send + Sync { /// Format a successful query result into protocol response @@ -135,6 +137,7 @@ pub trait QueryResponseAdapter: Send + Sync { /// define separate adapter traits. /// /// Note: Fallback logic is handled separately via FallbackClient +#[allow(clippy::double_must_use)] #[async_trait] pub trait HttpProtocolAdapter: QueryRequestAdapter + QueryResponseAdapter + Send + Sync { /// Get a descriptive name for this adapter (for logging/debugging) diff --git a/asap-query-engine/src/drivers/query/fallback/mod.rs b/asap-query-engine/src/drivers/query/fallback/mod.rs index 5add3333..c379b415 100644 --- a/asap-query-engine/src/drivers/query/fallback/mod.rs +++ b/asap-query-engine/src/drivers/query/fallback/mod.rs @@ -37,6 +37,7 @@ impl IntoResponse for FallbackResponse { } /// Client for forwarding unsupported queries to a fallback backend +#[allow(clippy::double_must_use)] #[async_trait] pub trait FallbackClient: Send + Sync { /// Execute a query against the fallback backend diff --git a/asap-query-engine/src/planner_client.rs b/asap-query-engine/src/planner_client.rs index 5d3f3e7c..f7496948 100644 --- a/asap-query-engine/src/planner_client.rs +++ b/asap-query-engine/src/planner_client.rs @@ -14,6 +14,7 @@ pub struct PlannerResult { pub punted_queries: Vec, } +#[allow(clippy::double_must_use)] #[async_trait::async_trait] pub trait PlannerClient: Send + Sync { async fn plan(&self, config: ControllerConfig) -> Result; diff --git a/asap-query-engine/src/precompute_engine/ingest_source.rs b/asap-query-engine/src/precompute_engine/ingest_source.rs index 3b4670fa..4e2977ff 100644 --- a/asap-query-engine/src/precompute_engine/ingest_source.rs +++ b/asap-query-engine/src/precompute_engine/ingest_source.rs @@ -130,6 +130,7 @@ fn compile_spatial_filter(config: &AggregationConfig) -> Result, St /// /// Implementors decode incoming data (HTTP, file, etc.) and push it /// into the engine via [`route_decoded_samples`]. +#[allow(clippy::double_must_use)] #[async_trait::async_trait] pub trait IngestSource: Send + Sync { async fn run(