feat(promql): evaluate topk, bottomk, scalar, absent and instant sorts over evaluated series

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
This commit is contained in:
Dennis Zhuang
2026-10-17 18:46:13 +08:00
parent 65d2536fe3
commit 82cc78d640
4 changed files with 2313 additions and 0 deletions
+1
View File
@@ -19,3 +19,4 @@ pub mod grid_series;
mod metrics;
pub mod range_array;
pub mod range_eval;
pub mod select;
+450
View File
@@ -0,0 +1,450 @@
// 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.
//! Operators that depend on the whole set of evaluated series at each grid point: `topk`,
//! `bottomk`, `scalar`, `absent` and the instant-result sorts.
//!
//! Semantics follow Prometheus (`aggregationK`, `funcScalar`, `funcAbsent`,
//! `createLabelsForAbsentFunction`, `funcSort*`). A scalar is a grid series without labels that
//! has a value at every point.
use std::cmp::Ordering;
use std::collections::HashMap;
use std::sync::Arc;
use datafusion::error::{DataFusionError, Result};
use datatypes::series::samples::{SampleRef, Series, SeriesSchema};
use datatypes::series::time::TimeAxis;
use datatypes::series::{Label, MetricName};
use crate::grid_series::GridSeriesBuilder;
#[cfg(test)]
mod tests;
/// The labels that form an aggregation group.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Grouping {
/// `by(...)`: only these labels, `__name__` included when listed.
By(Vec<Arc<str>>),
/// `without(...)`: every label except these and `__name__`.
Without(Vec<Arc<str>>),
}
/// The order `sort_instant` arranges an instant result in.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SortOrder {
/// `sort`: ascending values, NaN last.
Asc,
/// `sort_desc`: descending values, NaN last.
Desc,
/// `sort_by_label`: natural order of the listed label values, then the label sets.
ByLabel(Vec<Arc<str>>),
/// `sort_by_label_desc`.
ByLabelDesc(Vec<Arc<str>>),
}
/// `topk(k, ...)` (`top`) or `bottomk(k, ...)` with a constant `k`.
///
/// At every point each group keeps the `k` largest (or smallest) values, NaN ranking last for
/// both; the selected samples keep their series' labels and values. Prometheus breaks ties by
/// the order its storage returns series in, which is not a property of the series; here ties
/// go to the series whose label set sorts first.
pub fn topk(
k: f64,
top: bool,
grouping: &Grouping,
schema: &SeriesSchema,
series: &[Series],
) -> Result<Vec<Series>> {
single_field(schema, "topk/bottomk")?;
let points = grid_points(schema, series)?;
// Prometheus converts k with int64() and caps it at the number of input series.
let k = if k.is_nan() || k < 1.0 {
0
} else {
(k.trunc().min(series.len() as f64)) as usize
};
if k == 0 {
return Ok(Vec::new());
}
let mut order: Vec<usize> = (0..series.len()).collect();
order.sort_by(|&a, &b| compare_labels(&series[a].metric, &series[b].metric));
let mut groups: HashMap<GroupKey, Vec<usize>> = HashMap::new();
for index in order {
groups
.entry(group_key(&series[index].metric, grouping))
.or_default()
.push(index);
}
let mut selected: Vec<Vec<usize>> = vec![Vec::new(); series.len()];
let mut candidates: Vec<(usize, f64)> = Vec::new();
for members in groups.values() {
for point in 0..points {
candidates.clear();
for &member in members {
if let Some(value) = value_at(&series[member], point)? {
candidates.push((member, value));
}
}
// Members are in label order, so a stable sort hands ties to the earlier label set.
candidates.sort_by(|a, b| rank(a.1, b.1, top));
for &(member, _) in candidates.iter().take(k) {
selected[member].push(point);
}
}
}
let output = Arc::new(schema.clone());
let mut builder = GridSeriesBuilder::try_new(output)?;
let mut result = Vec::new();
for (index, points) in selected.into_iter().enumerate() {
if points.is_empty() {
continue;
}
let mut sorted = points;
sorted.sort_unstable();
for point in sorted {
builder.push(point, &[value_at(&series[index], point)?])?;
}
if let Some(built) = builder.finish(series[index].metric.clone())? {
result.push(built);
}
}
Ok(result)
}
/// `scalar(v)`: the value of the only series present at each point, NaN otherwise.
pub fn scalar(schema: &SeriesSchema, series: &[Series]) -> Result<Series> {
single_field(schema, "scalar")?;
let points = grid_points(schema, series)?;
let mut values: Vec<Option<f64>> = vec![None; points];
let mut counts = vec![0u8; points];
for item in series {
let TimeAxis::Grid(range) = &item.time else {
unreachable!("checked by grid_points");
};
for point in range.start()..range.start() + range.len() {
if let Some(value) = value_at(item, point)? {
counts[point] = counts[point].saturating_add(1);
values[point] = Some(value);
}
}
}
let mut builder = GridSeriesBuilder::try_new(Arc::new(schema.clone()))?;
for (point, (value, count)) in values.into_iter().zip(counts).enumerate() {
let value = if count == 1 { value } else { Some(GO_NAN) };
builder.push(point, &[value])?;
}
let empty = MetricName::try_new(None, Vec::new()).map_err(external)?;
Ok(builder
.finish(empty)?
.expect("every point of a scalar has a value"))
}
/// `absent(v)`: 1 with `labels` at every point where no series is present.
pub fn absent(
schema: &SeriesSchema,
series: &[Series],
labels: MetricName,
) -> Result<Option<Series>> {
let points = grid_points(schema, series)?;
let mut present = vec![false; points];
for item in series {
let TimeAxis::Grid(range) = &item.time else {
unreachable!("checked by grid_points");
};
let points = &mut present[range.start()..range.start() + range.len()];
for (row, present) in points.iter_mut().enumerate() {
if item
.fields
.iter()
.any(|field| !matches!(field.sample(row), SampleRef::Missing))
{
*present = true;
}
}
}
let ones = vec![Some(1.0); schema.fields.len()];
let mut builder = GridSeriesBuilder::try_new(Arc::new(schema.clone()))?;
for (point, present) in present.into_iter().enumerate() {
if !present {
builder.push(point, &ones)?;
}
}
builder.finish(labels)
}
/// A label matcher of the selector `absent` is applied to.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LabelMatcher {
pub name: Arc<str>,
pub op: MatchOp,
pub value: Arc<str>,
/// Whether the matcher accepts an empty value, i.e. a missing label; the caller evaluates
/// regular expressions.
pub matches_empty: bool,
}
/// The operator of a [`LabelMatcher`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MatchOp {
Equal,
NotEqual,
Regex,
NotRegex,
}
/// The labels of an `absent` result, as Prometheus `createLabelsForAbsentFunction` derives them:
/// the first equality matcher of a name sets that label, any other matcher of the name removes
/// it, and `__name__` is skipped.
///
/// Prometheus reads the matchers after TSDB's `PostingsForMatchers` has reordered them in place,
/// moving the matchers that narrow the result before those that only subtract from it; that is
/// the order users see whenever the storage holds data, and the order used here. It changes the
/// result only when a name has both an equality and another matcher.
pub fn absent_labels(matchers: &[LabelMatcher]) -> Result<MetricName> {
let must_be_set: Vec<&str> = matchers
.iter()
.filter(|matcher| !matcher.matches_empty)
.map(|matcher| &*matcher.name)
.collect();
let subtracting = |matcher: &LabelMatcher| {
!must_be_set.contains(&&*matcher.name)
|| (matches!(matcher.op, MatchOp::NotEqual | MatchOp::NotRegex)
&& matcher.matches_empty)
};
let ordered = matchers
.iter()
.filter(|matcher| !subtracting(matcher))
.chain(matchers.iter().filter(|matcher| subtracting(matcher)));
let mut labels: Vec<Label> = Vec::new();
let mut set: Vec<&str> = Vec::new();
for matcher in ordered {
if &*matcher.name == "__name__" {
continue;
}
if matcher.op == MatchOp::Equal && !set.contains(&&*matcher.name) {
labels.retain(|label| label.name != matcher.name);
labels.push(Label {
name: matcher.name.clone(),
value: matcher.value.clone(),
});
set.push(&matcher.name);
} else {
labels.retain(|label| label.name != matcher.name);
}
}
labels.retain(|label| !label.value.is_empty());
MetricName::try_new(None, labels).map_err(external)
}
/// Orders the series of an instant result. A range result is returned unchanged: Prometheus
/// does not sort matrices.
pub fn sort_instant(
order: &SortOrder,
schema: &SeriesSchema,
mut series: Vec<Series>,
) -> Result<Vec<Series>> {
let points = grid_points(schema, &series)?;
if points != 1 {
return Ok(series);
}
match order {
SortOrder::Asc | SortOrder::Desc => {
single_field(schema, "sort")?;
let mut keyed = Vec::with_capacity(series.len());
for item in series {
let value = value_at(&item, 0)?;
keyed.push((value.unwrap_or(f64::NAN), item));
}
let descending = *order == SortOrder::Desc;
keyed.sort_by(|(a, _), (b, _)| match (a.is_nan(), b.is_nan()) {
(true, true) => Ordering::Equal,
(true, false) => Ordering::Greater,
(false, true) => Ordering::Less,
_ if descending => b.total_cmp(a),
_ => a.total_cmp(b),
});
Ok(keyed.into_iter().map(|(_, item)| item).collect())
}
SortOrder::ByLabel(labels) | SortOrder::ByLabelDesc(labels) => {
let descending = matches!(order, SortOrder::ByLabelDesc(_));
series.sort_by(|a, b| {
for label in labels {
let (a, b) = (
a.metric.get(label).unwrap_or(""),
b.metric.get(label).unwrap_or(""),
);
if a == b {
continue;
}
let less = natural_less(a, b);
return if less != descending {
Ordering::Less
} else {
Ordering::Greater
};
}
let order = compare_labels(&a.metric, &b.metric);
if descending { order.reverse() } else { order }
});
Ok(series)
}
}
}
/// Go's `math.NaN()` bit pattern, which Prometheus returns for an undefined scalar.
const GO_NAN: f64 = f64::from_bits(0x7FF8_0000_0000_0001);
/// `true` when `a` ranks before `b` for `topk` (`top`) or `bottomk`; NaN ranks last for both.
fn rank(a: f64, b: f64, top: bool) -> Ordering {
match (a.is_nan(), b.is_nan()) {
(true, true) => Ordering::Equal,
(true, false) => Ordering::Greater,
(false, true) => Ordering::Less,
_ if top => b.partial_cmp(&a).expect("not NaN"),
_ => a.partial_cmp(&b).expect("not NaN"),
}
}
/// Label pairs identifying a group; empty values are absent, as in Prometheus.
type GroupKey = Vec<(Arc<str>, Arc<str>)>;
fn group_key(metric: &MetricName, grouping: &Grouping) -> GroupKey {
let mut key: Vec<_> = labels_with_name(metric)
.into_iter()
.filter(|(name, value)| {
!value.is_empty()
&& match grouping {
Grouping::By(names) => names.contains(name),
Grouping::Without(names) => &**name != "__name__" && !names.contains(name),
}
})
.collect();
key.sort();
key
}
/// The label pairs of `metric` with `__name__` among them, sorted by name.
fn labels_with_name(metric: &MetricName) -> Vec<(Arc<str>, Arc<str>)> {
let mut labels: Vec<_> = metric
.tags()
.iter()
.map(|tag| (tag.name.clone(), tag.value.clone()))
.collect();
if let Some(name) = metric.name() {
labels.push((Arc::from("__name__"), Arc::from(name)));
}
labels.sort();
labels
}
/// Prometheus `labels.Compare`: label pairs in name order, then the shorter set first.
fn compare_labels(a: &MetricName, b: &MetricName) -> Ordering {
labels_with_name(a).cmp(&labels_with_name(b))
}
/// `github.com/facette/natsort.Compare`, which `sort_by_label` uses: digit runs compare as
/// integers when both parse, other runs as strings.
fn natural_less(a: &str, b: &str) -> bool {
let (chunks_a, chunks_b) = (chunks(a), chunks(b));
for (i, chunk_a) in chunks_a.iter().enumerate() {
let Some(chunk_b) = chunks_b.get(i) else {
return false;
};
let equal = match (chunk_a.parse::<i64>(), chunk_b.parse::<i64>()) {
(Ok(a), Ok(b)) if a != b => return a < b,
(Ok(_), Ok(_)) => true,
_ => chunk_a == chunk_b,
};
if !equal {
return chunk_a < chunk_b;
}
if i == chunks_a.len() - 1 {
return true;
} else if i == chunks_b.len() - 1 {
return false;
}
}
false
}
/// Maximal runs of ASCII digits and of other characters, like the regexp `(\d+|\D+)`.
fn chunks(s: &str) -> Vec<&str> {
let mut chunks = Vec::new();
let mut start = 0;
let bytes = s.as_bytes();
for i in 1..=bytes.len() {
if i == bytes.len() || bytes[i].is_ascii_digit() != bytes[start].is_ascii_digit() {
chunks.push(&s[start..i]);
start = i;
}
}
chunks
}
fn single_field(schema: &SeriesSchema, operator: &str) -> Result<()> {
if schema.fields.len() != 1 {
return Err(DataFusionError::NotImplemented(format!(
"{operator} over series with {} fields",
schema.fields.len()
)));
}
Ok(())
}
/// Checks every series against `schema` and returns the grid length.
fn grid_points(schema: &SeriesSchema, series: &[Series]) -> Result<usize> {
let grid = schema.eval_grid.as_ref().ok_or_else(|| {
DataFusionError::Execution("select operators need an evaluation grid".into())
})?;
for item in series {
match &item.time {
TimeAxis::Grid(range)
if range.grid().spec() == grid.spec()
&& item.fields.len() == schema.fields.len() => {}
_ => {
return Err(DataFusionError::Execution(
"series does not match the select operator schema".into(),
));
}
}
}
Ok(grid.timestamps().len())
}
/// The first field's value of `series` at grid `point`.
fn value_at(series: &Series, point: usize) -> Result<Option<f64>> {
let TimeAxis::Grid(range) = &series.time else {
unreachable!("checked by grid_points");
};
if !(range.start()..range.start() + range.len()).contains(&point) {
return Ok(None);
}
match series.fields.as_slice()[0].sample(point - range.start()) {
SampleRef::Missing => Ok(None),
SampleRef::Float(value) => Ok(Some(value)),
SampleRef::Histogram(_) => Err(DataFusionError::NotImplemented(
"select operators over histogram samples".into(),
)),
}
}
fn external(error: datatypes::error::Error) -> DataFusionError {
DataFusionError::External(Box::new(error))
}
File diff suppressed because it is too large Load Diff
+273
View File
@@ -0,0 +1,273 @@
// 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::BTreeMap;
use std::sync::Arc;
use datafusion::error::DataFusionError;
use datatypes::series::samples::{FieldSpec, Series, SeriesSchema};
use datatypes::series::time::{EvalGrid, GridSpec};
use datatypes::series::{Label, MetricName};
use super::{
Grouping, LabelMatcher, MatchOp, SortOrder, absent, absent_labels, natural_less, scalar,
sort_instant, topk,
};
use crate::grid_series::GridSeriesBuilder;
use crate::grid_series::reference::{Dense, assert_series_eq};
fn schema(points: u64, fields: usize) -> SeriesSchema {
SeriesSchema {
fields: (0..fields)
.map(|index| FieldSpec {
name: Arc::from(format!("f{index}")),
})
.collect::<Vec<_>>()
.into(),
eval_grid: Some(Arc::new(
EvalGrid::try_new(GridSpec::try_new(100_000, 10_000, points).unwrap()).unwrap(),
)),
}
}
/// Parses `name=value,...`, with `__name__` as the metric name; `-` is no labels.
fn metric(labels: &str) -> MetricName {
let mut name = None;
let mut tags = Vec::new();
for pair in labels.split(',').filter(|pair| *pair != "-") {
let (label, value) = pair.split_once('=').unwrap();
if label == "__name__" {
name = Some(Arc::from(value));
} else {
tags.push(Label {
name: Arc::from(label),
value: Arc::from(value),
});
}
}
MetricName::try_new(name, tags).unwrap()
}
fn dense(points: usize, values: &str) -> Dense {
let mut dense = vec![vec![None]; points];
for point in values.split_whitespace() {
let (index, bits) = point.split_once(':').unwrap();
dense[index.parse::<usize>().unwrap()][0] =
Some(f64::from_bits(u64::from_str_radix(bits, 16).unwrap()));
}
dense
}
fn build(schema: &SeriesSchema, metric: MetricName, dense: &Dense) -> Option<Series> {
let mut builder = GridSeriesBuilder::try_new(Arc::new(schema.clone())).unwrap();
for (index, row) in dense.iter().enumerate() {
builder.push(index, row).unwrap();
}
builder.finish(metric).unwrap()
}
fn names(list: &str) -> Vec<Arc<str>> {
list.split(',')
.filter(|name| *name != "-")
.map(Arc::from)
.collect()
}
/// `topk`/`bottomk`, `scalar` and `absent` evaluated by the Prometheus v3.15.0 engine; see the
/// header of `prometheus_cases.txt`.
#[test]
fn select_operators_match_prometheus() {
let text = include_str!("prometheus_cases.txt");
let mut checked = 0;
for case in text.split("end\n").filter(|case| case.contains("case ")) {
let lines: Vec<&str> = case.lines().filter(|line| !line.starts_with('#')).collect();
let spec: Vec<&str> = lines[0].split_whitespace().skip(1).collect();
let points: usize = lines[1].split_whitespace().nth(3).unwrap().parse().unwrap();
let schema = schema(points as u64, 1);
let mut inputs = Vec::new();
let mut expected: BTreeMap<String, Dense> = BTreeMap::new();
for line in &lines[2..] {
let (kind, rest) = line.split_once(' ').unwrap();
let (labels, values) = rest.split_once(" ;").unwrap();
match kind {
"series" => inputs.push((metric(labels), dense(points, values))),
"result" => {
expected.insert(labels.to_string(), dense(points, values));
}
other => panic!("unexpected line {other} in {case}"),
}
}
let series: Vec<Series> = inputs
.iter()
.filter_map(|(metric, dense)| build(&schema, metric.clone(), dense))
.collect();
let actual: Vec<Series> = match spec[0] {
"topk" => {
let grouping = match spec[3] {
"without" => Grouping::Without(names(spec[4])),
"by" => Grouping::By(names(spec[4])),
_ => Grouping::By(Vec::new()),
};
let k: f64 = spec[2].parse().unwrap();
topk(k, spec[1] == "topk", &grouping, &schema, &series).unwrap()
}
"scalar" => vec![scalar(&schema, &series).unwrap()],
"absent" => {
let matchers: Vec<LabelMatcher> = spec
.get(1)
.filter(|matchers| **matchers != "-")
.map(|matchers| {
matchers
.split(',')
.map(|matcher| {
let (name, op, value) = match matcher.split_once("!=") {
Some((name, value)) => (name, MatchOp::NotEqual, value),
None => {
let (name, value) = matcher.split_once('=').unwrap();
(name, MatchOp::Equal, value)
}
};
let value = value.trim_matches('"');
LabelMatcher {
name: Arc::from(name),
op,
value: Arc::from(value),
matches_empty: (op == MatchOp::Equal) == value.is_empty(),
}
})
.collect()
})
.unwrap_or_default();
let selected: Vec<Series> = series
.iter()
.filter(|item| {
matchers.iter().all(|matcher| {
let value = item.metric.get(&matcher.name).unwrap_or("");
(value == &*matcher.value) == (matcher.op == MatchOp::Equal)
})
})
.cloned()
.collect();
absent(&schema, &selected, absent_labels(&matchers).unwrap())
.unwrap()
.into_iter()
.collect()
}
other => panic!("unknown case {other}"),
};
let actual: BTreeMap<String, Series> = actual
.into_iter()
.map(|series| (render(&series.metric), series))
.collect();
assert_eq!(
actual.keys().collect::<Vec<_>>(),
expected.keys().collect::<Vec<_>>(),
"{case}"
);
for (labels, dense) in &expected {
assert_series_eq(actual.get(labels), dense, &metric(labels));
checked += 1;
}
}
assert!(checked > 200, "{checked} result series checked");
}
/// The fixture's label rendering: `name=value` pairs in name order, `-` when empty.
fn render(metric: &MetricName) -> String {
let mut labels: Vec<(String, String)> = metric
.tags()
.iter()
.map(|tag| (tag.name.to_string(), tag.value.to_string()))
.collect();
if let Some(name) = metric.name() {
labels.push(("__name__".to_string(), name.to_string()));
}
labels.sort();
if labels.is_empty() {
return "-".to_string();
}
labels
.iter()
.map(|(name, value)| format!("{name}={value}"))
.collect::<Vec<_>>()
.join(",")
}
fn instant(values: &[(&str, Option<f64>)]) -> (SeriesSchema, Vec<Series>) {
let schema = schema(1, 1);
let series = values
.iter()
.map(|(labels, value)| build(&schema, metric(labels), &vec![vec![*value]]).unwrap())
.collect();
(schema, series)
}
fn rendered(series: &[Series]) -> Vec<String> {
series.iter().map(|series| render(&series.metric)).collect()
}
#[test]
fn sorts_instant_results_like_prometheus() {
let (schema, series) = instant(&[
("host=a", Some(2.0)),
("host=b", Some(f64::NAN)),
("host=c", Some(-1.0)),
]);
let ascending = sort_instant(&SortOrder::Asc, &schema, series.clone()).unwrap();
assert_eq!(rendered(&ascending), ["host=c", "host=a", "host=b"]);
let descending = sort_instant(&SortOrder::Desc, &schema, series).unwrap();
assert_eq!(rendered(&descending), ["host=a", "host=c", "host=b"]);
let (schema, series) = instant(&[
("host=x10", Some(1.0)),
("host=x9", Some(1.0)),
("host=x9,job=a", Some(1.0)),
("host=x", Some(1.0)),
]);
let by_host = sort_instant(&SortOrder::ByLabel(names("host")), &schema, series.clone());
assert_eq!(
rendered(&by_host.unwrap()),
["host=x", "host=x9", "host=x9,job=a", "host=x10"]
);
let by_host_desc = sort_instant(&SortOrder::ByLabelDesc(names("host")), &schema, series);
assert_eq!(
rendered(&by_host_desc.unwrap()),
["host=x10", "host=x9,job=a", "host=x9", "host=x"]
);
}
#[test]
fn natural_order_follows_natsort() {
assert!(natural_less("a", "a1"));
assert!(!natural_less("a1", "a"));
assert!(natural_less("x9", "x10"));
assert!(!natural_less("10", "9"));
// A digit run too long for an i64 compares as a string, like Go's strconv.Atoi failing.
assert!(natural_less("20000000000000000000", "3"));
}
#[test]
fn rejects_value_operators_over_several_fields() {
let schema = schema(1, 2);
assert!(matches!(
topk(1.0, true, &Grouping::By(Vec::new()), &schema, &[]),
Err(DataFusionError::NotImplemented(_))
));
assert!(matches!(
scalar(&schema, &[]),
Err(DataFusionError::NotImplemented(_))
));
}