Files
greptimedb/src/query/src/promql/planner/test.rs
T
discord9 28e415a103 feat(query): implement PromQL @ modifier on vector and matrix selectors (#9224)
* feat(query): implement PromQL @ modifier on vector and matrix selectors

The planner previously dropped the `at` field of selectors (`at: _`),
so `some_metric @ 300` silently returned step-following values instead
of the samples anchored at the fixed timestamp.

Anchoring follows Prometheus's `setOffsetForAtModifier` + `refetch`
semantics: resolve the anchor (`@ <ts>`, `@ start()`, `@ end()`), apply
`offset` to the anchor, rewrite the selector offset to
`eval_start - anchor`, scan only the anchored window, then report the
same window at every evaluation step via a grid-wide-replay
`InstantManipulate`. `@ start()` / `@ end()` resolve against the
statement's evaluation range (`stmt_start` / `stmt_end`), which subquery
planning does not rewrite.

Timestamps before the Unix epoch are accepted; unrepresentable ones are
rejected with `AtModifierTimestampOutOfRange` instead of wrapping.
Selectors without `@` are planned exactly as before.

Report: .e-agent/greptimedb_promql_compatibility_report_2026-09-16.md P0-2
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(promql): center predict_linear on the evaluation timestamp

predict_linear_impl used the window's last sample time as the evaluation
timestamp, and the UDF took only (ts_range, value_range, t) with no channel
for the current instant. After a range selector is folded for @ (or with an
offset) the same window is replayed at every step, so the prediction stayed
constant at the anchor's answer; even a plain window ended before the step
produced a stale value.

Give prom_predict_linear a 4th argument carrying the step's evaluation
instant (ms timestamp), derived in the planner from the row's time index
plus the offset the window was folded with (at_offset for @, offset_ms
otherwise, recorded on PromPlannerContext). The regression is centered on
that instant, matching Prometheus' use of enh.Ts. The mixed
float/native-histogram path forwards the same argument.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* refactor(query): tighten @ modifier planner and predict_linear

- range_fold_offset is always a concrete millisecond offset, not an
  Option; the single-step replay helper no longer wraps an infallible
  plan in Result.
- predict_linear's eval timestamp is always cast to Timestamp(ms) at the
  call site, so the UDF drops its dead Int64 branch and the extra
  func_name argument.
- Drop two redundant comparison cases from the at_modifier sqlness test
  and fix the lookback window comment to the half-open (0s, 300s].

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(query): accept parse-time rejection of @ on Windows

`@ 1e16` is 10^19 milliseconds, beyond i64::MAX. A Unix SystemTime
holds it and the planner rejects the anchor it cannot represent, but a
Windows SystemTime tops out near 1.8e12 seconds, so the parser's
checked_add fails first and the same literal is rejected while parsing.
Accept either rejection path so the test passes on both platforms.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(query): avoid replaying label_join over rewritten series keys

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(query): narrow anchored range call promotion

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(query): address @ modifier review feedback

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(query): satisfy super import format check

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* docs(query): address at modifier review follow-ups

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

---------

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-09-28 09:38:12 +00:00

8251 lines
315 KiB
Rust

// Copyright 2023 Greptime Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::collections::HashMap;
use std::time::{Duration, UNIX_EPOCH};
use catalog::RegisterTableRequest;
use catalog::memory::{MemoryCatalogManager, new_memory_catalog_manager};
use common_base::Plugins;
use common_catalog::consts::{DEFAULT_CATALOG_NAME, DEFAULT_SCHEMA_NAME};
use common_query::native_histogram::{
CUSTOM_BUCKETS_SCHEMA, CounterResetHint, NativeHistogram, build_histogram_array,
};
use common_query::prelude::{greptime_native_histogram, greptime_timestamp, greptime_value};
use common_query::prometheus::PROMETHEUS_STALE_NAN_BITS;
use common_query::test_util::DummyDecoder;
use common_recordbatch::RecordBatch as GreptimeRecordBatch;
use datafusion::arrow::array::{
Array, ArrayRef, Float64Array, Int64Array, StringArray, TimestampMillisecondArray,
};
use datafusion::arrow::datatypes::{Field, Schema as ArrowSchema};
use datafusion::arrow::record_batch::RecordBatch;
use datafusion::catalog::{CatalogProvider, MemoryCatalogProvider, MemorySchemaProvider};
use datafusion::datasource::memory::MemorySourceConfig;
use datafusion::datasource::source::DataSourceExec;
use datafusion::datasource::{MemTable, provider_as_source};
use datafusion::execution::context::SessionContext;
use datafusion::logical_expr::Extension;
use datatypes::prelude::ConcreteDataType;
use datatypes::schema::{ColumnSchema, Schema};
use promql::extension_plan::HistogramFold;
use promql_parser::label::Labels;
use promql_parser::parser;
use session::context::QueryContext;
use substrait::{DFLogicalSubstraitConvertor, SubstraitPlan};
use table::Table;
use table::metadata::{FilterPushDownType, TableInfoBuilder, TableMetaBuilder};
use table::test_util::{EmptyTable, MemTable as GreptimeMemTable};
use super::*;
use crate::QueryEngineContext;
use crate::options::QueryOptions;
use crate::parser::QueryLanguageParser;
use crate::query_engine::DefaultSerializer;
mod delta;
fn find_instant_manipulate(plan: &LogicalPlan) -> Option<&InstantManipulate> {
if let LogicalPlan::Extension(Extension { node }) = plan
&& let Some(instant_manipulate) = node.as_any().downcast_ref::<InstantManipulate>()
{
return Some(instant_manipulate);
}
plan.inputs().into_iter().find_map(find_instant_manipulate)
}
fn build_query_engine_state() -> QueryEngineState {
QueryEngineState::new(
new_memory_catalog_manager().unwrap(),
None,
None,
None,
None,
None,
false,
Plugins::default(),
QueryOptions::default(),
)
}
#[test]
fn common_label_type_preserves_only_shared_dictionary_encoding() {
let dictionary = ArrowDataType::Dictionary(
Box::new(ArrowDataType::UInt32),
Box::new(ArrowDataType::Utf8),
);
let other_dictionary = ArrowDataType::Dictionary(
Box::new(ArrowDataType::Int32),
Box::new(ArrowDataType::Utf8),
);
assert_eq!(
Some(dictionary.clone()),
PromPlanner::common_label_data_type(Some(&dictionary), Some(&dictionary))
);
assert_eq!(
Some(ArrowDataType::Utf8),
PromPlanner::common_label_data_type(Some(&dictionary), Some(&ArrowDataType::Utf8))
);
assert_eq!(
Some(ArrowDataType::Utf8),
PromPlanner::common_label_data_type(Some(&dictionary), Some(&other_dictionary))
);
assert_eq!(
Some(ArrowDataType::Utf8),
PromPlanner::common_label_data_type(Some(&dictionary), None)
);
}
async fn build_optimized_promql_plan(
table_provider: DfTableSourceProvider,
eval_stmt: &EvalStmt,
) -> LogicalPlan {
let state = build_query_engine_state();
let raw_plan = PromPlanner::stmt_to_plan(table_provider, eval_stmt, &state)
.await
.unwrap();
let context = QueryEngineContext::new(state.session_state(), QueryContext::arc());
state
.optimize_by_extension_rules(raw_plan, &context)
.unwrap()
}
async fn build_optimized_tsid_plan(
query: &str,
num_tag: usize,
num_field: usize,
end_secs: u64,
lookback_secs: u64,
) -> String {
let eval_stmt = EvalStmt {
expr: parser::parse(query).unwrap(),
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(end_secs))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(lookback_secs),
};
let table_provider = build_test_table_provider_with_tsid(
&[(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string())],
num_tag,
num_field,
)
.await;
build_optimized_promql_plan(table_provider, &eval_stmt)
.await
.display_indent_schema()
.to_string()
}
async fn assert_nested_count_rewrite_applies(query: &str, expected_outer_agg: &str) {
let plan_str = build_optimized_tsid_plan(query, 2, 1, 100_000, 1).await;
assert!(plan_str.contains("PromSeriesDivide: tags=[\"__tsid\"]"));
assert!(plan_str.contains("Projection: some_metric.timestamp, some_metric.tag_0"));
assert!(plan_str.contains("Distinct:"));
assert!(plan_str.contains(expected_outer_agg), "{plan_str}");
assert!(!plan_str.contains("PromSeriesDivide: tags=[\"tag_0\"]"));
}
async fn assert_nested_count_rewrite_missing(query: &str, num_tag: usize, lookback_secs: u64) {
let plan_str = build_optimized_tsid_plan(query, num_tag, 1, 100_000, lookback_secs).await;
assert!(!plan_str.contains("Distinct:"), "{plan_str}");
}
fn build_eval_stmt(expr: &str) -> EvalStmt {
EvalStmt {
expr: parser::parse(expr).unwrap(),
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
}
}
enum DirectOrValue {
Float64(f64),
Int64(i64),
NativeHistogram(NativeHistogram),
Utf8(&'static str),
}
impl DirectOrValue {
fn data_type(&self) -> ArrowDataType {
match self {
Self::Float64(_) => ArrowDataType::Float64,
Self::Int64(_) => ArrowDataType::Int64,
Self::NativeHistogram(_) => native_histogram_value_type().as_arrow_type(),
Self::Utf8(_) => ArrowDataType::Utf8,
}
}
fn array(&self) -> Arc<dyn Array> {
match self {
Self::Float64(v) => Arc::new(Float64Array::from(vec![*v])),
Self::Int64(v) => Arc::new(Int64Array::from(vec![*v])),
Self::NativeHistogram(v) => build_histogram_array(&[Some(v.clone())]),
Self::Utf8(v) => Arc::new(StringArray::from(vec![*v])),
}
}
}
fn direct_or_histogram() -> NativeHistogram {
NativeHistogram {
schema: 0,
zero_threshold: 0.0,
sum: 1.0,
reset_hint: CounterResetHint::Unknown,
start_timestamp: None,
custom_values: vec![],
positive_spans: vec![],
negative_spans: vec![],
count: 1.0,
zero_count: 1.0,
positive_buckets: vec![],
negative_buckets: vec![],
}
}
fn operator_metric_table(
name: &str,
table_id: u32,
tag: &str,
le: Option<&str>,
value: DirectOrValue,
) -> table::TableRef {
let value_type = match &value {
DirectOrValue::Float64(_) => ConcreteDataType::float64_datatype(),
DirectOrValue::Int64(_) => ConcreteDataType::int64_datatype(),
DirectOrValue::NativeHistogram(_) => native_histogram_value_type().clone(),
DirectOrValue::Utf8(_) => ConcreteDataType::string_datatype(),
};
let tag_count = 1 + usize::from(le.is_some());
let mut columns = vec![ColumnSchema::new(
"tag".to_string(),
ConcreteDataType::string_datatype(),
false,
)];
if le.is_some() {
columns.push(ColumnSchema::new(
LE_COLUMN_NAME.to_string(),
ConcreteDataType::string_datatype(),
false,
));
}
columns.extend([
ColumnSchema::new(
"ts".to_string(),
ConcreteDataType::timestamp_millisecond_datatype(),
false,
)
.with_time_index(true),
ColumnSchema::new("v".to_string(), value_type, true),
]);
let schema = Arc::new(Schema::new(columns));
let mut arrays = vec![Arc::new(StringArray::from(vec![tag])) as Arc<dyn Array>];
if let Some(le) = le {
arrays.push(Arc::new(StringArray::from(vec![le])));
}
arrays.extend([
Arc::new(TimestampMillisecondArray::from(vec![1_000])) as Arc<dyn Array>,
value.array(),
]);
let batch = RecordBatch::try_new(schema.arrow_schema().clone(), arrays).unwrap();
let backing = GreptimeMemTable::new_with_catalog(
name,
GreptimeRecordBatch::from_df_record_batch(schema.clone(), batch),
table_id,
DEFAULT_CATALOG_NAME.to_string(),
DEFAULT_SCHEMA_NAME.to_string(),
);
let value_index = tag_count + 1;
let meta = TableMetaBuilder::empty()
.schema(schema)
.primary_key_indices((0..tag_count).collect())
.value_indices(vec![value_index])
.next_column_id((value_index + 1) as u32)
.build()
.unwrap();
let info = Arc::new(
TableInfoBuilder::default()
.table_id(table_id)
.name(name)
.meta(meta)
.build()
.unwrap(),
);
Arc::new(Table::new(
info,
FilterPushDownType::Unsupported,
backing.data_source(),
))
}
fn operator_table_provider() -> DfTableSourceProvider {
let catalog = MemoryCatalogManager::with_default_setup();
let tables = [
operator_metric_table("lf", 2_001, "a", None, DirectOrValue::Float64(2.0)),
operator_metric_table(
"lh",
2_002,
"b",
None,
DirectOrValue::NativeHistogram(direct_or_histogram()),
),
operator_metric_table("rf", 2_003, "b", None, DirectOrValue::Float64(3.0)),
operator_metric_table(
"rh",
2_004,
"a",
None,
DirectOrValue::NativeHistogram(direct_or_histogram()),
),
operator_metric_table("fallback", 2_005, "c", None, DirectOrValue::Float64(7.0)),
operator_metric_table(
"bad_classic",
2_006,
"d",
Some("broken"),
DirectOrValue::Float64(1.0),
),
operator_metric_table(
"bad_native",
2_007,
"d",
None,
DirectOrValue::NativeHistogram(direct_or_histogram()),
),
];
for table in tables {
let info = table.table_info();
catalog
.register_table_sync(RegisterTableRequest {
catalog: DEFAULT_CATALOG_NAME.to_string(),
schema: DEFAULT_SCHEMA_NAME.to_string(),
table_name: info.name.clone(),
table_id: info.ident.table_id,
table,
})
.unwrap();
}
DfTableSourceProvider::new(
catalog,
false,
QueryContext::arc(),
DummyDecoder::arc(),
false,
)
}
fn operator_eval_stmt(expr: &str) -> EvalStmt {
let time = UNIX_EPOCH.checked_add(Duration::from_secs(1)).unwrap();
EvalStmt {
expr: parser::parse(expr).unwrap(),
start: time,
end: time,
interval: Duration::from_secs(1),
lookback_delta: Duration::from_secs(5),
}
}
struct DirectOrSource {
name: &'static str,
empty: bool,
timestamp: i64,
tags: Vec<(&'static str, Option<&'static str>)>,
value: DirectOrValue,
}
fn source(
name: &'static str,
empty: bool,
timestamp: i64,
tags: Vec<(&'static str, Option<&'static str>)>,
value: DirectOrValue,
) -> DirectOrSource {
DirectOrSource {
name,
empty,
timestamp,
tags,
value,
}
}
fn tagged_source(
name: &'static str,
empty: bool,
tag: (&'static str, Option<&'static str>),
value: DirectOrValue,
) -> DirectOrSource {
source(name, empty, 1, vec![("job", Some("job")), tag], value)
}
fn job_source(name: &'static str, value: DirectOrValue) -> DirectOrSource {
source(name, true, 1, vec![("job", Some("job"))], value)
}
fn table(source: &DirectOrSource) -> Arc<MemTable> {
let mut fields = vec![Field::new(
"ts",
ArrowDataType::Timestamp(ArrowTimeUnit::Millisecond, None),
false,
)];
fields.extend(
source
.tags
.iter()
.map(|(name, _)| Field::new(*name, ArrowDataType::Utf8, true)),
);
fields.push(Field::new("v", source.value.data_type(), true));
let schema = Arc::new(ArrowSchema::new(fields));
let partitions = if source.empty {
vec![vec![]]
} else {
let mut columns: Vec<Arc<dyn Array>> =
vec![Arc::new(TimestampMillisecondArray::from(vec![
source.timestamp,
]))];
columns.extend(
source
.tags
.iter()
.map(|(_, value)| Arc::new(StringArray::from(vec![*value])) as Arc<dyn Array>),
);
columns.push(source.value.array());
vec![vec![RecordBatch::try_new(schema.clone(), columns).unwrap()]]
};
Arc::new(MemTable::try_new(schema, partitions).unwrap())
}
fn scan(source: &DirectOrSource) -> LogicalPlan {
LogicalPlanBuilder::scan(source.name, provider_as_source(table(source)), None)
.unwrap()
.build()
.unwrap()
}
fn direct_or_context(qualifier: &str, tags: &[&str], field: &str) -> PromPlannerContext {
PromPlannerContext {
table_name: Some(qualifier.to_string()),
time_index_column: Some("ts".to_string()),
field_columns: vec![field.to_string()],
tag_columns: tags.iter().map(|tag| (*tag).to_string()).collect(),
..Default::default()
}
}
fn or_modifier(expr: &str) -> Option<BinModifier> {
let PromExpr::Binary(expr) = parser::parse(expr).unwrap() else {
unreachable!()
};
expr.modifier
}
async fn plan_direct_or(
left: LogicalPlan,
right: LogicalPlan,
left_context: PromPlannerContext,
right_context: PromPlannerContext,
modifier: &Option<BinModifier>,
) -> LogicalPlan {
let table_provider = build_test_table_provider_with_fields(
&[(DEFAULT_SCHEMA_NAME.to_string(), "dummy".to_string())],
&[],
)
.await;
let mut planner = PromPlanner {
table_provider,
ctx: PromPlannerContext::default(),
promql_annotations: None,
};
planner
.or_operator(
left,
right,
left_context.tag_columns.iter().cloned().collect(),
right_context.tag_columns.iter().cloned().collect(),
left_context,
right_context,
modifier,
)
.unwrap()
}
async fn execute(plan: LogicalPlan, state: &QueryEngineState) -> (LogicalPlan, Vec<RecordBatch>) {
let context = QueryEngineContext::new(state.session_state(), QueryContext::arc());
let optimized = state.optimize_by_extension_rules(plan, &context).unwrap();
let physical = state
.session_state()
.create_physical_plan(&optimized)
.await
.unwrap();
let batches = datafusion::physical_plan::collect(physical, state.session_state().task_ctx())
.await
.unwrap();
(optimized, batches)
}
async fn run(
left: &DirectOrSource,
right: &DirectOrSource,
left_context: PromPlannerContext,
right_context: PromPlannerContext,
modifier: &Option<BinModifier>,
) -> (LogicalPlan, Vec<RecordBatch>) {
let plan = plan_direct_or(
scan(left),
scan(right),
left_context,
right_context,
modifier,
)
.await;
execute(plan, &build_query_engine_state()).await
}
async fn mixed_direct_or(histogram_on_left: bool) -> (PromPlanner, LogicalPlan) {
let sample = |histogram: bool| {
if histogram {
DirectOrValue::NativeHistogram(direct_or_histogram())
} else {
DirectOrValue::Float64(1.25)
}
};
let left = tagged_source(
"lhs",
false,
(
"k",
Some(if histogram_on_left {
"histogram"
} else {
"float"
}),
),
sample(histogram_on_left),
);
let right = tagged_source(
"rhs",
false,
(
"k",
Some(if histogram_on_left {
"float"
} else {
"histogram"
}),
),
sample(!histogram_on_left),
);
let table_provider = build_test_table_provider_with_fields(
&[(DEFAULT_SCHEMA_NAME.to_string(), "dummy".to_string())],
&[],
)
.await;
let mut planner = PromPlanner {
table_provider,
ctx: PromPlannerContext::default(),
promql_annotations: None,
};
let left_context = direct_or_context("lhs", &["job", "k"], "v");
let right_context = direct_or_context("rhs", &["job", "k"], "v");
let plan = planner
.or_operator(
scan(&left),
scan(&right),
left_context.tag_columns.iter().cloned().collect(),
right_context.tag_columns.iter().cloned().collect(),
left_context,
right_context,
&or_modifier("lhs or on(k) rhs"),
)
.unwrap();
(planner, plan)
}
async fn mixed_aggregate_input(histograms: Vec<NativeHistogram>) -> (PromPlanner, LogicalPlan) {
let float_field = format!("{OR_FLOAT_FIELD_PREFIX}0");
let histogram_field = format!("{OR_HISTOGRAM_FIELD_PREFIX}0");
let row_count = histograms.len() + 1;
let schema = Arc::new(ArrowSchema::new(vec![
Field::new(
"ts",
ArrowDataType::Timestamp(ArrowTimeUnit::Millisecond, None),
false,
),
Field::new("k", ArrowDataType::Utf8, false),
Field::new(&float_field, ArrowDataType::Float64, true),
Field::new(
&histogram_field,
native_histogram_value_type().as_arrow_type(),
true,
),
]));
let mut histogram_values = Vec::with_capacity(row_count);
histogram_values.push(None);
histogram_values.extend(histograms.into_iter().map(Some));
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(TimestampMillisecondArray::from(vec![1; row_count])),
Arc::new(StringArray::from_iter_values(
(0..row_count).map(|row| format!("kind_{row}")),
)),
Arc::new(Float64Array::from_iter(
(0..row_count).map(|row| (row == 0).then_some(1.25)),
)),
build_histogram_array(&histogram_values),
],
)
.unwrap();
let table = Arc::new(MemTable::try_new(schema, vec![vec![batch]]).unwrap());
let plan = LogicalPlanBuilder::scan("mixed", provider_as_source(table), None)
.unwrap()
.build()
.unwrap();
let table_provider = build_test_table_provider_with_fields(
&[(DEFAULT_SCHEMA_NAME.to_string(), "dummy".to_string())],
&[],
)
.await;
let planner = PromPlanner {
table_provider,
ctx: PromPlannerContext {
table_name: Some("mixed".to_string()),
time_index_column: Some("ts".to_string()),
field_columns: vec![float_field, histogram_field],
tag_columns: vec!["k".to_string()],
..Default::default()
},
promql_annotations: None,
};
(planner, plan)
}
fn assert_no_internal_or_keys(schema: &DFSchema) {
assert!(
schema
.fields()
.iter()
.all(|field| !field.name().starts_with("__promql_or_match_")),
"{schema:?}"
);
}
fn values(batches: &[RecordBatch], column: &str) -> Vec<f64> {
batches
.iter()
.flat_map(|batch| {
batch
.column_by_name(column)
.unwrap()
.as_any()
.downcast_ref::<Float64Array>()
.unwrap()
.iter()
.flatten()
})
.collect()
}
fn numeric_values(batches: &[RecordBatch], column: &str) -> Vec<f64> {
batches
.iter()
.flat_map(|batch| {
let values = datafusion::arrow::compute::cast(
batch.column_by_name(column).unwrap(),
&ArrowDataType::Float64,
)
.unwrap();
values
.as_any()
.downcast_ref::<Float64Array>()
.unwrap()
.iter()
.flatten()
.collect::<Vec<_>>()
})
.collect()
}
fn histograms(batches: &[RecordBatch], column: &str) -> Vec<NativeHistogram> {
batches
.iter()
.flat_map(|batch| {
let values = batch
.column_by_name(column)
.unwrap()
.as_any()
.downcast_ref::<datafusion::arrow::array::StructArray>()
.unwrap();
(0..values.len()).filter_map(|row| {
common_query::native_histogram::read_histogram(values, row).unwrap()
})
})
.collect()
}
fn rows(batches: &[RecordBatch]) -> Vec<(f64, Option<String>)> {
let mut rows = batches
.iter()
.flat_map(|batch| {
let values = batch
.column_by_name("v")
.unwrap()
.as_any()
.downcast_ref::<Float64Array>()
.unwrap();
let labels = batch
.column_by_name("k")
.map(|column| column.as_any().downcast_ref::<StringArray>().unwrap());
(0..batch.num_rows()).map(move |i| {
(
values.value(i),
labels.and_then(|labels| {
(!labels.is_null(i)).then(|| labels.value(i).to_string())
}),
)
})
})
.collect::<Vec<_>>();
rows.sort_by(|left, right| left.0.total_cmp(&right.0));
rows
}
fn matrix_source(
name: &'static str,
k: Option<Option<&'static str>>,
timestamp: i64,
value: f64,
) -> DirectOrSource {
let mut tags = vec![("job", Some("job"))];
if let Some(k) = k {
tags.push(("k", k));
}
source(name, false, timestamp, tags, DirectOrValue::Float64(value))
}
fn matrix_context(name: &str, k: Option<Option<&str>>) -> PromPlannerContext {
direct_or_context(
name,
if k.is_some() { &["job", "k"] } else { &["job"] },
"v",
)
}
async fn build_missing_le_or_normal_metric_table_provider() -> DfTableSourceProvider {
build_test_table_provider_with_fields(
&[
(
DEFAULT_SCHEMA_NAME.to_string(),
"non_existent_histogram_bucket".to_string(),
),
(DEFAULT_SCHEMA_NAME.to_string(), "normal_metric".to_string()),
],
&["pod", "instance"],
)
.await
}
fn assert_normal_metric_schema(plan: &LogicalPlan) {
let fields = plan.schema().fields();
assert_eq!(fields.len(), 4, "{fields:?}");
assert!(
fields.iter().any(|field| field.name() == "pod"),
"{fields:?}"
);
assert!(
fields.iter().any(|field| field.name() == "instance"),
"{fields:?}"
);
assert!(
fields
.iter()
.any(|field| field.name() == greptime_timestamp()),
"{fields:?}"
);
assert!(
fields.iter().any(|field| {
field.name() == greptime_value() && field.data_type() == &ArrowDataType::Float64
}),
"{fields:?}"
);
}
async fn build_test_table_provider_with_distinct_tags(
table_tags: &[(&str, &[&str])],
) -> DfTableSourceProvider {
let catalog_list = MemoryCatalogManager::with_default_setup();
for (table_name, tags) in table_tags {
let mut columns = tags
.iter()
.map(|tag| {
ColumnSchema::new(
(*tag).to_string(),
ConcreteDataType::string_datatype(),
false,
)
})
.collect::<Vec<_>>();
columns.push(
ColumnSchema::new(
greptime_timestamp().to_string(),
ConcreteDataType::timestamp_millisecond_datatype(),
false,
)
.with_time_index(true),
);
columns.push(ColumnSchema::new(
greptime_value().to_string(),
ConcreteDataType::float64_datatype(),
true,
));
let table_meta = TableMetaBuilder::empty()
.schema(Arc::new(Schema::new(columns)))
.primary_key_indices((0..tags.len()).collect())
.next_column_id(1024)
.build()
.unwrap();
let table_info = TableInfoBuilder::default()
.name((*table_name).to_string())
.meta(table_meta)
.build()
.unwrap();
assert!(
catalog_list
.register_table_sync(RegisterTableRequest {
catalog: DEFAULT_CATALOG_NAME.to_string(),
schema: DEFAULT_SCHEMA_NAME.to_string(),
table_name: (*table_name).to_string(),
table_id: 1024,
table: EmptyTable::from_table_info(&table_info),
})
.is_ok()
);
}
DfTableSourceProvider::new(
catalog_list,
false,
QueryContext::arc(),
DummyDecoder::arc(),
false,
)
}
fn contains_histogram_fold(plan: &LogicalPlan) -> bool {
matches!(plan, LogicalPlan::Extension(Extension { node }) if node.as_any().is::<HistogramFold>())
|| plan.inputs().into_iter().any(contains_histogram_fold)
}
async fn build_set_op_context_table_provider() -> DfTableSourceProvider {
build_test_table_provider_with_distinct_tags(&[
("bucket_metric", &["job", "le"]),
("normal_metric", &["job"]),
("fallback_metric", &["instance"]),
])
.await
}
async fn build_or_context_table_provider() -> DfTableSourceProvider {
build_test_table_provider_with_distinct_tags(&[
("normal_metric", &["job"]),
("other_metric", &["instance"]),
("non_hist_metric", &["instance"]),
])
.await
}
async fn optimize_and_create_physical_plan(
state: &QueryEngineState,
plan: LogicalPlan,
) -> (
LogicalPlan,
Arc<dyn datafusion::physical_plan::ExecutionPlan>,
) {
let context = QueryEngineContext::new(state.session_state(), QueryContext::arc());
let optimized = state.optimize_by_extension_rules(plan, &context).unwrap();
let physical = state
.session_state()
.create_physical_plan(&optimized)
.await
.unwrap();
(optimized, physical)
}
async fn build_test_table_provider(
table_name_tuples: &[(String, String)],
num_tag: usize,
num_field: usize,
) -> DfTableSourceProvider {
let catalog_list = MemoryCatalogManager::with_default_setup();
for (schema_name, table_name) in table_name_tuples {
let mut columns = vec![];
for i in 0..num_tag {
columns.push(ColumnSchema::new(
format!("tag_{i}"),
ConcreteDataType::string_datatype(),
false,
));
}
columns.push(
ColumnSchema::new(
"timestamp".to_string(),
ConcreteDataType::timestamp_millisecond_datatype(),
false,
)
.with_time_index(true),
);
for i in 0..num_field {
columns.push(ColumnSchema::new(
format!("field_{i}"),
ConcreteDataType::float64_datatype(),
true,
));
}
let schema = Arc::new(Schema::new(columns));
let table_meta = TableMetaBuilder::empty()
.schema(schema)
.primary_key_indices((0..num_tag).collect())
.value_indices((num_tag + 1..num_tag + 1 + num_field).collect())
.next_column_id(1024)
.build()
.unwrap();
let table_info = TableInfoBuilder::default()
.name(table_name.clone())
.meta(table_meta)
.build()
.unwrap();
let table = EmptyTable::from_table_info(&table_info);
assert!(
catalog_list
.register_table_sync(RegisterTableRequest {
catalog: DEFAULT_CATALOG_NAME.to_string(),
schema: schema_name.clone(),
table_name: table_name.clone(),
table_id: 1024,
table,
})
.is_ok()
);
}
DfTableSourceProvider::new(
catalog_list,
false,
QueryContext::arc(),
DummyDecoder::arc(),
false,
)
}
async fn build_test_native_histogram_table_provider(table_name: &str) -> DfTableSourceProvider {
build_test_native_histogram_table_provider_with_marker(table_name, false).await
}
async fn build_test_native_histogram_table_provider_with_marker(
table_name: &str,
temporality_marker: bool,
) -> DfTableSourceProvider {
let catalog_list = MemoryCatalogManager::with_default_setup();
let mut columns = vec![
ColumnSchema::new(
"tag_0".to_string(),
ConcreteDataType::string_datatype(),
false,
),
ColumnSchema::new(
LE_COLUMN_NAME.to_string(),
ConcreteDataType::string_datatype(),
true,
),
];
if temporality_marker {
columns.push(ColumnSchema::new(
OTLP_AGGREGATION_TEMPORALITY_LABEL.to_string(),
ConcreteDataType::string_datatype(),
true,
));
}
let tag_count = columns.len();
columns.extend([
ColumnSchema::new(
"timestamp".to_string(),
ConcreteDataType::timestamp_millisecond_datatype(),
false,
)
.with_time_index(true),
ColumnSchema::new(
greptime_native_histogram().to_string(),
native_histogram_value_type().clone(),
true,
),
]);
let schema = Arc::new(Schema::new(columns));
let table_meta = TableMetaBuilder::empty()
.schema(schema)
.primary_key_indices((0..tag_count).collect())
.value_indices(vec![tag_count + 1])
.next_column_id(1024)
.build()
.unwrap();
let table_info = TableInfoBuilder::default()
.name(table_name)
.meta(table_meta)
.build()
.unwrap();
let table = EmptyTable::from_table_info(&table_info);
assert!(
catalog_list
.register_table_sync(RegisterTableRequest {
catalog: DEFAULT_CATALOG_NAME.to_string(),
schema: DEFAULT_SCHEMA_NAME.to_string(),
table_name: table_name.to_string(),
table_id: 1024,
table,
})
.is_ok()
);
DfTableSourceProvider::new(
catalog_list,
false,
QueryContext::arc(),
DummyDecoder::arc(),
false,
)
}
async fn build_test_multi_histogram_table_provider(table_name: &str) -> DfTableSourceProvider {
let catalog_list = MemoryCatalogManager::with_default_setup();
let columns = vec![
ColumnSchema::new(
"tag_0".to_string(),
ConcreteDataType::string_datatype(),
false,
),
ColumnSchema::new(
"timestamp".to_string(),
ConcreteDataType::timestamp_millisecond_datatype(),
false,
)
.with_time_index(true),
ColumnSchema::new(
greptime_native_histogram().to_string(),
native_histogram_value_type().clone(),
true,
),
ColumnSchema::new(
"native_histogram_2".to_string(),
native_histogram_value_type().clone(),
true,
),
];
let schema = Arc::new(Schema::new(columns));
let table_meta = TableMetaBuilder::empty()
.schema(schema)
.primary_key_indices(vec![0])
.value_indices(vec![2, 3])
.next_column_id(1024)
.build()
.unwrap();
let table_info = TableInfoBuilder::default()
.name(table_name)
.meta(table_meta)
.build()
.unwrap();
let table = EmptyTable::from_table_info(&table_info);
assert!(
catalog_list
.register_table_sync(RegisterTableRequest {
catalog: DEFAULT_CATALOG_NAME.to_string(),
schema: DEFAULT_SCHEMA_NAME.to_string(),
table_name: table_name.to_string(),
table_id: 1024,
table,
})
.is_ok()
);
DfTableSourceProvider::new(
catalog_list,
false,
QueryContext::arc(),
DummyDecoder::arc(),
false,
)
}
async fn build_test_mixed_native_histogram_table_provider(
table_name: &str,
) -> DfTableSourceProvider {
build_test_mixed_native_histogram_table_provider_with_marker(table_name, false).await
}
async fn build_test_mixed_native_histogram_table_provider_with_marker(
table_name: &str,
temporality_marker: bool,
) -> DfTableSourceProvider {
let catalog_list = MemoryCatalogManager::with_default_setup();
let mut columns = vec![ColumnSchema::new(
"tag_0".to_string(),
ConcreteDataType::string_datatype(),
false,
)];
if temporality_marker {
columns.push(ColumnSchema::new(
OTLP_AGGREGATION_TEMPORALITY_LABEL.to_string(),
ConcreteDataType::string_datatype(),
true,
));
}
let tag_count = columns.len();
columns.extend([
ColumnSchema::new(
"timestamp".to_string(),
ConcreteDataType::timestamp_millisecond_datatype(),
false,
)
.with_time_index(true),
ColumnSchema::new(
greptime_native_histogram().to_string(),
native_histogram_value_type().clone(),
true,
),
ColumnSchema::new(
greptime_value().to_string(),
ConcreteDataType::float64_datatype(),
true,
),
]);
let schema = Arc::new(Schema::new(columns));
let table_meta = TableMetaBuilder::empty()
.schema(schema.clone())
.primary_key_indices((0..tag_count).collect())
.value_indices(vec![tag_count + 1, tag_count + 2])
.next_column_id(1024)
.build()
.unwrap();
let table_info = Arc::new(
TableInfoBuilder::default()
.name(table_name)
.meta(table_meta)
.build()
.unwrap(),
);
let mut arrays: Vec<Arc<dyn Array>> =
vec![Arc::new(StringArray::from(vec!["float", "histogram"]))];
if temporality_marker {
arrays.push(Arc::new(StringArray::from(vec![
Some(GREPTIME_TEMPORALITY_DELTA),
Some(GREPTIME_TEMPORALITY_DELTA),
])));
}
arrays.extend([
Arc::new(TimestampMillisecondArray::from(vec![1_000, 1_000])) as Arc<dyn Array>,
build_histogram_array(&[None, Some(direct_or_histogram())]),
Arc::new(Float64Array::from(vec![Some(2.0), None])),
]);
let batch = RecordBatch::try_new(schema.arrow_schema().clone(), arrays).unwrap();
let backing = GreptimeMemTable::new_with_catalog(
table_name,
GreptimeRecordBatch::from_df_record_batch(schema, batch),
1024,
DEFAULT_CATALOG_NAME.to_string(),
DEFAULT_SCHEMA_NAME.to_string(),
);
let table = Arc::new(Table::new(
table_info,
FilterPushDownType::Unsupported,
backing.data_source(),
));
assert!(
catalog_list
.register_table_sync(RegisterTableRequest {
catalog: DEFAULT_CATALOG_NAME.to_string(),
schema: DEFAULT_SCHEMA_NAME.to_string(),
table_name: table_name.to_string(),
table_id: 1024,
table,
})
.is_ok()
);
DfTableSourceProvider::new(
catalog_list,
false,
QueryContext::arc(),
DummyDecoder::arc(),
false,
)
}
fn classic_and_native_histogram_table_provider(
native_tag: &str,
native_le: Option<&str>,
native_histogram: NativeHistogram,
) -> DfTableSourceProvider {
let table_name = "mixed_histogram";
let catalog = MemoryCatalogManager::with_default_setup();
let schema = Arc::new(Schema::new(vec![
ColumnSchema::new(
"tag".to_string(),
ConcreteDataType::string_datatype(),
false,
),
ColumnSchema::new(
LE_COLUMN_NAME.to_string(),
ConcreteDataType::string_datatype(),
true,
),
ColumnSchema::new(
"timestamp".to_string(),
ConcreteDataType::timestamp_millisecond_datatype(),
false,
)
.with_time_index(true),
ColumnSchema::new(
greptime_native_histogram().to_string(),
native_histogram_value_type().clone(),
true,
),
ColumnSchema::new(
greptime_value().to_string(),
ConcreteDataType::float64_datatype(),
true,
),
]));
let table_meta = TableMetaBuilder::empty()
.schema(schema.clone())
.primary_key_indices(vec![0, 1])
.value_indices(vec![3, 4])
.next_column_id(5)
.build()
.unwrap();
let table_info = Arc::new(
TableInfoBuilder::default()
.name(table_name)
.meta(table_meta)
.build()
.unwrap(),
);
let batch = RecordBatch::try_new(
schema.arrow_schema().clone(),
vec![
Arc::new(StringArray::from(vec![
"classic", "classic", native_tag, "classic", "classic", native_tag,
])),
Arc::new(StringArray::from(vec![
Some("1"),
Some("+Inf"),
native_le,
Some("1"),
Some("+Inf"),
native_le,
])),
Arc::new(TimestampMillisecondArray::from(vec![
1_000, 1_000, 1_000, 2_000, 2_000, 2_000,
])),
build_histogram_array(&[
None,
None,
Some(native_histogram.clone()),
None,
None,
Some(native_histogram),
]),
Arc::new(Float64Array::from(vec![
Some(2.0),
Some(4.0),
None,
Some(2.0),
Some(4.0),
None,
])),
],
)
.unwrap();
let backing = GreptimeMemTable::new_with_catalog(
table_name,
GreptimeRecordBatch::from_df_record_batch(schema, batch),
2_200,
DEFAULT_CATALOG_NAME.to_string(),
DEFAULT_SCHEMA_NAME.to_string(),
);
let table = Arc::new(Table::new(
table_info,
FilterPushDownType::Unsupported,
backing.data_source(),
));
catalog
.register_table_sync(RegisterTableRequest {
catalog: DEFAULT_CATALOG_NAME.to_string(),
schema: DEFAULT_SCHEMA_NAME.to_string(),
table_name: table_name.to_string(),
table_id: 2_200,
table,
})
.unwrap();
DfTableSourceProvider::new(
catalog,
false,
QueryContext::arc(),
DummyDecoder::arc(),
false,
)
}
async fn build_test_table_provider_with_tsid(
table_name_tuples: &[(String, String)],
num_tag: usize,
num_field: usize,
) -> DfTableSourceProvider {
let table_specs = table_name_tuples
.iter()
.map(|(schema_name, table_name)| ((schema_name.clone(), table_name.clone()), num_field))
.collect::<Vec<_>>();
build_test_table_provider_with_tsid_fields(&table_specs, num_tag).await
}
async fn build_test_table_provider_with_tsid_fields(
table_specs: &[((String, String), usize)],
num_tag: usize,
) -> DfTableSourceProvider {
let table_specs = table_specs
.iter()
.map(|(table_name_tuple, num_field)| (table_name_tuple.clone(), num_tag, *num_field))
.collect::<Vec<_>>();
build_test_table_provider_with_tsid_tag_fields(&table_specs).await
}
async fn build_test_table_provider_with_tsid_tag_fields(
table_specs: &[((String, String), usize, usize)],
) -> DfTableSourceProvider {
let catalog_list = MemoryCatalogManager::with_default_setup();
let physical_table_name = "phy";
let physical_table_id = 999u32;
let physical_num_tag = table_specs
.iter()
.map(|(_, num_tag, _)| *num_tag)
.max()
.unwrap_or(0);
let physical_num_field = table_specs
.iter()
.map(|(_, _, num_field)| *num_field)
.max()
.unwrap_or(0);
// Register a metric engine physical table with internal columns.
{
let mut columns = vec![
ColumnSchema::new(
DATA_SCHEMA_TABLE_ID_COLUMN_NAME.to_string(),
ConcreteDataType::uint32_datatype(),
false,
),
ColumnSchema::new(
DATA_SCHEMA_TSID_COLUMN_NAME.to_string(),
ConcreteDataType::uint64_datatype(),
false,
),
];
for i in 0..physical_num_tag {
columns.push(ColumnSchema::new(
format!("tag_{i}"),
ConcreteDataType::string_datatype(),
false,
));
}
columns.push(
ColumnSchema::new(
"timestamp".to_string(),
ConcreteDataType::timestamp_millisecond_datatype(),
false,
)
.with_time_index(true),
);
for i in 0..physical_num_field {
columns.push(ColumnSchema::new(
format!("field_{i}"),
ConcreteDataType::float64_datatype(),
true,
));
}
let schema = Arc::new(Schema::new(columns));
let primary_key_indices = (0..(2 + physical_num_tag)).collect::<Vec<_>>();
let table_meta = TableMetaBuilder::empty()
.schema(schema)
.primary_key_indices(primary_key_indices)
.value_indices(
(2 + physical_num_tag..2 + physical_num_tag + 1 + physical_num_field).collect(),
)
.engine(METRIC_ENGINE_NAME.to_string())
.next_column_id(1024)
.build()
.unwrap();
let table_info = TableInfoBuilder::default()
.table_id(physical_table_id)
.name(physical_table_name)
.meta(table_meta)
.build()
.unwrap();
let table = EmptyTable::from_table_info(&table_info);
assert!(
catalog_list
.register_table_sync(RegisterTableRequest {
catalog: DEFAULT_CATALOG_NAME.to_string(),
schema: DEFAULT_SCHEMA_NAME.to_string(),
table_name: physical_table_name.to_string(),
table_id: physical_table_id,
table,
})
.is_ok()
);
}
// Register metric engine logical tables without `__tsid`, referencing the physical table.
for (idx, ((schema_name, table_name), num_tag, num_field)) in table_specs.iter().enumerate() {
let mut columns = vec![];
for i in 0..*num_tag {
columns.push(ColumnSchema::new(
format!("tag_{i}"),
ConcreteDataType::string_datatype(),
false,
));
}
columns.push(
ColumnSchema::new(
"timestamp".to_string(),
ConcreteDataType::timestamp_millisecond_datatype(),
false,
)
.with_time_index(true),
);
for i in 0..*num_field {
columns.push(ColumnSchema::new(
format!("field_{i}"),
ConcreteDataType::float64_datatype(),
true,
));
}
let schema = Arc::new(Schema::new(columns));
let mut options = table::requests::TableOptions::default();
options.extra_options.insert(
LOGICAL_TABLE_METADATA_KEY.to_string(),
physical_table_name.to_string(),
);
let table_id = 1024u32 + idx as u32;
let table_meta = TableMetaBuilder::empty()
.schema(schema)
.primary_key_indices((0..*num_tag).collect())
.value_indices((*num_tag + 1..*num_tag + 1 + *num_field).collect())
.engine(METRIC_ENGINE_NAME.to_string())
.options(options)
.next_column_id(1024)
.build()
.unwrap();
let table_info = TableInfoBuilder::default()
.table_id(table_id)
.name(table_name.clone())
.meta(table_meta)
.build()
.unwrap();
let table = EmptyTable::from_table_info(&table_info);
assert!(
catalog_list
.register_table_sync(RegisterTableRequest {
catalog: DEFAULT_CATALOG_NAME.to_string(),
schema: schema_name.clone(),
table_name: table_name.clone(),
table_id,
table,
})
.is_ok()
);
}
DfTableSourceProvider::new(
catalog_list,
false,
QueryContext::arc(),
DummyDecoder::arc(),
false,
)
}
async fn build_test_table_provider_with_fields(
table_name_tuples: &[(String, String)],
tags: &[&str],
) -> DfTableSourceProvider {
let catalog_list = MemoryCatalogManager::with_default_setup();
for (schema_name, table_name) in table_name_tuples {
let mut columns = vec![];
let num_tag = tags.len();
for tag in tags {
columns.push(ColumnSchema::new(
tag.to_string(),
ConcreteDataType::string_datatype(),
false,
));
}
columns.push(
ColumnSchema::new(
greptime_timestamp().to_string(),
ConcreteDataType::timestamp_millisecond_datatype(),
false,
)
.with_time_index(true),
);
columns.push(ColumnSchema::new(
greptime_value().to_string(),
ConcreteDataType::float64_datatype(),
true,
));
let schema = Arc::new(Schema::new(columns));
let table_meta = TableMetaBuilder::empty()
.schema(schema)
.primary_key_indices((0..num_tag).collect())
.next_column_id(1024)
.build()
.unwrap();
let table_info = TableInfoBuilder::default()
.name(table_name.clone())
.meta(table_meta)
.build()
.unwrap();
let table = EmptyTable::from_table_info(&table_info);
assert!(
catalog_list
.register_table_sync(RegisterTableRequest {
catalog: DEFAULT_CATALOG_NAME.to_string(),
schema: schema_name.clone(),
table_name: table_name.clone(),
table_id: 1024,
table,
})
.is_ok()
);
}
DfTableSourceProvider::new(
catalog_list,
false,
QueryContext::arc(),
DummyDecoder::arc(),
false,
)
}
// {
// input: `abs(some_metric{foo!="bar"})`,
// expected: &Call{
// Func: MustGetFunction("abs"),
// Args: Expressions{
// &VectorSelector{
// Name: "some_metric",
// LabelMatchers: []*labels.Matcher{
// MustLabelMatcher(labels.MatchNotEqual, "foo", "bar"),
// MustLabelMatcher(labels.MatchEqual, model.MetricNameLabel, "some_metric"),
// },
// },
// },
// },
// },
async fn do_single_instant_function_call(fn_name: &'static str, plan_name: &str) {
let prom_expr = parser::parse(&format!("{fn_name}(some_metric{{tag_0!=\"bar\"}})")).unwrap();
let eval_stmt = EvalStmt {
expr: prom_expr,
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let table_provider = build_test_table_provider(
&[(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string())],
1,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let expected = String::from(
"Filter: TEMPLATE(field_0) IS NOT NULL [timestamp:Timestamp(ms), TEMPLATE(field_0):Float64;N, tag_0:Utf8]\
\n Projection: some_metric.timestamp, TEMPLATE(some_metric.field_0) AS TEMPLATE(field_0), some_metric.tag_0 [timestamp:Timestamp(ms), TEMPLATE(field_0):Float64;N, tag_0:Utf8]\
\n PromInstantManipulate: range=[0..100000000], lookback=[1000], interval=[5000], time index=[timestamp] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n PromSeriesDivide: tags=[\"tag_0\"] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Sort: some_metric.tag_0 ASC NULLS FIRST, some_metric.timestamp ASC NULLS FIRST [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Filter: some_metric.tag_0 != Utf8(\"bar\") AND some_metric.timestamp >= TimestampMillisecond(-999, None) AND some_metric.timestamp <= TimestampMillisecond(100000000, None) [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n TableScan: some_metric [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]"
).replace("TEMPLATE", plan_name);
assert_eq!(plan.display_indent_schema().to_string(), expected);
}
#[tokio::test]
async fn single_abs() {
do_single_instant_function_call("abs", "abs").await;
}
#[tokio::test]
#[should_panic]
async fn single_absent() {
do_single_instant_function_call("absent", "").await;
}
#[tokio::test]
async fn single_ceil() {
do_single_instant_function_call("ceil", "ceil").await;
}
#[tokio::test]
async fn single_exp() {
do_single_instant_function_call("exp", "exp").await;
}
#[tokio::test]
async fn single_ln() {
do_single_instant_function_call("ln", "ln").await;
}
#[tokio::test]
async fn single_log2() {
do_single_instant_function_call("log2", "log2").await;
}
#[tokio::test]
async fn single_log10() {
do_single_instant_function_call("log10", "log10").await;
}
#[tokio::test]
#[should_panic]
async fn single_scalar() {
do_single_instant_function_call("scalar", "").await;
}
#[tokio::test]
#[should_panic]
async fn single_sgn() {
do_single_instant_function_call("sgn", "").await;
}
#[tokio::test]
#[should_panic]
async fn single_sort() {
do_single_instant_function_call("sort", "").await;
}
#[tokio::test]
#[should_panic]
async fn single_sort_desc() {
do_single_instant_function_call("sort_desc", "").await;
}
#[tokio::test]
async fn single_sqrt() {
do_single_instant_function_call("sqrt", "sqrt").await;
}
#[tokio::test]
async fn single_timestamp_plan_preserves_source_value() {
let eval_stmt = build_eval_stmt(r#"timestamp(some_metric{tag_0!="bar"})"#);
let table_provider = build_test_table_provider(
&[(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string())],
1,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let expected = String::from(
"Filter: value IS NOT NULL [timestamp:Timestamp(ms), value:Float64, tag_0:Utf8]\
\n Projection: some_metric.timestamp, value AS value, some_metric.tag_0 [timestamp:Timestamp(ms), value:Float64, tag_0:Utf8]\
\n Projection: some_metric.timestamp, __promql_timestamp_value_ AS value, some_metric.tag_0 [timestamp:Timestamp(ms), value:Float64, tag_0:Utf8]\
\n PromInstantManipulate: range=[0..100000000], lookback=[1000], interval=[5000], time index=[timestamp] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N, __promql_timestamp_value_:Float64]\
\n Projection: some_metric.tag_0, some_metric.timestamp, some_metric.field_0, CAST(CAST(CAST(CAST(some_metric.timestamp AS Int64) AS Decimal128(19, 0)) * Decimal128(1,1,0) + Decimal128(0,19,0) AS Int64) AS Float64) / Float64(1000) AS __promql_timestamp_value_ [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N, __promql_timestamp_value_:Float64]\
\n PromSeriesDivide: tags=[\"tag_0\"] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Sort: some_metric.tag_0 ASC NULLS FIRST, some_metric.timestamp ASC NULLS FIRST [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Filter: some_metric.tag_0 != Utf8(\"bar\") AND some_metric.timestamp >= TimestampMillisecond(-999, None) AND some_metric.timestamp <= TimestampMillisecond(100000000, None) [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n TableScan: some_metric [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]",
);
assert_eq!(plan.display_indent_schema().to_string(), expected);
}
#[tokio::test]
async fn single_acos() {
do_single_instant_function_call("acos", "acos").await;
}
#[tokio::test]
#[should_panic]
async fn single_acosh() {
do_single_instant_function_call("acosh", "").await;
}
#[tokio::test]
async fn single_asin() {
do_single_instant_function_call("asin", "asin").await;
}
#[tokio::test]
#[should_panic]
async fn single_asinh() {
do_single_instant_function_call("asinh", "").await;
}
#[tokio::test]
async fn single_atan() {
do_single_instant_function_call("atan", "atan").await;
}
#[tokio::test]
#[should_panic]
async fn single_atanh() {
do_single_instant_function_call("atanh", "").await;
}
#[tokio::test]
async fn single_cos() {
do_single_instant_function_call("cos", "cos").await;
}
#[tokio::test]
#[should_panic]
async fn single_cosh() {
do_single_instant_function_call("cosh", "").await;
}
#[tokio::test]
async fn single_sin() {
do_single_instant_function_call("sin", "sin").await;
}
#[tokio::test]
#[should_panic]
async fn single_sinh() {
do_single_instant_function_call("sinh", "").await;
}
#[tokio::test]
async fn single_tan() {
do_single_instant_function_call("tan", "tan").await;
}
#[tokio::test]
#[should_panic]
async fn single_tanh() {
do_single_instant_function_call("tanh", "").await;
}
#[tokio::test]
#[should_panic]
async fn single_deg() {
do_single_instant_function_call("deg", "").await;
}
#[tokio::test]
#[should_panic]
async fn single_rad() {
do_single_instant_function_call("rad", "").await;
}
// {
// input: "avg by (foo)(some_metric)",
// expected: &AggregateExpr{
// Op: AVG,
// Expr: &VectorSelector{
// Name: "some_metric",
// LabelMatchers: []*labels.Matcher{
// MustLabelMatcher(labels.MatchEqual, model.MetricNameLabel, "some_metric"),
// },
// PosRange: PositionRange{
// Start: 13,
// End: 24,
// },
// },
// Grouping: []string{"foo"},
// PosRange: PositionRange{
// Start: 0,
// End: 25,
// },
// },
// },
async fn do_aggregate_expr_plan(fn_name: &str, plan_name: &str) {
let prom_expr = parser::parse(&format!(
"{fn_name} by (tag_1)(some_metric{{tag_0!=\"bar\"}})",
))
.unwrap();
let mut eval_stmt = EvalStmt {
expr: prom_expr,
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
// test group by
let table_provider = build_test_table_provider(
&[(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string())],
2,
2,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let expected_no_without = String::from(
"Sort: some_metric.tag_1 ASC NULLS LAST, some_metric.timestamp ASC NULLS LAST [tag_1:Utf8, timestamp:Timestamp(ms), TEMPLATE(some_metric.field_0):Float64;N, TEMPLATE(some_metric.field_1):Float64;N]\
\n Aggregate: groupBy=[[some_metric.tag_1, some_metric.timestamp]], aggr=[[TEMPLATE(some_metric.field_0), TEMPLATE(some_metric.field_1)]] [tag_1:Utf8, timestamp:Timestamp(ms), TEMPLATE(some_metric.field_0):Float64;N, TEMPLATE(some_metric.field_1):Float64;N]\
\n PromInstantManipulate: range=[0..100000000], lookback=[1000], interval=[5000], time index=[timestamp] [tag_0:Utf8, tag_1:Utf8, timestamp:Timestamp(ms), field_0:Float64;N, field_1:Float64;N]\
\n PromSeriesDivide: tags=[\"tag_0\", \"tag_1\"] [tag_0:Utf8, tag_1:Utf8, timestamp:Timestamp(ms), field_0:Float64;N, field_1:Float64;N]\
\n Sort: some_metric.tag_0 ASC NULLS FIRST, some_metric.tag_1 ASC NULLS FIRST, some_metric.timestamp ASC NULLS FIRST [tag_0:Utf8, tag_1:Utf8, timestamp:Timestamp(ms), field_0:Float64;N, field_1:Float64;N]\
\n Filter: some_metric.tag_0 != Utf8(\"bar\") AND some_metric.timestamp >= TimestampMillisecond(-999, None) AND some_metric.timestamp <= TimestampMillisecond(100000000, None) [tag_0:Utf8, tag_1:Utf8, timestamp:Timestamp(ms), field_0:Float64;N, field_1:Float64;N]\
\n TableScan: some_metric [tag_0:Utf8, tag_1:Utf8, timestamp:Timestamp(ms), field_0:Float64;N, field_1:Float64;N]"
).replace("TEMPLATE", plan_name);
assert_eq!(
plan.display_indent_schema().to_string(),
expected_no_without
);
// test group without
if let PromExpr::Aggregate(AggregateExpr { modifier, .. }) = &mut eval_stmt.expr {
*modifier = Some(LabelModifier::Exclude(Labels {
labels: vec![String::from("tag_1")].into_iter().collect(),
}));
}
let table_provider = build_test_table_provider(
&[(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string())],
2,
2,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let expected_without = String::from(
"Sort: some_metric.tag_0 ASC NULLS LAST, some_metric.timestamp ASC NULLS LAST [tag_0:Utf8, timestamp:Timestamp(ms), TEMPLATE(some_metric.field_0):Float64;N, TEMPLATE(some_metric.field_1):Float64;N]\
\n Aggregate: groupBy=[[some_metric.tag_0, some_metric.timestamp]], aggr=[[TEMPLATE(some_metric.field_0), TEMPLATE(some_metric.field_1)]] [tag_0:Utf8, timestamp:Timestamp(ms), TEMPLATE(some_metric.field_0):Float64;N, TEMPLATE(some_metric.field_1):Float64;N]\
\n PromInstantManipulate: range=[0..100000000], lookback=[1000], interval=[5000], time index=[timestamp] [tag_0:Utf8, tag_1:Utf8, timestamp:Timestamp(ms), field_0:Float64;N, field_1:Float64;N]\
\n PromSeriesDivide: tags=[\"tag_0\", \"tag_1\"] [tag_0:Utf8, tag_1:Utf8, timestamp:Timestamp(ms), field_0:Float64;N, field_1:Float64;N]\
\n Sort: some_metric.tag_0 ASC NULLS FIRST, some_metric.tag_1 ASC NULLS FIRST, some_metric.timestamp ASC NULLS FIRST [tag_0:Utf8, tag_1:Utf8, timestamp:Timestamp(ms), field_0:Float64;N, field_1:Float64;N]\
\n Filter: some_metric.tag_0 != Utf8(\"bar\") AND some_metric.timestamp >= TimestampMillisecond(-999, None) AND some_metric.timestamp <= TimestampMillisecond(100000000, None) [tag_0:Utf8, tag_1:Utf8, timestamp:Timestamp(ms), field_0:Float64;N, field_1:Float64;N]\
\n TableScan: some_metric [tag_0:Utf8, tag_1:Utf8, timestamp:Timestamp(ms), field_0:Float64;N, field_1:Float64;N]"
).replace("TEMPLATE", plan_name);
assert_eq!(plan.display_indent_schema().to_string(), expected_without);
}
#[tokio::test]
async fn aggregate_sum() {
do_aggregate_expr_plan("sum", "sum").await;
}
#[tokio::test]
async fn tsid_is_used_for_series_divide_when_available() {
let prom_expr = parser::parse("some_metric").unwrap();
let eval_stmt = EvalStmt {
expr: prom_expr,
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let table_provider = build_test_table_provider_with_tsid(
&[(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string())],
1,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert!(plan_str.contains("PromSeriesDivide: tags=[\"__tsid\"]"));
assert!(plan_str.contains("__tsid ASC NULLS FIRST"));
assert!(
!plan
.schema()
.fields()
.iter()
.any(|field| field.name() == DATA_SCHEMA_TSID_COLUMN_NAME)
);
let manipulate = find_instant_manipulate(&plan).unwrap();
let exec = manipulate.to_execution_plan(Arc::new(DataSourceExec::new(Arc::new(
MemorySourceConfig::try_new(
&[],
Arc::new(
datafusion_expr::UserDefinedLogicalNodeCore::inputs(manipulate)[0]
.schema()
.as_arrow()
.clone(),
),
None,
)
.unwrap(),
))));
assert!(format!("{exec:?}").contains("reuse_tsid_column: true"));
}
async fn build_at_modifier_plan(query: &str, start_secs: u64, end_secs: u64) -> LogicalPlan {
let eval_stmt = build_at_modifier_eval_stmt(query, start_secs, end_secs);
let table_provider = build_test_table_provider(
&[(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string())],
1,
1,
)
.await;
PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap()
}
fn build_at_modifier_eval_stmt(query: &str, start_secs: u64, end_secs: u64) -> EvalStmt {
EvalStmt {
expr: parser::parse(query).unwrap(),
start: UNIX_EPOCH
.checked_add(Duration::from_secs(start_secs))
.unwrap(),
end: UNIX_EPOCH
.checked_add(Duration::from_secs(end_secs))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
}
}
/// Every selector of an `@` anchored query must scan around the anchor only, instead of the
/// whole evaluation range.
#[tokio::test]
async fn at_modifier_anchors_selector_scan_window() {
// `@ 100` anchors at t=100s; the lookback delta is 1s, so the scan covers (99s, 100s].
let plan = build_at_modifier_plan("some_metric @ 100", 0, 1000).await;
let plan_str = plan.display_indent_schema().to_string();
assert!(
plan_str.contains(
"some_metric.timestamp >= TimestampMillisecond(99001, None) AND some_metric.timestamp <= TimestampMillisecond(100000, None)"
),
"{plan_str}"
);
// The result is reported at the evaluation timestamps, not at the anchor.
assert!(plan_str.contains("range=[0..1000000]"), "{plan_str}");
// `@ start()` / `@ end()` resolve to the evaluation range of the statement.
let plan = build_at_modifier_plan("some_metric @ start()", 0, 1000).await;
let plan_str = plan.display_indent_schema().to_string();
assert!(
plan_str.contains(
"some_metric.timestamp >= TimestampMillisecond(-999, None) AND some_metric.timestamp <= TimestampMillisecond(0, None)"
),
"{plan_str}"
);
let plan = build_at_modifier_plan("some_metric @ end()", 0, 1000).await;
let plan_str = plan.display_indent_schema().to_string();
assert!(
plan_str.contains(
"some_metric.timestamp >= TimestampMillisecond(999001, None) AND some_metric.timestamp <= TimestampMillisecond(1000000, None)"
),
"{plan_str}"
);
// `offset` moves the anchor backwards and is not applied twice.
let plan = build_at_modifier_plan("some_metric @ 200 offset 50s", 0, 1000).await;
let plan_str = plan.display_indent_schema().to_string();
assert!(
plan_str.contains(
"some_metric.timestamp >= TimestampMillisecond(149001, None) AND some_metric.timestamp <= TimestampMillisecond(150000, None)"
),
"{plan_str}"
);
// A timestamp before the Unix epoch is accepted, as in Prometheus.
let plan = build_at_modifier_plan("some_metric @ -1", 0, 1000).await;
let plan_str = plan.display_indent_schema().to_string();
assert!(
plan_str.contains(
"some_metric.timestamp >= TimestampMillisecond(-1999, None) AND some_metric.timestamp <= TimestampMillisecond(-1000, None)"
),
"{plan_str}"
);
}
/// A call over one range selector anchored by `@` is evaluated once, at the start of the
/// evaluation, and its result is reported at every step: the window is folded around the anchor
/// instead of following the outer evaluation grid. This is the planner's counterpart of
/// Prometheus' `StepInvariantExpr` wrapper; see [`PromPlanner::promotes_anchored_range_call`].
#[tokio::test]
async fn at_modifier_promotes_anchored_range_call() {
let plan = build_at_modifier_plan("rate(some_metric[5m] @ 300)", 0, 1000).await;
let plan_str = plan.display_indent_schema().to_string();
// The scan is limited to the anchored window (offset by `eval_start - anchor`).
assert!(
plan_str.contains(
"some_metric.timestamp >= TimestampMillisecond(1, None) AND some_metric.timestamp <= TimestampMillisecond(300000, None)"
),
"{plan_str}"
);
// A single fold, at the anchor...
assert_eq!(
plan_str
.matches("PromRangeManipulate: req range=[0..0]")
.count(),
1,
"{plan_str}"
);
// ... never a fold per step of the outer grid.
assert!(
!plan_str.contains("PromRangeManipulate: req range=[0..1000000]"),
"{plan_str}"
);
// A single replay of the function result over the whole grid...
assert_eq!(
plan_str.matches("PromInstantManipulate").count(),
1,
"{plan_str}"
);
assert!(
plan_str.contains("PromInstantManipulate: range=[0..1000000], lookback=[1000001]"),
"{plan_str}"
);
// ... so `rate` itself is evaluated below that replay node, on the single evaluation
// instant of the anchored subtree, instead of once per step.
let replay = plan_str
.find("PromInstantManipulate")
.expect("instant manipulate node");
let rate = plan_str.find("prom_rate(").expect("rate projection");
assert!(
replay < rate,
"`rate` must be evaluated below the replay node:\n{plan_str}"
);
}
/// Parentheses around the range argument are transparent: `rate((some_metric[5m] @ 300))` gets
/// the same fixed-window promotion as `rate(some_metric[5m] @ 300)`, with the anchored window
/// folded once and its result replayed at every step of the grid. Only that one argument is
/// looked through, so a parenthesis above the call promotes no operator of its own and a
/// parenthesized subtree is planned exactly like the bare one.
#[tokio::test]
async fn at_modifier_promotes_parenthesized_range_argument() {
// Each form is planned exactly like its unparenthesized counterpart: parentheses below the
// call are transparent, a parenthesis around the call adds nothing, and a parenthesis above
// it does not widen the promotion.
for (query, plain) in [
(
"rate((some_metric[5m] @ 300))",
"rate(some_metric[5m] @ 300)",
),
(
"rate(((some_metric[5m] @ 300)))",
"rate(some_metric[5m] @ 300)",
),
(
"(rate(some_metric[5m] @ 300))",
"rate(some_metric[5m] @ 300)",
),
(
"abs((rate(some_metric[5m] @ 300)))",
"abs(rate(some_metric[5m] @ 300))",
),
] {
assert_eq!(
build_at_modifier_plan(query, 0, 1000)
.await
.display_indent_schema()
.to_string(),
build_at_modifier_plan(plain, 0, 1000)
.await
.display_indent_schema()
.to_string(),
"`{query}` must be planned like `{plain}`"
);
}
// The parentheses do not push the enclosing operator into the promotion either: `abs` stays
// above the replay of the promoted call, exactly as it does without them. The promotion
// itself (one anchored fold, one replay, `rate` below it) is asserted for the
// unparenthesized form by `at_modifier_promotes_anchored_range_call`, and the form above is
// planned identically to it.
let plan_str = build_at_modifier_plan("abs((rate(some_metric[5m] @ 300)))", 0, 1000)
.await
.display_indent_schema()
.to_string();
let replay = plan_str
.find("PromInstantManipulate")
.expect("instant manipulate node");
assert!(
plan_str.find("abs(").expect("`abs` projection") < replay,
"`abs` must be evaluated above the replay of the promoted call:\n{plan_str}"
);
}
/// `@ start()` and `@ end()` are fixed anchors for the whole statement, so a call using them is
/// promoted as well.
#[tokio::test]
async fn at_modifier_promotes_start_and_end_anchored_call() {
for query in [
"rate(some_metric[5m] @ start())",
"rate(some_metric[5m] @ end())",
"max_over_time(some_metric[5m] @ end())",
] {
let plan = build_at_modifier_plan(query, 0, 1000).await;
let plan_str = plan.display_indent_schema().to_string();
assert_eq!(
plan_str
.matches("PromRangeManipulate: req range=[0..0]")
.count(),
1,
"{query}:\n{plan_str}"
);
assert!(
plan_str.contains("PromInstantManipulate: range=[0..1000000], lookback=[1000001]"),
"{query}:\n{plan_str}"
);
}
// Only the call itself is promoted: a call above it (`abs`) is planned as usual and
// evaluated at every step over the replayed result of the promoted call. The window is
// still folded once, around the anchor.
let plan = build_at_modifier_plan("abs(max_over_time(some_metric[5m] @ end()))", 0, 1000).await;
let plan_str = plan.display_indent_schema().to_string();
assert_eq!(
plan_str
.matches("PromRangeManipulate: req range=[0..0]")
.count(),
1,
"{plan_str}"
);
let abs = plan_str.find("abs(").expect("`abs` projection");
let replay = plan_str
.find("PromInstantManipulate: range=[0..1000000], lookback=[1000001]")
.expect("replay node");
assert!(
abs < replay,
"`abs` must be evaluated above the replay of the promoted call:\n{plan_str}"
);
// The window is the only one folded once, and it sits below the replay: the promoted call
// feeds the grid from there.
let fold = plan_str.find("PromRangeManipulate").expect("range fold");
assert!(
replay < fold,
"the anchored window must be folded below the replay:\n{plan_str}"
);
// An aggregation above the promoted call stays above it as well: `sum` aggregates the
// replayed per-series rows at every step, instead of aggregating the single anchored
// instant and replaying the aggregation — which would also have to replay rows of several
// groups through the one-series-per-batch `InstantManipulate`.
let plan = build_at_modifier_plan("sum(rate(some_metric[5m] @ start()))", 0, 1000).await;
let plan_str = plan.display_indent_schema().to_string();
assert_eq!(
plan_str
.matches("PromRangeManipulate: req range=[0..0]")
.count(),
1,
"{plan_str}"
);
// A single replay, and it sits below the aggregation node: `sum` aggregates the replayed
// per-series rows at every step.
assert_eq!(
plan_str.matches("PromInstantManipulate").count(),
1,
"{plan_str}"
);
let aggregate = plan_str.find("Aggregate:").expect("aggregate node");
let replay = plan_str
.find("PromInstantManipulate: range=[0..1000000], lookback=[1000001]")
.expect("replay node");
assert!(
aggregate < replay,
"the aggregation must stay above the replay of the promoted call:\n{plan_str}"
);
// Without `@` nothing is promoted: the function keeps folding one window per step.
let plan = build_at_modifier_plan("rate(some_metric[5m])", 0, 1000).await;
let plan_str = plan.display_indent_schema().to_string();
assert!(
plan_str.contains("PromRangeManipulate: req range=[0..1000000]"),
"{plan_str}"
);
assert!(!plan_str.contains("lookback=[1000001]"), "{plan_str}");
}
/// A call or a unary operator above the anchored range call is planned as usual: the inner range
/// call is promoted on its own (it is the direct call over the anchored range selector), and the
/// operator above it is evaluated at every step over the replayed result.
#[tokio::test]
async fn at_modifier_promotes_inner_range_call_below_wrappers() {
for (query, wrapper) in [
("abs(rate(some_metric[5m] @ 300))", "abs(prom_rate("),
("-rate(some_metric[5m] @ 300)", "(- prom_rate("),
(
"abs(max_over_time(some_metric[5m] @ 300))",
"abs(prom_max_over_time(",
),
] {
let plan = build_at_modifier_plan(query, 0, 1000).await;
let plan_str = plan.display_indent_schema().to_string();
// The anchored window is folded once, and the wrapper sits above its replay.
assert_eq!(
plan_str
.matches("PromRangeManipulate: req range=[0..0]")
.count(),
1,
"{query}:\n{plan_str}"
);
assert_eq!(
plan_str.matches("PromInstantManipulate").count(),
1,
"{query}:\n{plan_str}"
);
let wrapper = plan_str
.find(wrapper)
.unwrap_or_else(|| panic!("no `{wrapper}` projection in:\n{plan_str}"));
let replay = plan_str
.find("PromInstantManipulate: range=[0..1000000], lookback=[1000001]")
.expect("replay node");
assert!(
wrapper < replay,
"the wrapper must be evaluated above the replay of the range call:\n{plan_str}"
);
}
}
/// `anchored + plain`: only the binary operand that is a call over the anchored range selector
/// is promoted, and the plain side keeps following the evaluation step.
#[tokio::test]
async fn at_modifier_promotes_only_anchored_binary_operand() {
let plan = build_at_modifier_plan("rate(some_metric[5m] @ 300) + some_metric", 0, 1000).await;
let plan_str = plan.display_indent_schema().to_string();
// The anchored operand is folded once and its result replayed over the whole grid.
assert_eq!(
plan_str
.matches("PromRangeManipulate: req range=[0..0]")
.count(),
1,
"{plan_str}"
);
assert!(
plan_str.contains("PromInstantManipulate: range=[0..1000000], lookback=[1000001]"),
"{plan_str}"
);
// The plain operand still selects one sample per step with the default lookback.
assert!(
plan_str.contains("PromInstantManipulate: range=[0..1000000], lookback=[1000]"),
"{plan_str}"
);
assert_eq!(
plan_str.matches("PromInstantManipulate").count(),
2,
"{plan_str}"
);
// Both operands anchored: the binary expression is not promoted as a whole, because a join
// emits the rows of several series in shared batches and the replay needs one series per
// batch. Each operand anchors and replays on its own instead, and the join runs at every
// step over those per-series results.
let plan = build_at_modifier_plan("some_metric @ 300 + some_metric @ 0", 0, 1000).await;
let plan_str = plan.display_indent_schema().to_string();
// One anchoring node and one replay per operand...
assert_eq!(
plan_str.matches("PromInstantManipulate").count(),
4,
"{plan_str}"
);
assert_eq!(
plan_str
.matches("PromInstantManipulate: range=[0..1000000], lookback=[1000001]")
.count(),
2,
"{plan_str}"
);
// ... and the two operands are anchored at different timestamps, so both select their own
// sample.
assert_eq!(
plan_str
.matches("PromInstantManipulate: range=[0..0], lookback=[1000]")
.count(),
2,
"{plan_str}"
);
}
/// Only a direct call over an anchored range selector is promoted. A value function or a unary
/// operator over an anchored *instant* selector needs no promotion: the selector anchors and
/// replays its sample per series on its own, and the operator above it is row-wise, so it can be
/// evaluated at every step over that replay.
#[tokio::test]
async fn at_modifier_does_not_promote_value_calls_over_anchored_selectors() {
for (query, value_expr) in [
("abs(some_metric @ 300)", "abs(some_metric.field_0)"),
("-some_metric @ 300", "(- some_metric.field_0)"),
] {
let plan = build_at_modifier_plan(query, 0, 1000).await;
let plan_str = plan.display_indent_schema().to_string();
// The scan is limited to the anchored sample (the lookback delta of this test is 1s).
assert!(
plan_str.contains(
"some_metric.timestamp >= TimestampMillisecond(299001, None) AND some_metric.timestamp <= TimestampMillisecond(300000, None)"
),
"{query}:\n{plan_str}"
);
// ... and the result is replayed at every step by the selector itself: the anchored
// selection, then the grid replay, with no promoted subtree on top of the operator.
assert!(
!plan_str.starts_with("PromInstantManipulate"),
"{query}:\n{plan_str}"
);
assert_eq!(
plan_str
.matches("PromInstantManipulate: range=[0..0], lookback=[1000]")
.count(),
1,
"{query}:\n{plan_str}"
);
let replay = plan_str
.find("PromInstantManipulate: range=[0..1000000], lookback=[1000001]")
.unwrap_or_else(|| panic!("replay node:\n{plan_str}"));
let value = plan_str
.find(value_expr)
.unwrap_or_else(|| panic!("no `{value_expr}` projection in:\n{plan_str}"));
assert!(
value < replay,
"`{value_expr}` must be evaluated above the per-series replay:\n{plan_str}"
);
}
}
/// A call whose argument merges series (an aggregation or a join) or whose own output reorders
/// the whole vector (`sort*`, the histogram folds) is never the promoted root either: it is
/// planned as usual over the leaf-level anchoring of its selectors, which replays every selector
/// per series and keeps the one-series-per-batch layout the replay needs.
#[tokio::test]
async fn at_modifier_keeps_multi_series_roots_out_of_promoted_subtree() {
// An aggregation below the call emits one row per group in shared batches, so the call is
// not promoted: the replay of the anchored selector stays below the aggregate.
let plan = build_at_modifier_plan("abs(sum(some_metric @ 300))", 0, 1000).await;
let plan_str = plan.display_indent_schema().to_string();
let aggregate = plan_str.find("Aggregate:").expect("aggregate node");
let replay = plan_str
.find("PromInstantManipulate: range=[0..1000000], lookback=[1000001]")
.expect("replay node");
assert!(
aggregate < replay,
"the aggregation must stay above the replay:\n{plan_str}"
);
// A join of two anchored selectors below the call: neither the join nor the call is
// promoted, so each operand is replayed on its own.
let plan = build_at_modifier_plan("abs(some_metric @ 300 + some_metric @ 0)", 0, 1000).await;
let plan_str = plan.display_indent_schema().to_string();
assert!(!plan_str.starts_with("PromInstantManipulate"), "{plan_str}");
assert_eq!(
plan_str
.matches("PromInstantManipulate: range=[0..1000000], lookback=[1000001]")
.count(),
2,
"{plan_str}"
);
// A call that reorders the whole vector keeps its sort above the per-series replay.
let plan = build_at_modifier_plan("sort_by_label(some_metric @ 300, \"tag_0\")", 0, 1000).await;
let plan_str = plan.display_indent_schema().to_string();
assert!(plan_str.starts_with("Sort:"), "{plan_str}");
assert_eq!(
plan_str
.matches("PromInstantManipulate: range=[0..1000000], lookback=[1000001]")
.count(),
1,
"{plan_str}"
);
}
/// `label_join` rewrites the labels of its input series, so it is never the promoted root: the
/// anchored selector keeps replaying one series per batch, and the join runs at every step above
/// that replay (see [`Self::promotes_anchored_range_call`]). Promoting it would replay the
/// joined rows through the labels the join just rewrote, which merges the distinct input series
/// into one timeline.
#[tokio::test]
async fn at_modifier_does_not_promote_label_join() {
for query in [
// Directly above the anchored instant selector...
"label_join(some_metric @ 300, \"tag_0\", \"-\", \"\")",
// ... and below another call, which is planned as usual over the join.
"abs(label_join(some_metric @ 300, \"tag_0\", \"-\", \"\"))",
] {
let plan = build_at_modifier_plan(query, 0, 1000).await;
let plan_str = plan.display_indent_schema().to_string();
// The join is not wrapped in a replay of its own; the only replay over the grid is the
// one of the anchored selector...
assert!(
!plan_str.starts_with("PromInstantManipulate"),
"{query}:\n{plan_str}"
);
assert_eq!(
plan_str
.matches("PromInstantManipulate: range=[0..1000000], lookback=[1000001]")
.count(),
1,
"{query}:\n{plan_str}"
);
// ... and the projected join stays above it, evaluated at every step.
let join = plan_str
.find("concat_ws(")
.unwrap_or_else(|| panic!("no `label_join` projection in:\n{plan_str}"));
let replay = plan_str
.find("PromInstantManipulate: range=[0..1000000], lookback=[1000001]")
.expect("replay node");
assert!(
join < replay,
"`label_join` must be evaluated above the per-series replay:\n{plan_str}"
);
}
// A range call below the join is still promoted on its own: the anchored window is folded
// once per series, and the join above it is evaluated at every step over that replay.
let plan = build_at_modifier_plan(
"label_join(rate(some_metric[5m] @ 300), \"tag_0\", \"-\", \"\")",
0,
1000,
)
.await;
let plan_str = plan.display_indent_schema().to_string();
assert_eq!(
plan_str
.matches("PromRangeManipulate: req range=[0..0]")
.count(),
1,
"{plan_str}"
);
assert!(!plan_str.starts_with("PromInstantManipulate"), "{plan_str}");
let join = plan_str.find("concat_ws(").expect("join projection");
let replay = plan_str
.find("PromInstantManipulate: range=[0..1000000], lookback=[1000001]")
.expect("replay node");
assert!(
join < replay,
"the join must be evaluated above the replay of the range call:\n{plan_str}"
);
}
#[test]
fn at_modifier_rejects_subtraction_overflow() {
for (anchor, offset) in [(i64::MAX, -1), (i64::MIN, 1)] {
let err = PromPlanner::anchor_sub(anchor, offset).unwrap_err();
assert_eq!(err.status_code(), StatusCode::InvalidArguments);
assert!(
err.to_string()
.contains("Timestamp out of range for the `@` modifier"),
"{err}"
);
}
assert_eq!(PromPlanner::anchor_sub(-1, 1).unwrap(), -2);
}
/// `@` beyond the representable millisecond range is rejected instead of silently wrapping.
///
/// `@ 1e16` is 10^19 milliseconds, beyond `i64::MAX`. A Unix `SystemTime` can hold it, so
/// the planner rejects the anchor it cannot represent. A Windows `SystemTime` tops out
/// below `i64::MAX` milliseconds, so the same literal is already rejected while parsing.
#[tokio::test]
async fn at_modifier_rejects_unrepresentable_timestamp() {
#[cfg(windows)]
{
let err = parser::parse("some_metric @ 1e16").unwrap_err();
assert!(
err.to_string()
.contains("timestamp out of bounds for @ modifier"),
"{err}"
);
}
#[cfg(not(windows))]
{
let eval_stmt = build_eval_stmt("some_metric @ 1e16");
let table_provider = build_test_table_provider(
&[(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string())],
1,
1,
)
.await;
let err =
PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap_err();
assert!(
err.to_string()
.contains("Timestamp out of range for the `@` modifier"),
"{err}"
);
assert_eq!(err.status_code(), StatusCode::InvalidArguments);
}
}
#[tokio::test]
async fn default_binary_join_uses_tsid_when_available() {
let eval_stmt = build_eval_stmt("some_metric / some_alt_metric");
let table_provider = build_test_table_provider_with_tsid(
&[
(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string()),
(
DEFAULT_SCHEMA_NAME.to_string(),
"some_alt_metric".to_string(),
),
],
1,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert!(
plan_str.contains("some_metric.__tsid = some_alt_metric.__tsid"),
"{plan_str}"
);
assert!(
!plan_str.contains("some_metric.tag_0 = some_alt_metric.tag_0"),
"{plan_str}"
);
}
#[tokio::test]
async fn reject_binary_fill_modifiers() {
let state = build_query_engine_state();
for query in [
"some_metric + fill(0) some_alt_metric",
"some_metric + fill_left(0) some_alt_metric",
"some_metric + fill_right(0) some_alt_metric",
"(some_metric + fill(0) some_alt_metric) + some_metric",
] {
let eval_stmt = build_eval_stmt(query);
let table_provider = build_test_table_provider(&[], 0, 0).await;
let err = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &state)
.await
.unwrap_err();
assert!(
matches!(
&err,
crate::promql::error::Error::UnsupportedExpr { name, .. }
if name == "PromQL fill modifiers"
),
"{err}"
);
}
}
#[tokio::test]
async fn timestamp_binary_join_falls_back_when_tsid_is_projected_out() {
for query in [
"timestamp(some_metric) / some_metric",
"some_metric / timestamp(some_metric)",
] {
let eval_stmt = build_eval_stmt(query);
let table_provider = build_test_table_provider_with_tsid(
&[(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string())],
1,
1,
)
.await;
let plan =
PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert!(!plan_str.contains("__tsid ="), "{query}: {plan_str}");
assert!(
plan_str.contains("lhs.tag_0 = rhs.tag_0"),
"{query}: {plan_str}"
);
assert!(
!plan
.schema()
.fields()
.iter()
.any(|field| field.name() == DATA_SCHEMA_TSID_COLUMN_NAME),
"{query}: {plan_str}"
);
}
}
#[tokio::test]
async fn timestamp_binary_join_rejects_default_matching_on_mismatched_labels() {
let eval_stmt = build_eval_stmt("timestamp(left_host_job) / right_by_job");
let table_provider = build_test_table_provider_with_tsid_tag_fields(&[
(
(DEFAULT_SCHEMA_NAME.to_string(), "left_host_job".to_string()),
2,
1,
),
(
(DEFAULT_SCHEMA_NAME.to_string(), "right_by_job".to_string()),
1,
1,
),
])
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert!(
plan_str.contains("Boolean(false)") || plan_str.contains("false"),
"{plan_str}"
);
}
#[tokio::test]
async fn tsid_is_preserved_for_nested_default_binary_joins() {
let eval_stmt = build_eval_stmt("(some_metric - some_alt_metric) / some_third_metric");
let table_provider = build_test_table_provider_with_tsid(
&[
(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string()),
(
DEFAULT_SCHEMA_NAME.to_string(),
"some_alt_metric".to_string(),
),
(
DEFAULT_SCHEMA_NAME.to_string(),
"some_third_metric".to_string(),
),
],
1,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert_eq!(plan_str.matches("__tsid =").count(), 2, "{plan_str}");
assert!(!plan_str.contains("tag_0 ="), "{plan_str}");
}
#[tokio::test]
async fn repeated_tsid_binary_operand_reuses_leaf_plan() {
let eval_stmt = build_eval_stmt("((some_metric - some_alt_metric) / some_metric) * 100");
let table_provider = build_test_table_provider_with_tsid(
&[
(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string()),
(
DEFAULT_SCHEMA_NAME.to_string(),
"some_alt_metric".to_string(),
),
],
1,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert_eq!(plan_str.matches("__tsid =").count(), 1, "{plan_str}");
assert_eq!(
plan_str
.matches("Filter: phy.__table_id = UInt32(1024)")
.count(),
1,
"{plan_str}"
);
assert_eq!(
plan_str.matches("PromInstantManipulate").count(),
2,
"{plan_str}"
);
assert!(!plan_str.contains("tag_0 ="), "{plan_str}");
}
#[tokio::test]
async fn repeated_tsid_binary_operand_reuses_shorter_field_side() {
let eval_stmt =
build_eval_stmt("((two_field_metric - one_field_metric) / one_field_metric) * 100");
let table_provider = build_test_table_provider_with_tsid_fields(
&[
(
(
DEFAULT_SCHEMA_NAME.to_string(),
"two_field_metric".to_string(),
),
2,
),
(
(
DEFAULT_SCHEMA_NAME.to_string(),
"one_field_metric".to_string(),
),
1,
),
],
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let field_names = plan
.schema()
.fields()
.iter()
.map(|field| field.name().clone())
.collect::<Vec<_>>();
let value_columns = field_names
.iter()
.filter(|name| {
*name != "tag_0" && *name != "timestamp" && *name != DATA_SCHEMA_TSID_COLUMN_NAME
})
.count();
assert_eq!(value_columns, 1, "{field_names:?}");
let plan_str = plan.display_indent_schema().to_string();
assert_eq!(plan_str.matches("__tsid =").count(), 1, "{plan_str}");
assert_eq!(
plan_str
.matches("Filter: phy.__table_id = UInt32(1025)")
.count(),
1,
"{plan_str}"
);
assert!(!plan_str.contains("tag_0 ="), "{plan_str}");
}
#[tokio::test]
async fn binary_island_reuses_self_operand_without_join() {
let eval_stmt = build_eval_stmt("some_metric / some_metric");
let table_provider = build_test_table_provider_with_tsid(
&[(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string())],
1,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert_eq!(plan_str.matches("__tsid =").count(), 0, "{plan_str}");
assert_eq!(
plan_str
.matches("Filter: phy.__table_id = UInt32(1024)")
.count(),
1,
"{plan_str}"
);
assert_eq!(
plan_str.matches("PromInstantManipulate").count(),
1,
"{plan_str}"
);
}
#[tokio::test]
async fn binary_island_reuses_leaf_across_two_branches() {
let eval_stmt =
build_eval_stmt("(some_metric + some_alt_metric) / (some_metric + third_metric)");
let table_provider = build_test_table_provider_with_tsid(
&[
(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string()),
(
DEFAULT_SCHEMA_NAME.to_string(),
"some_alt_metric".to_string(),
),
(DEFAULT_SCHEMA_NAME.to_string(), "third_metric".to_string()),
],
1,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert_eq!(plan_str.matches("__tsid =").count(), 2, "{plan_str}");
assert_eq!(
plan_str
.matches("Filter: phy.__table_id = UInt32(1024)")
.count(),
1,
"{plan_str}"
);
assert_eq!(
plan_str.matches("PromInstantManipulate").count(),
3,
"{plan_str}"
);
}
#[tokio::test]
async fn binary_island_generated_alias_avoids_user_column_names() {
let eval_stmt = build_eval_stmt("(some_metric + some_alt_metric) / some_metric");
let table_provider = build_test_table_provider_with_fields(
&[
(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string()),
(
DEFAULT_SCHEMA_NAME.to_string(),
"some_alt_metric".to_string(),
),
],
&["prom_v0", "__prom_v0"],
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let field_names = plan.schema().field_names();
assert!(field_names.iter().any(|name| name.ends_with(".prom_v0")));
assert!(field_names.iter().any(|name| name.ends_with(".__prom_v0")));
let plan_str = plan.display_indent_schema().to_string();
assert!(plan_str.contains("SubqueryAlias: __prom_v0"), "{plan_str}");
assert_eq!(
plan_str.matches("PromInstantManipulate").count(),
2,
"{plan_str}"
);
}
#[tokio::test]
async fn binary_island_clears_qualifier_for_nested_unary_projection() {
let eval_stmt = build_eval_stmt("-((some_metric + some_alt_metric) / some_metric)");
let table_provider = build_test_table_provider_with_tsid(
&[
(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string()),
(
DEFAULT_SCHEMA_NAME.to_string(),
"some_alt_metric".to_string(),
),
],
1,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert_eq!(plan_str.matches("__tsid =").count(), 1, "{plan_str}");
assert_eq!(
plan_str.matches("PromInstantManipulate").count(),
2,
"{plan_str}"
);
}
#[tokio::test]
async fn binary_island_keeps_distinct_matcher_leaves() {
let eval_stmt = build_eval_stmt(
"(some_metric{tag_0=\"foo\"} + some_alt_metric) / some_metric{tag_0=\"bar\"}",
);
let table_provider = build_test_table_provider_with_tsid(
&[
(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string()),
(
DEFAULT_SCHEMA_NAME.to_string(),
"some_alt_metric".to_string(),
),
],
1,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert_eq!(plan_str.matches("__tsid =").count(), 2, "{plan_str}");
assert_eq!(
plan_str.matches("PromInstantManipulate").count(),
3,
"{plan_str}"
);
}
#[tokio::test]
async fn binary_island_keeps_offset_leaves_distinct() {
let eval_stmt = build_eval_stmt("(some_metric offset 5m + some_alt_metric) / some_metric");
let table_provider = build_test_table_provider_with_tsid(
&[
(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string()),
(
DEFAULT_SCHEMA_NAME.to_string(),
"some_alt_metric".to_string(),
),
],
1,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert_eq!(plan_str.matches("__tsid =").count(), 2, "{plan_str}");
assert_eq!(
plan_str.matches("PromInstantManipulate").count(),
3,
"{plan_str}"
);
}
#[tokio::test]
async fn binary_island_falls_back_for_group_modifier() {
let eval_stmt =
build_eval_stmt("(some_metric + ignoring(tag_0) group_left some_alt_metric) / some_metric");
let table_provider = build_test_table_provider_with_tsid(
&[
(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string()),
(
DEFAULT_SCHEMA_NAME.to_string(),
"some_alt_metric".to_string(),
),
],
1,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert_eq!(
plan_str.matches("PromInstantManipulate").count(),
3,
"{plan_str}"
);
}
#[tokio::test]
async fn binary_island_falls_back_for_comparison_filter() {
let eval_stmt = build_eval_stmt("(some_metric > some_alt_metric) / some_metric");
let table_provider = build_test_table_provider_with_tsid(
&[
(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string()),
(
DEFAULT_SCHEMA_NAME.to_string(),
"some_alt_metric".to_string(),
),
],
1,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert_eq!(plan_str.matches("__tsid =").count(), 2, "{plan_str}");
assert_eq!(
plan_str.matches("PromInstantManipulate").count(),
3,
"{plan_str}"
);
}
#[tokio::test]
async fn tsid_binary_join_uses_shorter_field_side() {
let eval_stmt = build_eval_stmt("one_field_metric / two_field_metric");
let table_provider = build_test_table_provider_with_tsid_fields(
&[
(
(
DEFAULT_SCHEMA_NAME.to_string(),
"one_field_metric".to_string(),
),
1,
),
(
(
DEFAULT_SCHEMA_NAME.to_string(),
"two_field_metric".to_string(),
),
2,
),
],
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let field_names = plan
.schema()
.fields()
.iter()
.map(|field| field.name().clone())
.collect::<Vec<_>>();
let value_columns = field_names
.iter()
.filter(|name| {
*name != "tag_0" && *name != "timestamp" && *name != DATA_SCHEMA_TSID_COLUMN_NAME
})
.count();
assert_eq!(value_columns, 1, "{field_names:?}");
}
#[tokio::test]
async fn comparison_binary_join_uses_shorter_field_side() {
let eval_stmt = build_eval_stmt("two_field_metric > one_field_metric");
let table_provider = build_test_table_provider_with_tsid_fields(
&[
(
(
DEFAULT_SCHEMA_NAME.to_string(),
"two_field_metric".to_string(),
),
2,
),
(
(
DEFAULT_SCHEMA_NAME.to_string(),
"one_field_metric".to_string(),
),
1,
),
],
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let field_names = plan
.schema()
.fields()
.iter()
.map(|field| field.name().clone())
.collect::<Vec<_>>();
assert!(
field_names.iter().any(|name| name == "field_0"),
"{field_names:?}"
);
assert!(
!field_names.iter().any(|name| name == "field_1"),
"{field_names:?}"
);
}
#[tokio::test]
async fn label_matching_modifier_disables_tsid_binary_join() {
let eval_stmt = build_eval_stmt("some_metric / ignoring(tag_0) some_alt_metric");
let table_provider = build_test_table_provider_with_tsid(
&[
(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string()),
(
DEFAULT_SCHEMA_NAME.to_string(),
"some_alt_metric".to_string(),
),
],
2,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert!(!plan_str.contains("__tsid ="), "{plan_str}");
assert!(
plan_str.contains("some_metric.tag_1 = some_alt_metric.tag_1"),
"{plan_str}"
);
}
#[tokio::test]
async fn ignoring_absent_label_keeps_tsid_binary_join() {
let eval_stmt = build_eval_stmt("some_metric / ignoring(missing) some_alt_metric");
let table_provider = build_test_table_provider_with_tsid(
&[
(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string()),
(
DEFAULT_SCHEMA_NAME.to_string(),
"some_alt_metric".to_string(),
),
],
2,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert!(
plan_str.contains("some_metric.__tsid = some_alt_metric.__tsid"),
"{plan_str}"
);
assert!(!plan_str.contains("tag_0 ="), "{plan_str}");
assert!(!plan_str.contains("tag_1 ="), "{plan_str}");
}
#[tokio::test]
async fn range_function_keeps_tsid_for_absent_ignoring_binary_join() {
let eval_stmt = build_eval_stmt("rate(some_metric[5m]) / ignoring(missing) some_alt_metric");
let table_provider = build_test_table_provider_with_tsid(
&[
(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string()),
(
DEFAULT_SCHEMA_NAME.to_string(),
"some_alt_metric".to_string(),
),
],
2,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert!(
plan_str.contains("some_metric.__tsid = some_alt_metric.__tsid"),
"{plan_str}"
);
assert!(!plan_str.contains("tag_0 ="), "{plan_str}");
assert!(!plan_str.contains("tag_1 ="), "{plan_str}");
}
#[tokio::test]
async fn on_full_label_set_keeps_tsid_binary_join() {
let eval_stmt = build_eval_stmt("some_metric / on(tag_0, tag_1) some_alt_metric");
let table_provider = build_test_table_provider_with_tsid(
&[
(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string()),
(
DEFAULT_SCHEMA_NAME.to_string(),
"some_alt_metric".to_string(),
),
],
2,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert!(
plan_str.contains("some_metric.__tsid = some_alt_metric.__tsid"),
"{plan_str}"
);
assert!(!plan_str.contains("tag_0 ="), "{plan_str}");
assert!(!plan_str.contains("tag_1 ="), "{plan_str}");
}
#[tokio::test]
async fn on_partial_label_set_disables_tsid_binary_join() {
let eval_stmt = build_eval_stmt("some_metric / on(tag_0) some_alt_metric");
let table_provider = build_test_table_provider_with_tsid(
&[
(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string()),
(
DEFAULT_SCHEMA_NAME.to_string(),
"some_alt_metric".to_string(),
),
],
2,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert!(!plan_str.contains("__tsid ="), "{plan_str}");
assert!(
plan_str.contains("some_metric.tag_0 = some_alt_metric.tag_0"),
"{plan_str}"
);
assert!(!plan_str.contains("tag_1 ="), "{plan_str}");
}
#[tokio::test]
async fn on_label_set_must_cover_both_sides_to_use_tsid_binary_join() {
let eval_stmt = build_eval_stmt("some_metric / on(tag_0) some_alt_metric");
let table_provider = build_test_table_provider_with_tsid_tag_fields(&[
(
(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string()),
2,
1,
),
(
(
DEFAULT_SCHEMA_NAME.to_string(),
"some_alt_metric".to_string(),
),
1,
1,
),
])
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert!(!plan_str.contains("__tsid ="), "{plan_str}");
assert!(
plan_str.contains("some_metric.tag_0 = some_alt_metric.tag_0"),
"{plan_str}"
);
assert!(!plan_str.contains("tag_1 ="), "{plan_str}");
}
#[tokio::test]
async fn comparison_binary_join_uses_tsid_and_keeps_it_in_filtered_result() {
let eval_stmt = build_eval_stmt("some_metric > some_alt_metric");
let table_provider = build_test_table_provider_with_tsid(
&[
(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string()),
(
DEFAULT_SCHEMA_NAME.to_string(),
"some_alt_metric".to_string(),
),
],
2,
1,
)
.await;
let mut planner = PromPlanner {
table_provider,
ctx: PromPlannerContext::from_eval_stmt(&eval_stmt),
promql_annotations: None,
};
let plan = planner
.prom_expr_to_plan(&eval_stmt.expr, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert!(
plan_str.contains("some_metric.__tsid = some_alt_metric.__tsid"),
"{plan_str}"
);
assert!(
plan.schema()
.fields()
.iter()
.any(|field| field.name() == DATA_SCHEMA_TSID_COLUMN_NAME),
"{plan_str}"
);
assert!(planner.ctx.use_tsid, "{plan_str}");
}
#[tokio::test]
async fn comparison_bool_binary_join_uses_tsid_when_available() {
let eval_stmt = build_eval_stmt("some_metric > bool some_alt_metric");
let table_provider = build_test_table_provider_with_tsid(
&[
(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string()),
(
DEFAULT_SCHEMA_NAME.to_string(),
"some_alt_metric".to_string(),
),
],
2,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert!(
plan_str.contains("some_metric.__tsid = some_alt_metric.__tsid"),
"{plan_str}"
);
assert!(!plan_str.contains("tag_0 ="), "{plan_str}");
assert!(!plan_str.contains("tag_1 ="), "{plan_str}");
}
#[tokio::test]
async fn scalar_count_count_range_keeps_full_window() {
let plan_str = build_optimized_tsid_plan(
"scalar(count(count(some_metric) by (tag_0)))",
1,
1,
100_000,
1,
)
.await;
assert!(plan_str.contains("ScalarCalculate: tags=[]"));
assert!(plan_str.contains("PromInstantManipulate: range=[0..100000000]"));
assert!(!plan_str.contains("PromInstantManipulate: range=[99999000..99999000]"));
}
#[tokio::test]
async fn scalar_count_count_rewrite_applies_inside_binary_expr_for_tsid_input() {
let plan_str = build_optimized_tsid_plan(
"sum(irate(some_metric[1h])) / scalar(count(count(some_metric) by (tag_0)))",
2,
1,
10,
300,
)
.await;
assert!(plan_str.contains("Distinct:"), "{plan_str}");
}
#[tokio::test]
async fn nested_count_rewrite_keeps_full_series_key_with_tsid_input() {
assert_nested_count_rewrite_applies(
"count(count(some_metric) by (tag_0))",
"Aggregate: groupBy=[[some_metric.timestamp]], aggr=[[count(Int64(1)) AS count(count(some_metric.field_0))]]"
)
.await;
}
#[tokio::test]
async fn nested_sum_count_rewrite_keeps_full_series_key_with_tsid_input() {
assert_nested_count_rewrite_applies(
"count(sum(some_metric) by (tag_0))",
"Aggregate: groupBy=[[some_metric.timestamp]], aggr=[[count(Int64(1)) AS count(sum(some_metric.field_0))]]"
)
.await;
}
#[tokio::test]
async fn nested_supported_inner_aggs_rewrite_apply_for_tsid_input() {
for (query, expected_outer_agg) in [
(
"count(avg(some_metric) by (tag_0))",
"Aggregate: groupBy=[[some_metric.timestamp]], aggr=[[count(Int64(1)) AS count(avg(some_metric.field_0))]]",
),
(
"count(min(some_metric) by (tag_0))",
"Aggregate: groupBy=[[some_metric.timestamp]], aggr=[[count(Int64(1)) AS count(min(some_metric.field_0))]]",
),
(
"count(max(some_metric) by (tag_0))",
"Aggregate: groupBy=[[some_metric.timestamp]], aggr=[[count(Int64(1)) AS count(max(some_metric.field_0))]]",
),
(
"count(stddev(some_metric) by (tag_0))",
"Aggregate: groupBy=[[some_metric.timestamp]], aggr=[[count(Int64(1)) AS count(stddev_pop(some_metric.field_0))]]",
),
(
"count(stdvar(some_metric) by (tag_0))",
"Aggregate: groupBy=[[some_metric.timestamp]], aggr=[[count(Int64(1)) AS count(var_pop(some_metric.field_0))]]",
),
] {
assert_nested_count_rewrite_applies(query, expected_outer_agg).await;
}
}
#[tokio::test]
async fn nested_non_count_inner_aggs_rewrite_filter_null_values_for_tsid_input() {
let count_plan =
build_optimized_tsid_plan("count(count(some_metric) by (tag_0))", 2, 1, 100_000, 1).await;
assert!(
!count_plan.contains("some_metric.field_0 IS NOT NULL"),
"{count_plan}"
);
for query in [
"count(sum(some_metric) by (tag_0))",
"count(avg(some_metric) by (tag_0))",
"count(min(some_metric) by (tag_0))",
"count(max(some_metric) by (tag_0))",
"count(stddev(some_metric) by (tag_0))",
"count(stdvar(some_metric) by (tag_0))",
] {
let plan_str = build_optimized_tsid_plan(query, 2, 1, 100_000, 1).await;
assert!(
plan_str.contains("Filter: some_metric.field_0 IS NOT NULL"),
"{query}: {plan_str}"
);
}
}
#[tokio::test]
async fn nested_unsupported_or_non_direct_inner_aggs_do_not_rewrite() {
assert_nested_count_rewrite_missing("count(group(some_metric) by (tag_0))", 2, 1).await;
assert_nested_count_rewrite_missing("count(sum(irate(some_metric[1h])) by (tag_0))", 2, 300)
.await;
}
#[tokio::test]
async fn physical_table_name_is_not_leaked_in_plan() {
let prom_expr = parser::parse("some_metric").unwrap();
let eval_stmt = EvalStmt {
expr: prom_expr,
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let table_provider = build_test_table_provider_with_tsid(
&[(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string())],
1,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert!(plan_str.contains("TableScan: phy"), "{plan}");
assert!(plan_str.contains("SubqueryAlias: some_metric"));
assert!(plan_str.contains("Filter: phy.__table_id = UInt32(1024)"));
assert!(!plan_str.contains("TableScan: some_metric"));
}
#[tokio::test]
async fn sum_without_does_not_group_by_tsid() {
let prom_expr = parser::parse("sum without (tag_0) (some_metric)").unwrap();
let eval_stmt = EvalStmt {
expr: prom_expr,
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let table_provider = build_test_table_provider_with_tsid(
&[(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string())],
1,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert!(plan_str.contains("PromSeriesDivide: tags=[\"__tsid\"]"));
let aggr_line = plan_str
.lines()
.find(|line| line.contains("Aggregate: groupBy="))
.unwrap();
assert!(!aggr_line.contains(DATA_SCHEMA_TSID_COLUMN_NAME));
}
#[tokio::test]
async fn topk_without_does_not_partition_by_tsid() {
let prom_expr = parser::parse("topk without (tag_0) (1, some_metric)").unwrap();
let eval_stmt = EvalStmt {
expr: prom_expr,
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let table_provider = build_test_table_provider_with_tsid(
&[(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string())],
1,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert!(plan_str.contains("PromSeriesDivide: tags=[\"__tsid\"]"));
let window_line = plan_str
.lines()
.find(|line| line.contains("WindowAggr: windowExpr=[[row_number()"))
.unwrap();
let partition_by = window_line
.split("PARTITION BY [")
.nth(1)
.and_then(|s| s.split("] ORDER BY").next())
.unwrap();
assert!(!partition_by.contains(DATA_SCHEMA_TSID_COLUMN_NAME));
}
#[tokio::test]
async fn sum_by_does_not_group_by_tsid() {
let prom_expr = parser::parse("sum by (__tsid) (some_metric)").unwrap();
let eval_stmt = EvalStmt {
expr: prom_expr,
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let table_provider = build_test_table_provider_with_tsid(
&[(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string())],
1,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert!(plan_str.contains("PromSeriesDivide: tags=[\"__tsid\"]"));
let aggr_line = plan_str
.lines()
.find(|line| line.contains("Aggregate: groupBy="))
.unwrap();
assert!(!aggr_line.contains(DATA_SCHEMA_TSID_COLUMN_NAME));
}
#[tokio::test]
async fn aggregate_over_binary_time_function_expr() {
for op in ["sum", "min", "max", "avg"] {
let prom_expr = parser::parse(&format!(
"{op} by (tag_0, tag_1, tag_2) (time() - some_metric)"
))
.unwrap();
let eval_stmt = EvalStmt {
expr: prom_expr,
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let table_provider = build_test_table_provider_with_tsid(
&[(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string())],
3,
1,
)
.await;
let plan =
PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
let aggr_line = plan_str
.lines()
.find(|line| line.contains("Aggregate: groupBy="))
.unwrap();
assert!(aggr_line.contains(op), "{plan_str}");
assert!(aggr_line.contains("first_value"), "{plan_str}");
assert!(
!plan
.schema()
.fields()
.iter()
.any(|field| { field.name() == DATA_SCHEMA_TSID_COLUMN_NAME })
);
}
}
#[tokio::test]
async fn topk_by_does_not_partition_by_tsid() {
let prom_expr = parser::parse("topk by (__tsid) (1, some_metric)").unwrap();
let eval_stmt = EvalStmt {
expr: prom_expr,
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let table_provider = build_test_table_provider_with_tsid(
&[(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string())],
1,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert!(plan_str.contains("PromSeriesDivide: tags=[\"__tsid\"]"));
let window_line = plan_str
.lines()
.find(|line| line.contains("WindowAggr: windowExpr=[[row_number()"))
.unwrap();
let partition_by = window_line
.split("PARTITION BY [")
.nth(1)
.and_then(|s| s.split("] ORDER BY").next())
.unwrap();
assert!(!partition_by.contains(DATA_SCHEMA_TSID_COLUMN_NAME));
}
#[tokio::test]
async fn selector_matcher_on_tsid_does_not_use_internal_column() {
let prom_expr = parser::parse(r#"some_metric{__tsid="123"}"#).unwrap();
let eval_stmt = EvalStmt {
expr: prom_expr,
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let table_provider = build_test_table_provider_with_tsid(
&[(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string())],
1,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
fn collect_filter_cols(plan: &LogicalPlan, out: &mut HashSet<Column>) {
if let LogicalPlan::Filter(filter) = plan {
datafusion_expr::utils::expr_to_columns(&filter.predicate, out).unwrap();
}
for input in plan.inputs() {
collect_filter_cols(input, out);
}
}
let mut filter_cols = HashSet::new();
collect_filter_cols(&plan, &mut filter_cols);
assert!(
!filter_cols
.iter()
.any(|c| c.name == DATA_SCHEMA_TSID_COLUMN_NAME)
);
}
#[tokio::test]
async fn tsid_is_not_used_when_physical_table_is_missing() {
let prom_expr = parser::parse("some_metric").unwrap();
let eval_stmt = EvalStmt {
expr: prom_expr,
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let catalog_list = MemoryCatalogManager::with_default_setup();
// Register a metric engine logical table referencing a missing physical table.
let mut columns = vec![ColumnSchema::new(
"tag_0".to_string(),
ConcreteDataType::string_datatype(),
false,
)];
columns.push(
ColumnSchema::new(
"timestamp".to_string(),
ConcreteDataType::timestamp_millisecond_datatype(),
false,
)
.with_time_index(true),
);
columns.push(ColumnSchema::new(
"field_0".to_string(),
ConcreteDataType::float64_datatype(),
true,
));
let schema = Arc::new(Schema::new(columns));
let mut options = table::requests::TableOptions::default();
options
.extra_options
.insert(LOGICAL_TABLE_METADATA_KEY.to_string(), "phy".to_string());
let table_meta = TableMetaBuilder::empty()
.schema(schema)
.primary_key_indices(vec![0])
.value_indices(vec![2])
.engine(METRIC_ENGINE_NAME.to_string())
.options(options)
.next_column_id(1024)
.build()
.unwrap();
let table_info = TableInfoBuilder::default()
.table_id(1024)
.name("some_metric")
.meta(table_meta)
.build()
.unwrap();
let table = EmptyTable::from_table_info(&table_info);
catalog_list
.register_table_sync(RegisterTableRequest {
catalog: DEFAULT_CATALOG_NAME.to_string(),
schema: DEFAULT_SCHEMA_NAME.to_string(),
table_name: "some_metric".to_string(),
table_id: 1024,
table,
})
.unwrap();
let table_provider = DfTableSourceProvider::new(
catalog_list,
false,
QueryContext::arc(),
DummyDecoder::arc(),
false,
);
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert!(plan_str.contains("PromSeriesDivide: tags=[\"tag_0\"]"));
assert!(!plan_str.contains("PromSeriesDivide: tags=[\"__tsid\"]"));
}
#[tokio::test]
async fn tsid_is_carried_only_when_aggregate_preserves_label_set() {
let prom_expr = parser::parse("sum by (tag_0) (some_metric)").unwrap();
let eval_stmt = EvalStmt {
expr: prom_expr,
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let table_provider = build_test_table_provider_with_tsid(
&[(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string())],
1,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert!(plan_str.contains("first_value") && plan_str.contains("__tsid"));
assert!(
!plan
.schema()
.fields()
.iter()
.any(|field| field.name() == DATA_SCHEMA_TSID_COLUMN_NAME)
);
// Merging aggregate: label set is reduced, tsid should not be carried.
let prom_expr = parser::parse("sum(some_metric)").unwrap();
let eval_stmt = EvalStmt {
expr: prom_expr,
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let table_provider = build_test_table_provider_with_tsid(
&[(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string())],
1,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert!(!plan_str.contains("first_value"));
}
#[tokio::test]
async fn or_operator_with_unknown_metric_does_not_require_tsid() {
let prom_expr = parser::parse("unknown_metric or some_metric").unwrap();
let eval_stmt = EvalStmt {
expr: prom_expr,
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let table_provider = build_test_table_provider_with_tsid(
&[(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string())],
1,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
assert!(
!plan
.schema()
.fields()
.iter()
.any(|field| field.name() == DATA_SCHEMA_TSID_COLUMN_NAME)
);
}
#[tokio::test]
async fn aggregate_avg() {
do_aggregate_expr_plan("avg", "avg").await;
}
#[tokio::test]
#[should_panic] // output type doesn't match
async fn aggregate_count() {
do_aggregate_expr_plan("count", "count").await;
}
#[tokio::test]
async fn aggregate_min() {
do_aggregate_expr_plan("min", "min").await;
}
#[tokio::test]
async fn aggregate_max() {
do_aggregate_expr_plan("max", "max").await;
}
#[tokio::test]
async fn aggregate_group() {
// Regression test for `group()` aggregator.
// PromQL: sum(group by (cluster)(kubernetes_build_info{service="kubernetes",job="apiserver"}))
// should be plannable, and `group()` should produce constant 1 for each group.
let prom_expr = parser::parse(
"sum(group by (cluster)(kubernetes_build_info{service=\"kubernetes\",job=\"apiserver\"}))",
)
.unwrap();
let eval_stmt = EvalStmt {
expr: prom_expr,
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let table_provider = build_test_table_provider_with_fields(
&[(
DEFAULT_SCHEMA_NAME.to_string(),
"kubernetes_build_info".to_string(),
)],
&["cluster", "service", "job"],
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert!(plan_str.contains("max(Float64(1"));
}
#[tokio::test]
async fn aggregate_stddev() {
do_aggregate_expr_plan("stddev", "stddev_pop").await;
}
#[tokio::test]
async fn aggregate_stdvar() {
do_aggregate_expr_plan("stdvar", "var_pop").await;
}
// TODO(ruihang): add range fn tests once exprs are ready.
// {
// input: "some_metric{tag_0="foo"} + some_metric{tag_0="bar"}",
// expected: &BinaryExpr{
// Op: ADD,
// LHS: &VectorSelector{
// Name: "a",
// LabelMatchers: []*labels.Matcher{
// MustLabelMatcher(labels.MatchEqual, "tag_0", "foo"),
// MustLabelMatcher(labels.MatchEqual, model.MetricNameLabel, "some_metric"),
// },
// },
// RHS: &VectorSelector{
// Name: "sum",
// LabelMatchers: []*labels.Matcher{
// MustLabelMatcher(labels.MatchxEqual, "tag_0", "bar"),
// MustLabelMatcher(labels.MatchEqual, model.MetricNameLabel, "some_metric"),
// },
// },
// VectorMatching: &VectorMatching{},
// },
// },
#[tokio::test]
async fn binary_op_column_column() {
let prom_expr =
parser::parse(r#"some_metric{tag_0="foo"} + some_metric{tag_0="bar"}"#).unwrap();
let eval_stmt = EvalStmt {
expr: prom_expr,
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let table_provider = build_test_table_provider(
&[(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string())],
1,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let expected = String::from(
"Projection: rhs.tag_0, rhs.timestamp, CAST(lhs.field_0 AS Float64) + CAST(rhs.field_0 AS Float64) AS lhs.field_0 + rhs.field_0 [tag_0:Utf8, timestamp:Timestamp(ms), lhs.field_0 + rhs.field_0:Float64;N]\
\n Inner Join: lhs.tag_0 = rhs.tag_0, lhs.timestamp = rhs.timestamp [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N, tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n SubqueryAlias: lhs [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n PromInstantManipulate: range=[0..100000000], lookback=[1000], interval=[5000], time index=[timestamp] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n PromSeriesDivide: tags=[\"tag_0\"] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Sort: some_metric.tag_0 ASC NULLS FIRST, some_metric.timestamp ASC NULLS FIRST [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Filter: some_metric.tag_0 = Utf8(\"foo\") AND some_metric.tag_0 = Utf8(\"bar\") AND some_metric.timestamp >= TimestampMillisecond(-999, None) AND some_metric.timestamp <= TimestampMillisecond(100000000, None) [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n TableScan: some_metric [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n SubqueryAlias: rhs [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n PromInstantManipulate: range=[0..100000000], lookback=[1000], interval=[5000], time index=[timestamp] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n PromSeriesDivide: tags=[\"tag_0\"] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Sort: some_metric.tag_0 ASC NULLS FIRST, some_metric.timestamp ASC NULLS FIRST [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Filter: some_metric.tag_0 = Utf8(\"bar\") AND some_metric.tag_0 = Utf8(\"foo\") AND some_metric.timestamp >= TimestampMillisecond(-999, None) AND some_metric.timestamp <= TimestampMillisecond(100000000, None) [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n TableScan: some_metric [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]",
);
assert_eq!(plan.display_indent_schema().to_string(), expected);
}
async fn indie_query_plan(query: &str) -> String {
let prom_expr = parser::parse(query).unwrap();
let eval_stmt = EvalStmt {
expr: prom_expr,
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let table_provider = build_test_table_provider(
&[
(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string()),
(
"greptime_private".to_string(),
"some_alt_metric".to_string(),
),
],
1,
1,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
plan.display_indent_schema().to_string()
}
async fn indie_query_plan_compare<T: AsRef<str>>(query: &str, expected: T) {
let plan = indie_query_plan(query).await;
assert_eq!(plan, expected.as_ref());
}
#[tokio::test]
async fn binary_op_literal_column() {
let query = r#"1 + some_metric{tag_0="bar"}"#;
let expected = String::from(
"Projection: some_metric.tag_0, some_metric.timestamp, Float64(1) + CAST(some_metric.field_0 AS Float64) AS Float64(1) + field_0 [tag_0:Utf8, timestamp:Timestamp(ms), Float64(1) + field_0:Float64;N]\
\n PromInstantManipulate: range=[0..100000000], lookback=[1000], interval=[5000], time index=[timestamp] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n PromSeriesDivide: tags=[\"tag_0\"] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Sort: some_metric.tag_0 ASC NULLS FIRST, some_metric.timestamp ASC NULLS FIRST [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Filter: some_metric.tag_0 = Utf8(\"bar\") AND some_metric.timestamp >= TimestampMillisecond(-999, None) AND some_metric.timestamp <= TimestampMillisecond(100000000, None) [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n TableScan: some_metric [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]",
);
indie_query_plan_compare(query, expected).await;
}
#[tokio::test]
async fn binary_op_literal_literal() {
let query = r#"1 + 1"#;
let expected = r#"EmptyMetric: range=[0..100000000], interval=[5000] [time:Timestamp(ms), value:Float64;N]
TableScan: dummy [time:Timestamp(ms), value:Float64;N]"#;
indie_query_plan_compare(query, expected).await;
}
#[tokio::test]
async fn simple_bool_grammar() {
let query = "some_metric != bool 1.2345";
let expected = String::from(
"Projection: some_metric.tag_0, some_metric.timestamp, CAST(some_metric.field_0 != Float64(1.2345) AS Float64) AS field_0 != Float64(1.2345) [tag_0:Utf8, timestamp:Timestamp(ms), field_0 != Float64(1.2345):Float64;N]\
\n PromInstantManipulate: range=[0..100000000], lookback=[1000], interval=[5000], time index=[timestamp] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n PromSeriesDivide: tags=[\"tag_0\"] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Sort: some_metric.tag_0 ASC NULLS FIRST, some_metric.timestamp ASC NULLS FIRST [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Filter: some_metric.timestamp >= TimestampMillisecond(-999, None) AND some_metric.timestamp <= TimestampMillisecond(100000000, None) [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n TableScan: some_metric [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]",
);
indie_query_plan_compare(query, expected).await;
}
#[tokio::test]
async fn bool_with_additional_arithmetic() {
let query = "some_metric + (1 == bool 2)";
let expected = String::from(
"Projection: some_metric.tag_0, some_metric.timestamp, CAST(some_metric.field_0 AS Float64) + CAST(Float64(1) = Float64(2) AS Float64) AS field_0 + Float64(1) = Float64(2) [tag_0:Utf8, timestamp:Timestamp(ms), field_0 + Float64(1) = Float64(2):Float64;N]\
\n PromInstantManipulate: range=[0..100000000], lookback=[1000], interval=[5000], time index=[timestamp] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n PromSeriesDivide: tags=[\"tag_0\"] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Sort: some_metric.tag_0 ASC NULLS FIRST, some_metric.timestamp ASC NULLS FIRST [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Filter: some_metric.timestamp >= TimestampMillisecond(-999, None) AND some_metric.timestamp <= TimestampMillisecond(100000000, None) [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n TableScan: some_metric [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]",
);
indie_query_plan_compare(query, expected).await;
}
#[tokio::test]
async fn simple_unary() {
let query = "-some_metric";
let expected = String::from(
"Projection: some_metric.tag_0, some_metric.timestamp, (- some_metric.field_0) AS (- field_0) [tag_0:Utf8, timestamp:Timestamp(ms), (- field_0):Float64;N]\
\n PromInstantManipulate: range=[0..100000000], lookback=[1000], interval=[5000], time index=[timestamp] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n PromSeriesDivide: tags=[\"tag_0\"] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Sort: some_metric.tag_0 ASC NULLS FIRST, some_metric.timestamp ASC NULLS FIRST [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Filter: some_metric.timestamp >= TimestampMillisecond(-999, None) AND some_metric.timestamp <= TimestampMillisecond(100000000, None) [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n TableScan: some_metric [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]",
);
indie_query_plan_compare(query, expected).await;
}
#[tokio::test]
async fn increase_aggr() {
let query = "increase(some_metric[5m])";
let expected = String::from(
"Filter: prom_increase(timestamp_range,field_0,timestamp,Int64(300000)) IS NOT NULL [timestamp:Timestamp(ms), prom_increase(timestamp_range,field_0,timestamp,Int64(300000)):Float64;N, tag_0:Utf8]\
\n Projection: some_metric.timestamp, prom_increase(timestamp_range, field_0, some_metric.timestamp, Int64(300000)) AS prom_increase(timestamp_range,field_0,timestamp,Int64(300000)), some_metric.tag_0 [timestamp:Timestamp(ms), prom_increase(timestamp_range,field_0,timestamp,Int64(300000)):Float64;N, tag_0:Utf8]\
\n PromRangeManipulate: req range=[0..100000000], interval=[5000], eval range=[300000], time index=[timestamp], values=[\"field_0\"] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Dictionary(Int64, Float64);N, timestamp_range:Dictionary(Int64, Timestamp(ms))]\
\n PromSeriesNormalize: offset=[0], time index=[timestamp], filter NaN: [true] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n PromSeriesDivide: tags=[\"tag_0\"] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Sort: some_metric.tag_0 ASC NULLS FIRST, some_metric.timestamp ASC NULLS FIRST [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Filter: some_metric.timestamp >= TimestampMillisecond(-299999, None) AND some_metric.timestamp <= TimestampMillisecond(100000000, None) [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n TableScan: some_metric [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]",
);
indie_query_plan_compare(query, expected).await;
}
#[tokio::test]
async fn predict_linear_injects_the_eval_timestamp() {
// The fourth argument is the evaluation instant of each row, which the fold offset
// recovers from the row's time index (here: no `@` and no `offset`, so the step itself).
let query = "predict_linear(some_metric[5m], 60)";
let expected = String::from(
"Filter: prom_predict_linear(timestamp_range,field_0,Float64(60)) IS NOT NULL [timestamp:Timestamp(ms), prom_predict_linear(timestamp_range,field_0,Float64(60)):Float64;N, tag_0:Utf8]\
\n Projection: some_metric.timestamp, prom_predict_linear(timestamp_range, field_0, CAST(Float64(60) AS Int64), CAST(CAST(some_metric.timestamp AS Int64) + Int64(0) AS Timestamp(ms))) AS prom_predict_linear(timestamp_range,field_0,Float64(60)), some_metric.tag_0 [timestamp:Timestamp(ms), prom_predict_linear(timestamp_range,field_0,Float64(60)):Float64;N, tag_0:Utf8]\
\n PromRangeManipulate: req range=[0..100000000], interval=[5000], eval range=[300000], time index=[timestamp], values=[\"field_0\"] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Dictionary(Int64, Float64);N, timestamp_range:Dictionary(Int64, Timestamp(ms))]\
\n PromSeriesNormalize: offset=[0], time index=[timestamp], filter NaN: [true] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n PromSeriesDivide: tags=[\"tag_0\"] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Sort: some_metric.tag_0 ASC NULLS FIRST, some_metric.timestamp ASC NULLS FIRST [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Filter: some_metric.timestamp >= TimestampMillisecond(-299999, None) AND some_metric.timestamp <= TimestampMillisecond(100000000, None) [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n TableScan: some_metric [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]",
);
indie_query_plan_compare(query, expected).await;
}
/// The evaluation instant follows the selector's fold offset, not the projection's: an
/// `@`-anchored selector is folded with `at_offset`, which is what the window's timestamps were
/// shifted by. The evaluation starts at 0s here, so `@ 100` anchors 100s in the future and
/// yields `at_offset` = -100000ms.
#[tokio::test]
async fn predict_linear_eval_ts_follows_the_fold_offset() {
for (query, expected_offset) in [
(
"predict_linear(some_metric[5m] @ 100, 60)",
"Int64(-100000)",
),
(
"predict_linear(some_metric[5m] offset 2m, 60)",
"Int64(120000)",
),
] {
let plan = indie_query_plan(query).await;
assert!(
plan.contains(&format!(
"some_metric.timestamp AS Int64) + {expected_offset}"
)),
"{query}\n{plan}"
);
}
}
async fn native_histogram_plan(query: &str) -> String {
let table_provider = build_test_native_histogram_table_provider("some_metric").await;
let plan = PromPlanner::stmt_to_plan(
table_provider,
&build_eval_stmt(query),
&build_query_engine_state(),
)
.await
.unwrap();
plan.display_indent_schema().to_string()
}
#[tokio::test]
async fn native_histogram_count_uses_native_udf() {
let plan = native_histogram_plan("histogram_count(some_metric)").await;
assert!(plan.contains("prom_native_histogram_count"), "{plan}");
assert!(!plan.contains("HistogramFold:"), "{plan}");
}
#[tokio::test]
async fn timestamp_filters_native_histogram_stale_marker_before_projection() {
let mut stale = direct_or_histogram();
stale.sum = f64::from_bits(PROMETHEUS_STALE_NAN_BITS);
let table = operator_metric_table(
"stale_histogram",
2_100,
"a",
None,
DirectOrValue::NativeHistogram(stale),
);
let catalog = MemoryCatalogManager::with_default_setup();
catalog
.register_table_sync(RegisterTableRequest {
catalog: DEFAULT_CATALOG_NAME.to_string(),
schema: DEFAULT_SCHEMA_NAME.to_string(),
table_name: "stale_histogram".to_string(),
table_id: 2_100,
table,
})
.unwrap();
let provider = DfTableSourceProvider::new(
catalog,
false,
QueryContext::arc(),
DummyDecoder::arc(),
false,
);
let state = build_query_engine_state();
let plan = PromPlanner::stmt_to_plan(
provider,
&operator_eval_stmt("timestamp(stale_histogram)"),
&state,
)
.await
.unwrap();
let plan_text = plan.display_indent_schema().to_string();
assert!(plan_text.contains(TIMESTAMP_VALUE_PREFIX), "{plan_text}");
let (_, batches) = execute(plan, &state).await;
assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 0);
}
#[tokio::test]
async fn timestamp_filters_stale_marker_from_mixed_sample_companion() {
let histograms = build_histogram_array(&[None]);
let schema = Arc::new(ArrowSchema::new(vec![
Field::new(
"timestamp",
ArrowDataType::Timestamp(ArrowTimeUnit::Millisecond, None),
false,
),
Field::new(
greptime_native_histogram(),
histograms.data_type().clone(),
true,
),
Field::new(greptime_value(), ArrowDataType::Float64, true),
]));
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(TimestampMillisecondArray::from(vec![1_000])),
histograms,
Arc::new(Float64Array::from(vec![f64::from_bits(
PROMETHEUS_STALE_NAN_BITS,
)])),
],
)
.unwrap();
let table = Arc::new(MemTable::try_new(schema, vec![vec![batch]]).unwrap());
let input = LogicalPlanBuilder::scan("mixed", provider_as_source(table), None)
.unwrap()
.build()
.unwrap();
let input = LogicalPlan::Extension(Extension {
node: Arc::new(SeriesDivide::new(
Vec::new(),
"timestamp".to_string(),
input,
)),
});
let input = LogicalPlan::Extension(Extension {
node: Arc::new(InstantManipulate::new(
1_000,
1_000,
5_000,
1_000,
0,
"timestamp".to_string(),
Vec::new(),
Some(greptime_native_histogram().to_string()),
input,
)),
});
// Match timestamp()'s parent projection, which otherwise prunes the companion lane.
let plan = LogicalPlanBuilder::from(input)
.project([col("timestamp")])
.unwrap()
.build()
.unwrap();
let (_, batches) = execute(plan, &build_query_engine_state()).await;
assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 0);
}
#[tokio::test]
async fn native_histogram_rate_can_feed_count() {
let plan = native_histogram_plan("histogram_count(rate(some_metric[5m]))").await;
assert!(plan.contains("prom_native_histogram_rate"), "{plan}");
assert!(plan.contains("prom_native_histogram_count"), "{plan}");
}
#[tokio::test]
async fn native_histogram_quantile_skips_classic_fold() {
let plan = native_histogram_plan("histogram_quantile(0.9, some_metric)").await;
assert!(plan.contains("prom_native_histogram_quantile"), "{plan}");
assert!(!plan.contains("HistogramFold:"), "{plan}");
assert!(plan.contains("some_metric.le"), "{plan}");
// The phi literal is threaded into the native quantile UDF as its second argument.
assert!(plan.contains("Float64(0.9)"), "{plan}");
// The empty-values filter drops NULL quantile results so the output is empty
// when all native histogram samples are dropped.
assert!(plan.contains("IS NOT NULL"), "{plan}");
}
#[tokio::test]
async fn mixed_native_histogram_quantile_uses_histogram_field() {
let table_provider = build_test_mixed_native_histogram_table_provider("some_metric").await;
let plan = PromPlanner::stmt_to_plan(
table_provider,
&build_eval_stmt("histogram_quantile(0.9, some_metric)"),
&build_query_engine_state(),
)
.await
.unwrap()
.display_indent_schema()
.to_string();
assert!(
plan.contains("prom_native_histogram_quantile(greptime_native_histogram"),
"{plan}"
);
assert!(!plan.contains("EmptyRelation"), "{plan}");
}
#[tokio::test]
async fn mixed_histogram_helpers_execute_classic_and_native_samples() {
let state = build_query_engine_state();
for (query, expected) in [
(
"histogram_quantile(0.5, mixed_histogram)",
vec![("classic", 1.0), ("native", 0.0)],
),
(
"histogram_fraction(-Inf, +Inf, mixed_histogram)",
vec![("classic", 1.0), ("native", 1.0)],
),
] {
let plan = PromPlanner::stmt_to_plan(
classic_and_native_histogram_table_provider("native", None, direct_or_histogram()),
&operator_eval_stmt(query),
&state,
)
.await
.unwrap();
let plan_text = plan.display_indent_schema().to_string();
assert!(plan_text.contains("HistogramFold:"), "{plan_text}");
assert!(plan_text.contains("prom_native_histogram_"), "{plan_text}");
let value_field = plan
.schema()
.fields()
.iter()
.find(|field| field.data_type() == &ArrowDataType::Float64)
.unwrap()
.name()
.clone();
let (_, batches) = execute(plan, &state).await;
let mut actual = batches
.iter()
.flat_map(|batch| {
let tags = batch
.column_by_name("tag")
.unwrap()
.as_any()
.downcast_ref::<StringArray>()
.unwrap();
let values = batch
.column_by_name(&value_field)
.unwrap()
.as_any()
.downcast_ref::<Float64Array>()
.unwrap();
(0..batch.num_rows()).map(|row| (tags.value(row), values.value(row)))
})
.collect::<Vec<_>>();
actual.sort_by_key(|(tag, _)| *tag);
assert_eq!(actual, expected, "{query}");
}
}
#[tokio::test]
async fn mixed_histogram_helpers_report_annotations() {
let state = build_query_engine_state();
let mut native_histogram = direct_or_histogram();
native_histogram.count = 2.0;
native_histogram.sum = f64::NAN;
for (native_tag, expected_rows, expected_warnings, expected_infos) in [
(
"classic",
0,
vec!["vector contains a mix of classic and native histograms"],
vec![],
),
(
"native",
2,
vec![],
vec!["input to histogram_quantile has NaN observations, result is skewed higher"],
),
] {
let collector = PromqlAnnotationCollector::default();
let plan = PromPlanner::stmt_to_plan_with_annotations(
classic_and_native_histogram_table_provider(native_tag, None, native_histogram.clone()),
&operator_eval_stmt("histogram_quantile(0.5, mixed_histogram)"),
&state,
Some(collector.clone()),
)
.await
.unwrap();
let (_, batches) = execute(plan, &state).await;
assert_eq!(
batches.iter().map(RecordBatch::num_rows).sum::<usize>(),
expected_rows
);
let mut warnings = vec![];
let mut infos = vec![];
collector.append_to(&mut warnings, &mut infos);
assert_eq!(warnings, expected_warnings);
assert_eq!(infos, expected_infos);
}
}
#[tokio::test]
async fn mixed_histogram_helper_preserves_native_le_and_scans_once() {
let state = build_query_engine_state();
let mut stmt = operator_eval_stmt("histogram_quantile(0.5, mixed_histogram)");
stmt.end = UNIX_EPOCH.checked_add(Duration::from_secs(2)).unwrap();
let plan = PromPlanner::stmt_to_plan(
classic_and_native_histogram_table_provider(
"classic",
Some("native"),
direct_or_histogram(),
),
&stmt,
&state,
)
.await
.unwrap();
let plan_text = plan.display_indent_schema().to_string();
assert_eq!(
plan_text.matches("TableScan: mixed_histogram").count(),
1,
"{plan_text}"
);
let value_field = plan
.schema()
.fields()
.iter()
.find(|field| field.data_type() == &ArrowDataType::Float64)
.unwrap()
.name()
.clone();
let (_, batches) = execute(plan, &state).await;
let mut actual = batches
.iter()
.flat_map(|batch| {
let le = batch
.column_by_name(LE_COLUMN_NAME)
.unwrap()
.as_any()
.downcast_ref::<StringArray>()
.unwrap();
let timestamps = batch
.column_by_name("timestamp")
.unwrap()
.as_any()
.downcast_ref::<TimestampMillisecondArray>()
.unwrap();
let values = batch
.column_by_name(&value_field)
.unwrap()
.as_any()
.downcast_ref::<Float64Array>()
.unwrap();
(0..batch.num_rows()).map(|row| {
(
timestamps.value(row),
(!le.is_null(row)).then(|| le.value(row).to_string()),
values.value(row),
)
})
})
.collect::<Vec<_>>();
actual.sort_by(|lhs, rhs| (lhs.0, &lhs.1).cmp(&(rhs.0, &rhs.1)));
assert_eq!(
actual,
vec![
(1_000, None, 1.0),
(1_000, Some("native".to_string()), 0.0),
(2_000, None, 1.0),
(2_000, Some("native".to_string()), 0.0),
]
);
}
#[tokio::test]
async fn nested_histogram_helpers_ignore_unparsable_bucket_labels() {
let state = build_query_engine_state();
for native_le in [None, Some("native")] {
for query in [
"histogram_quantile(0.5, histogram_quantile(0.5, mixed_histogram))",
"histogram_fraction(-Inf, +Inf, histogram_fraction(-Inf, +Inf, mixed_histogram))",
] {
let plan = PromPlanner::stmt_to_plan(
classic_and_native_histogram_table_provider(
"native",
native_le,
direct_or_histogram(),
),
&operator_eval_stmt(query),
&state,
)
.await
.unwrap();
let (_, batches) = execute(plan, &state).await;
assert_eq!(
batches.iter().map(RecordBatch::num_rows).sum::<usize>(),
0,
"native_le={native_le:?}, query={query}"
);
}
}
}
#[tokio::test]
async fn native_histogram_quantile_rejects_multi_field_input() {
let table_provider = build_test_multi_histogram_table_provider("some_metric").await;
let result = PromPlanner::stmt_to_plan(
table_provider,
&build_eval_stmt("histogram_quantile(0.9, some_metric)"),
&build_query_engine_state(),
)
.await;
let err = result.expect_err("histogram_quantile on two native histogram fields must fail");
assert!(
err.to_string()
.contains("Multi fields calculation is not supported in histogram_quantile"),
"{err}"
);
}
#[tokio::test]
async fn native_histogram_topk_uses_drop_udf() {
let plan = native_histogram_plan("topk(1, some_metric)").await;
assert!(plan.contains("prom_native_histogram_drop_float"), "{plan}");
assert!(
plan.contains("Filter: prom_native_histogram_drop_float") && plan.contains("IS NOT NULL"),
"{plan}"
);
}
#[tokio::test]
async fn mixed_or_topk_bottomk_ignore_native_histograms() {
for op in ["topk", "bottomk"] {
let collector = PromqlAnnotationCollector::default();
let state = build_query_engine_state();
let plan = PromPlanner::stmt_to_plan_with_annotations(
operator_table_provider(),
&operator_eval_stmt(&format!("{op}(1, lf or on(tag) lh)")),
&state,
Some(collector.clone()),
)
.await
.unwrap();
let float_field = plan
.schema()
.fields()
.iter()
.find(|field| field.data_type() == &ArrowDataType::Float64)
.unwrap()
.name()
.clone();
assert!(
plan.schema()
.fields()
.iter()
.all(|field| field.data_type() != &PromPlanner::native_histogram_arrow_type()),
"{plan:?}"
);
let (_, batches) = execute(plan, &state).await;
assert_eq!(values(&batches, &float_field), vec![2.0], "{op}");
let mut warnings = vec![];
let mut infos = vec![];
collector.append_to(&mut warnings, &mut infos);
assert!(warnings.is_empty());
assert_eq!(
infos,
vec![format!(
"{op}: dropped native histogram samples because this aggregation is not supported for native histograms"
)]
);
}
}
#[tokio::test]
async fn native_histogram_scalar_is_ignored_before_scalar_calculate() {
let plan = native_histogram_plan("scalar(some_metric)").await;
assert!(plan.contains("ScalarCalculate"), "{plan}");
assert!(plan.contains("Filter: Boolean(false)"), "{plan}");
assert!(!plan.contains("prom_native_histogram_drop"), "{plan}");
}
#[tokio::test]
async fn native_histogram_value_sort_is_empty_but_label_sort_preserves_samples() {
for function in ["sort", "sort_desc"] {
let plan = native_histogram_plan(&format!("{function}(some_metric)")).await;
assert!(plan.contains("Float64(NULL) IS NOT NULL"), "{plan}");
assert!(
!plan.contains(&format!("Sort: {}", greptime_native_histogram())),
"{plan}"
);
assert!(!plan.contains("prom_native_histogram_drop"), "{plan}");
}
for (function, direction) in [("sort_by_label", "ASC"), ("sort_by_label_desc", "DESC")] {
let plan = native_histogram_plan(&format!("{function}(some_metric, \"tag_0\")")).await;
assert!(plan.contains(&format!("tag_0 {direction}")), "{plan}");
assert!(plan.contains(greptime_native_histogram()), "{plan}");
assert!(!plan.contains("Float64(NULL) IS NOT NULL"), "{plan}");
}
}
#[tokio::test]
async fn unsupported_native_histogram_functions_use_drop_udf() {
for query in [
"deriv(some_metric[5m])",
"min_over_time(some_metric[5m])",
"quantile_over_time(0.9, some_metric[5m])",
"predict_linear(some_metric[5m], 60)",
"round(some_metric)",
"abs(some_metric)",
] {
let plan = native_histogram_plan(query).await;
assert!(
plan.contains("prom_native_histogram_drop_float"),
"{query}\n{plan}"
);
}
}
#[tokio::test]
async fn native_histogram_absent_over_time_uses_native_udf() {
let plan = native_histogram_plan("absent_over_time(some_metric[5m])").await;
assert!(
plan.contains("prom_native_histogram_absent_over_time"),
"{plan}"
);
}
#[tokio::test]
async fn native_histogram_all_function_arms_route_correctly() {
// Every native-histogram match arm in `create_function_expr` must route to the
// expected UDF when all field columns are native histograms. `holt_winters` shares
// the `double_exponential_smoothing` arm but is not registered in the promql
// parser (0.10), so it cannot be exercised through a query string.
let cases = [
// Range functions routed to native histogram UDFs.
(
"increase(some_metric[5m])",
"prom_native_histogram_increase",
),
("rate(some_metric[5m])", "prom_native_histogram_rate"),
("delta(some_metric[5m])", "prom_native_histogram_delta"),
("idelta(some_metric[5m])", "prom_native_histogram_idelta"),
("irate(some_metric[5m])", "prom_native_histogram_irate"),
("resets(some_metric[5m])", "prom_native_histogram_resets"),
("changes(some_metric[5m])", "prom_native_histogram_changes"),
(
"avg_over_time(some_metric[5m])",
"prom_native_histogram_avg_over_time",
),
(
"sum_over_time(some_metric[5m])",
"prom_native_histogram_sum_over_time",
),
(
"count_over_time(some_metric[5m])",
"prom_native_histogram_count_over_time",
),
(
"last_over_time(some_metric[5m])",
"prom_native_histogram_last_over_time",
),
(
"present_over_time(some_metric[5m])",
"prom_native_histogram_present_over_time",
),
// Unsupported functions dropped with the float-null UDF.
("deriv(some_metric[5m])", "prom_native_histogram_drop_float"),
(
"min_over_time(some_metric[5m])",
"prom_native_histogram_drop_float",
),
(
"max_over_time(some_metric[5m])",
"prom_native_histogram_drop_float",
),
(
"stddev_over_time(some_metric[5m])",
"prom_native_histogram_drop_float",
),
(
"stdvar_over_time(some_metric[5m])",
"prom_native_histogram_drop_float",
),
(
"quantile_over_time(0.9, some_metric[5m])",
"prom_native_histogram_drop_float",
),
(
"predict_linear(some_metric[5m], 60)",
"prom_native_histogram_drop_float",
),
(
"double_exponential_smoothing(some_metric[5m], 0.5, 0.5)",
"prom_native_histogram_drop_float",
),
("round(some_metric)", "prom_native_histogram_drop_float"),
("rad(some_metric)", "prom_native_histogram_drop_float"),
("deg(some_metric)", "prom_native_histogram_drop_float"),
("sgn(some_metric)", "prom_native_histogram_drop_float"),
// Instant helper functions routed to native histogram UDFs.
(
"histogram_count(some_metric)",
"prom_native_histogram_count",
),
("histogram_sum(some_metric)", "prom_native_histogram_sum"),
("histogram_avg(some_metric)", "prom_native_histogram_avg"),
(
"histogram_stddev(some_metric)",
"prom_native_histogram_stddev",
),
(
"histogram_stdvar(some_metric)",
"prom_native_histogram_stdvar",
),
(
"histogram_fraction(-2 + 1, 2 / 2, some_metric)",
"prom_native_histogram_fraction",
),
];
for (query, expected_udf) in cases {
let plan = native_histogram_plan(query).await;
assert!(plan.contains(expected_udf), "{query}\n{plan}");
if query.starts_with("histogram_fraction") {
assert!(plan.contains("Float64(-1)"), "{query}\n{plan}");
}
}
}
#[tokio::test]
async fn mixed_native_histogram_ranges_use_coordinated_udfs() {
let dual_output = [
"increase(some_metric[5m])",
"rate(some_metric[5m])",
"delta(some_metric[5m])",
"idelta(some_metric[5m])",
"irate(some_metric[5m])",
"avg_over_time(some_metric[5m])",
"sum_over_time(some_metric[5m])",
"last_over_time(some_metric[5m])",
];
let float_output = [
"resets(some_metric[5m])",
"changes(some_metric[5m])",
"deriv(some_metric[5m])",
"min_over_time(some_metric[5m])",
"max_over_time(some_metric[5m])",
"count_over_time(some_metric[5m])",
"absent_over_time(some_metric[5m])",
"present_over_time(some_metric[5m])",
"stddev_over_time(some_metric[5m])",
"stdvar_over_time(some_metric[5m])",
"quantile_over_time(0.9, some_metric[5m])",
"predict_linear(some_metric[5m], 60)",
"double_exponential_smoothing(some_metric[5m], 0.5, 0.5)",
];
for query in dual_output.iter().chain(float_output.iter()) {
let plan = PromPlanner::stmt_to_plan(
build_test_mixed_native_histogram_table_provider("some_metric").await,
&build_eval_stmt(query),
&build_query_engine_state(),
)
.await
.unwrap()
.display_indent_schema()
.to_string();
assert!(plan.contains("prom_mixed_range_float"), "{query}\n{plan}");
assert_eq!(
plan.contains("prom_mixed_range_histogram"),
dual_output.contains(query),
"{query}\n{plan}"
);
}
let plan = PromPlanner::stmt_to_plan(
build_test_mixed_native_histogram_table_provider("some_metric").await,
&build_eval_stmt("sum_over_time(rate(some_metric[5m])[10m:1m])"),
&build_query_engine_state(),
)
.await
.unwrap()
.display_indent_schema()
.to_string();
let expected = r#"Filter: greptime_value IS NOT NULL OR greptime_native_histogram IS NOT NULL [timestamp:Timestamp(ms), greptime_value:Float64;N, greptime_native_histogram:Struct("schema": Int32, "zero_threshold": Float64, "sum": Float64, "reset_hint": Int32, "start_timestamp": Timestamp(ms), "custom_values": List(Float64), "positive_span_offsets": List(Int32), "positive_span_lengths": List(Int32), "negative_span_offsets": List(Int32), "negative_span_lengths": List(Int32), "count_i64": Int64, "zero_count_i64": Int64, "positive_buckets_i64": List(Int64), "negative_buckets_i64": List(Int64), "count_f64": Float64, "zero_count_f64": Float64, "positive_buckets_f64": List(Float64), "negative_buckets_f64": List(Float64));N, tag_0:Utf8]
Projection: some_metric.timestamp, prom_mixed_range_float(Utf8("sum_over_time"), timestamp_range, greptime_value, greptime_native_histogram) AS greptime_value, prom_mixed_range_histogram(Utf8("sum_over_time"), timestamp_range, greptime_value, greptime_native_histogram) AS greptime_native_histogram, some_metric.tag_0 [timestamp:Timestamp(ms), greptime_value:Float64;N, greptime_native_histogram:Struct("schema": Int32, "zero_threshold": Float64, "sum": Float64, "reset_hint": Int32, "start_timestamp": Timestamp(ms), "custom_values": List(Float64), "positive_span_offsets": List(Int32), "positive_span_lengths": List(Int32), "negative_span_offsets": List(Int32), "negative_span_lengths": List(Int32), "count_i64": Int64, "zero_count_i64": Int64, "positive_buckets_i64": List(Int64), "negative_buckets_i64": List(Int64), "count_f64": Float64, "zero_count_f64": Float64, "positive_buckets_f64": List(Float64), "negative_buckets_f64": List(Float64));N, tag_0:Utf8]
PromRangeManipulate: req range=[0..100000000], interval=[5000], eval range=[600000], time index=[timestamp], values=["greptime_value", "greptime_native_histogram"] [timestamp:Timestamp(ms), greptime_value:Dictionary(Int64, Float64);N, greptime_native_histogram:Dictionary(Int64, Struct("schema": Int32, "zero_threshold": Float64, "sum": Float64, "reset_hint": Int32, "start_timestamp": Timestamp(ms), "custom_values": List(Float64), "positive_span_offsets": List(Int32), "positive_span_lengths": List(Int32), "negative_span_offsets": List(Int32), "negative_span_lengths": List(Int32), "count_i64": Int64, "zero_count_i64": Int64, "positive_buckets_i64": List(Int64), "negative_buckets_i64": List(Int64), "count_f64": Float64, "zero_count_f64": Float64, "positive_buckets_f64": List(Float64), "negative_buckets_f64": List(Float64)));N, tag_0:Utf8, timestamp_range:Dictionary(Int64, Timestamp(ms))]
PromSeriesDivide: tags=["tag_0"] [timestamp:Timestamp(ms), greptime_value:Float64;N, greptime_native_histogram:Struct("schema": Int32, "zero_threshold": Float64, "sum": Float64, "reset_hint": Int32, "start_timestamp": Timestamp(ms), "custom_values": List(Float64), "positive_span_offsets": List(Int32), "positive_span_lengths": List(Int32), "negative_span_offsets": List(Int32), "negative_span_lengths": List(Int32), "count_i64": Int64, "zero_count_i64": Int64, "positive_buckets_i64": List(Int64), "negative_buckets_i64": List(Int64), "count_f64": Float64, "zero_count_f64": Float64, "positive_buckets_f64": List(Float64), "negative_buckets_f64": List(Float64));N, tag_0:Utf8]
Sort: some_metric.tag_0 ASC NULLS FIRST, some_metric.timestamp ASC NULLS FIRST [timestamp:Timestamp(ms), greptime_value:Float64;N, greptime_native_histogram:Struct("schema": Int32, "zero_threshold": Float64, "sum": Float64, "reset_hint": Int32, "start_timestamp": Timestamp(ms), "custom_values": List(Float64), "positive_span_offsets": List(Int32), "positive_span_lengths": List(Int32), "negative_span_offsets": List(Int32), "negative_span_lengths": List(Int32), "count_i64": Int64, "zero_count_i64": Int64, "positive_buckets_i64": List(Int64), "negative_buckets_i64": List(Int64), "count_f64": Float64, "zero_count_f64": Float64, "positive_buckets_f64": List(Float64), "negative_buckets_f64": List(Float64));N, tag_0:Utf8]
Filter: greptime_value IS NOT NULL OR greptime_native_histogram IS NOT NULL [timestamp:Timestamp(ms), greptime_value:Float64;N, greptime_native_histogram:Struct("schema": Int32, "zero_threshold": Float64, "sum": Float64, "reset_hint": Int32, "start_timestamp": Timestamp(ms), "custom_values": List(Float64), "positive_span_offsets": List(Int32), "positive_span_lengths": List(Int32), "negative_span_offsets": List(Int32), "negative_span_lengths": List(Int32), "count_i64": Int64, "zero_count_i64": Int64, "positive_buckets_i64": List(Int64), "negative_buckets_i64": List(Int64), "count_f64": Float64, "zero_count_f64": Float64, "positive_buckets_f64": List(Float64), "negative_buckets_f64": List(Float64));N, tag_0:Utf8]
Projection: some_metric.timestamp, prom_mixed_range_float(Utf8("rate"), timestamp_range, greptime_value, greptime_native_histogram, some_metric.timestamp, Int64(300000)) AS greptime_value, prom_mixed_range_histogram(Utf8("rate"), timestamp_range, greptime_value, greptime_native_histogram, some_metric.timestamp, Int64(300000)) AS greptime_native_histogram, some_metric.tag_0 [timestamp:Timestamp(ms), greptime_value:Float64;N, greptime_native_histogram:Struct("schema": Int32, "zero_threshold": Float64, "sum": Float64, "reset_hint": Int32, "start_timestamp": Timestamp(ms), "custom_values": List(Float64), "positive_span_offsets": List(Int32), "positive_span_lengths": List(Int32), "negative_span_offsets": List(Int32), "negative_span_lengths": List(Int32), "count_i64": Int64, "zero_count_i64": Int64, "positive_buckets_i64": List(Int64), "negative_buckets_i64": List(Int64), "count_f64": Float64, "zero_count_f64": Float64, "positive_buckets_f64": List(Float64), "negative_buckets_f64": List(Float64));N, tag_0:Utf8]
PromRangeManipulate: req range=[-540000..100000000], interval=[60000], eval range=[300000], time index=[timestamp], values=["greptime_native_histogram", "greptime_value"] [tag_0:Utf8, timestamp:Timestamp(ms), greptime_native_histogram:Dictionary(Int64, Struct("schema": Int32, "zero_threshold": Float64, "sum": Float64, "reset_hint": Int32, "start_timestamp": Timestamp(ms), "custom_values": List(Float64), "positive_span_offsets": List(Int32), "positive_span_lengths": List(Int32), "negative_span_offsets": List(Int32), "negative_span_lengths": List(Int32), "count_i64": Int64, "zero_count_i64": Int64, "positive_buckets_i64": List(Int64), "negative_buckets_i64": List(Int64), "count_f64": Float64, "zero_count_f64": Float64, "positive_buckets_f64": List(Float64), "negative_buckets_f64": List(Float64)));N, greptime_value:Dictionary(Int64, Float64);N, timestamp_range:Dictionary(Int64, Timestamp(ms))]
PromSeriesNormalize: offset=[0], time index=[timestamp], filter NaN: [true] [tag_0:Utf8, timestamp:Timestamp(ms), greptime_native_histogram:Struct("schema": Int32, "zero_threshold": Float64, "sum": Float64, "reset_hint": Int32, "start_timestamp": Timestamp(ms), "custom_values": List(Float64), "positive_span_offsets": List(Int32), "positive_span_lengths": List(Int32), "negative_span_offsets": List(Int32), "negative_span_lengths": List(Int32), "count_i64": Int64, "zero_count_i64": Int64, "positive_buckets_i64": List(Int64), "negative_buckets_i64": List(Int64), "count_f64": Float64, "zero_count_f64": Float64, "positive_buckets_f64": List(Float64), "negative_buckets_f64": List(Float64));N, greptime_value:Float64;N]
PromSeriesDivide: tags=["tag_0"] [tag_0:Utf8, timestamp:Timestamp(ms), greptime_native_histogram:Struct("schema": Int32, "zero_threshold": Float64, "sum": Float64, "reset_hint": Int32, "start_timestamp": Timestamp(ms), "custom_values": List(Float64), "positive_span_offsets": List(Int32), "positive_span_lengths": List(Int32), "negative_span_offsets": List(Int32), "negative_span_lengths": List(Int32), "count_i64": Int64, "zero_count_i64": Int64, "positive_buckets_i64": List(Int64), "negative_buckets_i64": List(Int64), "count_f64": Float64, "zero_count_f64": Float64, "positive_buckets_f64": List(Float64), "negative_buckets_f64": List(Float64));N, greptime_value:Float64;N]
Sort: some_metric.tag_0 ASC NULLS FIRST, some_metric.timestamp ASC NULLS FIRST [tag_0:Utf8, timestamp:Timestamp(ms), greptime_native_histogram:Struct("schema": Int32, "zero_threshold": Float64, "sum": Float64, "reset_hint": Int32, "start_timestamp": Timestamp(ms), "custom_values": List(Float64), "positive_span_offsets": List(Int32), "positive_span_lengths": List(Int32), "negative_span_offsets": List(Int32), "negative_span_lengths": List(Int32), "count_i64": Int64, "zero_count_i64": Int64, "positive_buckets_i64": List(Int64), "negative_buckets_i64": List(Int64), "count_f64": Float64, "zero_count_f64": Float64, "positive_buckets_f64": List(Float64), "negative_buckets_f64": List(Float64));N, greptime_value:Float64;N]
Filter: some_metric.timestamp >= TimestampMillisecond(-839999, None) AND some_metric.timestamp <= TimestampMillisecond(100000000, None) [tag_0:Utf8, timestamp:Timestamp(ms), greptime_native_histogram:Struct("schema": Int32, "zero_threshold": Float64, "sum": Float64, "reset_hint": Int32, "start_timestamp": Timestamp(ms), "custom_values": List(Float64), "positive_span_offsets": List(Int32), "positive_span_lengths": List(Int32), "negative_span_offsets": List(Int32), "negative_span_lengths": List(Int32), "count_i64": Int64, "zero_count_i64": Int64, "positive_buckets_i64": List(Int64), "negative_buckets_i64": List(Int64), "count_f64": Float64, "zero_count_f64": Float64, "positive_buckets_f64": List(Float64), "negative_buckets_f64": List(Float64));N, greptime_value:Float64;N]
TableScan: some_metric [tag_0:Utf8, timestamp:Timestamp(ms), greptime_native_histogram:Struct("schema": Int32, "zero_threshold": Float64, "sum": Float64, "reset_hint": Int32, "start_timestamp": Timestamp(ms), "custom_values": List(Float64), "positive_span_offsets": List(Int32), "positive_span_lengths": List(Int32), "negative_span_offsets": List(Int32), "negative_span_lengths": List(Int32), "count_i64": Int64, "zero_count_i64": Int64, "positive_buckets_i64": List(Int64), "negative_buckets_i64": List(Int64), "count_f64": Float64, "zero_count_f64": Float64, "positive_buckets_f64": List(Float64), "negative_buckets_f64": List(Float64));N, greptime_value:Float64;N]"#;
assert_eq!(plan, expected);
}
#[tokio::test]
async fn mixed_native_histogram_predict_linear_forwards_the_eval_timestamp() {
let query = "predict_linear(some_metric[5m], 60)";
let plan = PromPlanner::stmt_to_plan(
build_test_mixed_native_histogram_table_provider("some_metric").await,
&build_eval_stmt(query),
&build_query_engine_state(),
)
.await
.unwrap()
.display_indent_schema()
.to_string();
assert!(
plan.contains(
"prom_mixed_range_float(Utf8(\"predict_linear\"), timestamp_range, greptime_value, greptime_native_histogram, CAST(Float64(60) AS Int64), CAST(CAST(some_metric.timestamp AS Int64) + Int64(0) AS Timestamp(ms)))"
),
"{query}\n{plan}"
);
}
#[tokio::test]
async fn mixed_native_histogram_rate_executes_real_ranges() {
let schema = Arc::new(ArrowSchema::new(vec![
Field::new(
"timestamp",
ArrowDataType::Timestamp(ArrowTimeUnit::Millisecond, None),
false,
),
Field::new(greptime_value(), ArrowDataType::Float64, true),
Field::new(
greptime_native_histogram(),
native_histogram_value_type().as_arrow_type(),
true,
),
]));
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(TimestampMillisecondArray::from(vec![1000, 2000, 3000])),
Arc::new(Float64Array::from(vec![Some(1.0), None, Some(3.0)])),
build_histogram_array(&[None, Some(direct_or_histogram()), None]),
],
)
.unwrap();
let table = Arc::new(MemTable::try_new(schema, vec![vec![batch]]).unwrap());
let input = LogicalPlanBuilder::scan("mixed", provider_as_source(table), None)
.unwrap()
.build()
.unwrap();
let collector = PromqlAnnotationCollector::default();
let mut planner = PromPlanner {
table_provider: build_test_table_provider_with_fields(
&[(DEFAULT_SCHEMA_NAME.to_string(), "dummy".to_string())],
&[],
)
.await,
ctx: PromPlannerContext {
start: 3000,
end: 3000,
interval: 1000,
range: Some(3000),
time_index_column: Some("timestamp".to_string()),
field_columns: vec![
greptime_native_histogram().to_string(),
greptime_value().to_string(),
],
..Default::default()
},
promql_annotations: Some(collector.clone()),
};
let input = LogicalPlan::Extension(Extension {
node: Arc::new(
RangeManipulate::new(
3000,
3000,
1000,
0,
3000,
"timestamp".to_string(),
planner.ctx.field_columns.clone(),
input,
)
.unwrap(),
),
});
let PromExpr::Call(call) = parser::parse("rate(mixed[3s])").unwrap() else {
unreachable!()
};
let preserve_any_value = PromPlanner::field_columns_are_alternative_samples(
input.schema(),
&planner.ctx.field_columns,
);
let state = build_query_engine_state();
let (mut exprs, _) = planner
.create_function_expr(&call.func, vec![], input.schema(), &state, None)
.unwrap();
exprs.insert(0, planner.create_time_index_column_expr().unwrap());
let plan = LogicalPlanBuilder::from(input)
.project(exprs)
.unwrap()
.filter(
planner
.create_empty_values_filter_expr(preserve_any_value)
.unwrap(),
)
.unwrap()
.build()
.unwrap();
let (_, batches) = execute(plan, &state).await;
assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 0);
let mut warnings = Vec::new();
collector.append_to(&mut warnings, &mut Vec::new());
assert!(
warnings
.iter()
.any(|warning| warning.contains("mix of float and native histogram"))
);
}
#[tokio::test]
async fn native_histogram_mixed_field_table_behaves() {
// Exercise function planning after float and histogram samples have already been
// represented as alternative nullable fields. Histogram functions must select the
// histogram field without adding a NULL float field that would reject every row.
let table_provider = build_test_mixed_native_histogram_table_provider("some_metric").await;
let plan = PromPlanner::stmt_to_plan(
table_provider,
&build_eval_stmt("histogram_count(some_metric)"),
&build_query_engine_state(),
)
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert!(
plan_str.contains("prom_native_histogram_count"),
"{plan_str}"
);
assert!(!plan_str.contains("Float64(NULL)"), "{plan_str}");
assert!(
plan_str.contains("prom_native_histogram_count(greptime_native_histogram) IS NOT NULL"),
"{plan_str}"
);
// Value sorting keeps the float column and never sorts by the histogram column.
let table_provider = build_test_mixed_native_histogram_table_provider("some_metric").await;
let plan = PromPlanner::stmt_to_plan(
table_provider,
&build_eval_stmt("sort(some_metric)"),
&build_query_engine_state(),
)
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert!(
plan_str.contains("greptime_value ASC NULLS FIRST"),
"{plan_str}"
);
assert!(
!plan_str.contains("greptime_native_histogram ASC"),
"{plan_str}"
);
// scalar() ignores histogram samples and evaluates only the float field.
let table_provider = build_test_mixed_native_histogram_table_provider("some_metric").await;
let plan = PromPlanner::stmt_to_plan(
table_provider,
&build_eval_stmt("scalar(some_metric)"),
&build_query_engine_state(),
)
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
assert!(plan_str.contains("ScalarCalculate"), "{plan_str}");
assert!(
plan_str.contains("greptime_value IS NOT NULL"),
"{plan_str}"
);
// Functions that preserve both alternative fields keep rows with either sample type.
let table_provider = build_test_mixed_native_histogram_table_provider("some_metric").await;
let plan = PromPlanner::stmt_to_plan(
table_provider,
&build_eval_stmt(r#"label_replace(some_metric, "copied", "$1", "tag_0", "(.*)")"#),
&build_query_engine_state(),
)
.await
.unwrap();
let plan_str = plan.display_indent_schema().to_string();
let filter = plan_str.lines().next().unwrap();
assert!(
filter.starts_with("Filter: ")
&& filter.contains("greptime_native_histogram IS NOT NULL")
&& filter.contains(" OR ")
&& filter.contains("greptime_value IS NOT NULL"),
"{plan_str}"
);
}
#[tokio::test]
async fn less_filter_on_value() {
let query = "some_metric < 1.2345";
let expected = String::from(
"Filter: some_metric.field_0 < Float64(1.2345) [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n PromInstantManipulate: range=[0..100000000], lookback=[1000], interval=[5000], time index=[timestamp] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n PromSeriesDivide: tags=[\"tag_0\"] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Sort: some_metric.tag_0 ASC NULLS FIRST, some_metric.timestamp ASC NULLS FIRST [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Filter: some_metric.timestamp >= TimestampMillisecond(-999, None) AND some_metric.timestamp <= TimestampMillisecond(100000000, None) [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n TableScan: some_metric [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]",
);
indie_query_plan_compare(query, expected).await;
}
#[tokio::test]
async fn count_over_time() {
let query = "count_over_time(some_metric[5m])";
let expected = String::from(
"Filter: prom_count_over_time(timestamp_range,field_0) IS NOT NULL [timestamp:Timestamp(ms), prom_count_over_time(timestamp_range,field_0):Float64;N, tag_0:Utf8]\
\n Projection: some_metric.timestamp, prom_count_over_time(timestamp_range, field_0) AS prom_count_over_time(timestamp_range,field_0), some_metric.tag_0 [timestamp:Timestamp(ms), prom_count_over_time(timestamp_range,field_0):Float64;N, tag_0:Utf8]\
\n PromRangeManipulate: req range=[0..100000000], interval=[5000], eval range=[300000], time index=[timestamp], values=[\"field_0\"] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Dictionary(Int64, Float64);N, timestamp_range:Dictionary(Int64, Timestamp(ms))]\
\n PromSeriesNormalize: offset=[0], time index=[timestamp], filter NaN: [true] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n PromSeriesDivide: tags=[\"tag_0\"] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Sort: some_metric.tag_0 ASC NULLS FIRST, some_metric.timestamp ASC NULLS FIRST [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Filter: some_metric.timestamp >= TimestampMillisecond(-299999, None) AND some_metric.timestamp <= TimestampMillisecond(100000000, None) [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n TableScan: some_metric [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]",
);
indie_query_plan_compare(query, expected).await;
}
/// The outer `PromRangeManipulate` from a subquery must be preceded by
/// `Sort` + `PromSeriesDivide`.
#[tokio::test]
async fn count_over_time_subquery() {
let query = "count_over_time(some_metric[10m:1m])";
let expected = String::from(
"Filter: prom_count_over_time(timestamp_range,field_0) IS NOT NULL [timestamp:Timestamp(ms), prom_count_over_time(timestamp_range,field_0):Float64;N, tag_0:Utf8]\
\n Projection: some_metric.timestamp, prom_count_over_time(timestamp_range, field_0) AS prom_count_over_time(timestamp_range,field_0), some_metric.tag_0 [timestamp:Timestamp(ms), prom_count_over_time(timestamp_range,field_0):Float64;N, tag_0:Utf8]\
\n PromRangeManipulate: req range=[0..100000000], interval=[5000], eval range=[600000], time index=[timestamp], values=[\"field_0\"] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Dictionary(Int64, Float64);N, timestamp_range:Dictionary(Int64, Timestamp(ms))]\
\n PromSeriesDivide: tags=[\"tag_0\"] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Sort: some_metric.tag_0 ASC NULLS FIRST, some_metric.timestamp ASC NULLS FIRST [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n PromInstantManipulate: range=[-540000..100000000], lookback=[1000], interval=[60000], time index=[timestamp] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n PromSeriesDivide: tags=[\"tag_0\"] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Sort: some_metric.tag_0 ASC NULLS FIRST, some_metric.timestamp ASC NULLS FIRST [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Filter: some_metric.timestamp >= TimestampMillisecond(-540999, None) AND some_metric.timestamp <= TimestampMillisecond(100000000, None) [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n TableScan: some_metric [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]",
);
indie_query_plan_compare(query, expected).await;
}
#[tokio::test]
async fn test_hash_join() {
let mut eval_stmt = EvalStmt {
expr: PromExpr::NumberLiteral(NumberLiteral { val: 1.0 }),
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let case = r#"http_server_requests_seconds_sum{uri="/accounts/login"} / ignoring(kubernetes_pod_name,kubernetes_namespace) http_server_requests_seconds_count{uri="/accounts/login"}"#;
let prom_expr = parser::parse(case).unwrap();
eval_stmt.expr = prom_expr;
let table_provider = build_test_table_provider_with_fields(
&[
(
DEFAULT_SCHEMA_NAME.to_string(),
"http_server_requests_seconds_sum".to_string(),
),
(
DEFAULT_SCHEMA_NAME.to_string(),
"http_server_requests_seconds_count".to_string(),
),
],
&["uri", "kubernetes_namespace", "kubernetes_pod_name"],
)
.await;
// Should be ok
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let expected = "Projection: http_server_requests_seconds_sum.uri, http_server_requests_seconds_count.greptime_timestamp, CAST(http_server_requests_seconds_sum.greptime_value AS Float64) / CAST(http_server_requests_seconds_count.greptime_value AS Float64) AS http_server_requests_seconds_sum.greptime_value / http_server_requests_seconds_count.greptime_value\
\n Projection: http_server_requests_seconds_sum.uri, http_server_requests_seconds_sum.kubernetes_namespace, http_server_requests_seconds_sum.kubernetes_pod_name, http_server_requests_seconds_sum.greptime_timestamp, http_server_requests_seconds_sum.greptime_value, http_server_requests_seconds_count.uri, http_server_requests_seconds_count.kubernetes_namespace, http_server_requests_seconds_count.kubernetes_pod_name, http_server_requests_seconds_count.greptime_timestamp, http_server_requests_seconds_count.greptime_value\
\n Filter: prom_assert_unique_match_group(__promql_match_group_count, http_server_requests_seconds_sum.uri)\
\n WindowAggr: windowExpr=[[count(Int64(1)) PARTITION BY [http_server_requests_seconds_sum.uri, http_server_requests_seconds_sum.greptime_timestamp] ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING AS __promql_match_group_count]]\
\n Inner Join: http_server_requests_seconds_sum.greptime_timestamp = http_server_requests_seconds_count.greptime_timestamp, http_server_requests_seconds_sum.uri = http_server_requests_seconds_count.uri\
\n SubqueryAlias: http_server_requests_seconds_sum\
\n PromInstantManipulate: range=[0..100000000], lookback=[1000], interval=[5000], time index=[greptime_timestamp]\
\n PromSeriesDivide: tags=[\"uri\", \"kubernetes_namespace\", \"kubernetes_pod_name\"]\
\n Sort: http_server_requests_seconds_sum.uri ASC NULLS FIRST, http_server_requests_seconds_sum.kubernetes_namespace ASC NULLS FIRST, http_server_requests_seconds_sum.kubernetes_pod_name ASC NULLS FIRST, http_server_requests_seconds_sum.greptime_timestamp ASC NULLS FIRST\
\n Filter: http_server_requests_seconds_sum.uri = Utf8(\"/accounts/login\") AND http_server_requests_seconds_sum.greptime_timestamp >= TimestampMillisecond(-999, None) AND http_server_requests_seconds_sum.greptime_timestamp <= TimestampMillisecond(100000000, None)\
\n TableScan: http_server_requests_seconds_sum\
\n SubqueryAlias: http_server_requests_seconds_count\
\n Projection: http_server_requests_seconds_count.uri, http_server_requests_seconds_count.kubernetes_namespace, http_server_requests_seconds_count.kubernetes_pod_name, http_server_requests_seconds_count.greptime_timestamp, http_server_requests_seconds_count.greptime_value\
\n Filter: prom_assert_unique_match_group(__promql_match_group_count, http_server_requests_seconds_count.uri)\
\n WindowAggr: windowExpr=[[count(Int64(1)) PARTITION BY [http_server_requests_seconds_count.uri, http_server_requests_seconds_count.greptime_timestamp] ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING AS __promql_match_group_count]]\
\n PromInstantManipulate: range=[0..100000000], lookback=[1000], interval=[5000], time index=[greptime_timestamp]\
\n PromSeriesDivide: tags=[\"uri\", \"kubernetes_namespace\", \"kubernetes_pod_name\"]\
\n Sort: http_server_requests_seconds_count.uri ASC NULLS FIRST, http_server_requests_seconds_count.kubernetes_namespace ASC NULLS FIRST, http_server_requests_seconds_count.kubernetes_pod_name ASC NULLS FIRST, http_server_requests_seconds_count.greptime_timestamp ASC NULLS FIRST\
\n Filter: http_server_requests_seconds_count.uri = Utf8(\"/accounts/login\") AND http_server_requests_seconds_count.greptime_timestamp >= TimestampMillisecond(-999, None) AND http_server_requests_seconds_count.greptime_timestamp <= TimestampMillisecond(100000000, None)\
\n TableScan: http_server_requests_seconds_count";
assert_eq!(plan.to_string(), expected);
}
#[tokio::test]
async fn test_nested_histogram_quantile() {
let mut eval_stmt = EvalStmt {
expr: PromExpr::NumberLiteral(NumberLiteral { val: 1.0 }),
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let case = r#"label_replace(histogram_quantile(0.99, sum by(pod, le, path, code) (rate(greptime_servers_grpc_requests_elapsed_bucket{container="frontend"}[1m0s]))), "pod_new", "$1", "pod", "greptimedb-frontend-[0-9a-z]*-(.*)")"#;
let prom_expr = parser::parse(case).unwrap();
eval_stmt.expr = prom_expr;
let table_provider = build_test_table_provider_with_fields(
&[(
DEFAULT_SCHEMA_NAME.to_string(),
"greptime_servers_grpc_requests_elapsed_bucket".to_string(),
)],
&["pod", "le", "path", "code", "container"],
)
.await;
// Should be ok
let _ = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
}
#[tokio::test]
async fn test_histogram_quantile_binary_op() {
let mut eval_stmt = EvalStmt {
expr: PromExpr::NumberLiteral(NumberLiteral { val: 1.0 }),
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
// Arithmetic applied to a histogram_quantile() result. Regression for #8144:
// HistogramFold used to drop the input column qualifiers, so the binary-op
// projection failed to resolve the qualified tag column.
let case = r#"histogram_quantile(0.5, sum by (le, pod) (rate(http_request_duration_seconds_bucket[5m]))) + 0"#;
let prom_expr = parser::parse(case).unwrap();
eval_stmt.expr = prom_expr;
let table_provider = build_test_table_provider_with_fields(
&[(
DEFAULT_SCHEMA_NAME.to_string(),
"http_request_duration_seconds_bucket".to_string(),
)],
&["pod", "le"],
)
.await;
// Should plan without a "No field named ..." error.
let _ = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
}
#[tokio::test]
async fn test_parse_and_operator() {
let mut eval_stmt = EvalStmt {
expr: PromExpr::NumberLiteral(NumberLiteral { val: 1.0 }),
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let cases = [
r#"count (max by (persistentvolumeclaim,namespace) (kubelet_volume_stats_used_bytes{namespace=~".+"} ) and (max by (persistentvolumeclaim,namespace) (kubelet_volume_stats_used_bytes{namespace=~".+"} )) / (max by (persistentvolumeclaim,namespace) (kubelet_volume_stats_capacity_bytes{namespace=~".+"} )) >= (80 / 100)) or vector (0)"#,
r#"count (max by (persistentvolumeclaim,namespace) (kubelet_volume_stats_used_bytes{namespace=~".+"} ) unless (max by (persistentvolumeclaim,namespace) (kubelet_volume_stats_used_bytes{namespace=~".+"} )) / (max by (persistentvolumeclaim,namespace) (kubelet_volume_stats_capacity_bytes{namespace=~".+"} )) >= (80 / 100)) or vector (0)"#,
];
for case in cases {
let prom_expr = parser::parse(case).unwrap();
eval_stmt.expr = prom_expr;
let table_provider = build_test_table_provider_with_fields(
&[
(
DEFAULT_SCHEMA_NAME.to_string(),
"kubelet_volume_stats_used_bytes".to_string(),
),
(
DEFAULT_SCHEMA_NAME.to_string(),
"kubelet_volume_stats_capacity_bytes".to_string(),
),
],
&["namespace", "persistentvolumeclaim"],
)
.await;
// Should be ok
let _ = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
}
}
#[tokio::test]
async fn test_nested_binary_op() {
let mut eval_stmt = EvalStmt {
expr: PromExpr::NumberLiteral(NumberLiteral { val: 1.0 }),
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let case = r#"sum(rate(nginx_ingress_controller_requests{job=~".*"}[2m])) -
(
sum(rate(nginx_ingress_controller_requests{namespace=~".*"}[2m]))
or
vector(0)
)"#;
let prom_expr = parser::parse(case).unwrap();
eval_stmt.expr = prom_expr;
let table_provider = build_test_table_provider_with_fields(
&[(
DEFAULT_SCHEMA_NAME.to_string(),
"nginx_ingress_controller_requests".to_string(),
)],
&["namespace", "job"],
)
.await;
// Should be ok
let _ = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
}
#[tokio::test]
async fn test_parse_or_operator() {
let mut eval_stmt = EvalStmt {
expr: PromExpr::NumberLiteral(NumberLiteral { val: 1.0 }),
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let case = r#"
sum(rate(sysstat{tenant_name=~"tenant1",cluster_name=~"cluster1"}[120s])) by (cluster_name,tenant_name) /
(sum(sysstat{tenant_name=~"tenant1",cluster_name=~"cluster1"}) by (cluster_name,tenant_name) * 100)
or
200 * sum(sysstat{tenant_name=~"tenant1",cluster_name=~"cluster1"}) by (cluster_name,tenant_name) /
sum(sysstat{tenant_name=~"tenant1",cluster_name=~"cluster1"}) by (cluster_name,tenant_name)"#;
let table_provider = build_test_table_provider_with_fields(
&[(DEFAULT_SCHEMA_NAME.to_string(), "sysstat".to_string())],
&["tenant_name", "cluster_name"],
)
.await;
eval_stmt.expr = parser::parse(case).unwrap();
let _ = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let case = r#"sum(delta(sysstat{tenant_name=~"sys",cluster_name=~"cluster1"}[2m])/120) by (cluster_name,tenant_name) /
(sum(delta(sysstat{tenant_name=~"sys",cluster_name=~"cluster1"}[2m])/120) by (cluster_name,tenant_name) *1000) +
sum(delta(sysstat{tenant_name=~"sys",cluster_name=~"cluster1"}[2m])/120) by (cluster_name,tenant_name) /
(sum(delta(sysstat{tenant_name=~"sys",cluster_name=~"cluster1"}[2m])/120) by (cluster_name,tenant_name) *1000) >= 0
or
sum(delta(sysstat{tenant_name=~"sys",cluster_name=~"cluster1"}[2m])/120) by (cluster_name,tenant_name) /
(sum(delta(sysstat{tenant_name=~"sys",cluster_name=~"cluster1"}[2m])/120) by (cluster_name,tenant_name) *1000) >= 0
or
sum(delta(sysstat{tenant_name=~"sys",cluster_name=~"cluster1"}[2m])/120) by (cluster_name,tenant_name) /
(sum(delta(sysstat{tenant_name=~"sys",cluster_name=~"cluster1"}[2m])/120) by (cluster_name,tenant_name) *1000) >= 0"#;
let table_provider = build_test_table_provider_with_fields(
&[(DEFAULT_SCHEMA_NAME.to_string(), "sysstat".to_string())],
&["tenant_name", "cluster_name"],
)
.await;
eval_stmt.expr = parser::parse(case).unwrap();
let _ = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let case = r#"(sum(background_waitevent_cnt{tenant_name=~"sys",cluster_name=~"cluster1"}) by (cluster_name,tenant_name) +
sum(foreground_waitevent_cnt{tenant_name=~"sys",cluster_name=~"cluster1"}) by (cluster_name,tenant_name)) or
(sum(background_waitevent_cnt{tenant_name=~"sys",cluster_name=~"cluster1"}) by (cluster_name,tenant_name)) or
(sum(foreground_waitevent_cnt{tenant_name=~"sys",cluster_name=~"cluster1"}) by (cluster_name,tenant_name))"#;
let table_provider = build_test_table_provider_with_fields(
&[
(
DEFAULT_SCHEMA_NAME.to_string(),
"background_waitevent_cnt".to_string(),
),
(
DEFAULT_SCHEMA_NAME.to_string(),
"foreground_waitevent_cnt".to_string(),
),
],
&["tenant_name", "cluster_name"],
)
.await;
eval_stmt.expr = parser::parse(case).unwrap();
let _ = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let case = r#"avg(node_load1{cluster_name=~"cluster1"}) by (cluster_name,host_name) or max(container_cpu_load_average_10s{cluster_name=~"cluster1"}) by (cluster_name,host_name) * 100 / max(container_spec_cpu_quota{cluster_name=~"cluster1"}) by (cluster_name,host_name)"#;
let table_provider = build_test_table_provider_with_fields(
&[
(DEFAULT_SCHEMA_NAME.to_string(), "node_load1".to_string()),
(
DEFAULT_SCHEMA_NAME.to_string(),
"container_cpu_load_average_10s".to_string(),
),
(
DEFAULT_SCHEMA_NAME.to_string(),
"container_spec_cpu_quota".to_string(),
),
],
&["cluster_name", "host_name"],
)
.await;
eval_stmt.expr = parser::parse(case).unwrap();
let _ = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
}
#[tokio::test]
async fn value_matcher() {
// template
let mut eval_stmt = EvalStmt {
expr: PromExpr::NumberLiteral(NumberLiteral { val: 1.0 }),
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let cases = [
// single equal matcher
(
r#"some_metric{__field__="field_1"}"#,
vec![
"some_metric.field_1",
"some_metric.tag_0",
"some_metric.tag_1",
"some_metric.tag_2",
"some_metric.timestamp",
],
),
// two equal matchers
(
r#"some_metric{__field__="field_1", __field__="field_0"}"#,
vec![
"some_metric.field_0",
"some_metric.field_1",
"some_metric.tag_0",
"some_metric.tag_1",
"some_metric.tag_2",
"some_metric.timestamp",
],
),
// single not_eq matcher
(
r#"some_metric{__field__!="field_1"}"#,
vec![
"some_metric.field_0",
"some_metric.field_2",
"some_metric.tag_0",
"some_metric.tag_1",
"some_metric.tag_2",
"some_metric.timestamp",
],
),
// two not_eq matchers
(
r#"some_metric{__field__!="field_1", __field__!="field_2"}"#,
vec![
"some_metric.field_0",
"some_metric.tag_0",
"some_metric.tag_1",
"some_metric.tag_2",
"some_metric.timestamp",
],
),
// equal and not_eq matchers (no conflict)
(
r#"some_metric{__field__="field_1", __field__!="field_0"}"#,
vec![
"some_metric.field_1",
"some_metric.tag_0",
"some_metric.tag_1",
"some_metric.tag_2",
"some_metric.timestamp",
],
),
// equal and not_eq matchers (conflict)
(
r#"some_metric{__field__="field_2", __field__!="field_2"}"#,
vec![
"some_metric.tag_0",
"some_metric.tag_1",
"some_metric.tag_2",
"some_metric.timestamp",
],
),
// single regex eq matcher
(
r#"some_metric{__field__=~"field_1|field_2"}"#,
vec![
"some_metric.field_1",
"some_metric.field_2",
"some_metric.tag_0",
"some_metric.tag_1",
"some_metric.tag_2",
"some_metric.timestamp",
],
),
// single regex not_eq matcher
(
r#"some_metric{__field__!~"field_1|field_2"}"#,
vec![
"some_metric.field_0",
"some_metric.tag_0",
"some_metric.tag_1",
"some_metric.tag_2",
"some_metric.timestamp",
],
),
];
for case in cases {
let prom_expr = parser::parse(case.0).unwrap();
eval_stmt.expr = prom_expr;
let table_provider = build_test_table_provider(
&[(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string())],
3,
3,
)
.await;
let plan =
PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let mut fields = plan.schema().field_names();
let mut expected = case.1.into_iter().map(String::from).collect::<Vec<_>>();
fields.sort();
expected.sort();
assert_eq!(fields, expected, "case: {:?}", case.0);
}
let bad_cases = [
r#"some_metric{__field__="nonexistent"}"#,
r#"some_metric{__field__!="nonexistent"}"#,
];
for case in bad_cases {
let prom_expr = parser::parse(case).unwrap();
eval_stmt.expr = prom_expr;
let table_provider = build_test_table_provider(
&[(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string())],
3,
3,
)
.await;
let plan =
PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await;
assert!(plan.is_err(), "case: {:?}", case);
}
}
#[tokio::test]
async fn custom_schema() {
let query = "some_alt_metric{__schema__=\"greptime_private\"}";
let expected = String::from(
"PromInstantManipulate: range=[0..100000000], lookback=[1000], interval=[5000], time index=[timestamp] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n PromSeriesDivide: tags=[\"tag_0\"] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Sort: greptime_private.some_alt_metric.tag_0 ASC NULLS FIRST, greptime_private.some_alt_metric.timestamp ASC NULLS FIRST [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Filter: greptime_private.some_alt_metric.timestamp >= TimestampMillisecond(-999, None) AND greptime_private.some_alt_metric.timestamp <= TimestampMillisecond(100000000, None) [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n TableScan: greptime_private.some_alt_metric [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]",
);
indie_query_plan_compare(query, expected).await;
let query = "some_alt_metric{__database__=\"greptime_private\"}";
let expected = String::from(
"PromInstantManipulate: range=[0..100000000], lookback=[1000], interval=[5000], time index=[timestamp] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n PromSeriesDivide: tags=[\"tag_0\"] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Sort: greptime_private.some_alt_metric.tag_0 ASC NULLS FIRST, greptime_private.some_alt_metric.timestamp ASC NULLS FIRST [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Filter: greptime_private.some_alt_metric.timestamp >= TimestampMillisecond(-999, None) AND greptime_private.some_alt_metric.timestamp <= TimestampMillisecond(100000000, None) [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n TableScan: greptime_private.some_alt_metric [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]",
);
indie_query_plan_compare(query, expected).await;
let query = "some_alt_metric{__schema__=\"greptime_private\"} / some_metric";
let expected = String::from(
"Projection: some_metric.tag_0, some_metric.timestamp, CAST(greptime_private.some_alt_metric.field_0 AS Float64) / CAST(some_metric.field_0 AS Float64) AS greptime_private.some_alt_metric.field_0 / some_metric.field_0 [tag_0:Utf8, timestamp:Timestamp(ms), greptime_private.some_alt_metric.field_0 / some_metric.field_0:Float64;N]\
\n Inner Join: greptime_private.some_alt_metric.tag_0 = some_metric.tag_0, greptime_private.some_alt_metric.timestamp = some_metric.timestamp [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N, tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n SubqueryAlias: greptime_private.some_alt_metric [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n PromInstantManipulate: range=[0..100000000], lookback=[1000], interval=[5000], time index=[timestamp] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n PromSeriesDivide: tags=[\"tag_0\"] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Sort: greptime_private.some_alt_metric.tag_0 ASC NULLS FIRST, greptime_private.some_alt_metric.timestamp ASC NULLS FIRST [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Filter: greptime_private.some_alt_metric.timestamp >= TimestampMillisecond(-999, None) AND greptime_private.some_alt_metric.timestamp <= TimestampMillisecond(100000000, None) [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n TableScan: greptime_private.some_alt_metric [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n SubqueryAlias: some_metric [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n PromInstantManipulate: range=[0..100000000], lookback=[1000], interval=[5000], time index=[timestamp] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n PromSeriesDivide: tags=[\"tag_0\"] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Sort: some_metric.tag_0 ASC NULLS FIRST, some_metric.timestamp ASC NULLS FIRST [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n Filter: some_metric.timestamp >= TimestampMillisecond(-999, None) AND some_metric.timestamp <= TimestampMillisecond(100000000, None) [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]\
\n TableScan: some_metric [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]",
);
indie_query_plan_compare(query, expected).await;
}
#[tokio::test]
async fn only_equals_is_supported_for_special_matcher() {
let queries = &[
"some_alt_metric{__schema__!=\"greptime_private\"}",
"some_alt_metric{__schema__=~\"lalala\"}",
"some_alt_metric{__database__!=\"greptime_private\"}",
"some_alt_metric{__database__=~\"lalala\"}",
];
for query in queries {
let prom_expr = parser::parse(query).unwrap();
let eval_stmt = EvalStmt {
expr: prom_expr,
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let table_provider = build_test_table_provider(
&[
(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string()),
(
"greptime_private".to_string(),
"some_alt_metric".to_string(),
),
],
1,
1,
)
.await;
let plan =
PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await;
assert!(plan.is_err(), "query: {:?}", query);
}
}
#[tokio::test]
async fn native_scan_bounds_preserve_zero_lookback_and_overflow() {
let table_provider = build_test_table_provider(
&[(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string())],
1,
1,
)
.await;
let mut planner = PromPlanner {
table_provider,
ctx: PromPlannerContext::from_eval_stmt(&build_eval_stmt("some_metric")),
promql_annotations: None,
};
planner.ctx.time_index_column = Some("timestamp".to_string());
planner.ctx.start = 1_000;
planner.ctx.lookback_delta = 0;
let schema = Arc::new(
DFSchema::try_from(ArrowSchema::new(vec![Field::new(
"timestamp",
ArrowDataType::Timestamp(ArrowTimeUnit::Nanosecond, None),
false,
)]))
.unwrap(),
);
for (end, interval, windows) in [
(1_000, 1_000, 1),
(2_000, 1_000, 1),
(7_201_000, 7_200_000, 2),
] {
planner.ctx.end = end;
planner.ctx.interval = interval;
let filter = planner
.build_time_index_filter(0, &schema)
.unwrap()
.unwrap()
.to_string();
assert_eq!(filter.matches(">=").count(), windows, "{filter}");
assert!(
filter.contains("TimestampNanosecond(1000000000, None)"),
"{filter}"
);
}
planner.ctx.end = i64::MAX;
let filter = planner
.build_time_index_filter(0, &schema)
.unwrap()
.unwrap()
.to_string();
assert!(
filter.contains("timestamp >= TimestampNanosecond(1000000000, None)"),
"{filter}"
);
// A lookback subtraction can underflow milliseconds while the upper bound remains
// representable. Keep that upper bound so LastRow cannot select a future sample.
let ms_schema = Arc::new(
DFSchema::try_from(ArrowSchema::new(vec![Field::new(
"timestamp",
ArrowDataType::Timestamp(ArrowTimeUnit::Millisecond, None),
false,
)]))
.unwrap(),
);
planner.ctx.start = i64::MIN + 100;
planner.ctx.end = planner.ctx.start;
planner.ctx.lookback_delta = 200;
let filter = planner
.build_time_index_filter(0, &ms_schema)
.unwrap()
.unwrap()
.to_string();
assert_eq!(
filter,
format!(
"timestamp <= TimestampMillisecond({}, None)",
i64::MIN + 100
)
);
// The lower bound can also overflow while converting milliseconds to native nanoseconds.
// Its representable upper bound still has to reach the scan.
planner.ctx.start = 0;
planner.ctx.end = 0;
planner.ctx.lookback_delta = 300_000;
let filter = planner
.build_time_index_filter(9_223_372_036_854, &schema)
.unwrap()
.unwrap()
.to_string();
assert_eq!(
filter,
"timestamp <= TimestampNanosecond(-9223372036854000000, None)"
);
}
#[tokio::test]
async fn test_non_ms_precision() {
let catalog_list = MemoryCatalogManager::with_default_setup();
let columns = vec![
ColumnSchema::new(
"tag".to_string(),
ConcreteDataType::string_datatype(),
false,
),
ColumnSchema::new(
"timestamp".to_string(),
ConcreteDataType::timestamp_nanosecond_datatype(),
false,
)
.with_time_index(true),
ColumnSchema::new(
"field".to_string(),
ConcreteDataType::float64_datatype(),
true,
),
];
let schema = Arc::new(Schema::new(columns));
let table_meta = TableMetaBuilder::empty()
.schema(schema)
.primary_key_indices(vec![0])
.value_indices(vec![2])
.next_column_id(1024)
.build()
.unwrap();
let table_info = TableInfoBuilder::default()
.name("metrics".to_string())
.meta(table_meta)
.build()
.unwrap();
let table = EmptyTable::from_table_info(&table_info);
assert!(
catalog_list
.register_table_sync(RegisterTableRequest {
catalog: DEFAULT_CATALOG_NAME.to_string(),
schema: DEFAULT_SCHEMA_NAME.to_string(),
table_name: "metrics".to_string(),
table_id: 1024,
table,
})
.is_ok()
);
let plan = PromPlanner::stmt_to_plan(
DfTableSourceProvider::new(
catalog_list.clone(),
false,
QueryContext::arc(),
DummyDecoder::arc(),
true,
),
&EvalStmt {
expr: parser::parse("metrics{tag = \"1\"}").unwrap(),
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
},
&build_query_engine_state(),
)
.await
.unwrap();
assert_eq!(
plan.display_indent_schema().to_string(),
"PromInstantManipulate: range=[0..100000000], lookback=[1000], interval=[5000], time index=[timestamp] [field:Float64;N, tag:Utf8, timestamp:Timestamp(ms)]\n PromSeriesDivide: tags=[\"tag\"] [field:Float64;N, tag:Utf8, timestamp:Timestamp(ns)]\n Sort: metrics.tag ASC NULLS FIRST, metrics.timestamp ASC NULLS FIRST [field:Float64;N, tag:Utf8, timestamp:Timestamp(ns)]\n Filter: metrics.tag = Utf8(\"1\") AND metrics.timestamp > TimestampNanosecond(-1000000000, None) AND metrics.timestamp <= TimestampNanosecond(100000000000000, None) [field:Float64;N, tag:Utf8, timestamp:Timestamp(ns)]\n Projection: metrics.field, metrics.tag, metrics.timestamp [field:Float64;N, tag:Utf8, timestamp:Timestamp(ns)]\n TableScan: metrics [tag:Utf8, timestamp:Timestamp(ns), field:Float64;N]"
);
let plan = PromPlanner::stmt_to_plan(
DfTableSourceProvider::new(
catalog_list.clone(),
false,
QueryContext::arc(),
DummyDecoder::arc(),
true,
),
&EvalStmt {
expr: parser::parse("avg_over_time(metrics{tag = \"1\"}[5s])").unwrap(),
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
},
&build_query_engine_state(),
)
.await
.unwrap();
assert_eq!(
plan.display_indent_schema().to_string(),
"Filter: prom_avg_over_time(timestamp_range,field) IS NOT NULL [timestamp:Timestamp(ms), prom_avg_over_time(timestamp_range,field):Float64;N, tag:Utf8]\n Projection: metrics.timestamp, prom_avg_over_time(timestamp_range, field) AS prom_avg_over_time(timestamp_range,field), metrics.tag [timestamp:Timestamp(ms), prom_avg_over_time(timestamp_range,field):Float64;N, tag:Utf8]\n PromRangeManipulate: req range=[0..100000000], interval=[5000], eval range=[5000], time index=[timestamp], values=[\"field\"] [field:Dictionary(Int64, Float64);N, tag:Utf8, timestamp:Timestamp(ms), timestamp_range:Dictionary(Int64, Timestamp(ms))]\n PromSeriesNormalize: offset=[0], time index=[timestamp], filter NaN: [true] [field:Float64;N, tag:Utf8, timestamp:Timestamp(ns)]\n PromSeriesDivide: tags=[\"tag\"] [field:Float64;N, tag:Utf8, timestamp:Timestamp(ns)]\n Sort: metrics.tag ASC NULLS FIRST, metrics.timestamp ASC NULLS FIRST [field:Float64;N, tag:Utf8, timestamp:Timestamp(ns)]\n Filter: metrics.tag = Utf8(\"1\") AND metrics.timestamp > TimestampNanosecond(-5000000000, None) AND metrics.timestamp <= TimestampNanosecond(100000000000000, None) [field:Float64;N, tag:Utf8, timestamp:Timestamp(ns)]\n Projection: metrics.field, metrics.tag, metrics.timestamp [field:Float64;N, tag:Utf8, timestamp:Timestamp(ns)]\n TableScan: metrics [tag:Utf8, timestamp:Timestamp(ns), field:Float64;N]"
);
}
#[tokio::test]
async fn test_nonexistent_label() {
// template
let mut eval_stmt = EvalStmt {
expr: PromExpr::NumberLiteral(NumberLiteral { val: 1.0 }),
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let case = r#"some_metric{nonexistent="hi"}"#;
let prom_expr = parser::parse(case).unwrap();
eval_stmt.expr = prom_expr;
let table_provider = build_test_table_provider(
&[(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string())],
3,
3,
)
.await;
// Should be ok
let _ = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
}
#[tokio::test]
async fn test_label_join() {
let prom_expr =
parser::parse("label_join(up{tag_0='api-server'}, 'foo', ',', 'tag_1', 'tag_2', 'tag_3')")
.unwrap();
let eval_stmt = EvalStmt {
expr: prom_expr,
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let table_provider =
build_test_table_provider(&[(DEFAULT_SCHEMA_NAME.to_string(), "up".to_string())], 4, 1)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let expected = r#"
Filter: up.field_0 IS NOT NULL [timestamp:Timestamp(ms), field_0:Float64;N, foo:Utf8;N, tag_0:Utf8, tag_1:Utf8, tag_2:Utf8, tag_3:Utf8]
Projection: up.timestamp, up.field_0, concat_ws(Utf8(","), up.tag_1, up.tag_2, up.tag_3) AS foo, up.tag_0, up.tag_1, up.tag_2, up.tag_3 [timestamp:Timestamp(ms), field_0:Float64;N, foo:Utf8;N, tag_0:Utf8, tag_1:Utf8, tag_2:Utf8, tag_3:Utf8]
PromInstantManipulate: range=[0..100000000], lookback=[1000], interval=[5000], time index=[timestamp] [tag_0:Utf8, tag_1:Utf8, tag_2:Utf8, tag_3:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]
PromSeriesDivide: tags=["tag_0", "tag_1", "tag_2", "tag_3"] [tag_0:Utf8, tag_1:Utf8, tag_2:Utf8, tag_3:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]
Sort: up.tag_0 ASC NULLS FIRST, up.tag_1 ASC NULLS FIRST, up.tag_2 ASC NULLS FIRST, up.tag_3 ASC NULLS FIRST, up.timestamp ASC NULLS FIRST [tag_0:Utf8, tag_1:Utf8, tag_2:Utf8, tag_3:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]
Filter: up.tag_0 = Utf8("api-server") AND up.timestamp >= TimestampMillisecond(-999, None) AND up.timestamp <= TimestampMillisecond(100000000, None) [tag_0:Utf8, tag_1:Utf8, tag_2:Utf8, tag_3:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]
TableScan: up [tag_0:Utf8, tag_1:Utf8, tag_2:Utf8, tag_3:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]"#;
let ret = plan.display_indent_schema().to_string();
assert_eq!(format!("\n{ret}"), expected, "\n{}", ret);
}
#[tokio::test]
async fn test_label_replace() {
let prom_expr =
parser::parse("label_replace(up{tag_0=\"a:c\"}, \"foo\", \"$1\", \"tag_0\", \"(.*):.*\")")
.unwrap();
let eval_stmt = EvalStmt {
expr: prom_expr,
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let table_provider =
build_test_table_provider(&[(DEFAULT_SCHEMA_NAME.to_string(), "up".to_string())], 1, 1)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let expected = r#"
Filter: up.field_0 IS NOT NULL [timestamp:Timestamp(ms), field_0:Float64;N, foo:Utf8;N, tag_0:Utf8]
Projection: up.timestamp, up.field_0, regexp_replace(up.tag_0, Utf8("^(?s:(.*):.*)$"), Utf8("$1")) AS foo, up.tag_0 [timestamp:Timestamp(ms), field_0:Float64;N, foo:Utf8;N, tag_0:Utf8]
PromInstantManipulate: range=[0..100000000], lookback=[1000], interval=[5000], time index=[timestamp] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]
PromSeriesDivide: tags=["tag_0"] [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]
Sort: up.tag_0 ASC NULLS FIRST, up.timestamp ASC NULLS FIRST [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]
Filter: up.tag_0 = Utf8("a:c") AND up.timestamp >= TimestampMillisecond(-999, None) AND up.timestamp <= TimestampMillisecond(100000000, None) [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]
TableScan: up [tag_0:Utf8, timestamp:Timestamp(ms), field_0:Float64;N]"#;
let ret = plan.display_indent_schema().to_string();
assert_eq!(format!("\n{ret}"), expected, "\n{}", ret);
}
#[tokio::test]
async fn label_replace_aggregation_queries_plan_successfully() {
let aggregate = r#"sum by (foo) (label_replace(some_metric, "foo", "$1", "tag_0", "(.*)"))"#;
let queries = [
aggregate.to_string(),
format!("{aggregate} <= 10"),
format!("{aggregate} * 0.8"),
format!("0.8 * {aggregate}"),
format!("{aggregate} <= {aggregate} * 0.8"),
];
let state = build_query_engine_state();
let mut failures = Vec::new();
for query in queries {
let table_provider = build_test_table_provider(
&[(DEFAULT_SCHEMA_NAME.to_string(), "some_metric".to_string())],
1,
1,
)
.await;
if let Err(error) =
PromPlanner::stmt_to_plan(table_provider, &build_eval_stmt(&query), &state).await
{
failures.push(format!("{query}: {error:?}"));
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[tokio::test]
async fn test_matchers_to_expr() {
let mut eval_stmt = EvalStmt {
expr: PromExpr::NumberLiteral(NumberLiteral { val: 1.0 }),
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let case =
r#"sum(prometheus_tsdb_head_series{tag_1=~"(10.0.160.237:8080|10.0.160.237:9090)"})"#;
let prom_expr = parser::parse(case).unwrap();
eval_stmt.expr = prom_expr;
let table_provider = build_test_table_provider(
&[(
DEFAULT_SCHEMA_NAME.to_string(),
"prometheus_tsdb_head_series".to_string(),
)],
3,
3,
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let expected = "Sort: prometheus_tsdb_head_series.timestamp ASC NULLS LAST [timestamp:Timestamp(ms), sum(prometheus_tsdb_head_series.field_0):Float64;N, sum(prometheus_tsdb_head_series.field_1):Float64;N, sum(prometheus_tsdb_head_series.field_2):Float64;N]\
\n Aggregate: groupBy=[[prometheus_tsdb_head_series.timestamp]], aggr=[[sum(prometheus_tsdb_head_series.field_0), sum(prometheus_tsdb_head_series.field_1), sum(prometheus_tsdb_head_series.field_2)]] [timestamp:Timestamp(ms), sum(prometheus_tsdb_head_series.field_0):Float64;N, sum(prometheus_tsdb_head_series.field_1):Float64;N, sum(prometheus_tsdb_head_series.field_2):Float64;N]\
\n PromInstantManipulate: range=[0..100000000], lookback=[1000], interval=[5000], time index=[timestamp] [tag_0:Utf8, tag_1:Utf8, tag_2:Utf8, timestamp:Timestamp(ms), field_0:Float64;N, field_1:Float64;N, field_2:Float64;N]\
\n PromSeriesDivide: tags=[\"tag_0\", \"tag_1\", \"tag_2\"] [tag_0:Utf8, tag_1:Utf8, tag_2:Utf8, timestamp:Timestamp(ms), field_0:Float64;N, field_1:Float64;N, field_2:Float64;N]\
\n Sort: prometheus_tsdb_head_series.tag_0 ASC NULLS FIRST, prometheus_tsdb_head_series.tag_1 ASC NULLS FIRST, prometheus_tsdb_head_series.tag_2 ASC NULLS FIRST, prometheus_tsdb_head_series.timestamp ASC NULLS FIRST [tag_0:Utf8, tag_1:Utf8, tag_2:Utf8, timestamp:Timestamp(ms), field_0:Float64;N, field_1:Float64;N, field_2:Float64;N]\
\n Filter: prometheus_tsdb_head_series.tag_1 ~ Utf8(\"^(?:(10.0.160.237:8080|10.0.160.237:9090))$\") AND prometheus_tsdb_head_series.timestamp >= TimestampMillisecond(-999, None) AND prometheus_tsdb_head_series.timestamp <= TimestampMillisecond(100000000, None) [tag_0:Utf8, tag_1:Utf8, tag_2:Utf8, timestamp:Timestamp(ms), field_0:Float64;N, field_1:Float64;N, field_2:Float64;N]\
\n TableScan: prometheus_tsdb_head_series [tag_0:Utf8, tag_1:Utf8, tag_2:Utf8, timestamp:Timestamp(ms), field_0:Float64;N, field_1:Float64;N, field_2:Float64;N]";
assert_eq!(plan.display_indent_schema().to_string(), expected);
}
#[tokio::test]
async fn test_topk_expr() {
let mut eval_stmt = EvalStmt {
expr: PromExpr::NumberLiteral(NumberLiteral { val: 1.0 }),
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let case = r#"topk(10, sum(prometheus_tsdb_head_series{ip=~"(10.0.160.237:8080|10.0.160.237:9090)"}) by (ip))"#;
let prom_expr = parser::parse(case).unwrap();
eval_stmt.expr = prom_expr;
let table_provider = build_test_table_provider_with_fields(
&[
(
DEFAULT_SCHEMA_NAME.to_string(),
"prometheus_tsdb_head_series".to_string(),
),
(
DEFAULT_SCHEMA_NAME.to_string(),
"http_server_requests_seconds_count".to_string(),
),
],
&["ip"],
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let expected = "Projection: sum(prometheus_tsdb_head_series.greptime_value), prometheus_tsdb_head_series.ip, prometheus_tsdb_head_series.greptime_timestamp [sum(prometheus_tsdb_head_series.greptime_value):Float64;N, ip:Utf8, greptime_timestamp:Timestamp(ms)]\
\n Sort: prometheus_tsdb_head_series.greptime_timestamp ASC NULLS LAST, row_number() PARTITION BY [prometheus_tsdb_head_series.greptime_timestamp] ORDER BY [sum(prometheus_tsdb_head_series.greptime_value) DESC NULLS FIRST, prometheus_tsdb_head_series.ip DESC NULLS FIRST] ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ASC NULLS LAST [ip:Utf8, greptime_timestamp:Timestamp(ms), sum(prometheus_tsdb_head_series.greptime_value):Float64;N, row_number() PARTITION BY [prometheus_tsdb_head_series.greptime_timestamp] ORDER BY [sum(prometheus_tsdb_head_series.greptime_value) DESC NULLS FIRST, prometheus_tsdb_head_series.ip DESC NULLS FIRST] ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW:UInt64]\
\n Filter: row_number() PARTITION BY [prometheus_tsdb_head_series.greptime_timestamp] ORDER BY [sum(prometheus_tsdb_head_series.greptime_value) DESC NULLS FIRST, prometheus_tsdb_head_series.ip DESC NULLS FIRST] ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW <= Float64(10) [ip:Utf8, greptime_timestamp:Timestamp(ms), sum(prometheus_tsdb_head_series.greptime_value):Float64;N, row_number() PARTITION BY [prometheus_tsdb_head_series.greptime_timestamp] ORDER BY [sum(prometheus_tsdb_head_series.greptime_value) DESC NULLS FIRST, prometheus_tsdb_head_series.ip DESC NULLS FIRST] ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW:UInt64]\
\n WindowAggr: windowExpr=[[row_number() PARTITION BY [prometheus_tsdb_head_series.greptime_timestamp] ORDER BY [sum(prometheus_tsdb_head_series.greptime_value) DESC NULLS FIRST, prometheus_tsdb_head_series.ip DESC NULLS FIRST] ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW]] [ip:Utf8, greptime_timestamp:Timestamp(ms), sum(prometheus_tsdb_head_series.greptime_value):Float64;N, row_number() PARTITION BY [prometheus_tsdb_head_series.greptime_timestamp] ORDER BY [sum(prometheus_tsdb_head_series.greptime_value) DESC NULLS FIRST, prometheus_tsdb_head_series.ip DESC NULLS FIRST] ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW:UInt64]\
\n Sort: prometheus_tsdb_head_series.ip ASC NULLS LAST, prometheus_tsdb_head_series.greptime_timestamp ASC NULLS LAST [ip:Utf8, greptime_timestamp:Timestamp(ms), sum(prometheus_tsdb_head_series.greptime_value):Float64;N]\
\n Aggregate: groupBy=[[prometheus_tsdb_head_series.ip, prometheus_tsdb_head_series.greptime_timestamp]], aggr=[[sum(prometheus_tsdb_head_series.greptime_value)]] [ip:Utf8, greptime_timestamp:Timestamp(ms), sum(prometheus_tsdb_head_series.greptime_value):Float64;N]\
\n PromInstantManipulate: range=[0..100000000], lookback=[1000], interval=[5000], time index=[greptime_timestamp] [ip:Utf8, greptime_timestamp:Timestamp(ms), greptime_value:Float64;N]\
\n PromSeriesDivide: tags=[\"ip\"] [ip:Utf8, greptime_timestamp:Timestamp(ms), greptime_value:Float64;N]\
\n Sort: prometheus_tsdb_head_series.ip ASC NULLS FIRST, prometheus_tsdb_head_series.greptime_timestamp ASC NULLS FIRST [ip:Utf8, greptime_timestamp:Timestamp(ms), greptime_value:Float64;N]\
\n Filter: prometheus_tsdb_head_series.ip ~ Utf8(\"^(?:(10.0.160.237:8080|10.0.160.237:9090))$\") AND prometheus_tsdb_head_series.greptime_timestamp >= TimestampMillisecond(-999, None) AND prometheus_tsdb_head_series.greptime_timestamp <= TimestampMillisecond(100000000, None) [ip:Utf8, greptime_timestamp:Timestamp(ms), greptime_value:Float64;N]\
\n TableScan: prometheus_tsdb_head_series [ip:Utf8, greptime_timestamp:Timestamp(ms), greptime_value:Float64;N]";
assert_eq!(plan.display_indent_schema().to_string(), expected);
}
#[tokio::test]
async fn test_count_values_expr() {
let mut eval_stmt = EvalStmt {
expr: PromExpr::NumberLiteral(NumberLiteral { val: 1.0 }),
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let case = r#"count_values('series', prometheus_tsdb_head_series{ip=~"(10.0.160.237:8080|10.0.160.237:9090)"}) by (ip)"#;
let prom_expr = parser::parse(case).unwrap();
eval_stmt.expr = prom_expr;
let table_provider = build_test_table_provider_with_fields(
&[
(
DEFAULT_SCHEMA_NAME.to_string(),
"prometheus_tsdb_head_series".to_string(),
),
(
DEFAULT_SCHEMA_NAME.to_string(),
"http_server_requests_seconds_count".to_string(),
),
],
&["ip"],
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let expected = "Sort: prometheus_tsdb_head_series.ip ASC NULLS LAST, prometheus_tsdb_head_series.greptime_timestamp ASC NULLS LAST, prometheus_tsdb_head_series.series ASC NULLS LAST [count(prometheus_tsdb_head_series.greptime_value):Int64, ip:Utf8, greptime_timestamp:Timestamp(ms), series:Utf8;N]\
\n Projection: count(prometheus_tsdb_head_series.greptime_value), prometheus_tsdb_head_series.ip, prometheus_tsdb_head_series.greptime_timestamp, prom_float_to_string(prometheus_tsdb_head_series.greptime_value) AS series [count(prometheus_tsdb_head_series.greptime_value):Int64, ip:Utf8, greptime_timestamp:Timestamp(ms), series:Utf8;N]\
\n Aggregate: groupBy=[[prometheus_tsdb_head_series.ip, prometheus_tsdb_head_series.greptime_timestamp, prom_float_to_string(prometheus_tsdb_head_series.greptime_value)]], aggr=[[count(prometheus_tsdb_head_series.greptime_value)]] [ip:Utf8, greptime_timestamp:Timestamp(ms), prom_float_to_string(prometheus_tsdb_head_series.greptime_value):Utf8;N, count(prometheus_tsdb_head_series.greptime_value):Int64]\
\n PromInstantManipulate: range=[0..100000000], lookback=[1000], interval=[5000], time index=[greptime_timestamp] [ip:Utf8, greptime_timestamp:Timestamp(ms), greptime_value:Float64;N]\
\n PromSeriesDivide: tags=[\"ip\"] [ip:Utf8, greptime_timestamp:Timestamp(ms), greptime_value:Float64;N]\
\n Sort: prometheus_tsdb_head_series.ip ASC NULLS FIRST, prometheus_tsdb_head_series.greptime_timestamp ASC NULLS FIRST [ip:Utf8, greptime_timestamp:Timestamp(ms), greptime_value:Float64;N]\
\n Filter: prometheus_tsdb_head_series.ip ~ Utf8(\"^(?:(10.0.160.237:8080|10.0.160.237:9090))$\") AND prometheus_tsdb_head_series.greptime_timestamp >= TimestampMillisecond(-999, None) AND prometheus_tsdb_head_series.greptime_timestamp <= TimestampMillisecond(100000000, None) [ip:Utf8, greptime_timestamp:Timestamp(ms), greptime_value:Float64;N]\
\n TableScan: prometheus_tsdb_head_series [ip:Utf8, greptime_timestamp:Timestamp(ms), greptime_value:Float64;N]";
assert_eq!(plan.display_indent_schema().to_string(), expected);
}
#[tokio::test]
async fn test_value_alias() {
let mut eval_stmt = EvalStmt {
expr: PromExpr::NumberLiteral(NumberLiteral { val: 1.0 }),
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let case = r#"count_values('series', prometheus_tsdb_head_series{ip=~"(10.0.160.237:8080|10.0.160.237:9090)"}) by (ip)"#;
let prom_expr = parser::parse(case).unwrap();
eval_stmt.expr = prom_expr;
eval_stmt = QueryLanguageParser::apply_alias_extension(eval_stmt, "my_series");
let table_provider = build_test_table_provider_with_fields(
&[
(
DEFAULT_SCHEMA_NAME.to_string(),
"prometheus_tsdb_head_series".to_string(),
),
(
DEFAULT_SCHEMA_NAME.to_string(),
"http_server_requests_seconds_count".to_string(),
),
],
&["ip"],
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let expected = r#"
Projection: count(prometheus_tsdb_head_series.greptime_value) AS my_series, prometheus_tsdb_head_series.ip, prometheus_tsdb_head_series.series, prometheus_tsdb_head_series.greptime_timestamp [my_series:Int64, ip:Utf8, series:Utf8;N, greptime_timestamp:Timestamp(ms)]
Sort: prometheus_tsdb_head_series.ip ASC NULLS LAST, prometheus_tsdb_head_series.greptime_timestamp ASC NULLS LAST, prometheus_tsdb_head_series.series ASC NULLS LAST [count(prometheus_tsdb_head_series.greptime_value):Int64, ip:Utf8, greptime_timestamp:Timestamp(ms), series:Utf8;N]
Projection: count(prometheus_tsdb_head_series.greptime_value), prometheus_tsdb_head_series.ip, prometheus_tsdb_head_series.greptime_timestamp, prom_float_to_string(prometheus_tsdb_head_series.greptime_value) AS series [count(prometheus_tsdb_head_series.greptime_value):Int64, ip:Utf8, greptime_timestamp:Timestamp(ms), series:Utf8;N]
Aggregate: groupBy=[[prometheus_tsdb_head_series.ip, prometheus_tsdb_head_series.greptime_timestamp, prom_float_to_string(prometheus_tsdb_head_series.greptime_value)]], aggr=[[count(prometheus_tsdb_head_series.greptime_value)]] [ip:Utf8, greptime_timestamp:Timestamp(ms), prom_float_to_string(prometheus_tsdb_head_series.greptime_value):Utf8;N, count(prometheus_tsdb_head_series.greptime_value):Int64]
PromInstantManipulate: range=[0..100000000], lookback=[1000], interval=[5000], time index=[greptime_timestamp] [ip:Utf8, greptime_timestamp:Timestamp(ms), greptime_value:Float64;N]
PromSeriesDivide: tags=["ip"] [ip:Utf8, greptime_timestamp:Timestamp(ms), greptime_value:Float64;N]
Sort: prometheus_tsdb_head_series.ip ASC NULLS FIRST, prometheus_tsdb_head_series.greptime_timestamp ASC NULLS FIRST [ip:Utf8, greptime_timestamp:Timestamp(ms), greptime_value:Float64;N]
Filter: prometheus_tsdb_head_series.ip ~ Utf8("^(?:(10.0.160.237:8080|10.0.160.237:9090))$") AND prometheus_tsdb_head_series.greptime_timestamp >= TimestampMillisecond(-999, None) AND prometheus_tsdb_head_series.greptime_timestamp <= TimestampMillisecond(100000000, None) [ip:Utf8, greptime_timestamp:Timestamp(ms), greptime_value:Float64;N]
TableScan: prometheus_tsdb_head_series [ip:Utf8, greptime_timestamp:Timestamp(ms), greptime_value:Float64;N]"#;
assert_eq!(format!("\n{}", plan.display_indent_schema()), expected);
}
#[tokio::test]
async fn test_quantile_expr() {
let mut eval_stmt = EvalStmt {
expr: PromExpr::NumberLiteral(NumberLiteral { val: 1.0 }),
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
let case = r#"quantile(0.3, sum(prometheus_tsdb_head_series{ip=~"(10.0.160.237:8080|10.0.160.237:9090)"}) by (ip))"#;
let prom_expr = parser::parse(case).unwrap();
eval_stmt.expr = prom_expr;
let table_provider = build_test_table_provider_with_fields(
&[
(
DEFAULT_SCHEMA_NAME.to_string(),
"prometheus_tsdb_head_series".to_string(),
),
(
DEFAULT_SCHEMA_NAME.to_string(),
"http_server_requests_seconds_count".to_string(),
),
],
&["ip"],
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
let expected = "Sort: prometheus_tsdb_head_series.greptime_timestamp ASC NULLS LAST [greptime_timestamp:Timestamp(ms), quantile(Float64(0.3),sum(prometheus_tsdb_head_series.greptime_value)):Float64;N]\
\n Aggregate: groupBy=[[prometheus_tsdb_head_series.greptime_timestamp]], aggr=[[quantile(Float64(0.3), sum(prometheus_tsdb_head_series.greptime_value))]] [greptime_timestamp:Timestamp(ms), quantile(Float64(0.3),sum(prometheus_tsdb_head_series.greptime_value)):Float64;N]\
\n Sort: prometheus_tsdb_head_series.ip ASC NULLS LAST, prometheus_tsdb_head_series.greptime_timestamp ASC NULLS LAST [ip:Utf8, greptime_timestamp:Timestamp(ms), sum(prometheus_tsdb_head_series.greptime_value):Float64;N]\
\n Aggregate: groupBy=[[prometheus_tsdb_head_series.ip, prometheus_tsdb_head_series.greptime_timestamp]], aggr=[[sum(prometheus_tsdb_head_series.greptime_value)]] [ip:Utf8, greptime_timestamp:Timestamp(ms), sum(prometheus_tsdb_head_series.greptime_value):Float64;N]\
\n PromInstantManipulate: range=[0..100000000], lookback=[1000], interval=[5000], time index=[greptime_timestamp] [ip:Utf8, greptime_timestamp:Timestamp(ms), greptime_value:Float64;N]\
\n PromSeriesDivide: tags=[\"ip\"] [ip:Utf8, greptime_timestamp:Timestamp(ms), greptime_value:Float64;N]\
\n Sort: prometheus_tsdb_head_series.ip ASC NULLS FIRST, prometheus_tsdb_head_series.greptime_timestamp ASC NULLS FIRST [ip:Utf8, greptime_timestamp:Timestamp(ms), greptime_value:Float64;N]\
\n Filter: prometheus_tsdb_head_series.ip ~ Utf8(\"^(?:(10.0.160.237:8080|10.0.160.237:9090))$\") AND prometheus_tsdb_head_series.greptime_timestamp >= TimestampMillisecond(-999, None) AND prometheus_tsdb_head_series.greptime_timestamp <= TimestampMillisecond(100000000, None) [ip:Utf8, greptime_timestamp:Timestamp(ms), greptime_value:Float64;N]\
\n TableScan: prometheus_tsdb_head_series [ip:Utf8, greptime_timestamp:Timestamp(ms), greptime_value:Float64;N]";
assert_eq!(plan.display_indent_schema().to_string(), expected);
}
#[tokio::test]
async fn test_or_not_exists_table_label() {
let state = build_query_engine_state();
let provider = build_test_table_provider_with_fields(
&[(DEFAULT_SCHEMA_NAME.to_string(), "normal_metric".to_string())],
&["job"],
)
.await;
let raw = PromPlanner::stmt_to_plan(
provider,
&build_eval_stmt(r#"missing_metric or on(absent_label) normal_metric"#),
&state,
)
.await
.unwrap();
assert!(
raw.display_indent_schema()
.to_string()
.contains("__promql_or_match_0@")
);
let (optimized, batches) = execute(raw, &state).await;
assert_no_internal_or_keys(optimized.schema());
assert!(batches.iter().all(|batch| {
batch
.schema()
.fields()
.iter()
.all(|field| !field.name().starts_with("__promql_or_match_"))
}));
}
#[tokio::test]
async fn test_histogram_quantile_missing_le_column() {
let mut eval_stmt = EvalStmt {
expr: PromExpr::NumberLiteral(NumberLiteral { val: 1.0 }),
start: UNIX_EPOCH,
end: UNIX_EPOCH
.checked_add(Duration::from_secs(100_000))
.unwrap(),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(1),
};
// Test case: histogram_quantile with a table that doesn't have 'le' column
let case = r#"histogram_quantile(0.99, sum by(pod,instance,le) (rate(non_existent_histogram_bucket{instance=~"xxx"}[1m])))"#;
let prom_expr = parser::parse(case).unwrap();
eval_stmt.expr = prom_expr;
// Create a table provider with a table that doesn't have 'le' column
let table_provider = build_test_table_provider_with_fields(
&[(
DEFAULT_SCHEMA_NAME.to_string(),
"non_existent_histogram_bucket".to_string(),
)],
&["pod", "instance"], // Note: no 'le' column
)
.await;
// Should return empty result instead of error
let result =
PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state()).await;
// This should succeed now (returning empty result) instead of failing with "Cannot find column le"
assert!(
result.is_ok(),
"Expected successful plan creation with empty result, but got error: {:?}",
result.err()
);
// Verify that the result is an EmptyRelation
let plan = result.unwrap();
match plan {
LogicalPlan::EmptyRelation(_) => {
// This is what we expect
}
_ => panic!("Expected EmptyRelation, but got: {:?}", plan),
}
}
#[tokio::test]
async fn test_direct_or_normalizes_missing_match_labels() {
type Case<'a> = (
Option<Option<&'a str>>,
Option<Option<&'a str>>,
i64,
i64,
&'a [(f64, Option<&'a str>)],
);
let modifier = or_modifier("lhs or on(k) rhs");
#[rustfmt::skip]
let cases: &[Case<'_>] = &[
(None, None, 1, 1, &[(1.0, None)]),
(None, Some(Some("")), 1, 1, &[(1.0, None)]),
(Some(Some("")), None, 1, 1, &[(1.0, Some(""))]),
(None, Some(Some("r")), 1, 1, &[(1.0, None), (2.0, Some("r"))]),
(Some(Some("l")), None, 1, 1, &[(1.0, Some("l")), (2.0, None)]),
(Some(None), Some(Some("")), 1, 1, &[(1.0, None)]),
(Some(None), Some(Some("r")), 1, 1, &[(1.0, None), (2.0, Some("r"))]),
(Some(Some("same")), Some(Some("same")), 1, 2, &[(1.0, Some("same")), (2.0, Some("same"))]),
];
for &(left, right, left_ts, right_ts, expected) in cases {
let (optimized, batches) = run(
&matrix_source("lhs", left, left_ts, 1.0),
&matrix_source("rhs", right, right_ts, 2.0),
matrix_context("lhs", left),
matrix_context("rhs", right),
&modifier,
)
.await;
assert_no_internal_or_keys(optimized.schema());
assert_eq!(
rows(&batches),
expected
.iter()
.map(|(value, label)| (*value, label.map(str::to_string)))
.collect::<Vec<_>>()
);
}
}
#[tokio::test]
async fn test_direct_or_match_modifiers() {
for (modifier, left, right, expected) in [
(None, "left", "right", 2),
(or_modifier("lhs or on(k) rhs"), "same", "same", 1),
(or_modifier("lhs or on() rhs"), "left", "right", 1),
(or_modifier("lhs or ignoring(k) rhs"), "left", "right", 1),
] {
let (_, batches) = run(
&matrix_source("lhs", Some(Some(left)), 1, 1.0),
&matrix_source("rhs", Some(Some(right)), 1, 2.0),
direct_or_context("lhs", &["job", "k"], "v"),
direct_or_context("rhs", &["job", "k"], "v"),
&modifier,
)
.await;
assert_eq!(
batches.iter().map(RecordBatch::num_rows).sum::<usize>(),
expected
);
}
}
#[tokio::test]
async fn test_direct_or_nested_projection_uses_left_context() {
let left = matrix_source("lhs", Some(Some("k")), 1, 1.0);
let right = matrix_source("rhs", Some(Some("k")), 1, 2.0);
let raw = plan_direct_or(
scan(&left),
scan(&right),
direct_or_context("lhs", &["job", "k"], "v"),
direct_or_context("rhs", &["job", "k"], "v"),
&or_modifier("lhs or on(k) rhs"),
)
.await;
assert!(raw.schema().iter().any(|(qualifier, field)| {
qualifier.as_ref().is_some_and(|q| q.to_string() == "lhs") && field.name() == "v"
}));
let nested = LogicalPlanBuilder::from(raw)
.project(vec![
DfExpr::BinaryExpr(BinaryExpr {
left: Box::new(DfExpr::Column(Column::new(
Some(TableReference::bare("lhs")),
"v",
))),
op: Operator::Plus,
right: Box::new(lit(1.0)),
})
.alias("v_plus"),
])
.unwrap()
.build()
.unwrap();
let (_, batches) = execute(nested, &build_query_engine_state()).await;
assert_eq!(values(&batches, "v_plus"), vec![2.0]);
}
#[tokio::test]
async fn test_direct_or_skips_user_internal_key_name() {
const USER_TAG: &str = "__promql_or_match_0";
let left = tagged_source(
"lhs",
false,
(USER_TAG, Some("left")),
DirectOrValue::Float64(1.0),
);
let right = tagged_source(
"rhs",
false,
(USER_TAG, Some("right")),
DirectOrValue::Float64(2.0),
);
let raw = plan_direct_or(
scan(&left),
scan(&right),
direct_or_context("lhs", &["job", USER_TAG], "v"),
direct_or_context("rhs", &["job", USER_TAG], "v"),
&or_modifier("lhs or on(missing_label) rhs"),
)
.await;
assert!(
raw.display_indent_schema()
.to_string()
.contains("__promql_or_match_1@")
);
let (_, batches) = execute(raw, &build_query_engine_state()).await;
assert!(
batches
.iter()
.all(|batch| batch.column_by_name(USER_TAG).is_some())
);
}
#[tokio::test]
async fn test_direct_or_substrait_round_trip_with_normalized_key() {
let state = build_query_engine_state();
let ctx = SessionContext::new_with_state(state.session_state());
let catalog = Arc::new(MemoryCatalogProvider::new());
catalog
.register_schema("public", Arc::new(MemorySchemaProvider::new()))
.unwrap();
ctx.register_catalog("datafusion", catalog);
let left = matrix_source("lhs", Some(Some("")), 1, 1.0);
let right = matrix_source("rhs", None, 1, 2.0);
ctx.register_table(
TableReference::full("datafusion", "public", "lhs"),
table(&left),
)
.unwrap();
ctx.register_table(
TableReference::full("datafusion", "public", "rhs"),
table(&right),
)
.unwrap();
let raw = plan_direct_or(
ctx.table("datafusion.public.lhs")
.await
.unwrap()
.into_unoptimized_plan(),
ctx.table("datafusion.public.rhs")
.await
.unwrap()
.into_unoptimized_plan(),
direct_or_context("lhs", &["job", "k"], "v"),
direct_or_context("rhs", &["job"], "v"),
&or_modifier("lhs or on(k) rhs"),
)
.await;
let decoded = DFLogicalSubstraitConvertor
.decode(
DFLogicalSubstraitConvertor
.encode(&raw, DefaultSerializer)
.unwrap(),
ctx.state(),
)
.await
.unwrap();
let (optimized, batches) = execute(decoded, &state).await;
assert_no_internal_or_keys(optimized.schema());
assert!(batches.iter().all(|batch| {
batch
.schema()
.fields()
.iter()
.all(|field| !field.name().starts_with("__promql_or_match_"))
}));
assert_eq!(values(&batches, "v"), vec![1.0]);
}
#[tokio::test]
async fn test_direct_or_numeric_value_types() {
let left = tagged_source("lhs", true, ("k", Some("lhs")), DirectOrValue::Int64(0));
let right = tagged_source(
"rhs",
false,
("k", Some("rhs")),
DirectOrValue::Float64(0.5),
);
let (optimized, batches) = run(
&left,
&right,
direct_or_context("lhs", &["job", "k"], "v"),
direct_or_context("rhs", &["job", "k"], "v"),
&or_modifier("lhs or on(k) rhs"),
)
.await;
assert_eq!(
optimized
.schema()
.field_with_name(None, "v")
.unwrap()
.data_type(),
&ArrowDataType::Float64
);
assert_eq!(values(&batches, "v"), vec![0.5]);
let provider = build_test_table_provider_with_fields(
&[(DEFAULT_SCHEMA_NAME.to_string(), "dummy".to_string())],
&[],
)
.await;
let mut planner = PromPlanner {
table_provider: provider,
ctx: PromPlannerContext::default(),
promql_annotations: None,
};
let left_context = direct_or_context("lhs", &["job"], "v");
let right_context = direct_or_context("rhs", &["job"], "v");
let error = planner
.or_operator(
scan(&job_source("lhs", DirectOrValue::Utf8("x"))),
scan(&job_source("rhs", DirectOrValue::Float64(1.0))),
left_context.tag_columns.iter().cloned().collect(),
right_context.tag_columns.iter().cloned().collect(),
left_context,
right_context,
&or_modifier("lhs or on() rhs"),
)
.unwrap_err();
assert!(
error
.to_string()
.contains("OR value fields have incompatible types")
);
}
#[tokio::test]
async fn test_or_with_histogram_quantile_missing_le_column() {
let case = r#"histogram_quantile(0.99, non_existent_histogram_bucket) or normal_metric"#;
let eval_stmt = build_eval_stmt(case);
let table_provider = build_missing_le_or_normal_metric_table_provider().await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
assert_normal_metric_schema(&plan);
}
#[tokio::test]
async fn test_or_with_right_empty_histogram_restores_left_context() {
let eval_stmt = build_eval_stmt(
r#"abs(sum by(instance) (normal_metric) or histogram_quantile(0.99, sum by(pod) (non_existent_histogram_bucket)))"#,
);
let table_provider = build_missing_le_or_normal_metric_table_provider().await;
PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
}
#[tokio::test]
async fn test_or_with_both_empty_histograms() {
let eval_stmt = build_eval_stmt(
r#"histogram_quantile(0.99, sum by(pod) (left_histogram_bucket)) or histogram_quantile(0.99, sum by(instance) (right_histogram_bucket))"#,
);
let table_provider = build_test_table_provider_with_fields(
&[
(
DEFAULT_SCHEMA_NAME.to_string(),
"left_histogram_bucket".to_string(),
),
(
DEFAULT_SCHEMA_NAME.to_string(),
"right_histogram_bucket".to_string(),
),
],
&["pod", "instance"],
)
.await;
let plan = PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
match plan {
LogicalPlan::EmptyRelation(relation) => {
assert!(!relation.produce_one_row);
assert!(!relation.schema.fields().is_empty());
assert!(
relation
.schema
.fields()
.iter()
.any(|field| field.data_type() == &ArrowDataType::Float64)
);
assert!(
relation
.schema
.fields()
.iter()
.any(|field| field.name() == "pod")
);
assert!(
!relation
.schema
.fields()
.iter()
.any(|field| field.name() == "instance")
);
}
_ => panic!("Expected EmptyRelation, but got: {plan:?}"),
}
}
#[tokio::test]
async fn test_nested_or_with_both_empty_histograms() {
for case in [
r#"abs(histogram_quantile(0.99, left_histogram_bucket) or histogram_quantile(0.99, right_histogram_bucket))"#,
r#"(histogram_quantile(0.99, left_histogram_bucket) or histogram_quantile(0.99, right_histogram_bucket)) + 1"#,
] {
let eval_stmt = build_eval_stmt(case);
let table_provider = build_test_table_provider_with_fields(
&[
(
DEFAULT_SCHEMA_NAME.to_string(),
"left_histogram_bucket".to_string(),
),
(
DEFAULT_SCHEMA_NAME.to_string(),
"right_histogram_bucket".to_string(),
),
],
&["pod", "instance"],
)
.await;
PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
}
}
#[tokio::test]
async fn test_or_with_empty_histogram_modifiers() {
for case in [
r#"histogram_quantile(0.99, non_existent_histogram_bucket) or on(pod) normal_metric"#,
r#"normal_metric or ignoring(instance) histogram_quantile(0.99, non_existent_histogram_bucket)"#,
] {
let eval_stmt = build_eval_stmt(case);
let table_provider = build_missing_le_or_normal_metric_table_provider().await;
let plan =
PromPlanner::stmt_to_plan(table_provider, &eval_stmt, &build_query_engine_state())
.await
.unwrap();
assert_normal_metric_schema(&plan);
}
}
#[tokio::test]
async fn test_unless_preserves_left_context_for_histogram() {
let eval_stmt = build_eval_stmt(
r#"histogram_quantile(0.99, bucket_metric unless on(job) normal_metric) or fallback_metric"#,
);
let state = build_query_engine_state();
let plan = PromPlanner::stmt_to_plan(
build_set_op_context_table_provider().await,
&eval_stmt,
&state,
)
.await
.unwrap();
assert!(contains_histogram_fold(&plan), "{plan:?}");
let (optimized, physical) = optimize_and_create_physical_plan(&state, plan).await;
assert!(contains_histogram_fold(&optimized), "{optimized:?}");
let batches = datafusion::physical_plan::collect(physical, state.session_state().task_ctx())
.await
.unwrap();
assert!(batches.iter().all(|batch| batch.num_rows() == 0));
}
#[tokio::test]
async fn test_and_preserves_left_context_for_histogram() {
let eval_stmt = build_eval_stmt(
r#"histogram_quantile(0.99, bucket_metric and on(job) normal_metric) or fallback_metric"#,
);
let plan = PromPlanner::stmt_to_plan(
build_set_op_context_table_provider().await,
&eval_stmt,
&build_query_engine_state(),
)
.await
.unwrap();
assert!(contains_histogram_fold(&plan), "{plan:?}");
}
#[tokio::test]
async fn test_and_preserves_left_context_when_le_is_missing() {
let eval_stmt =
build_eval_stmt(r#"histogram_quantile(0.99, normal_metric and on(job) bucket_metric)"#);
let plan = PromPlanner::stmt_to_plan(
build_set_op_context_table_provider().await,
&eval_stmt,
&build_query_engine_state(),
)
.await
.unwrap();
assert!(matches!(&plan, LogicalPlan::EmptyRelation(_)), "{plan:?}");
assert!(!plan.schema().fields().is_empty());
assert!(!contains_histogram_fold(&plan), "{plan:?}");
}
async fn build_matching_filter_plan(query: &str) -> String {
let table_provider = build_test_table_provider_with_distinct_tags(&[
("metric_a", &["host", "device"]),
("metric_b", &["host", "device"]),
])
.await;
let plan = PromPlanner::stmt_to_plan(
table_provider,
&build_eval_stmt(query),
&build_query_engine_state(),
)
.await
.unwrap();
plan.display_indent().to_string()
}
/// [`build_test_table_provider_with_distinct_tags`] plus a `status` string column: a value
/// field that is neither a primary key nor a value column of the metric, so
/// `count by(status) (...)` still reports it among the aggregation's tag columns.
async fn build_test_table_provider_with_string_field(
table_tags: &[(&str, &[&str])],
) -> DfTableSourceProvider {
let catalog_list = MemoryCatalogManager::with_default_setup();
for (table_name, tags) in table_tags {
let mut columns = tags
.iter()
.map(|tag| {
ColumnSchema::new(
(*tag).to_string(),
ConcreteDataType::string_datatype(),
false,
)
})
.collect::<Vec<_>>();
columns.push(
ColumnSchema::new(
greptime_timestamp().to_string(),
ConcreteDataType::timestamp_millisecond_datatype(),
false,
)
.with_time_index(true),
);
columns.push(ColumnSchema::new(
greptime_value().to_string(),
ConcreteDataType::float64_datatype(),
true,
));
columns.push(ColumnSchema::new(
"status".to_string(),
ConcreteDataType::string_datatype(),
true,
));
let table_meta = TableMetaBuilder::empty()
.schema(Arc::new(Schema::new(columns)))
.primary_key_indices((0..tags.len()).collect())
.next_column_id(1024)
.build()
.unwrap();
let table_info = TableInfoBuilder::default()
.name((*table_name).to_string())
.meta(table_meta)
.build()
.unwrap();
assert!(
catalog_list
.register_table_sync(RegisterTableRequest {
catalog: DEFAULT_CATALOG_NAME.to_string(),
schema: DEFAULT_SCHEMA_NAME.to_string(),
table_name: (*table_name).to_string(),
table_id: 1024,
table: EmptyTable::from_table_info(&table_info),
})
.is_ok()
);
}
DfTableSourceProvider::new(
catalog_list,
false,
QueryContext::arc(),
DummyDecoder::arc(),
false,
)
}
async fn build_matching_filter_plan_with_string_field(query: &str) -> String {
let table_provider = build_test_table_provider_with_string_field(&[
("metric_a", &["host", "device"]),
("metric_b", &["host", "device"]),
])
.await;
let plan = PromPlanner::stmt_to_plan(
table_provider,
&build_eval_stmt(query),
&build_query_engine_state(),
)
.await
.unwrap();
plan.display_indent().to_string()
}
#[tokio::test]
async fn binary_matching_label_filter_reaches_both_operands() {
for query in [
r#"metric_a / metric_b{host="foo"}"#,
r#"metric_a / on(host, device) metric_b{host="foo"}"#,
r#"count_over_time(metric_a[1m]) / on(host) count_over_time(metric_b{host="foo"}[1m])"#,
r#"metric_a / ignoring(device) metric_b{host="foo"}"#,
r#"sum by(host) (metric_a) / on(host) sum by(host) (metric_b{host="foo"})"#,
r#"metric_a / on(host) avg without(device) (metric_b{host="foo"})"#,
] {
let plan = build_matching_filter_plan(query).await;
assert_eq!(
plan.matches(r#"host = Utf8("foo")"#).count(),
2,
"{query}\n{plan}"
);
}
}
#[tokio::test]
async fn binary_matching_label_filter_reaches_scalar_ranking_and_grouped_operands() {
for query in [
r#"(8 * metric_a{host="foo"}) / on(host) metric_b"#,
r#"topk(1, metric_a{host="foo"}) / on(host, device) metric_b"#,
r#"(8 * metric_a{host="foo"}) / on(host) group_left topk by(host)(1, max by(host)(metric_b))"#,
] {
let plan = build_matching_filter_plan(query).await;
assert_eq!(
plan.matches(r#"host = Utf8("foo")"#).count(),
2,
"{query}\n{plan}"
);
}
// A global ranking one-side must see every host, so the matcher stays put.
let query = r#"metric_a{host="foo"} / on(host) group_left topk(1, max by(host)(metric_b))"#;
let plan = build_matching_filter_plan(query).await;
assert_eq!(
plan.matches(r#"host = Utf8("foo")"#).count(),
1,
"{query}\n{plan}"
);
}
#[tokio::test]
async fn binary_matching_label_filter_skips_selecting_aggregations() {
// `topk` ranks its input, so filtering before it changes the candidate set.
let query = r#"topk(1, metric_a) / on(host, device) metric_b{host="foo"}"#;
let plan = build_matching_filter_plan(query).await;
assert_eq!(plan.matches("foo").count(), 1, "{query}\n{plan}");
}
#[tokio::test]
async fn binary_value_field_matcher_stays_on_its_own_operand() {
let value = greptime_value();
for query in [
format!(r#"metric_a / metric_b{{{value}="2"}}"#),
format!(r#"metric_a / on(host, device, {value}) metric_b{{{value}="2"}}"#),
] {
let plan = build_matching_filter_plan(&query).await;
assert_eq!(plan.matches(r#"Utf8("2")"#).count(), 1, "{query}\n{plan}");
}
}
#[tokio::test]
async fn binary_value_field_matcher_is_not_copied_across_aggregations() {
// `status` varies between the samples of one series, so filtering the other operand by it
// would drop the newest sample before sample selection (#9242).
for query in [
r#"count by(status) (metric_a) / on(status) count by(status) (metric_b{status="ready"})"#,
r#"(8 * count by(status)(metric_a{__field__="status"})) / on(status) topk by(status)(1, count by(status)(metric_b{__field__="status",status="ready"}))"#,
r#"count by(status)(metric_a{__field__="status"}) / on(status) group_left topk by(status)(1, count by(status)(metric_b{__field__="status",status="ready"}))"#,
] {
let plan = build_matching_filter_plan_with_string_field(query).await;
assert_eq!(
plan.matches(r#"Utf8("ready")"#).count(),
1,
"{query}\n{plan}"
);
}
}
#[tokio::test]
async fn binary_matching_label_filter_reaches_aggregations_grouping_by_tags() {
// `count`, not `sum`: the string value field is not summable.
let query = r#"count by(host) (metric_a) / on(host) count by(host) (metric_b{host="foo"})"#;
let plan = build_matching_filter_plan_with_string_field(query).await;
assert_eq!(plan.matches(r#"Utf8("foo")"#).count(), 2, "{query}\n{plan}");
}
#[tokio::test]
async fn binary_matching_label_filter_skips_unproven_expressions() {
for query in [
r#"metric_a > on(host, device) metric_b{host="foo"}"#,
r#"metric_a / on(host) group_left metric_b{host="foo"}"#,
r#"metric_a / on(host) label_replace(metric_b{host="foo"},"extra","e","host",".*")"#,
r#"metric_a / on(device) metric_b{host="foo"}"#,
] {
let plan = build_matching_filter_plan(query).await;
assert_eq!(plan.matches("foo").count(), 1, "{query}\n{plan}");
}
}
#[tokio::test]
async fn test_or_context_uses_left_qualified_output() {
let case = r#"(normal_metric or other_metric) + 1"#;
let eval_stmt = build_eval_stmt(case);
let state = build_query_engine_state();
let plan =
PromPlanner::stmt_to_plan(build_or_context_table_provider().await, &eval_stmt, &state)
.await
.unwrap();
assert!(
plan.schema()
.fields()
.iter()
.any(|field| field.data_type() == &ArrowDataType::Float64),
"{plan:?}"
);
let (_optimized, _physical) = optimize_and_create_physical_plan(&state, plan).await;
}
#[tokio::test]
async fn test_or_context_uses_left_qualified_empty_histogram_output() {
let case = r#"(abs(histogram_quantile(0.99, non_hist_metric)) or normal_metric) + 1"#;
let eval_stmt = build_eval_stmt(case);
let plan = PromPlanner::stmt_to_plan(
build_or_context_table_provider().await,
&eval_stmt,
&build_query_engine_state(),
)
.await
.unwrap();
assert!(
plan.schema()
.fields()
.iter()
.any(|field| field.data_type() == &ArrowDataType::Float64),
"{plan:?}"
);
}
#[tokio::test]
async fn test_direct_or_preserves_float_and_native_histogram_samples() {
for histogram_on_left in [false, true] {
let (planner, plan) = mixed_direct_or(histogram_on_left).await;
let float_field = &planner.ctx.field_columns[0];
let histogram_field = &planner.ctx.field_columns[1];
assert!(float_field.starts_with(OR_FLOAT_FIELD_PREFIX));
assert!(histogram_field.starts_with(OR_HISTOGRAM_FIELD_PREFIX));
assert_eq!(
plan.schema()
.field_with_name(None, float_field)
.unwrap()
.data_type(),
&ArrowDataType::Float64
);
assert_eq!(
plan.schema()
.field_with_name(None, histogram_field)
.unwrap()
.data_type(),
&native_histogram_value_type().as_arrow_type()
);
let (optimized, batches) = execute(plan, &build_query_engine_state()).await;
assert_no_internal_or_keys(optimized.schema());
let mut sample_kinds = batches
.iter()
.flat_map(|batch| {
let values = batch.column_by_name(float_field).unwrap();
let histograms = batch.column_by_name(histogram_field).unwrap();
(0..batch.num_rows()).map(|row| (values.is_valid(row), histograms.is_valid(row)))
})
.collect::<Vec<_>>();
sample_kinds.sort_unstable();
assert_eq!(sample_kinds, vec![(false, true), (true, false)]);
}
}
#[tokio::test]
async fn malformed_classic_bucket_does_not_drop_native_histogram() {
let state = build_query_engine_state();
let collector = PromqlAnnotationCollector::default();
let plan = PromPlanner::stmt_to_plan_with_annotations(
operator_table_provider(),
&operator_eval_stmt("histogram_quantile(0.5, bad_classic or bad_native)"),
&state,
Some(collector.clone()),
)
.await
.unwrap();
let value_field = plan
.schema()
.fields()
.iter()
.find(|field| field.data_type() == &ArrowDataType::Float64)
.unwrap()
.name()
.clone();
let (_, batches) = execute(plan, &state).await;
assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 1);
assert_eq!(values(&batches, &value_field), vec![0.0]);
let mut warnings = vec![];
let mut infos = vec![];
collector.append_to(&mut warnings, &mut infos);
assert!(warnings.is_empty());
assert!(infos.is_empty());
}
#[tokio::test]
async fn test_mixed_binary_operator_aligns_both_alternative_inputs() {
let state = build_query_engine_state();
let plan = PromPlanner::stmt_to_plan(
operator_table_provider(),
&operator_eval_stmt("(lf or on(tag) lh) * on(tag) (rf or on(tag) rh)"),
&state,
)
.await
.unwrap();
let plan_text = plan.display_indent_schema().to_string();
assert!(
plan_text.contains("prom_native_histogram_mul_scalar"),
"{plan_text}"
);
assert!(
plan_text.contains("prom_native_histogram_scalar_mul"),
"{plan_text}"
);
let float_field = plan
.schema()
.fields()
.iter()
.find(|field| field.name().starts_with(OR_FLOAT_FIELD_PREFIX))
.unwrap()
.name()
.clone();
let histogram_field = plan
.schema()
.fields()
.iter()
.find(|field| field.name().starts_with(OR_HISTOGRAM_FIELD_PREFIX))
.unwrap()
.name()
.clone();
let (_, batches) = execute(plan, &state).await;
assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 2);
assert!(values(&batches, &float_field).is_empty());
let mut sums = histograms(&batches, &histogram_field)
.into_iter()
.map(|histogram| histogram.sum)
.collect::<Vec<_>>();
sums.sort_by(f64::total_cmp);
assert_eq!(sums, vec![2.0, 3.0]);
}
#[tokio::test]
async fn test_mixed_binary_operator_reports_only_dropped_samples() {
for (query, expected_rows, expected_infos) in [
("(lf or on(tag) lh) + on(tag) (rf or on(tag) rh)", 0, 1),
("(lf or on(tag) lh) + on(tag) (lf or on(tag) lh)", 2, 0),
("(lf or on(tag) lh) % on(tag) lh", 0, 1),
] {
let state = build_query_engine_state();
let annotations = PromqlAnnotationCollector::default();
let plan = PromPlanner::stmt_to_plan_with_annotations(
operator_table_provider(),
&operator_eval_stmt(query),
&state,
Some(annotations.clone()),
)
.await
.unwrap();
let (_, batches) = execute(plan, &state).await;
assert_eq!(
batches.iter().map(RecordBatch::num_rows).sum::<usize>(),
expected_rows,
"{query}"
);
let mut warnings = vec![];
let mut infos = vec![];
annotations.append_to(&mut warnings, &mut infos);
assert!(warnings.is_empty(), "{query}: {warnings:?}");
assert_eq!(infos.len(), expected_infos, "{query}: {infos:?}");
}
}
#[tokio::test]
async fn test_histogram_only_min_drops_empty_aggregate_group() {
// `min` over native-histogram-only input drops every sample in the group, so the
// NULL-valued aggregate row must be filtered out. Otherwise an outer expression
// like `group()` resurrects the group Prometheus considers unseen.
let state = build_query_engine_state();
for query in ["min(lh)", "group(min(lh))"] {
let plan = PromPlanner::stmt_to_plan(
operator_table_provider(),
&operator_eval_stmt(query),
&state,
)
.await
.unwrap();
let (_, batches) = execute(plan, &state).await;
assert_eq!(
batches.iter().map(RecordBatch::num_rows).sum::<usize>(),
0,
"{query}"
);
}
}
#[tokio::test]
async fn test_mixed_min_drops_histogram_only_group() {
// With alternative float/histogram fields, `min by (tag)` keeps float-only groups
// (tag=a from `lf`) and drops histogram-only groups (tag=b from `lh`) instead of
// emitting a NULL-valued row for them.
let state = build_query_engine_state();
let plan = PromPlanner::stmt_to_plan(
operator_table_provider(),
&operator_eval_stmt("min by (tag) (lf or on(tag) lh)"),
&state,
)
.await
.unwrap();
let float_field = plan
.schema()
.fields()
.iter()
.find(|field| field.data_type() == &ArrowDataType::Float64)
.unwrap()
.name()
.clone();
let (_, batches) = execute(plan, &state).await;
assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 1);
assert_eq!(values(&batches, &float_field), vec![2.0]);
}
#[tokio::test]
async fn test_mixed_or_can_feed_another_or() {
let state = build_query_engine_state();
let plan = PromPlanner::stmt_to_plan(
operator_table_provider(),
&operator_eval_stmt("lf or on(tag) lh or on(tag) fallback"),
&state,
)
.await
.unwrap();
let float_field = plan
.schema()
.fields()
.iter()
.find(|field| field.name().starts_with(OR_FLOAT_FIELD_PREFIX))
.unwrap()
.name()
.clone();
let histogram_field = plan
.schema()
.fields()
.iter()
.find(|field| field.name().starts_with(OR_HISTOGRAM_FIELD_PREFIX))
.unwrap()
.name()
.clone();
let (_, batches) = execute(plan, &state).await;
assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 3);
let mut float_values = values(&batches, &float_field);
float_values.sort_by(f64::total_cmp);
assert_eq!(float_values, vec![2.0, 7.0]);
assert_eq!(histograms(&batches, &histogram_field).len(), 1);
}
#[tokio::test]
async fn test_mixed_fields_align_with_single_float_vector() {
let (planner, mixed) = mixed_direct_or(false).await;
let scale = tagged_source(
"scale",
false,
("k", Some("float")),
DirectOrValue::Float64(2.0),
);
let scale = scan(&scale);
let scale_fields = vec!["v".to_string()];
let PromExpr::Binary(binary) = parser::parse("lhs * rhs").unwrap() else {
unreachable!()
};
let (groups, invalid_pairs) = PromPlanner::align_binary_field_columns(
mixed.schema(),
scale.schema(),
&planner.ctx.field_columns,
&scale_fields,
binary.op,
false,
false,
);
assert!(invalid_pairs.is_empty());
assert_eq!(
groups
.iter()
.map(|(output, _)| output.clone())
.collect::<Vec<_>>(),
planner.ctx.field_columns
);
assert_eq!(groups.len(), 2);
assert!(
groups
.iter()
.flat_map(|(_, pairs)| pairs)
.all(|(_, right)| *right == &scale_fields[0])
);
let (groups, invalid_pairs) = PromPlanner::align_binary_field_columns(
scale.schema(),
mixed.schema(),
&scale_fields,
&planner.ctx.field_columns,
binary.op,
false,
false,
);
assert!(invalid_pairs.is_empty());
assert_eq!(
groups
.iter()
.map(|(output, _)| output.clone())
.collect::<Vec<_>>(),
planner.ctx.field_columns
);
assert_eq!(groups.len(), 2);
assert!(
groups
.iter()
.flat_map(|(_, pairs)| pairs)
.all(|(left, _)| *left == &scale_fields[0])
);
}
#[tokio::test]
async fn test_non_bool_comparison_filters_mixed_sample_lanes() {
let (planner, input) = mixed_direct_or(false).await;
let input_schema = input.schema().clone();
let plan = planner
.filter_on_field_column(input, |field| {
if PromPlanner::field_column_is_native_histogram(&input_schema, field) {
Ok(lit(false))
} else {
Ok(col(field).gt(lit(0.0)))
}
})
.unwrap();
let float_field = planner.ctx.field_columns[0].clone();
let (_, batches) = execute(plan, &build_query_engine_state()).await;
assert_eq!(values(&batches, &float_field), vec![1.25]);
assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 1);
}
#[tokio::test]
async fn test_mixed_left_and_unless_preserve_sample_lanes() {
for (expression, expected_sample_kind) in [
("lhs and on(k) mask", (false, true)),
("lhs unless on(k) mask", (true, false)),
] {
let (mut planner, left) = mixed_direct_or(false).await;
let left_context = planner.ctx.clone();
let float_field = left_context.field_columns[0].clone();
let histogram_field = left_context.field_columns[1].clone();
let mask = tagged_source(
"mask",
false,
("k", Some("histogram")),
DirectOrValue::Float64(1.0),
);
let PromExpr::Binary(binary) = parser::parse(expression).unwrap() else {
unreachable!()
};
let plan = planner
.set_op_on_non_field_columns(
left,
scan(&mask),
left_context,
direct_or_context("mask", &["job", "k"], "v"),
binary.op,
&binary.modifier,
)
.unwrap();
let (_, batches) = execute(plan, &build_query_engine_state()).await;
let sample_kinds = batches
.iter()
.flat_map(|batch| {
let floats = batch.column_by_name(&float_field).unwrap();
let histograms = batch.column_by_name(&histogram_field).unwrap();
(0..batch.num_rows()).map(|row| (floats.is_valid(row), histograms.is_valid(row)))
})
.collect::<Vec<_>>();
assert_eq!(sample_kinds, vec![expected_sample_kind], "{expression}");
}
}
#[tokio::test]
async fn test_mixed_fields_arithmetic_broadcasts_computed_scalar() {
let plan = PromPlanner::stmt_to_plan(
build_test_mixed_native_histogram_table_provider("some_metric").await,
&build_eval_stmt("some_metric * scalar(vector(2))"),
&build_query_engine_state(),
)
.await
.unwrap();
let schema = plan.schema();
assert_eq!(
schema
.field_with_unqualified_name(greptime_value())
.unwrap()
.data_type(),
&ArrowDataType::Float64
);
assert_eq!(
schema
.field_with_unqualified_name(greptime_native_histogram())
.unwrap()
.data_type(),
&native_histogram_value_type().as_arrow_type()
);
assert!(
plan.display_indent_schema()
.to_string()
.contains("prom_native_histogram_mul_scalar"),
"{plan:?}"
);
}
#[tokio::test]
async fn test_unsupported_histogram_binary_does_not_block_or_fallback() {
let state = build_query_engine_state();
let plan = PromPlanner::stmt_to_plan(
operator_table_provider(),
&operator_eval_stmt("((lf or on(tag) lh) % 2) or on(tag) lh"),
&state,
)
.await
.unwrap();
let float_field = plan
.schema()
.fields()
.iter()
.find(|field| field.data_type() == &ArrowDataType::Float64)
.unwrap()
.name()
.clone();
let histogram_field = plan
.schema()
.fields()
.iter()
.find(|field| field.data_type() == &native_histogram_value_type().as_arrow_type())
.unwrap()
.name()
.clone();
let (_, batches) = execute(plan, &state).await;
assert_eq!(values(&batches, &float_field), vec![0.0]);
assert_eq!(histograms(&batches, &histogram_field).len(), 1);
}
#[tokio::test]
async fn test_unary_negates_mixed_float_and_native_histogram_samples() {
for histogram_on_left in [false, true] {
let (mut planner, input) = mixed_direct_or(histogram_on_left).await;
let plan = planner.negate_field_columns(input).unwrap();
assert!(PromPlanner::field_columns_are_alternative_samples(
plan.schema(),
&planner.ctx.field_columns
));
let float_field = planner
.ctx
.field_columns
.iter()
.find(|field| field.starts_with(OR_FLOAT_FIELD_PREFIX))
.unwrap();
let histogram_field = planner
.ctx
.field_columns
.iter()
.find(|field| field.starts_with(OR_HISTOGRAM_FIELD_PREFIX))
.unwrap();
let (_, batches) = execute(plan, &build_query_engine_state()).await;
assert_eq!(values(&batches, float_field), vec![-1.25]);
let histogram = batches
.iter()
.find_map(|batch| {
let values = batch
.column_by_name(histogram_field)
.unwrap()
.as_any()
.downcast_ref::<datafusion::arrow::array::StructArray>()
.unwrap();
(0..values.len()).find_map(|row| {
common_query::native_histogram::read_histogram(values, row).unwrap()
})
})
.unwrap();
assert_eq!(histogram.count, -1.0);
assert_eq!(histogram.sum, -1.0);
assert_eq!(histogram.reset_hint, CounterResetHint::Gauge);
}
}
#[tokio::test]
async fn test_native_histogram_sum_and_avg_execute_real_batches() {
for op_name in ["sum", "avg"] {
for incompatible in [false, true] {
let mut second = direct_or_histogram();
if incompatible {
second.schema = CUSTOM_BUCKETS_SCHEMA;
second.custom_values = vec![1.0];
}
let collector = PromqlAnnotationCollector::default();
let (mut planner, input) =
mixed_aggregate_input(vec![direct_or_histogram(), second]).await;
planner.promql_annotations = Some(collector.clone());
let histogram_column = planner.ctx.field_columns[1].clone();
planner.ctx.field_columns = vec![histogram_column.clone()];
let input = LogicalPlanBuilder::from(input)
.project([col("ts"), col(&histogram_column)])
.unwrap()
.build()
.unwrap();
let PromExpr::Aggregate(AggregateExpr { op, param, .. }) =
parser::parse(&format!("{op_name}(mixed)")).unwrap()
else {
unreachable!()
};
let (aggregate_exprs, _) = planner.create_aggregate_exprs(op, &param, &input).unwrap();
let plan = LogicalPlanBuilder::from(input)
.aggregate(vec![col("ts")], aggregate_exprs)
.unwrap()
.filter(planner.create_empty_values_filter_expr(false).unwrap())
.unwrap()
.build()
.unwrap();
let (_, batches) = execute(plan, &build_query_engine_state()).await;
let mut warnings = vec![];
let mut infos = vec![];
collector.append_to(&mut warnings, &mut infos);
assert!(infos.is_empty());
if incompatible {
assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 0);
assert!(warnings.iter().any(|warning| {
warning
== &format!(
"prom_native_histogram_agg_{op_name}: dropped native histogram aggregate with incompatible schemas"
)
}));
} else {
let histograms = histograms(&batches, &histogram_column);
assert_eq!(histograms.len(), 1);
let expected = if op_name == "sum" { 2.0 } else { 1.0 };
assert_eq!(histograms[0].count, expected);
assert_eq!(histograms[0].sum, expected);
assert!(warnings.is_empty());
}
}
}
}
#[tokio::test]
async fn test_canonical_mixed_count_group_and_count_values_execute() {
let state = build_query_engine_state();
for (query, expected) in [
("count(some_metric)", vec![2.0]),
("group(some_metric)", vec![1.0]),
(r#"count_values("sample", some_metric)"#, vec![1.0, 1.0]),
] {
let plan = PromPlanner::stmt_to_plan(
build_test_mixed_native_histogram_table_provider("some_metric").await,
&operator_eval_stmt(query),
&state,
)
.await
.unwrap();
assert!(
plan.schema()
.fields()
.iter()
.all(|field| !field.name().starts_with("__promql_sample_count")),
"{query}: {plan:?}"
);
let value_fields = plan
.schema()
.fields()
.iter()
.filter(|field| {
matches!(
field.data_type(),
ArrowDataType::Float64 | ArrowDataType::Int64 | ArrowDataType::UInt64
) || field.data_type() == &native_histogram_value_type().as_arrow_type()
})
.collect::<Vec<_>>();
assert_eq!(value_fields.len(), 1, "{query}: {plan:?}");
assert_ne!(
value_fields[0].data_type(),
&native_histogram_value_type().as_arrow_type(),
"{query}: {plan:?}"
);
let value_column = value_fields[0].name().clone();
let (_, batches) = execute(plan, &state).await;
let mut actual = numeric_values(&batches, &value_column);
actual.sort_by(f64::total_cmp);
assert_eq!(actual, expected, "{query}");
if query.starts_with("count_values") {
let mut sample_labels = batches
.iter()
.flat_map(|batch| {
batch
.column_by_name("sample")
.unwrap()
.as_any()
.downcast_ref::<StringArray>()
.unwrap()
.iter()
.flatten()
.map(str::to_string)
})
.collect::<Vec<_>>();
sample_labels.sort();
let mut expected_labels = vec!["2".to_string(), direct_or_histogram().promql_string()];
expected_labels.sort();
assert_eq!(sample_labels, expected_labels);
}
}
}
#[tokio::test]
async fn test_mixed_or_sum_aggregates_each_sample_type() {
let PromExpr::Aggregate(AggregateExpr { op, param, .. }) = parser::parse("sum(lhs)").unwrap()
else {
unreachable!()
};
let collector = PromqlAnnotationCollector::default();
let (mut planner, input) = mixed_direct_or(false).await;
planner.promql_annotations = Some(collector.clone());
let float_column = planner.ctx.field_columns[0].clone();
let histogram_column = planner.ctx.field_columns[1].clone();
let (aggregate_exprs, _) = planner.create_aggregate_exprs(op, &param, &input).unwrap();
let plan = LogicalPlanBuilder::from(input)
.aggregate(vec![col("ts"), col("k")], aggregate_exprs)
.unwrap()
.filter(
planner
.mixed_aggregate_filter_expr(op, &float_column, &histogram_column)
.unwrap(),
)
.unwrap()
.project([
col(&float_column),
col(&histogram_column),
col("ts"),
col("k"),
])
.unwrap()
.build()
.unwrap();
let (_, batches) = execute(plan, &build_query_engine_state()).await;
assert_eq!(values(&batches, &float_column), vec![1.25]);
let histogram = batches
.iter()
.find_map(|batch| {
let values = batch
.column_by_name(&histogram_column)?
.as_any()
.downcast_ref::<datafusion::arrow::array::StructArray>()?;
(0..values.len()).find_map(|row| {
common_query::native_histogram::read_histogram(values, row).unwrap()
})
})
.unwrap();
assert_eq!(histogram.count, 1.0);
let mut warnings = vec![];
let mut infos = vec![];
collector.append_to(&mut warnings, &mut infos);
assert!(warnings.is_empty());
let collector = PromqlAnnotationCollector::default();
let (mut planner, input) = mixed_direct_or(false).await;
planner.promql_annotations = Some(collector.clone());
let float_column = planner.ctx.field_columns[0].clone();
let histogram_column = planner.ctx.field_columns[1].clone();
let (aggregate_exprs, _) = planner.create_aggregate_exprs(op, &param, &input).unwrap();
let plan = LogicalPlanBuilder::from(input)
.aggregate(vec![col("ts")], aggregate_exprs)
.unwrap()
.filter(
planner
.mixed_aggregate_filter_expr(op, &float_column, &histogram_column)
.unwrap(),
)
.unwrap()
.project([col(&float_column), col(&histogram_column), col("ts")])
.unwrap()
.build()
.unwrap();
let (_, batches) = execute(plan, &build_query_engine_state()).await;
assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 0);
let mut warnings = vec![];
let mut infos = vec![];
collector.append_to(&mut warnings, &mut infos);
assert_eq!(
warnings,
vec!["sum: dropped aggregation result containing both float and native histogram samples"]
);
}
#[tokio::test]
async fn test_mixed_or_sum_drops_incompatible_mixed_group() {
let PromExpr::Aggregate(AggregateExpr { op, param, .. }) = parser::parse("sum(lhs)").unwrap()
else {
unreachable!()
};
let mut custom = direct_or_histogram();
custom.schema = CUSTOM_BUCKETS_SCHEMA;
custom.custom_values = vec![1.0];
let collector = PromqlAnnotationCollector::default();
let (mut planner, input) = mixed_aggregate_input(vec![direct_or_histogram(), custom]).await;
planner.promql_annotations = Some(collector.clone());
let float_column = planner.ctx.field_columns[0].clone();
let histogram_column = planner.ctx.field_columns[1].clone();
let (aggregate_exprs, _) = planner.create_aggregate_exprs(op, &param, &input).unwrap();
let plan = LogicalPlanBuilder::from(input)
.aggregate(vec![col("ts")], aggregate_exprs)
.unwrap()
.filter(
planner
.mixed_aggregate_filter_expr(op, &float_column, &histogram_column)
.unwrap(),
)
.unwrap()
.project([col(&float_column), col(&histogram_column), col("ts")])
.unwrap()
.build()
.unwrap();
let (_, batches) = execute(plan, &build_query_engine_state()).await;
assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 0);
let mut warnings = vec![];
let mut infos = vec![];
collector.append_to(&mut warnings, &mut infos);
assert!(warnings.iter().any(|warning| {
warning
== "sum: dropped aggregation result containing both float and native histogram samples"
}));
}
#[tokio::test]
async fn test_mixed_or_min_records_only_present_histograms() {
let PromExpr::Aggregate(AggregateExpr { op, param, .. }) = parser::parse("min(lhs)").unwrap()
else {
unreachable!()
};
let expected_info = "min: dropped native histogram samples because this aggregation is not supported for native histograms";
for (histograms, expected_infos) in [
(vec![], vec![]),
(vec![direct_or_histogram()], vec![expected_info]),
] {
let collector = PromqlAnnotationCollector::default();
let (mut planner, input) = mixed_aggregate_input(histograms).await;
planner.promql_annotations = Some(collector.clone());
let float_column = planner.ctx.field_columns[0].clone();
let histogram_column = planner.ctx.field_columns[1].clone();
let (aggregate_exprs, _) = planner.create_aggregate_exprs(op, &param, &input).unwrap();
let plan = LogicalPlanBuilder::from(input)
.aggregate(vec![col("ts")], aggregate_exprs)
.unwrap()
.filter(
planner
.mixed_ignored_histogram_filter_expr(op, &histogram_column)
.unwrap(),
)
.unwrap()
.project([col(&float_column), col("ts")])
.unwrap()
.build()
.unwrap();
let (_, batches) = execute(plan, &build_query_engine_state()).await;
assert_eq!(values(&batches, &float_column), vec![1.25]);
let mut warnings = vec![];
let mut infos = vec![];
collector.append_to(&mut warnings, &mut infos);
assert!(warnings.is_empty());
assert_eq!(infos, expected_infos);
}
}
#[tokio::test]
async fn test_mixed_or_value_aliases_do_not_replace_labels() {
let left = source(
"lhs",
false,
1,
vec![("job", Some("job")), ("k", Some("float"))],
DirectOrValue::Float64(1.0),
);
let right = source(
"rhs",
false,
1,
vec![
("job", Some("job")),
("k", Some("histogram")),
(greptime_value(), Some("value-label")),
],
DirectOrValue::NativeHistogram(direct_or_histogram()),
);
let table_provider = build_test_table_provider_with_fields(
&[(DEFAULT_SCHEMA_NAME.to_string(), "dummy".to_string())],
&[],
)
.await;
let mut planner = PromPlanner {
table_provider,
ctx: PromPlannerContext::default(),
promql_annotations: None,
};
let left = LogicalPlanBuilder::from(scan(&left))
.project(vec![
col("ts"),
col("job"),
col("k"),
col("v").alias(greptime_value()),
])
.unwrap()
.build()
.unwrap();
let left_context = direct_or_context("lhs", &["job", "k"], greptime_value());
let right_context = direct_or_context("rhs", &["job", "k", greptime_value()], "v");
let plan = planner
.or_operator(
left,
scan(&right),
left_context.tag_columns.iter().cloned().collect(),
right_context.tag_columns.iter().cloned().collect(),
left_context,
right_context,
&or_modifier("lhs or on(k) rhs"),
)
.unwrap();
assert_eq!(
plan.schema()
.field_with_name(None, greptime_value())
.unwrap()
.data_type(),
&ArrowDataType::Utf8
);
assert!(
planner
.ctx
.field_columns
.iter()
.all(|field| { field != greptime_value() && field != greptime_native_histogram() })
);
assert!(PromPlanner::field_columns_are_alternative_samples(
plan.schema(),
&planner.ctx.field_columns
));
let (_, batches) = execute(plan, &build_query_engine_state()).await;
assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 2);
let labels = batches
.iter()
.flat_map(|batch| {
batch
.column_by_name(greptime_value())
.unwrap()
.as_any()
.downcast_ref::<StringArray>()
.unwrap()
.iter()
.flatten()
})
.collect::<Vec<_>>();
assert_eq!(labels, vec!["value-label"]);
}
#[tokio::test]
async fn test_mixed_or_routes_float_histogram_and_label_functions() {
for (function, expected) in [("abs", 1.25), ("round", 1.0), ("histogram_count", 1.0)] {
let (mut planner, input) = mixed_direct_or(false).await;
let preserve_any_value = PromPlanner::field_columns_are_alternative_samples(
input.schema(),
&planner.ctx.field_columns,
);
let PromExpr::Call(call) = parser::parse(&format!("{function}(lhs)")).unwrap() else {
unreachable!()
};
let state = build_query_engine_state();
let (mut exprs, _) = planner
.create_function_expr(&call.func, vec![], input.schema(), &state, None)
.unwrap();
exprs.insert(0, planner.create_time_index_column_expr().unwrap());
exprs.extend(planner.create_tag_column_exprs().unwrap());
let plan = LogicalPlanBuilder::from(input)
.project(exprs)
.unwrap()
.filter(
planner
.create_empty_values_filter_expr(preserve_any_value)
.unwrap(),
)
.unwrap()
.build()
.unwrap();
let (_, batches) = execute(plan, &state).await;
let values = batches
.iter()
.flat_map(|batch| {
batch
.schema()
.fields()
.iter()
.position(|field| field.data_type() == &ArrowDataType::Float64)
.map(|index| {
batch
.column(index)
.as_any()
.downcast_ref::<Float64Array>()
.unwrap()
.iter()
.flatten()
})
.into_iter()
.flatten()
})
.collect::<Vec<_>>();
assert_eq!(values, vec![expected], "{function}");
}
let (mut planner, input) = mixed_direct_or(false).await;
let preserve_any_value = PromPlanner::field_columns_are_alternative_samples(
input.schema(),
&planner.ctx.field_columns,
);
let PromExpr::Call(call) =
parser::parse(r#"label_replace(lhs, "copy", "$1", "k", "(.*)")"#).unwrap()
else {
unreachable!()
};
let args = planner.create_function_args(&call.args.args).unwrap();
let state = build_query_engine_state();
let (mut exprs, _) = planner
.create_function_expr(&call.func, args.literals, input.schema(), &state, None)
.unwrap();
exprs.insert(0, planner.create_time_index_column_expr().unwrap());
exprs.extend(planner.create_tag_column_exprs().unwrap());
let plan = LogicalPlanBuilder::from(input)
.project(exprs)
.unwrap()
.filter(
planner
.create_empty_values_filter_expr(preserve_any_value)
.unwrap(),
)
.unwrap()
.build()
.unwrap();
let (_, batches) = execute(plan, &state).await;
let sample_count = batches.iter().map(RecordBatch::num_rows).sum::<usize>();
assert_eq!(sample_count, 2);
}
/// Table provider with a single metric `cv_metric` holding three series at `ts=1000`:
/// `k="a"` and `k="c"` carry the value 1.0, `k="b"` carries 2.0.
async fn build_count_values_table_provider() -> DfTableSourceProvider {
build_count_values_table_provider_with_values(&[1.0, 2.0, 1.0]).await
}
/// Table provider with a single metric `cv_metric` holding one series per given value at
/// `ts=1000` (`k` names the series, the sample value is the given value).
async fn build_count_values_table_provider_with_values(values: &[f64]) -> DfTableSourceProvider {
build_count_values_table_provider_with_value_array(Arc::new(Float64Array::from(
values.to_vec(),
)))
.await
}
/// Like [`build_count_values_table_provider_with_values`], but with a caller provided value
/// column, so tests can cover value columns that are not `Float64` (e.g. `BIGINT`).
async fn build_count_values_table_provider_with_value_array(
values: ArrayRef,
) -> DfTableSourceProvider {
let value_data_type = ConcreteDataType::from_arrow_type(values.data_type());
let catalog_list = MemoryCatalogManager::with_default_setup();
let columns = vec![
ColumnSchema::new("k".to_string(), ConcreteDataType::string_datatype(), false),
ColumnSchema::new(
"timestamp".to_string(),
ConcreteDataType::timestamp_millisecond_datatype(),
false,
)
.with_time_index(true),
ColumnSchema::new(greptime_value().to_string(), value_data_type, true),
];
let schema = Arc::new(Schema::new(columns));
let table_meta = TableMetaBuilder::empty()
.schema(schema.clone())
.primary_key_indices(vec![0])
.value_indices(vec![2])
.next_column_id(1024)
.build()
.unwrap();
let table_info = Arc::new(
TableInfoBuilder::default()
.table_id(3_001)
.name("cv_metric")
.meta(table_meta)
.build()
.unwrap(),
);
let batch = RecordBatch::try_new(
schema.arrow_schema().clone(),
vec![
Arc::new(StringArray::from(
(0..values.len())
.map(|index| format!("k{index}"))
.collect::<Vec<_>>(),
)),
Arc::new(TimestampMillisecondArray::from(vec![1_000; values.len()])),
values.clone(),
],
)
.unwrap();
let backing = GreptimeMemTable::new_with_catalog(
"cv_metric",
GreptimeRecordBatch::from_df_record_batch(schema, batch),
3_001,
DEFAULT_CATALOG_NAME.to_string(),
DEFAULT_SCHEMA_NAME.to_string(),
);
let table = Arc::new(Table::new(
table_info,
FilterPushDownType::Unsupported,
backing.data_source(),
));
assert!(
catalog_list
.register_table_sync(RegisterTableRequest {
catalog: DEFAULT_CATALOG_NAME.to_string(),
schema: DEFAULT_SCHEMA_NAME.to_string(),
table_name: "cv_metric".to_string(),
table_id: 3_001,
table,
})
.is_ok()
);
DfTableSourceProvider::new(
catalog_list,
false,
QueryContext::arc(),
DummyDecoder::arc(),
false,
)
}
/// Collects `(label, value)` pairs of a `count_values` result, where `label` is the
/// PromQL label generated by `count_values` and `value` is the aggregated sample value.
///
/// The generated label holds the original sample value in PromQL's textual form, so it
/// is asserted as a string: comparing it as a number would not catch formatting bugs
/// (`1.0` instead of `1`, scientific notation, ...).
fn count_values_rows<'a>(batches: &'a [RecordBatch], label: &str) -> Vec<(&'a str, f64)> {
let mut rows = batches
.iter()
.flat_map(|batch| {
// The aggregated value is the only numeric column that is not the generated label.
let value_index = batch
.schema()
.fields()
.iter()
.position(|field| {
field.name() != label
&& matches!(
field.data_type(),
ArrowDataType::Float64 | ArrowDataType::Int64 | ArrowDataType::UInt64
)
})
.expect("no aggregated value column");
let labels = batch
.column_by_name(label)
.expect("no generated label column")
.as_any()
.downcast_ref::<StringArray>()
.expect("the generated label must be a string column");
let values = datafusion::arrow::compute::cast(
batch.column(value_index),
&ArrowDataType::Float64,
)
.unwrap();
let values = values.as_any().downcast_ref::<Float64Array>().unwrap();
labels
.iter()
.zip(values.iter())
.map(|(label, value)| (label.unwrap(), value.unwrap()))
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
rows.sort_by(|left, right| left.0.cmp(right.0).then(left.1.total_cmp(&right.1)));
rows
}
/// Asserts that a `count_values` result holds one sample per label set and evaluation
/// timestamp: Prometheus groups by the generated label, so a timestamp must never repeat
/// a label set (that would mean the samples were still grouped by the overwritten input
/// label).
fn assert_unique_label_set_per_timestamp(batches: &[RecordBatch], label: &str) {
let mut seen = HashMap::<i64, HashSet<String>>::new();
for batch in batches {
let timestamp_index = batch
.schema()
.fields()
.iter()
.position(|field| matches!(field.data_type(), ArrowDataType::Timestamp(..)))
.expect("no timestamp column");
let timestamps = batch
.column(timestamp_index)
.as_any()
.downcast_ref::<TimestampMillisecondArray>()
.expect("timestamp column is not a millisecond timestamp");
let labels = batch
.column_by_name(label)
.expect("no generated label column")
.as_any()
.downcast_ref::<StringArray>()
.expect("the generated label must be a string column");
for (timestamp, label) in timestamps.iter().zip(labels.iter()) {
let timestamp = timestamp.unwrap();
let label = label.unwrap();
assert!(
seen.entry(timestamp).or_default().insert(label.to_string()),
"duplicated label set `{label}` at timestamp {timestamp}"
);
}
}
}
#[tokio::test]
async fn test_count_values_generated_label_survives_enclosing_expr() {
// https://github.com/GreptimeTeam/greptimedb/issues/9181
for (case, label) in [
(r#"count_values("v", prometheus_tsdb_head_series)"#, "v"),
(
r#"abs(count_values("v", prometheus_tsdb_head_series))"#,
"v",
),
(
r#"round(count_values("v", prometheus_tsdb_head_series))"#,
"v",
),
(r#"count_values("v", prometheus_tsdb_head_series) + 1"#, "v"),
(
r#"topk(1, count_values("v", prometheus_tsdb_head_series))"#,
"v",
),
(
r#"sum by (v) (count_values("v", prometheus_tsdb_head_series))"#,
"v",
),
(
r#"label_replace(count_values("v", prometheus_tsdb_head_series), "vcopy", "$1", "v", "(.*)")"#,
"v",
),
(
r#"count_values("v", prometheus_tsdb_head_series) by (ip) + 1"#,
"v",
),
// The generated label overwrites an input label with the same name.
(
r#"count_values("ip", prometheus_tsdb_head_series) by (ip)"#,
"ip",
),
(
r#"count_values("ip", prometheus_tsdb_head_series) by (ip) + 1"#,
"ip",
),
] {
let plan = PromPlanner::stmt_to_plan(
build_test_table_provider_with_fields(
&[(
DEFAULT_SCHEMA_NAME.to_string(),
"prometheus_tsdb_head_series".to_string(),
)],
&["ip"],
)
.await,
&build_eval_stmt(case),
&build_query_engine_state(),
)
.await
.unwrap();
let label_columns = plan
.schema()
.fields()
.iter()
.filter(|field| field.name() == label)
.count();
assert_eq!(
label_columns,
1,
"{case}: the `{label}` label must survive: {}",
plan.display_indent()
);
}
}
#[tokio::test]
async fn test_count_values_generated_label_in_enclosing_expr_execute() {
// https://github.com/GreptimeTeam/greptimedb/issues/9181
let state = build_query_engine_state();
// (query, generated label, expected `(label value, aggregated value)` pairs)
for (query, label, expected) in [
(
r#"count_values("v", cv_metric)"#,
"v",
vec![("1", 2.0), ("2", 1.0)],
),
(
r#"abs(count_values("v", cv_metric))"#,
"v",
vec![("1", 2.0), ("2", 1.0)],
),
(
r#"round(count_values("v", cv_metric))"#,
"v",
vec![("1", 2.0), ("2", 1.0)],
),
(
r#"count_values("v", cv_metric) + 1"#,
"v",
vec![("1", 3.0), ("2", 2.0)],
),
(
r#"sum by (v) (count_values("v", cv_metric))"#,
"v",
vec![("1", 2.0), ("2", 1.0)],
),
(
r#"topk(10, count_values("v", cv_metric))"#,
"v",
vec![("1", 2.0), ("2", 1.0)],
),
// The generated label overwrites the input label with the same name, and the
// samples are grouped by the generated label only: `{k="1"}` holds the two
// samples of value `1.0` instead of one row per (overwritten label, value).
(
r#"count_values("k", cv_metric) by (k)"#,
"k",
vec![("1", 2.0), ("2", 1.0)],
),
] {
let plan = PromPlanner::stmt_to_plan(
build_count_values_table_provider().await,
&operator_eval_stmt(query),
&state,
)
.await
.unwrap_or_else(|err| panic!("{query}: {err}"));
assert_eq!(
plan.schema()
.fields()
.iter()
.filter(|field| field.name() == label)
.count(),
1,
"{query}: {}",
plan.display_indent()
);
let (_, batches) = execute(plan, &state).await;
assert_eq!(count_values_rows(&batches, label), expected, "{query}");
assert_unique_label_set_per_timestamp(&batches, label);
}
}
#[tokio::test]
async fn test_count_values_label_is_prometheus_formatted_value() {
// PromQL materializes the generated label with `strconv.FormatFloat(value, 'f', -1, 64)`:
// the shortest decimal form of the sample value without an exponent. The label is a
// label, so it must be a string column holding exactly that text: arrow's
// `Float64 -> Utf8` cast would render `1`/`200`/`1e21` as `1.0`/`200.0`/`1e21`.
let state = build_query_engine_state();
// Samples are grouped by that formatted text, exactly like Prometheus groups by the
// generated label: `-0.0` and `0.0` are two series (`-0` and `0`), while values that
// round to the same text share one group. `0.0` formats as "0".
let plan = PromPlanner::stmt_to_plan(
build_count_values_table_provider_with_values(&[
-0.0, 0.0, 1.0, 0.5, 200.0, 1e21, 1e-7, 2.5,
])
.await,
&operator_eval_stmt(r#"count_values("v", cv_metric)"#),
&state,
)
.await
.unwrap();
let (_, batches) = execute(plan, &state).await;
assert_unique_label_set_per_timestamp(&batches, "v");
let mut labels = count_values_rows(&batches, "v")
.into_iter()
.map(|(label, _)| label)
.collect::<Vec<_>>();
labels.sort();
assert_eq!(
labels,
vec![
"-0",
"0",
"0.0000001",
"0.5",
"1",
"1000000000000000000000",
"2.5",
"200",
]
);
}
#[tokio::test]
async fn test_count_values_groups_by_formatted_value_for_bigint_input() {
// The grouping key of `count_values` is the formatted sample value, not the raw input
// value. Two `BIGINT` values that differ below the `Float64` precision (`2^53` and
// `2^53 + 1`) cast and format to the same label, so they must share one group and one
// count, exactly like Prometheus, which groups by the generated label text.
let state = build_query_engine_state();
let plan = PromPlanner::stmt_to_plan(
build_count_values_table_provider_with_value_array(Arc::new(Int64Array::from(vec![
9_007_199_254_740_992_i64,
9_007_199_254_740_993_i64,
])))
.await,
&operator_eval_stmt(r#"count_values("v", cv_metric)"#),
&state,
)
.await
.unwrap();
let (_, batches) = execute(plan, &state).await;
// One timestamp must never carry the same label set twice.
assert_unique_label_set_per_timestamp(&batches, "v");
assert_eq!(
count_values_rows(&batches, "v"),
vec![("9007199254740992", 2.0)]
);
}