Files
Oxicloud/examples/bench_blob_cache_index.rs
Claude 50eca0627f perf: round 12 — auth write-path narrowing, fused quota gate, moka blob-cache index, media single-read, sized listing JSON
Benchmark-gated round (benches/ROUND12.md; every change ships with a
BEFORE/AFTER harness + equivalence gates, one candidate rejected by its
own bench):

DB / query shapes (bench_round12_queries):
- NC sharee search: username-only projection instead of the 21-column row
  (incl. the <=512 KiB avatar) per match, + gin_trgm_ops indexes on
  auth.users for the leading-wildcard ILIKE (4.98x; 54.7x with index).
- Password login: delete the redundant full-row update_user — create_session
  already stamps last_login_at in its own txn (4.45x per login).
- Email-verified stamp: narrow conditional UPDATE (8.9x); OIDC repeat login
  now compares profile state in memory and issues ZERO queries when nothing
  changed (was: full 17-column rewrite per login).
- Refresh rotation: revoke+insert+stamp fused into one transaction via new
  rotate_session port method (1.18x).
- WOPI CheckFileInfo / authorize_wopi_access: require(Read) + get_file +
  check(Update) overlapped with tokio::join!, original result precedence
  (cold 1.34x).
- Upload quota gate: user-envelope + drive-cap checks fused into ONE
  round-trip (check_upload_quotas) — the NC chunked PUT pays this per
  chunk (1.81x, 2 -> 1 queries/chunk); shared verdict evaluators keep
  error shapes byte-identical.

CPU / allocs (bench_round12_micro):
- sized_json: pre-sized listing serialization replacing axum Json's 128 B
  seed + doubling-realloc chain on files/folder-resources/photos/search
  responses (1.40x, 13 -> 2 allocs per 500-row page; byte-identical).
- Security headers: 4 SetResponseHeaderLayer folded into the CSP middleware
  pass (5 layers -> 1; 1.43x per request, -26 allocs; header set gated
  byte-identical incl. 304s).
- Media capture-metadata: single-read extraction — nom-exif now parses the
  buffer kamadak already read (zero-copy Bytes) and videos open once with a
  kind() dispatch; per-image opens 2-3 -> 1 (1.44x warm geomean, 1.6-3.2x
  cold cache; extraction outputs gated identical incl. the MIME-mislabel
  track fallback).
- Chunked-upload session ops: owner gate folded into the operation's own
  DashMap lookup + stack-encoded uuid compare (5 -> 3 lookups, -2 allocs,
  1.28x per chunk).

Blob cache (bench_blob_cache_index + round-3 regression guard):
- CachedBlobBackend index: tokio::sync::Mutex<LruCache> -> moka::sync::Cache
  with byte weigher. The mutex serialized every cached chunk read and scaled
  NEGATIVELY (2.08 -> 1.07 Mops/s from 1 -> 2 readers); moka probes are
  lock-free (2.17x at K=2). Byte budget now enforced by moka (manual
  current_size + collect_evictions machinery deleted); eviction listener
  unlinks size-evicted files only (Replaced entries keep their file —
  gated). Single-flight miss gate unchanged (16 concurrent misses -> 1
  fetch re-verified via the round-3 harness).
- put_blob now populates the cache BEFORE the inner backend consumes the
  source file (the old order failed 100% of the time — local renames,
  S3/Azure delete the source — so the first read after a whole-file put
  re-downloaded from the remote); inner-put failure invalidates the entry.

Frontend (vitest gates):
- List-view thumbnails request the 150px icon rendition instead of 400px
  preview into a 40px slot (~7.1x fewer pixels, ~4-5x fewer bytes per
  thumbnail across list views); grid keeps preview.

Rejected by its own bench (kept as evidence in bench_round12_micro §2):
- Single-pass compression predicate: the monomorphized And-chain already
  costs ~4.6 ns / 0 allocs total; the fused node measured within noise.

New migration: 20260719000000_users_search_trgm.sql (trgm indexes).
Deferred with prepared design: grouped file/grid view virtualization
(single-VirtualRows flatten, the photos pattern) — next round's headline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfidAJD5AHw23jtvBUNamB
2026-07-19 01:32:00 +00:00

