|
| 1 | +//! Blob-manifest read benchmark — `read_blob_bytes` manifest round-trips. |
| 2 | +//! |
| 3 | +//! `read_blob_bytes` used to read the same `storage.chunk_manifests` PK row |
| 4 | +//! TWICE per full-blob read: once via `blob_size` (`SELECT total_size`) and once |
| 5 | +//! via `read_blob_stream` (`SELECT chunk_hashes`). On the thumbnail cold path |
| 6 | +//! that is 2N manifest queries for an N-image gallery load. The change folds both |
| 7 | +//! into ONE query (`SELECT chunk_hashes, total_size`). |
| 8 | +//! |
| 9 | +//! This isolates exactly that change — the two manifest lookups vs the one — and |
| 10 | +//! leaves the (unchanged) chunk streaming out, so the signal is the DB |
| 11 | +//! round-trip(s) per blob read. Runs OLD (2 queries) vs NEW (1 query) against the |
| 12 | +//! real dev Postgres, at low contention (raw per-op latency) and high contention |
| 13 | +//! (concurrency > pool, where holding a connection ~2× longer inflates the tail). |
| 14 | +//! |
| 15 | +//! Run (needs the dev Postgres up; reads DATABASE_URL from .env): |
| 16 | +//! cargo run --release --features bench --example bench_blob_manifest |
| 17 | +//! Tunables (env): BENCH_POOL (20), BENCH_SECONDS (4), BENCH_CHUNKS (64), |
| 18 | +//! BENCH_CONCURRENCIES ("4,64"). |
| 19 | +
|
| 20 | +use std::env; |
| 21 | +use std::sync::Arc; |
| 22 | +use std::time::{Duration, Instant}; |
| 23 | + |
| 24 | +use sqlx::PgPool; |
| 25 | +use sqlx::postgres::PgPoolOptions; |
| 26 | + |
| 27 | +/// Synthetic manifest key: exactly 64 chars (the VARCHAR(64) PK width), and the |
| 28 | +/// non-hex letters ('n','s','h') guarantee it can never collide with a real |
| 29 | +/// BLAKE3 blob hash (always lowercase hex). 8 × "bench000". |
| 30 | +const FILE_HASH: &str = "bench000bench000bench000bench000bench000bench000bench000bench000"; |
| 31 | + |
| 32 | +fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T { |
| 33 | + env::var(key) |
| 34 | + .ok() |
| 35 | + .and_then(|v| v.parse().ok()) |
| 36 | + .unwrap_or(default) |
| 37 | +} |
| 38 | + |
| 39 | +#[derive(Clone, Copy)] |
| 40 | +enum Mode { |
| 41 | + /// Two separate manifest lookups (the old blob_size + read_blob_stream). |
| 42 | + Old, |
| 43 | + /// One combined manifest lookup (the new read_blob_bytes). |
| 44 | + New, |
| 45 | +} |
| 46 | + |
| 47 | +async fn seed(pool: &PgPool, n_chunks: usize) { |
| 48 | + let chunk_hashes: Vec<String> = (0..n_chunks).map(|i| format!("{i:064x}")).collect(); |
| 49 | + let chunk_sizes: Vec<i64> = vec![65_536; n_chunks]; |
| 50 | + let total: i64 = chunk_sizes.iter().sum(); |
| 51 | + sqlx::query( |
| 52 | + "INSERT INTO storage.chunk_manifests |
| 53 | + (file_hash, chunk_hashes, chunk_sizes, total_size, chunk_count) |
| 54 | + VALUES ($1, $2, $3, $4, $5) |
| 55 | + ON CONFLICT (file_hash) DO UPDATE |
| 56 | + SET chunk_hashes = $2, chunk_sizes = $3, total_size = $4, chunk_count = $5", |
| 57 | + ) |
| 58 | + .bind(FILE_HASH) |
| 59 | + .bind(&chunk_hashes) |
| 60 | + .bind(&chunk_sizes) |
| 61 | + .bind(total) |
| 62 | + .bind(n_chunks as i32) |
| 63 | + .execute(pool) |
| 64 | + .await |
| 65 | + .expect("seed chunk_manifests row"); |
| 66 | +} |
| 67 | + |
| 68 | +async fn cleanup(pool: &PgPool) { |
| 69 | + let _ = sqlx::query("DELETE FROM storage.chunk_manifests WHERE file_hash = $1") |
| 70 | + .bind(FILE_HASH) |
| 71 | + .execute(pool) |
| 72 | + .await; |
| 73 | +} |
| 74 | + |
| 75 | +/// One blob "manifest read" — exactly the queries the production code issues. |
| 76 | +async fn one_op(pool: &PgPool, mode: Mode) { |
| 77 | + match mode { |
| 78 | + Mode::Old => { |
| 79 | + let _total: i64 = sqlx::query_scalar( |
| 80 | + "SELECT total_size FROM storage.chunk_manifests WHERE file_hash = $1", |
| 81 | + ) |
| 82 | + .bind(FILE_HASH) |
| 83 | + .fetch_one(pool) |
| 84 | + .await |
| 85 | + .expect("old total_size query"); |
| 86 | + let _chunks: Vec<String> = sqlx::query_scalar( |
| 87 | + "SELECT chunk_hashes FROM storage.chunk_manifests WHERE file_hash = $1", |
| 88 | + ) |
| 89 | + .bind(FILE_HASH) |
| 90 | + .fetch_one(pool) |
| 91 | + .await |
| 92 | + .expect("old chunk_hashes query"); |
| 93 | + } |
| 94 | + Mode::New => { |
| 95 | + let _row: (Vec<String>, i64) = sqlx::query_as( |
| 96 | + "SELECT chunk_hashes, total_size FROM storage.chunk_manifests WHERE file_hash = $1", |
| 97 | + ) |
| 98 | + .bind(FILE_HASH) |
| 99 | + .fetch_one(pool) |
| 100 | + .await |
| 101 | + .expect("new combined query"); |
| 102 | + } |
| 103 | + } |
| 104 | +} |
| 105 | + |
| 106 | +struct Stats { |
| 107 | + count: usize, |
| 108 | + rps: f64, |
| 109 | + p50: f64, |
| 110 | + p95: f64, |
| 111 | + p99: f64, |
| 112 | + max: f64, |
| 113 | +} |
| 114 | + |
| 115 | +fn summarize(mut lats: Vec<f64>, secs: u64) -> Stats { |
| 116 | + lats.sort_by(|a, b| a.partial_cmp(b).unwrap()); |
| 117 | + let n = lats.len(); |
| 118 | + let pct = |p: f64| { |
| 119 | + if n == 0 { |
| 120 | + 0.0 |
| 121 | + } else { |
| 122 | + lats[((n as f64 * p) as usize).min(n - 1)] |
| 123 | + } |
| 124 | + }; |
| 125 | + Stats { |
| 126 | + count: n, |
| 127 | + rps: n as f64 / secs as f64, |
| 128 | + p50: pct(0.50), |
| 129 | + p95: pct(0.95), |
| 130 | + p99: pct(0.99), |
| 131 | + max: lats.last().copied().unwrap_or(0.0), |
| 132 | + } |
| 133 | +} |
| 134 | + |
| 135 | +async fn run_window(pool: Arc<PgPool>, concurrency: usize, secs: u64, mode: Mode) -> Stats { |
| 136 | + let deadline = Instant::now() + Duration::from_secs(secs); |
| 137 | + let mut handles = Vec::with_capacity(concurrency); |
| 138 | + for _ in 0..concurrency { |
| 139 | + let pool = pool.clone(); |
| 140 | + handles.push(tokio::spawn(async move { |
| 141 | + let mut lats = Vec::new(); |
| 142 | + while Instant::now() < deadline { |
| 143 | + let t = Instant::now(); |
| 144 | + one_op(&pool, mode).await; |
| 145 | + lats.push(t.elapsed().as_secs_f64() * 1000.0); |
| 146 | + } |
| 147 | + lats |
| 148 | + })); |
| 149 | + } |
| 150 | + let mut all = Vec::new(); |
| 151 | + for h in handles { |
| 152 | + all.extend(h.await.unwrap()); |
| 153 | + } |
| 154 | + summarize(all, secs) |
| 155 | +} |
| 156 | + |
| 157 | +#[tokio::main(flavor = "multi_thread")] |
| 158 | +async fn main() { |
| 159 | + dotenvy::dotenv().ok(); |
| 160 | + let url = env::var("DATABASE_URL") |
| 161 | + .or_else(|_| env::var("OXICLOUD_DB_CONNECTION_STRING")) |
| 162 | + .expect("set DATABASE_URL (or OXICLOUD_DB_CONNECTION_STRING) — the dev Postgres URL"); |
| 163 | + |
| 164 | + let pool_size: u32 = env_or("BENCH_POOL", 20); |
| 165 | + let secs: u64 = env_or("BENCH_SECONDS", 4); |
| 166 | + let n_chunks: usize = env_or("BENCH_CHUNKS", 64); |
| 167 | + let concurrencies: Vec<usize> = env::var("BENCH_CONCURRENCIES") |
| 168 | + .ok() |
| 169 | + .map(|s| s.split(',').filter_map(|x| x.trim().parse().ok()).collect()) |
| 170 | + .unwrap_or_else(|| vec![4, 64]); |
| 171 | + |
| 172 | + let pool = Arc::new( |
| 173 | + PgPoolOptions::new() |
| 174 | + .max_connections(pool_size) |
| 175 | + .min_connections(pool_size) // pre-warm: don't time connection setup |
| 176 | + .acquire_timeout(Duration::from_secs(10)) |
| 177 | + .connect(&url) |
| 178 | + .await |
| 179 | + .expect("connect dev Postgres"), |
| 180 | + ); |
| 181 | + |
| 182 | + seed(&pool, n_chunks).await; |
| 183 | + |
| 184 | + println!("\n###########################################################"); |
| 185 | + println!("# read_blob_bytes manifest round-trips: OLD (2 queries) vs NEW (1)"); |
| 186 | + println!("# pool={pool_size} window={secs}s/run chunks/manifest={n_chunks}"); |
| 187 | + println!("# latency = acquire-wait + manifest query/queries per blob read"); |
| 188 | + println!("###########################################################\n"); |
| 189 | + println!( |
| 190 | + "| {:>5} | {:<4} | {:>9} | {:>9} | {:>7} | {:>7} | {:>7} | {:>7} |", |
| 191 | + "conc", "mode", "ops", "ops/s", "p50 ms", "p95 ms", "p99 ms", "max ms" |
| 192 | + ); |
| 193 | + println!( |
| 194 | + "|{:-<7}|{:-<6}|{:-<11}|{:-<11}|{:-<9}|{:-<9}|{:-<9}|{:-<9}|", |
| 195 | + "", "", "", "", "", "", "", "" |
| 196 | + ); |
| 197 | + |
| 198 | + for &conc in &concurrencies { |
| 199 | + // Warm-up (discarded) so the first real window isn't skewed. |
| 200 | + let _ = run_window(pool.clone(), conc, 1, Mode::New).await; |
| 201 | + |
| 202 | + let old = run_window(pool.clone(), conc, secs, Mode::Old).await; |
| 203 | + let new = run_window(pool.clone(), conc, secs, Mode::New).await; |
| 204 | + let row = |label: &str, s: &Stats| { |
| 205 | + println!( |
| 206 | + "| {:>5} | {:<4} | {:>9} | {:>9.0} | {:>7.3} | {:>7.3} | {:>7.3} | {:>7.3} |", |
| 207 | + conc, label, s.count, s.rps, s.p50, s.p95, s.p99, s.max |
| 208 | + ); |
| 209 | + }; |
| 210 | + row("OLD", &old); |
| 211 | + row("NEW", &new); |
| 212 | + let thr = if old.rps > 0.0 { |
| 213 | + new.rps / old.rps |
| 214 | + } else { |
| 215 | + 0.0 |
| 216 | + }; |
| 217 | + let p99 = if new.p99 > 0.0 { |
| 218 | + old.p99 / new.p99 |
| 219 | + } else { |
| 220 | + 0.0 |
| 221 | + }; |
| 222 | + println!( |
| 223 | + "| | → | {:>9} | {:>7.2}× | {:>7} | {:>7} | {:>6.2}× | {:>7} |", |
| 224 | + "throughput", thr, "", "", p99, "" |
| 225 | + ); |
| 226 | + } |
| 227 | + |
| 228 | + cleanup(&pool).await; |
| 229 | + println!("\n(ops = blob-manifest reads completed; NEW issues 1 query/op, OLD issues 2.)"); |
| 230 | +} |
0 commit comments