Skip to content

Commit f1395b3

Browse files
fix(query-engine): reject cross-label-set joins in range binary-expr; fix stale dead-code comment
handle_binary_expr_range_promql's vector-vector join matched purely on positional KeyByLabelValues equality (rhs labels discarded), unlike the instant-query combine_vector_vector fixed earlier in this stack (#572) to reject a join between arms grouped by different label sets. Two arms grouped by disjoint labels (e.g. (host) vs (region)) whose values happened to coincide could silently join into a wrong-but-plausible result across the whole range. Now rejects the join (returns None) when lhs_labels != rhs_labels, mirroring the instant-query guard. Extends create_range_engine_two_metrics (range_query_arithmetic_tests.rs) to take per-metric grouping labels, and adds a regression test proving the divergence. Also corrects execute_logical_plan's doc comment: it claimed to be "part of the still-exercised DataFusion path (see its dedicated tests)", but it has zero callers anywhere in the repo, including tests -- unlike its sibling execute_plan, which genuinely is still called by DataFusion-path tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 14be50f commit f1395b3

3 files changed

Lines changed: 63 additions & 10 deletions

File tree

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -823,10 +823,12 @@ impl SimpleEngine {
823823

824824
/// Executes a pre-built DataFusion logical plan and returns results.
825825
///
826-
/// This was the shared execution kernel for `execute_plan` and the DataFusion-based
827-
/// binary arithmetic dispatch path; the latter was cut over to a native implementation
828-
/// in #567, leaving this unused in production. Kept alongside `execute_plan` as part
829-
/// of the still-exercised DataFusion path (see its dedicated tests).
826+
/// This was the entry point for the DataFusion-based binary arithmetic
827+
/// dispatch path, cut over to a native implementation in #567. Unlike its
828+
/// sibling `execute_plan` (still called by DataFusion-path tests), this
829+
/// function has zero callers anywhere in the repo, including tests — it
830+
/// is genuinely dead code, kept only in case the native cutover needs to
831+
/// be reverted.
830832
#[allow(dead_code)]
831833
pub async fn execute_logical_plan(
832834
&self,

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -651,9 +651,17 @@ impl SimpleEngine {
651651
return Some((KeyByLabelNames::new(labels), QueryResult::matrix(combined)));
652652
}
653653

654-
// Vector-vector: evaluate both arms, join by label key, apply op per matching timestamp
654+
// Vector-vector: evaluate both arms, join by label key, apply op per matching timestamp.
655+
// Reject mismatched label sets up front — same guard as the instant-query
656+
// combine_vector_vector, and for the same reason: positional
657+
// KeyByLabelValues equality below is only safe once the label *names*
658+
// match (they're canonically sorted by KeyByLabelNames::new(), so two
659+
// arms with the same label set always order their values the same way).
655660
let (lhs_ctx, lhs_labels) = self.build_arm_range_context(lhs, start, end, step)?;
656-
let (rhs_ctx, _) = self.build_arm_range_context(rhs, start, end, step)?;
661+
let (rhs_ctx, rhs_labels) = self.build_arm_range_context(rhs, start, end, step)?;
662+
if lhs_labels != rhs_labels {
663+
return None;
664+
}
657665
let lhs_results = self.execute_range_query_pipeline(&lhs_ctx).ok()?;
658666
let rhs_results = self.execute_range_query_pipeline(&rhs_ctx).ok()?;
659667

asap-query-engine/src/tests/datafusion/range_query_arithmetic_tests.rs

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,18 +36,22 @@ mod tests {
3636
/// `engine_factories::create_engine_two_metrics` (single timestamp, instant
3737
/// queries only), this inserts one bucket per `(timestamp, value)` pair so
3838
/// range queries have more than one output sample to join across.
39+
#[allow(clippy::too_many_arguments)]
3940
fn create_range_engine_two_metrics(
4041
metric_a: &str,
42+
labels_a: Vec<&str>,
4143
data_a: TimeSeriesData,
4244
query_a: &str,
4345
metric_b: &str,
46+
labels_b: Vec<&str>,
4447
data_b: TimeSeriesData,
4548
query_b: &str,
4649
) -> SimpleEngine {
47-
let labels = vec!["host".to_string()];
50+
let labels_a: Vec<String> = labels_a.iter().map(|s| s.to_string()).collect();
51+
let labels_b: Vec<String> = labels_b.iter().map(|s| s.to_string()).collect();
4852

4953
let mut aggregation_configs = HashMap::new();
50-
for (id, metric) in [(1u64, metric_a), (2u64, metric_b)] {
54+
for (id, metric, labels) in [(1u64, metric_a, &labels_a), (2u64, metric_b, &labels_b)] {
5155
aggregation_configs.insert(
5256
id,
5357
AggregationConfig {
@@ -91,8 +95,8 @@ mod tests {
9195
}
9296

9397
let promql_schema = PromQLSchema::new()
94-
.add_metric(metric_a.to_string(), KeyByLabelNames::new(labels.clone()))
95-
.add_metric(metric_b.to_string(), KeyByLabelNames::new(labels));
98+
.add_metric(metric_a.to_string(), KeyByLabelNames::new(labels_a))
99+
.add_metric(metric_b.to_string(), KeyByLabelNames::new(labels_b));
96100

97101
let inference_config = InferenceConfig {
98102
schema: SchemaConfig::PromQL(promql_schema),
@@ -143,9 +147,11 @@ mod tests {
143147
let data_requests = host_a_series([(1000, 200.0), (2000, 300.0)]);
144148
let engine = create_range_engine_two_metrics(
145149
"errors_total",
150+
vec!["host"],
146151
data_errors,
147152
"sum(errors_total) by (host)",
148153
"requests_total",
154+
vec!["host"],
149155
data_requests,
150156
"sum(requests_total) by (host)",
151157
);
@@ -174,9 +180,11 @@ mod tests {
174180
let data_b = host_a_series([(1000, 20.0), (2000, 25.0)]);
175181
let engine = create_range_engine_two_metrics(
176182
"metric_a",
183+
vec!["host"],
177184
data_a,
178185
"sum(metric_a) by (host)",
179186
"metric_b",
187+
vec!["host"],
180188
data_b,
181189
"sum(metric_b) by (host)",
182190
);
@@ -199,10 +207,12 @@ mod tests {
199207
let data_a = host_a_series([(1000, 5.0), (2000, 6.0)]);
200208
let engine = create_range_engine_two_metrics(
201209
"metric_a",
210+
vec!["host"],
202211
data_a,
203212
"sum(metric_a) by (host)",
204213
// second metric not used but the helper requires it; empty data.
205214
"dummy",
215+
vec!["host"],
206216
vec![],
207217
"sum(dummy) by (host)",
208218
);
@@ -225,9 +235,11 @@ mod tests {
225235
let data_a = host_a_series([(1000, 0.9), (2000, 0.75)]);
226236
let engine = create_range_engine_two_metrics(
227237
"metric_a",
238+
vec!["host"],
228239
data_a,
229240
"sum(metric_a) by (host)",
230241
"dummy",
242+
vec!["host"],
231243
vec![],
232244
"sum(dummy) by (host)",
233245
);
@@ -243,4 +255,35 @@ mod tests {
243255
assert!((by_ts[&1000] - 0.1).abs() < 1e-10);
244256
assert!((by_ts[&2000] - 0.25).abs() < 1e-10);
245257
}
258+
259+
// Regression test: handle_binary_expr_range_promql's vector-vector join
260+
// used to match purely on positional KeyByLabelValues equality (rhs
261+
// labels discarded), unlike the instant-query combine_vector_vector,
262+
// which rejects a join between arms grouped by different label sets. Two
263+
// arms grouped by disjoint labels ((host) vs (region)) that happen to
264+
// produce the same value could silently join into a wrong-but-plausible
265+
// result across the whole range.
266+
#[tokio::test(flavor = "multi_thread")]
267+
async fn test_range_vector_vector_mismatched_label_sets_return_none() {
268+
let data_a = host_a_series([(1000, 10.0), (2000, 15.0)]);
269+
let data_b = host_a_series([(1000, 10.0), (2000, 15.0)]);
270+
let engine = create_range_engine_two_metrics(
271+
"metric_a",
272+
vec!["host"],
273+
data_a,
274+
"sum(metric_a) by (host)",
275+
"metric_b",
276+
vec!["region"],
277+
data_b,
278+
"sum(metric_b) by (region)",
279+
);
280+
281+
let query = "sum(metric_a) by (host) + sum(metric_b) by (region)";
282+
let result = engine.handle_range_query_promql(query.to_string(), 1.0, 2.0, 1.0);
283+
assert!(
284+
result.is_none(),
285+
"BUG: arms grouped by different label sets must not join, even when their \
286+
values coincide, got {result:?}"
287+
);
288+
}
246289
}

0 commit comments

Comments
 (0)