Skip to content

Commit 38fdd8e

Browse files
fix(query-engine): judge a Sliding range step's cover by distinct windows (#847)
* fix(query-engine): judge a Sliding range step's cover by distinct windows Two workers can each emit a bucket for the same (key, window), so counting buckets let a duplicate stand in for a missing cover window and return a partial value. Count distinct window starts instead, on both the value and the key side. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(query-engine): check Sliding covers before cloning --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 328aea5 commit 38fdd8e

2 files changed

Lines changed: 242 additions & 65 deletions

File tree

‎asap-query-engine/src/engines/simple_engine/mod.rs‎

Lines changed: 64 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -2342,11 +2342,9 @@ impl SimpleEngine {
23422342
}
23432343

23442344
/// Collects every bucket in `bucket_map` whose start falls in
2345-
/// `[window_start, window_end)`, stepping by `step_increment`. Missing
2346-
/// buckets at a given start are skipped (partial data is okay). Used
2347-
/// identically by `execute_range_query_pipeline` for both the value
2348-
/// side and (#583) the keys side — the only difference between the two
2349-
/// call sites is which map/lookback/bucket-width they pass in.
2345+
/// `[window_start, window_end)`. Sliding walks by its stored-window
2346+
/// stride; Tumbling walks by `step_increment`. An incomplete Sliding
2347+
/// cover is rejected before accumulator sketches are cloned.
23502348
///
23512349
/// Cost is proportional to `(window_end - window_start) / step_increment`
23522350
/// -- the nominal window width -- not to how many buckets actually exist
@@ -2356,32 +2354,59 @@ impl SimpleEngine {
23562354
/// `[0, current_time)` ("replay from the beginning") -- callers on that
23572355
/// path MUST use `collect_bucket_map_entries_before` instead, never this
23582356
/// (#581 stage E.4 review; see that function's doc for why).
2359-
fn sum_window(
2357+
fn window_buckets_for_step(
23602358
bucket_map: &BucketMap,
23612359
window_start: u64,
23622360
window_end: u64,
23632361
step_increment: u64,
2364-
) -> Vec<Box<dyn AggregateCore>> {
2362+
window_type: WindowType,
2363+
stored_window_size_ms: u64,
2364+
expected_sliding_window_count: Option<usize>,
2365+
) -> Option<Vec<Box<dyn AggregateCore>>> {
23652366
// A zero step never advances `t`, so the loop below would never
23662367
// terminate. This is the single caller-facing guard for that hazard
23672368
// -- callers upstream may also validate their own step sources, but
23682369
// this function has two callers (values, keys) and should not rely
23692370
// on either of them to have done so. Kept active in release builds
23702371
// (assert!, not debug_assert!): a hung query is a production
23712372
// incident, not just a debug-time nicety.
2373+
let grid_step_ms = if window_type == WindowType::Sliding {
2374+
stored_window_size_ms
2375+
} else {
2376+
step_increment
2377+
};
23722378
assert!(
2373-
step_increment > 0,
2374-
"sum_window: step_increment must be nonzero, or this loop never terminates"
2379+
grid_step_ms > 0,
2380+
"window_buckets_for_step: grid step must be nonzero"
23752381
);
2376-
let mut window_buckets: Vec<Box<dyn AggregateCore>> = Vec::new();
2382+
assert_eq!(
2383+
expected_sliding_window_count.is_some(),
2384+
window_type == WindowType::Sliding,
2385+
"only Sliding windows have a completeness requirement"
2386+
);
2387+
2388+
let mut selected_buckets = Vec::new();
2389+
let mut covered_window_count = 0;
23772390
let mut t = window_start;
23782391
while t < window_end {
2379-
if let Some(buckets) = bucket_map.get(&t) {
2380-
window_buckets.extend(buckets.iter().map(|b| b.clone_boxed_core()));
2392+
if let Some(window_buckets) = bucket_map.get(&t) {
2393+
if !window_buckets.is_empty() {
2394+
covered_window_count += 1;
2395+
selected_buckets.extend(window_buckets.iter());
2396+
}
23812397
}
2382-
t += step_increment;
2398+
t += grid_step_ms;
2399+
}
2400+
if expected_sliding_window_count.is_some_and(|expected| covered_window_count < expected) {
2401+
return None;
23832402
}
2384-
window_buckets
2403+
2404+
Some(
2405+
selected_buckets
2406+
.into_iter()
2407+
.map(|bucket| bucket.clone_boxed_core())
2408+
.collect(),
2409+
)
23852410
}
23862411

23872412
/// Collects every bucket in `bucket_map` with a start timestamp strictly
@@ -2444,30 +2469,6 @@ impl SimpleEngine {
24442469
.collect()
24452470
}
24462471

2447-
/// Picks how a step's window is composed from `bucket_map`: Sliding walks
2448-
/// the non-overlapping stored-window stride `W`; Tumbling walks the
2449-
/// bucket grid stride. Used identically for values and SetAggregator
2450-
/// keys. Walking Sliding by its slide `S` would double-count overlapping
2451-
/// full-window aggregates (#608/#621).
2452-
///
2453-
/// NOT used for `AggregationType::DeltaSetAggregator` keys -- callers on
2454-
/// that path must call `collect_bucket_map_entries_before` directly
2455-
/// instead (see its doc comment).
2456-
fn window_buckets_for_step(
2457-
bucket_map: &BucketMap,
2458-
window_start: u64,
2459-
window_end: u64,
2460-
step_increment: u64,
2461-
window_type: WindowType,
2462-
stored_window_size_ms: u64,
2463-
) -> Vec<Box<dyn AggregateCore>> {
2464-
if window_type == WindowType::Sliding {
2465-
Self::sum_window(bucket_map, window_start, window_end, stored_window_size_ms)
2466-
} else {
2467-
Self::sum_window(bucket_map, window_start, window_end, step_increment)
2468-
}
2469-
}
2470-
24712472
/// Execute the range query pipeline.
24722473
///
24732474
/// `enable_topk_limiting`/`enable_topk_formatting` mirror
@@ -2987,28 +2988,28 @@ impl SimpleEngine {
29872988
};
29882989
let keys_window_start =
29892990
keys_window_end.saturating_sub(*keys_lookback_ms);
2990-
Self::window_buckets_for_step(
2991+
let expected_sliding_window_count =
2992+
(*keys_window_type == WindowType::Sliding).then_some(
2993+
(*keys_lookback_ms / *keys_stored_window_size_ms) as usize,
2994+
);
2995+
let Some(keys_window_buckets) = Self::window_buckets_for_step(
29912996
keys_bucket_map,
29922997
keys_window_start,
29932998
keys_window_end,
29942999
*keys_tumbling_window_ms,
29953000
*keys_window_type,
29963001
*keys_stored_window_size_ms,
2997-
)
3002+
expected_sliding_window_count,
3003+
) else {
3004+
debug!(
3005+
"Skipping incomplete Sliding key cover at t={}",
3006+
current_time
3007+
);
3008+
continue;
3009+
};
3010+
keys_window_buckets
29983011
};
29993012

3000-
if *keys_window_type == WindowType::Sliding {
3001-
let expected =
3002-
(*keys_lookback_ms / *keys_stored_window_size_ms) as usize;
3003-
if keys_window_buckets.len() < expected {
3004-
debug!(
3005-
"Skipping incomplete Sliding key cover at t={}",
3006-
current_time
3007-
);
3008-
continue;
3009-
}
3010-
}
3011-
30123013
if keys_window_buckets.is_empty() {
30133014
debug!(
30143015
"No keys data in window at t={} — skipping this step for this group",
@@ -3043,14 +3044,23 @@ impl SimpleEngine {
30433044
};
30443045
let window_start = window_end.saturating_sub(lookback_ms);
30453046

3046-
let window_buckets = Self::window_buckets_for_step(
3047+
let expected_sliding_window_count = (window_type == WindowType::Sliding)
3048+
.then_some((lookback_ms / context.window_size_ms) as usize);
3049+
let Some(window_buckets) = Self::window_buckets_for_step(
30473050
bucket_map,
30483051
window_start,
30493052
window_end,
30503053
tumbling_window_ms,
30513054
window_type,
30523055
context.window_size_ms,
3053-
);
3056+
expected_sliding_window_count,
3057+
) else {
3058+
debug!(
3059+
"Skipping incomplete Sliding value cover at t={}",
3060+
current_time
3061+
);
3062+
continue;
3063+
};
30543064

30553065
trace!(
30563066
current_time,
@@ -3063,17 +3073,6 @@ impl SimpleEngine {
30633073
"Composed query output window from stored buckets"
30643074
);
30653075

3066-
if window_type == WindowType::Sliding {
3067-
let expected = (lookback_ms / context.window_size_ms) as usize;
3068-
if window_buckets.len() < expected {
3069-
debug!(
3070-
"Skipping incomplete Sliding value cover at t={}",
3071-
current_time
3072-
);
3073-
continue;
3074-
}
3075-
}
3076-
30773076
if window_buckets.is_empty() {
30783077
// No data at all for this window - skip sample
30793078
debug!(

‎asap-query-engine/src/tests/window_semantics_consistency_tests.rs‎

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,184 @@ mod tests {
331331
);
332332
}
333333

334+
/// Two workers can each emit a bucket for the same (key, window), so a
335+
/// range step's Sliding cover must be judged by distinct window starts,
336+
/// not bucket count: two buckets for [3s, 6s) must not stand in for the
337+
/// missing [0s, 3s).
338+
#[tokio::test(flavor = "multi_thread")]
339+
async fn sliding_range_step_with_duplicate_bucket_and_missing_window_is_skipped() {
340+
let query = "sum_over_time(cpu_load[6s])";
341+
let streaming_config = Arc::new(StreamingConfig {
342+
aggregation_configs: HashMap::from([(
343+
1,
344+
AggregationConfig {
345+
aggregation_id: 1,
346+
aggregation_type: AggregationType::Sum,
347+
aggregation_sub_type: String::new(),
348+
parameters: HashMap::new(),
349+
grouping_labels: KeyByLabelNames::new(vec!["host".to_string()]),
350+
aggregated_labels: KeyByLabelNames::empty(),
351+
rollup_labels: KeyByLabelNames::empty(),
352+
original_yaml: String::new(),
353+
window_size_ms: 3_000,
354+
slide_interval_ms: 1_000,
355+
window_type: WindowType::Sliding,
356+
spatial_filter: String::new(),
357+
spatial_filter_normalized: String::new(),
358+
metric: "cpu_load".to_string(),
359+
num_aggregates_to_retain: None,
360+
read_count_threshold: None,
361+
table_name: None,
362+
value_column: None,
363+
},
364+
)]),
365+
});
366+
let store = Arc::new(SimpleMapStore::new(
367+
streaming_config.clone(),
368+
CleanupPolicy::NoCleanup,
369+
));
370+
let host_a = Some(KeyByLabelValues {
371+
labels: vec!["host-a".to_string()],
372+
});
373+
for (start, value) in [
374+
(3_000, 10.0),
375+
(3_000, 10.0),
376+
(1_000, 100.0),
377+
(4_000, 1_000.0),
378+
] {
379+
store
380+
.insert_precomputed_output(
381+
PrecomputedOutput::new(start, start + 3_000, host_a.clone(), 1),
382+
Box::new(SumAccumulator::with_sum(value)),
383+
)
384+
.unwrap();
385+
}
386+
let inference_config = InferenceConfig {
387+
schema: SchemaConfig::PromQL(PromQLSchema::new().add_metric(
388+
"cpu_load".to_string(),
389+
KeyByLabelNames::new(vec!["host".to_string()]),
390+
)),
391+
query_configs: vec![QueryConfig::new(query.to_string())
392+
.add_aggregation(AggregationReference::new(1, None))],
393+
cleanup_policy: CleanupPolicy::NoCleanup,
394+
};
395+
let engine = SimpleEngine::new(
396+
store,
397+
inference_config,
398+
streaming_config,
399+
1_000,
400+
QueryLanguage::promql,
401+
);
402+
403+
let result = engine
404+
.handle_range_query_promql(query.to_string(), 6.0, 7.0, 1.0)
405+
.expect("native query execution should not fail")
406+
.expect("the step at 7s has a complete cover");
407+
408+
assert_eq!(
409+
host_a_samples(&matrix_values(result.1)),
410+
vec![(7_000, 1_100.0)]
411+
);
412+
}
413+
414+
/// The same cover rule applies to per-step keys: worker partials at one
415+
/// window start cannot make a missing key window look complete.
416+
#[tokio::test(flavor = "multi_thread")]
417+
async fn sliding_key_cover_with_duplicate_bucket_and_missing_window_is_skipped() {
418+
let query = "sum by (host) (count_over_time(event_frequency[6s]))";
419+
let config = |aggregation_id, aggregation_type, aggregated_labels| AggregationConfig {
420+
aggregation_id,
421+
aggregation_type,
422+
aggregation_sub_type: String::new(),
423+
parameters: HashMap::new(),
424+
grouping_labels: KeyByLabelNames::empty(),
425+
aggregated_labels,
426+
rollup_labels: KeyByLabelNames::empty(),
427+
original_yaml: String::new(),
428+
window_size_ms: 3_000,
429+
slide_interval_ms: 1_000,
430+
window_type: WindowType::Sliding,
431+
spatial_filter: String::new(),
432+
spatial_filter_normalized: String::new(),
433+
metric: "event_frequency".to_string(),
434+
num_aggregates_to_retain: None,
435+
read_count_threshold: None,
436+
table_name: None,
437+
value_column: None,
438+
};
439+
let streaming_config = Arc::new(StreamingConfig {
440+
aggregation_configs: HashMap::from([
441+
(
442+
1,
443+
config(1, AggregationType::CountMinSketch, KeyByLabelNames::empty()),
444+
),
445+
(
446+
2,
447+
config(
448+
2,
449+
AggregationType::SetAggregator,
450+
KeyByLabelNames::new(vec!["host".to_string()]),
451+
),
452+
),
453+
]),
454+
});
455+
let store = Arc::new(SimpleMapStore::new(
456+
streaming_config.clone(),
457+
CleanupPolicy::NoCleanup,
458+
));
459+
let host_a = KeyByLabelValues {
460+
labels: vec!["host-a".to_string()],
461+
};
462+
463+
// Values fully cover both steps. Keys have two worker partials at
464+
// [3s, 6s), no [0s, 3s) bucket, then complete coverage at 7s.
465+
for start in [0, 1_000, 3_000, 4_000] {
466+
let mut values = CountMinSketchAccumulator::new(4, 128);
467+
values.inner.update(&host_a.to_semicolon_str(), 1.0);
468+
store
469+
.insert_precomputed_output(
470+
PrecomputedOutput::new(start, start + 3_000, None, 1),
471+
Box::new(values),
472+
)
473+
.unwrap();
474+
}
475+
for start in [3_000, 3_000, 1_000, 4_000] {
476+
let mut keys = SetAggregatorAccumulator::new();
477+
keys.add_key(host_a.clone());
478+
store
479+
.insert_precomputed_output(
480+
PrecomputedOutput::new(start, start + 3_000, None, 2),
481+
Box::new(keys),
482+
)
483+
.unwrap();
484+
}
485+
486+
let inference_config = InferenceConfig {
487+
schema: SchemaConfig::PromQL(PromQLSchema::new().add_metric(
488+
"event_frequency".to_string(),
489+
KeyByLabelNames::new(vec!["host".to_string()]),
490+
)),
491+
query_configs: vec![QueryConfig::new(query.to_string())
492+
.add_aggregation(AggregationReference::new(1, None))
493+
.add_aggregation(AggregationReference::new(2, None))],
494+
cleanup_policy: CleanupPolicy::NoCleanup,
495+
};
496+
let engine = SimpleEngine::new(
497+
store,
498+
inference_config,
499+
streaming_config,
500+
1_000,
501+
QueryLanguage::promql,
502+
);
503+
504+
let result = engine
505+
.handle_range_query_promql(query.to_string(), 6.0, 7.0, 1.0)
506+
.expect("native query execution should not fail")
507+
.expect("the step at 7s has a complete key cover");
508+
509+
assert_eq!(host_a_samples(&matrix_values(result.1)), vec![(7_000, 2.0)]);
510+
}
511+
334512
// ════════════════════════════════════════════════════════════════════
335513
// 2. Tumbling: instant and range paths must AGREE, and both must
336514
// correctly SUM every disjoint bucket in the query's window (proving

0 commit comments

Comments
 (0)