Skip to main content

fraiseql_cli/commands/perf/
mod.rs

1//! `fraiseql perf` — change-log performance observability (#392).
2//!
3//! The first consumer of the Change-Spine change-log contract. It reads
4//! `core.v_entity_change_log` (the framework-owned outbox view) and turns it into
5//! operator-facing forensics. `perf regression-scan` flags mutations whose
6//! latency regressed between a baseline and a recent time window, per
7//! `(object_type, modification_type)`. `perf explore` offers ad-hoc reads — the
8//! slowest mutations, the `duration_ms` completeness rate, and per-operation
9//! latency percentiles.
10//!
11//! Architecture: this crate builds the *capability* (read + analyze + a stable
12//! machine-readable seam); the `fraisier` orchestrator schedules it on a cadence.
13//! The DB [`reader`] is a thin PostgreSQL projection; all analysis lives in pure,
14//! unit-tested functions so correctness is covered without a live database.
15
16pub mod analysis;
17pub mod reader;
18
19#[cfg(test)]
20mod tests;
21
22use anyhow::Result;
23
24use self::{
25    analysis::{NullRateReport, NullRateRow, RegressionParams, RegressionReport, SkipReason},
26    reader::{ChangeLogSample, PerfReader},
27};
28use crate::commands::migrate::resolve_database_url;
29
30/// Arguments for `perf regression-scan`.
31pub struct RegressionScanArgs {
32    /// Explicit PostgreSQL URL; falls back to `fraiseql.toml` / `DATABASE_URL`.
33    pub database:           Option<String>,
34    /// Recent window size, in days.
35    pub recent_days:        i64,
36    /// Baseline window size (immediately before the recent window), in days.
37    pub baseline_days:      i64,
38    /// Minimum comparable samples required on each side.
39    pub min_samples:        usize,
40    /// Minimum `p50` increase to flag, in percent.
41    pub threshold_pct:      f64,
42    /// Minimum absolute `p50` increase to flag, in milliseconds.
43    pub min_delta_ms:       f64,
44    /// Restrict the scan to a single `object_type`.
45    pub object_type:        Option<String>,
46    /// Exit non-zero when any regression is found.
47    pub fail_on_regression: bool,
48    /// Emit machine-readable JSON instead of the human report.
49    pub json:               bool,
50    /// Suppress the human report (no effect in JSON mode).
51    pub quiet:              bool,
52}
53
54/// Run `perf regression-scan`: read the change-log window, analyze it, render the
55/// report, and report whether the process should exit successfully.
56///
57/// Returns `Ok(true)` for a normal exit (code 0) and `Ok(false)` only when
58/// `--fail-on-regression` was passed *and* at least one regression was found.
59/// A scan that finds regressions without that flag still exits 0 — it is a
60/// report, not a gate.
61///
62/// # Errors
63///
64/// Returns an error if no database URL can be resolved or a query fails.
65pub async fn run_regression_scan(args: RegressionScanArgs) -> Result<bool> {
66    let url = resolve_database_url(args.database.as_deref())?;
67    let reader = PerfReader::connect(&url)?;
68
69    let now_epoch = reader.db_now_epoch().await?;
70    let window_days = i32::try_from(args.recent_days + args.baseline_days).unwrap_or(i32::MAX);
71    let samples = reader.load_samples(window_days, args.object_type.as_deref()).await?;
72
73    let params = RegressionParams {
74        recent_days:   args.recent_days,
75        baseline_days: args.baseline_days,
76        min_samples:   args.min_samples,
77        threshold_pct: args.threshold_pct,
78        min_delta_ms:  args.min_delta_ms,
79    };
80    let report = analysis::regression_scan(&samples, &params, now_epoch);
81
82    render_regression(&report, args.json, args.quiet)?;
83
84    let has_regressions = !report.findings.is_empty();
85    Ok(!(args.fail_on_regression && has_regressions))
86}
87
88/// Render a regression report: pretty JSON in `--json` mode, otherwise the
89/// human report with `WARN` / `SKIP` lines (a stable seam the `fraisier`
90/// orchestrator greps).
91fn render_regression(report: &RegressionReport, json: bool, quiet: bool) -> Result<()> {
92    if json {
93        println!("{}", serde_json::to_string_pretty(report)?);
94        return Ok(());
95    }
96    if quiet {
97        return Ok(());
98    }
99
100    let s = &report.summary;
101    println!(
102        "perf regression-scan — {} regression(s) across {} operation(s) · {} samples · {} \
103         excluded (non-v2)",
104        s.regressions, s.groups_analyzed, s.total_samples, s.excluded_samples
105    );
106
107    if report.findings.is_empty() && report.skipped.is_empty() {
108        println!("no regressions detected");
109        return Ok(());
110    }
111
112    for f in &report.findings {
113        println!(
114            "WARN  {}/{}  p50 {:.0}ms → {:.0}ms ({:+.1}%)  p95 {:.0}ms → {:.0}ms  [baseline={} \
115             recent={}]",
116            f.object_type,
117            f.modification_type,
118            f.baseline_p50,
119            f.recent_p50,
120            f.pct_change,
121            f.baseline_p95,
122            f.recent_p95,
123            f.baseline_samples,
124            f.recent_samples,
125        );
126    }
127    for sk in &report.skipped {
128        let reason = match sk.reason {
129            SkipReason::InsufficientSamples => {
130                format!(
131                    "insufficient samples (baseline={} recent={})",
132                    sk.baseline_samples, sk.recent_samples
133                )
134            },
135            SkipReason::NoComparableV2Data => {
136                format!("no comparable v2 data ({} excluded)", sk.excluded_samples)
137            },
138        };
139        println!("SKIP  {}/{}  {reason}", sk.object_type, sk.modification_type);
140    }
141    Ok(())
142}
143
144/// Connect and load a trailing-`days` window of samples for an `explore` read.
145async fn load(
146    database: Option<&str>,
147    days: i32,
148    object_type: Option<&str>,
149) -> Result<Vec<ChangeLogSample>> {
150    let url = resolve_database_url(database)?;
151    let reader = PerfReader::connect(&url)?;
152    reader.load_samples(days, object_type).await
153}
154
155/// Run `perf explore slowest`: the `limit` slowest comparable mutations.
156///
157/// # Errors
158///
159/// Returns an error if no database URL can be resolved or a query fails.
160pub async fn run_explore_slowest(
161    database: Option<String>,
162    days: i32,
163    object_type: Option<String>,
164    limit: usize,
165    json: bool,
166    quiet: bool,
167) -> Result<()> {
168    let samples = load(database.as_deref(), days, object_type.as_deref()).await?;
169    let rows = analysis::slowest(&samples, limit);
170
171    if json {
172        println!("{}", serde_json::to_string_pretty(&rows)?);
173    } else if !quiet {
174        if rows.is_empty() {
175            println!("perf explore slowest — no comparable (v2) mutations in the window");
176        } else {
177            println!("perf explore slowest — top {} by duration", rows.len());
178            for (i, r) in rows.iter().enumerate() {
179                println!(
180                    "{:>3}. {:>7}ms  {}/{}  {}  {}",
181                    i + 1,
182                    r.duration_ms,
183                    r.object_type,
184                    r.modification_type,
185                    r.object_id.as_deref().unwrap_or("-"),
186                    r.trace_id.as_deref().unwrap_or("-"),
187                );
188            }
189        }
190    }
191    Ok(())
192}
193
194/// Run `perf explore null-rate`: `duration_ms` completeness, per operation.
195///
196/// # Errors
197///
198/// Returns an error if no database URL can be resolved or a query fails.
199pub async fn run_explore_null_rate(
200    database: Option<String>,
201    days: i32,
202    object_type: Option<String>,
203    json: bool,
204    quiet: bool,
205) -> Result<()> {
206    let samples = load(database.as_deref(), days, object_type.as_deref()).await?;
207    let report = analysis::null_rate(&samples);
208
209    if json {
210        println!("{}", serde_json::to_string_pretty(&report)?);
211    } else if !quiet {
212        render_null_rate(&report);
213    }
214    Ok(())
215}
216
217/// Run `perf explore summary`: latency percentiles, per operation.
218///
219/// # Errors
220///
221/// Returns an error if no database URL can be resolved or a query fails.
222pub async fn run_explore_summary(
223    database: Option<String>,
224    days: i32,
225    object_type: Option<String>,
226    json: bool,
227    quiet: bool,
228) -> Result<()> {
229    let samples = load(database.as_deref(), days, object_type.as_deref()).await?;
230    let rows = analysis::summary(&samples);
231
232    if json {
233        println!("{}", serde_json::to_string_pretty(&rows)?);
234    } else if !quiet {
235        if rows.is_empty() {
236            println!("perf explore summary — no comparable (v2) mutations in the window");
237        } else {
238            println!("perf explore summary — latency percentiles (ms), slowest p95 first");
239            for r in &rows {
240                println!(
241                    "{}/{}  n={}  p50={:.0}  p95={:.0}  p99={:.0}  max={:.0}",
242                    r.object_type, r.modification_type, r.count, r.p50, r.p95, r.p99, r.max,
243                );
244            }
245        }
246    }
247    Ok(())
248}
249
250fn render_null_rate(report: &NullRateReport) {
251    println!("perf explore null-rate — duration_ms completeness");
252    for row in &report.rows {
253        print_null_rate_row(row);
254    }
255    println!("---");
256    print_null_rate_row(&report.overall);
257}
258
259fn print_null_rate_row(r: &NullRateRow) {
260    println!(
261        "{}/{}  total={} null={} non_v2={} null_rate={:.1}%",
262        r.object_type, r.modification_type, r.total, r.null_duration, r.non_v2, r.null_rate_pct,
263    );
264}