test: compare complete OTLP query schemas and row values

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
This commit is contained in:
discord9
2026-09-10 17:45:32 +08:00
parent 0834e7c36a
commit 4b020fe5b3
4 changed files with 390 additions and 60 deletions
@@ -21,7 +21,9 @@ use serde_json::{Map, Value, json};
use crate::query_regression_runner::model::{Measurement, Query, QueryResult, Scenario, Table};
use crate::query_regression_runner::plan::{load_plan, normalize_scenario};
use crate::query_regression_runner::sql::{http_post_prom_range_query, http_post_sql, sql_ident};
use crate::query_regression_runner::sql::{
http_post_prom_range_query, http_post_sql, http_post_sql_with_schema, sql_ident,
};
use crate::query_regression_runner::{MeasureArgs, Result};
pub(super) async fn run_measure(args: MeasureArgs) -> Result<()> {
@@ -131,6 +133,8 @@ async fn post_query(client: &Client, port: u16, query: &Query, db: &str) -> Valu
db,
)
.await
} else if query.compare_results || query.expected_cardinality.is_some() {
http_post_sql_with_schema(client, port, &query.query, db).await
} else {
http_post_sql(client, port, &query.query, db).await
}
@@ -272,15 +276,28 @@ fn compare_measured_results(
base: &mut QueryResult,
candidate: &mut QueryResult,
) {
let mut errors = Vec::new();
for query in queries {
if !query.measure || (!query.compare_results && query.expected_cardinality.is_none()) {
continue;
}
if query.compare_results && !matches!(query.kind.as_deref(), Some("sql" | "tql")) {
errors.push(json!({
"query": query.name,
"error": "result comparison only supports sql and tql queries",
}));
continue;
}
let Some(base_measurement) = base
.measurements
.iter()
.find(|measurement| measurement.name == query.name)
else {
errors.push(json!({
"query": query.name,
"error": "missing base measurement",
}));
continue;
};
let Some(candidate_measurement) = candidate
@@ -288,71 +305,162 @@ fn compare_measured_results(
.iter()
.find(|measurement| measurement.name == query.name)
else {
errors.push(json!({
"query": query.name,
"error": "missing candidate measurement",
}));
continue;
};
for (iteration, (base_sample, candidate_sample)) in base_measurement
.samples
.iter()
.zip(&candidate_measurement.samples)
.enumerate()
{
let base_rows = crate::query_regression_runner::sql::extract_rows(
base_sample.get("response").unwrap_or(&Value::Null),
if let Some(expected) = query.expected_cardinality {
validate_cardinality(
query,
"base",
&base_measurement.samples,
expected,
&mut errors,
);
let candidate_rows = crate::query_regression_runner::sql::extract_rows(
candidate_sample.get("response").unwrap_or(&Value::Null),
validate_cardinality(
query,
"candidate",
&candidate_measurement.samples,
expected,
&mut errors,
);
if let Some(expected) = query.expected_cardinality
&& (base_rows.len() as u64 != expected || candidate_rows.len() as u64 != expected)
{
candidate.validation_errors.push(json!({"query": query.name, "iteration": iteration, "error": format!("expected cardinality {expected}, got base={} candidate={}", base_rows.len(), candidate_rows.len())}));
}
if query.compare_results
&& normalized_rows(base_rows) != normalized_rows(candidate_rows)
{
candidate.validation_errors.push(json!({"query": query.name, "iteration": iteration, "error": "base/candidate response rows differ"}));
}
if !query.compare_results {
continue;
}
if base_measurement.samples.len() != candidate_measurement.samples.len() {
errors.push(json!({
"query": query.name,
"error": format!(
"sample count differs: base={} candidate={}",
base_measurement.samples.len(),
candidate_measurement.samples.len(),
),
}));
continue;
}
for iteration in 0..base_measurement.samples.len() {
let base_records = extract_records(
base_measurement.samples[iteration]
.get("response")
.unwrap_or(&Value::Null),
);
let candidate_records = extract_records(
candidate_measurement.samples[iteration]
.get("response")
.unwrap_or(&Value::Null),
);
match (base_records, candidate_records) {
(Ok(base_records), Ok(candidate_records)) => {
if !records_match(base_records, candidate_records) {
errors.push(json!({
"query": query.name,
"iteration": iteration,
"error": "base/candidate response records differ",
}));
}
}
(Err(error), _) => errors.push(json!({
"query": query.name,
"iteration": iteration,
"error": format!("malformed base records response: {error}"),
})),
(_, Err(error)) => errors.push(json!({
"query": query.name,
"iteration": iteration,
"error": format!("malformed candidate records response: {error}"),
})),
}
}
}
candidate.validation_errors.extend(errors);
if !candidate.validation_errors.is_empty() {
candidate.status = "failed".to_string();
}
}
fn normalized_rows(rows: Vec<Value>) -> Vec<String> {
struct Records<'a> {
schema: &'a Value,
rows: &'a [Value],
}
fn extract_records(response: &Value) -> std::result::Result<Records<'_>, &'static str> {
let output = response
.as_object()
.and_then(|response| response.get("output"))
.and_then(Value::as_array)
.ok_or("response has no output array")?;
let [output] = output.as_slice() else {
return Err("response output must contain exactly one records result");
};
let records = output
.as_object()
.and_then(|output| output.get("records"))
.and_then(Value::as_object)
.ok_or("output has no records object")?;
let schema = records
.get("schema")
.filter(|schema| schema.is_object())
.ok_or("records has no schema object")?;
if schema
.get("column_schemas")
.and_then(Value::as_array)
.is_none()
{
return Err("schema has no column_schemas array");
}
let rows = records
.get("rows")
.and_then(Value::as_array)
.ok_or("records has no rows array")?;
Ok(Records { schema, rows })
}
fn validate_cardinality(
query: &Query,
target: &str,
samples: &[Value],
expected: u64,
errors: &mut Vec<Value>,
) {
for (iteration, sample) in samples.iter().enumerate() {
match extract_records(sample.get("response").unwrap_or(&Value::Null)) {
Ok(records) if records.rows.len() as u64 == expected => {}
Ok(records) => errors.push(json!({
"query": query.name,
"iteration": iteration,
"error": format!(
"expected cardinality {expected}, got {target}={}",
records.rows.len(),
),
})),
Err(error) => errors.push(json!({
"query": query.name,
"iteration": iteration,
"error": format!("malformed {target} records response: {error}"),
})),
}
}
}
fn records_match(base: Records<'_>, candidate: Records<'_>) -> bool {
base.schema == candidate.schema && row_multiset(base.rows) == row_multiset(candidate.rows)
}
fn row_multiset(rows: &[Value]) -> Vec<String> {
let mut rows = rows
.into_iter()
.map(|mut row| {
strip_timing_metadata(&mut row);
serde_json::to_string(&row).unwrap_or_default()
})
.iter()
.map(|row| serde_json::to_string(row).expect("JSON values are serializable"))
.collect::<Vec<_>>();
rows.sort_unstable();
rows
}
fn strip_timing_metadata(value: &mut Value) {
match value {
Value::Object(values) => {
values.retain(|key, _| {
!matches!(
key.as_str(),
"execution_time" | "execution_time_ms" | "elapsed" | "elapsed_ms" | "cost"
)
});
for value in values.values_mut() {
strip_timing_metadata(value);
}
}
Value::Array(values) => {
for value in values {
strip_timing_metadata(value);
}
}
_ => {}
}
}
fn validate_show_create(result: &Value, table: &Table) -> Vec<&'static str> {
let text = result
.get("response")
@@ -553,6 +661,195 @@ mod tests {
);
}
fn compared_query(iterations: usize, expected_cardinality: Option<u64>) -> Query {
Query {
name: Some("q".to_string()),
kind: Some("tql".to_string()),
query: "TQL EVAL (0, 1, '1s') 1".to_string(),
start: None,
end: None,
step: None,
warmup: 0,
iterations,
expected_cardinality,
compare_results: true,
measure: true,
plan_contains: None,
plan_absent: None,
thresholds: Map::new(),
}
}
fn records_sample(column: &str, data_type: &str, rows: Value) -> Value {
json!({
"response": {
"output": [{
"records": {
"schema": {"column_schemas": [{"name": column, "data_type": data_type}]},
"rows": rows,
}
}],
"execution_time_ms": 10,
"elapsed": 20,
"cost": 30,
}
})
}
fn compared_results(samples: Vec<Value>) -> QueryResult {
QueryResult {
validation: vec![],
validation_errors: vec![],
measurements: vec![Measurement {
name: Some("q".to_string()),
kind: Some("tql".to_string()),
iterations: samples.len(),
samples,
latency_ms_median: None,
latency_ms_p95: None,
status: "ok".to_string(),
}],
status: "ok".to_string(),
}
}
fn compare_samples(
expected_cardinality: Option<u64>,
base_samples: Vec<Value>,
candidate_samples: Vec<Value>,
) -> QueryResult {
let query = compared_query(base_samples.len(), expected_cardinality);
let mut base = compared_results(base_samples);
let mut candidate = compared_results(candidate_samples);
compare_measured_results(&[query], &mut base, &mut candidate);
candidate
}
#[test]
fn comparison_rejects_changed_column_name() {
let candidate = compare_samples(
None,
vec![records_sample("value", "Int64", json!([[1]]))],
vec![records_sample("other", "Int64", json!([[1]]))],
);
assert_eq!(candidate.validation_errors.len(), 1);
assert_eq!(candidate.validation_errors[0]["iteration"], 0);
}
#[test]
fn comparison_rejects_changed_column_type() {
let candidate = compare_samples(
None,
vec![records_sample("value", "Int64", json!([[1]]))],
vec![records_sample("value", "String", json!([[1]]))],
);
assert_eq!(candidate.validation_errors.len(), 1);
}
#[test]
fn comparison_preserves_cost_and_elapsed_in_row_json() {
let base = records_sample("value", "Json", json!([{"cost": 1, "elapsed": 2}]));
let candidate = records_sample("value", "Json", json!([{"cost": 3, "elapsed": 4}]));
let candidate = compare_samples(None, vec![base], vec![candidate]);
assert_eq!(candidate.validation_errors.len(), 1);
}
#[test]
fn comparison_ignores_response_execution_timing() {
let base = records_sample("value", "Int64", json!([[1]]));
let mut candidate = records_sample("value", "Int64", json!([[1]]));
candidate["response"]["execution_time_ms"] = json!(99);
candidate["response"]["elapsed"] = json!(88);
candidate["response"]["cost"] = json!(77);
let candidate = compare_samples(None, vec![base], vec![candidate]);
assert!(candidate.validation_errors.is_empty());
}
#[test]
fn comparison_accepts_unordered_equal_rows() {
let candidate = compare_samples(
None,
vec![records_sample("value", "Int64", json!([[1], [2]]))],
vec![records_sample("value", "Int64", json!([[2], [1]]))],
);
assert!(candidate.validation_errors.is_empty());
}
#[test]
fn comparison_rejects_duplicate_row_mismatch() {
let candidate = compare_samples(
None,
vec![records_sample("value", "Int64", json!([[1], [1]]))],
vec![records_sample("value", "Int64", json!([[1], [2]]))],
);
assert_eq!(candidate.validation_errors.len(), 1);
}
#[test]
fn comparison_rejects_cardinality_mismatch() {
let sample = records_sample("value", "Int64", json!([[1]]));
let candidate = compare_samples(Some(2), vec![sample.clone()], vec![sample]);
assert_eq!(candidate.validation_errors.len(), 2);
assert!(candidate.validation_errors.iter().all(|error| {
error["error"]
.as_str()
.unwrap()
.contains("expected cardinality 2")
}));
}
#[test]
fn comparison_rejects_later_iteration_mismatch() {
let candidate = compare_samples(
None,
vec![
records_sample("value", "Int64", json!([[1]])),
records_sample("value", "Int64", json!([[2]])),
],
vec![
records_sample("value", "Int64", json!([[1]])),
records_sample("value", "Int64", json!([[3]])),
],
);
assert_eq!(candidate.validation_errors.len(), 1);
assert_eq!(candidate.validation_errors[0]["iteration"], 1);
}
#[test]
fn comparison_rejects_missing_iteration() {
let candidate = compare_samples(
None,
vec![
records_sample("value", "Int64", json!([[1]])),
records_sample("value", "Int64", json!([[2]])),
],
vec![records_sample("value", "Int64", json!([[1]]))],
);
assert_eq!(candidate.validation_errors.len(), 1);
assert!(
candidate.validation_errors[0]["error"]
.as_str()
.unwrap()
.contains("sample count differs: base=2 candidate=1")
);
}
#[test]
fn comparison_rejects_malformed_records_response() {
let candidate = compare_samples(
None,
vec![json!({"response": {"output": []}})],
vec![json!({"response": {"output": []}})],
);
assert_eq!(candidate.validation_errors.len(), 1);
assert!(
candidate.validation_errors[0]["error"]
.as_str()
.unwrap()
.contains("malformed base records response")
);
}
#[test]
fn threshold_rejects_unknown_keys_and_zero_base() {
let query = Query {
+37 -6
View File
@@ -117,12 +117,27 @@ pub(super) fn value_f64(value: Option<&Value>) -> Option<f64> {
}
pub(super) async fn http_post_sql(client: &Client, port: u16, sql: &str, db: &str) -> Value {
let mut sample = post_form(
client,
format!("http://127.0.0.1:{port}/v1/sql"),
&[("sql", sql), ("db", db), ("format", "json")],
)
.await;
http_post_sql_with_format(client, port, sql, db, "json").await
}
pub(super) async fn http_post_sql_with_schema(
client: &Client,
port: u16,
sql: &str,
db: &str,
) -> Value {
http_post_sql_with_format(client, port, sql, db, "greptimedb_v1").await
}
async fn http_post_sql_with_format(
client: &Client,
port: u16,
sql: &str,
db: &str,
format: &str,
) -> Value {
let form = sql_form(sql, db, format);
let mut sample = post_form(client, format!("http://127.0.0.1:{port}/v1/sql"), &form).await;
sample
.as_object_mut()
.expect("HTTP samples are objects")
@@ -130,6 +145,10 @@ pub(super) async fn http_post_sql(client: &Client, port: u16, sql: &str, db: &st
sample
}
fn sql_form<'a>(sql: &'a str, db: &'a str, format: &'a str) -> [(&'static str, &'a str); 3] {
[("sql", sql), ("db", db), ("format", format)]
}
/// Posts a Prometheus HTTP API range query (`/v1/prometheus/api/v1/query_range`)
/// and measures the full request-to-body latency. This exercises the Prometheus
/// JSON response building path (`PrometheusJsonResponse::record_batches_to_data`)
@@ -268,6 +287,18 @@ mod tests {
);
}
#[test]
fn sql_form_uses_requested_response_format() {
assert_eq!(
sql_form("SELECT 1", "public", "greptimedb_v1"),
[
("sql", "SELECT 1"),
("db", "public"),
("format", "greptimedb_v1"),
]
);
}
#[test]
fn top_level_errors_do_not_inspect_rows() {
assert!(response_has_error(&json!({"error_code": 7})));
+5 -3
View File
@@ -461,9 +461,11 @@ fails preparation. It flushes the logical metric table, checks all 262144 rows,
and verifies the stored timestamp retains the `+123ns` remainder.
The measured requests are ordinary `TQL EVAL` instant and range selectors (3
warmups, 9 iterations). Untimed `TQL EXPLAIN VERBOSE` evidence is retained in
the report, and every measured response is row-normalized and compared with
the base target, ignoring only timing metadata. This is explicitly
warmups, 9 iterations). Untimed `TQL ANALYZE VERBOSE` evidence is retained in
the report. Measured queries request `greptimedb_v1` responses and compare the
complete ordered column schema and unordered row multiset with the base target
on every iteration. Row values are preserved verbatim; response-envelope timing
metadata is outside the comparison. This is explicitly
legacy-data query coverage; it does not assert the new default OTLP
millisecond table behavior. Use the usual remote-case dispatch:
@@ -55,13 +55,13 @@ max_candidate_latency_regression_pct = 25
[[scenario.queries]]
name = "instant_last_row_plan"
kind = "tql"
query = "TQL EXPLAIN VERBOSE (1704067303, 1704067303, '1s') otlp_ns_last_row_9070{host=~'host.*'}"
query = "TQL ANALYZE VERBOSE (1704067303, 1704067303, '1s') otlp_ns_last_row_9070{host=~'host.*'}"
measure = false
plan_contains = "LastRow"
plan_contains = "LastRow { after_merge: true }"
[[scenario.queries]]
name = "range_no_last_row_plan"
kind = "tql"
query = "TQL EXPLAIN VERBOSE (1704067293, 1704067303, '1s') otlp_ns_last_row_9070{host=~'host.*'}"
query = "TQL ANALYZE VERBOSE (1704067293, 1704067303, '1s') otlp_ns_last_row_9070{host=~'host.*'}"
measure = false
plan_absent = "LastRow"