//! Blob-cache index benchmark — `Mutex` vs moka byte-weigher //! (the ROUND11 deferred lead; no Postgres). //! //! `CachedBlobBackend` keeps its cache index in a //! `tokio::sync::Mutex>`: 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(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>>, } 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 { 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, } 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 { if self.index.get(hash).is_some() { return Some(self.cached_path(hash)); } None } } // ──────────────────────────────────────────────────────────────────────────── async fn section_index_ops(hashes: Arc>) { 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 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>) { 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 = 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, _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>>> = Arc::new(DashMap::new()); let done: Arc> = 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> = Arc::new( (0..n_files) .map(|i| format!("{:02x}benchhash{i:06}", i % 256)) .collect(), ); println!("#################################################################"); println!("# Blob-cache index — Mutex 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)"); }