Skip to content

Commit 80222d6

Browse files
fix(planner,query-engine): recognize topk over sum_over_time/count_over_time
The planner and query-engine's PromQL pattern matchers only recognized topk over a raw vector selector. Their spatial-over-temporal collapsable pattern lists covered sum/count/avg/quantile/min/max but omitted topk, so `topk(k, sum_over_time(...))` and `topk(k, count_over_time(...))` were silently dropped from inference_config.yaml even though the underlying temporal aggregation was planned (#699). - get_is_collapsable now treats (Topk, SumOverTime) and (Topk, CountOverTime) as collapsable; both pattern-generator lists (planner and query-engine each carry their own copy) include Topk. - get_statistics_to_compute special-cases Topk: the outer op always wins over the inner function's statistic (unlike sum/min/max, which absorb into the same accumulator), still checked by the existing collapsability assert. - topk_count_events is now derived per query via the new promql_topk_count_events helper: sum_over_time (or a raw vector) is value-weighted, count_over_time is count-weighted. Previously this was hardcoded to value-weighted everywhere PromQL topk requirements were built. Covers all 24 topk-over-temporal cases in the aggregations differential suite. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CD4LRkJNNDb1TQ17XeQjmy
1 parent 2401edf commit 80222d6

8 files changed

Lines changed: 184 additions & 19 deletions

File tree

‎asap-common/dependencies/rs/asap_types/src/query_requirements.rs‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use promql_utilities::ast_matching::PromQLMatchResult;
22
use promql_utilities::data_model::KeyByLabelNames;
33
use promql_utilities::query_logics::enums::Statistic;
4+
use promql_utilities::query_logics::logics::promql_topk_count_events;
45
use promql_utilities::query_logics::parsing::{
56
get_metric_and_spatial_filter, get_spatial_aggregation_output_labels, get_statistics_to_compute,
67
};
@@ -32,8 +33,9 @@ pub struct QueryRequirements {
3233
/// * `Some(true)` → COUNT semantics (`count_events: true`, weight 1/event),
3334
/// * `Some(false)` → SUM semantics (`count_events: false`, weight = value).
3435
///
35-
/// PromQL top-k is value-weighted, so it uses `Some(false)`. `None` is only
36-
/// valid for non-top-k requirements.
36+
/// PromQL top-k over a raw vector or `sum_over_time` is value-weighted
37+
/// (`Some(false)`); over `count_over_time` it is count-weighted
38+
/// (`Some(true)`). `None` is only valid for non-top-k requirements.
3739
pub topk_count_events: Option<bool>,
3840
}
3941

@@ -91,7 +93,7 @@ pub fn build_query_requirements_promql(
9193
all_labels
9294
};
9395

94-
let topk_count_events = statistics.contains(&Statistic::Topk).then_some(false);
96+
let topk_count_events = promql_topk_count_events(match_result);
9597

9698
Some(QueryRequirements {
9799
metric,

‎asap-common/dependencies/rs/promql_utilities/src/query_logics/logics.rs‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,45 @@ pub fn get_is_collapsable(
107107
),
108108
AggregationOperator::Min => temporal_aggregation == PromQLFunction::MinOverTime,
109109
AggregationOperator::Max => temporal_aggregation == PromQLFunction::MaxOverTime,
110+
// topk ranks by whatever value the temporal function produces per
111+
// series: sum_over_time's summed value or count_over_time's event
112+
// count. See `Statistic::Topk`'s handling in `get_statistics_to_compute`
113+
// and `promql_topk_count_events`, which derive from the same pairing.
114+
AggregationOperator::Topk => matches!(
115+
temporal_aggregation,
116+
PromQLFunction::SumOverTime | PromQLFunction::CountOverTime
117+
),
110118
_ => false,
111119
}
112120
}
113121

