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.
573 lines
16 KiB
Rust
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
|
|
);
|
|
}
|