fdf445d2b0
Benchmark-gated round (benches/ROUND9.md): every change carries a BEFORE/AFTER bench with equivalence/safety gates; verdicts below are from the committed harnesses on 4 cores / local PG 16. Backend: - Blob decorators (Retry/Cached) now forward put_blob_from_bytes_unsynced + sync_blobs — the trait default had silently reinstated HEAD-before-PUT per chunk on decorated remote stacks, undoing ROUND3 §8. Full production stack: 500 probes -> 0, 1.9x wall at 10 ms RTT (bench_s3_put §3). - NC PROPFIND per-page enrichment triple (favorites / oc:fileid / dead props) overlapped with tokio::join!: 2.07x local, 2.86x at 5 ms RTT (bench_nc_enrich_join, injected-latency decide-by-bench). - Search enrichment consumes its DTOs and carries the interned Arc<str> display fields end-to-end (SearchFileResultDto type change, OpenAPI shape preserved): enrich_file 2.0x, 11.6 -> 2.2 allocs/row; the NC REPORT conversion stops re-running all three classifiers per row (bench_search_enrich). - NC session Arc end-to-end: SharedNcSession extractor (8 -> 0 allocs), Arc<FolderDto> chroot cache (4 -> 0/hit), single shared Arc<CurrentUser> + lazy span render (11 -> 6/build) (bench_nc_session). - Storage micro-pack: atomic create_new chunk writes (2.1x fresh), stream_chunks over the manifest Arc (4097 -> 0 allocs/read incl. the Range path), manifest single-flight (herd 64 -> 1 loads), hex_lower for chunk Content-MD5 (18 -> 1 allocs) (bench_storage_micro). - OCS capabilities memoized into OnceLock<[Bytes;2]>: 237x, 102 -> 0 allocs/poll, byte-identical (bench_capabilities_static). - Drive::is_empty COUNT(*) sum -> EXISTS: 34.4x on a 100k-file drive (bench_drive_is_empty). - favorites/recents row-map ROUND7 port: path/name/blob_hash moved, -2.75 allocs/row (bench_resource_row_map §2). - Folder rows decode binary UUIDs (ROUND6 §10 port): 1.03-1.07x page fetch, honest verdict incl. one noise-band wash documented (bench_folder_uuid_decode). - Authz: file cascade decision decomposed into memoized folder-level decision + direct-grant lookup (ROUND8 deferred item). Cold shared-album first view 592 -> 418 µs/thumb; warm path unchanged; safety gates incl. new direct-grant sibling isolation, revoke-flush re-verified, full integration authz suite green (bench_thumbnail_cascade_cache). Frontend (vitest gates committed beside the code): - resolveLabel/resolveRecipient O(directory) scan -> id-keyed Map: 13.9x (recipients.bench.test.ts). - ResourceList selection-prune effect skips when nothing is selected (100 -> 0 Set builds per drain) and the photos timeline reads a listener-fed mobile flag instead of matchMedia per recompute (listDerives.bench.test.ts). Verification: cargo fmt + clippy --all-features --all-targets -D warnings clean; 524 unit + 554 integration (--cfg integration_tests) tests pass; frontend npm run check clean with 293 vitest tests green. Deferred with rationale in ROUND9.md: CalDAV authz-before-fetch reorder (maintainer sign-off), per-page batched parent resolution, JWT-claims Arc<str>, batch_operations signature widening. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDc9VtXvskJ6dnMRraSndn
403 lines
14 KiB
Rust
403 lines
14 KiB
Rust
//! S3 chunk-PUT benchmark — HEAD-before-PUT vs unconditional PUT.
|
|
//!
|
|
//! `DedupService::settle_batch` writes every NEW chunk of every upload via
|
|
//! `put_blob_from_bytes_unsynced`. S3/Azure never overrode it, so the trait
|
|
//! default routed it through `put_blob_from_bytes`, whose "idempotent" HEAD
|
|
//! probe made every chunk write pay 2 request round-trips. Content-addressed
|
|
//! keys make re-PUTs overwrite-safe, so the new override PUTs directly.
|
|
//!
|
|
//! The stub S3 endpoint (in-process axum, per-request latency injection)
|
|
//! counts HEAD/PUT requests:
|
|
//! BEFORE — put_blob_from_bytes (HEAD 404 + PUT per chunk)
|
|
//! AFTER — put_blob_from_bytes_unsynced (PUT per chunk)
|
|
//!
|
|
//! Section 2 measures the removed Azure `data.to_vec()` copy in isolation.
|
|
//!
|
|
//! Section 3 (round 9) drives the same A/B **through the decorator stacks**
|
|
//! (`RetryBlobBackend`, `CachedBlobBackend`, and the full production
|
|
//! Cache(Encrypted(Retry(S3))) composition). Until round 9 neither Retry nor
|
|
//! Cached overrode `put_blob_from_bytes_unsynced`/`sync_blobs`, so the trait
|
|
//! default silently re-routed every decorated chunk write back through the
|
|
//! probing synced path — undoing this bench's own Section-1 win on every
|
|
//! remote deployment with retry or cache enabled. The BEFORE arm is the
|
|
//! still-present synced route (`put_blob_from_bytes`, byte-identical requests
|
|
//! to what the fallthrough produced); the AFTER arm is the now-forwarded
|
|
//! unsynced route. A write-through equivalence gate asserts the Cached stack
|
|
//! still populates its local cache identically on both routes.
|
|
//!
|
|
//! Gates: AFTER request count == chunks (vs 2x), AFTER wall < BEFORE wall,
|
|
//! per-stack AFTER HEADs == 0, cache population identical on both routes.
|
|
//!
|
|
//! No Postgres. Run:
|
|
//! cargo run --release --features bench --example bench_s3_put
|
|
//! Tunables: BENCH_CHUNKS (500), BENCH_CHUNK_KB (256), BENCH_CONCURRENCY (8),
|
|
//! BENCH_RTT_MS (10)
|
|
|
|
use std::env;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use bytes::Bytes;
|
|
use oxicloud::application::ports::blob_storage_ports::BlobStorageBackend;
|
|
use oxicloud::common::config::S3StorageConfig;
|
|
use oxicloud::infrastructure::services::cached_blob_backend::{BlobCacheConfig, CachedBlobBackend};
|
|
use oxicloud::infrastructure::services::encrypted_blob_backend::EncryptedBlobBackend;
|
|
use oxicloud::infrastructure::services::retry_blob_backend::{RetryBlobBackend, RetryPolicy};
|
|
use oxicloud::infrastructure::services::s3_blob_backend::S3BlobBackend;
|
|
|
|
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)
|
|
}
|
|
|
|
/// Recursively count regular files under `dir` (the blob cache shards blobs
|
|
/// into 2-hex-char prefix subdirectories).
|
|
fn count_files(dir: &std::path::Path) -> usize {
|
|
let mut n = 0;
|
|
if let Ok(entries) = std::fs::read_dir(dir) {
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
n += count_files(&path);
|
|
} else {
|
|
n += 1;
|
|
}
|
|
}
|
|
}
|
|
n
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
struct Counters {
|
|
heads: Arc<AtomicU64>,
|
|
puts: Arc<AtomicU64>,
|
|
}
|
|
|
|
async fn stub_s3(latency: Duration, counters: Counters) -> String {
|
|
use axum::http::{Method, StatusCode};
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind");
|
|
let addr = listener.local_addr().expect("addr");
|
|
let app = axum::Router::new().fallback(move |req: axum::extract::Request| {
|
|
let counters = counters.clone();
|
|
async move {
|
|
tokio::time::sleep(latency).await;
|
|
match *req.method() {
|
|
Method::HEAD => {
|
|
counters.heads.fetch_add(1, Ordering::Relaxed);
|
|
StatusCode::NOT_FOUND
|
|
}
|
|
Method::PUT => {
|
|
// Drain the body like a real endpoint would.
|
|
let _ = axum::body::to_bytes(req.into_body(), usize::MAX).await;
|
|
counters.puts.fetch_add(1, Ordering::Relaxed);
|
|
StatusCode::OK
|
|
}
|
|
_ => StatusCode::OK,
|
|
}
|
|
}
|
|
});
|
|
tokio::spawn(async move {
|
|
axum::serve(listener, app).await.expect("serve");
|
|
});
|
|
format!("http://{addr}")
|
|
}
|
|
|
|
async fn drive(
|
|
backend: Arc<dyn BlobStorageBackend>,
|
|
chunks: usize,
|
|
chunk_kb: usize,
|
|
concurrency: usize,
|
|
unsynced: bool,
|
|
hash_prefix: &str,
|
|
) -> f64 {
|
|
let payload = Bytes::from(vec![0x5au8; chunk_kb * 1024]);
|
|
let sem = Arc::new(tokio::sync::Semaphore::new(concurrency));
|
|
let t = Instant::now();
|
|
let mut set = tokio::task::JoinSet::new();
|
|
for i in 0..chunks {
|
|
let b = backend.clone();
|
|
let p = payload.clone();
|
|
let sem = sem.clone();
|
|
let hash = format!("{hash_prefix}{i:060x}");
|
|
set.spawn(async move {
|
|
let _permit = sem.acquire().await.expect("sem");
|
|
let n = if unsynced {
|
|
b.put_blob_from_bytes_unsynced(&hash, p).await.expect("put")
|
|
} else {
|
|
b.put_blob_from_bytes(&hash, p).await.expect("put")
|
|
};
|
|
// Encrypted arms return the ciphertext size (plaintext + AEAD
|
|
// framing), so gate on >= rather than == for stack generality.
|
|
assert!(n as usize >= chunk_kb * 1024);
|
|
});
|
|
}
|
|
while let Some(r) = set.join_next().await {
|
|
r.expect("join");
|
|
}
|
|
t.elapsed().as_secs_f64() * 1000.0
|
|
}
|
|
|
|
/// Run BEFORE (synced route == the pre-round-9 unsynced fallthrough) and
|
|
/// AFTER (forwarded unsynced route) through one backend stack, printing the
|
|
/// two rows and gating AFTER on zero probe requests. `prefixes` carries the
|
|
/// (BEFORE, AFTER) hash namespaces keeping the arms' key spaces disjoint.
|
|
async fn stack_ab(
|
|
label: &str,
|
|
backend: Arc<dyn BlobStorageBackend>,
|
|
counters: &Counters,
|
|
chunks: usize,
|
|
chunk_kb: usize,
|
|
concurrency: usize,
|
|
prefixes: (&str, &str),
|
|
) -> (f64, f64) {
|
|
let (prefix_before, prefix_after) = prefixes;
|
|
let before = drive(
|
|
backend.clone(),
|
|
chunks,
|
|
chunk_kb,
|
|
concurrency,
|
|
false,
|
|
prefix_before,
|
|
)
|
|
.await;
|
|
let before_heads = counters.heads.swap(0, Ordering::Relaxed);
|
|
let before_puts = counters.puts.swap(0, Ordering::Relaxed);
|
|
println!(
|
|
"{:<34} {:>10.0} {:>8} {:>8} {:>8}",
|
|
format!("{label} BEFORE (synced route)"),
|
|
before,
|
|
before_heads,
|
|
before_puts,
|
|
"1.0x"
|
|
);
|
|
|
|
let after = drive(
|
|
backend.clone(),
|
|
chunks,
|
|
chunk_kb,
|
|
concurrency,
|
|
true,
|
|
prefix_after,
|
|
)
|
|
.await;
|
|
let after_heads = counters.heads.swap(0, Ordering::Relaxed);
|
|
let after_puts = counters.puts.swap(0, Ordering::Relaxed);
|
|
println!(
|
|
"{:<34} {:>10.0} {:>8} {:>8} {:>8}",
|
|
format!("{label} AFTER (unsynced)"),
|
|
after,
|
|
after_heads,
|
|
after_puts,
|
|
format!("{:.1}x", before / after)
|
|
);
|
|
|
|
if before_heads != chunks as u64 {
|
|
eprintln!(
|
|
"GATE FAIL [{label}]: BEFORE issued {before_heads} HEADs (expected {chunks} — the probing route must still probe)"
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
if after_heads != 0 || after_puts != chunks as u64 {
|
|
eprintln!(
|
|
"GATE FAIL [{label}]: AFTER issued {after_heads} HEADs / {after_puts} PUTs (expected 0 / {chunks})"
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
if after >= before {
|
|
eprintln!(
|
|
"GATE FAIL [{label}]: AFTER ({after:.0} ms) not faster than BEFORE ({before:.0} ms) — rollback"
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
(before, after)
|
|
}
|
|
|
|
#[tokio::main(flavor = "multi_thread")]
|
|
async fn main() {
|
|
let chunks: usize = env_or("BENCH_CHUNKS", 500);
|
|
let chunk_kb: usize = env_or("BENCH_CHUNK_KB", 256);
|
|
let concurrency: usize = env_or("BENCH_CONCURRENCY", 8);
|
|
let rtt_ms: u64 = env_or("BENCH_RTT_MS", 10);
|
|
|
|
let counters = Counters::default();
|
|
let endpoint = stub_s3(Duration::from_millis(rtt_ms), counters.clone()).await;
|
|
let backend = Arc::new(S3BlobBackend::new(&S3StorageConfig {
|
|
endpoint_url: Some(endpoint),
|
|
bucket: "bench".into(),
|
|
region: "us-east-1".into(),
|
|
access_key: "bench".into(),
|
|
secret_key: "bench".into(),
|
|
force_path_style: true,
|
|
}));
|
|
|
|
println!(
|
|
"# {chunks} x {chunk_kb} KiB chunk PUTs at concurrency {concurrency}, {rtt_ms} ms/request stub"
|
|
);
|
|
println!(
|
|
"{:<26} {:>10} {:>8} {:>8} {:>8}",
|
|
"variant", "wall ms", "HEADs", "PUTs", "vs OLD"
|
|
);
|
|
|
|
// BEFORE: the trait-default route (put_blob_from_bytes = HEAD + PUT).
|
|
let before = drive(
|
|
backend.clone() as Arc<dyn BlobStorageBackend>,
|
|
chunks,
|
|
chunk_kb,
|
|
concurrency,
|
|
false,
|
|
"a0a0",
|
|
)
|
|
.await;
|
|
let before_heads = counters.heads.swap(0, Ordering::Relaxed);
|
|
let before_puts = counters.puts.swap(0, Ordering::Relaxed);
|
|
println!(
|
|
"{:<26} {:>10.0} {:>8} {:>8} {:>8}",
|
|
"BEFORE (HEAD+PUT)", before, before_heads, before_puts, "1.0x"
|
|
);
|
|
|
|
// AFTER: the unsynced override (PUT only).
|
|
let after = drive(
|
|
backend.clone() as Arc<dyn BlobStorageBackend>,
|
|
chunks,
|
|
chunk_kb,
|
|
concurrency,
|
|
true,
|
|
"a0a1",
|
|
)
|
|
.await;
|
|
let after_heads = counters.heads.swap(0, Ordering::Relaxed);
|
|
let after_puts = counters.puts.swap(0, Ordering::Relaxed);
|
|
println!(
|
|
"{:<26} {:>10.0} {:>8} {:>8} {:>8}",
|
|
"AFTER (PUT only)",
|
|
after,
|
|
after_heads,
|
|
after_puts,
|
|
format!("{:.1}x", before / after)
|
|
);
|
|
|
|
// ── Section 2: the removed Azure to_vec() copy, in isolation ───────
|
|
let mb = 4;
|
|
let data = Bytes::from(vec![0x77u8; mb * 1024 * 1024]);
|
|
let reps = 200;
|
|
let t = Instant::now();
|
|
for _ in 0..reps {
|
|
let v = data.to_vec();
|
|
std::hint::black_box(&v);
|
|
}
|
|
let copy_ms = t.elapsed().as_secs_f64() * 1000.0 / reps as f64;
|
|
println!(
|
|
"\n# [2] removed Azure per-chunk copy: to_vec() of {mb} MiB = {copy_ms:.2} ms + {mb} MiB transient alloc per chunk"
|
|
);
|
|
|
|
// ── Section 3: the same A/B through the decorator stacks ────────────
|
|
println!(
|
|
"\n# [3] decorated stacks — pre-round-9 the unsynced call fell through to the synced (probing) route"
|
|
);
|
|
println!(
|
|
"{:<34} {:>10} {:>8} {:>8} {:>8}",
|
|
"variant", "wall ms", "HEADs", "PUTs", "vs OLD"
|
|
);
|
|
|
|
// Retry(S3)
|
|
let retry_stack: Arc<dyn BlobStorageBackend> = Arc::new(RetryBlobBackend::new(
|
|
backend.clone() as Arc<dyn BlobStorageBackend>,
|
|
RetryPolicy::default(),
|
|
));
|
|
stack_ab(
|
|
"retry(s3)",
|
|
retry_stack,
|
|
&counters,
|
|
chunks,
|
|
chunk_kb,
|
|
concurrency,
|
|
("b0b0", "b0b1"),
|
|
)
|
|
.await;
|
|
|
|
// Cache(S3) — count cache write-through population on both routes.
|
|
let cache_dir_a = tempfile::tempdir().expect("tempdir");
|
|
let cached_stack: Arc<dyn BlobStorageBackend> = Arc::new(CachedBlobBackend::new(
|
|
backend.clone() as Arc<dyn BlobStorageBackend>,
|
|
&BlobCacheConfig {
|
|
cache_dir: cache_dir_a.path().to_path_buf(),
|
|
max_cache_bytes: u64::MAX,
|
|
},
|
|
));
|
|
stack_ab(
|
|
"cache(s3)",
|
|
cached_stack,
|
|
&counters,
|
|
chunks,
|
|
chunk_kb,
|
|
concurrency,
|
|
("c0c0", "c0c1"),
|
|
)
|
|
.await;
|
|
// Write-through equivalence gate: BOTH routes populated the local cache
|
|
// (the round-9 override keeps post-upload read locality intact).
|
|
let cached_files = count_files(cache_dir_a.path());
|
|
if cached_files != 2 * chunks {
|
|
eprintln!(
|
|
"GATE FAIL [cache(s3)]: cache holds {cached_files} blobs (expected {} — write-through must populate on BOTH routes)",
|
|
2 * chunks
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
|
|
// Full production composition: Cache(Encrypted(Retry(S3))).
|
|
let cache_dir_b = tempfile::tempdir().expect("tempdir");
|
|
let full_stack: Arc<dyn BlobStorageBackend> = Arc::new(CachedBlobBackend::new(
|
|
Arc::new(EncryptedBlobBackend::new(
|
|
Arc::new(RetryBlobBackend::new(
|
|
backend.clone() as Arc<dyn BlobStorageBackend>,
|
|
RetryPolicy::default(),
|
|
)),
|
|
&[0x42u8; 32],
|
|
)),
|
|
&BlobCacheConfig {
|
|
cache_dir: cache_dir_b.path().to_path_buf(),
|
|
max_cache_bytes: u64::MAX,
|
|
},
|
|
));
|
|
let (full_before, full_after) = stack_ab(
|
|
"cache(enc(retry(s3)))",
|
|
full_stack,
|
|
&counters,
|
|
chunks,
|
|
chunk_kb,
|
|
concurrency,
|
|
("d0d0", "d0d1"),
|
|
)
|
|
.await;
|
|
println!(
|
|
"# full stack: a {chunks}-chunk upload sheds {} probe round-trips ({:.0} -> {:.0} ms at {rtt_ms} ms RTT)",
|
|
chunks, full_before, full_after
|
|
);
|
|
|
|
// ── Gates ───────────────────────────────────────────────────────────
|
|
if after_heads != 0 || after_puts != chunks as u64 {
|
|
eprintln!(
|
|
"GATE FAIL: AFTER issued {after_heads} HEADs / {after_puts} PUTs (expected 0 / {chunks})"
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
if after >= before {
|
|
eprintln!(
|
|
"GATE FAIL: AFTER ({after:.0} ms) not faster than BEFORE ({before:.0} ms) — rollback"
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
println!(
|
|
"GATE PASS: {}-request walk -> {} requests, {:.1}x faster",
|
|
before_heads + before_puts,
|
|
after_puts,
|
|
before / after
|
|
);
|
|
}
|