feat(promql): evaluate time, date and label rewriting functions over grid series (#9684)

I hereby agree to the terms of the [GreptimeDB
CLA](https://github.com/GreptimeTeam/.github/blob/main/CLA.md).

## Refer to a related PR or issue link (optional)

Targets `narthex`. Stacked on #9673 for `GridSeriesBuilder` and the test
reference; review only `feat(promql): evaluate time, date and label
rewriting functions over grid series`.

## What's changed and what's your intention?

### Problem

The native path has no kernels for `time()`, the date functions
(`minute`, `hour`, `day_of_month`, `day_of_week`, `day_of_year`,
`days_in_month`, `month`, `year`) or `label_replace`/`label_join`.

### Change

- `map_time::time` and `map_time::date_of_eval_time` produce one
unlabelled series over the schema's grid from the evaluation timestamps
(`ms / 1000` for `time()`, whole seconds truncated towards zero for the
date functions, as Prometheus `funcTime`/`dateWrapper`).
- `map_time::map_date` reads every float value as Unix seconds and drops
`__name__`, `__type__` and `__unit__`. The value goes through Go's
`int64(f)` as on amd64 (NaN and out-of-range values become `i64::MIN`)
and a port of Go's `time` absolute-date arithmetic, including its
wrapping for instants Go cannot represent, which decides the result for
those inputs. All-float series are mapped chunk by chunk without copying
the time axis; histogram samples are dropped and the series is trimmed
again.
- `map_label::label_replace` anchors the regex as `^(?s:...)$`, expands
`$1`/`${name}`/`$$` with Go's `regexp.Expand` rules, and leaves
unmatched series unchanged. `map_label::label_join` joins source values,
an absent label joining as `""`. An empty result removes the label in
both. Empty source or destination label names and invalid regexes are
`Plan` errors with Prometheus' messages.
- `grid_series::merge_same_labelset` follows Prometheus
`mergeSeriesWithSameLabelset`: series that share a label set (empty
values counting as absent) become one series, merged field by field; a
field with values from two of them at one point fails with "vector
cannot contain metrics with the same labelset". The label functions call
it; `map_date` leaves it to the caller, as other per-series kernels that
drop `__name__` do.
- `promql` now depends on the workspace `regex` crate, which is already
in the build graph.

Review focus:

- `map_time::DateFunction::apply` and `civil`: the port of Go's
`absSeconds`/`absDays` arithmetic; `days_in_month` uses the proleptic
Gregorian leap rule on the computed year.
- `map_time::go_int64`: NaN and values outside `[-2^63, 2^63)` map to
`i64::MIN`.
- `merge_same_labelset`: the fast path returns the input untouched when
no label sets collide; colliding groups are rebuilt row by row and fail
on any shared point.
- `label_replace` expands its template with Go's `regexp.Expand` rules:
a reference with a leading zero (`$01`) or of more than nine digits
names a group, unlike `regex::Captures::expand`.

### Non-goals

- `timestamp()` (EVAL-TS), which needs sample timestamps from instant
selection.
- Merging histogram series that collide after a label function
(`NotImplemented`).
- Planner wiring.

### Constraints

- Every per-series kernel that drops `__name__` (range functions, date
functions, math) can leave two series with one label set. Prometheus
merges or rejects them per step; on the native path that is
`merge_same_labelset`, which the caller must apply after such kernels.
- Regexes compile with the Rust `regex` crate, as the ordinary planner
does. Perl classes (`\d`, `\w`, `\s`, `\b`) are Unicode-aware there and
ASCII-only in Go's RE2, so non-ASCII label values can match differently.
The dialects also differ in what compiles: `\Q...\E` is rejected, `(?x)`
and repeat counts above 1000 are accepted, and `\<`, `\>` are word
boundaries instead of literal characters. The rustdoc of `label_replace`
lists the same differences.
- `go_int64` follows amd64. On arm64 Go saturates (`NaN` becomes 0), so
Prometheus' own results for non-finite inputs differ by architecture.

### Verification

- `cargo nextest run -p promql`: 315 passed.
- `cargo clippy -p promql --all-targets --all-features -- -D warnings`,
`cargo fmt --check`, `hawkeye check`: clean.
- `time_functions_match_prometheus`: 200 fixtures produced by the
Prometheus v3.15.0 engine built for amd64
(`map_time/prometheus_cases.txt`): `time()` and argument-less date
functions over grids from 1685 to 2254, and date functions over values
including NaN, ±Inf, ±1e300, ±9.2e18, leap-day edges of
1600/1900/2000/2100/2400 and years 0 and 10000. Values compare bit for
bit after `merge_same_labelset`; 6 cases expect the duplicate-labelset
error.
- `label_functions_match_prometheus`: 200 fixtures
(`map_label/prometheus_cases.txt`) with random destinations including
`__name__`, absent sources, templates with valid and invalid `$`
references, anchoring-sensitive regexes and invalid ones; 25 expect
errors, the rest compare label sets and value bits.
- Mutation check: each of these fails a test: Go's NaN and out-of-range
conversion replaced with a saturating cast or NaN-to-0; floor instead of
truncating division of the evaluation time; whole-second `time()`;
dropping the 400-year rule; leap day counted in January and February;
hour from the Unix seconds instead of Go's absolute seconds; no leap
February in `days_in_month`; leap year taken from the March-based year;
keeping `__name__`; mapping only the first chunk; regex without the
`(?s:...)` group or without anchors; setting an empty label instead of
removing it; skipping absent `label_join` sources; skipping the merge;
skipping the duplicate check; comparing empty label values as present.
- `replacements_expand_like_go`: 16 templates with expectations taken
from Go, including `$1²`, where Go's Unicode classes end the name before
`²`.
- `merge_combines_disjoint_fields_at_one_point`: different fields at one
point merge, one field present twice fails.

## PR Checklist
Please convert it to a draft if some of the following conditions are not
met.

- [x] I have written the necessary rustdoc comments.
- [x] I have added the necessary unit tests and integration tests.
- [ ] This PR requires documentation updates.
- [x] API changes are backward compatible.
- [x] Schema or data changes are backward compatible.
- [ ] This PR needs to be backported to release branches, and I have
added the `backport-<target>` labels (e.g. `backport-v1.3` targets
`release/v1.3`).
This commit is contained in:
dennis zhuang
2026-10-18 01:00:43 +08:00
committed by GitHub
12 changed files with 4195 additions and 6 deletions
Generated
+1
View File
@@ -11371,6 +11371,7 @@ dependencies = [
"prometheus 0.14.0",
"prost 0.14.1",
"rand 0.9.4",
"regex",
"snafu 0.8.6",
"tokio",
]
+1
View File
@@ -24,6 +24,7 @@ greptime-proto.workspace = true
lazy_static.workspace = true
prometheus.workspace = true
prost.workspace = true
regex.workspace = true
snafu.workspace = true
[dev-dependencies]
+121 -1
View File
@@ -14,13 +14,15 @@
//! Building evaluated float series that cover only their result range on a shared grid.
use std::collections::HashMap;
use std::collections::hash_map::Entry;
use std::sync::Arc;
use datafusion::arrow::array::Float64Array;
use datafusion::arrow::array::builder::NullBufferBuilder;
use datafusion::error::{DataFusionError, Result};
use datatypes::series::MetricName;
use datatypes::series::samples::{Samples, Series, SeriesFields, SeriesSchema};
use datatypes::series::samples::{SampleRef, Samples, Series, SeriesFields, SeriesSchema};
use datatypes::series::time::{EvalGrid, GridRange, TimeAxis};
#[cfg(test)]
@@ -140,6 +142,124 @@ impl GridSeriesBuilder {
}
}
type LabelSet = Vec<(Arc<str>, Arc<str>)>;
/// The label set Prometheus compares, which has no empty values.
fn label_set(metric: &MetricName) -> LabelSet {
let name = metric
.name()
.filter(|name| !name.is_empty())
.map(|name| (Arc::from("__name__"), Arc::from(name)));
name.into_iter()
.chain(
metric
.tags()
.iter()
.filter(|tag| !tag.value.is_empty())
.map(|tag| (tag.name.clone(), tag.value.clone())),
)
.collect()
}
/// Merges series that share a label set into one, as Prometheus does after a function that
/// rewrites labels or drops `__name__`; two of them holding one field at one point fail.
pub fn merge_same_labelset(schema: &Arc<SeriesSchema>, series: Vec<Series>) -> Result<Vec<Series>> {
let mut groups: HashMap<LabelSet, Vec<usize>> = HashMap::with_capacity(series.len());
let mut collided = false;
for (index, series) in series.iter().enumerate() {
match groups.entry(label_set(&series.metric)) {
Entry::Occupied(mut entry) => {
entry.get_mut().push(index);
collided = true;
}
Entry::Vacant(entry) => {
entry.insert(vec![index]);
}
}
}
if !collided {
return Ok(series);
}
let mut series: Vec<Option<Series>> = series.into_iter().map(Some).collect();
let mut output = Vec::with_capacity(groups.len());
let mut builder = GridSeriesBuilder::try_new(schema.clone())?;
let mut rows = Vec::new();
let mut values = vec![None; schema.fields.len()];
let mut merged = vec![None; schema.fields.len()];
for (labels, members) in groups {
if let [member] = members[..] {
output.extend(series[member].take());
continue;
}
rows.clear();
for &member in &members {
let member_series = series[member]
.as_ref()
.expect("each series is in one group");
let TimeAxis::Grid(range) = &member_series.time else {
return Err(DataFusionError::Execution(
"merging requires evaluated series".into(),
));
};
for row in 0..range.len() {
if read_row(member_series, row, &mut values)? {
rows.push((range.start() + row, member, row));
}
}
}
rows.sort_unstable_by_key(|&(point, _, _)| point);
let mut start = 0;
while start < rows.len() {
let point = rows[start].0;
let end = start + rows[start..].partition_point(|&(p, _, _)| p == point);
// Rows of one point merge field by field; only a field present twice collides.
merged.iter_mut().for_each(|value| *value = None);
for &(_, member, row) in &rows[start..end] {
read_row(series[member].as_ref().unwrap(), row, &mut values)?;
for (slot, value) in merged.iter_mut().zip(&values) {
if value.is_some() {
if slot.is_some() {
let labels: Vec<_> = labels
.iter()
.map(|(name, value)| format!("{name}=\"{value}\""))
.collect();
return Err(DataFusionError::Execution(format!(
"vector cannot contain metrics with the same labelset {{{}}}",
labels.join(", ")
)));
}
*slot = *value;
}
}
}
builder.push(point, &merged)?;
start = end;
}
let metric = series[members[0]].as_ref().unwrap().metric.clone();
output.extend(builder.finish(metric)?);
}
Ok(output)
}
/// Reads the float fields of `row`; returns whether any is present.
fn read_row(series: &Series, row: usize, values: &mut [Option<f64>]) -> Result<bool> {
let mut present = false;
for (value, samples) in values.iter_mut().zip(series.fields.iter()) {
*value = match samples.sample(row) {
SampleRef::Missing => None,
SampleRef::Float(value) => Some(value),
SampleRef::Histogram(_) => {
return Err(DataFusionError::NotImplemented(
"merging histogram series with the same label set".into(),
));
}
};
present |= value.is_some();
}
Ok(present)
}
fn external(error: datatypes::error::Error) -> DataFusionError {
DataFusionError::External(Box::new(error))
}
+149 -3
View File
@@ -15,11 +15,16 @@
//! Brute-force reference for operators producing grid series: results are compared as dense
//! `[grid index][field]` matrices, so a kernel cannot pass by aligning local row numbers.
use datatypes::series::MetricName;
use datatypes::series::samples::{SampleRef, Series};
use datatypes::series::time::TimeAxis;
use std::collections::BTreeMap;
use std::sync::Arc;
use datatypes::series::samples::{SampleRef, Series, SeriesSchema};
use datatypes::series::time::{EvalGrid, GridSpec, TimeAxis};
use datatypes::series::{Label, MetricName};
use rand::Rng;
use crate::grid_series::GridSeriesBuilder;
/// Values by absolute grid index, then by field; `None` is missing.
pub(crate) type Dense = Vec<Vec<Option<f64>>>;
@@ -141,3 +146,144 @@ pub(crate) fn random_dense(rng: &mut impl Rng, grid_len: usize, fields: usize) -
}
dense
}
/// Labels with `__name__` as an ordinary entry, the way Prometheus holds them.
pub(crate) type Labels = BTreeMap<String, String>;
/// Value bits by grid index.
pub(crate) type Points = BTreeMap<usize, u64>;
/// A case of a fixture written by a promql-compat `narthex-golden-*` command.
pub(crate) struct GoldenCase {
/// The operation and its tab-separated arguments.
pub(crate) spec: Vec<String>,
pub(crate) grid: Arc<EvalGrid>,
pub(crate) inputs: Vec<(Labels, Points)>,
/// Result series by label set, or the error message.
pub(crate) outcome: Result<BTreeMap<Labels, Points>, String>,
}
/// Parses a fixture whose label pairs are separated by `|`.
pub(crate) fn golden_cases(text: &str) -> Vec<GoldenCase> {
let labels = |text: &str| -> Labels {
text.split('|')
.filter(|pair| *pair != "-")
.map(|pair| {
let (name, value) = pair.split_once('=').unwrap();
(name.to_string(), value.to_string())
})
.collect()
};
let points = |text: &str| -> Points {
text.split_whitespace()
.map(|point| {
let (index, bits) = point.split_once(':').unwrap();
(
index.parse().unwrap(),
u64::from_str_radix(bits, 16).unwrap(),
)
})
.collect()
};
let mut cases = Vec::new();
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 = lines[0].strip_prefix("case ").unwrap();
let grid: Vec<i64> = lines[1]
.split_whitespace()
.skip(1)
.map(|value| value.parse().unwrap())
.collect();
let grid = GridSpec::try_new(grid[0], grid[1], grid[2] as u64).unwrap();
let mut inputs = Vec::new();
let mut outcome = Ok(BTreeMap::new());
for line in &lines[2..] {
if let Some(message) = line.strip_prefix("error ") {
outcome = Err(message.to_string());
continue;
}
let (kind, rest) = line.split_once(' ').unwrap();
let (series_labels, series_points) = rest.split_once(" ;").unwrap();
let entry = (labels(series_labels), points(series_points));
match kind {
"series" => inputs.push(entry),
"result" => {
outcome.as_mut().unwrap().insert(entry.0, entry.1);
}
other => panic!("unexpected line {other}"),
}
}
cases.push(GoldenCase {
spec: spec.split('\t').map(str::to_string).collect(),
grid: Arc::new(EvalGrid::try_new(grid).unwrap()),
inputs,
outcome,
});
}
cases
}
pub(crate) fn metric_of(labels: &Labels) -> MetricName {
let mut labels = labels.clone();
let name = labels.remove("__name__").map(Arc::from);
MetricName::try_new(
name,
labels
.into_iter()
.map(|(name, value)| Label {
name: Arc::from(name),
value: Arc::from(value),
})
.collect(),
)
.unwrap()
}
pub(crate) fn labels_of(metric: &MetricName) -> Labels {
let mut labels: Labels = metric
.tags()
.iter()
.map(|tag| (tag.name.to_string(), tag.value.to_string()))
.collect();
if let Some(name) = metric.name() {
labels.insert("__name__".to_string(), name.to_string());
}
labels
}
/// A single-field series of `schema` holding `points`; `None` without points.
pub(crate) fn series_of(
schema: &Arc<SeriesSchema>,
labels: &Labels,
points: &Points,
) -> Option<Series> {
let mut builder = GridSeriesBuilder::try_new(schema.clone()).unwrap();
for (&index, &bits) in points {
builder.push(index, &[Some(f64::from_bits(bits))]).unwrap();
}
builder.finish(metric_of(labels)).unwrap()
}
/// The points of a single-field series, any NaN as one NaN: Prometheus' NaN payloads depend on
/// how the Go compiler orders operands.
pub(crate) fn points_of(series: &Series) -> Points {
let TimeAxis::Grid(range) = &series.time else {
panic!("expected a grid series");
};
series.fields.as_slice()[0]
.iter()
.enumerate()
.filter_map(|(row, sample)| match sample {
SampleRef::Float(value) => Some((range.start() + row, canonical_bits(value))),
_ => None,
})
.collect()
}
pub(crate) fn canonical_bits(value: f64) -> u64 {
if value.is_nan() {
f64::NAN.to_bits()
} else {
value.to_bits()
}
}
+61 -2
View File
@@ -20,8 +20,8 @@ use datatypes::series::{Label, MetricName};
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use super::GridSeriesBuilder;
use super::reference::{assert_series_eq, random_dense};
use super::reference::{Labels, Points, assert_series_eq, points_of, random_dense, series_of};
use super::{GridSeriesBuilder, merge_same_labelset};
fn grid_schema(fields: usize, grid_len: u64) -> Arc<SeriesSchema> {
Arc::new(SeriesSchema {
@@ -83,3 +83,62 @@ fn builder_rejects_invalid_rows() {
schema.eval_grid = None;
assert!(GridSeriesBuilder::try_new(Arc::new(schema)).is_err());
}
/// Label sets compare without empty values, which Prometheus does not have: a series with
/// `host=""` and one without `host` merge.
#[test]
fn merge_treats_empty_label_values_as_absent() {
let schema = grid_schema(1, 2);
let labels = |host: Option<&str>| {
let mut labels = Labels::from([("job".to_string(), "x".to_string())]);
if let Some(host) = host {
labels.insert("host".to_string(), host.to_string());
}
labels
};
let series = vec![
series_of(
&schema,
&labels(Some("")),
&Points::from([(0, 1f64.to_bits())]),
)
.unwrap(),
series_of(&schema, &labels(None), &Points::from([(1, 2f64.to_bits())])).unwrap(),
];
let merged = merge_same_labelset(&schema, series).unwrap();
assert_eq!(merged.len(), 1);
assert_eq!(
points_of(&merged[0]),
Points::from([(0, 1f64.to_bits()), (1, 2f64.to_bits())])
);
}
/// Series of one label set merge field by field: different fields at one point combine, and
/// only one field present in both collides.
#[test]
fn merge_combines_disjoint_fields_at_one_point() {
let schema = grid_schema(2, 1);
let series = |row: [Option<f64>; 2]| {
let mut builder = GridSeriesBuilder::try_new(schema.clone()).unwrap();
builder.push(0, &row).unwrap();
builder.finish(metric(0)).unwrap().unwrap()
};
let merged = merge_same_labelset(
&schema,
vec![series([Some(1.0), None]), series([None, Some(2.0)])],
)
.unwrap();
assert_eq!(merged.len(), 1);
assert_series_eq(
Some(&merged[0]),
&vec![vec![Some(1.0), Some(2.0)]],
&metric(0),
);
assert!(
merge_same_labelset(
&schema,
vec![series([Some(1.0), None]), series([Some(2.0), None])]
)
.is_err()
);
}
+2
View File
@@ -17,6 +17,8 @@ pub mod extension_plan;
pub mod functions;
mod go_sort;
pub mod grid_series;
pub mod map_label;
pub mod map_time;
mod metrics;
pub mod range_array;
pub mod range_eval;
+157
View File
@@ -0,0 +1,157 @@
// 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.
//! `label_replace` and `label_join` over evaluated series, following Prometheus
//! `evalLabelReplace`, `evalLabelJoin` and `mergeSeriesWithSameLabelset`.
use std::sync::{Arc, LazyLock};
use datafusion::error::{DataFusionError, Result};
use datatypes::series::MetricName;
use datatypes::series::samples::{Series, SeriesSchema};
use regex::Regex;
use crate::grid_series::merge_same_labelset;
#[cfg(test)]
mod tests;
/// `label_replace(v, dst, replacement, src, regex)`: where `regex` matches the whole value of
/// `src`, `dst` is set to `replacement` with `$1`, `${name}` and `$$` expanded.
///
/// `regex` compiles with the `regex` crate, whose dialect differs from Go's RE2 used by
/// Prometheus: Perl classes (`\d`, `\w`, `\s`, `\b`) are Unicode-aware, `\Q...\E` is rejected,
/// `(?x)` and repeat counts above 1000 are accepted, and `\<`, `\>` are word boundaries instead
/// of literal characters.
pub fn label_replace(
schema: &Arc<SeriesSchema>,
mut series: Vec<Series>,
dst: &str,
replacement: &str,
src: &str,
regex: &str,
) -> Result<Vec<Series>> {
let compiled = Regex::new(&format!("^(?s:{regex})$")).map_err(|_| {
DataFusionError::Plan(format!(
"invalid regular expression in label_replace(): {regex}"
))
})?;
if dst.is_empty() {
return Err(DataFusionError::Plan(
"invalid destination label name in label_replace(): ".into(),
));
}
let mut value = String::new();
for series in &mut series {
let Some(captures) = compiled.captures(series.metric.get(src).unwrap_or_default()) else {
continue;
};
value.clear();
expand(&compiled, &captures, replacement, &mut value);
set_label(&mut series.metric, dst, &value)?;
}
merge_same_labelset(schema, series)
}
/// Go `regexp.Expand` reads a group name as Unicode letters (L), decimal digits (Nd) and `_`,
/// narrower than `char::is_alphanumeric`.
static GROUP_NAME: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[\p{L}\p{Nd}_]*").unwrap());
/// Go `regexp.Expand`, whose template syntax differs from `regex::Captures::expand`: a numeric
/// reference with a leading zero, like `$01`, or of more than nine digits is a group name.
fn expand(regex: &Regex, captures: &regex::Captures<'_>, template: &str, out: &mut String) {
let mut rest = template;
while let Some(dollar) = rest.find('$') {
out.push_str(&rest[..dollar]);
rest = &rest[dollar + 1..];
if let Some(after) = rest.strip_prefix('$') {
out.push('$');
rest = after;
continue;
}
let (braced, body) = match rest.strip_prefix('{') {
Some(body) => (true, body),
None => (false, rest),
};
let len = GROUP_NAME.find(body).map_or(0, |name| name.end());
let name = &body[..len];
let closed = !braced || body[len..].starts_with('}');
if name.is_empty() || !closed {
// Malformed; Go keeps the `$` as text.
out.push('$');
continue;
}
rest = &body[len + usize::from(braced)..];
let number = (name.bytes().all(|b| b.is_ascii_digit())
&& !(name.len() > 1 && name.starts_with('0')))
.then(|| name.parse::<usize>().ok())
.flatten()
.filter(|_| name.len() <= 9);
let group = match number {
Some(number) => captures.get(number),
None => regex
.capture_names()
.position(|group| group == Some(name))
.and_then(|index| captures.get(index)),
};
if let Some(group) = group {
out.push_str(group.as_str());
}
}
out.push_str(rest);
}
/// `label_join(v, dst, separator, src...)`: `dst` is set to the values of `src` joined by
/// `separator`, an absent label joining as the empty string.
pub fn label_join(
schema: &Arc<SeriesSchema>,
mut series: Vec<Series>,
dst: &str,
separator: &str,
src: &[&str],
) -> Result<Vec<Series>> {
if let Some(src) = src.iter().find(|src| src.is_empty()) {
return Err(DataFusionError::Plan(format!(
"invalid source label name in label_join(): {src}"
)));
}
if dst.is_empty() {
return Err(DataFusionError::Plan(
"invalid destination label name in label_join(): ".into(),
));
}
let mut value = String::new();
for series in &mut series {
value.clear();
for (index, src) in src.iter().enumerate() {
if index > 0 {
value.push_str(separator);
}
value.push_str(series.metric.get(src).unwrap_or_default());
}
set_label(&mut series.metric, dst, &value)?;
}
merge_same_labelset(schema, series)
}
/// An empty value removes the label, as Prometheus label sets have no empty values.
fn set_label(metric: &mut MetricName, name: &str, value: &str) -> Result<()> {
if value.is_empty() {
metric.remove(name);
return Ok(());
}
metric
.set(name, Arc::from(value))
.map_err(|error| DataFusionError::External(Box::new(error)))
}
File diff suppressed because it is too large Load Diff
+115
View File
@@ -0,0 +1,115 @@
// 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 datatypes::series::samples::{FieldSpec, SeriesSchema};
use super::{label_join, label_replace};
use crate::grid_series::reference::{
canonical_bits, golden_cases, labels_of, points_of, series_of,
};
#[test]
fn label_functions_match_prometheus() {
let cases = golden_cases(include_str!("prometheus_cases.txt"));
assert_eq!(cases.len(), 200);
let (mut matched, mut merged, mut failed) = (0, 0, 0);
for case in cases {
let schema = Arc::new(SeriesSchema {
fields: vec![FieldSpec {
name: Arc::from("value"),
}]
.into(),
eval_grid: Some(case.grid.clone()),
});
let input = case
.inputs
.iter()
.filter_map(|(labels, points)| series_of(&schema, labels, points))
.collect::<Vec<_>>();
let inputs = input.len();
let spec: Vec<&str> = case.spec.iter().map(String::as_str).collect();
let actual = match spec[0] {
"label_replace" => label_replace(&schema, input, spec[1], spec[2], spec[3], spec[4]),
"label_join" => label_join(&schema, input, spec[1], spec[2], &spec[3..]),
other => panic!("unknown case {other}"),
};
match (actual, case.outcome) {
(Ok(actual), Ok(expected)) => {
if expected.len() < inputs {
merged += 1;
}
let actual: BTreeMap<_, _> = actual
.iter()
.map(|series| (labels_of(&series.metric), points_of(series)))
.collect();
let expected: BTreeMap<_, _> = expected
.into_iter()
.map(|(labels, points)| {
let points = points
.into_iter()
.map(|(index, bits)| (index, canonical_bits(f64::from_bits(bits))))
.collect();
(labels, points)
})
.collect();
assert_eq!(actual, expected, "case {spec:?}");
matched += 1;
}
(Err(actual), Err(expected)) => {
assert!(
actual.to_string().contains(&expected),
"case {spec:?}: {actual} is not {expected}"
);
failed += 1;
}
(actual, expected) => panic!("case {spec:?}: {actual:?} is not {expected:?}"),
}
}
assert!(
matched > 150 && merged > 2 && failed > 20,
"{matched} matched, {merged} merged, {failed} failed"
);
}
/// Replacement templates expand as Go's `regexp.Expand` does; expectations come from Go.
#[test]
fn replacements_expand_like_go() {
let regex = regex::Regex::new("^(?s:(?P<word>[a-z]+)-([0-9]+))$").unwrap();
let captures = regex.captures("abc-42").unwrap();
for (template, expected) in [
("$01", ""),
("${01}", ""),
("$1", "abc"),
("${1}x", "abcx"),
("$1x", ""),
("$$", "$"),
("$", "$"),
("${1", "${1"),
("$word", "abc"),
("${word}!", "abc!"),
("$2$1", "42abc"),
("$-", "$-"),
("$0", "abc-42"),
("$9", ""),
("$1234567890", ""),
("$1²", "abc²"),
] {
let mut out = String::new();
super::expand(&regex, &captures, template, &mut out);
assert_eq!(out, expected, "{template}");
}
}
+219
View File
@@ -0,0 +1,219 @@
// 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.
//! `time()` and the date functions over evaluated series, following Prometheus `funcTime` and
//! `dateWrapper`.
use std::sync::Arc;
use datafusion::arrow::array::Float64Array;
use datafusion::error::{DataFusionError, Result};
use datatypes::series::MetricName;
use datatypes::series::samples::{
DenseSamplesRef, SampleRef, Samples, Series, SeriesFields, SeriesSchema,
};
use datatypes::series::time::{GridRange, TimeAxis};
use crate::grid_series::GridSeriesBuilder;
#[cfg(test)]
mod tests;
/// A PromQL date function: `minute(v)`, `year()` and so on.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DateFunction {
DayOfMonth,
DayOfWeek,
DayOfYear,
DaysInMonth,
Hour,
Minute,
Month,
Year,
}
/// `time()`: the evaluation time in seconds at every point of the schema's grid, unlabelled.
pub fn time(schema: &SeriesSchema) -> Result<Series> {
eval_time_series(schema, |ms| ms as f64 / 1000.0)
}
/// A date function without argument, which reads the evaluation time: one unlabelled series
/// over the whole grid.
pub fn date_of_eval_time(function: DateFunction, schema: &SeriesSchema) -> Result<Series> {
// Prometheus truncates the timestamp to whole seconds towards zero.
eval_time_series(schema, |ms| function.apply(ms / 1000))
}
/// A date function over `series`: each float value is read as Unix seconds and replaced by the
/// date component, and the metric name is dropped. Missing values stay missing; histogram
/// samples are dropped, so the result is `None` when no float remains.
///
/// Series that share a label set once their names are dropped must then go through
/// [`merge_same_labelset`](crate::grid_series::merge_same_labelset).
pub fn map_date(
function: DateFunction,
schema: &Arc<SeriesSchema>,
series: &Series,
) -> Result<Option<Series>> {
let TimeAxis::Grid(range) = &series.time else {
return Err(DataFusionError::Execution(
"date functions require an evaluated series".into(),
));
};
let mut metric = series.metric.clone();
metric.remove_all(&METADATA_LABELS);
let map = |seconds: f64| function.apply(go_int64(seconds));
let all_floats = series.fields.iter().all(|samples| {
samples
.segments()
.iter()
.all(|segment| matches!(segment.dense(), Some(DenseSamplesRef::Float(_))))
});
if all_floats {
let fields = series
.fields
.iter()
.map(|samples| {
let segments = samples
.segments()
.iter()
.map(|segment| {
let Some(DenseSamplesRef::Float(floats)) = segment.dense() else {
unreachable!("checked above");
};
Samples::from_float(floats.unary(map))
})
.collect();
Samples::try_from_chunks(segments).map_err(external)
})
.collect::<Result<Vec<_>>>()?;
let fields = SeriesFields::try_from_vec(fields).map_err(external)?;
return Series::try_new(schema, metric, series.time.clone(), fields)
.map(Some)
.map_err(external);
}
// Histogram samples leave holes, which may move the ends of the series.
let mut builder = GridSeriesBuilder::try_new(schema.clone())?;
let mut values = vec![None; series.fields.len()];
for row in 0..range.len() {
for (value, samples) in values.iter_mut().zip(series.fields.iter()) {
*value = match samples.sample(row) {
SampleRef::Float(seconds) => Some(map(seconds)),
SampleRef::Missing | SampleRef::Histogram(_) => None,
};
}
builder.push(range.start() + row, &values)?;
}
builder.finish(metric)
}
fn eval_time_series(schema: &SeriesSchema, value: impl Fn(i64) -> f64) -> Result<Series> {
let grid = schema.eval_grid.clone().ok_or_else(|| {
DataFusionError::Execution("time functions require an evaluation grid".into())
})?;
let values: Float64Array = grid.timestamps().iter().map(|&ms| value(ms)).collect();
let range = GridRange::try_new(grid.clone(), 0, values.len()).map_err(external)?;
let fields = vec![Samples::from_float(values); schema.fields.len()];
Series::try_new(
schema,
MetricName::try_new(None, Vec::new()).map_err(external)?,
TimeAxis::Grid(range),
SeriesFields::try_from_vec(fields).map_err(external)?,
)
.map_err(external)
}
/// Go's `int64(f)` on amd64: NaN and values out of range become `i64::MIN`.
fn go_int64(value: f64) -> i64 {
if (-9_223_372_036_854_775_808.0..9_223_372_036_854_775_808.0).contains(&value) {
value as i64
} else {
i64::MIN
}
}
/// Prometheus `schema.IsMetadataLabel`: the labels removed with the metric name.
const METADATA_LABELS: [&str; 3] = ["__name__", "__type__", "__unit__"];
const SECONDS_PER_DAY: u64 = 86_400;
const MARCH_THRU_DECEMBER: u32 = 306;
const ABSOLUTE_YEARS: u64 = 292_277_022_400;
/// Go's `unixToInternal + internalToAbsolute`.
const UNIX_TO_ABSOLUTE: i64 = (1969 * 365 + 1969 / 4 - 1969 / 100 + 1969 / 400) * 86_400
+ (ABSOLUTE_YEARS as i64 / 400 * 146_097 + MARCH_THRU_DECEMBER as i64) * 86_400;
impl DateFunction {
/// The component of Unix second `seconds` in UTC, computed the way Go's `time` package does,
/// including its wrapping arithmetic for instants out of its range.
fn apply(self, seconds: i64) -> f64 {
let abs = seconds.wrapping_add(UNIX_TO_ABSOLUTE) as u64;
let days = abs / SECONDS_PER_DAY;
let value = match self {
Self::Hour => (abs % SECONDS_PER_DAY / 3600) as i64,
Self::Minute => (abs % 3600 / 60) as i64,
Self::DayOfWeek => ((days + 3) % 7) as i64,
Self::Year => civil(days).year,
Self::Month => civil(days).month as i64,
Self::DayOfMonth => civil(days).day as i64,
Self::DayOfYear => civil(days).year_day,
Self::DaysInMonth => {
let Civil { year, month, .. } = civil(days);
let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
match month {
2 if leap => 29,
2 => 28,
4 | 6 | 9 | 11 => 30,
_ => 31,
}
}
};
value as f64
}
}
struct Civil {
year: i64,
month: u32,
day: u32,
year_day: i64,
}
/// Go's `absDays.split`, `date` and `yearYday`: days since March 1 of year `-ABSOLUTE_YEARS`.
fn civil(days: u64) -> Civil {
let d = days.wrapping_mul(4).wrapping_add(3);
let century = d / 146_097;
let cd = (d % 146_097) as u32 | 3;
let product = 2_939_745u64 * cd as u64;
let cyear = (product >> 32) as u32;
let ayday = product as u32 / 2_939_745 / 4;
let m = 2141 * ayday + 197_913;
let jan_feb = u32::from(ayday >= MARCH_THRU_DECEMBER);
let leap = cyear.is_multiple_of(4) && (cyear != 0 || century.is_multiple_of(4));
Civil {
year: century.wrapping_mul(100).wrapping_sub(ABSOLUTE_YEARS) as i64
+ cyear as i64
+ jan_feb as i64,
month: (m >> 16) - jan_feb * 12,
day: 1 + (m & 0xFFFF) / 2141,
year_day: ayday as i64 + (1 + 31 + 28) + i64::from(leap && jan_feb == 0)
- 365 * jan_feb as i64,
}
}
fn external(error: datatypes::error::Error) -> DataFusionError {
DataFusionError::External(Box::new(error))
}
File diff suppressed because it is too large Load Diff
+175
View File
@@ -0,0 +1,175 @@
// 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::arrow::array::{Float64Array, UInt32Array};
use datatypes::series::histogram::{
BucketSideView, CounterResetHint, HistogramBatchBuilder, HistogramView,
};
use datatypes::series::samples::{FieldSpec, Samples, Series, SeriesFields, SeriesSchema};
use datatypes::series::time::{EvalGrid, GridRange, GridSpec, TimeAxis};
use super::{DateFunction, date_of_eval_time, map_date, time};
use crate::grid_series::merge_same_labelset;
use crate::grid_series::reference::{
Labels, canonical_bits, golden_cases, labels_of, metric_of, points_of, series_of,
};
fn schema(grid: Arc<EvalGrid>) -> Arc<SeriesSchema> {
Arc::new(SeriesSchema {
fields: vec![FieldSpec {
name: Arc::from("value"),
}]
.into(),
eval_grid: Some(grid),
})
}
fn function(name: &str) -> DateFunction {
match name {
"day_of_month" => DateFunction::DayOfMonth,
"day_of_week" => DateFunction::DayOfWeek,
"day_of_year" => DateFunction::DayOfYear,
"days_in_month" => DateFunction::DaysInMonth,
"hour" => DateFunction::Hour,
"minute" => DateFunction::Minute,
"month" => DateFunction::Month,
"year" => DateFunction::Year,
other => panic!("unknown date function {other}"),
}
}
#[test]
fn time_functions_match_prometheus() {
let cases = golden_cases(include_str!("prometheus_cases.txt"));
assert_eq!(cases.len(), 200);
for case in cases {
let schema = schema(case.grid.clone());
let actual: Vec<Series> = match (case.spec[0].as_str(), case.spec[1].as_str()) {
("eval_time", "time") => vec![time(&schema).unwrap()],
("eval_time", name) => vec![date_of_eval_time(function(name), &schema).unwrap()],
("date", name) => case
.inputs
.iter()
.filter_map(|(labels, points)| series_of(&schema, labels, points))
.filter_map(|series| map_date(function(name), &schema, &series).unwrap())
.collect::<Vec<_>>(),
(kind, _) => panic!("unknown case {kind}"),
};
// Dropping `__name__` can give two series one label set.
let actual = match (merge_same_labelset(&schema, actual), case.outcome) {
(Ok(actual), Ok(expected)) => (actual, expected),
(Err(actual), Err(expected)) => {
assert!(actual.to_string().contains(&expected), "{actual}");
continue;
}
(actual, expected) => panic!("case {:?}: {actual:?} is not {expected:?}", case.spec),
};
let (actual, expected) = actual;
let actual: BTreeMap<_, _> = actual
.iter()
.map(|series| (labels_of(&series.metric), points_of(series)))
.collect();
let expected: BTreeMap<_, _> = expected
.into_iter()
.map(|(labels, points)| {
let points = points
.into_iter()
.map(|(index, bits)| (index, canonical_bits(f64::from_bits(bits))))
.collect();
(labels, points)
})
.collect();
assert_eq!(actual, expected, "case {:?}", case.spec);
}
}
/// Histogram samples are dropped and the series trimmed to the remaining floats; chunked floats
/// are mapped chunk by chunk.
#[test]
fn map_date_drops_histograms_and_maps_chunks() {
let grid = Arc::new(EvalGrid::try_new(GridSpec::try_new(0, 1000, 4).unwrap()).unwrap());
let schema = schema(grid.clone());
let range = GridRange::try_new(grid, 1, 3).unwrap();
let series = |fields: Samples| {
Series::try_new(
&schema,
metric_of(
&[
("__name__", "m"),
("__type__", "gauge"),
("__unit__", "seconds"),
("host", "a"),
]
.map(|(n, v)| (n.into(), v.into()))
.into(),
),
TimeAxis::Grid(range.clone()),
SeriesFields::one(fields),
)
.unwrap()
};
let floats = |values: &[f64]| Samples::from_float(Float64Array::from(values.to_vec()));
let empty = BucketSideView {
span_offsets: &[],
span_lengths: &[],
buckets: &[],
};
let mut histograms = HistogramBatchBuilder::new();
histograms
.push(HistogramView {
schema: 0,
zero_threshold: 0.0,
sum: 1.0,
count: 1.0,
zero_count: 1.0,
reset_hint: CounterResetHint::Unknown,
start_timestamp: None,
custom_values: &[],
positive: empty,
negative: empty,
})
.unwrap();
let histogram =
Samples::try_from_histogram(UInt32Array::from(vec![0]), histograms.finish().unwrap())
.unwrap();
let mixed = Samples::try_from_chunks(vec![histogram, floats(&[3600.0, 7200.0])]).unwrap();
let hours = map_date(DateFunction::Hour, &schema, &series(mixed))
.unwrap()
.unwrap();
assert_eq!(
points_of(&hours),
BTreeMap::from([(2, 1f64.to_bits()), (3, 2f64.to_bits())])
);
assert_eq!(
labels_of(&hours.metric),
Labels::from([("host".into(), "a".into())])
);
let chunked = Samples::try_from_chunks(vec![floats(&[60.0]), floats(&[120.0, 180.0])]).unwrap();
let minutes = map_date(DateFunction::Minute, &schema, &series(chunked))
.unwrap()
.unwrap();
assert_eq!(
points_of(&minutes),
BTreeMap::from([
(1, 1f64.to_bits()),
(2, 2f64.to_bits()),
(3, 3f64.to_bits())
])
);
}