perf(round25): in-place encrypted decrypt, dedup hash move, dead folder-Query, playlist N+1 fold, contact vcard over-fetch
Five benchmark-gated optimizations from a fresh six-way audit (benches/ROUND25.md), each with a BEFORE/AFTER gate that rolls back if AFTER does not beat BEFORE: - M1 EncryptedBlobBackend::decrypt_bytes: replace split_off (a fresh Vec + full ciphertext memcpy on every decrypted chunk, contradicting its own "in place" doc) with in-place detached decrypt + a zero-copy Bytes::slice past the nonce. Peak RAM per read drops from ~2x to ~1x the payload (-262KB/op at 256KiB; scales with blob size). Plaintext byte-identical; tamper/wrong-key tests pass. - M2 delta commit: move-unzip the owned chunk list instead of a third per-occurrence hash clone (-4000 allocs on a 4000-chunk commit). - M3 folder ZIP download: drop the dead Query<HashMap> extractor it never read (byte-identical response; 5->0 allocs/request). - Q1 public-playlist listing: fold the per-playlist COUNT(*) N+1 into one LEFT JOIN ... GROUP BY via a new inherent repo method (101 -> 1 round-trips, 36x wall on a 100-playlist page). - Q2 contact REST listings (paginated/search/by-group): stop over-fetching the multi-KB vcard TEXT the ContactDto discards, via a shared lite row mapper and narrowed SELECTs (6.4x wall on 1000 contacts with 8KiB vcards). The whole-book vCard export and CardDAV sync paths keep the column. Adds bench_round25_micro (counting allocator tracking count+bytes) and bench_round25_queries (live Postgres), both with equivalence gates and a rollback exit(1). Verified: cargo fmt clean, cargo clippy -D warnings clean, cargo test --lib --features bench = 529 passed / 0 failed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L8gs91AhmazoxMsDcNk3KT
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
//! Round-25 CPU/alloc micro-pack (no Postgres).
|
||||
//!
|
||||
//! Same rule as ROUND2–24: each section is BEFORE (verbatim replica of the
|
||||
//! shipped-before shape) vs AFTER (verbatim replica of the shipped-after shape,
|
||||
//! which the source is then made to match), with a byte/-value equivalence gate
|
||||
//! and a `GATE FAIL … rollback` check that `std::process::exit(1)`s if the AFTER
|
||||
//! arm fails to beat its BEFORE — the round's roll-back rule encoded into the
|
||||
//! benchmark. An AFTER that doesn't win is never applied to the source.
|
||||
//!
|
||||
//! [M1] `EncryptedBlobBackend::decrypt_bytes` decrypts "in place" per its own
|
||||
//! doc comment — but `let mut ciphertext = encrypted.split_off(NONCE_SIZE)`
|
||||
//! allocates a fresh `Vec` and memcpy's the ENTIRE ciphertext+tag (~1 MiB
|
||||
//! per CDC chunk, up to a whole legacy blob) on every decrypted read.
|
||||
//! ROUND11 §15 fixed only the encrypt side. AFTER copies the 12-byte nonce
|
||||
//! and 16-byte tag to the stack, decrypts the middle in place via
|
||||
//! `decrypt_in_place_detached`, and returns a zero-copy `Bytes::slice`
|
||||
//! past the nonce — 0 extra allocations, 0 full-payload memcpy. The RAM
|
||||
//! win is in BYTES: peak drops from ~2× to ~1× the payload.
|
||||
//!
|
||||
//! [M2] Delta commit (`delta_upload_service::commit_with_perms`) materializes
|
||||
//! the per-occurrence chunk-hash list a THIRD time at the manifest bind
|
||||
//! (`request.chunks.iter().map(|c| c.h.clone()).collect()`), even though
|
||||
//! `request.chunks` is owned and dead after that line. AFTER move-unzips
|
||||
//! (`request.chunks.into_iter().map(|c| (c.h, c.s)).unzip()`) — N 64-byte
|
||||
//! hash-String clones → 0.
|
||||
//!
|
||||
//! [M3] `folder_handler::download_folder_zip{,_impl}` binds a
|
||||
//! `Query<HashMap<String, String>>` as `_params` and discards it — pure
|
||||
//! dead work: axum parses the whole query string into a `HashMap` + one
|
||||
//! owned `String` key and value per param, all dropped unread. AFTER
|
||||
//! removes the extractor (byte-identical response; the handler only reads
|
||||
//! the path `id`).
|
||||
//!
|
||||
//! Run:
|
||||
//! RUSTFLAGS="-C target-cpu=x86-64-v3" \
|
||||
//! cargo run --release --features bench --example bench_round25_micro
|
||||
//! Tunables (env): BENCH_ITERS (200000), M1_ITERS (2000), CHUNKS (4000),
|
||||
//! PAYLOAD (262144 bytes for the M1 decrypt payload).
|
||||
|
||||
use std::alloc::{GlobalAlloc, Layout, System};
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::hint::black_box;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use aes_gcm::aead::{AeadInPlace, KeyInit, OsRng};
|
||||
use aes_gcm::{AeadCore, Aes256Gcm, Nonce};
|
||||
use bytes::Bytes;
|
||||
|
||||
// ── Counting allocator: tracks BOTH alloc count and total bytes requested ────
|
||||
static ALLOC_CALLS: AtomicU64 = AtomicU64::new(0);
|
||||
static ALLOC_BYTES: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct CountingAlloc;
|
||||
|
||||
unsafe impl GlobalAlloc for CountingAlloc {
|
||||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||
ALLOC_CALLS.fetch_add(1, Ordering::Relaxed);
|
||||
ALLOC_BYTES.fetch_add(layout.size() as u64, Ordering::Relaxed);
|
||||
unsafe { System.alloc(layout) }
|
||||
}
|
||||
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
||||
unsafe { System.dealloc(ptr, layout) }
|
||||
}
|
||||
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
|
||||
ALLOC_CALLS.fetch_add(1, Ordering::Relaxed);
|
||||
// A realloc that grows requests `new_size` fresh bytes.
|
||||
ALLOC_BYTES.fetch_add(new_size as u64, Ordering::Relaxed);
|
||||
unsafe { System.realloc(ptr, layout, new_size) }
|
||||
}
|
||||
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
|
||||
ALLOC_CALLS.fetch_add(1, Ordering::Relaxed);
|
||||
ALLOC_BYTES.fetch_add(layout.size() as u64, Ordering::Relaxed);
|
||||
unsafe { System.alloc_zeroed(layout) }
|
||||
}
|
||||
}
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: CountingAlloc = CountingAlloc;
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Measure {
|
||||
ns: f64,
|
||||
allocs: f64,
|
||||
bytes: f64,
|
||||
}
|
||||
|
||||
/// Run `f` `iters` times, returning per-op wall ns, alloc count and alloc bytes.
|
||||
fn measure<T>(iters: u64, mut f: impl FnMut() -> T) -> Measure {
|
||||
// warm
|
||||
black_box(f());
|
||||
ALLOC_CALLS.store(0, Ordering::Relaxed);
|
||||
ALLOC_BYTES.store(0, Ordering::Relaxed);
|
||||
let start = Instant::now();
|
||||
for _ in 0..iters {
|
||||
black_box(f());
|
||||
}
|
||||
let ns = start.elapsed().as_nanos() as f64 / iters as f64;
|
||||
let allocs = ALLOC_CALLS.load(Ordering::Relaxed) as f64 / iters as f64;
|
||||
let bytes = ALLOC_BYTES.load(Ordering::Relaxed) as f64 / iters as f64;
|
||||
Measure { ns, allocs, bytes }
|
||||
}
|
||||
|
||||
fn report(tag: &str, before: Measure, after: Measure) {
|
||||
println!("## {tag}");
|
||||
println!("| arm | ns/op | allocs/op | bytes/op |");
|
||||
println!(
|
||||
"| BEFORE | {:>12.1} | {:>11.2} | {:>11.0} |",
|
||||
before.ns, before.allocs, before.bytes
|
||||
);
|
||||
println!(
|
||||
"| AFTER | {:>12.1} | {:>11.2} | {:>11.0} |",
|
||||
after.ns, after.allocs, after.bytes
|
||||
);
|
||||
println!(
|
||||
"# {:.2}x wall · {:.2} fewer allocs/op · {:.0} fewer bytes/op\n",
|
||||
before.ns / after.ns.max(0.0001),
|
||||
before.allocs - after.allocs,
|
||||
before.bytes - after.bytes
|
||||
);
|
||||
}
|
||||
|
||||
/// Roll-back gate: `exit(1)` unless AFTER strictly beats BEFORE on `metric`.
|
||||
fn gate(tag: &str, metric: &str, before: f64, after: f64) {
|
||||
if !(after < before) {
|
||||
eprintln!("GATE FAIL [{tag}] {metric}: AFTER {after} !< BEFORE {before} — rollback");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const NONCE_SIZE: usize = 12;
|
||||
const TAG_SIZE: usize = 16;
|
||||
|
||||
// ── [M1] EncryptedBlobBackend::decrypt_bytes ─────────────────────────────────
|
||||
// Build one ciphertext template `[nonce][ciphertext+tag]` and, per iteration,
|
||||
// clone it (1 alloc, common to both arms) then decrypt via each shape.
|
||||
|
||||
fn build_ciphertext(cipher: &Aes256Gcm, plaintext: &[u8]) -> Vec<u8> {
|
||||
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||
let mut out = Vec::with_capacity(NONCE_SIZE + plaintext.len() + TAG_SIZE);
|
||||
out.extend_from_slice(nonce.as_slice());
|
||||
out.extend_from_slice(plaintext);
|
||||
let tag = cipher
|
||||
.encrypt_in_place_detached(&nonce, b"", &mut out[NONCE_SIZE..])
|
||||
.expect("encrypt");
|
||||
out.extend_from_slice(&tag);
|
||||
out
|
||||
}
|
||||
|
||||
/// BEFORE: the shipped `split_off` shape — one fresh Vec + full memcpy.
|
||||
fn decrypt_before(cipher: &Aes256Gcm, mut encrypted: Vec<u8>) -> Bytes {
|
||||
let mut ciphertext = encrypted.split_off(NONCE_SIZE);
|
||||
let nonce = Nonce::from_slice(&encrypted);
|
||||
cipher
|
||||
.decrypt_in_place(nonce, b"", &mut ciphertext)
|
||||
.expect("decrypt");
|
||||
Bytes::from(ciphertext)
|
||||
}
|
||||
|
||||
/// AFTER: decrypt the middle in place, return a zero-copy slice past the nonce.
|
||||
fn decrypt_after(cipher: &Aes256Gcm, mut encrypted: Vec<u8>) -> Bytes {
|
||||
let len = encrypted.len();
|
||||
let mut nonce_buf = [0u8; NONCE_SIZE];
|
||||
nonce_buf.copy_from_slice(&encrypted[..NONCE_SIZE]);
|
||||
let nonce = Nonce::from_slice(&nonce_buf);
|
||||
let tag = aes_gcm::aead::Tag::<Aes256Gcm>::clone_from_slice(&encrypted[len - TAG_SIZE..]);
|
||||
cipher
|
||||
.decrypt_in_place_detached(nonce, b"", &mut encrypted[NONCE_SIZE..len - TAG_SIZE], &tag)
|
||||
.expect("decrypt");
|
||||
encrypted.truncate(len - TAG_SIZE);
|
||||
Bytes::from(encrypted).slice(NONCE_SIZE..)
|
||||
}
|
||||
|
||||
fn section_m1() {
|
||||
let iters: u64 = env_or("M1_ITERS", 2000);
|
||||
let payload_len: usize = env_or("PAYLOAD", 262_144);
|
||||
let key = [7u8; 32];
|
||||
let cipher = Aes256Gcm::new_from_slice(&key).unwrap();
|
||||
let plaintext: Vec<u8> = (0..payload_len).map(|i| (i * 31 + 7) as u8).collect();
|
||||
let template = build_ciphertext(&cipher, &plaintext);
|
||||
|
||||
// Equivalence: both arms recover the exact plaintext.
|
||||
let a = decrypt_before(&cipher, template.clone());
|
||||
let b = decrypt_after(&cipher, template.clone());
|
||||
assert_eq!(
|
||||
a.as_ref(),
|
||||
plaintext.as_slice(),
|
||||
"M1 BEFORE plaintext mismatch"
|
||||
);
|
||||
assert_eq!(
|
||||
b.as_ref(),
|
||||
plaintext.as_slice(),
|
||||
"M1 AFTER plaintext mismatch"
|
||||
);
|
||||
assert_eq!(a, b, "M1 arms disagree");
|
||||
|
||||
let before = measure(iters, || decrypt_before(&cipher, template.clone()));
|
||||
let after = measure(iters, || decrypt_after(&cipher, template.clone()));
|
||||
report(
|
||||
&format!("[M1] decrypt_bytes in place ({payload_len}-byte payload)"),
|
||||
before,
|
||||
after,
|
||||
);
|
||||
// The RAM win: AFTER must allocate strictly fewer bytes (no ciphertext copy).
|
||||
gate("M1", "bytes/op", before.bytes, after.bytes);
|
||||
}
|
||||
|
||||
// ── [M2] Delta commit chunk-hash list: clone vs move-unzip ───────────────────
|
||||
struct ChunkRefRep {
|
||||
h: String,
|
||||
s: u64,
|
||||
}
|
||||
|
||||
fn hex64(i: usize) -> String {
|
||||
// 64-char hex, deterministic — mirrors a BLAKE3 chunk hash string.
|
||||
let mut s = String::with_capacity(64);
|
||||
for k in 0..32 {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(
|
||||
s,
|
||||
"{:02x}",
|
||||
(i.wrapping_mul(2_654_435_761).wrapping_add(k)) as u8
|
||||
);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn section_m2() {
|
||||
let n: usize = env_or("CHUNKS", 4000);
|
||||
let iters: u64 = env_or("M2_ITERS", 400);
|
||||
|
||||
// Equivalence check on one build.
|
||||
let build = || -> Vec<ChunkRefRep> {
|
||||
(0..n)
|
||||
.map(|i| ChunkRefRep {
|
||||
h: hex64(i),
|
||||
s: (i as u64) * 7,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let cb = build();
|
||||
let before_h: Vec<String> = cb.iter().map(|c| c.h.clone()).collect();
|
||||
let before_s: Vec<u64> = cb.iter().map(|c| c.s).collect();
|
||||
let (after_h, after_s): (Vec<String>, Vec<u64>) =
|
||||
build().into_iter().map(|c| (c.h, c.s)).unzip();
|
||||
assert_eq!(before_h, after_h, "M2 hash arms differ");
|
||||
assert_eq!(before_s, after_s, "M2 size arms differ");
|
||||
|
||||
let before = measure(iters, || {
|
||||
let chunks = build();
|
||||
let hh: Vec<String> = chunks.iter().map(|c| c.h.clone()).collect();
|
||||
let ss: Vec<u64> = chunks.iter().map(|c| c.s).collect();
|
||||
(hh, ss)
|
||||
});
|
||||
let after = measure(iters, || {
|
||||
let chunks = build();
|
||||
let (hh, ss): (Vec<String>, Vec<u64>) = chunks.into_iter().map(|c| (c.h, c.s)).unzip();
|
||||
(hh, ss)
|
||||
});
|
||||
report(
|
||||
&format!("[M2] delta-commit chunk-hash list ({n} chunks)"),
|
||||
before,
|
||||
after,
|
||||
);
|
||||
gate("M2", "allocs/op", before.allocs, after.allocs);
|
||||
}
|
||||
|
||||
// ── [M3] folder_handler dead Query<HashMap> ──────────────────────────────────
|
||||
// Replicates axum's `Query<HashMap<String,String>>` extraction (build an owned
|
||||
// key+value map from the query string) vs no extractor.
|
||||
fn parse_query_map(q: &str) -> HashMap<String, String> {
|
||||
let mut m = HashMap::new();
|
||||
for pair in q.split('&') {
|
||||
if let Some((k, v)) = pair.split_once('=') {
|
||||
m.insert(k.to_string(), v.to_string());
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
fn section_m3() {
|
||||
let iters: u64 = env_or("BENCH_ITERS", 200_000);
|
||||
// A representative query string a client might append (cache-buster etc.).
|
||||
let q = "folder_id=8c1f0e2a-1234-4a5b-9c8d-abcdef012345&t=1720000000";
|
||||
|
||||
// Equivalence: the handler only ever needs the path id, never these params.
|
||||
let before_map = parse_query_map(q);
|
||||
assert!(before_map.contains_key("folder_id"), "M3 setup");
|
||||
|
||||
let before = measure(iters, || {
|
||||
// BEFORE: axum builds and drops the map on every request.
|
||||
let m = parse_query_map(black_box(q));
|
||||
black_box(m.len())
|
||||
});
|
||||
let after = measure(iters, || {
|
||||
// AFTER: no extractor — nothing parsed.
|
||||
black_box(())
|
||||
});
|
||||
report("[M3] folder download dead Query<HashMap>", before, after);
|
||||
gate("M3", "allocs/op", before.allocs, after.allocs);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println!("# Round-25 micro alloc/RAM pack\n");
|
||||
section_m1();
|
||||
section_m2();
|
||||
section_m3();
|
||||
println!("All Round-25 micro sections passed their gate.");
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
//! Round-25 PostgreSQL query-shape pack — end-to-end round-trips + wall on the
|
||||
//! live dev Postgres, with an equivalence gate (mismatch → `exit(1)`) mirroring
|
||||
//! ROUND23's methodology.
|
||||
//!
|
||||
//! [Q1] `music_storage_adapter::list_public_playlists` is 1 + N round-trips:
|
||||
//! one listing SELECT then one `SELECT COUNT(*) FROM audio.playlist_items`
|
||||
//! per returned playlist (up to 101 at limit=100). AFTER folds the count
|
||||
//! into the listing with a `LEFT JOIN … GROUP BY` — one round-trip.
|
||||
//! Gate: AFTER wall < BEFORE wall AND identical (playlist → track_count).
|
||||
//!
|
||||
//! [Q2] The three REST contact listings `SELECT … vcard …` — the multi-KB
|
||||
//! vCard TEXT (may embed a base64 PHOTO) — but every caller maps
|
||||
//! Contact → ContactDto, which has NO vcard field, so it is fetched,
|
||||
//! shipped over the wire, decoded into a String and dropped. AFTER omits
|
||||
//! the vcard column (a lite mapper passes an empty string). Gate: AFTER
|
||||
//! wall < BEFORE wall AND identical (id, full_name, photo_url) DTO fields.
|
||||
//!
|
||||
//! Run (needs the dev Postgres up; reads DATABASE_URL from .env):
|
||||
//! RUSTFLAGS="-C target-cpu=x86-64-v3" \
|
||||
//! cargo run --release --features bench --example bench_round25_queries
|
||||
//! Tunables (env): Q1_PLAYLISTS (100), Q1_PASSES (30),
|
||||
//! Q2_CONTACTS (1000), Q2_PASSES (20), Q2_VCARD_KB (8)
|
||||
|
||||
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 p50(mut s: Vec<f64>) -> f64 {
|
||||
s.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
s[s.len() / 2]
|
||||
}
|
||||
|
||||
fn report(tag: &str, unit: &str, before: f64, after: f64, stmts_before: usize, stmts_after: usize) {
|
||||
println!("## {tag}");
|
||||
println!("| arm | {unit:>16} | statements |");
|
||||
println!("| BEFORE | {before:>16.3} | {stmts_before:>10} |");
|
||||
println!("| AFTER | {after:>16.3} | {stmts_after:>10} |");
|
||||
println!(
|
||||
"# {:.2}x wall · {} → {} round-trips\n",
|
||||
before / after.max(1e-9),
|
||||
stmts_before,
|
||||
stmts_after
|
||||
);
|
||||
}
|
||||
|
||||
fn gate(tag: &str, metric: &str, before: f64, after: f64) {
|
||||
if !(after < before) {
|
||||
eprintln!("GATE FAIL [{tag}] {metric}: AFTER {after} !< BEFORE {before} — rollback");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup(pool: &PgPool) {
|
||||
// Idempotent teardown (also clears fixtures a prior crashed run left).
|
||||
let _ = sqlx::query("SET session_replication_role = default")
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM audio.playlist_items WHERE playlist_id IN (SELECT id FROM audio.playlists WHERE name LIKE 'bench25_pl_%')").execute(pool).await;
|
||||
let _ = sqlx::query("DELETE FROM audio.playlists WHERE name LIKE 'bench25_pl_%'")
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM carddav.contacts WHERE uid LIKE 'bench25-%'")
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM carddav.address_books WHERE name = 'bench25_ab'")
|
||||
.execute(pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM auth.users WHERE email LIKE 'bench25-%@bench.invalid'")
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn seed_user(pool: &PgPool, tag: &str) -> Uuid {
|
||||
sqlx::query_scalar(
|
||||
"INSERT INTO auth.users (username, email, role) VALUES ($1, $2, 'user') RETURNING id",
|
||||
)
|
||||
.bind(format!("bench25_{tag}"))
|
||||
.bind(format!("bench25-{tag}@bench.invalid"))
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("seed user")
|
||||
}
|
||||
|
||||
// ── [Q1] Public-playlist listing: 1 + N COUNT vs one LEFT JOIN GROUP BY ───────
|
||||
async fn section_q1(pool: &PgPool) {
|
||||
let n: usize = env_or("Q1_PLAYLISTS", 100);
|
||||
let passes: usize = env_or("Q1_PASSES", 30);
|
||||
let owner = seed_user(pool, "q1owner").await;
|
||||
|
||||
// Seed N public playlists, playlist i carrying (i % 10) + 1 items. The
|
||||
// playlist_items.file_id FK to storage.files is bypassed with replica role
|
||||
// (superuser) so the query SHAPE can be isolated without a files fixture.
|
||||
let mut conn = pool.acquire().await.expect("acquire");
|
||||
sqlx::query("SET session_replication_role = replica")
|
||||
.execute(&mut *conn)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut ids: Vec<Uuid> = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let pid: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO audio.playlists (id, name, owner_id, is_public)
|
||||
VALUES (gen_random_uuid(), $1, $2, TRUE) RETURNING id",
|
||||
)
|
||||
.bind(format!("bench25_pl_{i}"))
|
||||
.bind(owner)
|
||||
.fetch_one(&mut *conn)
|
||||
.await
|
||||
.expect("seed playlist");
|
||||
ids.push(pid);
|
||||
for j in 0..((i % 10) + 1) {
|
||||
sqlx::query(
|
||||
"INSERT INTO audio.playlist_items (id, playlist_id, file_id, position)
|
||||
VALUES (gen_random_uuid(), $1, gen_random_uuid(), $2)",
|
||||
)
|
||||
.bind(pid)
|
||||
.bind(j as i32)
|
||||
.execute(&mut *conn)
|
||||
.await
|
||||
.expect("seed item");
|
||||
}
|
||||
}
|
||||
sqlx::query("SET session_replication_role = default")
|
||||
.execute(&mut *conn)
|
||||
.await
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let limit = n as i64;
|
||||
|
||||
// BEFORE: list (1) then one COUNT per playlist (N) → 1 + N round-trips.
|
||||
let before_counts = {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id FROM audio.playlists WHERE is_public = TRUE ORDER BY updated_at DESC LIMIT $1 OFFSET 0",
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut m: Vec<(Uuid, i64)> = Vec::with_capacity(rows.len());
|
||||
for r in &rows {
|
||||
let pid: Uuid = r.get(0);
|
||||
let c: (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM audio.playlist_items WHERE playlist_id = $1")
|
||||
.bind(pid)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
m.push((pid, c.0));
|
||||
}
|
||||
m.sort();
|
||||
m
|
||||
};
|
||||
|
||||
// AFTER: one LEFT JOIN + GROUP BY → 1 round-trip.
|
||||
let after_counts = {
|
||||
let rows = sqlx::query(
|
||||
"SELECT p.id, COUNT(pi.id) AS track_count
|
||||
FROM audio.playlists p
|
||||
LEFT JOIN audio.playlist_items pi ON pi.playlist_id = p.id
|
||||
WHERE p.is_public = TRUE
|
||||
GROUP BY p.id
|
||||
ORDER BY p.updated_at DESC LIMIT $1 OFFSET 0",
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut m: Vec<(Uuid, i64)> = rows
|
||||
.iter()
|
||||
.map(|r| (r.get::<Uuid, _>(0), r.get::<i64, _>(1)))
|
||||
.collect();
|
||||
m.sort();
|
||||
m
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
before_counts, after_counts,
|
||||
"Q1 track_count mismatch BEFORE vs AFTER"
|
||||
);
|
||||
|
||||
// Timed passes.
|
||||
let mut before_ms = Vec::new();
|
||||
let mut after_ms = Vec::new();
|
||||
for _ in 0..passes {
|
||||
let t = Instant::now();
|
||||
let rows = sqlx::query("SELECT id FROM audio.playlists WHERE is_public = TRUE ORDER BY updated_at DESC LIMIT $1 OFFSET 0").bind(limit).fetch_all(pool).await.unwrap();
|
||||
for r in &rows {
|
||||
let pid: Uuid = r.get(0);
|
||||
let _c: (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM audio.playlist_items WHERE playlist_id = $1")
|
||||
.bind(pid)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
before_ms.push(t.elapsed().as_secs_f64() * 1e3);
|
||||
|
||||
let t = Instant::now();
|
||||
let _rows = sqlx::query("SELECT p.id, COUNT(pi.id) FROM audio.playlists p LEFT JOIN audio.playlist_items pi ON pi.playlist_id = p.id WHERE p.is_public = TRUE GROUP BY p.id ORDER BY p.updated_at DESC LIMIT $1 OFFSET 0").bind(limit).fetch_all(pool).await.unwrap();
|
||||
after_ms.push(t.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
let b = p50(before_ms);
|
||||
let a = p50(after_ms);
|
||||
report(
|
||||
&format!("[Q1] public-playlist listing ({n} playlists)"),
|
||||
"p50 ms",
|
||||
b,
|
||||
a,
|
||||
1 + n,
|
||||
1,
|
||||
);
|
||||
gate("Q1", "p50 ms", b, a);
|
||||
}
|
||||
|
||||
// ── [Q2] Contact listing: over-fetch vcard TEXT vs lite (no vcard) ────────────
|
||||
async fn section_q2(pool: &PgPool) {
|
||||
let n: usize = env_or("Q2_CONTACTS", 1000);
|
||||
let passes: usize = env_or("Q2_PASSES", 20);
|
||||
let vcard_kb: usize = env_or("Q2_VCARD_KB", 8);
|
||||
let owner = seed_user(pool, "q2owner").await;
|
||||
let ab: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO carddav.address_books (id, name, owner_id) VALUES (gen_random_uuid(), 'bench25_ab', $1) RETURNING id",
|
||||
)
|
||||
.bind(owner)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("seed address book");
|
||||
|
||||
// A realistic vCard body with an embedded base64 PHOTO of ~vcard_kb KiB.
|
||||
let photo_blob = "A".repeat(vcard_kb * 1024);
|
||||
for i in 0..n {
|
||||
let vcard = format!(
|
||||
"BEGIN:VCARD\nVERSION:3.0\nFN:Contact {i}\nEMAIL:c{i}@example.com\nPHOTO;ENCODING=b;TYPE=JPEG:{photo_blob}\nEND:VCARD"
|
||||
);
|
||||
sqlx::query(
|
||||
"INSERT INTO carddav.contacts (id, address_book_id, uid, full_name, photo_url, email, phone, address, vcard, etag)
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, $4, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, $5, $6)",
|
||||
)
|
||||
.bind(ab)
|
||||
.bind(format!("bench25-{i}"))
|
||||
.bind(format!("Contact {i}"))
|
||||
.bind(format!("https://example.com/p/{i}.jpg"))
|
||||
.bind(&vcard)
|
||||
.bind(format!("etag{i}"))
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("seed contact");
|
||||
}
|
||||
|
||||
// Lite DTO shape the REST listing actually keeps.
|
||||
#[derive(PartialEq, Debug)]
|
||||
struct LiteDto {
|
||||
id: Uuid,
|
||||
full_name: Option<String>,
|
||||
photo_url: Option<String>,
|
||||
}
|
||||
|
||||
let before_select = "SELECT id, full_name, photo_url, vcard FROM carddav.contacts WHERE address_book_id = $1 ORDER BY full_name LIMIT $2";
|
||||
let after_select = "SELECT id, full_name, photo_url FROM carddav.contacts WHERE address_book_id = $1 ORDER BY full_name LIMIT $2";
|
||||
let limit = n as i64;
|
||||
|
||||
// Equivalence: the kept DTO fields are identical whether or not vcard is read.
|
||||
let before_dtos: Vec<LiteDto> = {
|
||||
let rows = sqlx::query(before_select)
|
||||
.bind(ab)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
rows.iter()
|
||||
.map(|r| {
|
||||
let _vcard: Option<String> = r.get("vcard"); // fetched + decoded, then dropped
|
||||
LiteDto {
|
||||
id: r.get("id"),
|
||||
full_name: r.get("full_name"),
|
||||
photo_url: r.get("photo_url"),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let after_dtos: Vec<LiteDto> = {
|
||||
let rows = sqlx::query(after_select)
|
||||
.bind(ab)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
rows.iter()
|
||||
.map(|r| LiteDto {
|
||||
id: r.get("id"),
|
||||
full_name: r.get("full_name"),
|
||||
photo_url: r.get("photo_url"),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(
|
||||
before_dtos, after_dtos,
|
||||
"Q2 DTO fields mismatch BEFORE vs AFTER"
|
||||
);
|
||||
|
||||
let mut before_ms = Vec::new();
|
||||
let mut after_ms = Vec::new();
|
||||
for _ in 0..passes {
|
||||
let t = Instant::now();
|
||||
let rows = sqlx::query(before_select)
|
||||
.bind(ab)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut sink = 0usize;
|
||||
for r in &rows {
|
||||
let v: Option<String> = r.get("vcard");
|
||||
sink += v.map(|s| s.len()).unwrap_or(0);
|
||||
let _d = LiteDto {
|
||||
id: r.get("id"),
|
||||
full_name: r.get("full_name"),
|
||||
photo_url: r.get("photo_url"),
|
||||
};
|
||||
}
|
||||
std::hint::black_box(sink);
|
||||
before_ms.push(t.elapsed().as_secs_f64() * 1e3);
|
||||
|
||||
let t = Instant::now();
|
||||
let rows = sqlx::query(after_select)
|
||||
.bind(ab)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
for r in &rows {
|
||||
let _d = LiteDto {
|
||||
id: r.get("id"),
|
||||
full_name: r.get("full_name"),
|
||||
photo_url: r.get("photo_url"),
|
||||
};
|
||||
}
|
||||
after_ms.push(t.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
let b = p50(before_ms);
|
||||
let a = p50(after_ms);
|
||||
report(
|
||||
&format!("[Q2] contact listing over-fetch vcard ({n} contacts, {vcard_kb} KiB vcard)"),
|
||||
"p50 ms",
|
||||
b,
|
||||
a,
|
||||
1,
|
||||
1,
|
||||
);
|
||||
gate("Q2", "p50 ms", b, a);
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
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 pool = PgPoolOptions::new()
|
||||
.max_connections(4)
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect Postgres");
|
||||
|
||||
println!("# Round-25 PG query-shape pack — BEFORE/AFTER (live Postgres)\n");
|
||||
cleanup(&pool).await;
|
||||
section_q1(&pool).await;
|
||||
section_q2(&pool).await;
|
||||
cleanup(&pool).await;
|
||||
println!("All Round-25 query sections passed their gate.");
|
||||
}
|
||||
Reference in New Issue
Block a user