Files
Oxicloud/examples/bench_n1_hydration.rs
T
Claude 12dc648cff perf: round 4 — one-pass row paths, drive-selector cache, CalDAV single-parse, streamed Azure, batched hydration
Nine benchmark-gated changes (benches/ROUND4.md; every one ships with a
BEFORE/AFTER bench + equivalence gate, rollback rule as ROUND2/3):

- Row→entity path build: one-pass StoragePath::from_folder_and_name /
  from_joined + normalize_storage_name_owned + alloc-free Display —
  743→417 ns/file-row (1.78x), −5 allocs/row on every listing surface.
- WebDAV drive-selector: per-user readable_cache (single-flight, 30 s
  TTL, explicit invalidation incl. membership + group changes) replaces
  the grants join per request — 441 µs → 0.8 µs (~550x), 0 queries warm.
- CalDAV from_ical/update_ical_data: 8 full IcalParser runs per VEVENT
  → 1 (7.1x per PUT, 4.4x on 50-event imports); alloc-free split_vevents,
  chunk scan without the whole-body uppercase copy (1.4x), borrowed-key
  UID grouping (1.3x), REPORT props no longer cloned.
- PROPFIND emit: partition Vecs dropped (single-pass 404 list) + stack
  rendered RFC 3339/2822 dates, sizes, quoted etags (common::fmt,
  chrono-byte-identical, sweep-tested) on both DAV surfaces — 1.22x
  per page, 17.9→12.0 allocs/row.
- Grant-listing hydration: calendars/address books/playlists batch
  hydrate via = ANY($1) — 15 serial queries → 1 (~13x per sync poll).
- user-flags cache: get→insert → try_get_with single-flight (32→1
  queries per cold herd).
- Azure downloads: whole-blob Vec buffering → streamed SDK pages —
  TTFB 349→4 ms (87x), peak heap 480→1.9 MiB (254x) on 256 MiB blobs;
  new OXICLOUD_AZURE_ENDPOINT_URL override (Azurite/bench hook).
- Face indexing: unbounded per-image tokio::spawn → core-count
  semaphore, permit before blob read — peak heap 1175→176 MiB (6.7x).

Checks: cargo fmt, clippy --all-features --all-targets -D warnings,
cargo test --workspace (523 passed) + --features test_utils. hurl API
suite and dockerized integration DB not runnable in this environment —
left to CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aJu9ghvuT8WqC31ZEGTBA
2026-07-17 13:48:37 +00:00

