@@ -14,14 +14,14 @@ pub(crate) struct NodeId(usize);
1414
1515#[ derive( Debug , Clone , PartialEq , Eq ) ]
1616pub ( crate ) enum StoreReadStrategy {
17- WindowGrid ,
17+ WindowGrid ( WindowCompositionSpec ) ,
1818 SlidingExactCover ( WindowCompositionSpec ) ,
1919}
2020
2121impl StoreReadStrategy {
2222 fn for_window ( window : & WindowCompositionSpec ) -> Self {
2323 match window. window_type {
24- WindowType :: Tumbling => Self :: WindowGrid ,
24+ WindowType :: Tumbling => Self :: WindowGrid ( window . clone ( ) ) ,
2525 WindowType :: Sliding => Self :: SlidingExactCover ( window. clone ( ) ) ,
2626 }
2727 }
@@ -170,24 +170,26 @@ impl QueryPlan {
170170 StoreReadStrategy :: for_window ( & estimate_spec. values . window ) ,
171171 ) ;
172172 let values = Self :: push_prepare_buckets ( & mut nodes, values_read) ;
173- let keys = match ( & context. base . store_plan . keys_query , & estimate_spec. keys ) {
174- ( None , KeyInputSpec :: FromValues ) => None ,
175- ( Some ( query) , KeyInputSpec :: Separate { window, .. } ) => {
176- let read = Self :: push_read (
177- & mut nodes,
178- query,
179- StoreReadRole :: Keys ,
180- StoreReadStrategy :: for_window ( window) ,
181- ) ;
182- Some ( Self :: push_prepare_buckets ( & mut nodes, read) )
183- }
184- ( Some ( _) , KeyInputSpec :: FromValues ) => {
185- return Err ( "Query plan has a keys read without a keys specification" . to_string ( ) ) ;
186- }
187- ( None , KeyInputSpec :: Separate { .. } ) => {
188- return Err ( "Query plan has a keys specification without a keys read" . to_string ( ) ) ;
189- }
190- } ;
173+ let keys = context
174+ . base
175+ . store_plan
176+ . keys_query
177+ . as_ref ( )
178+ . map ( |query| match & estimate_spec. keys {
179+ KeyInputSpec :: Separate { window, .. } => {
180+ let read = Self :: push_read (
181+ & mut nodes,
182+ query,
183+ StoreReadRole :: Keys ,
184+ StoreReadStrategy :: for_window ( window) ,
185+ ) ;
186+ Ok ( Self :: push_prepare_buckets ( & mut nodes, read) )
187+ }
188+ KeyInputSpec :: FromValues => {
189+ Err ( "Query plan has a keys read without a keys specification" . to_string ( ) )
190+ }
191+ } )
192+ . transpose ( ) ?;
191193 let resolved = Self :: push ( & mut nodes, QueryPlanNode :: ResolveKeys { values, keys } ) ;
192194 let mut root = Self :: push (
193195 & mut nodes,
@@ -281,6 +283,69 @@ impl QueryPlan {
281283 ) ) ;
282284 }
283285 }
286+ if let QueryPlanNode :: Estimate {
287+ input,
288+ statistic,
289+ spec,
290+ ..
291+ } = node
292+ {
293+ spec. validate_for ( * statistic) ?;
294+ self . validate_estimate_reads ( * input, spec) ?;
295+ }
296+ }
297+ Ok ( ( ) )
298+ }
299+
300+ fn validate_estimate_reads (
301+ & self ,
302+ resolved : NodeId ,
303+ spec : & RangeEstimateSpec ,
304+ ) -> Result < ( ) , String > {
305+ let QueryPlanNode :: ResolveKeys { values, keys } = & self . nodes [ resolved. 0 ] else {
306+ return Err ( format ! (
307+ "Estimate input n{} must resolve value and key reads" ,
308+ resolved. 0
309+ ) ) ;
310+ } ;
311+ self . validate_read_window ( * values, StoreReadRole :: Values , & spec. values . window ) ?;
312+ match ( & spec. keys , keys) {
313+ ( KeyInputSpec :: FromValues , None ) => Ok ( ( ) ) ,
314+ ( KeyInputSpec :: Separate { window, .. } , Some ( keys) ) => {
315+ self . validate_read_window ( * keys, StoreReadRole :: Keys , window)
316+ }
317+ ( KeyInputSpec :: FromValues , Some ( _) ) => {
318+ Err ( "Self-keyed estimate must not have a separate key read" . to_string ( ) )
319+ }
320+ ( KeyInputSpec :: Separate { .. } , None ) => {
321+ Err ( "Separate-key estimate must have a key read" . to_string ( ) )
322+ }
323+ }
324+ }
325+
326+ fn validate_read_window (
327+ & self ,
328+ prepared : NodeId ,
329+ expected_role : StoreReadRole ,
330+ expected_window : & WindowCompositionSpec ,
331+ ) -> Result < ( ) , String > {
332+ let QueryPlanNode :: PrepareBuckets { input } = & self . nodes [ prepared. 0 ] else {
333+ return Err ( format ! ( "Read n{} must prepare buckets" , prepared. 0 ) ) ;
334+ } ;
335+ let QueryPlanNode :: StoreRead { role, strategy, .. } = & self . nodes [ input. 0 ] else {
336+ return Err ( format ! (
337+ "Prepared node n{} must read from the store" ,
338+ prepared. 0
339+ ) ) ;
340+ } ;
341+ if * role != expected_role {
342+ return Err ( format ! ( "Store read n{} has the wrong role" , input. 0 ) ) ;
343+ }
344+ if strategy != & StoreReadStrategy :: for_window ( expected_window) {
345+ return Err ( format ! (
346+ "Store read n{} window does not match its estimate" ,
347+ input. 0
348+ ) ) ;
284349 }
285350 Ok ( ( ) )
286351 }
@@ -397,7 +462,13 @@ impl QueryPlan {
397462
398463 fn describe_read_strategy ( strategy : & StoreReadStrategy ) -> String {
399464 match strategy {
400- StoreReadStrategy :: WindowGrid => "WindowGrid" . to_string ( ) ,
465+ StoreReadStrategy :: WindowGrid ( window) => format ! (
466+ "WindowGrid(lookback={}ms, window={}ms, step={}ms, {})" ,
467+ window. lookback_ms,
468+ window. window_size_ms,
469+ window. bucket_step_ms,
470+ Self :: describe_timestamps( & window. output_timestamps) ,
471+ ) ,
401472 StoreReadStrategy :: SlidingExactCover ( window) => format ! (
402473 "SlidingExactCover(lookback={}ms, window={}ms, step={}ms, {})" ,
403474 window. lookback_ms,
@@ -465,6 +536,27 @@ impl RangeEstimateSpec {
465536 ) ,
466537 } )
467538 }
539+
540+ fn validate_for ( & self , statistic : Statistic ) -> Result < ( ) , String > {
541+ let window = & self . values . window ;
542+ if window. window_type != WindowType :: Sliding
543+ || window. bucket_step_ms == 0
544+ || !matches ! ( statistic, Statistic :: Increase | Statistic :: Rate )
545+ {
546+ return Ok ( ( ) ) ;
547+ }
548+ if let Some ( & timestamp) = window
549+ . output_timestamps
550+ . iter ( )
551+ . find ( |& & timestamp| !timestamp. is_multiple_of ( window. bucket_step_ms ) )
552+ {
553+ return Err ( format ! (
554+ "Exact Prometheus counter bounds are unavailable for off-grid Sliding timestamp {} (grid interval {}ms)" ,
555+ timestamp, window. bucket_step_ms
556+ ) ) ;
557+ }
558+ Ok ( ( ) )
559+ }
468560}
469561
470562impl QueryPlanNode {
@@ -546,7 +638,6 @@ mod tests {
546638 } ,
547639 output_timestamps : vec ! [ 1_000 ] ,
548640 query_range_ms : 1_000 ,
549- buckets_per_step : 1 ,
550641 lookback_bucket_count : 1 ,
551642 tumbling_window_ms : 1_000 ,
552643 window_type : WindowType :: Tumbling ,
@@ -588,6 +679,8 @@ mod tests {
588679 assert ! ( explanation. contains( "role=Keys" ) ) ;
589680 assert ! ( explanation
590681 . contains( "n2 StoreRead(SlidingExactCover(lookback=2000ms, window=1000ms, step=500ms" ) ) ;
682+ assert ! ( explanation
683+ . contains( "n0 StoreRead(WindowGrid(lookback=1000ms, window=1000ms, step=1000ms" ) ) ;
591684 assert ! ( explanation. ends_with( "root: n5" ) ) ;
592685 }
593686
@@ -697,6 +790,90 @@ mod tests {
697790 assert_eq ! ( error, "Separate keys query is missing its lookback" ) ;
698791 }
699792
793+ #[ test]
794+ fn rejects_off_grid_sliding_counter_plans ( ) {
795+ let mut context = context ( ) ;
796+ context. window_type = WindowType :: Sliding ;
797+ context. output_timestamps = vec ! [ 1_500 ] ;
798+ context. base . metadata . statistic_to_compute = Statistic :: Rate ;
799+
800+ let error = QueryPlan :: compile_range (
801+ & context,
802+ PlanOptions {
803+ limit_topk : false ,
804+ format_output : false ,
805+ } ,
806+ & [ ] ,
807+ )
808+ . expect_err ( "off-grid Sliding counters must not compile" ) ;
809+
810+ assert ! ( error. contains( "off-grid Sliding timestamp 1500" ) ) ;
811+ }
812+
813+ #[ test]
814+ fn direct_execution_rejects_an_off_grid_sliding_counter_plan ( ) {
815+ let mut context = context ( ) ;
816+ context. window_type = WindowType :: Sliding ;
817+ let mut plan = QueryPlan :: compile_range (
818+ & context,
819+ PlanOptions {
820+ limit_topk : false ,
821+ format_output : false ,
822+ } ,
823+ & [ ] ,
824+ )
825+ . unwrap ( ) ;
826+ let QueryPlanNode :: Estimate {
827+ statistic, spec, ..
828+ } = & mut plan. nodes [ 3 ]
829+ else {
830+ panic ! ( "fourth node must estimate the resolved value read" ) ;
831+ } ;
832+ * statistic = Statistic :: Rate ;
833+ spec. values . window . output_timestamps = Arc :: from ( [ 1_500 ] ) ;
834+
835+ let error = plan
836+ . execute ( & RecordingRuntime ( RefCell :: new ( Vec :: new ( ) ) ) )
837+ . expect_err ( "direct execution must validate its plan" ) ;
838+ assert ! ( matches!(
839+ error,
840+ QueryPlanExecutionError :: InvalidPlan ( message)
841+ if message. contains( "off-grid Sliding timestamp 1500" )
842+ ) ) ;
843+ }
844+
845+ #[ test]
846+ fn rejects_a_read_window_that_does_not_match_its_estimate ( ) {
847+ let mut context = context ( ) ;
848+ context. window_type = WindowType :: Sliding ;
849+ let mut plan = QueryPlan :: compile_range (
850+ & context,
851+ PlanOptions {
852+ limit_topk : false ,
853+ format_output : false ,
854+ } ,
855+ & [ ] ,
856+ )
857+ . unwrap ( ) ;
858+
859+ let QueryPlanNode :: StoreRead { strategy, .. } = & mut plan. nodes [ 0 ] else {
860+ panic ! ( "first node must read values" ) ;
861+ } ;
862+ * strategy = StoreReadStrategy :: WindowGrid ( WindowCompositionSpec {
863+ output_timestamps : Arc :: from ( [ 1_000 ] ) ,
864+ lookback_ms : 1_000 ,
865+ window_type : WindowType :: Tumbling ,
866+ window_size_ms : 1_000 ,
867+ bucket_step_ms : 1_000 ,
868+ } ) ;
869+
870+ assert_eq ! (
871+ plan. validate( )
872+ . expect_err( "mismatched plan must fail validation" ) ,
873+ "Store read n0 window does not match its estimate"
874+ ) ;
875+ }
876+
700877 struct PlanOwnedReadRuntime ( RefCell < Vec < ( StoreReadRole , StoreReadStrategy ) > > ) ;
701878
702879 impl QueryPlanRuntime for PlanOwnedReadRuntime {
@@ -759,7 +936,16 @@ mod tests {
759936 bucket_step_ms: 1_000 ,
760937 } ) ,
761938 ) ,
762- ( StoreReadRole :: Keys , StoreReadStrategy :: WindowGrid ) ,
939+ (
940+ StoreReadRole :: Keys ,
941+ StoreReadStrategy :: WindowGrid ( WindowCompositionSpec {
942+ output_timestamps: Arc :: from( [ 1_000 ] ) ,
943+ lookback_ms: 3_000 ,
944+ window_type: WindowType :: Tumbling ,
945+ window_size_ms: 1_000 ,
946+ bucket_step_ms: 1_000 ,
947+ } ) ,
948+ ) ,
763949 ]
764950 ) ;
765951 }
@@ -857,7 +1043,13 @@ mod tests {
8571043 end_timestamp: 1 ,
8581044 } ,
8591045 role: StoreReadRole :: Values ,
860- strategy: StoreReadStrategy :: WindowGrid ,
1046+ strategy: StoreReadStrategy :: WindowGrid ( WindowCompositionSpec {
1047+ output_timestamps: Arc :: from( [ 1 ] ) ,
1048+ lookback_ms: 1 ,
1049+ window_type: WindowType :: Tumbling ,
1050+ window_size_ms: 1 ,
1051+ bucket_step_ms: 1 ,
1052+ } ) ,
8611053 } ,
8621054 QueryPlanNode :: PrepareBuckets { input: NodeId ( 0 ) } ,
8631055 ] ,
0 commit comments