Files
Oxicloud/examples/bench_upload_spool.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

191 lines
6.8 KiB
Rust

//! Upload spool/assembly I/O benchmark — buffer sizing on the chunk paths.
//!
//! Section 1 — assembly read (`stream_from_files`): every completed chunked
//! upload is read back once, part file by part file, through
//! `ReaderStream::with_capacity(file, N)`. Each poll is one blocking-pool
//! dispatch + one read(2) of N bytes; the shipped capacity was 64 KiB while
//! every other blob read path uses 256 KiB+. Sweeps N over
//! 64K/256K/512K/1M and reports wall time + read syscalls.
//!
//! Section 2 — chunk spool write (`stream_body_to_path`): the PUT handlers
//! wrote each HTTP frame (~16-64 KiB) straight to a bare tokio File — one
//! blocking-pool dispatch + write(2) per frame. Compares that against the
//! adopted `BufWriter::with_capacity(512 KiB)`.
//!
//! No Postgres. Run:
//! cargo run --release --features bench --example bench_upload_spool
//! Tunables: BENCH_PARTS (16), BENCH_PART_MB (10), BENCH_FRAME_KB (16),
//! BENCH_SPOOL_MB (10), BENCH_REPS (5)
use std::env;
use std::path::PathBuf;
use std::time::Instant;
use bytes::Bytes;
use futures::{StreamExt, TryStreamExt, stream};
use tokio::io::AsyncWriteExt;
use tokio_util::io::ReaderStream;
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)
}
/// (read syscalls, write syscalls) from /proc/self/io.
fn io_counters() -> (u64, u64) {
let s = std::fs::read_to_string("/proc/self/io").expect("io");
let get = |k: &str| {
s.lines()
.find(|l| l.starts_with(k))
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|v| v.parse().ok())
.unwrap_or(0)
};
(get("syscr:"), get("syscw:"))
}
fn median(mut xs: Vec<f64>) -> f64 {
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
xs[xs.len() / 2]
}
/// The `stream_from_files` shape with a parameterized capacity.
async fn drain_parts(paths: Vec<PathBuf>, cap: usize) -> (u64, [u8; 32]) {
let mut hasher = blake3::Hasher::new();
let mut total = 0u64;
let s = stream::iter(paths.into_iter().map(Ok::<_, std::io::Error>))
.and_then(|path| async move {
tokio::fs::File::open(path)
.await
.map(|file| ReaderStream::with_capacity(file, cap))
})
.try_flatten();
let mut s = Box::pin(s);
while let Some(chunk) = s.next().await {
let chunk = chunk.expect("read");
total += chunk.len() as u64;
hasher.update(&chunk);
}
(total, hasher.finalize().into())
}
/// The `stream_body_to_path` inner loop: frames -> file, optionally buffered.
async fn spool_frames(frames: &[Bytes], path: &std::path::Path, buffered: bool) {
let file = tokio::fs::File::create(path).await.expect("create");
if buffered {
let mut w = tokio::io::BufWriter::with_capacity(512 * 1024, file);
for f in frames {
w.write_all(f).await.expect("write");
}
w.flush().await.expect("flush");
} else {
let mut w = file;
for f in frames {
w.write_all(f).await.expect("write");
}
w.flush().await.expect("flush");
}
}
#[tokio::main(flavor = "multi_thread")]
async fn main() {
let parts: usize = env_or("BENCH_PARTS", 16);
let part_mb: usize = env_or("BENCH_PART_MB", 10);
let frame_kb: usize = env_or("BENCH_FRAME_KB", 16);
let spool_mb: usize = env_or("BENCH_SPOOL_MB", 10);
let reps: usize = env_or("BENCH_REPS", 5);
let dir = tempfile::tempdir().expect("tempdir");
// ── Section 1: assembly read capacity sweep ─────────────────────────
println!("# [1] assembly read: {parts} x {part_mb} MiB part files, warm page cache");
let mut paths = Vec::with_capacity(parts);
let payload: Vec<u8> = (0..part_mb * 1024 * 1024)
.map(|i| (i * 31 % 251) as u8)
.collect();
for i in 0..parts {
let p = dir.path().join(format!("part_{i:05}"));
tokio::fs::write(&p, &payload).await.expect("seed part");
paths.push(p);
}
let expect_total = (parts * part_mb * 1024 * 1024) as u64;
let (_, ref_hash) = drain_parts(paths.clone(), 256 * 1024).await;
println!(
"{:<10} {:>10} {:>12} {:>8}",
"capacity", "wall ms", "read sysc", "vs 64K"
);
let mut base: Option<f64> = None;
for cap in [64 * 1024, 256 * 1024, 512 * 1024, 1024 * 1024] {
let mut walls = Vec::with_capacity(reps);
let mut syscr = 0u64;
for _ in 0..reps {
let (r0, _) = io_counters();
let t = Instant::now();
let (total, h) = drain_parts(paths.clone(), cap).await;
walls.push(t.elapsed().as_secs_f64() * 1000.0);
let (r1, _) = io_counters();
syscr = r1 - r0;
assert_eq!(total, expect_total);
assert_eq!(h, ref_hash, "content mismatch at capacity {cap}");
}
let ms = median(walls);
let speedup = base
.map(|b| format!("{:.2}x", b / ms))
.unwrap_or_else(|| "1.00x".into());
if base.is_none() {
base = Some(ms);
}
println!(
"{:<10} {:>10.1} {:>12} {:>8}",
format!("{}K", cap / 1024),
ms,
syscr,
speedup
);
}
// ── Section 2: chunk spool write, per-frame vs buffered ─────────────
let frames_n = spool_mb * 1024 / frame_kb;
println!(
"\n# [2] chunk spool: {frames_n} x {frame_kb} KiB frames ({spool_mb} MiB), 20 files/rep"
);
let frame: Bytes = Bytes::from(vec![0xabu8; frame_kb * 1024]);
let frames: Vec<Bytes> = (0..frames_n).map(|_| frame.clone()).collect();
println!(
"{:<22} {:>10} {:>12} {:>8}",
"variant", "wall ms", "write sysc", "vs bare"
);
let mut base: Option<f64> = None;
for (label, buffered) in [
("bare File (BEFORE)", false),
("BufWriter 512K (AFTER)", true),
] {
let mut walls = Vec::with_capacity(reps);
let mut syscw = 0u64;
for r in 0..reps {
let (_, w0) = io_counters();
let t = Instant::now();
for i in 0..20 {
let p = dir.path().join(format!("spool_{r}_{i}"));
spool_frames(&frames, &p, buffered).await;
tokio::fs::remove_file(&p).await.ok();
}
walls.push(t.elapsed().as_secs_f64() * 1000.0);
let (_, w1) = io_counters();
syscw = w1 - w0;
}
let ms = median(walls);
let speedup = base
.map(|b| format!("{:.2}x", b / ms))
.unwrap_or_else(|| "1.00x".into());
if base.is_none() {
base = Some(ms);
}
println!("{label:<22} {:>10.1} {:>12} {:>8}", ms, syscw, speedup);
}
}