1use std::collections::BTreeMap;
17
18use fraiseql_db::changelog::DURATION_CALC_VERSION;
19use serde::Serialize;
20
21use super::reader::ChangeLogSample;
22
23const DAY_SECS: f64 = 86_400.0;
25
26#[derive(Debug, Clone)]
28pub struct RegressionParams {
29 pub recent_days: i64,
31 pub baseline_days: i64,
33 pub min_samples: usize,
36 pub threshold_pct: f64,
38 pub min_delta_ms: f64,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
45#[serde(rename_all = "snake_case")]
46pub enum SkipReason {
47 InsufficientSamples,
49 NoComparableV2Data,
51}
52
53#[derive(Debug, Clone, PartialEq, Serialize)]
55pub struct RegressionFinding {
56 pub object_type: String,
58 pub modification_type: String,
60 pub baseline_p50: f64,
62 pub baseline_p95: f64,
64 pub recent_p50: f64,
66 pub recent_p95: f64,
68 pub pct_change: f64,
70 pub baseline_samples: usize,
72 pub recent_samples: usize,
74}
75
76#[derive(Debug, Clone, PartialEq, Serialize)]
78pub struct SkippedGroup {
79 pub object_type: String,
81 pub modification_type: String,
83 pub reason: SkipReason,
85 pub baseline_samples: usize,
87 pub recent_samples: usize,
89 pub excluded_samples: usize,
92}
93
94#[derive(Debug, Clone, PartialEq, Serialize)]
96pub struct ScanSummary {
97 pub groups_analyzed: usize,
99 pub regressions: usize,
101 pub total_samples: usize,
103 pub excluded_samples: usize,
105}
106
107#[derive(Debug, Clone, PartialEq, Serialize)]
109pub struct RegressionReport {
110 pub findings: Vec<RegressionFinding>,
112 pub skipped: Vec<SkippedGroup>,
114 pub summary: ScanSummary,
116}
117
118#[must_use]
125#[allow(clippy::cast_precision_loss)] pub fn regression_scan(
127 samples: &[ChangeLogSample],
128 params: &RegressionParams,
129 now_epoch: f64,
130) -> RegressionReport {
131 let recent_start = (params.recent_days as f64).mul_add(-DAY_SECS, now_epoch);
132 let baseline_start =
133 ((params.recent_days + params.baseline_days) as f64).mul_add(-DAY_SECS, now_epoch);
134
135 let mut groups: BTreeMap<(&str, &str), Vec<&ChangeLogSample>> = BTreeMap::new();
138 for s in samples {
139 groups
140 .entry((s.object_type.as_str(), s.modification_type.as_str()))
141 .or_default()
142 .push(s);
143 }
144
145 let groups_analyzed = groups.len();
146 let mut findings = Vec::new();
147 let mut skipped = Vec::new();
148 let mut total_excluded = 0usize;
149
150 for ((object_type, modification_type), rows) in groups {
151 let mut baseline = Vec::new();
152 let mut recent = Vec::new();
153 let mut excluded = 0usize;
154
155 for s in rows {
156 let comparable =
158 s.duration_ms.is_some() && s.duration_calc_version == Some(DURATION_CALC_VERSION);
159 let Some(duration) = s.duration_ms.filter(|_| comparable).map(f64::from) else {
160 excluded += 1;
161 continue;
162 };
163 if s.created_at_epoch >= recent_start {
164 recent.push(duration);
165 } else if s.created_at_epoch >= baseline_start {
166 baseline.push(duration);
167 }
168 }
169 total_excluded += excluded;
170
171 let (object_type, modification_type) =
172 (object_type.to_string(), modification_type.to_string());
173
174 if baseline.is_empty() && recent.is_empty() {
175 skipped.push(SkippedGroup {
176 object_type,
177 modification_type,
178 reason: SkipReason::NoComparableV2Data,
179 baseline_samples: 0,
180 recent_samples: 0,
181 excluded_samples: excluded,
182 });
183 continue;
184 }
185
186 if baseline.len() < params.min_samples || recent.len() < params.min_samples {
187 skipped.push(SkippedGroup {
188 object_type,
189 modification_type,
190 reason: SkipReason::InsufficientSamples,
191 baseline_samples: baseline.len(),
192 recent_samples: recent.len(),
193 excluded_samples: excluded,
194 });
195 continue;
196 }
197
198 baseline.sort_by(f64::total_cmp);
199 recent.sort_by(f64::total_cmp);
200 let baseline_p50 = percentile_sorted(&baseline, 50.0);
201 let recent_p50 = percentile_sorted(&recent, 50.0);
202 let pct_change = (recent_p50 / baseline_p50.max(1.0) - 1.0) * 100.0;
205
206 if pct_change >= params.threshold_pct && (recent_p50 - baseline_p50) >= params.min_delta_ms
207 {
208 findings.push(RegressionFinding {
209 object_type,
210 modification_type,
211 baseline_p50,
212 baseline_p95: percentile_sorted(&baseline, 95.0),
213 recent_p50,
214 recent_p95: percentile_sorted(&recent, 95.0),
215 pct_change,
216 baseline_samples: baseline.len(),
217 recent_samples: recent.len(),
218 });
219 }
220 }
221
222 let summary = ScanSummary {
223 groups_analyzed,
224 regressions: findings.len(),
225 total_samples: samples.len(),
226 excluded_samples: total_excluded,
227 };
228 RegressionReport {
229 findings,
230 skipped,
231 summary,
232 }
233}
234
235#[derive(Debug, Clone, PartialEq, Serialize)]
237pub struct SlowSample {
238 pub object_type: String,
240 pub modification_type: String,
242 pub duration_ms: i32,
244 pub object_id: Option<String>,
246 pub trace_id: Option<String>,
248}
249
250#[must_use]
256pub fn slowest(samples: &[ChangeLogSample], limit: usize) -> Vec<SlowSample> {
257 let mut rows: Vec<SlowSample> = samples
258 .iter()
259 .filter_map(|s| {
260 let comparable = s.duration_calc_version == Some(DURATION_CALC_VERSION);
261 s.duration_ms.filter(|_| comparable).map(|duration_ms| SlowSample {
262 object_type: s.object_type.clone(),
263 modification_type: s.modification_type.clone(),
264 duration_ms,
265 object_id: s.object_id.clone(),
266 trace_id: s.trace_id.clone(),
267 })
268 })
269 .collect();
270 rows.sort_by_key(|row| core::cmp::Reverse(row.duration_ms));
271 rows.truncate(limit);
272 rows
273}
274
275#[derive(Debug, Clone, PartialEq, Serialize)]
277pub struct NullRateRow {
278 pub object_type: String,
280 pub modification_type: String,
282 pub total: usize,
284 pub null_duration: usize,
286 pub non_v2: usize,
288 pub null_rate_pct: f64,
290}
291
292#[derive(Debug, Clone, PartialEq, Serialize)]
294pub struct NullRateReport {
295 pub rows: Vec<NullRateRow>,
297 pub overall: NullRateRow,
299}
300
301#[must_use]
307pub fn null_rate(samples: &[ChangeLogSample]) -> NullRateReport {
308 let mut groups: BTreeMap<(&str, &str), [usize; 3]> = BTreeMap::new();
310 let mut overall = [0usize; 3];
311
312 for s in samples {
313 let acc = groups
314 .entry((s.object_type.as_str(), s.modification_type.as_str()))
315 .or_default();
316 acc[0] += 1;
317 overall[0] += 1;
318 if s.duration_ms.is_none() {
319 acc[1] += 1;
320 overall[1] += 1;
321 } else if s.duration_calc_version != Some(DURATION_CALC_VERSION) {
322 acc[2] += 1;
323 overall[2] += 1;
324 }
325 }
326
327 let rows = groups
328 .into_iter()
329 .map(|((object_type, modification_type), c)| {
330 null_rate_row(object_type.to_string(), modification_type.to_string(), c)
331 })
332 .collect();
333
334 NullRateReport {
335 rows,
336 overall: null_rate_row("*".to_string(), "*".to_string(), overall),
337 }
338}
339
340fn null_rate_row(object_type: String, modification_type: String, c: [usize; 3]) -> NullRateRow {
341 let [total, null_duration, non_v2] = c;
342 let null_rate_pct = if total == 0 {
343 0.0
344 } else {
345 ratio_pct(null_duration, total)
346 };
347 NullRateRow {
348 object_type,
349 modification_type,
350 total,
351 null_duration,
352 non_v2,
353 null_rate_pct,
354 }
355}
356
357#[derive(Debug, Clone, PartialEq, Serialize)]
359pub struct OperationSummary {
360 pub object_type: String,
362 pub modification_type: String,
364 pub count: usize,
366 pub p50: f64,
368 pub p95: f64,
370 pub p99: f64,
372 pub max: f64,
374}
375
376#[must_use]
379pub fn summary(samples: &[ChangeLogSample]) -> Vec<OperationSummary> {
380 let mut groups: BTreeMap<(&str, &str), Vec<f64>> = BTreeMap::new();
381 for s in samples {
382 let comparable = s.duration_calc_version == Some(DURATION_CALC_VERSION);
383 if let Some(duration) = s.duration_ms.filter(|_| comparable).map(f64::from) {
384 groups
385 .entry((s.object_type.as_str(), s.modification_type.as_str()))
386 .or_default()
387 .push(duration);
388 }
389 }
390
391 let mut out: Vec<OperationSummary> = groups
392 .into_iter()
393 .map(|((object_type, modification_type), mut durations)| {
394 durations.sort_by(f64::total_cmp);
395 OperationSummary {
396 object_type: object_type.to_string(),
397 modification_type: modification_type.to_string(),
398 count: durations.len(),
399 p50: percentile_sorted(&durations, 50.0),
400 p95: percentile_sorted(&durations, 95.0),
401 p99: percentile_sorted(&durations, 99.0),
402 max: durations.last().copied().unwrap_or(0.0),
403 }
404 })
405 .collect();
406 out.sort_by(|a, b| b.p95.total_cmp(&a.p95));
407 out
408}
409
410#[allow(clippy::cast_precision_loss)] fn ratio_pct(numerator: usize, denominator: usize) -> f64 {
413 if denominator == 0 {
414 0.0
415 } else {
416 numerator as f64 / denominator as f64 * 100.0
417 }
418}
419
420#[must_use]
423#[allow(
426 clippy::cast_precision_loss,
427 clippy::cast_possible_truncation,
428 clippy::cast_sign_loss
429)]
430pub fn percentile_sorted(sorted: &[f64], pct: f64) -> f64 {
431 match sorted {
432 [] => 0.0,
433 [only] => *only,
434 _ => {
435 let rank = (pct / 100.0) * (sorted.len() - 1) as f64;
436 let lo = rank.floor() as usize;
437 let hi = rank.ceil() as usize;
438 if lo == hi {
439 sorted[lo]
440 } else {
441 let frac = rank - lo as f64;
442 (sorted[hi] - sorted[lo]).mul_add(frac, sorted[lo])
443 }
444 },
445 }
446}
447
448#[must_use]
451pub fn percentile(values: &mut [f64], pct: f64) -> f64 {
452 values.sort_by(f64::total_cmp);
453 percentile_sorted(values, pct)
454}