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.
This commit is contained in:
572
crates/lore-tui/tests/perf_benchmarks.rs
Normal file
572
crates/lore-tui/tests/perf_benchmarks.rs
Normal file
@@ -0,0 +1,572 @@
|
||||
//! 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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user