Files
Oxicloud/examples/bench_blob_cache.rs
T
Claude cd4c62042a perf: keyset/LATERAL SQL shapes, auth+blob-cache single-flight, spool buffers, DTO interning
Round 3 of benchmark-gated optimizations (benches/ROUND3.md; every change
gated by a before/after benchmark — an AFTER that did not beat its BEFORE
was to be rolled back; none needed it. Equivalence gates assert identical
row sequences / byte-identical output on every behavior-preserving rewrite):

DB hot paths (local PG16, EXPLAIN-verified):
- Web-UI listing (list_resources_paged): cursor pushed INSIDE the
  folders/files UNION-ALL branches as sargable row-value comparisons with
  per-branch ORDER/LIMIT + two partial expression indexes
  (folder_id, LOWER(name), id). 20k-entry folder: 26.6 -> 1.3 ms/page
  (19.5x); other sort modes at parity or better. New migration
  20260918000000. [benches/LISTING-KEYSET.md section in ROUND3]
- Photos timeline (list_media_files): per-drive CROSS JOIN LATERAL top-N
  on the timeline index, joins moved above the top-N. 50k-photo library:
  97.4 -> 1.6 ms/page (55.7x). The old "LIMIT stops the scan early"
  comment was refuted by EXPLAIN.
- PROPFIND sub-folders (both DAV surfaces): keyset list_folders_batch off
  idx_folders_unique_name replaces COUNT(*) OVER() + LIMIT/OFFSET
  (5k dirs: 79.7 -> 17.9 ms full walk, 4.5x).

Concurrency:
- Basic-auth cache single-flight (moka try_get_with): 8 concurrent DAV
  connections at TTL expiry paid 8 Argon2id runs (2.6 s CPU + 8x64 MiB);
  now 1 (300 ms). Failed verifications remain uncached.
- CachedBlobBackend per-hash single-flight + unique tmp names: 16
  concurrent cold readers = 16 full remote downloads racing truncating
  writes on ONE deterministic .tmp (corruptible cache); now 1 download
  (16x less egress, 2.8x wall on a shared link) and torn files can never
  be renamed into the cache.

I/O and allocations:
- Chunk-assembly reads 64K -> 512K buffers (2.3x, 8x fewer syscalls);
  chunk-spool writes via BufWriter 512K (5.6x, 32x fewer syscalls).
- S3/Azure put_blob_from_bytes_unsynced overrides: dedup settle no longer
  pays a HEAD probe per new chunk (2 RTT -> 1, 1.8x); Azure stops copying
  every chunk (Bytes -> Body, -0.44 ms - 4 MiB alloc per 4 MiB chunk).
- Entity->DTO mapping: Arc<str> interning of closed-set display fields +
  common MIMEs, 1-alloc etag/size formatting, FolderDto moves instead of
  clones. File row: 11 -> 4 allocs; folder row: 11.8 -> 1 (2.1x faster).
- CardDAV REPORT: deleted dead per-contact vCard pre-generation and the
  O(N^2) uid scan whose result was discarded (5k contacts: 55.7 -> 5.7 ms,
  9.8x); byte-identical XML asserted.
- Search-results cache: byte weigher + 32 MiB budget
  (OXICLOUD_SEARCH_CACHE_MAX_BYTES) replaces the 1000-ENTRY cap that let
  ~300 MiB of enriched rows sit in RSS; read latency parity.
- Dropped aws-config + aws-smithy-types (zero references; -82 dep-graph
  nodes, three SDK stacks gone from every build). tokio "process" is now
  an explicit feature (was enabled transitively by aws-config).

Frontend:
- Cached Intl.DateTimeFormat keyed by (locale, options) in formatDate and
  4 sibling callsites: 20k dates 2612 -> 51 ms (51.6x); vitest gate
  asserts output identity across locales and a 3x floor.

Validation: cargo fmt + clippy --all-features --all-targets -D warnings
clean; 518 unit + 548 integration-cfg tests green; new-shape endpoints
smoke-tested end-to-end over HTTP (all 5 listing sort modes with cursor
walks, WebDAV PROPFIND Depth-1, photos timeline, Basic-auth DAV login);
frontend npm run check clean, new vitest gates green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBsU2qEzny3A8WQUEuMNCr
2026-07-17 11:10:27 +00:00

280 lines
10 KiB
Rust

