Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
171 changes: 171 additions & 0 deletions asap-query-engine/src/engines/simple_engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,17 @@ struct RangeQueryReads {
keys: Option<TimestampedBucketsMap>,
}

/// 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] = [

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.

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.

AggregationType::Sum,
AggregationType::MinMax,
AggregationType::DatasketchesKLL,
AggregationType::DDSketch,
AggregationType::HLL,
];

type BucketMap = HashMap<u64, Vec<Arc<dyn AggregateCore>>>;
type TopkPartition = (u64, Vec<String>);
type TopkCandidates<'a> = HashMap<TopkPartition, Vec<(&'a KeyByLabelValues, f64)>>;
Expand Down Expand Up @@ -2616,10 +2627,83 @@ impl SimpleEngine {
))
}

/// For a query grouped by labels `Q` served from a single-subpopulation
/// aggregation grouped by `G ⊋ Q` (a roll-up), the position in `G` of
/// each label of `Q`. `None` when the query reads the aggregation at its
/// own grouping. Keyed and top-k aggregations are never rolled up here.
fn rollup_projection(
context: &QueryExecutionContext,
) -> Result<Option<Vec<usize>>, QueryExecutionError> {
let value_type = context.agg_info.aggregation_type_for_value;
let single_subpopulation = !value_type.is_keyed()
&& context.store_plan.keys_query.is_none()
&& context.aggregated_labels.labels.is_empty()
&& 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 {

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.

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.

return Ok(None);
}
let projection = output
.iter()
.map(|label| grouping.iter().position(|g| g == label))
.collect::<Option<Vec<usize>>>()
.ok_or_else(|| {

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.

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.

QueryExecutionError::Native(format!(
"query on metric {} is grouped by {:?}, which aggregation {} (grouped by \
{:?}) can't serve",
context.metric, output, context.agg_info.aggregation_id_for_value, grouping
))
})?;
if !ROLLUP_AGGREGATION_TYPES.contains(&value_type) {
return Err(QueryExecutionError::Native(format!(
"query on metric {} is grouped by {:?}, a roll-up of aggregation {} (grouped by \
{:?}), but {} can't be merged across groups",
context.metric,
output,
context.agg_info.aggregation_id_for_value,
grouping,
value_type.as_str()
)));
}
Ok(Some(projection))
}

/// Re-keys every store group by its projection onto the query's labels,
/// so the per-step merge folds all subpopulations of an output group.
fn roll_up_groups(
values: TimestampedBucketsMap,
projection: &[usize],
grouping_label_count: usize,
) -> Result<TimestampedBucketsMap, QueryExecutionError> {
let mut rolled: TimestampedBucketsMap = HashMap::new();
for (key, buckets) in values {
let key = key
.filter(|key| key.labels.len() == grouping_label_count)
.ok_or_else(|| {
QueryExecutionError::Native(format!(
"store key doesn't have the aggregation's {grouping_label_count} \
grouping label values"
))
})?;
let projected = KeyByLabelValues::new_with_labels(
projection.iter().map(|&i| key.labels[i].clone()).collect(),

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.

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.

);
rolled.entry(Some(projected)).or_default().extend(buckets);
}
// 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);

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.

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.

}
Ok(rolled)
}

fn read_range_query_inputs(
&self,
context: &RangeQueryExecutionContext,
) -> Result<RangeQueryReads, QueryExecutionError> {
let rollup_projection = Self::rollup_projection(&context.base)?;
let lookback_ms = (context.lookback_bucket_count as u64) * context.tumbling_window_ms;
let values = if context.window_type == WindowType::Sliding {
self.execute_sliding_cover_query(
Expand All @@ -2644,6 +2728,14 @@ impl SimpleEngine {
values.len(),
values.values().map(|buckets| buckets.len()).sum::<usize>()
);
let values = match rollup_projection {

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.

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.

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.

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.

Some(projection) => Self::roll_up_groups(
values,
&projection,
context.base.grouping_labels.labels.len(),
)?,
None => values,
};

let keys = match &context.base.store_plan.keys_query {
Some(query) if context.keys_window_type == Some(WindowType::Sliding) => {
Expand Down Expand Up @@ -3174,6 +3266,85 @@ impl SimpleEngine {
}
}

#[cfg(test)]
mod roll_up_groups_tests {
use super::SimpleEngine;
use crate::data_model::{AggregateCore, KeyByLabelValues};
use crate::precompute_operators::sum_accumulator::SumAccumulator;
use crate::stores::TimestampedBucketsMap;
use std::sync::Arc;

fn bucket(start: u64, value: f64) -> ((u64, u64), Arc<dyn AggregateCore>) {
(
(start, start + 1_000),
Arc::new(SumAccumulator::with_sum(value)),
)
}

fn key(labels: &[&str]) -> Option<KeyByLabelValues> {
Some(KeyByLabelValues::new_with_labels(
labels.iter().map(|label| label.to_string()).collect(),
))
}

#[test]
fn groups_with_the_same_projection_share_one_chronological_bucket_list() {
let values: TimestampedBucketsMap = [
(key(&["/x", "a"]), vec![bucket(2_000, 1.0)]),
(
key(&["/y", "a"]),
vec![bucket(1_000, 2.0), bucket(2_000, 4.0)],
),
(key(&["/x", "b"]), vec![bucket(1_000, 8.0)]),
]
.into_iter()
.collect();

let rolled = SimpleEngine::roll_up_groups(values, &[1], 2).unwrap();

let ranges = |labels: &[&str]| -> Vec<(u64, u64)> {
rolled[&key(labels)]
.iter()
.map(|(range, _)| *range)
.collect()
};
assert_eq!(rolled.len(), 2);
assert_eq!(
ranges(&["a"]),
vec![(1_000, 2_000), (2_000, 3_000), (2_000, 3_000)]
);
assert_eq!(ranges(&["b"]), vec![(1_000, 2_000)]);
}

#[test]
fn empty_projection_rolls_everything_into_one_group() {
let values: TimestampedBucketsMap = [
(key(&["/x", "a"]), vec![bucket(1_000, 1.0)]),
(key(&["/x", "b"]), vec![bucket(1_000, 2.0)]),
]
.into_iter()
.collect();

let rolled = SimpleEngine::roll_up_groups(values, &[], 2).unwrap();

assert_eq!(rolled.len(), 1);
assert_eq!(rolled[&key(&[])].len(), 2);
}

#[test]
fn a_store_key_without_every_grouping_value_is_an_error() {
for bad_key in [None, key(&["a"])] {
let values: TimestampedBucketsMap = [(bad_key.clone(), vec![bucket(1_000, 1.0)])]
.into_iter()
.collect();
assert!(
SimpleEngine::roll_up_groups(values, &[0], 2).is_err(),
"{bad_key:?} should be rejected"
);
}
}
}

#[cfg(test)]
mod topk_metadata_tests {
use super::{QueryExecutionError, SimpleEngine};
Expand Down
1 change: 1 addition & 0 deletions asap-query-engine/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub mod prometheus_forwarding_tests;
pub mod query_equivalence_tests;
pub mod query_time_aggregation_public_tests;
pub mod range_query_arithmetic_tests;
pub mod rollup_tests;
pub mod sql_pattern_matching_tests;
pub mod stage_e_instant_range_equivalence_tests;
pub mod store_correctness_tests;
Expand Down
Loading