Skip to content

Commit 3539309

Browse files
fix(query-engine): validate native range plan invariants
1 parent b53ad94 commit 3539309

4 files changed

Lines changed: 235 additions & 119 deletions

File tree

‎asap-query-engine/src/engines/query_plan.rs‎

Lines changed: 216 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@ pub(crate) struct NodeId(usize);
1414

1515
#[derive(Debug, Clone, PartialEq, Eq)]
1616
pub(crate) enum StoreReadStrategy {
17-
WindowGrid,
17+
WindowGrid(WindowCompositionSpec),
1818
SlidingExactCover(WindowCompositionSpec),
1919
}
2020

2121
impl 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

470562
impl 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

Comments
 (0)