Skip to content

Commit 005a193

Browse files
feat: Added support for PromQL queries with binary arithmetic operations, using the Datafusion-based query path (#256)
* Added support for PromQL queries with binary arithmetic operations, using the Datafusion-based query path * Added logic for range queries
1 parent 9ce1570 commit 005a193

17 files changed

Lines changed: 2052 additions & 2 deletions

asap-planner-rs/src/output/generator.rs

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ use sketch_db_common::enums::CleanupPolicy;
88

99
use crate::config::input::ControllerConfig;
1010
use crate::error::ControllerError;
11-
use crate::planner::single_query::{IntermediateAggConfig, SingleQueryProcessor};
11+
use crate::planner::single_query::{BinaryArm, IntermediateAggConfig, SingleQueryProcessor};
1212
use crate::RuntimeOptions;
1313

14+
/// `(query_string, Vec<(identifying_key, cleanup_param)>)` pairs produced by binary leaf decomposition.
15+
type LeafEntries = Vec<(String, Vec<(String, Option<u64>)>)>;
16+
1417
/// Run the full planning pipeline and produce YAML outputs
1518
pub fn generate_plan(
1619
controller_config: &ControllerConfig,
@@ -92,6 +95,14 @@ pub fn generate_plan(
9295
}
9396
Err(e) => return Err(e),
9497
}
98+
} else if let Some(arm_entries) =
99+
collect_binary_leaf_entries(&processor, &mut dedup_map)?
100+
{
101+
// Binary arithmetic: register each leaf arm in dedup_map and query_keys_map
102+
for (arm_query, keys_for_arm) in arm_entries {
103+
// Use `entry` so a standalone query that duplicates an arm wins
104+
query_keys_map.entry(arm_query).or_insert(keys_for_arm);
105+
}
95106
}
96107
}
97108
}
@@ -123,6 +134,62 @@ pub fn generate_plan(
123134
})
124135
}
125136

