Files
Oxicloud/examples/bench_round14_queries.rs
T
Claude c930f865b0 perf: round 14 — faces narrow projection, auth per-request allocs, CalDAV emit buffers, frontend set churn
Benchmark-gated (benches/ROUND14.md); every change ships a BEFORE/AFTER
benchmark with an equivalence gate and is rolled back on regression (the
rule is encoded as a GATE FAIL exit / threshold expect).

Backend
- Q1 faces_for_file → narrow face_boxes_for_file(id, person_id, bbox) with the
  caller filter pushed into SQL: drops the 2 KiB embedding BYTEA + 6 unused
  columns per face. 15-face lightbox open 0.312→0.219 ms, 32 KB→840 B/req.
- A1 cookie auth uses the borrow-only extract_cookie_str (already backs CSRF)
  instead of extract_cookie_value's owned String: -1 alloc/cookie request.
- A2 compute_relevance ASCII case-fold fast path vs name.to_lowercase() per
  result row (Unicode fallback preserved): 1.40x, 12→3 allocs/page.
- A3 sub pre-parsed to Uuid at decode time (TokenClaims.sub_id) vs re-parsing
  the 36-char claim on every request incl. cache hits: 22.7→0.7 ns.
- A4 auth + NextCloud middlewares borrow request.headers() instead of taking
  axum's HeaderMap extractor (a full map clone): 2→0 allocs/authed request.
- A5 CalDAV getlastmodified via the stack rfc2822_utc (byte-identical to
  chrono) vs a per-event to_rfc2822() heap String: 5→0 allocs.
- A6 CalDAV per-event href + quoted etag written into reused page buffers vs a
  fresh format! pair per event: 3.48x, 240→6 allocs/40-event page.

Frontend
- F1 t() shares one frozen EMPTY_PARAMS for the no-interpolation call forms vs
  a throwaway {} per call: -1 alloc/call.
- F2 favorites favoriteIds is a persistent SvelteSet with per-page add (clear
  on reset) vs a brand-new set over the whole accumulated list each page:
  22.3x over a 40-page drain (O(N^2)→O(N)).

Verified: cargo check --all-targets, cargo clippy -D warnings, both bench
packs (GATE PASS), frontend npm run check + vitest (4/4). ROUND14.md also
records the investigated-but-deferred backlog (music N+1, contact vcard
over-fetch, CachedBlobBackend syscalls, ResourceList.sections builder, etc.).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PymgCdK78NzUF3oRAQCJfN
2026-07-19 10:22:12 +00:00

