Skip to content

Commit 0def7fa

Browse files
zz_yclaude
andcommitted
fix(lower): topk over a bare selector ranks its own samples, preserving by-labels (#30)
`topk(k, <bare instant selector>) by (labels)` — e.g. `topk(3, http_requests_total) by (job)` ("top-3 series per job") — mislowered. The non-heavy-hitter path in `build`'s `Outer::TopK` else-branch defaulted a bare selector argument (`inner.func == None`) to an implicit cross-series `AggFunc::Sum`. That reducing `Sum` collapsed every label (including the `by` partition keys) into a single `sum` column, so `Sort.partition_by = [job]` no longer resolved at L3 — a regression surfaced reviewing the Partition → `Sort.partition_by` reframe (#12, PR #18). It was also semantically wrong: PromQL `topk` ranks the raw instant-vector samples, it does not sum them. A bare selector now ranks over the `filtered_source` directly (label- preserving), so `Sort.partition_by` ranks within each group. A range-vector- function argument (`topk(k, rate(m[5m]))`) still reduces per series first — also label-preserving — so those paths are unchanged, as are the heavy-hitter `count_over_time` and `bottomk` cases. Expected: `topk(3, http_requests_total) by (job)` → `Limit{3} → Sort{value desc, partition_by:[job]} → Scan`. Tests: bare-selector topk by-label (ranks per group, no implicit Sum) and bare-selector topk without `by` (ranks raw samples). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a54b45d commit 0def7fa

2 files changed

Lines changed: 70 additions & 8 deletions

File tree

crates/lower/src/promql.rs

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -418,15 +418,21 @@ fn build(inner: Inner, keys: Vec<ColumnRef>, outer: Outer) -> Result<L2> {
418418
input: Box::new(count_agg),
419419
})
420420
} else {
421-
let func = match &inner.func {
422-
Some(f) => inner_func(f),
423-
None => AggFunc::Sum,
421+
// The base over which we rank. A range-vector-function argument
422+
// (`topk(k, rate(m[5m]))`) reduces *per series* first — that is
423+
// label-preserving, so the `by (host)` partition labels survive.
424+
// A **bare instant selector** (`topk(k, m)`) ranks its own
425+
// samples directly: it must NOT be wrapped in a reducing
426+
// aggregate. Defaulting it to `Sum` was both semantically wrong
427+
// (PromQL `topk` ranks the raw samples, it does not sum them) and
428+
// destructive — the cross-series `Sum` collapses every label,
429+
// including the `by (…)` partition keys, so they no longer
430+
// resolve at L3 (issue #30). Keep the selector label-preserving so
431+
// `Sort.partition_by` can rank within each group (issue #12).
432+
let base = match inner.func.as_ref().map(inner_func) {
433+
Some(func) => windowed_aggregate(inner, vec![], func),
434+
None => filtered_source(inner.metric, inner.matchers),
424435
};
425-
// The grouping (`topk by (host)`) is per-group *ranking*, not a
426-
// reduction — it rides on `Sort.partition_by` (→ positional L3
427-
// `Sort.partition_by`), so the windowed reduction below stays
428-
// label-preserving with no group keys (issue #12).
429-
let base = windowed_aggregate(inner, vec![], func);
430436
let sorted = L2::Sort {
431437
keys: vec![L2SortKey {
432438
expr: L2Expr::Column(ColumnRef::SampleValue),

crates/lower/tests/promql_lowering.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -724,3 +724,59 @@ fn generic_topk_grouping_lowers_to_sort_partition_by() {
724724
assert_eq!(partition_by, &vec![2], "host is col 2 in [ts, value, host]");
725725
assert!(matches!(child.as_ref(), QueryExpr::Aggregate { by, .. } if by.is_empty()));
726726
}
727+
728+
#[test]
729+
fn topk_over_bare_selector_by_label_ranks_per_group() {
730+
// `topk(3, http_requests_total) by (job)` — top-3 series per `job`. A bare
731+
// instant selector ranks its OWN samples; it must not be wrapped in an
732+
// implicit cross-series `Sum`, which would collapse the `job` partition
733+
// label before `Sort.partition_by` resolves it (issue #30 — follow-up to the
734+
// Partition→Sort.partition_by reframe in #12). Expected:
735+
// Limit{3} → Sort{value desc, partition_by:[job]} → Scan
736+
let q = lower("topk(3, http_requests_total) by (job)");
737+
let QueryExpr::Limit { n, child, .. } = &q else {
738+
panic!("expected Limit, got {q:?}");
739+
};
740+
assert_eq!(*n, 3);
741+
let QueryExpr::Sort {
742+
keys,
743+
partition_by,
744+
child,
745+
} = child.as_ref()
746+
else {
747+
panic!("expected Sort, got {child:?}");
748+
};
749+
assert!(!keys[0].ascending, "topk ranks descending");
750+
assert_eq!(partition_by, &vec![2], "job is col 2 in [ts, value, job]");
751+
// No implicit reducing aggregate — the selector is label-preserving, so the
752+
// sort is directly over the Scan (the `job` label survives to partition by).
753+
assert!(
754+
matches!(child.as_ref(), QueryExpr::Scan { .. }),
755+
"ranking is over the bare Scan, not a reducing Aggregate, got {child:?}"
756+
);
757+
assert!(
758+
!has_intent(&q, |i| matches!(i, AggIntent::Sum { .. })),
759+
"no implicit Sum is introduced over a bare selector"
760+
);
761+
}
762+
763+
#[test]
764+
fn topk_over_bare_selector_ranks_raw_samples() {
765+
// Even without `by`, `topk(3, m)` ranks the raw instant-vector samples — it
766+
// does not sum them. The sort sits directly over the Scan, partition empty.
767+
let q = lower("topk(3, http_requests_total)");
768+
let QueryExpr::Limit { child, .. } = &q else {
769+
panic!("expected Limit, got {q:?}");
770+
};
771+
let QueryExpr::Sort {
772+
partition_by,
773+
child,
774+
..
775+
} = child.as_ref()
776+
else {
777+
panic!("expected Sort, got {child:?}");
778+
};
779+
assert!(partition_by.is_empty(), "no `by` → global ranking");
780+
assert!(matches!(child.as_ref(), QueryExpr::Scan { .. }));
781+
assert!(!has_intent(&q, |i| matches!(i, AggIntent::Sum { .. })));
782+
}

0 commit comments

Comments
 (0)