122+
/// For a topk match result, the `count_events` weighting the wrapped temporal
123+
/// function implies:
124+
/// * no temporal function (raw vector topk), or `sum_over_time` → `Some(false)`
125+
/// (value-weighted: topk ranks by the value itself),
126+
/// * `count_over_time` → `Some(true)` (count-weighted: topk ranks by the
127+
/// per-series event count).
128+
///
129+
/// Returns `None` if the match result is not a topk aggregation.
130+
pub fn promql_topk_count_events(
131+
match_result: &crate::ast_matching::PromQLMatchResult,
132+
) -> Option<bool> {
133+
if match_result
134+
.get_aggregation_op()?
135+
.parse::<AggregationOperator>()
136+
!= Ok(AggregationOperator::Topk)
137+
{
138+
return None;
139+
}
140+
match match_result
141+
.get_function_name()
142+
.and_then(|name| name.parse::<PromQLFunction>().ok())
143+
{
144+
Some(PromQLFunction::CountOverTime) => Some(true),
145+
_ => Some(false),
146+
}
147+
}
148+
114149
#[cfg(test)]
115150
mod tests {
116151
use super::*;

‎asap-common/dependencies/rs/promql_utilities/src/query_logics/parsing.rs‎

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,16 @@ pub fn get_metric_and_spatial_filter(match_result: &PromQLMatchResult) -> (Strin
8484
/// reachable via a pattern already narrowed to a collapsable `(function,
8585
/// op)` pair (see `get_is_collapsable`, and #508's pattern-narrowing fix
8686
/// that makes non-collapsable combinations fail to match at all) — asserted
87-
/// below rather than silently trusted. The statistic still comes from the
88-
/// *function*, never the outer op: e.g. `count_over_time` + `sum` needs a
89-
/// `Count` accumulator, not a `Sum` one — summing per-series counts gives
90-
/// the group's total count, so the outer op only describes how per-series
91-
/// results combine, never which statistic must be precomputed.
87+
/// below rather than silently trusted. Which side supplies the statistic
88+
/// then depends on the outer op:
89+
/// - `topk`: the statistic is always `Topk` — a `topk(k, sum_over_time(x))`
90+
/// still needs a heavy-hitter sketch, not a `Sum` accumulator, so the
91+
/// outer op is never dropped (see #699).
92+
/// - every other collapsable op: the statistic comes from the *function*,
93+
/// never the outer op — e.g. `count_over_time` + `sum` needs a `Count`
94+
/// accumulator, not a `Sum` one, since summing per-series counts gives
95+
/// the group's total count. Here the outer op only describes how
96+
/// per-series results combine, never which statistic must be precomputed.
9297
///
9398
/// Returns a typed error if the matched statistic/function name is not
9499
/// recognized, so callers can decide whether to skip or fail the query.
@@ -107,20 +112,23 @@ pub fn get_statistics_to_compute(
107112
};
108113

109114
let statistic_to_compute: Option<String> = if has_function && has_aggregation {
115+
let aggregation_op = match_result
116+
.get_aggregation_op()
117+
.and_then(|o| o.parse::<AggregationOperator>().ok());
110118
debug_assert!(
111119
match_result
112120
.get_function_name()
113121
.and_then(|f| f.parse::<PromQLFunction>().ok())
114-
.zip(
115-
match_result
116-
.get_aggregation_op()
117-
.and_then(|o| o.parse::<AggregationOperator>().ok())
118-
)
122+
.zip(aggregation_op)
119123
.is_some_and(|(f, o)| get_is_collapsable(f, o)),
120124
"a match with both function and aggregation tokens must be collapsable \
121125
(patterns are narrowed to only collapsable pairs, see #508)"
122126
);
123-
function_statistic(match_result)
127+
if aggregation_op == Some(AggregationOperator::Topk) {
128+
Some(AggregationOperator::Topk.as_str().to_string())
129+
} else {
130+
function_statistic(match_result)
131+
}
124132
} else if has_function {
125133
function_statistic(match_result)
126134
} else if has_aggregation {

‎asap-planner-rs/src/planner/patterns.rs‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ pub fn build_patterns() -> Vec<PromQLPattern> {
9090
AggregationOperator::Quantile,
9191
AggregationOperator::Min,
9292
AggregationOperator::Max,
93+
AggregationOperator::Topk,
9394
]
9495
.into_iter()
9596
.filter_map(move |op| {
@@ -131,10 +132,11 @@ mod tests {
131132
}
132133

133134
#[test]
134-
fn exactly_four_collapsable_one_temporal_one_spatial_patterns() {
135-
// 2 ONLY_TEMPORAL + 1 ONLY_SPATIAL + 4 collapsable ONE_TEMPORAL_ONE_SPATIAL
136-
// (sum+sum_over_time, sum+count_over_time, min+min_over_time, max+max_over_time).
137-
assert_eq!(build_patterns().len(), 7);
135+
fn exactly_six_collapsable_one_temporal_one_spatial_patterns() {
136+
// 2 ONLY_TEMPORAL + 1 ONLY_SPATIAL + 6 collapsable ONE_TEMPORAL_ONE_SPATIAL
137+
// (sum+sum_over_time, sum+count_over_time, min+min_over_time,
138+
// max+max_over_time, topk+sum_over_time, topk+count_over_time).
139+
assert_eq!(build_patterns().len(), 9);
138140
}
139141

140142
#[test]
@@ -143,6 +145,10 @@ mod tests {
143145
assert!(matches_some_pattern("sum(count_over_time(x[5m]))"));
144146
assert!(matches_some_pattern("min(min_over_time(x[5m]))"));
145147
assert!(matches_some_pattern("max(max_over_time(x[5m]))"));
148+
assert!(matches_some_pattern("topk(1, sum_over_time(x[5m]))"));
149+
assert!(matches_some_pattern(
150+
"topk by (job) (3, count_over_time(x[5m]))"
151+
));
146152
}
147153

148154
#[test]
@@ -163,5 +169,13 @@ mod tests {
163169
!matches_some_pattern("min(max_over_time(x[5m]))"),
164170
"min+max_over_time is not collapsable"
165171
);
172+
assert!(
173+
!matches_some_pattern("topk(1, rate(x[5m]))"),
174+
"topk+rate is not collapsable"
175+
);
176+
assert!(
177+
!matches_some_pattern("topk(1, avg_over_time(x[5m]))"),
178+
"topk+avg_over_time is not collapsable"
179+
);
166180
}
167181
}

‎asap-planner-rs/src/planner/sketch.rs‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use crate::config::input::SketchParameterOverrides;
22
use promql_utilities::ast_matching::PromQLMatchResult;
33
use promql_utilities::query_logics::enums::AggregationType;
4+
use promql_utilities::query_logics::logics::promql_topk_count_events;
45
use std::collections::HashMap;
56

67
// Default sketch parameters
@@ -163,11 +164,18 @@ pub fn build_sketch_parameters_from_promql(
163164
} else {
164165
None
165166
};
167+
let topk_count_events = if aggregation_type == AggregationType::CountMinSketchWithHeap {
168+
Some(promql_topk_count_events(match_result).ok_or_else(|| {
169+
"topk query missing required aggregation match to derive count_events".to_string()
170+
})?)
171+
} else {
172+
None
173+
};
166174
build_sketch_parameters(
167175
aggregation_type,
168176
aggregation_sub_type,
169177
topk_k,
170-
Some(false),
178+
topk_count_events,
171179
sketch_params,
172180
)
173181
}

‎asap-planner-rs/tests/integration.rs‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,62 @@ fn topk_produces_count_min_sketch_with_heap() {
463463
);
464464
}
465465

466+
#[test]
467+
fn topk_over_sum_over_time_produces_value_weighted_heap() {
468+
// https://github.com/ProjectASAP/asap-internal/issues/699 — topk wrapping
469+
// a temporal aggregation must still be planned, not silently omitted.
470+
let c = Controller::from_yaml_with_schema(
471+
r#"
472+
query_groups:
473+
- id: 1
474+
queries:
475+
- "topk(1, sum_over_time(http_requests_total[5m]))"
476+
repetition_delay_ms: 60000
477+
controller_options:
478+
accuracy_sla: 0.99
479+
latency_sla: 1.0
480+
"#,
481+
http_requests_schema(),
482+
arroyo_opts(),
483+
)
484+
.unwrap();
485+
let out = c.generate().unwrap();
486+
assert_eq!(out.inference_query_count(), 1);
487+
assert!(out.has_aggregation_type("CountMinSketchWithHeap"));
488+
assert_eq!(
489+
out.aggregation_parameter("CountMinSketchWithHeap", "count_events"),
490+
Some(serde_yaml::Value::Bool(false)),
491+
"topk ranks the summed value, not the observation count"
492+
);
493+
}
494+
495+
#[test]
496+
fn topk_over_count_over_time_produces_count_weighted_heap() {
497+
let c = Controller::from_yaml_with_schema(
498+
r#"
499+
query_groups:
500+
- id: 1
501+
queries:
502+
- "topk by (job) (3, count_over_time(http_requests_total[5m]))"
503+
repetition_delay_ms: 60000
504+
controller_options:
505+
accuracy_sla: 0.99
506+
latency_sla: 1.0
507+
"#,
508+
http_requests_schema(),
509+
arroyo_opts(),
510+
)
511+
.unwrap();
512+
let out = c.generate().unwrap();
513+
assert_eq!(out.inference_query_count(), 1);
514+
assert!(out.has_aggregation_type("CountMinSketchWithHeap"));
515+
assert_eq!(
516+
out.aggregation_parameter("CountMinSketchWithHeap", "count_events"),
517+
Some(serde_yaml::Value::Bool(true)),
518+
"topk over count_over_time ranks the observation count"
519+
);
520+
}
521+
466522
#[test]
467523
fn heap_parameters_require_explicit_count_events_weighting() {
468524
let error = asap_planner::planner::sketch::build_sketch_parameters(

‎asap-query-engine/src/engines/simple_engine/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,7 @@ impl SimpleEngine {
301301
AggregationOperator::Quantile,
302302
AggregationOperator::Min,
303303
AggregationOperator::Max,
304+
AggregationOperator::Topk,
304305
]
305306
.into_iter()
306307
.filter_map(move |op| {

‎asap-query-engine/src/engines/simple_engine/promql.rs‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1453,6 +1453,47 @@ mod topk_pipeline_tests {
14531453
assert_eq!(requirements.topk_count_events, Some(false));
14541454
}
14551455

1456+
#[test]
1457+
fn topk_over_sum_over_time_matches_and_is_value_weighted() {
1458+
// https://github.com/ProjectASAP/asap-internal/issues/699
1459+
let (engine, _store) = build_topk_engine();
1460+
let query = "topk(1, sum_over_time(transfer_events[5m]))";
1461+
let ast = promql_parser::parser::parse(query).unwrap();
1462+
let match_result = engine
1463+
.find_matching_controller_pattern(&ast, query)
1464+
.expect("topk over sum_over_time should match a controller pattern");
1465+
let schema = PromQLSchema::new().add_metric(
1466+
METRIC.to_string(),
1467+
KeyByLabelNames::new(vec!["srcip".to_string()]),
1468+
);
1469+
let requirements =
1470+
asap_types::build_query_requirements_promql(query, &match_result, &schema, 1000)
1471+
.expect("topk over sum_over_time requirements should build");
1472+
1473+
assert_eq!(requirements.statistics, vec![Statistic::Topk]);
1474+
assert_eq!(requirements.topk_count_events, Some(false));
1475+
}
1476+
1477+
#[test]
1478+
fn topk_over_count_over_time_matches_and_is_count_weighted() {
1479+
let (engine, _store) = build_topk_engine();
1480+
let query = "topk by (job) (3, count_over_time(transfer_events[5m]))";
1481+
let ast = promql_parser::parser::parse(query).unwrap();
1482+
let match_result = engine
1483+
.find_matching_controller_pattern(&ast, query)
1484+
.expect("topk over count_over_time should match a controller pattern");
1485+
let schema = PromQLSchema::new().add_metric(
1486+
METRIC.to_string(),
1487+
KeyByLabelNames::new(vec!["srcip".to_string()]),
1488+
);
1489+
let requirements =
1490+
asap_types::build_query_requirements_promql(query, &match_result, &schema, 1000)
1491+
.expect("topk over count_over_time requirements should build");
1492+
1493+
assert_eq!(requirements.statistics, vec![Statistic::Topk]);
1494+
assert_eq!(requirements.topk_count_events, Some(true));
1495+
}
1496+
14561497
fn build_topk_engine() -> (SimpleEngine, Arc<SimpleMapStore>) {
14571498
let promql_schema = PromQLSchema::new().add_metric(
14581499
METRIC.to_string(),

0 commit comments

Comments
 (0)