Skip to content

Commit 5fc64ac

Browse files
fix(query-engine): range query bucket scan steps by slide_interval_ms, not window_size_ms (#600) (#603)
finish_range_context derived the range-query bucket-scan step from window_size_ms for both the value and keys sides, but precompute buckets are always persisted on the slide_interval_ms grid (window_manager.rs's panes_for_window). Tumbling windows set the two equal, masking this; Sliding windows with slide_interval_ms < window_size_ms had real buckets fall on timestamps the scan never visited and got silently dropped from the merge. Fixes #600. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent faff7b8 commit 5fc64ac

2 files changed

Lines changed: 239 additions & 2 deletions

File tree

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

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,25 @@ impl SimpleEngine {
557557
lookback_ms
558558
}
559559

560+
/// The bucket-map grid width for scanning an aggregation's stored
561+
/// buckets in a range query: `slide_interval_ms`, not `window_size_ms`.
562+
/// `precompute_engine/window_manager.rs` persists buckets on the
563+
/// `slide_interval_ms` grid unconditionally (its `panes_for_window`
564+
/// steps by `slide_interval_ms`, regardless of `WindowType`) — for
565+
/// Tumbling aggregations the two are equal by construction, so this is
566+
/// a no-op there, but for Sliding aggregations with
567+
/// `slide_interval_ms < window_size_ms`, stepping by `window_size_ms`
568+
/// walks straight past real buckets and silently drops them (#600).
569+
/// Mirrors `WindowManager::new`'s `slide_interval_ms == 0` fallback so a
570+
/// config that leaves the field unset is still treated as Tumbling.
571+
fn bucket_step_ms(config: &asap_types::AggregationConfig) -> u64 {
572+
if config.slide_interval_ms == 0 {
573+
config.window_size_ms
574+
} else {
575+
config.slide_interval_ms
576+
}
577+
}
578+
560579
/// Extends an instant `QueryExecutionContext` into a `RangeQueryExecutionContext`:
561580
/// computes the lookback window from the aggregation's tumbling window size,
562581
/// validates the range params, and widens the store plan to cover
@@ -581,7 +600,7 @@ impl SimpleEngine {
581600
.read()
582601
.unwrap()
583602
.get_aggregation_config(base_context.agg_info.aggregation_id_for_value)
584-
.map(|c| c.window_size_ms)?;
603+
.map(Self::bucket_step_ms)?;
585604

586605
self.validate_range_query_params(start_ms, end_ms, step_ms, tumbling_window_ms)
587606
.map_err(|e| {
@@ -617,7 +636,7 @@ impl SimpleEngine {
617636
.read()
618637
.unwrap()
619638
.get_aggregation_config(base_context.agg_info.aggregation_id_for_key)
620-
.map(|c| c.window_size_ms)?,
639+
.map(Self::bucket_step_ms)?,
621640
),
622641
None => None,
623642
};

asap-query-engine/src/tests/native_range_query_tests.rs

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,178 @@ mod tests {
254254
)
255255
}
256256

257+
/// Same dual-population shape as `create_range_engine_dual_input_with_windows`,
258+
/// but the KEY aggregation is a Sliding window with
259+
/// `key_slide_interval_ms < key_window_size_ms` (#600). Real Sliding
260+
/// buckets are persisted on the slide_interval_ms grid, not the
261+
/// window_size_ms grid (`precompute_engine/window_manager.rs`), so the
262+
/// keys bucket span here is `key_slide_interval_ms`, not
263+
/// `key_window_size_ms` -- unlike the value side, which stays Tumbling
264+
/// (span == window) exactly as `create_range_engine_dual_input_with_windows`
265+
/// already does.
266+
#[allow(clippy::too_many_arguments)]
267+
fn create_range_engine_dual_input_sliding_keys(
268+
metric: &str,
269+
value_agg_type: AggregationType,
270+
key_agg_type: AggregationType,
271+
grouping_labels: Vec<&str>,
272+
aggregated_labels: Vec<&str>,
273+
value_data: TimeSeriesData,
274+
keys_data: TimeSeriesData,
275+
promql_query: &str,
276+
value_window_ms: u64,
277+
key_window_size_ms: u64,
278+
key_slide_interval_ms: u64,
279+
) -> SimpleEngine {
280+
let grouping_label_strings: Vec<String> =
281+
grouping_labels.iter().map(|s| s.to_string()).collect();
282+
let aggregated_label_strings: Vec<String> =
283+
aggregated_labels.iter().map(|s| s.to_string()).collect();
284+
let all_labels: Vec<String> = grouping_label_strings
285+
.iter()
286+
.chain(aggregated_label_strings.iter())
287+
.cloned()
288+
.collect();
289+
290+
let mut aggregation_configs = HashMap::new();
291+
aggregation_configs.insert(
292+
1u64,
293+
AggregationConfig {
294+
aggregation_id: 1,
295+
aggregation_type: value_agg_type,
296+
aggregation_sub_type: String::new(),
297+
parameters: HashMap::new(),
298+
grouping_labels: KeyByLabelNames::new(grouping_label_strings.clone()),
299+
aggregated_labels: KeyByLabelNames::empty(),
300+
rollup_labels: KeyByLabelNames::empty(),
301+
original_yaml: String::new(),
302+
window_size_ms: value_window_ms,
303+
slide_interval_ms: value_window_ms,
304+
window_type: WindowType::Tumbling,
305+
spatial_filter: String::new(),
306+
spatial_filter_normalized: String::new(),
307+
metric: metric.to_string(),
308+
num_aggregates_to_retain: None,
309+
read_count_threshold: None,
310+
table_name: None,
311+
value_column: None,
312+
},
313+
);
314+
aggregation_configs.insert(
315+
2u64,
316+
AggregationConfig {
317+
aggregation_id: 2,
318+
aggregation_type: key_agg_type,
319+
aggregation_sub_type: String::new(),
320+
parameters: HashMap::new(),
321+
grouping_labels: KeyByLabelNames::new(grouping_label_strings),
322+
aggregated_labels: KeyByLabelNames::new(aggregated_label_strings),
323+
rollup_labels: KeyByLabelNames::empty(),
324+
original_yaml: String::new(),
325+
window_size_ms: key_window_size_ms,
326+
slide_interval_ms: key_slide_interval_ms,
327+
window_type: WindowType::Sliding,
328+
spatial_filter: String::new(),
329+
spatial_filter_normalized: String::new(),
330+
metric: metric.to_string(),
331+
num_aggregates_to_retain: None,
332+
read_count_threshold: None,
333+
table_name: None,
334+
value_column: None,
335+
},
336+
);
337+
338+
let streaming_config = Arc::new(StreamingConfig {
339+
aggregation_configs,
340+
});
341+
342+
let store = Arc::new(SimpleMapStore::new(
343+
streaming_config.clone(),
344+
CleanupPolicy::NoCleanup,
345+
));
346+
347+
for (agg_id, bucket_span_ms, data) in [
348+
(1u64, value_window_ms, value_data),
349+
(2u64, key_slide_interval_ms, keys_data),
350+
] {
351+
for (timestamp, label_values_opt, acc) in data {
352+
let key = label_values_opt.map(|labels| KeyByLabelValues { labels });
353+
let output =
354+
PrecomputedOutput::new(timestamp - bucket_span_ms, timestamp, key, agg_id);
355+
store.insert_precomputed_output(output, acc).unwrap();
356+
}
357+
}
358+
359+
let promql_schema =
360+
PromQLSchema::new().add_metric(metric.to_string(), KeyByLabelNames::new(all_labels));
361+
362+
let query_config = QueryConfig::new(promql_query.to_string())
363+
.add_aggregation(AggregationReference::new(1, None))
364+
.add_aggregation(AggregationReference::new(2, None));
365+
366+
let inference_config = InferenceConfig {
367+
schema: SchemaConfig::PromQL(promql_schema),
368+
query_configs: vec![query_config],
369+
cleanup_policy: CleanupPolicy::NoCleanup,
370+
};
371+
372+
SimpleEngine::new(
373+
store,
374+
inference_config,
375+
streaming_config,
376+
WINDOW_MS,
377+
QueryLanguage::promql,
378+
)
379+
}
380+
381+
#[tokio::test(flavor = "multi_thread")]
382+
async fn range_query_sliding_keys_bucket_found_on_slide_interval_grid() {
383+
// #600: the keys-side scan_window must step by the KEY aggregation's
384+
// own slide_interval_ms, not its window_size_ms. Key aggregation:
385+
// window_size_ms=2000, slide_interval_ms=1000 (Sliding) -- real
386+
// buckets land on the 1000ms grid (start=1000), which isn't on the
387+
// 2000ms window_size_ms grid ({0, 2000, 4000, ...}) at all. A scan
388+
// that steps by window_size_ms never visits t=1000 and silently
389+
// drops host-a's key.
390+
let mut keys_add = SetAggregatorAccumulator::new();
391+
keys_add.add_key(KeyByLabelValues {
392+
labels: vec!["host-a".to_string(), "evt-1".to_string()],
393+
});
394+
395+
let engine = create_range_engine_dual_input_sliding_keys(
396+
"event_frequency",
397+
AggregationType::CountMinSketch,
398+
AggregationType::SetAggregator,
399+
vec![],
400+
vec!["host", "event"],
401+
vec![(
402+
2000,
403+
None,
404+
Box::new(CountMinSketchAccumulator::new(2, 3)) as Box<dyn AggregateCore>,
405+
)],
406+
// Keys bucket spans [1000, 2000) -- on the slide_interval_ms=1000
407+
// grid, but not the window_size_ms=2000 grid.
408+
vec![(2000, None, Box::new(keys_add) as Box<dyn AggregateCore>)],
409+
"count(event_frequency) by (host, event)",
410+
1000, // value_window_ms (Tumbling, unaffected by #600)
411+
2000, // key_window_size_ms
412+
1000, // key_slide_interval_ms
413+
);
414+
415+
let query = "count(event_frequency) by (host, event)";
416+
let result = engine.handle_range_query_promql(query.to_string(), 2.0, 2.5, 1.0);
417+
let (_, qr) = result.expect("range query failed");
418+
let elements = matrix_values(qr);
419+
420+
assert!(
421+
key_has_sample_at(&elements, "host-a", 2000),
422+
"BUG #600: host-a's keys delta bucket (start=1000, on the \
423+
slide_interval_ms=1000 grid but not the window_size_ms=2000 \
424+
grid) was not found -- the keys-side scan_window is stepping \
425+
by window_size_ms instead of slide_interval_ms"
426+
);
427+
}
428+
257429
#[tokio::test(flavor = "multi_thread")]
258430
async fn range_query_dual_population_returns_key_expansion() {
259431
// Same dual-population shape as native_binary_instant_tests::binary_expr_vector_vector_dual_population,
@@ -525,6 +697,52 @@ mod tests {
525697
);
526698
}
527699

700+
#[tokio::test(flavor = "multi_thread")]
701+
async fn range_query_sliding_window_slide_lt_size_merges_bucket_off_window_size_grid() {
702+
// #600: value-side counterpart of
703+
// range_query_sliding_keys_bucket_found_on_slide_interval_grid.
704+
// window_size_ms=2000, but create_engine_multi_timestamp_with_window
705+
// fixes the bucket span at slide_interval_ms=1000, so the two
706+
// buckets below land at start=0 and start=1000 -- only one of which
707+
// is on the window_size_ms=2000 grid ({0, 2000, ...}). A scan_window
708+
// that steps by window_size_ms instead of slide_interval_ms never
709+
// visits start=1000 and silently drops that bucket from the merge.
710+
let data = vec![
711+
(
712+
1000,
713+
Some(vec!["host-a".to_string()]),
714+
Box::new(SumAccumulator::with_sum(10.0)) as Box<dyn AggregateCore>,
715+
),
716+
(
717+
2000,
718+
Some(vec!["host-a".to_string()]),
719+
Box::new(SumAccumulator::with_sum(5.0)) as Box<dyn AggregateCore>,
720+
),
721+
];
722+
let query = "sum_over_time(http_requests[1s])";
723+
let engine = create_engine_multi_timestamp_with_window(
724+
"http_requests",
725+
AggregationType::Sum,
726+
vec!["host"],
727+
data,
728+
query,
729+
2000, // window_size_ms
730+
WindowType::Sliding,
731+
);
732+
733+
let result = engine.handle_range_query_promql(query.to_string(), 2.0, 2.5, 2.0);
734+
let (_, qr) = result.expect("range query failed");
735+
let elements = matrix_values(qr);
736+
assert_eq!(elements.len(), 1, "expected one series for host-a");
737+
assert!(
738+
(elements[0].samples[0].value - 15.0).abs() < 1e-9,
739+
"BUG #600: expected both buckets (10.0 + 5.0 = 15.0) merged, got {} \
740+
-- tumbling_window_ms is stepping by window_size_ms=2000 instead \
741+
of slide_interval_ms=1000, so the bucket at start=1000 is dropped",
742+
elements[0].samples[0].value
743+
);
744+
}
745+
528746
#[tokio::test(flavor = "multi_thread")]
529747
async fn range_query_dual_population_expands_keys_across_multiple_steps() {
530748
// Extends range_query_dual_population_returns_key_expansion across

0 commit comments

Comments
 (0)