use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use sz_orm_flamegraph::QueryPhaseTiming;
use crate::regression::{compare, PlanRegression};
use crate::ExplainPlan;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PerfBaseline {
pub query_key: String,
pub phase_baselines: HashMap<String, u64>,
pub total_elapsed_ms: u64,
pub plan: ExplainPlan,
pub captured_at: String,
}
impl PerfBaseline {
pub fn new(
query_key: impl Into<String>,
plan: ExplainPlan,
timings: &[QueryPhaseTiming],
) -> Self {
let mut phase_baselines = HashMap::new();
let mut total = 0u64;
for t in timings {
phase_baselines.insert(t.phase.as_str().to_string(), t.duration_ms);
total += t.duration_ms;
}
Self {
query_key: query_key.into(),
phase_baselines,
total_elapsed_ms: total,
plan,
captured_at: now_iso8601(),
}
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct PerfBaselineSet {
pub baselines: HashMap<String, PerfBaseline>,
}
impl PerfBaselineSet {
pub fn upsert(&mut self, baseline: PerfBaseline) {
self.baselines.insert(baseline.query_key.clone(), baseline);
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum PerfRegression {
PhaseSlowdown {
query_key: String,
phase: String,
before: u64,
after: u64,
},
TotalSlowdown {
query_key: String,
before: u64,
after: u64,
},
PlanRegression(PlanRegression),
}
impl PerfRegression {
pub fn describe(&self) -> String {
match self {
PerfRegression::PhaseSlowdown {
query_key,
phase,
before,
after,
} => {
format!("query '{query_key}': phase '{phase}' slowed from {before}ms to {after}ms")
}
PerfRegression::TotalSlowdown {
query_key,
before,
after,
} => {
format!("query '{query_key}': total elapsed slowed from {before}ms to {after}ms")
}
PerfRegression::PlanRegression(r) => r.describe(),
}
}
}
pub fn check_perf_regressions(
baseline_json: &str,
current_json: &str,
threshold_factor: u64,
) -> Result<Vec<PerfRegression>, serde_json::Error> {
let baseline: PerfBaselineSet = serde_json::from_str(baseline_json)?;
let current: PerfBaselineSet = serde_json::from_str(current_json)?;
let mut regressions = Vec::new();
for (key, base) in &baseline.baselines {
if let Some(cur) = current.baselines.get(key) {
for (phase, &base_ms) in &base.phase_baselines {
if let Some(&cur_ms) = cur.phase_baselines.get(phase) {
if base_ms > 0 && cur_ms > base_ms.saturating_mul(threshold_factor) {
regressions.push(PerfRegression::PhaseSlowdown {
query_key: key.clone(),
phase: phase.clone(),
before: base_ms,
after: cur_ms,
});
}
}
}
if base.total_elapsed_ms > 0
&& cur.total_elapsed_ms > base.total_elapsed_ms.saturating_mul(threshold_factor)
{
regressions.push(PerfRegression::TotalSlowdown {
query_key: key.clone(),
before: base.total_elapsed_ms,
after: cur.total_elapsed_ms,
});
}
let plan_regressions = compare(&base.plan, &cur.plan, key, threshold_factor);
for r in plan_regressions {
regressions.push(PerfRegression::PlanRegression(r));
}
}
}
Ok(regressions)
}
fn now_iso8601() -> String {
let Ok(d) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) else {
return "1970-01-01T00:00:00Z".into();
};
let secs = d.as_secs();
let days = secs / 86400;
let time = secs % 86400;
let hours = time / 3600;
let minutes = (time % 3600) / 60;
let seconds = time % 60;
let (y, m, dd) = civil_from_days(days as i64);
format!("{y:04}-{m:02}-{dd:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
}
fn civil_from_days(z: i64) -> (i64, i64, i64) {
let z = z + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
(if m <= 2 { y + 1 } else { y }, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ExplainPlan, ScanType};
use sz_orm_flamegraph::Phase;
fn timing(phase: Phase, ms: u64) -> QueryPhaseTiming {
QueryPhaseTiming {
phase,
start_ms: 0,
duration_ms: ms,
}
}
fn plan(scan: ScanType, rows: u64) -> ExplainPlan {
ExplainPlan {
scan_type: scan,
table: "users".into(),
index: None,
rows,
extra: vec![],
}
}
#[test]
fn perf_baseline_collects_phase_timings() {
let timings = vec![
timing(Phase::Build, 5),
timing(Phase::SqlExecute, 50),
timing(Phase::ResultMap, 10),
];
let bl = PerfBaseline::new("q1", plan(ScanType::IndexRange, 100), &timings);
assert_eq!(bl.phase_baselines.get("query.build"), Some(&5));
assert_eq!(bl.phase_baselines.get("db.execute"), Some(&50));
assert_eq!(bl.phase_baselines.get("result.map"), Some(&10));
assert_eq!(bl.total_elapsed_ms, 65);
}
#[test]
fn perf_baseline_json_roundtrip() {
let timings = vec![timing(Phase::SqlExecute, 50)];
let bl = PerfBaseline::new("q1", plan(ScanType::IndexRange, 100), &timings);
let json = bl.to_json().unwrap();
let back = PerfBaseline::from_json(&json).unwrap();
assert_eq!(bl, back);
}
#[test]
fn empty_timings_still_creates_baseline() {
let bl = PerfBaseline::new("q1", plan(ScanType::IndexRange, 100), &[]);
assert!(bl.phase_baselines.is_empty());
assert_eq!(bl.total_elapsed_ms, 0);
}
#[test]
fn phase_slowdown_detected() {
let base_timings = vec![timing(Phase::Build, 5)];
let cur_timings = vec![timing(Phase::Build, 50)];
let base = PerfBaseline::new("q1", plan(ScanType::IndexRange, 100), &base_timings);
let cur = PerfBaseline::new("q1", plan(ScanType::IndexRange, 100), &cur_timings);
let mut base_set = PerfBaselineSet::default();
base_set.upsert(base);
let mut cur_set = PerfBaselineSet::default();
cur_set.upsert(cur);
let regressions =
check_perf_regressions(&base_set.to_json().unwrap(), &cur_set.to_json().unwrap(), 5)
.unwrap();
assert!(regressions.iter().any(
|r| matches!(r, PerfRegression::PhaseSlowdown { phase, .. } if phase == "query.build")
));
}
#[test]
fn total_slowdown_detected() {
let base_timings = vec![timing(Phase::SqlExecute, 10)];
let cur_timings = vec![timing(Phase::SqlExecute, 100)];
let base = PerfBaseline::new("q1", plan(ScanType::IndexRange, 100), &base_timings);
let cur = PerfBaseline::new("q1", plan(ScanType::IndexRange, 100), &cur_timings);
let mut base_set = PerfBaselineSet::default();
base_set.upsert(base);
let mut cur_set = PerfBaselineSet::default();
cur_set.upsert(cur);
let regressions =
check_perf_regressions(&base_set.to_json().unwrap(), &cur_set.to_json().unwrap(), 5)
.unwrap();
assert!(regressions
.iter()
.any(|r| matches!(r, PerfRegression::TotalSlowdown { .. })));
}
#[test]
fn plan_regression_detected() {
let base = PerfBaseline::new("q1", plan(ScanType::IndexRange, 100), &[]);
let cur = PerfBaseline::new("q1", plan(ScanType::FullTable, 100), &[]);
let mut base_set = PerfBaselineSet::default();
base_set.upsert(base);
let mut cur_set = PerfBaselineSet::default();
cur_set.upsert(cur);
let regressions =
check_perf_regressions(&base_set.to_json().unwrap(), &cur_set.to_json().unwrap(), 5)
.unwrap();
assert!(regressions.iter().any(|r| matches!(
r,
PerfRegression::PlanRegression(PlanRegression::ScanTypeUpgrade { .. })
)));
}
#[test]
fn both_timing_and_plan_regression_detected() {
let base_timings = vec![timing(Phase::Build, 5)];
let cur_timings = vec![timing(Phase::Build, 50)];
let base = PerfBaseline::new("q1", plan(ScanType::IndexRange, 100), &base_timings);
let cur = PerfBaseline::new("q1", plan(ScanType::FullTable, 100), &cur_timings);
let mut base_set = PerfBaselineSet::default();
base_set.upsert(base);
let mut cur_set = PerfBaselineSet::default();
cur_set.upsert(cur);
let regressions =
check_perf_regressions(&base_set.to_json().unwrap(), &cur_set.to_json().unwrap(), 5)
.unwrap();
let has_phase = regressions
.iter()
.any(|r| matches!(r, PerfRegression::PhaseSlowdown { .. }));
let has_plan = regressions.iter().any(|r| {
matches!(
r,
PerfRegression::PlanRegression(PlanRegression::ScanTypeUpgrade { .. })
)
});
assert!(has_phase && has_plan);
}
#[test]
fn no_regression_when_within_threshold() {
let base_timings = vec![timing(Phase::Build, 10)];
let cur_timings = vec![timing(Phase::Build, 20)];
let base = PerfBaseline::new("q1", plan(ScanType::IndexRange, 100), &base_timings);
let cur = PerfBaseline::new("q1", plan(ScanType::IndexRange, 100), &cur_timings);
let mut base_set = PerfBaselineSet::default();
base_set.upsert(base);
let mut cur_set = PerfBaselineSet::default();
cur_set.upsert(cur);
let regressions =
check_perf_regressions(&base_set.to_json().unwrap(), &cur_set.to_json().unwrap(), 5)
.unwrap();
assert!(regressions.is_empty());
}
}