//! CachedBlobBackend miss-stampede benchmark — duplicate remote fetches.
//!
//! K concurrent cold readers of ONE blob (a video player's parallel Range
//! probes on an uncached file, N sync clients pulling the same new file)
//! used to each download the FULL blob from the remote backend and race
//! their writes on one shared deterministic `.tmp` path. The per-hash
//! single-flight gate coalesces them onto one download; waiters serve the
//! leader's cached file.
//!
//! The mock inner backend counts `get_blob_stream` calls and serves a
//! 32 MiB blob with an injected 15 ms first-byte latency + paced chunks
//! (models a remote object store).
//!
//! BEFORE (emulated) — K concurrent direct inner fetches, each draining
//! the full stream (what the old miss path did)
//! AFTER — K concurrent `CachedBlobBackend::get_blob_stream`
//! on a cold cache
//!
//! Gates: AFTER's inner-fetch count == 1; the cached file must BLAKE3-match
//! the source; K x full-drain wall reported for both.
//!
//! No Postgres. Run:
//! cargo run --release --features bench --example bench_blob_cache
//! Tunables: BENCH_CONCURRENCY (16), BENCH_BLOB_MB (32)
use std::env;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use bytes::Bytes;
use futures::StreamExt;
use oxicloud::application::ports::blob_storage_ports::{
BlobStorageBackend, BlobStream, StorageHealthStatus,
};
use oxicloud::domain::errors::DomainError;
use oxicloud::infrastructure::services::cached_blob_backend::{BlobCacheConfig, CachedBlobBackend};
type BoxFut<'a, T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
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)
}
/// Mock remote backend: one in-RAM blob, counted reads, and — crucially —
/// SHARED aggregate bandwidth: concurrent streams split one simulated
/// 1 GiB/s link (a real NIC/egress link doesn't hand every duplicate
/// download its own private lane, so duplicate fetches cost real wall
/// time, not just bytes).
struct MockRemote {
data: Bytes,
fetches: AtomicU64,
bytes_served: AtomicU64,
/// Virtual time (µs since bench start) when the shared link frees up.
link_busy_until_us: Arc<tokio::sync::Mutex<u64>>,
epoch: Instant,
}
const LINK_BYTES_PER_SEC: u64 = 1024 * 1024 * 1024; // 1 GiB/s aggregate
impl MockRemote {
fn new(data: Bytes) -> Self {
Self {
data,
fetches: AtomicU64::new(0),
bytes_served: AtomicU64::new(0),
link_busy_until_us: Arc::new(tokio::sync::Mutex::new(0)),
epoch: Instant::now(),
}
}
fn stream(&self) -> BlobStream {
self.fetches.fetch_add(1, Ordering::Relaxed);
self.bytes_served
.fetch_add(self.data.len() as u64, Ordering::Relaxed);
let data = self.data.clone();
let link = self.link_busy_until_us.clone();
let epoch = self.epoch;
let s = async_stream::stream! {
// First-byte latency of a remote GET.
tokio::time::sleep(Duration::from_millis(15)).await;
let chunk = 4 * 1024 * 1024;
let mut off = 0usize;
while off < data.len() {
let end = (off + chunk).min(data.len());
// Reserve this chunk's slot on the shared link, then sleep
// until the slot has elapsed — bandwidth divides across
// every in-flight stream.
let slot_us = (end - off) as u64 * 1_000_000 / LINK_BYTES_PER_SEC;
let wake_us = {
let mut busy = link.lock().await;
let now_us = epoch.elapsed().as_micros() as u64;
let start = (*busy).max(now_us);
*busy = start + slot_us;
*busy
};
let now_us = epoch.elapsed().as_micros() as u64;
if wake_us > now_us {
tokio::time::sleep(Duration::from_micros(wake_us - now_us)).await;
}
yield Ok::<Bytes, std::io::Error>(data.slice(off..end));
off = end;
}
};
Box::pin(s)
}
}
impl BlobStorageBackend for MockRemote {
fn initialize(&self) -> BoxFut<'_, Result<(), DomainError>> {
Box::pin(async { Ok(()) })
}
fn put_blob(&self, _hash: &str, _source_path: &Path) -> BoxFut<'_, Result<u64, DomainError>> {
Box::pin(async { Ok(0) })
}
fn put_blob_from_bytes(
&self,
_hash: &str,
data: Bytes,
) -> BoxFut<'_, Result<u64, DomainError>> {
Box::pin(async move { Ok(data.len() as u64) })
}
fn get_blob_stream(&self, _hash: &str) -> BoxFut<'_, Result<BlobStream, DomainError>> {
let s = self.stream();
Box::pin(async move { Ok(s) })
}
fn get_blob_range_stream(
&self,
_hash: &str,
start: u64,
end: Option<u64>,
) -> BoxFut<'_, Result<BlobStream, DomainError>> {
let data = self.data.clone();
self.fetches.fetch_add(1, Ordering::Relaxed);
Box::pin(async move {
let end = end.unwrap_or(data.len() as u64).min(data.len() as u64);
let s = futures::stream::once(async move {
Ok::<Bytes, std::io::Error>(data.slice(start as usize..end as usize))
});
Ok(Box::pin(s) as BlobStream)
})
}
fn delete_blob(&self, _hash: &str) -> BoxFut<'_, Result<(), DomainError>> {
Box::pin(async { Ok(()) })
}
fn blob_exists(&self, _hash: &str) -> BoxFut<'_, Result<bool, DomainError>> {
Box::pin(async { Ok(true) })
}
fn blob_size(&self, _hash: &str) -> BoxFut<'_, Result<u64, DomainError>> {
let n = self.data.len() as u64;
Box::pin(async move { Ok(n) })
}
fn health_check(&self) -> BoxFut<'_, Result<StorageHealthStatus, DomainError>> {
Box::pin(async {
Ok(StorageHealthStatus {
connected: true,
backend_type: "mock".into(),
message: "ok".into(),
available_bytes: None,
})
})
}
fn backend_type(&self) -> &'static str {
"mock"
}
fn local_blob_path(&self, _hash: &str) -> Option<PathBuf> {
None
}
}
async fn drain(mut s: BlobStream) -> (u64, [u8; 32]) {
let mut hasher = blake3::Hasher::new();
let mut n = 0u64;
while let Some(chunk) = s.next().await {
let b = chunk.expect("chunk");
n += b.len() as u64;
hasher.update(&b);
}
(n, hasher.finalize().into())
}
#[tokio::main(flavor = "multi_thread")]
async fn main() {
let k: usize = env_or("BENCH_CONCURRENCY", 16);
let blob_mb: usize = env_or("BENCH_BLOB_MB", 32);
let data: Bytes = (0..blob_mb * 1024 * 1024)
.map(|i| (i * 37 % 249) as u8)
.collect::<Vec<u8>>()
.into();
let ref_hash: [u8; 32] = blake3::hash(&data).into();
let blob_len = data.len() as u64;
let hash = "benchblobcache00000000000000000000000000000000000000000000000000";
// ── BEFORE (emulated): K concurrent direct inner fetches ───────────
let remote = Arc::new(MockRemote::new(data.clone()));
let t = Instant::now();
let mut set = tokio::task::JoinSet::new();
for _ in 0..k {
let r = remote.clone();
set.spawn(async move {
let s = r.get_blob_stream(hash).await.expect("stream");
drain(s).await
});
}
while let Some(res) = set.join_next().await {
let (n, h) = res.expect("join");
assert_eq!(n, blob_len);
assert_eq!(h, ref_hash);
}
let before_wall = t.elapsed().as_secs_f64() * 1000.0;
let before_fetches = remote.fetches.load(Ordering::Relaxed);
let before_mb = remote.bytes_served.load(Ordering::Relaxed) / (1024 * 1024);
// ── AFTER: K concurrent CachedBlobBackend reads, cold cache ────────
let remote = Arc::new(MockRemote::new(data.clone()));
let dir = tempfile::tempdir().expect("tempdir");
let cached = Arc::new(CachedBlobBackend::new(
remote.clone(),
&BlobCacheConfig {
cache_dir: dir.path().to_path_buf(),
max_cache_bytes: 1 << 30,
},
));
cached.initialize().await.expect("init");
let t = Instant::now();
let mut set = tokio::task::JoinSet::new();
for _ in 0..k {
let c = cached.clone();
set.spawn(async move {
let s = c.get_blob_stream(hash).await.expect("stream");
drain(s).await
});
}
while let Some(res) = set.join_next().await {
let (n, h) = res.expect("join");
assert_eq!(n, blob_len);
assert_eq!(h, ref_hash, "cached read corrupted");
}
let after_wall = t.elapsed().as_secs_f64() * 1000.0;
let after_fetches = remote.fetches.load(Ordering::Relaxed);
let after_mb = remote.bytes_served.load(Ordering::Relaxed) / (1024 * 1024);
// Integrity of the durable cache file itself.
let (n, h) = drain(cached.get_blob_stream(hash).await.expect("warm")).await;
assert_eq!(n, blob_len);
assert_eq!(h, ref_hash, "durable cache file corrupted");
let warm_fetches = remote.fetches.load(Ordering::Relaxed) - after_fetches;
println!("# {k} concurrent cold readers of one {blob_mb} MiB blob (remote: 15 ms TTFB, paced)");
println!(
"{:<24} {:>10} {:>14} {:>12}",
"variant", "wall ms", "inner fetches", "remote MiB"
);
println!(
"{:<24} {:>10.0} {:>14} {:>12}",
"BEFORE (per-caller)", before_wall, before_fetches, before_mb
);
println!(
"{:<24} {:>10.0} {:>14} {:>12}",
"AFTER (single-flight)", after_wall, after_fetches, after_mb
);
// ── Gates ───────────────────────────────────────────────────────────
if after_fetches != 1 {
eprintln!("GATE FAIL: expected exactly 1 coalesced remote fetch, got {after_fetches}");
std::process::exit(1);
}
if warm_fetches != 0 {
eprintln!("GATE FAIL: warm read hit the remote backend");
std::process::exit(1);
}
println!("\nGATE PASS: {before_fetches} remote fetches -> 1, cache file verified");
}