Fix sum()/avg() merging sparse across staggered raw timestamps (ticket 042)
merge_series_exact required an exact-millisecond timestamp match to combine sub-series, so sum(requests) by (path)-style queries over raw occurrence counts produced one point per underlying (label, timestamp) pair instead of a meaningful combined total, since real occurrence timestamps across different label groups essentially never coincide. Added a regression test confirming the sparse behavior, then replaced the merge with merge_series_windowed: a single-sub-series group is passed through unchanged (preserving existing single-label-group resolution exactly), and groups with multiple sub-series are bucketed into fixed 1s windows before summing/averaging, mirroring the fixed-width bucketing rate() already uses. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
85fb145be7
commit
9f84be26ef
+100
-7
@@ -1176,14 +1176,30 @@ fn windowed_rate_series(points: &[(i64, f64)], range_ms: i64) -> Vec<(i64, f64)>
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Merges several series by exact timestamp match: values sharing a timestamp are summed (or averaged).
|
||||
/// This is the plain `sum()`/`avg()` behavior — appropriate when the underlying points are occurrence
|
||||
/// markers rather than a continuous reading, so there's nothing to carry forward between timestamps.
|
||||
fn merge_series_exact(sub_series: &[Vec<(i64, f64)>], is_avg: bool) -> Vec<(i64, f64)> {
|
||||
/// Fixed window width (ms) used by `merge_series_windowed` to combine multiple raw-occurrence
|
||||
/// sub-series into one time series — the same fixed-width bucketing style `histogram()`/the
|
||||
/// `Timeline` query already use elsewhere in this file, just applied along the time axis instead of
|
||||
/// the value axis. Occurrence timestamps across different label groups (e.g. different hosts serving
|
||||
/// the same path) essentially never land on the same millisecond, so summing/averaging only requires
|
||||
/// matching *this* window to combine into "total over time" rather than going sparse (ticket 042).
|
||||
const MERGE_WINDOW_MS: i64 = 1_000;
|
||||
|
||||
/// Merges several series into one combined time series (ticket 042). A single sub-series is returned
|
||||
/// untouched — there's only one contributor, so there's nothing to merge and no reason to lose
|
||||
/// resolution. With two or more sub-series, points are bucketed into fixed `MERGE_WINDOW_MS`-wide
|
||||
/// windows (rather than requiring an exact-millisecond timestamp match) before summing/averaging
|
||||
/// within each window, so raw occurrence markers from different label groups combine into a
|
||||
/// meaningful "total over time" instead of each window reflecting only whichever sub-series happened
|
||||
/// to report at that exact millisecond.
|
||||
fn merge_series_windowed(sub_series: &[Vec<(i64, f64)>], is_avg: bool) -> Vec<(i64, f64)> {
|
||||
if sub_series.len() <= 1 {
|
||||
return sub_series.first().cloned().unwrap_or_default();
|
||||
}
|
||||
let mut grouped: BTreeMap<i64, Vec<f64>> = BTreeMap::new();
|
||||
for points in sub_series {
|
||||
for (timestamp_ms, value) in points {
|
||||
grouped.entry(*timestamp_ms).or_default().push(*value);
|
||||
let window_start = timestamp_ms.div_euclid(MERGE_WINDOW_MS) * MERGE_WINDOW_MS;
|
||||
grouped.entry(window_start).or_default().push(*value);
|
||||
}
|
||||
}
|
||||
grouped
|
||||
@@ -1198,7 +1214,7 @@ fn merge_series_exact(sub_series: &[Vec<(i64, f64)>], is_avg: bool) -> Vec<(i64,
|
||||
|
||||
/// Merges several already-windowed rate series by sweeping the union of their timestamps, carrying each
|
||||
/// sub-series' last-known value forward (0.0 before its first point) so every step reflects every
|
||||
/// sub-series' most recent reading — unlike `merge_series_exact`, which only combines exact-timestamp matches.
|
||||
/// sub-series' most recent reading — unlike `merge_series_windowed`, which buckets by fixed time windows instead.
|
||||
fn merge_series_locf(sub_series: &[Vec<(i64, f64)>], is_avg: bool) -> Vec<(i64, f64)> {
|
||||
let mut all_timestamps: std::collections::BTreeSet<i64> = std::collections::BTreeSet::new();
|
||||
for points in sub_series {
|
||||
@@ -1276,7 +1292,7 @@ fn evaluate_promql(
|
||||
let points = if rate_range_ms.is_some() {
|
||||
merge_series_locf(&sub_series, is_avg)
|
||||
} else {
|
||||
merge_series_exact(&sub_series, is_avg)
|
||||
merge_series_windowed(&sub_series, is_avg)
|
||||
};
|
||||
PromqlSeriesDto { labels: group_labels, points }
|
||||
})
|
||||
@@ -2025,6 +2041,83 @@ mod tests {
|
||||
assert_eq!(series[1].points, vec![(0, 1.0)]);
|
||||
}
|
||||
|
||||
fn declare_requests_by_host_metric(index: &mut BTreeMap<IndexKey, DataValue>) {
|
||||
declare_metric(
|
||||
index,
|
||||
metric_request(
|
||||
"requests",
|
||||
r"request path=(?P<path>\S+) host=(?P<host>\S+)",
|
||||
vec![
|
||||
("path", "path", MetricPrimitiveKind::String),
|
||||
("host", "host", MetricPrimitiveKind::String),
|
||||
],
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sum_by_merges_staggered_raw_timestamps_across_label_groups_instead_of_going_sparse() {
|
||||
// Regression test for ticket 042: `sum(requests) by (path)` groups together several
|
||||
// underlying (path, host) series. Their raw occurrence timestamps are staggered (as real
|
||||
// occurrence timestamps essentially always are), so a merge that only combines exact-
|
||||
// timestamp matches (the old `merge_series_exact` behavior) would leave each point
|
||||
// reflecting just one host instead of the combined total across hosts.
|
||||
let mut index = BTreeMap::new();
|
||||
declare_requests_by_host_metric(&mut index);
|
||||
ingest_log(&mut index, 0, "request path=/a host=web1");
|
||||
ingest_log(&mut index, 1, "request path=/a host=web2");
|
||||
ingest_log(&mut index, 2, "request path=/a host=web3");
|
||||
|
||||
let parsed = promql::parse_query("sum(requests) by (path)").unwrap();
|
||||
let series = evaluate_promql(&index, &parsed, None, None);
|
||||
|
||||
assert_eq!(series.len(), 1);
|
||||
assert_eq!(series[0].labels.get("path").map(String::as_str), Some("/a"));
|
||||
// The three staggered per-host occurrences merge into one combined-total point rather than
|
||||
// three separate points each showing a lone host's count of 1.
|
||||
let total: f64 = series[0].points.iter().map(|(_, v)| v).sum();
|
||||
assert_eq!(total, 3.0, "expected all three staggered occurrences to sum to 3.0, got {:?}", series[0].points);
|
||||
assert!(
|
||||
series[0].points.len() < 3,
|
||||
"expected staggered timestamps to be bucketed together rather than staying sparse/near-empty, got {:?}",
|
||||
series[0].points
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn avg_by_merges_staggered_raw_timestamps_across_label_groups() {
|
||||
let mut index = BTreeMap::new();
|
||||
declare_requests_by_host_metric(&mut index);
|
||||
ingest_log(&mut index, 0, "request path=/a host=web1");
|
||||
ingest_log(&mut index, 1, "request path=/a host=web2");
|
||||
|
||||
let parsed = promql::parse_query("avg(requests) by (path)").unwrap();
|
||||
let series = evaluate_promql(&index, &parsed, None, None);
|
||||
|
||||
assert_eq!(series.len(), 1);
|
||||
assert_eq!(series[0].labels.get("path").map(String::as_str), Some("/a"));
|
||||
// Both occurrences (value 1.0 each) land in the same merged bucket, so the average stays 1.0
|
||||
// rather than reporting two separate points that each only saw one host.
|
||||
assert_eq!(series[0].points, vec![(0, 1.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sum_by_still_gives_a_single_label_group_its_own_line_at_full_resolution() {
|
||||
// Existing single-label-group behavior (only one underlying series feeds each output group)
|
||||
// must stay untouched by the multi-series windowing fix.
|
||||
let mut index = BTreeMap::new();
|
||||
declare_requests_metric(&mut index);
|
||||
ingest_log(&mut index, 0, "request path=/a");
|
||||
ingest_log(&mut index, 1, "request path=/a");
|
||||
|
||||
let parsed = promql::parse_query("sum(requests) by (path)").unwrap();
|
||||
let series = evaluate_promql(&index, &parsed, None, None);
|
||||
|
||||
assert_eq!(series.len(), 1);
|
||||
assert_eq!(series[0].points, vec![(0, 1.0), (1, 1.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_rate_produces_a_windowed_point_per_timestamp_not_a_single_point() {
|
||||
let mut index = BTreeMap::new();
|
||||
|
||||
Reference in New Issue
Block a user