diff --git a/Cargo.toml b/Cargo.toml index c1a96ff3..a6993549 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -354,6 +354,31 @@ name = "bench_micro_allocs" path = "examples/bench_micro_allocs.rs" required-features = ["bench"] +# Round-12 battery ──────────────────────────────────────────────────────────── + +# Round-12 query-shape pack — sharee narrow read + trgm, login/email stamp +# narrowing, session-rotation fused txn, WOPI triple join!, fused quota pair +# (needs the dev Postgres up). +[[example]] +name = "bench_round12_queries" +path = "examples/bench_round12_queries.rs" +required-features = ["bench"] + +# Round-12 CPU/alloc micro-pack — sized listing JSON, single-pass compression +# predicate, fused security-header middleware, media single-read extraction, +# chunked-session fused lookups. No Postgres. +[[example]] +name = "bench_round12_micro" +path = "examples/bench_round12_micro.rs" +required-features = ["bench"] + +# Blob-cache index — Mutex vs moka byte-weigher (index scaling, +# warm-hit reads, eviction-unlink + single-flight safety gates). No Postgres. +[[example]] +name = "bench_blob_cache_index" +path = "examples/bench_blob_cache_index.rs" +required-features = ["bench"] + # Round-11 battery ──────────────────────────────────────────────────────────── # Round-11 CPU/alloc micro-pack — download DTO hand-off, Last-Modified stack diff --git a/benches/ROUND12.md b/benches/ROUND12.md new file mode 100644 index 00000000..18f98e81 --- /dev/null +++ b/benches/ROUND12.md @@ -0,0 +1,291 @@ +# Round 12 — auth write-path narrowing, fused quota gate, moka blob-cache index, media single-read, sized listing JSON + +Benchmark-gated, same rule as ROUND2-11: every change ships with a +BEFORE/AFTER benchmark and an equivalence/safety gate; an AFTER that doesn't +beat its BEFORE gets rolled back or redesigned. One candidate went through +exactly that loop this round (§Rejected): the single-pass compression +predicate — the profiler-plausible "28 redundant Content-Type reads" turned +out to cost ~4.6 ns TOTAL once monomorphized, and the fused replacement +measured within noise, so the declarative chain stays. + +Measured on 4 cores / 15 GiB, local PostgreSQL 16 (fsync off), release +profile; frontend on Node 22 / vitest 4. Reproduce any row with the command +in its section. + +## Summary + +| # | change | key metric | before → after | +|--:|---|---|---| +| Q1 | NC sharee search: username-only projection (was 21 wide columns incl. the ≤512 KiB avatar per match) | 26-row page, 3 000 users, all matches avatared | 11.77 → 2.37 ms (**4.98x**) | +| Q1b | + `gin_trgm_ops` indexes on `auth.users` (migration 20260719000000) | same page, leading-wildcard ILIKE | → 0.215 ms (**54.7x** total) | +| Q2 | Password login: redundant full-row `update_user` deleted (`create_session` already stamps `last_login_at`) | ms/login, 256 KiB avatar | 2.96 → 0.67 (**4.45x**) · −1 txn, −17-column rewrite, −512 KiB clone | +| Q3 | Email-verified stamp → narrow conditional UPDATE (magic-link) | ms/stamp | 2.20 → 0.25 (**8.9x**) | +| Q3b | OIDC repeat login → in-memory compare, sync only on change | queries per repeat login | full-row rewrite (2.37 ms) → **0 queries** | +| Q4 | Refresh-token rotation: 2 transactions → 1 (`rotate_session`) | ms/rotation | 1.135 → 0.959 (**1.18x**) | +| Q5 | WOPI CheckFileInfo triple → `tokio::join!` (real `PgAclEngine`) | ms/call | cold 0.485 → 0.363 (**1.34x**) · warm 0.228 → 0.209 | +| Q6 | Upload quota pair → ONE fused read (user envelope + drive cap) — NC chunk PUT pays it per chunk | ms/check | 0.350 → 0.193 (**1.81x**) · 2 → 1 queries/chunk | +| M1 | Listing JSON: pre-sized buffer (`sized_json`) vs axum `Json`'s 128 B seed | 500-row page | 282.4 → 201.0 µs (**1.40x**) · 13 → 2 allocs | +| M3 | Security headers: 4 `SetResponseHeaderLayer` + CSP middleware → 1 fused pass | per request (incl. router) | 5.35 → 3.74 µs (**1.43x**) · −26 allocs | +| M4 | Media capture-metadata: single-read (images were read 2-3×, videos opened 2×) | warm geomean / cold cache | **1.44x** warm · **1.6-3.2x** cold · opens 2-3 → 1 | +| M5 | Chunked-upload session ops: 5 → 3 map lookups + stack-encoded uuid compare | ns per chunk (prepare+commit) | 469 → 366 (**1.28x**) · −2 allocs | +| B1 | Blob-cache index: `Mutex` → moka byte-weigher | pure index probes, K readers | K=2 **2.17x**, K=4 1.61x, K=8 1.46x (mutex scaled NEGATIVELY: 2.08 → 1.07 Mops/s from 1 → 2 readers) | +| B2 | `put_blob` populates the cache BEFORE the inner backend consumes the source (was: after → failed 100%) | first read after whole-file put | full remote re-download → local hit | +| F1 | SPA list view: 150 px `icon` thumbnails (was 400 px `preview` into a 40 px slot) | pixels per list thumbnail | **~7.1x fewer** (≈4-5x fewer bytes) | + +## [Q1] NC sharee search — the 512 KiB-per-row autocomplete + +``` +cargo run --release --features bench --example bench_round12_queries # §1 +``` + +`handle_sharees_search` fired `search_users` per keystroke — the full +21-column row (incl. the ≤512 KiB avatar data-URI `image`, TOAST-detoasted +per match) hydrated into `User` → `UserDto`, of which the handler read ONLY +`username`. And the leading-wildcard `ILIKE '%q%'` had no trigram index, so +every keystroke seq-scanned `auth.users` (contacts/files/folders all have +`gin_trgm_ops`; users was the gap). Now: `search_usernames` port method +(same WHERE/ORDER/LIMIT, username-only projection; NULL usernames filtered +app-side exactly like the wide flow's post-limit filter) + the two trgm +indexes. Gates: identical username lists, with and without the indexes. +The wide method stays for the admin table (which serializes `image`). + +## [Q2][Q3][Q3b] Auth write-path narrowing + +``` +cargo run --release --features bench --example bench_round12_queries # §2-3 +``` + +- **Login** ran `update_user(user.clone())` — a transaction rewriting all + 17 columns (incl. the avatar, plus a 512 KiB deep clone to feed it) — + purely to persist `last_login_at`… which `create_session` overwrites in + its own transaction three lines later. Nothing reads the row in between + (verified). The call is deleted; the in-memory `register_login()` stays + so the response DTO carries the timestamp. +- **Magic-link redemption** kept its `update_user` for the email-verified + stamp only (last-login again covered by `create_session`) — now a narrow + `WHERE … AND email_verified_at IS NULL` single-column UPDATE, idempotency + moved into SQL (gated: second stamp is a 0-row no-op, first timestamp + preserved). +- **OIDC repeat login** additionally syncs the IdP avatar. The row fetched + by `get_user_by_oidc_subject` already carries the stored avatar + + verification stamp, so the service now compares IN MEMORY and issues NO + query at all on the repeat-login common case (same picture, already + verified) — the bench's §3b arm is the reason: even a guarded + `IS DISTINCT FROM` no-op UPDATE ships the ≤512 KiB avatar parameter over + the wire just to compare it (1.20 ms vs the 2.37 ms full-row rewrite; + the in-memory skip makes it 0). When something DID change, + `sync_oidc_login_profile` runs the guarded narrow UPDATE (image + + conditional stamp, `update_storage_usage` pattern) instead of the + 17-column rewrite. + +## [Q4] Refresh rotation — one transaction + +`refresh_token` paid two full BEGIN/COMMIT pairs per rotation +(`revoke_session` then `create_session`), and DAV clients rotate +constantly. New `rotate_session(old_id, new_session)` port method: revoke + +insert + last-login stamp in one `with_transaction`. Gates: old session +revoked, new session live, reuse-detection semantics untouched (family +revocation still fires on replay). The per-rotation "Session … revoked" +info-line is gone with the old method call (routine rotation is not a +security event; explicit logout/family revocation still log). + +## [Q5] WOPI CheckFileInfo — three independent lookups overlapped + +The handler ran require(Read) → get_file → check(Update) serially; all +three key off `(caller, file)` alone. Now `tokio::join!` with results +evaluated in the original precedence (Read gate first, then 404, then the +can_write hint — deny responses byte-identical; the Update probe still +skips its query when the token has no write claim). Same fusion applied to +`authorize_wopi_access` (host page / editor-url). Cold is the shape that +matters: office editors poll CheckFileInfo through a session, but each +(file × TTL-window) pays the cold chain once. + +## [Q6] Fused upload-quota gate + +`refuse_if_over_quota` (NC chunked PUT — runs on EVERY chunk) issued the +user-envelope read and the drive-cap read serially. One `LEFT JOIN` row +now carries both counter pairs; the verdict evaluators were extracted +(`eval_user_envelope` / `eval_drive_cap`) and are shared by the old point +methods and the fused one, so every error string is identical by +construction. Gates: verdict identity across ok / drive-over / user-over +(precedence) / unlimited / missing-drive. A `check_upload_quotas_by_folder` +twin exists for folder-keyed callers; the three REST once-per-upload pair +sites were left as-is (their two checks carry different rejection logs, and +one query per whole upload isn't worth entangling that — see §Skipped). + +## [M1] `sized_json` — the 128-byte seed on every listing + +``` +cargo run --release --features bench --example bench_round12_micro # §1 +``` + +axum's `Json` serializes into `BytesMut::with_capacity(128)`; a 500-row +listing (~190 KB) grows it through ~11 doubling reallocs, memcpy-ing ~1.3× +the payload. `interfaces::api::sized_json` pre-sizes from the row count +(FileDto ≈ 380 B serialized; estimate 384) and serves byte-identical output +(gated). Applied to the four hot listing responses: `list_files` (which is +UNBOUNDED — no page cap), folder resources, photos timeline, search (both +verbs). + +## [M3] Security-header stack 5 → 1 + +The CSP middleware already post-processed every response; the four static +headers (`x-content-type-options`, `x-frame-options`, `referrer-policy`, +`permissions-policy`) each rode their own `SetResponseHeaderLayer` on top. +Folded into the same pass — inserted before the 304 early-return because +the standalone layers stamped 304s too. Gate: status + full sorted header +set byte-identical for json / html / 304 through real axum routers. + +## [M4] Media capture-metadata single-read (the ROUND11 deferred lead) + +``` +cargo run --release --features bench --example bench_round12_micro # §4 +``` + +`extract_blocking` read each image once wholesale for kamadak, then +nom-exif re-opened the SAME file (`read_exif(path)`), and date-less images +paid a third open (`read_track(path)` fallback). Videos opened twice (a +doomed `read_exif` sniff, then `read_track`). Now: nom-exif parses from the +kamadak buffer zero-copy (`MediaSource::from_memory` over the same `Bytes` +allocation, API verified on the pinned 3.6.1), one reused `MediaParser`, +and videos open once with a `kind()` dispatch. The track fallback for +images SURVIVES (fed from the same bytes) — it covers MIME-mislabeled rows, +the only case where it ever produced a date; behaviour is +observable-identical (gated over dated/undated JPEG, PNG, crafted MP4 — +corpus asserted non-vacuous: the crafted EXIF date and mvhd creation time +must actually extract). Warm: 1.44x geomean. Cold cache (`drop_caches` +arms): dated JPEG 0.81 → 0.34 ms, undated 0.97 → 0.30, PNG 0.12 → 0.06, +MP4 0.050 → 0.032. Per-image opens 2-3 → 1; the backfill sweeps multiply +this by the library size. + +## [M5] Chunked-upload session ops + +`prepare_chunk` ran `verify_session_owner` (own DashMap lookup + a +`Uuid::to_string`) then re-fetched the same entry; `commit_chunk` did the +same plus its `get_mut` (3 lookups + allocation per chunk). The owner gate +now rides the operation's own lookup (same anti-enum not-found for unknown +and foreign sessions — gated), and the uuid compares against a +stack-encoded hyphenated form. 5 → 3 shard-lock round-trips and −2 allocs +per chunk cycle. + +## [B1][B2] Blob-cache: moka byte-weigher index + the put_blob ordering fix + +``` +cargo run --release --features bench --example bench_blob_cache_index +cargo run --release --features bench --example bench_blob_cache # regression guard +``` + +The ROUND11 deferred headline. The cache index was a +`tokio::sync::Mutex`: every cached chunk read took the one global +async mutex to probe+promote (LRU `get` needs `&mut`), so a 100-chunk video +playback was 100 serialized critical sections and concurrent readers +contended process-wide — measured NEGATIVE scaling (2.08 → 1.07 Mops/s +going from 1 to 2 readers). `moka::sync::Cache` with a byte weigher makes +the probe lock-free (K=2 **2.17x**, K=8 1.46x; end-to-end warm reads with +real files 1.00-1.15x on this 4-core box — the gap is the index share of +the path and widens with cores/readers). moka also absorbs the byte budget: +the manual `current_size` counter + `collect_evictions` sweep are gone; an +eviction listener unlinks size-evicted `.blob` files. Safety gates: budget +enforced (100 × 1 MiB into a 10 MiB cap → ≥88 files unlinked, survivors +readable), a Replaced entry does NOT unlink its file, Explicit +invalidations unlink at their call sites, and the per-hash single-flight +still collapses 16 concurrent misses to 1 fetch. The `CachedRef` clone +bundle (incl. a `cache_dir` PathBuf clone paid on every HIT for a miss-only +struct) is gone — internals now borrow `self`. + +Two behavioural notes, both strict improvements: the write-through PUT +paths now respect the byte budget (the old index deliberately skipped +eviction there, letting write bursts overshoot until the next read-miss); +and a restored over-budget cache trims at startup instead of on the next +insert. + +**B2 (the ROUND11 correctness note):** `put_blob` populated the cache AFTER +`inner.put_blob` — but every inner backend consumes the source file (local +renames it, S3/Azure delete it post-upload), so the `fs::copy` failed 100% +of the time, silently (`let _`), and the first read after a whole-file put +(the backend-migration copier) re-downloaded the blob from the remote. +Cache-first now, with invalidate+unlink if the inner put fails so a +rejected blob can never be served. The round-3 stampede guard re-run passes +against the migrated backend (16 → 1 remote fetches, cache file verified). + +## [F1] SPA list-view thumbnails (vitest gate) + +``` +cd frontend && npx vitest run src/lib/api/endpoints/round12.bench.test.ts +``` + +Both views requested the 400 px `preview` rendition; the list row draws it +in a 40×40 slot (the 150 px `icon` rendition is already ≥2× retina density +there). `thumbSizeForView` switches list rows to `icon` — ~7.1x fewer +pixels per thumbnail, roughly 4-8 KB vs 20-40 KB encoded WebP each, across +files/recent/favorites/trash/shared list views. Grid keeps `preview` +(100×70 slot at 2x DPR genuinely needs it). + +## Rejected / reworked this round (the discipline working) + +- **Single-pass compression predicate**: the sweep flagged "~28 redundant + Content-Type header reads per compressible response" in `main.rs`'s + `And`-chain. The bench says otherwise: the monomorphized chain runs in + **4.6 ns / 0 allocs** total (straight-line inlined probes), and the + hand-fused single-pass node measured 5.2 ns on the compressible hot case + — within noise, sometimes slower. Not shipped; the declarative chain + stays. `bench_round12_micro` §2 keeps the reproducible evidence. + +## Considered and skipped (cost/benefit, not measurement) + +- **REST per-upload quota pair fusion** (multipart / native-chunked / + delta): the two checks sit in separate `if` blocks with distinct + rejection logs and folder-id guards; fusing saves ONE query per whole + upload (not per chunk) and would entangle that flow. The NC per-chunk + site — the hot one — is fused (Q6). +- **NC per-session quota budget cache** (0 queries per chunk instead of 1): + needs a staleness/invalidation story vs concurrent sessions; the fused + read already halves the per-chunk cost with bit-identical semantics. + Flagged for a future round. +- **`lto = "fat"` on the release profile**: the bench profile already uses + it; flipping release trades a large link-time regression for every + contributor and CI/Docker build against a low-single-digit runtime gain. + That's a project-level call for maintainers, not a bench-gated code + change — flagged, not shipped. + +## Deferred / flagged (not shipped this round) + +- **Grouped file/grid views are still unvirtualized** (files route + `groupBy != ''` mounts EVERY row in both view modes; ResourceList's + grouped GRID branch too — trash is grouped-by-default). Design prepared + this round: flatten groups into the existing `VirtualRows` + (photos-timeline pattern — headers as first-class rows, grid rows as + fixed-height strips of `gridColumns(width)` tiles), which also collapses + the per-section `VirtualList` scroll listeners the grouped LIST path + pays today (one `getBoundingClientRect` per section per scroll tick). + This is the next round's headline; it wants its own pass with UI gates. +- **Duplicate `TraceLayer` on `/api`** (`routes.rs` layers it again under + the global `ClientIpMakeSpan` layer) and the **per-request `client_ip` + String** in the span factory — small, want their own measured arms. +- **`CachedBlobBackend::local_blob_path` sync `stat`** (ROUND10/11 flag + stands — background-extraction paths only; needs an async port variant). +- **Media hooks read the same blob up to 3×** per upload (thumbnail + + capture-metadata + faces each pull it independently; the latter two read + the RAW blob path directly, bypassing the content cache — and, on + encrypted deployments, reading ciphertext: correctness note for + maintainers, same class as the ROUND11 put_blob note). +- **`mp3_duration::from_path` full-file frame scan** runs even when the + ID3 `TLEN` tag is present (ingest-path only). Preferring TLEN is a + speed/accuracy tradeoff on VBR files — maintainer call. +- **Thumbnail orientation re-parses EXIF** that capture-metadata also + parses; reusing the persisted `orientation` is ordering-dependent + (hooks run concurrently) — needs a small sequencing decision. + +## Environment / methodology + +- `cargo run --release --features bench --example bench_round12_queries` + — needs Postgres; seeds and sweeps its own fixtures (BENCH_PASSES, + BENCH_SHR_USERS, BENCH_WOPI_FILES, BENCH_WARM_ITERS). +- `cargo run --release --features bench --example bench_round12_micro` + — counting allocator; §4's cold arms drop the page cache (root; set + BENCH_COLD_ITERS=0 to skip). +- `cargo run --release --features bench --example bench_blob_cache_index` + — index scaling + eviction/single-flight safety gates. +- `cargo run --release --features bench --example bench_blob_cache` + — round-3 cross-round regression guard (passes against the moka index). +- `cd frontend && npx vitest run src/lib/api/endpoints/round12.bench.test.ts`. diff --git a/examples/bench_blob_cache_index.rs b/examples/bench_blob_cache_index.rs new file mode 100644 index 00000000..7ffb118b --- /dev/null +++ b/examples/bench_blob_cache_index.rs @@ -0,0 +1,422 @@ +//! Blob-cache index benchmark — `Mutex` vs moka byte-weigher +//! (the ROUND11 deferred lead; no Postgres). +//! +//! `CachedBlobBackend` keeps its cache index in a +//! `tokio::sync::Mutex>`: EVERY cached chunk +//! read acquires the one global async mutex to probe + LRU-promote (the +//! promote needs `&mut`), so N-core read concurrency collapses onto a +//! single serialization domain — and an N-chunk CDC file read is N +//! acquisitions, with every other concurrent reader contending. +//! +//! AFTER: a `moka::sync::Cache` with a byte weigher — lock-free sharded +//! reads with striped recency, byte-budget eviction handled by moka +//! (replacing the manual `current_size` + `collect_evictions` machinery), +//! and an eviction listener that unlinks the evicted `.blob` file (only on +//! size-eviction — Replaced/Explicit must NOT unlink, gated below). +//! +//! Arms: +//! [1] pure index ops, K tasks × M hit-probes (the scaling ceiling) +//! [2] end-to-end warm-hit read (index probe + open + 64 KiB read), +//! K = 1/2/4/8 readers over a shared corpus +//! [3] safety gates: byte budget enforced + evicted files unlinked + +//! replaced entries keep their file + single-flight still coalesces +//! K concurrent misses onto 1 inner fetch +//! +//! Run: +//! cargo run --release --features bench --example bench_blob_cache_index +//! Tunables (env): BENCH_OPS (200000), BENCH_FILES (256), BENCH_READERS (8) + +use std::num::NonZeroUsize; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +use lru::LruCache; +use tokio::sync::Mutex; + +fn env_or(key: &str, default: T) -> T { + std::env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +#[derive(Debug, Clone)] +struct CacheEntry { + size: u64, +} + +/// BEFORE, verbatim: the shipped index shape + the per-hit prologue +/// allocations of `get_blob_stream` (hash `to_string`, `cached_path` +/// build, unconditional `cache_dir.clone()`). +struct BeforeIndex { + cache_dir: PathBuf, + index: Arc>>, +} + +impl BeforeIndex { + fn cached_path(&self, hash: &str) -> PathBuf { + let prefix = &hash[..2.min(hash.len())]; + self.cache_dir.join(prefix).join(format!("{hash}.blob")) + } + + /// The exact hit-path prologue of `get_blob_stream`. + async fn hit_probe(&self, hash: &str) -> Option { + let hash = hash.to_string(); + let cached = self.cached_path(&hash); + let _cache_dir = self.cache_dir.clone(); // paid on hits, used on misses + if self.index.lock().await.get(&hash).is_some() { + return Some(cached); + } + None + } +} + +/// AFTER: moka byte-weigher index + borrow-only hit prologue. +struct AfterIndex { + cache_dir: PathBuf, + index: moka::sync::Cache, +} + +impl AfterIndex { + fn new(cache_dir: PathBuf, max_bytes: u64) -> Self { + Self { + cache_dir, + index: moka::sync::Cache::builder() + .weigher(|_k: &String, e: &CacheEntry| e.size.clamp(1, u32::MAX as u64) as u32) + .max_capacity(max_bytes) + .build(), + } + } + + fn cached_path(&self, hash: &str) -> PathBuf { + let prefix = &hash[..2.min(hash.len())]; + self.cache_dir.join(prefix).join(format!("{hash}.blob")) + } + + fn hit_probe(&self, hash: &str) -> Option { + if self.index.get(hash).is_some() { + return Some(self.cached_path(hash)); + } + None + } +} + +// ──────────────────────────────────────────────────────────────────────────── + +async fn section_index_ops(hashes: Arc>) { + let ops: usize = env_or("BENCH_OPS", 200_000); + let readers_max: usize = env_or("BENCH_READERS", 8); + + let before = Arc::new(BeforeIndex { + cache_dir: PathBuf::from("/tmp/bench-blob-idx"), + index: Arc::new(Mutex::new(LruCache::new( + NonZeroUsize::new(1_000_000).unwrap(), + ))), + }); + let after = Arc::new(AfterIndex::new( + PathBuf::from("/tmp/bench-blob-idx"), + u64::MAX, + )); + for h in hashes.iter() { + before + .index + .lock() + .await + .put(h.clone(), CacheEntry { size: 1024 }); + after.index.insert(h.clone(), CacheEntry { size: 1024 }); + } + + println!("\n## [1] Pure index hit-probes (ops total = {ops}, split across K tasks)"); + println!("| K | BEFORE Mutex Mops/s | AFTER moka Mops/s | speedup |"); + for k in [1usize, 2, 4, 8].into_iter().filter(|k| *k <= readers_max) { + let per_task = ops / k; + + let t = Instant::now(); + let mut handles = Vec::new(); + for t_id in 0..k { + let idx = before.clone(); + let hs = hashes.clone(); + handles.push(tokio::spawn(async move { + for i in 0..per_task { + let h = &hs[(i * 31 + t_id * 7) % hs.len()]; + std::hint::black_box(idx.hit_probe(h).await); + } + })); + } + for h in handles { + h.await.unwrap(); + } + let before_mops = (per_task * k) as f64 / t.elapsed().as_secs_f64() / 1e6; + + let t = Instant::now(); + let mut handles = Vec::new(); + for t_id in 0..k { + let idx = after.clone(); + let hs = hashes.clone(); + handles.push(tokio::spawn(async move { + for i in 0..per_task { + let h = &hs[(i * 31 + t_id * 7) % hs.len()]; + std::hint::black_box(idx.hit_probe(h)); + } + })); + } + for h in handles { + h.await.unwrap(); + } + let after_mops = (per_task * k) as f64 / t.elapsed().as_secs_f64() / 1e6; + + println!( + "| {k} | {before_mops:>10.2} | {after_mops:>10.2} | {:>6.2}x |", + after_mops / before_mops + ); + } +} + +async fn section_warm_reads(hashes: Arc>) { + let readers_max: usize = env_or("BENCH_READERS", 8); + let reads: usize = 20_000; + + // Real cached files on disk (64 KiB each). + let dir = PathBuf::from("/tmp/bench-blob-idx"); + let _ = std::fs::remove_dir_all(&dir); + let payload = vec![0xA5u8; 64 * 1024]; + let before = Arc::new(BeforeIndex { + cache_dir: dir.clone(), + index: Arc::new(Mutex::new(LruCache::new( + NonZeroUsize::new(1_000_000).unwrap(), + ))), + }); + let after = Arc::new(AfterIndex::new(dir.clone(), u64::MAX)); + for h in hashes.iter() { + let p = before.cached_path(h); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(&p, &payload).unwrap(); + before.index.lock().await.put( + h.clone(), + CacheEntry { + size: payload.len() as u64, + }, + ); + after.index.insert( + h.clone(), + CacheEntry { + size: payload.len() as u64, + }, + ); + } + + async fn read_file(path: &PathBuf) -> u64 { + use tokio::io::AsyncReadExt; + let mut f = tokio::fs::File::open(path).await.unwrap(); + let mut buf = vec![0u8; 64 * 1024]; + let mut total = 0u64; + loop { + let n = f.read(&mut buf).await.unwrap(); + if n == 0 { + break; + } + total += n as u64; + } + total + } + + println!("\n## [2] Warm-hit read (probe + open + 64 KiB read), {reads} reads split across K"); + println!("| K | BEFORE Kops/s | AFTER Kops/s | speedup |"); + for k in [1usize, 2, 4, 8].into_iter().filter(|k| *k <= readers_max) { + let per_task = reads / k; + + let t = Instant::now(); + let mut handles = Vec::new(); + for t_id in 0..k { + let idx = before.clone(); + let hs = hashes.clone(); + handles.push(tokio::spawn(async move { + for i in 0..per_task { + let h = &hs[(i * 31 + t_id * 7) % hs.len()]; + let p = idx.hit_probe(h).await.expect("hit"); + std::hint::black_box(read_file(&p).await); + } + })); + } + for h in handles { + h.await.unwrap(); + } + let before_kops = (per_task * k) as f64 / t.elapsed().as_secs_f64() / 1e3; + + let t = Instant::now(); + let mut handles = Vec::new(); + for t_id in 0..k { + let idx = after.clone(); + let hs = hashes.clone(); + handles.push(tokio::spawn(async move { + for i in 0..per_task { + let h = &hs[(i * 31 + t_id * 7) % hs.len()]; + let p = idx.hit_probe(h).expect("hit"); + std::hint::black_box(read_file(&p).await); + } + })); + } + for h in handles { + h.await.unwrap(); + } + let after_kops = (per_task * k) as f64 / t.elapsed().as_secs_f64() / 1e3; + + println!( + "| {k} | {before_kops:>9.1} | {after_kops:>9.1} | {:>6.2}x |", + after_kops / before_kops + ); + } +} + +// ──────────────────────────────────────────────────────────────────────────── + +async fn section_safety_gates() { + use dashmap::DashMap; + + println!("\n## [3] Safety gates"); + + // (a) Byte budget + eviction-unlink + replaced-keeps-file, on the moka + // shape the production migration ships. + let dir = PathBuf::from("/tmp/bench-blob-idx-gate"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let unlinked = Arc::new(AtomicU64::new(0)); + + let cache_dir = dir.clone(); + let unlinked_l = unlinked.clone(); + let cache: moka::sync::Cache = moka::sync::Cache::builder() + .weigher(|_k: &String, e: &CacheEntry| e.size.clamp(1, u32::MAX as u64) as u32) + .max_capacity(10 * 1024 * 1024) // 10 MiB budget + .eviction_listener(move |hash: Arc, _entry, cause| { + // Unlink ONLY blobs moka pushed out for size; a Replaced entry + // refers to the same path as its replacement, and Explicit + // removals (delete_blob) unlink at the call site. + if cause == moka::notification::RemovalCause::Size { + let prefix = &hash[..2.min(hash.len())]; + let p = cache_dir.join(prefix).join(format!("{hash}.blob")); + let _ = std::fs::remove_file(&p); + unlinked_l.fetch_add(1, Ordering::Relaxed); + } + }) + .build(); + + let payload = vec![0x5Au8; 1024 * 1024]; // 1 MiB blobs + for i in 0..100 { + let hash = format!("{i:02x}gatehash{i:04}"); + let prefix = &hash[..2]; + let p = dir.join(prefix).join(format!("{hash}.blob")); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(&p, &payload).unwrap(); + cache.insert( + hash, + CacheEntry { + size: payload.len() as u64, + }, + ); + } + cache.run_pending_tasks(); + let weighted = cache.weighted_size(); + assert!(weighted <= 10 * 1024 * 1024, "budget exceeded: {weighted}"); + // Every surviving entry's file exists; evicted files unlinked. + let mut on_disk = 0u64; + for i in 0..100 { + let hash = format!("{i:02x}gatehash{i:04}"); + let prefix = &hash[..2]; + let p = dir.join(prefix).join(format!("{hash}.blob")); + let exists = p.exists(); + if cache.get(&hash).is_some() { + assert!(exists, "surviving entry lost its file: {hash}"); + } + if exists { + on_disk += 1; + } + } + assert!( + on_disk <= 12, + "disk not trimmed to budget: {on_disk} files remain" + ); + assert!(unlinked.load(Ordering::Relaxed) >= 88); + println!( + "# gate (a) OK — weighted {:.1} MiB ≤ 10 MiB budget, {} files on disk, {} unlinked", + weighted as f64 / (1024.0 * 1024.0), + on_disk, + unlinked.load(Ordering::Relaxed) + ); + + // (b) Replacing an entry must NOT unlink the shared path. + let u0 = unlinked.load(Ordering::Relaxed); + let some_hash = cache + .iter() + .next() + .map(|(k, _)| (*k).clone()) + .expect("nonempty"); + let some_path = { + let prefix = &some_hash[..2.min(some_hash.len())]; + dir.join(prefix).join(format!("{some_hash}.blob")) + }; + cache.insert(some_hash.clone(), CacheEntry { size: 1024 * 1024 }); + cache.run_pending_tasks(); + assert!(some_path.exists(), "replace unlinked the live file"); + assert_eq!( + unlinked.load(Ordering::Relaxed), + u0, + "replace must not count as size-eviction unlink" + ); + println!("# gate (b) OK — replaced entry keeps its file"); + + // (c) Single-flight (DashMap gate, unchanged by the migration) still + // coalesces K concurrent misses to one inner fetch. + let fetches = Arc::new(AtomicU64::new(0)); + let inflight: Arc>>> = Arc::new(DashMap::new()); + let done: Arc> = + Arc::new(moka::sync::Cache::builder().max_capacity(1_000_000).build()); + let mut handles = Vec::new(); + for _ in 0..16 { + let fetches = fetches.clone(); + let inflight = inflight.clone(); + let done = done.clone(); + handles.push(tokio::spawn(async move { + let hash = "sf-hash".to_string(); + let gate = inflight + .entry(hash.clone()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone(); + let _guard = gate.lock().await; + if done.get(&hash).is_some() { + return; + } + // simulate the remote fetch + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + fetches.fetch_add(1, Ordering::Relaxed); + done.insert(hash.clone(), CacheEntry { size: 1 }); + inflight.remove(&hash); + })); + } + for h in handles { + h.await.unwrap(); + } + assert_eq!(fetches.load(Ordering::Relaxed), 1, "single-flight broken"); + println!("# gate (c) OK — 16 concurrent misses → 1 fetch"); +} + +#[tokio::main(flavor = "multi_thread", worker_threads = 4)] +async fn main() { + let n_files: usize = env_or("BENCH_FILES", 256); + let hashes: Arc> = Arc::new( + (0..n_files) + .map(|i| format!("{:02x}benchhash{i:06}", i % 256)) + .collect(), + ); + + println!("#################################################################"); + println!("# Blob-cache index — Mutex vs moka byte-weigher"); + println!("#################################################################"); + + section_index_ops(hashes.clone()).await; + section_warm_reads(hashes.clone()).await; + section_safety_gates().await; + + println!("\nGATE PASS (safety gates all hold — adopt if [1]/[2] favour moka)"); +} diff --git a/examples/bench_round12_micro.rs b/examples/bench_round12_micro.rs new file mode 100644 index 00000000..5804c0db --- /dev/null +++ b/examples/bench_round12_micro.rs @@ -0,0 +1,1261 @@ +//! Round-12 CPU/alloc micro-pack (no Postgres). +//! +//! Five sections, each BEFORE (verbatim replica of the shipped shape) vs +//! AFTER (proposed shape), with byte-identity / equivalence gates: +//! +//! [1] Listing JSON serialization — axum `Json`'s 128-byte `BytesMut` +//! seed + doubling-realloc chain vs a pre-sized `Vec` + +//! `serde_json::to_writer` (the `sized_json` helper). +//! [2] Dynamic-compression predicate — the ~28-node `And` chain (each +//! `NotForContentType` re-reading + re-validating the Content-Type +//! header) vs a single-pass policy node. +//! [3] Security-header stack — 4 `SetResponseHeaderLayer`s wrapping the +//! CSP middleware (5 tower layers) vs the headers folded into the +//! CSP pass (1 layer). +//! [4] Media capture-metadata extraction — the 2-3 opens per image / +//! 2 per video (kamadak full read + nom-exif path re-reads) vs the +//! single-read shape (nom-exif fed from the in-RAM bytes, +//! one `MediaParser`, kind-dispatched videos). +//! [5] Chunked-upload session map — 2 (prepare) + 3 (commit) DashMap +//! lookups per chunk plus 2 `Uuid::to_string` allocs vs fused +//! owner-check lookups + stack-encoded uuid compare. +//! +//! Run: +//! cargo run --release --features bench --example bench_round12_micro +//! Tunables (env): BENCH_ITERS (100000), BENCH_ROWS (500), +//! BENCH_MEDIA_ITERS (300), BENCH_COLD_ITERS (20; 0 disables the +//! drop_caches cold arms, which need root) + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::env; +use std::hint::black_box; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +use bytes::{BufMut, Bytes, BytesMut}; +use chrono::{DateTime, FixedOffset, TimeZone, Utc}; +use dashmap::DashMap; +use uuid::Uuid; + +static ALLOC_CALLS: AtomicU64 = AtomicU64::new(0); + +struct CountingAlloc; + +unsafe impl GlobalAlloc for CountingAlloc { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + ALLOC_CALLS.fetch_add(1, Ordering::Relaxed); + unsafe { System.alloc(layout) } + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + ALLOC_CALLS.fetch_add(1, Ordering::Relaxed); + unsafe { System.realloc(ptr, layout, new_size) } + } + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + ALLOC_CALLS.fetch_add(1, Ordering::Relaxed); + unsafe { System.alloc_zeroed(layout) } + } +} + +#[global_allocator] +static GLOBAL: CountingAlloc = CountingAlloc; + +fn env_or(key: &str, default: T) -> T { + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +struct Measured { + wall_ns_per_op: f64, + allocs_per_op: f64, +} + +fn measure(iters: usize, mut f: F) -> Measured { + let a0 = ALLOC_CALLS.load(Ordering::Relaxed); + let t = Instant::now(); + for _ in 0..iters { + f(); + } + let wall = t.elapsed().as_nanos() as f64 / iters as f64; + let allocs = (ALLOC_CALLS.load(Ordering::Relaxed) - a0) as f64 / iters as f64; + Measured { + wall_ns_per_op: wall, + allocs_per_op: allocs, + } +} + +fn print_row(label: &str, m: &Measured) { + println!( + "| {:<38} | {:>12.1} | {:>10.2} |", + label, m.wall_ns_per_op, m.allocs_per_op + ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// [1] Listing JSON — axum Json 128-byte seed vs pre-sized writer +// ──────────────────────────────────────────────────────────────────────────── + +#[derive(serde::Serialize)] +struct RowDto { + id: String, + name: String, + path: String, + size: u64, + mime_type: Arc, + folder_id: Option, + created_at: u64, + modified_at: u64, + icon_class: Arc, + icon_special_class: Arc, + category: Arc, + size_formatted: String, +} + +fn make_rows(n: usize) -> Vec { + let mime: Arc = Arc::from("image/jpeg"); + let icon: Arc = Arc::from("fas fa-file-image"); + let special: Arc = Arc::from("image-icon"); + let category: Arc = Arc::from("Image"); + (0..n) + .map(|i| RowDto { + id: Uuid::new_v4().to_string(), + name: format!("IMG_2024_{i:05}.jpg"), + path: format!("/Photos/2024/Summer trip/IMG_2024_{i:05}.jpg"), + size: 3_274_291 + i as u64, + mime_type: mime.clone(), + folder_id: Some(Uuid::new_v4().to_string()), + created_at: 1_719_830_000 + i as u64, + modified_at: 1_719_830_100 + i as u64, + icon_class: icon.clone(), + icon_special_class: special.clone(), + category: category.clone(), + size_formatted: "3.27 MB".to_string(), + }) + .collect() +} + +/// BEFORE, verbatim axum `Json::into_response` buffer flow. +fn json_before(rows: &[RowDto]) -> Bytes { + let mut buf = BytesMut::with_capacity(128).writer(); + serde_json::to_writer(&mut buf, rows).expect("serialize"); + buf.into_inner().freeze() +} + +/// AFTER: the `sized_json` shape — one pre-sized allocation. +fn json_after(rows: &[RowDto], per_row_estimate: usize) -> Bytes { + let mut buf = Vec::with_capacity(64 + rows.len() * per_row_estimate); + serde_json::to_writer(&mut buf, rows).expect("serialize"); + Bytes::from(buf) +} + +fn section_sized_json() { + let n: usize = env_or("BENCH_ROWS", 500); + let iters: usize = env_or("BENCH_ITERS", 100_000) / 100; + let rows = make_rows(n); + + let b = json_before(&rows); + let a = json_after(&rows, 384); + assert_eq!(b, a, "serialized bytes differ"); + let actual = b.len() / n; + println!( + "# [1] gate: bytes identical — OK ({} rows, {} B total, ~{} B/row, estimate 384)", + n, + b.len(), + actual + ); + + let before = measure(iters, || { + black_box(json_before(black_box(&rows))); + }); + let after = measure(iters, || { + black_box(json_after(black_box(&rows), 384)); + }); + + println!("\n## [1] Listing JSON serialization ({n} rows)"); + println!("| arm | ns/op | allocs/op |"); + print_row("BEFORE axum Json (128 B seed)", &before); + print_row("AFTER sized_json (pre-sized)", &after); + println!( + "# {:.2}x wall, {:.1} fewer allocs/response", + before.wall_ns_per_op / after.wall_ns_per_op, + before.allocs_per_op - after.allocs_per_op + ); + if after.wall_ns_per_op >= before.wall_ns_per_op { + eprintln!("GATE FAIL [1]: pre-sized arm not faster — rollback"); + std::process::exit(1); + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// [2] Compression predicate — 28-node And chain vs single pass +// ──────────────────────────────────────────────────────────────────────────── + +mod predicate_bench { + use axum::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE}; + use tower_http::compression::predicate::{NotForContentType, Predicate, SizeAbove}; + + /// BEFORE, verbatim `main.rs` predicate (SizeAbove + 27 content-type + /// exclusions + Content-Disposition guard, left-nested `And`). + #[derive(Clone, Copy)] + pub struct NotForDownloads; + impl Predicate for NotForDownloads { + fn should_compress(&self, response: &axum::http::Response) -> bool + where + B: http_body::Body, + { + !response.headers().contains_key(CONTENT_DISPOSITION) + } + } + + pub fn before_predicate() -> impl Predicate { + SizeAbove::new(256) + .and(NotForContentType::GRPC) + .and(NotForContentType::SSE) + .and(NotForContentType::const_new("image/jpeg")) + .and(NotForContentType::const_new("image/png")) + .and(NotForContentType::const_new("image/gif")) + .and(NotForContentType::const_new("image/webp")) + .and(NotForContentType::const_new("image/avif")) + .and(NotForContentType::const_new("image/heic")) + .and(NotForContentType::const_new("image/heif")) + .and(NotForContentType::const_new("image/jp2")) + .and(NotForContentType::const_new("image/x-icon")) + .and(NotForContentType::const_new("image/vnd.microsoft.icon")) + .and(NotForContentType::const_new("video/")) + .and(NotForContentType::const_new("audio/")) + .and(NotForContentType::const_new("font/woff")) + .and(NotForContentType::const_new("application/font-woff")) + .and(NotForContentType::const_new("application/zip")) + .and(NotForContentType::const_new("application/gzip")) + .and(NotForContentType::const_new("application/x-gzip")) + .and(NotForContentType::const_new("application/x-tar")) + .and(NotForContentType::const_new("application/x-7z-compressed")) + .and(NotForContentType::const_new("application/x-rar-compressed")) + .and(NotForContentType::const_new("application/x-bzip2")) + .and(NotForContentType::const_new("application/zstd")) + .and(NotForContentType::const_new("application/x-xz")) + .and(NotForContentType::const_new( + "application/vnd.openxmlformats-officedocument", + )) + .and(NotForContentType::const_new( + "application/vnd.oasis.opendocument", + )) + .and(NotForContentType::const_new("application/epub+zip")) + .and(NotForContentType::const_new("application/java-archive")) + .and(NotForContentType::const_new( + "application/vnd.android.package-archive", + )) + .and(NotForContentType::const_new("application/pdf")) + .and(NotForContentType::const_new("application/octet-stream")) + .and(NotForDownloads) + } + + /// AFTER: the single-pass content-policy node (chained after the same + /// `SizeAbove`, which keeps tower-http's size heuristics verbatim). + /// One Content-Type read + one prefix scan + one disposition probe. + #[derive(Clone, Copy)] + pub struct SinglePassContentPolicy; + + /// Exact prefix set of the BEFORE chain, in chain order. + const EXCLUDED_CT_PREFIXES: &[&str] = &[ + "application/grpc", + "text/event-stream", + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/avif", + "image/heic", + "image/heif", + "image/jp2", + "image/x-icon", + "image/vnd.microsoft.icon", + "video/", + "audio/", + "font/woff", + "application/font-woff", + "application/zip", + "application/gzip", + "application/x-gzip", + "application/x-tar", + "application/x-7z-compressed", + "application/x-rar-compressed", + "application/x-bzip2", + "application/zstd", + "application/x-xz", + "application/vnd.openxmlformats-officedocument", + "application/vnd.oasis.opendocument", + "application/epub+zip", + "application/java-archive", + "application/vnd.android.package-archive", + "application/pdf", + "application/octet-stream", + ]; + + impl Predicate for SinglePassContentPolicy { + fn should_compress(&self, response: &axum::http::Response) -> bool + where + B: http_body::Body, + { + let headers = response.headers(); + // Mirror `NotForContentType`: a missing / non-UTF8 Content-Type + // is compressible as far as the type exclusions are concerned. + let ct = headers + .get(CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if !ct.is_empty() + && EXCLUDED_CT_PREFIXES + .iter() + .any(|prefix| ct.starts_with(prefix)) + { + return false; + } + !headers.contains_key(CONTENT_DISPOSITION) + } + } + + pub fn after_predicate() -> impl Predicate { + SizeAbove::new(256).and(SinglePassContentPolicy) + } +} + +fn section_predicate() { + use axum::body::Body; + use axum::http::Response; + use tower_http::compression::predicate::Predicate; + + let iters: usize = env_or("BENCH_ITERS", 100_000); + let before = predicate_bench::before_predicate(); + let after = predicate_bench::after_predicate(); + + // Corpus: (content-type, content-length, disposition, label). Covers the + // compressible hot cases, every exclusion family, edge cases. + let mut corpus: Vec<(Response, &'static str)> = Vec::new(); + let mk = |ct: Option<&str>, len: usize, disp: bool| -> Response { + let mut b = Response::builder().status(200); + if let Some(ct) = ct { + b = b.header("content-type", ct); + } + b = b.header("content-length", len.to_string()); + if disp { + b = b.header("content-disposition", "attachment; filename=\"x\""); + } + b.body(Body::empty()).unwrap() + }; + corpus.push((mk(Some("application/json"), 50_000, false), "json 50K")); + corpus.push(( + mk(Some("text/html; charset=utf-8"), 8_000, false), + "html 8K", + )); + corpus.push((mk(Some("image/jpeg"), 500_000, false), "jpeg")); + corpus.push((mk(Some("image/svg+xml"), 12_000, false), "svg")); + corpus.push((mk(Some("video/mp4"), 10_000_000, false), "mp4")); + corpus.push((mk(Some("application/pdf"), 900_000, false), "pdf")); + corpus.push((mk(Some("application/zip"), 70_000, false), "zip")); + corpus.push(( + mk( + Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + 90_000, + false, + ), + "docx", + )); + corpus.push((mk(Some("application/json"), 100, false), "tiny json")); + corpus.push((mk(Some("text/event-stream"), 50_000, false), "sse")); + corpus.push((mk(Some("application/grpc"), 50_000, false), "grpc")); + corpus.push((mk(Some("font/woff2"), 30_000, false), "woff2")); + corpus.push((mk(Some("font/woff"), 30_000, false), "woff")); + corpus.push((mk(Some("application/xml"), 20_000, true), "download xml")); + corpus.push((mk(None, 20_000, false), "no content-type")); + corpus.push((mk(Some("application/octet-stream"), 5_000, false), "octet")); + corpus.push((mk(Some("audio/flac"), 5_000_000, false), "flac")); + corpus.push((mk(Some("image/x-icon"), 5_000, false), "ico")); + + // Verdict-identity gate across the whole corpus. + for (resp, label) in &corpus { + let b = before.should_compress(resp); + let a = after.should_compress(resp); + assert_eq!(b, a, "verdict differs for {label}"); + } + println!( + "# [2] gate: predicate verdicts identical across {} response shapes — OK", + corpus.len() + ); + + // Hot case: the compressible JSON response (worst case for the chain — + // every node runs). + let hot = mk(Some("application/json"), 50_000, false); + let m_before = measure(iters, || { + black_box(before.should_compress(black_box(&hot))); + }); + let m_after = measure(iters, || { + black_box(after.should_compress(black_box(&hot))); + }); + // Excluded case (early-exit for the chain on node 3): jpeg. + let jpeg = mk(Some("image/jpeg"), 500_000, false); + let m_before_x = measure(iters, || { + black_box(before.should_compress(black_box(&jpeg))); + }); + let m_after_x = measure(iters, || { + black_box(after.should_compress(black_box(&jpeg))); + }); + + println!("\n## [2] Compression predicate — VERDICT: REJECTED (kept as evidence)"); + println!("| arm | ns/op | allocs/op |"); + print_row("BEFORE chain, compressible JSON", &m_before); + print_row("AFTER single-pass, same", &m_after); + print_row("BEFORE chain, excluded jpeg", &m_before_x); + print_row("AFTER single-pass, same", &m_after_x); + println!( + "# compressible {:.2}x, excluded {:.2}x", + m_before.wall_ns_per_op / m_after.wall_ns_per_op, + m_before_x.wall_ns_per_op / m_after_x.wall_ns_per_op + ); + // REJECTED (round 12): the monomorphized `And` chain compiles to + // straight-line inlined header probes — ~4.6 ns TOTAL for the whole + // 28-node walk, zero allocs. The "28 redundant Content-Type reads" + // hypothesis was wrong at the machine level; a hand-fused single-pass + // node measures within noise (±10%) and is sometimes slower on the + // compressible case. Production keeps the declarative chain — it costs + // nothing and reads better. This section stays as the reproducible + // evidence for that rejection (the bench_favorites_authz pattern). + println!("# not shipped: chain is already ~free; fused node within noise"); +} + +// ──────────────────────────────────────────────────────────────────────────── +// [3] Security-header stack — 5 layers vs 1 fused middleware +// ──────────────────────────────────────────────────────────────────────────── + +mod headers_bench { + use axum::Router; + use axum::http::HeaderValue; + use axum::http::header::HeaderName; + use axum::routing::get; + use tower_http::set_header::SetResponseHeaderLayer; + + const CSP: &str = "default-src 'self'; \ + script-src 'self'; \ + worker-src 'self'; \ + style-src 'self' 'unsafe-inline'; \ + img-src 'self' data: blob: https:; \ + media-src 'self' blob:; \ + connect-src 'self'; \ + font-src 'self' data:; \ + frame-src * blob:; \ + frame-ancestors 'none'; \ + base-uri 'self'; \ + form-action 'self' https:"; + + async fn csp_only( + req: axum::extract::Request, + next: axum::middleware::Next, + ) -> axum::response::Response { + let mut res = next.run(req).await; + if res.status() == axum::http::StatusCode::NOT_MODIFIED { + return res; + } + let is_html = res + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.starts_with("text/html")); + if is_html { + res.headers_mut().insert( + axum::http::header::CACHE_CONTROL, + HeaderValue::from_static("no-store"), + ); + } else { + res.headers_mut().insert( + axum::http::header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static(CSP), + ); + } + res + } + + /// AFTER: the four static headers folded into the same response pass. + /// NOTE: applied BEFORE the 304 early-return — the standalone + /// `SetResponseHeaderLayer`s stamp 304s too, and byte-identity with + /// the BEFORE stack (including on 304s) is gated below. + async fn fused( + req: axum::extract::Request, + next: axum::middleware::Next, + ) -> axum::response::Response { + let mut res = next.run(req).await; + let h = res.headers_mut(); + h.insert( + HeaderName::from_static("x-content-type-options"), + HeaderValue::from_static("nosniff"), + ); + h.insert( + HeaderName::from_static("x-frame-options"), + HeaderValue::from_static("DENY"), + ); + h.insert( + HeaderName::from_static("referrer-policy"), + HeaderValue::from_static("strict-origin-when-cross-origin"), + ); + h.insert( + HeaderName::from_static("permissions-policy"), + HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), + ); + if res.status() == axum::http::StatusCode::NOT_MODIFIED { + return res; + } + let is_html = res + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.starts_with("text/html")); + if is_html { + res.headers_mut().insert( + axum::http::header::CACHE_CONTROL, + HeaderValue::from_static("no-store"), + ); + } else { + res.headers_mut().insert( + axum::http::header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static(CSP), + ); + } + res + } + + async fn json_handler() -> ([(HeaderName, &'static str); 1], &'static str) { + ( + [(axum::http::header::CONTENT_TYPE, "application/json")], + "{\"ok\":true}", + ) + } + async fn html_handler() -> ([(HeaderName, &'static str); 1], &'static str) { + ( + [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")], + "", + ) + } + async fn not_modified() -> axum::http::StatusCode { + axum::http::StatusCode::NOT_MODIFIED + } + + fn routes() -> Router { + Router::new() + .route("/json", get(json_handler)) + .route("/html", get(html_handler)) + .route("/304", get(not_modified)) + } + + /// BEFORE, verbatim `main.rs` stack: CSP middleware + 4 header layers. + pub fn before_app() -> Router { + routes() + .layer(axum::middleware::from_fn(csp_only)) + .layer(SetResponseHeaderLayer::overriding( + HeaderName::from_static("x-content-type-options"), + HeaderValue::from_static("nosniff"), + )) + .layer(SetResponseHeaderLayer::overriding( + HeaderName::from_static("x-frame-options"), + HeaderValue::from_static("DENY"), + )) + .layer(SetResponseHeaderLayer::overriding( + HeaderName::from_static("referrer-policy"), + HeaderValue::from_static("strict-origin-when-cross-origin"), + )) + .layer(SetResponseHeaderLayer::overriding( + HeaderName::from_static("permissions-policy"), + HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), + )) + } + + pub fn after_app() -> Router { + routes().layer(axum::middleware::from_fn(fused)) + } +} + +fn section_headers() { + use tower::ServiceExt; + + let iters: usize = env_or("BENCH_ITERS", 100_000) / 10; + let rt = tokio::runtime::Builder::new_current_thread() + .build() + .expect("rt"); + + let call = + |app: &axum::Router, path: &str| -> (axum::http::StatusCode, Vec<(String, String)>) { + let app = app.clone(); + rt.block_on(async move { + let res = app + .oneshot( + axum::http::Request::builder() + .uri(path) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = res.status(); + let mut headers: Vec<(String, String)> = res + .headers() + .iter() + .map(|(k, v)| { + ( + k.as_str().to_string(), + String::from_utf8_lossy(v.as_bytes()).to_string(), + ) + }) + .collect(); + headers.sort(); + (status, headers) + }) + }; + + let before_app = headers_bench::before_app(); + let after_app = headers_bench::after_app(); + + // Byte-identity gate on all three response classes (incl. the 304). + for path in ["/json", "/html", "/304"] { + let b = call(&before_app, path); + let a = call(&after_app, path); + assert_eq!(b, a, "headers differ for {path}"); + } + println!("# [3] gate: status + full sorted header set identical (json/html/304) — OK"); + + let m_before = measure(iters, || { + black_box(call(&before_app, "/json")); + }); + let m_after = measure(iters, || { + black_box(call(&after_app, "/json")); + }); + + println!("\n## [3] Security-header stack (per request, incl. router overhead)"); + println!("| arm | ns/op | allocs/op |"); + print_row("BEFORE 5 layers (CSP + 4 set-header)", &m_before); + print_row("AFTER 1 fused middleware", &m_after); + println!( + "# {:.2}x wall, {:.1} fewer allocs/request", + m_before.wall_ns_per_op / m_after.wall_ns_per_op, + m_before.allocs_per_op - m_after.allocs_per_op + ); + if m_after.wall_ns_per_op >= m_before.wall_ns_per_op { + eprintln!("GATE FAIL [3]: fused middleware not faster — rollback"); + std::process::exit(1); + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// [4] Media capture-metadata single-read +// ──────────────────────────────────────────────────────────────────────────── + +mod media_bench { + use super::*; + use nom_exif::{EntryValue, ExifTag, MediaParser, MediaSource, TrackInfoTag}; + use oxicloud::infrastructure::services::exif_service::{ExifMetadata, ExifService}; + use std::sync::atomic::{AtomicU64, Ordering}; + + pub static OPENS: AtomicU64 = AtomicU64::new(0); + + /// Minimal EXIF APP1 with IFD0 { Orientation, ExifIFD ptr } and + /// ExifIFD { DateTimeOriginal } spliced after the JPEG SOI — + /// the `bench_support::inject_exif_orientation` technique extended + /// with a capture date. + pub fn inject_exif_with_date(jpeg: &[u8], orientation: u16, date: Option<&str>) -> Vec { + assert!( + jpeg.len() >= 2 && jpeg[0] == 0xFF && jpeg[1] == 0xD8, + "not a JPEG" + ); + + let mut tiff = Vec::new(); + tiff.extend_from_slice(b"II"); + tiff.extend_from_slice(&0x2Au16.to_le_bytes()); + tiff.extend_from_slice(&8u32.to_le_bytes()); // IFD0 offset + + match date { + None => { + // Orientation only (the date-less arm). + tiff.extend_from_slice(&1u16.to_le_bytes()); + tiff.extend_from_slice(&0x0112u16.to_le_bytes()); + tiff.extend_from_slice(&3u16.to_le_bytes()); + tiff.extend_from_slice(&1u32.to_le_bytes()); + tiff.extend_from_slice(&(orientation as u32).to_le_bytes()); + tiff.extend_from_slice(&0u32.to_le_bytes()); + } + Some(dt) => { + assert_eq!(dt.len(), 19, "EXIF datetime must be 19 chars"); + // IFD0: 2 entries (Orientation, ExifIFD pointer). + // IFD0 @8, size = 2 + 2*12 + 4 = 30 → ExifIFD @38. + // ExifIFD: 1 entry (DateTimeOriginal), size = 2+12+4 = 18 + // → date bytes @56, 20 bytes (19 + NUL). + tiff.extend_from_slice(&2u16.to_le_bytes()); + tiff.extend_from_slice(&0x0112u16.to_le_bytes()); + tiff.extend_from_slice(&3u16.to_le_bytes()); + tiff.extend_from_slice(&1u32.to_le_bytes()); + tiff.extend_from_slice(&(orientation as u32).to_le_bytes()); + tiff.extend_from_slice(&0x8769u16.to_le_bytes()); // ExifIFD ptr + tiff.extend_from_slice(&4u16.to_le_bytes()); // LONG + tiff.extend_from_slice(&1u32.to_le_bytes()); + tiff.extend_from_slice(&38u32.to_le_bytes()); + tiff.extend_from_slice(&0u32.to_le_bytes()); // next IFD + + tiff.extend_from_slice(&1u16.to_le_bytes()); // ExifIFD entries + tiff.extend_from_slice(&0x9003u16.to_le_bytes()); // DateTimeOriginal + tiff.extend_from_slice(&2u16.to_le_bytes()); // ASCII + tiff.extend_from_slice(&20u32.to_le_bytes()); + tiff.extend_from_slice(&56u32.to_le_bytes()); + tiff.extend_from_slice(&0u32.to_le_bytes()); // next IFD + + tiff.extend_from_slice(dt.as_bytes()); + tiff.push(0); + } + } + + let mut payload = Vec::with_capacity(6 + tiff.len()); + payload.extend_from_slice(b"Exif\0\0"); + payload.extend_from_slice(&tiff); + let seg_len = u16::try_from(2 + payload.len()).expect("segment size"); + + let mut out = Vec::with_capacity(jpeg.len() + 4 + payload.len()); + out.extend_from_slice(&jpeg[0..2]); + out.extend_from_slice(&[0xFF, 0xE1]); + out.extend_from_slice(&seg_len.to_be_bytes()); + out.extend_from_slice(&payload); + out.extend_from_slice(&jpeg[2..]); + out + } + + /// Minimal ISO-BMFF: ftyp(isom) + moov(mvhd v0 with a creation time). + pub fn craft_minimal_mp4(creation: DateTime) -> Vec { + let epoch_1904 = Utc.with_ymd_and_hms(1904, 1, 1, 0, 0, 0).unwrap(); + let secs = (creation - epoch_1904).num_seconds() as u32; + + let mut mvhd = Vec::new(); + mvhd.extend_from_slice(&[0, 0, 0, 0]); // version 0 + flags + mvhd.extend_from_slice(&secs.to_be_bytes()); // creation_time + mvhd.extend_from_slice(&secs.to_be_bytes()); // modification_time + mvhd.extend_from_slice(&1000u32.to_be_bytes()); // timescale + mvhd.extend_from_slice(&60_000u32.to_be_bytes()); // duration + mvhd.extend_from_slice(&0x0001_0000u32.to_be_bytes()); // rate 1.0 + mvhd.extend_from_slice(&0x0100u16.to_be_bytes()); // volume 1.0 + mvhd.extend_from_slice(&[0u8; 10]); // reserved + // identity matrix + for v in [0x0001_0000u32, 0, 0, 0, 0x0001_0000, 0, 0, 0, 0x4000_0000] { + mvhd.extend_from_slice(&v.to_be_bytes()); + } + mvhd.extend_from_slice(&[0u8; 24]); // pre_defined + mvhd.extend_from_slice(&2u32.to_be_bytes()); // next_track_ID + + let boxed = |name: &[u8; 4], body: &[u8]| -> Vec { + let mut b = Vec::with_capacity(8 + body.len()); + b.extend_from_slice(&(8 + body.len() as u32).to_be_bytes()); + b.extend_from_slice(name); + b.extend_from_slice(body); + b + }; + + let mvhd_box = boxed(b"mvhd", &mvhd); + let moov = boxed(b"moov", &mvhd_box); + let ftyp = boxed(b"ftyp", b"isom\x00\x00\x02\x00isomiso2mp41"); + let mdat = boxed(b"mdat", &[0u8; 1024]); + + let mut out = Vec::new(); + out.extend_from_slice(&ftyp); + out.extend_from_slice(&moov); + out.extend_from_slice(&mdat); + out + } + + /// Mirror of `media_metadata_service`'s private `NomExif` accumulator. + #[derive(Debug, Default, PartialEq)] + pub struct NomLite { + pub captured_at: Option>, + pub latitude: Option, + pub longitude: Option, + } + + fn to_utc(ev: &EntryValue) -> Option> { + let edt = ev.as_datetime()?; + let utc0 = FixedOffset::east_opt(0)?; + Some(edt.or_offset(utc0).with_timezone(&Utc)) + } + + fn nom_from_exif(exif: &nom_exif::Exif, out: &mut NomLite) { + out.captured_at = exif + .get(ExifTag::DateTimeOriginal) + .and_then(to_utc) + .or_else(|| exif.get(ExifTag::CreateDate).and_then(to_utc)); + if let Some(gps) = exif.gps_info() { + out.latitude = gps.latitude_decimal(); + out.longitude = gps.longitude_decimal(); + } + } + + /// BEFORE, verbatim `read_nom_exif`: `read_exif(path)` (open #1) then + /// the `read_track(path)` fallback (open #2). The two top-level nom-exif + /// fns are replicated inline (open → seekable → fresh parser) so the + /// bench can count opens; this is exactly their lib.rs body. + pub fn before_read_nom(path: &Path) -> NomLite { + let mut out = NomLite::default(); + + OPENS.fetch_add(1, Ordering::Relaxed); + if let Ok(file) = std::fs::File::open(path) + && let Ok(ms) = MediaSource::seekable(file) + && let Ok(iter) = MediaParser::new().parse_exif(ms) + { + let exif: nom_exif::Exif = iter.into(); + nom_from_exif(&exif, &mut out); + } + + if out.captured_at.is_none() { + OPENS.fetch_add(1, Ordering::Relaxed); + if let Ok(file) = std::fs::File::open(path) + && let Ok(ms) = MediaSource::seekable(file) + && let Ok(track) = MediaParser::new().parse_track(ms) + && let Some(dt) = track.get(TrackInfoTag::CreateDate).and_then(to_utc) + { + out.captured_at = Some(dt); + } + } + out + } + + /// BEFORE, verbatim `extract_blocking` image arm: whole-file read for + /// kamadak (open #0) + `read_nom_exif` (opens #1/#2). + pub fn before_image(path: &Path) -> (Option, NomLite) { + OPENS.fetch_add(1, Ordering::Relaxed); + let kamadak = std::fs::read(path) + .ok() + .and_then(|b| ExifService::extract(&b)); + let nom = before_read_nom(path); + (kamadak, nom) + } + + pub fn before_video(path: &Path) -> NomLite { + before_read_nom(path) + } + + /// AFTER: nom-exif fed from the already-read bytes (zero-copy), one + /// reused parser, memory-mode track fallback (covers MIME-mislabel). + pub fn after_nom_from_bytes(parser: &mut MediaParser, bytes: &Bytes) -> NomLite { + let mut out = NomLite::default(); + if let Ok(ms) = MediaSource::from_memory(bytes.clone()) + && let Ok(iter) = parser.parse_exif(ms) + { + let exif: nom_exif::Exif = iter.into(); + nom_from_exif(&exif, &mut out); + } + if out.captured_at.is_none() + && let Ok(ms) = MediaSource::from_memory(bytes.clone()) + && let Ok(track) = parser.parse_track(ms) + && let Some(dt) = track.get(TrackInfoTag::CreateDate).and_then(to_utc) + { + out.captured_at = Some(dt); + } + out + } + + pub fn after_image(path: &Path) -> (Option, NomLite) { + OPENS.fetch_add(1, Ordering::Relaxed); + let Ok(buf) = std::fs::read(path) else { + return (None, NomLite::default()); + }; + let kamadak = ExifService::extract(&buf); + let bytes = Bytes::from(buf); + let mut parser = MediaParser::new(); + let nom = after_nom_from_bytes(&mut parser, &bytes); + (kamadak, nom) + } + + /// AFTER video arm: ONE open, kind-dispatched. + pub fn after_video(path: &Path) -> NomLite { + let mut out = NomLite::default(); + OPENS.fetch_add(1, Ordering::Relaxed); + let Ok(file) = std::fs::File::open(path) else { + return out; + }; + let Ok(ms) = MediaSource::seekable(file) else { + return out; + }; + let mut parser = MediaParser::new(); + match ms.kind() { + nom_exif::MediaKind::Image => { + if let Ok(iter) = parser.parse_exif(ms) { + let exif: nom_exif::Exif = iter.into(); + nom_from_exif(&exif, &mut out); + } + } + nom_exif::MediaKind::Track => { + if let Ok(track) = parser.parse_track(ms) + && let Some(dt) = track.get(TrackInfoTag::CreateDate).and_then(to_utc) + { + out.captured_at = Some(dt); + } + } + } + out + } +} + +fn section_media() { + use media_bench::*; + + let iters: usize = env_or("BENCH_MEDIA_ITERS", 300); + let cold_iters: usize = env_or("BENCH_COLD_ITERS", 20); + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target/bench-media"); + std::fs::create_dir_all(&dir).expect("mkdir"); + + // Corpus: a ~1.5 MB JPEG with an EXIF date, the same without a date + // (exercises the track-fallback re-open), a PNG (no EXIF at all), and + // a minimal MP4. + let img = image::RgbImage::from_fn(2000, 1500, |x, y| { + image::Rgb([ + ((x * 7 + y * 3) % 251) as u8, + ((x * 13 + y * 5) % 241) as u8, + ((x * 3 + y * 11) % 239) as u8, + ]) + }); + let mut jpeg_plain = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg_plain, 90) + .encode_image(&image::DynamicImage::ImageRgb8(img.clone())) + .expect("jpeg"); + let jpeg_dated = inject_exif_with_date(&jpeg_plain, 6, Some("2024:06:01 12:00:00")); + let jpeg_undated = inject_exif_with_date(&jpeg_plain, 6, None); + let mut png = Vec::new(); + image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(800, 600, |x, y| { + image::Rgb([(x % 256) as u8, (y % 256) as u8, ((x + y) % 256) as u8]) + })) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .expect("png"); + let mp4 = craft_minimal_mp4(Utc.with_ymd_and_hms(2024, 6, 1, 12, 0, 0).unwrap()); + + let cases: Vec<(&str, PathBuf, bool)> = vec![ + ("jpeg_dated", dir.join("dated.jpg"), true), + ("jpeg_undated", dir.join("undated.jpg"), true), + ("png_noexif", dir.join("plain.png"), true), + ("mp4_video", dir.join("clip.mp4"), false), + ]; + std::fs::write(&cases[0].1, &jpeg_dated).unwrap(); + std::fs::write(&cases[1].1, &jpeg_undated).unwrap(); + std::fs::write(&cases[2].1, &png).unwrap(); + std::fs::write(&cases[3].1, &mp4).unwrap(); + + // Equivalence gates: identical extraction output per corpus file, and + // the dated JPEG / MP4 must actually yield the crafted timestamp (so + // the corpus is known-good, not vacuously equal). + for (name, path, is_image) in &cases { + if *is_image { + let (bk, bn) = before_image(path); + let (ak, an) = after_image(path); + assert_eq!( + format!("{bk:?}"), + format!("{ak:?}"), + "kamadak differs for {name}" + ); + assert_eq!(bn, an, "nom-exif differs for {name}"); + } else { + let b = before_video(path); + let a = after_video(path); + assert_eq!(b, a, "video extraction differs for {name}"); + assert!( + b.captured_at.is_some(), + "crafted MP4 must yield a creation date" + ); + } + } + let (_, dated_nom) = before_image(&cases[0].1); + assert!( + dated_nom.captured_at.is_some(), + "dated JPEG must yield a date" + ); + println!("# [4] gate: BEFORE/AFTER extraction identical across 4 corpus files — OK"); + + println!("\n## [4] Media capture-metadata extraction (warm page cache)"); + println!("| case / arm | ns/op | allocs/op | opens/op |"); + let mut total_speedup = 1.0f64; + for (name, path, is_image) in &cases { + let o0 = OPENS.load(Ordering::Relaxed); + let m_before = measure(iters, || { + if *is_image { + black_box(before_image(path)); + } else { + black_box(before_video(path)); + } + }); + let before_opens = (OPENS.load(Ordering::Relaxed) - o0) as f64 / iters as f64; + let o1 = OPENS.load(Ordering::Relaxed); + let m_after = measure(iters, || { + if *is_image { + black_box(after_image(path)); + } else { + black_box(after_video(path)); + } + }); + let after_opens = (OPENS.load(Ordering::Relaxed) - o1) as f64 / iters as f64; + println!( + "| BEFORE {name:<30} | {:>12.1} | {:>10.2} | {:>9.2} |", + m_before.wall_ns_per_op, m_before.allocs_per_op, before_opens + ); + println!( + "| AFTER {name:<30} | {:>12.1} | {:>10.2} | {:>9.2} |", + m_after.wall_ns_per_op, m_after.allocs_per_op, after_opens + ); + total_speedup *= m_before.wall_ns_per_op / m_after.wall_ns_per_op; + if m_after.wall_ns_per_op >= m_before.wall_ns_per_op * 1.02 { + eprintln!("GATE FAIL [4]: AFTER slower for {name} — rollback"); + std::process::exit(1); + } + } + println!( + "# geomean speedup {:.2}x across the corpus", + total_speedup.powf(0.25) + ); + + // Cold-cache arms (root only): true disk-I/O shape of the extra opens. + if cold_iters > 0 && std::fs::write("/proc/sys/vm/drop_caches", "3").is_ok() { + println!("\n## [4b] Cold page cache (drop_caches between passes)"); + println!("| case | BEFORE ms/op | AFTER ms/op |"); + for (name, path, is_image) in &cases { + let mut b_ms = 0.0; + let mut a_ms = 0.0; + for _ in 0..cold_iters { + std::fs::write("/proc/sys/vm/drop_caches", "3").ok(); + let t = Instant::now(); + if *is_image { + black_box(before_image(path)); + } else { + black_box(before_video(path)); + } + b_ms += t.elapsed().as_secs_f64() * 1e3; + std::fs::write("/proc/sys/vm/drop_caches", "3").ok(); + let t = Instant::now(); + if *is_image { + black_box(after_image(path)); + } else { + black_box(after_video(path)); + } + a_ms += t.elapsed().as_secs_f64() * 1e3; + } + println!( + "| {name:<14} | {:>12.3} | {:>11.3} |", + b_ms / cold_iters as f64, + a_ms / cold_iters as f64 + ); + } + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// [5] Chunked-upload session map — fused owner-check lookups +// ──────────────────────────────────────────────────────────────────────────── + +mod session_bench { + use super::*; + + pub struct FakeSession { + pub user_id: String, + pub chunk_sizes: Vec, + pub temp_dir: PathBuf, + pub bytes_received: u64, + } + + pub type Sessions = DashMap; + + /// BEFORE, verbatim shapes: `verify_session_owner` (get #1 + + /// `user_id.to_string()`) then the operation's own get / get_mut. + fn verify_owner(sessions: &Sessions, upload_id: &str, user_id: &str) -> Result<(), ()> { + let session = sessions.get(upload_id).ok_or(())?; + if session.user_id != user_id { + return Err(()); + } + Ok(()) + } + + pub fn before_prepare( + sessions: &Sessions, + upload_id: &str, + user_id: Uuid, + chunk_index: usize, + ) -> Result<(PathBuf, usize), ()> { + verify_owner(sessions, upload_id, &user_id.to_string())?; + let session = sessions.get(upload_id).ok_or(())?; + if chunk_index >= session.chunk_sizes.len() { + return Err(()); + } + Ok(( + session.temp_dir.join(format!("chunk_{:06}", chunk_index)), + session.chunk_sizes[chunk_index], + )) + } + + pub fn before_commit( + sessions: &Sessions, + upload_id: &str, + user_id: Uuid, + chunk_index: usize, + actual_size: u64, + ) -> Result { + verify_owner(sessions, upload_id, &user_id.to_string())?; + let (_chunk_path, _expected) = { + let session = sessions.get(upload_id).ok_or(())?; + if chunk_index >= session.chunk_sizes.len() { + return Err(()); + } + ( + session.temp_dir.join(format!("chunk_{:06}", chunk_index)), + session.chunk_sizes[chunk_index], + ) + }; + let bytes = { + let mut session = sessions.get_mut(upload_id).ok_or(())?; + session.bytes_received += actual_size; + session.bytes_received + }; + Ok(bytes) + } + + /// AFTER: the owner check folded into the operation's own lookup, uuid + /// compared via a stack-encoded hyphenated form (no `to_string`). + #[inline] + fn owner_matches(session_user: &str, user_id: Uuid) -> bool { + let mut buf = [0u8; 36]; + session_user == user_id.hyphenated().encode_lower(&mut buf) as &str + } + + pub fn after_prepare( + sessions: &Sessions, + upload_id: &str, + user_id: Uuid, + chunk_index: usize, + ) -> Result<(PathBuf, usize), ()> { + let session = sessions.get(upload_id).ok_or(())?; + if !owner_matches(&session.user_id, user_id) { + return Err(()); + } + if chunk_index >= session.chunk_sizes.len() { + return Err(()); + } + Ok(( + session.temp_dir.join(format!("chunk_{:06}", chunk_index)), + session.chunk_sizes[chunk_index], + )) + } + + pub fn after_commit( + sessions: &Sessions, + upload_id: &str, + user_id: Uuid, + chunk_index: usize, + actual_size: u64, + ) -> Result { + let (_chunk_path, _expected) = { + let session = sessions.get(upload_id).ok_or(())?; + if !owner_matches(&session.user_id, user_id) { + return Err(()); + } + if chunk_index >= session.chunk_sizes.len() { + return Err(()); + } + ( + session.temp_dir.join(format!("chunk_{:06}", chunk_index)), + session.chunk_sizes[chunk_index], + ) + }; + let bytes = { + let mut session = sessions.get_mut(upload_id).ok_or(())?; + session.bytes_received += actual_size; + session.bytes_received + }; + Ok(bytes) + } +} + +fn section_sessions() { + use session_bench::*; + + let iters: usize = env_or("BENCH_ITERS", 100_000); + let sessions: Sessions = DashMap::new(); + let owner = Uuid::new_v4(); + let intruder = Uuid::new_v4(); + let upload_id = Uuid::new_v4().to_string(); + sessions.insert( + upload_id.clone(), + FakeSession { + user_id: owner.to_string(), + chunk_sizes: vec![5 * 1024 * 1024; 200], + temp_dir: PathBuf::from("/tmp/oxi-chunk-bench"), + bytes_received: 0, + }, + ); + + // Equivalence gates: same accept/reject on owner, intruder, unknown + // session, out-of-range index; same returned values. + let b_ok = before_prepare(&sessions, &upload_id, owner, 3); + let a_ok = after_prepare(&sessions, &upload_id, owner, 3); + assert_eq!(b_ok, a_ok); + assert!(b_ok.is_ok()); + assert_eq!( + before_prepare(&sessions, &upload_id, intruder, 3), + after_prepare(&sessions, &upload_id, intruder, 3) + ); + assert!(after_prepare(&sessions, &upload_id, intruder, 3).is_err()); + assert_eq!( + before_prepare(&sessions, "nope", owner, 0), + after_prepare(&sessions, "nope", owner, 0) + ); + assert_eq!( + before_prepare(&sessions, &upload_id, owner, 9999), + after_prepare(&sessions, &upload_id, owner, 9999) + ); + { + let b = before_commit(&sessions, &upload_id, owner, 3, 100); + let a = after_commit(&sessions, &upload_id, owner, 3, 100); + assert!(b.is_ok() && a.is_ok()); + assert_eq!(a.unwrap(), b.unwrap() + 100, "cumulative counter advances"); + sessions.get_mut(&upload_id).unwrap().bytes_received = 0; + } + println!( + "# [5] gate: identical accept/reject + values across owner/intruder/unknown/range — OK" + ); + + let m_before = measure(iters, || { + black_box(before_prepare(&sessions, &upload_id, owner, 3).ok()); + black_box(before_commit(&sessions, &upload_id, owner, 3, 5 * 1024 * 1024).ok()); + }); + sessions.get_mut(&upload_id).unwrap().bytes_received = 0; + let m_after = measure(iters, || { + black_box(after_prepare(&sessions, &upload_id, owner, 3).ok()); + black_box(after_commit(&sessions, &upload_id, owner, 3, 5 * 1024 * 1024).ok()); + }); + + println!("\n## [5] Chunked-upload session ops (prepare + commit per chunk)"); + println!("| arm | ns/op | allocs/op |"); + print_row("BEFORE 5 lookups + 2 to_string", &m_before); + print_row("AFTER 3 lookups + stack encode", &m_after); + println!( + "# {:.2}x wall, {:.1} fewer allocs/chunk", + m_before.wall_ns_per_op / m_after.wall_ns_per_op, + m_before.allocs_per_op - m_after.allocs_per_op + ); + if m_after.wall_ns_per_op >= m_before.wall_ns_per_op { + eprintln!("GATE FAIL [5]: fused lookups not faster — rollback"); + std::process::exit(1); + } +} + +fn main() { + println!("#################################################################"); + println!("# Round-12 CPU/alloc micro-pack"); + println!("#################################################################\n"); + + section_sized_json(); + section_predicate(); + section_headers(); + section_media(); + section_sessions(); + + println!("\nGATE PASS (all sections)"); +} diff --git a/examples/bench_round12_queries.rs b/examples/bench_round12_queries.rs new file mode 100644 index 00000000..3f3d8b53 --- /dev/null +++ b/examples/bench_round12_queries.rs @@ -0,0 +1,1099 @@ +//! Round-12 query-shape pack (needs the dev Postgres up; reads DATABASE_URL +//! from `.env`). +//! +//! Six sections, each BEFORE (verbatim replica of the shipped query shape) +//! vs AFTER (proposed shape), with equivalence/safety gates: +//! +//! [1] NC sharee search — the wide 21-column `search_users` row (incl. the +//! up-to-512 KiB avatar `image`) fetched per match when the handler +//! only reads `username`, vs a narrow username-only SELECT; plus a +//! `gin_trgm_ops` index arm for the leading-wildcard ILIKE. +//! [2] Password login — the redundant full-row `update_user` (17 columns +//! incl. `image`) that `create_session`'s own `last_login_at` UPDATE +//! immediately overwrites, vs create_session alone. +//! [3] Email-verified stamp (magic-link / OIDC JIT) — full-row +//! `update_user` to set one timestamp vs a narrow conditional UPDATE. +//! [4] Refresh-token rotation — revoke txn + create txn (2 transactions, +//! 6 statements) vs one fused rotation transaction. +//! [5] WOPI CheckFileInfo — require(Read) → get_file → check(Update) +//! serial vs `tokio::join!` (real `PgAclEngine` + real file read repo; +//! cold and warm arms). +//! [6] Upload quota pair — user-envelope + drive-cap checks as two serial +//! point reads vs one fused SELECT (verdict precedence preserved). +//! +//! Run: +//! cargo run --release --features bench --example bench_round12_queries +//! Tunables (env): BENCH_PASSES (200), BENCH_SHR_USERS (3000), +//! BENCH_WOPI_FILES (100), BENCH_WARM_ITERS (2000) + +use std::env; +use std::sync::Arc; +use std::time::Instant; + +use chrono::Utc; +use oxicloud::application::ports::authorization_ports::AuthorizationEngine; +use oxicloud::application::ports::storage_ports::FileReadPort; +use oxicloud::domain::services::authorization::{Permission, Resource, Subject}; +use oxicloud::infrastructure::repositories::pg::{ + FileBlobReadRepository, FolderDbRepository, SubjectGroupPgRepository, +}; +use oxicloud::infrastructure::services::dedup_service::DedupService; +use oxicloud::infrastructure::services::local_blob_backend::LocalBlobBackend; +use oxicloud::infrastructure::services::pg_acl_engine::PgAclEngine; +use sqlx::postgres::PgPoolOptions; +use sqlx::{PgPool, Row}; +use uuid::Uuid; + +fn env_or(key: &str, default: T) -> T { + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +fn stats(mut samples: Vec) -> (f64, f64, f64) { + samples.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let n = samples.len(); + let mean = samples.iter().sum::() / n as f64; + let p50 = samples[n / 2]; + let p95 = samples[((n as f64 * 0.95) as usize).min(n - 1)]; + (mean, p50, p95) +} + +// ──────────────────────────────────────────────────────────────────────────── +// [1] NC sharee search — wide row vs narrow username-only (+ trgm arm) +// ──────────────────────────────────────────────────────────────────────────── + +/// BEFORE, verbatim `UserPgRepository::search_users` SELECT list. +async fn sharee_before(pool: &PgPool, pattern: &str, limit: i64) -> Vec> { + let rows = sqlx::query( + r#" + SELECT + id, username, email, password_hash, role::text as role_text, + storage_quota_bytes, storage_used_bytes, + created_at, updated_at, last_login_at, active, + oidc_provider, oidc_subject, image, is_external, + given_name, family_name, email_verified_at, preferred_locale, notify_on_share, + ui_preferences + FROM auth.users + WHERE (username ILIKE $1 OR email ILIKE $1) + AND ($3 OR is_external = FALSE) + ORDER BY username + LIMIT $2 + "#, + ) + .bind(pattern) + .bind(limit) + .bind(false) + .fetch_all(pool) + .await + .expect("sharee wide"); + rows.into_iter() + .map(|r| { + // The handler materializes the whole row (incl. `image`) into a + // `User`/`UserDto` and then keeps only the username. Touch the + // wide columns like the entity build does. + let _image: Option = r.get("image"); + let _email: Option = r.get("email"); + r.get("username") + }) + .collect() +} + +/// AFTER: same WHERE / ORDER / LIMIT, username-only projection. +async fn sharee_after(pool: &PgPool, pattern: &str, limit: i64) -> Vec> { + let rows = sqlx::query( + r#" + SELECT username + FROM auth.users + WHERE (username ILIKE $1 OR email ILIKE $1) + AND ($3 OR is_external = FALSE) + ORDER BY username + LIMIT $2 + "#, + ) + .bind(pattern) + .bind(limit) + .bind(false) + .fetch_all(pool) + .await + .expect("sharee narrow"); + rows.into_iter().map(|r| r.get("username")).collect() +} + +async fn section_sharee(pool: &PgPool) { + let n_users: i64 = env_or("BENCH_SHR_USERS", 3000); + let passes: usize = env_or("BENCH_PASSES", 200); + let avatared = 600.min(n_users); + + // Seed server-side (no avatar bytes on the wire): first `avatared` users + // carry a ~256 KiB data-URI image, the rest none. + sqlx::query( + r#" + INSERT INTO auth.users (username, email, role, image) + SELECT + 'shr_user_' || lpad(i::text, 5, '0'), + 'shr' || i || '@bench.invalid', + 'user', + CASE WHEN i < $2 THEN 'data:image/png;base64,' || repeat('QUJDRA==', 32768) END + FROM generate_series(0, $1 - 1) AS g(i) + "#, + ) + .bind(n_users) + .bind(avatared) + .execute(pool) + .await + .expect("seed sharee users"); + + // Typing "shr_user_0" — 26-row NC sharee page, all matches avatar-carrying. + let pattern = "%shr_user_0%"; + let limit = 26i64; + + // Equivalence gate: identical username lists. + let b = sharee_before(pool, pattern, limit).await; + let a = sharee_after(pool, pattern, limit).await; + assert_eq!(b, a, "sharee result lists differ"); + assert_eq!(b.len(), limit as usize, "expected a full page"); + println!("# [1] gate: wide and narrow username lists identical — OK"); + + let mut wide = Vec::with_capacity(passes); + for _ in 0..passes { + let t = Instant::now(); + std::hint::black_box(sharee_before(pool, pattern, limit).await); + wide.push(t.elapsed().as_secs_f64() * 1e3); + } + let mut narrow = Vec::with_capacity(passes); + for _ in 0..passes { + let t = Instant::now(); + std::hint::black_box(sharee_after(pool, pattern, limit).await); + narrow.push(t.elapsed().as_secs_f64() * 1e3); + } + + // trgm arm: the production migration candidate. + sqlx::query( + "CREATE INDEX IF NOT EXISTS bench_users_username_trgm + ON auth.users USING gin (username gin_trgm_ops)", + ) + .execute(pool) + .await + .expect("trgm username"); + sqlx::query( + "CREATE INDEX IF NOT EXISTS bench_users_email_trgm + ON auth.users USING gin (email gin_trgm_ops)", + ) + .execute(pool) + .await + .expect("trgm email"); + sqlx::query("ANALYZE auth.users") + .execute(pool) + .await + .expect("analyze"); + let a_idx = sharee_after(pool, pattern, limit).await; + assert_eq!(b, a_idx, "trgm-indexed narrow list differs"); + let mut narrow_idx = Vec::with_capacity(passes); + for _ in 0..passes { + let t = Instant::now(); + std::hint::black_box(sharee_after(pool, pattern, limit).await); + narrow_idx.push(t.elapsed().as_secs_f64() * 1e3); + } + + let (wm, wp50, wp95) = stats(wide); + let (nm, np50, np95) = stats(narrow); + let (im, ip50, ip95) = stats(narrow_idx); + println!("\n## [1] NC sharee search ({n_users} users, 26-row page, all matches avatared)"); + println!("| arm | mean ms | p50 ms | p95 ms |"); + println!("| BEFORE wide row (incl. image) | {wm:>8.3} | {wp50:>7.3} | {wp95:>7.3} |"); + println!("| AFTER narrow username | {nm:>8.3} | {np50:>7.3} | {np95:>7.3} |"); + println!("| AFTER narrow + trgm index | {im:>8.3} | {ip50:>7.3} | {ip95:>7.3} |"); + println!("# narrow speedup {:.2}x; +trgm {:.2}x", wm / nm, wm / im); + + // Cleanup (drop bench indexes; production ones ship via migration only + // if the arm wins). + sqlx::query("DROP INDEX IF EXISTS auth.bench_users_username_trgm") + .execute(pool) + .await + .ok(); + sqlx::query("DROP INDEX IF EXISTS auth.bench_users_email_trgm") + .execute(pool) + .await + .ok(); + sqlx::query("DELETE FROM auth.users WHERE username LIKE 'shr\\_user\\_%'") + .execute(pool) + .await + .expect("cleanup sharee users"); + + if nm >= wm { + eprintln!("GATE FAIL [1]: narrow arm not faster — rollback"); + std::process::exit(1); + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// [2] Login stamp — redundant full-row update_user + create_session +// vs create_session alone. [3] email-verified narrow stamp. +// [4] rotation fused txn. Shared user fixture with a 256 KiB avatar. +// ──────────────────────────────────────────────────────────────────────────── + +struct AuthFixture { + user_id: Uuid, + image: String, +} + +async fn seed_auth_user(pool: &PgPool, tag: &str) -> AuthFixture { + let image = format!("data:image/png;base64,{}", "QUJDRA==".repeat(32 * 1024)); + let user_id: Uuid = sqlx::query_scalar( + "INSERT INTO auth.users (username, email, role, image, storage_quota_bytes) + VALUES ($1, $2, 'user', $3, 10737418240) RETURNING id", + ) + .bind(format!("bench12_{tag}")) + .bind(format!("bench12_{tag}@bench.invalid")) + .bind(&image) + .fetch_one(pool) + .await + .expect("seed auth user"); + AuthFixture { user_id, image } +} + +/// Verbatim replica of `UserPgRepository::update_user`'s statement, executed +/// inside a transaction like `with_transaction` does. +async fn full_row_update_user(pool: &PgPool, f: &AuthFixture, last_login: bool) { + let now = Utc::now(); + let mut tx = pool.begin().await.expect("begin"); + sqlx::query( + r#" + UPDATE auth.users + SET + username = $2, + email = $3, + password_hash = $4, + role = $5::auth.userrole, + storage_quota_bytes = $6, + storage_used_bytes = $7, + updated_at = $8, + last_login_at = $9, + active = $10, + image = $11, + given_name = $12, + family_name = $13, + email_verified_at = $14, + preferred_locale = $15, + notify_on_share = $16, + is_external = $17 + WHERE id = $1 + "#, + ) + .bind(f.user_id) + .bind("bench12_login") + .bind("bench12_login@bench.invalid") + .bind(Option::::None) + .bind("user") + .bind(10737418240i64) + .bind(0i64) + .bind(now) + .bind(if last_login { Some(now) } else { None }) + .bind(true) + .bind(&f.image) + .bind(Option::::None) + .bind(Option::::None) + .bind(if last_login { None } else { Some(now) }) + .bind(Option::::None) + .bind(true) + .bind(false) + .execute(&mut *tx) + .await + .expect("full-row update"); + tx.commit().await.expect("commit"); +} + +/// Verbatim replica of `SessionPgRepository::create_session` (insert + the +/// last_login stamp, one transaction). +async fn create_session_txn(pool: &PgPool, user_id: Uuid) -> Uuid { + let sid = Uuid::new_v4(); + let mut tx = pool.begin().await.expect("begin"); + sqlx::query( + r#" + INSERT INTO auth.sessions ( + id, user_id, refresh_token, expires_at, + ip_address, user_agent, created_at, revoked, family_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + "#, + ) + .bind(sid) + .bind(user_id) + .bind(format!("rt-{sid}")) + .bind(Utc::now() + chrono::Duration::days(30)) + .bind(Option::::None) + .bind(Option::::None) + .bind(Utc::now()) + .bind(false) + .bind(Uuid::new_v4()) + .execute(&mut *tx) + .await + .expect("insert session"); + sqlx::query("UPDATE auth.users SET last_login_at = NOW(), updated_at = NOW() WHERE id = $1") + .bind(user_id) + .execute(&mut *tx) + .await + .expect("stamp last_login"); + tx.commit().await.expect("commit"); + sid +} + +async fn section_login_stamp(pool: &PgPool) { + let passes: usize = env_or("BENCH_PASSES", 200); + let f = seed_auth_user(pool, "login").await; + + // Safety gate: the AFTER flow must leave the same observable row state + // (last_login_at set, avatar intact, everything else untouched). + full_row_update_user(pool, &f, true).await; + create_session_txn(pool, f.user_id).await; + let before_row: (Option>, Option, bool) = + sqlx::query_as("SELECT last_login_at, image, active FROM auth.users WHERE id = $1") + .bind(f.user_id) + .fetch_one(pool) + .await + .expect("row"); + sqlx::query("UPDATE auth.users SET last_login_at = NULL WHERE id = $1") + .bind(f.user_id) + .execute(pool) + .await + .unwrap(); + create_session_txn(pool, f.user_id).await; + let after_row: (Option>, Option, bool) = + sqlx::query_as("SELECT last_login_at, image, active FROM auth.users WHERE id = $1") + .bind(f.user_id) + .fetch_one(pool) + .await + .expect("row"); + assert!(before_row.0.is_some() && after_row.0.is_some()); + assert_eq!(before_row.1, after_row.1, "avatar must be untouched"); + assert_eq!(before_row.2, after_row.2); + println!("# [2] gate: create_session alone leaves identical observable state — OK"); + + let mut before = Vec::with_capacity(passes); + for _ in 0..passes { + let t = Instant::now(); + full_row_update_user(pool, &f, true).await; + create_session_txn(pool, f.user_id).await; + before.push(t.elapsed().as_secs_f64() * 1e3); + } + let mut after = Vec::with_capacity(passes); + for _ in 0..passes { + let t = Instant::now(); + create_session_txn(pool, f.user_id).await; + after.push(t.elapsed().as_secs_f64() * 1e3); + } + let (bm, bp50, bp95) = stats(before); + let (am, ap50, ap95) = stats(after); + println!("\n## [2] Password-login stamp (user with 256 KiB avatar)"); + println!("| arm | mean ms | p50 ms | p95 ms |"); + println!("| BEFORE update_user + create_session | {bm:>7.3} | {bp50:>7.3} | {bp95:>7.3} |"); + println!("| AFTER create_session only | {am:>7.3} | {ap50:>7.3} | {ap95:>7.3} |"); + println!( + "# {:.2}x faster per login; 1 txn + full-row write (incl. avatar) removed", + bm / am + ); + + sqlx::query("DELETE FROM auth.sessions WHERE user_id = $1") + .bind(f.user_id) + .execute(pool) + .await + .ok(); + sqlx::query("DELETE FROM auth.users WHERE id = $1") + .bind(f.user_id) + .execute(pool) + .await + .ok(); + if am >= bm { + eprintln!("GATE FAIL [2]: AFTER not faster — rollback"); + std::process::exit(1); + } +} + +async fn section_email_stamp(pool: &PgPool) { + let passes: usize = env_or("BENCH_PASSES", 200); + let f = seed_auth_user(pool, "email").await; + + // AFTER: the narrow conditional stamp (idempotent in SQL, mirroring the + // entity guard `if email_verified_at.is_none()`). + async fn narrow_stamp(pool: &PgPool, id: Uuid) -> u64 { + sqlx::query( + "UPDATE auth.users + SET email_verified_at = NOW(), updated_at = NOW() + WHERE id = $1 AND email_verified_at IS NULL", + ) + .bind(id) + .execute(pool) + .await + .expect("narrow stamp") + .rows_affected() + } + + // Gates: first call stamps; second call is a no-op (idempotent); value + // survives; avatar untouched. + assert_eq!(narrow_stamp(pool, f.user_id).await, 1); + let first: Option> = + sqlx::query_scalar("SELECT email_verified_at FROM auth.users WHERE id = $1") + .bind(f.user_id) + .fetch_one(pool) + .await + .unwrap(); + assert!(first.is_some()); + assert_eq!( + narrow_stamp(pool, f.user_id).await, + 0, + "second stamp must be a no-op" + ); + let second: Option> = + sqlx::query_scalar("SELECT email_verified_at FROM auth.users WHERE id = $1") + .bind(f.user_id) + .fetch_one(pool) + .await + .unwrap(); + assert_eq!(first, second, "timestamp must not move on re-stamp"); + println!("# [3] gate: narrow stamp idempotent, value stable — OK"); + + let mut before = Vec::with_capacity(passes); + for _ in 0..passes { + let t = Instant::now(); + full_row_update_user(pool, &f, false).await; + before.push(t.elapsed().as_secs_f64() * 1e3); + } + // Reset so the narrow arm measures the write path (not the no-op path). + let mut after = Vec::with_capacity(passes); + for _ in 0..passes { + sqlx::query("UPDATE auth.users SET email_verified_at = NULL WHERE id = $1") + .bind(f.user_id) + .execute(pool) + .await + .unwrap(); + let t = Instant::now(); + narrow_stamp(pool, f.user_id).await; + after.push(t.elapsed().as_secs_f64() * 1e3); + } + let (bm, bp50, bp95) = stats(before); + let (am, ap50, ap95) = stats(after); + + // [3b] OIDC repeat-login profile sync — the guarded narrow UPDATE in + // its no-op case (same avatar, already verified). This arm is the + // evidence for why production ALSO short-circuits app-side: even a + // 0-row guarded UPDATE ships the ≤512 KiB avatar parameter over the + // wire just to compare it server-side, so the shipped shape compares + // against the already-fetched row in memory and issues NO query on + // the repeat-login common case (the guarded UPDATE remains as the + // write path when something actually changed, and as a belt-and- + // braces guard). + sqlx::query("UPDATE auth.users SET email_verified_at = NOW(), image = $2 WHERE id = $1") + .bind(f.user_id) + .bind(&f.image) + .execute(pool) + .await + .unwrap(); + let mut sync_noop = Vec::with_capacity(passes); + for _ in 0..passes { + let t = Instant::now(); + let res = sqlx::query( + "UPDATE auth.users + SET image = $2, + email_verified_at = COALESCE(email_verified_at, NOW()), + updated_at = NOW() + WHERE id = $1 + AND (image IS DISTINCT FROM $2 OR email_verified_at IS NULL)", + ) + .bind(f.user_id) + .bind(&f.image) + .execute(pool) + .await + .unwrap(); + assert_eq!(res.rows_affected(), 0, "no-op path must not write"); + sync_noop.push(t.elapsed().as_secs_f64() * 1e3); + } + let (sm, sp50, sp95) = stats(sync_noop); + + println!("\n## [3] Email-verified stamp (user with 256 KiB avatar)"); + println!("| arm | mean ms | p50 ms | p95 ms |"); + println!("| BEFORE full-row update_user | {bm:>7.3} | {bp50:>7.3} | {bp95:>7.3} |"); + println!("| AFTER narrow conditional | {am:>7.3} | {ap50:>7.3} | {ap95:>7.3} |"); + println!("| AFTER oidc guarded no-op | {sm:>7.3} | {sp50:>7.3} | {sp95:>7.3} |"); + println!( + "# {:.2}x faster per stamp; guarded no-op still ships the avatar param \ + ({:.2}x) — hence the app-side skip (0 queries) shipped in production", + bm / am, + bm / sm + ); + + sqlx::query("DELETE FROM auth.users WHERE id = $1") + .bind(f.user_id) + .execute(pool) + .await + .ok(); + if am >= bm { + eprintln!("GATE FAIL [3]: AFTER not faster — rollback"); + std::process::exit(1); + } +} + +async fn section_rotation(pool: &PgPool) { + let passes: usize = env_or("BENCH_PASSES", 200); + let f = seed_auth_user(pool, "rot").await; + + // BEFORE: revoke txn (verbatim) + create txn (verbatim). + async fn before_rotate(pool: &PgPool, user_id: Uuid, old: Uuid) -> Uuid { + let mut tx = pool.begin().await.expect("begin"); + let _row = + sqlx::query("UPDATE auth.sessions SET revoked = true WHERE id = $1 RETURNING user_id") + .bind(old) + .fetch_optional(&mut *tx) + .await + .expect("revoke"); + tx.commit().await.expect("commit"); + create_session_txn(pool, user_id).await + } + + // AFTER: one fused transaction (same three statements, one txn). + async fn after_rotate(pool: &PgPool, user_id: Uuid, old: Uuid) -> Uuid { + let sid = Uuid::new_v4(); + let mut tx = pool.begin().await.expect("begin"); + sqlx::query("UPDATE auth.sessions SET revoked = true WHERE id = $1 RETURNING user_id") + .bind(old) + .fetch_optional(&mut *tx) + .await + .expect("revoke"); + sqlx::query( + r#" + INSERT INTO auth.sessions ( + id, user_id, refresh_token, expires_at, + ip_address, user_agent, created_at, revoked, family_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + "#, + ) + .bind(sid) + .bind(user_id) + .bind(format!("rt-{sid}")) + .bind(Utc::now() + chrono::Duration::days(30)) + .bind(Option::::None) + .bind(Option::::None) + .bind(Utc::now()) + .bind(false) + .bind(Uuid::new_v4()) + .execute(&mut *tx) + .await + .expect("insert"); + sqlx::query( + "UPDATE auth.users SET last_login_at = NOW(), updated_at = NOW() WHERE id = $1", + ) + .bind(user_id) + .execute(&mut *tx) + .await + .expect("stamp"); + tx.commit().await.expect("commit"); + sid + } + + // Gate: both arms leave old session revoked + new session live. + let s0 = create_session_txn(pool, f.user_id).await; + let s1 = before_rotate(pool, f.user_id, s0).await; + let s2 = after_rotate(pool, f.user_id, s1).await; + let states: Vec<(Uuid, bool)> = + sqlx::query_as("SELECT id, revoked FROM auth.sessions WHERE user_id = $1") + .bind(f.user_id) + .fetch_all(pool) + .await + .unwrap(); + let get = |id: Uuid| states.iter().find(|(s, _)| *s == id).map(|(_, r)| *r); + assert_eq!(get(s0), Some(true), "s0 revoked"); + assert_eq!(get(s1), Some(true), "s1 revoked by after_rotate"); + assert_eq!(get(s2), Some(false), "s2 live"); + println!("# [4] gate: fused rotation leaves identical session states — OK"); + + let mut cur = s2; + let mut before = Vec::with_capacity(passes); + for _ in 0..passes { + let t = Instant::now(); + cur = before_rotate(pool, f.user_id, cur).await; + before.push(t.elapsed().as_secs_f64() * 1e3); + } + let mut after = Vec::with_capacity(passes); + for _ in 0..passes { + let t = Instant::now(); + cur = after_rotate(pool, f.user_id, cur).await; + after.push(t.elapsed().as_secs_f64() * 1e3); + } + let (bm, bp50, bp95) = stats(before); + let (am, ap50, ap95) = stats(after); + println!("\n## [4] Refresh-token rotation"); + println!("| arm | mean ms | p50 ms | p95 ms |"); + println!("| BEFORE 2 transactions | {bm:>7.3} | {bp50:>7.3} | {bp95:>7.3} |"); + println!("| AFTER 1 transaction | {am:>7.3} | {ap50:>7.3} | {ap95:>7.3} |"); + println!("# {:.2}x faster per rotation", bm / am); + + sqlx::query("DELETE FROM auth.sessions WHERE user_id = $1") + .bind(f.user_id) + .execute(pool) + .await + .ok(); + sqlx::query("DELETE FROM auth.users WHERE id = $1") + .bind(f.user_id) + .execute(pool) + .await + .ok(); + if am >= bm { + eprintln!("GATE FAIL [4]: AFTER not faster — rollback"); + std::process::exit(1); + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// [5] WOPI CheckFileInfo triple — serial vs join! (real engine + file repo) +// ──────────────────────────────────────────────────────────────────────────── + +struct WopiSeed { + caller: Uuid, + drive_id: Uuid, + root_folder: Uuid, + blob_hash: String, + file_ids: Vec, +} + +async fn wopi_seed(pool: &PgPool, n_files: usize) -> WopiSeed { + let mut tx = pool.begin().await.expect("begin"); + let caller: Uuid = sqlx::query_scalar( + "INSERT INTO auth.users (username, email, role) + VALUES ('bench12_wopi', 'bench12_wopi@bench.invalid', 'user') RETURNING id", + ) + .fetch_one(&mut *tx) + .await + .expect("seed caller"); + let drive_id: Uuid = + sqlx::query_scalar("INSERT INTO storage.drives (kind) VALUES ('shared') RETURNING id") + .fetch_one(&mut *tx) + .await + .expect("seed drive"); + let root_folder: Uuid = sqlx::query_scalar( + "INSERT INTO storage.folders (name, path, lpath, drive_id) + VALUES ('Bench12 WOPI', '/Bench12 WOPI', 'x', $1) RETURNING id", + ) + .bind(drive_id) + .fetch_one(&mut *tx) + .await + .expect("seed folder"); + sqlx::query("UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2") + .bind(root_folder) + .bind(drive_id) + .execute(&mut *tx) + .await + .expect("stamp root"); + sqlx::query( + "INSERT INTO storage.role_grants + (subject_type, subject_id, resource_type, resource_id, role, granted_by) + VALUES ('user', $1, 'drive', $2, 'editor'::storage.grant_role, $1)", + ) + .bind(caller) + .bind(drive_id) + .execute(&mut *tx) + .await + .expect("seed grant"); + let blob_hash = "bench12wopi00000000000000000000000000000000000000000000000000b1".to_string(); + sqlx::query("INSERT INTO storage.blobs (hash, size, ref_count) VALUES ($1, 1, 1)") + .bind(&blob_hash) + .execute(&mut *tx) + .await + .expect("seed blob"); + let mut file_ids = Vec::with_capacity(n_files); + for i in 0..n_files { + let id: Uuid = sqlx::query_scalar( + "INSERT INTO storage.files (name, folder_id, blob_hash, size, mime_type, drive_id) + VALUES ($1, $2, $3, 1, 'application/vnd.oasis.opendocument.text', $4) RETURNING id", + ) + .bind(format!("bench12-{i:04}.odt")) + .bind(root_folder) + .bind(&blob_hash) + .bind(drive_id) + .fetch_one(&mut *tx) + .await + .expect("seed file"); + file_ids.push(id); + } + tx.commit().await.expect("commit"); + WopiSeed { + caller, + drive_id, + root_folder, + blob_hash, + file_ids, + } +} + +async fn wopi_cleanup(pool: &PgPool, s: &WopiSeed) { + let _ = sqlx::query("DELETE FROM storage.role_grants WHERE resource_id = $1") + .bind(s.drive_id) + .execute(pool) + .await; + let _ = sqlx::query("DELETE FROM storage.files WHERE drive_id = $1") + .bind(s.drive_id) + .execute(pool) + .await; + let _ = sqlx::query("DELETE FROM storage.drives WHERE id = $1") + .bind(s.drive_id) + .execute(pool) + .await; + let _ = sqlx::query("DELETE FROM storage.folders WHERE id = $1") + .bind(s.root_folder) + .execute(pool) + .await; + let _ = sqlx::query("DELETE FROM storage.blobs WHERE hash = $1") + .bind(&s.blob_hash) + .execute(pool) + .await; + let _ = sqlx::query("DELETE FROM auth.users WHERE id = $1") + .bind(s.caller) + .execute(pool) + .await; +} + +fn wopi_engine(pool: &Arc) -> (Arc, Arc) { + let folder_repo = Arc::new(FolderDbRepository::new(pool.clone())); + let backend = Arc::new(LocalBlobBackend::new(std::path::Path::new( + "/tmp/bench12-wopi-blobs", + ))); + let dedup = Arc::new(DedupService::new(backend, pool.clone(), pool.clone())); + let file_repo = Arc::new(FileBlobReadRepository::new( + pool.clone(), + dedup, + folder_repo.clone(), + )); + let group_repo = Arc::new(SubjectGroupPgRepository::new(pool.clone())); + ( + Arc::new(PgAclEngine::new( + pool.clone(), + folder_repo, + file_repo.clone(), + group_repo, + )), + file_repo, + ) +} + +/// BEFORE, verbatim handler shape: require(Read) → get_file → check(Update). +async fn wopi_before( + engine: &Arc, + files: &Arc, + caller: Uuid, + file_id: Uuid, +) -> (String, bool) { + engine + .require( + Subject::User(caller), + Permission::Read, + Resource::File(file_id), + ) + .await + .expect("read"); + let file = files.get_file(&file_id.to_string()).await.expect("file"); + let can_write = engine + .check( + Subject::User(caller), + Permission::Update, + Resource::File(file_id), + ) + .await + .unwrap_or(false); + (file.name().to_string(), can_write) +} + +/// AFTER: the three independent lookups overlapped. +async fn wopi_after( + engine: &Arc, + files: &Arc, + caller: Uuid, + file_id: Uuid, +) -> (String, bool) { + let id_str = file_id.to_string(); + let (read, file, can_write) = tokio::join!( + engine.require( + Subject::User(caller), + Permission::Read, + Resource::File(file_id) + ), + files.get_file(&id_str), + engine.check( + Subject::User(caller), + Permission::Update, + Resource::File(file_id) + ), + ); + read.expect("read"); + let file = file.expect("file"); + (file.name().to_string(), can_write.unwrap_or(false)) +} + +async fn section_wopi(pool: &Arc) { + let n_files: usize = env_or("BENCH_WOPI_FILES", 100); + let warm_iters: usize = env_or("BENCH_WARM_ITERS", 2000); + let seed = wopi_seed(pool, n_files).await; + + // Equivalence gate (fresh engines so both arms run the same cold path). + let (e1, f1) = wopi_engine(pool); + let (e2, f2) = wopi_engine(pool); + for id in seed.file_ids.iter().take(10) { + let b = wopi_before(&e1, &f1, seed.caller, *id).await; + let a = wopi_after(&e2, &f2, seed.caller, *id).await; + assert_eq!(b, a, "wopi results differ"); + } + println!("# [5] gate: serial and join! results identical (10 files) — OK"); + + // COLD arms: fresh engine, one triple per file (the first CheckFileInfo + // per file per TTL window). + let (ec, fc) = wopi_engine(pool); + let t = Instant::now(); + for id in &seed.file_ids { + std::hint::black_box(wopi_before(&ec, &fc, seed.caller, *id).await); + } + let cold_before = t.elapsed().as_secs_f64() * 1e3 / n_files as f64; + let (ec2, fc2) = wopi_engine(pool); + let t = Instant::now(); + for id in &seed.file_ids { + std::hint::black_box(wopi_after(&ec2, &fc2, seed.caller, *id).await); + } + let cold_after = t.elapsed().as_secs_f64() * 1e3 / n_files as f64; + + // WARM arms: same engine, authz caches hot — get_file dominates. + let (ew, fw) = wopi_engine(pool); + for id in &seed.file_ids { + wopi_before(&ew, &fw, seed.caller, *id).await; + } + let t = Instant::now(); + for i in 0..warm_iters { + let id = seed.file_ids[i % n_files]; + std::hint::black_box(wopi_before(&ew, &fw, seed.caller, id).await); + } + let warm_before = t.elapsed().as_secs_f64() * 1e3 / warm_iters as f64; + let t = Instant::now(); + for i in 0..warm_iters { + let id = seed.file_ids[i % n_files]; + std::hint::black_box(wopi_after(&ew, &fw, seed.caller, id).await); + } + let warm_after = t.elapsed().as_secs_f64() * 1e3 / warm_iters as f64; + + println!("\n## [5] WOPI CheckFileInfo triple (real PgAclEngine)"); + println!("| arm | cold ms/call | warm ms/call |"); + println!("| BEFORE serial | {cold_before:>9.3} | {warm_before:>9.3} |"); + println!("| AFTER join! | {cold_after:>9.3} | {warm_after:>9.3} |"); + println!( + "# cold {:.2}x, warm {:.2}x", + cold_before / cold_after, + warm_before / warm_after + ); + + wopi_cleanup(pool, &seed).await; + if cold_after >= cold_before && warm_after >= warm_before { + eprintln!("GATE FAIL [5]: join! not faster on either arm — rollback"); + std::process::exit(1); + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// [6] Upload quota pair — two serial point reads vs one fused SELECT +// ──────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +enum QuotaVerdict { + Ok, + UserQuotaExceeded, + DriveQuotaExceeded, + DriveNotFound, +} + +/// BEFORE, verbatim: `check_storage_quota` (narrow user read) then +/// `check_drive_quota` (drive point read), serial. +async fn quota_before( + pool: &PgPool, + user_id: Uuid, + drive_id: Uuid, + additional: u64, +) -> QuotaVerdict { + let (used, quota): (i64, i64) = sqlx::query_as( + "SELECT storage_used_bytes, storage_quota_bytes FROM auth.users WHERE id = $1", + ) + .bind(user_id) + .fetch_one(pool) + .await + .expect("user quota row"); + if quota > 0 { + let additional_i = additional as i64; + if additional_i > quota || used + additional_i > quota { + return QuotaVerdict::UserQuotaExceeded; + } + } + let row: Option<(i64, Option)> = + sqlx::query_as("SELECT used_bytes, quota_bytes FROM storage.drives WHERE id = $1") + .bind(drive_id) + .fetch_optional(pool) + .await + .expect("drive quota row"); + let Some((dused, dquota)) = row else { + return QuotaVerdict::DriveNotFound; + }; + let Some(dquota) = dquota else { + return QuotaVerdict::Ok; + }; + if (dused as i128) + (additional as i128) > dquota as i128 { + return QuotaVerdict::DriveQuotaExceeded; + } + QuotaVerdict::Ok +} + +/// Fused row: `(user_used, user_quota, drive_used, drive_quota, drive_found)`. +type QuotaPairRow = (i64, i64, Option, Option, bool); + +/// AFTER: one fused round-trip; verdict precedence identical (user envelope +/// first, then drive existence, then drive cap). +async fn quota_after( + pool: &PgPool, + user_id: Uuid, + drive_id: Uuid, + additional: u64, +) -> QuotaVerdict { + let row: Option = sqlx::query_as( + r#" + SELECT u.storage_used_bytes, u.storage_quota_bytes, + d.used_bytes, d.quota_bytes, (d.id IS NOT NULL) AS drive_found + FROM auth.users u + LEFT JOIN storage.drives d ON d.id = $2 + WHERE u.id = $1 + "#, + ) + .bind(user_id) + .bind(drive_id) + .fetch_optional(pool) + .await + .expect("fused quota row"); + let Some((used, quota, dused, dquota, drive_found)) = row else { + // user missing — out of scope here (upload paths resolve the caller + // first); keep the BEFORE panic semantics. + panic!("user quota row"); + }; + if quota > 0 { + let additional_i = additional as i64; + if additional_i > quota || used + additional_i > quota { + return QuotaVerdict::UserQuotaExceeded; + } + } + if !drive_found { + return QuotaVerdict::DriveNotFound; + } + match dquota { + None => QuotaVerdict::Ok, + Some(dq) => { + if (dused.unwrap_or(0) as i128) + (additional as i128) > dq as i128 { + QuotaVerdict::DriveQuotaExceeded + } else { + QuotaVerdict::Ok + } + } + } +} + +async fn section_quota(pool: &PgPool) { + let iters: usize = env_or("BENCH_WARM_ITERS", 2000); + + // Fixtures: user 10 GiB quota / 1 GiB used; capped drive; unlimited drive. + let user_ok: Uuid = sqlx::query_scalar( + "INSERT INTO auth.users (username, email, role, storage_quota_bytes, storage_used_bytes) + VALUES ('bench12_quota', 'bench12_quota@bench.invalid', 'user', 10737418240, 1073741824) + RETURNING id", + ) + .fetch_one(pool) + .await + .expect("seed quota user"); + let drive_cap: Uuid = sqlx::query_scalar( + "INSERT INTO storage.drives (kind, quota_bytes, used_bytes) + VALUES ('shared', 5368709120, 4294967296) RETURNING id", + ) + .fetch_one(pool) + .await + .expect("seed capped drive"); + let drive_unl: Uuid = + sqlx::query_scalar("INSERT INTO storage.drives (kind) VALUES ('shared') RETURNING id") + .fetch_one(pool) + .await + .expect("seed unlimited drive"); + let drive_missing = Uuid::new_v4(); + + // Verdict-identity gate across the scenario matrix. + let scenarios: &[(Uuid, Uuid, u64)] = &[ + (user_ok, drive_cap, 1024), // ok + (user_ok, drive_cap, 2 * 1024 * 1024 * 1024), // drive cap exceeded + (user_ok, drive_cap, 20 * 1024 * 1024 * 1024), // user envelope exceeded (precedence) + (user_ok, drive_unl, 8 * 1024 * 1024 * 1024), // unlimited drive, user ok + (user_ok, drive_missing, 1024), // drive missing + ]; + for (u, d, add) in scenarios { + let b = quota_before(pool, *u, *d, *add).await; + let a = quota_after(pool, *u, *d, *add).await; + assert_eq!(b, a, "verdict differs for add={add}"); + } + println!("# [6] gate: verdict identity across 5 scenarios (incl. precedence) — OK"); + + let t = Instant::now(); + for i in 0..iters { + std::hint::black_box(quota_before(pool, user_ok, drive_cap, (i % 4096) as u64).await); + } + let before_ms = t.elapsed().as_secs_f64() * 1e3 / iters as f64; + let t = Instant::now(); + for i in 0..iters { + std::hint::black_box(quota_after(pool, user_ok, drive_cap, (i % 4096) as u64).await); + } + let after_ms = t.elapsed().as_secs_f64() * 1e3 / iters as f64; + + println!("\n## [6] Upload quota pair (per NC chunk PUT / upload gate)"); + println!("| arm | ms/check |"); + println!("| BEFORE 2 serial point reads | {before_ms:>7.3} |"); + println!("| AFTER 1 fused read | {after_ms:>7.3} |"); + println!( + "# {:.2}x faster, 1 query saved per check", + before_ms / after_ms + ); + + sqlx::query("DELETE FROM storage.drives WHERE id IN ($1, $2)") + .bind(drive_cap) + .bind(drive_unl) + .execute(pool) + .await + .ok(); + sqlx::query("DELETE FROM auth.users WHERE id = $1") + .bind(user_ok) + .execute(pool) + .await + .ok(); + if after_ms >= before_ms { + eprintln!("GATE FAIL [6]: fused read not faster — rollback"); + std::process::exit(1); + } +} + +#[tokio::main(flavor = "multi_thread", worker_threads = 4)] +async fn main() { + let _ = dotenvy::dotenv(); + let url = std::env::var("DATABASE_URL").expect("DATABASE_URL required (see .env)"); + let pool = Arc::new( + PgPoolOptions::new() + .max_connections(8) + .connect(&url) + .await + .expect("connect"), + ); + + println!("#################################################################"); + println!("# Round-12 query-shape pack"); + println!("#################################################################"); + + section_sharee(&pool).await; + section_login_stamp(&pool).await; + section_email_stamp(&pool).await; + section_rotation(&pool).await; + section_wopi(&pool).await; + section_quota(&pool).await; + + println!("\nGATE PASS (all sections)"); +} diff --git a/frontend/src/lib/api/endpoints/files.ts b/frontend/src/lib/api/endpoints/files.ts index 880e0d8f..fd6e37db 100644 --- a/frontend/src/lib/api/endpoints/files.ts +++ b/frontend/src/lib/api/endpoints/files.ts @@ -163,3 +163,13 @@ export function fileThumbnailUrl( ): string { return `/api/files/${fileId}/thumbnail/${size}`; } + +/** + * Thumbnail size matched to the rendering slot. List rows draw thumbnails in + * a 40×40 box, so the 150px `icon` rendition is already ≥2× retina density — + * fetching the 400px `preview` there moved ~7× more pixels than the slot can + * show (benches/ROUND12.md §F1). Grid cards (100×70 slot) keep `preview`. + */ +export function thumbSizeForView(view: 'grid' | 'list'): 'icon' | 'preview' { + return view === 'list' ? 'icon' : 'preview'; +} diff --git a/frontend/src/lib/api/endpoints/round12.bench.test.ts b/frontend/src/lib/api/endpoints/round12.bench.test.ts new file mode 100644 index 00000000..0b0fb7a4 --- /dev/null +++ b/frontend/src/lib/api/endpoints/round12.bench.test.ts @@ -0,0 +1,34 @@ +// Round-12 §F1 — list-view thumbnail rendition (benches/ROUND12.md). +// +// The list rows draw file thumbnails in a 40×40 CSS-px slot (100×70 in +// grid), but both views requested the 400px `preview` rendition. The list +// view now requests the 150px `icon` rendition: still ≥2× device-pixel +// density for the 40px slot, at ~1/7th of the decoded pixels (and roughly +// icon ≈ 4-8 KB vs preview ≈ 20-40 KB encoded WebP per thumbnail). +// +// Gates: the URL actually switches per view; grid keeps `preview`; the +// pixel-area saving is the documented ~7x. + +import { describe, expect, it } from 'vitest'; +import { fileThumbnailUrl, thumbSizeForView } from './files'; + +describe('round12 §F1 — thumbnail rendition per view', () => { + it('list view requests the icon rendition, grid keeps preview', () => { + expect(thumbSizeForView('list')).toBe('icon'); + expect(thumbSizeForView('grid')).toBe('preview'); + expect(fileThumbnailUrl('abc', thumbSizeForView('list'))).toBe('/api/files/abc/thumbnail/icon'); + expect(fileThumbnailUrl('abc', thumbSizeForView('grid'))).toBe( + '/api/files/abc/thumbnail/preview' + ); + }); + + it('icon rendition moves ~7x fewer pixels than preview for the 40px slot', () => { + // Server renditions: icon = 150px, preview = 400px (see the photos + // srcset: `icon 150w, preview 400w, large 800w`). + const areaRatio = (400 * 400) / (150 * 150); + expect(areaRatio).toBeGreaterThan(7); + // The 40×40 slot at 2x DPR needs 80px — icon's 150px still + // oversamples it; preview was pure waste. + expect(150).toBeGreaterThanOrEqual(80); + }); +}); diff --git a/frontend/src/lib/components/PeopleView.test.ts b/frontend/src/lib/components/PeopleView.test.ts index 049288e9..36923117 100644 --- a/frontend/src/lib/components/PeopleView.test.ts +++ b/frontend/src/lib/components/PeopleView.test.ts @@ -6,7 +6,10 @@ vi.mock('$lib/api/endpoints/people', () => ({ fetchPersonPhotos: vi.fn(), renamePerson: vi.fn() })); -vi.mock('$lib/api/endpoints/files', () => ({ fileThumbnailUrl: () => '/thumb.png' })); +vi.mock('$lib/api/endpoints/files', () => ({ + fileThumbnailUrl: () => '/thumb.png', + thumbSizeForView: () => 'preview' as const +})); vi.mock('$lib/stores/dialogs.svelte', () => ({ promptDialog: vi.fn() })); import { fetchPeople, fetchPersonPhotos, renamePerson } from '$lib/api/endpoints/people'; diff --git a/frontend/src/lib/components/PhotoLightbox.test.ts b/frontend/src/lib/components/PhotoLightbox.test.ts index ba9f5a75..2470abcf 100644 --- a/frontend/src/lib/components/PhotoLightbox.test.ts +++ b/frontend/src/lib/components/PhotoLightbox.test.ts @@ -4,7 +4,8 @@ vi.mock('$lib/api/endpoints/files', () => ({ deleteFile: vi.fn(), fileDownloadUrl: () => '/d', fileInlineUrl: () => '/i', - fileThumbnailUrl: () => '/t' + fileThumbnailUrl: () => '/t', + thumbSizeForView: () => 'preview' as const })); vi.mock('$lib/api/endpoints/favorites', () => ({ addFavorite: vi.fn() })); vi.mock('$lib/api/endpoints/photos', () => ({ fetchFileMetadata: vi.fn() })); diff --git a/frontend/src/lib/components/ResourceList.svelte b/frontend/src/lib/components/ResourceList.svelte index cc0587f6..1d78349d 100644 --- a/frontend/src/lib/components/ResourceList.svelte +++ b/frontend/src/lib/components/ResourceList.svelte @@ -78,7 +78,7 @@ import { formatBytes } from '$lib/utils/format'; import { formatDate, iconNameFromClass, fileIconKindClass } from '$lib/utils/display'; import { gridColumns } from '$lib/utils/grid'; - import { fileThumbnailUrl } from '$lib/api/endpoints/files'; + import { fileThumbnailUrl, thumbSizeForView } from '$lib/api/endpoints/files'; import { canThumbnailClientSide, preloadPdf, @@ -621,7 +621,7 @@ {#if enableThumbnails && kind === 'file' && mimeVal && canThumbnailClientSide( { id: item.id, name: item.name, mime_type: mimeVal } )} { diff --git a/frontend/src/routes/favorites/page.test.ts b/frontend/src/routes/favorites/page.test.ts index 0626082b..cd0277ff 100644 --- a/frontend/src/routes/favorites/page.test.ts +++ b/frontend/src/routes/favorites/page.test.ts @@ -18,6 +18,7 @@ vi.mock('$lib/api/endpoints/files', () => ({ // src for the fallback path; tests don't render actual thumbnails // but the module import needs to succeed. fileThumbnailUrl: () => '/thumb.png', + thumbSizeForView: () => 'preview' as const, renameFile: vi.fn(), deleteFile: vi.fn() })); diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index 444384dd..7f279891 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -28,6 +28,7 @@ fileThumbnailUrl, moveFile, renameFile, + thumbSizeForView, uploadFileWithProgress } from '$lib/api/endpoints/files'; import { folderZipUrl } from '$lib/api/endpoints/folders'; @@ -2137,7 +2138,7 @@ {#if canThumbnail(file)} { diff --git a/frontend/src/routes/files/page.test.ts b/frontend/src/routes/files/page.test.ts index bde2b0d1..be32d400 100644 --- a/frontend/src/routes/files/page.test.ts +++ b/frontend/src/routes/files/page.test.ts @@ -39,6 +39,7 @@ vi.mock('$lib/api/endpoints/files', () => ({ deleteFile: vi.fn(), fileDownloadUrl: () => '/dl', fileThumbnailUrl: () => '/thumb', + thumbSizeForView: () => 'preview' as const, moveFile: vi.fn(), renameFile: vi.fn(), uploadFile: vi.fn(), diff --git a/frontend/src/routes/photos/page.test.ts b/frontend/src/routes/photos/page.test.ts index 9242f7bc..457a272c 100644 --- a/frontend/src/routes/photos/page.test.ts +++ b/frontend/src/routes/photos/page.test.ts @@ -15,7 +15,8 @@ vi.mock('$lib/api/endpoints/photos', () => ({ vi.mock('$lib/api/endpoints/people', () => ({ peopleEnabled: vi.fn() })); vi.mock('$lib/api/endpoints/files', () => ({ fileDownloadUrl: () => '/dl', - fileThumbnailUrl: () => '/thumb' + fileThumbnailUrl: () => '/thumb', + thumbSizeForView: () => 'preview' as const })); import { fetchPhotos } from '$lib/api/endpoints/photos'; diff --git a/migrations/20260719000000_users_search_trgm.sql b/migrations/20260719000000_users_search_trgm.sql new file mode 100644 index 00000000..4117c7e5 --- /dev/null +++ b/migrations/20260719000000_users_search_trgm.sql @@ -0,0 +1,14 @@ +-- Trigram indexes for the user search path (NC sharee autocomplete + admin +-- user search), which filters with a leading-wildcard `ILIKE '%q%'` that no +-- btree can serve — every keystroke was a full `auth.users` seq scan. +-- +-- Mirrors the existing `gin_trgm_ops` indexes on contacts / files / folders +-- (pg_trgm is a hard startup requirement, see 20260307000000). Measured in +-- benches/ROUND12.md §1: 26-row sharee page over 3 000 users drops from +-- 2.37 ms (narrow read, seq scan) to 0.22 ms; the gap widens with user count. + +CREATE INDEX IF NOT EXISTS idx_users_username_trgm + ON auth.users USING gin (username gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS idx_users_email_trgm + ON auth.users USING gin (email gin_trgm_ops); diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index bb148925..ee477054 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -131,6 +131,37 @@ pub trait UserStoragePort: Send + Sync + 'static { include_external: bool, ) -> Result, DomainError>; + /// Username-only projection of [`search_users`] — same WHERE / ORDER / + /// LIMIT semantics, but skips hydrating the 21-column row (incl. the + /// up-to-512 KiB avatar `image`) when the caller only needs handles. + /// Rows whose username is NULL are returned as `None` so callers can + /// keep the wide flow's post-limit filtering semantics. + async fn search_usernames( + &self, + query: &str, + limit: i64, + include_external: bool, + ) -> Result>, DomainError>; + + /// Stamps `email_verified_at = NOW()` iff it is still NULL (idempotent, + /// preserves the first timestamp — the SQL twin of + /// `User::mark_email_verified`). Narrow single-column write; avoids the + /// full-row [`update_user`] (incl. the avatar `image`) on the + /// magic-link redemption path. + async fn mark_email_verified(&self, user_id: Uuid) -> Result<(), DomainError>; + + /// OIDC repeat-login profile sync: persists the IdP-provided avatar and + /// stamps `email_verified_at` (guarded, idempotent) in ONE narrow + /// statement. The `IS DISTINCT FROM` guard makes the common case (same + /// avatar, already verified) a zero-write no-op — vs the full 17-column + /// row rewrite this path used to pay per login. `last_login_at` is NOT + /// touched here: session creation stamps it, as on every login path. + async fn sync_oidc_login_profile( + &self, + user_id: Uuid, + image: Option<&str>, + ) -> Result<(), DomainError>; + /// Lists users by role (e.g., "admin" or "user") async fn list_users_by_role(&self, role: &str) -> Result, DomainError>; @@ -239,6 +270,16 @@ pub trait SessionStoragePort: Send + Sync + 'static { /// Creates a new session async fn create_session(&self, session: Session) -> Result; + /// Refresh-token rotation: revokes `old_session_id` and creates + /// `new_session` in ONE transaction (the refresh path used to pay two + /// full BEGIN/COMMIT round-trip pairs per rotation). Also stamps the + /// user's `last_login_at` exactly like [`create_session`] does. + async fn rotate_session( + &self, + old_session_id: Uuid, + new_session: Session, + ) -> Result; + /// Gets a session by refresh token async fn get_session_by_refresh_token( &self, diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 7550e794..4bfd4e4e 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -786,9 +786,14 @@ impl AuthApplicationService { lc.dispatch_login(&user).await; } - // Update last login + // Update last login (in-memory only — the DTO below carries it). + // The full-row `update_user` this path used to issue was 100% + // redundant: `create_session` stamps `last_login_at`/`updated_at` + // in its own transaction right below, and nothing re-reads the row + // in between. Dropping it removes one transaction + a 17-column + // rewrite (incl. the up-to-512 KiB avatar) per password login + // (benches/ROUND12.md §2, 4.45x). user.register_login(); - self.user_storage.update_user(user.clone()).await?; // Generate tokens using the injected token service let access_token = self.token_service.generate_access_token(&user)?; @@ -1017,9 +1022,12 @@ impl AuthApplicationService { // PR 23: clicking the magic-link IS proof of email control — // stamp the verification (idempotent, preserves the first // timestamp). Applies to both invitation and login-via-email - // tokens. + // tokens. Narrow single-column write: `last_login_at` is stamped + // by `create_session` below, so the full-row `update_user` this + // path used to issue only ever contributed the verification + // timestamp (benches/ROUND12.md §3, 8.9x). user.mark_email_verified(); - self.user_storage.update_user(user.clone()).await?; + self.user_storage.mark_email_verified(user.id()).await?; let access_token = self.token_service.generate_access_token(&user)?; let refresh_token = self.token_service.generate_refresh_token(); @@ -1163,15 +1171,15 @@ impl AuthApplicationService { )); } - // Revoke current session before issuing the next token in the family - self.session_storage.revoke_session(session.id()).await?; - // Generate new tokens let access_token = self.token_service.generate_access_token(&user)?; let new_refresh_token = self.token_service.generate_refresh_token(); // New session inherits the family_id so reuse of any ancestor triggers - // full-family revocation + // full-family revocation. Revoking the old session and inserting the + // new one happen in ONE transaction (`rotate_session`) — this path + // used to pay two BEGIN/COMMIT pairs per refresh, and DAV clients + // rotate constantly (benches/ROUND12.md §4). let new_session = Session::new( user.id(), new_refresh_token.clone(), @@ -1181,7 +1189,9 @@ impl AuthApplicationService { session.family_id(), ); - self.session_storage.create_session(new_session).await?; + self.session_storage + .rotate_session(session.id(), new_session) + .await?; Ok(AuthResponseDto { user: UserDto::from(user), @@ -2030,6 +2040,24 @@ impl AuthApplicationService { Ok(users.into_iter().map(UserDto::from).collect()) } + /// Username-only search for the NC sharee autocomplete: identical + /// predicate / order / limit to [`search_users`], but the repository + /// projects just `username` — no 21-column hydration (incl. the + /// up-to-512 KiB avatar `image`) per matched row, per keystroke + /// (benches/ROUND12.md §1). NULL usernames (email-only signups) are + /// filtered app-side, exactly like the wide flow's post-limit filter. + pub async fn search_sharee_usernames( + &self, + query: &str, + limit: i64, + ) -> Result, DomainError> { + let names = self + .user_storage + .search_usernames(query, limit, false) + .await?; + Ok(names.into_iter().flatten().collect()) + } + // ======================================================================== // Admin User Management Methods // ======================================================================== @@ -2607,6 +2635,15 @@ impl AuthApplicationService { if let Some(lc) = &self.user_lifecycle { lc.dispatch_login(&existing_user).await; } + // Decide BEFORE mutating: the row just fetched already + // carries the stored avatar + verification stamp, so the + // repeat-login common case (same IdP picture, already + // verified) skips the DB entirely — the old shape rewrote + // all 17 columns per login, and even a guarded UPDATE + // would ship the avatar over the wire just to compare it + // (benches/ROUND12.md §3b). + let needs_profile_sync = existing_user.email_verified_at().is_none() + || existing_user.image() != claims.picture.as_deref(); existing_user.register_login(); existing_user.set_image(claims.picture.clone()); // PR 23: retroactive email verification for OIDC users @@ -2615,7 +2652,16 @@ impl AuthApplicationService { // any user reaching this branch has a verified email // by the IdP's word; stamping is safe and idempotent. existing_user.mark_email_verified(); - self.user_storage.update_user(existing_user.clone()).await?; + // Narrow guarded sync instead of the 17-column row rewrite: + // persists the IdP avatar + the verification stamp only + // when either actually changed; `last_login_at` is stamped + // by `create_session` at the end of this flow + // (benches/ROUND12.md §3). + if needs_profile_sync { + self.user_storage + .sync_oidc_login_profile(existing_user.id(), claims.picture.as_deref()) + .await?; + } existing_user } Err(_) => { diff --git a/src/application/services/storage_usage_service.rs b/src/application/services/storage_usage_service.rs index 0e598ee8..24fadc87 100644 --- a/src/application/services/storage_usage_service.rs +++ b/src/application/services/storage_usage_service.rs @@ -16,6 +16,10 @@ use uuid::Uuid; * Storage usage is calculated directly from the `storage.files` table * by summing file sizes for each user (using the `user_id` column). */ +/// Fused quota-gate row: `(user_used, user_quota, drive_used, drive_quota, +/// drive_found)` — see [`StorageUsageService::check_upload_quotas`]. +type QuotaPairRow = (i64, i64, Option, Option, bool); + pub struct StorageUsageService { pool: Arc, user_repository: Arc, @@ -372,6 +376,18 @@ impl StorageUsageService { // first, so this branch fires only on a deleted-drive race. return Err(DomainError::not_found("Drive", drive_id.to_string())); }; + Self::eval_drive_cap(used, quota, additional_bytes) + } + + /// Drive-cap verdict over already-fetched counters. Shared by + /// [`Self::check_drive_quota`] and the fused + /// [`Self::check_upload_quotas`] pair so both produce byte-identical + /// errors. + fn eval_drive_cap( + used: i64, + quota: Option, + additional_bytes: u64, + ) -> Result<(), DomainError> { let Some(quota) = quota else { return Ok(()); // unlimited }; @@ -391,6 +407,123 @@ impl StorageUsageService { Ok(()) } + /// User-envelope verdict over already-fetched counters. Shared by + /// `check_storage_quota` and the fused [`Self::check_upload_quotas`] + /// pair so both produce byte-identical errors. + fn eval_user_envelope(used: i64, quota: i64, additional_bytes: u64) -> Result<(), DomainError> { + // Quota of 0 means unlimited + if quota <= 0 { + return Ok(()); + } + + let additional = additional_bytes as i64; + + // Case 1: the single file alone exceeds the entire quota + if additional > quota { + let quota_fmt = format_bytes(quota); + let file_fmt = format_bytes(additional); + return Err(DomainError::quota_exceeded(format!( + "File size ({}) exceeds your total storage quota ({})", + file_fmt, quota_fmt + ))); + } + + // Case 2: the upload would push usage over the quota + if used + additional > quota { + let available = (quota - used).max(0); + let avail_fmt = format_bytes(available); + let file_fmt = format_bytes(additional); + return Err(DomainError::quota_exceeded(format!( + "Not enough storage space. File size: {}, available: {}", + file_fmt, avail_fmt + ))); + } + + Ok(()) + } + + /// Fused pre-upload gate: user envelope + drive cap in ONE round-trip. + /// + /// Upload entry points used to run `check_storage_quota` then + /// `check_drive_quota` as two serial point reads — and the NC chunked + /// PUT pays that pair on EVERY chunk. One `LEFT JOIN` row carries both + /// counter pairs; verdict precedence (user envelope first, then drive + /// existence, then drive cap) and every error shape are identical to + /// the two-call sequence (benches/ROUND12.md §6, 1.81x). + /// + /// Row shape shared with [`Self::check_upload_quotas_by_folder`]: + /// `(user_used, user_quota, drive_used, drive_quota, drive_found)`. + pub async fn check_upload_quotas( + &self, + user_id: Uuid, + drive_id: Uuid, + additional_bytes: u64, + ) -> Result<(), DomainError> { + let row: Option = sqlx::query_as( + r#" + SELECT u.storage_used_bytes, u.storage_quota_bytes, + d.used_bytes, d.quota_bytes, (d.id IS NOT NULL) + FROM auth.users u + LEFT JOIN storage.drives d ON d.id = $2 + WHERE u.id = $1 + "#, + ) + .bind(user_id) + .bind(drive_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::internal_error("StorageUsage", format!("upload quota lookup: {e}")) + })?; + + let Some((uused, uquota, dused, dquota, drive_found)) = row else { + return Err(DomainError::not_found("User", user_id.to_string())); + }; + Self::eval_user_envelope(uused, uquota, additional_bytes)?; + if !drive_found { + return Err(DomainError::not_found("Drive", drive_id.to_string())); + } + Self::eval_drive_cap(dused.unwrap_or(0), dquota, additional_bytes) + } + + /// [`Self::check_upload_quotas`] with the drive resolved from a parent + /// folder id — for the REST upload paths, which hold `folder_id`. + /// A missing folder (or a folder whose drive vanished mid-race) maps to + /// `not_found("Folder")`, exactly like `check_drive_quota_by_folder`. + pub async fn check_upload_quotas_by_folder( + &self, + user_id: Uuid, + folder_id: Uuid, + additional_bytes: u64, + ) -> Result<(), DomainError> { + let row: Option = sqlx::query_as( + r#" + SELECT u.storage_used_bytes, u.storage_quota_bytes, + d.used_bytes, d.quota_bytes, (d.id IS NOT NULL) + FROM auth.users u + LEFT JOIN storage.folders f ON f.id = $2 + LEFT JOIN storage.drives d ON d.id = f.drive_id + WHERE u.id = $1 + "#, + ) + .bind(user_id) + .bind(folder_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::internal_error("StorageUsage", format!("upload quota lookup: {e}")) + })?; + + let Some((uused, uquota, dused, dquota, drive_found)) = row else { + return Err(DomainError::not_found("User", user_id.to_string())); + }; + Self::eval_user_envelope(uused, uquota, additional_bytes)?; + if !drive_found { + return Err(DomainError::not_found("Folder", folder_id.to_string())); + } + Self::eval_drive_cap(dused.unwrap_or(0), dquota, additional_bytes) + } + /// Same as [`Self::check_drive_quota`] but resolves the drive id /// from a parent folder id. Mirrors /// [`Self::add_drive_storage_usage_delta_by_folder`] so the upload @@ -563,36 +696,7 @@ impl StorageUsagePort for StorageUsageService { // Narrow 2-column read — the full user row carries the up-to-512 KiB // avatar `image` column, paid on every upload quota check otherwise. let (used, quota) = self.user_repository.get_storage_usage(user_id).await?; - - // Quota of 0 means unlimited - if quota <= 0 { - return Ok(()); - } - - let additional = additional_bytes as i64; - - // Case 1: the single file alone exceeds the entire quota - if additional > quota { - let quota_fmt = format_bytes(quota); - let file_fmt = format_bytes(additional); - return Err(DomainError::quota_exceeded(format!( - "File size ({}) exceeds your total storage quota ({})", - file_fmt, quota_fmt - ))); - } - - // Case 2: the upload would push usage over the quota - if used + additional > quota { - let available = (quota - used).max(0); - let avail_fmt = format_bytes(available); - let file_fmt = format_bytes(additional); - return Err(DomainError::quota_exceeded(format!( - "Not enough storage space. File size: {}, available: {}", - file_fmt, avail_fmt - ))); - } - - Ok(()) + Self::eval_user_envelope(used, quota, additional_bytes) } async fn get_user_storage_info(&self, user_id: Uuid) -> Result<(i64, i64), DomainError> { diff --git a/src/infrastructure/repositories/pg/session_pg_repository.rs b/src/infrastructure/repositories/pg/session_pg_repository.rs index 65676be6..ed0572e1 100644 --- a/src/infrastructure/repositories/pg/session_pg_repository.rs +++ b/src/infrastructure/repositories/pg/session_pg_repository.rs @@ -327,6 +327,77 @@ impl SessionStoragePort for SessionPgRepository { .map_err(DomainError::from) } + /// Revoke + insert + last-login stamp in ONE transaction — the refresh + /// rotation used to pay two full BEGIN/COMMIT round-trip pairs + /// (`revoke_session` then `create_session`) per token refresh. + async fn rotate_session( + &self, + old_session_id: Uuid, + new_session: Session, + ) -> Result { + let session_clone = new_session.clone(); + with_transaction(&self.pool, "rotate_session", |tx| { + Box::pin(async move { + sqlx::query("UPDATE auth.sessions SET revoked = true WHERE id = $1") + .bind(old_session_id) + .execute(&mut **tx) + .await + .map_err(Self::map_sqlx_error)?; + + sqlx::query( + r#" + INSERT INTO auth.sessions ( + id, user_id, refresh_token, expires_at, + ip_address, user_agent, created_at, revoked, family_id + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9 + ) + "#, + ) + .bind(session_clone.id()) + .bind(session_clone.user_id()) + .bind(session_clone.refresh_token()) + .bind(session_clone.expires_at()) + .bind(session_clone.ip_address()) + .bind(session_clone.user_agent()) + .bind(session_clone.created_at()) + .bind(session_clone.is_revoked()) + .bind(session_clone.family_id()) + .execute(&mut **tx) + .await + .map_err(Self::map_sqlx_error)?; + + sqlx::query( + r#" + UPDATE auth.users + SET last_login_at = NOW(), updated_at = NOW() + WHERE id = $1 + "#, + ) + .bind(session_clone.user_id()) + .execute(&mut **tx) + .await + .map_err(|e| { + tracing::warn!( + "Could not update last_login_at for user {}: {}", + session_clone.user_id(), + e + ); + SessionRepositoryError::DatabaseError(format!( + "Session rotated but could not update last_login_at: {}", + e + )) + })?; + + Ok(session_clone) + }) as BoxFuture<'_, SessionRepositoryResult> + }) + .await + .map_err(DomainError::from)?; + + Ok(new_session) + } + async fn get_session_by_refresh_token( &self, refresh_token: &str, diff --git a/src/infrastructure/repositories/pg/user_pg_repository.rs b/src/infrastructure/repositories/pg/user_pg_repository.rs index 7847fc65..90d05a5d 100644 --- a/src/infrastructure/repositories/pg/user_pg_repository.rs +++ b/src/infrastructure/repositories/pg/user_pg_repository.rs @@ -1067,6 +1067,81 @@ impl UserStoragePort for UserPgRepository { .map_err(DomainError::from) } + async fn search_usernames( + &self, + query: &str, + limit: i64, + include_external: bool, + ) -> Result>, DomainError> { + // Same predicate / order / limit as `search_users`, username-only + // projection — the sharee autocomplete path reads nothing else, and + // the wide row drags the avatar `image` per matched user. + let pattern = format!("%{}%", query); + let rows = sqlx::query( + r#" + SELECT username + FROM auth.users + WHERE (username ILIKE $1 OR email ILIKE $1) + AND ($3 OR is_external = FALSE) + ORDER BY username + LIMIT $2 + "#, + ) + .bind(&pattern) + .bind(limit) + .bind(include_external) + .fetch_all(&*self.pool) + .await + .map_err(Self::map_sqlx_error) + .map_err(DomainError::from)?; + Ok(rows.into_iter().map(|row| row.get("username")).collect()) + } + + async fn mark_email_verified(&self, user_id: Uuid) -> Result<(), DomainError> { + // SQL twin of `User::mark_email_verified` — stamps once, keeps the + // first timestamp, and touches only the two columns involved. + sqlx::query( + r#" + UPDATE auth.users + SET email_verified_at = NOW(), updated_at = NOW() + WHERE id = $1 AND email_verified_at IS NULL + "#, + ) + .bind(user_id) + .execute(&*self.pool) + .await + .map_err(Self::map_sqlx_error) + .map_err(DomainError::from)?; + Ok(()) + } + + async fn sync_oidc_login_profile( + &self, + user_id: Uuid, + image: Option<&str>, + ) -> Result<(), DomainError> { + // `IS DISTINCT FROM` guard (the `update_storage_usage` pattern): the + // common repeat-login case — same IdP avatar, already verified — + // writes nothing at all (no dead tuple, no WAL). + sqlx::query( + r#" + UPDATE auth.users + SET image = $2, + email_verified_at = COALESCE(email_verified_at, NOW()), + updated_at = NOW() + WHERE id = $1 + AND (image IS DISTINCT FROM $2 OR email_verified_at IS NULL) + "#, + ) + .bind(user_id) + .bind(image) + .execute(&*self.pool) + .await + .map_err(Self::map_sqlx_error) + .map_err(DomainError::from)?; + Ok(()) + } + async fn list_users_by_role(&self, role: &str) -> Result, DomainError> { UserRepository::list_users_by_role(self, role) .await diff --git a/src/infrastructure/services/cached_blob_backend.rs b/src/infrastructure/services/cached_blob_backend.rs index 69af358b..36bec900 100644 --- a/src/infrastructure/services/cached_blob_backend.rs +++ b/src/infrastructure/services/cached_blob_backend.rs @@ -10,12 +10,9 @@ use std::path::{Path, PathBuf}; use std::pin::Pin; use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; use bytes::Bytes; use dashmap::DashMap; -use lru::LruCache; -use std::num::NonZeroUsize; use tokio::fs; use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; use tokio::sync::Mutex; @@ -52,12 +49,22 @@ struct CacheEntry { /// A `BlobStorageBackend` decorator that adds an LRU disk cache in front of /// a remote backend. +/// +/// The index is a `moka::sync::Cache` with a byte weigher: cached reads +/// probe it lock-free (sharded, striped recency) where the previous +/// `tokio::sync::Mutex` serialized EVERY cached chunk read on one +/// global async mutex — negative scaling under concurrent readers +/// (benches/ROUND12.md §B: 2.08 → 1.07 Mops/s going 1 → 2 readers on the +/// mutex; moka holds 1.7-2.4). moka also owns the byte budget: eviction by +/// weighted size replaces the manual `current_size` counter + +/// `collect_evictions` sweep, and the eviction listener unlinks the evicted +/// `.blob` (only on size-eviction — a Replaced entry shares its file with +/// the replacement, and Explicit invalidations unlink at their call site). pub struct CachedBlobBackend { inner: Arc, cache_dir: PathBuf, max_cache_bytes: u64, - index: Arc>>, - current_size: Arc, + index: moka::sync::Cache, /// Per-hash single-flight gates for cache misses. K concurrent cold /// readers of one blob (e.g. a video player's parallel Range probes) /// used to each download the FULL blob from the remote backend — and @@ -67,26 +74,38 @@ pub struct CachedBlobBackend { inflight: Arc>>>, } +fn cached_path_in(cache_dir: &Path, hash: &str) -> PathBuf { + let prefix = &hash[..2.min(hash.len())]; + cache_dir.join(prefix).join(format!("{hash}.blob")) +} + impl CachedBlobBackend { /// Create a new cached backend wrapping `inner`. pub fn new(inner: Arc, config: &BlobCacheConfig) -> Self { + let listener_dir = config.cache_dir.clone(); Self { inner, cache_dir: config.cache_dir.clone(), max_cache_bytes: config.max_cache_bytes, - // Capacity is essentially unbounded — eviction is by byte budget, not count. - index: Arc::new(Mutex::new(LruCache::new( - NonZeroUsize::new(1_000_000).unwrap(), - ))), - current_size: Arc::new(AtomicU64::new(0)), + index: moka::sync::Cache::builder() + .weigher(|_k: &String, e: &CacheEntry| e.size.clamp(1, u32::MAX as u64) as u32) + .max_capacity(config.max_cache_bytes) + .eviction_listener(move |hash: Arc, _entry, cause| { + // Size-evicted blobs lose their on-disk file here (the + // sweep `collect_evictions` used to do). A quick unlink + // on the inserting task's thread, off the hot get path. + if cause == moka::notification::RemovalCause::Size { + let _ = std::fs::remove_file(cached_path_in(&listener_dir, &hash)); + } + }) + .build(), inflight: Arc::new(DashMap::new()), } } /// Path where a blob is cached locally. fn cached_path(&self, hash: &str) -> PathBuf { - let prefix = &hash[..2.min(hash.len())]; - self.cache_dir.join(prefix).join(format!("{hash}.blob")) + cached_path_in(&self.cache_dir, hash) } } @@ -97,7 +116,6 @@ impl BlobStorageBackend for CachedBlobBackend { let inner = self.inner.clone(); let cache_dir = self.cache_dir.clone(); let index = self.index.clone(); - let current_size = self.current_size.clone(); Box::pin(async move { inner.initialize().await?; @@ -130,14 +148,13 @@ impl BlobStorageBackend for CachedBlobBackend { } } } - // Bulk-insert the rebuilt index under a single brief lock. - { - let mut idx = index.lock().await; - for (stem, size) in entries { - idx.put(stem, CacheEntry { size }); - } + // Rebuild the index; if the restored set exceeds the byte + // budget, moka trims it (and the eviction listener unlinks the + // trimmed files) — the old index carried the excess until the + // next insert. + for (stem, size) in entries { + index.insert(stem, CacheEntry { size }); } - current_size.store(total_bytes, Ordering::Relaxed); tracing::info!( "Blob cache initialized: {} bytes in cache at {}", total_bytes, @@ -152,22 +169,28 @@ impl BlobStorageBackend for CachedBlobBackend { hash: &str, source_path: &Path, ) -> Pin> + Send + '_>> { - let inner = self.inner.clone(); let hash = hash.to_string(); let source = source_path.to_path_buf(); - let self_ref = CachedRef { - cache_dir: self.cache_dir.clone(), - max_cache_bytes: self.max_cache_bytes, - index: self.index.clone(), - current_size: self.current_size.clone(), - inflight: self.inflight.clone(), - }; Box::pin(async move { - // Write to inner backend - let bytes = inner.put_blob(&hash, &source).await?; - // Also cache locally (best-effort) - let _ = self_ref.insert_into_cache_static(&hash, &source).await; - Ok(bytes) + // Cache FIRST: every inner backend consumes the source file + // (local renames it, S3/Azure delete it after upload), so the + // old populate-after-put ordering failed 100% of the time and + // the first read after a whole-file put paid a full remote + // re-download (the ROUND11 deferred correctness note; fix + // gated in benches/ROUND12.md §B). + let cached = self.insert_into_cache(&hash, &source).await.is_ok(); + match self.inner.put_blob(&hash, &source).await { + Ok(bytes) => Ok(bytes), + Err(e) => { + // Never serve a blob the backend rejected: drop the + // just-inserted cache entry + file. + if cached { + self.index.invalidate(&hash); + let _ = fs::remove_file(self.cached_path(&hash)).await; + } + Err(e) + } + } }) } @@ -176,18 +199,10 @@ impl BlobStorageBackend for CachedBlobBackend { hash: &str, data: Bytes, ) -> Pin> + Send + '_>> { - let inner = self.inner.clone(); let hash = hash.to_string(); - let self_ref = CachedRef { - cache_dir: self.cache_dir.clone(), - max_cache_bytes: self.max_cache_bytes, - index: self.index.clone(), - current_size: self.current_size.clone(), - inflight: self.inflight.clone(), - }; Box::pin(async move { - let size = inner.put_blob_from_bytes(&hash, data.clone()).await?; - self_ref.cache_bytes_write_through(hash, &data).await; + let size = self.inner.put_blob_from_bytes(&hash, data.clone()).await?; + self.cache_bytes_write_through(hash, &data).await; Ok(size) }) } @@ -202,20 +217,13 @@ impl BlobStorageBackend for CachedBlobBackend { hash: &str, data: Bytes, ) -> Pin> + Send + '_>> { - let inner = self.inner.clone(); let hash = hash.to_string(); - let self_ref = CachedRef { - cache_dir: self.cache_dir.clone(), - max_cache_bytes: self.max_cache_bytes, - index: self.index.clone(), - current_size: self.current_size.clone(), - inflight: self.inflight.clone(), - }; Box::pin(async move { - let size = inner + let size = self + .inner .put_blob_from_bytes_unsynced(&hash, data.clone()) .await?; - self_ref.cache_bytes_write_through(hash, &data).await; + self.cache_bytes_write_through(hash, &data).await; Ok(size) }) } @@ -235,40 +243,24 @@ impl BlobStorageBackend for CachedBlobBackend { ) -> Pin> + Send + '_>> { let hash = hash.to_string(); - let cached = self.cached_path(&hash); - let index = self.index.clone(); - let inner = self.inner.clone(); - let cache_dir = self.cache_dir.clone(); - let max_cache_bytes = self.max_cache_bytes; - let current_size = self.current_size.clone(); - let inflight = self.inflight.clone(); Box::pin(async move { - // Check cache presence (and bump LRU recency) under a brief lock, - // then release it BEFORE touching the filesystem so concurrent - // readers don't serialize behind a single open() syscall. - if index.lock().await.get(&hash).is_some() { + // Lock-free cache probe (bumps moka recency) — the old shape + // took the one global async mutex here on EVERY cached chunk + // read, and cloned `cache_dir` per hit for a miss-only struct. + if self.index.get(&hash).is_some() { + let cached = self.cached_path(&hash); if let Ok(file) = fs::File::open(&cached).await { let stream: BlobStream = Box::pin(ReaderStream::with_capacity(file, STREAM_CHUNK_SIZE)); return Ok(stream); } // Cache entry stale (file vanished) — drop it from the index. - if let Some(entry) = index.lock().await.pop(&hash) { - current_size.fetch_sub(entry.size, Ordering::Relaxed); - } + self.index.invalidate(&hash); } // Cache miss — fetch from inner (single-flight), spool to cache - let self_ref = CachedRef { - cache_dir, - max_cache_bytes, - index: index.clone(), - current_size: current_size.clone(), - inflight, - }; - let dest = self_ref - .fetch_and_cache_singleflight(&hash, &*inner, &cached) - .await?; + let cached = self.cached_path(&hash); + let dest = self.fetch_and_cache_singleflight(&hash, &cached).await?; let file = fs::File::open(&dest).await.map_err(|e| { DomainError::internal_error("BlobCache", format!("re-open cached: {e}")) })?; @@ -285,18 +277,11 @@ impl BlobStorageBackend for CachedBlobBackend { ) -> Pin> + Send + '_>> { let hash = hash.to_string(); - let cached = self.cached_path(&hash); - let index = self.index.clone(); - let inner = self.inner.clone(); - let cache_dir = self.cache_dir.clone(); - let max_cache_bytes = self.max_cache_bytes; - let current_size = self.current_size.clone(); - let inflight = self.inflight.clone(); Box::pin(async move { - // Check cache presence (and bump LRU recency) under a brief lock, - // then release it BEFORE the open()/seek() syscalls so concurrent - // range readers don't serialize behind the index mutex. - if index.lock().await.get(&hash).is_some() { + // Lock-free cache probe (bumps moka recency); the filesystem is + // only touched after the probe, as before. + if self.index.get(&hash).is_some() { + let cached = self.cached_path(&hash); if let Ok(mut file) = fs::File::open(&cached).await { file.seek(std::io::SeekFrom::Start(start)) .await @@ -309,24 +294,14 @@ impl BlobStorageBackend for CachedBlobBackend { Box::pin(ReaderStream::with_capacity(limited, STREAM_CHUNK_SIZE)); return Ok(stream); } - if let Some(entry) = index.lock().await.pop(&hash) { - current_size.fetch_sub(entry.size, Ordering::Relaxed); - } + self.index.invalidate(&hash); } // Cache miss — fetch full blob into cache (single-flight: a // player's parallel cold Range probes coalesce onto ONE remote // download), then serve the range locally. - let self_ref = CachedRef { - cache_dir, - max_cache_bytes, - index: index.clone(), - current_size: current_size.clone(), - inflight, - }; - let dest = self_ref - .fetch_and_cache_singleflight(&hash, &*inner, &cached) - .await?; + let cached = self.cached_path(&hash); + let dest = self.fetch_and_cache_singleflight(&hash, &cached).await?; let mut file = fs::File::open(&dest) .await .map_err(|e| DomainError::internal_error("BlobCache", format!("re-open: {e}")))?; @@ -345,19 +320,13 @@ impl BlobStorageBackend for CachedBlobBackend { &self, hash: &str, ) -> Pin> + Send + '_>> { - let inner = self.inner.clone(); let hash = hash.to_string(); - let cached = self.cached_path(&hash); - let index = self.index.clone(); - let current_size = self.current_size.clone(); Box::pin(async move { - inner.delete_blob(&hash).await?; - // Remove from cache — drop the index lock before the unlink() - // syscall so deletes don't serialize concurrent cache lookups. - if let Some(entry) = index.lock().await.pop(&hash) { - current_size.fetch_sub(entry.size, Ordering::Relaxed); - } - let _ = fs::remove_file(&cached).await; + self.inner.delete_blob(&hash).await?; + // Explicit invalidation unlinks here (the eviction listener + // only unlinks size-evictions). + self.index.invalidate(&hash); + let _ = fs::remove_file(self.cached_path(&hash)).await; Ok(()) }) } @@ -366,18 +335,13 @@ impl BlobStorageBackend for CachedBlobBackend { &self, hash: &str, ) -> Pin> + Send + '_>> { - let inner = self.inner.clone(); let hash = hash.to_string(); - let index = self.index.clone(); Box::pin(async move { - // Check cache first (fast) - { - let mut idx = index.lock().await; - if idx.get(&hash).is_some() { - return Ok(true); - } + // Check cache first (fast, lock-free) + if self.index.get(&hash).is_some() { + return Ok(true); } - inner.blob_exists(&hash).await + self.inner.blob_exists(&hash).await }) } @@ -385,23 +349,17 @@ impl BlobStorageBackend for CachedBlobBackend { &self, hash: &str, ) -> Pin> + Send + '_>> { - let inner = self.inner.clone(); let hash = hash.to_string(); - let index = self.index.clone(); - let cached = self.cached_path(&hash); Box::pin(async move { - // Check cache - { - let mut idx = index.lock().await; - if let Some(entry) = idx.get(&hash) { - return Ok(entry.size); - } + // Check cache (lock-free) + if let Some(entry) = self.index.get(&hash) { + return Ok(entry.size); } // Fallback to cached file on disk (in case index was lost) - if let Ok(meta) = fs::metadata(&cached).await { + if let Ok(meta) = fs::metadata(self.cached_path(&hash)).await { return Ok(meta.len()); } - inner.blob_size(&hash).await + self.inner.blob_size(&hash).await }) } @@ -410,19 +368,18 @@ impl BlobStorageBackend for CachedBlobBackend { ) -> Pin< Box> + Send + '_>, > { - let inner = self.inner.clone(); - let cache_dir = self.cache_dir.clone(); - let current_size = self.current_size.clone(); - let max_bytes = self.max_cache_bytes; Box::pin(async move { - let mut status = inner.health_check().await?; - let used = current_size.load(Ordering::Relaxed); + let mut status = self.inner.health_check().await?; + // Flush moka's pending maintenance so the reported byte count + // is current (rare admin path — the cost is fine here). + self.index.run_pending_tasks(); + let used = self.index.weighted_size(); status.message = format!( "{} | Cache: {}/{} bytes used at {}", status.message, used, - max_bytes, - cache_dir.display() + self.max_cache_bytes, + self.cache_dir.display() ); status.backend_type = format!("cached({})", status.backend_type); Ok(status) @@ -446,27 +403,13 @@ impl BlobStorageBackend for CachedBlobBackend { } } -// ── Helper struct for owned references in async closures ─────────── - -/// Cloneable set of cache internals — avoids borrow issues in boxed futures. -struct CachedRef { - cache_dir: PathBuf, - max_cache_bytes: u64, - index: Arc>>, - current_size: Arc, - inflight: Arc>>>, -} - -impl CachedRef { - fn cached_path(&self, hash: &str) -> PathBuf { - let prefix = &hash[..2.min(hash.len())]; - self.cache_dir.join(prefix).join(format!("{hash}.blob")) - } +// ── Cache internals (miss path + population) ─────────────────────── +impl CachedBlobBackend { /// Best-effort write-through cache population shared by both blob-bytes - /// PUT paths. Deliberately no eviction sweep here — the byte budget is - /// enforced on read-miss inserts (`insert_into_cache_static`), matching - /// the historical write-path behavior. + /// PUT paths. moka enforces the byte budget on every insert (the old + /// index deliberately skipped the eviction sweep on this path, letting + /// write bursts overshoot the budget until the next read-miss insert). async fn cache_bytes_write_through(&self, hash: String, data: &Bytes) { let dest = self.cached_path(&hash); if let Some(parent) = dest.parent() { @@ -474,22 +417,17 @@ impl CachedRef { } let _ = fs::write(&dest, data).await; let data_len = data.len() as u64; - let mut idx = self.index.lock().await; - if let Some(old) = idx.put(hash, CacheEntry { size: data_len }) { - self.current_size.fetch_sub(old.size, Ordering::Relaxed); - } - self.current_size.fetch_add(data_len, Ordering::Relaxed); + self.index.insert(hash, CacheEntry { size: data_len }); } - /// Single-flight wrapper around [`Self::fetch_and_cache_static`]: the - /// first caller for a hash becomes the leader and downloads; concurrent + /// Single-flight wrapper around [`Self::fetch_and_cache`]: the first + /// caller for a hash becomes the leader and downloads; concurrent /// callers queue on the per-hash gate, then re-check the cache and serve /// the leader's file without touching the remote backend. Errors are not /// cached — the gate entry is dropped, so the next caller retries. async fn fetch_and_cache_singleflight( &self, hash: &str, - inner: &dyn BlobStorageBackend, cached: &Path, ) -> Result { let gate = self @@ -501,42 +439,18 @@ impl CachedRef { // Re-check under the gate: if we queued behind the leader, the blob // is on disk now and this turns into a local open. - if self.index.lock().await.get(hash).is_some() && fs::metadata(cached).await.is_ok() { + if self.index.get(hash).is_some() && fs::metadata(cached).await.is_ok() { return Ok(cached.to_path_buf()); } - let result = self.fetch_and_cache_static(hash, inner).await; + let result = self.fetch_and_cache(hash).await; // Drop the gate whether we succeeded or failed; a late-arriving // caller after an error creates a fresh gate and retries the fetch. self.inflight.remove(hash); result } - /// Pop LRU entries until the cache is back within its byte budget, - /// returning the on-disk paths of the evicted blobs. - /// - /// Only the in-memory index is touched here (atomic counter + LRU map); - /// the caller MUST unlink the returned paths AFTER releasing the index - /// lock so the `remove_file` syscalls never run while the mutex is held. - fn collect_evictions(&self, idx: &mut LruCache) -> Vec { - let mut victims = Vec::new(); - while self.current_size.load(Ordering::Relaxed) > self.max_cache_bytes { - if let Some((evicted_hash, evicted_entry)) = idx.pop_lru() { - self.current_size - .fetch_sub(evicted_entry.size, Ordering::Relaxed); - victims.push(self.cached_path(&evicted_hash)); - } else { - break; - } - } - victims - } - - async fn insert_into_cache_static( - &self, - hash: &str, - source_path: &Path, - ) -> Result<(), DomainError> { + async fn insert_into_cache(&self, hash: &str, source_path: &Path) -> Result<(), DomainError> { let dest = self.cached_path(hash); if let Some(parent) = dest.parent() { fs::create_dir_all(parent).await.map_err(|e| { @@ -553,29 +467,14 @@ impl CachedRef { DomainError::internal_error("BlobCache", format!("cache copy failed: {e}")) })?; - // Update the index and pick eviction victims under a single brief - // lock, then unlink the evicted files AFTER releasing it — file - // removal must not run while the index mutex is held. - let to_evict = { - let mut idx = self.index.lock().await; - if let Some(old) = idx.put(hash.to_string(), CacheEntry { size }) { - self.current_size.fetch_sub(old.size, Ordering::Relaxed); - } - self.current_size.fetch_add(size, Ordering::Relaxed); - self.collect_evictions(&mut idx) - }; - for path in to_evict { - let _ = fs::remove_file(&path).await; - } + // moka enforces the byte budget; size-evicted victims are unlinked + // by the eviction listener. + self.index.insert(hash.to_string(), CacheEntry { size }); Ok(()) } - async fn fetch_and_cache_static( - &self, - hash: &str, - inner: &dyn BlobStorageBackend, - ) -> Result { - let stream = inner.get_blob_stream(hash).await?; + async fn fetch_and_cache(&self, hash: &str) -> Result { + let stream = self.inner.get_blob_stream(hash).await?; let dest = self.cached_path(hash); if let Some(parent) = dest.parent() { @@ -630,17 +529,10 @@ impl CachedRef { )); } - let to_evict = { - let mut idx = self.index.lock().await; - if let Some(old) = idx.put(hash.to_string(), CacheEntry { size: total }) { - self.current_size.fetch_sub(old.size, Ordering::Relaxed); - } - self.current_size.fetch_add(total, Ordering::Relaxed); - self.collect_evictions(&mut idx) - }; - for path in to_evict { - let _ = fs::remove_file(&path).await; - } + // moka enforces the byte budget; size-evicted victims are unlinked + // by the eviction listener. + self.index + .insert(hash.to_string(), CacheEntry { size: total }); Ok(dest) } diff --git a/src/infrastructure/services/chunked_upload_service.rs b/src/infrastructure/services/chunked_upload_service.rs index 0ab49019..05e2b8f5 100644 --- a/src/infrastructure/services/chunked_upload_service.rs +++ b/src/infrastructure/services/chunked_upload_service.rs @@ -514,6 +514,17 @@ impl ChunkedUploadService { Ok(()) } + /// Alloc-free owner compare for the per-chunk hot path: the caller's + /// `Uuid` is stack-encoded (hyphenated, the format sessions store) — + /// `prepare_chunk`/`commit_chunk` used to pay a `Uuid::to_string` each + /// plus a dedicated `verify_session_owner` map lookup per chunk + /// (benches/ROUND12.md §M5, 1.28x / −2 allocs per chunk). + #[inline] + fn owner_matches(session_user_id: &str, user_id: Uuid) -> bool { + let mut buf = [0u8; 36]; + session_user_id == user_id.hyphenated().encode_lower(&mut buf) as &str + } + /// Create a new upload session (persists `session.json` + empty `progress.bin`) async fn create_session_inner( &self, @@ -617,9 +628,8 @@ impl ChunkedUploadService { user_id: Uuid, chunk_index: usize, ) -> Result<(PathBuf, usize), DomainError> { - self.verify_session_owner(upload_id, &user_id.to_string()) - .map_err(|e| DomainError::new(ErrorKind::NotFound, "ChunkedUpload", e))?; - + // Single map lookup: the owner gate rides the same guard (same + // anti-enum not-found for unknown session and foreign session). let session = self.sessions.get(upload_id).ok_or_else(|| { DomainError::new( ErrorKind::NotFound, @@ -627,6 +637,13 @@ impl ChunkedUploadService { format!("Upload session not found: {}", upload_id), ) })?; + if !Self::owner_matches(&session.user_id, user_id) { + return Err(DomainError::new( + ErrorKind::NotFound, + "ChunkedUpload", + format!("Upload session not found: {}", upload_id), + )); + } if chunk_index >= session.chunks.len() { return Err(DomainError::new( @@ -678,20 +695,23 @@ impl ChunkedUploadService { computed_checksum: Option, expected_checksum: Option, ) -> Result { - self.verify_session_owner(upload_id, &user_id.to_string()) - .map_err(|e| DomainError::new(ErrorKind::NotFound, "ChunkedUpload", e))?; - - // Re-fetch chunk metadata under fresh lock — guards against the - // (vanishingly unlikely) case of a session expiry / cancellation - // racing with the write. + // Owner gate folded into the metadata read below — one lookup + // instead of two, same anti-enum not-found semantics. let (chunk_path, expected_size, persist_path) = { let session = self.sessions.get(upload_id).ok_or_else(|| { DomainError::new( ErrorKind::NotFound, "ChunkedUpload", - "Session disappeared".to_string(), + format!("Upload session not found: {}", upload_id), ) })?; + if !Self::owner_matches(&session.user_id, user_id) { + return Err(DomainError::new( + ErrorKind::NotFound, + "ChunkedUpload", + format!("Upload session not found: {}", upload_id), + )); + } if chunk_index >= session.chunks.len() { return Err(DomainError::new( ErrorKind::InvalidInput, diff --git a/src/infrastructure/services/media_metadata_service.rs b/src/infrastructure/services/media_metadata_service.rs index b6487f34..3d165711 100644 --- a/src/infrastructure/services/media_metadata_service.rs +++ b/src/infrastructure/services/media_metadata_service.rs @@ -96,22 +96,33 @@ impl MediaMetadataService { } if Self::is_image_file(mime_type) { + // ONE disk read: kamadak needs the full buffer anyway, and + // nom-exif 3.6+ parses from in-RAM bytes zero-copy + // (`MediaSource::from_memory` over the same allocation). This + // path used to re-open the file 1-2 more times — nom-exif's + // `read_exif(path)` plus a `read_track(path)` fallback for + // date-less images (2-3 opens per image, benches/ROUND12.md §M4: + // 1.44x warm geomean, 2-3x cold-cache). + let buf = std::fs::read(path).ok()?; // Rich EXIF (GPS / camera / orientation / dimensions + naive date) // from the proven kamadak extractor. - let kamadak = std::fs::read(path) - .ok() - .and_then(|b| ExifService::extract(&b)); + let kamadak = ExifService::extract(&buf); // nom-exif complements kamadak: a timezone-correct capture date and, // crucially, the date + GPS for files kamadak rejects outright // ("Unexpected next IFD"), where `kamadak` is None and the GPS would // otherwise be lost. See `merge_image_metadata`. - merge_image_metadata(kamadak, read_nom_exif(path)) + let bytes = bytes::Bytes::from(buf); + merge_image_metadata(kamadak, read_nom_exif_from_bytes(&bytes)) } else if Self::is_video_file(mime_type) { // Videos carry no EXIF — pull the container creation time only. - read_nom_exif(path).captured_at.map(|dt| ExifMetadata { - captured_at: Some(dt), - ..Default::default() - }) + // Single open + header sniff; the old shape opened twice (a + // doomed `read_exif` sniff, then `read_track`). + read_nom_exif_video(path) + .captured_at + .map(|dt| ExifMetadata { + captured_at: Some(dt), + ..Default::default() + }) } else { None } @@ -375,34 +386,49 @@ struct NomExif { /// carries `OffsetTimeOriginal` (or a tz-aware container time); otherwise the /// naive wall-clock is interpreted as UTC. Either way it is converted to a true /// UTC instant. GPS is returned as signed decimal degrees. -fn read_nom_exif(path: &Path) -> NomExif { - use nom_exif::{EntryValue, ExifTag, TrackInfoTag, read_exif, read_track}; +fn nom_to_utc(ev: &nom_exif::EntryValue) -> Option> { + let edt = ev.as_datetime()?; + let utc0 = FixedOffset::east_opt(0)?; + Some(edt.or_offset(utc0).with_timezone(&Utc)) +} - // Captures nothing → `Copy`, so it can be reused across the calls below. - let to_utc = |ev: &EntryValue| -> Option> { - let edt = ev.as_datetime()?; - let utc0 = FixedOffset::east_opt(0)?; - Some(edt.or_offset(utc0).with_timezone(&Utc)) - }; +fn nom_fill_from_exif(exif: &nom_exif::Exif, out: &mut NomExif) { + use nom_exif::ExifTag; + out.captured_at = exif + .get(ExifTag::DateTimeOriginal) + .and_then(nom_to_utc) + .or_else(|| exif.get(ExifTag::CreateDate).and_then(nom_to_utc)); + if let Some(gps) = exif.gps_info() { + out.latitude = gps.latitude_decimal(); + out.longitude = gps.longitude_decimal(); + } +} + +/// Image arm: nom-exif fed from the buffer the kamadak pass already read — +/// `MediaSource::from_memory` shares the `Bytes` refcount, so this re-parses +/// without touching the disk again (the old shape re-opened the file once, +/// plus a second time for date-less images). The track fallback stays (fed +/// from the same bytes): it covers MIME-mislabeled rows whose actual +/// container is a video — the only case where it ever produced a date. +fn read_nom_exif_from_bytes(bytes: &bytes::Bytes) -> NomExif { + use nom_exif::{MediaParser, MediaSource, TrackInfoTag}; let mut out = NomExif::default(); + let mut parser = MediaParser::new(); // Images: EXIF DateTimeOriginal → DateTimeDigitized (CreateDate), plus GPS. - if let Ok(exif) = read_exif(path) { - out.captured_at = exif - .get(ExifTag::DateTimeOriginal) - .and_then(to_utc) - .or_else(|| exif.get(ExifTag::CreateDate).and_then(to_utc)); - if let Some(gps) = exif.gps_info() { - out.latitude = gps.latitude_decimal(); - out.longitude = gps.longitude_decimal(); - } + if let Ok(ms) = MediaSource::from_memory(bytes.clone()) + && let Ok(iter) = parser.parse_exif(ms) + { + let exif: nom_exif::Exif = iter.into(); + nom_fill_from_exif(&exif, &mut out); } // Videos / audio containers (mov/mp4/mkv): track creation time. if out.captured_at.is_none() - && let Ok(track) = read_track(path) - && let Some(dt) = track.get(TrackInfoTag::CreateDate).and_then(to_utc) + && let Ok(ms) = MediaSource::from_memory(bytes.clone()) + && let Ok(track) = parser.parse_track(ms) + && let Some(dt) = track.get(TrackInfoTag::CreateDate).and_then(nom_to_utc) { out.captured_at = Some(dt); } @@ -410,6 +436,42 @@ fn read_nom_exif(path: &Path) -> NomExif { out } +/// Video arm: ONE open, dispatched on the sniffed container kind. Matches +/// the old `read_exif(path)`-then-`read_track(path)` observable behaviour +/// exactly — a Track container never parsed as EXIF (the old first open was +/// pure waste) and an Image container never parsed as a track, so the +/// two-open sequence always reduced to a single effective parse. +fn read_nom_exif_video(path: &Path) -> NomExif { + use nom_exif::{MediaKind, MediaParser, MediaSource, TrackInfoTag}; + + let mut out = NomExif::default(); + let Ok(file) = std::fs::File::open(path) else { + return out; + }; + let Ok(ms) = MediaSource::seekable(file) else { + return out; + }; + let mut parser = MediaParser::new(); + match ms.kind() { + MediaKind::Image => { + // MIME said video, bytes say image (mislabeled row): same EXIF + // extraction the old `read_exif(path)` performed. + if let Ok(iter) = parser.parse_exif(ms) { + let exif: nom_exif::Exif = iter.into(); + nom_fill_from_exif(&exif, &mut out); + } + } + MediaKind::Track => { + if let Ok(track) = parser.parse_track(ms) + && let Some(dt) = track.get(TrackInfoTag::CreateDate).and_then(nom_to_utc) + { + out.captured_at = Some(dt); + } + } + } + out +} + /// Combine kamadak's rich EXIF with nom-exif's date + GPS. /// /// nom-exif's tz-correct date wins whenever present; its GPS only fills gaps diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index b426b69a..37218ffa 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -864,7 +864,13 @@ impl FileHandler { } tracing::info!("Found {} files", files.len()); - let mut resp = (StatusCode::OK, Json(files)).into_response(); + // Pre-sized serialization — this listing is unbounded (no + // page cap), the axum Json 128-byte seed reallocs ~11 times + // on a big folder (benches/ROUND12.md §M1). + let mut resp = crate::interfaces::api::sized_json::sized_json( + 64 + files.len() * crate::interfaces::api::sized_json::EST_ROW_BYTES, + &files, + ); resp.headers_mut() .insert(header::ETAG, header::HeaderValue::from_str(&etag).unwrap()); resp diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index 171c3295..b6d1e964 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -551,11 +551,15 @@ pub async fn list_folder_resources( }) .collect(); - ( - StatusCode::OK, - Json(FolderResourcesDto::with_cursor(items, next_cursor)), - ) - .into_response() + { + // Pre-sized serialization (benches/ROUND12.md §M1). + let body = FolderResourcesDto::with_cursor(items, next_cursor); + crate::interfaces::api::sized_json::sized_json( + 128 + body.items.len() + * crate::interfaces::api::sized_json::EST_WRAPPED_ROW_BYTES, + &body, + ) + } } Err(e) => AppError::from(e).into_response(), } diff --git a/src/interfaces/api/handlers/photos_handler.rs b/src/interfaces/api/handlers/photos_handler.rs index f1d88b7a..dd436054 100644 --- a/src/interfaces/api/handlers/photos_handler.rs +++ b/src/interfaces/api/handlers/photos_handler.rs @@ -119,7 +119,11 @@ pub async fn list_photos( }) .collect(); - let mut response = Json(&dtos).into_response(); + // Pre-sized serialization (benches/ROUND12.md §M1). + let mut response = crate::interfaces::api::sized_json::sized_json( + 64 + dtos.len() * crate::interfaces::api::sized_json::EST_WRAPPED_ROW_BYTES, + &dtos, + ); { let h = response.headers_mut(); h.insert(header::ETAG, header::HeaderValue::from_str(&etag).unwrap()); diff --git a/src/interfaces/api/handlers/search_handler.rs b/src/interfaces/api/handlers/search_handler.rs index 0514f494..32e4b107 100644 --- a/src/interfaces/api/handlers/search_handler.rs +++ b/src/interfaces/api/handlers/search_handler.rs @@ -84,7 +84,14 @@ impl SearchHandler { results.files.len(), results.folders.len() ); - (StatusCode::OK, Json(&*results)).into_response() + { + // Pre-sized serialization (benches/ROUND12.md §M1). + let rows = results.files.len() + results.folders.len(); + crate::interfaces::api::sized_json::sized_json( + 256 + rows * crate::interfaces::api::sized_json::EST_WRAPPED_ROW_BYTES, + &*results, + ) + } } Err(err) => { error!("Search error: {}", err); @@ -125,7 +132,14 @@ impl SearchHandler { results.files.len(), results.folders.len() ); - (StatusCode::OK, Json(&*results)).into_response() + { + // Pre-sized serialization (benches/ROUND12.md §M1). + let rows = results.files.len() + results.folders.len(); + crate::interfaces::api::sized_json::sized_json( + 256 + rows * crate::interfaces::api::sized_json::EST_WRAPPED_ROW_BYTES, + &*results, + ) + } } Err(err) => { error!("Search error: {}", err); diff --git a/src/interfaces/api/handlers/wopi_handler.rs b/src/interfaces/api/handlers/wopi_handler.rs index 9587136f..f3a2c1f2 100644 --- a/src/interfaces/api/handlers/wopi_handler.rs +++ b/src/interfaces/api/handlers/wopi_handler.rs @@ -83,14 +83,21 @@ pub struct CheckFileInfoResponse { /// structured `audit` line on denial internally, so ops sees the real /// reason without the attacker being able to distinguish "gone" from /// "revoked". +/// Shared id parsing for the WOPI authz paths: a malformed caller sub is a +/// bad token (401), a malformed file id can't exist (404, anti-enum). +fn parse_wopi_ids(caller_sub: &str, file_id: &str) -> Result<(uuid::Uuid, uuid::Uuid), StatusCode> { + let caller_uuid = uuid::Uuid::parse_str(caller_sub).map_err(|_| StatusCode::UNAUTHORIZED)?; + let file_uuid = uuid::Uuid::parse_str(file_id).map_err(|_| StatusCode::NOT_FOUND)?; + Ok((caller_uuid, file_uuid)) +} + async fn require_wopi_perm( authz: &PgAclEngine, caller_sub: &str, file_id: &str, perm: Permission, ) -> Result<(uuid::Uuid, uuid::Uuid), StatusCode> { - let caller_uuid = uuid::Uuid::parse_str(caller_sub).map_err(|_| StatusCode::UNAUTHORIZED)?; - let file_uuid = uuid::Uuid::parse_str(file_id).map_err(|_| StatusCode::NOT_FOUND)?; + let (caller_uuid, file_uuid) = parse_wopi_ids(caller_sub, file_id)?; authz .require(Subject::User(caller_uuid), perm, Resource::File(file_uuid)) .await @@ -118,25 +125,52 @@ async fn check_file_info( // Redemption-time authz: even with a valid token, the caller must // still hold Read on this file. Catches revoked-grant-mid-session. - if let Err(status) = require_wopi_perm( - state.app_state.authorization.as_ref(), - &claims.sub, - &file_id, - Permission::Read, - ) - .await - { - return status.into_response(); + // + // The Read gate, the metadata fetch and the Update probe are three + // independent lookups keyed only off (caller, file) — overlapped with + // `tokio::join!` (benches/ROUND12.md §5). Results are evaluated in the + // original precedence: Read gate first, then file existence. + let (caller_uuid, file_uuid) = match parse_wopi_ids(&claims.sub, &file_id) { + Ok(ids) => ids, + Err(status) => return status.into_response(), + }; + let authz = state.app_state.authorization.as_ref(); + let (read_gate, file, can_write_now) = tokio::join!( + authz.require( + Subject::User(caller_uuid), + Permission::Read, + Resource::File(file_uuid) + ), + state + .app_state + .applications + .file_retrieval_service + .get_file(&file_id), + // `user_can_write` = actual current Update permission ∧ token's + // can_write flag. If the caller's Update was revoked since the + // token was minted (e.g. their grant was downgraded from Editor + // to Viewer), the editor sees the file as read-only and won't + // even attempt PutFile. The stricter `require_wopi_perm(Update)` + // in put_file is the actual gate; this field is a UI hint. + async { + if claims.can_write { + authz + .check( + Subject::User(caller_uuid), + Permission::Update, + Resource::File(file_uuid), + ) + .await + .unwrap_or(false) + } else { + false + } + } + ); + if read_gate.is_err() { + return StatusCode::NOT_FOUND.into_response(); } - - // Fetch file metadata - let file = match state - .app_state - .applications - .file_retrieval_service - .get_file(&file_id) - .await - { + let file = match file { Ok(f) => f, Err(_) => return StatusCode::NOT_FOUND.into_response(), }; @@ -146,24 +180,6 @@ async fn check_file_info( .map(|dt| dt.to_rfc3339()) .unwrap_or_default(); - // `user_can_write` = actual current Update permission ∧ token's - // can_write flag. If the caller's Update was revoked since the - // token was minted (e.g. their grant was downgraded from Editor - // to Viewer), the editor sees the file as read-only and won't - // even attempt PutFile. The stricter `require_wopi_perm(Update)` - // in put_file is the actual gate; this field is a UI hint. - let can_write_now = claims.can_write - && state - .app_state - .authorization - .check( - Subject::User(uuid::Uuid::parse_str(&claims.sub).unwrap_or(uuid::Uuid::nil())), - Permission::Update, - Resource::File(uuid::Uuid::parse_str(&file_id).unwrap_or(uuid::Uuid::nil())), - ) - .await - .unwrap_or(false); - let response = CheckFileInfoResponse { base_file_name: file.name.clone(), // WOPI's `OwnerId` field is required. Post-D7 the DTO no @@ -550,34 +566,31 @@ async fn authorize_wopi_access( ) -> Result<(crate::application::dtos::file_dto::FileDto, bool), StatusCode> { let file_uuid = uuid::Uuid::parse_str(file_id).map_err(|_| StatusCode::NOT_FOUND)?; - // Step 1 — Read is required to even open the file. - authz - .require( + // The Read gate (step 1), the metadata fetch and the Update probe + // (step 2) are independent — overlapped with `tokio::join!` + // (benches/ROUND12.md §5); results evaluated in the original order. + // + // Step 2 rationale — can_write reflects real Update, not the client's + // action-string. `check` returns bool without throwing; failure + // just means the caller lacks Update, so we degrade the token to + // read-only. Deliberately no `require` there — a Viewer opening + // the file is legitimate; only the write claim is suppressed. + let (read_gate, file, has_update) = tokio::join!( + authz.require( Subject::User(caller_id), Permission::Read, Resource::File(file_uuid), - ) - .await - .map_err(|_| StatusCode::NOT_FOUND)?; - - let file = file_retrieval - .get_file(file_id) - .await - .map_err(|_| StatusCode::NOT_FOUND)?; - - // Step 2 — can_write reflects real Update, not the client's - // action-string. `check` returns bool without throwing; failure - // just means the caller lacks Update, so we degrade the token to - // read-only. Deliberately no `require` here — a Viewer opening - // the file is legitimate; only the write claim is suppressed. - let has_update = authz - .check( + ), + file_retrieval.get_file(file_id), + authz.check( Subject::User(caller_id), Permission::Update, Resource::File(file_uuid), ) - .await - .unwrap_or(false); + ); + read_gate.map_err(|_| StatusCode::NOT_FOUND)?; + let file = file.map_err(|_| StatusCode::NOT_FOUND)?; + let has_update = has_update.unwrap_or(false); // Step 3 — allow explicit view-mode downgrade for Editors. let can_write = has_update && requested_action != "view"; diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index f9277935..15f07ecc 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -2,6 +2,7 @@ pub mod cookie_auth; pub mod deserializer; pub mod handlers; pub mod routes; +pub mod sized_json; pub use routes::create_api_routes; pub use routes::create_health_routes; diff --git a/src/interfaces/api/sized_json.rs b/src/interfaces/api/sized_json.rs new file mode 100644 index 00000000..93694a66 --- /dev/null +++ b/src/interfaces/api/sized_json.rs @@ -0,0 +1,54 @@ +//! Pre-sized JSON responses for listing endpoints. +//! +//! `axum::Json` serializes into a `BytesMut::with_capacity(128)` — a 500-row +//! listing grows that seed through ~11 doubling reallocations, memcpy-ing +//! ~1.3× the payload on every hot listing response (files, folder +//! resources, photos timeline, search). `sized_json` serializes into one +//! right-sized `Vec` instead: 2 allocations total and no copy chain +//! (benches/ROUND12.md §M1, 1.40x / −11 allocs on a 500-row page). +//! +//! The per-row estimates are calibrated against the serialized DTOs (a +//! realistic `FileDto` row measures ~380 B). Underestimates cost one extra +//! doubling — still far better than the 128-byte seed; overestimates waste +//! transient capacity only (the buffer is freed after the response). + +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use bytes::Bytes; +use serde::Serialize; + +/// Serialized size estimate for one file/folder row (FileDto ≈ 380 B). +pub const EST_ROW_BYTES: usize = 384; + +/// Serialized size estimate for one wrapped resource row (PhotoDto / +/// FolderResourcesDto items carry a FileDto plus wrapper fields). +pub const EST_WRAPPED_ROW_BYTES: usize = 448; + +/// Serialize `value` into a single pre-sized buffer and wrap it as an +/// `application/json` response — drop-in for `Json(value).into_response()` +/// (byte-identical body, gated in `bench_round12_micro` §1), minus the +/// doubling-realloc chain. +pub fn sized_json(estimated_bytes: usize, value: &T) -> Response { + let mut buf = Vec::with_capacity(estimated_bytes.max(128)); + match serde_json::to_writer(&mut buf, value) { + Ok(()) => ( + StatusCode::OK, + [( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + )], + Bytes::from(buf), + ) + .into_response(), + // Mirror axum's Json error arm: 500 + plain-text serializer error. + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + [( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + )], + err.to_string(), + ) + .into_response(), + } +} diff --git a/src/interfaces/nextcloud/ocs_handler.rs b/src/interfaces/nextcloud/ocs_handler.rs index 924de08c..d1326ab4 100644 --- a/src/interfaces/nextcloud/ocs_handler.rs +++ b/src/interfaces/nextcloud/ocs_handler.rs @@ -342,20 +342,21 @@ pub async fn handle_sharees_search( None => return sharees_response(vec![]).into_response(), }; - // SQL-level ILIKE search with limit — avoids loading all users into memory. - let users = auth_service - .search_users(&search, 26) + // SQL-level ILIKE search with limit — avoids loading all users into + // memory. Username-only projection: the wide `search_users` row drags + // the up-to-512 KiB avatar `image` per matched user, per keystroke + // (benches/ROUND12.md §1). NULL-username (email-only signup) rows are + // already filtered by the service, preserving the old post-limit + // filtering semantics. + let usernames = auth_service + .search_sharee_usernames(&search, 26) .await .unwrap_or_default(); - // Skip users with no claimed username — NC sharees autocomplete relies - // on a username being typeable; users still on the email-only signup - // path can't be addressed here. Also skip self (don't suggest sharing - // with yourself). - let matches: Vec = users + // Skip self (don't suggest sharing with yourself). + let matches: Vec = usernames .into_iter() - .filter_map(|u| { - let handle = u.username.clone()?; + .filter_map(|handle| { if handle.as_str() == &*user.username { return None; } diff --git a/src/interfaces/nextcloud/uploads_handler.rs b/src/interfaces/nextcloud/uploads_handler.rs index a1682692..ccd3e66a 100644 --- a/src/interfaces/nextcloud/uploads_handler.rs +++ b/src/interfaces/nextcloud/uploads_handler.rs @@ -7,7 +7,6 @@ use std::sync::Arc; use uuid::Uuid; use crate::application::ports::file_ports::FileUploadUseCase; -use crate::application::ports::storage_ports::StorageUsagePort; use crate::common::di::AppState; use crate::common::mime_detect::filename_from_path; use crate::interfaces::errors::AppError; @@ -52,10 +51,10 @@ async fn refuse_if_over_quota( // remains authoritative. return Ok(()); }; - svc.check_storage_quota(user_id, additional) - .await - .map_err(AppError::from)?; - svc.check_drive_quota(drive_id, additional) + // Fused single round-trip (user envelope + drive cap) — this gate runs + // on EVERY chunk PUT, and the serial pair cost two point reads per + // chunk (benches/ROUND12.md §6). Verdict precedence unchanged. + svc.check_upload_quotas(user_id, drive_id, additional) .await .map_err(AppError::from) } diff --git a/src/main.rs b/src/main.rs index f57fb08a..8e33fc4a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,7 +24,6 @@ use oxicloud::access_log; use oxicloud::interfaces::middleware::trace_span::{ClientIpMakeSpan, UuidRequestId}; use tower_http::limit::RequestBodyLimitLayer; use tower_http::request_id::{PropagateRequestIdLayer, SetRequestIdLayer}; -use tower_http::set_header::SetResponseHeaderLayer; use tower_http::trace::TraceLayer; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -918,11 +917,37 @@ async fn run() -> Result<(), Box> { // • form-action 'https:': the WOPI office editor is launched by POSTing a // token form to a cross-origin, admin-configured Collabora/OnlyOffice // host. Mirrors the SPA meta policy in frontend/svelte.config.js. + // The four static security headers ride in the same response pass — + // they used to be four separate `SetResponseHeaderLayer`s stacked on + // top of this middleware (5 tower layers per response). Folding them + // here measured 1.43x per request / −26 allocs with a byte-identical + // header set, including on 304s (benches/ROUND12.md §M3). They are + // inserted BEFORE the 304 early-return below because the standalone + // layers stamped 304s too. async fn content_security_policy( req: axum::extract::Request, next: axum::middleware::Next, ) -> axum::response::Response { let mut res = next.run(req).await; + { + let h = res.headers_mut(); + h.insert( + HeaderName::from_static("x-content-type-options"), + HeaderValue::from_static("nosniff"), + ); + h.insert( + HeaderName::from_static("x-frame-options"), + HeaderValue::from_static("DENY"), + ); + h.insert( + HeaderName::from_static("referrer-policy"), + HeaderValue::from_static("strict-origin-when-cross-origin"), + ); + h.insert( + HeaderName::from_static("permissions-policy"), + HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), + ); + } // A 304 Not Modified carries no entity headers (no Content-Type) since // there's no body — `is_html` would read `None` and misclassify it as // "not html", attaching the strict headerless CSP below. Browsers merge @@ -982,24 +1007,7 @@ async fn run() -> Result<(), Box> { res } - app = app - .layer(axum::middleware::from_fn(content_security_policy)) - .layer(SetResponseHeaderLayer::overriding( - HeaderName::from_static("x-content-type-options"), - HeaderValue::from_static("nosniff"), - )) - .layer(SetResponseHeaderLayer::overriding( - HeaderName::from_static("x-frame-options"), - HeaderValue::from_static("DENY"), - )) - .layer(SetResponseHeaderLayer::overriding( - HeaderName::from_static("referrer-policy"), - HeaderValue::from_static("strict-origin-when-cross-origin"), - )) - .layer(SetResponseHeaderLayer::overriding( - HeaderName::from_static("permissions-policy"), - HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), - )); + app = app.layer(axum::middleware::from_fn(content_security_policy)); // Warn once at startup if auth cookies are not Secure. // HttpOnly + SameSite protection is nullified over plain HTTP because tokens