Files
gitlore/crates/lore-tui/tests/perf_benchmarks.rs
teernisse 5143befe46 feat(tui): add 14 performance benchmark tests (bd-wnuo)
S/M/L tiered benchmarks measuring TUI update+render cycles with
synthetic data fixtures. SLO gates: S-tier <10ms update/<20ms render,
M-tier <50ms each. L-tier advisory only. All pass with generous margins.
2026-02-19 07:42:51 -05:00

573 lines
16 KiB
Rust

//! Performance benchmark fixtures with S/M/L tiered SLOs (bd-wnuo).
//!
//! Measures TUI update+render cycle time with synthetic data at three scales:
//! - **S-tier** (small): ~100 issues, 50 MRs — CI gate, strict SLOs
//! - **M-tier** (medium): ~1,000 issues, 500 MRs — CI gate, relaxed SLOs
//! - **L-tier** (large): ~5,000 issues, 2,000 MRs — advisory, no CI gate
//!
//! SLOs are measured in wall-clock time per operation (update or render).
//! Tests run 20 iterations and assert on the median to avoid flaky p95.
//!
//! These test the TUI state/render performance, NOT database query time.
//! DB benchmarks belong in the root `lore` crate.
use std::time::{Duration, Instant};
use chrono::{TimeZone, Utc};
use ftui::Model;
use ftui::render::frame::Frame;
use ftui::render::grapheme_pool::GraphemePool;
use lore_tui::app::LoreApp;
use lore_tui::clock::FakeClock;
use lore_tui::message::{Msg, Screen};
use lore_tui::state::dashboard::{DashboardData, EntityCounts, LastSyncInfo, ProjectSyncInfo};
use lore_tui::state::issue_list::{IssueListPage, IssueListRow};
use lore_tui::state::mr_list::{MrListPage, MrListRow};
use lore_tui::task_supervisor::TaskKey;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const RENDER_WIDTH: u16 = 120;
const RENDER_HEIGHT: u16 = 40;
const ITERATIONS: usize = 20;
// SLOs (median per operation).
// These are generous to avoid CI flakiness.
const SLO_UPDATE_S: Duration = Duration::from_millis(10);
const SLO_UPDATE_M: Duration = Duration::from_millis(50);
const SLO_RENDER_S: Duration = Duration::from_millis(20);
const SLO_RENDER_M: Duration = Duration::from_millis(50);
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn frozen_clock() -> FakeClock {
FakeClock::new(Utc.with_ymd_and_hms(2026, 1, 15, 12, 0, 0).unwrap())
}
fn test_app() -> LoreApp {
let mut app = LoreApp::new();
app.clock = Box::new(frozen_clock());
app
}
fn render_app(app: &LoreApp) {
let mut pool = GraphemePool::new();
let mut frame = Frame::new(RENDER_WIDTH, RENDER_HEIGHT, &mut pool);
app.view(&mut frame);
}
fn median(durations: &mut [Duration]) -> Duration {
durations.sort();
durations[durations.len() / 2]
}
// ---------------------------------------------------------------------------
// Seeded fixture generators
// ---------------------------------------------------------------------------
/// Simple xorshift64 PRNG for deterministic fixtures.
struct Rng(u64);
impl Rng {
fn new(seed: u64) -> Self {
Self(seed.wrapping_add(1))
}
fn next(&mut self) -> u64 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.0 = x;
x
}
fn range(&mut self, max: u64) -> u64 {
self.next() % max
}
}
const AUTHORS: &[&str] = &[
"alice", "bob", "carol", "dave", "eve", "frank", "grace", "heidi", "ivan", "judy", "karl",
"lucy", "mike", "nancy", "oscar", "peggy", "quinn", "ruth", "steve", "tina",
];
const LABELS: &[&str] = &[
"backend",
"frontend",
"infra",
"bug",
"feature",
"refactor",
"docs",
"ci",
"security",
"performance",
"ui",
"api",
"testing",
"devops",
"database",
];
const PROJECTS: &[&str] = &[
"infra/platform",
"web/frontend",
"api/backend",
"tools/scripts",
"data/pipeline",
];
fn random_author(rng: &mut Rng) -> String {
AUTHORS[rng.range(AUTHORS.len() as u64) as usize].to_string()
}
fn random_labels(rng: &mut Rng, max: usize) -> Vec<String> {
let count = rng.range(max as u64 + 1) as usize;
(0..count)
.map(|_| LABELS[rng.range(LABELS.len() as u64) as usize].to_string())
.collect()
}
fn random_project(rng: &mut Rng) -> String {
PROJECTS[rng.range(PROJECTS.len() as u64) as usize].to_string()
}
fn random_state(rng: &mut Rng) -> String {
match rng.range(10) {
0..=5 => "closed".to_string(),
6..=8 => "opened".to_string(),
_ => "merged".to_string(),
}
}
fn generate_issue_list(count: usize, seed: u64) -> IssueListPage {
let mut rng = Rng::new(seed);
let rows = (0..count)
.map(|i| IssueListRow {
project_path: random_project(&mut rng),
iid: (i + 1) as i64,
title: format!(
"{} {} for {} component",
if rng.range(2) == 0 { "Fix" } else { "Add" },
[
"retry logic",
"caching",
"validation",
"error handling",
"rate limiting"
][rng.range(5) as usize],
["auth", "payments", "search", "notifications", "dashboard"][rng.range(5) as usize]
),
state: random_state(&mut rng),
author: random_author(&mut rng),
labels: random_labels(&mut rng, 3),
updated_at: 1_736_900_000_000 + rng.range(100_000_000) as i64,
})
.collect();
IssueListPage {
rows,
next_cursor: None,
total_count: count as u64,
}
}
fn generate_mr_list(count: usize, seed: u64) -> MrListPage {
let mut rng = Rng::new(seed);
let rows = (0..count)
.map(|i| MrListRow {
project_path: random_project(&mut rng),
iid: (i + 1) as i64,
title: format!(
"{}: {} {} implementation",
if rng.range(3) == 0 { "WIP" } else { "feat" },
["Implement", "Refactor", "Update", "Fix", "Add"][rng.range(5) as usize],
["middleware", "service", "handler", "model", "view"][rng.range(5) as usize]
),
state: random_state(&mut rng),
author: random_author(&mut rng),
labels: random_labels(&mut rng, 2),
updated_at: 1_736_900_000_000 + rng.range(100_000_000) as i64,
draft: rng.range(5) == 0,
target_branch: "main".to_string(),
})
.collect();
MrListPage {
rows,
next_cursor: None,
total_count: count as u64,
}
}
fn generate_dashboard_data(
issues_total: u64,
mrs_total: u64,
project_count: usize,
) -> DashboardData {
let mut rng = Rng::new(42);
DashboardData {
counts: EntityCounts {
issues_total,
issues_open: issues_total * 3 / 10,
mrs_total,
mrs_open: mrs_total / 5,
discussions: issues_total * 3,
notes_total: issues_total * 8,
notes_system_pct: 18,
documents: issues_total * 2,
embeddings: issues_total,
},
projects: (0..project_count)
.map(|_| ProjectSyncInfo {
path: random_project(&mut rng),
minutes_since_sync: rng.range(60),
})
.collect(),
recent: vec![],
last_sync: Some(LastSyncInfo {
status: "succeeded".into(),
finished_at: Some(1_736_942_100_000),
command: "sync".into(),
error: None,
}),
}
}
// ---------------------------------------------------------------------------
// Benchmark runner
// ---------------------------------------------------------------------------
fn bench_update(app: &mut LoreApp, msg_factory: impl Fn() -> Msg) -> Duration {
let mut durations = Vec::with_capacity(ITERATIONS);
for _ in 0..ITERATIONS {
let msg = msg_factory();
let start = Instant::now();
app.update(msg);
durations.push(start.elapsed());
}
median(&mut durations)
}
fn bench_render(app: &LoreApp) -> Duration {
let mut durations = Vec::with_capacity(ITERATIONS);
for _ in 0..ITERATIONS {
let start = Instant::now();
render_app(app);
durations.push(start.elapsed());
}
median(&mut durations)
}
// ---------------------------------------------------------------------------
// S-Tier Benchmarks (100 issues, 50 MRs)
// ---------------------------------------------------------------------------
#[test]
fn bench_s_tier_dashboard_update() {
let mut app = test_app();
let data = generate_dashboard_data(100, 50, 5);
let generation = app
.supervisor
.submit(TaskKey::LoadScreen(Screen::Dashboard))
.generation;
let med = bench_update(&mut app, || Msg::DashboardLoaded {
generation,
data: Box::new(data.clone()),
});
eprintln!("S-tier dashboard update median: {med:?}");
assert!(
med < SLO_UPDATE_S,
"S-tier dashboard update {med:?} exceeds SLO {SLO_UPDATE_S:?}"
);
}
#[test]
fn bench_s_tier_issue_list_update() {
let mut app = test_app();
let generation = app
.supervisor
.submit(TaskKey::LoadScreen(Screen::IssueList))
.generation;
let med = bench_update(&mut app, || Msg::IssueListLoaded {
generation,
page: generate_issue_list(100, 1),
});
eprintln!("S-tier issue list update median: {med:?}");
assert!(
med < SLO_UPDATE_S,
"S-tier issue list update {med:?} exceeds SLO {SLO_UPDATE_S:?}"
);
}
#[test]
fn bench_s_tier_mr_list_update() {
let mut app = test_app();
let generation = app
.supervisor
.submit(TaskKey::LoadScreen(Screen::MrList))
.generation;
let med = bench_update(&mut app, || Msg::MrListLoaded {
generation,
page: generate_mr_list(50, 2),
});
eprintln!("S-tier MR list update median: {med:?}");
assert!(
med < SLO_UPDATE_S,
"S-tier MR list update {med:?} exceeds SLO {SLO_UPDATE_S:?}"
);
}
#[test]
fn bench_s_tier_dashboard_render() {
let mut app = test_app();
let generation = app
.supervisor
.submit(TaskKey::LoadScreen(Screen::Dashboard))
.generation;
app.update(Msg::DashboardLoaded {
generation,
data: Box::new(generate_dashboard_data(100, 50, 5)),
});
let med = bench_render(&app);
eprintln!("S-tier dashboard render median: {med:?}");
assert!(
med < SLO_RENDER_S,
"S-tier dashboard render {med:?} exceeds SLO {SLO_RENDER_S:?}"
);
}
#[test]
fn bench_s_tier_issue_list_render() {
let mut app = test_app();
let generation = app
.supervisor
.submit(TaskKey::LoadScreen(Screen::IssueList))
.generation;
app.update(Msg::NavigateTo(Screen::IssueList));
app.update(Msg::IssueListLoaded {
generation,
page: generate_issue_list(100, 1),
});
let med = bench_render(&app);
eprintln!("S-tier issue list render median: {med:?}");
assert!(
med < SLO_RENDER_S,
"S-tier issue list render {med:?} exceeds SLO {SLO_RENDER_S:?}"
);
}
// ---------------------------------------------------------------------------
// M-Tier Benchmarks (1,000 issues, 500 MRs)
// ---------------------------------------------------------------------------
#[test]
fn bench_m_tier_dashboard_update() {
let mut app = test_app();
let data = generate_dashboard_data(1_000, 500, 10);
let generation = app
.supervisor
.submit(TaskKey::LoadScreen(Screen::Dashboard))
.generation;
let med = bench_update(&mut app, || Msg::DashboardLoaded {
generation,
data: Box::new(data.clone()),
});
eprintln!("M-tier dashboard update median: {med:?}");
assert!(
med < SLO_UPDATE_M,
"M-tier dashboard update {med:?} exceeds SLO {SLO_UPDATE_M:?}"
);
}
#[test]
fn bench_m_tier_issue_list_update() {
let mut app = test_app();
let generation = app
.supervisor
.submit(TaskKey::LoadScreen(Screen::IssueList))
.generation;
let med = bench_update(&mut app, || Msg::IssueListLoaded {
generation,
page: generate_issue_list(1_000, 10),
});
eprintln!("M-tier issue list update median: {med:?}");
assert!(
med < SLO_UPDATE_M,
"M-tier issue list update {med:?} exceeds SLO {SLO_UPDATE_M:?}"
);
}
#[test]
fn bench_m_tier_mr_list_update() {
let mut app = test_app();
let generation = app
.supervisor
.submit(TaskKey::LoadScreen(Screen::MrList))
.generation;
let med = bench_update(&mut app, || Msg::MrListLoaded {
generation,
page: generate_mr_list(500, 20),
});
eprintln!("M-tier MR list update median: {med:?}");
assert!(
med < SLO_UPDATE_M,
"M-tier MR list update {med:?} exceeds SLO {SLO_UPDATE_M:?}"
);
}
#[test]
fn bench_m_tier_dashboard_render() {
let mut app = test_app();
let generation = app
.supervisor
.submit(TaskKey::LoadScreen(Screen::Dashboard))
.generation;
app.update(Msg::DashboardLoaded {
generation,
data: Box::new(generate_dashboard_data(1_000, 500, 10)),
});
let med = bench_render(&app);
eprintln!("M-tier dashboard render median: {med:?}");
assert!(
med < SLO_RENDER_M,
"M-tier dashboard render {med:?} exceeds SLO {SLO_RENDER_M:?}"
);
}
#[test]
fn bench_m_tier_issue_list_render() {
let mut app = test_app();
let generation = app
.supervisor
.submit(TaskKey::LoadScreen(Screen::IssueList))
.generation;
app.update(Msg::NavigateTo(Screen::IssueList));
app.update(Msg::IssueListLoaded {
generation,
page: generate_issue_list(1_000, 10),
});
let med = bench_render(&app);
eprintln!("M-tier issue list render median: {med:?}");
assert!(
med < SLO_RENDER_M,
"M-tier issue list render {med:?} exceeds SLO {SLO_RENDER_M:?}"
);
}
// ---------------------------------------------------------------------------
// L-Tier Benchmarks (5,000 issues, 2,000 MRs) — advisory, not CI gate
// ---------------------------------------------------------------------------
#[test]
fn bench_l_tier_issue_list_update() {
let mut app = test_app();
let generation = app
.supervisor
.submit(TaskKey::LoadScreen(Screen::IssueList))
.generation;
let med = bench_update(&mut app, || Msg::IssueListLoaded {
generation,
page: generate_issue_list(5_000, 100),
});
// Advisory — log but don't fail CI.
eprintln!("L-tier issue list update median: {med:?} (advisory, no SLO gate)");
}
#[test]
fn bench_l_tier_issue_list_render() {
let mut app = test_app();
let generation = app
.supervisor
.submit(TaskKey::LoadScreen(Screen::IssueList))
.generation;
app.update(Msg::NavigateTo(Screen::IssueList));
app.update(Msg::IssueListLoaded {
generation,
page: generate_issue_list(5_000, 100),
});
let med = bench_render(&app);
eprintln!("L-tier issue list render median: {med:?} (advisory, no SLO gate)");
}
// ---------------------------------------------------------------------------
// Combined update+render cycle benchmarks
// ---------------------------------------------------------------------------
#[test]
fn bench_full_cycle_s_tier() {
let mut app = test_app();
let mut durations = Vec::with_capacity(ITERATIONS);
for i in 0..ITERATIONS {
let generation = app
.supervisor
.submit(TaskKey::LoadScreen(Screen::IssueList))
.generation;
let page = generate_issue_list(100, i as u64 + 500);
let start = Instant::now();
app.update(Msg::IssueListLoaded { generation, page });
render_app(&app);
durations.push(start.elapsed());
}
let med = median(&mut durations);
eprintln!("S-tier full cycle (update+render) median: {med:?}");
assert!(
med < SLO_UPDATE_S + SLO_RENDER_S,
"S-tier full cycle {med:?} exceeds combined SLO {:?}",
SLO_UPDATE_S + SLO_RENDER_S
);
}
#[test]
fn bench_full_cycle_m_tier() {
let mut app = test_app();
let mut durations = Vec::with_capacity(ITERATIONS);
for i in 0..ITERATIONS {
let generation = app
.supervisor
.submit(TaskKey::LoadScreen(Screen::IssueList))
.generation;
let page = generate_issue_list(1_000, i as u64 + 500);
let start = Instant::now();
app.update(Msg::IssueListLoaded { generation, page });
render_app(&app);
durations.push(start.elapsed());
}
let med = median(&mut durations);
eprintln!("M-tier full cycle (update+render) median: {med:?}");
assert!(
med < SLO_UPDATE_M + SLO_RENDER_M,
"M-tier full cycle {med:?} exceeds combined SLO {:?}",
SLO_UPDATE_M + SLO_RENDER_M
);
}