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
191 lines
6.8 KiB
Rust
191 lines
6.8 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.
|
|
//!
|
|
//! Gates: AFTER request count == chunks (vs 2x), AFTER wall < BEFORE wall.
|
|
//!
|
|
//! 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::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)
|
|
}
|
|
|
|
#[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<S3BlobBackend>,
|
|
chunks: usize,
|
|
chunk_kb: usize,
|
|
concurrency: usize,
|
|
unsynced: bool,
|
|
) -> 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();
|
|
set.spawn(async move {
|
|
let _permit = sem.acquire().await.expect("sem");
|
|
let hash = format!("{i:064x}");
|
|
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")
|
|
};
|
|
assert_eq!(n as usize, chunk_kb * 1024);
|
|
});
|
|
}
|
|
while let Some(r) = set.join_next().await {
|
|
r.expect("join");
|
|
}
|
|
t.elapsed().as_secs_f64() * 1000.0
|
|
}
|
|
|
|
#[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(), chunks, chunk_kb, concurrency, false).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(), chunks, chunk_kb, concurrency, true).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"
|
|
);
|
|
|
|
// ── 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
|
|
);
|
|
}
|