Skip to main content

fraiseql_cli/commands/perf/
analysis.rs

1//! Pure analysis over change-log samples — no database, no clock.
2//!
3//! Every function takes an in-memory `&[ChangeLogSample]` (and, for windowed
4//! analyses, an explicit `now_epoch` reference) so correctness is covered by
5//! ordinary unit tests; the live [`super::reader`] only moves rows across the
6//! wire. This is the layer that holds the two #392 correctness guards:
7//!
8//! 1. **`duration_calc_version` gating.** Only rows marked with the current
9//!    [`fraiseql_db::changelog::DURATION_CALC_VERSION`] enter the duration math, so pre-fix
10//!    `EXTRACT(MILLISECONDS)` rows are never averaged against the wall-clock-correct ones.
11//! 2. **`(object_type, modification_type)` split.** Latency is *never* aggregated across
12//!    modification types. A shift in the operation mix (more cheap `DELETE`s, fewer expensive
13//!    `UPDATE`s) can make an `object_type`-only aggregate look *faster* while every individual
14//!    operation regressed — the split makes that false improvement impossible.
15
16use std::collections::BTreeMap;
17
18use fraiseql_db::changelog::DURATION_CALC_VERSION;
19use serde::Serialize;
20
21use super::reader::ChangeLogSample;
22
23/// Seconds in a day — the window unit.
24const DAY_SECS: f64 = 86_400.0;
25
26/// Tunable parameters for [`regression_scan`].
27#[derive(Debug, Clone)]
28pub struct RegressionParams {
29    /// The recent window spans the trailing `recent_days`.
30    pub recent_days:   i64,
31    /// The baseline window spans the `baseline_days` immediately before that.
32    pub baseline_days: i64,
33    /// Minimum comparable samples required on *each* side, else the group is
34    /// skipped rather than flagged on thin data.
35    pub min_samples:   usize,
36    /// A regression needs at least this `p50` increase, in percent.
37    pub threshold_pct: f64,
38    /// …and at least this absolute `p50` increase, in milliseconds — the floor
39    /// suppresses noise on already-fast operations (e.g. 1ms → 2ms).
40    pub min_delta_ms:  f64,
41}
42
43/// Why a `(object_type, modification_type)` group was not evaluated.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
45#[serde(rename_all = "snake_case")]
46pub enum SkipReason {
47    /// Fewer than `min_samples` comparable rows on at least one side.
48    InsufficientSamples,
49    /// No rows carrying the current `duration_calc_version` on either side.
50    NoComparableV2Data,
51}
52
53/// A flagged latency regression for one operation.
54#[derive(Debug, Clone, PartialEq, Serialize)]
55pub struct RegressionFinding {
56    /// Entity type (`object_type`).
57    pub object_type:       String,
58    /// `INSERT` / `UPDATE` / `DELETE` / `CUSTOM`.
59    pub modification_type: String,
60    /// Median latency over the baseline window, in milliseconds.
61    pub baseline_p50:      f64,
62    /// 95th-percentile latency over the baseline window, in milliseconds.
63    pub baseline_p95:      f64,
64    /// Median latency over the recent window, in milliseconds.
65    pub recent_p50:        f64,
66    /// 95th-percentile latency over the recent window, in milliseconds.
67    pub recent_p95:        f64,
68    /// `(recent_p50 / max(baseline_p50, 1ms) - 1) * 100`.
69    pub pct_change:        f64,
70    /// Comparable rows on the baseline side.
71    pub baseline_samples:  usize,
72    /// Comparable rows on the recent side.
73    pub recent_samples:    usize,
74}
75
76/// A group that was skipped, with the reason and the counts behind it.
77#[derive(Debug, Clone, PartialEq, Serialize)]
78pub struct SkippedGroup {
79    /// Entity type (`object_type`).
80    pub object_type:       String,
81    /// `INSERT` / `UPDATE` / `DELETE` / `CUSTOM`.
82    pub modification_type: String,
83    /// Why the group was not evaluated.
84    pub reason:            SkipReason,
85    /// Comparable rows on the baseline side.
86    pub baseline_samples:  usize,
87    /// Comparable rows on the recent side.
88    pub recent_samples:    usize,
89    /// Rows dropped by the `duration_calc_version` gate (NULL duration or a
90    /// missing / pre-fix marker).
91    pub excluded_samples:  usize,
92}
93
94/// Roll-up counts for the whole scan — the stable seam summary.
95#[derive(Debug, Clone, PartialEq, Serialize)]
96pub struct ScanSummary {
97    /// Distinct `(object_type, modification_type)` groups seen.
98    pub groups_analyzed:  usize,
99    /// Number of groups flagged as regressed (`findings.len()`).
100    pub regressions:      usize,
101    /// Total rows scanned (before gating).
102    pub total_samples:    usize,
103    /// Rows dropped by the `duration_calc_version` gate across all groups.
104    pub excluded_samples: usize,
105}
106
107/// The full regression-scan result.
108#[derive(Debug, Clone, PartialEq, Serialize)]
109pub struct RegressionReport {
110    /// Operations whose latency regressed past both thresholds.
111    pub findings: Vec<RegressionFinding>,
112    /// Groups that could not be evaluated, with the reason.
113    pub skipped:  Vec<SkippedGroup>,
114    /// Roll-up counts.
115    pub summary:  ScanSummary,
116}
117
118/// Scan `samples` for per-operation latency regressions between a baseline and a
119/// recent window, both measured back from `now_epoch` (the database clock, so
120/// there is no app↔DB skew).
121///
122/// Findings and skips are returned sorted by `(object_type, modification_type)`
123/// for deterministic output.
124#[must_use]
125#[allow(clippy::cast_precision_loss)] // Reason: window day counts are tiny; i64→f64 is lossless here
126pub 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    // Group by the operation pair — the split that defeats the false-improvement
136    // hazard. BTreeMap keeps the output deterministic.
137    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            // duration_calc_version gate.
157            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        // Floor the denominator at 1ms so a near-zero baseline can't divide by
203        // zero (the JSON seam must stay finite).
204        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/// One of the slowest individual mutations (`perf explore slowest`).
236#[derive(Debug, Clone, PartialEq, Serialize)]
237pub struct SlowSample {
238    /// Entity type (`object_type`).
239    pub object_type:       String,
240    /// `INSERT` / `UPDATE` / `DELETE` / `CUSTOM`.
241    pub modification_type: String,
242    /// Measured wall-clock duration, in milliseconds.
243    pub duration_ms:       i32,
244    /// The changed entity's id, when present.
245    pub object_id:         Option<String>,
246    /// W3C trace id, when populated — the handle for deeper investigation.
247    pub trace_id:          Option<String>,
248}
249
250/// Return the `limit` slowest comparable (v2) mutations, slowest first.
251///
252/// Gated on the current `duration_calc_version`: pre-fix rows under-report long
253/// durations, so ranking them would mislead. Use `null-rate` to see how much
254/// data that excludes.
255#[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(|a, b| b.duration_ms.cmp(&a.duration_ms));
271    rows.truncate(limit);
272    rows
273}
274
275/// `duration_ms` completeness for one operation.
276#[derive(Debug, Clone, PartialEq, Serialize)]
277pub struct NullRateRow {
278    /// Entity type (`object_type`); `"*"` on the overall row.
279    pub object_type:       String,
280    /// Modification type; `"*"` on the overall row.
281    pub modification_type: String,
282    /// Total rows seen.
283    pub total:             usize,
284    /// Rows with a `NULL` duration (cooperative producers; no request clock).
285    pub null_duration:     usize,
286    /// Rows with a duration but a missing / pre-fix `duration_calc_version`.
287    pub non_v2:            usize,
288    /// `null_duration / total * 100`.
289    pub null_rate_pct:     f64,
290}
291
292/// Per-operation and overall `duration_ms` completeness (`perf explore null-rate`).
293#[derive(Debug, Clone, PartialEq, Serialize)]
294pub struct NullRateReport {
295    /// One row per `(object_type, modification_type)`.
296    pub rows:    Vec<NullRateRow>,
297    /// The aggregate across every row.
298    pub overall: NullRateRow,
299}
300
301/// Measure how complete `duration_ms` is, per operation and overall.
302///
303/// `null_duration` counts cooperative-producer rows (legitimately `NULL`); `non_v2`
304/// counts rows with a duration but no current marker. Both are *expected*, not
305/// drift — this is the lens that quantifies them.
306#[must_use]
307pub fn null_rate(samples: &[ChangeLogSample]) -> NullRateReport {
308    // (total, null_duration, non_v2) accumulator.
309    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/// Latency percentiles for one operation (`perf explore summary`).
358#[derive(Debug, Clone, PartialEq, Serialize)]
359pub struct OperationSummary {
360    /// Entity type (`object_type`).
361    pub object_type:       String,
362    /// `INSERT` / `UPDATE` / `DELETE` / `CUSTOM`.
363    pub modification_type: String,
364    /// Comparable (v2) rows behind the percentiles.
365    pub count:             usize,
366    /// 50th / 95th / 99th percentile latency and the max, in milliseconds.
367    pub p50:               f64,
368    /// 95th-percentile latency, in milliseconds.
369    pub p95:               f64,
370    /// 99th-percentile latency, in milliseconds.
371    pub p99:               f64,
372    /// Slowest observed latency, in milliseconds.
373    pub max:               f64,
374}
375
376/// Per-operation latency percentiles over comparable (v2) rows, slowest `p95`
377/// first. Operations with no comparable rows are omitted.
378#[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/// `numerator / denominator * 100`, guarding the zero denominator.
411#[allow(clippy::cast_precision_loss)] // Reason: completeness counts are small; usize→f64 is lossless here
412fn 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/// Linear-interpolation percentile (the NumPy default) over an **already-sorted**
421/// slice. `pct` is in `[0, 100]`. An empty slice yields `0.0`.
422#[must_use]
423// Reason: percentile index math — `rank` is non-negative and bounded by the slice
424// length, so the f64↔usize conversions cannot truncate or lose sign meaningfully.
425#[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/// Sort `values` and return its `pct` percentile. Convenience wrapper over
449/// [`percentile_sorted`] for callers holding an unsorted buffer.
450#[must_use]
451pub fn percentile(values: &mut [f64], pct: f64) -> f64 {
452    values.sort_by(f64::total_cmp);
453    percentile_sorted(values, pct)
454}