use std::collections::BTreeMap;
use fraiseql_db::changelog::DURATION_CALC_VERSION;
use serde::Serialize;
use super::reader::ChangeLogSample;
const DAY_SECS: f64 = 86_400.0;
#[derive(Debug, Clone)]
pub struct RegressionParams {
pub recent_days: i64,
pub baseline_days: i64,
pub min_samples: usize,
pub threshold_pct: f64,
pub min_delta_ms: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SkipReason {
InsufficientSamples,
NoComparableV2Data,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RegressionFinding {
pub object_type: String,
pub modification_type: String,
pub baseline_p50: f64,
pub baseline_p95: f64,
pub recent_p50: f64,
pub recent_p95: f64,
pub pct_change: f64,
pub baseline_samples: usize,
pub recent_samples: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct SkippedGroup {
pub object_type: String,
pub modification_type: String,
pub reason: SkipReason,
pub baseline_samples: usize,
pub recent_samples: usize,
pub excluded_samples: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ScanSummary {
pub groups_analyzed: usize,
pub regressions: usize,
pub total_samples: usize,
pub excluded_samples: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RegressionReport {
pub findings: Vec<RegressionFinding>,
pub skipped: Vec<SkippedGroup>,
pub summary: ScanSummary,
}
#[must_use]
#[allow(clippy::cast_precision_loss)] pub fn regression_scan(
samples: &[ChangeLogSample],
params: &RegressionParams,
now_epoch: f64,
) -> RegressionReport {
let recent_start = (params.recent_days as f64).mul_add(-DAY_SECS, now_epoch);
let baseline_start =
((params.recent_days + params.baseline_days) as f64).mul_add(-DAY_SECS, now_epoch);
let mut groups: BTreeMap<(&str, &str), Vec<&ChangeLogSample>> = BTreeMap::new();
for s in samples {
groups
.entry((s.object_type.as_str(), s.modification_type.as_str()))
.or_default()
.push(s);
}
let groups_analyzed = groups.len();
let mut findings = Vec::new();
let mut skipped = Vec::new();
let mut total_excluded = 0usize;
for ((object_type, modification_type), rows) in groups {
let mut baseline = Vec::new();
let mut recent = Vec::new();
let mut excluded = 0usize;
for s in rows {
let comparable =
s.duration_ms.is_some() && s.duration_calc_version == Some(DURATION_CALC_VERSION);
let Some(duration) = s.duration_ms.filter(|_| comparable).map(f64::from) else {
excluded += 1;
continue;
};
if s.created_at_epoch >= recent_start {
recent.push(duration);
} else if s.created_at_epoch >= baseline_start {
baseline.push(duration);
}
}
total_excluded += excluded;
let (object_type, modification_type) =
(object_type.to_string(), modification_type.to_string());
if baseline.is_empty() && recent.is_empty() {
skipped.push(SkippedGroup {
object_type,
modification_type,
reason: SkipReason::NoComparableV2Data,
baseline_samples: 0,
recent_samples: 0,
excluded_samples: excluded,
});
continue;
}
if baseline.len() < params.min_samples || recent.len() < params.min_samples {
skipped.push(SkippedGroup {
object_type,
modification_type,
reason: SkipReason::InsufficientSamples,
baseline_samples: baseline.len(),
recent_samples: recent.len(),
excluded_samples: excluded,
});
continue;
}
baseline.sort_by(f64::total_cmp);
recent.sort_by(f64::total_cmp);
let baseline_p50 = percentile_sorted(&baseline, 50.0);
let recent_p50 = percentile_sorted(&recent, 50.0);
let pct_change = (recent_p50 / baseline_p50.max(1.0) - 1.0) * 100.0;
if pct_change >= params.threshold_pct && (recent_p50 - baseline_p50) >= params.min_delta_ms
{
findings.push(RegressionFinding {
object_type,
modification_type,
baseline_p50,
baseline_p95: percentile_sorted(&baseline, 95.0),
recent_p50,
recent_p95: percentile_sorted(&recent, 95.0),
pct_change,
baseline_samples: baseline.len(),
recent_samples: recent.len(),
});
}
}
let summary = ScanSummary {
groups_analyzed,
regressions: findings.len(),
total_samples: samples.len(),
excluded_samples: total_excluded,
};
RegressionReport {
findings,
skipped,
summary,
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct SlowSample {
pub object_type: String,
pub modification_type: String,
pub duration_ms: i32,
pub object_id: Option<String>,
pub trace_id: Option<String>,
}
#[must_use]
pub fn slowest(samples: &[ChangeLogSample], limit: usize) -> Vec<SlowSample> {
let mut rows: Vec<SlowSample> = samples
.iter()
.filter_map(|s| {
let comparable = s.duration_calc_version == Some(DURATION_CALC_VERSION);
s.duration_ms.filter(|_| comparable).map(|duration_ms| SlowSample {
object_type: s.object_type.clone(),
modification_type: s.modification_type.clone(),
duration_ms,
object_id: s.object_id.clone(),
trace_id: s.trace_id.clone(),
})
})
.collect();
rows.sort_by(|a, b| b.duration_ms.cmp(&a.duration_ms));
rows.truncate(limit);
rows
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct NullRateRow {
pub object_type: String,
pub modification_type: String,
pub total: usize,
pub null_duration: usize,
pub non_v2: usize,
pub null_rate_pct: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct NullRateReport {
pub rows: Vec<NullRateRow>,
pub overall: NullRateRow,
}
#[must_use]
pub fn null_rate(samples: &[ChangeLogSample]) -> NullRateReport {
let mut groups: BTreeMap<(&str, &str), [usize; 3]> = BTreeMap::new();
let mut overall = [0usize; 3];
for s in samples {
let acc = groups
.entry((s.object_type.as_str(), s.modification_type.as_str()))
.or_default();
acc[0] += 1;
overall[0] += 1;
if s.duration_ms.is_none() {
acc[1] += 1;
overall[1] += 1;
} else if s.duration_calc_version != Some(DURATION_CALC_VERSION) {
acc[2] += 1;
overall[2] += 1;
}
}
let rows = groups
.into_iter()
.map(|((object_type, modification_type), c)| {
null_rate_row(object_type.to_string(), modification_type.to_string(), c)
})
.collect();
NullRateReport {
rows,
overall: null_rate_row("*".to_string(), "*".to_string(), overall),
}
}
fn null_rate_row(object_type: String, modification_type: String, c: [usize; 3]) -> NullRateRow {
let [total, null_duration, non_v2] = c;
let null_rate_pct = if total == 0 {
0.0
} else {
ratio_pct(null_duration, total)
};
NullRateRow {
object_type,
modification_type,
total,
null_duration,
non_v2,
null_rate_pct,
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct OperationSummary {
pub object_type: String,
pub modification_type: String,
pub count: usize,
pub p50: f64,
pub p95: f64,
pub p99: f64,
pub max: f64,
}
#[must_use]
pub fn summary(samples: &[ChangeLogSample]) -> Vec<OperationSummary> {
let mut groups: BTreeMap<(&str, &str), Vec<f64>> = BTreeMap::new();
for s in samples {
let comparable = s.duration_calc_version == Some(DURATION_CALC_VERSION);
if let Some(duration) = s.duration_ms.filter(|_| comparable).map(f64::from) {
groups
.entry((s.object_type.as_str(), s.modification_type.as_str()))
.or_default()
.push(duration);
}
}
let mut out: Vec<OperationSummary> = groups
.into_iter()
.map(|((object_type, modification_type), mut durations)| {
durations.sort_by(f64::total_cmp);
OperationSummary {
object_type: object_type.to_string(),
modification_type: modification_type.to_string(),
count: durations.len(),
p50: percentile_sorted(&durations, 50.0),
p95: percentile_sorted(&durations, 95.0),
p99: percentile_sorted(&durations, 99.0),
max: durations.last().copied().unwrap_or(0.0),
}
})
.collect();
out.sort_by(|a, b| b.p95.total_cmp(&a.p95));
out
}
#[allow(clippy::cast_precision_loss)] fn ratio_pct(numerator: usize, denominator: usize) -> f64 {
if denominator == 0 {
0.0
} else {
numerator as f64 / denominator as f64 * 100.0
}
}
#[must_use]
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
pub fn percentile_sorted(sorted: &[f64], pct: f64) -> f64 {
match sorted {
[] => 0.0,
[only] => *only,
_ => {
let rank = (pct / 100.0) * (sorted.len() - 1) as f64;
let lo = rank.floor() as usize;
let hi = rank.ceil() as usize;
if lo == hi {
sorted[lo]
} else {
let frac = rank - lo as f64;
(sorted[hi] - sorted[lo]).mul_add(frac, sorted[lo])
}
},
}
}
#[must_use]
pub fn percentile(values: &mut [f64], pct: f64) -> f64 {
values.sort_by(f64::total_cmp);
percentile_sorted(values, pct)
}