From b07bcb37723cb2e35bb93e137a7643021877a2fb Mon Sep 17 00:00:00 2001 From: centra Date: Wed, 1 Apr 2026 15:58:13 +0900 Subject: [PATCH] nightlight: align metadata matcher semantics --- .../crates/nightlight-server/src/query.rs | 219 ++++++++++++++---- 1 file changed, 169 insertions(+), 50 deletions(-) diff --git a/nightlight/crates/nightlight-server/src/query.rs b/nightlight/crates/nightlight-server/src/query.rs index a7a3b52..17d8940 100644 --- a/nightlight/crates/nightlight-server/src/query.rs +++ b/nightlight/crates/nightlight-server/src/query.rs @@ -19,6 +19,7 @@ use promql_parser::{ NumberLiteral, UnaryExpr, VectorMatchCardinality, VectorSelector, }, }; +use regex::Regex; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap, VecDeque}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -72,6 +73,13 @@ enum EvalValue { Scalar(f64), } +#[derive(Debug, Clone)] +struct MetadataMatcher { + name: String, + op: MatchOp, + value: String, +} + impl QueryService { pub fn new() -> Self { Self { @@ -751,20 +759,7 @@ impl QueryService { } fn matcher_matches(&self, ts: &TimeSeries, matcher: &promql_parser::label::Matcher) -> bool { - let label_value = ts.get_label(&matcher.name); - - 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, - }, - } + label_matcher_matches(ts.get_label(&matcher.name), &matcher.op, &matcher.value) } pub async fn series_metadata( @@ -776,16 +771,19 @@ impl QueryService { let started = self.metrics.begin_query(); let storage = self.storage.read().await; let series = self.matching_series(&storage, matchers, start, end); - let result = Ok(series - .into_iter() - .map(|ts| { - ts.labels - .iter() - .map(|label| (label.name.clone(), label.value.clone())) - .collect() - }) - .collect()); - self.metrics.finish_query(started, true); + let success = series.is_ok(); + let result = series.map(|series| { + series + .into_iter() + .map(|ts| { + ts.labels + .iter() + .map(|label| (label.name.clone(), label.value.clone())) + .collect() + }) + .collect() + }); + self.metrics.finish_query(started, success); result } @@ -798,15 +796,19 @@ impl QueryService { ) -> Result> { let started = self.metrics.begin_query(); let storage = self.storage.read().await; - let mut values: Vec = self + let values = self .matching_series(&storage, matchers, start, end) - .into_iter() - .filter_map(|series| series.get_label(label_name).map(str::to_string)) - .collect(); - values.sort(); - values.dedup(); - self.metrics.finish_query(started, true); - Ok(values) + .map(|series| { + let mut values: Vec = series + .into_iter() + .filter_map(|series| series.get_label(label_name).map(str::to_string)) + .collect(); + values.sort(); + values.dedup(); + values + }); + self.metrics.finish_query(started, values.is_ok()); + values } fn matching_series( @@ -815,15 +817,15 @@ impl QueryService { matchers: &[String], start: Option, end: Option, - ) -> Vec { - let parsed_matchers = parse_label_matchers(matchers); - storage + ) -> Result> { + let parsed_matchers = parse_metadata_matchers(matchers)?; + Ok(storage .series .values() .filter(|series| series_matches(series, &parsed_matchers)) .filter(|series| series_in_time_range(series, start, end)) .cloned() - .collect() + .collect()) } } @@ -1330,28 +1332,65 @@ fn percentile(values: &[u64], quantile: f64) -> 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> { matchers .iter() - .filter_map(|matcher| matcher.split_once('=')) - .map(|(key, value)| { - ( - key.trim().to_string(), - value.trim().trim_matches('"').to_string(), - ) - }) + .map(|matcher| parse_metadata_matcher(matcher)) .collect() } -fn series_matches(series: &TimeSeries, matchers: &[(String, String)]) -> bool { - matchers.iter().all(|(key, value)| { - series - .labels - .iter() - .any(|label| &label.name == key && &label.value == value) +fn parse_metadata_matcher(input: &str) -> Result { + for operator in ["!~", "=~", "!=", "="] { + if let Some((name, value)) = input.split_once(operator) { + let name = name.trim(); + let raw_value = value.trim().trim_matches('"'); + 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, end: Option) -> bool { let Some((series_start, series_end)) = series.time_range() else { return true; @@ -2033,6 +2072,86 @@ mod tests { .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] fn test_persistence_save_load_empty() { use tempfile::tempdir;