Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fa57681
feat(asap-common): RQE expression tree type and validation
milindsrivastava1997 Oct 14, 2026
d8681d5
feat(planner): --planner saved reads RQE trees and writes rqe_configs
milindsrivastava1997 Oct 14, 2026
2138a02
feat(query-engine): answer RQE expression trees on POST /api/v1/rqe_q…
milindsrivastava1997 Oct 14, 2026
7c77405
feat(asap-common): per-bucket top-k (`topk_by`) on RQE leaves
milindsrivastava1997 Oct 15, 2026
c25fd46
Merge branch '880-featasap-common-rqe-expression-tree-type-and-valida…
milindsrivastava1997 Oct 15, 2026
f8ae11f
feat(planner): plan a top-k RQE leaf by its `topk_by` buckets
milindsrivastava1997 Oct 15, 2026
b80eaf4
Merge branch '882-featplanner-planner-saved-reads-rqe-trees-and-write…
milindsrivastava1997 Oct 15, 2026
3236e88
feat(query-engine): rank RQE top-k leaves within their `topk_by` buckets
milindsrivastava1997 Oct 15, 2026
1b07c83
fix(asap-common): tighten RQE tree validation
milindsrivastava1997 Oct 15, 2026
7ef4f7c
Merge branch '880-featasap-common-rqe-expression-tree-type-and-valida…
milindsrivastava1997 Oct 15, 2026
33ddb10
fix(planner): saved-plan RQE checks per review; add --expr-only
milindsrivastava1997 Oct 15, 2026
4a4e9da
Merge branch '882-featplanner-planner-saved-reads-rqe-trees-and-write…
milindsrivastava1997 Oct 15, 2026
a791ac5
fix(query-engine): check each RQE leaf against its plan before reading
milindsrivastava1997 Oct 15, 2026
60a7844
style(asap-common): state behaviour in duplicate-label test comment
milindsrivastava1997 Oct 15, 2026
995f16a
Merge branch '880-featasap-common-rqe-expression-tree-type-and-valida…
milindsrivastava1997 Oct 15, 2026
2f18617
fix(planner): require rqes with text; --expr-only plans tree leaves only
milindsrivastava1997 Oct 15, 2026
08b50c6
Merge branch '882-featplanner-planner-saved-reads-rqe-trees-and-write…
milindsrivastava1997 Oct 15, 2026
c2064d2
fix(query-engine): refuse RQE leaves their plan can't serve; honour d…
milindsrivastava1997 Oct 15, 2026
c2da7dc
Merge remote-tracking branch 'origin/main' into 880-featasap-common-r…
milindsrivastava1997 Oct 15, 2026
292f30d
Merge remote-tracking branch 'origin/880-featasap-common-rqe-expressi…
milindsrivastava1997 Oct 15, 2026
f051622
Merge remote-tracking branch 'origin/882-featplanner-planner-saved-re…
milindsrivastava1997 Oct 15, 2026
bd683bc
Merge remote-tracking branch 'origin/main' into 883-featquery-engine-…
milindsrivastava1997 Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 80 additions & 2 deletions asap-query-engine/src/drivers/query/servers/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,15 @@ use std::time::Instant;
use tokio::net::TcpListener;
use tracing::{debug, info, warn};

use crate::drivers::query::adapters::{create_http_adapter, AdapterConfig, HttpProtocolAdapter};
use crate::engines::{QueryExecutionError, SimpleEngine};
use crate::drivers::query::adapters::{
create_http_adapter, AdapterConfig, HttpProtocolAdapter, PrometheusResponse,
};
use crate::engines::{QueryExecutionError, RqeQueryError, SimpleEngine};
use crate::utils::http::convert_query_result_to_prometheus;
use asap_types::rqe_expr::RqeExpr;

/// Answers an RQE expression tree, whatever the adapter's query language.
pub const RQE_QUERY_ENDPOINT: &str = "/api/v1/rqe_query";
use crate::query_tracker::QueryTracker;
use crate::stores::Store;

