diff --git a/src/query/src/promql/planner.rs b/src/query/src/promql/planner.rs index cbc6ddb8277..8984ce9c205 100644 --- a/src/query/src/promql/planner.rs +++ b/src/query/src/promql/planner.rs @@ -1522,6 +1522,25 @@ impl PromPlanner { &right_field_columns, ); + // `+`/`-` over two alternative float/histogram operands records its discarded + // mixed pairs from the float lane's `coalesce` fallbacks instead of a standalone + // annotation filter. `align_binary_field_columns` emits that lane first, named + // after the left operand's float lane. + let arithmetic_float_lane_output = if matches!(op.id(), token::T_ADD | token::T_SUB) + && Self::field_columns_are_alternative_samples( + left_input.schema(), + &left_field_columns, + ) + && Self::field_columns_are_alternative_samples( + right_input.schema(), + &right_field_columns, + ) { + Self::alternative_sample_columns(left_input.schema(), &left_field_columns) + .map(|(float, _)| float.to_string()) + } else { + None + }; + // normal join if left_table_ref == right_table_ref { // rename table references to avoid ambiguity @@ -1556,7 +1575,7 @@ impl PromPlanner { // Computed scalars reach this join path instead of the literal projection paths. // Broadcast them for arithmetic in the same way as literal scalars. let broadcast_scalar = !is_comparison_op; - let (field_groups, invalid_field_pairs) = Self::align_binary_field_columns( + let (mut field_groups, mut invalid_field_pairs) = Self::align_binary_field_columns( left_input.schema(), right_input.schema(), &left_field_columns, @@ -1587,6 +1606,20 @@ impl PromPlanner { .iter() .map(|(output, _)| output.clone()) .collect(); + // `+`/`-` over two alternative float/histogram operands records the discarded + // mixed pairs from the float lane: the valid float expression stays first and + // each fallback records its pair while returning NULL. A standalone always-true + // annotation filter cannot be used for them, because the outer non-null filter + // can be pushed into the same physical filter and short-circuit those UDFs + // before they run. + if self.promql_annotations.is_some() + && let Some(float_output) = arithmetic_float_lane_output.as_deref() + && let Some((_, float_pairs)) = field_groups + .iter_mut() + .find(|(output, _)| output.as_str() == float_output) + { + float_pairs.extend(std::mem::take(&mut invalid_field_pairs)); + } let mut field_groups = field_groups.into_iter(); // `vector()` uses EmptyMetric and keeps GreptimeDB's timestamp broadcast. let has_empty_metric_operand = left_is_empty_metric || right_is_empty_metric; @@ -1651,7 +1684,9 @@ impl PromPlanner { } let promql_annotations = self.promql_annotations.clone(); // These predicates always pass; they only evaluate otherwise-discarded pairs - // while collecting annotations. + // while collecting annotations. `+`/`-` over two alternative float/histogram + // operands already consumed its mixed pairs into the float lane above, where + // the physical plan cannot short-circuit them away. let invalid_pair_predicates = invalid_field_pairs .into_iter() .filter(|_| promql_annotations.is_some()) diff --git a/src/query/src/promql/planner/test.rs b/src/query/src/promql/planner/test.rs index f40ac25c86a..4f845450d6d 100644 --- a/src/query/src/promql/planner/test.rs +++ b/src/query/src/promql/planner/test.rs @@ -8254,8 +8254,18 @@ async fn test_mixed_binary_operator_aligns_both_alternative_inputs() { #[tokio::test] async fn test_mixed_binary_operator_reports_only_dropped_samples() { for (query, expected_rows, expected_infos) in [ + // `+`/`-` report the discarded mixed pairs from the float lane's coalesce fallbacks. ("(lf or on(tag) lh) + on(tag) (rf or on(tag) rh)", 0, 1), + ("(lf or on(tag) lh) - on(tag) (rf or on(tag) rh)", 0, 1), + // Compatible float/float and histogram/histogram pairs must not invent an annotation. ("(lf or on(tag) lh) + on(tag) (lf or on(tag) lh)", 2, 0), + ("(lf or on(tag) lh) - on(tag) (lf or on(tag) lh)", 2, 0), + // An unmatched operand has no dropped pair to report. + ( + "(lf or on(tag) lh) + on(tag) (fallback or on(tag) bad_native)", + 0, + 0, + ), ("(lf or on(tag) lh) % on(tag) lh", 0, 1), ] { let state = build_query_engine_state();