Repository navigation
feat(query-engine): roll up single-subpopulation aggregations to a coarser grouping - #848
Conversation
…arser grouping A query grouped by Q can now read an aggregation grouped by G ⊋ Q. Right after the store read, every G-group is re-keyed by its projection onto Q, so the per-step merge folds all subpopulations of an output group into one state before the statistic is queried. Instant and range queries, and all query languages, share this path. Only Sum, MinMax, DatasketchesKLL, DDSketch and HLL roll up. A query whose grouping isn't a subset of the aggregation's, or a roll-up of any other type, is an error rather than a fall-through. Keyed and top-k aggregations keep their existing path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| values.len(), | ||
| values.values().map(|buckets| buckets.len()).sum::<usize>() | ||
| ); | ||
| let values = match rollup_projection { |
There was a problem hiding this comment.
Instant Sliding roll-ups fail if any subgroup has an incomplete cover. execute_sliding_cover_query runs its all-or-nothing check (output_timestamps.len() == 1) on the raw G-keys, before this roll-up. So if any one (endpoint, service) subgroup started partway through the lookback, the whole instant query returns NoLocalData and falls through, even though every Q-group is servable. A one-step range query at the same timestamp would answer it. The finer G is, the more often this happens. There's no instant Sliding roll-up test. Consider doing the instant completeness check after the roll-up (per Q-key), or using the per-step distinct-window check for both.
| values.len(), | ||
| values.values().map(|buckets| buckets.len()).sum::<usize>() | ||
| ); | ||
| let values = match rollup_projection { |
There was a problem hiding this comment.
Sliding range: rolling up before the per-step cover check can hide a subgroup's missing window. sliding_cover_window_count now runs on the merged Q-group map, so a window counts as present if any subgroup has it. Suppose subgroup /x really is missing [4s,6s) (late or lost flush) while /new has it. The step passes as complete and returns a sum that silently leaves out /x's window, where before that series' step would have been skipped. This is fine for series that truly had no samples, but it can't be told apart from missing data.
| .iter() | ||
| .map(|label| grouping.iter().position(|g| g == label)) | ||
| .collect::<Option<Vec<usize>>>() | ||
| .ok_or_else(|| { |
There was a problem hiding this comment.
This is a hard error, not a fallback. Q ⊄ G and non-mergeable roll-ups return QueryExecutionError::Native, which map_local_execution_outcome passes through as Err. The HTTP server then returns an error instead of forwarding to Prometheus (that only happens on NoLocalData/None). A query config that points a query at an incompatible aggregation used to get an answer (if mislabelled). Now the user gets a failure and there's no fallback. Consider NoLocalData plus a warn log, so the query is still answered upstream.
| // The store sorts each group's buckets chronologically; keep that | ||
| // order across the merged groups. | ||
| for buckets in rolled.values_mut() { | ||
| buckets.sort_by_key(|(range, _)| *range); |
There was a problem hiding this comment.
Merge order isn't deterministic. sort_by_key is stable on the range only, so buckets from different subgroups with the same range stay in values' HashMap iteration order, which is randomized per process. Float Sum merges (non-associative) and KLL merges can then change from run to run. The sort also adds little: build_bucket_map regroups by start timestamp anyway. Either drop it, or sort by (range, source key) if you want determinism.
| && context.metadata.statistic_to_compute != Statistic::Topk; | ||
| let grouping = &context.grouping_labels.labels; | ||
| let output = &context.metadata.query_output_labels.labels; | ||
| if !single_subpopulation || grouping == output { |
There was a problem hiding this comment.
grouping == output is an order-sensitive Vec equality. It relies on both KeyByLabelNames having been built through new() (which sorts), but labels is a pub field and some code builds it as a struct literal (e.g. planner/elastic_dsl.rs KeyByLabelNames { labels: rollup }). The same set in a different order would go down the projection path, and for Increase it would error with "can't be merged across groups". A set comparison (or projection == 0..n) would be safer.
| /// Single-subpopulation types whose merge of two groups' states is the state | ||
| /// of their union. Increase is excluded: its merge joins pieces of one | ||
| /// counter in time. | ||
| const ROLLUP_AGGREGATION_TYPES: [AggregationType; 5] = [ |
There was a problem hiding this comment.
ROLLUP_AGGREGATION_TYPES is really a property of AggregationType, the same kind of thing as is_keyed() in promql_utilities/.../enums.rs. An exhaustive match method there (e.g. is_mergeable_across_groups) would force a decision whenever a new variant is added. With this list, a new variant silently becomes non-roll-upable.
| assert_eq!(rows.len(), 2); | ||
| assert_eq!(rows[0].0, vec!["a".to_string()]); | ||
| assert!( | ||
| (rows[0].1 - 500.0).abs() <= 1_000.0 * 0.02, |
There was a problem hiding this comment.
Possible flake. KLL k=200 has a normalized rank error of about 1.65%, but that's a probabilistic bound (about 99% confidence), and compaction is randomized. Unless the sketch's RNG is seeded, this 2% bound on 1000 items can occasionally fail. Consider a wider tolerance, or seeding the sketch.
| )) | ||
| })?; | ||
| let projected = KeyByLabelValues::new_with_labels( | ||
| projection.iter().map(|&i| key.labels[i].clone()).collect(), |
There was a problem hiding this comment.
Small thing: key is owned here, so the projected values could be moved out with std::mem::take(&mut key.labels[i]) (the projection indices are distinct) instead of cloning one String per label per store group.
Part 2/5 of #839. Stacked on #847. Stack: Sliding cover fix (#847) → engine roll-up (this) → compliance suite → MILP sketches → MILP exact types.
What
The engine can now answer a query grouped by
Qfrom a single-subpopulation aggregation grouped byG ⊋ Q, by merging the states of everyG-group with the same projection ontoQ. For example,quantile by (service)can be served from a KLL grouped by(endpoint, service).G(value configgrouping_labels) vsQ(query_output_labels).read_range_query_inputs): right after the store read,roll_up_groupsre-keys eachG-group by its projection and appends its buckets. The existing per-stepget_merged()then folds time and subpopulations in one batch merge, andfallback_keybecomes theQ-key, so output labels come out right. Instant (run as a one-step range) and range queries, and PromQL, SQL and Elastic, all pass through here.rollup_projection): only applies when the value type isn't keyed, there's no key aggregation,aggregated_labelsis empty and the statistic isn't top-k. Otherwise behaviour is unchanged.Q ⊄ G(can't serve).{Sum, MinMax, DatasketchesKLL, DDSketch, HLL}, e.g. Increase (can't be merged across groups).G-key. The per-step range check counts distinct windows (fix(query-engine): judge a Sliding range step's cover by distinct windows #847), so a subgroup with no samples in one cover window still contributes its other windows.Tests (
src/tests/rollup_tests.rs,roll_up_groups_testsinmod.rs)Q. Alsosum(x)with no grouping rolls everything into one group, and dropping a middle grouping label keeps the remaining label values aligned.max by (service)over subgroups.COUNT(DISTINCT), the only path that reaches HLL.Q ⊄ G, Increase roll-up, and a malformed store key inroll_up_groups.Existing suites are unchanged: 667 lib tests and the workspace tests pass.
🤖 Generated with Claude Code