137+
/// Recursively collect (arm_query_string, Vec<(dedup_key, cleanup_param)>) pairs
138+
/// from a binary arithmetic expression, registering new configs in `dedup_map`.
139+
///
140+
/// Returns `Some(Vec<...>)` when every leaf arm is acceleratable.
141+
/// Returns `None` if any arm is unsupported (caller should skip the query).
142+
/// Returns `Err` only on internal planner errors.
143+
fn collect_binary_leaf_entries(
144+
processor: &SingleQueryProcessor,
145+
dedup_map: &mut IndexMap<String, IntermediateAggConfig>,
146+
) -> Result<Option<LeafEntries>, ControllerError> {
147+
let arms = match processor.get_binary_arm_queries() {
148+
Some(arms) => arms,
149+
None => return Ok(None), // not a binary expression
150+
};
151+
152+
let mut all_entries: LeafEntries = Vec::new();
153+
154+
for arm in [arms.0, arms.1] {
155+
match arm {
156+
BinaryArm::Scalar(_) => {
157+
// Scalar literals need no aggregation config — skip silently.
158+
}
159+
BinaryArm::Query(arm_query) => {
160+
let arm_processor = processor.make_arm_processor(arm_query.clone());
161+
162+
if arm_processor.is_supported() {
163+
// Leaf arm: gather its streaming aggregation configs.
164+
let (configs, cleanup_param) =
165+
arm_processor.get_streaming_aggregation_configs()?;
166+
let mut keys_for_arm = Vec::new();
167+
for config in configs {
168+
let key = config.identifying_key();
169+
keys_for_arm.push((key.clone(), cleanup_param));
170+
dedup_map.entry(key).or_insert(config);
171+
}
172+
all_entries.push((arm_query, keys_for_arm));
173+
} else {
174+
// The arm might itself be a binary expression — recurse.
175+
match collect_binary_leaf_entries(&arm_processor, dedup_map)? {
176+
Some(sub_entries) => {
177+
all_entries.extend(sub_entries);
178+
}
179+
None => {
180+
// Arm is neither a supported leaf nor a binary expression.
181+
// This entire query cannot be accelerated.
182+
return Ok(None);
183+
}
184+
}
185+
}
186+
}
187+
}
188+
}
189+
190+
Ok(Some(all_entries))
191+
}
192+
126193
pub fn parse_cleanup_policy(s: &str) -> Result<CleanupPolicy, ControllerError> {
127194
match s {
128195
"circular_buffer" => Ok(CleanupPolicy::CircularBuffer),

asap-planner-rs/src/planner/single_query.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,37 @@ use crate::planner::logics::{
2121
use crate::planner::patterns::build_patterns;
2222
use crate::StreamingEngine;
2323

24+
/// Represents one arm of a binary arithmetic expression in the planner.
25+
#[derive(Debug, Clone)]
26+
pub enum BinaryArm {
27+
/// A PromQL query expression that may be acceleratable.
28+
Query(String),
29+
/// A scalar literal (e.g. `100` in `rate(x[5m]) * 100`).
30+
Scalar(f64),
31+
}
32+
33+
/// Convert an AST expression to a `BinaryArm`. Scalar literals become
34+
/// `BinaryArm::Scalar`; everything else is serialized to a query string.
35+
/// Outer parentheses are stripped so nested binary arms can be re-parsed
36+
/// as `Binary` expressions (not `Paren`).
37+
fn expr_to_binary_arm(expr: &promql_parser::parser::Expr) -> BinaryArm {
38+
let inner = strip_parens(expr);
39+
if let promql_parser::parser::Expr::NumberLiteral(nl) = inner {
40+
BinaryArm::Scalar(nl.val)
41+
} else {
42+
BinaryArm::Query(format!("{}", inner))
43+
}
44+
}
45+
46+
/// Recursively remove outer `Paren` wrappers from an expression.
47+
fn strip_parens(expr: &promql_parser::parser::Expr) -> &promql_parser::parser::Expr {
48+
if let promql_parser::parser::Expr::Paren(paren) = expr {
49+
strip_parens(&paren.expr)
50+
} else {
51+
expr
52+
}
53+
}
54+
2455
/// Internal representation of an aggregation config before IDs are assigned
2556
#[derive(Debug, Clone)]
2657
pub struct IntermediateAggConfig {
@@ -161,6 +192,38 @@ impl SingleQueryProcessor {
161192
}
162193
}
163194

195+
/// Returns `Some((lhs, rhs))` if this query is a binary arithmetic expression.
196+
/// Each arm is either a query string (`BinaryArm::Query`) or a scalar literal
197+
/// (`BinaryArm::Scalar`). Returns `None` if the query is not a binary expression
198+
/// or cannot be parsed.
199+
pub fn get_binary_arm_queries(&self) -> Option<(BinaryArm, BinaryArm)> {
200+
let ast = promql_parser::parser::parse(&self.query).ok()?;
201+
if let promql_parser::parser::Expr::Binary(binary) = ast {
202+
let lhs = expr_to_binary_arm(binary.lhs.as_ref());
203+
let rhs = expr_to_binary_arm(binary.rhs.as_ref());
204+
// Only handle arithmetic operators (not comparison or set operators)
205+
if !binary.op.is_comparison_operator() && !binary.op.is_set_operator() {
206+
return Some((lhs, rhs));
207+
}
208+
}
209+
None
210+
}
211+
212+
/// Create a new processor for an arm query, reusing all parameters from this processor.
213+
pub fn make_arm_processor(&self, arm_query: String) -> Self {
214+
SingleQueryProcessor::new(
215+
arm_query,
216+
self.t_repeat,
217+
self.prometheus_scrape_interval,
218+
self.metric_schema.clone(),
219+
self.streaming_engine,
220+
self.sketch_parameters.clone(),
221+
self.range_duration,
222+
self.step,
223+
self.cleanup_policy,
224+
)
225+
}
226+
164227
/// Check if query should be processed (supported pattern)
165228
pub fn is_supported(&self) -> bool {
166229
if let Ok(ast) = promql_parser::parser::parse(&self.query) {
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
query_groups:
2+
- id: 1
3+
queries:
4+
- "rate(errors_total[5m]) / rate(requests_total[5m])"
5+
repetition_delay: 300
6+
controller_options:
7+
accuracy_sla: 0.99
8+
latency_sla: 1.0
9+
metrics:
10+
- metric: "errors_total"
11+
labels: ["instance", "job"]
12+
- metric: "requests_total"
13+
labels: ["instance", "job"]
14+
aggregate_cleanup:
15+
policy: "read_based"
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
query_groups:
2+
- id: 1
3+
queries:
4+
- "rate(errors_total[5m]) / rate(requests_total[5m])"
5+
- "rate(errors_total[5m])"
6+
repetition_delay: 300
7+
controller_options:
8+
accuracy_sla: 0.99
9+
latency_sla: 1.0
10+
metrics:
11+
- metric: "errors_total"
12+
labels: ["instance", "job"]
13+
- metric: "requests_total"
14+
labels: ["instance", "job"]
15+
aggregate_cleanup:
16+
policy: "read_based"
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
query_groups:
2+
- id: 1
3+
queries:
4+
- "(rate(a_total[5m]) + rate(b_total[5m])) / rate(c_total[5m])"
5+
repetition_delay: 300
6+
controller_options:
7+
accuracy_sla: 0.99
8+
latency_sla: 1.0
9+
metrics:
10+
- metric: "a_total"
11+
labels: ["instance"]
12+
- metric: "b_total"
13+
labels: ["instance"]
14+
- metric: "c_total"
15+
labels: ["instance"]
16+
aggregate_cleanup:
17+
policy: "read_based"
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
query_groups:
2+
- id: 1
3+
queries:
4+
- "foo(errors_total[5m]) / rate(requests_total[5m])"
5+
repetition_delay: 300
6+
controller_options:
7+
accuracy_sla: 0.99
8+
latency_sla: 1.0
9+
metrics:
10+
- metric: "errors_total"
11+
labels: ["instance"]
12+
- metric: "requests_total"
13+
labels: ["instance"]
14+
aggregate_cleanup:
15+
policy: "read_based"
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
query_groups:
2+
- id: 1
3+
queries:
4+
- "rate(errors_total[5m]) * 100"
5+
repetition_delay: 300
6+
controller_options:
7+
accuracy_sla: 0.99
8+
latency_sla: 1.0
9+
metrics:
10+
- metric: "errors_total"
11+
labels: ["instance", "job"]
12+
aggregate_cleanup:
13+
policy: "read_based"

asap-planner-rs/tests/integration.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,73 @@ fn temporal_overlapping_cleanup_param_equals_range_over_repeat() {
430430
);
431431
}
432432

433+
// --- Binary arithmetic tests ---
434+
435+
#[test]
436+
fn binary_arithmetic_produces_two_leaf_configs() {
437+
let c = Controller::from_file(
438+
Path::new("tests/comparison/test_data/configs/binary_arithmetic.yaml"),
439+
arroyo_opts(),
440+
)
441+
.unwrap();
442+
let out = c.generate().unwrap();
443+
// Two arms → two streaming aggregation configs
444+
assert_eq!(out.streaming_aggregation_count(), 2);
445+
// Two separate query_config entries (one per arm)
446+
assert_eq!(out.inference_query_count(), 2);
447+
}
448+
449+
#[test]
450+
fn binary_arithmetic_deduplicates_shared_arm() {
451+
let c = Controller::from_file(
452+
Path::new("tests/comparison/test_data/configs/binary_arithmetic_dedup.yaml"),
453+
arroyo_opts(),
454+
)
455+
.unwrap();
456+
let out = c.generate().unwrap();
457+
// errors_total arm is shared — only 2 streaming configs total (not 3)
458+
assert_eq!(out.streaming_aggregation_count(), 2);
459+
// 2 query_config entries: rate(errors_total[5m]) and rate(requests_total[5m])
460+
assert_eq!(out.inference_query_count(), 2);
461+
}
462+
463+
#[test]
464+
fn nested_binary_arithmetic_produces_three_leaf_configs() {
465+
let c = Controller::from_file(
466+
Path::new("tests/comparison/test_data/configs/binary_arithmetic_nested.yaml"),
467+
arroyo_opts(),
468+
)
469+
.unwrap();
470+
let out = c.generate().unwrap();
471+
assert_eq!(out.streaming_aggregation_count(), 3);
472+
assert_eq!(out.inference_query_count(), 3);
473+
}
474+
475+
#[test]
476+
fn binary_arithmetic_scalar_constant_produces_one_leaf_config() {
477+
let c = Controller::from_file(
478+
Path::new("tests/comparison/test_data/configs/binary_arithmetic_scalar.yaml"),
479+
arroyo_opts(),
480+
)
481+
.unwrap();
482+
let out = c.generate().unwrap();
483+
// Only the vector arm needs a streaming config; 100 is a literal
484+
assert_eq!(out.streaming_aggregation_count(), 1);
485+
assert_eq!(out.inference_query_count(), 1);
486+
}
487+
488+
#[test]
489+
fn binary_arithmetic_with_non_acceleratable_arm_produces_no_configs() {
490+
let c = Controller::from_file(
491+
Path::new("tests/comparison/test_data/configs/binary_arithmetic_non_acceleratable.yaml"),
492+
arroyo_opts(),
493+
)
494+
.unwrap();
495+
let out = c.generate().unwrap();
496+
assert_eq!(out.streaming_aggregation_count(), 0);
497+
assert_eq!(out.inference_query_count(), 0);
498+
}
499+
433500
#[test]
434501
fn temporal_overlapping_rate_increase_deduped() {
435502
// rate and increase produce identical MultipleIncrease configs → 1 streaming entry shared,

0 commit comments

Comments
 (0)