Expand Down Expand Up @@ -106,6 +113,7 @@ impl HttpServer {
.route(range_query_endpoint, post(handle_range_query_post))
.route(runtime_info_path, get(handle_runtime_info))
.route(runtime_info_path, post(handle_runtime_info))
.route(RQE_QUERY_ENDPOINT, post(handle_rqe_query))
.route("/metrics", get(handle_metrics))
.with_state(app_state);

Expand Down Expand Up @@ -143,6 +151,7 @@ impl HttpServer {
.route(range_query_endpoint, get(handle_range_query))
.route(range_query_endpoint, post(handle_range_query_post))
.route(runtime_info_path, get(handle_runtime_info))
.route(RQE_QUERY_ENDPOINT, post(handle_rqe_query))
.with_state(app_state);

let listener = TcpListener::bind("127.0.0.1:0").await?;
Expand Down Expand Up @@ -445,6 +454,75 @@ async fn handle_instant_query_post(
result
}

/// Body of an RQE query: the tree and its evaluation time in unix seconds.
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct RqeQueryRequest {
expr: RqeExpr,
time: f64,
}

/// A Prometheus-format error response with an explicit HTTP status.
fn prometheus_error(status: StatusCode, error_type: &str, error: &str) -> Response {
(status, Json(PrometheusResponse::error(error_type, error))).into_response()
}

/// `POST /api/v1/rqe_query`: answers with Prometheus instant-vector JSON. Never
/// forwards to a fallback backend: an invalid request is a 400, a failed
/// execution a 500, and a window without data yet an empty result. With
/// request handling disabled it answers 500 `internal`, as the instant route
/// does when it has no fallback either.
async fn handle_rqe_query(State(state): State<AppState>, body: Bytes) -> Response {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handle_rqe_query skips the state.config.handle_http_requests check that process_query_request applies to every other query route.

Failure scenario: With handle_http_requests=false, POST /api/v1/rqe_query still evaluates RQE trees against the local store, bypassing the disable switch for this route only.

(from automated /code-review; unverified)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c2064d2: with handle_http_requests off, the route returns an internal error ('RQE query handling is disabled'), as the instant route does when it has no fallback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handle_rqe_query never calls state.query_tracker.record_instant, unlike the instant/range handlers.

Failure scenario: With the query tracker enabled for passive auto-discovery, rqe-frontend workloads are invisible to it, so auto-planning never sees them.

(from automated /code-review; unverified)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining. The tracker discovers PromQL strings for passive auto-planning, and RQE trees can't be planned that way; trees come from saved plans.

if !state.config.handle_http_requests {
debug!("HTTP request handling is disabled; RQE queries have no fallback");
return prometheus_error(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
"RQE query handling is disabled",
);
}
let start_time = Instant::now();
let request: RqeQueryRequest = match serde_json::from_slice(&body) {
Ok(request) => request,
Err(error) => {
return prometheus_error(
StatusCode::BAD_REQUEST,
"bad_data",
&format!("invalid RQE query body: {error}"),
);
}
};
match state
.query_engine
.handle_rqe_query(&request.expr, request.time)
{
Ok((labels, result)) => {
info!(
"rqe_query time={} destination=asap total_latency_ms={:.2}",
request.time,
start_time.elapsed().as_secs_f64() * 1000.0
);
match convert_query_result_to_prometheus(&result, &labels) {
Ok(data) => Json(PrometheusResponse::success(data)).into_response(),
Err(error) => {
prometheus_error(StatusCode::INTERNAL_SERVER_ERROR, "internal", error)
}
}
}
Err(RqeQueryError::BadRequest(error)) => {
prometheus_error(StatusCode::BAD_REQUEST, "bad_data", &error)
}
Err(RqeQueryError::Execution(error)) => {
warn!(error = %error, "RQE query execution failed");
prometheus_error(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&error.to_string(),
)
}
}
}

async fn handle_runtime_info(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Expand Down
2 changes: 1 addition & 1 deletion asap-query-engine/src/engines/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ pub mod window_merger;
pub use query_result::{InstantVector, QueryResult, RangeVector, RangeVectorElement, Sample};
#[cfg(feature = "native_query_legacy_test_support")]
pub use simple_engine::NativeRangeExecutionMode;
pub use simple_engine::{QueryExecutionError, SimpleEngine};
pub use simple_engine::{QueryExecutionError, RqeQueryError, SimpleEngine};
pub use window_merger::{create_window_merger, NaiveMerger, WindowMerger};
2 changes: 1 addition & 1 deletion asap-query-engine/src/engines/simple_engine/elastic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ impl SimpleEngine {
// TODO: Figure out how to handle query configuration for ElasticSearch queries.
let query_config = self.find_query_config(&query)?;
let agg_info = self
.get_aggregation_id_info(&query_config)
.get_aggregation_id_info(&query_config.aggregations)
.map_err(|e| {
warn!("{}", e);
e
Expand Down
53 changes: 48 additions & 5 deletions asap-query-engine/src/engines/simple_engine/mod.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
mod elastic;
mod promql;
mod rqe;
mod sql;

use promql::METRIC_NAME_LABEL;
pub use rqe::RqeQueryError;

use crate::data_model::{
AggregationIdInfo, InferenceConfig, KeyByLabelValues, QueryBounds, QueryConfig, QueryLanguage,
StreamingConfig,
AggregationIdInfo, AggregationReference, InferenceConfig, KeyByLabelValues, QueryBounds,
QueryConfig, QueryLanguage, StreamingConfig,
};
use crate::engines::query_plan::{
NodeId, PlanOptions, QueryPlan, QueryPlanExecutionError, QueryPlanNode, QueryPlanRuntime,
Expand Down Expand Up @@ -691,6 +693,47 @@ impl SimpleEngine {
))
}

/// Builds a `QueryExecutionContext` from already-resolved plain values:
/// plans the store reads and derives the grouping/aggregated labels from
/// the aggregation configs. Shared by the SQL and RQE context builders.
#[allow(clippy::too_many_arguments)]
fn build_execution_context_tail(
&self,
metric: &str,
timestamps: &QueryTimestamps,
metadata: QueryMetadata,
agg_info: AggregationIdInfo,
spatial_filter: String,
query_time: u64,
) -> Result<QueryExecutionContext, String> {
let (query_plan, do_merge, value_window_type) =
self.create_store_query_plan(metric, timestamps, &agg_info)?;

let sc = self.streaming_config.read().unwrap().clone();
let grouping_labels = sc
.get_aggregation_config(agg_info.aggregation_id_for_value)
.map(|config| config.grouping_labels.clone())
.unwrap_or_else(|| metadata.query_output_labels.clone());

let aggregated_labels = sc
.get_aggregation_config(agg_info.aggregation_id_for_key)
.map(|config| config.aggregated_labels.clone())
.unwrap_or_else(KeyByLabelNames::empty);

Ok(QueryExecutionContext {
metric: metric.to_string(),
metadata,
store_plan: query_plan,
agg_info,
value_window_type,
do_merge,
spatial_filter,
query_time,
grouping_labels,
aggregated_labels,
})
}

/// The bucket-map grid width for scanning an aggregation's stored
/// buckets: `slide_interval_ms`, not `window_size_ms`.
/// `precompute_engine/window_manager.rs` persists buckets on the
Expand Down Expand Up @@ -1771,12 +1814,12 @@ impl SimpleEngine {
stats.into_iter().next()
}

/// Resolves the key and value aggregations a query reads. With two
/// aggregations, the set/delta-set one supplies keys and the other values.
fn get_aggregation_id_info(
&self,
query_config: &QueryConfig,
query_config_aggregations: &[AggregationReference],
) -> Result<AggregationIdInfo, String> {
let query_config_aggregations = &query_config.aggregations;

if query_config_aggregations.is_empty() {
return Err("Query config has no aggregations defined".to_string());
}
Expand Down
12 changes: 6 additions & 6 deletions asap-query-engine/src/engines/simple_engine/promql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ fn detect_scalar_arm<'a>(
/// two arms don't share the same label set — mirroring DataFusion's
/// `build_binary_vector_plan`, which fails to resolve a join column that
/// only exists on one side.
fn combine_vector_vector(
pub(super) fn combine_vector_vector(
lhs_results: Vec<InstantVectorElement>,
lhs_labels: &[String],
rhs_results: Vec<InstantVectorElement>,
Expand Down Expand Up @@ -83,7 +83,7 @@ fn combine_vector_vector(
/// Scalar combiner for one binary-expr level (instant query): applies
/// `op(scalar, value)` or `op(value, scalar)` per `scalar_on_left` to every
/// element of the vector arm's results.
fn combine_scalar(
pub(super) fn combine_scalar(
vector_results: Vec<InstantVectorElement>,
scalar: f64,
op: &promql_parser::parser::token::TokenType,
Expand Down Expand Up @@ -119,14 +119,14 @@ impl SimpleEngine {
/// boundary, unconditionally — mirroring SQL's `align_end_timestamp_sql`.
/// A no-op in the common case (already-aligned timestamps), a safety net
/// otherwise, for every PromQL query shape, not just a subset.
fn align_end_timestamp_promql(&self, end_timestamp: u64) -> u64 {
pub(super) fn align_end_timestamp_promql(&self, end_timestamp: u64) -> u64 {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RQE leaves reuse align_end_timestamp_promql, whose warning says 'PromQL query end timestamp ... not aligned', so every unaligned RQE request logs a misleading PromQL warning. Make the message language-neutral or move the aligner to mod.rs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a791ac5: the warning is now language-neutral ("Query end timestamp …").

let interval_ms = self.data_ingestion_interval_ms;
if end_timestamp.is_multiple_of(interval_ms) {
return end_timestamp;
}
let aligned = (end_timestamp / interval_ms) * interval_ms;
warn!(
"PromQL query end timestamp {} is not aligned with data ingestion interval of {} ms; \
"Query end timestamp {} is not aligned with data ingestion interval of {} ms; \
aligning down to {}.",
end_timestamp, interval_ms, aligned
);
Expand Down Expand Up @@ -286,7 +286,7 @@ impl SimpleEngine {
let match_result = self.find_matching_controller_pattern(arm_ast, planned_subquery)?;

let agg_info = self
.get_aggregation_id_info(query_config)
.get_aggregation_id_info(&query_config.aggregations)
.map_err(|e| {
warn!("{}", e);
e
Expand Down Expand Up @@ -1323,7 +1323,7 @@ impl SimpleEngine {

// Resolve aggregation: try pre-configured query_configs first, fall back to capability matching.
let agg_info: AggregationIdInfo = if let Some(config) = self.find_query_config(query) {
self.get_aggregation_id_info(&config)
self.get_aggregation_id_info(&config.aggregations)
.map_err(|e| {
warn!("{}", e);
e
Expand Down
Loading
Loading