247 lines
9.3 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.
//! Round-14 query-shape pack (needs the dev Postgres up; reads DATABASE_URL
//! from `.env`).
//!
//! Each section is BEFORE (verbatim replica of the shipped query shape) vs
//! AFTER (proposed shape), with an equivalence/safety gate and a `GATE FAIL`
//! rollback check — an AFTER that doesn't beat its BEFORE exits non-zero.
//!
//! [Q1] Lightbox face boxes — `faces_for_file`'s 10-column row (incl. the
//! 2,048-byte `embedding` BYTEA, decoded into a `Vec<f32>` per face)
//! hydrated for a group photo, then filtered `user_id == caller` in
//! Rust, vs a narrow `SELECT id, person_id, bbox … WHERE file_id = $1
//! AND user_id = $2` (embedding + 6 unused columns dropped; the caller
//! filter pushed into SQL). The only consumer, `people_service::
//! faces_for_file`, builds `FaceBoxDto { id, person_id, x,y,w,h }`.
//!
//! Run:
//! cargo run --release --features bench --example bench_round14_queries
//! Tunables (env): BENCH_PASSES (200), BENCH_FACES_PER_FILE (15)
use std::env;
use std::time::Instant;
use sqlx::postgres::PgPoolOptions;
use sqlx::{PgPool, Row};
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)
}
fn stats(mut s: Vec<f64>) -> (f64, f64, f64) {
s.sort_by(|a, b| a.partial_cmp(b).unwrap());
let n = s.len();
(
s.iter().sum::<f64>() / n as f64,
s[n / 2],
s[((n as f64 * 0.95) as usize).min(n - 1)],
)
}
/// Mirror of `face_pg_repository::bytes_to_embedding` — the per-face
/// `Vec<f32>` decode the BEFORE path pays for a column it never reads.
fn bytes_to_embedding(b: &[u8]) -> Vec<f32> {
b.chunks_exact(4)
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
.collect()
}
// ────────────────────────────────────────────────────────────────────────────
// [Q1] Lightbox face boxes — wide row (incl. embedding) vs narrow projection
// ────────────────────────────────────────────────────────────────────────────
/// BEFORE, verbatim `faces_for_file` + `people_service::faces_for_file`:
/// hydrate the full 10-column row (decoding the 2 KiB embedding like
/// `row_to_face`), then filter `user_id == caller` in Rust and keep only
/// `(id, person_id, bbox)`.
async fn boxes_before(pool: &PgPool, file_id: Uuid, caller: Uuid) -> Vec<(Uuid, Option<Uuid>, Vec<f32>)> {
let rows = sqlx::query(
"SELECT id, file_id, user_id, person_id, bbox, det_score, quality, embedding, blob_hash, created_at
FROM faces.faces WHERE file_id = $1",
)
.bind(file_id)
.fetch_all(pool)
.await
.expect("faces wide");
rows.into_iter()
.filter_map(|r| {
let user_id: Uuid = r.get("user_id");
// Decode the embedding exactly as `row_to_face` does (the cost the
// BEFORE path pays even though `FaceBoxDto` never reads it).
let emb_bytes: Vec<u8> = r.get("embedding");
let _embedding = bytes_to_embedding(&emb_bytes);
if user_id != caller {
return None;
}
let bbox: Vec<f32> = r.get("bbox");
Some((r.get("id"), r.get("person_id"), bbox))
})
.collect()
}
/// AFTER: narrow projection, caller filter in SQL.
async fn boxes_after(pool: &PgPool, file_id: Uuid, caller: Uuid) -> Vec<(Uuid, Option<Uuid>, Vec<f32>)> {
let rows = sqlx::query(
"SELECT id, person_id, bbox FROM faces.faces WHERE file_id = $1 AND user_id = $2",
)
.bind(file_id)
.bind(caller)
.fetch_all(pool)
.await
.expect("faces narrow");
rows.into_iter()
.map(|r| {
let bbox: Vec<f32> = r.get("bbox");
(r.get("id"), r.get("person_id"), bbox)
})
.collect()
}
async fn section_face_boxes(pool: &PgPool) {
let n: usize = env_or("BENCH_FACES_PER_FILE", 15);
let passes: usize = env_or("BENCH_PASSES", 200);
// Seed: user + drive + folder + one photo file + N faces on it.
let mut tx = pool.begin().await.expect("begin");
let user_id: Uuid = sqlx::query_scalar(
"INSERT INTO auth.users (username, email, role)
VALUES ('bench14_faces', 'bench14_faces@bench.invalid', 'user') RETURNING id",
)
.fetch_one(&mut *tx)
.await
.expect("user");
let drive_id: Uuid = sqlx::query_scalar(
"INSERT INTO storage.drives (kind, default_for_user) VALUES ('personal', $1) RETURNING id",
)
.bind(user_id)
.fetch_one(&mut *tx)
.await
.expect("drive");
let folder_id: Uuid = sqlx::query_scalar(
"INSERT INTO storage.folders (name, path, lpath, drive_id)
VALUES ('bench14', '/bench14', 'bench14', $1) RETURNING id",
)
.bind(drive_id)
.fetch_one(&mut *tx)
.await
.expect("folder");
sqlx::query("UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2")
.bind(folder_id)
.bind(drive_id)
.execute(&mut *tx)
.await
.expect("stamp root");
let file_id: Uuid = sqlx::query_scalar(
"INSERT INTO storage.files (name, folder_id, blob_hash, size, mime_type, drive_id)
VALUES ('group.jpg', $1, 'bench14blob00000000000000000000000000000000000000000000000000', 1024, 'image/jpeg', $2)
RETURNING id",
)
.bind(folder_id)
.bind(drive_id)
.fetch_one(&mut *tx)
.await
.expect("file");
tx.commit().await.expect("commit");
let person_id: Uuid = sqlx::query_scalar(
"INSERT INTO faces.persons (user_id, display_name) VALUES ($1, 'P') RETURNING id",
)
.bind(user_id)
.fetch_one(pool)
.await
.expect("person");
let embedding = vec![7u8; 2048]; // 512 × f32, like the real thing
for i in 0..n {
// Half the faces are named, half unassigned — exercises Option<Uuid>.
let pid = if i % 2 == 0 { Some(person_id) } else { None };
sqlx::query(
"INSERT INTO faces.faces
(file_id, user_id, person_id, bbox, det_score, quality, embedding, blob_hash)
VALUES ($1, $2, $3, ARRAY[0.1,0.2,0.3,0.4]::real[], 0.99, 0.9, $4, NULL)",
)
.bind(file_id)
.bind(user_id)
.bind(pid)
.bind(&embedding)
.execute(pool)
.await
.expect("face");
}
sqlx::query("ANALYZE faces.faces").execute(pool).await.ok();
// Equivalence gate: same (id, person_id, bbox) set both ways, all N present.
let mut b = boxes_before(pool, file_id, user_id).await;
let mut a = boxes_after(pool, file_id, user_id).await;
b.sort_by(|x, y| x.0.cmp(&y.0));
a.sort_by(|x, y| x.0.cmp(&y.0));
assert_eq!(b, a, "face-box projections differ");
assert_eq!(a.len(), n, "expected all faces");
println!("# [Q1] gate: wide/narrow face-box sets identical ({n} faces) — OK");
let mut wide = Vec::with_capacity(passes);
for _ in 0..passes {
let t = Instant::now();
std::hint::black_box(boxes_before(pool, file_id, user_id).await);
wide.push(t.elapsed().as_secs_f64() * 1e3);
}
let mut narrow = Vec::with_capacity(passes);
for _ in 0..passes {
let t = Instant::now();
std::hint::black_box(boxes_after(pool, file_id, user_id).await);
narrow.push(t.elapsed().as_secs_f64() * 1e3);
}
let (wm, wp50, wp95) = stats(wide);
let (nm, np50, np95) = stats(narrow);
let wire_before = n * (2048 + 16 * 4 + 24); // embedding + uuids/bbox + row overhead
let wire_after = n * (16 + 16 + 16 + 8);
println!("\n## [Q1] Lightbox face boxes — group photo, {n} faces");
println!("| arm | mean ms | p50 ms | p95 ms | ~bytes/req |");
println!("| BEFORE wide row (incl. embedding) | {wm:>7.3} | {wp50:>6.3} | {wp95:>6.3} | {wire_before:>9} |");
println!("| AFTER narrow (id,person,bbox) | {nm:>7.3} | {np50:>6.3} | {np95:>6.3} | {wire_after:>9} |");
println!(
"# {:.2}x faster; ~{} KiB embedding/columns off the wire per lightbox open (scales with face count)",
wm / nm,
(wire_before - wire_after) / 1024
);
// Cleanup (cascades faces + persons via FKs on drive/user delete).
sqlx::query("DELETE FROM storage.drives WHERE id = $1")
.bind(drive_id)
.execute(pool)
.await
.ok();
sqlx::query("DELETE FROM auth.users WHERE id = $1")
.bind(user_id)
.execute(pool)
.await
.ok();
if nm >= wm {
eprintln!("GATE FAIL [Q1]: narrow projection not faster — rollback");
std::process::exit(1);
}
}
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() {
let _ = dotenvy::dotenv();
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL required (see .env)");
let pool = PgPoolOptions::new()
.max_connections(8)
.connect(&url)
.await
.expect("connect");
println!("#################################################################");
println!("# Round-14 query-shape pack");
println!("#################################################################");
section_face_boxes(&pool).await;
println!("\nGATE PASS (all sections)");
}