nightlight: align metadata matcher semantics

This commit is contained in:
centra 2026-04-01 15:58:13 +09:00
parent 9b26deee9b
commit b07bcb3772
Signed by: centra
GPG key ID: 0C09689D20B25ACA

View file

@ -19,6 +19,7 @@ use promql_parser::{
NumberLiteral, UnaryExpr, VectorMatchCardinality, VectorSelector, NumberLiteral, UnaryExpr, VectorMatchCardinality, VectorSelector,
}, },
}; };
use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap, VecDeque}; use std::collections::{BTreeMap, HashMap, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
@ -72,6 +73,13 @@ enum EvalValue {
Scalar(f64), Scalar(f64),
} }
#[derive(Debug, Clone)]
struct MetadataMatcher {
name: String,
op: MatchOp,
value: String,
}
impl QueryService { impl QueryService {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@ -751,20 +759,7 @@ impl QueryService {
} }
fn matcher_matches(&self, ts: &TimeSeries, matcher: &promql_parser::label::Matcher) -> bool { fn matcher_matches(&self, ts: &TimeSeries, matcher: &promql_parser::label::Matcher) -> bool {
let label_value = ts.get_label(&matcher.name); label_matcher_matches(ts.get_label(&matcher.name), &matcher.op, &matcher.value)
match &matcher.op {
MatchOp::Equal => label_value == Some(matcher.value.as_str()),
MatchOp::NotEqual => match label_value {
Some(value) => value != matcher.value,
None => true,
},
MatchOp::Re(regex) => label_value.is_some_and(|value| regex.is_match(value)),
MatchOp::NotRe(regex) => match label_value {
Some(value) => !regex.is_match(value),
None => true,
},
}
} }
pub async fn series_metadata( pub async fn series_metadata(
@ -776,16 +771,19 @@ impl QueryService {
let started = self.metrics.begin_query(); let started = self.metrics.begin_query();
let storage = self.storage.read().await; let storage = self.storage.read().await;
let series = self.matching_series(&storage, matchers, start, end); let series = self.matching_series(&storage, matchers, start, end);
let result = Ok(series let success = series.is_ok();
.into_iter() let result = series.map(|series| {
.map(|ts| { series
ts.labels .into_iter()
.iter() .map(|ts| {
.map(|label| (label.name.clone(), label.value.clone())) ts.labels
.collect() .iter()
}) .map(|label| (label.name.clone(), label.value.clone()))
.collect()); .collect()
self.metrics.finish_query(started, true); })
.collect()
});
self.metrics.finish_query(started, success);
result result
} }
@ -798,15 +796,19 @@ impl QueryService {
) -> Result<Vec<String>> { ) -> Result<Vec<String>> {
let started = self.metrics.begin_query(); let started = self.metrics.begin_query();
let storage = self.storage.read().await; let storage = self.storage.read().await;
let mut values: Vec<String> = self let values = self
.matching_series(&storage, matchers, start, end) .matching_series(&storage, matchers, start, end)
.into_iter() .map(|series| {
.filter_map(|series| series.get_label(label_name).map(str::to_string)) let mut values: Vec<String> = series
.collect(); .into_iter()
values.sort(); .filter_map(|series| series.get_label(label_name).map(str::to_string))
values.dedup(); .collect();
self.metrics.finish_query(started, true); values.sort();
Ok(values) values.dedup();
values
});
self.metrics.finish_query(started, values.is_ok());
values
} }
fn matching_series( fn matching_series(
@ -815,15 +817,15 @@ impl QueryService {
matchers: &[String], matchers: &[String],
start: Option<i64>, start: Option<i64>,
end: Option<i64>, end: Option<i64>,
) -> Vec<TimeSeries> { ) -> Result<Vec<TimeSeries>> {
let parsed_matchers = parse_label_matchers(matchers); let parsed_matchers = parse_metadata_matchers(matchers)?;
storage Ok(storage
.series .series
.values() .values()
.filter(|series| series_matches(series, &parsed_matchers)) .filter(|series| series_matches(series, &parsed_matchers))
.filter(|series| series_in_time_range(series, start, end)) .filter(|series| series_in_time_range(series, start, end))
.cloned() .cloned()
.collect() .collect())
} }
} }
@ -1330,28 +1332,65 @@ fn percentile(values: &[u64], quantile: f64) -> f64 {
values[index.min(values.len() - 1)] as f64 values[index.min(values.len() - 1)] as f64
} }
fn parse_label_matchers(matchers: &[String]) -> Vec<(String, String)> { fn parse_metadata_matchers(matchers: &[String]) -> Result<Vec<MetadataMatcher>> {
matchers matchers
.iter() .iter()
.filter_map(|matcher| matcher.split_once('=')) .map(|matcher| parse_metadata_matcher(matcher))
.map(|(key, value)| {
(
key.trim().to_string(),
value.trim().trim_matches('"').to_string(),
)
})
.collect() .collect()
} }
fn series_matches(series: &TimeSeries, matchers: &[(String, String)]) -> bool { fn parse_metadata_matcher(input: &str) -> Result<MetadataMatcher> {
matchers.iter().all(|(key, value)| { for operator in ["!~", "=~", "!=", "="] {
series if let Some((name, value)) = input.split_once(operator) {
.labels let name = name.trim();
.iter() let raw_value = value.trim().trim_matches('"');
.any(|label| &label.name == key && &label.value == value) if name.is_empty() {
return Err(Error::Query(format!(
"invalid metadata matcher {input:?}: empty label name"
)));
}
let op = match operator {
"=" => MatchOp::Equal,
"!=" => MatchOp::NotEqual,
"=~" => MatchOp::Re(Regex::new(raw_value).map_err(|error| {
Error::Query(format!("invalid regex in matcher {input:?}: {error}"))
})?),
"!~" => MatchOp::NotRe(Regex::new(raw_value).map_err(|error| {
Error::Query(format!("invalid regex in matcher {input:?}: {error}"))
})?),
_ => unreachable!(),
};
return Ok(MetadataMatcher {
name: name.to_string(),
op,
value: raw_value.to_string(),
});
}
}
Err(Error::Query(format!(
"invalid metadata matcher {input:?}: expected one of =, !=, =~, !~"
)))
}
fn series_matches(series: &TimeSeries, matchers: &[MetadataMatcher]) -> bool {
matchers.iter().all(|matcher| {
label_matcher_matches(series.get_label(&matcher.name), &matcher.op, &matcher.value)
}) })
} }
fn label_matcher_matches(label_value: Option<&str>, op: &MatchOp, expected: &str) -> bool {
let value = label_value.unwrap_or("");
match op {
MatchOp::Equal => value == expected,
MatchOp::NotEqual => value != expected,
MatchOp::Re(regex) => regex.is_match(value),
MatchOp::NotRe(regex) => !regex.is_match(value),
}
}
fn series_in_time_range(series: &TimeSeries, start: Option<i64>, end: Option<i64>) -> bool { fn series_in_time_range(series: &TimeSeries, start: Option<i64>, end: Option<i64>) -> bool {
let Some((series_start, series_end)) = series.time_range() else { let Some((series_start, series_end)) = series.time_range() else {
return true; return true;
@ -2033,6 +2072,86 @@ mod tests {
.any(|value| value == "test_job")); .any(|value| value == "test_job"));
} }
#[tokio::test]
async fn test_metadata_queries_support_regex_and_negative_matchers() {
let service = QueryService::new();
seed_series(
&service,
vec![
test_series(
1,
&[
("__name__", "http_requests_total"),
("job", "api"),
("instance", "a"),
],
&[(1000, 1.0)],
),
test_series(
2,
&[
("__name__", "http_requests_total"),
("job", "worker"),
("instance", "b"),
],
&[(1000, 1.0)],
),
],
)
.await;
let series = service
.series_metadata(&["job=~\"a.*|api\"".to_string()], None, None)
.await
.unwrap();
assert_eq!(series.len(), 1);
assert_eq!(series[0].get("job"), Some(&"api".to_string()));
let values = service
.label_values_for_matchers("instance", &["job!=\"worker\"".to_string()], None, None)
.await
.unwrap();
assert_eq!(values, vec!["a".to_string()]);
}
#[tokio::test]
async fn test_empty_label_matchers_treat_missing_labels_as_empty() {
let service = QueryService::new();
seed_series(
&service,
vec![test_series(
1,
&[("__name__", "up"), ("job", "api")],
&[(1000, 1.0)],
)],
)
.await;
let equal_empty = service
.execute_instant_query("up{zone=\"\"}", 1000)
.await
.unwrap();
assert_eq!(equal_empty.result.len(), 1);
let regex_empty = service
.execute_instant_query("up{zone=~\".*\"}", 1000)
.await
.unwrap();
assert_eq!(regex_empty.result.len(), 1);
let not_equal_empty = service
.execute_instant_query("up{zone!=\"\"}", 1000)
.await
.unwrap();
assert!(not_equal_empty.result.is_empty());
let metadata = service
.series_metadata(&["zone=\"\"".to_string()], None, None)
.await
.unwrap();
assert_eq!(metadata.len(), 1);
}
#[test] #[test]
fn test_persistence_save_load_empty() { fn test_persistence_save_load_empty() {
use tempfile::tempdir; use tempfile::tempdir;