aba89c4f5d
Every change is benchmark-verified (harness + before/after numbers in benches/, measured on this branch; reproduction commands in each doc): DAV / sync-client hot paths - PROPFIND dead-properties: one = ANY($1) query per 500-child page instead of one sequential query per child, and indexable `=` predicates instead of IS NOT DISTINCT FROM (seq scans). 2,000-child folder: 1.07-4.54 s of DB chatter -> 4-6 ms (258-773x). Applied to native + NC PROPFIND and both NC REPORT handlers. [benches/DEAD-PROPS.md] - Folder paging: keyset cursor (name > $last) + new partial index (folder_id, name) replaces LIMIT/OFFSET full-folder rescan per page. Full 20k-file walk: 1266 ms -> 77 ms (16.5x). New migration 20260917000000. [benches/PROPFIND-PAGING.md] - NC chroot / default-drive resolution: moka caches (30 s TTL, explicit invalidation on drive mutations) for find_default_for_user and the markerless chroot FolderDto. 2 uncached queries + 2 pool checkouts per NC/WebDAV/WOPI request -> sub-us moka hit (p50 0.7-3.6 ms -> ~1 us). [benches/CHROOT-CACHE.md] - Quota: PROPFINDs whose prop list never names a quota prop skip the 2-query resolution entirely (wants_quota()); the remaining lookups read 2 columns instead of the full auth.users row with its <=512 KiB avatar (11-16x, p50 3.4 ms -> 0.29 ms). Same narrow read now gates every upload quota check. [benches/QUOTA-PATH.md] CPU on the request path - ZIP exports (folder download, share ZIP, batch download): entries whose MIME says already-compressed (JPEG/MP4/zip/pdf/...) are Stored instead of Deflate - deflate ran inline on the tokio writer task at ~41 MB/s for ~0% size gain. Mixed media corpus: 4.31x wall and CPU, archive size unchanged. Shared predicate in common::mime_detect. [benches/ZIP-MEDIA.md] - Compression layers: tower-http's default maps to Brotli QUALITY 11 (verified in brotli-8.0.2 source and empirically: 90 ms per 64 KiB JSON response, 1.3 s per 700 KiB bundle). Both layers pinned to Precise(4): 99x less CPU for ~15% more bytes. SPA assets are now precompressed at build time (scripts/precompress.mjs, 77% smaller) and served via ServeDir::precompressed_br/gzip: 2016x less per-request work, and clients get the better q11 bytes. [benches/STATIC-PRECOMPRESSED.md] Batched / cached backend paths [benches/NPLUS1-AND-CACHES.md] - Content-search ReBAC re-verification: new AuthorizationEngine::check_files_read_batch (default = old loop; PgAclEngine override batches drive resolution + reuses role cache). 200 sequential point SELECTs per search -> 1-2 queries. - Batch-ZIP subtree downloads: drop per-file re-authz + per-file Recent recording (2 writes/file) for subtree entries already authorized at the root - mirrors the native folder-download path. ~6,000 statements removed from a 2,000-file archive. - CDC chunk manifests: immutable by content address, now moka-cached (weight-bounded 32 MiB, 60 s TTL, positive-only, invalidated on delete) - removes one manifest query (p50 0.44-4.4 ms) from every stream, range and full blob read. - People tab: grouped COUNT + batched cover lookup instead of dragging every face row with its 2 KiB embedding (10k faces: 30.4 ms & 21 MB -> 3.8 ms & 1.3 KB, 8.1x); merge() is one set-based UPDATE. [benches/PEOPLE-LIST.md] - Photos timeline cursor: raw timestamptz comparison instead of EXTRACT(EPOCH ...) wrapper + IS NULL OR disjunction - cursor is an index boundary again, deep scroll stops re-scanning skipped rows. - Public share landing: one atomic UPDATE ... access_count + 1 (was SELECT + full-row write-back: racy, lost updates, clobbered concurrent owner edits) - 3 round-trips -> 2 per visit. - move_to_trash: dead full-entity SELECT feeding a documented no-op removed from both branches; dead fields dropped from TrashService. - NFC normalization: is_nfc_quick fast path skips the decompose/recompose state machine for the ~100% already-NFC case (every row loaded from PG). Frontend - Large folders paint after page one (~200 items) via fetchFolderListing's new onPage hook instead of waiting for every sequential page. - Tested-and-reverted (kept for the record): cached Intl.Collator for name sorts - vitest showed it 2x SLOWER than V8's argument-less localeCompare fast path (5.6 ms vs 12.1 ms / 5k names). Sort order untouched. New bench harnesses under examples/ (bench feature): zip_media, dead_props, chroot_cache, quota_path, people_list, propfind_paging, static_precompress. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CBK1RdtzyP6759Muqe1K1w
263 lines
8.6 KiB
Rust
263 lines
8.6 KiB
Rust
//! ZIP entry-compression benchmark — `Deflate`-always vs MIME-aware `Stored`.
|
||
//!
|
||
//! Isolates the ONE variable the ZIP-export change touches: the per-entry
|
||
//! `Compression` mode chosen by `ZipService::write_prefetched_file` /
|
||
//! `BatchOperations::add_file_entry_streamed`. It rebuilds the *exact*
|
||
//! production writer stack —
|
||
//!
|
||
//! `ZipFileWriter::with_tokio(BufWriter(File))` + `write_entry_stream`
|
||
//! fed in ~64 KiB chunks (the blob-stream chunk size)
|
||
//!
|
||
//! — and writes the same corpus once per mode, measuring wall time, process
|
||
//! CPU time (utime+stime from `/proc/self/stat`), and final archive size.
|
||
//!
|
||
//! Corpora:
|
||
//! • `media` — incompressible bytes (models JPEG/HEIC/MP4/WebP, the
|
||
//! dominant "download folder" payload). Deflate here is pure CPU burn.
|
||
//! • `text` — compressible text (models docs/source). Deflate genuinely
|
||
//! shrinks these; the MIME-aware change keeps deflating them.
|
||
//! • `mixed` — 80 % media / 20 % text by bytes: `all-Deflate` row is the
|
||
//! production behaviour BEFORE the change; `mime-aware` row (Stored for
|
||
//! media, Deflate for text) is AFTER.
|
||
//!
|
||
//! Run (no Postgres needed):
|
||
//! cargo run --release --features bench --example bench_zip_media
|
||
//! Tunables (env):
|
||
//! BENCH_MEDIA_FILES (48) BENCH_MEDIA_MB (4) per-file size
|
||
//! BENCH_TEXT_FILES (24) BENCH_TEXT_MB (2)
|
||
//! BENCH_REPS (3) median reported
|
||
|
||
use std::env;
|
||
use std::time::{Duration, Instant};
|
||
|
||
use async_zip::base::write::ZipFileWriter;
|
||
use async_zip::{Compression, ZipEntryBuilder};
|
||
use futures::io::AsyncWriteExt as FuturesWriteExt;
|
||
use tokio::io::BufWriter;
|
||
|
||
const CHUNK: usize = 64 * 1024; // blob-stream chunk size on the real path
|
||
|
||
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)
|
||
}
|
||
|
||
/// Process CPU seconds (user + system) from /proc/self/stat — covers all
|
||
/// threads, so it catches deflate work wherever tokio schedules it.
|
||
fn cpu_seconds() -> f64 {
|
||
let stat = std::fs::read_to_string("/proc/self/stat").expect("read /proc/self/stat");
|
||
// utime and stime are fields 14 and 15 (1-based), after the comm field
|
||
// which may contain spaces — skip past the closing paren first.
|
||
let after = &stat[stat.rfind(')').unwrap() + 2..];
|
||
let fields: Vec<&str> = after.split_whitespace().collect();
|
||
let utime: u64 = fields[11].parse().unwrap(); // field 14 overall
|
||
let stime: u64 = fields[12].parse().unwrap(); // field 15 overall
|
||
(utime + stime) as f64 / 100.0 // USER_HZ = 100 on Linux
|
||
}
|
||
|
||
/// Deterministic xorshift64* stream — incompressible "media" bytes.
|
||
fn fill_random(buf: &mut [u8], seed: &mut u64) {
|
||
for chunk in buf.chunks_mut(8) {
|
||
*seed ^= *seed << 13;
|
||
*seed ^= *seed >> 7;
|
||
*seed ^= *seed << 17;
|
||
let bytes = seed.wrapping_mul(0x2545F4914F6CDD1D).to_le_bytes();
|
||
let n = chunk.len();
|
||
chunk.copy_from_slice(&bytes[..n]);
|
||
}
|
||
}
|
||
|
||
/// Compressible pseudo-text (~3-4× deflate ratio, like real docs/source).
|
||
fn fill_text(buf: &mut [u8], seed: &mut u64) {
|
||
const WORDS: &[&str] = &[
|
||
"the",
|
||
"quick",
|
||
"brown",
|
||
"fox",
|
||
"jumps",
|
||
"over",
|
||
"lazy",
|
||
"dog",
|
||
"folder",
|
||
"file",
|
||
"storage",
|
||
"performance",
|
||
"benchmark",
|
||
"archive",
|
||
"download",
|
||
"stream",
|
||
];
|
||
let mut pos = 0;
|
||
while pos < buf.len() {
|
||
*seed ^= *seed << 13;
|
||
*seed ^= *seed >> 7;
|
||
*seed ^= *seed << 17;
|
||
let w = WORDS[(*seed as usize) % WORDS.len()].as_bytes();
|
||
let n = w.len().min(buf.len() - pos);
|
||
buf[pos..pos + n].copy_from_slice(&w[..n]);
|
||
pos += n;
|
||
if pos < buf.len() {
|
||
buf[pos] = b' ';
|
||
pos += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
struct CorpusFile {
|
||
name: String,
|
||
data: Vec<u8>,
|
||
is_media: bool,
|
||
}
|
||
|
||
struct RunResult {
|
||
wall: Duration,
|
||
cpu: f64,
|
||
bytes_out: u64,
|
||
}
|
||
|
||
/// Write the corpus through the exact production writer stack, choosing the
|
||
/// compression mode per entry with `pick`.
|
||
async fn write_zip(files: &[CorpusFile], pick: impl Fn(&CorpusFile) -> Compression) -> RunResult {
|
||
let temp = tempfile::NamedTempFile::new().expect("temp file");
|
||
let tokio_file = tokio::fs::File::create(temp.path()).await.expect("create");
|
||
let buf_writer = BufWriter::with_capacity(256 * 1024, tokio_file);
|
||
let mut zip = ZipFileWriter::with_tokio(buf_writer);
|
||
|
||
let cpu0 = cpu_seconds();
|
||
let t0 = Instant::now();
|
||
for f in files {
|
||
let entry = ZipEntryBuilder::new(f.name.clone().into(), pick(f));
|
||
let mut w = zip.write_entry_stream(entry).await.expect("entry start");
|
||
for chunk in f.data.chunks(CHUNK) {
|
||
w.write_all(chunk).await.expect("chunk write");
|
||
}
|
||
w.close().await.expect("entry close");
|
||
}
|
||
let mut compat = zip.close().await.expect("zip close");
|
||
compat.close().await.expect("flush");
|
||
let wall = t0.elapsed();
|
||
let cpu = cpu_seconds() - cpu0;
|
||
|
||
let bytes_out = std::fs::metadata(temp.path()).map(|m| m.len()).unwrap_or(0);
|
||
RunResult {
|
||
wall,
|
||
cpu,
|
||
bytes_out,
|
||
}
|
||
}
|
||
|
||
fn median(mut xs: Vec<f64>) -> f64 {
|
||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||
xs[xs.len() / 2]
|
||
}
|
||
|
||
#[tokio::main(flavor = "multi_thread")]
|
||
async fn main() {
|
||
let media_files: usize = env_or("BENCH_MEDIA_FILES", 48);
|
||
let media_mb: usize = env_or("BENCH_MEDIA_MB", 4);
|
||
let text_files: usize = env_or("BENCH_TEXT_FILES", 24);
|
||
let text_mb: usize = env_or("BENCH_TEXT_MB", 2);
|
||
let reps: usize = env_or("BENCH_REPS", 3);
|
||
|
||
let mut seed = 0x9E3779B97F4A7C15u64;
|
||
let mut corpus: Vec<CorpusFile> = Vec::new();
|
||
for i in 0..media_files {
|
||
let mut data = vec![0u8; media_mb * 1024 * 1024];
|
||
fill_random(&mut data, &mut seed);
|
||
corpus.push(CorpusFile {
|
||
name: format!("photos/IMG_{i:04}.jpg"),
|
||
data,
|
||
is_media: true,
|
||
});
|
||
}
|
||
for i in 0..text_files {
|
||
let mut data = vec![0u8; text_mb * 1024 * 1024];
|
||
fill_text(&mut data, &mut seed);
|
||
corpus.push(CorpusFile {
|
||
name: format!("docs/notes_{i:04}.txt"),
|
||
data,
|
||
is_media: false,
|
||
});
|
||
}
|
||
let media_bytes: usize = corpus
|
||
.iter()
|
||
.filter(|f| f.is_media)
|
||
.map(|f| f.data.len())
|
||
.sum();
|
||
let text_bytes: usize = corpus
|
||
.iter()
|
||
.filter(|f| !f.is_media)
|
||
.map(|f| f.data.len())
|
||
.sum();
|
||
let total_mb = (media_bytes + text_bytes) as f64 / 1048576.0;
|
||
println!(
|
||
"corpus: {} media files ({} MiB, incompressible) + {} text files ({} MiB, compressible), {} reps\n",
|
||
media_files,
|
||
media_bytes / 1048576,
|
||
text_files,
|
||
text_bytes / 1048576,
|
||
reps
|
||
);
|
||
|
||
// (label, per-entry compression picker)
|
||
type Picker = Box<dyn Fn(&CorpusFile) -> Compression>;
|
||
let modes: Vec<(&str, Picker)> = vec![
|
||
(
|
||
"all-Deflate (BEFORE)",
|
||
Box::new(|_: &CorpusFile| Compression::Deflate),
|
||
),
|
||
(
|
||
"mime-aware (AFTER) ",
|
||
Box::new(|f: &CorpusFile| {
|
||
if f.is_media {
|
||
Compression::Stored
|
||
} else {
|
||
Compression::Deflate
|
||
}
|
||
}),
|
||
),
|
||
(
|
||
"all-Stored (bound) ",
|
||
Box::new(|_: &CorpusFile| Compression::Stored),
|
||
),
|
||
];
|
||
|
||
println!(
|
||
"{:<22} {:>9} {:>9} {:>10} {:>11} {:>9}",
|
||
"mode", "wall s", "cpu s", "MB/s", "out MiB", "ratio"
|
||
);
|
||
let mut baseline_wall = None;
|
||
for (label, pick) in &modes {
|
||
let mut walls = Vec::new();
|
||
let mut cpus = Vec::new();
|
||
let mut out = 0u64;
|
||
for _ in 0..reps {
|
||
let r = write_zip(&corpus, pick).await;
|
||
walls.push(r.wall.as_secs_f64());
|
||
cpus.push(r.cpu);
|
||
out = r.bytes_out;
|
||
}
|
||
let wall = median(walls);
|
||
let cpu = median(cpus);
|
||
let speedup = baseline_wall
|
||
.map(|b: f64| format!("{:.2}x", b / wall))
|
||
.unwrap_or_else(|| "1.00x".into());
|
||
if baseline_wall.is_none() {
|
||
baseline_wall = Some(wall);
|
||
}
|
||
println!(
|
||
"{:<22} {:>9.3} {:>9.2} {:>10.1} {:>11.1} {:>9}",
|
||
label,
|
||
wall,
|
||
cpu,
|
||
total_mb / wall,
|
||
out as f64 / 1048576.0,
|
||
speedup
|
||
);
|
||
}
|
||
println!("\n(archive `out MiB` for mime-aware stays ~= all-Deflate: media doesn't deflate,");
|
||
println!(" text keeps Deflate — the win is CPU/wall, not size loss)");
|
||
}
|