423 lines
15 KiB
Rust
Raw Permalink 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.
//! Blob-cache index benchmark — `Mutex<LruCache>` vs moka byte-weigher
//! (the ROUND11 deferred lead; no Postgres).
//!
//! `CachedBlobBackend` keeps its cache index in a
//! `tokio::sync::Mutex<LruCache<String, CacheEntry>>`: EVERY cached chunk
//! read acquires the one global async mutex to probe + LRU-promote (the
//! promote needs `&mut`), so N-core read concurrency collapses onto a
//! single serialization domain — and an N-chunk CDC file read is N
//! acquisitions, with every other concurrent reader contending.
//!
//! AFTER: a `moka::sync::Cache` with a byte weigher — lock-free sharded
//! reads with striped recency, byte-budget eviction handled by moka
//! (replacing the manual `current_size` + `collect_evictions` machinery),
//! and an eviction listener that unlinks the evicted `.blob` file (only on
//! size-eviction — Replaced/Explicit must NOT unlink, gated below).
//!
//! Arms:
//! [1] pure index ops, K tasks × M hit-probes (the scaling ceiling)
//! [2] end-to-end warm-hit read (index probe + open + 64 KiB read),
//! K = 1/2/4/8 readers over a shared corpus
//! [3] safety gates: byte budget enforced + evicted files unlinked +
//! replaced entries keep their file + single-flight still coalesces
//! K concurrent misses onto 1 inner fetch
//!
//! Run:
//! cargo run --release --features bench --example bench_blob_cache_index
//! Tunables (env): BENCH_OPS (200000), BENCH_FILES (256), BENCH_READERS (8)
use std::num::NonZeroUsize;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;
use lru::LruCache;
use tokio::sync::Mutex;
fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
std::env::var(key)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
#[derive(Debug, Clone)]
struct CacheEntry {
size: u64,
}
/// BEFORE, verbatim: the shipped index shape + the per-hit prologue
/// allocations of `get_blob_stream` (hash `to_string`, `cached_path`
/// build, unconditional `cache_dir.clone()`).
struct BeforeIndex {
cache_dir: PathBuf,
index: Arc<Mutex<LruCache<String, CacheEntry>>>,
}
impl BeforeIndex {
fn cached_path(&self, hash: &str) -> PathBuf {
let prefix = &hash[..2.min(hash.len())];
self.cache_dir.join(prefix).join(format!("{hash}.blob"))
}
/// The exact hit-path prologue of `get_blob_stream`.
async fn hit_probe(&self, hash: &str) -> Option<PathBuf> {
let hash = hash.to_string();
let cached = self.cached_path(&hash);
let _cache_dir = self.cache_dir.clone(); // paid on hits, used on misses
if self.index.lock().await.get(&hash).is_some() {
return Some(cached);
}
None
}
}
/// AFTER: moka byte-weigher index + borrow-only hit prologue.
struct AfterIndex {
cache_dir: PathBuf,
index: moka::sync::Cache<String, CacheEntry>,
}
impl AfterIndex {
fn new(cache_dir: PathBuf, max_bytes: u64) -> Self {
Self {
cache_dir,
index: moka::sync::Cache::builder()
.weigher(|_k: &String, e: &CacheEntry| e.size.clamp(1, u32::MAX as u64) as u32)
.max_capacity(max_bytes)
.build(),
}
}
fn cached_path(&self, hash: &str) -> PathBuf {
let prefix = &hash[..2.min(hash.len())];
self.cache_dir.join(prefix).join(format!("{hash}.blob"))
}
fn hit_probe(&self, hash: &str) -> Option<PathBuf> {
if self.index.get(hash).is_some() {
return Some(self.cached_path(hash));
}
None
}
}
// ────────────────────────────────────────────────────────────────────────────
async fn section_index_ops(hashes: Arc<Vec<String>>) {
let ops: usize = env_or("BENCH_OPS", 200_000);
let readers_max: usize = env_or("BENCH_READERS", 8);
let before = Arc::new(BeforeIndex {
cache_dir: PathBuf::from("/tmp/bench-blob-idx"),
index: Arc::new(Mutex::new(LruCache::new(
NonZeroUsize::new(1_000_000).unwrap(),
))),
});
let after = Arc::new(AfterIndex::new(
PathBuf::from("/tmp/bench-blob-idx"),
u64::MAX,
));
for h in hashes.iter() {
before
.index
.lock()
.await
.put(h.clone(), CacheEntry { size: 1024 });
after.index.insert(h.clone(), CacheEntry { size: 1024 });
}
println!("\n## [1] Pure index hit-probes (ops total = {ops}, split across K tasks)");
println!("| K | BEFORE Mutex<LruCache> Mops/s | AFTER moka Mops/s | speedup |");
for k in [1usize, 2, 4, 8].into_iter().filter(|k| *k <= readers_max) {
let per_task = ops / k;
let t = Instant::now();
let mut handles = Vec::new();
for t_id in 0..k {
let idx = before.clone();
let hs = hashes.clone();
handles.push(tokio::spawn(async move {
for i in 0..per_task {
let h = &hs[(i * 31 + t_id * 7) % hs.len()];
std::hint::black_box(idx.hit_probe(h).await);
}
}));
}
for h in handles {
h.await.unwrap();
}
let before_mops = (per_task * k) as f64 / t.elapsed().as_secs_f64() / 1e6;
let t = Instant::now();
let mut handles = Vec::new();
for t_id in 0..k {
let idx = after.clone();
let hs = hashes.clone();
handles.push(tokio::spawn(async move {
for i in 0..per_task {
let h = &hs[(i * 31 + t_id * 7) % hs.len()];
std::hint::black_box(idx.hit_probe(h));
}
}));
}
for h in handles {
h.await.unwrap();
}
let after_mops = (per_task * k) as f64 / t.elapsed().as_secs_f64() / 1e6;
println!(
"| {k} | {before_mops:>10.2} | {after_mops:>10.2} | {:>6.2}x |",
after_mops / before_mops
);
}
}
async fn section_warm_reads(hashes: Arc<Vec<String>>) {
let readers_max: usize = env_or("BENCH_READERS", 8);
let reads: usize = 20_000;
// Real cached files on disk (64 KiB each).
let dir = PathBuf::from("/tmp/bench-blob-idx");
let _ = std::fs::remove_dir_all(&dir);
let payload = vec![0xA5u8; 64 * 1024];
let before = Arc::new(BeforeIndex {
cache_dir: dir.clone(),
index: Arc::new(Mutex::new(LruCache::new(
NonZeroUsize::new(1_000_000).unwrap(),
))),
});
let after = Arc::new(AfterIndex::new(dir.clone(), u64::MAX));
for h in hashes.iter() {
let p = before.cached_path(h);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(&p, &payload).unwrap();
before.index.lock().await.put(
h.clone(),
CacheEntry {
size: payload.len() as u64,
},
);
after.index.insert(
h.clone(),
CacheEntry {
size: payload.len() as u64,
},
);
}
async fn read_file(path: &PathBuf) -> u64 {
use tokio::io::AsyncReadExt;
let mut f = tokio::fs::File::open(path).await.unwrap();
let mut buf = vec![0u8; 64 * 1024];
let mut total = 0u64;
loop {
let n = f.read(&mut buf).await.unwrap();
if n == 0 {
break;
}
total += n as u64;
}
total
}
println!("\n## [2] Warm-hit read (probe + open + 64 KiB read), {reads} reads split across K");
println!("| K | BEFORE Kops/s | AFTER Kops/s | speedup |");
for k in [1usize, 2, 4, 8].into_iter().filter(|k| *k <= readers_max) {
let per_task = reads / k;
let t = Instant::now();
let mut handles = Vec::new();
for t_id in 0..k {
let idx = before.clone();
let hs = hashes.clone();
handles.push(tokio::spawn(async move {
for i in 0..per_task {
let h = &hs[(i * 31 + t_id * 7) % hs.len()];
let p = idx.hit_probe(h).await.expect("hit");
std::hint::black_box(read_file(&p).await);
}
}));
}
for h in handles {
h.await.unwrap();
}
let before_kops = (per_task * k) as f64 / t.elapsed().as_secs_f64() / 1e3;
let t = Instant::now();
let mut handles = Vec::new();
for t_id in 0..k {
let idx = after.clone();
let hs = hashes.clone();
handles.push(tokio::spawn(async move {
for i in 0..per_task {
let h = &hs[(i * 31 + t_id * 7) % hs.len()];
let p = idx.hit_probe(h).expect("hit");
std::hint::black_box(read_file(&p).await);
}
}));
}
for h in handles {
h.await.unwrap();
}
let after_kops = (per_task * k) as f64 / t.elapsed().as_secs_f64() / 1e3;
println!(
"| {k} | {before_kops:>9.1} | {after_kops:>9.1} | {:>6.2}x |",
after_kops / before_kops
);
}
}
// ────────────────────────────────────────────────────────────────────────────
async fn section_safety_gates() {
use dashmap::DashMap;
println!("\n## [3] Safety gates");
// (a) Byte budget + eviction-unlink + replaced-keeps-file, on the moka
// shape the production migration ships.
let dir = PathBuf::from("/tmp/bench-blob-idx-gate");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let unlinked = Arc::new(AtomicU64::new(0));
let cache_dir = dir.clone();
let unlinked_l = unlinked.clone();
let cache: moka::sync::Cache<String, CacheEntry> = moka::sync::Cache::builder()
.weigher(|_k: &String, e: &CacheEntry| e.size.clamp(1, u32::MAX as u64) as u32)
.max_capacity(10 * 1024 * 1024) // 10 MiB budget
.eviction_listener(move |hash: Arc<String>, _entry, cause| {
// Unlink ONLY blobs moka pushed out for size; a Replaced entry
// refers to the same path as its replacement, and Explicit
// removals (delete_blob) unlink at the call site.
if cause == moka::notification::RemovalCause::Size {
let prefix = &hash[..2.min(hash.len())];
let p = cache_dir.join(prefix).join(format!("{hash}.blob"));
let _ = std::fs::remove_file(&p);
unlinked_l.fetch_add(1, Ordering::Relaxed);
}
})
.build();
let payload = vec![0x5Au8; 1024 * 1024]; // 1 MiB blobs
for i in 0..100 {
let hash = format!("{i:02x}gatehash{i:04}");
let prefix = &hash[..2];
let p = dir.join(prefix).join(format!("{hash}.blob"));
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(&p, &payload).unwrap();
cache.insert(
hash,
CacheEntry {
size: payload.len() as u64,
},
);
}
cache.run_pending_tasks();
let weighted = cache.weighted_size();
assert!(weighted <= 10 * 1024 * 1024, "budget exceeded: {weighted}");
// Every surviving entry's file exists; evicted files unlinked.
let mut on_disk = 0u64;
for i in 0..100 {
let hash = format!("{i:02x}gatehash{i:04}");
let prefix = &hash[..2];
let p = dir.join(prefix).join(format!("{hash}.blob"));
let exists = p.exists();
if cache.get(&hash).is_some() {
assert!(exists, "surviving entry lost its file: {hash}");
}
if exists {
on_disk += 1;
}
}
assert!(
on_disk <= 12,
"disk not trimmed to budget: {on_disk} files remain"
);
assert!(unlinked.load(Ordering::Relaxed) >= 88);
println!(
"# gate (a) OK — weighted {:.1} MiB ≤ 10 MiB budget, {} files on disk, {} unlinked",
weighted as f64 / (1024.0 * 1024.0),
on_disk,
unlinked.load(Ordering::Relaxed)
);
// (b) Replacing an entry must NOT unlink the shared path.
let u0 = unlinked.load(Ordering::Relaxed);
let some_hash = cache
.iter()
.next()
.map(|(k, _)| (*k).clone())
.expect("nonempty");
let some_path = {
let prefix = &some_hash[..2.min(some_hash.len())];
dir.join(prefix).join(format!("{some_hash}.blob"))
};
cache.insert(some_hash.clone(), CacheEntry { size: 1024 * 1024 });
cache.run_pending_tasks();
assert!(some_path.exists(), "replace unlinked the live file");
assert_eq!(
unlinked.load(Ordering::Relaxed),
u0,
"replace must not count as size-eviction unlink"
);
println!("# gate (b) OK — replaced entry keeps its file");
// (c) Single-flight (DashMap gate, unchanged by the migration) still
// coalesces K concurrent misses to one inner fetch.
let fetches = Arc::new(AtomicU64::new(0));
let inflight: Arc<DashMap<String, Arc<Mutex<()>>>> = Arc::new(DashMap::new());
let done: Arc<moka::sync::Cache<String, CacheEntry>> =
Arc::new(moka::sync::Cache::builder().max_capacity(1_000_000).build());
let mut handles = Vec::new();
for _ in 0..16 {
let fetches = fetches.clone();
let inflight = inflight.clone();
let done = done.clone();
handles.push(tokio::spawn(async move {
let hash = "sf-hash".to_string();
let gate = inflight
.entry(hash.clone())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone();
let _guard = gate.lock().await;
if done.get(&hash).is_some() {
return;
}
// simulate the remote fetch
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
fetches.fetch_add(1, Ordering::Relaxed);
done.insert(hash.clone(), CacheEntry { size: 1 });
inflight.remove(&hash);
}));
}
for h in handles {
h.await.unwrap();
}
assert_eq!(fetches.load(Ordering::Relaxed), 1, "single-flight broken");
println!("# gate (c) OK — 16 concurrent misses → 1 fetch");
}
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() {
let n_files: usize = env_or("BENCH_FILES", 256);
let hashes: Arc<Vec<String>> = Arc::new(
(0..n_files)
.map(|i| format!("{:02x}benchhash{i:06}", i % 256))
.collect(),
);
println!("#################################################################");
println!("# Blob-cache index — Mutex<LruCache> vs moka byte-weigher");
println!("#################################################################");
section_index_ops(hashes.clone()).await;
section_warm_reads(hashes.clone()).await;
section_safety_gates().await;
println!("\nGATE PASS (safety gates all hold — adopt if [1]/[2] favour moka)");
}