use crate::ExplainPlan;
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PlanSnapshot {
pub query_key: String,
pub plan: ExplainPlan,
pub captured_at: String,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct PlanBaseline {
pub snapshots: HashMap<String, PlanSnapshot>,
}
impl PlanSnapshot {
pub fn new(query_key: impl Into<String>, plan: ExplainPlan) -> Self {
Self {
query_key: query_key.into(),
plan,
captured_at: now_iso8601(),
}
}
}
impl PlanBaseline {
pub fn upsert(&mut self, snapshot: PlanSnapshot) {
self.snapshots.insert(snapshot.query_key.clone(), snapshot);
}
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, serde::Serialize, serde::Deserialize)]
pub enum PlanRegression {
ScanTypeUpgrade {
query_key: String,
from: String,
to: String,
},
IndexLost { query_key: String, index: String },
RowsGrowth {
query_key: String,
before: u64,
after: u64,
},
}
impl PlanRegression {
pub fn describe(&self) -> String {
match self {
PlanRegression::ScanTypeUpgrade {
query_key,
from,
to,
} => {
format!("query '{query_key}': scan type degraded from {from} to {to}")
}
PlanRegression::IndexLost { query_key, index } => {
format!("query '{query_key}': index '{index}' no longer used")
}
PlanRegression::RowsGrowth {
query_key,
before,
after,
} => {
format!("query '{query_key}': estimated rows grew from {before} to {after}")
}
}
}
}
pub fn compare(
baseline: &ExplainPlan,
current: &ExplainPlan,
query_key: &str,
rows_growth_factor: u64,
) -> Vec<PlanRegression> {
let mut regressions = Vec::new();
if severity(current.scan_type) > severity(baseline.scan_type) {
regressions.push(PlanRegression::ScanTypeUpgrade {
query_key: query_key.to_string(),
from: format!("{:?}", baseline.scan_type),
to: format!("{:?}", current.scan_type),
});
}
if baseline.index.is_some() && current.index.is_none() {
regressions.push(PlanRegression::IndexLost {
query_key: query_key.to_string(),
index: baseline.index.clone().unwrap_or_default(),
});
}
if baseline.rows > 0 && current.rows > baseline.rows.saturating_mul(rows_growth_factor) {
regressions.push(PlanRegression::RowsGrowth {
query_key: query_key.to_string(),
before: baseline.rows,
after: current.rows,
});
}
regressions
}
fn severity(scan: crate::ScanType) -> u8 {
match scan {
crate::ScanType::FullTable => 5,
crate::ScanType::IndexRange => 4,
crate::ScanType::IndexLookup => 3,
crate::ScanType::UniqueLookup => 2,
crate::ScanType::Other => 1,
}
}
pub fn check_regressions(
baseline_json: &str,
current_json: &str,
rows_growth_factor: u64,
) -> Result<Vec<PlanRegression>, serde_json::Error> {
let baseline: PlanBaseline = serde_json::from_str(baseline_json)?;
let current: PlanBaseline = serde_json::from_str(current_json)?;
let mut regressions = Vec::new();
for (key, base) in &baseline.snapshots {
if let Some(cur) = current.snapshots.get(key) {
regressions.extend(compare(&base.plan, &cur.plan, key, rows_growth_factor));
}
}
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".to_string();
};
let secs = d.as_secs();
let days = secs / 86_400;
let rem = secs % 86_400;
let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
let z = days as i64 + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let day = doy - (153 * mp + 2) / 5 + 1; let month = if mp < 10 { mp + 3 } else { mp - 9 }; let year = if month <= 2 { y + 1 } else { y };
format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}Z")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ExplainPlan, ScanType};
fn plan(scan: ScanType, index: Option<&str>, rows: u64) -> ExplainPlan {
ExplainPlan {
scan_type: scan,
table: "users".into(),
index: index.map(|s| s.to_string()),
rows,
extra: Vec::new(),
}
}
#[test]
fn detects_scan_type_upgrade() {
let base = plan(ScanType::IndexRange, Some("idx"), 10);
let cur = plan(ScanType::FullTable, None, 1150);
let regs = compare(&base, &cur, "q1", 2);
assert!(regs
.iter()
.any(|r| matches!(r, PlanRegression::ScanTypeUpgrade { .. })));
assert!(regs
.iter()
.any(|r| matches!(r, PlanRegression::IndexLost { .. })));
}
#[test]
fn detects_index_lost_only() {
let base = plan(ScanType::IndexRange, Some("idx"), 10);
let cur = plan(ScanType::IndexRange, None, 12);
let regs = compare(&base, &cur, "q2", 2);
assert_eq!(
regs,
vec![PlanRegression::IndexLost {
query_key: "q2".into(),
index: "idx".into()
}]
);
}
#[test]
fn detects_rows_growth() {
let base = plan(ScanType::IndexRange, Some("idx"), 100);
let cur = plan(ScanType::IndexRange, Some("idx"), 500);
let regs = compare(&base, &cur, "q3", 2);
assert!(regs.iter().any(|r| matches!(
r,
PlanRegression::RowsGrowth {
before: 100,
after: 500,
..
}
)));
}
#[test]
fn no_regression_when_improved() {
let base = plan(ScanType::FullTable, None, 1150);
let cur = plan(ScanType::UniqueLookup, Some("pk"), 1);
let regs = compare(&base, &cur, "q4", 2);
assert!(regs.is_empty());
}
#[test]
fn snapshot_roundtrip() {
let mut baseline = PlanBaseline::default();
baseline.upsert(PlanSnapshot::new(
"q1",
plan(ScanType::FullTable, None, 1150),
));
let json = baseline.to_json().expect("serialize");
let parsed = PlanBaseline::from_json(&json).expect("deserialize");
assert_eq!(parsed.snapshots.len(), 1);
assert_eq!(parsed.snapshots["q1"].plan.rows, 1150);
}
#[test]
fn check_regressions_integration() {
let mut base = PlanBaseline::default();
base.upsert(PlanSnapshot::new(
"q1",
plan(ScanType::IndexRange, Some("idx"), 10),
));
let mut cur = PlanBaseline::default();
cur.upsert(PlanSnapshot::new(
"q1",
plan(ScanType::FullTable, None, 1150),
));
let regs = check_regressions(&base.to_json().unwrap(), &cur.to_json().unwrap(), 2).unwrap();
assert_eq!(regs.len(), 3);
assert!(regs
.iter()
.any(|r| matches!(r, PlanRegression::ScanTypeUpgrade { .. })));
assert!(regs
.iter()
.any(|r| matches!(r, PlanRegression::IndexLost { .. })));
assert!(regs
.iter()
.any(|r| matches!(r, PlanRegression::RowsGrowth { .. })));
}
#[test]
fn check_regressions_configurable_threshold() {
let mut base = PlanBaseline::default();
base.upsert(PlanSnapshot::new(
"q1",
plan(ScanType::IndexRange, Some("idx"), 100),
));
let mut cur = PlanBaseline::default();
cur.upsert(PlanSnapshot::new(
"q1",
plan(ScanType::IndexRange, Some("idx"), 300),
));
let regs_strict =
check_regressions(&base.to_json().unwrap(), &cur.to_json().unwrap(), 2).unwrap();
assert!(regs_strict
.iter()
.any(|r| matches!(r, PlanRegression::RowsGrowth { .. })));
let regs_loose =
check_regressions(&base.to_json().unwrap(), &cur.to_json().unwrap(), 5).unwrap();
assert!(!regs_loose
.iter()
.any(|r| matches!(r, PlanRegression::RowsGrowth { .. })));
}
}