437 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Grant-listing hydration N+1 benchmark + user-flags herd (ROUND4).
//!
//! [1-3] After `list_incoming_grants`, the CalDAV calendar discovery,
//! CardDAV book discovery and playlist listing each hydrated their K
//! accessible resources with K SERIAL point SELECTs (one
//! `WHERE id = $1` round-trip per resource, awaited in a loop) on every
//! client sync poll / dashboard load. AFTER: one `WHERE id = ANY($1)`
//! round-trip via the new `find_*_by_ids` batch methods — this bench
//! drives the REAL repositories both ways (the single-get methods still
//! exist for point lookups).
//!
//! [4] `get_user_flags` (called by the auth middleware on EVERY
//! authenticated request) used a get→insert cache: on each 30 s TTL
//! expiry, all in-flight requests of that user fired the SELECT
//! concurrently. AFTER: `try_get_with` single-flight. The bench
//! replicates both cache patterns around the real `UserPgRepository`
//! query, herd-style.
//!
//! Equivalence gates: identical id sets from loop vs batch for all
//! three resources; identical flags from every herd caller.
//!
//! Run (needs Postgres up; reads DATABASE_URL from .env):
//! cargo run --release --features bench --example bench_n1_hydration
//! Tunables (env): BENCH_RESOURCES (15), BENCH_PASSES (200), BENCH_HERD (32).
use std::collections::HashSet;
use std::env;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use oxicloud::domain::repositories::address_book_repository::AddressBookRepository;
use oxicloud::domain::repositories::calendar_repository::CalendarRepository;
use oxicloud::domain::repositories::playlist_repository::PlaylistRepository;
use oxicloud::infrastructure::repositories::pg::{
AddressBookPgRepository, CalendarPgRepository, PlaylistPgRepository, UserPgRepository,
};
use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions;
use uuid::Uuid;
fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
env::var(key)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
struct Seeded {
user_id: Uuid,
calendar_ids: Vec<Uuid>,
book_ids: Vec<Uuid>,
playlist_ids: Vec<Uuid>,
}
async fn seed(pool: &PgPool, n: usize) -> Seeded {
let user_id: Uuid = sqlx::query_scalar(
"INSERT INTO auth.users (username, email, role)
VALUES ('bench_n1', 'bench_n1@bench.invalid', 'user') RETURNING id",
)
.fetch_one(pool)
.await
.expect("seed user");
let mut calendar_ids = Vec::with_capacity(n);
let mut book_ids = Vec::with_capacity(n);
let mut playlist_ids = Vec::with_capacity(n);
for i in 0..n {
calendar_ids.push(
sqlx::query_scalar(
"INSERT INTO caldav.calendars (id, name, owner_id, color)
VALUES (gen_random_uuid(), $1, $2, '#3788d8') RETURNING id",
)
.bind(format!("Calendario {i}"))
.bind(user_id)
.fetch_one(pool)
.await
.expect("seed calendar"),
);
book_ids.push(
sqlx::query_scalar(
"INSERT INTO carddav.address_books (id, name, owner_id)
VALUES (gen_random_uuid(), $1, $2) RETURNING id",
)
.bind(format!("Libreta {i}"))
.bind(user_id)
.fetch_one(pool)
.await
.expect("seed book"),
);
playlist_ids.push(
sqlx::query_scalar(
"INSERT INTO audio.playlists (name, owner_id)
VALUES ($1, $2) RETURNING id",
)
.bind(format!("Lista {i}"))
.bind(user_id)
.fetch_one(pool)
.await
.expect("seed playlist"),
);
}
Seeded {
user_id,
calendar_ids,
book_ids,
playlist_ids,
}
}
async fn cleanup(pool: &PgPool, s: &Seeded) {
let _ = sqlx::query("DELETE FROM caldav.calendars WHERE owner_id = $1")
.bind(s.user_id)
.execute(pool)
.await;
let _ = sqlx::query("DELETE FROM carddav.address_books WHERE owner_id = $1")
.bind(s.user_id)
.execute(pool)
.await;
let _ = sqlx::query("DELETE FROM audio.playlists WHERE owner_id = $1")
.bind(s.user_id)
.execute(pool)
.await;
let _ = sqlx::query("DELETE FROM auth.users WHERE id = $1")
.bind(s.user_id)
.execute(pool)
.await;
}
fn p50(mut xs: Vec<f64>) -> f64 {
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
xs[xs.len() / 2]
}
async fn bench_pair<FB, FA, TB, TA>(
label: &str,
passes: usize,
n: usize,
mut before: FB,
mut after: FA,
) where
FB: AsyncFnMut() -> TB,
FA: AsyncFnMut() -> TA,
{
let mut lb = Vec::with_capacity(passes);
let mut la = Vec::with_capacity(passes);
for _ in 0..passes {
let t0 = Instant::now();
std::hint::black_box(before().await);
lb.push(t0.elapsed().as_secs_f64() * 1e3);
let t0 = Instant::now();
std::hint::black_box(after().await);
la.push(t0.elapsed().as_secs_f64() * 1e3);
}
let b = p50(lb);
let a = p50(la);
println!("[{label}] ms/listing (p50, K={n})");
println!(" BEFORE (K point SELECTs) {b:8.3} ({n} queries)");
println!(
" AFTER (1 × = ANY) {a:8.3} (1 query) {:.1}x",
b / a
);
}
#[tokio::main(flavor = "multi_thread")]
async fn main() {
dotenvy::dotenv().ok();
let url = env::var("DATABASE_URL")
.or_else(|_| env::var("OXICLOUD_DB_CONNECTION_STRING"))
.expect("set DATABASE_URL — the dev Postgres URL");
let n: usize = env_or("BENCH_RESOURCES", 15);
let passes: usize = env_or("BENCH_PASSES", 200);
let herd: usize = env_or("BENCH_HERD", 32);
let pool = Arc::new(
PgPoolOptions::new()
.max_connections(40)
.min_connections(40)
.acquire_timeout(Duration::from_secs(10))
.connect(&url)
.await
.expect("connect Postgres"),
);
let seeded = seed(&pool, n).await;
let cal_repo = CalendarPgRepository::new(pool.clone());
let book_repo = AddressBookPgRepository::new(pool.clone());
let pl_repo = PlaylistPgRepository::new(pool.clone());
println!("bench_n1_hydration — {n} resources/listing, {passes} passes, herd={herd}\n");
// ── [1] calendars ──
bench_pair(
"1 calendars",
passes,
n,
async || {
let mut out = Vec::with_capacity(n);
for id in &seeded.calendar_ids {
if let Ok(c) = cal_repo.find_calendar_by_id(id).await {
out.push(c);
}
}
out
},
async || {
cal_repo
.find_calendars_by_ids(&seeded.calendar_ids)
.await
.expect("batch calendars")
},
)
.await;
// ── [2] address books ──
bench_pair(
"2 address books",
passes,
n,
async || {
let mut out = Vec::with_capacity(n);
for id in &seeded.book_ids {
if let Ok(Some(b)) = book_repo.get_address_book_by_id(id).await {
out.push(b);
}
}
out
},
async || {
book_repo
.get_address_books_by_ids(&seeded.book_ids)
.await
.expect("batch books")
},
)
.await;
// ── [3] playlists ──
bench_pair(
"3 playlists",
passes,
n,
async || {
let mut out = Vec::with_capacity(n);
for id in &seeded.playlist_ids {
if let Ok(p) = pl_repo.find_playlist_by_id(id).await {
out.push(p);
}
}
out
},
async || {
pl_repo
.find_playlists_by_ids(&seeded.playlist_ids)
.await
.expect("batch playlists")
},
)
.await;
// ── Equivalence gates ──
let mut ok = true;
{
let loop_ids: HashSet<Uuid> = {
let mut s = HashSet::new();
for id in &seeded.calendar_ids {
if let Ok(c) = cal_repo.find_calendar_by_id(id).await {
s.insert(*c.id());
}
}
s
};
let batch_ids: HashSet<Uuid> = cal_repo
.find_calendars_by_ids(&seeded.calendar_ids)
.await
.expect("batch")
.iter()
.map(|c| *c.id())
.collect();
if loop_ids != batch_ids {
eprintln!("GATE FAIL calendars: {loop_ids:?} != {batch_ids:?}");
ok = false;
}
// Missing ids drop out on both sides.
let with_ghost: Vec<Uuid> = seeded
.calendar_ids
.iter()
.copied()
.chain([Uuid::new_v4()])
.collect();
let ghost_ids: HashSet<Uuid> = cal_repo
.find_calendars_by_ids(&with_ghost)
.await
.expect("batch+ghost")
.iter()
.map(|c| *c.id())
.collect();
if ghost_ids != batch_ids {
eprintln!("GATE FAIL calendars: ghost id changed result");
ok = false;
}
}
{
let loop_ids: HashSet<Uuid> = {
let mut s = HashSet::new();
for id in &seeded.book_ids {
if let Ok(Some(b)) = book_repo.get_address_book_by_id(id).await {
s.insert(*b.id());
}
}
s
};
let batch_ids: HashSet<Uuid> = book_repo
.get_address_books_by_ids(&seeded.book_ids)
.await
.expect("batch")
.iter()
.map(|b| *b.id())
.collect();
if loop_ids != batch_ids {
eprintln!("GATE FAIL books");
ok = false;
}
}
{
let loop_ids: HashSet<Uuid> = {
let mut s = HashSet::new();
for id in &seeded.playlist_ids {
if let Ok(p) = pl_repo.find_playlist_by_id(id).await {
s.insert(*p.id());
}
}
s
};
let batch_ids: HashSet<Uuid> = pl_repo
.find_playlists_by_ids(&seeded.playlist_ids)
.await
.expect("batch")
.iter()
.map(|p| *p.id())
.collect();
if loop_ids != batch_ids {
eprintln!("GATE FAIL playlists");
ok = false;
}
}
// ── [4] user-flags herd: get→insert vs try_get_with ─────────────────────
let user_repo = Arc::new(UserPgRepository::new(pool.clone()));
let queries = Arc::new(AtomicUsize::new(0));
// BEFORE: sync moka get/insert — every cold caller queries.
let sync_cache: moka::sync::Cache<Uuid, oxicloud::domain::entities::user::UserFlags> =
moka::sync::Cache::builder()
.max_capacity(10_000)
.time_to_live(Duration::from_secs(30))
.build();
let t0 = Instant::now();
let mut handles = Vec::new();
for _ in 0..herd {
let cache = sync_cache.clone();
let repo = user_repo.clone();
let queries = queries.clone();
let uid = seeded.user_id;
handles.push(tokio::spawn(async move {
if let Some(f) = cache.get(&uid) {
return f;
}
queries.fetch_add(1, Ordering::Relaxed);
let f = repo.get_user_flags(uid).await.expect("flags");
cache.insert(uid, f);
f
}));
}
let mut before_flags = Vec::new();
for h in handles {
before_flags.push(h.await.unwrap());
}
let before_wall = t0.elapsed().as_secs_f64() * 1e3;
let before_queries = queries.swap(0, Ordering::Relaxed);
// AFTER: future moka try_get_with — one query per herd.
let future_cache: moka::future::Cache<Uuid, oxicloud::domain::entities::user::UserFlags> =
moka::future::Cache::builder()
.max_capacity(10_000)
.time_to_live(Duration::from_secs(30))
.build();
let t0 = Instant::now();
let mut handles = Vec::new();
for _ in 0..herd {
let cache = future_cache.clone();
let repo = user_repo.clone();
let queries = queries.clone();
let uid = seeded.user_id;
handles.push(tokio::spawn(async move {
cache
.try_get_with(uid, async {
queries.fetch_add(1, Ordering::Relaxed);
repo.get_user_flags(uid).await
})
.await
.expect("flags")
}));
}
let mut after_flags = Vec::new();
for h in handles {
after_flags.push(h.await.unwrap());
}
let after_wall = t0.elapsed().as_secs_f64() * 1e3;
let after_queries = queries.load(Ordering::Relaxed);
println!("[4] user-flags cold-cache herd of {herd}");
println!(" BEFORE (get→insert) {before_wall:7.2} ms {before_queries} queries");
println!(" AFTER (try_get_with) {after_wall:7.2} ms {after_queries} queries");
for f in before_flags.iter().chain(&after_flags) {
if *f != before_flags[0] {
eprintln!("GATE FAIL user flags mismatch");
ok = false;
}
}
cleanup(&pool, &seeded).await;
println!(
"\n[gate] {}",
if ok {
"OK (identical result sets)"
} else {
"FAILED"
}
);
if !ok {
std::process::exit(1);
}
}