@@ -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