cd4c62042a
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
280 lines
10 KiB
Rust
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");
|
|
}
|